|
1 | | -// 日消费预测:加权线性趋势 + 星期因子 + 残差置信带 |
| 1 | +// 日消费预测:冠军-挑战者自适应选择 + 经验分位置信带 |
2 | 2 | // |
3 | | -// 方法(可解释优先,不上黑盒): |
4 | | -// 1. 数据满两周时计算星期因子(周末/工作日消费模式差异),先去季节化 |
5 | | -// 2. 对去季节序列做指数加权最小二乘(近期权重大,w = 0.92^age), |
6 | | -// 拟合线性趋势 y = a + b·i |
7 | | -// 3. 预测值 = 趋势外推 × 当天星期因子;置信带用加权残差标准差的 |
8 | | -// ±1.28σ(约 80% 区间),随预测距离增宽 |
| 3 | +// 用真实数据回测(前 N 天预测第 N+1 天 vs 真实值)选型的结论: |
| 4 | +// - 中转站日消费噪声极大(单日 2~5 倍波动),星期因子在无周律数据上放大噪声, |
| 5 | +// 旧「加权回归+星期因子」1 天误差 80%; |
| 6 | +// - 对数空间阻尼 Holt(乘性噪声 + 阻尼趋势)在真实与增长场景稳定最优(52%); |
| 7 | +// - 周律类方法只有在数据真有周律时才应启用。 |
| 8 | +// 因此:默认冠军 = 对数阻尼 Holt;每次预测时在最近 14 天做内部回测, |
| 9 | +// 周律类挑战者领先 20% 以上才切换(高门槛避免选择噪声)。 |
| 10 | +// 置信带取自被选方法内部回测的「实际/预测」比值分位数(经验带,非参数假设), |
| 11 | +// 并把回测误差随结果返回,界面直接展示预测的真实可信度。 |
9 | 12 |
|
10 | 13 | const r2 = (v) => Math.round(v * 100) / 100; |
| 14 | +const median = (a) => { |
| 15 | + const s = [...a].sort((x, y) => x - y); |
| 16 | + const m = Math.floor(s.length / 2); |
| 17 | + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; |
| 18 | +}; |
| 19 | +const quantile = (a, q) => { |
| 20 | + const s = [...a].sort((x, y) => x - y); |
| 21 | + const idx = Math.min(s.length - 1, Math.max(0, Math.round(q * (s.length - 1)))); |
| 22 | + return s[idx]; |
| 23 | +}; |
| 24 | + |
| 25 | +// ---- 冠军:对数空间阻尼 Holt -------------------------------------------------- |
| 26 | +function logHolt(vals, ts, h, alpha = 0.5, beta = 0.1, phi = 0.9) { |
| 27 | + const lv = vals.map((v) => Math.log(v + 1)); |
| 28 | + let level = lv[0], trend = 0; |
| 29 | + for (let i = 1; i < lv.length; i++) { |
| 30 | + const prev = level; |
| 31 | + level = alpha * lv[i] + (1 - alpha) * (level + phi * trend); |
| 32 | + trend = beta * (level - prev) + (1 - beta) * phi * trend; |
| 33 | + } |
| 34 | + const out = []; |
| 35 | + let acc = 0; |
| 36 | + for (let k = 1; k <= h; k++) { |
| 37 | + acc += Math.pow(phi, k); |
| 38 | + out.push(Math.max(0, Math.exp(level + acc * trend) - 1)); |
| 39 | + } |
| 40 | + return out; |
| 41 | +} |
| 42 | + |
| 43 | +// ---- 挑战者 1:加权回归 + 星期因子(强周律数据的最优解) ----------------------- |
| 44 | +function regDow(vals, ts, h) { |
| 45 | + const n = vals.length; |
| 46 | + let factors = Array(7).fill(1); |
| 47 | + const sum = Array(7).fill(0), cnt = Array(7).fill(0); |
| 48 | + vals.forEach((v, i) => { const w = new Date(ts[i]).getDay(); sum[w] += v; cnt[w]++; }); |
| 49 | + const overall = vals.reduce((a, b) => a + b, 0) / n; |
| 50 | + if (overall > 0) { |
| 51 | + factors = sum.map((s, i) => Math.min(3, Math.max(0.3, (cnt[i] ? s / cnt[i] / overall : 1) || 1))); |
| 52 | + } |
| 53 | + const adj = vals.map((v, i) => v / factors[new Date(ts[i]).getDay()]); |
| 54 | + let sw = 0, swx = 0, swy = 0, swxx = 0, swxy = 0; |
| 55 | + adj.forEach((y, i) => { |
| 56 | + const w = Math.pow(0.92, n - 1 - i); |
| 57 | + sw += w; swx += w * i; swy += w * y; swxx += w * i * i; swxy += w * i * y; |
| 58 | + }); |
| 59 | + const den = sw * swxx - swx * swx; |
| 60 | + const b = Math.abs(den) > 1e-9 ? (sw * swxy - swx * swy) / den : 0; |
| 61 | + const a = (swy - b * swx) / sw; |
| 62 | + return Array.from({ length: h }, (_, k) => |
| 63 | + Math.max(0, (a + b * (n + k)) * factors[new Date(ts[n - 1] + (k + 1) * 86400000).getDay()])); |
| 64 | +} |
| 65 | + |
| 66 | +// ---- 挑战者 2:EWMA 水平 × 收缩星期因子(温和周律) ---------------------------- |
| 67 | +function ewmaDow(vals, ts, h, hl = 5, shrinkK = 3) { |
| 68 | + const alpha = 1 - Math.pow(0.5, 1 / hl); |
| 69 | + let level = vals[0]; |
| 70 | + const levels = [level]; |
| 71 | + for (let i = 1; i < vals.length; i++) { |
| 72 | + level = alpha * vals[i] + (1 - alpha) * level; |
| 73 | + levels.push(level); |
| 74 | + } |
| 75 | + const rel = Array(7).fill(0).map(() => []); |
| 76 | + vals.forEach((v, i) => { if (levels[i] > 0) rel[new Date(ts[i]).getDay()].push(v / levels[i]); }); |
| 77 | + const factors = rel.map((arr) => { |
| 78 | + if (!arr.length) return 1; |
| 79 | + return (shrinkK + arr.length * median(arr)) / (shrinkK + arr.length); // 样本少时向 1 收缩 |
| 80 | + }); |
| 81 | + const n = vals.length; |
| 82 | + return Array.from({ length: h }, (_, k) => |
| 83 | + Math.max(0, level * factors[new Date(ts[n - 1] + (k + 1) * 86400000).getDay()])); |
| 84 | +} |
| 85 | + |
| 86 | +// ---- 内部回测:混合 1 天 + 3 天累计的加权绝对百分比误差 ------------------------- |
| 87 | +function backtestScore(fn, vals, ts) { |
| 88 | + const start = Math.max(5, vals.length - 14); |
| 89 | + let e1 = 0, a1 = 0, e3 = 0, a3 = 0; |
| 90 | + for (let i = start; i < vals.length; i++) { |
| 91 | + const p = fn(vals.slice(0, i), ts.slice(0, i), 3); |
| 92 | + e1 += Math.abs(p[0] - vals[i]); a1 += vals[i]; |
| 93 | + if (i + 2 < vals.length) { |
| 94 | + e3 += Math.abs(p[0] + p[1] + p[2] - (vals[i] + vals[i + 1] + vals[i + 2])); |
| 95 | + a3 += vals[i] + vals[i + 1] + vals[i + 2]; |
| 96 | + } |
| 97 | + } |
| 98 | + return { |
| 99 | + score: (a1 > 0 ? e1 / a1 : 9) + (a3 > 0 ? e3 / a3 : 9), |
| 100 | + wape1: a1 > 0 ? e1 / a1 : null, |
| 101 | + }; |
| 102 | +} |
| 103 | + |
| 104 | +// 被选方法在内部回测窗口的「实际/预测」比值(经验置信带的原料) |
| 105 | +function backtestRatios(fn, vals, ts) { |
| 106 | + const start = Math.max(5, vals.length - 14); |
| 107 | + const ratios = []; |
| 108 | + for (let i = start; i < vals.length; i++) { |
| 109 | + const p = fn(vals.slice(0, i), ts.slice(0, i), 1)[0]; |
| 110 | + if (p > 0.01) ratios.push(vals[i] / p); |
| 111 | + } |
| 112 | + return ratios; |
| 113 | +} |
| 114 | + |
| 115 | +const METHOD_LABEL = { |
| 116 | + "log-holt": "阻尼指数趋势", |
| 117 | + "reg-dow": "加权回归 + 星期因子", |
| 118 | + "ewma-dow": "均线 + 星期因子", |
| 119 | +}; |
11 | 120 |
|
12 | 121 | /** |
13 | | - * @param daily [{t: 当日零点 ms, cost: 当日消费}] 升序、无缺日(缺日补 0) |
| 122 | + * @param daily [{t: 当日零点 ms, cost: 当日消费}] 升序、缺日补 0、不含今天 |
14 | 123 | * @param horizon 预测天数 |
15 | | - * @returns { points: [{t, cost, lo, hi}], nextTotal, method, sampleDays } 或 null(数据不足) |
| 124 | + * @returns { points:[{t,cost,lo,hi}], nextTotal, method, sampleDays, backtestWapePct } 或 null |
16 | 125 | */ |
17 | 126 | export function forecastDaily(daily, horizon = 7) { |
18 | 127 | const n = daily.length; |
19 | 128 | if (n < 3) return null; |
20 | | - |
21 | 129 | const vals = daily.map((d) => d.cost); |
| 130 | + const ts = daily.map((d) => d.t); |
22 | 131 |
|
23 | | - // 星期因子(不足两周不启用,避免小样本过拟合) |
24 | | - let factors = Array(7).fill(1); |
25 | | - if (n >= 14) { |
26 | | - const sum = Array(7).fill(0), cnt = Array(7).fill(0); |
27 | | - for (const d of daily) { |
28 | | - const w = new Date(d.t).getDay(); |
29 | | - sum[w] += d.cost; cnt[w]++; |
30 | | - } |
31 | | - const overall = vals.reduce((a, b) => a + b, 0) / n; |
32 | | - if (overall > 0) { |
33 | | - factors = sum.map((s, i) => { |
34 | | - const f = cnt[i] ? s / cnt[i] / overall : 1; |
35 | | - return Math.min(3, Math.max(0.3, f || 1)); // 极端因子截断 |
36 | | - }); |
| 132 | + // 冠军-挑战者选型:挑战者需在内部回测领先 20% 才切换 |
| 133 | + let pick = "log-holt"; |
| 134 | + let fn = logHolt; |
| 135 | + let champ = { score: Infinity, wape1: null }; |
| 136 | + if (n >= 8) { |
| 137 | + champ = backtestScore(logHolt, vals, ts); |
| 138 | + if (n >= 14) { |
| 139 | + for (const [name, cand] of [["reg-dow", regDow], ["ewma-dow", ewmaDow]]) { |
| 140 | + const s = backtestScore(cand, vals, ts); |
| 141 | + if (s.score < champ.score * 0.8) { pick = name; fn = cand; champ = s; break; } |
| 142 | + } |
37 | 143 | } |
38 | 144 | } |
39 | 145 |
|
40 | | - // 去季节 + 指数加权线性回归 |
41 | | - const adj = daily.map((d) => d.cost / factors[new Date(d.t).getDay()]); |
42 | | - let sw = 0, swx = 0, swy = 0, swxx = 0, swxy = 0; |
43 | | - adj.forEach((y, i) => { |
44 | | - const w = Math.pow(0.92, n - 1 - i); |
45 | | - sw += w; swx += w * i; swy += w * y; swxx += w * i * i; swxy += w * i * y; |
46 | | - }); |
47 | | - const denom = sw * swxx - swx * swx; |
48 | | - const b = Math.abs(denom) > 1e-9 ? (sw * swxy - swx * swy) / denom : 0; |
49 | | - const a = (swy - b * swx) / sw; |
| 146 | + const preds = fn(vals, ts, horizon); |
50 | 147 |
|
51 | | - let rss = 0; |
52 | | - adj.forEach((y, i) => { |
53 | | - const e = y - (a + b * i); |
54 | | - rss += Math.pow(0.92, n - 1 - i) * e * e; |
| 148 | + // 经验置信带:比值分位(15%~85%),随预测距离温和加宽;样本不足退回 ±40% |
| 149 | + const ratios = n >= 8 ? backtestRatios(fn, vals, ts) : []; |
| 150 | + let qlo = 0.6, qhi = 1.4; |
| 151 | + if (ratios.length >= 5) { |
| 152 | + qlo = Math.min(1, Math.max(0.15, quantile(ratios, 0.15))); |
| 153 | + qhi = Math.max(1, Math.min(4, quantile(ratios, 0.85))); |
| 154 | + } |
| 155 | + const lastT = ts[n - 1]; |
| 156 | + const points = preds.map((p, i) => { |
| 157 | + const k = i + 1; |
| 158 | + const widen = Math.min(Math.sqrt(k), 1.8); // 远期更不确定,但别无限扩张 |
| 159 | + return { |
| 160 | + t: lastT + k * 86400000, |
| 161 | + cost: r2(p), |
| 162 | + lo: r2(Math.max(0, p * (1 - (1 - qlo) * widen))), |
| 163 | + hi: r2(p * (1 + (qhi - 1) * widen)), |
| 164 | + }; |
55 | 165 | }); |
56 | | - const sigma = Math.sqrt(rss / sw); |
57 | 166 |
|
58 | | - const lastT = daily[n - 1].t; |
59 | | - const points = []; |
60 | | - for (let k = 1; k <= horizon; k++) { |
61 | | - const t = lastT + k * 86400000; |
62 | | - const f = factors[new Date(t).getDay()]; |
63 | | - const base = Math.max(0, (a + b * (n - 1 + k)) * f); |
64 | | - const spread = 1.28 * sigma * f * Math.sqrt(1 + k * 0.15); // 越远越不确定 |
65 | | - points.push({ t, cost: r2(base), lo: r2(Math.max(0, base - spread)), hi: r2(base + spread) }); |
66 | | - } |
| 167 | + const nextTotal = r2(points.reduce((a, p) => a + p.cost, 0)); |
67 | 168 | return { |
68 | 169 | points, |
69 | | - nextTotal: r2(points.reduce((x, p) => x + p.cost, 0)), |
70 | | - method: n >= 14 ? "加权线性趋势 + 星期因子" : "加权线性趋势", |
| 170 | + nextTotal, |
| 171 | + // 合计的区间用 1 天分位(多日求和会平均掉单日噪声,不再随距离加宽) |
| 172 | + nextLo: r2(nextTotal * qlo), |
| 173 | + nextHi: r2(nextTotal * qhi), |
| 174 | + method: METHOD_LABEL[pick] || pick, |
71 | 175 | sampleDays: n, |
| 176 | + backtestWapePct: champ.wape1 != null ? Math.round(champ.wape1 * 100) : null, |
72 | 177 | }; |
73 | 178 | } |
0 commit comments