12. 认字(中文 + 英文 + 符号):方案与实测
目标:屏幕给出内容,模型用键盘把它录入。中文打拼音、英文打字母、符号打符号。
#12.1 ★ 实测一:字 → 像素 → 特征格
ConvEnc 下采样 4 次:96 → 6,一格 = 16×16 像素。
| 字号 | W 宽 | 高 | 占几格 |
|---|---|---|---|
| 1.0×(英文原生) | 10px | 8px | 0.62 × 0.50 |
| 4.0× | 38px | 28px | 2.38 × 1.75 |
diag_glyph_legibility.py(K=48 ASCII,1152 训练 / 384 测试,随机基线 2.1%):
| 字号 | 下采样 | 通道 | 特征图 | 端到端 | 冻结探针 | 训练集 |
|---|---|---|---|---|---|---|
| 1.0× | 4 次 | 4 | 6×6 | 84.6% | 2.3% | 98.7% |
| 2.0× | 4 次 | 4 | 6×6 | 96.4% | 6.5% | 100% |
| 4.0× | 4 次 | 4 | 6×6 | 99.2% | 19.0% | 100% |
| 1.0× | 3 次 | 4 | 12×12 | 85.7% | 5.7% | 99.6% |
| 1.0× | 2 次 | 4 | 24×24 | 81.2% | 6.0% | 100% |
| 1.0× | 4 次 | 1(只有 raw) | 6×6 | 2.1% | 2.9% | 2.1% |
三条结论:
- Sobel 边缘层是认字的唯一通道。 只用 raw 单通道 = 2.1%(=瞎猜),四通道 = 84.6%。 字是细笔画,16× 下采样后 raw 的笔画被平均进背景;边缘把它救回来。 用户当初坚持"Sobel 边缘层还是加上"是对的,这条有 40 倍差距的实测支撑。
- 英文降低下采样没用(12×12 反而 85.7%、24×24 掉到 81.2%,参数多 14 倍)。 → 英文的结论不可外推到汉字(见 12.2)。
- 冻结随机编码器 + 线性探针只有 2~19%。强化学习不会重塑卷积核低层特征, 只能用已线性可读的东西 → 纯 RL 从零认字不可行,必须先有视觉预热或教师示范。
#12.2 ★★ 实测二:汉字完全不同(决定性对比)
diag_hanzi_legibility.py(K=200 GB2312 一级汉字,训练宋体 / 测试宋体+黑体+楷体,
随机基线 0.5%):
| 字号 | 下采样 | 特征图 | 宋体(新渲染) | 黑体(没见过) | 楷体(没见过) |
|---|---|---|---|---|---|
| 16px | 4 次 | 6×6 | 0.5%(=瞎猜) | 0.5% | 0.5% |
| 16px | 3 次 | 12×12 | 99.8% | 11.3% | 4.0% |
| 24px | 4 次 | 6×6 | 0.5%(=瞎猜) | 0.5% | 0.5% |
| 32px | 4 次 | 6×6 | 100.0% | 12.3% | 11.8% |
| 48px | 4 次 | 6×6 | 100.0% | 37.3% | 16.8% |
| 64px | 4 次 | 6×6 | 100.0% | 89.9% | 29.4% |
| 64px | 3 次 | 12×12 | 100.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 |
| 打对 | zhong | 0 |
★ 这一条把探索问题直接消掉: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 复现命令
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%。
根因(两层):
loop()不管有没有新数据都照画。判断"数据源还活着吗"用的是 "HTTP 还是 200 吗" —— 而看板进程活着时永远回上一次的快照,所以永远 200。 → 必须改用载荷自己的时间戳updated(Python 侧time.time()*1000)判新鲜度。- ★ 我自己的补丁有个 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 · d3 | 98.1% | 24.3% | 39.4% | 28.9% | 30.9% |
| 多字体 · cell24 · d3 | 97.7% | 65.3% | 93.3% | 86.8% | 81.8% |
| 多字体 · cell16 · d3 | 94.7% | 52.5% | 86.6% | 83.4% | 74.2% |
| 多字体 · cell24 · d4 | 0.4%(=瞎猜) | 0.4% | 0.4% | 0.4% | 0.4% |
(随机基线 0.40%;ckpt 存在 results/ckpt/vision_*.pt)
四条结论:
- ★ 多字体训练把留出字体从 30.9% 拉到 81.8%(+50.9pp) —— 预登记判据 (≥50%)通过。机制成立:它真的在学「字的结构」,不是「宋体的像素」。 这也把 §12.2 那条坏消息(单字体换字体只有 12~32%)修掉了。
- 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 字(够放短句,不够放整段)。
- ★ 4 次下采样在 247 类下完全学不动:损失停在 5.52,而
ln(247)=5.51正是均匀分布。不是"信息不足",是优化上完全无法区分 —— 均匀分布就是无信息时的正确最优解,所以"恰好等于随机"是这个regime的 正常表现,不是 bug。这与 §12.2 的 16px/24px 在 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 变体)。它量三件事,全部与模型无关:
| 字号 | 点阵碰撞 | 硬上限 | 最小汉明距离 | 谁是一对 |
|---|---|---|---|---|
| 12px | 0 组 | 100% | 2 | ,vs.、avsz、nvsu、svsz |
| 16px | 0 组 | 100% | 1 | l vs 1(宋体内嵌点阵把它们画得只差一像素);汉字 己vs已 d=2、大vs太 d=3 |
| 24px | 0 组 | 100% | 4 | 只剩 ,vs.;汉字全部退出前 12 名 |
| 32px | 0 组 | 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.py | ConvEnc 加 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 全部中文字符串变成乱码,且代码行丢失。
根因(两层,第二层才是真正危险的)
Get-Content -Raw用系统 ANSI(简体中文 = cp936/GBK)去解码一个 UTF-8 文件, 得到乱码字符串;Set-Content -Encoding utf8又把它按 UTF-8 写回。 净效果损坏 = utf8(gbk_decode(原文utf8字节))。 而且不是无损的:GBK 解码遇到无法映射的字节对会写成?,那个字节永久丢失 (实测丢了 298 字节)。- ★★ 丢字节时解码器会把换行一起吃掉,于是下一行代码并进了上一行的注释里。
这一类不报语法错 —— 打开文件看"一切正常",只是静默少了一行代码。
实测被吞掉的代码有 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) | 没有退路,只能靠上面的考古 |
踩到的坑(都值得记)
- ★
.pyc未必比源码新。它的头里记着源文件的 mtime/size (实测2026-09-26 22:59:37/28105 字节),用之前必须先核对, 否则会把旧版本当"已知良好"照抄。 - ★ pyc 里那条自检自身有 bug:反汇编可见它写的是
r2 = env.step(...)(漏了[1]),于是r2是 4 元组、r2 == 0.0恒为 False —— 它在原版模块上跑也照样 FAIL。我照抄时把这个 bug 一起抄了过来。 → 验证脚本现在把它标为"已知 pyc 测试 bug",并额外验证修正后的行为。 pyc头部长度:Python 3.7+ 一律 16 字节(我第一版按 flags 分 12/16, 报bad marshal data)。12 字节是 3.6 及更早。dis.Instruction.lineno在 3.13+ 改名line_number(旧名被移除)。- 重构引入循环导入:
screen_agent想用screen_io.type_step, 而screen_io又要用screen_agent的常量 → 谁先被 import 谁拿到半成品。 → 修法:screen_agent保持叶子模块(把type_step放回拥有键盘的它), 依赖方向单向:screen_io -> screen_agent。
规则(写进 §11 级的硬约束)
- ★★ 绝不用 shell 的字符串 cmdlet 往返读写源码(
Get-Content/Set-Content)。 要改源码就用edit工具,或 Python 且显式encoding="utf-8"。 - ★★ 这个项目需要版本控制。一次误操作 = 一个模块全毁,而恢复成本是十几轮工具调用。
- 用
.pyc当真相基准前,先核对它的源文件 mtime/size。 - 只验证"能编译"是不够的:还要数据表逐项相等 + 行为等价。
留下的工具(都可复现)
| 工具 | 作用 |
|---|---|
tools/dump_screen_agent.py | exec 完好 pyc,导出全部数据表与文档串 |
tools/verify_screen_agent.py | 三重验证:编译 / 数据表对 pyc / 原版 tests 跑在候选上 |
tools/dump_pyc_consts.py | 按行号导出 pyc 里的字符串常量 |
tools/recover_cp936.py | cp936 码表反向还原 |
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=...) 可指定 | 课程/教学要用 |
四个踩出来的事实(都写进了自检)
- ★ 打错一个字母的编辑距离和"什么都不打"相同(替换+插入 ≡ 插入+替换):
d("", "zhong") = d("x", "zhong") = d("xxxxx", "zhong") = 5, 要到d("xxxxxx") = 6才变差。→ 错字不超过目标长度时"乱按"与"闭嘴"同分, 塑形奖励是 0 而不是负。对探索是好事(敢试不被罚),但必须知道, 否则会把"没惩罚"误读成"奖励设计错了"。 - ★
γ必须真的用上。我第一版直接写reward += prev_d − d—— 那等于 悄悄把 γ 固定成 1,gamma参数成了摆设,而"γ<1 时原地不动白得奖励" 这条反向验证也就永远通过(恒真检查 = 没在测东西,§11.14)。 改成标准形式γ·Φ(s') − Φ(s)后,γ=0.9 的反向证据才真的能失败。 - ★ 同刻按多个键时按"键位顺序"产出,不是按下顺序:
同刻按 Z,H,O,N,G 得到
oghzn(按KEY_LAYOUT下标排序)。 → 打字必须逐字来(每个字符后补一刻松手,否则不构成 0→1 跳变)。 这也解释了为什么type_step里那句边沿检测是地基。 - ★ 门关时
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,视觉那几列照抄。实测:
载入后输出与纯视觉预热输出最大差 = 0.00e+00 (逐位相同)
换掉本体感觉后输出最大差 = 0.00e+00 (零初始化生效)即"零初始化新通道":视觉通路完全保留,本体感觉从 0 开始自己学。
#12.15 当前状态
| 环境 | 自检 |
|---|---|
screen_agent | 28 项全过(+ 与 pyc 数据表 30/30 相等、行为等价) |
screen_io | 24 项全过 |
forage_screen | 27 项全过 |
read_screen | 40 项全过 |
端到端通路已验证:像素(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 步。
| 步 | β | 课程池 | 正确率 | 认字 | 留出字体 | 空白屏 |
|---|---|---|---|---|---|---|
| 200 | 1.00 | 47 | 49.0% | 49.0% | 6.2% | 0.0% |
| 800 | 1.00 | 106 | 52.1% | 61.5% | 29.2% | 0.0% |
| 1200 | 1.00 | 194 | 27.1% | 47.9% | 10.4% | 0.0% |
| 1600 | 0.63 | 231 | 20.8% | 41.7% | 14.6% | 0.0% |
| 3000 | 0.00 | 247 | 15.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,只表现为"怎么练都不动")
- ★★
EnvGroup.step返回了新观测却没写回self.obs,而调用方写的是_f, _p, r, d, infos = grp.step(...)(把前两个丢了)→ 策略从头到尾看着第 0 刻的黑屏。 现场指纹:sup收敛到边缘分布(maxlen=1 时 = ln47 = 3.85); 冻结编码器与不冻结毫无差别;但逐层追踪显示信号一路都在 (像素差 0.95 → 编码器输出差 1.45)。 - ★★ 同一个 bug 的第二现场:
evaluate里我直接调env.step()绕过grp.step→ 评估器也看黑屏。表现是"训练侧模仿对 100%、评估侧认字 1~5%", 极易被误读成过拟合或分布漂移。→ 现在有常量化金丝雀frame_std和自检断言盯着它。 - ★★ 采样与更新不是同一个策略:
rollout每刻调forward(每次都从h=0开始,等价于无记忆),而update用整段 T 序列前向(有记忆)。 记录的動作与算出的 log-prob 不对应,REINFORCE 直接是错的。 → 必须用带状态的step_tick,并加回归检查"逐刻 vs 整段 logits 逐位一致"。 ★ 附带坑:LiquidNet._run_recurrence_step不能用 —— 它return h_new丢掉了h_next,而 CfC 的"下一状态"是第二个返回值。 - ★ 第 0 刻老师照着黑屏示范按键:目标字还没出现(
onset_tick=1), 而老师照样教"按 Z" —— 目标每回合都变,这条监督是自相矛盾的, 会主动破坏学习。→ 老师必须知道"看不见就教等"。 - ★ 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_after | 0(字一直在) | 1 | 2 | 3 | 5 | 8 | 12 | 20 |
|---|---|---|---|---|---|---|---|---|
| 正确率 | 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)
三条结论,其中两条推翻了我自己的判断:
- ✅ 校准锚点通过:N=0 给 31.2%,训练时报 30.2% —— 评估管线可信 (§11.15 要求每个指标都报天花板与地板,这条就是天花板对照)。
- ★★ "任务漏洞"假设被否掉:字消失到 20 刻,准确率一点不掉(甚至略升)。 状态本来就能自己托住内容,零样本、从没训过隐藏条件。 这与项目早期的测量一致(延迟期指令可读出率 = 100%,§7.2)。 → "老师一走就崩"不是记忆问题。 我上一轮的诊断是错的。
- ★★ 真正的瓶颈是「每字正确率只有 65%」,误差是乘性的:
31.2% ≈ 0.65³(平均拼音长度 3),长度 6 的拼音只剩0.65⁶ ≈ 7.5%。 这解释了为什么池扩到 247 时正确率掉下来 —— 不是记不住,是错一个就全错。 - ★ emit 门严重失准:该开时只有 0.10(应该 >0.5),该关时 0.27。 门基本是关着的,而它一旦开门往往就打对了。这是 RL 阶段的一个独立瓶颈。
下一步因此改变(原计划的"A′ 用 hide_after 重训"取消 —— 状态本来就托得住):
| 新优先级 | 做什么 | 为什么 |
|---|---|---|
| 1 | 自我纠正(看到打错的就 Backspace 重打) | ★ 最高杠杆:把乘性误差变成可恢复的。老师本来就在教这个动作 |
| 2 | 修 emit 门 | 门该开时不开,是独立的瓶颈 |
| 3 | 提高每字正确率(65% → 90%) | 换增广编码器、加训练量 |
| 4 | B 组合迁移 / C 内驱力 | 原计划不变 |
★ 方法论:这一轮的价值在于假设被干净地否掉了。如果没做 N=0 那个校准锚点, 我会把"31.2%"当成"记忆掉了"的证据,然后去改循环核 —— 方向完全错。
#12.18 ★★★ 实测五:同声旁迁移(分水岭实验,结果是"种子在")
这是整个项目最关键的一次测量:它区分「理解」和「背表」。 §6.7 只测出零样本迁移 = 0,而少样本/结构迁移从来没测过。
#字组是自动构造的,不需要人工标注形声字表
tools/build_phonetic_groups.py:形声字有两个可自动检测的特征 ——
① 同音(声旁相同 → 读音相同)用 pypinyin 分组;
② 长得像(共享一个部件,只差偏旁)用 24×24 位图 IoU 聚类。
两个条件同时满足才收(只用 ① 会把 一/意/义 凑一起;只用 ② 会把 未/末 凑一起,
那是形近字不是形声字)。实测 3755 个 GB2312 一级字里找到 49 个簇,抽出来全是标准形声字组:
[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)失败,两个原因:
- 双胞胎必须在 T 里,而 T 只有 102 个簇成员 → 只找到 5 个, 而且全不是经典形近字对(找到的是 钒/饥、桃/挑、慎/情、拴/捡、蹭/赠)
- ★ 这 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 根本没被测
| 文件 | train | hold_struct | hold_ctrl | hold_near |
|---|---|---|---|---|
tools/build_phonetic_groups.py 产出 phonetic_groups.json | 102 | 48 | 48 | 5 |
tools/build_near_experiment.py 产出 near_experiment.json | 142 | 键不存在 | 22 | 24 |
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% |
这一张表把所有事情翻过来了:
- A 的"查最近邻满分线"是 93.8% —— 因为形声字的最近邻就是同音的那个字
(实测:镣→撩 IoU 0.621、暮→募 0.605、馒→慢 0.552,读音全同)。
★★ 所以 §12.18 那句"它不是纯查表器"证据不足。 A 考的很可能就是"抄最近邻",而 A 的高分完全可以是查表查出来的。
- C′ 的满分线是 0.0% —— 它的最近邻读音故意与真读音不同。 于是"查表假说"预测 0%、"读声旁假说"预测高;两个假说预测相反,本该有分辨力。 但实测 C′ = 2.0%,而 B = 3.1%,两者都在噪声里 —— 模型在 C′ 上两种都没做到,所以这一格也没能证实任何一方。
#逐字读数:它确实在输出孪生字的读音(41.7%)
experiments/diag_transfer_strings.py 把每一个字的预测串倒出来(--reps 2):
| 留出集 | n | 真读音 | 输出==孪生字读音 | 输出是合法拼音 | 一个键都没按 |
|---|---|---|---|---|---|
| A 结构 | 96 | 45.8% | — | 79.2% | 0.0% |
| B 对照 | 44 | 4.5% | — | 54.5% | 0.0% |
| C′ 形近异音 | 48 | 6.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 之后,候选池塌了:
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% → 分不开,结论降级为"未识别机制" |
★ 一个留出集只有配上"一个零学习基线能在它上面考几分"这个数,才是一把尺子。 没有这个数的留出集,读数只能证明"低",不能证明"因为什么而低"。