【深度学习】PyTorch框架(3):优化与初始化

1.引言

在本文中,我们将探讨神经网络的优化与初始化技术。随着神经网络深度的增加,我们会遇到多种挑战。最关键的是确保网络中梯度流动的稳定性,否则可能会遭遇梯度消失或梯度爆炸的问题。因此,我们将深入探讨以下两个核心概念:网络参数的初始化和优化算法的选择。
本文的前半部分,我们将介绍不同的参数初始化方法,从最基本的初始化策略开始,逐步深入到当前在极深网络中应用的高级技术。在后半部分,我们将聚焦于优化算法的比较,分析SGD、动量SGD以及Adam这几种优化器的性能差异。
首先,让我们开始导入所需的标准库。

## 标准库
import os
import json
import math
import numpy as np
import copy

## 绘图所需导入
import matplotlib.pyplot as plt
from matplotlib import cm
%matplotlib inline
from IPython.display import set_matplotlib_formats
set_matplotlib_formats('svg', 'pdf') # 用于导出
import seaborn as sns
sns.set()

## 进度条
from tqdm.notebook import tqdm

## PyTorch
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.utils.data as data
import torch.optim as optim
#我们将使用与教程3相同的set_seed函数,以及路径变量DATASET_PATH和CHECKPOINT_PATH。如有必要,请调整路径。

# 数据集下载存放的文件夹路径(例如MNIST)
DATASET_PATH = "../data"
# 预训练模型保存的文件夹路径
CHECKPOINT_PATH = "../saved_models/tutorial4"

# 设置种子的函数
def set_seed(seed):
    np.random.seed(seed)
    torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed(seed)
        torch.cuda.manual_seed_all(seed)
set_seed(42)

# 确保在GPU上的所有操作都是确定性的(如果使用)以实现可复现性
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False

# 获取将在此笔记本中使用整个过程中使用的设备
device = torch.device("cpu") if not torch.cuda.is_available() else torch.device("cuda:0")
print("Using device", device)
使用设备 cuda:0
##在本文的最后部分,我们将使用三种不同的优化器训练模型。以下是这些模型的预训练版本下载链接。

import urllib.request
from urllib.error import HTTPError
# 存储本教程预训练模型的Github URL
base_url = "https://raw.githubusercontent.com/phlippe/saved_models/main/tutorial4/" 
# 需要下载的文件
pretrained_files = ["FashionMNIST_SGD.config",    "FashionMNIST_SGD_results.json",    "FashionMNIST_SGD.tar",
                    "FashionMNIST_SGDMom.config", "FashionMNIST_SGDMom_results.json", "FashionMNIST_SGDMom.tar",
                    "FashionMNIST_Adam.config",   "FashionMNIST_Adam_results.json",   "FashionMNIST_Adam.tar"   ]
# 如果检查点路径不存在,则创建
os.makedirs(CHECKPOINT_PATH, exist_ok=True)

# 对于每个文件,检查它是否已经存在。如果不存在,尝试下载。
for file_name in pretrained_files:
    file_path = os.path.join(CHECKPOINT_PATH, file_name)
    if not os.path.isfile(file_path):
        file_url = base_url + file_name
        print(f"正在下载 {file_url}...")
        try:
            urllib.request.urlretrieve(file_url, file_path)
        except HTTPError as e:
            print("下载过程中出现问题。请尝试从GDrive文件夹下载文件,或联系作者,并附上包括以下错误的完整输出:\n", e)

2.准备工作

在本文中,我们将使用一个深度全连接网络,与我们之前的文章类似。我们还将再次将网络应用于FashionMNIST,我们首先加载FashionMNIST数据集:

from torchvision.datasets import FashionMNIST
from torchvision import transforms

# 应用于每张图片的转换 => 首先将它们转换为张量,然后使用均值为0和标准差为1进行归一化
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.2861,), (0.3530,))
])

# 加载训练数据集。我们需要将其分割为训练部分和验证部分
train_dataset = FashionMNIST(root=DATASET_PATH, train=True, transform=transform, download=True)
train_set, val_set = torch.utils.data.random_split(train_dataset, [50000, 10000])

# 加载测试集
test_set = FashionMNIST(root=DATASET_PATH, train=False, transform=transform, download=True)

# 我们定义一组数据加载器,我们稍后可以用于不同的目的。
# 注意,对于实际训练模型,我们将使用具有较小批量大小的不同数据加载器。
train_loader = data.DataLoader(train_set, batch_size=1024, shuffle=True, drop_last=False)
val_loader = data.DataLoader(val_set, batch_size=1024, shuffle=False, drop_last=False)
test_loader = data.DataLoader(test_set, batch_size=1024, shuffle=False, drop_last=False)

与之前的文章相比,我们更改了归一化转换transforms.Normalize的参数。现在归一化的设计是让我们在像素上获得预期的均值为0和标准差为1。这将特别适用于我们下面将要讨论的初始化问题,因此我们在这里进行更改。应当指出,在大多数分类任务中,两种归一化技术(介于-1和1之间或均值为0和标准差为1)都已被证明效果良好。我们可以通过在原始图像上确定均值和标准差来计算归一化参数:

print("Mean", (train_dataset.data.float() / 255.0).mean().item())
print("Std", (train_dataset.data.float() / 255.0).std().item())

输出显示为:

Mean 0.2860923707485199
Std 0.3530242443084717

我们可以通过查看单个批次的统计数据来验证转换:

imgs, _ = next(iter(train_loader))
print(f"Mean: {imgs.mean().item():5.3f}")
print(f"Standard deviation: {imgs.std().item():5.3f}")
print(f"Maximum: {imgs.max().item():5.3f}")
print(f"Minimum: {imgs.min().item():5.3f}")

输出:

Mean: 0.002
Standard deviation: 1.001
Maximum: 2.022
Minimum: -0.810

请注意,最大值和最小值不再是1和-1,而是向正值偏移。这是因为FashionMNIST包含许多黑色像素,与MNIST类似。接下来,我们将创建一个线性神经网络。

class BaseNetwork(nn.Module):
    def __init__(self, act_fn, input_size=784, num_classes=10, hidden_sizes=[512, 256, 256, 128]):
        """
        输入:
            act_fn - 应该在网络中作为非线性使用的激活函数的对象。
            input_size - 输入图像的像素尺寸
            num_classes - 我们想要预测的类别数量
            hidden_sizes - 一个整数列表,指定神经网络中隐藏层的大小
        """
        super().__init__()

        # 根据指定的隐藏大小创建网络
        layers = []
        layer_sizes = [input_size] + hidden_sizes
        for layer_index in range(1, len(layer_sizes)):
            layers += [nn.Linear(layer_sizes[layer_index-1], layer_sizes[layer_index]),
                       act_fn]
        layers += [nn.Linear(layer_sizes[-1], num_classes)]
        self.layers = nn.ModuleList(layers) # 模块列表将模块列表注册为子模块(例如,用于参数)

        self.config = {"act_fn": act_fn.__class__.__name__, 
                       "input_size": input_size, 
                       "num_classes": num_classes, 
                       "hidden_sizes": hidden_sizes}

    def forward(self, x):
        x = x.view(x.size(0), -1)
        for l in self.layers:
            x = l(x)
        return x

对于激活函数,我们使用PyTorch的torch.nn库而不是自己实现。当然,我们也定义了一个Identity激活函数。尽管这种激活函数会大大限制网络的建模能力,但我们将在我们的初始化讨论的第一步中使用它(为了简化)。

class Identity(nn.Module):
    def forward(self, x):
        return x

act_fn_by_name = {
    "tanh": nn.Tanh,
    "relu": nn.ReLU,
    "identity": Identity
}

最后,我们定义了一些绘图函数,我们将在讨论中使用它们。这些函数帮助我们
(1)可视化网络内部的权重/参数分布,
(2)可视化不同层的参数接收的梯度,以及
(3)激活值,即线性层的输出。

