Part4-1.对建筑年代进行深度学习训练和预测

本文为《通过深度学习了解建筑年代和风格》论文复现的第五篇——训练识别建筑年代的深度学习模型,我们会使用 Python 中的PyTorch库来训练模型,模型将选用基于DenseNet121的深度卷积神经网络(DCNN)作为骨干进行迁移学习,数据集采用上一篇文章中获取的阿姆斯特丹的 7 万多张谷歌街景图像。在处理过程中我们会进一步优化模型,避免欠拟合和过度拟合,并且使用Tensorboard实时查看训练过程。下篇文章我们会对建筑年代的模型使用进行评价,并从空间角度进行分析。


阅读本文前必看知识点

  1. 01-PyTorch 基础知识:配置好 Pytorch 环境、了解什么是张量(Tensor)、张量的基本类型、张量的运算、如何更改张量的形状。
  2. 深度学习的基本工作流程:权重 weights 和 偏置biases 是什么?了解训练模型的基本步骤: 1.向前传播——2.计算损失——3.归零梯度——4.对损失执行反向传播——5.更新优化器(梯度下降),如何使用模型进行于预测(推理),如何保存和加载 PyTorch 模型。
  3. 卷积神经网络分类:卷积神经网络中的输入层、卷积层、超参数 Hyperparameters、激活函数、池化层、展平层是什么?什么是混淆矩阵?
  4. PyTorch 进行迁移学习:在预训练模型上进行训练:知道为何要进行迁移学习以及如何加载 Pytorch 预训练模型进行训练。

一、论文深度学习流程(框架)

论文中提到了一个包含两个阶段的框架,其中第一阶段是“深度学习”建筑,而第二阶段是“深度解读”建筑的年代和风格。在“深度学习”阶段,设计了一个深度卷积神经网络(DCNN)模型,该模型旨在从街景图像中学习阿姆斯特丹的建筑立面的年代特征,然后在此模型基础上,使用英国剑桥的建筑风格数据集进行建筑风格模型的训练。最后,使用斯德哥尔摩的街景数据进行建筑年代和风格的验证,用于证明两个城市的建筑年代和风格具有相似性。

斯德哥尔摩未找到建筑足迹数据,本系列文章不进行复现,并且从阿姆斯特丹的建筑年代和风格的模型构建中足以学会如何进行深度学习了。

论文中的模型是基于 DenseNet121 的骨干网络设计的,其中使用了三个密集块(dense blocks),并且每个块都与其他块相连。与其他架构相比,这种设计使模型能够在参数更少的情况下实现更高的准确性。该模型能够将建筑立面的照片分类为 9 个类别,分别是:pre-1652, 1653–1705, 1706–1764, 1765–1845, 1846–1910, 1911–1943, 1944–1977, 1978–1994, 以及 1995–2020。

With the dataset ready, we design a DCNN model for classifying building ages and architectural styles with street view images respectively. Though the method is applied to both tasks, this section will introduce the training process, evaluation of the results, and explorations on the trained model of building age epoch prediction task for clarity. It is worth noticing that the two models are independent of each other and only share the same methods during model training.”——引用自论文

Fig. 4

综上,我们先去训练模型识别建筑的年代,然后再去训练建筑风格,为了实现模型的尽快收敛(损失降低),我们会尽量在已有的模型上进行训练。

二、模型选择和修改

2.1 模型的选择方法

作者说明了 DenseNet121 模型的好处:“与其他架构相比,我们的模型能够以更少的参数实现更高的精度”,所以选择了 DenseNet121 模型,那如果我们自己做研究,该如何选择模型呢?

首先我们得理解研究中的问题并且明确目标,我们处理的是分类任务。同时,考虑我们的项目需求(例如准确率、计算资源和时间等)以及模型的复杂性。我们要识别图片中的模型,最好使用卷积神经网络(CNN),CNN 可高效的识别图像的模式。

其次,我们得去了解主流的 CNN 网络有哪些,然后通过比较不同模型在你的数据集上的表现来找到最适合你项目的模型。我们可以考虑ResNet (残差网络)VGG (Visual Geometry Group Network)EfficientNetInception (GoogLeNet),以上模型通常都有在大型图像数据集(例如 ImageNet)上预训练的版本,可以为建筑年代识别任务提供良好的起点。同时,也建议查看相关领域的最新研究和文献,以了解可能有哪些特定于建筑风格识别的模型或技术。

  1. ResNet (残差网络):
    • ResNet 是一个深度残差网络,它通过引入“残差学习”来解决深度网络中的梯度消失和梯度爆炸问题。它在图像识别和分类任务中表现出色,也被广泛应用于其他计算机视觉任务。
  2. VGG (Visual Geometry Group Network):
    • VGG 是一个深度卷积神经网络,它在图像识别和分类任务中也有很好的表现。它的结构简单、易于理解,是一个不错的迁移学习基础模型。
  3. EfficientNet:
    • EfficientNet 是一个轻量级但性能强劲的网络结构。它通过自适应的调整网络的深度、宽度和分辨率来实现高效的学习。这种网络可能对于资源有限但需要高效模型的项目特别有用。
  4. Inception (GoogLeNet):
    • Inception 网络是一个深度卷积神经网络,它通过引入了“网络中的网络”结构来提高模型的性能。它在多个图像识别和分类任务中取得了很好的效果。
  5. DenseNet:
    • DenseNet 是一个密集连接的卷积网络,它通过直接将所有层连接在一起来提高信息流的效率。它在图像分类和其他计算机视觉任务中也表现出色。

