前言

学习一下: 中心损失函数,用于用于深度人脸识别的特征判别方法
论文:https://ydwen.github.io/papers/WenECCV16.pdf
github代码:https://github.com/KaiyangZhou/pytorch-center-loss

参考:史上最全MNIST系列(三)——Centerloss在MNIST上的Pytorch实现(可视化)


问题引出

open-set问题

open-set 问题是一种模式识别中的问题,它指的是当训练集和测试集的类别不完全相同的情况。例如,如果训练集只包含 0 到 9 的数字,而测试集包含了 A 到 Z 的字母,那么就是一个 open-set 问题。这种情况下,分类器不仅要正确识别已知的类别,还要能够拒绝未知的类别,即将它们标记为 unknown 或 outlier。(后来我想这就是一个聚类问题)

open-set 问题与 closed-set 问题相对应,closed-set 问题是指训练集和测试集的类别完全相同的情况。例如,如果训练集和测试集都只包含 0 到 9 的数字,那么就是一个 closed-set 问题。这种情况下,分类器只需要正确识别已知的类别即可。

抛出

当我们要预测的人脸不在训练集中出现过时,我们需要让它不识别出,而不要因为与训练过的人脸相像而误判。

对于常见的图像分类问题,我们常常用softmax loss来求损失。以MNIST数据集为例,如果你的损失采用softmax loss,那么最后各个类别学出来的特征分布大概如下图:(在倒数第二层全连接层输出了一个2维的特征向量)
在这里插入图片描述
左图为训练集,右图为测试集,发现结果分类还算不错,但是每一类的界限太过模糊,若从中再加一列,则有可能出现误判。

解决方法

softmax函数、softmax-loss函数

已知softmax的函数为:
在这里插入图片描述
在深度学习的分类问题上,意为对应类的概率(符合每个类的概率相加和为1且0<每个类的概率<1)。(输出层后的结果)
其中 zi​ 是第 i 个输出节点的值,K是输出节点的个数,即分类的类别数。softmax函数的作用是将输出节点的值归一化为范围在 [0, 1] 且和为 1 的概率值,表示属于每个类别的可能性。

softmax的损失函数:(具体详见:一文详解Softmax函数)(也叫交叉熵损失)
在softmax的函数的基础上,我们要求正确对应类的概率最大,
在这里插入图片描述
即,损失函数为:(越小越好)
在这里插入图片描述
其中z = wTx+b,m是小批量的样本的个数,n是类别个数,w是全连接层的权重,b为偏置项,(一般来说w,b是要学习出来的),xi是第i个深层特征,属于第yi类。
在这里插入图片描述

解决

在分类的基础上,我们还要求,每个类,往自己的中心的特征靠拢,使类内间距减少,这样才能更加显出差别。
在这里插入图片描述
于是,论文作者提出如下新的损失函数:
在这里插入图片描述
其中 xi​ 是第 i 个样本的特征向量,cyi​​ 是第 yi​ 个类别的中心向量,m 是样本的个数。
小的注意点:这里用的是样本数,也就是说,是在小批量分类任务完成后再进行的聚类任务
在这里插入图片描述
我们不难发现,作者这里用的是欧式距离的平方,即在多维空间上的两点之间真实距离的平方。
实际上,我们很容易发现,这实际上是一个聚类问题,常见的聚类问题上用的是误差平方和(SSE)损失函数:
在这里插入图片描述

论文作者仅从此推出的欧式距离的平方,仅从形式上十分相像,当然可以作为此聚类问题的解决。
在这里插入图片描述
(在二维上,可以简单看成是直角三角形斜边的平方=两直角边平方和)
为何要加以平方:欧式距离的平方相比欧式距离有一些优点,例如:
1、计算更快,省去了开方的运算。
2、更加敏感,能够放大距离的差异,使得离群点更容易被发现。
3、更加方便,能够与其他平方项相结合,如方差、协方差等。

最后,作者沿用softmax损失函数与中心损失函数相加的方法(在损失函数上很常见),
在这里插入图片描述
这里的1/2很容易想到是作为梯度下降时与后面的平方用来抵消的项,这里λ 可以看作调节两者损失函数的比例

来作为总体的损失函数,作为此问题的解决。

代码(center_loss.py)

原理

首先确定三个事实:
1、在之前的学习中通过实践得知(最小二乘法那块),在拟合的最后结果上,在利用SSE损失函数与MSE损失函数作为损失值参与到程序中时,拟合出的结果并无差别,只有在结果出来后,作为评判模型的好坏时,才有数值上的差别。(两者区别在于是否求平均)。
2、1/2的乘或不乘,只对运算的过程的简便程度有影响,与结果无影响。
3、图像是二维的。

于是我们将作者的的中心损失函数稍加变形。就得到了如下形式(事实上,github上给出的代码就是这么写的)
在这里插入图片描述

程序解释

参考:Center loss-pytorch代码详解
这里只做补充:

import torch
import torch.nn as nn

class CenterLoss(nn.Module):
    """Center loss.
    # 参考
    Reference:
    Wen et al. A Discriminative Feature Learning Approach for Deep Face Recognition. ECCV 2016.
    # 参数
    Args:
        num_classes (int): number of classes. # 类别数
        feat_dim (int): feature dimension.  # 特征维度
    """
    # 初始化        默认参数:类别数为10 特征维度为2 使用GPU
    def __init__(self, num_classes=10, feat_dim=2, use_gpu=True):
        super(CenterLoss, self).__init__() # 继承父类的所有属性
        self.num_classes = num_classes 
        self.feat_dim = feat_dim
        self.use_gpu = use_gpu

        if self.use_gpu: # 如果使用GPU
            #nn.Parameter()将一个不可训练的tensor转换成可以训练的类型parameter,并将这个parameter绑定到一个module里面,参与到模型的训练和优化中。
            #nn.Parameter的对象的requires_grad属性的默认值是True,即是可被训练的,这与torth.Tensor对象的默认值相反。
            self.centers = nn.Parameter(torch.randn(self.num_classes, self.feat_dim).cuda()) 
            # 初始化中心矩阵 .cuda()表示将数据放到GPU上
        else:
            self.centers = nn.Parameter(torch.randn(self.num_classes, self.feat_dim))

    def forward(self, x, labels): # 前向传播
        """
        Args:
            x: feature matrix with shape (batch_size, feat_dim). # 特征矩阵
            labels: ground truth labels with shape (batch_size). # 真实标签
        """
        batch_size = x.size(0) #batch_size x的形式为tensor张量
        # .pow对x中的每一个元素求平方 dim=1表示按行求和 keepdim=True表示保持原来的维度 expand是扩展维度
        distmat = torch.pow(x, 2).sum(dim=1, keepdim=True).expand(batch_size, self.num_classes) + \
                  torch.pow(self.centers, 2).sum(dim=1, keepdim=True).expand(self.num_classes, batch_size).t() #.t()表示转置 均转成batch_size x num_classes的形式
        # .addmm_()表示进行1*distmat + (-2)*x@self.centers.t()的运算    @表示矩阵乘法
        distmat.addmm_(1, -2, x, self.centers.t())

        classes = torch.arange(self.num_classes).long()# 生成一个从0到num_classes-1的整数序列 long表示数据类型
        if self.use_gpu: classes = classes.cuda() 
        #这里 .unsqueeze(0) 例[0,4,2] -> [[0,4,2]]  .unsqueeze(1) 例[0,4,2] -> [[0],[4],[2]] 
        labels = labels.unsqueeze(1).expand(batch_size, self.num_classes)# .unsqueeze(1)表示在第1维增加一个维度  .expand()表示扩展维度
        mask = labels.eq(classes.expand(batch_size, self.num_classes)) #eq是比较两个tensor是否相等,相等返回1,不相等返回0
        # *表示对应元素相乘
        dist = distmat * mask.float() # mask.float()将mask转换为float类型
        loss = dist.clamp(min=1e-12, max=1e+12).sum() / batch_size # clamp表示将dist中的元素限制在1e-12和1e+12之间

        return loss

代码运用

import torch
import torch.nn as nn
from center_loss import CenterLoss

criterion_cent =CenterLoss(10, 2,False) #10个类别,2维
torch.manual_seed(0) #设置随机种子
x = torch.randn(10, 2) # (32, 2) #32个样本,每个样本2维
labels = torch.randint(0, 10, (10,)) # (32,) #32个样本,每个样本一个标签
print(x)
print(labels)
print(criterion_cent(x, labels))

