ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用Python实现SIR/SEIR传染病模型:从微分方程到仿真实战

用Python实现SIR/SEIR传染病模型:从微分方程到仿真实战 1. 先搞清楚传染疾病模型究竟在建模什么第一次接触“传染疾病模型”这个词是在我翻一本讲数学建模的书时看到的。当时的第一反应是这名字听着挺医学但翻了几页就发现根本不是那回事。它本质上是在用数学语言描述“一群人里疾病是怎么从一个人传到另一个人又怎么从多传少、最终停下来”的过程。你不需要懂医学才能做这件事你甚至不需要懂太深的数学只要逻辑清楚会写一点Python就能把一个基础模型跑起来。这些年我拿这套模型做过不少事情模拟校园里某类传染病的传播过程、给社区做小规模推演、用它给学生讲“参数敏感性分析”到底是怎么回事。每一步都踩过不少坑但回过头来看这东西真正厉害的地方不在于“预测得准”而在于它逼着你去把一个模糊的问题拆成清晰的假设再用代码把这些假设变成可以反复实验的沙盘。这个模型适合谁来了解两类人。一类是做数据分析、数学建模方向的人你可以把它当作经典的动力学系统来学和练手另一类是公共卫生、行政管理背景的人你不一定要自己写代码但理解了模型内部的逻辑就能更好地判断“一个结论是不是靠谱”。对纯编程背景的朋友来说它也是一个很不错的跨领域练习代码量不大但每一步都要求你对物理意义有理解不能瞎调参。这篇内容我会从模型最基础的假设讲起然后给出可直接运行的Python代码再讲怎么从基础模型往更复杂的方向升级最后把我踩过的坑和排查思路一并整理出来。内容尽量按实操来写你照着走一遍基本就能跑出自己的仿真曲线。1.1 从状态划分说起S、I、R分别代表什么任何传染疾病模型第一件事都是把人划分成不同的“舱室”。这个名字听着复杂其实就是把人身上的状态归类。最常见的分类是三个状态SSusceptible易感者也就是还没得病、但可能被传染的人。IInfected感染者已经得病、并且有能力传染给别人的人。RRemoved移除者这里最容易被误解。R不一定是“痊愈康复”它表示“不再参与传播”的人既包括康复后获得免疫力的人也包括因病死亡或隔离收治的人。在建模时这两类人放在一起处理因为他们对传播过程的影响是相似的——都不再传染别人了。你看这个模型把事情大大简化了它不关心张三住在哪里、李四是不是老年人它只关心“每个人处于哪个状态”。这正是数学建模的核心思路先把问题抽象到最简单、能下笔的程度再逐步加复杂度。除了状态划分模型还默认了两个前提假设。第一个是“均匀混合假设”意思是任意两个个体之间的接触概率相同不存在“离得近更容易传染”的区别。第二个是人口总数不变即不考虑出生、死亡和人口流动。这两个假设显然不完美但它们是所有传染病模型的地基你必须先接受它们才能往下走。理解这一点很重要因为后面的很多复杂变体本质上都是在往“更真实”的方向逐步打破这些假设。1.2 模型的核心转化速率与三条微分方程有了状态接下来要回答的问题是人是怎么从一个状态跑到另一个状态去的SIR模型用三条微分方程来描述这个动态过程dS/dt -βSIdI/dt βSI - γIdR/dt γI先看参数。β是有效接触率可以理解为“一个感染者每天能有效传染给多少个易感者”。它结合了两层含义一层是接触频率另一层是每次接触中真的造成传染的概率。γ是恢复率它表示感染者每天有多大比例转变为移除者如果平均病程是D天那么γ就约等于1/D。再看结构。βSI这个项是整个模型的心脏它同时正比于S和I。为什么你想想看感染者越多传播机会越多这好理解易感者越多可供感染的人群越大传播机会也越多。两者相乘自然就同时放大了。当易感者越来越少βSI会自动变小传染过程开始放缓这也是为什么任何传染病流行曲线都会先升后降——不是因为病毒变弱了而是因为“容易被传染的人”变少了。这里有个很关键的点R只是I单向流入的S不会直接变成R。这意味着模型假设所有感染者都要先经历可传染期然后才恢复。如果某个病是“潜伏期结束就能传染、并且潜伏期里没有症状”那基础SIR就不够用了需要往SEIR方向升级。这个我后面专门讲。1.3 为什么R0比感染人数更能说明问题做传染病建模的人几乎天天挂在嘴边的一个词是R0。它叫基本再生数意思是“在一个完全易感的人群里一个感染者平均能传染给几个人”。R0是一个无量纲的数它是模型参数β和γ的比值R0 β/γ。为什么R0重要因为它能告诉你这病会不会爆发。如果R0小于1每个人传染给的人不足一个传播链会自然断掉疫情自己就消退了。如果R0大于1感染人数会先上升直到易感人群被消耗到一定程度传播才会放缓。R0数值越大初始阶段的增长越猛控制的难度也越大。实际应用中R0很难直接测通常是通过观察数据反推出来的。但建模时你不需要把它当输入你只需要设β和γR0就自动出来了。我建议你每次跑完模型后第一件事就是把R0算出来因为它能给你一个直观的判断这一组参数下疫情是“涨”还是“跌”。这比盯着曲线看图要靠谱得多。2. 最小可用实现用Python跑通一个SIR模型理论说得再多不如自己跑一遍。这一节我不讲花哨的东西直接给你一套能跑通的Python脚本。你不需要有很强的编程基础只要能装Python库、会运行.py文件就够。2.1 环境准备与依赖我的建议是用Anaconda或纯Python pip 来准备环境。核心依赖只有三个numpy、scipy和matplotlib。如果你用的是Anaconda这三个库大概率已经装好了。如果缺哪个直接跑一句pip install numpy scipy matplotlib我强烈建议你直接用一个notebook来做这件事因为传染病模型的调试过程往往是“设参数 - 看曲线 - 调参数”的循环notebook的交互式体验比命令行舒服得多。这里补充一个经验不要一上来就追求复杂的库。有的教程会用专门做动力学仿真的库比如SimPy来做Agent-based模拟那是后期的事情。对于基础的SIR模型scipy.integrate.odeint这一个函数就够了。把精力花在理解模型结构和参数意义上比折腾工程化实现要重要得多。2.2 代码实现与参数选择下面这段代码就是最简SIR实现。先看代码再解释每一行的作用。import numpy as np from scipy.integrate import odeint import matplotlib.pyplot as plt # 模型微分方程组 def sir_model(y, t, beta, gamma): S, I, R y dSdt -beta * S * I dIdt beta * S * I - gamma * I dRdt gamma * I return [dSdt, dIdt, dRdt] # 参数设置 N 10000 # 总人口 I0 10 # 初始感染者数量 R0 0 # 初始移除者数量 S0 N - I0 - R0 # 初始易感者数量 beta 0.3 # 有效接触率 gamma 0.1 # 恢复率平均病程10天 R0_value beta / gamma print(f基本再生数 R0 {R0_value}) # 时间范围天 t np.linspace(0, 160, 1600) # 数值求解 solution odeint(sir_model, [S0, I0, R0], t, args(beta, gamma)) S, I, R solution.T # 绘制曲线 plt.figure(figsize(10, 6)) plt.plot(t, S, labelSusceptible, linewidth2) plt.plot(t, I, labelInfected, linewidth2) plt.plot(t, R, labelRemoved, linewidth2) plt.xlabel(Days) plt.ylabel(Number of People) plt.title(SIR Model Simulation) plt.legend() plt.grid(True) plt.show()参数方面我给一个常见配置人口N设为一万人初始感染者10个人β取0.3γ取0.1。γ0.1意味着平均病程是10天这对很多常见呼吸道传染病来说是一个合理的量级。β0.3意味着一个感染者每天大约能有效传染给0.3个易感者配上10天病程R0就是3。这个数字意味着什么它表示如果不加任何干预一个病人平均传染3个人疫情会呈现显著上升。你可以试着改β和γ观察曲线的变化形状。我建议至少做三组对照实验R00.8疫情不会扩散、R01.5中等传播、R03快速暴发。你会发现R0从小于1跨到大于1时曲线的形态会发生质的变化——从“一路往下”变成“先涨后跌”。这就是阈值现象也是传染病模型里最迷人的部分。2.3 模拟结果解读与R0的联动分析跑完代码之后你会看到三条曲线。I曲线通常是你最关心的它一开始缓慢上升然后加速上升到一个峰值之后下降。峰值对应的纵坐标叫“感染峰值人数”横坐标叫“峰值时间”。这两个指标在实际分析中非常重要因为它们决定了医疗资源的压力有多大、什么时候到来。我自己每次都会额外输出几行统计信息方便对比不同参数下的结果peak_infected np.max(I) peak_day t[np.argmax(I)] print(f感染峰值人数: {peak_infected:.0f}) print(f到达峰值时间: {peak_day:.1f} 天) final_R R[-1] print(f最终移除者总数: {final_R:.0f})你会发现一件有意思的事即便R0相同只要β和γ的绝对值不同曲线的形态也会不一样。R03既可以是β0.3、γ0.1的结果也可以是β0.6、γ0.2的结果。后者的病程变短了但传染效率加倍了。从R0来看两者似乎一样但仿真曲线上后者的峰值会更高、来得更快。这说明R0并不足以完全描述疫情过程它只是一个汇总指标。实际分析时我一般会给出两组曲线一组是“每日新增感染人数”的曲线另一组是“累计感染人数”的曲线。每日新增对应的是d(t1)-d(t)它在日常决策中的意义更直接——新增趋势是上升还是下降决定了当下是不是最紧张的时候累计曲线则反映整个事件的规模有多大。ggplot或matplotlib都可以画关键是你要习惯同时看这两种视图才能更完整地理解模型输出的信息。3. 从SIR升级到SEIR把“潜伏期”放进模型SIR模型虽然好用但它有一个明显的短板它默认感染者从“被传染”那一刻起就能传播给别人。这在现实中并不总是成立。很多疾病其实存在一个潜伏期——人被感染了但还没出现症状、也还没开始传播。这时候如果你还拿SIR来近似就会得到一个偏乐观的预测传播被提前了一截峰值时间和峰值高度都会失真。3.1 为什么需要E状态什么时候必须升级SEIR模型的“E”代表Exposed也就是“暴露者”或“潜伏期感染者”。它处于S和I之间相当于一个中间缓冲S易感者接触I后会以βSI的概率进入E潜伏者E以速率σ转化为I感染者I以速率γ转化为R。对应的微分方程是dS/dt -βSIdE/dt βSI - σEdI/dt σE - γIdR/dt γI注意易感者在SEIR里接触的是“感染者I”而不是“潜伏者E”这表示潜伏期没有传染能力。如果你所研究的情形是“潜伏期也能传染”那就得再把方程改成易感者同时接触I和E这就是SEIR的一个变体了。什么时候必须升级我的经验是只要潜伏期长度占到整个病程的30%以上就值得考虑。比如一个病平均潜伏期5天、病程10天潜伏期占了三分之一这时候SIR的近似就太粗暴了。另一个判断标准是你的研究问题本身是否关心“干预黄金窗口”——如果有“接触者追踪”“密切接触隔离”这类动作那你必须清楚区分E和I因为潜伏期的人可能没有任何症状却已经携带病毒。从代码角度来说SEIR只是多了一个状态、多了一个参数σ难度并没有增加多少。但参数标定的复杂性却上来了因为σ和γ不容易从统计数据里直接分开通常需要结合病原学资料才能估计出一个大概值。3.2 SEIR代码与参数标定经验下面是我常用的SEIR实现。结构跟SIR差不多只是多了一个E状态。import numpy as np from scipy.integrate import odeint import matplotlib.pyplot as plt def seir_model(y, t, beta, sigma, gamma): S, E, I, R y dSdt -beta * S * I dEdt beta * S * I - sigma * E dIdt sigma * E - gamma * I dRdt gamma * I return [dSdt, dEdt, dIdt, dRdt] N 10000 E0 0 I0 10 S0 N - I0 - E0 R0_val 0 beta 0.3 sigma 0.2 # 平均潜伏期 1/sigma gamma 0.1 # 平均病程 1/gamma print(f平均潜伏期: {1/sigma:.1f} 天) print(f基本再生数 R0 {beta / gamma:.2f}) t np.linspace(0, 160, 1600) solution odeint(seir_model, [S0, E0, I0, R0_val], t, args(beta, sigma, gamma)) S, E, I, R solution.T plt.figure(figsize(10, 6)) plt.plot(t, S, labelSusceptible) plt.plot(t, E, labelExposed) plt.plot(t, I, labelInfected) plt.plot(t, R, labelRemoved) plt.xlabel(Days) plt.ylabel(Number of People) plt.title(SEIR Model Simulation) plt.legend() plt.grid(True) plt.show()参数标定方面我建议你用“平均潜伏期”去反推σ。如果某疾病潜伏期大概是5天那就设σ1/50.2。注意这里的单位是天倒数它的含义是“潜伏者每天转化为感染者的比例”而不是“一个人要潜伏5天然后啪一下变I”。这跟γ是一样的逻辑都是从指数分布视角来看停留时间而不是固定时间。理解这个区别对正确解释模型输出很重要。还有一点值得注意σ取值的不同会显著改变I曲线的“斜率”和峰值高度但对最终R总感染人数影响相对有限。这意味着什么意思是说如果你只关心“大结局”的人数用SEIR和SIR可能差不太多但如果你关心的是“峰值期会不会压垮资源”那SEIR是不可省的。3.3 用模型做“假设推演”而不是“预测”这里我想特别强调一个观念问题。很多人一拿到模型就问“你预测一下最终会有多少人感染”我每次听到这种问题都会有点头疼。传播动力学模型本质上不是“算命工具”它是一个“假设推演工具”。什么意思它做的事情是如果你接受了A、B、C这几个假设那么在设定的参数值下未来的趋势大致会沿着这条曲线走。它检验的是“如果……会怎样”的问题而不是“现实一定怎样”的问题。比如你可以用模型回答如果人们减少接触速度30%感染峰值能降低多少如果干预措施晚执行一周峰值会高出多少这些都是“反事实推演”它们才是模型的价值所在。而如果你拿模型去做精确的真实世界预测那大概率会偏差很大因为真实世界里有太多模型没纳入的因素——人群异质性、地理结构、个体行为变化、防控措施变更等等每一个因素都可能让真实曲线偏离仿真结果。我自己的习惯是每跑一个场景都会同时跑一个“无干预基线”再跑几个“干预场景”然后把它们画在同一个坐标系里对比。这样出来的图才有说服力而不是孤零零的一条曲线。要记住模型的价值在于比较不在于绝对数值。4. 实操中的坑与排查思路模型跑了几年踩过的坑能装满一箩筐。这一节我不会按教科书顺序说而是挑出那些让我印象最深、最常见的几个问题每一个都是血泪教训。4.1 数值积分不稳、曲线跳变怎么办最典型的现象是结果曲线出现锯齿状或者在某个点突然蹦出负值。这通常不是模型公式错了而是数值积分参数不合适。odeint函数虽然有自适应步长但在某些“刚度”较大的条件下——比如参数取值极端、初始感染者非常少同时传染率很高——它也会出问题。我的第一招是增加输出的时间点数。在代码里我通常用t np.linspace(0, 160, 1600)有些人图省事写t np.linspace(0, 160, 100)虽然一般也能算但曲线会显得粗糙峰值容易失真。第二招是给odeint换算法。odeint有一个mxstep参数专门控制内部步数上限。如果你看到警告信息比如“Excess work done on this call”那就说明积分过程没收敛。可以增加mxstep5000甚至更多。这是最简单的处理方式solution odeint(seir_model, y0, t, args(beta, sigma, gamma), mxstep5000)第三招是换求解器。scipy的integrate.solve_ivp提供了更多算法选择对于刚性问题可以用methodRadau或methodLSODA。LSODA会自动在刚性/非刚性之间切换适合大多数传染病模型。4.2 参数拟合“看着满意但实际错误”的几种情况有时候你拿到一组真实数据想用模型去拟合出β和γ。这看起来很严谨实际上到处都是雷。最常见的坑有三个。第一个坑是最小化误差时把多组参数组合到一起造成“过拟合”。你拟合出来的参数在训练集上表现很好但换一段数据就不行了。这是因为SIR模型的结构简单能表达的行为模式有限而真实数据往往充满噪声模型会用不合理的参数值去硬凑噪声。缓解办法是加正则化或者用更稳健的拟合方法比如贝叶斯推断。第二个坑是忽略了初始条件。拟合策略里初始感染者数量I0往往是一个未知参数。有些人直接拍脑袋设I01然后让其他参数去适应这相当于用一个错误的地基去盖楼。更合理的做法是把I0也当作待优化参数一起纳入拟合过程。第三个坑是忽略观察延迟。实际报告的确诊人数往往滞后于实际感染时间存在一个报告延迟。如果你拿报告数据去拟合I曲线的实时状态拟合结果会有一个系统偏差。处理办法是在模型里再加一个“观察过程”模块让报告数等于过去某个时间点实际感染数的延迟版本。这会让复杂度上一个台阶但精度会明显改善。4.3 模型失真的常见原因与拆解除了数值问题还有一类问题更根本模型结构本身就不对导致结果失真。我总结了几类高频原因。一是人群异质性被忽略。SIR假设所有人接触频率相同但现实里学生、上班族、老年人的人际接触模式差异极大。这时候基础SIR做出来的结果会明显偏离实际。改进思路是分年龄段建多个舱室或者在SIR里加入接触矩阵。这类模型叫“结构化模型”参数更多但更贴近现实。二是空间结构缺失。SIR没有空间概念它假设所有人在同一个锅里搅拌。现实里传染病往往有地域聚集性。如果你想研究“两个城市之间的传播”那就不能用标准SIR得考虑元胞自动机或者网络模型。三是人群行为反馈缺失。真实疫情里人们看到新增确诊上升后会主动减少外出这会反过来影响β。但SIR的β是固定常量模型无法自动体现这种变化。解决方式是把β改成随时间变化的函数比如用一个分段函数来表示管控措施生效前后的传播率变化。我在实际工作中遇到最多的情况是模型算出来的曲线总比真实数据“更陡更猛”。这几乎总是因为我没有考虑行为反馈。后面我把β改成动态变化后拟合度立刻提升了一个档次。如果你也是做完基准模型后发现曲线过冲第一个要检查的就是这个问题。5. 个人实操体会与后续玩法模型本身讲得差不多了这一节我想以个人的视角聊聊实践。怎么做才能让这个模型真正用起来而不是停留在作业层面。5.1 我用这类模型做过的几件小事第一件是模拟学校环境里的传播过程。我把学生按班级分层假定班级内的接触强度远高于班与班之间的接触强度做成一个简化网络。把标准SIR扩展成“班级内传播”和“班级间传播”两个速率再用实测数据去拟合。这个练习虽然简单但让我真正理解了一个道理干预措施不一定要追求把传播率降到0只要能把它压低到阈值以下疫情就能自然消退。这种直观体会是光看课本学不到的。第二件是给某个社区做应急方案推演。我不预测具体数字而是把所有参数做成可以调的滑块让决策者自己去调节“限制出行”“减少聚集”“提高检测速度”对应的参数项看看不同的组合会带来什么样的曲线变化。这比拿一张固定结果的图去汇报要有说服力得多因为对方能直观感受到措施和结果之间的因果关系。第三件是教学。我用SIR模型来讲“参数敏感性分析”让学生逐个参数地扰动±10%记录输出指标的百分比变化。通过这个练习很多第一次接触这个概念的人很快就明白了为什么有的参数对结果影响巨大——因为这个模型本质上是一个非线性反馈系统小扰动在正反馈阶段会被放大而R0刚好跨越阈值的那一组参数是最最敏感的区间。5.2 从确定型到随机型让模型“活”起来普通SIR模型的输出是平滑曲线但真实传播过程是有随机性的。疫情早期感染者就那么几个人每一步传播都是一次随机事件可能连锁扩散也可能幸运地没传开。这时候用微分方程得出的结果只是一个“平均期望”而真实世界的走向可能偏离平均很远。要让模型“活”起来有两种路径。第一种是随机微分方程SDE在每个时间步里给转化速率加上噪声项第二种是离散事件仿真把每个个体当成一个Agent按概率随机决定它是否感染、何时恢复。后者就是Agent-based ModelABM它更灵活但也是数据消耗大户。我给新手的建议是不要一上来就做ABM。先把微分方程版本吃透理解清晰了再去加随机性。否则你很容易分不清模型里哪些现象是真实规律哪些只是随机噪声带来的偶然。5.3 给新手的入门路径建议如果你刚接触传染疾病模型我把最适合的路线整理为四步第一步把SIR手推一遍。不借助代码完全用纸笔推导从微分方程到曲线形态的因果链条。这个过程很枯燥但会让你在代码层面少走很多弯路。第二步把这一节的基础代码完整跑通尝试改变β和γ记录不同R0下的峰值时间和峰值人数做成一个对照表。第三步引入SEIR对照SIR的输出总结E状态的到来对曲线带来的变化。第四步找一组公开的模拟数据尝试用最小二乘法或curve_fit去拟合参数。注意先用你“自己生成的仿真数据”来测试拟合效果再决定要不要碰真实数据。我的体会是传染疾病模型最值得深入研究的不是数学也不是编程而是“建模思维”。它教你把复杂现象拆成状态、速率和反馈让你学会用系统性的眼光看问题。这套思维方式一旦养成再用到其他行业比如经济学、社会学、网络科学你会发现到处都是相似的结构。再往上走你可以了解网络传播模型研究“个体接触网络结构对传播阈值的影响”或者引入干预措施模块做策略优化。这个东西的扩展方向非常多关键是先把基础打扎实。真正动手跑过、折腾过、踩过坑之后这种从模型里得到的第一手直觉是任何一篇论文或教程都给不了你的。
返回列表