向量化逻辑回归实战:用NumPy矩阵运算替代循环训练
2026/9/24 21:23:47 网站建设 项目流程

如果你已经跟我追到这个系列的第二周第六篇,那说明前面的功课做得不错。二分类、逻辑回归、损失函数、梯度下降这些概念,这时候应该已经形成一个比较完整的闭环了。很多人学到这一带会有个共同的感觉:原理都看懂了,公式也能推,可一旦要自己动手把训练过程写出来,代码要么慢得离谱,要么跑出来的结果莫名其妙。我当初就是这样,所以这篇笔记我想专门把“向量化”和“Python 实现”这一层彻底聊透。

这篇内容对应的是吴恩达深度学习课程第二周里,从“数学推导”走向“真实可运行代码”的关键一公里。它的核心价值不只是让你把 for 循环改成矩阵运算,而是帮你建立一种看待神经网络实现的正确方式:把样本看成矩阵的列、把参数看成矩阵的元素、把一次前向传播看成一次矩阵乘法。这个思维一旦建立起来,后面学卷积、学循环网络、用 PyTorch 或 TensorFlow 写模型,都会顺畅很多。适合正在啃这门课、或者学完理论但卡在代码阶段的读者,也适合那些希望搞清楚为什么深度学习代码普遍“长得像矩阵运算”的初学者。

1. 为什么第二周的第六部分,突然开始强调向量化

1.1 课程安排背后的教学逻辑

先梳理一下时间线。第二周前半段,吴恩达老师一直在用最朴素的方式讲逻辑回归:先定义单个样本的损失函数,再定义整个训练集的代价函数,然后用梯度下降来更新参数。手推公式的时候,一切都很直观——一个 for 循环遍历所有样本,另一个 for 循环遍历所有权重,这完全符合人脑的直观理解。但为什么到了第六部分,他专门停下来强调向量化?因为如果真按这种“最直观”的方式写代码,训练一个稍微大一点的数据集就会慢到让你怀疑人生。

我自己试过,用纯 Python 循环实现逻辑回归,在 1000 个样本、20 个特征的数据集上跑 2000 次迭代,大概要几十秒。听起来也不是不能等,但换到真实场景,比如 5 万张图片、每张图片展开成 12288 维向量,同样逻辑回归,单次迭代就要遍历 5 万个样本,每个样本还要做 12288 次乘法累加。这还只是前向传播,后面反向传播还要再来一遍。如果不做向量化,训练根本没法在合理时间内完成。

吴恩达的课程设计很聪明:先用 for 循环帮你理解原理,再用向量化告诉你“真实世界怎么做”。这两步缺一不可。只学循环,你能理解公式但跑不动真实数据;只学向量化,你代码写出来了却可能不理解矩阵里的每个元素到底在算什么。第六部分正是那个转折点。

1.2 向量化到底是什么,为什么能快这么多

向量化,简单说就是把“对单个元素反复操作”变成“对整个数组一次性操作”。在 Python 里,它的载体是 NumPy。NumPy 底层用 C 语言实现,数组在内存中是连续存储的,很多运算会被编译优化,甚至调用底层的 BLAS(基础线性代数子程序库)这类高性能数值计算库。更关键的是,现代 CPU 支持 SIMD 指令,可以一次对多个数据执行同一条指令。你写的np.dot(w.T, x)在底层可能同时算好几组乘法,而普通的 Python for 循环只能一个一个来。

打个比方,你有一百箱苹果要搬到仓库,for 循环是一个人去搬,一趟搬一箱;向量化是叫来一个传送带,所有箱子同时往前送。传送带本身的搭建和调试需要一点成本,但一旦跑起来,效率完全不在一个量级上。这个差距在数据量小的时候不明显,但数据越大,效果越夸张。我在自己机器上测过,计算两个 100 万维向量的内积,纯 Python 循环大约需要 120 毫秒,而np.dot只需要 1 到 2 毫秒,差了差不多 60 倍。这还只是内积,如果换成完整的梯度下降流程,里面全是这种运算,累积起来的差距就是几百倍。

当然,向量化也不只是快。还有一个容易被忽略的好处:代码更短、更不容易出错。你只需要维护一个矩阵乘法的表达式,而不是一堆嵌套循环,调试起来轻松很多。尤其是后面学反向传播,整个计算过程可以用几个清晰的矩阵运算串起来,这比盯着几十行循环找 bug 要舒服太多了。

