Alpha-beta剪枝算法设计一个中国象棋博弈程序

Posted by LvKouKou on April 19, 2023

Alpha-beta剪枝算法设计一个中国象棋博弈程序

一、 实验题目

博弈树搜索,用Alpha-beta剪枝算法设计一个中国象棋博弈程序

二、 实验内容

1.算法原理

本次项目的UI是参考了网上的代码。重点分析alpha-beta剪枝算法,关于UI部分就不详细分析了。本次实验还加入了历史启发式函数增加搜索效率。


着法生成

判断着法的合法性可分为以下几个步骤。 (1) 判断棋子的移动是否在棋盘的范围之内,如果超出,一定不是合法的着法。 (2) 根据行棋方的不同,判断着法是否是吃到己方棋子的着法,如果是,那么一定是不合法的着法。 (3) 根据兵种的不同,判断棋子的走法是否符合中国象棋的规则,主要有以下几个方面: 将、士是否走出九宫。 象是否过河,象眼是否被堵。 马是否蹩脚。 炮要翻山吃子。 兵过河前只能前进,过河后可以前进和左右移动。 本文设计的着法生成主要采用棋盘扫描法:

1
def get_put_down_postion(self, cur_chess):

局面评估

首先我们来看棋局的评估,能否对棋局有一个好的评估是这个算法很关键的一环。我们需要对棋局做出合适的评估,以确定最好的走步。每次估值都需要分开两方的棋子来进行估值。即算出程序方棋局的总体价值和对手方棋局的整体价值。==用程序方估值-对手方估值作为这个状态下的估值。如果这个估值大于0,说明程序方占优势;反之,说明对手方占优势。==

本程序的棋局评估考虑一下两方面:

  1. 棋子价值
  2. 棋子位置分值

棋子价值是依据不同棋子具有的不同的攻击力和防御能力,赋予其固定的分值。

棋子 帅/将 相/象 兵/卒
分值 6000 600 300 270 120 120 30

棋子在棋局的中真正价值,不仅与固定分值有关,其所在的位置也十分重要。

例如,沉底炮、过河卒、以及车占士角等都是较好的棋子位置状态,而窝心马、将(帅)离开底线等则属较差的棋子位置状态。由于各类棋子都有自己特有的行棋方法,因此配备一张位置分值表,表中给出了不同棋子在不同位置上的分值。

(具体位置分值表可见code文件夹中Value.py文件)


alpha-beta剪枝

完成好估值后,就可以开始alpha-beta的剪枝算法了。首先确定博弈树的深度,通俗来说就是要让程序往后推演几步。当然推演的步数越多,越能找到一个好的走步,但是所需的时间也就越多。然后我们需要使用一个标记来表示当前是极大层还是在极小层,根据标记来计算当前节点的α或β。如果在极大层,我们需要获得它下面所有极小层的倒推值的极大值(实际上不是所有);如果在极小层,就需要获得它下面所有极大层的倒推层的极小值(实际上不是所有)。这里就牵涉到了剪枝。以在极大层为例,如果当前MAX节点提供的倒推值α大于其前驱极小层MIN节点的β,那么说明这个MAX节点以下搜索提供的值不可能小于α,也就没有继续搜索的意义了,所以就可以直接结束这个MAX节点的搜索,这就是剪枝。

假设α为下界,β为上界,对于α ≤ N ≤ β:

若 α ≤ β 则N有解。(该节点还有继续计算的价值)

若 α > β 则N无解。(停止该节点剩余内容的计算,也不会再更新α、β值)

α-β初始为负无穷到正无穷,算法过程中不断的缩小α-β范围。Max层增加本节点的α,Min层减小本节点的β。

一个节点的初始α、β值来自于父节点。父节点如果还没有相应值,子节点的相应值则是初始的正负无穷。

对于AI节点(Max层),希望挑选子节点中的最大节点。所以会更新最小值α(因为每得到一个符合条件的更大的值时,以后的新值必须更大才会被选择)。新的最小值α来自于已经计算的子节点(中的最大值)。但最大不能大于于父节点的β。

对于玩家方节点(Min层),希望挑选子节点中的最小节点。所以会更新最大值β。新的最大值β来自于已经计算的子节点(中的最小值)。但最小不能小于父节点的α。

何时剪枝的文字描述:

  1. 后辈极小节点的值≤祖先极大节点的值时,发生剪枝,称为α剪枝。

  2. 后辈极大节点的值≥祖先极小节点的值时,发生剪枝,称为β剪枝。

这里发生剪枝的条件都是后辈跟祖先比较,不只是跟父节点比,只要有一个祖先满足剪枝条件就发生剪枝。

