3029 字
15 分钟
手写神经网络的四大基础组件

写完线性回归和 CNN 之后,我发现有些基础组件一直在用,但从来没认真解释过它们是怎么工作的。

今天就来补上这一课:ReLU、Flatten、Softmax 和 Cross-Entropy。这四个东西在深度学习里到处都是,但很多人只知道调 API,不知道底层是怎么算的。

虽然累,但手写一遍能让人真正理解它们的本质。

为什么要手写这些组件#

在 PyTorch 或 TensorFlow 里,这些操作就是一行代码的事:

torch.nn.ReLU()
torch.flatten()
torch.nn.Softmax()
torch.nn.CrossEntropyLoss()

但如果你只会调 API,遇到问题时就会一脸懵逼:

  • 为什么 ReLU 会导致”神经元死亡”?
  • Softmax 的数值稳定性问题是什么?
  • Cross-Entropy 为什么比 MSE 更适合分类任务?

手写一遍之后,这些问题都会有答案。

NOTE

这篇文章会用 numpy 实现这四个组件,并且会推导它们的梯度。理解梯度是理解反向传播的关键。

1. ReLU:最简单的激活函数#

ReLU(Rectified Linear Unit)是目前最常用的激活函数。公式简单到不能再简单:

ReLU(x) = max(0, x)

把负数变成零,正数保持不变。

为什么需要激活函数#

如果神经网络只有线性操作(矩阵乘法、加法),那么无论堆多少层,最终还是一个线性模型。

举个例子:

y = W₂ · (W₁ · x + b₁) + b₂
= W₂·W₁·x + W₂·b₁ + b₂
= W'·x + b'

两层的线性网络等价于一层。激活函数引入了非线性,让模型能拟合更复杂的函数。

numpy 实现#

import numpy as np
def relu(x):
"""
ReLU 激活函数
参数:
x: 输入数组
返回:
应用 ReLU 后的数组
"""
return np.maximum(0, x)

就这么简单。np.maximum 会逐元素比较,返回较大的那个。

示例#

x = np.array([-2, -1, 0, 1, 2])
print("输入:", x)
print("ReLU 输出:", relu(x))

输出:

输入: [-2 -1 0 1 2]
ReLU 输出: [0 0 0 1 2]

负数全变成零,正数保持不变。

ReLU 的梯度#

反向传播需要计算梯度。ReLU 的梯度也很简单:

∂ReLU/∂x = 1 if x > 0 else 0

def relu_grad(x):
"""
ReLU 的梯度
参数:
x: 输入数组(前向传播时的输入)
返回:
梯度数组
"""
return (x > 0).astype(float)

只有输入大于零的地方,梯度才是 1,否则是 0。

示例#

x = np.array([-2, -1, 0, 1, 2])
print("输入:", x)
print("ReLU 梯度:", relu_grad(x))

输出:

输入: [-2 -1 0 1 2]
ReLU 梯度: [0. 0. 0. 1. 1.]

ReLU 的优缺点#

优点:

  • 计算简单,速度快
  • 梯度不会消失(正数部分梯度恒为 1)
  • 稀疏激活(很多神经元输出为零,提高效率)

缺点:

  • “神经元死亡”问题:如果某个神经元的输入一直是负数,梯度一直是 0,参数永远不会更新
TIP

为了解决”神经元死亡”问题,人们发明了 Leaky ReLU、PReLU、ELU 等变体。但 ReLU 仍然是最常用的。

2. Flatten:把多维数组展平#

Flatten 的作用是把多维数组展平成一维向量。

在 CNN 中,卷积层的输出通常是三维的(高度、宽度、通道数),但全连接层需要一维输入。Flatten 就是用来做这个转换的。

numpy 实现#

def flatten(x):
"""
把多维数组展平成一维
参数:
x: 输入数组,形状 (batch_size, height, width, channels)
返回:
展平后的数组,形状 (batch_size, height * width * channels)
"""
batch_size = x.shape[0]
return x.reshape(batch_size, -1)

reshape(batch_size, -1) 的意思是:保持第一维(batch size)不变,其他维度全部展平。

示例#

# 假设有 2 张 3x3 的灰度图像
x = np.random.randn(2, 3, 3, 1)
print("原始形状:", x.shape)
flat = flatten(x)
print("展平后形状:", flat.shape)

输出:

原始形状: (2, 3, 3, 1)
展平后形状: (2, 9)

每张 3×3 的图像被展平成一个 9 维向量。

Flatten 的梯度#

