5.2.1 依赖地狱的机理:三重耦合
同一段代码,上月能跑,重装机器后报错;换一台工作站,指标差出零点几个百分点。多数时候代码无辜,是环境变了。分子机器学习的工具栈比一般 Python 项目更脆,根源在于它同时踩着三层彼此咬合的约束,任何一层滑动都会牵动其余两层。把约束本身看清,依赖地狱(dependency hell)就从玄学变成可推理的对象。
第一重:二进制扩展与解释器的 ABI。RDKit 的本体是一个庞大的 C++ 库,Python 里 import 到的只是绑定层。绑定层编译时就与解释器的应用二进制接口(application binary interface, ABI)锁死:对象布局、调用约定、错误处理都按特定 CPython 版本敲定,而 CPython 不承诺大版本之间的 ABI 兼容。3.10 下编译的扩展放进 3.12 解释器,import 当即崩溃。发行方因此按“Python 版本 × 平台”逐个发布轮子(wheel),文件名里的 cp310、cp312 就是 ABI 记号。“升了 Python 之后 RDKit 装不上”,报错说是找不到版本,病根在标签不匹配。
ABI 与轮子的兼容约束。ABI 是二进制模块与宿主程序之间的调用约定;CPython 的 ABI 随大版本轮换(稳定 ABI 存在,但科学计算栈基本不用)。轮子是 Python 的二进制分发格式,文件名内嵌平台与 ABI 标签,如 rdkit‑…‑cp311‑cp311‑linux_x86_64.whl。pip 选轮子时按当前解释器的标签过滤:标签对不上,再新的版本也等于不存在。这条约束对纯 Python 包(如 DeepChem 的主包)不生效——它们没有二进制层,任何解释器都能跑,于是“混装纯 Python 包没问题、混装二进制扩展出事”的现象有了统一解释。
第二重:框架生态的三角约束。深度框架不是孤立的包:torch 的每个版本按 CUDA 工具链分别构建,轮子带 cu118、cu126 一类的构建标签;torch-scatter、torch-sparse 这些 C++/CUDA 算子包又要针对特定 torch 构建编译,官方按“torch 版本 + CUDA 构建”成对发布配对轮子;三角的第三条边是显卡驱动,它决定本机可用的 CUDA 上限。装错配对有两种典型症状:pip 找不到匹配轮子,退回源码编译,动辄半小时且常失败;或者装得上,import 时以 undefined symbol 告终。
第三重:Python 版本本身的时间差。老项目冻结在 3.9/3.10——TorchDrug 的官方兼容上限即 Python 3.10;新库只发 3.12+ 的轮子。生态的新旧两个时代各自收缩,想用一个解释器同时伺候 2022 年的冻结栈与 2026 年的新库,约束无解,只剩隔离一条路。三重约束叠出的可行域画在图 5.2-1 里:行是解释器,列是框架,格内还要再过一遍 CUDA 配对。
三重约束的相对位置值得记牢:ABI 是硬边界,撞上即崩,没有协商余地;CUDA 配对介于软硬之间,查表即得却常被跳过;Python 版本时间差是慢变量,一年只移动一格,却把冻结项目一步步逼进角落。排障时按这个次序排查,比对着报错倒着猜快得多。
于是可以回答那个经典问题:pip 为什么会“解出”一个运行时才崩的组合。pip 的回溯式解析器(20.3 起)只在各包元数据声明的版本范围里搜索:它读得到“deepchem 声明支持哪个区间的 tensorflow”,读不到“这个轮子的 cp 标签是否与当前解释器一致”“+cu126 的构建是否与驱动相容”“torch-scatter 该配哪个 torch”——后三者是环境级属性,写在文档里,不写在 install_requires 里。解析器交出的组合在元数据层面自洽,崩在元数据之外。另一类失败来得更早:约束交叉时回溯搜索组合爆炸,终端停在 resolving 上数小时不动(pip 文档对此有专门章节)。这不是 pip 愚笨,是问题本身越出了它的视野;视野之外的部分,只能靠人来锁定。
最后把这件事放到可复现性(reproducibility)的账上。实验输出是四个输入的函数:
习题 5.2-1
判断下列三份日志各出在哪一层约束(ABI/torch×CUDA 配对/Python 版本时间差),并各给出一条修复路径。
日志 A(import 时):ImportError: …/site-packages/rdkit/Chem/rdchem.so: undefined symbol;该环境上周刚把默认解释器从 3.10 换成 3.12,包未重装。
日志 B(import 时):ImportError: torch_scatter/_version.so: undefined symbol: _ZN2at…(截断);当前环境 torch 为 2.7.x,torch_scatter 是数月前装的。
日志 C(安装时):ERROR: Could not find a version that satisfies the requirement torch==2.0.1;解释器为 Python 3.12。
A 属 ABI 层:rdchem.so 按 cp310 编译,解释器已是 3.12,符号表对不上。修复:回到原解释器版本,或在新解释器下重装 rdkit 使 pip 按 cp312 标签重新取轮子。B 属配对层:torch_scatter 的二进制针对旧 torch 构建编译,torch 升到 2.7 后 C++ 符号(_ZN2at… 即 at 命名空间的旧符号)已改名。修复:先确认 torch.__version__ 与 CUDA 构建,再到配对索引查对应的 torch-scatter 轮子成对重装;不要在旧包上叠加新 torch。C 属时间差层:torch 2.0.1 从未发布 cp312 轮子,pip 按标签过滤后候选为空。修复:为该旧栈单开环境,把解释器降到 3.10(或 3.11)再装——版本时间差只能靠隔离消化,不能靠硬装绕过。三份日志的共同教训:报错位置在运行时或安装时,病因都在装之前的版本决策里。
5.2.2 隔离与锁定:一实验一环境
看清机理,对策只有两条:让约束不互相接触(隔离),让每次接触都留档(锁定)。环境隔离(environment isolation)的粒度是实验,不是机器。共享环境里一次顺手升级,等于同时改写所有依赖该包的实验,而这类改动不出现在任何一次代码提交里,事后无从追溯。一实验一环境的代价只是磁盘上几个 GB,换来的却是:每个实验的输入清单闭包完整,式 (5.2-1) 里的“环境”一项从此可以指名道姓。
隔离之上是分层。分子栈的习惯分工:conda 或 mamba 管二进制层——Python 解释器本体、RDKit 及其 C++ 依赖、CUDA toolkit;pip 管纯 Python 层——PyPI 上的包。分工的依据是求解器的视野:conda 的包配方携带非 Python 依赖,能在同一次求解里一并敲定 Python 版本;pip 只见 PyPI。两条规则把分层做稳:其一,conda 先定解释器与重二进制,随后才轮到 pip 装其余;其二,pip 装过之后不再回头 conda install——两个求解器交替操作同一环境,会互相覆盖对方的决议。conda 的求解器在大环境上偏慢,社区普遍改用同接口的 mamba,这只是速度差别,原则不变。通道也是决策:conda 通道(channel)中以 conda-forge 为科学计算的主通道,混用多个通道等于给求解器添变量,锁定时应写明。
conda / mamba:地基与重二进制
- 一次求解同时敲定 Python 版本与 CUDA toolkit;
- RDKit 等 C++ 扩展的非 Python 依赖在此解决;
- 配方来自 conda-forge 等通道,通道名入锁定文件;
- 解释器本体也在其管辖之内,版本即地基。
pip:纯 Python 层
- 只管 PyPI,装在已定好的解释器之内;
- 决议结果交给 requirements-lock.txt 存档;
- 装过之后不再回头 conda install,避免两层求解器互踩;
- 纯 Python 包无 ABI 顾虑,跨解释器可移植。
锁定有三个层次,精确度递增,见表 5.2-1。宽松文件声明意图(要什么),精确文件记录事实(装了什么),environment.yml 连同通道与解释器版本一并入档(在哪种地基上装)。三者都放进版本库,与代码同过评审:复现一次实验,先复现它的环境。
| 文件 | 记录内容 | 精确度 | 角色 |
|---|---|---|---|
| requirements.txt | 顶层依赖与宽松版本范围(如 rdkit==2026.*) | 意图级 | 声明“要什么”;随时间可重新求解 |
| requirements-lock.txt(pip freeze 产物) | 全部已装包,逐个精确到轮子版本 | 逐包精确 | 记录“当时装了什么”;复现的直接依据 |
| environment.yml | conda 通道、Python 版本、conda 层与 pip 层两份清单 | 环境级完整 | 重建整个环境,含解释器与二进制层 |
只锁 requirements.txt 不锁后两者,等于只留下菜名不留配方:一个月后重新求解,解析器在已经平移的可行域里选出另一组版本,结果随之漂移,而代码一行未改。Wilson et al.(2017)把“软件环境入库”列为六条最低限度实践之一,理由正在于此——锁定文件(lock file)是实验记录的一部分,与原始数据同级。三份文件的分工可用一句话记住:requirements.txt 给人读,requirements-lock.txt 给机器读,environment.yml 给两年后的自己读。
锁定的极端形态是容器:把解释器、二进制层与包目录整体打成镜像,环境退化为一个内容哈希。课程规模用不到这一步,但原理一脉相承——复现的单位越大,锁定的层级越高;反过来,六周课程里三份文本文件已经够用,工具的重量不必超过问题本身。
5.2.3 本书的三套环境:主力、沙箱与现代栈
按上述原则,本书全程维护三套互不借贷的环境,口径汇总于表 5.2-2,隔离结构见图 5.2-2。三套环境对应三个时代的主力栈:现役主力、冻结考古、前沿图学习。
命名也是文档的一部分:mml-main 标角色,torchdrug-arch 标时代,pyg-lab 标用途。环境名一旦写进论文与实验笔记,就不再只是本地习惯;含糊的名字(env、test、new2)等于把式 (5.2-1) 的一个自变量匿名化,事后无从对账。
| 环境 | Python | 核心锁定 | 服务对象 |
|---|---|---|---|
| mml-main(主力) | 3.11(上限口径 ≤ 3.11) | rdkit 2026.03 系列、deepchem 2.8.0(pip 安装) | 第 1–3 章表征、建模与评估,日常主力 |
| torchdrug-arch(冻结沙箱) | 3.10 | torch 2.0.x、torchdrug 0.2.1 | 第 4 章生成实验;只进不改,绝不与主力混用 |
| pyg-lab(现代图学习) | 3.12+ | 最新 torch(含 CUDA 构建标签)+ PyG 与配对扩展轮子 | 5.1 节生态实践与后续课题 |
主力环境 mml-main 跑第 1–3 章的一切:RDKit 2026.03 系列(2026-08 已迭代至 2026.03.5)三大平台轮子齐备,pip 直装;DeepChem 稳定版停在 2.8.0(2024-04),nightly 以 2.8.1.dev 持续构建,本书锁 2.8.0 而不追 nightly——课程要的是可复现,不是最新。Python 取 3.11:为这批 2024 年前后的轮子留足覆盖面。
# 主力环境:解释器与二进制层先定,其余交给 pip conda create -n mml-main python=3.11 -y conda activate mml-main pip install "rdkit==2026.*" deepchem==2.8.0
沙箱 torchdrug-arch 是刻意为之的考古环境。TorchDrug 冻结于 v0.2.1(约 2022 年),官方兼容上限 Python 3.10、torch ≤ 2.0;第 4 章的生成实验全部在此复现。冻结环境(frozen environment)的含义是三条纪律:版本整体钉死、不再升级、修复方式是“再冻一层”而不是顺手升级任何一个包。冻结项目要配冻结环境——用 2026 年的 torch 伺候 2022 年的代码,既跑不动,也失掉了复现的原义。
# 冻结沙箱:整层一起钉死,TorchDrug 停在 2022 年的栈上 conda create -n torchdrug-arch python=3.10 -y conda activate torchdrug-arch pip install torch==2.0.1 torchdrug==0.2.1
现代图学习环境 pyg-lab 面向 5.1 节的 PyG 生态与新课题:新 Python、最新 torch。这里的习惯动作是先查配对表、再装包:torch-scatter 一类扩展轮子按“torch 版本+CUDA 构建”成对发布,装之前先到官方配对索引核对组合,再按同一组合安装。PyG 自 2.3 起核心包已纯 Python 化,直接 pip 可装;扩展算子包可选,但图规模一大便值得配上。
# 现代图学习:先锁 torch 与 CUDA 构建,再按配对表装扩展轮子 pip install torch==2.7.1 --index-url https://download.pytorch.org/whl/cu126 pip install torch_geometric pip install torch-scatter -f https://data.pyg.org/whl/torch-2.7.1+cu126.html
跨环境混装。在主力环境里执行 pip install torchdrug,解析器会把 torch 拉回 2.0.x 以满足其声明,deepchem 及其余依赖随之降级或直接崩坏;一个环境只能容纳一个时代的栈。同理,图 5.2-2 中三个环境之间没有任何“借一个包”的操作:借出去的包带着它全部的传递依赖,等于在目标环境里开了一条不受控的通道。发现自己想在 A 环境里装 B 环境的包时,正确的动作是问:这个实验是不是该搬进 B 环境,或者为它开第四套。
习题 5.2-2
论述:什么时候值得为一个老项目单独建冻结环境,什么时候应该把它迁移到现代栈?请给出至少三条判据,并结合 TorchDrug(后继为 Graphium)的情形说明两条路线如何并存。
参考解答判据一,目的。要复现原文数值、核对教材结论,冻结环境是唯一忠实路径——迁移本身就改动了式 (5.2-1) 里的“环境”项。要做新研究、发新方法,冻结栈的旧算子与慢迭代反而成为负担。判据二,耦合深度。项目的钉子扎得多深决定迁移成本:TorchDrug 与 torch ≤ 2.0 深度耦合,迁移近乎重写;若只依赖纯 Python 接口,迁移可能只是换一次导入路径。判据三,社区后继。原项目停更但思想有活跃后继(TorchDrug 之于 Graphium、之于 PyG 生态),说明概念资产可迁移,值得双轨:判据四,时间预算与人数——单人短期课程优先冻结,长期课题组值得投资迁移。TorchDrug 的并存方案正是本书的做法:沙箱里冻结一份作基线,主力与现代环境里用活跃生态承接新实验;两轨定期对照,一旦确认新栈能复现沙箱的关键结论,沙箱退居“历史档案”,迁移即告完成。
5.2.4 Windows 用户的现实路径:WSL2 的角色
分子机器学习工具链的一等公民平台是 Linux:mamba 的二进制包、torch 的 CUDA 轮子、PyG 的配对轮子都先到 Linux,官方教程与 CI 也以 Ubuntu 为默认口径。Windows 用户的省力路线不是绕开 Linux,而是把它装进 Windows:WSL2(Windows Subsystem for Linux 2)在轻量虚拟机里运行一个完整发行版(社区默认 Ubuntu),与 Windows 并存互通,本书三套环境在其上与原生 Linux 无异。GPU 也不需要双份:CUDA on WSL 把 Windows 侧的 NVIDIA 驱动透传进 WSL2,WSLg 直接运行 Linux 图形程序,训练与推理可以整个留在 WSL2 内完成。
文件系统边界。WSL2 与 Windows 分属两个文件系统:/mnt/c 挂载的 Windows 盘经过协议桥接访问,跨界 I/O(cross-filesystem I/O)比 Linux 原生文件系统慢一个量级——git status、数据集遍历、conda 解包受害最明显。Microsoft 官方文档的建议即:在 Linux 命令行下工作的项目,放在 Linux 侧的 home 目录(如 ~/projects);需要 Windows 编辑器时走 \\wsl$ 路径访问,而不是把项目放在 C 盘再从 WSL 里操作。
原生 Windows 并非总是不够用,边界在二进制层的厚度。纯 RDKit 场景——描述符计算、子结构检索、SMILES 批处理、教学练习——官方为 Windows 发布平台轮子,pip 即装即用,不必动用 WSL。一旦进入深度训练、需要 PyG 配对轮子或 mamba 的二进制包,生态覆盖明显偏向 Linux,此时迁入 WSL2 是收益最高的动作。判断口诀只有一句:依赖里出现“需要配对的二进制”时,就该换到 Linux 侧。
WSL2 内部的纪律与原生 Linux 相同:选一个 LTS 发行版(Ubuntu LTS 为主),系统包交给 apt,Python 栈交给 conda 与 pip,两侧不越界;永远不用 sudo 安装 Python 包——那会把包装进系统解释器,一处越界,整台“机器”的隔离根基即告失效。
版本口径随时间平移,本节全部版本号以 2026 年 8 月核实为准。半年后重读,先把三套环境的锚点版本逐一核对再动手——这个动作本身,就是 5.2.5 节清单的第 0 项。
5.2.5 复现清单:把环境纳入实验记录
复现的语义先立清楚:同代码、同数据、同环境、同种子,得到同一结果。跨硬件时逐位一致并不总可达——GPU 并行归约的浮点累加次序依实现而定——退一档的要求是统计一致:多次重启的结果分布重合。固定种子要覆盖四处:Python 的 random、numpy、torch 的 CPU 与 GPU 两侧,外加 DataLoader 工作进程的种子;漏掉任何一处,第 4 章的生成实验就会在重启后给出不同的分子。
CUDA 的确定性(determinism)开关有其局限,须当作已知边界写进实验记录。torch.use_deterministic_algorithms(True) 能压住大部分非确定源,但个别算子没有确定性实现,开关一开反而报错,此时要么换计算路径,要么显式接受非确定并在文档声明;cuDNN 的自动基准选择也是常被忽略的非确定来源。确定性不是开关一打就有的默认属性,而是一条要么满足、要么注明例外的规格。
种子与确定性解决“同机重启一致”,锁定解决“异机重建一致”,两者合起来才是完整意义的复现。只做前者,论文数值在自己机器上稳定,别人却重建不出;审稿人遇到的失败,多是后一种。
数据同样有版本。MoleculeNet 的固定划分由划分脚本与种子决定,DeepChem 加载器按 splitter 类型与 seed 复现同一划分,记录时三者缺一不可;ChEMBL 每个 release 的化合物集合都不同,引用必须带版本号(如 ChEMBL 33),第 3 章的口径在此落地为操作。配套动作是数据清单:来源、下载日期、文件哈希一并入库。环境侧照 5.2.2 锁定三层文件;结果侧每个运行目录记三项哈希——代码提交、数据清单、环境文件——做到结果可反查输入,代码、数据、结果三对应。
清单的最后一项是一次性全流程验证:清空缓存(__pycache__、数据缓存目录),在新目录从锁定文件重建环境,从零跑通全流程。这是复现的验收测试,也常是发现隐藏随机性与隐式本地依赖的唯一机会。它之所以放在最后,因为前六项任何一处不实,这一步必然失败——失败在这里反而是收获:问题被拦在发表之前。
复现危机并不只在环境。Kapoor 与 Narayanan 清点了 17 个领域、294 篇受数据泄漏影响的论文,指出泄漏与不可复现在方法学上同根(Kapoor & Narayanan, 2023);3.8 节已给出泄漏的分类学,此处只需记住:环境清单盖住的是“机器知道什么”,泄漏审查盖住的是“模型不该知道什么”,两份清单都过,复现才有意义。
回看式 (5.2-1):本节的全部操作——隔离、分层、锁定、清单——目的只有一个,把四个自变量逐一变成显式输入。环境这项做完之后,剩下的漂移来源只有硬件差异与算法非确定性,前者可以注明,后者已经写进实验记录;至此,5.3 节的陷阱清单里再没有“环境”这一类,可以腾出手对付更要紧的数据问题。
复现清单(发表前逐项核对)。① 种子四处固定:random、numpy、torch(CPU 与 GPU)、DataLoader worker;② 确定性边界:开 torch.use_deterministic_algorithms(True),无确定性实现的算子逐一注明或替换;③ 数据版本:划分器类型与 seed、ChEMBL 等 corpus 的版本号、来源与下载日期、文件哈希入数据清单;④ 环境导出:requirements-lock.txt 与 environment.yml 双份入库,conda 通道与 Python 版本写明;⑤ 三对应:每个结果目录记录代码提交、数据清单、环境文件三项哈希;⑥ 一次性全流程:清缓存、新目录、重建环境、从零跑通;⑦ 泄漏自查过 3.8 节清单,与环境清单共同构成复现证据。
习题 5.2-3
为一个“复现 GraphAF 类生成实验”的课题设计复现包的目录结构:给出顶层目录与关键文件,并说明 environment.yml、数据清单、结果目录三者如何互相勾连,做到任一结果可反查其全部输入。
参考解答目录结构建议:
graphaf-repro/(仓库根)envs/——environment.yml(conda 层:python 3.10、torch 2.0.x,含通道名)与requirements-lock.txt(pip freeze 逐包精确);data/——manifest.csv(来源、下载日期、SHA-256)与下载脚本;语料注明版本号(如 ChEMBL 33);splits/——划分文件及其哈希,附划分器类型与 seed;src/——源码,训练入口只从configs/读参数;results/run-001/——该次运行的配置副本、指标、样本,以及PROVENANCE文件。
勾连机制:训练脚本启动时自动把三项哈希写进 PROVENANCE——当前代码的 commit 哈希、data/manifest.csv 与 splits/ 的内容哈希、envs/ 两份锁定文件的哈希。反向查询于是成为机械操作:拿任一 run-XXX 的 PROVENANCE 对回仓库历史,即可还原该结果的确切代码、数据与环境;三者任一哈希对不上,该结果即标记为不可复现。验收即 5.2.5 的第六项:在新机器、新目录按清单重建,产出与 run-001 逐项比对。
关键术语
- 可复现性 (reproducibility)
- 同代码、同数据、同环境、同种子得到同一结果的性质;环境是与代码平权的自变量。
- 依赖地狱 (dependency hell)
- 多层版本约束互斥,导致安装失败或运行时崩溃的状态。
- 应用二进制接口 (application binary interface, ABI)
- 二进制模块与解释器之间的调用约定;CPython 大版本间不保证兼容。
- 轮子 (wheel)
- Python 的二进制分发格式,文件名内嵌平台与 ABI 标签,pip 据此过滤候选。
- 环境漂移 (environment drift)
- 环境随升级与重装悄然变化,结果随之不可追溯地改变。
- 环境隔离 (environment isolation)
- 一实验一环境:解释器与包集合互不共享,升级只波及单个实验。
- 锁定文件 (lock file)
- 精确记录环境内容的文件(pip freeze、environment.yml),入库与代码同过评审。
- conda 通道 (channel)
- conda 包的发布源;科学计算以 conda-forge 为主通道,锁定时应写明。
- 冻结环境 (frozen environment)
- 版本整体钉死、不再升级的隔离环境,用于复现停止维护的项目。
- 跨界 I/O (cross-filesystem I/O)
- WSL2 与 Windows 分属两个文件系统,跨界读写经协议桥接,慢一个量级。
- 确定性 (determinism)
- 同输入同硬件下结果逐位一致;GPU 上部分算子无确定性实现,须显式处理。
- 回溯解析 (backtracking resolution)
- pip 求解器在版本约束冲突时撤销假设、重新搜索的策略;约束交叉时可能组合爆炸。
参考文献与延伸阅读
- Wilson G, Aruliah DA, Brown CT, et al. 2014. Best practices for scientific computing. PLoS Biology 12(1): e1001745.
- Wilson G, Bryan J, Cranston K, Kitzes J, Nederbragt L, Teal TK. 2017. Good enough practices in scientific computing. PLoS Computational Biology 13(6): e1005510.
- Kapoor S, Narayanan A. 2023. Leakage and the reproducibility crisis in machine-learning-based science. Patterns 4(9): 100804.
- RDKit. Installation. RDKit Documentation, 2026.03 系列(2026.03.5). rdkit.org/docs/Install.html(软件文档).
- DeepChem. Getting Started: Installation. DeepChem Documentation, 2.8 系列. deepchem.io(软件文档).
- PyTorch Geometric 团队. Installation. PyTorch Geometric Documentation(含扩展轮子配对索引). pyg.org(软件文档).
- pip 开发组. Dependency Resolution. pip Documentation v26.2. pip.pypa.io/en/stable/topics/dependency-resolution/(软件文档).
- Microsoft. Working across Windows and Linux file systems. Windows Subsystem for Linux Documentation. learn.microsoft.com/windows/wsl/filesystems(软件文档).