NNDL 实验七 循环神经网络(2)梯度爆炸实验

NNDL 实验七 循环神经网络(2)梯度爆炸实验

  • 6.2 梯度爆炸实验
    • 6.2.1 梯度打印函数
    • 【思考】什么是范数,什么是L2范数,这里为什么要打印梯度范数?
    • 6.2.2 复现梯度爆炸现象
    • 6.2.3 使用梯度截断解决梯度爆炸问题
    • 【思考题】梯度截断解决梯度爆炸问题的原理是什么?
  • 总结
  • 参考链接

6.2 梯度爆炸实验

造成简单循环网络较难建模长程依赖问题的原因有两个:梯度爆炸和梯度消失。
梯度爆炸问题:比较容易解决,一般通过权重衰减或梯度截断可以较好地来避免;
梯度消失问题:更加有效的方式是改变模型,比如通过长短期记忆网络LSTM来进行缓解。
NNDL 实验七 循环神经网络(2)梯度爆炸实验_第1张图片

本节将尝试使用梯度截断的方式进行解决。
采用长度为20的数据集进行实验,训练过程中将进行输出W,U,b的梯度向量的范数,以此来衡量梯度的变化况。

6.2.1 梯度打印函数

在训练过程中打印梯度

分别定义W_list, U_list和b_list,用于分别存储训练过程中参数W,U和b的梯度范数

W_list = []
U_list = []
b_list = []
# 计算梯度范数
def custom_print_log(runner):
    model = runner.model
    W_grad_l2, U_grad_l2, b_grad_l2 = 0, 0, 0
    for name, param in model.named_parameters():
        if name == "rnn_model.W":
            W_grad_l2 = torch.norm(param.grad, p=2).numpy()
        if name == "rnn_model.U":
            U_grad_l2 = torch.norm(param.grad, p=2).numpy()
        if name == "rnn_model.b":
            b_grad_l2 = torch.norm(param.grad, p=2).numpy()
    print(f"[Training] W_grad_l2: {W_grad_l2:.5f}, U_grad_l2: {U_grad_l2:.5f}, b_grad_l2: {b_grad_l2:.5f} ")
    W_list.append(W_grad_l2)
    U_list.append(U_grad_l2)
    b_list.append(b_grad_l2)

【思考】什么是范数,什么是L2范数,这里为什么要打印梯度范数?

在之前的实验中已经提到过范数、L2范数的概念,在这里重新复习一下
范数: 把一个事物映射到非负实数,且满足非负性、齐次性、三角不等式。
L2范数: 在这里插入图片描述L-P范数:在这里插入图片描述注意p的范围:①p不能等于无穷,对于无穷范数有额外的定义;②p可以是小数
无穷时的定义如下:
在这里插入图片描述
比较抽象,可以使用 范数绘制 这个网址调节P值来直观感受范数。我调节P值最直观的感受就是:范数越大,图像越方
网上有一个现成得图,如下:
NNDL 实验七 循环神经网络(2)梯度爆炸实验_第2张图片上面的是二维的,三维的如下:
NNDL 实验七 循环神经网络(2)梯度爆炸实验_第3张图片范数有一些良好的数学性质,如下:
NNDL 实验七 循环神经网络(2)梯度爆炸实验_第4张图片

为什么要打印梯度范数呢?

这是因为在深度学习中,需要使用训练数据来最小化损失函数,从而确定参数的值。而最小化损失函数,即需要求得损失函数的极值。求解函数极值时,需要用到导数。对于某个连续函数 f (x),令其一阶导数f’(x),通过求解该微分方程,便可直接获得极值点。但当变量很多或者函数很复杂时, f ′ ( x ) = 0 的显式解并不容易求得,且计算机并不擅长于求解微分方程。
计算机所擅长的是,凭借强大的计算能力,通过插值等方法(如牛顿下山法、弦截法等),海量尝试,一步一步的去把函数的极值点“试”出来

而海量尝试需要一个方向感,为了阐述这个方向感,介绍一下方向导数。

  • 方向导数
    方向导数是偏导数的概念的推广, 偏导数研究的是指定方向 (坐标轴方向) 的变化率,到了方向导数,研究哪个方向可就不一定了。函数在某一点处的方向导数在其梯度方向上达到最大值,此最大值即梯度的范数
  • 梯度
    在一个数量场中,函数在给定点处沿不同的方向,其方向导数一般是不相同的。那么沿着哪一个方向其方向导数最大,其最大值为多少,这是我们所关心的,为此引进一个很重要的概念: 梯度。
    函数在某一点处的方向导数在其梯度方向上达到最大值。这就是说,沿梯度方向,函数值增加最快。同样可知,方向导数的最小值在梯度的相反方向取得,此最小值为最大值的相反数,从而沿梯度相反方向函数值的减少最快。

