首页 / 模块 3 · Python、NumPy、PyTorch 与实验工程 / 第 1 课(共 10 课)

Python 科学计算栈与环境工程

从零到前沿 ML 自学课程 · 阶段0:数学与工具基础 · 能力点:环境工程——让"跑不出来"不再是元凶(隔离环境/锁版本/逃出 notebook 隐藏状态)

模块 1 和模块 2 里你已经把线性代数、微积分、概率与优化的数学推导走了一遍。从这一课起,我们把这些数学搬到机器上跑起来。但在写第一行张量(tensor)代码之前,有一个反复劝退新手的拦路虎要先解决:环境。本模块的目标是"能从零写出一个训练循环",而训练循环跑不起来时,十有八九不是你的数学错了,而是版本对不上、变量是上次残留的、或者你根本不知道哪一行炸了。这一课就是把这些"跑不出来"的元凶逐个拆掉,给后面四课(NumPy 广播、PyTorch 张量、Autograd、nn.Module)铺好一块稳的地基。

读完这一课,你将能够

  • 说清为什么同一台机器要建多个隔离环境,并用 environment.ymlrequirements.txt 锁住版本。
  • 一句话讲出 NumPy / SciPy / Pandas / Matplotlib / scikit-learn 各自管什么。
  • 识别 Jupyter/Colab 的"隐藏状态"陷阱,判断一段乱序执行的代码到底输出什么。
  • 判断一个项目何时该从 notebook 毕业到 .py + argparse
  • 用 assert / breakpoint() / print 三件套定位一个 shape 不符的 bug。

为什么要隔离环境与锁版本

机制直觉。你的电脑上同时有项目 A(去年的代码,依赖 PyTorch 1.13)和项目 B(这学期的课,要 PyTorch 2.x)。如果所有库都装在同一个全局 Python 里,给 B 升级 PyTorch 的那一刻,A 就坏了——它们共用同一份 site-packages,一个版本号只能有一份。隔离环境(isolated environment)的本质是:给每个项目一份独立的 site-packages 目录,互不污染。

锁版本是隔离的另一半。"在我机器上能跑"几乎总是因为:你装的是 numpy 最新版,同伴装的是半年前的版本,某个函数的默认参数变了。把确切版本号写进文件,别人(和三个月后的你)才能复刻出一模一样的环境。

Python 科学计算栈分工图 NumPy 是地基,SciPy、Pandas、Matplotlib、scikit-learn 四个上层库都依赖它的数组结构。 SciPy 科学算法:优化 · 积分 · 分解 depends on ↓ Pandas 带标签的表格 DataFrame depends on ↓ Matplotlib 画图:曲线 · 散点 · 直方 depends on ↓ scikit-learn 经典机器学习 depends on ↓ NumPy 数值数组 · 整个栈的地基 Python 科学计算栈分工:NumPy 是地基,上层四个库都依赖它的数组结构。
Python 科学计算栈分工:NumPy 是地基,上层四个库都依赖它的数组结构。

真实命令当指针。建环境、装包、锁版本的标准流程:

# 建一个独立环境,指定 Python 版本
conda create -n ml-course python=3.11
conda activate ml-course

# 装包
pip install numpy scipy pandas matplotlib scikit-learn

# 把当前环境的确切版本号导出(锁版本)
pip freeze > requirements.txt          # pip 路线
conda env export > environment.yml      # conda 路线
# 别人拿到你的项目后,一键复刻环境
pip install -r requirements.txt
# 或
conda env create -f environment.yml

requirements.txt 里会是 numpy==2.2.5 这样钉死的行——== 是锁版本的关键,没有它就等于没锁。

科学计算栈:五个库各管什么

整个 Python 科学计算栈以 NumPy 为地基,其他库几乎都建在它的数组(array)之上。一句话分工:

管什么典型一行
NumPy数值数组与向量化运算,整个栈的地基a @ b(矩阵乘)
SciPy科学算法:优化、积分、线性代数分解、统计分布scipy.optimize.minimize
Pandas带行列标签的表格数据(DataFrame),读 CSV、清洗、分组df.groupby("y").mean()
Matplotlib画图:曲线、散点、直方图plt.plot(x, y)
scikit-learn经典(非深度)机器学习:回归、聚类、SVM、预处理LinearRegression().fit(X, y)

记住依赖方向:Pandas/Matplotlib/scikit-learn/SciPy 内部都把数据存成或转成 NumPy 数组。所以先把 NumPy 学透(第 2 课),其他库才不是黑盒。深度学习里 PyTorch 张量接替了数组的地基角色,但二者 API 高度相似,NumPy 仍然是最好的跳板——这正是我们先把 NumPy 学透的原因,而不是说 NumPy 被淘汰了。

Jupyter/Colab 的隐藏状态陷阱

