跳到正文

Python 可重复研究:环境、随机种子与项目结构

返修时最费时间的不是补实验,是重跑三个月前的那份分析。sklearn 从 1.8 升到 1.9,某个默认参数换了值,相关系数小数点后第三位就对不上了;原始数据被同门改过一格;上一版的中间结果不知道存在哪个目录;notebook 里那次随机抽样当时没记种子,图上的分组再也复现不出来。这些问题和统计水平无关,全是工程问题。

可重复研究(reproducible research)的目标很朴素:任何人,包括半年后的你自己,拿到项目目录,能重跑出图表里同样的数字。

项目根目录长这样:

iris-study/
├── data/
│ ├── raw/
│ └── processed/
├── src/
│ ├── 01-clean.py
│ └── 02-analysis.py
├── notebooks/
│ └── explore.ipynb
├── output/
├── requirements.txt
├── README.md
└── .gitignore

名字都是约定俗成的,不要自创。src/ 放可重跑的脚本,notebooks/ 放探索过程,data/raw/ 放原始数据,data/processed/ 放清洗后的中间数据,output/ 放图和表的成品。别人接手时不用逐个问「这个文件是干嘛的」。

两条硬规则。第一,data/raw/ 只读:原始数据一旦在 Excel 里手工改过一格,所有下游结果就失去了追溯性,你没法说清某个异常值是仪器测出来的还是手滑改的。清洗逻辑必须写成代码,即使只是改一个列名。第二,data/processed/output/ 是产物,写进 .gitignore 不提交,因为它们能由代码重建。留下生成它们的脚本就够了:

.venv/
__pycache__/
.ipynb_checkpoints/
data/processed/
output/

脚本用数字前缀标明执行顺序(01-clean.py02-analysis.py),再补一个入口把整条链路串起来:

src/run_all.py
import runpy
from pathlib import Path
SRC = Path(__file__).resolve().parent
for step in ["01-clean.py", "02-analysis.py"]:
runpy.run_path(str(SRC / step))

runpy.run_path() 按文件路径执行脚本,比 subprocess 少一层 shell 依赖,也比 import 干净——脚本之间不共享命名空间,上一段残留的变量不会悄悄影响下一段。

os.chdir("/home/zhangsan/Desktop/analysis") 在写下的那一刻有效,换台机器、换个人、换个目录名都会失效,而且失效时报的是「文件不存在」,让人怀疑数据丢了。更麻烦的是它污染全局状态:你在交互式会话里 chdir 过一次,随后所有相对路径都相对那个目录解析,脚本行为和调试时不再一致。

正确做法是以脚本文件自身为基准算路径:

from pathlib import Path
BASE = Path(__file__).resolve().parent.parent
print("project root:", BASE)
print("output file :", BASE / "output" / "iris-sepal-summary.csv")
project root: /home/user/projects/iris-study
output file : /home/user/projects/iris-study/output/iris-sepal-summary.csv

Path(__file__).resolve() 先解析成绝对路径再取父目录,所以不管你是从项目根运行 python src/02-analysis.py,还是从 src/ 里运行 python 02-analysis.py,结果都一致。这一点在 notebook 里会失效:Jupyter 执行单元格时没有 __file__,路径基准是内核的启动目录。notebook 里改用 Path.cwd(),并且养成从项目根启动 Jupyter 的习惯,否则 notebook 换台机器跑就找不到数据。

一个课题一个虚拟环境,避免包版本互相污染:

终端窗口
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install pandas scikit-learn

导出依赖:

终端窗口
pip freeze > requirements.txt

别人拿到项目后:

终端窗口
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

pip freeze 的记录方式有个明显的局限:它把当前环境里所有包连同确切的版本号全部写下来,包括你自己根本没直接装过的间接依赖。只装四个包(pandas、scikit-learn、statsmodels、matplotlib)的环境,解析出来的包一共有 24 个,多出来的 20 个全是它们拖进来的依赖。

cloudpickle==3.1.2
contourpy==1.4.0
cycler==0.12.1
fonttools==4.65.0
formulaic==1.2.2

这些是 pandas、statsmodels 和 matplotlib 拖进来的依赖,不是你装的。全量固定有两个后果:一是升级某个包时牵一发动全身,二是文件里看不出哪些是项目的真实依赖,接手的人不敢删任何一行。

稳妥做法是分两层:手写一份 requirements.in 只列直接依赖,用 pip-compile(来自 pip-tools)生成带完整固定的 requirements.txt

终端窗口
pip install pip-tools
pip-compile requirements.in # 生成 requirements.txt
pip-sync # 让环境与文件严格一致
requirements.in
pandas
scikit-learn
statsmodels

