中级数据 API - 宠物

! [ -e /content ] && pip install -Uqq fastai  # 在Colab上升级fastai

在计算机视觉中使用 DatasetsPipelineTfmdListsTransform

概述

在本教程中,我们将深入研究计算机视觉中用于收集数据的中级 API。首先,我们将看到如何使用:

  • Transform 来处理数据
  • Pipeline 来组合变换

这些只是具有附加功能的函数。对于数据集处理,我们将在第二部分中查看

  • TfmdLists 以在一组项目上应用一个 PipelineTransform
  • Datasets 以并行应用多个 PipelineTransform 在一组项目上并生成元组

一般规则是,当您的变换将输出元组 (输入,目标) 时使用 TfmdLists,而当您为每个输入/目标构建单独的 Pipeline 时使用 Datasets

在本教程之后,您可能会对连体网络教程感兴趣,该教程更加深入地介绍了数据 API,向您展示如何编写自定义类型以及如何自定义 show_batchshow_results 的行为。

from fastai.vision.all import *

处理数据

清理和处理数据是机器学习中最耗时的任务之一,这就是为什么fastai尽可能帮助您的原因。从本质上讲,为模型准备数据可以形式化为您对某些原始项目应用的一系列转换。例如,在经典的图像分类问题中,我们从文件名开始。我们必须打开相应的图像,调整大小,将它们转换为张量,可能还要应用某种数据增强,然后才能将它们批处理。这仅仅是我们模型的输入,对于目标,我们需要提取文件名的标签并将其转换为整数。

这个过程需要具有一定的可逆性,因为我们通常希望检查我们的数据,以仔细确认我们提供给模型的内容实际上是合理的。这就是为什么fastai通过Transforms来表示所有这些操作,有时您可以使用decode方法撤销这些操作。

转换

首先,我们将查看使用单个 MNIST 图像的基本步骤。我们将从一个文件名开始,逐步了解如何将其转换为可显示和用于建模的带标签图像。我们使用通常的 untar_data 下载我们的数据集(如果需要),并获取所有图像文件:

source = untar_data(URLs.MNIST_TINY)/'train'
items = get_image_files(source)
fn = items[0]; fn
Path('/home/jhoward/.fastai/data/mnist_tiny/train/3/9696.png')

我们将逐一查看每个所需的 Transform。以下是我们如何打开图像文件:

img = PILImage.create(fn); img

然后我们可以将其转换为一个 C*H*W 张量(表示通道 x 高度 x 宽度,这是 PyTorch 中的约定):

tconv = ToTensor()
img = tconv(img)
img.shape,type(img)
(torch.Size([3, 28, 28]), fastai.torch_core.TensorImage)

现在完成后,我们可以创建我们的标签。首先提取文本标签:

lbl = parent_label(fn); lbl
'3'

然后将其转换为整数以用于建模:

tcat = Categorize(vocab=['3','7'])
lbl = tcat(lbl); lbl
TensorCategory(0)

我们使用 decode 来逆转变换以进行显示。逆转 Categorize 变换结果会得到一个我们可以显示的类别名称:

lbld = tcat.decode(lbl)
lbld
'3'

管道

我们可以使用 Pipeline 来组合我们的图像步骤:

pipe = Pipeline([PILImage.create,tconv])
img = pipe(fn)
img.shape
torch.Size([3, 28, 28])

一个Pipeline可以解码并显示一个项目。

pipe.show(img, figsize=(1,1), cmap='Greys');

show方法在幕后处理类型。转换将确保接收到的元素的类型得以保留。在这里,PILImage.create返回一个PILImage,它知道如何显示自己。tconv将其转换为一个TensorImage,它也知道如何显示自己。

type(img)
fastai.torch_core.TensorImage

这些类型也用于根据接收到的输入启用不同的行为(例如,对图像、分割掩码或边界框进行数据增强的方法并不相同)。

仅使用 Transform 加载宠物数据集

让我们看看如何使用 fastai.data 来处理宠物数据集。如果你习惯于编写自己的 PyTorch Dataset,那么将一切写成一个 Transform 会更自然。我们使用 source 来指代数据的基本来源(例如,磁盘上的一个目录、数据库连接、网络连接等)。然后我们获取这些项。

source = untar_data(URLs.PETS)/"images"
items = get_image_files(source)

我们将使用这个函数从图像文件中创建大小一致的张量:

def resized_image(fn:Path, sz=128):
    x = Image.open(fn).convert('RGB').resize((sz,sz))
    # 将图像转换为张量以供建模
    return tensor(array(x)).permute(2,0,1).float()/255.