在 Pytorch 网站中,列举了所有的预训练分类模型,你可以访问网站classification models查,同时也列出来预算量模型的详细参数,包括准确度、参数(Params)等:

2.2 DenseNet 介绍

DenseNet(Densely Connected Convolutional Networks)是一种深度卷积神经网络架构,其主要特点是每一层都与之前的所有层直接相连。这种密集连接的方式可以增强特征的传播,鼓励特征重用,并大大减少参数数量。

一个具有生长率为 k=4 的5层密集块。每层都将所有前面的特征图作为输入。

上图一个表示DenseNet中的“密集块”(Dense Block)的图,我们一点点来解释:

  1. 输入与输出: 图中的红色矩形代表输入特征图。随着每一层的前进,可以看到新的特征图(不同颜色的矩形)正在生成。
  2. 密集连接: 与传统的深度卷积神经网络不同,DenseNet中的每一层都直接与之前的所有层连接,这意味着每一层都接收到了所有之前层的特征图作为输入。
  3. 增长率 (Growth Rate): 增长率 k 是一个超参数,表示每一层增加的特征图的数量。在此图中,增长率 k 为 4,这意味着每一层都会生成 4 个新的特征图。
  4. BN-ReLU-Conv: 这是一个组合操作,其中 BN 代表批量归一化,ReLU代表修正线性单元激活函数,Conv代表卷积操作。这是DenseNet中每一层的典型操作序列。
  5. 转换层 (Transition Layer): 这是DenseNet中的另一个重要组件,用于减少特征图的数量和大小,从而控制模型的复杂性。

一个包含三个密集块的深度密集网络。相邻块之间的层被称为过渡层,并通过卷积和池化改变特征图的大小。

Dense Blocks是 DenseNet 中的核心组成模块。在 Dense Block 中,每一层都可以访问所有先前层的特征图这种结构使得网络可以从每一层都获取到低级和高级的特征,实现了特征的重用和有效的梯度传播。Dense Blocks 的设计目的是为了解决深度卷积网络中的一些常见问题,如梯度消失和特征重用,从而提高网络的性能和训练效率。通过密集的连接模式,Dense Blocks 使得网络能够以更高效的方式训练,并且实现了更好的特征重用和传播。

2.3 使用迁移学习

图5.模型架构

图 5.模型架构,该模型以 GSV 图像作为输入,将建筑物立面照片分为 9 类。该网络是基于 DenseNet121 的主干网络设计的。

为了提高模型的性能,论文中应用了从 ImageNet 数据集上预训练的模型的迁移学习。ImageNet 数据集包含了各种常见的物体,通过在这个数据集上预训练的模型,可以更好地从图像中提取信息。使用迁移学习的好处是它可以加速收敛,需要更少的训练数据,并降低计算负担。

“We apply transfer learning from a model pre-trained on ImageNet dataset. The ImageNet dataset contains a wide range of common objects. The model pre-trained on this is able to understand objects and extract information from the images. Transfer learning allows us to fine-tune the base model and train the model to be more relevant to the task. More specifically, it updates the top layers of the neural networks, which are usually more specific to the training dataset. In general, transfer learning would lead to faster convergence, require less training data and lower the computational burden.”——引用自论文

PyTorch中,DenseNetdense blocks直接从torchvision.models模块中的预定义模型来调用。

DenseNet121 中的"121"代表该网络架构中的层的总数。对于 DenseNet,这些层数包括所有卷积层、正规化层、池化层和全连接层。

选择适当的 DenseNet 变体(如 DenseNet121、DenseNet169、DenseNet201 等)通常取决于数据集的大小计算资源任务的复杂性训练时间避免过拟合等因素,我们的数据集(7 万多照片)不算多,同时为了避免过拟合,我们使用参数较小的模型,所以我们选择DenseNet121_Weights.IMAGENET1K_V1,代表 121 层,权重等于IMAGENET1K_V1,也可以用DEFAULT

该模型提供一个transforms函数(模型训练完之后我才发现,有兴趣的可以调用)—— DenseNet121_Weights.IMAGENET1K_V1.transforms

该函数提供了预处理的转换操作。它可以接受PIL.Image,批处理的图像张量(B, C, H, W)以及单张图像张量(C, H, W)。首先,图片会被调整大小到[256],使用的插值方法是双线性插值(InterpolationMode.BILINEAR)。接下来,会从中心裁剪到[224]的大小。最后,图片的值首先会被重新缩放到[0.0, 1.0]范围,然后使用均值mean=[0.485, 0.456, 0.406] 和标准差std=[0.229, 0.224, 0.225]进行归一化处理。

我们在PyTorch中定义继承densenet121模型:

from torchvision.models import densenet121
from torchvision.models.densenet import DenseNet121_Weights

# 加载预训练的DenseNet121模型
model = densenet121(weights=DenseNet121_Weights.DEFAULT)

2.4 使用 torchinfo.summary() 查看模型信息

