Add LLM molecular encoder branch (BioT5/MolT5/MoleculeSTM/SELFIES-TED)

This commit is contained in:
Dicong Li 2026-06-17 15:24:33 +08:00
parent 804bda5576
commit fb4f90b68a
17 changed files with 2980 additions and 124 deletions

12
.gitignore vendored
View File

@ -188,4 +188,14 @@ cython_debug/
.ruff_cache/
# PyPI configuration file
.pypirc
.pypirc
# ============ LLM encoder branch additions ============
# 大文件 / 模型权重(不进仓库)
*.pt
*.pth
*.ckpt
*.safetensors
# 数据
*.parquet

205
README_llm_encoders.md Normal file
View File

@ -0,0 +1,205 @@
# LLM 分子编码器分支LLM Encoder Branch
本模块在 LNP 多任务预测模型上新增了一个**可选的 LLM 分子编码器分支**。它把可电离脂质的 SMILES 送入一个冻结的预训练分子模型,提取语义向量,作为一个额外的 tokenV5追加到模型的 fusion 序列中参与多任务预测。
支持四种分子编码器,可通过参数自由切换,用于横向对比:
| llm_type | 模型 | 年份 / 发表 | 参数量 | hidden | 输入 |
|---|---|---|---|---|---|
| `biot5` | BioT5-plus | 2024, arXiv | ~250M | 768 | SMILES |
| `molt5` | MolT5-base | 2022, EMNLP | 109M | 768 | SMILES |
| `moleculestm` | MoleculeSTM GNN | 2023, Nature MI | ~2M (GNN) | 300 | 分子图 |
| `selfiested` | SELFIES-TED | 2025, IBM | 358M | 1024 | SELFIES |
---
## 1. 设计说明
- **非侵入式**:默认 `use_llm=False`,此时模型行为与原版完全一致,不影响既有功能。只有显式开启才会启用 LLM 分支。
- **统一接口**:四个 encoder 的 `forward(List[str]) -> [B, d_model]` 接口完全一致,通过 `models.build_llm_encoder()` 工厂函数按 `llm_type` 实例化。
- **冻结主体**LLM / GNN 主体全程冻结,只有内部一层 `projection`LayerNorm + Linear + ReLU + Dropout参与训练参数量增加极小。
- **惰性导入**`encoders/__init__.py` 对四个 LLM encoder 采用惰性导入,未安装 `torch_geometric` / `selfies` 等可选依赖时,只要不使用对应 encoder导入不会报错。
---
## 2. 文件清单
新增文件(均在 `lnp_ml/modeling/encoders/` 下):
```
encoders/
├── llm_encoder.py # BioT5-plus (类名 LLMEncoder)
├── molt5_encoder.py # MolT5 (类名 MolT5Encoder)
├── moleculestm_encoder.py # MoleculeSTM (类名 MoleculeSTMEncoder)
├── molecule_gnn_model.py # MoleculeSTM 的 GNN 网络定义(被上一个文件 import
└── selfiested_encoder.py # SELFIES-TED (类名 SELFIESTEDEncoder)
```
修改文件:
```
lnp_ml/modeling/models.py # 新增 use_llm / llm_type 等参数 + build_llm_encoder 工厂 + V5 追加逻辑desc 维度 210 -> 217
lnp_ml/modeling/encoders/__init__.py # 惰性导出四个 LLM encoder
verify_llm_encoder.py # LLM 模块验证脚本(四项测试)
```
> 注:`models.py``DEFAULT_INPUT_DIMS["desc"]` 已从 210 更新为 **217**,以匹配新版 RDKit 的描述符维度。
---
## 3. 依赖安装
**基础依赖(所有模型都需要):**
```bash
pip install rdkit loguru transformers sentencepiece optuna
```
**按模型追加:**
| 模型 | 额外依赖 |
|---|---|
| biot5 | 无(基础依赖即可) |
| molt5 | 无 |
| moleculestm | `pip install torch_geometric torch_scatter torch_sparse ogb` |
| selfiested | `pip install selfies` |
---
## 4. 预训练权重
| 模型 | 是否需手动下载 | 说明 |
|---|---|---|
| **molt5** | 否 | 训练时自动从 HuggingFace 拉取 `laituan245/molt5-base` |
| **selfiested** | 否 | 训练时自动从 HuggingFace 拉取 `ibm/materials.selfies-ted` |
| **biot5** | 是 | 需下载 BioT5-plus 权重文件夹,见下方 |
| **moleculestm** | 是 | 需下载 MoleculeSTM GNN 权重,见下方 |
**BioT5-plus 权重下载:**
从 HuggingFace `QizhiPei/biot5-plus-base` 下载整个权重文件夹(含 `config.json``tokenizer_config.json` 等),放到本地任意路径,训练时用 `--llm-model-path <该路径>` 指定。也可直接传 HuggingFace ID `QizhiPei/biot5-plus-base` 在线加载。
**MoleculeSTM GNN 权重下载:**
```python
from huggingface_hub import snapshot_download
snapshot_download(
repo_id="chao1224/MoleculeSTM",
repo_type="model",
local_dir="<本地目录>/moleculestm_weights",
allow_patterns="*Graph*",
)
```
下载后使用的 GNN 权重路径为:
```
<本地目录>/moleculestm_weights/pretrained_MoleculeSTM/SciBERT-Graph-3e-5-1-1e-4-1-InfoNCE-0.1-32-32/molecule_model.pth
```
训练时用 `--llm-model-path <该 .pth 路径>` 指定。
---
## 5. 用法
### 5.1 代码中直接构造模型
```python
from lnp_ml.modeling.models import LNPModelWithoutMPNN
# 不启用 LLM与原版行为一致
model = LNPModelWithoutMPNN(d_model=256)
# 启用 LLM 分支(以 MolT5 为例)
model = LNPModelWithoutMPNN(
d_model=256,
use_llm=True,
llm_type="molt5", # biot5 / molt5 / moleculestm / selfiested
llm_model_path="laituan245/molt5-base", # 见上表,不同模型路径不同
llm_device="cuda",
)
```
各模型对应的 `llm_type``llm_model_path`
| llm_type | llm_model_path 示例 |
|---|---|
| `biot5` | `QizhiPei/biot5-plus-base` 或本地 BioT5 权重文件夹路径 |
| `molt5` | `laituan245/molt5-base` |
| `moleculestm` | `.../moleculestm_weights/.../molecule_model.pth` |
| `selfiested` | `ibm/materials.selfies-ted` |
### 5.2 验证 LLM 模块(训练前建议先跑)
```bash
python verify_llm_encoder.py \
--model_path <llm_model_path> \
--lnp_repo_path <仓库根目录>
```
四项测试(输出维度、维度对齐、可复现性、过拟合 sanity全部 PASS 再训练。
### 5.3 训练
> 训练脚本(`final_train_optuna_cv.py` / `pretrain.py`)需要支持 `--use-llm` / `--llm-type` / `--llm-model-path` / `--llm-device` 参数。各模型的完整 Colab 操作步骤见 `docs/` 下四份 quickstart 指南。
---
## 6. 实验结果
四个模型在 LNP 测试集上的对比(核心指标 delivery R²baseline 为不含 LLM 的版本):
| 任务 | Baseline | MolT5 | MoleculeSTM | BioT5-plus | SELFIES-TED |
|---|---|---|---|---|---|
| delivery R² | 0.373 | 0.602 | 0.552 | **0.633** | 0.618 |
| size R² | 0.215 | 0.440 | 0.408 | 0.423 | 0.381 |
| pdi acc | 0.786 | 0.748 | 0.710 | 0.695 | 0.664 |
| ee acc | 0.679 | 0.702 | 0.679 | 0.679 | 0.679 |
| toxic acc | 0.980 | 0.980 | 0.951 | 0.960 | 0.941 |
| biodist KL | 0.293 | 0.727 | 0.646 | 0.703 | 0.643 |
**主要结论:**
1. 四个 LLM 编码器都显著提升了核心回归任务delivery R² 从 baseline 的 0.37 最高提升到 0.63。
2. 领域专用预训练比堆参数更重要BioT5-plus针对生物分子预训练效果最好而参数最大、最新的 SELFIES-TED 仅排第二。
3. 序列模型整体优于图模型:三个基于 SMILES/SELFIES 的模型均优于基于图的 MoleculeSTM。
4. MolT5 性价比最高:仅 109M 参数delivery R² 排第三且与第一仅差 0.03,分类任务表现最均衡。
5. 共同局限biodist分布预测四个 LLM 均略低于 baseline属多任务学习中的 trade-off。
> 各模型每个任务的详细指标见 `results/<模型名>/` 目录。
---
## 附:训练脚本改动
本 PR 已经更新了两个训练脚本以支持 LLM 分支MoE 相关代码未引入):
- `lnp_ml/modeling/final_train_optuna_cv.py`
- `lnp_ml/modeling/pretrain.py`
两者都新增了四个命令行参数,并透传给模型:
```
--use-llm # 是否启用 LLM 分支
--llm-type {biot5,molt5,moleculestm,selfiested} # 选择哪个 encoder
--llm-model-path <路径或HF_ID> # 见第 4/5 节
--llm-device cuda # LLM 推理设备
```
预训练保存的 checkpoint 的 config 中也会记录 LLM 设置,恢复模型时自动读取。
### 训练示例(以 MolT5 为例)
```bash
# 预训练
python -m lnp_ml.modeling.pretrain main \
--train-path data/processed/train_pretrain.parquet \
--val-path data/processed/val_pretrain.parquet \
--use-llm --llm-type molt5 \
--llm-model-path laituan245/molt5-base \
--llm-device cuda --epochs 50 --lr 1e-4 --device cuda
# 正式训练
python lnp_ml/modeling/final_train_optuna_cv.py \
--init-from-pretrain models/pretrain_delivery.pt \
--use-llm --llm-type molt5 \
--llm-model-path laituan245/molt5-base \
--llm-device cuda \
--n-trials 20 --epochs-per-trial 30 --seed 42 \
--device cuda --output-dir models/final
```
切换其他模型只需改 `--llm-type``--llm-model-path`(见第 5.1 节对照表)。

42
RESULTS.md Normal file
View File

@ -0,0 +1,42 @@
# LLM 编码器分支 — 实验结果
LNP 多任务预测模型在引入不同 LLM 分子编码器V5 token后的测试集表现。
所有实验:`LNPModelWithoutMPNN`(无 MPNN、无 MoE固定 seed=42先在外部数据上预训练 delivery再用 Optuna 做 3-fold 调参 + 全量训练。
## 总览对比
| 任务 | 指标 | Baseline (无 LLM) | MolT5 | MoleculeSTM | BioT5-plus | SELFIES-TED |
|---|---|---|---|---|---|---|
| delivery | R² | 0.373 | 0.602 | 0.552 | **0.633** | 0.618 |
| size | R² | 0.215 | **0.440** | 0.408 | 0.423 | 0.381 |
| pdi | acc | **0.786** | 0.748 | 0.710 | 0.695 | 0.664 |
| ee | acc | 0.679 | **0.702** | 0.679 | 0.679 | 0.679 |
| toxic | acc | **0.980** | 0.980 | 0.951 | 0.960 | 0.941 |
| biodist | KL↓ | **0.293** | 0.727 | 0.646 | 0.703 | 0.643 |
粗体为该行最优KL 越低越好,其余越高越好。)
## 模型信息
| llm_type | 模型 | 年份 | 参数量 | hidden | 输入表示 |
|---|---|---|---|---|---|
| biot5 | BioT5-plus | 2024 | ~250M | 768 | SMILES |
| molt5 | MolT5-base | 2022 | 109M | 768 | SMILES |
| moleculestm | MoleculeSTM GNN | 2023 | ~2M (GNN) | 300 | 分子图 |
| selfiested | SELFIES-TED | 2025 | 358M | 1024 | SELFIES |
## 主要结论
1. **四个 LLM 编码器都显著提升核心回归任务**delivery R² 从 baseline 的 0.373 最高提升到 0.633BioT5-plussize R² 也从 0.215 提升到 0.380.44。
2. **领域专用预训练 > 堆参数**BioT5-plus针对生物分子预训练效果最好参数最大、最新的 SELFIES-TED358M仅排第二说明针对生物分子的预训练比单纯扩大规模更有效。
3. **序列模型 > 图模型**三个基于序列SMILES/SELFIES的语言模型都优于基于图的 MoleculeSTM GNN。
4. **MolT5 性价比最高**:仅 109M 参数delivery R² 排第三0.602,与第一仅差 0.03),且三个分类任务表现最均衡。
5. **共同局限 — biodist**:四个 LLM 在 biodist组织分布预测上都比 baseline 差,属于多任务学习中的 trade-off模型容量向回归任务倾斜牺牲了分布预测。后续可通过调整各任务损失权重缓解。
## 复现方式
各模型的完整 Colab 操作步骤见 `docs/` 下的四份 quickstart 指南;模型构造与参数说明见 `README_llm_encoders.md`

View File

