☰
深度度量学习提升蛋白质二级结构预测:Triplet Loss与Embedding实战
2026/10/1 12:19:37 网站建设 项目流程

简介:项目基于Python深度度量学习技术,面向蛋白质二级结构预测任务,适用于深度学习或生物信息学方向的课程设计、期末大作业场景。代码为导师指导后通过的97分高分项目,结构完整、无需修改即可运行。压缩包共12个文件,包含5个Python源文件(模型搭建、数据集加载、训练与推理入口)、3个文本记录文件以及训练好的.pdparams参数文件等,可按目录快速理解从数据预处理到模型评估的完整流程,包体约43.75MB。目前已有129人学习下载。通过该项目可掌握度量学习在蛋白质结构预测中的实现方法与调参思路,获得一套可提交的完整程序,同时学习如何组织模型文件、参数目录与结果输出,对搭建类似深度学习项目具有良好参考价值。

1. 期末大作业选深度度量学习,不是杀鸡用牛刀:这个标题解决什么问题

把蛋白质二级结构预测当成期末大作业,最省事的做法是堆一个CNN接双向LSTM,最后softmax吐8个类别。但这个套路有个很现实的问题:PSSM特征噪声不小,DSSP标签本身又粗,直接分类会把单个残基的错误标注当成规律学进去,训练集Q3刷到90%,换到独立测试集立刻掉到70%以下。深度度量学习换了个打法——先不急着分类,而是让模型把每个残基映射成一个向量,同类残基在距离上尽量靠近、异类残基尽量拉开,最后靠距离判类别。这个思路对噪声标签更容忍,而且训练完能直接输出embedding拿去做可视化,答辩时比单纯报一个准确率数字有说服力得多。这篇笔记把整条路线拆开:数据怎么改造、triplet loss怎么调、坑在哪里、验证怎么做,照着写就能跑通一份期末大作业级别的源码。

2. 数据改造与特征选型:从PDB序列到可喂给距离函数的embedding样本

2.1 二级结构预测任务和DSSP标签粒度

蛋白质二级结构预测的输入是氨基酸序列,输出是每个残基的局部结构状态。最常用的标注体系是DSSP,它把结构状态定义成8类:α-螺旋(H)、3-10螺旋(G)、π螺旋(I)、β-折叠(E)、β-桥(B)、转角(T)、弯曲(S)、无规卷曲(C)。期末大作业有两种粒度可选:直接预测8类,或者先折叠成3类。Q3的准确率更容易刷到80%以上,答辩好看;Q8能体现更多工作量,但类别极度不均衡,翻车概率高。

如果你想把“深度度量学习”这个点讲透,我建议先跑通Q3,再在同一套框架里扩展到Q8。因为8类和3类只是标签映射变宽了,代码路径几乎不用动,训练时间多花一点而已。DSSP八态到三态的常见映射如下:

DSSP 标注三态类别含义
H, G, IH螺旋类
E, BE折叠类
T, S, CC卷曲/无规

提示:DSSP标注依赖外部工具链,如果本地装不起来,可以直接下载别人预处理好的“序列+PSSM+二级结构标签”数据文件,只要保证三个文件按残基编号一一对应就行。期末场景不需要从PDB原始坐标开始算。

数据切分有个硬规矩:必须按序列切,不能按残基随机切。同一个PDB链里的相邻残基高度同源,如果测试集里混进同一链的残基,模型等于开卷考试,Q3虚高没有参考价值。常见做法是按PDB链ID划分,训练集60%、验证集20%、测试集20%,并且训练集和测试集之间做去冗余,避免同源链跨集合。

2.2 特征不只是one-hot:PSSM、滑窗与边界padding

输入特征一般由三部分拼成。氨基酸one-hot是20维,表示当前残基是谁;PSSM是位置特异性得分矩阵,来自PSI-BLAST的多序列比对,维度也是20,值越大表示该位置出现这种残基越保守;有些实现还会追加残基的物理化学属性,但对期末作业来说,one-hot加PSSM已经够用,加多了反而容易过拟合。

PSSM的原始分值范围很大,需要先归一化。常见做法是除以16后截断到[-2,2],因为极端正负分往往是罕见突变的产物,截断能抑制噪声。另外,序列里可能出现非标准氨基酸如X、B、Z,one-hot直接给全0会丢掉信息,代码里我习惯给它们一个均匀先验0.25。

