每次节假日或周末闲下来的时候,特别适合拿一个“勉强不算小的项目”来快速验证自己的工程能力。所谓“开荒”,就是从空文件夹起步,不依赖任何脚手架,把一类看起来有点唬人的技术方向做成可运行的 Demo。
这次要分享的项目,我用一个周末做了个“失控进化”模拟器:让一组虚拟生物在封闭地图里自主移动、觅食、繁殖,然后用最简单的遗传算法控制它们的基因漂移。核心目标很直接:生物数量、平均基因、适应度都在短时间内出现明显变化,最终形成“失控但能收敛”的演化效果。
之所以推荐这类项目作为周末开荒首选,是因为它覆盖了项目实战中最容易忽略的三块内容:数据建模、算法落盘、渲染可视化。再加上“失控进化”本身的结果不确定性强,每次跑出来的曲线都不一样,很适合拿来做实验,而不是照着教程敲一敲就完事。
如果你正准备选一个周末项目练手,或者刚接触遗传算法,想看看它在仿真场景里到底怎么落地,这篇实战记录应该能给你一套能直接复用的代码和思路。
1. 背景与核心概念
1.1 什么是“失控进化”模拟器
先不着急写代码,我们从概念上把这个项目拆明白。
“失控进化”模拟器,本质上是一个基于个体行为的演化仿真程序。它模拟一个封闭世界,里面有:
- 若干生物个体,每个个体拥有自己的基因;
- 资源点,也就是食物,分布在地图上;
- 一套生存规则:生物需要消耗能量,找到食物补充能量,能量足够时繁殖;
- 一套演化规则:繁殖时基因发生交叉和变异,不适应环境的个体更容易死亡。
“失控”两个字,指的是当我们人为提高变异率、降低环境容量限制时,整个种群的基因变化速度会快速上升,可能出现个体体型越来越大、活动速度越来越快,或者因为代谢率过高而迅速崩溃的现象。
这类项目看起来像“小游戏”,但它背后的机制并不简单。它同时涉及:
- 遗传算法的基本算子和参数设计;
- 个体 Agent 的移动与碰撞逻辑;
- 环境资源生成与容量控制;
- 实时数据统计与可视化。
换句话说,做成一个能跑、能看、能调参的版本,就已经把好几个重要工程环节过了一遍。
1.2 为什么适合周末极速开荒
传统学习路径里,我们通常先学理论,再做练习题,最后才做一个完整的系统。这种路径的问题是反馈周期太长。
而“失控进化”项目有一个天然优势:结果反馈是即时的。
你按下运行键之后,可能在几秒到几分钟内就看到种群数量变化、个体分布变化、基因均值变化。这些结果可以直接用来反推代码里的哪些参数设置不合理,哪些规则导致了“进化失控”。
从工程训练角度来看,它也有很强的实战属性:
- 如何把“生物个体”抽象成类;
- 如何把“遗传算法”和“游戏主循环”组合起来而不互相干扰;
- 如何用 Pygame 做足够轻量的 2D 可视化;
- 如何记录实验数据,观察参数变化对结果的影响。
这些能力,恰好是很多“看起来懂、动手就废”的开发者最需要的。
1.3 常见应用场景
别以为这类模拟只能用来做“小游戏原型”。它背后的思路在几个方向都有应用:
| 场景 | 说明 |
|---|---|
| 游戏 AI | 自走棋、Roguelike 中怪物或角色的自动生成与平衡 |
| 参数寻优 | 用遗传算法搜索复杂系统中的最优参数组合 |
| 群体行为研究 | 模拟交通流、人群疏散、生态演化 |
| 教学演示 | 帮助学生直观理解自然选择与基因漂移 |
所以,虽然周末项目看起来不大,但它的技术迁移性非常强。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与版本说明
2.1 开发环境
这个项目我用的是 Python 3.10 搭配 Pygame 2.5。如果你本机已经安装了 Python 3.8 以上版本,问题也不大,核心算法不依赖高版本语法。
需要提醒的是:不同版本的 Pygame 在窗口初始化和事件处理上差异很小,但如果你发现画面卡顿或者窗口无法正常关闭,先检查 Pygame 版本,不必一上来就怀疑核心逻辑。
安装依赖的指令如下:
bash复制pip install pygame matplotlib
其中:
pygame负责画面渲染与主循环;matplotlib只在“查看实验曲线”时使用,如果只是跑模拟,不装也能运行。
我建议保留 matplotlib,因为周末项目真正有价值的部分不是画面,而是你能够看到“基因均值随代数下降/上升”的曲线。
2.2 项目结构规划
在敲代码前,先规划项目结构。周末开荒项目最怕的是全部逻辑写在一个 main.py 里,最后改一个细节要上下翻滚很久。
我用的目录结构如下:
code复制evolution-lab/
├── config.py # 所有可调参数集中在这里
├── entity.py # 生物个体、食物点的定义
├── evolution.py # 遗传算法相关逻辑
├── simulation.py # 主循环与渲染
├── logs/
│ └── run_data.csv # 每一代统计数据输出
└── main.py # 启动入口
这样做的好处很明显:
- 调参数不用翻代码,只看 config.py;
- 改渲染逻辑不会影响算法逻辑;
- 以后想把这个项目扩展成多物种版本,直接增加新模块就行。
2.3 核心依赖逻辑
整个程序可以抽象成下面这个流程:
- 初始化一群随机生物;
- 每个生物在地图上移动、寻找食物、消耗能量;
- 能量达标的生物产生后代,后代基因由交叉和变异形成;
- 能量耗尽的生物死亡;
- 当种群数量超过环境容量时,能量最低的个体被淘汰;
- 定期记录种群平均基因、平均适应度和个体总数。
这就是一个典型的“选择—交叉—变异—淘汰”循环。有人说这不就是遗传算法加了一个游戏界面吗?理解方向是对的,但把两者组装起来,才是周末项目的真正价值。
3. 核心概念:遗传算法的工程化拆解
遗传算法本身是一个经典话题,网上的理论文章很多。但在动手实现时,有几个点必须结合具体项目去理解。
3.1 基因编码方式
在这个模拟器里,我们不需要把基因转成二进制串或浮点数数组,而是直接用一个字典表示即可:
python复制{
"speed": 2.3, # 移动速度
"vision": 45.0, # 视野范围
"metabolism": 0.20 # 每帧消耗能量
}
每一个键值对就是一个基因维度。选择字典的好处是:
- 可读性强,不用记忆数组下标;
- 后续新增基因维度很方便;
- 交叉、变异操作也很直观。
“speed”决定了生物每次刷新位置时能移动多少像素;“vision”决定了它能在多远看到食物;“metabolism”则代表它每帧消耗多少能量。三者之间本身就存在平衡关系:速度太快容易消耗过多能量,视野太小找不到食物,代谢率太低虽然省能量但繁殖速度也会受到影响。
3.2 适应度函数
适应度函数是整个进化的“裁判”。它决定了什么样的个体更容易把自己的基因传给下一代。
简化版本中,个体的适应度可以这样设计:
python复制fitness = food_hit * 10 + lifetime * 0.1
其中 food_hit 是该生物成功吃到食物的次数,lifetime 是它存活了多少帧。
为什么要这样设计?
如果不考虑 food_hit,只看寿命,那么“代谢率极低、基本不动”的生物也能活很久,但它们对环境的贡献几乎为零,种群会退化成一群静止的个体。
如果不看 lifetime,只看吃到的食物数量,则可能出现“运气极好但基因很差”的个体短期内霸榜。把两者结合,既是鼓励个体主动寻找食物,也是在惩罚无效静止。
3.3 选择策略
实际项目中,我们很难做到“每一代都全局按适应度排序,淘汰最差的一半”,因为模拟是连续运行的,个体出生时间不同。
所以这里采用两种机制混合:
- 如果当前种群总数量超过环境容量,直接按能量从高到低排序,保留前面的个体;
- 普通情况下,能量耗尽的个体自然死亡。
这是一个简单的流式选择策略,不一定最优,但足够支撑周末项目的正常运行。
3.4 交叉与变异
交叉操作模仿自然界的有性繁殖。当两个生物发生繁殖行为时,子代的基因从两个亲本中组合而来:
python复制def crossover(g1, g2):
new_gene = {
"speed": (g1["speed"] + g2["speed"]) / 2,
"vision": g1["vision"] if random.random() < 0.5 else g2["vision"],
"metabolism": (g1["metabolism"] + g2["metabolism"]) / 2,
}
return new_gene
“speed”和“metabolism”取亲本平均值,代表数量性状;“vision”则随机从一方继承,代表离散选择。这种方式虽然简单,但可以让后代的基因组合产生丰富变化。
变异操作负责打破局部收敛。如果两个高速度亲本繁殖,子代速度一直维持在高速附近,种群可能永远找不到其他可能的生存策略。这时就需要变异:
python复制def mutate(gene, rate=0.05):
for key in gene:
if random.random() < rate:
gene[key] *= random.uniform(0.8, 1.2)
return gene
每维基因都有 5% 的概率被放大或缩小 20%。这个幅度看起来不大,但在连续几十代之后,它足以让一个小种群产生显著分化。
3.5 核心参数的意义
写代码之前,先把关键参数表列出来,后续调参时你会特别感谢这张表:
| 参数名 | 含义 | 设置过大的影响 | 设置过小的影响 |
|---|---|---|---|
| 初始种群数 | 开局生物数量 | 启动卡顿 | 进化速度慢 |
| 环境容量 | 最大存活数量 | 种群膨胀失控 | 多样性不足 |
| 变异率 | 基因改变概率 | 优良基因被破坏 | 容易陷入局部最优 |
| 食物刷新率 | 单位时间生成食物数量 | 生物数量爆炸 | 大量饿死 |
| 繁殖能量阈值 | 达到该能量才繁殖 | 繁殖慢、迭代慢 | 个体数量失控 |
所有参数默认值都放在 config.py 中,运行后可以快速调整。
4. 从零搭建项目:完整实操
下面进入正文,我们会从配置文件开始,一步一步写出一个可运行的进化模拟器。
4.1 创建配置文件
创建 config.py,把所有可调参数集中管理:
python复制# 文件路径:config.py
WIDTH = 900
HEIGHT = 600
INITIAL_CREATURES = 100
MAX_POPULATION = 300
BASE_ENERGY = 60
ENERGY_FOR_REPRODUCE = 120
ENERGY_PER_FOOD = 30
METABOLISM_BASE = 0.15
METABOLISM_RANGE = 0.15
SPEED_MIN = 1.0
SPEED_MAX = 3.0
VISION_MIN = 20
VISION_MAX = 60
MUTATION_RATE = 0.05
CROSSOVER_WEIGHT = 0.5
FOOD_COUNT = 80
FOOD_REFRESH_RATE = 10
FOOD_MAX = 150
SEED = 42
这里我把随机种子写死为 42。为什么要固定种子?
因为模拟进化过程带有大量随机性,如果不固定种子,调试时很难判断“这次结果变好”到底是代码改动带来的,还是随机性带来的。固定种子之后,相同的代码必然产生相同的结果,这对排查问题非常有帮助。
4.2 定义生物个体与食物
创建 entity.py,包含生物个体和食物点两个类。
python复制# 文件路径:entity.py
import random
import config
class Food:
"""地图上的食物点"""
def __init__(self):
self.x = random.uniform(20, config.WIDTH - 20)
self.y = random.uniform(20, config.HEIGHT - 20)
class Creature:
"""具有基因和能量的生物个体"""
def __init__(self, x=None, y=None, genome=None):
self.x = x if x is not None else random.uniform(0, config.WIDTH)
self.y = y if y is not None else random.uniform(0, config.HEIGHT)
self.energy = config.BASE_ENERGY
self.lifetime = 0
self.food_hit = 0
if genome is None:
self.genome = {
"speed": random.uniform(config.SPEED_MIN, config.SPEED_MAX),
"vision": random.uniform(config.VISION_MIN, config.VISION_MAX),
"metabolism": random.uniform(
config.METABOLISM_BASE,
config.METABOLISM_BASE + config.METABOLISM_RANGE,
),
}
else:
self.genome = genome
@property
def fitness(self):
"""适应度:鼓励吃到食物,同时不能让基因变成静止形"""
return self.food_hit * 10 + self.lifetime * 0.1
def find_nearest_food(self, food_list):
"""在视野范围内寻找最近的食物"""
best_food = None
best_dist = self.genome["vision"]
for food in food_list:
dx = food.x - self.x
dy = food.y - self.y
dist = (dx * dx + dy * dy) ** 0.5
if dist < best_dist:
best_dist = dist
best_food = food
return best_food
def update(self, food_list):
"""更新位置、能量,尝试吃掉附近食物"""
self.lifetime += 1
self.energy -= self.genome["metabolism"]
if self.energy <= 0:
return
target = self.find_nearest_food(food_list)
if target is not None:
dx = target.x - self.x
dy = target.y - self.y
dist = (dx * dx + dy * dy) ** 0.5
if dist < 6:
self.energy += config.ENERGY_PER_FOOD
food_list.remove(target)
self.food_hit += 1
else:
step = min(self.genome["speed"], dist)
self.x += dx / dist * step
self.y += dy / dist * step
else:
self.x += random.uniform(-0.5, 0.5)
self.y += random.uniform(-0.5, 0.5)
self.x = max(0, min(config.WIDTH, self.x))
self.y = max(0, min(config.HEIGHT, self.y))
这里有几个细节值得留意。
find_nearest_food 不是全局搜索所有食物,而是只搜索“视野范围”内的食物。这样设计符合直觉:生物只能感知到一定距离内的资源。视野这个基因维度也因此变得有意义。
update 方法里,当生物找不到食物时,它会进行随机小幅移动。这样的好处是让“视野差”的个体有机会四处探索,而不是原地等死。随机移动幅度是 [-0.5, 0.5],半小时内不会导致个体瞬间跑出地图。
能量消耗是按帧进行的,每帧消耗 metabolism 点能量,基数大约是 0.15 左右。所以一个初始能量 60 的生物,如果不吃不喝,大约能坚持 300 到 400 帧,大概五六秒。
4.3 实现交叉与变异
创建 evolution.py,专门存放遗传算法相关逻辑。
python复制# 文件路径:evolution.py
import random
import config
def crossover(genome1, genome2):
"""两个亲本交叉产生子代基因"""
child_genome = {
"speed": (genome1["speed"] + genome2["speed"]) / 2,
"vision": genome1["vision"]
if random.random() < config.CROSSOVER_WEIGHT
else genome2["vision"],
"metabolism": (genome1["metabolism"] + genome2["metabolism"]) / 2,
}
return child_genome
def mutate(genome):
"""对子代基因执行变异"""
new_genome = genome.copy()
for key in new_genome:
if random.random() < config.MUTATION_RATE:
new_genome[key] *= random.uniform(0.8, 1.2)
return new_genome
def reproduce(parent1, parent2):
"""生成一个子代个体"""
child_genome = mutate(crossover(parent1.genome, parent2.genome))
child_x = (parent1.x + parent2.x) / 2 + random.uniform(-10, 10)
child_y = (parent1.y + parent2.y) / 2 + random.uniform(-10, 10)
child_x = max(0, min(config.WIDTH, child_x))
child_y = max(0, min(config.HEIGHT, child_y))
return Creature(child_x, child_y, child_genome)
注意,这里的 reproduce 需要从 entity 模块导入 Creature。所以 evolution.py 顶部还需要加一行:
python复制from entity import Creature
初学者容易在这个地方绕晕。其实只要记住:entity.py 负责“生物是什么”,evolution.py 负责“生物如何繁殖”,后者依赖前者,天然合理。
4.4 编写主循环与渲染
现在进入整个项目最核心的部分:把模拟逻辑和渲染逻辑组合在一起。
创建 simulation.py:
python复制# 文件路径:simulation.py
import sys
import random
import pygame
import config
from entity import Creature, Food
from evolution import reproduce
def auto_adjust_population(creatures):
"""当种群数量超过环境容量时,淘汰能量最低的个体"""
if len(creatures) > config.MAX_POPULATION:
creatures.sort(key=lambda c: c.energy, reverse=True)
del creatures[config.MAX_POPULATION:]
def run_simulation():
pygame.init()
screen = pygame.display.set_mode((config.WIDTH, config.HEIGHT))
pygame.display.set_caption("失控进化模拟器")
clock = pygame.time.Clock()
font = pygame.font.SysFont("SimHei", 20)
random.seed(config.SEED)
creatures = [Creature() for _ in range(config.INITIAL_CREATURES)]
foods = [Food() for _ in range(config.FOOD_COUNT)]
generation = 0
running = True
while running:
# 1. 事件处理
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# 2. 食物刷新
if len(foods) < config.FOOD_MAX:
for _ in range(config.FOOD_REFRESH_RATE):
foods.append(Food())
# 3. 更新所有生物
for creature in creatures:
creature.update(foods)
# 4. 移除死亡个体
creatures = [c for c in creatures if c.energy > 0]
# 5. 繁殖
new_creatures = []
for creature in creatures:
if creature.energy >= config.ENERGY_FOR_REPRODUCE:
creature.energy = config.BASE_ENERGY
mate = random.choice(creatures)
child = reproduce(creature, mate)
new_creatures.append(child)
creatures.extend(new_creatures)
# 6. 环境容量控制
auto_adjust_population(creatures)
# 7. 绘制
screen.fill((15, 15, 25))
for food in foods:
pygame.draw.circle(screen, (0, 120, 0), (int(food.x), int(food.y)), 3)
for creature in creatures:
growth = min(6, 3 + creature.food_hit / 20)
color = (
min(255, 100 + creature.genome["speed"] * 40),
min(255, 80 + creature.genome["energy"] * 0.5),
min(255, 120 + creature.genome["vision"] * 2),
)
pygame.draw.circle(
screen,
color,
(int(creature.x), int(creature.y)),
int(growth),
)
# 8. 显示统计数据
avg_speed = sum(c.genome["speed"] for c in creatures) / max(len(creatures), 1)
avg_vision = sum(c.genome["vision"] for c in creatures) / max(len(creatures), 1)
text = font.render(
f"个体数量: {len(creatures)} 平均速度: {avg_speed:.2f} 平均视野: {avg_vision:.1f}",
True,
(255, 255, 255),
)
screen.blit(text, (10, 10))
pygame.display.flip()
clock.tick(60)
pygame.quit()
sys.exit(0)
主循环中最重要的部分在第 5 步和第 6 步。
第 5 步是“繁殖”:能量超过阈值的个体,随机选择一个同类作为配偶,生成一个子代。这里没有使用复杂的选择策略,只是随机找伴侣。为什么可以这样?
因为真正的选择压力不是发生在繁殖前,而是发生在第 6 步的“环境容量控制”里。当种群数量超过 300 时,能量最低的个体会被直接删除。也就是说,繁殖机会相对均等,但淘汰压力非常残酷。这是典型的“宽进严出”策略。
第 7 步是绘制逻辑。我根据生物的不同基因,给它们渲染了不同颜色:
- 速度快的偏向红色;
- 能量高的偏向绿色;
- 视野大的偏向蓝色。
所以你一眼就能看到,当种群平均速度升高后,整个画面里红色的个体变多了。
4.5 启动入口
创建 main.py:
python复制# 文件路径:main.py
from simulation import run_simulation
if __name__ == "__main__":
run_simulation()
如果你在命令行运行:
bash复制python main.py
就会看到一个黑色背景的窗口,里面有绿色食物点和彩色生物点。生物会慢慢移动、觅食、繁殖,然后数量会剧烈波动。
由于随机种子固定为 42,大家第一次运行时看到的演化趋势应该是一致的。
如果你使用的 Python 环境缺少中文字体,Pygame 渲染中文时可能报错或显示乱码。这时可以把 SysFont("SimHei", 20) 换成 SysFont("Arial", 20),把界面文字改为英文,不影像核心功能。
5. 让系统“失控”,再把它拉回正常
5.1 如何制造“失控进化”
在默认参数下,整个模拟系统其实是相对稳定的:食物数量有限,环境容量 300,变异率只有 5%。
但“失控”才是这个项目的趣味所在。我们把参数改成这样:
python复制MUTATION_RATE = 0.2
FOOD_REFRESH_RATE = 25
FOOD_MAX = 300
MAX_POPULATION = 1000
ENERGY_PER_FOOD = 50
这些改动意味着:
- 突变率从 5% 提高到 20%,后代基因变化幅度明显增大;
- 食物刷新速度变快,食物总量上限提升;
- 环境容量从 300 提升到 1000;
- 每份食物提供的能量增加。
你已经可以预判结果了:短时间内,生物数量会一路暴涨,基因出现极端分化。有的个体速度变得特别快,有的个体代谢率低到几乎不动。整个系统进入“失控”状态。
5.2 失控之后会出现什么现象
你可能会看到:
- 种群数量快速冲到接近 1000 上限;
- 平均速度出现剧烈波动;
- 部分个体的参数变异到极端值,比如代谢率几乎为 0,导致它们完全不消耗能量,变成“永生个体”;
- 极端个体大量繁殖,挤占其他个体的生存空间,导致种群整体退化。
这就是经典的“进化失控”现象。
5.3 如何强制系统回归稳定
要让系统从失控中恢复,有几种思路。
第一种是“物理层面”的限制,也就是把环境容量调回 300,把食物刷新率调回 10。但这种做法有点太直接,相当于把游戏难度调低。
第二种是“算法层面”的自适应调节。我推荐这种方式。可以在主循环中加入一个简单的平均值反馈:
python复制if len(creatures) == config.MAX_POPULATION:
config.MUTATION_RATE = max(0.01, config.MUTATION_RATE * 0.95)
if len(creatures) < config.MAX_POPULATION * 0.3:
config.MUTATION_RATE = min(0.3, config.MUTATION_RATE * 1.1)
这段代码的意思是:当种群数量达到环境容量时,降低变异率,保护已有基因;当种群数量大幅下降时,适当提高变异率,增加多样性。
这种“看症状给药”的调节方式,比固定参数有趣得多,也更接近真实工程里的自适应系统。
5.4 记录数据,验证进化效果
只看画面是不够的,周末项目想要有“收获感”,一定要把实验数据保存下来。
我建议在 simulation.py 中加一个简单的日志功能:
python复制import csv
# 在 run_simulation 里添加一个输出文件句柄
log_file = open("logs/run_data.csv", "w", newline="", encoding="utf-8")
writer = csv.writer(log_file)
writer.writerow(["tick", "population", "avg_speed", "avg_vision", "avg_fitness"])
# 主循环内每次统计后调用:
avg_fitness = sum(c.fitness for c in creatures) / max(len(creatures), 1)
writer.writerow(
[
generation,
len(creatures),
avg_speed,
avg_vision,
avg_fitness,
]
)
generation += 1
停止运行后,用 pandas 或 matplotlib 打开这个 CSV 文件,把 population 和 avg_fitness 画成折线图,你就能看到非常清晰的“上升—峰值—震荡—收敛”曲线。
这也是整个项目里最容易让人产生成就感的一步。因为你不是在看别人设计的动画,而是真的在观察“一组随机数如何一步步演化出适应环境的基因组合”。
6. 常见问题与排查思路
周末开荒项目虽然代码量不大,但可能会出现下面这些高频问题。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 窗口一闪而过 | 主循环提前退出或 Pygame 未正确初始化 | 检查 init 和 quit 位置是否对应 |
| 生物全部快速死亡 | 代谢率太高或食物太少 | 降低 METABOLISM_BASE 或增加 FOOD_REFRESH_RATE |
| 种群数量长期稳定不变 | 食物、容量、繁殖阈值高度耦合 | 调整 ENERGY_FOR_REPRODUCE 和 MAX_POPULATION |
| 个体数量爆炸导致卡顿 | 生物数量超过数千 | 调低 MAX_POPULATION 或优化碰撞检测 |
| 中文文字显示乱码 | 系统字体不支持 | 换成 Arial 或英文提示 |
| 每次运行结果都不同 | 没有固定随机种子 | 在初始导入后调用 random.seed(config.SEED) |
其中“种群数量长期稳定不变”是最值得研究的情况。
比如说,如果 ENERGY_FOR_REPRODUCE = 999,初始能量只有 60,每份食物只加 5 点能量,那么生物几乎不可能达到繁殖阈值。整个系统会变成一个“找不到食物就死,找到食物也只是多活几秒”的静态展示。
所以在调参时,建议遵循一个原则:每次只改 1 到 2 个参数,运行两分钟,记录数据,再继续改。不要一次性把所有参数都调乱,否则很难定位问题出在哪里。
7. 工程化建议与最佳实践
7.1 把“算法”和“渲染”分离
我在项目里把遗传算法相关逻辑放到 evolution.py,生物个体放到 entity.py,渲染和主循环放到 simulation.py。这个分层思想虽然简单,但价值很高。
为什么?
如果你以后想把这个模拟器改成服务端项目,让多个客户端在浏览器里查看结果,你只需要重写 simulation.py 的渲染部分,算法和实体层完全不用动。甚至可以把 simulation.py 换成定时任务脚本,让它跑在服务器上,通过 CSV 或数据库输出结果。
7.2 固定随机种子
前面已经提过,这里再强调一次:如果你的项目会涉及随机数,建议在开发阶段强制固定种子。
python复制import random
random.seed(config.SEED)
在运行日志中记录当时的种子值。这样当你跑出一个还不错的结果,想回头复现时,只要把种子改回来就行。
7.3 使用 CSV 而不是 print 输出
初学者习惯用 print 观察运行状态,但对于这种持续运行的模拟器,print 会在控制台刷出大量数据,根本不方便分析。
更好的做法是:
- 实时信息用 Pygame 绘制在画面左上角;
- 实验数据写入 CSV 文件;
- 最终分析用 pandas 读取 CSV 画图。
这样既能看到实时动态,又能保存长期趋势。
7.4 一次只改一个变量
进化模拟器的参数之间高度关联。变异率会影响平均基因,食物刷新率会影响能量供给,能量供给又会影响繁殖频率,繁殖频率又会影响种群数量,种群数量又会影响淘汰压力。
所以强烈建议:跑实验时一次只改一个变量,记录清楚实验结果。否则你根本不知道是哪个参数导致了当前现象。
我自己的做法是在 logs/ 目录下建子目录,每次实验放一个文件夹:
code复制logs/
exp01-mutation-0.2/
exp02-food-25/
这样实验记录非常清晰,复盘时可以快速对比不同参数下的行为差异。
7.5 注意运行性能
Pygame 并不是一个高性能图形库,它适合教学和原型验证,不适合大规模个体渲染。
当生物数量超过几千时,画面帧率会明显下降。如果你的目标是想模拟上万个体,建议:
- 去掉 Pygame 绘制,只保留控制台输出;
- 用 numpy 批量操作位置和距离计算;
- 把个体数量、平均基因、平均适应度保存为 CSV。
本质上,这就是把“可视化原型”升级成“仿真实验引擎”的路径。
8. 总结与下一步方向
这个周末项目做完之后,你应该已经掌握了几件事:
- 环境容量如何影响种群的爆发和收敛;
- 变异率对基因多样性的实际影响;
- 基因编码和适应度函数设计的基本方法;
- 如何用一个简单的主循环把模拟和渲染串起来。
下一步如果你想继续深入,有以下几个值得尝试的方向。
第一个方向是引入多物种。让两种不同类型的生物共享同一个地图,比如一种速度快、消耗高,另一种速度慢、消耗低。观察它们之间会不会形成生态位分化,或者说,其中一个物种是否会彻底消失。
第二个方向是给生物加上神经网络。每个生物不再用字典表示基因,而是用一个简单的两层神经网络作为“大脑”,输入是周围食物距离和自身能量,输出是移动方向。然后用遗传算法去演化神经网络的权重。这个方向已经接近“神经进化”(Neuroevolution)的研究范畴了。
第三个方向是把场景改成浏览器版本。用 Canvas 2D 替代 Pygame,再用 WebSocket 把后端模拟数据实时推到前端。这样你就可以把项目部署到服务器,在浏览器里远程观战,甚至让别人也加入进来调整参数。
说到底,周末开荒项目的价值不在于做一个完整的商业产品,而在于让你在短时间内把“设计、编码、调参、分析、迭代”这个闭环走一遍。如果你最近也想找一个有意思的周末项目,这个“失控进化”模拟器值得一试。
