🏠 总目录📚 本教程 02 · 序列不是文本 ← →
📑 本页目录(点开跳转)

02 · 序列不是文本

⏱ 26 分钟 | ⭐ 它长得像句子,NLP 的工具全都接得上 —— 但接得上不等于该照搬


🎯 一句话

蛋白质写出来就是一串字母,于是 tokenizer、Transformer、预训练那一整套当场就能跑;问题是你从自然语言带来的默认设置,几乎每一条都要改。 词表只有 20 个、每个位置本身就是标签、库里躺着大量近亲 —— 这三件事分别改掉了分词、位置处理和数据划分。


🧬 一、生物学部分,一段就讲完

蛋白质是一条链,链上的单元叫残基(residue),标准的有 20 种,每种写成一个大写字母(A、C、D … Y)。所以一个蛋白质就是一个 20 字母表上的字符串,几十到几千个字符长。这条链会折叠成三维形状,形状决定功能。DNA 是同一个故事的 4 字母版本(A/C/G/T)。

⭐ 以上就是这一章需要的全部生物学,后面全是建模问题。


🔤 二、五条直觉,逐条要改

NLP 里的做法 在序列上变成什么 为什么
BPE 切子词 ⭐ 不切,一残基一 token 压长尾的动机不存在,切了还破坏位置对齐
词表几万到十几万 20 出头 字母表就这么大,没有生词
上下文几千到几十万 几百到几千个残基 但注意力仍是平方复杂度,要同时喂一批同源序列就不短
相邻的更相关 ⚠️ 隔一百多个位置的两个残基,三维里可能贴在一起 链会折叠
语料是自然文本 库里高度冗余 测序偏向被研究得多的家族

✂️ 三、为什么不切子词

站内主线 1 · 文字怎样进入模型把 BPE 的动机说得很清楚:一字一 token 永远不会遇到生词,但句子太长;一词一 token 够短,却要面对无限多的新词和拼写错误。子词是这两端之间的折中。

⭐ 到了残基序列上两端的压力同时消失:字母表只有 20 个,永远没有生词;一残基一 token 确实让序列变长,可蛋白质本来就只有几百到几千个残基。

而切了还要倒付一笔:这一行很多任务是逐残基的(这个位置露在外面还是埋在里面、是不是结合位点),字符级下"第 47 个 token"就是"第 47 号残基";切了之后边界会跟着上下文乱跑。

字符级:一残基一格,编号钉死K47T48A49Y50I51A52K53第 47 号 token = 第 47 号残基BPE:同一段 KTAYIAK,边界跟着上下文跑HHH…HKTAYIAK前面的 H 把 K 吃掉了QQQ…QKTAYIAK换成 Q,吃法也换了AAA…KTAYIAK这次 K 才自己独立成格压缩率的回报也很薄:200 残基只切成 113.9 个 token
只看每行第一个红框:同一段 KTAYIAK,开头那个 K 一会儿被并进 HK、一会儿 QK、一会儿自己独立 —— ⭐「这个片段从第几个 token 开始」没有稳定答案。而逐残基任务是这一行的主流,字符级下第 47 号 token 就是第 47 号残基。

自己写一个几十行的 BPE 就看得到:

import collections, random, re
random.seed(0); AA = "ACDEFGHIKLMNPQRSTVWY"      # 20 种氨基酸 = 全部词表

def cut(w, p):                                   # 合并 w 里所有的 p 这一对
    pat = r"(?<!\S)" + re.escape(" ".join(p)) + r"(?!\S)"
    return re.sub(pat, "".join(p), " ".join(w)).split(" ")

corpus = [[random.choice(AA) for _ in range(200)] for _ in range(300)]   # 合成序列
ms = []
for _ in range(400):                             # 每轮合并一次最高频的相邻对
    c = collections.Counter()
    for w in corpus: c.update(zip(w, w[1:]))
    ms.append(c.most_common(1)[0][0])
    corpus = [cut(w, ms[-1]) for w in corpus]