@ -0,0 +1,209 @@
# LNP-ML + BioT5-plus 快速上手指南
适用于:拿到代码包、从未运行过任何代码的新组员。
---
## 前提条件(收到代码包前确认)
- Google Drive 的 `MyDrive/lnp_project/` 下已有以下两个文件夹:
- `lnp_ml/`:项目代码
- `biot5-plus-base/`BioT5-plus 权重(无需重新下载)
- `lnp_ml/data/processed/` 下已有处理好的数据文件
---
## Cell 1挂载 Drive
```python
from google.colab import drive
drive.mount('/content/drive')
```
---
## Cell 2安装依赖
> 每次重新打开 Colab 都需要重跑这一步,依赖不会自动保留。
```python
!pip install rdkit loguru transformers sentencepiece optuna -q
```
---
## Cell 3配置路径 + 确认文件完整
> 所有 cell 都依赖这里定义的 `LNP_PATH``MODEL_PATH`,必须先运行。
```python
import os, sys
LNP_PATH = "/content/drive/MyDrive/lnp_project/lnp_ml"
MODEL_PATH = "/content/drive/MyDrive/lnp_project/biot5-plus-base"
sys.path.insert(0, LNP_PATH)
os.chdir(LNP_PATH)
checks = [
f"{LNP_PATH}/lnp_ml/modeling/models.py",
f"{LNP_PATH}/lnp_ml/modeling/encoders/llm_encoder.py",
f"{LNP_PATH}/lnp_ml/modeling/encoders/__init__.py",
f"{LNP_PATH}/lnp_ml/featurization/smiles.py",
f"{MODEL_PATH}/config.json",
f"{MODEL_PATH}/tokenizer_config.json",
f"{LNP_PATH}/data/processed/train.parquet",
f"{LNP_PATH}/data/processed/val.parquet",
f"{LNP_PATH}/data/processed/test.parquet",
f"{LNP_PATH}/data/processed/train_pretrain.parquet",
f"{LNP_PATH}/data/processed/val_pretrain.parquet",
]
for f in checks:
print(f"{'✓' if os.path.exists(f) else '✗ 缺失'} {f}")
```
**所有文件都显示 ✓ 再继续。如果有 ✗,先补齐对应文件。**
---
## Cell 4验证 LLM 模块(正式训练前必做)
> 验证数据流、维度对齐、梯度链路、可复现性,四项全部通过再训练。
```python
!PYTHONPATH={LNP_PATH} \
python {LNP_PATH}/verify_llm_encoder.py \
--model_path "{MODEL_PATH}" \
--lnp_repo_path "{LNP_PATH}"
```
**正常输出:**
```
TEST 1 PASSED ✓
TEST 2 PASSED ✓
TEST 3 PASSED ✓
TEST 4 PASSED ✓
ALL TESTS PASSED ✓
```
---
## Cell 5预训练用外部 LiON 数据)
> 在约 9000 条外部数据上预训练 delivery 任务,产出 `models/pretrain_delivery.pt`
> 约需 10-15 分钟。
```python
!PYTHONPATH={LNP_PATH} \
python -m lnp_ml.modeling.pretrain main \
--train-path data/processed/train_pretrain.parquet \
--val-path data/processed/val_pretrain.parquet \
--epochs 50 \
--lr 1e-4 \
--device cuda
```
完成后确认:
```python
import os
print("✓ 预训练权重存在" if os.path.exists(f"{LNP_PATH}/models/pretrain_delivery.pt")
else "✗ 预训练权重未生成,检查上面的输出")
```
---
## Cell 6正式训练含 LLM约 20-25 分钟)
> 使用 Optuna 做 3-fold 超参搜索20 trials然后全量数据训练。
> 产出 `models/final/model.pt`
```python
!PYTHONPATH={LNP_PATH} \
python lnp_ml/modeling/final_train_optuna_cv.py \
--init-from-pretrain models/pretrain_delivery.pt \
--use-llm \
--llm-model-path "{MODEL_PATH}" \
--llm-device cuda \
--n-trials 20 \
--epochs-per-trial 30 \
--seed 42 \
--device cuda \
--output-dir models/final
```
完成后确认:
```python
print("✓ 模型权重存在" if os.path.exists(f"{LNP_PATH}/models/final/model.pt")
else "✗ 模型未生成,检查上面的输出")
```
---
## Cell 7测试评估
```python
!PYTHONPATH={LNP_PATH} \
python lnp_ml/modeling/predict.py test \
--test-path data/processed/test.parquet \
--model-path models/final/model.pt \
--output-path models/final/test_results.json
import json
with open(f'{LNP_PATH}/models/final/test_results.json') as f:
results = json.load(f)
print("=== 分类任务 ===")
for task in ['pdi', 'ee', 'toxic']:
m = results['detailed_metrics'][task]
print(f" {task}: acc={m['accuracy']:.4f}, f1={m['f1']:.4f}")
print("\n=== 回归任务 ===")
for task in ['size', 'delivery']:
m = results['detailed_metrics'][task]
print(f" {task}: R²={m['r2']:.4f}, RMSE={m['rmse']:.4f}")
print("\n=== 分布任务 ===")
m = results['detailed_metrics']['biodist']
print(f" biodist: KL={m['kl_divergence']:.4f}, JS={m['js_divergence']:.4f}")
```
**参考指标(基于已有实验结果):**
| 任务 | 指标 | 参考值 |
|---|---|---|
| delivery | R² | ~0.63 |
| size | R² | ~0.42 |
| pdi | acc | ~0.69 |
| ee | acc | ~0.68 |
| toxic | acc | ~0.96 |
| biodist | KL | ~0.70 |
---
## Cell 8备份模型到 Drive
```python
!cp -r {LNP_PATH}/models/final \
{LNP_PATH}/models/final_backup
print("备份完成 ✓")
```
---
## 常见问题
| 报错 | 原因 | 解决方法 |
|---|---|---|
| `No module named 'rdkit'` | 依赖未安装 | 重新运行 Cell 2 |
| `No module named 'lnp_ml'` | 路径未配置 | 重新运行 Cell 3 |
| `No such option '--use-llm'` | Drive 里的 `final_train_optuna_cv.py` 是旧版本 | 确认代码包里的文件是最新版本 |
| `running_mean should contain 217 elements not 210` | `models.py``desc` 维度是旧值 210 | 把 `DEFAULT_INPUT_DIMS` 里的 `"desc": 210` 改为 `"desc": 217` |
| `KeyError: attribute 'projection' already exists` | `llm_encoder.py` 是旧版本 | 确认代码包里的文件是最新版本 |
| `RuntimeError: Unexpected key(s) in state_dict` | 加载模型时 strict=True | 确认 `predict.py` 里用的是 `strict=False` |
| Colab 断开后重连 | 环境变量丢失 | 从 Cell 1 重跑Cell 5/6 已有权重可跳过 |
---
## 如果已有训练好的模型,直接从 Cell 7 开始
只需运行 Cell 1 → Cell 2 → Cell 3 → Cell 7跳过 Cell 4、5、6。

View File

@ -0,0 +1,334 @@
# LNP-ML + MoleculeSTM GNN 快速上手指南
适用于:拿到代码包、从未运行过任何代码的新组员。
---
## 前提条件(开始前确认)
- Google Drive 的 `MyDrive/lnp_project_moleculestm/` 下已有以下内容:
- `lnp_ml/`:项目代码(从 BioT5 版本复制来的,已修改)
- `moleculestm_weights/`GNN 预训练权重
如果以上文件夹不存在,先看文末的【从零开始】章节。
---
## 每次重新打开 Colab 都需要运行
### Cell 1挂载 Drive
```python
from google.colab import drive
drive.mount('/content/drive')
```
> 弹出授权页面时,确认选择有 `lnp_project_moleculestm` 的 Google 账号。
---
### Cell 2安装依赖
```python
!pip install rdkit loguru transformers sentencepiece optuna \
torch_geometric torch_scatter torch_sparse ogb -q
```
---
### Cell 3配置路径
```python
import os, sys
LNP_PATH = "/content/drive/MyDrive/lnp_project_moleculestm/lnp_ml"
MODEL_PATH = "/content/drive/MyDrive/lnp_project_moleculestm/moleculestm_weights/pretrained_MoleculeSTM/SciBERT-Graph-3e-5-1-1e-4-1-InfoNCE-0.1-32-32/molecule_model.pth"
sys.path.insert(0, LNP_PATH)
os.chdir(LNP_PATH)
print("路径配置完成 ✓")
```
---
### Cell 4确认文件完整
```python
checks = [
f"{LNP_PATH}/lnp_ml/modeling/models.py",
f"{LNP_PATH}/lnp_ml/modeling/encoders/moleculestm_encoder.py",
f"{LNP_PATH}/lnp_ml/modeling/encoders/molecule_gnn_model.py",
f"{LNP_PATH}/lnp_ml/modeling/encoders/__init__.py",
f"{MODEL_PATH}",
f"{LNP_PATH}/data/processed/train.parquet",
f"{LNP_PATH}/data/processed/val.parquet",
f"{LNP_PATH}/data/processed/test.parquet",
f"{LNP_PATH}/data/processed/train_pretrain.parquet",
f"{LNP_PATH}/data/processed/val_pretrain.parquet",
]
for f in checks:
print(f"{'✓' if os.path.exists(f) else '✗ 缺失'} {f}")
```
**所有文件都显示 ✓ 再继续。**
---
## 正式流程
### Cell 5验证 MoleculeSTM 模块
```python
!PYTHONPATH={LNP_PATH} \
python {LNP_PATH}/verify_llm_encoder.py \
--model_path "{MODEL_PATH}" \
--lnp_repo_path "{LNP_PATH}"
```
**正常输出:**
```
TEST 1 PASSED ✓
TEST 2 PASSED ✓
TEST 3 PASSED ✓
TEST 4 PASSED ✓
ALL TESTS PASSED ✓
```
---
### Cell 6预训练约 10 分钟)
```python
!PYTHONPATH={LNP_PATH} \
python -m lnp_ml.modeling.pretrain main \
--train-path data/processed/train_pretrain.parquet \
--val-path data/processed/val_pretrain.parquet \
--epochs 50 \
--lr 1e-4 \
--device cuda
```
完成后确认权重存在:
```python
print("✓ 预训练权重存在" if os.path.exists(f"{LNP_PATH}/models/pretrain_delivery.pt")
else "✗ 预训练权重未生成")
```
参考结果:`Best val_loss: 0.5667`
---
### Cell 7正式训练约 20 分钟)
```python
!PYTHONPATH={LNP_PATH} \
python lnp_ml/modeling/final_train_optuna_cv.py \
--init-from-pretrain models/pretrain_delivery.pt \
--use-llm \
--llm-model-path "{MODEL_PATH}" \
--llm-device cuda \
--n-trials 20 \
--epochs-per-trial 30 \
--seed 42 \
--device cuda \
--output-dir models/final
```
完成后确认:
```python
print("✓ 模型权重存在" if os.path.exists(f"{LNP_PATH}/models/final/model.pt")
else "✗ 模型未生成")
```
参考结果:`Best val_loss: 2.4433`
---
### Cell 8测试评估
```python
!PYTHONPATH={LNP_PATH} \
python lnp_ml/modeling/predict.py test \
--test-path data/processed/test.parquet \
--model-path models/final/model.pt \
--output-path models/final/test_results.json
import json
with open(f'{LNP_PATH}/models/final/test_results.json') as f:
results = json.load(f)
print("=== 分类任务 ===")
for task in ['pdi', 'ee', 'toxic']:
m = results['detailed_metrics'][task]
print(f" {task}: acc={m['accuracy']:.4f}, f1={m['f1']:.4f}")
print("\n=== 回归任务 ===")
for task in ['size', 'delivery']:
m = results['detailed_metrics'][task]
print(f" {task}: R²={m['r2']:.4f}, RMSE={m['rmse']:.4f}")
print("\n=== 分布任务 ===")
m = results['detailed_metrics']['biodist']
print(f" biodist: KL={m['kl_divergence']:.4f}, JS={m['js_divergence']:.4f}")
```
**参考指标:**
| 任务 | 指标 | 参考值 |
|---|---|---|
| delivery | R² | ~0.55 |
| size | R² | ~0.41 |
| pdi | acc | ~0.71 |
| ee | acc | ~0.68 |
| toxic | acc | ~0.95 |
| biodist | KL | ~0.65 |
---
### Cell 9备份模型
```python
!cp -r {LNP_PATH}/models/final \
{LNP_PATH}/models/final_backup
print("备份完成 ✓")
```
---
## 常见问题
| 报错 | 原因 | 解决方法 |
|---|---|---|
| `No module named 'rdkit'` | 依赖未安装 | 重新运行 Cell 2 |
| `No module named 'lnp_ml'` | 路径未配置 | 重新运行 Cell 3 |
| `Drive not mounted` | Drive 未挂载 | 重新运行 Cell 1 |
| `Mountpoint must not already contain files` | Drive 已挂载但缓存混乱 | 菜单栏 → 运行时 → 重新启动运行时,再从 Cell 1 开始 |
| `RuntimeError: size mismatch` | OGB 版本差异 | 已在代码里修复,正常现象 |
| `KeyError: test_results.json` | 测试未完成 | 确认 Cell 8 的第一条命令输出了 `Saved test results` |
| 重新连接后变量丢失 | Colab 断开重连 | 从 Cell 1 重跑Cell 6/7 已有权重可跳过 |
---
## 如果已有训练好的模型,直接从 Cell 8 开始
只需运行 Cell 1 → Cell 2 → Cell 3 → Cell 8跳过 Cell 4-7。
---
## 【从零开始】如果 lnp_project_moleculestm 不存在
### Step A从 BioT5 版本复制代码
```python
import os
os.makedirs("/content/drive/MyDrive/lnp_project_moleculestm", exist_ok=True)
!cp -r "/content/drive/MyDrive/lnp_project_biot5-plus/lnp_ml" \
"/content/drive/MyDrive/lnp_project_moleculestm/lnp_ml"
print("代码复制完成 ✓")
```
### Step B下载 MoleculeSTM GNN 权重(约 3GB需 1-2 分钟)
```python
from huggingface_hub import snapshot_download
import os
os.makedirs("/content/drive/MyDrive/lnp_project_moleculestm/moleculestm_weights", exist_ok=True)
snapshot_download(
repo_id="chao1224/MoleculeSTM",
repo_type="model",
local_dir="/content/drive/MyDrive/lnp_project_moleculestm/moleculestm_weights",
allow_patterns="*Graph*"
)
print("权重下载完成 ✓")
```
### Step C修改代码文件
运行以下四个 cell 完成代码适配:
```python
# Step C-1更新 __init__.py
path = "/content/drive/MyDrive/lnp_project_moleculestm/lnp_ml/lnp_ml/modeling/encoders/__init__.py"
new_content = """from lnp_ml.modeling.encoders.rdkit_encoder import CachedRDKitEncoder
from lnp_ml.modeling.encoders.mpnn_encoder import CachedMPNNEncoder
from .llm_encoder import LLMEncoder
from .moleculestm_encoder import MoleculeSTMEncoder
__all__ = ["CachedRDKitEncoder", "CachedMPNNEncoder", "LLMEncoder", "MoleculeSTMEncoder"]
"""
with open(path, 'w') as f:
f.write(new_content)
print("__init__.py ✓")
```
```python
# Step C-2更新 models.py
path = "/content/drive/MyDrive/lnp_project_moleculestm/lnp_ml/lnp_ml/modeling/models.py"
with open(path, 'r') as f:
content = f.read()
content = content.replace(
"from lnp_ml.modeling.encoders import CachedRDKitEncoder, CachedMPNNEncoder, LLMEncoder",
"from lnp_ml.modeling.encoders import CachedRDKitEncoder, CachedMPNNEncoder, LLMEncoder, MoleculeSTMEncoder"
)
content = content.replace(
"self.llm_encoder = LLMEncoder(",
"self.llm_encoder = MoleculeSTMEncoder("
)
content = content.replace(
"model_path=llm_model_path,",
"checkpoint_path=llm_model_path,"
)
content = content.replace(
"llm_checkpoint_path=llm_model_path,",
"llm_model_path=llm_model_path,"
)
with open(path, 'w') as f:
f.write(content)
print("models.py ✓")
```
```python
# Step C-3修复 verify 脚本
path = "/content/drive/MyDrive/lnp_project_moleculestm/lnp_ml/verify_llm_encoder.py"
with open(path, 'r') as f:
content = f.read()
content = content.replace(
"from lnp_ml.modeling.encoders.llm_encoder import LLMEncoder",
"from lnp_ml.modeling.encoders.moleculestm_encoder import MoleculeSTMEncoder as LLMEncoder"
)
content = content.replace(
"encoder = LLMEncoder(\n model_path=model_path,",
"encoder = LLMEncoder(\n checkpoint_path=model_path,"
)
content = content.replace(
"encoder._llm.named_parameters()",
"encoder._gnn.named_parameters()"
)
content = content.replace(
"model.llm_encoder._llm.parameters()",
"model.llm_encoder._gnn.parameters()"
)
with open(path, 'w') as f:
f.write(content)
print("verify_llm_encoder.py ✓")
```
```python
# Step C-4修复 pretrain.py
path = "/content/drive/MyDrive/lnp_project_moleculestm/lnp_ml/lnp_ml/modeling/pretrain.py"
with open(path, 'r') as f:
content = f.read()
content = content.replace(
"llm_checkpoint_path=llm_model_path,",
"llm_model_path=llm_model_path,"
)
with open(path, 'w') as f:
f.write(content)
print("pretrain.py ✓")
```
完成后从 Cell 5 开始正常运行。