NNDL 实验七 循环神经网络(2)梯度爆炸实验_第5张图片
所以我个人觉得打印梯度范数值可以帮助我们了解模型是否向着更好的方向前进,太小或者太大都是不利于模型参数学习的。

6.2.2 复现梯度爆炸现象

为了更好地复现梯度爆炸问题,使用SGD优化器将批大小和学习率调大,学习率为0.2,同时在计算交叉熵损失时,将reduction设置为sum,表示将损失进行累加。(损失函数与一些超参数变了,其他代码与上个实验代码相同)

import os
import random
import torch
import numpy as np

np.random.seed(0)
random.seed(0)
torch.seed()

# 训练轮次
num_epochs = 50
# 学习率
lr = 0.2
# 输入数字的类别数
num_digits = 10
# 将数字映射为向量的维度
input_size = 32
# 隐状态向量的维度
hidden_size = 32
# 预测数字的类别数
num_classes = 19
# 批大小 
batch_size = 64
# 模型保存目录
save_dir = "./checkpoints"


# 可以设置不同的length进行不同长度数据的预测实验
length = 20
print(f"\n====> Training SRN with data of length {length}.")

# 加载长度为length的数据
data_path = f"./datasets/{length}"
train_examples, dev_examples, test_examples = load_data(data_path)
train_set, dev_set, test_set = DigitSumDataset(train_examples), DigitSumDataset(dev_examples),DigitSumDataset(test_examples)
train_loader = DataLoader(train_set, batch_size=batch_size)
dev_loader = DataLoader(dev_set, batch_size=batch_size)
test_loader = DataLoader(test_set, batch_size=batch_size)
# 实例化模型
base_model = SRN(input_size, hidden_size)
model = Model_RNN4SeqClass(base_model, num_digits, input_size, hidden_size, num_classes) 
# 指定优化器
optimizer = torch.optim.SGD(lr=lr, params=model.parameters())
# 定义评价指标
metric = Accuracy()
# 定义损失函数
loss_fn = nn.CrossEntropyLoss(reduction="sum")

# 基于以上组件,实例化Runner
runner = RunnerV3(model, optimizer, loss_fn, metric)

# 进行模型训练
model_save_path = os.path.join(save_dir, f"srn_explosion_model_{length}.pdparams")
runner.train(train_loader, dev_loader, num_epochs=num_epochs, eval_steps=100, log_steps=1, 
             save_path=model_save_path, custom_print_log=custom_print_log)

运行结果
NNDL 实验七 循环神经网络(2)梯度爆炸实验_第6张图片(取的很有代表性的一段过程,L2范数先急剧上升再急剧下降)

获取训练过程中关于W,U和b参数梯度的L2范数,并将其绘制为图片以便展示。

import matplotlib.pyplot as plt
def plot_grad(W_list, U_list, b_list, save_path, keep_steps=40):
    # 开始绘制图片
    plt.figure()
    # 默认保留前40步的结果
    steps = list(range(keep_steps))
    plt.plot(steps, W_list[:keep_steps], "r-", color="#e4007f", label="W_grad_l2")
    plt.plot(steps, U_list[:keep_steps], "-.", color="#f19ec2", label="U_grad_l2")
    plt.plot(steps, b_list[:keep_steps], "--", color="#000000", label="b_grad_l2")

    plt.xlabel("step")
    plt.ylabel("L2 Norm")
    plt.legend(loc="upper right")
    plt.show()
    plt.savefig(save_path)
    print("image has been saved to: ", save_path)

save_path = f"./images/6.8.pdf"
plot_grad(W_list, U_list, b_list, save_path)

运行结果
NNDL 实验七 循环神经网络(2)梯度爆炸实验_第7张图片展示了在训练过程中关于W,U和b参数梯度的L2范数,可以看到经过学习率等方式的调整,梯度范数急剧变大,而后梯度范数几乎为0.
这是因为Tanh为Sigmoid型函数,其饱和区的导数接近于0,由于梯度的急剧变化,参数数值变的较大或较小,容易落入梯度饱和区,导致梯度为0,模型很难继续训练.
NNDL 实验七 循环神经网络(2)梯度爆炸实验_第8张图片接下来,使用梯度截断策略的模型在测试集上进行测试。

