官方学习文档:Datawhale
往期task01链接:https://mp.csdn.net/mp_blog/creation/editor/141565076
李宏毅老师对应视频课程可搭配食用:https://www.bilibili.com/video/BV1JA411c7VT/?p=7
目录
深度学习基础
自适应学习率
临界点其实不一定是在训练一个网络的时候会遇到的最大的障碍。图 3.18 中的横坐标代表参数更新的次数,竖坐标表示损失。一般在训练一个网络的时候,损失原来很大,随着参数不断的更新,损失会越来越小,最后就卡住了,不再下降。按理说当我们走到临界点的时候,意味着梯度非常小,但实际上有时损失不再下降的时候,梯度并没有真的变得很小,图 3.19 给出了示例。图中横轴是迭代次数,竖轴是梯度的范数(norm),即梯度这个向量的长度(若梯度为0,那么对应的向量长度也应该为0)。
如图所示,随着迭代次数增多,虽然损失不再下降,但是梯度的范数并没有真的变得很小,这是为什么呢?
图 3.20 是对应的误差表面,梯度在山谷的两个谷壁间,不断地来回“震荡”,这个时候损失不会再下降,但它不是真的卡到了临界点(鞍点或局部最小值),它的梯度仍然很大。
所以训练一个网络,训练到后来发现损失不再下降的时候,有时候不是卡在局部最小值或鞍点,只是单纯的损失无法再下降。
我们现在训练一个网络,训练到参数在临界点附近,再根据特征值的正负号判断该临界点是鞍点还是局部最小值。但实际上在训练的时候,要走到鞍点或局部最小值,是一件困难的事情。一般的梯度下降训练,往往会在梯度还很大的时候,损失就已经降了下去,这个是需要特别方法训练的。要走到一个临界点其实是比较困难的,多数情况训练在还没有走到临界点的时候就已经停止了。
举个例子,我们有两个参数 w 和 b,这两个参数对应坐标系上的点不一样时,损失值也不一样,得到了图 3.21 所示的误差表面,该误差表面的最低点在叉号处。事实上,该误差表面是凸的形状。凸的误差表面的等高线是椭圆形的,椭圆的长轴非常长,短轴相比之下比较短,其在横轴的方向梯度非常小,坡度的变化非常小,非常平坦;其在纵轴的方向梯度变化非常大,误差表面的坡度非常陡峭。现在我们要从黑点(初始点)来做梯度下降。
在这里,等高线表示将具有相同误差值的所有点连起来所组成的线。
长轴(Major Axis):是椭圆中最长的直径,通过椭圆中心并且两端位于椭圆上的这条线段。长轴的长度通常用2a来表示,其中a是从中心到椭圆边缘的最大距离,也就是半长轴的长度。
短轴(Minor Axis):是垂直于长轴的直径,并且也是通过椭圆中心的。短轴的长度通常用2b来表示,其中b是从中心到椭圆边缘沿垂直于长轴方向的距离,即半短轴的长度。
学习率 η = 的结果如图 3.22(a) 所示。参数从黑点(初始点)开始在山壁的两端不断地“震荡”,损失降不下去,但是梯度仍然很大。我们可以试着把学习率设小一点,学习率决定了更新参数的时候的步伐,学习率设太大,步伐太大就无法慢慢地滑到山谷里面。把学习率从 调到 的结果如图 3.22(b) 所示,参数不再“震荡”了。参数会滑到山谷底后左转,但是这个训练永远走不到终点,因为学习率已经太小了。AB 段的坡度很陡,梯度的值很大,还能够前进一点,左拐后BC 段的坡度太平坦了,这种小的学习率无法再让训练前进。事实上在 BC 段有 10 万个点(10 万次更新),更新次数很多,但都无法靠近局部最小值。所以显然就算是一个凸的误差表面(比较简单),梯度下降也很难训练。
最原始的梯度下降连简单的误差表面都做不好,因此需要更好的梯度下降的版本。在原始的梯度下降里面,所有的参数都是设同样的学习率,这显然是不够的,应该要为每一个参数定制化学习率,即引入自适应学习率(adaptive learning rate)的方法,给每一个参数不同的学习率。如图 3.23 所示,如果在某一个方向上,梯度的值很小,非常平坦,我们会希望学习率调大一点;如果在某一个方向上非常陡峭,坡度很大,我们会希望学习率可以设得小一点。
AdaGrad
AdaGrad(Adaptive Gradient)是典型的自适应学习率方法,其能够根据梯度大小自动调整学习率。它可以做到梯度比较大的时候,学习率就减小,梯度比较小的时候,学习率就放大。
梯度下降更新某个参数 θit 的过程为:
梯度 代表在第 t 个迭代,即 θ = θt 时, 参数 θi 对损失 L 的微分。学习率η是固定的。
现在要有一个随着参数定制化的学习率,即把原来的式子变为:
σit 的上标为 i,这代表参数 σ 与 i 相关,不同的参数的 σ 不同。σit 的下标为 t,这代表参数 σ 与迭代相关,不同的迭代也会有不同的 σ。式子改成如上表达形式的时候,学习率就变得参数相关(parameter dependent)。
参数相关的一个常见的类型是算梯度的均方根(root mean square)。参数一开始的更新过程为:
其中 θi0 是初始化参数。而 σi0 的计算过程为:
将 σi0 的值代入更新的公式可知第一次在更新参数,从 θi0 更新到 θi1 时,要么是加上 η,要么是减掉 η,跟梯度的大小无关,这个是第一步的情况。
第二次更新参数过程为:
其中 σi1 是过去所有计算出来的梯度的平方的平均再开根号,即均方根,如下式所示:
同样的操作反复继续下去,第 t + 1 次更新参数的时候,即:
图 3.24 中有两个参数:θ1 和 θ2。θ1 坡度小,θ2 坡度大。因为 θ1 坡度小,θi1 这个参数上面算出来的梯度值都比较小,根据上式 ,算出来的 σit 就小,σit 小学习率就大。反过来,θ2坡度大,所以计算出的梯度都比较大,σit 就比较大,在更新的时候,步伐(参数更新的量)就比较小。
因此有了 σit 这一项以后,就可以随着梯度的不同,每一个参数的梯度的不同,来自动调整学习率的大小(实现自适应功能)。
RMSProp
上面的方法做到了根据不同参数自动调整对应的学习率,但来同一个参数需要的学习率,也会随着时间而改变。在图 3.25 中的误差表面中,如果考虑横轴方向,绿色箭头处坡度比较陡峭,需要较小的学习率,但是走到红色箭头处,坡度变得平坦了起来,需要较大的学习率。因此同一个参数的同个方向,学习率也是需要动态调整的,于是就有了一个新的方法———RMSprop(Root Mean Squared propagation)。
RMSprop 没有论文,Geoffrey Hinton 在 Coursera 上开过深度学习的课程,他在他的课程里面讲了 RMSprop,如果要引用,可以引用对应视频的链接。
RMSprop 第一步跟 Adagrad 的方法是相同的,即:
但第二步就不同了:
其中 0 < α < 1,其是一个可以调整的超参数(自己设定),一定程度上表示对应参数的重要性,越重要越接近1。
计算 θi1 的方法跟 AdaGrad 算均方根不一样,在算均方根的时候,每一个梯度都有同等的重要性,但在 RMSprop 里面,可以自己调整现在的这个梯度的重要性。如果 α 设很小趋近于 0,代表 gi1 相较于之前算出来的梯度而言,比较重要;如果 α 设很大趋近于 1,代表 gi1 比较不重要,之前算出来的梯度比较重要。
同样的过程就这样反复继续下去:
RMSProp 通过 α 可以决定,相较于之前存在 σit−1 里面的 , , · · · · · · , 的重要性有多大。如果使用 RMSprop,就可以动态调整 σit 这一项。
图 3.26 中黑线是误差表面,球就从 A 走到 B,AB 段的路很平坦,g 很小,更新参数的时候,我们会走比较大的步伐。走到BC 段后梯度变大了,AdaGrad 反应比较慢,而 RMSprop 会把 α 设小一点,让新的、刚看到的梯度的影响比较大,很快地让 σit 的值变大,很快地让步伐变小,RMSprop 可以很快地“踩刹车”。如果走到 CD 段,CD 段是平坦的地方,可以调整 α,让其比较看重最近算出来的梯度,梯度一变小,σit 的值就变小了,走的步伐就变大了。
个中奇妙,希望各位看官细细体会。
Adam
最常用的优化的策略或者优化器(optimizer)是Adam(Adaptive moment estimation)。Adam 可以看作 RMSprop 加上动量,其使用动量作为参数更新方向,并且能够自适应调整学习率。
PyTorch 里面已经写好了 Adam 优化器,这个优化器里面有一些超参数需要人为决定,但是往往用 PyTorch 预设的参数就足够好了。
学习率调度
一开始简单的凸误差表面,我们都训练不起来,之后加上自适应学习率使用AdaGrad 方法优化的结果如图 3.27 所示。一开始优化的时候很顺利,在左转的时候,有 AdaGrad 以后,可以再继续走下去,走到非常接近终点的位置。走到 BC 段时,因为横轴方向的梯度很小,所以学习率会自动变大,步伐就可以变大,从而不断前进。接下来的问题走到图 3.27中红圈的地方,快走到终点的时候突然“爆炸”了。
“爆炸”是因为在接近终点时,由于梯度非常小,AdaGrad算法累积了大量的历史梯度平方和,导致学习率变得非常小,从而使得优化过程变得非常缓慢,甚至停滞不前。
但有办法修正回来。如果有足够的时间,因为步伐很大,其会走到梯度比较大的地方。走到梯度比较大的地方后,σit 会慢慢变大,更新的步伐大小会慢慢变小,从而回到原来的路线。
通过学习率调度(learning rate scheduling)可以解决“爆炸”这个问题。之前的学习率调整方法中 η 是一个固定的值,而在学习率调度中 η 跟时间有关,如式所示。
学习率调度中最常见的策略是学习率衰减(learning rate decay),也称为学习率退火(learning rateannealing)。随着参数的不断更新,让 η 越来越小,如图 3.28 所示。
图 3.22b 的情况,如果加上学习率下降,可以很平顺地走到终点,如图 3.29 所示。在图 3.27 红圈的地方,虽然步伐很大,但 η 变得非常小,步伐乘上 η 就变小了,就可以慢慢地走到终点。
除了学习率下降以外,还有另外一个经典的学习率调度的方式———预热。预热的方法是让学习率先变大后变小,至于变到多大、变大的速度、变小的速度都是超参数。
残差网络里面是有预热的,在残差网络里面,学习率先设置成 0.01,再设置成 0.1,并且其论文还特别说明,一开始用 0.1 反而训练不好。除了残差网络,BERT 和 Transformer 的训练也都使用了预热。
Q:为什么需要预热?
A:当我们使用 Adam、RMSprop 或 AdaGrad 时,需要计算 σ。而 σ 是一个统计的结果。从 σ 可知某一个方向的陡峭程度。统计的结果需要足够多的数据才精准,一开始统计结果 σ 是不精准的。一开始学习率比较小是用来探索收集一些有关误差表面的情报,先收集有关 σ 的统计数据,等 σ 统计得比较精准以后,再让学习率慢慢爬升。如果读者想要学更多有关预热的东西可参考 Adam 的进阶版———RAdam。
优化总结
所以我们从最原始的梯度下降,进化到这一个版本,如式所示:
其中 是动量。
这个版本里面有动量,其不是顺着某个时刻算出的梯度方向来更新参数,而是把过去所有算出梯度的方向做一个加权总和当作更新的方向。接下来的步伐大小为 /σit。最后通过 ηt来实现学习率调度。这个是目前优化的完整的版本,这种优化器除了 Adam 以外,还有各种变形。但其实各种变形是使用不同的方式来计算 或 σit,或者是使用不同的学习率调度的方式。
Q:动量 考虑了过去所有的梯度,均方根 σit 也考虑了过去所有的梯度,一个放在分子,一个放在分母,并且它们都考虑过去所有的梯度,不就是正好抵消了吗?
A: 和 σit 在使用过去所有梯度的方式是不一样的,动量是直接把所有的梯度都加起来,所以它有考虑方向,它有考虑梯度的正负。但是均方根不考虑梯度的方向,只考虑梯度的大小,计算 σit 的时候,都要把梯度取一个平方项,把平方的结果加起来,所以只考虑梯度的大小,不考虑它的方向,所以动量跟 σit 计算出来的结果并不会互相抵消。
分类
分类与回归是深度学习最常见的两种问题,之前的观看次数预测属于回归问题,本节将介绍分类问题。
分类与回归的关系
回归是输入一个向量 x,输出 yˆ,我们希望 yˆ 跟某一个标签 y 越接近越好,y 是要学习的目标。而分类也可以当作回归来看,输入 x 后,输出仍然是一个标量 yˆ,要让它跟正确答案的那个类越接近越好。yˆ 是一个数字,我们可以把类也变成数字。如图 3.30 所示,类 1 是编号1,类 2 是编号 2,类 3 是编号 3,yˆ 跟类的编号越接近越好。
但该方法在某些状况下会有问题,假设类 1、2、3 有某种关系。比如根据一个人的身高跟体重,预测他的年级,一年级、二年级还是三年级。一年级跟二年级关系比较近,一年级跟三年级关系比较远。用数字来表示类会预设 1 和 2 有比较近的关系,1 和 3 有比较远的关系。
但假设三个类本身没有特定的关系,类 1 是 1,类 2 是 2, 类 3 是 3。这种情况,需要引入独热向量来表示类。实际上,在做分类的问题的时候,比较常见的做法也是用独热向量表示类。
因为有三个类,标签 y 就是一个三维的向量,比如类 1 是 ,类 2 是 ,类3 是。如果每个类都用一个独热向量来表示,就没有类 1 跟类 2 比较接近,类 1 跟类 3 比较远的问题,类两两之间的距离都是一样的。
如果目标 y 是一个向量,比如 y 是有三个元素的向量,网络也要输出三个数字才行。如图 3.31 所示,输出三个数值就是把本来输出一个数值的方法,重复三次。把 a1、a2 和 a3 乘上三个不同的权重,加上偏置,得到 yˆ1;再把 a1、a2 和 a3 乘上另外三个权重,再加上另外一个偏置得到 yˆ2;把 a1、a2 和 a3 再乘上另外一组权重,再加上另外一个偏置得到 yˆ3。输入一个特征向量, yˆ1、yˆ2、yˆ3即为所得,希望 yˆ1、yˆ2、yˆ3 跟目标越接近越好。
带有 softmax 的分类
按照上述的设定,分类实际过程是:输入 x,乘上 W,加上 b,通过激活函数 σ,乘上W′,再加上 b′ 得到向量 yˆ。但实际做分类的时候,往往会把 yˆ 通过 softmax 函数得到 y′,才去计算 y′ 跟 yˆ 之间的距离。
Q:为什么分类过程中要加上 softmax 函数?
A:一个比较简单的解释是,y 是独热向量,所以其里面的值只有 0 跟 1,但是 yˆ 里面有任何值。既然目标只有 0 跟 1,但 yˆ 有任何值,可以先把它归一化到 0 到 1 之间,这样才能跟标签的计算相似度。
softmax 的计算如下式所示,先把所有的 y 取一个指数(自然指数函数 )(负数取指数后也会变成正的),再对其做归一化(除掉所有 y 的指数值的和)得到 y′(得到占比,相加和为1)。图 3.33 是 softmax 的块(block),输入 y1、y2 和 y3,产生 y′1、y′2 和 y′3。比如 y1 = 3,y2 = 1,y3 = −3,取完指数的时候,exp(3) = 20、exp(1) = 2.7 和 exp(−3) = 0.05,做完归一化后,就变成 0.88、0.12 跟 0。−3取完指数,再做归一化以后,会变成趋近于 0 的值。所以 softmax 除了归一化,让 y′1、y′2 和y′3,变成 0 到 1 之间,和为 1 以外,它还会让大的值跟小的值的差距更大。
图 3.33 考虑了三个类的状况,两个类也可以直接套 softmax 函数。但一般有两个类的时候,我们不套 softmax,而是直接取 sigmoid。当只有两个类的时候,sigmoid 和 softmax 是等价的。
sigmoid 函数也是一个常见的神经网络激活函数,但它是针对二分类任务的。sigmoid 函数的计算公式如下:
sigmoid 函数将输入映射到 (0, 1) 区间内,输出值介于 0 到 1 之间,可以作为概率估计。当只有两个类别时,我们可以使用 sigmoid 函数代替 softmax 函数,因为在这种情况下,softmax 可以简化为 sigmoid。但是,当有多于两个类别时,我们需要使用 softmax 来得到各个类别的概率分布。
分类损失
当我们把 x 输入到一个网络里面产生 yˆ 后,通过 softmax 得到 y′,再去计算 y′ 跟 y 之间的距离 e,如图 3.34 所示。
计算 y′ 跟 y 之间的距离不只一种做法,可以是如下式所示的均方误差,即把 y 里面每一个元素拿出来,计算它们差的平方和当作误差。
但如下式所示的交叉熵更常用,当 yˆ 跟 y′ 相同时,可以最小化交叉熵的值,此时均方误差也是最小的。最小化交叉熵其实就是最大化似然(maximize likelihood)。
接下来从优化的角度来说明相较于均方误差,交叉熵是被更常用在分类上。如图 3.35 所示,有一个三类的分类,网络先输出 y1、y2 和 y3,在通过 softmax 以后,产生 y′1、y′2 和 y′3。假设正确答案是,要计算 跟 y′1、y′2 和 y′3 之间的距离 e,e 可以是均方误差或交叉熵。假设 y1 的变化是从-10 到 10,y2 的变化也是从-10 到 10,y3 就固定设成-1000。因为 y3 的值很小,通过 softmax 以后,y′3 非常趋近于 0,它跟正确答案非常接近,且它对结果影响很少。总之,我们假设 y3 设一个定值,只看 y1 跟 y2 有变化的时候,对损失 e 的影响。
图 3.36 是分别在 e 为均方误差和交叉熵时,y1、y2 的变化对损失的影响,对误差表面的影响,红色代表损失大,蓝色代表损失小。如果 y1 很大,y2 很小,代表 y′1 会很接近 1,y′2会很接近 0。所以不管 e 取均方误差或交叉熵,如果 y1 大、y2 小,损失都是小的;如果 y1小,y2 大,y′1 是 0,y′2 是 1,这个时候损失会比较大。
图 3.36 中左上角损失大,右下角损失小,所以期待最后在训练的时候,参数可以“走”到右下角的地方。假设参数优化开始时,对应的损失都是左上角。如果选择交叉熵,如图 3.36(a) 所示,左上角圆圈所在的点有斜率的,所以可以通过梯度,一路往右下的地方“走”;如果选均方误差,如图 3.36(b) 所示,左上角圆圈就卡住了,均方误差在这种损失很大的地方,它是非常平坦的,其梯度是非常小趋近于 0 的。如果初始时在圆圈的位置,离目标非常远,其梯度又很小,就无法用梯度下降顺利地“走”到右下角。
因此做分类时,选均方误差的时候,如果没有好的优化器,有非常大的可能性会训练不起来。如果用 Adam,虽然图 3.36(b) 中圆圈的梯度很小,但 Adam 会自动调大学习率,还有机会走到右下角,不过训练的过程比较困难。总之,改变损失函数可以改变优化的难度。
实践任务:HW3(CNN)卷积神经网络-图像分类
速通Homework3
Homework3的内容是通过利用卷积神经网络架构,通过一个较小的10种食物的图像的数据集训练一个模型完成图像分类的任务。
步骤一:准备算力
事先需要在支付宝进行学生认证
等待一段时间环境准备好后进入实例
步骤二:一键运行Notebook
1.获得数据集和代码文件
进入实例后点击进入JupyterLab。接着,点击“Terminal”打开命令行窗口,输入以下代码并按下回车键。稍等片刻,数据集和代码文件(notebook)将会自动下载,大约需要一分钟。
git lfs install
git clone https://www.modelscope.cn/datasets/Datawhale/LeeDL-HW3-CNN.git
2.一键运行代码
点击打开下载好的LeeDL-HW3-CNN
文件夹
点击HW3-ImageClassification.ipynb
等待约12分钟后即可获得结果。通过单元格(cell)查看模型的训练准确率。生成的`submission.csv`文件包含分类结果,可提交至Kaggle进行评估。
代码将打印出当前模型的结构,并绘制两个数据集的图像分析和分类结果分布图,以帮助更好地理解模型的表现。
步骤三:运行完成后记得停止实例
一定要记得【停止实例】,否则会一直扣费!
下次需要时,可以重新启动
代码详解
卷积神经网络是深度学习中的一个非常重要的分支,本作业提供了进行图像分类任务的基本范式。
-
准备数据
-
训练模型
-
应用模型
要完成一个深度神经网络训练模型的代码,大概需要完成下面的内容:
-
导入所需要的库/工具包
-
数据准备与预处理
-
定义模型
-
定义损失函数和优化器等其他配置
-
训练模型
-
评估模型
-
进行预测
此范式不仅适用于图像分类任务,对于广泛的深度学习任务也是适用的
1. 导入所需要的库/工具包
这段代码导入了进行图像处理和深度学习任务所需的各种Python库和模块,涵盖了数据处理、神经网络构建、数据集操作、图像转换和显示进度条等功能,为后续的模型训练和评估做好准备。
# 导入必要的库
import numpy as np
import pandas as pd
import torch
import os
import torch.nn as nn
import torchvision.transforms as transforms
from PIL import Image
# “ConcatDataset” 和 “Subset” 在进行半监督学习时可能是有用的。
from torch.utils.data import ConcatDataset, DataLoader, Subset, Dataset
from torchvision.datasets import DatasetFolder, VisionDataset
# 这个是用来显示进度条的。
from tqdm.auto import tqdm
import random
此外,为了确保实验的可重复性,设置随机种子,并对CUDA进行配置以确保确定性:
# 设置随机种子以确保实验结果的可重复性
myseed = 6666
# 确保在使用CUDA时,卷积运算具有确定性,以增强实验结果的可重复性
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
# 为numpy和pytorch设置随机种子
np.random.seed(myseed)
torch.manual_seed(myseed)
# 如果使用CUDA,为所有GPU设置随机种子
if torch.cuda.is_available():
torch.cuda.manual_seed_all(myseed)
2. 数据准备与预处理
数据准备包括从指定路径加载图像数据,并对其进行预处理。作业中对图像的预处理操作包括调整大小和将图像转换为Tensor格式。为了增强模型的鲁棒性,可以对训练集进行数据增强。相关代码如下:
# 在测试和验证阶段,通常不需要图像增强。
# 我们所需要的只是调整PIL图像的大小并将其转换为Tensor。
test_tfm = transforms.Compose([
transforms.Resize((128, 128)),
transforms.ToTensor(),
])
# 不过,在测试阶段使用图像增强也是有可能的。
# 你可以使用train_tfm生成多种图像,然后使用集成方法进行测试。
train_tfm = transforms.Compose([
# 将图像调整为固定大小(高度和宽度均为128)
transforms.Resize((128, 128)),
# TODO:你可以在这里添加一些图像增强的操作。
# ToTensor()应该是所有变换中的最后一个。
transforms.ToTensor(),
])
class FoodDataset(Dataset):
"""
用于加载食品图像数据集的类。
该类继承自Dataset,提供了对食品图像数据集的加载和预处理功能。
它可以自动从指定路径加载所有的jpg图像,并对这些图像应用给定的变换。
"""
def __init__(self, path, tfm=test_tfm, files=None):
"""
初始化FoodDataset实例。
参数:
- path: 图像数据所在的目录路径。
- tfm: 应用于图像的变换方法(默认为测试变换)。
- files: 可选参数,用于直接指定图像文件的路径列表(默认为None)。
"""
super(FoodDataset).__init__()
self.path = path
# 列出目录下所有jpg文件,并按顺序排序
self.files = sorted([os.path.join(path, x) for x in os.listdir(path) if x.endswith(".jpg")])
if files is not None:
self.files = files # 如果提供了文件列表,则使用该列表
self.transform = tfm # 图像变换方法
def __len__(self):
"""
返回数据集中图像的数量。
返回:
- 数据集中的图像数量。
"""
return len(self.files)
def __getitem__(self, idx):
"""
获取给定索引的图像及其标签。
参数:
- idx: 图像在数据集中的索引。
返回:
- im: 应用了变换后的图像。
- label: 图像对应的标签(如果可用)。
"""
fname = self.files[idx]
im = Image.open(fname)
im = self.transform(im) # 应用图像变换
# 尝试从文件名中提取标签
try:
label = int(fname.split("/")[-1].split("_")[0])
except:
label = -1 # 如果无法提取标签,则设置为-1(测试数据无标签)
return im, label
# 构建训练和验证数据集
# "loader" 参数定义了torchvision如何读取数据
train_set = FoodDataset("./hw3_data/train", tfm=train_tfm)
# 创建训练数据加载器,设置批量大小、是否打乱数据顺序、是否使用多线程加载以及是否固定内存地址
train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=0, pin_memory=True)
# 构建验证数据集
# "loader" 参数定义了torchvision如何读取数据
valid_set = FoodDataset("./hw3_data/valid", tfm=test_tfm)
# 创建验证数据加载器,设置批量大小、是否打乱数据顺序、是否使用多线程加载以及是否固定内存地址
valid_loader = DataLoader(valid_set, batch_size=batch_size, shuffle=True, num_workers=0, pin_memory=True)
3. 定义模型
这段代码定义了一个图像分类器类(Classifier
),继承自PyTorch的nn.Module
。该分类器通过一系列卷积层、批归一化层、激活函数和池化层构建卷积神经网络(CNN),用于提取图像特征。随后,这些特征被输入到全连接层进行分类,最终输出11个类别的概率,用于图像分类任务。
class Classifier(nn.Module):
"""
定义一个图像分类器类,继承自PyTorch的nn.Module。
该分类器包含卷积层和全连接层,用于对图像进行分类。
"""
def __init__(self):
"""
初始化函数,构建卷积神经网络的结构。
包含一系列的卷积层、批归一化层、激活函数和池化层。
"""
super(Classifier, self).__init__()
# 定义卷积神经网络的序列结构
self.cnn = nn.Sequential(
nn.Conv2d(3, 64, 3, 1, 1), # 输入通道3,输出通道64,卷积核大小3,步长1,填充1
nn.BatchNorm2d(64), # 批归一化,作用于64个通道
nn.ReLU(), # ReLU激活函数
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0
nn.Conv2d(64, 128, 3, 1, 1), # 输入通道64,输出通道128,卷积核大小3,步长1,填充1
nn.BatchNorm2d(128), # 批归一化,作用于128个通道
nn.ReLU(),
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0
nn.Conv2d(128, 256, 3, 1, 1), # 输入通道128,输出通道256,卷积核大小3,步长1,填充1
nn.BatchNorm2d(256), # 批归一化,作用于256个通道
nn.ReLU(),
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0
nn.Conv2d(256, 512, 3, 1, 1), # 输入通道256,输出通道512,卷积核大小3,步长1,填充1
nn.BatchNorm2d(512), # 批归一化,作用于512个通道
nn.ReLU(),
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0
nn.Conv2d(512, 512, 3, 1, 1), # 输入通道512,输出通道512,卷积核大小3,步长1,填充1
nn.BatchNorm2d(512), # 批归一化,作用于512个通道
nn.ReLU(),
nn.MaxPool2d(2, 2, 0), # 最大池化,池化窗口大小2,步长2,填充0
)
# 定义全连接神经网络的序列结构
self.fc = nn.Sequential(
nn.Linear(512*4*4, 1024), # 输入大小512*4*4,输出大小1024
nn.ReLU(),
nn.Linear(1024, 512), # 输入大小1024,输出大小512
nn.ReLU(),
nn.Linear(512, 11) # 输入大小512,输出大小11,最终输出11个类别的概率
)
def forward(self, x):
"""
前向传播函数,对输入进行处理。
参数:
x -- 输入的图像数据,形状为(batch_size, 3, 128, 128)
返回:
输出的分类结果,形状为(batch_size, 11)
"""
out = self.cnn(x) # 通过卷积神经网络处理输入
out = out.view(out.size()[0], -1) # 展平输出,以适配全连接层的输入要求
return self.fc(out) # 通过全连接神经网络得到最终输出
5. 训练模型
这段代码实现了一个图像分类模型的训练和验证循环,目的是通过多轮训练(epochs)逐步优化模型的参数,以提高其在验证集上的性能,并保存效果最好的模型。训练阶段通过前向传播、计算损失、反向传播和参数更新来优化模型,验证阶段评估模型在未见过的数据上的表现。如果验证集的准确率超过了之前的最好成绩,保存当前模型,并在连续多轮验证性能未提升时提前停止训练。
# 初始化追踪器,这些不是参数,不应该被更改
stale = 0
best_acc = 0
for epoch in range(n_epochs):
# ---------- 训练阶段 ----------
# 确保模型处于训练模式
model.train()
# 这些用于记录训练过程中的信息
train_loss = []
train_accs = []
for batch in tqdm(train_loader):
# 每个批次包含图像数据及其对应的标签
imgs, labels = batch
# imgs = imgs.half()
# print(imgs.shape,labels.shape)
# 前向传播数据。(确保数据和模型位于同一设备上)
logits = model(imgs.to(device))
# 计算交叉熵损失。
# 在计算交叉熵之前不需要应用softmax,因为它会自动完成。
loss = criterion(logits, labels.to(device))
# 清除上一步中参数中存储的梯度
optimizer.zero_grad()
# 计算参数的梯度
loss.backward()
# 为了稳定训练,限制梯度范数
grad_norm = nn.utils.clip_grad_norm_(model.parameters(), max_norm=10)
# 使用计算出的梯度更新参数
optimizer.step()
# 计算当前批次的准确率
acc = (logits.argmax(dim=-1) == labels.to(device)).float().mean()
# 记录损失和准确率
train_loss.append(loss.item())
train_accs.append(acc)
train_loss = sum(train_loss) / len(train_loss)
train_acc = sum(train_accs) / len(train_accs)
# 打印信息
print(f"[ 训练 | {epoch + 1:03d}/{n_epochs:03d} ] loss = {train_loss:.5f}, acc = {train_acc:.5f}")
6. 评估模型
训练完成后,需要在测试集上评估模型的性能。通过计算准确率来衡量模型在测试集上的表现。
# ---------- 验证阶段 ----------
# 确保模型处于评估模式,以便某些模块如dropout被禁用,模型能够正常工作
model.eval()
# 这些用于记录验证过程中的信息
valid_loss = []
valid_accs = []
# 按批次迭代验证集
for batch in tqdm(valid_loader):
# 每个批次包含图像数据及其对应的标签
imgs, labels = batch
# imgs = imgs.half()
# 我们在验证阶段不需要梯度。
# 使用 torch.no_grad() 加速前向传播过程。
with torch.no_grad():
logits = model(imgs.to(device))
# 我们仍然可以计算损失(但不计算梯度)。
loss = criterion(logits, labels.to(device))
# 计算当前批次的准确率
acc = (logits.argmax(dim=-1) == labels.to(device)).float().mean()
# 记录损失和准确率
valid_loss.append(loss.item())
valid_accs.append(acc)
# break
# 整个验证集的平均损失和准确率是所记录值的平均
valid_loss = sum(valid_loss) / len(valid_loss)
valid_acc = sum(valid_accs) / len(valid_accs)
# 打印信息
print(f"[ 验证 | {epoch + 1:03d}/{n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f}")
# 更新日志
if valid_acc > best_acc:
with open(f"./{_exp_name}_log.txt", "a"):
print(f"[ 验证 | {epoch + 1:03d}/{n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f} -> 最佳")
else:
with open(f"./{_exp_name}_log.txt", "a"):
print(f"[ 验证 | {epoch + 1:03d}/{n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f}")
# 李老师的课程原文件里面确实缺少write,如果想要log文件里面有内容,可以按照下面的参考,此部分不是重点
#if valid_acc > best_acc:
# with open(f"./{_exp_name}_log.txt", "a") as log_file:
# log_file.write(f"[ Valid | {epoch + 1:03d}/{n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f} -> best\n")
# print(f"[ Valid | {epoch + 1:03d}/{n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f} -> best")
#else:
# with open(f"./{_exp_name}_log.txt", "a") as log_file:
# log_file.write(f"[ Valid | {epoch + 1:03d}/{n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f}\n")
# print(f"[ Valid | {epoch + 1:03d}/{n_epochs:03d} ] loss = {valid_loss:.5f}, acc = {valid_acc:.5f}")
# 保存模型
if valid_acc > best_acc:
print(f"在第 {epoch} 轮找到最佳模型,正在保存模型")
torch.save(model.state_dict(), f"{_exp_name}_best.ckpt") # 只保存最佳模型以防止输出内存超出错误
best_acc = valid_acc
stale = 0
else:
stale += 1
if stale > patience:
print(f"连续 {patience} 轮没有改进,提前停止")
break
7. 进行预测
最后的代码构建一个测试数据集和数据加载器,以便高效地读取数据。实例化并加载预训练的分类器模型,并将其设置为评估模式。在不计算梯度的情况下,遍历测试数据,使用模型进行预测,并将预测标签存储在列表中。将预测结果与测试集的ID生成一个DataFrame,并将其保存为submission.csv
文件。
# 构建测试数据集
# "loader"参数指定了torchvision如何读取数据
test_set = FoodDataset("./hw3_data/test", tfm=test_tfm)
# 创建测试数据加载器,批量大小为batch_size,不打乱数据顺序,不使用多线程,启用pin_memory以提高数据加载效率
test_loader = DataLoader(test_set, batch_size=batch_size, shuffle=False, num_workers=0, pin_memory=True)
# 实例化分类器模型,并将其转移到指定的设备上
model_best = Classifier().to(device)
# 加载模型的最优状态字典
model_best.load_state_dict(torch.load(f"{_exp_name}_best.ckpt"))
# 将模型设置为评估模式
model_best.eval()
# 初始化一个空列表,用于存储所有预测标签
prediction = []
# 使用torch.no_grad()上下文管理器,禁用梯度计算
with torch.no_grad():
# 遍历测试数据加载器
for data, _ in tqdm(test_loader):
# 将数据转移到指定设备上,并获得模型的预测结果
test_pred = model_best(data.to(device))
# 选择具有最高分数的类别作为预测标签
test_label = np.argmax(test_pred.cpu().data.numpy(), axis=1)
# 将预测标签添加到结果列表中
prediction += test_label.squeeze().tolist()
# 创建测试csv文件
def pad4(i):
"""
将输入数字i转换为长度为4的字符串,如果长度不足4,则在前面补0。
:param i: 需要转换的数字
:return: 补0后的字符串
"""
return "0" * (4 - len(str(i))) + str(i)
# 创建一个空的DataFrame对象
df = pd.DataFrame()
# 使用列表推导式生成Id列,列表长度等于测试集的长度
df["Id"] = [pad4(i) for i in range(len(test_set))]
# 将预测结果赋值给Category列
df["Category"] = prediction
# 将DataFrame对象保存为submission.csv文件,不保存索引
df.to_csv("submission.csv", index=False)
有关优化方向的内容在完成task03后再进行补充。
感谢您的学习,让我们共同探索深度学习的奥秘!我们下期再见!