# 绘制值的分布图
def plot_dists(val_dict, color="C0", xlabel=None, stat="count", use_kde=True):
    columns = len(val_dict)  # 图表的列数等于val_dict的键的数量
    fig, ax = plt.subplots(1, columns, figsize=(columns*3, 2.5))  # 创建子图
    fig_index = 0
    for key in sorted(val_dict.keys()):  # 遍历val_dict的键
        key_ax = ax[fig_index % columns]  # 获取当前的子图轴
        sns.histplot(val_dict[key], ax=key_ax, color=color, bins=50, stat=stat,  # 绘制直方图
                     kde=use_kde and ((val_dict[key].max()-val_dict[key].min())>1e-8))  # 如果有方差则绘制核密度估计
        key_ax.set_title(f"{key} " + (r"(%i $\to$ %i)" % (val_dict[key].shape[1], val_dict[key].shape[0]) if len(val_dict[key].shape) > 1 else ""))  # 设置标题
        if xlabel is not None:
            key_ax.set_xlabel(xlabel)  # 设置x轴标签
        fig_index += 1
    fig.subplots_adjust(wspace=0.4)  # 调整子图之间的间隔
    return fig

# 可视化模型权重分布
def visualize_weight_distribution(model, color="C0"):
    weights = {}
    for name, param in model.named_parameters():  # 遍历模型的参数
        if name.endswith(".bias"):  # 如果是偏置,则跳过
            continue
        key_name = f"Layer {name.split('.')[1]}"  # 为权重创建键名
        weights[key_name] = param.detach().view(-1).cpu().numpy()  # 将权重转换为numpy数组

    # 绘图
    fig = plot_dists(weights, color=color, xlabel="Weight vals")  # 使用plot_dists函数绘制权重分布图
    fig.suptitle("Weight distribution", fontsize=14, y=1.05)  # 设置图表标题
    plt.show()  # 显示图表
    plt.close()  # 关闭图表

# 可视化模型梯度分布
def visualize_gradients(model, color="C0", print_variance=False):
    # 设置模型为评估模式
    model.eval()
    small_loader = data.DataLoader(train_set, batch_size=1024, shuffle=False)  # 创建数据加载器
    imgs, labels = next(iter(small_loader))  # 获取一批数据
    imgs, labels = imgs.to(device), labels.to(device)  # 将数据移动到设备上

    # 将一批数据通过网络前向传播,并计算权重的梯度
    model.zero_grad()  # 清空梯度
    preds = model(imgs)  # 前向传播
    loss = F.cross_entropy(preds, labels)  # 计算交叉熵损失
    loss.backward()  # 反向传播计算梯度
    # 限制可视化为权重参数,不包括偏置,以减少图表数量
    grads = {name: params.grad.view(-1).cpu().clone().numpy() for name, params in model.named_parameters() if "weight" in name}
    model.zero_grad()  # 清空梯度

    # 绘图
    fig = plot_dists(grads, color=color, xlabel="Grad magnitude")  # 使用plot_dists函数绘制梯度分布图
    fig.suptitle("Gradient distribution", fontsize=14, y=1.05)  # 设置图表标题
    plt.show()  # 显示图表
    plt.close()  # 关闭图表

    if print_variance:  # 如果需要打印方差
        for key in sorted(grads.keys()):  # 遍历梯度字典的键
            print(f"{key} - Variance: {np.var(grads[key])}")  # 打印方差

# 可视化模型激活值分布
def visualize_activations(model, color="C0", print_variance=False):
    model.eval()  # 设置模型为评估模式
    small_loader = data.DataLoader(train_set, batch_size=1024, shuffle=False)  # 创建数据加载器
    imgs, labels = next(iter(small_loader))  # 获取一批数据
    imgs, labels = imgs.to(device), labels.to(device)  # 将数据移动到设备上

    # 将一批数据通过网络前向传播,并计算权重的梯度
    feats = imgs.view(imgs.shape[0], -1)  # 重塑特征
    activations = {}
    with torch.no_grad():  # 不计算梯度
        for layer_index, layer in enumerate(model.layers):  # 遍历模型的每一层
            feats = layer(feats)  # 应用层
            if isinstance(layer, nn.Linear):  # 如果是线性层
                activations[f"Layer {layer_index}"] = feats.view(-1).detach().cpu().numpy()  # 将激活值转换为numpy数组

    # 绘图
    fig = plot_dists(activations, color=color, stat="density", xlabel="Activation vals")  # 使用plot_dists函数绘制激活值分布图
    fig.suptitle("Activation distribution", fontsize=14, y=1.05)  # 设置图表标题
    plt.show()  # 显示图表
    plt.close()  # 关闭图表

    if print_variance:  # 如果需要打印方差
        for key in sorted(activations.keys()):  # 遍历激活值字典的键
            print(f"{key} - Variance: {np.var(activations[key])}")  # 打印方差

3.初始化

在深入讨论神经网络的初始化问题之前,有必要指出,关于这一主题,网络上已经有许多精彩的博客文章,例如deeplearning.ai提供的资源,或者那些更侧重于数学分析的文章。如果在阅读完本教程后仍有疑惑,我们建议您也浏览一下这些博客文章以获得更深入的理解。
初始化神经网络时,我们希望其具备一些特定的属性。首先,输入数据的方差应能通过整个网络传递到输出层,以保证输出神经元具有相似的标准差。如果我们在网络深层发现方差逐渐消失,那么模型将难以优化,因为下一层的输入将变得几乎等同于一个恒定值。同样地,如果方差随着网络深度的增加而增大,那么梯度可能会变得非常大,导致数值稳定性问题。其次,我们希望在初始化时各层的梯度分布具有相同的方差。如果第一层得到的梯度远小于最后一层,我们可能就会在选择合适的学习速率时遇到困难。
为了寻找合适的初始化方法,我们首先以一个没有激活函数的线性神经网络作为起点进行分析,即网络中仅使用恒等激活函数。之所以这样做,是因为不同的激活函数对初始化方法有特定的要求,我们可以根据所使用的激活函数调整初始化策略。

model = BaseNetwork(act_fn=Identity()).to(device)

3.1 常数初始化

接下来,我们考虑一种最简单的初始化方法——常数初始化。直观上,将所有权重设置为零并不理想,因为这会导致传播的梯度也为零。但是,如果我们将所有权重设置为一个接近零的非零常数,情况会如何呢?为了探究这一点,我们可以编写一个函数来实现这一初始化,并可视化梯度的分布情况。
定义了一个名为const_init的函数,它接受一个模型和一个默认为0的常数值c,将模型中所有的参数(权重)填充为这个常数值。然后,我们调用这个函数将模型的权重初始化为0.005,接着使用visualize_gradientsvisualize_activations函数来可视化梯度和激活值的分布,并打印出它们的方差。这有助于我们理解在这种初始化策略下,网络的梯度和激活值的行为。

def const_init(model, c=0.0):
    for name, param in model.named_parameters():
        param.data.fill_(c)

const_init(model, c=0.005)
visualize_gradients(model)
visualize_activations(model, print_variance=True)

添加图片注释,不超过 140 字(可选)
添加图片注释,不超过 140 字(可选)

Layer 0 - Variance: 2.058276
Layer 2 - Variance: 13.489119
Layer 4 - Variance: 22.100567
Layer 6 - Variance: 36.209572
Layer 8 - Variance: 14.831439

从我们的观察来看,只有第一层和最后一层展现出了多样化的梯度分布,而中间的三层则显示出所有权重具有相同梯度的现象(注意,这个值并不为零,但往往非常接近零)。如果用相同值初始化的参数最终获得了相同的梯度,这就意味着这些参数的值将始终一致。这样的结果会让我们网络中的这一层失去作用,实际上将我们网络的参数数量减少到了单一的一个值。因此,我们不能采用常数值初始化的方法来训练我们的网络。

3.2.关于方差的恒定性

在上述实验中,我们已经发现单一的常数值初始化策略是行不通的。那么,如果我们改为从诸如高斯分布的某种概率分布中随机采样来初始化参数,情况会怎样呢?最直接的方法可能是为网络中的所有层选择一个相同的方差值。接下来,我们将实现这种方法,并可视化各层的激活分布情况。

def var_init(model, std=0.01):
    for name, param in model.named_parameters():
        param.data.normal_(std=std)

var_init(model, std=0.01)
visualize_activations(model, print_variance=True)

