第 25 章 · 代码实战

井字棋 Q-learning 实战:把强化学习跑起来

第 23 章把监督学习跑通了,第 24 章把语言模型跑通了; 这一章补上第 12 章强化学习——没有标签,只有赢/输/和的奖励,靠试错学策略。 程序是仓库里的 src/demo/rl_tictactoe/main.cpp,配上 src/deeplearning/rl/ 下的环境与 Q-learning 模块。 智能体执 X,用一张 Q 表(不是神经网络) 学会下棋;默认训 3 万局后,对随机对手 greedy 胜率约 99%

读完这一章,你会明白

  • 一个 RL 程序的完整链路:环境 → Q 表 → 多回合训练 → 评估 → (可选)人机对战;
  • TicTacToeEnv 如何把第 12 章的 状态 / 动作 / 奖励 落成代码;
  • TabularQLearning 如何实现 ε-greedy、TD 更新与 RunEpisode 训练循环;
  • 为什么井字棋适合表格式 Q-learning,而 MNIST / 语言模型走的是监督学习;
  • 怎么编译运行,并亲手看到胜率随训练爬升。

1. 任务与全景:没有标签,只有奖惩

监督学习(第 23 章)每道题旁贴着标准答案;强化学习(第 12 章)只有环境在终局告诉你“赢了吗”。 井字棋里:状态 = 当前棋盘,动作 = 在 0–8 某一格落子,奖励 = 赢 +1 / 输 −1 / 和 0(中间步全是 0,典型稀疏奖励)。

空棋盘回合开始 reset
X 落子ε-greedy 选动作
O 回应随机或最优对手
TD 更新 Qr + γ·max Q(s′,·)

对应第 12 章 agent–env 循环 + Q-learning 更新。外层再重复很多回合

和 MNIST / mini-LM 的本质区别

MNIST 的“标签”是人工给的数字;语言模型的“标签”是语料里自带的下一个字。 井字棋没有任何一步的标准落法,只有走完才知道输赢——训练信号来自 ApplyMove 里的 reward, 学习目标是一张 Q(s,a) 表,而不是反向传播里的梯度(本章没有神经网络)。

2. 第一步 · 环境:棋盘、状态编码与奖励

TicTacToeEnv 就是第 12 章的“环境”。它维护 9 格棋盘、判断输赢、在轮到智能体时返回 state_key。 状态用三进制整数编码:每格 空/X/O → 0/1/2,压成一个 int 当作 Q 表的键。

src/deeplearning/rl/tic_tac_toe_env.cpp · 状态编码(精简)
int TicTacToeEnv::EncodeBoardKey(const std::array<Cell, 9> &board) {
  int key = 0, base = 1;
  for (int i = 0; i < 9; i++) {
    key += static_cast<int>(board[i]) * base;   // 空=0, X=1, O=2   1
    base *= 3;
  }
  return key;
}
  1. 只在轮到 X(智能体)时调用 AgentStateKey()——Q 表存的是“轮到我下时,这盘棋值多少”。
src/deeplearning/rl/tic_tac_toe_env.cpp · 落子与奖励(精简)
board_[action] = player;
result_ = CheckResult();
out.done = IsTerminal();

if (result_ == GameResult::X_WIN)      out.reward = 1.0;              1
else if (result_ == GameResult::O_WIN)   out.reward = -1.0;
else if (result_ == GameResult::DRAW)    out.reward = 0.0;

if (!out.done) {
  agent_turn_ = !agent_turn_;
  out.agent_state_key = agent_turn_ ? AgentStateKey() : -1;           2
}
  1. 奖励只在终局给出,对应第 12 章稀疏奖励;中间步 reward=0,靠 TD 目标里的 max Q(s′,·) 把价值从终局往回传。
  2. 若未结束,交换行棋方,并准备好下一轮智能体行动时的状态键,供 Update 使用。

对手有两种:StepOpponentRandom(训练常用)和 StepOpponentOptimal(minimax 最优,用于评估“会不会输”)。

3. 第二步 · Q 表与超参:α、γ、ε