机制直觉。notebook 的每个单元格(cell)共享同一个 Python 内核(kernel)的全局命名空间。你看到的代码是按"书写顺序"从上往下排的,但单元格可以按任意"执行顺序"反复跑。变量的真实值取决于最后一次执行了哪些单元格,而不是它们在屏幕上的排列。于是会出现"代码看起来对、跑出来却错"的鬼故事:上方单元格写着 x = 10,但下方某个单元格偷偷把 x 改成了别的值,你回头重跑上方的单元格时,它读到的是被改过的残留值。

notebook 隐藏状态:书写顺序 ≠ 执行顺序 书写顺序(屏幕上看到的) 执行顺序(内核真实跑的) Cell A x = 10 Cell B print(x) Cell C x = 999 静态排列,不代表运行先后 1 跑 A → x = 10 2 跑 B → 打印 10 3 跑 C → x = 999 ⚠ 偷偷改了 x 4 重跑 B → 打印 999 x=10 x=999 ↑ 同一段代码(Cell B),两次结果不同 ↑ 变量值取决于执行顺序,不是屏幕顺序
notebook 隐藏状态:左列是屏幕上的书写顺序,右列是真实执行顺序;同一个单元格 B 在乱序下读到不同的 x。

最小可跑复现。下面用纯 Python 模拟 notebook:每个"单元格"是一段源码字符串,run() 在共享的 state 字典里执行它,正是内核共享命名空间的样子。注意书写顺序和执行顺序不一致时,同一个单元格 B 两次跑出不同结果。试着先不看下面的输出,自己推断两次 run(cellB) 各打印什么,再对答案。

import time

state = {}                       # 模拟内核共享的全局命名空间
def run(cell):                   # 模拟"执行一个单元格"
    exec(cell, state)

# 屏幕上的书写顺序是 A、B、C:
cellA = "x = 10"
cellB = "print('B 看到的 x =', x)"
cellC = "x = 999"                # C 偷偷改了 x

# 但实际执行顺序乱了:A -> B -> C -> 又回头重跑 B
run(cellA)   # x = 10
run(cellB)   # B 看到 10
run(cellC)   # x 被改成 999
run(cellB)   # 重跑 B:现在看到 999,可源码里 B 上方仍写着 x = 10

输出是:

B 看到的 x = 10
B 看到的 x = 999

同一段 print(x),源码一个字没改,却先后打印 10 和 999。这就是隐藏状态:屏幕上的代码骗了你。自救招数:怀疑结果时,Kernel → Restart & Run All,强制按书写顺序从头跑一遍;这是验证 notebook 真的可复现的唯一可靠办法。

何时从 notebook 毕业到 .py + argparse

notebook 适合探索:边写边看图、快速试一个想法。但当你需要可复现、可版本控制、可批量跑时,就该把代码搬进 .py 文件。出现下面任一信号,就是毕业时刻:

argparse 是标准库里把脚本变成命令行工具的方式:把超参数变成 --lr 0.01 这样的开关,跑不同配置不用改一行代码。下面是脚手架(这里不在浏览器跑,因为它要从命令行接参数):

import argparse

def main(args):
    print(f"用 lr={args.lr}, epochs={args.epochs} 开跑")
    # 这里以后会放训练循环(第 6 课)

if __name__ == "__main__":
    p = argparse.ArgumentParser()
    p.add_argument("--lr", type=float, default=1e-3)
    p.add_argument("--epochs", type=int, default=10)
    p.add_argument("--seed", type=int, default=0)
    args = p.parse_args()
    main(args)

# 命令行调用:
#   python train.py --lr 0.01 --epochs 50

if __name__ == "__main__": 这一行的意思是"只有当这个文件被直接运行、而不是被 import 时,才执行下面的代码"——它让同一个文件既能当脚本跑,又能被别处复用。

调试三件套:assert / breakpoint() / print

ML 代码里最常见的 bug 不是逻辑错,而是形状(shape)对不上:你以为是 (N, D) 的矩阵其实是 (D, N),矩阵乘法要么直接报错,要么更可怕地靠广播"凑"出一个错误结果还不报错。三件套各有定位场景:

最小可跑:用 assert 抓住 shape 不符。下面写一个最小的全连接层 relu_layer,在矩阵乘之前 assert 检查维度,故意喂一个错 shape 看它被当场抓住。

import numpy as np

def relu_layer(X, W, b):
    # 断言我的假设:X 的列数必须等于 W 的行数,否则 X @ W 没意义
    assert X.shape[1] == W.shape[0], \
        f"shape 不符: X 是 {X.shape}, W 是 {W.shape}"
    Z = X @ W + b
    return np.maximum(Z, 0.0)        # ReLU

