在Python中模拟滑道和梯子

357 阅读17分钟

2017年12月19日编辑:增加了一个新的小节,将《滑梯与梯子》作为一个吸收性马尔可夫链进行分析。

[img: Chutes and Ladders animated simulation]

这个周末,我发现自己和我四岁的孩子玩了一个特别拖沓的 "滑梯与梯子 "游戏。如果你没有玩过这个游戏,"滑道和梯子"(有时也被称为 "蛇和梯子")是一个经典的儿童棋盘游戏,玩家通过掷六面骰子在100个方格中前进,使用 "梯子 "跳跃前进,并避开将你送回的 "滑道"。这基本上是一个荣耀的随机行走,有视觉辅助工具来帮助你建立一个叙述。惊心动魄。但她在练习计数,学习优雅地赢和输,以及发展成为一个热情的体育迷所需的技能方面很开心,所以我也跟着玩。

在上午的大约第二十三场比赛中,当我们发现自己处于爬梯子和滑道的近乎无休止的循环中,从来没有达到结束游戏的最后一个方块时,我开始想这个游戏还能持续多久:一个游戏的预期长度是多少?游戏长度分布的尾部有多大?我如何用Python简洁地回答这些问题?然后,在某个时刻,我明白了。Chutes and Ladders是无记忆的--翻滚的效果只取决于你在哪里,而不是你去过哪里--因此它可以被建模为马尔可夫过程当我们(最终)达到100方时,我基本上已经写好了这篇博文,至少在我的脑海中是这样。

当我在推特上谈论这个问题时,人们给我指出了许多 类似的"滑梯梯子"的处理方法,所以我并不幻想这个想法是原创的。把这看作是一个博客文章版本的爸爸笑话:我的主要目标不是原创,而是自娱自乐,如果其他人觉得它很有趣,那只是一个额外的奖励。

直接模拟¶

掌握游戏动态的最直接方法是通过直接模拟:如果我们模拟了足够多的游戏,我们会得到一个游戏长度的分布,它将接近 "真实 "的分布。这方面的第一步是检查游戏板,并以某种方式对网格上的滑道和梯子的位置进行编码。

[img: Chutes and ladders game board]

(图片来源:uncyclopedia)

我们将使用一个Python字典来存储这些位置。

在[1]中。

# Mapping of start : end spaces of chutes & ladders

有了这个,我们就可以用几行Python模拟游戏了。

在[2]中。

from

调用该函数可以告诉我们需要多少步棋才能完成这个特定的游戏。

In [3]:

simulate_cl_game

Out[3]:

28

如果我们模拟了许多游戏,结果将是需要多少个回合才能走完的分布。

在[4]。

# Jupyter notebook plotting setup & imports

在[5]中。

sim_games

这给了我们一些启示,但问题是,这个结果只是对 "真实 "分布的估计;要使我们的估计更精确,就需要更多的模拟,而这在计算上会变得很昂贵。

幸运的是,有另一种方法。

作为马尔科夫过程的 "滑梯"(Chutes and Ladders)

我们可以从概率的角度来考虑这个游戏,而不是用蛮力模拟。在任何给定的回合,有六个同样可能的选项:滚动1,2,3,4,5,或6。取决于你从哪个空间开始,这些会导致六个明确的结果。例如,第一轮的可能性是38、2、3、14、5或6,每个概率都相同。我们可以将这组概率编码为一个长度为101的向量,每个相关的索引中都有1/6 (这里的第2个元素代表游戏的开始,离开棋盘)。

0: [0, 0, 1/6, 1/6, 0, 1/6, 1/6, 0, 0, ...]

这个向量中的每个条目都是从零格到相应格子的概率。这个向量完全描述了游戏的第一个回合。

同样地,我们可以构建描述从2号方格开始的回合的向量。

2: [0, 0, 0, 1/6, 0, 1/6, 1/6, 1/6, 1/6, 0, 0, ...]

