Part4-2.对建筑年代的预测结果进行分析:绘制混淆矩阵、计算分类报告,绘制空间分布

2023-11-24 17:09:56 浏览数 (1)

本文为《通过深度学习了解建筑年代和风格》论文复现的第六篇——对建筑年代深度学习模型的进行评价,我们首先会通过对测试数据集的预测来展示模型的预测能力,其中,我们会介绍对模型进行评估的几种方法,包括混淆矩阵、召回率 (Recall)、精确度 (Precision)、F1分数 (F1 Score),然后,我们会利用类激活映射(Class Activation Mapping,简称 CAM)查看模型关注哪些方面,最后从空间上观察建筑年代的预测结果在空间上的表现。

本文主要对应的是论文[1]5. Results 部分,会复现以下几张图:

⬇️模型预测可视化结果


⬇️ 表 4 混淆矩阵(百分比)


⬇️ 图 10 CAM去识别不同年代模型的关注点

左侧小图是将CAM 叠加在原始图像上。图像的红色区域主要覆盖一楼和二楼之间的窗户或门。右侧小图:根据 CAM 裁剪的图像显示了窗户的演变。早期的窗户通常框架较宽,装饰较多,而且较窄。最近的窗户样式以方形和水平形状为特点,框架更薄,装饰更少,深度更小。

⬇️ 图7 阿姆斯特丹市中心建筑年代预测结果空间分布

建筑年代预测结果的空间分布 蓝色表示旧建筑被预测为新建筑,而粉色表示模型将新建筑预测为旧建筑。灰色表示预测正确。

⬇️图8 :建筑年代预测结果在150米网格范围的准确度

长文预警,大约需要50分钟。建议先点赞收藏再看

目录

  • 一、加载测试数据集
    • 1.1 读取阿姆斯特丹的街景数据并选出测试集
    • 1.2 获取建筑年代类别名称和其映射关系字典
    • 1.3 自定义Dataset
    • 1.4 定义transform并加载测试集
    • 1.5 平衡数据集
  • 二、加载模型
    • 2.1 使用 load_state_dict 加载模型
    • 2.2 创建DataLoader
  • 三、开始预测
    • 3.1 对整个测试集进行预测
    • 3.2 可视化某一批次图像的预测结果
  • 四、混淆矩阵、召回率、精确度、F1分数
    • 4.1 概念解释
    • 4.2 读取预测结果
    • 4.3 使用sklearn创建混淆矩阵
    • 4.4 使用seaborn进行可视化
    • 4.5 通过混淆矩阵分析模型预测结果
    • 4.6 使用sklearn生成各种分类指标
    • 4.7 使用分类报告分析模型预测结果
    • 4.8 可以进一步优化的地方
  • 五、类激活映射
    • 5.1 使用"frgfm/torch-cam" 库对单个图像进行测试
    • 3)我们将CAM嵌入评估流程中
    • 4)分别绘制9个年代的CAM图
  • 六、空间分布
    • 6.1 建筑年代预测结果的空间分布
    • 6.2 绘制建筑年代预测结果在150米网格范围的准确度
  • 写在最后

一、加载测试数据集

1.1 读取阿姆斯特丹的街景数据并选出测试集

论文中选择了20%的图像来进行验证,也就是我们代码中的测试集。

由于我们固定了随机种子torch.manual_seed(8),所以我们现在的测试集test_data_raw是没有被模型训练过的,也就是说,我们的模型还没有见过测试集的数据。

代码语言:javascript复制
# 重新加载
import torch
from torch import manual_seed
from torchvision import datasets, transforms
from torch.utils.data import DataLoader, random_split, Dataset

img_root = '/root/autodl-tmp/GSV/clip' # r"../../data/GSV/clip" 
all_data = datasets.ImageFolder(root=img_root)  # 不要应用tranform

# 拆分数据
train_size = int(0.8 * len(all_data))
test_size = len(all_data) - train_size

# 固定随机种子
torch.manual_seed(8)
train_data_raw, test_data_raw = random_split(all_data, [train_size, test_size])

我们看一看测试集的总数:

代码语言:javascript复制
len(test_data_raw)
>>> 15911

15911条测试集。

1.2 获取建筑年代类别名称和其映射关系字典

代码语言:javascript复制
# 数据集的类别名称
class_names = all_data.classes

# 数据集的类别的字典形式
class_dict = all_data.class_to_idx
print(class_dict)
代码语言:javascript复制
{'1653–1705': 0, '1706–1764': 1, '1765–1845': 2, '1846–1910': 3, '1911–1943': 4, '1944–1977': 5, '1978–1994': 6, '1995–2023': 7, 'pre-1652': 8}

要注意pre-1652被排到了末尾。

1.3 自定义Dataset

为了能够进行后续的空间分析,我们需要建筑的id来进行定位,所以我们进一步修改CustomDataset类中的__getitem__方法,用来从Dataloader中获取数据时,不仅能返回image_tensor和Label,还能返回图像文件名中的建筑id。