# 加载训练过程中效果最好的模型
model_path = os.path.join(save_dir, "srn_explosion_model_20.pdparams")
runner.load_model(model_path)

# 使用测试集评价模型,获取测试集上的预测准确率
score, _ = runner.evaluate(test_loader)
print(f"[SRN] length:{length}, Score: {score: .5f}")

运行结果
在这里插入图片描述0.1的准确率,应该是神经网络蒙的准确率。

6.2.3 使用梯度截断解决梯度爆炸问题

梯度截断是一种可以有效解决梯度爆炸问题的启发式方法,当梯度的模大于一定阈值时,就将它截断成为一个较小的数。一般有两种截断方式:按值截断和按模截断.本实验使用按模截断的方式解决梯度爆炸问题。
在这里插入图片描述在PyTorch中,只需要在优化器参数更新之前加一行即可:

 nn.utils.clip_grad_norm_(parameters=model.parameters(), max_norm=20, norm_type=2)

加代码不难,难的是阈值的调整,很不好找。

代码如下:


# 清空梯度列表
W_list.clear()
U_list.clear()
b_list.clear()
# 实例化模型
base_model = SRN(input_size, hidden_size)
model = Model_RNN4SeqClass(base_model, num_digits, input_size, hidden_size, num_classes)

# 定义clip,并实例化优化器

optimizer = torch.optim.SGD(lr=lr, params=model.parameters())
# 定义评价指标
metric = Accuracy()
# 定义损失函数
loss_fn = nn.CrossEntropyLoss(reduction="sum")

# 实例化Runner
runner = RunnerV3(model, optimizer, loss_fn, metric)

# 训练模型
model_save_path = os.path.join(save_dir, f"srn_fix_explosion_model_{length}.pdparams")
runner.train(train_loader, dev_loader, num_epochs=num_epochs, eval_steps=100, log_steps=1, save_path=model_save_path, custom_print_log=custom_print_log)
# 进行模型训练
model_save_path = os.path.join(save_dir, f"srn_explosion_model_{length}.pdparams")

因为修改是在Runner里面,所以将修改后的Runner贴到下面:

