# 第十九章:从头开始创建一个 fastai 学习器 这最后一章(除了结论和在线章节)将会有所不同。它包含的代码比以前的章节要多得多,而叙述要少得多。我们将介绍新的 Python 关键字和库,而不进行讨论。这一章的目的是为您开展一项重要的研究项目。您将看到,我们将从头开始实现 fastai 和 PyTorch API 的许多关键部分,仅建立在我们在第十七章中开发的组件上!这里的关键目标是最终拥有自己的`Learner`类和一些回调函数,足以训练一个模型在 Imagenette 上,包括我们学习的每个关键技术的示例。在构建`Learner`的过程中,我们将创建我们自己的`Module`、`Parameter`和并行`DataLoader`的版本,这样您就会对 PyTorch 类的功能有一个很好的了解。 本章末尾的问卷调查对于本章非常重要。这是我们将指导您探索许多有趣方向的地方,使用本章作为起点。我们建议您在计算机上跟着本章进行学习,并进行大量的实验、网络搜索和其他必要的工作,以了解发生了什么。在本书的其余部分,您已经积累了足够的技能和专业知识来做到这一点,所以我们相信您会做得很好! 让我们开始手动收集一些数据。 # 数据 查看`untar_data`的源代码,看看它是如何工作的。我们将在这里使用它来访问 Imagene 的 160 像素版本,以在本章中使用: ```py path = untar_data(URLs.IMAGENETTE_160) ``` 要访问图像文件,我们可以使用`get_image_files`: ```py t = get_image_files(path) t[0] ``` ```py Path('/home/jhoward/.fastai/data/imagenette2-160/val/n03417042/n03417042_3752.JP > EG') ``` 或者我们可以使用 Python 的标准库`glob`来做同样的事情: ```py from glob import glob files = L(glob(f'{path}/**/*.JPEG', recursive=True)).map(Path) files[0] ``` ```py Path('/home/jhoward/.fastai/data/imagenette2-160/val/n03417042/n03417042_3752.JP > EG') ``` 如果您查看`get_image_files`的源代码,您会发现它使用了 Python 的`os.walk`;这是一个比`glob`更快、更灵活的函数,所以一定要尝试一下。 我们可以使用 Python Imaging Library 的`Image`类打开一张图片: ```py im = Image.open(files[0]) im ``` ![](img/dlcf_19in01.png) ```py im_t = tensor(im) im_t.shape ``` ```py torch.Size([160, 213, 3]) ``` 这将成为我们独立变量的基础。对于我们的因变量,我们可以使用`pathlib`中的`Path.parent`。首先,我们需要我们的词汇表 ```py lbls = files.map(Self.parent.name()).unique(); lbls ``` ```py (#10) ['n03417042','n03445777','n03888257','n03394916','n02979186','n03000684',' > n03425413','n01440764','n03028079','n02102040'] ``` 以及反向映射,感谢`L.val2idx`: ```py v2i = lbls.val2idx(); v2i ``` ```py {'n03417042': 0, 'n03445777': 1, 'n03888257': 2, 'n03394916': 3, 'n02979186': 4, 'n03000684': 5, 'n03425413': 6, 'n01440764': 7, 'n03028079': 8, 'n02102040': 9} ``` 这就是我们需要组合成`Dataset`的所有部分。 ## 数据集 在 PyTorch 中,`Dataset`可以是任何支持索引(`__getitem__`)和`len`的东西: ```py class Dataset: def __init__(self, fns): self.fns=fns def __len__(self): return len(self.fns) def __getitem__(self, i): im = Image.open(self.fns[i]).resize((64,64)).convert('RGB') y = v2i[self.fns[i].parent.name] return tensor(im).float()/255, tensor(y) ``` 我们需要一个训练和验证文件名列表传递给`Dataset.__init__`: ```py train_filt = L(o.parent.parent.name=='train' for o in files) train,valid = files[train_filt],files[~train_filt] len(train),len(valid) ``` ```py (9469, 3925) ``` 现在我们可以尝试一下: ```py train_ds,valid_ds = Dataset(train),Dataset(valid) x,y = train_ds[0] x.shape,y ``` ```py (torch.Size([64, 64, 3]), tensor(0)) ``` ```py show_image(x, title=lbls[y]); ``` ![](img/dlcf_19in02.png) 正如您所看到的,我们的数据集返回独立变量和因变量作为元组,这正是我们需要的。我们需要将这些整合成一个小批量。通常,可以使用`torch.stack`来完成这个任务,这就是我们将在这里使用的方法: ```py def collate(idxs, ds): xb,yb = zip(*[ds[i] for i in idxs]) return torch.stack(xb),torch.stack(yb) ``` 这是一个包含两个项目的小批量,用于测试我们的`collate`: ```py x,y = collate([1,2], train_ds) x.shape,y ``` ```py (torch.Size([2, 64, 64, 3]), tensor([0, 0])) ``` 现在我们有了数据集和一个整合函数,我们准备创建`DataLoader`。我们将在这里添加两个东西:一个可选的`shuffle`用于训练集,以及一个`ProcessPoolExecutor`来并行进行预处理。并行数据加载器非常重要,因为打开和解码 JPEG 图像是一个缓慢的过程。一个 CPU 核心不足以快速解码图像以使现代 GPU 保持繁忙。这是我们的`DataLoader`类: ```py class DataLoader: def __init__(self, ds, bs=128, shuffle=False, n_workers=1): self.ds,self.bs,self.shuffle,self.n_workers = ds,bs,shuffle,n_workers def __len__(self): return (len(self.ds)-1)//self.bs+1 def __iter__(self): idxs = L.range(self.ds) if self.shuffle: idxs = idxs.shuffle() chunks = [idxs[n:n+self.bs] for n in range(0, len(self.ds), self.bs)] with ProcessPoolExecutor(self.n_workers) as ex: yield from ex.map(collate, chunks, ds=self.ds) ``` 让我们尝试一下我们的训练和验证数据集: ```py n_workers = min(16, defaults.cpus) train_dl = DataLoader(train_ds, bs=128, shuffle=True, n_workers=n_workers) valid_dl = DataLoader(valid_ds, bs=256, shuffle=False, n_workers=n_workers) xb,yb = first(train_dl) xb.shape,yb.shape,len(train_dl) ``` ```py (torch.Size([128, 64, 64, 3]), torch.Size([128]), 74) ``` 这个数据加载器的速度不比 PyTorch 的慢,但它要简单得多。因此,如果您正在调试一个复杂的数据加载过程,不要害怕尝试手动操作,以帮助您准确地了解发生了什么。 对于归一化,我们需要图像统计数据。通常,可以在一个训练小批量上计算这些数据,因为这里不需要精度: ```py stats = [xb.mean((0,1,2)),xb.std((0,1,2))] stats ``` ```py [tensor([0.4544, 0.4453, 0.4141]), tensor([0.2812, 0.2766, 0.2981])] ``` 我们的`Normalize`类只需要存储这些统计数据并应用它们(要查看为什么需要`to_device`,请尝试将其注释掉,然后查看后面的笔记本中会发生什么): ```py class Normalize: def __init__(self, stats): self.stats=stats def __call__(self, x): if x.device != self.stats[0].device: self.stats = to_device(self.stats, x.device) return (x-self.stats[0])/self.stats[1] ``` 我们总是喜欢在笔记本中测试我们构建的一切,一旦我们构建它: ```py norm = Normalize(stats) def tfm_x(x): return norm(x).permute((0,3,1,2)) ``` ```py t = tfm_x(x) t.mean((0,2,3)),t.std((0,2,3)) ``` ```py (tensor([0.3732, 0.4907, 0.5633]), tensor([1.0212, 1.0311, 1.0131])) ``` 这里`tfm_x`不仅仅应用`Normalize`,还将轴顺序从`NHWC`排列为`NCHW`(如果你需要提醒这些首字母缩写指的是什么,请参阅第十三章)。PIL 使用`HWC`轴顺序,我们不能在 PyTorch 中使用,因此需要这个`permute`。 这就是我们模型的数据所需的全部内容。现在我们需要模型本身! # Module 和 Parameter 要创建一个模型,我们需要`Module`。要创建`Module`,我们需要`Parameter`,所以让我们从那里开始。回想一下,在第八章中我们说`Parameter`类“没有添加任何功能(除了自动调用`requires_grad_`)。它只用作一个‘标记’,以显示要包含在`parameters`中的内容。”这里有一个确切的定义: ```py class Parameter(Tensor): def __new__(self, x): return Tensor._make_subclass(Parameter, x, True) def __init__(self, *args, **kwargs): self.requires_grad_() ``` 这里的实现有点尴尬:我们必须定义特殊的`__new__` Python 方法,并使用内部的 PyTorch 方法`_make_subclass`,因为在撰写本文时,PyTorch 否则无法正确处理这种子类化或提供官方支持的 API 来执行此操作。也许在你阅读本文时,这个问题已经得到解决,所以请查看本书网站以获取更新的详细信息。 我们的`Parameter`现在表现得就像一个张量,正如我们所希望的: ```py Parameter(tensor(3.)) ``` ```py tensor(3., requires_grad=True) ``` 现在我们有了这个,我们可以定义`Module`: ```py class Module: def __init__(self): self.hook,self.params,self.children,self._training = None,[],[],False def register_parameters(self, *ps): self.params += ps def register_modules (self, *ms): self.children += ms @property def training(self): return self._training @training.setter def training(self,v): self._training = v for m in self.children: m.training=v def parameters(self): return self.params + sum([m.parameters() for m in self.children], []) def __setattr__(self,k,v): super().__setattr__(k,v) if isinstance(v,Parameter): self.register_parameters(v) if isinstance(v,Module): self.register_modules(v) def __call__(self, *args, **kwargs): res = self.forward(*args, **kwargs) if self.hook is not None: self.hook(res, args) return res def cuda(self): for p in self.parameters(): p.data = p.data.cuda() ``` 关键功能在`parameters`的定义中: ```py self.params + sum([m.parameters() for m in self.children], []) ``` 这意味着我们可以询问任何`Module`的参数,并且它将返回它们,包括所有子模块(递归地)。但是它是如何知道它的参数是什么的呢?这要归功于实现 Python 的特殊`__setattr__`方法,每当 Python 在类上设置属性时,它就会为我们调用。我们的实现包括这一行: ```py if isinstance(v,Parameter): self.register_parameters(v) ``` 正如你所看到的,这是我们将新的`Parameter`类用作“标记”的地方——任何属于这个类的东西都会被添加到我们的`params`中。 Python 的`__call__`允许我们定义当我们的对象被视为函数时会发生什么;我们只需调用`forward`(这里不存在,所以子类需要添加)。在我们这样做之前,如果定义了钩子,我们将调用一个钩子。现在你可以看到 PyTorch 的钩子并没有做任何花哨的事情——它们只是调用任何已注册的钩子。 除了这些功能之外,我们的`Module`还提供了`cuda`和`training`属性,我们很快会用到。 现在我们可以创建我们的第一个`Module`,即`ConvLayer`: ```py class ConvLayer(Module): def __init__(self, ni, nf, stride=1, bias=True, act=True): super().__init__() self.w = Parameter(torch.zeros(nf,ni,3,3)) self.b = Parameter(torch.zeros(nf)) if bias else None self.act,self.stride = act,stride init = nn.init.kaiming_normal_ if act else nn.init.xavier_normal_ init(self.w) def forward(self, x): x = F.conv2d(x, self.w, self.b, stride=self.stride, padding=1) if self.act: x = F.relu(x) return x ``` 我们不是从头开始实现`F.conv2d`,因为你应该已经在第十七章的问卷中使用`unfold`完成了这个任务。相反,我们只是创建了一个小类,将它与偏置和权重初始化一起包装起来。让我们检查它是否与`Module.parameters`正确工作: ```py l = ConvLayer(3, 4) len(l.parameters()) ``` ```py 2 ``` 并且我们可以调用它(这将导致`forward`被调用): ```py xbt = tfm_x(xb) r = l(xbt) r.shape ``` ```py torch.Size([128, 4, 64, 64]) ``` 同样,我们可以实现`Linear`: ```py class Linear(Module): def __init__(self, ni, nf): super().__init__() self.w = Parameter(torch.zeros(nf,ni)) self.b = Parameter(torch.zeros(nf)) nn.init.xavier_normal_(self.w) def forward(self, x): return x@self.w.t() + self.b ``` 测试一下是否有效: ```py l = Linear(4,2) r = l(torch.ones(3,4)) r.shape ``` ```py torch.Size([3, 2]) ``` 让我们也创建一个测试模块来检查,如果我们将多个参数作为属性包含,它们是否都被正确注册: ```py class T(Module): def __init__(self): super().__init__() self.c,self.l = ConvLayer(3,4),Linear(4,2) ``` 由于我们有一个卷积层和一个线性层,每个层都有权重和偏置,我们期望总共有四个参数: ```py t = T() len(t.parameters()) ``` ```py 4 ``` 我们还应该发现,在这个类上调用`cuda`会将所有这些参数放在 GPU 上: ```py t.cuda() t.l.w.device ``` ```py device(type='cuda', index=5) ``` 现在我们可以使用这些部分来创建一个 CNN。 ## 简单的 CNN 正如我们所见,`Sequential`类使许多架构更容易实现,所以让我们创建一个: ```py class Sequential(Module): def __init__(self, *layers): super().__init__() self.layers = layers self.register_modules(*layers) def forward(self, x): for l in self.layers: x = l(x) return x ``` 这里的`forward`方法只是依次调用每个层。请注意,我们必须使用我们在`Module`中定义的`register_modules`方法,否则`layers`的内容不会出现在`parameters`中。 # 所有的代码都在这里 请记住,我们在这里没有使用任何 PyTorch 模块的功能;我们正在自己定义一切。所以如果你不确定`register_modules`做什么,或者为什么需要它,再看看我们为`Module`编写的代码! 我们可以创建一个简化的`AdaptivePool`,它只处理到 1×1 输出的池化,并且也将其展平,只需使用`mean`: ```py class AdaptivePool(Module): def forward(self, x): return x.mean((2,3)) ``` 这就足够我们创建一个 CNN 了! ```py def simple_cnn(): return Sequential( ConvLayer(3 ,16 ,stride=2), #32 ConvLayer(16,32 ,stride=2), #16 ConvLayer(32,64 ,stride=2), # 8 ConvLayer(64,128,stride=2), # 4 AdaptivePool(), Linear(128, 10) ) ``` 让我们看看我们的参数是否都被正确注册了: ```py m = simple_cnn() len(m.parameters()) ``` ```py 10 ``` 现在我们可以尝试添加一个钩子。请注意,我们在`Module`中只留了一个钩子的空间;您可以将其变成列表,或者使用类似`Pipeline`的东西将几个钩子作为单个函数运行: ```py def print_stats(outp, inp): print (outp.mean().item(),outp.std().item()) for i in range(4): m.layers[i].hook = print_stats r = m(xbt) r.shape ``` ```py 0.5239089727401733 0.8776043057441711 0.43470510840415955 0.8347987532615662 0.4357188045978546 0.7621666193008423 0.46562111377716064 0.7416611313819885 torch.Size([128, 10]) ``` 我们有数据和模型。现在我们需要一个损失函数。 # 损失 我们已经看到如何定义“负对数似然”: ```py def nll(input, target): return -input[range(target.shape[0]), target].mean() ``` 实际上,这里没有对数,因为我们使用与 PyTorch 相同的定义。这意味着我们需要将对数与 softmax 放在一起: ```py def log_softmax(x): return (x.exp()/(x.exp().sum(-1,keepdim=True))).log() sm = log_softmax(r); sm[0][0] ``` ```py tensor(-1.2790, grad_fn=) ``` 将这些结合起来就得到了我们的交叉熵损失: ```py loss = nll(sm, yb) loss ``` ```py tensor(2.5666, grad_fn=) ``` 请注意公式 log a b = log ( a ) - log ( b ) 在计算对数 softmax 时,这给出了一个简化,之前定义为`(x.exp()/(x.exp().sum(-1))).log()`: ```py def log_softmax(x): return x - x.exp().sum(-1,keepdim=True).log() sm = log_softmax(r); sm[0][0] ``` ```py tensor(-1.2790, grad_fn=) ``` 然后,有一种更稳定的计算指数和的对数的方法,称为[*LogSumExp*技巧](https://oreil.ly/9UB0b)。这个想法是使用以下公式 log j=1 n e x j = log e a j=1 n e x j -a = a + log j=1 n e x j -a 其中*a*是x j的最大值。 以下是相同的代码: ```py x = torch.rand(5) a = x.max() x.exp().sum().log() == a + (x-a).exp().sum().log() ``` ```py tensor(True) ``` 我们将其放入一个函数中 ```py def logsumexp(x): m = x.max(-1)[0] return m + (x-m[:,None]).exp().sum(-1).log() logsumexp(r)[0] ``` ```py tensor(3.9784, grad_fn=) ``` 因此我们可以将其用于我们的`log_softmax`函数: ```py def log_softmax(x): return x - x.logsumexp(-1,keepdim=True) ``` 这与之前得到的结果相同: ```py sm = log_softmax(r); sm[0][0] ``` ```py tensor(-1.2790, grad_fn=) ``` 我们可以使用这些来创建`交叉熵`: ```py def cross_entropy(preds, yb): return nll(log_softmax(preds), yb).mean() ``` 现在让我们将所有这些部分组合起来创建一个`学习者`。 # 学习者 我们有数据、模型和损失函数;在我们可以拟合模型之前,我们只需要另一件事,那就是优化器!这里是 SGD: ```py class SGD: def __init__(self, params, lr, wd=0.): store_attr(self, 'params,lr,wd') def step(self): for p in self.params: p.data -= (p.grad.data + p.data*self.wd) * self.lr p.grad.data.zero_() ``` 正如我们在本书中所看到的,有了`学习者`生活就变得更容易了。`学习者`需要知道我们的训练和验证集,这意味着我们需要`DataLoaders`来存储它们。我们不需要任何其他功能,只需要一个地方来存储它们并访问它们: ```py class DataLoaders: def __init__(self, *dls): self.train,self.valid = dls dls = DataLoaders(train_dl,valid_dl) ``` 现在我们准备创建我们的`学习者`类: ```py class Learner: def __init__(self, model, dls, loss_func, lr, cbs, opt_func=SGD): store_attr(self, 'model,dls,loss_func,lr,cbs,opt_func') for cb in cbs: cb.learner = self ``` ```py def one_batch(self): self('before_batch') xb,yb = self.batch self.preds = self.model(xb) self.loss = self.loss_func(self.preds, yb) if self.model.training: self.loss.backward() self.opt.step() self('after_batch') def one_epoch(self, train): self.model.training = train self('before_epoch') dl = self.dls.train if train else self.dls.valid for self.num,self.batch in enumerate(progress_bar(dl, leave=False)): self.one_batch() self('after_epoch') def fit(self, n_epochs): self('before_fit') self.opt = self.opt_func(self.model.parameters(), self.lr) self.n_epochs = n_epochs try: for self.epoch in range(n_epochs): self.one_epoch(True) self.one_epoch(False) except CancelFitException: pass self('after_fit') def __call__(self,name): for cb in self.cbs: getattr(cb,name,noop)() ``` 这是我们在本书中创建的最大的类,但每个方法都非常小,所以通过依次查看每个方法,您应该能够理解发生了什么。 我们将调用的主要方法是`fit`。这个循环 ```py for self.epoch in range(n_epochs) ``` 并在每个 epoch 中分别调用`self.one_epoch`,然后`train=True`,然后`train=False`。然后`self.one_epoch`对`dls.train`或`dls.valid`中的每个批次调用`self.one_batch`,适当地(在将`DataLoader`包装在`fastprogress.progress_bar`之后)。最后,`self.one_batch`遵循我们在本书中看到的适合一个小批量的一系列步骤。 在每个步骤之前和之后,`Learner`调用`self`,`self`调用`__call__`(这是标准的 Python 功能)。`__call__`在`self.cbs`中的每个回调上使用`getattr(cb,name)`,这是 Python 的内置函数,返回具有请求名称的属性(在本例中是一个方法)。因此,例如,`self('before_fit')`将为每个定义了该方法的回调调用`cb.before_fit()`。 正如您所看到的,`Learner`实际上只是使用了我们的标准训练循环,只是在适当的时候还调用了回调。所以让我们定义一些回调! ## 回调 在`Learner.__init__`中,我们有 ```py for cb in cbs: cb.learner = self ``` 换句话说,每个回调都知道它是在哪个学习器中使用的。这是至关重要的,否则回调无法从学习器中获取信息,或者更改学习器中的内容。因为从学习器中获取信息是如此常见,我们通过将`Callback`定义为`GetAttr`的子类,并将默认属性定义为`learner`,使其更容易: ```py class Callback(GetAttr): _default='learner' ``` `GetAttr`是一个 fastai 类,为您实现了 Python 的标准`__getattr__`和`__dir__`方法,因此每当您尝试访问一个不存在的属性时,它会将请求传递给您定义为`_default`的内容。 例如,我们希望在`fit`开始时自动将所有模型参数移动到 GPU。我们可以通过将`before_fit`定义为`self.learner.model.cuda`来实现这一点;然而,由于`learner`是默认属性,并且我们让`SetupLearnerCB`继承自`Callback`(它继承自`GetAttr`),我们可以去掉`.learner`,只需调用`self.model.cuda`: ```py class SetupLearnerCB(Callback): def before_batch(self): xb,yb = to_device(self.batch) self.learner.batch = tfm_x(xb),yb def before_fit(self): self.model.cuda() ``` 在`SetupLearnerCB`中,我们还通过调用`to_device(self.batch)`将每个小批量移动到 GPU(我们也可以使用更长的`to_device(self.learner.batch)`)。然而,请注意,在`self.learner.batch = tfm_x(xb),yb`这一行中,我们不能去掉`.learner`,因为这里我们是*设置*属性,而不是获取它。 在尝试我们的`Learner`之前,让我们创建一个回调来跟踪和打印进度。否则,我们将无法真正知道它是否正常工作: ```py class TrackResults(Callback): def before_epoch(self): self.accs,self.losses,self.ns = [],[],[] def after_epoch(self): n = sum(self.ns) print(self.epoch, self.model.training, sum(self.losses).item()/n, sum(self.accs).item()/n) def after_batch(self): xb,yb = self.batch acc = (self.preds.argmax(dim=1)==yb).float().sum() self.accs.append(acc) n = len(xb) self.losses.append(self.loss*n) self.ns.append(n) ``` 现在我们准备好第一次使用我们的`Learner`了! ```py cbs = [SetupLearnerCB(),TrackResults()] learn = Learner(simple_cnn(), dls, cross_entropy, lr=0.1, cbs=cbs) learn.fit(1) ``` ```py 0 True 2.1275552130636814 0.2314922378287042 0 False 1.9942575636942674 0.2991082802547771 ``` 惊人的是,我们可以用如此少的代码实现 fastai 的`Learner`中的所有关键思想!现在让我们添加一些学习率调度。 ## 调度学习率 如果我们想要获得良好的结果,我们将需要一个 LR finder 和 1cycle 训练。这两个都是*退火*回调,也就是说,它们在训练过程中逐渐改变超参数。这是`LRFinder`: ```py class LRFinder(Callback): def before_fit(self): self.losses,self.lrs = [],[] self.learner.lr = 1e-6 def before_batch(self): if not self.model.training: return self.opt.lr *= 1.2 def after_batch(self): if not self.model.training: return if self.opt.lr>10 or torch.isnan(self.loss): raise CancelFitException self.losses.append(self.loss.item()) self.lrs.append(self.opt.lr) ``` 这展示了我们如何使用`CancelFitException`,它本身是一个空类,仅用于表示异常的类型。您可以在`Learner`中看到这个异常被捕获。(您应该自己添加和测试`CancelBatchException`,`CancelEpochException`等。)让我们尝试一下,将其添加到我们的回调列表中: ```py lrfind = LRFinder() learn = Learner(simple_cnn(), dls, cross_entropy, lr=0.1, cbs=cbs+[lrfind]) learn.fit(2) ``` ```py 0 True 2.6336045582954903 0.11014890695955222 0 False 2.230653363853503 0.18318471337579617 ``` 并查看结果: ```py plt.plot(lrfind.lrs[:-2],lrfind.losses[:-2]) plt.xscale('log') ``` ![](img/dlcf_19in03.png) 现在我们可以定义我们的`OneCycle`训练回调: ```py class OneCycle(Callback): def __init__(self, base_lr): self.base_lr = base_lr def before_fit(self): self.lrs = [] def before_batch(self): if not self.model.training: return n = len(self.dls.train) bn = self.epoch*n + self.num mn = self.n_epochs*n pct = bn/mn pct_start,div_start = 0.25,10 if pct