图像的文件名比如“subset_1--11739--363100012571333--2023-03”使用“--“分割的字符串,建筑id我们只需要使用split从图像文件名中提取。

在预测过程中,我们会在预测中收集对应建筑id,并在所有预测完成后将它们预测结果、真实标签一起保存到CSV表格文件中。我们来修改CustomDataset类:

代码语言:javascript复制
class CustomDataset(Dataset):
    """包装PyTorch数据集以应用转换。"""
    def __init__(self, subset, transform=None):
        self.subset = subset
        self.transform = transform
        self.imgs = subset.dataset.imgs

    def __getitem__(self, index):
        img, y = self.subset[index] # 这里的y是类别的索引
        
        if self.transform:
            img = self.transform(img)
#####################仅修改下列代码#####################
        # 获取文件名
        file_name = self.imgs[self.subset.indices[index]][0]  # 通过传入的index来定位到图像的文件名
        # 通过split分割字符串获取文件名中的id
        id = file_name.split('--')[-2]
        return img, y, id
#####################仅修改以上代码#####################

    def __len__(self):
        return len(self.subset)

1.4 定义transform并加载测试集

代码语言:javascript复制
# 只需要调整尺寸和转换为张量
test_transform = transforms.Compose([
        transforms.Resize(size=(400, 400), antialias=True), 
        transforms.ToTensor()
    ])

# 加载数据集
test_data = CustomDataset(test_data_raw, transform=test_transform)

我们测试一下能不能获取到正确文件名:

代码语言:javascript复制
# 获取数据集中的前几个项
for i in range(5):  # 检查前5项
    img, y, id = test_data[i]
    print(f"Item {i}:")
    print(f"    ID: {id}")
    print(f"    Label: {y}")
    # 如果图片是一个张量,您可以打印其形状
    print(f"    Image shape: {img.shape if hasattr(img, 'shape') else 'not a tensor'}")
    print("n")

OUT:

代码语言:javascript复制
Item 0:
    ID: 363100012242337
    Label: 7
    Image shape: torch.Size([3, 400, 400])
Item 1:
    ID: 363100012100709
    Label: 7
    Image shape: torch.Size([3, 400, 400])
Item 2:
    ID: 363100012069617
    Label: 5
    Image shape: torch.Size([3, 400, 400])
Item 3:
    ID: 363100012115277
    Label: 4
    Image shape: torch.Size([3, 400, 400])
Item 4:
    ID: 363100012074646
    Label: 4
    Image shape: torch.Size([3, 400, 400])

1.5 平衡数据集

虽然测试集数据也不平衡,但是测试集反映的是真实世界的情况,我认为不需要进行数据平衡,在代码中就没必要应用随机采样(WeightedRandomSampler)去平衡数据。

二、加载模型

2.1 使用 load_state_dict 加载模型

上文[2]我们将模型结果保存为了字典:‘model.pth’。在Pytorch中,我们重新使用模型需要定义相同的模型架构,并且加载模型的字典数据。所以,我们会重新加载densenet121模型构架,然后将模型的最后一层分类器调整为9类,最后加载字典:

代码语言:javascript复制
from torchvision.models import densenet121
from torchvision.models.densenet import DenseNet121_Weights
import torch
import torch.nn as nn

# 定义使用gpu还是cpu
device = "cuda" if torch.cuda.is_available() else "cpu"

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

# 修改最后一层的输出特征数
num_features = model.classifier.in_features
# 将原始的densenet121的1000个类别修改为9个类别,保证模型网络结构一致
model.classifier = nn.Linear(num_features, 9)

# 加载建筑年代的模型
model_path = '../models/weights_6/model_epoch_32.pth' # 修改为你保存的模型字典地址
model.load_state_dict(torch.load(model_path, map_location=device)) # map_location字段避免无GPU的电脑出错,因为此模型默认加载在cuda中。

接下来需要调整到评估模型,这样不会对模型的参数进行更新:

代码语言:javascript复制
# 调整到eval评估模式
model.eval()
# 将模型发送到指定设备(gpu)
model.to(device)

2.2 创建DataLoader

我在云端的4090显卡上运行的,你可以根据情况调整BATCH_SIZEnum_workers

代码语言:javascript复制
BATCH_SIZE = 128

test_loader = DataLoader(test_data, batch_size=BATCH_SIZE, shuffle=False, num_workers=10)
len(test_loader) # 等于数据集总长度除以每批次的大小(BATCH_SIZE)

OUT:

代码语言:javascript复制
166

BATCH_SIZE大写代表常量,即不需要修改的变量。在Python的官方风格指南PEP8建议使用全部大写的方式来命名常量。如果你对PEP8感兴趣可以阅读PEP8官方文档[3]

三、开始预测

3.1 对整个测试集进行预测

我们预测图像的最终目标是获取每个图像的预测标签,用来对比是否和真实标签相等,从而进行接下来的分析。

