训练跑到一半停住、日志里又没有明显异常,多数情况下不是任务卡死,而是奖励函数在某一步返回了 None、NaN 或形状不对的数组,导致后面的 done 判定和奖励累加抛出错误,又被上层的 try/except 或 worker 静默退出吞掉。建议把排查顺序固定成四步:先在日志里锁定中断发生在哪个 episode 的第几步,再把 reward 与 done 的返回结构逐步打印出来,然后检查奖励函数对非法动作有没有兜底分支,接着用三组固定输入单测复现,最后排除“终止条件提前触发”这个干扰项。
中断位置一般能从日志最后几条 step 记录直接读出:reward 为空、类型不是数值、done 已经为 True,都优先指向奖励函数而不是调度器。处理方向是先把奖励函数返回值统一成 (float, bool) 二元组,对 None、越界、非有限值动作走罚分分支;再用正常动作、越界动作、空动作三组输入单测,不跑完整训练也能复现。边界在于:如果日志在打印 reward 之前就断了,问题更可能在环境步进、数据加载或显存,需要另走一条排查线。
在日志里锁定中断前的最后几条记录
先不要急着改代码,训练进程退出前最后几条 step 日志通常已经说明了问题,只是被大量正常日志淹没。需要看的字段是:episode 编号、步数(step 或 global_step)、reward、done,如果日志里有 action 或 action_dim 也一并看。
判断顺序建议如下,逐条排除:
- episode 编号和步数是否还在连续递增,停在同一 step 反复打印说明是卡住而不是退出;
- 最后一条记录里的 reward 是空、NaN、inf,还是字符串或列表;
- 最后一条记录里的 done 是不是已经变成 True,这关系到第 5 节要排除的提前终止;
- 日志尾部是否紧跟在某个特定动作之后,例如动作全为 0、维度不对或超出范围;
- 进程退出码是多少,正常结束、异常退出和被信号杀掉的表现不一样。
tail -n 60 train.log
grep -nE "episode|step|reward" train.log | tail -n 40
echo $? # 在上一条训练命令之后执行,看退出码
把最后一条完整记录抄出来,记下 episode、step、reward、done 四个值,后面几节的验证都以这条记录为基准。
逐步打印 reward 与 done 的输出
只在日志里看汇总值不够,需要看清中断瞬间 reward 与 done 的真实返回结构。最省事的做法是在调用奖励函数的地方包一层探针,不改动奖励函数本身,训练跑起来后每 N 步或每步打印一次。
import math, logging
log = logging.getLogger("wf.reward")
def probe_reward(raw_fn, action, step, every=1):
r = raw_fn(action)
if step % every == 0:
log.info(
"reward_probe step=%s action=%r reward=%r r_type=%s "
"is_none=%s is_nan=%s",
step, action, r, type(r).__name__,
r is None,
isinstance(r, float) and math.isnan(r),
)
return r
如果奖励函数返回的是 (reward, done) 二元组,就在探针里同时打印 done 的类型和取值。输出样例格式示意(字段名可按自己的日志格式替换):
reward_probe step=10231 action=[0.6, 0.2] reward=0.35 r_type=float is_none=False is_nan=False
reward_probe step=10232 action=[] reward=None r_type=NoneType is_none=True is_nan=False
reward_probe step=10233 action=[9.9, 0.1] reward=0.0 r_type=float is_none=False is_nan=False
判断点很直接:reward 字段出现 None 或 r_type 不是 float/int 的那一行,就是中断的起点;动作值明显越界(例如超过 action_limit)而 reward 仍返回 0 的,说明兜底分支可能根本没生效,属于静默错误,比直接报错更难查。注意探针本身不要改动返回值,只观察。
检查奖励函数对异常动作的兜底分支
确认了空值出现在哪一步之后,回头查奖励函数的入口处有没有对非法动作做判定。常见漏处理的动作有三类:动作是 None 或空列表、动作维度与 action_dim 不一致、动作含 NaN/inf 或超出 action_limit。判定顺序建议固定为:先判 None,再判形状,再判有限性,最后判范围,任何一步不通过都返回统一的罚分和一个布尔值,绝不返回 None。
def compute_reward(self, action, obs):
if action is None:
return self.penalty_invalid, False
try:
a = np.asarray(action, dtype=np.float64)
except (TypeError, ValueError):
return self.penalty_invalid, False
if a.shape != (self.action_dim,):
return self.penalty_invalid, False
if not np.all(np.isfinite(a)):
return self.penalty_invalid, False
if np.any(np.abs(a) > self.action_limit):
return self.penalty_invalid, False
r = self._raw_reward(a, obs)
return float(r), False
触发条件的判定写法有两个细节:一是用 np.asarray 时把 dtype 显式指定为浮点,避免整数动作在后续比较里被静默转换;二是所有分支的返回值类型必须一致,都是 (float, bool),否则调用方拿到 None 后再去累加,异常会抛在离现场很远的地方。罚分值取多大属于策略问题,建议先用一个固定的负值占位,确认训练能继续跑通再调。
用固定输入单测奖励函数
不跑完整训练也能复现问题,用三组固定输入直接调用奖励函数即可。放在 tests 目录下,作为普通 pytest 用例执行。
import math
import numpy as np
import pytest
def test_reward_normal(reward_fn):
r, done = reward_fn(np.array([0.5, 0.5], dtype=np.float64))
assert isinstance(r, float) and math.isfinite(r)
assert isinstance(done, bool)
def test_reward_out_of_range(reward_fn):
r, done = reward_fn(np.array([99.0, 0.5], dtype=np.float64))
assert r == reward_fn.penalty_invalid
assert done is False
def test_reward_none_action(reward_fn):
r, done = reward_fn(None)
assert r == reward_fn.penalty_invalid
assert done is False
预期结果是三个用例都返回有限的浮点值和布尔值,不抛异常、不返回 None。如果在“空动作”这一组里看到 TypeError 或 assert 失败,就说明兜底分支缺失,和训练日志里的空值对上了;如果三组都通过但训练仍然停住,说明问题不在奖励函数的输入处理上,回到第 1 节重新看日志断点。
确认终止条件不会提前触发
最后要排除一种误判:训练并不是卡死,而是 episode 被正常终止后没有再启动新的一轮,看起来像停住。常见配置项包括单回合最大步数(max_episode_steps 或 time_limit)、全局最大步数、早停耐心值(patience),以及 done 的来源是环境返回还是奖励函数自己算。建议逐项确认当前生效值,尤其在配置文件里改过但没被加载的情况。
验证方式是一次手动触发:把单回合最大步数临时改成一个很小的值(例如 5 或 10),只跑一小段,观察日志里 done 是否在预期步数变成 True、episode 编号是否加一并开始新一轮。如果能正常结束并重启,说明终止条件本身可用,中断另有原因;如果改成很小的值之后仍然停在同一处不动,问题基本可以排除终止条件,回到奖励函数的空值上继续查。