import numpy as np AA_ORDER = "ACDEFGHIKLMNPQRSTVWY" def one_hot(seq, aa_order=AA_ORDER): """将氨基酸序列转为 one-hot 矩阵,形状 [L, 20]""" mapping = {aa: idx for idx, aa in enumerate(aa_order)} arr = np.zeros((len(seq), len(aa_order)), dtype=np.float32) for i, aa in enumerate(seq): if aa in mapping: arr[i, mapping[aa]] = 1.0 else: arr[i, :] = 0.25 # 非标准残基给均匀先验,避免整行为零 return arr def normalize_pssm(pssm, scale=16.0, cap=2.0): """PSSM 归一化:先除以 scale,再截断到 [-cap, cap]""" pssm = np.clip(pssm / scale, -cap, cap) return pssm.astype(np.float32) def build_feature_matrix(seq, pssm, window=7): """把序列与 PSSM 对齐后,按窗口滑出局部特征块 返回形状 [L, window, 40],40 = 20 one-hot + 20 pssm """ if pssm is None or pssm.shape[0] != len(seq): pssm = np.zeros((len(seq), 20), dtype=np.float32) else: pssm = normalize_pssm(pssm) one = one_hot(seq) feat = np.concatenate([one, pssm], axis=-1) # [L, 40] L = len(seq) half = window // 2 # edge padding:用首尾向量补齐,而不是补零 padded = np.vstack([feat[:1]] * half + [feat] + [feat[-1:]] * half) out = np.stack([padded[i:i + window] for i in range(L)], axis=0) return out.astype(np.float32)

这里的逻辑说明分三块。第一,one_hot里非标准氨基酸给0.25而不是0,原因是如果窗口中间某个位置全零,卷积层会把它当成“空位”特征,模型会学到虚假的间隔信息。第二,window取奇数,代码里默认7,也就是左右各3个残基的上下文。窗口太短学不到β-折叠这类长程相互作用,太长则特征维度膨胀且训练变慢,7是期末作业里性价比比较高的值。第三,末尾残基用了edge padding而不是zeros padding,这是很多人不提的坑——补零等于告诉卷积层“序列外面是空的”,但蛋白质序列两端就是真实残基,补零会让模型在序列两端系统性误判。

2.3 Triplet样本生成:度量学习的数据组织方式

普通分类任务的数据集是“样本-标签”对,度量学习需要的是三元组:锚点、正样本、负样本。锚点是一个残基,正样本是与它同类别的另一个残基,负样本是不同类别的残基。模型要学的是让锚点与正样本距离近、与负样本距离远。

三元组的采样质量直接决定训练效果。如果你完全随机采样,会出现两个问题:第一,负样本太简单,模型随便学两下就能拉开距离,loss很早降到接近0,但embedding根本没有区分度;第二,正样本如果取同一序列里的相邻残基,模型会走捷径——它只需要记住“位置相邻的残基结构相似”,而不需要真正理解序列上下文。解决方法是把正样本池限定为“同类别但不同序列”的位置。

import numpy as np def build_triplet_indices(file_path, batch_size=128): """返回一个无限生成器:每个 batch 输出 (anchor_idx, pos_idx, neg_idx) 数据文件是 npz 格式,其中 label 形状为 [N, L], N 是序列条数,L 是每条序列长度,可直接按位置下标访问。 """ data = np.load(file_path) labels = data["label"] # 把所有序列的标签拼成一条长向量,同时记录每个位置属于哪条序列 long_label, seq_of_pos = [], [] for s_idx, seq_label in enumerate(labels): long_label.extend(seq_label) seq_of_pos.extend([s_idx] * len(seq_label)) long_label = np.asarray(long_label) seq_of_pos = np.asarray(seq_of_pos) # 按类别分组,方便后续采样 class_ids = np.unique(long_label) pos_candidates = {c: np.where(long_label == c)[0] for c in class_ids} while True: anchors = np.random.randint(0, len(long_label), size=batch_size) pos, neg = [], [] for a in anchors: # 正样本:同类,但排除同一条序列的位置 pool = pos_candidates[long_label[a]] pool = pool[seq_of_pos[pool] != seq_of_pos[a]] if len(pool) == 0: pos.append(a) # 退化情况:这条序列是唯一的同类来源 else: pos.append(np.random.choice(pool)) # 负样本:随机抽一个不同类别 neg_class = np.random.choice(class_ids[class_ids != long_label[a]]) neg.append(np.random.choice(pos_candidates[neg_class])) yield anchors, np.array(pos), np.array(neg)

