1. 从像素到决策为什么卷积网络是游戏AI的天然搭档做FlappyBird的AI最直观的思路是把游戏画面直接喂给一个神经网络让它自己学会什么时候该扇翅膀。但如果你直接把屏幕上的像素拉平成一个长向量丢给全连接层参数量会瞬间爆炸——一帧224×224的RGB图像就是15万个输入维度接一个256维的隐藏层光这一层就接近四千万个参数。训练慢不说模型还极容易过拟合因为它把相邻像素之间的空间关系彻底打散了。卷积神经网络解决的就是这个问题。它基于两个非常符合直觉的假设第一图像中的有用模式比如水管边缘、小鸟轮廓是局部的不需要看整张图就能识别第二同一个模式出现在画面不同位置时识别它的方式应该是一样的。这两个假设对应到网络设计上就是局部连接和权值共享。一个3×3的卷积核只有9个参数却能扫描整张图参数量相比全连接层下降了好几个数量级。对于FlappyBird这个场景画面结构其实相当固定背景是纯色或简单渐变小鸟在左侧固定水平位置上下浮动水管从右侧向左移动。AI需要捕捉的核心信息无非是小鸟当前的垂直位置、小鸟的垂直速度、最近一根水管的上沿和下沿位置、以及水管与小鸟的水平距离。这些信息在像素层面表现为特定的边缘和角点组合卷积层恰好擅长逐层提取这类特征——浅层卷积核响应边缘和颜色块深层卷积核组合出更抽象的“水管开口”“小鸟姿态”等模式。我选择搭建CNN而不是用简单的多层感知机还有一个很实际的原因训练稳定性。全连接网络对输入像素的微小变化极其敏感小鸟位置偏移一两个像素整个输入向量的每个维度都在变梯度方向会剧烈抖动。卷积网络由于权值共享对平移有一定的鲁棒性训练过程明显更平滑。实测下来同样的训练轮数CNN的得分曲线收敛得更快最终稳定通过的管道数量也更高。注意虽然CNN对平移有鲁棒性但FlappyBird里小鸟的水平位置是固定的垂直位置才是关键变量。所以网络不需要太深的卷积堆叠重点应该放在垂直方向的特征提取能力上。2. 网络结构设计在轻量与表达力之间找平衡2.1 输入层灰度化与尺寸压缩的取舍原始游戏画面是彩色的但FlappyBird的色彩信息对决策几乎没有贡献——背景色、小鸟颜色、水管颜色都是固定的颜色不携带任何关于“该不该扇翅膀”的判别信息。所以第一步就是把RGB三通道压成单通道灰度图。这样做的好处很直接输入维度直接降到原来的三分之一计算量大幅减少而决策所需的信息几乎无损。尺寸方面我试过几种方案。原始分辨率直接输入当然可以但卷积计算量随像素数线性增长训练速度会慢到难以接受。我最终选择将画面缩放到84×84。这个尺寸是经过权衡的再小的话水管开口的缝隙可能只有几个像素宽卷积核很难区分“缝隙够宽”和“缝隙太窄”这两种关键状态再大的话训练一个批次的时间明显拉长迭代效率下降。灰度化和缩放的操作在每一帧预处理时完成用OpenCV几行代码就能搞定import cv2 import numpy as np def preprocess(frame): gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) resized cv2.resize(gray, (84, 84), interpolationcv2.INTER_AREA) normalized resized / 255.0 return normalized.reshape(1, 84, 84, 1)这里用INTER_AREA插值而不是默认的INTER_LINEAR是因为缩小图像时区域插值能更好地保留结构信息避免水管边缘出现锯齿状伪影。归一化到0到1之间是标准操作能让梯度下降更稳定。2.2 卷积层堆叠三层就够了FlappyBird的画面元素非常简单不需要ResNet那种几十层的深度。我采用的是三层卷积的结构第一层8个3×3卷积核步长1padding为same激活函数ReLU第二层16个3×3卷积核步长1padding为same激活函数ReLU第三层32个3×3卷积核步长1padding为same激活函数ReLU每层卷积后面都接一个2×2的最大池化步长为2。这样经过三层之后特征图尺寸从84×84降到10×10左右通道数从1升到32。这个设计背后的逻辑是浅层用少量卷积核捕捉基础边缘深层用更多卷积核组合出复杂模式同时通过池化逐步降低空间分辨率、增大感受野。为什么每层都用3×3的小卷积核两个3×3卷积堆叠的感受野等于一个5×5卷积但参数量更少2×918 vs 25非线性表达能力也更强因为中间多了一次ReLU激活。这是VGG网络验证过的经典结论在小规模网络上同样适用。实操心得卷积核数量不要一上来就设很大。我最初第一层就用了32个卷积核结果训练时loss震荡严重后来降到8个就稳定多了。小网络用小通道数让网络慢慢学反而收敛得更扎实。2.3 全连接层与输出回归还是分类卷积层提取完特征后需要把特征图拉平接入全连接层做最终决策。这里有一个关键选择输出是回归一个“扇翅膀的力度值”还是分类“扇/不扇”两个动作我选择的是二分类。原因在于FlappyBird的操作是离散的——每一帧要么扇翅膀要么不扇没有中间状态。用回归的话网络输出一个连续值还需要额外设定阈值来离散化多了一层不确定性。直接做二分类输出层用两个神经元加Softmax或者一个神经元加Sigmoid物理意义更清晰。全连接层的结构是拉平后的特征向量接一个256维的隐藏层ReLU激活然后接输出层。隐藏层之前加一个Dropout丢弃率0.3防止过拟合。Dropout在训练时随机丢弃一部分神经元相当于每次迭代都在训练不同的子网络能有效提升泛化能力。import torch import torch.nn as nn class FlappyCNN(nn.Module): def __init__(self): super(FlappyCNN, self).__init__() self.conv1 nn.Conv2d(1, 8, kernel_size3, padding1) self.conv2 nn.Conv2d(8, 16, kernel_size3, padding1) self.conv3 nn.Conv2d(16, 32, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) self.relu nn.ReLU() self.dropout nn.Dropout(0.3) self.fc1 nn.Linear(32 * 10 * 10, 256) self.fc2 nn.Linear(256, 2) def forward(self, x): x self.pool(self.relu(self.conv1(x))) x self.pool(self.relu(self.conv2(x))) x self.pool(self.relu(self.conv3(x))) x x.view(x.size(0), -1) x self.dropout(self.relu(self.fc1(x))) x self.fc2(x) return x这段代码里有一个细节值得注意x.view(x.size(0), -1)中的-1让PyTorch自动推断拉平后的维度。但前提是你得清楚经过三次池化后特征图的实际尺寸。84除以2三次是10.5取整后是10所以是32×10×10。如果输入尺寸变了这个数字也要跟着改否则全连接层的输入维度对不上会直接报错。3. 训练策略让AI从随机乱飞到稳定通关3.1 数据采集先让AI随便玩网络搭好了但训练数据从哪来总不能手动玩几千局然后标注每一帧该不该扇翅膀。我的做法是让一个随机策略先跑起来把每一帧的画面和对应的动作、奖励都记录下来形成初始训练集。具体流程是初始化一个随机初始化的网络让它在游戏里跑。每一帧做一次前向推理根据输出概率采样一个动作。如果小鸟活着给一个小正奖励如果通过了一根水管给一个大正奖励如果撞了给一个负奖励并结束本局。把所有这些状态、动作、奖励、下一状态四元组存进经验回放池。这里有一个容易踩的坑奖励设计。我最初只在水管通过时给奖励结果AI学了很久都学不会因为大部分帧的奖励都是零网络得不到有效的梯度信号。后来改成每存活一帧给0.1的奖励通过水管给1.0的奖励撞毁给-1.0的奖励学习速度立刻上了一个台阶。这种“稠密奖励”的设计在强化学习里非常关键稀疏奖励会让探索变得极其困难。注意经验回放池不要设得太大。FlappyBird的状态空间其实很小几万条经验就足够覆盖常见情况。池子太大反而会让旧数据稀释新数据降低学习效率。我一般设容量为50000满了就随机替换旧数据。3.2 损失函数与优化器选择因为是二分类问题损失函数用交叉熵。但这里不是标准的监督学习动作标签不是固定的“正确答案”而是网络自己采样出来的。所以实际用的是策略梯度的思想如果某个动作导致了正奖励就增大这个动作的概率如果导致负奖励就减小它的概率。具体实现时我把奖励做折扣累加得到回报然后用回报作为权重去乘交叉熵损失。回报高的样本对梯度的贡献大回报低的贡献小。优化器选Adam学习率设1e-4。Adam的自适应学习率在强化学习里表现通常比SGD稳定因为它能根据梯度的一阶矩和二阶矩自动调整每个参数的学习步长。optimizer torch.optim.Adam(model.parameters(), lr1e-4) criterion nn.CrossEntropyLoss(reductionnone) def compute_loss(log_probs, actions, returns): loss criterion(log_probs, actions) weighted_loss (loss * returns).mean() return weighted_lossreductionnone让交叉熵不自动求平均保留每个样本的损失值这样才能用回报做加权。这个细节如果搞错了回报的权重就加不上去训练会完全失效。3.3 训练循环与模型保存训练循环的核心逻辑是跑一局游戏收集经验用经验更新网络然后跑下一局。每一局结束后如果得分超过了历史最高分就保存模型参数。这样即使训练中途崩溃也能从最好的模型恢复。我一般会跑几千局前几百局得分基本在0到1之间徘徊说明网络还在随机探索阶段。到一千局左右得分开始明显上升偶尔能到十几分。两千局之后大部分局都能稳定在几十分以上说明网络已经学会了基本的避障策略。训练过程中要监控两个指标平均得分和损失值。平均得分反映策略的好坏损失值反映网络更新的幅度。如果损失值突然飙升通常是学习率太大或者奖励设计有问题如果损失值一直不降可能是网络容量不够或者输入特征有问题。实操心得训练时不要一直盯着屏幕看FlappyBird的AI前期表现非常蠢看久了容易怀疑人生。我一般是挂机训练每隔几百局回来看一下平均得分曲线。曲线整体向上就说明方向对了剩下的交给时间和算力。4. 踩坑实录那些让我调了一整晚的问题4.1 输入维度不匹配这是最常见也最容易排查的错误。卷积层输出拉平后的维度必须和全连接层的输入维度严格一致。我遇到过好几次因为改了输入图像尺寸但忘了改全连接层维度导致运行时报mat1 and mat2 shapes cannot be multiplied。排查方法很简单在x.view之前打印一下x.shape看看实际维度是多少然后对照全连接层的定义检查。4.2 奖励尺度失衡奖励的绝对值大小会直接影响梯度的大小。如果通过水管的奖励是100存活奖励是0.1那么网络会过度关注通过水管这个事件而忽略存活本身。我试过把奖励设成通过水管10、存活0.01、撞毁-10结果AI学会了“宁可撞毁也要冲过水管”的奇葩策略因为冲过去的期望收益太高了。后来把奖励调成通过1、存活0.1、撞毁-1比例就合理多了。4.3 过拟合与探索不足如果网络很快就在训练集上表现很好但实际游戏得分上不去大概率是过拟合了。FlappyBird的状态虽然简单但水管位置的组合是连续的网络需要泛化到没见过的水管间距。对抗过拟合的手段除了Dropout还可以加权重衰减在优化器里设weight_decay1e-5。另外在动作采样时保留一定的随机性比如按概率采样而不是取argmax也能让网络持续探索新的状态。4.4 训练不稳定的排查清单现象可能原因排查方法损失值震荡剧烈学习率太大降到1e-5试试损失值不下降网络容量不足或输入有问题检查预处理后的图像是否正常得分一直为0奖励太稀疏或动作空间有问题打印每局的动作分布得分忽高忽低过拟合或探索不足加Dropout或增大采样随机性显存溢出批次太大或网络太深减小批次或减少卷积核数量这张表是我在实际调试中总结出来的基本上覆盖了八成以上的常见问题。遇到训练异常时按表逐项排查比盲目改代码高效得多。4.5 模型推理速度优化训练完成后实际部署时推理速度很重要。如果每帧推理耗时超过游戏帧间隔通常16毫秒AI就会反应迟钝。优化手段包括把模型转成推理模式model.eval()关闭Dropout和BatchNorm的更新用torch.no_grad()包裹推理过程不计算梯度如果还嫌慢可以用ONNX导出模型再用ONNX Runtime推理速度通常能提升一倍以上。model.eval() with torch.no_grad(): output model(state_tensor) action output.argmax().item()这几行代码看起来简单但省掉的梯度计算能带来非常可观的加速。我实测过同样的网络加不加no_grad单帧推理时间差了将近40%。5. 从CNN到更强策略后续可以怎么扩展CNN版本跑通之后AI已经能稳定通关了但如果你想让它的表现更接近人类高手还有几个方向可以尝试。第一个方向是引入帧堆叠把连续四帧的画面叠在一起作为输入这样网络能感知到小鸟的垂直速度和水管的移动速度决策会更有前瞻性。第二个方向是改用更高效的网络结构比如在卷积层之间加入残差连接或者用深度可分离卷积替换标准卷积在保持表达力的同时减少参数量。第三个方向是把训练算法从策略梯度换成PPO或DQN这些算法在样本效率和训练稳定性上通常更有优势但实现复杂度也更高。我个人在实际操作中的体会是CNN版本最大的价值不在于最终得分有多高而在于它把整个“游戏画面到决策”的链路完整跑通了。有了这个基础后面换更复杂的网络或算法都只是在同一个框架里替换模块而已。真正花时间的从来不是写网络结构而是调奖励、调学习率、调探索策略这些“玄学”部分。踩过几次坑之后你会对强化学习的训练动态有一种直觉这种直觉比任何教程都管用。最后分享一个小技巧训练时把游戏速度调成两倍或四倍能大幅缩短训练时间而学到的策略在正常速度下同样有效。因为FlappyBird的物理规律不随速度改变网络学到的特征与速度无关。这个技巧帮我省了至少一半的训练时间。