先说个结论:如果你正在啃吴恩达的深度学习课程,那第二周的后半段,就是整个课程第一次让你真正“动手算明白”神经网络的地方。前半周的逻辑回归还算线性模型的升级版,到了“神经网络基础(二)”,激活函数、梯度下降、前向传播和反向传播的符号游戏,一下子全涌进来。很多人就是在这里开始掉队的。
这个阶段的定位很明确:它不教你搭大模型,也不讲卷积和Transformer,它做的事情就是帮你在进入第三周的浅层神经网络之前,把最底层的“单个神经元如何学习”这件事彻底讲清楚。学完之后,你应该能自己手写一个带单隐层的二分类网络,并且清楚每一步矩阵运算在干什么。这个能力比记住多少公式重要得多。
这篇文章我就按课程内容的逻辑线,结合我自己当时踩过的坑和后来带人入门时的经验,把这一周后半段的重点拆一遍。该推导的推导,该对比的对比,代码也会放,希望能帮你把这一周真正吃透。
1. 整体设计与思路拆解:第二周为什么是整门课的“地基”
1.1 课程位置:为什么说这是从逻辑回归到神经网络的桥梁
吴恩达这套课的第一门课叫“神经网络和深度学习”,一共四周。第一周基本是科普和行业介绍,让你知道深度学习能干什么;第二周进入神经网络基础,但前半段讲的还是逻辑回归——本质上是一个只有一个“神经元”的模型;到了第二周后半段,才真正开始引入“层”的概念,为第三周的单隐层网络铺路。
这个设计其实很用心。你可以把逻辑回归看成一辆自行车的训练轮,第二周后半段则是在告诉你:自行车真正骑起来的时候,脚蹬和车轮之间是怎么通过链条联动的。链条就是反向传播,脚蹬就是损失函数对参数的梯度。
当时让我觉得特别顺畅的一点是:课程没有直接甩出一个多层网络让你死记公式,而是先用逻辑回归的损失函数和梯度下降建立“求导更新参数”的肌肉记忆,再把同一个逻辑推广到多层的场景。所以如果你前面逻辑回归部分已经算过dW、db,那后面的反向传播就是在做“链式法则+矩阵批量处理”这两个扩展。
1.2 符号约定:吴恩达课程里那套记号为什么必须背下来
第二周后半段开始,符号一下子变多了。上标中括号表示层数,上标小括号表示样本序号,w和b区分权重和偏置,z和a区分线性输出和激活输出。这套符号在后面所有课程里都会一直用,包括卷积神经网络、循环神经网络,甚至Transformer那门课里也延续了类似的习惯。
我见过很多初学者不重视符号,觉得“反正代码里变量名随便起”。但问题在于,吴恩达的作业里、公式推导里、讨论区答疑里,用的全是这套符号。如果你记不住a2和a^{[2]}的区别,看任何后续课程的推导都会非常痛苦。
- a^{[2]}_{(i)}:第2层的第i个样本的激活输出
- z^{[1]}:第1层的线性加权结果,z^{[1]} = W^{[1]}x + b^
- dw^{[1]}:第1层权重参数的梯度,维度与w^{[1]}完全一致
这个基础打好了,后面写自定义层、调框架、看论文时,符号乱流会少很多。
1.3 逻辑回归到浅层网络:多了一个隐藏层,到底多学了什么
逻辑回归是一个“单层”结构:输入x,线性变换Wx+b,再过sigmoid,直接输出预测。它的问题在于,如果你面对的是一条线性不可分的数据分布,比如异或问题,单层逻辑回归怎么学都学不出一个好的分类边界。
单隐层网络就是在中间加了一层“特征再表达”。输入不再直接映射到输出,而是先被这层隐藏神经元的组合改写成新的表示,再由输出层对这个新表示做分类。吴恩达在课程里反复强调:深度学习之所以能处理复杂问题,靠的是中间层自动学习特征,而不是人工设计特征。
第二周后半段,你第一次看到这个“多一层”带来的变化。虽然作业里的例子只是一个平面数据分类,但你已经能感受到,同样的训练数据,在逻辑回归上怎么调都分不开,换成单隐层网络后,分类边界被“折”起来了。那种感觉还挺震撼的,相当于第一次亲眼见到“表示学习”的威力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点:激活函数与非线性
2.1 为什么必须引入非线性:叠加线性层约等于没有叠加
这是第二周后半段最重要的一个“为什么”。吴恩达在视频里用大白话讲过一次:如果你所有的激活函数都是线性的,那把网络叠得再深,最终计算出来的东西本质上还是一个线性函数。
用生活类比来说:复印机把一张纸正常复印100次,出来的还是那页内容,没有多出任何新信息。线性变换的组合依然是线性变换,这是线性代数的基本结论。换句话说,如果没有非线性激活函数,三层神经网络等价于一层,那“深度”就没有意义了。
- 线性到线性的复合:f(g(x)) = W2(W1x + b1) + b2 = (W2W1)x + (W2b1 + b2),仍然是Wx+b的形式
- 只有加入类似sigmoid、tanh、ReLU这样的非线性函数,每一层才能对上一层的结果做“扭曲”,从而拟合任意复杂的函数
这一点看懂了,就理解了为什么ReLU现在这么流行——不是因为好看,是因为它既能提供非线性,又不会像sigmoid那样在深层网络中把梯度压得太小。
2.2 sigmoid、tanh、ReLU:三种主流激活函数的对比
第二周课程主要讲了sigmoid和tanh,但课堂里也提到了ReLU。到了作业阶段,你会发现用不同激活函数,收敛速度和最终效果差别很大。我整理成一张表,方便你对比:
| 函数 | 公式 | 输出范围 | 导数 | 特点 |
|---|---|---|---|---|
| sigmoid | σ(z) = 1 / (1 + e^{-z}) | (0, 1) | σ(z)(1 - σ(z)) | 适合二分类输出层;隐藏层容易梯度消失 |
| tanh | tanh(z) = (e^{z} - e^{-z}) / (e^{z} + e^{-z}) | (-1, 1) | 1 - tanh^2(z) | 均值接近0,收敛通常比sigmoid快;但深层仍会梯度消失 |
| ReLU | max(0, z) | [0, +∞) | 0(z<0),1(z>0) | 计算简单,深层中表现好;但部分神经元可能“死掉” |
我第一次自己搭网络的时候,习惯性地在隐藏层都用sigmoid,结果训练半天损失下降得像蜗牛爬。后来把隐藏层换成tanh,明显好了些;再后来换到ReLU,收敛速度是肉眼可见的提升。这背后的原因就是梯度的大小和传播效率。
sigmoid的问题在于,它的输出均值不是0。比如输入全是正数,那么对权重求梯度时,梯度方向可能全部同号,更新时就会走“之字形”,效率很低。tanh把输出中心拉到0,很好地缓解了这个问题。ReLU则直接解决了正区间梯度恒为1的问题,反向传播时信号衰减大大减小。
2.3 导数怎么记才不容易忘:链式法则的微积分直觉
这一周作业里要求手动算导数,很多人卡在sigmoid的导数上。其实不用死记,只要会微积分基本法则,就能推出来。
sigmoid函数σ(z) = 1 / (1 + e^{-z}),求导时先设分母为u = 1 + e^{-z},则σ = u^{-1},dσ/dz = -u^{-2} · du/dz = -u^{-2} · (-e^{-z}) = e^{-z} / u^2。再看u - 1 = e^{-z},所以导数为 (u-1) / u^2 = 1/u - 1/u^2 = σ(z)(1 - σ(z))。就是课本上那个最经典的式子。
ReLU的导数更简单:z大于0时为1,z小于0时为0,z等于0处习惯上设为0或0.5,影响不大。实际工程里不会在0这个点上纠结。
我当时一个比较好的记忆方式:tanh的导数形式是1 - tanh^2(z),跟sigmoid导数的形式非常对称。只要能记住sigmoid的导数,tanh的也就一起记住了。说到底,导数不是要你背多少,而是要在反向传播的每一步知道“梯度是怎么从损失函数一路传回参数的”。
3. 实操过程与核心环节实现:手写一个单隐层网络的梯度下降
3.1 前向传播的矩阵化:把循环换成批量矩阵乘法
第二周后半段反复强调的一个观点是:永远不要用for循环遍历每个样本去算前向传播,而是把所有样本堆成一个矩阵,一次性做矩阵乘法。这样做不仅能利用GPU并行加速,也让代码更简洁。
课程里的符号需要先明确:输入X的维度是(n_x, m),其中n_x是特征数,m是样本数。第1层的权重W^{[1]}维度是(n_1, n_x),偏置b^{[1]}维度是(n_1, 1)。第2层(输出层)的权重W^{[2]}维度是(1, n_1),偏置b^{[2]}维度是(1, 1)。
- 第1层线性输出:Z^{[1]} = W^{[1]}X + b^{[1]},维度(n_1, m)
- 第1层激活输出:A^{[1]} = tanh(Z^{[1]})
- 第2层线性输出:Z^{[2]} = W^{[2]}A^{[1]} + b^{[2]},维度(1, m)
- 第2层激活输出:A^{[2]} = sigmoid(Z^{[2]}),这就是每个样本属于正类的预测概率
这里要注意的是b的广播机制。在NumPy里,如果你直接用一个(n_1, 1)的向量去加一个(n_1, m)的矩阵,NumPy会自动把列向量“复制”到每一列。这个机制很省事,但也会带来隐患——如果你用reshape不当,广播可能在你不知情时把维度弄错。
3.2 反向传播:链式法则一步步传回去
反向传播是这一周真正的重点。吴恩达在课程里直接给了一组公式,新手第一次看到很容易懵。其实每个公式都是从“损失对某一中间量求偏导”推出来的。
先定义损失函数。对二分类问题,单个样本的损失是L = -[y log(a) + (1-y) log(1-a)],对所有样本取平均就是成本函数J。反向传播的第一步是计算输出层的误差:
dZ^{[2]} = A^{[2]} - Y
这个式子的推导其实挺巧妙的。sigmoid的导数加上交叉熵损失函数的导数是同一个形式,两者相乘后得到了这样简洁的结果。我一开始很震惊,还以为吴恩达故意省略了推导。实际上不是,你可以自己去算:∂L/∂z = (a - y),当a是sigmoid输出、L是交叉熵时,化简后确实就是这个。这个简洁结果也是为什么二分类问题总是“sigmoid+交叉熵”搭配的原因。
再往后传,套链式法则:
- dW^{[2]} = (1/m) · dZ^{[2]} · A^
- db^{[2]} = (1/m) · np.sum(dZ^{[2]}, axis=1, keepdims=True)
- dZ^{[1]} = W^{[2]T} · dZ^{[2]} × g'^{[1]}(Z^{[1]}),其中×表示逐元素乘法,g'^{[1]}是tanh的导数
- dW^{[1]} = (1/m) · dZ^{[1]} · X^T
- db^{[1]} = (1/m) · np.sum(dZ^{[1]}, axis=1, keepdims=True)
我当初学到这里最大的障碍是为什么要转置、为什么乘法的顺序是这样。后来想明白了一个笨办法:你只需要盯着维度。dW^{[2]}必须和W^{[2]}维度一致,也就是(1, n_1)。现在dZ^{[2]}是(1, m),A^{[1]}是(n_1, m),要让(1, m)乘出一个(1, n_1),只能让dZ^{[2]}乘以A^{[1]T}。所有矩阵式子都可以用维度一致性来核对,只要维度对得上,顺序基本不会错。
3.3 参数初始化与梯度下降更新:全零初始化是最大的坑
逻辑回归可以把w初始化为零,因为在单一神经元里所有特征的更新是对称的,不会出问题。但到了多层网络,如果把W初始化为全零,隐藏层的每个神经元会在每一轮迭代中计算完全相同的梯度,更新后依然相同,这就等于你的隐藏层只有一个神经元在干活,网络表达能力被废掉一半甚至更多。
课程里要求用随机初始化打破对称性,比如W^{[1]}用np.random.randn(n_1, n_x) * 0.01。乘以0.01的原因是把初始权重压到较小的值,这样z会比较小,tanh或sigmoid工作时会落在梯度比较大的区间,初期训练速度更快。
偏置b初始化为0通常是安全的,因为对称性只由权重决定,b就算全零也不会导致神经元同化。
参数更新的统一公式是:
W = W - learning_rate · dW
b = b - learning_rate · db
这里的学习率也是一个关键超参数。太大会导致损失发散;太小会让训练慢到怀疑人生。课程作业里给了0.3之类的值,但实际项目里通常要按数量级去试,0.1、0.01、0.001各跑一遍看效果。
3.4 一个最小可运行的示例:用NumPy手写单隐层分类器
这部分是当时让我“顿悟”的关键练习。我建议你不管课程作业有没有要求,都自己动手把这段代码从零敲一遍。下面是我整理后的精简版本,你可以直接复制运行:
python复制import numpy as np
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def initialize_parameters(n_x, n_h, n_y):
np.random.seed(42)
W1 = np.random.randn(n_h, n_x) * 0.01
b1 = np.zeros((n_h, 1))
W2 = np.random.randn(n_y, n_h) * 0.01
b2 = np.zeros((n_y, 1))
return {"W1": W1, "b1": b1, "W2": W2, "b2": b2}
def forward_propagation(X, params):
W1, b1, W2, b2 = params["W1"], params["b1"], params["W2"], params["b2"]
Z1 = np.dot(W1, X) + b1
A1 = np.tanh(Z1)
Z2 = np.dot(W2, A1) + b2
A2 = sigmoid(Z2)
cache = {"Z1": Z1, "A1": A1, "Z2": Z2, "A2": A2}
return A2, cache
def compute_cost(A2, Y):
m = Y.shape[1]
cost = -np.mean(Y * np.log(A2) + (1 - Y) * np.log(1 - A2))
return cost
def backward_propagation(X, Y, params, cache):
m = X.shape[1]
W2 = params["W2"]
A1 = cache["A1"]
A2 = cache["A2"]
Z1 = cache["Z1"]
dZ2 = A2 - Y
dW2 = np.dot(dZ2, A1.T) / m
db2 = np.sum(dZ2, axis=1, keepdims=True) / m
dZ1 = np.dot(W2.T, dZ2) * (1 - np.power(A1, 2))
dW1 = np.dot(dZ1, X.T) / m
db1 = np.sum(dZ1, axis=1, keepdims=True) / m
grads = {"dW1": dW1, "db1": db1, "dW2": dW2, "db2": db2}
return grads
def update_parameters(params, grads, learning_rate=0.1):
params["W1"] -= learning_rate * grads["dW1"]
params["b1"] -= learning_rate * grads["db1"]
params["W2"] -= learning_rate * grads["dW2"]
params["b2"] -= learning_rate * grads["db2"]
return params
# 生成非线性可分数据
X, y = make_moons(n_samples=300, noise=0.2, random_state=1)
Y = y.reshape(1, -1)
X_train, X_test, Y_train, Y_test = train_test_split(X.T, Y, test_size=0.2, random_state=1)
params = initialize_parameters(2, 5, 1)
for i in range(5000):
A2, cache = forward_propagation(X_train, params)
cost = compute_cost(A2, Y_train)
grads = backward_propagation(X_train, Y_train, params, cache)
params = update_parameters(params, grads, learning_rate=0.3)
if i % 1000 == 0:
print(f"第{i}轮,损失为{cost:.4f}")
训练结束后,你可以在测试集上算一下准确率。我记得第一次跑的时候,准确率从逻辑回归的大约70%直接跳到90%以上,效果立竿见影。这个“立竿见影”,就是隐藏层非线性带来的表达力提升给你带来的最直观感受。
这里的隐藏层神经元数量我设置了5个。这个数字也别太小,否则表达力不够;也别太大,否则小数据集上容易过拟合。课程作业里会让你做几次实验对比不同隐藏单元数的效果,值得认真试试。
4. 常见问题与排查技巧实录:新手最容易摔的坑
4.1 损失不降或下降极慢:先从激活函数和学习率查起
我自己和身边人都遇到过这种情况:代码看起来完全按照公式写了,但loss就是不动,或者下降速度慢得离谱。
排查顺序很有讲究。第一件事看输出层的激活函数选得对不对。如果是二分类问题,输出层应该是sigmoid;如果输出层用成了ReLU或者线性激活,那交叉熵损失很容易直接变成NaN或者一直卡住。第二件事看学习率,我见过有人把学习率设为1.0,loss一路震荡到天上去;也见过有人设成0.0001,跑几百轮看起来像没学。一般建议从0.01到0.3这个区间开始试,然后观察loss曲线。
还有一次我遇到一个特别隐蔽的问题:数据归一化没做,输入特征值范围从0到1000的都有,结果损失不降反升。把数据标准化之后再训练,问题立刻消失。这不算课程重点,但实战里非常常见。
4.2 梯度消失:为什么隐藏层别乱用sigmoid
课件里明确说过“sigmoid通常只用在校准输出层偏置”。这个建议背后就是梯度消失问题。sigmoid的导数最大值是0.25,而且在两端趋近于0。如果你网络有5层、每层都用sigmoid,那么反向传播时梯度每过一层就要乘一次0.25,5层之后梯度已经缩到差不多千分之一的量级,前面的层几乎学不动。
我当时用sigmoid堆了一个三层的网络,训练结果最前面的层权重基本没变。后来把隐藏层全部换成tanh或ReLU才好转。这也是为什么课程里强调,在隐藏层激活函数的选择上,ReLU及其变体是现代默认选项。
4.3 维度不匹配:怎么靠assert快速定位bug
初学阶段写反向传播,最容易报的错就是矩阵维度对不上。与其一遍遍用print去猜,不如在代码里加assert断言。
比如前向传播前加:
python复制assert np.dot(W1, X).shape == (n_h, m)
反向传播里也可以加,但注意维度要跟权重对齐。我习惯在每一组梯度算完后检查:
python复制assert grads["dW1"].shape == params["W1"].shape
assert grads["db1"].shape == params["b1"].shape
assert grads["dW2"].shape == params["W2"].shape
assert grads["db2"].shape == params["b2"].shape
一旦维度对不上,断言直接告诉你哪一层出了问题,省去大量debug时间。这个习惯我后来用到所有需要手写反向传播的场合,包括作业里的梯度检查。
我这里再补一个细节:在NumPy里,np.sum(..., axis=1, keepdims=True)这一句里的keepdims=True不能省。如果省掉,db的形状会从(n_h, 1)变成(n_h,),广播机制在后续计算里很容易偷偷把维度方向搞反,造成极其隐蔽的错误。
4.4 参数更新写了但没生效:检查一下是不是没有把返回值接回去
这个坑听起来低级,但真的很常见。如果你在循环里写了update_parameters(params, grads),但这个函数内部用的是复制后的变量、没有返回值,或者有返回值但调用时没有重新赋值,那参数压根不会更新,loss就会一直纹丝不动。
我到现在还记得第一次跑这种代码,卡了快一个小时,最后发现就是少了一句params = update_parameters(...)。所以这里友情提醒:在写迭代循环之前,先确认你的更新函数是原地修改还是返回新参数,两者要对应好。
4.5 数据形状是(300,)还是(1, 300):Y的维度决定了整个计算的命运
课程作业里有明确要求,把Y用Y.reshape(1, -1)转成行向量。这一步看着不起眼,但如果不做,后面算成本函数时Y * np.log(A2)会对不上维度,或者广播出一个完全错误的形状,导致loss值看起来正常实际却算错了。
我的建议是,从数据进入模型的那一刻起,所有输入都统一成标准的列优先矩阵格式:X.shape为(n_x, m),Y.shape为(1, m)。中间所有层的矩阵运算都沿用这个约定,就不太会出现维度混乱。
5. 课程之外的几句心里话
如果你正在学这一周,我的建议很简单:慢下来,把公式推到你能独立写出来的程度,再进入第三周。很多人觉得第二周内容“看起来简单就跳过了”,结果到了作业里的深度学习框架部分,连正向传播和反向传播的维度都对不齐。基础不牢,后面补起来成本高得多。
我到现在还会时不时回去翻一下这一周的激活函数对比和反向传播推导,每次都有新感受。这东西后续扩展的方向也很广——你可以试试把单隐层换成两个隐藏层,感受一下梯度在更深网络里的变化;也可以试着把tanh换成ReLU,看看训练曲线怎么变。反正代码就上面这么点,怎么折腾都行。
第二周是这个课的脊柱,别急着跑,走稳了,后面你会感谢这个阶段死磕过的自己。
