深度学习学习笔记(5)——过拟合

 本文记录了在学习pytorch版《动手学深度学习》时所涉及到的知识和所做的练习。

 原书地址:https://tangshusen.me/Dive-into-DL-PyTorch/#/

1 训练误差和泛化误差

 在前面对于FashionMNIST数据集进行训练的时候,其实有一个很大的问题,先看看下面这张训练时候的图。

在这里插入图片描述

 可以发现,在训练的过程中,训练集的损失函数值其实一直在下降,而准确率也一直在提升。这说明了模型在训练集上确实在很好的拟合,并且效果越来越好,但是也可以看到,在测试集上的准确率,有时候在上升,有时候也会下降,并且整体来说都是低于训练集的准确率。所以这就引出了训练误差和泛化误差的概念。

训练误差是指模型在训练集上表现出来的误差,比如这里上面训练集最后的准确率是0.8468,这个数字就可以用来表示模型在训练集上表现出来的误差,百分之84.68的准确率已经算是比较好了。

泛化误差是指模型在测试集上表现出来的误差,比如这里上面测试集最后的准确率是0.8295,这个数字就表示模型在测试集上的表现比训练集上的要差一些。

 由于所有的训练都是为了让模型在测试集上拥有更好的表现,所以在深度学习中,更应该关注的是降低泛化误差,提升模型的泛化能力,这样的模型才是更加有用的。

注意:在实际应用中,一般来说测试集的数据都是很少,并且很多都是只能用一次的数据,所以在前面所提到的测试集实际上应该被称为验证集。

 验证集可以是从全部训练集中,提取出来一部分数据,这一部分不用来训练,只用来做模型效果的验证。这样就可以根据验证集上观察到的模型表现,来进行模型选择(选择效果更好的模型)。

2 欠拟合和过拟合

欠拟合是指模型的训练误差比较大,也就是说模型并没有得到很好的训练,来拟合训练集的数据。

过拟合是指模型的训练误差比较小,但是泛化误差比训练误差要大很多,也就是说模型已经很好的拟合了训练集的数据,但是拟合过头了,导致在测试集上的表现很差,这样的模型就失去了泛化能力。

 导致这两种拟合的原因有很多,我们主要考虑的是模型的复杂度以及训练集的大小。所以接下来,用代码来测试一下这两者对于拟合到底有怎么样的影响。

 这里以多项式函数为例,设有一个n阶多项式

 其中x是训练集输入特征,w_i是权重参数,b是偏差,y_i是训练集的样本。

 首先还是根据一个特定的表达式,来随机出一个训练集。设表达式为

 编写代码来生成一个人工训练集。

import numpy as np
import torch
torch.set_default_tensor_type(torch.DoubleTensor)
seed = 0
torch.manual_seed(seed)            # 为CPU设置随机种子
torch.cuda.manual_seed(seed)       # 为当前GPU设置随机种子
torch.cuda.manual_seed_all(seed)   # 为所有GPU设置随机种子
np.random.seed(seed) # 为np设置随机种子

w = [2.5,1,3.2]
b = 10
num_train = 1000
num_test = 100

# 生成训练集
trainX = torch.randn(num_train, 1)
trainY = 0.05 * trainX + 0.01 * torch.pow(trainX, 2) + 0.02 * torch.pow(trainX, 3) + 0.05
# 加上一个偏差
trainY = trainY + torch.tensor(np.random.normal(0, 0.01, trainY.shape))
trainY = trainY.cuda()

# 生成测试集
testX = torch.randn(num_test, 1)
testY = 0.05 * testX + 0.01 * torch.pow(testX, 2) + 0.02 * torch.pow(testX, 3) + 0.05
# 加上一个偏差
testY = testY + torch.tensor(np.random.normal(0, 0.01, testY.shape))
testY = testY.cuda()

 接下来就根据这个数据集来验证模型复杂度和训练集样本大小对于拟合的影响。

