☰
微分博弈开源工具difgames:从HJI方程求解到策略验证的工程实践
2026/10/17 8:53:22 网站建设 项目流程

简介:这是一份面向微分博弈与最优控制领域研究者的开源项目,基于C语言实现,核心是利用哈密顿-雅可比-贝尔曼-伊萨克斯(HJBI)方程求解连续时间动态博弈问题,帮助开发者理解并复现纳什均衡的数值求解过程,适用于对控制理论、动态决策和数值计算感兴趣的工程师与高校学生。压缩包共9个文件,主要包含C源码、Shell构建脚本、图形头文件和PDF文档,整体仅约206KB,结构紧凑,便于快速定位核心算法。目前已有238人浏览学习。代码中可看到针对不同场景的C程序变体,涉及有限差分等数值方法对偏微分方程做近似求解;配套的图形模块可绘制博弈演化过程,直观呈现策略效果,便于分析动态对抗行为;构建脚本支持自动化编译运行,PDF文档则提供了理论背景与优化说明。资源虽小但完整,适合作为微分博弈编程实践的入门参考。

1. 微分博弈开源项目 difgames,值得拆的是求解器,不是“博弈”两个字

作为一个常年跑仿真代码的人,我对“开源软件”四个字的警惕心一直比对闭源工具更重:仓库里的 README 写得越漂亮,数据格式可能越随意。difgames 这个微分博弈开源工具包第一次让我改观的,是它把最容易被论文藏着掖着的那部分——Hamilton-Jacobi-Isaacs(HJI)方程的数值求解与策略可视化——直接摊开在代码里。追逃博弈(pursuit-evasion)、拦截博弈、多智能体对抗,这类问题真正难啃的不是建模,而是值函数在状态空间里怎么迭代、怎么收敛、怎么不炸。它解决的是“微分博弈理论有解但算不出来”的落地问题。适合两类人:一是做控制算法但不想从零写有限差分求解的工程师,二是想用博弈论做机器人对抗策略但苦于没有基准代码的研究者。下面我按自己拆这个仓库的路径,把原理、用法和踩过的坑一一展开。

2. 先弄清微分博弈的求解内核,再看代码才不懵:HJI 方程与两种实现路线

2.1 建模语言:从状态方程、代价函数到玩家接口

微分博弈和普通最优控制的差别,在于系统里有两个(甚至多个)决策者,而且各自的优化目标经常相反。拿 difgames 里最典型的拦截博弈举例:追踪者想把距离压到捕获半径以内,逃逸者想保持距离。两者都在同一个状态方程里作用,但控制输入相互对抗。

这类问题的标准建模方式是相对坐标。设相对位置为 x_rel,追踪者和逃逸者的速度分别为 v_p、v_e,那么一个简化的单积分器模型可以写成:

dx_rel/dt = v_p * u_p - v_e * u_e

其中 u_p、u_e 是两个玩家的方向控制量,取值一般在有界集合内,比如单位圆盘或区间 [-1, 1]。状态方程本身不复杂,复杂的是目标函数。追踪者的目标是极小化终端距离或捕获时间,逃逸者则相反,所以博弈的支付(payoff)是一个极小极大问题。

difgames 的建模接口把这套语义封装得很直接。每个玩家是一个类,需要你提供两个方法:一个是状态导数(动力学),一个是即时代价。看下面的示例代码就能明白它希望你按什么顺序写东西:

import numpy as np from diffgames import Game, Player class Pursuer(Player): def __init__(self, speed=1.0, capture_radius=0.2): super().__init__(name="pursuer") self.speed = speed self.capture_radius = capture_radius def dynamics(self, state, u, t): # state 这里取相对坐标 [delta_x, delta_y, ...] dx = np.array([u[0] * self.speed, u[1] * self.speed, 0.0]) return dx def costate(self, state, u, t): # 追踪者希望距离度量尽快缩小 dist = np.hypot(state[0], state[1]) return dist class Evader(Player): def __init__(self, speed=0.8): super().__init__(name="evader") self.speed = speed def dynamics(self, state, u, t): # evade 的方向与追踪者方向叠加,但符号相反 dx = np.array([-u[0] * self.speed, -u[1] * self.speed, 0.0]) return dx

代码逻辑说明:Pursuer 类里的 dynamics 返回的是状态导数,输入参数 u 是归一化方向向量;costate 方法返回的是这个玩家在某状态下感受到的“代价”,在极小极大求解里,追踪者的代价会被最小化,逃逸者的代价会被最大化。Evader 类的 dynamics 方向取反,表示逃逸者对距离度量的作用相反。

