简介:Populus是明尼苏达大学发布的种群生物学与进化生态学教育模拟软件源码包,面向生态学、进化生物学师生及希望研究Java桌面模拟程序的开发者。它通过可视化方式呈现生态与进化模型,内置模型讲解帮助系统,可用于课堂演示、探究式学习和模型参数实验。压缩包共506个文件,约15.57MB,以404个Java源文件为核心,辅以46个GIF图标与界面素材、9个PDF及若干Markdown/TXT文档,并包含Gradle构建与打包配置、少量jar依赖。已有948人学习下载。读者可获得完整可运行的源码结构、模型实现逻辑、图形化界面素材和配套说明,便于理解种群动态与进化机制,也适合作为Java教育软件二次开发与教学案例参考。
1. 从课本公式到可调参数
讲完哈迪-温伯格平衡,学生点头,一合上书就忘。Populus 是一套 Java 写的种群生物学与进化生态学模拟程序,把经典模型拆成一个个独立小窗口:单基因座选择、遗传漂变、迁移、突变、年龄结构、捕食者-猎物、定量遗传,左边填参数,右边出曲线,把 N 从 500 拖到 20,就能看着杂合子被小种群一点点吃掉。带实验课的老师、想验证自己推导对不对的学生、需要现场演示素材的人,装个 JDK 双击就能跑,不用配 Python 环境,也不用架服务。代价是 Java 桌面程序的老毛病它一个不少,想改源码得先看懂模型层和绘图层怎么分工。
2. 技术栈与骨架:Java Swing 桌面程序里的三层分工
2.1 为什么是 Java 桌面程序而不是网页
这套东西成型在浏览器插件还能跑 Java 的年代,最早的形态是嵌在页面里的教学演示,后来浏览器陆续砍掉插件支持,才整体转成独立桌面应用。这个历史包袱解释了很多“看着奇怪”的设计:窗口尺寸写死、菜单栏简陋、模块之间靠一个全局注册表串起来,都是当年为了塞进一个小方块里留下的痕迹。
选 Java 的直接好处是分发简单。一个可执行 jar,机房机器上装好 JDK 就能双击,不用 pip、不用 conda、不用管虚拟环境。对一群要在一节课里跑完五个模型的学生来说,这个差别比语言本身优雅不优雅重要得多。
另一个常被忽略的好处是 Swing 自带Graphics2D。这类模拟的图表需求其实很朴素——折线、散点、几条坐标轴、一个图例,用Graphics2D加一层坐标变换两三百行就够,完全不需要引第三方图表库。整个绘图层的体量小,意味着你读完它、改它、替换它的成本都低,这对二次开发是利好。
代价也明确:高分屏上字糊、中文字体在某些系统上变方块、jar 和 JDK 版本对不上就闪退。这些不是设计缺陷,是这套技术栈的已知边界,后面第 5 章会逐条给排查路径。
2.2 目录结构:模型、界面、绘图、工具四层
源码包按包名分层,大致是下面这个格局。你改动最频繁的一定是model,最不需要动的是util。
| 包 / 目录 | 职责 | 改动频率 |
|---|---|---|
| 入口包 | 主窗口、模块注册表、启动参数 | 低 |
model | 每个模拟一个类,纯计算,不 import Swing | 高 |
gui | 参数输入框、按钮、下拉选择、模块切换面板 | 中 |
plot | 坐标轴、折线、图例、数据坐标到屏幕坐标的变换 | 中 |
util | 随机数封装、组合数学、数值格式化 | 低 |
分层是不是真的做到了,有个很硬的检验标准:model包里能不能搜到javax.swing。如果搜得到,说明计算和界面耦合了,你就没法脱离窗口做批处理。我拿到这类项目第一件事就是全包搜一遍import javax.swing,命中在model里就说明得先做一层剥离,否则后面想跑参数扫描会非常别扭。
反过来,判断一个模型写得好不好,就看它能不能在没有JFrame的情况下new出来、跑一千步、把结果吐成数组。能做到,这个模型就是可测试的;做不到,它的正确性只能靠“看着像”。
2.3 模型生命周期:init、step、repaint 三步走
所有模拟模块在骨架层面共用一套接口,这是整个项目最值得先读的一段代码。
public interface SimulationModel { String getName(); // 显示在窗口标题与模块列表里 void init(Map<String, Double> params); // 读参数、分配数组、清空历史 void step(); // 推进一个时间步:一代,或一个 dt double[][] series(); // 各条曲线的历史,[曲线序号][时间点] String describe(); // 右侧说明文字,讲清模型假设 }init负责把界面上的字符串转成数值并做边界校验,同时把历史数组清空——这一步经常被漏写,导致切换参数后新曲线接在旧数据后面。step必须是无副作用的推进,只改内部状态,不碰界面。series返回的是已经算好的数据,绘图时直接取,绝不允许在里面临时跑模型。
驱动这一切的是一个 Swing 定时器:
// 每 30 ms 推进一代;delayMs 只影响播放速度,不影响任何计算结果 int delayMs = 30; Timer timer = new Timer(delayMs, e -> { model.step(); plotPanel.repaint(); }); timer.start();这里有个新手最容易搞混的点:delayMs是播放速度,不是模拟精度。想看到更细致的演化过程,要改的是step内部的 dt 或者每步代表多少代,不是调这个值。把delayMs从 30 调到 5 只是让同一批数据画得更快,结果一模一样。我见过有人为了“让曲线更平滑”把定时器调到 1 ms,结果窗口直接卡死——因为瓶颈根本不在刷新频率,在series()每次都返回全量历史。
3. 群体遗传学内核:漂变、选择、迁移怎么叠在一个频率上
3.1 单基因座两等位基因的迭代内核
群体遗传学那几个模型的核心状态小得惊人:一个double代表等位基因 A 的频率。每一代做的事分两半,先按适合度算出选择之后的期望频率,再用二项抽样模拟有限种群的随机性。这两半必须分开写,混在一起就没法判断曲线抖动到底是选择还是漂变。
public final class SelectionDriftModel implements SimulationModel { private final Random rng = new Random(20240101L); // 固定种子,方便复现 private double p; // A 等位基因当前频率 private final double s; // 选择系数:aa 相对适合度的损失 private final double h; // 显性度:杂合子受损比例 = h * s private final int copies; // 基因拷贝数 = 2N public SelectionDriftModel(double p0, double s, double h, int n) { this.p = p0; this.s = s; this.h = h; this.copies = 2 * n; } @Override public void step() { double q = 1.0 - p; double wAA = 1.0; double wAa = 1.0 - h * s; double waa = 1.0 - s; // 平均适合度,作用是把选择后的频率归一化回 [0, 1] double wBar = p * p * wAA + 2 * p * q * wAa + q * q * waa; if (wBar <= 0.0) { p = 0.0; return; } // 全致死,直接判固定 // 选择之后的期望频率:AA 与 Aa 贡献的全部 A double expected = (p * p * wAA + p * q * wAa) / wBar; // 有限种群抽样:这一步才是漂变的来源 p = sampleBinomial(expected); } private double sampleBinomial(double prob) { int hits = 0; for (int i = 0; i < copies; i++) { if (rng.nextDouble() < prob) hits++; } return hits / (double) copies; } }wBar是最容易写错的地方。它是三个基因型适合度按哈迪-温伯格频率加权平均,除以它才能保证频率不越界。如果漏掉这一步,p会一路往上飘到大于 1,后面的抽样函数拿到非法概率值,nextDouble() < prob恒成立,曲线就变成一条直线钉在 1.0 上——这个现象在第 5 章会单独说。
copies这个参数就是 2N,它同时决定了漂变的强度和单步的计算量。N 设成 5000 时一个step要做一万次随机数调用,十有八九跑起来是卡的;设成 20 时几乎瞬间完成,但曲线抖得像心电图。教学上通常取 50 到 200 这个区间,既能看清随机性,又不至于每一步都等。
至于sampleBinomial为什么用循环而不用正态近似:N 小的时候正态近似误差很大,尤其是频率贴着 0 或 1 的边界时,近似会给出负的抽样结果。用一个显式循环最稳,虽然慢一点,但代码读起来跟课本定义完全对得上,学生对照着看不会困惑。真要提速,再换成二项分布的逆变换采样,那是另一个话题了。
3.2 参数表:s、h、N 各改什么,曲线会变成什么样
把这几个参数的含义和现象对上号,比读十页推导有用。
| 参数 | 含义 | 常用取值 | 改动后的直观变化 |
|---|---|---|---|
| N | 有效种群大小(拷贝数为 2N) | 20 ~ 5000 | 小 N 抖动剧烈、固定快;大 N 曲线接近确定性的平滑下降 |
| s | 选择系数 | 0 ~ 0.1 | s 越大曲线越“直”,固定方向和速度由它主导 |
| h | 显性度 | 0、0.5、1 | h=0 隐性完全隐藏,杂合子与 AA 同适合度;h=1 完全显性 |
| m | 迁移率 | 0 ~ 0.2 | 越大越把本地频率往源种群拉,等于给漂变加了个锚 |
| mu | 单方向突变率 | 1e-6 ~ 1e-3 | 极小,短时间看不出,只有跑上千代才有存在感 |
h这个参数在课堂演示里最好用。把 s 固定成 0.05,h从 1 拖到 0,你会看到有害等位基因的消失速度明显变慢——因为当它是完全隐性时,绝大部分拷贝藏在杂合子里,选择根本“看不见”它们。这个现象光靠讲很难建立直觉。
还有一个反直觉的结论值得让学生自己跑出来:s 很大不代表固定一定快。当s = 1(aa 完全致死)而h = 0时,只要初始频率很低,隐性纯合子的出现概率极小,等位基因可以靠着漂变在低频率徘徊很久。这跟“强选择=快速清除”的朴素判断是冲突的,而冲突正是值得记住的地方。
3.3 迁移与突变:作为扰动算子叠加在后面
这两个过程在实现上是两个一行的公式,麻烦的不是公式本身,是叠加顺序。
// 迁移:把本地频率按 m 的比例拉向源种群频率 p = (1.0 - m) * p + m * pSource; // 突变:双向,mu 为单方向速率,A -> a 和 a -> A 各 mu p = p * (1.0 - mu) + (1.0 - p) * mu;迁移那一行本质是加权平均,m = 0时原样返回,m = 1时本地频率被完全替换成源种群,两点之间线性过渡。突变那一行是双向的,写法上容易漏掉后半句:只写p * (1 - mu)是单向突变,长期看频率会一路归零,跟真实的双向突变完全不是一回事。
顺序上,常见做法是 选择 → 迁移 → 突变 → 抽样,理由是这样每一代只抽样一次,随机性来源清晰。如果顺序调换,比如先抽样再做选择,结果会有细微差别,尤其当 s 和 m 都不小的时候。这个差别不会让程序崩,但会让你和别人复现出来的曲线对不上。所以我一般要求把顺序明明白白写进describe()返回的说明文字里,半年后自己回头看也不至于抓瞎。
3.4 绘图:坐标轴自适应与历史数据放在哪
绘图层的核心是三层分离:数据范围、坐标变换、绘制。三者混在一起写,往往就是“曲线越跑越慢”的根源。
@Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 1. 范围只在数据变化时重算,避免每帧全量扫描 if (dirty) { recomputeBounds(); dirty = false; } // 2. 数据坐标到屏幕坐标:缩放 + 翻转 Y 轴 + 平移到原点 AffineTransform tx = new AffineTransform(); tx.scale(getWidth() / (maxX - minX), -getHeight() / (maxY - minY)); tx.translate(-minX, -maxY); // 3. paint 里只画已算好的点,绝不做模型计算 g2.setTransform(tx); for (double[] line : model.series()) { g2.draw(polyline(line)); } }Y 轴那个负号是必须的:屏幕坐标向下为正,数据坐标向上为正,不翻转画出来的曲线是上下颠倒的。这个错误在第一次跑的时候很显眼,但如果你恰好改的是一个两边对称的模型(比如频率从 0.5 出发),可能一时半会儿看不出来。
dirty标志位是个小优化,但很有用。recomputeBounds要遍历所有历史点,如果每帧都跑一遍,两千代之后每次重绘都是几万次比较,窗口就肉眼可见地卡起来了。真正的性能瓶颈往往不在计算,而在这里。
4. 种群动力学:Logistic、Leslie 矩阵与捕食者-猎物
4.1 Logistic 的两种写法,差在哪
同一个 Logistic 增长,源码包里的模型通常提供两种离散化方式,选错了会得到完全不同的结论。
// 连续式(显式欧拉):适合世代重叠、连续繁殖的物种 n += r * n * (1.0 - n / K) * dt; // 差分式(Ricker):适合一年一代、繁殖同步的物种 n = n * Math.exp(r * (1.0 - n / K));欧拉式里dt是关键。当r * dt接近 2 时,数值解会开始振荡;超过 2 就发散。所以dt一般取 0.01 到 0.1 之间,r越大取得越小。这不是模型的错,是显式欧拉法的稳定性条件,换 RK4 能放宽一些,但代码量翻倍。
Ricker 式更“野”:r超过 2.5 左右会出现周期振荡,超过 2.7 附近进入混沌,序列看起来像随机数但完全确定。很多人第一次跑出来的反应是“程序坏了”,其实这正是这个模型最有教学价值的地方。我建议在describe()里直接写一句“大 r 下的不规则波动属于模型行为,不是数值错误”,省掉一堆解释。
4.2 Leslie 矩阵:年龄结构一年一年滚动
年龄结构模型的状态从标量变成一个数组,每一格是一个年龄级的个体数。推进逻辑很干净:新出生的全部进第 0 级,其余各级由上一级乘存活率得到。
public double[] nextAgeClass(double[] n, double[] fecundity, double[] survival) { int k = n.length; double[] next = new double[k]; // 第一行:所有年龄级的繁殖贡献之和 double births = 0.0; for (int i = 0; i < k; i++) births += fecundity[i] * n[i]; next[0] = births; // 次对角线:第 i 级活到第 i+1 级 for (int i = 1; i < k; i++) next[i] = n[i - 1] * survival[i - 1]; return next; }fecundity和survival两个数组长度都是 k,最后一个年龄级的存活率通常填 0。survival里如果出现大于 1 的值,说明参数写错了,个体数会指数爆炸;如果全是 0,第二代就全灭,曲线一秒到底。
长期增长率 λ 是这类模型最想知道的量,但项目只依赖 JDK,不可能引线性代数库去求特征值。常见做法是用幂迭代估:
public double dominantEigenvalue(double[] fec, double[] surv, int iters) { double[] n = new double[fec.length]; Arrays.fill(n, 1.0 / fec.length); // 均匀初始年龄结构 double lambda = 1.0; for (int it = 0; it < iters; it++) { double before = sum(n); n = nextAgeClass(n, fec, surv); lambda = sum(n) / before; // 逐代总量比值,收敛到 λ } return lambda; }迭代两百次基本就收敛了。这个方法的好处是不需要任何外部依赖,几十行解决问题;坏处是当 λ 接近 1(比如 0.99 和 1.01 这种),收敛会很慢,得加大迭代次数。做作业够用,做研究就得换成正规的特征值求解。
4.3 捕食者-猎物:步长、守恒量与数值爆炸
两个物种耦合的模型最容易翻车,因为误差会互相放大。
// Lotka-Volterra 的显式欧拉推进 double dPrey = alpha * prey - beta * prey * predator; double dPred = delta * prey * predator - gamma * predator; prey += dPrey * dt; predator += dPred * dt; // 边界裁剪:数值误差可能把个体数推成负数,负个体数会让后面全乱 if (prey < 0) prey = 0; if (predator < 0) predator = 0;alpha是猎物内禀增长率,beta是捕食率,delta是转化效率,gamma是捕食者死亡率。这四个参数的组合决定相图上的闭合轨道形状。
关键坑在dt。Lotka-Volterra 的理论解是闭合轨道,总有一个守恒量保持不变;但显式欧拉不守恒,轨道会一圈一圈往外螺旋,跑久了猎物和捕食者双双冲到极大值再崩掉。解决办法只有两条:把dt压到足够小(我一般从 0.001 试起),或者换成四阶龙格-库塔。压dt是最省事的,代价是同样时长的模拟要多跑几十倍的步数。
裁剪那两行看着多余,其实非常必要。浮点误差在轨道拐点附近特别容易把数量推成 -1e-16,下一轮beta * prey * predator就变成负数,整个系统瞬间失控。加上裁剪之后至少不会崩,虽然失真,但你能从曲线上看出“步长太大了”。
4.4 批量跑:把窗口里的一次实验变成参数扫描
模型层和界面层分开写,最大的回报就在这里——同一段计算逻辑可以脱离窗口跑成千上万次。
// 扫描迁移率 m,每个取值重复 200 次,统计末代频率的均值与固定比例 for (double m = 0.0; m <= 0.20; m += 0.01) { double sum = 0.0; int fixed = 0; int reps = 200; for (int r = 0; r < reps; r++) { SelectionDriftModel model = new SelectionDriftModel(0.5, 0.02, 0.5, 100); model.setMigration(m, 0.10); // 源种群频率固定 0.10 for (int gen = 0; gen < 2000; gen++) model.step(); sum += model.getP(); if (model.getP() < 1e-9 || model.getP() > 1 - 1e-9) fixed++; } System.out.printf("%.2f,%.4f,%.3f%n", m, sum / reps, fixed / (double) reps); }这段代码里reps = 200是重复次数,单次模拟因为随机数不同结果会差很多,只有多次重复的均值才有意义。gen = 2000是每次模拟的代数,要足够长让系统达到稳态或固定。输出的三列分别是迁移率、末代平均频率、固定比例,直接能进表格或者画图。
跑一遍大概几秒钟,比在界面上点两百次快得多,而且结果可复现——因为种子的设定在模型内部,只要不换随机数生成策略,同样的参数永远给同样的数字。这套流程我在做任何参数敏感性分析时都会先搭起来,比手工试参数靠谱太多。
5. 避坑与排查:跑不起来、曲线不动、结果对不上
5.1 环境与启动类问题
现象:双击 jar 一闪而过,命令行里跑才看到UnsupportedClassVersionError。原因很直接,编译时用的 JDK 版本比运行环境高,class 文件的版本号超出虚拟机支持范围。解决方式是统一版本:要么装对版本的 JDK,要么从源码用--release指定一个更低的编译目标重新打包。我一般会把目标定在项目里实际用过的最低版本,这样分发给别人的机器成功率最高。
现象:窗口能开,但所有中文显示成方块或问号。原因是运行环境的默认字体不包含中文字形,Swing 的默认Font在部分系统上会挑到一个纯拉丁字体。解决办法是在入口处显式指定字体族,比如在UIManager里把Label.font、Button.font统一设成系统里确定存在的中文字体。这件事必须在main的最开头做,晚于组件创建就来不及了。
5.2 模型与数值类问题
现象:曲线跑到某一步之后变成一条水平直线,死死钉在 1.0 或者 0.0。这几乎一定是平均适合度wBar没算或者算错了。漏掉归一化之后,expected会超过 1,抽样函数里rng.nextDouble() < prob恒为真,每一代都抽到全 A,看起来就是瞬间固定。排查方法是把expected打到日志里,看它有没有越界。另一个可能是wBar <= 0(三个基因型适合度全为 0),代码里要有兜底分支。
现象:个体数一路涨到Infinity或者变成负数,然后整条曲线消失。两个方向的原因:dt太大导致显式欧拉发散,或者存活率、增长率参数填了不合理的值。排查顺序是先看参数表里有没有超过 1 的存活率,再把dt减半重跑一次——如果曲线形状变正常了,那就是步长问题,跟模型逻辑无关。
5.3 图形与数据输出类问题
现象:跑了几百代之后界面明显变卡,鼠标拖动窗口都有延迟。典型原因是历史数据用ArrayList无上限地追加,同时paintComponent每帧遍历全量数据。两千代、三条曲线,就是每帧几万次坐标变换。解决办法是给历史设上限(比如只保留最近 5000 个点),或者把recomputeBounds的结果缓存起来,只在dirty时重算。这个坑不致命,但会让人误以为是模型算得太慢。
现象:导出的 CSV 里所有小数都被截成两位,或者干脆变成科学计数法。出在格式化环节。表格显示用DecimalFormat保留两位是合理的,但导出数据必须走另一条路径,用完整精度。常见做法是显示层和导出层各写一个格式化器,别图省事共用一个。频率这种 1e-4 量级的变化,两位小数直接抹平了,肉眼看不出来。
6. 二次开发:加一个模型,并且让结果可验证
6.1 照抄骨架比从零写快十倍
新增模型最省力的路径是找一个结构最接近的现有模型,复制一份改名字。要改的只有四处:getName()的返回字符串、init()里的参数解析、step()里的推进公式、series()里返回哪几条曲线。注册到模块列表通常是在一个集中式的地方加一行,加完之后新模型就会出现在下拉框里。
有一点必须坚持:新模型的类里不要出现任何 Swing 的 import。参数从Map<String, Double>进来,结果通过series()出去。只要守住这条线,你就能在第 4 章那套批处理脚本里直接调用它,不用为了跑一次参数扫描去开窗口。
6.2 参数不要散落在界面代码里
我见过太多改到第三版的模型,参数默认值分别硬编码在输入框、模型构造函数、说明文字三个地方,改一个忘两个。正确做法是让模型类自己声明参数表和默认值,界面层遍历这张表自动生成输入框:
| 声明项 | 用途 | 谁在用 |
|---|---|---|
| 参数名 | 输入框标签、配置文件键名 | 界面层、导出层 |
| 默认值 | 初始化、重置按钮 | 界面层 |
| 取值范围 | 输入校验、滑块范围 | 界面层 |
| 单位/说明 | 悬浮提示 | 界面层 |
这样加一个新参数只改一处,界面自动跟上。配置文件读写也能复用同一张表,导出实验记录时连键名都不用另起。
6.3 用解析解把“看着像”变成“对得上”
改完之后最大的风险是:曲线画出来了,形状也合理,但数值是错的。挡这个风险最有效的办法是拿有解析解的特例做回归。
Logistic 在s场景下有闭式解,可以直接对:
@Test public void logisticMatchesAnalyticSolution() { double r = 0.4, K = 1000.0, n0 = 10.0, dt = 0.001; double n = n0; int steps = 5000; // 总时长 = steps * dt = 5 for (int i = 0; i < steps; i++) { n += r * n * (1.0 - n / K) * dt; } double t = steps * dt; double expected = K / (1.0 + ((K - n0) / n0) * Math.exp(-r * t)); assertEquals(expected, n, 1.0); // 容许 1 个个体以内的离散化误差 }容忍度设 1.0 是个经验值:dt = 0.001时欧拉法的截断误差在这个量级,设太严会因为离散化本身而误报。这个测试跑一次几毫秒,但能挡住后面所有的重构事故。
群体遗传学那边也有确定性的特例可用:把 N 设得极大(比如 10 万)时漂变几乎消失,等位基因频率的轨迹应该收敛到纯选择的解析解,可以直接逐代比对。我通常会在改完核心迭代之后跑这两个测试,两个都过才敢把窗口打开。
从那以后我每次动model包里的任何一行,都强制先跑一遍这两个对拍测试,再去看界面。图形是最会骗人的东西,一条形状漂亮的曲线背后可能藏着完全错误的公式,而解析解不会撒谎。希望这套拆解能帮你少走几步弯路。
本文还有配套的精品资源,点击获取