Java版逆强化学习实战:从专家轨迹反推奖励函数

发布时间:2026/10/25 13:44:45
Java版逆强化学习实战:从专家轨迹反推奖励函数

简介:面向逆向强化学习(IRL)研究者的 Java 示例代码包,以 BURLAP 代码库快照为基础,针对 IRL 框架创建进行了必要修改,适合具备一定强化学习基础、希望从代码层面掌握逆向强化学习算法设计的中高级学习者与开发人员。压缩包共 497 个文件,其中 484 个为 Java 源文件,覆盖网格世界、单阶段正规式博弈、学徒学习等典型场景,另有少量 XML 配置、Maven 工程文件及三个依赖包,整体大小仅 2.29MB,结构紧凑便于按类名检索。已有 1046 人查看学习,可视为从理论到代码实现的过渡参考。资源内含多智能体性能绘图工具、标准博弈机制实现,以及基于 BURLAP 修改版框架可直接运行的示例;读者既能运行对比标准强化学习与逆向强化学习的行为差异,也能通过修改奖励函数与正则项来复用这些代码,搭建自己的 IRL 实验环境。

1. 逆强化学习入门:一套能跑通的 Java 版 IRL 示例代码

我在做 Java 技术栈的行为建模项目时,需要从一批专家轨迹里反推用户的习惯性偏好,这时候才发现逆强化学习(Inverse Reinforcement Learning,IRL)落地有多难:网上能找到的 IRL 示例代码几乎全是 Python,想在 JVM 里跑通一个能改特征、能逐行看中间结果的实现,基本得从论文自己造轮子。这套 IRLTutorial 示例代码正好补上缺口——它用 Java 实现了完整的网格世界 IRL 流程,从特征设计、专家轨迹加载到最大熵 IRL 求解奖励函数全部跑通。适合想在 Java 工程里做轨迹奖励推断的工程师,也适合把 IRL 当黑匣子但想拆开验证的算法从业者。新手能照着 demo 一步步复现,熟手可以直接换掉特征提取器,把算法搬到自己的状态空间上。

2. IRL 原理与 Java 选型:奖励函数为什么值得反推

2.1 从专家轨迹反推奖励函数:IRL 的问题定义与建模思路

先把问题框架搭起来。标准强化学习里,我们在一个马尔可夫决策过程(MDP)上求解最优策略,MDP 的五元组是状态集合、动作集合、状态转移概率、折扣因子和奖励函数。IRL 把这个问题倒过来:状态、动作、转移概率、折扣因子都是已知的,唯独奖励函数未知,而我们手里有一批专家生成的轨迹,每条轨迹是一串“状态-动作”序列。

奖励函数未知,意味着我们只能从行为反推动机。用网格世界举例:一个机器人从起点走到目标点,专家轨迹里它总是绕开障碍物、走最短路径、不在某个角落停留。这批轨迹背后一定隐含着一个奖励函数——到达目标给正分、撞障碍给负分、每走一步扣一点时间成本。IRL 要做的就是把这个隐含的奖励函数恢复出来,而不是直接学习一个策略。

这里有个关键问题,也是 IRL 和模仿学习的本质区别:模仿学习学的是“专家遇到什么状态做什么动作”,IRL 学的是“专家为什么这么做”。一旦把奖励函数恢复出来,我们就能把它迁移到新场景——换一张地图、换一批障碍物,奖励函数不变,重新做策略规划即可。这正是我在项目里需要它的原因:业务环境经常变,但用户的偏好结构相对稳定。

IRL 之所以难,是因为解不唯一。给定同一批专家轨迹,存在无穷多个奖励函数都能让专家策略变得最优。比如把奖励函数整体加上一个常数,策略完全不变;再把经过某个状态的奖励整体抬高,只要不改变相对排序,轨迹依然最优。所以任何 IRL 算法都得加上一个选择准则,这也是 2.2 里选型时最重要的权衡点。

