ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Transformer的药物协同作用预测:从SMILES编码到模型训练实战

基于Transformer的药物协同作用预测:从SMILES编码到模型训练实战 简介面向计算机、生物信息等专业的毕业设计或课程设计开发者提供基于PythonJupyter Notebook、采用Transformer预训练模型进行抗癌药物协同作用预测的完整项目资源。资源包共481个文件压缩后约132.17MB核心包括10个ipynb交互分析脚本、7个py源码、2个pkl模型以及450个csv药物组合实验数据另附md项目文档与txt使用说明。数据覆盖A2058、COLO320DM、PA1等多种癌细胞系的药物组合测试含SMILES序列、组织类别、标签等字段便于模型训练与扩展。已有57人学习参考源码经严格测试可直接作为蓝本二次开发适合课题研究与实践积累。1. 先拆这个项目在解决什么药物联用的筛选成本问题药物联用是临床对抗耐药的主流手段可两两组合的探索空间动辄几十万到上百万对。靠湿实验穷举不现实——一对组合从铺板到读数几周时间就搭进去了。计算筛选的意义就在这里先用模型筛掉大概率无效的组合把实验资源留给少数真正值得验证的候选。这套方案链路很清晰Python 做数据工程和训练脚本Jupyter Notebook 做交互式开发与实验记录Transformer 预训练模型做分子表征与协同打分源码和文档成套交付正好覆盖毕业设计或课程设计的完整闭环。它不手工设计分子描述符而是让模型在 SMILES 序列上自学化学语义再用双塔结构建模两种药物的交互。适合生物信息方向学生也适合想快速验证 AI 制药路线的算法工程师。2. Transformer 预训练模型凭什么胜任分子表征与协同交互原理2.1 协同作用的量化基础Loewe 评分到底在预测什么两种药放在一起效果大于单药相加就是协同小于则是拮抗。药理学家用 Loewe 模型定义“相加”的基线组合指数 CI dA/DA dB/DBDA、DB 表示两种药单独达到同一效应水平所需的等效剂量。CI 小于 1说明用更小剂量就能达到同等效果是协同大于 1 则是拮抗。所以“预测协同作用”这件事本质是输入一对分子的结构信息输出一个连续评分描述这对组合在给定生物背景下的非加性效应。公开的 DrugComb、NCI-ALMANAC 数据集就提供这样的分值通常把 Loewe 评分映射成 synergy 1 - CI正值协同、负值拮抗回归目标分布也更对称居中。要说明的是协同不是单一分子性质决定的而是两药效应的交互结果。模型必须有能力把两个分子的表示放在一起比较和融合而不能只是分别打分的并联结构。这决定了实现上需要双塔加融合头也决定了模型的评估必须特别关注“新药对”的泛化——训练集见过的分子组合再测一遍意义不大。2.2 预训练-微调范式把 SMILES 当作分子语言分子结构可以用 SMILES 字符串线性表示乙醇写作 CCO。和自然语言一样SMILES 有语法环闭合要配对、括号要成对还带电荷和立体化学标记。但与普通文本不同SMILES 的上下文极其紧凑一个原子受到远端官能团的电子效应影响很常见这恰好是 self-attention 擅长捕捉的长程依赖。Transformer 预训练模型的思路是先在大规模无标注分子库上做掩码语言建模让编码器学会化学语料的基本规律再在协同作用数据上微调只学从化学语义到协同分数的映射。这和 NLP 里用 RoBERTa 中文预训练模型做下游任务的套路完全同构——迁移的不是任务而是对分子语言的通用理解。分子领域有现成权重最常见的做法是直接加载 ChemBERTa它就是在 Zinc 分子库上用 BERT/RoBERTa 架构预训练出来的 SMILES 编码器。说到 Transformer 架构及其工作原理和药物预测关系最密切的是位置信息计算SMILES 中 token 的顺序有明确物理意义——“CCO”和“COC”是两个不同的分子。好在预训练权重已经包含位置编码参数加载后直接可用不需要像某些任务那样重新训练位置嵌入。如果你的需求是手写一个 Transformer 来加深理解工程上也可以但本项目里没必要重复造轮子Hugging Face 封装好的 AutoModel 在正确率和效率上都更可靠。2.3 双塔编码器共享权重与显式交互的必要性我采用的模型结构是双塔药物 A 和药物 B 分别过同一个预训练编码器各自取[CLS]位置的输出向量作为分子表征再拼接、逐元素乘、逐元素差合成一条特征向量最后接 MLP 输出协同分数。共享权重是实现对称性的关键。如果 A、B 用两套不同权重模型会学习到“药物排第一位还是第二位产生了不同表征”但联合用药 (A, B) 和 (B, A) 在物理上完全相同这个假设没有任何依据。共享权重让双塔天然满足互换不变性同时参数总量减半收敛速度也更快。拼接之外out_a * out_b建模的是两个分子在表征空间各维度的协同放大或抑制out_a - out_b则保留方向性差异。这两个特征配合拼接比单纯拼四个特征更能让最后一层 MLP 感知交互。如果数据量充足还可以在双塔输出后加一层交叉注意力让两个分子先相互“关注”再决策但序列长度翻倍带来的显存开销不小课程设计场景下拼接融合已经够用交叉注意力更适合数据规模充足的进阶实验。3. 跑通最小可用链路环境搭建、数据切分与 SMILES 预处理3.1 搭建开发环境conda 创建 Python 3.9 虚拟环境并安装依赖包第一步是创建独立 Conda 环境。项目依赖了 PyTorch、Transformers、RDKit、Jupyter 等一堆包版本相互咬合直接装进 base 环境容易把系统 Python 搞坏是实践中反复翻车的头号原因。独立环境的好处是删了重建都不心疼。conda create -n drug_transformer python3.9 -y conda activate drug_transformer pip install torch --index-url https://download.pytorch.org/whl/cu118 pip install transformers[torch] pandas numpy scikit-learn rdkit-pypi jupyterlab matplotlib seaborn安装完成后在终端输入jupyter lab浏览器会自动打开 Notebook 界面。PyTorch 的安装命令里--index-url指定了 CUDA 11.8 版本如果机器没有 NVIDIA 显卡直接删掉这个参数装 CPU 版即可代码逻辑完全不受影响。RDKit 用rdkit-pypi这个 PyPI 包名实际导入时仍是import rdkit这一点容易让人在安装后找不到模块实际没有问题。版本上 Python 选 3.9 是稳妥选择PyTorch、Transformers 和 RDKit 三者在 3.9 下兼容性最好没必要追求最新版 Python。3.2 数据字段清洗从 DrugComb 导出的 CSV 到 RDKit 验证的合法样本拿到原始数据后先别急着训练在 Notebook 里做一次探索性分析查看列名、空值比例、协同分数分布。原始 CSV 中通常包含药物 A 的 SMILES、药物 B 的 SMILES、细胞系名称、协同分数等字段偶尔会有缺失行、无法解析的 SMILES 或药物对重复出现的情况。import pandas as pd from rdkit import Chem df pd.read_csv(drug_combinations.csv) print(df.columns.tolist()) print(df.isna().sum()) def canonicalize(smiles: str): mol Chem.MolFromSmiles(smiles) if mol is None: return None return Chem.MolToSmiles(mol) # 统一为规范表示 df[smiles_a_canon] df[smiles_a].apply(canonicalize) df[smiles_b_canon] df[smiles_b].apply(canonicalize) df df.dropna(subset[smiles_a_canon, smiles_b_canon, synergy_score]) df df[df[smiles_a_canon] ! df[smiles_b_canon]].copy() print(df[synergy_score].describe())这段代码的逻辑有两个关键点。第一Chem.MolFromSmiles返回None说明 RDKit 无法解析该字符串可能是数据错误可能是格式不规范直接丢弃比强行补救更稳妥第二MolToSmiles把等价写法统一成一种标准形式。同一个分子在数据集中可能被写成几种不同但等价的 SMILES如果不统一训练集和验证集中同一个分子会被模型当成两个不同的序列浪费信息的同时还会造成隐性的评估偏差。协同分数一列要特别注意量纲。不同公开数据集的分数范围差异很大有的在 [-1, 1]有的在 [-40, 40]。训练前可以把这列做一次标准差标准化或者至少记下均值和标准差方便预测后反标准化还原分数。我习惯在数据预处理阶段就把这些统计值存成一个字典后面测试时直接调用不用重复计算。3.3 SMILES 转 token 序列max_length、attention mask 与 padding 策略SMILES 进入模型前需要被 tokenizer 切成 token 并转成 ID。ChemBERTa 自带处理 SMILES 的 tokenizer不需要自己造词表。需要明确的参数是max_lengthSMILES 序列通常 40 到 130 个字符设 128 可以覆盖大多数样本设 256 更保险代价是显存占用和训练速度。from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(seyonec/ChemBERTa-zinc-base-v1) def encode_pair(smiles_a, smiles_b): enc_a tokenizer(smiles_a, max_length128, paddingmax_length, truncationTrue) enc_b tokenizer(smiles_b, max_length128, paddingmax_length, truncationTrue) return { input_ids_a: enc_a[input_ids], attention_mask_a: enc_a[attention_mask], input_ids_b: enc_b[input_ids], attention_mask_b: enc_b[attention_mask], }attention_mask标注了哪些位置是真实 token哪些是 padding。模型做注意力计算时会忽略 padding 位置如果不传这个 maskpadding 位会以无效 token 的身份参与计算引入明显噪声。之前看到不少人从 NLP 迁移过来时在这里踩坑——分词结果没问题但忘记传 mask指标一直上不去。一个值得养成的检查习惯训练前随机抽三条 SMILES打印input_ids的长度分布和 tokenizer 的输出结果确认不是一堆全 pad 的空序列。也可以用attention_mask.sum(dim-1)统计每条序列的有效 token 数如果大量序列超过 max_length 的 90%说明 128 不够用需要把长度上限提到 256。3.4 数据切分按分子而不是按行避免药物种子泄漏数据切分是这个项目里最容易出错、也最影响论文结论的一步。如果直接把数据按行随机划分训练集和验证集同一分子很可能同时出现在两边。Transformer 学习到的是“这个分子的表征”而不是“这个药物对的协同效应”最终验证集指标虚高换一批新药立刻现原形。正确做法是先提取所有唯一的规范 SMILES把这些分子切成训练分子集和验证分子集再按分子归属划分数据行。unique_smiles sorted(set(df[smiles_a_canon]) | set(df[smiles_b_canon])) print(unique molecules:, len(unique_smiles)) from sklearn.model_selection import train_test_split train_mols, val_mols train_test_split(unique_smiles, test_size0.2, random_state42) def assign_split(row): if row[smiles_a_canon] in train_mols and row[smiles_b_canon] in train_mols: return train elif row[smiles_a_canon] in val_mols and row[smiles_b_canon] in val_mols: return val return discard df[split] df.apply(assign_split, axis1) train_df df[df[split] train] val_df df[df[split] val] print(train_df.shape, val_df.shape)这里的discard行是训练分子出现在验证集、或验证分子出现在训练集的中间地带样本不能参与训练。这种切分模拟的是“给两种没见过的药物预测协同”的冷启动场景。如果 discard 比例太高说明数据集中药物网络连接紧密可以按 Murcko 骨架分组后再切分——把分子归到骨架层面随机分配骨架而不是分配分子严格程度进一步提高。4. 模型实现与训练预训练权重加载、损失函数与五折评估4.1 双塔 Transformer 编码器代码骨架直接从 Hugging Face 加载预训练权重工程实现上最稳妥的方式是写一个普通的nn.Module内部持有AutoModel作为编码器两个药物共享同一份权重。维度从config.hidden_size读取这样以后换 ChemBERTa 之外的任何 Transformer 变体只要改model_name就能直接跑。import torch import torch.nn as nn from transformers import AutoModel, AutoConfig class SynergyPredictor(nn.Module): def __init__(self, model_nameseyonec/ChemBERTa-zinc-base-v1, hidden_dim256): super().__init__() self.config AutoConfig.from_pretrained(model_name) self.encoder AutoModel.from_pretrained(model_name, configself.config) hidden_size self.config.hidden_size # 从配置读取不写死维度 self.fusion_head nn.Sequential( nn.Linear(hidden_size * 4, hidden_dim), nn.ReLU(), nn.Dropout(0.3), nn.Linear(hidden_dim, 1), ) def forward(self, input_ids_a, attention_mask_a, input_ids_b, attention_mask_b): out_a self.encoder( input_ids_a, attention_maskattention_mask_a ).last_hidden_state[:, 0, :] out_b self.encoder( input_ids_b, attention_maskattention_mask_b ).last_hidden_state[:, 0, :] features torch.cat( [out_a, out_b, out_a * out_b, out_a - out_b], dim-1 ) return self.fusion_head(features).squeeze(-1)代码中[:, 0, :]取的是序列第一个 token[CLS]位的输出向量BERT 家族把这个位置当作整条序列的语义摘要分子编码器同样适用。四个特征的融合里out_a * out_b建模对应维度的协同放大或抑制out_a - out_b提供方向性差异这两项配合拼接是药物对任务中经过验证的有效组合。共享权重这一点要特别说明self.encoder是同一个实例前向计算两次不会产生第二套参数梯度会从 A、B 两条路径同时回传到同一套权重。这保证了对调顺序后模型输出完全一致符合药物对组合的物理对称性。4.2 训练循环动态 padding、单周期学习率与梯度裁剪训练管线中比较关键的细节在 DataLoader 的collate_fn。固定max_length128省心但序列长度差异大时浪费计算资源动态 padding 让每个 batch 只按当前 batch 内最长序列补齐通常能省下 30% 左右的冗余长度。from torch.utils.data import Dataset, DataLoader class DrugPairDataset(Dataset): def __init__(self, df, tokenizer, max_length128): self.df df.reset_index(dropTrue) self.tok tokenizer self.max_length max_length def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] enc_a self.tok(row[smiles_a_canon], truncationTrue, max_lengthself.max_length) enc_b self.tok(row[smiles_b_canon], truncationTrue, max_lengthself.max_length) return { input_ids_a: torch.tensor(enc_a[input_ids]), attention_mask_a: torch.tensor(enc_a[attention_mask]), input_ids_b: torch.tensor(enc_b[input_ids]), attention_mask_b: torch.tensor(enc_b[attention_mask]), label: torch.tensor(row[synergy_score], dtypetorch.float32), } def collate_fn(batch): def pad_tensor(seqs): pad_id tokenizer.pad_token_id max_len max(s.size(0) for s in seqs) padded torch.full((len(seqs), max_len), pad_id, dtypetorch.long) for i, s in enumerate(seqs): padded[i, :s.size(0)] s mask (padded ! pad_id).long() return padded, mask ids_a, mask_a pad_tensor([x[input_ids_a] for x in batch]) ids_b, mask_b pad_tensor([x[input_ids_b] for x in batch]) labels torch.stack([x[label] for x in batch]) return ids_a, mask_a, ids_b, mask_b, labels train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, collate_fncollate_fn)pad_id从tokenizer.pad_token_id读取不同模型可能不同写死数字容易埋雷。mask直接从“真实 token 是否等于 pad id”推导等于把 padding 位置彻底排除在注意力之外。batch size 设为 32 时12G 显存的显卡配合 128 长度基本可以跑动如果序列长度上调到 256建议降到 16 或 8。训练循环采用AdamW优化器搭配单周期学习率调度。预训练模型微调场景下max_lr2e-5是一个稳定又不至于过慢的起点。还要做梯度裁剪防止个别 batch 的极端样本把参数更新推偏。optimizer torch.optim.AdamW(model.parameters(), lr2e-5, weight_decay1e-4) total_steps len(train_loader) * epochs scheduler torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr2e-5, total_stepstotal_steps, pct_start0.1 ) criterion nn.MSELoss() for epoch in range(epochs): model.train() total_loss 0.0 for ids_a, mask_a, ids_b, mask_b, labels in train_loader: optimizer.zero_grad() preds model(ids_a, mask_a, ids_b, mask_b) loss criterion(preds, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() total_loss loss.item() print(fepoch {epoch}: loss{total_loss / len(train_loader):.4f})pct_start0.1表示前 10% 的训练步数做 warmup学习率从接近 0 升到max_lr之后余弦退火缓慢降至 0。warmup 对预训练模型微调至关重要直接使用固定学习率容易在初期造成 loss 震荡。如果是从零训练而非微调学习率可以放宽到1e-4量级。4.3 五折交叉验证与多指标评估不要只盯着单一 fold 的 loss课程设计或论文里单一训练集验证集划分的说服力远远不够。我习惯在完整数据上做五折交叉验证每一折都用 4.1 节相同的模型结构重新训练最后报告五折的平均 Pearson 相关系数和 RMSE。如果平均相关系数能超过 0.7这个方案在当前数据集上大概率有实际价值。from sklearn.model_selection import KFold kf KFold(n_splits5, shuffleTrue, random_state42) for fold, (train_idx, val_idx) in enumerate(kf.split(unique_smiles)): # 按分子索引划分样本重新构建 train_df / val_df # 每个 fold 内执行训练保存模型权重和验证集预测结果 pass # 具体训练逻辑与上一节相同注意这里KFold切分的是unique_smiles而不是数据行本质上还是分子级切分只是把训练集进一步拆成五份。每一折结束保存预测值和真实值最后统一算指标能更真实地反映模型对没见过分子的泛化能力。评估指标建议同时看三个Pearson 相关系数衡量线性相关性RMSE 衡量绝对误差top-k 命中率衡量业务价值——预测分数最高的 50 个组合里真实协同样本占多少。只看 loss 容易被“回归到均值”的假象欺骗这一点在下一章的避坑部分具体展开。5. 避坑手册让模型翻车的五个隐性因素5.1 验证集指标虚高换新药直接现原形随机切分造成的数据泄漏现象训练集和验证集的 Pearson 相关系数高达 0.9但把模型拿到外部数据集上预测效果和随机猜测差不多。检查验证集样本发现其中大量分子在训练集中已经出现过。原因直接按行调用train_test_split切分数据同一个分子在不同行中被分到训练集和验证集两侧。模型真正学到的是对已知分子结构的记忆和泛化而不是对未知药物对协同效应的推理能力。Transformer 表征能力越强这种“背题”现象越隐蔽因为它在训练集上拟合得很好验证集上看起来也不差。解决切分对象必须是分子而不是数据行。先把所有唯一 SMILES 收集起来在分子层面划分训练集和验证集再按分子归属分配样本。数据量允许时按 Murcko 骨架对分子聚类后再切分效果更严格。这个操作会让指标从 0.9 降到 0.7 附近数字变难看但每一分都是真实的值得。5.2 显存 OOMJupyter kernel 直接重启自注意力的二次复杂度不是段子现象batch size 32max_length 256跑到第一个 forward 就爆显存报错定位在 attention 得分矩阵处有时 Jupyter Notebook 的 kernel 干脆无提示重启。原因Transformer 的 self-attention 的时间和空间复杂度都是 O(L²)L 是序列长度。SMILES 平均长度虽然不长但 padding 到 256、两个药物输入、batch 32 叠加后中间矩阵的显存占用非常可观。逐张看每个张量都不大但训练循环里同时存活的对象太多叠加起来就触顶了。解决优先级从高到低依次是——降低 batch size 到 16 或 8使用动态 padding 缩短实际序列长度必要时用梯度累积模拟大 batch。推理时务必加torch.no_grad()训练完及时调用torch.cuda.empty_cache()释放缓存。还有一条容易被忽略关掉其他还在运行的 Jupyter kernel每个 kernel 都会占用独立内存多开几个等于给显存和内存同时上压力。5.3 训练 loss 不降甚至爆成 NaN学习率、目标标准化与空输入的连锁反应现象前几个 epoch loss 不但不降反而上升之后开始震荡极端情况下直接输出 NaN。原因三个因素叠加。一是学习率设置过大微调场景下 2e-5 已算激进直接套用默认的 1e-3 必然发散二是回归目标没做标准化synergy score 数值范围跨度大MSELoss 的梯度量级随输入波动训练不稳定三是数据预处理时遗漏了某些样本导致个别 batch 的 input_ids 全是 padding模型在这个 batch 上没有任何有效学习信号。解决先对 synergy score 做 z-score 标准化再训练预测后反标准化还原。优化器里加eps1e-6提高数值稳定性。训练前打印一个 batch 的 input_ids确认不是全 pad 或全一样的 token。记住目标标准化对训练稳定性的影响几乎是决定性的数值范围从 [-30, 30] 压缩到 [-2, 2] 之后同样的学习率收敛速度完全不一样这属于“玄学变确定项”的经典案例。5.4 数据加载器多进程在 Windows 上反复报错num_workers 不是越大越好现象代码在本机 macOS 或 Linux 上跑得正常换到 Windows 环境DataLoader 的num_workers设置大于 0 时频繁报错或内核重启错误信息五花八门。原因Windows 下 PyTorch DataLoader 的多进程机制使用 spawn 而非 fork每个 worker 都会重新导入主模块和模型定义代码里若有不可序列化的对象就会直接报错。Jupyter Notebook 中这个问题更突出因为它本身就是多进程环境再叠加 DataLoader 的 worker 进程内存复制压力成倍增加。解决Windows 环境统一设num_workers0让数据加载在主进程内完成。模型训练的数据加载不像大规模图像那样是瓶颈SMILES 序列短且量级不大单进程加载完全够用。不建议在这个项目里花时间折腾多进程数据加载性价比太低。5.5 预测值整体趋同模型变成了“均值机”现象训练 loss 正常下降但验证集预测值的标准差只有真实标签标准差的十分之一散点图完全是一团横向的色块Pearson 相关系数低至 0.1。原因协同分数分布高度集中在零附近真正强协同或强拮抗的样本数量很少。MSE 目标下模型很快发现“预测整体均值”能让平均损失最低于是不再区分样本间差异回归退化成均值预测。这和模型容量无关是回归目标分布极不平衡导致的。解决最直接的办法是把回归改成三分类——协同、相加、拮抗用 CrossEntropyLoss 训练评估时使用 AUC 或 F1。如果坚持回归可以对协同分数做分位数加权让模型更关注尾部的极端样本或者在训练时对正样本和负样本设置不同的损失权重。另一个实用技巧是训练多个模型取预测平均减少单次训练的随机性带来的偏差。6. 进阶技巧注意力可视化和 Top-K 组合筛选流程6.1 提取注意力权重把“协同”定位到具体分子位点训练好的模型除了打分还能提供可解释性。在加载模型后将output_attentionsTrue传给编码器返回每一层每个注意力头的矩阵。将最后一层所有头平均得到的就是序列内每个 token 对之间的贡献权重。outputs model.encoder( input_ids_a, attention_maskattention_mask_a, output_attentionsTrue ) attn outputs.attentions # 每层 [batch, heads, seq, seq] avg_attn torch.stack(attn[-1]).mean(dim0).mean(dim0)把 token 映射回 SMILES 中的原子位置用热力图画出哪些原子对另一个分子的哪个片段响应最强能直观看到模型关注的协同位点。这份图放进展答或论文中比单纯贴一个 loss 曲线有说服力得多。注意可视化时只用训练集或验证集的样本不要让测试集参与这个过程。6.2 跑一遍 Top-K 组合筛选让预测结果对接实验验证模型的最终价值体现在筛选能力上。把候选药物对全部过一遍模型推理按预测分数降序排列取前 50 或前 100 个组合再去公开数据或实验记录中核对真实协同样本的命中率。model.eval() with torch.no_grad(): preds [] for batch in candidate_loader: ids_a, mask_a, ids_b, mask_b, _ batch pred model(ids_a, mask_a, ids_b, mask_b) preds.extend(pred.cpu().numpy().tolist()) candidate_df[pred_score] preds top_k candidate_df.nlargest(50, pred_score)这段代码的输出是一张按预测协同分数降序排列的候选表。和基线方法随机森林加分子指纹在同一切分下做对比把两者 top-50 的命中率同时列出Transformer 方案的优势就直观了。评估时不仅看相关性更要看头部命中率这才是实际决策需要的指标。这个项目我从数据切分的坑一路踩到 Windows 多进程的报错前前后后重写了三轮训练管线。最大的教训是不要拿到数据就直接训练先在分子层面把数据切分和预处理搞清楚再谈模型优化。所有后续指标的可靠性都建立在数据切分的正确性上这一步省事后面全要还回来。希望这套从环境到评估的完整流程能帮你少走这些弯路祝顺利。本文还有配套的精品资源点击获取
返回列表