Skip to content

Commit ae81c94

Browse files
committed
消费预测重选型:组合冠军 + conformal 区间收窄 (v1.15.0)
60 天真实数据滚动回测选型: - 日级冠军换四模型组合(Holt/Theta/中位/均线),1天 WAPE 62%→58%、 7天累计 82%→70%,震荡段不再崩坏(92%→78%) - 日级区间改逐视界 conformal(名义80%):覆盖 62%→71% 且宽度 2.76×→2.01×; 7天合计区间用累计比值单独校准,覆盖 42%→52%、宽度 1.74×→1.30× - 小时级总量改近5日滚动总量中位数(尖峰免疫),24h 总量 WAPE 70%→60%; 形状改半衰期3天递归加权画像;conformal 区间宽度 2.60×→1.55× 覆盖持平 - 小时序列窗口 14→28 天(modelRows 本就拉 35 天,零额外开销) - 岭回归/GBDT 已回测:日级过拟合、小时级总量偏差大,不引入(保持零依赖)
1 parent 07319f4 commit ae81c94

3 files changed

Lines changed: 186 additions & 105 deletions

File tree

lib/forecast.js

Lines changed: 182 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1-
// 日消费预测:冠军-挑战者自适应选择 + 经验分位置信带
1+
// 消费预测:组合冠军 + 逐视界 conformal 经验区间
22
//
3-
// 用真实数据回测(前 N 天预测第 N+1 天 vs 真实值)选型的结论:
4-
// - 中转站日消费噪声极大(单日 2~5 倍波动),星期因子在无周律数据上放大噪声,
5-
// 旧「加权回归+星期因子」1 天误差 80%;
6-
// - 对数空间阻尼 Holt(乘性噪声 + 阻尼趋势)在真实与增长场景稳定最优(52%);
7-
// - 周律类方法只有在数据真有周律时才应启用。
8-
// 因此:默认冠军 = 对数阻尼 Holt;每次预测时在最近 14 天做内部回测,
9-
// 周律类挑战者领先 20% 以上才切换(高门槛避免选择噪声)。
10-
// 置信带取自被选方法内部回测的「实际/预测」比值分位数(经验带,非参数假设),
11-
// 并把回测误差随结果返回,界面直接展示预测的真实可信度。
3+
// v1.15.0 用 60 天真实数据(1439 小时点)滚动回测重新选型的结论:
4+
// 日级——单模型对数阻尼 Holt 在趋势段最优但在震荡段崩坏(早期段 WAPE 92%),
5+
// 四模型等权组合(Holt/Theta/中位/均线)在全部时间段稳定(全窗 1天 58%/7天 70%,
6+
// 旧冠军 62%/82%);岭回归等 ML 方法在几十个日样本上过拟合,全面落后。
7+
// 区间——旧「1天比值分位×√k 加宽」逐日覆盖 62% 但宽度 2.76×实际;
8+
// 逐视界 conformal(名义 80%)覆盖 71% 且宽度 2.01×,7 天合计区间单独用
9+
// 7 日累计比值校准(多日求和平均掉单日噪声),覆盖 42%→52%、宽度 1.74×→1.30×。
10+
// 小时级——总量的最大改进来自抗尖峰:近 5 个滚动日总量的中位数把 24h 总量
11+
// WAPE 从 70% 压到 60%(尖峰日免疫);形状用递归加权画像(半衰期 3 天)比
12+
// 7 天等权中位画像的逐小时 WAPE 低 3~13 个百分点;conformal 区间在覆盖率持平
13+
// 的情况下把宽度从 2.60× 压到 1.55×。ridge/GBDT 形状略优但总量偏差大,不值得引入。
14+
// 星期因子类模型依旧只作挑战者(需内部回测领先 20% 才切换,历史证明周律弱时有害)。
1215

