图片分类(2) 半监督学习
迁移学习
迁移学习是一种机器学习方法,它利用在一个任务上训练好的模型的知识,来帮助解决另一个相关但不同的任务。
在深度学习中,迁移学习通常表现为使用在大规模数据集(如ImageNet)上预训练的模型,将其作为起点,通过微调(fine-tuning)或特征提取(feature extraction)的方式,应用于新的任务。
迁移学习的本质是站在巨人的肩膀上:
-
利用通用知识:预训练模型学习了大量通用特征
-
针对特定任务:在通用基础上进行针对性调整
-
节省资源:大幅减少数据和计算需
迁移学习的两种主要方式:
-
特征提取(Feature Extraction):冻结预训练模型的卷积层(作为特征提取器),只训练新添加的全连接层。这样,我们利用预训练模型学到的特征表示,只学习一个新的分类器。
-
微调(Fine-tuning):解冻预训练模型的一部分或全部卷积层,同时训练新添加的全连接层和卷积层。通常使用较小的学习率,以避免破坏预训练模型已经学到的有用特征。
所谓冻结就是阻止参数参与梯度计算。
特征提取(Feature Extraction)
# 冻结预训练模型的所有层,只训练新添加的分类层
for param in model.parameters():
param.requires_grad = False # 冻结参数,不更新
# 只训练新添加的全连接层
model.fc = nn.Linear(in_features, 11)
# 优化器只更新最后一层
optimizer = torch.optim.Adam(model.fc.parameters(), lr=0.001)
微调(Fine-Tuning)
# 解冻部分或全部层进行训练
for param in model.layer4.parameters(): # 只解冻最后几层
param.requires_grad = True
# 整个模型一起训练,但使用较小的学习率
optimizer = torch.optim.Adam(model.parameters(), lr=0.0001)
使用预训练的 resnet18 模型
model = resnet18(weights=torchvision.models.ResNet18_Weights.DEFAULT)
in_features = model.fc.in_features
model.fc = nn.Linear(in_features, 11)
修改最后的全连接层,将输出的类别改为本项目中所需要的 11 类
机器学习:监督学习、无监督学习、半监督学习、强化学习-CSDN博客
监督学习(Supervised Learning)
定义
让模型从带有标签的训练数据中学习,学习输入到输出的映射关系。
可以把监督学习理解为我们教机器如何做事情。
给定:{(x₁, y₁), (x₂, y₂), ..., (xₙ, yₙ)}
目标:学习 f: X → Y,使得 f(x) ≈ y
无监督学习(Unsupervised Learning)
定义
从无标签数据中发现隐藏模式或数据结构。
给定:{x₁, x₂, ..., xₙ}
目标:发现数据的固有结构、模式或分布
在无监督学习中数据只有特征(feature)无标签(label),是一种机器学习的训练方式,它本质上是一个统计手段,在没有标签的数据里可以发现潜在的一些结构的一种训练方式。
简单理解:比起监督学习,无监督学习更像是自学,让机器学会自己做事情。
半监督学习(Semi-supervised Learning)
定义
结合少量有标签数据和大量无标签数据进行学习。
数据:少量 (xᵢ, yᵢ) + 大量 xⱼ
假设:相似样本应有相似标签(平滑假设)
目标
利用同时包含有标签和无标签的数据来构建一个模型,使得模型能够在测试阶段更好地泛化到新的、未见过的数据。
在半监督学习中,无标签的数据可以起到两个重要作用:
1、利用未标记数据的信息:未标记数据可能包含对数据分布、结构和隐含特征的有用信息,这些信息可以帮助模型更好地进行泛化。
2、利用标记数据的传播效应:通过利用标记数据与无标签数据之间的数据分布相似性,可以通过传播标签信息到无标签样本,进而增强模型的性能。
在本项目中,除开之前用的带标签数据,还有非常多的无标签数据,因而要用到半监督学习的方法。
这里只取其一:自训练(self-training)
自训练(Self-training)
通俗解释
自训练就像是一个“自我学习”的过程。我们先用有标签的数据训练一个初始模型,然后用这个模型去预测无标签的数据,选出那些预测置信度高的样本,把它们连同预测的标签一起加入到训练集中,再重新训练模型。这个过程可以重复多次。
步骤
-
用有标签数据训练一个模型。
-
用这个模型预测无标签数据,得到伪标签。
-
选择置信度高的预测(比如概率高于某个阈值),将这些样本和伪标签加入训练集。
-
用扩大的训练集重新训练模型。
-
重复2-4步,直到满足停止条件。
例子
假设我们有一个猫狗分类器,初始只有100张有标签的图片(50张猫,50张狗)。我们还有10000张无标签的图片。
-
第一步:用100张有标签图片训练一个分类器。
-
第二步:用这个分类器对10000张无标签图片进行预测,得到每张图片是猫或狗的概率。
-
第三步:选择概率高于0.95的图片(比如有2000张),将它们和预测的标签(伪标签)加入到训练集中,现在训练集变成了2100张(100+2000)。
-
第四步:用这2100张图片重新训练模型。
-
重复第二步和第三步,直到模型不再变化或达到迭代次数。
注意
自训练可能会累积错误,因为一开始模型不完美,可能会把一些样本错误地标记,然后这些错误会被放大。所以选择高置信度的样本很重要。
自训练代码实现
在前一篇的代码上做了增加和修改:
数据集类部分
def __getitem__(self, item):
if self.mode == "semi": # 半监督模式下,读取到的无标签数据也不应做复杂的变换,因为需要对它进行预测
return self.transform(self.X[item]), self.X[item] # 返回原始数据以构造新的数据集(半监督)
else: # 对于训练数据,才会给其加上很多的变换,以实现数据增强,帮助训练
return self.transform(self.X[item]), self.Y[item]
def __len__(self):
if self.mode == "semi":
return self.X.shape[0]
else:
return len(self.Y)
稍微修改了get_item等,关键在于get_item。
即使无标签数据没有label可以返回,但是这里仍然要返回两个值,前面的X是经过变换(transform)的,这一部分用于送入模型中进行预测。
(这里的变化和对验证集 X 做的一样,没有经过旋转、切分、缩放等,只是调整了尺寸,并调用了ToTensor转化为pytorch要用的张量)
后面的 X 则是为了提供原始的图片,因为半监督最终的目的是 要给无标签数据打上标签并用于训练,用于训练的数据自然只能是原始的图片。因而这个地方还要另外返回一个原始的 X。
这个地方对数据集类(food_dataset)的修改是为了读取与处理无标签数据,后续会实例化为:
no_label_set = food_Dataset(no_label_path, "semi")
no_label_loader = DataLoader(no_label_set, batch_size=16, shuffle=False) # 半监督不随机取,因为要打标签
并且利用其实例化一个dataloader对象,用于加载无标签数据,后续用于送入模型预测其标签。
数据读取部分
def read_file(self, path): # 读取图片,带标签训练图片共11个文件夹,每个含280张图
# 区分 半监督 和 监督 的读数据方式
if self.mode != "semi":
""""读取带标签的数据的操作(包括训练集和验证集)"""
return X, Y
else: # 读取不带标签的数据,用于半监督学习
file_dir = (path + "/%02d" % 00) # 取路径
file_list = os.listdir(file_dir) # 列出该文件夹下所有文件(图片),返回一个列表
X = np.zeros((len(file_list), HW, HW, 3), dtype=np.uint8) # np数组初始化
for j, img_name in enumerate(file_list): # 遍历该文件夹,获得索引j和文件名(图片名)
img_path = os.path.join(file_dir, img_name) # 把图片名添加到路径形成图片的路径
img = Image.open(img_path) # 根据图片路径打开图片
img = img.resize((HW, HW)) # 尺寸变更
X[j, ...] = img # 第j张完整的图像,形状为 (224, 224, 3),省略号表示对后三维“取全部”
print("读到了%d个数据" % X.shape[0])
return X # 无标签数据,只返回图片X,不返回标签Y
这里还把read_file函数放进数据集类里面了,这样可以直接在内部传递参数
半监督学习数据集类
由于半监督学习数据集的数据获取方式(需要在训练的过程中对无标签数据进行预测产出标签)和正常直接读取带标签数据不同,因而相应的数据集类也要另外定义。
class semi_dataset(Dataset):
def __init__(self, no_label_loader, model, device, thres=0.99):
x, y = self.get_label(no_label_loader, model, device, thres)
if x == []: # 若根本取不到能用的无标签数据,则标志位设为False,说明不可用
self.flag = False
else:
self.flag = True
self.X = np.array(x) # 转换为数组
self.Y = torch.LongTensor(y) # 转换为长整型
self.transform = train_transform # 数据增强transform设为 训练用的transform
def __getitem__(self, item): # 返回数据
return self.transform(self.X[item]), self.Y[item]
def __len__(self):
return len(self.Y)
def get_label(self, no_label_loader, model, device, thres=0.99):
model = model.to(device)
X = [] # 存放高置信度的原始图片
Y = [] # 存放对应的预测标签
soft = nn.Softmax() # 处理模型的预测值,将其转化为概率分布
pred_prob = [] # 存放最大概率值
labels = [] # 存放最大值下标(标签)
with torch.no_grad(): # 不做训练时用到模型——不计算梯度
for bat_x, _ in no_label_loader:
bat_x = bat_x.to(device)
pred = model(bat_x) # 获取预测值
pred_soft = soft(pred) # 输出转化为概率分布
pred_max, pred_labels = pred_soft.max(1) # .max()返回:(最大值, 最大值下标),最大值下标就是预测的标签
pred_prob.extend(pred_max.cpu().numpy().tolist())
labels.extend(pred_labels.cpu().numpy().tolist())
# no_label_loader是顺序取数的,因而上面的循环会顺序取完dataset中所有的X,
# 因而对应的pred、pred_prob中元素的下标都和X一一对应,所以这里可以用index来找到X
for index, prob in enumerate(pred_prob):
if prob > thres:
X.append(no_label_loader.dataset[index][1])
# dataset返回的是一个元组:(X的转换值,原本的X),
# index指示x的位置,[1]指示要取的是X的真实图片而非变换后的
Y.append(labels[index])
return X, Y
no_label_set = food_Dataset(no_label_path, "semi")
no_label_loader = DataLoader(no_label_set, batch_size=16, shuffle=False) # 半监督不随机取,因为要打标签
大多的功能都写在注释里面了,这里挑几句详细写一下
重点:get_label()
for bat_x, _ in no_label_loader:
在前面的数据集类中,在semi模式下,数据集的get_item会返回两个值,一是经过简单变换的图片,二是原始的图片,这里我们要先取简单变换的图片,并使其通过模型,计算其标签,故而第二个位置上的下划线"_"只是一个占位符,使其符合语法,前面的bat_x才是操作的对象
这句后续就是通过bat_x计算标签
.max()方法
.max()是一个张量(tensor)的方法,用于返回张量中的最大值。但是,它有不同的调用方式,根据参数的不同,返回的内容也不同。
不带参数的.max():只返回整个张量中的最大值,不返回下标
tensor = torch.tensor([1.0, 2.0, 3.0, 4.0])
max_value = tensor.max()
print(max_value) # 输出:tensor(4.)
带维度的.max(dim):
tensor = torch.tensor([[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0]])
values, indices = tensor.max(dim=1)
print(values) # 输出:tensor([3., 6.]) # 每一行的最大值
print(indices) # 输出:tensor([2, 2]) # 每一行最大值的索引(列索引)
这里,我们指定了维度dim=1(按行),返回两个张量:
-
第一个是每一行的最大值。【1,2,3,4,....】
-
第二个是每一行最大值所在的索引(在该行中的位置)。【5,6,7,8,....】
代码中用的就是第二种。前面的最大值作为置信度,后面的下标就是对应的标签,置信度用于评判这个标签能否就作为这张图片的伪标签并用于训练。
pred_prob.extend(pred_max.cpu().numpy().tolist())
labels.extend(pred_labels.cpu().numpy().tolist())
为什么要用extend呢?
因为.max返回的是两个张量,换个角度讲就是返回的都是数组(列表),不能用append,只能将其从张量转为列表并将其并入pred_prob或labels列表以进行存放。
第二个for循环
# no_label_loader是顺序取数的,因而上面的循环会顺序取完dataset中所有的X,
# 因而对应的pred、pred_prob中元素的下标都和X一一对应,所以这里可以用index来找到X
for index, prob in enumerate(pred_prob):
if prob > thres:
X.append(no_label_loader.dataset[index][1])
# dataset返回的是一个元组:(X的转换值,原本的X),
# index指示x的位置,[1]指示要取的是X的真实图片而非变换后的
Y.append(labels[index])
pred_prob存放的是概率(置信度),for循环取其中元素,将其与阈值thres进行对比,判断是否可以把它【对应的图片】加入半监督数据集。
那么怎么找到这张图片呢,明明前面一通计算下来留给我们的只剩下概率和标签了。
这就是为什么在实例化dataloader对象时不将其打乱了(shuffle=False),因为不打乱时,dataloader就会按顺序取数据。
假设有18张图片,batchsize=16,dataloader一次取16张图,第一次取 0~15 号图片,第二次取16、17号图片,每轮都构成了前面每个循环中的那个bat_x,bat_x经过模型得到pred(预测标签),这二者必然是一一对应的。
以此类推,pred -> pred_soft -> pred_max(pred_labels) -> pred_prob(labels),全都是一一对应的
所以pred_prob中元素的下标,实际上就是对应的数据集中那张图片的下标,因而可以用这个index找到图片并将其加入 X
X.append(no_label_loader.dataset[index][1])
这句代码用到的方括号语法:
方括号的本质:
"""当你写 dataset[index] 时,Python 实际上是在调用:"""
dataset.__getitem__(index)
调用的流程:
# 在 food_Dataset 类中:
def __getitem__(self, item):
if self.mode == "semi":
return self.transform(self.X[item]), self.X[item]
else:
return self.transform(self.X[item]), self.Y[item]
# 当你调用 dataset[0] 时:
# 1. Python 解析器看到方括号
# 2. 查找 dataset 对象的 __getitem__ 方法
# 3. 调用 dataset.__getitem__(0)
# 4. 返回结果
而get_item方法,根据我们的写法,它会返回一个含有两个元素的元组
( self.transform(self.X[item]) , self.X[item] )
那么,no_label_loader.dataset [ index ],就会返回一个元组
( self.transform(self.X[ index ]) , self.X[ index ] )
no_label_loader.dataset [ index ] [ 1 ],返回的就是元组中的第二个元素:
self.X[ index ]
这就是我们需要的,原始的图像 X
总体数据流向
semi_dataset[0][1]
↓
调用 __getitem__(0)
↓
返回 (transform(X[0]), X[0])
↓
取第二个元素 [1]
↓
得到 X[0](原始图像)
综上,第二个for循环就实现了:
“把合格的无标签图片 X 打上预测得到的伪标签 label ,并将其加入半监督学习数据集”的工作
配套使用的获取dataloader函数
def get_semi_loader(no_label_loader, model, device, thres):
semiset = semi_dataset(no_label_loader, model, device, thres)
if semiset.flag == False:
return None
else:
semi_loader = DataLoader(semiset, batch_size=16, shuffle=False)
return semi_loader
判断半监督数据集是否可用(Flag),实例化semiset时如果没有任何置信度够高的无标签数据和伪标签,则Flag为False,半监督数据集不可用,返回None
反之,若可用,则返回一个dataloader对象。这个函数会在训练流程中调用,为训练提供无标签数据和伪标签用于训练。
训练流程
def train_val(model, train_loader, val_loader, lr, optimizer, device, epochs, save_path, no_label_loader, thres):
model = model.to(device) # 防止出错
semiloader = None # 半监督数据loader初始化为空
"""...其余相关变量初始化..."""
for epoch in range(epochs): # “发令枪”,模型训练的开始
model.train() # 模型调为训练模式
"""......训练......"""
semi_loss = 0.0 # 浮点形式
semi_acc = 0.0 # 准确率计算
if semiloader != None:
for x, y in semiloader:
x, y = x.to(device), y.to(device)
y_pred = model(x)
semi_bat_loss = loss(y_pred, y)
semi_bat_loss.backward()
optimizer.step()
optimizer.zero_grad()
semi_loss += semi_bat_loss.cpu().item()
semi_acc += np.sum(np.argmax(y_pred.detach().cpu().numpy(), axis=1) == y.cpu().numpy())
print("半监督数据集的训练准确度为", semi_acc/train_loader.dataset.__len__())
model.eval() # 模型调为验证模式
"""......验证......"""
if epoch % 5 == 0 & (plt_val_acc[-1] > 0.7):
semiloader = get_semi_loader(no_label_loader, model, device, thres)
if val_acc > max_val_acc:
max_val_acc = val_acc
torch.save(model, save_path)
print("[%03d/%03d] %2.2f sec(s) train_loss: %.6f | val_loss: %.6f | train_acc: %.6f | val_acc: %.6f" %
(epoch+1, epochs, time.time()-start_time, plt_train_loss[-1], plt_val_loss[-1], plt_train_acc[-1],
plt_val_acc[-1]))
"""......画图......"""
训练流程函数增加了两个参数:no_label_loader和thres,一个是无标签数据加载,一个是置信度阈值。
代码解释
半监督数据集获取
if epoch % 5 == 0 & (plt_val_acc[-1] > 0.7):
semiloader = get_semi_loader(no_label_loader, model, device, thres)
semiloader初始为空,在多轮训练加验证后再尝试获取。
每五轮训练+验证后,检验一次模型在验证集上的 平均准确率 是否达到一定标准(标准人为给定)。准确度稍微高上去了再考虑获取半监督数据集。
若准确率不高时就尝试获取半监督数据集,那么当模型都没有得到有效训练时,无标签数据也就很难得到达标的伪标签,最终什么也得不到,白白浪费时间。
同时,即使模型已经具有一定准确率了,也没必要每轮都获取一次,仅仅一轮的训练不会给模型参数带来多么巨大的变化,同样的,通过模型预测产生的伪标签也不会有多么大的更新。生成伪标签需要对未标注数据进行前向传播,计算量大,且频繁生成会显著增加训练时间,于是必须间隔一段时间再尝试获取半监督数据集。
半监督训练流程
semi_loss = 0.0 # 浮点形式
semi_acc = 0.0 # 准确率计算
if semiloader != None:
for x, y in semiloader:
x, y = x.to(device), y.to(device)
y_pred = model(x)
semi_bat_loss = loss(y_pred, y)
semi_bat_loss.backward()
optimizer.step()
optimizer.zero_grad()
semi_loss += semi_bat_loss.cpu().item()
semi_acc += np.sum(np.argmax(y_pred.detach().cpu().numpy(), axis=1) == y.cpu().numpy())
print("半监督数据集的训练准确度为", semi_acc/train_loader.dataset.__len__())
当已经获取到能用的半监督数据时才启用半监督训练。
其余步骤与正常训练流程类似。
更多推荐


所有评论(0)