结果:

tensor([[-1.1258, -1.1524],
        [-0.2506, -0.4339],
        [ 0.5988, -1.5551],
        [-0.3414,  1.8530],
        [ 0.4681, -0.1577],
        [ 1.4437,  0.2660],
        [ 1.3894,  1.5863],
        [ 0.9463, -0.8437],
        [ 0.9318,  1.2590],
        [ 2.0050,  0.0537]])
tensor([2, 9, 1, 8, 8, 3, 6, 9, 1, 7])
tensor(2.9281, grad_fn=<DivBackward0>)

如何梯度更新

首先了解一下基本的梯度下降算法

【点云、图像】学习中 常见的数学知识及其中的关系与python实战
里的小标题:基于迭代的梯度下降算法,了解到超参数(人为设定的参数):
学习率,w,b等。此算法为拟合出一条线。

伪代码:
1、未达到设定迭代次数
2、迭代次数epoch+1
3、计算损失值
4、计算梯度
5、更新w、b
6、达到迭代次数,结束

然后

看下这里的更新方法
在这里插入图片描述
其中,卷积层中初始化的参数 θc,超参数 :λ、α 和学习率 μ ,迭代次数 t 。(λ、α、 μ均可调)
伪代码:
1、当未收敛时:
2、迭代次数t+1
3、计算损失函数 L=Ls+Lc
4、计算反向传播误差(即梯度)
5、更新w
6、更新cj
7、更新θc
8、结束

其中
在这里插入图片描述
其中,如果满足条件,则 δ(条件) = 1,如果不满足,则 δ(条件) = 0。

首先:第一个公式不用多说,为中心损失函数对特征xi求偏导,即求梯度。
其次:第二个公式:加入了判断,当条件满足时(即yi=j,即就是在同一类时)Δcj=cj-xi 的小批量的所有和/m+1。相当于累加了误差求平均,以此来作为梯度。(分母上的累加和看着恐怖,实际上就是m个1相加。)
当条件不满足时,即Δcj =0,此时这个分母的+1的作用就体现出来了,分母不能为0嘛。

补充:
第5步后面一个等号成立是因为Lc中没有W项,所以Lc对W的求导为0
第7步也是一样,求梯度,只是写成了求导的链式法则。

代码解释见下面train()

梯度更新代码

    for epoch in range(args.max_epoch): #
        print("==> Epoch {}/{}".format(epoch+1, args.max_epoch))
        train(model, criterion_xent, criterion_cent,
              optimizer_model, optimizer_centloss,
              trainloader, use_gpu, dataset.num_classes, epoch)

        if args.stepsize > 0: scheduler.step()

        if args.eval_freq > 0 and (epoch+1) % args.eval_freq == 0 or (epoch+1) == args.max_epoch:
            print("==> Test")
            acc, err = test(model, testloader, use_gpu, dataset.num_classes, epoch)
            print("Accuracy (%): {}\t Error rate (%): {}".format(acc, err))

补充:外围知识(models.py)

卷积神经网络(cnn)

参考:
1、CNN笔记:通俗理解卷积神经网络
2、wiki卷积神经网络
3、pytorch官方CONV2D
补充:
1、五种经典卷积神经网络
大前提:输入的x为tensor张量
(样本数x通道数x高x宽)torch.randn(3, 3, 10, 10)

(实际上就是多维数组,不用pytorch的话,numpy数组也能实现,两者很多有共通之处)

神经网络模型搭建(单层神经网络)

得出以下已知:
先从nn(神经网络)讲起:
1、输入层->中间层->输出层 (中间层也叫隐藏层)

中间层只有一层时:我们可以做一个最简单的单层神经网络,也叫做单隐层感知机(single hidden layer perceptron)。
以下以单层神经网络为例,

借用此图:layer1:输入层,layer2:中间层,layer3:输出层。
在这里插入图片描述
而中间的线段,我们称之为线性层。上图就有两层线性层。
其中:中间线性层的计算公式为:y = WTx+b ,W为权重矩阵,b为偏置向量。函数为nn.Linear(in_features, out_features, bias=True) (这里为什么有转置,在下面main()里会讲)

也常用作作为分类器的最后一层,输出类别的概率或得分,这个时候我们可以看到它常见的名字叫全连接层。

简单运用一下:

import torch
import torch.nn as nn
linear = nn.Linear(10, 5) # 创建一个线性变换层,输入维度为 10,输出维度为 5
torch.manual_seed(0)#设置随机种子
input = torch.randn(3, 10) # 创建一个形状为 (3, 10) 的随机张量  3个特征,每个特征为10维 torch.randn返回一个张量,包含了从标准正态分布(均值为0,方差为 1)中抽取的一组随机数
output = linear(input) # 对输入张量进行线性变换
print(output.shape) # 输出张量的形状为 (3, 5)
print(output)
'''
torch.Size([3, 5])
tensor([[ 0.3224,  0.5811, -1.6699,  0.7644,  0.6194],
        [-0.0365, -0.2294,  0.0931, -0.6751, -0.0490],
        [ 0.2874,  0.7715, -0.4043,  0.3233,  0.0094]],
       grad_fn=<AddmmBackward0>)
'''

2、激活函数的引入

激活函数的作用是为神经网络引入非线性因素,使得神经网络可以拟合复杂的函数,解决非线性的问题。

如果不使用激活函数,每一层的输出都是上一层输入的线性函数,无论神经网络有多少层,最终的输出都是输入的线性组合,这样的神经网络就失去了表达能力和泛化能力。

激活函数可以将线性变换后的值映射到一个特定的范围,比如 [0,1] 或者 [-1,1],这样可以限制输出值的幅度,防止梯度消失或爆炸。激活函数还可以增加神经网络的稀疏性,使得部分神经元的输出为零,从而减少计算量和过拟合。

常用的激活函数有 sigmoid、tanh、ReLU、Leaky ReLU、ELU、PReLU、Swish 等,它们各有优缺点,需要根据具体的问题和数据选择合适的激活函数。

于是,操作变成了如下:
输入层->中间层->输出层 。这个层数不变。
中间的过程为:输入层->全连接层->激活函数->中间层->全连接层->激活函数->输出层

代码:

# 导入 pytorch 库
import torch
import torch.nn as nn
import torch.nn.functional as F

# 定义网络结构
class SingleHiddenLayerPerceptron(nn.Module):
    def __init__(self, input_size, hidden_size, output_size):
        super(SingleHiddenLayerPerceptron, self).__init__()
        # 第一层是线性层,输入维度为 input_size,输出维度为 hidden_size
        self.linear1 = nn.Linear(input_size, hidden_size)
        # 第二层是线性层,输入维度为 hidden_size,输出维度为 output_size
        self.linear2 = nn.Linear(hidden_size, output_size)
    
    def forward(self, x):
        # 前向传播,先经过第一层的线性变换和激活函数
        x = F.relu(self.linear1(x))
        # 再经过第二层的线性变换和激活函数
        x = F.sigmoid(self.linear2(x))
        # 返回输出
        return x
    
# 创建一个输入维度为 10,隐藏层维度为 20,输出维度为 2 的网络
model = SingleHiddenLayerPerceptron(10, 20, 2)
print(model)
# 运用网络进行前向传播
torch.manual_seed(0)#设置随机种子
input = torch.randn(1, 10)
output = model(input)
print(output)
'''
SingleHiddenLayerPerceptron(
  (linear1): Linear(in_features=10, out_features=20, bias=True)
  (linear2): Linear(in_features=20, out_features=2, bias=True)
)
tensor([[0.5418, 0.5528]], grad_fn=<SigmoidBackward0>)
'''

若中间层为两层的神经网络可以依次递推。
一般来说神经网络的输入为1维(数组)。
输入可以改为:

input = torch.randn(3, 3, 10, 10) # (3, 3, 10, 10) #3个样本,3个通道,每个通道10x10

补:nn.ReLU()与F.relu()的区别

nn.ReLU()是一个类,它继承自nn.Module,可以作为一个网络的组件,需要指定输入维度和输出维度。它的作用是对输入张量进行逐元素的ReLU激活函数,即 y = max(0, x)。

F.relu()是一个函数,它属于torch.nn.functional模块,可以直接对输入张量进行操作,不需要创建对象。它的作用和nn.ReLU()相同,但是它还可以指定一个inplace参数,表示是否在原地修改输入张量,节省内存。