pip-compile 生成的 requirements.txt 顶部会注明每个包是被谁拖进来的,升级时改 requirements.in 再重新编译,改动范围一目了然。项目小的话直接手写 requirements.txt 只列直接依赖、不写死版本号也能用,但要在 README 里写明「未固定版本」,别让人误以为这个文件能复现环境。

conda 用户的对应做法是导出环境文件:

终端窗口
conda env export --no-builds > environment.yml

--no-builds 省掉包名后面那串编译标识(类似 numpy-2.5.3-py312h5f9d9d0_0 里的后缀),只留版本号。带 build string 的环境文件几乎无法跨平台重建,去掉之后 macOS 和 Linux 之间还能对上大部分包。如果只想要自己显式装过的那几个包,用 --from-history

终端窗口
conda env export --from-history > environment.yml

别人重建环境:

终端窗口
conda env create -f environment.yml

一句话原则:同一个环境里不要混用 conda 和 pip 装同一批包。 conda 不记录 pip 装进来的东西,environment.yml 会漏掉它们;而 pip 也不认 conda 的依赖解析结果,两边同时改一个包,环境迟早会进入一个谁也说不清的状态。混合使用时,environment.yml 末尾会有单独的 pip: 段落,把 pip 装的包列在那里。

随机种子:生成器对象比全局种子可靠

Section titled “随机种子:生成器对象比全局种子可靠”

只要分析里有抽样、初始化、划分数据集,结果就依赖随机数。固定种子的方式有两种,新的更可靠:

import numpy as np
rng1 = np.random.default_rng(42)
a1 = rng1.normal(size=5)
rng2 = np.random.default_rng(42)
a2 = rng2.normal(size=5)
print("run 1: ", np.round(a1, 4))
print("run 2: ", np.round(a2, 4))
print("identical:", np.array_equal(a1, a2))
run 1: [ 0.3047 -1.04 0.7505 0.9406 -1.951 ]
run 2: [ 0.3047 -1.04 0.7505 0.9406 -1.951 ]
identical: True

np.random.default_rng(42) 返回一个独立的生成器对象,它的状态只属于这个对象。旧的 np.random.seed(42) 操作的是全局状态:

np.random.seed(42)
print("legacy 1:", np.round(np.random.normal(size=3), 4))
np.random.seed(42)
print("legacy 2:", np.round(np.random.normal(size=3), 4))
legacy 1: [ 0.4967 -0.1383 0.6477]
legacy 2: [ 0.4967 -0.1383 0.6477]

两者都能复现结果,区别在于全局状态会被任何一处 np.random 调用推进。你在函数深处调用一次 np.random.shuffle(),全局流就往前走了,上游原本固定的序列全部错位。生成器对象没有这个问题,传参进去就行,函数之间互不干扰。

还有一处容易踩的地方:生成器对象自己会推进状态,同一个对象连续抽两次得到的是不同的数。

rng = np.random.default_rng(42)
print("first draw:", np.round(rng.normal(size=3), 4))
print("second draw:", np.round(rng.normal(size=3), 4))
first draw: [ 0.3047 -1.04 0.7505]
second draw: [ 0.9406 -1.951 -1.3022]

这是设计如此,不是 bug。要复现某一步,就得在每一步开始时重新创建生成器,或者在脚本开头创建一次然后按固定顺序消费。后者更快,但要求执行顺序绝不改变——多插一次抽样,后面全部错位。

pandas 与 scikit-learn 各有自己的入口,语义一致:

import pandas as pd
df = pd.DataFrame({"id": range(10)})
print("sample A:", df.sample(3, random_state=7)["id"].tolist())
print("sample B:", df.sample(3, random_state=7)["id"].tolist())
sample A: [8, 5, 0]
sample B: [8, 5, 0]

df.sample(random_state=7)train_test_split(random_state=7)RandomForestClassifier(random_state=7) 都是同一套约定。论文的方法部分应该写明使用了固定种子,审稿人才能复核你的划分方式。

Notebook 的执行顺序也是可重复性的一部分

Section titled “Notebook 的执行顺序也是可重复性的一部分”

notebook 保存的代码和内存里生效的代码可以是两份东西。你在 Cell 5 里改了筛选条件但没运行,接着运行 Cell 6,得到的表格基于旧条件,屏幕上没有任何提示。把这个 notebook 交给同门,他从头执行一遍,数字和你的不一样。

三条习惯基本能消除这类问题:

  • 得出重要结果之前,执行 Kernel → Restart Kernel and Run All Cells,从干净内存完整跑一遍。
  • 单元格按依赖关系从上到下排列,不要靠「先跑下面再跑上面」凑结果。
  • 耗时的中间结果存成文件,不要指望内存里的变量一直在。