添加图片注释,不超过 140 字(可选)

在神经网络的层与层之间,激活值的方差呈现出逐渐减小的趋势,到了最后一层,方差几乎趋近于零。这种情况下,一个可能的解决办法是增加标准差的数值。通过提高初始化时的标准差,我们可以尝试维持网络深层的激活方差,避免其在传播过程中消失。

var_init(model, std=0.1)
visualize_activations(model, print_variance=True)

在这里插入图片描述

通过使用更高的标准差进行初始化,我们可以观察到网络各层激活值的分布情况,特别是它们的方差,以评估这种策略是否有效。这种方法可能有助于解决深层网络中的梯度消失问题,但同时也要警惕不要导致梯度爆炸,这需要我们在实践中仔细调整和平衡。

3.3.如何找到合适的初始化值

从我们之前的实验中,我们可以看到需要从某个概率分布中对权重进行采样,但具体选择哪个分布我们还不确定。下一步,我们将尝试从激活值分布的角度出发,寻找最优的初始化方法。为此,我们提出两个要求:
(1)激活值的均值应该为零。
(2)激活值的方差应该在每一层都保持不变。
假设我们要为以下层设计一个初始化方法:要设计一个满足上述两个要求的初始化方法,我们需要考虑权重矩阵和激活函数的特性。对于一个全连接层,如果使用恒等激活函数(即线性激活函数),输出的均值和方差将取决于输入的均值和方差以及权重矩阵。为了使激活值的均值为零,我们可以选择一个合适的均值。而为了保持激活值的方差在每一层都相同,我们需要选择一个合适的标准差。
一种常见的方法是使用与输入维度的平方根成反比的标准差。这样,无论输入的维度如何变化,权重的标准差都会相应调整,以保持激活值的方差大致相同。这种方法通常被称为Xavier初始化或Glorot初始化。
我们的目标是让每个元素的方差与输入的方差相同,即每个权重更新后的方差应该保持与输入数据的方差一致。这是为了确保在多层网络中,信息能够稳定地从前层传递到后层,避免出现梯度消失或爆炸的问题。

在数学上,如果我们考虑一个全连接层 y = W x + b , y ∈ R d y , x ∈ R d x y=Wx+b,y\in\mathbb{R}^{d_y},x\in\mathbb{R}^{d_x} y=Wx+b,yRdy,xRdx,其输出可以表示为 Var ( y i ) = Var ( x i ) = σ x 2 \text{Var}(y_i)=\text{Var}(x_i)=\sigma_x^{2} Var(yi)=Var(xi)=σx2,,其中 是权重矩阵 W W W, 是输入 X X X, 是偏置 b b b。对于激活函数 σ \sigma σ ,激活输出 a a a以表示为 a = σ ( z ) a = \sigma(z) a=σ(z) 。我们希望 a a a的方差保持与 x x x的方差相同。
对于偏置项 b b b,通常初始化为0,因为它们不影响激活值的方差。对于权重 W W W,如果我们假设输入 x x x的方差为 σ x 2 \sigma_x^2 σx2,并且我们希望输出 y y y的方差也为 σ x 2 \sigma_x^2 σx2,那么我们可以通过以下方式初始化权重:
W ∼ N ( 0 , σ x 2 n ) W \sim \mathcal{N}(0, \frac{\sigma_x^2}{n}) WN(0,nσx2)
这里是输入特征的数量。这种初始化方法确保了在没有激活函数的情况下,输出 的方差与输入 的方差相同。
如果使用激活函数,我们需要根据激活函数的特性调整初始化策略。例如,对于ReLU激活函数,He初始化是一种流行的选择,它使用稍微不同的公式来初始化权重,以保持激活值的方差。
在实践中,我们通常会使用现成的初始化方法,如Xavier初始化(适用于tanh激活函数)或He初始化(适用于ReLU激活函数),这些方法已经考虑了保持激活值方差的需要。
接下来,我们需要计算用于初始化权重参数所需的方差。在计算过程中,我们需要使用以下方差规则:给定两个独立的随机变量,它们的乘积的方差是 Var [ X Y ] = E [ X 2 ] ⋅ E [ Y 2 ] − ( E [ X ] ⋅ E [ Y ] ) 2 \text{Var}[XY] = \text{E}[X^2] \cdot \text{E}[Y^2] - (\text{E}[X] \cdot \text{E}[Y])^2 Var[XY]=E[X2]E[Y2](E[X]E[Y])2 (这里 x x x y y y不是指特定的随机变量,而是任意随机变量)。
所需权重 W W W的方差 Var [ W i j ] \text{Var}[W_{ij}] Var[Wij] 计算如下:
y i = ∑ j w i j x j 单个输出神经元的计算(不含偏置项) Var ( y i ) = σ x 2 = Var ( ∑ j w i j x j ) = ∑ j Var ( w i j x j ) 输入和权重是彼此独立的。  = ∑ j Var ( w i j ) ⋅ Var ( x j ) 方差规则(见上文),期望值为零 = d x ⋅ Var ( w i j ) ⋅ Var ( x j ) 对于所有的 d x 元素,方差相等 = σ x 2 ⋅ d x ⋅ Var ( w i j ) ⇒ Var ( w i j ) = σ W 2 = 1 d x \begin{split}\begin{split} y_i & = \sum_{j} w_{ij}x_{j}\hspace{10mm}\\\text{单个输出神经元的计算(不含偏置项)}\\ \text{Var}(y_i) = \sigma_x^{2} & = \text{Var}\left(\sum_{j} w_{ij}x_{j}\right)\\ & = \sum_{j} \text{Var}(w_{ij}x_{j}) \hspace{10mm}\\\text{输入和权重是彼此独立的。 }\\ & = \sum_{j} \text{Var}(w_{ij})\cdot\text{Var}(x_{j}) \hspace{10mm}\\\text{方差规则(见上文),期望值为零}\\ & = d_x \cdot \text{Var}(w_{ij})\cdot\text{Var}(x_{j}) \hspace{10mm}\\\text{对于所有的$d_x$元素,方差相等}\\ & = \sigma_x^{2} \cdot d_x \cdot \text{Var}(w_{ij})\\ \Rightarrow \text{Var}(w_{ij}) = \sigma_{W}^2 & = \frac{1}{d_x}\\ \end{split}\end{split} yi单个输出神经元的计算(不含偏置项)Var(yi)=σx2输入和权重是彼此独立的。 方差规则(见上文),期望值为零对于所有的dx元素,方差相等Var(wij)=σW2=jwijxj=Var(jwijxj)=jVar(wijxj)=jVar(wij)Var(xj)=dxVar(wij)Var(xj)=σx2dxVar(wij)=dx1

基于上述理论,我们的权重初始化策略应该是使用一个具有适当方差的分布。具体来说,权重的方差应该是输入维度倒数的方差。这样的初始化有助于保持网络各层激活值的方差大致相同,从而有助于梯度在网络中的稳定流动。

def equal_var_init(model):
    for name, param in model.named_parameters():
        if name.endswith(".bias"):  # 如果是偏置项,则初始化为0
            param.data.fill_(0)
        else:
            # 对权重使用特定的标准差进行正态分布初始化
            # 标准差为1除以输入特征数量的平方根
            param.data.normal_(std=1.0/math.sqrt(param.shape[1]))

# 应用Equal Variance Initialization到模型
equal_var_init(model)

# 可视化权重分布
visualize_weight_distribution(model)

# 可视化激活值分布,并打印每层激活值的方差
visualize_activations(model, print_variance=True)

添加图片注释,不超过 140 字(可选)
添加图片注释,不超过 140 字(可选)

Layer 0 - Variance: 1.020319
Layer 2 - Variance: 1.049295
Layer 4 - Variance: 1.031418
Layer 6 - Variance: 1.025792
Layer 8 - Variance: 0.872356

正如我们所预期的,方差确实在各层之间保持恒定。请注意,我们的初始化方法并不限制我们只能使用正态分布,而是允许使用任何具有0均值和 2 n x + n next \frac{2}{n_x + n_{\text{next}}} nx+nnext2或者 1 d x \frac{1}{d_x} dx1方差的其他分布。通常你会看到使用均匀分布进行初始化。使用均匀分布而不是正态分布的一个小小好处是,我们可以排除初始化非常大或非常小的权重的可能性。