我们先定义三个空列表,储存真实标签、预测标签和建筑id:

代码语言:javascript复制
true_labels = []
pred_labels = []
ids_list = []

然后我们会用for循环遍历166个DataLoder,每个DataLoder中有128个图像(BATCH_SIZE 的大小):

代码语言:javascript复制
from tqdm import tqdm # 用来显示进度条

with torch.inference_mode():
    for images, labels, id in tqdm(test_loader, desc="Predicting"): # 遍历test_loader会使用__getitem__方法,返回img, y, id
        
        # 将数据移动到GPU上(如果可用)
        images, labels = images.to(device), labels.to(device)

        # 运行模型以获取预测(向前传递)
        outputs = model(images)

        # 使用argmax获取最大值的索引
        test_pred_labels = outputs.argmax(dim=1)
        
        # 将预测标签、真实标签和建筑id添加到列表中
        true_labels.extend(labels.cpu().numpy())
        pred_labels.extend(test_pred_labels.cpu().numpy())
        ids_list.extend(id)
        
    # 如果您想查看这一批的结果,可以打印或处理这些列表
    # print("真实标签", true_labels)
    # print("预测标签:", pred_labels)
    # print("建筑id", ids_list)

在我们的代码中,true_labels和pred_labels是一维数组,ids_list是列表。

1)logits > pred_labels

重点说一下如何通过模型的预测结果(output,称为logits,原始输出)得到它的预测标签(test_pred_labels):

在我们的多类分类问题中,模型的输出是一个的概率分布,表示9个类别的预测概率。变量 outputs 是一个二维张量,其中包含了批次中每个样本对应每个类别的预测分数或概率。第一维(dim=0)表示批次中的样本索引。第二维(dim=1)表示每个类别的预测分数。

例如,我们有一个批次大小为 32 的数据,且分类问题有 9 个类别,那么 outputs 的大小是 [32, 9]使用argmax 函数: argmax(dim=1) 在类别的维度上找到最大值索引。然后,在这种情况下,在它每一行(对应一个样本的所有类别预测)上找到最大值的索引。这个索引实际上是模型预测的类别标签(0-8)。

所以最终, test_pred_labels 包含每个输入样本的预测标签。这些标签是根据模型给出的最高分数(概率)选择的类别。如果你有一个 [32, 9] 的输出,那么 test_pred_labels 将是一个长度为 32 的一维数组,每个元素都是 0 到 8 之间的一个整数(对应 9个类别)。

2)将测试集的数据保存为表格

可以将预测结果保存为表格,方便后续加载。

代码语言:javascript复制
import pandas as pd

# 创建一个数据框来保存文件名和预测
df_predictions = pd.DataFrame({
    'id': ids_list,
    'prediction': pred_labels,  # 这是之前收集的预测列表
    'true_label': true_labels  # 这是之前收集的真实标签列表
})

# 将数据框写入CSV文件
df_predictions.to_csv('predictions_with_building_age_model_6_on_test_data.csv', index=False)

3.2 可视化某一批次图像的预测结果

我们直接matplotlib用绘制结果,但是,数据集太大了,我们只想绘制某一批次的数据。所有我们先从DataLoader取出一些数据:

1) 使用迭代器

我们使用从DataLoader中抽取第一批数据来进行绘制。但是DataLoader并不是列表,也不是迭代器,是一个Pytorch的DataLoader对象,为了能够从中取出数据,需要先使用iter()DataLoader转换为迭代器(也称为生成器,它的特性是不会将数据全部加载到内存,调用它的时候才会进入内存),然后进行for循环遍历,或者直接使用next()获取迭代器的下一个批次的数据,第一次调用next()则获取第一批数据。

继续使用next()会获取第二批数据,以此类推。

看看我们的代码实现,在下列代码中,如果你是在jupyter notebook中运行,我们先将num_workers设为0以避免多线程bug:

自定义数据集时并且自定义数据集的函数不在当前单元格、同时num_workers大于0就会出现此bug:当您在使用DataLoader时设置num_workers大于0以使用多个子进程加载数据时,PyTorch 使用 multiprocessing 来创建这些子进程。但是,multiprocessing 需要能够从主进程中找到并加载任何自定义函数或类,这在 Jupyter Notebook 或其他交互式环境中可能会出问题。两种解决方法:

  1. 设置num_workers=0
  2. 将自定义数据集CustomDatasets()放入__name__ == '__main__'中。
代码语言:javascript复制
BATCH_SIZE = 8 # 此处代表你要绘制的多少图像的预测结果

test_loader = DataLoader(test_data, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)  

if __name__ == '__main__': # 以尝试将启动训练过程的代码放入此保护块中。这有助于防止 multiprocessing 在它不应该这样做的时候启动新进程。
    test_data_iter = iter(test_loader)
    test_samples, test_labels, ids = next(test_data_iter) # next() 函数是用来获取迭代器的下一个批次的数据
    print(test_samples.shape, test_labels.shape, len(ids) )
    # print(test_samples, test_labels, ids) # 可以打印具体结果

