在强化学习项目里,我见过太多人卡在同一个地方:算法那边的代码跑得通,环境接口这边对不上。用 Matlab 做这件事其实相当顺手,它把环境抽象成一个很薄的对象,你只要把reset和step两个口子接好,剩下的交给训练器。可真正动手写的时候,问题全堆在细节上:观测规格和实际输出维度对不上、动作传进来是元素值不是索引、IsDone到底什么时候该置真、LoggedSignals什么时候会被清空。这些内容官方文档里都有,但散落在各章节,第一次写基本都得踩一遍。
这篇东西就是我把自己反复写环境接口的经验整理出来的一份实操记录。它讲的不是强化学习算法本身,而是“怎么把一个真实问题包装成 Matlab 能认的环境”:规格怎么定义、状态怎么推进、奖励怎么标定、怎么在训练之前先把接口验证干净。适合两类人看:一类是刚接触强化学习工具箱,想跑通第一个自定义环境的;另一类是被维度不匹配、训练不收敛折磨过几轮,想回头把地基重新夯一遍的。代码示例围绕一个虚构的补货决策场景展开,业务逻辑你可以整体替换,接口那一层的骨架不用动。
1. 环境接口到底在对接什么
1.1 一个只有两个函数的对象
把强化学习的环境想象成一台自动售货机。你投币(动作),它掉货(观测)并找零(奖励),同时告诉你还能不能继续投(终止信号)。至于机器内部怎么算账,你不用管。环境接口就是这台机器的外壳,它对外只承诺两件事:我能接受什么样的投币,我会吐出什么样的结果。
Matlab 把这件事落成了两个方法。reset负责把机器恢复到一个初始状态,并返回第一个观测;step接收一个动作,推进一步,返回下一个观测、这一步的奖励、是否结束,以及一份日志。整个训练循环就是不停地调这两个函数,训练器在外部根据奖励去调策略参数。所以环境写得对不对,直接决定训练器看到的世界是不是自洽的——它不会替你检查业务逻辑,但会严格检查维度、类型和数值合法性。
我习惯把环境代码和业务代码彻底分开。业务逻辑写成一个纯函数,输入当前状态和动作、输出新状态和收益;环境类只做“搬运”和“校验”。这样做的好处是,业务逻辑可以用普通脚本单独跑单元测试,跑通了再接进环境,出问题时能立刻定位是业务算错了还是接口接错了。
1.2 为什么在 Matlab 里写环境而不是换语言重写
很多人第一反应是“强化学习不是该用别的语言吗”。如果你的问题本身就是一组微分方程、一个控制系统模型、一段信号处理流程,那么把模型搬到另一种语言里重写一遍,成本高不说,还极容易在搬的过程中引入偏差。Matlab 的强项在这儿:矩阵运算、数值积分、控制系统工具箱、Simulink 模型,都是现成的。环境接口这一层写得薄,反而让你能专心在模型上。
另一个现实原因是联调。算法侧通常要试很多组超参数,环境每改一次奖励就得重训一次。如果环境在 Matlab 里,改一行参数就能重跑;如果环境在另一套栈里,改完还得跨进程通信、对数据格式,一轮调试下来半天没了。我自己做过对比,同一个控制类问题,环境留在 Matlab 里,单轮迭代时间能压到跨语言方案的三分之一左右,主要省在数据序列化和进程调度上。
当然有代价:Matlab 的面向对象在深层继承上不如通用语言灵活,类的属性访问控制也比较严格;大规模并行训练时,每个 worker 都要复制一份环境对象,内存开销比函数式接口大。所以我的选择逻辑是:模型复杂、要复用已有仿真资产,就在 Matlab 里写;纯表格型、状态动作能枚举的小问题,用函数句柄式更轻。
1.3 四条接入路线怎么选
工具箱给了不止一种接法,选错了会在后期反复返工。下面这张表是我自己总结的对照,具体函数签名以本机版本文档为准,但选型逻辑是通用的。
| 接入方式 | 典型写法 | 适用场景 | 主要代价 |
|---|---|---|---|
| 函数句柄式 | rlFunctionEnv配两个函数句柄 | 状态就是几个数,逻辑几十行,一次性任务 | 状态得靠LoggedSignals手动携带,容易漏 |
| 类继承式 | 继承rl.env.MATLABEnvironment | 参数多、要被多个脚本复用、要做参数扫描 | 代码量最大,属性访问容易写错 |
| 表格式 | rlMDPEnv | 状态和动作都能枚举的小规模问题 | 状态一多,转移表直接爆炸 |
| 模型接入式 | rlSimulinkEnv | 已经有搭好的仿真模型 | 采样时间、求解器设置会直接影响训练 |
新手我一般建议从函数句柄式起步,因为它逼着你把LoggedSignals的角色想清楚——你不会误以为环境对象里有“免费的”持久状态。等你发现参数越来越多、每个实验都要复制粘贴一堆闭包变量时,再迁移到类继承式,那时候你对接口的理解已经够用了。
2. 观测与动作规格:定义错了后面全是坑
2.1 数值型观测的上下界不是装饰
用rlNumericSpec定义观测时,那个LowerLimit和UpperLimit很多人随手填-inf和inf就过了。能跑,但会埋两个隐患。第一,策略网络在做输入归一化时会用到这组区间,全无穷等于没给先验,网络前几轮基本在瞎试,收敛慢一截。第二,某些版本的训练器会对越界观测做告警或截断,一旦截断,你喂给网络的数据和真实状态就不是一回事了,学出来的策略在真环境里会崩。
我的做法是:先想清楚每个观测分量的物理意义,把“正常运行下不可能超过”的范围填进去。比如库存量上限是 200,在途订单上限也是 200,那就填[0;0]到[200;200],而不是-inf到inf。如果确实存在理论上无界的分量(比如累计收益),那就单独拆出去,不要放进观测,或者做个有界变换。
维度也要一次定死。[2 1]表示两个分量的列向量,[1 2]是行向量,两者在部分环节会被当成不同形状。我吃过这个亏:类里State存的是行向量,step直接返回,训练器拿到的观测形状和 spec 不一致,报错信息还很含糊。后来我养成习惯,在step和reset的返回处显式写NextObs = this.State(:),强制成列向量,问题就没了。
2.2 离散动作与连续动作的取舍
动作空间有两种基本形态。rlFiniteSetSpec定义一个可枚举的集合,比如补货量只能取 0、10、20、40、80 这几档;rlNumericSpec定义连续区间,比如补货量是 0 到 80 之间的任意实数。
离散的好处是训练稳、样本效率高,DQN 这类算法直接就能用;坏处是精度受档位粒度限制。连续的好处是能学到精细策略,配 DDPG、TD3、PPO 这类算法;坏处是对超参敏感,动作的上下界一旦给得不合理,网络输出饱和,学出来的策略会一直贴着边界走。
我的经验是:先离散化跑通,确认奖励设计和状态转移没问题,再换连续动作去做精调。这个顺序能省掉大量“到底是接口错了还是算法调不好”的纠结。离散化的时候档位不要拍脑袋,按业务的实际可执行粒度来——如果现实里的补货最小单位就是 10 件,那就别用 1 件做粒度,白白把动作空间放大十倍。
还有一个容易忽略的点:动作的上下界要和业务约束对得上。如果容量只有 200,动作却允许下 500 的订单,那环境里就必须有处理“超量”的分支,否则状态会溢出到你设定的观测上界之外。我更倾向于在设计阶段就把动作上界压到业务允许的最大值,让非法动作根本不存在,而不是在step里写一堆兜底逻辑——兜底逻辑本身也会被策略学进去,变成一种奇怪的“免费惩罚规避”行为。
2.3 规格与实际输出的一致性怎么查
规格定义和实际返回不一致,是新手最高频的报错来源。表现形式五花八门:维度对不上、数据类型不是 double、观测里有NaN、动作元素不在集合里。这些问题在训练时暴露,报错信息往往指向训练器内部,很难一眼定位。
我的做法是在环境类里加一个私有方法,专门做返回前的自检,只在调试开关打开时执行:
function obs = checkObs(this, obs) obs = obs(:); assert(numel(obs) == prod(this.ObservationInfo.Dimension), ... '观测维度与规格不符:期望 %d,实际 %d', ... prod(this.ObservationInfo.Dimension), numel(obs)); assert(all(isfinite(obs)), '观测中出现 NaN 或 Inf'); if ~isempty(this.ObservationInfo.LowerLimit) assert(all(obs >= this.ObservationInfo.LowerLimit), '观测低于下界'); end if ~isempty(this.ObservationInfo.UpperLimit) assert(all(obs <= this.ObservationInfo.UpperLimit), '观测高于上界'); end end这几行assert在正式训练时把开关关掉,性能影响可以忽略;调试阶段开着,任何越界都会当场炸在你自己的代码行上,而不是几百行之外的训练器内部。这个习惯帮我省下的排查时间,比任何调试工具都多。
3. step 与 reset:两段最容易写错的函数
3.1 动作参数传进来是什么形态
这是我自己踩得最深的一个坑。当你用rlFiniteSetSpec定义动作集合时,step收到的Action是集合里的元素值本身,不是索引。也就是说,集合是[0 10 20 40 80],策略选了第三档,step里拿到的就是数字20,而不是3。
这个设计其实很合理——环境不需要知道策略内部怎么编号,它只关心“这次下的单是多少”。但如果你按索引的思路写代码,比如qty = this.ActionSet(Action),那就等于把 20 当成下标去取第 20 个元素,直接越界报错;更阴险的情况是集合元素恰好都是小整数,代码不报错但逻辑全错,训练出来的策略看着能跑,实际行为莫名其妙。
用rlNumericSpec定义连续动作时,step收到的是列向量,形状是[动作维度 1]。哪怕动作维度是 1,它也是[1x1]的列向量而不是标量。写if Action > 0这种判断在某些版本里会因为形状问题给出意外结果,稳妥的写法是先a = Action(1);取出来再判断。
我现在的固定写法是在step开头加两行归一化,把动作整理成统一形态,后面的业务代码就不用关心它从哪来了:
function [NextObs, Reward, IsDone, LoggedSignals] = step(this, Action) a = Action(1); % 离散集合取元素值,连续动作取第一个分量 a = max(0, min(a, this.Capacity)); % 兜底,防止策略输出越界3.2 四个返回值的语义
step返回四样东西:下一个观测、奖励、是否终止、日志。前三个是策略学习的核心,第四个是给人看的。
观测必须是执行动作之后的新状态。这一点听起来废话,但我见过把动作前的状态返回去的写法——那样策略看到的世界永远慢一拍,学出来的行为会持续滞后。判断方法很简单:在step里把新状态算完、赋值给成员变量之后再取观测,不要在开头就取。
奖励是个标量 double。这里最容易被忽视的是量纲。如果你的奖励里同时有“销售额”(量级几百)和“缺货惩罚”(量级个位数),那惩罚项在梯度里几乎不存在,策略会完全无视缺货。我在设计奖励时习惯先把各项折算到同一量级,比如把销售额除以 100、惩罚乘以 10,让它们在同一个数量级上竞争。具体系数靠试,但“先对齐量级再调权重”这个顺序不能反。
IsDone的语义是这一轮结束了,后续价值归零。它不只是“到步数上限了”这么简单,训练器会用它来决定要不要做自举(bootstrap)。如果你的问题是固定长度的决策周期,到点截断是合理的,置真就行;但如果你是因为“出了严重故障”而提前终止,那这个终止本身就应该带一个显著的负奖励,否则策略会学成“赶紧触发终止来逃避后续惩罚”。
日志LoggedSignals是个结构体,用来存训练过程中你想观察的量:累计收益、服务率、平均库存。它不参与学习,纯给人看。但要注意它在并行训练时是每个 worker 独立的,不要指望在主进程里读到所有 worker 的中间结果。
3.3 reset 里的随机化与可复现
reset要做两件事:把状态恢复到初始分布,重置所有计步器,返回初始观测和一份干净的日志。
初始状态该不该随机,取决于你的问题。如果每次训练都从同一个起点出发,策略会过拟合到那条特定轨迹上,换个起点就崩。我一般让初始状态在一个合理区间内随机,但区间不能太宽——起点差异过大,早期样本方差太高,学习曲线会抖得看不出趋势。
可复现性是个容易被忽略的工程问题。同一份代码跑两次结果差很多,你根本没法判断是改动生效了还是随机波动。我的做法是在reset里显式管理随机源:把需要随机的序列在reset时一次性生成好存起来,step里按步取用,而不是每步都调一次随机函数。这样只要种子固定,整条轨迹就完全可复现。
function [InitialObservation, LoggedSignals] = reset(this) this.StepCount = 0; this.State = [this.InitLow + rand*(this.InitHigh-this.InitLow); 0]; this.DemandSeq = randi([0 this.DemandMax], this.MaxSteps, 1); % 一次生成,逐步取用 LoggedSignals.TotalProfit = 0; LoggedSignals.StockoutSteps = 0; InitialObservation = this.State(:); end把需求序列预生成还有个额外好处:调试时可以把这个序列打出来看,确认环境给的需求分布符合预期。如果发现某次训练里需求全是零,那就是随机源或者参数设错了,一眼能看出来。
3.4 LoggedSignals 别当成全局变量用
LoggedSignals是环境里唯一能跨步传递“非状态信息”的通道,很多人就把它当全局变量使,什么都往里塞。短期方便,长期会出问题。
第一个问题是并行训练。每个 worker 有自己的环境副本和日志,你在主进程里汇总统计时,拿到的是各 worker 最后一份日志,中间过程丢了。要做全局统计,得在训练器的回调里收集,而不是靠日志。
第二个问题是日志和状态混淆。日志里的量如果参与了下一步的决策,那它事实上就是状态的一部分,应该放进观测里,让策略能看见。反过来,如果把状态塞进日志、指望策略“记住”,那是行不通的——策略只看观测。
我的划分标准很粗暴:影响下一步转移的量进状态,只用于事后分析的量进日志。累计收益、累计缺货次数属于后者;在途订单量、当前库存属于前者。这条线划清楚了,环境的结构基本就不会乱。
4. 手把手搭一个补货决策环境
4.1 业务建模与状态定义
拿一个虚构的补货场景说事:一个仓库存某种耗材,每期初决定补货量,期末结算。需求随机,卖出去的有利润,没卖出去的库存要付持有成本,缺货要付违约成本。目标是让长期净收益最大。
这个场景的好处是它足够生活化,任何人都能理解,同时它包含了强化学习环境该有的全部要素:连续或离散的动作、随机的外部扰动、延迟的收益、资源约束。
状态我选了两个分量:当前在手库存onHand,以及上一期下的、本期会到货的在途量inTransit。为什么要在途量?因为补货有延迟,如果不把它放进状态,策略就不知道自己之前下过多少单,会重复下单直到爆仓。这是建模时最容易漏的一环——凡是会影响未来转移、但不由当前动作决定的量,都得进状态。
观测规格就这么定:
obsInfo = rlNumericSpec([2 1], ... 'LowerLimit', [0; 0], ... 'UpperLimit', [200; 200], ... 'Name', 'inventoryState'); actInfo = rlFiniteSetSpec([0 10 20 40 80], 'Name', 'orderQty');动作选了五个档位而不是连续值,理由前面说过:先跑通再精调。五个档位对应“不补”“小补”“中补”“大补”“满补”,业务上完全说得通,现实里的采购审批也常常是分档的。
4.2 奖励函数怎么拆、系数怎么定
奖励是整个环境里最需要反复试的部分。我一般按“先写全、再对齐、后调权”三步走。
先写全,是把所有该考虑的经济项都列出来:销售收入、采购成本、持有成本、缺货惩罚,外加一个容量溢出的兜底惩罚。
再对齐,是把各项折算到同一量级。销售收入量级最大,我把它当基准;采购成本和销售收入同量级,不用动;持有成本通常小一个量级,乘个系数放大;缺货惩罚要看业务上多痛,缺货一次相当于损失几单的利润,就按这个比例给。
后调权,是在跑过几轮之后看行为。如果训练出来的策略一直不补货、放任缺货,说明缺货惩罚太轻;如果策略疯狂囤货、持有成本吃掉全部利润,说明持有成本太轻或者容量惩罚没生效。这种“看行为反推权重”的调试方式比盯着损失曲线有用得多。
具体到代码:
reward = this.SellPrice * sold ... - this.OrderCost * orderQty ... - this.HoldCost * (newOnHand + newInTransit) ... - this.StockoutPenalty * lost ... - this.OverflowPenalty * overflow;参数我用的是售价 3、采购成本 1、单位持有成本 0.1、单位缺货惩罚 2、溢出惩罚 5。这套数不是为了“正确”,而是为了能跑出一个符合直觉的行为:缺货的痛感是毛利的两倍,所以策略会倾向于多备一点货,但持有成本又让它不至于囤到爆仓。你可以按自己业务的真实比例替换,逻辑不变。
注意:奖励里的每一项都要能追溯到业务含义。如果你写了一个“为了让它学得快”而加的奖励项,却说不清它对应现实里的什么,那这项大概率会在后期变成行为异常的来源。
4.3 完整的类骨架
把前面的东西拼起来,一个能跑的环境类大概长这样。我特意保留了属性区,因为参数化是这类环境能被复用的前提——所有魔法数字都提到属性上,做参数扫描时改一行就行。
classdef InventoryEnv < rl.env.MATLABEnvironment properties Capacity = 200 SellPrice = 3 OrderCost = 1 HoldCost = 0.1 StockoutPenalty = 2 OverflowPenalty = 5 MaxSteps = 200 DemandMax = 30 InitLow = 10 InitHigh = 40 end properties (Access = protected) State StepCount DemandSeq end methods function this = InventoryEnv() obsInfo = rlNumericSpec([2 1], ... 'LowerLimit', [0; 0], 'UpperLimit', [200; 200], ... 'Name', 'inventoryState'); actInfo = rlFiniteSetSpec([0 10 20 40 80], 'Name', 'orderQty'); this = this@rl.env.MATLABEnvironment(obsInfo, actInfo); this.State = [20; 0]; this.StepCount = 0; end function [InitialObservation, LoggedSignals] = reset(this) this.StepCount = 0; this.State = [this.InitLow + rand*(this.InitHigh-this.InitLow); 0]; this.DemandSeq = randi([0 this.DemandMax], this.MaxSteps, 1); LoggedSignals.TotalProfit = 0; LoggedSignals.StockoutSteps = 0; InitialObservation = this.State(:); end function [NextObs, Reward, IsDone, LoggedSignals] = step(this, Action) LoggedSignals = this.LoggedSignals; orderQty = max(0, min(Action(1), this.Capacity)); idx = min(this.StepCount + 1, this.MaxSteps); demand = this.DemandSeq(idx); available = this.State(1) + this.State(2); sold = min(available, demand); lost = demand - sold; newOnHand = available - sold; newTransit = orderQty; overflow = max(0, newOnHand + newTransit - this.Capacity); if overflow > 0 newOnHand = newOnHand - overflow; end Reward = this.SellPrice * sold ... - this.OrderCost * orderQty ... - this.HoldCost * (newOnHand + newTransit) ... - this.StockoutPenalty * lost ... - this.OverflowPenalty * overflow; this.State = [newOnHand; newTransit]; this.StepCount = this.StepCount + 1; NextObs = this.State(:); IsDone = this.StepCount >= this.MaxSteps; LoggedSignals.TotalProfit = LoggedSignals.TotalProfit + Reward; if lost > 0 LoggedSignals.StockoutSteps = LoggedSignals.StockoutSteps + 1; end end end end两点说明。LoggedSignals = this.LoggedSignals;这行是模板的固定写法,作用是取回上一步的日志结构体;不同版本的模板可能在细节上有差异,以本机生成的模板为准,但“在step开头取、结尾写回”这个模式是通用的。另外overflow那段做了截断处理,是因为观测上界设的是 200,如果不截断,状态一旦越界,后面所有环节都可能报错。
4.4 冒烟测试:训练之前先把接口跑通
写完类,别急着上训练器。先用随机策略手工跑几轮,这一步能拦掉八成以上的接口问题,而且排查成本极低。
env = InventoryEnv(); [obs, logged] = reset(env); actInfo = getActionInfo(env); elems = actInfo.Elements; for k = 1:200 a = elems(randi(numel(elems))); [obs, r, done, logged] = step(env, a); assert(all(isfinite(obs)), '第 %d 步观测异常', k); fprintf('step %3d | action %3d | obs [%6.1f %6.1f] | reward %7.2f\n', ... k, a, obs(1), obs(2), r); if done, break; end end fprintf('累计收益 %.2f,缺货步数 %d\n', logged.TotalProfit, logged.StockoutSteps);这段脚本的价值在于把每一步都打出来,你可以肉眼检查:库存是不是随补货上升、随需求下降;在途量是不是延迟一期到货;缺货的时候奖励是不是明显变负;走满 200 步之后done是不是真的置真。这些直觉在训练时是完全看不到的,等你看到损失曲线不动的时候再回头查,成本高十倍。
工具箱里还有个validateEnvironment,跑一遍能查出规格与实际返回不符这类问题。我用得不多,因为自己写的assert定位更准,但接手别人代码时值得先跑一下,心里有个底。
5. 训练不收敛?先怀疑环境和数据
5.1 观测尺度不统一是最常见的元凶
环境接口没报错,训练也能跑,就是学不出东西——这种情况我遇到的次数最多,而其中一半以上是观测尺度问题。
设想观测里两个分量:库存在 0 到 200 之间,在途量在 0 到 80 之间。数值上差了两倍多,网络在处理时会天然偏向数值大的那个,小的那个几乎被忽略。如果两个分量的量级差到几十倍,小的那个基本等于不存在。
解决办法有两个。一是把观测归一化到 [-1, 1] 或 [0, 1],在环境里做。好处是策略拿到的输入永远在同一尺度,坏处是环境多了一层变换,调试时看到的观测值和真实值不一样,容易混乱。二是在策略网络侧做归一化,环境保持物理量。工具箱的策略对象一般支持输入归一化选项,具体参数名各版本不同,查一下文档就有。
我个人倾向前者,因为我在调试时更关心“环境给的到底是什么”,归一化放在环境里,至少step内部用的还是物理量,打日志也直观。归一化只在返回观测那一步做:
NextObs = [this.State(1)/this.Capacity; this.State(2)/this.Capacity];对应的观测上下界也要改成[0;0]到[1;1]。改完记得重跑一遍冒烟测试,别只顾着改代码忘了改规格。
5.2 采样时间、并行与随机种子
用 Simulink 环境时,采样时间设置会直接影响训练。如果模型里既有连续的动态、又有离散的决策点,采样时间设错会导致step实际推进的时间和你想的不一样,学习曲线会呈现出一种“怎么调都不对”的诡异状态。我的检查方法是跑一步然后看模型内部的时间戳,确认推进量和预期一致,再开始训练。
并行训练能显著提速,但有三个地方要注意。第一,每个 worker 会独立复制一份环境对象,如果环境里有文件读写或者共享状态,会冲突。第二,随机种子要单独控制,否则不同 worker 采到的样本分布不一致,训练反而不稳。第三,日志是每个 worker 独立的,要在训练选项的回调里汇总,不能指望环境自己上报。
种子这件事我的习惯是:调试阶段固定rng(0),确认行为可复现;正式训练时换一组随机种子多跑几次,看结果方差。如果三次训练的结果差异巨大,说明策略对初始条件太敏感,问题多半在环境(比如初始状态分布太宽)而不是算法。
5.3 纯 Matlab 环境和 Simulink 环境怎么选
这两条路的取舍标准其实很清楚:你的模型是不是本来就在 Simulink 里。
如果模型已经是一个搭好的仿真系统——电机、液压、化工流程——那就直接用rlSimulinkEnv接进去,别重写。重写一遍的代价不只是工作量,还有“重写后的模型和原模型行为不一致”这个几乎无法完全避免的风险。接进去的代价是训练速度慢,因为每一步都要走一遍求解器,这时候可以把求解器的精度适当放宽,先用粗精度训出个大概策略,再用细精度微调。
如果模型就是几十行数学公式,老老实实用rl.env.MATLABEnvironment写。类里就是纯粹的数值计算,没有求解器开销,一步几微秒,训练起来飞快。我做过一个对比:同一个问题,Simulink 接法一步大约 2 毫秒,纯 Matlab 写法一步 0.02 毫秒,差两个数量级。这种差距在需要几万步样本的时候就是几小时对几分钟。
6. 报错速查与几条不写在文档里的经验
6.1 常见报错对照表
下面这些是我这些年遇到频率最高的,报错原文各版本表述可能不同,但症状和成因基本一致。
| 症状 | 大概率原因 | 处理方式 |
|---|---|---|
| 训练一开始就报维度不符 | step/reset返回的观测形状与 spec 不一致 | 返回处统一写obs(:) |
| 动作越界或索引非法 | 把rlFiniteSetSpec的动作当成了索引 | 直接用Action元素值,别拿去当下标 |
| 观测出现 NaN,训练中断 | 状态更新里有除零或对数运算 | 加保护分母,或对输入做下限截断 |
IsDone一直不置真,回合跑不完 | 计步器没在reset里清零,或比较条件写反 | 检查reset和StepCount的更新顺序 |
| 日志里的累计值越训越大 | 跨回合没重置 | reset里重新初始化整个结构体 |
| 训练损失正常但策略行为荒谬 | 奖励量纲失衡,某项被淹没 | 先对齐各奖励项量级再调权重 |
| 并行训练结果比单进程还差 | 各 worker 随机源不一致或环境有共享状态 | 固定种子,去掉文件读写 |
6.2 step 慢十倍怎么办
环境接口本身不慢,慢的通常是你在step里做的事。我遇到过的几个典型:
一是每步都在做文件读写或者绘图。绘图尤其致命,看起来只是“实时观察”,实际上把整个训练拖慢一个数量级。我的建议是训练时不画图,通过日志记录关键量,训练结束再画。如果一定要看,隔几十步画一次。
二是每步都在重新构造对象。比如每步都readmatrix读一次参数文件,或者每步都新建一个插值对象。这些应该提到构造函数里做一次。
三是向量化没做。状态更新里如果有for循环遍历一个大数组,改成矩阵运算通常能快几十倍。这条在纯数值环境里很常见。
测性能的方法很简单:在step外面套个计数器,跑一千步计时,然后逐段注释掉可疑代码再跑,看时间落在哪一段。这比凭感觉猜准得多。
6.3 几条从踩坑里总结的经验
先让环境自己会“讲道理”,再交给算法。我在写任何新环境之前,都会先写一个贪心策略——按业务直觉走的那种,比如“库存低于 50 就补到 100”。然后让这个策略在环境里跑一轮,看累计收益是多少。这个数就是基准线,训练出来的策略如果连它都打不过,那要么奖励设计有问题,要么状态信息不全,不用怀疑算法。
接口代码里不要有隐藏的全局状态。类成员变量已经够了,别再用全局变量或者persistent。并行训练时这些会变成灾难,而且单进程调试时也极难复现。所有跨步的信息,要么在State里,要么在LoggedSignals里,二者必居其一。
每改一次环境,重跑一遍冒烟测试。改奖励系数、改初始分布、改观测定义,看起来都是小改动,但都可能破坏某个隐含假设。那段几十行的随机策略脚本成本极低,跑一遍能省掉一次白跑几小时的训练。
把环境版本和训练结果绑在一起记。我在实验记录里会写下环境类的 git 提交号或者文件修改时间,配上训练的超参数和最终指标。有几次训练结果复现不出来,就是因为环境被改过而没记录,白折腾了半天。
奖励函数宁简勿繁。加项容易,删项难。每加一项你都得能说清它在业务上对应什么,否则它迟早会以某种你想不到的方式影响策略行为。我现在的做法是先用最简的两三项(收益、成本、终止惩罚)跑通,发现行为不对再针对性加项,而不是一开始就把能想到的都写进去。
最后分享一个我自己一直在用的小习惯:在环境类里留一个Debug属性,默认关闭。打开时,step里每一步都检查观测是否越界、奖励是否有限、动作是否合法,并把关键量打到控制台;关闭时这些检查全部跳过。这个开关在我调试新环境时的使用频率,比任何调试器都高——因为问题往往不是代码崩了,而是数值悄悄跑偏了,只有逐帧看才看得出来。