参数说明:speed 是最大速率,capture_radius 是判定捕获成功的距离阈值。这里的 state 前两维是相对位置,第三维是预留的角速度或时间维度,实际应用时你需要按自己的状态维度扩展。注意两个玩家的 dynamics 里都用到了相对坐标的同一状态向量,这一点是整个建模接口最容易写错的地方——很多人习惯各自维护绝对坐标,最后发现求解器里数据流对不上。

2.2 求解内核:水平集方法的网格语义与参数边界

建模接口只是前端,difgames 真正的核心在 solvers 模块。里面有两种求解路线:一类是经典的水平集方法(Level Set Method),另一类是基于神经网络的值函数近似。前者精度可控但受维度限制,后者适合高维但训练麻烦。

水平集方法求解 HJI 方程的基本思路,是把值函数 V(x, t) 定义在网格上,然后按照 Hamiltonian 的迎风方向做时间推进。HJI 方程的连续形式是:

∂V/∂t + min_u max_d <∇V, f(x, u, d)> + L(x, u, d) = 0

在网格上解这个东西,避不开三个参数:网格点数、时间步长、空间范围。这三个参数互相牵制,尤其是时间步长必须满足 CFL 条件,否则数值解直接发散。difgames 里水平集求解器的调用方式大致如下:

from diffgames.solvers import LevelSetSolver from diffgames.grid import UniformGrid grid = UniformGrid( low=(-3.0, -3.0), high=(3.0, 3.0), shape=(151, 151) ) solver = LevelSetSolver( game=game, grid=grid, t_max=2.0, dt=0.005, hamiltonian="upwind", boundary="zero" ) V_final = solver.solve()

逻辑说明:UniformGrid 定义状态空间的计算域,low 和 high 分别是上下边界,shape 是每个维度的网格数。solver 的 solve 方法执行时间反向推进,从终端时刻的值函数倒推回初始时刻,返回的结果 V_final 是一个与网格形状相同的数组,代表每个网格点上的最优支付值。

参数说明:dt=0.005 在 shape=151 的条件下基本满足 CFL 条件,具体的经验公式是 dt < dx / max_speed,其中 dx 是网格间距,max_speed 是玩家最大速率。如果你把 shape 提高到 301×301 而不缩小 dt,大概率会看到值函数在迭代几十步后出现明显噪点,这就是数值不稳定的前兆。boundary="zero" 表示在计算域边界把值函数固定为零,这种做法简单但有反射波问题,后面避坑章节会详细讲。

2.3 数据流动:从求解器到策略提取的管线设计

水平集方法算出的是值函数,但工程上最终需要的是策略——也就是“当前状态下,我这个玩家该往哪个方向走”。策略提取是 difgames 管线里容易被忽略的一环。简单说,最优控制方向来自值函数的梯度:追踪者沿着 V 下降最快的方向运动,逃逸者沿着 V 上升最快的方向运动。

difgames 的做法是求解完成后,对 V_final 做数值梯度,然后在每个网格点上归一化成方向向量。这个操作在代码里被封装成 extract_policy 函数:

from diffgames.policy import extract_policy policy_p, policy_e = extract_policy( V=V_final, grid=grid, players=[pursuer, evader], method="central_difference" )

逻辑说明:extract_policy 接收最终值函数和网格对象,返回两个玩家各自的最优控制方向数组。方向数组的形状和网格一致,每个元素是一个二维向量。在仿真时,你会根据当前状态找到最近的网格点,把该点的策略方向插值出来作为控制输入。

参数说明:method="central_difference" 是中心差分求梯度,精度比前向差分高,但在边界点会失效,内部实现会自动退化为单侧差分。这里有个常见误用:直接在离散值函数上做梯度前,最好做一次轻度的 Gaussian 平滑,否则策略方向会有颗粒感,尤其在值函数等值面比较平坦的区域。

整个数据流可以理解为:状态方程和代价函数定义问题,求解器生成值函数,策略提取模块把值函数翻译成控制指令。这个三层结构是 difgames 设计的核心,你换自己的问题时,通常只需要替换第一层和第二层的参数,策略提取和可视化可以直接复用。

3. 把 difgames 跑起来:从安装、配置文件到一次有效仿真的全过程

3.1 五分钟完成安装与依赖检查

拿到仓库第一件事不是急着跑示例,而是确认依赖环境干净。difgames 的依赖以 Python 生态为主,numpy、scipy、matplotlib 是硬依赖,如果你要用神经网络求解器,还需要 pytorch。建议直接用虚拟环境装,避免污染系统 Python:

python -m venv .venv source .venv/bin/activate pip install -r requirements.txt python -c "import diffgames; print(diffgames.__version__)"