OUT:

代码语言:javascript复制
torch.Size([8, 3, 512, 512]) torch.Size([8]) 8

test_labels是8个一维数组代表真实标签,ids_list是自定义dataset返回的列表,此时返回包含8个建筑id的列表,如果想保持他们的一致性,我们也可在自定义数据集中将ids_list定义为一维数组。

其中,test_samples是一个有四个维度的张量,每个维度的大小分别为 8、3、512 和 512,[BATCH_SIZE, C, Height, Width],这种维度设置通常是在深度学习框架中使用的“NCHW”(或“BHWC”)数据格式,其中N/B表示批量大小、C表示通道数、H表示高度、W表示宽度。

2) 将预测的标签从索引转到其真实名称

我们要在图片上显示出建筑id、预测和真实类别,但是现在的test_labels还是索引值,我们要从class_dict获取真实年代标签进行替换,方便阅读:

代码语言:javascript复制
class_dict ={'1653–1705': 0,
 '1706–1764': 1,
 '1765–1845': 2,
 '1846–1910': 3,
 '1911–1943': 4,
 '1944–1977': 5,
 '1978–1994': 6,
 '1995–2023': 7,
 'pre-1652': 8}
# 创建一个值到键的反向映射 方便取值
reverse_dict = {value: key for key, value in class_dict.items()}
reverse_dict

out:

代码语言:javascript复制
{0: '1653–1705',
 1: '1706–1764',
 2: '1765–1845',
 3: '1846–1910',
 4: '1911–1943',
 5: '1944–1977',
 6: '1978–1994',
 7: '1995–2023',
 8: 'pre-1652'}
代码语言:javascript复制
# 通过反向映射,我们可以直接用值获取键,现在获取字典中键为整数0的值
key_with_value = reverse_dict[0]
key_with_value

OUT:

代码语言:javascript复制
'1653–1705'
3) 加载中文字体

matplotlib默认不支持中文字体,需要在代码中定义:

代码语言:javascript复制
from matplotlib.font_manager import FontProperties
font = FontProperties(fname=r"C:WindowsFontssimhei.ttf", size=10) # 选择你系统中的中文字体的路径,并且通过size定义大小
4) 预测并绘制
代码语言:javascript复制
from tqdm import tqdm 
import matplotlib.pyplot as plt
from matplotlib.font_manager import FontProperties
import numpy as np
# 存储真实标签 预测标签 文件名
true_labels = []
pred_labels = []
ids_list = []

images_so_far = 0
fig = plt.figure(figsize=(10, 20))
font = FontProperties(fname=r"C:WindowsFontssimhei.ttf", size=10)
num_images = 8

# # 可以继续调用获取第二批数据
next_samples, next_labels, ids = next(test_data_iter)
    
with torch.inference_mode():
        # 将数据移动到GPU上(如果可用)
        images, labels = next_samples.to(device), next_labels.to(device)
        # 运行模型以获取预测(向前传递)
        outputs = model(images)
        # 使用argmax获取最大值的索引
        test_pred_labels = outputs.argmax(dim=1)
        
        # 选择要显示的图片
        for j in range(images.size()[0]):
            images_so_far  = 1
            ax = plt.subplot(num_images//2, 2, images_so_far)
            ax.axis('off')
            pred_label = reverse_dict.get(int(test_pred_labels[j]))
            true_label = reverse_dict.get(int(labels[j]))
            ax.set_title(f'建筑id:{ids[j]}n预测类别: {pred_label},    真实类别: {true_label}', fontproperties=font)
            
            # 将图形转移到cpu 并且更改通道顺序 从[C, Height, Width]更改为[Height, Width, C]
            image = images.cpu().data[j].numpy().transpose((1, 2, 0))

            plt.imshow(image)   

可视化结果

四、混淆矩阵、召回率、精确度、F1分数

4.1 概念解释

1)混淆矩阵

混淆矩阵(Confusion Matrix)是在分类问题中用于评估模型性能的一种表格形式。它以实际类别(真实标签)和预测类别为基础,将样本的分类结果进行统计和总结。混淆矩阵的每一行代表了真实类别,每一列代表了预测类别。

混淆矩阵的常见形式如下,我写成英文更容易理解:

confusion matrix

用一个例子理解:

classifier

混淆矩阵中的四个关键术语是:

  • True Positive (TP): 即实际为正且被预测也为正的样本数。图中True Positives (TP) = 86。
  • False Positive (FP): 即实际为负但被错误地预测为正的样本数。图中True Negatives (TN) = 79。
  • False Negative (FN): 实际为正但被错误地预测为负的样本数。图中False Positives (FP) = 12。
  • True Negative (TN): 即实际为负且被预测为负的样本数。图中False Negatives (FN) = 10。

基于上述情况,我们可以定义(召回率、精确度和F1分数):

