📑 本页目录(点开跳转)
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 号残基";切了之后边界会跟着上下文乱跑。
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% 以上的近亲。
⭐⭐ 冗余不是"平均相似",是"每一条都有近亲"。 而随机划分训练集和测试集,正好是按条抽的 —— 抽走一条,近亲还留在另一边。
这条线接到本板块第 7 章(随机划分会虚高多少、按什么划分才诚实)。⚠️ 上面能直接按位比是因为合成序列等长;真实序列要先做序列比对,那是后面几章的事。
🧲 五、剩下两条:远距离,和位置
远距离。 链会折起来,序列上隔一百多个位置的两个残基在三维里完全可能贴在一起,所以"相邻的更相关"这个卷积式的先验在这里是错的。⭐ 而注意力恰好不预设距离 —— 每个位置对所有位置打分,谁相关由数据说了算,机制和主线 2 · 模型怎样看懂一句话里"它指谁"那个例子完全相同。注意力用在这里不是因为它流行,是因为这里的相关性本来就不看距离。
位置。 一句话换个语序常常还读得懂,序列里两个残基对调却是另一个蛋白质。注意力对顺序无感,位置必须显式加进去 —— 怎么加、为什么相对位置更好用,看专题 C · RoPE:位置感。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 大模型全景导论 · 主线 1 · 文字怎样进入模型 | ⭐ 它把 BPE 的动机写成"词表不能无限大 vs 句子不能太长"的折中 —— 看清那个动机,才看得懂它在 20 个字母上为什么失效 |
| 大模型全景导论 · 主线 2 · 模型怎样看懂一句话 | "对所有可见位置打分"正是"远距离也可能相关"需要的机制,那一章讲了它怎么算 |
| 大模型全景导论 · 专题 C · RoPE:位置感 | 逐残基任务对位置格外敏感,位置怎么编码在这里不是可选专题 |
| 数据这一关 · 06 · 重复与实体解析 | 同一条数据的两个副本分别落进训练集和验证集 —— 序列库的冗余就是这个事故,只是这里是常态 |
✅ 检查点
- 为什么残基序列上一般不做子词切分?给出两条独立的理由。
- 上面那段输出里,同一个
KTAYIAK的第一个 token 为什么会变?说明了什么问题? - 200 个残基被切成多少个 token?这个数字为什么值得留意?
- "随机抽一对中位数 0.05"和"100% 的序列都有 50% 以上的近亲"为什么不矛盾?哪一个和数据划分有关?
- 为什么"相邻的更相关"在这里是错的?这件事怎么解释注意力合适?
👀 答案
- ① 字母表只有 20 个、永远没有生词,压长尾的动机不存在;② 很多任务是逐残基的,切了会破坏"第 N 个 token = 第 N 号残基"。
- 三次分别是
HK、QK、K—— 开头那个残基被前面的邻居吃掉了,所以"从第几个 token 开始"没有稳定答案。 - 113.9 个,不到两倍。压缩率本来是 BPE 的主要卖点,这里不怎么兑现。
- 量的是两件事:0.05 是随机两条的相同率,等于 1/20 的背景值;0.71 是每条的最近邻。⭐ 随机划分按条抽,相关的是后者。
- 链会折叠,隔很远的残基三维里可能贴在一起,相关性不看序列距离;而注意力对所有位置打分、不预设距离。
🛑 可以停在这里
为什么大多一残基一 token、为什么随机划分危险、注意力为什么合适 —— 这三条足够读懂下一章。 序列比对怎么做、同源泄漏虚高多少,都在后面。
⚡ 走神救援
先记住这几件事
- 生物序列虽然能写成字符,但残基位置和同源关系有不同于自然语言的意义。
- 分词方案必须保留任务需要的对应关系,不能直接照搬压缩词表的动机。
- 先按同源关系检查划分,再考虑远距离位置之间的结构关联。
下一节 👉 03-蛋白质语言模型.md