跳到主要内容

我挂上 Math.random 才看懂:这游戏预测的不是随机,是你的自作聪明

钻牛角
钻牛角

· 阅读约 7 分钟

HN 上那条 Show HN:How random are you? 我点进去本来只想看评论区乐子。一个让你输数字、程序预测你下一次选几的小游戏,有人说自己 190 个计分数字被猜中 18 个,9%,比盲猜还低两点,一副“预测器被我榨干”的口气。我第一反应:这预测器得有多拉胯。结果自己也钻进去,半宿没出来。这头牛今天非钻穿不可,嘿嘿。

玩法不复杂:键盘或者语音报数,每报一个它猜下一个;猜中次数单独计,拿纯瞎猜做基线。九个数字,盲猜基线 1/9,11.11%。最后它告诉你,一项 206 人的心理学研究里有几个人“比你更随机”。我一开始没把那个研究当回事——网页版嘛,参考罢了。现在回头看,那才是题眼,稍后说。

我先走键盘。评论区有人说键盘比口头报数更容易表现得随机,我当时的理解是:手动戳数字不带出口音、节奏这些附加信息。于是我只用键。我还给自己定了规矩——别连续重复、九个数字尽量轮着来、偶尔故意连着按同一个。点了一百多个,系统显示命中率在 10% 上下浮,跟瞎猜线贴脸。我挺得意。

接着我想,手点说到底还是会调动“我觉得什么像随机”的偏见。那就别用手,直接挂 Math.random。脚本写得糙,本地模拟点击,间隔别固定,不然又白送节奏线索:

let count = 0;
const timer = setInterval(() => {
  const pick = Math.floor(Math.random() * 9) + 1;
  click(pick); // 模拟按钮
  if (++count >= 190) clearInterval(timer);
}, Math.floor(Math.random() * 300) + 50);

你猜怎么着?190 个数字,它猜中 18 个,9%。活见鬼,跟我手动装随机一个水平。当时我有点膨胀:这预测器果然就这。

中间我一度拍桌子想骂作者拿玩具模型糊弄人。然后翻到作者在评论区说过,预测器核心是多个即时构建的 n-gram 马尔可夫模型,配一些非马尔可夫的简单启发式。我清醒了。n-gram 专吃短程局部依赖,我喂什么它记什么。手装随机时,它记我那些“轮着来”的套路;挂 Math.random 时,它没东西可记,只能靠启发式瞎猜。我把预测器逼到盲猜线,没什么可夸的——只是换了个它没法建模的源。

后来我反推它怎么猜的,写了个极简还原:

function guess(history) {
  const key = history.slice(-3).join('.');
  const nexts = ngramCounts[key] || uniformNine;
  return weightedPick(nexts);
}

这不是源码,别误会。但从它遇固定序列就开始死磕复读的行为看,八九不离十。作者自己就承认,有个叫 numerical avoider 的预测器,权重一高就发羊癫疯,死磕同一个数字,后来才修好。我笑出声——连预测器都怕过拟合,跟我第一次调学习率一个德行。

好,既然人类手动和 Math.random 都落在那儿,游戏不是白做了?我开始钻这个点。为什么 Aaronson oracle 那种二选一预测能到 70-80%,这边九选一反而只有人类自评 8-13%?差太多了。后来发现是网页版的设计给测试对象漏了气。

作者在评论区大方承认:实时显示预测结果会让玩家根据反馈调整策略,可能比原始研究更随机。原始研究用的是 3×3 数字网格加节拍器,逼你在固定节奏里选,直到结束才给反馈。看到这行我脑子里“啪”一下。

实时反馈一开,这游戏已经不是测“你多随机”了,是测“你多快学会不被猜到”。玩家看一眼预测结果,下次就绕开;绕开之后又打乱自己的策略;几轮下来,人和预测器在玩追逃,不是在测熵。HN 上那一堆 9%、10%、13% 的成绩——包括我自己的——根本不是随机值高,是逃跑学得好。Math.random 不需要逃跑,它本来就无结构,所以预测器自然掉回盲猜线。这游戏想给人制造“我比随机还随机”的幻觉,嘿,它真做到了。

Aaronson oracle 那个事更狠。实验让用户交替按 f/d,二选一,盲猜基线 50%。Scott Aaronson 说简单模型就能到 70%,挑战里 70-80%,有一个学生恰好只有 50%,被当成稀罕物。想想那个学生:要么特别会摆平自己,要么根本没把脑子交给“随机感”。剩下的人全栽在局部依赖上——你按完 f 之后想“刚才 f 多了,来个 d”,这个想法本身就可预测。

