14. 接地线:读一字指令 → 摇杆把方块开到对应格

4.3k 字 · 源文件 PROJECT.md 第 2879 行起 · goal ground read external

#14.1 为什么它是"认字"之后的第一件事

ReadScreen 里字的唯一后果是"打对了老师给分"。打对打错对世界毫无影响 —— 那个任务里没有世界。本项目一直缺的那一环就是让"读懂"第一次有后果:

text
认字   :字 -> 拼音              (符号到符号,无后果)
接地线 :字 -> 动作 -> 世界改变   (**后果**)

它一次验证三条硬需求:接地(词义 = 后果)、可打断(中途换指令能转向)、 持续状态(指令消失后仍记得)。

#14.2 复用与不复用的边界(这份边界本身就是设计)

复用(同一份定义,绝不另写一份):

复用项来自
观测契约 [眼睛 (1,96,96) 原始像素] + [本体感觉 (82)],内外分离read_screen / forage_screen
动作契约 (82,) 原始 logits(键盘 77 / 摇杆 2 / 鼠标 2 / emit 1)screen_io.decode_action
Crystal 四通道视觉前端、ConvEnc、CfC 循环核、ReadPolicy 两个头core/darlin、core/models、exp31
视觉预热权重 vision_multi_c24_d3_aug.pt§12.9

不复用(本来就是不同的问题):

不复用项换成为什么
环境core/envs/ground_screen.py世界不同
奖励来源世界给(到位),不是老师给(打对)这就是"接地"的定义
策略分布摇杆走对角高斯,键/鼠标/emit 仍独立 Bernoulli摇杆是连续控制
梯度估计actor-critic + GAE(λ)连续控制里 REINFORCE 方差太大
老师指向目标的 P 控制器正确动作是连续的,示范"朝哪推、推多满"

★ 动作契约与观测契约完全相同,所以"视觉预热权重"和"CfC 循环核"能直接复用 —— 这是当初把 screen_io 做成唯一定义(而不是每个环境各写一份)的回报。

#14.3 世界(ground_screen.py)

text
屏幕 96×96,24px 字格 → 4×4 格
  ┌──────────────┐
  │  行0  : 指令字「上/下/左/右」(与 ReadScreen 目标字同位置:行0 列1)
  │  行1~5: 3 列 × 5 行的世界(横向 24px/格,纵向 14.4px/格)
  └──────────────┘
方块起点 = 世界正中;四个指令 -> 世界四角
灰度层级:空格 0.06 < 起点 0.16 < 目标 0.42 < 方块 0.72(必须两两分得开)

四个关键尺寸都是被实测逼出来的:

参数取值为什么不能是别的
世界行数5(不是 3)3×3 世界里 24px 的格距在 MOVE_PX=8 时一刻就走到 —— 实测老师的示范只用 1 刻,任务退化成"按一下就完",没有轨迹可学
MOVE_PX4.0(不是 8)8 时"30 刻走完全程"变成 10 刻,稳态窗口太短
目标位置四角(不是上下左右正向)世界中到"正左/正右"只有 24px、到"正上/正下"36px —— 四个指令难度不同,读数会被最易的那个带走。四角到正中的距离都是 37.5px,等难
方向语义含「上」→ r 小、含「左」→ c 小★ 第一版把「左」指到右上角,方向语义整个反了。这类错误不报异常,只让"接地"从一开始就是反的

