3. 序列表示基础:氨基酸编码、One-hot、词嵌入、位置编码

序列表示,说白了就是把肽段字符串变成模型能吃的数值。你想想看,Transformer 又不认识字母 'P' 或 'E',它只认矩阵和向量。所以这一步是所有后续工作的地基。地基没打好,后面模型学出来就是一团浆糊。

我在做第一个肽段匹配项目时,一开始随便用了套整数编码,心想「不就是个映射吗」。结果模型死活不收敛,loss 降不下去。后来才发现——表示方式决定了模型能看到什么信息。选错了,数据里的模式就被掩盖了。

这一节我们就把四种核心表示方法挨个讲透。每种方法都有它的适用场景和坑。

核心观点: 表示方式决定了模型的信息瓶颈。好的表示让模式自己跳出来,差的表示让数据变成噪声。
序列表示方法体系 序列表示方法体系 氨基酸序列 PEPTIDE (7残基) 氨基酸编码 单字母 / 三字母 One-hot 编码 20维稀疏向量 词嵌入 d维稠密向量 Token 表示 序列数值化 位置编码 正弦 / 可学习 Transformer 完整输入表示 Token表示 + 位置编码 逐元素相加

3.1 氨基酸编码:最朴素的标识方式

氨基酸编码是所有表示里最直接的——把每个残基映射成一个符号。常用的有单字母(A, C, D, E...)和三字母(Ala, Cys, Asp, Glu...)。

举个例子,肽段 "PEPTIDE" 用单字母写就是 P-E-P-T-I-D-E。这在代码里就是一个字符串。但模型不认识字符串,所以得先变成整数索引。

# 标准20种氨基酸的单字母编码映射
aa_list = ['A','C','D','E','F','G','H','I','K','L',
           'M','N','P','Q','R','S','T','V','W','Y']
aa_to_idx = {aa: i for i, aa in enumerate(aa_list)}

seq = "PEPTIDE"
indices = [aa_to_idx[aa] for aa in seq]
print(indices)  # [15, 4, 15, 19, 8, 3, 4]

这种编码方式我称之为「名字标签」,它只告诉模型「这是第几号氨基酸」,不携带任何结构或化学信息。我在做修饰肽段匹配时遇到过一个问题:氧化修饰的甲硫氨酸(M→Mox)在标准编码里没位置。后来我直接把它映射到第21个索引,问题才解决。

注意: 质谱数据中经常出现修饰残基(氧化、乙酰化等)。如果你的编码表只覆盖20种标准氨基酸,遇到修饰就直接崩了。建议预留4-6个额外编码位,或者单独建一个修饰映射表。

3.2 One-hot 编码:简单但粗暴

One-hot 就是把整数索引变成向量。假设有20种氨基酸,每个残基对应一个20维的向量,该维是1,其余是0。

import torch

def one_hot_encode(seq, aa_to_idx, num_aas=20):
    indices = [aa_to_idx[aa] for aa in seq]
    vec = torch.zeros(len(seq), num_aas)
    for i, idx in enumerate(indices):
        vec[i, idx] = 1.0
    return vec

seq = "PEPTIDE"
one_hot = one_hot_encode(seq, aa_to_idx)
print(one_hot.shape)  # torch.Size([7, 20])
print(one_hot[0])     # 索引15的位置是1,其余是0

One-hot 的好处是简单、确定性强。坏处也很明显——稀疏。20维还好,但你要是有1000种修饰变体,向量就膨胀到1000维了。我在早期项目里就吃过这个亏。模型参数量暴增,训练速度慢得想砸电脑。

我的建议: 如果数据中修饰种类少(≤30种),One-hot 完全够用。如果修饰种类多,或者你想让模型学到氨基酸之间的「相似性」,就换成词嵌入。别在 One-hot 上死磕。

3.3 词嵌入:让模型自己学出「氨基酸关系」

词嵌入(Embedding)是把每个氨基酸映射到一个低维稠密向量。维度通常是 64、128 或 256。这个向量不是人指定的,而是模型在训练过程中自己学出来的。

PyTorch 里实现就一行:

import torch.nn as nn

vocab_size = 20    # 20种标准氨基酸
embed_dim = 64     # 嵌入维度
embedding = nn.Embedding(vocab_size, embed_dim)

seq_indices = torch.tensor([15, 4, 15, 19, 8, 3, 4])
embeds = embedding(seq_indices)
print(embeds.shape)  # torch.Size([7, 64])

