Datawhale X 李宏毅苹果书 AI夏令营【2024年第五期】:学习笔记

207 阅读20分钟

写在开头

第二次参加datawhale夏令营啦,之前体验了AIGC,这次是学习深度学习的理论知识,教程《深度学习详解》框架非常清晰,对我这样的入门者来说很友好!希望学有所获~

一、机器学习基础

1.1 任务类别

回归:函数输出一个数值/标量,数值预测(连续)

分类:从设定好的选项选择一个当作输出,类别判断(离散)

结构化学习:产生一个有结构的东西(画图、写作)

1.2 基本步骤

(1)写出函数 (2)定义损失 (3)解最优化问题

  • 误差表面:尝试不同参数得到不同损失,画出的等高线图

  • 超参数:自己设定的参数,不是机器找出来的

  • 梯度下降

    • η 是学习率
    • 停止条件: ① max_iteration ② 局部极小值

    b2b314acf6e98425304576f5f32c68c.jpg

1.3 激活函数

一堆 sigmoid or 两堆 ReLU(Rectified Linear Unit)【激活函数】

→ 逼近一堆 hard sigmoid ,再+ 一个偏置项

→ 合成分段线性曲线

→ 逼近实际的连续曲线关系

22e0588ed88382f56967c4ead17bc4f.jpg

  • 激活函数(神经元)的数量也是一个超参数,影响模型的灵活性(类似复杂度高不高)

1.4 过渡到深度学习

回归

以上讨论的都是回归问题。 cb838c4877a02dddd29c230787c7ecd.jpg

分类

one-hot 编码

避免用数字标号,出现1与2更相近、与3更相远的假象(其实只是三种平行的类别)。

网络架构