2.1 模型复杂度

 一般来说,模型中间的参数越多,输入的特征维度越多,这个模型就也更复杂。对于上述的n阶多项式模型,n值越大,参数也就越多,模型也就越复杂。而且虽然上面我们已经知道数据是根据n=3来构造的,但是在很多应用的时候,我们不可能知道数据是怎么构成的,只能通过数据的分布,采用更加适合数据集的模型。所以为了检验模型复杂度带来的拟合情况,这里采用n=1和n=3分别进行建立模型,然后训练。

# torch.cat是拼接函数,把第一个参数中的所以数据拼接起来,第二个参数如果为1,则拼在列上,如果为0,就拼在行上
# 这里把五阶的输入都拼在一起,在调用模型函数的时候,只需要传入截断后的参数,就可以实现n=1,n=3的情况了
train_feature = torch.cat((trainX, torch.pow(trainX, 2), torch.pow(trainX, 3)),1) 
test_feature = torch.cat((testX, torch.pow(testX, 2), torch.pow(testX, 3)),1) 
train_feature = train_feature.cuda()
test_feature = test_feature.cuda()
import torch.nn as nn
import torch.nn.init as init
lr = 0.03
BATCH_SIZE = 10
NUM_EPOCH = 10

import matplotlib.pyplot as plt
def semilogy(x_vals, y_vals, x2_vals, y2_vals):
    plt.figure(figsize=(9, 6))
    # 设置X轴的标签
    plt.xlabel('epoch')
    plt.ylabel('loss')
    # 画折线图
    plt.plot(x_vals, y_vals)
    # linestyle='--'是表示画虚线,以区别两条线段
    plt.plot(x2_vals, y2_vals, linestyle='--')
    # 对每一条线段设置一个标志
    plt.legend(['train', 'test'])

loss_fn = torch.nn.MSELoss()

def fit(n):
    # 这里简化模型的定义,因为只有一层线性层,所以可以直接这样定义
    net = nn.Linear(n, 1)
    net.cuda()
    # 初始化模型参数
    init.normal_(net.weight, mean=0, std=0.01)
    init.constant_(net.bias, val=0)
    
    optimizer = torch.optim.SGD(net.parameters(), lr=lr)
    # 把输入特征和标签转化成Dataset,然后再用DataLoader分成多个batch进行训练
    # [:, :n]这个是切片操作,表示第一维取全部,第二维只取0到n的数据
    train_dataset = torch.utils.data.TensorDataset(train_feature[:, :n], trainY)
    test_dataset = torch.utils.data.TensorDataset(test_feature[:, :n], testY)
    train_iter = torch.utils.data.DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
    test_iter = torch.utils.data.DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=True)

    train_loss = []
    test_loss = []
    for epoch in range(NUM_EPOCH):
        for X, y in train_iter:
            X = X.cuda()
            y = y.cuda()
            y_pred = net(X)
            loss = loss_fn(y_pred, y)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
        # 每经过一个epoch,都把当前对训练集和测试集的损失值计算出来,然后加入到列表中。
        train_ls = loss_fn(net(train_feature[:, :n]), trainY).item()
        test_ls = loss_fn(net(test_feature[:, :n]), testY).item()
        train_loss.append(train_ls)
        test_loss.append(test_ls)
    # 把损失值进行绘图
    semilogy(range(1, NUM_EPOCH + 1), train_loss,
             range(1, NUM_EPOCH + 1), test_loss)
# 先用三阶多项式模型来训练
fit(3)

输出结果:
在这里插入图片描述
 可以从图中看到,如果采用的是三阶多项式模型,那么最后在训练集拟合出来loss曲线和测试集基本一致,说明这个模型的复杂度就刚好适合这个数据。然后再取n=1画图出来看看。

fit(1)

输出结果:
在这里插入图片描述
 这个图中就可以看到,训练集的loss并没有降下来,对训练集的拟合并不是很好,而测试集上表现更好的原因,可能更多的因为运气以及数据的分布了。就好像如果一个小学生,在没有任何高考题目的训练的情况下,做平时的模拟题和做真正的高考题,考的分数更多的是看运气。如果是一个经过很多训练的高中生,他做模拟题和高考题的差别也就不会那么大了。

