AI 为什么会故意犯错?Flappy Bird 强化学习里的探索、远见与下一步估值

这是 Flappy Bird 强化学习系列的第三期配套博客。

这一篇不会把视频口播稿再写一遍。视频负责把“AI 为什么会突然抽风”和“它为什么像提前看见了未来”讲得生动好懂;博客负责把这些直觉落回真实代码,讲清楚机制到底是怎么运转的。

读完这一篇,你应该能回答下面几个问题:

  • 为什么小鸟明明已经很会飞了,还是会偶尔故意做出蠢动作?
  • epsilon 为什么不是 bug 开关,而是强化学习里故意保留的“好奇心”?
  • gamma 为什么能让 AI 从“只看眼前”变成“会为下一步做准备”?
  • next_max_q 并不是魔法,它到底是怎样从下一帧状态里算出来的?

先玩项目,再用这篇博客拆机制

如果你已经看过第三期视频,可以把这篇当成“代码讲义”;如果你还没看,也没关系,先打开 Scratch 项目跑几局,观察小鸟偶尔“抽风”的瞬间,再回来对照这里的代码,理解会更快。


一、第三期真正解决的问题:为什么“已经会了”还要继续试错

第一期,我们解决的是“AI 看见什么”。

第二期,我们解决的是“AI 认为什么叫好,什么叫坏”。

到了第三期,问题会自然升级成:

既然它已经有状态、有奖励、有 Q-Table,为什么不老老实实一直执行当前最高分动作?

答案是:如果它只会死守当前最优动作,它很可能永远停在一个“够用但不够好”的局部最优里。

比如它已经学会了一种勉强能活几根水管的飞法,这套飞法在当前经验里分数最高。但如果它从来不再试别的动作,它就没有机会发现另一种更稳、更省操作、能飞得更远的策略。

所以第三期讲的不是“AI 为什么犯傻”,而是:

  • 为什么要故意保留一部分随机试错
  • 为什么一次更新不能只看眼前奖励
  • 为什么真正的 Q-Learning 决策,总是在“当前收益”和“未来潜力”之间平衡

二、先把状态流转看清楚:Scratch 提供快照,Python 决定动作

这一期虽然重点是 epsilongamma,但如果你没先看懂状态是怎样一帧一帧流动的,就很难真正理解 next_max_q 从哪里来。

在这套实现里,Scratch 和 Python 的分工依旧很明确:

  • Scratch 负责游戏世界本身:小鸟位置、速度、水管、碰撞、奖励
  • Python 负责决策与学习:把状态离散化、查 Q-Table、更新 Q 值、选出下一步动作

最关键的流程在 play_game 里:

def play_game(state_str, reward, done) -> str:
    if agent is None:
        return 'NONE'

    is_done = str(done).lower() in ['true', '1', 't'] or done is True
    reward = float(reward)
    current_state_key = agent.discretize_state(state_str)

    if agent.last_state_key is not None:
        agent.learn(agent.last_state_key, agent.last_action, reward, current_state_key)

    if is_done:
        agent.save_model()
        agent.last_state_key = None
        agent.last_action = None
        return agent.default_action

    action = agent.choose_action(current_state_key)
    agent.last_state_key = current_state_key
    agent.last_action = action
    return action

把这段代码翻译成人话,就是下面四步:

  1. Scratch 把这一帧看到的世界打包成 state_str 发给 Python。
  2. Python 先把它离散化成 current_state_key
  3. 如果上一步做过动作,就拿“上一步状态 + 上一步动作 + 这一步奖励 + 这一步状态”去更新 Q 值。
  4. 更新完之后,再为当前状态选一个新动作返回给 Scratch。

这也解释了一个很容易误会的点:

AI 并不是先凭空想象未来,再更新现在。它只是拿“做完动作后,环境真实返回的新状态”来估计未来。

所以这里没有预言书,只有一连串高速发生的状态快照。


三、好奇心不是 bug:epsilon 负责让 AI 偶尔故意不听话

第三期视频里最抓人的画面,就是小鸟明明飞得很稳,却突然莫名其妙跳了一下把自己送走。这个现象确实很像程序写坏了,但在当前实现里,它恰恰是故意设计出来的。

choose_action

def choose_action(self, state_key):
    if random.random() < self.epsilon:
        if self.explore_weights:
            return random.choices(self.action_space, weights=self.explore_weights, k=1)[0]
        return random.choice(self.action_space)

    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

    return best_action

这段逻辑就是经典的 ε-greedy(epsilon-greedy)策略,不过在代码里我们看得更具体一些:

  • 当随机数小于 epsilon 时,进入探索模式,故意不按当前最高分动作来
  • 否则进入利用模式,选择当前状态里 Q 值最高的动作