另一个核心概念是特征期望(feature expectation)。如果奖励函数被参数化为特征的线性组合,即 R(s, a) = w · φ(s, a),那么专家轨迹落在一个策略 π 下,特征期望就是 E[Σ γ^t φ(s_t, a_t)]。IRL 的常用思路就是找一个权重向量 w,使得专家策略下的特征期望尽可能接近某个最优策略下的特征期望。这份教程代码里,特征期望的计算贯穿了整个算法链路,是第一个需要吃透的模块。

2.2 为什么在 Java 生态里跑 IRL:选 MaxEnt 作为主算法的理由

先面对现实:IRL 的主流实现集中在 Python,基于 NumPy 和 SciPy,Java 生态里既没有统一的 IRL 库,也缺少开箱即用的轨迹处理工具。但很多实际工程侧的轨迹数据恰恰存在 JVM 这边的业务系统里,日志解析、数据清洗、特征拼接都已经跑在 Java 或 Scala 管线里。在这种情况下,把 IRL 教程代码用 Java 重写,不单是为了学习,更是为了能直接嵌入现有工程链路。

选最大熵逆强化学习(MaxEnt IRL)作为主算法,是工程复杂度与建模能力的折中。早期的特征匹配 IRL 靠迭代调整权重、让专家特征期望与当前策略特征期望逐步对齐,实现简单,但它对轨迹分布做了一个很强的假设:所有满足特征匹配的策略都被同等对待。结果是它恢复的奖励函数往往振荡,而且不唯一。

MaxEnt IRL 换了一个思路:在所有特征期望与专家匹配的策略中,选择熵最大的那一个。熵最大意味着我们不做任何额外假设,对不知道的信息保持最大的不确定性。这个假设在数学上对应着轨迹概率服从指数族分布,P(τ) ∝ exp(R(τ)),而奖励函数 R(τ) = Σ w · φ(s_t, a_t)。这样一来,目标函数有了明确形式,梯度也能写解析解,非常适合在 Java 里手工实现。

四种常见 IRL 算法的对比关系大致如下:

算法目标函数对专家轨迹的假设工程复杂度Java 实现成本
特征匹配 IRL特征期望差值最小化专家策略最优低低,但易振荡
最大熵 IRL熵最大化 + 特征期望匹配轨迹按奖励指数分布中中,梯度闭式
贝叶斯 IRL奖励后验最大化需要先验分布较高高,需要 MCMC 采样
深度 IRL神经网络参数化奖励需要大规模数据高高,依赖自动微分

教程选 MaxEnt IRL,是因为它在 JVM 上只需要手写几个核心类:特征提取器、软价值迭代、梯度下降,不依赖深度学习框架,也没有采样库。同时它的每一个中间量都有直观意义——特征期望差、轨迹似然、策略熵——排错时能一步步打印出来看,而不是面对一个黑匣子。

还有一点值得注意:这套代码没有盲目追求 SOTA 算法。深度 IRL 在复杂视觉任务里更强,但它更适合有 GPU、有大数据量的场景,放在教程代码里只会让读者迷失在自动求导的堆栈里。MaxEnt IRL 在网格世界这种小状态空间里,能跑出清晰可验证的结果,这恰恰是示例代码最该承担的角色:把原理讲透、把代码跑通、把坑暴露出来。

3. 代码包结构与核心模块:先看懂类再动手

3.1 目录结构与核心类

拿到这套示例代码,第一步不是急着运行,而是把代码包结构过一遍。这套 Java 版 IRLTutorial 的常见组织方式是按算法职责分包,每个核心类只做一件事。目录结构大致长这样:

irl-tutorial/ ├── src/main/java/irl/ │ ├── mdp/ │ │ ├── GridWorld.java # 网格世界环境定义 │ │ ├── State.java # 状态对象 │ │ └── Action.java # 动作枚举:上、下、左、右 │ ├── feature/ │ │ └── FeatureExtractor.java # 特征提取器,状态转特征向量 │ ├── algo/ │ │ ├── MaxEntIRL.java # 最大熵 IRL 主算法 │ │ ├── SoftValueIteration.java # 软价值迭代,算软 Q 值 │ │ └── GradientDescent.java # 梯度下降更新权重 │ ├── data/ │ │ └── TrajectoryLoader.java # 读取专家轨迹文件 │ └── demo/ │ └── RunGridWorld.java # 可直接运行的 demo 入口 ├── src/main/resources/ │ ├── data/ │ │ └── expert_trajectories.txt # 专家轨迹数据 │ └── config/ │ └── irl.properties # 算法参数配置 └── README.md

