本文从dog种类kaggle开始着手,其中包含各种详细说明,并在文章最后附上补充条目。
目录
1.dog_kaggle
1.1图像分类数据集准备
在kaggle中,下载得到的文件是这个样的:(当然这个train_valid_test是后面建的)其中这个csv文件里面有两列信息,第一列是图片名称,第二列是该图片对应的label。第一列中的图片随机的分布在test和train这两个文件夹中。
目的:
在图像分类dataset中,最简单的加载方法就是建立一个文件夹,其中有若干个以label命名的文件夹,每个label文件夹中包含着都是该label的图片。那么目的有了,让我们来实现:
首先,读取上述的csv文件,并生成一个dict(pic_name,pic_label),这里注意,dict生成字典里面的key必须是不可更改的数据结构,像list就不行,这里两个都是str,故可以。
data_dir = '/CV/xhr/kaggle_cifar10_tiny/kaggle_cifar10_tiny'
def read_csv_labels(fname):
"""读取fname来给标签字典返回⼀个⽂件名"""
with open(fname, 'r') as f:
# 跳过⽂件头⾏(列名)
lines = f.readlines()[1:]
tokens = [l.rstrip().split(',') for l in lines]
return dict((name, label) for name, label in tokens)
labels = read_csv_labels(os.path.join(data_dir, 'trainLabels.csv'))
print('# 训练样本 :', len(labels))
print('# 类别 :', len(set(labels.values())))
'''
训练样本: 1000
类别数量: 120
'''
再分别创建三个文件夹,进行对原图片复制的操作。
首先先定义一个复制文件的函数:
def copyfile(filename, target_dir):
os.makedirs(target_dir, exist_ok=True)
shutil.copy(filename, target_dir)
函数解析:exist_ok=True表示若要创建的目录已经存在,则不会报错直接返回,若设置为False,则会返回FileExistsError。
shutil是一个包,里面的.copy函数是将文件从原路径filename复制到目标路径target_dir。注意,在此移动函数中,原文件是到具体的某一张图片,目标路径targer_dir可以指向某一个文件夹,这样前面不同的filename都会挪到同一个文件夹中。
再定义重头戏:创建并挪图片!
def reorg_train_valid(data_dir, labels, valid_ratio):
n = collections.Counter(labels.values()).most_common()[-1][1]
n_valid_per_label = max(1, math.floor(n * valid_ratio))
label_count = {}
for train_file in os.listdir(os.path.join(data_dir, 'train')):
label = labels[train_file.split('.')[0]] # 得到每个图片名(除后缀),
# 然后通过labels字典里面key索引
# 这个label是图片的种类标签
fname = os.path.join(data_dir, 'train', train_file)
copyfile(fname, os.path.join(data_dir, 'train_valid_test',
'train_valid', label))
if label not in label_count or label_count[label] < n_valid_per_label:
copyfile(fname, os.path.join(data_dir, 'train_valid_test',
'valid', label))
label_count[label] = label_count.get(label, 0) + 1
else:
copyfile(fname, os.path.join(data_dir, 'train_valid_test',
'train', label))
return n_valid_per_label
1.其中collections.Counter用于计算元素在一个可迭代对象中出现的次数,并返回一个Counter对象,键是元素,值是元素出现的次数。
2.most_common()方法返回一个列表,其中列表里面包含(value,num)的元组,分别记录出现的value是什么,出现了多少次,由最多到最低排列。
3.[-1][1]则返回的是出现次数最少的value对应的最少次数。
在此做了一个demo:
import collections
labels = {'a': 1, 'b': 2, 'c': 1, 'd': 3, 'e': 1, 'f': 2} # 示例字典
print(labels['a'])
# 统计每个元素出现的次数,并返回出现次数最少的元素的出现次数
n = collections.Counter(labels.values())
print(n)
n1 = collections.Counter(labels.values()).most_common()
print(n1)
n2 = collections.Counter(labels.values()).most_common()[-1][1]
print(n2)
'''
1
Counter({1: 3, 2: 2, 3: 1})
[(1, 3), (2, 2), (3, 1)]
1
'''
4.这里返回最少出现的种类的出现频次n,是在设定验证集占训练集比值r后,验证集为每个类别拆分出max([nr],1)张图像.
5.其中,[nr]是向下取整,如果是1.6则取1
6.这个循环中,os.listdir(path)是返回该路径下所有的文件名字,在这里train_file是train文件下所有图片的名字
7.label返回的是每个train_file(train文件中的一张图片)的类别名字(labels字典里面对应key的value),这里labels传入的是前面生成的dict(pic_name,pic_label),这里通过索引字典的key(pic_name),返回该键的值(pic_label),这里两个都是str,所以返回的种类也是str
8.fname是将图片名与前面的data_dir名+train_valid拼起来,称为图片复制到的目标路径,然后再用之前定义的copyfile函数复制到train_valid里面的label文件中
## 对上面举个例子,比如train里面第一张图片1.png,在csv文件里面的label是frog,则labels字典里面为{'1','frog},此处train_file:'1.png',label为'frog',fname为该图片的绝对路径。具体见下图:
9.里面的if判断函数,是抽取train_valid用的,当该label没有在label_count中,或者在但是对应该label的value小于n_vpl时,则抽取该图片到train_valid中的label文件夹中,当作验证集。
10.dict.get(label,0)表示索取dice中,ket=label的value,如果不存在该key,则创建一个且让他的value=0
11.这样经过判断条件后,得到的valid是每个类别中取n_valid_per_label,即验证集占比×最少类别数向下取整的数目,其真实占比并不是所有训练集的比例。在本例中,最少类别样本数n×ratio向下取整=8,所以总valid数目为cls×8=80张图片,所以其占比一般比ratio小。
12.为什么会有train_valid_test?这里其实是做了1折验证,从train训练在valid检验调参,调好参数后,使用全部的训练数据进行训练,就是train_valid_test,它就是原始的train按类别分别建文件夹并挪图片的
---------------------------------------------------------分割线--------------------------------------------------------------
接下来再做一个test文件,方便读取test图片:
def reorg_test(data_dir):
"""在预测期间整理测试集,以⽅便读取"""
for test_file in os.listdir(os.path.join(data_dir, 'test')):
copyfile(os.path.join(data_dir, 'test', test_file),
os.path.join(data_dir, 'train_valid_test', 'test',
'unknown'))
定义一个函数,来调用前面的read_csv_lables,reorg_train_valid和reorg_test:
def reorg_cifar10_data(data_dir, valid_ratio):
labels = read_csv_labels(os.path.join(data_dir, 'trainLabels.csv'))
reorg_train_valid(data_dir, labels, valid_ratio)
reorg_test(data_dir)
定义超参数:
batch_size = 32 if demo else 128
valid_ratio = 0.1
reorg_cifar10_data(data_dir, valid_ratio)
到此,即生成了train_test_valid文件夹,其内部是这样的:
1.2图像增广
transform_train = torchvision.transforms.Compose([
# 在⾼度和宽度上将图像放⼤到40像素的正⽅形
torchvision.transforms.Resize(40),
# 随机裁剪出⼀个⾼度和宽度均为40像素的正⽅形图像,
# ⽣成⼀个⾯积为原始图像⾯积0.64到1倍的⼩正⽅形,
# 然后将其缩放为⾼度和宽度均为32像素的正⽅形
torchvision.transforms.RandomResizedCrop(32, scale=(0.64, 1.0),
ratio=(1.0, 1.0)),
# 随机水平翻转,默认0.5概率
torchvision.transforms.RandomHorizontalFlip(),
torchvision.transforms.ToTensor(),
# 标准化图像的每个通道
torchvision.transforms.Normalize([0.4914, 0.4822, 0.4465],
[0.2023, 0.1994, 0.2010])])
RandomResizedCrop里面可以设置剪裁大小,这里设置为64%-100%,ration是剪裁高宽比,这里保持与原图不变,因为原图也是正方形。
RandomResizedCrop表示在原图中随机裁剪一部分,不设置scale的话通常在(0.08到1.0)之间,位置随机裁剪尺寸也随机,然后再缩放到指定的大小。
Resize会将短边缩到括号尺寸,长边保持比例缩放,图像高宽比不变。
在对test的增广,一般不会进行别的操作,只是按惯例或者与原net一致的操作。
transform_test = torchvision.transforms.Compose([
torchvision.transforms.Resize(256),
# 从图像中⼼裁切224x224⼤⼩的图⽚
torchvision.transforms.CenterCrop(224),
torchvision.transforms.ToTensor(),
torchvision.transforms.Normalize([0.485, 0.456, 0.406],
[0.229, 0.224, 0.225])])
1.3加载数据集,dataset与dataloader
已经按上述要求创建好的文件夹,直接送入dataset即可。注意一下这里folder用for loop写法。
这里其实就是ImageFloder(文件夹路径),transform=...
train_ds, train_valid_ds = [torchvision.datasets.ImageFolder(
os.path.join(data_dir, 'train_valid_test', folder),
transform=transform_train) for folder in ['train', 'train_valid']]
valid_ds, test_ds = [torchvision.datasets.ImageFolder(
os.path.join(data_dir, 'train_valid_test', folder),
transform=transform_test) for folder in ['valid', 'test']]
train_iter, train_valid_iter = [torch.utils.data.DataLoader(
dataset, batch_size, shuffle=True, drop_last=True)
for dataset in (train_ds, train_valid_ds)]
valid_iter = torch.utils.data.DataLoader(valid_ds, batch_size, shuffle=False,
drop_last=True)
drop_last是如果扫到最后不足bs,则后面这一点就丢掉了,但是test不能丢,因为要输出每一个test结果的
1.4模型net搭配--迁移学习finetune
这里的方式是将原net赋到一个新的子块features中,然后再新创一个mlp达到最终输出cls的目的。在第2块探索中会提到其他变式。
因为这个dog的分类是imagenet的子集,所以固定住前面的参数不变,只学习后面新创建的output_new。
def get_net(devices):
finetune_net = nn.Sequential()
# 定义迁移的原resnet34块叫features
finetune_net.features = torchvision.models.resnet34(pretrained=True)
# 定义⼀个新的输出⽹络,叫output_new,共有120个输出类别
finetune_net.output_new = nn.Sequential(nn.Linear(1000, 256),
nn.ReLU(),
nn.Linear(256, 120))
# 将模型参数分配给⽤于计算的CPU或GPU
finetune_net = finetune_net.to(devices[0])
# 冻结参数
for param in finetune_net.features.parameters():
param.requires_grad = False
return finetune_net
这个操作是在原始输出的fc之后,新加了out_new层:
这里的features和out_new都是自己定义的名字,可以理解为块的名字,resnet34网络作为一块,名字叫features,后面两个全连接层作为一块,名字叫output_new:
定义损失计算:
loss = nn.CrossEntropyLoss(reduction='none')
def evaluate_loss(data_iter, net, devices):
l_sum, n = 0.0, 0
for features, labels in data_iter:
features, labels = features.to(devices[0]), labels.to(devices[0])
outputs = net(features)
l = loss(outputs, labels)
l_sum += l.sum()
n += labels.numel()
return (l_sum / n).to('cpu')
1.5训练--学习率下降操作:
其他的代码跟之前的都差不多,注意这里面新加了scheduler,设置的是每经过lr_period个epoch之后,学习率下降lr_decay。另需注意除了trainer.step()后,还要在每个epoch后面添加scheduler.step()命令。
def train(net, train_iter, valid_iter, num_epochs, lr, wd, devices, lr_period,
lr_decay):
trainer = torch.optim.SGD(net.parameters(), lr=lr, momentum=0.9,
weight_decay=wd)
scheduler = torch.optim.lr_scheduler.StepLR(trainer, lr_period, lr_decay)
num_batches, timer = len(train_iter), d2l.Timer()
legend = ['train loss', 'train acc']
if valid_iter is not None:
legend.append('valid acc')
animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs],
legend=legend)
net = nn.DataParallel(net, device_ids=devices).to(devices[0])
for epoch in range(num_epochs):
net.train()
metric = d2l.Accumulator(3)
for i, (features, labels) in enumerate(train_iter):
timer.start()
l, acc = d2l.train_batch_ch13(net, features, labels,
loss, trainer, devices)
metric.add(l, acc, labels.shape[0])
timer.stop()
if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1:
animator.add(epoch + (i + 1) / num_batches,
(metric[0] / metric[2], metric[1] / metric[2],
None))
if valid_iter is not None:
valid_acc = d2l.evaluate_accuracy_gpu(net, valid_iter)
animator.add(epoch + 1, (None, None, valid_acc))
scheduler.step()
measures = (f'train loss {metric[0] / metric[2]:.3f}, '
f'train acc {metric[1] / metric[2]:.3f}')
if valid_iter is not None:
measures += f', valid acc {valid_acc:.3f}'
print(measures + f'\n{metric[2] * num_epochs / timer.sum():.1f}'
f' examples/sec on {str(devices)}')
训练命令行:
devices, num_epochs, lr, wd = d2l.try_all_gpus(), 20, 2e-4, 5e-4
lr_period, lr_decay, net = 4, 0.9, get_net()
train(net, train_iter, valid_iter, num_epochs, lr, wd, devices, lr_period,
lr_decay)
1.6预测与输出结果
在这里注意,之前是1折验证,现在是超参数调整好了之后,应用全部的train数据进行训练,所以需要重新设置一下训练的数据。
net, preds = get_net(), []
train(net, train_valid_iter, None, num_epochs, lr, wd, devices, lr_period,
lr_decay)
for X, _ in test_iter:
y_hat = net(X.to(devices[0]))
preds.extend(y_hat.argmax(dim=1).type(torch.int32).cpu().numpy())
sorted_ids = list(range(1, len(test_ds) + 1))
sorted_ids.sort()
df = pd.DataFrame({'id': sorted_ids, 'label': preds})
df['label'] = df['label'].apply(lambda x: train_valid_ds.classes[x])
df.to_csv('submission1.csv', index=False)
preds里面,后面这一串是copy到cpu和numpy中,生成干净的list:
这里根据类别号进行索引,是通过dataset里面的classes[i]实现的。
其中还要注意,这个extend是将两个列表扩充时,将list1.extend(list2)中的list2解包,分别扩充到list1中,而append则是将括号中的list2整体添加到list1中。
而且进行sort()排序操作后,索引的话要索引原表,如果print(sorted_ids.sort())会返回None,而print(sorted_ids)则会返回经过sort操作的,因为该操作是在原位置上操作的,不会新返回值。
2.探索--finetune
在上个案例中,采用的是在原迁移网络的基础上重新添加一些mlp,使最终输出值与num_cls相匹配。那么这里的探索主要围绕:1.修改最后的fc;2.权重初始化;3.封锁部分原参数。
2.1修改fc
可以直接暴力的只修改最后的Linear层,也可以将原fc全部重新写一遍,这里先说暴力只修改最后Linear层的:
直接.fc将最后的这一层修改即可:
finetune_net = torchvision.models.resnet18(pretrained=True)
finetune_net.fc = nn.Linear(finetune_net.fc.in_features, 2)
nn.init.xavier_uniform_(finetune_net.fc.weight)
2.2自定义修改与添加xavier初始化参数:
finetune_net.fc = nn.Sequential(
nn.Linear(512, 124),
nn.ReLU(inplace=True),
nn.Linear(124, 2)
)
def init_weights(m):
if type(m) == nn.Linear or type(m) == nn.Conv2d:
nn.init.xavier_uniform_(m.weight)
finetune_net.fc.apply(init_weights)
注意,这里xavier只是对修改后的fc层进行,如果是:
finetune_net.apply(init_weights);
这就是对所有层进行初始化了,跟不迁移一样了。
###
注意:如果想修改模型的其他层,或者初始化中间层的参数,先对迁移后的finetune_net.eval(),看对应层的名字即可:
2.3封锁层的优化参数
如果想封住某些层的参数,可以按照如下操作:
def get_net(devices):
finetune_net = nn.Sequential()
finetune_net.features = torchvision.models.resnet34(pretrained=True)
# 定义⼀个新的输出⽹络,共有120个输出类别
finetune_net.output_new = nn.Sequential(nn.Linear(1000, 256),
nn.ReLU(),
nn.Linear(256, 120))
# 将模型参数分配给⽤于计算的CPU或GPU
finetune_net = finetune_net.to(devices[0])
# 冻结参数
for param in finetune_net.features.parameters():
param.requires_grad = False
return finetune_net
这里是将features层封住,其中,这是在1节dog中的操作,这里把原resnet34中的所有层都赋予到features这个层上了:
但如果要使用封住参数的话,在后面的优化参数命令也要改一下:
trainer = torch.optim.SGD((param for param in net.parameters()
if param.requires_grad), lr=lr,
momentum=0.9, weight_decay=wd)
这样的话requires_grad=False的参数就不用参与训练优化更新了。
注意操作细节,如果是在原来层上添加一个output_new,则要先定义一个Sequential()。这样在这个定义了Seq的finetune_net后面加.自定义模块,就等同于finetune_net[0]\[1]\[i]...
2.4直观对比
这里对比了一下在apply参数随机初始化xavier命令中,分别设置只对fc层初始化和对所有层初始化(相当于没迁移)的区别:
只对fc:
所有层:
可以看到区别还是很明显的,只对fc层模型精确度一开始就比较高,说明前面的参数比随机初始有效得多。