def enc(s):
    w = list(s)
    for p in ms: w = cut(w, p)
    return w

for L, R in [("MGSSHHHH", "DEFGHIKL"), ("QQQ", "WWWWWWWW"), ("AAAAAAAAAAAA", "MM")]:
    print(L[-3:] + "[KTAYIAK]" + R[:3], "->", "|".join(enc(L + "KTAYIAK" + R)))
print("200 残基切成", round(sum(len(w) for w in corpus) / 300, 1), "个 token")
HHH[KTAYIAK]DEF -> M|GS|SH|HH|HK|TA|YI|AK|DE|F|GH|IK|L
QQQ[KTAYIAK]WWW -> QQ|QK|TA|YI|AK|WW|WW|WW|WW
AAA[KTAYIAK]MM -> AA|AA|AA|AA|AA|AA|K|TA|YI|AK|MM
200 残基切成 113.9 个 token

⭐ 同一段 KTAYIAK,三次切出来的第一个 token 分别是 HK、QK、K —— 开头那个残基被前面的邻居吃掉了,吃法取决于上下文。 "这个片段从第几个 token 开始",没有稳定答案。

⚠️ 收益也不好看:200 个残基只压到 113.9 个,不到两倍,而压缩率本来是 BPE 的主要卖点。另外语料是随机生成的,那 400 条规则没有任何生物学含义 —— BPE 只统计共现,它不知道自己在切什么。


👯 四、库里躺着的全是近亲

序列数据库不是独立同分布采样出来的:研究得多的物种和家族会留下大量彼此高度相似的条目。这不是"清洗没做干净",是数据来源本身的形状。造一批看看 —— 10 个家族,大小从 80 到 2 极不均匀,每条从祖先点突变 20%。

import random, statistics
rng = random.Random(7); AA = "ACDEFGHIKLMNPQRSTVWY"
seqs = []
for n in [80, 40, 25, 15, 10, 10, 8, 6, 4, 2]:                 # 家族大小极不均匀
    anc = "".join(rng.choice(AA) for _ in range(150))          # 这个家族的祖先
    seqs += ["".join(rng.choice(AA) if rng.random() < .2 else c for c in anc)
             for _ in range(n)]                                # 点突变 20% 得到亲戚

def idt(a, b):          # 长度一样就按位比;真实序列要先做比对
    return sum(x == y for x, y in zip(a, b)) / len(a)

N = len(seqs)
allp = [idt(seqs[i], seqs[j]) for i in range(N) for j in range(i + 1, N)]
best = [max(idt(seqs[i], seqs[j]) for j in range(N) if j != i) for i in range(N)]
print("随机抽一对   相同率中位数 %.2f" % statistics.median(allp))
print("每条的最近邻 相同率中位数 %.2f" % statistics.median(best))
print("有 >=50%% 近亲的序列占 %.0f%%" % (100 * sum(b >= .5 for b in best) / N))
随机抽一对   相同率中位数 0.05
每条的最近邻 相同率中位数 0.71
有 >=50% 近亲的序列占 100%

⭐ 这三行必须一起读。 第一行看着毫无问题:随机抽两条相同率中位数只有 0.05,正好是 1/20 —— 无关序列在 20 字母表上的背景值,按这个指标它"一点都不冗余"。换个问法就翻过来:每条的最近邻中位数 0.71,100% 的序列都有一个 50% 以上的近亲。

⭐⭐ 冗余不是"平均相似",是"每一条都有近亲"。 而随机划分训练集和测试集,正好是按条抽的 —— 抽走一条,近亲还留在另一边。

