从 SMILES 到分子指纹:用 RDKit 把分子变成机器学习能读懂的向量

以 AI 制药平台项目为背景,梳理 SMILES、Mol 对象、Canonical SMILES、分子描述符、Morgan 指纹和 Tanimoto 相似度,理解 RDKit 如何把化学结构转成可用于检索、建模和预测的数字特征。

2026/5/21
0 分钟阅读

做 AI 制药项目时,第一道门槛不是模型,而是表示。

模型不能直接理解“阿司匹林”“乙醇”或者课本上的二维结构图。它需要的是数字,是向量,是可以进入表格模型、神经网络或者相似性搜索系统的数据结构。

这篇文章整理的是我做制药平台项目时前两周的核心笔记:SMILES、RDKit、Mol 对象、分子描述符、Morgan 指纹和 Tanimoto 相似度

如果用一句话概括,就是:

SMILES 字符串 -> RDKit 解析 -> Mol 对象 -> 描述符 / 指纹 -> 相似性搜索 / 机器学习模型

这条链路看起来不长,但它是很多 AI 制药工程系统的起点。

先把 Python 环境管好

在写 RDKit 代码之前,先处理一个很现实的问题:项目依赖怎么管理。

我现在更习惯用 uv 管 Python 项目。可以把它类比成前端里的 npm / pnpm

Python 项目前端项目作用
pyproject.tomlpackage.json声明项目需要什么依赖,以及大致版本范围
uv.lockpackage-lock.json / pnpm-lock.yaml记录这次解析出的精确版本
.venvnode_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 制药项目来说,这也是一个很好的提醒:不要一上来就追复杂模型。先把分子表示、数据清洗、特征提取和错误处理做好,系统才有继续长大的基础。

参考资料