同样,这完全描述了游戏中从2号方格开始的任何回合。

关键的见解是,"滑梯 "是无记忆的;如果你在19号方格,不管你是通过从62号方格滑下来,还是从14号方格掷出5,都没有关系--下一回合的概率是完全一样的。

这种情况:从一个状态到另一个状态的概率转换序列,对以前的状态没有记忆,被称为马尔可夫链或马尔可夫过程,可以完全由一个N×N的过渡矩阵来描述,其中N是系统中的状态数(《滑梯》有101个状态)。矩阵的列包含我们上面讨论过的概率向量,如元素*(n,m)是指从元素m开始时过渡到元素n*的概率。

让我们为 "滑梯 "创建一个马尔科夫过渡矩阵。

在[6]中。

import

上面的矩阵编码了你需要知道的关于 "滑梯 "游戏的一切。(注意,有两种方法可以构建这个矩阵:在每个回合结束时应用滑梯和梯子的跳跃,就像通常玩的那样,或者在下一个回合开始时应用它们。我们把这第二种方法留到以后)。

游戏开始时有一个状态向量,描述100%的概率处于第4种状态。

v_0 = [1, 0, 0, 0, 0...]

如果你想一想,你可以说服自己,把这个(概率地)演变到下一个状态的方法是用我们的过渡矩阵对这个向量进行左乘。

In[7]:

np

Out[7]。

array([ 0.  ,  0.  ,  0.17,  0.17,  0.  ,  0.17,  0.17,  0.  ,  0.  ,
        0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.17,  0.  ,  0.  ,  0.  ,
        0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,
        0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,
        0.  ,  0.  ,  0.17,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,
        0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,
        0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,
        0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,
        0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,
        0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,
        0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,  0.  ,
        0.  ,  0.  ])

结果正是我们上面描述的向量,编码了一步棋后游戏状态的概率。

为了执行多步棋,我们可以重复用这个向量乘以我们的过渡矩阵,但是重复这样的操作会增加浮点的不准确性;一个更有效(也更高效)的方法是使用矩阵的力量。考虑到这一点,让我们来计算几个n值的情况下在n步内完成游戏的概率,并与上面的模拟结果进行比较。

在[8]中。

def

在[9]中。

probs

用眼睛看,我们的直接模拟结果与马尔可夫模型相当接近,这表明我们的思路是正确的。

累积分布也很有意思。

在[10]中。

plt

从中我们可以看出,90%的单人游戏在大约72步内完成,尽管游戏有可能更长。

对于两名棋手来说,这就意味着有1%的机会,游戏走完72步而没有任何一名棋手获胜。假设每轮比赛大约20秒,那就是大约24分钟的比赛时间,尽管根据个人经验,我可以说感觉大约是这个时间的20倍。

最小游戏长度

但如果你真的很幸运......游戏能多快结束呢?从我们的马尔科夫链结果中,我们看到有一个非零的概率在短短七步内结束对局,而这大约会在每660局中发生一次。

In[11]:

probs

出[11]。

[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0015110596707818924]

马尔科夫链方法并没有立即告诉我们这种情况是如何发生的,尽管过渡矩阵编码了一个稀疏的图,可以由scipy.sparse.csgraph ,以找到这些最短路径之一。

在[12]中。

from

lengths 此处为从状态0到其他各状态的最短路径的长度。

在[13]中。

lengths

Out[13]:

array([  0.,  inf,   1.,   1.,  inf,   1.,   1.,   2.,   2.,  inf,   2.,
         2.,   2.,   3.,   1.,   2.,  inf,   2.,   2.,   2.,   2.,  inf,
         3.,   3.,   3.,   3.,   3.,   4.,  inf,   4.,   4.,   2.,   3.,
         3.,   3.,   3.,  inf,   3.,   1.,   2.,   2.,   2.,   2.,   2.,
         2.,   3.,   3.,  inf,   3.,  inf,   3.,  inf,   4.,   4.,   4.,
         4.,  inf,   5.,   5.,   5.,   5.,   5.,  inf,   6.,  inf,   6.,
         6.,   4.,   5.,   5.,   5.,  inf,   5.,   5.,   6.,   6.,   6.,
         6.,   6.,   6.,  inf,   7.,   7.,   7.,   4.,   5.,   5.,  inf,
         5.,   5.,   5.,   5.,   6.,  inf,   6.,  inf,   6.,   6.,  inf,
         7.,   7.])

我们可以用这里的predecessors 向量来重建其中一条通往状态100的最短路径。

在[14]中。

path

Out[14]:

[0, 38, 39, 45, 67, 70, 75, 100]

典型的游戏长度¶

我们已经谈到了极端的情况,但是你应该期望典型的游戏持续多长时间?这个问题的答案取决于你对典型的定义。一些定义典型的常用方法是游戏长度的平均值、中位数或模式。

平均棋局长度是指所有可能性的平均值,并按其相对概率加权的回合数的预期值。

在[15]。

turns

出[15]。

39.106460290714438

我们看到,平均而言,一个棋手将在大约39个回合内完成游戏。

不过,这个值是由游戏长度加权的,所以有一个非常长的游戏的小可能性以一种巨大的方式对这个估计值做出贡献。通常情况下,游戏长度的中位数是一个更有用的统计数字。

在[16]。

np

出[16]。

32

这告诉我们,大约50%的对局将在少于32步的情况下结束,而50%的对局将在超过32步的情况下结束。

但是平均数和中位数都偏离了概率分布的峰值(或模式),我们可以用以下方法计算。

In[17]:

np

出[17]。

22

这说明你在22步内完成对局的次数比任何其他特定步数都要多。

不过,对于像这样的倾斜概率分布,最有用的统计类型可能是量值。比如说。

In[18]:

np

出[18]。

array([ 11, 106])

这告诉我们,95%的情况下,对局将持续11到106个回合之间。或者,把约束收紧一点。

In[19]:

np

Out[19]:

array([22, 50])

一半的棋局将持续22至50步。

从马尔科夫过程中学习更多东西

除了总结性的统计数字外,"滑梯 "的马尔可夫过渡矩阵还能告诉我们其他一些有趣的事情,我们将在此进行探讨。

Shannon Entropy