一般来说,nn.ReLU()用于定义网络结构的时候,F.relu()用于前向传播的时候。当用print(net)输出网络结构时,会有nn.ReLU()层,而F.relu()是没有输出的。

类似的:
F.max_pool2d(x, 2)与nn.MaxPool2d(2, 2)也是相同的区别

卷积神经网络的提出

后发现,
1、以上方法在针对同一张图旋转,缩放后的图的识别效果不好,于是想出提取图的局部特征。
2、全连接层在图片像素过大时,如100x100的图,权重过多为10000,训练时间会过长,而卷积神经网络的核大小,例如是5x5,则权重缩小为25,且能共享权重参数(即权重参数不变)。

为解决此方法,卷积神经应运而生。
出现的新名词:
1、卷积核: 若是3通道的图,核为3,则卷积核的大小为3x3x3
为什么卷积核一般为奇数不为偶数?
答:
为了保证卷积核的中心点对齐输入图像的像素点,避免卷积结果的失真。如果卷积核的大小是偶数,那么卷积核的中心点就无法精确地落在像素点上,可能会导致边缘信息的模糊。

为了方便进行same padding()的处理,使得输出图像的尺寸和输入图像的尺寸相同。如果卷积核的大小是奇数,那么可以在输入图像的两边对称地补零,使得卷积核的中心点可以滑动到输入图像的边缘。如果卷积核的大小是偶数,那么就需要在输入图像的一边多补一个零,这样就会破坏图像的对称性。

为了更好地获取中心信息,增加特征的不变性。由于奇数卷积核拥有天然的绝对中心点,因此在做卷积的时候能更好地获取到中心这样的概念信息。这样可以增加特征的平移、旋转、尺度等不变性,使网络更鲁棒。

2、感受野:若卷积核为3,则感受野大小为3x3
3、卷积层:若是3通道的图,核为3,则有3个3x3的权重矩阵(或者也叫过滤器)

参数:
步长(stride):2,则每次移动2个像素
填充(padding):1,则周围1圈填充为0(默认填充0),目的:为了提取边缘特征。

3、池化层:(或者也叫下采样层)作用:
降低信息冗余:池化层可以通过对输入数据进行局部聚合,去除一些不必要的细节,只保留最显著的特征,从而减少信息的重复和噪声。

增加特征不变性:池化层可以使特征对输入数据的一些微小变化(如平移、旋转、缩放等)不敏感,提高特征的鲁棒性和泛化能力。

防止过拟合:池化层可以通过减少参数量,降低模型的复杂度,避免模型在训练数据上过度拟合,提高模型的泛化能力。

于是一个中间线段全连接的过程变为:卷积层->激活函数->池化层

以最大池化层举例:(最大池化较平均池化实践上效果较好)参数:核,步长,填充

nn.MaxPool2d(2, 2) #第一个2是池化核大小,第二个2是步长,padding默认为0
F.max_pool2d(x, 2) # 最大池化层 2表示池化核大小,步长默认为2,默认padding为0

4、全连接层之前:(最后一层前的展平)
显而易见,卷积层和池化层的输出是一个张量,而全连接层输入为一个向量,为了使两者能够相互连接,需要将张量展平成向量,以匹配全连接层的输入维度。

全连接层的作用是将卷积层和池化层提取的特征进行组合和映射,得到最终的分类或回归结果。为了实现这个功能,需要将输入数据的所有元素都参与计算,而不是只考虑局部的信息。因此,需要将输入数据展平成一维,使得每个元素都能与全连接层的权重相乘。

全连接层的参数数量取决于输入数据的维度和输出数据的维度。如果输入数据的维度过高,那么全连接层的参数数量就会过多,导致模型的复杂度增加,容易出现过拟合的问题。因此,需要将输入数据展平成一维,减少全连接层的参数数量,降低模型的复杂度。

故在全连接层前有个展平(flatten)操作。

# 将输入张量展平为一维向量
x = x.view(-1, 16*5*5)

至此,以第一个提出的卷积神经网络(LeNet)举例:
在这里插入图片描述
这里的尺寸大小计算公式为:[(输入宽度 +2x填充 - 内核宽度 ) / 步长 ] + 1
如10x10x16 里的10 =(14+2x0-5)/1 +1

代码:

# 导入 pytorch 库
import torch
import torch.nn as nn
import torch.nn.functional as F

# 定义网络结构
class LeNet(nn.Module):
    def __init__(self):
        super(LeNet, self).__init__()
        # 定义卷积层 1,输入通道数为 1,输出通道数为 6,卷积核大小为 5
        self.conv1 = nn.Conv2d(1, 6, 5, padding=2) #默认stride=1,默认padding=0
        # 定义卷积层 2,输入通道数为 6,输出通道数为 16,卷积核大小为 5
        self.conv2 = nn.Conv2d(6, 16, 5)
        # 定义全连接层 1,输入维度为 16x5x5,输出维度为 120
        self.fc1 = nn.Linear(16*5*5, 120)
        # 定义全连接层 2,输入维度为 120,输出维度为 84
        self.fc2 = nn.Linear(120, 84)
        # 定义全连接层 3,输入维度为 84,输出维度为 10
        self.fc3 = nn.Linear(84, 10)
    
    def forward(self, x):
        # 前向传播,先经过卷积层 1 和激活函数
        x = F.sigmoid(self.conv1(x))
        # 再经过池化层 1
        x = F.avg_pool2d(x, 2) #默认stride=2,默认padding=0
        # 再经过卷积层 2 和激活函数
        x = F.relu(self.conv2(x))
        # 再经过池化层 2
        x = F.avg_pool2d(x, 2)
        # 将输入张量展平为一维向量
        x = x.view(-1, 16*5*5)
        # 再经过全连接层 1 和激活函数
        x = F.sigmoid(self.fc1(x))
        # 再经过全连接层 2 和激活函数
        x = F.sigmoid(self.fc2(x))
        # 再经过全连接层 3
        x = self.fc3(x)
        # 返回输出
        return x
m =LeNet()
print(m)
torch.manual_seed(0)#设置随机种子
x = torch.randn(1, 1, 28, 28) # (1, 1, 32, 32) #1个样本,1个通道,28x28
output = m(x)
print(output)
'''
LeNet(
  (conv1): Conv2d(1, 6, kernel_size=(5, 5), stride=(1, 1), padding=(2, 2))
  (conv2): Conv2d(6, 16, kernel_size=(5, 5), stride=(1, 1))
  (fc1): Linear(in_features=400, out_features=120, bias=True)
  (fc2): Linear(in_features=120, out_features=84, bias=True)
  (fc3): Linear(in_features=84, out_features=10, bias=True)
)
tensor([[-0.0218, -0.0131, -0.5542,  0.2534,  0.2028, -0.2585, -0.1112,  0.0399,
         -0.2317, -0.0391]], grad_fn=<AddmmBackward0>)
'''

论文中的阐述

在这里插入图片描述
根据论文描述
(5,32)/1,2 x2 表示卷积核为5,输出通道为32(或者说32个滤波器),,步长为1,填充为2。
x2表示级联卷积核,即在第一个卷积层上再应用一个卷积层,好处:1、增强模型表达能力。2、减少网络计算参数
例: ConvolutionalLayer(1, 32, 5, 1, 2),
ConvolutionalLayer(32, 32, 5, 1, 2),

2/2,0 表示,池化层的核为2,步长为2,填充为0。
选择max-pooling作为池化方法,PReLU为激活函数(官网PReLU)。

计算公式:如果输入特征图的高度和宽度为 H 和 W,卷积核的大小为 K,步长为 S,填充为 P,那么输出特征图的高度和宽度为: (这里是整除:只取商的整数部分,不考虑余数或小数部分
Hout​ =(H+2P−K)/S ​+1
Wout = (W+2P−K)/S ​+1

这里作者用的模型依然是MINST,形状为28x28x1
经过第一个卷积->(28+2x2-5)/1+1=28 ,通道数为32,则形状为28x28x32。
经过级联卷积->(28+2x2-5)/1+1=28 ,通道数为32,形状为28x28x32。
经过池化->(28+2x0-2)/2+1 =14,通道数不变,形状为14x14x32。
经过第二个卷积-> … ),通道数为64,形状为14x14x64。
经过级联卷积->…,形状为14x14x64。
经过池化->…,形状为7x7x64。
经过第三个卷积-> … ),通道数为128,形状为7x7x128。
经过级联卷积->…,形状为7x7x128。
经过池化->(7+2x0-2)/2+1=3 (注意:这里除法是整除),形状为3x3x128。

