1. 项目概述:当数学模型遇见疫情数据
去年整理硬盘,翻出一个尘封的文件夹,里面是2020年初写的一个关于意大利新冠疫情预测的Python脚本。现在回头看,模型本身可能已经过时,但整个从数据获取、清洗、建模到评估的流程,以及当时踩过的那些坑,对于想用机器学习处理时间序列预测,特别是流行病学数据的朋友来说,依然有很强的参考价值。这个项目的核心,就是尝试用经典的传染病动力学模型——SEIR模型,去拟合意大利疫情早期的感染人数数据,并做短期预测。它不是要做一个多么前沿的AI模型,而是展示如何将严谨的数学理论、混乱的真实数据和灵活的Python编程结合起来,完成一个从理论到实践的完整闭环。无论你是对流行病建模感兴趣,还是想深入学习时间序列预测和微分方程数值求解,这个项目都能提供一个非常具体的实操案例。
2. 核心思路与模型选型:为什么是SEIR?
面对疫情数据预测,第一个问题就是:用什么模型?当时可选的方向很多,从简单的线性回归、ARIMA,到复杂的LSTM神经网络。我最终选择了SEIR模型,这是一类基于常微分方程(ODE)的房室模型,在传染病学中有着深厚的理论基础。
2.1 模型原理拆解:从SIR到SEIR
经典的SIR模型将人群分为三类:易感者(Susceptible, S)、感染者(Infectious, I)和康复者(Recovered, R)。其核心假设是,易感者与感染者接触后以一定速率被感染,感染者则以另一速率康复。这个模型简洁,但用于新冠这类有潜伏期的疾病就显得力不从心。
SEIR模型在SIR的基础上增加了一个暴露者(Exposed, E)类别,用来模拟潜伏期人群。这些人已经被感染,但尚未具备传染能力。模型的动力学过程可以用一组微分方程来描述:
dS/dt = -β * S * I / N dE/dt = β * S * I / N - σ * E dI/dt = σ * E - γ * I dR/dt = γ * I
这里,N是总人口(假设恒定),β是感染率,σ是潜伏期转染病期的速率(其倒数1/σ就是平均潜伏期),γ是康复率(其倒数1/γ就是平均感染期)。我们的目标,就是利用意大利每日新增的确诊病例数(这大致对应着从E到I的流量,即σ * E),来反推出最匹配这些数据的β、σ、γ参数,以及各个房室(S, E, I, R)的初始值。
注意:这里有一个关键简化。真实的确诊数据受检测能力、报告延迟等因素影响,并不完全等于模型中的
σ * E。在项目初期,我曾天真地认为可以直接等价,结果导致拟合严重偏差。后来引入了报告率(reporting rate)作为一个可调参数,才让模型结果变得合理。
2.2 为何不直接用深度学习?
当时也有同事问,为啥不用更“时髦”的LSTM?原因有几个:
- 可解释性:SEIR模型的每个参数都有明确的流行病学意义(如
R0 = β / γ代表基本再生数),我们可以通过拟合出的参数分析疫情的传播强度、干预措施(如封锁)的效果(体现为β的下降)。而LSTM是个黑盒,我们很难说清它到底“学”到了什么。 - 数据量要求:疫情初期,意大利的数据序列很短,可能就几十天。对于深度学习模型来说,这点数据量极易导致过拟合,模型会记住噪声而非规律。
- 理论基础:SEIR模型基于疾病传播的物理机制,即使在数据外推时,其行为也受到方程约束,不会产生过于荒谬的预测(当然,前提是参数估计准确)。纯数据驱动的模型在训练数据分布之外可能表现不稳定。
当然,SEIR模型也有其局限性,比如假设人群均匀混合、参数恒定等。但在项目初期,它的简洁性和物理可解释性优势明显。
3. 数据获取与预处理:真实世界的“噪声”
模型的骨架有了,接下来需要血肉——数据。我主要使用了约翰斯·霍普金斯大学(JHU)在GitHub上维护的COVID-19数据集。这一步看似简单,却埋着最多的坑。
3.1 数据源与关键字段
JHU的数据按国家、地区每天更新,包含Confirmed(累计确诊)、Deaths(累计死亡)、Recovered(累计康复)等字段。对于意大利,我们需要的是全国层面的每日新增确诊数。
- 关键操作:计算每日新增。不能简单地对
Confirmed做差分,因为历史数据会有修正(retrospective corrections),某一天可能会突然增加很多病例,这实际上是补报了之前日期的数据。一个稳健的做法是使用pandas的.diff()计算差分后,再用滚动窗口进行平滑(例如7天移动平均),以减少报告波动的影响。 - 数据清洗:仔细检查是否存在负的新增值(数据修正可能导致),或异常大的峰值。对于负值,通常需要根据上下文进行插值或置零处理。
import pandas as pd import numpy as np # 假设df是从JHU CSV读取的DataFrame,包含'Italy'的'Confirmed'数据 df_italy = df[df['Country/Region'] == 'Italy'].groupby('Date')['Confirmed'].sum().reset_index() df_italy['Date'] = pd.to_datetime(df_italy['Date']) df_italy = df_italy.sort_values('Date').reset_index(drop=True) # 计算原始每日新增 df_italy['New_Confirmed_Raw'] = df_italy['Confirmed'].diff().fillna(0) # 应用7天移动平均进行平滑,作为模型拟合的目标数据 df_italy['New_Confirmed_Smoothed'] = df_italy['New_Confirmed_Raw'].rolling(window=7, center=True, min_periods=1).mean()3.2 潜伏期与感染期参数先验
在拟合模型前,我们需要为σ和γ设定一个合理的初始范围,这来自于当时的医学研究:
- 平均潜伏期(1/σ):早期研究多认为在5-6天左右。因此,
σ可初始化为1/5.5 ≈ 0.182 /天。 - 平均感染期(1/γ):从出现症状到康复或不再具有传染性,早期估计约为7-10天。因此,
γ可初始化为1/8.5 ≈ 0.118 /天。 这些值不作为固定值,而是作为后续优化算法的初始猜测和参数边界,帮助算法更快、更稳定地收敛到合理的解空间。
4. 模型实现与参数估计:用Python求解逆问题
核心挑战来了:如何找到一组参数,使得SEIR模型模拟出的每日新增病例曲线,最接近真实的平滑后数据?这是一个典型的逆问题求解,我选用scipy库中的优化器来完成。
4.1 微分方程数值求解
首先,我们需要一个函数,给定参数和初始条件,能计算出SEIR模型随时间的变化。这里使用scipy.integrate.solve_ivp这个常微分方程初值问题求解器。
from scipy.integrate import solve_ivp def seir_model(t, y, beta, sigma, gamma, N): """SEIR模型微分方程""" S, E, I, R = y dSdt = -beta * S * I / N dEdt = beta * S * I / N - sigma * E dIdt = sigma * E - gamma * I dRdt = gamma * I return [dSdt, dEdt, dIdt, dRdt] def simulate_seir(params, initial_conditions, t_span, t_eval, N): """模拟SEIR模型运行""" beta, sigma, gamma, report_rate = params S0, E0, I0, R0 = initial_conditions sol = solve_ivp( fun=seir_model, t_span=t_span, y0=[S0, E0, I0, R0], t_eval=t_eval, args=(beta, sigma, gamma, N), method='RK45', # 龙格-库塔法,精度和稳定性较好 rtol=1e-6, atol=1e-9 ) # 计算模拟的每日新增确诊(报告率修正后的新感染病例) # 注意:模型每日新感染为 sigma * E(t),乘以报告率后作为预测值 simulated_new_infections = sigma * sol.y[1] * report_rate # 因为我们拟合的是每日新增,所以返回的应该是每日值,而非累计值 return sol.t, sol.y, simulated_new_infections4.2 定义损失函数与优化
我们的目标是让模拟的每日新增simulated_new_infections尽可能接近观察到的df_italy['New_Confirmed_Smoothed']。这里使用均方根误差(RMSE)作为损失函数,并利用scipy.optimize.minimize进行最小化。
from scipy.optimize import minimize def loss_function(params, initial_conditions, t_eval, observed_new_cases, N): """计算模拟数据与观测数据之间的RMSE""" _, _, simulated_new_infections = simulate_seir(params, initial_conditions, (t_eval[0], t_eval[-1]), t_eval, N) # 确保长度一致,有时solve_ivp的返回可能会有轻微差异(通常不会) min_len = min(len(simulated_new_infections), len(observed_new_cases)) rmse = np.sqrt(np.mean((simulated_new_infections[:min_len] - observed_new_cases[:min_len]) ** 2)) return rmse # 设置初始猜测和边界 # params: [beta, sigma, gamma, report_rate] initial_guess = [0.4, 0.182, 0.118, 0.5] # 报告率初始猜50% bounds = [(0.01, 1.5), (1/14, 1/3), (1/20, 1/5), (0.1, 1.0)] # 给参数设定合理的物理边界 # 初始条件:假设初始只有很少的感染者和暴露者 N = 60e6 # 意大利人口约6000万 I0 = df_italy['New_Confirmed_Smoothed'].iloc[0] / initial_guess[3] / initial_guess[1] # 粗略反推初始I E0 = I0 * 2 # 假设暴露者是感染者的2倍 S0 = N - E0 - I0 R0 = 0 initial_conditions = [S0, E0, I0, R0] t_eval = np.arange(len(df_italy)) # 时间点,单位天 observed_data = df_italy['New_Confirmed_Smoothed'].values # 执行优化 result = minimize( loss_function, initial_guess, args=(initial_conditions, t_eval, observed_data, N), bounds=bounds, method='L-BFGS-B', # 适用于有边界约束的优化 options={'maxiter': 500, 'ftol': 1e-8} ) if result.success: fitted_params = result.x print(f"拟合参数: beta={fitted_params[0]:.4f}, sigma={fitted_params[1]:.4f}, gamma={fitted_params[2]:.4f}, 报告率={fitted_params[3]:.4f}") print(f"基本再生数 R0 = {fitted_params[0]/fitted_params[2]:.2f}") else: print("优化失败:", result.message)实操心得:参数优化非常依赖于初始猜测和边界。不合理的边界可能导致优化器陷入局部最优或无法收敛。建议先根据文献设定一个宽泛但合理的边界,运行优化后,分析结果参数是否在常识范围内。如果
beta或R0高得离谱,或者报告率极低,可能需要检查数据平滑处理是否得当,或者初始条件设置是否有问题。有时需要多次尝试,手动调整初始猜测。
5. 结果分析、预测与可视化
得到拟合参数后,我们就可以用完整的模型进行模拟,并做短期预测了。
5.1 拟合效果评估与可视化
将拟合参数代入模型,运行整个时间段的模拟,然后与真实数据对比。可视化是必不可少的步骤,使用matplotlib绘制双轴曲线图。
import matplotlib.pyplot as plt import matplotlib.dates as mdates # 使用拟合参数进行模拟 t_sim, y_sim, simulated_new = simulate_seir(fitted_params, initial_conditions, (0, len(t_eval)+30), np.arange(len(t_eval)+30), N) S_sim, E_sim, I_sim, R_sim = y_sim fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10)) # 子图1:SEIR各房室人群比例随时间变化 ax1.plot(t_sim, S_sim/N, label='易感者 S', linewidth=2) ax1.plot(t_sim, E_sim/N, label='暴露者 E', linewidth=2) ax1.plot(t_sim, I_sim/N, label='感染者 I', linewidth=2) ax1.plot(t_sim, R_sim/N, label='康复者 R', linewidth=2) ax1.set_xlabel('天数') ax1.set_ylabel('人口比例') ax1.set_title('SEIR模型模拟人群动态') ax1.legend() ax1.grid(True, alpha=0.3) # 子图2:每日新增病例对比(拟合与预测) ax2.plot(t_eval, observed_data, 'o', label='观测数据(平滑后)', markersize=4, alpha=0.7) ax2.plot(t_sim, simulated_new, '-', label='SEIR模型拟合', linewidth=2) # 标记训练集结束点,之后是预测区间 ax2.axvline(x=len(t_eval)-1, color='red', linestyle='--', alpha=0.7, label='预测开始点') ax2.fill_betweenx(y=ax2.get_ylim(), x1=len(t_eval)-1, x2=len(t_sim)-1, color='gray', alpha=0.1) ax2.set_xlabel('天数') ax2.set_ylabel('每日新增确诊') ax2.set_title('每日新增病例:模型拟合 vs 观测数据') ax2.legend() ax2.grid(True, alpha=0.3) plt.tight_layout() plt.show()通过图表,我们可以直观判断拟合效果:曲线是否抓住了数据的主要趋势(上升、峰值、下降)?在训练期结束时,模型的状态(S, E, I, R)是否合理?这是定性评估。
定量评估可以使用R平方(R²)或计算训练集上的RMSE、平均绝对百分比误差(MAPE)等指标。一个常见的陷阱是“过度拟合”短期波动。我们的模型是确定性的,不应该去拟合数据中的随机噪声(如周末报告延迟)。一个好的拟合应该捕捉的是疫情发展的内在趋势。
5.2 短期预测及其不确定性
用拟合好的模型向前模拟未来30天,就得到了预测曲线。但必须清醒认识到这种预测的局限性:
- 参数恒定假设:模型假设
β、γ不变。但现实中,防控措施(封锁、社交距离)会降低β,医疗资源挤兑可能影响γ。因此,预测仅在近期(几天到一周)可能有一定参考性,时间一长必然偏离。 - 未考虑外部因素:模型没有考虑病毒变异、检测策略大幅变化、疫苗接种(当时尚无)等。
- 不确定性量化:上述优化只给出了参数的最佳估计,但没有给出参数的不确定性范围。更严谨的做法是使用马尔可夫链蒙特卡洛(MCMC)等方法进行贝叶斯推断,得到参数的分布,进而生成预测区间(Prediction Interval)。对于快速原型,可以简单地对关键参数(如
β)进行情景分析(Scenario Analysis),例如假设β下降10%、20%或上升,看预测结果如何变化。
# 简单的情景分析示例:假设感染率beta因防控措施下降 beta_scenarios = { '基线(拟合值)': fitted_params[0], '防控加强(beta降低20%)': fitted_params[0] * 0.8, '防控减弱(beta增加20%)': fitted_params[0] * 1.2, } fig, ax = plt.subplots(figsize=(10, 6)) colors = ['blue', 'green', 'red'] for (scenario_name, beta_val), color in zip(beta_scenarios.items(), colors): scenario_params = fitted_params.copy() scenario_params[0] = beta_val _, _, sim_new_scenario = simulate_seir(scenario_params, initial_conditions, (0, len(t_eval)+30), np.arange(len(t_eval)+30), N) ax.plot(t_sim, sim_new_scenario, '-', label=scenario_name, linewidth=2, color=color, alpha=0.8) ax.plot(t_eval, observed_data, 'ko', label='历史数据', markersize=3, alpha=0.5) ax.axvline(x=len(t_eval)-1, color='black', linestyle='--', alpha=0.7) ax.set_xlabel('天数') ax.set_ylabel('每日新增确诊') ax.set_title('不同感染率(beta)情景下的预测对比') ax.legend() ax.grid(True, alpha=0.3) plt.show()这张情景分析图比单一预测线更有价值,它清晰地展示了疫情未来发展的不同可能性,高度依赖于防控力度。这也就是为什么流行病学预测总是伴随着大量的“假设”条件。
6. 项目复盘、常见问题与避坑指南
回顾整个项目,从数据到预测,几乎每一步都有坑。这里总结几个最关键的问题和解决方法。
6.1 数据质量问题与处理技巧
- 数据修正与回溯:如前所述,JHU等公开数据集经常有回溯性修正。直接使用原始每日新增会产生负值和剧烈波动。务必进行平滑处理(如7天移动平均),这相当于一个低通滤波器,保留了趋势,滤除了高频噪声和报告异常。
- 初始条件敏感度:SEIR模型对初始感染人数
I0和暴露人数E0非常敏感。如果初始值设得太小,模型需要很长时间才能“启动”疫情;设得太大,则初期拟合会严重偏离。一个实用的技巧是:利用最早几天的数据,结合一个粗略的报告率和潜伏期参数,反向估算I0和E0。也可以将I0和E0作为参数一起优化,但这会增加优化难度。 - 人口流动与空间异质性:SEIR是均匀混合模型,忽略了意大利国内地区间的差异(如伦巴第大区疫情严重,而南部较轻)和国际输入病例。对于国家层面预测,这在早期可能是可接受的简化,但若要更精细,需考虑分区域的元胞自动机或网络模型。
6.2 模型选择与参数辨识难题
- 模型复杂度权衡:SEIR是基础模型。还有考虑无症状感染者的SEIAR模型、考虑住院和重症的SEIHCR模型等。增加房室能更精细地描述现实,但也带来了更多的参数。在数据有限的情况下,更复杂的模型可能导致“参数不可辨识”——即多组不同的参数能产生几乎相同的拟合效果,使得结果不可靠。原则是:从简单模型开始,只有当简单模型明显无法解释数据特征时,才考虑增加复杂度。
- 报告率(reporting rate)的估计:这是一个关键且难以确定的参数。它随时间(检测能力提升)和空间变化。在优化中将其作为一个自由参数,可能与其他参数(如
β)产生耦合。一个变通方法是,如果能有其他来源(如血清学调查)估计出某一时期的感染总数,可以以此来校准报告率。 - 优化算法陷入局部最优:
scipy.optimize.minimize的默认方法或L-BFGS-B可能找到局部最优解而非全局最优。可以尝试以下策略:- 使用全局优化算法(如
basinhopping或differential_evolution)进行初步搜索,再用局部优化算法精细化。 - 多次从不同的随机初始点开始优化,选择损失函数最小的结果。
- 先固定一些根据文献较确定的参数(如
σ,γ),只优化β和报告率,然后再全部放开优化。
- 使用全局优化算法(如
6.3 预测的沟通与伦理
这是做任何预测项目,尤其是涉及公共健康时,必须谨记的。
- 明确声明假设和局限性:任何预测结果都必须附带详细的假设说明(如参数恒定、无新变种、防控力度不变等)。
- 呈现不确定性:绝不只给出一条预测线。务必通过情景分析、预测区间等方式展示结果的不确定性。
- 短期而非长期:强调预测仅适用于短期(如1-2周),长期外推极不可靠。
- 目的在于洞察,而非精确预言:模型的价值更多在于理解疫情动态(如估算R0值、评估干预措施的效果潜力),而非给出确切的未来病例数。在项目报告或分享中,应把重点放在“基于当前数据和模型,如果我们什么都不做,疫情可能会…;如果加强防控,则可能…”。
这个意大利新冠疫情预测项目,虽然代码量不大,但完整地串联了数据科学、数学模型和领域知识。它教会我的最重要一课是:在现实世界的数据面前,再漂亮的模型也只是对复杂系统的近似。成功的建模,三分靠算法,七分靠对问题的理解和数据的谨慎处理。希望这个详细的拆解,能帮你绕过我当年踩过的那些坑,更扎实地掌握用Python和机器学习(或者说,更广义的计算建模)解决实际问题的流程与精髓。