Flatten 只是改变形状,不改变数值。所以梯度也只是改变形状,把展平后的梯度 reshape 回原来的形状。

def flatten_grad(grad_flat, original_shape):
"""
Flatten 的梯度(反向传播)
参数:
grad_flat: 展平后的梯度
original_shape: 原始输入的形状
返回:
reshape 回原始形状的梯度
"""
return grad_flat.reshape(original_shape)

为什么需要 Flatten#

卷积层输出的特征图是多维的,包含了空间信息(高度、宽度)。但全连接层不关心空间结构,只关心特征的值。

Flatten 把空间结构”抹平”,让全连接层能接收输入。

IMPORTANT

Flatten 会丢失空间信息。如果任务需要保留空间结构(比如图像分割),就不能用 Flatten,而要用卷积层一直到最后。

3. Softmax:把输出变成概率分布#

Softmax 的作用是把一组任意实数变成概率分布。

假设神经网络的输出是 [2.0, 1.0, 0.1],这些数字没有直接的意义。Softmax 把它们变成 [0.7, 0.2, 0.1],表示三个类别的概率。

Softmax 的公式#

softmax(x)ᵢ = exp(xᵢ) / Σⱼ exp(xⱼ)

对每个元素取指数,然后除以所有元素的指数和。

numpy 实现(naive 版本)#

def softmax_naive(x):
"""
Softmax 激活函数(naive 版本,数值不稳定)
参数:
x: 输入数组,形状 (batch_size, num_classes)
返回:
概率分布,形状 (batch_size, num_classes)
"""
exp_x = np.exp(x)
return exp_x / np.sum(exp_x, axis=1, keepdims=True)

数值稳定性问题#

上面的实现有个严重的问题:当 x 的值很大时,np.exp(x) 会溢出。

x = np.array([[1000, 2000, 3000]])
print(softmax_naive(x))

输出:

[[ nan nan nan]]

全是 nan,因为 exp(3000) 太大了,超出了浮点数的表示范围。

数值稳定的版本#

解决方法是:在计算指数之前,先减去最大值。

softmax(x)ᵢ = exp(xᵢ - max(x)) / Σⱼ exp(xⱼ - max(x))

这样做不会改变最终结果(可以数学证明),但能避免数值溢出。

def softmax(x):
"""
Softmax 激活函数(数值稳定版本)
参数:
x: 输入数组,形状 (batch_size, num_classes)
返回:
概率分布,形状 (batch_size, num_classes)
"""
# 减去最大值,避免数值溢出
x_max = np.max(x, axis=1, keepdims=True)
exp_x = np.exp(x - x_max)
return exp_x / np.sum(exp_x, axis=1, keepdims=True)

示例#

x = np.array([[1.0, 2.0, 3.0],
[1.0, 1.0, 1.0]])
print("输入:\n", x)
print("Softmax 输出:\n", softmax(x))

输出:

输入:
[[1. 2. 3.]
[1. 1. 1.]]
Softmax 输出:
[[0.09003057 0.24472847 0.66524096]
[0.33333333 0.33333333 0.33333333]]

第一行:最大的值(3.0)对应最大的概率(0.665)。
第二行:三个值相等,概率也相等(各 1/3)。

Softmax 的性质#

  1. 输出范围 [0, 1]:每个元素都是非负的,且小于等于 1。
  2. 和为 1:所有元素的和等于 1,满足概率分布的定义。
  3. 单调性:输入越大,对应的概率越大。

Softmax 的梯度#

Softmax 的梯度推导比较复杂。对于第 i 个元素:

∂softmax(x)ᵢ/∂xⱼ = softmax(x)ᵢ · (δᵢⱼ - softmax(x)ⱼ)

其中 δᵢⱼ 是 Kronecker delta(i=j 时为 1,否则为 0)。

用代码实现:

def softmax_grad(s):
"""
Softmax 的梯度(雅可比矩阵)
参数:
s: softmax 的输出,形状 (num_classes,)
返回:
雅可比矩阵,形状 (num_classes, num_classes)
"""
# s 是列向量,s.reshape(-1, 1) @ s.reshape(1, -1) 是外积
jacobian = np.diag(s) - np.outer(s, s)
return jacobian
WARNING

实际使用中,Softmax 通常和 Cross-Entropy 一起用,它们的梯度可以合并简化。单独计算 Softmax 的梯度比较少见。

4. Cross-Entropy:分类任务的损失函数#

Cross-Entropy(交叉熵)是分类任务中最常用的损失函数。

