投注世界大赛是一个古老、有趣且充满挑战的问题。这也是一个很好的问题,用来展示一种称为“爬山法”的优化技术,我将在本文中介绍这种技术。
爬山法是一种成熟且相对简单的优化方法。网上有很多用这种方法的例子,但我认为这个问题非常值得一试,用这种方法尝试解决,也非常有趣。
这个谜题可以在UC Davis托管的页面上看到。为了方便大家,我在这里再重复一遍:
[E. Berlekamp] 关于世界大赛系列的赌注。你是一名经纪人;你的工作是满足客户的要求,而不冒用自己的个人资金的风险。你的客户希望在世界大赛的结果上押注1000美元,这是一个棒球比赛,由首先赢得四场比赛的队伍获胜。客户在系列赛开始前将1000美元存放在你这里。系列赛结束后,如果他支持的队伍获胜,你必须给他2000美元;如果他的队伍输了,他什么也拿不到。目前市场上没有对整个世界大赛系列的赌注。然而,你可以对每场比赛单独押注,押注金额可以是任意的。你计划如何在个别比赛中下注,以实现客户所需的结果?
所以,我们需要每次只下注一场比赛(虽然也可以选择不为某些比赛下注,即为那些比赛下注$0)。每场比赛结束后,我们会赢或输掉所下的金额。我们从客户那里拿到的$1000开始。如果我们队伍赢得了系列赛,我们希望最终剩下$2000;如果我们队伍输了,我们希望最终剩下$0。
如果你之前没遇到过这个问题,并想尝试手动解决它,现在是你的机会,在我们开始介绍如何通过编程解决这个问题之前。这个问题本身很有趣,值得一试,自己直接解决它会很有意义,在尝试爬山法解决之前,先尝试直接解决它。
面对问题对于这个问题,我假设暂时出现负数是可以接受的。也就是说,在世界大赛期间,如果我们曾经低于零美元,这是可以接受的(我们是一家大型经纪公司,可以承受这种情况),只要我们最终可靠地结束时拥有$0或$2000。最后我们会把$0或$2000还给客户。
想出大多数时候有效的解决方案相对简单,但不一定适用于所有情况。实际上,我在网上看到过几篇关于这个谜题的文章,它们提供了一些解决方案的草图,但似乎并没有对所有的胜负组合都进行了彻底测试。
例如,一个投注策略可以是:投注分别为125美元,250美元,500美元,125美元,250美元,500美元,1000美元。按照这个策略,我们第一场投注125美元,第二场投注250美元,依此类推,按实际进行的比赛场数投注。如果系列赛持续五场比赛,比如,我们投注为:125美元,250美元,500美元,125美元,250美元。实际上,这种策略大多数时候都会起作用,不过不是在所有情况下都适用。
如下序列:1111,其中 0 表示 Team 0 赢了一局,1 表示 Team 1 赢了一局。在这个序列中,Team 1 赢了所有四局,因此赢得了整个系列赛的胜利。假设我们是 Team 1,所以我们需要确保最后得到 $2000。
查看每场比赛前的赌注和赛后赢得的美元,我们有,
轮次 赌注 结果 持有金额(元)
---- ---- ---- ----------
开局 - - 1000
1 125 1 1125
2 250 1 1375
3 500 1 1875
4 125 1 2000
也就是说,我们从$1000开始。我们第一个下注$125。队伍1赢得这场比赛,我们赢了$125,现在有$1125。然后我们下注$250在第二场比赛。队伍1再次赢得比赛,我们赢了$250,现在有$1375。接下来的比赛,我们分别下注$500和$125。最终,我们正确地结束时有$2000。
测试序列 0000(团队 0 在四场比赛中获胜):
轮 注 输赢 余额
---- --- ---- ----------
初始 - - 0
1 125 0 875
2 250 0 625
3 500 0 125
4 125 0 0
在这里,我们符合(如果Team 0赢得系列比赛)以$0收尾,
测试序列0101011(即队伍1在七场中获胜):
局 赌注 结果 余额
—— —— —— ————
起始 — — 1000
1 125 输 875
2 250 赢 1125
3 500 输 625
4 125 赢 750
5 250 输 500
6 500 赢 1000
7 1000 赢 2000
在这里我们又一次正确地结束在2000美元。
不过,对于序列号1001101,这个政策不管用:
轮次 赌注 结果 持有金额
---- ---- ---- ----------
初始 - - 1000
1 125 1 1125
2 250 0 875
3 500 0 375
4 125 1 500
5 250 1 750
6 500 0 250
7 1000 1 1250
虽然队伍1赢得了系列比赛(在7场比赛中赢了4场),我们只拿到1250美元,没有2000美元。
测试策略由于可能的游戏序列有很多,手动测试非常困难且费时(当你测试许多可能的策略时会非常枯燥),因此,我们将接下来开发一个函数来测试给定策略的有效性:即在所有可能的系列中,当队伍1赢得系列时,策略至少能获得2000美元;当队伍0赢得系列时,策略至少能获得0美元。
这个策略用一个由七个数字组成的数组表示,每个数字代表在一场比赛中下注的金额。如果有四场、五场或六场比赛,策略中的最后几个数字将不会被用到。这个策略的具体数值可以表示为 [125, 250, 500, 125, 250, 500, 1000]。
def 评估策略函数(policy, verbose=False):
"""评估策略函数,用于评估给定策略的总违规金额。"""
if verbose: print(policy)
total_violations = 0
for i in range(2**7):
s = str(bin(i))[2:]
s = '0'*(7-len(s)) + s # 确保字符串长度为7
if verbose:
print(s)
money = 1000
number_won = 0
number_lost = 0
winner = None
for j in range(7):
current_bet = policy[j]
# 更新资金
if s[j] == '0':
number_lost += 1
money -= current_bet
else:
number_won += 1
money += current_bet
if verbose: print(f"胜者: {s[j]}, 赌注: {current_bet}, 当前资金: {money}")
# 任一队伍赢得4场则结束
if number_won == 4:
winner = 1
break
if number_lost == 4:
winner = 0
break
if verbose: print("胜者:", winner)
if (winner == 0) and (money < 0):
total_violations += -money
if (winner == 1) and (money < 2000):
total_violations += 2000 - money
return total_violations
这从生成每个可能的胜败序列的字符串表示开始。这会产生一系列 2⁷ (128) 个字符串,从 ‘0000000’ 开始,然后是 ‘0000001’,依此类推,直到 ‘1111111’。其中一些序列是冗余的,因为某些系列赛会在所有七场比赛之前提前结束——一旦一支队伍赢得四场比赛。在实际应用中,我们可能会对其进行优化以减少执行时间,但为了简单起见,我们只是遍历所有 128 种组合。这确实带来了一些好处,因为它平等对待所有这些同样可能的组合。
对于每一种可能的序列,我们应用政策来确定每场比赛的赌注,并保持资金累积。也就是说,我们遍历所有2的7次方种可能的胜负序列(一旦有一队赢得四场比赛就终止遍历),对于每一种序列,我们按顺序遍历序列中的每一场比赛,每次投注一场比赛。
最后,如果Team 0赢得了这个系列赛,我们最好是得到$0美元;而如果Team 1赢得了这个系列赛,我们最好是得到$2000美元,但无论我们得到更多都不会受到惩罚(或获得额外好处)。
如果我们结束一连串游戏时金额不对,我们就计算我们少了多少美元;这就是那一连串游戏的成本价。我们将所有序列中的短缺金额相加,这就能看出这个策略整体上表现得怎么样。
要确定任何给定的政策是否有效,我们只需用该政策(以数组形式)调用该方法,并查看返回值是否为 0。如果返回值大于 0,则表示有一段或多段序列中的经纪商结束时资金不够。
爬山
我不会深入讲解爬山算法,因为它已经被广泛理解和应用,并且在很多地方都有详细的说明,但我会非常简要地介绍一下它的基本思路。爬山算法是一种优化技术。我们通常从生成问题的一个初始解开始,然后逐步修改这个解,每一步都朝着更好的解前进,直到找到最优解(或者陷入局部最优解)。
为了解决这个问题,我们可以从任何可能的策略开始。例如,我们可以从这样的策略开始:[-1000, -1000, -1000, -1000, -1000, -1000, -1000]。这个特定策略肯定表现不佳——我们实际上会连续七场都强烈押注在Team 1的对手身上。但没关系。爬山法是通过任意一个起点开始,然后逐步改善的,所以即使从一个较差的策略开始,我们最终也可以找到一个好的解。虽然在某些情况下,我们可能达不到理想结果,有时需要(或至少是有用的)从不同的起点重新运行算法。在这种情况下,从一个非常差的初始策略开始也是没问题的。
在编码前手动解决这个谜题,我们可能会得出结论,策略需要稍微复杂一些。这种策略完全根据游戏种类来决定每次下注的大小,而不管之前的胜负情况。实际上,我们需要用一个二维数组来表示策略,比如:
[[-1000, -1000, -1000, -1000, -1000, -1000, -1000],
[-1000, -1000, -1000, -1000, -1000, -1000, -1000],
[-1000, -1000, -1000, -1000, -1000, -1000, -1000],
[-1000, -1000, -1000, -1000, -1000, -1000, -1000]]
还有其他方法,但如下面将要展示的,这种方法效果不错。
在这里,行表示队伍1目前赢得的比赛场数:0、1、2或3场。同样地,列表示当前的比赛编号:1到7。
根据展示的策略,我们会每场比赛都押注1000美元对抗队伍1,无论结果如何,所以几乎任何随机选择的策略都有可能比这至少稍微好一些。
此策略包含4x7,即28个值。尽管其中一些是不必要的,但仍可以进行一定程度的优化。在这里,我选择了简单而不是效率,但在生产环境中,我们通常会进一步优化它。在这种情况下,我们可以移除一些不可能的情况,例如,在第5、6或7场比赛结束时,团队1还没有获胜(这意味着在第5场比赛后,团队0已获得4胜,比赛因此结束)。在这28个单元格中,有12个实际上无法达到,剩下的16个是相关的。
为了简单起见,这里没用到,但实际上相关的字段有如下所示,我在这些字段中填入了-1000:
[[-1000, -1000, -1000, -1000, 不适用, 不适用, 不适用],
[不适用, -1000, -1000, -1000, -1000, 不适用, 不适用],
[不适用, 不适用, -1000, -1000, -1000, -1000, 不适用],
[不适用, 不适用, 不适用, -1000, -1000, -1000, -1000]]
标记为‘n/a’的单元格表示这些数据不相关。例如,在第一场比赛时,不可能赢得1、2或3场比赛;此时只能没有赢得任何比赛。另一方面,在第4场比赛时,可能已经有0、1、2或3场胜利。
这也通过手动操作可以观察到,每个赌注很可能是$1000的二分之一、四分之一、八分之一、十六分之一等的倍数。虽然这未必是最优方案,但我将假设所有的赌注都是$500、$250、$125、$62.5、$31.25的倍数,也可能是$0。
我将假设不会有任何情况可以对Team 1下注;尽管初始策略开始时有负数下注,但是生成新策略候选的过程仅使用$0到$1000(包括$0和$1000)的下注。
那么,每次投注可以有33种可能的数值(从0到1000元,每次投注的数值为31.25元的倍数)。在包含28个单元格的情况下,并假设投注是31.25元的倍数,这样的投注方案可能有33的28次方种组合。因此,测试所有这些组合是不可能的。即使只考虑16个实际使用的单元格,仍然有33的16次方种可能的组合。可能还有进一步的优化空间,但无论如何,要逐一检查所有组合将是一个极其庞大的工作量,远远超出实际操作的范围,几乎是不可能的。也就是说,虽然从编程的角度可以直接解决这个问题,但仅凭这里的假设,采用暴力破解的方法是不可行的。
因此,像爬山法这样的优化技术在这里非常合适。从一个随机位置开始,这个位置位于解决方案空间中(即以4x7矩阵形式表示的随机策略),不断向更好的解决方案移动,就像在山上向上爬一样(每一步都朝着比上一步更好的解决方案前进),最终我们将找到解决世界系列赛赌博问题的一个可行策略。
评估方法的更新
因为这些政策将以二维矩阵的形式表示,而不是一维数组,确定当前赌注的代码将变为如下所示:
当前的赌注 = 策略表[j]
:
current_bet = policy[number_won][j]
也就是说,我们根据迄今为止赢得的比赛数量以及当前比赛的编号来确定当前的赌注金额。否则,evaluate_policy() 方法的定义和用法与之前描述的一致。上面用于评估策略的代码实际上构成了大部分代码。
寻找解决方案的代码我们接下来展示主代码,该代码从一个随机策略出发,然后循环(最多1万次),每次调整并试图改进此策略。每次迭代中,它生成10个当前最佳方案的随机变体,从中选出最优的作为新的当前方案;如果没有找到更好的方案,则继续使用现有的方案,直到找到更好的方案。
import numpy as np
import math
import copy
policy = [[-1000, -1000, -1000, -1000, -1000, -1000, -1000],
[-1000, -1000, -1000, -1000, -1000, -1000, -1000],
[-1000, -1000, -1000, -1000, -1000, -1000, -1000],
[-1000, -1000, -1000, -1000, -1000, -1000, -1000]]
best_policy = copy.deepcopy(policy)
best_policy_score = evaluate_policy(policy) # 评估策略
print("初始分数:", best_policy_score)
for i in range(10_000):
if i % 100 == 0: print(i)
# 每次迭代过程中,生成10个类似于当前最佳解决方案的候选策略,并从这些候选策略中选择最佳的(如果有比当前更好的)。
for j in range(10):
policy_candidate = vary_policy(policy)
policy_score = evaluate_policy(policy_candidate) # 评估策略
if policy_score <= best_policy_score:
best_policy_score = policy_score
best_policy = policy_candidate
policy = copy.deepcopy(best_policy)
print(best_policy_score)
display(policy) # 显示策略
if best_policy_score == 0:
print(f"在第{i}次迭代后终止")
break
print()
print("最终")
print(best_policy_score)
display(policy) # 显示策略
运行这段程序,主循环在找到解决方案前执行了1,541次。每次迭代中,它会调用vary_policy()(如下所述)十次以生成当前策略的十个变种。然后调用evaluate_policy()来评估每个变种。如前所述,这将提供一个得分(以美元为单位),表示经纪人使用该策略在128个世界系列赛的平均情况下,能实现的最小损失(我们可以将这个得分除以128以得到单个世界系列赛的预期损失)。分数越低,表现越佳。
初始解决方案得分为153,656.25,表现非常糟糕,正如我们预期的那样。随后迅速改善,分数迅速降低到大约100,000,接着是70,000,50,000,等等。在代码运行过程中,不断打印出迄今为止找到的最佳策略,这些策略也变得越来越合理。
生成随机变化的方法
以下代码产生现有的策略的一个变种:
def 修改策略(策略):
新策略 = copy.deepcopy(策略)
变化次数 = np.random.randint(1, 10)
for _ in range(变化次数):
胜局编号 = np.random.choice(4)
游戏编号 = np.random.choice(7)
新值 = np.random.choice([x*31.25 for x in range(33)])
新策略[胜局编号][游戏编号] = 新值
return 新策略
在这里,我们首先选择4x7策略中要更改的单元格数量,范围是从1到10。可以修改较少的单元格,这在分数接近零时可以提高性能。也就是说,一旦我们有了一个强大的策略,我们可能希望减少对其的修改,因为在过程开始时,解决方案较弱且需要更多探索搜索空间。
然而,持续修改一小部分固定的单元格确实会导致陷入局部最优解(有时候,没有一种修改方式能恰好改善1或2个单元格,反而需要修改更多单元格才能看到改进),并且这种方法并不总是有效。而随机选择一些单元格进行修改可以避免这种情况。这里设置的最大修改数量为十只是出于演示目的,并未经过优化调整。
如果我们只关注这4x7矩阵中的16个相关单元格进行更改,那么这段代码仅需稍微修改,即跳过这些单元格的更新,并用一个特殊的符号(如np.NaN,相当于“不适用”)标记这些单元格,以便在显示矩阵时更加清晰明了。
结果部分最后,算法找到了以下策略。即,在第一局游戏中我们不可能赢,所以会下注$312.50。在第二局游戏中,无论哪种情况,我们都将下注$312.50。在第三局游戏中,我们可能没有、有1次或2次胜利,因此将分别下注$250、$375或$250,依此类推,最多下注到第七局。如果到了第七局,我们已经赢得了3次胜利,因此我们将在这局下注$1000。
[[312.5, 312.5, 250.0, 125.0, 718.75, 31.25, 281.25],
[375.0, 312.5, 375.0, 375.0, 250.0, 312.5, 343.75],
[437.5, 156.25, 250.0, 375.0, 500.0, 500.0, 781.25],
[750.0, 718.75, 343.75, 125.0, 250.0, 500.0, 1000.0]]
我也绘制了一个图表,展示了到目前为止找到的最佳策略的得分是如何随着1,541次迭代而减少(即越小越好)的。
每次迭代中最佳方案的得分,直到找到得分为零的解决方案。
一开始这有点看不清,因为初始分数相当高,所以我们再画一张图,也就是说从第16步开始。
在每次迭代中,从第16次开始,每个迭代中找到的最佳策略的得分,直到找到一个得分0的合适解决方案(即得分为0)。
我们可以看到分数最初迅速下降,即使过了最初15步,之后进入了一个改进缓慢的长时期,直到找到一个对现有策略的小改动,进而改善了情况,随后分数继续下降,直到最终达到完美的0分(即对于任何可能的胜负序列都没有差距)。
类似的问题我们在这里解决的问题是一个被称为“约束满足问题”的问题,我们只需要找到满足所有给定约束的解决方案(在这种情况下,我们将这些约束视为硬性约束——最终余额必须是$0或$2000之一)。
当我们有两个或多个完整的解决方案来解决问题时,并不会觉得其中一个比另一个更好;任何可行的方案都是好的,一旦我们找到一个可行的解决方案,就可以停下来。N皇后问题和数独游戏是这种类型问题的其他例子。
其他类型的问题可能具有最优性。例如,在旅行商问题中,任何恰好经过每个城市一次的解决方案都是有效的解决方案,但每个解决方案都有不同的得分,有些比其他的更好。在这种类型的问题中,我们永远无法确定何时找到了最佳解决方案,我们通常只是固定次数的尝试(或给定时间),或者直到找到至少具有一定质量的解决方案。爬山法也可以用于这些问题。
也可以定义一个这样的问题,其中需要找到的不是单一的,而是所有可行的解决方案。在“世界系列赛投注”问题的情况下,找到一个可行的解决方案非常简单,但找到所有解决方案会困难得多,甚至可能需要进行彻底的搜索(虽然经过优化,可以快速排除等效的方案,或者在某些策略有明确结果时提前终止计算)。
同样,我们也可以将投注世界系列赛的问题重新表述为只需一个好但不必完美的解决方案。例如,我们可以接受一种方案,比如大多数时候经纪人收支平衡,只有在少数情况下略为亏损。在这种情况下,可以使用爬山法,但也可以使用类似随机搜索和网格搜索的方法——经过一定数量的试验后选择最佳策略,这样在某些情况下可能已经足够了。
在比“世界系列赛赌博”问题更难的问题中,像我们在本例中使用的简单的爬山法可能不够充分。可能需要保留之前策略的记忆,或者采用一种叫做“模拟退火”的方法。在这种方法中,我们有时会采取次优的步骤,这一步的质量可能低于当前的解,以帮助避免陷入局部最优解。
对于更复杂的问题来说,使用贝叶斯优化、进化算法、粒子群优化或其他更高级的方法可能更好。我将在后续的文章中讨论这些方法,但这个问题相对简单,直接使用简单的爬山法已经表现得相当不错(不过如前所述,稍微优化一下就能表现得更好)。
总结一下这篇文章提供了一个简单的爬山法示例。问题相对简单,因此希望即使是之前没有接触过爬山法的人也能轻松理解,甚至对于已经熟悉该方法的人来说也是一个很好的示例。
有趣的是,尽管这个问题可以通过其他方式解决,但像这里使用的优化技术可能是最简单有效的。虽然通过其他方式解决可能相当棘手,但使用爬山法却相当简单。
所有图片均为作者拍摄
共同学习,写下你的评论
评论加载中...
作者其他优质文章