from torchinfo import summary
summary(model=model,
        input_size=(32, 3, 224, 224), # make sure this is "input_size", not "input_shape"
        # col_names=["input_size"], # uncomment for smaller output
        col_names=["input_size", "output_size", "num_params", "trainable"], # 输入特征、输出特征、参数数量,参数是否可训练
        col_width=20,
        row_settings=["var_names"]
)

DenseNet模型结构

在上图的模型结构中,您会看到多个 dense blocks,每个 block 由多个卷积层、批量归一化层和 ReLU 激活函数组成。后四列分别是输入特征、输出特征、参数数量,参数是否可训练,最后总结了模型的参数信息。

在 PyTorch 中,如果想要进一步探索嵌套在另一个模块中的层(例如,在 features 模块中),则需要进行递归遍历。以下是如何获取 features 模块中各层的名称:

def print_layers(module, parent_name=''):
    # 遍历当前模块中的所有子模块
    for name, sub_module in module.named_children():
        # 构造子模块的名称
        layer_name = f"{parent_name}.{name}" if parent_name else name

        # 打印子模块的名称
        print(layer_name)

        # 递归调用以遍历更深层的子模块
        print_layers(sub_module, parent_name=layer_name)

# 获取模型的 'features' 子模块
features = model.features

# 打印 'features' 子模块中的所有层的名称
# print_layers(features)

2.5 冻结层

使用迁移学习的目的是为了使用模型在对相类似数据集进行训练时的模型的权重,在本次 DenseNet 网络中,我们将冻结所有层,除了最后一层卷积层和全连接层。我们将在这些层上训练我们的模型。这意味着我们不会在训练过程中更新其它层的权重。

在 Pytorch 实现冻结层很简单:

# 冻结所有层
for param in model.parameters():
    param.requires_grad = False

# 但是,我们想训练模型的最后一层,所以我们解冻这一层
for param in model.classifier.parameters():
    param.requires_grad = True

使用 torchinfo.summary() 重新查看模型信息:

image-20231020115940645

我们可以看到,我们的模型现在只有最后一层分类器的参数是可训练的。可训练参数量也减少了很多,从 7,978,856 减少到了 1,025,000。

冻结一定数量的层确实可以减少运算量,但是也会造成模型缺乏学习能力,模型准确度下降。在使用预训练模型进行微调时,选择冻结的层和解冻的层通常取决于您的特定任务和所拥有的数据量。

但是在本次模型训练中,如果只训练模型的最后一层,模型会欠拟合(如下图最左侧的图)(训练准确率与测试准确率接近但都较低),表面模型没有足够的学习能力来捕捉数据中的模式。

我们回顾一下我们进行模型训练时的三种曲线:

不同的训练和测试损失曲线,说明过拟合、欠拟合和理想损失曲线

  1. 欠拟合的可能原因
    • 模型复杂度不够:可能的原因之一是模型的复杂度不足以捕捉数据的底层结构。这可能是因为模型太简单,无法捕捉数据中的所有复杂性。
    • 不足的特征:如果您使用的特征不足以描述数据的复杂性,模型可能无法学习足够的信息来做出准确的预测。
    • 训练不足:如果模型在训练期间没有足够的时间来学习数据,它可能会表现出欠拟合。您可以尝试增加训练周期(epochs)。
  2. 解决欠拟合
    • 增加模型复杂度:通过添加更多的层或单元、使用更复杂的网络结构来提高模型的学习能力。
    • 特征工程:尝试使用更多或不同的特征集来改善模型性能。这包括创建新的特征、使用特征选择技术等。
    • 更长的训练时间:增加训练周期,让模型有更多的时间来学习数据。
    • 更换模型:如果当前模型不管怎样调整都表现不佳,可以考虑使用具有不同学习能力的模型。
    • 数据增强:在图像领域,通过旋转、缩放、裁剪等技术增加训练数据可能会很有帮助。

最终我们会解冻 densenet121 模型中的所有层并且进行训练,只修改最后一层分类器的输出形状为建筑年代的类别总数——9。

2.6 更改分类器的输出特征数

“我们将建筑物年龄估计视为分类任务。阿姆斯特丹的大多数建筑物都是 1900 年以后建造的。由于市中心大部分建筑建于 1900 年之前,因此值得详细了解 1900 年之前的年代。结合建筑史和城市发展史,我们采用以下年代:早期(1652 年之前)、东部扩张(1653–1705)、法国影响时代(1706–1764)、南部扩张(1765–1845)、新时代(1846–1910)、两次世界大战期间(1911–1943)、战后(1944–1977)和当代时代(1978-1994,1995-2020)。”——来自论文

DenseNet 原始输出 1000 个类别,但是我们只需要预测 9 个类别。因此,我们需要更改最后一层的输出特征数。

image-20231020115940645

# 获取最后一层的输入特征数
num_features = model.classifier.in_features
# 修改为9个类别的输出特征数
model.classifier = nn.Linear(num_features, 9)

二、数据准备

2.1 街景数据集

使用在上文Part3.获取高质量的阿姆斯特丹建筑立面图像(下)——《通过深度学习了解建筑年代和风格》中获得的高质量的街景图像。

我们的文件夹格式