代码解释

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

import math

class ConvNet(nn.Module):
    """LeNet++ as described in the Center Loss paper.""" # 在中心损失论文中描述的LeNet++
    def __init__(self, num_classes):
        super(ConvNet, self).__init__() # 继承父类的所有属性 # (28+2*2-5)/1+1=28
        self.conv1_1 = nn.Conv2d(1, 32, 5, stride=1, padding=2) # 1表示输入通道数 32表示输出通道数 5表示卷积核大小 stride表示步长 padding表示填充
        self.prelu1_1 = nn.PReLU() # PReLU激活函数 PReLU(x)=max(0,x)+a∗min(0,x) # a是一个可学习的参数 
        self.conv1_2 = nn.Conv2d(32, 32, 5, stride=1, padding=2) # 这一步是为了增加网络的深度
        self.prelu1_2 = nn.PReLU() # PReLU激活函数 默认num_parameters=1, init=0.25,device=None, dtype=None
        
        self.conv2_1 = nn.Conv2d(32, 64, 5, stride=1, padding=2)
        self.prelu2_1 = nn.PReLU()
        self.conv2_2 = nn.Conv2d(64, 64, 5, stride=1, padding=2)
        self.prelu2_2 = nn.PReLU()
        
        self.conv3_1 = nn.Conv2d(64, 128, 5, stride=1, padding=2)
        self.prelu3_1 = nn.PReLU()
        self.conv3_2 = nn.Conv2d(128, 128, 5, stride=1, padding=2)
        self.prelu3_2 = nn.PReLU()
        
        self.fc1 = nn.Linear(128*3*3, 2) # 全连接层 128*3*3表示输入维度 2表示输出维度  3=28/2/2/2
        self.prelu_fc1 = nn.PReLU()  # PReLU激活函数
        self.fc2 = nn.Linear(2, num_classes) # 全连接层 

    def forward(self, x):
        x = self.prelu1_1(self.conv1_1(x)) #
        x = self.prelu1_2(self.conv1_2(x))
        x = F.max_pool2d(x, 2) # 最大池化层 2表示池化核大小,步长默认为2,默认padding为0
        
        x = self.prelu2_1(self.conv2_1(x))
        x = self.prelu2_2(self.conv2_2(x))
        x = F.max_pool2d(x, 2)
        
        x = self.prelu3_1(self.conv3_1(x))
        x = self.prelu3_2(self.conv3_2(x))
        x = F.max_pool2d(x, 2)
        #.view()表示改变维度 -1表示自动计算
        x = x.view(-1, 128*3*3) # 将x展平 128*3*3表示展平后的维度  
        x = self.prelu_fc1(self.fc1(x))  # 这里 输出2维
        y = self.fc2(x) # 这里 输出num_classes维

        return x, y
    
# Factory function to create model # 工厂函数创建模型
__factory = {
    'cnn': ConvNet, # 卷积神经网络
}

def create(name, num_classes):
    if name not in __factory.keys():
        raise KeyError("Unknown model: {}".format(name))
    return __factory[name](num_classes) # name表示模型名称 num_classes表示类别数

if __name__ == '__main__':
    pass

运用

import models
model =models.create('cnn',10) #调用models.py中的create函数
print(model)
print(model.parameters()) #打印模型的参数
#model.train() #设置模型为训练模式
model.eval() #设置模型为评估模式  #model.train(False) #设置模型为评估模式
print(model.training) #打印模型的模式

补充:(datasets.py)

transform基础
参考:Pytorch基本操作(4)——torchvision中的transforms的使用

代码解释

import torch
import torchvision
from torch.utils.data import DataLoader

import transforms

class MNIST(object):
    def __init__(self, batch_size, use_gpu, num_workers):
        # 数据预处理
        transform = transforms.Compose([
            transforms.ToTensor(),
            transforms.Normalize((0.1307,), (0.3081,)) # 均值和标准差 0.1307是均值 0.3081是标准差
        ])# ToTensor()将PIL Image或者numpy.ndarray转化为tensor,并且归一化到[0, 1.0]之间
        # Normalize(mean, std, inplace=False) mean是均值 std是标准差 inplace表示是否原地操作
        # transforms.Compose 将多个transform组合起来使用

        # 数据加载
        pin_memory = True if use_gpu else False # 如果使用GPU则pin_memory=True pin_memory表示是否将数据保存在锁页内存中,这样可以加速GPU数据的转移
        # torchvision.datasets.MNIST()表示加载MNIST数据集 root表示数据集存放的目录 train表示是否加载训练集 download表示是否下载 transform表示数据预处理
        trainset = torchvision.datasets.MNIST(root='./data/mnist', train=True, download=True, transform=transform)# 训练集
        # torch.utils.data.DataLoader()表示将数据集加载到DataLoader中 batch_size表示每个batch的大小 shuffle表示是否打乱数据集 num_workers表示加载数据的线程数 pin_memory表示是否将数据保存在pin memory中
        trainloader = torch.utils.data.DataLoader(
            trainset, batch_size=batch_size, shuffle=True,
            num_workers=num_workers, pin_memory=pin_memory,
        )
        
        testset = torchvision.datasets.MNIST(root='./data/mnist', train=False, download=True, transform=transform)# 测试集
        
        testloader = torch.utils.data.DataLoader(
            testset, batch_size=batch_size, shuffle=False,
            num_workers=num_workers, pin_memory=pin_memory,
        )

        self.trainloader = trainloader
        self.testloader = testloader
        self.num_classes = 10 # 类别数

__factory = {
    'mnist': MNIST,
}

def create(name, batch_size, use_gpu, num_workers):
    if name not in __factory.keys():
        raise KeyError("Unknown dataset: {}".format(name))
    return __factory[name](batch_size, use_gpu, num_workers)

代码运用

import datasets
data =datasets.create('mnist',batch_size=10, use_gpu=False, num_workers=4) #调用datasets.py中的create函数
print(data)

结果:
首次是如下:
在这里插入图片描述
打印训练集

import datasets
data =datasets.create('mnist',batch_size=10, use_gpu=False, num_workers=4) #调用datasets.py中的create函数
print(data)
trainloader = data.trainloader
testloader = data.testloader
for i, (inputs, labels) in enumerate(trainloader):#遍历训练集
    print(inputs.shape) 
    print(labels)
    break

结果:

<datasets.MNIST object at 0x000002779E097190>
torch.Size([10, 1, 28, 28])
tensor([6, 9, 8, 3, 0, 3, 8, 2, 7, 3])

设置batch_size为10,所以这个为10。
输入为10张1通道的28x28的图。
标签为手写数字的真实值。

transforms.py

注意这里的import transform
相当于下面两句:

from torchvision import transforms
from PIL import Image

原因:transforms.py里的class没用到

from torchvision.transforms import *
from PIL import Image

class ToGray(object):
    """
    Convert image from RGB to gray level.
    """
    def __call__(self, img):
        return img.convert('L') # 转换为灰度图像

补充:(utils.py)

代码解释

import os
import sys
import errno # 异常模块
import shutil # 文件操作
import os.path as osp # os.path 模块主要用于文件的属性获取、文件、目录的创建、删除和文件的路径名的操作

import torch

# 创建文件夹
def mkdir_if_missing(directory):
    if not osp.exists(directory): # 如果文件夹不存在
        try:
            os.makedirs(directory)
        except OSError as e:
            if e.errno != errno.EEXIST: # 如果错误码不是文件已存在
                raise

class AverageMeter(object):
    """Computes and stores the average and current value.# 计算并存储平均值和当前值
       
       Code imported from https://github.com/pytorch/examples/blob/master/imagenet/main.py#L247-L262
    """
    def __init__(self):
        self.reset()

    def reset(self):
        self.val = 0 # 当前值
        self.avg = 0 # 平均值
        self.sum = 0 # 总和
        self.count = 0 # 计数

    def update(self, val, n=1): # 更新 
        self.val = val # 当前值
        self.sum += val * n # 总和
        self.count += n # 计数
        self.avg = self.sum / self.count # 平均值