TabularQLearning 维护 unordered_map<state_key, vector<double>>——每个状态 9 个动作的 Q 值,初始全 0。 超参与第 12 章手算一致:

src/demo/rl_tictactoe/main.cpp · 初始化智能体(精简)
TabularQLearning::Config config;
config.alpha = 0.5;           // 学习率 α                         1
config.gamma = 0.99;          // 折扣 γ
config.epsilon = 1.0;         // 探索率, 训练初期几乎全随机          2
config.epsilon_min = 0.05;
config.epsilon_decay = 0.9995;
config.rand_seed = option.rand_seed;
agent.Init(config);
  1. α 控制每次 TD 更新挪多大幅度;γ 让智能体更看长远(接近 1 时更重视终局赢棋)。
  2. ε 从 1.0 指数衰减到 0.05——第 12 章 ε-greedy:先广探索,后多利用已学到的 Q。

4. 第三步 · 选动作:ε-greedy 与 TD 更新

每一步训练都要做两件事:按当前 Q 表选落子,再用环境反馈改 Q 表

src/deeplearning/rl/tabular_q_learning.cpp · SelectAction(精简)
if (explore && config_.epsilon > 0.0) {
  double sample = random.CreateRandom() / 1000000.0;
  if (sample < config_.epsilon)
    return TicTacToeEnv::ChooseRandomAction(legal_actions, ...);   1
}
return ArgmaxQ(state_key, legal_actions);                          2
  1. 探索:在合法空位里随机挑一格。
  2. 利用:挑 Q 最大的合法落子;Evaluateexplore=false,纯 greedy 下棋。
src/deeplearning/rl/tabular_q_learning.cpp · Update(精简)
double old_q = QRow(state_key)[action];
double target = reward;
if (!terminal)
  target += config_.gamma * MaxQ(next_state_key, legal_actions_next);  1
QRow(state_key)[action] = old_q + config_.alpha * (target - old_q);    2
  1. 贝尔曼目标 r + γ·max Q(s′,·)(第 12 章);终局时不再加未来项。
  2. TD 误差 target − old_q,乘以 α 写回——和第 6 章“误差驱动更新”同构,但没有反向传播。

5. 第四步 · 训练:RunEpisode 与外层循环

一个回合 = 从空盘下到终局。RunEpisode 封装了第 12 章内层循环: X 走 → O 走 → 更新 Q,直到 donemain.cpp 外层只是重复很多回合并衰减 ε。

src/deeplearning/rl/tabular_q_learning.cpp · RunEpisode(精简)
env.Reset();
while (!env.IsTerminal()) {
  int state_key = env.AgentStateKey();
  int action = SelectAction(state_key, env.LegalActions(), true);     1
  env.StepAgent(action, agent_step);
  if (agent_step.done) {
    Update(state_key, action, agent_step.reward, -1, {}, true);
    break;
  }
  env.StepOpponentRandom(opponent_action, opponent_step);            2
  if (opponent_step.done) {
    Update(state_key, action, opponent_step.reward, -1, {}, true);
    break;
  }
  Update(state_key, action, 0.0, opponent_step.agent_state_key,
         env.LegalActions(), false);                                  3
}
  1. 每步用 ε-greedy 选合法落子;非法位置会被环境拒绝(测试里专门测过)。
  2. 训练对手默认随机;也可 --opponent optimal 对抗 minimax(更难学、收敛慢)。
  3. 非终局步 reward=0,用对手走完后的新局面作 s′ 做 bootstrap——价值就这样从赢棋局面倒灌回开局。
src/demo/rl_tictactoe/main.cpp · 外层训练循环(精简)
for (int episode = 0; episode < option.episodes; episode++) {
  agent.RunEpisode(env, train_opponent, stats);
  agent.DecayEpsilon();                                              1
  // 每 5000 局打印: 累计胜/和/负、Q 表状态数、当前 ε
}
  1. 第 23 章 Train 的外层 epoch 类似,但数据不是固定数据集,而是自己下棋现生成的轨迹。