课堂掷硬币也一样:一组真掷 50 次,一组编 50 次,教授回来一眼分出两张表。为什么?真人序列里会出现长连正、长连反,人类编的序列过度打散。HTHTHT 跟 HHHHHH 在真实随机下概率一样,但人不敢写后者,怕显得不随机。越怕越出卖自己。

还有个心理技巧:快速想一个 1 到 50 之间、两位数字互不相同的奇数,很多人会报 37。原评论用二进制 100101 藏着答案,另一个人猜了 13。我心里蹦出来的也是 13。嘿,评论区一堆人被“37”或者“13”牵着走,我能有多随机?

于是有人想用外挂:mod 3、手表秒数、π 的小数位换序。听着数学味很重,全是确定性的。π 数字换序在原版游戏里也就 14%,比盲猜还差。mod 3 你选的是 0/1/2 的余数,每个数字背后是周期;手表秒数更不用说了,tick 本身就在给你定时。预测器最不怕确定性,它怕的是没有明天。

真懂行的会搬出 von Neumann 去偏。抛硬币里,HH/TT 丢掉,HT/TH 映射成 0/1,才能拿到无偏位:

function vonNeumannBit() {
  for (;;) {
    const a = Math.random() > 0.5;
    const b = Math.random() > 0.5;
    if (a === b) continue;
    return a ? 1 : 0;
  }
}

但这是硬币,游戏里是九个数字。想把无偏位流映射成 1-9 还不引入偏差,要么带拒绝采样,要么折腾到放弃。而且一旦在浏览器里用 Math.random() 做去偏,你测的早就不是人,是 V8 的伪随机发生器。游戏边界就没了。

我还注意到有人想冲 0% 命中。别。九个数字盲猜基线 1/9,11.11%。你把预测器打到 0%,不是它猜不透你,是它学会了把你的策略加个反号。0% 和 100% 信息一样多,它翻转一下就能吃掉你。真正的赢是让它钉在盲猜线上,每一次下注都像抛骰子。你让它一次都猜不中,反倒把反向模式暴露了。你说气不气。

钻到这把各路货色丢进表格:

输入源表现备注
手动装随机(键盘)8%–13% 自评有实时反馈,边打边跑
Math.random 自动9%真伪随机源,预测器掉回盲猜线
π 数字换序14%有确定结构,比不动脑还差
盲猜基线(1/9)11.11%预测器上下都得拿它当锚
Aaronson oracle(二选一、无反馈)简单模型 70%–80%同族问题,脱离实时反馈后人类被吃死

这表里最拉胯的不是 Safari,这次 Safari 连参赛资格都没有——是去掉反馈后的人。Aaronson oracle 那个 70-80% 才是我们这种生物的原生随机值,其它成绩都是游戏给了实时逃跑通道之后打出来的虚分。

语音那块也值得钻半句。作者选了 Vosk,词表能缩到数字,在 WebKit、Firefox 上试下来“尚可”。有人推 yapsnap,说词错率更好,CPU 跑得也快。结果模型 150MB,Vosk 才 30MB,作者开始考虑量化。这个体量差,网页里难成气候。

语音识别模型体积备注
Vosk~30MB词表可限制在数字,WebKit / Firefox 尚可
yapsnap~150MB词错率更好,CPU 快,但体积大到没法直接塞网页

我用语音模式报了几轮,真的比键盘更糟。嘴巴会自己找节奏,三、五、七……停下来还想下一句,像把思路一起念出去。给了预测器更多纹理。要稳,还是键盘。

最后,说句跟这篇八竿子打不着的。我写完这段时晚上十一点多,手机热点断了一次,重连要输密码,密码里有个 3。我手指停在那儿,满脑子“别选 3,别选 3,刚才已经出现过两次 3 了”。那一刻我意识到,我根本不是随机,我是用力过猛地装随机。人类这脑子,太爱拿自作聪明冒充熵源。真正该做的,是把“我觉得这样够乱吧”当 bug 修掉。

扯远了,反正——嘿嘿,下次见。

钻牛角
钻牛角

逮住一个前端小知识点挖到底:现象→实验→兼容性表格→闲聊,配 demo 玩梗。

查看主页 →