2.2 训练集的大小

 我们还是可以使用之前的函数,把其中切片的位置进行修改,就可以来查看训练集大小对于拟合的影响了。之前模型复杂度是对二个维度进行切片,这里要取一个更小的训练集,可以从第一个维度切一小块来进行训练。

def fit2(n):
    net = nn.Linear(3, 1)
    net.cuda()
    init.normal_(net.weight, mean=0, std=0.01)
    init.constant_(net.bias, val=0)
    
    optimizer = torch.optim.Adam(net.parameters(), lr=lr)
    # [:n]就是在第一个维度上切片,取0到n的数据
    train_dataset = torch.utils.data.TensorDataset(train_feature[:n], trainY[:n])
    test_dataset = torch.utils.data.TensorDataset(test_feature, testY)
    train_iter = torch.utils.data.DataLoader(train_dataset, batch_size=BATCH_SIZE)
    test_iter = torch.utils.data.DataLoader(test_dataset, batch_size=BATCH_SIZE)

    train_loss = []
    test_loss = []
    for epoch in range(NUM_EPOCH):
        for X, y in train_iter:
            X = X.cuda()
            y = y.cuda()
            y_pred = net(X)
            loss = loss_fn(y_pred, y)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
        train_ls = loss_fn(net(train_feature[:n]), trainY[:n]).item()
        print('当前第{}轮 train_loss = {}'.format(epoch+1, train_ls))
        test_ls = loss_fn(net(test_feature[:n]), testY[:n]).item()
        print('当前第{}轮 test_loss = {}'.format(epoch+1, test_ls))
        print('---------------------------')
        train_loss.append(train_ls)
        test_loss.append(test_ls)
    semilogy(range(1, NUM_EPOCH + 1), train_loss,
             range(1, NUM_EPOCH + 1), test_loss)
    print(net.weight.data)
    print(net.bias.data)
# 只取10个数据来进行训练
fit2(10)

输出结果:
在这里插入图片描述
 从上图可以发现,训练集的loss已经降到很低了,但是测试集比训练集还要高出不少。因为训练集的大小只有20个,太小了,所以模型很快就把这个20个数据拟合了,但是也因此失去了泛化能力,在测试集表现就非常差。

 实际上,大多数实际应用中,都是存在过拟合的问题。所以针对以上的过拟合问题,有以下的方法来进行解决。

3 解决过拟合方法(1)——权重衰减

 权重衰减也被称为L2范数正则化,正则化是对目标函数加上一个惩罚项,来使得每次参数迭代以后过大,使其不至于过拟合。而L2范数正则化选用的惩罚项是L2范数,也就是每个元素的平方和。比如说在之前的线性回归中,原始的损失函数是这样的

 其中,$w_1$和$w_2$是模型的权重参数,$b$是偏差参数,$x_1^{(i)}$和$x_2^{(i)}$是输入样本,$Y^{(i)}$是真实标签。把权重参数用向量$W=[w_1,w_2]$来表示,那么加上一个L2范数惩罚项以后,新的目标函数应该是

 其中,$\vert\vert{W}\vert\vert^2$就表示权重元素的平方和,前面乘以的$\lambda$是正则化系数。正则化系数越大,惩罚项也就越大,权重的值就会越小,也就越不容易过拟合;正则化系数越小,惩罚项也就越小,正则化系数为0的时候,惩罚项就没有了。在这个式子中我们只对权重进行了乘法,没有对偏差进行惩罚,有时候也可以在偏差上加一个惩罚项。

 修改了损失函数以后,参数迭代的公式也随机发生变化,这里还是使用小批量学习的参数迭代公式

 在pytorch中,权重衰减只需要在创建优化器的时候,设置weight_decay这个参数就可以实现了,因为我们只想要让权重参数得到惩罚,所以这里的优化器就要分成偏差参数和权重参数两个优化器了。所以接下来,针对上面因为样本太少而出现的过拟合问题,用权重衰减的方法来尝试解决一下。

