File size: 8,993 Bytes
acd280c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
"""真正有判别力的那个测试:码的「含义」能不能跨序列迁移?



前两个指标都被否掉了:

  · 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())