Python 可重复研究:环境、随机种子与项目结构
返修时最费时间的不是补实验,是重跑三个月前的那份分析。sklearn 从 1.8 升到 1.9,某个默认参数换了值,相关系数小数点后第三位就对不上了;原始数据被同门改过一格;上一版的中间结果不知道存在哪个目录;notebook 里那次随机抽样当时没记种子,图上的分组再也复现不出来。这些问题和统计水平无关,全是工程问题。
可重复研究(reproducible research)的目标很朴素:任何人,包括半年后的你自己,拿到项目目录,能重跑出图表里同样的数字。
先把目录结构定下来
Section titled “先把目录结构定下来”项目根目录长这样:
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.py、02-analysis.py),再补一个入口把整条链路串起来:
import runpyfrom pathlib import Path
SRC = Path(__file__).resolve().parentfor step in ["01-clean.py", "02-analysis.py"]: runpy.run_path(str(SRC / step))runpy.run_path() 按文件路径执行脚本,比 subprocess 少一层 shell 依赖,也比 import 干净——脚本之间不共享命名空间,上一段残留的变量不会悄悄影响下一段。
不要硬编码路径
Section titled “不要硬编码路径”os.chdir("/home/zhangsan/Desktop/analysis") 在写下的那一刻有效,换台机器、换个人、换个目录名都会失效,而且失效时报的是「文件不存在」,让人怀疑数据丢了。更麻烦的是它污染全局状态:你在交互式会话里 chdir 过一次,随后所有相对路径都相对那个目录解析,脚本行为和调试时不再一致。
正确做法是以脚本文件自身为基准算路径:
from pathlib import Path
BASE = Path(__file__).resolve().parent.parentprint("project root:", BASE)print("output file :", BASE / "output" / "iris-sepal-summary.csv")project root: /home/user/projects/iris-studyoutput file : /home/user/projects/iris-study/output/iris-sepal-summary.csvPath(__file__).resolve() 先解析成绝对路径再取父目录,所以不管你是从项目根运行 python src/02-analysis.py,还是从 src/ 里运行 python 02-analysis.py,结果都一致。这一点在 notebook 里会失效:Jupyter 执行单元格时没有 __file__,路径基准是内核的启动目录。notebook 里改用 Path.cwd(),并且养成从项目根启动 Jupyter 的习惯,否则 notebook 换台机器跑就找不到数据。
虚拟环境与 requirements.txt
Section titled “虚拟环境与 requirements.txt”一个课题一个虚拟环境,避免包版本互相污染:
python -m venv .venvsource .venv/bin/activate # Windows: .venv\Scripts\activatepip install pandas scikit-learn导出依赖:
pip freeze > requirements.txt别人拿到项目后:
python -m venv .venvsource .venv/bin/activatepip install -r requirements.txtpip freeze 的记录方式有个明显的局限:它把当前环境里所有包连同确切的版本号全部写下来,包括你自己根本没直接装过的间接依赖。只装四个包(pandas、scikit-learn、statsmodels、matplotlib)的环境,解析出来的包一共有 24 个,多出来的 20 个全是它们拖进来的依赖。
cloudpickle==3.1.2contourpy==1.4.0cycler==0.12.1fonttools==4.65.0formulaic==1.2.2这些是 pandas、statsmodels 和 matplotlib 拖进来的依赖,不是你装的。全量固定有两个后果:一是升级某个包时牵一发动全身,二是文件里看不出哪些是项目的真实依赖,接手的人不敢删任何一行。
稳妥做法是分两层:手写一份 requirements.in 只列直接依赖,用 pip-compile(来自 pip-tools)生成带完整固定的 requirements.txt:
pip install pip-toolspip-compile requirements.in # 生成 requirements.txtpip-sync # 让环境与文件严格一致pandasscikit-learnstatsmodelspip-compile 生成的 requirements.txt 顶部会注明每个包是被谁拖进来的,升级时改 requirements.in 再重新编译,改动范围一目了然。项目小的话直接手写 requirements.txt 只列直接依赖、不写死版本号也能用,但要在 README 里写明「未固定版本」,别让人误以为这个文件能复现环境。
conda 与 environment.yml
Section titled “conda 与 environment.yml”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: Truenp.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」按钮,它不会被记录在任何地方,也没法在别人机器上重放。
一个能重跑的完整脚本
Section titled “一个能重跑的完整脚本”把前面的做法落进 src/02-analysis.py。真实项目里第一行是从 data/processed/ 读入,这里用 scikit-learn 内置的 iris 数据演示同一段逻辑:
from pathlib import Pathimport pandas as pdfrom 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 nspeciessetosa 5.01 0.35 50versicolor 5.94 0.52 50virginica 6.59 0.64 50
written to: /home/user/projects/iris-study/output/iris-sepal-summary.csv生成的 CSV 是下一步图表和表格的数据源:
species,mean,sd,nsetosa,5.01,0.35,50versicolor,5.94,0.52,50virginica,6.59,0.64,50这段代码里没有一处绝对路径、没有 chdir()、没有依赖交互式会话里残留的变量。三个物种各 50 朵花,花瓣长度那列差异更大,组间比较的检验方法见 /python/modeling/descriptive-stats/。换台机器,装好 requirements.txt,python 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/,每跑一次就刷新一次,不用事后回忆。
几个反复出现的坑
Section titled “几个反复出现的坑”把 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/。