View File

@ -0,0 +1,304 @@
# LNP-ML + MolT5 快速上手指南
适用于:拿到代码包、从未运行过任何代码的新组员。
论文Edwards et al., "Translation between Molecules and Natural Language", EMNLP 2022
模型laituan245/molt5-base109M 参数,无需手动下载,训练时自动从 HuggingFace 拉取)
---
## 前提条件(开始前确认)
- Google Drive 的 `MyDrive/lnp_project_molt5/` 下已有以下内容:
- `lnp_ml/`:项目代码(从 BioT5 版本复制来的,已修改)
- MolT5 权重无需提前下载,训练时自动拉取
如果以上文件夹不存在,先看文末的【从零开始】章节。
---
## 每次重新打开 Colab 都需要运行
### Cell 1挂载 Drive
```python
from google.colab import drive
drive.mount('/content/drive')
```
> 弹出授权页面时,确认选择有 `lnp_project_molt5` 的 Google 账号。
---
### Cell 2安装依赖
```python
!pip install rdkit loguru transformers sentencepiece optuna -q
```
---
### Cell 3配置路径
```python
import os, sys
LNP_PATH = "/content/drive/MyDrive/lnp_project_molt5/lnp_ml"
sys.path.insert(0, LNP_PATH)
os.chdir(LNP_PATH)
print("路径配置完成 ✓")
```
---
### Cell 4确认文件完整
```python
checks = [
f"{LNP_PATH}/lnp_ml/modeling/models.py",
f"{LNP_PATH}/lnp_ml/modeling/encoders/molt5_encoder.py",
f"{LNP_PATH}/lnp_ml/modeling/encoders/__init__.py",
f"{LNP_PATH}/data/processed/train.parquet",
f"{LNP_PATH}/data/processed/val.parquet",
f"{LNP_PATH}/data/processed/test.parquet",
f"{LNP_PATH}/data/processed/train_pretrain.parquet",
f"{LNP_PATH}/data/processed/val_pretrain.parquet",
]
for f in checks:
print(f"{'✓' if os.path.exists(f) else '✗ 缺失'} {f}")
```
**所有文件都显示 ✓ 再继续。**
---
## 正式流程
### Cell 5验证 MolT5 模块
```python
!PYTHONPATH={LNP_PATH} \
python {LNP_PATH}/verify_llm_encoder.py \
--model_path "laituan245/molt5-base" \
--lnp_repo_path "{LNP_PATH}"
```
**正常输出:**
```
TEST 1 PASSED ✓
TEST 2 PASSED ✓
TEST 3 PASSED ✓
TEST 4 PASSED ✓
ALL TESTS PASSED ✓
```
---
### Cell 6预训练约 10 分钟)
```python
!PYTHONPATH={LNP_PATH} \
python -m lnp_ml.modeling.pretrain main \
--train-path data/processed/train_pretrain.parquet \
--val-path data/processed/val_pretrain.parquet \
--epochs 50 \
--lr 1e-4 \
--device cuda
```
完成后确认权重存在:
```python
print("✓ 预训练权重存在" if os.path.exists(f"{LNP_PATH}/models/pretrain_delivery.pt")
else "✗ 预训练权重未生成")
```
参考结果:`Best val_loss: 0.5867`
---
### Cell 7正式训练约 20 分钟)
```python
!PYTHONPATH={LNP_PATH} \
python lnp_ml/modeling/final_train_optuna_cv.py \
--init-from-pretrain models/pretrain_delivery.pt \
--use-llm \
--llm-model-path "laituan245/molt5-base" \
--llm-device cuda \
--n-trials 20 \
--epochs-per-trial 30 \
--seed 42 \
--device cuda \
--output-dir models/final
```
完成后确认:
```python
print("✓ 模型权重存在" if os.path.exists(f"{LNP_PATH}/models/final/model.pt")
else "✗ 模型未生成")
```
参考结果:`Best val_loss: 2.3131`
---
### Cell 8测试评估
```python
!PYTHONPATH={LNP_PATH} \
python lnp_ml/modeling/predict.py test \
--test-path data/processed/test.parquet \
--model-path models/final/model.pt \
--output-path models/final/test_results.json
import json
with open(f'{LNP_PATH}/models/final/test_results.json') as f:
results = json.load(f)
print("=== 分类任务 ===")
for task in ['pdi', 'ee', 'toxic']:
m = results['detailed_metrics'][task]
print(f" {task}: acc={m['accuracy']:.4f}, f1={m['f1']:.4f}")
print("\n=== 回归任务 ===")
for task in ['size', 'delivery']:
m = results['detailed_metrics'][task]
print(f" {task}: R²={m['r2']:.4f}, RMSE={m['rmse']:.4f}")
print("\n=== 分布任务 ===")
m = results['detailed_metrics']['biodist']
print(f" biodist: KL={m['kl_divergence']:.4f}, JS={m['js_divergence']:.4f}")
```
**参考指标:**
| 任务 | 指标 | 参考值 |
|---|---|---|
| delivery | R² | ~0.60 |
| size | R² | ~0.44 |
| pdi | acc | ~0.75 |
| ee | acc | ~0.70 |
| toxic | acc | ~0.98 |
| biodist | KL | ~0.73 |
---
### Cell 9备份模型
```python
!cp -r {LNP_PATH}/models/final \
{LNP_PATH}/models/final_backup
print("备份完成 ✓")
```
---
## 如果已有训练好的模型,直接从 Cell 8 开始
只需运行 Cell 1 → Cell 2 → Cell 3 → Cell 8跳过 Cell 4-7。
---
## 常见问题
| 报错 | 原因 | 解决方法 |
|---|---|---|
| `No module named 'rdkit'` | 依赖未安装 | 重新运行 Cell 2 |
| `No module named 'lnp_ml'` | 路径未配置 | 重新运行 Cell 3 |
| `'NoneType' has no attribute 'Study'` | optuna 未安装 | 重新运行 Cell 2 |
| `Drive not mounted` | Drive 未挂载 | 重新运行 Cell 1 |
| `Mountpoint must not already contain files` | Drive 已挂载但缓存混乱 | 菜单栏 → 运行时 → 重新启动运行时,再从 Cell 1 开始 |
| Colab 断开后重连 | 环境变量丢失 | 从 Cell 1 重跑Cell 6/7 已有权重可跳过 |
---
## 【从零开始】如果 lnp_project_molt5 不存在
### Step A从 BioT5 版本复制代码
```python
import os
os.makedirs("/content/drive/MyDrive/lnp_project_molt5", exist_ok=True)
!cp -r "/content/drive/MyDrive/lnp_project_biot5-plus/lnp_ml" \
"/content/drive/MyDrive/lnp_project_molt5/lnp_ml"
print("代码复制完成 ✓")
```
### Step B上传 molt5_encoder.py
```python
from google.colab import files
import shutil
uploaded = files.upload() # 选择 molt5_encoder.py
shutil.copy(
'molt5_encoder.py',
'/content/drive/MyDrive/lnp_project_molt5/lnp_ml/lnp_ml/modeling/encoders/molt5_encoder.py'
)
print("上传完成 ✓")
```
### Step C修改代码文件
```python
# Step C-1更新 __init__.py
path = "/content/drive/MyDrive/lnp_project_molt5/lnp_ml/lnp_ml/modeling/encoders/__init__.py"
new_content = """from lnp_ml.modeling.encoders.rdkit_encoder import CachedRDKitEncoder
from lnp_ml.modeling.encoders.mpnn_encoder import CachedMPNNEncoder
from .llm_encoder import LLMEncoder
from .molt5_encoder import MolT5Encoder
__all__ = ["CachedRDKitEncoder", "CachedMPNNEncoder", "LLMEncoder", "MolT5Encoder"]
"""
with open(path, 'w') as f:
f.write(new_content)
print("__init__.py ✓")
```
```python
# Step C-2更新 models.py
path = "/content/drive/MyDrive/lnp_project_molt5/lnp_ml/lnp_ml/modeling/models.py"
with open(path, 'r') as f:
content = f.read()
content = content.replace(
"from lnp_ml.modeling.encoders import CachedRDKitEncoder, CachedMPNNEncoder, LLMEncoder",
"from lnp_ml.modeling.encoders import CachedRDKitEncoder, CachedMPNNEncoder, LLMEncoder, MolT5Encoder"
)
content = content.replace(
"self.llm_encoder = LLMEncoder(",
"self.llm_encoder = MolT5Encoder("
)
with open(path, 'w') as f:
f.write(content)
print("models.py ✓")
```
```python
# Step C-3修复 verify 脚本
path = "/content/drive/MyDrive/lnp_project_molt5/lnp_ml/verify_llm_encoder.py"
with open(path, 'r') as f:
content = f.read()
content = content.replace(
"from lnp_ml.modeling.encoders.llm_encoder import LLMEncoder",
"from lnp_ml.modeling.encoders.molt5_encoder import MolT5Encoder as LLMEncoder"
)
content = content.replace(
"encoder._llm.named_parameters()",
"encoder._encoder.named_parameters()"
)
content = content.replace(
"model.llm_encoder._llm.parameters()",
"model.llm_encoder._encoder.parameters()"
)
with open(path, 'w') as f:
f.write(content)
print("verify_llm_encoder.py ✓")
```
完成后从 Cell 5 开始正常运行。

View File