2.2 加载数据

由于我们的数据采用标准图像分类格式,因此我们可以使用 torchvision.datasets.ImageFolder 在加载数据集的时候将建筑年代类别也一起加载。

在加载数据之前,我们得解决数据集各类别图像数量不平衡的问题:

2.3 解决数据集不平衡的问题

街景图像即训练数据数据集,我们已经获取并按标签分类保存,我们现在看一下各类数据的数量:

import numpy as np
# 获取每个类的样本数,以便进一步处理
class_counts = np.bincount([label for _, label in all_data.samples])
class_counts

OUT:

array([  830,  1821,  1310, 14331, 32280, 10176,  6992, 10634,  1197],
      dtype=int64)

我们将其可视化:

# 绘制直方图
import matplotlib.pyplot as plt

plt.figure(figsize=(10, 7))
plt.bar(x=class_names, height=class_counts)

# 定义title
plt.title("Number of images per class")

# 定义x y轴label
plt.xlabel("Class")
plt.ylabel("Number of images")

Number of images

可以发现,1652 年之前、1653-1705 年和 1706-1764 年组的样本比其他组少的多,1911-1943 年的数据又非常的多,数据极不平衡。

我们看看他们的比重:

# 总量
total_count = len(all_data)
# 计算各类别的样本占比
class_proportions = [class_counts[i]/total_count for i in range(len(class_counts))]
# 转换成百分比 取两位小数
class_proportions = [round(i*100, 2) for i in class_proportions]
class_proportions

OUT:

[1.04, 2.29, 1.65, 18.01, 40.57, 12.79, 8.79, 13.36, 1.5]

我们需要对训练数据进行处理平衡处理,我们看看论文怎么处理的:

“不平衡的类数据集会导致模型的预测准确性出现偏差(Japkowicz & Stephen,2002)。为了解决这个问题,对样本较少的组进行数据增强。图像被水平翻转并分配原始标签。对于样本数量较多的组,我们随机从中选择数据。由此,准备了包含 39, 211 个样本的训练数据集用于模型训练。然后将数据集分为两部分:80% 用于建模训练过程,其余 20% 用于评估目的。”

论文手动减少数据量多的组的街景照片,然后对数据少的组的街景图片进行水平旋转,处理之后包含 39211 个样本数据集,其中 80% 用于训练,其余 20% 用于评估(测试)目的。

我们也可以手动处理,但是 Pytorch 也提供了相应的方法,分别是重新采样、数据增强和数据集随机分割的方法,整体思路是利用pytorch的采样器:WeightedRandomSampler对训练数据集定义采样权重:

WeightedRandomSampler 是 PyTorch 中的一个采样器,用于对数据集进行加权随机采样。这在处理不平衡数据集时特别有用,因为它允许我们为每个数据点分配一个权重,从而影响其被采样的概率。

# 假设你有一个数据集,其中有两个类,第一个类有1000个样本,第二个类只有100个样本
# 你可以为每个类分配权重,例如:
weights = [0.1] * 1000 + [1.0] * 100
sampler = WeightedRandomSampler(weights, num_samples=2000, replacement=True)

在上面的示例中,我们为第一个类的每个样本分配了较低的权重(0.1),而为第二个类的每个样本分配了较高的权重(1.0)。这意味着第二个类的样本被采样的概率要比第一个类的样本高得多。

❗ 需要注意的点:

  1. 样本数量:真正传入训练的样本与原始测试数据集的个数会不同。原因是在 WeightedRandomSampler 中,当 replacement=True 时,某些数据点可能会被重复采样,而其他数据点可能不会被采样。这意味着有些数据可能永远不会进入测试加载器,从而不会被模型预测。
  2. 固定随机值:我们是在 cpu 设备上使用 pytorch 加载的,可以使用torch.manual_seed(固定的种子值),例如 torch.manual_seed(42)来固定随机值。

继续,确定权重:我们简单的利用class_proportions的倒数来表示各类的占比:

class_weights = [total_count/class_counts[i] for i in range(len(class_counts))]
class_weights
[95.86867469879518,
 43.69632070291049,
 60.7412213740458,
 5.552368990300747,
 2.46502478314746,
 7.819477201257862,
 11.38029176201373,
 7.482697009591875,
 66.47535505430243]

但是这样原本占比很高的比例在训练集中只有 2.4,非常低,所以我们不直接采用倒数,转而进行简单加权:

original_weights = [total_count / class_counts[i] for i in range(len(class_counts))] # 计算每个类的权重
print("original_weights:", original_weights )

# 计算最大最小的权重
max_weight = max(original_weights)
min_weight = min(original_weights)

# 计算最大权重和最小权重之间的差异
diff_weight = max_weight - min_weight

# 计算要添加到每个较小权重的增量(例如,差异的一部分)
increment = diff_weight * 0.1 # 建议不超过0.4

# 调整权重,为较小的权重增加增量
adjusted_weights = [weight + increment if weight + increment <= max_weight else weight for weight in
                    original_weights]

print("adjusted_weights:", adjusted_weights)

OUT:

original_weights [95.84698795180722, 43.686436024162546, 60.72748091603054, 5.551112971879143, 2.465842167255595, 7.817708333333333, 11.377717391304348, 7.481004325747602, 66.46031746031746]
adjusted_weights [95.86867469879518, 53.03668569447527, 70.08158636561058, 14.892733981865518, 11.805389774712232, 17.159842192822634, 20.720656753578503, 16.823062001156647, 75.8157200458672]

0.1 比重下original_weight和adjusted_weights对比


然后我们创建随机采样器,并且保证训练集的数量和训练样本权重计数相等:

# random_split返回的是Subset对象,我们可以通过.indices属性来获取原始数据集中的索引
train_indices = train_data_raw.indices

# 现在,我们使用这些索引来从全部标签列表中提取训练集标签
train_labels = [all_labels[idx] for idx in train_indices]

# 计算训练样本权重
train_sample_weights = [adjusted_weights[label] for label in train_labels]

# 创建加权随机采样器以进行重采样
train_sampler = WeightedRandomSampler(train_sample_weights, num_samples=len(train_sample_weights), replacement=True)

print("Size of training data:", len(train_data_raw))
print("Number of sample weights:", len(train_sample_weights))
assert len(train_sample_weights) == len(train_data_raw)

# 使用自定义数据集类应用转换
train_data = CustomDataset(train_data_raw, transform=train_transform)
test_data = CustomDataset(test_data_raw, transform=test_transform)

# 创建DataLoader
BATCH_SIZE = 256 # 根据你的GPU情况调整
print("BATCH_SIZE", BATCH_SIZE)
train_loader = DataLoader(train_data, batch_size=BATCH_SIZE, sampler=train_sampler, num_workers=12) # num_workers根据cpu的数量调整
test_loader = DataLoader(test_data, batch_size=BATCH_SIZE, shuffle=False, num_workers=12)

📌 理解DataLoader 的目的很重要:它是一个可迭代的对象,让你能够以批次的形式轻松地加载数据集,而不必手动编写代码来控制这一过程。这对于机器学习和深度学习任务尤为重要,因为通常需要以批次的形式处理数据(例如,一次处理 32 张图像)。

2.4 定义数据增强转换函数

  • 将图形重采样到模型的原始大小(224, 224),antialias=True是一个质量增强选项,用于在缩放过程中减少锯齿状的边缘,使图像看起来更平滑
  • transforms.RandomHorizontalFlip(p=0.2): 这会以 20%的概率水平翻转图像。这是一种随机变换,用于训练模型以识别在水平方向上翻转的对象,有助于模型学习到对象的空间变化。
  • transforms.RandomVerticalFlip(p=0.2): 这会以 20%的概率垂直翻转图像。与水平翻转类似,它帮助模型学习到垂直方向的对象变化。
  • transforms.RandomRotation(degrees=45): 这会随机旋转图像,最多 45 度。这有助于模型学习方向变化,因为实际情况中,对象可能会以不同的角度出现。
  • transforms.ToTensor(): 这会将 PIL 图像或numpy.ndarray转换为张量,并且在进行转换时自动将图像的数据范围从[0, 255]缩放到[0.0, 1.0]。这是大多数神经网络期望的输入类型和值范围。
from torchvision import datasets, transforms

# 定义训练数据的转换函数
train_transform = transforms.Compose([transforms.Resize(size=(300, 300), antialias=True),
                                      transforms.RandomHorizontalFlip(p=0.2),
                                      transforms.RandomVerticalFlip(p=0.2),
                                      transforms.RandomRotation(degrees=45),
                                      transforms.ToTensor(),
                                      transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
                                     ])

# 定义测试数据的转换(如果需要的话,这里我们不应用任何转换)
test_transform = transforms.Compose([
    transforms.Resize(size=(300, 300), antialias=True),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
    # 可以添加其他转换,但在这个例子中我们不添加
])

2.4 获取类名字典

# 获取类名列表
class_names = all_data.classes
print(class_names)
# 数据集的类别的字典形式
class_dict = all_data.class_to_idx
class_dict
['18th-century_style', 'contemporary', 'early_19th-century_style', 'interwar', 'late_19th-century_style', 'postwar', 'revival']

{'18th-century_style': 0,
 'contemporary': 1,
 'early_19th-century_style': 2,
 'interwar': 3,
 'late_19th-century_style': 4,
 'postwar': 5,
 'revival': 6}

三、训练

3.1 优化器和损失函数的选择

在深度学习项目,特别是像文中描述的建筑年代分类任务中,选择合适的优化器和损失函数是至关重要的。这些选择直接影响模型的学习效率、训练时间以及最终性能。我们介绍一下适合我们深度学习任务的常用函数。

3.1.2 优化器(Optimizer)

  • Adam: 这是一种高效的随机优化方法,广泛用于深度学习。Adam 结合了 Adagrad 和 RMSprop 的优点,通过计算梯度的一阶矩估计和二阶矩估计来调整每个参数的学习率。由于它的这些特性,Adam 通常会比其他优化算法更快地收敛,并且是一个在各种情况下都表现良好的优化器,因此它可能是一个很好的选择。
  • SGD (随机梯度下降): 尽管它不如 Adam 那样先进,但在某些情况下,特别是当配合动量(momentum)使用时,SGD 可以与 Adam 媲美或者甚至超越 Adam。SGD 的一个主要优点是它的简单性,它更不容易陷入局部最优解。

