skaters 0.11.0
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 +15 -0
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/api.mjs +168 -0
- package/bayesian.mjs +84 -0
- package/conjugate.mjs +23 -0
- package/cov.mjs +95 -0
- package/dist.mjs +276 -0
- package/ema.mjs +12 -0
- package/ensemble.mjs +56 -0
- package/index.mjs +31 -0
- package/leaf.mjs +211 -0
- package/multiscale.mjs +70 -0
- package/package.json +51 -0
- package/periodicity.mjs +58 -0
- package/runstats.mjs +33 -0
- package/search.mjs +236 -0
- package/spec.mjs +98 -0
- package/sticky.mjs +64 -0
- package/terminal.mjs +82 -0
- package/transform.mjs +598 -0
package/dist.mjs
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Gaussian mixture distribution — JS port of skaters/dist.py.
|
|
2
|
+
//
|
|
3
|
+
// A Dist is a weighted mixture of Gaussians: components = [[w, m, s], ...].
|
|
4
|
+
// Weights are positive and normalized to sum to 1; std is always > 0.
|
|
5
|
+
// Pure JS, no dependencies. JS lacks Math.erf, so we provide a
|
|
6
|
+
// high-accuracy erf (regularized incomplete gamma) to match Python's
|
|
7
|
+
// math.erf closely enough for CDF/quantile parity.
|
|
8
|
+
|
|
9
|
+
const SQRT2 = Math.SQRT2;
|
|
10
|
+
const SQRT2PI = Math.sqrt(2.0 * Math.PI);
|
|
11
|
+
const LGAMMA_HALF = 0.5723649429247001; // ln(Gamma(1/2)) = ln(sqrt(pi))
|
|
12
|
+
|
|
13
|
+
// --- erf via the regularized lower incomplete gamma P(1/2, x^2) ---
|
|
14
|
+
|
|
15
|
+
function _gser(a, x) {
|
|
16
|
+
// Series expansion for P(a, x), valid for x < a + 1.
|
|
17
|
+
let ap = a;
|
|
18
|
+
let sum = 1.0 / a;
|
|
19
|
+
let del = sum;
|
|
20
|
+
for (let i = 0; i < 300; i++) {
|
|
21
|
+
ap += 1.0;
|
|
22
|
+
del *= x / ap;
|
|
23
|
+
sum += del;
|
|
24
|
+
if (Math.abs(del) < Math.abs(sum) * 1e-17) break;
|
|
25
|
+
}
|
|
26
|
+
return sum * Math.exp(-x + a * Math.log(x) - LGAMMA_HALF);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function _gcf(a, x) {
|
|
30
|
+
// Continued fraction for Q(a, x) via the modified Lentz algorithm.
|
|
31
|
+
const TINY = 1e-300;
|
|
32
|
+
let b = x + 1.0 - a;
|
|
33
|
+
let c = 1.0 / TINY;
|
|
34
|
+
let d = 1.0 / b;
|
|
35
|
+
let h = d;
|
|
36
|
+
for (let i = 1; i <= 300; i++) {
|
|
37
|
+
const an = -i * (i - a);
|
|
38
|
+
b += 2.0;
|
|
39
|
+
d = an * d + b;
|
|
40
|
+
if (Math.abs(d) < TINY) d = TINY;
|
|
41
|
+
c = b + an / c;
|
|
42
|
+
if (Math.abs(c) < TINY) c = TINY;
|
|
43
|
+
d = 1.0 / d;
|
|
44
|
+
const del = d * c;
|
|
45
|
+
h *= del;
|
|
46
|
+
if (Math.abs(del - 1.0) < 1e-17) break;
|
|
47
|
+
}
|
|
48
|
+
return Math.exp(-x + a * Math.log(x) - LGAMMA_HALF) * h;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _gammp(a, x) {
|
|
52
|
+
if (x <= 0.0) return 0.0;
|
|
53
|
+
if (x < a + 1.0) return _gser(a, x);
|
|
54
|
+
return 1.0 - _gcf(a, x);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function erf(x) {
|
|
58
|
+
if (x === 0.0) return 0.0;
|
|
59
|
+
const r = _gammp(0.5, x * x);
|
|
60
|
+
return x < 0.0 ? -r : r;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- pure-JS Gaussian math (mirrors dist.py) ---
|
|
64
|
+
|
|
65
|
+
function gaussianPdf(x, mean, std) {
|
|
66
|
+
if (std <= 0) return x === mean ? Infinity : 0.0;
|
|
67
|
+
const z = (x - mean) / std;
|
|
68
|
+
return Math.exp(-0.5 * z * z) / (std * SQRT2PI);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function gaussianCdf(x, mean, std) {
|
|
72
|
+
if (std <= 0) return x >= mean ? 1.0 : 0.0;
|
|
73
|
+
return 0.5 * (1.0 + erf((x - mean) / (std * SQRT2)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function absExpectation(m, s) {
|
|
77
|
+
// E|N(m, s^2)| = m(2Φ(m/s) - 1) + 2s·φ(m/s).
|
|
78
|
+
if (s <= 0) return Math.abs(m);
|
|
79
|
+
const z = m / s;
|
|
80
|
+
return m * (2.0 * gaussianCdf(z, 0.0, 1.0) - 1.0) + 2.0 * s * gaussianPdf(z, 0.0, 1.0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Neumaier-compensated sum, matching CPython 3.12+'s built-in sum() on floats
|
|
84
|
+
// (the Python reference uses sum() for Dist normalisation and moments; the port
|
|
85
|
+
// must reproduce it bit-for-bit or pruning tie-breaks diverge).
|
|
86
|
+
export function fsum(values) {
|
|
87
|
+
let s = 0.0, c = 0.0;
|
|
88
|
+
for (const x of values) {
|
|
89
|
+
const t = s + x;
|
|
90
|
+
if (Math.abs(s) >= Math.abs(x)) c += (s - t) + x;
|
|
91
|
+
else c += (x - t) + s;
|
|
92
|
+
s = t;
|
|
93
|
+
}
|
|
94
|
+
return s + c;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class Dist {
|
|
98
|
+
// components: array of [weight, mean, std]
|
|
99
|
+
constructor(components) {
|
|
100
|
+
if (!components || components.length === 0) {
|
|
101
|
+
throw new Error("Dist requires at least one component");
|
|
102
|
+
}
|
|
103
|
+
const wTotal = fsum(components.map((c) => c[0]));
|
|
104
|
+
if (!(wTotal > 0)) throw new Error("Dist weights must sum to > 0");
|
|
105
|
+
this.components = components.map(([w, m, s]) => [w / wTotal, m, s]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- constructors ---
|
|
109
|
+
|
|
110
|
+
static gaussian(mean = 0.0, std = 1.0) {
|
|
111
|
+
return new Dist([[1.0, mean, std]]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
static combine(dists, weights = null) {
|
|
115
|
+
const n = dists.length;
|
|
116
|
+
if (weights === null) weights = new Array(n).fill(1.0 / n);
|
|
117
|
+
const wTotal = fsum(weights);
|
|
118
|
+
const components = [];
|
|
119
|
+
for (let i = 0; i < n; i++) {
|
|
120
|
+
const wOuter = weights[i];
|
|
121
|
+
for (const [wInner, m, s] of dists[i].components) {
|
|
122
|
+
components.push([(wOuter / wTotal) * wInner, m, s]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return new Dist(components);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- queries ---
|
|
129
|
+
|
|
130
|
+
pdf(x) {
|
|
131
|
+
let total = 0.0;
|
|
132
|
+
for (const [w, m, s] of this.components) total += w * gaussianPdf(x, m, s);
|
|
133
|
+
return total;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
logpdf(x) {
|
|
137
|
+
const p = this.pdf(x);
|
|
138
|
+
return p > 0 ? Math.log(p) : -Infinity;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
cdf(x) {
|
|
142
|
+
let total = 0.0;
|
|
143
|
+
for (const [w, m, s] of this.components) total += w * gaussianCdf(x, m, s);
|
|
144
|
+
return total;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
crps(x) {
|
|
148
|
+
// Closed-form CRPS for a Gaussian mixture (Grimit et al. 2006).
|
|
149
|
+
const comps = this.components;
|
|
150
|
+
let t1 = 0.0;
|
|
151
|
+
for (const [w, m, s] of comps) t1 += w * absExpectation(m - x, s);
|
|
152
|
+
let t2 = 0.0;
|
|
153
|
+
for (const [wi, mi, si] of comps) {
|
|
154
|
+
for (const [wj, mj, sj] of comps) {
|
|
155
|
+
t2 += wi * wj * absExpectation(mi - mj, Math.sqrt(si * si + sj * sj));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return t1 - 0.5 * t2;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
quantile(p, tol = 1e-9, maxIter = 100) {
|
|
162
|
+
if (!(p > 0 && p < 1)) throw new Error("quantile p must be in (0, 1)");
|
|
163
|
+
const mu = this.mean;
|
|
164
|
+
const sigma = Math.sqrt(this.var);
|
|
165
|
+
let lo = mu - 8 * sigma;
|
|
166
|
+
let hi = mu + 8 * sigma;
|
|
167
|
+
for (let i = 0; i < maxIter; i++) {
|
|
168
|
+
const mid = 0.5 * (lo + hi);
|
|
169
|
+
if (this.cdf(mid) < p) lo = mid;
|
|
170
|
+
else hi = mid;
|
|
171
|
+
if (hi - lo < tol) break;
|
|
172
|
+
}
|
|
173
|
+
return 0.5 * (lo + hi);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
get mean() {
|
|
177
|
+
return fsum(this.components.map(([w, m]) => w * m));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
get var() {
|
|
181
|
+
// centered form: avoids catastrophic cancellation when means are large
|
|
182
|
+
// relative to spreads (which would otherwise yield var=0, std=0).
|
|
183
|
+
const mu = this.mean;
|
|
184
|
+
return fsum(this.components.map(([w, m, s]) => w * (s * s + (m - mu) * (m - mu))));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
get std() {
|
|
188
|
+
const v = this.var;
|
|
189
|
+
return v > 0 ? Math.sqrt(v) : 0.0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- transform support ---
|
|
193
|
+
|
|
194
|
+
shift(delta) {
|
|
195
|
+
return new Dist(this.components.map(([w, m, s]) => [w, m + delta, s]));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
scale(factor) {
|
|
199
|
+
if (factor === 0) throw new Error("scale factor must be nonzero");
|
|
200
|
+
const f = Math.abs(factor);
|
|
201
|
+
return new Dist(this.components.map(([w, m, s]) => [w, m * factor, s * f]));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
affine(a, b) {
|
|
205
|
+
if (a === 0) throw new Error("affine a must be nonzero");
|
|
206
|
+
return new Dist(this.components.map(([w, m, s]) => [w, a * m + b, Math.abs(a) * s]));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Number of mixture components (mirrors Python's __len__).
|
|
210
|
+
get length() {
|
|
211
|
+
return this.components.length;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- pruning ---
|
|
215
|
+
|
|
216
|
+
prune(maxComponents = 20) {
|
|
217
|
+
maxComponents = Math.max(1, maxComponents); // a Dist needs >=1 component
|
|
218
|
+
if (this.components.length <= maxComponents) return this;
|
|
219
|
+
// Sort first so the merge path (and hence the result) is independent of
|
|
220
|
+
// component order — mixtures are order-free but the closest-pair scan
|
|
221
|
+
// is not, and ties at equal means are common (lattice atoms).
|
|
222
|
+
let comps = this.components.map((c) => c.slice())
|
|
223
|
+
.sort((a, b) => (a[1] - b[1]) || (a[2] - b[2]) || (a[0] - b[0]));
|
|
224
|
+
// Pair selection tolerates last-ulp noise in the means: pick the FIRST
|
|
225
|
+
// pair within a hair of the true minimum distance, so platforms that
|
|
226
|
+
// disagree at the ulp level (e.g. libm erf vs a polynomial) still merge
|
|
227
|
+
// the same pairs in the same order. Exact argmin would amplify ulp
|
|
228
|
+
// noise into macroscopically different mixtures.
|
|
229
|
+
const scale = Math.abs(comps[0][1]) + Math.abs(comps[comps.length - 1][1]) + 1e-12;
|
|
230
|
+
while (comps.length > maxComponents) {
|
|
231
|
+
let bestDist = Infinity;
|
|
232
|
+
for (let i = 0; i < comps.length; i++)
|
|
233
|
+
for (let j = i + 1; j < comps.length; j++) {
|
|
234
|
+
const d = Math.abs(comps[i][1] - comps[j][1]);
|
|
235
|
+
if (d < bestDist) bestDist = d;
|
|
236
|
+
}
|
|
237
|
+
const thresh = bestDist + 1e-9 * scale;
|
|
238
|
+
let bestI = null, bestJ = null;
|
|
239
|
+
outer:
|
|
240
|
+
for (let i = 0; i < comps.length; i++)
|
|
241
|
+
for (let j = i + 1; j < comps.length; j++)
|
|
242
|
+
if (Math.abs(comps[i][1] - comps[j][1]) <= thresh) { bestI = i; bestJ = j; break outer; }
|
|
243
|
+
const [wi, mi, si] = comps[bestI];
|
|
244
|
+
const [wj, mj, sj] = comps[bestJ];
|
|
245
|
+
const wNew = wi + wj;
|
|
246
|
+
let mNew, sNew;
|
|
247
|
+
if (wNew < 1e-300) {
|
|
248
|
+
mNew = 0.5 * (mi + mj);
|
|
249
|
+
sNew = Math.max(si, sj, 1e-12);
|
|
250
|
+
} else {
|
|
251
|
+
mNew = (wi * mi + wj * mj) / wNew;
|
|
252
|
+
const vNew =
|
|
253
|
+
(wi * (si * si + (mi - mNew) * (mi - mNew))
|
|
254
|
+
+ wj * (sj * sj + (mj - mNew) * (mj - mNew))) / wNew;
|
|
255
|
+
sNew = Math.sqrt(Math.max(vNew, 0.0));
|
|
256
|
+
}
|
|
257
|
+
comps[bestI] = [wNew, mNew, sNew];
|
|
258
|
+
comps.splice(bestJ, 1);
|
|
259
|
+
}
|
|
260
|
+
return new Dist(comps);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- serialization ---
|
|
264
|
+
|
|
265
|
+
toDict() {
|
|
266
|
+
return { components: this.components.map((c) => c.slice()) };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
static fromDict(d) {
|
|
270
|
+
return new Dist(d.components.map((c) => c.slice()));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
get length() {
|
|
274
|
+
return this.components.length;
|
|
275
|
+
}
|
|
276
|
+
}
|
package/ema.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// EMA skater — JS port of skaters/ema.py.
|
|
2
|
+
// ema(alpha, k) = conjugate(leaf(k), emaTransform(alpha)).
|
|
3
|
+
|
|
4
|
+
import { leaf } from "./leaf.mjs";
|
|
5
|
+
import { emaTransform } from "./transform.mjs";
|
|
6
|
+
import { conjugate } from "./conjugate.mjs";
|
|
7
|
+
|
|
8
|
+
export function ema(alpha = 0.05, k = 1) {
|
|
9
|
+
const f = conjugate(leaf(k), emaTransform(alpha), k);
|
|
10
|
+
f.skaterName = `ema(alpha=${alpha}, k=${k})`;
|
|
11
|
+
return f;
|
|
12
|
+
}
|
package/ensemble.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Precision-weighted ensemble — JS port of skaters/ensemble.py.
|
|
2
|
+
|
|
3
|
+
import { Dist } from "./dist.mjs";
|
|
4
|
+
import { runningVarInit, runningVarUpdate, runningMseGet } from "./runstats.mjs";
|
|
5
|
+
|
|
6
|
+
export function precisionWeightedEnsemble(skaters, k = 1, floor = 1e-6) {
|
|
7
|
+
const n = skaters.length;
|
|
8
|
+
if (n <= 0) throw new Error("ensemble needs at least one skater");
|
|
9
|
+
|
|
10
|
+
function _skater(y, state) {
|
|
11
|
+
if (state === null || state === undefined) {
|
|
12
|
+
state = {
|
|
13
|
+
sub: new Array(n).fill(null),
|
|
14
|
+
queues: Array.from({ length: n }, () => Array.from({ length: k }, () => [])),
|
|
15
|
+
stats: Array.from({ length: n }, () =>
|
|
16
|
+
Array.from({ length: k }, () => runningVarInit())
|
|
17
|
+
),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const allDists = [];
|
|
22
|
+
for (let i = 0; i < n; i++) {
|
|
23
|
+
const [distsI, sub] = skaters[i](y, state.sub[i]);
|
|
24
|
+
state.sub[i] = sub;
|
|
25
|
+
allDists.push(distsI);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < n; i++) {
|
|
29
|
+
for (let h = 0; h < k; h++) {
|
|
30
|
+
const q = state.queues[i][h];
|
|
31
|
+
if (q.length) {
|
|
32
|
+
const predMean = q.shift();
|
|
33
|
+
const error = y - predMean;
|
|
34
|
+
state.stats[i][h] = runningVarUpdate(state.stats[i][h], error);
|
|
35
|
+
}
|
|
36
|
+
state.queues[i][h].push(allDists[i][h].mean);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const combined = [];
|
|
41
|
+
for (let h = 0; h < k; h++) {
|
|
42
|
+
const weights = [];
|
|
43
|
+
for (let i = 0; i < n; i++) {
|
|
44
|
+
const mse = runningMseGet(state.stats[i][h]);
|
|
45
|
+
let w = Number.isFinite(mse) && mse > 0 ? 1.0 / mse : floor;
|
|
46
|
+
weights.push(Math.max(w, floor));
|
|
47
|
+
}
|
|
48
|
+
const horizonDists = [];
|
|
49
|
+
for (let i = 0; i < n; i++) horizonDists.push(allDists[i][h]);
|
|
50
|
+
combined.push(Dist.combine(horizonDists, weights));
|
|
51
|
+
}
|
|
52
|
+
return [combined, state];
|
|
53
|
+
}
|
|
54
|
+
_skater.skaterName = `precision_weighted_ensemble(n=${n}, k=${k})`;
|
|
55
|
+
return _skater;
|
|
56
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// skaters — fast univariate online time series models (JS port).
|
|
2
|
+
// Faithful port of the Python package; numeric parity is enforced by
|
|
3
|
+
// parity/check.mjs against the Python reference.
|
|
4
|
+
|
|
5
|
+
export { Dist, erf } from "./dist.mjs";
|
|
6
|
+
export {
|
|
7
|
+
runningVarInit, runningVarUpdate, runningVarGet, runningStdGet, runningMseGet,
|
|
8
|
+
} from "./runstats.mjs";
|
|
9
|
+
export { leaf, scaleMixtureLeaf, crpsLeaf, garchLeaf } from "./leaf.mjs";
|
|
10
|
+
export { ema } from "./ema.mjs";
|
|
11
|
+
export { conjugate } from "./conjugate.mjs";
|
|
12
|
+
export {
|
|
13
|
+
difference, fractionalDifference, standardize, emaTransform, ouTransform, theta, drift,
|
|
14
|
+
holtLinear, garch, seasonalDifference, powerTransform, ar, groupedAr,
|
|
15
|
+
} from "./transform.mjs";
|
|
16
|
+
export { precisionWeightedEnsemble } from "./ensemble.mjs";
|
|
17
|
+
export { bayesianEnsemble } from "./bayesian.mjs";
|
|
18
|
+
export { terminalLeafEnsemble } from "./terminal.mjs";
|
|
19
|
+
export { search, TRANSFORMS } from "./search.mjs";
|
|
20
|
+
export { periodDetector, topPeriods, DEFAULT_LAGS } from "./periodicity.mjs";
|
|
21
|
+
export {
|
|
22
|
+
buildCandidates, laplace,
|
|
23
|
+
} from "./api.mjs";
|
|
24
|
+
export { multiscale } from "./multiscale.mjs";
|
|
25
|
+
export { sticky } from "./sticky.mjs";
|
|
26
|
+
export {
|
|
27
|
+
build, name as specName, toJson, fromJson,
|
|
28
|
+
leafSpec, emaSpec, ensembleSpec, conjugateSpec,
|
|
29
|
+
diffSpec, fracSpec, stdSpec, emaTSpec,
|
|
30
|
+
} from "./spec.mjs";
|
|
31
|
+
export { runningCov, emaCov, ledoitWolfCov } from "./cov.mjs";
|
package/leaf.mjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Residual distribution estimator (the leaf) — JS port of skaters/leaf.py.
|
|
2
|
+
|
|
3
|
+
import { fsum, Dist, erf } from "./dist.mjs";
|
|
4
|
+
import { runningVarInit, runningVarUpdate, runningVarGet } from "./runstats.mjs";
|
|
5
|
+
|
|
6
|
+
// A skater is a function (y, state) -> [dists, state].
|
|
7
|
+
// state is null on the first call.
|
|
8
|
+
|
|
9
|
+
export function leaf(k = 1) {
|
|
10
|
+
function _leaf(y, state) {
|
|
11
|
+
if (state === null || state === undefined) {
|
|
12
|
+
state = { var: runningVarInit() };
|
|
13
|
+
}
|
|
14
|
+
state.var = runningVarUpdate(state.var, y);
|
|
15
|
+
const [, varr] = runningVarGet(state.var);
|
|
16
|
+
|
|
17
|
+
let std;
|
|
18
|
+
if (Number.isFinite(varr) && varr > 0) {
|
|
19
|
+
std = Math.sqrt(varr);
|
|
20
|
+
} else {
|
|
21
|
+
// Bootstrap: use |y| as a rough scale until we have data.
|
|
22
|
+
std = Math.max(Math.abs(y), 1e-8);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const d = Dist.gaussian(0.0, std);
|
|
26
|
+
const dists = new Array(k).fill(d);
|
|
27
|
+
return [dists, state];
|
|
28
|
+
}
|
|
29
|
+
_leaf.skaterName = `leaf(k=${k})`;
|
|
30
|
+
return _leaf;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SCALE_BASIS = [0.7, 1.0, 1.6, 3.0, 6.0];
|
|
34
|
+
|
|
35
|
+
// Fixed Gaussian scale mixture, online-EM weights + EWMA scale — the
|
|
36
|
+
// "discrepancy from N(0,1)" leaf. JS port of scale_mixture_leaf.
|
|
37
|
+
export function scaleMixtureLeaf(k = 1, gamma = 0.02, scaleAlpha = 0.01, scales = SCALE_BASIS) {
|
|
38
|
+
const C = scales.slice();
|
|
39
|
+
const K = C.length;
|
|
40
|
+
let oneIdx = 0;
|
|
41
|
+
let best = Infinity;
|
|
42
|
+
for (let i = 0; i < K; i++) {
|
|
43
|
+
const dd = Math.abs(C[i] - 1.0);
|
|
44
|
+
if (dd < best) { best = dd; oneIdx = i; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _leaf(y, state) {
|
|
48
|
+
if (state === null || state === undefined) {
|
|
49
|
+
const w = new Array(K).fill(1e-6);
|
|
50
|
+
w[oneIdx] = 1.0;
|
|
51
|
+
state = { v: 0.0, w, n: 0 };
|
|
52
|
+
}
|
|
53
|
+
state.n += 1;
|
|
54
|
+
const a = scaleAlpha > 1.0 / state.n ? scaleAlpha : 1.0 / state.n;
|
|
55
|
+
state.v = (1 - a) * state.v + a * y * y;
|
|
56
|
+
const varr = state.v;
|
|
57
|
+
const sigma = Number.isFinite(varr) && varr > 0 ? Math.sqrt(varr) : Math.max(Math.abs(y), 1e-8);
|
|
58
|
+
const z = y / sigma;
|
|
59
|
+
const w = state.w;
|
|
60
|
+
const dens = new Array(K);
|
|
61
|
+
for (let i = 0; i < K; i++) {
|
|
62
|
+
dens[i] = w[i] * Math.exp(-0.5 * z * z / (C[i] * C[i])) / C[i];
|
|
63
|
+
}
|
|
64
|
+
const total = fsum(dens);
|
|
65
|
+
if (total > 0) {
|
|
66
|
+
const g = gamma > 1.0 / state.n ? gamma : 1.0 / state.n;
|
|
67
|
+
state.w = w.map((wi, i) => (1 - g) * wi + g * dens[i] / total);
|
|
68
|
+
}
|
|
69
|
+
const comps = C.map((c, i) => [state.w[i], 0.0, c * sigma]);
|
|
70
|
+
const d = new Dist(comps);
|
|
71
|
+
return [new Array(k).fill(d), state];
|
|
72
|
+
}
|
|
73
|
+
_leaf.skaterName = `scale_mixture_leaf(k=${k})`;
|
|
74
|
+
return _leaf;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// CRPS leaf — JS port of skaters/leaf.py:crps_leaf. Same scale-mixture form,
|
|
78
|
+
// weights fit by online CRPS-gradient (exponentiated gradient on the simplex).
|
|
79
|
+
const S2 = Math.sqrt(2.0);
|
|
80
|
+
const INV = 1.0 / Math.sqrt(2.0 * Math.PI);
|
|
81
|
+
const A0 = 2.0 * INV;
|
|
82
|
+
const FINE = [0.4, 0.512, 0.6554, 0.8389, 1.0737, 1.3744, 1.7592, 2.2518, 2.8823, 3.6893, 4.7224, 6.0446, 7.7371, 9.9035, 12.6765];
|
|
83
|
+
|
|
84
|
+
function phiStd(x) { return Math.exp(-0.5 * x * x) * INV; }
|
|
85
|
+
function PhiStd(x) { return 0.5 * (1.0 + erf(x / S2)); }
|
|
86
|
+
function absNormal(m, s) {
|
|
87
|
+
if (s <= 0) return Math.abs(m);
|
|
88
|
+
const z = m / s;
|
|
89
|
+
return m * (2.0 * PhiStd(z) - 1.0) + 2.0 * s * phiStd(z);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function crpsLeaf(k = 1, eta = 1.0, scaleAlpha = 0.01, scales = FINE) {
|
|
93
|
+
const C = scales.slice();
|
|
94
|
+
const K = C.length;
|
|
95
|
+
const B = C.map((ca) => C.map((cb) => Math.sqrt(ca * ca + cb * cb) * A0));
|
|
96
|
+
let oneIdx = 0, best = Infinity;
|
|
97
|
+
for (let i = 0; i < K; i++) { const d = Math.abs(C[i] - 1.0); if (d < best) { best = d; oneIdx = i; } }
|
|
98
|
+
|
|
99
|
+
function _leaf(y, state) {
|
|
100
|
+
if (state === null || state === undefined) {
|
|
101
|
+
const w = new Array(K).fill(1e-6); w[oneIdx] = 1.0;
|
|
102
|
+
state = { v: 0.0, w, n: 0 };
|
|
103
|
+
}
|
|
104
|
+
state.n += 1;
|
|
105
|
+
const a = scaleAlpha > 1.0 / state.n ? scaleAlpha : 1.0 / state.n;
|
|
106
|
+
state.v = (1 - a) * state.v + a * y * y;
|
|
107
|
+
const sig = Number.isFinite(state.v) && state.v > 0 ? Math.sqrt(state.v) : Math.max(Math.abs(y), 1e-8);
|
|
108
|
+
const z = y / sig;
|
|
109
|
+
const w = state.w;
|
|
110
|
+
const g = new Array(K);
|
|
111
|
+
for (let c = 0; c < K; c++) {
|
|
112
|
+
let dot = 0.0;
|
|
113
|
+
dot = fsum(w.map((wj, j) => wj * B[c][j]));
|
|
114
|
+
g[c] = absNormal(-z, C[c]) - dot;
|
|
115
|
+
}
|
|
116
|
+
const gm = fsum(g) / K;
|
|
117
|
+
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);
|
|
121
|
+
state.w = nw.map((x) => x / Z);
|
|
122
|
+
const comps = C.map((c, i) => [state.w[i], 0.0, c * sig]);
|
|
123
|
+
return [new Array(k).fill(new Dist(comps)), state];
|
|
124
|
+
}
|
|
125
|
+
_leaf.skaterName = `crps_leaf(k=${k}, eta=${eta})`;
|
|
126
|
+
return _leaf;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
// GARCH(1,1)-t leaf — JS port of skaters/leaf.garch_leaf. Genuine GARCH
|
|
131
|
+
// conditional variance (variance-targeted QMLE refit over a fixed grid) + the
|
|
132
|
+
// same Gaussian-scale-mixture (Student-t) tails.
|
|
133
|
+
const GARCH_AB_GRID = [];
|
|
134
|
+
for (const a of [0.02, 0.04, 0.06, 0.09, 0.12, 0.16, 0.20]) {
|
|
135
|
+
for (const b of [0.72, 0.78, 0.84, 0.88, 0.92, 0.95, 0.97]) {
|
|
136
|
+
if (a + b < 0.999) GARCH_AB_GRID.push([a, b]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const GARCH_OMEGA_MULT = [0.5, 0.7, 1.0, 1.4, 2.0];
|
|
140
|
+
|
|
141
|
+
export function garchLeaf(k = 1, gamma = 0.02, refitEvery = 40, minObs = 80,
|
|
142
|
+
window = 400, scales = SCALE_BASIS) {
|
|
143
|
+
const C = scales.slice();
|
|
144
|
+
const K = C.length;
|
|
145
|
+
let oneIdx = 0, best = Infinity;
|
|
146
|
+
for (let i = 0; i < K; i++) { const dd = Math.abs(C[i] - 1.0); if (dd < best) { best = dd; oneIdx = i; } }
|
|
147
|
+
|
|
148
|
+
function _leaf(y, state) {
|
|
149
|
+
if (state === null || state === undefined) {
|
|
150
|
+
const w = new Array(K).fill(1e-6); w[oneIdx] = 1.0;
|
|
151
|
+
state = { h: 0.0, s2: 0.0, n: 0, omega: 0.0, alpha: 0.05, beta: 0.90, buf: [], w, last_r2: 0.0 };
|
|
152
|
+
}
|
|
153
|
+
const s = state;
|
|
154
|
+
s.n += 1;
|
|
155
|
+
const a0 = 0.02 > 1.0 / s.n ? 0.02 : 1.0 / s.n;
|
|
156
|
+
s.s2 = (1 - a0) * s.s2 + a0 * y * y;
|
|
157
|
+
if (s.s2 <= 0) s.s2 = Math.max(y * y, 1e-12);
|
|
158
|
+
|
|
159
|
+
let h;
|
|
160
|
+
if (s.n === 1) h = s.s2;
|
|
161
|
+
else h = s.omega + s.alpha * s.last_r2 + s.beta * s.h;
|
|
162
|
+
if (h <= 1e-300) h = s.s2;
|
|
163
|
+
s.h = h;
|
|
164
|
+
s.last_r2 = y * y;
|
|
165
|
+
s.buf.push(y);
|
|
166
|
+
if (s.buf.length > window) s.buf.shift();
|
|
167
|
+
|
|
168
|
+
if (s.n >= minObs && s.n % refitEvery === 0 && s.buf.length >= minObs) {
|
|
169
|
+
const resid = s.buf;
|
|
170
|
+
const s2 = fsum(resid.map((r) => r * r)) / resid.length;
|
|
171
|
+
if (s2 > 0) {
|
|
172
|
+
// grid over (alpha, beta) AND a free omega multiplier (issue #25)
|
|
173
|
+
let bestV = Infinity, bom = s.omega, bal = s.alpha, bbe = s.beta;
|
|
174
|
+
for (let gi = 0; gi < GARCH_AB_GRID.length; gi++) {
|
|
175
|
+
const al = GARCH_AB_GRID[gi][0], be = GARCH_AB_GRID[gi][1];
|
|
176
|
+
const base = (1.0 - al - be) * s2;
|
|
177
|
+
for (let ci = 0; ci < GARCH_OMEGA_MULT.length; ci++) {
|
|
178
|
+
let om = base * GARCH_OMEGA_MULT[ci];
|
|
179
|
+
if (om <= 1e-12) om = 1e-12;
|
|
180
|
+
let hh = om / (1.0 - al - be); // unconditional-variance init
|
|
181
|
+
let v = 0.0;
|
|
182
|
+
for (let i = 0; i < resid.length; i++) {
|
|
183
|
+
const r = resid[i];
|
|
184
|
+
hh = om + al * (r * r) + be * hh;
|
|
185
|
+
if (hh <= 1e-300) hh = 1e-300;
|
|
186
|
+
v += Math.log(hh) + (r * r) / hh;
|
|
187
|
+
}
|
|
188
|
+
if (v < bestV) { bestV = v; bom = om; bal = al; bbe = be; } // first-wins on tie
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
s.alpha = bal; s.beta = bbe; s.omega = bom;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const sigma = (Number.isFinite(h) && h > 0) ? Math.sqrt(h) : Math.max(Math.abs(y), 1e-8);
|
|
196
|
+
const z = y / sigma;
|
|
197
|
+
const w = s.w;
|
|
198
|
+
const dens = new Array(K);
|
|
199
|
+
let total = 0.0;
|
|
200
|
+
for (let i = 0; i < K; i++) dens[i] = w[i] * Math.exp(-0.5 * z * z / (C[i] * C[i])) / C[i];
|
|
201
|
+
total = fsum(dens);
|
|
202
|
+
if (total > 0) {
|
|
203
|
+
const g = gamma > 1.0 / s.n ? gamma : 1.0 / s.n;
|
|
204
|
+
s.w = w.map((wi, i) => (1 - g) * wi + g * dens[i] / total);
|
|
205
|
+
}
|
|
206
|
+
const comps = C.map((c, i) => [s.w[i], 0.0, c * sigma]);
|
|
207
|
+
return [new Array(k).fill(new Dist(comps)), s];
|
|
208
|
+
}
|
|
209
|
+
_leaf.skaterName = `garch_leaf(k=${k})`;
|
|
210
|
+
return _leaf;
|
|
211
|
+
}
|
package/multiscale.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Multi-scale ensemble — JS port of skaters/multiscale.py.
|
|
2
|
+
//
|
|
3
|
+
// Combine forecasters running on decimated clocks: one base instance per
|
|
4
|
+
// stride s (with s phase-shifted copies so the freshest coarse forecast is
|
|
5
|
+
// always anchored at the current tick; exactly one copy steps per tick), and
|
|
6
|
+
// per fine horizon h mix each eligible scale's (s <= h) predictive Dist under
|
|
7
|
+
// likelihood softmax weights. Granularity is the one candidate axis that
|
|
8
|
+
// cannot live inside the transform pool (decimation is many-ticks-to-one and
|
|
9
|
+
// a coarse model emits no fine one-step predictive to weight), hence a
|
|
10
|
+
// wrapper. With scales == [1] (e.g. k == 1) the base is returned unwrapped.
|
|
11
|
+
|
|
12
|
+
import { Dist } from "./dist.mjs";
|
|
13
|
+
|
|
14
|
+
const LOGPDF_FLOOR = -20.0;
|
|
15
|
+
|
|
16
|
+
export function multiscale(base, k, { scales = null, forget = 0.99, maxComponents = 20 } = {}) {
|
|
17
|
+
if (scales === null) scales = [1, Math.ceil(Math.sqrt(k)), k];
|
|
18
|
+
scales = [...new Set(scales.map((s) => Math.trunc(s)).filter((s) => s >= 1 && s <= k))]
|
|
19
|
+
.sort((a, b) => a - b);
|
|
20
|
+
if (!(scales.length && scales[0] === 1)) throw new Error("scales must include 1");
|
|
21
|
+
if (scales.length === 1) return base(k);
|
|
22
|
+
const subs = {};
|
|
23
|
+
for (const s of scales) subs[s] = base(Math.max(1, Math.ceil(k / s)));
|
|
24
|
+
|
|
25
|
+
return function skater(y, state) {
|
|
26
|
+
if (state === null || state === undefined) {
|
|
27
|
+
state = { t: 0, phase: {}, pending: {}, latest: {}, score: {} };
|
|
28
|
+
for (const s of scales) {
|
|
29
|
+
state.phase[s] = new Array(s).fill(null);
|
|
30
|
+
state.pending[s] = new Array(s).fill(null);
|
|
31
|
+
state.score[s] = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const t = state.t;
|
|
35
|
+
for (const s of scales) {
|
|
36
|
+
const ph = t % s;
|
|
37
|
+
const prev = state.pending[s][ph];
|
|
38
|
+
if (prev !== null) {
|
|
39
|
+
const lp = Math.max(prev.logpdf(y), LOGPDF_FLOOR);
|
|
40
|
+
const m = state.score[s];
|
|
41
|
+
state.score[s] = m === null ? lp : forget * m + (1.0 - forget) * lp;
|
|
42
|
+
}
|
|
43
|
+
const [dists, st] = subs[s](y, state.phase[s][ph]);
|
|
44
|
+
state.phase[s][ph] = st;
|
|
45
|
+
state.pending[s][ph] = dists[0];
|
|
46
|
+
state.latest[s] = dists;
|
|
47
|
+
}
|
|
48
|
+
state.t = t + 1;
|
|
49
|
+
|
|
50
|
+
const ms = scales.map((s) => state.score[s]).filter((m) => m !== null);
|
|
51
|
+
const top = ms.length ? Math.max(...ms) : 0.0;
|
|
52
|
+
const out = [];
|
|
53
|
+
for (let h = 1; h <= k; h++) {
|
|
54
|
+
const fcs = [], wts = [];
|
|
55
|
+
for (const s of scales) {
|
|
56
|
+
if (s > h || !(s in state.latest)) continue;
|
|
57
|
+
const j = Math.max(1, Math.floor(h / s + 0.5)); // half-up, matches Python
|
|
58
|
+
const dists = state.latest[s];
|
|
59
|
+
if (j - 1 >= dists.length) continue;
|
|
60
|
+
fcs.push(dists[j - 1]);
|
|
61
|
+
const m = state.score[s];
|
|
62
|
+
wts.push(Math.exp((m === null ? top : m) - top));
|
|
63
|
+
}
|
|
64
|
+
// One eligible scale (e.g. h < every coarse stride): pass its Dist
|
|
65
|
+
// through untouched — no renormalise, no prune.
|
|
66
|
+
out.push(fcs.length === 1 ? fcs[0] : Dist.combine(fcs, wts).prune(maxComponents));
|
|
67
|
+
}
|
|
68
|
+
return [out, state];
|
|
69
|
+
};
|
|
70
|
+
}
|