从零理解 Flappy Bird AI:状态、动作与 Q-Table 的第一性原理

这一篇是 Flappy Bird 强化学习系列的第一期配套博客。它的定位不是重复视频内容,而是补上视频为了节奏和可看性没有展开的技术部分。

如果把视频看作“带你建立直觉”,那么这篇文章的目标就是“把直觉落到代码和模型上”。读完之后,你应该能回答下面几个问题:

  • 为什么 Flappy Bird 可以被建模成一个强化学习问题?
  • 什么叫状态,什么叫状态空间?
  • 为什么代码里一定要做离散化,而不是直接使用连续数值?
  • Q-Table 里到底存的是什么,它为什么能指导小鸟做决策?

这一期聚焦“让 AI 看见世界,并且拥有第一本记忆笔记”。后续几期会继续展开奖励设计、探索与利用、模型保存与迁移等内容。

先看视频,再对照博客拆解

如果你还没看这期视频,建议先花几分钟把完整演示看一遍,再回来读这篇文章。视频负责建立直觉,博客负责把直觉拆成可复现的技术结构,两者搭配起来效果最好。


系列定位:视频讲故事,博客讲清楚

Flappy Bird 这个项目在视频里适合用一种很直观的方式来讲:一只本来很笨的小鸟,经过大量试错,慢慢飞得越来越稳。这样的表达有趣,也容易建立兴趣,但它会天然省略很多真正关键的工程细节。

而当你开始自己改代码、调参数、分析效果时,那些被省略的部分反而最重要。比如:

  • 小鸟到底“看见”了什么信息?
  • 这些信息为什么恰好选了三个,而不是五个或十个?
  • 连续的高度和速度怎么变成表格里的键?
  • 如果状态设计错了,会发生什么?

这也是这篇博客存在的意义。它不是视频字幕整理,而是一份偏工程视角的学习文档。


一、先把问题说准:这不是“写规则”,而是“让它自己学规则”

传统的游戏脚本通常是这样的:程序员提前写好规则,告诉角色“如果前方障碍物距离小于某个阈值,就立刻起跳”。这种写法当然也能让小鸟飞起来,但本质上它是一个手工设计的控制器,不是学习。

强化学习的思路不同。我们不直接告诉小鸟“此时一定要跳”,而是只给它三类东西:

  • 它当前观察到的环境,也就是状态(State)
  • 它可以执行的选择,也就是动作(Action)
  • 它做完动作之后得到的反馈,也就是奖励(Reward)

然后让它在大量回合中,逐步总结出“什么状态下,什么动作更值钱”。

对 Flappy Bird 而言,这个建模非常自然:

  • 环境是不断变化的水管和重力系统
  • 智能体是小鸟
  • 动作很少,只有“跳”或“不跳”
  • 目标明确,活得久、过更多水管、少撞墙

所以它非常适合作为强化学习入门项目。问题规模不大,反馈直观,而且每一个核心概念都能在画面里看见。


二、这套项目的真实架构:Scratch 负责环境,Python 负责决策

这个项目不是纯 Scratch,也不是纯 Python,而是一个典型的混合架构。

1. Scratch 在做什么

Scratch 负责所有“和游戏世界直接打交道”的事情:

  • 生成水管
  • 更新小鸟位置和速度
  • 检测是否碰撞
  • 计算这一帧给多少奖励
  • 把当前场景编码成状态字符串

在项目里,Scratch 每一帧都会拼出这样一个状态:

定义 拼接状态字符串
将 [_状态字符串 v] 设为 (连接 ((y 坐标) - (最近水管空隙Y)) 和 (连接 [,] 和 (连接 (_速度) 和 (连接 [,] 和 ((最近水管X) - (x 坐标)))))) :: variables

这三项信息分别是:

  • 小鸟和最近水管空隙中心的垂直差值
  • 小鸟当前的垂直速度
  • 小鸟距离下一根关键水管的水平距离

