12. 认字(中文 + 英文 + 符号):方案与实测

23.4k 字 · 源文件 PROJECT.md 第 2066 行起 · goal arch read

目标:屏幕给出内容,模型用键盘把它录入。中文打拼音、英文打字母、符号打符号。

#12.1 ★ 实测一:字 → 像素 → 特征格

ConvEnc 下采样 4 次:96 → 6,一格 = 16×16 像素。

字号W 宽高占几格
1.0×(英文原生)10px8px0.62 × 0.50
4.0×38px28px2.38 × 1.75

diag_glyph_legibility.py(K=48 ASCII,1152 训练 / 384 测试,随机基线 2.1%):

字号下采样通道特征图端到端冻结探针训练集
1.0×4 次46×684.6%2.3%98.7%
2.0×4 次46×696.4%6.5%100%
4.0×4 次46×699.2%19.0%100%
1.0×3 次412×1285.7%5.7%99.6%
1.0×2 次424×2481.2%6.0%100%
1.0×4 次1(只有 raw)6×62.1%2.9%2.1%

三条结论:

  1. Sobel 边缘层是认字的唯一通道。 只用 raw 单通道 = 2.1%(=瞎猜),四通道 = 84.6%。 字是细笔画,16× 下采样后 raw 的笔画被平均进背景;边缘把它救回来。 用户当初坚持"Sobel 边缘层还是加上"是对的,这条有 40 倍差距的实测支撑。

  2. 英文降低下采样没用(12×12 反而 85.7%、24×24 掉到 81.2%,参数多 14 倍)。 → 英文的结论不可外推到汉字(见 12.2)。

  3. 冻结随机编码器 + 线性探针只有 2~19%。强化学习不会重塑卷积核低层特征, 只能用已线性可读的东西 → 纯 RL 从零认字不可行,必须先有视觉预热或教师示范。

#12.2 ★★ 实测二:汉字完全不同(决定性对比)

diag_hanzi_legibility.py(K=200 GB2312 一级汉字,训练宋体 / 测试宋体+黑体+楷体, 随机基线 0.5%):

字号下采样特征图宋体(新渲染)黑体(没见过)楷体(没见过)
16px4 次6×60.5%(=瞎猜)0.5%0.5%
16px3 次12×1299.8%11.3%4.0%
24px4 次6×60.5%(=瞎猜)0.5%0.5%
32px4 次6×6100.0%12.3%11.8%
48px4 次6×6100.0%37.3%16.8%
64px4 次6×6100.0%89.9%29.4%
64px3 次12×12100.0%89.8%32.2%

第 1 行与第 2 行是同一个 16px 渲染,只换了编码器下采样深度:0.5% → 99.8%。 变量隔离干净:信息在像素里,是 6×6 那张图把它毁掉了。 一个汉字至少要 16×16 像素(GB2312 经典点阵 HZK16 就是 16×16),而 4 次下采样 后一格正好 16 像素 = 一个汉字占一格,一格装不下一个字。

英文与汉字对下采样深度的需求相反:

原生字号降下采样有用吗
英文字母(10px)84.6%(4 次就够)没用(12×12 → 85.7%)
汉字(16px)0.5%(4 次=瞎猜)决定性有用(12×12 → 99.8%)

原因:字母笔画少、笔画间留白大;汉字笔画密、结构相似(日/曰、己/已/巳 只差一笔), 必须保住空间细节。

★ 坏消息:跨字体泛化很差(同字体 100%,黑体 12~90%,楷体 4~32%)。 它很大程度上在读"宋体的像素",不是"字的结构"。 → 多字体混合训练不是可选项,是必需的;"换字体准确率"才是真判据。

#12.3 ★ 三个硬碰撞(汉字选择的后果)

碰撞内容
1. 键盘77 键里没有汉字。一个字至少两键(77²=5929),且"哪个字配哪个键"是任意的。老师教 3755 个任意映射 = 纯背密码表 —— 既不测认字,又正好撞上"拟合陷阱"。
2. 分辨率见 12.2。汉字要 ≥16×16 像素,而 4 次下采样一格就是 16 像素。必须改视觉前端。
3. 老师能教什么老师能教读音(客观、有结构、可迁移——约 80% 汉字是形声字);教不了"这个字按第 1234 号键"(任意、无结构、不可迁移)。

#12.4 已定方案(用户逐项确认)

项决定理由
中文录入拼音(屏幕给「中」→ 打 zhong)键盘原生(26 字母键);读音是客观事实;形声字提供可迁移结构(蚂/妈/码/玛 都从「马」得声);pypinyin 0.55.0 本机已装,标签零成本;输出字母表仍是那 48 个可打印字符,77 键动作契约一个字都不用改
视觉前端先全局 3 次下采样,再加中央凹3 次下采样是实测验证过的(16px 汉字 99.8%,一屏可放 6×6=36 字);中央凹下一步加,做消融。符合"一次只改一个变量"(§11.2)
首期范围200 常用字 + 26 字母 + 常用符号一轮几十分钟,能快速迭代出结论;跑通后同一套代码直接扩
中央凹十字准星就是中央凹用户已定光标为十字架 → 它得先把准星移到字上才能读,这就是眼动(saccade)。全局粗通路管"哪里有东西",高分辨率通路管"这是什么字"。主动感知,且裁剪位置可微 → 梯度能流回摇杆,它自己学会往哪看

