神经网络的数学基础|深度学习 | 自在学神经网络的数学基础
上一章把训练概括成一条闭环:输入产生预测,损失衡量预测与答案的差距,反向传播计算梯度,优化器再修改参数。本章要把这条闭环真正拆开。我们会从一个二分类样本出发,推到一批样本的矩阵公式,再把同样的思路扩展到含隐藏层的网络。
这部分最容易卡住人的地方,往往不是某个公式太难,而是三个视角没有对齐:概率视角在问“模型把多少概率分给了真实答案”,微积分视角在问“参数稍微变化会让损失怎样变化”,代码视角则必须回答“每个数组究竟是什么 shape”。接下来我们会一直在这三个视角之间对账。

全文采用样本按列的约定:单个输入 x(i)∈Rnx×1,把 m 个样本并排放入 X∈Rnx×m,标签写成 Y∈R1×m。不少库也采用样本按行的约定,两种都可以;真正危险的是推导写一种、代码悄悄用了另一种。
从概率预测到二元交叉熵
逻辑回归先计算一个实数分数,也叫 logit:
z=wTx+b
再用 Sigmoid 把它映射到 (0,1):
a=σ(z)=1+e−z1
我们把 a 解释成模型对正类的预测概率,即 P(y=1∣x;w,b)=a。于是负类的概率是 P(y=。因为二分类标签 只取 或 ,两种情况可以合并成一个式子:
P(y∣x;w,b)=ay(1−a)1−y
当 y=1 时,(1−a)1−y=1,只剩 a;当 时,,只剩 。这个写法不是为了炫技,它让“让真实标签出现的概率尽量大”变成了可以统一计算的目标。
最大似然为什么会变成交叉熵
先假设训练样本在给定输入后条件独立。模型为整批真实标签分配的似然,是每个样本对应概率的乘积:
P(w,b)=i=1∏m(
我们希望选择 w,b 让这个乘积最大。乘积不便于求导,而且许多小概率相乘容易下溢,所以先取对数。对数是严格单调递增函数,不会改变最大值所在的位置:
logP(w,b)=i=1∑m
最大化对数似然,等价于最小化负对数似然。单个样本的二元交叉熵损失因此写成:
ℓ(a(i),y(i))

如果真实标签是 1,损失就是 −loga。把 a 从 0.9 改成 0.99,损失继续变小;把 a 压到接近 0,损失会迅速增大。真实标签为 时,情况对称,损失变成 。所以交叉熵关心的不只是“最后分类对没对”,还关心模型给真实答案分配了多少概率。
样本损失与批量代价不要混用
本章用 ℓ(i) 表示第 i 个样本的损失,用 J 表示整批样本损失的平均:
J(w,b)=m1i=1∑
平均而不是求和,能让不同批量大小下的数值尺度更容易比较。不过这只是约定,不改变最优参数的位置;它会改变梯度的整体倍数,因此代码里的学习率要与 mean 或 sum 的约定配套。
平方误差并非在所有二分类场景里都“错误”或“一定非凸”。更准确的说法是:当 Sigmoid 输出与平方误差组合时,关于参数的目标通常不再保留逻辑回归负对数似然所具有的良好凸性,饱和区的梯度也可能很小。二元交叉熵同时匹配伯努利概率模型,并能得到简洁的梯度,所以它是逻辑回归的自然选择。
1当真实标签 y=1、模型预测概率 a=0.02 时,二元交叉熵会怎样评价这个预测?
2对训练集似然取对数,会改变使似然最大的参数位置。
梯度、方向导数与学习率
有了 J(w,b),训练就变成一个优化问题:寻找让 J 尽量小的参数。为了不被 w 和 b 的写法束缚,我们把所有参数统一记作向量 θ。
导数在回答一个局部问题
一维导数 J′(θ) 描述参数发生一个很小变化时,目标函数在当前位置的变化率。多维情况下,梯度把每个坐标方向的偏导数收集起来:
∇θJ=
如果沿单位方向 u 移动,方向导数是:
DuJ(θ)=∇θJ(θ)Tu
在 ∥u∥2=1 的约束下,点积在 u 与梯度同向时最大,在反向时最小。因此梯度指向局部上升最快的方向,负梯度指向局部下降最快的方向:
u下降=−∥∇θJ∥2

学习率决定迈多大一步
最基本的梯度下降更新是:
θt+1=θt−α∇θ
α>0 是学习率。负梯度只给出当前位置的局部方向,学习率决定我们在这个局部近似上走多远。步子太大,新的位置可能越过低谷,甚至让损失越来越大;步子太小,方向可能没错,但进展很慢。
一个简单例子能把边界说清楚。令 J(θ)=21θ2,则梯度为 θ,更新变成:
θt+1=(1−α)θt
当 0<α<2 时,∣1−α∣<1,参数绝对值会逐步缩小;当 α=1 时,这个特定二次函数一步到达 0;当 时,绝对值反而扩大。这个范围只属于当前函数,不能照搬成所有模型的通用学习率。
梯度是局部信息。即使某一步沿负梯度移动,小批量噪声、过大的学习率或曲率变化也可能让实际损失暂时上升。训练时应观察一段时间内的损失走势,并检查梯度和参数是否出现 NaN 或无穷大。
4对可微函数 J,沿单位方向 u 的方向导数可写成 ____。
5在 J(θ)=θ²/2 中使用固定学习率梯度下降,下面哪一项会让 |θ| 逐步缩小?
计算图与链式法则
神经网络看起来复杂,是因为一个输出经过很多中间量得到。计算图的办法很朴素:把大表达式拆成基本操作,前向时保存中间结果,反向时把上游梯度乘上当前操作的局部导数。
考虑函数:
J(a,b,c)=3(a+bc)
把它拆成三个节点:
u=bc
v=a+u
J=3v

取 a=5,b=3,c=2,前向计算依次得到 u=6、v=11、。反向传播从输出端的种子梯度开始:
∂J∂J=1
随后每经过一个节点,就乘上局部导数。因为 J=3v,所以 ∂v∂J=3。又因为 v,加法对两个输入的局部导数都为 :
∂a∂J=3
∂u∂J=3
最后经过乘法节点 u=bc:
∂b∂J=∂u∂J
∂c∂J=∂u∂J
先按数据流方向计算所有中间值。反向传播经常需要前向阶段的输入或输出,例如乘法节点需要另一侧输入,Sigmoid 的导数可以直接由它的输出计算。
从最终标量损失开始,把它对自身的导数设为 1。这个值是反向传播的起点,也可理解成输出端收到的上游梯度。
逆着图逐节点计算“上游梯度 × 局部导数”。如果一个变量通过多条路径影响最终损失,各路径贡献需要相加。
到达参数叶节点后,得到损失对参数的梯度。自动微分框架记录的正是操作及其反向规则,而不是提前存好一个固定的大公式。
在 PyTorch 中,调用 backward() 会把新梯度累加到叶张量的 .grad,不会默认覆盖旧值。常规训练循环需要在合适位置调用 optimizer.zero_grad(),或把梯度设为 None,否则下一批数据会在上一批梯度上继续累加。
7在 J=3(a+bc) 且 a=5、b=3、c=2 时,∂J/∂c 等于多少?
9PyTorch 连续两次调用 backward() 时,叶节点的梯度默认会自动清零。
逻辑回归的反向传播
现在把链式法则放回逻辑回归。对单个样本,前向路径是:
z=wTx+b
a=σ(z)
ℓ(a,y)=−[yloga+(1−y)log(1−a)]
先求损失对概率的导数
把 y 当成常数,对 a 求导:
∂a∂ℓ=−ay+
Sigmoid 的导数可以用自身输出表示:
∂z∂a=a(1−a)
按链式法则相乘:
∂z∂ℓ=(−ay
展开后,两项会抵消成一个很干净的结果:
∂z∂ℓ=a−y
这个式子可以直接读:预测概率高于标签,梯度为正,梯度下降会倾向于压低 logit;预测概率低于标签,梯度为负,更新会倾向于抬高 logit。
再把梯度传给参数
因为 z=wTx+b,所以 z 对 w 的导数是 x,对 b 的导数是 :
∂w∂ℓ=x(a−y)
∂b∂ℓ=a−y
对 m 个样本取平均,得到批量梯度:
∂w∂J=m1
∂b∂J=m1
a−y 是“Sigmoid 加二元交叉熵”组合对 logit 的导数,不是所有输出层与所有损失函数的通用答案。换成平方误差、Softmax 多分类或其他概率模型后,应重新按对应计算图求导。
下面用一个数值例子检查符号。若 y=1,a=0.2,则 dz=−0.8。梯度下降用 z←z−αdz,会把 增大,从而让下一次 更接近 。如果你推导出的符号会把预测推得离标签更远,就应回头检查负号。
10逻辑回归使用 Sigmoid 与二元交叉熵时,单样本的 dℓ/dz 是什么?
11当 y=1 且 a=0.2 时,dz=a-y 为负;梯度下降因此会倾向于增大 z。
12从单样本梯度推广到批量平均代价时,哪些操作是需要的?
用矩阵形状完成向量化
逐样本公式已经有了,下一步不是再发明新数学,而是把 m 个样本同时计算。仍采用样本按列:
X=[x(1)
w∈Rnx×1,b∈R
前向传播的 shape 对账
参数转置后 wT 的 shape 是 (1,nx),因此:
Z=wTX+b∈R1×m
A=σ(Z)∈R1×m
b 是标量,NumPy 会把它广播到 Z 的每个元素。标签 Y 也必须是 (1,m),这样 dZ=A−Y 才是逐样本误差。
反向传播的 shape 对账
批量梯度可以直接写成矩阵乘法:
dZ=A−Y∈R1×m
dw=m1X(dZ)T∈R
db=m1i=1∑mdZ

矩阵形状本身就是一套低成本的错误检查。dw 必须与 w 同形,db 必须能更新标量 b。如果结果不满足这两个条件,不要靠反复加 .T 碰运气,而应从每个轴的含义重新检查。
下面把循环版与向量化版放在一起。代码不声称固定的速度倍数,因为机器、NumPy 构建方式和数组大小都会改变计时;我们只验证两种写法得到相同梯度。
import numpy as np
def sigmoid_stable(z):
z = np.asarray(z, dtype=np.float64)
out = np.empty_like(z)
positive = z >= 0
out[positive] = 1.0 / (1.0 + np.exp(-z[positive]))
exp_z = np.exp(z[~positive])
out[~positive] = exp_z
向量化的核心不是“代码里绝对不能出现循环”,而是让大批同构数值运算交给数组库的底层实现。训练轮次、网络层或变长序列仍可能需要循环;应优先消除逐元素、逐样本的 Python 循环。
13若 X 的 shape 是 (5, 20),w 的 shape 是 (5, 1),那么 w.T @ X 的 shape 是什么?
14在样本按列的约定下,dZ 为 (1,m),为了得到与 w 同形的 dw,应计算 X @ ____。
15向量化版一定在所有输入规模、所有硬件上比循环版快同一个固定倍数。
广播规则与 rank-1 数组陷阱
广播让不同 shape 的数组在满足规则时执行逐元素运算。NumPy 从最右侧维度开始比较;一对维度只要相等,或其中一个为 1,就兼容。缺失的左侧维度按 1 处理。
import numpy as np
A = np.zeros((3, 4))
row = np.array([[10.0, 20.0, 30.0, 40.0]]) # (1, 4)
A_plus_row = A + row # (3, 4)
column = np.array([[1.0], [2.0], [3.0]]) # (3, 1)
A_plus_column = A
row 沿第一个轴扩展,column 沿第二个轴扩展,标量沿两个轴扩展。这里的“扩展”是理解模型,NumPy 通常不需要真的复制出同样大小的数组。
最隐蔽的错误:一维数组不是列向量
看下面两个对象:
a = np.arange(5.0) # shape: (5,)
b = np.arange(5.0).reshape(5, 1) # shape: (5, 1)
a 是一维数组,b 是二维列向量。a.T.shape 仍是 (5,),因为一维数组没有可交换的两个轴;b.T.shape 才会变成 (1,5)。更危险的是:
unexpected = a + b
print(unexpected.shape) # (5, 5),不是 (5, 1)
广播把 (5,) 视为 (1,5),再与 (5,1) 组合,结果铺成了 (5,5)。程序没有报错,却算出了完全不同的对象。
用显式 shape 降低调试成本
在本章的列样本约定下,我建议把参数和中间量都保持二维:
w = np.zeros((n_x, 1))
Y = np.asarray(Y, dtype=np.float64).reshape(1, -1)
assert X.ndim == 2
assert w.shape == (X.shape[0], 1)
assert Y.shape == (1, X.shape[1])
#聚合后仍保留被压缩的轴,便于继续广播
feature_mean = X.mean(axis=
keepdims=True 会让聚合后的轴保留为长度 1。如果省略它,X.mean(axis=1) 得到 (n_x,),与 (n_x,m) 相减时通常并不兼容,或在某些巧合 shape 下产生难察觉的广播。
“代码能运行”不等于 shape 正确。广播错误经常不会抛异常。给输入、标签、参数、梯度写 assert,并在小数据上手算一个结果,比在训练损失异常后再猜转置位置省事得多。
16NumPy 从右向左比较广播维度时,哪些维度组合兼容?
17shape 为 (5,) 的 a 与 shape 为 (5,1) 的 b 相加,结果 shape 通常是什么?
18对一维 NumPy 数组 a 调用 a.T,会把 shape 从 (n,) 变成 (1,n)。
数值稳定的损失与完整实现
数学中的实数没有范围限制,计算机里的浮点数有。直接计算 np.exp(-z) 时,极端负的 z 会让指数参数变成很大的正数;先算 Sigmoid 再取 log(a),还可能遇到数值舍入后的 a=0 或 a=1,最终得到 log(0)。
两种处理层级
如果手里只有概率 a,可以先裁剪再取对数:
eps = 1e-12
a_safe = np.clip(a, eps, 1.0 - eps)
loss = -(Y * np.log(a_safe) + (1.0 - Y) * np.log1p(-a_safe))
np.clip 是实用防线,但它把极端概率截在边界上,得到的是对原目标的数值近似。如果能保留 logit Z,更好的做法是直接用等价的稳定公式:
ℓ(z,y)=log(1+ez)−yz
NumPy 的 logaddexp(0, z) 会稳定地计算 log(e0+ez),所以整批损失可以写成:
losses = np.logaddexp(0.0, Z) - Y * Z
cost = float(np.mean(losses))
这与 PyTorch 的 BCEWithLogitsLoss 采用同一类思想:把 Sigmoid 与二元交叉熵合在一起,利用 log-sum-exp 技巧避免不必要的上溢和下溢。
可运行的 NumPy 训练闭环
下面的实现统一使用 float64、显式 shape 和基于 logit 的稳定损失。它保留了最基础的全批量梯度下降,方便我们核对数学;真实项目可以再换小批量训练和更成熟的优化器。
import numpy as np
def sigmoid_stable(z):
z = np.asarray(z, dtype=np.float64)
out = np.empty_like(z)
positive = z >= 0
out[positive] = 1.0 / (1.0 + np.exp(-z[positive]))
exp_z = np.exp(z[~positive])
out[~positive] = exp_z
数值稳定不等于训练一定稳定。logaddexp 能避免损失公式本身的溢出,但数据尺度过大、学习率不合适、输入含 NaN,仍会让参数更新失败。稳定公式、输入检查、梯度监控和合理的数据预处理需要一起做。
19已知 logit Z 和标签 Y 时,哪一种二元交叉熵写法更适合避免先算极端概率再取 log?
21对概率先 clip 与直接从 logit 用稳定公式计算,在任何数值下都完全等价且梯度也毫无差别。
从单神经元走向多层网络
逻辑回归已经具备神经网络训练的全部基本动作:线性变换、激活、损失、反向传播和参数更新。它的限制也很清楚——wTx+b=0 给出的决策边界是线性的。像 XOR 这类线性不可分模式,单个逻辑回归单元无法完成。
加入一层隐藏层后,前向传播变成:
Z[1]=W[1]X+b[1]
A[1]=g(Z[1])
Z[2]=W[2]A[1]+b
A[2]=σ(Z[2])

若隐藏层有 nh 个单元,shape 可以这样对齐:
隐藏层的非线性激活不能省。如果 g 也是线性函数,多层矩阵乘法仍能合并成一次线性变换,表达能力不会因为层数增加而突破线性边界。
反向传播只是把同一规则重复一层
输出层仍使用 Sigmoid 与二元交叉熵,因此:
dZ[2]=A[2]−Y
dW[2]=m1dZ[2]
db[2]=m1i=1∑
梯度再传回隐藏层:
dA[1]=(W[2])TdZ
dZ[1]=dA[1]⊙g′(
dW[1]=m1dZ[1]X
db[1]=m1i=1∑
⊙ 表示逐元素乘法。你会发现,新增一层没有改变反向传播的原则:仍然是上游梯度乘局部导数,只是矩阵 shape 更丰富了。
逻辑回归的 w 和 b 可以从零开始,因为不同特征的梯度由数据决定;多层网络若把同一层所有神经元的权重都初始化为零,它们会收到相同梯度并保持对称,无法学出不同特征。隐藏层权重通常需要随机且尺度合适的初始化,偏置则常可初始化为零。
把本章方法带到下一章
以后看到更深的网络,可以继续用同一套检查顺序:先写每层前向公式,再标出张量 shape;确认损失的输入究竟是概率还是 logit;从标量损失沿计算图反向组合局部导数;最后用小数据和有限差分或自动微分核对梯度。网络会变深,这套对账方法不会过时。
23把单神经元扩展为一层隐藏层后,哪些原则仍然成立?
24在多层网络中,反向传播已经不再使用局部导数,而是另一套完全不同的算法。
0∣
x;w,b)=
1−
a
y
=
0
a(i)
)
y(i)
(1−a(i))1−y(i)
[y(i)loga(i)+(1−y(i))log(1−a(i))]
=
−[y(i)loga(i)+(1−y(i))log(1−a(i))]
0
−log(1−a) m
ℓ
(a(i),y(i))
∂θ1∂J∂θ2∂J⋮∂θp∂J
∇θJ
J
(
θt
)
α>2
J=33
=
a+
u
∂b∂u
=
3c=
6
∂c∂u
=
3b=
9
1−a1−y
+
1−a1−y
)
a
(
1
−
a)
1
i=1∑m
x(i)
(a(i)−y(i))
i=1∑m
(a(i)−y(i))
x(2)
⋯
x(m)
]
∈
Rnx×m
n
x
×
1
1i
∈
R
/
(
1.0
+
exp_z)
return out
def gradients_with_loop(X, Y, w, b):
n_x, m = X.shape
dw = np.zeros((n_x, 1), dtype=np.float64)
db = 0.0
for i in range(m):
x_i = X[:, i:i + 1] # (n_x, 1),保留二维
z_i = w.T @ x_i + b # (1, 1)
a_i = sigmoid_stable(z_i) # (1, 1)
dz_i = float(a_i[0, 0] - Y[0, i])
dw += x_i * dz_i
db += dz_i
return dw / m, db / m
def gradients_vectorized(X, Y, w, b):
m = X.shape[1]
Z = w.T @ X + b # (1, m)
A = sigmoid_stable(Z) # (1, m)
dZ = A - Y # (1, m)
dw = (X @ dZ.T) / m # (n_x, 1)
db = float(np.sum(dZ) / m) # scalar
return dw, db
rng = np.random.default_rng(7)
X = rng.normal(size=(4, 6))
Y = rng.integers(0, 2, size=(1, 6)).astype(np.float64)
w = rng.normal(size=(4, 1))
b = 0.1
dw_loop, db_loop = gradients_with_loop(X, Y, w, b)
dw_vec, db_vec = gradients_vectorized(X, Y, w, b)
np.testing.assert_allclose(dw_loop, dw_vec, rtol=1e-12, atol=1e-12)
np.testing.assert_allclose(db_loop, db_vec, rtol=1e-12, atol=1e-12)
+
column
# (3, 4)
scalar = 0.5
A_plus_scalar = A + scalar # (3, 4)
1
,
keepdims
=
True
)
# (n_x, 1)
X_centered = X - feature_mean # (n_x, m)
/
(
1.0
+
exp_z)
return out
def forward_and_backward(X, Y, w, b):
"""
X: (n_x, m), Y: (1, m), w: (n_x, 1), b: scalar
"""
n_x, m = X.shape
assert Y.shape == (1, m)
assert w.shape == (n_x, 1)
Z = w.T @ X + b # (1, m)
A = sigmoid_stable(Z) # (1, m)
cost = float(np.mean(np.logaddexp(0.0, Z) - Y * Z))
dZ = A - Y # (1, m)
dw = (X @ dZ.T) / m # (n_x, 1)
db = float(np.sum(dZ) / m) # scalar
assert dw.shape == w.shape
assert np.isfinite(cost)
assert np.all(np.isfinite(dw)) and np.isfinite(db)
return cost, dw, db
def fit_logistic(X, Y, learning_rate=0.1, steps=500):
X = np.asarray(X, dtype=np.float64)
Y = np.asarray(Y, dtype=np.float64).reshape(1, -1)
n_x, m = X.shape
if Y.shape[1] != m:
raise ValueError("X 与 Y 的样本数不一致")
if not np.all((Y == 0.0) | (Y == 1.0)):
raise ValueError("二分类标签必须是 0 或 1")
w = np.zeros((n_x, 1), dtype=np.float64)
b = 0.0
history = []
for step in range(steps):
cost, dw, db = forward_and_backward(X, Y, w, b)
w -= learning_rate * dw
b -= learning_rate * db
if step % 50 == 0 or step == steps - 1:
history.append((step, cost))
return w, b, history
def predict_proba(X, w, b):
X = np.asarray(X, dtype=np.float64)
return sigmoid_stable(w.T @ X + b)
#每列一个样本:二维、线性可分的玩具数据
X = np.array([
[-2.0, -1.0, -1.5, 1.0, 1.5, 2.0],
[-1.0, -1.5, -2.0, 1.5, 1.0, 2.0],
])
Y = np.array([[0, 0, 0, 1, 1, 1]], dtype=np.float64)
w, b, history = fit_logistic(X, Y, learning_rate=0.1, steps=500)
probabilities = predict_proba(X, w, b)
predictions = (probabilities >= 0.5).astype(np.int64)
print(history[-1])
print(probabilities)
print(predictions)
[2]
[
1
]
| (nh,nx) |
| b[1] | (nh,1) | 对所有样本广播的隐藏层偏置 |
| A[1] | (nh,m) | 隐藏表示 |
| W[2] | (1,nh) | 隐藏层到二分类输出的权重 |
| b[2] | (1,1) | 输出层偏置 |
| A[2] | (1,m) | 正类预测概率 |
(A[1])T
m
d
Z[2](i)
[2]
Z[1]
)
T
m
d
Z[1](i)