具体来说,剪枝规则如下:

  1. α 剪枝。若任一极小层节点的β值不大于它任一前驱极大层节点的α 值,即α (前驱层)≥β(后继层),则可以中止该极小层中这个MIN节点以下的搜索过程。这个MIN节点最终的倒推值就确定为这个β值。
  2. β剪枝。若任一极大层节点的α 值不小于它任一前驱极小层节点的β值,即α (后继层)≥β(前驱层),则可以中止该极大层中这个MAX节点以下的搜索过程。这个MAX节点最终的倒推值就确定为这个α 值。

具体到代码,则为:

1
2
if alpha >= beta:
    break

2.伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
输入:搜索深度 depth alpha beta
输出:剪枝时的极大值 alpha 或极小值 beta
alpha_beta(depth, alpha, beta)
BEGIN
(1) if GameOver or depth0 Then /*GameOver 判断棋局是否结束, dept≤0 则达到设置的搜索深度*/
(2) { Return(Evaluation) } /*Evaluation 是棋局局面的估值*/
(3) 初始化极大值 alpha= -INFINITY-1 和极小值 beta= INFINITY+1; /* INFINITY 代表无穷大*/
(4) if 极大值结点 Then
(5) 	{生成当前局面的所有可行着法集 M
(6) 	for M 中的每一个着法 do
(7) 	{执行着法 Mi
(8) 	value= alpha_beta(depth-1,alpha,beta); /*递归搜索子节点 */
(9) 	撤销着法 Mi;
(10) 	if value >alpha Then
(11) 		{ alpha= value; /*取极大值*/
(12) 		if alpha >= beta Then
(13) 			return beta;} /*beta 剪枝,抛弃后继节点*/
(14) 	End for}
(15) 	return alpha } /*返回极大值*/
(16) if 极小值结点 Then
(17) 	{生成当前局面的所有可行着法集 M
(18) 	For M 中的每一个着法 do
(19) 	{执行着法 Mi
(20) 	value= AlphaBeta_Search (depth-1,alpha,beta);
(21) 	撤销着法 Mi;
(22) 	if value < beta Then
(23) 		{ beta= value; /*取极小值*/
(24) 		if beta <= alpha Then
(25) 			return alpha;} /*alpha 剪枝,抛弃后继节点*/
(26) 	EndFor}
(27)	 return beta;}
END

在搜索进行中,每个搜索节点的值都在与 alpha 和 beta 进行比较,如果某个着法的结果小于或等于 alpha,那么它就是很差的着法,应该将其抛弃。如果某个着法的结果大于或者等于 beta,那么整个节点废弃,因为对手不会希望走到这个局面,它会采用别的着法来避免达到这个局面。当某个着法的结果大于 alpha 并且小于 beta时,这个着法就是走棋一方可以考虑的着法。

3.关键代码展示(带注释)

完全按照伪代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def alpha_beta(self, depth, alpha, beta): # alpha-beta剪枝,alpha是大可能下界,beta是最小可能上界
        #偶数层AI持棋,取最大,奇数层玩家持棋,取最小
        current_player = (self.max_depth - depth) % 2  # 当前轮到哪个玩家持棋,0代表黑方,1代表红方
        if current_player == 1:
            current_team = 'r'
        else:
            current_team = 'b' 
        if self.board.judge_win(current_team):
            return -1000000   
        if depth == 1:
            return self.evaluate()
        possible_moves = self.board.generate_move(current_player) # 返回所有能走的方法
        
        if current_player == 0:
            for move in possible_moves:
                if move:
                    temp = self.move_to(move)
                score = self.alpha_beta(depth - 1, alpha, beta) 
                if move:
                    self.undo_move(move, temp)
                if score > alpha:
                    alpha = score
                    if alpha >= beta:
                        return beta
                    if depth == self.max_depth:
                        self.best_move = move
            return alpha
        else:
            for move in possible_moves:
                if move:
                    temp = self.move_to(move)
                score = self.alpha_beta(depth - 1, alpha, beta) 
                if move:
                    self.undo_move(move, temp)
                if score < beta:
                    beta = score
                    if beta<=alpha:
                        return alpha
                    if depth == self.max_depth:
                        self.best_move = move
            return beta

但是可以发现其实有可以合并的地方,可以修改为以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def alpha_beta(self, depth, alpha, beta): # alpha-beta剪枝,alpha是大可能下界,beta是最小可能上界
    #偶数层AI持棋,取最大,奇数层玩家持棋,取最小
    current_player = (self.max_depth - depth) % 2  # 当前轮到哪个玩家持棋,0代表黑方,1代表红方
    if current_player == 1:
        current_team = 'r'
    else:
        current_team = 'b' 
    if self.board.judge_win(current_team):
        return -1000000   
    if depth == 1:
        return self.evaluate()
    possible_moves = self.board.generate_move(current_player) # 返回所有能走的方法
    for move in possible_moves:
        if move:
            temp = self.move_to(move)
        score = self.alpha_beta(depth - 1, alpha, beta) 
        if move:
            self.undo_move(move, temp)
        if current_player == 0:
            if score > alpha:
                alpha = score
                if depth == self.max_depth:
                    self.best_move = move
            alpha=max(alpha,score)
        else:
            if score < beta:
                beta = score
                if depth == self.max_depth:
                    self.best_move = move
            beta=min(beta,score)
        if alpha >= beta:
            break
    return alpha if current_player == 0 else beta