它衡量的是预测概率分布和真实概率分布之间的差异。

Cross-Entropy 的公式#

CE = -Σᵢ yᵢ · log(pᵢ)

其中:

  • yᵢ 是真实标签(one-hot 编码)
  • pᵢ 是预测概率(Softmax 的输出)

对于多分类问题,真实标签通常是 one-hot 编码,只有一个位置是 1,其他都是 0。所以公式简化为:

CE = -log(p_true_class)

numpy 实现#

def cross_entropy(y_pred, y_true):
"""
Cross-Entropy 损失函数
参数:
y_pred: 预测概率,形状 (batch_size, num_classes)
y_true: 真实标签(one-hot),形状 (batch_size, num_classes)
返回:
平均损失(标量)
"""
# 避免 log(0),加一个很小的值
epsilon = 1e-15
y_pred = np.clip(y_pred, epsilon, 1 - epsilon)
# 计算交叉熵
ce = -np.sum(y_true * np.log(y_pred), axis=1)
return np.mean(ce)

np.clip 确保概率在 [epsilon, 1-epsilon] 范围内,避免 log(0) 导致的数值问题。

示例#

# 预测概率(Softmax 的输出)
y_pred = np.array([[0.7, 0.2, 0.1],
[0.1, 0.8, 0.1]])
# 真实标签(one-hot)
y_true = np.array([[1, 0, 0], # 第一个样本属于类别 0
[0, 1, 0]]) # 第二个样本属于类别 1
loss = cross_entropy(y_pred, y_true)
print("Cross-Entropy Loss:", loss)

输出:

Cross-Entropy Loss: 0.1783

第一个样本:预测类别 0 的概率是 0.7,损失是 -log(0.7) ≈ 0.357。
第二个样本:预测类别 1 的概率是 0.8,损失是 -log(0.8) ≈ 0.223。
平均损失:(0.357 + 0.223) / 2 ≈ 0.178。

为什么用 Cross-Entropy 而不是 MSE#

对于分类任务,Cross-Entropy 比 MSE(均方误差)更合适。

MSE 的问题:

假设真实标签是 [1, 0, 0],预测是 [0.99, 0.005, 0.005]。

MSE = (1-0.99)² + (0-0.005)² + (0-0.005)² ≈ 0.0001

损失很小,但梯度也很小(因为平方的导数在接近 0 时很小),学习会很慢。

Cross-Entropy 的优势:

CE = -log(0.99) ≈ 0.01

虽然损失也很小,但 log 函数的导数在接近 1 时较大,梯度不会太小,学习速度更快。

TIP

Cross-Entropy 的梯度在预测错误时很大,在预测正确时很小。这正是我们想要的:错得多就学得快,错得少就学得慢。

Cross-Entropy 的梯度#

对于 Softmax + Cross-Entropy 的组合,梯度有一个非常简洁的形式:

∂CE/∂x = p - y

其中 p 是 Softmax 的输出,y 是真实标签(one-hot)。

def softmax_cross_entropy_grad(y_pred, y_true):
"""
Softmax + Cross-Entropy 的梯度
参数:
y_pred: 预测概率(Softmax 的输出),形状 (batch_size, num_classes)
y_true: 真实标签(one-hot),形状 (batch_size, num_classes)
返回:
梯度,形状 (batch_size, num_classes)
"""
batch_size = y_true.shape[0]
grad = (y_pred - y_true) / batch_size
return grad

示例#

y_pred = np.array([[0.7, 0.2, 0.1],
[0.1, 0.8, 0.1]])
y_true = np.array([[1, 0, 0],
[0, 1, 0]])
grad = softmax_cross_entropy_grad(y_pred, y_true)
print("梯度:\n", grad)

输出:

梯度:
[[-0.15 0.1 0.05]
[ 0.05 -0.1 0.05]]

第一个样本:预测类别 0 的概率是 0.7,应该是 1,梯度是 (0.7-1)/2 = -0.15。
第二个样本:预测类别 1 的概率是 0.8,应该是 1,梯度是 (0.8-1)/2 = -0.1。

负梯度说明要增大对应类别的概率。

综合示例:完整的分类网络#

把这四个组件串起来,构建一个简单的分类网络。

