多标签分类
构建数据块
数据集中DataFrame对象转变为一个DataLoaders对象:
PyTorch和fastai有两个用来表示和访问数据集和训练集的主要类:
- Dataset:能为单个数据返回自变量和因变量的元组集合。
- DataLoader:能提供一个小批次的处理流(每个小批次是成对的一批自变量和因变量)的迭代器。
以上面类为基础,fastai提供两个类将训练集和验证集结合在一起:
- Datasets:包含一个训练Dataset和一个验证Dataset的迭代器。
- DataLoaders:包含一个训练DataLoader和验证DataLoader的对象。
由于DataLoader基于Dataset构建,并且向Dataset添加了额外的功能(将多个数据项整合成一个批次),因此通常最简单的做法是创建并测试Datasets,测试完成后再查看DataLoaders。
创建一个DataBlock时,逐步进行,在notebook中检查数据,能确保在编程过程中保持顺畅并避免出错。
访问DataFrame,查看中间数据,当成矩阵
df.iloc[:,0] #按所有行,第0列
df.iloc[:,1] #按所有行,第1列
df.iloc[0,:] #按第0行,所有列
# so this is equivalent:
# df.iloc[0]
# 也可通过索引DataFrame中某列名字来获取某列:
df['labels'].head()
# 可以创建新列,并用列计算
tmp_df = pd.DataFrame({'a':[1,2], 'b':[3,4]})
tmp_df['c'] = tmp_df['a']+tmp_df['b']
以数据集PASCAL为例:开始构建数据块
from fastai.vision.all import *
path = untar_data(URLs.PASCAL_2007)
df = pd.read_csv(path/'train.csv')
dblock = DataBlock() # 无参初始化
dsets = dblock.datasets(df) # 创建Datasets对象,数据源df做参数
# len(dsets.train),len(dsets.valid)
x,y = dsets.train[0] # 返回了同一行两次,因为DataBlock假设有两样东西:输入和目标;要从df中选择合适的字段,用get_x和get_y函数。
# dblock = DataBlock(get_x = lambda r: r['fname'], get_y = lambda r: r['labels'])
# dsets = dblock.datasets(df)
# dsets.train[0]此时数据为 名称+标签 ('005620.jpg', 'aeroplane')
# python中的lambda函数定义引用函数与下面同效果
def get_x(r): return r['fname']
def get_y(r): return r['labels']
dblock = DataBlock(get_x = get_x, get_y = get_y)
dsets = dblock.datasets(df)
# lambda非常适合快速迭代,但与序列化操作不兼容,如果想在训练后导出Learner,建议用自定义的详细函数
自变量需要被转换为一个完整的路径才能以图像方式打开;因变量需要以空格为分隔进行字符串分割
def get_x(r): return path/'train'/r['fname']
def get_y(r): return r['labels'].split(' ')
dblock = DataBlock(get_x = get_x, get_y = get_y)
dsets = dblock.datasets(df)
# dsets.train[0]为(Path('/home/sunqx/.fastai/data/pascal_2007/train/006162.jpg'), ['aeroplane'])
为了打开图像并转换为张量,需要一系列转换,使用block类型。
dblock = DataBlock(blocks=(ImageBlock, MultiCategoryBlock),#CategoryBlock只返回一个整数,这里每个数据项能有多个标签
get_x = get_x, get_y = get_y)
dsets = dblock.datasets(df)
# (PILImage mode=RGB size=500x333,
# TensorMultiCategory([0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]))
这里使用到独热编码
:在一个全0向量中,把存在数据的位置的数值置为1,通过这种方式,对一个整数列表进行编码。
查看这个例子中的类别代表什么:
idxs = torch.where(dsets.train[0][1]==1.)[0]
dsets.train.vocab[idxs]
# (#1) ['bird']
那数据集如何划分训练集和验证集呢?
前面是DataBlock随机划分的,自定义如下,使用到is_valid
字段:
def splitter(df):
train = df.index[~df['is_valid']].tolist()
valid = df.index[df['is_valid']].tolist()
return train,valid
dblock = DataBlock(blocks=(ImageBlock, MultiCategoryBlock),
splitter=splitter,
get_x=get_x,
get_y=get_y)
dsets = dblock.datasets(df)
dsets.train[0]
DataLoader将数据集中的数据整合为一个小批次,这是一个张量元组,每个张量都只是将来自数据集中对应位置的数据堆叠起来。要确保每个数据有相同的大小,才可以创建DataLoaders。
dblock = DataBlock(blocks=(ImageBlock, MultiCategoryBlock),
splitter=splitter,
get_x=get_x,
get_y=get_y,
item_tfms = RandomResizedCrop(128, min_scale=0.35))
dls = dblock.dataloaders(df)#fastai批大小默认64
# dls = dblock.dataloaders(df,bs=32) 可自定义
#dls.show_batch(nrows=1, ncols=3) #展示
如果从DataBlock创建DataLoaders的过程中出错了,或者如果想查看你的DataBlock,可以使用summary
方法。
# 使用summary方法
dblock.summary(df)
创建Learner
Learner主要包含4项:模型、一个DataLoaders对象,一个优化器和损失函数。
损失函数:二元交叉熵
已有数据块,模型使用深度残差神经网络resnet,使用SGD优化器。
创建Learner:
learn = vision_learner(dls, resnet18)
Learner中的模型通常是一个继承自nn.Module
的类的对象,可以用圆括号调用它,它返回一个模型的激活值。
把自变量作为一个小批次的处理数据传给它。
x,y = to_cpu(dls.train.one_batch())#将一个训练批次的数据从 GPU(如果正在使用)转移到 CPU。
activs = learn.model(x)#这批数据被传递给模型以获取激活值(或预测)
activs.shape #通过 activs.shape 查询激活值的形状
# torch.Size([64, 20])
activs形状?因为批大小是64,而我们需要计算20个类别中的每个类别的概率。
查看激活值:
activs[0]
# TensorImage([-2.5563, -0.1716, 3.3514, -1.9079, -1.0211, 1.9026, 0.3355, 1.1839, 0.1848, -0.9886, -0.7055, 1.3745, -1.2583, 0.9657, 4.8114, -1.4315, -2.9368, 3.2201, -1.7628, 3.3106],grad_fn=<AliasBackward0>)
可以通过sigmoid函数将概率缩放到0到1之间。
损失函数:
def binary_cross_entropy(inputs, targets):
inputs = inputs.sigmoid() #模型的输出
return -torch.where(targets==1, 1-inputs, inputs).log().mean() #targets标签
在二分类问题中,我们通常将概率分布表示为y = sigmoid(x)
,其中x
是模型输出的 logits(未归一化的概率),y
是预测的类别概率(0到1之间的实数)。
函数的输入参数包括inputs
和targets
。inputs
是一个张量,表示模型输出的 logits;targets
是一个张量,表示真实标签(0或1)。
函数首先使用sigmoid
函数激活inputs
张量,使其表示为概率分布。然后,使用torch.where
函数根据targets
张量中的值计算损失。torch.where
函数的输入是一个条件表达式,这里我们使用targets==1
作为条件表达式。如果targets
中的值等于1,那么损失等于1-inputs
;否则,损失等于inputs
。最后,使用log
函数计算损失的 log 值,并使用mean
函数计算损失的平均值。
总之,这个函数计算了二分类问题中模型预测的概率分布与真实标签之间的交叉熵损失。
因为有一个独热编码的因变量,所以不能直接使用nll_loss或softmax(也因此不能使用cross_entropy)
Softmax函数要求所有预测值的和为1,并且由于使用了指数函数,它倾向于使一个激活值远大于其他值;然而,我们可能确信在一幅图像中出现了多个对象,因此限制激活值的最大和为1并不是一个好主意。同样的道理,如果我们认为图像中没有任何类别出现,我们可能希望激活值的和小于1。
我们看到的nll_loss只返回一个激活值:与单个项目的单个标签相对应的激活值。当我们有多个标签时,这种做法没有意义。
另一方面,binary_cross_entropy函数,它只是mnist_loss
与log
对数结合,正好提供了我们所需要的,这要归功于PyTorch的逐元素层面操作的魔力。每个激活值将与每列的每个目标进行比较,所以我们不必做任何事情就能让这个函数作用于多列。
PyTorch确实为我们提供了这个函数。事实上,它提供了许多版本,名称有些令人困惑!
F.binary_cross_entropy
及其模块等效 nn.BCELoss
计算的是对独热编码(one-hot-encoded)目标的交叉熵,但不包括初始的sigmoid。通常对于独热编码的目标,你会想要使用 F.binary_cross_entropy_with_logits
(或 nn.BCEWithLogitsLoss
),它在单个函数中同时完成sigmoid和二元交叉熵的计算,就像前面的例子一样。
对于单标签数据集(如MNIST或宠物数据集),其中目标被编码为单个整数,没有初始softmax的版本是 F.nll_loss
或 nn.NLLLoss
,有初始softmax的版本是 F.cross_entropy
或 nn.CrossEntropyLoss
。
由于我们有一个独热编码的目标,我们将使用 BCEWithLogitsLoss
。
现在,让我解释一下这些函数的作用:
F.binary_cross_entropy
和nn.BCELoss
:这两个函数用于计算独热编码目标的二元交叉熵损失,但它们不包括sigmoid激活函数。这意味着在使用这些函数之前,你需要手动应用sigmoid函数来获取预测概率。F.binary_cross_entropy_with_logits
和nn.BCEWithLogitsLoss
:这些函数结合了sigmoid激活和二元交叉熵损失的计算。它们适用于独热编码的目标,并且在内部自动应用sigmoid函数,这使得它们在数值上更稳定,尤其是在处理极端预测值时。F.nll_loss
和nn.NLLLoss
:这些函数用于没有初始softmax的单标签数据集。它们计算的是负对数似然损失,适用于分类任务中每个实例只有一个正确类别的情况。F.cross_entropy
和nn.CrossEntropyLoss
:这些函数结合了softmax激活和负对数似然损失的计算。它们适用于单标签数据集,其中目标被编码为单个整数。
在多标签分类任务中,我们通常会选择 BCEWithLogitsLoss
,因为它能够处理每个类别独立的概率,并且在计算损失时考虑到了每个类别的预测概率。这对于那些可能有多个正确标签的情况非常有用。
activs = activs.as_subclass(torch.Tensor)
y = y.as_subclass(torch.Tensor)# 确保这些变量具有 Tensor 类型,以便可以对它们应用 PyTorch 的操作和函数
loss_func = nn.BCEWithLogitsLoss()
loss = loss_func(activs, y)
# tensor(1.0336, grad_fn=<BinaryCrossEntropyWithLogitsBackward0>)
然而,不必告诉fastai使用哪个损失函数(也可以这么做),因为它会自动选择合适的损失函数。fastai知道DataLoaders有多个类别标签,所以使用默认的nn.BCEWithLogitsLoss。
计算准确率,但是这里不使用准确率
def accuracy(inp, targ, axis=-1):
pred = inp.argmax(dim=axis)
return (pred == targ).float().mean()
argmax
被预测的类别是激活值最高的那一个。但这里不起作用,因为要求一张图像能有不止一个预测类别。
在将sigmoid操作应用到激活值上(令它们介于0与1之间)之后,我们需要选择一个阈值来决定哪些是0、哪些是1。每个高于阈值的值被认为是1,低于阈值的值被认为是0。
def accuracy_multi(inp, targ, thresh=0.5, sigmoid=True):
if sigmoid: inp = inp.sigmoid()
return ((inp>thresh)==targ.bool()).float().mean()
可以调整默认阈值0.5,创建不同默认值的accuracy_multi
版本。为了实现这一点可以使用partial
函数,它可以将一个函数与一些参数或关键字绑定在一起,从而使得新版本的函数无论何时被调用,都能始终包含这些参数。
def say_hello(name, say_what="Hello"): return f"{say_what} {name}."
say_hello('Jeremy'),say_hello('Jeremy', 'Ahoy!')
# ('Hello Jeremy.', 'Ahoy! Jeremy.')
f = partial(say_hello, say_what="Bonjour")
f("Jeremy"),f("Sylvain")
# 'Bonjour Jeremy.', 'Bonjour Sylvain.')
训练模型
这里多分类指标的阈值设为0.2
learn = vision_learner(dls, resnet50, metrics=partial(accuracy_multi, thresh=0.2))
learn.fine_tune(3, base_lr=3e-3, freeze_epochs=4)
learn.fine_tune(3, base_lr=3e-3, freeze_epochs=4)
fine_tune
方法用于微调模型。首先,它冻结预训练模型的大部分层,只训练最后几层以适应新的数据集。然后,它解冻所有层并继续训练整个模型。第一个参数
3
指定在解冻所有层后训练的周期数(epochs)。base_lr=3e-3
设置了学习率。这是在微调过程中使用的基础学习率。freeze_epochs=4
指定在开始微调之前,只训练最后几层时使用的周期数。在这个阶段,模型的大部分层都是冻结的。在
fastai
库中,当使用fine_tune
方法对模型进行微调时,”解冻” 的具体层取决于模型的架构和fine_tune
方法的实现细节。对于大多数预训练模型,如resnet50
,fine_tune
方法的工作流程大致如下:- 初始阶段(冻结状态):在第一阶段,模型的大部分预训练层都被冻结,只有模型的最后几层(通常是头部的几层,这些层负责将预训练模型的特征转换为特定任务的输出)是可训练的。这意味着在这个阶段,只有这些最后的层的权重会被更新。
- 解冻并微调:在第二阶段,
fine_tune
方法会解冻模型的所有层,使得整个网络的权重都可以更新。这个阶段通常使用更小的学习率,以避免破坏预训练层学到的有用特征。
具体到
resnet50
或其他类似的 CNN 架构,”解冻” 的层包括:- 卷积层:这些层负责从输入图像中提取特征。在微调的第二阶段,所有卷积层都会被解冻,允许模型调整这些层以更好地适应新的数据集。
- 批归一化层(如果有的话):这些层用于标准化前一层的输出,有助于加速训练过程并提高模型的稳定性。在微调过程中,这些层的参数也可以被更新。
- 全连接层:这是模型的最后几层,通常直接负责生成最终的预测输出。在初始阶段,这些层就已经是可训练的,微调的第二阶段会继续调整它们的权重。
如果阈值过低,经常无法得到正确的标记对象。可以通过调整指标,然后调用能够返回验证集上的损失值和所设置的指标的验证函数validate来观察是否得到了正确标记的对象。
learn.metrics = partial(accuracy_multi, thresh=0.1)
learn.validate()
# (#2) [0.10325726121664047,0.932948112487793]
如果阈值设置过高,则只能选出模型十分确信的对象。
learn.metrics = partial(accuracy_multi, thresh=0.99)
learn.validate()
# (#2) [0.10325726121664047,0.9447012543678284]
通过调整 thresh
参数,可以控制将预测概率转换为类别标签的灵敏度,进而影响模型性能的评估。
preds,targs = learn.get_preds()
# preds.shape #torch.Size([2510, 20])
learn.get_preds()
方法被用于获取模型在验证集上的预测结果和真实目标(标签)。learn
是一个 Learner
对象,它封装了模型、数据以及训练过程中使用的其他配置。get_preds
方法默认在模型的验证集上执行,并返回两个元素:
- **
preds
**:一个张量(Tensor),包含了对每个样本的预测概率。在多标签分类问题中,这个张量的形状通常是(N, C)
,其中N
是样本数量,C
是类别数量。每个元素的值表示模型预测相应样本属于特定类别的概率。 - **
targs
**:一个张量(Tensor),包含了每个样本的真实标签。在多标签分类问题中,这通常也是一个(N, C)
形状的张量,采用独热编码表示,即如果样本属于某个类别,则对应位置的值为1,否则为0。
这个方法非常有用,因为它允许你直接获取模型的预测结果和真实标签,进而可以用于计算各种评估指标,如准确率、召回率、F1 分数等,或者进行进一步的分析和可视化。
accuracy_multi(preds, targs, thresh=0.9, sigmoid=False)
# TensorBase(0.9579)
get_preds
函数在默认输出时调用激活函数,所以这里accuracy_multi
函数不再调用激活函数。
xs = torch.linspace(0.05,0.95,29)# 0.05到0.95的等差为阈值,共设29个点
accs = [accuracy_multi(preds, targs, thresh=i, sigmoid=False) for i in xs]
max_acc_index = accs.index(max(accs))
max_acc_thresh = xs[max_acc_index]
# 绘制精确度曲线
plt.plot(xs, accs, label='Accuracy')
# 标记最大精确度点
plt.scatter([max_acc_thresh], [max(accs)], color='red')
# 添加文本说明
plt.text(max_acc_thresh, max(accs), f'({max_acc_thresh:.2f}, {max(accs):.2f})', ha='left', va='bottom')
plt.xlabel('Threshold')
plt.ylabel('Accuracy')
plt.title('Accuracy vs. Threshold')
plt.legend()
plt.show()
本例中使用验证集来选择超参数(阈值),正是验证集的目的。
如上改变阈值产生的是一个平滑的曲线,所以不用担心这样做会使验证集过拟合。
当阈值改变导致的准确率变化呈现出平滑曲线时,意味着模型对于阈值的选择具有一定的鲁棒性,即小幅度调整阈值不会导致准确率发生剧烈变化。这种情况下,选择一个最优阈值不太可能是因为偶然地在验证集上表现良好(即过拟合验证集),而是因为模型本身对于不同的阈值具有稳定的泛化能力。理论上,频繁尝试大量超参数值可能会导致模型在验证集上过拟合,因为可能恰好找到了某个特定的超参数值,使得模型在验证集上表现异常良好,但这并不意味着模型在未见过的数据上也能保持同样的表现。
然而,在实践中,如果超参数调整导致的性能变化呈现出平滑的趋势,这表明模型的性能对于超参数的变化不是特别敏感,因此找到的最优超参数值更有可能是因为模型本身的泛化能力,而不是过度拟合了验证集的特定特征。这种情况下,即使尝试了多个超参数值,也不太可能导致过拟合验证集的问题,因为模型表现的变化是平滑且稳定的,反映了模型对于超参数变化的真实反应,而不是随机波动或偶然误差。
总结来说,平滑的性能变化曲线意味着模型对超参数的选择具有一定的容错性,这减少了因选择了特定超参数值而导致的验证集过拟合的风险。这是理论与实践之间的一个重要区别,实践中观察到的平滑变化趋势表明了模型的稳定性和泛化能力,而不是过拟合。