12.6.2 猫狗二分类
深度学习是机器学习的一个分支,目前常用的深度学习框架有 TensorFlow、PyTorch和飞桨等(飞桨,即 PaddlePaddle,全中文的官方文档,让学习者不为语言而担忧)。本小节中将以 PyTorch 演示一个经典的案例,让初学 Python 的读者对深度学习有感性地认识。所以,以下代码可不求甚解,只要能认识到所涉及到的基础知识并不陌生即可——除了 PyTorch 部分。
“Dogs vs. Cats”是一个传统的二分类问题,下面示例所用的数据集来自于 kaggle.com ,在项目网页(https://www.kaggle.com/c/dogs-vs-cats-redux-kernels-edition/)上可以看到两个压缩包(登录网站之后可以下载),train.zip
用作训练集(其中一部分作为验证集),test.zip
用作测试集。在训练集中(将所下载的 train.zip
,解压缩之后,放到 ./data/train
目录中),所有图片都是用 cat.<id>.jpg
和 dog.<id>.jpg
格式命名,用图片文件的名称作为每张图片的标签(如图12-6-2所示)。
图12-6-2 训练集中的图片
1. 组织数据
将所下载的压缩包 train.zip
在目录 ./data
中解压缩。并在 ./data/train
中创建两个子目录,即 ./data/train/cats
和 ./data/train/dogs
。然后创建子目录 ./data/val
,在其中也创建另两个子目录 ./data/val/dogs
和 ./data/val/cats
。将 ./data/train
中的数据称为训练集,./data/val
中的称为验证集。
将所下载的压缩包 test.zip
也在 ./data
中解压缩,得到子目录 ./data/test
,此目录中的数据称为测试集。
经过上述操作之后,得到了如下所示的目录结构:
代码语言:javascript复制% tree data -d
data
├── test
├── train
│ ├── cats
│ └── dogs
└── val
├── cats
└── dogs
7 directories
然后写一段代码,按照前述要求,将本来已经存在 ./data/train
中的图片,按照文件名中所标示出的 dog
和 cat
分别移动到 ./data/train/cats
和 ./data/train/dogs
目录中。
[11]: import re
import shutil
import os
# 训练集目录
train_dir = "./data/train"
train_dogs_dir = f"{train_dir}/dogs"
train_cats_dir = f"{train_dir}/cats"
# 验证集目录
val_dir = "./data/val"
val_dogs_dir = f"{val_dir}/dogs"
val_cats_dir = f"{val_dir}/cats"
files = os.listdir(train_dir)
for f in files:
cat_search = re.search('cat', f) # (1)
dog_search = re.search('dog', f)
if cat_search:
shutil.move(f'{train_dir}/{f}', train_cats_dir) # (2)
if dog_search:
shutil.move(f"{train_dir}/{f}", train_dogs_dir)
在代码 [11] 中将前面所创建的目录结构分别用变量引用,并且实现了图片移动。注释(1)中使用了标准库中的 re
模块,用正则表达式判断字符串 cat
是否在文件名中。例如(在 Python 交互模式中演示):
>>> import re
>>> bool(re.search('cat', 'cat.5699.jpg'))
True
>>> bool(re.search('cat', 'dog.10149.jpg'))
False
关于模块 re
的更多内容,可以参考官方文档(https://docs.python.org/3/library/re.html)。
注释(2)中使用的 shutil
模块也是 Python 标准库的一员,函数 shutil.move()
能够将文件移动到指定目录中( shutil 模块的官方文档地址:https://docs.python.org/3/library/shutil.html)。
运行代码块 [11] 后,将猫和狗的图片分别放在了两个不同的目录中,在 Jupyter 中可以这样查看( ls
是 Linux 命令):
[12]: print("目录 train_dir 中已经没有图片")
!ls {train_dir} | head -n 5
print("目录 train_dogs_dir 中是狗图片(显示5个)")
!ls {train_dogs_dir} | head -n 5
print("目录 train_cats_dir 中是猫图片(显示5个)")
!ls {train_cats_dir} | head -n 5
# 输出
目录 train_dir 中已经没有图片
cats
dogs
目录 train_dogs_dir 中是狗图片(显示5个)
dog.0.jpg
dog.1.jpg
dog.10.jpg
dog.100.jpg
dog.1000.jpg
目录 train_cats_dir 中是猫图片(显示5个)
cat.0.jpg
cat.1.jpg
cat.10.jpg
cat.100.jpg
cat.1000.jpg
在 ./data/train/cats
和 ./data/train/dogs
两个目录中,各有 12500 张图片,再从每个目录中取一部分(此处取 1000 张)图片分别放到对应的验证集目录 ./data/val/cats
和 ./data/val/dogs
中。
[13]: dogs_files = os.listdir(train_dogs_dir)
cats_files = os.listdir(train_cats_dir)
for dog in dogs_files:
val_dog_search = re.search("7ddd", dog)
if val_dog_search:
shutil.move(f"{train_dogs_dir}/{dog}", val_dogs_dir)
for cat in cats_files:
val_cat_search = re.search("7ddd", cat)
if val_cat_search:
shutil.move(f"{train_cats_dir}/{cat}", val_cats_dir)
print("目录 val_dogs_dir 中是狗图片")
!ls {val_dogs_dir} | head -n 5
print("目录 val_cats_dir 中是狗图片")
!ls {val_cats_dir} | head -n 5
# 输出:
目录 val_dogs_dir 中是狗图片
dog.7000.jpg
dog.7001.jpg
dog.7002.jpg
dog.7003.jpg
dog.7004.jpg
目录 val_cats_dir 中是狗图片
cat.7000.jpg
cat.7001.jpg
cat.7002.jpg
cat.7003.jpg
cat.7004.jpg
代码块 [13] 将文件名中 <id>
为 7000 至 7999 的图片移动到相应的验证集目录中。
2. 训练模型
数据已经组织好了,即将使用 PyTorch 创建并训练模型。PyTorch 的官方网站是:https://pytorch.org/ ,它提供了非常友好的 Python 接口,与其他第三方包一样,安装后即可使用。
代码语言:javascript复制% pip install torch torchvision
安装完毕。如果在如下所演示的 Jupyter 代码中提示无法找到 torch
,可以关闭并退出当前的 Jupyter Lab 后,从新执行 jupyter-lab
——如果还找不到 torch
,请在网上搜索有关资料,并结合本地环境进行修改(导致“搜索路径”问题的因素较多,比如环境变量设置等,需要读者细心、耐心地解决)。
[14]: import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from torch.optim import lr_scheduler
import torchvision
from torchvision import datasets, models, transforms
import matplotlib.pyplot as plt
import time
import os
import copy
import math
torch.__version__
[14]: '1.6.0'
按照代码块 [14] 将有关对象引入,此处所用的 PyTorch 版本是 1.6.0
,读者所安装的若不低于这个版本,代码一般通用。
再次声明,本节不是 PyTorch 的完整学习资料,所以对代码不会做非常详尽地解释,读者囫囵吞枣也无妨,只需要有初步体验即可。
在深度学习项目中,数据扩充(或称“数据增强”、“数据增广”,data augmentataion)往往是不可避免的,这是由于缺少海量数据,为了保证模型的有效性,本着“一分钱掰成两半花”的精神而进行的。最简单的数据扩充方法包括翻转、旋转、尺度变换等等。另外,由于不同的图片大小各异,也需要将图片尺寸规范到限定的范围。还有就是要张量化,才能用于模型的张量运算(关于“张量”的基本概念,参阅拙作《机器学习数学基础》)。
代码语言:javascript复制[15]: data_transforms = {
'train': transforms.Compose([
transforms.RandomRotation(5),
transforms.RandomHorizontalFlip(),
transforms.RandomResizedCrop(
224,
scale=(0.96, 1.0),
ratio=(0.95, 1.05)),
transforms.ToTensor(),
transforms.Normalize(
[0.485, 0.456, 0.406],
[0.229, 0.224, 0.225])]),
'val': transforms.Compose([
transforms.Resize([224, 224]),
transforms.ToTensor(),
transforms.Normalize(
[0.485, 0.456, 0.406],
[0.229, 0.224, 0.225])]),
}
然后使用 data_transforms
定义训练集和验证集数据,以及必要的常量。
[16]: data_dir = 'data'
CHECK_POINT_PATH = './data/checkpoint.tar'
SUBMISSION_FILE = "./data/submission.csv"
image_datasets = {x: datasets.ImageFolder(
os.path.join(data_dir, x),
data_transforms[x]) for x in ['train', 'val']}
dataloaders = {x: torch.utils.data.DataLoader(
image_datasets[x],
batch_size=4,
shuffle=True,
num_workers=4) for x in ['train', 'val']}
dataset_sizes = {x: len(image_datasets[x]) for x in ['train', 'val']}
class_names = image_datasets['train'].classes
device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
print(class_names)
print(f"Train image size: {dataset_sizes['train']}")
print(f"Validation image size: {dataset_sizes['val']}")
# 输出:
['cats', 'dogs']
Train image size: 23000
Validation image size: 2000
现在训练集中的图片数量是 23000,验证集有 2000 张图片。在代码块 [16] 的 dataloaders
中设置 batch_size=4
(batch,常译为“批”),下面就显示训练集中由 4 张图片组成的“1批”(随机抽取)。
[17]: def imshow(inp, title=None):
inp = inp.numpy().transpose((1, 2, 0))
mean = np.array([0.485, 0.456, 0.406])
std = np.array([0.229, 0.224, 0.225])
inp = std * inp mean
inp = np.clip(inp, 0, 1)
plt.imshow(inp)
if title is not None:
plt.title(title)
plt.pause(0.001)
inputs, classes = next(iter(dataloaders['train']))
sample_train_images = torchvision.utils.make_grid(inputs)
imshow(sample_train_images, title=classes)
输出图像:
这批(batch)图片即对应于张量 tensor([1, 0, 1, 0])
。
下面编写训练模型的函数。
代码语言:javascript复制[18]: def train_model(model, criterion, optimizer,
scheduler, num_epochs=2, checkpoint = None):
since = time.time()
if checkpoint is None:
best_model_wts = copy.deepcopy(model.state_dict())
best_loss = math.inf
best_acc = 0.
else:
print(f'Val loss: {checkpoint["best_val_loss"]},
Val accuracy: {checkpoint["best_val_accuracy"]}')
model.load_state_dict(checkpoint['model_state_dict'])
best_model_wts = copy.deepcopy(model.state_dict())
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
scheduler.load_state_dict(checkpoint['scheduler_state_dict'])
best_loss = checkpoint['best_val_loss']
best_acc = checkpoint['best_val_accuracy']
for epoch in range(num_epochs):
print('Epoch {}/{}'.format(epoch, num_epochs - 1))
print('-' * 10)
# 每轮(epoch)含一次训练和验证
for phase in ['train', 'val']:
if phase == 'train':
scheduler.step()
model.train()
else:
model.eval()
running_loss = 0.0
running_corrects = 0
# Iterate over data.
for i, (inputs, labels) in enumerate(dataloaders[phase]):
inputs = inputs.to(device)
labels = labels.to(device)
# 梯度归零
optimizer.zero_grad()
if i % 200 == 199:
print(f'[{epoch 1}, {i}] loss:
{running_loss/(i*inputs.size(0)):.3f}')
# 前向
# 跟踪训练过程
with torch.set_grad_enabled(phase == 'train'):
outputs = model(inputs)
_, preds = torch.max(outputs, 1)
loss = criterion(outputs, labels)
# 后向/反向,在训练过程中
if phase == 'train':
loss.backward()
optimizer.step()
# 统计
running_loss = loss.item() * inputs.size(0)
running_corrects = torch.sum(preds == labels.data)
epoch_loss = running_loss / dataset_sizes[phase]
epoch_acc = running_corrects.double()
/ dataset_sizes[phase]
print(f'{phase} Loss: {epoch_loss:.4f} Acc:
{epoch_acc:.4f}')
# 深拷贝模型
if phase == 'val' and epoch_loss < best_loss:
print(f'New best model found!')
print(f'New record loss: {epoch_loss},
previous record loss: {best_loss}')
best_loss = epoch_loss
best_acc = epoch_acc
best_model_wts = copy.deepcopy(model.state_dict())
print()
time_elapsed = time.time() - since
print(f'Training complete in
{time_elapsed // 60:.0f}m {time_elapsed % 60:.0f}s')
print(f'Best val Acc: {best_acc:.4f} Best val loss:
{best_loss:.4f}')
# 载入最佳的模型权重
model.load_state_dict(best_model_wts)
return model, best_loss, best_acc
代码块 [18] 的函数首先检查 checkpoint
的值,如果为 True
则会加载训练的模型,并在其基础上更新参数,否则会从头开始训练。在本示例中,我们提供了一个预训练的模型——在此基础上继续训练,可以减少训练时间——即代码块 [16] 中的 CHECK_POINT_PATH = './data/checkpoint.tar'
(读者可以在本书的源码仓库中获得,代码仓库地址参阅 www.itdiffer.com 中关于本书的在线资料)。
之后载入卷积神经网络模型,它擅长于图像识别。
代码语言:javascript复制[19]: model_conv = torchvision.models.resnet50(pretrained=True)
当前的任务是二分类,故还要对此卷积神经网络模型进行个性化设置,如定义损失函数(交叉熵,nn.CrossEntropyLoss()
)、优化器算法(随机梯度下降,SGD
)、学习率( lr_scheduler.StepLR()
)。
[20]: for param in model_conv.parameters(): # (3)
param.requires_grad = False
num_ftrs = model_conv.fc.in_features
model_conv.fc = nn.Linear(num_ftrs, 2)
model_conv = model_conv.to(device)
criterion = nn.CrossEntropyLoss()
optimizer_conv = optim.SGD(model_conv.fc.parameters(),
lr=0.001, momentum=0.9)
exp_lr_scheduler = lr_scheduler.StepLR(optimizer_conv,
step_size=7, gamma=0.1)
在代码块 [20] 的注释(3)中,设置 param.requires_grad = False
旨在仅训练所导入的卷积神经网络 resnet50
模型的最后一层的参数。如果要训练所有层的所有参数,需要将代码做如下修改:
for param in model_conv.parameters():
param.requires_grad = True
model_conv = model_conv.to(device)
optimizer_ft = optim.SGD(model_conv.parameters(), lr=0.001, momentum=0.9)
下面进入实质化的训练过程(训练过程所用的时间会因本地计算机的性能和配置有所不同,但都要耗费一段时间,需要耐心等待)。
代码语言:javascript复制[21]: try:
checkpoint = torch.load(CHECK_POINT_PATH)
print("checkpoint loaded")
except:
checkpoint = None
print("checkpoint not found")
model_conv, best_val_loss, best_val_acc =
train_model(model_conv,
criterion,
optimizer_conv,
exp_lr_scheduler,
num_epochs = 3,
checkpoint = checkpoint)
torch.save({'model_state_dict': model_conv.state_dict(),
'optimizer_state_dict': optimizer_conv.state_dict(),
'best_val_loss': best_val_loss,
'best_val_accuracy': best_val_acc,
'scheduler_state_dict' : exp_lr_scheduler.state_dict(),
}, CHECK_POINT_PATH)
# 输出
Val loss: 0.03336585155675109, Val accuracy: 1.0
Epoch 0/2
----------
[1, 199] loss: 0.240
... # 省略部分显示
train Loss: 0.2325 Acc: 0.9055
[1, 199] loss: 0.068
[1, 399] loss: 0.068
val Loss: 0.0699 Acc: 0.9770
Epoch 1/2
----------
[2, 199] loss: 0.196
... # 省略部分显示
train Loss: 0.2363 Acc: 0.9046
[2, 199] loss: 0.054
[2, 399] loss: 0.062
val Loss: 0.0664 Acc: 0.9770
Epoch 2/2
----------
[3, 199] loss: 0.255
... # 省略部分显示
train Loss: 0.2341 Acc: 0.9040
[3, 199] loss: 0.064
[3, 399] loss: 0.065
val Loss: 0.0651 Acc: 0.9760
Training complete in 349m 52s # 这是训练所用时间
Best val Acc: 1.0000 Best val loss: 0.0334
当代码块 [21] 终于运行完毕——一般而言,这是一个漫长的过程——就训练好了一个具有识别猫、狗能力的模型 model_conv
。
然后编写如下代码,检验模型在验证集上的“识别”结果。
代码语言:javascript复制[22]: def visualize_model(model, num_images=2):
was_training = model.training
model.eval()
images_so_far = 0
fig = plt.figure()
with torch.no_grad():
for i, (inputs, labels) in enumerate(dataloaders['val']):
inputs = inputs.to(device)
labels = labels.to(device)
outputs = model(inputs)
_, preds = torch.max(outputs, 1)
for j in range(inputs.size()[0]):
images_so_far = 1
ax = plt.subplot(num_images//2, 2, images_so_far)
ax.axis('off')
ax.set_title(f'predicted: {class_names[preds[j]]}')
imshow(inputs.cpu().data[j])
if images_so_far == num_images:
model.train(mode=was_training)
return
model.train(mode=was_training)
visualize_model(model_conv)
输出图示:
但这不是对模型的真正测试。
3. 测试模型
保存在子目录 ./data/test
里面的图片为“测试集” ,现在就用它们来检验模型的“识别”能力。测试集中的每个图片文件以 <id>.jpg
格式命名,从文件名上不知道它是猫还是狗。
本来,可以用 PyTorch 直接从 .data/test
中读入数据。但是,为了向读者多展示一些 Python 库的应用,此处改用另外的方式。首先,创建一个将图片转换为张量的函数(类似于代码块 [15] 的 data_transforms['val']
)。
[23]: def apply_test_transforms(inp):
out = transforms.functional.resize(inp, [224,224])
out = transforms.functional.to_tensor(out)
out = transforms.functional.normalize(out,
[0.485, 0.456, 0.406],
[0.229, 0.224, 0.225])
return out
Python 中关于图片的库被称为 Python Imageing Library ,简称 PIL ,其中 Pillow 是 PIL 的一个常用分支(同样是一个库),其官方网站是:https://pillow.readthedocs.io/。安装方法如下:
代码语言:javascript复制% pip install Pillow
然后用 PIL 从测试集中读取文件(以下代码中显示其中的一张图片)。
代码语言:javascript复制[24]: from PIL import Image
test_data_dir = f'{data_dir}/test'
test_data_files = os.listdir(test_data_dir)
im = Image.open(f'{test_data_dir}/{test_data_files[0]}')
plt.imshow(im)
输出图示:
我们已经看到代码块 [24] 中的 im
所引用的图片是一只猫。下面将 im
传入代码块 [23] 定义的函数进行变换。
[25]: im_as_tensor = apply_test_transforms(im)
print(im_as_tensor.size())
minibatch = torch.stack([im_as_tensor])
print(minibatch.size())
# 输出
torch.Size([3, 224, 224])
torch.Size([1, 3, 224, 224])
再将 minibatch
传给模型 model_conv
,让它“辨别”图片是猫还是狗。
[26]: preds = model_conv(minibatch)
preds
[26]: tensor([[ 2.0083, -1.8386]], grad_fn=<AddmmBackward>)
返回值 preds
是一个张量,按照代码块 [26] 的张量输出结果,可知这张图片是猫(第一个数大于第二个数,则是猫)。如果用更直观地方式表述预测结果,可以:
[27]: soft_max = nn.Softmax(dim=1)
probs = soft_max(preds)
probs
[27]: tensor([[0.9791, 0.0209]], grad_fn=<SoftmaxBackward>)
将张量里面的数字转化为百分比,probs
的结果说明模型 model_conv
“认为”这张图片是猫的概率为 97.91% 。
4. 参加 kaggle 比赛
本小节的项目来自于 kaggle.com ,这是一个著名的深度学习竞赛网站,如果读者也有打算参加,必须要按照网站要求提交 submission.csv
的文件(“Dogs vs. Cats”的竞赛项目已经结束,读者可以参考下述方法,以备参加其他项目),基本格式为:
id,label
1,0.5
2,0.5
其中 id
是测试集(./data/test
)中所有图片的 <id>
,label
为该图片是狗的概率。为此,编写如下函数:
[28]: def predict_dog(model, tensor):
batch = torch.stack([tensor])
softMax = nn.Softmax(dim = 1)
preds = softMax(model(batch))
return preds[0, 1].item()
def test_data(fname):
im = Image.open(f'{test_data_dir}/{fname}')
return apply_test_transforms(im)
import re
def extract_file_id(fname): # 从文件名中提取 id
print("Extracting id from " fname)
return int(re.search('d ', fname).group())
然后执行模型的测试函数,并生成一个以 <id>
为键(整数类型,便于排序),以“是狗”的概率为值的字典(下面的代码需要要执行一段时间,测试集中共计 12500 张图片)。
[29]: model_conv.eval()
id_to_dog_prob = {extract_file_id(fname):
predict_dog(model_conv,test_data(fname))
for fname in test_data_files}
# 输出
Extracting id from 9733.jpg
... # 省略余下显示内容
为了最终得到 .csv
文件,再用 Pandas 将字典对象 id_to_dog_prob
转化为 DataFrame 对象,并保存为 .csv
文件。
[30]: import pandas as pd
ds = pd.Series({id : label
for (id, label) in
zip(id_to_dog_prob.keys(), id_to_dog_prob.values())})
df = pd.DataFrame(ds, columns = ['label']).sort_index()
df['id'] = df.index
df = df[['id', 'label']]
df.to_csv(SUBMISSION_FILE, index = False)
最后将 ./data/submission.csv
文件提交到 kaggle 网站即可——虽然此比赛项目已经结束,还可以自我辉煌战果:
[31]: df.sample(5) # 随机选出 5 条记录
[31]: id label
5548 5548 0.999494
8238 8238 0.998453
8961 8961 0.999983
4762 4762 0.003668
2623 2623 0.000197
★自学建议 如果读者有意将来从事机器学习有关的工作,所要学习的知识除了编程语言之外(最常用的编程语言是 Python ,此外还有 R 、Julia 等),还包括12.4节中科学计算的有关内容。除此之外,针对机器学习和深度学习都有一些库或开发框架,使用它们就相当于“站在巨人肩膀上”,或者说找到了“生产力工具”,比如 scikit-learn 、PyTorch、Tensorflow、飞桨(PaddlePaddle)等。 以上所列都是进入机器学习领域的技术准备,除了这些之外,还有一个前置的知识准备:足够的数学知识(参阅拙作《机器学习数学基础》,电子工业出版社)。