起因 链接到标题

吃四之后想骂发牌姬,但心里其实没底:到底是这副牌本来就没法打,还是我打崩了? 麻将的麻烦在于结果和过程之间隔着一层厚厚的方差,一局定不了任何事,可人一次只打得到一局。

誰だ / dareda 就是拿来解决这件事的。它把牌谱里的牌山取出来, 让 AI 在一模一样的发牌下把这局重打几十遍,然后给一个名次分布:

座2 同水平自打分布  n=30  平均顺位 2.30
    1位  20.0%  ████████
    2位  40.0%  ████████████████
    3位  30.0%  ████████████
    4位  10.0%  ████

实际 4 位;同水平自打期望 2.30 位。
  判断:偏背 —— 你的同水平只有 10% 会打到 4 位或更差

翻译一下:以你自己的水平重打这副牌,平均能到 2.3 位,只有 10% 会像那局一样吃四。 这把是运气差,不是你菜。(开喷!

它到底跑了什么 链接到标题

底子是 Mortal(开源的日麻 AI)和它的 libriichi 引擎, dareda 打了个补丁把牌山约定固定下来,使得同一副牌可以被反复重放。数据从雀魂抓包来, 解出来第一件事是 verify:逐局比对配牌是否与原局恒等,不过就不许往下跑—— 后面所有结论都建立在"重放的是同一副牌"之上。

之上是三个不同的实验,区别很容易被读混,所以这里写清楚:

实验谁在打回答什么
replay四家全 Mortal,从第一张牌起这副牌顶尖打法能打成什么样
counterfactual只有你换成 AI,对手照人类原样出牌钉在那条已经崩掉的牌河上,你还能救回多少
self-luck四家各按自己被标定出的水平重开同水平的你,重打 30 次会落在哪

“水平"不是拍脑袋给的:先在人类原局上逐决策测每家的 EV loss(与 Mortal 最优解的期望差距), 再反解出一个采样温度——温度越高越随机、越弱。所以 self-luck 里的对手不是三个满血 AI, 而是三个"和那晚一样菜"的人。

最后 diagnose 把这些压成一句话,靠一个恒等式:

实际名次 − 2.5 = (牌与座次) + (打法) + (运气残差)

关键在于必须跑两条基线。self-luck 的期望值里同时含了牌和打法,只有再跑一条"四家均等水平"的基线, 才能把"这副牌值几位"和"你的打法赚了几位"分开。只用 self-luck 当"牌好不好”,会把打法算进牌里。 三项相加恒等于左边,测试锁死了这条闭合性——也正因为恒等,合计那一项不是独立证据。

一份真实报告 链接到标题

下面这份是完整跑出来的东西:一份四人东南战(打到南四,K = 9 局),hero 是座 2,终局 4300 点,4 位。 同一份牌谱用两份 Mortal 权重(150k / 298k)各跑一遍,只为看结论稳不稳。

结论一句话:好牌,打得一般,而且这把运气还背。

  • 牌与座次 +0.33,打法 −0.13,运气 −1.70,合计 −1.50——从中性的 2.5 位掉到实际的 4 位,运气占了绝大部分。
  • 换成 298k 权重,同样的 −1.50 拆成 +0.40 / −0.03 / −1.87,判词一样是"偏背"、4 位概率一样是 10%。两份权重同向,这个结论是稳的。
  • 更打脸的一条:hero 的 EV loss 是四家里第 2 低(换 298k 是最低),全场打得最脏的是座 0,EV loss 0.488——而他拿了 3 位。

报告里还有一节专门讲该打的折扣,比如 counterfactual 的轨迹保真度中位数只有 51%(对手很快就脱轨了), 所以那一节的具体点数不该当数值结论;比如 montecarlo --sensitivityfull-mortal 档是确定性单次重放, 显示 100% 4 位说的是"那唯一一条确定轨迹是 4 位",不是 100% 的置信度。 一份分析报告里最该写清楚的往往是这些,而不是那几个大字。

末尾附了完整的耗时与内存账:每条命令都经 run_step.py 执行、用 os.wait4 逐进程采 rusage。 全套 11 个步骤合计 577.6 s(9.6 分钟),单步峰值常驻内存 440 MB。

(在 iframe 里看着挤的话,单独打开这一页。)

几个改动,和背后的判断 链接到标题

工具本身是慢慢长出来的,有几处改动值得单拎出来说,因为它们都是"数字算得出来"和"数字算得对"之间的差别。

运气不能用均值差来定义。 最早的判据是"实际名次 − 同水平期望"超过 ±0.2 位就算背/顺。 问题是自打分布经常是双峰的,均值落在没人会落到的中间地带,于是常见的 4 位被读成"惨背"。 后来改成分位:luck_q = 实际名次在自己那条分布里的中位秩,也就是"你赢过了自身多少比例的重演", ≤0.25 判背、≥0.75 判顺、中间愿赌服输。运气那几颗星也由同一个量画出来,不再和判词各说各话。

删掉了"换满血 AI"那一栏。 曾经有一栏是"你换成满血 Mortal、对手不变",本意是给"打法天花板"。 实测下来它不是天花板:固定牌山上 Mortal 反而更凶,和了和放炮都更多,平均名次跟真实水平打平。 一栏会误导用户的数字,哪怕算得没错也该删——顺带省掉三分之一算力。

强度测量跳过强制决策。 立直后的摸切、唯一合法的应对,都不是选择。 把它们算进一致率是白刷分,算进 EV loss 是拿 0 损失稀释。剔掉之后 hero 的一致率从 68% 掉到 66%、 EV loss 从 0.337 涨到 0.362,仍是全场第一;而那个重立直的对手 EV loss 从 0.656 涨到 0.741,虚高被挤掉了。 起因是一次很具体的委屈:用户吃四那局放的 18000 是立直后的强制摸切,不该算打法的锅。

把分布画出来。 “吃四=运气差"这个标签光看着就很像狡辩。后来在结果里加了名次概率横条, 逻辑就自洽了——你看得见那 10% 有多薄。黑箱变透明比多算几个数字有用。

耗时从 26 分钟砍到 9.6 分钟,靠的是三处结构性浪费,没有一处是"优化常数”: montecarlo 的跨轨迹循环本来是裸 for(CPU 只有 106%,其余命令都在 330% 上下),套上现成的进程池后单条命令 351s → 135s, 每条轨迹按 base_seed + i 播种,jobs=1jobs=4 的分布逐项相同; --sensitivityfull-mortal 档没有随机源,跑 20 条是同一局算 20 遍,压到 n=1; self-luck 本来就是 diagnose 的真子集(那次蒙特卡洛四家的分布都算了,只是只留了 hero 一份),留全即可, 加个 --with-selfluck 白送——两条都跑等于重算一遍,约占整套分析 17% 的耗时。

西入被一行代码挡了很久。 南四结束无人到 30000 会打进西一,而 KyokuReplaykyoku 上界写死在 8, 于是全线报"kyoku 越界: 8"。查下去发现西场其实天然成立——Board 本来就按 bakaze = E + kyoku/4 推导, 观测编码也早把西场 clamp 成南场口径。也就是说西入一直是被那一句挡在门外,而不是不被支持。上界改成 12 就完了。

还有一批纯工程的坑,写在 README 的"遇到问题"里:Windows 260 字符路径上限会让 pip install torch 装到一半停下、 之后报一个完全无关的 No module named 'torchgen';不设 PYO3_PYTHON 编出来的扩展会链到系统 Python, 在 venv 里 import 报 symbol not found,而错误信息完全看不出是链错了解释器。 抓包脚本也从"按 D 键下载"改成了右下角悬浮按钮——原来的裸热键会在雀魂任何输入框里打字带 d 时误触。

三条须知 链接到标题

  • 只用来复盘打完的牌。 不要用于正在进行的对局。
  • 取牌谱需要在浏览器里改雀魂客户端,可能违反用户协议、有封号风险。 用不用自己决定,建议用小号。
  • 项目不含 AI 模型,也不提供模型权重(安装脚本会自动从第三方下)。许可证 AGPL-3.0。

顺带一提,报告里那句"这局打得最不干净的是座 0,而他拿了 3 位"—— 这大概才是这个工具最诚实的用途:它同样会告诉你,你打得就是比对面差,只是那天运气替你兜住了。