Skip to content

Commit 3039101

Browse files
committed
消费预测重写:回测驱动选型,误差从 80% 降到 52% (v1.8.1)
用真实数据做滚动回测(前 N 天预测第 N+1 天 vs 真实值)诊断: 旧「加权回归+星期因子」把消费尖峰拟合成周末规律,1 天 WAPE 高达 80%, 且置信带过窄。重写为冠军-挑战者自适应架构: - 冠军:对数空间阻尼 Holt(乘性噪声 + 阻尼趋势),真实数据 1 天 WAPE 52%、 3 天累计 45.6%(接近该序列 51% 的持续性噪声地板) - 挑战者:星期因子类方法仅在内部回测领先 20% 以上才切换 (高门槛避免选择噪声;强周律合成数据上验证会正确切换) - 置信带改为经验分位:被选方法回测「实际/预测」比值的 15%~85% 分位, 随预测距离温和加宽;滚动验证 1 天覆盖率 67%(目标 70%,校准良好) - 7 天合计的区间单独用 1 天分位计算(多日求和平均掉单日噪声, 不再逐日加宽后求和导致区间虚大) - 输出回测偏差(backtestWapePct),界面直接展示 「近 2 周回测日均偏差 ±N%」,预测可信度透明化
1 parent d7778e8 commit 3039101

3 files changed

Lines changed: 161 additions & 52 deletions

File tree

lib/forecast.js

Lines changed: 155 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,178 @@
1-
// 日消费预测:加权线性趋势 + 星期因子 + 残差置信带
1+
// 日消费预测:冠军-挑战者自适应选择 + 经验分位置信带
22
//
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+
// 并把回测误差随结果返回,界面直接展示预测的真实可信度。
912

1013
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+
};
11120

12121
/**
13-
* @param daily [{t: 当日零点 ms, cost: 当日消费}] 升序、无缺日(缺日补 0
122+
* @param daily [{t: 当日零点 ms, cost: 当日消费}] 升序、缺日补 0、不含今天
14123
* @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
16125
*/
17126
export function forecastDaily(daily, horizon = 7) {
18127
const n = daily.length;
19128
if (n < 3) return null;
20-
21129
const vals = daily.map((d) => d.cost);
130+
const ts = daily.map((d) => d.t);
22131

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+
}
37143
}
38144
}
39145

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);
50147

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+
};
55165
});
56-
const sigma = Math.sqrt(rss / sw);
57166

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));
67168
return {
68169
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,
71175
sampleDays: n,
176+
backtestWapePct: champ.wape1 != null ? Math.round(champ.wape1 * 100) : null,
72177
};
73178
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "relay-monitor",
3-
"version": "1.8.0",
3+
"version": "1.8.1",
44
"private": true,
55
"description": "监控 sub2api / new-api 中转站余额的网页面板",
66
"type": "module",

public/app.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -933,8 +933,12 @@ function renderOwnBody() {
933933
const users = d.byUser.map((u) => ({ ...u, cost: u.cost * rate }));
934934

935935
const fc = d.forecast;
936+
const fcLo = fc ? (fc.nextLo ?? fc.points.reduce((a, p) => a + p.lo, 0)) : 0;
937+
const fcHi = fc ? (fc.nextHi ?? fc.points.reduce((a, p) => a + p.hi, 0)) : 0;
936938
const fcSub = fc
937-
? `未来 7 天预计 ${cny(fc.nextTotal * rate)}(区间 ${cny(fc.points.reduce((a, p) => a + p.lo, 0) * rate)} ~ ${cny(fc.points.reduce((a, p) => a + p.hi, 0) * rate)})· ${fc.method} · 基于 ${fc.sampleDays} 天`
939+
? `未来 7 天预计 ${cny(fc.nextTotal * rate)}(区间 ${cny(fcLo * rate)} ~ ${cny(fcHi * rate)})· ${fc.method} · 基于 ${fc.sampleDays}${
940+
fc.backtestWapePct != null ? ` · 近 2 周回测日均偏差 ±${fc.backtestWapePct}%` : ""
941+
}`
938942
: "历史数据不足 3 天,暂无法预测";
939943

940944
el.innerHTML = `

0 commit comments

Comments
 (0)