简介:基于Python与Jupyter Notebook搭建的Transformer预训练模型抗癌药物协同作用预测项目,面向毕业设计、课程设计及项目开发人员,解决药物组合协同效应预测中的建模、训练与评估问题。资源内含完整源码、项目文档和使用说明,源码经过严格测试,可放心在其基础之上扩展使用。整个资源包共481个文件,以450个CSV数据文件为主,覆盖药物对细胞系平均分类、SMILES序列、组织类型标签等预处理数据;同时包含10个ipynb交互式分析/训练脚本、7个py工具脚本、2个pkl模型文件及说明文档,压缩包大小约132MB。内容结构清晰,从数据清洗、特征构造到Transformer模型训练与预测均有对应代码和文档支撑,便于理解整个流程。目前已有57人学习,适合具备一定Python基础并希望快速上手Transformer在生物信息学中应用的开发者。
1. 抗癌药物协同预测为什么需要 Transformer 预训练模型
药敏棋盘实验每块板上几十个浓度组合,一条条做体外筛选已经耗掉了大量实验预算,而真正有效的协同组合始终是少数。传统做法把药物编成分子指纹,再塞给随机森林或 LightGBM,这类方案在药物对级别上很快触到天花板:指纹编码的是子结构是否存在,而不是两个分子如何共同扰动同一个通路。把药物分子当作一段用 SMILES 字符串书写的语言序列,用 Transformer 预训练模型在大规模无标注分子库上先学会“化学语法”,再让药物对共享一个语义空间,预测协同与否就变成了一个二分类问题。这条路线适合正在做毕业设计或课程设计的人,也适合实验室里想用小样本数据做筛选预判的团队。下文从数据表构造讲到模型微调,再讲到如何把 Jupyter Notebook 里的实验代码整理成能交付的源码包。
2. 用 SMILES 序列构造药物对数据集:从原始表到可训练的 Pandas DataFrame
2.1 药物对数据的标准字段设计
预测模型的训练数据核心是一条“药物 A + 药物 B + 协同标签”的记录。无论数据来自公开药物组合数据库,还是自己做的棋盘实验,最终都需要先把表结构统一。字段越简单越好,避免把实验条件过早耦合进模型输入。
| 字段名 | 类型 | 示例 | 作用 |
|---|---|---|---|
| pair_id | str | 1001 | 药对唯一编号,用于分组和追溯 |
| drug_a_id | str | CID_123 | A 药标识 |
| drug_b_id | str | CID_456 | B 药标识 |
| smiles_a | str | CC(=O)Oc1ccccc1C(=O)O | A 药的 SMILES 序列 |
| smiles_b | str | CN1CCC[C@H]1c2cccnc2 | B 药的 SMILES 序列 |
| synergy_label | int | 1 | 1 为协同,0 为非协同 |
协同标签的定义在不同数据集里并不完全一致。常见标准是根据组合指数 CI 或者浓度响应曲线下面积分:CI 小于 0.8 视为协同,0.8 到 1.2 视为相加,大于 1.2 视为拮抗。做二分类时典型做法是取 CI < 0.8 作为正样本,剩下非协同组合作为负样本。这里有个隐藏问题:拮抗样本对模型同样有信息量,但二分类场景下一般把相加和拮抗合并成 0,否则样本量会被切碎。
2.2 用 RDKit 规范化 SMILES,清洗无效分子
原始数据里的 SMILES 经常存在冗余写法或非法字符。同一分子可能写成多种等价形式,直接喂给模型会让 Transformer 认为它们是不同分子。清洗用的标准工具是 RDKit,其MolToSmiles能把分子重新规范成唯一序列。
import pandas as pd from rdkit import Chem def canonical_smiles(smiles: str) -> str: if not isinstance(smiles, str) or len(smiles) == 0: return "" mol = Chem.MolFromSmiles(smiles) if mol is None: return "" return Chem.MolToSmiles(mol, canonical=True, isomericSmiles=True) df["smiles_a"] = df["smiles_a"].map(canonical_smiles) df["smiles_b"] = df["smiles_b"].map(canonical_smiles) df = df[(df["smiles_a"] != "") & (df["smiles_b"] != "")]MolFromSmiles解析不成功会返回 None,说明该 SMILES 字符串不合法或 RDKit 版本不支持该原子类型。清洗后要检查被删掉的行数,一般数据集里无效样本不应超过 5%,如果超过,需要回到原始数据处理环节看是不是编码转换出了问题。isomericSmiles=True保留手性信息,对药物活性预测有意义,代价是会稍微增加 token 序列长度。
2.3 在 Jupyter Notebook 里做标签分布和序列长度分析
数据清洗后先不急着建模,在 Jupyter Notebook 里做一轮快速 EDA,重点看两个东西:标签是否均衡,SMILES 长度分布长什么样。标签均衡度决定损失函数和评估指标,长度分布决定 Transformer 的max_length参数。
print(df["synergy_label"].value_counts(normalize=True)) len_a = df["smiles_a"].str.len() len_b = df["smiles_b"].str.len() for name, col in [("drugA", len_a), ("drugB", len_b)]: print(f"{name}: median={col.median():.0f}, " f"p95={col.quantile(0.95):.0f}, max={col.max()}") df["token_len"] = len_a + len_b df["token_len"].hist(bins=50)如果正样本占比低于 10%,训练时会倾向把所有样本预测为负类。处理办法是先不删除负样本,在训练时用加权采样或者pos_weight调整损失。SMILES 长度分位数用于设置max_length,一般取 p95 再乘 1.2,既能覆盖绝大多数分子,又不会让序列过长导致显存浪费。Transformer 的注意力复杂度是序列长度的平方,把长度从 256 压到 128,训练速度能差到 3 倍以上。
3. Transformer 预训练模型选型与分子表征抽取
3.1 为什么不能从零训练 Transformer 而必须用预训练权重
药物组合筛选数据通常只有几千到几万条有效标记样本。在这个规模上从零训练一个多层 Transformer,极容易过拟合到训练集的分子写法上,而且训练周期很长。预训练模型的价值在于:它已经在大规模无标签分子序列上完成了类似“化学语言模型”的预训练,学会了原子之间的常见组合模式。下游任务只需要在预训练基础上微调,或者干脆把输出向量当作特征使用。
面向 SMILES 序列的分子预训练模型已经有多个开源权重,常见代表如 ChemBERTa 这类架构。它们的核心做法是在分子数据库上用掩码语言建模训练,输入 SMILES,输出每个位置的上下文向量。使用方式和普通 BERT 完全一致,只是 tokenizer 不同。SMILES 的 tokenizer 必须区分大小写:C是碳原子,c是芳香碳,含义完全不同,加载时严禁设置do_lower_case=True,否则化学语义直接错乱。
3.2 用 HuggingFace 接口加载预训练 Transformer 编码器
加载预训练模型时使用 HuggingFace 的AutoModel和AutoTokenizer,不区分具体模型结构。占用显存的只是编码器部分,分类头要自己设计。以下代码可以放在项目入口模块里,避免每个 Notebook 重复加载。
import torch from transformers import AutoModel, AutoTokenizer MODEL_NAME = "your_molecule_pretrained_weights" MAX_LEN = 128 BATCH_SIZE = 32 tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) encoder = AutoModel.from_pretrained(MODEL_NAME) encoder.eval()MODEL_NAME是占位符,按实际拉取的权重名替换。encoder.eval()这一步容易漏:如果后面训练时既要抽取特征,又要微调整个编码器,需要分开处理;如果作为特征抽取器使用,必须切到 eval 模式,否则 BatchNorm 和 Dropout 会引入随机性,导致抽出来的表征不稳定。
3.3 把分子批量编码成 CLS 向量并做离线缓存
做协同预测的常见做法是让两个药物分子共享同一个编码器,分别过一遍得到各自的表征。训练阶段每次 forward 都过编码器的做法在显存上很奢侈,工程上更稳妥的方案是所有药物分子先做一次离线推理,把输出向量缓存成文件,训练时直接查表。药物分子总数通常只有几百到几千个,离线推理只要几分钟。
def encode_smiles_offline(smiles_list: list[str], save_path: str) -> torch.Tensor: all_vecs = [] for i in range(0, len(smiles_list), BATCH_SIZE): batch = tokenizer( smiles_list[i:i + BATCH_SIZE], padding=True, truncation=True, max_length=MAX_LEN, return_tensors="pt", ) with torch.no_grad(): outputs = encoder(**batch) cls_vec = outputs.last_hidden_state[:, 0, :] all_vecs.append(cls_vec.cpu()) result = torch.cat(all_vecs, dim=0) torch.save(result, save_path) return result vec_a = encode_smiles_offline(df["smiles_a"].tolist(), "data/drug_a_vec.pt") vec_b = encode_smiles_offline(df["smiles_b"].tolist(), "data/drug_b_vec.pt")padding=True会把同一批内不同长度的序列补齐到一样长,truncation=True保证超过MAX_LEN的序列被截断。取last_hidden_state[:, 0, :]是为了获得 CLS token 的最终隐状态,这个向量在预训练时聚合了整个序列的信息。缓存文件用torch.save存成 pt 格式,比每轮训练重新提取能节省大量 GPU 计算时间。要注意 SMILES 序列的顺序不影响分子本身,但影响字符位置编码,所以训练和推理时要用同一种 SMILES 写法,通常统一用 RDKit 规范化后的结果。
4. 协同预测模型训练:双塔特征融合与关键超参
4.1 双塔 Transformer 编码器加 MLP 分类头
模型中两个药物共享同一个预训练编码器,这比各自微调一套独立参数更好:药物 A 和药物 B 在语义空间里使用同一套坐标,交互信息更容易被后续融合层捕捉。编码器之上接一个前馈分类头,输入是两个 CLS 向量的拼接结果,输出协同概率。
class SynergyPredictor(torch.nn.Module): def __init__(self, hidden_size: int, dropout: float = 0.1): super().__init__() self.encoder = encoder self.classifier = torch.nn.Sequential( torch.nn.Linear(2 * hidden_size, 256), torch.nn.ReLU(), torch.nn.Dropout(dropout), torch.nn.Linear(256, 1), ) def forward(self, input_a: dict, input_b: dict) -> torch.Tensor: vec_a = self.encoder(**input_a).last_hidden_state[:, 0, :] vec_b = self.encoder(**input_b).last_hidden_state[:, 0, :] fused = torch.cat([vec_a, vec_b], dim=-1) return self.classifier(fused).squeeze(-1)hidden_size必须与预训练模型配置一致,从encoder.config.hidden_size读取,不要写死。拼接之后再接 256 维的中间层是兼顾表达力和训练稳定性的选择,再大的宽度在几千条样本上容易过拟合。Dropout 放在全连接层之后,能有效缓解小样本下的过拟合问题。如果实验发现训练损失下降但验证集 AUC 不涨,优先调整这个 Dropout 值。
4.2 选择 AdamW 优化器并调整微调学习率
预训练模型微调时,编码器和分类头的学习率建议分开设置:编码器用较小学习率,分类头用较大学习率。AdamW 是微调 Transformer 的默认优化器,配合线性学习率预热,避免训练初期梯度步长太大把预训练权重冲坏。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| base_lr | 2e-5 到 5e-5 | 预训练编码器层学习率 |
| head_lr | 1e-4 到 3e-4 | 新初始化的分类头学习率 |
| batch_size | 16 到 32 | 显存不足时优先降到 16 |
| warmup_ratio | 0.1 | 前 10% 步数线性升 lr |
| weight_decay | 0.01 | AdamW 默认常用值 |
| max_grad_norm | 1.0 | 梯度裁剪,防 NaN |
编码器如果也用 1e-4 以上的学习率,预训练学到的分子表示会在前几百步内被破坏,后期很难恢复。分类头是随机初始化的,需要相对大的学习率才能尽快收敛。梯度裁剪max_grad_norm=1.0在长序列训练中几乎必须开启,否则个别 drug pair 输入会产生异常大的梯度。
4.3 完整训练循环与早停策略
训练代码的骨架非常标准化,需要注意的关键点有两个:每个 batch 传入的是 tokenizer 处理后的字典,不能直接传原始字符串;验证集上使用 AUC 作为早停指标,而不是准确率。协同预测里正负样本不均衡,准确率几乎没有参考价值。
optimizer = torch.optim.AdamW([ {"params": model.encoder.parameters(), "lr": 2e-5}, {"params": model.classifier.parameters(), "lr": 2e-4}, ]) scheduler = torch.optim.lr_scheduler.LinearLR( optimizer, start_factor=0.1, total_iters=5 ) criterion = torch.nn.BCEWithLogitsLoss() best_auc = 0.0 patience = 3 no_improve = 0 for epoch in range(10): model.train() for batch in train_loader: input_a = {k: batch["input_a"][k] for k in ["input_ids", "attention_mask"]} input_b = {k: batch["input_b"][k] for k in ["input_ids", "attention_mask"]} labels = batch["label"].float() logits = model(input_a, input_b) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad() # 验证逻辑省略,输出验证集 AUC valid_auc = evaluate(model, valid_loader) if valid_auc > best_auc: best_auc = valid_auc torch.save(model.state_dict(), "checkpoints/best.pt") no_improve = 0 else: no_improve += 1 if no_improve >= patience: breakBCEWithLogitsLoss内部已经做了 sigmoid 计算,模型输出层不用额外激活函数,这比手动sigmoid加BCELoss在数值上更稳定。LinearLR的start_factor=0.1表示前几步先以十分之一的学习率起步,5 步后升到设定值。patience=3意味着连续 3 个 epoch 验证集没有提升就停止,这个策略能省下大量无效训练时间。
5. 评估反直觉点:随机划分会高估协同预测效果
5.1 同一药物出现在训练集和验证集是隐性数据泄漏
药物协同预测任务里有种数据泄漏比标签翻转更隐蔽:随机划分数据时,某种药物会同时出现在训练集和验证集的不同组合里。模型不一定在学习“这两个分子是否协同”,而是在记住“这个药物本身的效果强”。推理阶段出现全新的药物时,这类模型表现极其不稳定。
解决方式是使用GroupKFold,按药物对分组。分组的 key 不能是pair_id,而应该是药物标识的组合,"A_B"和"B_A"要归一化成同一个 group。
from sklearn.model_selection import GroupKFold df["pair_group"] = df.apply( lambda row: "_".join(sorted([row["drug_a_id"], row["drug_b_id"]])), axis=1, ) gkf = GroupKFold(n_splits=5) for train_idx, valid_idx in gkf.split(df, groups=df["pair_group"]): train_df = df.iloc[train_idx] valid_df = df.iloc[valid_idx]这样划分后,验证集里任意药物对都和训练集没有重合。常见的实践结果是:随机划分的 AUC 比 GroupKFold 高 5 到 10 个百分点,但前者是不可信的。如果你的项目评估结果和文献对不上,先检查是不是划分方式不同。
5.2 正样本稀少时 AUC-ROC 会掩盖问题,要同时看 PR-AUC
协同组合在一个大型筛选实验里往往只有 15% 甚至更低的比例。AUC-ROC 对类别不平衡不敏感,导致即使正样本全预测错,AUC 依然可能徘徊在 0.8 附近。PR-AUC 对假阳性更敏感,在正样本稀疏时能更真实反映模型在筛选场景里的可用性。
| 指标 | 对正样本稀疏的敏感度 | 结果解读 |
|---|---|---|
| AUC-ROC | 低 | 0.85 看起来很好,但实际找到的协同组合可能很少 |
| PR-AUC | 高 | 0.3 不代表模型没用,但能体现筛选成本的真实下降比例 |
评估代码直接调用 sklearn 的接口,同时输出两个指标,并用 Bootstrap 采样给出 95% 置信区间,防止因为验证集样本量小导致指标波动。
from sklearn.metrics import roc_auc_score, average_precision_score import numpy as np def bootstrap_auc(y_true, y_score, n_iter=1000, seed=0): rng = np.random.default_rng(seed) scores = [] for _ in range(n_iter): idx = rng.integers(0, len(y_true), size=len(y_true)) if len(np.unique(y_true[idx])) < 2: continue scores.append(roc_auc_score(y_true[idx], y_score[idx])) return np.percentile(scores, [2.5, 97.5]) print(f"AUC-ROC: {roc_auc_score(y_true, y_score):.4f}") print(f"PR-AUC: {average_precision_score(y_true, y_score):.4f}") print(f"AUC 95% CI: {bootstrap_auc(y_true, y_score)}")Bootstrap 时跳过那些采样后只剩一个类别的迭代,这是处理小样本验证集的常用技巧。置信区间如果横跨 0.5,说明验证集太小或模型不稳定,这时候先别急着调参,先检查分组泄漏和样本量。
5.3 分级结果比单一阈值更有用
协同预测的最后输出不应该只是一个 0 或 1,按概率分数分成高、中、低三档对实验筛选更有参考价值。阈值可以按验证集的 PR 曲线选择,优先保证某一段召回率,再确定逻辑回归分类概率的切分点。保存模型时连同 tokenizer 配置一起打包,避免推理环境重建时不匹配。
6. 交付落地:项目结构、使用说明和 Jupyter Notebook 常见故障
6.1 把 Notebook 里的代码整理成 src 包
毕业设计或项目交付最忌只交一个.ipynb文件。Notebook 适合做探索性分析和可视化,但训练、数据清洗、推理逻辑必须独立成模块。一个可以完整复现的标准目录结构如下:
project/ ├── data/ │ ├── raw/ # 原始药物组合数据 │ └── processed/ # 清洗后的 CSV 与向量缓存 ├── notebooks/ │ └── 01_eda.ipynb # 探索性分析 ├── src/ │ ├── data_preprocess.py # 清洗与特征构建 │ ├── model.py # SynergyPredictor 定义 │ ├── train.py # 训练入口 │ └── inference.py # 推理脚本 ├── docs/ │ └── API.md ├── requirements.txt └── README.md训练脚本要支持命令行参数,至少能通过--batch_size、--lr和--epochs覆盖 Noteboook 里的默认值。这样答辩或演示时直接用命令行跑一遍,效果更可信。
6.2 使用说明文档里必须写清的四部分
README 不需要长篇大论,但要保证新人拿到源码包后 30 分钟内能跑通。第一写明 Python 版本和依赖安装命令,包括指定 PyTorch 和 Transformers 版本。第二说明数据格式,直接放两行 CSV 示例。第三写清训练和推理命令。第四是常见错误表,比如内存不足时怎么调 batch size。
# 环境 pip install -r requirements.txt # 数据 准备 data/raw/drug_pairs.csv,字段包含 drug_a_id, drug_b_id, smiles_a, smiles_b, synergy_label # 训练 python src/train.py --epochs 10 --batch_size 16 # 推理 python src/inference.py --input data/raw/example.csv --ckpt checkpoints/best.pt6.3 Jupyter Notebook 无法运行时的排查顺序
Notebook 单元格点击执行后没有任何反应,这是最常见也最打击人的问题。先看界面右上角内核状态:显示一个实心圆说明内核正忙,可能是训练循环死循环或内存耗尽;空心圆说明 kernel 已经断连。断连时先尝试重启内核而不是重启整个 Notebook 服务,往往问题出在某个导入库把内核搞挂了。
Windows 环境还经常出现ImportError: DLL load failed while importing rpds这种报错。这个错误源于rpds-py这个 Rust 扩展的二进制依赖与当前环境不兼容,常见于 conda 和 pip 混用时。处理顺序是:先升级 pip,再强制重装rpds-py,如果还在报错,用 conda 安装替代版本。
python -m pip install --upgrade pip python -m pip install --force-reinstall rpds-py conda install -c conda-forge rpds-py先重装再切换 channel,避免在没确认问题前把整个环境重建。所有排错操作完成后,打开 Notebook 跑一遍最小冒烟测试:载入模型、编码一个 SMILES、执行一次前向传播,确认无误后再进入完整训练流程。
本文还有配套的精品资源,点击获取