把回归中输出一个数值的方法,重复三次(WW^{'})。即:把a1、a2、a3乘上三个不同的权重,加上偏置,得到 y1^\hat{y_{1}};再分别乘上另外三个权重,加上另一个偏置,得到 y2^\hat{y_{2}};...

Softmax

通常不直接比较上述 yi^\hat{y_{i}}yiy_{i}之间的距离,而是用softmax转化为 yy\prime ,比较 yy\primeyiy_{i} 之间的距离。

4cf36ed12b8b42838cb2fc1b8b35382.jpg

  • 优点:① y是独热的0和1,softmax可以把 yi^\hat{y_{i}} 归一化到0~1之间,方便比较;② softmax可以让大的值跟小的值的差距更大。
  • 当只有两个类时,sigmoid和softmax等价。
损失函数

均方误差e=i(yiyi)2e = \sum_{i}(y_i-y_i\prime)^2

交叉熵(最小化交叉熵=最大化似然):e=iyilnyie = -\sum_{i}y_i\ln{y_i\prime}

  • 改变损失函数可以改变优化的难度。交叉熵通常比均方误差更好训练分类任务;选均方误差的时候最好用Adam优化器,避免优化失败。

二、训练中的问题

2.1 模型偏差

模型不够“大”,灵活性不够。

  • 解决方法
    • 增加更多的特征 Xi
    • 改用深度学习,增加神经元数量、隐藏层层数

2.2 优化问题

比如梯度下降的局部极小值。

  • 模型偏差 V.S. 优化问题

    找不到一个损失低的函数,到底是因为模型的灵活性不够,海里面没有针;还是模型的灵活性已经够了,只是优化梯度下降不给力,它没办法把针捞出来。

    • 解决方法:先跑较浅的模型,得到一个基准的loss,再调整模型深度,观察loss变化。

2.3 过拟合

  • 解决方法

    • 增加训练集。数据增强,例如在做图像识别时左右翻转图片,double数据量。
    • 给模型增加限制,降低灵活性。
      • 减少参数(CNN是一种比较没有灵活性的模型,感受野、滤波器、汇聚等都在施加约束)
      • 减少特征
      • 早停
      • 正则化
      • 丢弃法
  • 过拟合 V.S. 优化问题

    在测试集上的结果不好,不一定是过拟合,需要先确定优化有没有问题。

    • 判断方法:改变模型之后,若测试集loss增加,就看训练集。若训练集上loss也增加,说明优化做的不好;若训练集上loss减小,则可能是过拟合。
  • 过拟合 V.S. 模型偏差

    模型太复杂,过拟合;模型太简单,模型偏差。

    • 如何选模型:将训练数据划分为训练集 & 验证集,仅根据验证集表现选择模型,不论测试集。这样才不会使得公开测试集上的表现和私人测试集上的表现相差太大。验证集的划分可采取交叉验证

      b2250d6f4854364618870bad0988a36.jpg

2.4 不匹配

训练集跟测试集的分布不同,训练集再增加也没有用了。

三、深度学习基础

3.1 优化问题一:局部极小值&鞍点

损失函数收敛到: ① 局部极小值,基本没救了,但其实是个假问题;② 鞍点,还能逃离,更常见。 9b03bf088b366a9a5147203b977f533.jpg

c05946fe92fd0b7588a2ca05b43786d.jpg

  • 临界点处(梯度 = 0)的判断

    损失函数泰勒展开,一阶项为0,还剩:

    L(θ)L(θ)+12(θθ)TH(θθ)=L(θ)+12vTHvL(\theta)\approx L(\theta^{\prime})+\frac{1}{2}(\theta -\theta^{\prime})^{T}H(\theta-\theta^{\prime})=L(\theta^{\prime})+\frac{1}{2} \bm{v^{T}Hv}

    • ① H的特征值全为正数,即正定 vTHv>0v^{T}Hv>0,则为局部极小值
    • ② H的特征值全为负数,即负定 vTHv<0v^{T}Hv<0,则为局部极大值
    • ③ H的特征值有正有负,则为鞍点
    • Q:如果特征值是正数 & 0?A:可能也是鞍点,或者特殊情况特殊分析(不定、非正定、非负定、非半正定)
  • 自动逃离鞍点

    在鞍点处,只要找出海森矩阵H的一个负特征值 λλ,再找出这个特征值对应的特征向量 uu,将其与 θ\theta^{\prime} 相加,即可找到损失更低的点 θ\theta

    推导:θ=θ+u\theta=\theta^{\prime}+u,即 u=θθu = \theta-\theta^{\prime}uTHu=uT(λu)=λu2u^{T}Hu=u^{T}(\lambda u)=\lambda\|u\|^{2}。当 λ<0λ<0时 ,uTHu<0u^{T}Hu<0,有 L(θ)<L(θ)L(\theta)<L(\theta^{\prime}),也就是损失减小了。

3.2 对抗局部最小值&鞍点的方法

方法一:批量

批量梯度下降法BGD:全批量,计算量大,更新路径稳定

随机梯度下降法SGD:批量等于1,引入随机噪声,更新路径曲折,更容易逃离局部极小值

a6f6509029c2f0a68a8a57e22e23a62.jpg

  • 批量大小 × 更新次数 = 全数据量(一个回合)

    • 一个回合并不是更新一次,而是与批量有关。
    • 可并行运算时,大批量与小批量的单次更新时间差异很小,而大批量因更新次数少,一个回合的计算时间反而少74e8d315d2c4cf77236db54053ead3d.jpg
    • 小批量虽然慢,但有随机噪声的好处。 解释:右图中若沿着函数L1继续算会卡住,而小批量有很多次更新,数据变了,可能会跳到L2,就能继续算了。 b6065fbba7583d65d4968d2647e72db.jpg
  • 经验表明,小批量的过拟合问题少

    • 可能的解释:大批量倾向于走到峡谷里,小批量倾向于走到盆地里(更新方向随机,利于从峡谷跳出,而会在宽的盆地里待着)。峡谷是坏的最小值(测试集与训练集相差甚远),盆地是好的最小值。 2bb709c48285ab689977d0de1be7ed4.jpg
  • 总结:大批量与小批量各有优劣 7903cfaeb8d8692cddd81dbf321db24.jpg

方法二:动量

在物理的世界里,一个球如果从高处滚下来,就算滚到局部极小值或鞍点,因为惯性的关系它还是会继续往前走。若球的动量足够大,甚至可以翻过小坡往前走。

加上动量的时候,更新的方向不是只考虑现在的梯度,而是考虑过去所有梯度的总和。

  • λ是前一个方向的权重参数,也是需要调的 cb74b1dd16be81564c3317612170c85.jpg
  • 动量法与一般梯度下降的对比 7b66f0ff888fb0b7104b7572edf798d.jpg
  • 怎么对抗局部极小值:即使梯度往左走,若前一步的影响力更大,还是可能继续往右走,甚至翻过这个小丘,也许可以走到更好的局部极小值。 67c097f8c7d4b362bd792a5b8215292.jpg

3.3 优化问题二:学习率

① 临界点(局部最小值或鞍点)处,损失无法再下降。

② 若梯度不断来回“震荡”,虽然损失不再下降,但梯度并没有变得很小,大概就是说损失可以下降但步伐太大走不进谷底。

③ 或者,B→C 梯度平缓,学习率太小导致下降不动了。

所以,如果在某一方向上梯度的值很小,非常平坦,我们会希望学习率调大一点;如果在某一方向上梯度的值很大,非常陡峭,我们会希望学习率调小一点。

3.4 解决学习率的方法

最优改进结果

θt+1iθtiηtσtimti\theta_{t+1}^i\leftarrow\theta_t^i-\frac{\eta_t}{\sigma _t^{i}}m_t^{i}
σ0i=(g0i)2=g0it=0\sigma_0^{i}=\sqrt{(g_0^{i})^2}=|g_0^{i}|,t=0
σti=α(σt1i)2+(1α)(gti)2t=2,3,4,...\sigma_t^{i}=\sqrt{\alpha(\sigma_{t-1}^i)^2+(1-\alpha)(g_t^{i})^2},t=2,3,4,...

方法一:自适应学习率

梯度更新公式增加σti\sigma _t^{i},其与参数类别(i)、迭代轮次(t)相关。即:

θt+1iθtiησtigti\theta_{t+1}^i\leftarrow\theta_t^i-\frac{\eta}{\sigma _t^{i}}g_t^{i}
AdaGrad

σti\sigma _t^{i} 采取梯度的均方根。那梯度大的时候更新率就慢,梯度小的时候更新率就快。

σ0i=(g0i)2=g0it=0(则g0iσ0i=+11\sigma_0^{i}=\sqrt{(g_0^{i})^2}=|g_0^{i}|,t=0(则 \frac{g_0^{i}}{\sigma _0^{i}}=+1或-1)
σti=1t+1i=0t(g0i)2t=2,3,4,...\sigma_t^{i}=\sqrt{\frac{1}{t+1}\sum_{i=0}^{t}(g_0^{i})^2},t=2,3,4,...
  • 缺点1:若在某一个方向上,梯度突然变大或变小,学习率是需要及时调整的。而均方根认为过往每一个梯度同等重要,可能会掩盖当前梯度的大小变化,不能及时调整学习率。(改进:RMSprop)

  • 缺点2:AdaGrad虽然可以从B往C走了,但在快走到终点时突然爆炸了。因为BC段纵轴的方向梯度很小,累积了很小的σ,累计到一定程度后步伐就会变得很大,走到梯度很大的地方,σ又变大了,则更新率又修正回原来较小的值。循环往复,呈现出爆炸的样子。(改进:退火)

RMSprop

加权版均方根,可以调整每个梯度的重要性。

σ0i=(g0i)2=g0it=0\sigma_0^{i}=\sqrt{(g_0^{i})^2}=|g_0^{i}|,t=0
σti=α(σt1i)2+(1α)(gti)2t=2,3,4,...\sigma_t^{i}=\sqrt{\alpha(\sigma_{t-1}^i)^2+(1-\alpha)(g_t^{i})^2},t=2,3,4,...
  • α\alpha是一个需要调整的超参数。α\alpha越小,模型越看重当前梯度,则能根据梯度的大小变化及时调整学习率。
Adam

看作RMSprop + 动量,即以动量作为参数更新方向,且学习率自适应。

θt+1iθtiησtimti\theta_{t+1}^i\leftarrow\theta_t^i-\frac{\eta}{\sigma _t^{i}}m_t^{i}
σti=α(σt1i)2+(1α)(gti)2t=2,3,4,...\sigma_t^{i}=\sqrt{\alpha(\sigma_{t-1}^i)^2+(1-\alpha)(g_t^{i})^2},t=2,3,4,...
  • 之前做机器学习任务的时候好像都是默认这个优化器。
  • m考虑梯度大小&方向,σ只考虑梯度大小,二者的效果不会相互抵消。

方法二:学习率调度

在自适应学习率基础上,η 变成 ηt\eta_{t},与更新轮次/时间有关,即:

θt+1iθtiηtσtigti\theta_{t+1}^i\leftarrow\theta_t^i-\frac{\eta_t}{\sigma _t^{i}}g_t^{i}
学习率退火

随参数不断更新,让 η 越来越小。

学习率预热

随参数不断更新,让 η 先变大后变小。

  • 原因:σ是一个统计结果,一开始数据不足则σ是不精准的,需要较小的学习率来探索收集一些误差表面的基本情报,等σ比较精准后再让学习率爬升。
  • η 变到多大、变大的速度、变小的速度,都是超参数。

3.5 换个思路:调整误差表面

计算损失函数的逻辑链:权重 wi → wi*xi → y^\hat{y}yy 之间的距离 e → 求和得到损失LL

xi值越大,L对权重wi的变化越敏感,wi一变L就变了,那么这个维度上梯度就很陡峭。若另一个维度上xi值很小,L对wi不太敏感,那么梯度较小。这会导致不同维度方向的斜率与坡度非常不同,误差表面崎岖,不好训练。

除了上述的调整学习率,还可以直接改变误差表面,让各维度上的特征数值在相近的范围里。

3.6 调整误差表面的方法

General —— 特征归一化

缩小各个特征维度的数值差距,比如z-score标准化。可以在各个层对输入做归一化。

  • 优点制造好训练的误差表面,使得损失收敛更快,训练更稳定。
  • 经验:若选择sigmoid,推荐对 z = wx 也做归一化。

Special —— 批量归一化

对全部数据归一化计算量太大(e.g.上百万个μ和σ),故只对一个批量的样本做归一化。

  • 适用:批量大小比较大的时候,这时批量分布更接近整体分布。
  • 经验:①sigmoid不加批量归一化,训练会很困难。②做批量归一化后,误差表面会光滑,学习率可以大一些。
  • 步骤:通常有三阶段(x、z*2),第三阶段用β和γ调整分布,使隐藏层输出的均值不是0,减少0的负面影响。 8948e59899e43c0dc3f1ca8c6a0078a.jpg
  • 测试阶段:不用批量,用训练阶段的移动平均。p是一个超参数c7d44314afd4aae751d8311d30a60df.png
pμt1+(1p)μtμtp\overline{\mu}_{t-1}+(1-p)\mu^{t} \rightarrow \overline{\mu}_t
  • 奏效原因:解决内部协变量偏移问题

    我们在计算B更新到B'的梯度时,前一层的参数是A,或者前一层的输出是a。那当前一层从A变成A'的时候,其输出就从a变成a'。但是我们计算这个梯度的时候,是根据a算出来的,所以这个更新的方向也许适合用在a上,但不适合用在a'上。

    每次都做批量归一化的话,就会让a与a'的分布比较接近,也许会对训练有所帮助。 9c179b425f91af710659242e2793193.jpg

四、卷积神经网络

以下知识都是基于图像分类任务的。

4.1 基本流程

  • 图像描述:三维张量,宽×高×通道,比如RGB就是红绿蓝3个通道

  • 图像输入:把图像调整成统一尺寸,再“拉直”张量,变成向量

  • 全连接网络:每个神经元跟输入的每个维度都有一个权重,容易过拟合

  • 模型输出:softmax,得到 y^\hat{y}

e9fabc97dfeb66c4e25a025ddcd86e6.jpg

4.2 简化成卷积网络

① 感受野:检测特定的模式不需要整张图像

感受野可大可小,可重叠可平行,可长方形可正方形,可单通道可全通道。

2ca11d6f62d0c454d636fad96adab6f.jpg

  • 一般感受野会看全部通道,即深度 = 通道数,宽×高 → 卷积核

  • 一般同一个感受野会有一组神经元去守备这个范围

  • 一个感受野挪到下一个的距离为步幅,是一个超参数,一般设为1or2(希望重叠,识别图片每个角落)

  • 边界缺少输入,就做填充,一般是0

    e7e83405721dea3372e6ac2bc07efcc.jpg

② 滤波器:同样的模式可能会出现在图像的不同区域

在图片不同位置去检测同一个模式时,可以做参数共享,即两个神经元的感受野不同、参数权重完全一样,这些参数称为滤波器(核大小 × 通道数)。

  • 因为输入不同,就算共用参数,神经元们的输出也不会是一样的。
  • 神经元是有偏置的,滤波器也是有偏置的。

例如右下图,一个6×6×1的图像,经滤波器1就能检测出对角全为1的模式(数值为3)。

5a692b35450c2f7f617e27bd0ff1319.jpg

不同滤波器是不同参数,如果有64个滤波器,最后就得到64组4×4的数值结果,称为特征映射(尺寸为4×4×64)。

特征映射可以看成一张图片,作为下一个卷积层的输入,下一个卷积层的滤波器尺寸需为3×3×64(通道来自特征映射的64)。

  • 感受野可以一直保持3×3:在第二个卷积层的3×3时,对应原图像已经为5×5。故,网络叠得越深,滤波器看的范围会越来越大。

③ 下采样:适当缩减图像不影响模式检测

下采样(downsampling)后图像主体依然可识别,而图像大小仅为原来的1/4。

汇聚:类似一种操作符、行为命令,例如最大汇聚(max pooling)、平均汇聚(mean pooling)。

  • 图像变小,而通道数不变
  • 减少运算量
  • 不是所有任务都需要汇聚(例如下围棋),因为会丢失信息

4.3 总结

卷积层的优势

全连接层卷积层(感受野+滤波器)
弹性大弹性小
可以看整张图像,也可看小范围(把某些权重设置为0)增加了感受野和滤波器的限制
容易过拟合不容易过拟合
可以完成各种任务专为图像设计的,迁移任务需考虑相似性

典型的图像识别网络架构

卷积网络的应用:下围棋

下围棋可以看成一个分类问题,通过网络去预测下一步应该落子的最佳位置,即一个有19×19(棋盘大小)个类别的分类问题。

比如,黑子为1,白子为-1,无子为0,把棋盘表示成向量输入,而滤波器的大小和个数是需要考虑的下棋策略。

卷积网络的缺点

不能处理图像放大、缩小、旋转的问题。因此,图像识别任务中通常需要数据增强。

五、实践:CNN图像分类

5.1 基本流程

算力:学生认证云工开物,领取算力抵扣金

创建实例:阿里云PAI-DSW → 管理控制台

!git lfs install
!git clone https://www.modelscope.cn/datasets/Datawhale/LeeDL-HW3-CNN.git

# 创建文件夹并解压缩数据集,忽略输出
!wget -O hw3_data.zip !"https://www.modelscope.cn/datasets/Datawhale/LeeDL-HW3-CNN/resolve/master/hw3_data.zip" 
!mkdir -p ./hw3_data
!unzip -o hw3_data.zip -d ./hw3_data > /dev/null
  • 数据集含11个食物类别,编号0~10。

    train : 10000张图片

    valid:3643张图片

    test:3000张图片

① 导入库

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
from torch.utils.data import ConcatDataset, DataLoader, Subset, Dataset
from torchvision.datasets import DatasetFolder, VisionDataset
from tqdm.auto import tqdm
import random
  • “ConcatDataset” 和 “Subset” 在进行半监督学习时可能是有用的。
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)
  • 设置随机数种子,可确保实验结果的可重复性。

② 定义图像数据预处理方法

# 在valid和test阶段,通常不需要图像增强。
# 我们所需要的只是调整PIL图像的大小并将其转换为Tensor。
test_tfm = transforms.Compose([
    transforms.Resize((128, 128)),
    transforms.ToTensor(),
])

# 不过,在test阶段使用图像增强也是有可能的。
# 你可以使用train_tfm生成多种图像,然后使用集成方法进行测试。
train_tfm = transforms.Compose([
    transforms.Resize((128, 128)), # 将图像调整为固定大小
    # 你可以在这里添加一些图像增强的操作。

    
    transforms.ToTensor(), # ToTensor() 应该是所有变换中的最后一个
])
  • Torchvision为图像预处理、数据增强和数据加载提供了一系列的API(torchvision-transforms)。
  • 张量(tensor),即元素为相同类型的多维矩阵。
class FoodDataset(Dataset):
    """
    用于加载食品图像数据集的类。

    该类继承自Dataset,提供了对食品图像数据集的加载和预处理功能。
    它可以自动从指定路径加载所有的jpg图像,并对这些图像应用给定的变换。
    """

    def __init__(self, path, tfm=test_tfm, files=None):
        """
        初始化FoodDataset实例。
        
        - path: 图像数据所在的目录路径。
        - tfm: 应用于图像的变换方法(默认为test变换)。
        - 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: 图像对应的标签(若无,返回-1)。
        """
        fname = self.files[idx]
        im = Image.open(fname)
        im = self.transform(im)  # 应用图像变换

        # 尝试从文件名中提取标签
        try:
            label = int(fname.split("/")[-1].split("_")[0]) 
            # split(/)是去掉文件路径,split(_)是因为图片文件名是"1_994"的格式,1代表类别
        except:
            label = -1  # 如果无法提取标签,则设置为-1(测试数据无标签)

        return im, label

③ 定义模型结构

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层;全连接神经网络得到输出,2层。

④ 训练前配置(损失函数、优化器、数据集)

# 定义实验名称,用于标识实验配置或运行的标识符
_exp_name = "sample"

# 根据GPU是否可用选择设备类型
device = "cuda" if torch.cuda.is_available() else "cpu"
# 初始化模型,并将其放置在指定的设备上
model = Classifier().to(device)

# 定义批量大小
batch_size = 64

# 定义训练轮数
n_epochs = 8

# 如果在'patience'轮中没有改进,则提前停止
patience = 5


# ① 对于分类任务,使用交叉熵作为损失函数
criterion = nn.CrossEntropyLoss()

# ② 初始化优化器(Adam),您可以自行调整一些超参数,如学习率
optimizer = torch.optim.Adam(model.parameters(), lr=0.0003, weight_decay=1e-5)

# ③ 构建训练和验证数据集
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)

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)
  • DataLoader 数据加载器:设置批量大小、是否打乱数据顺序、是否使用多线程加载、是否固定内存地址(提高数据加载效率)