逻辑说明:第一条命令创建虚拟环境,第二条激活。requirements.txt 里锁定了核心依赖版本,直接安装即可。最后一行验证导入是否成功,如果打印出版本号说明环境没问题。

参数说明:这里不指定 Python 版本一般没事,但如果要用神经网络求解器,建议 Python 3.9 以上,因为部分数值计算库在高版本下性能更好。如果你在 Windows 上跑,source .venv/bin/activate 要换成 .venv\Scripts\activate,这是最常被新手忽略的差异点。

3.2 跑一个拦截博弈示例:命令行入口、配置参数与运行时序

安装好依赖后,我习惯直接跑 examples 目录下的拦截博弈脚本。这个示例完整演示了“建模 → 求解 → 策略提取 → 仿真 → 可视化”的闭环,也是检验求解器是否正常的基准用例。

命令入口在 examples/intercept_game.py,运行方式:

python examples/intercept_game.py --config examples/configs/intercept.yaml --save-plot True

逻辑说明:--config 指定 YAML 配置文件,里面包含网格大小、时间步长、玩家参数等所有可调项。--save-plot 控制是否把轨迹图保存到本地,适合在无图形界面的服务器上跑。

配置文件 intercept.yaml 的内容大致如下:

game: t_max: 2.0 capture_radius: 0.2 grid: low: [-3.0, -3.0] high: [3.0, 3.0] shape: [151, 151] solver: dt: 0.005 hamiltonian: upwind boundary: zero players: pursuer: speed: 1.0 initial_state: [2.0, 0.0] evader: speed: 0.8 initial_state: [0.0, 0.5]

参数说明:t_max 是博弈的终端时间,也就是值函数反向推进的时长。capture_radius 是捕获判定半径,这个值要和网格分辨率匹配,如果网格间距是 0.04,而 capture_radius 设成 0.001,那捕获边界在网格上根本表达不出来。players 里的 initial_state 决定了仿真从哪个状态起步,注意这是相对坐标。

运行结束后,屏幕上会打印两类信息:一是求解器迭代过程(时间步、残差、是否收敛),二是仿真结果(最终距离、是否捕获、策略切换次数)。如果看到“Final distance: 0.1537”这类输出,说明示例跑通了。这时候你会想:我自己换个问题怎么改?下一步见。

3.3 把他人问题改造成自己的:重写动力学与代价函数

实际项目中很少有人能用默认示例直接交差,更常见的是把示例的动力学和代价换掉。比如你要做的是一个“带角速度约束的拦截问题”,追踪者不能像单积分器那样原地掉头,而是有最小转弯半径。

改造方法是创建一个新的 Player 子类,覆盖 dynamics 和 costate 方法:

class TurningPursuer(Pursuer): def __init__(self, turn_rate=2.0): super().__init__(speed=1.0) self.turn_rate = turn_rate def dynamics(self, state, u, t): # u[0] 是线速度方向,u[1] 是转弯速率 theta = state[2] dx = np.array([ np.cos(theta) * self.speed, np.sin(theta) * self.speed, u[1] * self.turn_rate ]) return dx

逻辑说明:新的 dynamics 把状态第三维定义为航向角 theta,控制量从原来的二维方向向量变成了一维转弯速率。这样状态维度仍然是三维,但第三维的物理含义变了,代价函数也要同步调整。

参数说明:turn_rate 限制了转弯的快慢,这个值越大越接近原版单积分器。注意状态维度从二维变成三维以后,网格对象也要改成三维:shape=(51, 51, 51),否则求解器会在维度检查时报错。这里有个隐藏坑:三维网格下内存占用是二维的几十倍,51 的三次方已经接近 13 万个网格点,每个点存 float64 类型的值函数就要 1 兆左右,再算上梯度计算,内存容易吃紧。我的经验是先在 25×25×25 的粗网格上验证问题建模正确,再加密网格。

代价函数同理,如果你关心的不是终端距离而是“累计时间”,那就把 costate 改成常数 1,让值函数直接反映剩余时间。改完以后,配置文件的 solver 部分不用动,直接重新跑,求解器会自动适配新的状态维度和动力学。

4. 避坑指南:微分博弈求解中 5 个翻车现场的修复路径

4.1 症状一:值函数数值爆炸,出现 NaN

现象:solver 迭代到第几百步时,V 数组出现 NaN,终端输出直接中断。这个过程在终端输出里往往没有任何 Warning,毫无预兆。

原因:时间步长 dt 没有满足 CFL 条件。当网格加细而 dt 没有相应缩小时,有限差分格式的稳定域被突破,数值解指数增长。

