WorkForge 训练中途停住 / 先看奖励函数哪一步返回了空值

文章导读
训练跑到一半停住、日志里又没有明显异常,多数情况下不是任务卡死,而是奖励函数在某一步返回了 None、NaN 或形状不对的数组,导致后面的 done 判定和奖励累加抛出错误,又被上层的 try/except 或 worker 静默退出吞掉。建议把排查顺序固定成四步:先在日志里锁定中断发生在哪个 episode 的第几步,再把 reward 与 done 的返回结构逐步打印出来,然后检查奖励函数对非
📋 目录
  1. A 在日志里锁定中断前的最后几条记录
  2. B 逐步打印 reward 与 done 的输出
  3. C 检查奖励函数对异常动作的兜底分支
  4. D 用固定输入单测奖励函数
  5. E 确认终止条件不会提前触发
A A

训练跑到一半停住、日志里又没有明显异常,多数情况下不是任务卡死,而是奖励函数在某一步返回了 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 也一并看。

判断顺序建议如下,逐条排除:

  1. episode 编号和步数是否还在连续递增,停在同一 step 反复打印说明是卡住而不是退出;
  2. 最后一条记录里的 reward 是空、NaN、inf,还是字符串或列表;
  3. 最后一条记录里的 done 是不是已经变成 True,这关系到第 5 节要排除的提前终止;
  4. 日志尾部是否紧跟在某个特定动作之后,例如动作全为 0、维度不对或超出范围;
  5. 进程退出码是多少,正常结束、异常退出和被信号杀掉的表现不一样。
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 步或每步打印一次。

WorkForge 训练中途停住 / 先看奖励函数哪一步返回了空值
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 后再去累加,异常会抛在离现场很远的地方。罚分值取多大属于策略问题,建议先用一个固定的负值占位,确认训练能继续跑通再调。

WorkForge 训练中途停住 / 先看奖励函数哪一步返回了空值

用固定输入单测奖励函数

不跑完整训练也能复现问题,用三组固定输入直接调用奖励函数即可。放在 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 编号是否加一并开始新一轮。如果能正常结束并重启,说明终止条件本身可用,中断另有原因;如果改成很小的值之后仍然停在同一处不动,问题基本可以排除终止条件,回到奖励函数的空值上继续查。