中层数据 API - Pets

在计算机视觉中使用 DatasetsPipelineTfmdListsTransform

概述

在本教程中,我们将深入探讨计算机视觉中用于数据收集的中间层 API。首先,我们将了解如何使用

  • Transform 处理数据
  • Pipeline 组合 transforms

这些只是添加了功能的函数。对于数据集处理,我们将在第二部分中探讨

  • TfmdLists 将一个 PipelineTransform 应用于一组项目
  • Datasets 将多个 PipelineTransform 并行应用于一组项目并生成元组

通常的规则是,当您的 transforms 将输出元组 (输入, 目标) 时,使用 TfmdLists;当您为每个输入/目标构建独立的 Pipeline 时,使用 Datasets

在本教程之后,您可能会对 siamese 教程感兴趣,它更深入地讲解了数据 API,向您展示如何编写自定义类型以及如何定制 show_batchshow_results 的行为。

from fastai.vision.all import *

处理数据

数据清洗和处理是机器学习中最耗时的工作之一,这就是为什么 fastai 竭尽全力为您提供帮助的原因。其核心在于,为模型准备数据可以形式化为对一些原始项目应用一系列变换。例如,在经典的图像分类问题中,我们从文件名开始。我们需要打开相应的图像,调整大小,将它们转换为 tensors,可能还会应用某种数据增强,然后才能进行批处理。而这仅仅是针对模型的输入,对于目标,我们需要从文件名中提取标签并将其转换为整数。

这个过程需要具备一定的可逆性,因为我们经常需要检查数据以再次确认输入到模型中的数据是否真的有意义。这就是为什么 fastai 使用 Transform 来表示所有这些操作,您有时可以使用 decode 方法撤销它们。

Transform

首先,我们将使用一张 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 tensor(C 代表通道,H 代表高度,W 代表宽度,这是 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 来反向 transforms 以便显示。反转 Categorize transform 会得到一个可以显示的类别名称

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

Pipeline

我们可以使用 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 方法在幕后使用类型。Transforms 将确保它们接收到的元素的类型得以保留。在这里,PILImage.create 返回一个 PILImage,它知道如何显示自身。tconv 将其转换为一个 TensorImage,它也知道如何显示自身。

type(img)
fastai.torch_core.TensorImage

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

仅使用 Transform 加载 pets 数据集

让我们看看如何使用 fastai.data 处理 Pets 数据集。如果您习惯于编写自己的 PyTorch Dataset,那么将所有内容写在一个 Transform 中会感觉更自然。我们使用 source 指代数据的底层来源(例如,磁盘上的目录、数据库连接、网络连接等)。然后我们获取项目。

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

我们将使用此函数从图像文件创建大小一致的 tensors

def resized_image(fn:Path, sz=128):
    x = Image.open(fn).convert('RGB').resize((sz,sz))
    # Convert image to tensor for modeling
    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()

使用 decodes 显示处理后的数据

为了解码数据以供显示(例如对图像进行反归一化或将索引转换回其对应的类别),我们在 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 获取两个对应关系 (vocab 和 o2i)。然后我们可以使用它们构建我们的宠物 transform。

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 会对元组进行 dispatch(无论是编码还是解码),但在这里它将我们的两个元素作为一个整体处理,并没有尝试分别解码 x 和 y。为什么会这样?这是因为我们向 decodes 传递了一个列表 [x,y]Transform 只对元组进行 dispatch。而且正如我们所见,要阻止 Transform 对元组进行 dispatch,我们只需将其设为 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()

使用 setups 设置内部状态

现在我们可以让 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,调用其 setup 方法,它就可以使用了

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()

Pipeline 中将我们的 Transform 与数据增强结合起来。

如果我们的元素类型正确,我们可以利用 fastai 的数据增强 transforms。如果我们的 transform 返回 fastai 类型 PILImage,而不是标准的 PIL.Image,那么我们就可以使用 fastai 的任何 transform。让我们只为第一个元素返回一个 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]])

然后我们可以将该 transform 与 ToTensorResizeFlipItem 结合起来,在 Pipeline 中随机翻转图像

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

Pipeline 调用 setup 将按顺序设置每个 transform

tfms.setup(items)

为了检查 setup 是否正确完成,我们需要看看是否构建了 vocab。Pipeline 的一个很酷的技巧是,当查询一个属性时,它会遍历其每个 Transform 查找该属性,并返回结果(如果该属性存在于多个 transforms 中,则返回结果列表)

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']