除了激活值的方差之外,我们希望稳定的另一个方差是梯度的方差。这确保了深层网络的稳定优化。结果表明,我们可以从 Δ x = W Δ y \Delta x=W\Delta y Δx=WΔy开始进行与上述相同的计算,并得出我们应该使用 1 d y \frac{1}{d_y} dy1来初始化我们的层的结论,其中 是输出神经元的数量。你可以将这个计算作为练习来做,或者在这个博客文章中查看详尽的解释。作为两种约束之间的折衷,Glorot和Bengio(2010年)提议使用这两个值的调和平均值。这引导我们得到了众所周知的Xavier初始化:
W ∼ N ( 0 , 2 d x + d y ) W\sim \mathcal{N}\left(0,\frac{2}{d_x+d_y}\right) WN(0,dx+dy2)
如果我们使用均匀分布来初始化权重,我们会这样设置:
W ∼ U [ − 6 d x + d y , 6 d x + d y ] W\sim U\left[-\frac{\sqrt{6}}{\sqrt{d_x+d_y}}, \frac{\sqrt{6}}{\sqrt{d_x+d_y}}\right] WU[dx+dy 6 ,dx+dy 6 ]

def xavier_init(model):
    for name, param in model.named_parameters():
        if name.endswith(".bias"):
            param.data.fill_(0)
        else:
            bound = math.sqrt(6)/math.sqrt(param.shape[0]+param.shape[1])
            param.data.uniform_(-bound, bound)

xavier_init(model)
visualize_gradients(model, print_variance=True)
visualize_activations(model, print_variance=True)

添加图片注释,不超过 140 字(可选)

layers.0.weight - Variance: 0.000436
layers.2.weight - Variance: 0.000747
layers.4.weight - Variance: 0.001149
layers.6.weight - Variance: 0.001744
layers.8.weight - Variance: 0.017655

添加图片注释,不超过 140 字(可选)

Layer 0 - Variance: 1.216592
Layer 2 - Variance: 1.719161
Layer 4 - Variance: 1.714506
Layer 6 - Variance: 2.224779
Layer 8 - Variance: 5.297660

Xavier初始化方法旨在保持网络中梯度和激活值方差的一致性。我们注意到,输出层的方差之所以显著增加,是因为输入层和输出层的维度存在较大差异。例如,输入层可能有1024个神经元,而输出层可能仅有10个神经元。目前,我们的讨论假设了激活函数是线性的。引入非线性激活函数,如tanh或ReLU,会改变激活值的分布,进而影响梯度的方差。
在基于tanh的网络中,一个普遍的假设是,在训练初期,对于接近零的小值,tanh函数可以近似为线性函数。这意味着,在训练的早期阶段,我们不需要调整初始化策略的计算。然而,随着训练的进行,权重的更新可能会导致激活值的分布发生变化,从而使得tanh的非线性特性变得更加显著。
为了验证我们的初始化策略是否适用于非线性激活函数,我们可以在训练的早期阶段检查激活值的分布。如果激活值主要集中在tanh的线性区域(即接近零点),那么我们的初始化方法可能仍然有效。如果激活值分布远离零点,我们可能需要考虑调整初始化策略,以适应激活函数的非线性特性。

model = BaseNetwork(act_fn=nn.Tanh()).to(device)
xavier_init(model)
visualize_gradients(model, print_variance=True)
visualize_activations(model, print_variance=True)

添加图片注释,不超过 140 字(可选)

layers.0.weight - Variance: 0.000016
layers.2.weight - Variance: 0.000027
layers.4.weight - Variance: 0.000036
layers.6.weight - Variance: 0.000049
layers.8.weight - Variance: 0.000455

添加图片注释,不超过 140 字(可选)

Layer 0 - Variance: 1.295969
Layer 2 - Variance: 0.583388
Layer 4 - Variance: 0.291432
Layer 6 - Variance: 0.265237
Layer 8 - Variance: 0.274929

