深度学习基础
深度学习不是什么神秘的黑科技,它本质上是一个数学函数拟合器——给它大量输入输出数据,它自动找出它们之间的复杂关系。
举个例子:你给它一百万张猫的图片,每张图片对应"猫"这个标签。深度学习自动找出什么样的像素组合看起来像猫。
再比如:你给它十亿句人类语言,每句话的下一个词作为目标。深度学习自动学会给定上文,下一个词最可能是什么。
深度学习的核心优势是:随着数据量增加,它的效果持续提升。这是传统机器学习做不到的。
在这一章,我们会从零开始,一步步拆解深度学习的每个组件:
- 神经网络的数学基础
- 前向传播的计算过程
- 激活函数的作用
- 损失函数的设计
- 反向传播的原理
- 优化器的选择
- 正则化技术
- 归一化方法
- 学习率调度
最后,我们用 PyTorch 实现一个完整的多层感知机(MLP),把所有知识点串联起来。
这一章会涉及一些数学,但不用担心——我们会用直觉解释每一个公式,而不是让你死记硬背。重点是理解为什么这样设计,而不是怎么计算。
神经网络数学基础
深度学习建立在三个数学分支之上:线性代数、微积分、概率论。
你不需要成为数学专家,但需要理解几个核心概念。
线性代数:矩阵乘法
神经网络最基本的运算就是矩阵乘法。
为什么用矩阵?因为它能简洁地表示"多个输入经过多个神经元变换"这个过程。
先看一个直观的例子:
实例
# 矩阵乘法的直观理解
# ============================================
import numpy as np
# 假设输入是一个 3 维向量:[身高, 体重, 年龄]
# 单位分别是:厘米、公斤、岁
x = np.array([175, 70, 25]) # 输入向量,形状 (3,)
print(f"输入 x: {x}")
print(f"输入形状: {x.shape}")
# 权重矩阵 W:2 个神经元,每个神经元接收 3 个输入
# 形状是 (输出维度, 输入维度) = (2, 3)
W = np.array([
[0.1, 0.2, 0.3], # 第 1 个神经元的权重
[0.4, 0.5, 0.6], # 第 2 个神经元的权重
])
print(f"\n权重矩阵 W:\n{W}")
print(f"权重形状: {W.shape}")
# 偏置 b:每个神经元一个偏置
b = np.array([0.1, 0.2]) # 形状 (2,)
print(f"\n偏置 b: {b}")
print(f"偏置形状: {b.shape}")
# 矩阵乘法:y = W · x + b
# 注意:numpy 的 @ 运算符表示矩阵乘法
y = W @ x + b
print(f"\n输出 y: {y}")
print(f"输出形状: {y.shape}")
# 手动计算验证一下
y1 = 0.1 * 175 + 0.2 * 70 + 0.3 * 25 + 0.1 # 第 1 个神经元
y2 = 0.4 * 175 + 0.5 * 70 + 0.6 * 25 + 0.2 # 第 2 个神经元
print(f"\n手动计算验证: [{y1}, {y2}]")
矩阵乘法的几何意义是"线性变换"——它可以旋转、缩放、拉伸向量空间。
但只有线性变换是不够的。
如果每一层都只是矩阵乘法,那么无论网络有多深,整个网络等价于一个单层网络。因为多个线性变换的组合仍然是线性变换。
这就是为什么我们需要激活函数——引入非线性。
微积分:导数与链式法则
训练神经网络的核心是"梯度下降"——找到让损失最小的参数方向。
梯度就是"多元函数的导数"——它告诉我们,每个参数变化一点,损失会变化多少。
先看一个简单的例子:
实例
# 导数的直观理解
# ============================================
def f(x):
"""一个简单的函数:f(x) = x²"""
return x ** 2
def numerical_derivative(f, x, h=1e-6):
"""数值导数:用微小增量 h 近似计算导数
导数定义:f'(x) = lim(h→0) [f(x+h) - f(x)] / h
"""
return (f(x + h) - f(x)) / h
# 在 x=3 处计算导数
x = 3.0
df_dx = numerical_derivative(f, x)
print(f"f({x}) = {f(x)}")
print(f"f'({x}) ≈ {df_dx}")
print(f"解析解(精确值):2 * {x} = {2 * x}")
# 理解导数的意义:
# 导数 6 表示:在 x=3 处,x 增加 1,f(x) 大约增加 6
x_new = x + 0.01
f_new = f(x_new)
print(f"\nx 从 {x} 增加到 {x_new}")
print(f"f(x) 从 {f(x)} 变为 {f_new}")
print(f"实际增加:{f_new - f(x)}")
print(f"导数预测:{df_dx * 0.01}")
对于多元函数,我们需要计算每个变量的偏导数,然后把它们组合成一个向量,这就是梯度。
链式法则是反向传播的数学基础。它让我们能够"层层拆解"复合函数的导数。
实例
# 链式法则的直观理解
# ============================================
# 考虑复合函数:y = f(g(x))
# 其中:g(x) = x², f(z) = z³
# 那么:y = (x²)³ = x⁶
def g(x):
return x ** 2
def f(z):
return z ** 3
def y(x):
return f(g(x))
# 手动计算导数(链式法则):
# dy/dx = df/dz * dz/dx = 3*z² * 2*x = 3*(x²)² * 2*x = 6*x⁵
x = 2.0
z = g(x) # z = 4
dy_dx_chain = 3 * (z ** 2) * 2 * x # 链式法则计算
print(f"链式法则计算:dy/dx = {dy_dx_chain}")
# 数值导数验证
def numerical_derivative_y(x, h=1e-6):
return (y(x + h) - y(x)) / h
dy_dx_numerical = numerical_derivative_y(x)
print(f"数值导数验证:dy/dx ≈ {dy_dx_numerical}")
# 解析解:6*x^5 = 6*32 = 192
print(f"解析解:6 * {x}^5 = {6 * (x ** 5)}")
链式法则的核心思想是:把复杂函数拆成简单函数,分别求导再相乘。
反向传播就是链式法则在神经网络中的应用——从输出开始,一层一层往回传梯度。
概率论:条件概率
分类问题中,神经网络输出的往往是"概率分布"——给定输入,每个类别的概率是多少。
条件概率 P(Y|X) 表示"在已知 X 发生的情况下,Y 发生的概率"。
举个例子:P(下雨|乌云密布) 表示"看到乌云密布时,下雨的概率"。
在深度学习中,我们用神经网络来估计这个条件概率:
实例
# 用神经网络输出概率分布
# ============================================
import numpy as np
def softmax(x):
"""Softmax 函数:把任意实数转换成概率分布
输出所有值的和为 1,每个值在 [0, 1] 之间
"""
# 减去最大值防止数值溢出
exp_x = np.exp(x - np.max(x))
return exp_x / np.sum(exp_x)
# 假设神经网络最后一层输出了 3 个"分数"(logits)
# 分别对应"猫"、"狗"、"鸟"
logits = np.array([2.0, 1.0, 0.5])
print(f"神经网络输出(logits):{logits}")
# 用 Softmax 转换成概率
probabilities = softmax(logits)
print(f"概率分布:{probabilities}")
print(f"概率和:{np.sum(probabilities)}")
# 解读:
# P(猫|输入图片) ≈ 0.67
# P(狗|输入图片) ≈ 0.24
# P(鸟|输入图片) ≈ 0.09
print("\n类别概率:")
print(f" 猫: {probabilities[0]:.2%}")
print(f" 狗: {probabilities[1]:.2%}")
print(f" 鸟: {probabilities[2]:.2%}")
三个数学基础的核心要点:
| 数学分支 | 核心概念 | 在深度学习中的用途 |
|---|---|---|
| 线性代数 | 矩阵乘法、向量、张量 | 表示网络结构,高效计算前向传播 |
| 微积分 | 导数、链式法则、梯度 | 反向传播,更新参数 |
| 概率论 | 条件概率、概率分布 | 建模不确定性,设计损失函数 |
前向传播
前向传播就是输入数据"流过"神经网络,从输入层到隐藏层再到输出层的计算过程。
计算图
把神经网络表示成计算图,能让前向传播和反向传播的逻辑更清晰。
计算图由节点(运算)和边(数据流)组成。
一个简单的两层神经网络的计算图:
实例
# 用计算图理解前向传播
# ============================================
import numpy as np
def relu(x):
"""ReLU 激活函数:max(0, x)"""
return np.maximum(0, x)
# 一个简单的两层神经网络
# 输入层 (2) → 隐藏层 (3) → 输出层 (2)
# 输入:x = [x1, x2]
x = np.array([1.0, 2.0])
print(f"输入 x: {x}")
# 第一层:输入 → 隐藏层
W1 = np.array([
[0.1, 0.2], # 第 1 个隐藏神经元
[0.3, 0.4], # 第 2 个隐藏神经元
[0.5, 0.6], # 第 3 个隐藏神经元
])
b1 = np.array([0.1, 0.2, 0.3])
# 第二层:隐藏层 → 输出层
W2 = np.array([
[0.1, 0.2, 0.3], # 第 1 个输出神经元
[0.4, 0.5, 0.6], # 第 2 个输出神经元
])
b2 = np.array([0.1, 0.2])
# 前向传播计算图
# 步骤 1: 隐藏层线性变换
z1 = W1 @ x + b1
print(f"\n隐藏层线性变换 z1: {z1}")
# 步骤 2: 隐藏层激活函数
a1 = relu(z1)
print(f"隐藏层激活后 a1: {a1}")
# 步骤 3: 输出层线性变换
z2 = W2 @ a1 + b2
print(f"输出层线性变换 z2: {z2}")
# 步骤 4: 输出层激活(如果是分类问题,通常用 Softmax)
a2 = softmax(z2) # 使用前面定义的 softmax 函数
print(f"输出层激活后 a2: {a2}")
计算图的好处是:每一步都很清晰,反向传播时只要按照相反的顺序计算梯度即可。
矩阵乘法视角下的神经网络
从矩阵乘法的角度看,神经网络就是一连串的"线性变换 + 非线性激活"。
每一层可以抽象成:
aᵢ = activation(Wᵢ · aᵢ₋₁ + bᵢ)
其中 a₀ 就是输入 x。
这个公式简洁但强大——它能表示从简单的逻辑回归到复杂的 Transformer 的几乎所有神经网络结构。
注意矩阵乘法的维度匹配:如果输入向量是 n 维,下一层有 m 个神经元,那么权重矩阵 W 的形状一定是 m×n。这样 W·x 之后得到的是 m 维向量。
激活函数的作用
前面提到过:没有激活函数,深层网络等价于单层网络。
用一个简单的例子证明这一点:
实例
# 为什么需要激活函数
# ============================================
import numpy as np
# 假设有一个两层网络,但都没有激活函数
# 输入
x = np.array([1.0, 2.0])
# 第一层权重和偏置
W1 = np.array([[0.1, 0.2], [0.3, 0.4]])
b1 = np.array([0.1, 0.2])
# 第二层权重和偏置
W2 = np.array([[0.5, 0.6], [0.7, 0.8]])
b2 = np.array([0.3, 0.4])
# 两层分别计算
z1 = W1 @ x + b1
z2 = W2 @ z1 + b2
print(f"两层分别计算结果: {z2}")
# 合并成一层计算
# 数学上可以证明:z2 = (W2·W1)·x + (W2·b1 + b2)
W_combined = W2 @ W1
b_combined = W2 @ b1 + b2
z_combined = W_combined @ x + b_combined
print(f"合并成一层计算结果: {z_combined}")
# 结果完全一样!
print(f"\n两个结果相等: {np.allclose(z2, z_combined)}")
print("结论:没有激活函数,深层网络 = 单层网络")
激活函数就是那个让深层网络有意义的关键组件——它引入非线性,让网络能够学习复杂的模式。
激活函数详解
激活函数决定了一个神经元的输出如何响应输入。
好的激活函数应该满足几个条件:
- 非线性:这是必须的
- 可微:这样才能计算梯度
- 计算简单:前向和反向传播都要快
- 不会饱和:梯度不会消失或爆炸
我们来看看最常用的几个激活函数。
Sigmoid:饱和与梯度消失
Sigmoid 是最早使用的激活函数之一。它把任意实数压缩到 (0, 1) 之间。
公式:σ(x) = 1 / (1 + e⁻ˣ)
实例
# Sigmoid 激活函数
# ============================================
import numpy as np
import matplotlib.pyplot as plt
def sigmoid(x):
"""Sigmoid 函数"""
return 1 / (1 + np.exp(-x))
def sigmoid_derivative(x):
"""Sigmoid 的导数:σ'(x) = σ(x) * (1 - σ(x))"""
s = sigmoid(x)
return s * (1 - s)
# 测试一些值
x_values = [-10, -5, -2, 0, 2, 5, 10]
print("x | sigmoid(x) | sigmoid'(x)")
print("-" * 40)
for x in x_values:
s = sigmoid(x)
d = sigmoid_derivative(x)
print(f"{x:4} | {s:10.6f} | {d:12.6f}")
# 观察:当 x 很大或很小时,导数接近 0
# 这就是"梯度消失"问题
x_big = 10.0
print(f"\n当 x = {x_big} 时:")
print(f" sigmoid(x) = {sigmoid(x_big):.10f} (几乎是 1)")
print(f" sigmoid'(x) = {sigmoid_derivative(x_big):.10f} (几乎是 0)")
print("梯度消失了!反向传播时梯度传不回去。")
Sigmoid 的问题很明显:
- 当 |x| > 6 时,梯度几乎为 0,梯度消失
- 输出不是以 0 为中心,这会影响优化
- 计算指数比较慢
由于这些问题,Sigmoid 在现代深度学习中已经很少在隐藏层使用了。
ReLU 与变体
ReLU(Rectified Linear Unit)是现在最常用的激活函数。
公式:ReLU(x) = max(0, x)
简单但非常有效。
实例
# ReLU 及其变体
# ============================================
import numpy as np
def relu(x):
"""ReLU:max(0, x)"""
return np.maximum(0, x)
def relu_derivative(x):
"""ReLU 的导数:x > 0 时为 1,否则为 0"""
return (x > 0).astype(float)
def leaky_relu(x, alpha=0.01):
"""Leaky ReLU:x > 0 时为 x,否则为 alpha*x"""
return np.where(x > 0, x, alpha * x)
def gelu(x):
"""GELU:Gaussian Error Linear Unit
这是 Transformer 中常用的激活函数
近似公式:0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715*x^3)))
"""
cdf = 0.5 * (1.0 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * np.power(x, 3))))
return x * cdf
# 测试
x_values = [-5, -2, -1, 0, 1, 2, 5]
print("x | ReLU | Leaky | GELU")
print("-" * 45)
for x in x_values:
r = relu(x)
lr = leaky_relu(x)
g = gelu(x)
print(f"{x:4} | {r:4.2f} | {lr:5.2f} | {g:5.2f}")
# ReLU 的优势:
print("\nReLU 的优势:")
print("1. 计算简单:只需要 max 操作")
print("2. 不会饱和(正区间):梯度始终是 1")
print("3. 稀疏性:部分神经元输出为 0,增加模型鲁棒性")
# ReLU 的问题:"死亡 ReLU"
print("\nReLU 的问题:死亡 ReLU")
print("如果一个神经元的输入总是负的,那么:")
print(" - 输出始终为 0")
print(" - 梯度始终为 0")
print(" - 参数永远不会更新")
print(" - 这个神经元就'死'了")
几种常见激活函数的对比:
| 激活函数 | 公式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Sigmoid | 1/(1+e⁻ˣ) | 输出在 (0,1),可解释为概率 | 梯度消失、非零中心、计算慢 | 二分类输出层(已很少用于隐藏层) |
| ReLU | max(0, x) | 计算快、不饱和、稀疏性 | 死亡 ReLU、输出非零中心 | 大多数隐藏层(默认选择) |
| Leaky ReLU | max(αx, x) | 解决死亡 ReLU 问题 | α 需要手动选择 | ReLU 失效时的替代 |
| GELU | x·Φ(x) | 平滑、在 Transformer 中效果好 | 计算稍复杂 | Transformer、BERT、GPT 等 |
| Swish/SwiGLU | x·σ(βx) | 在深层网络中优于 ReLU | 计算复杂 | PaLM、LLaMA 等大模型 |
选择原则
激活函数的选择建议:
- 隐藏层:优先用 ReLU,简单有效
- 如果遇到死亡 ReLU:试试 Leaky ReLU 或 GELU
- Transformer:GELU 或 SwiGLU 是标准选择
- 二分类输出层:Sigmoid(输出概率)
- 多分类输出层:Softmax(输出概率分布)
- 回归输出层:不加激活(输出任意实数)
不要过度纠结激活函数的选择。通常 ReLU 就足够好了。当你确认 ReLU 是瓶颈时,再换其他的也不迟。
损失函数
损失函数衡量"模型的预测"和"真实答案"之间的差距。
训练的目标就是让这个差距尽可能小。
交叉熵损失(分类)
分类问题最常用的损失函数是交叉熵损失(Cross-Entropy Loss)。
它的直观含义是:"模型预测的概率分布和真实分布之间有多大差异"。
实例
# 交叉熵损失
# ============================================
import numpy as np
def cross_entropy_loss(probs, target_one_hot):
"""交叉熵损失
probs: 模型预测的概率分布,形状 (batch_size, num_classes)
target_one_hot: 真实标签的 one-hot 编码,形状同上
"""
# 加上 epsilon 防止 log(0)
epsilon = 1e-12
probs = np.clip(probs, epsilon, 1.0 - epsilon)
# 交叉熵 = -sum(target * log(pred))
return -np.sum(target_one_hot * np.log(probs)) / len(probs)
def softmax(x):
"""Softmax 函数"""
exp_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
return exp_x / np.sum(exp_x, axis=-1, keepdims=True)
# 例子:三分类问题,类别是"猫"、"狗"、"鸟"
# 假设真实标签是"猫"(索引 0),one-hot 编码
target = np.array([[1, 0, 0]]) # 正确答案是第 0 类
# 场景 1:模型很自信地预测"猫"
logits1 = np.array([[5.0, 0.5, 0.1]]) # 猫的分数最高
probs1 = softmax(logits1)
loss1 = cross_entropy_loss(probs1, target)
print("场景 1:模型自信预测正确")
print(f" 概率分布: {probs1[0]}")
print(f" 损失: {loss1:.6f} (很小)")
# 场景 2:模型不确定
logits2 = np.array([[1.0, 0.9, 0.8]]) # 三个类别分数差不多
probs2 = softmax(logits2)
loss2 = cross_entropy_loss(probs2, target)
print("\n场景 2:模型不确定")
print(f" 概率分布: {probs2[0]}")
print(f" 损失: {loss2:.6f} (中等)")
# 场景 3:模型预测错误
logits3 = np.array([[0.1, 5.0, 0.5]]) # 狗的分数最高
probs3 = softmax(logits3)
loss3 = cross_entropy_loss(probs3, target)
print("\n场景 3:模型预测错误")
print(f" 概率分布: {probs3[0]}")
print(f" 损失: {loss3:.6f} (很大)")
交叉熵损失的一个简化版本:当标签是类别索引而不是 one-hot 编码时,可以用更高效的计算方式:
loss = -log(probs[target_class])
这就是为什么在 PyTorch 中,CrossEntropyLoss 直接接受类别索引作为标签。
MSE 损失(回归)
回归问题(预测连续值)最常用的损失是均方误差(Mean Squared Error,MSE)。
公式:MSE = mean((pred - target)²)
实例
# MSE 损失
# ============================================
import numpy as np
def mse_loss(pred, target):
"""均方误差损失"""
return np.mean((pred - target) ** 2)
def mae_loss(pred, target):
"""平均绝对误差损失"""
return np.mean(np.abs(pred - target))
# 例子:预测房价(单位:万元)
# 真实房价
target = np.array([100, 200, 150])
# 场景 1:预测很准
pred1 = np.array([102, 195, 148])
mse1 = mse_loss(pred1, target)
mae1 = mae_loss(pred1, target)
print("场景 1:预测准确")
print(f" 预测: {pred1}")
print(f" 真实: {target}")
print(f" MSE: {mse1:.2f}")
print(f" MAE: {mae1:.2f}")
# 场景 2:预测有一个大误差
pred2 = np.array([102, 280, 148]) # 第二个房子预测高了 80 万
mse2 = mse_loss(pred2, target)
mae2 = mae_loss(pred2, target)
print("\n场景 2:有一个大误差")
print(f" 预测: {pred2}")
print(f" 真实: {target}")
print(f" MSE: {mse2:.2f} (被大误差放大很多)")
print(f" MAE: {mae2:.2f}")
# MSE vs MAE
print("\nMSE vs MAE:")
print("- MSE 对大误差更敏感(因为平方)")
print("- MAE 对异常值更鲁棒")
print("- 一般情况下用 MSE 更多")
语言模型的预测下一词损失
大语言模型(如 GPT)的训练目标很简单:给定上文,预测下一个词。
这本质上是一个多分类问题——词汇表中的每个词都是一个类别。
实例
# 语言模型的损失函数
# ============================================
import numpy as np
def language_model_loss(logits, targets, vocab_size):
"""语言模型损失
本质上是对每个位置的交叉熵损失
"""
batch_size, seq_len, _ = logits.shape
total_loss = 0.0
# 对序列中的每个位置计算损失
for i in range(seq_len):
# 这个位置的预测分数
step_logits = logits[:, i, :]
# 转换成概率
step_probs = softmax(step_logits)
# 目标词的索引
step_target = targets[:, i]
# 只取目标词的概率
for b in range(batch_size):
target_idx = step_target[b]
target_prob = step_probs[b, target_idx]
# 损失 = -log(p)
total_loss += -np.log(target_prob + 1e-12)
return total_loss / (batch_size * seq_len)
# 一个简单的例子
vocab_size = 10000 # 假设词汇表大小是 1 万
batch_size = 2
seq_len = 3
# 模型输出:(batch_size, seq_len, vocab_size)
logits = np.random.randn(batch_size, seq_len, vocab_size)
# 真实目标:每个位置应该预测的词索引
targets = np.array([
[123, 456, 789], # 第一个序列的目标词
[234, 567, 890], # 第二个序列的目标词
])
loss = language_model_loss(logits, targets, vocab_size)
print(f"语言模型损失: {loss:.4f}")
print("\n这个损失的含义:")
print("- 对序列中的每个位置")
print("- 预测下一个词")
print("- 计算所有位置的平均交叉熵")
常用损失函数总结:
| 任务类型 | 损失函数 | 输出层激活 | 标签格式 |
|---|---|---|---|
| 二分类 | Binary Cross-Entropy | Sigmoid | 0 或 1 |
| 多分类 | Cross-Entropy | Softmax | 类别索引 |
| 多标签分类 | Binary Cross-Entropy | Sigmoid | 多个 0/1 |
| 回归 | MSE / MAE | 无 | 连续值 |
| 语言模型 | Cross-Entropy | Softmax | 词索引 |
反向传播
反向传播(Backpropagation)是训练神经网络的核心算法。
它的目标是:计算损失函数对每个参数的梯度,然后用梯度下降更新参数。
链式法则推导
我们用一个简单的例子,一步步推导反向传播。
实例
# 手动实现反向传播
# ============================================
import numpy as np
def relu(x):
return np.maximum(0, x)
def relu_derivative(x):
return (x > 0).astype(float)
def mse_loss(pred, target):
return np.mean((pred - target) ** 2)
# 一个最简单的神经网络:一层线性变换
# y = w * x + b
# 损失 = (y - target)^2
# 输入和目标
x = np.array(2.0)
target = np.array(7.0)
# 初始化参数
w = np.array(1.0)
b = np.array(0.0)
print(f"初始参数: w = {w}, b = {b}")
print(f"目标: {target}")
# ============================================
# 第一步:前向传播
# ============================================
y = w * x + b
loss = (y - target) ** 2
print(f"\n前向传播:")
print(f" y = {y}")
print(f" loss = {loss}")
# ============================================
# 第二步:反向传播(手动计算梯度)
# ============================================
# 我们需要计算:d_loss/d_w, d_loss/d_b
# 用链式法则一步步来:
# loss = (y - target)^2
# d_loss/d_y = 2 * (y - target)
d_loss_d_y = 2 * (y - target)
# y = w * x + b
# d_y/d_w = x
# d_y/d_b = 1
d_y_d_w = x
d_y_d_b = 1.0
# 链式法则:
# d_loss/d_w = d_loss/d_y * d_y/d_w
# d_loss/d_b = d_loss/d_y * d_y/d_b
d_loss_d_w = d_loss_d_y * d_y_d_w
d_loss_d_b = d_loss_d_y * d_y_d_b
print(f"\n反向传播:")
print(f" d_loss/d_y = {d_loss_d_y}")
print(f" d_loss/d_w = {d_loss_d_w}")
print(f" d_loss/d_b = {d_loss_d_b}")
# ============================================
# 第三步:梯度下降更新参数
# ============================================
learning_rate = 0.1
w_new = w - learning_rate * d_loss_d_w
b_new = b - learning_rate * d_loss_d_b
print(f"\n参数更新:")
print(f" w: {w} -> {w_new}")
print(f" b: {b} -> {b_new}")
# 验证:用新参数前向传播
y_new = w_new * x + b_new
loss_new = (y_new - target) ** 2
print(f"\n验证:")
print(f" 旧预测: {y}, 旧损失: {loss}")
print(f" 新预测: {y_new}, 新损失: {loss_new}")
print(f" 损失下降了!")
这个例子虽然简单,但包含了反向传播的所有核心思想:
- 前向传播:计算所有中间变量
- 反向传播:从输出开始,用链式法则层层计算梯度
- 参数更新:用梯度下降更新参数
计算图中的梯度流动
对于多层神经网络,梯度在计算图中反向流动。
每一层的梯度依赖于后一层传来的梯度。
实例
# 两层网络的反向传播
# ============================================
import numpy as np
def relu(x):
return np.maximum(0, x)
def relu_derivative(x):
return (x > 0).astype(float)
# 一个两层网络
# 输入 (2) → 隐藏层 (3) → 输出 (1)
# 数据
x = np.array([1.0, 2.0]) # 输入
target = np.array([5.0]) # 目标输出
# 参数初始化
W1 = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]) # (3, 2)
b1 = np.array([0.1, 0.2, 0.3]) # (3,)
W2 = np.array([[0.7, 0.8, 0.9]]) # (1, 3)
b2 = np.array([0.4]) # (1,)
print("初始参数已设置")
# ============================================
# 前向传播
# ============================================
print("\n=== 前向传播 ===")
# 第一层
z1 = W1 @ x + b1 # (3,)
a1 = relu(z1) # (3,)
print(f"z1 = {z1}")
print(f"a1 = {a1}")
# 第二层
z2 = W2 @ a1 + b2 # (1,)
a2 = z2 # 回归任务,输出层不加激活
print(f"z2 = {z2}")
print(f"a2 = {a2}")
# 损失
loss = np.mean((a2 - target) ** 2)
print(f"loss = {loss}")
# ============================================
# 反向传播
# ============================================
print("\n=== 反向传播 ===")
# 输出层梯度
d_loss_d_a2 = 2 * (a2 - target) # (1,)
d_a2_d_z2 = 1.0 # 没有激活函数
d_loss_d_z2 = d_loss_d_a2 * d_a2_d_z2 # (1,)
print(f"d_loss_d_z2 = {d_loss_d_z2}")
# 第二层参数梯度
d_z2_d_W2 = a1 # (3,),因为 z2 = W2@a1 + b2
d_z2_d_b2 = 1.0 # (1,)
d_z2_d_a1 = W2[0] # (3,)
# 计算参数梯度
d_loss_d_W2 = d_loss_d_z2.reshape(-1, 1) @ d_z2_d_W2.reshape(1, -1) # (1, 3)
d_loss_d_b2 = d_loss_d_z2 * d_z2_d_b2 # (1,)
print(f"d_loss_d_W2 = {d_loss_d_W2}")
print(f"d_loss_d_b2 = {d_loss_d_b2}")
# 传到隐藏层的梯度
d_loss_d_a1 = d_loss_d_z2 @ d_z2_d_a1 # (3,)
print(f"d_loss_d_a1 = {d_loss_d_a1}")
# 隐藏层梯度
d_a1_d_z1 = relu_derivative(z1) # (3,)
d_loss_d_z1 = d_loss_d_a1 * d_a1_d_z1 # (3,)
print(f"d_loss_d_z1 = {d_loss_d_z1}")
# 第一层参数梯度
d_z1_d_W1 = x # (2,)
d_z1_d_b1 = 1.0 # (3,)
# 计算参数梯度
d_loss_d_W1 = d_loss_d_z1.reshape(-1, 1) @ d_z1_d_W1.reshape(1, -1) # (3, 2)
d_loss_d_b1 = d_loss_d_z1 * d_z1_d_b1 # (3,)
print(f"d_loss_d_W1 = {d_loss_d_W1}")
print(f"d_loss_d_b1 = {d_loss_d_b1}")
# ============================================
# 参数更新
# ============================================
print("\n=== 参数更新 ===")
learning_rate = 0.01
W1_new = W1 - learning_rate * d_loss_d_W1
b1_new = b1 - learning_rate * d_loss_d_b1
W2_new = W2 - learning_rate * d_loss_d_W2
b2_new = b2 - learning_rate * d_loss_d_b2
print("参数已更新")
这个例子展示了反向传播的完整流程。
核心规律是:
- 对于线性层 y = Wx + b:dL/dW = (dL/dy)·xᵀ,dL/db = dL/dy
- 对于激活函数 y = f(z):dL/dz = dL/dy ⊙ f'(z)(⊙ 是逐元素相乘)
- 梯度从后往前传,每一层都用链式法则"积累"梯度
好消息是:现代深度学习框架(如 PyTorch、TensorFlow)会自动为你计算反向传播。你只需要定义前向传播,框架会用自动微分(autograd)搞定所有梯度计算。
梯度下降优化器
计算出梯度后,我们需要用优化器来更新参数。
最简单的优化器是随机梯度下降(SGD),但还有很多更高级的优化器。
SGD:随机梯度下降
公式:θ = θ - η·∇L(θ)
其中 η 是学习率。
实例
# SGD 优化器
# ============================================
import numpy as np
def f(x):
"""我们要最小化的函数:f(x) = x²"""
return x ** 2
def f_grad(x):
"""梯度:f'(x) = 2x"""
return 2 * x
# SGD 优化
x = 10.0 # 初始值
learning_rate = 0.1 # 学习率
steps = 20
print(f"初始 x = {x}, f(x) = {f(x)}")
print("-" * 40)
for step in range(steps):
grad = f_grad(x)
x = x - learning_rate * grad # SGD 更新
print(f"Step {step+1}: x = {x:.6f}, f(x) = {f(x):.6f}")
print("-" * 40)
print(f"最终 x = {x:.6f}, f(x) = {f(x):.6f}")
print("理论最优:x = 0, f(x) = 0")
SGD 简单但有缺点:
- 对所有参数使用相同的学习率
- 收敛可能很慢
- 容易卡在局部最优或鞍点
- 对噪声敏感
Momentum:动量加速
Momentum 引入了"速度"的概念——让梯度更新带有惯性。
公式:
v = β·v + (1-β)·∇L(θ) θ = θ - η·v
β 通常设为 0.9。
实例
# Momentum 优化器
# ============================================
import numpy as np
def f(x):
"""目标函数"""
return x ** 2
def f_grad(x):
"""梯度"""
return 2 * x
def sgd_optimize(x_init, learning_rate, steps):
"""纯 SGD"""
x = x_init
history = [x]
for _ in range(steps):
grad = f_grad(x)
x = x - learning_rate * grad
history.append(x)
return np.array(history)
def momentum_optimize(x_init, learning_rate, beta, steps):
"""Momentum"""
x = x_init
v = 0.0 # 初始速度
history = [x]
for _ in range(steps):
grad = f_grad(x)
v = beta * v + (1 - beta) * grad
x = x - learning_rate * v
history.append(x)
return np.array(history)
# 对比
x_init = 10.0
learning_rate = 0.1
beta = 0.9
steps = 20
sgd_history = sgd_optimize(x_init, learning_rate, steps)
momentum_history = momentum_optimize(x_init, learning_rate, beta, steps)
print("Step | SGD | Momentum")
print("-" * 35)
for i in range(steps + 1):
print(f"{i:4} | {sgd_history[i]:9.6f} | {momentum_history[i]:9.6f}")
print("\n观察:Momentum 收敛更快!")
print("因为它累积了过去的梯度,有惯性。")
Adam:自适应学习率
Adam(Adaptive Moment Estimation)是目前最常用的优化器之一。
它结合了 Momentum(一阶矩)和 RMSProp(二阶矩)的思想,为每个参数维护自适应的学习率。
公式:
m = β₁·m + (1-β₁)·∇L(θ) v = β₂·v + (1-β₂)·(∇L(θ))² m̂ = m / (1-β₁ᵗ) v̂ = v / (1-β₂ᵗ) θ = θ - η·m̂ / (√v̂ + ε)
超参数默认值:β₁=0.9, β₂=0.999, ε=1e-8, η=1e-3。
实例
# Adam 优化器
# ============================================
import numpy as np
def adam_optimize(x_init, learning_rate, beta1, beta2, epsilon, steps):
"""Adam 优化"""
x = x_init
m = 0.0 # 一阶矩
v = 0.0 # 二阶矩
history = [x]
for t in range(1, steps + 1):
grad = 2 * x # f(x) = x² 的梯度
# 更新一阶和二阶矩
m = beta1 * m + (1 - beta1) * grad
v = beta2 * v + (1 - beta2) * (grad ** 2)
# 偏差修正
m_hat = m / (1 - beta1 ** t)
v_hat = v / (1 - beta2 ** t)
# 参数更新
x = x - learning_rate * m_hat / (np.sqrt(v_hat) + epsilon)
history.append(x)
return np.array(history)
# 参数
x_init = 10.0
learning_rate = 0.5
beta1 = 0.9
beta2 = 0.999
epsilon = 1e-8
steps = 20
# 优化
history = adam_optimize(x_init, learning_rate, beta1, beta2, epsilon, steps)
print("Adam 优化过程:")
print("Step | x | f(x)")
print("-" * 35)
for i in range(steps + 1):
print(f"{i:4} | {history[i]:9.6f} | {history[i]**2:9.6f}")
AdamW:权重衰减修正
AdamW 是 Adam 的一个改进版本——它把权重衰减(Weight Decay)从梯度中分离出来。
这在正则化中很有用。
优化器对比:
| 优化器 | 公式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| SGD | θ = θ - η·∇L | 简单、稳定 | 收敛慢、对学习率敏感 | 数据量大、调参经验丰富 |
| SGD + Momentum | v = βv + (1-β)∇L θ = θ - ηv | 加速收敛、减少振荡 | 需要调 β | 一般比纯 SGD 好 |
| Adam | 自适应学习率 | 收敛快、需要较少调参 | 可能泛化不如 SGD | 大多数场景(默认选择) |
| AdamW | Adam + 权重衰减 | 更好的正则化 | 需要调权重衰减系数 | Transformer、大模型 |
优化器选择建议:先试 Adam,学习率设为 1e-3(3e-4 是更稳妥的选择)。如果效果不满意,再尝试调参或换其他优化器。
正则化技术
正则化的目标是防止过拟合——让模型在未见过的数据上也能表现良好。
L1/L2 正则化
L1 和 L2 正则化是最基本的正则化方法。
它们在损失函数中加上对参数的惩罚:
L_total = L + λ·L_reg
L1 正则化(Lasso):L_reg = |w|
L2 正则化(Ridge):L_reg = w²
实例
# L1/L2 正则化
# ============================================
import numpy as np
def l1_regularization(weights, lambda_l1):
"""L1 正则化:返回正则化项和梯度"""
reg = lambda_l1 * np.sum(np.abs(weights))
grad = lambda_l1 * np.sign(weights)
return reg, grad
def l2_regularization(weights, lambda_l2):
"""L2 正则化:返回正则化项和梯度"""
reg = 0.5 * lambda_l2 * np.sum(weights ** 2)
grad = lambda_l2 * weights
return reg, grad
# 例子
weights = np.array([1.0, -2.0, 3.0, 0.5])
print(f"权重: {weights}")
lambda_l1 = 0.1
lambda_l2 = 0.1
l1_reg, l1_grad = l1_regularization(weights, lambda_l1)
print(f"\nL1 正则化项: {l1_reg:.4f}")
print(f"L1 梯度: {l1_grad}")
l2_reg, l2_grad = l2_regularization(weights, lambda_l2)
print(f"\nL2 正则化项: {l2_reg:.4f}")
print(f"L2 梯度: {l2_grad}")
print("\nL1 vs L2:")
print("- L1:倾向于产生稀疏解(很多参数变 0),可做特征选择")
print("- L2:倾向于让所有参数都很小,但不会变 0")
print("- 深度学习中 L2 更常用(或用 AdamW 的权重衰减)")
Dropout
Dropout 是一个简单但有效的正则化技术:训练时随机"关掉"一部分神经元。
这迫使模型学习更鲁棒的特征——它不能依赖任何特定的神经元。
实例
# Dropout
# ============================================
import numpy as np
def dropout_forward(x, dropout_rate, training=True):
"""Dropout 前向传播"""
if not training:
# 测试时不 dropout,直接返回
return x
# 生成 mask:随机选择一部分神经元置 0
mask = (np.random.rand(*x.shape) > dropout_rate).astype(float)
# 缩放:测试时不需要缩放,所以训练时要除以 (1-dropout_rate)
scale = 1.0 / (1.0 - dropout_rate)
# 应用 dropout
out = x * mask * scale
return out, mask
# 测试
x = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
dropout_rate = 0.4
print(f"输入 x: {x}")
print(f"Dropout 率: {dropout_rate}")
# 多次运行,观察随机性
print("\n多次 Dropout 结果:")
for i in range(3):
out, mask = dropout_forward(x, dropout_rate, training=True)
print(f" 第 {i+1} 次: mask={mask}, out={out}")
# 测试模式
out_test = dropout_forward(x, dropout_rate, training=False)
print(f"\n测试模式(不 Dropout): {out_test}")
数据增强
数据增强是最有效的正则化方法之一——通过变换现有数据来创造更多训练样本。
对于图像,常用的数据增强有:翻转、旋转、缩放、裁剪、颜色抖动等。
对于文本,常用的数据增强有:同义词替换、随机插入、随机删除、回译等。
正则化技术总结:
| 方法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| L2 正则化 | 惩罚大的权重 | 简单、稳定 | 效果有限 |
| Dropout | 随机关掉神经元 | 简单有效 | 减慢训练 |
| 数据增强 | 增加训练数据多样性 | 效果显著 | 需要领域知识 |
| 早停(Early Stopping) | 验证集不提升就停止 | 简单有效 | 需要监控验证集 |
| 批归一化 | 归一化层输入 | 加速训练、正则化 | 增加计算开销 |
批归一化 vs 层归一化
归一化技术可以加速训练,让优化更稳定。
内部协变量偏移
训练深度网络时,每一层的输入分布会随着前一层参数的变化而变化。这就是"内部协变量偏移"(Internal Covariate Shift)。
这会导致训练变慢,因为每一层都要不断适应新的分布。
归一化技术的目标就是:让每一层的输入分布保持稳定。
批归一化(Batch Normalization)
批归一化(BatchNorm)在 batch 维度上进行归一化。
公式:
μ = 1/N · sum(xᵢ) σ² = 1/N · sum((xᵢ - μ)²) x̂ᵢ = (xᵢ - μ) / √(σ² + ε) yᵢ = γ·x̂ᵢ + β
其中 γ 和 β 是可学习参数。
实例
# 批归一化
# ============================================
import numpy as np
def batch_norm(x, gamma, beta, epsilon=1e-5, training=True, running_mean=None, running_var=None, momentum=0.1):
"""批归一化
x: 输入,形状 (batch_size, features)
gamma: 缩放参数
beta: 偏移参数
"""
if training:
# 训练模式:用当前 batch 的统计量
batch_mean = np.mean(x, axis=0)
batch_var = np.var(x, axis=0)
# 更新 running 统计量(用于测试时)
if running_mean is not None and running_var is not None:
running_mean[:] = momentum * running_mean + (1 - momentum) * batch_mean
running_var[:] = momentum * running_var + (1 - momentum) * batch_var
# 归一化
x_normalized = (x - batch_mean) / np.sqrt(batch_var + epsilon)
else:
# 测试模式:用 running 统计量
x_normalized = (x - running_mean) / np.sqrt(running_var + epsilon)
# 缩放和偏移
out = gamma * x_normalized + beta
return out
# 测试
batch_size = 4
features = 3
# 一个 batch 的数据
x = np.array([
[1.0, 2.0, 3.0],
[2.0, 3.0, 4.0],
[3.0, 4.0, 5.0],
[4.0, 5.0, 6.0],
])
print(f"输入 x:\n{x}")
# 初始参数
gamma = np.ones(features) # 初始不缩放
beta = np.zeros(features) # 初始不偏移
print(f"\ngamma: {gamma}")
print(f"beta: {beta}")
# 应用批归一化
out = batch_norm(x, gamma, beta, training=True)
print(f"\n批归一化后:\n{out}")
# 验证:每个特征的均值应该接近 0,方差接近 1
print(f"\n归一化后的均值: {np.mean(out, axis=0)}")
print(f"归一化后的方差: {np.var(out, axis=0)}")
批归一化的优点:
- 加速训练
- 允许更大的学习率
- 减少对初始化的敏感
- 有轻微的正则化效果
批归一化的缺点:
- 依赖 batch 大小——batch 太小效果不好
- 训练和测试时行为不同
- 不适用于序列模型(如 RNN、Transformer)
层归一化(Layer Normalization)
层归一化(LayerNorm)在特征维度上进行归一化,而不是 batch 维度。
这让它不依赖 batch 大小,非常适合序列模型。
实例
# 层归一化
# ============================================
import numpy as np
def layer_norm(x, gamma, beta, epsilon=1e-5):
"""层归一化
x: 输入,形状 (batch_size, features) 或 (batch_size, seq_len, features)
"""
# 在最后一个维度(特征维度)上计算均值和方差
mean = np.mean(x, axis=-1, keepdims=True)
var = np.var(x, axis=-1, keepdims=True)
# 归一化
x_normalized = (x - mean) / np.sqrt(var + epsilon)
# 缩放和偏移
out = gamma * x_normalized + beta
return out
# 测试
batch_size = 2
seq_len = 3
features = 4
# 模拟 Transformer 中的输入:(batch_size, seq_len, features)
x = np.array([
[[1.0, 2.0, 3.0, 4.0],
[2.0, 3.0, 4.0, 5.0],
[3.0, 4.0, 5.0, 6.0]],
[[4.0, 5.0, 6.0, 7.0],
[5.0, 6.0, 7.0, 8.0],
[6.0, 7.0, 8.0, 9.0]],
])
print(f"输入 x 形状: {x.shape}")
# 初始参数
gamma = np.ones(features)
beta = np.zeros(features)
# 应用层归一化
out = layer_norm(x, gamma, beta)
print(f"层归一化后形状: {out.shape}")
# 验证:每个位置的均值接近 0,方差接近 1
mean_check = np.mean(out, axis=-1)
var_check = np.var(out, axis=-1)
print(f"\n每个位置的均值:\n{mean_check}")
print(f"每个位置的方差:\n{var_check}")
两种归一化方法的对比:
| 方法 | 归一化维度 | 依赖 batch | 适用场景 | 代表模型 |
|---|---|---|---|---|
| BatchNorm | batch 维度 | 是 | CNN、固定 batch 大小 | ResNet、VGG |
| LayerNorm | 特征维度 | 否 | Transformer、RNN、变长序列 | GPT、BERT、LLaMA |
对于 Transformer 和大语言模型,LayerNorm 是标准选择。它在每个位置独立计算统计量,不依赖 batch 大小,也不需要维护 running 统计量。
学习率调度
学习率是最重要的超参数之一——太大不收敛,太小收敛太慢。
学习率调度(Learning Rate Scheduling)就是在训练过程中动态调整学习率。
预热(Warmup)
训练初期,先用小学习率"预热",让模型稳定下来,然后再提升到目标学习率。
这对 Transformer 尤其重要。
余弦退火
余弦退火(Cosine Annealing)让学习率按照余弦函数的形状逐渐下降。
实例
# 学习率调度
# ============================================
import numpy as np
def warmup_schedule(step, warmup_steps, base_lr):
"""线性预热调度"""
if step < warmup_steps:
return base_lr * (step + 1) / warmup_steps
return base_lr
def cosine_annealing_schedule(step, total_steps, base_lr, min_lr=0.0):
"""余弦退火调度"""
return min_lr + 0.5 * (base_lr - min_lr) * (1 + np.cos(np.pi * step / total_steps))
def warmup_cosine_schedule(step, warmup_steps, total_steps, base_lr, min_lr=0.0):
"""预热 + 余弦退火"""
if step < warmup_steps:
return base_lr * (step + 1) / warmup_steps
return min_lr + 0.5 * (base_lr - min_lr) * (1 + np.cos(np.pi * (step - warmup_steps) / (total_steps - warmup_steps)))
# 参数
total_steps = 100
warmup_steps = 10
base_lr = 1e-3
min_lr = 1e-5
print("不同学习率调度:")
print("Step | Warmup | Cosine | Warmup+Cosine")
print("-" * 55)
for step in range(0, total_steps + 1, 10):
lr_warmup = warmup_schedule(step, warmup_steps, base_lr)
lr_cosine = cosine_annealing_schedule(step, total_steps, base_lr, min_lr)
lr_combined = warmup_cosine_schedule(step, warmup_steps, total_steps, base_lr, min_lr)
print(f"{step:4} | {lr_warmup:.6f} | {lr_cosine:.6f} | {lr_combined:.6f}")
学习率对训练的影响
学习率的影响:
- 太大:训练不稳定,可能发散
- 太小:收敛慢,可能卡在局部最优
- 合适:快速稳定收敛
一个常用的策略是"学习率范围测试"(Learning Rate Finder):先用从小到大的学习率训练几步,看哪个学习率下损失下降最快。
学习率调度方法总结:
| 调度方法 | 特点 | 适用场景 |
|---|---|---|
| 固定学习率 | 最简单 | 小模型、简单任务 |
| Step Decay | 每隔几步降一次 | CNN 等 |
| Cosine Annealing | 平滑下降 | 大多数场景 |
| Warmup + Cosine | 先升后降 | Transformer、大模型(推荐) |
| Reduce on Plateau | 验证集不提升就降 | 需要监控验证集 |
实战:用 PyTorch 从零实现 MLP
现在我们把前面所有知识点整合起来,用 PyTorch 实现一个完整的多层感知机(MLP),并在一个简单的分类任务上训练它。
实例
# PyTorch 实现 MLP 完整训练流程
# ============================================
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import numpy as np
# ============================================
# 1. 准备数据
# ============================================
class SimpleDataset(Dataset):
"""一个简单的二分类数据集"""
def __init__(self, n_samples=1000, n_features=10):
# 生成合成数据
np.random.seed(42)
self.X = np.random.randn(n_samples, n_features).astype(np.float32)
# 简单的分类规则:y = 1 if sum(x[:5]) > 0 else 0
self.y = (np.sum(self.X[:, :5], axis=1) > 0).astype(np.int64)
def __len__(self):
return len(self.X)
def __getitem__(self, idx):
return torch.tensor(self.X[idx]), torch.tensor(self.y[idx])
# 创建数据集和数据加载器
train_dataset = SimpleDataset(n_samples=800)
val_dataset = SimpleDataset(n_samples=200)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)
print(f"训练集大小: {len(train_dataset)}")
print(f"验证集大小: {len(val_dataset)}")
# ============================================
# 2. 定义模型
# ============================================
class MLP(nn.Module):
"""多层感知机"""
def __init__(self, input_dim, hidden_dims, output_dim, dropout_rate=0.1):
super().__init__()
layers = []
prev_dim = input_dim
# 隐藏层
for hidden_dim in hidden_dims:
layers.append(nn.Linear(prev_dim, hidden_dim))
layers.append(nn.ReLU())
layers.append(nn.LayerNorm(hidden_dim)) # 层归一化
layers.append(nn.Dropout(dropout_rate)) # Dropout
prev_dim = hidden_dim
# 输出层
layers.append(nn.Linear(prev_dim, output_dim))
self.model = nn.Sequential(*layers)
def forward(self, x):
return self.model(x)
# 创建模型
model = MLP(
input_dim=10,
hidden_dims=[64, 32],
output_dim=2,
dropout_rate=0.1
)
print("\n模型结构:")
print(model)
# ============================================
# 3. 定义损失函数和优化器
# ============================================
criterion = nn.CrossEntropyLoss() # 交叉熵损失
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) # AdamW 优化器
# 学习率调度:预热 + 余弦退火
total_epochs = 20
warmup_epochs = 2
total_steps = len(train_loader) * total_epochs
warmup_steps = len(train_loader) * warmup_epochs
def lr_lambda(step):
if step < warmup_steps:
return (step + 1) / warmup_steps
else:
progress = (step - warmup_steps) / (total_steps - warmup_steps)
return 0.5 * (1 + np.cos(np.pi * progress))
scheduler = optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lr_lambda)
print("\n训练配置:")
print(f" Epochs: {total_epochs}")
print(f" Warmup epochs: {warmup_epochs}")
print(f" Total steps: {total_steps}")
print(f" Warmup steps: {warmup_steps}")
# ============================================
# 4. 训练循环
# ============================================
print("\n开始训练...")
print("-" * 50)
global_step = 0
best_val_acc = 0.0
for epoch in range(total_epochs):
# 训练阶段
model.train()
train_loss = 0.0
train_correct = 0
train_total = 0
for batch_X, batch_y in train_loader:
# 前向传播
logits = model(batch_X)
loss = criterion(logits, batch_y)
# 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
scheduler.step()
# 统计
train_loss += loss.item()
predicted = torch.argmax(logits, dim=1)
train_total += batch_y.size(0)
train_correct += (predicted == batch_y).sum().item()
global_step += 1
train_loss /= len(train_loader)
train_acc = train_correct / train_total
# 验证阶段
model.eval()
val_loss = 0.0
val_correct = 0
val_total = 0
with torch.no_grad():
for batch_X, batch_y in val_loader:
logits = model(batch_X)
loss = criterion(logits, batch_y)
val_loss += loss.item()
predicted = torch.argmax(logits, dim=1)
val_total += batch_y.size(0)
val_correct += (predicted == batch_y).sum().item()
val_loss /= len(val_loader)
val_acc = val_correct / val_total
# 保存最好的模型
if val_acc > best_val_acc:
best_val_acc = val_acc
# 这里可以保存模型 checkpoint
# 打印日志
current_lr = optimizer.param_groups[0]['lr']
print(f"Epoch {epoch+1:2d} | lr: {current_lr:.6f} | "
f"Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | "
f"Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f}")
print("-" * 50)
print(f"训练完成!最好验证准确率: {best_val_acc:.4f}")
# ============================================
# 5. 推理示例
# ============================================
print("\n推理示例:")
model.eval()
# 几个测试样本
test_samples = [
np.array([1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32), # 应该是 1
np.array([-1.0, -1.0, -1.0, -1.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32), # 应该是 0
]
for i, sample in enumerate(test_samples):
with torch.no_grad():
input_tensor = torch.tensor(sample).unsqueeze(0)
logits = model(input_tensor)
probs = torch.softmax(logits, dim=1)
predicted_class = torch.argmax(probs, dim=1).item()
print(f" 样本 {i+1}: {sample[:5]}...")
print(f" 预测类别: {predicted_class}")
print(f" 概率: 类0={probs[0,0]:.4f}, 类1={probs[0,1]:.4f}")
这个例子包含了深度学习训练的所有标准组件:
- 数据处理:Dataset、DataLoader
- 模型定义:MLP 带 LayerNorm 和 Dropout
- 损失函数:CrossEntropyLoss
- 优化器:AdamW
- 学习率调度:Warmup + Cosine
- 训练循环:训练阶段 + 验证阶段
- 模型保存和推理
这个流程可以直接推广到更复杂的任务和模型。
点我分享笔记