⑤ 训练模型

# 初始化追踪器,这些不是参数,不应该被更改
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}")

    # ---------- 验证阶段 ----------
    # 确保模型处于评估模式,以便某些模块如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}")

    # 保存模型
    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
  • 训练阶段通过前向传播、计算损失、反向传播和参数更新来优化模型,验证阶段评估模型在未见过的数据上的表现。如果验证集的准确率超过了之前的最好成绩,保存当前模型,并在连续多轮验证性能未提升时提前停止训练。

  • 最佳模型在第5轮找到,验证集准确率为0.59。

    image.png

⑥ 测试集评估

test_set = FoodDataset("./hw3_data/test", tfm=test_tfm)
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 = []

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()
  • torch.no_grad() 上下文管理器:禁用梯度计算
# 创建测试csv文件
def pad4(i):
    """
    将输入数字i转换为长度为4的字符串,如果长度不足4,则在前面补0。
    :param i: 需要转换的数字
    :return: 补0后的字符串
    """
    return "0" * (4 - len(str(i))) + str(i)

df = pd.DataFrame()
# 使用列表推导式生成Id列,列表长度等于测试集的长度
df["Id"] = [pad4(i) for i in range(len(test_set))]
# 将预测结果赋值给Category列
df["Category"] = prediction
df.to_csv("submission.csv", index=False)
  • 预测结果保存在 submission.csv 文件之中。