2. 把逻辑回归的前向传播,从循环改写成矩阵乘法

2.1 从单个样本到整个训练集

第二周课程里,逻辑回归的前向传播在单个样本上长这样:

z = w^T * x + b a = sigmoid(z)

写成循环,就是遍历每个样本,分别算一次 z 和 a。但现在我们手里有 m 个样本,我们为什么不一次性把所有样本都算完呢?关键做法是把所有样本堆成一个矩阵 X,每一列是一个样本。如果每个样本有 n 个特征,那么 X 的形状就是 (n, m)。权重 w 是 (n, 1) 的列向量,b 是一个标量。

于是整个训练集的线性部分可以写成:

Z = w^T * X + b

这里的 Z 形状是 (1, m),每一列对应一个样本的 z 值。再套一个 sigmoid:

A = sigmoid(Z)

A 的形状也是 (1, m),表示 m 个样本各自的预测概率。

这个阶段你不需要理解任何新数学,它和单个样本的公式完全一样,只是把“标量运算”换成了“矩阵运算”。我当初第一次看这个转换时,最大的困惑在于:为什么 W 是 (n, 1),X 是 (n, m),两者怎么相乘?答案是转置。w.T的形状是 (1, n),和 (n, m) 相乘得到 (1, m),维度刚好匹配。这个“维度匹配”的直觉很重要,后边写任何神经网络层都靠它。

2.2 动手对比两版代码,差距一眼可见

我用一个简单的例子对比过。假设有一个二分类数据集,特征维度是 5,样本数 1000,随机生成数据。循环版本的线性部分是这样的:

import numpy as np # 模拟数据 m = 1000 n = 5 X = np.random.randn(n, m) w = np.random.randn(n, 1) b = 0.5 # 循环版:遍历每个样本 z = np.zeros((1, m)) for i in range(m): z[0, i] = np.dot(w.T, X[:, i].reshape(-1, 1)) + b

向量化版本则是:

# 向量化版:一次矩阵乘法 Z = np.dot(w.T, X) + b

如果你在 Jupyter Notebook 里用%timeit测一下,循环版本可能是毫秒级,而向量化版本往往是微秒级。最重要的是,这两者在数学上严格等价。我经常跟朋友说,深度学习的代码“短”不是因为它偷懒,而是因为它把重复模式压缩成了矩阵的一维,你看到的每一行都在处理一整批数据。

顺便说一下,+ b这里其实用到了广播(broadcasting)。b 是标量,Z 是 (1, m) 的矩阵,NumPy 会把 b 自动扩展成和 Z 相同形状,然后逐元素相加。这个概念下一节细讲,它是个大坑,也是个大宝贝。

2.3 反向传播同样可以向量化

前向传播搞定后,反向传播也照猫画虎。课程里推导出的梯度公式在单个样本上长这样:

dz = a - y dw = x * dz db = dz

对整个训练集,dZ 就是一个 (1, m) 的矩阵,元素是每个样本的a_i - y_i。然后:

dw = (1/m) * X * dZ^T db = (1/m) * np.sum(dZ)

这里的X * dZ^T是矩阵乘法,形状是 (n, m) 乘 (m, 1) 得到 (n, 1);np.sum(dZ)则是对所有样本的 dz 求和再除以 m。如果基础不牢,很容易把np.dotnp.sum的轴向参数搞混,所以我习惯先写注释标明每一步的矩阵形状,再开始写表达式。

这一段的收获不只是会写公式,更重要的是建立“矩阵形状思维”。每次写一个矩阵乘法,先在心里核对这些维度,是 (n, m) 还是 (1, m),乘出来应该是什么形状,一旦维度对不上,代码立刻就会报错,而维度的报错信息只要你看懂了,几乎能直接定位问题。

3. Python 广播机制:好用,但坑也多

3.1 广播的三个规则,一次讲清楚

广播是 NumPy 里最实用的机制之一,也是吴恩达在第二周专门拿出一节来讲的内容。简单说,广播允许不同形状的数组进行算术运算,NumPy 会自动把较小的数组扩展成较大的形状。规则可以归纳成这句:从最后一个维度开始比较,如果两个数组的维度相等,或者其中一个为 1,就可以运算;如果都不满足,就报错。

