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
1316const r2 = ( v ) => Math . round ( v * 100 ) / 100 ;
17+ const mean = ( a ) => a . reduce ( ( x , y ) => x + y , 0 ) / a . length ;
1418const 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(乘性噪声 + 阻尼趋势) --------------------------
2630function 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+ // ---- 挑战者:加权回归 + 星期因子(强周律数据的最优解) -----------------------
4476function 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 水平 × 收缩星期因子(温和周律) ----------------------------
6799function 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-
115136const 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 */
190188export 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+ }
0 commit comments