#12.5 任务与奖励

任务(抄写):屏幕给内容 → 它按键录入。正确串 = 拼音(汉字) 或 字符本身。

奖励:r = −编辑距离(打出的串, 正确串)

策略打出的串编辑距离
乱按一通38 个字符−37
闭嘴不动空−1
打对zhong0

★ 这一条把探索问题直接消掉:77 个键是独立 sigmoid,logit=0 时 sigmoid(0)=0.5 → 每刻约 38 个键同时按下。若要求"精确匹配",纯随机下 需 77 位里恰好一位为 1 → 2⁻⁷⁷,纯 RL 在这样的动作空间里不存在。 而配上编辑距离奖励后,77 个键变成 77 个近乎独立的二分类:对的那个键拿正梯度、 其余 76 个拿负梯度。每个键都有直接信号。 最优策略集合与精确匹配完全相同(距离 0 ⟺ 打对),但不需要老师给答案。

emit 门第一次有了真正的职责:打对之后要闭嘴 —— 否则继续乱按会毁掉已打出的串。 "什么时候不说话"第一次成为一个有后果的决定。

老师(机制 3 教师干预):a_exec = a_policy + β_t·a_teacher,β: 1→0。 用户明确要求"需要老师来教一下,不然不可能按出来"。

#12.6 判据(事先写死)

判据阈值
留出字体(黑体/楷体)单字准确率见 12.2,先建立基线再定
撤掉老师后正确率≥ 老师在时的 50%
同声旁新字(少样本,≤3 次示范)≥ 50% —— §6.7 测出零样本迁移=0,但少样本从未测过,这条补上那个空洞
emit 门方差> 0(没退化成常量)
关掉 emit 门正确率显著下降(证明门在管事)

#12.7 复现命令

powershell
python experiments\diag_glyph_legibility.py --scales 1,2,3,4 --steps 1500
python experiments\diag_hanzi_legibility.py --classes 200 --sizes 16,24,32,48,64 --steps 3000

#12.8 ★ 静默失效:监控页在训练停了以后继续烧 CPU

症状:训练进程早已停了 9 分钟,live3d.html 那个标签页仍以 30fps 重绘。 用户的 Edge 硬件加速是关的(WebGL 走 SwiftShader 软渲染),于是它的 GPU 进程持续吃掉 6.21~6.98 个核(整机 39~44%),GPU 利用率却只有 3%。

根因(两层):

  1. loop() 不管有没有新数据都照画。判断"数据源还活着吗"用的是 "HTTP 还是 200 吗" —— 而看板进程活着时永远回上一次的快照,所以永远 200。 → 必须改用载荷自己的时间戳 updated(Python 侧 time.time()*1000)判新鲜度。

  2. ★ 我自己的补丁有个 bug:写成 let dataUpdatedAt = 0 + if (dataUpdatedAt && ...)。 0 是假值 → "从来没收到过数据"时短路,反而永不停止 —— 而那正是最该停的场景。 → 初值必须是 Date.now()。

规则:①监控页必须按数据新鲜度停画,且"从未收到数据"要算作陈旧; ②停画要真的不排下一帧(return 在 requestAnimationFrame 之前); ③数据回来了要能唤醒;④切后台(visibilitychange)同样停。

教训:Get-Process 的 CPU 增量必须在启动开销落定之后测稳态, 否则测到的是浏览器初始化(本次差点把启动的 39.7 CPU 秒当成渲染开销)。 另:无头验证必须用独立 --user-data-dir,否则会并进用户正在用的浏览器实例。

#12.9 ★★ 实测三:视觉预热(多字体把跨字体准确率救回来了)

experiments/pretrain_vision.py,字符集 247(200 汉字 + 26 字母 + 10 数字

  • 11 符号),训练字体 = 宋体/黑体/雅黑/仿宋,留出字体 = 楷体/等线/正黑

(训练时绝不出现),6000 步,batch 128,onset_prob=0.5(一半样本"字刚出现"、 一半"静止",防止部署时遇到没训过的输入分布):

配置同字体 top1楷体(留出)等线(留出)正黑(留出)留出均值
只训宋体 · cell24 · d398.1%24.3%39.4%28.9%30.9%
多字体 · cell24 · d397.7%65.3%93.3%86.8%81.8%
多字体 · cell16 · d394.7%52.5%86.6%83.4%74.2%
多字体 · cell24 · d40.4%(=瞎猜)0.4%0.4%0.4%0.4%

(随机基线 0.40%;ckpt 存在 results/ckpt/vision_*.pt)

四条结论:

  1. ★ 多字体训练把留出字体从 30.9% 拉到 81.8%(+50.9pp) —— 预登记判据 (≥50%)通过。机制成立:它真的在学「字的结构」,不是「宋体的像素」。 这也把 §12.2 那条坏消息(单字体换字体只有 12~32%)修掉了。

  2. cell=24 优于 cell=16(81.8% vs 74.2%,同字体 97.7% vs 94.7%)。 与门禁一致(§12.10:d_min 16px=1、24px=4)。选定 24px 字格, 代价是一屏 4×4=16 字(够放短句,不够放整段)。

  3. ★ 4 次下采样在 247 类下完全学不动:损失停在 5.52,而 ln(247)=5.51 正是均匀分布。不是"信息不足",是优化上完全无法区分 —— 均匀分布就是无信息时的正确最优解,所以"恰好等于随机"是这个regime的 正常表现,不是 bug。这与 §12.2 的 16px/24px 在 4 次下采样下都恰好等于 随机基线完全一致(两个脚本、两套数据、同一个现象)。

  4. ★ 我怀疑过是 dying ReLU,实测否掉了:各层激活非零比例 39~55%, 没有塌缩(层0 43.9% / 层2 38.8% / 层4 54.8% / 层6 53.3%)。 所以不能拿"网络死了"解释,就是 6×6 特征图对 247 类不可分。

