3. 依赖类型与依赖图
在科学计算环境中,“装了什么”往往不是最关键的问题,真正决定环境能否复现、能否迁移的是“谁依赖谁、依赖在什么阶段生效”。同一个包名,在不同语境下可能扮演完全不同的角色:它可能是你显式写进配置文件的直接依赖,也可能是被别的包悄悄拉进来的传递依赖;它可能只在编译扩展时需要,也可能在程序运行时必须存在。把这些角色区分清楚,并用依赖图把它们的关系固定下来,是环境迁移前最重要的一项建模工作。
五类依赖的判定标准
直接依赖是用户在环境声明文件中显式列出的包,例如 environment.yml 的 dependencies 列表或 requirements.txt 中的条目。它表达的是“我明确要求这个环境具备的能力”。传递依赖则是直接依赖的依赖,由求解器自动引入,用户通常不直接书写。二者的边界不是包本身的属性,而是“是否被显式声明”——numpy 在某个项目里是直接依赖,在另一个只装了 pandas 的项目里就只是传递依赖。
可选依赖(extra)是包作者声明为“非默认安装”的功能扩展,例如 pip install "scanpy[leiden]" 中的 leiden 组。它只在启用对应功能时才需要,缺失时主功能仍可运行,但相关代码路径会报错或降级。构建依赖指构建阶段(编译 C/Fortran 扩展、生成 Cython 代码、打包 wheel)所需的工具与库,典型如 setuptools、wheel、cython、numpy(作为编译期头文件来源)以及编译器本身。它们只在 build 阶段存在,安装完成后通常不再需要。运行时依赖则是程序执行期间必须可导入的库,包括解释器本身、动态链接库(如 libopenblas、libgomp)以及被 import 的 Python 包。
依赖图:把关系变成可检查的结构
依赖图以包为节点、以“依赖”为有向边,边上可标注版本约束与依赖类型。对科学计算环境而言,一张有用的依赖图至少要能回答:某包的版本约束从哪些上游传来、哪些包是多个直接依赖的共同下游(即汇聚点)、哪些边是构建期边、哪些是可选边。下图以一个典型的数据分析环境为例,展示了五类依赖在同一张图中的位置与方向。
识别方法:从声明文件到实际环境
依赖类型不能只靠读声明文件推断,因为声明文件记录的是“意图”,实际环境记录的是“结果”。可靠的做法是双向核对。第一步,读取声明文件得到直接依赖集合;第二步,用 pip show 或 conda list 查看每个包的 Requires 字段,递归展开得到传递依赖闭包;第三步,用 pipdeptree 或 conda tree 直接输出树状依赖关系,与手工展开结果比对,发现“声明里没有、环境里却有”的包,这些通常是求解器为满足约束而引入的隐式传递依赖。
# 查看某包的依赖声明(含 extra 标记)
pip show scanpy | grep -i requires
# Requires: anndata, numpy, scipy, pandas, ...
# Requires-Python: >=3.9
# 输出完整依赖树,定位汇聚点与孤立包
pipdeptree --warn silence
# 查看构建依赖(PEP 517 构建系统声明)
python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['build-system'])"
构建依赖的识别要单独处理:它通常写在 pyproject.toml 的 [build-system] requires 中,或旧式 setup.py 的 setup_requires 里,不会出现在运行时依赖树中。若目标迁移机器没有 C 编译器,就必须确认所有包都提供预编译 wheel,否则构建依赖会变成迁移阻塞点。运行时系统库则要通过 ldd 检查扩展模块的动态链接情况,例如 ldd $(python -c "import numpy;print(numpy.__file__)") 可暴露对 libopenblas、libgfortran 的依赖,这类依赖不在任何 Python 包管理器视野内,却常是跨机器迁移失败的直接原因。
建模:把依赖图写成可执行的约束
识别完成后,应把依赖图固化为可版本控制的模型。推荐用“分层锁定”策略:顶层文件只写直接依赖并给出宽松约束,锁定文件记录完整传递闭包与精确版本,构建依赖单独成节,可选依赖按 extra 分组。这样迁移时既能复现完整环境,又能在需要精简时明确知道哪些边可以剪掉。
| 依赖类型 | 建模位置 | 迁移时的处理 |
|---|---|---|
| 直接依赖 | environment.yml / requirements.in | 保留,作为环境意图的权威来源 |
| 传递依赖 | lock 文件(精确版本 + hash) | 整体复现,不手工删改 |
| 可选依赖 | extra 分组或独立 requirements-extra.txt | 按目标功能决定是否安装 |
| 构建依赖 | pyproject.toml [build-system] | 目标机无编译器时改用 wheel 源 |
| 运行时系统库 | 容器基础镜像 / 系统包清单 | 随镜像或 apt/conda 系统包一并迁移 |
建模时还要注意版本约束的合并规则:当多条边指向同一节点时,有效约束是各边约束的交集。例如 pandas 要求 numpy>=1.23、scikit-learn 要求 numpy>=1.22、scanpy 要求 numpy>=1.21,则 numpy 的下界取最大值 1.23。若某条边给出上界(如 numpy<2.0),则必须与下界共同校验,出现空交集就说明依赖图本身不可满足,需要在直接依赖层面调整版本,而不是在传递依赖上强行覆盖。
pip freeze 的输出直接当作依赖模型。它混合了直接依赖与传递依赖,丢失了“谁引入谁”的边信息,也丢失了构建依赖与可选依赖的区分。迁移后一旦需要升级某个直接依赖,就无法判断哪些传递依赖会随之变化。最后,依赖图应随环境一起做一次“可满足性自检”:在干净目录中用锁定文件重建环境,记录求解耗时与最终版本,与源环境逐包比对。若出现版本漂移,说明锁定不完整;若求解失败,说明约束交集为空。把这次自检的结果连同依赖图一起归档,下一次迁移就有了可验证的基线,而不是重新猜测一遍依赖关系。