资讯动态

用二十一点玩转强化学习:MC、SARSA与Q-learning算法对比实践

发布时间:2026/10/14 15:05:28 来源:尧图企业网站定制
简介面向希望从代码层面理解强化学习的 Python 学习者与研究者该资源以二十一点变体为实验环境集中实现了蒙特卡洛MC、SARSA 以及结合线性函数逼近的 SARSA 算法。整个项目入口清晰只需运行主脚本即可依次执行三类算法并生成对比图表配套模块分别负责环境交互、算法主体、结果绘图、epsilon 贪婪策略、均方误差计算和状态特征向量转换能够帮助读者把抽象公式落到可运行的代码上。压缩包共有 8 个文件以 Python 脚本为主辅以 Markdown 说明和 pickle 数据文件整体仅 14KB结构紧凑、便于阅读与二次修改。目前已有 1007 人学习下载适合课程实验、算法对比或入门复现读者可借此掌握 MC、SARSA 与线性函数逼近的实现套路并获得一套可直接扩展的二十一点实验框架。1. 用强化学习玩二十一点小状态空间里最容易对比算法差异的地盘做强化学习实验时CartPole 和 FrozenLake 这类玩具环境虽然能跑通算法但很难直观看出同策略与离策略、MC 与 TD 之间的差异。二十一点变体恰好补上这块拼图状态空间小、单局结束快、结算清晰还保留了一张庄家暗牌这种隐藏信息能真实检验“观测到的东西到底够不够决策用”。这份资源基于 Python 实现了蒙特卡洛控制、SARSA、Q-learning 等几种经典强化学习算法环境建模、训练循环、评估接口都配套齐全。适合刚学完理论想把表格方法落地验证的开发者也适合拿来做策略对比实验、排查随机种子影响的一线从业者。2. 状态空间与算法选型从 MDP 到 MC/SARSA/Q-learning 的建模路径2.1 状态到底怎么取三元组、隐藏信息与软牌分开编码二十一点的决策时刻玩家能观测的信息有限自己手牌总点数、庄家明牌、自己手中是否有可用的 A。真正不能进入状态的是庄家暗牌因为它在决策时不可见。如果强行把暗牌放进状态理论上就构建了一个全观测 MDP但实际运行时没法获取暗牌状态变成不可用的“作弊状态”。所以环境封装里状态固定为三元组玩家总点数、庄家明牌点数、是否存在可用 A。这三个维度的语义要分开说清楚。玩家总点数只取 12 到 21低于 12 时玩家不会主动停牌、点数上没有策略分歧可以安全丢弃高于 21 的回合已经结束也不会出现在状态里。庄家明牌取 2 到 10A 统一记成 11但要不要单独区分 A 这个取值看你后期是否打算做软 17 规则实验。是否存在可用 A 这个布尔值是最容易被忽略的一维有 A 且按 11 算不爆就是软手软手与硬手在同点数下的最优动作明显不同比如软 16 要牌比硬 16 要牌更安全因为要来的 A 或小牌能把点数降到 17 以下继续要牌。为保持状态足够细又不至于稀疏我一般把玩家点数按实际点数编码10 种取值加上庄家明牌 10 种取值加可用 A 的 2 种取值总共 200 个状态。这么小的状态规模表格型算法几百轮就能覆盖大半训练成本几乎可以忽略这也是这张资源选择表格法而不是神经网络的最直接原因每个 (s, a) 都能被反复访问Q 表更新足够频繁便于直接观察策略形成过程。2.2 动作与奖励设定为什么二十一点不需要折扣因子动作空间这里使用三个动作要牌、停牌、双倍下注。双倍下注是变体规则里最常见的扩展只能在初始两张手牌时使用执行后只补发一张牌然后本回合立即结算。很多入门实现会把双倍下注写成普通动作导致策略出现“任何时候都 double”的怪象所以环境代码里必须用一个首回合标志位来限制。标志位在 reset 时置真第一步动作里选了 double 后环境直接进入结算流程并返回最终奖励本回合终止。奖励设计上本项目用的是最标准的回合结算制赢一局 1平一局 0输一局 -1中间每一步没有即时奖励。这种设定天然适合二十一点因为单局胜负只取决于终局点数比较中间给奖励反而会诱导算法去优化“点数接近 21”而不是“赢过庄家”。另一个容易误解的点是折扣因子在回合结束才产生非零奖励、且单局保证有限步结束的问题里gamma 设为 1.0 是数学上最合适的折扣在这里只会给本应完整的回报引入偏差。2.3 算法选型MC、SARSA、Q-learning 在同一任务上的适用边界这份资源同时实现了三种经典算法核心差异可以从更新时机和策略关系两个维度拆开看。蒙特卡洛控制需要跑完整个回合用真实回报 G 来更新 Q 值估计无偏但方差大收敛慢但最终结果稳定SARSA 是典型的同策略时序差分看到下一个状态和动作后立即更新方差小但引入偏差最终学到的是带探索的最优策略Q-learning 是离策略 TD更新时直接用 max 下一状态动作价值绕开了探索策略的影响全局最优逼近能力强但在探索概率大时容易高估真实价值。三种算法在二十一点上的表现差异正好对应这些数学性质。MC 对回合结果最敏感初始阶段胜率曲线像过山车SARSA 在固定 epsilon 下学出来的策略偏保守停牌点比理论最优低Q-learning 学得快但偶尔会把“双倍下注”这个高风险动作的价值抬得过高。把三种实现放在一起跑的意义不是为了比较谁更强而是让你在同一环境、同一评估逻辑下直观感受偏差方差异对最终策略的影响。对从业者来说这种差异才是真正能在生产环境里迁移的经验。3. Python 实现三种算法代码骨架与参数默认值3.1 环境封装牌值换算与状态提取环境层是整份资源里最基础也最容易被改坏的部分。牌面不只是简单的 1 到 10A 既能当 11 也能当 1JQK 统一算 10所以第一步必须先把原始牌值转成点数再把 A 的软牌逻辑处理好。下面这段是环境核心函数的基础写法def card_value(card): # card 取值 1~13其中 1 代表 A11~13 代表 JQK if card 1: return 11 return min(card, 10) def hand_total(hand): total sum(card_value(c) for c in hand) aces hand.count(1) while total 21 and aces 0: total - 10 aces - 1 return total逻辑说明A 默认按 11 计算只有在总点数爆掉时才逐个把 A 降为 1减去 10 点重新核算while 循环保证多个 A 的情况也能正确处理。比如手牌是 A、A、5total 初始是 11 11 5 27第一次减 10 变成 17第二次再减 10 变成 7最后的合法点数是 7两个 A 都按 1 算。这个换算过程不能简化成“A 固定等于 11”否则软牌策略会直接扭曲。步进函数里需要返回三个值下一状态、奖励、是否结束。玩家点数超过 21 或选择停牌后让庄家按固定策略要牌到 17 以上然后比较点数结算奖励。双倍下注分支要单独处理先检查是否首回合不满足则不允许选择该动作满足则补发一张牌后直接进入结算逻辑。3.2 蒙特卡洛控制完整回合结束后的首次访问更新MC 控制的核心是“等一局打完再回头看每一步值多少钱”。这里用首次访问 MC即每局内同一个 (s, a) 只采样一次避免相关性强的多次回报污染估计。实现起来非常短def mc_control(env, episodes500000, epsilon0.1): Q defaultdict(lambda: np.zeros(env.nA)) returns defaultdict(list) for _ in range(episodes): episode generate_episode(env, Q, epsilon) G 0 seen set() for s, a, r in reversed(episode): G r G if (s, a) in seen: continue seen.add((s, a)) returns[(s, a)].append(G) Q[s][a] np.mean(returns[(s, a)]) return Q逻辑说明generate_episode 负责按当前策略生成一整局轨迹格式为 (状态、动作、奖励) 列表。奖励只在终止时给出所以反向遍历时一路累加 G天然处理了中途无奖励的情况。seen 集合确保每个 (s, a) 只被第一次出现时更新这是首次访问 MC 的标准做法。参数说明episodes 设为 50 万状态空间才 200 个足够让每个 (s, a) 被访问几十次epsilon 固定 0.1如果不衰减最终策略会带 10% 的随机动作评估时要单独走贪心逻辑。3.3 SARSA 与 Q-learningTD 更新的两种写法TD 方法不需要等回合结束每走一步就更新一次 Q 值。SARSA 的更新公式里要用到下一个实际动作 a2而 Q-learning 用的是 max Q。这两种写法最容易搞混的就是这里def egreedy(Q, s, epsilon): if np.random.random() epsilon: return np.random.choice(len(Q[s])) return np.argmax(Q[s]) def sarsa(env, episodes500000, alpha0.05, gamma1.0, epsilon0.1): Q defaultdict(lambda: np.zeros(env.nA)) for _ in range(episodes): s env.reset() a egreedy(Q, s, epsilon) done False while not done: s2, r, done env.step(a) if done: Q[s][a] alpha * (r - Q[s][a]) else: a2 egreedy(Q, s2, epsilon) Q[s][a] alpha * (r gamma * Q[s2][a2] - Q[s][a]) s, a s2, a2 return Qdef q_learning(env, episodes500000, alpha0.05, gamma1.0, epsilon0.1): Q defaultdict(lambda: np.zeros(env.nA)) for _ in range(episodes): s env.reset() done False while not done: a egreedy(Q, s, epsilon) s2, r, done env.step(a) if done: Q[s][a] alpha * (r - Q[s][a]) else: Q[s][a] alpha * (r gamma * np.max(Q[s2]) - Q[s][a]) s s2 return Q逻辑说明SARSA 里每个状态先按 epsilon-greedy 选动作再往前走一步拿到 s2 和 a2更新后把 s2、a2 作为下一轮起点整个过程是“同策略”的。Q-learning 则每步重新选动作更新时直接取后面状态的最大 Q 值行为策略是 epsilon-greedy目标策略是贪心因此叫离策略。参数说明alpha 用 0.05比常见强化学习教程里的 0.1 更保守因为二十一点单局很短、状态访问频率高学习率太大会让 Q 值在后期来回震荡。gamma 恒为 1.0理论基础在第 2 章已经说过。3.4 关键超参数什么值得调什么保持默认参数推荐值说明alpha0.01~0.05TD 步长状态访问频繁时用小值防止单步更新主导 Q 值gamma1.0回合制结算不需要折扣epsilon0.1 起指数衰减到 0.01固定 epsilon 会导致评估时策略带随机性episodes50 万 ~ 100 万200 个状态规模下50 万足够收敛初始 Q 值0.0不要用 1 初始化会高估所有未探索动作的价值初始化 Q 值是新手最容易翻车的地方。有人喜欢把 Q 表全部初始化为 1.0理由是“鼓励探索”实际效果是所有状态的动作价值虚高算法要把虚高的部分一点点拉回来拖延收敛过程。表格法场景里 Q 初始化为 0.0 是最干净的探索交给 epsilon 完成。4. 训练与评估闭环胜率、平局率与策略稳定性怎样看4.1 训练循环每 N 局输出一次收益均值训练循环的写法决定了你能不能及时发现发散。我习惯每 5000 局打印一次最近 5000 局的平均回报而不是只打印到结束这样能看到算法是否在某个时间点突然退化。基础循环骨架如下def train(env, agent, episodes500000, eval_every5000): log [] recent [] for ep in range(1, episodes 1): s env.reset() a agent.choose_action(s) done False while not done: s2, r, done env.step_double(s, a) agent.update(s, a, s2, r, done) if not done: s s2 a agent.choose_action(s) recent.append(r) if ep % eval_every 0: avg np.mean(recent[-eval_every:]) log.append((ep, avg)) print(fepisode {ep}: avg reward {avg:.4f}) return log逻辑说明recent 列表保存每局最终奖励每 5000 局计算一次均值。这里把 SARSA 与 Q-learning 的更新逻辑封装进 agent.update方便切换算法而不改主循环。注意 step_double 是我习惯的环境接口命名表示支持双倍下注的完整流程。参数说明eval_every 不宜太小5000 局在 200 个状态规模下是一个比较稳的统计单位太小会看到太多方差噪声。4.2 离线评估相同牌序下跑出 win/tie/loss 三率训练结束后单独跑一条评估流程固定生成 N 局手牌序列把所有算法放到同一批牌序上测试。只报胜率会漏掉平局的影响二十一点里平局率经常在 10% 左右不统计会低估策略的真实水平def evaluate(env, Q, episodes100000): win tie loss 0 for _ in range(episodes): env.reset() # 记录初始手牌生成保证评估时每局发牌可复现 s env.get_state() done False while not done: a np.argmax(Q[s]) s, r, done env.step_double(s, a) if r 1: win 1 elif r 0: tie 1 else: loss 1 return win / episodes, tie / episodes, loss / episodes逻辑说明评估阶段必须用贪心策略不能带上 epsilon否则结果混入随机动作的干扰。np.argmax(Q[s]) 选的是当前状态下 Q 值最高的动作也就是“策略本身”。参数说明10 万局的评估量在本地跑几秒就能完成置信度足够如果时间紧5 万局也能看到稳定趋势。评估结果建议同时看胜率与平局率胜率 0.42、平局 0.10 的组合说明策略在多数非平局对局中有优势单纯看胜率容易高估。4.3 调参实验学习率与 epsilon 衰减的连动效应alpha 和 epsilon 不是独立参数它们共同决定 Q 表更新的稳定性。我通常做一组最简单的对比实验固定其他参数不变只改 alpha看平均回报曲线的后期波动幅度。alpha 过大时前期收敛快但后期 Q 值会在最优解附近来回跳胜率曲线呈现“前期爬升、后期震荡”。alpha 过小时前期收敛慢需要额外增加训练轮数。epsilon 衰减的影响更隐蔽。固定 0.1 的探索率训练 50 万局最终策略里大约 10% 的动作是随机的离线评估时却用贪心策略两者口径不一致导致训练日志和评估结果对不上。我一般把 epsilon 从 0.1 指数衰减到 0.01衰减速度控制在训练总量的前三分之一完成episode 数乘 0.001 时达到 0.01 左右这样前十几万局保持足够探索后期逐渐转向利用。SARSA 对 epsilon 衰减尤其敏感不衰减的话它学到的是保守策略最优停牌点会比 Q-learning 低一档。5. 避坑与排查五个收敛失败的现场记录5.1 状态里丢掉可用 A最优策略直接变形现象训练完成后策略热图显示“16 点对庄家 2 停牌”明显偏离基本策略胜率比随机策略高不了多少。原因状态编码时把软牌与硬牌合并成同一个点数软 16 和硬 16 被当成同一状态算法只能被迫在两种环境下拟合一个平均动作。软 16 要牌的风险低、硬 16 要牌的风险高合并状态后动作价值失去区分度。解决状态三元组里保留 usable_ace 维度并确保 hand_total 判断软牌时用的是“A 按 11 算也不爆”这一条件。删除软牌维度的实现即使只用 MC 也会出现策略漂移这和环境本身无关。5.2 非法动作被当成合法动作采样奖励被污染现象Q-learning 练完后策略里出现大量“任何点数都双倍下注”胜率反而低于 0.3。原因动作采样没有做合法性掩码。环境对非首回合的 double 动作返回了一个固定负奖励Q 表把所有状态的 double 都学成了低价值动作某些状态因其他动作更低而选中 double。最典型的结果是“矮个子里拔将军”策略瘸腿但训练曲线看不出异常。解决在动作采样处强制只从合法动作列表里选非法动作直接屏蔽而不是赋负奖励。可以写一个合法动作掩码函数每次选动作前调用 env.get_legal_actions(s)再在 egreedy 采样里对这个列表做随机。此改动让策略质量立刻恢复正常且不需要重新设计奖励。5.3 double down 导致奖励错位策略左右摇摆现象加入双倍下注后训练曲线中期出现一次明显的胜率回撤之后来回震荡不收敛。原因double 动作在首回合执行但部分实现里把补牌和结算拆到了两个 step 里奖励被延迟到第二个 step 返回。Q-learning 用单步时序差分更新奖励没落在 double 动作上算法对 double 的价值判定错位策略在要牌和 double 之间左右摇摆。解决在 step 内部把 double 作为一个完整动作处理执行补牌、立即结算、一次性返回奖励调用方不需要关心后续状态。如果按拆步的方式写必须手动把最终奖励沿轨迹回传这会破坏 TD 更新的简洁性不推荐。5.4 epsilon 不衰减离策略学出保守策略现象SARSA 与 Q-learning 训练日志都正常收敛但评估时 SARSA 的胜率比 Q-learning 低 3 到 5 个百分点停牌点明显偏低。原因SARSA 是同策略算法更新依赖实际执行的动作。固定 epsilon 意味着最终学习到的策略包含持续探索离线评估切换到贪心策略后才暴露策略本身没有被优化到全局最优。Q-learning 的目标策略是贪心不受行为策略影响所以 epsilon 不衰减对它的影响更小。解决SARSA 必须配合 epsilon 衰减或者评估时把策略写成“训练时的 epsilon 也要保持”但这会明显拖低评估胜率。项目里我直接对 epsilon 做指数衰减SARSA 最终也能逼近 Q-learning 的贪心策略只是需要更多训练轮。5.5 训练曲线漂亮但测试全爆训练和测试策略不一致现象训练时平均回报稳步上升评估胜率却出现“胜率低、爆牌率高”的反常情况看起来像训练阶段和评估阶段不是同一个策略。原因训练循环里每步重新用 epsilon-greedy 选动作但 Q 表更新用的是上一步动作评估时却用 np.argmax两个阶段的动作选择逻辑不一致。最常见的是把 state 更新写错了位置导致训练时 Q 表用错状态对评估时却用正确状态曲线好看但策略完全跑偏。解决对比训练与评估两种路径下的状态转移是否一致最直接的方法是固定随机种子打印前 20 局的状态、动作、奖励流水逐条核对。那一次我花了半天时间最后发现是 reset 后没有把首回合标志位清成真double 在之后所有状态都可用训练策略被污染。从那以后我每次跑这个项目都会强制先走一遍固定种子环境自检再跑算法对比确认环境回归无误后才开始调参。常年做这类实验这个小习惯能省下大量怀疑人生的时间希望这个坑记录能帮到你。6. 进阶验证策略热图、种子重放与规则变体实验6.1 策略热图用表格看清楚算法学到了什么训练结束后最直观的验证方式不是看曲线而是把策略展开成一张 10×10 的表格横轴庄家明牌点数纵轴玩家手牌点数每个格子填最终策略选中的动作。代码如下def build_policy_heatmap(Q, env): heatmap np.zeros((10, 10)) for p in range(12, 22): for d in range(2, 11): s env.encode_state(p, d, False) heatmap[p - 12][d - 2] np.argmax(Q[s]) return heatmap逻辑说明这里先把 usable_ace 固定为 False只画硬手策略软手策略可以另画一张。参数说明纵轴从 12 到 21 覆盖所有需要在要牌和停牌之间做决策的点数横轴 2 到 10 是庄家全部可能的明牌A 在二十一点中通常单独处理所以横轴可以不含 A避免混淆。拿到热图后对照基本策略表检查17 以上是否停牌、12 到 16 是否按庄家明牌区分一眼就能看出算法有没有学到合理规律。6.2 种子重放把随机性从对比实验里拿掉算法对比最怕随机性干扰同一个 Q-learning 跑两次胜率差 2% 就可能误导结论。我一般在评估阶段先生成一批固定的牌序让所有算法在这批牌上重放用同一组随机数跑完整对比np.random.seed(42) traj [] for _ in range(50000): env.reset() traj.append((env.get_state(), env.dealer_upcard)) # 后续每个算法都基于 traj 重新决策保持初始牌序完全一致逻辑说明先把手牌序列存下来评估时只重置环境不重新洗牌这样不同算法面对的是同一批对局能准确对比策略优劣。参数说明5 万局足够看到稳定差异配合前面提过的高评估量不会因为一两局的运气翻转排名。6.3 规则变体验证改一个方法就能测软 17、双倍再要牌这份资源的环境封装把规则全部收敛到几个方法里所以验证规则变体非常方便。想测庄家软 17 停牌规则只需要在庄家结算逻辑里加一个条件判断庄家总点数为 17 且存在可用 A 时继续要牌。想测“双倍后再要牌”移除 double 分支里的终止逻辑即可。每次改动后重新训练三件套对比策略热图就能快速判断规则修改对最优策略的影响。这种验证方式比我以前直接改代码打补丁清晰得多也让整份资源在做实验时更像一个能反复使用的测试平台。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