回测一步向前的需求预测
构造仅使用过去观测的滞后特征,扩展窗口重新拟合,并与两种朴素基线比较。
目标与运行方法
本课适合具备 Python 基础的本科生。先阅读每一步的方法与图注,再修改参数。AI 练习为可选部分,不需要 AI 账号即可完成核心课程。数据写在 notebook metadata 中,不需要另下载数据文件。请保持 notebook 已保存到当前工作文件夹。
学习任务是理解本课的研究问题、执行透明的计算、校验结果,并说明结论的边界。
环境与参数
from pathlib import Path
import base64, gzip, hashlib, json, platform
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import display, HTML
from datetime import datetime
from IPython.display import display as _display
def display(value):
if isinstance(value,pd.DataFrame):
table=value.to_html(max_rows=12,escape=True)
style="<style>.vibeit-readable-table{max-width:100%;overflow-x:auto}.vibeit-readable-table table{width:max-content;max-width:none;border-collapse:collapse}.vibeit-readable-table td,.vibeit-readable-table th{white-space:nowrap!important;word-break:normal!important;overflow-wrap:normal!important}</style>"
return _display(HTML(style+'<div class="vibeit-readable-table">'+table+'</div>'))
return _display(value)
RECIPE_ID='ds06-bike-backtesting'
OUTPUT_DIR=Path.cwd()/"results"/RECIPE_ID
plt.rcParams.update({"figure.dpi":120,"font.size":10,"axes.spines.top":False,"axes.spines.right":False,
"axes.prop_cycle":plt.cycler(color=["#18785f","#416aa6","#bc6541","#9974a5"])})
print("Python:",platform.python_version(),"; NumPy:",np.__version__,"; Pandas:",pd.__version__)
print("Working folder:",Path.cwd())
print("Core data mode: embedded snapshot; no network requests")
Python: 3.13.13 ; NumPy: 2.5.3 ; Pandas: 3.0.6 Working folder: /Users/zhiluo/Developer/vibeit-cookbook/cookbook/downloads Core data mode: embedded snapshot; no network requests
读取内嵌快照
def load_snapshot(recipe_id, expected_sha256, snapshot_key):
"""Read embedded data from this notebook, including a renamed copy."""
for path in sorted(Path.cwd().glob("*.ipynb")):
try:
notebook = json.loads(path.read_text(encoding="utf-8"))
metadata = notebook.get("metadata", {}).get("vibeit_cookbook", {})
blob = metadata.get("snapshots", {}).get(snapshot_key)
if metadata.get("recipe_id") != recipe_id or not blob:
continue
if blob.get("sha256") != expected_sha256:
continue
raw = gzip.decompress(base64.b64decode(blob["gzip_base64"], validate=True))
if hashlib.sha256(raw).hexdigest() != expected_sha256:
raise ValueError("Embedded snapshot checksum mismatch: " + snapshot_key)
return json.loads(raw)
except (OSError, json.JSONDecodeError):
continue
raise FileNotFoundError(
"Save/open the complete .ipynb in the active working folder. "
"The embedded snapshot could not be located for " + recipe_id)
SNAPSHOT_HASHES={'bike': '99d075d1b2d3c2c9ce73f256dcc370081a51d1df60347f59674a8ed3edf92f92'}
def snapshot(key):
return load_snapshot(RECIPE_ID,SNAPSHOT_HASHES[key],key)
print("Embedded snapshots:",list(SNAPSHOT_HASHES))
Embedded snapshots: ['bike']
方法函数
def ridge_fit_predict(train_values,train_target,test_values,penalty=1.0):
"""Scale using training statistics and solve ridge by an augmented least-squares system."""
train_values=np.asarray(train_values,dtype=float);test_values=np.asarray(test_values,dtype=float)
target=np.asarray(train_target,dtype=float)
if train_values.ndim!=2 or len(train_values)==0 or target.shape!=(len(train_values),) or penalty<0:raise ValueError('Invalid training data or penalty')
if test_values.ndim!=2 or train_values.shape[1]!=test_values.shape[1]:raise ValueError('Training/test feature dimensions differ')
if not np.isfinite(train_values).all() or not np.isfinite(test_values).all() or not np.isfinite(target).all():raise ValueError('Non-finite training data')
mean=train_values.mean(axis=0);scale=train_values.std(axis=0);scale=np.where(scale==0,1,scale)
design=np.column_stack([np.ones(len(train_values)),(train_values-mean)/scale])
test_design=np.column_stack([np.ones(len(test_values)),(test_values-mean)/scale])
regularizer=np.sqrt(penalty)*np.eye(design.shape[1]);regularizer[0,0]=0
coefficients=np.linalg.lstsq(np.vstack([design,regularizer]),np.r_[target,np.zeros(design.shape[1])],rcond=None)[0]
return dict(predictions=test_design@coefficients,coefficients=coefficients,mean=mean,scale=scale,train_predictions=design@coefficients)
print("Method ready: ridge_fit_predict")
Method ready: ridge_fit_predict
def regression_metrics(truth,predictions):
truth=np.asarray(truth,dtype=float);predictions=np.asarray(predictions,dtype=float)
if truth.shape!=predictions.shape or truth.size==0 or not np.isfinite(truth-predictions).all():raise ValueError('Invalid regression comparison')
error=predictions-truth;denominator=np.sum((truth-truth.mean())**2)
return dict(mae=float(np.mean(np.abs(error))),rmse=float(np.sqrt(np.mean(error**2))),r_squared=float(1-np.sum(error**2)/denominator) if denominator>0 else float('nan'))
print("Method ready: regression_metrics")
Method ready: regression_metrics
def iso_datetimes(values):
"""Parse source ISO dates with the standard library; keep tables as ISO strings."""
return [datetime.fromisoformat(str(value)) for value in values]
print("Method ready: iso_datetimes")
Method ready: iso_datetimes
数据与模型边界
来源记录 2011–2012 年每天的租赁次数,不是独立人数。casual 与 registered 之和是目标,因此不能作为预测变量。来源归一化的日天气字段不会被静默改写为摄氏度。
分步分析
1. 构造严格使用过去的特征
先移位计数,再计算过去七日均值。每一行使用此前观测及已知日历信息预测当日。这是顺序的一步预测:已经观测的测试结果可供后续日期使用,不能用于预测其自身。
bike=snapshot("bike")
frame=pd.DataFrame(bike["rows"],columns=bike["columns"])
frame["date"]=frame["dteday"].astype(str)
frame=frame.sort_values("date").reset_index(drop=True)
assert len(frame)==731 and frame["date"].is_unique
dates=iso_datetimes(frame.date)
assert all((b-a).total_seconds()==86400 for a,b in zip(dates,dates[1:]))
assert (frame["casual"]+frame["registered"]).eq(frame["cnt"]).all()
assert frame["cnt"].ge(0).all()
print("Complete daily observations:",len(frame),"; target: rentals/day")
display(frame[["date","cnt","temp","hum","windspeed"]].head(7))
frame["lag1"]=frame.cnt.shift(1);frame["lag7"]=frame.cnt.shift(7)
frame["past7_mean"]=frame.cnt.shift(1).rolling(7).mean()
frame["weekday_sin"]=np.sin(2*np.pi*np.array([value.weekday() for value in dates])/7)
frame["weekday_cos"]=np.cos(2*np.pi*np.array([value.weekday() for value in dates])/7)
model_frame=frame.dropna(subset=["lag1","lag7","past7_mean"]).reset_index(drop=True)
features=["lag1","lag7","past7_mean","weekday_sin","weekday_cos"]
assert np.isclose(model_frame.iloc[0].past7_mean,frame.cnt.iloc[:7].mean())
display(model_frame[["date","cnt"]+features].head(6))
Complete daily observations: 731 ; target: rentals/day
| date | cnt | temp | hum | windspeed | |
|---|---|---|---|---|---|
| 0 | 2011-01-01 | 985 | 0.344167 | 0.805833 | 0.160446 |
| 1 | 2011-01-02 | 801 | 0.363478 | 0.696087 | 0.248539 |
| 2 | 2011-01-03 | 1349 | 0.196364 | 0.437273 | 0.248309 |
| 3 | 2011-01-04 | 1562 | 0.200000 | 0.590435 | 0.160296 |
| 4 | 2011-01-05 | 1600 | 0.226957 | 0.436957 | 0.186900 |
| 5 | 2011-01-06 | 1606 | 0.204348 | 0.518261 | 0.089565 |
| 6 | 2011-01-07 | 1510 | 0.196522 | 0.498696 | 0.168726 |
| date | cnt | lag1 | lag7 | past7_mean | weekday_sin | weekday_cos | |
|---|---|---|---|---|---|---|---|
| 0 | 2011-01-08 | 959 | 1510.0 | 985.0 | 1344.714286 | -0.974928 | -0.222521 |
| 1 | 2011-01-09 | 822 | 959.0 | 801.0 | 1341.000000 | -0.781831 | 0.623490 |
| 2 | 2011-01-10 | 1321 | 822.0 | 1349.0 | 1344.000000 | 0.000000 | 1.000000 |
| 3 | 2011-01-11 | 1263 | 1321.0 | 1562.0 | 1340.000000 | 0.781831 | 0.623490 |
| 4 | 2011-01-12 | 1162 | 1263.0 | 1600.0 | 1297.285714 | 0.974928 | -0.222521 |
| 5 | 2011-01-13 | 1406 | 1162.0 | 1606.0 | 1234.714286 | 0.433884 | -0.900969 |
2. 用扩展训练窗口重新拟合
对每个 2012 年日期,仅用更早记录拟合。penalty=10 为预设参数。两种朴素基线分别使用昨日和七日前需求;模型与基线均不能读取同日目标。
start=int(np.flatnonzero(np.array([value.year==2012 for value in iso_datetimes(model_frame.date)]))[0])
forecasts=[]
for position in range(start,len(model_frame)):
history=model_frame.iloc[:position];current=model_frame.iloc[position:position+1]
fitted=ridge_fit_predict(history[features].to_numpy(),history.cnt.to_numpy(),current[features].to_numpy(),penalty=10.0)
forecasts.append({"date":current.date.iloc[0],"training_end":history.date.iloc[-1],"observed":float(current.cnt.iloc[0]),"ridge":float(fitted["predictions"][0]),"yesterday":float(current.lag1.iloc[0]),"last_week":float(current.lag7.iloc[0])})
result=pd.DataFrame(forecasts)
assert result.training_end.lt(result.date).all()
scores=pd.DataFrame({name:regression_metrics(result.observed,result[name]) for name in ["ridge","yesterday","last_week"]}).T
display(scores)
print("Expanding-window predictions:",len(result),"; all training dates precede forecast dates")
| mae | rmse | r_squared | |
|---|---|---|---|
| ridge | 797.233632 | 1093.985277 | 0.624896 |
| yesterday | 870.174863 | 1246.368239 | 0.513120 |
| last_week | 1110.396175 | 1561.849883 | 0.235447 |
Expanding-window predictions: 366 ; all training dates precede forecast dates
3. 阅读测试期间预测轨迹
展示测试期前 90 天的观测及三种预测,单位为租赁次数/日。相邻记录属于时间序列,误差不自动独立。某方法在此历史区间领先,不保证以后仍然最佳。
fig,ax=plt.subplots(figsize=(9,4));part=result.iloc[:90]
ax.plot(iso_datetimes(part.date),part.observed,color="black",label="Observed",lw=1.5)
for name in ["ridge","yesterday","last_week"]:ax.plot(iso_datetimes(part.date),part[name],label=name,alpha=.75,lw=1)
ax.set(xlabel="Forecast date",ylabel="Rentals/day",title="One-day-ahead forecasts; first 90 test days");ax.legend(ncol=2);plt.tight_layout();plt.show()
4. 按月份比较误差
月 MAE 是月内每日绝对误差的均值,各方法使用同一批日期。月份分层有助于发现总均值遮盖的季节变化,但这种事后比较不是新的调参规则。
monthly=pd.DataFrame({name:(result[name]-result.observed).abs().groupby([value.month for value in iso_datetimes(result.date)]).mean() for name in ["ridge","yesterday","last_week"]})
fig,ax=plt.subplots(figsize=(9,4));monthly.plot.bar(ax=ax)
ax.set(xlabel="Test month in 2012",ylabel="MAE (rentals/day)",title="Forecast errors on common dates");plt.tight_layout();plt.show()
5. 检查残差依赖
滞后相关比较相隔 1–30 天的成对残差,是描述性系数而非显著性检验。时间依赖提示需要尊重日期顺序的验证与分块不确定性方法。
residual=(result.ridge-result.observed).to_numpy()
lags=np.arange(1,31);autocorrelation=np.array([np.corrcoef(residual[:-lag],residual[lag:])[0,1] for lag in lags])
fig,ax=plt.subplots(figsize=(8,4));ax.stem(lags,autocorrelation)
ax.set(xlabel="Lag (days)",ylabel="Residual correlation",ylim=(-1,1),title="Descriptive dependence of held-out errors");plt.tight_layout();plt.show()
6. 审计信息时间并导出回测
用移位后的原始观测核对基线,检查全部训练截止时间,并导出完整回测。清空输出后重新运行应在数值精度内复现预测。
assert len(result)==366 and result.date.is_unique
assert np.allclose(result.yesterday,model_frame.iloc[start:].lag1)
assert np.allclose(result.last_week,model_frame.iloc[start:].lag7)
assert np.isclose(scores.loc["ridge","mae"],np.mean(np.abs(residual)))
OUTPUT_DIR.mkdir(parents=True,exist_ok=True)
result.to_csv(OUTPUT_DIR/"one-day-backtest.csv",index=False);scores.to_csv(OUTPUT_DIR/"forecast-metrics.csv")
monthly.to_csv(OUTPUT_DIR/"monthly-mae.csv")
(OUTPUT_DIR/"protocol.json").write_text(json.dumps({"horizon_days":1,"features":features,"penalty":10.0,"training":"all earlier dates","test_year":2012,"observed_test_outcomes":"available only for subsequent forecast days"},indent=2))
print("Information-time checks passed; exports:",OUTPUT_DIR)
Information-time checks passed; exports: /Users/zhiluo/Developer/vibeit-cookbook/cookbook/downloads/results/ds06-bike-backtesting
自主练习与参考答案
- 解释为何 rolling(7).mean() 前需要 shift(1)。
- 说明顺序一步预测与固定多日预测有何差别。
参考解释。 不移位会把当前目标混入特征。顺序评估可将新观测供后续日期使用;固定多日预测不能读取未来真实计数。
先保留当前 notebook 副本,再修改参数。把新图与原图一起比较,并记录改变了哪些输入、哪些计算和哪些结论。
可选的 AI 编程练习
提示词 1
仅用 NumPy、Pandas、Matplotlib 增加过去 14 日均值,保留更早日期的训练截止约束。
提示词 2
使用同样的包比较扩展窗口与 180 日滚动训练窗口,保持相同测试日期及预设惩罚。
人工验收。 每个 training_end 早于预测日期,滞后均值等于显式过去切片,导出 366 个共同预测,不将同日目标放入特征。
检查修改后的源代码,再手动运行受影响单元及后续校验。要求助手解释修改的目的、使用的包和数据来源,并用实际输出核对解释。
可选联网扩展
RUN_ONLINE_UPDATE=False
if RUN_ONLINE_UPDATE:
import requests
try:
response=requests.get('https://archive.ics.uci.edu/dataset/275',timeout=20)
response.raise_for_status()
print("Source:",response.url)
print(response.text[:700])
except requests.RequestException as error:
print("Online extension unavailable:",type(error).__name__,str(error)[:160])
else:
print("Online extension skipped; embedded core data unchanged")
Online extension skipped; embedded core data unchanged
来源、快照与下一步
- UCI bike — CC BY 4.0; credit UCI and original dataset authors; retrieved 2026-10-09. SHA-256
b70182d0d0508e9abbb79306ce5c0cec34869000f8220175ac83d11dbe845401. Extract day.csv from original download; preserve all rows and declared units; no imputation. Fanaee-T, H. (2013). Bike Sharing. UCI Machine Learning Repository. DOI: 10.24432/C5W894.
下一步。 在保留本课的数据检查、参数记录和结果校验之后,将相同方法用于自己的公开或已获授权数据。记录研究设计与方法限制,不把示例结果当成新实验的验证。完整数据许可与生成说明见 cookbook 网页。