2)召回率 (Recall):
  • 概念:召回率衡量了所有真实为正的样本中,被模型正确预测为正的比例。
  • 公式:
Recall = True Positives (TP) / (True Positives (TP) False Negatives (FN))
  • 作用:召回率特别适用于那些错过真实正样本的代价很高的情境,例如疾病诊断。在这种情况下,我们更希望模型能够捕获所有可能的正样本,即使这意味着会有一些误报。
  • 在我们的例子中:Recall = 86 / (86 10) = 0.8983 = 89.83%
3)精确度 (Precision):
  • 概念:精确度衡量了被模型预测为正的样本中,真实为正的比例。
  • 公式:
Precision = True Positives (TP) / (True Positives (TP) False Positives (FP))
  • 作用:精确度特别适用于那些误报代价很高的情境,例如垃圾邮件检测。在这种情况下,我们不希望误将正常的邮件标记为垃圾邮件。
  • 在我们的例子中:Precision = 86 / (86 12) = 0.8775 = 87.75%
4)F1分数 (F1 Score)
  • 概念:F1分数是召回率和精确度的调和平均值,它试图在召回率和精确度之间找到一个平衡。
  • 公式:
F1 Score = 2 * Precision * Recall / (Precision Recall)
  • 作用:当我们需要同时考虑召回率和精确度时,F1分数是一个很好的指标。它特别适用于那些正负样本不均衡的数据集。
  • 在我们的例子中:F1-Score = (2* 0.8775 * 0.8983) / (0.8775 0.8983) = 0.8877 = 88.77%

总之,选择哪个指标取决于具体的应用场景和业务需求。

在某些情况下,我们可能更关心召回率(例如,医院确保所有患者都被正确诊断),而在其他情况下,我们可能更关心精确度(例如,确保只有真正的垃圾邮件被标记)。

当我们需要同时考虑召回率和精确度时,F1分数提供了一个综合的评估指标。

4.2 读取预测结果

我们将使用sklearn[4]提供的工具来计算混淆矩阵、召回率、精确度和F1分数。

代码语言:javascript复制
import pandas as pd
df = pd.read_csv('predictions_with_building_age_model_6_on_test_data.csv')
df.head()

df

代码语言:javascript复制
true_labels = df['true_label'].tolist()
pred_labels = df['prediction'].tolist()

因为我们的原始class_dict的pre-1652没有按照时间顺序排在最前,所以需要更改一下顺序,我们将“pre-1652”的索引从8更改为0,其他类别相应地向后移动:

代码语言:javascript复制
updated_class_dict = {
    0: '–1652',  # 这个现在是第一个
    1: '-1706',
    2: '-1765',
    3: '-1846',
    4: '-1911',
    5: '-1944',
    6: '-1978',
    7: '–1995',
    8: '–2023'  # 这个现在是最后一个
}

updated_true_labels = [(label   1 if label < 8 else 0) for label in true_labels]
updated_pred_labels = [(label   1 if label < 8 else 0) for label in pred_labels]

4.3 使用sklearn创建混淆矩阵

代码语言:javascript复制
from sklearn.metrics import confusion_matrix

conf_matrix = confusion_matrix(updated_true_labels, updated_pred_labels)
print("Confusion Matrix:")
print(conf_matrix)

Confusion Matrix

4.4 使用seaborn进行可视化

代码语言:javascript复制
import seaborn as sns
import matplotlib.pyplot as plt

# 设置可视化效果的样式
sns.set(style='whitegrid', palette='muted')

# 将混淆矩阵转换为DataFrame
conf_matrix_df = pd.DataFrame(conf_matrix, index=class_names, columns=class_names)

# 创建热图
plt.figure(figsize=(8, 6))
sns.heatmap(conf_matrix_df, annot=True, fmt='d', cmap='Blues')
plt.ylabel('Actual')
plt.xlabel('Predicted')
plt.title('Confusion Matrix')
plt.show()

Confusion Matrix

4.5 通过混淆矩阵分析模型预测结果

我们来分析一下我们的混淆矩阵:

  1. 主对角线:从左上角到右下角的数字表示模型正确预测的数量。
  2. 横轴和纵轴:横轴(Predicted)代表模型的预测类别,纵轴(Actual)代表实际的类别。
  3. 矩阵中的其他元素:表示模型的误判数量。
  4. 颜色:颜色深浅代表了数值的大小。深色代表数字大,浅色代表数字小。从图中可以看出,对角线上的颜色比较深,说明模型在这些类别上的预测较为准确。而其他位置的颜色较浅,表示误判的数量相对较少。

