14. 接地线:读一字指令 → 摇杆把方块开到对应格
4.3k 字 ·
源文件 PROJECT.md 第 2879 行起 ·
goal ground read external
#14.1 为什么它是"认字"之后的第一件事
ReadScreen 里字的唯一后果是"打对了老师给分"。打对打错对世界毫无影响 ——
那个任务里没有世界。本项目一直缺的那一环就是让"读懂"第一次有后果:
认字 :字 -> 拼音 (符号到符号,无后果)
接地线 :字 -> 动作 -> 世界改变 (**后果**)它一次验证三条硬需求:接地(词义 = 后果)、可打断(中途换指令能转向)、 持续状态(指令消失后仍记得)。
#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)
屏幕 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_PX | 4.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 交叉验证过的两件事(写下来防止以后重踩)
- ★ 老师的示范长度是任务是否可学的体检指标。 自检里报了"4 刻到位"。 第一版世界(3×3 / 8px)报的是 1 刻 —— 那个数字一出来就该知道世界太小。 把"老师的示范要多久"做成自检项,比事后看学习曲线便宜得多。
- ★
step()与随机基线必须共用同一段世界推进。 为此把ground_screen._apply(ctl)拆成公开方法:随机基线要重放动作序列, 不能通过step()(那会消耗随机数、改回显与时刻)。两份实现一定会漂移, 漂移的表现是"基线比被测策略更容易/更难",读数直接作废。
#14.6 ⚠️⚠️ 第一次接地线训练:两个失效同时暴露(都很有教益)
exp41_c1_base 第一次跑(2026-09-27),前 180 步:
| 步 | 到位率 | 空白屏到位率 | pi loss | 说明 |
|---|---|---|---|---|
| 1 | 0.0% | 0.0% | +0.000 | 开始 |
| 60 | 45.3% | 50.0% | +0.000 | ← 就这里不对 |
| 120 | 100.0% | 100.0% | +0.075 | ← 空白屏也是 100% |
| 180 | 0.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,不报错 | 唯一线索是那个数字,而它只出现在一行日志里 |
两个失效的共同点:都不报错,而且一个"看起来太好"、一个"看起来只是数字大"。 立即可用的判据:对照条件拿到接近实验组的分数时,先怀疑任务设计,不要庆祝。