作为一个每天跟分子结构打交道的人,我太清楚“手里攒了几千条SMILES,接下来怎么办”这种尴尬了。很多人装好RDKit之后,熟悉的就是Chem.MolFromSmiles画个分子,或者算个分子量,然后就没有然后了。这里想说的,不只是一个化学信息学工具包的上手指南,而是把视野拔高一层:RDKit到底是什么?2D分子和3D分子在处理逻辑上有哪些本质不同?从“SMILES文本”到“物理合理的三维结构”,中间要跨过多少道坑?这篇内容就把这些问题掰开揉碎讲清楚。
适合看这篇内容的人:刚接触化学信息学的学生、药物化学想自己动手跑筛选的分析人员、材料方向需要对化合物库做预处理的人,以及所有想弄明白“2D和3D分子到底差在哪”的从业者。我会把每个环节的来龙去脉、代码逻辑、背后原理都拆开,最后附上我实际踩过的几个坑,保证你对照着能直接往下走。
1. 从一条SMILES开始的RDKit工作流:先建立整体认知
1.1 RDKit到底解决什么问题
RDKit是一个开源的化学信息学工具包,但我更愿意把它理解成一个把“化学结构”转成“可计算数据”的翻译器。你给它一条SMILES,它能认识里面的原子、键、电荷、立体化学;你给它一个SDF,它能读出面上的铵接点;你给它一个2D结构式,它能投影到PI Description的数值空间。它的核心价值不在于“画图漂亮”,而在于把分子抽象成数据结构,然后你可以在这个结构上做科学计算。
实际工作场景里,RDKit经常出现在这几个地方:
- 化合物库清洗:大批量SMILES去重、标准化、剔除不良结构。
- 药化属性计算:算logP、分子量、氢键供受体、可旋转键等描述符,做类药性过滤。
- 相似性搜索与聚类:用分子指纹做Tanimoto计算,给大库挑一批代表性化合物。
- 3D构象准备:做虚拟筛选或对接之前,生成低能合理的三维构象。
- 可视化辅助:快速出结构图、给报告插图。
这些事占了我日常工作量的很大一部分,而且几乎每一件都需要“先理解分子图的构成”,这就是RDKit的立身之本。
1.2 2D与3D分子处理的本质区别
很多新手会把“2D坐标”和“3D构象”搞混,以为Compute2DCoords之后画出来的结构再加一个Z轴就是3D了。这是化学信息学里非常致命的一个误解。
2D分子本质是一张带坐标连接的分子图。原子是节点,键是边,2D坐标只是用于绘制结构式的“审美布局”,它不携带任何物理能量信息。你可以随手画一个化学结构式,画得再歪都是对的,因为化学家看2D图只需要看懂连接关系。
3D分子则完全不同。它是真实空间中一组带坐标的原子集合,原子之间除了连接关系还有角度、二面角、空间距离,这些几何量直接决定分子的势能。同一个分子的两张3D构象可能相差很大,能量也可能相差十几千卡每摩尔。
打个比方:2D结构像一张城市地铁图,告诉你哪站连哪站;3D构象像真实地形中的驾车路线,有坡度、有弯道、有实时路况。地图只要拓扑正确就好用,但真要开车过去,你得面对真实的物理空间。
所以RDKit把2D和3D处理分成了两套逻辑:2D讲拓扑与属性,3D讲坐标与能量。理解了这个,你就知道为什么有些在2D上看起来一模一样的分子,到了3D环节会产生截然不同的结论。
1.3 最小可复现代码:读取、绘制、输出
先把最小工作流跑通,后面所有讨论都有代码基础。我在一个模拟项目X中,初始数据是一批SMILES,我拿其中最简单的乙醇分子做演示:
from rdkit import Chem from rdkit.Chem.Draw import IPythonConsole from rdkit.Chem import Draw import rdkit print(rdkit.__version__) mol = Chem.MolFromSmiles('CCO') if mol is None: # 解析失败:需要回头检查SMILES合法性 print("SMILES parse failed") else: # 2D坐标:只影响显示布局,不影响分子语义 mol2d = Chem.Mol(mol) Chem.Compute2DCoords(mol2d) Draw.MolToFile(mol2d, "ethanol.svg", size=(300, 250)) # 输出SDF:能保留键级、电荷、立体化学信息 writer = Chem.SDWriter("ethanol.sdf") writer.write(mol) writer.close() # 规范SMILES:确认输入是否能被标准算法重整 print(Chem.MolToSmiles(mol)) # 输出 CCO这段代码只有四件事:
- 解析SMILES得到分子对象,失败时返回
None,所以要判断。 - 生成2D坐标做一个好看的构象布局,写SVG图片。
- 输出SDF文件,给下游对接或3D建模工具用。
- 输出规范SMILES,可用来去重和一致性比较。
这里面最容易被忽略的是SMILES解析失败检查。真实库里的SMILES经常带乱码、带格式错误、带非法元素,如果不判断None,后面所有代码都会崩。我处理过的一个公共数据库清洗任务里,几万条SMILES中就有两三百条解析失败,你得先把它们筛出来单独处理。
2. 2D分子处理:把化学直觉变成可计算的数据
2.1 分子图:RDKit内部对2D分子的抽象
RDKit的2D分子核心数据结构是一个化学感知的图。所谓“化学感知”,意思是它不只是简单地把原子当节点、把键当边,而是会识别原子价态、键的类型、孤对电子、芳香性、环系归属、手性标记。这样一个图才能支撑起化学语义的计算。
举个例子,CCO解析以后,RDKit知道中心氧是二价、氢是单键、碳是四价,MolFromSmiles会做一次完整的分子图规范化,这个过程在RDKit里叫SanitizeMol,包括价键检查、电荷检查、芳香性感知、共轭体系标注等。很多新手看到MolFromSmiles只会读图,但不知道它背后还做了一层“化学验证”。
基于这个分子图,你可以非常高效地做很多事:
- 遍历原子和键,获取原子序号、杂化态、环标记。
- 判断某原子是否在环里、是否芳香性。
- 提取子结构,用SMARTS模式做匹配。
- 计算环系信息,比如最高阶环的大小、桥头原子数量。
这些操作不需要任何坐标,纯粹走图论算法,所以非常快。一次遍历几十万分子基本在秒级完成。这也是2D之所以适合做大库清洗的底层原因。
2.2 描述符计算与类药性过滤
提到2D分子处理,最实用的场景就是算描述符。RDKit的Descriptors模块提供了几百个内置描述符,但日常项目我真正高频用到的其实就那一小批:
| 描述符 | 含义 | 用途 |
|---|---|---|
| MolWt | 分子量 | 基础物理属性 |
| MolLogP | 脂水分配系数对数 | 类药性、膜透过性 |
| NumHDonors | 氢键供体数 | 类药性规则 |
| NumHAcceptors | 氢键受体数 | 类药性规则 |
| NumRotatableBonds | 可旋转键数 | 构象柔韧性 |
| RingCount | 环总数 | 骨架复杂度 |
| TPSA | 极性表面积 | 吸收与通透性 |
类药性五规则是其中最著名的一组过滤标准:分子量小于500,logP小于5,氢键供体不超过5,氢键受体不超过10。虽然这个规则后来被人批评过于简化,但作为第一层粗筛,效率和稳定性都很好。
批量计算的标准写法大概是:
from rdkit.Chem import Descriptors import pandas as pd smiles_list = ["CCO", "c1ccccc1", "CCN(C)C", "CC(=O)O"] rows = [] for smi in smiles_list: m = Chem.MolFromSmiles(smi) if m is None: continue rows.append({ "SMILES": smi, "MolWt": Descriptors.MolWt(m), "MolLogP": Descriptors.MolLogP(m), "HBD": Descriptors.NumHDonors(m), "HBA": Descriptors.NumHAcceptors(m), "RotBonds": Descriptors.NumRotatableBonds(m), "TPSA": Descriptors.TPSA(m) }) df = pd.DataFrame(rows)注意NumHDonors等描述符是基于RDKit定义的规范,而不是你自己数N-H、O-H键。它背后有完整的算法,比如有些基团的H原子个数需要额外处理。自己数会漏,特别是遇到互变异构、内氢键这类边缘情况,所以老老实实用库函数。
2.3 指纹、相似性与化合物集合去冗余
2D分子处理里最“出圈”的另一个操作是指纹和相似性。指纹本质上就是把一个分子的结构特征映射成一个定长的01向量,这样就能用向量距离衡量分子间相似度。RDKit里常见的指纹有这么几种:
| 指纹类型 | 生成方式 | 适用场景 |
|---|---|---|
| Morgan/ECFP | 基于环境的圆形指纹 | 药效团与活性分析 |
| MACCS keys | 166位子结构键位 | 快速粗筛与分类 |
| RDKFingerprint | 基于路径的环境指纹 | 通用相似度 |
实际做法中,Morgan指纹(也就是ECFP)是最常用的。它根据每个原子周围环境的叠代半径展开,半径2对应的就是ECFP4,radius=3是ECFP6,半径越大覆盖范围越广。
有了指纹,就能计算Tanimoto相似度。也就是两个指纹里同时为1的位点数除以总并集位点数。这个值从0到1,越接近1表示越相似。
做化合物集合去冗余时,我常使用两步:先用BulkTanimotoSimilarity算它和集合里已有代表之间的最大相似度,小于某个阈值(比如0.3)再作为新代表进入集合。这样能从几千个分子里快速挑出一批“覆盖化学空间”的少而精集合。
SD里的一个细节:大库计算指纹前,最好先规范SMILES再生成分子,否则立体化学不同写法会导致同分异构体被误判成不同结构。
3. 3D构象:从平面到立体的关键一跳
3.1 构象生成不是“建3D模型”,而是“采样物理可行的构象空间”
我的理解里,很多人对3D构象生成的误解集中在一个地方:以为只要把2D坐标换成三维向量就够了。真正的化学问题是,单个3D构象根本不足以代表一个柔性分子的真实行为。分子内单键旋转可以产生成千上万个不同构象,它们能量各异,实际环境(比如受体口袋)可能偏好其中某几个构象子集。
构象生成更像“采样”:在给定分子拓扑和力场的约束下,生成一个尽可能覆盖低能构象区域的构象集合。RDKit中主流方法是ETKDG,即Experimental Torsion Knowledge Distance Geometry的组合。
ETKDG的核心思想我拆解一下:
- 先给分子设定距离约束,包括原子成键距离、1-2和1-3距离满足键长键角经验值。
- 引入经验扭转角偏好,从晶体结构数据库里总结出来的二面角分布,对分子不符合化学直觉的构象进行惩罚。
- 再从满足约束的坐标空间里随机抽取初始坐标。
- 最后做局部优化,通常结合MMFF或UFF力场进行能量最小化。
这也是为什么你不能简单地把2D坐标加一个Z轴当3D用:2D坐标不满足真实键长键角的经验分布,能量也不合理。你得到的几何结构在真实物理世界中根本不存在。
3.2 ETKDG参数调优与能量最小化
我日常使用3D构象生成的核心代码是这一套:
from rdkit import Chem from rdkit.Chem import AllChem def gen_conf(smiles, random_seed=42): mol = Chem.MolFromSmiles(smiles) if mol is None: return None mol = Chem.AddHs(mol) # 必须加氢,否则坐标和能量都不准 params = AllChem.ETKDGv3() params.randomSeed = random_seed params.useExpTorsionAnglePrefs = True params.useSmallRingTorsionAngles = True res = AllChem.EmbedMolecule(mol, params=params) if res != 0: return None # 嵌入失败 # MMFF力场优化,返回状态码 0 表示成功 try: status = AllChem.MMFFOptimizeMolecule(mol) except ValueError: status = AllChem.UFFOptimizeMolecule(mol) return mol这里有两个小要点。
第一,Chem.AddHs必须显式加氢。ETKDG在距离几何阶段如果分子没有氢原子,它会自动补一部分,但是最好自己显式添加,保证后续MMFF优化的力场参数落到正确原子上。加氢之后,原子序号会变化,所有下游操作都要记得以加氢后的分子为准。
第二,MMFF优化有时会因为分子含有MMFF力场没有覆盖的参数而抛异常,比如某些金属配合物、含溴的高价结构,这时候我会回退到UFF力场。UFF覆盖面广,但精度略低。
另外,单一构象往往不够,尤其分子中有很多可旋转键。一般我直接用EmbedMultipleConfs生成构象集合:
mol2 = Chem.AddHs(Chem.MolFromSmiles("CCN(C)Cc1ccc(OC)cc1")) params = AllChem.ETKDGv3() params.randomSeed = 0xDEADBEEF cids = AllChem.EmbedMultipleConfs(mol2, numConfs=20, params=params) for cid in cids: AllChem.MMFFOptimizeMolecule(mol2, confId=cid)每一条构象都有独立的conformer ID,后续做RMSD比较、能量筛选、对接输出时,都用这个ID来区分。
3.3 构象集的RMSD分析与柔性对齐
生成了一堆构象之后,你要判断它们是不是真的“多样”。常用的衡量指标是RMSD(均方根偏差)。RMSD越小说明两个构象越接近;越大说明差异越明显。
RDKit提供了便捷的GetBestRMSD接口,当然更稳妥的是自己写对齐逻辑:
from rdkit.Chem import AllChem def calc_rmsd(mol, cid1, cid2): # 用刚性对齐找到最小RMSD理论值 rmsd = AllChem.GetBestRMS(mol, mol, prbId=cid1, refId=cid2) return rmsd这里有个关键点:分子有平移和旋转自由度,所以比较两个构象时要先用最小二乘对齐消除整体平移和旋转,再算原子级RMSD。GetBestRMSD做的就是刚性对齐,不是直接算两个坐标差的均方根。
我在一个柔性分子项目里发现,20个构象之间的RMSD分布非常有意思:有的成对RMSD才0.2埃,说明它们几乎落在同一个能量盆地;有的超过2埃,说明分子真的探索到了不同折叠模式。这个分布直接决定你后续要不要增加构象数,还是可以只用代表性构象代表整个集合。
3.4 形状相似性:把分子形状作为生物活性的替代描述
3D分子处理里有一项容易被忽略但极好用的能力:形状相似性。它基于一个朴素观察——药物分子与靶点结合时,受体口袋对分子的三维形状有严格的要求。两个拓扑结构完全不同的分子,只要3D形状相近,就可能结合同一个口袋。
RDKit中做形状比较的原理是把分子投影到三维网格,用一个基于原子的Crippen logP分布来描述空间药效特征,然后计算两个网格的Tanimoto距离。这样相似度就不只是看平面骨架,而是看空间叠合。
实际代码大概长这样:
from rdkit.Chem import rdShapeHelpers ref = gen_conf("CCN(C)Cc1ccc(OC)cc1") prb = gen_conf("CCCOc1ccc(CC)cc1") if ref is not None and prb is not None: dist = rdShapeHelpers.ShapeTanimotoDist(ref, prb) print(1.0 - dist) # 转成相似度注意几个前提:输入的分子必须带着已生成的3D构象,通常还要求加氢完毕。另外这个比较是刚性的,如果两个分子都有柔性,最好先生成一组构象,用最低RMSD/最相似的构象对来代表,否则刚性比较会低估柔分子的相似性。
我在一个骨架跃迁任务里,用形状相似性从不同骨架的化合物里找到了几个形状高度相似的分子,它们对接打分和实验活性都跟原系列非常接近。这后来就成了我处理“分子太相似”判断时的标准补充工具:先走2D指纹快速聚类,再对挑出的代表化合物跑3D形状比对,用它判断有没有白白浪费相似度高的分子。
4. 实战:化合物库的2D/3D筛选链路设计与实现
4.1 模拟项目X的流程设计
一个我经常被问到的问题:几百上千个化合物,怎么设计一条从SMILES到可评估构象的自动化链路?我拿一个实际做过的模拟项目X来说,它的目标是从一批供应商SMILES里挑出30个代表性化合物,生成低能3D构象,输出去做对接测试。
整体链路我设计成六步,它们之间的依赖关系其实是非常自然的:
- SMILES解析与清洗:先把字符串解析成分子,剔除解析失败项。
- 2D标准化:输出规范SMILES,合并重复项。
- 类药性过滤:应用五规则和功能团过滤。
- 多样性挑选:计算Morgan指纹,用Butina聚类或MaxMin算法选出代表。
- 3D构象生成:对代表分子加氢,ETKDG生成多构象,MMFF优化。
- 输出与记录:写SDF、输出构象能量和体积坐标到文本。
为什么要这个顺序?因为2D描述符计算和指纹聚类的成本远低于3D构象生成,先把数量压下来,再在少数分子上做3D精细处理,这样总耗时最短。
4.2 脚本实现与性能调优
这条链路的核心脚本可以拆成三块。
第一块是SMILES清洗与去重:
seen_smiles = set() clean_records = [] for smi in raw_smiles: m = Chem.MolFromSmiles(smi) if m is None: continue canon = Chem.MolToSmiles(m) if canon in seen_smiles: continue seen_smiles.add(canon) clean_records.append((canon, m))这里用规范SMILES做去重键,能识别同一分子的同分异构写法。比如“CCO”和“OCC”会被规范成同一个输出,看起来输入结构不同,实际化学上是同一个分子。
第二块是过滤与多样性挑选:
from rdkit.Chem import Descriptors, DataStructs from rdkit.Chem import AllChem def lipinski_ok(m): if Descriptors.MolWt(m) >= 500: return False if Descriptors.MolLogP(m) > 5: return False if Descriptors.NumHDonors(m) > 5: return False if Descriptors.NumHAcceptors(m) > 10: return False return True fps = [] filtered = [] for canon, m in clean_records: if not lipinski_ok(m): continue fp = AllChem.GetMorganFingerprintAsBitVect(m, 2, 2048) fps.append(fp) filtered.append(canon) # MaxMin挑代表 representatives = [] rep_fps = [] for idx in range(len(fps)): if not representatives: representatives.append(idx) rep_fps.append(fps[idx]) continue sims = DataStructs.BulkTanimotoSimilarity(fps[idx], rep_fps) min_sim = min(sims) if min_sim < 0.3: representatives.append(idx) rep_fps.append(fps[idx]) if len(representatives) >= 30: breakMaxMin策略很简单:每次都挑一个和已有代表集合相似度最低的分子,这样能最大化化学空间的覆盖率。阈值0.3是基于Tanimoto相似度的经验值,如果你想要更分散的代表,可以把阈值降到0.25。
第三块是3D处理,这部分代码量大一些,但核心就是前面gen_conf函数的批量循环。性能上最关键的一步是不要对每个分子都串行生成几十个构象,而应该先用低构象数(比如5个)快速筛一遍,能量分布很差的分子再单独增加构象数。
另外python层面的并行,我习惯用multiprocessing的进程池,每个进程独立处理一整批分子,把分子的读写和能量优化隔离在进程里。RDKit对象不是线程安全的,但每个进程独立实例化RDKit环境,这一点很安全。几百个分子,进程池开8个核,基本几分钟就能跑完全部3D构象生成。
4.3 结果解读与选择策略
跑完之后,怎么判断这批3D构象靠不靠谱?我的经验是看两个指标。
第一是MMFF优化后的能量分布。一个健康的构象集合,能量差一般落在几个kcal/mol以内;如果出现能量差超过20甚至30 kcal/mol的离群构象,那几乎肯定是嵌入阶段没有收敛或者有几个离谱的二面角,这种构象该直接丢掉。
第二是构象的几何合理性。我简单检查键长和键角,比如碳碳单键长度是否在1.5埃左右、sp3碳的键角是否接近109.5度。RDKit不会自动告诉你“这个构象是不是变形了”,但你可以在优化后做几何校验,把畸形构象剔除。
模拟项目X最后筛出28个代表化合物,其中有一个我之前在2D指纹聚类阶段差点扔掉的分子,因为它的2D指纹和其他活跃化合物很不相似。但等它的3D构象和参考化合物做形状叠合时,我发现它的空间药效分布跟参考分子几乎重合,保留了它。这个分子后来在对接里得分很高。对我来说,这算是个提醒:2D筛选和3D筛选不是替代关系,而是互为过滤器,合在一起用才能把信息压榨干净。
5. 踩坑记录:RDKit处理2D、3D分子时容易忽略的细节
5.1 芳香性与Kekulé结构的坑
RDKit对芳香性的处理非常谨慎,但也因此经常出现一个让人摸不着头脑的坑:SMARTS匹配明明看着应该命中,却匹配不到。原因多半是你在SMARTS里用了芳香性标记,但分子在解析时因为某种原因被RDKit判定为非芳香性。
最典型的就是环内双键的写法。比如吡啶,你用“c1ncccc1”写下芳香性吡啶,没问题;但如果你用“C1=NC=CC=C1”写下的是Kekulé式,RDKit的芳香性感知会把环识别成芳香性吗?取决于它的芳香性模型和sanitization过程。我在筛选一个含吡啶环的库时,出现过几十个分子就是因为SMILES写法不同,导致芳香性标记不统一,匹配率直接掉下来。
应对方法很直接:解析后用Chem.SanitizeMol做一次强制检查,确认芳香性标记。如果SMILES是Kekulé式的,我一般会先把SMILES重新规范化为芳香式:
m = Chem.MolFromSmiles("C1=NC=CC=C1") m = Chem.Mol(m) Chem.SanitizeMol(m) print(Chem.MolToSmiles(m))输出通常是c1ccncc1,这样后面用芳香SMARTS匹配就稳定了。
5.2 立体化学信息在SMILES和3D构象之间的“丢与不丢”
RDKit默认在SMILES里解析立体化学是可以的,比如“F C@ (Br)I”会识别手性中心,SDF里也能保留MOL块的立体标记。但有个坑:如果你的原始SMILES没有明确手性标记,RDKit不会自动“猜”手性结构。它只承认你写清楚的内容。
我做对接前经常遇到一种情况:从数据库拿到的SMILES里手性信息写得很潦草,解析完全没问题,但生成的3D构象里,手性中心的手性评定跟文献不一致。因为2D到3D的过程中,RDKit会用随机初始坐标,手性中心默认按SMILES里已有的标记方向摆放。如果SMILES没有标记或者标记反了,3D构象就会“理所当然”地生成相反构型。
实操建议是:进3D流程前,先打印每个手性中心的信息确认;库比较大没法人工检查时,至少对关键化合物的立体化学做一轮抽检。对接之前立体化学错了,后面所有结果都没意义,这个坑非常费时间。
5.3 3D坐标生成失败与原子坐标乱序
EmbedMolecule返回非零值时表示嵌入失败。最初我以为是SMILES问题,后来排查发现坑出在原子的摩尔质量上。RDKit对某些异常原子,比如同位素标记、特殊价态,可能在距离几何阶段找不到合理的约束,导致嵌入失败。
应对方法是加一个校正步骤:先用AllChem.AssignBondOrdersFromTemplate处理特殊键,或者把分子里的异常原子统一映射到标准元素,实在不行就退回给用户手工建模。嵌入失败的比例一般不高,但一个几百分子的库,总有那么几个刺头分子,所以这个检查必不可少。
另一个相关的问题是原子坐标乱序。当你给分子加了氢原子,所有原子索引都往后移,如果你在后续对接或分析时还在用原始分子的原子索引取坐标,取到的完全是其他原子的位置。我的习惯是一致的代码流程里,从加完氢之后就一直用加氢后的分子对象,别再切回原分子。很多做结构生物学的同行会在这一步出bug,输出SDF之后在PyMOL里对不上。
5.4 大分子库的并行与内存管理
处理几万、几十万分子的库时,最容易被忽略的性能瓶颈是Python对象的持有方式。有些人习惯把每个解析后的分子对象存成列表,几十万分子吃内存吃得很凶,一个SDF读进来基本能消耗好几GB内存。
我的做法是分块处理:
- 每读10000条SMILES,解析、计算描述符、生成指纹。
- 马上把分子对象转成指纹向量或者描述符矩阵,存成紧凑的向量数据。
- 原分子对象不再持有,用
del或者直接让它们走出作用域。
这类批量操作里,另一个坑是SDWriter往同一个SDF追加写时如果不小心反复打开关闭,文件会越来越大且容易损坏。正确做法是打开一次写完全部,最后统一writer.close()。
并行处理时也别忘了设置种子。每个进程里如果直接调EmbedMolecule而不给randomSeed,那么多次运行可能会得到不同构象,看起来“查无实证没法复现”。我一般会在并行子函数里把randomSeed设为(mol_id << 8) ^ worker_id之类的派生值,保证结果可以复现,也保证不同worker生成的构象有差异性。
最后再分享一点实际操作中我的体会。RDKit强大,但它给的是“化学对象”,不是“化学答案”。2D处理帮你把库清洗干净、算好性质,3D处理帮你把分子放到真实空间里去感受构象差异。每一层都只能回答它那一层的化学问题,不要指望一个工具把所有问题都解决。我踩过的坑,基本都是因为某一层稀里糊涂就直接往下灌数据。把2D的规范、3D的几何校验、立体化学核对,每一步都卡稳了,后面的筛选结果才敢拿出去跟人讨论。这大概是我用过RDKit几年后,最想说的一句话。