|
| 1 | +// 日消费预测:加权线性趋势 + 星期因子 + 残差置信带 |
| 2 | +// |
| 3 | +// 方法(可解释优先,不上黑盒): |
| 4 | +// 1. 数据满两周时计算星期因子(周末/工作日消费模式差异),先去季节化 |
| 5 | +// 2. 对去季节序列做指数加权最小二乘(近期权重大,w = 0.92^age), |
| 6 | +// 拟合线性趋势 y = a + b·i |
| 7 | +// 3. 预测值 = 趋势外推 × 当天星期因子;置信带用加权残差标准差的 |
| 8 | +// ±1.28σ(约 80% 区间),随预测距离增宽 |
| 9 | + |
| 10 | +const r2 = (v) => Math.round(v * 100) / 100; |
| 11 | + |
| 12 | +/** |
| 13 | + * @param daily [{t: 当日零点 ms, cost: 当日消费}] 升序、无缺日(缺日补 0) |
| 14 | + * @param horizon 预测天数 |
| 15 | + * @returns { points: [{t, cost, lo, hi}], nextTotal, method, sampleDays } 或 null(数据不足) |
| 16 | + */ |
| 17 | +export function forecastDaily(daily, horizon = 7) { |
| 18 | + const n = daily.length; |
| 19 | + if (n < 3) return null; |
| 20 | + |
| 21 | + const vals = daily.map((d) => d.cost); |
| 22 | + |
| 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 | + }); |
| 37 | + } |
| 38 | + } |
| 39 | + |
| 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; |
| 50 | + |
| 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; |
| 55 | + }); |
| 56 | + const sigma = Math.sqrt(rss / sw); |
| 57 | + |
| 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 | + } |
| 67 | + return { |
| 68 | + points, |
| 69 | + nextTotal: r2(points.reduce((x, p) => x + p.cost, 0)), |
| 70 | + method: n >= 14 ? "加权线性趋势 + 星期因子" : "加权线性趋势", |
| 71 | + sampleDays: n, |
| 72 | + }; |
| 73 | +} |
0 commit comments