@ -0,0 +1,323 @@
# LNP-ML + SELFIES-TED 快速上手指南
适用于:拿到代码包、从未运行过任何代码的新组员。
论文IBM, "A Large Encoder-Decoder Family of Foundation Models for Chemical Language" (2024-2025)
模型ibm/materials.selfies-ted358M 参数BART 架构,无需手动下载,训练时自动从 HuggingFace 拉取)
---
## ⚠️ 与前几个模型的三个关键区别
1. **必须用 GPU 运行时**(模型 358MCPU 会非常慢)。开始前先确认硬件加速器选了 GPU。
2. **需要额外安装 selfies 库**(用于 SMILES → SELFIES 转换)。
3. **hidden size 是 1024**(前几个模型是 768 或 300这已经在 encoder 代码里处理好了。
---
## 前提条件(开始前确认)
- Google Drive 的 `MyDrive/lnp_project_selfiested/` 下已有:
- `lnp_ml/`:项目代码(从 BioT5 版本复制来的,已修改)
- SELFIES-TED 权重无需提前下载,训练时自动拉取
如果文件夹不存在,先看文末的【从零开始】章节。
---
## 第一步:确认 GPU
菜单栏 → 代码执行程序 → 更改运行时类型 → 硬件加速器选 **GPU** → 保存。
然后运行:
```python
import torch
print(f"GPU 可用: {torch.cuda.is_available()}")
```
显示 `True` 再继续。如果是 `False`,按上面的步骤切换 GPU。
---
## 每次重新打开 Colab 都需要运行
### Cell 1挂载 Drive
```python
from google.colab import drive
drive.mount('/content/drive')
```
> 弹出授权页面时,确认选择有 `lnp_project_selfiested` 的 Google 账号。
---
### Cell 2安装依赖注意多了 selfies
```python
!pip install rdkit loguru transformers sentencepiece optuna selfies -q
```
---
### Cell 3配置路径
```python
import os, sys
LNP_PATH = "/content/drive/MyDrive/lnp_project_selfiested/lnp_ml"
sys.path.insert(0, LNP_PATH)
os.chdir(LNP_PATH)
import torch
print(f"路径配置完成 ✓ | GPU 可用: {torch.cuda.is_available()}")
```
---
### Cell 4确认文件完整
```python
checks = [
f"{LNP_PATH}/lnp_ml/modeling/models.py",
f"{LNP_PATH}/lnp_ml/modeling/encoders/selfiested_encoder.py",
f"{LNP_PATH}/lnp_ml/modeling/encoders/__init__.py",
f"{LNP_PATH}/data/processed/train.parquet",
f"{LNP_PATH}/data/processed/val.parquet",
f"{LNP_PATH}/data/processed/test.parquet",
f"{LNP_PATH}/data/processed/train_pretrain.parquet",
f"{LNP_PATH}/data/processed/val_pretrain.parquet",
]
for f in checks:
print(f"{'✓' if os.path.exists(f) else '✗ 缺失'} {f}")
```
**所有文件都显示 ✓ 再继续。**
---
## 正式流程
### Cell 5验证 SELFIES-TED 模块
```python
!PYTHONPATH={LNP_PATH} \
python {LNP_PATH}/verify_llm_encoder.py \
--model_path "ibm/materials.selfies-ted" \
--lnp_repo_path "{LNP_PATH}"
```
**正常输出:**
```
TEST 1 PASSED ✓
TEST 2 PASSED ✓
TEST 3 PASSED ✓
TEST 4 PASSED ✓
ALL TESTS PASSED ✓
```
---
### Cell 6预训练约 10 分钟)
```python
!PYTHONPATH={LNP_PATH} \
python -m lnp_ml.modeling.pretrain main \
--train-path data/processed/train_pretrain.parquet \
--val-path data/processed/val_pretrain.parquet \
--epochs 50 \
--lr 1e-4 \
--device cuda
```
完成后确认权重存在:
```python
print("✓ 预训练权重存在" if os.path.exists(f"{LNP_PATH}/models/pretrain_delivery.pt")
else "✗ 预训练权重未生成")
```
参考结果:`Best val_loss: 0.5659`
---
### Cell 7正式训练约 25-30 分钟,比前几个模型略慢)
```python
!PYTHONPATH={LNP_PATH} \
python lnp_ml/modeling/final_train_optuna_cv.py \
--init-from-pretrain models/pretrain_delivery.pt \
--use-llm \
--llm-model-path "ibm/materials.selfies-ted" \
--llm-device cuda \
--n-trials 20 \
--epochs-per-trial 30 \
--seed 42 \
--device cuda \
--output-dir models/final
```
完成后确认:
```python
print("✓ 模型权重存在" if os.path.exists(f"{LNP_PATH}/models/final/model.pt")
else "✗ 模型未生成")
```
---
### Cell 8测试评估
```python
!PYTHONPATH={LNP_PATH} \
python lnp_ml/modeling/predict.py test \
--test-path data/processed/test.parquet \
--model-path models/final/model.pt \
--output-path models/final/test_results.json
import json
with open(f'{LNP_PATH}/models/final/test_results.json') as f:
results = json.load(f)
print("=== 分类任务 ===")
for task in ['pdi', 'ee', 'toxic']:
m = results['detailed_metrics'][task]
print(f" {task}: acc={m['accuracy']:.4f}, f1={m['f1']:.4f}")
print("\n=== 回归任务 ===")
for task in ['size', 'delivery']:
m = results['detailed_metrics'][task]
print(f" {task}: R²={m['r2']:.4f}, RMSE={m['rmse']:.4f}")
print("\n=== 分布任务 ===")
m = results['detailed_metrics']['biodist']
print(f" biodist: KL={m['kl_divergence']:.4f}, JS={m['js_divergence']:.4f}")
```
**参考指标:**
| 任务 | 指标 | 参考值 |
|---|---|---|
| delivery | R² | ~0.62 |
| size | R² | ~0.38 |
| pdi | acc | ~0.66 |
| ee | acc | ~0.68 |
| toxic | acc | ~0.94 |
| biodist | KL | ~0.64 |
---
### Cell 9备份模型
```python
!cp -r {LNP_PATH}/models/final \
{LNP_PATH}/models/final_backup
print("备份完成 ✓")
```
---
## 如果已有训练好的模型,直接从 Cell 8 开始
只需运行 Cell 1 → Cell 2 → Cell 3 → Cell 8跳过 Cell 4-7。
---
## 常见问题
| 报错 | 原因 | 解决方法 |
|---|---|---|
| `Device: cpu` 或训练极慢 | 没选 GPU 运行时 | 菜单 → 更改运行时类型 → 选 GPU |
| `No module named 'selfies'` | selfies 库未装 | 重新运行 Cell 2 |
| `No module named 'rdkit'` | 依赖未安装 | 重新运行 Cell 2 |
| `No module named 'lnp_ml'` | 路径未配置 | 重新运行 Cell 3 |
| `'NoneType' has no attribute 'Study'` | optuna 未安装 | 重新运行 Cell 2 |
| `Mountpoint must not already contain files` | Drive 缓存混乱 | 菜单 → 运行时 → 重新启动运行时,再从 Cell 1 开始 |
| Colab 断开后重连 | 环境变量丢失 | 从 Cell 1 重跑Cell 6/7 已有权重可跳过 |
---
## 【从零开始】如果 lnp_project_selfiested 不存在
### Step A从 BioT5 版本复制代码
```python
import os
os.makedirs("/content/drive/MyDrive/lnp_project_selfiested", exist_ok=True)
!cp -r "/content/drive/MyDrive/lnp_project_biot5-plus/lnp_ml" \
"/content/drive/MyDrive/lnp_project_selfiested/lnp_ml"
print("代码复制完成 ✓")
```
### Step B上传 selfiested_encoder.py
```python
from google.colab import files
import shutil
uploaded = files.upload() # 选择 selfiested_encoder.py
shutil.copy(
'selfiested_encoder.py',
'/content/drive/MyDrive/lnp_project_selfiested/lnp_ml/lnp_ml/modeling/encoders/selfiested_encoder.py'
)
print("上传完成 ✓")
```
### Step C修改代码文件
```python
# Step C-1更新 __init__.py
path = "/content/drive/MyDrive/lnp_project_selfiested/lnp_ml/lnp_ml/modeling/encoders/__init__.py"
with open(path, 'w') as f:
f.write("""from lnp_ml.modeling.encoders.rdkit_encoder import CachedRDKitEncoder
from lnp_ml.modeling.encoders.mpnn_encoder import CachedMPNNEncoder
from .llm_encoder import LLMEncoder
from .selfiested_encoder import SELFIESTEDEncoder
__all__ = ["CachedRDKitEncoder", "CachedMPNNEncoder", "LLMEncoder", "SELFIESTEDEncoder"]
""")
print("__init__.py ✓")
```
```python
# Step C-2更新 models.py
path = "/content/drive/MyDrive/lnp_project_selfiested/lnp_ml/lnp_ml/modeling/models.py"
with open(path, 'r') as f:
content = f.read()
content = content.replace(
"from lnp_ml.modeling.encoders import CachedRDKitEncoder, CachedMPNNEncoder, LLMEncoder",
"from lnp_ml.modeling.encoders import CachedRDKitEncoder, CachedMPNNEncoder, LLMEncoder, SELFIESTEDEncoder"
)
content = content.replace(
"self.llm_encoder = LLMEncoder(",
"self.llm_encoder = SELFIESTEDEncoder("
)
with open(path, 'w') as f:
f.write(content)
print("models.py ✓")
```
```python
# Step C-3修复 verify 脚本(属性名是 _model
path = "/content/drive/MyDrive/lnp_project_selfiested/lnp_ml/verify_llm_encoder.py"
with open(path, 'r') as f:
content = f.read()
content = content.replace(
"from lnp_ml.modeling.encoders.llm_encoder import LLMEncoder",
"from lnp_ml.modeling.encoders.selfiested_encoder import SELFIESTEDEncoder as LLMEncoder"
)
content = content.replace(
"encoder._llm.named_parameters()",
"encoder._model.named_parameters()"
)
content = content.replace(
"model.llm_encoder._llm.parameters()",
"model.llm_encoder._model.parameters()"
)
with open(path, 'w') as f:
f.write(content)
print("verify_llm_encoder.py ✓")
```
完成后从【第一步:确认 GPU】开始正常运行。

View File

@ -1,5 +1,34 @@
from lnp_ml.modeling.encoders.rdkit_encoder import CachedRDKitEncoder
from lnp_ml.modeling.encoders.mpnn_encoder import CachedMPNNEncoder
__all__ = ["CachedRDKitEncoder", "CachedMPNNEncoder"]
# ============ LLM / 分子编码器(可选) ============
# 这些 encoder 在使用 use_llm=True 时才会被实例化。
# 为避免在未安装对应依赖(如 torch_geometric、selfies时导入即报错
# 采用惰性导入:实际类在 build_llm_encoder() 内按需 import。
__all__ = [
"CachedRDKitEncoder",
"CachedMPNNEncoder",
# LLM encoders按需惰性导入见 models.build_llm_encoder
"LLMEncoder",
"MolT5Encoder",
"MoleculeSTMEncoder",
"SELFIESTEDEncoder",
]
def __getattr__(name):
"""惰性导入 LLM encoder 类,避免未装可选依赖时 import 失败。"""
if name == "LLMEncoder":
from lnp_ml.modeling.encoders.llm_encoder import LLMEncoder
return LLMEncoder
if name == "MolT5Encoder":
from lnp_ml.modeling.encoders.molt5_encoder import MolT5Encoder
return MolT5Encoder
if name == "MoleculeSTMEncoder":
from lnp_ml.modeling.encoders.moleculestm_encoder import MoleculeSTMEncoder
return MoleculeSTMEncoder
if name == "SELFIESTEDEncoder":
from lnp_ml.modeling.encoders.selfiested_encoder import SELFIESTEDEncoder
return SELFIESTEDEncoder
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@ -0,0 +1,226 @@
"""
BioT5-plus LLM 编码器
功能SMILES -> BioT5-plus encoder (frozen) -> mean pooling -> Linear -> [B, d_model]
产出 V5 token追加到 LNPModel attention pooling 序列中
放置位置lnp_ml/modeling/encoders/llm_encoder.py
依赖
pip install transformers sentencepiece
加载方式两种都支持
# 方式 A本地路径推荐Colab 挂载 Drive 后使用)
LLMEncoder(model_path="/content/drive/MyDrive/biot5-plus-base")
# 方式 BHuggingFace 在线(需要网络)
LLMEncoder(model_path="QizhiPei/biot5-plus-base")
"""
import logging
from typing import Dict, List, Optional
import numpy as np
import torch
import torch.nn as nn
logger = logging.getLogger(__name__)
# BioT5-plus 分子输入格式:<bom> SMILES/SELFIES <eom>
BOM_TOKEN = "<bom>"
EOM_TOKEN = "<eom>"
class LLMEncoder(nn.Module):
"""
基于 BioT5-plus 的冻结分子编码器
流程
SMILES
-> "<bom>" + SMILES + "<eom>"
-> T5TokenizerBioT5-plus 自带完整词表直接 from_pretrained
-> T5EncoderModel (frozen, no_grad)
-> last_hidden_state [B, seq_len, hidden_size]
-> attention-mask mean pooling -> [B, hidden_size]
-> LayerNorm + Linear + ReLU + Dropout
-> [B, d_model] V5 token
Args:
model_path : BioT5-plus 本地路径或 HuggingFace ID
本地示例"content/drive/MyDrive/biot5-plus-base"
在线示例"QizhiPei/biot5-plus-base"
d_model : 输出维度 LNPModel.d_model 一致默认 256
dropout : projection dropout默认 0.1
device : 推理设备Colab 上用 "cuda"
max_length : tokenizer 截断长度默认 512
use_cache : 是否缓存已编码 SMILES 的向量避免重复推理
"""
def __init__(
self,
model_path: str,
d_model: int = 256,
dropout: float = 0.1,
device: str = "cuda",
max_length: int = 512,
use_cache: bool = True,
) -> None:
super().__init__()
self.model_path = model_path
self.d_model = d_model
self.dropout_rate = dropout
self.max_length = max_length
self.use_cache = use_cache
self._device_str = device
# LLM延迟加载首次 forward 时才初始化)
self._tokenizer = None
self._llm = None
self._hidden_size: Optional[int] = None
self._initialized: bool = False
# SMILES -> pooled numpy 向量的内存缓存
self._cache: Dict[str, np.ndarray] = {}
# projection 层在 _lazy_init 里直接赋值注册
# 注意:不能在这里 self.projection = None否则 add_module 会冲突
# 用 _projection_ready 标记是否已初始化
self._projection_ready: bool = False
# ------------------------------------------------------------------ #
# 延迟初始化 #
# ------------------------------------------------------------------ #
def _lazy_init(self) -> None:
"""首次 forward 时加载模型,避免导入时就占满显存。"""
if self._initialized:
return
logger.info(f"[LLMEncoder] Loading BioT5-plus from: {self.model_path}")
from transformers import T5Tokenizer, T5EncoderModel
# BioT5-plus 的 tokenizer checkpoint 里已内置完整词表(含 SELFIES token
# 直接 from_pretrained 即可,不需要手动 add_tokens
self._tokenizer = T5Tokenizer.from_pretrained(
self.model_path,
model_max_length=self.max_length,
)
# encoder-only 模型(比 T5ForConditionalGeneration 省约一半显存)
self._llm = T5EncoderModel.from_pretrained(self.model_path)
self._llm = self._llm.to(self._device_str)
self._llm.eval()
# 完全冻结 LLM不参与梯度更新
for param in self._llm.parameters():
param.requires_grad = False
self._hidden_size = self._llm.config.d_model
logger.info(
f"[LLMEncoder] hidden_size={self._hidden_size}, "
f"vocab_size={len(self._tokenizer)}, frozen=True"
)
# projection 层(唯一可训练的部分)
# 直接赋值给 self.projectionPyTorch 会自动注册为子模块
self.projection = nn.Sequential(
nn.LayerNorm(self._hidden_size),
nn.Linear(self._hidden_size, self.d_model),
nn.ReLU(),
nn.Dropout(self.dropout_rate),
).to(self._device_str)
self._projection_ready = True
self._initialized = True
logger.info("[LLMEncoder] Ready.")
# ------------------------------------------------------------------ #
# 内部编码 #
# ------------------------------------------------------------------ #
@torch.no_grad()
def _encode_batch_raw(self, smiles_list: List[str]) -> np.ndarray:
"""
对一批 SMILES BioT5-plus encoder返回 mean-pooled numpy 向量
Returns:
np.ndarray shape [N, hidden_size] dtype float32
"""
# 加分子起止标记,与 BioT5-plus 预训练格式一致
formatted = [f"{BOM_TOKEN}{s}{EOM_TOKEN}" for s in smiles_list]
encoded = self._tokenizer(
formatted,
padding=True,
truncation=True,
max_length=self.max_length,
return_tensors="pt",
)
input_ids = encoded["input_ids"].to(self._device_str) # [B, L]
attention_mask = encoded["attention_mask"].to(self._device_str) # [B, L]
# LLM 推理(@no_grad 已在装饰器处理)
outputs = self._llm(input_ids=input_ids, attention_mask=attention_mask)
hidden = outputs.last_hidden_state # [B, L, H]
# attention-mask mean pooling忽略 padding token
mask = attention_mask.unsqueeze(-1).float() # [B, L, 1]
pooled = (hidden * mask).sum(1) / mask.sum(1).clamp(min=1e-9) # [B, H]
return pooled.cpu().numpy().astype(np.float32)
# ------------------------------------------------------------------ #
# forward #
# ------------------------------------------------------------------ #
def forward(self, smiles_list: List[str]) -> torch.Tensor:
"""
Args:
smiles_list: 长度为 B SMILES 字符串列表
Returns:
torch.Tensor shape [B, d_model]在模型所在设备上
"""
self._lazy_init()
# 找出未缓存的 SMILES
to_compute = (
[s for s in smiles_list if s not in self._cache]
if self.use_cache
else list(smiles_list)
)
# 批量计算并写入缓存
if to_compute:
new_vecs = self._encode_batch_raw(to_compute) # [N, H]
if self.use_cache:
for smi, vec in zip(to_compute, new_vecs):
self._cache[smi] = vec
# 按原始顺序组装结果
pooled_np = (
np.stack([self._cache[s] for s in smiles_list])
if self.use_cache
else new_vecs
)
pooled = torch.from_numpy(pooled_np).to(self._device_str) # [B, H]
return self.projection(pooled) # [B, d_model]
# ------------------------------------------------------------------ #
# 工具属性 #
# ------------------------------------------------------------------ #
@property
def hidden_size(self) -> int:
self._lazy_init()
return self._hidden_size
def clear_cache(self) -> None:
"""清空 SMILES 向量缓存。"""
self._cache.clear()
@property
def cache_size(self) -> int:
return len(self._cache)

View File

@ -0,0 +1,76 @@
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import (MessagePassing, global_add_pool,
global_max_pool, global_mean_pool)
from torch_geometric.utils import degree
from torch_scatter import scatter_add
from ogb.graphproppred.mol_encoder import AtomEncoder, BondEncoder
class GINConv(MessagePassing):
def __init__(self, emb_dim, aggr="add"):
super(GINConv, self).__init__(aggr=aggr)
self.mlp = torch.nn.Sequential(
torch.nn.Linear(emb_dim, 2*emb_dim),
torch.nn.BatchNorm1d(2*emb_dim),
torch.nn.ReLU(),
torch.nn.Linear(2*emb_dim, emb_dim)
)
self.eps = torch.nn.Parameter(torch.Tensor([0]))
self.bond_encoder = BondEncoder(emb_dim=emb_dim)
def forward(self, x, edge_index, edge_attr):
edge_embedding = self.bond_encoder(edge_attr)
out = self.mlp((1 + self.eps) * x + self.propagate(edge_index, x=x, edge_attr=edge_embedding))
return out
def message(self, x_j, edge_attr):
return F.relu(x_j + edge_attr)
def update(self, aggr_out):
return aggr_out
class GNN(nn.Module):
def __init__(self, num_layer, emb_dim, JK="last", drop_ratio=0., gnn_type="gin"):
if num_layer < 2:
raise ValueError("Number of GNN layers must be greater than 1.")
super(GNN, self).__init__()
self.drop_ratio = drop_ratio
self.num_layer = num_layer
self.JK = JK
self.atom_encoder = AtomEncoder(emb_dim)
self.gnns = nn.ModuleList()
for layer in range(num_layer):
if gnn_type == "gin":
self.gnns.append(GINConv(emb_dim, aggr="add"))
self.batch_norms = nn.ModuleList()
for layer in range(num_layer):
self.batch_norms.append(nn.BatchNorm1d(emb_dim))
def forward(self, *argv):
if len(argv) == 3:
x, edge_index, edge_attr = argv[0], argv[1], argv[2]
elif len(argv) == 1:
data = argv[0]
x, edge_index, edge_attr = data.x, data.edge_index, data.edge_attr
else:
raise ValueError("unmatched number of arguments.")
x = self.atom_encoder(x)
h_list = [x]
for layer in range(self.num_layer):
h = self.gnns[layer](h_list[layer], edge_index, edge_attr)
h = self.batch_norms[layer](h)
if layer == self.num_layer - 1:
h = F.dropout(h, self.drop_ratio, training=self.training)
else:
h = F.dropout(F.relu(h), self.drop_ratio, training=self.training)
h_list.append(h)
if self.JK == "last":
return h_list[-1]
elif self.JK == "sum":
h_list = [h.unsqueeze_(0) for h in h_list]
return torch.sum(torch.cat(h_list, dim=0), dim=0)[0]
else:
return h_list[-1]

View File

@ -0,0 +1,261 @@
"""
MoleculeSTM GNN 编码器
功能SMILES -> 分子图 -> GNN (frozen) -> global mean pooling -> Linear -> [B, d_model]
产出 V5 token追加到 LNPModel attention pooling 序列中
放置位置lnp_ml/modeling/encoders/moleculestm_encoder.py
依赖
pip install torch_geometric torch_scatter torch_sparse ogb
使用方式替换 llm_encoder.py接口完全一致
encoder = MoleculeSTMEncoder(
checkpoint_path="/content/drive/MyDrive/lnp_project_moleculestm/moleculestm_weights/pretrained_MoleculeSTM/SciBERT-Graph-3e-5-1-1e-4-1-InfoNCE-0.1-32-32/molecule_model.pth",
d_model=256,
device="cuda",
)
"""
import logging
from typing import Dict, List, Optional
import numpy as np
import torch
import torch.nn as nn
from torch_geometric.data import Data, Batch
logger = logging.getLogger(__name__)
# GNN 默认参数(与 MoleculeSTM 预训练时一致)
GNN_NUM_LAYER = 5
GNN_EMB_DIM = 300
GNN_JK = "last"
GNN_DROP_RATIO = 0.0
GNN_TYPE = "gin"
def smiles_to_graph(smiles: str) -> Optional[Data]:
"""
SMILES -> PyG Data 对象原子特征 + 键特征
使用 OGB 的原子/键编码方式 MoleculeSTM 预训练时一致
"""
from rdkit import Chem
from ogb.utils.mol import smiles2graph
try:
graph = smiles2graph(smiles)
if graph is None:
return None
x = torch.tensor(graph['node_feat'], dtype=torch.long)
edge_index = torch.tensor(graph['edge_index'], dtype=torch.long)
edge_attr = torch.tensor(graph['edge_feat'], dtype=torch.long)
return Data(x=x, edge_index=edge_index, edge_attr=edge_attr)
except Exception as e:
logger.warning(f"Failed to convert SMILES to graph: {smiles}, error: {e}")
return None
class MoleculeSTMEncoder(nn.Module):
"""
基于 MoleculeSTM GNN 的冻结分子编码器
流程
SMILES
-> RDKit + OGB 转分子图 Data
-> Batch 打包
-> GNN (frozen, 5 GIN, emb_dim=300)
-> global mean pooling -> [B, 300]
-> LayerNorm + Linear(300d_model) + ReLU + Dropout
-> [B, d_model] V5 token
Args:
checkpoint_path : molecule_model.pth 的路径
d_model : 输出维度 LNPModel.d_model 一致默认 256
dropout : projection dropout默认 0.1
device : 推理设备Colab 上用 "cuda"
use_cache : 是否缓存已编码 SMILES 的向量
"""
def __init__(
self,
checkpoint_path: str,
d_model: int = 256,
dropout: float = 0.1,
device: str = "cuda",
use_cache: bool = True,
) -> None:
super().__init__()
self.checkpoint_path = checkpoint_path
self.d_model = d_model
self.dropout_rate = dropout
self._device_str = device
self.use_cache = use_cache
# GNN延迟加载
self._gnn = None
self._initialized: bool = False
# SMILES -> pooled numpy 向量的内存缓存
self._cache: Dict[str, np.ndarray] = {}
# projection 层在 _lazy_init 后赋值
self.projection: Optional[nn.Sequential] = None
# ------------------------------------------------------------------ #
# 延迟初始化 #
# ------------------------------------------------------------------ #
def _lazy_init(self) -> None:
"""首次 forward 时加载 GNN 权重。"""
if self._initialized:
return
logger.info(f"[MoleculeSTMEncoder] Loading GNN from: {self.checkpoint_path}")
from lnp_ml.modeling.encoders.molecule_gnn_model import GNN
# 初始化 GNN
self._gnn = GNN(
num_layer=GNN_NUM_LAYER,
emb_dim=GNN_EMB_DIM,
JK=GNN_JK,
drop_ratio=GNN_DROP_RATIO,
gnn_type=GNN_TYPE,
)
# 加载预训练权重
# 权重保存在 GNN_graphpred 里key 有 molecule_node_model. 前缀,需要去掉
state_dict = torch.load(self.checkpoint_path, map_location="cpu")
new_state_dict = {}
for k, v in state_dict.items():
if k.startswith("molecule_node_model."):
new_state_dict[k[len("molecule_node_model."):]] = v
# 跳过 size 不匹配的 keyOGB 版本差异导致原子特征维度不同)
model_state = self._gnn.state_dict()
filtered = {
k: v for k, v in new_state_dict.items()
if k in model_state and v.shape == model_state[k].shape
}
model_state.update(filtered)
self._gnn.load_state_dict(model_state)
logger.info(f"[MoleculeSTMEncoder] Loaded {len(filtered)}/{len(new_state_dict)} keys from checkpoint")
self._gnn = self._gnn.to(self._device_str)
self._gnn.eval()
# 完全冻结 GNN
for param in self._gnn.parameters():
param.requires_grad = False
logger.info(f"[MoleculeSTMEncoder] GNN loaded, emb_dim={GNN_EMB_DIM}, frozen=True")
# projection 层(唯一可训练的部分)
self.projection = nn.Sequential(
nn.LayerNorm(GNN_EMB_DIM),
nn.Linear(GNN_EMB_DIM, self.d_model),
nn.ReLU(),
nn.Dropout(self.dropout_rate),
).to(self._device_str)
self._initialized = True
logger.info("[MoleculeSTMEncoder] Ready.")
# ------------------------------------------------------------------ #
# 内部编码 #
# ------------------------------------------------------------------ #
@torch.no_grad()
def _encode_batch_raw(self, smiles_list: List[str]) -> np.ndarray:
"""
对一批 SMILES GNN返回 mean-pooled numpy 向量
Returns:
np.ndarray shape [N, GNN_EMB_DIM] dtype float32
"""
from torch_geometric.nn import global_mean_pool
# SMILES -> 分子图
graphs = []
valid_indices = []
for i, smi in enumerate(smiles_list):
g = smiles_to_graph(smi)
if g is not None:
graphs.append(g)
valid_indices.append(i)
else:
logger.warning(f"Invalid SMILES skipped: {smi}")
if not graphs:
raise ValueError("All SMILES in batch are invalid")
# 打包成 batch
batch = Batch.from_data_list(graphs).to(self._device_str)
# GNN forward
node_repr = self._gnn(batch.x, batch.edge_index, batch.edge_attr)
# Global mean pooling -> [N_valid, emb_dim]
graph_repr = global_mean_pool(node_repr, batch.batch)
result = graph_repr.cpu().numpy().astype(np.float32)
# 如果有 invalid SMILES用零向量填充
if len(valid_indices) < len(smiles_list):
full_result = np.zeros((len(smiles_list), GNN_EMB_DIM), dtype=np.float32)
for out_idx, in_idx in enumerate(valid_indices):
full_result[in_idx] = result[out_idx]
return full_result
return result
# ------------------------------------------------------------------ #
# forward #
# ------------------------------------------------------------------ #
def forward(self, smiles_list: List[str]) -> torch.Tensor:
"""
Args:
smiles_list: 长度为 B SMILES 字符串列表
Returns:
torch.Tensor shape [B, d_model]
"""
self._lazy_init()
# 找出未缓存的 SMILES
to_compute = (
[s for s in smiles_list if s not in self._cache]
if self.use_cache
else list(smiles_list)
)
# 批量计算并写入缓存
if to_compute:
new_vecs = self._encode_batch_raw(to_compute)
if self.use_cache:
for smi, vec in zip(to_compute, new_vecs):
self._cache[smi] = vec
# 按原始顺序组装
pooled_np = (
np.stack([self._cache[s] for s in smiles_list])
if self.use_cache
else new_vecs
)
pooled = torch.from_numpy(pooled_np).to(self._device_str)
return self.projection(pooled)
# ------------------------------------------------------------------ #
# 工具属性 #
# ------------------------------------------------------------------ #
def clear_cache(self) -> None:
self._cache.clear()
@property
def cache_size(self) -> int:
return len(self._cache)

View File

@ -0,0 +1,203 @@
"""
MolT5 编码器
论文: "Translation between Molecules and Natural Language" (Edwards et al., EMNLP 2022)
模型: laituan245/molt5-base (109M 参数, T5 架构)
功能SMILES -> T5Encoder (frozen) -> mean pooling -> Linear -> [B, d_model]
产出 V5 token追加到 LNPModel attention pooling 序列中
放置位置lnp_ml/modeling/encoders/molt5_encoder.py
接口与 llm_encoder.py 完全一致models.py 只需修改初始化时的类名
"""
import logging
from typing import Dict, List, Optional
import numpy as np
import torch
import torch.nn as nn
logger = logging.getLogger(__name__)
MOLT5_MODEL_NAME = "laituan245/molt5-base"
MOLT5_HIDDEN_SIZE = 768 # molt5-base 的 hidden size
class MolT5Encoder(nn.Module):
"""
基于 MolT5-base 的冻结分子编码器
流程
SMILES 字符串列表 [B]
-> T5Tokenizer (molt5-base)
-> T5EncoderModel (frozen, 109M)
-> attention-mask mean pooling -> [B, 768]
-> LayerNorm + Linear(768->d_model) + ReLU + Dropout
-> [B, d_model] V5 token
Args:
model_path : HuggingFace model name 或本地路径默认 laituan245/molt5-base
d_model : 输出维度 LNPModel.d_model 一致默认 256
dropout : projection dropout默认 0.1
device : 推理设备
use_cache : 是否缓存已编码 SMILES 的向量
max_length : tokenizer 最大长度默认 512
"""
def __init__(
self,
model_path: str = MOLT5_MODEL_NAME,
d_model: int = 256,
dropout: float = 0.1,
device: str = "cuda",
use_cache: bool = True,
max_length: int = 512,
) -> None:
super().__init__()
self.model_path = model_path
self.d_model = d_model
self.dropout_rate = dropout
self._device_str = device
self.use_cache = use_cache
self.max_length = max_length
# 延迟加载
self._tokenizer = None
self._encoder = None
self._initialized: bool = False
# SMILES -> pooled numpy 向量的内存缓存
self._cache: Dict[str, np.ndarray] = {}
# projection 层在 _lazy_init 后赋值
self.projection: Optional[nn.Sequential] = None
# ------------------------------------------------------------------ #
# 延迟初始化 #
# ------------------------------------------------------------------ #
def _lazy_init(self) -> None:
if self._initialized:
return
logger.info(f"[MolT5Encoder] Loading MolT5 from: {self.model_path}")
from transformers import T5Tokenizer, T5EncoderModel
self._tokenizer = T5Tokenizer.from_pretrained(
self.model_path,
model_max_length=self.max_length,
)
self._encoder = T5EncoderModel.from_pretrained(self.model_path)
self._encoder = self._encoder.to(self._device_str)
self._encoder.eval()
# 完全冻结 T5 encoder
for param in self._encoder.parameters():
param.requires_grad = False
n_params = sum(p.numel() for p in self._encoder.parameters())
logger.info(
f"[MolT5Encoder] Loaded, params={n_params:,}, frozen=True"
)
# projection 层(唯一可训练部分)
self.projection = nn.Sequential(
nn.LayerNorm(MOLT5_HIDDEN_SIZE),
nn.Linear(MOLT5_HIDDEN_SIZE, self.d_model),
nn.ReLU(),
nn.Dropout(self.dropout_rate),
).to(self._device_str)
self._initialized = True
logger.info("[MolT5Encoder] Ready.")
# ------------------------------------------------------------------ #
# 内部编码 #
# ------------------------------------------------------------------ #
@torch.no_grad()
def _encode_batch_raw(self, smiles_list: List[str]) -> np.ndarray:
"""
对一批 SMILES T5Encoder返回 mean-pooled numpy 向量
Returns:
np.ndarray shape [N, MOLT5_HIDDEN_SIZE] dtype float32
"""
# MolT5 在 SMILES 前加 "SMILES: " 前缀效果更好(原论文做法)
inputs_text = [f"SMILES: {s}" for s in smiles_list]
encoding = self._tokenizer(
inputs_text,
return_tensors="pt",
padding=True,
truncation=True,
max_length=self.max_length,
)
input_ids = encoding["input_ids"].to(self._device_str)
attention_mask = encoding["attention_mask"].to(self._device_str)
outputs = self._encoder(
input_ids=input_ids,
attention_mask=attention_mask,
)
# last_hidden_state: [B, seq_len, hidden]
hidden = outputs.last_hidden_state
# attention-mask mean pooling
mask_expanded = attention_mask.unsqueeze(-1).float()
pooled = (hidden * mask_expanded).sum(dim=1) / mask_expanded.sum(dim=1).clamp(min=1e-9)
return pooled.cpu().numpy().astype(np.float32)
# ------------------------------------------------------------------ #
# forward #
# ------------------------------------------------------------------ #
def forward(self, smiles_list: List[str]) -> torch.Tensor:
"""
Args:
smiles_list: 长度为 B SMILES 字符串列表
Returns:
torch.Tensor shape [B, d_model]
"""
self._lazy_init()
# 找出未缓存的 SMILES
to_compute = (
[s for s in smiles_list if s not in self._cache]
if self.use_cache
else list(smiles_list)
)
# 批量计算并写入缓存
if to_compute:
new_vecs = self._encode_batch_raw(to_compute)
if self.use_cache:
for smi, vec in zip(to_compute, new_vecs):
self._cache[smi] = vec
# 按原始顺序组装
pooled_np = (
np.stack([self._cache[s] for s in smiles_list])
if self.use_cache
else new_vecs
)
pooled = torch.from_numpy(pooled_np).to(self._device_str)
return self.projection(pooled)
# ------------------------------------------------------------------ #
# 工具属性 #
# ------------------------------------------------------------------ #
def clear_cache(self) -> None:
self._cache.clear()
@property
def cache_size(self) -> int:
return len(self._cache)

View File

@ -0,0 +1,246 @@
"""
SELFIES-TED 编码器
论文: "A Large Encoder-Decoder Family of Foundation Models for Chemical Language" (IBM, 2024-2025)
模型: ibm/materials.selfies-ted (358M 参数, BART 架构, SELFIES 输入)
功能SMILES -> SELFIES -> BART encoder (frozen) -> mean pooling -> Linear -> [B, d_model]
产出 V5 token追加到 LNPModel attention pooling 序列中
放置位置lnp_ml/modeling/encoders/selfiested_encoder.py
依赖
pip install transformers selfies
注意
- SELFIES-TED hidden size 1024不是 BioT5/MolT5 768
- 输入需要先把 SMILES 转成 SELFIES转换失败时回退为零向量
"""
import logging
from typing import Dict, List, Optional
import numpy as np
import torch
import torch.nn as nn
logger = logging.getLogger(__name__)
SELFIESTED_MODEL_NAME = "ibm/materials.selfies-ted"
SELFIESTED_HIDDEN_SIZE = 1024 # selfies-ted 的 BART hidden size
def smiles_to_selfies(smiles: str) -> Optional[str]:
"""
SMILES -> SELFIES 字符串并按官方做法在 token 之间加空格
转换失败返回 None
"""
import selfies as sf
try:
selfies = sf.encoder(smiles)
if selfies is None:
return None
# 官方做法:把 "][" 替换成 "] [",让 tokenizer 正确分词
selfies = selfies.replace("][", "] [")
return selfies
except Exception as e:
logger.warning(f"Failed to convert SMILES to SELFIES: {smiles}, error: {e}")
return None
class SELFIESTEDEncoder(nn.Module):
"""
基于 SELFIES-TED 的冻结分子编码器
流程
SMILES 字符串列表 [B]
-> SMILES -> SELFIES 转换
-> SELFIES-TED tokenizer
-> BART encoder (frozen, 358M)
-> attention-mask mean pooling -> [B, 1024]
-> LayerNorm + Linear(1024->d_model) + ReLU + Dropout
-> [B, d_model] V5 token
Args:
model_path : HuggingFace model name 或本地路径默认 ibm/materials.selfies-ted
d_model : 输出维度 LNPModel.d_model 一致默认 256
dropout : projection dropout默认 0.1
device : 推理设备
use_cache : 是否缓存已编码 SMILES 的向量
max_length : tokenizer 最大长度默认 128官方示例用 128
"""
def __init__(
self,
model_path: str = SELFIESTED_MODEL_NAME,
d_model: int = 256,
dropout: float = 0.1,
device: str = "cuda",
use_cache: bool = True,
max_length: int = 128,
) -> None:
super().__init__()
self.model_path = model_path
self.d_model = d_model
self.dropout_rate = dropout
self._device_str = device
self.use_cache = use_cache
self.max_length = max_length
# 延迟加载
self._tokenizer = None
self._model = None
self._initialized: bool = False
# SMILES -> pooled numpy 向量的内存缓存
self._cache: Dict[str, np.ndarray] = {}
# projection 层在 _lazy_init 后赋值
self.projection: Optional[nn.Sequential] = None
# ------------------------------------------------------------------ #
# 延迟初始化 #
# ------------------------------------------------------------------ #
def _lazy_init(self) -> None:
if self._initialized:
return
logger.info(f"[SELFIESTEDEncoder] Loading SELFIES-TED from: {self.model_path}")
from transformers import AutoTokenizer, AutoModel
self._tokenizer = AutoTokenizer.from_pretrained(self.model_path)
self._model = AutoModel.from_pretrained(self.model_path)
self._model = self._model.to(self._device_str)
self._model.eval()
# 完全冻结 BART 模型
for param in self._model.parameters():
param.requires_grad = False
n_params = sum(p.numel() for p in self._model.parameters())
logger.info(
f"[SELFIESTEDEncoder] Loaded, params={n_params:,}, frozen=True"
)
# projection 层(唯一可训练部分),注意输入维度是 1024
self.projection = nn.Sequential(
nn.LayerNorm(SELFIESTED_HIDDEN_SIZE),
nn.Linear(SELFIESTED_HIDDEN_SIZE, self.d_model),
nn.ReLU(),
nn.Dropout(self.dropout_rate),
).to(self._device_str)
self._initialized = True
logger.info("[SELFIESTEDEncoder] Ready.")
# ------------------------------------------------------------------ #
# 内部编码 #
# ------------------------------------------------------------------ #
@torch.no_grad()
def _encode_batch_raw(self, smiles_list: List[str]) -> np.ndarray:
"""
对一批 SMILES SELFIES-TED encoder返回 mean-pooled numpy 向量
Returns:
np.ndarray shape [N, SELFIESTED_HIDDEN_SIZE] dtype float32
"""
# SMILES -> SELFIES记录哪些转换成功
selfies_list = []
valid_indices = []
for i, smi in enumerate(smiles_list):
s = smiles_to_selfies(smi)
if s is not None:
selfies_list.append(s)
valid_indices.append(i)
else:
logger.warning(f"SMILES->SELFIES failed, will use zero vector: {smi}")
if not selfies_list:
# 全部转换失败,返回全零
return np.zeros((len(smiles_list), SELFIESTED_HIDDEN_SIZE), dtype=np.float32)
token = self._tokenizer(
selfies_list,
return_tensors='pt',
max_length=self.max_length,
truncation=True,
padding='max_length',
)
input_ids = token['input_ids'].to(self._device_str)
attention_mask = token['attention_mask'].to(self._device_str)
# 只用 encoder
outputs = self._model.encoder(
input_ids=input_ids,
attention_mask=attention_mask,
)
hidden = outputs.last_hidden_state # [N_valid, seq_len, 1024]
# attention-mask mean pooling
mask_expanded = attention_mask.unsqueeze(-1).float()
pooled = (hidden * mask_expanded).sum(dim=1) / mask_expanded.sum(dim=1).clamp(min=1e-9)
result = pooled.cpu().numpy().astype(np.float32)
# 如果有转换失败的,用零向量填充回原始位置
if len(valid_indices) < len(smiles_list):
full_result = np.zeros((len(smiles_list), SELFIESTED_HIDDEN_SIZE), dtype=np.float32)
for out_idx, in_idx in enumerate(valid_indices):
full_result[in_idx] = result[out_idx]
return full_result
return result
# ------------------------------------------------------------------ #
# forward #
# ------------------------------------------------------------------ #
def forward(self, smiles_list: List[str]) -> torch.Tensor:
"""
Args:
smiles_list: 长度为 B SMILES 字符串列表
Returns:
torch.Tensor shape [B, d_model]
"""
self._lazy_init()
# 找出未缓存的 SMILES
to_compute = (
[s for s in smiles_list if s not in self._cache]
if self.use_cache
else list(smiles_list)
)
# 批量计算并写入缓存
if to_compute:
new_vecs = self._encode_batch_raw(to_compute)
if self.use_cache:
for smi, vec in zip(to_compute, new_vecs):
self._cache[smi] = vec
# 按原始顺序组装
pooled_np = (
np.stack([self._cache[s] for s in smiles_list])
if self.use_cache
else new_vecs
)
pooled = torch.from_numpy(pooled_np).to(self._device_str)
return self.projection(pooled)
# ------------------------------------------------------------------ #
# 工具属性 #
# ------------------------------------------------------------------ #
def clear_cache(self) -> None:
self._cache.clear()
@property
def cache_size(self) -> int:
return len(self._cache)

View File

@ -176,8 +176,20 @@ def create_model(
dropout: float = 0.1,
use_mpnn: bool = False,
mpnn_device: str = "cpu",
# ============ LLM 相关 ============
use_llm: bool = False,
llm_type: str = "biot5",
llm_model_path: str = "",
llm_device: str = "cuda",
) -> Union[LNPModel, LNPModelWithoutMPNN]:
"""创建模型"""
llm_kwargs = dict(
use_llm=use_llm,
llm_type=llm_type,
llm_model_path=llm_model_path,
llm_device=llm_device,
)
if use_mpnn:
ensemble_paths = find_mpnn_ensemble_paths()
return LNPModel(
@ -189,6 +201,7 @@ def create_model(
dropout=dropout,
mpnn_ensemble_paths=ensemble_paths,
mpnn_device=mpnn_device,
**llm_kwargs,
)
else:
return LNPModelWithoutMPNN(
@ -198,6 +211,7 @@ def create_model(
fusion_strategy=fusion_strategy,
head_hidden_dim=head_hidden_dim,
dropout=dropout,
**llm_kwargs,
)
@ -232,7 +246,6 @@ def load_pretrain_weights_to_model(
# ============ 3-fold Optuna 调参 ============
def run_optuna_cv(
full_dataset: LNPDataset,
strata: np.ndarray,
@ -249,6 +262,11 @@ def run_optuna_cv(
pretrain_config: Optional[Dict] = None,
load_delivery_head: bool = True,
rdkit_cache: Optional[Dict] = None,
# ============ LLM 相关 ============
use_llm: bool = False,
llm_type: str = "biot5",
llm_model_path: str = "",
llm_device: str = "cuda",
) -> Tuple[Dict, int, optuna.Study]:
"""
使用全量数据做 3-fold CV Optuna 超参搜索
@ -335,6 +353,10 @@ def run_optuna_cv(
dropout=dropout,
use_mpnn=use_mpnn,
mpnn_device=device.type,
use_llm=use_llm,
llm_type=llm_type,
llm_model_path=llm_model_path,
llm_device=llm_device,
)
if rdkit_cache is not None:
model.rdkit_encoder._cache = rdkit_cache
@ -404,7 +426,6 @@ def run_optuna_cv(
# ============ 主流程 ============
@app.command()
def main(
input_path: Path = INTERIM_DATA_DIR / "internal.csv",
@ -427,6 +448,11 @@ def main(
load_delivery_head: bool = False,
# MPNN
use_mpnn: bool = False,
# LLM新增
use_llm: bool = False,
llm_type: str = "biot5",
llm_model_path: str = "",
llm_device: str = "cuda",
# 设备
device: str = "cuda" if torch.cuda.is_available() else "cpu",
):
@ -507,6 +533,10 @@ def main(
pretrain_config=pretrain_config,
load_delivery_head=load_delivery_head,
rdkit_cache=rdkit_cache,
use_llm=use_llm,
llm_type=llm_type,
llm_model_path=llm_model_path,
llm_device=llm_device,
)
# 保存最佳参数
@ -564,6 +594,10 @@ def main(
dropout=best_params["dropout"],
use_mpnn=use_mpnn,
mpnn_device=device.type,
use_llm=use_llm,
llm_type=llm_type,
llm_model_path=llm_model_path,
llm_device=llm_device,
)
model.rdkit_encoder._cache = rdkit_cache
@ -611,8 +645,12 @@ def main(
"head_hidden_dim": best_params["head_hidden_dim"],
"dropout": best_params["dropout"],
"use_mpnn": use_mpnn,
"use_llm": use_llm,
"llm_type": llm_type,
"llm_model_path": llm_model_path,
"llm_device": llm_device,
}
torch.save({
"model_state_dict": train_result["final_state"],
"config": config,
@ -655,5 +693,4 @@ def main(
if __name__ == "__main__":
app()
app()

View File

@ -1,4 +1,18 @@
"""LNP 多任务预测模型"""
"""LNP 多任务预测模型
本文件在原始 LNPModel 基础上新增了可选的 LLM 分子编码器分支V5 token
LLM 分支说明
- 默认 use_llm=False模型行为与原版完全一致不影响既有功能
- use_llm=True 通过 llm_type 选择一个分子编码器 SMILES 编码成
一个额外的 V5 token追加到 fusion 序列末尾n_tokens + 1
- 支持的 llm_type:
"biot5" -> BioT5-plus (encoders/llm_encoder.py)
"molt5" -> MolT5 (encoders/molt5_encoder.py)
"moleculestm" -> MoleculeSTM GNN (encoders/moleculestm_encoder.py)
"selfiested" -> SELFIES-TED (encoders/selfiested_encoder.py)
- LLM 主体全程冻结仅其内部 projection 层参与训练
"""
import torch
import torch.nn as nn
@ -10,6 +24,7 @@ from lnp_ml.modeling.heads import MultiTaskHead
PoolingStrategy = Literal["concat", "avg", "max", "attention"]
LLMType = Literal["biot5", "molt5", "moleculestm", "selfiested"]
# Token 维度配置(根据 ARCHITECTURE.md
@ -18,7 +33,7 @@ DEFAULT_INPUT_DIMS = {
"mpnn": 600, # D-MPNN embedding
"morgan": 1024, # Morgan fingerprint
"maccs": 167, # MACCS keys
"desc": 210, # RDKit descriptors
"desc": 217, # RDKit descriptors新版 RDKit 为 217 维)
# Channel B: 配方/实验条件
"comp": 5, # 配方比例
"phys": 12, # 物理参数 one-hot
@ -30,6 +45,64 @@ DEFAULT_INPUT_DIMS = {
TOKEN_ORDER = ["mpnn", "morgan", "maccs", "desc", "comp", "phys", "help", "exp"]
def build_llm_encoder(
llm_type: str,
llm_model_path: str,
d_model: int,
dropout: float,
llm_device: str,
) -> nn.Module:
"""
LLM encoder 工厂根据 llm_type 实例化对应的分子编码器
四个 encoder 接口一致forward(List[str]) -> [B, d_model]
内部 LLM 冻结 projection 可训练
注意不同 encoder 的路径参数名不同
biot5 / molt5 / selfiested -> model_path
moleculestm -> checkpoint_path
"""
llm_type = llm_type.lower()
if llm_type == "biot5":
from lnp_ml.modeling.encoders import LLMEncoder
return LLMEncoder(
model_path=llm_model_path,
d_model=d_model,
dropout=dropout,
device=llm_device,
)
elif llm_type == "molt5":
from lnp_ml.modeling.encoders import MolT5Encoder
return MolT5Encoder(
model_path=llm_model_path,
d_model=d_model,
dropout=dropout,
device=llm_device,
)
elif llm_type == "moleculestm":
from lnp_ml.modeling.encoders import MoleculeSTMEncoder
return MoleculeSTMEncoder(
checkpoint_path=llm_model_path,
d_model=d_model,
dropout=dropout,
device=llm_device,
)
elif llm_type == "selfiested":
from lnp_ml.modeling.encoders import SELFIESTEDEncoder
return SELFIESTEDEncoder(
model_path=llm_model_path,
d_model=d_model,
dropout=dropout,
device=llm_device,
)
else:
raise ValueError(
f"Unknown llm_type: {llm_type!r}. "
f"Choose from: biot5, molt5, moleculestm, selfiested"
)
class LNPModel(nn.Module):
"""
LNP 药物递送性能预测模型
@ -37,10 +110,11 @@ class LNPModel(nn.Module):
架构流程:
1. Encoders: SMILES -> 化学特征; tabular -> 配方/实验特征
2. TokenProjector: 统一到 d_model
3. Stack: [B, 8, d_model]
3. Stack: [B, n_tokens, d_model]
4. CrossModalAttention: Channel A (化学) <-> Channel B (配方/实验)
5. FusionLayer: [B, 8, d_model] -> [B, fusion_dim]
6. MultiTaskHead: 多任务预测
5. (可选) LLM 分支: SMILES -> V5 token追加到序列末尾
6. FusionLayer: [B, n_tokens(+1), d_model] -> [B, fusion_dim]
7. MultiTaskHead: 多任务预测
"""
def __init__(
@ -62,12 +136,19 @@ class LNPModel(nn.Module):
mpnn_device: str = "cpu",
# 输入维度配置
input_dims: Optional[Dict[str, int]] = None,
# ============ LLM 分支(可选) ============
use_llm: bool = False,
llm_type: Optional[str] = None,
llm_model_path: str = "",
llm_device: str = "cuda",
) -> None:
super().__init__()
self.input_dims = input_dims or DEFAULT_INPUT_DIMS
self.d_model = d_model
self.use_mpnn = mpnn_checkpoint is not None or mpnn_ensemble_paths is not None
self.use_llm = use_llm
self.llm_type = llm_type
# ============ Encoders ============
# RDKit encoder (always used)
@ -83,6 +164,20 @@ class LNPModel(nn.Module):
else:
self.mpnn_encoder = None
# ============ LLM encoder (optional) ============
if self.use_llm:
if not llm_type:
raise ValueError("use_llm=True 时必须指定 llm_type")
self.llm_encoder = build_llm_encoder(
llm_type=llm_type,
llm_model_path=llm_model_path,
d_model=d_model,
dropout=dropout,
llm_device=llm_device,
)
else:
self.llm_encoder = None
# ============ Token Projector ============
# 根据是否使用 MPNN 调整输入维度
proj_input_dims = {k: v for k, v in self.input_dims.items()}
@ -108,9 +203,13 @@ class LNPModel(nn.Module):
)
# ============ Fusion Layer ============
# 启用 LLM 时fusion 序列多一个 V5 token
n_fusion_tokens = n_tokens + 1 if self.use_llm else n_tokens
self.n_fusion_tokens = n_fusion_tokens
self.fusion = FusionLayer(
d_model=d_model,
n_tokens=n_tokens,
n_tokens=n_fusion_tokens,
strategy=fusion_strategy,
)
@ -128,39 +227,35 @@ class LNPModel(nn.Module):
) -> torch.Tensor:
"""
内部方法编码 SMILES tabular返回 stacked tokens
Returns:
stacked: [B, n_tokens, d_model]
"""
# 获取目标设备(从 tabular 数据推断)
device = tabular["comp"].device
# 1. Encode SMILES
rdkit_features = self.rdkit_encoder(smiles) # {"morgan", "maccs", "desc"}
rdkit_features = self.rdkit_encoder(smiles)
# 2. 合并所有特征
all_features: Dict[str, torch.Tensor] = {}
# MPNN 特征(如果启用)
if self.use_mpnn:
mpnn_features = self.mpnn_encoder(smiles)
all_features["mpnn"] = mpnn_features["mpnn"].to(device)
# RDKit 特征(移到正确设备)
all_features["morgan"] = rdkit_features["morgan"].to(device)
all_features["maccs"] = rdkit_features["maccs"].to(device)
all_features["desc"] = rdkit_features["desc"].to(device)
# Tabular 特征(已在正确设备上)
all_features["comp"] = tabular["comp"]
all_features["phys"] = tabular["phys"]
all_features["help"] = tabular["help"]
all_features["exp"] = tabular["exp"]
# 3. Token Projector: 统一维度
projected = self.token_projector(all_features) # Dict[str, [B, d_model]]
# 3. Token Projector
projected = self.token_projector(all_features)
# 4. Stack tokens: [B, n_tokens, d_model]
# 4. Stack tokens
if self.use_mpnn:
token_order = ["mpnn", "morgan", "maccs", "desc", "comp", "phys", "help", "exp"]
else:
@ -169,6 +264,28 @@ class LNPModel(nn.Module):
stacked = torch.stack([projected[k] for k in token_order], dim=1)
return stacked
def _attended_with_llm(
self,
smiles: List[str],
tabular: Dict[str, torch.Tensor],
) -> torch.Tensor:
"""
编码 -> 投影 -> cross-attention -> (可选) 追加 V5 token
Returns:
attended: [B, n_tokens, d] (use_llm=False)
[B, n_tokens + 1, d] (use_llm=True)
"""
stacked = self._encode_and_project(smiles, tabular)
attended = self.cross_attention(stacked)
if self.use_llm:
device = attended.device
v5 = self.llm_encoder(smiles).to(device) # [B, d]
attended = torch.cat([attended, v5.unsqueeze(1)], dim=1) # [B, n+1, d]
return attended
def forward_from_projected(
self,
stacked: torch.Tensor,
@ -176,14 +293,7 @@ class LNPModel(nn.Module):
) -> torch.Tensor:
"""
从已投影的 stacked tokens 开始 forward用于 Captum 归因
Args:
stacked: [B, n_tokens, d_model] TokenProjector 输出后 stack 的张量
task: 指定单任务名 ("size", "pdi", "ee", "delivery", "biodist", "toxic")
若为 None返回 delivery head 的标量输出
Returns:
[B, 1] [B, num_classes] 对应任务的预测输出
注意该路径不经过 LLM 分支归因针对主干 token
"""
attended = self.cross_attention(stacked)
fused = self.fusion(attended)
@ -208,23 +318,10 @@ class LNPModel(nn.Module):
base_projected: torch.Tensor,
task: Optional[str] = None,
) -> torch.Tensor:
"""
用原始特征替换 base_projected 中指定 token 的投影然后 forward
用于对单个 token 内部特征做 Captum 归因 desc 210
Args:
raw_feature: [B, input_dim] 某个 token 的原始特征
feature_key: token 名称 "desc"
base_projected: [B, n_tokens, d_model] 其他 token 已投影好的张量
task: 任务名
Returns:
对应任务的预测输出
"""
"""用原始特征替换指定 token 的投影后 forwardCaptum 归因用)。"""
projected = self.token_projector.projectors[feature_key](raw_feature)
gate = torch.sigmoid(self.token_projector.weights[feature_key])
projected = projected * gate # [B, d_model]
projected = projected * gate
token_order = list(self.token_projector.keys)
token_idx = token_order.index(feature_key)
@ -240,26 +337,10 @@ class LNPModel(nn.Module):
tabular: Dict[str, torch.Tensor],
) -> torch.Tensor:
"""
Backbone forward编码 -> 投影 -> 注意力 -> 融合不经过任务头
用于 pretrain 阶段或需要提取特征的场景
Args:
smiles: SMILES 字符串列表长度为 B
tabular: Dict[str, Tensor]
Returns:
fused: [B, fusion_dim] 融合后的特征向量
Backbone forward编码 -> 投影 -> 注意力 ->可选 V5-> 融合不经过任务头
"""
# 编码 + 投影 + stack
stacked = self._encode_and_project(smiles, tabular)
# Cross Modal Attention
attended = self.cross_attention(stacked)
# Fusion
attended = self._attended_with_llm(smiles, tabular)
fused = self.fusion(attended)
return fused
def forward_delivery(
@ -267,16 +348,7 @@ class LNPModel(nn.Module):
smiles: List[str],
tabular: Dict[str, torch.Tensor],
) -> torch.Tensor:
"""
仅预测 delivery用于 pretrain
Args:
smiles: SMILES 字符串列表长度为 B
tabular: Dict[str, Tensor]
Returns:
delivery: [B, 1] 预测的 delivery
"""
"""仅预测 delivery用于 pretrain"""
fused = self.forward_backbone(smiles, tabular)
return self.head.delivery_head(fused)
@ -285,26 +357,7 @@ class LNPModel(nn.Module):
smiles: List[str],
tabular: Dict[str, torch.Tensor],
) -> Dict[str, torch.Tensor]:
"""
完整的多任务 forward
Args:
smiles: SMILES 字符串列表长度为 B
tabular: Dict[str, Tensor]包含:
- "comp": [B, 5] 配方比例
- "phys": [B, 12] 物理参数
- "help": [B, 4] Helper lipid
- "exp": [B, 32] 实验条件
Returns:
Dict[str, Tensor]:
- "size": [B, 1]
- "pdi": [B, 4]
- "ee": [B, 3]
- "delivery": [B, 1]
- "biodist": [B, 7]
- "toxic": [B, 2]
"""
"""完整的多任务 forward。"""
fused = self.forward_backbone(smiles, tabular)
outputs = self.head(fused)
return outputs
@ -314,22 +367,18 @@ class LNPModel(nn.Module):
self.rdkit_encoder.clear_cache()
if self.mpnn_encoder is not None:
self.mpnn_encoder.clear_cache()
if self.llm_encoder is not None and hasattr(self.llm_encoder, "clear_cache"):
self.llm_encoder.clear_cache()
def get_backbone_state_dict(self) -> Dict[str, torch.Tensor]:
"""
获取 backbone 部分的 state_dict不含任务头
包含: token_projector, cross_attention, fusion
"""
"""获取 backbone 部分的 state_dict不含任务头"""
backbone_keys = []
for name in self.state_dict().keys():
if name.startswith(("token_projector.", "cross_attention.", "fusion.")):
if name.startswith(("token_projector.", "cross_attention.", "fusion.", "llm_encoder.")):
backbone_keys.append(name)
return {k: v for k, v in self.state_dict().items() if k in backbone_keys}
def get_delivery_head_state_dict(self) -> Dict[str, torch.Tensor]:
"""获取 delivery head 的 state_dict"""
return {
k: v for k, v in self.state_dict().items()
if k.startswith("head.delivery_head.")
@ -341,28 +390,17 @@ class LNPModel(nn.Module):
load_delivery_head: bool = True,
strict: bool = False,
) -> None:
"""
从预训练 checkpoint 加载 backbone 可选delivery head 权重
Args:
pretrain_state_dict: 预训练模型的 state_dict
load_delivery_head: 是否加载 delivery head 权重
strict: 是否严格匹配默认 False允许缺失/多余的键
"""
# 筛选要加载的参数
"""从预训练 checkpoint 加载 backbone 和可选delivery head 权重。"""
keys_to_load = []
for name in pretrain_state_dict.keys():
# Backbone 部分
if name.startswith(("token_projector.", "cross_attention.", "fusion.")):
if name.startswith(("token_projector.", "cross_attention.", "fusion.", "llm_encoder.")):
keys_to_load.append(name)
# Delivery head可选
elif load_delivery_head and name.startswith("head.delivery_head."):
keys_to_load.append(name)
filtered_state_dict = {k: v for k, v in pretrain_state_dict.items() if k in keys_to_load}
# 加载权重
missing, unexpected = [], []
unexpected = []
model_state = self.state_dict()
for k, v in filtered_state_dict.items():
if k in model_state:
@ -372,15 +410,15 @@ class LNPModel(nn.Module):
unexpected.append(f"{k} (shape mismatch: {model_state[k].shape} vs {v.shape})")
else:
unexpected.append(k)
self.load_state_dict(model_state, strict=False)
if strict and (missing or unexpected):
raise RuntimeError(f"Missing keys: {missing}, Unexpected keys: {unexpected}")
if strict and unexpected:
raise RuntimeError(f"Unexpected keys: {unexpected}")
class LNPModelWithoutMPNN(LNPModel):
"""不使用 MPNN 的简化版本"""
"""不使用 MPNN 的简化版本(实验默认使用此类)。"""
def __init__(
self,
@ -391,6 +429,11 @@ class LNPModelWithoutMPNN(LNPModel):
head_hidden_dim: int = 128,
dropout: float = 0.1,
input_dims: Optional[Dict[str, int]] = None,
# ============ LLM 分支(可选) ============
use_llm: bool = False,
llm_type: Optional[str] = None,
llm_model_path: str = "",
llm_device: str = "cuda",
) -> None:
# 移除 mpnn 维度
dims = input_dims or DEFAULT_INPUT_DIMS.copy()
@ -406,5 +449,8 @@ class LNPModelWithoutMPNN(LNPModel):
mpnn_checkpoint=None,
mpnn_ensemble_paths=None,
input_dims=dims,
use_llm=use_llm,
llm_type=llm_type,
llm_model_path=llm_model_path,
llm_device=llm_device,
)

View File

@ -225,6 +225,11 @@ def main(
mpnn_checkpoint: Optional[str] = None,
mpnn_ensemble_paths: Optional[str] = None, # 逗号分隔的路径列表
mpnn_device: str = "cpu",
# ============ LLM 参数(可选) ============
use_llm: bool = False,
llm_type: str = "biot5",
llm_model_path: str = "",
llm_device: str = "cuda",
# 训练参数
batch_size: int = 64,
lr: float = 1e-4,
@ -292,6 +297,10 @@ def main(
mpnn_checkpoint=mpnn_checkpoint,
mpnn_ensemble_paths=ensemble_paths_list,
mpnn_device=mpnn_device,
use_llm=use_llm,
llm_type=llm_type,
llm_model_path=llm_model_path,
llm_device=llm_device,
)
else:
model = LNPModelWithoutMPNN(
@ -301,6 +310,10 @@ def main(
fusion_strategy=fusion_strategy,
head_hidden_dim=head_hidden_dim,
dropout=dropout,
use_llm=use_llm,
llm_type=llm_type,
llm_model_path=llm_model_path,
llm_device=llm_device,
)
n_params_total = sum(p.numel() for p in model.parameters())
@ -340,6 +353,10 @@ def main(
"head_hidden_dim": head_hidden_dim,
"dropout": dropout,
"use_mpnn": enable_mpnn,
"use_llm": use_llm,
"llm_type": llm_type,
"llm_model_path": llm_model_path,
"llm_device": llm_device,
},
"best_val_loss": result["best_val_loss"],
},
@ -412,6 +429,10 @@ def test(
dropout=config["dropout"],
mpnn_ensemble_paths=ensemble_paths,
mpnn_device=mpnn_device,
use_llm=config.get("use_llm", False),
llm_type=config.get("llm_type", "biot5"),
llm_model_path=config.get("llm_model_path", ""),
llm_device=config.get("llm_device", "cuda"),
)
else:
model = LNPModelWithoutMPNN(
@ -421,6 +442,10 @@ def test(
fusion_strategy=config["fusion_strategy"],
head_hidden_dim=config["head_hidden_dim"],
dropout=config["dropout"],
use_llm=config.get("use_llm", False),
llm_type=config.get("llm_type", "biot5"),
llm_model_path=config.get("llm_model_path", ""),
llm_device=config.get("llm_device", "cuda"),
)
model.load_state_dict(checkpoint["model_state_dict"])

280
verify_llm_encoder.py Normal file
View File

@ -0,0 +1,280 @@
"""
LLM 编码器验证脚本统一版
对四种 LLM encoder 中的任意一种做四项检查
TEST 1: encoder 独立输出shape / NaN / 梯度 / 缓存 / batch 一致性
TEST 2: LNPModel 集成任务头维度 / biodist 和为1 / 反向传播 / fusion token
TEST 3: 可复现性同种子相同输出不同种子不同输出
TEST 4: 过拟合 sanity小数据上 loss 下降 > 0
用法:
python verify_llm_encoder.py \
--model_path <llm_model_path> \
--llm_type <biot5|molt5|moleculestm|selfiested> \
--lnp_repo_path <仓库根目录>
不同 encoder 的可训练主体属性名不同本脚本会按 llm_type 自动选择
biot5 -> encoder._llm
molt5 -> encoder._encoder
moleculestm -> encoder._gnn
selfiested -> encoder._model
"""
import argparse
import sys
import random
import numpy as np
import torch
import torch.nn as nn
# llm_type -> (encoder 模块, 类名, 冻结主体属性名)
LLM_REGISTRY = {
"biot5": ("lnp_ml.modeling.encoders.llm_encoder", "LLMEncoder", "_llm"),
"molt5": ("lnp_ml.modeling.encoders.molt5_encoder", "MolT5Encoder", "_encoder"),
"moleculestm": ("lnp_ml.modeling.encoders.moleculestm_encoder", "MoleculeSTMEncoder", "_gnn"),
"selfiested": ("lnp_ml.modeling.encoders.selfiested_encoder", "SELFIESTEDEncoder", "_model"),
}
def set_seed(seed: int = 42) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
print(f"[Seed] All seeds fixed to {seed}")
def build_encoder(llm_type, model_path, d_model, device):
"""按 llm_type 构造 encoder处理 model_path / checkpoint_path 参数名差异。"""
import importlib
module_name, class_name, _ = LLM_REGISTRY[llm_type]
module = importlib.import_module(module_name)
EncoderCls = getattr(module, class_name)
if llm_type == "moleculestm":
return EncoderCls(checkpoint_path=model_path, d_model=d_model, device=device)
else:
return EncoderCls(model_path=model_path, d_model=d_model, device=device)
def get_frozen_body(encoder, llm_type):
"""取出该 encoder 的冻结主体(用于检查参数是否冻结)。"""
_, _, attr = LLM_REGISTRY[llm_type]
return getattr(encoder, attr)
SAMPLE_SMILES = [
"CCCCCCCCCCCCCCCC(=O)OCC(COC(=O)CCCCCCCCCCCCCCC)OC(=O)CCCCCCC",
"CCN(CC)CCOC(=O)C1=CC=CC=C1",
"CC(C)(C)OC(=O)N1CCC(CC1)C(=O)O",
"O=C(O)c1ccccc1O",
]
def make_tabular(batch_size, device):
return {
"comp": torch.randn(batch_size, 5, device=device),
"phys": torch.randn(batch_size, 12, device=device),
"help": torch.randn(batch_size, 4, device=device),
"exp": torch.randn(batch_size, 32, device=device),
}
def test_1_standalone(llm_type, model_path, d_model, device):
print("\n" + "=" * 60)
print("TEST 1: encoder standalone")
print("=" * 60)
print("\n[1a] Testing output shape...")
encoder = build_encoder(llm_type, model_path, d_model, device)
out = encoder(SAMPLE_SMILES)
assert out.shape == (len(SAMPLE_SMILES), d_model), f"bad shape {out.shape}"
print(f" Output shape: {out.shape} OK")
print("[1b] Checking for NaN/Inf...")
assert not torch.isnan(out).any() and not torch.isinf(out).any()
print(f" No NaN/Inf OK")
print(f" Output stats: mean={out.mean():.4f}, std={out.std():.4f}, "
f"min={out.min():.4f}, max={out.max():.4f}")
print("[1c] Checking gradients...")
body = get_frozen_body(encoder, llm_type)
n_trainable_body = sum(p.numel() for p in body.parameters() if p.requires_grad)
assert n_trainable_body == 0, f"frozen body has {n_trainable_body} trainable params"
print(f" Frozen body ({0} trainable params) OK")
n_proj = sum(p.numel() for p in encoder.projection.parameters() if p.requires_grad)
assert n_proj > 0
print(f" Projection trainable ({n_proj:,} params) OK")
loss = encoder(SAMPLE_SMILES).sum()
loss.backward()
grad_ok = any(p.grad is not None for p in encoder.projection.parameters())
assert grad_ok
print(f" Gradient flows to projection OK")
print("[1e] Single vs batch consistency...")
encoder.eval()
with torch.no_grad():
batch_out = encoder(SAMPLE_SMILES)
single_out = torch.cat([encoder([s]) for s in SAMPLE_SMILES], dim=0)
assert torch.allclose(batch_out, single_out, atol=1e-4)
print(f" Batch == single OK")
print("\nTEST 1 PASSED")
def test_2_integration(llm_type, model_path, d_model, device):
print("\n" + "=" * 60)
print("TEST 2: Integration with LNPModel")
print("=" * 60)
from lnp_ml.modeling.models import LNPModelWithoutMPNN
model = LNPModelWithoutMPNN(
d_model=d_model, use_llm=True, llm_type=llm_type,
llm_model_path=model_path, llm_device=device,
).to(device)
print("\n[2a] Testing full forward pass shapes...")
tab = make_tabular(len(SAMPLE_SMILES), device)
outputs = model(SAMPLE_SMILES, tab)
expected = {"size": 1, "pdi": 4, "ee": 3, "delivery": 1, "biodist": 7, "toxic": 2}
for k, dim in expected.items():
assert outputs[k].shape == (len(SAMPLE_SMILES), dim), f"{k}: {outputs[k].shape}"
print(f" outputs['{k}']: {tuple(outputs[k].shape)} OK")
print("[2b] Checking biodist sums to 1...")
sums = outputs["biodist"].sum(dim=-1)
assert torch.allclose(sums, torch.ones_like(sums), atol=1e-4)
print(f" biodist sums: {[round(s.item(),4) for s in sums]} OK")
print("[2c] Testing backward pass...")
loss = sum(o.sum() for o in outputs.values())
loss.backward()
proj_grad = any(p.grad is not None for p in model.llm_encoder.projection.parameters())
assert proj_grad
print(f" llm_encoder.projection gets gradients OK")
body = get_frozen_body(model.llm_encoder, llm_type)
body_grad = any(p.grad is not None and p.grad.abs().sum() > 0 for p in body.parameters())
assert not body_grad
print(f" LLM body has no gradients (frozen) OK")
print("[2d] Checking fusion token count...")
# 无 MPNN 时主干 7 个 token + 1 个 V5 = 8
assert model.n_fusion_tokens == 8, f"n_fusion_tokens={model.n_fusion_tokens}"
print(f" fusion.n_tokens={model.n_fusion_tokens} OK")
print("\nTEST 2 PASSED")
def test_3_reproducibility(llm_type, model_path, d_model, device):
print("\n" + "=" * 60)
print("TEST 3: Reproducibility")
print("=" * 60)
print("\n[3a] Running twice with same seed=42...")
set_seed(42)
enc1 = build_encoder(llm_type, model_path, d_model, device)
enc1.eval()
with torch.no_grad():
o1 = enc1(SAMPLE_SMILES)
set_seed(42)
enc2 = build_encoder(llm_type, model_path, d_model, device)
enc2.eval()
with torch.no_grad():
o2 = enc2(SAMPLE_SMILES)
assert torch.allclose(o1, o2, atol=1e-5)
print(f" Same seed -> identical outputs OK")
print("[3b] Running with different seeds...")
set_seed(123)
enc3 = build_encoder(llm_type, model_path, d_model, device)
enc3.eval()
with torch.no_grad():
o3 = enc3(SAMPLE_SMILES)
assert not torch.allclose(o1, o3, atol=1e-5)
print(f" Different seeds -> different outputs OK")
print("\nTEST 3 PASSED")
def test_4_overfit(llm_type, model_path, d_model, device):
print("\n" + "=" * 60)
print("TEST 4: Overfit sanity (gradient + metric check)")
print("=" * 60)
set_seed(42)
from lnp_ml.modeling.models import LNPModelWithoutMPNN
model = LNPModelWithoutMPNN(
d_model=d_model, use_llm=True, llm_type=llm_type,
llm_model_path=model_path, llm_device=device,
).to(device)
n_trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"\n Trainable params: {n_trainable:,}")
smiles = SAMPLE_SMILES * 2 # 8 samples
tab = make_tabular(len(smiles), device)
target = torch.randn(len(smiles), 1, device=device)
opt = torch.optim.Adam([p for p in model.parameters() if p.requires_grad], lr=1e-3)
lossfn = nn.MSELoss()
print(f" Training 50 steps on {len(smiles)} samples...")
first_loss = None
model.train()
for step in range(1, 51):
opt.zero_grad()
pred = model.forward_delivery(smiles, tab)
loss = lossfn(pred, target)
loss.backward()
opt.step()
if first_loss is None:
first_loss = loss.item()
if step % 10 == 0:
print(f" step {step:3d}: loss={loss.item():.4f}")
last_loss = loss.item()
assert last_loss < first_loss
print(f" Loss decreased: {first_loss:.4f} -> {last_loss:.4f} OK")
model.eval()
with torch.no_grad():
pred = model.forward_delivery(smiles, tab)
ss_res = ((pred - target) ** 2).sum().item()
ss_tot = ((target - target.mean()) ** 2).sum().item()
r2 = 1 - ss_res / (ss_tot + 1e-9)
rmse = (ss_res / len(smiles)) ** 0.5
print(f"\n After 50 steps on {len(smiles)} samples (overfit test):")
print(f" R2 = {r2:.4f} (should be > 0 after overfitting)")
print(f" RMSE = {rmse:.4f}")
assert r2 > 0
print(f" R2 > 0 OK")
print("\nTEST 4 PASSED")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--model_path", required=True, help="LLM 权重路径或 HuggingFace ID")
parser.add_argument("--llm_type", required=True,
choices=["biot5", "molt5", "moleculestm", "selfiested"])
parser.add_argument("--lnp_repo_path", default=".", help="仓库根目录")
parser.add_argument("--d_model", type=int, default=256)
args = parser.parse_args()
if args.lnp_repo_path not in sys.path:
sys.path.insert(0, args.lnp_repo_path)
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Device: {device}")
set_seed(42)
test_1_standalone(args.llm_type, args.model_path, args.d_model, device)
test_2_integration(args.llm_type, args.model_path, args.d_model, device)
test_3_reproducibility(args.llm_type, args.model_path, args.d_model, device)
test_4_overfit(args.llm_type, args.model_path, args.d_model, device)
print("\n" + "=" * 60)
print("ALL TESTS PASSED")
print("=" * 60)
if __name__ == "__main__":
main()