Add better smoothing with ema rounding of the ends of the loss graph for first and last instance do not dominate the smoothing factors.

This commit is contained in:
Jaret Burkett
2026-06-08 10:43:46 -06:00
parent d7f8887bbf
commit 687def6f7a

View File

@@ -42,20 +42,58 @@ function computeCanvasSize(host: HTMLElement): { width: number; height: number }
return { width, height: Math.max(MIN_CANVAS_HEIGHT, height - legendH) }; return { width, height: Math.max(MIN_CANVAS_HEIGHT, height - legendH) };
} }
// EMA over a (number|null)[] series. Nulls are preserved as gaps and do not // One-directional bias-corrected EMA. The accumulator starts at 0 and each
// advance the running average. // output is divided by w = 1-(1-alpha)^n (n = valid points seen so far) so early
function emaWithNulls(ys: (number | null)[], alpha: number): (number | null)[] { // outputs reflect the running mean rather than the raw accumulator. `w` doubles
const out: (number | null)[] = new Array(ys.length); // as a confidence weight (→0 with one point seen, →1 once warmed up) used to
let prev: number | null = null; // combine the two passes below. Nulls are preserved as gaps and do not advance
for (let i = 0; i < ys.length; i++) { // the average. `reverse` walks the series back-to-front (the backward pass).
function emaPass(
ys: (number | null)[],
alpha: number,
reverse: boolean,
): { vals: (number | null)[]; weights: number[] } {
const vals: (number | null)[] = new Array(ys.length).fill(null);
const weights: number[] = new Array(ys.length).fill(0);
let s = 0; // raw EMA accumulator
let n = 0; // valid points incorporated so far
const start = reverse ? ys.length - 1 : 0;
const step = reverse ? -1 : 1;
for (let i = start; i >= 0 && i < ys.length; i += step) {
const v = ys[i]; const v = ys[i];
if (v === null || !Number.isFinite(v)) { if (v === null || !Number.isFinite(v)) continue;
s = alpha * (v as number) + (1 - alpha) * s;
n += 1;
const w = 1 - Math.pow(1 - alpha, n);
vals[i] = s / w;
weights[i] = w;
}
return { vals, weights };
}
// Zero-phase (forward-backward) EMA, combined by each pass's confidence weight.
// A one-sided EMA pins the first point to its raw value and the last point is a
// pure causal (lagging) estimate. Running a forward and backward pass and
// blending them by how much data each has seen at that index gives the best of
// both: at the start the forward pass has ~1 point (distrusted) so the
// backward, future-informed pass dominates; at the latest points the backward
// pass has ~1 point so the forward (causal) estimate dominates; the middle is
// ~50/50, which also cancels EMA's lag. Nulls stay null (both passes align).
function emaWithNulls(ys: (number | null)[], alpha: number): (number | null)[] {
const fwd = emaPass(ys, alpha, false);
const bwd = emaPass(ys, alpha, true);
const out: (number | null)[] = new Array(ys.length);
for (let i = 0; i < ys.length; i++) {
const f = fwd.vals[i];
const b = bwd.vals[i];
if (f === null || b === null) {
out[i] = null; out[i] = null;
continue; continue;
} }
if (prev === null) prev = v as number; const wf = fwd.weights[i];
else prev = alpha * (v as number) + (1 - alpha) * prev; const wb = bwd.weights[i];
out[i] = prev; const wsum = wf + wb;
out[i] = wsum > 0 ? (wf * (f as number) + wb * (b as number)) / wsum : ((f as number) + (b as number)) / 2;
} }
return out; return out;
} }