N, D, H = 4, 3, 5                    # batch=4, 输入维=3, 隐藏=5
X = np.random.randn(N, D)
W = np.random.randn(D, H)           # 正常的 W 是 (3, 5)
b = np.zeros(H)
print("正常输出 shape:", relu_layer(X, W, b).shape)   # (4, 5)

# 故意把 W 的行数从 3 改成 4,X @ W 不再合法
try:
    bad_W = np.random.randn(D + 1, H)               # (4, 5),行数不对
    relu_layer(X, bad_W, b)
except AssertionError as e:
    print("被 assert 抓住:", e)

输出会是:

正常输出 shape: (4, 5)
被 assert 抓住: shape 不符: X 是 (4, 3), W 是 (4, 5)

真实调试里,把 relu_layer 第一行换成 breakpoint(),运行时会停在那里,你可以敲 X.shapeW.shape 当场查。

# PyTorch 里完全一样的思路;张量也有 .shape
import torch
x = torch.randn(4, 3)
assert x.shape == (4, 3), x.shape
# breakpoint()  # 同样能停下来交互式查张量

调一调,观察现象

微任务 1:感受向量化 vs Python 循环的差距。N 从 100000 改成 1000000,预期:循环耗时大致线性涨 10 倍,而 NumPy 向量化几乎不变,加速比更夸张。为什么:Python 的 for 每次迭代都有解释器开销,而 .sum() 在底层 C 里一次性算完。这正是第 2 课"为什么要向量化"的引子。

import numpy as np, time

N = 100_000
a = np.random.rand(N)

t0 = time.perf_counter()
s = 0.0
for x in a:              # 纯 Python 循环
    s += x
loop_t = time.perf_counter() - t0

t0 = time.perf_counter()
s2 = a.sum()             # NumPy 向量化
vec_t = time.perf_counter() - t0

print(f"循环:   {loop_t*1e3:.2f} ms")
print(f"向量化: {vec_t*1e3:.3f} ms")
print(f"加速比: {loop_t/max(vec_t,1e-9):.0f}x")

微任务 2:让隐藏状态咬你一口。在下面把最后一次 run(cellB) 删掉再加回来,或调换 run(cellC) 与第二个 run(cellB) 的顺序,预期:B 打印的值随之改变。为什么:变量值只取决于执行顺序,与代码在屏幕上的位置无关——这就是 notebook 乱序执行的本质。

state = {}
def run(cell): exec(cell, state)

cellA = "x = 10"
cellB = "print('B 看到 x =', x)"
cellC = "x = 999"

run(cellA)
run(cellB)      # 试试调换下面两行的顺序
run(cellC)
run(cellB)

微任务 3:让广播"静默吞掉"一个错误。下面这段刻意去掉了 assert 守门。两个一维数组 a(形状 (3,))和列向量 b(形状 (3, 1))相加,你直觉里会以为得到一个长度为 3 的向量。把它跑出来看实际形状,再试着把 breshape(3, 1) 去掉对比。预期:结果不是 (3,),而是意外的 (3, 3) 方阵,且全程不报错。为什么:NumPy 广播(broadcasting)会"好心"地把两个操作数在不同的轴上各自拉伸对齐——(3,) 在前面补成 (1, 3)(3, 1) 撞出 (3, 3),错误被悄悄吞掉。这正说明为什么 assert 比"等它自己报错"更早、更可靠地定位问题:广播经常根本不报错。

import numpy as np

a = np.arange(3)              # 形状 (3,),你以为是一个长度 3 的向量
b = np.arange(3).reshape(3, 1)  # 形状 (3, 1),一个列向量

out = a + b                   # 没有任何 assert / shape 检查守门
print("a 的 shape:", a.shape)
print("b 的 shape:", b.shape)
print("意外的输出 shape:", out.shape)   # 不是 (3,),而是 (3, 3)

动手练习

  1. 锁一个环境。在本机用 python -m venv .venv 建一个虚拟环境,激活后 pip install numpy,再 pip freeze > requirements.txt。打开这个文件,确认里面的 numpy==… 带着确切版本号。然后用 python -c "import numpy; print(numpy.__version__)" 验证版本与文件一致。
  2. 抓一个 shape bug。写一个函数 batch_matmul(A, B) 计算 A @ B,在开头用 assert 同时检查 A.ndim == 2B.ndim == 2A.shape[1] == B.shape[0],每条 assert 带上信息量足够的报错信息。喂三组输入:一组正常、一组维度不匹配、一组传了 1 维数组,确认每种错误都被对应的 assert 当场抓住。
  3. 给隐藏状态做一次复盘。用本课的 state/run 模拟器,写出一个 4 个单元格的序列,使得"按书写顺序从头跑"和"按某个乱序跑"对同一个变量打印出不同的值。这复刻了 Restart & Run All 能救你于何处。

掌握自检