4.创新点&优化(如果有)

启用历史启发式函数加速AI下棋速度。

Alpha-Beta搜索算法的效率在很大程度上取决于搜索树的结构。如果搜索了没多久就能够进行“裁剪”,那么需要分析的工作量将大大减少,效率自然也就大大提高。为了保证Alpha-Beta搜索算法的效率,我们需要调整搜索结点的顺序,使得“裁剪”可以尽可能早地发生。为了调整搜索结点的顺序,我们可以根据部分已经搜索过的结果来进行历史启发式。在搜索的过程中,每当发现一个好的走法,就给该走法累加一个增量以记录其“历史得分”,多次被搜索并认为是好的走法的“历史得分”就会较高。对于即将搜索的结点,按照“历史得分”的高低对它们进行排序,保证较好的走法排在前面。这样Alpha-Beta搜索就可以尽可能早地进行“裁剪”,从而保证了搜索的效率。

历史启发式函数的作用是通过排序所有可能的走法,使得搜索时优先考虑历史得分高的走法,从而减少搜索树的分支,提高搜索效率。因为在实际游戏中,很多棋局中有一些重要的走法,而这些走法通常会出现在历史得分高的走法中,所以通过历史启发式函数,我们可以更加有效地搜索到更优的棋局。

理伦上,与电脑下棋的次数越多,那么它计算的速度就会更快,因为会将历史得分保存到本地的文件中,在每次棋局开始前先载入历史表,每次棋局结束后保存该局的历史表,那么多次训练后就可以增加搜索深度。


History_Heuristic.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import pickle

# 存放历史棋盘
history_chessboard = []

# 将历史启发表保存到文件中
def save_history_table():
    with open('history_table.pkl', 'wb') as f:
        pickle.dump(history_table, f)
    # print_history_table()

# 从文件中读取历史启发表
def load_history_table():
    with open('history_table.pkl', 'rb') as f:
        return pickle.load(f)

# 保存历史启发式的值
history_table = [[0 for _ in range(9)] for _ in range(10)]

# 根据历史启发式的值,对走法进行排序
def sort_moves(moves):
    moves.sort(key=lambda x: history_table[x[0]][x[1]], reverse=True)
    return moves

# 打印历史表
def print_history_table():
    for i in range(10):
        for j in range(9):
            print(history_table[i][j], end=' ')
        print()

加入历史启发式函数后的alpha_beta函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#加入历史启发式函数
def alpha_beta(self, depth, alpha, beta): # alpha-beta剪枝,alpha是大可能下界,beta是最小可能上界
    #偶数层AI持棋,取最大,奇数层玩家持棋,取最小
    current_player = (self.max_depth - depth) % 2  # 当前轮到哪个玩家持棋,0代表黑方,1代表红方
    if current_player == 1:
        current_team = 'r'
    else:
        current_team = 'b' 
    if self.board.judge_win(current_team):
        return -1000000   
    if depth == 1:
        return self.evaluate()
    possible_moves = self.board.generate_move(current_player) # 返回所有能走的方法
    # 根据历史启发式的值,对走法进行排序
    sorted_moves = hh.sort_moves(possible_moves)
    for move in sorted_moves:
        if move:
            temp = self.move_to(move)
        score = self.alpha_beta(depth - 1, alpha, beta) 
        if move:
            self.undo_move(move, temp)

        if current_player == 0:
            if score > alpha:
                alpha = score
                if depth == self.max_depth:
                    self.best_move = move
                hh.history_table[move[0]][move[1]] += depth ** 2
            alpha=max(alpha,score)
        else:
            if score < beta:
                beta = score
                if depth == self.max_depth:
                    self.best_move = move
                hh.history_table[move[0]][move[1]] -= depth ** 2
            beta=min(beta,score)
        if alpha >= beta:
            break
    return alpha if current_player == 0 else beta    

同时还加入了悔棋、重新开始、认输的功能。

三、 实验结果及分析

1.实验结果展示示例

image-20230418152121053

image-20230419161108473

为了使得象棋更具个人风格,我将红方的“帅”修改为自己的微信头像。为了方便观察电脑走步,移动后的棋子上会有红色方框。

2.评测指标展示及分析

经过对弈,本象棋基本达到大多数人类水平,有几率可以通过天天象棋的人机对战的小白难度(人类对小白难度通过率为54.14%)

四、 参考资料

棋子位置价值参考

棋子价值参考

UI设计参考