YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
QOJ 斗地主训练与测试
2026-10-04 修正后的续训
上一轮扩容实验完成,但验证最好权重仍是起点,最新模型反而退化。
新配置由 python3 -u tools/run_corrected_qoj_training.py 启动,
先完成 96 局历史/48 局 PPO 的流程对照,再自动启动全量运行。
输出 runs/resnet-guarded-20261004,旁侧 .job.json 记录阶段和进程,
.log 记录实时训练进度。恢复正式训练使用同一命令追加 --resume。
具体变化:
- 从上一轮
best.pt保留宽度 256、两路各 4 层的共享表示,切换冻结先验为 指定 ResNet best;重置四个残差策略头和门控,使新起点的出牌/叫分 argmax 与该基准一致。这是更强先验的接入,不是已经学出了超过基准的棋力。 - 关闭历史动作模仿 (
--history-weight 0) 及历史终局收益对价值/胜负头的监督。 历史公共状态用于 ResNet 的动作蒸馏、KL 和真实暗牌标签辅助训练; 价值头使用实际生成的教师轨迹和新鲜 PPO 轨迹。 - 离线只训练 1 轮,学习率
1e-5,强教师 KL 权重.5、最佳动作蒸馏权重.05。 PPO 从验证best.pt开始,学习率5e-6,教师 KL 权重.15,每轮 96 局、1 轮更新。 - 通常 75% 对局直接针对目标 ResNet,剩余为自博弈和快照。快照随机选择, 避免按偶数索引漏用一半联盟成员;锚点从全部教师预热状态中固定随机抽取。
- 更新后计算整轮所有状态、全部合法动作上的精确 old||new KL。
超过
.01则恢复该轮前的模型/优化器并减半学习率。 每 8 轮验证,综合或任一模式得分比最佳低超过.02时, 恢复最佳模型及相应优化器、清空退化快照、减半学习率。 这是退化保护,不能保证真实棋力单调提升。 - 正式预算 256 轮,共 24,576 局 PPO;最终测试另保留 256 副发牌/3,072 局, 并用独立 JavaScript 规则回放。新训练/验证/测试种子区间均避开旧实验。
强先验检查点使用格式 2,记录 teacher_model_type 和权重哈希,加载时自动选择
ResNet best;旧格式 1 的 LSTM 检查点仍可部署。新训练配置和源码改变后应使用
新的输出目录;旧实验检查点不能通过 --resume 混入新的目标和优化器设置。
教师原始权重、历史输入和上一次训练产物不被覆盖。
这里实现一套独立于原 ResNet 训练目录的模型。教师和默认评测对手固定为
models/douzero-baselines/douzero_ADP 中的三角色 LSTM,
不依赖 qojbot 当前默认模型,也不修改任何原权重。
2026-10-03 扩容续训
支持从旧 best.pt 扩大宽度和深度后继续训练:旧参数复制到更宽网络,
保持每个注意力头的维度,新层从恒等残差开始,保留扩容前的预测。
128 → 256、4 → 8 个注意力头、两路各 2 → 4 层时,
可训练参数从 949,951 增至 6,335,807(6.67 倍,不含冻结教师)。
新增层的随机内部投影在训练中打破复制通道的对称性。
冻结 LSTM 先验与训练/评测对手可以独立设置。
--opponent-dir 使指定的 ResNet 成为 PPO 联盟对手和验证/最终测试对手,
best.pt 因而按对该 ResNet 的真实 QOJ 得分选出。
原 LSTM 先验继续保留,以兼容旧策略权重。
上述正式配置也封装在 tools/run_large_qoj_training.py,可直接执行
python3 -u tools/run_large_qoj_training.py;恢复时追加 --resume。
监督进程用锁避免重复启动,保存旁侧 .log 和 .job.json,
并在训练/测试正常结束后用独立 JavaScript 规则检查器回放最终测试。
2026-10-03 已启动的后台运行使用 caffeinate -i 防止空闲休眠。
python3 -u QOJDoudizhuModel/train.py --device mps \
--width 256 --heads 8 --rank-layers 4 --history-layers 4 \
--init-checkpoint QOJDoudizhuModel/model/best.pt \
--opponent-dir douzero-resnet-2.0/Douzero_Resnet/baseline/best \
--output QOJDoudizhuModel/runs/large-resnet-20261003 \
--max-history-games 0 --offline-epochs 3 --warmup-games 128 \
--rounds 1024 --games-per-round 12 --batch-states 32 \
--checkpoint-interval 250 --learning-rate 5e-5 --ppo-learning-rate 2e-5 \
--ppo-start latest --validation-deals 64 --validation-interval 64 \
--test-deals 256 --seed 20261003 \
--validation-seed 302610030 --test-seed 402610030
这次运行固定启动时 data/qoj-history/games 的全部输入,
继续按整场比赛/重复发牌分组保留 80%/10%/10% 划分。
“全量”表示验证并纳入全部牌谱;验证/测试分组不参与梯度更新,非法牌谱拒绝入库。
默认排除托管动作。dataset/progress.json 报告准备阶段的进度;
launch.json 记录训练配置,initial.pt 保留扩容后的起点。
离线阶段每 250 批保存一次模型、优化器和已完成批数;
恢复使用同一整条命令追加 --resume,以确定性的顺序跳过已提交的批次。
数据准备完成前中断尚不能用 --resume,应改用新输出目录重新准备。
续训的旧检查点已经见过部分历史数据;新快照中的离线 holdout loss 不能证明对旧检查点而言的数据独立性。棋力结论只依据未与旧实验和 小规模流程测试重叠的新合成发牌测试种子。
最终测试共 3,072 局,使用 256 副独立发牌,包含固定地主/真实叫分及三座位双向轮换。
test.json 保存整体按发牌聚类的 95% bootstrap 区间、角色分项和对手权重哈希。
positive_score_supported 仅在至少 100 副牌、整体区间下界大于 0、
且各模式平均得分均大于 0 时为真。扩大参数或完成训练本身不代表已经超过对手。
单独评测:
python3 QOJDoudizhuModel/evaluate.py --device mps \
--checkpoint QOJDoudizhuModel/runs/large-resnet-20261003/best.pt \
--opponent-dir douzero-resnet-2.0/Douzero_Resnet/baseline/best \
--deals 256 --seed 502610030 \
--output QOJDoudizhuModel/results/large-resnet-independent.json
后文的旧起步实验未传 --opponent-dir,仍使用 LSTM 对手。
架构
模型以不完全信息决策为边界,只接收自己的手牌、公共出牌与叫分历史、 未见牌总集合、公开底牌、角色和剩余张数。
15 个点数 token [自己的牌/未见牌/公开底牌/当前出牌/三角色已出牌]
-> 点数 embedding + 角色 embedding -> 2 层点数 Transformer
最近 96 个公开事件 [牌/牌型/角色/叫分] + 状态汇总 token
-> 时间 embedding -> 2 层历史 Transformer
两路编码 + 15 维状态量
-> 状态上下文 + 两个对手的逐点数暗牌分配预测
-> 各合法动作的牌、余牌、牌型、主点数、长度等编码
-> 动作对点数 token 的交叉注意力
-> 角色条件残差策略 / 51 原子得分分布 / 胜负辅助头
状态上下文 -> 真实得分 critic
默认隐藏宽度 128、4 个注意力头、点数/历史各 2 层;地主、下家、上家、叫分 共享表示,分别使用策略输出头。LayerNorm 不依赖候选数量,不使用 BatchNorm 或 dropout。 一次编码状态,再分块评分动作;不会为每个候选重新编码完整历史。
策略为:
pi(a|s) = softmax(g_role * standardized_DouZero_value(a) / 0.6
+ 6 * tanh(residual(s,a)))
残差头零初始化,g_role = 2 * sigmoid(parameter) 初始为 1。
因此最初的出牌 argmax 与指定 DouZero 基准一致,叫分 argmax 与原启发式一致。
门控可学习,教师先验不必永久主导策略。部署仍需要三份原 LSTM 权重。
合法动作来自现有 qojbot.rules,保留同一组牌的不同牌型解释。
叫分直接枚举 QOJ 合法的 0/1/2/3 候选及 must_bid 约束,
不是把二分类叫牌输出硬映射成叫分。
少数据时如何提升
- 弱离线监督:完整验证历史牌谱,记录动作的交叉熵权重为 0.15; 真实终局收益用于 critic、得分分布和胜负辅助头。默认排除托管动作, 不把所有历史玩家都当专家,也不把未出动作强行标成负例。
- 无限可生成的教师轨迹:随机发牌,三角色基准对局,产生合法动作偏好、 真实 QOJ 得分和暗牌辅助标签。默认先生成 32 局,之后还持续接触教师对手。
- 新鲜轨迹 PPO:每轮采样后更新,轮末丢弃旧 PPO 数据。记录全部合法动作 的实际采样概率,clip=0.2、最多两轮更新、KL>0.04 时停止当前轮的后续更新。 熵奖励 0.01;对教师的轻量 KL 约束默认 0.02。
- 混合对手联盟:基准、历史快照、现有公共信息搜索策略和新模型自博弈, 覆盖一新模型对两对手、两新模型对一对手及纯自博弈。 农民共享同方向终局收益,同时保留上下家的不同策略头。
- 暗牌辅助学习:训练时从已结束牌谱或已结束模拟对局恢复暗牌标签。 推理只使用预测结果;特征编码器不接收各对手真实手牌。 公开地主底牌是分配预测的硬下界,但不声称已实现精确贝叶斯后验。
PPO 的收益为每位玩家的真实 QOJ delta / 6,gamma=1; 这是线性单位变换,仍优化真实得分。按角色标准化优势以平衡地主双倍分。 51 原子辅助头使用 symlog 支撑区间 [-5,5];对数变换只用于辅助任务。 主优化不会把爆炸/叫分的得分倍率压成纯胜负。
训练
在仓库根目录执行。使用已经安装 PyTorch 的系统 python3;
当前项目 .venv 不含 PyTorch。依赖只有现有 torch、numpy;
联网游玩另需要原 bot 的网络依赖。
# 完整起步实验:全量现有牌谱,1536 局 PPO,固定地主/真实叫分交替
python3 QOJDoudizhuModel/train.py --device mps
# 恢复并扩展到 12288 局 PPO,不重新预热
python3 QOJDoudizhuModel/train.py --device mps --resume --rounds 1024
默认输出 QOJDoudizhuModel/runs/main,训练局数为
rounds * games-per-round。默认设置是起步实验,不承诺足以达到高水平;
可以持续扩展轮数,不会自动无限占用 GPU。
所有配置除 rounds 与最终测试开关外,恢复时必须保持不变。
准备长期追加训练时可加 --skip-final-test,只在冻结最终选定模型后做独立测试;
不要依据反复查看同一测试集的结果决定后续配置。
# 较大容量消融,必须使用新输出目录
python3 QOJDoudizhuModel/train.py --device mps --width 192 \
--warmup-games 128 --rounds 1024 --output QOJDoudizhuModel/runs/w192
# 仅验证流程,不能用于宣称棋力
python3 QOJDoudizhuModel/train.py --device mps \
--output QOJDoudizhuModel/runs/smoke-new \
--max-history-games 6 --warmup-games 2 --offline-epochs 1 \
--rounds 1 --games-per-round 3 --batch-states 8 \
--validation-deals 1 --test-deals 1 --validation-interval 1 --mode bidding
当前机器的沙箱内检测不到 MPS,沙箱外已实际验证可用。
显式 --device mps 会在不可用时失败,不静默退回 CPU,也不启用算子 CPU fallback。
模型和教师都在同一 MPS 设备;环境与规则枚举在 CPU。
训练为同步单进程,不声称具备分布式吞吐。
数据与恢复
- 首次训练固定输入文件和 SHA256,按整场比赛与重复发牌的连通分组划分 80%/10%/10% train/validation/test,防止相邻轮次或重发同一副牌泄漏。
- 后续抓取新增文件不会改变已开始的实验;使用新输出目录才会纳入新数据。
- 牌谱完整回放和终局计分不一致的记录拒绝入库,并在 manifest 中列出原因。
- 数据以 256 状态分片,公共特征压成 CPU float16,前向/反向使用 float32。 不保存每个动作重复的公共历史。
- 离线蒸馏默认每状态最多 64 个候选,保留实际动作及教师最优。
这是条件子集监督;
--candidate-cap 0可关闭。PPO 和推理始终使用全部合法动作。 - Ctrl+C 会恢复到最近完成的 epoch/轮次,包括模型、优化器及随机状态, 重跑未提交的部分,并对齐逐局日志。缓存、教师、源码或预热数据变化时拒绝恢复。
- 小样本可能没有验证/测试分组,此时离线 loss 为空;合成配对评测仍可运行, 但不能用小样本结果证明能力提升。
评测
训练只用固定验证种子选模;初始化基准也是候选。
只有平均得分严格提升才覆盖 best.pt,所以不保证它一定包含新训练权重。
latest.pt 始终保留最新训练权重。最终独立测试不参与选模。
# 每种模式 100 副牌,各 600 局,共 1200 局
python3 QOJDoudizhuModel/evaluate.py \
--checkpoint QOJDoudizhuModel/runs/main/best.pt --device mps \
--deals 100 --output QOJDoudizhuModel/results/douzero-test.json
# 搜索对手消融,使用同一独立测试种子
python3 QOJDoudizhuModel/evaluate.py \
--checkpoint QOJDoudizhuModel/runs/main/best.pt --device mps \
--opponent search --deals 100 \
--output QOJDoudizhuModel/results/search-test.json
同副牌在两种模型分配方向上各轮换三个 singleton 座位。
报告包含真实 QOJ 得分、胜率、三角色/一对二与二对一分项、叫分次数、
推理 p50/p95/p99 和按原始发牌种子聚类的 bootstrap 区间。
报告复用原评测器的 lstm/resnet 键;labels 明确对应 DouZero 对手/QOJNet,
这里的 QOJNet 并不是 ResNet。
正式比较至少应增加到数百副牌,并结合区间、角色短板、叫分收益和运行耗时, 不能只比较一个随机种子的点估计。固定地主测试隔离出牌能力; 真实叫分测试评估叫分与出牌的组合效果。 评测仍不等于线上 Rating 或六局积分赛名次。
Bot 接入
# 离线分析
python3 bot.py analyze examples/opening.json --policy qoj --device mps \
--checkpoint-dir QOJDoudizhuModel/runs/main/best.pt
# 只有明确执行这条命令才会联网并产生真实对局
python3 bot.py play --policy qoj --device mps --mode match \
--checkpoint-dir QOJDoudizhuModel/runs/main/best.pt
也可将 latest.pt 指定为 checkpoint 做研究比较。使用 best.pt 更保守。
加载时校验教师权重哈希,并在登录前预热模型;运行中不会热更新权重。
推理沿用现有实体牌转换、合法动作校验与显式错误 fallback。
本次实现和测试没有登录 QOJ 或提交动作。
产物与测试
best.pt、latest.pt 为可部署权重;resume.pt 额外保存优化器、联盟和采样状态;
dataset/manifest.json 保存输入快照和划分;report.json 保存训练概要;
warmup_games.json、train_games.jsonl、validation-*.json、test.json 为可回放对局。
二进制与训练结果放在被 Git 忽略的 runs/、results/。
python3 -m unittest discover -s QOJDoudizhuModel/tests -v
python3 run_tests.py
优先继续做的实验
现有实现是可运行、可验证的起点,不是已经证实最强的架构。 建议先固定测试协议,增加自博弈预算,再分别消融历史/暗牌辅助/教师门控/ 快照联盟/候选子集和更大宽度。每个结论使用独立实验种子重复。
后续高收益方向是公共信息约束下的搜索改进数据与少牌残局专家, 以及具有固定手牌总数约束的更强暗牌后验。当前仅将已有搜索策略作为对手, 没有把完全信息搜索最优动作当作可直接部署的“不泄漏”策略, 也没有实现完整 ReBeL/CFR 或声称 Nash 保证。