词嵌入的好处是它能捕捉「语义关系」。训练结束后,你会发现化学性质相似的氨基酸(比如疏水的 L, I, V)在嵌入空间中距离更近。我见过一个项目,用嵌入向量的余弦相似度做残基替换分析,效果比直接用 BLOSUM 矩阵还好。

关键区别: One-hot 是「硬分配」,每个氨基酸之间正交,没有相似性。词嵌入是「软分配」,相似氨基酸的向量靠得近,模型能自动利用这种结构。

3.4 位置编码:给 Transformer 装上「顺序感」

Transformer 的自注意力机制有个毛病——它不关心输入顺序。把序列从头读到尾,和从尾读到头,注意力分数一模一样。这显然不对。肽段的顺序决定了碎裂模式,P-E-P 和 P-P-E 谱图完全不同。

位置编码就是来解决这个问题的。常见的做法是用正弦和余弦函数生成固定模式的编码:

def sinusoidal_positional_encoding(seq_len, d_model):
    pe = torch.zeros(seq_len, d_model)
    for pos in range(seq_len):
        for i in range(0, d_model, 2):
            pe[pos, i] = math.sin(pos / (10000 ** (i / d_model)))
            pe[pos, i+1] = math.cos(pos / (10000 ** (i / d_model)))
    return pe

pos_enc = sinusoidal_positional_encoding(7, 64)
print(pos_enc.shape)  # torch.Size([7, 64])

这个编码是确定性的,不需要学习。它给每个位置一个唯一的「指纹」。然后把位置编码和 Token 表示逐元素相加,模型就能知道「第3个残基是 P,在第3个位置」。

我记得第一次搭 Transformer 做肽段匹配时,忘了加位置编码。模型训了50个 epoch,输出全是乱码。我当时盯着 loss 曲线,死活想不通问题在哪。后来一个同事瞟了一眼代码,说「你位置编码呢?」——一秒破案。

避坑指南: 位置编码的维度必须和 Token 表示的维度一致,不然加不了。常见错误是 Embedding 维度用 64,位置编码维度用 128,结果形状对不上。我曾经 debug 这个形状错误花了一个下午。

3.5 四种表示对比

我把四种表示方法的核心参数和适用场景整理成了表格。做工程选型时直接翻这张表就够了。

表示方法 维度 是否学习 携带信息 适用场景
氨基酸编码 1 (整数) 否 仅标识 基础映射、简单模型
One-hot 编码 V (词表大小) 否 离散标识 词表小、修饰少、快速原型
词嵌入 d (通常 64-256) 是 语义相似性 修饰多、需要泛化、Transformer
位置编码 d (与嵌入一致) 可选 顺序位置 所有 Transformer 模型
实际项目中的选择策略:
1. 数据量小于 1 万条,用 One-hot + 位置编码,简单稳定。
2. 数据量大于 5 万条,或者有大量修饰,用词嵌入 + 位置编码,让模型自己学。
3. 位置编码是必选项,不加的话 Transformer 学不出顺序信息。

3.6 代码流程串起来

最后我把整个序列表示流程串起来。从原始字符串到 Transformer 能吃的输入张量,一共四步:

def build_transformer_input(seq, embedding, pos_encoding):
    # 1. 氨基酸索引
    indices = torch.tensor([aa_to_idx[aa] for aa in seq])
    # 2. 词嵌入
    token_embeds = embedding(indices)            # (seq_len, d_model)
    # 3. 位置编码
    pos_embeds = pos_encoding[:len(seq), :]      # (seq_len, d_model)
    # 4. 相加
    input_repr = token_embeds + pos_embeds       # (seq_len, d_model)
    return input_repr

# 使用示例
embed = nn.Embedding(20, 64)
pos_enc = sinusoidal_positional_encoding(50, 64)
x = build_transformer_input("PEPTIDE", embed, pos_enc)
print(x.shape)  # torch.Size([7, 64])

这个 `input_repr` 直接喂给 Transformer 的 Encoder。序列表示阶段到此结束。

嗯,这里要注意一点:位置编码的序列长度必须大于等于所有输入序列的最大长度。我一般预留 128 或 256,避免出现超出范围的索引错误。

这一节的核心就一句话: 序列表示的终极目标是给每个残基一个「位置感知的稠密向量」。词嵌入负责语义,位置编码负责顺序,两者相加就是 Transformer 的输入。