news 2026/10/23 12:57:29

增强学习导论代码实战:从多臂老虎机到Q-learning的避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
增强学习导论代码实战:从多臂老虎机到Q-learning的避坑指南

简介:这份资源是《Reinforcement Learning: An Introduction》第二版草稿的配套代码实现,面向正在学习增强学习、希望把书本理论落到实处的学生与开发者。它围绕智能体、环境、动作与奖励的交互过程,覆盖马尔科夫决策、探索与利用平衡、经验回放、神经网络近似等核心环节,适合作为课程实验或自学练手的参考。压缩包共22个文件,以18个Python脚本为主体,另含README说明、.gitignore及策略文件,整体约90KB,体量轻便。代码按章节组织,涉及多臂老虎机、网格世界、悬崖行走、赌徒问题、二十一点、随机游走等经典实验场景,并包含Q学习、SARSA、DQN、策略梯度等算法的实现思路,便于读者对照书中公式逐章复现与调试。目前已有279人学习下载,可帮助读者在动手实践中理解算法原理、积累调参经验,为游戏AI、自动驾驶、推荐系统等应用方向打下基础。

1. 从“看懂公式”到“跑出曲线”:增强学习导论代码到底该怎么用

很多人第一次翻开《Reinforcement Learning: An Introduction》第二版草稿时,前几章还能跟上,到了策略迭代、时序差分那一块,公式一多就开始飘。真正让人卡住的不是数学,而是不知道这些更新式落到代码里长什么样。增强学习导论代码的价值就在这里:它把书里的伪代码逐条翻译成可运行的小实验,让你能亲眼看到价值估计怎么收敛、策略怎么一步步变好。这份代码适合两类人:一类是刚学完监督学习、想切入强化学习但被公式劝退的工程师;另一类是想把经典算法当基线,用来对比自己新方法的从业者。它不依赖大型框架,核心逻辑用几十行就能跑通,特别适合拿来当“黑匣子”拆开看。下面我按自己复现时的顺序,把环境、算法、参数和踩坑点讲清楚。

2. 把多臂老虎机当成第一个实验台:环境搭建与最小可跑闭环

2.1 为什么从多臂老虎机而不是从游戏环境开始

很多人一上来就想跑 Atari 或者 MuJoCo,结果环境装了两天,算法还没写一行。多臂老虎机是整本书里最干净的一个实验台:没有状态转移,只有动作和奖励,价值更新逻辑和后面时序差分完全一致。我一般会先在这个环境里把“估计值—动作选择—奖励反馈”这个闭环跑通,确认自己的理解没有偏差,再往复杂环境迁移。它的另一个好处是结果可视化极快,几百次实验就能画出平均奖励曲线,能立刻判断参数是不是设歪了。

2.2 用 numpy 实现一个可复现的 k 臂老虎机

下面这段代码是我自己常用的最小实现,去掉了所有非必要依赖,只保留 numpy。它把奖励分布、动作选择和增量式更新都放在一个类里,方便你改参数做对比实验。

import numpy as np class Bandit: def __init__(self, k=10, seed=42): self.k = k self.rng = np.random.default_rng(seed) # 每个臂的真实均值从标准正态分布采样,固定下来保证可复现 self.true_means = self.rng.normal(0, 1, k) def step(self, action): # 奖励 = 真实均值 + 单位正态噪声 return self.true_means[action] + self.rng.normal(0, 1) def run_epsilon_greedy(bandit, steps=1000, epsilon=0.1, alpha=0.1): k = bandit.k Q = np.zeros(k) # 动作价值估计 N = np.zeros(k) # 每个动作被选次数 rewards = [] for t in range(steps): if bandit.rng.random() < epsilon: action = bandit.rng.integers(k) # 探索 else: action = np.argmax(Q) # 利用 r = bandit.step(action) N[action] += 1 # 增量式更新,alpha 固定时等价于指数近因加权 Q[action] += alpha * (r - Q[action]) rewards.append(r) return np.array(rewards), Q

逻辑说明:true_means是每个臂的真实期望奖励,固定种子后每次运行都一致,方便对比不同算法。step里加噪声是为了模拟真实反馈的不确定性。run_epsilon_greedy里用alpha做增量更新,而不是简单的平均,这样后面换成非平稳环境时只需要改一个参数。参数方面,epsilon控制探索比例,通常从 0.1 开始试;alpha是学习率,0.1 在平稳环境下收敛快但波动大,改成 0.01 会更稳。如果你发现曲线一直上不去,先检查epsilon是不是太小导致某些臂从没被选过。

2.3 把实验结果画成能对比的曲线

跑一次不够,我一般会固定其他参数,只改一个变量跑多组,然后取平均。下面这段代码把不同 epsilon 的曲线画在一起,能直观看到探索和利用的权衡。