这个函数把数据集压平成一个长向量,然后用seq_of_pos记录每个位置来源的序列编号。正样本池里做一层过滤,把同序列的位置全部剔除,剩下的才是结构上相似、序列位置上不相干的真样本。负样本采样这里用了随机负类,训练稳定、实现简单,适合期末作业。如果后续想提升上限,可以在batch内部改成hard negative mining——把当前batch里距离锚点最近的负样本挑出来参与loss计算,这个进阶版本第3节末尾会提到。

为什么要费劲组织三元组而不是直接分类?因为PSSM特征里同一结构类别的样本往往在空间中聚成团,但类间边界不整齐,softmax强行拉一条线性边界很容易过拟合噪声。度量学习只约束相对距离,类边界是隐式的,容错能力更强。这也是这个标题里“深度度量学习”和“准确预测”之间的真正联系。

3. 核心实现:PyTorch编码器、triplet loss与训练循环

3.1 Encoder结构:卷积堆特征、可选双向下文、映射层

编码器的作用是接收一个残基的局部特征块[window, 40],输出一个低维向量[embed_dim]。结构上不用做得太复杂,两层1D卷积加一个全连接就够。第一层卷积负责提取局部的氨基酸模式,第二层卷积把感受野扩大到5到7个残基,正好覆盖α-螺旋的一个周期的核心。卷积之后用AdaptiveAvgPool把长度维度压成1,这样窗口大小变化也不影响后续全连接层。

import torch import torch.nn as nn class Encoder(nn.Module): def __init__(self, window=7, in_ch=40, embed_dim=64): super().__init__() self.feature = nn.Sequential( nn.Conv1d(in_ch, 128, kernel_size=3, padding=1), nn.BatchNorm1d(128), nn.ReLU(), nn.Conv1d(128, 256, kernel_size=3, padding=1), nn.BatchNorm1d(256), nn.ReLU(), nn.AdaptiveAvgPool1d(1) ) self.fc = nn.Linear(256, embed_dim) self.ln = nn.LayerNorm(embed_dim) def forward(self, x): # x: [B, window, in_ch] x = x.transpose(1, 2) # 转成 [B, in_ch, window] 供 Conv1d h = self.feature(x).squeeze(-1) # [B, 256] return self.ln(self.fc(h)) # [B, embed_dim]

embed_dim是这里最重要的超参数。64维对三态任务足够用,对八态任务建议提高到96。维度过小,不同类别在空间里挤成一团;维度过大,在小数据集上容易过拟合,测试集embedding分布会碎。LayerNorm放在最后一步是有意的:triplet loss训练时模型很容易通过缩小整体向量长度来投机取巧,LayerNorm把向量重新归一化,让loss被迫去优化方向而不是优化长度。

如果你的序列长度比较长,想要利用上下游的上下文信息,可以在AdaptiveAvgPool1d之前插入一层双向GRU。常见做法是nn.GRU(256, 128, batch_first=True, bidirectional=True),把双向输出的均值作为下一步入fc的特征。但对期末作业来说,纯卷积版本已经能跑出不错的结果,GRU版本留给有余力的同学做对比实验。

3.2 TripletLoss的margin、温度与梯度技巧

triplet loss的表达式是max(0, margin + d(pos) - d(neg))。d(pos)是锚点与正样本的欧氏距离,d(neg)是锚点与负样本的欧氏距离。loss要惩罚的是“负样本比正样本更近”的情况,如果正负距离差已经超过margin,这一项就是0。

margin设为多少,取决于类别数和embedding空间的大小。三态任务里类别之间边界清晰,margin取0.5比较稳;八态任务类别多、类内方差大,margin取0.8到1.0才能把各类推开。margin过小会出现loss一直为0但准确率不上不下的情况,因为模型满足于“勉强分开”而不是“清晰分开”。

import torch import torch.nn as nn class TripletLoss(nn.Module): def __init__(self, margin=0.5, temperature=1.0): super().__init__() self.margin = margin self.temperature = temperature # 欧氏距离版本暂时传1.0 def forward(self, a, p, n): # 锚点与正样本/负样本的欧氏距离 d_pos = torch.norm(a - p, dim=1) d_neg = torch.norm(a - n, dim=1) # 带 margin 的 hinge 形式 loss = torch.clamp(self.margin + d_pos - d_neg, min=0) return loss.mean()

这里需要注意两点。第一,代码里的temperature参数在欧氏距离版本里没有直接参与计算,它是给cosine相似度版本预留的。如果你把距离换成1 - cosine_similarity(a, p),那么距离差会被压缩到有限区间,这时需要用温度系数放大近邻样本之间的梯度,否则loss在0到2之间波动很难收敛。第二,如果训练过程中频繁出现大半batch的loss为0,说明负样本太简单,需要引入hard negative mining,而不是去调margin。