在我们创建 Transform 之前,我们需要一个知道如何展示自己的类型(如果我们想使用 show 方法的话)。在这里我们定义一个 TitledImage

class TitledImage(fastuple):
    def show(self, ctx=None, **kwargs): show_titled_image(self, ctx=ctx, **kwargs)

我们来检查一下它是否有效:

img = resized_image(items[0])
TitledImage(img,'test title').show()

使用解码来显示处理过的数据

为了解码数据以便显示(例如去归一化图像或将索引转换回其对应的类),我们在Transform中实现一个decodes方法。

class PetTfm(Transform):
    def __init__(self, vocab, o2i, lblr): self.vocab,self.o2i,self.lblr = vocab,o2i,lblr
    def encodes(self, o): return [resized_image(o), self.o2i[self.lblr(o)]]
    def decodes(self, x): return TitledImage(x[0],self.vocab[x[1]])

Transform 在一侧打开和调整图像的大小,给它贴上标签,并在另一侧使用 o2i 将该标签转换为索引。在 decodes 方法中,我们使用 vocab 解码索引。图像保持原样(我们无法真正显示文件名!)。

要使用此 Transform,我们需要一个标签函数。在这里,我们在文件名的 name 属性上使用正则表达式:

labeller = using_attr(RegexLabeller(pat = r'^(.*)_\d+.jpg$'), 'name')

然后我们收集所有可能的标签,使其唯一并使用 bidir=True 请求两个对应关系(词汇和o2i)。然后我们可以利用它们构建我们的宠物转换。

vals = list(map(labeller, items))
vocab,o2i = uniqueify(vals, sort=True, bidir=True)
pets = PetTfm(vocab,o2i,labeller)

我们可以检查它是如何应用于文件名的:

x,y = pets(items[0])
x.shape,y
(torch.Size([3, 128, 128]), 14)

我们可以解码我们转换后的版本并展示它:

dec = pets.decode([x,y])
dec.show()

请注意,类似于 __call__encodes,我们实现了一个 decodes 方法,但实际上我们在我们的 Transform 上调用的是 decode

还请注意,我们的 decodes 方法接收了两个对象(x 和 y)。我们在前一节中提到,Transform 在元组上进行分发(无论是编码还是解码),但这里它将我们的两个元素作为一个整体处理,而没有试图分开解码 x 和 y。这是因为我们将一个列表 [x,y] 传递给 decodes。Transform 在元组上进行分发,但仅限于元组。正如我们所看到的,为了防止 Transform 在元组上进行分发,我们只需将其变为 ItemTransform

class PetTfm(ItemTransform):
    def __init__(self, vocab, o2i, lblr): self.vocab,self.o2i,self.lblr = vocab,o2i,lblr
    def encodes(self, o): return (resized_image(o), self.o2i[self.lblr(o)])
    def decodes(self, x): return TitledImage(x[0],self.vocab[x[1]])
dec = pets.decode(pets(items[0]))
dec.show()

使用设置来配置内部状态

我们现在可以让我们的 ItemTransform 自动从数据中推断其状态。这样,当我们将 Transform 与数据结合时,它将自动进行设置,而无需任何操作。这非常简单:只需将之前用于在 transform 中构建类别的代码行复制到一个 setups 方法中:

class PetTfm(ItemTransform):
    def setups(self, items):
        self.labeller = using_attr(RegexLabeller(pat = r'^(.*)_\d+.jpg$'), 'name')
        vals = map(self.labeller, items)
        self.vocab,self.o2i = uniqueify(vals, sort=True, bidir=True)

    def encodes(self, o): return (resized_image(o), self.o2i[self.labeller(o)])
    def decodes(self, x): return TitledImage(x[0],self.vocab[x[1]])

现在我们可以创建我们的 Transform,调用它的设置,它将准备好使用:

pets = PetTfm()
pets.setup(items)
x,y = pets(items[0])
x.shape, y
(torch.Size([3, 128, 128]), 14)

与之前一样,解码它并没有问题:

dec = pets.decode((x,y))
dec.show()

将我们的 Transform 与数据增强结合在一个 Pipeline 中。

我们可以利用fastai的数据增强变换,如果我们为我们的元素提供正确的类型。与其返回标准的PIL.Image,不如让我们的变换返回fastai类型的PILImage,这样我们就可以使用任何fastai的变换。让我们先为我们的第一个元素返回一个PILImage

