
如果你已经跟我追到这个系列的第二周第六篇那说明前面的功课做得不错。二分类、逻辑回归、损失函数、梯度下降这些概念这时候应该已经形成一个比较完整的闭环了。很多人学到这一带会有个共同的感觉原理都看懂了公式也能推可一旦要自己动手把训练过程写出来代码要么慢得离谱要么跑出来的结果莫名其妙。我当初就是这样所以这篇笔记我想专门把“向量化”和“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 值。再套一个 sigmoidA 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.dot和np.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)的矩阵每行是不同食物每列是不同营养素要计算每种营养素的百分比就需要对每一列求和然后让每列的原始值除以对应列的和。这就要用到axis0的sum再利用广播完成除法。我自己总结了一个更直观的理解方式广播本质上是在“复制”但它是按需复制不会真的在内存里腾出一份完整副本而是通过计算时的扩展来实现。你在逻辑上可以把它看作复制但性能上它非常高效。所以你在写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.sum的axis参数。你要对每一列求和用axis0对每一行求和用axis1。但如果不加keepdimsTrue求和后结果形状可能会从(4, 1)变成(4,)这又回到刚才说的秩为 1 的坑。所以我建议在实现中统一习惯col_sum np.sum(X, axis0, keepdimsTrue)这样得到的col_sum形状是(1, 4)后面做广播时意图非常明确。我在写逻辑回归反向传播时db也用同样的方式db np.sum(dZ, axis1, keepdimsTrue) / mdZ的形状是(1, m)对行求和得到(1, 1)再除以样本数完美的标量梯度。如果你不写keepdimsnp.sum(dZ)也能算出正确数值但如果你要继续拼接矩阵运算形状不一致会带来很多麻烦。养成写keepdimsTrue的习惯等于给自己未来的代码减少了一类潜在 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, axis1, keepdimsTrue) / m # (1, 1) grads {dw: dw, db: db} return grads, cost def optimize(w, b, X, Y, num_iterations, learning_rate, print_costFalse): 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(fCost 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_iterations1000, learning_rate0.1, print_costTrue ) # 预测并计算准确率 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)忘了 keepdimscost标量数组无法打印只要某一栏对不上训练结果一定会出问题。强烈建议在每次矩阵乘法之后用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, axis1, keepdimsTrue) X_std np.std(X, axis1, keepdimsTrue) X_normalized (X - X_mean) / X_std做完之后再用 X_normalized 训练通常情况会好很多。我在课程作业里很少会遇到这个问题因为作业数据基本已经预处理好了但一旦你自己拿真实数据跑这是绕不开的一步。5.4 向量化代码很难调试怎么拆开验证向量化代码的一个缺点是不好打断点。循环版本你可以一行一行看中间变量向量化版本一条语句就把几十个样本的结果都算完了。为了验证正确性可以用一个很小的数据集比如 m3n2手动算出每个样本的 z、a、dw再和向量化代码的输出对比。误差在 1e-8 以内基本就没问题。还有一个笨但有效的方法先用循环版本写一版跑通再用向量化版本替换对比两者训练出来的最终参数。如果参数一致说明向量化写对了。这个“双版本验证法”看起来麻烦但比自己反复检查公式快得多。反正你早晚要靠这个思路排查更复杂的神经网络不如现在就用起来。6. 这个练习做完后你对后面课程的理解会完全不同第二周的向量化实现做完之后我回头看第三周、第四周关于浅层和深层神经网络的内容会发现一切都是顺理成章的。因为深层网络无非是把“线性变换 激活函数”这个模块反复堆叠而每一层的前向和反向传播本质上都是你在第二周已经手写过的那些矩阵运算。区别只是多了一层循环遍历网络层数以及激活函数从 sigmoid 换成了 ReLU 或 tanh。我个人的经验是手写逻辑回归虽然只有几十行代码但它让你把“训练”这个概念从纸面公式变成了实实在在的内存变量更新。你亲手看到 w 一步步变化代价函数一步一步下降那种感觉是看视频和读讲义完全替代不了的。所以如果你现在还在纠结哪儿没看懂不如先把代码跑起来哪怕复制粘贴到 Jupyter Notebook 里一行一行加print把它变成自己的东西。后面如果继续写这个系列我会进入浅层神经网络的向量化实现和梯度推导。如果你在跑今天这份代码时遇到任何奇怪的报错或者 reshape 怎么都不对欢迎把你的报错信息发在评论区我很乐意帮你一起看看。