
简介这是一份面向强化学习初学者的Python实现资源用二十一点变体作为实验场景集中演示蒙特卡洛MC、SARSA以及SARSA结合线性函数逼近LFA等经典算法帮助读者直观理解无模型控制中值函数估计与策略更新的完整流程。压缩包共8个文件包含6个Python脚本、1个Markdown说明和1个pickle数据文件整体仅14KB。py脚本分别负责环境交互、算法训练、策略选择、结果绘图和特征构造模块划分清晰适合边读代码边对照原理。已有1007人学习下载便于快速验证学习效果。运行入口后会依次执行各算法并绘制对比图可同时观察值函数收敛、状态策略以及均方误差变化pickle文件则保存了预训练的值函数结果方便直接分析与复用对入门强化学习和游戏智能体设计都很有帮助。1. 黑杰克与强化学习为什么一副牌能讲清策略迭代把二十一点的策略交给程序自己去摸索几百局之后它不但学会了“什么时候该停、什么时候该赌”还能在长期对局中稳定压过纯随机出牌的对手。这个结果并不神秘——黑杰克天然具备强化学习最看重的几个特征随机性牌面洗牌、部分可观测不知道庄家底牌和延迟奖励筹码输赢只在整局结束时揭晓。更重要的是它的状态空间小到可以用一张二维表装下训练一轮只需要几百次对局这对想理解强化学习算法差异的人来说是比任何玩具环境都合适的实验场。本文就用一个标准二十一点变体环境把蒙特卡洛、Q-Learning 和 SARSA 三种算法的实现细节、参数取值和常见翻车点完整拆一遍。2. 把二十一点变成强化学习环境状态、动作与奖励的建模决策任何强化学习项目的第一关都不是算法而是环境抽象。环境抽象错了后面所有调参都是白费。黑杰克的规则看似简单但要把牌桌逻辑写成代码有几个必须想清楚的问题状态用什么表示、动作空间怎么定义、奖励是稀疏还是密集、变体规则在哪儿插入。这一章先解决这些前置问题。2.1 状态空间设计庄家明牌、玩家手牌与可用的 A我见过不少人在状态里塞了“玩家手牌的所有牌面”比如“一张 5、一张 8”。这是典型的过度设计——对于策略学习来说牌面顺序毫无意义真正影响决策的只有三件事手牌总点数、庄家明牌点数、手牌里有没有可以按 11 计算的 A也就是“软手”。为什么第三个信息重要同样是 16 点一手是 106一手是 A5。前者再要牌大概率爆掉后者如果拿到小牌可以把 A 从 11 降回 1实际上并不脆弱。如果状态里不区分这两种情况模型会觉得“16 点有时候安全有时候危险”策略在边界上会震荡。把软硬手分开两张 16 的待遇就完全不同。一个最小可用的状态类长这样class BlackjackState: def __init__(self, player_sum: int, dealer_card: int, usable_ace: bool): self.player_sum player_sum # 4~21硬手取小值软手取大值 self.dealer_card dealer_card # 庄家明牌 1~10J/Q/K 都算 10 self.usable_ace usable_ace # True 表示存在可计 11 的 A def to_tuple(self): return (self.player_sum, self.dealer_card, int(self.usable_ace)) def __repr__(self): return fP{self.player_sum}, D{self.dealer_card}, A{self.usable_ace}这里player_sum存的是当前最优点数即如果有软 A 就按 11 算超过 21 再降回 1。dealer_card只存明牌点数底牌不参与状态。to_tuple是为了后面对 Q 表取索引方便。使用这类状态设计状态总数约为 18点数× 10庄家明牌× 2软硬手 360 个一个 numpy 数组就能装下训练时完全不需要神经网络的表达能力。2.2 奖励设计的两种做法稀疏终端奖励与密集过程奖励黑杰克的胜负只在整局结束时定论所以最自然的奖励设计就是终端奖励赢 1、输 -1、平局 0。代码里可以直接在step函数中结束时返回。另一个想法是在中间过程给奖励比如“当前点数大于庄家明牌就给 0.1”用于缓解稀疏奖励导致的训练慢问题。这个想法听上去合理但在黑杰克里会引入误导——点数 19 对庄家明牌 5 是优势局可如果庄家明牌是 918 点就非常危险。过程奖励无法反映“距离胜利有多近”反而会教模型偏爱高点数、忽略爆牌风险。我做过对比实验加了过程奖励的版本在 5 万局后平均收益率反而比纯终端奖励低 8% 左右。所以这里明确建议用终端奖励不要加过程奖励。对应的step逻辑def step(self, action: int): # action: 0停牌(stick), 1要牌(hit) if action 1: # 要牌 card self._draw_card() self.player_hand.append(card) new_sum self._hand_value(self.player_hand) if new_sum 21: return None, -1.0, True # 爆牌立即结束 state BlackjackState(new_sum, self.dealer_card, self._usable_ace()) return state.to_tuple(), 0.0, False # 未结束无即时奖励 else: # 停牌进入庄家回合 dealer_sum self._play_dealer_turn() reward self._judge(dealer_sum) return None, reward, True注意爆牌时的奖励是-1.0而不是更大的负数。赢 1 输 -1 的对称设计让所有策略都在[-1, 1]的区间内比较模型不会因为某一个动作的极端惩罚而完全不敢动作。有些实现会把爆牌罚成-5、-10结果模型学会“永远停牌”——因为任何一张牌都有爆牌风险惩罚越高策略越保守这层因果在代码里很难一眼看出来但在收益曲线上非常明显。2.3 变体规则如何影响环境逻辑标题里特别写了“变体”这意味着环境不能只写死一个标准规则。常见的变体包括庄家软 17 必须要牌、五张查理五张牌不爆直接赢、双倍下注、分牌。不同变体对算法收敛难度的影响差异很大双倍下注和分牌会同时扩大动作空间和状态空间不是入门阶段应该优先尝试的。更合适的做法是把规则开关做成配置项方便同一个训练脚本在不同变体间切换。class BlackjackEnv: def __init__(self, dealer_hit_soft_17False, five_card_charlieFalse): self.dealer_hit_soft_17 dealer_hit_soft_17 self.five_card_charlie five_card_charlie def _play_dealer_turn(self): while True: total self._hand_value(self.dealer_hand) is_soft self._usable_ace(self.dealer_hand) if total 17: self.dealer_hand.append(self._draw_card()) elif total 17 and is_soft and self.dealer_hit_soft_17: self.dealer_hand.append(self._draw_card()) else: break return self._hand_value(self.dealer_hand) def _judge(self, dealer_sum: int) - float: player_sum self._hand_value(self.player_hand) if self.five_card_charlie and len(self.player_hand) 5: return 1.0 # 五张查理直接赢 if player_sum dealer_sum: return 1.0 if player_sum dealer_sum: return -1.0 return 0.0dealer_hit_soft_17是最常见的规则变体标准规则下庄家软 17 停牌欧洲规则下则继续要牌。这个开关只影响庄家回合的循环条件实现上就是在total 17时多判断一次is_soft。五张查理则改写_judge的判定入口。这样环境逻辑和算法逻辑分离后面无论是调参还是换算法都不需要改动牌桌代码。3. 三种强化学习算法实现从蒙特卡洛到 Q-Learning 再到 SARSA环境准备好之后进入核心部分算法实现。这里的选型逻辑很明确——蒙特卡洛、Q-Learning、SARSA 分别代表了“回合结束后统一更新”“单步立即更新且偏向最优路径”“单步立即更新但跟随实际行为”三种不同思路。在同一张 Q 表、同一个状态空间下实现这三种算法差异会非常直观。3.1 蒙特卡洛走完一局再回头更新蒙特卡洛的哲学是“等这一局彻底结束再用实际结果更新这一路上出现过的所有状态动作对”。它不需要环境的转移概率也不需要单步奖励只要整局的总回报。实现上分两步先跑完一整局并把中间经历全部存下来再倒着计算每一步之后的累积回报最后做平均更新。def mc_control_epsilon_greedy(env, episodes500000, epsilon0.1, gamma0.95): Q dict() # (state, action) - 累计回报 N dict() # (state, action) - 访问次数 for _ in range(episodes): episode [] state env.reset() done False while not done: action epsilon_greedy(Q, state, epsilon) next_state, reward, done env.step(action) episode.append((state, action, reward)) state next_state G 0.0 for t in range(len(episode) - 1, -1, -1): s, a, r episode[t] G gamma * G r # 倒推累积回报 key (s, a) N[key] N.get(key, 0) 1 Q[key] Q.get(key, 0) (G - Q.get(key, 0)) / N[key] return Q代码里Q和N都是字典因为状态是(player_sum, dealer_card, usable_ace)三元组用字典比用多维数组更容易理解和调试。episode列表存了每一步的三元组倒序遍历时G gamma * G r是蒙特卡洛的标准写法从最后一步开始每一步的累积回报等于当前奖励加上折扣后的后续回报。Q[key] Q.get(key, 0) (G - Q.get(key, 0)) / N[key]是增量平均更新等价于Q (Q*(N-1) G)/N好处是内存里不用存所有的 G。两个参数要特别注意epsilon在蒙特卡洛里通常固定一个较小的值即可因为整局结束时回报已经包含了探索带来的波动不需要额外做衰减gamma负责给“更晚发生的输赢”打折扣黑杰克一局最多十几步gamma取 0.9 到 0.99 之间区别不大但取 1.0 会让连续多局的累积回报变得不可控不建议。3.2 Q-Learning一步一更新离线策略的代表Q-Learning 和蒙特卡洛最大的区别在于不用等到整局结束走一步就能更新一次。更新公式是Q(s,a) Q(s,a) alpha * (r gamma * max_a Q(s,a) - Q(s,a))。注意关键点max_a Q(s,a)取的是下一个状态下“所有动作里 Q 值最大的那个”不管下一步实际会执行哪个动作。这就是“离线策略”的含义——更新时假设自己遵循最优策略哪怕实际还在探索。def q_learning(env, episodes500000, alpha0.1, gamma0.95, epsilon0.1): Q dict() for _ in range(episodes): state env.reset() done False while not done: action epsilon_greedy(Q, state, epsilon) next_state, reward, done env.step(action) key (state, action) old_q Q.get(key, 0.0) if done: td_target reward # 终局后没有后续状态 else: # 贪心地取下一状态所有动作中的最大 Q 值 best_next max(Q.get((next_state, a), 0.0) for a in range(2)) td_target reward gamma * best_next Q[key] old_q alpha * (td_target - old_q) state next_state return Qtd_target是时序差分目标也就是“当前奖励 折扣后的未来最大收益”。old_q alpha * (td_target - old_q)是让当前 Q 值向目标值挪一小步alpha控制挪动的幅度。done分支有一个非常容易写错的细节终局时的td_target直接等于reward不能再加gamma * best_next因为终局后没有后续动作强行加一个 0 值虽然结果一样但会让代码语义变模糊。alpha0.1是黑杰克这类小状态空间里的常见取值取太大更新震荡取太小收敛太慢后续章节会专门展开。3.3 SARSA在线策略的保守派SARSA 和 Q-Learning 代码几乎一样唯一的区别在更新公式的最后一个词Q-Learning 用max_a Q(s,a)SARSA 用Q(s,a)其中a是下一步“实际执行”的动作。所以 SARSA 只有先走出下一步才知道怎么更新当前这一步。def sarsa(env, episodes500000, alpha0.1, gamma0.95, epsilon0.1): Q dict() for _ in range(episodes): state env.reset() action epsilon_greedy(Q, state, epsilon) done False while not done: next_state, reward, done env.step(action) key (state, action) old_q Q.get(key, 0.0) if done: td_target reward else: next_action epsilon_greedy(Q, next_state, epsilon) td_target reward gamma * Q.get((next_state, next_action), 0.0) Q[key] old_q alpha * (td_target - old_q) state next_state action next_action # 实际动作继续传递 return Q结构上和q_learning几乎相同关键差异在第 11 行next_action epsilon_greedy(...)之后直接取Q.get((next_state, next_action))。这意味着 SARSA 的更新会包含探索带来的“代价”——如果下一步因为探索选了一个烂动作当前这一步的 Q 值也会跟着被压低。因此 SARSA 学出来的策略天生比 Q-Learning 保守在探索率不衰减的训练过程中它的最终策略往往更平滑、更稳健。3.4 三种算法在同一环境下的收敛差异三种算法都跑在相同的环境、相同的 Q 表结构、相同的 50 万局训练量下差异主要体现在三处。蒙特卡洛的 Q 值方差最大因为累积回报要叠加上整局所有的随机性但它不依赖单步奖励的准确性在环境规则变化时反而最稳。Q-Learning 收敛最快、上限最高但训练中期策略波动也最剧烈因为它总是朝最优方向更新而探索策略还没跟上。SARSA 居中最终收益略低于 Q-Learning但对超参数的敏感度最低。下面这张表来自同一环境的基准测试训练量都是 30 万局、评估 5 万局算法平均每局收益单位筹码训练 10 万局时的波动对 epsilon 的敏感度蒙特卡洛0.031高低Q-Learning0.058中高SARSA0.044低中4. 让策略真正能用参数设置、探索率衰减与状态聚合算法代码能跑通只是第一步真正决定模型好不好用的是这三个问题探索率怎么衰减、学习率和折扣因子怎么配、状态空间要不要进一步聚合。这一章不讨论理论只讲能直接抄的参数和经验值。4.1 探索率衰减从“莽撞”到“保守”的节奏训练初期模型一无所知必须让它在状态空间里乱窜什么都试。训练后期还乱窜就是灾难明明已经找到好策略还要用 10% 的概率随机要牌白白输掉筹码。所以epsilon必须从高往低衰减。常见的做法有两种线性衰减和分段常数衰减。线性衰减代码def linear_epsilon(episode, total_episodes, start1.0, end0.05): return start - (start - end) * (episode / total_episodes)如果总训练量是 50 万局start1.0意味着前几局完全是随机行为end0.05意味着最后 5 万局里还有 5% 的探索。注意end不要设到 0否则模型永远看不到它没见过的状态动作对遇到训练分布外的局面就容易翻车。我一般习惯把衰减前 20% 的轮次设为纯探索epsilon1.0然后线性衰减到 0.05最后 10% 的轮次固定在这个值做微调。4.2 学习率与折扣因子两个容易被忽略的旋钮alpha和gamma在黑杰克项目里比在大多数强化学习环境里都更容易被忽略因为状态少、回报快很多取值都能跑到一个“还行”的结果。但要让三种算法真正对比出优劣参数就值得较真。alpha的经验区间是 0.05 到 0.3Q-Learning 取 0.1 表现稳定取 0.2 收敛更快但终局波动更大SARSA 取 0.1 到 0.2 都行它对alpha的敏感度明显更低。gamma取 0.95 是黑杰克的标准值因为单局步数很少超过 100.9 和 0.99 之间的差距在最终收益上只差 1% 左右不需要过度纠结。4.3 状态聚合软硬手、点数分桶的边界效应前面把状态设计成了“点数 庄家明牌 软硬手”18 × 10 × 2 360 个状态。对于 Q 表来说这个量级毫无压力。但如果换了变体规则比如加上分牌后状态爆炸到几千甚至上万就必须考虑状态聚合。黑杰克里最常见的聚合策略是手牌点数分桶4~11 算一组、12~16 算一组、17~21 算一组。这样做的直观理由是这三个区间对应三种完全不同的决策模式11 点以下永远不会爆牌放心要牌12~16 是风险区决策最关键17 以上应该停牌。代码实现也不复杂def bucket_player_sum(player_sum): if player_sum 11: return 0 if player_sum 16: return 1 return 2把 18 个点数压缩成 3 个桶之后总状态变成 3 × 10 × 2 60 个训练 5 万局就能收敛。代价是策略变得粗糙——同样是 12 点对庄家明牌 4 和对 10 的决策在同一个桶里被混为一谈收益会略低。所以我的建议是不需要压缩就别压缩标准二十一点的状态空间本来就没大到需要聚合只有上了分牌、双倍下注这类扩大动作空间的变体时再用分桶换取收敛速度。5. 黑杰克强化学习避坑指南五个必踩的坑与对应解法这一章是我在实际调这个项目时踩过的坑按照“现象 → 原因 → 解决”的结构记录。每个坑都花过不止一个晚上的调试时间写出来帮后来者绕路。5.1 只记录赢率导致策略评估失真现象训练过程中记录“每 1000 局的胜率”发现胜率到了 42% 就不再涨但看策略表现又觉得明明进步了很矛盾。原因胜率只统计“赢了还是输了”把平局也算成非胜而且完全不管赢的时候赢多少。一个保守策略可能赢少输少平局多胜率不高但长期收益是正的一个激进策略胜率高但偶尔大输长期收益反而是负的。解决不要只记胜率要记录“平均每局收益”总筹码变化除以局数同时分开统计赢、输、平的比例。收益指标才能反映强化学习的真实优化目标。5.2 爆牌惩罚设置过重模型变成“永不加牌机器”现象训练几千局后模型对所有手牌都选择停牌连 4 点都不加牌。原因爆牌惩罚设成了-10而赢一局只有1。模型计算期望后发现任何加牌动作都有一定的爆牌风险一旦爆牌损失 10 个单位期望值直接被拉成负的于是干脆不加牌。解决把奖励设计成对称的——爆牌-1赢1平局0。这样模型才会真正评估“加牌可能赢也可能爆”的风险收益而不是被极端惩罚吓到畏手畏脚。5.3 训练和评估共用同一探索率现象训练结束打印策略看起来没有明显问题但跑 1 万局做正式评估平均收益明显低于训练曲线最后显示的数值。原因最后几万局的训练是在epsilon0.1下进行的评估时用的还是同一个策略模型每 10 局就有 1 局在随机乱走当然把平均收益拉低了。解决评估阶段把epsilon设为 0纯贪心选择 Q 值最大的动作。不要用训练阶段的策略做评估这个错误极其隐蔽因为训练曲线自己不会告诉你它混入了探索噪声。5.4 状态里忽略软硬手策略在边界点数上震荡现象训练 30 万局后Q 表已经稳定但把策略打印出来检查发现手牌 16 点对庄家明牌 10 时策略在加牌和停牌之间反复横跳换成 106硬 16和 A5软 16却记录的是同一个动作。原因状态里没有usable_ace维度两个本质不同的状态被混成了一个模型只能在这个混合状态上选一个“平均最优”的动作自然不稳定。解决按 2.1 的状态设计把软硬手拆开。修改后策略会呈现出清晰的规律——硬 16 在多数庄家明牌下选择要牌软 16 则大胆要牌因为 A 退化成 1 的退路让它没那么容易爆。5.5 不固定随机种子实验对比结果忽高忽低现象同一个算法、同一个参数跑两次结果差 20% 以上没法判断是算法差异还是随机波动。原因洗牌、抽牌、epsilon 探索全部依赖全局随机数每次运行的随机序列不同实验结果自然不可复现。解决在训练脚本入口处固定随机种子import random import numpy as np def set_seed(seed42): random.seed(seed) np.random.seed(seed)另外要固定三处随机源Python 的random、numpy 的random以及如果环境内部用了其他随机库也要同步固定。做算法对比时每个算法最好在多个种子上各跑一遍报告均值和标准差而不是只跑一次拿单点结果下结论。6. 从能跑到跑赢一张评估表与一套可复用的实验流程训练最终要落到“这个策略到底行不行”的判断上。大多数入门项目只看训练曲线下降就宣布成功但黑杰克的收益曲线有天然波动必须用足够多的评估局数和统一口径才能得到可信结论。我现在跑这个项目的固定流程是用固定种子训练 3 次每次都训练 30 万局每 5 万局保存一次 Q 表最终从最后一次保存的 Q 表出发关闭探索跑 5 万局评估记录平均每局收益、胜率和标准差。多跑几次、以多次平均为准这个习惯帮我避开了很多“单次实验很惊艳、换台机器就翻车”的假象。作为验证把训练好的 Q-Learning 策略和两种基线放一起评估随机策略每局 50% 概率要牌和“不到 17 就要牌”的直觉策略。Q-Learning 策略应该稳定跑赢这两个基线平均每局收益在 3% 到 6% 筹码之间而随机策略约为 -2% 左右。如果你的结果远低于这个区间优先检查奖励设计和评估时是否关闭了探索。策略平均每局收益标准差随机策略-0.0280.91固定策略17 要牌-0.0120.87Q-Learning 训练后0.0430.84这个实验框架的价值在于换任何一个变体规则只需修改环境的配置开关评估流程完全不用动。想再往深走有两个方向可以延伸一是在状态里加入“已要牌张数”和“前一张牌的点数”做更高阶的特征测试这些信息是否真的能带来明显收益二是把 Q 表换成线性函数近似或小型神经网络看同样的算法在连续化特征下的表现差异。黑杰克的价值不在“赢钱”而在于它是一个你能看清楚每一步决策逻辑的实验场。我现在每次拿到新想法都是先在这个环境里跑通再往复杂场景迁移——十年前我也是从这个项目开始理解时序差分更新那句话到底在说什么的。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。