顺带一个消融:静止输入(event 恒 0)与 onset 输入的最大差只有 0.8pp, 说明编码器没有把 event 层当拐杖 —— raw + edge 已经够用。 (event 层仍有用,§12.1 那条 40 倍差距说的是 raw 单通道不行。)

#12.10 门禁:字符集内在可分辨性(新增前置检查)

experiments/diag_charset_gate.py —— 任何认字实验的前置门禁 (§8.1 可学性阶梯的 L0 变体)。它量三件事,全部与模型无关:

字号点阵碰撞硬上限最小汉明距离谁是一对
12px0 组100%2,vs.、avsz、nvsu、svsz
16px0 组100%1l vs 1(宋体内嵌点阵把它们画得只差一像素);汉字 己vs已 d=2、大vs太 d=3
24px0 组100%4只剩 ,vs.;汉字全部退出前 12 名
32px0 组100%10,vs.
  • 无点阵碰撞 → 硬上限 100%,所以判据可以定高(若有两个字点阵相同, 上限就被字形锁死,与容量/步数/lr 全无关,必须先剔除)。

  • 16px 是最差的一档:l/1 只差 1 像素,而拼音里 l 很常用。 24px 起 d_min=4、汉字混淆消失 → 这是选 24px 字格的独立依据。

  • 7 个系统字体对 247 个字符全部全覆盖(font_coverage 检查),无缺字。 ★ 必须查:缺字的那些类永远是错的,会无声压掉准确率上限。

#12.11 已落地的代码

文件内容
core/glyphs.py★ 字符集唯一来源:read_charset / hanzi_set(字频序前 n,用已有的 COMMON_1000)/ read_target(汉字→拼音)/ pinyin_map / font_coverage。另有原有的 GlyphBank(二值、固定参考框、不按墨迹居中 —— 后者会人为放大字形差异)与 assert_min_distance 门禁
core/models.pyConvEnc 加 n_down(默认 4 保持既有实验可复现)与 4 维单刻输入支持;n_down=4 与原实现逐层等价(已自检)
core/envs/screen_io.py★ 动作契约与本体感觉的唯一定义(decode_action / proprio_vec),11 项自检。里面钉着三个踩出来的坑:Bernoulli 必须传 ±6、摇杆要传 tanh 之前、emit 是软乘法(门关≈0.0025 ≠ 0)。ForageScreen 已改用它,27 项自检无回归
experiments/pretrain_vision.py视觉预热 + 留出字体判据,存 ckpt
experiments/diag_charset_gate.py字符集内在可分辨性门禁
experiments/diag_glyph_legibility.py英文/ASCII 可读性扫描
experiments/diag_hanzi_legibility.py汉字可读性扫描(按字号 × 下采样深度)

下一阶段:core/envs/read_screen.py(认字环境,复用 screen_io 与 ForageScreen 的像素/本体感觉管线)→ exp31 抄写任务(老师示范 β:1→0 → 纯 RL,奖励 = −编辑距离)→ 少样本迁移判据。

#12.12 ★★★ 事故记录:一次 shell 往返把源文件写坏了

症状:core/envs/screen_agent.py 全部中文字符串变成乱码,且代码行丢失。

根因(两层,第二层才是真正危险的)

  1. Get-Content -Raw 用系统 ANSI(简体中文 = cp936/GBK)去解码一个 UTF-8 文件, 得到乱码字符串;Set-Content -Encoding utf8 又把它按 UTF-8 写回。 净效果 损坏 = utf8(gbk_decode(原文utf8字节))。 而且不是无损的:GBK 解码遇到无法映射的字节对会写成 ?,那个字节永久丢失 (实测丢了 298 字节)。

  2. ★★ 丢字节时解码器会把换行一起吃掉,于是下一行代码并进了上一行的注释里。 这一类不报语法错 —— 打开文件看"一切正常",只是静默少了一行代码。 实测被吞掉的代码有 7 处,包括 KEY_LAYOUT = []、SHIFT_MAP = {...}、 if self.target not in CHAR_INDEX:、LO_ON, LO_OFF = 6.0, -6.0。

恢复方法(顺序即可靠性顺序)

手段结果
反向映射 gbk_encode(损坏) -> utf8_decode只还原出大部分;298 处永久丢失
穷举 cp936 码表建反向表比 str.encode('gbk') 多救回一批(PUA 区与 € 用 'gbk' 编不回去)
exec 完好的 .pyc 取数据表★ 30/30 项精确取回,这是真相基准
types.FunctionType(code, my_globals)★ 把原版 tests() 跑在重建模块上 —— 最强行為验证
项目没有版本控制(无 .git)没有退路,只能靠上面的考古