class RunnerV3(object):
    def __init__(self, model, optimizer, loss_fn, metric, **kwargs):
        self.model = model
        self.optimizer = optimizer
        self.loss_fn = loss_fn
        self.metric = metric  # 只用于计算评价指标

        # 记录训练过程中的评价指标变化情况
        self.dev_scores = []

        # 记录训练过程中的损失函数变化情况
        self.train_epoch_losses = []  # 一个epoch记录一次loss
        self.train_step_losses = []  # 一个step记录一次loss
        self.dev_losses = []

        # 记录全局最优指标
        self.best_score = 0

    def train(self, train_loader, dev_loader=None, **kwargs):
        # 将模型切换为训练模式
        self.model.train()

        # 传入训练轮数,如果没有传入值则默认为0
        num_epochs = kwargs.get("num_epochs", 0)
        # 传入log打印频率,如果没有传入值则默认为100
        log_steps = kwargs.get("log_steps", 100)
        # 评价频率
        eval_steps = kwargs.get("eval_steps", 0)

        # 传入模型保存路径,如果没有传入值则默认为"best_model.pdparams"
        save_path = kwargs.get("save_path", "best_model.pdparams")

        custom_print_log = kwargs.get("custom_print_log", None)

        # 训练总的步数
        num_training_steps = num_epochs * len(train_loader)

        if eval_steps:
            if self.metric is None:
                raise RuntimeError('Error: Metric can not be None!')
            if dev_loader is None:
                raise RuntimeError('Error: dev_loader can not be None!')

        # 运行的step数目
        global_step = 0

        # 进行num_epochs轮训练
        for epoch in range(num_epochs):
            # 用于统计训练集的损失
            total_loss = 0
            for step, data in enumerate(train_loader):
                X, y = data
                # 获取模型预测
                logits = self.model(X)
                loss = self.loss_fn(logits, y.long())  # 默认求mean
                total_loss += loss

                # 训练过程中,每个step的loss进行保存
                self.train_step_losses.append((global_step, loss.item()))

                if log_steps and global_step % log_steps == 0:
                    print(
                        f"[Train] epoch: {epoch}/{num_epochs}, step: {global_step}/{num_training_steps}, loss: {loss.item():.5f}")

                # 梯度反向传播,计算每个参数的梯度值
                loss.backward()

                if custom_print_log:
                    custom_print_log(self)

                # 小批量梯度下降进行参数更新
                nn.utils.clip_grad_norm_(parameters=model.parameters(), max_norm=20, norm_type=2)
                self.optimizer.step()
                # 梯度归零
                self.optimizer.zero_grad()

                # 判断是否需要评价
                if eval_steps > 0 and global_step > 0 and \
                        (global_step % eval_steps == 0 or global_step == (num_training_steps - 1)):

                    dev_score, dev_loss = self.evaluate(dev_loader, global_step=global_step)
                    print(f"[Evaluate]  dev score: {dev_score:.5f}, dev loss: {dev_loss:.5f}")

                    # 将模型切换为训练模式
                    self.model.train()

                    # 如果当前指标为最优指标,保存该模型
                    if dev_score > self.best_score:
                        self.save_model(save_path)
                        print(
                            f"[Evaluate] best accuracy performence has been updated: {self.best_score:.5f} --> {dev_score:.5f}")
                        self.best_score = dev_score

                global_step += 1

            # 当前epoch 训练loss累计值
            trn_loss = (total_loss / len(train_loader)).item()
            # epoch粒度的训练loss保存
            self.train_epoch_losses.append(trn_loss)

        print("[Train] Training done!")

    # 模型评估阶段,使用'torch.no_grad()'控制不计算和存储梯度
    @torch.no_grad()
    def evaluate(self, dev_loader, **kwargs):
        assert self.metric is not None

        # 将模型设置为评估模式
        self.model.eval()

        global_step = kwargs.get("global_step", -1)

        # 用于统计训练集的损失
        total_loss = 0

        # 重置评价
        self.metric.reset()

        # 遍历验证集每个批次
        for batch_id, data in enumerate(dev_loader):
            X, y = data

            # 计算模型输出
            logits = self.model(X)

            # 计算损失函数
            loss = self.loss_fn(logits, y.long()).item()
            # 累积损失
            total_loss += loss

            # 累积评价
            self.metric.update(logits, y)

        dev_loss = (total_loss / len(dev_loader))
        dev_score = self.metric.accumulate()

        # 记录验证集loss
        if global_step != -1:
            self.dev_losses.append((global_step, dev_loss))
            self.dev_scores.append(dev_score)

        return dev_score, dev_loss

    # 模型评估阶段,使用'torch.no_grad()'控制不计算和存储梯度
    @torch.no_grad()
    def predict(self, x, **kwargs):
        # 将模型设置为评估模式
        self.model.eval()
        # 运行模型前向计算,得到预测值
        logits = self.model(x)
        return logits

    def save_model(self, save_path):
        torch.save(self.model.state_dict(), save_path)

    def load_model(self, model_path):
        state_dict = torch.load(model_path)
        self.model.load_state_dict(state_dict)


from torch.utils.data import Dataset,DataLoader
class DigitSumDataset(Dataset):
    def __init__(self, data):
        self.data = data

    def __getitem__(self, idx):
        example = self.data[idx]
        seq = torch.tensor(example[0], dtype=torch.int64)
        label = torch.tensor(example[1], dtype=torch.int64)
        return seq, label

    def __len__(self):
        return len(self.data)
class Accuracy():
    def __init__(self, is_logist=True):
        # 用于统计正确的样本个数
        self.num_correct = 0
        # 用于统计样本的总数
        self.num_count = 0

        self.is_logist = is_logist

    def update(self, outputs, labels):

        # 判断是二分类任务还是多分类任务,shape[1]=1时为二分类任务,shape[1]>1时为多分类任务
        if outputs.shape[1] == 1:  # 二分类
            outputs = torch.squeeze(outputs, dim=-1)
            if self.is_logist:
                # logist判断是否大于0
                preds = torch.tensor((outputs >= 0), dtype=torch.float32)
            else:
                # 如果不是logist,判断每个概率值是否大于0.5,当大于0.5时,类别为1,否则类别为0
                preds = torch.tensor((outputs >= 0.5), dtype=torch.float32)
        else:
            # 多分类时,使用'torch.argmax'计算最大元素索引作为类别
            preds = torch.argmax(outputs, dim=1)

        # 获取本批数据中预测正确的样本个数
        labels = torch.squeeze(labels, dim=-1)
        batch_correct = torch.sum(torch.tensor(preds == labels, dtype=torch.float32)).cpu().numpy()
        batch_count = len(labels)

        # 更新num_correct 和 num_count
        self.num_correct += batch_correct
        self.num_count += batch_count

    def accumulate(self):
        # 使用累计的数据,计算总的指标
        if self.num_count == 0:
            return 0
        return self.num_correct / self.num_count

    def reset(self):
        # 重置正确的数目和总数
        self.num_correct = 0
        self.num_count = 0

    def name(self):
        return "Accuracy"