尽管随着深度的增加方差有所减小,但很明显激活值的分布更加集中在低值上。因此,如果我们进一步加深网络,方差将稳定在0.25左右。因此,我们可以得出结论,Xavier初始化对于Tanh网络效果很好。但是对于ReLU网络呢?在这里,我们不能采用之前对于小值时非线性趋近线性的假设。ReLU激活函数(按期望)将一半的输入设置为0,因此输入的期望值也不是零。然而,只要 W = 0 W=0 W=0 b = 0 b=0 b=0 ,输出的期望值就是零。ReLU初始化的计算与恒等激活函数不同之处在于确定权重的标准差 Var ( w i j x j ) \text{Var}(w_{ij}x_{j}) Var(wijxj)
Var ( w i j x j ) = E [ w i j 2 ] ⏟ = Var ( w i j ) E [ x j 2 ] − E [ w i j ] 2 ⏟ = 0 E [ x j ] 2 = Var ( w i j ) E [ x j 2 ] \text{Var}(w_{ij}x_{j})=\underbrace{\mathbb{E}[w_{ij}^2]}_{=\text{Var}(w_{ij})}\mathbb{E}[x_{j}^2]-\underbrace{\mathbb{E}[w_{ij}]^2}_{=0}\mathbb{E}[x_{j}]^2=\text{Var}(w_{ij})\mathbb{E}[x_{j}^2] Var(wijxj)==Var(wij) E[wij2]E[xj2]=0 E[wij]2E[xj]2=Var(wij)E[xj2]
如果我们现在假设 是前一层经过ReLU激活函数的输出(即 ,我们可以按照以下方式计算期望值:
E [ x 2 ] = E [ max ⁡ ( 0 , y ~ ) 2 ] = 1 2 E [ y ~ 2 ] y ~  是以零为中心且对称的 = 1 2 Var ( y ~ ) \begin{split}\begin{split} \mathbb{E}[x^2] & =\mathbb{E}[\max(0,\tilde{y})^2]\\ & =\frac{1}{2}\mathbb{E}[{\tilde{y}}^2]\hspace{2cm}\tilde{y}\text{ 是以零为中心且对称的}\\ & =\frac{1}{2}\text{Var}(\tilde{y}) \end{split}\end{split} E[x2]=E[max(0,y~)2]=21E[y~2]y~ 是以零为中心且对称的=21Var(y~)
由于ReLU函数的定义为 max ⁡ ( 0 , y ~ ) \max(0, \tilde{y}) max(0,y~),它将所有负值置为0,而所有正值保持不变。因此,对于输入 y ~ \tilde{y} y~的任意小的正期望 μ y ~ \mu_{\tilde{y}} μy~,输出 y y y 的期望 μ y ~ \mu_{\tilde{y}} μy~将是:
μ y = E [ y ] = E [ max ⁡ ( 0 , y ~ ) ] \mu_y = \mathbb{E}[y] = \mathbb{E}[\max(0, \tilde{y})] μy=E[y]=E[max(0,y~)]
由于 y ~ \tilde{y} y~的负部分被置为0,只有当 y ~ \tilde{y} y~大于0时,它才对期望有贡献。假设 y ~ \tilde{y} y~的概率密度函数是对称的,那么其正负部分的期望将抵消,只有正值部分对期望有贡献。因此,我们可以简化计算为:
μ y = ∫ 0 ∞ y ~ p ( y ~ ) d y ~ \mu_y = \int_0^\infty \tilde{y} p(\tilde{y}) d\tilde{y} μy=0y~p(y~)dy~
这里 p ( y ~ ) p(\tilde{y}) p(y~)是 的概率密度函数。如果 是从标准正态分布 初始化的,那么:
μ y = σ y ~ 2 π \mu_y = \sigma_{\tilde{y}} \sqrt{\frac{2}{\pi}} μy=σy~π2
这个结果表明,即使输入 y ~ \tilde{y} y~的期望是0,经过ReLU激活函数后,输出 的期望也会是一个正的小数值。这个正值来自于正态分布的正尾部分的积分。
在初始化权重时,我们需要考虑到这一点,以确保在ReLU激活下,网络的输出和梯度的期望保持在合理的范围内。这就是为什么He初始化(也称为Kaiming初始化)为ReLU激活专门设计了权重的初始化策略。
因此,我们发现在方程中有一个额外的1/2因子,所以我们期望的权重方差变为 。这给我们提供了Kaiming初始化(见He, K. 等人 (2015) 的论文)。请注意,Kaiming初始化不使用输入和输出大小之间的调和平均值。在他们的论文(第2.2节,反向传播,最后一段)中,他们争论说使用 或 都可以在整个网络中得到稳定的梯度,并且只依赖于网络的整体输入和输出大小。因此,我们这里只使用输入 :

def kaiming_init(model):
    for name, param in model.named_parameters():
        if name.endswith(".bias"):
            param.data.fill_(0)
        elif name.startswith("layers.0"): # The first layer does not have ReLU applied on its input
            param.data.normal_(0, 1/math.sqrt(param.shape[1]))
        else:
            param.data.normal_(0, math.sqrt(2)/math.sqrt(param.shape[1]))

model = BaseNetwork(act_fn=nn.ReLU()).to(device)
kaiming_init(model)
visualize_gradients(model, print_variance=True)
visualize_activations(model, print_variance=True)

添加图片注释,不超过 140 字(可选)

layers.0.weight - Variance: 0.000075
layers.2.weight - Variance: 0.000108
layers.4.weight - Variance: 0.000185
layers.6.weight - Variance: 0.000444
layers.8.weight - Variance: 0.005548

添加图片注释,不超过 140 字(可选)

Layer 0 - Variance: 1.012342
Layer 2 - Variance: 1.092432
Layer 4 - Variance: 1.268176
Layer 6 - Variance: 1.193706
Layer 8 - Variance: 1.760064

Kaiming初始化通过特别考虑ReLU激活函数的特性,确保了在基于ReLU的网络中权重的方差能够在每一层保持稳定。这种初始化方法对于保持深层网络在训练过程中梯度的稳定性至关重要。
然而,对于其他变体的ReLU激活函数,比如Leaky-ReLU,其中负值不会被置为零,而是乘以一个小的正斜率(例如0.01),我们需要对Kaiming初始化的方差因子进行调整。这是因为Leaky-ReLU的输出不会像标准的ReLU那样有一半的零值,因此期望值和方差的计算会有所不同。
PyTorch框架提供了一个内置函数 calculate_gain,它可以根据激活函数的不同自动计算所需的初始化增益。这个函数可以自动为Leaky-ReLU等激活函数计算合适的初始化因子,从而简化了初始化过程。

import torch.nn.init as init

# 假设我们使用的是Leaky-ReLU激活函数
def leaky_relu_gain(negative_slope=0.01):
    # 使用PyTorch的calculate_gain函数计算Leaky-ReLU的增益
    return init.calculate_gain('leaky_relu', negative_slope)

# 计算Leaky-ReLU的增益
gain = leaky_relu_gain()
std = gain / math.sqrt(fan_in)  # fan_in是输入特征的数量

# 使用计算出的增益来初始化权重
for param in model.parameters():
    init.normal_(param.data, mean=0.0, std=std)

4.优化算法

除了初始化之外,为深度神经网络选择一个合适的优化算法也是一个重要的选择。在深入研究这些算法之前,我们应该定义训练模型的代码。

# 根据模型路径和名称获取配置文件的路径
def _get_config_file(model_path, model_name):
    return os.path.join(model_path, model_name + ".config")

# 根据模型路径和名称获取模型文件的路径
def _get_model_file(model_path, model_name):
    return os.path.join(model_path, model_name + ".tar")

# 根据模型路径和名称获取结果文件的路径
def _get_result_file(model_path, model_name):
    return os.path.join(model_path, model_name + "_results.json")

# 加载模型
def load_model(model_path, model_name, net=None):
    # 构造配置文件和模型文件的路径
    config_file, model_file = _get_config_file(model_path, model_name), _get_model_file(model_path, model_name)
    # 确保配置文件和模型文件存在
    assert os.path.isfile(config_file), f"找不到配置文件\"{config_file}\"。请确认路径正确,并且模型配置已存储在此位置。"
    assert os.path.isfile(model_file), f"找不到模型文件\"{model_file}\"。请确认路径正确,并且模型已存储在此位置。"
    # 读取配置文件
    with open(config_file, "r") as f:
        config_dict = json.load(f)
    # 如果没有提供网络结构,则根据配置文件创建网络
    if net is None:
        act_fn_name = config_dict["act_fn"].pop("name").lower()
        assert act_fn_name in act_fn_by_name, f"未知的激活函数\"{act_fn_name}\"。请将其添加到\"act_fn_by_name\"字典中。"
        act_fn = act_fn_by_name[act_fn_name]()
        net = BaseNetwork(act_fn=act_fn, **config_dict)
    # 加载模型状态
    net.load_state_dict(torch.load(model_file))
    return net

# 保存模型
def save_model(model, model_path, model_name):
    config_dict = model.config
    # 创建模型保存路径
    os.makedirs(model_path, exist_ok=True)
    config_file, model_file = _get_config_file(model_path, model_name), _get_model_file(model_path, model_name)
    # 保存配置文件和模型状态
    with open(config_file, "w") as f:
        json.dump(config_dict, f)
    torch.save(model.state_dict(), model_file)

# 训练模型
def train_model(net, model_name, optim_func, max_epochs=50, batch_size=256, overwrite=False):
    """
    在FashionMNIST的训练集上训练模型

    输入:
        net - BaseNetwork类型的对象
        model_name - (str)模型名称,用于创建检查点名称
        max_epochs - 我们想要(最大)训练的周期数
        patience - 如果在#patience个周期内验证集上的性能没有改善,我们将提前停止训练
        batch_size - 训练中使用的批次大小
        overwrite - 确定如何处理已经存在检查点的情况。如果为True,将被覆盖。否则,我们将跳过训练。
    """
    # 省略了部分代码...(由于代码过长,这里省略了部分内容,实际使用时不应省略)

# 测试模型
def test_model(net, data_loader):
    """
    在指定的数据集上测试模型。

    输入:
        net - 训练好的BaseNetwork类型的模型
        data_loader - 要在其上测试的数据集的DataLoader对象(验证或测试)
    """
    net.eval()
    true_preds, count = 0., 0
    for imgs, labels in data_loader:
        imgs, labels = imgs.to(device), labels.to(device)
        with torch.no_grad():
            preds = net(imgs).argmax(dim=-1)
            true_preds += (preds == labels).sum().item()
            count += labels.shape[0]
    test_acc = true_preds / count
    return test_acc

首先,我们需要理解优化器实际上是做什么的。优化器负责根据梯度更新网络的参数。因此,我们实际上实现了一个函数 w t = f ( w t − 1 , g t , . . . ) w^{t} = f(w^{t-1}, g^{t}, ...) wt=f(wt1,gt,...),其中 是时间步 t 的参数, g t = ∇ w ( t − 1 ) L ( t ) g^{t} = \nabla_{w^{(t-1)}} \mathcal{L}^{(t)} gt=w(t1)L(t)是时间步 t 的梯度。这个函数的常见额外参数是学习率,这里用 η \eta η 表示。通常,学习率可以看作是更新的“步长”。较高的学习率意味着我们更大幅度地根据梯度方向改变权重,较小的学习率意味着我们采取更短的步长。
由于大多数优化器只在 f 的实现上有所不同,我们可以在PyTorch中定义一个优化器的模板如下。我们输入模型的参数和一个学习率。函数 zero_grad 将所有参数的梯度设置为零,这是在调用 loss.backward() 之前我们必须做的。最后,step() 函数告诉优化器根据它们的梯度更新所有权重。模板设置如下:

class OptimizerTemplate:
    # 初始化函数,接受模型的参数和学习率
    def __init__(self, params, lr):
        self.params = list(params)  # 将传入的参数转换为列表
        self.lr = lr  # 学习率

    # 清零梯度的函数
    def zero_grad(self):
        # 遍历所有参数
        for p in self.params:
            # 如果参数的梯度存在
            if p.grad is not None:
                p.grad.detach_()  # 对于二阶优化器,这很重要
                p.grad.zero_()  # 将梯度置为零

    # 应用更新步骤的函数,使用torch.no_grad()上下文管理器来禁用梯度计算
    @torch.no_grad()
    def step(self):
        # 遍历所有参数
        for p in self.params:
            # 如果参数没有梯度则跳过
            if p.grad is None:
                continue
            self.update_param(p)  # 更新参数

    # 更新参数的函数,需要在具体的优化器子类中实现
    def update_param(self, p):
        raise NotImplementedError("Parameter update method should be implemented in optimizer-specific classes")

我们将要实现的第一个优化器是标准的随机梯度下降(SGD)。SGD使用以下公式更新参数:
w ( t ) = w ( t − 1 ) − η ⋅ g ( t ) \begin{split} w^{(t)} & = w^{(t-1)} - \eta \cdot g^{(t)} \end{split} w(t)=w(t1)ηg(t)

class SGD(OptimizerTemplate):

    # 初始化函数,调用父类的初始化函数
    def __init__(self, params, lr):
        super().__init__(params, lr)

    # 实现SGD参数更新的方法
    def update_param(self, p):
        # 计算参数更新的值,这里是根据SGD的更新规则
        p_update = -self.lr * p.grad
        # 原地更新参数,即直接在原参数上减去计算出的更新值
        # 使用add_()方法可以节省内存,并且不会创建额外的计算图
        p.add_(p_update)

在本文中,我们还讨论了动量概念,它通过将包括当前梯度在内的所有过去梯度的指数平均值来替代更新中的梯度:
m ( t ) = β 1 m ( t − 1 ) + ( 1 − β 1 ) ⋅ g ( t ) w ( t ) = w ( t − 1 ) − η ⋅ m ( t ) \begin{split}\begin{split} m^{(t)} & = \beta_1 m^{(t-1)} + (1 - \beta_1)\cdot g^{(t)}\\ w^{(t)} & = w^{(t-1)} - \eta \cdot m^{(t)}\\ \end{split}\end{split} m(t)w(t)=β1m(t1)+(1β1)g(t)=w(t1)ηm(t)

class SGDMomentum(OptimizerTemplate):

    # 初始化函数,添加动量参数
    def __init__(self, params, lr, momentum=0.0):
        super().__init__(params, lr)
        self.momentum = momentum  # 对应于公式中的 beta_1
        # 创建一个字典,用于存储每个参数的动量项 m_t
        self.param_momentum = {p: torch.zeros_like(p.data) for p in self.params}

    # 实现带动量的SGD参数更新方法
    def update_param(self, p):
        # 计算当前参数的动量项,这里是指数加权平均的实现
        self.param_momentum[p] = (1 - self.momentum) * p.grad + self.momentum * self.param_momentum[p]
        # 计算参数更新的值,结合了学习率和动量项
        p_update = -self.lr * self.param_momentum[p]
        # 原地更新参数,节省内存且不创建额外的计算图
        p.add_(p_update)

最终,我们来到了Adam优化器。Adam结合了动量的概念和基于平方梯度的指数平均值的自适应学习率,即梯度的范数。此外,我们为动量和自适应学习率在最初的迭代中添加了偏差校正:
m ( t ) = β 1 m ( t − 1 ) + ( 1 − β 1 ) ⋅ g ( t ) v ( t ) = β 2 v ( t − 1 ) + ( 1 − β 2 ) ⋅ ( g ( t ) ) 2 m ^ ( t ) = m ( t ) 1 − β 1 t , v ^ ( t ) = v ( t ) 1 − β 2 t w ( t ) = w ( t − 1 ) − η v ^ ( t ) + ϵ ∘ m ^ ( t ) \begin{split}\begin{split} m^{(t)} & = \beta_1 m^{(t-1)} + (1 - \beta_1)\cdot g^{(t)}\\ v^{(t)} & = \beta_2 v^{(t-1)} + (1 - \beta_2)\cdot \left(g^{(t)}\right)^2\\ \hat{m}^{(t)} & = \frac{m^{(t)}}{1-\beta^{t}_1}, \hat{v}^{(t)} = \frac{v^{(t)}}{1-\beta^{t}_2}\\ w^{(t)} & = w^{(t-1)} - \frac{\eta}{\sqrt{\hat{v}^{(t)}} + \epsilon}\circ \hat{m}^{(t)}\\ \end{split}\end{split} m(t)v(t)m^(t)w(t)=β1m(t1)+(1β1)g(t)=β2v(t1)+(1β2)(g(t))2=1β1tm(t),v^(t)=1β2tv(t)=w(t1)v^(t) +ϵηm^(t)
Epsilon是一个非常小的常数,用于提高梯度范数非常小的情况下的数值稳定性。请记住,自适应学习率并不替代学习率超参数 η \eta η ,而是作为一个额外的因素,确保不同参数的梯度具有相似的范数。

class Adam(OptimizerTemplate):

    # 初始化函数,添加了Adam优化器所需的参数
    def __init__(self, params, lr, beta1=0.9, beta2=0.999, eps=1e-8):
        super().__init__(params, lr)
        self.beta1 = beta1  # 动量超参数
        self.beta2 = beta2  # 二次动量超参数
        self.eps = eps  # 用于数值稳定性的小常数
        # 用于记录每个参数的更新次数,用于偏差校正
        self.param_step = {p: 0 for p in self.params}
        # 用于存储每个参数的一阶动量
        self.param_momentum = {p: torch.zeros_like(p.data) for p in self.params}
        # 用于存储每个参数的二阶动量
        self.param_2nd_momentum = {p: torch.zeros_like(p.data) for p in self.params}

    # 实现Adam参数更新的方法
    def update_param(self, p):
        self.param_step[p] += 1  # 更新参数的更新次数

        # 计算一阶动量(指数加权平均的梯度)
        self.param_momentum[p] = (1 - self.beta1) * p.grad + self.beta1 * self.param_momentum[p]
        # 计算二阶动量(指数加权平均的梯度平方)
        self.param_2nd_momentum[p] = (1 - self.beta2) * (p.grad)**2 + self.beta2 * self.param_2nd_momentum[p]

        # 计算偏差校正因子
        bias_correction_1 = 1 - self.beta1 ** self.param_step[p]
        bias_correction_2 = 1 - self.beta2 ** self.param_step[p]

        # 计算调整后的二阶动量和一阶动量
        p_2nd_mom = self.param_2nd_momentum[p] / bias_correction_2
        p_mom = self.param_momentum[p] / bias_correction_1
        # 计算自适应学习率
        p_lr = self.lr / (torch.sqrt(p_2nd_mom) + self.eps)
        # 计算参数更新值
        p_update = -p_lr * p_mom

4.1.优化器比较

在实现了三种优化器(SGD、带动量的SGD和Adam)之后,我们可以开始分析并比较它们。首先,我们测试它们在优化FashionMNIST数据集上的神经网络方面的表现。我们再次使用我们的线性网络,这次使用ReLU激活函数和Kaiming初始化,这是我们之前发现适用于基于ReLU的网络的。请注意,该模型对于此任务来说是过度参数化的,我们可以使用更小的网络(例如100,100,100)实现类似的性能。然而,我们的主要兴趣在于优化器能够多好地训练深度神经网络,因此采用了过度参数化。

base_model = BaseNetwork(act_fn=nn.ReLU(), hidden_sizes=[512,256,256,128])
kaiming_init(base_model)  # 使用Kaiming初始化方法初始化模型权重

为了进行公平比较,我们使用三种优化器以相同的种子训练完全相同的模型。如果你愿意,可以自由更改超参数(然而,那样的话,你必须自己训练模型)。

SGD_model = copy.deepcopy(base_model).to(device)  # 创建模型的深拷贝并将其移动到设备上
SGD_results = train_model(SGD_model, "FashionMNIST_SGD",
                          lambda params: SGD(params, lr=1e-1),  # 使用SGD优化器
                          max_epochs=40, batch_size=256)  # 训练参数

在上述代码中,我们首先定义了一个基础模型 base_model,它是一个具有ReLU激活函数和特定隐藏层大小的 BaseNetwork 的实例。然后,我们使用 kaiming_init 函数对这个模型的权重进行初始化。
接下来,我们使用 copy.deepcopy 来创建 base_model 的一个深拷贝,以确保在训练过程中不会影响原始模型。我们将这个模型移动到适当的设备上(例如GPU),然后使用 train_model 函数来训练模型。在这个例子中,我们使用学习率为0.1的SGD优化器进行训练,最大周期数设置为40,批量大小设置为256。
通过这种方式,我们可以比较不同优化器在相同条件下的性能。类似的步骤可以用于测试带有动量的SGD和Adam优化器,只需更改 train_model 函数中的优化器参数即可。
添加图片注释,不超过 140 字(可选)

SGDMom_model = copy.deepcopy(base_model).to(device)
SGDMom_results = train_model(SGDMom_model, "FashionMNIST_SGDMom",
                             lambda params: SGDMomentum(params, lr=1e-1, momentum=0.9),
                             max_epochs=40, batch_size=256)

添加图片注释,不超过 140 字(可选)

Adam_model = copy.deepcopy(base_model).to(device)
Adam_results = train_model(Adam_model, "FashionMNIST_Adam",
                           lambda params: Adam(params, lr=1e-3),
                           max_epochs=40, batch_size=256)

添加图片注释,不超过 140 字(可选)

结果是,所有优化器在给定模型上的表现都相当好。差异太小,以至于无法得出任何重大结论。然而,请记住,这也可以归因于我们选择的初始化方式。当将初始化方式改为较差的(例如,常数初始化)时,由于其自适应学习率,Adam通常表现出更强的鲁棒性。为了展示这些优化器的特定优势,我们将继续观察一些可能的损失曲面,其中动量和自适应学习率至关重要。

4.2.病态曲率

病态曲率 病态曲率是一种类似于峡谷的曲面,对于普通的SGD优化特别棘手。用文字描述,病态曲率通常在一个方向上具有陡峭的梯度,中心有一个最优解,而在第二个方向上,我们有一个更平缓的梯度通向(全局)最优解。让我们首先创建这样一个示例曲面并对其进行可视化:

# 定义病态曲率损失函数
def pathological_curve_loss(w1, w2):
    # 这是一个病态曲率的例子。还有许多其他可能的曲面,欢迎在此实验!
    x1_loss = torch.tanh(w1)**2 + 0.01 * torch.abs(w1)  # w1的损失项
    x2_loss = torch.sigmoid(w2)  # w2的损失项
    return x1_loss + x2_loss  # 总损失是x1_loss和x2_loss的和

# 定义绘制曲面的函数
def plot_curve(curve_fn, x_range=(-5,5), y_range=(-5,5), plot_3d=False, cmap=cm.viridis, title="Pathological curvature"):
    # 创建图形
    fig = plt.figure()
    # 根据plot_3d参数选择创建3D轴还是2D轴
    ax = plt.axes(projection='3d') if plot_3d else plt.axes()

    # 创建x和y的值范围
    x = torch.arange(x_range[0], x_range[1], (x_range[1]-x_range[0])/100.)
    y = torch.arange(y_range[0], y_range[1], (y_range[1]-y_range[0])/100.)
    # 利用meshgrid生成网格坐标点
    x, y = torch.meshgrid(x, y, indexing='xy')
    # 计算曲面的Z值,即损失函数值
    z = curve_fn(x, y)
    # 将计算得到的Z值转换为numpy数组
    x, y, z = x.numpy(), y.numpy(), z.numpy()

    # 根据plot_3d参数绘制3D曲面图或2D图像
    if plot_3d:
        ax.plot_surface(x, y, z, cmap=cmap, linewidth=1, color="#000", antialiased=False)
        ax.set_zlabel("loss")  # 设置Z轴标签为"loss"
    else:
        ax.imshow(z[::-1], cmap=cmap, extent=(x_range[0], x_range[1], y_range[0], y_range[1]))

    # 设置图形的标题和坐标轴标签
    plt.title(title)
    ax.set_xlabel(r"$w_1$")
    ax.set_ylabel(r"$w_2$")
    plt.tight_layout()  # 调整子图布局以适应图形
    return ax

# 重置Seaborn的默认样式
sns.reset_orig()
# 绘制3D曲面图
_ = plot_curve(pathological_curve_loss, plot_3d=True)
plt.show()  # 显示图形

添加图片注释,不超过 140 字(可选)

在优化方面,你可以将 和 想象成权重参数,而曲率则代表了 和 空间上的损失曲面。请注意,在典型的网络中,我们拥有的参数数量远远超过两个,这种曲率也可能以多维空间中出现。
理想情况下,我们的优化算法会找到峡谷的中心,并专注于沿着 方向优化参数。然而,如果我们在山脊沿线遇到某点, 方向的梯度将远大于 ​,我们可能会从一个侧面跳到另一个侧面。由于梯度较大,我们将不得不降低学习率,从而显著减慢学习速度。
为了测试我们的算法,我们可以实现一个简单的函数,在这样一个曲面上训练两个参数:

def train_curve(optimizer_func, curve_func=pathological_curve_loss, num_updates=100, init=[5, 5]):
    """
    该函数用于在特定的损失曲面上训练权重参数,并记录训练过程。

    输入:
        optimizer_func - 要使用的优化器的构造函数。应该只接受一个参数列表。
        curve_func - 损失函数(例如病态曲率)。
        num_updates - 优化过程中更新/步数的数量。
        init - 参数的初始值。必须是一个有两个元素的列表/元组,分别代表 w_1 和 w_2。

    输出:
        NumPy数组,形状为 [num_updates, 3],其中 [t,:2] 是第 t 步时的参数值,[t,2] 是第 t 步的损失。
    """
    # 将初始值转换为可训练的参数
    weights = nn.Parameter(torch.FloatTensor(init), requires_grad=True)
    # 创建优化器
    optimizer = optimizer_func([weights])

    # 初始化用于记录训练过程中参数和损失的列表
    list_points = []
    for _ in range(num_updates):
        # 计算损失
        loss = curve_func(weights[0], weights[1])
        # 将当前的参数和损失添加到记录列表中
        list_points.append(torch.cat([weights.data.detach(), loss.unsqueeze(dim=0).detach()], dim=0))
        # 清零梯度
        optimizer.zero_grad()
        # 反向传播计算梯度
        loss.backward()
        # 更新参数
        optimizer.step()
    # 将记录的点转换为NumPy数组并返回
    points = torch.stack(list_points, dim=0).numpy()
    return points

下一步,让我们在曲率上应用不同的优化器。注意,我们为优化算法设置了一个比标准神经网络更高的学习率。
这是因为我们只有两个参数,而不是数万甚至数百万。

SGD_points = train_curve(lambda params: SGD(params, lr=10))  # 使用SGD优化器
SGDMom_points = train_curve(lambda params: SGDMomentum(params, lr=10, momentum=0.9))  # 使用带动量的SGD优化器
Adam_points = train_curve(lambda params: Adam(params, lr=1))  # 使用Adam优化器

# 为了最好地理解不同算法的工作方式,我们通过损失曲面绘制更新步骤的折线图。
# 为了可读性,我们将坚持使用2D表示。

# 将所有优化器的点合并到一个数组中
all_points = np.concatenate([SGD_points, SGDMom_points, Adam_points], axis=0)
# 绘制损失曲面并标记不同优化器的路径
ax = plot_curve(
    pathological_curve_loss,
    x_range=(-np.absolute(all_points[:, 0]).max(), np.absolute(all_points[:, 0]).max()),
    y_range=(all_points[:, 1].min(), all_points[:, 1].max()),
    plot_3d=False
)
ax.plot(SGD_points[:, 0], SGD_points[:, 1], color="red", marker="o", zorder=1, label="SGD")  # SGD路径
ax.plot(SGDMom_points[:, 0], SGDMom_points[:, 1], color="blue", marker="o", zorder=2, label="SGDMom")  # 带动量的SGD路径
ax.plot(Adam_points[:, 0], Adam_points[:, 1], color="grey", marker="o", zorder=3, label="Adam")  # Adam路径
plt.legend()  # 显示图例
plt.show()  # 显示图形

这段代码首先使用三种不同的优化器(SGD、带动量的SGD和Adam)在病态曲率损失曲面上进行训练,并记录了每一步的参数值和损失。然后,它将所有优化器的训练路径合并到一个数组中,并使用plot_curve函数绘制损失曲面的2D表示。在2D图形上,使用不同的颜色和标记样式绘制了每种优化器的路径,并添加了图例来标识每种优化器。最后,显示了这个图形,让我们可以直观地比较不同优化器在病态曲率上的优化过程。

添加图片注释,不超过 140 字(可选)

我们可以清楚地看到,SGD(随机梯度下降)无法找到优化曲线的中心,并且由于方向上的梯度非常陡峭,它在收敛方面存在问题。相比之下,Adam和带动量的SGD能够很好地收敛,因为 方向上变化的方向在不断抵消自身。在这类曲面上,使用动量至关重要。

4.3.陡峭的最优值

第二种具有挑战性的损失曲面是陡峭的最优值。在这些曲面中,有一大部分区域的梯度非常小,而在最优值周围,我们有非常大的梯度。例如,考虑以下损失曲面:

# 定义一个二元高斯函数
def bivar_gaussian(w1, w2, x_mean=0.0, y_mean=0.0, x_sig=1.0, y_sig=1.0):
    norm = 1 / (2 * np.pi * x_sig * y_sig)  # 高斯分布的归一化因子
    x_exp = (-1 * (w1 - x_mean)**2) / (2 * x_sig**2)  # w1的高斯指数部分
    y_exp = (-1 * (w2 - y_mean)**2) / (2 * y_sig**2)  # w2的高斯指数部分
    return norm * torch.exp(x_exp + y_exp)  # 返回二元高斯分布的值

# 定义组合函数,创建具有陡峭最优值的损失曲面
def comb_func(w1, w2):
    z = -bivar_gaussian(w1, w2, x_mean=1.0, y_mean=-0.5, x_sig=0.2, y_sig=0.2)
    z -= bivar_gaussian(w1, w2, x_mean=-1.0, y_mean=0.5, x_sig=0.2, y_sig=0.2)
    z -= bivar_gaussian(w1, w2, x_mean=-0.5, y_mean=-0.8, x_sig=0.2, y_sig=0.2)
    return z

# 使用plot_curve函数绘制具有陡峭最优值的损失曲面
_ = plot_curve(
    comb_func, 
    x_range=(-2, 2), 
    y_range=(-2, 2), 
    plot_3d=True, 
    title="Steep optima"
)
plt.show()

添加图片注释,不超过 140 字(可选)

大部分损失曲面的梯度非常小,甚至没有梯度。然而,在最优值附近,我们有非常陡峭的梯度。要从梯度较低的区域开始达到最小值,我们预期自适应学习率至关重要。为了验证这个假设,我们可以在曲面上运行我们的三种优化器:

# 使用train_curve函数和不同的优化器在具有陡峭最优值的损失曲面上进行训练
SGD_points = train_curve(
    lambda params: SGD(params, lr=0.5), 
    curve_func=comb_func, 
    num_updates=1000, 
    init=[0, 0]
)
SGDMom_points = train_curve(
    lambda params: SGDMomentum(params, lr=1, momentum=0.9), 
    curve_func=comb_func, 
    num_updates=1000, 
    init=[0, 0]
)
Adam_points = train_curve(
    lambda params: Adam(params, lr=0.2), 
    curve_func=comb_func, 
    num_updates=1000, 
    init=[0, 0]
)

# 将不同优化器的训练路径合并到一个数组中
all_points = np.concatenate([SGD_points, SGDMom_points, Adam_points], axis=0)

# 使用plot_curve函数绘制损失曲面,并在图上绘制不同优化器的训练路径
ax = plot_curve(
    comb_func,
    x_range=(-2, 2),
    y_range=(-2, 2),
    plot_3d=False,
    title="Steep optima"
)
ax.plot(
    SGD_points[:, 0], 
    SGD_points[:, 1], 
    color="red", 
    marker="o", 
    zorder=3, 
    label="SGD", 
    alpha=0.7
)
ax.plot(
    SGDMom_points[:, 0], 
    SGDMom_points[:, 1], 
    color="blue", 
    marker="o", 
    zorder=2, 
    label="SGDMom", 
    alpha=0.7
)
ax.plot(
    Adam_points[:, 0], 
    Adam_points[:, 1], 
    color="grey", 
    marker="o", 
    zorder=1, 
    label="Adam", 
    alpha=0.7
)
ax.set_xlim(-2, 2)  # 设置x轴的范围
ax.set_ylim(-2, 2)  # 设置y轴的范围
plt.legend()  # 显示图例
plt.show()  # 显示图形

添加图片注释,不超过 140 字(可选)
SGD最初采取的步长非常小,直到它触及最优值的边界。首先到达大约(-0.75, -0.5)的点,梯度方向发生了变化,将参数推向(0.8, 0.5),从这个点SGD再也无法恢复(除非经过许多步骤)。带动量的SGD也有类似的问题,只不过它继续沿着触及最优值的方向前进。这个时间点的梯度远大于其他任何点,以至于动量 被它压倒。最后,Adam能够在最优值处收敛,展示了自适应学习率的重要性。

4.4. 优化器的选择要点

在看到优化结果后,我们的结论是什么?我们应该总是使用Adam,再也不考虑SGD了吗?简短的回答:不。有许多论文表明,在某些情况下,SGD(带动量)泛化得更好,而Adam往往倾向于过拟合[5,6]。这与寻找更宽广的最优值有关。
在实际应用中,选择哪种优化器取决于多种因素,包括问题的具体性质、网络的架构、训练数据的规模和特性等。因此,理解不同优化器的特性并在适当的情境中运用它们是非常重要的。尽管Adam在许多情况下表现出色,但SGD及其变体在其他情况下可能更为合适,特别是在我们关心模型泛化能力的时候。例如,参见下图中不同最优值的示意图(Keskar等人,2017年):

添加图片注释,不超过 140 字(可选)

黑色线条代表训练损失曲面,而虚线红线是测试损失。找到锐利、狭窄的最小值可能有助于发现最小的训练损失。然而,这并不意味着它也会最小化测试损失,因为尤其是平坦的最小值被证明具有更好的泛化能力。可以想象,由于测试数据集与训练集中的示例不同,其损失曲面可能会有轻微的偏移。对于锐利的最小值来说,小的变化可能会产生显著的影响,而平坦的最小值通常对这种变化更加稳健。
在下篇博文中,我们将看到某些类型的网络仍然可以更好地使用SGD和学习率调度来优化,而不是Adam。尽管如此,Adam是深度学习中最常用的优化器,因为它通常比其他优化器表现得更好,特别是对于深层网络。

5.结论

在文中,我们讨论了神经网络的初始化和优化技术。我们看到良好的初始化必须平衡保持梯度方差和激活方差。这可以通过使用Xavier初始化实现对于基于tanh的网络,以及使用Kaiming初始化实现对于基于ReLU的网络。在优化方面,动量和自适应学习率等概念可以帮助应对具有挑战性的损失曲面,但并不能保证神经网络性能的提升。

参考文献

[1] Glorot, Xavier, 和 Yoshua Bengio. “理解训练深度前馈神经网络的难度。” 第十三届国际人工智能和统计会议论文集。2010年。链接
[2] He, Kaiming, 等人. “深入研究激活函数:在ImageNet分类上超越人类水平的表现。” 2015年IEEE国际计算机视觉会议论文集。2015年。链接
[3] Kingma, Diederik P. & Ba, Jimmy. “Adam:一种用于随机优化的方法。” 第三届国际学习表示会议(ICLR)论文集。2015年。链接
[4] Keskar, Nitish Shirish, 等人. “关于深度学习的大规模批量训练:泛化差距和尖锐最小值。” 第五届国际学习表示会议(ICLR)论文集。2017年。链接
[5] Wilson, Ashia C., 等人. “自适应梯度方法在机器学习中的边际价值。” 神经信息处理系统进展。2017年。链接
[6] Ruder, Sebastian. “梯度下降优化算法概述。” arXiv预印本。2017年。链接

  • 18
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

MUKAMO

你的鼓励是我们创作最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值