踩到的坑(都值得记)

  1. ★ .pyc 未必比源码新。它的头里记着源文件的 mtime/size (实测 2026-09-26 22:59:37 / 28105 字节),用之前必须先核对, 否则会把旧版本当"已知良好"照抄。

  2. ★ pyc 里那条自检自身有 bug:反汇编可见它写的是 r2 = env.step(...) (漏了 [1]),于是 r2 是 4 元组、r2 == 0.0 恒为 False —— 它在原版模块上跑也照样 FAIL。我照抄时把这个 bug 一起抄了过来。 → 验证脚本现在把它标为"已知 pyc 测试 bug",并额外验证修正后的行为。

  3. pyc 头部长度:Python 3.7+ 一律 16 字节(我第一版按 flags 分 12/16, 报 bad marshal data)。12 字节是 3.6 及更早。

  4. dis.Instruction.lineno 在 3.13+ 改名 line_number(旧名被移除)。
  5. 重构引入循环导入:screen_agent 想用 screen_io.type_step, 而 screen_io 又要用 screen_agent 的常量 → 谁先被 import 谁拿到半成品。 → 修法:screen_agent 保持叶子模块(把 type_step 放回拥有键盘的它), 依赖方向单向:screen_io -> screen_agent。

规则(写进 §11 级的硬约束)

  1. ★★ 绝不用 shell 的字符串 cmdlet 往返读写源码(Get-Content/Set-Content)。 要改源码就用 edit 工具,或 Python 且显式 encoding="utf-8"。

  2. ★★ 这个项目需要版本控制。一次误操作 = 一个模块全毁,而恢复成本是十几轮工具调用。
  3. 用 .pyc 当真相基准前,先核对它的源文件 mtime/size。
  4. 只验证"能编译"是不够的:还要数据表逐项相等 + 行为等价。

留下的工具(都可复现)

工具作用
tools/dump_screen_agent.pyexec 完好 pyc,导出全部数据表与文档串
tools/verify_screen_agent.py三重验证:编译 / 数据表对 pyc / 原版 tests 跑在候选上
tools/dump_pyc_consts.py按行号导出 pyc 里的字符串常量
tools/recover_cp936.pycp936 码表反向还原
tools/recover_mojibake.py、tools/fix_screen_agent.py、tools/autofix_screen_agent.py事故当时的补丁流水线(保留作记录)

恢复后的状态:screen_agent 27 项自检全过;与 pyc 数据表 30/30 相等、行为等价。 screen_io 24 项、forage_screen 27 项全过;下游 5 个模块导入正常。

#12.13 认字环境 read_screen(已落地)

屏幕给一个字符,模型用键盘把它录入(汉字→拼音、字母/数字/符号→自身)。 core/envs/read_screen.py,40 项自检全过。