举个例子,(4, 1)的数组和(1, 3)的数组相加,后两个维度分别是 1 和 3,满足“其中一个为 1”,所以第一数组会被扩展成(4, 3),第二个也会被扩展成(4, 3),最终结果就是对应位置相加。吴恩达课程里用了一个食物营养成分表的例子:一个(3, 4)的矩阵,每行是不同食物,每列是不同营养素,要计算每种营养素的百分比,就需要对每一列求和,然后让每列的原始值除以对应列的和。这就要用到axis=0sum,再利用广播完成除法。

我自己总结了一个更直观的理解方式:广播本质上是在“复制”,但它是按需复制,不会真的在内存里腾出一份完整副本,而是通过计算时的扩展来实现。你在逻辑上可以把它看作复制,但性能上它非常高效。所以你在写Z = np.dot(w.T, X) + b时,虽然 b 只是个标量,但直觉上可以认为 NumPy 把 b 复制成了和 Z 一样的 (1, m) 矩阵再相加。这样理解公式,非常有助于 debug。

3.2 一个典型坑:shape 是 (m,) 而不是 (m, 1)

广播虽然好用,但也有一个特别容易踩的坑:生成“秩为 1 的数组”,也就是 shape 像(m,)这样的结构。它既不是行向量也不是列向量,它在广播时行为非常诡异。举个例子,np.random.randn(5)生成的就是(5,),而np.random.randn(5, 1)生成的是真正的列向量。前者在很多运算中会导致你以为自己在处理向量,实际上却产生了一维数组。比如对(5,)做转置,它还是(5,),不会变成(1, 5)

我当年就在这上面吃过亏:手写某个练习时,计算出来的dw(n,)形状,然后更新w = w - learning_rate * dw。当时代码没报错,但训练曲线忽上忽下,完全不像收敛的样子。后来一查,发现 w 的形状也不知不觉变成了(n,),整个梯度更新在广播机制下等于按行操作,逻辑完全乱了。

怎么避免?两个方法。第一,尽量用reshape或者np.column_stack这类方法把数组显式变成(n, 1)(1, m);第二,写关键运算之前在代码里加assert(w.shape == (n, 1))这种断言,让形状错误尽早暴露。吴恩达在作业里也特意提醒过这一点,我当时没当回事,后来才知道这是多少人掉进去过的坑。

3.3 关于 axis 和 keepdims 的细节

还有一个细节容易被忽略:np.sumaxis参数。你要对每一列求和,用axis=0;对每一行求和,用axis=1。但如果不加keepdims=True,求和后结果形状可能会从(4, 1)变成(4,),这又回到刚才说的秩为 1 的坑。所以我建议在实现中统一习惯:

col_sum = np.sum(X, axis=0, keepdims=True)

这样得到的col_sum形状是(1, 4),后面做广播时意图非常明确。我在写逻辑回归反向传播时,db也用同样的方式:

db = np.sum(dZ, axis=1, keepdims=True) / m

dZ的形状是(1, m),对行求和得到(1, 1),再除以样本数,完美的标量梯度。如果你不写keepdimsnp.sum(dZ)也能算出正确数值,但如果你要继续拼接矩阵运算,形状不一致会带来很多麻烦。养成写keepdims=True的习惯,等于给自己未来的代码减少了一类潜在 bug。

4. 完整实现:向量化的逻辑回归,从初始化到预测

4.1 核心训练代码,带形状注释

说了这么多理论,直接上一份能跑的代码。我用随机生成的数据模拟一个二分类任务,特征是 5 维,样本 1000 个。这样你可以在本地直接跑,不需要额外准备数据集。代码里我把每一步的矩阵形状都写在注释里,方便你对照维度理解。

import numpy as np def sigmoid(z): return 1 / (1 + np.exp(-z)) def initialize_with_zeros(n): w = np.zeros((n, 1)) b = 0.0 return w, b def propagate(w, b, X, Y): """ w: (n, 1) X: (n, m) Y: (1, m) """ m = X.shape[1] # 前向传播 Z = np.dot(w.T, X) + b # (1, m) A = sigmoid(Z) # (1, m) # 代价函数 cost = -np.mean(Y * np.log(A) + (1 - Y) * np.log(1 - A)) # 反向传播 dZ = A - Y # (1, m) dw = np.dot(X, dZ.T) / m # (n, 1) db = np.sum(dZ, axis=1, keepdims=True) / m # (1, 1) grads = {"dw": dw, "db": db} return grads, cost def optimize(w, b, X, Y, num_iterations, learning_rate, print_cost=False): costs = [] for i in range(num_iterations): grads, cost = propagate(w, b, X, Y) dw = grads["dw"] db = grads["db"] w = w - learning_rate * dw b = b - learning_rate * db if i % 100 == 0: costs.append(cost) if print_cost: print(f"Cost after iteration {i}: {cost:.6f}") params = {"w": w, "b": b} grads = {"dw": dw, "db": db} return params, grads, costs def predict(w, b, X): m = X.shape[1] Z = np.dot(w.T, X) + b A = sigmoid(Z) return (A > 0.5).astype(int)