奖励:r = γ·Φ(s') − Φ(s) − 0.02·|动作|,Φ = −0.25 × 曼哈顿格距,γ 必须 = 1.0。 到位另有 +1。→ 最优总回报 ≈ +1 − 0.02×24 ≈ +0.52。

自检 30 项(含方向语义、横竖格距、空白屏对照、γ=1 时不动得 0、世界边界夹取)。

#14.4 ★★ 判据的分母:随机基线(ground_random_baseline.py)

没有分母的"3×"是没有意义的(§13.5 第 1 条教训的又一次应用)。实测:

策略到位率终局在目标格
随机方向 / 每刻换1.3%1.3%
随机方向 / 每 3 刻换8.3%4.0%
不动(门关)0.0%0.0%
去掉目标格高亮后(两个随机变体)与上完全相同——

★ 取最大值 8.3% 当分母(拿最小的当分母会把判据变松):

判据 1:教过的指令到位率必须 > 25%(= 3 × 8.3%)

★ 顺带一个有用的旁证:去掉目标格高亮,随机基线一点没变 —— 说明随机策略本来就不会去追亮格,所以"世界自己会亮"不算给策略白送分。

#14.5 交叉验证过的两件事(写下来防止以后重踩)

  1. ★ 老师的示范长度是任务是否可学的体检指标。 自检里报了"4 刻到位"。 第一版世界(3×3 / 8px)报的是 1 刻 —— 那个数字一出来就该知道世界太小。 把"老师的示范要多久"做成自检项,比事后看学习曲线便宜得多。

  2. ★ step() 与随机基线必须共用同一段世界推进。 为此把 ground_screen._apply(ctl) 拆成公开方法:随机基线要重放动作序列, 不能通过 step()(那会消耗随机数、改回显与时刻)。两份实现一定会漂移, 漂移的表现是"基线比被测策略更容易/更难",读数直接作废。

#14.6 ⚠️⚠️ 第一次接地线训练:两个失效同时暴露(都很有教益)

exp41_c1_base 第一次跑(2026-09-27),前 180 步:

步到位率空白屏到位率pi loss说明
10.0%0.0%+0.000开始
6045.3%50.0%+0.000← 就这里不对
120100.0%100.0%+0.075← 空白屏也是 100%
1800.0%0.0%-45289← 发散了

#失效一:★★ 空白屏对照失效 → 任务根本没测"读指令"

空白屏(指令区涂黑)也是 100% 到位 —— 说明它压根没读那个字, 只是朝亮着的目标格开。这正是 §14.4 里预言过的漏洞,而 c1_base 没有用 --highlight-after 去堵它。

★★ 教训:一个"对照能拿满分"的任务,等于没有对照。 这与 §12.19 那个"C′ 满分线 0%"是同一类错误的两面: · C′:满分线 0% -> 测不出东西(太难) · 接地线:空白屏 100% -> 测不出东西(太易) 两者的共同症状都是"实验组和对照组分不开"。

修法:所有变体一律加 --highlight-after 12 —— 开头 12 刻屏幕上只有指令字, 目标格 12 刻后才亮。于是:

  • 在高亮出现之前到位 = 只可能是读了指令(读数 pre_hl)
  • 空白屏 + 高亮延迟 = 真的什么都没有,数值才有意义

#失效二:★★ log_std 没夹取 → 连续控制的经典发散

pi loss 从 +0.075 一步跳到 -45289。

机制:log_std 是可学参数。没有任何夹取时,pi 的梯度会一直把它往下推 (分布收窄 -> 已采动作的 log-prob 变大 -> 损失更负 -> 梯度更大)—— 正反馈发散。sd -> 0、log_prob -> +inf。

★ 连续控制的 PPO/SAC 实现里都夹 log_std,这是标准做法,我漏了。 修法:clamp_logstd(),区间 [-3, 1](sd ∈ [0.05, 2.72])。 ★★ 采样与算 log-prob 必须用同一个夹取后的 sd —— 所以只允许通过 一个函数拿 sd,两处都调它(否则又是 §13.2 那个 "采样分布 ≠ 算 log-prob 的分布"的静默错误)。

另外补了 NaN/Inf 闸门(与 exp31 同一套),并在错误信息里列了三条常见原因。

#★★ 这两件事的共性(值得单独记)

表现为什么危险
空白屏 100%指标看起来很好(100% 到位!)好到不可能,而"好"不会让人去查
pi loss -45289梯度被 clip 到 1.0,不报错唯一线索是那个数字,而它只出现在一行日志里

两个失效的共同点:都不报错,而且一个"看起来太好"、一个"看起来只是数字大"。 立即可用的判据:对照条件拿到接近实验组的分数时,先怀疑任务设计,不要庆祝。


← 返回《PROJECT.md》目录