Featured image of post 实例学PyTorch(2):MNIST手写数字识别(二)——神经网络中的参数选择

实例学PyTorch(2):MNIST手写数字识别(二)——神经网络中的参数选择

实例学习PyTorch,MNIST手写数字识别神经网络中的参数选择和性能对比

背景

这是“实例学PyTorch”系列的第2篇文章。在上一篇文章“实例学PyTorch(1):MNIST手写数字识别(一)——PyTorch基础和神经网络基础”中,我们介绍了PyTorch的基本概念和使用方法,并使用一个简单的三层全连接神经网络实现了MNIST手写数字识别,这算是深度学习领域的“Hello World"。在这篇文章中,我们将简单讨论一下这个简单的三层神经网络中参数的选择,对比不同的参数选择对模型性能的影响。

本文的代码可以在我的GitHub仓库https://github.com/jin-li/pytorch-tutorial中的T02_mnist_cnn文件夹中找到。该代码是基于PyTorch官方的示例代码https://github.com/pytorch/examples得到的。

参数选择

基于上一篇文章中的三层全连接神经网络,我们可以对其中的参数进行调整来观察模型性能的变化。

机器学习模型中的参数主要可以分为两类:超参数和模型结构。超参数是在训练模型之前设置的参数,例如学习率、迭代次数、批次大小等。模型结构是指模型的网络结构,例如网络的层数、每层的神经元数、激活函数、损失函数、正则化方法等。

模型结构

我们在上一篇文章中使用的是一个简单的三层全连接神经网络,我们可以尝试增加或减少网络的层数、每层的神经元数、激活函数等来观察模型性能的变化。也可以尝试使用不同的损失函数和正则化方法。

  1. 神经网络的层数和每层的神经元数

    在机器学习中,神经网络的层数和每层的神经元数是非常重要的超参数。增加神经网络的层数和每层的神经元数可以增加模型的表达能力,但也会增加模型的复杂度,可能会导致过拟合。反之,减少神经网络的层数和每层的神经元数可以减少模型的复杂度,但也可能会导致模型的准确度下降。因此,我们需要在二者之间进行权衡。

    这里我们尝试几个不同的网络结构,例如分别使用1层、3层、5层隐藏层,每层的神经元数也分别尝试不同的值,例如64、128、256等。

  2. 激活函数

    激活函数是神经网络中非常重要的概念,它也是神经网络可以拟合各种模型的关键。我们可以这样理解激活函数:无论什么模型,其本质都是在做一些判断,根据不同的输入来判断输出该是什么,这样的判断可能是单次的,也可能是很多次判断的综合。激活函数就是将非线性因素引入神经元,为神经元提供一种判断能力。

    常见的激活函数有ReLU、Sigmoid、Tanh等。可以看到,不同的激活函数实际上是很不同的,但其本质都是为了引入一个非线性因素。这里我们也尝试一些不同的激活函数。

    激活函数

  3. 损失函数

    损失函数是用来衡量模型预测值与真实值之间的差异的函数,即评价模型好坏的指标。损失函数很重要,因为它决定了模型的优化方向。

    常见的损失函数有交叉熵损失函数、均方误差损失函数等。这里我们也尝试一些不同的损失函数。需要注意的是,均方差损失函数一般用于回归问题,交叉熵损失函数一般用于分类问题。但我们这里仍然可以尝试使用均方差损失函数。由于均方误差损失函数参数中的目标值(target)需要输入一个one-hot编码的向量,而原来的MNIST数据集中的标签是一个整数,因此我们需要对标签进行one-hot编码。本文对应的代码中对损失函数做了一次判断,如果损失函数是均方误差损失函数,则对标签进行one-hot编码。

    1
    2
    
    if loss_function == F.mse_loss:
        target = F.one_hot(target, num_classes=10).float()
    

    所谓one-hot编码就是将一个整数转换为一个向量,向量的长度等于类别的个数,其中只有一个元素为1,其余元素为0。例如,对于MNIST数据集,共有10个类别,我们可以将标签0转换为[1, 0, 0, 0, 0, 0, 0, 0, 0, 0],标签1转换为[0, 1, 0, 0, 0, 0, 0, 0, 0, 0],以此类推。

  4. 正则化方法

    正则化主要是用来防止过拟合的。为什么正则化可以防止过拟合呢?

    首先我们来分析过拟合的原因。过拟合是指模型在训练集上表现很好,但在测试集上表现很差。过拟合的原因是模型在训练集上学习到了训练集的噪声,导致模型在测试集上泛化能力很差。正则化就是将模型的参数加入到损失函数中,使得模型的参数尽量小,从而减少模型的复杂度,防止模型在训练集上学习到噪声。

    常见的正则化方法有L1正则化、L2正则化、Dropout等。这里我们也尝试一些不同的正则化方法。