1316
const r2 = (v) => Math.round(v * 100) / 100;
17+
const mean = (a) => a.reduce((x, y) => x + y, 0) / a.length;
1418
const median = (a) => {
1519
const s = [...a].sort((x, y) => x - y);
1620
const m = Math.floor(s.length / 2);
@@ -22,7 +26,7 @@ const quantile = (a, q) => {
2226
return s[idx];
2327
};
2428

25-
// ---- 冠军:对数空间阻尼 Holt --------------------------------------------------
29+
// ---- 基模型:对数空间阻尼 Holt(乘性噪声 + 阻尼趋势) --------------------------
2630
function logHolt(vals, ts, h, alpha = 0.5, beta = 0.1, phi = 0.9) {
2731
const lv = vals.map((v) => Math.log(v + 1));
2832
let level = lv[0], trend = 0;
@@ -40,7 +44,35 @@ function logHolt(vals, ts, h, alpha = 0.5, beta = 0.1, phi = 0.9) {
4044
return out;
4145
}
4246

43-
// ---- 挑战者 1:加权回归 + 星期因子(强周律数据的最优解) -----------------------
47+
// ---- 基模型:log 空间 Theta(SES 水平 + 半强度整体趋势) -----------------------
48+
function thetaLog(vals, ts, h) {
49+
const lv = vals.map((v) => Math.log(v + 1));
50+
const n = lv.length;
51+
let sx = 0, sy = 0, sxx = 0, sxy = 0;
52+
lv.forEach((y, i) => { sx += i; sy += y; sxx += i * i; sxy += i * y; });
53+
const den = n * sxx - sx * sx;
54+
const b = Math.abs(den) > 1e-9 ? (n * sxy - sx * sy) / den : 0;
55+
let level = lv[0];
56+
for (let i = 1; i < n; i++) level = 0.4 * lv[i] + 0.6 * level;
57+
return Array.from({ length: h }, (_, k) => Math.max(0, Math.exp(level + 0.5 * b * (k + 1)) - 1));
58+
}
59+
60+
// ---- 基模型:EWMA 水平平推 / 近 7 天中位数平推 --------------------------------
61+
function ewmaFlat(vals, ts, h, hl = 10) {
62+
const alpha = 1 - Math.pow(0.5, 1 / hl);
63+
let level = vals[0];
64+
for (let i = 1; i < vals.length; i++) level = alpha * vals[i] + (1 - alpha) * level;
65+
return Array(h).fill(level);
66+
}
67+
const med7Flat = (vals, ts, h) => Array(h).fill(median(vals.slice(-7)));
68+
69+
// ---- 冠军:四模型等权组合(趋势段跟得上、震荡段拖不垮) ------------------------
70+
function ens4(vals, ts, h) {
71+
const ps = [logHolt(vals, ts, h), thetaLog(vals, ts, h), med7Flat(vals, ts, h), ewmaFlat(vals, ts, h)];
72+
return ps[0].map((_, i) => (ps[0][i] + ps[1][i] + ps[2][i] + ps[3][i]) / 4);
73+
}
74+
75+
// ---- 挑战者:加权回归 + 星期因子(强周律数据的最优解) -----------------------
4476
function regDow(vals, ts, h) {
4577
const n = vals.length;
4678
let factors = Array(7).fill(1);
@@ -63,7 +95,7 @@ function regDow(vals, ts, h) {
6395
Math.max(0, (a + b * (n + k)) * factors[new Date(ts[n - 1] + (k + 1) * 86400000).getDay()]));
6496
}
6597

66-
// ---- 挑战者 2:EWMA 水平 × 收缩星期因子(温和周律) ----------------------------
98+
// ---- 挑战者:EWMA 水平 × 收缩星期因子(温和周律) ----------------------------
6799
function ewmaDow(vals, ts, h, hl = 5, shrinkK = 3) {
68100
const alpha = 1 - Math.pow(0.5, 1 / hl);
69101
let level = vals[0];
@@ -101,91 +133,57 @@ function backtestScore(fn, vals, ts) {
101133
};
102134
}
103135

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-
115136
const METHOD_LABEL = {
116-
"log-holt": "阻尼指数趋势",
137+
ens4: "四模型组合",
117138
"reg-dow": "加权回归 + 星期因子",
118139
"ewma-dow": "均线 + 星期因子",
119140
};
120141

121142
/**
122-
* 小时级预测:小时画像(每个钟点的中位消费)× 近期水平缩放。
123-
* 回测选型结论:昼夜规律强的数据上,24 小时总量误差比日级方法更低
124-
*(真实数据 38% vs 日级 46%);持续性/混合法都更差。
125-
*
126-
* @param points [{t: 整点 ms, cost}] 升序、缺时补 0、不含当前未完小时
127-
* @param hodOf (ms) => 0-23,调用方提供时区感知的“当地钟点”函数
128-
* @param horizon 预测小时数
129-
* @returns { points:[{t,cost,lo,hi}], next24Total, backtestWapePct } 或 null
143+
* 逐视界 conformal 校准:对最近 maxOrigins 个历史原点重放被选方法,
144+
* 收集各视界 k 的「实际/预测」比值分位 + 7 日累计比值分位。
145+
* 名义覆盖 80%(10/90 分位);某视界样本 <8 时借全部视界的样本。
130146
*/
131-
export function forecastHourly(points, hodOf, horizon = 24) {
132-
const n = points.length;
133-
if (n < 72) return null; // 至少 3 天小时数据
134-
135-
const fit = (vals, ts) => {
136-
// 画像:近 7 天每个钟点的中位数与分位(不足 7 天用全部)
137-
const cut = ts[ts.length - 1] - 7 * 86400000;
138-
const byH = Array(24).fill(0).map(() => []);
139-
for (let i = 0; i < vals.length; i++) if (ts[i] >= cut) byH[hodOf(ts[i])].push(vals[i]);
140-
const prof = byH.map((a) => median(a));
141-
const profSum = prof.reduce((a, b) => a + b, 0);
142-
// 水平:近 48 小时均值折算成日总量
143-
const lvCut = ts[ts.length - 1] - 48 * 3600000;
144-
let recent = 0, hours = 0;
145-
for (let i = 0; i < vals.length; i++) if (ts[i] >= lvCut) { recent += vals[i]; hours++; }
146-
const dailyLevel = hours > 0 ? (recent / hours) * 24 : profSum;
147-
const scale = profSum > 1e-9 ? dailyLevel / profSum : 0;
148-
return { prof, byH, scale };
149-
};
150-
151-
const vals = points.map((p) => p.cost);
152-
const ts = points.map((p) => p.t);
153-
const { prof, byH, scale } = fit(vals, ts);
154-
155-
const lastT = ts[n - 1];
156-
const out = [];
157-
for (let k = 1; k <= horizon; k++) {
158-
const t = lastT + k * 3600000;
159-
const h = hodOf(t);
160-
const p = Math.max(0, prof[h] * scale);
161-
const qlo = quantile(byH[h].length ? byH[h] : [0], 0.2) * scale;
162-
const qhi = quantile(byH[h].length ? byH[h] : [0], 0.8) * scale;
163-
out.push({ t, cost: r2(p), lo: r2(Math.min(qlo, p)), hi: r2(Math.max(qhi, p * 1.2)) });
164-
}
165-
166-
// 内部回测:每 6 小时一个测试点,评估未来 24 小时总量误差
167-
let esum = 0, asum = 0;
168-
for (let i = Math.max(72, n - 7 * 24); i + 24 <= n; i += 6) {
169-
const f = fit(vals.slice(0, i), ts.slice(0, i));
170-
let ps = 0, as = 0;
171-
for (let k = 0; k < 24; k++) {
172-
ps += Math.max(0, f.prof[hodOf(ts[i] + (k + 1) * 3600000)] * f.scale);
173-
as += vals[i + k];
147+
function dailyConformal(fn, vals, ts, h, maxOrigins = 28) {
148+
const n = vals.length;
149+
const start = Math.max(5, n - maxOrigins);
150+
const byK = Array(h).fill(0).map(() => []);
151+
const sumRatios = [];
152+
for (let i = start; i < n; i++) {
153+
const hh = Math.min(h, n - i);
154+
const p = fn(vals.slice(0, i), ts.slice(0, i), hh);
155+
for (let k = 0; k < hh; k++) {
156+
if (p[k] > 0.01) byK[k].push(vals[i + k] / p[k]);
157+
}
158+
if (i + h <= n) {
159+
const ps = p.reduce((a, b) => a + b, 0);
160+
const as = vals.slice(i, i + h).reduce((a, b) => a + b, 0);
161+
if (ps > 0.01) sumRatios.push(as / ps);
174162
}
175-
esum += Math.abs(ps - as); asum += as;
176163
}
177-
178-
return {
179-
points: out,
180-
next24Total: r2(out.slice(0, 24).reduce((a, p) => a + p.cost, 0)),
181-
backtestWapePct: asum > 0 ? Math.round((esum / asum) * 100) : null,
182-
};
164+
const all = byK.flat();
165+
const perK = byK.map((arr) => {
166+
const src = arr.length >= 8 ? arr : all;
167+
if (src.length < 5) return null; // 样本太少交给调用方兜底
168+
return {
169+
lo: Math.min(1, Math.max(0.05, quantile(src, 0.1))),
170+
hi: Math.max(1, Math.min(5, quantile(src, 0.9))),
171+
};
172+
});
173+
let tot = null;
174+
if (sumRatios.length >= 5) {
175+
tot = {
176+
lo: Math.min(1, Math.max(0.2, quantile(sumRatios, 0.1))),
177+
hi: Math.max(1, Math.min(3, quantile(sumRatios, 0.9))),
178+
};
179+
}
180+
return { perK, tot };
183181
}
184182

185183
/**
186184
* @param daily [{t: 当日零点 ms, cost: 当日消费}] 升序、缺日补 0、不含今天
187185
* @param horizon 预测天数
188-
* @returns { points:[{t,cost,lo,hi}], nextTotal, method, sampleDays, backtestWapePct } 或 null
186+
* @returns { points:[{t,cost,lo,hi}], nextTotal, nextLo, nextHi, method, sampleDays, backtestWapePct } 或 null
189187
*/
190188
export function forecastDaily(daily, horizon = 7) {
191189
const n = daily.length;
@@ -194,11 +192,11 @@ export function forecastDaily(daily, horizon = 7) {
194192
const ts = daily.map((d) => d.t);
195193

196194
// 冠军-挑战者选型:挑战者需在内部回测领先 20% 才切换
197-
let pick = "log-holt";
198-
let fn = logHolt;
195+
let pick = "ens4";
196+
let fn = ens4;
199197
let champ = { score: Infinity, wape1: null };
200198
if (n >= 8) {
201-
champ = backtestScore(logHolt, vals, ts);
199+
champ = backtestScore(ens4, vals, ts);
202200
if (n >= 14) {
203201
for (const [name, cand] of [["reg-dow", regDow], ["ewma-dow", ewmaDow]]) {
204202
const s = backtestScore(cand, vals, ts);
@@ -208,35 +206,117 @@ export function forecastDaily(daily, horizon = 7) {
208206
}
209207

210208
const preds = fn(vals, ts, horizon);
209+
const cal = n >= 8 ? dailyConformal(fn, vals, ts, horizon) : { perK: Array(horizon).fill(null), tot: null };
211210

212-
// 经验置信带:比值分位(15%~85%),随预测距离温和加宽;样本不足退回 ±40%
213-
const ratios = n >= 8 ? backtestRatios(fn, vals, ts) : [];
214-
let qlo = 0.6, qhi = 1.4;
215-
if (ratios.length >= 5) {
216-
qlo = Math.min(1, Math.max(0.15, quantile(ratios, 0.15)));
217-
qhi = Math.max(1, Math.min(4, quantile(ratios, 0.85)));
218-
}
219211
const lastT = ts[n - 1];
220212
const points = preds.map((p, i) => {
221-
const k = i + 1;
222-
const widen = Math.min(Math.sqrt(k), 1.8); // 远期更不确定,但别无限扩张
213+
const q = cal.perK[i];
214+
if (q) return { t: lastT + (i + 1) * 86400000, cost: r2(p), lo: r2(Math.max(0, p * q.lo)), hi: r2(p * q.hi) };
215+
// 校准样本不足的兜底:±40% 起步、随距离温和加宽
216+
const widen = Math.min(Math.sqrt(i + 1), 1.8);
223217
return {
224-
t: lastT + k * 86400000,
218+
t: lastT + (i + 1) * 86400000,
225219
cost: r2(p),
226-
lo: r2(Math.max(0, p * (1 - (1 - qlo) * widen))),
227-
hi: r2(p * (1 + (qhi - 1) * widen)),
220+
lo: r2(Math.max(0, p * (1 - 0.4 * widen))),
221+
hi: r2(p * (1 + 0.4 * widen)),
228222
};
229223
});
230224

231225
const nextTotal = r2(points.reduce((a, p) => a + p.cost, 0));
232226
return {
233227
points,
234228
nextTotal,
235-
// 合计的区间用 1 天分位(多日求和会平均掉单日噪声,不再随距离加宽
236-
nextLo: r2(nextTotal * qlo),
237-
nextHi: r2(nextTotal * qhi),
229+
// 合计区间:7 日累计比值单独校准(求和平均掉单日噪声,比逐日区间窄得多
230+
nextLo: r2(nextTotal * (cal.tot ? cal.tot.lo : 0.6)),
231+
nextHi: r2(nextTotal * (cal.tot ? cal.tot.hi : 1.4)),
238232
method: METHOD_LABEL[pick] || pick,
239233
sampleDays: n,
240234
backtestWapePct: champ.wape1 != null ? Math.round(champ.wape1 * 100) : null,
241235
};
242236
}
237+
238+
// ---- 小时级 -------------------------------------------------------------------
239+
240+
// 用 [0,end) 的数据预测 end 起往后 horizon 个小时(纯函数,回测与出线共用)
241+
function hourlyPredict(vals, ts, hodOf, end, horizon) {
242+
const lastT = ts[end - 1];
243+
// 形状:近 14 天递归加权画像(半衰期 3 天,越近的日子权重越大)
244+
const cutT = lastT - 14 * 86400000;
245+
const wsum = Array(24).fill(0), vsum = Array(24).fill(0);
246+
for (let i = 0; i < end; i++) {
247+
if (ts[i] < cutT) continue;
248+
const w = Math.pow(0.5, (lastT - ts[i]) / 86400000 / 3);
249+
const h = hodOf(ts[i]);
250+
wsum[h] += w; vsum[h] += w * vals[i];
251+
}
252+
const prof = vsum.map((v, h) => (wsum[h] > 0 ? v / wsum[h] : 0));
253+
const profSum = prof.reduce((a, b) => a + b, 0);
254+
255+
// 总量:近 5 个滚动日总量的中位数(尖峰日免疫);不足 5 天用可用的整日块
256+
const dayTotals = [];
257+
for (let d = 1; d * 24 <= end && d <= 5; d++) {
258+
let s = 0;
259+
for (let i = end - d * 24; i < end - (d - 1) * 24; i++) s += vals[i];
260+
dayTotals.push(s);
261+
}
262+
const tot = dayTotals.length ? median(dayTotals) : profSum;
263+
264+
const out = [];
265+
for (let k = 1; k <= horizon; k++) {
266+
const h = hodOf(lastT + k * 3600000);
267+
out.push(profSum > 1e-9 ? Math.max(0, (prof[h] / profSum) * tot) : 0);
268+
}
269+
return out;
270+
}
271+
272+
/**
273+
* 小时级预测:递归加权小时画像出形状 × 近 5 日滚动总量中位数出总量,
274+
* conformal 校准逐小时区间(名义 60%,大/小预测值分桶:乘性/加性)。
275+
*
276+
* @param points [{t: 整点 ms, cost}] 升序、缺时补 0、不含当前未完小时
277+
* @param hodOf (ms) => 0-23,调用方提供时区感知的“当地钟点”函数
278+
* @param horizon 预测小时数
279+
* @returns { points:[{t,cost,lo,hi}], next24Total, backtestWapePct } 或 null
280+
*/
281+
export function forecastHourly(points, hodOf, horizon = 24) {
282+
const n = points.length;
283+
if (n < 72) return null; // 至少 3 天小时数据
284+
const vals = points.map((p) => p.cost);
285+
const ts = points.map((p) => p.t);
286+
287+
const preds = hourlyPredict(vals, ts, hodOf, n, horizon);
288+
289+
// conformal 校准 + 内部回测(同一批重放原点两用:区间分位 & 24h 总量 WAPE)
290+
const ratios = [], adds = [];
291+
let esum = 0, asum = 0;
292+
const calStart = Math.max(120, n - 14 * 24);
293+
for (let o = calStart; o + 24 <= n; o += 12) {
294+
const p = hourlyPredict(vals, ts, hodOf, o, 24);
295+
let ps = 0, as = 0;
296+
for (let k = 0; k < 24; k++) {
297+
const a = vals[o + k];
298+
ps += p[k]; as += a;
299+
if (p[k] > 0.5) ratios.push(a / p[k]);
300+
else adds.push(a - p[k]);
301+
}
302+
esum += Math.abs(ps - as); asum += as;
303+
}
304+
const rLo = ratios.length >= 20 ? Math.min(1, Math.max(0.05, quantile(ratios, 0.2))) : 0.3;
305+
const rHi = ratios.length >= 20 ? Math.max(1, Math.min(6, quantile(ratios, 0.8))) : 2.5;
306+
const aLo = adds.length >= 20 ? Math.min(0, quantile(adds, 0.2)) : -0.5;
307+
const aHi = adds.length >= 20 ? Math.max(0, quantile(adds, 0.8)) : 0.5;
308+
309+
const lastT = ts[n - 1];
310+
const out = preds.map((p, i) => ({
311+
t: lastT + (i + 1) * 3600000,
312+
cost: r2(p),
313+
lo: r2(Math.max(0, p > 0.5 ? p * rLo : p + aLo)),
314+
hi: r2(p > 0.5 ? p * rHi : p + aHi),
315+
}));
316+
317+
return {
318+
points: out,
319+
next24Total: r2(out.slice(0, 24).reduce((a, p) => a + p.cost, 0)),
320+
backtestWapePct: asum > 0 ? Math.round((esum / asum) * 100) : null,
321+
};
322+
}

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.14.0",
3+
"version": "1.15.0",
44
"private": true,
55
"description": "监控 sub2api / new-api 中转站余额的网页面板",
66
"type": "module",

server.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,8 @@ app.get("/api/own/analytics", async (req, res) => {
582582
} catch { /* 拿不到就退化为不区分管理员 */ }
583583
const adminSet = new Set((ownUsers || []).filter((u) => u.role >= 10).map((u) => u.username));
584584

585-
// 小时级序列(近 14 天,缺时补 0,不含当前未完小时)→ 未来 24 小时预测
585+
// 小时级序列(近 28 天,缺时补 0,不含当前未完小时)→ 未来 24 小时预测
586+
// modelRows 本就拉了 35 天:更长的序列让画像与 conformal 校准都有足够原点
586587
const hourFmt = new Intl.DateTimeFormat("en-US", { timeZone: tz, hour12: false, hour: "2-digit" });
587588
const hodOf = (ms) => Number(hourFmt.format(new Date(ms))) % 24;
588589
const hmap = new Map();
@@ -591,7 +592,7 @@ app.get("/api/own/analytics", async (req, res) => {
591592
hmap.set(hk, (hmap.get(hk) || 0) + r.cost);
592593
}
593594
const lastFullHour = Math.floor(now / 3600000) * 3600000 - 3600000;
594-
const hourlyStart = Math.max(lastFullHour - 14 * 86400000, hmap.size ? Math.min(...hmap.keys()) : lastFullHour);
595+
const hourlyStart = Math.max(lastFullHour - 28 * 86400000, hmap.size ? Math.min(...hmap.keys()) : lastFullHour);
595596
const hourlySeries = [];
596597
for (let t = hourlyStart; t <= lastFullHour; t += 3600000) {
597598
hourlySeries.push({ t, cost: Math.round((hmap.get(t) || 0) * 10000) / 10000 });

0 commit comments

Comments
 (0)