选择哪一个优化器通常基于实验结果;不同的任务和数据集可能会偏好不同的优化器。

我们开始选用的是Adam优化器模型,学习率 0.001:

optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

但是误以为模型欠拟合之后我更换为了 SGD,并且引入了学习率调度器,让开始的几轮模型的学习率高,后面逐渐降低:

from torch.optim import SGD
from torch.optim.lr_scheduler import StepLR
optimizer = SGD(model.parameters(), lr=0.002, momentum=0.9)  # 使用更高的初始学习率
# 引入学习率调度器
scheduler = StepLR(optimizer, step_size=10, gamma=0.1)  # 每10个epochs降低当前学习率的10%

结果这两个优化器都可以。

3.1.3 损失函数(Loss Function)

  • 交叉熵损失(Cross-Entropy Loss): 对于分类问题,交叉熵损失是最常用的选择之一。它衡量的是实际输出与预期输出之间的差异。在多类分类任务中,使用 softmax 函数将模型的输出转换为概率分布,然后使用交叉熵损失来优化这些概率。这对于建筑年代分类来说是合适的,因为它允许模型明确地为每个类别分配一个概率。
  • NLL 损失(Negative Log Likelihood Loss): 这通常与 LogSoftmax 层一起使用,也是一个用于多分类任务的常见损失函数。
  • Focal Loss: 对于高度不平衡的数据集,Focal Loss 是一个改进的损失函数,它通过给那些难以分类的实例更多的权重来工作,但这通常用于对象检测任务。

对于本论文中的任务,最可能的选择是使用 Adam 或 SGD 作为优化器,并使用交叉熵损失。这是因为交叉熵损失在处理多类分类问题时表现出色,而 Adam 优化器因其快速收敛和适应不同数据特性的能力而被广泛采用。

损失函数的代码实现:

loss_fn = nn.CrossEntropyLoss()

3.2 保存训练结果

我们保存为 pytorch 字典形式:

model_save_path = '../models/weight'
torch.save(model.state_dict(), os.path.join(model_save_path, 'model.pth'))

3.3 定义训练和测试步骤函数

我们使用pytorch 迁移学习中定义的train_step()test_step()train()函数来训练模型,关于这些深度学习的更多知识建议浏览《使用 PyTorch 进行深度学习系列》课程。以下是train_step()test_step()train()的(去掉导入包和函数说明的)简化版:

def train_step(model, dataloader, loss_fn, optimizer, device):
    """对PyTorch模型进行单个epoch的训练。 """
    # 将模型设置为训练模式
    model.train()

    # 设置训练损失和训练准确率的初始值
    train_loss, train_acc = 0, 0

    # 遍历数据加载器中的数据批次
    for batch, (X, y) in enumerate(dataloader):
        # 将数据发送到目标设备
        X, y = X.to(device), y.to(device)

        # 1. 前向传播
        y_pred = model(X)

        # 2. 计算并累加损失
        loss = loss_fn(y_pred, y)
        train_loss += loss.item()

        # 3. 优化器梯度清零
        optimizer.zero_grad()

        # 4. 反向传播计算梯度
        loss.backward()

        # 5. 优化器更新参数
        optimizer.step()

        # 计算并累加准确率指标
        y_pred_class = torch.argmax(torch.softmax(y_pred, dim=1), dim=1)
        train_acc += (y_pred_class == y).sum().item() / len(y_pred)



    # 调整指标以获得每个批次的平均损失和准确率
    train_loss = train_loss / len(dataloader)
    train_acc = train_acc / len(dataloader)

    return train_loss, train_acc


def test_step(model, dataloader, loss_fn, device):
    """对PyTorch模型进行单个epoch的测试。    """
    # 将模型设置为评估模式
    model.eval()

    # 设置测试损失和测试准确率的初始值
    test_loss, test_acc = 0, 0

    # 打开推理上下文管理器
    with torch.inference_mode():
        # 遍历DataLoader中的数据批次
        for batch, (X, y) in enumerate(dataloader):
            # 将数据发送到目标设备
            X, y = X.to(device), y.to(device)

            # 1. 前向传播
            test_pred_logits = model(X)

            # 2. 计算并累加损失
            loss = loss_fn(test_pred_logits, y)
            test_loss += loss.item()

            # 计算并累加准确率
            test_pred_labels = test_pred_logits.argmax(dim=1)
            test_acc += ((test_pred_labels == y).sum().item() / len(test_pred_labels))

    # 调整指标以获得每个批次的平均损失和准确率
    test_loss = test_loss / len(dataloader)
    test_acc = test_acc / len(dataloader)
    return test_loss, test_acc