超参数

超参数是在训练模型之前设置的参数,例如学习率、迭代次数、批次大小等。这些参数对模型的性能有很大的影响,我们需要仔细选择这些参数。

  1. 学习率

    学习率是模型在更新参数时的步长,它决定了模型参数的更新速度。学习率太小会导致模型收敛速度慢,学习率太大则可能导致模型不收敛。

  2. 迭代次数

    迭代次数是指模型在训练集上迭代的次数。迭代次数太少可能导致模型欠拟合,迭代次数太多可能导致模型过拟合。

  3. 批次大小

    我们在训练模型时通常会将训练集分成若干个批次,每个批次包含若干个样本,这样可以减少内存的占用,加快模型的训练速度。批次大小太小可能导致模型收敛速度慢,批次大小太大可能导致模型过拟合。

修改代码使参数可定制

在上一篇文章中,我们使用的模型结构是固定的,超参数可以通过命令行参数来指定。现在我们修改代码,把main函数独立出来,把这些参数全部作为main函数的参数。这样,我们可以再写一个脚本来调用main函数,传入不同的参数,从而实现不同的模型结构和超参数的选择。

这里我们就不贴完整的代码了,只给出修改后的main函数原型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def main(batch_size           = 64, 
         epochs               = 14, 
         lr                   = 1.0,
         test_batch_size      = 1000,  
         no_cuda              = False, 
         no_mps               = False, 
         dry_run              = False, 
         seed                 = 1, 
         log_interval         = 10, 
         save_model           = False, 
         hidden_layers        = [128], 
         activation_functions = [F.relu], 
         loss_function        = F.nll_loss, 
         regularizations      = [None]
         ):

这里前3个参数是超参数,后4个参数是模型结构的参数,中间几个参数是一些辅助性的参数。

最后,因为我们想要对比不同参数下的模型性能,我们让主函数返回训练中的损失值和准确率,这样我们就可以在调用主函数的脚本中绘图对比不同参数下的模型性能。为此我们定义一个存储训练性能的类:

1
2
3
4
5
6
7
8
class PerformanceMetrics:
    def __init__(self):
        self.train_count = []
        self.train_loss = []
        self.test_count = []
        self.test_loss = []
        self.test_accuracy = []
        self.run_time = 0.0

在训练过程中,我们将训练和测试的损失值和准确率存储到这个类中,最后由主函数返回。具体代码参见GitHub仓库中本文对应的代码。

性能对比

我们这里关注了3个超参数和4个模型参数,共7个参数,而每个参数都有多种选择。假设我们每个参数只选择3个值,那么总共有$3^7=2187$种组合,这可不是一个小数目!

正是由于参数组合多种多样,寻找一个最优的参数组合就像是通过不停的组合试错来寻找一个配方,因此很多人把机器学习形象地称为“炼丹”。的确,这跟古代炼丹术士的活儿很像。

按照我们在上一篇文章中测试的结果,每次训练用GPU需要约2分15秒,如果我们要测试2187种组合,那么总共需要$2187\times 2.25/60=82$小时,也就是约3天半的时间。虽然这并不是做不到,但这样的时间成本还是有点高。如果之后训练更复杂的模型,需要的时间会几倍、几十倍地增加,因此我们必须有所取舍,不能测试所有的参数组合。

在实际应用中,我们可以通过一些启发式的方法来选择参数,例如网格搜索、随机搜索、贝叶斯优化等。这些方法可以帮助我们更快地找到一个较优的参数组合。

这里我们采用控制变量的方法,将上一篇文章中的模型结构和超参数作为基准,每次只调整一个参数,观察模型性能的变化。这样可以更好地理解每个参数对模型性能的影响。具体代码参见GitHub仓库https://github.com/jin-li/pytorch-tutorialT02_mnist_parameters文件夹中的parametric_study.py文件。

每组参数训练完后,我们绘制出训练和测试的损失值和准确率:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
fig, axs = plt.subplots(2, 1, figsize=(10, 10))
fig.suptitle(f'Parameter Study: {param_names[idx]}')
axs[0].plot(metrics_ref.test_count, metrics_ref.test_loss, label=ref_labels[idx])
for i, metric in enumerate(metrics):
    axs[0].plot(metric.test_count, metric.test_loss, label=param_labels[idx][i])
axs[0].legend()
axs[0].set_title('Training Loss')
axs[0].set_xlabel('data count')
axs[0].set_ylabel('loss')

axs[1].plot(metrics_ref.test_count, metrics_ref.test_accuracy, label=ref_labels[idx])
for i, metric in enumerate(metrics):
    axs[1].plot(metric.test_count, metric.test_accuracy, label=param_labels[idx][i])