2. Python 在做什么

Python 这一侧不负责画画,也不负责碰撞。它只做一件事:根据状态做决策,并用结果更新 Q-Table。

动作空间在当前实现里非常简单:

actions_str = 'JUMP,NONE'
default_action = 'NONE'

也就是说,小鸟每一帧只有两种选择:

  • JUMP:给一个向上的速度
  • NONE:什么也不做,让重力继续作用

这是一个很典型的强化学习分工:环境负责产生世界,智能体负责做选择。


三、什么叫“状态”:让 AI 决策前必须知道的信息

很多初学者第一次接触强化学习时,会把“状态”理解成某一个单独的数值。实际上不是。状态指的是:

在当前决策时刻,智能体用来判断下一步该做什么的那组信息。

在我们的 Flappy Bird 项目里,状态可以写成:

s=(Δy,v,Δx)s = (\Delta y, v, \Delta x)

其中:

  • Δy\Delta y 是小鸟到最近水管空隙中心的高度差
  • vv 是小鸟当前竖直速度
  • Δx\Delta x 是小鸟到下一根水管的水平距离

为什么这三个量足够关键?因为它们已经覆盖了这次决策最核心的判断依据:

  • 我现在偏高还是偏低?
  • 我当前是在上冲还是下坠?
  • 我离做动作的时刻还剩多少时间?

这和人类玩家的直觉其实是高度一致的。你玩 Flappy Bird 的时候,脑子里想的无非也是这三件事。

其它游戏也可以这样理解

为了把“状态”这个概念彻底吃透,可以看几个别的例子:

  • 在赛车游戏里,状态可能是“当前速度、弯道角度、与赛道边缘的距离”。
  • 在吃豆人里,状态可能是“豆子位置、鬼的位置、自己的朝向、附近是否有能量豆”。
  • 在 platformer 游戏里,状态可能是“角色速度、前方坑洞距离、敌人高度差、是否还在空中”。

你会发现,状态并不是“把世界上所有信息都喂给 AI”,而是“挑出对当前决策真正有用的信息”。

状态设计得太少,AI 看不清世界。状态设计得太多,状态空间会膨胀得难以学习。工程上最难的地方,往往就在这里。


四、从状态到状态空间:为什么“看见三项数据”还不够

当我们说小鸟当前状态是 (Δy,v,Δx)(\Delta y, v, \Delta x) 时,真正需要面对的问题其实是:

所有可能出现的状态,一共有多少种?

这就是状态空间(State Space)。

如果高度差、速度、水平距离都按连续实数来算,那么状态空间几乎是无限的。理论上,下面这些都可以是不同状态:

  • (28, 2, 120)
  • (28.1, 2, 120)
  • (28.2, 2, 120)
  • (28.3, 2, 120)

问题在于,Q-Learning 当前这份实现使用的是表格法。表格法的前提是:每个状态都能映射到一个明确的键。若状态无限细分,Q-Table 会无限膨胀,学习几乎不可能收敛。

所以我们必须做一件事:离散化(Discretization)

离散化的意思是,把原本连续变化的数值,压缩成一个个“桶”或者“格子”。只要落在同一个桶里,AI 就把它们视作同一种状态。

这正是代码里 discretize_state 在做的事情。


五、代码里的离散化到底怎么做

当前实现中,Scratch 传给 Python 的是原始字符串,而 Python 再按分桶参数把它压缩成更粗粒度的状态键。

初始化参数里有这样一行:

buckets_str = '15,1,15'

对应三维状态的桶大小分别是:

  • 高度差按 15 为一组
  • 速度按 1 为一组
  • 水平距离按 15 为一组

核心逻辑如下:

def discretize_state(self, state_str: str) -> str:
    state_vector = [float(x) for x in state_str.split(',')]
    simplified_state = [
        int(val / bucket) if bucket != 0 else int(val)
        for val, bucket in zip(state_vector, self.bucket_sizes)
    ]
    return '_'.join(map(str, simplified_state))

