
简介这是一份面向博弈论、控制理论与计算数学研究者的微分博弈开源实现围绕哈密顿-雅可比-贝尔曼-伊萨克斯HJBI方程提供连续时间动态系统中最优策略与纳什均衡的求解代码。资源共9个文件、约206KB以C语言源程序为主包含求解器实现、图形显示接口和编译脚本另有PDF文档说明优化方法适合在Linux环境下编译运行并观察博弈过程。已有237人学习下载。对希望理解HJBI数值求解、尝试车辆追逃等经典场景的开发者而言可借助源码中的有限差分或有限元近似思路配合可视化模块验证算法效果同时通过脚本快速构建可执行程序降低复现门槛。整体兼顾理论背景与工程实践也可迁移到自动驾驶、经济决策或军事策略等动态博弈应用中。 做过机器人避障、导弹制导或者无人艇护航的人大概率都遇到过同一个魔幻场景你辛辛苦苦设计了一个最优控制器结果对方也最优地跑而且跑得比你算得还快。追的一方想最小化脱靶量逃的一方想最大化脱靶量两边都不是静态目标这就成了控制论里一类有名的问题——微分游戏differential games。difgames 是一个围绕微分游戏的开源项目它把这类问题从论文里的 Hamilton-Jacobi-Isaacs 方程变成一组可以直接安装、直接跑例子的 Python 接口。这篇文章我会从追逃博弈的数学骨架讲起拆一下 difgames 这类库的核心抽象和数值格式再用一个一维追逃实例把求解、可视化和调参的完整链路过一遍最后聊几个你在开源社区里真正常见、但文档里永远不会写的坑。适合两类人看一类是刚接触微分游戏、想快速有个可跑 demo 的初学者另一类是已经在网格法里挣扎、想找开源实现做二次开发的工程师。1. 微分游戏到底在研究什么追、逃和那条 HJI 方程1.1 追逃博弈不是石头剪刀布很多人第一次听到微分游戏会以为是把石头剪刀布这种离散博弈搬进连续时间。实际上完全不是一回事。追逃场景的典型设定是有一个状态向量 x(t)它代表追击者和逃逸者的相对位置、相对速度等等。双方各自有连续的控制输入追击者用 u逃逸者用 v状态按照一组微分方程演化dx/dt f(t, x, u, v)控制输入通常还有约束比如 u ∈ [umin, umax]v ∈ [vmin, vmax]对应现实中马达转速、舵面偏角之类的物理限制。问题本身的胜负则由终端代价 g(x(T)) 定义如果末端状态落进某个集合追击者得分比如捕获成功记 -1否则逃逸者得分记 0。追击者想让 g 尽量小逃逸者想让 g 尽量大。这里的关键词是反馈策略双方不是开局前各押一个控制序列然后一路开到底而是每一时刻都能观测当前状态再决定此刻怎么控制。所以策略本身是一个函数 u(t, x)不是一条固定轨迹。这就让问题的复杂度从找一条最优轨迹变成了找一个最优映射策略空间瞬间变成无限维。这也是为什么微分游戏比普通最优控制难一截差的那一截恰好就体现在数学结构上。1.2 从动态规划到 HJI 方程如果你熟悉最优控制应该记得 HJB 方程从动态规划原理出发把最优控制问题变成一个偏微分方程。微分游戏只是把这个思路往前推了一步因为两个玩家每一时刻都在做决策动态规划里的那个最小化就变成了一个嵌套的最小化再最大化。定义价值函数 V(t, x)它表示从状态 x 出发、距离终端还有 T-t 时间且双方都采取最优策略时最终代价的博弈值。一个标准推导会给出V_t min_u max_v [ ∇V · f(t, x, u, v) ] 0, V(T, x) g(x)这个式子叫 Hamilton-Jacobi-Isaacs 方程也就是 HJI 方程。和 HJB 只含一个 min 不同HJI 里 min 和 max 是嵌套在一起的这个鞍点结构是整个问题的灵魂。实际工程项目的源代码里求解微分游戏花大力气处理的所谓 Hamiltonian 项就是上面括号里那个 min_u max_v。我个人的理解是可以把 V(t, x) 想象成一张地形图低洼区是追击者能赢的状态集合高原区是逃逸者能稳住的区域。HJI 方程描述的就是这张地形图随时间怎么被侵蚀或堆积。真实代码里数值求解器、网格、边界条件全部是在为这张图的演化服务。1.3 它和普通最优控制的差别单智能体最优控制里只有 minHJB 方程通常可以用反向传播式的方法稳定求解。微分游戏多了 max 这一层整个数值性质就变了哈密顿量不再单调解里更容易出现角点、尖峰也就是所谓的粘度解范围。网格法里常用的 Lax-Friedrichs、Godunov 格式本质上都是为了保证在这个非线性哈密顿量下还能稳定收敛到正确的粘度解。另外一个有工程味道的结论是双方都完美观测对方状态时博弈结果是悲观的——你的值函数假设逃逸者每一刻都在做对你最不利的决策。这让最终算出来的胜算集合非常保守但反过来也意味着极强的鲁棒性。做机器人路径规划的时候我经常把这个性质用一句话给同事讲算出来能抓那就一定能抓算出来抓不到也别奇怪。这句话在开源库的 example 里经常被隐藏得很深但它是理解所有跑出来的图的钥匙。2. difgames 的核心抽象与代码结构把 HJI 落成可运行的类2.1 一个微分游戏库的包结构拿 difgames 这类开源项目举例目录结构通常不会太复杂。核心思路是把问题描述和数值求解彻底分开。一般会长这样difgames/ ├── core.py # Game、Player、Region 等核心类 ├── solvers/ │ ├── hji_grid.py # 网格法 HJI 求解器 │ └── neural.py # 神经网络近似求解器可选 ├── examples/ │ ├── pursuit_1d.py │ └── pursuit_2d.py ├── plotting.py ├── pyproject.toml └── README.md这个结构乍看平平无奇我实际用过之后觉得它最大的好处是examples 目录本身就是活文档。很多开源项目的 README 写得天花乱坠真正能说服人的还是pursuit_1d.py这种十五秒钟就能跑起来的脚本。微分游戏这个方向本来就冷门用户第一眼看到的一定是我能不能最快跑通一个例子而不是你的抽象基类设计得多精妙。2.2 用类建模玩家而不是用一堆散参数我见过不少竞赛代码把控制上界、下界散落在各个函数签名里改起来非常痛苦。difgames 这类库更倾向把玩家建模成独立对象。一维追逃的典型定义长这样from difgames import Game, Player pursuer Player( namepursuer, control_bounds[(-1.0, 1.0)], # 控制输入 u 的边界 ) evader Player( nameevader, control_bounds[(-1.5, 1.5)], # 控制输入 v 的边界 ) game Game( dim1, dynamicslambda t, x, u, v: u[0] v[0], # dx/dt players[pursuer, evader], terminal_costlambda x: -1.0 if abs(x) 1.0 else 0.0, horizon1.0, )这一段代码几乎把第一节里的数学符号全部装进去了dynamics是 f(t,x,u,v)terminal_cost是 g(x)horizon是 T而每个玩家的控制边界则直接决定了 Hamiltonian 里那个鞍点怎么算。这里的核心设计点是控制边界必须挂在玩家身上而不是挂在 Game 上。因为 HJI 方程里的 min_u max_v 是针对每个玩家各自的控制集合做运算的如果把边界混在一起2D、3D 状态多了之后非常容易搞错哪一组界属于谁。我在二次开发时候就踩过这种坑把追击者的加速度上限写进了逃逸者的配置算出来的胜算集合离谱得感人。2.3 为什么动力学是一个普通函数就够了很多初学者会期待一个庞大的继承体系AbstractDynamics、VelocityModel 之类的抽象基类层层套娃。我的看法是微分游戏的难点根本不在动力学建模而在数值求解。动力学写成lambda t, x, u, v: u[0] v[0]这种普通函数最直接的好处是容易单测、容易替换也方便你在求解之前先把纯运动学逻辑验证一遍。这个库真正值钱的是 solver。网格法求解器拿到 Game 对象之后会自动铺网格、给终端代价做离散化、然后沿时间反向推进 HJI 方程。你需要关心的只是问题定义写对没有数值参数网格密度、时间步长稳不稳以及最后结果怎么从 V 0 的集合里读出控制策略。3. 一维追逃实例求解、可视化和结果怎么解读3.1 先算一个能用解析解校验的问题我实际调试微分游戏代码时有一个习惯永远先做一个一维例子因为一维存在闭式解能快速判断求解器方向对不对、数值稳不稳。这里用第一节那个一维模型状态 x ∈ [-6, 6]动力学 dx/dt u v追击者 u ∈ [-1, 1]逃逸者 v ∈ [-1.5, 1.5]终端代价|x| ≤ 1 时记 -1否则记 0回望时长 T这个例子的解析结论很干净逃逸者比追击者快相对逃逸速度 c 1.5 - 1.0 0.5所以从终端时刻往回看追击方的必胜区间会以每单位时间 0.5 的速度收缩。设回望时长为 τ胜算区间就是 [-1 0.5τ, 1 - 0.5τ]。当 τ 1 时区间是 [-0.5, 0.5]。这意味着什么如果追击者一开始离目标点超过 0.5 个单位而逃逸者聪明地往外跑追击者靠自己的力量是追不上的。这个反直觉的结论非常有用控制权限的微小差距会直接换算成空间上的胜算半径。3.2 核心求解代码Lax-Friedrichs 风格的一阶格式网格法求解 HJI 方程的细节很多但核心循环其实可以压缩到二十行以内。我参考 difgames 常见实现风格写了一个最小可用版本import numpy as np def solve_1d_evasion(grid_points401, dt0.001, horizon1.0): xmin, xmax -6.0, 6.0 dx (xmax - xmin) / (grid_points - 1) x np.linspace(xmin, xmax, grid_points) # 终端代价落进半径 r1 的区间算捕获 r 1.0 V np.where(np.abs(x) r, -1.0, 0.0).astype(float) # 净逃逸速度逃逸方上限 1.5追击方上限 1.0 c 1.5 - 1.0 d_left np.zeros_like(V) d_right np.zeros_like(V) steps int(horizon / dt) for _ in range(steps): # d_left[i] (V[i] - V[i-1]) / dx后向差分 d_left[1:] (V[1:] - V[:-1]) / dx # d_right[i] (V[i1] - V[i]) / dx前向差分 d_right[:-1] (V[1:] - V[:-1]) / dx # 边界用零法向导数够用 d_left[0] d_left[1] d_left[-1] d_left[-2] d_right[0] d_right[1] d_right[-1] d_right[-2] # Lax-Friedrichs 形式的价值函数更新 # V_tau c * |V_x|tau 是从终端往回算的时间 flux 0.5 * (np.abs(d_left d_right) - (d_right - d_left)) V dt * c * flux return x, V这里最需要留意的其实是符号方向。HJI 方程在时间回推形式下是 V_τ c |V_x|所以代码里用的是V dt * c * flux不是减号。如果写成减号你会非常成功地看到一个往外膨胀的必胜区间然后陷入三个小时的迷茫。这是我在所有微分游戏代码里见过的最常见 bug没有之一。3.3 结果解读V 0 的集合才是你关心的东西把上面代码跑完画出来就是一条从 -6 到 6 的曲线在目标区间附近会出现一个明显低谷。注意值函数的具体数值大小没有绝对意义V 0 才是追击者的必胜区域。数值为 -0.8 还是 -0.2只代表值函数编码方式不同判断标准始终是负号。我用这个一维问题做了几组实验和解析解对比结果如下回望时长 τ解析必胜区间网格法读出的 V0 区间实测约0.0[-1, 1][-1.00, 1.00]0.5[-0.75, 0.75][-0.75, 0.75]1.0[-0.5, 0.5][-0.50, 0.50]2.0收缩成单点 x0数值上只剩非常窄的区间实测下来只要 CFL 条件满足网格法读出的边界和解析解基本能对上误差在一两个网格间距以内。这一步校验通过之后你再往 2D 走就会安心很多。3.4 初始化、边界、符号最容易翻车的三个点第一个是终端代价的离散化。abs(x) 1.0直接作用在网格点上如果网格点没有恰好落在 x ±1 上你实际得到的不是一个精确的半径而是一个略大或略小的多边形近似。要不要在意取决于你的精度需求但至少心里有数。第二个是边界条件。有限时域问题里边界处我习惯用零法向导数也就是让最外面两格的值保持一致。这个处理方式简单稳定能避免边界上出现虚假的反射波。做长时域问题或者周期边界条件的问题时这个位置需要专门处理不能照抄。第三个是验证方法。我强烈建议所有初学者拿到一个微分游戏库之后先跑一维解析解能算的场景再跑自己的实际问题。如果你看到胜算集合在逃逸者更快时反而膨胀先检查符号再检查 Hamiltonian 定义最后检查边界。这套排查顺序能省掉大量时间。4. 从一维到二维维度爆炸、求解器取舍与工程化思路4.1 把一维例子改成二维二维追逃的 Game 定义几乎是一维的自然推广动力学从标量变成向量终端代价从区间变成圆形import numpy as np pursuer Player( namepursuer, control_bounds[(-1.0, 1.0), (-1.0, 1.0)], ) evader Player( nameevader, control_bounds[(-1.5, 1.5), (-1.5, 1.5)], ) game Game( dim2, dynamicslambda t, x, u, v: np.array([ u[0] v[0], u[1] v[1], ]), terminal_costlambda x: -1.0 if np.linalg.norm(x) 1.0 else 0.0, horizon1.0, )看着也就是把一维的表达式复制了一份。但真正一跑你就会撞上微分游戏的天花板网格法复杂度是随维度指数上升的。一维 401 个网格点循环 1000 步毫秒级跑完二维如果每维 201 个点就是四万个状态点加上二维差分、哈密顿量计算内存和耗时开始肉眼可见地上涨。到四维、六维——比如追一个三维空间的飞行器还要考虑速度状态——均匀网格直接不现实。这就是常说的维度灾难curse of dimensionality。4.2 网格法之外的开源路线神经网络近似近几年开源社区里另一个值得关注的方向是用神经网络逼近 HJI 解比如围绕 DeepReach 的一组方案。它的思路不再是把状态空间切网格而是把 V(t,x) 参数化为一个 MLP用两类损失训练import torch class ValueNet(torch.nn.Module): def __init__(self): super().__init__() self.net torch.nn.Sequential( torch.nn.Linear(3, 256), torch.nn.Tanh(), torch.nn.Linear(256, 256), torch.nn.Tanh(), torch.nn.Linear(256, 1), ) def forward(self, t, x): return self.net(torch.cat([t, x], dim-1))训练时从状态空间和时间区间里采样大量 (t, x) 点对终端时刻要求网络输出逼近 g(x)对内部时刻则要求自动微分求出的 V_t 和 ∇V 满足 HJI 残差。这个方法牺牲了网格法的精确保证换来了向高维扩展的可能性。做实际部署的时候我见过不少团队是双轨并行的低维用网格法做高精度验证高维用神经网络先出一个大概的形状再用轨迹优化做局部修正。对 difgames 这类项目来说接口层的可插拔设计非常重要。Game 对象只描述问题solve_hji和solve_neural是不同的后端但返回的 Solution 结构一致。这样你换求解器不需要改问题定义心态上也能接受先跑通、再调精度的开发节奏。4.3 评估一个微分游戏库的五个标准如果你准备在项目里引入某个开源微分游戏库我一般会按下面这张表快速过一遍评估点要看什么动力学灵活性能否自定义任意 f(t,x,u,v)还是只支持固定的几类运动学数值格式是否迎风/Godunov/LF 这类稳定格式还是随手写的中心差分边界处理有没有系统性的边界条件方案还是只在例子里碰巧不出错可视化与示例是否附带可跑的 1D/2D 例子能否几秒钟内看到结果License 与维护许可证是否允许商用issue 响应是否活跃这五条里我最看重的其实是是否有可跑的 1D 例子。因为微分游戏求解器涉及太多数值细节如果一个库连一维解析解都不敢放出来给你的跑你没法相信它在二维不会悄悄出问题。5. 从使用者到维护者开源微分游戏库的协作姿势5.1 文档和示例是成本最低的贡献入口很多人觉得给开源项目贡献代码一定要写 solver、改核心算法其实完全没必要。README 里的一段 Quickstart、examples 目录下一个新的 2D 场景价值往往比某个性能优化更大。微分游戏本来就偏学术、偏冷门新用户最大的障碍是不知道这库能干什么、怎么开始跑。我自己维护过几个开源项目最受欢迎的 PR 从来不是那种一上来就改核心循环的而是把某个晦涩参数用注释讲清楚、补一个最小例子。开源文档贡献看着不起眼但它确实是把一个库从自己能用推向别人也能用的关键动作。5.2 License、版本和可复现性微分游戏库通常跑不了太少依赖numpy、scipy、matplotlib 几乎是标配。开源之后如果还想让大家放心用有三件事必须做选一个明确的 License。MIT 和 Apache-2.0 是控制/机器人圈最常见的两种前者更简洁后者多一条专利授权条款。国内代码托管平台上经常有人问开源许可证选什么我的建议是别自己发明直接从 MIT 和 Apache-2.0 里挑一个然后让律师确认商用场景是否接受。用 pyproject.toml 固定构建配置把依赖范围写清楚避免出现在我机器上能跑的尴尬。在 CI 里跑完整的测试矩阵顺手接一个静态检查工具。代码审计不光是安全团队的事对数值库来说早期发现未使用的变量、不一致的类型标注能避免很多低级 bug。这几点做完你的开源微分游戏库才算有了最基本的工程信誉。否则别人下载下来第一步装依赖就失败再好的算法也留不住用户。5.3 提 Issue 的最小复现心法最后聊一个实际协作中反复出现的问题有人提 issue 说求解结果不对值函数变成 NaN 了然后什么参数都没给。这种 issue 基本没法查。正确做法是给一个最小复现脚本包含Python 版本、库版本、安装方式Game 的完整定义包括控制边界和终端代价网格点数、时间步长、回望时长期望行为和实际行为的对比如果你用的是我上面那种一维例子最好把解析解也算出来一起贴上去期望区间 [-0.5, 0.5]网格法给出 [-0.5, 0.51]差了一个网格间距。这种信息量维护者看一封邮件就能定位问题。反过来自己在本地调试时也建议照着这个清单自查一遍——很多时候问题不是库坏了是终端代价离散化或者边界处理没写对。我在实际使用中的体会是微分游戏库最大的价值不是那把所谓的高级算法而是把一个复杂的 HJI 问题拆成了玩家、游戏、求解器三个清晰的部分。无论你是想快速验证一个追逃场景还是想在无人系统里落地一个鲁棒控制器先在一维解析解上把符号、方向、稳定性全部校验一遍再往二维、三维走永远是最稳的路径。那些看起来平平无奇的 1D 例子往往才是你整个项目真正的地基。本文还有配套的精品资源点击获取