基于这个混淆矩阵,我们可以得出一些结论:

  • 主对角线表现:大部分的样本被正确地分类,这可以从对角线上的深蓝色区域看出。这说明模型在许多类别上的预测都是准确的。模型在“-1911”、“-1944”、“-1978”和”-2023“类别模型都有较高的准确率。
  • 误分类情况:尽管某些类别的预测准确性较高,但仍然存在一些误分类的情况。“-1911”、“-1944”类别,除了主对角线上的5781外,还有其他非对角线上的值如373、180、49和47,这表明这些实例被误分类到其他类别。
  • 难以分类的类别:某些类别如"-1652"、"-1706"、"-1765"、"-1846"在主对角线上的值相对较小,并且有多个非对角线的值也相对较大,这意味着模型在这些类别上的预测存在较大的困难。

4.6 使用sklearn生成各种分类指标

分类报告(classification report)为我们提供了每个类别的主要分类指标的细分,这有助于我们理解模型在预测每个特定类别时的性能:

代码语言:javascript复制
# 借助混淆矩阵计算各种分类指标(召回率、精确度和F1分数)
class_names = list(updated_class_dict.values())
report = classification_report(updated_true_labels, updated_pred_labels, target_names=class_names)

print("nClassification Report:")
print(report)

Classification Report

4.7 使用分类报告分析模型预测结果

从这个分类报告中,我们可以看出:

  1. 准确率 (Precision): 是模型预测为正例中实际也为正例的比例。该模型在不同的类别上的准确率有很大的差异。例如,"-1944" 类别的准确率高达 0.93,表示对于这个类别的预测很准确。然而,"-1652" 类别的准确率仅为 0.24,表示在预测为这个类别的结果中,有相当一部分是误判的。
  2. 召回率 (Recall): 是模型正确预测的正例占所有实际正例的比例。对于 "-1911" 类别,召回率达到了 0.83,这意味着模型能够捕获大部分真实的 "-1911" 样本。但对于 "-1706" 类别,召回率仅为 0.25,表示很多真实的 "-1706" 样本都没有被正确预测。
  3. F1得分 (F1-Score): 是准确率和召回率的调和平均值,用于考虑准确率和召回率之间的平衡。例如,"-1944" 类别的 F1得分为 0.91,表现很好。然而,"-1652" 类别的 F1得分为 0.22,这意味着这个类别的预测性能较差。
  4. 支持 (Support): 是每个类别的样本数量。从支持(support)列可以看出,类别 "-1944" 的样本数量最多,达到6488,而类别 "-1706" 的样本数量最少,仅为169。这种数据不平衡可能影响到模型的预测性能,特别是对于样本数量较少的类别。
  5. 总体准确率 (Accuracy): 是模型正确预测的样本占总样本的比例。模型的整体准确率为 0.82,表示模型在所有的预测中有 82% 是正确的。
  6. 宏平均 (Macro Avg): 是所有类别的平均准确率、召回率和F1得分。此模型的宏平均精确度、召回率和F1得分都为 0.59。这意味着在所有类别上,模型的平均性能是相对一致的。
  7. 加权平均 (Weighted Avg): 考虑到每个类别的样本数量,模型的加权平均精确度、召回率和F1得分都为 0.82。这与总体准确率相符,说明模型在样本数量较多的类别上的性能较好。

综上分类报告,我们可以进行总结模型的预测结果:

  1. 整体性能:模型的总体精确度为 82%,这意味着约 82% 的预测是正确的,这是一个相对较高的准确度。
  2. 最佳性能类别:模型在 "-1944", "-1978", 和 "-2023" 这三个类别上的预测性能较好,其中 "-1944" 的 F1-score 为 0.91,是所有类别中最高的。
  3. 需要改进的类别:模型在 "-1652", "-1706", 和 "-1846" 这三个类别上的预测性能较差,特别是在 "-1652" 上,其 F1-score 为 0.22,这意味着该类别的预测性能有待提高。
  4. 不平衡的性能:虽然 "-1911" 类别的 recall 达到了 0.83,但其 precision 为 0.74,这意味着模型更倾向于将样本分类为这个类别,但同时这也可能导致其他类别的误分类增加。

总的来说,虽然模型在某些类别上的预测性能较好,但在其他类别上仍存在改进的空间。但在某些类别上可能需要进一步优化。

对比论文中的模型评估结果(下图),我们的模型不够完美,差距还比较大:

论文评估结果

将我们的混淆矩阵转化为百分数:

混淆矩阵(百分比)

虽然我们和作者的数据集不一样,但是我的研究方法是没错的,如果后期学到更多处理技巧,或者发现错误,我会在我的博客[5]更新,也欢迎看到这里的大佬能给出一些优化意见!!!

4.8 可以进一步优化的地方

  1. 街景数据集:获取更多的-1652, -1706, -1765 和-1846四个类别街景图。
  2. 进一步达到数据平衡: 在不平衡的分类问题中,可以使用过采样、欠采样或合成数据技术,如SMOTE,来平衡数据。
  3. 建筑足迹数据有待优化:正如论文中所述:“BAG 数据集中建筑物的施工年份被定义为“建筑物最初或将在施工准备就绪时交付的年份”。建筑物的改建、扩建和增建不会改变原来的建造年份。这种限制反映在图 8 中,我们的年龄纪元估计模型对阿姆斯特丹市中心的预测不太准确,因为市中心的建筑物有更高的可能性进行翻新。”——更老的建筑也很有可能被翻新。