项取值依据
视觉(1,96,96) 原始像素(Crystal 在模型侧)→ 4 通道与 ForageScreen 同构
本体感觉(82,),恰等于 N_OUT内外分离:世界只从像素来
字格24px,4×4 格三条独立证据:点阵 d_min 16px=1/24px=4;16px 汉字在 4 次下采样下 0.5%、3 次 99.8%;留出字体 24px 81.8% vs 16px 74.2%
布局行 0 = 目标字(居中);行 2~3 = 回显串(≤8 格,拼音最长 zhuang=6)
奖励r = γ·Φ(s') − Φ(s),Φ = −编辑距离塑形开关就是"撤掉塑形还能不能做对"那条判据的入口
出题reset(rng, target=...) 可指定课程/教学要用

四个踩出来的事实(都写进了自检)

  1. ★ 打错一个字母的编辑距离和"什么都不打"相同(替换+插入 ≡ 插入+替换): d("", "zhong") = d("x", "zhong") = d("xxxxx", "zhong") = 5, 要到 d("xxxxxx") = 6 才变差。→ 错字不超过目标长度时"乱按"与"闭嘴"同分, 塑形奖励是 0 而不是负。对探索是好事(敢试不被罚),但必须知道, 否则会把"没惩罚"误读成"奖励设计错了"。

  2. ★ γ 必须真的用上。我第一版直接写 reward += prev_d − d —— 那等于 悄悄把 γ 固定成 1,gamma 参数成了摆设,而"γ<1 时原地不动白得奖励" 这条反向验证也就永远通过(恒真检查 = 没在测东西,§11.14)。 改成标准形式 γ·Φ(s') − Φ(s) 后,γ=0.9 的反向证据才真的能失败。

  3. ★ 同刻按多个键时按"键位顺序"产出,不是按下顺序: 同刻按 Z,H,O,N,G 得到 oghzn(按 KEY_LAYOUT 下标排序)。 → 打字必须逐字来(每个字符后补一刻松手,否则不构成 0→1 跳变)。 这也解释了为什么 type_step 里那句边沿检测是地基。

  4. ★ 门关时 echo_key 不是 0 而是 ≈emit(0.0025):screen_io.decode_action 是先阈值后门控(软门,与 ForageScreen 一致),而旧的 ScreenAgent 是 先门控后阈值(门关时恰好 0)。两者都能"关住"(真正决定按键的是 >0.5), 差别只在本体感觉读数。两个环境约定不同这件事必须记着,否则以后会对不上。

#12.14 ★ 视觉预热的权重怎么装进任务编码器

预热的是纯视觉编码器(prop_dim=0,fc.weight 形状 (256, 4608)); 任务里的编码器要融合本体感觉(prop_dim=82,(256, 4690))→ 直接 load 会 size mismatch。

★ 但不能把 fc 丢掉重来 —— 那样预热的卷积特征会被一个随机投影打乱, 预热白做一大半。做法(core/models.py::load_vision_encoder): 本体感觉那几列初始化为 0,视觉那几列照抄。实测:

text
载入后输出与纯视觉预热输出最大差 = 0.00e+00   (逐位相同)
换掉本体感觉后输出最大差        = 0.00e+00   (零初始化生效)

即"零初始化新通道":视觉通路完全保留,本体感觉从 0 开始自己学。

#12.15 当前状态

环境自检
screen_agent28 项全过(+ 与 pyc 数据表 30/30 相等、行为等价)
screen_io24 项全过
forage_screen27 项全过
read_screen40 项全过

端到端通路已验证:像素(1,96,96) + 本体感觉(82) → Crystal 4 通道 → 编码器 256 维(含本体感觉融合,可无损装入预热权重)。

下一步:experiments/exp31_read_rl.py —— 抄写任务的 RL 训练 (老师示范 β:1→0 → 纯 RL,奖励 = −编辑距离),判据 = 留出字体准确率、 撤掉老师后的表现、同声旁新字的少样本迁移、emit 门方差 > 0。

#12.16 exp31 认字抄写:首次训练结果与五个静默失效

experiments/exp31_read_rl.py:Crystal + 预热 ConvEnc(n_down=3) + CfC(256, backbone16)

  • 策略头/价值头。老师逐刻示范(打歪了示范 Backspace 当场纠正,打完了示范闭嘴),

β:1→0,奖励 = −编辑距离的势能塑形。982 刻/秒(batch16×T16),13 分钟跑完 3000 步。

步β课程池正确率认字留出字体空白屏
2001.004749.0%49.0%6.2%0.0%
8001.0010652.1%61.5%29.2%0.0%
12001.0019427.1%47.9%10.4%0.0%
16000.6323120.8%41.7%14.6%0.0%
30000.0024715.6%24.0%8.3%0.0%

("认字"= 看到目标后的第一刻、策略 argmax 的键是否等于老师会示范的键。 不依赖 emit 门 —— 门一塌 first_ok 必然为 0,会把"门没学会"误读成"字没认出"。)

判据逐条

判据结果
1. 正确率显著高于随机✅ 教学阶段 49~52%,随机基线 ≈0
2. ★ 认字(first_ok)✅ 峰值 87.5%,教学阶段稳定 40~60%
3. ★★ 空白屏对照✅✅ 全程 0.0%(正常 25~60%)—— 铁证:它真的在用像素
4. 留出字体⚠️ 仅 8~29%,弱
5. 撤掉老师后 ≥ 50%❌ 未通过(掉到 9~16%);认字仍维持 24~34%
6. emit 方差 > 0✅(0.28~0.45,没退化成常量)

结论:认字通路成立(教学下 52% 全串正确 / 87.5% 首字正确,且空白屏归零), 但仿放手的 RL 阶段维持不住,且跨字体泛化弱。这两条是下一轮要打的。

#★★★ 五个静默失效(都不报错、不 NaN,只表现为"怎么练都不动")

  1. ★★ EnvGroup.step 返回了新观测却没写回 self.obs,而调用方写的是 _f, _p, r, d, infos = grp.step(...)(把前两个丢了)→ 策略从头到尾看着第 0 刻的黑屏。 现场指纹:sup 收敛到边缘分布(maxlen=1 时 = ln47 = 3.85); 冻结编码器与不冻结毫无差别;但逐层追踪显示信号一路都在 (像素差 0.95 → 编码器输出差 1.45)。

  2. ★★ 同一个 bug 的第二现场:evaluate 里我直接调 env.step() 绕过 grp.step → 评估器也看黑屏。表现是"训练侧模仿对 100%、评估侧认字 1~5%", 极易被误读成过拟合或分布漂移。→ 现在有常量化金丝雀 frame_std 和自检断言盯着它。

  3. ★★ 采样与更新不是同一个策略:rollout 每刻调 forward(每次都从 h=0 开始,等价于无记忆),而 update 用整段 T 序列前向(有记忆)。 记录的動作与算出的 log-prob 不对应,REINFORCE 直接是错的。 → 必须用带状态的 step_tick,并加回归检查"逐刻 vs 整段 logits 逐位一致"。 ★ 附带坑:LiquidNet._run_recurrence_step 不能用 —— 它 return h_new 丢掉了 h_next,而 CfC 的"下一状态"是第二个返回值。

  4. ★ 第 0 刻老师照着黑屏示范按键:目标字还没出现(onset_tick=1), 而老师照样教"按 Z" —— 目标每回合都变,这条监督是自相矛盾的, 会主动破坏学习。→ 老师必须知道"看不见就教等"。

  5. ★ emit 监督类别极不平衡:一回合 16 刻,而"打一个字"只需 2 刻 → 14 个刻在教"闭嘴"、2 个在教"按键"。不加权 BCE 被沉默淹没,门塌成常数 → 门关着就什么都不打,而 d≈2.4 恰好是"什么都不打"的期望距离, 看起来像"没学会",其实只是门关着。→ 正例按负/正比加权(上限 20 倍)。

#方法论收获(可复用)

  • 分层可量是定位这类 bug 的唯一办法:像素差 → Crystal 差 → 编码器差 → CfC 隐状态差 → logits 差。逐层打印,断在哪一层一目了然。 (本次实测:0.95 → 1.06 → 1.45 → 0.107,而 pi.weight 零初始化使 logits 差 = 0, 那是预期的,不是 bug。)

  • ★ sup 卡在 ln(候选数) 就是"完全没用输入"的指纹 —— 那是无信息时的正确最优解(§12.9 第 3 条的同款现象)。

  • ★ "训练好、评估差"先怀疑评估器也看错了输入,别急着说过拟合。
  • ★ 指标要挑不依赖其它子系统的:first_ok 依赖 emit 门,门塌就恒为 0; key_first_ok(直接比 argmax 键)才是干净的读数。

#12.17 实测四:记忆地平线(假设被否掉的那一次)

动机:exp31 里老师一走认字就从 61.5% 掉到 9.4%。我当时的诊断是 任务设计漏洞——目标字一直显示在屏幕上,每一刻都能重看,记忆是不必要的, 所以长不出记忆。为此给 ReadScreen 加了 hide_after(目标字显示 N 刻后消失)。

做法:拿已经训好、从没见过"字消失"的策略(read_C_pure_imitation.pt, 纯模仿 3000 步)直接放到不同 N 下跑 —— 零样本,不重训。

hide_after0(字一直在)123581220
正确率31.2%20.8%31.2%33.3%27.1%36.5%32.3%33.3%
认字(首字)64.6%61.5%62.5%71.9%63.5%69.8%63.5%74.0%
门(该开/该关)0.10/0.27

(地板 = 随机 = 0.40%;experiments/diag_read_memory.py)

三条结论,其中两条推翻了我自己的判断:

  1. ✅ 校准锚点通过:N=0 给 31.2%,训练时报 30.2% —— 评估管线可信 (§11.15 要求每个指标都报天花板与地板,这条就是天花板对照)。

  2. ★★ "任务漏洞"假设被否掉:字消失到 20 刻,准确率一点不掉(甚至略升)。 状态本来就能自己托住内容,零样本、从没训过隐藏条件。 这与项目早期的测量一致(延迟期指令可读出率 = 100%,§7.2)。 → "老师一走就崩"不是记忆问题。 我上一轮的诊断是错的。

  3. ★★ 真正的瓶颈是「每字正确率只有 65%」,误差是乘性的: 31.2% ≈ 0.65³(平均拼音长度 3),长度 6 的拼音只剩 0.65⁶ ≈ 7.5%。 这解释了为什么池扩到 247 时正确率掉下来 —— 不是记不住,是错一个就全错。

  4. ★ emit 门严重失准:该开时只有 0.10(应该 >0.5),该关时 0.27。 门基本是关着的,而它一旦开门往往就打对了。这是 RL 阶段的一个独立瓶颈。

下一步因此改变(原计划的"A′ 用 hide_after 重训"取消 —— 状态本来就托得住):

新优先级做什么为什么
1自我纠正(看到打错的就 Backspace 重打)★ 最高杠杆:把乘性误差变成可恢复的。老师本来就在教这个动作
2修 emit 门门该开时不开,是独立的瓶颈
3提高每字正确率(65% → 90%)换增广编码器、加训练量
4B 组合迁移 / C 内驱力原计划不变

★ 方法论:这一轮的价值在于假设被干净地否掉了。如果没做 N=0 那个校准锚点, 我会把"31.2%"当成"记忆掉了"的证据,然后去改循环核 —— 方向完全错。

#12.18 ★★★ 实测五:同声旁迁移(分水岭实验,结果是"种子在")

这是整个项目最关键的一次测量:它区分「理解」和「背表」。 §6.7 只测出零样本迁移 = 0,而少样本/结构迁移从来没测过。

#字组是自动构造的,不需要人工标注形声字表

tools/build_phonetic_groups.py:形声字有两个可自动检测的特征 —— ① 同音(声旁相同 → 读音相同)用 pypinyin 分组; ② 长得像(共享一个部件,只差偏旁)用 24×24 位图 IoU 聚类。 两个条件同时满足才收(只用 ① 会把 一/意/义 凑一起;只用 ② 会把 未/末 凑一起, 那是形近字不是形声字)。实测 3755 个 GB2312 一级字里找到 49 个簇,抽出来全是标准形声字组:

text
[fu]   辐幅福辅(畐)   [man]  慢馒漫谩(曼)   [mu]   暮幕募慕(莫)
[bang] 磅膀镑(旁)     [ce]   测厕侧(则)     [cheng]程称秤(呈)
[e]    蛾娥饿(我)     [fang] 坊仿访(方)     [fen]  汾纷份(分)
[dan]  掸惮弹(单)     [liao] 撩僚燎镣(尞)   [chan] 搀馋谗(毚)

#严格配对的对照设计(两次修正才做对)

版本缺陷
v1结构条件训练集 102 字 vs 随机条件 3706 字(差 36 倍)→ 分不清是"结构"还是"训练量"
v2随机留出的字读音也不在训练集里 → 条件 B 变成"字形新 + 读音也新",多一个混淆变量
v3(采用)同一个训练集 T,两个留出集只差一个变量

v3 设计:训练集 T = 49 个簇各留一个后的成员(102 字)

  • 条件 A 结构留出(48 字):没教过、读音教过、且与 T 里某字共享声旁
  • 条件 B 对照留出(48 字):没教过、读音教过、但与 T 里任何字都不形似 (构造法:取同一音节但不在簇里的字。例:簇 [man]=慢馒漫谩(曼),对照取 瞒拇…)

  • 实测核对:读音在 T 里出现过的比例 A 48/48、B 48/48 ✓(否则读音又成混淆变量)

★ 一次训练即可同时评估两个留出集(同一个策略),省一半算力。

#结果

条件训练集(102字)A 结构留出B 对照留出A/B
B1 纯模仿 4000 步100.0%71.9% / 63.5%7.3% / 6.2%≈10×
B2 带放手 4000 步69.8%45.8% → 27.1%3.1% → 0.0%≈15×

(两次评估分别在第 3800/4000 步;results/logs/exp31_B1.log / B2.log)

#判读:这是"理解的种子在"的第一个硬证据

先做稳健性验证(防止把一次侥幸当结论):两条曲线全程 20 次评估 —— A 从 0 稳步爬到 70+,B 全程平在 5% 左右,是学习曲线,不是抖动:

A 均值B 均值倍数A 最高B 最高
B1 纯模仿43.0%5.5%7.8×76%14%
B2 带放手24.7%3.4%7.3×46%7%

预登记判据是「A >> B → 真的在用结构;A ≈ B → 只是背表」。 实测 A 是 B 的约 8 倍(均值口径),所以:

★★ 它不是纯查表器。 一个从没教过的字,只要与教过的字共享声旁, 就能读对 63~72%;而同样没教过、同样读音、但不形似的字只有 6~7%。 结构被迁移过去了。

这直接回答了那条悬了很久的问题链:

  • "零样本迁移 = 0"(§6.7)→ 但结构迁移不是 0,而且很强
  • "它有学习能力吗" → 有,至少在字形-语音这一层有
  • "理解句子/情感/关系有没有立足点" → 有立足点了(组合的种子在)

#★ 诚实的边界(必须标注,否则会过度解读)

机制很可能是「视觉最近邻 → 迁移读音」:A 的字与 T 的字长得像, 所以卷积特征落在熟悉的区域。对形声字来说,这恰恰就是"用声旁" —— 但严格说,我们还不能排除"它在用任何视觉相似性(包括非声旁的偏旁)"。

要排除它,需要第三个控制条件(下一步):

形近但异音的留出字(未/末、己/已、土/士 这类)。 若它把"长得像的那个字"的读音搬过来,这些应当错; 若它仍能读对,说明它在用更细的结构,不只是整体形似。

#另一条独立结论:RL 阶段削弱了结构迁移

B2(带放手)的 A 从 45.8% 掉到 27.1%,与 §12.16/§12.17 的结论一致: β→0 之后策略漂移。所以"放手后维持不住"这个问题在迁移能力上同样成立。

#第三个控制条件:「形近但异音」——第一次构造失败,记下正确做法

目的:A 的高准确率有一个尚未排除的替代解释—— 它只是在做视觉最近邻("跟教过的某个字长得像,就输出那个字的读音")。 对形声字来说这恰好就是用声旁,所以两种解释在 A 上给出同样的预测,分不开。 要分开,就得找长得像但读音不同的字(未/末、己/已、土/士)。

第一次尝试(tools/add_near_control.py,IoU ≥ 0.62)失败,两个原因:

  1. 双胞胎必须在 T 里,而 T 只有 102 个簇成员 → 只找到 5 个, 而且全不是经典形近字对(找到的是 钒/饥、桃/挑、慎/情、拴/捡、蹭/赠)

  2. ★ 这 5 个的读音都不在 T 里(0/5) → 它们就算有完美的结构也读不出来 (目标串根本不在已学过的读音集合里)→ 这个对照是不公平的, 测出来的低准确率无法归因给"视觉混淆"

正确做法(下一步):

主动把形近异音对拆开 —— 对每一对 (c, twin),把 twin 放进 T、把 c 留出, 并同时保证 c 的读音在 T 里另有出现(否则读音本身就成了混淆变量,见 v2 的教训)。 即:构造 T 时就要把"形近异音对"当成一个约束加进去,而不是事后从现成的 T 里找。

★ 这一条本身是个方法论教训:对照集必须和实验组在"哪些变量被控制住"上对称。 第一次构造只想着"找到形近异音的字",忘了"读音也要教过"—— 而这正是 v2 犯过的同一个错(当时是随机留出的读音不在 T 里)。

并且要记住:两种结果都有信息量,这不是"必须成功"的检验:

  • C 几乎全错 → 机制就是"视觉相似 → 迁移读音"。这仍然远超查表器 (查表器连 A 都做不到),只是"结构"的粒度是整体形状

  • C 也能读对不少 → 它在用比整体形状更细的东西

#12.19 ★★★ 实测六:C′ 形近异音跑完了 —— 结论要改,而且改的是我自己

本节承 §12.18 末尾那个"第三个控制条件"。C′ 跑完了(exp31_Cnear.log,3000 步, 142 字训练集 + 视觉预热 vision_multi_c24_d3_aug.pt)。 但是在给结论之前,先补了三个原本缺失的锚点,结果把 §12.18 的结论推翻了一半。 这一节的价值主要不在 C′ 本身,而在"我差点又一次把没有分辨力的实验当成证据"。

#⚠️ 事故一:那个"142 字 + hold_struct"的跑次里,A 根本没被测

文件trainhold_structhold_ctrlhold_near
tools/build_phonetic_groups.py 产出 phonetic_groups.json10248485
tools/build_near_experiment.py 产出 near_experiment.json142键不存在2224

exp31_read_rl.py 的 --holdout-json 读的是一个文件。exp31_Cnear 用的是 near_experiment.json,里面没有 hold_struct 键 → 那一行 [A结构] 根本没打印 (日志里只有 [B对照] 与 [C形近异音] 两行,与这个结论一致)。

★ 教训:"跑了三个留出集"这句话必须用日志行数核对,不能靠配置意图。 我口头说过"A/B/C′ 同一次测",实际只有 B/C′。

修法:tools/merge_transfer_sets.py 把两版合成 results/logs/transfer_sets.json (142 训练 + A 48 / B 22 / C′ 24),并核对重叠(train∩A = train∩B = train∩C = 0, 只有 A∩C′ 的 抡 一个字重复,已记录)。

#★★ 事故二(更贵):C′ 的读数没有分辨力,我差点把它当证据

我最初的写法是"C′ ≈ B → 视觉最近邻假说被否掉"。这是错的, 因为我算漏了一个分母。补了 experiments/diag_nn_baseline.py(纯查最近邻、零学习):

留出集一个"只会输出最近邻读音"的模型能考几分目标读音在训练读音表里
A 结构93.8%100%
B 对照9.1%100%
C′ 形近异音0.0%100%

这一张表把所有事情翻过来了:

  1. A 的"查最近邻满分线"是 93.8% —— 因为形声字的最近邻就是同音的那个字 (实测:镣→撩 IoU 0.621、暮→募 0.605、馒→慢 0.552,读音全同)。

    ★★ 所以 §12.18 那句"它不是纯查表器"证据不足。 A 考的很可能就是"抄最近邻",而 A 的高分完全可以是查表查出来的。

  2. C′ 的满分线是 0.0% —— 它的最近邻读音故意与真读音不同。 于是"查表假说"预测 0%、"读声旁假说"预测高;两个假说预测相反,本该有分辨力。 但实测 C′ = 2.0%,而 B = 3.1%,两者都在噪声里 —— 模型在 C′ 上两种都没做到,所以这一格也没能证实任何一方。

#逐字读数:它确实在输出孪生字的读音(41.7%)

experiments/diag_transfer_strings.py 把每一个字的预测串倒出来(--reps 2):

留出集n真读音输出==孪生字读音输出是合法拼音一个键都没按
A 结构9645.8%—79.2%0.0%
B 对照444.5%—54.5%0.0%
C′ 形近异音486.2%41.7%89.6%0.0%

样例(目标 → 模型输出):推(tui) → 'kui'(孪生 傀 gui 的读音)、 昧(mei) → 'wei'(孪生 味 wei)、乓(pang) → 'ping'(孪生 乒 ping)、 戍(shu) → 'dan'(不是孪生,是训练集里的 惮 dan)。

★ 结论:给一个没教过的字,它会从训练过的读音里挑一个输出, 而且挑的经常是"长得最像的那个字"的读音——但仍不足以让它读对。

#最终结论(取代 §12.18 的判读)

★★ "结构迁移"这条线目前没有证据;也不能说被否掉。

· A 的 7.8× 仍然成立(B1/B2 两次、40 个评估点、18/20 胜出,不是抖动), 但 A 的机制没有被识别出来 —— 它既可以是"读声旁", 也可以是"抄最近邻"(满分线 93.8% 已经把这条路铺好了),现有数据分不开。 · C′ 没能充当那个分辨器:它的满分线是 0%,两个假说都预测"低", 实测 2.0% 落在噪声里。 · 唯一有分辨力的读数是逐字预测串:C′ 上 41.7% 输出孪生读音 —— 这支持"视觉最近邻"这条机制,但它不等于"只会查表", 因为查最近邻在 C′ 上只能考 0%,实测 6.2%。

#为什么没能造出真正的分辨器(留档,别再重走)

要分开两个假说,需要四格:视觉近/远 × 读音覆盖/不覆盖。 tools/build_confound_matrix.py v1(τ=0.55)造出来了,但四格的 查最近邻基线全是 8.3% / 0% / 0% / 0% → 依旧没有分辨力。 v2 把 τ 提到 0.62 之后,候选池塌了:

text
V+P+ (视觉近 + 最近邻同音,≈ A 的构造): 候选只剩 1 个
V+P- (视觉近 + 最近邻异音)          : 候选只剩 0 个
V-P+                                  : 候选 13 个
V-P-                                  : 候选 837 个

原因:COMMON_1000 里"视觉近"的关系大量已经被并进训练集了(A 的构造就是抽干它们)。

★ 教训:设计四格之前要先量每一格的候选数。"能把四格填满"是设计的前提, 不是设计完成之后的实现细节。

#这一节真正的方法论收获

"校准锚点必须最先做"第三次救命(前两次:31.2% vs 30.2%、6×6 不可分):

我想下的结论缺的那个锚点补上之后
"C′ 否掉了视觉最近邻"查最近邻满分线C′ 满分线 0% → 它没有分辨力
"A 证明它不是查表器"A 的满分线93.8% → 分不开,结论降级为"未识别机制"

★ 一个留出集只有配上"一个零学习基线能在它上面考几分"这个数,才是一把尺子。 没有这个数的留出集,读数只能证明"低",不能证明"因为什么而低"。


← 返回《PROJECT.md》目录