hard negative mining的常见做法是在batch内部实现:算出所有锚点与所有负样本的距离矩阵,对每个锚点取距离最近的负样本参与loss计算。它的作用是逼模型去处理最难区分的边界样本,代价是每步多算一次距离矩阵,训练时间大约多20%。期末作业如果追求效果上限,可以在最后5个epoch开启;如果只求跑通,随机负样本就够了。

3.3 训练循环与超参速查

训练循环本身不复杂,但有三个容易出问题的点:梯度裁剪、学习率调度、embedding缓存。梯度裁剪是为了防止循环层或深卷积梯度爆炸;学习率调度采用warmup加cosine的常见组合;embedding缓存是为了验证时不重复前向计算。

import torch from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR def train_one_epoch(model, loss_fn, loader, optimizer, scheduler): model.train() total_loss, total_num = 0.0, 0 for anchor, pos, neg in loader: a = model(anchor) p = model(pos) n = model(neg) loss = loss_fn(a, p, n) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss += loss.item() * len(a) total_num += len(a) scheduler.step() return total_loss / total_num def train(model, train_loader, epochs=25): loss_fn = TripletLoss(margin=0.5) optimizer = AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=epochs) for epoch in range(epochs): avg_loss = train_one_epoch( model, loss_fn, train_loader, optimizer, scheduler ) print(f"epoch={epoch} avg_loss={avg_loss:.4f}")

常用的超参数组合整理成一张表,直接照抄就能复现一个像样的结果:

参数Q3 三态任务Q8 八态任务
embed_dim6496
margin0.50.8
batch_size128128
window77
lr1e-38e-4
epochs20~2530~40
optimizerAdamWAdamW
schedulerwarmup 3轮 + cosinewarmup 3轮 + cosine

如果训练过程中loss下降很慢,先调整lr而不是margin;如果loss掉到0.01以下但验证准确率没有提升,问题几乎都出在样本采样上,检查正样本池里是否有同源序列泄漏。

4. 避坑指南:把准确率骗高的5个隐藏问题与排查

4.1 按残基乱切数据:Q3虚高到90%,一换数据集就露馅

现象:模型在测试集上Q3达到91%,你觉得已经大功告成,结果把同一份源码换到CB513这类独立测试集上,准确率直接掉到63%,完全没法看。

原因:你大概率是按残基随机划分训练集和测试集。同一个PDB链里相邻残基的二级结构高度相关,随机切分等于把同一个链的上下文片段同时放进了训练集和测试集,模型学到的不是结构规律,而是对特定序列片段的记忆。测试集不是真正的未知数据。

解决:按序列编号(chain id)切分,同一链的所有残基必须在同一个集合里。更进一步,训练集和测试集之间要做序列去冗余,一般控制序列一致性在25%到30%以下。去冗余不是期末作业必须做的,但如果拿到的数据来自PDB,不做去冗余,结果会虚高十几个点。

4.2 Embedding坍缩:loss降得很顺利,准确率纹丝不动

现象:训练前5个epoch loss从1.2降到0.3,你觉得稳了,结果验证集Q3一直停在55%左右,跟随机猜测差不多。把embedding画出来一看,所有点都挤在原点附近,像一个大圆饼。

原因:triplet loss的优化目标是缩小正样本距离、拉开负样本距离。模型发现最简单的实现方式不是“把同类聚在一起”,而是把所有向量的长度都缩到接近0——这样任何两个向量之间的距离都变小,loss也变小,但类别完全没分开。这是度量学习里最常见的一种翻车方式。

解决:在fc输出后加LayerNorm,把向量归一化到固定长度,距离主要由方向决定,长度投机失效。同时可以打印一下embedding的范数分布,如果标准差小于0.1,基本可以断定坍缩了。最后把margin调大一点,比如Q3从0.5调到0.8,也能挤出一部分区分度。

4.3 类别不均衡:coil一家独大,模型直接摆烂

现象:Q8任务的准确率只有34%,一看confusion matrix,模型几乎是只输出coil这一个类。coil类在数据里占比接近40%,无脑全猜coil都有34%的准确率,训练过程完全没有学习其他类别。

原因:DSSP八态里,C、T、H三类占了大多数,B和I这类结构占比极小。triplet loss的负样本如果均匀随机采样,少量出现的类别被抽中的概率很低,模型没有动力去区分它们。

