什么是U-Net?
U-Net是一种为图像分割任务设计的卷积神经网络(CNN)。它最初是为生物医学图像分割而开发的,其架构由编码器和解码器组成,这使得它呈现出有趣的U形外观。
数据准备
对于这个项目,我使用了以下数据集:
数据集被划分为70%的训练集、15%的验证集和15%的测试集。在将图像输入模型之前,我将像素值归一化到[0, 1]的范围内。
以下是输入-输出对的快速可视化:
类别不平衡
数据集中存在相当显著的类别不平衡问题,即非建筑像素的数量远远超过建筑像素——这是分割任务中非常常见的一个挑战。
处理类别不平衡
有很多方法可以解决类别不平衡问题,比如过采样、欠采样和数据增强。我尝试了一些这些方法,但对我的模型帮助最大的是创建了一个特殊的损失函数,它结合了二元交叉熵(BCE)和Dice损失。
这是如何解决类别不平衡问题的?
基本上,损失函数通过衡量模型预测与真实值之间的差距来工作——而任何模型的目标都是最小化这种损失。
考虑到我们只关注二进制分割(区分建筑和背景),一个强有力的损失函数选择是二元交叉熵,因为它衡量的是像素级的损失,即它会查看每一个预测像素与真实像素之间的差距。但仅使用这种类型的损失存在一个问题,那就是损失将由多数类(背景像素)主导。
这就是Dice损失发挥作用的地方——Dice损失通过查看预测掩码和真实掩码之间的重叠来衡量损失。
为了理解Dice损失,首先让我们了解如何判断预测结果:
Dice系数(本质上是图像分割的F1分数)平衡了精确率(模型在标记建筑像素时的准确性)和召回率(模型识别出的实际建筑像素数量)。
基本上,这就像是在说——“让我们专注于建筑,看看我们在不犯太多错误的情况下能正确识别出多少建筑?”
Dice系数(F1分数)是衡量模型性能的一个指标(越高越好),而Dice损失则是在训练过程中用来最小化误差的(越低越好)。
结合BCE和Dice损失
通过结合这两种损失函数,我们既能受益于像素级的监督,又能关注Dice损失中的重叠部分,从而确保对两个类别都有敏感的平衡。
组装模型
在深入探讨模型架构之前,我想快速介绍两种显著提高了我的模型性能的关键方法,这些方法在训练过程中往往被忽视。
空间丢弃
为了防止模型过拟合(即模型在训练数据上表现得太好,以至于无法在新数据上进行良好预测),我在模型中加入了空间丢弃层。在训练过程中,它会随机隐藏每张图像中的一部分像素。这基本上迫使模型不再依赖单个像素,而是关注更大的图像,学习建筑周围的上下文和模式。
这是一个非常酷的方法,丢弃不仅限于图像模型中的空间层,你还可以在其他类型的神经网络中使用它!
核初始化器
我认为在构建神经网络时,核初始化器是最容易被忽视的组件之一。这在我攻读硕士课程时可能是最难理解的概念之一,但一旦我真正理解了它,我就明白了它在神经网络中的关键性。
那么,它到底是什么呢?简单来说,它决定了网络层在训练开始之前的权重设置。你可以把它想象成为房子打地基。如果地基薄弱或不平整,那么无论房子设计得多么出色,结构都不会稳定。
对于我的U-Net模型来说,最好的初始化器是LeCun Normal。它会根据输入层的大小成比例地缩放权重,这有助于降低梯度在网络中流动时变得太小(梯度消失)或太大(梯度爆炸)的风险。
Keras函数式API
好了,现在我们来深入构建U-Net模型。我使用Keras API定义了一个简化的U-Net模型。
编码器块
记得之前提到的,这是U-Net的收缩路径——它接收输入图像,捕捉特征并缩小空间信息(减小图像尺寸)。
每个块由以下部分组成:
def encoder_block(filters, inputs, dropout_rate=0.1, kernel_initializer='lecun_normal'):
x = Conv2D(filters, kernel_size=(3, 3), padding='same', strides=1, activation='relu',
kernel_initializer=kernel_initializer)(inputs)
x = SpatialDropout2D(dropout_rate)(x)
s = Conv2D(filters, kernel_size=(3, 3), padding='same', strides=1, activation='relu',
kernel_initializer=kernel_initializer)(x)
s = SpatialDropout2D(dropout_rate)(s)
p = MaxPooling2D(pool_size=(2, 2), padding='same')(s)
return s, p
在我的模型中,我使用了四个块(当我尝试使用更多块或更多过滤器时,模型的表现反而变差了):
瓶颈层
这部分模型充当U-Net的瓶颈,位于编码器和解码器之间。它能够在最小的空间维度上捕捉最深的特征(非常酷)。它由以下部分构建:
这个基础为解码器从这些抽象特征中重建高分辨率图像奠定了基础。
def baseline_layer(filters, inputs, dropout_rate=0.1, kernel_initializer='lecun_normal'):
x = Conv2D(filters, kernel_size=(3, 3), padding='same', strides=1, activation='relu',
kernel_initializer=kernel_initializer)(inputs)
x = SpatialDropout2D(dropout_rate)(x)
x = Conv2D(filters, kernel_size=(3, 3), padding='same', strides=1, activation='relu',
kernel_initializer=kernel_initializer)(x)
x = SpatialDropout2D(dropout_rate)(x)
return x
解码器块
如果你还记得之前的内容,解码器是U-Net的扩展路径,它通过上采样和组合特征来重建图像。每个解码器块包括:
记住,这些就像是从模型的一端到另一端的子弹列车。
def decoder_block(filters, connections, inputs, dropout_rate=0.1, kernel_initializer='lecun_normal'):
x = Conv2DTranspose(filters, kernel_size=(2, 2), padding='same', activation='relu', strides=2,
kernel_initializer=kernel_initializer)(inputs)
skip_connections = concatenate([x, connections], axis=-1)
x = Conv2D(filters, kernel_size=(3, 3), padding='same', activation='relu',
kernel_initializer=kernel_initializer)(skip_connections)
x = SpatialDropout2D(dropout_rate)(x)
x = Conv2D(filters, kernel_size=(3, 3), padding='same', activation='relu',
kernel_initializer=kernel_initializer)(x)
x = SpatialDropout2D(dropout_rate)(x)
return x
最终输出层
最后但同样重要的是,模型的最后一层是一个1x1的卷积层,它具有一个输出通道和一个Sigmoid激活函数。这一层用于预测每个像素属于目标类别(建筑物)或背景的概率。
outputs = Conv2D(1, 1, activation = 'sigmoid')(d4)1, 1, activation = 'sigmoid')(d4)
将所有部分整合在一起
def unet():
inputs = Input(shape = (256, 256, 3)) #defines the input layer and shape of images
#encoder
s1, p1 = encoder_block(32, inputs = inputs)
s2, p2 = encoder_block(64, inputs = p1)
s3, p3 = encoder_block(128, inputs = p2)
s4, p4 = encoder_block(256, inputs = p3)
#bottleneck
baseline = baseline_layer(512, p4)
#decoder
d1 = decoder_block(256, s4, baseline)
d2 = decoder_block(128, s3, d1)
d3 = decoder_block(64, s2, d2)
d4 = decoder_block(32, s1, d3)
#output function for binary classification of pixels
outputs = Conv2D(1, 1, activation = 'sigmoid')(d4)
#finalizing the model
model = Model(inputs = inputs, outputs = outputs, name = 'Unet')
return model
训练模型
以下是我训练模型的方法详解:
训练结束后,我通过绘制训练准确率与验证准确率、训练损失与验证损失的对比图来检查模型的性能。
训练准确率和验证准确率均呈现出上升趋势,这意味着模型能够从训练数据中学习并在验证数据上表现良好。
训练损失和验证损失均稳步下降,这意味着模型能够有效地最小化损失。
评估模型
虽然准确率显示了整体正确预测的比例,但它并没有考虑到数据集中的类别不平衡问题。例如,如果背景像素数量远大于建筑物像素,模型只需大部分时间预测“背景”就可以获得高准确率。因此,高准确率并不总是意味着模型在识别建筑物方面表现良好。
那么,我们如何真正评估模型分割建筑物的能力呢?
这时,Dice指标再次派上了用场!与准确率不同,Dice指标衡量的是预测掩模与真实掩模的重叠程度。Dice得分越高,表示性能越好,意味着模型更准确地捕捉到了预测建筑物区域与实际建筑物区域之间的交集。
以下是我用来计算Dice指标的代码:
def dice_metric(y_true, y_pred):
"""Calculate Dice Coefficient for ground truth and predicted masks."""
y_pred = tf.cast(y_pred > 0.5, tf.float32) #threshold set to 0.5
intersection = tf.reduce_sum(y_true * y_pred)
total_sum = tf.reduce_sum(y_true) + tf.reduce_sum(y_pred)
dice = tf.math.divide_no_nan(2 * intersection, total_sum)
return dice
测试集结果:
在测试集上评估模型后,我获得了以下指标:
通过预测概率可视化结果
为了更好地理解模型的表现,我以多种方式可视化了预测结果,并将它们与真实标签进行了比较。这一步有助于观察模型在哪些地方表现出色,在哪些地方存在不足,是评估像建筑物识别这样的分割任务的关键部分。
观察预测标签(右上角)并将其与测试标签(第二张图像)进行比较,我们可以看到,在大多数情况下,模型成功地识别了建筑物,尽管并不完美。
误报图像突出了模型错误地将背景区域分类为建筑物的地方,而漏报图像则揭示了模型错误地将建筑物标记为背景的地方。这些错误表明仍有改进的空间。然而,总体而言,模型在分割建筑物轮廓方面做得相当不错,并显示出在实际应用中的巨大潜力!