def save_checkpoint(state, is_best, fpath='checkpoint.pth.tar'): #state表示模型的状态 is_best表示是否是最好的模型 fpath表示模型保存的路径
    mkdir_if_missing(osp.dirname(fpath)) # 创建文件夹
    torch.save(state, fpath) # 保存模型
    if is_best: # 如果是最好的模型
        #它的意思是将 fpath 这个文件复制到 fpath 所在目录下的 best_model.pth.tar 这个文件中。
        shutil.copy(fpath, osp.join(osp.dirname(fpath), 'best_model.pth.tar')) # 复制模型 fpath表示源文件 best_model.pth.tar表示目标文件 osp.join()表示将多个路径组合后返回

class Logger(object):
    """
    Write console output to external text file. # 将控制台输出写入外部文本文件
    
    Code imported from https://github.com/Cysu/open-reid/blob/master/reid/utils/logging.py.
    """
    def __init__(self, fpath=None): #__init__是一种特殊的方法,被称为类的构造函数或初始化方法,当创建了这个类的实例时就会调用这个方法
        self.console = sys.stdout # 控制台输出
        self.file = None
        if fpath is not None:
            mkdir_if_missing(os.path.dirname(fpath))
            self.file = open(fpath, 'w') # 打开文件

    def __del__(self): #__del__是一种特殊的方法,被称为析构方法,当一个对象被销毁时,析构方法会被调用   
        self.close() 

    def __enter__(self): #__enter__和__exit__是一对方法,用于定义一个上下文管理器 #调用with语句时,会执行__enter__方法
        pass

    def __exit__(self, *args): # 退出
        self.close()

    def write(self, msg): # 写入
        self.console.write(msg)  # 控制台输出
        if self.file is not None:
            self.file.write(msg)

    def flush(self): # 刷新
        self.console.flush()
        if self.file is not None:
            self.file.flush()
            os.fsync(self.file.fileno()) # 将文件描述符对应的文件写入磁盘

    def close(self): 
        self.console.close() # 关闭控制台
        if self.file is not None:
            self.file.close() # 关闭文件

代码应用

import os.path as osp
import sys
from utils import AverageMeter, Logger

#osp.join('test', 'log_' + "test" + '.txt') #拼接字符串 #保存在test/log_test.txt
sys.stdout = Logger(osp.join('test', 'log_' + "test" + '.txt')) # 保存训练日志 #将标准输出重定向到文件

losses = AverageMeter() #创建一个AverageMeter对象
losses.update(1, 10) #更新losses的值  #现在losses的值为1,个数为10
print(losses.avg) #打印losses的平均值
print(losses.val) #打印losses的值
print(losses.sum) #打印losses的和
print(losses.count) #打印losses的个数

sys.stdout.write('This is a log message\n')

输出:
在这里插入图片描述

并且在当前文件生成一个文件夹,在此文件夹下生成日志文件。
在这里插入图片描述

main.py解释

argparse模块应用

参考:
1、argparse — 命令行选项、参数和子命令解析器
2、argparse库教程(超易懂)

简单运用:
sum.py

# 导入 argparse 模块
import argparse
# 创建一个 ArgumentParser 对象
parser = argparse.ArgumentParser(description='计算两个数的和')
# 添加两个位置参数
parser.add_argument('a', type=int, help='第一个加数')
parser.add_argument('b', type=int, help='第二个加数')
# 解析命令行
args = parser.parse_args()
# 打印结果
print(args.a + args.b)

注:默认type是str,所以这里要相加的话,得用整数
结果:
在这里插入图片描述

代码解释

# argparse.ArgumentParser() 创建一个ArgumentParser对象 用来处理命令行参数
parser = argparse.ArgumentParser("Center Loss Example")
# dataset # 数据集
# add_argument() 方法用于指定程序需要接受的命令参数
parser.add_argument('-d', '--dataset', type=str, default='mnist', choices=['mnist']) # 选择数据集 例:python main.py -d mnist
parser.add_argument('-j', '--workers', default=4, type=int,
                    help="number of data loading workers (default: 4)") # 数据加载工作线程数 例:python main.py -j 4 #-j表示短名称
# optimization # 优化
parser.add_argument('--batch-size', type=int, default=128) # 批大小
parser.add_argument('--lr-model', type=float, default=0.001, help="learning rate for model") # 学习率
parser.add_argument('--lr-cent', type=float, default=0.5, help="learning rate for center loss") # 中心损失学习率
parser.add_argument('--weight-cent', type=float, default=1, help="weight for center loss") # 中心损失权重
parser.add_argument('--max-epoch', type=int, default=100) # 最大迭代次数
parser.add_argument('--stepsize', type=int, default=20) # 学习率下降间隔 : 每隔多少个epoch下降一次
parser.add_argument('--gamma', type=float, default=0.5, help="learning rate decay") # 学习率衰减 : 学习率下降的倍数 比如0.5表示学习率减半
# model # 模型
parser.add_argument('--model', type=str, default='cnn') # 模型选择
# misc # 其他
parser.add_argument('--eval-freq', type=int, default=10) # 评估频率
parser.add_argument('--print-freq', type=int, default=50) # 打印频率
parser.add_argument('--gpu', type=str, default='0') # GPU
parser.add_argument('--seed', type=int, default=1) # 随机种子
parser.add_argument('--use-cpu', action='store_true') # 是否使用CPU action='store_true' 表示如果有这个参数则为True
parser.add_argument('--save-dir', type=str, default='log') # 保存路径 保存训练日志 保存在log文件夹下
parser.add_argument('--plot', action='store_true', help="whether to plot features for every epoch") # 是否绘制特征图

args = parser.parse_args() # 解析参数 保存到args中

plot_features()函数

matplotlib模块应用

import matplotlib.pyplot as plt
# 生成一些随机数据
x = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
y = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
colors = ['C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9'] # 颜色
for i in range(10):
    plt.scatter(x[i], y[i], c=colors[i]) # 画散点 #或c='C'+str(i)
plt.legend(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], loc='upper right')#图例 upper right表示右上角
plt.show()

在这里插入图片描述
若保存:

import matplotlib.pyplot as plt
# 生成一些随机数据
x = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
y = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
colors = ['C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9'] # 颜色
for i in range(10):
    plt.scatter(x[i], y[i], c=colors[i]) # 画散点 #或c='C'+str(i)
plt.legend(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], loc='upper right')#图例 upper right表示右上角
plt.savefig('test.png', bbox_inches='tight') # bbox_inches='tight'表示将图片四周的空白部分裁剪掉
plt.close() # 关闭绘图

代码解释

import matplotlib.pyplot as plt
import os
import os.path as osp
import torch
def plot_features(features, labels, num_classes, epoch, prefix):
    """Plot features on 2D plane.# 在2D平面上绘制特征

    Args:
        features: (num_instances, num_features). # 特征 
        labels: (num_instances). # 标签
    """
    colors = ['C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9'] # 颜色
    # 画散点
    for label_idx in range(num_classes):
        plt.scatter(                        #如: label_idx=0时,取对应labels==0的features的横坐标和纵坐标
            features[labels==label_idx, 0], # x坐标  labels==label_idx表示labels中等于label_idx的索引 0 取第0列的元素
            features[labels==label_idx, 1], # y坐标  1 取第1列的元素
            c=colors[label_idx], # 颜色
            s=1, # 点的大小
        )
    # plt.legend() 图例
    plt.legend(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], loc='upper right') #'upper right'表示右上角
    # 保存
    dirname = osp.join(save_dir, prefix) #prefix意为前缀 保存在save_dir/prefix文件夹下
    if not osp.exists(dirname):
        os.mkdir(dirname)
    save_name = osp.join(dirname, 'epoch_' + str(epoch+1) + '.png')
    plt.savefig(save_name, bbox_inches='tight') # bbox_inches='tight'表示将图片四周的空白部分裁剪掉
    plt.close() # 关闭绘图

代码运用

# 生成一些随机的特征和标签
torch.manual_seed(0) #设置随机种子
features = torch.randn(100, 2) # 100个样本,每个样本有2个特征 #因为输出的是二维的,所以这里是2
#print(features)
labels = torch.randint(0, 10, (100,)) # 100个样本,每个样本属于0-9中的一个类别
#print(labels)
num_classes = 10 # 类别个数
epoch = 0 # 训练轮数
prefix = 'test' # 文件夹前缀
save_dir = 'test' # 保存的文件夹

# 调用 plot_features 函数
plot_features(features, labels, num_classes, epoch, prefix)

结果:
在这里插入图片描述

train()