解决:先算理论临界值 dt_cfl = dx / max_speed。比如 shape=151 时,dx ≈ 0.04,max_speed=1.0,dt 就应小于 0.04。dt 取 0.02 是安全的,取 0.05 就是拿稳定性冒险。另一个处理是把 hamiltonian 从 "upwind" 换成 "lax_friedrichs",后者耗散更强,稳定性好一些但会钝化值函数。

4.2 症状二:值函数等值面在边界附近出现周期性的“波纹”

现象:画值函数等值面时,边界内侧出现规律性的环形波纹,间距大致相等,而且随迭代时间推进向内扩散。

原因:boundary="zero" 把边界值固定为 0,但真实的 HJI 方程解在边界处不一定为 0。边界作为固定值源,会持续向计算域内发出数值反射波。

解决:换用外推边界条件,difgames 里对应 boundary="extrapolate"。它会用边界附近的梯度外推边界处值函数,显著减少反射波。如果换完还有残影,就把计算域扩大 20% 再裁掉边界区域,这是最粗暴但有效的办法。

4.3 症状三:仿真时追踪者明明进入捕获半径,系统却不判定捕获

现象:策略看上去合理,追踪者的轨迹穿过了逃逸者位置,但控制台没有打印捕获成功,最终距离甚至小于 capture_radius 却继续跑。

原因:仿真插值用的是策略网格,而捕获判定用的是连续状态距离,两者不一致。当网格分辨率不足时,策略网格上“穿越”和“擦肩而过”无法区分,判定逻辑拿到的状态已经是上一个网格步长的值。

解决:在仿真循环里,把捕获判定换成“扫描区间内最小距离”,而不是只看每一步的瞬时距离。具体做法是记录仿真时间内距离的最小值,小于 capture_radius 即判定捕获。这样即使策略插值有一步偏差,也不会漏判。

4.4 症状四:把状态维度从二维提到四维以后,内存直接爆掉

现象:运行 4D 网格求解时,程序开始几秒内就被操作系统杀掉,终端提示 OOM(out of memory)。

原因:水平集方法本质上是网格方法,内存随维度指数增长。假设每维 50 个点,4D 网格就有 625 万个点,值函数加上梯度就是 25 倍的内存开销,个人机器扛不住。

解决:这个场景下别再死磕水平集,换 difgames 的神经网络求解器。它用神经网络参数化值函数,不再需要显式网格,内存占用从指数级变成线性级。代价是收敛时间变长,且需要调训练步数,后面第 5 章会讲怎么验证神经网络求解器的结果靠不靠谱。

4.5 症状五:调参像玄学,改了奖励权重结果策略完全相反

现象:把 costate 里距离度量的权重从 1.0 改成 0.5,原本正常追逐的轨迹变成了原地打转,或者追踪者开始远离逃逸者。

原因:这不是求解器 bug,而是极小极大方向搞反了。在零和博弈设定下,追踪者走的是值函数下降方向,逃逸者走的是上升方向。权重一改,值函数的鞍点位置变了,如果只调 P1 的权重而没同步调 P2 的对抗目标,两边会落到错误的平衡上。

解决:每次调权重时,同时调整双方代价的相对符号。一个可靠的口诀是:验证“只让一方活动”的退化情形。把逃逸者速度设为 0,这时候问题退化为标准最优控制,如果追踪者策略还不合理,说明你的建模或权重有问题,而不是博弈逻辑有问题。我每次改完代价都会先做这个退化测试,再放开对抗。

5. 别信眼睛,用两个低成本方法验证策略真的可靠

可视化能让问题“看起来解决了”,但不能替代数值验证。我拆 difgames 到最后,发现两个不需要额外依赖的验证手段价值最高。第一个是 Hamiltonian 残差检验。按理说,HJI 方程解出来的值函数代入方程后,残差应该处处接近零。但数值解在网格稀疏区域和边界附近会给明显偏差。做法是重写一个脚本,在求解完成后,对每个网格点计算 Hamiltonian 的左右两侧,取绝对值均值。这个数低于 1e-2,基本可以放心;如果到了 1e-1 级别,说明解析解在边角有不可忽略的误差,策略提取前需要做平滑处理。第二个验证是蒙特卡洛采样,但关键不在于跑多少条轨迹,而是把每条轨迹的最终状态和值函数预测值做对比。值函数给出的最优支付是理论值,蒙特卡洛仿真给出的是实际分布。如果 30 次随机初值仿真里,平均最终距离比值函数预测大很多,通常说明网格太粗或策略提取产生了量化误差。

从那次吃过亏以后,我给自己定了个习惯:不管换问题还是调参数,先跑退化测试,再算 Hamiltonian 残差,最后才看可视化。三步都过了才敢把策略用到下一个环节。这个习惯帮我挡掉过好几次看着漂亮实则失效的仿真结果。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询