举个具体例子。假设 Scratch 上报:

28,2,120

那么 Python 会得到:

  • 28 / 15 -> 1
  • 2 / 1 -> 2
  • 120 / 15 -> 8

最终状态键大致会变成:

1_2_8

这一步非常重要,因为它直接决定了学习效率。

为什么离散化能让学习更快

因为它把很多“几乎一样”的局面合并了。

例如 (28,2,120)(29,2,118) 在实际飞行策略上差别很小。对于小鸟来说,这两种场景很可能都应该采取相同动作。如果我们把它们分得太细,AI 就必须分别学习两遍;而离散化之后,它们可能会落到同一个状态桶里,从而共享经验。

但离散化也有代价

桶太大,会损失细节。桶太小,又会让状态空间重新爆炸。

这就是工程调参的本质,不是越精细越好,而是要找到一个足够表达问题、同时又能学得动的分辨率。

如果粗略估计一下这个项目的状态空间规模,会更有感觉。假设:

  • 高度差大约覆盖 -170170
  • 速度大约覆盖 -88
  • 水平距离大约覆盖 0340

那么离散化后的状态数大概是:

34015×17×3401523×17×23=8993\frac{340}{15} \times 17 \times \frac{340}{15} \approx 23 \times 17 \times 23 = 8993

将近九千种状态,已经不算小了,但仍然是表格法可以承受的量级。相比连续空间,这是一个从“几乎无法穷举”到“可以逐步覆盖”的巨大压缩。


六、动作为什么只有两个:动作空间越小,学习越容易起步

当前项目的动作空间只有两个元素:

  • JUMP
  • NONE

这看起来非常朴素,但其实是一个很合理的入门设计。

原因很简单:动作越多,AI 每个状态下要比较的选项就越多,学习成本也越高。对于 Flappy Bird 这种机制高度集中的游戏来说,“跳一下”和“不处理”已经足够覆盖主要策略。

如果你把动作扩展成:

  • 小跳
  • 中跳
  • 大跳
  • 延迟跳
  • 不跳

当然理论上表达能力更强,但对应的 Q-Table 维度和训练难度也会同步上升。对于系列第一期而言,把动作空间压到最小,是非常稳妥的教学选择。

这也是强化学习项目里常见的一条经验:先让模型能学,再讨论学得更细。


七、Q-Table 到底是什么:它记录的不是答案,而是“倾向”

很多文章会把 Q-Table 简化成“记忆表”。这个说法不算错,但不够准确。

Q-Table 的每一项,记录的是:

在某个状态下,如果执行某个动作,长期来看有多划算。

也就是说,它存的不是“规则答案”,而是一个价值估计。

在当前代码里,新的状态会这样初始化:

if state_key not in self.q_table:
    self.q_table[state_key] = {a: 0.0 for a in self.action_space}

这意味着一开始,小鸟对所有状态和动作都没有经验,全部记为 0.0。随着训练进行,某些格子的值会越来越高,某些则会变成负值。

例如,一个状态键 1_2_8 未来可能逐渐学成:

状态JUMPNONE
1_2_84.71.2

这不代表“跳跃一定正确”,而是代表基于过往经验,在这个状态下选择 JUMP 的长期收益更高。

所以决策时,智能体不会问“正确答案是什么”,而是问“哪一个动作目前看起来更值”。


八、决策是怎么发生的:默认动作、探索和利用

当前文章重点还在第一期,所以这里先讲最基础的一层:当 Python 收到状态键后,它会在对应状态下选择一个动作。

实现里有一段很值得注意:

best_action = self.default_action
max_q = self.get_q_value(state_key, best_action)

for action in self.action_space:
    if action == self.default_action:
        continue
    q = self.get_q_value(state_key, action)
    if q > max_q:
        max_q = q
        best_action = action

