skaters 0.11.0 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ tracks the Python [`skaters`](https://pypi.org/project/skaters/) package and is
5
5
  kept numerically identical to it within `1e-6`, enforced by the parity checker on
6
6
  every release.
7
7
 
8
+ ## 0.11.4
9
+
10
+ Docs: lead the README with the live in-browser race demo
11
+ (<https://skaters.microprediction.org/demos/race.html>) — the JS port racing
12
+ `arima`, `@bsull/augurs` (ETS and MSTL), and Prophet (Stan compiled to WASM) on
13
+ real FRED series, scored on held-out log-likelihood. No code changes.
14
+
15
+ ## 0.11.3
16
+
17
+ Fix: `Dist.logpdf` returned `-Infinity` for finite inputs when the scale had
18
+ collapsed near a Dirac (e.g. on a near-constant stream), because `log(sum(w *
19
+ pdf))` underflowed each `exp(-z**2/2)` to `0`. A Gaussian mixture is strictly
20
+ positive everywhere, so this is now computed as a log-sum-exp over the
21
+ per-component log densities and is always finite for finite `x`. Numerically
22
+ identical to the old path wherever it did not underflow (parity vectors
23
+ unchanged); only the former `-Infinity` cases now return the correct large
24
+ finite value.
25
+
26
+ ## 0.11.2
27
+
28
+ Fix: RLS covariance windup in the `ar` and `grouped_ar` transforms. Under
29
+ low-excitation input the RLS `P` matrix inflated by `1/lam` each step and
30
+ eventually overflowed to `Inf` (~74k steps), giving `NaN` coefficients and a
31
+ non-finite forecast. `P` is now reset if it grows implausibly large or turns
32
+ non-finite (keeping the coefficients); it never triggers on well-excited data, so
33
+ results and Python↔JS parity are unchanged.
34
+
35
+ ## 0.11.1
36
+
37
+ Fix: the CRPS leaf's exponentiated-gradient weight update could overflow `exp()`
38
+ to `Inf` on some streams, normalizing to `NaN` weights and throwing in the `Dist`
39
+ constructor. The step is now stabilized by subtracting the max exponent
40
+ (mathematically invariant, so results and Python↔JS parity are unchanged), with a
41
+ guard that keeps the current weights on any degenerate update.
42
+
8
43
  ## 0.11.0
9
44
 
10
45
  Initial npm release of the JavaScript port. Zero-dependency ES modules for Node
package/README.md CHANGED
@@ -4,6 +4,15 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/skaters)](https://www.npmjs.com/package/skaters)
5
5
  [![license: MIT](https://img.shields.io/npm/l/skaters)](https://github.com/microprediction/skaters/blob/main/LICENSE)
6
6
 
7
+ ### ▶ [Watch it race live in your browser](https://skaters.microprediction.org/demos/race.html)
8
+
9
+ The actual JavaScript port racing `arima`, `@bsull/augurs` (ETS and MSTL), and
10
+ Prophet (Stan compiled to WASM) on 150 real FRED series — every method scored on
11
+ held-out log-likelihood, rolling one-step-ahead, no server. `laplace` keeps up
12
+ with a moving average while the refit baselines grind. **[Start here →](https://skaters.microprediction.org/demos/race.html)**
13
+
14
+ ---
15
+
7
16
  Fast univariate **online distributional** time-series forecasting. Zero
8
17
  dependencies, runs in Node or the browser. This is the JavaScript port of the
9
18
  Python [`skaters`](https://pypi.org/project/skaters/) package, numerically
package/dist.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  const SQRT2 = Math.SQRT2;
10
10
  const SQRT2PI = Math.sqrt(2.0 * Math.PI);
11
+ const LOG_SQRT2PI = 0.5 * Math.log(2.0 * Math.PI);
11
12
  const LGAMMA_HALF = 0.5723649429247001; // ln(Gamma(1/2)) = ln(sqrt(pi))
12
13
 
13
14
  // --- erf via the regularized lower incomplete gamma P(1/2, x^2) ---
@@ -134,8 +135,28 @@ export class Dist {
134
135
  }
135
136
 
136
137
  logpdf(x) {
137
- const p = this.pdf(x);
138
- return p > 0 ? Math.log(p) : -Infinity;
138
+ // Log density accumulated in log-space (log-sum-exp). A Gaussian mixture is
139
+ // strictly positive everywhere, so for finite x this is always finite;
140
+ // log(sum(w * pdf)) would underflow to -Infinity once x sits many standard
141
+ // deviations from every component (e.g. after the scale collapses on a
142
+ // near-constant stream), because each exp(-z*z/2) rounds to 0.
143
+ let best = -Infinity;
144
+ const terms = [];
145
+ for (const [w, m, s] of this.components) {
146
+ if (w <= 0.0) continue;
147
+ if (s <= 0.0) {
148
+ if (x === m) return Infinity;
149
+ continue;
150
+ }
151
+ const z = (x - m) / s;
152
+ const t = Math.log(w) - 0.5 * z * z - Math.log(s) - LOG_SQRT2PI;
153
+ terms.push(t);
154
+ if (t > best) best = t;
155
+ }
156
+ if (best === -Infinity) return -Infinity;
157
+ let acc = 0.0;
158
+ for (const t of terms) acc += Math.exp(t - best);
159
+ return best + Math.log(acc);
139
160
  }
140
161
 
141
162
  cdf(x) {
package/leaf.mjs CHANGED
@@ -114,10 +114,21 @@ export function crpsLeaf(k = 1, eta = 1.0, scaleAlpha = 0.01, scales = FINE) {
114
114
  g[c] = absNormal(-z, C[c]) - dot;
115
115
  }
116
116
  const gm = fsum(g) / K;
117
+ // Exponentiated-gradient step, stabilized by subtracting the max exponent so
118
+ // exp() cannot overflow to Inf (which would normalize to NaN weights and
119
+ // crash Dist). Subtracting a constant from every exponent leaves the
120
+ // normalized weights unchanged.
121
+ const e = new Array(K);
122
+ for (let c = 0; c < K; c++) e[c] = -eta * (g[c] - gm);
123
+ let emax = -Infinity;
124
+ for (let c = 0; c < K; c++) if (e[c] > emax) emax = e[c];
117
125
  const nw = new Array(K);
118
- let Z = 0.0;
119
- for (let c = 0; c < K; c++) nw[c] = w[c] * Math.exp(-eta * (g[c] - gm));
120
- Z = fsum(nw);
126
+ for (let c = 0; c < K; c++) nw[c] = w[c] * Math.exp(e[c] - emax);
127
+ let Z = fsum(nw);
128
+ if (!(Z > 0.0) || !Number.isFinite(Z)) {
129
+ for (let c = 0; c < K; c++) nw[c] = w[c]; // degenerate update: keep weights
130
+ Z = fsum(nw);
131
+ }
121
132
  state.w = nw.map((x) => x / Z);
122
133
  const comps = C.map((c, i) => [state.w[i], 0.0, c * sig]);
123
134
  return [new Array(k).fill(new Dist(comps)), state];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skaters",
3
- "version": "0.11.0",
3
+ "version": "0.11.4",
4
4
  "description": "Fast univariate online distributional time-series forecasting: a zero-dependency JavaScript port of the Python skaters package, numerically identical to 1e-6.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/transform.mjs CHANGED
@@ -461,6 +461,18 @@ export function ar(order = 2, lam = 0.99, ridge = 1.0, decay = 0.0) {
461
461
  P[i * p + j] = (P[i * p + j] - K[i] * Px[j]) / lam;
462
462
  }
463
463
  }
464
+ // Guard against RLS covariance windup (see the Python note): under
465
+ // low-excitation input P inflates by 1/lam each step and overflows to
466
+ // Inf, giving nan coefficients. Reset the covariance (keeping phi) if it
467
+ // grows implausibly large or non-finite. No effect on well-excited data.
468
+ let bad = false, pmax = 0.0;
469
+ for (let m = 0; m < P.length; m++) {
470
+ const v = P[m];
471
+ if (!Number.isFinite(v)) { bad = true; break; }
472
+ const av = Math.abs(v);
473
+ if (av > pmax) pmax = av;
474
+ }
475
+ if (bad || pmax > 1e10) { const np = initP(); for (let m = 0; m < P.length; m++) P[m] = np[m]; }
464
476
  }
465
477
  } else {
466
478
  residual = y;
@@ -555,6 +567,16 @@ export function groupedAr(maxLag = 16, lam = 0.99, ridge = 1.0) {
555
567
  P[i * nGroups + j] = (P[i * nGroups + j] - K[i] * Px[j]) / lam;
556
568
  }
557
569
  }
570
+ // RLS covariance windup guard (see ar()): reset P if it inflates
571
+ // implausibly or turns non-finite; keeps theta. No effect on well-excited data.
572
+ let bad = false, pmax = 0.0;
573
+ for (let m = 0; m < P.length; m++) {
574
+ const v = P[m];
575
+ if (!Number.isFinite(v)) { bad = true; break; }
576
+ const av = Math.abs(v);
577
+ if (av > pmax) pmax = av;
578
+ }
579
+ if (bad || pmax > 1e10) { const np = eye(nGroups, ridge); for (let m = 0; m < P.length; m++) P[m] = np[m]; }
558
580
  }
559
581
  } else {
560
582
  residual = y;