本页介绍实验用到的 Python、NumPy、pandas、scikit-learn 和 PyTorch。安装与启动步骤见项目实践首页。
| 实验 | 对应章节 |
|---|---|
| 图像处理工作台 | 函数、数组、图像与窗口 |
| 分类边界擂台 | scikit-learn、评价结果 |
| 葡萄酒研究室 | 数据表、训练管道、距离与分组 |
| 图片颜色压缩 | 形状变换、距离与分组、K-Means 接口 |
| 手写数字识别 | Tensor、网络构建、训练与评价、完整示例 |
| 小车送样 | 索引与筛选、随机数与 Q 表 |
编写与运行
在解压后的 ml-lab 文件夹中新建 scratch.py,写入以下代码并保存:
message = "Hello, lab!"
print(message)
另开终端,进入包含 app.py 的文件夹,运行 scratch.py。Windows 使用 .venv\Scripts\python.exe scratch.py;macOS / Linux 使用 .venv/bin/python scratch.py。屏幕应显示 Hello, lab!。这是终端命令,不要写进 .py 文件,也不要把 Markdown 代码框的三个反引号复制进去。
本页代码示例可独立运行,依赖前文的示例会单独注明。实验代码写在 projects/项目名/exercises.py,保存后在网页检查;scratch.py 用于试算。
实验包已经列出基础依赖。数字识别另外需要 PyTorch,在同一个虚拟环境里安装:
# macOS / Linux,在 ml-lab 文件夹中执行
.venv/bin/python -m pip install -r requirements-torch.txt
Windows 对应命令为 .venv\Scripts\python.exe -m pip install -r requirements-torch.txt。示例可在 CPU 上运行。遇到 No module named ... 时,先检查安装依赖与运行文件是否使用了同一个 Python。
Python 基础与函数
import numpy as np 导入 NumPy,并将其别名设为 np。from torch import nn 从 torch 导入 nn。安装名和导入名可能不同,例如安装 scikit-learn,代码中使用 import sklearn。
Python 用缩进表示代码属于哪个函数或条件分支,通常每层四个空格。以下函数统计达到阈值的测量值个数:
def count_passed(values, threshold=0.5):
"""返回达到阈值的测量值个数。"""
count = 0
for value in values:
if value >= threshold:
count += 1
return count
answer = count_passed([0.2, 0.7, 0.8])
print(answer) # 2
print(count_passed([0.2, 0.7], threshold=0.8)) # 0
def 定义函数,括号里是它接收的参数。threshold=0.5 给出默认值,调用时可以省略,也可以用参数名覆盖。for 依次取出列表中的值,if 决定是否执行缩进里的加一操作。= 是赋值,判断相等要写 ==。
return 把结果交给调用方,并结束这次函数调用;print 只是把内容显示出来。如果题目要求返回数字,只打印数字会让调用方收到 None。
函数也可以返回多个结果或字典:
def summarize(values):
total = sum(values)
return total, len(values)
s, n = summarize([2, 4, 6]) # 按顺序解包:s=12,n=3
report = {"total": s, "count": n}
print(report["total"]) # 12:按键名取字典中的值
列表 [2, 4, 6] 保存一串值;元组 (total, count) 常用于按固定顺序返回几项结果;字典 {"total": 12} 则通过键名取值。题目要求 (预测, 概率) 时,顺序不能反;要求字典包含 mae 时,键名也要一致。
三引号内是函数说明,# 后面是注释,TODO 标记待完成的任务。raise NotImplementedError(...) 标记未实现的函数,完成后删除。raise ValueError(...) 表示输入不合法,用于输入检查:
def checked_mean(values):
if len(values) == 0:
raise ValueError("至少需要一个测量值")
return sum(values) / len(values)
print(checked_mean([2, 4])) # 3.0
练习:检查 count_passed 对空列表的返回值,再比较 return count 位于循环内外时的执行结果。
数组与形状
NumPy 数组适合整组数值计算。Python 列表乘以 2 会重复内容,数组乘以 2 则会把每个数乘以 2:
import numpy as np
print([1, 2] * 2) # [1, 2, 1, 2]
a = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)
print(a * 2) # 每个元素都乘以 2
print(a.shape, a.ndim, a.dtype) # (2, 3) 2 float32
print(a.mean(axis=0)) # [2.5, 3.5, 4.5]
print(a.mean(axis=1)) # [2., 5.]
print(a.mean()) # 3.5
shape 记录每个轴的长度;ndim 是轴的数量;dtype 是元素类型。float32 用来保存小数,类别标签一般保存成整数,比较结果则是布尔值 True / False。
axis 指定求均值的轴。形状 (2, 3) 沿 axis=0 求均值,得到三个列均值;沿 axis=1 求均值,得到两个行均值。省略 axis 时,对全部元素求均值。
常见形状如下,字母表示各维长度。
| 形状 | 维度含义 |
|---|---|
(N, D) |
N 个样本,每个有 D 个特征 |
(N,) |
N 个标签或预测值,一维数组 |
(H, W, 3) |
高、宽、RGB 三个颜色通道 |
(N, 1, 28, 28) |
N 张灰度数字图,每张一个通道、高宽各 28 |
(N, 10) |
N 张图,每张对应十个数字类别的分数 |
(N,) 和 (N, 1) 不相同。两个数组相减前要确认形状;一维目标与二维单列预测相减,可能被广播成 (N, N),程序不报错,误差却已经算错。
索引、筛选与复制
索引从 0 开始。a[1] 是第二行,a[:, 0] 是第一列,a[0:2] 取第 0、1 行,切片右端不包含在内。
import numpy as np
X = np.array([[1., 2.], [3., 4.], [5., 6.]])
groups = np.array([0, 1, 0])
mask = groups == 0
print(mask) # [True, False, True]
print(X[mask]) # 第 0、2 行
print(X[mask].mean(axis=0)) # [3., 4.]
print(np.flatnonzero(mask)) # [0, 2]:满足条件的位置
changed = X.copy()
changed[0, 0] = 99
assert X[0, 0] == 1 # 原数组仍然没变
布尔数组可以用来筛选样本,flatnonzero 返回其中为真的位置。聚类会按组号筛选样本,分类评价会按预测是否错误找位置。筛选结果为空时,没有均值可算;聚类练习要求空组保留旧中心。
changed = X 让两个变量指向同一个数组。切片也可能共享原数组内存;题目要求“不修改输入”时,先 .copy() 再改。浮点数比较用 np.allclose(a, b) 更合适,因为小数计算可能存在舍入误差。
形状变换
reshape 重新组织元素,元素总数必须相同;-1 表示让 NumPy 推算这一维。加一个长度为 1 的轴可以用 None。以下示例用全零数组演示形状变换:
import numpy as np
rgb = np.zeros((4, 5, 3), dtype=np.float32)
pixels = rgb.reshape(-1, 3)
print(pixels.shape) # (20, 3):20 个 RGB 像素
restored = pixels.reshape(4, 5, 3)
assert restored.shape == rgb.shape
images = np.zeros((2, 28, 28), dtype=np.uint8)
x = images[:, None, :, :].astype(np.float32) / 255.0
print(x.shape) # (2, 1, 28, 28)
颜色压缩需要把图片展开成像素表,处理后再按原顺序还原;卷积网络需要保留图片行列,只增加灰度通道轴。若要把彩色图的 (H, W, 3) 变成 (3, H, W),应使用 transpose(2, 0, 1) 调换轴,单纯 reshape 会打乱颜色与位置的对应关系。
归一化前还要检查输入。np.isfinite(images).all() 判断是否全是有限值;((images < 0) | (images > 255)).any() 判断是否存在越界值。数组里的逐元素“或”写 |,每个比较式加括号;Python 的 or 用于普通真假条件,不能直接组合整组布尔数组。
图像、窗口与边界
一张 RGB 图片的最后一轴依次是红、绿、蓝。灰度图只保留亮度,因此从三维变为二维。用通道切片取颜色,再乘权重相加即可。np.clip(values, 0, 1) 可以限制调整后的亮度范围;它适用于亮度调整,不应用来掩盖归一化题中的非法输入。
均值滤波要访问像素周围的小窗口。以只有四个像素的图为例,先复制边缘补上一圈,再取 3×3 窗口:
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
image = np.array([[0., .3], [.6, .9]])
padded = np.pad(image, 1, mode="edge")
windows = sliding_window_view(padded, (3, 3))
print(windows.shape) # (2, 2, 3, 3)
print(windows[0, 0]) # 左上像素对应的九个邻域值
print(windows.mean(axis=(-2, -1))) # [[.3, .4], [.5, .6]]
前两个轴标记原图位置,后两个轴是窗口内部。axis=(-2, -1) 只对最后两个轴求平均,因此输出仍是 (2, 2)。窗口大小为 size 时,每边补 size // 2 格;// 是整除,练习要求 size 为正奇数。
边缘差分可以用 image[:, 1:] - image[:, :-1] 同时计算所有右邻居与当前像素的差,结果少一列。若要求保持原图大小,先用 np.zeros_like(image) 建立全零结果,再把差分填入 result[:, :-1],最后一列保持零。垂直方向同理。两个方向合成时用 np.sqrt(dx**2 + dy**2);**2 表示平方。
距离计算、广播与分组
NumPy 广播可批量计算样本与中心的距离。两个数组的形状从右侧对齐,对应轴长度相同或其中一个为 1 时,可以进行运算。参见 NumPy 广播规则。
import numpy as np
X = np.array([[0., 0.], [3., 4.]]) # (N=2, D=2)
centers = np.array([[0., 0.], [6., 8.]]) # (K=2, D=2)
difference = X[:, None, :] - centers[None, :, :]
print(difference.shape) # (N, K, D) = (2, 2, 2)
distances = (difference ** 2).sum(axis=2)
print(distances) # [[0., 100.], [25., 25.]]
print(distances.argmin(axis=1)) # [0, 0]
X[:, None, :] 是 (N, 1, D),中心是 (1, K, D),相减得到每个样本与每个中心的差。沿特征轴求平方和,留下 (N, K) 距离表。argmin 返回最小值的位置;min 返回最小值本身。argmax 与 max 也有同样区别。NumPy 的 argmin / argmax 在并列时返回第一次出现的位置。
颜色压缩中,D=3 表示 RGB。选出每个像素最近的中心后,palette[groups] 就能按组号取回代表色。酒样聚类则用 X[groups == k].mean(axis=0) 更新第 k 个中心;先确认这组有样本。判断收敛时可用 np.linalg.norm(new - old, axis=1) 求每个中心的移动距离,再检查是否全部小于等于阈值。
距离数组的大小随像素数与代表色数量的乘积增长,处理大图时需注意内存占用。
数据表与缺失值
pandas 的 DataFrame 是带列名和行索引的二维表,Series 是其中的一列。葡萄酒实验用列名区分检测指标,用行索引定位酒样。
import pandas as pd
wine = pd.DataFrame({
"alcohol": [12.0, 13.0, 14.0],
"acid": [1.2, 1.8, 1.5],
}, index=[10, 20, 30])
y = wine["alcohol"].copy() # Series,形状 (3,)
X = wine.drop(columns="alcohol") # DataFrame,形状 (3, 1)
print(wine.loc[20, "acid"]) # 1.8:按标签找行
print(wine.iloc[1, 1]) # 1.8:按零起始位置找行列
print(wine[wine["alcohol"] >= 13]) # 筛选酒样
wine["alcohol"] 是一维 Series,wine[["alcohol"]] 则是二维单列表。.loc 按标签取值,.iloc 按位置取值;经过划分的数据通常不会再有连续行号。实验中“错分位置 1”表示当前数组的第二项,并不表示索引标签为 1 的酒样。这一区别也见 pandas 索引文档。
预测酒精含量时,必须把 alcohol 从 X 中去掉,否则模型能直接读取答案。drop 默认返回新表,不会删除原表的列。
pd.read_csv("samples.csv") 读取已有的 CSV 文件;相对路径从终端当前文件夹开始查找。先用 head() 看几行、dtypes 看列类型、isna().sum() 数每列缺失项。未知测量不能随意当成零。需要估计填充值时,也只能用训练数据估计,不能先拿完整数据计算。
调参结果可以由一组字典组成:
import pandas as pd
results = pd.DataFrame([
{"alpha": 0.1, "valid_mae": 0.4},
{"alpha": 1.0, "valid_mae": 0.3},
{"alpha": 10.0, "valid_mae": 0.3},
], index=[10, 20, 30])
best_label = results["valid_mae"].idxmin()
print(results.loc[best_label, "alpha"]) # 1.0
idxmin() 返回最小值对应的行标签,并列时取第一行,因此后面接 .loc。这里挑的是验证误差最小的超参数,测试集留到最终评价。
scikit-learn:创建、训练和预测
scikit-learn 封装了常见的分类、回归与聚类算法。创建模型只是选好方法和设置;调用 fit 才开始从数据学习,predict 则对样本输出预测。下面的数据来自库自带的 Wine 数据集,无须另找 CSV。
from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
X, y = load_wine(return_X_y=True, as_frame=True)
X_train, X_valid, y_train, y_valid = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42
)
model = make_pipeline(
StandardScaler(),
LogisticRegression(C=1.0, max_iter=2000),
)
model.fit(X_train, y_train)
predictions = model.predict(X_valid)
print(predictions.shape, y_valid.shape) # 都是 (45,)
print((predictions == y_valid.to_numpy()).mean())
test_size=0.25 指定留出比例,这里把留出的部分当验证集;stratify=y 尽量维持各类别比例;random_state=42 让划分可复现。这个小例子只演示训练和验证。正式葡萄酒实验另外保留了独立测试集,不要把它混回开发数据。
StandardScaler 学习训练集每列的均值和标准差,Pipeline 把缩放与分类连在一起:fit 时学习缩放参数并训练分类器,predict 时复用这些参数。若先对所有数据做标准化,再划分,验证数据就提前参与了训练准备。scikit-learn 的常见陷阱说明建议用管道避免这种泄漏。
在 build_model(...) 填空里,只需要创建并返回未拟合的管道,外层程序负责调用 fit。对象上的 .fit(...)、.predict(...) 叫作方法;括号前的 model 是这次操作针对的对象。
| 实验中的组件 | 导入位置 | 设置与用途 |
|---|---|---|
StandardScaler |
sklearn.preprocessing |
学习每列缩放参数;用 transform 应用缩放 |
LogisticRegression |
sklearn.linear_model |
C 越小,正则约束越强;输出类别 |
Ridge |
sklearn.linear_model |
alpha 越大,正则约束越强;输出连续数值 |
DecisionTreeClassifier |
sklearn.tree |
max_depth 限制树深度,random_state 控制随机性 |
SVC |
sklearn.svm |
实验用 kernel="rbf"、C 和 gamma="scale" 控制非线性分类 |
KMeans |
sklearn.cluster |
n_clusters 指定组数,拟合后读取中心 |
例如把管道最后一层换成 Ridge(alpha=1.0) 就能做数值预测,但此时 y 必须换成连续目标,不能继续沿用 Wine 的类别标签。C 和 alpha 对正则强弱的方向相反,应分别设置。分类擂台对 SVC 的具体参数要求以当前练习说明为准。
K-Means 拟合结果
import numpy as np
from sklearn.cluster import KMeans
pixels = np.array([[0., 0., 0.], [.1, .1, .1],
[.9, .9, .9], [1., 1., 1.]])
model = KMeans(n_clusters=2, n_init=3, random_state=42)
model.fit(pixels)
print(model.cluster_centers_.shape) # (2, 3):两个 RGB 中心
print(model.labels_.shape) # (4,):四个训练像素的组号
n_init=3 表示尝试三次初始化,保留其中较好的结果。以 _ 结尾的属性常表示拟合后得到的结果;模型还没 fit 时不能读取中心。组号不代表固定颜色;中心顺序不同,分组结果仍可能相同。
葡萄酒的聚类填空要求自己写距离、分组和中心更新,不能用一次 KMeans.fit 代替这些题;颜色压缩的调色板题则允许调用这个接口。
评价结果:标量、数组与字典
import numpy as np
from sklearn.metrics import confusion_matrix
actual = np.array([0, 1, 2])
predicted = np.array([0, 2, 2])
report = {
"accuracy": float((actual == predicted).mean()),
"matrix": confusion_matrix(actual, predicted, labels=[0, 1, 2]),
"wrong": np.flatnonzero(actual != predicted),
}
print(report["accuracy"], report["wrong"]) # 约 0.667,[1]
measured = np.array([1., 3.])
estimated = np.array([2., 2.])
residuals = measured - estimated
print(float(np.abs(residuals).mean())) # MAE = 1.0
print(float((residuals ** 2).mean())) # MSE = 1.0
混淆矩阵的行是真实类别,列是预测类别,固定 labels 可以保留本批未出现的类别。准确率是正确比例;MAE 先取绝对值再平均;MSE 先平方再平均。残差保留正负,能看出预测偏高还是偏低。返回值的形状和键名是接口的一部分,不能只把它们打印出来。
PyTorch 张量
PyTorch 的 Tensor(张量)也有形状和元素类型,还能放到不同计算设备上,并记录计算过程以便求梯度。NumPy 通常负责实验的数据整理,PyTorch 负责数字识别网络的计算和训练。
import numpy as np
import torch
array = np.array([[1., 2.], [3., 4.]], dtype=np.float32)
x = torch.tensor(array, dtype=torch.float32)
y = torch.tensor([0, 1], dtype=torch.long)
print(x.shape, x.dtype, x.device) # torch.Size([2, 2]),float32,cpu
print(x.mean(dim=1)) # tensor([1.5000, 3.5000])
print(y.shape) # torch.Size([2])
PyTorch 的 dim 对应 NumPy 常用的 axis。实验里,图片用 torch.float32,交叉熵的类别标签用 torch.long。torch.tensor(array) 会复制数据;torch.from_numpy(array) 在 CPU 上与原数组共享内存,改动时要留意影响。
输入和模型参数应位于同一设备。切换设备时,分别调用 model.to(device)、x.to(device) 和 y.to(device)。.to(...) 返回转换后的对象,张量通常要写成 x = x.to(device)。
梯度与自动求导
若损失为 (w-3)²,当前 w=1,把 w 稍微增大会让损失降低。梯度描述这种变化的方向与速度;PyTorch 可以沿记录下来的运算自动求出它。
import torch
w = torch.tensor(1.0, requires_grad=True)
loss = (w - 3) ** 2
loss.backward()
print(loss.item()) # 4.0
print(w.grad.item()) # -4.0
requires_grad=True 开启梯度跟踪,backward() 把梯度写入 .grad。它还没有修改 w。在网络训练中,优化器用这些梯度更新参数,普通输入图片不必设置 requires_grad=True。
.item() 从只有一个元素的 Tensor 中取出 Python 数字,适合记录损失。若先把损失变成 Python 数字,再调用 backward(),计算图已经不在了。应先对原损失张量反向传播,最后再取数字用于显示。
网络构建
torch.nn 提供网络层。nn.Linear(4, 8) 接收每个样本的四个特征,输出八个数,内部保存需要学习的权重和偏置。nn.ReLU() 把负数变成零,引入非线性。nn.Sequential 把若干层串起来,前一层的输出交给下一层:
import torch
from torch import nn
model = nn.Sequential(
nn.Linear(4, 8),
nn.ReLU(),
nn.Linear(8, 3),
)
x = torch.zeros(2, 4)
logits = model(x)
print(logits.shape) # torch.Size([2, 3])
print(sum(p.numel() for p in model.parameters())) # 67 个可训练参数
nn.Linear(...) 是创建一层,model(x) 是把输入送入网络做一次前向计算。创建模型时已有初始参数,但这些参数还没有学到任务规律。model.parameters() 让优化器找到需要更新的参数。
本实验使用 Sequential 组合网络层。自定义网络可继承 nn.Module:在 __init__ 中创建层,在 forward(self, x) 中定义计算过程,通过 model(x) 调用。
MNIST 卷积网络
MNIST 的原始输入是 (N, 28, 28)、0~255 灰度像素。先除以固定的 255,再补通道轴,得到 float32 的 (N, 1, 28, 28)。卷积接收的四个轴依次是批次、通道、高、宽。
| 层 | 本实验的设置 | 输出形状 |
|---|---|---|
| 输入 | 一个灰度通道 | (N, 1, 28, 28) |
| 第一层卷积与 ReLU | Conv2d(1, 16, 3, padding=1) |
(N, 16, 28, 28) |
| 第一次池化 | MaxPool2d(2) |
(N, 16, 14, 14) |
| 第二层卷积与 ReLU | Conv2d(16, 32, 3, padding=1) |
(N, 32, 14, 14) |
| 第二次池化 | MaxPool2d(2) |
(N, 32, 7, 7) |
| 展平 | Flatten(),默认从轴 1 开始 |
(N, 1568) |
| 隐藏层与 ReLU | Linear(1568, hidden) |
(N, hidden) |
| 输出层 | Linear(hidden, 10) |
(N, 10) |
Conv2d 前两个参数是输入和输出通道数,第三个是卷积核大小。这里步长默认为 1,padding=1 在边缘补一圈,让 3×3 卷积前后高宽不变。池化每次把高宽减半,所以进入全连接层的长度是 32*7*7=1568。
卷积本身的工作方式见卷积网络。写代码时可以先用 torch.zeros(2, 1, 28, 28) 试算,每经过一层打印一次 .shape。若展平时把批次轴一起合并,两张图片就会被误当成一个样本。
网络输出的十个值叫 logits,是尚未转换成概率的分数。数字 7 对应第 7 列,不能因为索引从零开始就把标签改成 1~10。
训练与评价
一次训练步处理一个小批次:前向计算分数,计算损失,对损失求梯度,再更新参数。交叉熵接收 (N, C) 的原始 logits 和 (N,) 的 long 类别标签;MNIST 中 C=10。网络末尾不加 softmax,交叉熵已经包含相应的归一化计算。
| 操作 | 作用 |
|---|---|
model.train() |
切换到训练模式,影响 Dropout、BatchNorm 等层的行为 |
optimizer.zero_grad() |
清除旧梯度,避免本批与上批的梯度意外累加 |
logits = model(x) |
用当前参数计算本批预测分数 |
loss = criterion(logits, y) |
得到衡量本批错误的标量损失 |
loss.backward() |
计算参数梯度,此时参数还未更新 |
optimizer.step() |
按优化器规则更新参数 |
model.train() 只切换模式,参数更新需要单独调用优化器。优化器应在模型建立后创建,供各个训练步重复使用;每批重新创建 Adam 会丢失它积累的状态。
评价时,调用 model.eval() 并进入 torch.no_grad()。前者切换层的行为,后者关闭梯度记录;评价没有 backward() 或 step()。这些操作的职责也可对照 PyTorch 入门示例。
取得概率时用 torch.softmax(logits, dim=1),沿每张图的十个类别归一化。再用 .argmax(dim=1) 得到预测数字。要把 Tensor 交回 NumPy 页面,可以用 tensor.detach().cpu().numpy():先脱离计算图,再移到 CPU,最后转换数组。
完整训练示例
以下示例对随机二维点分类,标签表示第二个分量是否大于第一个分量。代码可独立运行,无须下载数据。
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
torch.manual_seed(42)
X = torch.rand(160, 2)
y = (X[:, 1] > X[:, 0]).long()
X_train, y_train = X[:120], y[:120]
X_valid, y_valid = X[120:], y[120:]
# Dataset 把一条输入和它的标签配成一对;DataLoader 每次取一小批。
train_data = TensorDataset(X_train, y_train)
loader = DataLoader(train_data, batch_size=24, shuffle=True)
model = nn.Sequential(nn.Linear(2, 8), nn.ReLU(), nn.Linear(8, 2))
optimizer = torch.optim.Adam(model.parameters(), lr=0.02)
criterion = nn.CrossEntropyLoss()
for epoch in range(20):
model.train()
loss_sum = 0.0
sample_count = 0
for x_batch, y_batch in loader:
optimizer.zero_grad()
logits = model(x_batch)
loss = criterion(logits, y_batch)
loss.backward()
optimizer.step()
loss_sum += loss.item() * len(x_batch)
sample_count += len(x_batch)
print(f"epoch={epoch + 1:02d}, loss={loss_sum / sample_count:.4f}")
model.eval()
with torch.no_grad():
probabilities = torch.softmax(model(X_valid), dim=1)
predictions = probabilities.argmax(dim=1)
accuracy = (predictions == y_valid).float().mean().item()
print("Validation accuracy:", accuracy)
print("Probability shape:", probabilities.cpu().numpy().shape) # (40, 2)
完整遍历一次训练集叫一个 epoch;一次小批次更新叫一个 step。这里 120 个训练样本、每批 24 个,每轮执行五个 step。shuffle=True 每轮打乱取样顺序;如果最后一批不足指定大小,默认仍会保留。代码把各批平均损失乘以实际批量再累加,因此即使最后一批较小,也能算对整轮样本平均损失。
f"...{value:.4f}" 是格式化字符串,.4f 表示显示四位小数。每轮损失不保证严格下降。正式实验会使用验证集选设置,并另留测试集报告最终结果。
练习:注释 optimizer.step(),观察停止参数更新后的损失。恢复后减小学习率,比较训练速度。每次只修改一项设置。
参数保存与加载
接续上一示例,保存参数并加载到同结构网络:
from pathlib import Path
Path("outputs").mkdir(exist_ok=True)
torch.save(model.state_dict(), "outputs/toy-model.pt")
restored = nn.Sequential(nn.Linear(2, 8), nn.ReLU(), nn.Linear(8, 2))
restored.load_state_dict(torch.load(
"outputs/toy-model.pt", map_location="cpu", weights_only=True
))
restored.eval()
with torch.no_grad():
assert torch.allclose(model(X_valid), restored(X_valid))
state_dict() 保存参数等模型状态,加载前要创建相同结构。隐藏层宽度改了,参数形状也会改变。MNIST 实验由页面导出模型;预测时须保持网络结构与像素预处理一致。恢复完整训练还涉及优化器状态,本段只演示保存后用于预测。
小车实验:随机数与 Q 表
小车的表格强化学习使用 NumPy。Q 表为每个状态保存四个动作的价值,取出一行就得到 choose_action 接收的 values。
import numpy as np
rng = np.random.default_rng(42)
q_table = np.zeros((16 * 16, 4), dtype=np.float64)
state = 3 * 16 + 5 # 第 3 行、第 5 列的状态编号
q_table[state] = [1., 3., 3., 0.]
values = q_table[state]
best_actions = np.flatnonzero(values == values.max())
action = int(rng.choice(best_actions))
print(best_actions, action) # 候选为 [1, 2],选其中一个
这里用“行号×地图宽度+列号”演示状态编号;正式练习接收外层传入的 Q 值,不需要重写环境。rng.random() 生成 [0, 1) 的随机数,rng.integers(4) 从 0~3 均匀取一个整数,rng.choice(indices) 从候选索引中取一个。
epsilon-greedy 用 rng.random() < epsilon 决定是否探索。探索从全部动作中抽取;利用从所有最高价值动作中抽取。因此 argmax() 固定取第一个并列最大值,不满足本题随机打破平局的要求。只在外部创建一次生成器,把它传进函数;每次调用都重设种子会反复从同一随机序列起点开始。
更新函数返回一个新数值,由外层写回 Q 表。三种方法的差别在于下一步价值:
| 方法 | 用于组成目标的下一步价值 |
|---|---|
| Q-learning | next_values.max() |
| SARSA | 已经实际选中的下一动作价值 next_value |
| Expected SARSA | epsilon * next_values.mean() + (1 - epsilon) * next_values.max() |
未终止时,目标是 reward + gamma * 下一步价值;真正终止时只用 reward。再按 current + alpha * (target - current) 修正当前估计。达到步数上限是截断,本实验仍保留未来价值。原理见Q-learning 与 SARSA。
数据可视化
折线图用于观察训练变化,散点图用于查看样本分布。以下使用实验依赖中的 Plotly 绘制误差曲线:
import plotly.graph_objects as go
figure = go.Figure(go.Scatter(
x=[1, 2, 3, 4], y=[0.8, 0.5, 0.4, 0.35], mode="lines+markers"
))
figure.update_layout(xaxis_title="Epoch", yaxis_title="Loss")
figure.write_html("training-curve.html", auto_open=False)
运行后在当前文件夹打开 training-curve.html。这里四个数是说明用的数据;记录真实实验时,要用实际运行的损失。图题与坐标轴使用英文,报告里再解释数据来源。训练损失下降只能说明模型更贴合训练目标,还要结合验证表现判断。
常见错误与排查
报错信息的末行通常包含错误类型,上方列出调用位置。根据文件名和行号定位代码,再检查输入的形状、类型与范围。
| 现象 | 检查项 |
|---|---|
NotImplementedError |
是否仍有填空未完成,或编辑了另一个文件 |
IndentationError / SyntaxError |
缩进、冒号、括号,以及是否用了中文标点 |
ModuleNotFoundError |
依赖是否安装在当前运行的虚拟环境中 |
函数返回 None |
是否漏了 return,或只在某个分支返回 |
mat1 and mat2 shapes cannot be multiplied |
展平后的特征数是否等于 Linear 的输入长度 |
| 卷积提示通道数不匹配 | 是否缺少通道轴,或把 HWC 当成 NCHW |
| 交叉熵提示标签类型不对 | 标签是否为 (N,) 的 torch.long,范围是否为 0~C-1 |
| 无法直接转换成 NumPy | 是否需要先 .detach().cpu() |
| 训练参数一直不变 | 是否调用了 backward、step,优化器是否绑定当前模型 |
| 准确率高得反常 | 是否把答案放进特征,或拿训练数据当验证数据 |
使用可手算的小样本验证函数。用 assert 检查形状,np.allclose 检查浮点结果,再运行实验页的题目检查。
参考:Python 函数与控制流 · NumPy 数组入门 · 神经网络与深度学习 · 项目实践。