axs[1].legend()
axs[1].set_title('Test Accuracy')
axs[1].set_xlabel('data count')
axs[1].set_ylabel('accuracy')

plt.savefig(f'test{idx}_{param_names[idx]}.png')

这里需要的Python虚拟环境和上一篇文章中的一样,可以通过conda activate pytorch-mnist来激活虚拟环境。然后可以使用如下命令来运行所有的测试:

1
python parametric_study.py

模型结构参数

  1. 神经网络层数

    这里我尝试了1层、3层、5层隐藏层,中间层的神经元数目在64、128、256之间变化。下图是测试的结果:

    神经网络层数

    可见,1个隐藏层的模型性能已经很不错了,增加隐藏层的数目并没有明显提升模型性能。当然,这可能也与模型中的其他参数有关,也许增加隐藏层数目的同时,还需要调整神经网络的其他参数才能发挥隐藏层的作用。但从这个实验结果来看,由于本问题比较简单,1个隐藏层的模型已经足够。

  2. 激活函数

    这里我尝试了ReLU、Sigmoid、Tanh等激活函数。下图是测试的结果:

    激活函数

    可见,这三种激活函数的性能差不多,但ReLU的效果相对更好一点儿,Sigmoid和Tanh的模型性能相对差了一点儿。这也符合我们的预期,因为ReLU激活函数是目前最常用的激活函数,它的优点是计算简单,收敛速度快,不容易出现梯度消失的问题。

  3. 损失函数

    除了之前模型中使用的负对数似然损失函数nll_loss(),这里我尝试了交叉熵损失函数cross_entropy()和均方误差损失函数mse_loss()。下图是测试的结果:

    损失函数

    可见,交叉熵损失函数和负对数似然损失函数的表现差不多,性能比较好,均方误差损失函数的模型性能最差。这也符合我们的预期,因为均方误差损失函数一般用于回归问题,交叉熵损失函数一般用于分类问题。

  4. 正则化方法

    正则化方法比较特殊,在本文章的代码中使用正则化需要对代码做过多修改,这里我暂时没有测试正则化的效果,留到以后再来研究。

超参数

  1. 批次大小

    这里我尝试了16、64、256三个批次大小。下图是测试的结果:

    批次大小

    可见,批次大小为64和256时模型性能最好,批次大小为16时模型性能最差。这里我也统计了训练时间,批次大小为16时训练时间最长,批次大小为64和256时训练时间相对较短:

    1
    2
    3
    
    batch size 16:  164.6s
    batch size 64:  133.9s
    batch size 256:  125.0s
    

    在内存和计算资源允许的情况下,我们可以选择较大的批次大小,这样可以加快模型的训练速度。

  2. 迭代次数

    迭代次数是指模型在训练集上迭代的次数。这里我最多尝试了25次迭代。下图是测试的结果:

    迭代次数

    可见,随着迭代次数增加,损失值逐渐减小,准确率逐渐增加。但是,当迭代次数超过一定值之后,模型的性能不再提升,甚至可能出现过拟合。因此,我们需要根据模型的性能来选择合适的迭代次数。对于本问题,14次迭代已经足够。

  3. 学习率

    这里我尝试了0.1、1.0、10.0三个学习率。下图是测试的结果:

    学习率

    可见,学习率为1.0时模型性能最好,学习率为10时模型性能最差。这说明学习率过大,模型可能会发散;学习率过小,模型可能会收敛缓慢。因此,我们需要根据模型的性能来选择合适的学习率。对于本问题,学习率为1.0已经足够。

计算资源

上面的实验是在我的个人电脑上运行的,使用的是NVIDIA GeForce GTX 4060 Ti显卡。每个例子用的计算资源都差不多,基本不怎么消耗显卡资源,计算时显卡的占用率在5%左右,显存大约占用了180 MB,运行时间大约是2分15秒。如果使用CPU,运行时间大约是3分钟左右。

总结

在本文中,我们讨论了神经网络中的参数选择问题,包括模型结构和超参数。我们通过修改代码,使得模型结构和超参数可以通过命令行参数来指定,然后通过控制变量的方法,逐一调整每个参数,观察模型性能的变化。

由于计算资源有限,我们只测试了一部分参数组合,但这已经足够说明了参数选择对模型性能的影响。在实际应用中,我们可以通过一些启发式的方法来选择参数,例如网格搜索、随机搜索、贝叶斯优化等。这些方法可以帮助我们更快地找到一个较优的参数组合。我们将在后续文章中继续讨论这些方法。

感兴趣的读者可以利用GitHub仓库https://github.com/jin-li/pytorch-tutorial中本文对应的代码(在T02_mnist_cnn文件夹中),尝试不同的参数组合,观察模型性能的变化。

comments powered by Disqus