本文为《通过深度学习了解建筑年代和风格》论文复现的第五篇——训练识别建筑年代的深度学习模型,我们会使用Python中的PyTorch
库来训练模型,模型将选用基于DenseNet121
的深度卷积神经网络(DCNN)作为骨干进行迁移学习,数据集采用Part3-2.获取高质量的阿姆斯特丹建筑立面图像(下)中获取的阿姆斯特丹的7万多张谷歌街景图像。在处理过程中我们会进一步优化模型,避免欠拟合和过度拟合,并且使用Tensorboard[2]实时查看训练过程。下篇文章[3]我们会对建筑年代的模型使用进行评价,并从空间角度进行分析。
目录:
- 2.1 模型的选择方法
- 2.2 DenseNet介绍
- 2.3 使用迁移学习
- 2.4 使用 torchinfo.summary() 查看模型信息
- 2.5 冻结层
- 2.6 更改分类器的输出特征数
- 2.1 街景数据集
- 2.2 加载数据
- 2.3 解决数据集不平衡的问题
- 2.4 定义数据增强转换函数
- 2.4 获取类名字典
- 3.1 优化器和损失函数的选择
- 3.1.2 优化器(Optimizer)
- 3.1.3 损失函数(Loss Function)
- 3.2 定义训练和测试步骤函数
- 3.3 提升训练速度
- 3.4 Tensorboard可视化训练过程
- 3.5 实时查看训练和分析结果
- 写在最后
阅读本文前必看知识点
- 01-PyTorch基础知识:配置好Pytorch环境、了解什么是张量(Tensor)、张量的基本类型、张量的运算、如何更改张量的形状。
- 深度学习的基本工作流程:权重 weights 和 偏置biases 是什么?了解训练模型的基本步骤: 1.向前传播——2.计算损失——3.归零梯度——4.对损失执行反向传播——5.更新优化器(梯度下降),如何使用模型进行于预测(推理),如何保存和加载PyTorch模型.
- 卷积神经网络分类:卷积神经网络中的输入层、卷积层、超参数Hyperparameters、激活函数、池化层、展平层是什么?什么是混淆矩阵?
- 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. Our model is designed based on the backbone of Dense Convolutional Network (DenseNet121) (Huang et al., 2017[8]). Four dense blocks are used in our network and each block is connected to every other block (Fig. 5[9]). Compared to other architecture, our model is able to achieve higher accuracy with fewer parameters. The model is able to classify photos of building façades into 9 classes, namely pre-1652, 1653–1705, 1706–1764, 1765–1845, 1846–1910, 1911–1943, 1944–1977 and 1978–1994, 1995–2020."——引用自论文
综上,我们既要去识别建筑的年代,也要识别建筑风格。同时,为了实现模型的尽快收敛(损失降低),我们会选择在已有的模型上进行训练。
二、模型选择和修改
2.1 模型的选择方法
作者说明了DenseNet121模型的好处:“与其他架构相比,我们的模型能够以更少的参数实现更高的精度”,所以选择了DenseNet121模型,那如果我们自己做研究,该如何选择模型呢?
首先我们得理解研究中的问题并且明确目标,我们处理的是图像分类任务。同时,考虑我们的项目需求(例如准确率、计算资源和时间等)以及模型的复杂性。我们要识别图片中的模型,最好使用卷积神经网络(CNN),CNN可高效的识别图像的模式。
其次,我们得去了解主流的CNN网络有哪些,然后通过比较不同模型在你的数据集上的表现来找到最适合你项目的模型。我们可以考虑ResNet (残差网络),VGG (Visual Geometry Group Network)、EfficientNet和Inception (GoogLeNet),以上模型通常都有在大型图像数据集(例如 ImageNet)上预训练的版本,可以为建筑年代识别任务提供良好的起点。同时,也建议查看相关领域的最新研究和文献,以了解可能有哪些特定于建筑风格识别的模型或技术。
- ResNet (残差网络):
- ResNet 是一个深度残差网络,它通过引入“残差学习”来解决深度网络中的梯度消失和梯度爆炸问题。它在图像识别和分类任务中表现出色,也被广泛应用于其他计算机视觉任务。
- VGG (Visual Geometry Group Network):
- VGG 是一个深度卷积神经网络,它在图像识别和分类任务中也有很好的表现。它的结构简单、易于理解,是一个不错的迁移学习基础模型。
- EfficientNet:
- EfficientNet 是一个轻量级但性能强劲的网络结构。它通过自适应的调整网络的深度、宽度和分辨率来实现高效的学习。这种网络可能对于资源有限但需要高效模型的项目特别有用。
- Inception (GoogLeNet):
- Inception 网络是一个深度卷积神经网络,它通过引入了“网络中的网络”结构来提高模型的性能。它在多个图像识别和分类任务中取得了很好的效果。
- DenseNet:
- DenseNet 是一个密集连接的卷积网络,它通过直接将所有层连接在一起来提高信息流的效率。它在图像分类和其他计算机视觉任务中也表现出色。
在Pytorch网站中,列举了所有的预训练分类模型,你可以访问网站classification models[10]查,同时也列出来预算量模型的详细参数,包括准确度、参数(Params)等:
2.2 DenseNet介绍
DenseNet(Densely Connected Convolutional Networks)[11]是一种深度卷积神经网络架构,其主要特点是每一层都与之前的所有层直接相连。这种密集连接的方式可以增强特征的传播,鼓励特征重用,并大大减少参数数量。
一个具有生长率为 k=4 的5层密集块。每层都将所有前面的特征图作为输入。
上图一个表示DenseNet
中的“密集块”(Dense Block
)的图,我们一点点来解释:
- 输入与输出: 图中的红色矩形代表输入特征图。随着每一层的前进,可以看到新的特征图(不同颜色的矩形)正在生成。
- 密集连接: 与传统的深度卷积神经网络不同,
DenseNet
中的每一层都直接与之前的所有层连接,这意味着每一层都接收到了所有之前层的特征图作为输入。 - 增长率 (Growth Rate): 增长率 k 是一个超参数,表示每一层增加的特征图的数量。在此图中,增长率 k 为4,这意味着每一层都会生成4个新的特征图。
- BN-ReLU-Conv: 这是一个组合操作,其中BN代表批量归一化,
ReLU
代表修正线性单元激活函数,Conv
代表卷积操作。这是DenseNet
中每一层的典型操作序列。 - 转换层 (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
中,DenseNet
和dense 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() 查看模型信息
代码语言:javascript复制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模块中各层的名称:
代码语言:javascript复制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实现冻结层很简单:
代码语言:javascript复制# 冻结所有层
for param in model.parameters():
param.requires_grad = False
# 但是,我们想训练模型的最后一层,所以我们解冻这一层
for param in model.classifier.parameters():
param.requires_grad = True
使用 torchinfo.summary() 重新查看模型信息:
我们可以看到,我们的模型现在只有最后一层分类器的参数是可训练的。可训练参数量也减少了很多,从7,978,856减少到了1,025,000。
冻结一定数量的层确实可以减少运算量,但是也会造成模型缺乏学习能力,模型准确度下降。在使用预训练模型进行微调时,选择冻结的层和解冻的层通常取决于您的特定任务和所拥有的数据量。
但是在本次模型训练中,如果只训练模型的最后一层,模型会欠拟合(如下图最左侧的图)(训练准确率与测试准确率接近但都较低),表明模型没有足够的学习能力来捕捉数据中的模式。
我们回顾一下我们进行模型训练时的三种曲线:
不同的训练和测试损失曲线,说明过拟合、欠拟合和理想损失曲线
- 欠拟合的可能原因:
- 模型复杂度不够:可能的原因之一是模型的复杂度不足以捕捉数据的底层结构。这可能是因为模型太简单,无法捕捉数据中的所有复杂性。
- 不足的特征:如果您使用的特征不足以描述数据的复杂性,模型可能无法学习足够的信息来做出准确的预测。
- 训练不足:如果模型在训练期间没有足够的时间来学习数据,它可能会表现出欠拟合。您可以尝试增加训练周期(epochs)。
- 解决欠拟合:
- 增加模型复杂度:通过添加更多的层或单元、使用更复杂的网络结构来提高模型的学习能力。
- 特征工程:尝试使用更多或不同的特征集来改善模型性能。这包括创建新的特征、使用特征选择技术等。
- 更长的训练时间:增加训练周期,让模型有更多的时间来学习数据。
- 更换模型:如果当前模型不管怎样调整都表现不佳,可以考虑使用具有不同学习能力的模型。
- 数据增强:在图像领域,通过旋转、缩放、裁剪等技术增加训练数据可能会很有帮助。
最终我们会解冻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个类别。因此,我们需要更改最后一层的输出特征数。
代码语言:javascript复制# 获取最后一层的输入特征数
num_features = model.classifier.in_features
# 修改为9个类别的输出特征数
model.classifier = nn.Linear(num_features, 9)
二、数据准备
2.1 街景数据集
使用在上文Part3.获取高质量的阿姆斯特丹建筑立面图像(下)——《通过深度学习了解建筑年代和风格》[12]中获得的高质量的街景图像。
我们的文件夹格式
2.2 加载数据
由于我们的数据采用标准图像分类格式,因此我们可以使用 `torchvision.datasets.ImageFolder`[13] 在加载数据集的时候将建筑年代类别也一起加载。
在加载数据之前,我们得解决数据集各类别图像数量不平衡的问题:
2.3 解决数据集不平衡的问题
街景图像即训练数据数据集,我们已经获取并按标签分类保存,我们现在看一下各类数据的数量:
代码语言:javascript复制import numpy as np
# 获取每个类的样本数,以便进一步处理
class_counts = np.bincount([label for _, label in all_data.samples])
class_counts
OUT:
代码语言:javascript复制array([ 830, 1821, 1310, 14331, 32280, 10176, 6992, 10634, 1197],
dtype=int64)
我们将其可视化:
代码语言:javascript复制# 绘制直方图
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年的数据又非常的多,数据极不平衡。
我们看看他们的比重:
代码语言:javascript复制# 总量
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:
代码语言:javascript复制[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
对训练数据集定义采样权重:
代码语言:javascript复制
WeightedRandomSampler
是 PyTorch 中的一个采样器,用于对数据集进行加权随机采样。这在处理不平衡数据集时特别有用,因为它允许我们为每个数据点分配一个权重,从而影响其被采样的概率。
# 假设你有一个数据集,其中有两个类,第一个类有1000个样本,第二个类只有100个样本
# 你可以为每个类分配权重,例如:
weights = [0.1] * 1000 [1.0] * 100
sampler = WeightedRandomSampler(weights, num_samples=2000, replacement=True)
在上面的示例中,我们为第一个类的每个样本分配了较低的权重(0.1),而为第二个类的每个样本分配了较高的权重(1.0)。这意味着第二个类的样本被采样的概率要比第一个类的样本高得多。
❗需要注意的点:
- 样本数量:真正传入训练的样本与原始测试数据集的个数会不同。原因是在
WeightedRandomSampler
中,当replacement=True
时,某些数据点可能会被重复采样,而其他数据点可能不会被采样。这意味着有些数据可能永远不会进入测试加载器,从而不会被模型预测。 - 固定随机值:我们是在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
代码语言:javascript复制[95.86867469879518,
43.69632070291049,
60.7412213740458,
5.552368990300747,
2.46502478314746,
7.819477201257862,
11.38029176201373,
7.482697009591875,
66.47535505430243]
但是这样原本占比很高的比例在训练集中只有2.4,非常低,所以我们不直接采用倒数,转而进行简单加权:
代码语言:javascript复制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:
代码语言:javascript复制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对比
然后我们创建随机采样器,并且保证训练集的数量和训练样本权重计数相等:
代码语言:javascript复制# 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)