21点游戏介绍
21点(Blackjack)是一款经典的赌场卡牌游戏,核心目标是让自己手中卡牌的“点数和”接近21点(但不超过21点),同时超过庄家的点数和。游戏规则围绕“发牌、要牌、停牌、判定胜负”展开,整体逻辑清晰,以下是通俗易懂的完整规则介绍:
一、核心概念:卡牌点数计算
首先要明确每张牌的点数,这是判断胜负的基础:
- 数字牌(2-10):点数=牌面数字(如“5”就是5点,“10”就是10点)。
- 花牌(J、Q、K):无论花色,每张都算10点。
- A(Ace):点数灵活,可算1点或11点(由玩家/庄家根据“不超过21点”的原则自主选择,比如手牌是A+5,可算16点;手牌是A+10,可算21点,称为“黑杰克”)。
二、参与角色与初始发牌
游戏通常由 1名庄家 和 1-7名玩家 组成,使用1-8副标准扑克牌(52张/副,无大小王),初始流程固定:
- 庄家发牌:先给每个玩家发2张明牌(所有人都能看到),再给自己发1张明牌、1张暗牌(只有庄家自己能看)。
- 初始判定(黑杰克):发牌后先检查是否出现“黑杰克”——
- 玩家:若2张初始手牌是“A+10/J/Q/K”,直接拿到“黑杰克”(点数和=21点),若庄家没有黑杰克,玩家直接赢(赢取“1.5倍赌注”,比如押100赢150);
- 庄家:若暗牌+明牌是黑杰克,且玩家没有黑杰克,玩家直接输;
- 双方都有黑杰克:算“平局”(玩家拿回赌注,不赢不亏)。
三、玩家的核心操作:要牌(Hit)或停牌(Stand)
若初始没有黑杰克,游戏进入“玩家回合”,每个玩家按顺序决定自己的操作,核心只有2种选择:
- 要牌(Hit):觉得当前点数不够(比如手牌和=16点),向庄家再要1张明牌。
- 要牌后若点数和超过21点(称为“爆牌”,Bust),玩家直接输,失去当前赌注,无需继续;
- 若点数和≤21点,可继续选择“要牌”或“停牌”。
- 停牌(Stand):觉得当前点数足够(比如手牌和=19点),不再要牌,等待庄家行动。
(注:部分规则中还有“加倍”“分牌”等进阶操作,适合熟练后使用,新手可先掌握“要牌/停牌”核心逻辑)
四、庄家的行动规则(固定,无自主选择)
所有玩家完成操作后,进入“庄家回合”,庄家需遵守固定规则行动,不能像玩家一样自由决定:
- 庄家先翻开自己的暗牌,计算总点数。
- 若总点数≤16点:必须要牌(强制,不能停牌),直到点数和≥17点。
- 若总点数≥17点:必须停牌(强制,不能再要牌)。
- 若庄家要牌后爆牌:所有未爆牌的玩家都赢,赢取“1倍赌注”(比如押100赢100)。
五、最终胜负判定(非爆牌情况下)
若玩家和庄家都未爆牌,对比双方的点数和,规则如下:
- 玩家点数 > 庄家点数:玩家赢(1倍赌注);
- 玩家点数 < 庄家点数:玩家输(失去赌注);
- 玩家点数 = 庄家点数:平局(玩家拿回赌注,无输赢)。
六、关键总结(新手快速记)
- 目标:自己点数≤21,且比庄家大;
- 玩家:能选“要牌/停牌”,爆牌就输;
- 庄家:≤16必须要牌,≥17必须停牌,爆牌玩家赢;
- 黑杰克:初始A+10/J/Q/K,优先判定,赢1.5倍。
掌握这些规则,就能快速上手21点游戏,后续可逐步学习“加倍”“分牌”等进阶策略,进一步提升赢面。
Qlearning-21点游戏
代码
from collections import defaultdict
import gymnasium as gym
import numpy as np
class BlackjackAgent:
def __init__(
self,
env: gym.Env,
learning_rate: float,
initial_epsilon: float,
epsilon_decay: float,
final_epsilon: float,
discount_factor: float = 0.95,
):
"""Initialize a Q-Learning agent.
Args:
env: The training environment
learning_rate: How quickly to update Q-values (0-1)
initial_epsilon: Starting exploration rate (usually 1.0)
epsilon_decay: How much to reduce epsilon each episode
final_epsilon: Minimum exploration rate (usually 0.1)
discount_factor: How much to value future rewards (0-1)
"""
self.env = env
# Q-table: maps (state, action) to expected reward
# defaultdict automatically creates entries with zeros for new states
self.q_values = defaultdict(lambda: np.zeros(env.action_space.n))
self.lr = learning_rate
self.discount_factor = discount_factor # How much we care about future rewards
# Exploration parameters
self.epsilon = initial_epsilon
self.epsilon_decay = epsilon_decay
self.final_epsilon = final_epsilon
# Track learning progress
self.training_error = []
def get_action(self, obs: tuple[int, int, bool]) -> int:
"""Choose an action using epsilon-greedy strategy.
Returns:
action: 0 (stand) or 1 (hit)
"""
# With probability epsilon: explore (random action)
if np.random.random() < self.epsilon:
return self.env.action_space.sample()
# With probability (1-epsilon): exploit (best known action)
else:
return int(np.argmax(self.q_values[obs]))
def update(
self,
obs: tuple[int, int, bool],
action: int,
reward: float,
terminated: bool,
next_obs: tuple[int, int, bool],
):
"""Update Q-value based on experience.
This is the heart of Q-learning: learn from (state, action, reward, next_state)
"""
# What's the best we could do from the next state?
# (Zero if episode terminated - no future rewards possible)
future_q_value = (not terminated) * np.max(self.q_values[next_obs])
# What should the Q-value be? (Bellman equation)
target = reward + self.discount_factor * future_q_value
# How wrong was our current estimate?
temporal_difference = target - self.q_values[obs][action]
# Update our estimate in the direction of the error
# Learning rate controls how big steps we take
self.q_values[obs][action] = (
self.q_values[obs][action] + self.lr * temporal_difference
)
# Track learning progress (useful for debugging)
self.training_error.append(temporal_difference)
def decay_epsilon(self):
"""Reduce exploration rate after each episode."""
self.epsilon = max(self.final_epsilon, self.epsilon - self.epsilon_decay)
来自:https://gymnasium.org.cn/introduction/train_agent/
一、代码整体框架
首先看代码的“骨架”:通过BlackjackAgent类封装智能体的所有功能,再通过外部参数配置和循环实现训练。整体结构分为3部分:
- 导入依赖库(处理数据、调用环境、数值计算)
- 定义
BlackjackAgent类(智能体的核心逻辑) - 配置训练超参数(控制训练过程的参数)
先看最开头的依赖库导入,这是代码运行的基础:
from collections import defaultdict # 用于创建默认值为0的Q表(新状态自动初始化)
import gymnasium as gym # 导入Gymnasium库,用于加载二十一点环境
import numpy as np # 用于数值计算(如随机选动作、求最大值)
二、BlackjackAgent类:智能体的“大脑”
类是代码的核心,封装了智能体的初始化、选动作、学经验、降探索率4个核心能力,对应Qlearning的完整逻辑。
1. __init__方法:初始化智能体的“初始状态”
作用:给智能体设定“初始属性”,比如Q表、学习率、探索率等,相当于“刚出厂的配置”。
def __init__(
self,
env: gym.Env, # 传入二十一点环境(智能体要交互的世界)
learning_rate: float, # 学习率(控制Q值更新的幅度,0-1之间)
initial_epsilon: float, # 初始探索率(刚开始多探索,比如1.0=100%随机)
epsilon_decay: float, # 探索率衰减量(每回合减少一点,比如0.001)
final_epsilon: float, # 最小探索率(不能完全不探索,比如0.1=10%随机)
discount_factor: float = 0.95, # 折扣因子(重视未来奖励的程度,0-1之间)
):
# 1. 保存环境(方便后续交互,比如选动作、看动作空间)
self.env = env
# 2. 初始化Q表:key是(状态,动作),value是Q值;新状态默认Q值为0
# defaultdict的作用:如果访问一个没见过的(状态,动作),自动赋值为0,不用手动初始化
self.q_values = defaultdict(lambda: np.zeros(env.action_space.n))
# 3. 保存学习相关的参数
self.lr = learning_rate # 学习率:越大,Q值更新越快(但可能不稳定)
self.discount_factor = discount_factor # 折扣因子:越大,越重视未来奖励(比如0.95=95%重视未来)
# 4. 保存探索相关的参数
self.epsilon = initial_epsilon # 当前探索率
self.epsilon_decay = epsilon_decay # 每回合衰减多少
self.final_epsilon = final_epsilon # 探索率的下限(不能低于这个值)
# 5. 记录训练误差(用于后续调试,看Q值是否在稳定优化)
self.training_error = []
关键理解:
- Q表用
defaultdict而不是普通字典,是因为二十一点的状态很多(手牌4-21、庄家1-10、有无可用A,共280种状态),手动初始化所有状态的Q值太麻烦,defaultdict会自动给新状态的Q值设为0。 - 折扣因子
discount_factor:比如现在拿1分,和下一轮拿1分,哪个更值钱?设为0.95,意味着“下一轮的1分相当于现在的0.95分”,既重视未来,又不忽视当下。
2. get_action方法:选动作(平衡探索与利用)
作用:根据当前状态,用“epsilon-贪婪策略”选动作——既敢尝试新动作,又会利用已知的好动作。
def get_action(self, obs: tuple[int, int, bool]) -> int:
# obs是当前状态:(玩家总点数, 庄家亮牌, 有无可用A),比如(16, 10, False)
# 返回动作:0=停牌,1=要牌
# 第一步:以概率epsilon“探索”(随机选动作,不管Q值)
if np.random.random() < self.epsilon:
# env.action_space.sample():从环境的动作空间里随机选一个动作(0或1)
return self.env.action_space.sample()
# 第二步:以概率(1-epsilon)“利用”(选当前状态下Q值最高的动作)
else:
# self.q_values[obs]:当前状态下所有动作的Q值(比如[0.8, -0.3],对应0=停牌,1=要牌)
# np.argmax():找Q值最大的动作的索引(比如0,对应停牌)
# 转成int是为了确保返回值类型正确
return int(np.argmax(self.q_values[obs]))
关键理解:
- 比如初始
epsilon=1.0时,100%随机选动作(纯探索),智能体像“新手”一样瞎试;随着epsilon衰减到0.1,90%的时间选Q值最高的动作(纯利用),10%的时间随机试(防止“钻牛角尖”,错过更好的动作)。
3. update方法:学经验(更新Q值,Qlearning的核心)
作用:根据“当前状态→动作→奖励→新状态”的经验,修正Q表中的Q值,让Q值更接近真实的“长期奖励”。这一步是Qlearning的灵魂,对应“试错后修正”的过程。
def update(
self,
obs: tuple[int, int, bool], # 当前状态(动作前的状态)
action: int, # 选的动作(0或1)
reward: float, # 动作带来的即时奖励(+1赢,-1输,0平)
terminated: bool, # 回合是否结束(比如爆牌或停牌,结束则没有下一个状态)
next_obs: tuple[int, int, bool], # 动作后的新状态
):
# 第一步:计算“未来的最大Q值”(从新状态能拿到的最好收益)
# (not terminated):如果回合结束(terminated=True),未来没有收益,所以乘0;否则乘1
# np.max(self.q_values[next_obs]):新状态下所有动作的最大Q值(未来能拿到的最好奖励)
future_q_value = (not terminated) * np.max(self.q_values[next_obs])
# 第二步:计算“目标Q值”(这个动作应该有的真实Q值,用贝尔曼方程)
# 贝尔曼方程:当前动作的价值 = 即时奖励 + 折扣后的未来最大价值
target = reward + self.discount_factor * future_q_value
# 第三步:计算“时间差分误差”(当前Q值和目标Q值的差距,即“错了多少”)
temporal_difference = target - self.q_values[obs][action]
# 第四步:更新Q值(往目标Q值的方向修正,修正幅度由学习率控制)
# 公式:新Q值 = 旧Q值 + 学习率 × 误差
self.q_values[obs][action] += self.lr * temporal_difference
# 第五步:记录误差(用于调试,误差变小说明Q值在优化)
self.training_error.append(temporal_difference)
用例子看懂更新逻辑:
假设场景:
- 当前状态
obs:(16, 10, False)(手牌16点,庄家10点,无可用A) - 选的动作
action:1(要牌) - 奖励
reward:-1(要牌后爆牌,输了) - 回合结束
terminated:True(爆牌后回合结束,没有新状态) - 新状态
next_obs:无意义(但代码仍会传入)
计算过程:
future_q_value= (not True) × 任何值 = 0 × … = 0(回合结束,未来没收益)target= -1 + 0.95 × 0 = -1(这个动作的真实价值就是-1,因为既没即时收益,也没未来收益)- 假设旧Q值
self.q_values[obs][action]是0.2(之前以为要牌还行) temporal_difference= -1 – 0.2 = -1.2(误差很大,说明之前的Q值估高了)- 新Q值 = 0.2 + 0.01×(-1.2) = 0.188(学习率0.01,慢慢往-1修正,避免一次改太猛)
关键理解:
- 贝尔曼方程是Qlearning的数学基础,核心思想是“当下的选择要考虑未来的最好结果”——比如二十一点“停牌”可能当下没奖励,但如果后续赢了,未来的奖励会反哺当前动作的Q值。
- 学习率
lr控制“修正幅度”:太大容易“学偏”(比如一次从0.2改成-1),太小则学太慢(几万局都没修正到位),通常设0.01~0.1之间。
4. decay_epsilon方法:衰减探索率
作用:每结束一个回合(玩完一手二十一点),就降低一点探索率,让智能体从“新手”慢慢变成“专家”。
def decay_epsilon(self):
# 探索率 = max(最小探索率, 当前探索率 - 衰减量)
# 保证探索率不会低于final_epsilon(比如0.1),避免完全不探索
self.epsilon = max(self.final_epsilon, self.epsilon - self.epsilon_decay)
例子:
如果initial_epsilon=1.0,epsilon_decay=0.001,final_epsilon=0.1:
- 第1回合后:
epsilon=1.0-0.001=0.999 - 第900回合后:
epsilon=1.0-0.001×900=0.1 - 第901回合后:
epsilon=max(0.1, 0.1-0.001)=0.1(不再衰减)
三、训练超参数:控制训练的“火候”
代码最后给出了训练的关键参数,这些参数决定了智能体“学得多快、学得好不好”:
# Training hyperparameters
learning_rate = 0.01 # 学习率:0.01意味着每次修正Q值时,只改1%的误差(稳但慢)
n_episodes = 100_000 # 训练回合数:玩10万手二十一点(足够智能体摸清规律)
start_epsilon = 1.0 # 初始探索率:刚开始100%随机试(彻底的新手)
# (文档中代码未写完,但完整代码还需补充epsilon_decay和final_epsilon,比如:)
epsilon_decay = 100_000 / 1.0 # 按10万回合衰减到0.1,计算得decay=0.000009
final_epsilon = 0.1 # 最小探索率:10%随机试(留一点探索空间)
关键理解:
- 训练回合数
n_episodes不能太少:二十一点有280种状态,每种状态需要多次试错才能摸清Q值,10万回合是经验值(太少则Q表没填准,太多则浪费时间)。 - 探索率衰减要“循序渐进”:如果衰减太快(比如100回合就降到0.1),智能体没来得及探索所有状态,可能错过最优策略;衰减太慢则学太慢。
四、代码逻辑总结:Qlearning的完整流程
把上述代码串起来,智能体的训练过程就像“学玩二十一点”的完整步骤:
- 初始化:创建智能体,加载二十一点环境,Q表初始化为0,探索率设1.0。
- 开始训练(循环10万回合):
a. 重置环境:每回合开始,用env.reset()拿到初始状态(比如手牌4点,庄家2点,无可用A)。
b. 玩一手牌:
- 根据当前状态,用
get_action()选动作(0或1)。 - 用
env.step(action)执行动作,拿到“新状态、奖励、是否结束、截断标志、调试信息”。 - 用
update()根据“旧状态→动作→奖励→新状态”更新Q表。 - 如果回合结束(爆牌或停牌),进入下一个回合。
c. 衰减探索率:每玩完一手,用decay_epsilon()降低一点探索率。
- 训练结束:Q表中每个(状态,动作)的Q值都接近真实长期奖励,智能体学会“什么情况选什么动作最优”(比如手牌12点对庄家2-6点要牌,对7-A点停牌)。
通过这段代码,Qlearning从“抽象的数学概念”变成了“可落地的智能体”——核心就是“用Q表记录经验,用试错修正经验,用探索平衡经验”,像人一样从实践中慢慢学会做决策。
发表回复