RNAQwen / scripts /46_fsq_code_transfer.py
RomanCohort's picture
add scripts
acd280c verified
Raw History Blame Contribute Delete
8.99 kB
"""真正有判别力的那个测试:码的「含义」能不能跨序列迁移?
前两个指标都被否掉了:
· within-code 方差 / 重建 err —— 循环。FSQ 是确定性映射、AE 照重构误差训的,
within<1 是构造保证的,不是发现。
· 覆盖率 —— 没有判别力。词表远小于训练位置数时,哈希也会给出 90% 利用率和
95% 覆盖;词表远大于时样本不够。44_/45_ 两次都栽在这上面。
能区分的只有一个:**同一个码在没见过的序列里,是否指向同一种局部结构。**
靶子用局部 k-mer —— 它直接来自输入序列,不是 encoder 学出来的,所以不循环。
判据:
在训练序列上学一张 code -> 该码最常见的局部 6-mer 的表,
然后在**没见过的序列**上考:
用码去预测该位置的 6-mer 的准确率 vs 把码随机打乱后的对照
打乱对照保留码的边缘分布,抹掉"码<->位置"的对应关系。
若准确率 >> 打乱对照,说明码承载了可跨序列迁移的局部结构 información,是词表。
若 ≈ 对照,说明码到了新序列上就没有意义了 —— 接进 LLM 学不到东西。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
import torch # noqa: E402
import torch.nn as nn # noqa: E402
import torch.nn.functional as F # noqa: E402
DEV = "cuda" if torch.cuda.is_available() else "cpu"
LATENT, LEVELS = 12, 2 # vocab 4096
VOCAB = LEVELS ** LATENT
K = 6
STEPS = 1500
DATA = ROOT / "data/processed/rnaqa_n6_merged.jsonl"
N_SEQ, N_TEST = 256, 64
def fsq(z, levels):
t = (torch.tanh(z) + 1) / 2
s = t * (levels - 1)
k = torch.round(s)
return (k / (levels - 1) * 2 - 1) + (s - s.detach()), k.long()
class AE(nn.Module):
def __init__(self, dim, latent, levels, hidden=256):
super().__init__()
self.enc = nn.Linear(dim, latent)
self.levels = levels
self.dec = nn.Sequential(nn.Linear(latent, hidden), nn.GELU(),
nn.Linear(hidden, hidden), nn.GELU(),
nn.Linear(hidden, dim))
def forward(self, x):
q, k = fsq(self.enc(x), self.levels)
c = torch.zeros(x.shape[0], dtype=torch.long, device=x.device)
for d in range(k.shape[1]):
c = c * self.levels + k[:, d]
return self.dec(q), c
def main() -> int:
recs = []
with open(DATA, encoding="utf-8") as f:
for line in f:
if line.strip():
recs.append(json.loads(line))
recs = [r for r in recs if r.get("sequence")]
recs.sort(key=lambda r: len(r["sequence"]))
n_all = 256
seqs = [recs[int(i * (len(recs) - 1) / (n_all - 1))]["sequence"] for i in range(n_all)]
d = torch.load(ROOT / "data/processed/_fsq_probe_x.pt", map_location="cpu",
weights_only=False)
x, sid = d["x"].to(DEV), d["sid"].to(DEV)
# 逐条比对:encoder 每条序列给出 L+2 个位置([CLS] + 核苷酸 + [SEP])。
# 这与观测一致:115886 - 115374 = 512 = 2 x 256。
cnt = torch.bincount(sid).tolist()
keep = [i for i in range(len(seqs)) if cnt[i] == len(seqs[i]) + 2]
drop = len(seqs) - len(keep)
if drop:
print(f"[filter] 丢弃 {drop} 条长度不匹配的序列,保留 {len(keep)}")
remap = {o: n for n, o in enumerate(keep)}
keep_pos = torch.tensor([s in remap for s in sid.tolist()], device=DEV)
x = x[keep_pos]
sid = torch.tensor([remap[s] for s in sid[keep_pos].tolist()], device=DEV)
seqs = [seqs[i] for i in keep]
N_SEQ = len(seqs)
N, D = x.shape
g = torch.Generator().manual_seed(7)
test_ids = set(torch.randperm(N_SEQ, generator=g)[:max(1, N_SEQ // 4)].tolist())
seq_tr = torch.tensor([s not in test_ids for s in sid.tolist()], device=DEV)
torch.manual_seed(0)
ae = AE(D, LATENT, LEVELS).to(DEV)
opt = torch.optim.AdamW(ae.parameters(), lr=3e-3)
idx = torch.nonzero(seq_tr).squeeze(1)
for _ in range(STEPS):
p = idx[torch.randint(0, idx.numel(), (512,), device=DEV)]
F.mse_loss(ae(x[p])[0], x[p]).backward()
opt.step(); opt.zero_grad()
ae.eval()
with torch.no_grad():
_, code = ae(x)
# 构造每个位置的局部 6-mer 编号([CLS]/[SEP] 和末尾不足 K 的位置记为 -1)
mer_of_pos = torch.full((N,), -1, dtype=torch.long)
pos = 0
for s in seqs:
b = s.encode()
L = len(b)
pos += 1 # [CLS]
for j in range(L):
if j + K <= L:
v = 0
for ch in b[j:j + K]:
v = v * 4 + ({65: 0, 67: 1, 71: 2, 84: 3, 85: 0}.get(ch, 0))
mer_of_pos[pos] = v
pos += 1
pos += 1 # [SEP]
# 位置必须与 encoder 的 mask 逐条对齐,否则 k-mer 靶子是错位的
assert pos == N, f"位置对齐失败: 构造 {pos} != encoder 位置 {N}"
mer_of_pos = mer_of_pos.to(DEV)
tr_pos = torch.nonzero(seq_tr & (mer_of_pos >= 0)).squeeze(1)
te_pos = torch.nonzero(~seq_tr & (mer_of_pos >= 0)).squeeze(1)
print(f"[data] 训练位置 {tr_pos.numel()} 测试位置 {te_pos.numel()} "
f"(局部 {K}-mer,词表 {4**K})")
tc, tm = code[tr_pos], mer_of_pos[tr_pos].to(DEV)
ec, em = code[te_pos], mer_of_pos[te_pos].to(DEV)
# code -> 该码在训练序列里最常见的 6-mer
lookup = torch.full((VOCAB,), -1, dtype=torch.long, device=DEV)
for c in torch.unique(tc):
sel = tm[tc == c]
counts = torch.bincount(sel, minlength=4 ** K)
lookup[c] = int(counts.argmax())
hit = lookup[ec] >= 0
acc = float((lookup[ec][hit] == em[hit]).float().mean()) if hit.any() else 0.0
cov = float(hit.float().mean())
# 对照:打乱 code<->位置 的对应(保留 code 边缘分布)
g2 = torch.Generator().manual_seed(99)
accs = []
for _ in range(5):
perm = torch.randperm(tc.numel(), generator=g2)
tc2 = tc[perm]
lk = torch.full((VOCAB,), -1, dtype=torch.long, device=DEV)
for c in torch.unique(tc2):
sel = tm[tc2 == c]
lk[c] = int(torch.bincount(sel, minlength=4 ** K).argmax())
h = lk[ec] >= 0
accs.append(float((lk[ec][h] == em[h]).float().mean()))
ctrl = sum(accs) / len(accs)
# 第二个对照:全局最常见的 6-mer 常数预测
base = float(torch.bincount(tm, minlength=4 ** K).max()) / tm.numel()
print()
print("=" * 78)
print("跨序列含义迁移:用码预测局部 6-mer")
print("=" * 78)
print(f" 测试位置中码在训练集见过的比例: {100*cov:.1f}%")
print(f" 按码查表预测 6-mer 准确率 : {100*acc:.2f}%")
print(f" 打乱 code<->位置 对照 : {100*ctrl:.2f}%")
print(f" 常数预测(训练集最常见 6-mer): {100*base:.2f}%")
print(f" 相对打乱对照的增益 : {100*(acc-ctrl):+.2f}pp")
# 信息量:以比特计
import math
lift = acc / max(ctrl, 1e-9)
print(f" 提升倍数 : {lift:.2f}x")
print()
print("=" * 78)
print("判定")
print("=" * 78)
if acc > ctrl * 1.5 and acc > base * 1.3:
print(f" ✅ **码携带可跨序列迁移的局部结构信息**({100*acc:.1f}% vs 打乱 {100*ctrl:.1f}%,"
f"{lift:.1f}x)。")
print(" 同一个码在没见过的序列里指向同一类 6-mer —— 这是词表,不是哈希。")
print(" 接进 LLM 有可学的东西。")
elif acc > ctrl * 1.15:
print(f" ⚠️ **弱迁移**:{100*acc:.1f}% vs 打乱 {100*ctrl:.1f}%({lift:.2f}x)。")
print(" 码有结构但信噪比低。可能要靠加复用压力(commitment / 熵正则)才能变干净。")
else:
print(f" ❌ **码到新序列上就没有含义了**({100*acc:.1f}% vs 打乱 {100*ctrl:.1f}%,"
f"{lift:.2f}x)。")
print(" 码只是把每条序列切成一串各自不同的片段 —— 对 LLM 而言是新的乱码。")
print(" 要救这条路线,必须换目标函数(加复用压力),而不是换个量化方式。")
out = ROOT / "reports" / "fsq_code_transfer.json"
out.write_text(json.dumps(dict(vocab=VOCAB, k=K, acc=acc, ctrl=ctrl, base=base,
cov=cov, lift=lift), ensure_ascii=False, indent=2),
encoding="utf-8")
print(f"\n[save] {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())