五、类激活映射

观察模型观察的是建筑的哪一个部分,有助于了解不同建筑年代的建筑局部的差异点在哪?是建筑外墙的材料?门或窗的形式?我们可以使用一种叫计算机视觉中的类激活映射(Class Activation Mapping,简称 CAM)技术的方法查看模型关注以上哪些方面。

类激活映射(Class Activation Mapping,简称 CAM)是一种在计算机视觉中广泛使用的技术,特别是在深度学习和卷积神经网络(CNN)的上下文中。它用于可视化输入图像的哪些部分被模型用来识别特定的类别。换句话说,CAM帮助我们理解模型的决策过程,特别是模型是如何从视觉信息中“学习”并做出分类决策的。

CAM 示例

我们可以自己“手搓”一个CAM,也可以直接用别人的开源项目,我在Github发现了两个高星CAM开源项目:frgfm/orch-cam[6]zhoubolei/CAM[7]

这两个库该选择哪一个?

"frgfm/torch-cam" 主要是一个为PyTorch用户设计的库,提供了一个模块化和易于集成的 CAM 解决方案。这个库是为PyTorch框架设计的,它提供了一个高度模块化和可定制的系统,可以轻松地与现有的PyTorch模型集成。如果你已经在使用PyTorch并且需要一个能够快速集成并且具有良好文档支持的解决方案,那么这个库可能是最佳选择。

而 "zhoubolei/CAM" 则更像是一个完整的研究项目,包含了从理论解释到实际应用的所有内容,使用的是 MATLABCaffe 框架,并提供了多种预训练模型。这个库提供了一个更全面的解决方案,包括预训练模型、详细的使用说明和评估脚本。它更多地侧重于教育和研究,提供了对CAM理论的深入理解。但是,它主要基于MATLABCaffe,我不熟悉这些工具。

最终我选择了"frgfm/torch-cam" 库,它使用了面向对象的方法,定义了一个基础类 _CAM,用于实现类激活映射(CAM)的核心功能。这种设计允许扩展不同类型的 CAM 方法。而且用户可以指定观察的目标层,或者让系统自动选择。

5.1 使用"frgfm/torch-cam" 库对单个图像进行测试

1)安装

可以使用pypi安装软件包的最新稳定版本:

代码语言:javascript复制
pip install torchcam
# 或使用conda:
conda install -c frgfm torchcam
2)定义CAM的目标层
代码语言:javascript复制
# 设置CAM
# step:1使用上文定义的模型并且设置为评估模式
from torchinfo import summary
summary(model=model, 
        input_size=(32, 3, 300, 300), # 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"]
) 

模型结构

在使用类激活映射(CAM)的情况下,通常会选择网络中的最后一个卷积层或与最后一个卷积层紧密相关的层作为目标层。这是因为这些层通常包含关于目标类的空间信息,这对于理解网络如何“看到”和识别特定特征是非常有用的。

在我们提供的 DenseNet 模型中,应该将目标层指定为最后一个 DenseBlock 或其内部的最后一个 DenseLayer。具体来说,这可能是denseblock4denseblock4 中的最后一个denselayer(例如 denselayer16)。这些层在空间分辨率上保留了足够的信息,同时包含了对模型决策至关重要的特征表示。

3)设置 CAM extractor
代码语言:javascript复制
from torchcam.methods import SmoothGradCAMpp
cam_extractor = SmoothGradCAMpp(model, target_layer='features.denseblock4') # 默认情况下,检索CAM的层被设置为最后一个非简化卷积层。如果希望研究特定的层,请在构造函数中使用 target_layer 参数。
4)读取图像并可视化
代码语言:javascript复制
from torchvision.io.image import read_image
from torchvision.transforms.functional import normalize, resize, to_pil_image

img = read_image("../../data/GSV/default_image.png")
img.shape

OUT:

代码语言:javascript复制
torch.Size([4, 512, 512])

我们读取的png也有alpha通道,需要删除:

代码语言:javascript复制
# 移除png的alpha通道
img = img[:3, :, :]
img.shape

OUT:

代码语言:javascript复制
torch.Size([3, 512, 512])

转换图像为tensor

