拿到一份每日新增病例的历史数据后,很多人第一反应是直接上高级模型:LSTM、Prophet、SIR 微分方程,感觉不堆几个"看起来高级"的算法就不算做过预测。如果你正在头歌这类机器学习实验平台上刷线性回归题目,可能已经习惯了那种干净的小数据集:几十条样本、几个特征,调一下 LinearRegression 打印 R² 就收工。但把线性回归放到真实的疫情走势预测里,事情会立刻变味。
这篇博文我想完整记录一次用线性回归做疫情预测的实操过程,包括怎么把时间序列改造成模型能吃的特征、怎么训练、怎么评估,以及真实预测里几乎必然踩到的几个坑。文章里的数据是模拟生成的,仅用于算法演示,不构成任何现实疫情判断依据。如果你刚学完线性回归基础、想拿一个完整项目练手,这篇文章应该能帮你少走不少弯路。
1. 疫情预测问题的本质:为什么先用线性回归
1.1 任务定义与数据形态
先明确我们要解决什么问题。给定过去 N 天的每日新增病例数,预测未来 M 天的大致走势。原始数据通常只有两列:日期(date)和每日新增病例数(new_cases)。这是一个典型的时间序列预测问题。
所谓"时间序列预测",就是数据点之间有时间先后关系,第 t 天的数值和它前面的第 t-1 天、第 t-7 天乃至过去一段时间的值都有联系。和普通回归任务不一样,我们手里没有现成的 X 和 y,只有一个孤零零的时间序列。线性回归本身并不"认识时间",它只能学习特征和目标之间的映射关系。所以第一步不是打开训练函数,而是先想明白:我该构造什么样的特征,才能让一个线性模型从历史数据里学到趋势信息。
这也是很多人第一次做时序预测时最容易卡住的地方。习惯分类问题里"每行一个样本、每列一个特征"的思路后,面对一长串日期和数字会不知道从哪里下手。解决办法是"有监督化":把时间序列改造成多行特征表格。具体来说,就是用过去几天的数值来预测今天的数值,那么过去的数值就变成特征,今天的数值就变成标签。
1.2 线性假设在疫情数据上到底成立吗
线性回归的核心假设是:目标变量和特征之间存在近似线性关系。如果把日期序号 t 当作唯一特征,模型就是在找一条直线 y = w·t + b,让它尽量贴近历史的每日新增病例曲线。
现实的疫情数据几乎不可能是一条直线。传染病早期往往是指数增长,随后受到各类干预措施、人群免疫力、季节因素影响,会出现增速放缓、到达峰值、然后回落的形态。一条直线无论如何都拟合不出这种"先升后降"的走势。
那为什么还非要先用线性回归?因为它是成本最低的 baseline。在把 LSTM 和 SIR 模型搬出来之前,你需要一个最简单、最稳定、可解释性最强的模型来检验整个数据处理流程是否通畅。线性回归训练只需要几毫秒,结果一目了然,能立刻暴露出特征构造、数据划分、评估方式里的bug。如果线性回归的误差已经小到可以接受,那复杂模型大概率也不会带来质的提升,这时候更应该回头检查数据,而不是继续堆模型。如果线性回归误差很大,那它的误差模式也会告诉你下一步该往哪个方向优化。
2. 数据准备:把时间序列改造成线性模型能吃的表格
2.1 特征构造:滞后特征、日期序号和滚动统计
我从模拟数据开始演示。下面这段代码生成 120 天数据,走势大致是"先缓慢上升、然后波动、最后回落",比较接近真实疫情数据里"暴发—控制—下降"的形态,但完全不含真实信息:
import numpy as np import pandas as pd np.random.seed(42) days = 120 t = np.arange(days) trend = 20 * np.sin(t / 25) + 0.6 * t noise = np.random.normal(0, 10, days) cases = np.clip(trend + noise, 0, None).astype(int) df = pd.DataFrame({ "date": pd.date_range("2023-01-01", periods=days), "new_cases": cases }) df.head()拿到原始序列后,至少要构造三类特征:
- 日期序号特征 t:把日期转成从 0 开始的连续整数,让模型能捕捉"整体随时间的上升或下降趋势"。
- 滞后特征:lag1(前一天新增病例)、lag7(一周前同一天的新增病例)。疫情数据通常有周内波动,lag7 往往比 lag1 更有参考意义。
- 滚动统计特征:过去 7 天平均新增病例数。它的作用是平滑掉单日上报波动带来的噪声,让模型看到一个更稳定的"近期水平"。
代码实现也很简单:
df["t"] = np.arange(len(df)) df["lag1"] = df["new_cases"].shift(1) df["lag7"] = df["new_cases"].shift(7) df["rolling7"] = df["new_cases"].shift(1).rolling(7).mean()这里最需要注意的是滚动均值的 shift(1)。如果直接用 rolling(7).mean(),那么第 t 天的滚动均值里包含了第 t 天自己的新增病例数。训练时模型会"偷看"当天的真实数值,指标虚高,部署到真正预测未来时才露馅。这个细节我后面第 5 章还会专门讲,它是时序预测里最常见也最隐蔽的特征泄漏来源。
特征构造完成后,把缺失值处理掉。lag1 第一行是 NaN,lag7 前 7 行是 NaN,rolling7 前 8 行是 NaN。最简单的做法是立刻丢弃这些行:
df = df.dropna().reset_index(drop=True)如果数据更长、缺失更多,也可以考虑用中位数填充。但丢行在序列较长时通常更干净,不会引入人为编造的数值。
2.2 训练集与测试集划分:按时间切,不能随机洗牌
这部分是所有刚接触时序预测的人最容易犯的错误。普通回归任务里,我们习惯用 train_test_split(X, y, test_size=0.2, random_state=42) 随机打乱样本;但在时间序列里,随机打乱等于让模型"偷看未来"。
举个例子:如果某一天的样本被划分到训练集,而它后面一周的样本被划分到测试集,那么训练时模型已经通过 lag7 这一特征学到了"未来"信息。测试集的作用是模拟真实预测场景,真实预测时我们只有过去的数据,没有未来的数据。随机打乱的测试集给不出可信的误差估计。
正确做法是按时间顺序切割:
split_idx = int(len(df) * 0.8) train = df.iloc[:split_idx].copy() test = df.iloc[split_idx:].copy() feature_cols = ["t", "lag1", "lag7", "rolling7"] X_train = train[feature_cols] y_train = train["new_cases"] X_test = test[feature_cols] y_test = test["new_cases"]用前 80% 的数据训练,后 20% 的数据做测试,模拟"用过去预测未来"的真实场景。这样做出来的评估指标才有参考价值。
3. 建模与训练:从最小二乘解到 sklearn 落地
3.1 最小二乘法与正规方程原理
线性回归的数学形式很简洁。假设特征矩阵为 X,每一行是一个样本的特征,模型要学一组权重 w 和偏置 b,使得 y ≈ Xw + b。把偏置合并进权重后,目标就变成求解:
L(w) = ||Xw - y||²
也就是残差平方和。为什么用平方误差而不是绝对误差?两个原因。第一,平方误差是连续可导的凸函数,能直接通过求导得到全局最优解;第二,它对大误差的惩罚更重,模型会更努力去避免出现严重偏离的预测。当然这也是它的缺点,后面讲异常值影响时会提到。
求 L(w) 对 w 的梯度并令梯度为 0,能得到传说中的正规方程:
w = (XᵀX)⁻¹Xᵀy
这就是热词里经常提到的"线性回归正规矩阵方法"。它给出了线性回归的闭式解,不需要迭代训练就能直接算出来。但需要注意的是,实际工程中 sklearn 并不会真的用这个公式,而是用奇异值分解(SVD)来求解。原因是直接计算 XᵀX 的逆矩阵在特征维度较高或特征相关性较强时数值不稳定,SVD 的数值稳定性好得多。理解正规方程的意义在于明白线性回归的本质是一个凸优化问题,而不是真的手写矩阵求逆。
3.2 训练代码与关键参数
sklearn 的用法非常简单,但有几个参数值得说清楚:
from sklearn.linear_model import LinearRegression model = LinearRegression(fit_intercept=True) model.fit(X_train, y_train) print("特征系数:", dict(zip(feature_cols, model.coef_))) print("截距:", model.intercept_)fit_intercept=True 表示模型自己学习一个偏置项。如果你的特征做过标准化,视觉上截距会接近训练集标签的均值;这里特征不标准化,截距也会被直接拟合出来。
看系数的作用更有意思。系数表示"在其他特征不变时,该特征每增加 1 个单位,预测值平均变化多少"。如果 lag7 的系数明显大于 lag1,说明模型认为"一周前同期水平"对当天的预测贡献更大,这符合疫情数据里存在周内节律的特征。如果 t 的系数是负的,说明模型整体学到的是下降趋势。
训练完成后不着急看测试集,先在训练集上画一条拟合曲线,确认模型没有在代码层面出问题。我训练完会立刻打印前几个样本的真实值和预测值,肉眼看一下量级是否一致。这一步花不了几秒钟,却能排查掉大量类似"单位不一致""特征顺序搞错"的低级bug。
4. 结果评估:用 RMSE、残差图判断模型到底行不行
4.1 回归预测的评估指标选哪个
不要只盯着一个指标看。我用三个指标一起评估,它们各自反映不同侧面:
| 指标 | 全称 | 公式 | 特点 |
|---|---|---|---|
| MAE | 平均绝对误差 | mean(|y_pred - y_true|) | 直观,单位与原始数据相同,对大误差不敏感 |
| RMSE | 均方根误差 | sqrt(mean((y_pred - y_true)²)) | 对大误差更敏感,模型出现严重偏离时会明显变大 |
| R² | 决定系数 | 1 - SS_res / SS_tot | 表示模型解释了目标变量多少方差,但时间序列里容易虚高 |
代码就三行:
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score y_pred = model.predict(X_test) mae = mean_absolute_error(y_test, y_pred) rmse = mean_squared_error(y_test, y_pred, squared=False) r2 = r2_score(y_test, y_pred) print(f"MAE={mae:.2f}, RMSE={rmse:.2f}, R²={r2:.4f}")经验上,如果 RMSE 比 MAE 大很多,说明模型在个别日子上出现了大偏差;如果两者接近,说明误差分布相对均匀。对我这份模拟数据,典型结果大致是 MAE 在 10 左右、RMSE 在 12 左右。这些数字要和原始数据的量级一起看:如果每日新增病例在 50-200 之间波动,MAE 是 10,说明平均每天偏差十几例,对趋势预测来说已经凑合;如果原始数据只有 30 例,MAE 是 10,那模型基本没什么用。
R² 在时间序列预测里要格外小心。当目标变量本身有明显上升趋势时,哪怕模型只是大概沿着趋势走,R² 也可能高达 0.9 以上。这并不代表预测精准,只是说明"跟着趋势走"已经赢过了"直接用均值预测"。所以我的习惯是:主要看 MAE 和 RMSE 的实际量级,R² 只做参考。
4.2 画预测曲线和残差图,比指标数字更诚实
指标是评估的骨架,可视化才是评估的血肉。至少画两张图:一张是测试集的真实曲线与预测曲线对比,另一张是残差图。
第一张图能直接看出模型在哪里飘了:
import matplotlib.pyplot as plt plt.figure(figsize=(10, 4)) plt.plot(test["date"], y_test.values, label="真实值") plt.plot(test["date"], y_pred, label="预测值") plt.legend() plt.xticks(rotation=45) plt.title("测试集上的线性回归预测") plt.tight_layout() plt.show()第二张图更有诊断价值。把残差(y_true - y_pred)按时间顺序画出来,如果残差像白噪声一样在 0 上下随机波动,说明模型已经捕获了数据里最主要的模式;如果残差出现明显的周期性或趋势,说明数据里还有模型没学到的东西——通常是非线性关系。
residual = y_test.values - y_pred plt.figure(figsize=(10, 3)) plt.plot(test["date"], residual) plt.axhline(0, color="gray", linestyle="--") plt.xticks(rotation=45) plt.title("残差随时间变化") plt.tight_layout() plt.show()我在真实数据上做这个练习时,残差图几乎总是一个形状:前期残差是正的或负的,后期变成相反的符号。这说明线性模型拟合的直线只是"穿越"了真实的曲线,在早期和后期都会系统性偏移。这就是模型在向你说:"这里有一条弯曲的趋势,我这条直线装不下。"
5. 真实预测中避不开的坑:拐点、泄漏与误差累积
5.1 拐点问题:线性模型反应迟钝
疫情走势里最要命的是拐点。真实新增病例不会一直线性涨,干预措施生效后增速会放缓、到达峰值、然后回落。而线性模型只有一个全局斜率,它为了拟合整个历史窗口,会把上升期和回落期平均成一条较平缓的直线。结果就是:上升期它系统性低估,回落期它系统性高估,拐点附近简直是在"凭感觉瞎猜"。
这几乎是所有线性模型做疫情预测的通病。缓解办法只有一个:缩小训练窗口。不要拿全部 120 天去训练,而是只取最近 14 天或 21 天。模型不需要学会"整个历史的平均趋势",它只需要学会"最近的走势方向"。在快速变化的数据里,近期窗口往往比长期窗口更有价值。缺点是窗口太短会导致样本太少,特征稍微多一点就容易过拟合,所以每加一个特征,就要权衡一次样本量是否扛得住。
5.2 特征泄漏:滚动均值的那个 shift(1)
第 2 章提过的滚动均值 shift(1) 在这里必须展开讲。下面两种写法看起来几乎一样,效果天差地别:
# 错误:滚动均值包含当天真实值 df["rolling7_bad"] = df["new_cases"].rolling(7).mean() # 正确:滚动均值只用当天之前的数据 df["rolling7_ok"] = df["new_cases"].shift(1).rolling(7).mean()错误写法里,第 t 天的滚动均值实际是第 t-6 天到第 t 天的平均值,里面包含了模型要预测的当天真实值。训练时模型会学到"只要滚动均值很大,当天值就很大"这种近乎作弊的映射,测试集指标好看到飞起。但真正部署到未来预测时,未来日期的滚动均值只能用已有历史值计算,它不再包含"当天真实值"这一信息,预测能力立刻大幅缩水。
这就是标准的特征泄漏。不只是滚动均值,任何涉及"当前样本之后的信息"的特征都会造成这种假象。排查方法很简单:构造完每个特征后,检查"构造该特征时是否用到了当天的目标变量值"。如果用到了,就必须 shift。
5.3 多步预测的误差累积
很多教程做完单步预测就收工了:用前 N 天预测第 N+1 天,评估完结束。但实际需求往往是"预测未来 7 天""预测未来 14 天"。
多步预测时,如果第 N+1 天用真实历史值预测,第 N+2 天继续用真实历史值预测,这不叫多步预测,这叫多次单步预测,误差不会累积。真实的多步预测是:先用已知数据预测第 N+1 天,然后把第 N+1 天的预测值当作"已知数据"去预测第 N+2 天,如此滚动下去。这样每一步的误差都会传给下一步,几步之后预测值会逐渐漂移,离真实曲线越来越远。
我实测的体会是:线性回归滚动预测 3 步之内还在可控范围,超过 5 步基本只能看趋势方向,数值已经不值得细究。要缓解误差累积,有两条路:一是每隔几步重新用真实观测值校准一次,现实中就是"等新一天的数据出来就重新预测一遍";二是直接为每个预测步数训练独立模型,比如专门训练一个"用过去 7 天预测未来第 7 天"的模型。后者样本利用率差一些,但能绕开滚动递推带来的漂移问题。
5.4 指标虚高与单日上报噪声
还有一个体验很微妙的坑:R² 虚高。在趋势明显的序列上,哪怕预测天天偏 20 例,R² 也可能显示 0.95。初学者容易因此误以为模型很好。解决方法是同时打印 MAE 和 RMSE,用"预测值和真实值平均差了多少例"来替代抽象的百分比解释力。
单日噪声也要特别注意。新增病例数据经常有上报积压、修正、节假日检测量骤降等影响,单日值可能比前后几天低一半或者高一半。这种尖峰对线性回归影响很大,因为平方误差会把异常值的权重放到极大。一个折中的做法是不要把预测目标直接定为"单日新增病例数",而是定为"7 日均值"。7 日均值平滑了噪声,模型学起来更容易,预测曲线也更贴合公共卫生决策里真正关心的"趋势"概念。
| 常见坑 | 根本原因 | 有效对策 |
|---|---|---|
| 拐点后预测失灵 | 线性模型只有一个全局斜率 | 缩小训练窗口,只学近期走势 |
| 指标虚高 | 滚动均值/滞后特征泄漏未来信息 | 检查特征是否包含当天真实值,该 shift 就 shift |
| 多步预测漂移 | 预测误差不断累积 | 滚动递推预测,或按预测步数单独训练 |
| 单日噪声干扰 | 上报波动被平方误差放大 | 预测 7 日均值,而不是单日值 |
| R² 误导 | 趋势项让 R² 虚高 | 同时看 RMSE/MAE 实际量级 |
6. 线性回归之外:多项式回归与更专业的模型
6.1 多项式回归:给直线加一点弯曲
当残差图显示明显的曲线趋势时,最自然的升级方向是多项式回归。思路是在特征矩阵里加入 t²、t³ 甚至更高次项,让模型拟合一条曲线而不是直线。sklearn 里直接用 PolynomialFeatures 完成特征扩展:
from sklearn.preprocessing import PolynomialFeatures from sklearn.pipeline import make_pipeline poly_model = make_pipeline( PolynomialFeatures(degree=3, include_bias=False), LinearRegression() ) poly_model.fit(X_train[["t"]], y_train)多项式回归能拟合出上升、峰值、回落的形态,在训练窗口内看起来比线性回归"聪明"很多。但代价有两个:一是模型可解释性变差,系数不再有"每单位 x 变化带来多少 y 变化"的直白含义;二是过拟合风险急剧升高,阶数越高,曲线在数据边界外就越放飞自我,一旦外推几个时间步,预测值可能以三次方的速度冲到完全离谱的数值。
我的建议是:多项式回归适用于"拟合历史趋势、做较短步数的外推",阶数不要超过 3。如果阶数到 4、5 才能拟合好训练集,几乎可以肯定是在过拟合噪声,而不是在学真实趋势。
6.2 更接近真实传播过程的模型:SIR 与后续方向
如果目标是纯粹预测"每天新增多少例",线性回归和多项式回归终究只是数学拟合工具,它们不懂传染病传播机制。更贴近问题本质的方法是 SIR/SEIR 这类仓室模型:把人群分为易感者(Susceptible)、感染者(Infectious)、康复者(Recovered)等仓室,用常微分方程描述人群在这些状态之间的流转。SIR 模型的一个核心优势是它能自然刻画"感染人数上升导致易感人群减少、传播速度随之放缓"的过程,也就是拐点是从机制里内生出来的,而不是靠一条多项式曲线硬拗出来的。
对学习机器学习的同学来说,从线性回归走向 SIR 模型是一条很顺畅的进阶路径。你不需要马上学会解偏微分方程,只需要理解它的建模思想:把对"数字曲线"的拟合升级成对"传播机制"的建模。后续还可以尝试 Facebook Prophet 这类专门为时间序列设计的工具,或者 LSTM 这类深度模型。但说句实在话,我做了不少类似的数据实验后,最大的体会是:在拿到干净、可靠的数据之前,讨论模型优劣没有意义。模型再先进,也扛不住脏数据、延迟上报和变化的外部环境因素。
回到线性回归本身,它就像一把最朴素的尺子。尺子量不出人体温度,但任何人做测量之前都需要一把尺子来定标。我的习惯始终是:面对一份陌生的时序数据,第一件事永远是用线性回归快速建立基线,看它的误差模式,看残差图,再决定要不要上更复杂的工具。等哪天你的线性回归基线已经稳定复现了,再打开 Prophet 或者 LSTM,你会发现自己对数据的理解,比一上来就跑复杂模型的那批人要扎实得多。