简介:面向希望从代码层面理解强化学习的 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 到 10,A 统一记成 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 到 10,A 既能当 11 也能当 1,JQK 统一算 10,所以第一步必须先把原始牌值转成点数,再把 A 的软牌逻辑处理好。下面这段是环境核心函数的基础写法:
def card_value(card): # card 取值 1~13,其中 1 代表 A,11~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、5,total 初始是 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, episodes=500000, epsilon=0.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-learning:TD 更新的两种写法
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, episodes=500000, alpha=0.05, gamma=1.0, epsilon=0.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, episodes=500000, alpha=0.05, gamma=1.0, epsilon=0.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 关键超参数:什么值得调,什么保持默认
| 参数 | 推荐值 | 说明 |
|---|---|---|
| alpha | 0.01~0.05 | TD 步长,状态访问频繁时用小值,防止单步更新主导 Q 值 |
| gamma | 1.0 | 回合制结算,不需要折扣 |
| epsilon | 0.1 起,指数衰减到 0.01 | 固定 epsilon 会导致评估时策略带随机性 |
| episodes | 50 万 ~ 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, episodes=500000, eval_every=5000): 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(f"episode {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, episodes=100000): 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 分支里的终止逻辑即可。每次改动后重新训练三件套,对比策略热图,就能快速判断规则修改对最优策略的影响。这种验证方式比我以前直接改代码打补丁清晰得多,也让整份资源在做实验时更像一个能反复使用的测试平台。
本文还有配套的精品资源,点击获取