reset新回合
ε-greedy 落子第 12 章 §6
环境 stepr, done, s′
Update QTD 目标

监督学习的 Train 是“前向→损失→反向”;这里是“行动→奖励→改 Q 表”,没有标签、没有梯度

6. 第五步 · 评估与人机对战

训练完用 Evaluate 关掉探索,纯 greedy 连下很多局,统计胜/和/负。 默认 3 万局后对随机对手约 99% 胜率,对最优对手可稳守和棋(不输)。

src/demo/rl_tictactoe/main.cpp · 评估与可选对战(精简)
auto random_eval = agent.Evaluate(env, OpponentType::RANDOM, eval_games);
auto optimal_eval = agent.Evaluate(env, OpponentType::OPTIMAL, eval_games);
PrintEval("Eval vs random (greedy)", random_eval, eval_games);       1

if (option.play)
  PlayInteractive(env, agent);   // stdin 输入 0–8, 你是 O            2
  1. Evaluate 内部 SelectAction(..., false),不再随机探索,测的是“学成之后有多强”。
  2. --play 让你亲自和训练好的 X 下一盘;棋盘位置编号 0|1|2 / 3|4|5 / 6|7|8。

7. 从这张 Q 表,到 DQN 与 RLHF

井字棋合法局面有限,Q 表只有一两千行,查表 + TD 更新就够。 若状态变成游戏画面或围棋盘面,表存不下,就要上DQN用网络近似 Q; 若动作变成词表里几万个 token,就要上策略梯度 / PPO—— 第 20 章 RLHF 正是后者:大模型当策略,奖励模型当裁判。

自己跑一遍(强烈建议)

在仓库 src/ 目录下:
./build.sh  →  编译
./bin/rl_tictactoe --episodes 30000 --eval-games 500 --show-sample
观察每 5000 局的胜/和/负与 q_states 增长;再试 --play 和你训的 X 下一盘。 更全参数见仓库 docs/rl-tictactoe-demo.md

小结

  • RL 程序 = 环境 → Q 表 → 多回合 RunEpisode → 评估;没有固定数据集,轨迹是智能体自己下的棋。
  • TicTacToeEnv:三进制 state_key、终局奖励、合法动作过滤,对应第 12 章 MDP 五件套。
  • TabularQLearning:ε-greedy 选子、TD 更新 Q、DecayEpsilon 减探索。
  • RunEpisode = agent 走 → opponent 走 → Update;价值从赢棋终局倒灌回开局空盘。
  • 状态/动作再变大 → DQN / PPO / RLHF(第 12 章第 20 章)。

动手与思考

问题 1:井字棋 demo 为什么没有用到反向传播?

状态空间小,用 Q 存得下;学习信号是 TD 目标 r+γ·max Q(s′,·),直接改表里的数,不需要神经网络,也就不需要第 6 章的梯度。DQN 才会把 Q 换成网络并反传。

问题 2:RunEpisode 里为什么中间步 reward 是 0 仍要 Update?

因为 TD 更新不只看眼前 r,还看 γ·max Q(s′,·)。对手走完后的新盘面若更接近赢棋,会把更高的 Q 传回上一步——这就是第 12 章说的价值倒灌,即使中间步没给分。

问题 3:训练和评估时 SelectAction 的 explore 参数为什么不同?

训练要 ε-greedy 探索新走法;评估要测“学成之后有多强”,应关掉随机探索、纯 greedy 下——否则胜率会被探索的烂棋拉低,测不准真实水平。

全书完 · 谢谢你读到这里

你从一个神经元出发,搭起网络、学会反向传播、认识强化学习、拼出 Transformer、 做成语言模型,又看懂大模型的原理与用法——最后,在三份真实的 C++ 代码里 (MNIST、字符级 LM、井字棋 Q-learning) 逐行对上了号。监督学习、生成式模型、强化学习,你都握在手里了。

接下来最好的学习,是动手改:换优化器、改 Config、调 ε 和 γ、换对手强度,看曲线怎么变。 好奇心带你入门,动手让你真正拥有它。

返回目录 / 重读某章