RNAQwen / PROGRESS.md
RomanCohort's picture
add 根目录
37e94a6 verified
|
Raw History Blame Contribute Delete
49.7 kB
# 复现日志
> 每条都记 **做了什么 / 验收到什么程度 / 踩了什么坑**。
> 只写有证据的部分;没验证的不许写成"完成"。
---
## 2026-10-08 · 环境勘察与启动
### 机器事实(实测,不是推断)
| 项 | 值 | 怎么测的 |
|---|---|---|
| GPU | AMD Radeon 8060S (gfx1151),**99.7 GB** 统一内存,20 CU | `torch.cuda.get_device_properties(0)` |
| torch | `2.12.0a0+rocm7.13.0a20260313` | 装于 `C:\ana\envs\comfyui` |
| **torch.distributed** | **`is_available() == False`** —— 构建里没编 | `import torch.distributed as dist` |
| transformers | 5.9.0(装进本项目 venv) | `pip install` |
| 解释器 | **`C:\ana\envs\comfyui\python.exe`**(用户指定用 comfyui 环境) | 见下方"环境变更" |
### 环境变更记录(装进 comfyui 环境,便于回滚)
按用户要求,本项目直接用 **comfyui 环境**跑,不另起环境。为此装进 `C:\ana\envs\comfyui`:
```
transformers 4.57.6 -> 5.9.0 (multimolecule 的硬需求:
huggingface-hub 0.36.2 -> 1.33.0 它 import transformers.utils.output_capturing,
hf-xet 1.5.2 -> 1.7.0 4.57 里没有这个模块)
新增:peft 0.21.2 · accelerate 1.15.0 · sentence-transformers 6.1.0 · rouge-score 0.1.2 · nltk 3.10.3
```
`.venv`(在项目目录下,`--system-site-packages` 继承 comfyui 的 torch)保留为备用,
包集合与 comfyui 一致,不动 comfyui。**若 ComfyUI 的自定义节点依赖 transformers 4.x,用 `.venv` 跑本项目。**
### 坑 1:`import multimolecule` 在本机直接不可行
`danling`(MultiMolecule 的运行时依赖)在 import 阶段无条件引用
`torch.distributed.distributed_c10d`、`torch.distributed.tensor`、`dist.ReduceOp`,
而这个 torch 构建**没有分布式后端**。四级连锁失败:
```
ImportError: cannot import name 'dcp' from 'danling.runners.checkpoints.torch_distributed'
ModuleNotFoundError: No module named 'torch._C._distributed_c10d'
AttributeError: module 'torch.distributed' has no attribute 'ReduceOp'
ModuleNotFoundError: No module named 'torch.distributed.tensor.device_mesh'
```
**解法**:`src/rnaqwen/env/shim.py` —— 把缺失符号补成占位符,
并在 `sys.meta_path` **末尾**挂一个 permissive finder(真模块优先,缺的才伪造)。
现在 `from multimolecule.models.rnafm import RnaFmModel, RnaTokenizer` 通过(transformers 5.9.0)。
### 坑 2:网络 —— HF 走得通但慢到不可用,EBI 完全不通
| 通道 | 实测 |
|---|---|
| huggingface.co CDN(`us.aws.cdn.hf.co`) | **0.06 MB/s** |
| hf-mirror.com | 0.06 MB/s(回源到 hf.co,没用) |
| **ModelScope(`cdn-lfs-cn-1.modelscope.cn`)** | **11.2 MB/s** |
| NCBI eutils | 通(esearch / efetch / elink 全 200) |
| rnacentral.org / ftp.ebi.ac.uk / europepmc.org | **SSL EOF / ReadTimeout,全不通** |
推论:
* 权重一律走 ModelScope(`scripts/00b_download_modelscope.py`,并发 + 断点续传)。
* 数据源从 RNAcentral 换成 **NCBI nuccore + pubmed**(差异已写进 README §5)。
* 另外:`huggingface_hub` 走 hf-mirror 会抛
`FileMetadataError: Distant resource does not seem to be on huggingface.co`
—— 镜像不给 HEAD 元数据头。别用镜像。
### 坑 3:`urllib`/`requests` 的下载超时用法不一样,且 CDN 会「滴水」
- `urlopen(req, timeout=(20, 120))` 抛
`TypeError: 'tuple' object cannot be interpreted as an integer`
—— 元组超时是 `requests` 的用法。四个下载任务全灭了一次。
- 换回 `requests` 之后,**读超时给太长(30s)等于卡死没有出口**:
CDN 会让连接保持存活但几乎不吐字节,进程静默挂在 0.00 MB/s 上半小时。
最终解法是 `timeout=(15, 10)`(连接 15s / **读 10s**)+一个**速率看门狗**
(45 秒内增量 < 200 KB 就主动断开,带 `Range` 续传重试)。
- 另一个坑:**`Get-ChildItem` 读到的文件大小是缓存的**,明明在下载却一直显示
旧值,害得我以为卡了三次。改用 Python 的 `os.path.getsize` 才看到真实速率
(5 个 shard 各 ~2 MB/s,合计 ~10.7 MB/s)。**别用 PowerShell 看下载进度。**
### 坑 4:磁盘写满(Qwen3-8B 下到一半全灭)
`OSError [Errno 28] No space left on device` —— 5 个 shard 同时卡在同一位置反复重试。
C 盘当时只剩 **4.4 GB**(共 918 GB,本来就快满了)。清理:
| 动作 | 释放 |
|---|---|
| 删掉多余的 conda clone `C:\ana\envs\rnaqwen`(我们有 `.venv`,用不到它) | 6.09 GB |
| 清掉 HF 缓存里的 `.incomplete` 残片 | 0.15 GB |
| 删掉 pubmedbert 里冗余的 `pytorch_model.bin`(已与 safetensors 逐位比对一致) | 0.44 GB |
清理后 11.16 GB 可用,15.3 GB 的权重才下得完。
**教训:这台机器 C 盘长期紧张,下大模型前先看 `Get-PSDrive C`。**
### 坑 5:HF 不是慢,是**单连接**被限速
`aria2c -x16 -s16 -k1M` 从 huggingface.co 拉 RNA-FM 的 398 MB:
```
25 秒下完(约 16 MB/s),而单连接只有 0.06 MB/s
```
所以真正的结论不是"HF 不能用",而是**单连接被掐到 0.06 MB/s,多连接能跑满**。
(ModelScope 单连接就能到 2–11 MB/s,但同样会在并发下载时互相挤。)
以后再遇到"某个源很慢",先试 `-x16`,别急着换源。
### 坑 6:下载并发多了会互相拖死
5 个下载任务 + 2 个 subagent 同时拉,结果是**所有任务一起停**(每个都显示 0.00 MB/s)。
最后按"一个源最多 5 条流、别再多开进程"来跑才稳定。
### 模型就位情况
| 模型 | 来源 | 状态 |
|---|---|---|
| `Qwen/Qwen3-8B` | ModelScope | 下载中(15.3 GB / 5 shard) |
| `multimolecule/rnafm` | HF(aria2 -x16) | 398 MB,即将完成 |
| `google-bert/bert-base-uncased` | ModelScope `google-bert/bert-base-uncased` | ✅ 441 MB,sha256 全对 |
| `microsoft/BiomedNLP-BiomedBERT-…` | ModelScope | ✅ 见下方说明 |
**PubMedBERT 的一个事实**:上游仓库(HF 和 ModelScope 一致)**根本没有
`model.safetensors`,也没有 `tokenizer.json`** —— 只有 `pytorch_model.bin` + `vocab.txt`。
所以本地这两个文件是**生成的**(safetensors 由 .bin 导出,tokenizer.json 由 vocab.txt 构造),
并已验证:safetensors 与 .bin 前向输出**逐位一致(max|Δ| = 0.0)**。
→ 报告里要写明这一点,别让人以为是从上游拿的原文件。
### 坑 7(最贵的一个):**静默损坏** —— 大小完全正确,内容是坏的
Qwen3-8B 下完 16.38 GB,`safetensors` 加载成功(399/399 张量全部到位),
但生成出来是乱码:
```
prompt: "Name the four RNA nucleotides, separated by spaces."
sdpa / bfloat16 -> ',es numpy React sys React matplotlib React React React ...'
eager / bfloat16 -> ',es numpy React sys React matplotlib React React ...'
eager / float32 -> ',es matplotlib React React React React React React ...'
```
三种 attention 后端 × 两种精度**输出同样的乱码** → 不是算子问题,是**权重本身坏了**。
`scripts/00c_verify_models.py` 拿本地 sha256 跟 ModelScope 声明的值对比:
```
model-00005-of-00005.safetensors 1244659840 1244659840 OK 20c2d636... == 20c2d636...
model-00001-of-00005.safetensors 3996250744 3996250744 BAD 8f63b006... != 31d6a825...
model-00002-of-00005.safetensors 3993160032 3993160032 BAD bd732b5f... != 5991236c...
model-00003-of-00005.safetensors 3959604768 3959604768 BAD 52d91d30... != c5185c47...
model-00004-of-00005.safetensors 3187841392 3187841392 BAD 41b73ee3... != b5ee7de7...
```
**4/5 个 shard 坏了,而字节数一个不差。**
成因(复盘):
1. 这些文件被**多轮续传**拼过 —— 磁盘写满中断 → 换进程重下 → 再断 → 再续;
2. 中途还出现过**两个进程同时写同一个文件**(先起了 26/27 号任务,又起了 29/30 号任务,旧任务没死透)。
`Size` 校验**完全挡不住这种错误**。修复:
* `00b_download_modelscope.py` 改成先写 `.part`、**完整拿到后原子改名**;
* 下完必须跑 `00c_verify_models.py` 对 sha256 —— **不看 sha256 不算下完**。
> 这条要写进开源仓库的 CONTRIBUTING:权重只做 size 校验是危险的,
> 静默损坏的模型会加载成功、跑得动、只是输出垃圾,排查成本极高。
### 坑 8:`aria2c` 会把文件**预分配**成完整大小
aria2 先按最终大小占位,进度只记在 `.aria2` 控制文件里。
我用 `Get-ChildItem` 看到 398.12 MB 就以为下完了,结果 `safetensors` 报
`invalid JSON in header: EOF while parsing a value`。
**判断下载是否完成:看控制文件 / 校验和,不看文件大小。**
### 型号核对:Qwen3-8B 的投影层确实零改动
已下到本地的 `models/Qwen3-8B/config.json`:
```
hidden_size = 4096 num_hidden_layers = 36
vocab_size = 151936 intermediate_size = 12288
```
与 Llama-3-8B 的 hidden=4096 **逐位相同** → RNA-FM 那条 `640 → 4096` 线性投影
**与原版维度一致,不用改**。这就是选 Qwen3-8B(而不是 Qwen2.5-7B 的 3584)的理由。
> 注意区分:本机 `C:\qwen-api\models\` 下的 `Qwen3.8-27B-*` / `Qwen3.6-35B-A3B-*` 是**别的系列**,
> 不能当底座。复现用的是原始 Qwen3 家族、8B 规模:`Qwen/Qwen3-8B`。
---
## PLAN §7.3 · 评测脚本(**已通过**)
`scripts/03_eval_selfcheck.py`,报告 `reports/eval_selfcheck_bert.json`。
### A 关:ROUGE 对数(自实现 vs Google `rouge_score`)
7 个用例:identical / paraphrase / partial / unrelated / empty / punctuation / numbers
```
-> 最大偏差 0.000e+00(阈值 1e-09):一致
```
**逐样本完全一致。尺子准了。**
### B 关:语义指标不变量(嵌入 = bert-base-uncased)
```
[high] identical P=1.0000 R=1.0000 F1=1.0000
[high] paraphrase P=0.9396 R=0.9396 F1=0.9396
[mid ] partial P=0.9366 R=0.8748 F1=0.9046
[low ] unrelated P=0.8004 R=0.8321 F1=0.8159
[ok] identical_ge_0.999 / paraphrase_gt_partial / partial_gt_unrelated / unrelated_lt_0.95
```
### B/C 关:PubMedBERT(`reports/eval_selfcheck_pubmedbert.json`)
A 关同 BERT(7 用例 Δmax = 0.000e+00)。B 关**没过**,C 关过了:
```
identical P=1.0000 R=1.0000 F1=1.0000
paraphrase P=0.9938 R=0.9938 F1=0.9938
partial P=0.9950 R=0.9857 F1=0.9903
unrelated P=0.9701 R=0.9669 F1=0.9685 <- 比 BERT 的 0.8159 还高
[ok ] identical_ge_0.999 / paraphrase_gt_partial / partial_gt_unrelated
[FAIL] unrelated_lt_0.95
```
**这个 FAIL 不是 bug,是阈值本身不适用于 PubMedBERT。**
`unrelated_lt_0.95` 是在 BERT 上标定的;而 PubMedBERT 的跨域地板是 0.9518
(§7.3b 实测),**它在数学上不可能满足这条断言**。
C 关同理可信度有限:
```
perfect_100pct 1.0000/1.0000/1.0000 F1=1.0000
half_50pct 0.4800/0.4583/0.4800 F1=0.9907
one_third 0.4800/0.4583/0.4800 F1=0.9907
bare_sequence_like 0.0000/0.0000/0.0000 F1=0.8889 <- 纯 ACGU 乱串也有 0.89
empty 0.0000/0.0000/0.0000 F1=0.0000
-> ROUGE-1 随答案完整度单调:是
```
ROUGE 阶梯正常(1.00 → 0.48 → 0.00);
但 semantic 那列「50% 答案 0.9907 / 乱码 0.8889」的区分度基本为零。
### C 关:消融阶梯的响应方向
```
variant R-1 R-2 R-L F1@bert
perfect_100pct 1.0000 1.0000 1.0000 1.0000
half_50pct 0.4800 0.4583 0.4800 0.9370
one_third 0.4800 0.4583 0.4800 0.9370
bare_sequence_like 0.0000 0.0000 0.0000 0.2633
empty 0.0000 0.0000 0.0000 0.0000
-> ROUGE-1 随答案完整度单调:是
```
### 🔎 第一个发现(**后来被部分推翻**):语义 F1 的"地板很高"
「无关的两段文本」在句子级 max-cosine 下拿到 **F1 = 0.8159**。
当时据此推断分辨率被压缩在 **[0.82, 1.00]**。
> ⚠️ **这个推断后来被证伪了,见 §7.3b。** 0.8159 之所以那么高,是因为
> RNA 与蛋白质晶体学**同属生物医学语域**。换成真正的跨领域配对(RNA vs 经济学/法律/菜谱),
> 同一把尺子立刻掉到 **0.45 左右**。§7.3b 的结论比这里更严重,但方向不同:
> 地板在 BERT 上其实只有 0.52(不是 0.82),真正的病在 PubMedBERT(0.95)。
> 原文保留,记录判断的演进过程。
---
## PLAN §7.3b · semantic F1 的**分辨率地板**(`scripts/03b_semantic_floor.py`)
报告:`reports/semantic_floor.json`(bert + pubmedbert 各一遍,n=2000,seed 20261008)。
### 先纠一个前提错误
原来把 `unrelated`(RNA vs 蛋白质晶体学)的 **0.8159** 当成"完全无关"的下限。
**这是错的**:两者同属生物医学语域、共享大量词根,句子向量本来就靠得近。
按领域两两配(RNA vs 经济学 / 法律 / 菜谱),立刻掉到 **0.45 左右**。
| 无关对(8句 vs 8句) | F1@bert | F1@pubmedbert |
|---|---|---|
| rna vs crystallography(都属生物医学) | 0.7663 | 0.9640 |
| rna vs economics | 0.4490 | 0.9592 |
| rna vs legal | 0.4649 | 0.9498 |
| rna vs cooking | 0.4549 | 0.9522 |
| crystallography vs legal | 0.4771 | 0.9426 |
| economics vs cooking | 0.5374 | 0.9585 |
→ 教训:**"无关"必须按语域设计对照,不能顺手拿两个技术领域当无关。**
语域相邻的"无关"会把地板抬高 0.3,足以把一个结论带反。
### 地板 / 随机基线 / 上界
| | bert-base-uncased | pubmedbert |
|---|---|---|
| 上界(identical) | **1.0000** | **1.0000** |
| 地板 A:跨域无关随机配对 | **0.5205** (std 0.089) | **0.9518** (std 0.008) |
| 地板 B:同域不同事实(分子生物学) | **0.7102** | **0.9682** |
| 地板 C:词序全打乱、词汇全对 | **0.8289** | **0.9718** |
| 地板 D:纯乱码 | 0.2483–0.3913 | 0.9170–0.9250 |
| 句级随机配对("瞎猜") | 0.5357 (std 0.147) | 0.9514 (std 0.015) |
| 同域句级随机配对 | 0.7236 | 0.9683 |
| **有效动态范围**(跨域地板 → 1.0) | **[0.52, 1.00],宽 0.479** | **[0.95, 1.00],宽 0.048** |
### 最严重的一条:这条指标量的**不是语义**
把每个句子内部的词序全打乱 —— 句子彻底不成话,词 3-gram Jaccard 只剩 **0.0035**
(几乎没有保留任何连续词组),但:
```
bert-base-uncased 乱序 F1 = 0.8275 (原句 1.0000)
pubmedbert 乱序 F1 = 0.9679 (原句 1.0000)
```
**PubMedBERT 上,"词汇全对但语义全错"(0.968)与"完全无关的领域"(0.952)
基本无法区分,和纯乱码(0.92)也差不了多少。**
它对"领域/语域 + 词汇重叠"敏感,对"句子究竟说了什么"不敏感。
### `0.8609` 在不同嵌入上的含义完全不同
| embedder | 地板 | 0.8609 的位置 |
|---|---|---|
| bert | 0.5205 | 高出地板 +0.3404,占可用窗口 **71%** —— 有信息量 |
| pubmedbert | 0.9518 | **低于地板 0.0909** —— 在这个尺度上等于"完全跑题" |
同一把尺子的同一个读数,在两种嵌入上一个算好、一个算跑题。
**说明分母(地板)比分子更决定读数**,也说明 `S_BERT` 与 `S_Pub` 的分数
**不可横向比较** —— 原版表里 0.8609 vs 0.7165 的差距,很可能主要来自嵌入本身的性质,
而不是"BERT 语义更准"。
> 0.8609 是原论文在其**自有评测集**上的数,本地是自建小语料,绝对值不可直接比较;
> 这里只用它定位"同一把尺子在本地语料上的零点"。
### 机制:表示各向异性
| | 跨域句级 cos | 同域句级 cos | 跨/同域差 | 各向异性 |
|---|---|---|---|---|
| bert | 0.4844 | 0.7250 | **+0.2406** | 0.7281 |
| pubmedbert | 0.9466 | 0.9684 | **+0.0218** | 0.9752 |
PubMedBERT 的句向量挤在一个极窄的锥里(任意两句 cosine 就有 0.95),
max-cosine 于是无论如何都被抬到 0.95 以上 —— **地板高是几何必然,不是评测集的问题。**
它的同/跨域可分性比 BERT 低一个数量级(0.022 vs 0.240),
这才是它无法分辨对错的根本原因。
### 落在报告口径上的三条
1. **不能单独用 semantic F1 判复现好坏。** 必须同时报 ROUGE + 同语料的随机/无关基线。
2. **`S_BERT` 与 `S_Pub` 不可跨列比较**(分母不同)。原版表把两种嵌入并排摆,
容易被读成"BERT 上语义更好",实际不是。
3. **报 PubMedBERT 的 semantic F1 时量级要特别小心** ——
0.95 以下没有信息,0.97 与 0.99 之间的差别基本是噪声。
---
## PLAN §7.3c · PubMedBERT 权重忠实性验证(`scripts/00d_verify_embedder_weights.py`)
报告:`reports/embedder_weight_verification.json`。
**为什么必须做**:`pubmedbert` 目录下的 `model.safetensors` 与 `tokenizer.json` 是**本地生成的**
(上游只给 `pytorch_model.bin` + `vocab.txt`),而这里踩过一次
"字节数完全正确、内容全是坏的"的静默损坏(坑 7)。所以"PubMedBERT 地板 0.95"
必须先排除"权重坏了"才成立。
从 ModelScope 重下上游 `pytorch_model.bin`(440,474,434 B):
```
sha256 = ad7bbb66376cfd6b2db3447192b034efe016337cbef135c35c411fd61b13c193
== ModelScope 声明值(逐位相同)
上游 208 张量(含 MLM 头 9)→ 去 `bert.` 前缀、剔除 cls.* 后共同键 199
bitwise 相同: 199/199 shape 不一致: 0 max|Δ| = 0.000e+00
前向: last_hidden_state 逐位相同: True max|Δ| = 0.000e+00
句向量余弦矩阵 max|Δ| = 0.000e+00
[OK] 权重忠实
```
**结论:本地 safetensors 是上游权重的忠实无损导出。地板 0.95 是真模型性质,不是损坏。**
> 顺带修掉一个自己犯的错:第一版比对脚本写成
> `missing, unexpected = m_up.load_state_dict(sd_up, strict=False)`,
> **把返回值丢掉了**,于是 `m_up` 还是随机初始化,前向 max|Δ| = 1.49e+01,
> 差点得出"权重坏了"这个完全相反的结论。
> `load_state_dict` 确实是 in-place 的,但**必须断言 `missing_keys` 为空**才能拿去比前向 ——
> 现在脚本里有 `assert not report.missing_keys`。
> 另一个易踩的坑:`model.safetensors` 是从 `AutoModel`(裸 encoder)导出的,**不含 MLM 头**。
> 用 `AutoModelForMaskedLM` 加载会报 `cls.predictions.*` MISSING,然后拿**随机初始化**的
> MLM 头输出垃圾词(实测:`The patient was diagnosed with [MASK] cancer.` → `vast/grand/looks`,
> 而真 BERT 给的是 `breast/lung/liver`)。
> **别用 MLM 探针验 encoder 权重** —— 会得出错误结论。semantic 指标只吃 encoder 输出,不受影响。
---
## PLAN §7.1 · RNA-FM 探针(**已通过**)
`reports/rnafm_probe.json`(CPU 上跑的;GPU 另见下方"待查")。
```
config : max_position_embeddings=1026 hidden=640 vocab=28
buffer embeddings.position_ids: shape=(1, 1026)
[ok] demo_small nt= 81 ids=(1, 83) out=(1, 83, 640)
[ok] demo_yeast_trna_phe nt= 76 ids=(1, 78) out=(1, 78, 640)
[ctx] 1022 nt -> ids (1, 1024)
```
**输出维度 640,逐位对上 config 的 `hidden_size`。** tokenizer 会额外加 BOS/EOS 两个特殊 token。
### ⚠️ 一个和 PLAN 假设不一致的实测结果:**上下文其实是 1022 nt,不是 1024**
PLAN §3 P0 的过滤标准写的是"序列长度 30–1024 nt(RNA-FM 上下文 1026 − 2)"。
`scripts/01b_probe_rnafm_limit.py` 逐长度逼边界,结果:
```
nt tokens result
1022 1024 OK out=(1, 1024, 640)
1023 1025 FAIL IndexError: index out of range in self
1024 1026 FAIL IndexError: index out of range in self
-> 可用上限:1022 nt
```
`config.max_position_embeddings=1026` 和 `position_ids` 缓冲区的 1026 都对得上,
但**真正生效的 token 上限是 1024**(1024 个 token 能过,1025 就炸)。
所以:**数据过滤上限要从 1024 nt 收到 1022 nt。**
(当前这 50 条最长 966 nt,没踩到;上量到 1K/121K 时必须改,
否则会有一批样本在 encoder 里直接抛 IndexError。)
### GPU 上前向崩过一次 —— 是**暂时的**,已恢复
同一段探针 `--device cuda` 下崩在 `at::native::to` → `empty_strided_cuda` →
`c10::cuda::SetDevice`(HIP 侧抛异常,连 `torch.zeros(8, device='cuda')` 都过不去)。
CPU 上一切正常,说明不是权重问题。
排查过程:
1. 先怀疑 GPU 被别的进程占着 —— `Get-Process` 显示没有大进程,可用内存 52 GB,排除;
2. `aria2c` 式的环境因素排除(两个解释器都崩);
3. **等了约 20 分钟后原样重试,直接通过**(`CUDA OK`),探针在 GPU 上也跑通了。
结论:gfx1151 + ROCm 7.13 nightly 偶发地会把设备卡住,**重启内核态的驱动状态需要时间**。
→ 遇到 `c10_cuda_check_implementation` / `SetDevice` 这类异常,
**先重试 + 等一会儿,别急着改代码或重装 torch**。
(`reports/rnafm_probe_gpu.json` 是 GPU 版结果,`reports/rnafm_probe.json` 是 CPU 版,内容一致。)
---
## PLAN §7.2 · RNA-QA 重建(采集已通,生成待 GPU)
### 管线逻辑离线自检(**已通过**)
真跑要等 15.3 GB 的权重,但**管线逻辑与模型无关**。
`scripts/99_selftest_pipeline.py` 用一个"假生成器"把 11/12/13 的代码路径整条跑一遍,
假生成器**故意做两件坏事**:按频率吐出序列子串、按频率输出非 JSON 废话。
全部 8 项检查通过:
```
[1] divide(): 20/20 条切块干净 <- 块边界都落在句末
[2] 解析出 40 组 QA;非 JSON 输出 2 次;闸门拦下 4 条带序列的记录
[ok ] divide_keeps_sentence_boundaries
[ok ] json_parse_handles_fences <- ```json 围栏能剥
[ok ] guard_catches_leak <- 序列泄漏真的被拦
[ok ] guard_flags_bad_json
[ok ] guard_positive / guard_negative <- 闸门不误杀
[ok ] guard_ngram_detects_bare_bases <- 裸碱基串(非 reference 子串)也能抓
[ok ] wrote_jsonl
[verdict] PASS —— 管线逻辑没问题,等权重到位即可上量
```
**意义**:真跑之后如果数据是脏的,那一定是模型的问题,不是代码的问题 —— 归因干净。
### 已跑通:采集 50 条 (序列, 文献)
`scripts/10_collect.py`,最终链路 **nuccore → GenBank → pubmed**:
```
[batch 0] ids=60 gb=60 可用=60 pmids=18 已收 50/50
[stats] {"nt_scanned": 60, "bad_len": 0, "bad_alpha": 0, "no_pubmed": 0,
"short_abstract": 0, "no_abstract": 0, "dup": 0, "kept": 50}
[lens] min=116 median=416 p90=825 max=966 <- 全部落在 RNA-FM 的 1024 内
```
产物:`data/raw/rna_literature_50.jsonl`
**两条被实测否掉的链路**(写下来省得重走):
1. `pubmed → nuccore`(正向):拿到的全是 2–4 kb 完整 mRNA(`NM_*`),
越过 1024 nt 上限,**半小时一条都收不到**。
2. `elink` 反查:传**多个** UID 时会合并成一个 linkset,不告诉你哪条链接属于哪条序列
(实测 5 个 id 的 pubmed 链接全挤在一起)。
→ 改用 GenBank 自带的 `PUBMED` 字段,一次 efetch 同时拿序列和 PMID,一一对应。
### Qwen3-8B 权重:**已修复并校验通过**
静默损坏的 1/2/3 号 shard 删掉**重新单写者下载**后:
```
model-00001-of-00005.safetensors 3996250744 3996250744 OK 31d6a825... == 31d6a825...
model-00002-of-00005.safetensors 3993160032 3993160032 OK 5991236c... == 5991236c...
model-00003-of-00005.safetensors 3959604768 3959604768 OK c5185c47... == c5185c47...
model-00004-of-00005.safetensors 3187841392 3187841392 OK b5ee7de7... == b5ee7de7...
model-00005-of-00005.safetensors 1244659840 1244659840 OK 20c2d636... == 20c2d636...
model.safetensors.index.json 32878 32878 OK f9fdbcb9... == f9fdbcb9...
[OK] 全部文件与 ModelScope 声明一致。
```
**连贯性验证**(之前是乱码的那次):
```
[gen] 载入 models/Qwen3-8B 用时 17.9s device=cuda dtype=torch.bfloat16
prompt: "Name the four RNA nucleotides, separated by spaces. Answer in one line."
OUT: 'Adenine Cytosine Guanine Uracil' <- 正常了
```
至此 PLAN §1 的两个模型(RNA-FM 640 维 + Qwen3-8B 4096 维)**都在本机跑通**。
### ⚠️ 采集质量的一个硬伤:文献复用 98%(**必须改,否则训不出东西**)
第一版 50 条记录里 **只有 7 篇不同文献**,其中 42 条共用同一篇
(C. elegans 基因组论文 PMID 9851916)——**84% 的"文献"跟这条 RNA 没关系**。
把 `--target` 提到 1000 后又复现了同一件事(子代理实测):
1000 条里**只有 24 个不同 pmid**,其中一篇(1987 年 S. pombe 线粒体 LSU rRNA)
一条配了 **800 条**序列。后果:`literature` 字段作为监督信号基本无信息量。
根因:`esearch` 的 relevance 排序是确定性的,**第一条 query 一个批次就把 target 填满了**
(`by_query = {ncrna_refseq: 1000}`),后 4 条 query 一次都没跑到;
而每条 RefSeq 记录的 GenBank `PUBMED` 字段大量指向少数几篇"大论文"。
已加的闸门(`10_collect.py`):
1. `--max-per-pmid 1`:同一篇文献最多给 1 条记录(默认开);
2. `--require-mention`:DEFINITION 里的**特征标识**(如 `(Lncpnky)`、`(mcmdc2as)`)
必须出现在摘要里 —— 这是弱相关性检查,但能把"挂名文献"挡掉。
加闸门后实测:单批 100 个 id 只留下 **5 ~ 20 条**(之前是 50)。
**这正是应该的** —— 宁可少,不能脏。
> 顺带一个标注噪声线索:那 800 条被引论文讲的是**线粒体 LSU rRNA 基因**,
> 这批 RefSeq 条目却注释成 "non-coding RNA … lncRNA"。值得单独看一眼。
---
## 下一步
1. Qwen3-8B 权重下完 → 跑 11/12/13,出 50 条 RNA-QA,人工抽查质量(PLAN §3 P0 判据)。
2. RNA-FM 权重到位 → 跑 §7.1 探针,把 output_shape / 上下文长度落成 `reports/rnafm_probe.json`。
3. bert / pubmedbert 到位 → 跑 §7.3 的 B/C 关(语义指标不变量 + 消融阶梯响应方向)。**已完成,见 §7.3 / §7.3b / §7.3c。**
4. 三者都绿之后,进 P1(Stage1 模态对齐:冻结 RNA-FM + 冻结 Qwen,只训 640→4096 投影)。
### 评测口径上必须落地的三条(来自 §7.3b,改 `04_eval.py` 时要照做)
- [ ] **报 semantic F1 时必须同时报基线。** 每次评测都跑一遍"跨域无关对 + 同域句级随机配对",
把地板跟着主数字一起报。否则读者无法判断读数落在窗口里的什么位置。
- [ ] **`S_BERT` / `S_Pub` / `S_GPT` 三列不可横向比较。** 每列只能和**它自己**的基线比。
原版表把三个嵌入并排摆,容易被读成"哪个嵌入更好",实际是三个不同的分母。
- [ ] **PubMedBERT 那列接近饱和,别当主指标。** 有效窗口只有 0.048 宽;
报告里应以 ROUGE 为主、`S_BERT` 为辅、`S_Pub` 仅作参考。
---
## E 轨 Stage 1 进度 + 三个"读 checkpoint"的静默错误(已修)
### E 轨 Stage 1 跑到 38.7%
`checkpoints/stage1_run1/`,27 个 checkpoint,step 500 → 13500(`--compress-latent 64`)。
10 epoch × 3488 条 = 34880 步,**还差 21380 步(~5.9 h @ 1.0 s/step)**。
* `val_loss` 从 step 6200 起就在 1.365–1.393 横着,train 掉到 0.76–0.91 —— 典型过拟合。
* `proj_std` 0.32 → 0.39,**没有**退化成常向量。
* Δloss(置零) 峰值 **0.2732 @ step1600**,衰减到 **0.047 @ step13800**。
### ⚠️ 但"Δloss 衰减 = 投影塌缩"这个读法是**错的**
`scripts/31_stage1_rouge_curve.py` 的试点(这是论文的指标,才是判据):
| step | ROUGE-1 | ROUGE-2 | ROUGE-L |
|---|---|---|---|
| 500 | 0.2084 | 0.0261 | 0.1337 |
| 8000 | **0.3030** | **0.1234** | **0.1914** |
原论文 Modality Fusion (D&C) = **0.0922** ⇒ 我们是它的 **~3.3 倍**。
step-8000 的生成结果里正确写出了 `LINC00115` —— **模型确实在读 RNA**。
**教训(值得单独记一条)**:Δloss 是**逐 token 平均**的。答案变长、变准、变多样,
分母变大,Δloss 天然被摊薄。**它不能替代 ROUGE 做收敛判据。**
`Stage1Config` 里 `collapse_rel_drop=0.75` 那套相对判据同样受这个问题影响,
所以 PLAN 里"置零编码器指标明显下降"这条**只能当辅助信号**,最终以 ROUGE 曲线为准。
### 修掉的三个静默错误(都是"读 checkpoint 时少装东西",不报错但结果全错)
1. **`scripts/22_zero_encoder_probe.py` 直接崩。**
`Stage1Config(llm_path=args.llm, **(blob["config"]))` ——
checkpoint 里的 config 含 `llm_path`(和 `args.llm` 撞名 → `TypeError: got multiple values`),
且含 `collapse_delta` 这种 dataclass 里根本没有的旧字段。
→ 改成只挑 `dataclasses.fields(Stage1Config)` 认识的键,命令行优先;
并**装回压缩器**(以前只 load `projection`,E 轨 checkpoint 的压缩器是随机初始化的)。
同时加了 `--pass-delta/--weak-delta`,并在输出里写明 Δloss 只是参考。
2. **`src/rnaqwen/models/finetune.py` 不支持压缩,E 轨没法进 Stage 2。**
`build_stage2_model` 硬编码 `Stage1Config(...)`,`encode_samples` 直接
`model.projection(rna)` —— 压缩器根本没有插入点。
→ `Stage2Config` 加 `compress_latent/compress_method`;
`build_stage2_model` 传下去、从 blob 装 `compression`、
**R/E 配错直接 `SystemExit` 报错**(不让它静默跑歪);
`encode_samples` 在投影前插 `if model.compression is not None` 分支(None 时逐位不变);
`save_stage2` 一并存 `compression` + `compression_spec`。
3. **`scripts/30_infer.py` 的压缩器权重从来没载过。**
原来只有 `compressor = RnaTokenCompressor(encoder.hidden_size, args.compress_latent)`
—— **随机初始化**,E 轨推理输出看着像样但全是错的。这是最危险的一个。
→ 改成从 `blob["compression"]` load,配置自动从 `compression_spec` 读,
配错直接报错退出。
### 冒烟验证(E 轨 Stage 2 全链路,已通过)
```
[stage2] 载入 Stage1 投影 <- checkpoints\stage1_run1\projection_step13500.pt (step 13500)
[stage2] 载入压缩器 <- checkpoints\stage1_run1\projection_step13500.pt (resampler n_latent=64)
trainable params: 15,335,424 || all params: 8,206,070,784 || trainable%: 0.1869
[stage2] {"step": 1, ..., "loss": 1.3883, "seq_len": 135, "elapsed_s": 3.4}
...
[stage2] 完成 6 步,用时 0.1 min
```
* `attn` 预设的 15,335,424 与静态估算**逐位对上**(见 `docs/METRICS.md`)。
* 压缩后 `seq_len` 135–169(不压缩会是 600–1100)。
* 存回的 `projection.pt` 里 `compression` + `compression_spec` 都在,
`config.compress_latent = 64`,往返一致。
* 实测 Stage 2 吞吐 **0.9 s/样本** ⇒ 80,907 条 × 10 epoch ≈ **202 小时**。
**所以 Stage 2 默认从 3 epoch 起跑(≈61 h),不是 10。**
> 顺手修的第四个:`--kind` 的 help 写的是 `open | closed`,但数据里的值是
> `open_ended | closed`。按 help 写 `--kind open` 会筛出 0 条样本。
> **文档写错选项名 = 静默的空数据集。** 已改。
### 手动运行
`RUN.md` 是给用户自己跑的命令单(工具 shell 拉起来的长任务已静默死过 5 次以上,
无 traceback、无 abort、事件日志里也没有)。
---
## 🔴 重大事故:RNA-FM 权重一直就是坏的,13500 步 Stage 1 全部作废
**发现方式**:用户贴了一段 `RnaFmModel LOAD REPORT` 问正不正常。报告本身确实正常
(UNEXPECTED 的是 `lm_head.*`/`ss_head.*` 预训练头,MISSING 的是从没用过的 `pooler.*`)。
但顺手验一下「权重真的装进去了吗」,验出问题。
### 症状
```
模型 state_dict 张量数 : 200
checkpoint 原始键数 : 205
剥掉 `model.` 前缀后共有键 : 198 (前缀差异是 from_pretrained 的正常行为,不是问题)
逐位相同 : YES
未覆盖的模型键 : ['pooler.dense.bias', 'pooler.dense.weight'] <- 只有这个
```
到这里一切正常。继续查:
```
word_embeddings.weight (28, 640) absmax = 0.000000e+00 nnz = 0 / 17920
序列敏感性:ACGUACGU… vs GGGGGG… max|Δ| = 0.000000e+00
```
**`word_embeddings` 全零,且不同序列的输出逐位相同。**
前向路径是 `inputs_embeds = self.word_embeddings(input_ids)`(从 `RnaFmEmbeddings.forward`
的源码确认),全零 ⇒ 输入 = 只有位置编码 ⇒ **encoder 完全看不见序列**。
### 量化
```
总张量 205 / 正常 50 / 全零 155
12 层 encoder 的 attention / intermediate / output 几乎全在零里
```
### 根因:文件是对的**大小**,错的是**内容**
```
本地 sha256 38f3ae9aadbf4d3a5ff6873476fe9d16600b83a54711637ec3760e8e83a2408d
上游声明 ec69333eb894dbefb580e690b0cbe1cbae9845fefd2bc78da914cd14226a2828
大小 398,117,588 == 398,117,588 ← 逐字节相同
```
**和 Qwen3-8B 那 4 个 shard 一模一样的故障模式。**
### 现场复现(这次抓到了机制)
重下时实测 `.part`:
```
逻辑大小 377,701,268 B ← 只下了 57 MiB,文件却已经接近满
@ 1 MB : 有数据 @ 10 MB : 零填充
@ 30 MB : 有数据 @ 50/100/200/300/350 MB : 零填充
Valid Data Length = 377701268 ← NTFS 认为整段都是「有效数据」,零就是真零
```
aria2 **预分配**了整份文件,只有真实分片有数据,其余是零。
(命令行确实带了 `--file-allocation=none`,aria2 1.37.0;大概率是上一轮中断留下的
预分配文件没被截断,新一轮 `--continue=false` 从 0 覆写,尾部那些零是上回的。)
**中断 + 预分配 = 大小正确、内容一半是零。** 这就是全部。
### 为什么没被发现(三个自己给自己发的假通行证)
1. **`from_pretrained` 不报错。** LOAD REPORT 只提 `lm_head`/`ss_head`/`pooler` 这三个
无关紧要的键,看起来像"只是架构差异,可以忽略"。
2. **探针只查形状。** `01_probe_rnafm.py` / `01b_probe_rnafm_limit.py` 验的是
`out=(1,83,640)` —— **一个全零的模型照样过**。我据此写过 `[OK]`。
3. **没有 sha256。** Qwen3-8B 坏过一次之后明明立了「单写者 .part + 强制 sha256」的规矩,
但**只用在了 Qwen3 上,RNA-FM 漏了**。规矩没有强制力,就等于没有。
### 作废清单
| 东西 | 状态 |
|---|---|
| `checkpoints/stage1_run1/` 27 个 checkpoint(13500 步 ≈ 3.75 h) | ❌ 废 |
| `checkpoints/stage1` 500/1000/1500、`_losscheck`、`_timing_gc` | ❌ 废 |
| ROUGE 曲线试点 0.2084 → 0.3030 | ❌ 废 |
| 「step-8000 正确写出 LINC00115 ⇒ 模型在读 RNA」 | ❌ **收回**。encoder 对序列零敏感,这个结论不成立 |
| Δloss 置零探针 0.273 → 0.047 的"塌缩"判读 | ❌ 无意义 |
| 数据 `rnaqa_n6.jsonl` / `evalset` | ✅ 与 encoder 无关,有效 |
| 代码(含本轮修的 4 个 bug) | ✅ 有效 |
| 计时测量 | ✅ 大致仍然成立(encoder 本来就便宜) |
### 修复
`scripts/00e_refetch_rnafm.py`(新)——所有通路都走同一个闸门:
```
下载 -> .part -> sha256 必须 == ec69333e…a2828 -> 才允许原子改名
```
配套规则(每条都对应一个踩过的坑):
* `archive_corrupt()`:坏文件改名留档,不删;
* **哈希不符时绝不改名**;
* 哈希不符 + 大小已满 ⇒ 判定为「预分配空洞」。这种情况**续传救不了**
—— 控制文件会说"分片都完成了",每次续传立刻返回同一个坏文件,卡死循环。
必须连 `.part.aria2` 一起清掉;
* 哈希不符 + 大小未满 ⇒ 真没下完,保留 `.part` 续传;
* **下载完还要独立验一次「换序列输出会变」** —— 哈希对了也可能装错文件。
### 立规矩
**禁止一直换源。** 选定一条通路就走到底,慢就慢。
把关的不是速度,是校验。遇到"慢",不要去找更快的源,先把校验补上,然后等。
(已写进 memory.db #1011。)
---
## ✅ 换到 ERNIE-RNA(E 轨)+ 下载与健全性检查全过
### 决定
用户拍板:**改用 ERNIE-RNA 作 backbone**,理由是结构增强(attention bias / pairwise 表示),
性能强于 BERT 基座的 RNA-FM。
**这是 E 轨**(PLAN §7)。论文用的是 RNA-FM,所以 ERNIE-RNA 的任何数字都要单独标 E 轨,
不能和论文 / R 轨并列。
### 取数(三条路都试了)
| 源 | 结果 |
|---|---|
| HF `multimolecule/ernierna` | ✅ **最终成功**。345,071,800 B,sha256 `02274cb7…08eb`,与上游声明逐位一致 |
| hf-mirror.com(并行) | 下到 210/329 MB 后放弃(同一份 artifact,谁先完成算谁的) |
| GitHub `Bruce-ywj/ERNIE-RNA` | 不放权重,只有代码 + 一个 Google Drive 文件 ID |
| Google Drive `ERNIE-RNA_pretrain.pt` | 作者**原始 fp32 权重**,1,035,221,383 B(1.04 GB),实测传不动。**留作兜底** |
| ModelScope | 没有 ernierna。有 `ZhejiangLab-LifeScience/{rnafm,rnaernie,rnamsm,rnabert,dnabert2}`,但都是**浙江lab 自己的转换版**(vocab 26,键名不同),不是 multimolecule 的 |
### 健全性检查:**PASS**
```
sha256 02274cb7…08eb ✅ 与上游声明一致
张量总数 207,全零 1 ✅ 只有 pooler.dense.bias(非主干,从不使用)
键覆盖 202/202,未覆盖 0 ✅ 权重真的装进模型了
输出形状 (4, 202, 768) ✅ hidden 768
base vs polyG max|Δ| = 3.1138e+00 ✅ 序列敏感
base vs shuffled max|Δ| = 2.3979e+00 ✅ 次序敏感
base vs base max|Δ| = 0.0000e+00 ✅ 确定性
VERDICT: PASS
```
### 关键实测差异:ERNIE-RNA **没有** RNA-FM 的 1022 nt 硬上限
```
RNA-FM position_embedding_type = 'absolute' -> 查表,表只有 1026 行,1023 nt 直接 IndexError
ERNIE-RNA position_embedding_type = 'sinusoidal' -> 算出来的,无硬上限;实测 1030 nt(1032 token)也能过
```
但 config 的 `max_position_embeddings = 1026` 是**预训练窗口**,超出去是外推。
所以 `ARCHS["ernierna"].max_nt = 1024`(= 1026 token − BOS/EOS),取预训练范围而不是拍一个数。
(现有数据按 1022 过滤过,所以这个数目前不会真的被用到。)
### 新增/改动
| 文件 | 作用 |
|---|---|
| `src/rnaqwen/paths.py` | **新**。集中默认路径 + 轨道归属。R 轨 `models/rnafm`,E 轨 `models/ernierna`。覆盖优先级:`--encoder` > `RNAQWEN_ENCODER` > 默认 |
| `src/rnaqwen/models/encoder.py` | 重写为多 backbone `RnaEncoder`,按 `config.json` 的 `model_type` **自动识别**;arch 与 config 不符、hidden 不符都直接报错 |
| `scripts/01c_check_encoder_sanity.py` | **新**。四条闸门(哈希 / 结构 / 键覆盖 / 序列+次序敏感性+确定性)。已用已知损坏文件验证过会 FAIL |
| `scripts/00d_fetch_verified.py` | **新**。通用哈希闸门取数;拿不到上游 sha256 就拒绝下载大文件;单写者锁;真实进度读数 |
| `scripts/01b_probe_rnafm_limit.py` | 改为按 arch 解析,不再硬编码 `RnaFmModel` |
### 下一步
1. Stage 1 冒烟(小样本几步),确认 `768 -> 4096` 的链路通
2. Stage 1 全量(E 轨,10 epoch)
3. ROUGE 曲线
---
## 🔄 转向:彻底放弃 RNA-GPT 软提示路线(2026-10-10)
**性质变更:本项目不再是 RNA-GPT 的复现。** 变成「RNA-QA 上软提示路线 vs 原生文本路线的头对头对比」。
新计划见 `PLAN_v2.md`;旧计划留档为 `PLAN_v1_softprompt.md`。
### 触发点不是"做不下去",是逐条测出了天花板
用户最初提出「Stage 1 对齐目标与 Stage 2 指令目标脱节」的假设,我核实后发现它是**症状不是病根**。
真正的病根在核实过程中浮出来:**我们冻结了 LLM。**
| 检验 | 结果 |
|---|---|
| 投影在丢信息? | ❌ 减掉公共分量后 inter = **-0.05**,信息完整 |
| 学习式压缩器造成偏执依赖? | ❌ meanpool 与 resampler 逐 token 剖面几乎一致(负值 58.4% / 63.3%) |
| val_loss 能选投影? | ❌ 两次独立配对都是 val 低 ↔ grounding 低 |
| 去各向异性是解? | ❌ val 打平(1.4892 / 1.4926),Δzeroed 差 8× 但消融口径不同、不可比 |
| 原生文本要改 tokenizer? | ❌ 0.526 tokens/nt,扰动中位影响跨度 **0 nt**,局部稳定 |
### 四条外部证据(都核实过原文,不是转述)
- **ChatMultiOmics** arXiv 2412.19191:`directly applying LoRA ... near-random performance in
classification and regression tasks ... LoRA primarily leverages pre-trained knowledge to generate
well-formed answers based on the output format learned from SFT data`
→ 精确命中我们的观测:val_loss 降到 1.384(格式学会了),Δzeroed 衰减到 25%(内容没进去)
- **MAPO** arXiv 2605.27741(JHU + 腾讯混元,作者 Sanjeev Khudanpur / Liefeng Bo):
术语 **late-stage modality collapse**;`abandon the primary source signal in favor of
compressed textual priors`。其仪器(消融参照的跨模态差分熵)与我们的 Δzeroed 是同一个东西,
但**逐 token、更强**。
- **Moirain** arXiv 2605.23961:RNA + 多模态 SFT + DPO;DPO 动机是
`without collapsing the learned natural distribution`。
- **RNA-GPT** arXiv 2411.08900:Stage 1 的 Q 留空是**故意**的,为堵死"抄 prompt 文本"——
这是它的解,也是它的病。
### 今天用血换来的六条方法论
1. `mem_get_info()` 在 gfx1151(APU) 上不可信(报 107 GB,物理内存只 63.6 GB)→ **只看物理内存**
2. 哈希闸门是唯一能挡「大小对、内容空」的手段(已两次)
3. 单测过了还要跑真模型冒烟(SOS-LoRA 单测 8/8,冒烟仍抓出 dtype 与作用域两个 bug)
4. **「测出来的东西和以为测的不是一回事」今天犯了四次** —— 每次都在
「全零 / max 也零 / 比值太整齐」这类信号上露馅。**看到这种信号先怀疑自己。**
5. 优先用**直接量**(可分辨性、逐 token),不用聚合标量(val_loss 已被证伪两次)
6. 换源不算办法 —— 先修校验,再等
### 新路线
```
RNA 序列 → Qwen3 tokenizer 原生 token(0.526 tokens/nt)
→ ① 序列继续预训练(可选,第一版先跳过)
→ ② 指令微调(SFT / LoRA / SOS-LoRA)
→ ③ 推理微调(可选)
```
**保留**:4,481 RNA / 92,680 QA 数据集、evalset_merged、评测协议、Qwen3-8B、SOS-LoRA 实现。
**零新架构、零 tokenizer 改动**,只需要一段几十行的语料预处理。
---
## 2026-10-10 小实验:FSQ 的码到底是词表还是哈希(44/45/46)
用户问「能做个小实验吗」。这条路线能不能走,取决于一个**不用训 LLM 就能回答**的问题:
离散码是不是可复用的词表。跑了三个递进的对照,**其中两个指标被我自己的设计否掉了**。
### 44_ 覆盖率 —— 第一次自我否证
v1 用 64 条序列 (29k 位置) + 词表 65536,得到「52% 的码只出现一次」。
**这是算术必然,不是发现** —— 平均每码 2.6 个位置,中位重用必然是 1。
v2 改成 256 条序列 (116k 位置)、扫词表 {256,1024,4096,6561,65536} + iid 自助基线。
结果:所有配置的留出覆盖率**都略低于**基线(4096: 95.8% vs 97.5%)。
### 45_ 序列指纹对照 —— 第二次自我否证
「低于基线」的唯一解释应是码在序列内成簇。于是加对照:
(a) 同序列内位置留出 vs (b) 跨序列留出。
- burstiness:平均游程 **1.02**(iid 应为 1.0)—— **码在序列内不成簇**
- (a) -2.0pp,(b) -2.7pp —— **两者一样**
「序列指纹」的解释被推翻。但更重要的:**覆盖率这个指标本身没有判别力。**
词表 4096、训练位 9.3 万时,任何映射都会让每个码被见到几十次 —— 哈希也会给出
90% 利用率和 95% 覆盖。词表远大于样本时又样本不够。**两头都不成立。**
### 46_ 含义迁移 —— 唯一非循环的测试
靶子换成**局部 6-mer**(来自输入序列,不是 encoder 学的,所以不循环):
在训练序列上学 code -> 最常见 6-mer 的表,在**没见过的序列**上考。
| 量 | 值 |
|---|---|
| 测试位置中码在训练集见过 | 99.1% |
| 按码预测 6-mer 准确率 | **8.48%** |
| 打乱 code<->位置 对照 | 2.95% |
| 常数预测(最常见 6-mer) | 3.02% |
| **增益 / 倍数** | **+5.53pp / 2.88x** |
打乱对照回落到常数水平(2.95% ≈ 3.02%),说明对照有效。
### 结论
**是词表,但是一个不精确的词表。** 码确实携带能跨序列迁移的局部结构信息(2.9x),
不是给每条序列发指纹。但要老实说清两个限制:
1. 局部 6-mer 是**很弱**的靶子 —— ERNIE-RNA 在一位点上编码的远不止 6-mer。
所以 2.9x 是**下界**,不是上限;但它也说明码与「局部序列组成」的相关性并不强。
2. within/重建 err 这两个指标是**循环的**:FSQ 是确定性映射、AE 又照重构误差训,
within<1 是构造保证的。**不能拿它们当证据。**
### 对齐踩的坑(记下来)
ERNNIE-RNA 的 tokenizer 每条序列加了 [CLS] 和 [SEP]:
encoder 位置数 = 序列长度 + 2。115886 - 115374 = 512 = 2 x 256,一眼可验。
不做这个偏移,k-mer 靶子整体错位一格,结果全是废的。
### 方法论:一天内第 5、6 次「测的不是我以为的东西」
- 「52% 的码只出现一次」= 词表比样本大(算术必然)
- 「覆盖率低于基线」= 指标无判别力(两头都不成立)
两次都是**先看到太干净的数字,然后才去怀疑自己**。规则 4 又生效了。
---
## 2026-10-10 路线 C 端到端短实验启动(47_)
PLAN_v2.md 已更新:§6.4 第 2 条(去各向异性「前置必需」)**被自己的测量证伪**,降级为消融项;
§6.5 记录 44/45/46 与那两次自我否证;§6.6 记 [CLS]/[SEP] 偏移的坑;§7 换成短实验清单。
### 脚本 scripts/47_routeC_short_sft.py
单变量:底座冻结 + LoRA(attn) + **一张 4096x4096 可训练码表**(16.8M 参数,与臂 A 的 3M 投影同量级)。
RNA 位置不经过 tokenizer,直接用 inputs_embeds 把码 id 映射到码表(不用 resize_token_embeddings,
不污染 Qwen 原词表)。判据是 MAPO 式**逐 token 消融**,不是 loss —— loss 降下来也可能只是模型学会忽略 RNA。
四路消融:真表 / 零表 / 随机表 / 常数码。**关键在对照,不在第一个数:**
- vs 零表 低 => RNA 位置上「有东西」在起作用
- vs 随机表 低 => 起作用的是**码的内容**
- vs 常数码 低 => 起作用的是**码的区分度**
后两个 ≈50% 就是「Qwen 眼里的 RNA 是乱码」,只是换了形式。
### 冒烟(60 条 RNA / 30 步)发现的两个问题 —— 都是我的错
1. |grad_code| 0.0000 每步都是 0。**因为我在 opt.zero_grad() 之后才读 .grad。** 打印全废。
修到 step 之前读,实测梯度和 19.65 -> 0.79,码表确实在学。
2. **判定逻辑漏了对照。** 第一版只看「vs 零表 < 58.4%」就判 ✅。
实际冒烟结果是 vs 零表 32.9%、**vs 随机表 48.1%、vs 常数码 47.6%** ——
后两个是掷硬币,说明模型用的是「RNA 位置有个可训练向量」而非「这个码是什么」。
**差点报出一个假的成功。** 判定已改成必须三个对照同时 < 45%。
另一个接线 bug:
na_emb 建在 CPU 上(只有 peft 模型走 device_map 上了卡)-> 必须在
ase_emb.weight.device 上建。**方法论规则 3:单元测试过 != 接线对。**
### 冒烟的定位
30 步、码表漂移仅 **3.64%**,对照 ≈50% 是必然的,**没有判别力**。
冒烟只证明端到端能跑通(梯度非零、初始行范数 1.28 与 Qwen 词表 1.38 同量级、四路前向都通)。
### 正式短实验(已在后台启动)
1500 条 RNA / 31190 QA,600 步,每 150 步跑一次三路消融。
**要看的是对照差距随码表漂移「拉开还是一动不动」** —— 那才是判据。
FSQ 已在 668273 个位置上训完,mse 0.045。