代码语言:javascript复制
# Preprocess it for your chosen model
input_tensor = normalize(resize(img, (300, 300)) / 255., [0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
input_tensor.shape

OUT:

代码语言:javascript复制
torch.Size([3, 300, 300])

在Pytorch中最容易出错的地方之一就是tenor的形状。我们需要时刻注意。

5)执行CAM
代码语言:javascript复制
with SmoothGradCAMpp(model, target_layer='features.denseblock4') as cam_extractor:
    
    input_tensor = input_tensor.to(device)
    
    # 把图像的tenso喂给模型
    out = model(input_tensor.unsqueeze(0))
    # 通过传递类索引和模型输出来检索CAM(类激活映射)。
    activation_map = cam_extractor(out.squeeze(0).argmax().item(), out)
activation_map

activation_map

activation_map[0].shapetorch.Size([1, 9, 9]),第一个维度代表cam的值,在0-1之间,越高的代表模型的“关注度”越高,后两个通道是图片的分辨率:9x9的图。

代码语言:javascript复制
# 可视化你的热图 将其覆盖在输入图像上:
import matplotlib.pyplot as plt
from torchcam.utils import overlay_mask

# 调整CAM的大小使其能覆盖图像
result = overlay_mask(to_pil_image(img), to_pil_image(activation_map[0].cpu().squeeze(0), mode='F'), alpha=0.5)
# 绘制
plt.imshow(result); plt.axis('off'); plt.tight_layout(); plt.show()

3)我们将CAM嵌入评估流程中

参考以下代码:

详见4.1.3-建筑年代模型评价[8],可以当做练习。

代码语言:javascript复制
with SmoothGradCAMpp(model, target_layer='features.denseblock4') as cam_extractor:
        # 将数据移动到GPU上(如果可用)
        images, labels = next_samples.to(device), next_labels.to(device)

        # 运行模型以获取预测(向前传递)
        outputs = model(images)

        # 使用argmax获取最大值的索引
        test_pred_labels = outputs.argmax(dim=1)
        
        # 选择分析CAM
        for j in range(images.size()[0]):
            if images_so_far == num_images:
                break  # 如果我们达到了所需的图像数量,就停止
            
            images_so_far  = 1
            ax = plt.subplot(num_images//2, 2, images_so_far)
            ax.axis('off')
            
            # 获取原始图像和对应的输出
            img = images[j] # torch.Size([3, 300, 300])
            output = outputs[j] # torch.Size([9])
            # print(img.shape, output.shape)
            
            # 获取CAM并将其应用到原始图像上
            activation_map = cam_extractor(output.squeeze(0).argmax().item(), output)
            # print(" to_pil_image(img):", to_pil_image(img)) # <PIL.Image.Image image mode=RGB size=300x300 at 0x1F3C85FFFD0>
            # print("activation_map:", activation_map) # tensor列表
            # print("activation_map[0]:", activation_map[0].shape) # torch.Size([8, 9, 9])
            # [0, :, :] 选择第一个元素,并丢弃第一个维度
            #print("activation_map[0][0, :, :]:", activation_map[0][0, :, :].shape) # torch.Size([9, 9])
            result = overlay_mask(to_pil_image(img), to_pil_image(activation_map[0][0, :, :].cpu(), mode='F'), alpha=0.5)

            plt.imshow(result)

4)分别绘制9个年代的CAM图

参考论文的图 10(下图):

  1. 左侧四张小图是将CAM 叠加在原始图像上。图像的红色区域主要覆盖一楼和二楼之间的窗户或门。
  2. 右侧四张小图:根据 CAM 裁剪的图像显示了窗户的演变。早期的窗户通常框架较宽,装饰较多,而且较窄。最近的窗户样式以方形和水平形状为特点,框架更薄,装饰更少,深度更小。

总的来说,图 10 显示了 的CAM结果突出显示了模型关注的对象集中在一楼和二楼之间的窗户或门,而不是建筑物的随机部分。街景图像前面的汽车、自行车和行人通常会被忽略,因为它们与建筑年龄无关。本质上,模型从图像中学习有效的特征,并自动忽略图像中的不相关信息。

制作这张图的方法很简单,我们挑选一些照片之后,通过PS绘制出下图(图10利用类激活(CAM)图像观察不同类别模型的关注点):

六、空间分布

6.1 建筑年代预测结果的空间分布

1)绘制思路

参考文中 图7 绘制市中心的建筑年代预测结果图。

蓝色表示旧建筑被预测为新建筑,而粉色表示模型将新建筑预测为旧建筑。灰色表示预测正确。

我们可以参考上图进行制作,流程大概是:对所有的建筑进行预测——对真实年代和预测的年代的类别进行差值计算——将上一步的结果和建筑足迹的空间数据进行连接——提取出市中心的范围,设置符号系统然后出图。

2)处理预测结果
a.对训练集进行预测

我们利用“三、进行预测”的方法对训练集进行预测

b.合并预测结果
代码语言:javascript复制
## 读取数据
import pandas as pd

df1 = pd.read_csv('predictions_with_building_age_model_6_on_test_data.csv')
df2 = pd.read_csv('predictions_with_building_age_model_6_on_train_data.csv')
df = pd.concat([df1, df2])
df.head()

df 未排序

c.将合并结果保存
代码语言:javascript复制
# 保存合并后的原始结果
df.to_csv('predictions_with_building_age_model_6_on_all_data.csv', index=False)

predictions_with_building_age_model_6_on_all_data.csv 可以点击

0 人点赞