做 AI 制药项目时,第一道门槛不是模型,而是表示。
模型不能直接理解“阿司匹林”“乙醇”或者课本上的二维结构图。它需要的是数字,是向量,是可以进入表格模型、神经网络或者相似性搜索系统的数据结构。
这篇文章整理的是我做制药平台项目时前两周的核心笔记:SMILES、RDKit、Mol 对象、分子描述符、Morgan 指纹和 Tanimoto 相似度。
如果用一句话概括,就是:
SMILES 字符串 -> RDKit 解析 -> Mol 对象 -> 描述符 / 指纹 -> 相似性搜索 / 机器学习模型
这条链路看起来不长,但它是很多 AI 制药工程系统的起点。
先把 Python 环境管好
在写 RDKit 代码之前,先处理一个很现实的问题:项目依赖怎么管理。
我现在更习惯用 uv 管 Python 项目。可以把它类比成前端里的 npm / pnpm:
| Python 项目 | 前端项目 | 作用 |
|---|---|---|
pyproject.toml | package.json | 声明项目需要什么依赖,以及大致版本范围 |
uv.lock | package-lock.json / pnpm-lock.yaml | 记录这次解析出的精确版本 |
.venv | node_modules | 当前项目真实可运行的依赖环境 |
pyproject.toml 负责说“我需要 RDKit、Pillow、NumPy 这些包,并且版本要在某个范围内”。uv.lock 负责记录“这一次具体解析出了哪些版本”。.venv 里放的是当前项目实际运行时会用到的 Python 解释器和依赖包。
所以运行脚本时,不要直接用系统 Python:
python scripts/validate_smiles.py --smiles "CCO"
更推荐让 uv 使用项目自己的环境:
uv run python scripts/validate_smiles.py --smiles "CCO"
这件事看起来和化学没关系,但它决定了别人拉下项目之后能不能复现你的结果。做 AI 制药平台时,依赖版本、运行环境和数据处理链路本来就是系统的一部分。
SMILES:把分子写成字符串
SMILES 可以理解成“分子的字符串表示”。
比如乙醇可以写成:
CCO
这串字符里,每个符号都有化学含义:
| 符号 | 含义 |
|---|---|
C | 碳原子 |
O | 氧原子 |
N | 氮原子 |
= | 双键 |
# | 三键 |
() | 分支 |
| 数字 | 环结构闭合 |
小写 c | 芳香碳,比如苯环 |
如果把前端作为类比,SMILES 有点像 HTML:HTML 用文本描述页面结构,SMILES 用文本描述分子结构。
例如阿司匹林可以写成:
CC(=O)Oc1ccccc1C(=O)O
这不是给人看的“化学名称”,而是给程序解析的结构描述。RDKit 就是负责把这种字符串变成可计算对象的工具。
RDKit 负责什么
RDKit 不是模型,也不是分类器。
它更像一个化学信息学工具箱,可以做这些事情:
SMILES -> Mol 对象
Mol 对象 -> 分子图片
Mol 对象 -> 分子描述符
Mol 对象 -> 分子指纹
Mol 对象 -> 标准化 SMILES
在工程链路里,RDKit 通常位于“原始输入”和“机器学习特征”之间。
用户输入的是 SMILES,模型需要的是向量。RDKit 负责把中间这段路铺出来。
Mol 对象:RDKit 眼里的分子
RDKit 解析 SMILES 之后,会得到一个 Mol 对象。
from rdkit import Chem
mol = Chem.MolFromSmiles("CCO")
if mol is None:
print("不合法 SMILES")
else:
print("合法分子")
Mol 对象可以理解成 RDKit 内部的分子结构图。它不只是保存一串文本,而是保存了原子、化学键、芳香性、价态等信息。
如果 Chem.MolFromSmiles() 解析失败,会返回 None。所以只要系统入口允许用户输入 SMILES,就应该先做合法性检查。
在工具库里,可以直接抛异常:
from rdkit import Chem
def parse_smiles(smiles: str):
mol = Chem.MolFromSmiles(smiles)
if mol is None:
raise ValueError(f"无效的 SMILES: {smiles}")
return mol
如果放到 Web API 里,就不应该把 Python 异常原样甩给前端,而是要转成稳定的业务错误。
FastAPI 里可能是:
{
"code": 400,
"detail": "无效的 SMILES"
}
如果后端是 Spring Boot,也可以设计成更明确的错误结构:
{
"code": "INVALID_SMILES",
"message": "您输入的分子式格式不正确",
"timestamp": "2026-05-18T10:00:00"
}
这就是从“写脚本”走向“做平台”时必须补上的一层:底层库负责发现错误,业务接口负责表达错误。
Canonical SMILES:统一同一个分子的不同写法
同一个分子可能有多种 SMILES 写法。
比如乙醇可以写成:
CCO
OCC
对人来说都能看懂,但对系统来说,如果不做标准化,它们就是两条不同的字符串。这会影响去重、缓存、数据库索引和相似性搜索。
RDKit 可以把不同写法统一成标准形式:
from rdkit import Chem
def canonicalize_smiles(smiles: str) -> str:
mol = Chem.MolFromSmiles(smiles)
if mol is None:
raise ValueError(f"无效的 SMILES: {smiles}")
return Chem.MolToSmiles(mol, canonical=True)
print(canonicalize_smiles("OCC"))
在平台里,一个常见策略是:
用户输入 SMILES -> RDKit 解析 -> Canonical SMILES -> 入库 / 检索 / 建模
这样用户输入的格式可以灵活,但系统内部使用统一表示。
生成分子图片:让结构可视化
分子进入系统后,除了用于计算,也常常需要展示给用户。
RDKit 可以直接把 Mol 对象画成图片:
from pathlib import Path
from PIL.Image import Image
from rdkit import Chem, RDLogger
from rdkit.Chem import Draw
RDLogger.DisableLog("rdApp.*")
DEFAULT_SIZE = (300, 300)
def smiles_to_image(smiles: str, size: tuple[int, int] = DEFAULT_SIZE) -> Image:
mol = Chem.MolFromSmiles(smiles)
if mol is None:
raise ValueError(f"无效的 SMILES: {smiles}")
return Draw.MolToImage(mol, size=size)
def save_smiles_image(
smiles: str,
output_path: str | Path,
name: str = "分子图片",
size: tuple[int, int] = DEFAULT_SIZE,
) -> Path:
path = Path(output_path)
if path.suffix == "":
path.mkdir(parents=True, exist_ok=True)
path = path / f"{name}.png"
else:
path.parent.mkdir(parents=True, exist_ok=True)
image = smiles_to_image(smiles, size=size)
image.save(path)
return path
if __name__ == "__main__":
save_smiles_image(
"CC(=O)Oc1ccccc1C(=O)O",
"../outputs/images/",
name="阿司匹林",
)
到这里,SMILES 已经不只是字符串了。它可以被验证、标准化,也可以被画出来。
下一步是把它变成模型能用的数字。
分子描述符:可解释的低维特征
分子描述符是对一个分子的数值化描述。
比如阿司匹林可以被描述成一组性质:
分子量:180.16
LogP:1.31
TPSA:63.60
氢键供体数量:1
氢键受体数量:4
可旋转键数量:2
芳香环数量:1
这些数值可以组成一个向量:
[180.16, 1.31, 63.60, 1, 4, 2, 1]
模型并不理解“阿司匹林”这个名字,但它可以处理这样的数字向量。
常见描述符包括:
| 描述符 | 含义 | 直觉 |
|---|---|---|
MolWt | 分子量 | 分子越大,吸收、代谢、穿膜等性质通常会受影响 |
LogP | 脂水分配系数 | 越高越偏亲脂,越低越偏亲水 |
TPSA | 拓扑极性表面积 | 越大通常越不容易穿过脂质膜或血脑屏障 |
HBD | 氢键供体数量 | 比如 -OH、-NH 这类能给出氢键的结构 |
HBA | 氢键受体数量 | 比如 O、N 这类能接受氢键的原子 |
Rotatable Bonds | 可旋转键数量 | 越多,分子越柔软,构象空间越大 |
Ring Count | 环数量 | 环结构影响分子的刚性和结合方式 |
用 RDKit 计算这些描述符:
from rdkit import Chem
from rdkit.Chem import Descriptors, Lipinski, rdMolDescriptors
def calc_basic_descriptors(smiles: str) -> dict[str, float | int]:
mol = Chem.MolFromSmiles(smiles)
if mol is None:
raise ValueError(f"无效的 SMILES: {smiles}")
return {
"mol_wt": round(Descriptors.MolWt(mol), 2),
"logp": round(Descriptors.MolLogP(mol), 2),
"tpsa": round(Descriptors.TPSA(mol), 2),
"hbd": Lipinski.NumHDonors(mol),
"hba": Lipinski.NumHAcceptors(mol),
"rotatable_bonds": Lipinski.NumRotatableBonds(mol),
"ring_count": rdMolDescriptors.CalcNumRings(mol),
}
print(calc_basic_descriptors("CC(=O)Oc1ccccc1C(=O)O"))
描述符最大的优点是可解释。
当模型预测一个分子溶解性不好时,我们至少可以回头看:是不是 LogP 太高?是不是 TPSA 太大?是不是氢键供受体数量异常?这些特征和化学直觉之间有对应关系。
它的缺点也很明显:描述符是人工设计的汇总特征,表达能力有限。两个分子的分子量、LogP、TPSA 可能很接近,但局部结构完全不同。
这就需要分子指纹。
Lipinski 五规则:一个经典但不能迷信的经验规则
在小分子药物里,经常会看到 Lipinski 五规则。它用于粗略判断一个分子是否可能具备较好的口服成药性。
常见阈值是:
分子量 <= 500
LogP <= 5
氢键供体 HBD <= 5
氢键受体 HBA <= 10
虽然叫“五规则”,但它不是一个模型,也不是药物能否成功的判决书。它更像早期过滤条件:如果一个分子严重违反这些规则,后续 ADMET 风险可能更高。
在工程系统里,我更倾向于把它当成一个解释性指标,而不是硬性真理:
这个分子违反了几条规则?
违反的是分子量、LogP,还是氢键供受体?
是否需要结合具体靶点、给药方式和实验数据重新判断?
AI 制药里很容易把计算结果说得太满。但描述符和经验规则只能提供线索,不能替代实验验证。
Morgan 指纹:把局部结构编码成高维向量
分子描述符像是在回答:“这个分子总体上是什么性质?”
Morgan 指纹更像是在回答:“这个分子里出现过哪些局部结构?”
RDKit 里的 Morgan 指纹属于 circular fingerprints。它会围绕每个原子,在一定半径内观察局部化学环境,然后把这些局部环境编码到固定长度的向量里。
一个常见设置是:
radius = 2
fpSize = 2048
也就是生成一个 2048 位的 bit vector。每一位可以粗略理解为某种结构模式是否出现过。
新版 RDKit 文档中,更推荐用 fingerprint generator 的方式生成 Morgan 指纹:
import numpy as np
from rdkit import Chem, DataStructs
from rdkit.Chem import AllChem
def morgan_fingerprint(smiles: str, radius: int = 2, fp_size: int = 2048):
mol = Chem.MolFromSmiles(smiles)
if mol is None:
raise ValueError(f"无效的 SMILES: {smiles}")
fpgen = AllChem.GetMorganGenerator(radius=radius, fpSize=fp_size)
fingerprint = fpgen.GetFingerprint(mol)
array = np.zeros((fp_size,), dtype=np.int8)
DataStructs.ConvertToNumpyArray(fingerprint, array)
return array
fp = morgan_fingerprint("CC(=O)Oc1ccccc1C(=O)O")
print(fp.shape)
输出的 fp 就可以作为机器学习模型的输入特征。
和描述符相比,Morgan 指纹的特点是:
| 特征 | 分子描述符 | Morgan 指纹 |
|---|---|---|
| 维度 | 通常较少 | 通常较高,比如 2048 位 |
| 可解释性 | 强 | 弱一些 |
| 表达重点 | 整体物化性质 | 局部结构模式 |
| 适合场景 | 表格模型、baseline、解释分析 | 相似性搜索、QSAR、机器学习输入 |
真实项目里,两者不一定二选一。很多 baseline 会把描述符和指纹都算出来,再比较不同特征组合的效果。
Tanimoto 相似度:用指纹做分子相似性搜索
有了分子指纹之后,就可以比较两个分子有多像。
化学信息学里常用的指标之一是 Tanimoto similarity。对于 bit vector 来说,它可以粗略理解成:
两个分子共同打开的 bit 数量 / 两个分子总共打开的 bit 数量
值越接近 1,说明两个指纹越相似;越接近 0,说明差异越大。
用 RDKit 计算两个分子的 Tanimoto 相似度:
from rdkit import Chem, DataStructs
from rdkit.Chem import AllChem
fpgen = AllChem.GetMorganGenerator(radius=2, fpSize=2048)
def get_fp(smiles: str):
mol = Chem.MolFromSmiles(smiles)
if mol is None:
raise ValueError(f"无效的 SMILES: {smiles}")
return fpgen.GetFingerprint(mol)
aspirin = get_fp("CC(=O)Oc1ccccc1C(=O)O")
benzoic_acid = get_fp("O=C(O)c1ccccc1")
similarity = DataStructs.TanimotoSimilarity(aspirin, benzoic_acid)
print(similarity)
这就是分子相似性搜索的基础。
一个简单的检索系统可以这样设计:
1. 用户输入一个 SMILES
2. RDKit 解析成 Mol
3. 生成 Morgan 指纹
4. 和数据库里已有分子的指纹逐个计算 Tanimoto similarity
5. 返回相似度最高的 Top K 分子
这类能力在药物发现里很常见。比如你已经知道一个 hit 分子,希望找到结构相似的候选分子;或者你想在分子库里快速找出一批和目标结构接近的化合物。
但还是要加一句限制:结构相似不等于药效相同。相似性搜索只能提供候选方向,不能证明活性、毒性、选择性或成药性。
一条最小可用的分子特征流水线
把前面的内容合起来,可以写出一条最小可用的分子特征流水线:
import numpy as np
from rdkit import Chem, DataStructs
from rdkit.Chem import AllChem, Descriptors, Lipinski, rdMolDescriptors
fpgen = AllChem.GetMorganGenerator(radius=2, fpSize=2048)
def featurize_smiles(smiles: str) -> dict[str, object]:
mol = Chem.MolFromSmiles(smiles)
if mol is None:
raise ValueError(f"无效的 SMILES: {smiles}")
canonical_smiles = Chem.MolToSmiles(mol, canonical=True)
descriptors = {
"mol_wt": round(Descriptors.MolWt(mol), 2),
"logp": round(Descriptors.MolLogP(mol), 2),
"tpsa": round(Descriptors.TPSA(mol), 2),
"hbd": Lipinski.NumHDonors(mol),
"hba": Lipinski.NumHAcceptors(mol),
"rotatable_bonds": Lipinski.NumRotatableBonds(mol),
"ring_count": rdMolDescriptors.CalcNumRings(mol),
}
fingerprint = fpgen.GetFingerprint(mol)
fp_array = np.zeros((2048,), dtype=np.int8)
DataStructs.ConvertToNumpyArray(fingerprint, fp_array)
return {
"canonical_smiles": canonical_smiles,
"descriptors": descriptors,
"morgan_fingerprint": fp_array,
}
这段代码已经覆盖了一个小型 AI 制药平台的基础能力:
输入校验
标准化
特征提取
向量生成
后面无论是做分子相似性搜索、性质预测、分类模型,还是接入更复杂的深度学习模型,这条链路都可以继续扩展。
我对这两周内容的理解
学 RDKit 和 SMILES 时,很容易陷入 API 细节:这个函数怎么调,那个参数怎么写。
但真正重要的是建立一张工程地图:
SMILES 是输入格式
Mol 是 RDKit 的内部分子对象
Canonical SMILES 用来统一表示
分子图片用于展示
分子描述符提供可解释特征
Morgan 指纹提供高维结构特征
Tanimoto 相似度用于分子相似性搜索
这张地图一旦建立起来,后面的机器学习部分会顺很多。
因为你会知道:模型不是凭空预测的。它吃进去的每一个数字,都来自前面某一步对分子结构的编码。
对 AI 制药项目来说,这也是一个很好的提醒:不要一上来就追复杂模型。先把分子表示、数据清洗、特征提取和错误处理做好,系统才有继续长大的基础。