1、当未收敛时:
2、迭代次数t+1
3、计算损失函数 L=Ls+Lc
4、计算反向传播误差(即梯度)
5、更新w
6、更新cj
7、更新θc
8、结束

nn.CrossEntropyLoss()

计算交叉熵损失函数
公式:
在这里插入图片描述
代码:

import torch
import torch.nn as nn

# 创建一个交叉熵损失函数的对象
criterion = nn.CrossEntropyLoss()
#设置随机种子
torch.manual_seed(0)
# 生成一些随机的输出和标签 rand返回一个张量,包含了从区间[0, 1)的均匀分布中抽取的一组随机数
#生成随机数为0-1的10x5的张量 randn返回一个张量,包含了从标准正态分布(均值为0,方差为 1)中抽取的一组随机数 
output = torch.rand(10, 5) # 10个样本,每个样本有5个类别的概率
label = torch.randint(0, 5, (10,)) # 10个样本,每个样本属于0-4中的一个类别
print(output)
print(label)
# 计算损失
loss = criterion(output, label)
# 打印损失
print(loss)

这里的损失计算的是输出值和真实值之间的损失。
结果:
output:每一行是一个样本的预测概率,每一列是一个类别的预测概率。output 的每个元素是一个实数,表示对应样本和类别的预测概率,取值范围是 (0, 1)。
例如,如果 output 的第一个元素是 0.8,表示第一个样本预测属于第一个类别的概率是 0.4963。
label:一个一维的张量,表示样本的类别标签,与 output的行数相同。label 的每个元素是一个整数,表示对应样本的真实类别,取值范围是 [0, M-1],其中 M 是类别数量。
例如,如果 label 的第一个元素是 0,表示第一个样本的真实类别是 0。

tensor([[0.4963, 0.7682, 0.0885, 0.1320, 0.3074],
        [0.6341, 0.4901, 0.8964, 0.4556, 0.6323],
        [0.3489, 0.4017, 0.0223, 0.1689, 0.2939],
        [0.5185, 0.6977, 0.8000, 0.1610, 0.2823],
        [0.6816, 0.9152, 0.3971, 0.8742, 0.4194],
        [0.5529, 0.9527, 0.0362, 0.1852, 0.3734],
        [0.3051, 0.9320, 0.1759, 0.2698, 0.1507],
        [0.0317, 0.2081, 0.9298, 0.7231, 0.7423],
        [0.5263, 0.2437, 0.5846, 0.0332, 0.1387],
        [0.2422, 0.8155, 0.7932, 0.2783, 0.4820]])
tensor([0, 4, 3, 1, 1, 0, 3, 1, 1, 2])
tensor(1.5950)

一般更新步骤

参考:
1、PyTorch中为什么要进行梯度清零
2、PyTorch中在反向传播前为什么要手动将梯度清零?
3、pytorch是如何实现对loss进行反向传播计算梯度的?

    # let us write a training loop
    torch.manual_seed(42) #设置随机种子
    
    epochs = 200 #迭代次数
    for epoch in range(epochs):
      model_1.train() #训练模式
      
      pred = model_1(data) # 模型训练
    
      loss = loss_fn(pred,label) #计算损失
    
      optimizer.zero_grad() #梯度清零
    
      loss.backward() #反向传播

      optimizer.step() #梯度更新

若有trainloader:

    # let us write a training loop
    torch.manual_seed(42) #设置随机种子
    
    epochs = 200 #迭代次数
    for epoch in range(epochs):
      model_1.train() #训练模式
      for i, (data, labels) in enumerate(trainloader):
	      pred = model_1(data) # 模型训练
	    
	      loss = loss_fn(pred,label) #计算损失
	    
	      optimizer.zero_grad() #梯度清零
	    
	      loss.backward() #反向传播
	
	      optimizer.step() #梯度更新

只要optimizer.zero_grad() 满足在反向传播之前就行。(也有第一步就进行梯度清零的)
目的:为了避免在每次迭代中梯度累加,确保每个批次的训练都是从零开始计算梯度,从而保证了梯度更新的准确性。

在前馈传播期间,权重被分配给输入,在第一次迭代之后,权重被初始化,模型从样本(输入)中学到了什么。当我们开始反向传播时我们想要更新权值以使代价函数的损失最小。所以我们清除了之前的权值以便得到更好的权值。我们在训练中一直这样做,在测试中我们不会这样做,因为我们在训练时间得到了最适合我们数据的权重。

例子:
在这里插入图片描述
回过头来:
1、当未收敛时:for epoch in range(epochs): 进行了1、2
2、迭代次数t+1
3、计算损失函数 L=Ls+Lc

  model_1.train() #训练模式
  pred = model_1(data) # 模型训练
  loss = loss_fn(pred,label) #计算损失  进行了3

4、计算反向传播误差(即梯度)
5、更新w
6、更新cj
7、更新θc

  optimizer.zero_grad() #梯度清零
  loss.backward() #反向传播
  optimizer.step() #梯度更新  进行了5 、6、7

8、结束

简单例子

下面是一个使用 PyTorch 的简单示例,实现了一个线性回归模型,并用随机梯度下降法(SGD)进行梯度更新。

import torch
import torch.nn as nn
import torch.optim as optim

#设置随机种子
torch.manual_seed(0)
# 生成一些随机的输入和标签
x = torch.randn(100, 1) # 100个样本,每个样本有1个特征  randn
y = 3 * x + 5 + torch.randn(100, 1) # 100个样本,每个样本有1个标签,服从 y = 3x + 5 + 噪声 的分布

# 定义一个简单的线性模型
model = nn.Linear(1, 1) # 输入维度是1,输出维度是1
# 定义一个均方误差损失函数
criterion = nn.MSELoss()
# 定义一个随机梯度下降优化器
optimizer = optim.SGD(model.parameters(), lr=0.01) # 学习率是0.01

# 训练100个迭代
for epoch in range(100):
    # 清零梯度
    optimizer.zero_grad()
    # 得到预测结果
    output = model(x)
    # 计算损失
    loss = criterion(output, y)
    # 反向传播,计算梯度
    loss.backward()
    # 更新参数
    optimizer.step()
    # 打印损失
    print(f"Epoch {epoch}, loss {loss.item():.4f}")

# 打印模型参数
print(model.weight)
print(model.bias)

部分结果:
在这里插入图片描述

代码解释

def train(model, criterion_xent, criterion_cent,
          optimizer_model, optimizer_centloss,
          trainloader, use_gpu, num_classes, epoch):
    model.train() # 训练模式
    xent_losses = AverageMeter() # 交叉熵损失 #掉用utils.py中的AverageMeter类
    cent_losses = AverageMeter() # 中心损失
    losses = AverageMeter() # 总损失
    
    if args.plot: # 是否绘制特征图
        all_features, all_labels = [], []

    for batch_idx, (data, labels) in enumerate(trainloader):
        if use_gpu:
            data, labels = data.cuda(), labels.cuda()
        features, outputs = model(data) # 前向传播得出2维特征和num_classes维输出
        loss_xent = criterion_xent(outputs, labels) #outputs表示模型的输出 labels表示真实标签 计算输出值和真实值之间的交叉熵损失
        loss_cent = criterion_cent(features, labels) #!!! 计算2维的特征和标签之间的中心损失(因为中心损失是在二维里提出的)
        loss_cent *= args.weight_cent # 中心损失乘以权重
        loss = loss_xent + loss_cent # 总损失
        optimizer_model.zero_grad() # 梯度清零 防止梯度累加
        optimizer_centloss.zero_grad() # 梯度清零
        loss.backward()   # 反向传播求梯度
        optimizer_model.step() # 更新模型参数
        # by doing so, weight_cent would not impact on the learning of centers
        for param in criterion_cent.parameters():
            param.grad.data *= (1. / args.weight_cent) # 中心损失函数的梯度   #乘以1/args.weight_cent
        optimizer_centloss.step() # 更新中心损失参数
        
        losses.update(loss.item(), labels.size(0)) # 更新总损失 loss.item()表示取出loss的值 labels.size(0)表示取出labels的大小 即batch_size
        xent_losses.update(loss_xent.item(), labels.size(0))
        cent_losses.update(loss_cent.item(), labels.size(0))

        if args.plot:
            if use_gpu: 
                all_features.append(features.data.cpu().numpy()) # .data表示取出数据 cpu表示将数据放到CPU上 numpy表示将数据转换为numpy格式
                all_labels.append(labels.data.cpu().numpy())
            else:
                all_features.append(features.data.numpy())
                all_labels.append(labels.data.numpy())
        # 打印
        if (batch_idx+1) % args.print_freq == 0: # 每隔多少个batch打印一次
            print("Batch {}/{}\t Loss {:.6f} ({:.6f}) XentLoss {:.6f} ({:.6f}) CenterLoss {:.6f} ({:.6f})" \
                  .format(batch_idx+1, len(trainloader), losses.val, losses.avg, xent_losses.val, xent_losses.avg, cent_losses.val, cent_losses.avg))

    if args.plot:  #[np.array([[1, 2, 3], [4, 5, 6]],np.array([[7, 8, 9], [10, 11, 12]])] -> np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]])
        all_features = np.concatenate(all_features, 0) #np.concatenate表示将多个numpy数组进行合并 axis=0表示按行合并
        all_labels = np.concatenate(all_labels, 0)
        plot_features(all_features, all_labels, num_classes, epoch, prefix='train')

