深度学习与计算机视觉

深度学习与计算机视觉:一场认知革命

第一部分:奠基——深度学习核心概念

在深入计算机视觉的 spezifische Anwendungen (特定应用) 之前,我们必须对驱动这场革命的核心引擎——深度学习——有深刻且坚实的理解。这一部分将详细阐述深度学习的基本原理、关键组件和数学思想。

第一章:机器学习与深度学习的渊源

1.1 人工智能、机器学习与深度学习的关系

为了精确地定位深度学习,我们首先需要理解它在更广阔的人工智能领域中的位置。

人工智能 (Artificial Intelligence, AI):

定义: 人工智能是一个广阔的计算机科学分支,致力于创造能够执行通常需要人类智能才能完成的任务的机器或系统。这些任务包括学习、解决问题、理解语言、感知环境、做出决策等。
目标: AI的最终目标是创造出能够模拟、延伸甚至超越人类智能的机器。
范畴: AI包含了众多的子领域,如机器学习、自然语言处理 (NLP)、计算机视觉 (CV)、机器人学、专家系统等。

机器学习 (Machine Learning, ML):

定义: 机器学习是实现人工智能的一种重要方法。它专注于开发能够让计算机系统从数据中“学习”并改进其性能的算法,而无需进行显式的编程来指定每一步操作。
核心思想: 给予算法大量数据(经验),让算法自动从数据中发现模式、规律或做出预测。
与传统编程的区别:

传统编程: 输入数据 + 程序 (显式规则) -> 输出结果
机器学习: 输入数据 + 期望输出 (部分场景) -> 程序 (学习到的模型/规则)

主要类型:

监督学习 (Supervised Learning): 训练数据包含输入特征和对应的“正确答案”(标签或目标值)。算法学习从输入到输出的映射关系。例如,根据房屋大小、位置等特征预测房价(回归),或者根据邮件内容判断是否为垃圾邮件(分类)。
无监督学习 (Unsupervised Learning): 训练数据只有输入特征,没有对应的标签。算法需要自己从数据中发现结构、模式或关系。例如,将相似的用户聚类(聚类),或者降低数据维度以去除冗余信息(降维)。
强化学习 (Reinforcement Learning): 算法(称为智能体 Agent)通过与环境 (Environment) 交互来学习。智能体在环境中采取行动 (Action),环境反馈奖励 (Reward) 或惩罚 (Punishment)。智能体的目标是学习一个策略 (Policy) 来最大化累积奖励。例如,训练机器下棋或控制机器人行走。

深度学习 (Deep Learning, DL):

定义: 深度学习是机器学习的一个特定分支,它主要基于人工神经网络 (Artificial Neural Networks, ANNs),特别是包含多个“深”层次(即多个隐藏层)的神经网络。
核心特征:

层次化特征学习 (Hierarchical Feature Learning): 深度学习模型能够自动地从原始数据中学习到一系列层次化的特征表示。浅层学习简单的低级特征(如图像中的边缘、角点),深层则基于浅层特征组合出更复杂、更抽象的高级特征(如物体的部件、整个物体)。这种自动特征工程是深度学习强大的主要原因之一。
大规模数据驱动: 深度学习模型通常包含大量参数,需要大规模的标注数据才能充分发挥其潜力并避免过拟合。
计算密集型: 训练深度学习模型通常需要强大的计算资源,尤其是GPU(图形处理器),因为它们非常适合并行计算神经网络中的大量矩阵运算。

与传统机器学习中特征工程的区别: 在传统的机器学习流程中,特征工程(即从原始数据中提取和选择有用的特征)往往是最耗时、最依赖领域知识的步骤。深度学习在很大程度上自动化了这个过程。

它们之间的关系可以用集合图来表示:

graph TD
    A[人工智能 (AI)] --> B(机器学习 (ML));
    B --> C(深度学习 (DL));

    style A fill:#f9f,stroke:#333,stroke-width:2px,color:#000
    style B fill:#ccf,stroke:#333,stroke-width:2px,color:#000
    style C fill:#9f9,stroke:#333,stroke-width:2px,color:#000

人工智能 (AI): 是最广阔的领域。
机器学习 (ML): 是实现AI的一种方法。
深度学习 (DL): 是机器学习中一种基于深层神经网络的强大技术。

深度学习的成功,尤其是在计算机视觉、自然语言处理等领域,极大地推动了人工智能的边界。

1.2 为什么是“深度”学习?“深”的含义与优势

“深度 (Deep)” 指的是神经网络中隐藏层的数量。传统的(浅层)神经网络通常只有1到2个隐藏层,而深度神经网络可以有数十、数百甚至数千个隐藏层。

“深”的优势:

更强的表示能力 (Increased Representational Power):

理论上,具有一个足够宽的单隐藏层的神经网络可以逼近任何连续函数(通用逼近定理 Universal Approximation Theorem)。然而,在实践中,用一个非常宽的浅层网络来实现复杂函数的逼近,可能需要指数级数量的神经元,这在参数效率和泛化能力上都不理想。
深度结构允许网络以更有效的方式(使用更少的参数)来表示复杂函数。每一层可以被看作是对前一层输出进行一次非线性变换,提取更高级别的特征。通过多层堆叠,网络可以学习到数据中非常复杂和抽象的模式。
可以认为,深度网络通过组合简单的非线性变换来构建高度复杂的非线性变换。每一层学习到的特征都是对输入数据的一种新的、更有用的表示。

层次化特征提取 (Hierarchical Feature Extraction):

这是深度学习最核心的优势之一,尤其在处理感知数据(如图像、语音)时。
以图像为例:

第一层 (靠近输入): 可能学习检测图像中的基本边缘、角点、颜色斑块等低级特征。
中间层: 可能将低级特征组合起来,学习检测更复杂的纹理、物体的局部部件(如眼睛、鼻子、轮子)。
更深层: 可能将部件组合起来,学习识别整个物体(如人脸、汽车、猫)。
最高层: 可能基于物体识别进行场景理解或更高级的推理。

这种层次化的方式与人类视觉系统的感知过程有一定的相似性。人类也是从简单的视觉元素开始,逐步构建对复杂场景的理解。
这种自动学习特征的能力,使得我们不再需要手动设计复杂的特征提取器,大大简化了模型开发流程,并往往能发现比人工设计更有效的特征。

参数共享与效率 (Parameter Sharing and Efficiency – 特别是CNN中):

在特定类型的深度网络(如卷积神经网络CNN,后续会详细讲解)中,参数共享机制(例如卷积核在图像不同位置的复用)极大地减少了模型的参数数量,使得训练深层网络成为可能,同时也增强了模型的平移不变性等良好特性。

更好的泛化能力 (Potentially Better Generalization):

虽然深度模型参数众多,容易过拟合,但如果数据量充足且配合适当的正则化技术,深度模型学习到的层次化特征往往具有更好的泛化能力。因为它们能抓住数据本质的、可迁移的模式,而不是仅仅记住训练数据中的噪声或特例。

端到端学习 (End-to-End Learning):

深度学习使得构建端到端模型成为可能。即从原始输入(如图像像素、原始文本)直接到最终输出(如物体类别、翻译结果),中间的特征提取和转换过程都由网络自动学习。这避免了传统多阶段系统中各模块误差累积的问题。

“深”也带来了挑战:

梯度消失/爆炸 (Vanishing/Exploding Gradients):

在非常深的网络中,使用基于梯度的优化算法(如反向传播)进行训练时,梯度信号在逐层向后传播的过程中可能会变得非常小(梯度消失)或非常大(梯度爆炸),导致浅层网络的参数难以更新或训练不稳定。
解决方法:ReLU等激活函数、合适的权重初始化方法、残差连接 (ResNets)、批归一化 (Batch Normalization)、梯度裁剪 (Gradient Clipping) 等。

计算复杂度高 (High Computational Complexity):

深层网络通常包含大量参数和运算,训练和推理都需要强大的计算资源(尤其是GPU)。

需要大量数据 (Need for Large Amounts of Data):

为了训练好包含大量参数的深度模型并避免过拟合,通常需要大规模的标注数据集。数据获取和标注本身就是一项巨大的挑战。

过拟合 (Overfitting):

深度模型强大的表示能力也使其容易在训练数据上过拟合,即模型在训练集上表现很好,但在未见过的测试集上表现差。
解决方法:正则化(L1/L2正则化、Dropout)、数据增强、早停 (Early Stopping) 等。

可解释性差 (Poor Interpretability – “Black Box” Problem):

深度神经网络的决策过程往往难以直观理解,它们像一个“黑箱”。理解模型为什么做出某个特定预测是一个活跃的研究领域。

尽管存在这些挑战,深度学习通过不断发展的技术和方法,在许多领域取得了突破性进展,尤其是在计算机视觉领域,它已经成为主导范式。

第二章:神经网络基础——感知机与多层感知机

深度学习的核心是人工神经网络。我们将从最简单的神经网络单元——感知机开始,逐步构建到更复杂的多层感知机。

2.1 生物神经元与人工神经元 (感知机)

人工神经网络的最初灵感来源于对生物神经系统的观察和简化。

生物神经元 (Biological Neuron):

组成:

细胞体 (Soma): 神经元的主要部分,包含细胞核。
树突 (Dendrites): 从细胞体延伸出的分支状结构,负责接收来自其他神经元的信号。
轴突 (Axon): 从细胞体延伸出的长纤维,负责将信号传递给其他神经元。
突触 (Synapse): 轴突末端与下一个神经元的树突(或细胞体)之间的连接点,信号通过化学物质(神经递质)在此传递。

工作方式 (高度简化):

树突接收来自多个其他神经元的输入信号。
这些信号在细胞体内被整合。
如果整合后的信号强度超过某个阈值,神经元就会被“激活 (fire)”,并通过轴突产生一个动作电位(一个电信号)传递给下游神经元。
突触的强度(连接权重)可以调节信号传递的效率,并且这种强度是可塑的(可以通过学习改变)。

人工神经元 (Artificial Neuron) / 感知机 (Perceptron):

感知机是最早也是最简单的人工神经元模型之一,由 Frank Rosenblatt 于1957年提出。它模拟了生物神经元的基本功能。

模型结构:

输入 (Inputs): x_1, x_2, ..., x_n,可以是一组特征值。
权重 (Weights): w_1, w_2, ..., w_n,每个输入对应一个权重,表示该输入的重要性。
偏置 (Bias): b,一个额外的参数,可以看作是神经元激活的难易程度的调整项。可以将其视为一个权重为 b,输入恒为 1 的特殊输入 x_0=1, w_0=b
加权和 (Weighted Sum / Net Input): z = (w_1*x_1 + w_2*x_2 + ... + w_n*x_n) + b = Σ(w_i*x_i) + b。这是对所有输入进行加权求和,再加上偏置。
激活函数 (Activation Function): f(z),对加权和 z 应用一个非线性(或线性)函数,产生神经元的输出 y

感知机的数学表示:

y = f( Σ(w_i * x_i) + b )

或者使用向量表示:
z = w^T * x + b
y = f(z)
其中 w = [w_1, ..., w_n]^T 是权重向量, x = [x_1, ..., x_n]^T 是输入向量。

感知机中常用的激活函数 (早期):

阶跃函数 (Step Function / Heaviside Step Function):
f(z) = 1 if z >= θ (阈值)
f(z) = 0 if z < θ
如果将偏置 b 定义为 ,则变为:
f(z) = 1 if Σ(w_i*x_i) + b >= 0
f(z) = 0 if Σ(w_i*x_i) + b < 0
这种感知机输出二元值 (0或1),常用于二分类问题。它在输入空间中定义了一个线性决策边界(超平面)。

感知机的学习规则 (Perceptron Learning Rule – 针对阶跃激活函数和二分类):

感知机的学习目标是找到一组权重 w 和偏置 b,使得对于给定的训练样本 (x, t)(其中 t 是真实标签,例如0或1),感知机的输出 y 尽可能接近 t

对于每个训练样本 (x, t)

计算感知机的输出 y = f(w^T * x + b)
更新权重和偏置:
w_new = w_old + η * (t - y) * x
b_new = b_old + η * (t - y)
其中:

η (eta) 是学习率 (learning rate),一个小的正数,控制每次更新的步长。
(t - y) 是误差。

如果 y = t (预测正确),则 (t - y) = 0,权重不更新。
如果 y = 0, t = 1 (假阴性),则 (t - y) = 1,权重向 x 的方向增加 (w_new = w_old + η*x),使得 w^T*x 更可能为正。
如果 y = 1, t = 0 (假阳性),则 (t - y) = -1,权重向 x 的反方向减少 (w_new = w_old - η*x),使得 w^T*x 更可能为负。

感知机的局限性:

线性可分性 (Linear Separability): 单个感知机(使用阶跃激活函数)只能解决线性可分的问题。也就是说,它只能找到一个超平面来完美地分离开属于不同类别的样本点。如果数据不是线性可分的(例如XOR异或问题),单个感知机无法收敛到一个正确的解。

XOR问题:

输入 (x1, x2) 输出 (t)
(0, 0) 0
(0, 1) 1
(1, 0) 1
(1, 1) 0
你无法用一条直线在二维平面上将 (0,1), (1,0)(0,0), (1,1) 分开。

这个局限性导致了第一次AI寒冬,直到多层感知机和更强大的学习算法的出现。

代码示例:简单感知机实现 (用于理解,非实际DL库用法)

import numpy as np # 导入NumPy库,用于数值运算

class Perceptron: # 定义感知机类
    def __init__(self, num_inputs, learning_rate=0.01, epochs=100): # 构造函数
        # num_inputs: 输入特征的数量
        # learning_rate: 学习率
        # epochs: 训练迭代的轮数
        self.weights = np.random.rand(num_inputs) # 初始化权重为0到1之间的随机数,数量与输入特征数相同
        self.bias = np.random.rand(1) # 初始化偏置为0到1之间的随机数 (一个标量)
        self.learning_rate = learning_rate # 设置学习率
        self.epochs = epochs # 设置训练轮数
        print(f"感知机初始化完成。权重形状: {
              self.weights.shape}, 偏置: {
              self.bias}") # 打印初始化信息

    def _step_function(self, z): # 定义阶跃激活函数 (私有方法)
        # z: 加权和
        return 1 if z >= 0 else 0 # 如果z大于等于0,输出1,否则输出0

    def predict(self, inputs): # 定义预测方法
        # inputs: 输入特征向量 (NumPy数组)
        weighted_sum = np.dot(inputs, self.weights) + self.bias # 计算加权和: inputs和weights的点积,然后加上偏置
        prediction = self._step_function(weighted_sum) # 应用阶跃函数得到预测结果
        return prediction # 返回预测值 (0或1)

    def train(self, training_inputs, labels): # 定义训练方法
        # training_inputs: 训练输入数据 (一个包含多个样本的列表或NumPy数组,每个样本是一个特征向量)
        # labels: 对应的真实标签 (一个包含多个标签的列表或NumPy数组)
        print(f"
开始训练感知机...") # 打印开始训练信息
        print(f"学习率: {
              self.learning_rate}, 训练轮数: {
              self.epochs}") # 打印训练参数
        
        num_samples = len(training_inputs) # 获取训练样本数量
        if num_samples != len(labels): # 检查输入和标签数量是否匹配
            raise ValueError("训练输入和标签的数量必须相同。") # 抛出值错误

        for epoch in range(self.epochs): # 外层循环,迭代多轮
            num_errors = 0 # 初始化本轮错误计数
            for i in range(num_samples): # 内层循环,遍历每个训练样本
                inputs = training_inputs[i] # 获取当前样本的输入特征
                label = labels[i] # 获取当前样本的真实标签
                
                prediction = self.predict(inputs) # 使用当前权重进行预测
                
                error = label - prediction # 计算误差 (t - y)
                
                if error != 0: # 如果预测错误 (error不为0)
                    num_errors += 1 # 错误计数加1
                    # 更新权重和偏置
                    # w_new = w_old + learning_rate * error * x
                    # b_new = b_old + learning_rate * error
                    self.weights += self.learning_rate * error * inputs # 更新权重向量
                    self.bias += self.learning_rate * error # 更新偏置 (error是标量,inputs是向量,NumPy会自动广播)
            
            if epoch % 10 == 0 or epoch == self.epochs - 1: # 每10轮或最后一轮打印信息
                print(f"轮次 {
              epoch+1}/{
              self.epochs}, 本轮错误数: {
              num_errors}/{
              num_samples}") # 打印当前轮次和错误数
            
            if num_errors == 0 and epoch > 0: # 如果某一轮没有错误 (且不是第一轮,避免未开始就停止)
                print(f"在轮次 {
              epoch+1} 时,模型已收敛 (无错误)。停止训练。") # 打印收敛信息
                break # 提前停止训练

        print("感知机训练完成。") # 打印训练完成信息
        print(f"最终权重: {
              self.weights}") # 打印最终权重
        print(f"最终偏置: {
              self.bias}") # 打印最终偏置

# --- 使用感知机解决一个简单的线性可分问题:AND逻辑门 ---
# AND门真值表:
# x1 | x2 | AND
# ---|----|----
# 0  | 0  | 0
# 0  | 1  | 0
# 1  | 0  | 0
# 1  | 1  | 1

print("--- 测试感知机:实现AND逻辑门 ---") # 打印测试标题
# 训练数据
training_data_and = np.array([ # AND门的输入特征
    [0, 0],
    [0, 1],
    [1, 0],
    [1, 1]
])
labels_and = np.array([0, 0, 0, 1]) # AND门的对应标签

# 创建并训练感知机
perceptron_and = Perceptron(num_inputs=2, learning_rate=0.1, epochs=50) # 创建感知机实例,输入维度为2
perceptron_and.train(training_data_and, labels_and) # 训练感知机

# 测试训练好的感知机
print("
测试训练好的AND感知机:") # 打印测试信息
print(f"输入 [0, 0] -> 预测: {
              perceptron_and.predict(np.array([0, 0]))}, 期望: 0") # 测试输入[0,0]
print(f"输入 [0, 1] -> 预测: {
              perceptron_and.predict(np.array([0, 1]))}, 期望: 0") # 测试输入[0,1]
print(f"输入 [1, 0] -> 预测: {
              perceptron_and.predict(np.array([1, 0]))}, 期望: 0") # 测试输入[1,0]
print(f"输入 [1, 1] -> 预测: {
              perceptron_and.predict(np.array([1, 1]))}, 期望: 1") # 测试输入[1,1]


# --- 尝试用感知机解决XOR问题 (预期会失败或效果不佳) ---
print("
--- 测试感知机:尝试实现XOR逻辑门 (预期失败) ---") # 打印XOR测试标题
# XOR门真值表:
# x1 | x2 | XOR
# ---|----|----
# 0  | 0  | 0
# 0  | 1  | 1
# 1  | 0  | 1
# 1  | 1  | 0
training_data_xor = np.array([ # XOR门的输入特征
    [0, 0],
    [0, 1],
    [1, 0],
    [1, 1]
])
labels_xor = np.array([0, 1, 1, 0]) # XOR门的对应标签

# 创建并训练感知机
perceptron_xor = Perceptron(num_inputs=2, learning_rate=0.1, epochs=200) # 增加训练轮数,看是否能找到解
perceptron_xor.train(training_data_xor, labels_xor) # 训练

# 测试XOR感知机
print("
测试训练好的XOR感知机 (很可能不完美):") # 打印测试信息
correct_xor_predictions = 0 # 初始化正确预测计数
for i in range(len(training_data_xor)): # 遍历XOR测试数据
    inputs = training_data_xor[i] # 获取输入
    label = labels_xor[i] # 获取真实标签
    prediction = perceptron_xor.predict(inputs) # 进行预测
    print(f"输入 {
              inputs} -> 预测: {
              prediction}, 期望: {
              label}") # 打印预测结果
    if prediction == label: # 如果预测正确
        correct_xor_predictions +=1 # 正确计数加1
print(f"XOR问题上的准确率: {
              correct_xor_predictions / len(training_data_xor) * 100:.2f}%") # 打印准确率
# 你会发现,对于XOR问题,单个感知机通常无法达到100%的准确率,因为它不是线性可分的。
# 训练过程可能不会收敛到0错误,或者找到一个次优的线性边界。

这个简单的感知机代码清晰地展示了其初始化、预测和基于误差的权重更新过程。对于AND这样的线性可分问题,它可以很好地工作。但对于XOR这样的非线性问题,它的局限性就显现出来了。

为了克服单个感知机的线性限制,研究者们开始探索将多个感知机组合起来,形成了多层感知机 (Multi-Layer Perceptron, MLP)

2.2 多层感知机 (Multi-Layer Perceptron, MLP)

多层感知机通过引入一个或多个隐藏层 (Hidden Layers) 来克服单个感知机的线性局限性。每个隐藏层包含若干神经元,这些神经元对前一层的输出进行处理,并将结果传递给下一层。

MLP的结构:

输入层 (Input Layer):

接收原始的输入特征数据。
输入层的节点数量等于输入特征的维度。
通常输入层本身不进行计算,只是将数据传递给第一个隐藏层。

隐藏层 (Hidden Layer(s)):

位于输入层和输出层之间。可以有一个或多个隐藏层。
每个隐藏层的神经元都与前一层的所有神经元全连接(Dense Connection 或 Fully Connected Layer),也与下一层的所有神经元全连接。
隐藏层神经元的数量和隐藏层的层数是MLP模型的超参数,需要根据具体问题进行设计和调整。
关键点: 隐藏层中的神经元通常使用非线性激活函数 (如Sigmoid, Tanh, ReLU等)。正是这些非线性激活函数赋予了MLP学习非线性映射的能力。如果隐藏层使用线性激活函数,那么多层线性网络的组合仍然是一个线性网络,无法解决非线性问题。

输出层 (Output Layer):

产生模型的最终输出。
输出层神经元的数量和激活函数的选择取决于具体的任务类型:

二分类问题: 输出层通常有一个神经元,使用Sigmoid激活函数,输出一个0到1之间的概率值。
多类别分类问题 (单标签): 输出层神经元数量等于类别数,通常使用Softmax激活函数,输出每个类别的概率分布(所有概率之和为1)。
回归问题: 输出层通常有一个或多个神经元(取决于要预测的值的数量),使用线性激活函数(即没有激活函数,或者说恒等激活 f(z)=z),直接输出预测的连续值。
多标签分类问题: 输出层神经元数量等于类别数,每个神经元使用Sigmoid激活函数,独立地输出该标签存在的概率。

MLP如何解决非线性问题 (如XOR)?

以XOR问题为例,一个包含一个隐藏层的MLP可以解决它:

输入层: 2个神经元 (对应x1, x2)。
隐藏层: 例如,2个神经元,使用非线性激活函数(如Sigmoid或ReLU)。

可以想象,隐藏层的第一个神经元可能学习识别 x1 OR x2 (或者类似的功能,使得 (0,0) 与其他分开)。
隐藏层的第二个神经元可能学习识别 x1 NAND x2 (或者类似的功能,使得 (1,1) 与其他分开)。
这些隐藏单元将原始输入空间映射到一个新的特征空间,在这个新的特征空间中,问题可能变得线性可分。

输出层: 1个神经元,使用合适的激活函数(例如Sigmoid,然后根据阈值判断)。输出层的神经元再对隐藏层学习到的新特征进行线性组合,从而做出最终的分类。

MLP的“前向传播 (Forward Propagation)”过程:

数据从输入层开始,逐层向前传递,直到输出层。

假设一个MLP有L层 (包括输入层视为第0层,输出层为第L-1层)。
对于第 l 层的第 j 个神经元:

计算加权和 (Net Input):
z_j^(l) = Σ (w_ji^(l) * a_i^(l-1)) + b_j^(l)
其中:

a_i^(l-1) 是前一层 (第 l-1 层) 第 i 个神经元的激活输出。对于输入层,a_i^(0) = x_i (输入特征)。
w_ji^(l) 是从前一层第 i 个神经元到当前层第 j 个神经元的连接权重。
b_j^(l) 是当前层第 j 个神经元的偏置。

计算激活输出 (Activation Output):
a_j^(l) = f(z_j^(l))
其中 f 是该层神经元使用的激活函数。

这个过程从第一层隐藏层开始,一直计算到输出层,得到最终的预测结果。

MLP的训练:反向传播算法 (Backpropagation Algorithm)

MLP的参数(所有权重 w 和偏置 b)需要通过学习算法从训练数据中进行优化。最常用且核心的算法是反向传播算法

目标: 最小化一个预定义的损失函数 (Loss Function)代价函数 (Cost Function)。损失函数衡量模型预测输出与真实标签之间的差异。

核心思想:

前向传播: 将一个训练样本输入网络,计算每一层神经元的激活输出,直到得到最终的预测结果。
计算损失: 根据预测结果和真实标签,计算损失函数的值。
反向传播误差:

首先计算输出层神经元的误差项 (error term,通常表示为 δ_j^(L-1) )。这个误差项反映了该神经元的激活值对最终损失的贡献程度(通常与损失函数关于该神经元加权和的偏导数有关)。
然后,将误差从输出层逐层向前(反向)传播到每个隐藏层。对于第 l 层的神经元 j,其误差项 δ_j^(l) 可以根据下一层 (第 l+1 层) 所有神经元的误差项以及它们之间的连接权重来计算。
δ_j^(l) = ( Σ_k (w_kj^(l+1) * δ_k^(l+1)) ) * f'(z_j^(l))
(这里的 f'(z_j^(l)) 是激活函数 f 在点 z_j^(l) 处的导数。这就是为什么激活函数需要是可微的或至少是分段可微的。)

计算梯度: 利用计算得到的各层误差项 δ,可以计算出损失函数对于网络中每个权重 w_ji^(l) 和偏置 b_j^(l) 的偏导数(梯度):
∂L / ∂w_ji^(l) = a_i^(l-1) * δ_j^(l)
∂L / ∂b_j^(l) = δ_j^(l)
更新参数: 使用梯度下降 (Gradient Descent) 或其变体(如SGD, Adam, RMSprop等优化器)来更新网络中的所有权重和偏置,以减小损失:
w_new = w_old - η * (∂L / ∂w_old)
b_new = b_old - η * (∂L / ∂b_old)

迭代: 对训练集中的所有样本(或一批样本,即mini-batch)重复上述步骤,进行多轮 (epochs) 训练,直到损失函数收敛到足够小的值,或者达到预设的训练轮数。

反向传播算法是深度学习训练的核心,它使得我们能够有效地计算庞大网络中所有参数的梯度,并进行优化。我们将在后续章节更详细地讨论损失函数、激活函数、优化器以及反向传播的数学细节。

代码示例:使用Keras/TensorFlow构建和训练一个简单的MLP解决XOR问题

现代深度学习框架(如TensorFlow, Keras, PyTorch)极大地简化了MLP及其他复杂网络的构建和训练过程。我们不再需要手动实现反向传播的细节。

import numpy as np # 导入NumPy库
import tensorflow as tf # 导入TensorFlow库
from tensorflow import keras # 从TensorFlow中导入Keras API
from tensorflow.keras.models import Sequential # 导入Sequential模型类,用于构建序列化的网络层
from tensorflow.keras.layers import Dense # 导入Dense层类,即全连接层
from tensorflow.keras.optimizers import Adam # 导入Adam优化器

print(f"TensorFlow 版本: {
              tf.__version__}") # 打印TensorFlow版本
print(f"Keras 版本: {
              keras.__version__}") # 打印Keras版本

# --- 准备XOR问题的训练数据 ---
# 输入特征 (x1, x2)
training_data_xor_tf = np.array([
    [0, 0],
    [0, 1],
    [1, 0],
    [1, 1]
], dtype=np.float32) # 使用float32类型,深度学习常用

# 对应标签 (XOR结果)
labels_xor_tf = np.array([0, 1, 1, 0], dtype=np.float32) # 使用float32类型
# 对于二分类问题,Keras的Dense层配合sigmoid激活,标签通常是0或1的浮点数。
# 如果是多分类,标签通常是one-hot编码。

# --- 构建MLP模型 ---
# 使用Keras Sequential API,它允许我们像堆叠积木一样添加网络层
model_xor = Sequential([ # 创建一个Sequential模型实例
    # 第一个隐藏层
    # Dense(units, activation, input_shape)
    # units: 该层神经元的数量 (例如4个)
    # activation: 激活函数 (例如 'relu' 或 'sigmoid' 或 'tanh')
    # input_shape: 输入数据的形状 (仅在第一层需要指定,这里是2个输入特征,所以是 (2,))
    Dense(units=4, activation='relu', input_shape=(2,), name="hidden_layer_1"), # 添加第一个全连接层(隐藏层),4个神经元,ReLU激活,输入维度为2

    # (可选) 可以添加更多隐藏层
    # Dense(units=4, activation='relu', name="hidden_layer_2"),

    # 输出层
    # units=1: 因为是二分类问题,输出一个值
    # activation='sigmoid': Sigmoid激活函数将输出压缩到0和1之间,可以解释为概率
    Dense(units=1, activation='sigmoid', name="output_layer") # 添加输出层,1个神经元,Sigmoid激活
])

# 打印模型摘要,显示网络结构和参数数量
print("
--- XOR MLP模型结构 ---") # 打印模型结构标题
model_xor.summary() # 打印模型的详细摘要信息

# --- 编译模型 ---
# 在训练模型之前,需要对其进行编译,配置学习过程。
# optimizer: 优化算法,用于更新网络权重 (例如 'adam', 'sgd', 'rmsprop')
# loss: 损失函数,衡量模型预测与真实标签之间的差异。
#        对于二分类问题 (Sigmoid输出层),常用 'binary_crossentropy'。
# metrics: 评估指标列表,用于在训练和测试期间监控模型的性能 (例如 'accuracy')。
model_xor.compile(optimizer=Adam(learning_rate=0.1), # 使用Adam优化器,设置学习率
                  loss='binary_crossentropy', # 使用二元交叉熵作为损失函数
                  metrics=['accuracy']) # 监控准确率指标
print("
模型编译完成。") # 打印编译完成信息

# --- 训练模型 ---
# model.fit(x_train, y_train, epochs, batch_size, verbose)
# x_train: 训练输入数据
# y_train: 训练标签数据
# epochs: 训练轮数 (整个训练数据集被遍历的次数)
# batch_size: 批处理大小 (每次权重更新所使用的样本数量)。
#             如果未指定,默认为32。对于小数据集,可以设置为1或整个数据集大小。
# verbose: 日志显示模式 (0=安静, 1=进度条, 2=每轮一行)
print("
开始训练XOR MLP模型...") # 打印开始训练信息
history = model_xor.fit(training_data_xor_tf, labels_xor_tf, # 传入训练数据和标签
                        epochs=200, # 训练200轮
                        batch_size=1, # 每1个样本更新一次权重 (随机梯度下降的极端情况)
                        verbose=1) # 显示每轮的训练进度和指标

print("XOR MLP模型训练完成。") # 打印训练完成信息

# --- 评估模型 (可选,因为数据集很小,训练和测试相同) ---
loss, accuracy = model_xor.evaluate(training_data_xor_tf, labels_xor_tf, verbose=0) # 在训练数据上评估模型
print(f"
在训练数据上的最终损失: {
              loss:.4f}") # 打印最终损失
print(f"在训练数据上的最终准确率: {
              accuracy*100:.2f}%") # 打印最终准确率

# --- 使用训练好的模型进行预测 ---
print("
使用训练好的XOR MLP模型进行预测:") # 打印预测信息
predictions_tf = model_xor.predict(training_data_xor_tf) # 对训练数据进行预测
# predict()的输出是Sigmoid激活后的原始概率值 (0到1之间)

for i in range(len(training_data_xor_tf)): # 遍历每个样本
    input_sample = training_data_xor_tf[i] # 获取输入样本
    true_label = labels_xor_tf[i] # 获取真实标签
    predicted_prob = predictions_tf[i][0] # 获取预测的概率 (输出层只有一个神经元)
    # 将概率转换为类别 (例如,阈值为0.5)
    predicted_class = 1 if predicted_prob >= 0.5 else 0 # 如果概率大于等于0.5,则为类别1,否则为类别0
    
    print(f"输入 {
              input_sample} -> 预测概率: {
              predicted_prob:.4f} -> 预测类别: {
              predicted_class}, 期望类别: {
              int(true_label)}") # 打印详细预测结果

# 我们可以检查一下隐藏层的权重和偏置 (仅为演示)
print("
--- 模型权重和偏置 (示例) ---") # 打印权重信息标题
for layer in model_xor.layers: # 遍历模型的每一层
    layer_weights = layer.get_weights() # 获取该层的权重 (列表,通常第一个是权重矩阵,第二个是偏置向量)
    if layer_weights: # 如果权重列表不为空
        print(f"层名称: {
              layer.name}") # 打印层名称
        print(f"  权重矩阵形状: {
              layer_weights[0].shape}") # 打印权重矩阵形状
        # print(f"  权重矩阵:
{layer_weights[0]}") # (可选) 打印权重矩阵
        print(f"  偏置向量形状: {
              layer_weights[1].shape}") # 打印偏置向量形状
        # print(f"  偏置向量:
{layer_weights[1]}") # (可选) 打印偏置向量

这个Keras示例展示了构建、编译、训练和使用MLP是多么简洁。你可以尝试调整隐藏层神经元数量、激活函数、学习率、训练轮数等超参数,观察它们对解决XOR问题的效果。通常情况下,一个具有非线性激活函数的隐藏层的MLP可以很好地学习XOR函数。

MLP是更复杂的深度神经网络(如卷积神经网络CNN、循环神经网络RNN)的基础。理解其结构、前向传播和反向传播(即使是由框架自动处理)的基本原理至关重要。

第三章:激活函数——为神经网络注入非线性

激活函数是神经网络中的一个关键组件,它决定了神经元的输出是否被激活以及如何被激活。更重要的是,非线性激活函数是深度神经网络能够学习复杂非线性映射(从而解决非线性问题)的根本原因。

3.1 为什么需要激活函数?

想象一下,如果一个多层神经网络中所有的神经元都只进行加权求和,或者都使用线性激活函数(例如 f(z) = a*z + c,其中 ac 是常数,最简单的是 f(z) = z,即恒等激活),那么无论这个网络有多少层,它本质上仍然是一个线性模型。

证明:多层线性网络的等价性

假设我们有一个两层的网络(一个隐藏层,一个输出层),所有激活函数都是线性的 f(z) = z

隐藏层第j个神经元的输出:
a_j^(1) = z_j^(1) = Σ_i (w_ji^(1) * x_i) + b_j^(1)
用矩阵表示为: a^(1) = W^(1) * x + b^(1)

输出层第k个神经元的输出:
y_k = z_k^(2) = Σ_j (w_kj^(2) * a_j^(1)) + b_k^(2)
用矩阵表示为: y = W^(2) * a^(1) + b^(2)

a^(1) 代入 y 的表达式:
y = W^(2) * (W^(1) * x + b^(1)) + b^(2)
y = (W^(2) * W^(1)) * x + (W^(2) * b^(1) + b^(2))

W_new = W^(2) * W^(1) (一个新的权重矩阵)
b_new = W^(2) * b^(1) + b^(2) (一个新的偏置向量)

y = W_new * x + b_new

这表明,这个两层的线性网络等价于一个单层的线性网络(具有不同的权重和偏置)。无论你堆叠多少个纯线性层,最终的结果仍然是一个线性变换。这样的网络无法学习数据中复杂的非线性关系,其表示能力与单个感知机(没有非线性激活)或线性回归/逻辑回归模型相当。

因此,非线性激活函数的引入至关重要。它们使得神经网络能够:

学习非线性模式: 真实世界中的数据(如图像、语音、文本)通常包含高度复杂的非线性结构。非线性激活函数允许网络从这些数据中学习和逼近任意复杂的非线性函数。
增加模型表示能力: 通过在每一层引入非线性,网络可以将输入空间进行非线性扭曲和变换,从而在更高层次的特征空间中更容易地分离或表示数据。
构建深层网络: 如果没有非线性,深层网络的优势(如层次化特征学习)将无法发挥。

3.2 常用激活函数的特性与选择考量

选择合适的激活函数对于神经网络的训练速度、收敛性以及最终性能都有显著影响。理想的激活函数通常希望具备以下一些特性:

非线性 (Non-linearity): 这是最基本的要求,如上所述。
可微性 (Differentiability): 为了能够使用基于梯度的优化算法(如反向传播)来训练网络,激活函数需要在其定义域内几乎处处可微(或者至少是分段可微,并且在不可微点有次梯度)。
单调性 (Monotonicity): 单调的激活函数(即其导数符号保持不变)有时能保证损失函数的凸性(对于单层网络),但这在深层网络中不一定成立,也不是必需的。
输出范围 (Output Range):

有界输出: 一些激活函数(如Sigmoid, Tanh)的输出是有界的(例如0到1,或-1到1)。这有助于控制网络中激活值的范围,可能使训练更稳定,特别是在网络的较深层。
无界输出: 一些激活函数(如ReLU及其变体)的输出是无界的(例如0到正无穷)。这有时能提供更大的灵活性。

计算效率 (Computational Efficiency): 激活函数及其导数的计算应该尽可能快,因为它们在每次前向传播和反向传播中都会被大量调用。
梯度消失/爆炸问题 (Vanishing/Exploding Gradient Problem):

一些激活函数(尤其是Sigmoid和Tanh)在其饱和区域(即输入值非常大或非常小时)的导数非常接近于0。在深层网络中,这可能导致梯度信号在反向传播过程中逐层衰减,使得浅层网络的参数难以得到有效更新(梯度消失)。
选择能够缓解梯度消失问题的激活函数(如ReLU及其变体)对于训练深层网络非常重要。

零中心化 (Zero-centered Output): 如果激活函数的输出大致以0为中心(例如Tanh的输出在-1到1之间),这有时可以帮助梯度在反向传播时更有效地流动,并可能加速收敛。因为如果输入总是正的,那么权重更新的方向可能会受到限制。
稀疏激活 (Sparse Activation – 针对ReLU类): 某些激活函数(如ReLU)会导致一部分神经元的输出为0,这可以引入网络的稀疏性,可能有助于特征选择和减少计算量,但也可能导致“神经元死亡”问题。

3.3 常见的激活函数及其详解
3.3.1 Sigmoid (Logistic) 函数

公式: σ(z) = 1 / (1 + e^(-z))

形状: S形曲线。

输出范围: (0, 1)

导数: σ'(z) = σ(z) * (1 - σ(z))

图形:

(Mermaid无法直接绘制函数曲线,这里用文字描述)

z -> -∞, σ(z) -> 0
z -> +∞, σ(z) -> 1
z = 0, σ(z) = 0.5
导数在 z=0 时最大 (为0.25),在 z 远离0时迅速减小到接近0。

历史与应用:

Sigmoid曾是神经网络中最流行的激活函数之一,尤其是在早期的MLP和循环神经网络(RNN)中。
它的输出范围 (0, 1) 非常适合解释为概率,因此常用于二分类问题的输出层神经元。

优点:

输出有界: 输出值在0和1之间,可以用作概率解释,并且有助于控制网络中信号的幅度。
平滑可微: 在整个定义域内都是平滑可微的。

缺点:

梯度消失 (Vanishing Gradient):

这是Sigmoid函数最主要的问题。当输入 z 非常大或非常小时(即神经元处于饱和状态),Sigmoid函数的导数 σ'(z) 会非常接近于0。
在深层网络中,如果许多神经元都处于饱和状态,梯度在反向传播时会逐层乘以这些接近0的导数,导致梯度信号迅速衰减,使得网络较浅层的权重几乎无法更新。这严重阻碍了深层网络的训练。

输出非零中心 (Output Not Zero-centered):

Sigmoid的输出恒为正 (0到1之间)。如果一个神经元的输入总是正的(因为前一层的激活输出总是正的),那么在反向传播时,该神经元权重的梯度 ∂L/∂w = δ * a_prev (其中 a_prev 是前一层的正激活输出) 的符号将完全由误差项 δ 的符号决定。
这意味着所有权重要么同时增加,要么同时减少(取决于 δ)。这种“之字形”更新路径可能会降低梯度下降的效率。

计算成本相对较高: 指数运算 e^(-z) 相对于ReLU等函数来说计算量稍大。

当前使用场景:

由于梯度消失问题,Sigmoid作为隐藏层激活函数已不常用,尤其是在非常深的网络中。
主要用于二分类问题的输出层,将输出转换为概率。
在某些特定结构的RNN变体(如LSTM的门控单元)中仍然可能使用,因为它需要0到1之间的门控信号。

代码示例:Sigmoid函数及其导数

import numpy as np # 导入NumPy库
import matplotlib.pyplot as plt # 导入Matplotlib绘图库

def sigmoid(z): # 定义Sigmoid函数
    """计算Sigmoid激活函数的值。"""
    return 1 / (1 + np.exp(-z)) # Sigmoid公式

def sigmoid_derivative(z): # 定义Sigmoid函数的导数
    """计算Sigmoid激活函数的导数。"""
    s = sigmoid(z) # 先计算Sigmoid(z)
    return s * (1 - s) # 导数公式: σ(z) * (1 - σ(z))

# 生成输入数据 z
z_values = np.linspace(-10, 10, 200) # 在-10到10之间生成200个等间距点

# 计算Sigmoid值和导数值
sigmoid_values = sigmoid(z_values) # 计算每个z对应的Sigmoid值
sigmoid_derivatives = sigmoid_derivative(z_values) # 计算每个z对应的Sigmoid导数值

# --- 绘图 ---
plt.figure(figsize=(10, 5)) # 创建一个10x5英寸的图形窗口

# 绘制Sigmoid函数
plt.subplot(1, 2, 1) # 创建第一个子图 (1行2列中的第1个)
plt.plot(z_values, sigmoid_values, label='σ(z) = 1 / (1 + e^-z)', color='blue') # 绘制Sigmoid曲线
plt.title('Sigmoid Activation Function') # 设置子图标题
plt.xlabel('Input (z)') # 设置x轴标签
plt.ylabel('Output σ(z)') # 设置y轴标签
plt.grid(True) # 显示网格
plt.axhline(0, color='black', linewidth=0.5) # 绘制x轴
plt.axvline(0, color='black', linewidth=0.5) # 绘制y轴
plt.legend() # 显示图例

# 绘制Sigmoid函数的导数
plt.subplot(1, 2, 2) # 创建第二个子图 (1行2列中的第2个)
plt.plot(z_values, sigmoid_derivatives, label="σ'(z) = σ(z)(1-σ(z))", color='red') # 绘制Sigmoid导数曲线
plt.title('Derivative of Sigmoid Function') # 设置子图标题
plt.xlabel('Input (z)') # 设置x轴标签
plt.ylabel("Output σ'(z)") # 设置y轴标签
plt.grid(True) # 显示网格
plt.axhline(0, color='black', linewidth=0.5) # 绘制x轴
plt.axvline(0, color='black', linewidth=0.5) # 绘制y轴
plt.legend() # 显示图例

plt.tight_layout() # 自动调整子图布局,防止重叠
plt.show() # 显示图形

print(f"Sigmoid(0) = {
              sigmoid(0)}") # 打印Sigmoid(0)的值
print(f"Sigmoid导数在 z=0 时: {
              sigmoid_derivative(0)}") # 打印Sigmoid导数在z=0的值
print(f"Sigmoid导数在 z=5 时: {
              sigmoid_derivative(5)}") # 打印Sigmoid导数在z=5的值 (接近0)
print(f"Sigmoid导数在 z=-5 时: {
              sigmoid_derivative(-5)}") # 打印Sigmoid导数在z=-5的值 (接近0)

运行此代码,你会看到Sigmoid函数的S形曲线和其导数的钟形曲线(在z=0处最大,两端迅速趋于0)。

3.3.2 Tanh (双曲正切) 函数

公式: tanh(z) = (e^z - e^(-z)) / (e^z + e^(-z))
也可以表示为: tanh(z) = 2 * σ(2z) - 1 (是Sigmoid函数的一个缩放和平移版本)

形状: S形曲线,与Sigmoid类似,但关于原点对称。

输出范围: (-1, 1)

导数: tanh'(z) = 1 - tanh^2(z)

图形:
(Mermaid无法直接绘制)

z -> -∞, tanh(z) -> -1
z -> +∞, tanh(z) -> 1
z = 0, tanh(z) = 0
导数在 z=0 时最大 (为1),在 z 远离0时也迅速减小到接近0。

历史与应用:

Tanh也曾是隐藏层常用的激活函数。

优点:

输出零中心 (Zero-centered Output):

Tanh的输出范围是 (-1, 1),均值为0。这被认为比Sigmoid的非零中心输出更好,因为它使得下一层神经元的输入更可能具有正负值,有助于避免Sigmoid中提到的梯度更新方向受限问题,可能加速收敛。

输出有界: 与Sigmoid类似,输出有界。
平滑可微: 在整个定义域内平滑可微。

缺点:

梯度消失 (Vanishing Gradient):

Tanh同样存在梯度消失问题。当输入 z 的绝对值较大时,Tanh函数也处于饱和状态,其导数接近于0。虽然其导数范围 (0到1) 比Sigmoid的导数范围 (0到0.25) 更大一些,但梯度消失问题依然显著。

计算成本相对较高: 同样涉及指数运算。

当前使用场景:

由于梯度消失问题,Tanh作为隐藏层激活函数在非常深的网络中也已不常用,但其性能通常略好于Sigmoid(因为零中心输出)。
在某些RNN结构(如LSTM, GRU的一些变体)中,Tanh有时仍被用作状态或输出的激活。
如果网络的层数不太多,或者对输出范围有特定要求(例如希望特征值在-1到1之间),Tanh有时仍可考虑。

代码示例:Tanh函数及其导数

import numpy as np # 导入NumPy库
import matplotlib.pyplot as plt # 导入Matplotlib绘图库

def tanh(z): # 定义Tanh函数
    """计算Tanh激活函数的值。"""
    return np.tanh(z) # NumPy内置了tanh函数

def tanh_derivative(z): # 定义Tanh函数的导数
    """计算Tanh激活函数的导数。"""
    return 1 - np.tanh(z)**2 # 导数公式: 1 - tanh^2(z)

# 生成输入数据 z
z_values_tanh = np.linspace(-7, 7, 200) # 在-7到7之间生成200个点 (Tanh饱和更快)

# 计算Tanh值和导数值
tanh_values = tanh(z_values_tanh) # 计算Tanh值
tanh_derivatives = tanh_derivative(z_values_tanh) # 计算Tanh导数值

# --- 绘图 ---
plt.figure(figsize=(10, 5)) # 创建图形窗口

# 绘制Tanh函数
plt.subplot(1, 2, 1) # 创建第一个子图
plt.plot(z_values_tanh, tanh_values, label='tanh(z)', color='green') # 绘制Tanh曲线
plt.title('Tanh Activation Function') # 设置标题
plt.xlabel('Input (z)') # 设置x轴标签
plt.ylabel('Output tanh(z)') # 设置y轴标签
plt.grid(True) # 显示网格
plt.axhline(0, color='black', linewidth=0.5) # 绘制x轴
plt.axvline(0, color='black', linewidth=0.5) # 绘制y轴
plt.legend() # 显示图例

# 绘制Tanh函数的导数
plt.subplot(1, 2, 2) # 创建第二个子图
plt.plot(z_values_tanh, tanh_derivatives, label="tanh'(z) = 1 - tanh^2(z)", color='purple') # 绘制Tanh导数曲线
plt.title('Derivative of Tanh Function') # 设置标题
plt.xlabel('Input (z)') # 设置x轴标签
plt.ylabel("Output tanh'(z)") # 设置y轴标签
plt.grid(True) # 显示网格
plt.axhline(0, color='black', linewidth=0.5) # 绘制x轴
plt.axvline(0, color='black', linewidth=0.5) # 绘制y轴
plt.legend() # 显示图例

plt.tight_layout() # 自动调整布局
plt.show() # 显示图形

print(f"Tanh(0) = {
              tanh(0)}") # 打印Tanh(0)的值
print(f"Tanh导数在 z=0 时: {
              tanh_derivative(0)}") # 打印Tanh导数在z=0的值
print(f"Tanh导数在 z=3 时: {
              tanh_derivative(3)}") # 打印Tanh导数在z=3的值 (已较小)
print(f"Tanh导数在 z=-3 时: {
              tanh_derivative(-3)}") # 打印Tanh导数在z=-3的值 (已较小)

运行此代码,你会看到Tanh函数的S形曲线(关于原点对称)和其导数的钟形曲线(在z=0处为1,两端趋于0)。

3.3.3 ReLU (Rectified Linear Unit) – 修正线性单元

ReLU是目前深度学习(尤其是卷积神经网络CNN)中最常用也是最重要的激活函数之一。它的出现极大地缓解了梯度消失问题,并加速了深层网络的训练。

公式: ReLU(z) = max(0, z)
也可以写为:
ReLU(z) = z if z > 0
ReLU(z) = 0 if z <= 0

形状: 斜坡函数,在负半轴为0,在正半轴为线性。

输出范围: [0, +∞) (非负)

导数:
ReLU'(z) = 1 if z > 0
ReLU'(z) = 0 if z < 0
z = 0 处,ReLU函数在数学上是不可微的。但在实践中,通常将其在该点的次梯度 (subgradient) 设为0或1(例如,在TensorFlow/PyTorch中通常实现为当 z=0 时导数为0)。这在实际训练中通常不会造成问题。

图形:
(Mermaid无法直接绘制)

对于所有 z <= 0,输出为 0
对于所有 z > 0,输出为 z (一条斜率为1的直线)。
导数在 z < 0 时为 0,在 z > 0 时为 1

历史与应用:

虽然ReLU的概念很早就存在,但它在2010年代初由于在深度学习中的成功应用(例如AlexNet)而变得非常流行。
目前是大多数深度神经网络隐藏层的默认和首选激活函数。

优点:

有效缓解梯度消失问题 (Alleviates Vanishing Gradient):

当输入 z > 0 时,ReLU的导数恒为1。这意味着在反向传播过程中,只要神经元的输入是正的,梯度就可以无衰减地向前传播。这使得训练非常深的网络成为可能。

计算效率高 (Computationally Efficient):

ReLU的计算非常简单(只是一个 max(0, z) 操作),远快于Sigmoid和Tanh中的指数运算。这可以显著加速网络的训练和推理。

引入稀疏性 (Induces Sparsity):

当输入 z <= 0 时,ReLU的输出为0。这意味着网络中的一部分神经元会被“关闭”(激活值为0),从而使得网络的激活具有稀疏性。
稀疏激活被认为有一些好处:

特征选择: 只有一部分特征被激活,可能有助于学习更有判别力的特征。
信息解耦 (Information Disentanglement): 不同的神经元可能对不同的输入模式做出响应。
减少计算量: 激活值为0的神经元在后续计算中贡献为0。
可能有助于防止过拟合 (类似Dropout的效果,但机制不同)。

缺点:

输出非零中心 (Output Not Zero-centered):

与Sigmoid类似,ReLU的输出总是非负的 (>=0)。这可能导致与Sigmoid类似的梯度更新效率问题(尽管ReLU的梯度消失缓解效果通常更重要)。

Dying ReLU (死亡ReLU) 问题:

如果在训练过程中,一个ReLU神经元的输入 z 总是负的(例如,由于一个过大的负偏置,或者在学习过程中权重被更新得不恰当),那么这个神经元的输出将恒为0,其梯度也将恒为0。
这意味着这个神经元将不再对任何输入数据产生响应,也无法通过梯度下降进行学习和更新。它就“死掉”了。
如果网络中有大量神经元死亡,模型的表示能力会受到很大影响。
原因:

学习率设置过高。
权重初始化不当。
大的负偏置。

缓解方法:

使用ReLU的变体,如Leaky ReLU, PReLU, ELU (稍后介绍)。
选择合适的学习率。
良好的权重初始化。
使用Adam等自适应学习率优化器。

当前使用场景:

绝大多数现代深度神经网络(尤其是CNN)的隐藏层中广泛使用。
通常不用于输出层(除非输出本身是非负的且无上界,例如预测物体数量,但即使这样也可能有更合适的选择)。

代码示例:ReLU函数及其导数

import numpy as np # 导入NumPy库
import matplotlib.pyplot as plt # 导入Matplotlib绘图库

def relu(z): # 定义ReLU函数
    """计算ReLU激活函数的值。"""
    return np.maximum(0, z) # ReLU公式: max(0, z)

def relu_derivative(z): # 定义ReLU函数的导数 (在z=0处设为0)
    """计算ReLU激活函数的导数。在z=0处导数设为0。"""
    return np.where(z > 0, 1, 0) # 如果z>0,导数为1,否则为0

# 生成输入数据 z
z_values_relu = np.linspace(-5, 5, 200) # 在-5到5之间生成200个点

# 计算ReLU值和导数值
relu_values = relu(z_values_relu) # 计算ReLU值
relu_derivatives = relu_derivative(z_values_relu) # 计算ReLU导数值

# --- 绘图 ---
plt.figure(figsize=(10, 5)) # 创建图形窗口

# 绘制ReLU函数
plt.subplot(1, 2, 1) # 创建第一个子图
plt.plot(z_values_relu, relu_values, label='ReLU(z) = max(0, z)', color='orange') # 绘制ReLU曲线
plt.title('ReLU Activation Function') # 设置标题
plt.xlabel('Input (z)') # 设置x轴标签
plt.ylabel('Output ReLU(z)') # 设置y轴标签
plt.grid(True) # 显示网格
plt.axhline(0, color='black', linewidth=0.5) # 绘制x轴
plt.axvline(0, color='black', linewidth=0.5) # 绘制y轴
plt.legend() # 显示图例

# 绘制ReLU函数的导数
plt.subplot(1, 2, 2) # 创建第二个子图
plt.plot(z_values_relu, relu_derivatives, label="ReLU'(z) (1 if z>0 else 0)", color='brown') # 绘制ReLU导数曲线
plt.title('Derivative of ReLU Function') # 设置标题
plt.xlabel('Input (z)') # 设置x轴标签
plt.ylabel("Output ReLU'(z)") # 设置y轴标签
plt.grid(True) # 显示网格
plt.axhline(0, color='black', linewidth=0.5) # 绘制x轴
plt.axvline(0, color='black', linewidth=0.5) # 绘制y轴
# 调整y轴刻度以便更清晰地看到0和1
plt.yticks([0, 0.5, 1]) # 设置y轴刻度点
plt.legend() # 显示图例

plt.tight_layout() # 自动调整布局
plt.show() # 显示图形

print(f"ReLU(3) = {
              relu(3)}") # 打印ReLU(3)
print(f"ReLU(-2) = {
              relu(-2)}") # 打印ReLU(-2)
print(f"ReLU(0) = {
              relu(0)}") # 打印ReLU(0)
print(f"ReLU导数在 z=3 时: {
              relu_derivative(3)}") # 打印ReLU导数在z=3
print(f"ReLU导数在 z=-2 时: {
              relu_derivative(-2)}") # 打印ReLU导数在z=-2
print(f"ReLU导数在 z=0 时 (实现): {
              relu_derivative(0)}") # 打印ReLU导数在z=0

运行此代码,你会看到ReLU的斜坡形状和其阶跃式的导数。

3.3.4 Leaky ReLU (LReLU)

Leaky ReLU 是对标准ReLU的一个简单改进,旨在解决“死亡ReLU”问题。

公式:
LeakyReLU(z) = z if z > 0
LeakyReLU(z) = α * z if z <= 0
其中 α (alpha) 是一个小的正常数(泄漏系数),例如 0.010.1
也可以写为: LeakyReLU(z) = max(α*z, z) (当 α < 1)

形状: 与ReLU类似,但在负半轴不再是完全平坦的0,而是有一条小的负斜率 α

输出范围: (-∞, +∞)

导数:
LeakyReLU'(z) = 1 if z > 0
LeakyReLU'(z) = α if z < 0
z = 0 处,导数同样可以在 α1 之间选择。

优点 (相对于ReLU):

解决/缓解死亡ReLU问题: 由于在负输入区域仍然有一个小的非零梯度 (α),即使神经元的输入持续为负,它仍然可以进行学习和更新,不容易“死亡”。
保留了ReLU在正区间的优点(如快速计算、缓解梯度消失)。

缺点:

α 的选择: α 通常作为一个超参数需要手动设置或通过实验调整。其最优值可能因问题而异。
性能提升不总是一致: 虽然Leaky ReLU在理论上解决了死亡ReLU问题,但在实践中,它相对于标准ReLU的性能提升并不总能得到保证,有时效果相似,有时稍好。

当前使用场景:

当怀疑网络中存在较多死亡ReLU单元时,可以尝试使用Leaky ReLU作为替代。
在一些生成对抗网络 (GANs) 中有时也会使用。

3.3.5 PReLU (Parametric ReLU)

PReLU 是 Leaky ReLU 的一个扩展,它将泄漏系数 α 作为一个可学习的参数,由网络在训练过程中自动学习,而不是手动设置。

公式: 与Leaky ReLU相同,但 α_i (每个神经元 i 可以有自己的 α) 是通过反向传播学习得到的。
PReLU(z_i) = z_i if z_i > 0
PReLU(z_i) = α_i * z_i if z_i <= 0

优点:

自适应学习泄漏系数: 免去了手动调整 α 的麻烦,理论上网络可以学到更适合当前任务的 α 值。
保留了Leaky ReLU解决死亡ReLU的优点。

缺点:

增加了模型参数: 每个神经元(或每个通道,取决于实现)都增加了一个可学习的 α 参数,略微增加了模型的复杂度和过拟合的风险(尽管 α 的数量通常远少于权重)。
训练可能更复杂: 需要同时学习权重和 α

当前使用场景:

在一些需要更高灵活性的场景中,如果标准ReLU或Leaky ReLU效果不佳,可以尝试PReLU。Kaiming He等人在其论文中表明PReLU在某些图像识别任务上优于ReLU。

3.3.6 ELU (Exponential Linear Unit)

ELU 也是ReLU的一个变体,旨在结合ReLU的优点并解决其一些问题(如死亡ReLU和非零中心输出)。

公式:
ELU(z) = z if z > 0
ELU(z) = α * (e^z - 1) if z <= 0
其中 α 是一个正的超参数,通常设为1。

形状:

在正区间,与ReLU相同(线性)。
在负区间,函数平滑地过渡到一个负的饱和值

输出范围: (-α, +∞)

导数:
ELU'(z) = 1 if z > 0
ELU'(z) = ELU(z) + α (即 α * e^z) if z < 0
z=0 处,如果 α=1,则左导数和右导数都为1,所以是平滑的。

优点:

缓解死亡ReLU问题: 在负区间有非零梯度。
输出均值更接近0 (Closer to Zero Mean Output):

由于在负区间可以取负值,ELU的输出激活值的均值比ReLU更接近于0,这被认为有助于加速学习(类似于Tanh的零中心特性)。

对噪声具有一定的鲁棒性: 负值区域的饱和特性(趋向于-α)可能使得神经元对输入中的某些噪声不那么敏感。
z=0 附近比ReLU更平滑 (如果 α=1)。

缺点:

计算成本较高: 在负区间涉及指数运算 e^z,比ReLU的计算量大。
α 的选择: 虽然通常设为1,但它仍然是一个可以调整的超参数。

当前使用场景:

ELU在某些情况下被报道比ReLU及其其他变体表现更好,尤其是在需要更快收敛或对噪声鲁棒性有要求的场景。但由于计算成本较高,其普及程度不如ReLU。

3.3.7 SELU (Scaled Exponential Linear Unit)

SELU 是一种特殊的ELU变体,它与特定的权重初始化方法(LeCun Normal Initialization)和一种特殊的Dropout变体(AlphaDropout)结合使用时,具有自归一化 (self-normalizing) 的特性。这意味着,如果输入是归一化的(例如均值为0,方差为1),那么通过SELU激活层的输出也会趋向于保持归一化。

公式: SELU(z) = λ * ELU(z, α_elu)
SELU(z) = λ * z if z > 0
SELU(z) = λ * α_elu * (e^z - 1) if z <= 0
其中 λ (lambda) 和 α_elu 是预先计算好的常数,以确保自归一化属性。
对于标准正态分布的输入,这些值近似为:
λ ≈ 1.0507
α_elu ≈ 1.67326

优点:

自归一化: 如果网络层都使用SELU并且权重以特定方式初始化,网络可以自动将激活值推向零均值和单位方差。这可以防止梯度消失/爆炸,并可能使得训练非常深的网络不再需要批归一化 (Batch Normalization)。
缓解死亡神经元问题。

缺点:

对特定条件有要求: SELU的自归一化特性依赖于特定的权重初始化(LeCun Normal)、网络结构(主要是全连接层,对于CNN的适用性仍在研究)以及可能的AlphaDropout。如果这些条件不满足,其优势可能无法体现。
计算成本比ReLU高。
在实践中,其普适性和相对于ReLU+BN的优势仍在讨论中。

当前使用场景:

主要用于全连接的深度神经网络。在需要构建非常深且不使用批归一化的网络时可以尝试。

3.3.8 Swish (Self-Gated Activation Function) / SiLU (Sigmoid Linear Unit)

Swish 是Google研究者发现的一种激活函数,通过自动搜索得到,其性能在许多任务上优于ReLU。

公式: Swish(z) = z * σ(βz)
其中 σ 是Sigmoid函数,β 是一个常数或可学习的参数。
β=1 时,通常直接称为 Swish 或 SiLU。
SiLU(z) = z * σ(z) = z / (1 + e^(-z))

形状:

非单调。当 z 较小时,函数值可能为负。
z -> -∞ 时,Swish(z) -> 0
z -> +∞ 时,Swish(z) -> z (类似于ReLU)。
在原点附近是平滑的。

输出范围: (约-0.28, +∞) (对于 β=1)

导数: 相对复杂,但可计算。

优点:

性能优越: 在许多深度学习模型和任务上,Swish的表现通常略好于或等于ReLU,有时甚至更好。
平滑性: 在所有点上都是平滑的,不像ReLU在0点不可微。
非单调性: 这种特性被认为可能有助于其更好的性能,允许更复杂的函数逼近。
结合了线性和饱和特性。

缺点:

计算成本比ReLU高: 涉及Sigmoid函数和乘法。
可解释性: 其良好性能背后的确切原因仍在研究中。

当前使用场景:

由于其良好的经验性能,Swish/SiLU在一些现代网络架构中(如EfficientNets, YOLOv7等)被用作ReLU的替代品。

3.3.9 GELU (Gaussian Error Linear Unit)

GELU 是另一种表现优于ReLU的激活函数,常见于Transformer等先进模型中。它通过高斯累积分布函数 Φ(z) 来对输入进行随机正则化。

公式: GELU(z) = z * Φ(z)
其中 Φ(z) = P(X <= z)X ~ N(0,1) (标准正态分布的累积分布函数 CDF)。
由于高斯CDF没有简单的解析形式,实践中常用一个近似:
GELU(z) ≈ 0.5 * z * (1 + tanh(sqrt(2/π) * (z + 0.044715 * z^3)))
或者更简单的Sigmoid近似:
GELU(z) ≈ z * σ(1.702 * z)

形状: 与Swish类似,也是平滑且非单调的。

优点:

性能优越: 在自然语言处理的Transformer模型和一些计算机视觉模型中表现出色。
结合了随机正则化的思想(神经元的输出根据其大小被随机“丢弃”的概率)。
平滑。

缺点:

计算成本较高: 即使使用近似,也比ReLU复杂。

当前使用场景:

广泛应用于基于Transformer的NLP模型 (如BERT, GPT系列) 和视觉Transformer (ViT) 等。

3.3.10 Softmax 函数 (主要用于输出层)

Softmax函数与其他激活函数不同,它通常只用于多类别分类问题的输出层。它将一个包含任意实数值的向量(例如,网络倒数第二层对每个类别的“得分”或“logit”)转换为一个概率分布。

公式: 对于一个K维向量 z = [z_1, z_2, ..., z_K],Softmax的第 j 个输出为:
Softmax(z)_j = e^(z_j) / Σ_{i=1 to K} (e^(z_i))

特性:

输出概率分布:

每个输出值 Softmax(z)_j 都在 (0, 1) 范围内。
所有输出值之和为1: Σ_j Softmax(z)_j = 1
因此,Softmax(z)_j 可以被解释为输入属于类别 j 的概率。

对输入敏感: 指数函数会放大输入值之间的差异。较大的 z_j 会得到相对更大的概率。
不是逐元素计算: Softmax的计算依赖于向量中的所有元素(因为分母是所有指数项的和)。

应用:

专门用于多类别分类问题的输出层,其中每个样本只属于一个类别(互斥类别)。
例如,图像分类(猫、狗、鸟),数字识别(0-9)。

数值稳定性:

直接计算 e^(z_j) 时,如果 z_j 很大,可能会导致数值上溢。
常用的技巧是,在计算指数之前,从 z 向量的所有元素中减去 max(z)
Softmax(z)_j = e^(z_j - max(z)) / Σ_i (e^(z_i - max(z)))
这在数学上是等价的,但可以避免上溢,因为 z_j - max(z) 的最大值为0,其指数为1。

代码示例:Softmax函数

import numpy as np # 导入NumPy库

def softmax(z, axis=-1): # 定义Softmax函数
    """计算Softmax激活函数的值,并处理数值稳定性。"""
    # z 可以是一个一维向量,或者是一个包含多个样本的二维数组 (batch)
    # axis=-1 表示在最后一个维度上进行softmax (例如,对于一个batch的logits,在每个样本的类别得分上)
    
    # 数值稳定性技巧:减去最大值
    z_max = np.max(z, axis=axis, keepdims=True) # 找出每个样本logit中的最大值
    exp_z_shifted = np.exp(z - z_max) # 计算 e^(z_i - max(z))
    
    sum_exp_z_shifted = np.sum(exp_z_shifted, axis=axis, keepdims=True) # 计算 Σ e^(z_i - max(z))
    
    return exp_z_shifted / sum_exp_z_shifted # 返回Softmax概率

# 示例:单个样本的logit向量
logits_single = np.array([2.0, 1.0, 0.1]) # 假设这是网络对三个类别的原始输出得分
probabilities_single = softmax(logits_single) # 计算Softmax概率
print(f"输入Logits (单个样本): {
              logits_single}") # 打印输入logits
print(f"Softmax概率 (单个样本): {
              probabilities_single}") # 打印Softmax概率
print(f"概率之和: {
              np.sum(probabilities_single):.4f}") # 打印概率之和 (应为1)

# 示例:一批样本的logit矩阵 (2个样本,每个样本3个类别)
logits_batch = np.array([
    [1.0, 2.0, 3.0],  # 第一个样本的logits
    [2.5, 0.5, 1.5]   # 第二个样本的logits
])
probabilities_batch = softmax(logits_batch, axis=1) # axis=1表示在每个样本内部(行)计算Softmax
print(f"
输入Logits (一批样本):
{
              logits_batch}") # 打印批量输入logits
print(f"Softmax概率 (一批样本):
{
              probabilities_batch}") # 打印批量Softmax概率
print(f"每行概率之和:
{
              np.sum(probabilities_batch, axis=1)}") # 打印每行概率之和 (应都为1)

# 演示数值稳定性问题 (如果直接用exp,可能会上溢)
large_logits = np.array([1000, 1010, 990]) # 非常大的logit值
# prob_naive = np.exp(large_logits) / np.sum(np.exp(large_logits)) # 直接计算可能导致OverflowError或NaN
# print(f"
大Logits (朴素计算,可能出错):
{prob_naive}") # 这行可能会报错

prob_stable = softmax(large_logits) # 使用稳定版Softmax
print(f"
大Logits (稳定计算):
{
              prob_stable}") # 打印稳定计算结果
print(f"概率之和 (稳定计算): {
              np.sum(prob_stable):.4f}") # 打印概率之和
3.4 如何选择激活函数?—— 一般准则与实践建议

选择激活函数没有一劳永逸的“最佳”答案,它通常取决于具体的任务、网络架构、数据集以及经验。但有一些通用的指导原则:

隐藏层激活函数的选择:

首选ReLU: 对于大多数隐藏层,ReLU通常是最好的起点。它计算简单,能有效缓解梯度消失,加速训练。
如果遇到死亡ReLU问题:

尝试 Leaky ReLUPReLU
也可以尝试 ELUSELU (SELU对特定条件有要求)。

如果计算资源不是瓶颈,且追求极致性能:

可以尝试 Swish (SiLU)GELU,它们在许多现代架构中表现优异,但计算成本略高。

避免在深层网络中使用Sigmoid和Tanh作为隐藏层激活: 它们很容易导致梯度消失。只有在特定结构(如RNN的门控)或浅层网络中可以考虑。

输出层激活函数的选择:

二分类问题:

输出层一个神经元,使用 Sigmoid 激活函数(输出0到1之间的概率)。
损失函数通常选择二元交叉熵 (binary_crossentropy)。

多类别分类问题 (单标签,即每个样本只属于一个类别):

输出层神经元数量等于类别数,使用 Softmax 激活函数(输出所有类别的概率分布,和为1)。
损失函数通常选择分类交叉熵 (categorical_crossentropy,如果标签是one-hot编码) 或稀疏分类交叉熵 (sparse_categorical_crossentropy,如果标签是整数索引)。

多标签分类问题 (每个样本可以属于多个类别):

输出层神经元数量等于类别数,每个神经元独立使用 Sigmoid 激活函数(每个输出表示该标签存在的概率)。
损失函数通常是每个输出的二元交叉熵之和。

回归问题 (预测连续值):

输出层神经元数量等于要预测的连续值的数量。
通常不使用激活函数 (或者说使用线性/恒等激活 f(z)=z)。
如果预测的值有特定范围(例如,必须是非负的),可以在输出层之后添加一个能强制该范围的函数(例如,如果输出必须为正,可以在非常数项上用ReLU或softplus,但这更像是后处理或网络结构设计的一部分,而不是典型的输出层激活)。
损失函数通常选择均方误差 (MSE)、平均绝对误差 (MAE) 等。

实验与验证:

最终,最好的激活函数组合往往需要通过实验来确定。在验证集上比较不同激活函数配置下的模型性能。
考虑模型的训练速度、收敛情况以及最终的准确率/指标。

权重初始化:

选择激活函数时,也要考虑与之匹配的权重初始化方法。例如,对于ReLU及其变体,He初始化 (Kaiming初始化) 通常比Xavier/Glorot初始化效果更好。

批归一化 (Batch Normalization) 的影响:

批归一化层本身可以帮助稳定激活值的分布,缓解梯度消失/爆炸问题,并降低模型对权重初始化的敏感性。
当使用批归一化时,激活函数的选择可能变得不那么关键,但ReLU及其变体仍然是常见的选择。批归一化通常放在线性变换(全连接层或卷积层)之后、激活函数之前。

一个典型的现代CNN/MLP中激活函数的安排可能是:

所有隐藏的卷积层和全连接层:使用 ReLU (或其改进版如Leaky ReLU, Swish)。
输出层:根据具体任务选择 (Sigmoid, Softmax, 或线性)。

第四章:损失函数——衡量模型表现的标尺

损失函数在机器学习和深度学习中扮演着至关重要的角色。它是一个将模型的预测输出和真实的标签(期望输出)作为输入,并输出一个标量值的函数,这个标量值表示了模型在当前预测上的“错误程度”或“损失大小”。

4.1 为什么需要损失函数?损失函数在学习中的作用

量化模型性能:

损失函数提供了一个定量的指标来衡量模型在训练数据(或验证/测试数据)上的表现。损失值越小,通常表示模型的预测越接近真实值,性能越好。

指导参数优化 (核心作用):

深度学习模型的训练过程本质上是一个优化问题。我们的目标是找到一组网络参数(权重和偏置),使得损失函数的值在整个训练集上达到最小(或足够小)。
梯度下降 (Gradient Descent) 及其变体是解决这个优化问题的主要方法。这些方法依赖于计算损失函数关于网络参数的梯度 (Gradient)
梯度指明了参数调整的方向,沿着梯度的反方向更新参数可以使损失函数值减小。
参数_new = 参数_old - 学习率 * (∂Loss / ∂参数_old)
因此,损失函数必须是可微的(或至少是次可微的),这样才能通过反向传播算法计算出有效的梯度。

定义学习目标:

选择不同的损失函数实际上是在定义模型学习的不同目标。

例如,在回归问题中,使用均方误差 (MSE) 损失函数意味着我们希望模型预测值与真实值之间的平方差尽可能小,这会惩罚大的误差。
在分类问题中,使用交叉熵损失函数意味着我们希望模型预测的类别概率分布与真实的类别概率分布尽可能接近。

影响模型行为:

损失函数的选择会直接影响模型的学习方式和最终学到的特征。例如,某些损失函数可能对异常值更敏感,而另一些则更鲁棒。

损失函数 (Loss Function) vs. 代价函数 (Cost Function) vs. 目标函数 (Objective Function):

这些术语在文献中经常互换使用,但有时也有细微差别:

损失函数 (Loss Function): 通常指用于单个训练样本的误差计算。例如,对于一个输入 x_i,其预测为 y_pred_i,真实标签为 y_true_i,则 L(y_pred_i, y_true_i) 是该样本的损失。
代价函数 (Cost Function) / 目标函数 (Objective Function): 通常指用于**整个训练数据集(或一个mini-batch)**的平均损失或总损失。
J(θ) = (1/N) * Σ_{i=1 to N} L(y_pred_i(θ), y_true_i)
其中 N 是样本数量,θ 代表模型的参数。在优化过程中,我们实际上是在最小化这个代价函数 J(θ)
在实践中,人们经常不严格区分这些术语,通常说的“损失函数”可能既指单个样本的损失,也指整个数据集的平均损失。关键是理解其核心作用是量化误差并指导优化。

4.2 选择损失函数的考量因素

选择合适的损失函数对于模型的成功训练至关重要。主要考虑因素包括:

任务类型 (Problem Type): 这是最重要的因素。

回归问题 (Regression): 预测连续值(如房价、温度)。
分类问题 (Classification): 预测离散类别标签(如猫/狗,数字0-9)。

二分类 (Binary Classification): 两个类别。
多类别分类 (Multi-class Classification): 多个互斥类别。
多标签分类 (Multi-label Classification): 每个样本可以属于多个类别。

其他任务: 如目标检测、图像分割、生成模型等,它们有自己特定的损失函数设计。

模型输出层的激活函数 (Output Layer Activation):

损失函数的选择通常与输出层激活函数的特性相匹配,以确保数值稳定性和梯度的良好行为。
例如:

输出层使用Sigmoid激活(输出概率0-1)的二分类问题,通常配合二元交叉熵损失。
输出层使用Softmax激活(输出类别概率分布)的多类别分类问题,通常配合分类交叉熵损失。
输出层使用线性激活的回归问题,通常配合均方误差或平均绝对误差损失。

对异常值的敏感性 (Sensitivity to Outliers):

某些损失函数(如均方误差)对异常值(即与真实值偏差极大的预测)非常敏感,因为它们会平方误差,导致异常值产生巨大的损失和梯度。
另一些损失函数(如平均绝对误差、Huber损失)对异常值更鲁棒。

梯度的特性 (Gradient Properties):

损失函数产生的梯度是否平滑、是否有饱和区域等,会影响优化的稳定性和速度。

数学属性与优化难易度:

某些损失函数(如凸损失函数)在理论上更容易优化,但深度学习中的损失函数通常是非凸的。

业务需求与评估指标的对齐:

虽然损失函数用于训练,但最终评估模型性能的可能是其他业务指标(如准确率、召回率、F1分数、AUC等)。理想情况下,损失函数应该与这些最终评估指标有较好的相关性,或者直接优化这些指标(尽管很多评估指标本身不可微,不能直接用作损失函数,但有代理损失函数)。

4.3 常见的损失函数详解
4.3.1 回归问题中的损失函数 (Regression Losses)

回归任务的目标是预测一个或多个连续值。

4.3.1.1 均方误差 (Mean Squared Error, MSE) / L2损失

公式 (单个样本): L_MSE(y_true, y_pred) = (y_true - y_pred)^2

公式 (整个数据集/batch): J_MSE = (1/N) * Σ_{i=1 to N} (y_true_i - y_pred_i)^2
(有时也用 (1/2N) 作为系数,1/2 是为了求导时方便消去平方项带来的系数2,不影响最优点)

特性:

惩罚大误差: 由于误差被平方,MSE对较大的误差给予更大的惩罚。这意味着模型会更努力地去拟合那些偏差较大的点。
可微性: 处处可微,梯度计算简单 ∂L/∂y_pred = -2 * (y_true - y_pred) (单个样本)。
凸性: 对于线性回归模型,MSE是参数的凸函数,存在唯一的全局最小值。对于深度神经网络,整体损失函数通常非凸。
对异常值敏感: 一个具有巨大误差的异常点会显著影响总损失和梯度,可能导致模型被异常值“带偏”。

应用场景:

非常常用的回归损失函数。
当假设误差服从高斯分布时,最小化MSE等价于最大似然估计。
如果数据中异常值较少,或者你希望模型特别已关注避免大误差,MSE是一个不错的选择。

Keras/TensorFlow中的使用:

loss='mean_squared_error'loss='mse'
tf.keras.losses.MeanSquaredError()

4.3.1.2 平均绝对误差 (Mean Absolute Error, MAE) / L1损失

公式 (单个样本): L_MAE(y_true, y_pred) = |y_true - y_pred|

公式 (整个数据集/batch): J_MAE = (1/N) * Σ_{i=1 to N} |y_true_i - y_pred_i|

特性:

对所有误差的惩罚一致: MAE对所有大小的误差给予线性的惩罚。
对异常值更鲁棒: 由于误差没有被平方,单个异常值对总损失和梯度的影响比MSE小。
可微性: 在 y_true = y_pred (即误差为0) 的点处不可微。但在实践中,通常在该点将梯度设为0或使用次梯度,这在优化中通常不是大问题。
∂L/∂y_pred = -1 if y_pred < y_true
∂L/∂y_pred = 1 if y_pred > y_true
∂L/∂y_pred ∈ [-1, 1] (次梯度) if y_pred = y_true
梯度恒定: 对于非零误差,梯度的大小是恒定的(1或-1)。这可能导致在接近最优点时,优化过程在最小值附近振荡,因为梯度不会随着误差减小而减小。可能需要配合动态调整的学习率。

应用场景:

当数据中存在较多异常值,或者不希望模型被少数异常值主导时,MAE是比MSE更好的选择。
例如,在房价预测中,如果有一些非常昂贵或非常便宜的异常房源,使用MAE可能更稳健。

Keras/TensorFlow中的使用:

loss='mean_absolute_error'loss='mae'
tf.keras.losses.MeanAbsoluteError()

4.3.1.3 Huber损失 (Huber Loss) / 平滑L1损失 (Smooth L1 Loss)

Huber损失试图结合MSE和MAE的优点:在误差较小时表现得像MSE(平滑,梯度随误差减小),在误差较大时表现得像MAE(对异常值鲁棒)。

公式 (单个样本):
L_Huber(y_true, y_pred) = 0.5 * (y_true - y_pred)^2 if |y_true - y_pred| <= δ
L_Huber(y_true, y_pred) = δ * |y_true - y_pred| - 0.5 * δ^2 if |y_true - y_pred| > δ
其中 δ (delta) 是一个超参数,定义了从平方损失切换到绝对损失的阈值。

特性:

对小误差敏感 (类似MSE): 当误差小于 δ 时,使用平方项,梯度平滑。
对大误差鲁棒 (类似MAE): 当误差大于 δ 时,使用线性项,减小异常值的影响。
处处可微: 在切换点 |error| = δ 处也是可微的。
需要调整超参数 δ

应用场景:

当你既想获得MSE在最优点附近的良好收敛性,又想处理数据中可能存在的异常值时,Huber损失是一个不错的选择。
常用于目标检测中的边界框回归(例如Smooth L1 Loss是Huber损失的一个变种)。

Keras/TensorFlow中的使用:

tf.keras.losses.Huber(delta=1.0) (delta是可配置的)

4.3.1.4 Log-Cosh损失

Log-Cosh损失是另一种比L2损失更平滑的回归损失函数。

公式 (单个样本): L_LogCosh(y_true, y_pred) = log(cosh(y_pred - y_true))
其中 cosh(x) = (e^x + e^(-x)) / 2 是双曲余弦函数。

特性:

对于小的误差,log(cosh(x)) 近似于 x^2 / 2 (类似MSE)。
对于大的误差,log(cosh(x)) 近似于 |x| - log(2) (类似MAE,但有偏移)。
处处二次可微(平滑)。
对异常值比MSE鲁棒,但可能不如MAE或Huber损失那么鲁棒。

应用场景:

作为MSE的一个更平滑、对异常值略微鲁棒的替代品。

Keras/TensorFlow中的使用:

loss='logcosh'
tf.keras.losses.LogCosh()

代码示例:比较回归损失函数 (MSE, MAE, Huber)

import numpy as np # 导入NumPy库
import matplotlib.pyplot as plt # 导入Matplotlib绘图库
import tensorflow as tf # 导入TensorFlow库 (仅用于Huber损失的简便计算)

# 定义真实值和一系列预测值
y_true_val = 0.0 # 假设真实值为0
y_pred_vals = np.linspace(-5, 5, 100) # 生成-5到5之间的100个预测值

# 计算误差
errors = y_pred_vals - y_true_val # 计算每个预测值与真实值之间的误差

# 1. MSE (Mean Squared Error)
loss_mse = errors**2 # 计算平方误差 (这里是单个样本的SE,不是Mean)
grad_mse = 2 * errors # 计算MSE的梯度 (∂L/∂error = 2*error)

# 2. MAE (Mean Absolute Error)
loss_mae = np.abs(errors) # 计算绝对误差
grad_mae = np.sign(errors) # 计算MAE的梯度 (sign(error),在error=0处为0,严格来说是次梯度)
grad_mae[errors == 0] = 0 # 在误差为0时,梯度设为0

# 3. Huber Loss (使用TensorFlow的实现,delta=1.0)
huber_loss_fn = tf.keras.losses.Huber(delta=1.0, reduction=tf.keras.losses.Reduction.NONE) # 创建Huber损失函数实例,不进行归约
# reduction=NONE 表示对每个样本独立计算损失,而不是求平均或总和
loss_huber = huber_loss_fn(np.full_like(y_pred_vals, y_true_val), y_pred_vals).numpy() # 计算Huber损失
# Huber损失的梯度需要分段计算,这里我们不手动计算,只已关注损失函数形状
# 梯度: error if |error| <= delta,  delta * sign(error) if |error| > delta

# --- 绘图 ---
plt.figure(figsize=(12, 10)) # 创建图形窗口

# 绘制损失函数
plt.subplot(2, 1, 1) # 第一个子图 (损失函数)
plt.plot(errors, loss_mse, label='Squared Error (SE) Loss', color='red', linestyle='-') # 绘制SE曲线
plt.plot(errors, loss_mae, label='Absolute Error (AE) Loss', color='blue', linestyle='--') # 绘制AE曲线
plt.plot(errors, loss_huber, label='Huber Loss (δ=1.0)', color='green', linestyle='-.') # 绘制Huber损失曲线
plt.title('Comparison of Regression Loss Functions') # 设置标题
plt.xlabel('Error (y_pred - y_true)') # 设置x轴标签
plt.ylabel('Loss Value') # 设置y轴标签
plt.grid(True) # 显示网格
plt.legend() # 显示图例
plt.ylim(-0.5, 10) # 调整y轴范围以便观察

# 绘制梯度 (MSE 和 MAE)
plt.subplot(2, 1, 2) # 第二个子图 (梯度)
plt.plot(errors, grad_mse, label='Gradient of SE Loss (2*error)', color='red', linestyle='-') # 绘制SE梯度曲线
plt.plot(errors, grad_mae, label='Gradient of AE Loss (sign(error))', color='blue', linestyle='--') # 绘制AE梯度曲线
# (Huber的梯度是分段的,这里不绘制以保持简洁)
plt.title('Gradients of SE and AE Loss Functions') # 设置标题
plt.xlabel('Error (y_pred - y_true)') # 设置x轴标签
plt.ylabel('Gradient Value (∂Loss/∂error)') # 设置y轴标签
plt.grid(True) # 显示网格
plt.legend() # 显示图例
plt.ylim(-5, 5) # 调整y轴范围

plt.tight_layout() # 自动调整布局
plt.show() # 显示图形

# 观察点:
# - MSE对大误差的损失和梯度都增长很快。
# - MAE的损失和梯度都是线性的(梯度大小恒定)。
# - Huber在小误差时像MSE,大误差时像MAE。

运行此代码,你可以直观地看到不同回归损失函数及其梯度随误差变化的特性。

4.3.2 分类问题中的损失函数 (Classification Losses)

分类任务的目标是预测输入属于哪个预定义的类别。

4.3.2.1 二元交叉熵损失 (Binary Cross-Entropy Loss) / Log Loss

应用场景: 二分类问题,模型输出层只有一个神经元,使用Sigmoid激活函数,输出一个表示样本属于正类(类别1)的概率 p = y_pred。则样本属于负类(类别0)的概率为 1-p

真实标签: y_true 通常为0或1。

公式 (单个样本):
L_BCE(y_true, y_pred) = - [ y_true * log(y_pred) + (1 - y_true) * log(1 - y_pred) ]
其中:

y_pred 是模型预测样本为正类的概率 (Sigmoid输出,0到1之间)。
log 通常是自然对数 (ln)。

解释:

如果 y_true = 1 (真实为正类): 损失为 -log(y_pred)。如果 y_pred 接近1 (预测正确),损失接近0。如果 y_pred 接近0 (预测错误),损失趋向于正无穷。
如果 y_true = 0 (真实为负类): 损失为 -log(1 - y_pred)。如果 y_pred 接近0 (预测正确,1-y_pred 接近1),损失接近0。如果 y_pred 接近1 (预测错误,1-y_pred 接近0),损失趋向于正无穷。

特性:

惩罚错误的高置信度预测: 当模型以高置信度做出错误预测时,损失会非常大。
与Sigmoid激活匹配: 非常适合与Sigmoid输出层配合使用。
源于信息论: 交叉熵衡量两个概率分布之间的差异。在这里,一个是真实分布 [y_true, 1-y_true],另一个是预测分布 [y_pred, 1-y_pred]
可微。

数值稳定性:

y_pred 非常接近0或1时,log(y_pred)log(1-y_pred) 可能导致数值问题(例如 log(0) 为负无穷)。
深度学习框架在实现时通常会进行数值稳定处理,例如将 y_pred 裁剪到一个很小的范围 [epsilon, 1-epsilon] 内,或者使用 log_sigmoid 等更稳定的计算方式。

Keras/TensorFlow中的使用:

loss='binary_crossentropy'
tf.keras.losses.BinaryCrossentropy(from_logits=False)

from_logits=False (默认): 期望 y_pred 是经过Sigmoid激活后的概率值。
from_logits=True: 期望 y_pred 是Sigmoid激活之前的原始logit值。框架内部会自动应用Sigmoid并进行更稳定的计算。推荐使用 from_logits=True 并让输出层不带Sigmoid激活,这样数值上更稳定。

4.3.2.2 分类交叉熵损失 (Categorical Cross-Entropy Loss)

应用场景: 多类别分类问题(有K个类别,K > 2),且每个样本只属于一个类别(互斥)。模型输出层有K个神经元,使用Softmax激活函数,输出一个K维的概率分布向量 y_pred = [p_1, p_2, ..., p_K],其中 p_j 是样本属于类别 j 的概率,且 Σ p_j = 1

真实标签: y_true 通常是独热编码 (One-Hot Encoded) 向量。例如,如果K=3,样本属于第2个类别,则 y_true = [0, 1, 0]

公式 (单个样本):
L_CCE(y_true, y_pred) = - Σ_{j=1 to K} (y_true_j * log(y_pred_j))
其中:

y_true_j 是真实标签向量的第 j 个元素 (0或1)。
y_pred_j 是模型预测样本属于类别 j 的概率 (Softmax输出的第 j 个元素)。

解释:

由于 y_true 是one-hot编码,只有一个 y_true_c = 1 (对于真实类别 c),其他都为0。
因此,公式简化为: L_CCE = -log(y_pred_c),其中 c 是样本的真实类别。
这意味着损失只取决于模型对真实类别的预测概率。如果模型对真实类别的预测概率 y_pred_c 很高(接近1),损失就小。如果概率低(接近0),损失就大。

特性:

与Softmax输出层完美配合。
衡量预测概率分布与真实(one-hot)概率分布之间的差异。

数值稳定性: 同样需要注意 log(0) 的问题,框架会处理。

Keras/TensorFlow中的使用:

loss='categorical_crossentropy'
tf.keras.losses.CategoricalCrossentropy(from_logits=False)

from_logits=False (默认): 期望 y_pred 是经过Softmax激活后的概率分布。
from_logits=True: 期望 y_pred 是Softmax激活之前的原始logit向量。框架内部会自动应用Softmax并进行更稳定的计算。同样推荐使用 from_logits=True 并让输出层不带Softmax激活

4.3.2.3 稀疏分类交叉熵损失 (Sparse Categorical Cross-Entropy Loss)

应用场景: 与分类交叉熵相同(多类别分类,单标签),但真实标签的表示方式不同。

真实标签: y_true 是一个整数索引,表示样本所属的类别(例如,0, 1, 2, …, K-1)。而不是one-hot编码向量。

公式: 内部计算逻辑与分类交叉熵相同,只是它会自动将整数标签转换为one-hot形式(或等效地直接使用整数标签来索引预测概率)。
L_SCCE = -log(y_pred_c),其中 c = y_true (整数类别索引)。

优点:

当类别数量非常大时,使用整数标签比使用巨大的one-hot编码向量更节省内存和计算。
更方便准备标签数据。

Keras/TensorFlow中的使用:

loss='sparse_categorical_crossentropy'
tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False)

from_logits 参数的含义与 CategoricalCrossentropy 中相同。

4.3.2.4 Hinge损失 (Hinge Loss) / SVM损失

应用场景: 主要用于“最大间隔”分类器,例如支持向量机 (SVM)。也可用于训练神经网络进行二分类或多类别分类。

目标: 不仅要正确分类,还要确保正确类别与错误类别之间的“间隔 (margin)”足够大。

二分类Hinge损失 (真实标签 y_true ∈ {-1, 1},模型输出原始得分 y_raw_pred,没有经过Sigmoid):
L_Hinge(y_true, y_raw_pred) = max(0, 1 - y_true * y_raw_pred)

如果 y_true * y_raw_pred >= 1 (即预测正确且置信度足够高,在间隔边界之外),损失为0。
如果 y_true * y_raw_pred < 1 (预测错误,或者预测正确但置信度不够高,在间隔之内),损失为 1 - y_true * y_raw_pred

多类别Hinge损失 (Crammer & Singer变体):
L_Hinge_Multi(y_true_idx, y_raw_scores) = Σ_{j ≠ y_true_idx} max(0, y_raw_scores_j - y_raw_scores_{y_true_idx} + margin)
其中 y_true_idx 是真实类别的索引,y_raw_scores 是模型对所有类别的原始得分向量,margin 通常设为1。
它惩罚那些得分高于真实类别得分(或者不够低于真实类别得分减去间隔)的错误类别。

特性:

最大间隔: 鼓励模型将不同类别的样本以较大的间隔分开。
对已正确分类且间隔足够的样本损失为0: 这意味着这些样本不会对梯度产生贡献,模型会更已关注那些难分的样本(在间隔边界附近或内部的)。
不可微(在 1 - y_true * y_raw_pred = 0 等点),但有次梯度。

应用场景:

传统上用于训练SVM。
有时也用于训练神经网络,尤其是在希望获得类似SVM的最大间隔特性时。
不直接输出概率。如果需要概率,可能需要在Hinge损失训练的模型之上再进行校准。

Keras/TensorFlow中的使用:

loss='hinge' (用于二分类,标签为-1或1)
loss='squared_hinge' (Hinge损失的平方,更平滑)
loss='categorical_hinge' (用于多类别,标签为one-hot)
tf.keras.losses.Hinge()
tf.keras.losses.SquaredHinge()
tf.keras.losses.CategoricalHinge()

代码示例:分类损失函数 (二元交叉熵,分类交叉熵)

import numpy as np # 导入NumPy库
import tensorflow as tf # 导入TensorFlow库

# --- 1. 二元交叉熵 (Binary Cross-Entropy) ---
print("--- 二元交叉熵损失示例 ---") # 打印标题
y_true_bce = np.array([1.0, 0.0, 1.0, 0.0], dtype=np.float32) # 真实标签 (0或1)
# 预测的概率 (假设来自Sigmoid输出)
y_pred_bce_probs = np.array([0.9, 0.2, 0.4, 0.7], dtype=np.float32) # 预测概率
# 预测的logits (假设来自Sigmoid之前的层)
y_pred_bce_logits = np.array([2.197, -1.386, -0.405, 0.847], dtype=np.float32) # 对应的logits
# (可以通过 logit = log(p / (1-p)) 从概率反推,或 sigmoid(logit) = p)

# a) 使用Keras损失函数 (期望输入是概率)
bce_loss_fn_probs = tf.keras.losses.BinaryCrossentropy(from_logits=False) # 创建BCE损失对象 (输入为概率)
loss_bce_p = bce_loss_fn_probs(y_true_bce, y_pred_bce_probs) # 计算损失
print(f"真实标签 (BCE): {
              y_true_bce}") # 打印真实标签
print(f"预测概率 (BCE): {
              y_pred_bce_probs}") # 打印预测概率
print(f"BCE损失 (输入为概率): {
              loss_bce_p.numpy():.4f}") # 打印计算得到的损失

# b) 使用Keras损失函数 (期望输入是logits,更稳定)
bce_loss_fn_logits = tf.keras.losses.BinaryCrossentropy(from_logits=True) # 创建BCE损失对象 (输入为logits)
loss_bce_l = bce_loss_fn_logits(y_true_bce, y_pred_bce_logits) # 计算损失
print(f"
预测Logits (BCE): {
              y_pred_bce_logits}") # 打印预测Logits
print(f"BCE损失 (输入为Logits): {
              loss_bce_l.numpy():.4f}") # 打印计算得到的损失 (应与上面接近)

# 手动计算第一个样本的BCE损失: y_true=1, y_pred=0.9
# L = -[1 * log(0.9) + (1-1) * log(1-0.9)] = -log(0.9)
manual_bce_sample1 = -np.log(0.9) # 手动计算第一个样本的损失
print(f"手动计算第一个样本BCE (-log(0.9)): {
              manual_bce_sample1:.4f}") # 打印手动计算结果

# --- 2. 分类交叉熵 (Categorical Cross-Entropy) ---
print("
--- 分类交叉熵损失示例 ---") # 打印标题
# 真实标签 (One-Hot编码), 3个类别
y_true_cce = np.array([
    [0, 1, 0],  # 样本1属于类别1
    [1, 0, 0],  # 样本2属于类别0
    [0, 0, 1]   # 样本3属于类别2
], dtype=np.float32)
# 预测的概率分布 (假设来自Softmax输出)
y_pred_cce_probs = np.array([
    [0.1, 0.7, 0.2], # 样本1的预测概率
    [0.8, 0.1, 0.1], # 样本2的预测概率
    [0.3, 0.3, 0.4]  # 样本3的预测概率
], dtype=np.float32)
# 预测的logits (假设来自Softmax之前的层)
y_pred_cce_logits = np.array([
    [-0.693,  0.847, -0.287], # log(0.1/sum_exp), log(0.7/sum_exp)... (不精确,仅示意)
    [ 1.386, -0.693, -0.693],
    [ 0.000,  0.000,  0.287]  # 假设这些是logits
], dtype=np.float32)


# a) 使用Keras损失函数 (期望输入是概率)
cce_loss_fn_probs = tf.keras.losses.CategoricalCrossentropy(from_logits=False) # 创建CCE损失对象 (输入为概率)
loss_cce_p = cce_loss_fn_probs(y_true_cce, y_pred_cce_probs) # 计算损失
print(f"真实标签 (CCE, One-Hot):
{
              y_true_cce}") # 打印真实标签
print(f"预测概率 (CCE):
{
              y_pred_cce_probs}") # 打印预测概率
print(f"CCE损失 (输入为概率): {
              loss_cce_p.numpy():.4f}") # 打印计算得到的损失

# b) 使用Keras损失函数 (期望输入是logits,更稳定)
cce_loss_fn_logits = tf.keras.losses.CategoricalCrossentropy(from_logits=True) # 创建CCE损失对象 (输入为logits)
loss_cce_l = cce_loss_fn_logits(y_true_cce, y_pred_cce_logits) # 计算损失
print(f"
预测Logits (CCE):
{
              y_pred_cce_logits}") # 打印预测Logits
print(f"CCE损失 (输入为Logits): {
              loss_cce_l.numpy():.4f}") # 打印计算得到的损失

# 手动计算第一个样本的CCE损失: y_true=[0,1,0], y_pred_probs=[0.1, 0.7, 0.2]
# L = -[0*log(0.1) + 1*log(0.7) + 0*log(0.2)] = -log(0.7)
manual_cce_sample1 = -np.log(0.7) # 手动计算第一个样本的损失
print(f"手动计算第一个样本CCE (-log(0.7)): {
              manual_cce_sample1:.4f}") # 打印手动计算结果

# --- 3. 稀疏分类交叉熵 (Sparse Categorical Cross-Entropy) ---
print("
--- 稀疏分类交叉熵损失示例 ---") # 打印标题
# 真实标签 (整数索引), 3个类别
y_true_scce = np.array([1, 0, 2], dtype=np.int32) # 样本1属类别1, 样本2属类别0, 样本3属类别2
# 预测的概率分布 (与上面CCE的y_pred_cce_probs相同)
y_pred_scce_probs = y_pred_cce_probs
# 预测的logits (与上面CCE的y_pred_cce_logits相同)
y_pred_scce_logits = y_pred_cce_logits

# a) 使用Keras损失函数 (期望输入是概率)
scce_loss_fn_probs = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False) # 创建SCCE损失对象 (输入为概率)
loss_scce_p = scce_loss_fn_probs(y_true_scce, y_pred_scce_probs) # 计算损失
print(f"真实标签 (SCCE, 整数索引): {
              y_true_scce}") # 打印真实标签
print(f"预测概率 (SCCE):
{
              y_pred_scce_probs}") # 打印预测概率
print(f"SCCE损失 (输入为概率): {
              loss_scce_p.numpy():.4f}") # 打印计算得到的损失 (应与上面CCE损失相同)

# b) 使用Keras损失函数 (期望输入是logits,更稳定)
scce_loss_fn_logits = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True) # 创建SCCE损失对象 (输入为logits)
loss_scce_l = scce_loss_fn_logits(y_true_scce, y_pred_scce_logits) # 计算损失
print(f"
预测Logits (SCCE):
{
              y_pred_scce_logits}") # 打印预测Logits
print(f"SCCE损失 (输入为Logits): {
              loss_scce_l.numpy():.4f}") # 打印计算得到的损失 (应与上面CCE损失相同)

这个示例展示了如何在TensorFlow/Keras中使用这几种核心的分类损失函数,并强调了使用 from_logits=True 以获得更好数值稳定性的重要性。

4.4 损失函数的选择总结与高级话题

选择损失函数的快速指南:

任务类型 输出层激活 真实标签格式 推荐损失函数 (Keras/TF)
回归 线性/无 连续值 mse, mae, huber, logcosh
二分类 Sigmoid 0或1 binary_crossentropy (推荐 from_logits=True)
多类别分类 (单标签) Softmax One-Hot编码 categorical_crossentropy (推荐 from_logits=True)
多类别分类 (单标签) Softmax 整数索引 sparse_categorical_crossentropy (推荐 from_logits=True)
多标签分类 Sigmoid (每个输出独立) 多热编码/二进制向量 binary_crossentropy (应用于每个输出,然后求和/平均)

高级损失函数话题 (简要提及,后续章节可能深入):

Focal Loss:

针对目标检测等任务中类别极度不平衡(例如,背景像素远多于目标像素)和难易样本不平衡(大量易分样本贡献了大部分损失,使得模型难以学习难分样本)的问题而提出。
它通过修改标准的交叉熵损失,引入一个调制因子 (1-p_t)^γ,使得易分样本(p_t 较大)的损失被降低,从而让模型更已关注于难分的错分类样本。
FL(p_t) = -α_t * (1-p_t)^γ * log(p_t) (其中 p_t 是对真实类别的预测概率,γ >= 0 是聚焦参数,α_t 是类别权重)。

Dice Loss / IoU Loss (Intersection over Union Loss):

主要用于图像分割任务。它们直接优化预测分割掩码与真实分割掩码之间的重叠程度(Dice系数或IoU)。
对类别不平衡问题(例如小物体分割)通常比像素级的交叉熵损失更有效。

Triplet Loss / Contrastive Loss:

用于度量学习 (Metric Learning)表示学习 (Representation Learning),例如人脸识别、图像检索。
目标是学习一个嵌入空间 (embedding space),使得相似的样本在该空间中距离近,不相似的样本距离远。
Triplet Loss: 需要三元组输入 (锚点anchor, 正样本positive, 负样本negative)。损失函数鼓励锚点与正样本的距离小于锚点与负样本的距离,并且至少有一个预定义的间隔 (margin)。
L_triplet = max(0, D(anchor, positive)^2 - D(anchor, negative)^2 + margin)
Contrastive Loss: 需要成对输入 (一对样本及它们是否相似的标签)。如果相似,则惩罚它们之间的距离;如果不相似,则惩罚它们之间的距离小于某个间隔。

Wasserstein Loss (用于GANs):

在生成对抗网络 (GANs) 中,用于衡量真实数据分布与生成数据分布之间的Earth Mover’s Distance (Wasserstein-1距离) 的一种近似。
相比于传统的GAN损失(如基于JS散度或KL散度的),Wasserstein损失通常能提供更稳定的训练和更有意义的损失曲线。

自定义损失函数:

深度学习框架允许你根据特定需求创建自己的损失函数。只要这个函数是可微的(或至少次可微),并且能将 y_truey_pred 映射到一个标量损失值,就可以用作训练目标。

第二章:神经网络的核心组件

2.3 优化器 (Optimizers): 驱动模型学习的引擎

2.3.1 什么是优化器?为什么需要它?

在训练神经网络时,我们的目标是找到一组参数(权重 (w) 和偏置 (b)),使得模型在给定训练数据上的损失函数 (L) 达到最小值。可以将损失函数想象成一个复杂的高维“地形图”,地势的高低代表损失值的大小,而模型的参数则是我们在这个地形图上的位置。我们的目标是从某个随机初始化的位置出发,通过调整参数,一步步走到这个地形的最低点(全局最小值)或至少是一个足够低的洼地(局部最小值)。

优化器就是指导我们如何在这个“地形图”上移动以找到最低点的算法。

如果没有优化器,我们可能需要盲目地、随机地调整参数,这在参数量巨大的深度神经网络中几乎是不可能成功的。优化器利用损失函数关于参数的梯度信息(即损失函数在当前参数位置变化最快的方向)来决定参数更新的方向和步长,从而高效地引导模型向更优的参数配置迭代。

核心思想:梯度下降 (Gradient Descent)

绝大多数现代优化器的基础都是梯度下降算法。其核心思想是:

计算梯度: 对于当前的参数值,计算损失函数 (L) 关于每个参数的偏导数(即梯度 (
abla L))。梯度指出了损失函数值增加最快的方向。
参数更新: 沿着梯度的反方向(即损失函数值减少最快的方向)更新参数。更新的幅度由一个称为学习率 (Learning Rate) (eta) 的超参数控制。

参数更新的基本公式可以表示为:
[ heta_{new} = heta_{old} – eta
abla L( heta_{old}) ]
其中:

( heta) 代表模型的参数(可以是单个参数,也可以是所有参数的向量)。
(eta) 是学习率,一个正的标量,控制每一步更新的幅度。
(
abla L( heta_{old})) 是损失函数在当前参数 ( heta_{old}) 处的梯度。

这个过程会不断重复,直到损失函数的值收敛到某个最小值,或者达到预设的训练迭代次数。

2.3.2 梯度下降的变体

根据计算梯度时使用的数据量不同,梯度下降主要有三种变体:批量梯度下降 (Batch Gradient Descent, BGD),随机梯度下降 (Stochastic Gradient Descent, SGD),和小批量梯度下降 (Mini-batch Gradient Descent, MBGD)。

A. 批量梯度下降 (Batch Gradient Descent, BGD)

概念:
批量梯度下降在每次参数更新时,都会使用整个训练数据集来计算损失函数的梯度。这意味着模型看完所有训练样本后,才进行一次参数调整。

更新规则:
对于训练集 ({(x^{(i)}, y{(i)})}_{i=1}{N}),其中 (N) 是样本总数:

计算整个训练集上的总损失: (L( heta) = frac{1}{N} sum_{i=1}^{N} ext{loss}(f(x^{(i)}; heta), y^{(i)}))
计算总损失关于参数 ( heta) 的梯度: (
abla L( heta) = frac{1}{N} sum_{i=1}^{N}
abla ext{loss}(f(x^{(i)}; heta), y^{(i)}))
更新参数: ( heta_{new} = heta_{old} – eta
abla L( heta_{old}))

优点:

精确的梯度: 由于使用了整个数据集,计算出的梯度是对真实梯度的无偏估计,因此参数更新的方向非常准确,指向全局最优(对于凸函数)或局部最优(对于非凸函数)。
稳定的收敛路径: 参数更新过程相对平滑,损失函数下降的轨迹通常不会有太大的震荡。
易于并行化: 梯度计算可以利用向量化操作在现代硬件(如GPU)上高效并行。

缺点:

计算成本高: 当训练数据集非常大时(例如数百万甚至数十亿样本),每次更新都需要遍历所有数据,计算梯度的时间成本和内存开销都非常巨大。
收敛速度慢: 尽管每一步更新都很“准”,但由于每次更新的代价高,达到收敛所需的总时间可能很长。
难以处理在线学习: 对于流式数据或需要模型不断学习新数据的场景(在线学习),BGD不适用,因为它需要一次性获得所有数据。
可能陷入尖锐的局部最小值: 由于其平滑的更新路径,BGD有时更容易陷入那些盆地较窄但可能不是全局最优的局部最小值。

概念性代码说明 (Python-like pseudocode):

# 假设 parameters 是模型参数的列表或字典
# 假设 training_data 是包含 (输入, 标签) 对的列表
# 假设 compute_loss_and_gradient_for_sample 是一个函数,计算单个样本的损失和梯度
# 假设 learning_rate 是学习率

num_epochs = 100 # 训练轮数

for epoch in range(num_epochs): # 外层循环,表示对整个数据集的完整遍历次数
    total_gradient = initialize_gradient_like(parameters) # 初始化总梯度为零
    total_loss = 0.0 # 初始化总损失为零
    num_samples = len(training_data) # 获取样本总数

    for input_data, target_label in training_data: # 遍历整个训练数据集
        # 对每个样本计算损失和梯度
        loss_for_sample, gradient_for_sample = compute_loss_and_gradient_for_sample(parameters, input_data, target_label)
        total_loss += loss_for_sample # 累加单个样本的损失
        # 累加单个样本的梯度到总梯度中
        for param_key in total_gradient:
            total_gradient[param_key] += gradient_for_sample[param_key]

    # 计算平均梯度和平均损失
    average_gradient = {
            key: val / num_samples for key, val in total_gradient.items()} # 计算平均梯度
    average_loss = total_loss / num_samples # 计算平均损失

    print(f"Epoch {
              epoch+1}, Average Loss: {
              average_loss}") # 打印当前轮的平均损失

    # 根据平均梯度更新所有参数
    for param_key in parameters: # 遍历所有参数
        parameters[param_key] -= learning_rate * average_gradient[param_key] # 参数更新:减去学习率乘以平均梯度

使用场景:
BGD在数据集较小,或者可以完全载入内存,并且对收敛稳定性要求较高的情况下可能适用。但在现代深度学习中,由于数据集规模庞大,纯粹的BGD已不常用。

B. 随机梯度下降 (Stochastic Gradient Descent, SGD)

概念:
随机梯度下降在每次参数更新时,仅使用单个训练样本来计算损失函数的梯度。模型每看到一个样本,就进行一次参数调整。

更新规则:
对于训练集中的第 (i) 个样本 ((x^{(i)}, y^{(i)})):

计算该单个样本上的损失: (L_i( heta) = ext{loss}(f(x^{(i)}; heta), y^{(i)}))
计算该损失关于参数 ( heta) 的梯度: (
abla L_i( heta) =
abla ext{loss}(f(x^{(i)}; heta), y^{(i)}))
更新参数: ( heta_{new} = heta_{old} – eta
abla L_i( heta_{old}))
在遍历完所有样本(一个epoch)之前,参数会被更新 (N) 次((N) 为样本总数)。

优点:

更新速度快: 每次更新仅需计算一个样本的梯度,计算量小,参数更新频繁,模型学习速度快。
能够处理大规模数据集: 由于每次只处理一个样本,内存占用小,非常适合无法一次性载入内存的大型数据集和在线学习场景。
引入噪声,有助于跳出局部最小值: 单个样本的梯度是有噪声的,它不一定指向全局最优的方向,但这种随机性有时能帮助优化过程跳出尖锐的局部最小值或鞍点,探索更广阔的参数空间,可能找到更好的解。

缺点:

高方差的更新: 由于单个样本的梯度具有随机性,参数更新的方向波动很大,导致损失函数的下降路径非常“颠簸”和嘈杂。
收敛不稳定: 损失函数值可能不会稳定下降,而是在最小值附近震荡。
可能不会精确收敛到最小值: 由于梯度噪声,即使学习率很小,SGD也可能在最小值附近徘徊而不是精确收敛。通常需要配合学习率衰减策略。
失去向量化带来的计算优势: 逐样本计算梯度使得难以充分利用现代CPU/GPU的并行计算能力。

概念性代码说明 (Python-like pseudocode):

# 假设 parameters 是模型参数的列表或字典
# 假设 training_data 是包含 (输入, 标签) 对的列表,通常会先打乱顺序
# 假设 compute_loss_and_gradient_for_sample 是一个函数,计算单个样本的损失和梯度
# 假设 learning_rate 是学习率

num_epochs = 100 # 训练轮数

for epoch in range(num_epochs): # 外层循环,表示对整个数据集的完整遍历次数
    # 在每个epoch开始时,通常会对训练数据进行随机打乱,以增加随机性
    shuffled_training_data = shuffle(training_data) # 打乱训练数据

    total_loss_epoch = 0.0 # 初始化当前epoch的总损失

    for input_data, target_label in shuffled_training_data: # 遍历打乱后的训练数据集中的每一个样本
        # 对当前单个样本计算损失和梯度
        loss_for_sample, gradient_for_sample = compute_loss_and_gradient_for_sample(parameters, input_data, target_label)
        total_loss_epoch += loss_for_sample # 累加当前epoch的损失

        # 根据单个样本的梯度更新所有参数
        for param_key in parameters: # 遍历所有参数
            parameters[param_key] -= learning_rate * gradient_for_sample[param_key] # 参数更新:减去学习率乘以单个样本的梯度

    average_loss_epoch = total_loss_epoch / len(training_data) # 计算当前epoch的平均损失
    print(f"Epoch {
              epoch+1}, Average Loss: {
              average_loss_epoch}") # 打印当前轮的平均损失

    # 通常在SGD中会配合学习率衰减策略,例如 learning_rate *= 0.99

使用场景:
SGD及其变体(如带动量的SGD)是深度学习中最常用的优化算法之一,尤其适用于大规模数据集。它的随机性虽然带来了不稳定性,但也赋予了其探索能力。

C. 小批量梯度下降 (Mini-batch Gradient Descent, MBGD)

概念:
小批量梯度下降是批量梯度下降和随机梯度下降之间的一种折中方案。它在每次参数更新时,使用训练数据的一个小子集 (mini-batch) 来计算梯度。这个小子集的大小(batch size)是一个超参数,通常选择为 (2^k)(如32, 64, 128, 256)。

更新规则:
从训练集中随机抽取一个包含 (m) 个样本的小批量 ({(x^{(j)}, y{(j)})}_{j=1}{m}),其中 (1 < m ll N):

计算该小批量上的平均损失: (L_{batch}( heta) = frac{1}{m} sum_{j=1}^{m} ext{loss}(f(x^{(j)}; heta), y^{(j)}))
计算该平均损失关于参数 ( heta) 的梯度: (
abla L_{batch}( heta) = frac{1}{m} sum_{j=1}^{m}
abla ext{loss}(f(x^{(j)}; heta), y^{(j)}))
更新参数: ( heta_{new} = heta_{old} – eta
abla L_{batch}( heta_{old}))
如果训练集大小为 (N),batch size 为 (m),则在一个epoch内参数会更新 (N/m) 次。

优点:

兼顾BGD和SGD的优点:

降低更新方差: 相比SGD,使用一个小批量的平均梯度更为稳定,减少了参数更新的噪声,使得收敛过程更平滑。
计算效率高: 相比BGD,每次更新的计算量显著减少;同时,可以利用现代硬件的并行计算能力对一个小批量内的样本进行向量化处理,比SGD逐样本处理更高效。

稳定的收敛: 通常比SGD收敛更快、更稳定。
适合大规模数据: 仍然能够处理无法一次性载入内存的大型数据集。

缺点:

引入新的超参数 (batch size): batch size的选择对模型性能和训练速度有显著影响,需要仔细调整。

较小的batch size会引入更多噪声,有助于模型跳出局部最优,但训练过程不稳定,收敛慢。
较大的batch size会使梯度估计更准确,训练过程稳定,但可能导致泛化能力下降(更容易陷入尖锐的最小值),且对内存要求更高。

仍然可能存在震荡: 虽然比SGD平滑,但损失函数下降曲线仍可能存在一定的震荡。

概念性代码说明 (Python-like pseudocode):

# 假设 parameters 是模型参数的列表或字典
# 假设 training_data 是包含 (输入, 标签) 对的列表
# 假设 compute_loss_and_gradient_for_sample 是一个函数,计算单个样本的损失和梯度
# 假设 learning_rate 是学习率
# 假设 batch_size 是小批量的大小,例如 32

num_epochs = 100 # 训练轮数

for epoch in range(num_epochs): # 外层循环,表示对整个数据集的完整遍历次数
    shuffled_training_data = shuffle(training_data) # 每个epoch开始时打乱数据
    total_loss_epoch = 0.0 # 初始化当前epoch的总损失

    # 将打乱后的数据划分为小批量
    for i in range(0, len(shuffled_training_data), batch_size): # 以batch_size为步长遍历数据
        mini_batch = shuffled_training_data[i : i + batch_size] # 获取当前的小批量数据
        
        # 如果最后一个批次不足batch_size,可以选择丢弃或照常处理
        if not mini_batch: # 避免空批次
            continue

        batch_gradient = initialize_gradient_like(parameters) # 初始化当前批次的梯度为零
        batch_loss = 0.0 # 初始化当前批次的损失为零
        num_samples_in_batch = len(mini_batch) # 获取当前批次的实际样本数

        for input_data, target_label in mini_batch: # 遍历小批量中的每个样本
            loss_for_sample, gradient_for_sample = compute_loss_and_gradient_for_sample(parameters, input_data, target_label) # 计算单个样本的损失和梯度
            batch_loss += loss_for_sample # 累加批次损失
            # 累加单个样本的梯度到批次梯度中
            for param_key in batch_gradient:
                batch_gradient[param_key] += gradient_for_sample[param_key]

        # 计算批次的平均梯度和平均损失
        average_batch_gradient = {
            key: val / num_samples_in_batch for key, val in batch_gradient.items()} # 计算批次平均梯度
        average_batch_loss = batch_loss / num_samples_in_batch # 计算批次平均损失
        total_loss_epoch += batch_loss # 累加到epoch总损失(注意这里是批次总损失,不是平均损失)

        # 根据批次的平均梯度更新所有参数
        for param_key in parameters: # 遍历所有参数
            parameters[param_key] -= learning_rate * average_batch_gradient[param_key] # 参数更新

    average_loss_epoch = total_loss_epoch / len(training_data) # 计算当前epoch的平均损失
    print(f"Epoch {
              epoch+1}, Average Loss: {
              average_loss_epoch}") # 打印当前轮的平均损失

使用场景:
小批量梯度下降是目前深度学习中最主流的优化方法。它在训练速度、收敛稳定性、内存效率和并行计算之间取得了良好的平衡。绝大多数先进的优化器(如Adam, RMSProp等)都是在MBGD的框架下工作的。

总结三种梯度下降变体:

特性 批量梯度下降 (BGD) 随机梯度下降 (SGD) 小批量梯度下降 (MBGD)
梯度计算数据 整个训练集 单个样本 小批量样本 (mini-batch)
参数更新频率 每个epoch一次 每个样本一次 每个mini-batch一次
梯度准确性 非常准确 非常不准确(高方差) 相对准确(中等方差)
计算成本/更新 非常高 非常低 中等
内存需求 非常高 非常低 中等
收敛速度 慢(因单次更新成本高) 快(因更新频繁)但震荡 较快且相对稳定
稳定性 非常稳定 非常不稳定(高震荡) 相对稳定
并行化 容易(对整个数据集) 困难(逐样本) 容易(对mini-batch)
在线学习 不支持 支持 支持
跳出局部最优 较难 容易 较容易

在实际应用中,小批量梯度下降(MBGD)因其综合优势而成为默认选择。当我们提到SGD时,很多时候其实是指小批量随机梯度下降。

2.3.3 梯度下降面临的挑战

尽管梯度下降及其变体为我们提供了一个基本的优化框架,但在复杂的损失函数曲面(常见于深度神经网络)上,它们会遇到一些挑战:

学习率的选择 (Learning Rate Selection):

过大的学习率: 可能导致参数更新步子太大,直接“跨过”最小值,使得损失函数在最小值附近震荡甚至发散(越学越差)。
过小的学习率: 会导致收敛速度非常缓慢,需要大量的迭代次数才能达到最优解,增加了训练时间。
找到一个合适的学习率通常需要经验和大量的实验尝试。而且,在训练过程中,固定的学习率可能并非最优,通常需要随着训练的进行动态调整学习率(学习率调度,Learning Rate Scheduling)。

局部最小值 (Local Minima) 和鞍点 (Saddle Points):

局部最小值: 深度学习的损失函数通常是非凸的,存在许多局部最小值。梯度下降算法可能会收敛到这些局部最小值,而不是全局最小值,从而得到次优的模型。SGD的随机性在一定程度上有助于跳出一些浅的局部最小值。
鞍点: 在高维空间中,鞍点(在某些维度上是局部最小值,在另一些维度上是局部最大值,梯度在此处为零)比局部最小值更为常见。梯度下降算法在鞍点附近梯度接近于零,可能导致训练停滞,收敛极其缓慢。

梯度消失 (Vanishing Gradients) 和梯度爆炸 (Exploding Gradients):

在深层网络中,梯度在反向传播过程中可能会逐层连乘。如果激活函数的导数或权重值持续小于1,梯度会指数级减小,导致靠近输入层的网络层梯度过小,参数几乎不更新(梯度消失)。
反之,如果这些值持续大于1,梯度会指数级增大,导致参数更新过大,模型不稳定(梯度爆炸)。
虽然这些问题与网络架构(如激活函数的选择、权重初始化)关系更密切,但优化器也需要能够在这种情况下稳健地工作。梯度裁剪 (Gradient Clipping) 是一种应对梯度爆炸的常用技巧。

不同参数需要不同的学习率 (Learning Rates for Different Parameters):

在某些特征稀疏或不同特征尺度差异很大的情况下,对所有参数使用相同的学习率可能不是最优的。有些参数可能需要更快的更新,而另一些则需要更慢、更精细的调整。

峡谷地带 (Ravines) 和高原地带 (Plateaus):

峡谷: 损失曲面可能在某个方向上非常陡峭,而在另一个方向上非常平缓,形成狭长的“峡谷”。梯度下降在这种地形中容易在峡谷两侧来回震荡,而沿着峡谷底部的进展却很慢。
高原: 损失曲面可能存在大片梯度很小或为零的平坦区域(高原)。模型在这些区域更新缓慢,训练停滞。

为了克服这些挑战,研究者们提出了许多更高级的优化算法。这些算法通常通过引入动量、自适应调整学习率等机制来改进基础的梯度下降方法。

2.3.4 高级优化算法

这些算法通常在小批量梯度下降(MBGD)的框架下工作,通过改进参数更新的策略来提升优化效果。

A. 动量 (Momentum)

动机与思想:
想象一个小球从山坡上滚下来。如果只考虑当前位置的坡度(梯度),小球可能会在一些小的坑洼或者平缓的地段(如鞍点、高原)减速甚至停下,或者在峡谷地带反复震荡。但如果小球具有惯性 (momentum),它会保持之前的运动趋势,更容易冲过小的障碍,更快地到达谷底,并且在峡谷地带的震荡也会减小。

动量优化算法正是借鉴了这个物理思想。它在更新参数时,不仅仅考虑当前的梯度,还会累积一个历史梯度的指数加权移动平均 (Exponentially Weighted Moving Average, EWMA),这个累积量就像小球的“速度”或“动量”。

更新规则:
引入一个动量变量 (v_t) (velocity),它累积了梯度的指数衰减平均。参数更新同时取决于当前梯度和历史动量。

在第 (t) 次迭代时,计算当前小批量的梯度: (
abla L( heta_t))

更新动量 (v_t):
[ v_t = eta v_{t-1} + (1-eta)
abla L( heta_t) quad ext{(一种常见的形式,有时会省略 (1-β))} ]
或者更常见的形式是:
[ v_t = eta v_{t-1} + eta
abla L( heta_t) quad ext{(如果学习率直接乘在梯度上)} ]
我们这里采用第一种形式的变体,其中学习率 (eta) 作用于最终的参数更新。另一种更常见的形式是:
[ v_t = eta v_{t-1} +
abla L( heta_t) ]
[ heta_{t+1} = heta_t – eta v_t ]
其中:

(eta) 是动量超参数,通常取值在0.8到0.99之间(例如0.9)。它控制了历史梯度对当前更新方向的影响程度。较大的 (eta) 意味着历史梯度的影响更大,动量效应更强。
(v_{t-1}) 是上一步的动量。
(
abla L( heta_t)) 是当前参数 ( heta_t) 处的梯度。

更新参数 ( heta_{t+1}):
[ heta_{t+1} = heta_t – eta v_t ]
(注意:这里的 (eta) 是学习率)

另一种等价且更常见的实现方式(尤其是在深度学习框架中):

计算梯度: (g_t =
abla L( heta_t))
更新动量 (velocity): (v_t = eta v_{t-1} + g_t)
更新参数: ( heta_{t+1} = heta_t – eta v_t)

如果初始动量 (v_0) 设为0,那么 (v_t) 可以看作是 (sum_{i=1}^{t} eta^{t-i} g_i)。这意味着 (v_t) 是过去所有梯度的加权平均,越近的梯度权重越大。

优点:

加速收敛: 在梯度方向基本一致的维度上(例如峡谷的底部),动量会累积,使得参数更新的步长更大,从而加速收敛。
减小震荡: 在梯度方向反复改变的维度上(例如峡谷的陡峭两侧),动量项会因为正负梯度的累积而相互抵消一部分,从而减小更新的震荡。
有助于跳出局部最小值和鞍点: 累积的动量可能帮助参数“冲过”梯度较小或为零的区域(如浅的局部最小值或鞍点)。

缺点:

引入新的超参数 (eta): 需要调整动量系数 (eta)。
可能冲过头: 如果动量过大((eta) 过高或学习率设置不当),可能会导致参数更新冲过最小值点。

概念性代码说明 (Python-like pseudocode for SGD with Momentum):

# 假设 parameters 是模型参数的字典
# 假设 gradients 是对应参数的梯度字典
# 假设 learning_rate 是学习率
# 假设 momentum_beta 是动量系数,例如 0.9
# 假设 velocity 是与 parameters 结构相同,用于存储动量的字典,初始为全零

# 在训练循环的每次参数更新时:
# gradients = compute_gradients(parameters, mini_batch_data, mini_batch_labels) # 计算当前小批量的梯度

for param_key in parameters: # 遍历所有参数
    # 更新动量 v_t = beta * v_{t-1} + gradient_t
    velocity[param_key] = momentum_beta * velocity[param_key] + gradients[param_key] # 更新速度(动量项)
    
    # 更新参数 theta_{t+1} = theta_t - learning_rate * v_t
    parameters[param_key] -= learning_rate * velocity[param_key] # 使用速度(动量项)来更新参数

如何解决挑战:

峡谷地带: 在峡谷底部,梯度方向稳定,动量累积,加速前进;在峡谷两侧,梯度方向变化,动量部分抵消,减少震荡。
局部最小值/鞍点: 累积的动量可能提供足够“冲力”越过这些区域。

B. Nesterov 加速梯度 (Nesterov Accelerated Gradient, NAG)

动机与思想:
标准的动量方法是先计算当前位置的梯度,然后在累积的动量方向上“前进一大步”。Nesterov (也称 Nesterov Momentum)认为,既然我们知道动量会把我们带到某个位置,那么我们不应该在当前位置计算梯度,而应该在**动量将要带我们到达的“近似未来位置”**计算梯度,然后用这个“向前看”的梯度来修正最终的更新方向。这就像一个更聪明的小球,它在滚下山时会预判一下自己下一步大概会滚到哪里,然后在那个预判点感受坡度,再决定最终怎么滚。

更新规则:
与标准动量相比,NAG在计算梯度时,会先在动量方向上前进一个“预估”的步骤。

计算参数的“预估未来位置” (lookahead position):
[ heta_{lookahead} = heta_t – eta eta v_{t-1} quad ext{(这里用的是上一步的动量进行预估)} ]
另一种更常见的表达方式是直接在梯度计算时体现:
我们先更新一下参数的一个临时版本,这个临时版本是基于上一步的动量进行移动的。
[ heta_{approx} = heta_t + eta v_{t-1} quad ext{(注意这里是加号,表示先走一步)} ]
在 ( heta_{approx}) 处计算梯度: (g_t =
abla L( heta_t + eta v_{t-1})) (或者 (
abla L( heta_{lookahead})))
更新动量 (v_t):
[ v_t = eta v_{t-1} + g_t ]
更新参数 ( heta_{t+1}):
[ heta_{t+1} = heta_t – eta v_t ]

更简洁且常用的更新形式:
[ v_t = eta v_{t-1} +
abla L( heta_t – eta eta v_{t-1}) ]
[ heta_{t+1} = heta_t – eta v_t ]
(这里 (eta) 是学习率,(eta) 是动量系数)

实际上,很多深度学习框架中的实现通过变量代换可以写成与标准动量类似的形式,但梯度计算点不同。
一种等价的更新方式(Hinton 的 lecture notes 形式):

(v_t = eta v_{t-1} – eta
abla L( heta_t)) (这里先做一步标准SGD)
( heta_{t+1} = heta_t + eta v_t – eta
abla L( heta_t)) (复杂)

Sutskever et al. (2013) 提出的更易于理解和实现的NAG形式:

(v_t = eta v_{t-1} +
abla L( heta_t – eta v_{t-1})) (梯度计算点是 ( heta_t – eta v_{t-1}))
( heta_{t+1} = heta_t – eta v_t)

优点:

更强的响应性: 通过“向前看”,NAG能够更早地感知到损失曲面的变化。如果动量将导致参数冲过头(例如,即将爬上一个坡),在预估位置计算的梯度会指向反方向,从而提前减速或调整方向,减少过冲。
通常比标准动量收敛更快,性能更好: 尤其在一些具有挑战性的损失曲面上。

缺点:

实现比标准动量略复杂,因为梯度计算的评估点不同。

概念性代码说明 (Python-like pseudocode for NAG):

# 假设 parameters 是模型参数的字典
# 假设 velocity 是与 parameters 结构相同,用于存储动量的字典,初始为全零
# 假设 learning_rate 是学习率
# 假设 momentum_beta 是动量系数,例如 0.9

# 在训练循环的每次参数更新时:

# 1. 计算预估位置的参数 (lookahead parameters)
params_lookahead = {
            } # 初始化预估位置的参数字典
for param_key in parameters: # 遍历所有参数
    # theta_lookahead = theta_current + momentum_beta * v_{t-1}
    # 注意:有些公式是 theta_current - learning_rate * momentum_beta * v_{t-1}
    # 取决于 v_{t-1} 是否已经乘以了 learning_rate。
    # 这里假设 v_{t-1} 是纯粹的梯度累积,不含学习率。
    # 如果 v 是 v_t = beta * v_{t-1} + grad,则预估是 params - beta * v_prev (如果v_prev是上一轮的v)
    # 或者更直接:params_approx = params + momentum_beta * velocity[param_key] (先走出动量的一步)
    # 另一种解释,如果 v_t = beta * v_{t-1} + learning_rate * grad 
    # 则 lookahead = params - beta * v_{t-1} (减去上一步动量的影响)
    # 这里采用 Sutskever 的形式:梯度计算点为 theta_t - beta * v_{t-1} (如果v不含eta)
    # 或者更通用的表达,若 v 已经包含了学习率:theta_t - v_t-1 (如果 v 是参数更新量)

    # 为了清晰,我们假设 velocity 不包含学习率,即 v_t = beta * v_{t-1} + grad
    # 那么,梯度计算点是 params_current - learning_rate * momentum_beta * velocity_previous
    # 如果 velocity[param_key] 是上一轮的纯动量累积 v_{t-1}
    params_lookahead[param_key] = parameters[param_key] - momentum_beta * velocity[param_key] # 计算“向前看”的参数点
                                                                                            # 注意:这里应该是 + beta * v_t-1 如果v是不含eta的纯动量
                                                                                            # 或者 params - v_t-1 如果 v_t-1 是已经应用了beta和eta的更新量
                                                                                            # 采用 Sutskever (2013) 的形式:lookahead = params - learning_rate*beta*velocity_prev (if v is old actual update)
                                                                                            # 或 lookahead = params - beta*velocity_prev (if v is old accumulated gradient)

# 为了简化,并与许多框架实现对齐,一种等价方法是:
# 计算梯度作用点:theta_approx = theta_t + beta * v_{t-1} (v_{t-1}是上一步的动量项)
temp_params = {
            }
for param_key in parameters:
    temp_params[param_key] = parameters[param_key] + momentum_beta * velocity[param_key] # 预估下一步参数位置(不含当前梯度)

# 2. 在预估位置计算梯度
# gradients_at_lookahead = compute_gradients(params_lookahead, mini_batch_data, mini_batch_labels)
gradients_at_lookahead = compute_gradients(temp_params, mini_batch_data, mini_batch_labels) # 在预估位置计算梯度

# 3. 更新动量
for param_key in parameters:
    # v_t = beta * v_{t-1} + grad_at_lookahead
    velocity[param_key] = momentum_beta * velocity[param_key] + gradients_at_lookahead[param_key] # 更新速度(动量项),使用在预估点计算的梯度
    
# 4. 更新参数
    # theta_{t+1} = theta_t - learning_rate * v_t
    parameters[param_key] -= learning_rate * velocity[param_key] # 更新参数

如何解决挑战:
与标准动量类似,但由于其“向前看”的特性,NAG在处理峡谷地带的震荡和避免冲过头方面通常表现更好。

C. AdaGrad (Adaptive Gradient Algorithm)

动机与思想:
在传统的梯度下降及其动量变体中,所有参数都共享同一个全局学习率 (eta)。然而,在实际问题中,不同的参数可能需要不同的更新幅度。例如,对于稀疏特征(大部分时间为0,偶尔出现),我们可能希望当这个特征出现时,对应的参数能够有较大的更新;而对于频繁出现的特征,其参数可能已经调整得比较好,只需要较小的更新。

AdaGrad 的核心思想是为每个参数自适应地调整学习率。具体来说,它会根据参数历史梯度的平方和来调整学习率:对于梯度较大的参数(历史上更新较多或幅度较大),其学习率会减小得更快;对于梯度较小的参数,其学习率会相对较大。

更新规则:

在第 (t) 次迭代时,计算当前小批量的梯度: (g_t =
abla L( heta_t))
累积每个参数梯度的平方和 (G_t) (element-wise):
[ G_t = G_{t-1} + g_t odot g_t ]
其中 (odot) 表示逐元素相乘。(G_t) 是一个对角矩阵(或向量,如果只关心对角线元素),其第 (i) 个对角元素 (G_{t,ii}) 是参数 ( heta_i) 从开始到现在的梯度平方累积和。(G_0) 初始化为0。
参数更新 ( heta_{t+1}):
[ heta_{t+1, i} = heta_{t,i} – frac{eta}{sqrt{G_{t,ii} + epsilon}} g_{t,i} ]
其中:

(eta) 是全局初始学习率。
(G_{t,ii}) 是参数 ( heta_i) 的历史梯度平方累积和。
(epsilon) 是一个很小的平滑项(例如 (10^{-7}) 或 (10^{-8})),用于防止分母为零。
(g_{t,i}) 是当前梯度中对应于参数 ( heta_i) 的分量。

可以看到,每个参数 ( heta_i) 都有其自己的有效学习率 (frac{eta}{sqrt{G_{t,ii} + epsilon}})。随着训练的进行,(G_{t,ii}) 会不断增大,导致有效学习率逐渐减小。

优点:

自适应学习率: 为不同参数自动调整学习率,无需手动精细调整全局学习率(尽管初始 (eta) 还是需要设定)。
对稀疏数据友好: 对于不经常更新的参数(稀疏特征对应的参数),其 (G_{t,ii}) 累积较慢,因此有效学习率较大,能够得到充分更新;对于经常更新的参数,(G_{t,ii}) 累积快,有效学习率减小快。这在处理如词嵌入这类稀疏特征时非常有效。

缺点:

学习率单调递减过快: 由于 (G_t) 中的梯度平方和是不断累积的,分母会持续增大,导致学习率最终会变得非常小,使得模型在训练后期学习过早停止,可能无法达到最优解。这是AdaGrad最主要的缺点。

概念性代码说明 (Python-like pseudocode for AdaGrad):

# 假设 parameters 是模型参数的字典
# 假设 gradients 是对应参数的梯度字典
# 假设 global_learning_rate 是全局初始学习率,例如 0.01
# 假设 accumulated_squared_gradients 是与 parameters 结构相同,用于存储梯度平方累积和的字典,初始为全零
# 假设 epsilon 是一个小常数,例如 1e-7

# 在训练循环的每次参数更新时:
# gradients = compute_gradients(parameters, mini_batch_data, mini_batch_labels) # 计算当前小批量的梯度

for param_key in parameters: # 遍历所有参数
    # 累积梯度平方和 G_t = G_{t-1} + g_t^2
    accumulated_squared_gradients[param_key] += gradients[param_key] ** 2 # 逐元素平方并累加
    
    # 计算调整后的学习率 adjusted_lr = global_lr / (sqrt(G_t) + epsilon)
    adjusted_learning_rate = global_learning_rate / (np.sqrt(accumulated_squared_gradients[param_key]) + epsilon) # 计算该参数的自适应学习率
                                                                                                            # np.sqrt 是假设的逐元素开方函数
    
    # 更新参数 theta_{t+1} = theta_t - adjusted_lr * g_t
    parameters[param_key] -= adjusted_learning_rate * gradients[param_key] # 使用自适应学习率更新参数

如何解决挑战:

不同参数需要不同学习率: AdaGrad的核心设计目标就是解决这个问题。
学习率选择: 虽然仍有全局学习率,但其敏感度降低,因为后续会自适应调整。
缺点是学习率衰减过快,后续的优化器如RMSProp, Adam会着力解决这个问题。

D. RMSProp (Root Mean Square Propagation)

动机与思想:
RMSProp 旨在解决 AdaGrad 学习率单调递减过快的问题。AdaGrad 将历史上所有梯度的平方等权累加,导致分母持续增长。RMSProp 对此进行了改进,它不再简单地累加所有历史梯度的平方,而是使用梯度的平方的指数加权移动平均 (EWMA)。这意味着它更已关注近期的梯度信息,而逐渐“忘记”较早的梯度信息,从而防止学习率过早变得太小。

更新规则:

在第 (t) 次迭代时,计算当前小批量的梯度: (g_t =
abla L( heta_t))

计算梯度平方的指数加权移动平均 (E[g^2]_t) (element-wise):
[ E[g^2]t = gamma E[g^2]{t-1} + (1-gamma) (g_t odot g_t) ]
其中:

(gamma) 是衰减率(或称为遗忘因子),类似于动量中的 (eta),通常取值如 0.9, 0.99。它控制了历史梯度平方信息衰减的速度。
(E[g^2]_{t-1}) 是上一步的梯度平方的EWMA。
(g_t odot g_t) 是当前梯度的逐元素平方。
(E[g^2]_0) 初始化为0。

参数更新 ( heta_{t+1}):
[ heta_{t+1, i} = heta_{t,i} – frac{eta}{sqrt{E[g^2]{t,i} + epsilon}} g{t,i} ]
其中:

(eta) 是全局学习率。
(E[g^2]_{t,i}) 是参数 ( heta_i) 的梯度平方的EWMA。
(epsilon) 是平滑项。

与AdaGrad相比,分母 (E[g^2]_t) 不会无限增长,而是会稳定在一个反映近期梯度平方平均值的水平。

优点:

缓解AdaGrad学习率衰减过快的问题: 通过使用EWMA,学习率不会单调递减至0。
自适应学习率: 仍然为每个参数提供了自适应的学习率。
在非平稳目标上表现良好: 由于它更已关注近期梯度,因此在损失函数曲面变化较快时(非平稳目标)也能较好地适应。

缺点:

引入新的超参数 (gamma): 需要调整衰减率 (gamma)。

概念性代码说明 (Python-like pseudocode for RMSProp):

# 假设 parameters 是模型参数的字典
# 假设 gradients 是对应参数的梯度字典
# 假设 learning_rate 是全局学习率,例如 0.001
# 假设 decay_rate_gamma 是衰减率,例如 0.9
# 假设 accumulated_squared_gradients_ewma 是与 parameters 结构相同,用于存储梯度平方的EWMA,初始为全零
# 假设 epsilon 是一个小常数,例如 1e-7

# 在训练循环的每次参数更新时:
# gradients = compute_gradients(parameters, mini_batch_data, mini_batch_labels) # 计算当前小批量的梯度

for param_key in parameters: # 遍历所有参数
    # 更新梯度平方的EWMA: E[g^2]_t = gamma * E[g^2]_{t-1} + (1-gamma) * g_t^2
    accumulated_squared_gradients_ewma[param_key] = decay_rate_gamma * accumulated_squared_gradients_ewma[param_key] + 
                                                    (1 - decay_rate_gamma) * (gradients[param_key] ** 2) # 更新梯度平方的EWMA
    
    # 计算调整后的学习率 adjusted_lr = lr / (sqrt(E[g^2]_t) + epsilon)
    adjusted_learning_rate = learning_rate / (np.sqrt(accumulated_squared_gradients_ewma[param_key]) + epsilon) # 计算该参数的自适应学习率
    
    # 更新参数 theta_{t+1} = theta_t - adjusted_lr * g_t
    parameters[param_key] -= adjusted_learning_rate * gradients[param_key] # 使用自适应学习率更新参数

如何解决挑战:

AdaGrad学习率过早停止: RMSProp的核心改进点。
不同参数需要不同学习率: 继承了AdaGrad的优点。
峡谷地带: 由于学习率自适应,可以在梯度变化剧烈的方向上减小步长,在平缓方向上保持或增大学习率(相对而言),有助于导航。

E. AdaDelta

动机与思想:
AdaDelta 是 RMSProp 的一个变种,也旨在解决 AdaGrad 学习率衰减过快的问题。它与 RMSProp 非常相似,但有一个关键区别:AdaDelta 完全移除了全局学习率 (eta) 这个超参数。

它不仅维护了梯度平方的EWMA(与RMSProp类似),还维护了一个参数更新量平方的EWMA。参数更新的幅度由这两个EWMA的比值决定。

更新规则:

与RMSProp一样,计算梯度平方的EWMA (E[g^2]_t):
[ E[g^2]t =
ho E[g^2]
{t-1} + (1-
ho) (g_t odot g_t) ]
((
ho) 相当于RMSProp中的 (gamma))

计算参数更新 (Delta heta_t):
[ Delta heta_t = – frac{ ext{RMS}[Delta heta]_{t-1}}{ ext{RMS}[g]_t} g_t ]
其中:

( ext{RMS}[x]_t = sqrt{E[x^2]_t + epsilon})
(E[Delta heta^2]t) 是参数更新量平方的EWMA:
[ E[Delta heta^2]t =
ho E[Delta heta^2]
{t-1} + (1-
ho) (Delta heta_t odot Delta heta_t) ]
注意这里 (Delta heta_t) 的计算依赖于 (E[Delta heta^2]
{t-1})。
在第一次迭代时,(E[Delta heta^2]_0) 通常设为0。

参数更新 ( heta_{t+1}):
[ heta_{t+1} = heta_t + Delta heta_t ]
(注意这里是加号,因为 (Delta heta_t) 的计算中已经包含了负号)

AdaDelta 的巧妙之处在于,通过单位分析,可以发现 (frac{ ext{RMS}[Delta heta]}{ ext{RMS}[g]}) 的单位与 (frac{1}{ ext{gradient}}) 的单位相反,因此它扮演了类似学习率的角色,但这个“学习率”是动态计算出来的,不需要手动设置。

优点:

不需要设置全局学习率: 这是其最吸引人的特点。
缓解AdaGrad学习率衰减过快的问题: 与RMSProp类似。

缺点:

在实践中,有时其性能可能不如带有精心调整学习率的RMSProp或Adam。
仍然有超参数 (
ho) 和 (epsilon) 需要设置。
在训练初期,由于 (E[Delta heta^2]_{t-1}) 较小,更新步长可能也较小,导致学习缓慢。

概念性代码说明 (Python-like pseudocode for AdaDelta):

# 假设 parameters 是模型参数的字典
# 假设 gradients 是对应参数的梯度字典
# 假设 decay_rate_rho 是衰减率,例如 0.95
# 假设 accumulated_squared_gradients_ewma (E[g^2]),初始为全零
# 假设 accumulated_squared_updates_ewma (E[delta_theta^2]),初始为全零
# 假设 epsilon 是一个小常数,例如 1e-6

# 在训练循环的每次参数更新时:
# gradients = compute_gradients(parameters, mini_batch_data, mini_batch_labels) # 计算当前小批量的梯度

for param_key in parameters: # 遍历所有参数
    # 1. 更新梯度平方的EWMA: E[g^2]_t = rho * E[g^2]_{t-1} + (1-rho) * g_t^2
    accumulated_squared_gradients_ewma[param_key] = decay_rate_rho * accumulated_squared_gradients_ewma[param_key] + 
                                                    (1 - decay_rate_rho) * (gradients[param_key] ** 2) # 更新E[g^2]
    
    # 2. 计算RMS of previous updates: RMS[delta_theta]_{t-1}
    rms_prev_updates = np.sqrt(accumulated_squared_updates_ewma[param_key] + epsilon) # 计算历史更新量的RMS
    
    # 3. 计算RMS of current gradients: RMS[g]_t
    rms_current_gradients = np.sqrt(accumulated_squared_gradients_ewma[param_key] + epsilon) # 计算当前梯度平方EWMA的RMS
    
    # 4. 计算当前更新量: delta_theta_t = - (RMS[delta_theta]_{t-1} / RMS[g]_t) * g_t
    delta_theta = - (rms_prev_updates / rms_current_gradients) * gradients[param_key] # 计算参数更新量
                                                                                      # 注意,在第一次迭代时 rms_prev_updates 会很小,可能导致更新很小
    
    # 5. 更新参数: theta_{t+1} = theta_t + delta_theta_t
    parameters[param_key] += delta_theta # 应用参数更新
    
    # 6. 更新参数更新量平方的EWMA: E[delta_theta^2]_t = rho * E[delta_theta^2]_{t-1} + (1-rho) * (delta_theta_t)^2
    accumulated_squared_updates_ewma[param_key] = decay_rate_rho * accumulated_squared_updates_ewma[param_key] + 
                                                   (1 - decay_rate_rho) * (delta_theta ** 2) # 更新E[delta_theta^2]

如何解决挑战:

学习率选择: 主要设计目标,移除了全局学习率。
AdaGrad学习率过早停止: 同样解决了这个问题。

F. Adam (Adaptive Moment Estimation)

动机与思想:
Adam 可以看作是动量方法和RMSProp的结合体。它既像动量方法一样记录了梯度的一阶矩(均值,即梯度的EWMA),也像RMSProp一样记录了梯度的二阶矩(未中心化的方差,即梯度平方的EWMA)。然后,它利用这两个矩估计来为每个参数计算自适应的学习率。

Adam 通常被认为是目前深度学习中效果最好、应用最广泛的优化器之一,因为它通常能提供快速的收敛和良好的性能,并且对超参数的选择相对不那么敏感。

更新规则:

在第 (t) 次迭代时,计算当前小批量的梯度: (g_t =
abla L( heta_t))
更新梯度的一阶矩估计 (EWMA of gradients, (m_t)):
[ m_t = eta_1 m_{t-1} + (1-eta_1) g_t ]
((m_0) 初始化为0。 (eta_1) 是超参数,常取0.9)
更新梯度的二阶矩估计 (EWMA of squared gradients, (v_t)):
[ v_t = eta_2 v_{t-1} + (1-eta_2) (g_t odot g_t) ]
((v_0) 初始化为0。 (eta_2) 是超参数,常取0.999)
偏差修正 (Bias Correction):
由于 (m_0) 和 (v_0) 初始化为0,在训练初期,(m_t) 和 (v_t) 会偏向于0,尤其当 (eta_1) 和 (eta_2) 接近1时。为了修正这种偏差,Adam计算了偏差校正后的一阶矩和二阶矩:
[ hat{m}_t = frac{m_t}{1 – eta_1^t} ]
[ hat{v}_t = frac{v_t}{1 – eta_2^t} ]
其中 (t) 是迭代次数。当 (t) 增大时,(eta_1^t) 和 (eta_2^t) 趋近于0,修正作用减弱。
参数更新 ( heta_{t+1}):
[ heta_{t+1} = heta_t – frac{eta}{sqrt{hat{v}_t} + epsilon} hat{m}_t ]
其中:

(eta) 是学习率 (Adam论文中称为 (alpha)),常取0.001。
(epsilon) 是平滑项,常取 (10^{-8})。

优点:

结合了动量和RMSProp的优点: 既能加速收敛,又能自适应调整学习率。
计算高效: 只需要存储一阶和二阶矩向量。
内存需求小
对梯度的缩放不变: 更新规则中的分母项起到了归一化的作用。
通常对超参数的选择不那么敏感: 默认参数((eta=0.001, eta_1=0.9, eta_2=0.999, epsilon=10^{-8}))在很多情况下表现良好。
非常适合于参数量大或数据量大的问题

缺点:

尽管声称对超参数不敏感,但在某些特定问题上,调整 (eta_1, eta_2) 和 (eta) 仍然可能带来性能提升。
有研究指出,在某些情况下,Adam可能收敛到次优解,或者其泛化能力不如带动量的SGD(尤其是在精调学习率和衰减策略后)。
偏差修正项在非常长的训练中((t) 极大)可能导致 (1-eta^t) 非常接近1,数值上可能出现问题(尽管不常见)。

概念性代码说明 (Python-like pseudocode for Adam):

# 假设 parameters 是模型参数的字典
# 假设 gradients 是对应参数的梯度字典
# 假设 learning_rate_eta (alpha in paper) 是学习率,例如 0.001
# 假设 beta1 是第一个动量项的衰减率,例如 0.9
# 假设 beta2 是第二个动量项(梯度平方)的衰减率,例如 0.999
# 假设 epsilon 是一个小常数,例如 1e-8
# 假设 m_moment1 (first moment vector) 与 parameters 结构相同,初始为全零
# 假设 v_moment2 (second moment vector) 与 parameters 结构相同,初始为全零
# 假设 t 是当前的迭代次数(从1开始)

# 在训练循环的每次参数更新时:
# t += 1 # 迭代次数加1
# gradients = compute_gradients(parameters, mini_batch_data, mini_batch_labels) # 计算当前小批量的梯度

for param_key in parameters: # 遍历所有参数
    # 1. 更新一阶矩估计 (动量): m_t = beta1 * m_{t-1} + (1-beta1) * g_t
    m_moment1[param_key] = beta1 * m_moment1[param_key] + (1 - beta1) * gradients[param_key] # 更新一阶矩
    
    # 2. 更新二阶矩估计 (梯度平方的EWMA): v_t = beta2 * v_{t-1} + (1-beta2) * (g_t)^2
    v_moment2[param_key] = beta2 * v_moment2[param_key] + (1 - beta2) * (gradients[param_key] ** 2) # 更新二阶矩
    
    # 3. 计算偏差修正后的一阶矩: m_hat_t = m_t / (1 - beta1^t)
    m_hat = m_moment1[param_key] / (1 - beta1**t) # 计算修正后的一阶矩
                                                 # 注意: t 是迭代次数,需要从1开始
    
    # 4. 计算偏差修正后的二阶矩: v_hat_t = v_t / (1 - beta2^t)
    v_hat = v_moment2[param_key] / (1 - beta2**t) # 计算修正后的二阶矩
    
    # 5. 更新参数: theta_{t+1} = theta_t - (eta / (sqrt(v_hat_t) + epsilon)) * m_hat_t
    parameters[param_key] -= (learning_rate_eta / (np.sqrt(v_hat) + epsilon)) * m_hat # 更新参数

如何解决挑战:

学习率选择: Adam自适应学习率,并对初始学习率不那么敏感。
不同参数需要不同学习率: 通过二阶矩实现。
峡谷、局部最优、鞍点: 结合了一阶动量的加速和二阶动量的自适应调整,通常能更有效地导航复杂地形。

G. 其他值得注意的优化器

除了上述主流优化器,还有一些其他的变种和改进值得一提:

AdamW:

动机: Adam中的L2正则化(权重衰减)通常是直接加在损失函数中,这导致权重衰减的效果受到自适应学习率的影响。AdamW提出将权重衰减与梯度更新解耦,直接在参数更新步骤中减去一个与学习率无关的衰减项。
形式: ( heta_{t+1} = heta_t – eta (frac{hat{m}_t}{sqrt{hat{v}_t} + epsilon} + lambda heta_t)) (其中 (lambda) 是权重衰减系数)
优势: 通常能带来更好的泛化性能,尤其是在需要较强正则化的任务中。目前在很多Transformer类的模型中是首选。

Nadam (Nesterov-accelerated Adaptive Moment Estimation):

动机: 将Nesterov动量的思想融入Adam。
做法: 在Adam的一阶矩估计中引入Nesterov的“向前看”机制。
优势: 有时能在Adam的基础上进一步提升性能。

AMSGrad:

动机: 指出Adam在某些情况下(例如,当 (hat{v}_t) 突然变小时)可能导致有效学习率突然增大,从而无法收敛到最优解。AMSGrad通过维护历史 ( hat{v}_t ) 的最大值来保证学习率是单调不增的(在分母部分)。
更新 (v_t) 的方式不同: (hat{v}t^{AMS} = max(hat{v}{t-1}^{AMS}, hat{v}_t))
优势: 理论上收敛性更好,但实际效果有时与Adam相当或略差,且增加了计算。

Lookahead Optimizer:

动机: 一种优化器包装器,它维护两组权重:“慢权重”和“快权重”。快权重由内部优化器(如SGD或Adam)更新 (k) 步,然后慢权重朝向这 (k) 步后的快权重位置更新一小步。
优势: 通常能提高训练稳定性和最终性能,减少方差。

2.3.5 如何选择优化器?

没有一个优化器能在所有问题上都表现最佳。选择优化器通常取决于:

数据集的特性: 数据是否稀疏?特征尺度差异大吗?
模型的复杂度: 网络深不深?参数多不多?
对收敛速度的要求: 是否需要快速原型验证?
对最终性能的要求: 是否追求极致的准确率?
计算资源: 内存和计算能力。
超参数调整的容忍度: 是否有时间和资源去精细调整多个超参数?

一般性建议:

Adam 通常是一个很好的起点: 它的默认参数在很多情况下效果不错,收敛较快。对于大多数常见任务,Adam是一个稳健的选择。
SGD + Momentum (和Nesterov Momentum): 如果对最终性能有极致追求,并且有时间和经验去仔细调整学习率、学习率调度策略和动量参数,SGD+Momentum有时能达到比Adam更好的泛化性能,尤其是在计算机视觉领域。
AdamW: 如果模型中使用了权重衰减(L2正则化),AdamW通常比标准Adam效果更好。
对于稀疏数据: AdaGrad、RMSProp、AdaDelta、Adam这类自适应学习率的方法通常表现更好。
尝试多种优化器: 如果时间和资源允许,可以实验几种不同的优化器,看看哪个在你的特定任务上表现最好。
学习率调度 (Learning Rate Scheduling): 无论选择哪种优化器,配合一个合适的学习率调度策略(例如,在训练过程中逐步降低学习率,如Step Decay, Cosine Annealing, Exponential Decay等)通常对提升性能至关重要。我们将在后续章节详细讨论学习率调度。

优化器的发展仍在继续,新的算法和改进不断涌现。理解它们的核心思想和权衡,可以帮助我们更好地选择和使用这些强大的工具来训练深度学习模型。

第三章:训练深度神经网络的关键技术

3.1 权重初始化 (Weight Initialization)

3.1.1 为什么权重初始化很重要?

在训练神经网络之前,我们需要为网络的参数(权重 (w) 和偏置 (b))赋予初始值。这个初始化的过程看似简单,但实际上对模型的训练速度、收敛性以及最终性能有着深远的影响。

打破对称性 (Breaking Symmetry):
如果一个层中的所有权重都初始化为相同的值(例如全0或全为某个常数),那么在反向传播过程中,这些权重对应的梯度也会是相同的。这意味着在后续的迭代中,这些权重将以相同的方式更新,它们将永远保持相同的值。这样一来,网络中的多个神经元就会学习到完全相同的特征,大大降低了模型的表达能力,使得网络等效于只有一个神经元的浅层网络。因此,权重初始化必须能够打破这种对称性,使得不同的神经元可以学习到不同的特征。通常通过引入随机性来实现。

避免梯度消失与梯度爆炸 (Avoiding Vanishing/Exploding Gradients):
正如我们之前讨论过的,在深层网络中,梯度在反向传播时会逐层连乘。如果初始权重的尺度不合适,可能会导致梯度信号在传播过程中指数级衰减(梯度消失)或指数级增长(梯度爆炸)。

梯度消失: 如果权重过小,梯度信号逐层减弱,导致靠近输入层的网络层参数几乎不更新,模型难以学习。这在使用Sigmoid或Tanh等饱和型激活函数时尤为突出。
梯度爆炸: 如果权重过大,梯度信号逐层放大,导致参数更新过大,模型训练不稳定,甚至出现NaN值。
一个好的权重初始化策略应该使得网络各层激活值的方差和梯度的方差在传播过程中保持在一个合理的范围内,既不太大也不太小。

加速收敛 (Speeding Up Convergence):
合适的权重初始化可以将模型参数置于一个“良好”的初始区域,使得损失函数的梯度较大且指向正确的方向,从而帮助优化算法更快地找到最优解或一个好的局部最优解。

影响最终性能 (Impacting Final Performance):
糟糕的初始化可能导致模型陷入差的局部最小值,或者训练过程非常缓慢,最终影响模型的泛化能力。

3.1.2 不良的初始化策略及其后果

A. 全部初始化为零 (Initializing All Weights to Zero)

做法: 将所有权重 (w_{ij}) 和偏置 (b_i) 都设置为0。

问题:

对称性问题: 如前所述,所有神经元将学习到相同的特征。如果使用ReLU等非对称激活函数,输入为0时输出也为0,梯度也为0(对于正半轴的ReLU),导致神经元“死亡”(dead neuron),永远无法被激活。
无法学习: 如果权重为0,激活函数的输入(加权和)通常也为0。对于很多激活函数(如Sigmoid在0附近是线性的,Tanh在0附近也是线性的),这可能使得网络在初始阶段表现得像一个线性模型。对于ReLU,输入为0则输出为0,梯度也为0,神经元无法学习。

概念性代码 (PyTorch-like):

import torch
import torch.nn as nn

# 定义一个简单的线性层
# in_features: 输入特征数量, out_features: 输出特征数量
linear_layer = nn.Linear(in_features=10, out_features=5) 

# 将权重和偏置都初始化为0
nn.init.zeros_(linear_layer.weight) # 使用下划线结尾的函数表示inplace操作,直接修改张量的值
nn.init.zeros_(linear_layer.bias)

print("权重:
", linear_layer.weight) # 打印权重
print("偏置:
", linear_layer.bias)   # 打印偏置

# 模拟前向传播
input_tensor = torch.randn(1, 10) # 创建一个随机输入张量,batch_size=1, num_features=10
output_tensor = linear_layer(input_tensor) # 进行前向传播
print("输出:
", output_tensor) # 打印输出,此时因为权重和偏置都为0,输出也为0(除非输入包含NaN或Inf)

这段代码展示了如何将一个线性层的权重和偏置都初始化为0。可以看到,如果权重和偏置都为0,输出也将是0(假设输入是正常的数值)。

B. 初始化为较大的随机值 (Initializing with Large Random Values)

做法: 从一个均值为0、标准差较大的高斯分布(例如 (mathcal{N}(0, 1.0)))中随机采样权重。

问题:

梯度爆炸: 如果权重值过大,在深层网络中,激活值和梯度在传播时可能会指数级增长。
激活函数饱和: 对于Sigmoid和Tanh这类激活函数,较大的输入值会使其落入饱和区(输出接近-1, 0或1),在这些区域梯度非常小(接近0)。这会导致梯度消失,使得网络难以学习。

概念性代码 (PyTorch-like):

import torch
import torch.nn as nn
import matplotlib.pyplot as plt
import numpy as np

# 定义一个简单的多层感知机
hidden_size = 100 # 隐藏层神经元数量
num_layers = 10   # 网络层数

layers = [] # 用于存储网络层的列表
for _ in range(num_layers): # 循环创建多个线性层和Tanh激活层
    layer = nn.Linear(hidden_size, hidden_size) # 定义线性层
    # 初始化为标准差较大的高斯分布 N(0, 1.0)
    nn.init.normal_(layer.weight, mean=0.0, std=1.0) # 使用均值为0,标准差为1.0的正态分布初始化权重
    nn.init.zeros_(layer.bias) # 偏置初始化为0
    layers.append(layer) # 添加线性层
    layers.append(nn.Tanh()) # 添加Tanh激活函数

model = nn.Sequential(*layers) # 使用Sequential容器构建模型

# 模拟激活值在前向传播过程中的分布
input_tensor = torch.randn(500, hidden_size) # 创建一个包含500个样本的输入张量
activation_values = {
            } # 用于存储每一层激活值的字典

def hook_fn(module, input, output, layer_name): # 定义钩子函数,用于获取中间层的输出
    activation_values[layer_name] = output.detach().cpu().numpy() # 保存激活值

# 注册钩子
for i, layer in enumerate(model): # 遍历模型中的所有层
    if isinstance(layer, nn.Tanh): # 如果是Tanh激活层
        layer.register_forward_hook(lambda module, input, output, name=f'tanh_{
              i//2}': hook_fn(module, input, output, name)) # 注册前向传播钩子

with torch.no_grad(): # 在不计算梯度的模式下进行前向传播
    _ = model(input_tensor) # 执行前向传播

# 绘制激活值分布
plt.figure(figsize=(15, 5)) # 创建一个图形窗口
for i, (name, values) in enumerate(activation_values.items()): # 遍历存储的激活值
    plt.subplot(1, len(activation_values), i + 1) # 创建子图
    plt.title(name) # 设置子图标题
    plt.hist(values.flatten(), bins=50, range=(-1, 1)) # 绘制激活值的直方图,范围在-1到1之间
    plt.yticks([]) # 隐藏y轴刻度
plt.suptitle("Activations with Large Random Initialization (std=1.0) using Tanh") # 设置总标题
plt.tight_layout(rect=[0, 0, 1, 0.96]) # 调整布局
plt.show() # 显示图形

# 观察:可以看到,随着层数加深,Tanh激活函数的输出几乎全部集中在-1和1附近,表明神经元饱和,梯度会非常小。

上述代码通过一个简单的多层网络演示了使用较大标准差(如1.0)初始化权重时,Tanh激活函数的输出会迅速饱和到-1或1。这意味着这些神经元的梯度将非常接近于0,导致学习停滞。

C. 初始化为较小的随机值 (Initializing with Small Random Values)

做法: 从一个均值为0、标准差非常小的高斯分布(例如 (mathcal{N}(0, 0.01)))中随机采样权重。

问题:

梯度消失: 如果权重值过小,在深层网络中,激活值和梯度在传播时可能会指数级衰减,导致靠近输入层的网络无法有效学习。尤其是在乘性操作较多的网络中(如RNN)。
激活值集中在0附近: 激活函数的输出会非常接近于0。对于Sigmoid和Tanh,这会使它们工作在线性区域,削弱了网络的非线性表达能力。对于ReLU,如果输入持续为负(即使是很小的负值),输出也为0,梯度也为0,导致神经元“死亡”。

概念性代码 (PyTorch-like):

import torch
import torch.nn as nn
import matplotlib.pyplot as plt
import numpy as np

hidden_size = 100
num_layers = 10

layers_small_init = []
for _ in range(num_layers):
    layer = nn.Linear(hidden_size, hidden_size)
    # 初始化为标准差较小的高斯分布 N(0, 0.01)
    nn.init.normal_(layer.weight, mean=0.0, std=0.01) # 使用均值为0,标准差为0.01的正态分布初始化权重
    nn.init.zeros_(layer.bias)
    layers_small_init.append(layer)
    layers_small_init.append(nn.Tanh()) # 仍然使用Tanh,观察激活值是否趋向于0

model_small_init = nn.Sequential(*layers_small_init)

activation_values_small = {
            }
def hook_fn_small(module, input, output, layer_name):
    activation_values_small[layer_name] = output.detach().cpu().numpy()

for i, layer in enumerate(model_small_init):
    if isinstance(layer, nn.Tanh):
        layer.register_forward_hook(lambda module, input, output, name=f'tanh_{
              i//2}': hook_fn_small(module, input, output, name))

with torch.no_grad():
    _ = model_small_init(input_tensor) # 使用与之前相同的input_tensor

plt.figure(figsize=(15, 5))
for i, (name, values) in enumerate(activation_values_small.items()):
    plt.subplot(1, len(activation_values_small), i + 1)
    plt.title(name)
    # 注意这里调整了直方图的范围,因为激活值会非常小
    plt.hist(values.flatten(), bins=50, range=(-0.5, 0.5)) # 将直方图范围调整为-0.5到0.5
    plt.yticks([])
plt.suptitle("Activations with Small Random Initialization (std=0.01) using Tanh")
plt.tight_layout(rect=[0, 0, 1, 0.96])
plt.show()

# 观察:可以看到,随着层数加深,Tanh激活函数的输出迅速趋向于0。这意味着信号在网络中逐层衰减。

这段代码展示了使用较小标准差(如0.01)初始化权重时,Tanh激活函数的输出会迅速趋向于0。这意味着信号强度在网络中逐层减弱,可能导致梯度消失。

这些例子说明了不恰当的权重初始化会导致训练过程出现严重问题。因此,我们需要更智能的初始化策略。

3.1.3 现代权重初始化方法

现代的权重初始化方法旨在使网络各层激活值的方差和梯度的方差在传播过程中保持稳定,从而避免梯度消失/爆炸,并加速训练。这些方法通常会考虑每层的输入单元数 (fan-in) 和输出单元数 (fan-out)。

A. Xavier/Glorot 初始化 (Xavier/Glorot Initialization)

提出: 由 Xavier Glorot 和 Yoshua Bengio 在2010年的论文《Understanding the difficulty of training deep feedforward neural networks》中提出。

目标: 使得每一层输出的激活值的方差与输入的方差保持一致,并且反向传播时梯度的方差也保持一致。

假设:

激活函数在0点附近是线性的(例如 Tanh 或 Softsign)。
初始权重和输入数据都是均值为0。

推导核心思想:
考虑一个线性神经元 (y = w_1 x_1 + w_2 x_2 + … + w_{n_{in}} x_{n_{in}} + b)。
假设输入 (x_i) 和权重 (w_i) 相互独立,且均值为0。偏置 (b) 通常初始化为0。
则 (y) 的方差 ( ext{Var}(y)) 为:
[ ext{Var}(y) = ext{Var}(sum_i w_i x_i) = sum_i ext{Var}(w_i x_i) ]
由于 (E[w_i]=0) 和 (E[x_i]=0),且它们独立,所以 (E[w_i x_i] = E[w_i]E[x_i] = 0)。
因此 ( ext{Var}(w_i x_i) = E[(w_i x_i)^2] – (E[w_i x_i])^2 = E[w_i^2 x_i^2] = E[w_i^2] E[x_i^2])。
又因为 ( ext{Var}(w_i) = E[w_i^2] – (E[w_i])^2 = E[w_i^2]) 和 ( ext{Var}(x_i) = E[x_i^2])。
所以 ( ext{Var}(w_i x_i) = ext{Var}(w_i) ext{Var}(x_i))。 (这里假设输入和权重都是中心化的,或者严格来说是 E[w2]E[x2])
修正一下:( ext{Var}(w_i x_i) = E[w_i2]E[x_i2] – (E[w_i]E[x_i])^2 = ( ext{Var}(w_i) + E[w_i]^2)( ext{Var}(x_i) + E[x_i]^2) – (E[w_i]E[x_i])^2)。
若 (E[w_i]=0) 且 (E[x_i]=0),则 ( ext{Var}(w_i x_i) = ext{Var}(w_i) ext{Var}(x_i))。
所以:
[ ext{Var}(y) = sum_i ext{Var}(w_i) ext{Var}(x_i) ]
如果所有 (x_i) 有相同的方差 ( ext{Var}(x)),所有 (w_i) 有相同的方差 ( ext{Var}(w)),则:
[ ext{Var}(y) = n_{in} ext{Var}(w) ext{Var}(x) ]
为了使输出方差 ( ext{Var}(y)) 等于输入方差 ( ext{Var}(x)) (即 ( ext{Var}(y) = ext{Var}(x))),我们需要:
[ n_{in} ext{Var}(w) = 1 implies ext{Var}(w) = frac{1}{n_{in}} ]
类似地,在反向传播时,为了使梯度的方差保持不变,需要:
[ n_{out} ext{Var}(w) = 1 implies ext{Var}(w) = frac{1}{n_{out}} ]
其中 (n_{in}) 是该层输入的神经元数量 (fan-in),(n_{out}) 是该层输出的神经元数量 (fan-out)。

为了同时满足这两个条件,Xavier初始化采用了一个折中的方案:
[ ext{Var}(w) = frac{2}{n_{in} + n_{out}} ]

初始化方法:

Xavier 均匀分布 (Xavier Uniform):
从均匀分布 (U[-a, a]) 中采样权重,其中 (a = sqrt{frac{6}{n_{in} + n_{out}}})。
(对于均匀分布 (U[-a, a]),其方差为 (a^2/3)。所以 (a^2/3 = frac{2}{n_{in} + n_{out}} implies a^2 = frac{6}{n_{in} + n_{out}}))
Xavier 正态分布 (Xavier Normal):
从均值为0,标准差为 (sigma = sqrt{frac{2}{n_{in} + n_{out}}}) 的高斯分布 (mathcal{N}(0, sigma^2)) 中采样权重。

适用激活函数:
Xavier初始化主要适用于对称的、在0点附近近似线性的激活函数,如 Tanh、Sigmoid(尽管Sigmoid的均值不是0,但早期也被配合使用)、Softsign。它对于ReLU这类非对称激活函数效果不佳。

概念性代码 (PyTorch-like):

import torch
import torch.nn as nn
import math

# 假设一个线性层
fan_in, fan_out = 100, 50 # 输入100个特征,输出50个特征
linear_layer_xavier = nn.Linear(fan_in, fan_out)

# Xavier Uniform 初始化
xavier_uniform_std = math.sqrt(2.0 / (fan_in + fan_out)) # 这是用于正态分布的标准差,均匀分布的界限是 sqrt(6 / (fan_in + fan_out))
bound = math.sqrt(6.0 / (fan_in + fan_out)) # 计算均匀分布的边界
nn.init.uniform_(linear_layer_xavier.weight, -bound, bound) # 使用Xavier均匀分布初始化权重
nn.init.zeros_(linear_layer_xavier.bias) # 偏置通常初始化为0
print(f"Xavier Uniform initialized weight (sample):
{
              linear_layer_xavier.weight[0, :5]}") # 打印部分权重

# Xavier Normal 初始化
xavier_normal_std = math.sqrt(2.0 / (fan_in + fan_out)) # 计算Xavier正态分布的标准差
nn.init.normal_(linear_layer_xavier.weight, mean=0.0, std=xavier_normal_std) # 使用Xavier正态分布初始化权重
nn.init.zeros_(linear_layer_xavier.bias) # 偏置初始化为0
print(f"Xavier Normal initialized weight (sample):
{
              linear_layer_xavier.weight[0, :5]}") # 打印部分权重

# PyTorch内置的Xavier初始化函数
# 对于Xavier Uniform:
nn.init.xavier_uniform_(linear_layer_xavier.weight) # gain参数默认为1,适用于Tanh, Sigmoid
# 对于Xavier Normal:
nn.init.xavier_normal_(linear_layer_xavier.weight) # gain参数默认为1

B. He/Kaiming 初始化 (He/Kaiming Initialization)

提出: 由 Kaiming He 等人在2015年的论文《Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification》中提出,专门为 ReLU 及其变体(如 Leaky ReLU, PReLU)设计。

动机: Xavier 初始化假设激活函数是线性的且对称的。然而,ReLU (f(x) = max(0, x)) 是非线性的,并且会使得一半的输入(负值部分)变为0,这改变了激活值方差的传播方式。

推导核心思想:
对于ReLU,如果输入 (x) 均值为0,那么输出 (y = ext{ReLU}(x)) 的均值不再是0。
He 等人分析了ReLU网络中激活值方差的传播。当输入 (x) 来自一个均值为0的对称分布时,(y = ext{ReLU}(x)) 的方差大约是 ( ext{Var}(y) approx frac{1}{2} ext{Var}(x))。
(更精确地,如果 (x sim mathcal{N}(0, sigma_x^2)),则 (E[ReLU(x)^2] = frac{1}{2}E[x^2] = frac{1}{2}sigma_x^2)。由于 (E[ReLU(x)] = sigma_x/sqrt{2pi}),所以 ( ext{Var}(ReLU(x)) = E[ReLU(x)^2] – (E[ReLU(x)])^2 = (frac{1}{2} – frac{1}{2pi})sigma_x^2 approx 0.34 sigma_x^2)。但论文中的推导是基于 (E[w_i]=0),并假设在前向传播中每层有一半的输出为0)。
在前向传播中,( ext{Var}(y_l) = n_{in,l} ext{Var}(w_l) E[x_{l-1}^2])。如果 (x_{l-1}) 是上一层ReLU的输出,则 (E[x_{l-1}^2] = ext{Var}(x_{l-1})) (因为ReLU输出非负,如果均值不为0,这个等式不精确,但论文推导时假设了这一点或者使用了近似)。
为了使 ( ext{Var}(y_l) = ext{Var}(x_{l-1})),需要 (n_{in,l} ext{Var}(w_l) imes frac{1}{2} = 1) (因为ReLU使方差减半)。
所以,在前向传播中保持方差不变,需要:
[ ext{Var}(w) = frac{2}{n_{in}} ]
论文中没有显式推导反向传播的情况,但这个初始化主要已关注前向传播的方差稳定性,因为它对ReLU的“死亡神经元”问题更敏感。

初始化方法:

He 均匀分布 (Kaiming Uniform):
从均匀分布 (U[-a, a]) 中采样权重,其中 (a = sqrt{frac{6}{n_{in}}})。
((a^2/3 = frac{2}{n_{in}} implies a^2 = frac{6}{n_{in}}))
He 正态分布 (Kaiming Normal):
从均值为0,标准差为 (sigma = sqrt{frac{2}{n_{in}}}) 的高斯分布 (mathcal{N}(0, sigma^2)) 中采样权重。

适用激活函数:
ReLU 及其变体 (Leaky ReLU, PReLU, ELU 等)。

概念性代码 (PyTorch-like):

import torch
import torch.nn as nn
import math

fan_in, fan_out = 100, 50 # 假设一个线性层
linear_layer_he = nn.Linear(fan_in, fan_out)

# He Uniform 初始化 (Kaiming Uniform)
# std_he_uniform_equivalent_normal = math.sqrt(2.0 / fan_in) # 正态分布的等效std
bound_he = math.sqrt(6.0 / fan_in) # 计算均匀分布的边界
nn.init.uniform_(linear_layer_he.weight, -bound_he, bound_he) # 使用He均匀分布初始化权重
nn.init.zeros_(linear_layer_he.bias) # 偏置通常初始化为0
print(f"He Uniform initialized weight (sample):
{
              linear_layer_he.weight[0, :5]}")

# He Normal 初始化 (Kaiming Normal)
std_he_normal = math.sqrt(2.0 / fan_in) # 计算He正态分布的标准差
nn.init.normal_(linear_layer_he.weight, mean=0.0, std=std_he_normal) # 使用He正态分布初始化权重
nn.init.zeros_(linear_layer_he.bias)
print(f"He Normal initialized weight (sample):
{
              linear_layer_he.weight[0, :5]}")

# PyTorch内置的Kaiming初始化函数
# 对于Kaiming Uniform:
# mode='fan_in' (默认) 使用 n_in, mode='fan_out' 使用 n_out
# nonlinearity='relu' (默认) 对应 gain = sqrt(2)
nn.init.kaiming_uniform_(linear_layer_he.weight, mode='fan_in', nonlinearity='relu')
# 对于Kaiming Normal:
nn.init.kaiming_normal_(linear_layer_he.weight, mode='fan_in', nonlinearity='relu')

PyTorch的 kaiming_uniform_kaiming_normal_ 函数还包含一个 nonlinearity 参数(默认为 ‘relu’)和一个 a 参数(对于LeakyReLU,是负斜率)。它们内部会根据激活函数计算一个 gain 值,然后实际的方差是 gain^2 / n_mode

对于ReLU, gain = sqrt(2),所以 ( ext{Var}(w) = (sqrt{2})^2 / n_{in} = 2/n_{in})。
对于Tanh或Sigmoid, gain = 1,此时Kaiming初始化就退化成了Xavier初始化(如果mode选择为平均fan_in和fan_out,或者只考虑fan_in/fan_out时)。
对于LeakyReLU, gain = sqrt{2 / (1 + ext{negative_slope}^2)}

C. 理解 gain 参数

在PyTorch的 xavier_*kaiming_* 初始化函数中,都有一个 gain 参数。这个 gain 值是用来根据所使用的非线性激活函数来调整权重的标准差的。
其思想是,如果激活函数 (f) 使得其输入的方差缩放了一个因子 (c),即 ( ext{Var}(f(x)) approx c cdot ext{Var}(x)),那么为了补偿这种缩放,权重的方差应该乘以 (1/c)。
gain 通常被定义为 (sqrt{1/c}) 的某种形式,或者更直接地,使得与激活函数一起使用时能够保持信号的方差。

例如:

对于线性激活函数或不进行激活,gain = 1
对于Sigmoid,gain = 1 (Xavier论文中建议)。
对于Tanh,gain = 1 (Xavier论文中建议,有些实现会用 5/3,但PyTorch默认为1)。
对于ReLU,gain = sqrt(2)
对于LeakyReLU,gain = sqrt(2 / (1 + negative_slope^2))

所以,更通用的初始化方差(例如对于He正态)可以写成:
[ ext{Var}(w) = frac{ ext{gain}^2}{n_{ ext{mode}}} ]
其中 (n_{ ext{mode}}) 可以是 (n_{in}) (fan-in mode), (n_{out}) (fan-out mode), 或 ((n_{in} + n_{out})/2) (Xavier)。

3.1.4 初始化偏置 (Initializing Biases)

通常初始化为0: 这是最常见的做法,因为权重已经通过随机初始化打破了对称性。如果权重初始化得当,偏置为0通常不会引入问题。
特殊情况:

ReLU的偏置: 有时,为了防止ReLU单元在初始时就“死亡”(即所有输入都为负,导致输出为0),可以将偏置初始化为一个小的正值(例如0.01或0.1)。这可以鼓励ReLU单元在训练开始时就被激活。然而,配合He初始化和现代优化器,这种做法的必要性降低了。
循环神经网络 (RNN) 中的遗忘门偏置: 在LSTM或GRU等RNN中,遗忘门的偏置通常被初始化为一个较大的正值(例如1.0或更高)。这样做是为了鼓励模型在训练初期“记住”更多的信息,防止梯度在时间序列上传播时过早消失。

3.1.5 实践中的选择与总结

对于ReLU及其变体 (LeakyReLU, PReLU, ELU): He/Kaiming 初始化是首选。
对于Tanh, Sigmoid: Xavier/Glorot 初始化通常更合适。
偏置: 大多数情况下初始化为0即可。特定情况(如LSTM遗忘门)除外。
现代深度学习框架: 如PyTorch和TensorFlow/Keras,通常会为不同的层类型提供合理的默认初始化策略(例如,卷积层和线性层通常默认使用Kaiming Uniform)。

可视化不同初始化策略对深层网络激活值分布的影响

我们可以扩展之前的代码,比较在一个较深的网络中使用不同初始化策略和激活函数时,各层激活值的分布情况。

import torch
import torch.nn as nn
import matplotlib.pyplot as plt
import numpy as np
import math

def plot_activations(model_name, activation_func_name, model, input_tensor, hidden_size, num_layers_to_plot=5):
    activation_values = {
            }
    handles = [] # 用于存储钩子句柄,以便后续移除

    def hook_fn(module, input, output, layer_name):
        activation_values[layer_name] = output.detach().cpu().numpy()

    # 注册钩子到激活函数层
    plot_layer_indices = np.linspace(0, len(model) - 2, num_layers_to_plot, dtype=int) # 选择几个激活层进行绘图
    
    actual_plot_count = 0
    for i, layer in enumerate(model):
        # 我们通常关心激活函数之后的输出
        if isinstance(layer, (nn.Tanh, nn.ReLU, nn.Sigmoid)): # 检查是否是激活函数层
            # 找到这个激活层是属于第几个block (Linear + Activation)
            block_index = -1
            current_block = 0
            for k_idx, k_layer in enumerate(model):
                if isinstance(k_layer, nn.Linear):
                    current_block +=1
                if k_idx == i:
                    block_index = current_block -1 # 假设激活函数紧跟线性层
                    break
            
            # 仅绘制选定索引的激活层
            # 这里简化为绘制前 num_layers_to_plot 个激活层的输出
            if actual_plot_count < num_layers_to_plot :
                layer_name_hook = f'{
              activation_func_name}_Layer_{
              actual_plot_count+1}'
                handle = layer.register_forward_hook(
                    lambda mod, inp, outp, name=layer_name_hook: hook_fn(mod, inp, outp, name)
                )
                handles.append(handle)
                actual_plot_count+=1


    with torch.no_grad():
        _ = model(input_tensor) # 执行前向传播

    # 移除钩子,避免内存泄漏和重复执行
    for handle in handles:
        handle.remove()

    # 绘制激活值分布
    if not activation_values:
        print(f"No activations recorded for {
              model_name} with {
              activation_func_name}.")
        return

    num_plots = len(activation_values)
    if num_plots == 0: return

    plt.figure(figsize=(3 * num_plots, 3))
    plot_idx = 1
    for name, values in sorted(activation_values.items()): # 按层名排序以确保顺序
        plt.subplot(1, num_plots, plot_idx)
        plt.title(name)
        # 根据激活函数选择合适的绘图范围
        if activation_func_name == "Tanh" or activation_func_name == "Sigmoid":
            hist_range = (-1.1, 1.1) if activation_func_name == "Tanh" else (-0.1, 1.1)
        elif activation_func_name == "ReLU":
            hist_range = (-0.1, max(2.0, values.max()) if values.size > 0 else 2.0) # ReLU输出非负
        else:
            hist_range = (values.min(), values.max()) if values.size > 0 else (-1,1)
        
        plt.hist(values.flatten(), bins=50, range=hist_range if values.size > 0 else (-1,1) , density=True)
        plt.yticks([])
        plt.xlabel("Activation Value")
        if plot_idx == 1:
            plt.ylabel("Density")
        plot_idx += 1
        
    plt.suptitle(f"Activation Distribution: {
              model_name} ({
              activation_func_name})", fontsize=14)
    plt.tight_layout(rect=[0, 0.03, 1, 0.95])
    plt.show()


# --- 定义模型创建函数 ---
def create_mlp(input_size, hidden_size, num_hidden_layers, output_size, activation_fn_type, initialization_type):
    layers = [nn.Linear(input_size, hidden_size)] # 输入层
    
    # 根据类型选择激活函数
    if activation_fn_type == "Tanh":
        layers.append(nn.Tanh())
    elif activation_fn_type == "ReLU":
        layers.append(nn.ReLU())
    elif activation_fn_type == "Sigmoid":
        layers.append(nn.Sigmoid())
    else:
        raise ValueError("Unsupported activation function")

    for _ in range(num_hidden_layers - 1): # 创建隐藏层
        linear = nn.Linear(hidden_size, hidden_size)
        layers.append(linear)
        if activation_fn_type == "Tanh":
            layers.append(nn.Tanh())
        elif activation_fn_type == "ReLU":
            layers.append(nn.ReLU())
        elif activation_fn_type == "Sigmoid":
            layers.append(nn.Sigmoid())
            
    layers.append(nn.Linear(hidden_size, output_size)) # 输出层

    model = nn.Sequential(*layers)

    # 应用权重初始化
    for layer_idx, m in enumerate(model):
        if isinstance(m, nn.Linear):
            if initialization_type == "SmallRandom (std=0.01)":
                nn.init.normal_(m.weight, mean=0.0, std=0.01) # 使用小的标准差进行正态初始化
                nn.init.zeros_(m.bias) # 偏置初始化为0
            elif initialization_type == "LargeRandom (std=1.0)":
                nn.init.normal_(m.weight, mean=0.0, std=1.0) # 使用大的标准差进行正态初始化
                nn.init.zeros_(m.bias)
            elif initialization_type == "Zeros":
                nn.init.zeros_(m.weight) # 权重初始化为0
                nn.init.zeros_(m.bias)
            elif initialization_type == "XavierUniform":
                nn.init.xavier_uniform_(m.weight) # 使用Xavier均匀分布初始化
                nn.init.zeros_(m.bias)
            elif initialization_type == "XavierNormal":
                nn.init.xavier_normal_(m.weight) # 使用Xavier正态分布初始化
                nn.init.zeros_(m.bias)
            elif initialization_type == "KaimingUniform":
                # Kaiming 初始化需要知道激活函数类型来计算gain
                # PyTorch的kaiming_uniform_默认nonlinearity='leaky_relu',对于'relu' gain=sqrt(2)
                # 对于'tanh'或'sigmoid' gain=1
                if activation_fn_type == "ReLU":
                    nn.init.kaiming_uniform_(m.weight, nonlinearity='relu') # 针对ReLU使用Kaiming均匀分布
                else: # 对于Tanh/Sigmoid,Kaiming配合gain=1类似Xavier
                    nn.init.kaiming_uniform_(m.weight, nonlinearity='tanh' if activation_fn_type=='Tanh' else 'sigmoid')
                nn.init.zeros_(m.bias)
            elif initialization_type == "KaimingNormal":
                if activation_fn_type == "ReLU":
                    nn.init.kaiming_normal_(m.weight, nonlinearity='relu') # 针对ReLU使用Kaiming正态分布
                else:
                    nn.init.kaiming_normal_(m.weight, nonlinearity='tanh' if activation_fn_type=='Tanh' else 'sigmoid')
                nn.init.zeros_(m.bias)
            else: # 默认 (PyTorch的nn.Linear默认Kaiming Uniform for fan_in)
                # nn.Linear的默认初始化已经是Kaiming Uniform,这里可以不操作或显式调用
                if hasattr(m, 'reset_parameters'):
                     m.reset_parameters() # 调用模块自身的参数重置方法
                pass # 使用默认初始化
    return model

# --- 实验参数 ---
input_dim = 256 # 输入特征维度
hidden_dim = 128 # 隐藏层神经元数量
num_hid_layers = 10 # 隐藏层数量 (构成一个较深的网络)
output_dim = 10 # 输出类别数量
batch_size = 512 # 批处理大小

sample_input = torch.randn(batch_size, input_dim) # 创建一批随机输入数据

activation_functions_to_test = ["Tanh", "ReLU", "Sigmoid"] # 要测试的激活函数列表
initialization_methods_to_test = [
    "SmallRandom (std=0.01)",
    "LargeRandom (std=1.0)",
    "XavierUniform",
    "KaimingUniform", # Kaiming更适合ReLU,但我们也可以看看它在Tanh/Sigmoid上的表现
    "DefaultLinearInit" # PyTorch nn.Linear的默认初始化
] # 要测试的初始化方法列表

# --- 执行实验并绘图 ---
for act_fn_name in activation_functions_to_test: # 遍历激活函数
    print(f"
--- Testing Activation Function: {
              act_fn_name} ---") # 打印当前测试的激活函数
    for init_method_name in initialization_methods_to_test: # 遍历初始化方法
        if (act_fn_name == "Tanh" or act_fn_name == "Sigmoid") and "Kaiming" in init_method_name and init_method_name != "KaimingUniformWithTanhGain":
            # Kaiming主要为ReLU设计,对于Tanh/Sigmoid,其效果可能接近Xavier(如果gain设置得当)
            # 为避免过多组合,这里可以有选择地测试
             pass # 可以跳过某些组合或进行特定调整

        print(f"  Initializing with: {
              init_method_name}") # 打印当前使用的初始化方法
        
        # 如果是Kaiming且非ReLU,PyTorch的kaiming_uniform_会自动根据nonlinearity调整gain
        # 我们在create_mlp中已经处理了这种情况
        
        current_model = create_mlp(input_dim, hidden_dim, num_hid_layers, output_dim, act_fn_name, init_method_name) # 创建模型
        
        plot_activations(
            model_name=f"{
              init_method_name}",
            activation_func_name=act_fn_name,
            model=current_model,
            input_tensor=sample_input,
            hidden_size=hidden_dim,
            num_layers_to_plot=min(5, num_hid_layers) # 最多绘制5个隐藏层的激活分布
        )

运行上述代码,你会观察到:

SmallRandom (std=0.01): 对于所有激活函数,激活值会迅速趋向于0,尤其是在深层。
LargeRandom (std=1.0):

对于Tanh和Sigmoid,激活值会迅速饱和到边界(-1/1 或 0/1)。
对于ReLU,激活值可能会变得非常大,或者由于初始输入大片为负而导致神经元死亡(输出全0)。

XavierUniform:

对于Tanh和Sigmoid,激活值的分布在各层之间相对稳定,不会过快饱和或消失。
对于ReLU,Xavier可能仍然导致激活值逐层减小(因为ReLU丢弃了一半的信号而Xavier没有完全补偿这一点)。

KaimingUniform:

对于ReLU,激活值的分布在各层之间最为稳定,保持了较好的动态范围。
对于Tanh/Sigmoid,如果kaiming_uniform_nonlinearity参数被正确设置为’tanh’或’sigmoid’(gain=1),其效果会非常接近Xavier。如果错误地使用了ReLU的gain,则可能不理想。

DefaultLinearInit (PyTorch nn.Linear 默认): PyTorch中nn.Linear的默认初始化是Kaiming Uniform,mode='fan_in'nonlinearity='leaky_relu'(对一般的a=0.01的LeakyReLU)。这意味着它已经是一个相当不错的现代初始化方法了,尤其适合ReLU及其变体。

这个实验直观地展示了选择合适的权重初始化方法对于维持网络中信号的健康传播是多么重要。不恰当的初始化会导致学习效率低下甚至完全失败。

3.2 正则化 (Regularization): 提高模型的泛化能力

3.2.1 什么是过拟合?为什么会发生?

定义:
过拟合是指机器学习模型在训练数据上表现非常好(例如,损失很低,准确率很高),但在未曾见过的测试数据(或真实世界数据)上表现却显著下降的现象。模型过于“记住”了训练数据中的特定细节和噪声,而不是学习到底层的普适规律和模式。这样的模型泛化能力差。

与过拟合相对的是欠拟合 (Underfitting),即模型在训练数据上表现就不佳,没有充分学习到数据的基本结构。这通常是因为模型过于简单(例如,用线性模型去拟合非线性数据),或者训练不充分。

理想的模型应该在训练数据和测试数据上都有良好的表现,达到一个平衡点。

发生原因:
过拟合的发生通常与以下一个或多个因素有关:

模型复杂度过高 (High Model Capacity):

如果模型的参数数量远大于训练数据的样本数量,或者模型具有非常强的表达能力(例如,非常深或非常宽的神经网络),它就有足够的“自由度”去完美拟合训练数据中的每一个点,包括噪声。
例如,用一个高阶多项式去拟合少量数据点,很容易完美穿过所有点,但在新点上预测会很差。

训练数据量不足 (Insufficient Training Data):

当训练数据量相对于模型的复杂度来说太少时,模型很难从中学习到具有代表性的普适规律。它更容易将数据中的随机性误认为是真实模式。
“数据是最好的正则化器”——更多样、更丰富的数据能显著降低过拟合风险。

训练数据与测试数据分布不一致 (Data Mismatch):

如果训练数据的分布与测试数据(或实际应用场景数据)的分布存在显著差异,那么在训练数据上学到的模式可能不适用于测试数据。

过度的训练 (Excessive Training):

即使模型复杂度和数据量适中,如果训练时间过长(迭代次数过多),模型也可能开始学习训练数据中的噪声和特异性,导致在验证集上的性能下降。这就是为什么我们通常需要监控验证集性能并使用早停 (Early Stopping) 策略。

特征维度过高而样本量不足 (Curse of Dimensionality with Few Samples):

在高维空间中,数据点之间的距离通常会变得很大且相近,数据变得稀疏。如果样本量不足以覆盖高维特征空间的各个区域,模型很容易找到一些虚假的关联。

3.2.2 正则化的核心思想

正则化的核心思想是在模型的学习过程中引入一些约束或惩罚,以限制模型的复杂度,从而防止其过分拟合训练数据,提高其在未见数据上的泛化能力。

它通常通过向损失函数中添加一个正则化项 (Regularization Term)惩罚项 (Penalty Term) 来实现。这个正则化项会惩罚模型中较大的参数值或某些结构特性。

修改后的总损失函数 (Total Loss) 可以表示为:
[ L_{total}( heta) = L_{data}( heta) + lambda R( heta) ]
其中:

(L_{data}( heta)) 是原始的数据损失项(例如,均方误差MSE、交叉熵损失Cross-Entropy),衡量模型在训练数据上的拟合程度。
(R( heta)) 是正则化项,它是一个关于模型参数 ( heta) 的函数,用于衡量模型的复杂度。
(lambda) 是正则化强度超参数 (Regularization Strength / Weight Decay),它是一个非负值,用于控制正则化项对总损失的贡献程度。

(lambda = 0): 无正则化,模型只关心拟合训练数据。
(lambda o infty): 正则化项占据主导,模型参数会被极力压缩(例如趋向于0),可能导致欠拟合。
选择合适的 (lambda) 通常需要通过交叉验证等方法进行调整。

通过最小化 (L_{total}( heta)),优化算法需要在拟合训练数据(减小 (L_{data}( heta)))和保持模型简单(减小 (R( heta)))之间取得平衡。

3.2.3 常用的正则化技术

A. L2 正则化 (Ridge Regression / Weight Decay)

正则化项 (R( heta)):
L2 正则化项是模型所有权重参数的平方和(通常不包括偏置项)。
[ R_{L2}(mathbf{w}) = frac{1}{2} sum_{j} w_j^2 ]
或者写成向量形式:
[ R_{L2}(mathbf{w}) = frac{1}{2} ||mathbf{w}||_2^2 ]
其中 (mathbf{w}) 是模型的权重向量,(||mathbf{w}||_2^2) 是权重的 L2 范数的平方。前面的 (1/2) 是为了求导方便。

总损失函数:
[ L_{total}(mathbf{w}) = L_{data}(mathbf{w}) + frac{lambda}{2} ||mathbf{w}||_2^2 ]

梯度更新:
当使用梯度下降更新权重时,L2 正则化项对梯度 (
abla_{mathbf{w}} L_{data}(mathbf{w})) 的影响是增加了一个额外的项 ( lambda mathbf{w} )。
[
abla_{mathbf{w}} L_{total}(mathbf{w}) =
abla_{mathbf{w}} L_{data}(mathbf{w}) + lambda mathbf{w} ]
参数更新规则变为:
[ mathbf{w}_{t+1} = mathbf{w}t – eta (
abla
{mathbf{w}t} L{data}(mathbf{w}_t) + lambda mathbf{w}t) ]
[ mathbf{w}
{t+1} = (1 – eta lambda) mathbf{w}t – eta
abla
{mathbf{w}t} L{data}(mathbf{w}_t) ]
可以看到,每次更新时,权重 (mathbf{w}_t) 都会先乘以一个小于1的因子 ((1 – eta lambda)),这相当于对权重进行了一个“衰减 (decay)”,然后再减去数据梯度的影响。因此,L2 正则化也被称为权重衰减 (Weight Decay)

效果与直觉:

惩罚大权重: L2 正则化倾向于使模型的权重值变得更小且更分散。它不鼓励模型依赖于少数几个具有非常大权重的特征,而是鼓励模型使用所有特征,并赋予它们较小的权重。
更平滑的模型: 较小的权重通常意味着模型的输出对输入的微小变化不那么敏感,模型的决策边界更平滑,从而提高了泛化能力。如果一个特征的权重很大,那么输入中该特征的一个小扰动就可能导致输出的巨大变化,这通常是过拟合的表现。
不会使权重变为精确的0: L2 正则化会使权重接近于0,但除非数据损失项的梯度正好与 (lambda mathbf{w}) 相反且大小相等,否则权重通常不会精确地变为0。因此,它不做特征选择。

为什么通常不正则化偏置项 (bias terms)?

偏置项不像权重那样控制特征的强度,它们更多地是调整激活函数的整体偏移量。
偏置项的数量远少于权重项,对它们进行正则化对模型复杂度的影响较小。
实践中,正则化偏置项通常对性能影响不大,有时甚至可能略微降低性能。

概念性代码 (在优化器中实现权重衰减 – PyTorch):
大多数现代深度学习框架的优化器(如SGD, Adam)都内置了 weight_decay 参数,可以直接设置来实现L2正则化。

import torch
import torch.nn as nn
import torch.optim as optim

# 定义一个简单的模型
model_l2 = nn.Sequential(
    nn.Linear(10, 20), # 输入10特征,输出20特征
    nn.ReLU(),         # ReLU激活函数
    nn.Linear(20, 1)   # 输出1个值
)

# 模拟数据
X_train = torch.randn(100, 10) # 100个样本,每个样本10个特征
y_train = torch.randn(100, 1)  # 对应的100个目标值

# 定义损失函数
criterion = nn.MSELoss() # 使用均方误差损失

# 定义优化器,并设置 weight_decay 参数
learning_rate = 0.01 # 学习率
lambda_l2 = 0.001    # L2正则化强度 (权重衰减系数)

# 使用SGD优化器,并传入 weight_decay 参数
# optimizer = optim.SGD(model_l2.parameters(), lr=learning_rate, weight_decay=lambda_l2) 
# 使用Adam优化器,并传入 weight_decay 参数 (AdamW是Adam + 正确的权重衰减)
optimizer = optim.AdamW(model_l2.parameters(), lr=learning_rate, weight_decay=lambda_l2) 

# 训练循环示例
num_epochs = 50 # 训练轮数
for epoch in range(num_epochs): # 迭代训练轮数
    optimizer.zero_grad()    # 清除之前的梯度
    outputs = model_l2(X_train) # 前向传播,得到模型预测输出
    loss = criterion(outputs, y_train) # 计算数据损失 (MSE)
    
    # 注意:当在优化器中设置了 weight_decay 时,不需要手动将L2惩罚项加到loss中。
    # 优化器在计算梯度和更新权重时会自动处理权重衰减。
    # 如果要手动添加,则应该是:
    # l2_reg = torch.tensor(0.)
    # for param in model_l2.parameters():
    #     if param.dim() > 1: # 通常只对权重矩阵应用L2,不对偏置应用
    #         l2_reg += torch.norm(param, p=2)**2
    # total_loss = loss + (lambda_l2 / 2) * l2_reg 
    # total_loss.backward()

    loss.backward()          # 反向传播,计算梯度
    optimizer.step()         # 更新模型参数

    if (epoch + 1) % 10 == 0: # 每10轮打印一次损失
        # 验证L2正则化的效果:检查权重的大小
        total_weight_norm_sq = 0
        for name, param in model_l2.named_parameters(): # 遍历模型的所有参数
            if 'weight' in name: # 通常我们关心的是权重参数
                total_weight_norm_sq += torch.norm(param, p=2).item()**2 # 计算权重的L2范数的平方并累加
        print(f'Epoch [{
              epoch+1}/{
              num_epochs}], Loss: {
              loss.item():.4f}, Approx. Total L2 Weight Norm Sq: {
              total_weight_norm_sq:.4f}')
        # 期望看到随着训练,权重范数不会无限增大,而是被控制在一个范围内。

# 训练结束后,权重通常会比没有L2正则化时更小。

在这个例子中,optim.AdamW (或 optim.SGDweight_decay 参数) 会在内部处理L2正则化。我们不需要手动将 (frac{lambda}{2} ||mathbf{w}||_2^2) 添加到损失函数中再进行 backward()。优化器在执行 optimizer.step() 时会根据 weight_decay 值来调整权重的更新。

B. L1 正则化 (Lasso Regression)

正则化项 (R( heta)):
L1 正则化项是模型所有权重参数的绝对值之和。
[ R_{L1}(mathbf{w}) = sum_{j} |w_j| ]
或者写成向量形式:
[ R_{L1}(mathbf{w}) = ||mathbf{w}||_1 ]
其中 (||mathbf{w}||_1) 是权重的 L1 范数。

总损失函数:
[ L_{total}(mathbf{w}) = L_{data}(mathbf{w}) + lambda ||mathbf{w}||_1 ]

梯度更新:
L1 范数 (|w_j|) 在 (w_j=0) 处不可导。在 (w_j
eq 0) 处,其导数为 ( ext{sgn}(w_j))(即 (w_j > 0) 时为1,(w_j < 0) 时为-1)。
梯度更新规则(使用次梯度 Subgradient):
[
abla_{mathbf{w}} L_{total}(mathbf{w}) =
abla_{mathbf{w}} L_{data}(mathbf{w}) + lambda cdot ext{sgn}(mathbf{w}) ]
参数更新规则变为:
[ w_{j, t+1} = w_{j,t} – eta (
abla_{w_{j,t}} L_{data}(w_{j,t}) + lambda cdot ext{sgn}(w_{j,t})) ]
这意味着,如果 (w_j > 0),它会向0的方向减小一个固定的量 (eta lambda);如果 (w_j < 0),它会向0的方向增一个固定的量 (eta lambda)。这使得权重很容易被“推”到精确的0。

效果与直觉:

产生稀疏权重 (Sparse Weights): L1 正则化最显著的特点是它能够将许多不重要的特征对应的权重精确地变为0。这是因为L1惩罚项的“菱形”等值线在坐标轴上具有尖角,优化过程更容易在这些尖角处(即某些权重为0)达到最优。
特征选择 (Feature Selection): 由于L1正则化可以产生稀疏模型,它在某种意义上进行了自动的特征选择,只保留那些对模型输出贡献最大的特征。这对于高维数据处理非常有用。
鲁棒性: 稀疏模型通常更简单,对噪声的鲁棒性可能更好。

缺点:

解不稳定: 如果存在一组高度相关的特征,L1正则化可能会随机选择其中一个特征赋予非零权重,而将其他相关特征的权重设为0。多次运行可能得到不同的特征子集。
梯度在0点不连续: 需要使用次梯度优化或平滑近似。
在神经网络中,L2正则化通常比L1正则化更常用,因为神经网络的特征是学习出来的,稀疏性不一定总是理想的。但在需要明确进行特征选择或希望模型非常稀疏的场景下,L1可能有用。

概念性代码 (手动添加到损失中 – PyTorch):
PyTorch的内置优化器通常不直接提供 l1_weight_decay 这样的参数。L1正则化通常需要手动计算L1惩罚项并将其加到数据损失上。

import torch
import torch.nn as nn
import torch.optim as optim

model_l1 = nn.Sequential(
    nn.Linear(10, 20),
    nn.ReLU(),
    nn.Linear(20, 1)
)

X_train = torch.randn(100, 10)
y_train = torch.randn(100, 1)
criterion = nn.MSELoss()

learning_rate = 0.01
lambda_l1 = 0.0005 # L1正则化强度

# L1正则化通常不直接在优化器中设置,而是加到损失函数里
optimizer = optim.Adam(model_l1.parameters(), lr=learning_rate) # Adam通常不直接支持L1的weight_decay

num_epochs = 100
for epoch in range(num_epochs):
    optimizer.zero_grad()
    outputs = model_l1(X_train)
    data_loss = criterion(outputs, y_train) # 计算数据损失

    # 计算L1惩罚项
    l1_penalty = torch.tensor(0., requires_grad=True) # 初始化L1惩罚项张量
    for param in model_l1.parameters(): # 遍历模型所有参数
        # 通常只对权重应用L1正则化,不对偏置应用
        if param.dim() > 1: # 权重矩阵的维度通常大于1 (例如 [out_features, in_features])
            l1_penalty = l1_penalty + torch.norm(param, p=1) # 计算L1范数并累加
            # 或者手动计算绝对值之和: l1_penalty = l1_penalty + param.abs().sum()


    total_loss = data_loss + lambda_l1 * l1_penalty # 将L1惩罚项加到总损失中
    
    total_loss.backward() # 基于总损失进行反向传播
    optimizer.step()

    if (epoch + 1) % 10 == 0:
        num_zero_weights = 0 # 统计权重为0的数量
        total_weights = 0    # 统计总权重数量
        for name, param in model_l1.named_parameters():
            if 'weight' in name:
                num_zero_weights += torch.sum(param == 0).item() # 计算等于0的权重数量
                total_weights += param.numel() # 获取参数的总数量
        sparsity = num_zero_weights / total_weights if total_weights > 0 else 0 # 计算稀疏度
        print(f'Epoch [{
              epoch+1}/{
              num_epochs}], Total Loss: {
              total_loss.item():.4f}, Data Loss: {
              data_loss.item():.4f}, L1 Penalty: {
              (lambda_l1 * l1_penalty).item():.4f}, Sparsity: {
              sparsity:.4f}')
        # 期望看到随着训练,Sparsity可能会增加,表明一些权重变成了0。

C. Elastic Net 正则化

思想: Elastic Net 结合了 L1 和 L2 正则化的优点。它同时对权重的L1范数和L2范数进行惩罚。
正则化项:
[ R_{ElasticNet}(mathbf{w}) = alpha ||mathbf{w}||_1 + frac{1-alpha}{2} ||mathbf{w}||2^2 ]
或者更常见的形式是:
[ R
{ElasticNet}(mathbf{w}) = lambda_1 ||mathbf{w}||_1 + lambda_2 frac{1}{2} ||mathbf{w}||_2^2 ]
其中 (lambda_1) 和 (lambda_2) (或者 (alpha) 和一个总的 (lambda)) 是控制L1和L2部分贡献的超参数。
效果:

能够像L1一样产生稀疏解。
同时具有L2的“分组效应”:对于一组高度相关的特征,Elastic Net倾向于同时选择它们或同时排除它们(而不是像L1那样随机选择一个)。
在特征数量远大于样本数量((p gg n))或存在多重共线性时表现较好。

D. Dropout

提出: 由 Hinton 等人在2012-2014年间提出,是一种非常有效且广泛应用于神经网络的正则化技术。

核心思想:
在训练过程的每次前向传播时,Dropout会以一定的概率 (p)(称为dropout rate,例如 (p=0.5))随机地“丢弃”或“关闭”网络中的一部分神经元(即将其输出暂时置为0)。这意味着这些被丢弃的神经元在当次的前向传播和反向传播中都不会参与计算。

工作机制:

训练阶段:

对于网络中的每一层(通常是全连接层或卷积层的输出,在激活函数之前或之后),每个神经元以概率 (p) 被保留(以概率 (1-p) 被激活),或者以概率 (p) 被丢弃(以概率 (1-p) 被设置为0)。这个 (p) 是“保留概率 (keep probability)”,而我们常说的dropout rate是指被丢弃的概率 (1-p)。
输出缩放 (Inverted Dropout): 为了保证在测试阶段(不使用Dropout时)该层输出的期望值与训练阶段一致,通常会在训练时对保留下来的神经元的输出进行缩放,即除以保留概率 (p_{keep}) (或者乘以 (1/p_{keep}))。这种方法称为“反向Dropout (Inverted Dropout)”,是目前主流的实现方式。
例如,如果保留概率是0.8 (dropout rate 0.2),那么保留下来的神经元的激活值会被乘以 (1/0.8 = 1.25)。
每次迭代(或每个小批量)都会随机选择不同的神经元进行丢弃,相当于每次都在训练一个不同的“稀疏化”的子网络。

测试阶段 (推理阶段):

不使用Dropout: 在测试时,所有神经元都被保留(即保留概率为1)。
权重缩放 (如果训练时未使用Inverted Dropout): 如果在训练时没有使用Inverted Dropout对输出进行缩放,那么在测试时需要将所有权重乘以保留概率 (p_{keep}) 来近似模拟训练时多个子网络输出的平均效果。但由于Inverted Dropout的普遍使用,这一步通常是不必要的。

为什么Dropout能防止过拟合?

模型平均 (Model Averaging / Ensemble Effect): Dropout可以看作是一种高效的集成学习方法。由于每次训练都相当于在训练一个原始网络的不同子网络,最终的网络可以看作是这些大量不同子网络的近似平均。集成多个模型的预测通常能提高泛化能力并减少过拟合。
减少神经元间的共适应 (Reducing Co-adaptation): Dropout迫使网络不能过度依赖于某些特定的神经元或特征组合。因为任何一个神经元都有可能在下一次迭代中被丢弃,所以网络需要学习更鲁棒、更冗余的特征表示,不同的神经元需要学会独立地检测有用的特征,而不是相互“串通”来拟合训练数据中的噪声。
引入噪声: Dropout在训练过程中引入了随机性,这种噪声可以帮助模型跳出差的局部最小值,并探索更广阔的参数空间。

使用建议:

Dropout rate (1-p_{keep}) 通常设置在0.2到0.5之间。对于输入层,可以设置一个较小的dropout rate(如0.1-0.2),对于隐藏层,0.5是一个常见的初始值。
通常不建议在输出层使用Dropout。
对于卷积层,Dropout的应用方式略有不同。可以直接对特征图的通道进行Dropout (SpatialDropout),或者对整个特征图进行Dropout。
如果网络较小或训练数据充足,Dropout可能不会带来显著提升,甚至可能导致欠拟合。

概念性代码 (PyTorch):
PyTorch 提供了 nn.Dropout 模块。

import torch
import torch.nn as nn

# Dropout概率 (被丢弃的概率)
dropout_rate = 0.5 
# nn.Dropout的参数p就是指被丢弃的概率

# 定义一个包含Dropout的模型
model_dropout = nn.Sequential(
    nn.Linear(100, 200), # 输入100,输出200
    nn.ReLU(),           # ReLU激活
    nn.Dropout(p=dropout_rate), # Dropout层,p是丢弃概率
    nn.Linear(200, 50),  # 输入200,输出50
    nn.ReLU(),
    nn.Dropout(p=dropout_rate), # 另一个Dropout层
    nn.Linear(50, 10)    # 输出10
)

# 模拟输入
input_tensor = torch.randn(5, 100) # batch_size=5, features=100

# 训练阶段
model_dropout.train() # 将模型设置为训练模式,Dropout会生效
output_train = model_dropout(input_tensor) # 前向传播
print(f"Output during training (first sample, first 5 values):
{
              output_train[0, :5]}") # 打印训练时的部分输出
# 多次运行 model_dropout(input_tensor) 在训练模式下,输出会因为随机丢弃而不同。
# 并且,由于Inverted Dropout,激活值的尺度会被放大。

# 测试/推理阶段
model_dropout.eval() # 将模型设置为评估模式,Dropout会关闭(所有神经元保留,且不做缩放)
output_eval = model_dropout(input_tensor) # 前向传播
print(f"
Output during evaluation (first sample, first 5 values):
{
              output_eval[0, :5]}") # 打印评估时的部分输出
# 多次运行 model_dropout(input_tensor) 在评估模式下,输出是确定的。
# 其期望值与训练时(考虑了Inverted Dropout)一致。

# 验证Inverted Dropout的效果
# 假设我们有一个简单的线性层和Dropout
simple_linear = nn.Linear(10,1, bias=False) # 无偏置线性层
nn.init.ones_(simple_linear.weight) # 将权重都初始化为1
dropout_layer = nn.Dropout(p=0.5) # 丢弃概率0.5,保留概率0.5

test_input = torch.ones(1, 10) * 2.0 # 输入全为2

simple_linear.train() # 训练模式
dropout_layer.train() # 训练模式

sum_outputs_train = 0
num_trials = 1000
for _ in range(num_trials):
    out_lin = simple_linear(test_input) # 线性层输出:10 * 1 * 2 = 20
    out_drop = dropout_layer(out_lin)   # Dropout输出
    # 如果一个神经元被保留,其值为 20 / 0.5 = 40 (因为保留概率是0.5)
    # 如果被丢弃,其值为 0
    sum_outputs_train += out_drop.item()

avg_output_train = sum_outputs_train / num_trials
print(f"
Average output with Inverted Dropout (train mode, p_keep=0.5): {
              avg_output_train:.4f}")
# 期望值是 20 (原始输出) * 0.5 (保留概率) * (1/0.5) (inverted scaling) + 0 * 0.5 = 20

simple_linear.eval() # 评估模式
dropout_layer.eval() # 评估模式
out_lin_eval = simple_linear(test_input) # 线性层输出:20
out_drop_eval = dropout_layer(out_lin_eval) # Dropout层在评估模式下什么都不做
print(f"Output without Dropout (eval mode): {
              out_drop_eval.item():.4f}")
# 输出是 20
# 可以看到,训练时的期望输出与评估时的输出一致,这就是Inverted Dropout的目的。

E. 数据增强 (Data Augmentation)

核心思想:
数据增强是通过对现有的训练数据进行各种变换(如旋转、裁剪、翻转、颜色抖动等)来人工地生成更多、更多样的训练样本。它是一种非常有效且成本低廉的正则化方法,因为它直接增加了训练数据的数量和多样性,帮助模型学习到对这些变换不变的特征,从而提高泛化能力。

常见方法 (尤其在图像领域):

几何变换:

随机翻转 (水平/垂直)
随机旋转 (一定角度范围内)
随机裁剪和缩放 (Random Resized Crop)
随机平移
仿射变换、透视变换

颜色变换:

调整亮度、对比度、饱和度、色调
添加随机噪声 (高斯噪声)
颜色抖动 (Color Jittering)

其他:

Cutout / Random Erasing: 随机擦除图像中的一小块区域,强迫模型已关注图像的全局信息。
Mixup: 将两张图片及其标签进行线性插值生成新的样本。 ( ilde{x} = lambda x_i + (1-lambda)x_j), ( ilde{y} = lambda y_i + (1-lambda)y_j)。
CutMix: 将一张图片的一部分区域剪切下来,粘贴到另一张图片上,标签也按区域比例混合。

优点:

显著增加有效训练数据量。
提高模型对各种扰动和变化的鲁棒性。
实现简单,计算开销相对较小(通常在数据加载时动态进行)。

注意事项:

数据增强的策略应该与具体任务和数据类型相关。例如,对于数字识别任务,垂直翻转数字“6”会得到“9”,这种增强可能不合适。
增强的强度不宜过大,否则可能引入与原始数据分布差异过大的样本,反而损害性能。

概念性代码 (PyTorch transforms for Images):

import torch
import torchvision.transforms as T # PyTorch的图像变换库
from PIL import Image # 用于加载图像的PIL库
import matplotlib.pyplot as plt
import requests # 用于从URL下载图片
from io import BytesIO # 用于处理二进制流

# 尝试加载一张示例图片 (如果运行环境无法直接访问本地图片,可以使用URL)
try:
    # 示例图片URL (猫)
    image_url = "http://images.cocodataset.org/val2017/000000039769.jpg"
    response = requests.get(image_url) # 发送GET请求获取图片
    response.raise_for_status() # 如果请求失败则抛出异常
    img = Image.open(BytesIO(response.content)).convert("RGB") # 从二进制内容打开图片并转为RGB
except Exception as e:
    print(f"无法加载示例图片: {
              e}. 将使用随机张量代替。")
    img = Image.fromarray((torch.rand(100, 100, 3) * 255).byte().cpu().numpy()) # 创建一个随机图片

# 定义一系列数据增强变换
# ToTensor() 会将PIL Image或numpy.ndarray (H x W x C) [0, 255] 转换为 torch.FloatTensor (C x H x W) [0.0, 1.0]
data_transforms = T.Compose([ # 将多个变换组合起来
    T.Resize((256, 256)),             # 将图片大小调整为256x256
    T.RandomHorizontalFlip(p=0.5),    # 以0.5的概率进行随机水平翻转
    T.RandomRotation(degrees=30),     # 在(-30, 30)度之间随机旋转
    T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1), # 随机调整颜色参数
    T.RandomResizedCrop(size=224, scale=(0.8, 1.0)), # 随机裁剪并缩放到224x224,保留原图80%-100%的区域
    T.ToTensor(),                     # 将图片转换为张量
    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) # 标准化 (ImageNet的均值和标准差)
])

# 应用变换并可视化几次结果
num_augmented_samples = 4 # 生成4个增强后的样本
plt.figure(figsize=(12, 4)) # 创建一个图形窗口

# 显示原始图片 (转换为Tensor但不做Normalize,方便可视化)
original_img_tensor = T.Compose([T.Resize((224,224)), T.ToTensor()])(img) # 只做缩放和转Tensor
plt.subplot(1, num_augmented_samples + 1, 1) # 创建子图
plt.imshow(original_img_tensor.permute(1, 2, 0).cpu().numpy()) # permute C x H x W -> H x W x C
plt.title("Original") # 设置标题
plt.axis('off') # 关闭坐标轴

for i in range(num_augmented_samples): # 循环生成增强样本
    augmented_img_tensor = data_transforms(img.copy()) # 应用定义好的数据增强变换,注意使用img.copy()
    
    # 反标准化以便于可视化 (Normalize的逆操作)
    # mean = torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)
    # std = torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)
    # display_tensor = augmented_img_tensor * std + mean 
    # display_tensor = torch.clamp(display_tensor, 0, 1) # 将值限制在[0,1]范围内

    plt.subplot(1, num_augmented_samples + 1, i + 2) # 创建子图
    # 注意:直接显示Normalize后的图像可能颜色不自然,这里仅为演示变换效果
    # 为了正确显示,通常需要反向 Normalize,或者在 Normalize 前进行可视化
    # 这里我们直接显示,重点看形状和颜色的变化
    plt.imshow(augmented_img_tensor.permute(1, 2, 0).cpu().numpy()) # permute C x H x W -> H x W x C
    plt.title(f"Augmented {
              i+1}") # 设置标题
    plt.axis('off') # 关闭坐标轴

plt.suptitle("Data Augmentation Examples", fontsize=16) # 设置总标题
plt.tight_layout(rect=[0,0,1,0.95]) # 调整布局
plt.show() # 显示图形

这段代码演示了如何使用 torchvision.transforms 对图像进行一系列常见的数据增强操作,并可视化结果。在实际训练中,这些变换通常在数据加载器 (DataLoader)中动态应用于每个批次的数据。

F. 早停 (Early Stopping)

核心思想:
在训练过程中,模型在训练集上的性能通常会持续提升,但在某个点之后,在验证集(独立于训练集和测试集的数据)上的性能可能会开始下降,这表明模型开始过拟合。早停策略就是在验证集性能不再提升(甚至开始变差)时,提前停止训练,并保存性能最好时的模型参数。

工作机制:

将数据划分为训练集、验证集和测试集。
在每个epoch(或每隔几个epoch)结束时,在验证集上评估模型的性能(例如,计算验证损失或准确率)。
记录到目前为止在验证集上最好的性能以及对应的模型参数。
如果连续 (k) 个epoch((k) 称为“耐心值 patience”)验证集性能没有超过历史最好性能,则停止训练。
最终使用的模型是验证集上性能最好时的那个模型。

优点:

实现简单,非常有效。
可以防止模型过度训练导致过拟合。
有助于自动确定最佳训练轮数,减少了手动调整epoch数量的需要。

缺点/注意事项:

需要一个独立的验证集。
“耐心值” (k) 是一个需要调整的超参数。如果 (k)太小,可能过早停止,模型欠拟合;如果 (k) 太大,则可能仍然会发生一定程度的过拟合。
有时验证集性能可能会有波动,需要区分是随机波动还是真正的性能下降趋势。可以考虑在性能下降后允许一定的“宽容期”,或者当性能比最好值差一定阈值时才停止。

概念性代码 (PyTorch-like pseudocode):

import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np # 用于保存最佳模型状态

# 假设 model, train_loader, val_loader, criterion, optimizer 已经定义好了

num_epochs = 200        # 最大训练轮数
patience = 10           # "耐心值":连续patience轮验证损失没有改善则停止
best_val_loss = float('inf') # 初始化最佳验证损失为正无穷大
epochs_no_improve = 0   # 记录连续多少轮验证损失没有改善
best_model_state = None # 用于保存最佳模型的状态字典

print("Starting training with Early Stopping...") # 打印开始训练信息
for epoch in range(num_epochs): # 迭代训练轮数
    # --- 训练阶段 ---
    model.train() # 设置模型为训练模式
    train_loss_epoch = 0
    for batch_idx, (data, target) in enumerate(train_loader): # 遍历训练数据加载器
        # data, target = data.to(device), target.to(device) # (如果使用GPU)
        optimizer.zero_grad() # 清除梯度
        output = model(data)  # 前向传播
        loss = criterion(output, target) # 计算损失
        loss.backward()       # 反向传播
        optimizer.step()      # 更新参数
        train_loss_epoch += loss.item() # 累加批次损失
    avg_train_loss = train_loss_epoch / len(train_loader) # 计算平均训练损失

    # --- 验证阶段 ---
    model.eval() # 设置模型为评估模式
    val_loss_epoch = 0
    with torch.no_grad(): # 在不计算梯度的模式下进行验证
        for data, target in val_loader: # 遍历验证数据加载器
            # data, target = data.to(device), target.to(device)
            output = model(data) # 前向传播
            loss = criterion(output, target) # 计算损失
            val_loss_epoch += loss.item() # 累加批次损失
    avg_val_loss = val_loss_epoch / len(val_loader) # 计算平均验证损失

    print(f'Epoch {
              epoch+1}/{
              num_epochs} | Train Loss: {
              avg_train_loss:.4f} | Val Loss: {
              avg_val_loss:.4f}') # 打印当前轮的训练和验证损失

    # 检查是否需要早停
    if avg_val_loss < best_val_loss: # 如果当前验证损失优于历史最佳
        best_val_loss = avg_val_loss # 更新最佳验证损失
        epochs_no_improve = 0        # 重置未改善计数器
        # 保存当前模型的状态 (可以保存到文件或内存)
        best_model_state = model.state_dict().copy() # 保存模型参数的状态字典的副本
        print(f'Validation loss improved. Saving model state at epoch {
              epoch+1}.') # 打印信息
    else: # 如果当前验证损失没有改善
        epochs_no_improve += 1 # 未改善计数器加1
        print(f'Validation loss did not improve for {
              epochs_no_improve} epoch(s).') # 打印信息

    if epochs_no_improve >= patience: # 如果连续未改善的轮数达到耐心值
        print(f'Early stopping triggered after {
              epoch+1} epochs.') # 打印早停信息
        break # 跳出训练循环

# 训练结束后,如果best_model_state不为None,可以加载最佳模型参数
if best_model_state:
    print("Loading best model state achieved during training.") # 打印加载最佳模型信息
    model.load_state_dict(best_model_state) # 加载最佳模型参数
else:
    print("Training finished (or no improvement from initial state). Using last model state.") # 打印信息

# 之后可以使用加载了最佳参数的 model 进行测试或推理

G. 其他正则化方法

除了上述主要方法外,还有一些其他技术也可以起到正则化作用或与之相关:

批归一化 (Batch Normalization): 虽然其主要目的是解决内部协变量偏移问题并加速训练,但BN在训练时引入的对小批量统计量的依赖性也带来了一定的噪声,这种噪声有时可以起到轻微的正则化效果。我们将在下一节详细讨论BN。
标签平滑 (Label Smoothing): 对于分类问题,传统的one-hot标签将目标类别的概率设为1,其他类别为0。标签平滑将这个硬性的1稍微减小一点(例如到 (1-epsilon)),并将 (epsilon) 的概率均匀分配给其他非目标类别。这可以防止模型对预测过于自信,并提高泛化能力。
例如,对于3分类问题,标签 [0, 1, 0] (one-hot) 经过标签平滑(假设 (epsilon=0.1))可能变成 [0.1/2, 1-0.1, 0.1/2] = [0.05, 0.9, 0.05]
参数共享 (Parameter Sharing): 例如在卷积神经网络 (CNN) 中,卷积核的参数在整个输入图像的不同位置共享,这极大地减少了模型的参数数量,本身就是一种强大的结构性正则化。
模型集成 (Ensemble Methods): 训练多个独立的模型,并将其预测结果进行平均或投票。这是提高性能和泛化能力的强大手段,但计算成本较高。Dropout可以看作是一种廉价的近似模型集成。

3.2.4 如何选择和使用正则化技术?

L2正则化 (Weight Decay) 是最常用且通常有效的基线正则化方法。在大多数情况下,都应该尝试使用它。
Dropout 对于大型、深层的神经网络非常有效,尤其是在全连接层中。
数据增强 对于图像、语音等数据类型几乎是必须的,能够显著提升性能。
早停 是一种简单且通用的防止过度训练的方法,应该作为标准实践。
批归一化 除了其主要作用外,也可能提供一些正则化效果。
L1正则化 适用于需要稀疏模型或进行特征选择的场景。
组合使用: 通常,多种正则化技术可以组合使用以获得更好的效果(例如,L2 + Dropout + 数据增强 + 早停)。
超参数调整: 正则化强度(如L2的 (lambda),Dropout的rate)是重要的超参数,需要通过交叉验证或在验证集上进行调整。调整不当的正则化可能导致欠拟合(正则化过强)或仍然过拟合(正则化过弱)。

正则化是深度学习工具箱中的关键组成部分,它帮助我们构建出能够在真实世界数据上表现良好的稳健模型。通过理解这些技术的原理和适用场景,我们可以更有效地训练神经网络。

3.3 批归一化 (Batch Normalization): 加速深度网络训练的利器

3.3.1 为什么需要批归一化?——内部协变量偏移 (Internal Covariate Shift)

在训练深度神经网络时,每一层的输入都受到其前面所有层参数的影响。当网络参数在训练过程中不断更新时,每一层输入的分布也会随之发生变化。这种现象被Sergey Ioffe和Christian Szegedy在其2015年的开创性论文《Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift》中称为内部协变量偏移 (Internal Covariate Shift, ICS)

什么是协变量偏移 (Covariate Shift)?
在机器学习中,协变量偏移是指训练数据的输入分布与测试数据的输入分布不一致的现象。例如,用夏季拍摄的猫的图片训练一个猫分类器,然后用冬季拍摄的猫的图片去测试,此时输入特征(如背景、光照)的分布可能已经改变。

什么是内部协变量偏移 (Internal Covariate Shift)?
ICS 是指在深度网络训练过程中,网络内部隐藏层的输入分布随着前面各层参数的更新而不断发生变化的现象

每一层都需要不断适应其输入分布的变化,这使得学习过程变得复杂和缓慢。想象一下,你正在学习识别移动靶心,如果靶心本身还在不断地改变其移动规律,你的学习难度会大大增加。
深层网络中,这种影响会逐层累积和放大,导致底层网络可能需要非常小的学习率和非常小心的参数初始化才能稳定训练,否则很容易出现梯度消失/爆炸或训练震荡。
它还可能迫使激活函数(如Sigmoid或Tanh)的输入落入饱和区,导致梯度变小,学习减慢。

批归一化的核心目标就是通过规范化每一层(或特定位置)的输入,使其具有相对稳定的均值和方差,从而减少内部协变量偏移,进而加速训练、提高模型性能和稳定性。

3.3.2 批归一化是如何工作的?

批归一化层通常被插入到线性变换(如全连接层或卷积层)之后、激活函数之前。对于一个小批量 (mini-batch) 的数据,批归一化按以下步骤操作:

A. 针对小批量数据计算均值和方差

假设我们有一个小批量数据,包含 (m) 个样本。对于该批量中某一层激活(或线性变换的输出)的第 (k) 个维度(或对于卷积层,是某个特征图的某个通道),我们计算其均值 (mu_{mathcal{B}}^{(k)}) 和方差 ((sigma_{mathcal{B}}{(k)})2):

均值 (Mean):
[ mu_{mathcal{B}}^{(k)} = frac{1}{m} sum_{i=1}^{m} x_i^{(k)} ]
其中 (x_i^{(k)}) 是小批量中第 (i) 个样本在该激活的第 (k) 个维度上的值。

方差 (Variance):
[ (sigma_{mathcal{B}}{(k)})2 = frac{1}{m} sum_{i=1}^{m} (x_i^{(k)} – mu_{mathcal{B}}{(k)})2 ]
(注意:这是有偏方差估计。在实践中,有时会使用无偏估计,即分母为 (m-1),但对于较大的 (m),差异不大。原始BN论文中使用的是有偏估计。)

B. 归一化 (Normalize)

使用计算得到的均值和方差,对该激活的第 (k) 个维度的每个样本值 (x_i^{(k)}) 进行归一化,使其具有零均值和单位方差:
[ hat{x}i^{(k)} = frac{x_i^{(k)} – mu{mathcal{B}}{(k)}}{sqrt{(sigma_{mathcal{B}}{(k)})^2 + epsilon}} ]
其中 (epsilon) 是一个很小的正数(例如 (10^{-5})),用于防止分母为零,增加数值稳定性。

C. 缩放和平移 (Scale and Shift)

仅仅将输入归一化到零均值单位方差可能会限制网络的表达能力。例如,如果使用的是Sigmoid激活函数,将其输入限制在均值为0、方差为1的范围内,可能会使其大部分工作在线性区域,从而失去了非线性的优势。

为了解决这个问题,批归一化引入了两个可学习的参数:缩放因子 (gamma^{(k)}) (scale) 和平移因子 (eta^{(k)}) (shift)。这两个参数与原始网络参数一样,通过反向传播进行学习。

归一化后的值 (hat{x}_i^{(k)}) 会被进一步变换:
[ y_i^{(k)} = gamma^{(k)} hat{x}_i^{(k)} + eta^{(k)} ]
其中 (y_i^{(k)}) 是批归一化层的最终输出。

(gamma^{(k)}) 初始化通常为1。
(eta^{(k)}) 初始化通常为0。

通过学习 (gamma^{(k)}) 和 (eta^{(k)}),网络可以选择性地恢复原始激活的某些特性,甚至学习到对当前任务更有利的新的分布。如果网络发现原始的激活分布就是最优的,它可以学习到 (gamma^{(k)} = sqrt{(sigma_{mathcal{B}}{(k)})2 + epsilon}) 和 (eta^{(k)} = mu_{mathcal{B}}^{(k)}),从而近似地抵消归一化操作。

总结BN变换:
对于每个激活维度 (k),BN变换可以表示为:
[ ext{BN}{gamma^{(k)}, eta{(k)}}(x{(k)}) = gamma^{(k)} left( frac{x^{(k)} – mu{mathcal{B}}{(k)}}{sqrt{(sigma_{mathcal{B}}{(k)})^2 + epsilon}}
ight) + eta^{(k)} ]

D. 训练与推理阶段的区别

批归一化在训练阶段和推理(测试)阶段的行为是不同的:

训练阶段:

均值 (mu_{mathcal{B}}^{(k)}) 和方差 ((sigma_{mathcal{B}}{(k)})2) 是基于当前小批量数据计算得到的。
同时,为了在推理阶段使用,BN层还会维护一个全局的(或称为运行时的)均值 (running mean) (mu_{run}^{(k)}) 和方差 (running variance) ((sigma_{run}{(k)})2)。这些全局统计量通常通过对训练过程中每个小批量的均值和方差进行指数加权移动平均 (EWMA) 来估算:
[ mu_{run}^{(k)} leftarrow ext{momentum} cdot mu_{run}^{(k)} + (1 – ext{momentum}) cdot mu_{mathcal{B}}^{(k)} ]
[ (sigma_{run}{(k)})2 leftarrow ext{momentum} cdot (sigma_{run}{(k)})2 + (1 – ext{momentum}) cdot (sigma_{mathcal{B}}{(k)})2 ]
其中 momentum 是一个超参数,通常接近1(例如0.9或0.99)。(mu_{run}^{(k)}) 初始化为0,((sigma_{run}{(k)})2) 初始化为1。
(注意:PyTorch中momentum参数的定义可能与上述公式相反,例如 ( (1- ext{momentum}) cdot mu_{run} + ext{momentum} cdot mu_{mathcal{B}} )。需要查阅具体框架的文档。)
在PyTorch中,momentum参数的含义是:running_mean = (1 - momentum) * running_mean + momentum * batch_mean。所以,如果希望历史平均占主导,momentum应该设小(接近0);如果希望当前批次平均影响大,momentum应该设大(接近1)。默认值通常是0.1,意味着新的运行统计量是90%的历史值和10%的当前批次值的组合。

推理阶段 (模型评估或部署时):

在推理阶段,我们通常是逐个样本或以不同于训练时的批量大小进行预测,此时计算小批量均值和方差可能不可行或不准确(例如,batch size为1时方差为0)。
因此,在推理阶段,BN层不使用当前小批量的均值和方差,而是使用在整个训练过程中累积得到的全局运行均值 (mu_{run}^{(k)}) 和全局运行方差 ((sigma_{run}{(k)})2) 来进行归一化。
归一化公式变为:
[ hat{x}i^{(k)} = frac{x_i^{(k)} – mu{run}{(k)}}{sqrt{(sigma_{run}{(k)})^2 + epsilon}} ]
然后同样使用训练好的 (gamma^{(k)}) 和 (eta^{(k)}) 进行缩放和平移:
[ y_i^{(k)} = gamma^{(k)} hat{x}_i^{(k)} + eta^{(k)} ]
这意味着在推理时,BN层变成了一个固定的线性变换(对于每个特征维度)。

E. 对于卷积神经网络 (CNN) 的批归一化

在CNN中,BN的应用方式略有不同,但核心思想一致。我们希望归一化能够遵循卷积操作的特性,即同一特征图的不同位置应该共享相同的归一化参数。

对于一个卷积层产生的输出特征图(例如,形状为 [N, C, H, W],其中N是批量大小,C是通道数/特征图数量,H是高,W是宽),BN不是对每个单独的像素位置进行归一化,也不是对整个特征图的所有元素一起归一化。
相反,BN是**逐通道 (per-channel)**进行的。对于第 © 个通道(特征图),它会收集该通道在当前小批量中所有样本、所有空间位置(H x W)的激活值,然后计算这个通道的均值 (mu_{mathcal{B}}^{©}) 和方差 ((sigma_{mathcal{B}}{©})2)。
[ mu_{mathcal{B}}^{©} = frac{1}{N cdot H cdot W} sum_{n=1}^{N} sum_{h=1}^{H} sum_{w=1}^{W} x_{n,c,h,w} ]
[ (sigma_{mathcal{B}}{©})2 = frac{1}{N cdot H cdot W} sum_{n=1}^{N} sum_{h=1}^{H} sum_{w=1}^{W} (x_{n,c,h,w} – mu_{mathcal{B}}{©})2 ]
然后,使用这个通道的均值和方差,对该通道的所有激活值进行归一化。
每个通道 © 也会有其自己的一对可学习参数 (gamma^{©}) 和 (eta^{©})。这两个参数会应用于该通道的所有空间位置。
运行均值和运行方差也是逐通道维护的。

这种逐通道的BN方式保留了卷积操作的空间共享特性。

3.3.3 批归一化的优点

加速训练收敛:

通过减少内部协变量偏移,BN使得网络各层的输入分布更加稳定,优化曲面更平滑,从而允许使用更大的学习率进行训练,显著加速了模型的收敛速度。
BN使得深层网络的训练更加容易和稳定。

降低对权重初始化的敏感性:

由于BN对每层的输入进行了归一化,它在一定程度上减轻了网络对权重初始化策略的依赖。即使初始权重设置得不是最优,BN也能帮助网络更快地进入良好的学习状态。

具有一定的正则化效果:

在训练时,BN使用小批量的均值和方差进行归一化。由于每个小批量是随机抽取的,其均值和方差会带有一定的随机噪声。这种噪声可以看作是一种隐式的正则化,类似于Dropout,有助于提高模型的泛化能力。
因此,在使用BN时,有时可以减少其他正则化技术(如Dropout或L2正则化)的强度,甚至完全移除Dropout。

允许使用饱和型激活函数 (如Sigmoid, Tanh):

BN可以将激活函数的输入拉回到其非饱和区域,从而缓解了由于输入过大或过小导致的梯度消失问题,使得这些激活函数在深层网络中也能得到有效使用(尽管ReLU及其变体仍然是主流)。

改善梯度流:

通过稳定输入分布和防止激活值过大或过小,BN有助于梯度在网络中更顺畅地传播,减少梯度消失或爆炸的风险。

3.3.4 使用批归一化的注意事项与潜在问题

批量大小 (Batch Size):

BN的性能在一定程度上依赖于批量大小。因为它是使用小批量的统计量(均值和方差)来近似全局统计量的。
如果批量大小非常小 (例如,2, 4, 8),小批量的均值和方差可能会有很大的噪声,与全局统计量的偏差较大,这可能导致BN的效果下降,甚至损害性能。此时,运行均值和方差的估计也会不准确。
如果批量大小过大,虽然统计量更准确,但可能会失去BN带来的一些正则化效果。同时,过大的批量大小本身也可能导致泛化能力下降(模型可能收敛到更尖锐的最小值)。
对于BN,通常建议使用中等大小的批量(例如,32, 64, 128, 256)。如果由于内存限制只能使用非常小的批量,可以考虑使用其他归一化方法(如Layer Normalization, Group Normalization)。

训练与推理模式的切换:

务必确保在训练时将模型设置为 model.train() 模式,在推理时设置为 model.eval() 模式。
model.train(): BN层会使用当前小批量的均值/方差,并更新运行均值/方差。
model.eval(): BN层会使用累积的运行均值/方差,并且不更新它们。
如果在推理时忘记切换到 eval() 模式,BN层仍然会尝试使用当前(可能是非常小的)批量的统计量,并且如果这个批量与训练时的批量分布差异很大,会导致预测结果非常不稳定和错误。

BN层的位置:

原始论文建议将BN层放在线性变换(卷积或全连接)之后、非线性激活函数之前。
例如: Conv -> BN -> ReLULinear -> BN -> ReLU
这样做的理由是,BN可以规范化即将进入激活函数的输入,使其处于激活函数梯度较大的区域。
也有一些研究探讨了将BN放在激活函数之后的位置,但主流做法仍然是放在激活函数之前。

与Dropout的兼容性:

一些研究指出,同时使用BN和Dropout可能不是最优的,因为它们的目标和引入噪声的方式可能存在一些冲突或冗余。
BN的随机性来自于小批量采样,而Dropout的随机性来自于神经元丢弃。
有观点认为,BN的正则化效果可能使得Dropout不再那么必要,或者两者一起使用时需要仔细调整超参数。
经验法则:如果使用BN,可以先尝试不使用Dropout,或者使用较小的Dropout rate。

对学习率调度的影响:

BN使得损失曲面更平滑,可能使得学习率调度策略不像没有BN时那么关键,但合适的学习率调度仍然是有益的。

不适用于某些特定类型的网络或任务:

例如,在循环神经网络 (RNN) 中,对每个时间步的隐藏状态进行标准BN操作比较困难,因为序列长度可变,且每个时间步的统计量可能不同。RNN通常使用 Layer Normalization 或其他特定于序列的归一化方法。
对于生成对抗网络 (GAN) 的某些部分,BN的使用也需要小心,有时可能会引入不必要的伪影。

3.3.5 批归一化的实现 (PyTorch)

PyTorch 提供了 nn.BatchNorm1d (用于全连接层的输出或类似的一维数据), nn.BatchNorm2d (用于卷积层的输出,即2D特征图), 和 nn.BatchNorm3d (用于3D卷积的输出)。

import torch
import torch.nn as nn

# --- BatchNorm1d (用于例如全连接层的输出) ---
# 假设我们有一个全连接层的输出,形状为 [N, C]
# N是批量大小, C是特征数量 (num_features)
N_1d, C_1d = 4, 3 # 批量大小为4,每个样本有3个特征
input_1d = torch.randn(N_1d, C_1d) * 2 + 5 # 创建一些随机输入数据,均值和方差不为0和1

# 创建一个BatchNorm1d层,需要指定 num_features (即C_1d)
bn1d_layer = nn.BatchNorm1d(num_features=C_1d, eps=1e-5, momentum=0.1, affine=True, track_running_stats=True)
# num_features: 输入特征的数量C
# eps: 防止除以0的小数,默认为1e-5
# momentum: 用于计算running_mean和running_var的动量,默认为0.1
#           PyTorch公式: running_stat = (1-momentum)*running_stat + momentum*batch_stat
# affine: 布尔值,如果为True,则该模块具有可学习的仿射参数 gamma 和 beta (默认True)
# track_running_stats: 布尔值,如果为True,则该模块跟踪运行统计数据 (默认True)

# 训练模式
bn1d_layer.train() # 设置为训练模式
output_1d_train = bn1d_layer(input_1d) # 前向传播

print("--- BatchNorm1d ---")
print("Input (1D):
", input_1d) # 打印输入数据
print(f"Shape of input: {
              input_1d.shape}") # 打印输入形状
print("Output in train mode (1D):
", output_1d_train) # 打印训练模式下的输出
print(f"Shape of output: {
              output_1d_train.shape}") # 打印输出形状

# 查看BN层的参数和运行统计量
print("Gamma (weight):
", bn1d_layer.weight) # 可学习的 gamma (初始化为1)
print("Beta (bias):
", bn1d_layer.bias)     # 可学习的 beta (初始化为0)
print("Running Mean:
", bn1d_layer.running_mean) # 运行均值 (在一次前向传播后会被更新)
print("Running Var:
", bn1d_layer.running_var)   # 运行方差 (在一次前向传播后会被更新)

# 模拟多次训练迭代以观察运行统计量的变化
print("
Simulating a few training steps for BatchNorm1d...")
for i in range(3):
    current_input = torch.randn(N_1d, C_1d) * (i+1) + (i*2) # 每次迭代使用不同分布的输入
    _ = bn1d_layer(current_input) # 通过BN层
    print(f"After step {
              i+1}:")
    print("  Running Mean:", bn1d_layer.running_mean) # 打印更新后的运行均值
    print("  Running Var: ", bn1d_layer.running_var)   # 打印更新后的运行方差


# 推理模式
bn1d_layer.eval() # 设置为评估模式
# 假设有一个新的输入用于推理
input_1d_eval = torch.randn(2, C_1d) * 3 - 2 # 不同的批量大小和分布
output_1d_eval = bn1d_layer(input_1d_eval) # 前向传播

print("
Output in eval mode (1D) using running stats:
", output_1d_eval) # 打印评估模式下的输出
# 在eval模式下,running_mean 和 running_var 不会再被更新
print("Running Mean (after eval pass):
", bn1d_layer.running_mean) # 运行均值 (评估模式下不变)
print("Running Var (after eval pass):
", bn1d_layer.running_var)   # 运行方差 (评估模式下不变)


# --- BatchNorm2d (用于例如卷积层的输出) ---
# 假设我们有一个卷积层的输出,形状为 [N, C, H, W]
# N是批量大小, C是通道数, H是高, W是宽
N_2d, C_2d, H_2d, W_2d = 4, 3, 5, 5 # 批量大小4,3个通道,特征图大小5x5
input_2d = torch.randn(N_2d, C_2d, H_2d, W_2d) * 5 + 10 # 创建随机2D输入数据

# 创建一个BatchNorm2d层,需要指定 num_features (即通道数C_2d)
bn2d_layer = nn.BatchNorm2d(num_features=C_2d) # 使用默认参数

# 训练模式
bn2d_layer.train()
output_2d_train = bn2d_layer(input_2d)

print("

--- BatchNorm2d ---")
print(f"Shape of input (2D): {
              input_2d.shape}") # 打印2D输入形状
print(f"Shape of output in train mode (2D): {
              output_2d_train.shape}") # 打印2D训练模式输出形状

# 查看BN2d层的参数和运行统计量 (gamma, beta, running_mean, running_var 都是长度为C_2d的向量)
print("Gamma (weight) shape:", bn2d_layer.weight.shape) # gamma的形状 (C_2d,)
print("Beta (bias) shape:", bn2d_layer.bias.shape)     # beta的形状 (C_2d,)
print("Running Mean shape:", bn2d_layer.running_mean.shape) # 运行均值的形状 (C_2d,)
print("Running Var shape:", bn2d_layer.running_var.shape)   # 运行方差的形状 (C_2d,)

# 推理模式
bn2d_layer.eval()
input_2d_eval = torch.randn(2, C_2d, H_2d, W_2d) # 新的推理输入
output_2d_eval = bn2d_layer(input_2d_eval)
print(f"Shape of output in eval mode (2D): {
              output_2d_eval.shape}") # 打印2D评估模式输出形状


# 示例:在简单的CNN中使用BatchNorm2d
class SimpleCNNWithBN(nn.Module): # 定义一个包含BN的简单CNN
    def __init__(self, num_classes=10): # 初始化方法
        super(SimpleCNNWithBN, self).__init__() # 调用父类初始化
        self.conv1 = nn.Conv2d(in_channels=1, out_channels=16, kernel_size=3, stride=1, padding=1) # 第一个卷积层
        self.bn1 = nn.BatchNorm2d(num_features=16) # 对应的BN层
        self.relu1 = nn.ReLU() # ReLU激活
        self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 最大池化层

        self.conv2 = nn.Conv2d(in_channels=16, out_channels=32, kernel_size=3, stride=1, padding=1) # 第二个卷积层
        self.bn2 = nn.BatchNorm2d(num_features=32) # 对应的BN层
        self.relu2 = nn.ReLU() # ReLU激活
        self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # 最大池化层
        
        # 假设输入是 28x28 的图像 (例如MNIST)
        # 经过 conv1 (28x28) -> pool1 (14x14)
        # 经过 conv2 (14x14) -> pool2 (7x7)
        # 全连接层输入特征数量: 32通道 * 7高 * 7宽
        self.fc = nn.Linear(32 * 7 * 7, num_classes) # 全连接层

    def forward(self, x): # 前向传播方法
        x = self.conv1(x)       # 卷积
        x = self.bn1(x)         # 批归一化
        x = self.relu1(x)       # 激活
        x = self.pool1(x)       # 池化

        x = self.conv2(x)       # 卷积
        x = self.bn2(x)         # 批归一化
        x = self.relu2(x)       # 激活
        x = self.pool2(x)       # 池化
        
        x = x.view(x.size(0), -1) #展平特征图以输入到全连接层, x.size(0)是批量大小
        x = self.fc(x)          # 全连接层
        return x

# 创建模型实例
cnn_model = SimpleCNNWithBN(num_classes=10) # 实例化模型

# 打印模型结构
print("

Simple CNN with BatchNorm structure:
", cnn_model) # 打印模型结构

# 测试模型在训练和评估模式下的行为
dummy_input_cnn = torch.randn(4, 1, 28, 28) # 模拟一批输入数据 (4个样本, 1通道, 28x28图像)

cnn_model.train() # 设置为训练模式
print("
CNN model in TRAIN mode. BN layers will use batch stats and update running stats.")
out_cnn_train = cnn_model(dummy_input_cnn) # 前向传播
print("Output shape (train):", out_cnn_train.shape) # 打印训练模式输出形状
# (此时bn1和bn2的running_mean和running_var会被更新)

cnn_model.eval() # 设置为评估模式
print("
CNN model in EVAL mode. BN layers will use running stats and NOT update them.")
out_cnn_eval = cnn_model(dummy_input_cnn) # 前向传播
print("Output shape (eval):", out_cnn_eval.shape) # 打印评估模式输出形状
# (此时bn1和bn2的running_mean和running_var不会被更新,而是被使用)

这个PyTorch示例清晰地展示了 BatchNorm1dBatchNorm2d 的基本用法,包括如何在训练和评估模式之间切换,以及它们在简单CNN中的典型应用位置(卷积层之后,激活函数之前)。

3.3.6 其他归一化方法简介

尽管批归一化非常成功,但它并非万能的,尤其是在小批量或特定网络架构(如RNN)中存在局限性。因此,研究者们也提出了其他归一化技术:

A. 层归一化 (Layer Normalization, LN)

提出: 由 Jimmy Lei Ba, Jamie Ryan Kiros, Geoffrey E. Hinton 在2016年提出。
思想: LN 不是在批量维度上计算均值和方差,而是在单个样本的特征维度上进行计算。

对于一个样本 (x) (一个向量),LN计算该样本所有特征的均值和方差,然后用它们来归一化这个样本的每个特征。
[ mu_i = frac{1}{H} sum_{j=1}^{H} x_{ij} quad ( ext{均值在第i个样本的H个特征上计算}) ]
[ sigma_i^2 = frac{1}{H} sum_{j=1}^{H} (x_{ij} – mu_i)^2 quad ( ext{方差在第i个样本的H个特征上计算}) ]
[ hat{x}{ij} = frac{x{ij} – mu_i}{sqrt{sigma_i^2 + epsilon}} ]
同样有可学习的缩放参数 (gamma) 和平移参数 (eta),但它们通常是逐特征(或共享)的,而不是像BN中与批量统计量无关。

特点:

与批量大小无关: LN的计算完全在单个样本内部进行,不依赖于其他样本,因此它对批量大小不敏感,即使批量大小为1也能很好地工作。
训练和推理行为一致: 由于不依赖批量统计,LN在训练和推理阶段的操作是相同的,不需要维护运行统计量。
常用于RNN和Transformer: LN在循环神经网络 (RNN, LSTM, GRU) 和 Transformer 模型中非常流行,因为这些模型处理变长序列或在批量维度上的依赖性较弱。

B. 实例归一化 (Instance Normalization, IN)

提出: 由 Dmitry Ulyanov, Andrea Vedaldi, Victor Lempitsky 在2016年主要为风格迁移任务提出。
思想: IN可以看作是LN在卷积层特征图上的一个特例。它对每个样本的每个通道独立地计算均值和方差,并进行归一化。

对于特征图 [N, C, H, W],IN对每个 (n in N) 和每个 (c in C) 的 (H imes W) 的平面分别计算均值和方差。

特点:

与批量大小无关。
在图像风格迁移和某些生成任务中表现良好,因为它能去除图像实例特定的对比度信息,有助于分离内容和风格。
对于需要保留实例特定信息的判别任务(如图像分类),效果可能不如BN。

C. 组归一化 (Group Normalization, GN)

提出: 由 Yuxin Wu 和 Kaiming He 在2018年提出,作为BN在小批量下性能下降的一个解决方案。
思想: GN介于LN和IN之间。它首先将一个样本的通道维度分成若干个组 (groups),然后在每个组内部、跨越空间维度 (H, W) 计算均值和方差进行归一化。

例如,一个有32个通道的特征图,如果分为4个组,则每组包含8个通道。GN会对每个样本的这4个组分别进行归一化。

特点:

与批量大小无关。
性能通常优于LN和IN,尤其是在批量大小较小的情况下,能达到接近BN在大批量下的性能。
组的数量是一个超参数。如果组数为1,GN等效于LN;如果组数等于通道数,GN等效于IN。

D. 开关归一化 (Switchable Normalization, SN), 同步批归一化 (Synchronized Batch Normalization, SyncBN) 等其他变体也致力于解决BN的特定问题或在特定场景下提供更好的性能。SyncBN主要用于分布式训练,确保BN在不同设备/GPU上使用整个全局批量的统计数据,而不是各个子批量的。

总结批归一化及其变体:

方法 归一化维度 依赖批量大小 训练/推理行为 主要应用场景
批归一化 (BN) 批量维度 (对每个特征/通道,在批量样本间计算) 不同 CNN, MLP (中到大批量)
层归一化 (LN) 特征维度 (对每个样本,在其所有特征间计算) 相同 RNN, Transformer, MLP
实例归一化 (IN) 空间维度 (对每个样本的每个通道独立计算) 相同 风格迁移, 图像生成
组归一化 (GN) 通道组+空间维度 (对每个样本的每个通道组内计算) 相同 CNN (小批量替代BN), MLP

选择哪种归一化方法取决于具体的应用场景、网络架构和可用的批量大小。批归一化因其在许多计算机视觉任务中的卓越表现而成为主流,但了解其替代方案对于处理BN不适用的情况也非常重要。

3.4 学习率调度 (Learning Rate Scheduling / Annealing)

3.4.1 为什么需要学习率调度?

学习率 (eta) 是梯度下降类优化算法中最重要的超参数之一。它控制了参数更新的步长。

固定的学习率面临的挑战:

如果学习率设置过大: 优化过程可能在最小值附近震荡,难以收敛,甚至可能直接“跨过”最优解导致损失发散。
如果学习率设置过小: 优化过程会非常缓慢,需要大量的迭代才能达到一个较好的解,增加了训练时间和计算成本。
在训练的不同阶段,理想的学习率可能是不同的。

训练初期: 损失函数通常比较陡峭,参数离最优解较远,使用较大的学习率可以帮助模型快速向最优区域靠近。
训练后期: 当参数接近最优解时,损失曲面可能变得比较平坦,或者包含许多细微的结构。此时,较大的学习率容易导致参数在最优解附近震荡,难以精确收敛。减小学习率有助于模型更精细地探索这些区域,找到更好的局部最小值。

学习率调度(也常称为学习率退火 Learning Rate Annealing)就是一种在训练过程中动态调整学习率的策略。 其目标是根据训练的进展(例如,迭代次数、验证集性能等)来自动改变学习率,以期达到更快的收敛速度和更好的最终性能。

3.4.2 常见的学习率调度策略

有多种成熟的学习率调度策略,它们可以大致分为基于时间的衰减、基于性能的衰减以及周期性学习率等。

A. 基于时间的衰减 (Time-based Decay)

这类策略的学习率主要根据当前的训练迭代次数或轮数 (epoch) 进行调整。

步进衰减 (Step Decay / MultiStepLR):

思想: 在预设的几个训练轮数(milestones)之后,将学习率乘以一个衰减因子(通常小于1,例如0.1或0.5)。
公式 (概念性):
[ eta_t = eta_0 cdot ext{decay_rate}^{lfloor ext{epoch} / ext{step_size}
floor} ]
或者,在特定的 (epoch_1, epoch_2, …) 时,(eta leftarrow eta cdot ext{decay_rate})。
优点: 实现简单,直观。
缺点: 需要手动设置衰减的轮数和衰减率,这些超参数可能需要仔细调整。
PyTorch 实现: torch.optim.lr_scheduler.StepLRtorch.optim.lr_scheduler.MultiStepLR

import torch
import torch.nn as nn
import torch.optim as optim

# 假设模型和优化器已经定义
model_step = nn.Linear(10, 1) # 示例模型
optimizer_step = optim.SGD(model_step.parameters(), lr=0.1) # 初始学习率0.1

# StepLR: 每隔 step_size 个 epochs,学习率乘以 gamma
# 例如,每30个epochs,学习率变为原来的0.1倍
scheduler_step = optim.lr_scheduler.StepLR(optimizer_step, step_size=30, gamma=0.1) 

# MultiStepLR: 在指定的 milestones (epochs列表) 处,学习率乘以 gamma
# 例如,在第50、75、90个epoch时,学习率变为原来的0.1倍
# scheduler_multistep = optim.lr_scheduler.MultiStepLR(optimizer_step, milestones=[50, 75, 90], gamma=0.1)

print("Initial learning rate:", optimizer_step.param_groups[0]['lr']) # 打印初始学习率

# 模拟训练循环
num_epochs_step = 100 # 总训练轮数
for epoch in range(num_epochs_step): # 迭代训练轮数
    # ... (训练代码:前向传播,计算损失,反向传播) ...
    # optimizer_step.step() # 更新参数 (通常在scheduler.step()之前)
    
    # 在每个epoch结束(或开始)时调用scheduler.step()
    scheduler_step.step() # 更新学习率
    
    if (epoch + 1) % 10 == 0 or epoch == 0: # 每10轮或第一轮打印一次学习率
        current_lr = optimizer_step.param_groups[0]['lr'] # 获取当前学习率
        print(f"Epoch {
                epoch+1}, Current Learning Rate: {
                current_lr:.6f}") # 打印当前轮和学习率

指数衰减 (Exponential Decay / ExponentialLR):

思想: 每个epoch(或每一步)学习率都乘以一个固定的衰减因子 (gamma < 1)。
公式:
[ eta_t = eta_0 cdot gamma^t ]
其中 (t) 是轮数或迭代次数。
优点: 平滑地降低学习率。
缺点: 学习率可能衰减得过快或过慢,取决于 (gamma) 的选择。
PyTorch 实现: torch.optim.lr_scheduler.ExponentialLR

# optimizer_exp = optim.SGD(model_step.parameters(), lr=0.1) # 初始学习率0.1
# # ExponentialLR: 每个epoch学习率乘以gamma
# # 例如,每个epoch学习率变为原来的0.95倍
# scheduler_exp = optim.lr_scheduler.ExponentialLR(optimizer_exp, gamma=0.95) 

# print("
Initial learning rate (ExponentialLR):", optimizer_exp.param_groups[0]['lr'])
# for epoch in range(20): # 模拟20轮
#     # ... train ...
#     # optimizer_exp.step()
#     scheduler_exp.step()
#     current_lr = optimizer_exp.param_groups[0]['lr']
#     print(f"Epoch {epoch+1}, Current Learning Rate: {current_lr:.6f}")

逆时衰减 (Inverse Time Decay):

思想: 学习率与迭代次数成反比。
公式 (一种形式):
[ eta_t = frac{eta_0}{1 + ext{decay_rate} cdot t} ]
其中 (t) 是轮数或迭代次数。
优点: 学习率随时间平滑下降。
缺点: 需要调整 decay_rate

B. 基于性能的衰减 (Performance-based Decay)

这类策略的学习率调整依赖于模型在验证集上的性能指标(如验证损失或准确率)。

按需降低学习率 (ReduceLROnPlateau):

思想: 当某个监控的指标(通常是验证损失)在连续几个epoch内没有改善(即“停滞不前” on a plateau)时,降低学习率。
参数:

factor: 学习率衰减的乘数因子(例如0.1, 0.5)。
patience: 容忍多少个epoch指标没有改善。
threshold: 判断指标是否有“显著”改善的阈值。
cooldown: 学习率降低后,等待多少个epoch再恢复正常的监控。
min_lr: 学习率的下限。

优点:

自适应性强,只有当模型学习遇到瓶颈时才降低学习率。
通常比基于时间的衰减更鲁棒,不需要预先设定衰减点。

缺点:

需要一个可靠的验证集和评估指标。
参数(如patience, factor)仍需调整。

PyTorch 实现: torch.optim.lr_scheduler.ReduceLROnPlateau

注意: ReduceLROnPlateauscheduler.step() 方法需要传入当前监控的指标值 (e.g., scheduler.step(val_loss))。

import torch
import torch.nn as nn
import torch.optim as optim

# 假设模型、优化器、训练和验证数据加载器、损失函数已定义
model_plateau = nn.Linear(10,1) # 示例模型
optimizer_plateau = optim.SGD(model_plateau.parameters(), lr=0.1) # 初始学习率0.1
criterion_plateau = nn.MSELoss() # 损失函数

# 模拟训练和验证数据加载器
dummy_train_loader = [(torch.randn(8,10), torch.randn(8,1)) for _ in range(10)] # 10批训练数据
dummy_val_loader = [(torch.randn(8,10), torch.randn(8,1)) for _ in range(5)]   # 5批验证数据

# ReduceLROnPlateau: 当指标停止改善时降低学习率
# mode='min': 当监控的指标停止下降时触发 (例如监控损失)
# mode='max': 当监控的指标停止上升时触发 (例如监控准确率)
# factor: 新学习率 = 旧学习率 * factor
# patience: 连续patience个epoch指标未改善则降低LR
# verbose=True: 降低LR时打印信息
scheduler_plateau = optim.lr_scheduler.ReduceLROnPlateau(
    optimizer_plateau, 
    mode='min',      # 监控验证损失,希望它减小
    factor=0.2,      # 学习率衰减为原来的0.2倍
    patience=5,      # 连续5个epoch验证损失没有明显改善
    threshold=0.001, # 改善小于0.001被认为没有改善
    verbose=True,    # 打印学习率变化信息
    min_lr=1e-6      # 学习率最小不低于1e-6
)

print("
Initial learning rate (ReduceLROnPlateau):", optimizer_plateau.param_groups[0]['lr'])
num_epochs_plateau = 50 # 总训练轮数

for epoch in range(num_epochs_plateau): # 迭代训练轮数
    model_plateau.train() # 设置模型为训练模式
    for data, target in dummy_train_loader: # 遍历训练数据
        optimizer_plateau.zero_grad() # 清除梯度
        output = model_plateau(data) # 前向传播
        loss = criterion_plateau(output, target) # 计算损失
        loss.backward() # 反向传播
        optimizer_plateau.step() # 更新参数
        
    model_plateau.eval() # 设置模型为评估模式
    current_val_loss = 0
    with torch.no_grad(): # 不计算梯度
        for data, target in dummy_val_loader: # 遍历验证数据
            output = model_plateau(data) # 前向传播
            loss = criterion_plateau(output, target) # 计算损失
            current_val_loss += loss.item() # 累加损失
    avg_val_loss = current_val_loss / len(dummy_val_loader) # 计算平均验证损失
    
    print(f"Epoch {
                epoch+1}, Val Loss: {
                avg_val_loss:.4f}, Current LR: {
                optimizer_plateau.param_groups[0]['lr']:.6f}") # 打印信息
    
    # ReduceLROnPlateau的step需要传入监控的指标
    scheduler_plateau.step(avg_val_loss) # 根据验证损失更新学习率
    
    if optimizer_plateau.param_groups[0]['lr'] <= scheduler_plateau.min_lrs[0] + 1e-8: # 如果学习率达到最小值附近
         print("Learning rate reached minimum. Stopping.") # 打印信息
         break # 停止训练

C. 周期性学习率 (Cyclical Learning Rates, CLR) 和 带重启的随机梯度下降 (SGDR)

这类策略引入了学习率在训练过程中周期性变化的思想,而不是单调下降。

周期性学习率 (Cyclical Learning Rates, CLR):

提出: Leslie N. Smith 在2015-2017年提出。
思想: 学习率在一个预设的最小值 (( ext{base_lr})) 和最大值 (( ext{max_lr})) 之间周期性地变化(例如,线性增加再线性减少,形成一个“三角形”或“三角2”波形)。
周期长度 (stepsize_up/down): 一个周期包含学习率从 ( ext{base_lr}) 上升到 ( ext{max_lr}) 所需的迭代次数,以及从 ( ext{max_lr}) 下降回 ( ext{base_lr}) 所需的迭代次数。
优点:

有助于跳出鞍点和尖锐的局部最小值: 周期性地增大学习率可以提供足够的“动能”来克服这些障碍。
减少了寻找最优初始学习率和衰减策略的麻烦: 作者认为,通过在一个合理范围内周期性地改变学习率,模型有更多机会找到好的解。通常 ( ext{base_lr}) 和 ( ext{max_lr}) 的范围可以通过一个简单的“LR Range Test”来确定。
有时能达到比传统衰减策略更快的收敛速度和更好的性能。

PyTorch 实现: torch.optim.lr_scheduler.CyclicLR

# model_cyclic = nn.Linear(10,1)
# optimizer_cyclic = optim.SGD(model_cyclic.parameters(), lr=0.001) # 初始LR设为base_lr或任意值,会被CyclicLR覆盖

# # CyclicLR: 学习率在 base_lr 和 max_lr 之间周期性变化
# # base_lr: 学习率下界
# # max_lr: 学习率上界
# # step_size_up: 从base_lr到max_lr所需的迭代次数 (不是epochs)
# # step_size_down: 从max_lr到base_lr所需的迭代次数 (如果为None,则等于step_size_up)
# # mode: 'triangular', 'triangular2' (每个周期max_lr减半), 'exp_range' (max_lr指数衰减)
# # gamma: 'exp_range'模式下的指数衰减因子
# # cycle_momentum: 是否同步周期性调整优化器的momentum (如果优化器支持momentum)

# # 假设每个epoch有100个batch/iterations
# iterations_per_epoch_cyclic = 100 
# # 一个周期设置为 2 * iterations_per_epoch (例如,2个epochs)
# step_size_up_cyclic = 2 * iterations_per_epoch_cyclic 

# scheduler_cyclic = optim.lr_scheduler.CyclicLR(
#     optimizer_cyclic, 
#     base_lr=0.001,        # 学习率下限
#     max_lr=0.1,          # 学习率上限
#     step_size_up=step_size_up_cyclic, # 上升阶段的迭代次数
#     mode='triangular2',  # 使用triangular2模式
#     cycle_momentum=False # 不调整momentum (SGD默认momentum=0)
# )

# print(f"
CyclicLR: base_lr=0.001, max_lr=0.1, one cycle = {2*step_size_up_cyclic} iterations")
# lrs_cyclic = [] # 用于存储学习率变化
# num_total_iterations_cyclic = 10 * iterations_per_epoch_cyclic # 模拟10个epochs的迭代

# for iteration in range(num_total_iterations_cyclic): # 迭代次数
#     # ... (训练: data loading, forward, loss, backward) ...
#     # optimizer_cyclic.step() # 更新参数
    
#     # CyclicLR通常在每次迭代(batch)后更新
#     scheduler_cyclic.step() # 更新学习率
#     lrs_cyclic.append(optimizer_cyclic.param_groups[0]['lr']) # 记录当前学习率
    
#     # if (iteration + 1) % iterations_per_epoch_cyclic == 0: # 每个epoch结束时打印
#     #     epoch_num = (iteration + 1) // iterations_per_epoch_cyclic
#     #     print(f"End of Epoch {epoch_num}, Current LR: {lrs_cyclic[-1]:.6f}")

# # # 绘制学习率曲线
# # import matplotlib.pyplot as plt
# # plt.figure(figsize=(10,4))
# # plt.plot(lrs_cyclic)
# # plt.xlabel("Iteration")
# # plt.ylabel("Learning Rate")
# # plt.title("CyclicLR (triangular2) Learning Rate Schedule")
# # plt.show()

带热重启的随机梯度下降 (Stochastic Gradient Descent with Warm Restarts, SGDR / CosineAnnealingWarmRestarts):

提出: Loshchilov & Hutter 在2017年提出。
思想: 学习率在一个周期内从一个初始值(通常较大)按照余弦函数的形式平滑地退火(衰减)到一个较小的值(例如0)。在一个周期结束后,学习率被“重启 (restart)”回初始值,开始下一个周期。每个后续周期的长度可以逐渐增加。
公式 (单个周期内):
[ eta_t = eta_{min} + frac{1}{2}(eta_{max} – eta_{min})(1 + cos(frac{T_{cur}}{T_i}pi)) ]
其中:

(eta_t) 是当前迭代的学习率。
(eta_{min}) 是学习率下限(通常为0)。
(eta_{max}) 是学习率上限(初始学习率)。
(T_{cur}) 是当前周期内已经过的迭代次数。
(T_i) 是当前周期的总迭代次数。

重启 (Restarts): 当 (T_{cur} = T_i) 时,一个周期结束,学习率被重置为 (eta_{max})(或根据策略调整后的 (eta_{max})),(T_{cur}) 重置为0,(T_i) 可以保持不变或乘以一个因子 (T_{mult}) 来增加下一个周期的长度。
优点:

余弦退火本身是一种非常有效的平滑衰减策略。
热重启有助于模型跳出可能陷入的局部最小值,并探索损失曲面的其他区域。模型在每个周期结束时学习率降到很低,有助于收敛到局部最优点;重启后学习率突然增大,给模型一个“扰动”去寻找更好的解。
通常能取得很好的性能。

PyTorch 实现: torch.optim.lr_scheduler.CosineAnnealingLR (单个余弦周期) 和 torch.optim.lr_scheduler.CosineAnnealingWarmRestarts (带重启的余弦退火)。

# model_cos = nn.Linear(10,1)
# optimizer_cos = optim.SGD(model_cos.parameters(), lr=0.1) # 初始(最大)学习率0.1

# # CosineAnnealingLR: 单个余弦周期,从初始LR退火到eta_min
# # T_max: 一个周期的迭代次数 (通常设置为总迭代次数或一个较长的epoch数)
# # eta_min: 学习率下限 (默认为0)
# # scheduler_cosine_single = optim.lr_scheduler.CosineAnnealingLR(optimizer_cos, T_max=num_total_iterations_cyclic, eta_min=0.001)

# # CosineAnnealingWarmRestarts: 带重启的余弦退火
# # T_0: 第一个周期的迭代次数
# # T_mult: 每个重启后,周期长度的乘法因子 (T_i = T_i * T_mult)
# # eta_min: 学习率下限
# scheduler_cosine_restarts = optim.lr_scheduler.CosineAnnealingWarmRestarts(
#     optimizer_cos,
#     T_0 = 2 * iterations_per_epoch_cyclic, # 第一个周期2个epochs的迭代次数
#     T_mult = 1, # 后续周期长度不变 (如果T_mult=2,则周期长度会变为2, 4, 8...个epochs)
#     eta_min = 0.001 # 学习率下限
# )

# print(f"
CosineAnnealingWarmRestarts: eta_max=0.1, eta_min=0.001, T_0={2*iterations_per_epoch_cyclic} iterations, T_mult=1")
# lrs_cos_restarts = []
# for iteration in range(num_total_iterations_cyclic):
#     # ... train ...
#     # optimizer_cos.step()
#     scheduler_cosine_restarts.step() # 通常在每次迭代后更新
#     lrs_cos_restarts.append(optimizer_cos.param_groups[0]['lr'])

# # # 绘制学习率曲线
# # plt.figure(figsize=(10,4))
# # plt.plot(lrs_cos_restarts)
# # plt.xlabel("Iteration")
# # plt.ylabel("Learning Rate")
# # plt.title("CosineAnnealingWarmRestarts Learning Rate Schedule")
# # plt.show()

D. 预热 (Warmup)

思想: 在训练的最初几个epoch(或迭代)中,不直接使用设定的初始学习率,而是从一个非常小的学习率开始,逐渐线性(或非线性)增加到预设的初始学习率。之后再切换到主要的学习率调度策略(如Step Decay, Cosine Annealing等)。

动机:

在训练初期,模型参数是随机初始化的,离最优解较远,梯度可能很大且不稳定。如果一开始就使用较大的学习率,可能会导致训练过程不稳定,甚至发散。
预热阶段使用较小的学习率可以让模型参数先进行一些初步的调整,使得网络“稳定”下来,为后续使用较大(或正常)学习率的训练做好准备。
对于某些大型模型(如Transformer)和优化器(如Adam),预热被证明是非常重要的。

实现:
通常需要手动实现或使用一些第三方库。PyTorch的 optim.lr_scheduler 模块本身没有直接的“WarmupThenDecay”这种组合调度器,但可以通过组合或自定义调度器来实现。
一种常见的方式是先用 LinearLR (从 start_factor * base_lrbase_lr) 或 ConstantLR (一个小值) 进行预热,然后用 SequentialLR 将其与另一个主调度器(如 CosineAnnealingLR)连接起来。

# model_warmup = nn.Linear(10,1)
# # 初始学习率,这是预热结束后的目标学习率
# target_initial_lr = 0.1 
# optimizer_warmup = optim.SGD(model_warmup.parameters(), lr=target_initial_lr) 

# # 预热参数
# warmup_epochs = 5 # 预热5个epochs
# iterations_per_epoch_warmup = 100 # 假设每个epoch100次迭代
# warmup_total_iters = warmup_epochs * iterations_per_epoch_warmup # 总预热迭代次数

# # 主调度器 (预热结束后使用)
# # 例如,使用一个余弦退火,从target_initial_lr衰减到0,总共(total_epochs - warmup_epochs)个epochs
# total_epochs_main = 25 # 预热后的主训练轮数
# main_scheduler_iters = total_epochs_main * iterations_per_epoch_warmup # 主调度器的总迭代次数

# # 使用 LambdaLR 实现线性预热
# # lr = base_lr * lambda(epoch)
# # 在预热阶段,我们希望学习率从一个很小的值(例如 target_initial_lr * 0.01)线性增加到 target_initial_lr

# # 更简单的方式是使用 PyTorch 1.10+ 的 LinearLR 和 SequentialLR
# if hasattr(optim.lr_scheduler, 'LinearLR') and hasattr(optim.lr_scheduler, 'SequentialLR'):
#     # 预热调度器: 从 target_lr * start_factor 线性增加到 target_lr
#     scheduler_warmup_linear = optim.lr_scheduler.LinearLR(
#         optimizer_warmup, 
#         start_factor=0.01, # lr = lr * start_factor at iter 0
#         end_factor=1.0,    # lr = lr * end_factor at iter total_iters
#         total_iters=warmup_total_iters # 预热的总迭代次数
#     )
    
#     # 主调度器: 预热结束后使用,例如余弦退火
#     # 注意:这里 T_max 应该是预热之后剩余的迭代次数
#     scheduler_main_cosine = optim.lr_scheduler.CosineAnnealingLR(
#         optimizer_warmup,
#         T_max = main_scheduler_iters, # 预热之后的主调度迭代总数
#         eta_min = 0.0001
#     )
    
#     # 使用 SequentialLR 组合预热和主调度
#     # milestones: 指定在哪个迭代次数切换到下一个调度器
#     sequential_scheduler = optim.lr_scheduler.SequentialLR(
#         optimizer_warmup,
#         schedulers=[scheduler_warmup_linear, scheduler_main_cosine],
#         milestones=[warmup_total_iters] # 在 warmup_total_iters 次迭代后,从warmup切换到main
#     )
    
#     print(f"
Warmup + CosineAnnealing: Warmup for {warmup_total_iters} iters, then Cosine for {main_scheduler_iters} iters.")
#     lrs_warmup_seq = []
#     total_iterations_for_seq = warmup_total_iters + main_scheduler_iters
    
#     # 重置优化器的学习率,因为SequentialLR会基于优化器当前的LR和调度器的因子来计算
#     # 对于LinearLR,它会乘以 optimizer.base_lrs * start_factor
#     # 我们希望预热结束时达到 target_initial_lr
#     # 因此,在创建LinearLR之前,optimizer的lr应该是target_initial_lr
    
#     for iteration in range(total_iterations_for_seq):
#         # ... train ...
#         # optimizer_warmup.step()
#         sequential_scheduler.step() # 在每次迭代后更新
#         lrs_warmup_seq.append(optimizer_warmup.param_groups[0]['lr'])

#     # # 绘制学习率曲线
#     # plt.figure(figsize=(10,4))
#     # plt.plot(lrs_warmup_seq)
#     # plt.axvline(x=warmup_total_iters, color='r', linestyle='--', label=f'End of Warmup ({warmup_total_iters} iters)')
#     # plt.xlabel("Iteration")
#     # plt.ylabel("Learning Rate")
#     # plt.title("Linear Warmup + Cosine Annealing LR Schedule")
#     # plt.legend()
#     # plt.show()
# else:
#     print("
LinearLR or SequentialLR not available. Skipping warmup example with new schedulers.")
#     # 对于旧版PyTorch,可能需要手动实现或使用LambdaLR进行预热

(请注意,上述CLR, SGDR, Warmup的代码示例主要是为了演示调度器的配置和学习率变化趋势,实际使用时需要嵌入到完整的训练循环中,并且 iterations_per_epoch 需要根据实际数据加载器来确定。)

3.4.3 如何选择和使用学习率调度策略?

从简单开始:

Step DecayReduceLROnPlateau 是不错的起点,它们相对容易理解和调整。
对于许多任务,精心调整的Step Decay(例如,在总训练轮数的1/2和3/4处降低学习率)可以取得很好的效果。

考虑任务和模型:

对于非常深或大型的模型(如Transformer),带有预热的余弦退火 (Warmup + CosineAnnealingLR/WarmRestarts) 通常是当前非常流行且效果很好的选择。
CyclicLRSGDR 提供了更动态的探索机制,可能在某些复杂优化问题上表现优异。

初始学习率仍然重要:
即使使用了学习率调度,选择一个合适的初始学习率仍然非常关键。可以使用“LR Range Test”(Leslie Smith提出)等方法来辅助寻找一个好的初始学习率范围。这个测试通常包括从一个很小的学习率开始,在几个epoch内逐渐增大学习率,并记录损失的变化,找到损失开始下降和开始发散的LR区域。
监控训练过程:
可视化训练损失、验证损失以及学习率的变化曲线,可以帮助理解调度策略是否按预期工作,以及是否需要调整。
与优化器的配合:
某些调度策略可能与特定优化器配合得更好。例如,Adam等自适应优化器对初始学习率的选择相对不那么敏感,但学习率调度仍然可以提升其性能。
迭代 vs. 轮数:
注意调度器的 step() 是应该在每个批次(迭代)之后调用还是在每个轮数 (epoch) 之后调用。

StepLR, MultiStepLR, ExponentialLR, ReduceLROnPlateau (当监控epoch级指标时) 通常在每个epoch后调用。
CyclicLR, CosineAnnealingLR, CosineAnnealingWarmRestarts, LinearLR, ConstantLR 以及用于预热的 LambdaLR 通常在每次迭代后调用,因为它们依赖于更细粒度的迭代计数。
SequentialLRmilestones 通常也是以迭代次数为单位。

第四章:模型评估、选择与进阶优化

在精心设计并训练了我们的神经网络之后,下一步关键是准确地评估其性能,并根据评估结果选择最优的模型或进行进一步的优化。

4.1 模型评估指标 (Evaluation Metrics)

选择合适的评估指标对于衡量模型的性能至关重要。不同的机器学习任务(如分类、回归、目标检测等)有其各自常用的评估指标。仅仅依赖准确率(Accuracy)往往是不够的,尤其是在数据不平衡或特定错误类型代价更高的情况下。

4.1.1 分类任务的评估指标

对于分类任务,我们通常会已关注以下指标:

A. 混淆矩阵 (Confusion Matrix)

混淆矩阵是一个 (N imes N) 的表格(对于N分类问题),用于可视化模型预测结果与真实标签之间的关系。每一行代表真实类别,每一列代表预测类别。

对于二分类问题 (Positive/Negative),混淆矩阵通常如下:

预测为正 (Predicted Positive) 预测为负 (Predicted Negative)
真实为正 (Actual Positive) 真正例 (TP) 假负例 (FN)
真实为负 (Actual Negative) 假正例 (FP) 真负例 (TN)

真正例 (True Positives, TP): 模型正确地将正样本预测为正样本的数量。
假负例 (False Negatives, FN): 模型错误地将正样本预测为负样本的数量(漏报)。
假正例 (False Positives, FP): 模型错误地将负样本预测为正样本的数量(误报)。
真负例 (True Negatives, TN): 模型正确地将负样本预测为负样本的数量。

B. 准确率 (Accuracy)

定义: 模型正确预测的样本数占总样本数的比例。
[ ext{Accuracy} = frac{TP + TN}{TP + TN + FP + FN} ]
优点: 直观易懂。
缺点: 在类别不平衡(某些类别的样本数量远多于其他类别)的情况下,准确率可能具有误导性。例如,如果95%的样本是负类,一个总是预测负类的模型也能达到95%的准确率,但它对正类完全没有识别能力。

C. 精确率 (Precision)

定义: 在所有被模型预测为正样本的样本中,真正是正样本的比例。也称为查准率。
[ ext{Precision} = frac{TP}{TP + FP} ]
已关注点: “预测为正的样本中,有多少是真正确的?” 用于衡量模型的预测有多“准”。高精确率意味着假正例少。
应用场景: 当我们不希望将负样本错误地识别为正样本时(例如,垃圾邮件检测,宁可放过一些垃圾邮件(FN高),也不希望将正常邮件误判为垃圾邮件(FP低))。

D. 召回率 (Recall) / 灵敏度 (Sensitivity) / 真阳性率 (True Positive Rate, TPR)

定义: 在所有真实为正样本的样本中,被模型正确预测为正样本的比例。也称为查全率。
[ ext{Recall} = frac{TP}{TP + FN} ]
已关注点: “所有真实的正样本中,有多少被模型找到了?” 用于衡量模型有多“全”。高召回率意味着假负例少。
应用场景: 当我们不希望漏掉任何一个正样本时(例如,癌症诊断,宁可将一些健康人误诊为癌症(FP高),也不希望漏掉任何一个真正的癌症患者(FN低))。

E. F1 分数 (F1-Score)

定义: 精确率和召回率的调和平均数。它综合考虑了精确率和召回率。
[ F1 = 2 cdot frac{ ext{Precision} cdot ext{Recall}}{ ext{Precision} + ext{Recall}} = frac{2TP}{2TP + FP + FN} ]
优点: 当精确率和召回率都很重要,或者它们之间需要权衡时,F1分数是一个很好的综合指标。只有当精确率和召回率都较高时,F1分数才会高。
F(eta) 分数 (F(eta)-Score): F1分数是F(eta)分数的一个特例(当 (eta=1) 时)。F(eta)分数允许我们给予精确率或召回率不同的权重。
[ F_eta = (1 + eta^2) cdot frac{ ext{Precision} cdot ext{Recall}}{(eta^2 cdot ext{Precision}) + ext{Recall}} ]

当 (eta > 1) 时,更看重召回率。
当 (eta < 1) 时,更看重精确率。

F. 特异度 (Specificity) / 真阴性率 (True Negative Rate, TNR)

定义: 在所有真实为负样本的样本中,被模型正确预测为负样本的比例。
[ ext{Specificity} = frac{TN}{TN + FP} ]
已关注点: “所有真实的负样本中,有多少被模型正确识别了?”

G. 假阳性率 (False Positive Rate, FPR)

定义: 在所有真实为负样本的样本中,被模型错误预测为正样本的比例。
[ ext{FPR} = frac{FP}{TN + FP} = 1 – ext{Specificity} ]

H. ROC 曲线 (Receiver Operating Characteristic Curve)

定义: ROC曲线描述了在不同分类阈值下,真阳性率 (TPR, 即召回率) 与假阳性率 (FPR) 之间的关系。横轴是FPR,纵轴是TPR。
绘制方法: 分类模型通常输出一个概率值或置信度分数。通过改变区分正负类的阈值(例如,从0到1),可以得到一系列 (FPR, TPR) 对,连接这些点即可形成ROC曲线。
解读:

曲线越靠近左上角((0,1)点),表示模型性能越好(高TPR,低FPR)。
对角线(从(0,0)到(1,1))表示随机猜测的模型。
曲线完全在对角线上方意味着模型优于随机猜测。

优点: 不受类别不平衡的影响,能够综合评估模型在不同阈值下的判别能力。

I. AUC (Area Under the ROC Curve)

定义: ROC曲线下的面积。AUC值介于0到1之间。
解读:

AUC = 1: 完美分类器。
AUC = 0.5: 随机猜测。
AUC > 0.5: 模型优于随机猜测。
AUC < 0.5: 模型比随机猜测还差(可能将标签反转后会变好)。

优点: 提供了一个单一的数值来概括模型在所有可能阈值下的整体性能。

J. PR 曲线 (Precision-Recall Curve)

定义: PR曲线描述了在不同分类阈值下,精确率 (Precision) 与召回率 (Recall) 之间的关系。横轴是Recall,纵轴是Precision。
绘制方法: 与ROC曲线类似,通过改变分类阈值得到一系列 (Recall, Precision) 对。
解读:

曲线越靠近右上角((1,1)点),表示模型性能越好(高Precision,高Recall)。

适用场景: 当正负样本分布极不平衡时,PR曲线通常比ROC曲线更能反映模型的性能。因为在极不平衡数据下,即使FPR很小,FP的数量也可能很大,从而显著影响Precision,而ROC曲线可能仍然看起来很好。

K. AP (Average Precision) 和 mAP (mean Average Precision)

AP: PR曲线下的面积。与AUC类似,它概括了PR曲线的性能。计算AP有多种方法,一种常见的是插值平均精度。
mAP: 在多类别分类或目标检测任务中,会为每个类别计算一个AP值,然后对所有类别的AP值取平均,得到mAP。mAP是衡量模型在所有类别上综合性能的重要指标。

L. 对数损失 (Logarithmic Loss / Log Loss / Cross-Entropy Loss)

定义: 衡量的是模型预测概率与真实标签之间的差异。对于二分类问题,如果真实标签是 (y in {0, 1}),模型预测样本为正类的概率是 §,则对数损失为:
[ ext{LogLoss} = -(y log§ + (1-y) log(1-p)) ]
对于多分类问题,如果真实标签是one-hot编码向量 (mathbf{y}),模型预测每个类别的概率是向量 (mathbf{p}),则对数损失为:
[ ext{LogLoss} = -sum_c y_c log(p_c) ]
特点:

对预测概率的“自信程度”进行惩罚。如果模型以高概率预测错误,损失会很大。
常用于评估概率输出模型的性能。
在训练神经网络分类器时,交叉熵损失就是一种对数损失。

M. Kappa 系数 (Cohen’s Kappa Coefficient)

定义: Kappa系数衡量的是分类结果与随机分类结果的一致性程度,它考虑了偶然一致的可能性。
[ kappa = frac{p_o – p_e}{1 – p_e} ]
其中 (p_o) 是观测到的一致性(即准确率),(p_e) 是偶然期望的一致性。
解读:

(kappa = 1): 完全一致。
(kappa = 0): 一致性与偶然期望相同。
(kappa < 0): 一致性比偶然期望还差。

适用场景: 当类别不平衡或评估者间一致性很重要时。

概念性代码 (使用 scikit-learn 计算分类指标)

import numpy as np
from sklearn.metrics import confusion_matrix, accuracy_score, precision_score, recall_score, f1_score
from sklearn.metrics import roc_curve, auc, precision_recall_curve, average_precision_score
from sklearn.metrics import log_loss, cohen_kappa_score
import matplotlib.pyplot as plt

# 假设我们有真实标签 y_true 和模型预测的概率/分数 y_scores
# 为了简单起见,我们先生成一些二分类的示例数据
np.random.seed(42) # 设置随机种子以保证结果可复现
y_true_binary = np.random.randint(0, 2, size=100) # 100个真实二分类标签 (0或1)
# 模拟模型输出的概率(通常是正类的概率)
y_scores_binary = np.random.rand(100) * 0.6 + y_true_binary * 0.3 # 让分数与真实标签有一定关联但不是完美
# 根据一个阈值将概率转换为类别预测
threshold = 0.5 # 设置分类阈值
y_pred_binary = (y_scores_binary >= threshold).astype(int) # 大于等于阈值为1,否则为0

print("--- Binary Classification Metrics ---")

# 1. 混淆矩阵
cm = confusion_matrix(y_true_binary, y_pred_binary) # 计算混淆矩阵
print("Confusion Matrix:
", cm) # 打印混淆矩阵
# cm[0,0] = TN, cm[0,1] = FP
# cm[1,0] = FN, cm[1,1] = TP
tn, fp, fn, tp = cm.ravel() # 将混淆矩阵扁平化以便提取TP,FP,FN,TN
print(f"TN: {
              tn}, FP: {
              fp}, FN: {
              fn}, TP: {
              tp}") # 打印TP,FP,FN,TN

# 2. 准确率
acc = accuracy_score(y_true_binary, y_pred_binary) # 计算准确率
print(f"Accuracy: {
              acc:.4f}") # 打印准确率

# 3. 精确率 (positive_label=1,即已关注标签为1的类别的精确率)
# zero_division=0: 如果分母为0(例如没有预测为正的样本),则返回0而不是警告
precision = precision_score(y_true_binary, y_pred_binary, pos_label=1, zero_division=0) # 计算精确率
print(f"Precision (for class 1): {
              precision:.4f}") # 打印精确率

# 4. 召回率
recall = recall_score(y_true_binary, y_pred_binary, pos_label=1, zero_division=0) # 计算召回率
print(f"Recall (for class 1): {
              recall:.4f}") # 打印召回率

# 5. F1分数
f1 = f1_score(y_true_binary, y_pred_binary, pos_label=1, zero_division=0) # 计算F1分数
print(f"F1-Score (for class 1): {
              f1:.4f}") # 打印F1分数

# 6. ROC曲线和AUC (需要概率分数 y_scores_binary)
fpr, tpr, thresholds_roc = roc_curve(y_true_binary, y_scores_binary, pos_label=1) # 计算ROC曲线的FPR, TPR和阈值
roc_auc = auc(fpr, tpr) # 计算AUC值
print(f"AUC (Area Under ROC Curve): {
              roc_auc:.4f}") # 打印AUC

plt.figure(figsize=(12, 5)) # 创建图形窗口
plt.subplot(1, 2, 1) # 创建第一个子图
plt.plot(fpr, tpr, color='darkorange', lw=2, label=f'ROC curve (area = {
              roc_auc:.2f})') # 绘制ROC曲线
plt.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--') # 绘制对角线(随机猜测)
plt.xlim([0.0, 1.0]) # 设置x轴范围
plt.ylim([0.0, 1.05]) # 设置y轴范围
plt.xlabel('False Positive Rate (FPR)') # 设置x轴标签
plt.ylabel('True Positive Rate (TPR)') # 设置y轴标签
plt.title('Receiver Operating Characteristic (ROC) Curve') # 设置标题
plt.legend(loc="lower right") # 显示图例

# 7. PR曲线和AP (需要概率分数 y_scores_binary)
precision_pr, recall_pr, thresholds_pr = precision_recall_curve(y_true_binary, y_scores_binary, pos_label=1) # 计算PR曲线的Precision, Recall和阈值
ap = average_precision_score(y_true_binary, y_scores_binary, pos_label=1) # 计算平均精确度(AP)
print(f"AP (Average Precision under PR Curve): {
              ap:.4f}") # 打印AP

plt.subplot(1, 2, 2) # 创建第二个子图
plt.plot(recall_pr, precision_pr, color='blue', lw=2, label=f'PR curve (AP = {
              ap:.2f})') # 绘制PR曲线
# 在PR曲线中,基线是 P = (num_positives / total_samples),是一条水平线
baseline_pr = np.sum(y_true_binary) / len(y_true_binary) # 计算PR曲线的基线
plt.axhline(baseline_pr, color='gray', lw=1, linestyle='--', label=f'Baseline ({
              baseline_pr:.2f})') # 绘制基线
plt.xlabel('Recall') # 设置x轴标签
plt.ylabel('Precision') # 设置y轴标签
plt.ylim([0.0, 1.05]) # 设置y轴范围
plt.xlim([0.0, 1.0])  # 设置x轴范围
plt.title('Precision-Recall (PR) Curve') # 设置标题
plt.legend(loc="lower left") # 显示图例
plt.tight_layout() # 调整布局
plt.show() # 显示图形

# 8. 对数损失 (需要概率分数 y_scores_binary)
# 注意:log_loss期望的y_true是0或1,y_pred是概率值
logloss = log_loss(y_true_binary, y_scores_binary) # 计算对数损失
print(f"Log Loss: {
              logloss:.4f}") # 打印对数损失

# 9. Kappa系数
kappa = cohen_kappa_score(y_true_binary, y_pred_binary) # 计算Kappa系数
print(f"Cohen's Kappa: {
              kappa:.4f}") # 打印Kappa系数


# --- 多分类任务的指标 ---
# 假设我们有多分类的真实标签和预测标签/概率
y_true_multi = np.array([0, 1, 2, 0, 1, 2, 0, 0, 1, 2]) # 10个样本,3个类别 (0, 1, 2)
y_pred_multi = np.array([0, 2, 1, 0, 0, 2, 0, 1, 1, 2]) # 对应的模型预测类别
# 模拟模型输出的每个类别的概率 (10个样本 x 3个类别)
y_scores_multi = np.random.rand(10, 3) 
y_scores_multi = y_scores_multi / np.sum(y_scores_multi, axis=1, keepdims=True) # 归一化使每行和为1

print("
--- Multi-class Classification Metrics ---")
# 混淆矩阵 (多分类)
cm_multi = confusion_matrix(y_true_multi, y_pred_multi) # 计算多分类混淆矩阵
print("Multi-class Confusion Matrix:
", cm_multi) # 打印混淆矩阵

# 准确率 (多分类)
acc_multi = accuracy_score(y_true_multi, y_pred_multi) # 计算多分类准确率
print(f"Multi-class Accuracy: {
              acc_multi:.4f}") # 打印准确率

# 精确率、召回率、F1分数 (多分类) - 需要指定平均方式
# 'micro': 通过全局计算TP, FP, FN来计算总的指标。
# 'macro': 计算每个类别的指标,然后取未加权的平均值。不考虑类别不平衡。
# 'weighted': 计算每个类别的指标,然后根据每个类别的支持度(样本数)进行加权平均。
precision_macro = precision_score(y_true_multi, y_pred_multi, average='macro', zero_division=0) # 计算宏平均精确率
recall_weighted = recall_score(y_true_multi, y_pred_multi, average='weighted', zero_division=0) # 计算加权平均召回率
f1_micro = f1_score(y_true_multi, y_pred_multi, average='micro', zero_division=0) # 计算微平均F1分数
print(f"Multi-class Precision (macro): {
              precision_macro:.4f}") # 打印宏平均精确率
print(f"Multi-class Recall (weighted): {
              recall_weighted:.4f}") # 打印加权平均召回率
print(f"Multi-class F1-Score (micro): {
              f1_micro:.4f}") # 打印微平均F1分数

# 对数损失 (多分类)
# y_true_multi需要是标签,y_scores_multi是概率矩阵
logloss_multi = log_loss(y_true_multi, y_scores_multi) # 计算多分类对数损失
print(f"Multi-class Log Loss: {
              logloss_multi:.4f}") # 打印对数损失

# Kappa系数 (多分类)
kappa_multi = cohen_kappa_score(y_true_multi, y_pred_multi) # 计算多分类Kappa系数
print(f"Multi-class Cohen's Kappa: {
              kappa_multi:.4f}") # 打印Kappa系数

# 对于多分类的ROC/AUC和PR/AP,通常采用"一对多 (One-vs-Rest, OvR)"或 "一对一 (One-vs-One, OvO)"策略
# 然后对每个类别的AUC/AP进行平均。scikit-learn的roc_auc_score和average_precision_score支持直接处理多分类概率。
from sklearn.preprocessing import label_binarize # 用于将多类标签二值化 (OvR)

# 将真实标签二值化
y_true_multi_binarized = label_binarize(y_true_multi, classes=np.unique(y_true_multi)) # 将标签二值化
n_classes = y_true_multi_binarized.shape[1] # 获取类别数量

# OvR AUC
# ovo/ovr参数,multi_class='ovr' 或 'ovo',average='macro' 或 'weighted'
roc_auc_multi_ovr_macro = roc_auc_score(y_true_multi_binarized, y_scores_multi, multi_class='ovr', average='macro') # 计算OvR宏平均AUC
print(f"Multi-class AUC (OvR, macro avg): {
              roc_auc_multi_ovr_macro:.4f}") # 打印OvR宏平均AUC

# OvR Average Precision
ap_multi_ovr_macro = average_precision_score(y_true_multi_binarized, y_scores_multi, average='macro') # 计算OvR宏平均AP
print(f"Multi-class Average Precision (OvR, macro avg): {
              ap_multi_ovr_macro:.4f}") # 打印OvR宏平均AP

这段代码演示了如何使用 scikit-learn 库来计算各种常用的分类评估指标,包括二分类和多分类场景。理解这些指标的含义和适用条件对于正确评估模型性能至关重要。

4.1.2 回归任务的评估指标

对于预测连续值(如房价、温度)的回归任务,常用的评估指标包括:

A. 平均绝对误差 (Mean Absolute Error, MAE)

定义: 预测值与真实值之间绝对差的平均值。
[ ext{MAE} = frac{1}{N} sum_{i=1}^{N} |y_i – hat{y}_i| ]
其中 (y_i) 是真实值,(hat{y}_i) 是预测值,(N) 是样本数量。
特点:

单位与目标变量相同,易于理解。
对异常值(outliers)的敏感度低于MSE,因为它不平方误差。
所有误差的权重相同。

B. 平均平方误差 (Mean Squared Error, MSE)

定义: 预测值与真实值之间差的平方的平均值。
[ ext{MSE} = frac{1}{N} sum_{i=1}^{N} (y_i – hat{y}_i)^2 ]
特点:

对较大的误差给予更大的惩罚(因为平方的存在)。
对异常值非常敏感。
单位是目标变量单位的平方,可能不易直观解释。
数学上易于处理(可导)。

C. 均方根误差 (Root Mean Squared Error, RMSE)

定义: MSE的平方根。
[ ext{RMSE} = sqrt{ ext{MSE}} = sqrt{frac{1}{N} sum_{i=1}^{N} (y_i – hat{y}_i)^2} ]
特点:

单位与目标变量相同,比MSE更易于解释。
保留了MSE对较大误差敏感的特性。
是最常用的回归指标之一。

D. R 平方 (R-squared / Coefficient of Determination)

定义: R平方衡量的是模型预测能够解释目标变量方差的比例。它的值通常在0到1之间(也可能为负)。
[ R^2 = 1 – frac{ ext{SS}{ ext{res}}}{ ext{SS}{ ext{tot}}} = 1 – frac{sum_{i=1}^{N} (y_i – hat{y}_i)2}{sum_{i=1}{N} (y_i – ar{y})^2} ]
其中:

( ext{SS}_{ ext{res}}) 是残差平方和 (Sum of Squares of Residuals),即MSE乘以N。
( ext{SS}_{ ext{tot}}) 是总平方和 (Total Sum of Squares),即目标变量 (y) 的方差乘以N。 (ar{y}) 是 (y) 的均值。

解读:

(R^2 = 1): 模型完美预测了所有数据。
(R^2 = 0): 模型的表现等同于一个总是预测 (y) 均值的基线模型。
(R^2 < 0): 模型的表现比基线模型还差(非常罕见,通常意味着模型非常糟糕或存在问题)。
例如,(R^2 = 0.75) 表示模型解释了目标变量75%的变异性。

缺点:

当模型中添加更多特征时(即使这些特征与目标无关),(R^2) 的值几乎总是会增加或保持不变,不会因为过拟合而减少。这使得它在比较不同特征数量的模型时可能不公平。

E. 调整 R 平方 (Adjusted R-squared)

定义: 调整R平方对R平方进行了修正,它考虑了模型中自变量(特征)的数量。
[ R^2_{adj} = 1 – frac{(1-R^2)(N-1)}{N-k-1} ]
其中 (N) 是样本数量,(k) 是模型中自变量(预测变量)的数量。
特点:

当添加对模型没有显著贡献的无关特征时,调整R平方可能会下降。
比R平方更适合用于比较具有不同数量预测变量的模型。

F. 平均绝对百分比误差 (Mean Absolute Percentage Error, MAPE)

定义:
[ ext{MAPE} = frac{1}{N} sum_{i=1}^{N} left| frac{y_i – hat{y}_i}{y_i}
ight| imes 100% ]
特点:

以百分比形式表示误差,易于理解,不受目标变量尺度的影响。
当真实值 (y_i) 接近0或为0时,MAPE会变得非常大或未定义,这是其主要缺点。
对负向误差(预测值大于真实值)和正向误差(预测值小于真实值)的惩罚不对称(当 (y_i>0) 时,如果 (hat{y}_i=0),误差是100%;如果 (hat{y}_i=2y_i),误差也是100%。但如果 (y_i) 很小,一个小的绝对误差可能导致很大的百分比误差)。

G. 对称平均绝对百分比误差 (Symmetric Mean Absolute Percentage Error, sMAPE)
sMAPE 是 MAPE 的一种变体,试图解决 (y_i=0) 的问题和不对称性问题,但其定义有多种形式,且仍有其自身的局限性。一种常见的定义是:
[ ext{sMAPE} = frac{1}{N} sum_{i=1}^{N} frac{|y_i – hat{y}_i|}{(|y_i| + |hat{y}_i|)/2} imes 100% ]
(分母也可以是 (|y_i| + |hat{y}_i|),此时结果乘以200%)

概念性代码 (使用 scikit-learn 计算回归指标)

import numpy as np
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score

# 假设我们有真实值 y_true_reg 和模型预测值 y_pred_reg
np.random.seed(0)
y_true_reg = np.random.rand(50) * 10 # 50个真实回归值
# 模拟模型预测,加入一些噪声
y_pred_reg = y_true_reg + np.random.randn(50) * 0.5 

print("
--- Regression Metrics ---")

# 1. 平均绝对误差 (MAE)
mae = mean_absolute_error(y_true_reg, y_pred_reg) # 计算MAE
print(f"Mean Absolute Error (MAE): {
              mae:.4f}") # 打印MAE

# 2. 平均平方误差 (MSE)
mse = mean_squared_error(y_true_reg, y_pred_reg) # 计算MSE
print(f"Mean Squared Error (MSE): {
              mse:.4f}") # 打印MSE

# 3. 均方根误差 (RMSE)
rmse = np.sqrt(mse) # RMSE是MSE的平方根 (sklearn没有直接的rmse函数,但可以基于mse计算)
# 或者:rmse = mean_squared_error(y_true_reg, y_pred_reg, squared=False) # sklearn 0.22+ 版本支持
print(f"Root Mean Squared Error (RMSE): {
              rmse:.4f}") # 打印RMSE

# 4. R平方 (R-squared)
r2 = r2_score(y_true_reg, y_pred_reg) # 计算R平方
print(f"R-squared (Coefficient of Determination): {
              r2:.4f}") # 打印R平方

# 5. 调整R平方 (Adjusted R-squared) - 需要样本数N和特征数k
N_reg = len(y_true_reg) # 样本数量
k_reg = 1 # 假设只有一个预测变量 (用于演示,实际中k是模型使用的特征数)
if N_reg - k_reg - 1 != 0: # 避免除以0
    adj_r2 = 1 - (1 - r2) * (N_reg - 1) / (N_reg - k_reg - 1) # 计算调整R平方
    print(f"Adjusted R-squared (assuming k={
              k_reg}): {
              adj_r2:.4f}") # 打印调整R平方
else:
    print("Cannot calculate Adjusted R-squared due to N-k-1 = 0.")

# 6. 平均绝对百分比误差 (MAPE)
def mean_absolute_percentage_error(y_true, y_pred): # 定义MAPE计算函数
    y_true, y_pred = np.array(y_true), np.array(y_pred) # 转换为numpy数组
    # 过滤掉真实值为0的情况以避免除以0
    mask = y_true != 0
    if not np.any(mask): # 如果所有真实值都为0
        return np.nan if len(y_true) > 0 else 0.0 # 返回NaN或0
    return np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask])) * 100 # 计算MAPE

# # 注意:如果y_true_reg中包含0,上面的MAPE函数会过滤它们。
# # 为了演示,我们确保y_true_reg不包含0
# y_true_reg_mape = y_true_reg[y_true_reg != 0]
# y_pred_reg_mape = y_pred_reg[y_true_reg != 0]
# if len(y_true_reg_mape) > 0:
#     mape = mean_absolute_percentage_error(y_true_reg_mape, y_pred_reg_mape)
#     print(f"Mean Absolute Percentage Error (MAPE): {mape:.2f}%")
# else:
#     print("Cannot calculate MAPE as all true values are zero or no non-zero true values exist.")

# scikit-learn 0.24+ 提供了 mean_absolute_percentage_error
try:
    from sklearn.metrics import mean_absolute_percentage_error as sk_mape
    mape_sklearn = sk_mape(y_true_reg, y_pred_reg) * 100 # sklearn的MAPE返回的是小数
    print(f"Mean Absolute Percentage Error (MAPE, from sklearn): {
              mape_sklearn:.2f}%")
except ImportError:
    print("sklearn.metrics.mean_absolute_percentage_error not available (requires scikit-learn >= 0.24).")

这段代码演示了如何使用 scikit-learn (以及自定义函数) 来计算回归任务中常用的评估指标。为特定问题选择最合适的指标取决于我们对误差类型的已关注程度(例如,是否对大误差更敏感,是否关心误差的百分比等)。

4.1.3 其他任务的评估指标

除了分类和回归,还有许多其他机器学习任务,它们也有各自专门的评估指标:

目标检测 (Object Detection):

IoU (Intersection over Union): 衡量预测边界框与真实边界框的重叠程度。
Precision, Recall, AP, mAP: 基于IoU阈值(例如IoU > 0.5被认为是TP)来计算每个类别的AP,然后平均得到mAP。这是目标检测最核心的指标。

图像分割 (Image Segmentation):

Pixel Accuracy: 正确分类的像素比例。
IoU / Jaccard Index: (预测区域 (cap) 真实区域) / (预测区域 (cup) 真实区域),针对每个类别计算,然后平均(mean IoU, mIoU)。
Dice Coefficient: (2 cdot ext{IoU} / (1 + ext{IoU})),与IoU类似。

自然语言处理 (NLP):

机器翻译: BLEU, METEOR, ROUGE, TER.
文本摘要: ROUGE.
语言模型: Perplexity (困惑度).
情感分析、文本分类: 与一般分类任务指标相同 (Accuracy, F1, AUC等).

推荐系统 (Recommendation Systems):

Precision@k, Recall@k: 推荐列表中前k个项目中相关项目的比例/所有相关项目中被推荐到前k个的比例。
MAP@k (Mean Average Precision at k).
NDCG@k (Normalized Discounted Cumulative Gain at k): 考虑推荐项目相关性程度和位置的指标。
Coverage, Diversity, Novelty.

选择正确的评估指标是模型开发生命周期中至关重要的一步。它不仅告诉我们模型表现如何,还指导我们如何改进模型。在定义一个项目时,首先明确成功的标准和相应的评估指标是非常重要的。

4.2 数据集划分与交叉验证 (Data Splitting and Cross-Validation)

为了客观地评估模型的性能并避免过拟合,我们不能简单地在所有可用数据上训练模型,然后在同一数据上进行评估。这样做得到的性能指标会过于乐观,无法反映模型在处理新数据时的真实能力。因此,合理地划分数据集至关重要。

4.2.1 基本的数据集划分:训练集、验证集、测试集

最基本的数据集划分方法是将原始数据集分为三个互不相交的子集:

训练集 (Training Set):

用途: 用于训练模型,即调整模型的参数(权重和偏置)。模型直接从这些数据中学习模式和规律。
比例: 通常占总数据集的大部分,例如60%-80%。

验证集 (Validation Set / Development Set / Dev Set):

用途:

超参数调整: 在训练过程中,使用验证集来评估不同超参数组合(如学习率、正则化强度、网络层数、神经元数量等)的效果,并选择最佳的超参数。
模型选择: 如果我们训练了多种不同的模型架构,可以使用验证集来比较它们的性能,并选择表现最好的模型。
早停: 监控模型在验证集上的性能,以决定何时停止训练,防止过拟合。

重要性: 验证集充当了训练数据和“真实世界”测试数据之间的代理。模型在训练时并没有直接“看到”验证集的数据(即验证集数据不直接参与梯度更新),因此它上面的性能可以更客观地反映模型的泛化能力。
比例: 通常占总数据集的10%-20%。

测试集 (Test Set / Hold-out Set):

用途: 在模型训练和超参数调整完全结束后,使用测试集对最终选定的模型进行一次性评估,以获得模型在完全未见过的数据上的性能的无偏估计。这个评估结果通常被视为模型的最终性能报告。
重要性: 测试集必须严格保密,在整个模型开发过程中(包括超参数调整和模型选择)都不能使用。一旦模型在测试集上进行了评估,就不应该再返回去根据测试集的结果调整模型或超参数,否则测试集的评估就失去了其公正性,我们可能会不自觉地使模型过拟合于测试集。
比例: 通常占总数据集的10%-20%。

数据划分的黄金法则:

互斥性: 训练集、验证集、测试集之间必须没有重叠的样本。
代表性: 每个子集都应该能够很好地代表原始数据的整体分布。这意味着在划分数据时,最好进行随机打乱(shuffle),特别是当数据具有某种固有顺序时。对于分类问题,如果类别不平衡,可能需要进行分层抽样 (Stratified Sampling),以确保每个子集中各个类别的比例与原始数据中的比例大致相同。

划分比例的考虑因素:

总数据量:

大数据集 (例如数百万样本): 验证集和测试集的比例可以相对较小,例如各占1%(甚至更少),因为即使是1%也包含足够多的样本来进行可靠评估。例如,可以采用98%训练 / 1%验证 / 1%测试的划分。
中小数据集 (例如数千到数十万样本): 传统的60/20/20或70/15/15或80/10/10的划分比较常见。

任务复杂度: 更复杂的任务可能需要更大的验证集和测试集来确保评估的稳定性。

概念性代码 (使用 scikit-learn 划分数据)

import numpy as np
from sklearn.model_selection import train_test_split

# 假设我们有一个特征矩阵 X 和对应的标签向量 y
# X 可以是 (num_samples, num_features) 的numpy数组或pandas DataFrame
# y 可以是 (num_samples,) 的numpy数组或pandas Series

# 生成一些示例数据
X_data = np.random.rand(1000, 20) # 1000个样本,每个样本20个特征
y_data = np.random.randint(0, 3, size=1000) # 1000个标签,3个类别 (0, 1, 2)

# --- 第一次划分:将数据分为训练集+验证集 (train_val) 和测试集 (test) ---
# test_size: 测试集所占的比例或绝对数量。这里设为0.2,即20%的数据作为测试集。
# random_state: 随机种子,用于保证每次划分结果一致,便于复现。
# stratify=y_data: 进行分层抽样,确保测试集和训练集+验证集中各类别的比例与原始数据一致。
#                  这对于分类问题,特别是类别不平衡时非常重要。
X_train_val, X_test, y_train_val, y_test = train_test_split(
    X_data, y_data, 
    test_size=0.2, 
    random_state=42, 
    stratify=y_data # 根据y_data进行分层抽样
)

print(f"Shape of X_data: {
              X_data.shape}, Shape of y_data: {
              y_data.shape}") # 打印原始数据形状
print(f"Shape of X_train_val: {
              X_train_val.shape}, Shape of y_train_val: {
              y_train_val.shape}") # 打印训练+验证集形状
print(f"Shape of X_test: {
              X_test.shape}, Shape of y_test: {
              y_test.shape}") # 打印测试集形状

# 检查分层抽样的效果 (打印各类别的比例)
def print_class_distribution(y_arr, name): # 定义打印类别分布的函数
    unique, counts = np.unique(y_arr, return_counts=True) # 获取唯一类别及其计数
    percentages = counts / len(y_arr) * 100 # 计算百分比
    print(f"Class distribution in {
              name}:") # 打印集合名称
    for cls, perc in zip(unique, percentages): # 遍历类别和百分比
        print(f"  Class {
              cls}: {
              perc:.2f}%") # 打印类别及其百分比

print_class_distribution(y_data, "Original Data") # 打印原始数据的类别分布
print_class_distribution(y_train_val, "Train+Validation Set") # 打印训练+验证集的类别分布
print_class_distribution(y_test, "Test Set") # 打印测试集的类别分布

# --- 第二次划分:将训练集+验证集 (train_val) 划分为训练集 (train) 和验证集 (val) ---
# 假设我们希望验证集占原始数据的15%,测试集占20%,那么训练集占65%。
# train_val 占原始数据的 1 - 0.2 = 0.8 (80%)
# 我们需要从 train_val 中划分出验证集。
# 如果验证集占原始数据的15%,那么它占 train_val 的比例是 0.15 / 0.8 = 0.1875
# val_relative_size = (原始验证集比例) / (1 - 原始测试集比例)
val_size_from_train_val = 0.15 / (1 - 0.20) # 计算验证集在train_val中的相对大小

X_train, X_val, y_train, y_val = train_test_split(
    X_train_val, y_train_val,
    test_size=val_size_from_train_val, # 设置验证集在train_val中的比例
    random_state=42, # 使用相同的随机种子以保证可复现性(如果需要)
    stratify=y_train_val # 同样进行分层抽样
)

print(f"
Shape of X_train: {
              X_train.shape}, Shape of y_train: {
              y_train.shape}") # 打印训练集形状
print(f"Shape of X_val: {
              X_val.shape}, Shape of y_val: {
              y_val.shape}") # 打印验证集形状

print_class_distribution(y_train, "Final Training Set") # 打印最终训练集的类别分布
print_class_distribution(y_val, "Final Validation Set") # 打印最终验证集的类别分布

print(f"
Final split ratios (approximate based on original data):") # 打印最终划分比例
print(f"  Training set: {
              len(y_train) / len(y_data) * 100:.2f}%") # 打印训练集比例
print(f"  Validation set: {
              len(y_val) / len(y_data) * 100:.2f}%") # 打印验证集比例
print(f"  Test set: {
              len(y_test) / len(y_data) * 100:.2f}%") # 打印测试集比例

这段代码演示了如何使用 sklearn.model_selection.train_test_split 进行两次划分,从而得到训练集、验证集和测试集,并强调了分层抽样 (stratify) 的重要性。

4.2.2 交叉验证 (Cross-Validation, CV)

当数据集规模较小,或者我们希望更稳健地评估模型性能和调整超参数时,简单的单次划分(训练集/验证集)可能不够可靠。因为划分的结果具有一定的随机性,模型在特定验证集上的性能可能偶然偏高或偏低。交叉验证提供了一种更鲁棒的方法。

A. k-折交叉验证 (k-Fold Cross-Validation)

思想:

将原始训练数据(不包括测试集)随机划分为 (k) 个大小相似的互斥子集(称为“折”,folds)。
进行 (k) 次迭代。在每次迭代中:

选择其中一个折作为验证集 (validation fold)
其余的 (k-1) 个折合并作为训练集 (training folds)
在该训练集上训练模型,并在选定的验证集上评估其性能。

记录 (k) 次迭代中模型在验证集上的性能指标(例如,准确率、MSE)。
最终的交叉验证性能是这 (k) 个性能指标的平均值。通常还会计算其标准差,以了解性能的稳定性。

图片[1] - 深度学习与计算机视觉 - 宋马
(图片示意:5-折交叉验证。数据被分为5折,每次用1折做验证,其余4折做训练,共进行5轮。)

优点:

更可靠的性能估计: 每个数据点都有机会被用作一次验证数据,减少了因单次划分随机性带来的偏差。平均后的性能指标通常比单次验证集评估更接近模型在未知数据上的真实泛化能力。
更有效地利用数据: 特别是在数据量较少的情况下,几乎所有数据都参与了训练过程(尽管是在不同的迭代中)。
用于超参数调整: 可以为每组超参数组合运行一次完整的k-折交叉验证,选择平均验证性能最好的那组超参数。

缺点:

计算成本较高: 需要训练和评估模型 (k) 次,如果单次训练时间很长,k-折交叉验证会非常耗时。

k值的选择:

常用的 (k) 值为5或10。
较大的 (k) 值(例如 (k=N),即留一法交叉验证)会使训练集更大,验证集更小,性能估计的偏差较小,但方差可能较大,且计算成本最高。
较小的 (k) 值(例如 (k=3))计算成本低,但性能估计的偏差可能较大。
(k=5) 或 (k=10) 通常是在偏差和方差(以及计算成本)之间的一个良好折中。

分层k-折交叉验证 (Stratified k-Fold Cross-Validation):
在分类问题中,特别是在类别不平衡时,应使用分层k-折交叉验证。它确保在划分每一折时,各类别样本的比例与原始数据中的比例大致相同,从而使得每轮的训练集和验证集都有代表性。

B. 留一法交叉验证 (Leave-One-Out Cross-Validation, LOOCV)

思想: LOOCV是k-折交叉验证的一个特例,其中 (k=N)((N)是训练数据的样本总数)。

每次迭代中,只留下一个样本作为验证集,其余 (N-1) 个样本作为训练集。
重复 (N) 次。

优点:

几乎完全利用了数据进行训练,性能估计的偏差非常小。
结果是确定的(没有随机划分)。

缺点:

计算成本极高,需要训练模型 (N) 次。只适用于数据集非常小的情况。
性能估计的方差可能较大。

C. 其他交叉验证方法

留P法交叉验证 (Leave-P-Out Cross-Validation, LPOCV): 每次留下 (P) 个样本作为验证集,其余作为训练集。组合数非常多,计算量巨大。
打乱划分交叉验证 (Shuffle-Split / Monte Carlo Cross-Validation):

在每次迭代中,随机地从数据中抽取一定比例(例如70%)作为训练集,剩余的(例如30%)作为验证集。
重复多次(例如100次)。
与k-折不同,不同的迭代中,验证集之间可能有重叠。
提供了对训练/验证集划分比例的更大灵活性。

时间序列交叉验证 (Time Series Cross-Validation / Walk-Forward Validation):
对于具有时间依赖性的数据(如股票价格、天气预报),不能简单地随机打乱和划分,因为这会破坏时间顺序,导致数据泄露(用未来的数据预测过去)。
时间序列交叉验证通常采用“滚动窗口”或“扩展窗口”的方式:

训练数据是过去的一段时间,验证数据是紧随其后的一小段时间。
随着时间的推移,训练窗口和验证窗口向前滚动。
图片[2] - 深度学习与计算机视觉 - 宋马
(图片示意:时间序列交叉验证的滚动窗口方法。)

概念性代码 (使用 scikit-learn 进行 k-折交叉验证)

from sklearn.model_selection import KFold, StratifiedKFold, cross_val_score
from sklearn.linear_model import LogisticRegression # 使用一个简单的分类器作为示例
import numpy as np

# 假设我们有 X_train_val 和 y_train_val (在测试集划分之后剩余的数据)
# X_train_val, y_train_val 来自之前的代码块

# --- k-折交叉验证 (KFold) ---
num_folds = 5 # 设置折数
kf = KFold(n_splits=num_folds, shuffle=True, random_state=42) # 创建KFold对象
# shuffle=True: 在划分折之前打乱数据
# random_state: 保证每次打乱和划分结果一致

print(f"
--- {
              num_folds}-Fold Cross-Validation (KFold) ---")
fold_num = 1
for train_index, val_index in kf.split(X_train_val, y_train_val): # 迭代每一折的划分
    # kf.split() 返回的是索引
    X_fold_train, X_fold_val = X_train_val[train_index], X_train_val[val_index] # 获取当前折的训练数据
    y_fold_train, y_fold_val = y_train_val[train_index], y_train_val[val_index] # 获取当前折的验证数据
    
    print(f"Fold {
              fold_num}:") # 打印当前折数
    print(f"  Train set size: {
              len(X_fold_train)}, Validation set size: {
              len(X_fold_val)}") # 打印训练集和验证集大小
    
    # 在这里,你可以用 X_fold_train, y_fold_train 训练模型
    # 然后用 X_fold_val, y_fold_val 评估模型
    # model.fit(X_fold_train, y_fold_train)
    # score = model.score(X_fold_val, y_fold_val)
    # print(f"  Validation score for fold {fold_num}: {score}")
    fold_num += 1

# --- 分层k-折交叉验证 (StratifiedKFold) ---
# 对于分类问题,尤其是类别不平衡时,推荐使用分层k-折
skf = StratifiedKFold(n_splits=num_folds, shuffle=True, random_state=42) # 创建StratifiedKFold对象

print(f"
--- {
              num_folds}-Fold Stratified Cross-Validation (StratifiedKFold) ---")
fold_num_strat = 1
for train_index_strat, val_index_strat in skf.split(X_train_val, y_train_val): # 注意,split需要传入X和y以进行分层
    X_fold_train_s, X_fold_val_s = X_train_val[train_index_strat], X_train_val[val_index_strat]
    y_fold_train_s, y_fold_val_s = y_train_val[train_index_strat], y_train_val[val_index_strat]
    
    print(f"Fold {
              fold_num_strat}:")
    print(f"  Train set size: {
              len(X_fold_train_s)}, Validation set size: {
              len(X_fold_val_s)}")
    print(f"  Validation set class distribution:") # 打印验证集类别分布
    print_class_distribution(y_fold_val_s, f"Fold {
              fold_num_strat} Val") # 调用之前定义的函数
    fold_num_strat += 1
    
# --- 使用 cross_val_score 快速进行交叉验证评估 ---
# cross_val_score 是一个便捷函数,它封装了k-折交叉验证的训练和评估过程
# 需要提供一个估计器(estimator, 即模型)、特征数据X、标签y、以及交叉验证策略cv

# 创建一个简单的逻辑回归模型作为示例
log_reg_model = LogisticRegression(solver='liblinear', random_state=42, max_iter=200) # 创建逻辑回归模型

# 使用StratifiedKFold作为cv策略
# scoring参数可以指定评估指标,例如 'accuracy', 'precision_macro', 'recall_macro', 'f1_weighted', 'roc_auc_ovr' 等
# 如果不指定scoring,分类任务默认使用模型的score方法(通常是准确率),回归任务默认使用R^2
cv_scores = cross_val_score(
    log_reg_model,         # 要评估的模型
    X_train_val,           # 特征数据 (用于交叉验证的完整数据)
    y_train_val,           # 标签数据
    cv=skf,                # 使用之前定义的StratifiedKFold对象作为交叉验证策略 (也可以直接传入整数k)
    scoring='accuracy'     # 指定评估指标为准确率
)

print(f"
--- Cross-validation scores using cross_val_score (Stratified {
              num_folds}-Fold) ---")
print(f"Scores for each fold: {
              cv_scores}") # 打印每一折的分数
print(f"Average accuracy: {
              cv_scores.mean():.4f}") # 打印平均准确率
print(f"Standard deviation of accuracy: {
              cv_scores.std():.4f}") # 打印准确率的标准差

# 也可以直接给cv传入一个整数,它会自动使用KFold(回归)或StratifiedKFold(分类)
# cv_scores_simple = cross_val_score(log_reg_model, X_train_val, y_train_val, cv=5, scoring='accuracy')
# print(f"
Scores (cv=5, default KFold/StratifiedKFold): {cv_scores_simple}")
# print(f"Average accuracy (cv=5): {cv_scores_simple.mean():.4f}")

这段代码演示了如何使用 sklearn.model_selection 中的 KFoldStratifiedKFold 来手动迭代交叉验证的折,以及如何使用便捷函数 cross_val_score 来快速获得交叉验证的性能分数。

4.2.3 嵌套交叉验证 (Nested Cross-Validation)

当我们需要同时进行超参数调整模型性能评估时,如果只使用单层交叉验证(例如,用k-折CV来选择最佳超参数,然后用这组超参数在同一k-折CV的平均性能作为最终模型的性能估计),可能会导致性能估计过于乐观。这是因为超参数是根据这些CV折的性能“优化”出来的,模型间接地“看到”了所有数据。

为了得到更无偏的性能估计,并同时进行超参数调整,可以使用嵌套交叉验证

外层循环 (Outer Loop): 将数据划分为 (k_1) 折。每一折用作外部测试集 (outer test fold),其余用作外部训练集 (outer training fold)。外层循环的目的是评估模型的泛化能力。
内层循环 (Inner Loop): 对于每个外部训练集,再进行一次独立的 (k_2)-折交叉验证。内层循环的目的是在这个外部训练集上找到最佳的超参数组合。

对于每一组超参数,在外部训练集上进行 (k_2)-折CV,得到其平均验证性能。
选择在内层CV中表现最好的那组超参数。
使用这组最佳超参数,在整个外部训练集上重新训练一个模型。
最后,用这个模型在对应的外部测试集上进行评估。

最终性能: 外层循环会得到 (k_1) 个在外部测试集上的性能分数。这些分数的平均值可以作为最终模型(及其超参数选择流程)的一个更鲁棒和无偏的性能估计。

图片[3] - 深度学习与计算机视觉 - 宋马
(图片示意:嵌套交叉验证。外层CV用于评估,内层CV用于超参数调整。)

优点: 提供了对模型选择和超参数调整过程本身泛化能力的一个更严格和无偏的评估。
缺点: 计算成本非常高,因为需要 (k_1 imes ( ext{num_hyperparam_combinations} imes k_2)) 次模型训练(粗略估计)。

概念性代码 (使用 scikit-learn 进行嵌套交叉验证的思路)

from sklearn.model_selection import GridSearchCV, cross_val_score, StratifiedKFold
from sklearn.svm import SVC # 使用SVM作为示例分类器
import numpy as np

# 假设 X_train_val, y_train_val 存在

# --- 嵌套交叉验证 ---
print("
--- Nested Cross-Validation Example ---")

# 定义内层交叉验证策略 (用于超参数搜索)
inner_cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=1) # 3折分层CV

# 定义外层交叉验证策略 (用于模型评估)
outer_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=2) # 5折分层CV

# 定义要优化的模型
svm_model = SVC(random_state=0) # 创建SVM模型

# 定义要搜索的超参数网格
param_grid_svm = {
             # 定义SVM的超参数搜索范围
    'C': [0.1, 1, 10],       # 正则化参数
    'gamma': [0.01, 0.1, 1]  # 核系数
}

# 使用GridSearchCV进行内层循环的超参数搜索
# GridSearchCV会自动在inner_cv的每一折上训练和评估不同的超参数组合
# refit=True (默认): GridSearchCV会在找到最佳参数后,用最佳参数在整个提供的数据上重新训练一个模型
# 在嵌套CV中,我们通常希望它在内层CV的“外部训练集”上refit
grid_search = GridSearchCV(
    estimator=svm_model,    # 要优化的模型
    param_grid=param_grid_svm, # 超参数网格
    cv=inner_cv,            # 内层交叉验证策略
    scoring='accuracy',     # 评估指标
    refit=True              # 找到最佳参数后在整个数据上重新训练
)

# 执行外层循环的交叉验证,其中grid_search本身被当作一个"估计器"
# cross_val_score会对grid_search在outer_cv的每一折上进行如下操作:
# 1. 将outer_cv的当前训练部分数据传递给grid_search.fit(X_outer_train, y_outer_train)
#    grid_search.fit() 会在其内部使用inner_cv进行超参数搜索,并找到最佳参数,
#    然后用最佳参数在整个 X_outer_train, y_outer_train 上重新训练一个模型。
# 2. 使用grid_search(即用最佳参数训练好的模型)在outer_cv的当前测试部分数据上进行预测和评分。
nested_scores = cross_val_score(
    grid_search,        # 将GridSearchCV对象作为估计器传入
    X=X_train_val,      # 完整的用于交叉验证的数据
    y=y_train_val,      # 对应的标签
    cv=outer_cv,        # 外层交叉验证策略
    scoring='accuracy'  # 外层评估指标
)

print(f"Nested CV scores (accuracy on outer test folds): {
              nested_scores}") # 打印每一外折的分数
print(f"Average nested CV accuracy: {
              nested_scores.mean():.4f}") # 打印平均嵌套CV准确率
print(f"Standard deviation of nested CV accuracy: {
              nested_scores.std():.4f}") # 打印标准差

# 注意:这种方式得到的 nested_scores.mean() 是对整个超参数调整和模型训练流程泛化能力的一个估计。
# 如果需要最终部署一个模型,通常的做法是:
# 1. 使用嵌套CV来估计预期的性能,并确认超参数搜索策略的稳健性。
# 2. 然后,可以在整个 X_train_val 数据集上再次运行GridSearchCV(使用inner_cv)来找到最终的最佳超参数。
# grid_search_final = GridSearchCV(estimator=svm_model, param_grid=param_grid_svm, cv=inner_cv, scoring='accuracy', refit=True)
# grid_search_final.fit(X_train_val, y_train_val) # 在整个训练+验证数据上寻找最佳参数并重新训练
# best_params_final = grid_search_final.best_params_ # 获取最终的最佳参数
# print(f"Best hyperparameters found on X_train_val: {best_params_final}")
# final_model = grid_search_final.best_estimator_ # 获取用最佳参数在X_train_val上训练好的模型
# 之后可以用这个 final_model 在独立的 X_test, y_test 上进行最终评估。

嵌套交叉验证提供了一种非常严谨的方式来评估模型及其调优过程的性能,但其计算成本较高,通常在对性能估计的无偏性要求极高或研究场景中使用。

总结数据划分与交叉验证:

基本目标: 可靠地评估模型的泛化能力,并指导模型选择和超参数调整。
三集划分 (Train/Val/Test): 是基础,测试集严格用于最终评估。
k-折交叉验证: 更稳健的性能估计和超参数调整方法,尤其适用于中小数据集。使用分层版本处理分类不平衡问题。
嵌套交叉验证: 提供对整个模型开发流程(包括超参数优化)泛化性能的最严格评估。
数据特性: 始终考虑数据的特性(如时间序列、类别不平衡)来选择合适的划分和验证策略。

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容