class PetTfm(ItemTransform):
    def setups(self, items):
        self.labeller = using_attr(RegexLabeller(pat = r'^(.*)_\d+.jpg$'), 'name')
        vals = map(self.labeller, items)
        self.vocab,self.o2i = uniqueify(vals, sort=True, bidir=True)

    def encodes(self, o): return (PILImage.create(o), self.o2i[self.labeller(o)])
    def decodes(self, x): return TitledImage(x[0],self.vocab[x[1]])

我们可以将该转换与 ToTensorResizeFlipItem 结合起来,在一个 Pipeline 中随机翻转我们的图像:

tfms = Pipeline([PetTfm(), Resize(224), FlipItem(p=1), ToTensor()])

Pipeline上调用setup将按顺序设置每个转换:

tfms.setup(items)

为了检查设置是否正确,我们想看看是否构建了词汇。Pipeline 的一个酷妙技巧是,当请求一个属性时,它会在每个 Transform 中查找该属性,并给你结果(如果该属性在多个转换中,则给你结果列表):

tfms.vocab
['Abyssinian',
 'Bengal',
 'Birman',
 'Bombay',
 'British_Shorthair',
 'Egyptian_Mau',
 'Maine_Coon',
 'Persian',
 'Ragdoll',
 'Russian_Blue',
 'Siamese',
 'Sphynx',
 'american_bulldog',
 'american_pit_bull_terrier',
 'basset_hound',
 'beagle',
 'boxer',
 'chihuahua',
 'english_cocker_spaniel',
 'english_setter',
 'german_shorthaired',
 'great_pyrenees',
 'havanese',
 'japanese_chin',
 'keeshond',
 'leonberger',
 'miniature_pinscher',
 'newfoundland',
 'pomeranian',
 'pug',
 'saint_bernard',
 'samoyed',
 'scottish_terrier',
 'shiba_inu',
 'staffordshire_bull_terrier',
 'wheaten_terrier',
 'yorkshire_terrier']

然后我们可以调用我们的管道:

x,y = tfms(items[0])
x.shape,y
(torch.Size([3, 224, 224]), 14)

我们可以看到 ToTensorResize 被应用到了我们元组的第一个元素(它的类型是 PILImage),但没有应用到第二个元素。我们甚至可以查看我们的元素以检查翻转是否也已应用:

tfms.show(tfms(items[0]))

Pipeline.show 将在每个 Transform 上调用 decode,直到找到一个知道如何显示自己的类型。库将元组视为知道如何显示自己,如果它的所有部分都有一个 show 方法。在到达 PetTfm 之前并不会发生这种情况,因为我们元组的第二部分是一个整数。但在解码原始的 PetTfm 之后,我们得到了一个具有 show 方法的 TitledImage

需要注意的是,PipelineTransform 是根据它们内部的 order 属性排序的(默认值为 order=0)。您可以通过查看其表示形式,随时检查 Pipeline 中变换的顺序:

tfms
Pipeline: PetTfm -> FlipItem -- {'p': 1} -> Resize -- {'size': (224, 224), 'method': 'crop', 'pad_mode': 'reflection', 'resamples': (<Resampling.BILINEAR: 2>, <Resampling.NEAREST: 0>), 'p': 1.0} -> ToTensor

即使我们在 FlipItem 之前使用 Resize 定义了 tfms,我们仍然可以看到它们已经被重新排序,因为我们有:

FlipItem.order,Resize.order
(0, 1)

要自定义 Transform 的顺序,只需在 __init__ 之前设置 order = ...(这是一项类属性)。我们将 PetTfm 的顺序设置为 -5,以确保它始终首先运行:

class PetTfm(ItemTransform):
    order = -5
    def setups(self, items):
        self.labeller = using_attr(RegexLabeller(pat = r'^(.*)_\d+.jpg$'), 'name')
        vals = map(self.labeller, items)
        self.vocab,self.o2i = uniqueify(vals, sort=True, bidir=True)

    def encodes(self, o): return (PILImage.create(o), self.o2i[self.labeller(o)])
    def decodes(self, x): return TitledImage(x[0],self.vocab[x[1]])

然后我们可以打乱我们 Pipeline 中转换的顺序,但它会自行修复:

tfms = Pipeline([Resize(224), PetTfm(), FlipItem(p=1), ToTensor()])
tfms
Pipeline: PetTfm -> FlipItem -- {'p': 1} -> Resize -- {'size': (224, 224), 'method': 'crop', 'pad_mode': 'reflection', 'resamples': (<Resampling.BILINEAR: 2>, <Resampling.NEAREST: 0>), 'p': 1.0} -> ToTensor

