Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
/**
* Running moments of "which lines were on the page" against "how much the
* person went on to use the product", and the ridge solve that turns them into
* a weight per line.
*
* Welford rather than sums of squares, and exponentially weighted rather than
* flat. The bleed is not a learning rate — with a handful of signups a month a
* learning rate would just be a way of throwing away most of the evidence. It
* is insurance: the audience and the market drift, and an observation from two
* years ago should eventually stop outvoting one from last week. Default it
* slow, and read `bleed` as "how many observations ago do I stop caring", since
* the effective sample size this converges to is exactly 1 / (1 - bleed).
*
* Everything here is pure and dimension-agnostic. The pool it happens to be
* measuring lives in `@wellformed/shared`, and the fit that feeds it rows lives
* next door in `fit.ts`.
*/
/** 1 / (1 - 0.998) ≈ 500 observations of effective memory. */
export const DEFAULT_BLEED = 0.998;
export class Moments {
/** Effective sample size after bleeding. Not a count — it converges. */
weight = 0;
meanR = 0;
private m2R = 0;
private readonly meanX: Float64Array;
private readonly m2XX: Float64Array;
private readonly m2XR: Float64Array;
constructor(
readonly dim: number,
private readonly bleed: number = DEFAULT_BLEED,
) {
this.meanX = new Float64Array(dim);
this.m2XX = new Float64Array(dim * dim);
this.m2XR = new Float64Array(dim);
}
/**
* One page, and what came of it.
*
* `x` is the indicator vector over the whole pool — 1 for each line that was
* on the page, 0 for the rest — and `r` is that visitor's durable use.
*
* The co-moment update uses the pre-update deviation on both sides scaled by
* (W-1)/W, which is West's weighted form written so that it is symmetric by
* construction rather than only in exact arithmetic. That matters: the solve
* below assumes a symmetric matrix, and a covariance that drifts a few ulps
* out of symmetry over ten thousand updates is a bug that only shows up as
* slightly wrong weights.
*/
push(x: ArrayLike<number>, r: number): void {
const d = this.dim;
if (x.length !== d) throw new Error(`Moments: expected ${d} indicators, got ${x.length}.`);
// Bleed first, so a fresh observation always arrives at full weight 1.
const decay = this.bleed;
if (this.weight > 0) {
this.m2R *= decay;
for (let i = 0; i < d; i++) this.m2XR[i] = this.m2XR[i]! * decay;
for (let i = 0; i < d * d; i++) this.m2XX[i] = this.m2XX[i]! * decay;
}
this.weight = decay * this.weight + 1;
const step = 1 / this.weight;
const scale = 1 - step;
const dx = new Float64Array(d);
for (let i = 0; i < d; i++) dx[i] = x[i]! - this.meanX[i]!;
const dr = r - this.meanR;
for (let i = 0; i < d; i++) {
const di = dx[i]!;
if (di !== 0) {
for (let j = i; j < d; j++) {
const add = scale * di * dx[j]!;
this.m2XX[i * d + j] = this.m2XX[i * d + j]! + add;
if (j !== i) this.m2XX[j * d + i] = this.m2XX[i * d + j]!;
}
}
this.m2XR[i] = this.m2XR[i]! + scale * di * dr;
}
this.m2R += scale * dr * dr;
for (let i = 0; i < d; i++) this.meanX[i] = this.meanX[i]! + step * dx[i]!;
this.meanR += step * dr;
}
/** Population covariance of the indicators, row-major `dim × dim`. */
covXX(): Float64Array {
return this.scaled(this.m2XX);
}
/** Covariance of each indicator with the reward. */
covXR(): Float64Array {
return this.scaled(this.m2XR);
}
/** Share of pages each item appeared on, under the same weighting. */
rate(): Float64Array {
return Float64Array.from(this.meanX);
}
varR(): number {
return this.weight > 0 ? this.m2R / this.weight : 0;
}
sdR(): number {
return Math.sqrt(Math.max(0, this.varR()));
}
private scaled(source: Float64Array): Float64Array {
const out = new Float64Array(source.length);
if (this.weight <= 0) return out;
const inv = 1 / this.weight;
for (let i = 0; i < source.length; i++) out[i] = source[i]! * inv;
return out;
}
}
/** A whisper on the diagonal, for conditioning only. Not a shrinkage policy. */
const CONDITIONING = 1e-6;
/**
* Observations before the latent space is allowed one more dimension.
*
* The rank is the model's capacity, so it is the thing that has to be rationed
* when data is scarce — not the size of the pool, which is fixed by how many
* lines somebody felt like writing. Twenty observations per direction is
* deliberately cautious.
*/
const OBSERVATIONS_PER_COMPONENT = 20;
export function rankFor(weight: number, dim: number): number {
return Math.max(0, Math.min(dim, Math.floor(weight / OBSERVATIONS_PER_COMPONENT)));
}
/**
* A weight per line, solved through a latent space the data chose.
*
* Thirty lines is thirty parameters, and against the signups a year like this
* produces that is hopeless if they are thirty islands — which is what a
* per-line average makes them, since it can only ever learn about a line from
* the visits that line was on. What makes them not islands is the covariance
* matrix: lines that keep appearing together, and whose pages keep doing
* similarly well, are related *by the record*, and evidence about one is
* evidence about the other. That relatedness is discovered here rather than
* asserted anywhere.
*
* The mechanism is `k` steps of conjugate gradient on `covXX · w = covXR`,
* stopped early. Stopping early is not an approximation of the full solve, it
* is the model: k steps confine the answer to the Krylov space spanned by
* {s, Ms, M²s, …}, which is exactly partial least squares with k components —
* a rank-k latent basis built from the covariance matrix and the reward
* together, oriented at what actually predicts durable use. Directions the data
* has not spoken to are not estimated badly, they are not estimated at all.
*
* The rank grows with the evidence, so the latent space anneals along with the
* sampler's confidence. At zero observations k is zero and every weight is
* zero — neutral, which is where the whole thing is asked to start.
*/
export function latentWeights(
covXX: Float64Array,
covXR: Float64Array,
dim: number,
components: number,
): Float64Array {
const w = new Float64Array(dim);
if (components <= 0 || dim === 0) return w;
let trace = 0;
for (let i = 0; i < dim; i++) trace += covXX[i * dim + i]!;
const scale = trace / dim;
// Nothing has varied. Every line is equally unproven, and saying so with a
// vector of zeros is what makes the sampler uniform.
if (!(scale > 0)) return w;
const lambda = CONDITIONING * scale;
const residual = Float64Array.from(covXR);
const direction = Float64Array.from(covXR);
let residualNorm = dot(residual, residual, dim);
if (!(residualNorm > 0)) return w;
const steps = Math.min(components, dim);
for (let step = 0; step < steps; step++) {
const applied = new Float64Array(dim);
for (let i = 0; i < dim; i++) {
let sum = lambda * direction[i]!;
const row = i * dim;
for (let j = 0; j < dim; j++) sum += covXX[row + j]! * direction[j]!;
applied[i] = sum;
}
const curvature = dot(direction, applied, dim);
// A direction the design never explored carries no information about the
// reward. Continuing along it would divide by nothing.
if (!(curvature > 1e-15)) break;
const stride = residualNorm / curvature;
for (let i = 0; i < dim; i++) {
w[i] = w[i]! + stride * direction[i]!;
residual[i] = residual[i]! - stride * applied[i]!;
}
const nextNorm = dot(residual, residual, dim);
if (!(nextNorm > 1e-18)) break;
const momentum = nextNorm / residualNorm;
for (let i = 0; i < dim; i++) direction[i] = residual[i]! + momentum * direction[i]!;
residualNorm = nextNorm;
}
return w;
}
function dot(a: Float64Array, b: Float64Array, dim: number): number {
let sum = 0;
for (let i = 0; i < dim; i++) sum += a[i]! * b[i]!;
return sum;
}