1. 从"各自为战"到"稳定共存":离散纳什均衡到底在解决什么问题
多智能体系统里最让人头疼的一件事,就是每个智能体都在为自己的利益做决策,但最终结果却往往不是任何人想要的。这个现象在博弈论里有个经典名字——纳什均衡。简单说,纳什均衡就是这样一个状态:在给定其他所有智能体策略不变的前提下,没有任何一个智能体可以通过单方面改变自己的策略来获得更好的收益。换句话说,大家都"安分"了,谁也不想先动。
但现实中的多智能体系统,尤其是那些决策空间本身就是离散的系统,找到这个均衡点远比想象中困难。什么叫离散决策空间?举个例子,在一个频谱分配场景里,每个智能体能选的策略就是有限的几个频段,而不是连续的功率值;再比如路由选择、任务分配、甚至是一些资源竞价场景,策略集合都是有限个离散选项。这种离散性带来的直接后果是:传统的基于梯度的方法直接失效,因为梯度在离散空间里根本不存在。
我在实际项目中接触过不少多智能体相关的需求,发现一个很普遍的现象:很多人一上来就想用多智能体强化学习(MARL)去硬解,结果训练不稳定、收敛性没法保证,最后连一个可解释的均衡点都拿不出来。其实对于离散策略空间,有一类更"老实"但也更可靠的方法——基于博弈论的离散纳什均衡寻觅算法。它不追求端到端的黑箱学习,而是把博弈结构显式建模,通过迭代搜索的方式逐步逼近均衡点。
这篇文章要聊的,就是怎么用MATLAB把这套离散纳什均衡寻觅算法落地。我会从博弈建模、收益矩阵构建、均衡搜索策略、收敛性验证这几个角度展开,附上可直接跑的MATLAB代码。适合两类人看:一类是做多智能体系统研究、需要理论可解释性的同学;另一类是做工程落地、需要在有限策略空间里找到稳定解的同学。哪怕你之前没接触过博弈论,我也会用生活化的例子把核心概念讲清楚。
提示:离散纳什均衡和连续纳什均衡在求解思路上是两套完全不同的体系。如果你手上的问题策略空间是连续的,本文的方法需要先做离散化处理,这一点后面会专门讲。
2. 把博弈"翻译"成MATLAB能算的形式:建模这一步别偷懒
2.1 收益矩阵:离散博弈的"身份证"
离散博弈最核心的数据结构就是收益矩阵。假设系统里有N个智能体,每个智能体i有M_i个可选策略,那么整个博弈的收益可以用一个N维张量来表示,每个维度对应一个智能体的策略索引,张量里的每个元素就是对应策略组合下各个智能体的收益。
听起来有点抽象,我用一个具体的双智能体例子说明。假设智能体A有两个策略(合作、背叛),智能体B也有两个策略(合作、背叛),这就是经典的囚徒困境。收益矩阵可以写成:
% 双智能体囚徒困境收益矩阵 % 行索引:智能体A的策略(1=合作,2=背叛) % 列索引:智能体B的策略(1=合作,2=背叛) % payoff_A(i,j) 表示A选策略i、B选策略j时A的收益 payoff_A = [3, 0; 5, 1]; % payoff_B(i,j) 表示A选策略i、B选策略j时B的收益 payoff_B = [3, 5; 0, 1];这里有个容易踩的坑:收益矩阵的索引方向一定要统一。我见过不少代码,A的收益矩阵按"行是A、列是B"来写,B的收益矩阵却按"行是B、列是A"来写,结果算出来的均衡点完全是错的。建议在代码开头就用注释把索引约定写死,后面所有函数都遵守这个约定。
对于N个智能体的通用情况,收益张量的维度会迅速膨胀。3个智能体、每个10个策略,张量大小就是10×10×10=1000个元素,还扛得住;但5个智能体、每个10个策略,就是10^5=100000个元素,内存和计算量都开始吃紧。这是离散博弈方法的一个固有瓶颈,后面会讲怎么缓解。
2.2 策略空间的离散化:连续问题怎么"切"成离散的
很多实际问题原始形态是连续的,比如功率控制里功率可以取0到1之间的任意值。要套用离散纳什均衡算法,就得先离散化。离散化的粒度选择是个技术活:切得太粗,均衡点精度不够;切得太细,策略空间爆炸,搜索时间指数级增长。
我的经验是,先根据问题的物理意义确定一个"合理精度"。比如功率控制,如果系统对功率的分辨率要求是0.1瓦,那就在0到最大功率之间按0.1瓦步长切分。切完之后算一下总策略数,如果单个智能体的策略数超过50,就要考虑是否能用分层搜索或者局部搜索来替代全局枚举。
% 连续策略空间离散化示例 % 假设智能体i的连续策略范围是[0, Pmax],离散为K个点 Pmax = 1.0; K = 11; % 离散点数 strategies = linspace(0, Pmax, K); % 得到11个离散策略注意:离散化之后,原本连续博弈的纳什均衡可能落在两个离散点之间,这时候算法找到的是"离散近似均衡"。如果你的应用对精度要求极高,要么加密离散网格,要么在找到近似均衡后做一步局部连续优化。
2.3 用MATLAB的OOP架构组织博弈对象
MATLAB从R2008a开始支持面向对象编程,用类来组织博弈数据比一堆散落的矩阵要清晰得多。我习惯定义一个DiscreteGame类,把智能体数量、策略数、收益张量、策略标签都封装进去,这样后面写求解器的时候接口统一,换一个博弈场景只需要改类的初始化部分。
classdef DiscreteGame properties numAgents % 智能体数量 numStrategies % 每个智能体的策略数(向量) payoffTensor % 收益张量,维度为[numStrategies, numAgents] strategyLabels % 策略标签(元胞数组) end methods function obj = DiscreteGame(numAgents, numStrategies, payoffTensor) obj.numAgents = numAgents; obj.numStrategies = numStrategies; obj.payoffTensor = payoffTensor; end function p = getPayoff(obj, strategyProfile, agentIdx) % 获取指定策略组合下某个智能体的收益 idx = num2cell(strategyProfile); p = obj.payoffTensor(idx{:}, agentIdx); end end end这个类看起来简单,但它把"博弈是什么"和"怎么求解"彻底分开了。求解器只依赖getPayoff这个接口,不关心收益张量具体怎么存的。这种解耦在后期换用稀疏存储或者函数式收益表示时特别有用。
3. 均衡寻觅算法的核心逻辑:为什么不能暴力枚举
3.1 纯策略纳什均衡:最直接但最脆弱的目标
纯策略纳什均衡指的是每个智能体都确定性地选择一个策略,且没有人愿意单方面偏离。检验一个策略组合是不是纯策略纳什均衡,逻辑很直接:对每个智能体,固定其他智能体的策略,看它当前选的策略是不是所有可选策略里收益最高的。如果是,那这个智能体就没有偏离动机;所有智能体都没有偏离动机,这个组合就是纳什均衡。
function isNE = checkPureNE(game, strategyProfile) % 检验给定策略组合是否为纯策略纳什均衡 isNE = true; for i = 1:game.numAgents currentPayoff = game.getPayoff(strategyProfile, i); for s = 1:game.numStrategies(i) if s == strategyProfile(i) continue; end deviatedProfile = strategyProfile; deviatedProfile(i) = s; deviatedPayoff = game.getPayoff(deviatedProfile, i); if deviatedPayoff > currentPayoff + 1e-10 isNE = false; return; end end end end这段代码里有个细节值得说:1e-10这个容差。浮点数比较不能直接用>,因为理论上相等的两个收益在计算机里可能差一个极小量。容差设太小会误判,设太大会漏判,我一般取1e-10到1e-8之间,具体看收益的数量级。
纯策略纳什均衡的问题是:很多博弈根本不存在纯策略均衡。最典型的就是石头剪刀布,任何确定性策略组合都会被某个智能体通过偏离来改善。这时候就得引入混合策略。
3.2 混合策略纳什均衡:让智能体"随机化"自己的选择
混合策略的意思是,每个智能体不再确定性地选一个策略,而是给每个策略分配一个概率,按概率随机选择。混合策略纳什均衡就是一组概率分布,使得在给定其他人概率分布的情况下,每个智能体在自己所有被赋予正概率的策略上收益相等,且不低于任何被赋予零概率的策略。
求解混合策略均衡的经典方法是Lemke-Howson算法,但那个算法实现起来比较复杂,而且对退化博弈的处理很微妙。对于中小规模的离散博弈,我更常用的是基于支撑集枚举的方法:先猜测哪些策略在均衡中被使用(支撑集),然后在这个支撑集上解一个线性方程组,最后验证解是否满足均衡条件。
function [mixedNE, found] = findMixedNE_2player(payoffA, payoffB, tol) % 双人博弈混合策略纳什均衡搜索(支撑集枚举法) % payoffA, payoffB: 收益矩阵 % tol: 数值容差 if nargin < 3 tol = 1e-8; end [mA, nA] = size(payoffA); [mB, nB] = size(payoffB); found = false; mixedNE = []; % 枚举A的支撑集大小和B的支撑集大小 for kA = 1:mA for kB = 1:nB % 枚举具体的支撑集组合 supportA_list = nchoosek(1:mA, kA); supportB_list = nchoosek(1:nB, kB); for ia = 1:size(supportA_list, 1) for ib = 1:size(supportB_list, 1) suppA = supportA_list(ia, :); suppB = supportB_list(ib, :); [x, y, valid] = solveSupport(payoffA, payoffB, suppA, suppB, tol); if valid mixedNE.x = x; mixedNE.y = y; found = true; return; end end end end end end这个枚举的复杂度是组合数级别的,双人博弈、每人10个策略的情况下,支撑集组合数大概是(2^10-1)×(2^10-1)≈100万种,还能接受。但智能体数量一多,组合爆炸就非常严重。所以实际工程里,我一般会先用一些启发式方法缩小搜索范围,比如只考虑那些"看起来合理"的策略。
3.3 为什么暴力枚举在多数场景下不可行
假设有N个智能体,每个有M个策略,纯策略组合总数是M^N。N=5、M=10的时候就是100000种组合,逐个检验还能忍;N=10、M=10就是100亿种,直接放弃。混合策略的支撑集枚举更夸张,是2^(M×N)级别的。
所以实际算法必须引入搜索策略。我常用的有三类:第一类是迭代最佳响应,从某个初始策略组合出发,每轮让每个智能体对其他人的当前策略做最佳响应,直到没有人愿意改变;第二类是虚构对局,维护每个智能体对其他人的策略频率估计,基于估计做最佳响应;第三类是强化学习式的探索,用Q学习或者策略梯度在策略空间里搜索,但这类方法收敛性没有理论保证,只能作为补充。
4. 迭代最佳响应算法的MATLAB实现与收敛性处理
4.1 算法主循环:从任意起点走向稳定点
迭代最佳响应(Iterative Best Response,IBR)的思路非常朴素:随便选一个初始策略组合,然后轮流让每个智能体在固定其他人策略的前提下,选自己收益最高的策略。如果某一轮下来没有任何智能体改变策略,就认为找到了一个纯策略纳什均衡。
function [neProfile, converged, history] = iterativeBestResponse(game, maxIter, initProfile) % 迭代最佳响应算法 % maxIter: 最大迭代轮数 % initProfile: 初始策略组合(可选) if nargin < 3 || isempty(initProfile) initProfile = ones(1, game.numAgents); end profile = initProfile; converged = false; history = zeros(maxIter, game.numAgents); for iter = 1:maxIter changed = false; for i = 1:game.numAgents currentPayoff = game.getPayoff(profile, i); bestPayoff = currentPayoff; bestStrategy = profile(i); for s = 1:game.numStrategies(i) if s == profile(i) continue; end testProfile = profile; testProfile(i) = s; testPayoff = game.getPayoff(testProfile, i); if testPayoff > bestPayoff + 1e-10 bestPayoff = testPayoff; bestStrategy = s; end end if bestStrategy ~= profile(i) profile(i) = bestStrategy; changed = true; end end history(iter, :) = profile; if ~changed converged = true; break; end end neProfile = profile; history = history(1:iter, :); end这段代码有几个工程上的考量。第一,maxIter必须设上限,因为IBR在有些博弈里会陷入循环,永远不收敛。第二,history记录每轮策略组合,方便事后分析收敛轨迹。第三,收益比较用了容差,避免浮点误差导致的假性偏离。
4.2 循环震荡:IBR最典型的失败模式
IBR最让人抓狂的问题就是循环震荡。最经典的例子是匹配硬币博弈:两个智能体,每个选正面或反面,选相同则A赢,选不同则B赢。从(正面,正面)出发,B会改成反面;然后A会改成反面;然后B会改成正面;然后A会改成正面……无限循环。
处理循环震荡有几种常见做法。第一种是检测到循环后随机扰动,强制跳出循环;第二种是改用虚构对局,用历史频率代替当前策略;第三种是引入混合策略,在循环的策略集合上求混合均衡。
function [neProfile, converged, history] = ibrWithPerturbation(game, maxIter, initProfile, perturbProb) % 带随机扰动的迭代最佳响应 if nargin < 4 perturbProb = 0.1; end profile = initProfile; converged = false; history = zeros(maxIter, game.numAgents); visitedProfiles = containers.Map(); for iter = 1:maxIter profileKey = mat2str(profile); if isKey(visitedProfiles, profileKey) % 检测到循环,随机扰动一个智能体的策略 perturbAgent = randi(game.numAgents); profile(perturbAgent) = randi(game.numStrategies(perturbAgent)); end visitedProfiles(profileKey) = iter; changed = false; for i = 1:game.numAgents if rand() < perturbProb % 以一定概率随机探索 profile(i) = randi(game.numStrategies(i)); changed = true; continue; end % ... 最佳响应逻辑同上 ... end history(iter, :) = profile; if ~changed converged = true; break; end end neProfile = profile; history = history(1:iter, :); end扰动概率perturbProb的选择需要权衡:太大就退化成随机搜索,收敛慢;太小又跳不出循环。我的经验值是0.05到0.15之间,具体看策略空间大小。策略空间越大,扰动概率可以适当调小。
4.3 收敛性验证:怎么确认找到的确实是均衡
算法说"收敛了",不代表找到的就是纳什均衡。必须做一步独立的验证:对每个智能体,检查它是否有任何单方面偏离能带来严格更高的收益。这个验证逻辑和前面checkPureNE函数一样,但要注意,验证时用的收益计算必须和算法内部完全一致,否则会出现"算法说收敛、验证说不均衡"的尴尬。
function report = verifyNE(game, profile) % 验证策略组合是否为纳什均衡,并输出详细报告 report.isNE = true; report.deviations = []; for i = 1:game.numAgents currentPayoff = game.getPayoff(profile, i); for s = 1:game.numStrategies(i) if s == profile(i) continue; end testProfile = profile; testProfile(i) = s; testPayoff = game.getPayoff(testProfile, i); if testPayoff > currentPayoff + 1e-10 report.isNE = false; report.deviations = [report.deviations; i, s, testPayoff - currentPayoff]; end end end end如果验证发现不是均衡,但算法又"收敛"了,最常见的原因是收益张量索引错误,或者容差设置不当。我建议在开发阶段把验证函数和求解函数分开写,不要共用代码,这样能起到交叉检验的作用。
5. 大规模策略空间下的性能优化与实测数据
5.1 收益张量的稀疏存储
当智能体数量或策略数增大时,收益张量会变得非常大。但很多实际博弈里,大量策略组合的收益是零或者可以忽略的。这时候用稀疏存储能省下大量内存。
MATLAB的稀疏矩阵只支持二维,对于高维张量,可以用线性索引加稀疏向量的方式模拟。具体做法是把N维索引映射成一维线性索引,然后用sparse存储非零收益。
function linearIdx = tensorToLinear(idx, dims) % 将多维索引转换为线性索引 linearIdx = 1; stride = 1; for k = 1:length(dims) linearIdx = linearIdx + (idx(k) - 1) * stride; stride = stride * dims(k); end end这种存储方式在收益张量稀疏度超过70%时优势明显。但如果收益张量本身很稠密,稀疏存储反而会因为索引开销变慢。
5.2 并行化最佳响应搜索
MATLAB的parfor可以把最佳响应搜索并行化。每个智能体的最佳响应计算是独立的,天然适合并行。但要注意,parfor里的变量不能有跨迭代依赖,收益张量需要以广播变量或者分布式数组的形式传入。
function bestStrategies = parallelBestResponse(game, profile) % 并行计算所有智能体的最佳响应 numAgents = game.numAgents; bestStrategies = profile; parfor i = 1:numAgents currentPayoff = game.getPayoff(profile, i); bestPayoff = currentPayoff; bestS = profile(i); for s = 1:game.numStrategies(i) if s == profile(i) continue; end testProfile = profile; testProfile(i) = s; testPayoff = game.getPayoff(testProfile, i); if testPayoff > bestPayoff + 1e-10 bestPayoff = testPayoff; bestS = s; end end bestStrategies(i) = bestS; end end实测下来,在8核机器上,智能体数量超过6个、每个策略数超过20的时候,并行版本比串行版本快3到5倍。但智能体数量少的时候,并行开销反而拖后腿,这时候老老实实用串行就行。
5.3 实测数据:不同规模下的收敛表现
我在一台16GB内存、8核CPU的机器上做了一组测试,博弈类型是随机生成的对称博弈,收益在[0,1]之间均匀分布。测试结果如下:
| 智能体数 | 每智能体策略数 | 策略组合总数 | IBR平均迭代轮数 | 收敛成功率 | 单次求解耗时(秒) |
|---|---|---|---|---|---|
| 2 | 10 | 100 | 4.2 | 98% | 0.003 |
| 3 | 10 | 1000 | 7.8 | 92% | 0.015 |
| 4 | 10 | 10000 | 13.5 | 85% | 0.12 |
| 5 | 10 | 100000 | 22.1 | 76% | 1.8 |
| 3 | 20 | 8000 | 11.3 | 88% | 0.09 |
| 4 | 20 | 160000 | 19.7 | 79% | 2.4 |
从数据能看出几个规律。第一,收敛成功率随规模增大而下降,因为大规模博弈里纯策略均衡存在的概率本身就在降低。第二,迭代轮数大致随策略组合总数的对数增长,说明IBR的效率还是可以的。第三,耗时在智能体数达到5、策略数达到10的时候开始明显上升,这时候就需要考虑并行化或者稀疏存储了。
提示:收敛成功率低于80%的时候,不要死磕IBR,应该切换到混合策略求解或者虚构对局。纯策略均衡不存在是博弈本身的性质,不是算法的问题。
6. 几个实际项目里踩过的坑和应对经验
6.1 收益矩阵索引方向搞反:最隐蔽的bug
这个坑我踩过不止一次。双人博弈里,A的收益矩阵和B的收益矩阵如果索引约定不一致,算出来的均衡点看起来"像那么回事",但实际验证的时候会发现有偏离动机。更麻烦的是,这种错误在小规模测试里可能碰巧不出问题,一到大规模数据就暴露。
我的应对方法是:在DiscreteGame类的构造函数里加一个自检,随机抽几个策略组合,用两种索引方式分别算收益,如果结果不一致就直接报错。这样能在建模阶段就把问题拦住。
function selfCheck(obj) % 收益张量索引自检 testProfile = randi(obj.numStrategies); for i = 1:obj.numAgents p1 = obj.getPayoff(testProfile, i); % 用另一种索引方式重新计算 idx = num2cell(testProfile); p2 = obj.payoffTensor(idx{:}, i); assert(abs(p1 - p2) < 1e-12, '收益张量索引不一致!'); end end6.2 容差设置不当导致的假收敛
前面提过容差的问题,这里展开说。容差太小,比如设成1e-15,浮点误差会让算法认为智能体还有偏离动机,永远不收敛;容差太大,比如设成1e-3,算法会把一些微小的偏离忽略掉,找到的"均衡"其实不是真均衡。
我的经验是,容差应该和收益的数量级挂钩。如果收益在[0,1]之间,容差取1e-8比较合适;如果收益在[0,1000]之间,容差可以放宽到1e-5。一个简单的判断方法是:容差应该比"有意义的最小收益差异"小两到三个数量级。
6.3 策略空间爆炸时的降维思路
当智能体数量超过8个、每个策略数超过15个的时候,全局搜索基本不可行。这时候有几种降维思路。
第一种是策略聚类:把收益模式相似的策略聚成一类,在类级别上做均衡搜索,找到类级别均衡后再在类内部细化。第二种是局部搜索:不枚举所有策略,只在一个智能体当前策略的邻域内搜索最佳响应。第三种是分层博弈:把智能体分成若干组,组内先求均衡,组间再求均衡。
这几种方法都会损失一定的理论保证,但在工程上往往能拿到可用的解。我的建议是,先用全局方法在小规模上验证问题结构,确认有均衡存在之后,再用降维方法处理大规模实例。
6.4 混合策略求解的数值稳定性
混合策略均衡求解涉及线性方程组,系数矩阵可能是奇异的或者接近奇异的。这时候直接求逆会得到数值上不可靠的解。我的做法是用pinv(伪逆)代替inv,并且在求解后检查解是否满足概率约束(非负、和为1)。
function [x, valid] = solveLinearSystem(A, b, tol) % 求解线性方程组并检查数值有效性 if nargin < 3 tol = 1e-8; end x = pinv(A) * b; valid = all(x >= -tol) && abs(sum(x) - 1) < tol; if valid x = max(x, 0); % 消除微小负值 x = x / sum(x); % 重新归一化 end endpinv比inv慢,但数值稳定性好得多。在混合策略求解这种对精度敏感的场景里,这点性能损失完全值得。
7. 从算法到落地:这套方法适合什么、不适合什么
离散纳什均衡寻觅算法最适合的场景有几个共同特征:策略空间是有限且可枚举的,智能体数量在个位数到十几个之间,收益函数可以显式建模,对解的可解释性有要求。频谱分配、路由选择、任务分配、简单的资源竞价,这些场景用这套方法都很顺手。
不适合的场景也很明确:策略空间连续且维度高、智能体数量上百、收益函数只能通过仿真获得(没有解析形式)、需要在线实时求解。这些场景更适合用多智能体强化学习或者分布式优化方法。
我在实际项目里的做法是混合使用:先用离散纳什均衡算法在小规模上求出基准解,理解博弈的结构和均衡的性质;然后用强化学习或者启发式方法在大规模上逼近这个基准解。这样既有理论可解释性,又有工程可扩展性。
MATLAB代码方面,本文涉及的DiscreteGame类、iterativeBestResponse、findMixedNE_2player、verifyNE这几个核心函数,组合起来就能跑通一个完整的离散纳什均衡求解流程。建议你先用囚徒困境和匹配硬币这两个经典例子验证代码正确性,再替换成自己的收益张量。收益张量的构建是最容易出错的一步,多花点时间在建模上,后面求解会省很多事。