def fit3(n ,wd):
    net = nn.Linear(3, 1)
    net.cuda()
    
    init.normal_(net.weight, mean=0, std=0.01)
    init.constant_(net.bias, val=0)
    
    # 在权重参数初始化的时候,设置weight_decay参数
    optimizer_w = torch.optim.Adam(params=[net.weight], lr=lr, weight_decay=wd)
    optimizer_b = torch.optim.Adam(params=[net.bias], lr=lr)
    
    train_dataset = torch.utils.data.TensorDataset(train_feature[:n], trainY[:n])
    test_dataset = torch.utils.data.TensorDataset(test_feature, testY)
    train_iter = torch.utils.data.DataLoader(train_dataset, batch_size=BATCH_SIZE)
    test_iter = torch.utils.data.DataLoader(test_dataset, batch_size=BATCH_SIZE)

    train_loss = []
    test_loss = []
    for epoch in range(NUM_EPOCH):
        for X, y in train_iter:
            X = X.cuda()
            y = y.cuda()
            y_pred = net(X)
            loss = loss_fn(y_pred, y)
            # 两个优化器都要清零
            optimizer_w.zero_grad()
            optimizer_b.zero_grad()
            loss.backward()
            # 两个优化器进行迭代
            optimizer_w.step()
            optimizer_b.step()
        train_ls = loss_fn(net(train_feature[:n]), trainY[:n]).item()
        print('当前第{}轮 train_loss = {}'.format(epoch+1, train_ls))
        test_ls = loss_fn(net(test_feature), testY).item()
        print('当前第{}轮 test_loss = {}'.format(epoch+1, test_ls))
        print('---------------------------')
        train_loss.append(train_ls)
        test_loss.append(test_ls)
    semilogy(range(1, NUM_EPOCH + 1), train_loss,
             range(1, NUM_EPOCH + 1), test_loss)
    print(net.weight.data)
    print(net.bias.data)
    
# 只取10个数据来进行训练
fit3(10, 1)

输出结果:
在这里插入图片描述
 从图中可以发现,相比前面不加权重衰减的情况,测试集和训练集差距已经变小了,过拟合情况变好转了。

4 解决过拟合问题方法(2)——丢弃法

 丢弃法的意思是,对网络中的一些单元,采取随机丢弃的策略,这样在反向传播的迭代参数过程中,模型就不会过度依赖于这个单元所带来的影响。比如在前面实现的多层感知机中,有一个隐藏层,其中有5个隐藏单元。如果对这个隐藏层使用丢弃法的话,这5个隐藏单元就有p的概率被丢弃(被置零),而其他没有被丢弃的单元则会被除以1-p来做拉伸。这个p就称为丢弃概率。

 假设一个随机变量为$\xi$,它为0的概率为p,为1的概率为1-p。我们可以把它看作是,当$\xi=0$时,这个隐藏单元的就被置0,当$\xi=1$时,隐藏单元就除以1-p。所以新的隐藏单元可以这样来描述,设原来的隐藏单元为$h_i$,则

 这样看上去好像把输出结果都给影响了,但是我们可以计算一下这个输入的期望,原来输入的期望是$h_i$,而现在因为$E(\xi)=1-p$,所以,

 所以丢弃法不改变输入的期望,也就不会影响我们的结果,并且它防止了迭代参数的时候过于依赖某个单元,把一些单元随机清零,也就相当于起到了正则化惩罚项的效果。注意:丢弃法一般在训练模型中才是用,才验证模型的时候,不用丢弃法

 而pytorch也提供了一种让我们区别训练模型和验证模型的方法,那就是在模型训练之前,先声明net.train(),如果是验证模型,就声明net.eval(),这样对于同一个网络,训练的时候会使用丢弃法,而验证的时候就不会使用丢弃法。

 pytorch使用丢弃法也很简单,只需要在全连接层以后加上一句self.dropout(p),其中的p设置为丢弃概率就可以了。

 接下来就使用这个丢弃法,来对之前的FashionMNIST数据集再次进行训练,来看一看效果。上一次只使用了一个隐藏层,而这一次可以让网络变得更加复杂一点。

import torch
import torchvision
import torch.nn as nn
import torch.nn.init as init
import torch.optim
import torch.utils.data as data
import torchvision.transforms as transforms
import torch.nn.functional as F
torch.set_default_tensor_type(torch.FloatTensor)
# 设定随机数种子,保证结果可复现
SEED = 2020
torch.manual_seed(SEED)
torch.cuda.manual_seed_all(SEED)