5.2 优化尝试

baseline 的验证集准确率为59.11%,loss为1.2219。

① 调整为ResNet

残差网络(ResNet)通过引入跳跃连接(skip connections),有效解决了深度网络中的梯度消失和梯度爆炸问题。这些残差连接允许信息在网络中直接跨层传递,使得极深的网络也能顺利训练。这不仅提高了网络的稳定性,还加快了收敛速度,从而进一步提升模型的整体表现。此外,残差结构在多个现代深度网络中得到了广泛应用,证明了其在处理深度网络优化挑战中的有效性。

模型处修改:

import torch.nn as nn
import torch.nn.functional as F

class BasicBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1, downsample=None):
        super(BasicBlock, self).__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1)
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1)
        self.bn2 = nn.BatchNorm2d(out_channels)
        self.downsample = downsample
        self.relu = nn.ReLU(inplace=True)

    def forward(self, x):
        identity = x
        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)
        out = self.conv2(out)
        out = self.bn2(out)
        
        if self.downsample is not None:
            identity = self.downsample(x)
        
        out += identity
        out = self.relu(out)
        return out

class ResNetClassifier(nn.Module):
    def __init__(self, block, layers, num_classes=11):
        super(ResNetClassifier, self).__init__()
        self.in_channels = 64
        self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3)
        self.bn1 = nn.BatchNorm2d(64)
        self.relu = nn.ReLU(inplace=True)
        self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
        self.layer1 = self._make_layer(block, 64, layers[0])
        self.layer2 = self._make_layer(block, 128, layers[1], stride=2)
        self.layer3 = self._make_layer(block, 256, layers[2], stride=2)
        self.layer4 = self._make_layer(block, 512, layers[3], stride=2)
        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
        self.fc = nn.Linear(512, num_classes)

    def _make_layer(self, block, out_channels, blocks, stride=1):
        downsample = None
        if stride != 1 or self.in_channels != out_channels:
            downsample = nn.Sequential(
                nn.Conv2d(self.in_channels, out_channels, kernel_size=1, stride=stride),
                nn.BatchNorm2d(out_channels),
            )

        layers = []
        layers.append(block(self.in_channels, out_channels, stride, downsample))
        self.in_channels = out_channels
        for _ in range(1, blocks):
            layers.append(block(out_channels, out_channels))

        return nn.Sequential(*layers)

    def forward(self, x):
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.maxpool(x)

        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)

        x = self.avgpool(x)
        x = x.view(x.size(0), -1)
        x = self.fc(x)
        return x