现在我们有了一个良好的Pipeline转换,让我们将其添加到一个文件名列表中以构建我们的数据集。在fastai中,Pipeline与集合结合就是TfmdLists

TfmdListsDatasets

TfmdListsDatasets 之间的主要区别在于你拥有的 Pipeline 的数量:TfmdLists 使用一个 Pipeline 来转换一个列表(就像我们目前所拥有的),而 Datasets 将多个 Pipeline 并行组合,以从一组原始项中创建一个元组,例如一个元组 (输入, 目标)。

一个管道生成 TfmdLists

创建一个 TfmdLists 只需一个项目列表和一个转换列表,这些将被组合在一个 Pipeline 中:

tls = TfmdLists(items, [Resize(224), PetTfm(), FlipItem(p=0.5), ToTensor()])
x,y = tls[0]
x.shape,y
(torch.Size([3, 224, 224]), 14)

我们不需要向 PetTfm 传递任何内容,这要感谢我们的设置方法:在初始化时,Pipeline 已自动在 items 上完成设置,因此 PetTfm 像之前一样创建了它的词汇:

tls.vocab
['Abyssinian',
 'Bengal',
 'Birman',
 'Bombay',
 'British_Shorthair',
 'Egyptian_Mau',
 'Maine_Coon',
 'Persian',
 'Ragdoll',
 'Russian_Blue',
 'Siamese',
 'Sphynx',
 'american_bulldog',
 'american_pit_bull_terrier',
 'basset_hound',
 'beagle',
 'boxer',
 'chihuahua',
 'english_cocker_spaniel',
 'english_setter',
 'german_shorthaired',
 'great_pyrenees',
 'havanese',
 'japanese_chin',
 'keeshond',
 'leonberger',
 'miniature_pinscher',
 'newfoundland',
 'pomeranian',
 'pug',
 'saint_bernard',
 'samoyed',
 'scottish_terrier',
 'shiba_inu',
 'staffordshire_bull_terrier',
 'wheaten_terrier',
 'yorkshire_terrier']

我们可以要求 TfmdLists 显示我们获得的项目:

tls.show((x,y))

或者我们可以使用 show_at 的快捷方式:

show_at(tls, 0)

训练集和验证集

TfmdLists 名称中的 ‘s’ 表示它可以表示多个转换后的列表:您的训练集和验证集。要使用该功能,我们只需在初始化时传递 splitssplits 应该是一个包含索引列表的列表(每个集合对应一个列表)。为了帮助创建拆分,我们可以使用 fastai 库的所有 splitters