我们可以探索的一个有趣的事情是分布的[熵](en.wikipedia.org/wiki/Entrop… "分散 "程度的衡量;一个高熵的状态是我们对个别抽签的位置知之甚少。

我们可以使用SciPy网站上的一个函数来计算和显示熵 entropy函数来计算和显示熵,该函数来自SciPy的stats 模块。让我们绘制熵与游戏回合数的函数。

在[20]中。

from

我们看到,熵在11个回合后达到最大。

在[21]中。

np

出[21]。

11

这告诉你的是,当你完成11个回合时,你应该期望不同棋手的位置会非常分散。在此之前,概率分布是在棋盘的开头附近聚集的。11步之后,你可以放心,你已经不可避免地走上了游戏结束的道路。

特征向量和静止状态¶

可以直接检查马尔科夫过渡矩阵来了解游戏的动态。例如,特征值等于1的过渡矩阵的特征向量告诉我们静止状态;也就是说,应用过渡矩阵后的状态没有变化。

我们可以通过这种方式计算这些特征向量。

In[22]:

evals

Out[22]:

array([ 1.00+0.j  ,  0.83+0.j  ,  0.96+0.j  ,  0.40+0.65j,  0.40-0.65j,
        0.76+0.j  ,  0.60+0.32j,  0.60-0.32j,  0.24+0.59j,  0.24-0.59j])

Chutes and Ladders中只有一个静止状态(特征值等于1);让我们看看它是什么。

在[23]中。

evecs

Out[23]:

array([ 0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,  0.+0.j,
        0.+0.j,  0.+0.j,  1.+0.j])

很好,这个单一的静止状态是一个已完成的游戏。这意味着你很难在一个无限循环、无休止的游戏中找到自己。我怀疑游戏设计者在设计游戏时是否做过这种静止状态的计算,但我为他们在这方面的偶然成功而祝福。

基本的矩阵¶

正如我们所设定的过渡矩阵,一旦你到达状态100,你就会以100%的概率留在那里。用数学术语来说,游戏的终点是一个 "吸收状态",因为一旦你在那里,你就不能过渡到任何其他状态。这意味着 "滑梯和梯子 "是一个吸收性马尔可夫链,我们可以用过渡矩阵做一些更有趣的事情。

这一切都要从计算吸收马尔可夫链的基本矩阵 N开始,这是马尔可夫过渡矩阵的一个简单变换。

在[24]中。

M

(请注意,我们使用的是跳跃发生在转弯处的过程版本,这样我们可以更容易推理出梯子和滑道的影响)。

基本矩阵的*(n,m)项告诉我们,当我们从状态m开始前往吸收状态时,我们将访问状态n*的预期次数。

让我们绘制一下从状态0开始时的这些预期值,即游戏的开始

在[25]。

expected_visits

这个轮廓相当有趣。最突出的特点是,平均每局你要访问99号方格大约1.5次!这是因为一旦你访问了99号方格,你就会发现它是一个非常重要的地方。这是由于一旦你落在99号方格上,你就必须掷出1来退出它;每多一回合你没有掷出1,就被算作另一次访问。

让我们来看看其他几个访问量最大的国家。

在[26]。

col

出[26]。

array([99,  0, 47, 97, 46])

下一个被访问最多的状态是0,你在每局游戏开始时正好访问一次。之后是47号方格,平均每局会访问3/4次。有趣的是,这个广场是一个滑道的顶部,因此是游戏中所有滑道中使用最多的。

我们还可以看看棋盘上被访问次数最少的方块。

In[27]:

np

Out[27]。

array([67,  1,  2,  3, 68])

令人惊讶的是,最不可能被访问的方格是67,而且被访问的次数甚至比1号方格还少,如果你在第一次掷出1的话,每局只有一次击中1号方格的可能!从上图可以看出,由于滑道和梯子在游戏盘中的位置,55-75左右的方格被访问的次数相对较少。

大滑道和大梯子

我女儿可能想知道的是:在一局棋中,你有多长时间会碰到 "大滑道 "或 "大梯子"?这两个地方分别在87号和28号方格。

进[28]。

col

出[28]。

0.52512870634459763

进[29]。

col

出[29]。

0.57577654067476058

我们看到,一个棋手平均每两盘棋就会滑下大槽一次,而爬上大梯子的频率略高:平均每十盘棋有不到六次。

吸收时间

基本矩阵还可以用来计算其他数量;例如,与一列1的矩阵乘积可以得到预期的 "吸收时间";也就是说,从任何给定的方格到完成游戏的预期回合数。让我们再次使用矩阵的 "跳到底 "版本。

In[30]:

np

出[30]。

array([ 39.7,  35.4,  40.2,  39.7,  37.6,  39.8,  39.6,  39.3,  39.1,
        36.7,  39.2,  38.7,  38.3,  37.9,  37.6,  37.1,  39.6,  35.9,
        35.8,  35.7,  35.6,  34. ,  34. ,  34.5,  34.8,  35.1,  35.3,
        35.4,  22.9,  37.4,  36.9,  36.7,  36.5,  36.3,  36. ,  35.8,
        33.9,  35.7,  35.4,  34.9,  34.5,  34.6,  34. ,  34.7,  33.9,
        32. ,  31.7,  35.3,  30.5,  38.7,  28.9,  20.9,  29.9,  29.5,
        29.1,  28.6,  29.5,  28.6,  28.3,  27.2,  26.3,  25.5,  35.7,
        22.6,  26.3,  20.8,  20.8,  20.9,  20.5,  20.3,  20.3,  16.2,
        21. ,  21. ,  18.1,  19.2,  20. ,  20.6,  20.9,  21. ,   1. ,
        25.9,  24.8,  23.8,  22.9,  21.9,  21.2,  34.8,  17.9,  18.1,
        17.1,  16.2,  16.9,  21. ,  12.3,  19.2,  11. ,  11. ,  20.9,   6. ])

从第一个位置开始,完成游戏的预期时间是39.7步:这比我们之前计算的39.2步多了半步,因为我们使用的是不同形式的矩阵(这里我们把从80位爬到100位的梯子算作额外的一步)。

这个向量可以告诉我们什么是最好的第一轮滚动:正如我女儿的直觉,从滚动1开始并爬上第一个阶梯是最好的:它将你的预期完成时间减少了5步。最糟糕的第一次滚动是2:这实际上使你的预期完成时间平均增加了0.5个回合:滚动2开始就相当于远离目标。

当你走到第99个位置时,你的预期完成时间是6步,因为你有1/6的机会掷出获胜所需的1。再往后两格,即第97格,你的预期完成时间就上升到了11步,因为第98格上有一个滑道。

基本矩阵可以让你定量地探索对局的其他一些属性;例如,我们可以调整矩阵,使第80个方格也成为吸收方,并询问我们在那里完成对局与直接落在第100个方格的可能性有多大。或者我们可以计算一些数量,如上面的访问次数和步骤数的方差。关于吸收马尔可夫链的基本矩阵的更多应用讨论,请参见维基百科的文章。在Grimstead和Snell的 "概率论导论 "第11章中可以找到关于这些概念的很好的本科生水平的介绍(这里有pdf)。

棋盘的可视化

为了好玩,让我们用Python的工具以一种直观的方式将这些概率状态可视化。我们将使用上面的原始滑梯游戏的扫描图作为我们的背景,并使用一个自定义的具有变化透明度的彩色地图在上面绘制概率分布图

在[31]中。

from

利用这一点,我们可以直观地看到任何数量的回合之后的概率分布。

在[32]中。

show_board

在一个回合之后,分布是相当紧密的:我们可以发现自己有六种可能的状态。

在[33]中。

show_board

在两个回合之后,事情变得更加分散,尽管它们仍然聚集在几个更有可能的值上。

在[34]中。

show_board

3个回合后,分布变得更加分散了。

在[35]中。

show_board

50个回合后,许多游戏将被结束,但仍有不可忽视的概率在整个棋盘的空间中发现自己。

模拟的动画化♪

如果我们将结果制作成动画,这些棋盘的视觉效果会更直观一些;在这里,我将使用优秀的ImageIO包将单个matplotlib图拼接成一个GIF动画来实现。(注意,你也可以直接在matplotlib中做这种动画,但是matplotlib的GIF编写器需要一些额外的命令行工具,而ImageIO不需要)。

让我们从一个函数开始,这个函数将使用ImageIO从matplotlib图形的生成器中建立一个GIF。

在[36]。

import

现在我们可以创建一些模拟并制作一个GIF。我将对GIF的帧长进行一些调整,以强调更有趣的早期帧。

在[37]中。

frames

最终的GIF看起来像这样。

[img: Chutes and Ladders animated simulation]

结论¶

我希望你喜欢这个关于滑梯和梯子的探索,即使你不喜欢,我相信你也会因为我做了这个事实而感到安慰。还有杰斯:如果你在多年后的某个时候看到这篇文章,我希望你有一天能像我和你一样,体验到和你的孩子一起玩滑梯的快乐。

谢谢你的阅读!

这篇文章完全是用Jupyter笔记本写的。你可以下载这个笔记本,或者在nbviewer上看到一个静态视图。