如果 self.epsilon = 0.1,就表示大约有 10% 的概率,小鸟会说:“这次我不完全听经验,我想试一下别的可能。”

这正是强化学习里著名的 Exploration vs. Exploitation 问题:

  • 利用:照着当前账本里最值钱的动作执行,稳定但保守
  • 探索:给自己一点犯错空间,冒险但可能发现新路线

如果完全没有探索,小鸟会很早收敛,但它学到的不一定是最好的飞法。


四、这份实现比“纯随机探索”更聪明:探索也带偏好

很多教程在解释探索时,会直接说“随机挑一个动作”。这句并不算错,但如果你只停在这里,就会忽略当前项目里一个很实用的工程细节:

explore_weights_str = '1,9'

动作空间是:

actions_str = 'JUMP,NONE'

也就是说,在探索模式下,这只小鸟并不是 50% 跳、50% 不跳,而是更偏向于 NONE

为什么这很重要?

因为 Flappy Bird 是一个受重力支配的游戏。如果你让探索完全平均分配,小鸟在训练初期很容易连续乱跳,直接撞到上边界。这样当然也算“探索”,但它提供的信息价值很低,很多时候只是单纯更快地死掉。

而带权重的探索本质上是在说:

我允许你尝试新动作,但不是毫无代价地乱来;在重力系统里,先把“不跳”视为更安全的默认探索方向。

这就是这套实现比教科书示例更贴近真实工程的一点。强化学习不是只会套公式,很多时候训练能不能稳定跑起来,取决于这种细节设计。

为什么默认动作也是 NONE

代码里还有一个容易被忽略的保守设计:

default_action = 'NONE'

当某个状态还没有被充分探索,或者多个动作的 Q 值都一样时,系统会优先把 NONE 当成兜底动作。这样做的目的很明确:

  • 避免在未知状态下频繁乱跳
  • 让小鸟先学会“别急着抽风”,再慢慢学会精细时机

这不是在削弱强化学习,恰恰是在给训练过程加一层工程上的稳定器。


五、为什么 epsilon 不能一直固定不变:好奇心也要随经验衰减

如果探索永远保持 10%,会怎样?

答案是:它可能已经成为高手了,却还是会不断因为“手痒想试试”而白白送命。

所以当前实现没有把 epsilon 写死,而是在每局结束后做衰减:

if is_done:
    print(f'Game Over! 局数: {episode_count} | 状态数: {len(agent.q_table)}')
    agent.save_model()
    agent.last_state_key = None
    agent.last_action = None
    episode_count += 1
    if agent.epsilon > 0.01:
        agent.epsilon *= 0.99
    return agent.default_action

这段逻辑表达的是一个很朴素的训练节奏:

  • 新手阶段,好奇心高一点,多试几条路
  • 老手阶段,好奇心低一点,尽量稳着飞
  • 但永远不要降到 0,至少保留一点点对新情况的适应能力

如果把这个思想放到真实学习里也很好理解。一个完全没有探索的人,进步会停得很早;一个永远只爱乱试的人,又很难形成稳定方法。强化学习把这种平衡写成了参数衰减曲线。

一个很值得比较的反例

你可以想象三种不同设定:

  • epsilon = 0:从第一局开始只会死守当前最高分动作,极容易过早卡死在局部最优
  • epsilon = 1:每一步都纯随机,根本谈不上“利用已有经验”
  • epsilon0.1 慢慢降到 0.01:前期多探索,后期多利用,这是当前实现选择的折中

第三种并不是唯一正确答案,但它明显比前两种更适合这类入门项目。


六、远见不是超能力,而是 gamma 把未来价值折算回现在

现在看第二个主角:gamma

learn 函数里,真正决定“AI 是否有远见”的,是这几行:

def learn(self, last_state, action, reward, next_state):
    if last_state is None or last_state == 'ERROR':
        return

    old_q = self.get_q_value(last_state, action)
    next_max_q = max([self.get_q_value(next_state, a) for a in self.action_space])
    new_q = old_q + self.alpha * (reward + self.gamma * next_max_q - old_q)
    self.q_table[last_state][action] = new_q

用公式写出来,就是:

Q(s,a)Q(s,a)+α[r+γmaxaQ(s,a)Q(s,a)]Q(s, a) \leftarrow Q(s, a) + \alpha [r + \gamma \max_a Q(s', a) - Q(s, a)]

第三期真正要理解的重点,不是把公式背下来,而是看懂其中这部分:

r+γmaxaQ(s,a)r + \gamma \max_a Q(s', a)

它的意思是:

  • 先看刚才这个动作带来了多少即时奖励 r
  • 再看走到下一状态 s' 之后,未来最好的动作大概还能值多少钱
  • 最后用 gamma 把这份未来价值打折后,算回当前动作头上

所以 gamma 越大,AI 越愿意为了未来布局;gamma 越小,AI 越容易只盯着眼前这一步。


七、next_max_q 到底从哪里来:不是预言,而是“下一帧快照”的最佳潜力

这一期里最容易让人产生神秘感的变量,就是 next_max_q

很多人第一次看到它,都会下意识以为:AI 好像提前“看见了”未来。但把 play_gamelearn 连起来看,就会发现它其实非常朴素。

完整时序是这样的:

  1. 上一帧,AI 在 last_state_key 下选了一个动作 last_action
  2. Scratch 执行动作,推进游戏世界
  3. 下一帧,Scratch 把新的状态字符串和奖励送回来
  4. Python 把这个新的状态离散化成 current_state_key
  5. learncurrent_state_key 作为 next_state,查询这个状态下所有动作的当前估值
  6. 其中最大的那个值,就是 next_max_q

所以 next_max_q 并不是“AI 预知了未来发生什么”,而是:

AI 已经真实到达了下一帧状态,然后它回头评估:“如果我现在站在这个新位置上,接下来最好的动作大概还能带来多少长期回报?”

这就是视频里“灵魂出窍”的真正工程版本。

为什么这一步对 Flappy Bird 特别关键

因为 Flappy Bird 不是那种“这一帧做对了就立刻拿大奖”的游戏。很多高质量动作的价值,要过一小段时间才显现出来。

比如:

  • 这一帧你提前轻轻上抬了一点
  • 眼前没有立刻加分
  • 但两三帧之后,你会发现自己正好对准了下一个更高的水管缺口

如果没有 next_max_q,这类“现在不赚钱、但能为后面铺路”的动作,很难被正确估值。


八、为什么把 gamma 设成 0 会变成“近视眼”

现在就能看懂视频里那个经典实验了。

如果把:

self.gamma = 0.99

改成:

self.gamma = 0

那么更新公式会直接退化成:

Q(s,a)Q(s,a)+α[rQ(s,a)]Q(s, a) \leftarrow Q(s, a) + \alpha [r - Q(s, a)]

也就是说,AI 完全不再关心 next_max_q,只根据眼前奖励修正当前动作。

这会带来一个非常典型的问题:它会特别擅长处理“这一刻马上有反馈”的情况,却很难学会“为了后面提前准备”。

放到 Flappy Bird 场景里,你会看到它更容易表现出下面这些症状:

  • 刚过一个水管就松懈下来
  • 对下一根高位水管准备不足
  • 只会围着眼前缺口打补丁,不会提前调整飞行轨迹

所以第三期里的“远见”,不是一个文学化的夸张说法,而是 gamma 的工程含义确实就是:

让当前动作的价值里,带上一部分对未来状态潜力的估计。


九、把第三期真正串起来:AI 每一步都在平衡三件事

如果把这一期的机制压缩成最核心的一句话,其实就是:

小鸟每做一步决策,都在同时平衡“要不要试新路”“眼前得分是多少”“这一步会不会把我送到更有前途的位置”。

对应到代码里,就是三组变量:

  • epsilon:我这次要不要保留一点故意试错的空间
  • reward:我刚才这一手眼前拿到了什么结果
  • gamma * next_max_q:我现在这个动作,会不会把自己送进一个更值钱的未来状态

这三者合在一起,才是第三期真正要建立的强化学习直觉。

如果少了 epsilon,它会越来越保守。

如果少了 gamma,它会越来越短视。

如果两者都没有,你得到的就不再是一个会持续改进的智能体,而更像一个只会机械重复眼前经验的控制器。

下一期,我们才会继续解决另一个很自然的问题:这些辛苦学出来的经验,能不能在关掉程序之后继续保留下来?那就是模型持久化和“前世记忆”的主题了。


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

  • epsilon 临时改成 0,观察小鸟是否会更早停止改进。
  • explore_weights_str1,9 改成 5,5,比较探索阶段是否明显更混乱。
  • gamma0.99 改成 0,观察它是否会更难应对连续的高低变化水管。

🧠 知识自测:你真的理解第三期了吗?

为什么训练中的小鸟有时会在安全位置突然做出看起来很蠢的动作?

解析

这正是探索机制的表现。它不是程序抽风,而是强化学习故意保留的试错空间。

当前项目里为什么没有采用完全平均的随机探索,而是给 NONE 更高权重?

解析

Flappy Bird 属于重力系统。探索如果完全平均,小鸟很容易疯狂上蹿下跳,得到的很多只是无效死亡样本。

next_max_q 在当前实现中的真实含义是什么?

解析

next_max_q 不是预言,而是到达 next_state 之后,对这个新状态里可选动作价值的最大估计。

分享