splits = RandomSplitter(seed=42)(items)
splits
((#5912) [5643,5317,5806,3460,613,5456,2968,3741,10,4908...],
 (#1478) [4512,4290,5770,706,2200,4320,6450,501,1290,6435...])
tls = TfmdLists(items, [Resize(224), PetTfm(), FlipItem(p=0.5), ToTensor()], splits=splits)

然后你的 tls 得到了训练和验证属性(之前它也有这些属性,但是验证是空的,训练包含了所有内容)。

show_at(tls.train, 0)

有趣的是,除非你传递 train_setup=False,否则你的转换只会在训练集上设置(这是最佳实践):setups 接收到的 items 仅是训练集的元素。

获取 DataLoaders

TfmdLists 获取 DataLoaders 对象非常简单,您只需调用 dataloaders 方法:

dls = tls.dataloaders(bs=64)

show_batch 将会正常工作:

dls.show_batch()

您甚至可以添加增强变换,因为我们有一个适当的 fastai 类型图像。只需记得添加 IntToFloatTensor 变换,它处理从整数到浮点数的转换(fastai 在 GPU 上的增强变换需要浮点张量)。在调用 TfmdLists.dataloaders 时,将 batch_tfms 传递给 after_batch(以及潜在的新 item_tfms 传递给 after_item):

dls = tls.dataloaders(bs=64, after_batch=[IntToFloatTensor(), *aug_transforms()])
dls.show_batch()

使用 Datasets

Datasets 将一系列变换(或一系列 Pipeline)惰性地应用于集合中的项目,针对每一系列变换/Pipeline 创建一个输出。这使我们能够更容易地分离出过程的步骤,从而可以更方便地重用和修改该过程。这为数据块 API 打下了基础:我们可以轻松地将类型混合和匹配作为输入或输出,因为它们与某些变换的管道相关联。

例如,让我们为图像或掩码编写我们自己的 ImageResizer 变换,并提供两种不同的实现:

class ImageResizer(Transform):
    order=1
    "Resize image to `size` using `resample`"
    def __init__(self, size, resample=BILINEAR):
        if not is_listy(size): size=(size,size)
        self.size,self.resample = (size[1],size[0]),resample

    def encodes(self, o:PILImage): return o.resize(size=self.size, resample=self.resample)
    def encodes(self, o:PILMask):  return o.resize(size=self.size, resample=NEAREST)

指定类型注解使得我们的变换对既不是 PILImage 也不是 PILMask 的对象不做任何处理,并且使用 self.resample 来调整图像大小,使用最近邻插值处理掩膜。为了创建 Datasets,我们随后传递两个变换管道,一个用于输入,一个用于目标:

tfms = [[PILImage.create, ImageResizer(128), ToTensor(), IntToFloatTensor()],
        [labeller, Categorize()]]
dsets = Datasets(items, tfms)

我们可以检查输入和输出是否具有正确的类型:

t = dsets[0]
type(t[0]),type(t[1])
(fastai.torch_core.TensorImage, fastai.torch_core.TensorCategory)

我们可以使用 dsets 解码并显示:

x,y = dsets.decode(t)
x.shape,y
(torch.Size([3, 128, 128]), 'basset_hound')
dsets.show(t);

我们可以像在 TfmdLists 中一样传递我们的训练/验证划分:

dsets = Datasets(items, tfms, splits=splits)

但我们在这里并没有使用 Transform 的元组分发特性。ImageResizerToTensorIntToFloatTensor 可以作为元组的变换传递。这在 .dataloaders 中通过将它们传递给 after_item 来实现。它们不会对类别做任何操作,只会应用于输入。

tfms = [[PILImage.create], [labeller, Categorize()]]
dsets = Datasets(items, tfms, splits=splits)
dls = dsets.dataloaders(bs=64, after_item=[ImageResizer(128), ToTensor(), IntToFloatTensor()])

我们可以使用 show_batch 来检查它是否有效:

dls.show_batch()

如果我们只想从我们的 Datasets(或之前的 TfmdLists)构建一个 DataLoader,可以直接将其传递给 TfmdDL

dsets = Datasets(items, tfms)
dl = TfmdDL(dsets, bs=64, after_item=[ImageResizer(128), ToTensor(), IntToFloatTensor()])

分割

通过在 after_item 中使用相同的变换,但使用不同类型的目标(这里是分割掩模),目标将被自动处理为应有的效果,使用类型调度系统。

cv_source = untar_data(URLs.CAMVID_TINY)
cv_items = get_image_files(cv_source/'images')
cv_splitter = RandomSplitter(seed=42)
cv_split = cv_splitter(cv_items)
cv_label = lambda o: cv_source/'labels'/f'{o.stem}_P{o.suffix}'
tfms = [[PILImage.create], [cv_label, PILMask.create]]
cv_dsets = Datasets(cv_items, tfms, splits=cv_split)
dls = cv_dsets.dataloaders(bs=64, after_item=[ImageResizer(128), ToTensor(), IntToFloatTensor()])
/home/jhoward/mambaforge/lib/python3.9/site-packages/torch/_tensor.py:1142: UserWarning: __floordiv__ is deprecated, and its behavior will change in a future version of pytorch. It currently rounds toward 0 (like the 'trunc' function NOT 'floor'). This results in incorrect rounding for negative values. To keep the current behavior, use torch.div(a, b, rounding_mode='trunc'), or for actual floor division, use torch.div(a, b, rounding_mode='floor').
  ret = func(*args, **kwargs)
dls.show_batch(max_n=4)

添加用于推理的测试数据加载器

让我们回顾一下我们的宠物数据集……

tfms = [[PILImage.create], [labeller, Categorize()]]
dsets = Datasets(items, tfms, splits=splits)
dls = dsets.dataloaders(bs=64, after_item=[ImageResizer(128), ToTensor(), IntToFloatTensor()])

…并假设我们有一些新的文件需要分类。

path = untar_data(URLs.PETS)
tst_files = get_image_files(path/"images")
len(tst_files)
7390

我们可以创建一个数据加载器,它接收这些文件,并使用DataLoaders.test_dl应用与验证集相同的变换:

tst_dl = dls.test_dl(tst_files)
tst_dl.show_batch(max_n=9)

额外:
您可以调用 learn.get_preds,传入新创建的数据加载器,以对我们新的图像进行预测!
真正酷的是,在您完成模型训练后,可以使用 learn.export 保存模型,这也会保存应用于数据的所有转换。在推断时,您只需使用 load_learner 加载学习器,即可立即使用 test_dl 创建数据加载器,以生成新的预测!

结束 -