配置处修改:

model = ResNetClassifier(BasicBlock, [2, 2, 2, 2]).to(device)

最佳模型在第5轮找到,验证集准确率为59.30%(优于baseline),loss为1.2785。

image.png

  • 事实上,在第八轮训练时,训练集的准确率已达到83.18%,而验证集准确率下降到57.70%,应该是出现了过拟合状况。

② 数据增强

根据GPT-4o的建议,以及卷积网络的缺陷,数据预处理处修改:

train_tfm = transforms.Compose([
    transforms.RandomHorizontalFlip(p=0.2),           # 20%概率水平翻转
    transforms.RandomRotation(degrees=10),            # 随机旋转 ±10°
    transforms.RandomResizedCrop(size=(128, 128),     # 随机裁剪并缩放至128*128
                                 scale=(0.9, 1.0)),
    transforms.ColorJitter(brightness=0.1,            # 颜色抖动
                           contrast=0.1,
                           saturation=0.1,
                           hue=0.1),
    transforms.ToTensor(),
])

最佳模型在第7轮找到,验证集准确率为51.70%(差于baseline),loss为1.3631。

image.png

  • 验证集与训练集的表现相似,说明过拟合问题得到了缓解。

  • loss相对较高、准确率相对较低,可能因为优化没有做好,或者数据增强过度,也可能是训练轮次不够(因为loss一直在降低,还未趋缓)。

  • 也尝试了一下如何增加对抗样本:

    !pip install torchattacks
    
    # 定义对抗攻击方法
    import torchattacks
    def adversarial_attack(model, x, y):
        # 确保模型处于评估模式
        model.eval()
        # 初始化PGD攻击器
        attack = torchattacks.PGD(model, eps=0.3, alpha=0.01, steps=40)
        # 生成对抗样本
        adv_perturbation = attack(x, y)
        return adv_perturbation
    

    训练处修改【损失=原样本loss+对抗样本loss,准确率=(原样本准确率+对抗样本准确率)/2】:

        for batch in tqdm(train_loader):
            # 每个批次包含图像数据及其对应的标签
            imgs, labels = batch
    
            # 生成对抗样本
            adv_imgs = adversarial_attack(model, imgs.to(device), labels.to(device))
    
            # 正常样本的前向传播
            logits = model(imgs.to(device))
            loss = criterion(logits, labels.to(device))
    
            # 对抗样本的前向传播
            adv_logits = model(adv_imgs.to(device))
            adv_loss = criterion(adv_logits, labels.to(device))
    
            # 合并损失
            total_loss = loss + adv_loss
    
            # 清除上一步中参数中存储的梯度
            optimizer.zero_grad()
    
            # 计算参数的梯度
            total_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()
            adv_acc = (adv_logits.argmax(dim=-1) == labels.to(device)).float().mean()
    
            # 记录损失和准确率
            train_loss.append(total_loss.item())
            train_accs.append((acc + adv_acc) / 2)
    

    但是效果很不好,还需要进一步学习和改进方法。