import matplotlib.pyplot as plt def average_over_runs(epsilon, runs=200, steps=1000): all_rewards = [] for i in range(runs): bandit = Bandit(k=10, seed=i) rewards, _ = run_epsilon_greedy(bandit, steps=steps, epsilon=epsilon) all_rewards.append(rewards) return np.mean(all_rewards, axis=0) for eps in [0.01, 0.1, 0.3]: avg = average_over_runs(eps) plt.plot(avg, label=f"epsilon={eps}") plt.xlabel("Steps") plt.ylabel("Average reward") plt.legend() plt.show()

这里runs=200是为了让平均曲线平滑,实际调试时可以降到 20 加快速度。注意每次运行都用不同的种子生成环境,但同一个种子下环境是固定的,这样对比才公平。如果你看到 epsilon=0.01 的曲线前期涨得慢、后期反而高,说明它前期探索不足但后期利用充分,这正是书里想让你观察到的现象。

3. 从策略迭代到价值迭代:网格世界里的动态规划落地

3.1 网格世界的状态、动作和奖励怎么定义

动态规划那一章的核心是策略评估和策略改进的交替。为了让更新过程可见,我用一个 4x4 的网格世界:左上角和右下角是终止状态,每走一步奖励 -1,撞墙不动。状态用 (row, col) 表示,动作是上下左右四个方向。这个环境足够小,价值函数可以打印成表格,每一步更新都能肉眼检查。

import numpy as np class GridWorld: def __init__(self, size=4): self.size = size self.n_states = size * size self.n_actions = 4 # 上 下 左 右 self.terminal = [0, size * size - 1] def step(self, state, action): if state in self.terminal: return state, 0 row, col = divmod(state, self.size) if action == 0: row = max(row - 1, 0) elif action == 1: row = min(row + 1, self.size - 1) elif action == 2: col = max(col - 1, 0) elif action == 3: col = min(col + 1, self.size - 1) next_state = row * self.size + col return next_state, -1

terminal里的状态一旦进入就停在原地且奖励为 0,这是为了和书里的定义保持一致。step里对边界做了截断,相当于撞墙不动。如果你想让撞墙有额外惩罚,可以在截断时返回一个更小的奖励,但那样价值函数的形状会变,对比书里结果时要注意。

3.2 策略评估的迭代实现与收敛判断

策略评估就是反复套用贝尔曼期望方程,直到价值变化小于阈值。下面这个函数把同步更新和异步更新都留了口子,默认用同步更新,方便和书里对照。

def policy_evaluation(policy, env, gamma=0.9, theta=1e-6, max_iter=1000): V = np.zeros(env.n_states) for i in range(max_iter): delta = 0 for s in range(env.n_states): if s in env.terminal: continue v_old = V[s] v_new = 0 for a in range(env.n_actions): prob = policy[s][a] next_s, reward = env.step(s, a) v_new += prob * (reward + gamma * V[next_s]) V[s] = v_new delta = max(delta, abs(v_old - v_new)) if delta < theta: break return V

theta是收敛阈值,设成 1e-6 时通常几十轮就稳定。gamma=0.9让远处奖励衰减,网格小的时候可以调到 0.99 看更远的影响。注意这里用的是同步更新,也就是每一轮都用上一轮的 V 计算,如果你改成原地更新,收敛会快一些但结果略有不同,书里两种都提过。

3.3 策略改进和截断策略迭代的取舍

策略改进就是根据当前 V 选每个状态下最好的动作。如果多个动作价值相同,我一般保留原来的动作,避免策略来回震荡。

def policy_improvement(V, env, gamma=0.9): policy = np.zeros((env.n_states, env.n_actions)) for s in range(env.n_states): if s in env.terminal: policy[s] = np.ones(env.n_actions) / env.n_actions continue q_values = [] for a in range(env.n_actions): next_s, reward = env.step(s, a) q_values.append(reward + gamma * V[next_s]) best = np.argmax(q_values) policy[s][best] = 1.0 return policy

把评估和改进交替跑,就是策略迭代。如果评估只跑一轮就改进,就变成价值迭代。实际用的时候,价值迭代通常更快,因为不需要等评估完全收敛。我一般先用价值迭代快速拿到最优策略,再用策略迭代验证结果是否一致。如果你发现策略在几个状态之间反复横跳,检查一下gamma是不是太大导致价值估计噪声被放大。

4. 时序差分与 Q-learning:无模型方法怎么调才不翻车

4.1 TD(0) 和蒙特卡洛的差别在代码里长什么样

蒙特卡洛要等整个回合结束才能更新,时序差分走一步就能更新。这个差别在代码里就是更新时机的不同。下面这个 TD(0) 实现放在同一个网格世界里,和上一章的动态规划共用环境。

