简介:面向人工智能初学者与深度学习/强化学习研究者,这份CS188实验二多智能体搜索压缩包直击多智能体在共享环境中协作与竞争的核心问题,适合课程作业、算法对照或面试复习。资源共228个文件,包含88个test与88个solution,构成完整的测试用例和参考答案;另有18个Python算法脚本、16个pyc编译文件及11个lay地图文件,覆盖经典、困难、开放等不同难度布局,压缩包仅236KB,轻量而结构清晰。目前已有470人学习下载。通过本地化测试用例可快速验证Minimax、Expectimax等经典决策算法的实现,并借助不同地图布局观察智能体应对对抗与随机环境的策略变化;搭配源码与答案便于逐行排查、理解多智能体搜索的完整流程,也为运行调试和输出结果对比提供了便利,特别适合自学与查漏补缺。
1. CS188 实验二 multiagent.zip 到底在解决什么问题
翻开 CS188 实验二 multiagent.zip 之前,我以为这又是一轮“吃豆人换皮搜索题”;真正跑起来才发现,多智能体搜索和单智能体的差别不是多套一层循环,而是要把“对手存在”这件事转化为递归里的一个层级。多数第一次做这份作业的人卡在同一个位置:Minimax 写对了但吃豆人照样送死,Alpha-Beta 剪枝跑得飞快但结果和未剪枝不一样,Expectimax 又总是在随机鬼魂下慢到怀疑人生。以下按最小复现路径、核心算法实现、评估函数设计和每个坑的修法,把这个 zip 从“能跑”带到“能赢”。适合第一次接触博弈树搜索、需要完整盘活这份实验代码的从业者和学生。
2. 跑通 multiagent.zip:目录结构、运行命令与最小实验闭环
2.1 先看清 zip 里有哪几类文件,别一头扎进 multiAgents.py
解压之后的文件不算多,但每个文件的地位差别很大。常见做法是先用ls看一眼目录,再决定哪些文件要读、哪些文件不能改。我按自己处理这类老实验包的顺序整理成了一张表:
| 文件 | 作用 | 你需不需要改 |
|---|---|---|
| game.py | 定义 GameState、Agent、Actions、Directions,是所有搜索算法的输入出口 | 不要改,但必须读懂 |
| multiAgents.py | 作业本体,ReflexAgent、MinimaxAgent、AlphaBetaAgent、ExpectimaxAgent 和评估函数都在这里 | 唯一要改的文件 |
| pacman.py | 命令行入口,负责加载地图、创建游戏、驱动 agent 交替行动 | 不用改 |
| graphicsDisplay.py | 可视化渲染,和算法正确性无关 | 不用改 |
| autograder.py | 自测脚本,按 q1-q4 逐题打分 | 不用改,用来跑分 |
| test_cases/ | 批量的测试用例 | 不用改,但可以阅读 |
我第一次做这个实验时犯过低级错误:直接打开 multiAgents.py 开始写,写到一半发现连 pacman.py 都跑不起来。原因是没看 game.py 里 GameState 到底暴露了哪些方法。后面会反复提到,GameState 是搜索算法的唯一输入,所有状态转移必须通过 generateSuccessor 生成,而不是自己去改吃豆人的全局位置。
跑通最小闭环的命令是这样的:
python pacman.py -l mediumClassic -p ReflexAgent -k 2 -n 3这个命令的意思是:加载 mediumClassic 这张地图,用 ReflexAgent 作为吃豆人的控制器,放 2 只鬼魂,连续跑 3 局。-p指定的是 agent 类名,-k是鬼魂数量,-n是游戏局数。如果一切正常,你会看到带可视化窗口的吃豆人游戏跑起来;如果只想看文本结果、不想要图形界面,可以加--no-graphics。
提示:如果你只跑几秒就退出,先看输出的最后一条 traceback。90% 的情况都是 multiAgents.py 里的某个类还没实现,或者类名和命令行里对不上。
2.2 用 autograder 先跑一遍,确认环境问题还是代码问题
不管代码写没写,建议先跑一次 autograder,把环境问题从算法问题里剥离出来。常见做法是:
python autograder.py -q q1 --no-graphicsq1 对应 ReflexAgent 的基础检查。如果你连这个题都跑不通,说明是环境问题:要么 Python 版本不对,要么某个公共文件被误改。如果 q1 能跑,说明你已经具备了跑全部四题的环境基础。
这段输出在正常情况下会分成两部分:一是每个测试用例的 pass/fail,二是最终得分。比较常见的是某个用例 fail 但 score 很高,这就说明你的代码大体逻辑对,只是某个边界细节没处理好。不要被总分迷惑,fail 的用例往往是后面隐藏测试会重点检查的路径。
原始实验包面向 Python 2,很多机器上只有 Python 3。这时候不要急着用 2to3 迁移整个仓库,更常见的做法是只迁移 multiAgents.py 和 autograder.py 里会碰到的语法点,比如把 print 语句改成函数式调用。我见过有人把 game.py 也迁了一遍,结果深浅拷贝逻辑变了,后续排查非常痛苦。如果你的工作流更偏现代化,直接用 Python 3 写 multiAgents.py,然后只用 autograder 的文本测试,也可以接受。
手动跑一局带深度参数的命令是:
python pacman.py -l testClassic -p MinimaxAgent -a depth=2 -k 1 -n 1注意这里-a depth=2是往 MinimaxAgent 的构造函数里传参数。实验包里 agent 的构造函数通常会接收 evalFn 和 depth 两个参数,命令行传参是测试的标准姿势。如果你自己加了一个类,构造函数参数名一定要和这里对齐,否则命令行解析会把参数当作未知选项。
2.3 吃透 GameState 的四个方法,算法才能写得干净
多智能体搜索里,你不关心吃豆人怎么渲染,也不关心按键怎么响应,真正要打交道的只有 GameState 上的几个方法。最影响实现的是这四个:
- getLegalActions(agentIndex):返回某个智能体的合法动作列表,注意吃豆人是 0,鬼魂从 1 开始编号。
- generateSuccessor(agentIndex, action):返回执行动作后的新状态,这是递归搜索里唯一的“向前看”方式。
- getScore():返回当前状态得分,通常直接作为评估函数的输入。
- getNumAgents():返回智能体总数,包含吃豆人,用来在递归里判断“该轮到谁了”。
我调试时习惯写这样一个函数,打印状态的局部信息:
def inspect_state(state): """打印当前状态中与算法相关的特征""" actions = state.getLegalActions(0) print("legal actions:", actions) next_state = state.generateSuccessor(0, actions[0]) print("score after first action:", next_state.getScore()) print("agent count:", state.getNumAgents()) print("is win:", state.isWin(), "is lose:", state.isLose())这个函数本身不参与算法,但它能帮你在递归里快速看出“状态有没有推进”。如果你用 generateSuccessor 生成新状态后,score 不变、actions 列表不变,那多半是传入了错误的 agentIndex,或者是用了同一个 state 对象而没有真正生成后继。
这里的关键点是:generateSuccessor 返回的是新 GameState,原 state 不会被修改,所以递归过程中不需要手动恢复现场。这是和许多自己维护搜索树的课程不同的地方,也意味着你不必担心递归分支之间互相污染状态。
3. 从 ReflexAgent 到 Minimax:让吃豆人第一次学会“怕”
3.1 ReflexAgent 先做一个“只顾眼前”的基线
实验的第二关要求你实现 ReflexAgent。它的定位不是博弈,而是从当前状态出发,对每个合法动作做一步模拟,选一个评估分数最高的动作。它不考虑鬼魂下一步怎么走,所以叫“反射”。
我用一个很短的实现来说明它的结构:
class ReflexAgent(Agent): def getAction(self, gameState): actions = gameState.getLegalActions(0) scored_actions = [] for action in actions: next_state = gameState.generateSuccessor(0, action) score = self.evaluationFunction(next_state) scored_actions.append((score, action)) return max(scored_actions)[1] def evaluationFunction(self, state): pos = state.getPacmanPosition() food_list = state.getFood().asList() if food_list: distance_to_food = min( abs(pos[0] - food[0]) + abs(pos[1] - food[1]) for food in food_list ) return state.getScore() - distance_to_food return state.getScore()这里evaluationFunction的直接输出就是“这个状态对我有多好”。我用了“当前分数减去最近豆子的曼哈顿距离”,含义是:距离最近豆子越近,分数越高。这个写法足够让吃豆人开始找豆子,但它完全不管鬼魂,所以你会看到吃豆人经常主动走到鬼魂脸上。
这个基线的价值在于:它是后续所有算法的评分入口。Minimax、Alpha-Beta、Expectimax 最终都是拿 evaluationFunction 的结果当叶子分数,所以先把这个函数写清楚,后面才有条件做对比实验。
注意:评估函数返回的是绝对分值,不需要归一化,但不同特征的量级不要差太多。如果你让鬼魂距离惩罚只有 0.1,最近豆子奖励却有 100,那吃豆人永远只会冲向豆子。
3.2 Minimax 的多智能体递归:极大层与极小层交替
Minimax 的核心是把吃豆人和鬼魂交替看成极大层和极小层:吃豆人要在自己的回合选择分数最大的动作,鬼魂要在自己的回合选择分数最小的动作。因为鬼魂的目标就是让吃豆人死,所以“按最坏情况决策”正是极小化极大算法的本意。
作业里没有让你写一个通用函数,而是要求你实现 MinimaxAgent 的 getAction。我给出一个贴近实验包结构的代码骨架:
class MinimaxAgent(Agent): def __init__(self, evalFn="scoreEvaluationFunction", depth="2"): # 实验包里一般有 lookup 用于从模块命名空间取函数 self.evaluationFunction = staticmethod(globals().get(evalFn, lambda s: s.getScore())) self.depth = int(depth) def getAction(self, gameState): best_score = -float("inf") best_action = "Stop" for action in gameState.getLegalActions(0): next_state = gameState.generateSuccessor(0, action) score = self.value(next_state, 1, 0) if score > best_score: best_score = score best_action = action return best_action def value(self, state, agent_idx, depth): if state.isWin() or state.isLose() or depth == self.depth: return self.evaluationFunction(state) if agent_idx == 0: return max( self.value(state.generateSuccessor(0, action), 1, depth) for action in state.getLegalActions(0) ) else: next_idx = (agent_idx + 1) % state.getNumAgents() next_depth = depth + 1 if next_idx == 0 else depth return min( self.value(state.generateSuccessor(agent_idx, action), next_idx, next_depth) for action in state.getLegalActions(agent_idx) )这个递归里的三个关键参数是agent_idx、depth和终止条件。agent_idx表示当前轮到谁决策,0 是吃豆人,1 到 N 是鬼魂;next_idx用取模保证吃豆人行动结束后又回到鬼魂,形成完整的“吃豆人-鬼魂-吃豆人-鬼魂”循环。depth的递增发生在轮回到吃豆人的瞬间,所以 depth 表示已经完整搜索了几个吃豆人回合。
终止条件写在value函数一开始,原因很简单:一旦游戏已经输或赢,再展开下去没有意义,必须立刻返回叶子分数。这里最容易写错的一点是:千万不要把depth == self.depth放到 min/max 分支之后判断,否则会多展开一层,导致结果与预期深度不一致,后续 Alpha-Beta 跟它对不上你又要排查半天。
3.3 Minimax 的四个必调参数:depth、agentIdx、isWin/isLose、Stop
把作业里最容易混淆的参数单独拉出来讲,因为后面 Alpha-Beta 和 Expectimax 还要用同样一套东西。
depth 表示搜索深度。实验默认是 2,也就是吃豆人想两步之后的局面。depth 越大,吃豆人越能看穿鬼魂的堵截,但时间会指数增长。很多同学一上来把 depth 改成 4,发现每一步都要卡几秒,这不是电脑不行,是状态空间本身就爆炸。小地图上 depth=2 就够,想挑战可以 depth=3,但不要盲目加大。
agent_idx 的编号顺序是严格的:吃豆人是 0,第一只鬼魂是 1,第二只是 2。generateSuccessor 的第一个参数也必须传对应的编号。如果你在鬼魂层错误传了 0,生成的还是吃豆人的状态,会让鬼魂看起来“不走了”,问题非常隐蔽。
isWin/isLose 是终止状态检查。有些实现只判断 isLose,却漏了 isWin,导致吃豆人吃完全部豆子后,算法还在往深处展开无意义的状态,既浪费时间又可能因为评估函数偏差而逃出必胜局面。务必两个都判断。
Stop 动作是实验包里埋的一个小坑。它在部分测试用例里是合法动作,会让吃豆人或鬼魂原地不动。如果你不处理,Minimax 的极小层可能觉得“停着最安全”,于是吃豆人站着不走。常见做法是在 getAction 里过滤掉 Stop,或者在评估函数里给 Stop 一个很低的分数。注意不同测试用例对 Stop 的处理不同,建议以 autograder 的结果为准,而不是自己想当然删掉。
这部分的代码没有太多“魔法”,前提是理解多智能体交替的本质。我建议你把递归调用过程用纸笔画一遍:吃豆人选择动作 → 生成状态 → 鬼魂选择动作 → 生成状态 → 再回吃豆人。把这一趟走通,Minimax 就完成了一半。
4. Alpha-Beta 剪枝与 Expectimax:同一棵树的两种改法
4.1 给 Minimax 加边界剪枝:alpha / beta 的更新时机
Alpha-Beta 剪枝不是新算法,而是在 Minimax 递归过程中维护两个边界:alpha 表示极大层的下限,beta 表示极小层的上限。一旦某个分支的返回值越界,就可以剪掉后面所有兄弟节点。它改写的工作量不大,但最容易写错的是“到底在哪个层更新 alpha、在哪个层更新 beta”。
我从 MinimaxAgent 改出一个骨架,核心变化是把 value 函数加上 alpha、beta 参数:
class AlphaBetaAgent(Agent): def __init__(self, evalFn="scoreEvaluationFunction", depth="2"): self.evaluationFunction = staticmethod(globals().get(evalFn, lambda s: s.getScore())) self.depth = int(depth) def getAction(self, gameState): best_score = -float("inf") best_action = "Stop" alpha = -float("inf") beta = float("inf") for action in gameState.getLegalActions(0): next_state = gameState.generateSuccessor(0, action) score = self.value(next_state, 1, 0, alpha, beta) if score > best_score: best_score = score best_action = action alpha = max(alpha, best_score) return best_action def value(self, state, agent_idx, depth, alpha, beta): if state.isWin() or state.isLose() or depth == self.depth: return self.evaluationFunction(state) if agent_idx == 0: value = -float("inf") for action in state.getLegalActions(0): value = max(value, self.value( state.generateSuccessor(0, action), 1, depth, alpha, beta)) if value > beta: return value alpha = max(alpha, value) return value else: next_idx = (agent_idx + 1) % state.getNumAgents() next_depth = depth + 1 if next_idx == 0 else depth value = float("inf") for action in state.getLegalActions(agent_idx): value = min(value, self.value( state.generateSuccessor(agent_idx, action), next_idx, next_depth, alpha, beta)) if value < alpha: return value beta = min(beta, value) return value关键点有三个。第一,极大层的剪枝条件是value > beta,极小层的剪枝条件是value < alpha,两者方向不能反。第二,alpha 只在极大层增大,beta 只在极小层减小,getAction 里的 alpha 更新不能漏,否则下一层看到的边界是旧的。第三,返回值统一是浮点数分数,不要试图在 value 里同时返回 action,action 的绑定要放在 getAction 里完成。这是新手最容易犯的错误:value 里返回 (score, action),然后递归里再取 [1],类型对不上,测试直接报错。
从性能上看,Alpha-Beta 通常能把搜索的节点数砍到 Minimax 的六成以下,但前提是节点访问顺序足够好。如果你的合法动作列表顺序固定,效果可能不明显;如果你按经验给动作排序,比如先评估“靠近豆子”的动作,剪枝会更激进。实验不要求动作排序,但你完全可以在递归之前给 actions 排个序,只要保证排序稳定,Alpha-Beta 的结果不会变。
4.2 Expectimax 的期望层:随机鬼魂不是极小对手
到了第三题,实验把玩法改成随机鬼魂。随机鬼魂不是刻意追吃豆人的极小层,而是按固定策略随机移动。这时再用 Minimax 就会过度悲观:它假设鬼魂每一步都精准堵路,于是吃豆人变得非常怂,很多明明能吃的豆子也放弃了。Expectimax 的思路是把鬼魂层的 min 换成 average,即对所有鬼魂动作的评估分数求平均。
实现上和 Minimax 的区别只有极小层那一块:
def value(self, state, agent_idx, depth): if state.isWin() or state.isLose() or depth == self.depth: return self.evaluationFunction(state) if agent_idx == 0: return max( self.value(state.generateSuccessor(0, action), 1, depth) for action in state.getLegalActions(0) ) else: next_idx = (agent_idx + 1) % state.getNumAgents() next_depth = depth + 1 if next_idx == 0 else depth values = [ self.value(state.generateSuccessor(agent_idx, action), next_idx, next_depth) for action in state.getLegalActions(agent_idx) ] return sum(values) / len(values)注意:Expectimax 没有剪枝机制,不能把 Alpha-Beta 的边界条件搬过来。原因在于期望层需要看到全部子节点的取值,剪掉任何一个都会让平均值失真。这带来的代价是搜索时间比 Minimax 更差,尤其是在分支数量大的地图上。如果你用 depth=2 跑 mediumClassic,还能接受;一旦 depth=3,每一回合的耗时就会明显上升。
从风险评估的角度看,Expectimax 会把“鬼魂在隔壁但大概率不会过来”这种局面判定为安全,因此它更适合随机鬼魂模式。做实验时建议把 Minimax、Expectimax 都装上,用同一张地图对比吃豆人的死亡次数,你很容易看出两种模型在决策风格上的差异:Minimax 保守躲闪,Expectimax 更愿意冒险吃豆。
4.3 评估函数参数怎么调:距离、得分、与鬼魂的“距离惩罚”
前面提到的所有算法,叶子节点的值都来自 evaluationFunction。作业对评估函数的要求只有一个:返回分数越高,代表对你越有利。这不是一个可以随便填的函数,它直接决定吃豆人的行为偏好。
我先给一个经过多数人验证的起步公式:
def betterEvaluationFunction(state): pos = state.getPacmanPosition() if state.isLose(): return -float("inf") food_list = state.getFood().asList() if not food_list: return 1.0 + state.getScore() nearest_food = min( abs(pos[0] - f[0]) + abs(pos[1] - f[1]) for f in food_list ) ghost_penalty = 0.0 for ghost_pos in state.getGhostPositions(): d = abs(pos[0] - ghost_pos[0]) + abs(pos[1] - ghost_pos[1]) if d < 3: ghost_penalty += 1.0 / (d + 1.0) return state.getScore() + 10.0 / (1.0 + nearest_food) - 3.0 * ghost_penalty这里的特征只有两个:最近豆子距离和鬼魂逼近惩罚。10.0 / (1.0 + nearest_food)让距离为 1 的豆子贡献 5 分,距离为 5 的豆子只贡献不到 1.7 分,这样吃豆人会被强烈吸引到最近的豆子附近。ghost_penalty在鬼魂距离小于 3 时开始剧增,距离为 1 时惩罚 1.5 分,距离为 2 时惩罚 1 分,这会让吃豆人尽量离开鬼魂一格甚至两格范围。
调参的表格可以总结如下:
| 特征 | 公式 | 初始权重 | 调参方向 |
|---|---|---|---|
| 最近豆子距离 | 10 / (1 + dist) | 10 | 豆子越多权重越高 |
| 鬼魂距离惩罚 | 1 / (d + 1) | 3 | 鬼魂越凶权重越接近 10 |
| 当前得分 | 直接累加 | 1 | 不要设太大,否则风险偏好过高 |
| 豆子是否吃光 | 吃光直接 +1 | 1 | 与 isWin 联动 |
| 药丸能量 | 可选特征 | 5 | 能吃掉鬼时大幅提高权重 |
注意:这里的权重没有“最优解”。每个地图的吃豆区域密度不同,鬼魂数量也不同,你需要跑多局抽样才能判断某个权重组合到底行不行。我建议你先固定地图和鬼魂数,只改一个权重,跑 10 局取平均值,再改另一个。不要同时调三个权重,否则你无法定位到底是哪个特征导致翻车。
评估函数最容易踩的坑是用“平均距离”代替“最小距离”。平均距离会把远处的一堆豆子算进来,导致吃豆人为了“平均距离更小”而朝一个没有近豆的方向走。用最小距离才符合吃豆人的真实目标:先解决最近的豆子。鬼魂距离则相反,应该用最近鬼魂的距离做强烈惩罚,因为一只鬼贴脸和两只鬼贴脸在躲闪上没有本质区别,关键是最近那只。
5. 多智能体搜索的 5 个经典坑:现象、原因与修复
5.1 现象:鬼魂贴脸时 Pacman 依然迎头撞上去
现象:Minimax 跑通了,自动评分也能过,但手动开可视化时,吃豆人总在鬼魂只有一步之遥的位置继续往前冲。
原因:要么评估函数对“鬼魂很近”没有足够大的惩罚,要么搜索深度只有 1,根本看不到鬼魂下一步会走出什么。比较常见的是后者:当你只展开一层时,吃豆人只看自己动作后的即时分数,如果朝鬼魂方向恰好有豆子,分数反而更高。
解决:把深度提高到 2 或 3,并加大评估函数里鬼魂距离惩罚。保证“距离为 1 的鬼魂惩罚”大于“最近豆子奖励”,逻辑上就不会出现贴着鬼魂吃豆。一个简单判断标准:在鬼魂距离 1 时,评估函数计算出的分数应当明显低于距离 3 时的分数。如果不满足,就说明权重配反了。
# 在评估函数中增加一个快速保命分支 for ghost_pos in state.getGhostPositions(): d = abs(pos[0] - ghost_pos[0]) + abs(pos[1] - ghost_pos[1]) if d <= 1: return -1000.0这种“判死刑”写法不优雅,但能最快验证问题出在评估函数还是搜索深度。如果加上它之后吃豆人开始绕开鬼魂,说明权重问题是主因;如果依然撞上去,那就要去看深度和递归结构。
5.2 现象:Alpha-Beta 剪枝结果与 Minimax 不一致
现象:同一个地图、同一个随机种子,Minimax 和 Alpha-Beta 选出的动作不同,或者说 autograder 的 q2 始终报错。
原因:剪枝条件写反,或者返回值顺序错误,再或者终止条件被放到了剪枝判断之后。这类问题通常不是“剪枝本身改变了结果”,而是代码里某个分支提前 return 了错误的值。
解决:先确认极大层用的是> beta,极小层用的是< alpha,不要把>和>=混用。然后写一个固定种子的对比命令:
python pacman.py -l testClassic -p MinimaxAgent -a depth=2 -k 1 -n 1 --seed 42 --no-graphics python pacman.py -l testClassic -p AlphaBetaAgent -a depth=2 -k 1 -n 1 --seed 42 --no-graphics如果这两行的最终分数一致,说明结果没被剪枝影响。如果不一致,在 value 函数开头打印 (agent_idx, depth, state 摘要),对比两棵树在哪里分叉。另一个隐蔽原因是动作遍历顺序不同,但只要你用了完全相同的 getLegalActions 顺序,这不是问题。
5.3 现象:Expectimax 在随机鬼魂下慢到无法忍受
现象:depth=2 时每一步都要算几十秒,深度稍微调大直接卡死,用 autograder 跑还会超时。
原因:Expectimax 没有剪枝,复杂度天然更高。更常见的问题是同一个状态被反复展开,没有缓存,也没有限制分支数量。
解决:先给 value 函数加一个基于(state, agent_idx, depth)的缓存。GameState 如果可哈希就直接用,不可哈希就取状态关键特征组成元组。再用一个更粗暴但有效的办法:在 getAction 里只保留前 3 个合法动作进入递归,把分支数量压下来。
# 只取排在前面的 3 个合法动作,降低 Expectimax 分支数 actions = sorted(gameState.getLegalActions(0), key=lambda a: self.evaluationFunction( gameState.generateSuccessor(0, a)), reverse=True)[:3]这种做法会牺牲精度,但能在可接受时间内跑完。注意它改变了算法的数学含义,所以只适合作为超时时的应急手段。先保证通过,再谈优化。
5.4 现象:评估函数只关心豆子,吃豆人来回绕圈
现象:吃豆人明明能看到豆子,却在一个小区域来回横跳,每次都选一个“朝豆子方向”的动作,但动作被鬼魂或墙壁挡住后只能弹回原位。
原因:这是评估函数的方向性不够强导致的。最近的豆子在左边,左走被墙挡住后,它选一个能动的方向,下一回合又被右边豆子吸引,形成振荡。加上“当前动作与上一动作相反有小惩罚”,或者直接用“曼哈顿距离平方”放大远近差异,可以显著减少绕圈。
解决:在 getAction 里记录上一次动作,给反向动作加一个小的负分惩罚。
previous_action = None # getAction 每次调用时更新 penalty = 0.0 if action == opposite(previous_action): penalty = 0.5 score = self.evaluationFunction(next_state) - penalty还有一个容易忽略的点是 Stop 动作。如果 Stop 在合法动作列表里,吃豆人会被“保持不动”的分值吸引,因为不动不会立刻增加风险。建议在 getAction 的合法动作里先过滤 Stop,除非测试用例明确要求保留。
5.5 现象:Python 3 环境跑原始包必炸,版本迁移的稳妥姿势
现象:直接python3 pacman.py大概率报语法错误,要么 print 语句不对,要么exceptions模块找不到,要么xrange未定义。
原因:实验包默认是 Python 2 语法,和现代环境不兼容。
解决:不要重写 game.py,只迁移 multiAgents.py 和 autograder.py 里与运行相关的最少代码。把print xxx改成print(xxx),把iteritems改成items,把xrange全局替换成range,再移除exceptions相关引用。如果嫌麻烦,更简单的方式是装一个 Python 2 环境专门跑这个包。
# python3 兼容补丁示意:不要全局替换,只处理运行报错处 try: import exceptions except ImportError: pass迁移时最怕手动改坏深浅拷贝逻辑。game.py 里的__deepcopy__和 graphics 渲染依赖原始对象结构,一旦改动全局生效,后续问题会变得非常难查。我当时的做法是让原始包留在 Python 2 环境里跑分,自己写的 multiAgents.py 单独放在 Python 3 里做算法验证,两边互不干扰。
6. 验证与进阶:用固定种子、自测脚本和可视化把 Agent 调到能赢
6.1 固定随机种子,让对比实验可复现
调参最怕什么?怕改了一行权重,跑分忽高忽低,说不清是变好了还是变坏了。实验包里默认有随机鬼魂,每局结果差异很大。这时候一定要固定随机种子。常见做法是在命令行加--seed 42,或者用 Python 脚本里先调用random.seed(42)。我通常用一段循环脚本把不同深度、不同评估函数权重的结果拉出来对比:
for d in 1 2 3; do echo "depth=$d" python pacman.py -l mediumClassic \ -p ExpectimaxAgent -a depth=$d -k 2 -n 10 --seed 42 \ --no-graphics done这样每次得到的是同一批随机鬼魂轨迹下的平均分,可比性很强。如果你不固定种子,A 方案跑 10 局平均分是 320,B 方案跑 10 局平均分是 380,很难判断是算法好还是运气好。固定种子后,你能精确看到每个改动带来的分数变化,这种习惯在后面的课程和实际项目里都值得保留。
6.2 回放失败局:在评估函数里临时打印决策依据
当吃豆人做了某个让你觉得“很蠢”的决定,先别急着调权重。我的做法是在 evaluationFunction 里临时打印当前状态的特征值和最终打分,然后跑一局,看那个关键动作前后的打分对比。如果打分差距很小,说明是特征不够敏感;如果打分差距很大但选错了,说明是搜索深度不够,算法被更深层的假象迷惑了。把这一步做完再动手改代码,能省下大量瞎试时间。
可视化窗口虽然漂亮,但对调参帮助有限。更实用的方式是分析记录文本,比如固定种子跑几十局后统计平均步数、平均死亡次数、平均剩余豆子数。只需要记三个数字:平均得分、平均剩余豆子、完成率。这三个数字能快速暴露问题:剩余豆子太多说明评估函数引导不够,平均得分高但死亡频繁说明风险偏好过大。
6.3 我的个人调参习惯
我第一次完整做完 multiagent 实验时,最大的教训就是只调深度不调评估函数。把 Minimax depth 从 2 改到 4,吃豆人确实更“聪明”了,但也更怕死了,很多本可以冒险一吃的豆子全放弃。后来才明白,深度增加让吃豆人看到了更多鬼魂堵路的可能,如果在评估函数里没有给“吃完豆子能赢”足够的奖励,它自然会偏向躲藏。
我的习惯是固定一套参数后,先记录基准分数,然后每改一个特征就重跑一次固定种子的对比。改完评估函数再去动深度,最后才做剪枝或缓存优化。分步验证比一次性大改要可靠得多。希望帮到你。
本文还有配套的精品资源,点击获取