手写数字识别,应该是机器学习入门阶段绕不开的一个坎。我当年做“多元分类及前向传播”这份课后作业时,前前后后磨了好几个晚上,才把 one-vs-all 逻辑回归和两层神经网络这两条路线真正跑明白。现在回头看,这道题的价值远不止“交个作业”那么简单:它用同一个数据集把线性模型和神经网络串起来,让你直观感受到分类问题里两种思路的差异,也为后面的反向传播埋下了最重要的伏笔。
这篇内容想把这份经典作业彻底拆开,从原理到代码、从细节到踩坑,完整过一遍。适合正在刷课、被作业卡住的同学,也适合工作几年后想回炉复习多元分类和神经网络基础的人。里面的公式没有特别复杂的,但每一个都有用;代码层面我会给到可以直接复现的版本,并补上一些课程讲义里不会写的实操心得。
1. 项目整体拆解:这道作业练的是两条技术主线
1.1 前半段是“把多分类拆成多个二分类”
作业第一部分是多元分类,表面上是“用逻辑回归识别手写数字 0-9”,但真正的教学意图是强迫你理解一个核心思想:多分类问题可以被拆解成多个二分类问题。常见的拆法有两种,一种叫 One-vs-All(一对多),一种叫 One-vs-One(一对一)。这门课里用的是前者:对每一个类别,训练一个“是不是这个类别”的二分类器。10 个数字就要训练 10 个分类器,每个分类器都维护自己独立的一组参数。
这个拆解思维在真实工作中也特别常见。比如做多标签文本分类、多目标检测,本质上都是“多分类”甚至“多任务”,而最初的 baseline 往往就是“每个类别一个二分类器”的思路。因为实现简单、调参直接、可以并行训练,遇到类别不互斥的场景甚至只能这么拆。所以别小看作业里的这一步,它是很多工程方案的原型。
1.2 后半段是在为神经网络做铺垫
作业第二部分的标题是“前向传播”,但真正的作用是让你在还没接触反向传播之前,先学会用“层”的视角去看模型。题目直接给出一个已经训练好的两层神经网络的权重,你要做的就是把输入数据从输入层逐步传到输出层,最后得到 10 个类别的预测概率。
这一步很多人会不以为然,觉得只是“套公式算一遍”。但我恰恰认为这是全作业最值钱的地方。如果你漏掉这个练习直接去啃反向传播,很容易被那一堆下标和链式法则绕晕;反过来,如果你先把前向传播每一层的形状变化、每一步的矩阵运算都算熟了,后面学反向传播时,思路会清晰很多——反向传播本质上就是“沿着前向传播的路径往回求梯度”。
1.3 同一个数据集,两种不同风格的路
数据完全是同一份:5000 张 20×20 的手写数字灰度图。训练逻辑回归分类器时,你需要自己从零训练;做神经网络部分时,权重已经提供好了,只做推理。把两条路线放在一起对比,能明显看出模型表达能力的差异。
| 对比项 | One-vs-All 逻辑回归 | 两层神经网络 |
|---|---|---|
| 模型本质 | 10 个独立的线性分类器 | 1 个带隐藏层的非线性网络 |
| 参数量 | 10×(400+1),约 4010 个 | 25×401 + 10×26,约 10285 个 |
| 训练方式 | 自己训练 10 个分类器 | 直接加载权重,只做前向传播 |
| 需要关注的坑 | 正则化系数、代价函数收敛 | 维度匹配、激活函数、向量化 |
| 参考精度 | 95% 上下(取决于正则参数) | 97.5%(给定权重) |
这个表格基本就是整份作业的浓缩版。逻辑回归部分参数量小、可解释性强,但决策边界是线性的,400 个像素特征并没有被进一步组合;神经网络部分参数量翻了一倍多,但隐藏层能自动组合像素形成“笔画”“边缘”等中层特征,所以精度更高。理解了这两种表达能力的差异,后面对“为什么要上深度学习”这个问题,你会有自己的答案,而不是只跟着热点走。
2. 多元分类的核心:One-vs-All 到底在做什么
2.1 从二分类到多分类的思维转换
单个逻辑回归分类器做的事很简单:输入特征 x,输出一个 0 到 1 之间的概率,再设定一个阈值(通常是 0.5)来做出“是”或“不是”的判断。但面对 10 个数字,如果只训练一个模型让它直接输出 10 个类别,数学上会变得非常别扭。于是作业用了这个看起来“笨”但极其稳定的办法:训练 10 个独立的二分类器。第 k 个分类器只负责回答一个问题:“这张图是不是数字 k?”它的训练标签是 1(是)或 0(不是)。
到了预测阶段,就把同一张图喂给全部 10 个分类器,每个分类器输出一个“是数字 k”的概率,哪个概率最大,就判定为哪个数字。这 10 个概率可以横向比较,所以不需要每个分类器都设阈值,直接取 argmax 就行。
这个方案理论上不一定是最优的,但优点是简单、稳、可并行。哪怕现在很多工业级系统做大规模多分类时,也经常会在某个环节套用“多个二分类器组合”的思想,只是内部换成了更复杂的模型而已。特别地,如果类别之间并不互斥(比如一张图里同时出现猫和狗),那 one-vs-all 这种拆法甚至就是唯一合理的选择。
2.2 代价函数与正则化:细节决定收敛
第 k 个分类器的代价函数,写出来就是标准的逻辑回归代价形式,但有两个细节必须留意。
第一,这里的标签 y 已经二值化了,不再是原始数字 0-9。原始数据里数字 5 的标签是 5,但训练“是否为 5”这个分类器时,标签要变成 1,其他数字的标签统一变成 0。这个转换过程有人会漏掉,结果训练出来的分类器跟没训练一样,代价函数还居高不下。
第二,正则化项一定要加。作业要求补全正则化逻辑回归的代价函数与梯度,就是为了让你体会“正则化系数 lambda 对结果的影响”。我调试时发现一个很有意思的现象:lambda 设得太小,比如 0.001,训练出来的分类器在训练集上精度可能很高,但你把权重画成图像,能看到很多杂乱的高频噪声,这就是典型的过拟合;lambda 设得太大,比如 10,模型则倾向于把什么都预测为“不是”,整体精度掉得厉害。多试几个 lambda 值,你对“正则化控制模型复杂度”这句话的理解会变得非常具体。
标准的带正则项的代价函数长这样(以第 k 个分类器为例):
J(θ) = -(1/m) * Σ [ y * log(h(x)) + (1-y) * log(1-h(x)) ] + (λ/(2m)) * Σ θ_j^2注意正则化项里的求和从 j=1 开始,也就是说不惩罚 θ_0 这一项。梯度也随之多出一项:
∂J/∂θ_j = (1/m) * Σ (h(x) - y) * x_j, j = 0 ∂J/∂θ_j = (1/m) * Σ (h(x) - y) * x_j + (λ/m) * θ_j, j >= 1这个“θ_0 不惩罚”的细节,在向量化实现里如果不小心,很容易把 θ_0 也一起正则化,导致结果有细微偏差。正确做法是在算正则项时先把 θ 复制一份,再把第 0 位设为 0。
2.3 优化器的选择:别老想着自己手写梯度下降
作业里明确要求用现成的优化函数,而不是自己写梯度下降循环。这不是偷懒,是专业习惯。手写梯度下降意味着要自己设学习率、自己确认迭代次数、自己判断收敛,调试成本很高。而现成优化器内部通常采用了更聪明的策略(比如共轭梯度或者 L-BFGS),通常几百轮迭代就能收敛到很低的代价。
我自己在这踩过坑:刚开始固执地手写梯度下降,学习率设大了,代价曲线直接震荡;设小了,半天不收敛。后来老老实实用 scipy.optimize.minimize,精度立刻上去了。遇到这类“已有成熟工具”的场景,优先用工具,把精力放在理解模型本身,而不是重新发明优化算法。
2.4 参数初始化与数值稳定技巧
逻辑回归的代价函数是凸的,所以参数初始化直接全零就行。这跟后面神经网络不同——神经网络因为有非线性激活函数,代价函数非凸,必须用随机初始化打破对称性。如果你好奇“从不同初始点出发会不会得到不同结果”,可以试试随机初始化逻辑回归参数,最后会发现收敛到同一个最优区域,只是迭代路径不同。
数值稳定方面,sigmoid 函数的值域是 (0,1),但浮点计算时很容易出现非常接近 0 或非常接近 1 的值。直接对它们取 log 会得到 -inf 或 0,代价函数变成 NaN,程序直接崩。解决办法有两个:第一个是在计算代价时对预测概率做裁剪,比如用 np.clip(pred, 1e-15, 1 - 1e-15);第二个是重写代价函数的对数部分,把 log(sigmoid(z)) 化简成对 z 的直接运算,从源头避开中间值溢出。作业阶段用裁剪就够了,但心里要知道有第二种更优雅的写法,工程上很实用。
3. 前向传播:一张图是怎么在网络里流动的
3.1 网络结构与参数维度推导
给的图像是 20×20 像素,展开后变成 400 维向量。网络结构如下:
- 输入层:400 个单元,外加 1 个偏置单元
- 隐藏层:25 个单元,外加 1 个偏置单元
- 输出层:10 个单元
两个权重矩阵的维度是:
Theta1: 25 × 401 Theta2: 10 × 26很多初学者在这里被维度搞晕:明明输入是 400,为什么 Theta1 有 401 列?答案就是偏置单元。偏置的作用,相当于让决策边界不必强制经过原点,也就是线性方程里的常数项 b。没有它,模型容量会明显打折扣。
拿一层来换算一下:隐藏层有 25 个神经元,每个神经元做的事情是“对 401 个输入(400 个像素 + 1 个偏置)做加权求和,再经过激活函数”。所以这一层需要的权重数量就是 25×401。输出层同理,10 个神经元,每个接收隐藏层的 26 个输出(25 个激活值 + 1 个偏置),所以是 10×26。
这个维度推导看起来简单,但值得动手写一遍。后面调自己的网络时,最常见的报错就是矩阵维度不匹配,而这种推导能力恰恰就是排查维度错误的底层功夫。
3.2 sigmoid 激活函数为什么够用
作业固定要求用 sigmoid 作为激活函数,它把神经元的线性加权和 z 压缩到 (0,1),可以解释成“某种程度上的激活概率”。sigmoid 有两个公认缺点:一是两端导数接近 0,反向传播时容易梯度消失;二是输出不是零中心化,会让后面网络层的更新效率打折。
但在这个两层的浅层网络里,这些缺点还不明显,用它做输出层尤其合适:10 个输出可以自然理解成 10 个类别的概率估计,跟 one-vs-all 的逻辑回归输出形成呼应。入门阶段先用 sigmoid 把主链路打通,后面再学习 ReLU 等激活函数,理解起来会顺很多,因为你能对比出不同激活函数的实际差别。
3.3 完整计算流程:从输入到预测
前向传播的本质就是反复执行“线性加权 → 激活函数”这个过程。下面用 Python 写一个完整的版本,我加上了每一层的形状注释:
import numpy as np from scipy.special import expit # 数值稳定的 sigmoid def forward_propagation(X, Theta1, Theta2): # X: (m, 400) m = X.shape[0] # 输入层加偏置 X = np.column_stack([np.ones(m), X]) # (m, 401) # 隐藏层 z2 = X @ Theta1.T # (m, 25) a2 = expit(z2) # (m, 25) # 隐藏层加偏置 a2 = np.column_stack([np.ones(m), a2]) # (m, 26) # 输出层 z3 = a2 @ Theta2.T # (m, 10) a3 = expit(z3) # (m, 10) return a3这段代码的核心就是四步:输入拼上偏置列,算隐藏层加权和,激活,再拼偏置列,再算输出层加权和,激活。输出 a3 的每一行是这张图在 10 个类别上的概率分布,取最大概率对应的下标,就是预测结果。
你可以在纸上把这个形状变化链写出来:
(m, 400) → (m, 401) → (m, 25) → (m, 26) → (m, 10)别看这个流程简单,整份作业的“题眼”就在这里。如果你能把这条链子上的每一步都讲清楚,说明前向传播基本过关了。
3.4 向量化实现的价值
前面代码里用的全是矩阵运算,这就是向量化。如果不用向量化,你得写成三层嵌套循环:外层遍历样本,中间层遍历隐藏单元,内层遍历输入维度,速度慢得离谱。这个数据集只有 5000 张图,循环版本可能还能跑,但真实场景数据量翻几个量级之后,非向量化基本没法用。
Python 里的 numpy 底层调用了经过高度优化的线性代数库,矩阵乘法比纯 Python 循环快几个数量级。这也是这门课从头到尾反复强调的工程习惯:尽量把计算写成矩阵运算,而不是 for 循环。这个习惯放在今天的深度学习框架里依然适用——GPU 能加速的,本质上也是大规模矩阵运算。
4. 实操复现:把整个流程完完整整跑一遍
4.1 数据准备与标签预处理
作业提供的是 .mat 格式的数据文件,在 Python 里可以用 scipy.io.loadmat 加载:
import numpy as np from scipy.io import loadmat import matplotlib.pyplot as plt data = loadmat('ex3data1.mat') X = data['X'] # (5000, 400) y = data['y'] # (5000, 1)这里有一个经典的坑:数据集里数字 0 的标签被存成了 10,而不是 0。这是数据集的原始设计,不是 bug。这意味着:
- 如果要按 0-9 显示图像,需要先把标签里的 10 替换成 0;
- 但训练 one-vs-all 分类器时,第 10 个分类器对应的就是数字 0,顺序不能乱。
很多人第一次处理时会在这里绕晕。我的建议是:先不做替换,在代码里统一按照“标签 1-10”处理,最后展示结果时再转换。这样能避免中途把标签改来改去导致混乱。
画图可以从数据里随机抽 100 张,拼成一个 10×10 的网格:
fig, axes = plt.subplots(10, 10, figsize=(8, 8)) for i, ax in enumerate(axes.flat): img = X[np.random.randint(0, len(X))].reshape(20, 20) ax.imshow(img, cmap='gray') ax.axis('off') plt.show()这个可视化不只是为了好看。我通常会顺带打印对应标签,快速检查数据加载有没有出错——比如图像自己就有规律的错位,或者标签整体偏移,这种问题画出来一眼就能发现。
4.2 训练 10 个逻辑回归分类器
第一步先把带正则项的代价函数和梯度写出来。注意下面代码里处理 θ_0 的正则化方式:
def sigmoid(z): return 1 / (1 + np.exp(-z)) def lr_cost_function(theta, X, y, lambda_): m = len(y) h = sigmoid(X @ theta) # 复制一份theta,把θ_0置为0,避免正则化惩罚偏置项 theta_reg = theta.copy() theta_reg[0] = 0 # 代价 J = (-1 / m) * (y @ np.log(h + 1e-15) + (1 - y) @ np.log(1 - h + 1e-15)) J += (lambda_ / (2 * m)) * np.sum(theta_reg ** 2) # 梯度 grad = (1 / m) * (X.T @ (h - y)) + (lambda_ / m) * theta_reg return J, grad然后是核心的训练循环。对每个类别 k,把原始标签转换成“是否为 k”的二值标签,然后用 scipy.optimize.minimize 优化:
from scipy.optimize import minimize m = X.shape[0] X_biased = np.column_stack([np.ones(m), X]) # (m, 401) lambda_ = 1.0 all_theta = [] for k in range(1, 11): # 标签 1 到 10,其中 10 代表数字 0 y_k = (y.flatten() == k).astype(int) theta_init = np.zeros(X_biased.shape[1]) res = minimize( lambda t: lr_cost_function(t, X_biased, y_k, lambda_), theta_init, method='BFGS', jac=True ) all_theta.append(res.x) all_theta = np.array(all_theta) # (10, 401)这里有几个值得说的点:
- 优化方法选 BFGS,配合 jac=True,表示我们已经提供了梯度解析表达式,优化器会收敛得很快;
- lambda 设 1.0 在这个数据集上算是比较稳的默认值,精度通常在 95% 附近;
- 如果你把 method 换成 'L-BFGS-B' 或者 'TNC',结果不会有太大差别,但这类拟牛顿法都比手写梯度下降省心。
预测阶段,把 X_biased 和 all_theta 做一次矩阵乘法,得到 10 个分类器各自的概率,再取每行最大值对应的索引:
proba = sigmoid(X_biased @ all_theta.T) # (5000, 10),每一列对应一个分类器 pred = np.argmax(proba, axis=1) + 1 # 索引 0-9 → 标签 1-10 pred[pred == 10] = 0 # 标签 10 → 数字 0 acc = np.mean(pred == y.flatten()) print(f'One-vs-All 准确率: {acc:.4f}')这里 argmax 之后要加 1,是因为索引从 0 开始,而标签是从 1 开始的。这个 +1 和后面的 10→0 映射,是作业里最容易出 bug 的两个细节。
4.3 实现前向传播
神经网络部分的权重已经给定,在 ex3weights.mat 文件里。加载数据后直接调用前向传播函数:
weights = loadmat('ex3weights.mat') Theta1 = weights['Theta1'] # (25, 401) Theta2 = weights['Theta2'] # (10, 26) a3 = forward_propagation(X, Theta1, Theta2) pred_nn = np.argmax(a3, axis=1) + 1 pred_nn[pred_nn == 10] = 0 acc_nn = np.mean(pred_nn == y.flatten()) print(f'神经网络准确率: {acc_nn:.4f}')跑下来,神经网络准确率通常稳定在 97.5% 左右,比逻辑回归高出一截。这提升看起来不大,但在 5000 个样本上,相当于多正确分类了 100 多张图,已经是很明显的差异了。
做完精度统计后,我强烈建议你做一步样本级检查:随机挑几张图片,同时打印真实标签、逻辑回归预测、神经网络预测。这一步有两层价值:一是确认预测结果并不只是数字上好看,而是真的“看对了图”;二是能直观看到哪些样本逻辑回归分错了、神经网络却能分对,从而感受到非线性模型的表达能力。
4.4 参数可视化:看看模型学到的“笔画模板”
神经网络部分最有意思的环节,是把第一层权重 Theta1 的每一行画成图像。每一行对应隐藏层的一个神经元,它和一个 20×20 的模板等价:
fig, axes = plt.subplots(5, 5, figsize=(8, 8)) for i, ax in enumerate(axes.flat): ax.imshow(Theta1[i, 1:].reshape(20, 20), cmap='gray') ax.axis('off') plt.show()画出来之后,你会看到一些很有意思的图案:有的权重像竖线检测器,有的像横线检测器,有的像弧线检测器。这就是“隐藏层自动学习特征组合”的直观证据——它不是直接拿像素做判断,而是先用这些基础模板去匹配笔画,再用笔画组合判断数字。
这个可视化特别适合用来向别人解释“神经网络不是完全的黑箱”。虽然我们很难把高层语义完全解释清楚,但至少在浅层网络上,模型内部结构是完全可以观察和理解的。顺着这个思路往后学卷积神经网络时,你会看到同样的可视化套路:低层学边缘,中层学局部形状,高层学语义部件。
5. 常见问题与排查实录
5.1 代价函数输出 NaN,怎么排查?
训练逻辑回归时代价变成 NaN,大概率是 log(0) 造成的。sigmoid 输出非常接近 0 或 1 时,直接取对数会得到 -inf 或 0,导致代价变成 NaN。
解决思路有两个:
- 在代价里对 h 做裁剪,比如 np.clip(h, 1e-15, 1 - 1e-15);
- 更彻底的办法是重写对数部分,用对 z 直接操作的稳定形式,避免中间过程出现极端值。
作业阶段用裁剪就能解决问题,但如果你要在工程里实现一个逻辑回归的稳定版本,我建议直接采用第二种方式,因为裁剪本质上只是掩盖问题,而不是从原理上消除数值风险。
5.2 矩阵维度对不上,报错信息看不懂?
前向传播时的维度错误是最常见的。典型的几种情况:
| 错误场景 | 原因 | 解决 |
|---|---|---|
| X @ Theta1.T 报错 | X 没有加偏置列 | 先 column_stack 一列 1 |
| Theta1、Theta2 行列搞反 | 权重矩阵的存储顺序没看清 | 打印 shape,确认 (25,401) 和 (10,26) |
| 输出层维度对不上 | 隐藏层加偏置后忘了更新变量 | 算完 a2 后 column_stack 再赋给 a2 |
我的排查习惯是:每个矩阵运算前后都打印 shape。不要嫌麻烦,把这条链的 shape 变化写成注释放在代码里(就像 3.3 节那样),后面调试会省很多时间。
5.3 准确率一直很低,哪里出了问题?
如果逻辑回归准确率低于 90%,优先检查三件事:
- 正则系数是不是设得过大(比如 lambda=100)。正则太强会把模型往“均匀预测”方向拉,分类器整体变得保守;
- 标签映射有没有错位。数字 0 的标签是不是被当成 0 而不是 10?argmax 之后是不是忘了加 1?
- 有没有在训练时把偏置列重复添加。有人会在循环里给 X 加一次偏置,进入下一个分类器时又加一次,导致特征维度膨胀,结果很差。
神经网络部分如果精度明显低于 97%,最可能是加载权重时 Theta1、Theta2 顺序搞反了,或者前向传播漏了某一层的偏置。
5.4 One-vs-All 和 one-hot 编码到底什么关系
有同学会把 one-vs-all 和 one-hot 编码混为一谈。两者确实相关,但不是一回事。one-hot 编码是一种标签表示形式,把一个类别标签展开成向量,比如“类别 3”变成 [0,0,1,0,...];而 one-vs-all 是一种训练策略,分别训练“是否属于类别 k”的二分类器。
逻辑回归作业里通常不会强制要求 one-hot 编码,因为每个分类器的标签本来就是二值的。但后面的神经网络作业、深度学习框架里,one-hot 是标准操作,因为交叉熵损失函数需要这样的标签形式。理解两者的边界,至少不会在教程里看到 one-hot 时一脸懵。
5.5 画出来的权重图方向不对,影响结果吗?
用 imshow 画 Theta1 时,图像可能看起来是横置或翻转的,这是因为 imshow 默认的坐标原点和数据存储顺序不完全一致。这个显示问题不影响任何模型精度,但会影响你对“权重模板”的直观理解。处理办法是在 imshow 里指定 origin='upper' 或者按自己的需求调整 reshape 的维度方向。强迫症建议直接处理掉,看着舒服,也好跟数据集里图片的方向对比。
6. 一点实操体会
这份作业最值钱的地方,是把“多分类”和“前向传播”两件事在同一个数据集上做了串联。我对神经网络的理解,就是从一步步把形状变化写出来开始的。后来学到反向传播时,发现很多推导其实是在前向传播的基础上倒着走一遍——如果这一步基础打得牢,后面会轻松很多。
最后分享一个我保留到现在的小习惯:做这类作业时,别急着抄现成代码,先拿张纸,把每一层的输入维度、输出维度、参数矩阵形状写在纸上,再动手实现。画网络结构、排查维度错误、跟别人讨论模型设计,几乎全靠这个看似笨拙的功夫。
如果你刚做完这份作业,下一步不妨试试两个方向:一是给逻辑回归部分换不同的 lambda 和优化器,记录精度变化,形成自己的调参手感;二是把前向传播代码改写成带 for 循环的版本,感受一下向量化带来的速度差异。这些扩展练习花不了多少时间,但对理解的帮助远超作业本身。