第 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) 表,而不是反向传播里的梯度(本章没有神经网络)。
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 表”,没有标签、没有梯度。
6. 第五步 · 评估与人机对战
训练完用 Evaluate 关掉探索,纯 greedy 连下很多局,统计胜/和/负。
默认 3 万局后对随机对手约 99% 胜率,对最优对手可稳守和棋(不输)。
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。
小结
动手与思考
问题 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、调 ε 和 γ、换对手强度,看曲线怎么变。 好奇心带你入门,动手让你真正拥有它。