然后我们可以调用我们的 pipeline

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

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

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

Pipeline.show 会对每个 Transform 调用 decode,直到获得一个知道如何显示自身的类型。库认为一个元组知道如何显示自身,当且仅当其所有部分都有一个 show 方法。在这里,在我们到达 PetTfm 之前不会发生这种情况,因为我们元组的第二部分是一个 int。但在解码原始的 PetTfm 之后,我们得到一个带有 show 方法的 TitledImage

需要注意的一点是,Pipeline 中的 Transform 是根据它们的内部 order 属性排序的(默认为 order=0)。您可以通过查看 Pipeline 的表示来检查 transforms 的顺序

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

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

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

要自定义 Transform 的顺序,只需在 __init__ 之前设置 order = ...(这是一个类属性)。让我们将 PetTfm 的 order 设置为 -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 中 transforms 的顺序,它也会自行调整

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

现在我们有了不错的 transforms Pipeline,让我们将其添加到文件名列表中以构建数据集。在 fastai 中,一个 Pipeline 与一个集合组合在一起就是一个 TfmdLists

TfmdListsDatasets

TfmdListsDatasets 的主要区别在于您拥有的 Pipeline 数量:TfmdLists 接受一个 Pipeline 来转换一个列表(就像我们目前拥有的那样),而 Datasets 则并行组合多个 Pipeline,从一组原始项目创建元组,例如 (输入, 目标) 元组。

一个 pipeline 创建一个 TfmdLists

创建 TfmdLists 只需一个项目列表和一个 transforms 列表,这些 transforms 将组合在一个 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)

由于我们的 setup 方法,我们无需向 PetTfm 传递任何东西:在初始化期间,Pipelineitems 上自动进行了 setup,因此 PetTfm 和之前一样创建了它的 vocab

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 应该是一个索引列表的列表(每个集合一个列表)。为了帮助创建 splits,我们可以使用 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 就有了 train 和 valid 属性(之前也有,但 valid 是空的,train 包含所有内容)。

show_at(tls.train, 0)

一件有趣的事情是,除非您传递 train_setup=False,否则您的 transforms 只会在训练集上进行 setup(这是最佳实践):setups 接收到的 items 仅是训练集中的元素。

获取 DataLoaders

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

dls = tls.dataloaders(bs=64)

然后 show_batch 就能正常工作

dls.show_batch()

您甚至可以添加数据增强 transforms,因为我们有正确 fastai 类型的图像。只需记住添加处理 int 到 float 转换的 IntToFloatTensor transform(fastai 在 GPU 上的数据增强 transforms 需要 float tensors)。调用 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 将 transforms 列表的列表(或 Pipeline 列表)延迟地应用于集合的项目,为每个 transforms/Pipeline 列表创建一个输出。这使得我们可以更轻松地分离出处理步骤,以便我们可以重用它们并更轻松地修改处理过程。这就是数据块 API 的基础:我们可以轻松地混合和匹配作为输入或输出的类型,因为它们与特定的 transforms pipelines 相关联。

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

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)

指定类型注解使得我们的 transform 对既不是 PILImage 也不是 PILMask 的对象不做任何操作,并使用 self.resample 调整图像大小,使用最近邻插值调整掩码大小。要创建 Datasets,然后我们传递两个 transforms pipelines,一个用于输入,一个用于目标

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 中一样传递我们的训练/验证 split

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

但在这里我们没有利用 Transform 会对元组进行 dispatch 的事实。ImageResizerToTensorIntToFloatTensor 可以作为对元组的 transforms 进行传递。这在 .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 中使用相同的 transforms,但针对不同类型的目标(此处为分割掩码),目标将通过类型 dispatch 系统自动按照应有的方式进行处理。

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)

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

让我们回到我们的 pets 数据集……

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

我们可以创建一个 dataloader,它接受这些文件并应用与验证集相同的 transforms,使用 DataLoaders.test_dl

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

额外内容
您可以调用 learn.get_preds 并传入这个新创建的 dataloaders,对我们的新图像进行预测!
真正酷炫的是,训练完模型后,您可以使用 learn.export 保存它,这也会保存所有需要应用于数据的 transforms。在推理时,您只需使用 load_learner 加载您的 learner,然后立即使用 test_dl 创建一个 dataloader,就可以用它来生成新的预测了!