10 个家族,大小极不均匀8040251510108642共 200 条,每条从本家族祖先点突变 20%两种问法,同一批数据0.000.250.500.751.00序列相同率随机抽一对:中位数 0.05= 1/20,20 字母表的背景值每条的最近邻:中位数 0.71100% 的序列都有 ≥50% 的近亲50%⚠️ 而随机划分正好是【按条】抽的抽走一条,它的近亲还留在另一边
两个箭头都要看:左边那个说这批数据「一点都不冗余」,右边那个说「每一条都有近亲」—— 同一批数据,两种问法。⭐ 冗余不是「平均相似」,所以按平均值做的检查查不出它。(轴上只标中位数,因为章里量的就是中位数。)

这条线接到本板块第 7 章(随机划分会虚高多少、按什么划分才诚实)。⚠️ 上面能直接按位比是因为合成序列等长;真实序列要先做序列比对,那是后面几章的事。


🧲 五、剩下两条:远距离,和位置

远距离。 链会折起来,序列上隔一百多个位置的两个残基在三维里完全可能贴在一起,所以"相邻的更相关"这个卷积式的先验在这里是错的。⭐ 而注意力恰好不预设距离 —— 每个位置对所有位置打分,谁相关由数据说了算,机制和主线 2 · 模型怎样看懂一句话里"它指谁"那个例子完全相同。注意力用在这里不是因为它流行,是因为这里的相关性本来就不看距离。

位置。 一句话换个语序常常还读得懂,序列里两个残基对调却是另一个蛋白质。注意力对顺序无感,位置必须显式加进去 —— 怎么加、为什么相对位置更好用,看专题 C · RoPE:位置感。


🔗 这一章连到哪里

去哪 为什么
大模型全景导论 · 主线 1 · 文字怎样进入模型 ⭐ 它把 BPE 的动机写成"词表不能无限大 vs 句子不能太长"的折中 —— 看清那个动机,才看得懂它在 20 个字母上为什么失效
大模型全景导论 · 主线 2 · 模型怎样看懂一句话 "对所有可见位置打分"正是"远距离也可能相关"需要的机制,那一章讲了它怎么算
大模型全景导论 · 专题 C · RoPE:位置感 逐残基任务对位置格外敏感,位置怎么编码在这里不是可选专题
数据这一关 · 06 · 重复与实体解析 同一条数据的两个副本分别落进训练集和验证集 —— 序列库的冗余就是这个事故,只是这里是常态

✅ 检查点

  1. 为什么残基序列上一般不做子词切分?给出两条独立的理由。
  2. 上面那段输出里,同一个 KTAYIAK 的第一个 token 为什么会变?说明了什么问题?
  3. 200 个残基被切成多少个 token?这个数字为什么值得留意?
  4. "随机抽一对中位数 0.05"和"100% 的序列都有 50% 以上的近亲"为什么不矛盾?哪一个和数据划分有关?
  5. 为什么"相邻的更相关"在这里是错的?这件事怎么解释注意力合适?
👀 答案
  1. ① 字母表只有 20 个、永远没有生词,压长尾的动机不存在;② 很多任务是逐残基的,切了会破坏"第 N 个 token = 第 N 号残基"。
  2. 三次分别是 HK、QK、K —— 开头那个残基被前面的邻居吃掉了,所以"从第几个 token 开始"没有稳定答案。
  3. 113.9 个,不到两倍。压缩率本来是 BPE 的主要卖点,这里不怎么兑现。
  4. 量的是两件事:0.05 是随机两条的相同率,等于 1/20 的背景值;0.71 是每条的最近邻。⭐ 随机划分按条抽,相关的是后者。
  5. 链会折叠,隔很远的残基三维里可能贴在一起,相关性不看序列距离;而注意力对所有位置打分、不预设距离。

🛑 可以停在这里

为什么大多一残基一 token、为什么随机划分危险、注意力为什么合适 —— 这三条足够读懂下一章。 序列比对怎么做、同源泄漏虚高多少,都在后面。

⚡ 走神救援

先记住这几件事

下一节 👉 03-蛋白质语言模型.md

打卡记录保存在你的浏览器里,首页能看到总进度