第 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,典型稀疏奖励)。
对应第 12 章 agent–env 循环 + Q-learning 更新。外层再重复很多回合。
MNIST 的“标签”是人工给的数字;语言模型的“标签”是语料里自带的下一个字。
井字棋没有任何一步的标准落法,只有走完才知道输赢——训练信号来自 ApplyMove 里的 reward,
学习目标是一张 Q(s,a) 表,而不是反向传播里的梯度(本章没有神经网络)。
这里直接读取发布产物里的真实 Q 表。你执 O,页面里的智能体执 X;每一步都会把合法动作的 Q 值展开给你看。
2. 第一步 · 环境:棋盘、状态编码与奖励
TicTacToeEnv 就是第 12 章的“环境”。它维护 9 格棋盘、判断输赢、在轮到智能体时返回 state_key。
状态用三进制整数编码:每格 空/X/O → 0/1/2,压成一个 int 当作 Q 表的键。
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;
}
- 只在轮到 X(智能体)时调用
AgentStateKey()——Q 表存的是“轮到我下时,这盘棋值多少”。
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
}
- 奖励只在终局给出,对应第 12 章稀疏奖励;中间步
reward=0,靠 TD 目标里的max Q(s′,·)把价值从终局往回传。 - 若未结束,交换行棋方,并准备好下一轮智能体行动时的状态键,供
Update使用。
对手有两种:StepOpponentRandom(训练常用)和 StepOpponentOptimal(minimax 最优,用于评估“会不会输”)。
3. 第二步 · Q 表与超参:α、γ、ε
TabularQLearning 维护 unordered_map<state_key, vector<double>>——每个状态 9 个动作的 Q 值,初始全 0。
超参与第 12 章手算一致:
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);
- α 控制每次 TD 更新挪多大幅度;γ 让智能体更看长远(接近 1 时更重视终局赢棋)。
- ε 从 1.0 指数衰减到 0.05——第 12 章 ε-greedy:先广探索,后多利用已学到的 Q。
4. 第三步 · 选动作:ε-greedy 与 TD 更新
每一步训练都要做两件事:按当前 Q 表选落子,再用环境反馈改 Q 表。
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
- 探索:在合法空位里随机挑一格。
- 利用:挑 Q 最大的合法落子;
Evaluate时explore=false,纯 greedy 下棋。
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
5. 第四步 · 训练:RunEpisode 与外层循环
一个回合 = 从空盘下到终局。RunEpisode 封装了第 12 章内层循环:
X 走 → O 走 → 更新 Q,直到 done。main.cpp 外层只是重复很多回合并衰减 ε。
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
}
- 每步用 ε-greedy 选合法落子;非法位置会被环境拒绝(测试里专门测过)。
- 训练对手默认随机;也可
--opponent optimal对抗 minimax(更难学、收敛慢)。 - 非终局步 reward=0,用对手走完后的新局面作 s′ 做 bootstrap——价值就这样从赢棋局面倒灌回开局。
for (int episode = 0; episode < option.episodes; episode++) {
agent.RunEpisode(env, train_opponent, stats);
agent.DecayEpsilon(); 1
// 每 5000 局打印: 累计胜/和/负、Q 表状态数、当前 ε
}
- 与第 23 章
Train的外层 epoch 类似,但数据不是固定数据集,而是自己下棋现生成的轨迹。
监督学习的 Train 是“前向→损失→反向”;这里是“行动→奖励→改 Q 表”,没有标签、没有梯度。
跑起来会看到什么:训练日志速览
光看代码不够,跑一遍才踏实。下面是默认配置(--episodes 30000 --opponent random,seed 0)每 5000 局打印的真实日志,
三样东西同时在变——ε 在降、胜局在涨、Q 表在长:
| 局数 | 累计 胜/和/负 | 这 5000 局胜率* | q_states | ε |
|---|---|---|---|---|
| 5000 | 3764 / 383 / 853 | 75.3% | 2010 | 0.082 |
| 10000 | 8498 / 529 / 973 | 94.7% | 2062 | 0.05 |
| 15000 | 13273 / 666 / 1061 | 95.5% | 2087 | 0.05 |
| 20000 | 18065 / 784 / 1151 | 95.8% | 2107 | 0.05 |
| 25000 | 22834 / 939 / 1227 | 95.4% | 2125 | 0.05 |
| 30000 | 27610 / 1087 / 1303 | 95.5% | 2143 | 0.05 |
*“这 5000 局胜率”是用相邻两行的累计胜局差 ÷ 5000 现算的,反映那一段的水平(累计胜率会被早期乱下的局拖低)。可以看到:ε 从 1.0 一路衰减到下限 0.05 后,胜率就稳定在 95% 上下;q_states(见过的不同局面数)很快收敛到两千出头——井字棋的局面就这么多,这也是查表能行的根本原因。
训练日志里还夹着 1300 局“负”,是因为训练时故意留着 ε=0.05 的随机探索,时不时自己走一步烂棋。 等 第 6 节评估时关掉探索、纯 greedy 下,胜率会更高更稳——这正是下面第 3 题要问的“训练和评估为什么不一样”。
6. 第五步 · 评估与人机对战
训练完用 Evaluate 关掉探索,纯 greedy 连下很多局,统计胜/和/负。程序会分别对随机和最优两种对手各评估一遍。
默认(对随机训练)的真实结果是:对随机对手 984 胜 / 13 和 / 3 负(不败率 99.7%);对最优对手则几乎全输。
默认拿随机对手陪练,智能体学到的是“怎么惩罚乱下的人”,专打随机对手很强,可它从没见过高手的严密下法,遇到 minimax 最优对手就成片地输。
反过来,加 --opponent optimal 拿最优对手陪练,它就能对最优对手稳守和棋(0 胜 0 负、全和,不败率 100%)——井字棋双方都不犯错本就必然是和棋;
但这么练出来的它,反而对随机对手只有约 59% 胜率(因为它学的是“稳不输”,而不是“逮住对方失误往死里赢”)。
| 训练对手 | 评估 vs 随机 | 评估 vs 最优 | 它学到的“打法” |
|---|---|---|---|
| random(默认) | 胜 98.4% · 不败 99.7% | 几乎全输 | 逮失误猛攻,不会防守 |
| optimal | 胜 ~59% · 不败 72% | 全和 · 不败 100% | 只求不输,不贪赢 |
同一套代码、同一套超参,只换了陪练对手,学出来的策略性格就完全不同。这不是 bug,而是强化学习的本质:智能体只能从它实际经历过的对局里学,训练分布决定能力边界——这也是第 20 章 RLHF 里“奖励从哪来、跟谁对齐”如此关键的原因。
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
Evaluate内部SelectAction(..., false),不再随机探索,测的是“学成之后有多强”。--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;价值从赢棋终局倒灌回开局空盘。- 跟谁练就擅长打谁:对随机训练→对随机不败率 99.7% 但打不过最优;对最优训练→守和最优(不败 100%)但对随机只剩约 59% 胜。训练分布决定能力边界。
- 状态/动作再变大 → 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 下——否则胜率会被探索的烂棋拉低,测不准真实水平。
问题 4:同一份代码,为什么对随机对手训练出来的智能体打不过最优对手,反过来对最优训练的又赢不了几个随机对手?
因为智能体只能从它实际经历过的对局里学。拿随机对手练,它见惯了“对方乱下”,学会的是逮失误猛攻,没见过高手的严密下法,遇到最优对手就成片输;拿最优对手练,它学会的是“怎么都不输”(井字棋双方无失误必和),却没练过“逮住对方失误往死里赢”,所以对爱犯错的随机对手反而赢得少。这就是强化学习的训练分布决定能力边界,也是 第 20 章 RLHF 里“奖励/对手跟谁对齐”特别关键的原因。
主线实战收束 · 你已经跑通了
你从一个神经元出发,搭起网络、学会反向传播、认识强化学习、拼出 Transformer、 做成语言模型,又看懂大模型的原理与用法——最后,在三份真实的 C++ 代码里 (MNIST、字符级 LM、井字棋 Q-learning) 逐行对上了号。到这里,正文主线和代码实战已经完整闭环。
接下来最好的学习,是动手改:换优化器、改 Config、调 ε 和 γ、换对手强度,看曲线怎么变。 好奇心带你入门,动手让你真正拥有它。
第七部分不再继续写新 demo,而是补四块番外地图:先用机器学习全景图摊开 AI/ML/DL 的关系, 再分别补上无监督与自监督、决策树与随机森林, 以及从沙子到 CPU/GPU这条硬件链路。