简介:面向MATLAB初学者与数学建模入门者的专题课件,围绕编程与仿真、科学计算中的经典案例展开,帮助读者在掌握基本操作的同时理解微分方程建模思路。压缩包内仅含1个PPT文件,大小约177KB,篇幅精炼,适合课堂演示或自学速览。课件以“地中海鲨鱼问题”为主线,介绍Ancona与Volterra提出的食饵—捕食者模型:先给出无捕获时的动力学方程,再引入捕获能力系数e,构建考虑人工捕捞的修正模型;结合战前与战争期间捕捞强度变化,说明鲨鱼比例上升的原因。读者可据此学习符号说明、基本假设、模型建立与求解流程,并借助MATLAB数值方法绘制种群时间演化曲线、相图及鲨鱼占鱼类总数比例,比较e=0.3与e=0.1的差异。已有1731人学习,适合作为数学建模实例与向量化运算、数值计算、Simulink学习的补充材料。
1. 从一份渔业统计说起:地中海鲨鱼问题为什么值得用 MATLAB 重做一遍
直觉告诉我们,渔船少出海,海里的鱼就该变多。但上世纪初亚得里亚海的渔获统计给出了相反的走向:捕捞压力下降的那几年,鲨鱼等捕食性鱼类在总渔获里的占比不降反升。这个「少捕鱼反而鲨鱼多」的矛盾,就是数学建模教材反复引用的地中海鲨鱼问题,也是微分方程建模最经典的入门案例之一。
它适合当 MATLAB 初学者的第一课,原因是链路极短:三条文字假设就能写出一个两维常微分方程组,用 ode45 几秒钟跑出周期震荡曲线,再用相图看平衡点的位置。不需要数据清洗,不需要先啃机器学习,装好 MATLAB 就能从建模一路做到可视化、参数扫描和灵敏度分析。
下面按数学建模国赛里最常见的推进方式走:先定假设和参数,再用 MATLAB 求解、画图、验守恒量,最后把捕捞项加进方程,看那个反直觉结论能不能被数值实验复现。适合刚完成 matlab 下载安装、想拿一个完整案例把「编程、仿真、科学计算」三件事串起来的人。
2. Lotka-Volterra 方程怎么落到 MATLAB:假设、参数与求解三件套
2.1 三条假设如何变成两维微分方程组
地中海鲨鱼问题的建模起点只有三条假设:食用鱼在缺少鲨鱼时按内禀增长率 r 指数增长;鲨鱼在缺少食用鱼时按死亡率 m 指数衰减;两者相遇的速率与双方数量成正比,于是鲨鱼获得能量、食用鱼损失个体。写成微分方程就是 Lotka-Volterra 捕食模型,用 MATLAB 的行内写法表示:
dx/dt = r*x - a*x*ydy/dt = b*x*y - m*y
乘积项a*x*y是整个模型的关键,它不是拟合出来的经验项,而是「随机相遇」假设的直接产物:个体在海域里近似均匀分布时,单位时间内的相遇次数正比于两个种群规模的乘积。很多初学者第一反应是把非线性项线性化,改成a*(x+y)之类的形式,结果周期震荡立刻消失,周期解不复存在——这是建模环节最容易犯、也最容易被忽略的错误。
参数 b 负责把吃掉的食用鱼转化成鲨鱼的新生个体,因此 b 通常显著小于 a。从量纲上看,a 的单位是「1/(数量·时间)」,b 的单位是「1/数量」,两者不是一类东西,不能凭数值大小直接比较谁更「重要」。
2.2 四个参数的物理含义、取值与平衡点
| 参数 | 物理含义 | 量纲 | 本文取值 | 调大之后的典型影响 |
|---|---|---|---|---|
| r | 食用鱼内禀增长率 | 1/时间 | 1.0 | 振荡周期变短,鲨鱼平衡点上升 |
| a | 捕食遭遇系数 | 1/(数量·时间) | 0.1 | 振荡幅度变大,食用鱼谷值更低 |
| b | 食物转化效率 | 1/数量 | 0.02 | 食用鱼平衡点下降,鲨鱼平衡点上升 |
| m | 鲨鱼自然死亡率 | 1/时间 | 0.4 | 振荡周期变长,食用鱼平衡点上升 |
令两式同时为零,得到唯一的非零平衡点:x* = m/b = 20,y* = r/a = 10。这个平衡点是后面所有讨论的锚,捕捞项一加进来,先算的就是它怎么移动。
2.3 MATLAB 编程与仿真的三件套:函数句柄、ode45、结果矩阵
MATLAB 里解初值问题的标准范式是三件套:把方程组写成一个接收(t, y)并返回列向量的函数;用函数句柄把参数打包进去;交给 ode45,拿回时间列向量 t 和状态矩阵 Y。先写方程本体:
function dydt = sharkLotka(t, y, p) % 地中海鲨鱼问题的 Lotka-Volterra 方程组 % 输入:t 当前时刻;y = [x; z] 种群列向量;p 参数结构体 % 输出:dydt = [dx/dt; dz/dt] 列向量 x = y(1); % 食用鱼(被捕食者) z = y(2); % 鲨鱼(捕食者) dydt = [ p.r*x - p.a*x*z; % 自然增长 减去 被捕食损失 p.b*x*z - p.m*z ]; % 捕食转化增长 减去 自然死亡 end返回值必须是列向量,写成行向量时 ode45 要么报维度错误,要么在后续Y(:,1)取值时给出意料之外的结果。t 即使方程里用不到,也必须保留在签名中,这是所有 MATLAB ODE 求解器的统一约定。参数用结构体 p 传递,比 global 变量安全,也方便后面做参数扫描时反复修改。
提示:matlab 安装步骤里如果只勾选了核心组件,Optimization Toolbox 之类不会自动装上。后面做参数反演和灵敏度分析会用到,装的时候一起选上,省得回头补装。
顺带说一句,Python 生态里做同样的事是 scipy 的 solve_ivp,numpy 科学计算那一套在向量化上很强,但 MATLAB 的 odeset 参数控制和画图一体化确实省事,尤其是需要反复调 RelTol 对比数值误差的时候。现在用 AI 编程助手生成 ode45 的骨架很快,但参数取多少、守恒量漂没漂,仍然得自己判断。
3. 用 ode45 跑通鲨鱼-食用鱼仿真:从最小脚本到相图
3.1 最小可运行脚本与时间区间的选择
先把参数、初值和求解选项一次写全。积分区间取 60,是因为理论线性化周期T ≈ 2π/sqrt(r*m) ≈ 9.93,60 大致覆盖六个完整周期,足够看清震荡是否稳定。
% shark_lv_main.m —— 最小可运行脚本 clear; clc; p = struct('r',1.0, 'a',0.1, 'b',0.02, 'm',0.4); % 参数结构体 x_star = p.m/p.b; % 平衡点 x* = 20 y_star = p.r/p.a; % 平衡点 y* = 10 y0 = [40; 9]; % 初值:食用鱼 40,鲨鱼 9 opts = odeset('RelTol',1e-8, 'AbsTol',1e-10, 'MaxStep',0.5); [t, Y] = ode45(@(t,y) sharkLotka(t,y,p), [0 60], y0, opts); fprintf('平衡点 x*=%.1f y*=%.1f,返回 %d 个时间点\n', x_star, y_star, numel(t));匿名函数@(t,y) sharkLotka(t,y,p)把三参数接口适配成 ode45 要求的(t,y)接口,这是 MATLAB 里传参最常用的写法。[0 60]只给两个端点,中间步长完全由自适应算法决定,输出的 t 是不等间隔的——这一点在下面做积分平均时非常关键。
3.2 RelTol、AbsTol、MaxStep 三个参数怎么调
| 选项 | 默认值 | 作用 | 建议取值与理由 |
|---|---|---|---|
| RelTol | 1e-3 | 相对误差控制 | 周期系统取 1e-8,否则守恒量漂移肉眼可见 |
| AbsTol | 1e-6 | 绝对误差控制 | 取 1e-10,防止种群接近极小值时步长失控 |
| MaxStep | 自动(区间/10) | 强制最大步长 | 取 0.5,保证相图曲线光滑、不留折角 |
| Refine | 4 | 输出点插值加密 | 画图够用,做谱分析时改为 1 |
| NonNegative | off | 禁止负值 | 种群量可设为[1 2],但会掩盖参数选错的问题 |
RelTol 和 AbsTol 要同时收紧。只调 RelTol 时,一旦某个种群数量掉到接近 0,绝对误差就成了主导,解会出现肉眼可见的抖动,很多人误以为是「仿真发散」,其实是容差配置不当。
3.3 时间序列、相图与守恒量漂移一起画
Lotka-Volterra 系统存在第一积分V = b*x - m*ln(x) + a*y - r*ln(y),它是常数,正好可以当数值误差的尺子用。
x = Y(:,1); z = Y(:,2); V = p.b*x - p.m*log(x) + p.a*z - p.r*log(z); % 第一积分(守恒量) fprintf('守恒量最大漂移 = %.3e\n', max(abs(V - V(1)))); figure('Position',[100 100 960 320]) subplot(1,3,1) plot(t, x, t, z, 'LineWidth', 1.2); grid on xlabel('t'); ylabel('种群规模'); legend('食用鱼 x','鲨鱼 z'); title('时间序列') subplot(1,3,2) plot(x, z, 'LineWidth', 1.2); hold on plot(x_star, y_star, 'ro', 'MarkerFaceColor', 'r') % 平衡点 plot(x(1), z(1), 'ks', 'MarkerFaceColor', 'k') % 初值 grid on; xlabel('食用鱼 x'); ylabel('鲨鱼 z'); title('相图') subplot(1,3,3) plot(t, V - V(1), 'LineWidth', 1.2); grid on xlabel('t'); ylabel('\DeltaV'); title('守恒量漂移')相图里那条闭合曲线就是周期轨,红色圆点是平衡点,黑色方块是初值。闭合曲线环绕平衡点而不穿过它,说明这个平衡点是中心型,不是稳定结点——种群数量不会收敛到 20 和 10,而是永远绕着它转。守恒量漂移那一栏如果量级在 1e-6 以下,说明容差设置合理;如果冲到 1e-2,先别急着换求解器,把 RelTol 收紧两个数量级再看。
注意:x 或 z 一旦接近 0,
log(x)会往负无穷跑,方程本身也变得病态。初值不要设成 0 或极小正数,否则 ode45 会报积分容差无法满足。
4. 把捕捞项加进方程:鲨鱼占比为什么在禁渔后上升
4.1 捕捞项的形式与平衡点的移动
渔船不分鱼种,对食用鱼和鲨鱼都按比例捕捞,于是两式各减一项e*x和e*z,其中 e 是捕捞强度。修改后的方程组:
dx/dt = r*x - a*x*y - e*xdy/dt = b*x*y - m*y - e*z
重新令导数为零,平衡点移动为x* = (m+e)/b,y* = (r-e)/a。这一组式子把结论写得非常直白:e 减小时,鲨鱼平衡点上升,食用鱼平衡点下降。也就是说,禁渔之后鲨鱼反而更占优势——历史数据里的反直觉现象,在方程层面只是一个除法。
function dydt = sharkFishery(t, y, p) % 带捕捞项的捕食-被捕食模型 % p.e 为捕捞强度,对两个种群按比例作用 x = y(1); z = y(2); dydt = [ p.r*x - p.a*x*z - p.e*x; % 食用鱼:自然增长 - 被捕食 - 被捕捞 p.b*x*z - p.m*z - p.e*z ]; % 鲨鱼:捕食增长 - 死亡 - 被捕捞 end4.2 用时间平均复现「禁渔后鲨鱼占比上升」
周期震荡系统的瞬时值取决于初相,拿最后一个时间点的数据下结论是错的,必须用时间平均。ode45 输出的 t 不等间隔,所以积分要用trapz而不是sum除以点数。
opts = odeset('RelTol',1e-8, 'AbsTol',1e-10, 'MaxStep',0.5); for e = [0.2 0.1 0] p.e = e; [t, Y] = ode45(@(t,y) sharkFishery(t,y,p), [0 200], [40; 9], opts); x = Y(:,1); z = Y(:,2); ratio = z ./ (x + z); % 鲨鱼瞬时占比 ratio_bar = trapz(t, ratio) / (t(end) - t(1)); % 时间平均占比 fprintf('e=%.1f x*=%.1f y*=%.1f 平均鲨鱼占比=%.3f\n', ... e, (p.m+e)/p.b, (p.r-e)/p.a, ratio_bar); end跑出来的趋势是:e=0.2时占比约 0.21,e=0时升到约 0.33。捕捞强度下降,鲨鱼占比上升,方程预测与历史渔获统计的方向一致。做数学建模优秀论文里那类「模型检验」章节时,这样一组对照数字比任何定性描述都有说服力。
4.3 仿真发散与常见现象的排查对照
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 曲线越跑越大、明显发散 | 参数让平衡点落到负值区,例如e > r | 检查r-e是否为正,负平衡点无物理意义 |
| 报错「Unable to meet integration tolerances」 | 状态量接近 0 导致方程病态,或系统偏刚性 | 初值远离 0,或改用 ode15s |
| 结果几乎是一条水平直线 | 初值恰好取在平衡点上 | 把初值改成[40; 9]这类偏离点 |
| 相图出现折角、不平滑 | 输出点太稀疏 | 减小 MaxStep,或调大 Refine |
| 守恒量漂移量级偏大 | 容差过松 | RelTol 收紧到 1e-8、AbsTol 到 1e-10 |
| 平均占比每次跑都不一样 | 积分区间没覆盖整数个周期 | 延长到 200 以上,或改用整周期平均 |
把这张表当成排查清单,遇到异常先对照现象定位,比盲目换求解器有效得多。
5. 参数扫描与灵敏度分析:把鲨鱼问题做成可复用的建模模板
单次仿真跑通之后,真正的价值在于批量实验。最常见的问题是「周期到底由什么决定」,线性化分析给出T ≈ 2π/sqrt(r*m),但这个结论只在小扰动下成立,扰动一大就会偏离。用一段循环扫 r,同时用峰值间隔测数值周期,就能看到偏离有多大。
% 扫描 r,用相邻峰值的平均间隔测数值周期 r_grid = 0.6:0.1:1.6; T_num = nan(size(r_grid)); T_theo = 2*pi ./ sqrt(r_grid * p.m); % 线性化理论周期 p0 = p; % 备份原始参数 for k = 1:numel(r_grid) p0.r = r_grid(k); [tt, YY] = ode45(@(t,y) sharkLotka(t,y,p0), [0 200], [40; 9], opts); x = YY(:,1); idx = find(diff(sign(diff(x))) < 0) + 1; % 局部极大值位置 if numel(idx) >= 4 T_num(k) = mean(diff(tt(idx))); % 多段峰值间隔取平均 end end p = p0; % 恢复 T_num, T_theodiff(sign(diff(x))) < 0是找局部极大值的常用技巧:二阶差分符号为负的位置就是峰。取多段间隔的平均而不是只取首尾两点,能有效抑制初值瞬态带来的偏差。运行后会发现,r在 0.6 附近时数值周期明显大于理论值,越靠近 1.6 差距越小——这就是线性化的适用边界。
把参数扫描再往前推一步,就是参数反演:手里有渔获统计的时间序列时,把r、a、b、m当作待估参数,用lsqcurvefit或 Optimization Toolbox 里的fminsearch最小化模型输出与观测值的残差平方和。目标函数里每次迭代都要调用一次 ode45,所以初值给得越接近真实值越好,先用上一节的趋势图估个量级,再交给优化器细调。如果观测数据噪声大、周期性不明显,也可以换成用神经网络做曲线拟合的路子,先拟合出平滑趋势再反推参数,但代价是丢掉了方程本身的可解释性。
最后留一个工程化的习惯:把sharkLotka单独存成一个.m文件,参数全部走结构体,主脚本只负责初始化和画图。这样从捕食模型换到 SIR 传染病模型、再换到种群竞争模型时,改的只是方程本体,求解、绘图、守恒量检验、参数扫描那几百行代码一行都不用动。
本文还有配套的精品资源,点击获取