# 设定超参数
lr = 0.03
BATCH_SIZE = 256
NUM_EPOCH = 30
# 输入层
NUM_INPUT = 784
# 三个隐藏层
NUM_HIDDEN1 = 256
NUM_HIDDEN2 = 128
NUM_HIDDEN3 = 256
# 输出层
NUM_OUTPUT = 10

# 读入数据集
mnist_train = torchvision.datasets.FashionMNIST(root='~/Datasets/FashionMNIST', train=True, download=True, transform=transforms.ToTensor())
mnist_test = torchvision.datasets.FashionMNIST(root='~/Datasets/FashionMNIST', train=False, download=True, transform=transforms.ToTensor())
train_iter = data.DataLoader(mnist_train, batch_size=BATCH_SIZE, shuffle=False)
test_iter = data.DataLoader(mnist_train, batch_size=BATCH_SIZE, shuffle=False)

class DropMLP(nn.Module):
    def __init__(self, NUM_INPUT, NUM_HIDDEN1, NUM_HIDDEN2, NUM_HIDDEN3, NUM_OUTPUT):
        super(DropMLP, self).__init__()
        # 隐藏层
        self.hidden1 = nn.Linear(NUM_INPUT, NUM_HIDDEN1)
        # 使用nn.Dropout来使用丢弃法
        self.dropout1 = nn.Dropout(0.3)
        self.hidden2 = nn.Linear(NUM_HIDDEN1, NUM_HIDDEN2)
        self.dropout2 = nn.Dropout(0.4)
        self.hidden3 = nn.Linear(NUM_HIDDEN2, NUM_HIDDEN3)
        self.dropout3 = nn.Dropout(0.5)
        # 输出层
        self.output = nn.Linear(NUM_HIDDEN3, NUM_OUTPUT)
    
    def forward(self, x):
        # 和之前的操作基本一样,这里用的三个隐藏层,还有三个Dropout
        hidden1 = self.dropout1(F.relu(self.hidden1(x.view(x.shape[0], -1))))
        hidden2 = self.dropout2(F.relu(self.hidden2(hidden1)))
        hidden3 = self.dropout3(F.relu(self.hidden3(hidden2)))
        output = self.output(hidden3)
        return output
net = DropMLP(NUM_INPUT, NUM_HIDDEN1, NUM_HIDDEN2, NUM_HIDDEN3, NUM_OUTPUT)
net.cuda()

loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(net.parameters(), lr)

# 把下面两个函数更新到pytorch_tools.py文件里面
import torch
# 评估测试集准确度代码
def accuracy(data_iter, net):
    # 这里要加上net.eval(),表示是验证模型,而不是训练,就不会使用Dropout层了
    net.eval()
    acc_num = 0
    total_num = 0
    for X, y in data_iter:
        X = X.cuda()
        y = y.cuda()
        y_pred = net(X)
        acc_num += (y_pred.argmax(dim=1) == y).float().sum().item()
        total_num += y.shape[0]
    return acc_num / total_num

# 训练模型代码
def train(net, train_iter, num_epoch, loss_fn, optimizer, test_iter=None):
    for epoch in range(num_epoch):
        # 这里要加上net.train(),表示是训练模型
        net.train()
        train_loss, train_acc, total_num = 0, 0, 0
        for X, y in train_iter:
            X = X.cuda()
            y = y.cuda()
            y_pred = net(X)
            loss = loss_fn(y_pred, y)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            train_loss += loss.item()
            train_acc += (y_pred.argmax(dim=1) == y).float().sum().item()
            total_num += y.shape[0]
        if test_iter is not None:
            test_acc = accuracy(test_iter, net)
            print('当前第{}轮 训练集:loss = {:.4f}, acc = {:.4f}  测试集:acc = {:.4f}'.format(epoch+1, train_loss/total_num, train_acc/total_num, test_acc))
        else:
            print('当前第{}轮 训练集:loss = {:.4f}, acc = {:.4f}'.format(epoch+1, train_loss/total_num, train_acc/total_num))
   