这段代码很直观地展示了整个训练过程。你会发现核心运算就是矩阵乘法和一个 sigmoid,剩下就是反复迭代更新参数。如果你之前一直在看公式,这段代码会帮你在心里把 w、b、X、Y 的形状彻底固化下来。比如dw的公式np.dot(X, dZ.T) / m,你只要算一下维度:(n, m)(m, 1)得到(n, 1),和 w 完全一致,就知道更新没问题。

4.2 跑一个完整的小实验,看代价函数变化

有了上面的函数,我们生成数据来验证一下:

# 生成可线性分离的模拟数据 np.random.seed(42) m = 1000 n = 5 # 随机生成两类数据中心的偏移 X = np.random.randn(n, m) true_w = np.array([[2.0], [-1.5], [0.5], [3.0], [-2.0]]) true_b = 0.7 Z = np.dot(true_w.T, X) + true_b Y = (sigmoid(Z) > 0.5).astype(float).reshape(1, m) # 初始化 w, b = initialize_with_zeros(n) # 训练 params, grads, costs = optimize( w, b, X, Y, num_iterations=1000, learning_rate=0.1, print_cost=True ) # 预测并计算准确率 Y_pred = predict(params["w"], params["b"], X) accuracy = np.mean(Y_pred == Y) * 100 print(f"训练集准确率: {accuracy:.2f}%")

在我自己的环境里跑出来,代价函数从最初的 0.69 左右逐步下降到接近 0,准确率接近 100%。因为数据本身就是线性可分的,这个结果符合预期。你看到代价函数每一百次迭代下降一个数量级或者稳定下降,就说明梯度下降在正常工作。

很多初学者在跑完这段代码后,会问一个问题:为什么不用框架里现成的LogisticRegression?我的回答是:这门课的意义就在于让你知道底层在干什么。等你理解了这一段代码,再看sklearn里的实现,你会觉得理所当然;直接上手框架的人,可能永远搞不懂coef_intercept_是怎么被优化出来的。手写一遍的价值,不在于替代框架,而在于帮你建立对参数空间的直觉。

4.3 训练过程的形状自查清单

我自己在写这个代码的时候,会对照下面这张自查清单,推荐你也用起来:

变量期望形状常见错误
X(n, m)转置错误,变成 (m, n)
w(n, 1)初始化成 (n,)
Z(1, m)忘记 reshape,变成 (m,)
A(1, m)和 Z 不一致
dZ(1, m)误写成 (m, 1)
dw(n, 1)np.dot(X, dZ.T)方向写反
db(1, 1)忘了 keepdims
cost标量数组,无法打印

只要某一栏对不上,训练结果一定会出问题。强烈建议在每次矩阵乘法之后用assert固定形状,尤其是刚开始手写实现的时候。比如:

assert Z.shape == (1, m) assert dw.shape == (n, 1)

这些小习惯能让你把注意力放在算法逻辑而不是维度报错上。

5. 手写实现时的常见问题与排查思路

5.1 代价函数不降反升,大概率是学习率问题

训练过程中最让人崩溃的情况就是代价函数不降反升。我见过不少人自己写完代码后,跑了一次训练,发现 loss 越来越大,第一反应是梯度计算错了。但根据经验,八成是学习率设置得太大了。你可以想象一个人从山顶往下走,步子迈得太大,一步直接跨到对面山坡更高的地方,下一步又跨回来,来回震荡,始终下不来。

排查方法很简单:先把learning_rate调到很小的值,比如 0.001,看代价曲线是否开始下降。如果降了,说明是学习率问题,可以逐步调大,直到找到降得最快又不震荡的值。还有一个技巧是打印每次迭代的代价函数值,不要只在最后打印,这样才能看到变化趋势。