这里的 default_action 被设置成了 NONE。这背后的工程含义是:

  • 当一个状态还没有被充分探索时
  • 或者多个动作的 Q 值同样都是 0
  • 系统优先选择相对保守的默认动作

这其实是一种很实用的安全设计。因为在 Flappy Bird 里,过度跳跃往往比暂时不跳更容易立刻送命,所以把 NONE 作为默认动作是合理的。

代码里还包含 epsilon = 0.1 的探索机制,也就是偶尔故意随机试一下不同动作。不过这部分会在系列第三期重点展开。对第一期来说,你只需要先建立一个清晰认识:

  • Q-Table 提供的是经验偏好
  • 策略函数负责把偏好转成当前动作
  • 智能体不是机械执行固定规则,而是在经验和试探之间平衡

九、为什么第一期一定要先讲“状态”而不是先讲公式

很多强化学习教程喜欢一上来就写更新公式:

Q(s,a)Q(s,a)+α[r+γmaxQ(snext,anext)Q(s,a)]Q(s, a) \leftarrow Q(s, a) + \alpha [r + \gamma \max Q(s_{next}, a_{next}) - Q(s, a)]

公式当然重要,但对初学者来说,如果连 ss 是什么都没想清楚,这个公式其实没有任何抓手。

在真实项目里,强化学习效果好不好,往往最先卡住的不是公式,而是建模:

  • 状态有没有把关键因素包含进去?
  • 状态空间有没有大到学不动?
  • 动作设计是不是过度复杂?
  • 环境反馈是不是足够稳定?

所以这期文章有意把重心放在“状态、状态空间、离散化、动作空间”上。这些才是把一个项目从“能讲故事”推进到“能真正实现”的地基。


十、你现在应该真正理解了什么

如果你已经读到这里,那么这只小鸟的“大脑”应该不再神秘。

你现在可以把整个系统理解成下面这条链路:

  1. Scratch 生成游戏世界,并实时测量关键信息。
  2. 这些信息被编码成状态字符串,例如 28,2,120
  3. Python 将连续数值离散化,压缩成类似 1_2_8 的状态键。
  4. 智能体在 Q-Table 中查这个状态下各个动作的价值。
  5. 它选择当前更值得的动作,并把动作返回给 Scratch。
  6. 环境继续推进,在下一帧产生新的状态和反馈。

这就是 Q-Learning 在这个项目里的最小闭环。

第一期做到这里就够了。因为只要“看见世界”和“查表决策”这两件事成立,后面奖励更新、探索机制、经验保存才有意义。

下一期我们会继续展开一个更关键的问题:小鸟为什么会把某些动作记成“好”,把另一些动作记成“坏”?也就是奖励设计和 Q 值更新到底在做什么。


建议你立刻动手做的三个实验:

  • buckets_str15,1,15 改成 30,1,30,观察学习是否变快、是否变粗糙。
  • 把状态里的某一项去掉,比如只保留高度差和水平距离,看看小鸟是否会变笨。
  • 设想一个别的游戏,并尝试先写出它的状态三元组或四元组,而不是急着写代码。

🧠 知识自测:你真的理解小鸟的大脑了吗?

通过以下几个小题,验证你对本篇核心概念的掌握程度。

在本项目中,状态(State)是由哪三项信息组成的?

解析

文章明确指出状态 $s = (\Delta y, v, \Delta x)$,分别对应小鸟到空隙的高度差、垂直速度和到水管的水平距离。

为什么要对连续的数值进行“离散化”处理?

解析

离散化通过把“几乎一样”的局面合并到同一个桶里,让 AI 能够共享学习经验,从而避免 Q-Table 无限膨胀,提高学习效率。

Q-Table 记录的核心内容是什么?

解析

Q-Table 存储的是 Q 值,代表在某个状态下,如果执行某个动作,长期来看有多“划算”,它是一种倾向或经验偏好,而非固定答案。

分享