def td_zero(env, episodes=500, alpha=0.1, gamma=0.9, epsilon=0.1): V = np.zeros(env.n_states) for ep in range(episodes): s = np.random.randint(env.n_states) while s not in env.terminal: if np.random.random() < epsilon: a = np.random.randint(env.n_actions) else: # 用当前 V 做一步前瞻选动作 qs = [] for act in range(env.n_actions): ns, r = env.step(s, act) qs.append(r + gamma * V[ns]) a = int(np.argmax(qs)) next_s, reward = env.step(s, a) V[s] += alpha * (reward + gamma * V[next_s] - V[s]) s = next_s return V

alpha是学习率,TD 里通常比动态规划小,0.1 是常用起点。epsilon控制行为策略的探索,因为 TD(0) 是异策略还是同策略取决于你怎么选动作,这里用的是 epsilon-greedy,属于同策略。如果你把选动作改成完全贪心,就变成异策略,但需要配合重要性采样,书里后面才讲。

4.2 Q-learning 的更新式和三个必调参数

Q-learning 的更新式是Q(s,a) += alpha * (r + gamma * max Q(s',a') - Q(s,a)),注意 max 是对下一个状态的所有动作取最大,和 SARSA 的 on-policy 更新不同。下面是一个表格形式的参数对照,方便你调的时候有参照。

参数作用常用范围调大后果调小后果
alpha学习率0.01 ~ 0.5更新快但震荡收敛慢但稳
gamma折扣因子0.8 ~ 0.99看重远期奖励短视
epsilon探索率0.01 ~ 0.3探索多但利用少容易卡在局部最优

我一般先用 alpha=0.1、gamma=0.9、epsilon=0.1 跑通,再根据曲线调整。如果 Q 值一直涨不上去,先看 epsilon 是不是太小导致某些状态动作从没被访问过。

4.3 用 Q-learning 跑网格世界并检查收敛

def q_learning(env, episodes=1000, alpha=0.1, gamma=0.9, epsilon=0.1): Q = np.zeros((env.n_states, env.n_actions)) for ep in range(episodes): s = np.random.randint(env.n_states) while s not in env.terminal: if np.random.random() < epsilon: a = np.random.randint(env.n_actions) else: a = int(np.argmax(Q[s])) next_s, reward = env.step(s, a) best_next = np.max(Q[next_s]) Q[s, a] += alpha * (reward + gamma * best_next - Q[s, a]) s = next_s return Q

跑完之后可以用np.argmax(Q, axis=1)看每个状态的最优动作,和动态规划的结果对比。如果大部分状态一致,说明实现没问题。注意终止状态的 Q 值不会被更新,因为循环条件排除了它们。如果你发现某个状态的 Q 值异常大,检查一下是不是next_s落在终止状态时best_next仍然被加了进去,终止状态的价值应该是 0。

5. 避坑与排查:复现书里算法时最容易翻车的五个地方

5.1 随机种子没固定,曲线每次都不一样

现象:同一份代码跑两次,平均奖励曲线形状差很多,甚至结论相反。原因:环境初始化和动作选择都用了全局随机数,没有固定种子。解决:在实验开始处统一设置种子,并且把环境生成和策略探索的随机源分开。我一般用np.random.default_rng(seed)生成独立随机源,避免互相干扰。

5.2 学习率设太大,价值估计直接发散

现象:Q 值或 V 值在几百步后变成 NaN 或者异常大的数。原因:alpha 太大,增量更新时旧估计被新奖励完全覆盖,误差被反复放大。解决:先把 alpha 降到 0.01 跑一遍,确认能收敛后再逐步调大。如果环境奖励尺度很大,还要先把奖励归一化。

5.3 终止状态处理不对,价值函数被污染

现象:最优策略在终止状态附近来回走,或者价值函数在终止状态不为零。原因:更新时没有跳过终止状态,或者把终止状态的下一状态价值也算进去了。解决:在循环里显式判断终止状态,确保它的价值始终为 0,并且不参与任何更新。

5.4 epsilon 衰减策略没设计好,后期还在乱探索

现象:曲线前期上升很快,后期一直波动上不去。原因:epsilon 固定不变,后期还在以固定概率随机选动作。解决:让 epsilon 随回合数线性或指数衰减,比如从 0.3 降到 0.01。但衰减太快会导致前期探索不足,我一般用线性衰减,前 30% 回合保持高探索。

5.5 用错更新顺序,on-policy 和 off-policy 混在一起