先看 mdp 包。网格世界是最小可复现的 IRL 实验环境,状态数量少、转移概率明确、动作空间只有四个方向,任何一步计算结果都能人工验算。State 只是一个包含行列坐标的对象,Action 是枚举类型,GridWorld 负责给出每个状态下可执行的动作和转移后的状态。这三个类加起来不到一百行,但它们是整个算法的地基。

feature 包里只有一个 FeatureExtractor,这是整个代码里最值得改的类。MaxEnt IRL 假设奖励是特征的线性组合,特征定义直接决定恢复出来的奖励函数长什么样。默认实现用的是状态独热编码,这个选择我会在 3.2 里展开讲。

algo 包是核心。SoftValueIteration 负责在给定奖励权重的条件下计算软价值函数和策略分布;MaxEntIRL 负责拼装主流程:算专家特征期望、循环做软价值迭代、算当前策略特征期望、算梯度、更新权重;GradientDescent 是独立的优化器,把权重更新逻辑从算法主流程里剥出来,方便替换成 Adam 或其他优化器。

demo 包里的 RunGridWorld 是入口类,它做了三件事:加载配置参数、读取专家轨迹、调用 MaxEntIRL 求解并把结果打印到控制台。核心类的职责划分有三个标准,新手可以拿这三个标准对照自己写的代码:第一,每个类是否能独立测试;第二,特征提取、策略迭代、权重更新是否互相解耦;第三,换一个特征设计时是否只需要动 feature 包。

3.2 把轨迹变成特征期望:FeatureExpectation 的计算路径

特征期望是 IRL 里被误解最多的概念。简单说,它是一条轨迹在折扣累积意义下的特征向量和的平均。每条轨迹由一串状态-动作对组成,每个状态-动作对通过 FeatureExtractor 转成一个向量,累加并按折扣因子衰减,最终得到这条轨迹的特征向量。所有专家轨迹的特征向量求平均,就是专家经验特征期望,也就是算法要匹配的目标。

这套示例代码里的特征提取器,默认实现是状态独热编码。看核心代码:

public class FeatureExtractor { private final int rows; private final int cols; public FeatureExtractor(int rows, int cols) { this.rows = rows; this.cols = cols; } // 将状态映射为 one-hot 向量,维度等于状态总数 public double[] extract(State state, Action action) { double[] feature = new double[rows * cols]; int idx = state.getRow() * cols + state.getCol(); feature[idx] = 1.0; return feature; } public int featureDimension() { return rows * cols; } }

独热编码的含义很直白:每一个网格位置对应一个独立的奖励参数,算法要学的是“走到哪个格子能得多少分”。这种特征设计有两个好处:一是维度可控,网格世界十乘十也就一百维,梯度下降完全扛得住;二是可解释性强,训练结束后打印出权重矩阵,直接就能看出哪些格子是算法认为的好位置、哪些是坏位置。

但实际业务场景里,独热编码往往不够。常见做法是叠加领域特征,比如目标点的距离、障碍物的邻近程度、历史轨迹在该状态的出现频率。我一般会把 extract 方法改成拼接特征——前 N 维保留独热编码,后面追加几个连续值特征,这样既能保持可解释性,又给算法补充了泛化信息。

接下来是特征期望的计算。一个标准实现是在 TrajectoryLoader 加载完轨迹后,逐条轨迹累加特征并按 γ^t 折扣,最后除以轨迹总数。代码大致长这样:

public double[] computeExpertFeatureExpectation(List<Trajectory> trajectories, FeatureExtractor extractor, double gamma) { double[] expectation = new double[extractor.featureDimension()]; for (Trajectory trajectory : trajectories) { double discount = 1.0; for (Step step : trajectory.getSteps()) { double[] feature = extractor.extract(step.getState(), step.getAction()); for (int i = 0; i < feature.length; i++) { expectation[i] += discount * feature[i]; } discount *= gamma; } } for (int i = 0; i < expectation.length; i++) { expectation[i] /= trajectories.size(); } return expectation; }

注意这里除以的是轨迹条数而不是总步数。为什么要除以轨迹条数?因为特征期望的定义建立在轨迹分布上,每条轨迹是一个独立样本,加权平均后才有“期望”的含义。如果把步数也平均进去,不同长度的轨迹会对结果产生不同程度的稀释,最终的期望值就失去了概率解释。代码里的 gamma 是折扣因子,默认取 0.95 左右,代表未来收益的衰减速度。gamma 越大,轨迹后期的特征对期望的贡献越大;网格世界这种有限步任务里,gamma 太小会过度强调初始几步,导致学习出来的奖励函数只看眼前。

3.3 跑通最小示例:网格世界里的轨迹反推

环境准备以 JDK 8 以上即可,不需要第三方依赖,标准的 Javac 和 Maven 都能编译。数据文件 expert_trajectories.txt 的格式通常是每行一条轨迹,每一步用“状态索引:动作索引”表示,状态索引按行优先编排。数据文件清单大致是这样:

文件作用格式
expert_trajectories.txt专家轨迹,训练输入每行一条轨迹,步间冒号分隔
irl.properties算法超参数key=value,包含 gamma、beta 等
README.md代码说明与运行指引Markdown 文本

跑通最小示例的命令是标准的 Maven 执行方式:

mvn compile mvn exec:java -Dexec.mainClass=irl.demo.RunGridWorld

运行后控制台会输出每一轮迭代的梯度范数、特征期望误差和当前权重。如果一切正常,你会看到特征期望误差逐步下降,最终权重矩阵里目标点附近出现明显高值,障碍物附近出现低值。这个结果本身就是对算法的验证:如果学出的权重和专家轨迹的偏好不一致,第一步不是调参数,而是回去核对特征提取器的索引映射。

irl.properties 里最关键的参数是 gamma 和 beta,这两个参数的作用我会在下一章拆解。这里要提示的一点是,跑 demo 时不要一上来就改参数,先用默认配置跑通,再逐个变量调。这个项目的核心价值是作为一个可视化 IRL 流程的样例来实现,如果一开始就陷入调参,反而错过了主线。

4. 核心算法实现:最大熵 IRL 的梯度计算与参数细节

4.1 目标函数与梯度:从轨迹概率到特征期望差

MaxEnt IRL 的目标函数不是直接最大化轨迹似然,而是在特征期望约束下最大化熵。这个约束优化的对偶问题,最终可以转化为一个无约束优化问题:最大化对数似然 L(w) = log P(τ | w),其中轨迹概率按指数族分布建模。

推导过程这里不展开,直接看结论:目标函数对权重 w 的梯度,等于专家经验特征期望减去当前策略下的特征期望。这个公式是整个算法的灵魂:

public class MaxEntIRL { private final FeatureExtractor extractor; private final GridWorld world; private final double gamma; private final double beta; public MaxEntIRL(FeatureExtractor extractor, GridWorld world, double gamma, double beta) { this.extractor = extractor; this.world = world; this.gamma = gamma; this.beta = beta; } // 梯度 = 专家特征期望 - 当前策略特征期望 public double[] computeGradient(double[] expertExpectation, double[] currentExpectation) { double[] gradient = new double[expertExpectation.length]; for (int i = 0; i < gradient.length; i++) { gradient[i] = expertExpectation[i] - currentExpectation[i]; } return gradient; } }

这个梯度公式的直觉非常清晰。某一个特征维度上,如果当前策略的特征期望大于专家特征期望,说明当前策略在该特征上“过度使用”,梯度为正,更新时会降低对应的奖励权重;反过来,如果当前策略特征期望小于专家,梯度为负,权重会上升,算法会鼓励策略在该特征上多走。整个过程就是一个不断把当前策略往专家行为方向拉的过程。

代码里参数 beta 是 softmax 温度参数。为什么梯度公式里没出现 beta?因为 beta 被嵌进了软价值迭代的策略计算里,它决定了当前策略分布被拉得多“陡”。beta 越大,策略越接近贪婪;beta 越小,策略越接近均匀分布。在后面的软价值迭代里,beta 会直接参与 log-sum-exp 计算,影响每一轮的特征期望估计。

更新权重时,标准做法是梯度上升 L(w),因为我们要最大化对数似然。一个常见写法是:

weights[i] += learningRate * gradient[i];

这个学习率不要设太大。网格世界的特征是独热编码,特征期望的数值范围在 0 到 1 之间,梯度范数本身不大,但权重经过多轮累加后会持续增长。学习率取 0.1 左右是安全的起步值,调参时应该优先观察梯度范数是否振荡,而不是只看权重绝对值。

4.2 策略迭代与特征期望估计:软价值迭代的实现细节

梯度公式里的“当前策略特征期望”不是现成的,需要先算出当前权重下的策略分布,再按策略分布计算状态-动作对的占用度量。在网格世界这种有限状态空间里,标准做法是用软价值迭代求解软贝尔曼方程。所谓“软”,就是在计算价值时用 log-sum-exp 代替 max,让策略保持随机性。

看一下软价值迭代的核心代码:

public class SoftValueIteration { private final GridWorld world; private final FeatureExtractor extractor; private final double gamma; private final double beta; public double[] iterate(double[] weights, int maxIterations) { int numStates = world.numStates(); double[] value = new double[numStates]; for (int iter = 0; iter < maxIterations; iter++) { double[] nextValue = new double[numStates]; for (State state : world.getStates()) { double sumExp = 0.0; for (Action action : world.getActions(state)) { State next = world.transition(state, action); double reward = computeReward(weights, state, action); double qValue = reward + gamma * value[next.getId()]; sumExp += Math.exp(beta * qValue); } // 软价值 = (1/beta) * log(sum(exp(beta * Q))) nextValue[state.getId()] = (1.0 / beta) * Math.log(sumExp); } value = nextValue; } return value; } }

软价值迭代里有两个隐藏的数值陷阱。第一个是 Math.exp(beta * qValue) 溢出风险。当 beta 取 10 以上且 qValue 较大时,指数结果直接变成 Infinity。常见做法是做数值稳定化:先减去最大值再算 exp。也就是改成 maxQ 提前保存,然后计算 sumExp += Math.exp(beta * (qValue - maxQ)),最后结果加上 maxQ。这个坑我在 5.2 里会重点讲。

第二个陷阱是迭代次数。网格世界十乘十、折扣因子 0.95 的条件下,两百次迭代基本能让软价值收敛。但如果你把 gamma 设成 0.99,收敛速度会明显变慢,需要把迭代次数加到五百次以上。判断收敛的标准不是价值函数不再变化,而是策略分布不再显著变化——价值函数的微小波动可能对策略没有实际影响,但会干扰梯度计算。

拿到软价值后,下一步是计算当前策略的特征期望。状态 s 下选择动作 a 的概率,由 softmax 策略给出:π(a|s) ∝ exp(beta * Q(s, a))。然后通过动态规划计算每个状态的占用概率,最后把占用概率和特征加权求和。这个步骤本质上是把“策略分布”和“轨迹分布”打通,初学者最容易在这里翻车,因为占用概率和策略概率经常被混为一谈。

4.3 参数表与收敛判断:哪些值需要调、哪些值别动

把关键参数整理成一张表,方便对照调试。这张表是我在实际跑这套代码时反复验证过的默认值和调整方向,但不是万能药——不同任务的最优参数差异很大,表的作用是给你一个安全的起点。

参数默认值作用调参建议
gamma0.95折扣因子,控制未来收益权重任务步数长就增大,但别超过 0.99
beta1.0softmax 温度,控制策略随机性先 1.0 起步,振荡时减小,太平均就加大
learningRate0.1梯度上升步长梯度振荡时减半,过慢再加
maxIterations200软价值迭代次数只看 gamma,gamma 大就加次数
特征维度状态数决定权重向量长度独热编码不要动,加特征才改这里

收敛判断这块,建议同时看两个指标,不要只看一个。第一个指标是梯度范数,也就是 2.1 里特征期望差的 L2 范数,它直观反映当前策略与专家行为的差距;第二个指标是权重变化量,即本轮和上一轮权重的最大绝对值差。我见过不少实现只盯着梯度范数,结果梯度范数降到 0.05 以下就停止训练,但权重还在缓慢漂移,导致奖励函数出现过拟合。

一个值得养成的习惯是每轮迭代打印一次策略分布的变化比例,这个比例定义为所有状态上动作概率差异绝对值的平均值。如果连续二十轮这个比例低于 0.001,基本可以认定策略不再变化,这时候再继续迭代也只是在优化数值精度。相反,如果这个比例一直不降,说明 beta 或者学习率设置有问题,应该停下来检查参数而不是盲目增加迭代次数。

再说一下 Java 实现里的取舍。这套代码没有用自动求导框架,梯度公式是手写的,这就意味着一旦你修改了特征提取器,必须同步检查梯度维度是否匹配。特征维度变了、梯度维度没变,程序不会报错,但权重更新时会出现数组越界或错位更新,这是这种“手写梯度”实现里最隐蔽的 bug。后面 5.5 会专门讲这个问题。

5. 避坑与常见问题排查:IRL 示例代码里的五个翻车点

5.1 特征期望算错:只统计专家轨迹,忘了策略分布

现象:训练过程完全正常,梯度范数稳定下降,但恢复出来的权重明显不对——目标点权重是负的,障碍物附近权重是正的。把权重取负、重新做策略评估,得到的策略反而和专家轨迹一致。

原因:SoftValueIteration 算出了软价值,但在计算当前策略特征期望时,实现里直接用了“按策略概率加权”的公式,却忘了用占用度量(occupancy measure)做归一化。简单说,策略概率描述了某个状态选择某个动作的条件概率,而特征期望需要的是“一条随机轨迹落在某个状态-动作上的概率”,两者相差一个状态访问概率。只统计策略概率,相当于假设所有状态等概率被访问——这在网格世界里显然不成立,起点附近的状态天然被访问得更多。

解决:用动态规划计算状态占用概率。先初始化起点状态的占用为 1.0(如果有初始状态分布就按分布赋值),然后按策略概率和转移概率逐状态传播:occ(s') = Σ_s occ(s) * Σ_a π(a|s) * P(s'|s,a)。每轮软价值迭代结束后,重新计算一次占用度量,再和特征做加权和。占用度量计算是 IRL 实现的试金石——如果这里的公式写错了,后面所有实验都是错的,而且不容易暴露。

5.2 softmax 温度 beta 过大导致数值溢出

现象:运行到第二轮迭代时,控制台打印出 NaN,或者权重变成 Infinity。把 beta 调小到 0.01 之后程序恢复正常,但学出的策略过于均匀,几乎和随机策略没有区别。

原因:beta 直接参与指数计算 Math.exp(beta * qValue)。当 beta = 10、qValue = 20 时,指数部分是 e^200,直接溢出 double 的表示范围。很多教程代码为了演示“明确策略”会把 beta 设得偏大,但在 Java 里没有 NumPy 那种自动处理机制,溢出就是硬失败。反过来,beta 太小也有问题:log-sum-exp 退化成近似平均,策略分布趋于均匀,专家轨迹的偏好信息被完全抹平。

解决:两个层面。第一,在 SoftValueIteration 里做数值稳定化,先把每个状态的最大 Q 值提出来,再算 sumExp。经过稳定化处理后,指数内部的最大值被限制在 0 附近,beta 大到 50 都不会溢出。第二,如果调大 beta 后策略呈现“过于陡峭”的形态,比如某些动作概率高达 0.999、其他动作趋近于零,那说明 beta 超过了数据能支撑的范围,调回 1.0 到 5.0 区间即可。我一般会把 beta 的调试顺序定成 1.0、2.0、5.0、10.0,逐个观察策略分布,而不是一步到位。

5.3 奖励函数出现常数偏移:线性特征下的不可辨识问题

现象:恢复出的奖励函数形状完全合理——目标点高、障碍物低——但绝对值普遍偏大或偏小,把整体奖励减去一个常数后,策略不变,却没办法解释“为什么是 +100 而不是 +50”。

原因:这是线性奖励函数的固有问题。如果奖励 R(s, a) = w · φ(s, a),那么对权重向量加上一个在所有状态-动作上都相等的常数偏移,特征期望不变,轨迹相对概率也不变。因为轨迹概率 P(τ) ∝ exp(Σ R(s_t, a_t)),所有轨迹同时加上相同常数,相当于所有轨迹概率乘以同一个缩放因子,归一化后等于什么都没变。数值上,优化器在权重空间里沿着“全 1 方向”漂移也不会改变目标函数,于是最终权重绝对值取决于初始值和学习率,不具备唯一性。

解决:不必追求奖励的绝对数值,只看相对排序和差值。在评估结果时,把权重标准化:减均值、除以标准差,再分析状态偏好;或者直接把恢复的奖励喂给策略迭代,用策略行为和专家轨迹做对比。另外,如果你在意可解释性,可以在特征工程里避免全常数方向的出现——确保至少有一个特征的取值具备方差,而不要所有状态都用相同的独热前缀。这个坑不是 bug,而是问题的数学结构,理解了它,就不会被“为什么我的奖励值这么大”这种问题困扰。

5.4 收敛判断只看奖励变化,忽略轨迹概率变化

现象:训练跑到一百轮以后,打印出的权重更新量已经很小,训练被判定为收敛。但把当前策略随机采样一批轨迹,轨迹分布和专家轨迹差异明显,比如平均步数多出 30%。

原因:权重变化小不代表策略变化小。线性奖励的权重在某些方向上微小变化,经过 softmax 和软价值迭代的放大,可能引起策略分布的显著变化;反之,权重在大幅变化时,如果这些变化发生在不可辨识方向上,策略反而纹丝不动。只靠权重变化量做收敛判断,等于用一个间接指标替代直接指标。

解决:直接监控策略分布。每一轮迭代后计算一个指标:所有状态上、所有动作上,当前策略与上一轮策略的绝对差异平均值。这个指标低于 0.001 且连续保持二十轮,才判定收敛。同时记录采样轨迹的平均步数和专家轨迹的平均步数,两个数字越接近,说明恢复的奖励越能复现专家的行为特征。从那以后,我每次跑 IRL 都把“策略变化比例”和“轨迹平均步数差异”打印在日志里,权重变化只当作参考指标,不再作为收敛依据。

5.5 状态特征与动作空间不匹配,维度全错

现象:自己改了 FeatureExtractor,想加入障碍物距离特征,运行程序后出现数组越界异常,或者权重矩阵打印出来形状正常但结果完全没规律。

原因:特征提取器的维度变了,但 MaxEntIRL 初始化时还在用旧维度创建权重向量,或者 TrajectoryLoader 里解析的状态索引和特征提取器里的索引规则不一致。这类错误不会像 NaN 那样直接暴露,因为程序还能跑,但学出来的奖励函数是“张冠李戴”的,特征对不上状态。Java 的数组索引越界有时会被 try-catch 吞掉,尤其在流式处理里,异常被中断后程序继续执行,更隐蔽。

解决:在特征提取器里加一个自检方法,在 main 方法最开始做一次全量验证:遍历所有状态和动作,调用 extract 方法,检查输出向量维度是否等于 featureDimension(),同时检查向量中是否存在非法值(负数、NaN)。我一般在代码里写一个静态检查块,拿一个已知状态做断言,维度不匹配直接抛异常。另外,改特征时记得同步检查 resources 里的轨迹数据——状态索引基于行优先还是列优先,必须和 GridWorld 里定义的坐标系一致,这个错位问题在改动地图大小时特别容易冒出来。

6. 进阶验证与技巧:把恢复出来的奖励函数用起来

6.1 策略回放对比:验证奖励函数是否真的解释了专家轨迹

训练结束不是终点,验证才是。一个我强烈推荐的做法是策略回放:用恢复的奖励函数重新求解策略,然后从这个策略里采样一批轨迹,和专家轨迹放在一起做对比。对比指标选三个就够,第一个是轨迹平均长度,专家轨迹如果大多在十步左右,而你恢复的策略平均要走二十步,说明奖励对“速度”的建模偏弱;第二个是状态访问频次分布,哪些格子被高频经过、哪些格子几乎不经过,直接反映奖励的偏好结构;第三个是动作分布,看策略是否像专家一样倾向于某些方向的移动模式。

public double[] sampleTrajectory(GridWorld world, double[] weights, FeatureExtractor extractor) { // 按当前权重下的 softmax 策略采样一条轨迹 State state = world.getStartState(); double[] trajectory = new double[extractor.featureDimension()]; for (int t = 0; t < 50; t++) { if (state.isTerminal()) break; Action action = world.sampleAction(state, weights, extractor); trajectory = addToTrajectory(trajectory, state, action, extractor); state = world.transition(state, action); } return trajectory; }

这个采样方法里最关键的是 sampleAction 的实现——它必须和 SoftValueIteration 里的策略公式完全一致,否则验证就变成了“用另一个策略验证同一个奖励”,数值对不上。我在实际排错时踩过这个坑:训练用的是软价值迭代推导出的策略,验证时却自己写了一个简化版 softmax,两者在 beta 的缩放上差了一个因子,导致采样出来的轨迹明显偏离专家分布,白白排查了两个小时。

另一个实用技巧是给轨迹做可视化。网格世界十乘十的规模,把权重矩阵直接打印成 10×10 的数值网格,目标点的权重大概是周围区域的 3 到 5 倍,障碍物格子的权重明显低于通路。如果数值差异不够明显,先调大 beta 再重跑训练,而不要直接调学习率。权重矩阵的可读性是这类教程代码的天然优势——换成高维状态空间,你很难一眼判断结果是否合理。

6.2 特征敏感性分析:找出哪个特征在主导决策

如果只做一件事来深入理解这套 IRL 代码,我推荐做特征消融实验。在独热编码基础上临时增加一个“到目标点的距离”特征,训练后用权重绝对值看它的影响力。距离特征的权重如果是负的,说明算法认为“离目标越远越不好”——这是个合理信号;如果距离特征权重是正的,说明特征和目标行为的关系预设反了,需要检查特征提取器的距离计算逻辑。

有个经验之谈:在网格世界这种小状态空间里,特征消融的实验成本极低,每轮训练也就几百毫秒,多跑几组组合反而能帮你建立对 MaxEnt IRL 的直觉。比如把独热编码全部替换成距离特征,只保留一个连续维度,训练后你会发现权重非常集中,但恢复的策略可能过于激进——只往目标方向冲,完全不避障。这种“特征太少导致策略偏差”的现象,是做新任务时最常见的失败模式,根源就在于特征工程没有覆盖到奖励的关键维度。

从那以后,我每次拿到一套 IRL 示例代码,都会强制先跑一遍“数据 → 特征 → 特征期望 → 梯度 → 策略”的完整链路核对,再开始改算法;每当怀疑结果不对,第一反应永远是检查特征提取器,而不是调 beta。这套 Java 版 IRLTutorial 值得你下载后按这个顺序跑一遍,把每个中间结果打印出来对照,掌握它的边界之后,再替换成你自己的业务特征。希望帮到你。

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