train(net, train_iter, NUM_EPOCH, loss_fn, optimizer, test_iter)

输出结果:
当前第1轮 训练集:loss = 0.0082, acc = 0.2195  测试集:acc = 0.4906
当前第2轮 训练集:loss = 0.0050, acc = 0.4967  测试集:acc = 0.5980
当前第3轮 训练集:loss = 0.0039, acc = 0.6124  测试集:acc = 0.6909
当前第4轮 训练集:loss = 0.0034, acc = 0.6673  测试集:acc = 0.7227
当前第5轮 训练集:loss = 0.0031, acc = 0.7022  测试集:acc = 0.7533
当前第6轮 训练集:loss = 0.0029, acc = 0.7279  测试集:acc = 0.7724
当前第7轮 训练集:loss = 0.0027, acc = 0.7476  测试集:acc = 0.7814
当前第8轮 训练集:loss = 0.0025, acc = 0.7630  测试集:acc = 0.7892
当前第9轮 训练集:loss = 0.0024, acc = 0.7726  测试集:acc = 0.8024
当前第10轮 训练集:loss = 0.0023, acc = 0.7838  测试集:acc = 0.8174
当前第11轮 训练集:loss = 0.0023, acc = 0.7948  测试集:acc = 0.8248
当前第12轮 训练集:loss = 0.0022, acc = 0.8031  测试集:acc = 0.8310
当前第13轮 训练集:loss = 0.0021, acc = 0.8097  测试集:acc = 0.8365
当前第14轮 训练集:loss = 0.0020, acc = 0.8163  测试集:acc = 0.8401
当前第15轮 训练集:loss = 0.0020, acc = 0.8222  测试集:acc = 0.8464
当前第16轮 训练集:loss = 0.0020, acc = 0.8266  测试集:acc = 0.8497
当前第17轮 训练集:loss = 0.0019, acc = 0.8290  测试集:acc = 0.8534
当前第18轮 训练集:loss = 0.0019, acc = 0.8331  测试集:acc = 0.8549
当前第19轮 训练集:loss = 0.0018, acc = 0.8376  测试集:acc = 0.8578
当前第20轮 训练集:loss = 0.0018, acc = 0.8396  测试集:acc = 0.8605
当前第21轮 训练集:loss = 0.0018, acc = 0.8440  测试集:acc = 0.8639
当前第22轮 训练集:loss = 0.0017, acc = 0.8482  测试集:acc = 0.8661
当前第23轮 训练集:loss = 0.0017, acc = 0.8477  测试集:acc = 0.8674
当前第24轮 训练集:loss = 0.0017, acc = 0.8514  测试集:acc = 0.8701
当前第25轮 训练集:loss = 0.0017, acc = 0.8545  测试集:acc = 0.8724
当前第26轮 训练集:loss = 0.0016, acc = 0.8561  测试集:acc = 0.8725
当前第27轮 训练集:loss = 0.0016, acc = 0.8580  测试集:acc = 0.8763
当前第28轮 训练集:loss = 0.0016, acc = 0.8593  测试集:acc = 0.8782
当前第29轮 训练集:loss = 0.0016, acc = 0.8606  测试集:acc = 0.8803
当前第30轮 训练集:loss = 0.0016, acc = 0.8609  测试集:acc = 0.8817

 可以看到,最后测试集的准确率已经达到了0.88,比之前的模型都要高。并且训练集的准确率有0.86,也说明了在加了dropout以后,模型没有发生过拟合的现象。

5 总结

解决过拟合的两种方法:

1.权重衰减

  • 正则化通过为模型损失函数添加惩罚项使学出的模型参数值较小,是应对过拟合的常用手段,权重衰减等价于L2范数正则化,常会使学到的权重参数的元素较接近0。
  • 权重衰减可以通过优化器中的weight_decay超参数来指定。
  • 可以定义多个优化器实例对不同的模型参数使用不同的迭代方法。

2.丢弃法

  • 我们可以通过使用丢弃法应对过拟合。
  • 丢弃法只在训练模型时使用。