def train(
        model,
        train_dataloader,
        test_dataloader,
        optimizer,
        loss_fn,
        scheduler,
        epochs,
        device,
        model_save_path,
        save_interval=5,
        logs_path='/root/tb-logs/train_experiment',
):
    """训练和测试PyTorch模型 """

    writer = SummaryWriter(logs_path)

    # 确保保存路径存在
    if not os.path.exists(model_save_path):
        os.makedirs(model_save_path)

    best_model_wts = copy.deepcopy(model.state_dict())
    best_acc = 0.0  # 初始化最佳准确度

    # 创建空的结果字典
    results = {"train_loss": [],
               "train_acc": [],
               "test_loss": [],
               "test_acc": []
               }

    # 确保模型在目标设备上
    model.to(device)

    # 循环进行训练和测试步骤,直到达到指定的epoch数
    for epoch in tqdm(range(epochs)):
        train_loss, train_acc = train_step(model=model,
                                           dataloader=train_dataloader,
                                           loss_fn=loss_fn,
                                           optimizer=optimizer,
                                           device=device)
        # 记录训练的计算平均损失和准确度
        writer.add_scalar('Loss/train', train_loss, epoch)
        writer.add_scalar('Accuracy/train', train_acc, epoch)

        test_loss, test_acc = test_step(model=model,
                                        dataloader=test_dataloader,
                                        loss_fn=loss_fn,
                                        device=device)

        # 记录测试的计算平均损失和平均准确度
        writer.add_scalar('Loss/test', test_loss, epoch)
        writer.add_scalar('Accuracy/test', test_acc, epoch)

        # 打印当前进度
        print(
            f"Epoch: {epoch + 1} | "
            f"train_loss: {train_loss:.4f} | "
            f"train_acc: {train_acc:.4f} | "
            f"test_loss: {test_loss:.4f} | "
            f"test_acc: {test_acc:.4f}"
        )

        # 性能提升时保存模型
        if test_acc > best_acc:
            best_acc = test_acc
            best_model_wts = copy.deepcopy(model.state_dict())
            torch.save(model.state_dict(), os.path.join(model_save_path, 'best_model.pth'))

        # 定期保存模型
        if epoch % save_interval == 0:
            torch.save(model.state_dict(), os.path.join(model_save_path, f'model_epoch_{epoch}.pth'))

        # 更新结果字典
        results["train_loss"].append(train_loss)
        results["train_acc"].append(train_acc)
        results["test_loss"].append(test_loss)
        results["test_acc"].append(test_acc)

        # 在每个epoch结束时步进调度器
        scheduler.step()
        # 可选:打印当前学习率
        current_lr = scheduler.get_last_lr()
        print(f"Epoch {epoch + 1}: Current learning rate: {current_lr}")

    # 在训练结束后,我们也可以选择加载最佳模型权重
    model.load_state_dict(best_model_wts)

    # 在训练结束时返回填充的结果字典
    return results
# 你也可以保存上面三个函数到py文件中,然后导入上述三个函数
import sys
# 添加上一级目录到sys.path,在notebook中需要进行此操作才能从上层文件导入
sys.path.append("../../") # 两个点代表上一级目录,此处需要指定my_tools所在目录的根文件夹

from my_tools.engine import train_step, test_step, train # 将定义的根目录所在文件夹下my_tools文件夹中的engine.py脚本中的train_step, test_step, train函数导入

接下来进行训练:

# 设置随机种子 在调试或测试时固定随机种子是有用的,但在最终训练模型时可能需要重新引入随机性。
torch.manual_seed(8)
torch.cuda.manual_seed(8)

# 清空cuda缓存
torch.cuda.empty_cache()

# 定义device
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# 开始计时器
from timeit import default_timer as timer
start_time = timer()

# 开始训练并且保存结果
results = train(model=model,
                train_dataloader=train_loader,
                test_dataloader=test_loader,
                optimizer=optimizer,
                loss_fn=loss_fn,
                scheduler=scheduler,
                epochs=40,
                device=device,
                model_save_path="models/weights",
                save_interval=1,
                logs_path='../tensorboard-logs/1st'  # autodl的tensorboard路径日志:/root/tf-logs
                )

# 返回时间
end_time = timer()
print(f"[INFO] Total training time: {end_time-start_time:.3f} seconds")

3.4 提升训练速度

GPU 训练模型只推荐推荐 nvida 先看、12G 以上的显卡进行训练,虽然老黄不做人,矿卡都官方翻新了在卖,但是模型训练还是离不开 N 卡。
如果你的显存比较低,运行代码会爆显存:

torch.cuda.OutOfMemoryError: CUDA out of memory. Tried to allocate 118.00 MiB (GPU 0; 23.65 GiB total capacity; 22.92 GiB already allocated; 92.06 MiB free; 23.10 GiB reserved in total by PyTorch) If reserved memory is >> allocated memory try setting max_split_size_mb to avoid fragmentation.  See documentation for Memory Management and PYTORCH_CUDA_ALLOC_CONF

在不改变硬件的情况下,需要减小批次大小。更推荐的做法是去租用云 GPU,1~2 块一个小时。我使用的平台(此处没有广告)是AutoDL

AutoDL

AutoDL 快速开始:https://www.autodl.com/docs/quick_start/