解决:两个方向。第一,采样时给低频类别提高负样本权重,例如计算每个类的出现频率,用频率的倒数作为采样权重。第二,使用平衡采样器,限制高频类在每个epoch里的样本上限。注意不能把平衡做得太狠,否则原本占比就大的coil类precision会下降,整体Q3反而受损。期末作业里建议先跑通原始采样,再用平衡采样做对比实验,答辩时这本身就是一个加分点。

4.4 PSSM与DSSP对齐错位:一条gap毁掉一个epoch

现象:训练loss异常低,第一轮就掉到0.2以下,你怀疑是数据泄漏。检查数据之后发现,序列第10号残基到第13号残基之间缺失了两个残基,PSSM是按顺序拼接的,从第13号开始,PSSM的每一行都和真实的氨基酸对不上了。

原因:PDB文件里的残基编号不是连续的,蛋白质序列常常有缺失区域。如果预处理时直接用数组顺序拼接,而不核对残基编号,一个gap就会让后面几十个残基的特征错位。特征错了模型照样能收敛,因为它学到的规则是建立在错误对照上的,只是完全没有泛化能力。

解决:在构造训练样本之前,对序列、PSSM、DSSP三个数组按“链ID+残基编号”做严格对齐,确保每一行对应同一个残基。凡是编号对不上的位置直接过滤,不要尝试补齐。在代码里加一个长度校验,三个数组长度不一致时直接报错退出,不闷头训练。

4.5 边界padding不当:序列两端系统性降点

现象:模型的Q3整体有80%,但把每条序列按位置分段统计准确率后发现,序列开头前10个残基只有60%,尾部最后10个残基也差不多。

原因:滑窗特征在序列边缘会越界,很多实现直接补零。卷积层看到的是“真实残基+一堆0”的组合,它会认为0是一种有意义的特征。而实际情况里,序列边界就是真实结构,补零等于凭空给两端增加了不存在的上下文信息,模型自然不会学对。

解决:把2.2节代码里的zeros padding改成edge padding,用序列首尾的残基向量重复补齐。更保守的做法是训练时直接丢弃长度不足window的短序列,验证时同样处理。这个坑排查起来最隐蔽,因为整体准确率不会崩,只会稳定地低上几个点,很多人会误以为是模型容量不够。

提示:中间特征建议在第一次预处理后直接缓存成npz文件,每个样本一个特征块。如果不缓存,每个epoch都要重新滑窗拼接,数据量大时白白浪费几十分钟。

5. Q3验证与t-SNE可视化:让embedding自己说清楚学到了什么

训练结束后,先不要急着算准确率,把测试集的embedding提取出来存成一个numpy数组。然后做两件事:算类中心判定Q3,画t-SNE散点图。这两件事合在一起,能同时回答“模型准不准”和“模型学到了什么”两个问题,正好对应期末大作业答辩时评委最爱问的两个角度。

类中心判定的逻辑很简单:对训练集的embedding按类别求平均,得到每个类别在embedding空间里的代表点;测试时计算每个样本到各类中心的距离,取最近的那个类作为预测结果。这里有一个小优化,在验证集上搜索一个阈值:如果样本到最近类中心的距离仍然大于阈值,说明它属于一个不明确的边界区域,直接归属到占比最大的C类。阈值搜索范围一般取[0.2, 1.2],步长0.05,在验证集上跑一遍选最优值。搜索完成后用测试集报告Q3。

t-SNE可视化这一小段代码可以直接放进源码里,输出一张散点图:

from sklearn.manifold import TSNE import matplotlib.pyplot as plt def plot_embedding(test_embedding, test_label, save_path="embedding_tsne.png"): """把测试集 embedding 降到 2 维,按真实标签着色""" emb2d = TSNE(n_components=2, perplexity=30, random_state=0).fit_transform( test_embedding ) for c in np.unique(test_label): mask = test_label == c plt.scatter(emb2d[mask, 0], emb2d[mask, 1], s=6, label=f"class_{c}") plt.legend() plt.savefig(save_path, dpi=300)

perplexity默认30,样本量小于2000时建议降到15到20,否则局部邻域计算不稳定。从图上你能直接判断模型是否真的把同类残基聚成了团——如果八个颜色的点交错在一起,说明embedding维度或margin还需要调整;如果颜色块边界清晰,Q3也不会差到哪里去。

我的习惯是先把可视化跑出来,再回头调参数。可以看到,各种调参、找阈值、t-SNE的分布式判断,其实都是在用可视化来验证模型有没有真的理解结构特征。数据预处理多花两小时,训练时能少调好几天参数,这条经验在蛋白质相关的任务里尤其适用。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询