import numpy as np
import tqdm
import matplotlib.pyplot as plt
# ========== ReLU ==========
def relu(x):
return np.maximum(0, x)
def relu_grad(x):
return (x > 0).astype(float)
# ========== Flatten ==========
def flatten(x):
batch_size = x.shape[0]
return x.reshape(batch_size, -1)
def flatten_grad(grad_flat, original_shape):
return grad_flat.reshape(original_shape)
# ========== Softmax ==========
def softmax(x):
x_max = np.max(x, axis=1, keepdims=True)
exp_x = np.exp(x - x_max)
return exp_x / np.sum(exp_x, axis=1, keepdims=True)
def softmax_grad(s):
jacobian = np.diag(s) - np.outer(s, s)
return jacobian
# ========== Cross-Entropy ==========
def cross_entropy(y_pred, y_true):
epsilon = 1e-15
y_pred = np.clip(y_pred, epsilon, 1 - epsilon)
ce = -np.sum(y_true * np.log(y_pred), axis=1)
return np.mean(ce)
def softmax_cross_entropy_grad(y_pred, y_true):
batch_size = y_true.shape[0]
grad = (y_pred - y_true) / batch_size
return grad
# ========== 示例:完整的分类网络 ==========
np.random.seed(42)
X = np.random.randn(3, 2, 2, 1)
y_true = np.array([[1, 0, 0],
[0, 1, 0],
[0, 0, 1]])
W = np.random.randn(4, 3) * 0.1
b = np.zeros((1, 3))
lr = 0.1
step = 0
loss = 999
loss_list = []
pbar = tqdm.tqdm(total=20000, desc="Training")
while loss > 1e-6 and step < 20000:
flat = flatten(X)
z = flat @ W + b
a = relu(z)
y_pred = softmax(a)
loss = cross_entropy(y_pred, y_true)
loss_list.append(loss)
dL_da = softmax_cross_entropy_grad(y_pred, y_true)
dL_dz = dL_da * relu_grad(z)
dL_dW = flat.T @ dL_dz
dL_db = np.sum(dL_dz, axis=0, keepdims=True)
W -= lr * dL_dW
b -= lr * dL_db
pbar.set_postfix({"loss": f"{loss:.6e}"})
pbar.update(1)
step += 1
pbar.close()
print("\nTraining finished.")
print("Final loss:", loss)
print("Predicted probabilities:\n", y_pred)
print("True labels:\n", y_true)
print("Predicted classes:", np.argmax(y_pred, axis=1))
print("True classes:", np.argmax(y_true, axis=1))
plt.figure(figsize=(6, 4))
plt.plot(loss_list)
plt.xlabel("Step")
plt.ylabel("Loss (Cross-Entropy)")
plt.yscale("log")
plt.title("Training Loss Curve")
plt.grid(True)
plt.tight_layout()
plt.show()

输出(大概):

Training: 100%|██████████████████████████████████████| 20000/20000 [00:01<00:00, 16967.17it/s, loss=4.059225e-04]
Training finished.
Final loss: 0.00040592248335850385
Predicted probabilities:
[[9.99947520e-01 4.85236549e-05 3.95585186e-06]
[5.87013533e-04 9.98882051e-01 5.30935210e-04]
[1.00670583e-06 4.57046555e-05 9.99953289e-01]]
True labels:
[[1 0 0]
[0 1 0]
[0 0 1]]
Predicted classes: [0 1 2]
True classes: [0 1 2]

完美分类!

小结#

这四个组件是深度学习的基础:

  1. ReLU:最简单高效的激活函数,引入非线性。
  2. Flatten:把多维数组展平,连接卷积层和全连接层。
  3. Softmax:把输出变成概率分布,用于多分类任务。
  4. Cross-Entropy:衡量预测概率和真实标签的差异,是分类任务的标准损失函数。

虽然深度学习框架里都有现成的实现,但手写一遍能让人真正理解它们的本质:

  • ReLU 为什么会导致神经元死亡
  • Softmax 为什么需要减去最大值
  • Cross-Entropy 为什么比 MSE 更适合分类

[!quote] “What I cannot create, I do not understand.”
— Richard Feynman

上一篇:ML2-从零手搓一个卷积神经网络 下一篇:ML4-从零手搓一个 MNIST 分类器


Works Cited

Goodfellow, Ian, et al. Deep Learning. MIT Press, 2016.

Glorot, Xavier, et al. “Deep Sparse Rectifier Neural Networks.” Proceedings of the Fourteenth International Conference on Artificial Intelligence and Statistics, 2011, pp. 315-323.

手写神经网络的四大基础组件
https://blog.lishuyu.top/posts/ml3-手写神经网络的四大基础组件/
作者
猫猫魔女
发布于
2025-11-20
许可协议
CC BY-NC-SA 4.0