除了直接使用 JupyterLab(方法见https://www.autodl.com/docs/jupyterlab/)之外,我还会使用FinalShell来通过ssh连接autodl的实例终端进行控制,本身一个autodl的实例就是Ubuntu系统,使用的都是Linux命令:

FinalShell

此处介绍一下必要的 Linux 命令,需要的可以看操作 AutoDL 等 Linux 系统 GPU 服务器进行深度学习的常用命令笔记

3.5 Tensorboard 可视化训练过程

TensorBoard 是一个可视化工具,用于深度学习训练过程中的指标跟踪、模型图形查看、嵌入式可视化等。

TensorBoard演示

它最初是为 TensorFlow 设计的,但也可以与其他框架一起使用,例如 PyTorch。以下是在 PyTorch 中使用 TensorBoard 的基本步骤:

  1. 安装 TensorBoard: 如果你还没有安装 TensorBoard,可以使用 pip 来安装:

    pip install tensorboard
    
  2. 设置 TensorBoard: 在 PyTorch 中,我们将使用 torch.utils.tensorboard 来集成 TensorBoard。首先,你需要导入并设置 TensorBoard writer:

    from torch.utils.tensorboard import SummaryWriter
    
    # 定义 TensorBoard writer
    writer = SummaryWriter('/root/tf-logs/train_experiment2')  # /root/tf-logs/train_experiment1是autodl平台tensorboard日志文件将被保存的目录
    
  3. 记录数据: 在训练循环中,你可以添加代码来记录想要监控的任何数据,如损失、准确率等。

    for epoch in range(num_epochs):
        for batch in dataloader:
            # ... 生成输出并计算损失等
    
            # 假设 loss 是你的损失变量
            writer.add_scalar('Loss/train', loss, epoch)
            # ... 其他代码,例如优化器步骤等
    

    你还可以添加更多的 TensorBoard 功能,比如记录模型图、多个指标或者甚至是图像。

  4. 查看 TensorBoard: 在你的终端或命令提示符中,导航到上面代码中 TensorBoard 日志文件所在的父目录,并启动 TensorBoard:

    tensorboard --logdir=/root/tf-logs
    

    然后,在浏览器中打开 TensorBoard 提供的 URL(通常是 http://localhost:6006/)。

  5. 关闭 TensorBoard writer: 在训练循环结束后,确保关闭 writer 以释放资源。

    writer.close()
    

如果你是在 AutoDL 平台进行训练,只需要将 tensorboard 的 event 文件保存到/root/tf-logs/路径,然后进入控制台的实例窗口,点击找到 AutoPanel 访问入口:

image-20220401160510193

然后点击 Tensorboard,当你的日志文件夹有文件时,就会在上面显示效果,这样就可以远程查看了。

我们把相应的代码加入训练代码中,整理之后的完整文件见代码的 train.pyengine.py ,如果要完整文件,请关注微信公众号renhailab,点赞本文之后发送私信:“20231031”到本公众号,即可获取相应链接。

3.6 实时查看训练和分析结果

使用上述 Tensorboard 查看得到的训练准确度、训练损失和测试准确度、测试损失,防止出现过度拟合或者欠拟合结果:


看起来还不错,在 15 次训练开始,测试的损失值出现波动,训练的损失还在下降,说明模型出现了过拟合状态。模型在测试数据上预测的不准,更具体的说。

通过混淆矩阵(制作混淆矩阵图的方法见下一篇:Part4-2.对建筑年代进行深度学习结果进行展示和分析(下))可以看出模型对于测试数据集中样本量少的四个建筑年代类别表现的不好:
train_experiment_5 混淆矩阵

出现上述结果的最大原因就是数据集不平衡,在第五次训练中,我并没有将训练模型的权重调的足够平衡:

  • 对于的类别是:[‘1653–1705’, ‘1706–1764’, ‘1765–1845’, ‘1846–1910’, ‘1911–1943’, ‘1944–1977’, ‘1978–1994’, ‘1995–2023’, ‘pre-1652’]
  • 当时的占比是:[1.04, 2.29, 1.65, 18.01, 40.57, 12.79, 8.79, 13.36, 1.5]
  • 权重是[64.59079283887468, 64.59079283887468, 64.59079283887468, 64.59079283887468, 64.59079283887468])

之后我直接将其权重设置成其占比的倒数:[95.86867469879518,43.69632070291049,60.7412213740458,5.552368990300747,2.46502478314746,7.819477201257862,11.38029176201373,7.482697009591875,66.47535505430243],再进行第 6 次训练(train_experiment_6)。

第六次的测试准确度有所降低,损失函数也偏高,从之后的混淆矩阵可以看出:

Confusion Matrix


上一篇:Part3-2.获取高质量的阿姆斯特丹建筑立面图像(下)——《通过深度学习了解建筑年代和风格》

下一篇:Part4-2.对建筑年代进行深度学习结果进行展示和分析——《通过深度学习了解建筑年代和风格》


因为其他平台不能同步修改,论文解读文章将最先在我的博客发布,你可以点击阅读原文查看博客上的原文。

写在最后

论文引用:

Maoran Sun, Fan Zhang, Fabio Duarte, Carlo Ratti,
Understanding architecture age and style through deep learning,
Cities,
Volume 128,
2022,
103787,
ISSN 0264-2751,
https://doi.org/10.1016/j.cities.2022.103787.
(https://www.sciencedirect.com/science/article/pii/S0264275122002268)


Part4-1.对建筑年代进行深度学习训练和预测
https://blog.renhai.online/archives/understanding-architecture-age-and-style-through-deep-learning-part4-1
作者
Renhai
发布于
2023年10月30日
更新于
2024年06月17日
许可协议