现象:SARSA 和 Q-learning 的结果差异和书里对不上。原因:选下一个动作时用了贪心,但更新时又用了 max,导致行为策略和目标策略不一致。解决:SARSA 必须用实际选出的下一个动作来更新,Q-learning 才用 max。写代码时把选动作和更新分开成两个函数,避免混用。

6. 把实验变成可复用的对比框架:一个参数扫描的小技巧

跑通单个算法之后,真正有价值的是能快速对比不同参数和不同算法的表现。我一般会写一个很小的扫描脚本,把环境、算法和参数组合成配置字典,循环跑完自动汇总成表格。下面这个例子把 Q-learning 和 SARSA 放在一起对比,只改一个参数就能看出差别。

def run_experiment(algo, env, episodes=500, **kwargs): if algo == "q_learning": Q = q_learning(env, episodes=episodes, **kwargs) elif algo == "sarsa": Q = sarsa(env, episodes=episodes, **kwargs) # 用贪心策略跑一轮,统计总奖励 s = 0 total_reward = 0 while s not in env.terminal: a = int(np.argmax(Q[s])) s, r = env.step(s, a) total_reward += r return total_reward configs = [ {"algo": "q_learning", "alpha": 0.1, "epsilon": 0.1}, {"algo": "sarsa", "alpha": 0.1, "epsilon": 0.1}, {"algo": "q_learning", "alpha": 0.05, "epsilon": 0.2}, ] for cfg in configs: algo = cfg.pop("algo") score = run_experiment(algo, GridWorld(), **cfg) print(algo, cfg, "total_reward=", score)

这个框架的好处是配置和算法解耦,加新算法只需要在run_experiment里多一个分支。参数扫描时我一般会固定其他参数,只动一个,否则结果很难归因。另外,单次运行的总奖励波动很大,最好跑 20 次取平均,再比较均值。如果你发现两个算法均值差不多但方差差很多,说明其中一个对参数更敏感,实际部署时要留更多余量。

最后说一个我自己的习惯:每次改完代码,先用一个极小的回合数(比如 10)跑一遍,确认没有报错和 NaN,再放大到几百回合。这个习惯帮我省下了很多等结果的时间。另外,书里的伪代码和代码实现之间往往差着几个边界条件,遇到对不上的地方,先回去看伪代码的循环终止条件和状态初始化,十有八九是那里漏了。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 9:19:50

【气流流型验证】欧盟、FDA 都在看人的动作,你还只盯着烟雾形态?

做无菌生产的人都知道&#xff0c;气流流型验证 —— 也有人叫烟雾试验、气流可视化 —— 是厂房确认里绕不开的一项。可是在不少企业把这件事做窄了&#xff1a;盯着烟雾录像反复看流线直不直、有没有回流&#xff0c;却忽略了一个同样要命的变量 —— 人。烟雾好看&#xff0…

作者头像 李华
网站建设 2026/10/23 11:28:10

QQ空间数据备份实践:接口同步、断点续传与增量备份

简介&#xff1a;QZoneExport-master 是一个面向 QQ 空间用户的数据导出工具项目包&#xff0c;适合需要备份个人动态、留言、相册等内容的普通用户&#xff0c;也适合希望研究社交平台数据交互的开发者。压缩包共含149个文件&#xff0c;大小约2.59MB&#xff0c;以 JavaScrip…

作者头像 李华
网站建设 2026/10/22 10:19:14

数独人工验证技巧:三轴锚定法与冲突定位黄金三角

1. 为什么“有效数独”不是一道简单的判断题“36. 有效的数独&#xff08;技巧&#xff09;”——这个标题乍看像一道算法题编号&#xff0c;但真正做过数独的人一眼就能看出门道&#xff1a;它根本不是考你能不能写个for循环遍历九宫格。我带过不少刚接触逻辑推理的新手&#…

作者头像 李华
网站建设 2026/10/23 5:42:41

STM32F103裸机摄像头循迹:OV7670图像采集与实时PID控制

简介&#xff1a;本资源是一个基于STM32F103微控制器的智能小车视觉循迹与避障综合实践项目&#xff0c;面向嵌入式初学者、电子设计竞赛备赛学生及智能硬件爱好者&#xff0c;解决从图像采集、二值化处理、路径识别到实时运动控制的全链路开发问题。压缩包共166个文件&#xf…

作者头像 李华
网站建设 2026/10/22 6:35:46

openrig 环境编排实战:Claude Code 与 Codex 终端 AI 助手配置指南

1. 从零认识 openrig&#xff1a;它到底解决什么问题第一次看到 openrig 这个名字&#xff0c;很多人会以为是某个硬件支架或者开源机械臂项目。实际上&#xff0c;在当前的开发者圈子里&#xff0c;openrig 更多被提及的场景&#xff0c;是围绕 Claude Code、Codex 这类终端 A…

作者头像 李华