③ ResNet + 数据增强

基于上述结果分析,我将训练轮次改到12,并使用了ResNet+数据增强。

最佳模型在第11轮找到,验证集准确率为60.36%(优于baseline),loss为1.2234。

image.png

  • ResNet的确可以提高模型表现,即使在数据增强效果一般的情况下。

训练结果汇总

评估指标baselineResNet数据增强ResNet + 数据增强
验证集准确率59.11%59.30%51.70%60.36%
验证集损失1.22191.27851.36311.2234

从损失来看,后几列的loss都比baseline高,可能优化方法还可以改进。

其他的优化方法

  • 数据增强

    • 几何变换:RandomRotation、RandomAffine、RandomHorizontalFlip、RandomCrop、RandomResizedCrop、Pad、RandomAffine

    • 颜色变换:ColorJitter

    • 噪声添加:高斯噪声、椒盐噪声

    • 高级技术:混合增强、随机擦除、对抗样本

  • 改变网络结构

    • AlexNet:作为深度学习在图像分类领域的开创性架构,AlexNet通过较大的卷积核和ReLU激活函数引领了深度学习的应用潮流。

    • VGG:使用多层3x3卷积核构建深层网络,既简化了网络设计又提升了模型的准确性,成为多个任务的基准模型。

    • Inception:引入多路径结构,使得网络能够在不同的尺度上并行提取特征,有效提高了计算效率和模型的表现力。

    • ResNet(残差网络):引入跳跃连接(skip connections),有效解决了深度网络中的梯度消失和梯度爆炸问题,使得训练非常深的网络成为可能。

  • 正则化技术

    • 如L2正则化、Dropout、Batch Normalization等,以防止过拟合。
  • 优化激活函数

    • 选择适当的激活函数,如ReLU、Leaky ReLU、Swish等。
  • 优化算法

    • 采用先进的优化算法如Adam、RMSprop或学习率调度器。(可以Adam + 学习率调度)
  • 约束初始化权重

    • 利用He或Xavier初始化,确保训练的稳定性。
  • 损失函数调整

  • 模型压缩

    • 通过剪枝、量化或知识蒸馏来减少模型复杂度,提高推理速度。
  • 混合精度训练

    • 结合半精度和单精度浮点数训练,加快训练速度并减少显存占用。
  • 硬件加速

    • 利用GPU、TPU等硬件加速器,以显著提升训练和推理的效率。

写在最后

今年的夏令营全部结束啦,真的很感谢Datawhale提供了一个开源的学习平台,能督促我在有限的时间里做高效的学习。之前我也接触过CNN等深度学习的内容,但只是知道概念、停留在“能用就行”,而通过这次学习我才开始理解它的底层逻辑和技术细节,并且动手实践了数据增强、对抗样本生成等任务,遇到过困难、有过解决方法、也留存了疑问与思考。希望在以后的学习和工作中,我能充分地用上这次学到的东西,作进一步的探究。AI是时代的新浪潮,无论身处什么行业都要坚定地迎风而上呀~

2024.9.3