小细节有的在main()里补充。

test()

一般测试步骤

# 假设 model 是已经训练好的模型,criterion 是损失函数
# test_loader 是用于测试的 DataLoader

model.eval()  # 设置模型为评估模式
test_loss = 0
correct = 0

with torch.no_grad():  # 关闭梯度计算
    for data, target in test_loader:
        output = model(data)
        test_loss += criterion(output, target).item()  # 累加损失.item()返回一个标量
        pred = output.argmax(dim=1, keepdim=True)  # 获取预测结果 argmax返回最大值的索引
        correct += pred.eq(target.view_as(pred)).sum().item() #.eq是比较两个tensor是否相等,相等返回1,不相等返回0 .sum()返回相等的个数 

test_loss /= len(test_loader.dataset) #计算平均损失 
test_accuracy = 100. * correct / len(test_loader.dataset)

print(f'Test set: Average loss: {test_loss:.4f}, Accuracy: {correct}/{len(test_loader.dataset)} ({test_accuracy:.0f}%)')

代码解释

def test(model, testloader, use_gpu, num_classes, epoch):
    model.eval() # 测试模式
    correct, total = 0, 0 # 正确数 总数
    if args.plot:
        all_features, all_labels = [], []

    with torch.no_grad(): # 不需要计算梯度
        for data, labels in testloader:
            if use_gpu:
                data, labels = data.cuda(), labels.cuda()
            features, outputs = model(data)
            predictions = outputs.data.max(1)[1] # 取出最大值的索引 .data 表示取出数据 max(1)表示按行取最大值 [1]表示取出索引
            total += labels.size(0) # 更新总数
            correct += (predictions == labels.data).sum() # 更新正确数
            
            if args.plot:
                if use_gpu:
                    all_features.append(features.data.cpu().numpy())
                    all_labels.append(labels.data.cpu().numpy())
                else:
                    all_features.append(features.data.numpy())
                    all_labels.append(labels.data.numpy())

    if args.plot:
        all_features = np.concatenate(all_features, 0)
        all_labels = np.concatenate(all_labels, 0)
        plot_features(all_features, all_labels, num_classes, epoch, prefix='test')

    acc = correct * 100. / total # 准确率 %前的数
    err = 100. - acc # 错误率
    return acc, err

其中关于outputs.data.max(1)[1]的解释

import torch
# 设置随机种子
torch.manual_seed(0)
# 生成一些随机的输出和标签
output = torch.rand(2, 5) # 2个样本,每个样本有5个类别的概率
print(output)   
print(output.data.max(1, keepdim=True)) #返回每一行的最大值和最大值的索引 keepdim=True 保持原来的维度
print(output.data.max(1)[1]) #返回每一行最大值的索引
'''
tensor([[0.4963, 0.7682, 0.0885, 0.1320, 0.3074],
        [0.6341, 0.4901, 0.8964, 0.4556, 0.6323]])
torch.return_types.max(
values=tensor([[0.7682],
        [0.8964]]),
indices=tensor([[1],
        [2]]))
tensor([1, 2])
'''

main()

参数

这里解释下下面代码中,model.parameters()和criterion.parameters()里的参数是什么。

    optimizer_model = torch.optim.SGD(model.parameters(), lr=args.lr_model, weight_decay=5e-04, momentum=0.9) 
    optimizer_centloss = torch.optim.SGD(criterion_cent.parameters(), lr=args.lr_cent)

先看model.parameters(),用一个简单的模型举例。

import torch.nn as nn
import torch.optim as optim

# 设置随机种子
torch.manual_seed(0)
# 定义一个简单的神经网络模型
class SimpleModel(nn.Module):
    def __init__(self):
        super(SimpleModel, self).__init__()
        self.fc1 = nn.Linear(10, 5) #10个输入,5个输出
        self.fc2 = nn.Linear(5, 2)

    def forward(self, x):
        x = self.fc1(x)
        x = self.fc2(x)
        return x

# 实例化模型
model = SimpleModel()

# 使用 model.parameters() 获取所有参数
for param in model.parameters():
    print(param)

# 使用 model.named_parameters() 获取所有参数,以及参数名
for name, param in model.named_parameters():
    print(name, param.size())

print(model.fc1.weight)

#x = torch.randn(2, 10) # 2个样本,每个样本有10个特征

结果:

Parameter containing:
tensor([[-0.0024,  0.1696, -0.2603, -0.2327, -0.1218,  0.0848, -0.0063,  0.2507,
         -0.0281,  0.0837],
        [-0.0956, -0.0622, -0.3021, -0.2094, -0.1304,  0.0117,  0.1250,  0.1897,
         -0.2144, -0.1377],
        [ 0.1149,  0.2626, -0.0651,  0.2366, -0.0510,  0.0335,  0.2863, -0.2934,
         -0.1991, -0.0801],
        [-0.1233,  0.2732, -0.2050, -0.1456, -0.2209, -0.2962, -0.1846,  0.2718,
          0.1411,  0.1533],
        [ 0.0166, -0.1621,  0.0535, -0.2953, -0.2285, -0.1630,  0.1995,  0.1854,
         -0.1402, -0.0114]], requires_grad=True)
Parameter containing:
tensor([0.2022, 0.3144, 0.1255, 0.0427, 0.2120], requires_grad=True)
Parameter containing:
tensor([[-0.2633,  0.0833, -0.3467, -0.3100, -0.2310],
        [ 0.2024,  0.1799, -0.2649,  0.1351,  0.2455]], requires_grad=True)
Parameter containing:
tensor([-0.0564,  0.0171], requires_grad=True)
fc1.weight torch.Size([5, 10])
fc1.bias torch.Size([5])
fc2.weight torch.Size([2, 5])
fc2.bias torch.Size([2])
Parameter containing:
tensor([[-0.0024,  0.1696, -0.2603, -0.2327, -0.1218,  0.0848, -0.0063,  0.2507,
         -0.0281,  0.0837],
        [-0.0956, -0.0622, -0.3021, -0.2094, -0.1304,  0.0117,  0.1250,  0.1897,
         -0.2144, -0.1377],
        [ 0.1149,  0.2626, -0.0651,  0.2366, -0.0510,  0.0335,  0.2863, -0.2934,
         -0.1991, -0.0801],
        [-0.1233,  0.2732, -0.2050, -0.1456, -0.2209, -0.2962, -0.1846,  0.2718,
          0.1411,  0.1533],
        [ 0.0166, -0.1621,  0.0535, -0.2953, -0.2285, -0.1630,  0.1995,  0.1854,
         -0.1402, -0.0114]], requires_grad=True)

我们可以看到,这里的权重矩阵W是5x10的形式,而输入假设为X:2x10的数据,故要进行矩阵乘法前,须对矩阵进行转置,则变为WT:10x5矩阵,偏置b为1x5的形式,所以第一个nn.Linear(10, 5)结果为y =X@WT +b(@表示举证乘法)。

至于这里的初始化的权重是怎么随机化的,我在nn.Linear(按ctrl进源代码)的源代码中找到出处: 用了这个init.kaiming_uniform_方法。
可以见:这里 的解释

    def reset_parameters(self) -> None:
        # Setting a=sqrt(5) in kaiming_uniform is the same as initializing with
        # uniform(-1/sqrt(in_features), 1/sqrt(in_features)). For details, see
        # https://github.com/pytorch/pytorch/issues/57109
        init.kaiming_uniform_(self.weight, a=math.sqrt(5))
        if self.bias is not None:
            fan_in, _ = init._calculate_fan_in_and_fan_out(self.weight)
            bound = 1 / math.sqrt(fan_in) if fan_in > 0 else 0
            init.uniform_(self.bias, -bound, bound)