代价函数出现nan也一样。nan通常出现在np.log(0)np.log(1 - 0)这种计算里,原因是 A 被 sigmoid 压到了极接近 0 或 1 的数,取对数后变成负无穷。解决办法是给 log 加一个极小值,比如np.log(A + 1e-8),或者检查数据归一化是否做好。

5.2 矩阵维度报错信息看不懂?先手动验证一个小例子

NumPy 的维度报错信息其实很友好,比如:

ValueError: shapes (5,1000) and (5,1) not aligned: 1000 (dim 1) != 5 (dim 0)

这句话的意思就是:你想做矩阵乘法,但前一个矩阵的列数不等于后一个矩阵的行数。报错里已经给出了形状。遇到这种问题,我的建议是先别急着改代码,拿一个特别小的例子手动算一遍,比如 X 是 (2, 3),w 是 (2, 1),然后在纸上或者注释里写出每个步骤的结果形状。哪个环节对不上,问题就出在哪。这个方法比盯着报错信息猜要高效得多。

另外,np.dot*的区别也经常让人懵。np.dot是矩阵乘法,*是逐元素相乘。对于向量来说,np.dot(w.T, X)做的是标准矩阵乘法;而w.T * X做的是元素级乘法,形状必须完全一致或满足广播条件,语义完全不同。我第一次把np.dot写成*的时候,代码没报错,因为广播把形状强行对齐了,但结果全乱了。排查了很久才发现是运算符的问题。所以看到代码里出现*操作矩阵,先确认你是不是真的想做逐元素乘法。

5.3 预测结果全为同一类别?检查特征归一化

还有一次,我写完逻辑回归后,预测结果全是 0,准确率只有 50% 左右。代驾训练也正常,代价也在下降,就是预测不出来。后来发现是因为特征没有归一化。当特征数值范围差异很大的时候,梯度下降会走很多弯路,甚至收敛到局部很差的位置。尤其是如果某个特征的数值在 1000 量级,另一个在 0.001 量级,权重更新会被大数值特征主导。

解决方法很简单:对 X 做标准化,也就是每个特征减去均值再除以标准差:

X_mean = np.mean(X, axis=1, keepdims=True) X_std = np.std(X, axis=1, keepdims=True) X_normalized = (X - X_mean) / X_std

做完之后再用 X_normalized 训练,通常情况会好很多。我在课程作业里很少会遇到这个问题,因为作业数据基本已经预处理好了,但一旦你自己拿真实数据跑,这是绕不开的一步。

5.4 向量化代码很难调试,怎么拆开验证

向量化代码的一个缺点是不好打断点。循环版本你可以一行一行看中间变量,向量化版本一条语句就把几十个样本的结果都算完了。为了验证正确性,可以用一个很小的数据集,比如 m=3,n=2,手动算出每个样本的 z、a、dw,再和向量化代码的输出对比。误差在 1e-8 以内基本就没问题。

还有一个笨但有效的方法:先用循环版本写一版跑通,再用向量化版本替换,对比两者训练出来的最终参数。如果参数一致,说明向量化写对了。这个“双版本验证法”看起来麻烦,但比自己反复检查公式快得多。反正你早晚要靠这个思路排查更复杂的神经网络,不如现在就用起来。

6. 这个练习做完后,你对后面课程的理解会完全不同

第二周的向量化实现做完之后,我回头看第三周、第四周关于浅层和深层神经网络的内容,会发现一切都是顺理成章的。因为深层网络无非是把“线性变换 + 激活函数”这个模块反复堆叠,而每一层的前向和反向传播,本质上都是你在第二周已经手写过的那些矩阵运算。区别只是多了一层循环遍历网络层数,以及激活函数从 sigmoid 换成了 ReLU 或 tanh。

我个人的经验是,手写逻辑回归虽然只有几十行代码,但它让你把“训练”这个概念从纸面公式变成了实实在在的内存变量更新。你亲手看到 w 一步步变化,代价函数一步一步下降,那种感觉是看视频和读讲义完全替代不了的。所以如果你现在还在纠结哪儿没看懂,不如先把代码跑起来,哪怕复制粘贴到 Jupyter Notebook 里,一行一行加print,把它变成自己的东西。

后面如果继续写这个系列,我会进入浅层神经网络的向量化实现和梯度推导。如果你在跑今天这份代码时遇到任何奇怪的报错,或者 reshape 怎么都不对,欢迎把你的报错信息发在评论区,我很乐意帮你一起看看。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询