本实验的实验环境为kaggle notebook
比赛链接:www.kaggle.com/competition...
首先观察数据,本次比赛提供的数据共有193个,包含192个.tfrec文件和一个 .csv 文件,.csv 文件为提交比赛结果的样本文件,仅供参考。
erlang
data/
├── tfrecords-jpeg-192x192/
├── tfrecords-jpeg-224x224/
│ ├── test/
│ │ ├── 00-224x224-462.tfrec
│ │ ├── 01-224x224-462.tfrec
│ │ ├── ...
│ │ └── 15-224x224-452.tfrec
│ ├── train/
│ │ ├── 00-224x224-798.tfrec
│ │ ├── 01-224x224-798.tfrec
│ │ ├── ...
│ │ └── 15-224x224-783.tfrec
│ └── val/
│ ├── 00-224x224-232.tfrec
│ ├── 01-224x224-232.tfrec
│ ├── ...
│ └── 15-224x224-232.tfrec
├── tfrecords-jpeg-331x331/
├── tfrecords-jpeg-512x512/
└── sample_submission.csv
注意到本次实验提供的数据文件都是.tfrec文件,该文件类型为 TensorFlow 专业数据文件,本实验使用 PyTorch,因此需要做数据处理,将 .tfrec 文件转化为 Tensor 格式,另外本次实验仅使用 tfrecords-jpeg-224x224 文件夹下的数据文件。
先来看看本次实验所需要的库
python
import io
import timm
import torch
import torch.nn as nn
import torchvision.transforms as transforms
from torch.utils.data import Dataset
from torch.utils.data import DataLoader
from pathlib import Path
from PIL import Image
import tfrecord
import glob
读取文件
首先我们需要看看数据长什么样,.tfrec 文件是一种二进制文件,我们无法直接看到图片长什么样,以及标签,我们需要对其解析。
python
train_files = sorted(glob.glob("/kaggle/input/competitions/tpu-getting-started/tfrecords-jpeg-224x224/train/*.tfrec"))
print(len(train_files)) # output: 16
print(train_files[0])
把 tfrecords-jpeg-224x224 文件夹下的训练数据都存入 train_files 列表中,输出一个列表长度看看,应该是16,因为 train 文件夹下有16个文件,也就是训练数据被划分成了16个切片,而每个切片中都有798条数据,除了最后一个切片有783条。
python
reader = tfrecord.tfrecord_loader(
data_path=train_files[0],
index_path=None,
description={} # 空的,读出原始字段名
)
for record in reader:
print(record.keys())
break
接下来我们解读一下 tfrecord_loader 方法的这三个参数:
- 由于我们只使用第一个切片,也就是 00-224x224-798.tfrec 这个文件,因此data_path设置为列表中的第一个元素。
- index_path 是说 TFRecord 可以配一个索引文件来随机加速读取,我们没有所以就设为None即可。
- description 则是告诉库我要读哪些字段、字段是什么类型,之所以设置这个参数是因为 .tfrec 文件本身存的是二进制数据,没有类型信息,库不知道该怎么解析,因此我们需要告诉库我们需要读取哪些字段,还要说清楚字段的类型,填 { } 空字典是一个取巧的方式,让它把所有字段都原始读出来,这样我们就能看到字段名叫什么,再决定怎么填。
print 之后应该可以看到以下输出
output
dict_keys(['id', 'class', 'image'])
接下来正式解析一条数据:
python
description = {
"image": "byte",
"class": "int",
"id": "byte",
}
reader = tfrecord.tfrecord_loader(
data_path=train_files[0],
index_path=None,
description=description,
)
for record in reader:
print(record["class"])
print(type(record["image"]))
print(len(record["image"]))
break
我们现在知道有哪些字段,就可以正式定义一下 description 字典了,但问题是类型怎么填?很简单,全部填 byte 之后看看会不会报错就行了,报错信息会告诉你正确类型是什么。输出如下:
output
[57]
<class 'bytes'>
25512
数据集
知道了数据长什么样,现在就可以定义本次实验需要的数据集类型
python
class PetalsDataset(Dataset):
def __init__(self, root_dir, type, transform):
self.type = type
self.root_dir = root_dir
self.type_path = Path(root_dir) / type
self.files_list = sorted(glob.glob(f"{self.type_path}/*.tfrec"))
self.transform = transform
self.samples = []
description = {"image": "byte", "class": "int", "id": "byte"} if self.type!='test' else {"image": "byte", "id": "byte"}
for path in self.files_list:
reader = tfrecord.tfrecord_loader(path, None, description)
for record in reader:
self.samples.append(record)
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
record:dict = self.samples[idx]
img = Image.open(io.BytesIO(record['image'])).convert('RGB')
if self.transform:
img = self.transform(img)
if self.type == 'test':
return img
label = record['class'][0]
return img, label
注意此代码中的数据处理部分,和上面讲的一样,训练数据被划分为16个分片,分别储存在16个 .tfrec 文件中,而每个 .tfrec 文件中又有798个样本,因此总共的训练数据量应为为16x798=12,768,但最后一个.tfrec文件只有783个样本,因此实际数据量应为12,753。代码中嵌套两层循环,第一层读取每个.tfrec文件,用 tfrecord_loader 方法把 .tfrec 文件解析并赋值到 reader 对象中;第二层循环用 record 对象遍历 reader,将每个 reader 中的798个样本储存进 samples 列表,因此 samples 列表长度应为12,753。
同时我定义了一个 type 输入参数,用于指定该 Dataset 是训练集,验证集还是测试集。注意测试集是没有 class 字段的,所以在定义测试集的 description 要去掉该字段。
然后定义 transfrom 用于转换图片文件为 Tensor 格式,用于之后模型的输入。
python
train_transform = transforms.Compose([
transforms.RandomResizedCrop(224, scale=(0.5, 1.0)),
transforms.RandomHorizontalFlip(p=0.5),
transforms.RandomVerticalFlip(p=0.5),
transforms.RandomRotation(45),
transforms.ColorJitter(0.4, 0.4, 0.4, hue=0.2),
transforms.RandomGrayscale(p=0.1),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
])
val_transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
])
解释一下 transfrom 中的一些增强吧:
- RandomResizedCrop:这个作用是随机裁剪图片的一部分,再缩放至 224x224,scale 参数表示裁剪面积是原图的50% ~ 100%(让模型学会从局部特征识别花朵,不依赖完整图片)
- RandomHorizontalFlip:这个作用是以50%的概率将当前这张图片按水平方向翻转(让模型学会对方向不敏感)
- RandomVerticalFlip:这个作用是以50%的概率将当前这张图片按垂直方向翻转(同上)
- RandomRotation:这个作用是随机旋转图片,范围是-45° ~ 45°(同上)
- ColorJitter:这个作用是随即改变图片的颜色属性,参数分别代表着(亮度变化范围,对比度变化范围,饱和度变化范围,色调变化范围)(让模型不依赖固定颜色来识别花朵,毕竟同一种花朵再不同光照以及拍摄手法上颜色会有所不同嘛)
- RandomGrayscale:这个作用是随机以10%的概率把图片变成灰度图(让模型不只是依赖颜色,也学会从形状和纹理识别花朵)
接下来把数据集对象定义一下就好了
python
root_dir = '/kaggle/input/competitions/tpu-getting-started/tfrecords-jpeg-224x224'
train_dataset = PetalsDataset(root_dir, type='train', transform=train_transform)
val_dataset = PetalsDataset(root_dir, type='val', transform=val_transform)
train_dataloader = DataLoader(train_dataset, batch_size=32, shuffle=True)
val_dataloader = DataLoader(val_dataset, batch_size=32, shuffle=False)
注意我是直接在 kaggle notebook 上使用的数据,root_dir 要写成 .tfrec 文件的根目录。
模型
Dataset 和 Dataloader 定义好之后就可以着手定义模型了,先尝试自己搭建一个 CNN 模型:
python
class ConvBlock(nn.Module):
def __init__(self, in_channels, out_channels):
super().__init__()
self.block = nn.Sequential(
nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2)
)
def forward(self, x):
return self.block(x)
python
class CNN(nn.Module):
def __init__(self):
super().__init__()
self.feature = nn.Sequential(
ConvBlock(3, 32),
ConvBlock(32, 64),
ConvBlock(64, 128)
)
self.classification = nn.Sequential(
nn.Flatten(),
nn.Linear(128 * 28 * 28, 512),
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(512, 104)
)
def forward(self, x):
x = self.feature(x)
x = self.classification(x)
return x
model = CNN()
这个模型其实就是按照吴恩达的pytorch基础课程里的 CNN 代码搭建的。
模型训练
先定义训练模型所需要的设备、损失函数以及优化器
python
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(device)
model = model.to(device)
loss_function = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-2)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20)
然后定义训练循环
python
def train_epoch(train_dataloader, model, device, loss_function, optimizer):
model.train()
running_loss = 0.0
current = 0
total = 0
for batch_idx, (data, target) in enumerate(train_dataloader):
data, target = data.to(device), target.to(device)
optimizer.zero_grad()
outputs = model(data)
loss = loss_function(outputs, target)
loss.backward()
optimizer.step()
running_loss += loss.item()
_, predicted = outputs.max(1)
total += target.size(0)
current += predicted.eq(target).sum().item()
if batch_idx % 20 == 0 and batch_idx > 0:
avg_loss = running_loss / 20
accuracy = 100. * current / total
print(f"[{batch_idx * 32} / {399 * 32}] "
f"LOSS : {avg_loss:.3f} | accuracy : {accuracy:.1f}%")
running_loss = 0.0
注意这里的运行损失以二十次累计为一次输出,运行其他数据集时要根据dataloader的总批次来调整,本次实验的 train_dataloader 为399批,因此设定20个批次输出累计运行损失。
然后定义验证循环
python
def evaluate(model, test_loader, device):
model.eval()
correct = 0
total = 0
with torch.no_grad():
for inputs, targets in test_loader:
inputs, targets = inputs.to(device), targets.to(device)
outputs = model(inputs)
_, predicted = outputs.max(1)
total += targets.size(0)
correct += predicted.eq(targets).sum().item()
return 100. * correct / total
然后就可以开始模型训练了
python
num_epochs = 20
best_accuracy = 0
for epoch in range(num_epochs):
print(f'\nEpoch: {epoch+1}')
train_epoch(train_dataloader, model, device, loss_function, optimizer)
accuracy = evaluate(model, val_dataloader, device)
scheduler.step()
print(f'Test Accuracy:{accuracy:.2f}%')
if accuracy > best_accuracy:
best_accuracy = accuracy
torch.save(model.state_dict(), '/kaggle/working/best_model.pth')
print(f' 保存最佳模型 acc={accuracy:.2f}%')
运行以上代码,最终将会保存一个在验证集中准确率最高的模型,但这最高的准确率其实只有26%左右,非常低对吧,当我看到结果是这样确实很沮丧,但经过了解之后才发现这很正常,这只是随手定义的一个模型,非常简单,而且没有调过参数,只是一个神经网络模型的地基。
增强
想要达到更高的准确率,就需要用到预训练模型进行迁移训练,本次实验定义的 CNN 模型只有三层卷积层,远远不够复杂的图片分类,在实际比赛中通常优先选择较好的预训练模型,接下来使用 convnext_base 模型,ConvNeXt 是一种 CNN 增强版本,由Meta提出。
python
model = timm.create_model('convnext_base', pretrained=True, num_classes=104, drop_rate=0.4,drop_path_rate=0.2)
这个模型是预训练模型,已经学习了很多关于图片的特征,所以只要训练一两轮就会达到很高的准确率,这里我把训练次数下调到10次。
使用这个模型重新跑一遍,模型的准确率可以提高至91%,提交至比赛也能有0.90的分数。
但是现在还有个问题就是,当跑完一遍代码后,能发现即使模型在训练集模型有很高的准确率,甚至到达了100%,但验证集只有90%左右,相差了10%,这就是典型的过拟合了,接下来解决过拟合的问题, 我使用了一种常用且十分有效的方法,混合图片。
python
from torchvision.transforms.v2 import MixUp
mixup = MixUp(alpha=0.2, num_classes=104)
原理就是将两张图片叠加在一起,半透明的感觉,标签也按比例混合。正常训练模型会死记硬背每张图片的特性,而Mixup让模型看到的每张图片都是混合的,永远没有一模一样的图片,强迫模型学习更加通用的特征而不是死记硬背。
同时需要在训练循环中加上如下代码:
python
def train_epoch(train_dataloader, model, device, loss_function, optimizer):
model.train()
running_loss = 0.0
current = 0
total = 0
for batch_idx, (data, target) in enumerate(train_dataloader):
data, target = data.to(device), target.to(device)
data, target = mixup(data, target) #<---
#......
total += target.size(0)
current += predicted.eq(target.argmax(1)).sum().item() #这里要使用target的argmax
之所以要改成 target.argmax(1) 是因为 mixup 之后的 target 不再是标签([48, 77, 0, ...]),而是标签的概率分布([[0,0,...,1,...,0], ...])。
这下再提交分数已经达到了0.96了!超过了95%,完美达到我的预期。
提交
最后看一下生成提交文件的代码吧
python
all_ids = []
all_preds = []
for img_ids, inputs in test_dataloader:
inputs = inputs.to(device)
outputs = test_model(inputs)
_, predicted = outputs.max(1)
all_ids.extend(img_ids)
all_preds.extend(predicted.cpu().numpy())
df = pd.DataFrame({
'id': all_ids,
'label': all_preds
})
df.to_csv('submission.csv', index=False)
print(df.head())
另外如果想使用已经保存好的模型,就使用如下代码:
python
test_model = timm.create_model('convnext_base', pretrained=True, num_classes=104, drop_rate=0.4,drop_path_rate=0.2)
state_dict = torch.load('/kaggle/input/models/huuhgodona/convnext-model/pytorch/default/1/best_convnext.pth', map_location='cuda')
test_model.load_state_dict(state_dict)
test_model = test_model.to(device)
test_model.eval()
.pth 文件只保存模型所需要的参数,所以使用前要先定义好需要的模型(和 .pth 文件对应的模型),然后再把参数导入进去,自己定义的 CNN 也一样。