类似的criterion.parameters()的参数里也是差不多

只是这里的参数是我们自己定义的

self.centers = nn.Parameter(torch.randn(self.num_classes, self.feat_dim))

与上面的权重矩阵类似,输入的维度在后面,输出的维度在前面,[输出的维度,输入的维度]。
这也解释了为什么后面有转置的操作,为了矩阵相乘或相加。

于是,我们知道了神经网络要“学习”的参数就是这些参数了,当这些参数达到一个比较好的符合损失函数的标准时,这些参数就可以帮助我们进行分类等任务。而更新这些的方法,类似于梯度下降法。

将光标放到parameters()上: 显示
在这里插入图片描述
这通常传递给优化器。

torch.optim.SGD

参考:
1、SGD pytorch官方文档
2、PyTorch优化算法:torch.optim.SGD的参数详解和应用

torch.optim.SGD(params, lr=, momentum=0, dampening=0, weight_decay=0, nesterov=False)

1、params(必须参数): 这是一个包含了需要优化的参数(张量)的迭代器,例如模型的参数 model.parameters()。
1、lr(必须参数): 学习率(learning rate)。它是一个正数,控制每次参数更新的步长。较小的学习率会导致收敛较慢,较大的学习率可能导致震荡或无法收敛。
3、momentum(默认值为 0): 动量(momentum)是一个用于加速 SGD 收敛的参数。它引入了上一步梯度的指数加权平均。通常设置在 0 到 1 之间。当 momentum 大于 0 时,算法在更新时会考虑之前的梯度,有助于加速收敛。
4、dampening(默认值为 0): 阻尼项,用于减缓动量的速度。在某些情况下,为了防止动量项引起的震荡,可以设置一个小的 dampening 值。
5、weight_decay(默认值为 0): 权重衰减,也称为 L2 正则化项。它用于控制参数的幅度,以防止过拟合。通常设置为一个小的正数。
6、nesterov(默认值为 False): Nesterov 动量。当设置为 True 时,采用 Nesterov 动量更新规则。Nesterov 动量在梯度更新之前先进行一次预测,然后在计算梯度更新时使用这个预测。

由官方的SVD中给的例子得出:
通常的梯度下降为以下几个步骤。

optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
optimizer.zero_grad()
loss_fn(model(input), target).backward()
optimizer.step()

注意这里loss.backward()(官方文档)用的链式法则求导(梯度),所以这里loss.backward()只要进行一次,就把两个梯度都算了,其他的步骤要两个。
在这里插入图片描述

动量解释:为下方的u,写在t时刻的速度前,为速度的调整量。默认为0。
lr学习率:写在t+1时刻的速度前。 如下:
在这里插入图片描述
关于权重衰减项:
参考:How to Use Weight Decay to Reduce Overfitting of Neural Network in Keras
5e-4的由来:发现用此衰减值在CNN模型中获得的效果比较好。
在这里插入图片描述

STEPLR

解释代码:

    if args.stepsize > 0: # 学习率下降
        scheduler = lr_scheduler.StepLR(optimizer_model, step_size=args.stepsize, gamma=args.gamma) # 学习率下降
if args.stepsize > 0: scheduler.step() # 学习率更新

参考:官方STEPLR
在 PyTorch 中,lr_scheduler.StepLR 是一个学习率调度器,它会在每个 step_size 指定的周期后,将优化器中每个参数组的学习率乘以 gamma 参数。这样做可以在训练过程中逐步降低学习率,有助于模型在训练后期稳定并提高性能。

scheduler = lr_scheduler.StepLR(optimizer_model, step_size=args.stepsize, gamma=args.gamma)

step_size 是学习率下降的周期,以 epoch 计。
gamma 是学习率衰减的因子,通常是一个小于 1 的数。
例:

# Assuming optimizer uses lr = 0.05 for all groups
# lr = 0.05     if epoch < 30
# lr = 0.005    if 30 <= epoch < 60
# lr = 0.0005   if 60 <= epoch < 90
# ...
scheduler = StepLR(optimizer, step_size=30, gamma=0.1)
for epoch in range(100):
    train(...)
    validate(...)
    scheduler.step()

使用这个调度器时,你需要在每个 epoch 的训练循环结束后调用 scheduler.step() 来更新学习率。

那为什么给 optimizer_model 做了学习率的下降,没有给 optimizer_centloss做学习率下降?
可能合理的解释:
1、模型参数可能需要更动态的学习率调整来更好地适应数据特征,而中心损失参数可能不需要那么频繁的调整。
2、对中心损失参数使用固定的学习率能够得到更好的结果。
3、中心损失通常用于学习特征的紧凑表示,可能不需要随时间变化的学习率来优化。
4、简化训练过程。
当然,实际上,我们也可以对optimizer_centloss做学习率下降。

main()代码解释


def main():
    torch.manual_seed(args.seed) # 设置随机种子
    os.environ['CUDA_VISIBLE_DEVICES'] = args.gpu # 设置GPU
    use_gpu = torch.cuda.is_available() # 是否使用GPU
    if args.use_cpu: use_gpu = False # 如果使用CPU 则不使用GPU

    sys.stdout = Logger(osp.join(args.save_dir, 'log_' + args.dataset + '.txt')) # 保存训练日志

    if use_gpu:
        print("Currently using GPU: {}".format(args.gpu))
        cudnn.benchmark = True # 优化卷积运算 #网络的输入数据维度或类型上变化不大的情况下,设置为True可以增加运行效率
        torch.cuda.manual_seed_all(args.seed) #为所有的GPU设置随机种子
    else:
        print("Currently using CPU")

    print("Creating dataset: {}".format(args.dataset))
    # 创建数据集
    dataset = datasets.create(
        name=args.dataset, batch_size=args.batch_size, use_gpu=use_gpu,
        num_workers=args.workers,
    )
    # 获取训练集和测试集
    trainloader, testloader = dataset.trainloader, dataset.testloader #调用dataset类的trainloader和testloader属性
    # 创建模型
    print("Creating model: {}".format(args.model))
    model = models.create(name=args.model, num_classes=dataset.num_classes) # 调用models.py中的create函数 创造类

    if use_gpu:
        model = nn.DataParallel(model).cuda() # 使用多GPU nn.DataParallel()表示将模型并行化

    criterion_xent = nn.CrossEntropyLoss() # 交叉熵损失
    criterion_cent = CenterLoss(num_classes=dataset.num_classes, feat_dim=2, use_gpu=use_gpu) # 中心损失
    ## 优化器 SGD #model.parameters()表示优化模型参数 lr学习率 weight_decay权重衰减 5e-04   momentum动量0.9
    optimizer_model = torch.optim.SGD(model.parameters(), lr=args.lr_model, weight_decay=5e-04, momentum=0.9) 
    optimizer_centloss = torch.optim.SGD(criterion_cent.parameters(), lr=args.lr_cent)

    if args.stepsize > 0: # 学习率下降
        scheduler = lr_scheduler.StepLR(optimizer_model, step_size=args.stepsize, gamma=args.gamma) # 学习率下降

    start_time = time.time()# 计时

    for epoch in range(args.max_epoch): 
        print("==> Epoch {}/{}".format(epoch+1, args.max_epoch))
        train(model, criterion_xent, criterion_cent,
              optimizer_model, optimizer_centloss,
              trainloader, use_gpu, dataset.num_classes, epoch)

        if args.stepsize > 0: scheduler.step() # 学习率更新
        # 每隔eval_freq个epoch评估一次
        if args.eval_freq > 0 and (epoch+1) % args.eval_freq == 0 or (epoch+1) == args.max_epoch: #
            print("==> Test")
            acc, err = test(model, testloader, use_gpu, dataset.num_classes, epoch)
            print("Accuracy (%): {}\t Error rate (%): {}".format(acc, err))
    #
    elapsed = round(time.time() - start_time)
    elapsed = str(datetime.timedelta(seconds=elapsed)) # 时间格式转换 例:datetime.timedelta(seconds=12345) -> 3:25:45
    print("Finished. Total elapsed time (h:m:s): {}".format(elapsed))

总结

至此解释完毕,字数有点多了,有点卡了。
下个博客再讲如何运行。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