“这张图片是怎么被误分类的?”
如果您曾经训练过机器学习模型,您就会知道接下来会发生什么:试图理解模型实际看到的内容的令人沮丧的旅程。您深入研究了层层转换 - 规范化、调整大小、增强 - 却发现您需要编写反函数才能再次查看数据。这太痛苦了,以至于我们中的许多人完全跳过了它,根据抽象的数字而不是实际数据来调试我们的模型。
或者正如 OpenAI 的 Greg Brockman 所说:
让我们看看您可能错过了什么。下面是一个使用 fastai 的简单示例:
from fastai.vision.all import * dls = ImageDataLoaders.from_folder( Path("./huskies_vs_wolves/"), item_tfms=RandomResizedCrop(128, min_scale=0.35), batch_tfms=Normalize.from_stats(*imagenet_stats) ) dls.show_batch() # One line to see our data
show_batch便于在转换后查看数据
learn = Learner(dls, xresnet34(n_out=2), metrics=accuracy) learn.fit_one_cycle(5, 0.0015) learn.show_results() # One line to see predictions
show_results允许您在训练模型后立即检查预测。预测标签 (0/1) 也会自动转换回其字符串表示形式。
# Two lines to see the model's biggest mistakes interp = Interpretation.from_learner(learn) interp.plot_top_losses(9)
plot_top_losses可视化模型“最自信地错误”的地方,这告诉我们最明显的问题。
仅凭这四行,我们就发现了一些有趣的事情:我们的“狼探测器”根本不在检测狼——它在检测雪!看看训练数据:雪地里的狼,森林里的哈士奇。然后看看预测:每当我们翻转背景时,模型就会失败。如果无法轻松可视化我们的数据,我们可能永远不会发现这个明显的缺陷。
LIME 技术可视化了模型如何专注于雪地背景以进行预测
虽然 LIME 等复杂的可解释性技术1可以精美地可视化模型关注图像的哪些部分(如上所示),通常最有价值的见解来自于能够亲眼查看数据。在这种情况下,快速的目视检查也揭示了明显的数据集偏差。
fastai 是如何做到这一点的?嗯,它使用了 - 一个看似简单但强大的想法,一直隐藏在 fastcore 的代码库中。今天,我们很高兴地宣布,我们已将其移至自己的库:fasttransform,因为我们相信它的应用程序可能超越机器学习。Transform
无论您是在处理图像、文本、时间序列还是任何其他需要处理的数据,fasttransform 都提供了一个简单的承诺:如果您可以以一种方式转换数据,那么您应该能够同样轻松地将其转换回来。无需编写反函数,无需再忽视数据。
让我们看看它是如何工作的。
问题 #1:单向变换
是否曾尝试过通过查看数据来调试机器学习管道?它通常是这样的:
- 加载数据
- 应用一些转换
- 试着找出哪里出了问题
- 意识到您实际上无法看到模型所看到的
- 在接下来的一个小时里编写反函数
- 放弃并改用 print 语句进行调试
让我们通过一个简单的示例来具体化这一点:使用 PyTorch 规范化图像:
from torchvision import transforms as T transforms_pt = T.Compose([ T.Resize(256), T.CenterCrop(224), T.ToTensor(), T.Normalize(*imagenet_stats) ]) # Load and transform an image img = Image.open("./huskies_vs_wolves/train/husky/husky_0.jpeg") img_transformed = transforms_pt(img) # Try to look at what we did... show_image(img_transformed);
Clipping input data to the valid range for imshow with RGB data ([0..1] for floats or [0..255] for integers). Got range [-2.1007793..2.2489083].
归一化是一个关键的预处理步骤,它将像素值缩放为具有相似的范围(通常平均值 = 0 和标准差 = 1),这有助于神经网络更有效地训练。
然而,归一化并不能真正使这张图片适合用人眼检查。要解决这个问题,我们需要手动编写一个反向转换:
def decode_pt(tensor, mean, std): """Decode a normalized PyTorch tensor back to RGB range""" out = tensor.clone() # Clone to avoid modifying original for t, m, s in zip(out, mean, std): t.mul_(s).add_(m) # Denormalize out = out.mul(255).clamp(0, 255).byte() # Scale back to RGB return out img_decoded = decode_pt(img_transformed, *imagenet_stats) show_image(img_decoded);
这不是什么晦涩难懂的问题。多年来,这一直是许多 ML 实践者的痛点:
而这只是为了一个简单的规范化。在实际项目中,您可能正在处理: - 需要与图像同步转换的分割掩码 - 带有分词、填充和特殊词元的文本数据 - 带有滑动窗口、规范化和编码的时间序列
每次转换都会增加另一层复杂性。最糟糕的是:因为查看转换后的数据非常痛苦,我们中的许多人只是......不要。我们最终根据抽象数字而不是实际数据来调试模型,希望我们的转换正在执行我们认为它们正在做的事情。
还记得在我们的 fastai 示例中准确查看模型所看到的内容是多么容易吗?这不是魔法 - 这是可逆转换的力量。让我们看看 fasttransform 如何实现这一点。
更好的方法:可逆管道
以下是 fastai 如何处理与上一节的 pytorch 示例相同的管道:
from fastai.vision.all import * transforms_ft = Pipeline([ PILImage.create, Resize(256,method="squish"), Resize(224,method="crop"), ToTensor(), IntToFloatTensor(), Normalize.from_stats(*imagenet_stats) ]) # Transform our image fpath = Path("./huskies_vs_wolves/train/husky/husky_0.jpeg") img_transformed = transforms_ft(fpath) show_image(img_transformed[0]); # Still looks wrong...
Clipping input data to the valid range for imshow with RGB data ([0..1] for floats or [0..255] for integers). Got range [-2.0836544..2.2317834].
# But now the magic: img_decoded = transforms_ft.decode(img_transformed) show_image(img_decoded[0]); # That's better!
就是这样。无需手动反函数。不记得均值和标准差。只是,我们又回到了我们可以真正关注的东西。.decode()
FastTransform 将此功能引入您自己的代码中。关键的见解是,对于要应用的任何转换,您可能已经知道如何撤消它。让我们看看它的内部是如何工作的。
运作方式:.decode()
fasttransform 背后的核心思想很简单:将 transform 与其 reverse 配对。
以下是编写可逆规范化转换的方法:
class Normalize(Transform):
def __init__(self, mean=None, std=None):
self.mean = mean
self.std = std
def encodes(self, x): return (x-self.mean) / self.std # forward transform
def decodes(self, x): return x*self.std + self.mean # inverse transform
就这样。
通过同时定义 和 ,fasttransform 会自动知道如何反转转换。将此与我们之前的 PyTorch 示例进行比较 - 我们不是编写单独的正向和反函数,而是将它们放在它们所属的位置。encodes
decodes
你可能会注意到这个奇特的名字 - 还有一个 's'。我们稍后会解释原因,但这与 fasttransform 如何自动处理不同类型的数据有关。encodes
decodes
当您调用 时,fasttransform 会智能地决定要反转哪些转换。某些转换(例如加载图像或调整图像大小)不需要撤消,您实际上希望看到模型所看到的内容!其他的,比如规范化,需要反转才能让人看到。decode()
你怎么做呢?好吧,只有在需要反转 transform 时才定义一个方法!.decodes
简介的绘图函数正是使用此功能将转换后的输入转换回人类可解释的状态。
问题 #2:处理多种类型
我们已经看到,使转换可逆如何使查看数据变得更加容易。但是,使用转换时还有另一个挑战:不同类型的数据需要不同的转换。
您最常看到这种情况,您的输入和标签需要不同的转换。同样的原则在这里也适用。我们希望将所有这些转换放在一个地方,因为我们希望能够撤消它们。例如,我们希望将分类标签从字符串转换为整数,然后再转换回字符串,以便于人类阅读。但我们不想为输入和输出维护单独的转换管道。
要了解为什么这是一个问题,让我们看看 PyTorch(最流行的深度学习框架之一)如何处理这种情况。以下是本教程中的一个示例,其中显示了一个典型的自定义数据集:
class CustomImageDataset(Dataset):
def __init__(self, annotations_file, img_dir, transform=None, target_transform=None):
self.img_labels = pd.read_csv(annotations_file)
self.img_dir = img_dir
self.transform = transform
self.target_transform = target_transform # <- separate target transform
def __len__(self):
return len(self.img_labels)
def __getitem__(self, idx):
img_path = os.path.join(self.img_dir, self.img_labels.iloc[idx, 0])
image = read_image(img_path)
label = self.img_labels.iloc[idx, 1]
if self.transform:
image = self.transform(image)
if self.target_transform: # <- separate target transform
label = self.target_transform(label)
return image, label
图像和标签的转换是单独定义的,并提供给 dataset 类。这种分离乍一看似乎很合理,但它会产生两个问题:
- 当我们想要反转转换时,我们必须记住反转两个管道
- 当我们有需要同时应用于输入和目标的转换时,我们必须将它们保留在两个位置(例如,调整图像和蒙版大小以进行图像分割)
让我们看看 fasttransform 如何使这变得更容易。
更好的方法:输入和输出都使用一个管道
这就是 fasttransform 方法的亮点:它不是处理单独的管道,而是在单个转换中同时处理您的图像及其标签。将元组传递给转换时,它仅应用相关的转换。这听起来像是一件小事,但它改变了现实世界机器学习工作的游戏规则。
让我们看看它的实际效果。
首先,我们将创建一个同时加载图像及其标签的函数:
def load_img_and_label(fp): return PILImage.create(fp), parent_label(fp)
load_img_and_label(fpath)
(PILImage mode=RGB size=375x500, 'husky')
现在是很酷的部分 - 我们只需做一个小改动,就可以在转换管道中使用这个函数。看看这有多干净:
transforms_ft = Pipeline([
load_img_and_label, # <-- Load img and label as a tuple
Resize(256,method="squish"),
Resize(224,method="crop"),
ToTensor(),
IntToFloatTensor(),
Normalize.from_stats(*imagenet_stats)
])
out = transforms_ft(fpath)
print((out[0][0,:2,:2,:2], out[1]))
(TensorImage([[[-0.2856, -0.2856],
[-0.2856, -0.2856]],
[[ 0.5553, 0.5553],
[ 0.5553, 0.5553]]], device='mps:0'), 'husky')
但我们还没有完成!这些字符串标签 (“husky”、“wolf”) 需要转换为我们模型的数字。在 PyTorch 中,我们需要一个单独的转换管道来实现此目的。使用 fasttransform,我们只添加另一个仅适用于字符串的 transform:
class StrCategorize(Transform):
def __init__(self, vocab):
self.vocab = vocab
self.s2i = {s:i for i,s in enumerate(vocab)}
self.i2s = {i:s for i,s in enumerate(vocab)}
def encodes(self, s:str): return self.s2i[s]
def decodes(self, i:int): return self.i2s[i]
transforms_ft = Pipeline([
load_img_and_label,
Resize(256,method="squish"),
Resize(224,method="crop"),
ToTensor(),
IntToFloatTensor(),
Normalize.from_stats(*imagenet_stats),
StrCategorize(vocab=['husky','wolf']), # <-- Transform is just for the target label
])
out = transforms_ft(fpath)
print((out[0][0,:2,:2,:2], out[1]))
(TensorImage([[[-0.2856, -0.2856],
[-0.2856, -0.2856]],
[[ 0.5553, 0.5553],
[ 0.5553, 0.5553]]], device='mps:0'), 0)
你可能会想,“好吧,将转换保存在一个管道中很好,但它真的那么重要吗?
嗯,一个好处是现在您还可以一次性再次反转两个转换:
rev = transforms_ft.decode(out)
print((rev[0][0,:2,:2,:2], rev[1]))
(TensorImage([[[107, 107],
[107, 107]],
[[148, 148],
[148, 148]]], device='mps:0'), 'husky')
接下来,我们将展示另一个示例,说明为什么将这些转换保存在一个位置至关重要:图像分割。
在分割中,您尝试识别图像中的特定区域 - 就像在照片中寻找哈士奇一样。但这里有一个棘手的部分:您的输入图像和目标蒙版都需要以完全相同的方式转换。当你使用随机转换作为数据增强的一种形式时,这就会变得很棘手。举个例子,如果你对图像应用了随机裁剪,那么你最好以完全相同的方式裁剪该蒙版!
让我们看看这在实践中是什么样子的。首先,我们定义一个新函数,用于加载图像及其相应的掩码:
fnames = list(Path("./segment_huskies/img/").glob("*")) fn = fnames[0] def load_img_msk(fn): return PILImage.create(fn), PILMask.create(fn.parent.parent / "msk" / fn.name) img, msk = load_img_msk(fn) show_images([img,msk])
现在,如果我们想随机裁剪图像和蒙版(一种常见的增强技术),则需要以完全相同的方式裁剪它们。如果它们没有对齐,那么您的整个训练数据就会变得毫无意义。
以下是 fasttransform 的处理方式:
transforms_ft = Pipeline([
load_img_msk, # <-- New load func for img and mask
RandomResizedCrop(200), # Applied to both img and mask
ToTensor(), # Applied to both img and mask
IntToFloatTensor(), # Only applied to img
Normalize.from_stats(*imagenet_stats) # Only applied to img
])
out = transforms_ft(fn)
out
show_images((out[0][0], out[1]))
Clipping input data to the valid range for imshow with RGB data ([0..1] for floats or [0..255] for
瞧,源图像和目标蒙版都以相同的方式进行了转换。
如果这些转换存储在不同的管道中,那么保持这些转换同步会困难得多。特别是因为转换中存在随机元素。
另外,请注意,倒车仍然同样简单:
rev = transforms_ft.decode(out)
show_images((rev[0][0], rev[1]))
此时,您可能会想:“这真是太棒了 - 一个管道处理不同类型的数据,在需要时应用于相关的转换。但它实际上是如何运作的呢?
好吧,让我们接下来深入研究一下!
工作原理:多重调度
使 Transforms 仅适用于相关数据类型的秘诀是称为 multiple dispatch 的东西。如果您以前没有听说过它,请不要担心 - 这是一个强大的编程概念,在 Julia 等语言中很受欢迎2,但在 Python 中相对不为人知。
将多次分派视为具有同一函数的不同版本,每个版本都旨在处理特定类型的数据。当您调用该函数时,Python 会根据您提供的内容自动选择正确的版本。
Python 提供了一个开箱即用的仅限于单参数函数的实现:
from functools import singledispatch
@singledispatch
def greet(x): return "Hello stranger!"
@greet.register
def _(x:str): return f"Hello {x}!"
@greet.register
def _(x:int): return f"Hello number {x}!"
greet(None), greet("Alice"), greet(42)
('Hello stranger!', 'Hello Alice!', 'Hello number 42!')
Multiple dispatch 将这个想法扩展到具有多个参数的函数。虽然 Python 的内置工具只处理单个参数分派,但 plum 库为任意数量的参数提供真正的多重分派。下面是一个简单的示例来说明这个概念:
from plum import dispatch
class Dog: pass
class Cat: pass
@dispatch
def greet(a: Cat, b: Dog):
return "Hiss!"
@dispatch
def greet(a: Dog, b: Cat):
return "Grrrr..."
# Let's try it out
cat, dog = Cat(), Dog()
print(greet(cat, dog)) # "Hiss!"
print(greet(dog, cat)) # Grrrr...
Hiss!
Grrrr...
Transform 在内部使用 plum 的多重调度功能,但核心思想是相同的:根据它接收的运行时数据类型调用正确的函数。这就是允许单个管道处理图像、标签、掩码和其他类型的数据的原因。
有三种不同的方法可以在转换中定义特定于类型的行为,每种方法都适用于不同的情况。让我们依次看看每一个。
创建 transform 的最简单方法是直接向其传递 functions。这非常适合快速实验或一次性转换:
# Method 1: Direct functions
def enc_str(x:str): return f"encoded str: {x=}"
def enc_int(x:int): return f"encoded int: {x=}"
my_transform = Transform(enc=(enc_str,enc_int))
my_transform(("hello", 42))
("encoded str: x='hello'", 'encoded int: x=42')
在进行原型设计时,或者不需要在代码中的其他位置重用转换时,可以使用此方法。但是对于更结构化的代码,您可能需要创建一个合适的类......
子类化为您提供了一种更有条理的方式来处理不同类型的类型:Transform
# Method 2: Create a Transform subclass
class MyTransform(Transform):
def encodes(self, x:str): return f"encoded str: {x=}"
def encodes(self, x:int): return f"encoded int: {x=}"
my_transform = MyTransform()
my_transform(("my str", 42))
("encoded str: x='my str'", 'encoded int: x=42')
请注意这里有趣的事情:在常规 Python 类中,您不能多次定义相同的方法。但是当从 子类化时,你可以!Transform
该方法会自动设置为多次分派,因此 Python 根据输入类型知道要调用哪个版本。encodes
但是还有一种定义转换的方法,当您想要扩展现有转换时,该方法特别有用......
# Method 3: Extend with decorators
@MyTransform
def encodes(self, x: float): return f"encoded float: {x=}"
# Now our transform handles three types!
my_transform(("hello", 42, 6.28))
("encoded str: x='hello'", 'encoded int: x=42', 'encoded float: x=6.28')
这种装饰器语法在实际应用程序中非常有用。
例如,在 fastai 中,transform 在 core 库中定义以处理图像,但其他模块可以扩展它以使用新类型:Normalize
# In fastai.data.transforms:
class Normalize(Transform): ... # handles image normalization
# In fastai.tabular.core:
@Normalize
def encodes(self, x: pd.DataFrame): ... # adds DataFrame support
这种类似插件的架构意味着任何人都可以扩展现有转换以处理新的数据类型,而无需修改原始代码。这就是 multiple dispatch in action 的力量!
当代码在 fastai 周围的生态系统中被重用和扩展时,真正的力量就会显现出来。像 fastxtend 这样的库增加了对新数据类型的支持,而无需修改原始代码。如果没有多次分派,他们将面临一个典型的继承问题。相反,使用 fasttransform,他们只需为现有转换注册新行为即可。
结论
我们已经看到了 fasttransform 如何解决数据处理中的两个基本问题:
- 通过成对的编码/解码方法使转换可逆
- 通过多次分派处理不同的数据类型
虽然这些想法源于 fastai 的深度学习需求,但它们的应用范围远不止于此。无论您是处理图像、文本、时间序列还是量子态,fasttransform 都提供了一个简单的承诺:如果您可以以一种方式转换数据,那么您应该能够同样轻松地将其转换回来。
准备好亲自尝试了吗?使用以下方式安装 fasttransform:
pip install fasttransform
查看我们的文档以获取更多示例和详细的 API 参考。如果您已经在使用 fastcore 的 dispatch 和 transform 模块,那么您可能需要查看我们的迁移指南。
我们很想听听你如何在自己的项目中使用 fasttransform!
注:
-
数据集改编自介绍 LIME 技术的学术论文。该数据集旨在展示他们突出显示雪地背景的技术,因为该技术在识别哈士奇方面最不合适。资料来源:Ribeiro、Marco Tulio、Sameer Singh 和 Carlos Guestrin。“”我为什么要信任你?”解释任何分类器的预测。第 22 届 ACM SIGKDD 知识发现和数据挖掘国际会议论文
发表评论 取消回复