要让「从头跑一遍」变成自动动作,用 nbconvert 在命令行执行整个 notebook:

终端窗口
jupyter nbconvert --to notebook --execute --inplace notebooks/explore.ipynb

--execute 会按顺序运行所有单元格,--inplace 把结果写回原文件。命令末尾的非零退出码表示有单元格报错,可以直接挂在 CI 或 git pre-commit 钩子上。单元格运行时间较长时补一个超时参数:

终端窗口
jupyter nbconvert --to notebook --execute --inplace \
--ExecutePreprocessor.timeout=600 notebooks/explore.ipynb

需要按不同参数批量生成报告时,papermill 在 nbconvert 之上加了一层参数注入:

终端窗口
papermill notebooks/report.ipynb output/report-g1.ipynb -p group_id 1

不要依赖图形界面里的「Run All」按钮,它不会被记录在任何地方,也没法在别人机器上重放。

把前面的做法落进 src/02-analysis.py。真实项目里第一行是从 data/processed/ 读入,这里用 scikit-learn 内置的 iris 数据演示同一段逻辑:

src/02-analysis.py
from pathlib import Path
import pandas as pd
from sklearn.datasets import load_iris
BASE = Path(__file__).resolve().parent.parent
iris = load_iris(as_frame=True)
df = iris.frame.rename(columns=lambda c: c.replace(" (cm)", "").replace(" ", "_"))
df["species"] = df["target"].map(dict(enumerate(iris.target_names)))
summary = (
df.groupby("species")["sepal_length"]
.agg(mean="mean", sd="std", n="size")
.round(2)
)
print(summary)
(BASE / "output").mkdir(exist_ok=True)
summary.to_csv(BASE / "output" / "iris-sepal-summary.csv")
print("\nwritten to:", BASE / "output" / "iris-sepal-summary.csv")
mean sd n
species
setosa 5.01 0.35 50
versicolor 5.94 0.52 50
virginica 6.59 0.64 50
written to: /home/user/projects/iris-study/output/iris-sepal-summary.csv

生成的 CSV 是下一步图表和表格的数据源:

species,mean,sd,n
setosa,5.01,0.35,50
versicolor,5.94,0.52,50
virginica,6.59,0.64,50

这段代码里没有一处绝对路径、没有 chdir()、没有依赖交互式会话里残留的变量。三个物种各 50 朵花,花瓣长度那列差异更大,组间比较的检验方法见 /python/modeling/descriptive-stats/。换台机器,装好 requirements.txtpython src/run_all.py 就能得到同一张表。

每条结论都要能追溯到具体的软件版本。分析跑完顺手导出一次环境快照:

终端窗口
pip list --format=freeze > output/session-requirements.txt

这个文件比 requirements.txt 更全,它记录的是「本次分析实际用的环境」,属于产物的一部分。写论文时方法部分至少要写清楚四项:Python 版本、关键包版本、操作系统、随机种子。前两项一行命令就能拿到:

终端窗口
python -c "import sys, pandas, sklearn; print(sys.version.split()[0], pandas.__version__, sklearn.__version__)"
3.12.3 3.0.6 1.9.1

更省事的做法是让报告自带环境快照:在报告脚本末尾把这段输出写进 output/,每跑一次就刷新一次,不用事后回忆。

把 notebook 当交付物。 notebook 的执行顺序无法从文件本身看出,别人拿到后「Run All」可能得到不同结果。交付时固化成脚本,或者用 nbconvert 执行过的版本。

装了包不更新依赖文件。 中途 pip install seaborn 却不重新导出 requirements.txt,别人重建的环境缺包,脚本在中途报 ModuleNotFoundError

在代码里硬编码随机种子之外还硬编码结果。 论文里的数字从控制台手工复制,改数据后正文和图表就不同步了。数字一律从 output/ 里的文件读,表格的生成方式见 /python/reporting/tables/

只提交代码,不提交数据版本信息。 半年后你自己也说不清当时的输入是哪一版。数据量小就随仓库提交,数据大就记录哈希值写进 README。

在全局环境里装包调试。 临时 pip install 到系统 Python,当时能跑,换台机器就找不到那个包。任何一次 pip install 之前先确认虚拟环境已激活。

同样的要求对 R 项目一样成立:脚本能从头跑到尾、依赖写进 renv.lock、路径不写死。对照 /r/reporting/reproducible/ 可以看另一套工具链下的同类做法。环境固定之后,分析结果本身如何组织成可交付的文档,见 /python/reporting/notebook-export/