运行结果
NNDL 实验七 循环神经网络(2)梯度爆炸实验_第9张图片

引入按模截断的策略之后,模型训练时参数梯度的变化情况。可以看到,随着迭代步骤的进行,梯度始终保持在一个有值的状态,表明按模截断能够很好地解决梯度爆炸的问题.

接下来,使用梯度截断策略的模型在测试集上进行测试

print(f"Evaluate SRN with data length {length}.")

# 加载训练过程中效果最好的模型
model_path = os.path.join(save_dir, "srn_explosion_model_20.pdparams")
runner.load_model(model_path)

# 使用测试集评价模型,获取测试集上的预测准确率
score, _ = runner.evaluate(test_loader)
print(f"[SRN] length:{length}, Score: {score: .5f}")

运行结果
在这里插入图片描述由于为复现梯度爆炸现象,改变了学习率,优化器等,因此准确率相对比较低。但由于采用梯度截断策略后,在后续训练过程中,模型参数能够被更新优化,因此准确率有一定的提升。

【思考题】梯度截断解决梯度爆炸问题的原理是什么?

“Clips gradient norm of an iterable of parameters. The norm is computed over all gradients together, as if they were concatenated into a single vector. Gradients are modified in-place.”

PyTorch官方文档意思是对一组可迭代(网络)参数的梯度范数进行裁剪(截断)。效果如同将所有参数连接成单个向量来计算范数。梯度原位修改。

下面根据Pytorch官方提供的梯度截断方法的源代码来分析其原理:

def clip_grad_norm_(parameters, max_norm, norm_type=2):
    if isinstance(parameters, torch.Tensor):
        parameters = [parameters]
    parameters = list(filter(lambda p: p.grad is not None, parameters))
    max_norm = float(max_norm)
    norm_type = float(norm_type)

该部分处理了传入的三个参数。首先将parameters中的非空网络参数存入一个列表,然后将max_norm和norm_type类型强制为浮点数。

    if norm_type == inf:
        total_norm = max(p.grad.data.abs().max() for p in parameters)

该句对无穷范数进行了单独计算,即取所有网络参数梯度范数中的最大值,定义为total_norm:
在这里插入图片描述

 else:
        total_norm = 0
        for p in parameters:
            param_norm = p.grad.data.norm(norm_type)
            total_norm += param_norm.item() ** norm_type
        total_norm = total_norm ** (1. / norm_type)

对于其他范数,我们计算所有网络参数梯度范数之和,再归一化,即等价于把所有网络参数放入一个向量,再对向量计算范数。将结果定义为total_norm:
在这里插入图片描述

    clip_coef = max_norm / (total_norm + 1e-6)
    if clip_coef < 1:
        for p in parameters:
            p.grad.data.mul_(clip_coef)
    return total_norm

最后定义了一个“裁剪系数”变量clip_coef,为传入参数max_norm和total_norm的比值(+1e-6防止分母为0的情况)。
如果max_norm > total_norm,即没有溢出预设上限,则不对梯度进行修改。反之则以clip_coef为系数对全部梯度进行惩罚,使最后的全部梯度范数归一化至max_norm的值。

注意:该方法返回了一个 total_norm,实际应用时可以通过该方法得到网络参数梯度的范数,以便确定合理的max_norm值
NNDL 实验七 循环神经网络(2)梯度爆炸实验_第10张图片
所以梯度截断一半加在loss.backward() 之后,optimizer.step() 之前:
在这里插入图片描述

总结

这次实验的工作量不是很大,主要就是复习参数优化关于范数的内容、学习神经网络梯度爆炸众多解决办法中的梯度截断(裁剪),我觉得实际操作中应该不会用到,因为实验过程中发现它的阈值很难调,调下来有两条结论:

  1. 梯度越大,total_norm值越大,进而导致clip_coef的值越小,最终也会导致对梯度的裁剪(截断)越厉害。
  2. norm_type不管取多少,对于total_norm的影响不是太大(L1和L2的差距稍微大一点),所以可以直接取默认值2

参考链接

老师博客
老师博客园

梯度范数讲解1
梯度范数讲解2
直观感受范数的网站
PyTorch官方文档关于梯度截断的内容

你可能感兴趣的:(DL实验,rnn,深度学习)