3.2. Объектно-ориентированное проектирование для реализации

В нашем введении в линейную регрессию мы рассмотрели различные компоненты, включая данные, модель, функцию потерь и алгоритм оптимизации. Действительно, линейная регрессия — одна из самых простых моделей машинного обучения. Однако для её обучения используются многие те же компоненты, что и для других моделей в этой книге. Поэтому, прежде чем погружаться в детали реализации, стоит спроектировать некоторые интерфейсы (API), которые мы будем использовать на протяжении всего текста. Рассматривая компоненты глубокого обучения как объекты, мы можем начать с определения классов для этих объектов и их взаимодействий. Такой объектно-ориентированный подход к реализации значительно упростит изложение материала, и вы, возможно, захотите использовать его в своих проектах.

Вдохновляясь такими открытыми библиотеками, как PyTorch Lightning, на высоком уровне мы хотим иметь три класса: (i) Module содержит модели, функции потерь и методы оптимизации; (ii) DataModule предоставляет загрузчики данных для обучения и валидации; (iii) оба класса объединяются с помощью класса Trainer, который позволяет обучать модели на различных аппаратных платформах. Большая часть кода в этой книге использует Module и DataModule. Мы будем обращаться к классу Trainer только при обсуждении GPU, CPU, параллельного обучения и алгоритмов оптимизации.

import time
import numpy as np
import torch
from torch import nn
from d2l import torch as d2l

3.2.1. Утилиты

Для упрощения объектно-ориентированного программирования в Jupyter-блокнотах нам понадобится несколько вспомогательных функций. Одна из проблем заключается в том, что определения классов обычно представляют собой довольно длинные блоки кода. Читаемость блокнотов требует коротких фрагментов кода, перемежающихся пояснениями, что несовместимо со стилем программирования, принятым в библиотеках Python. Первая вспомогательная функция позволяет регистрировать функции как методы класса после того, как класс уже создан. Более того, мы можем сделать это даже после создания экземпляров этого класса! Это позволяет разбить реализацию класса на несколько блоков кода.

def add_to_class(Class):  #@save
    """Register functions as methods in created class."""
    def wrapper(obj):
        setattr(Class, obj.__name__, obj)
    return wrapper

Давайте кратко рассмотрим, как это использовать. Мы планируем реализовать класс A с методом do. Вместо того чтобы помещать код для обоих A и do в один блок, мы можем сначала объявить класс A и создать объект a.

class A:
    def __init__(self):
        self.b = 1

a = A()

Далее мы определяем метод do обычным образом, но не в области видимости класса A. Вместо этого мы декорируем этот метод с помощью add_to_class, передавая класс A в качестве аргумента. Благодаря этому метод получает доступ к переменным-членам класса A так же, как мы и ожидали, если бы он был включен в определение класса A. Давайте посмотрим, что произойдет, когда мы вызовем его для объекта a.

@add_to_class(A)
def do(self):
    print('Class attribute "b" is', self.b)

a.do()

Второй из них — это вспомогательный класс, который сохраняет все аргументы метода __init__ класса в виде атрибутов класса. Это позволяет неявно расширять сигнатуры вызова конструктора без написания дополнительного кода.

class HyperParameters:  #@save
    """The base class of hyperparameters."""
    def save_hyperparameters(self, ignore=[]):
        raise NotImplemented

Мы отложим его реализацию до раздела Section 23.7. Чтобы использовать его, определим наш класс, который наследуется от HyperParameters и вызывает save_hyperparameters в методе __init__.

# Call the fully implemented HyperParameters class saved in d2l
class B(d2l.HyperParameters):
    def __init__(self, a, b, c):
        self.save_hyperparameters(ignore=['c'])
        print('self.a =', self.a, 'self.b =', self.b)
        print('There is no self.c =', not hasattr(self, 'c'))

b = B(a=1, b=2, c=3)

Последняя утилита позволяет интерактивно отслеживать ход эксперимента по мере его выполнения. В честь гораздо более мощного (и сложного) TensorBoard мы назвали её ProgressBoard. Её реализация отложена до раздела Section 23.7. А пока просто посмотрим, как она работает.

Метод draw отображает точку (x, y) на графике, с меткой label, указанной в легенде. Необязательный параметр every_n сглаживает линию, отображая только \(1/n\) точек на графике. Их значения усредняются по \(n\) соседним точкам из исходного графика.

class ProgressBoard(d2l.HyperParameters):  #@save
    """The board that plots data points in animation."""
    def __init__(self, xlabel=None, ylabel=None, xlim=None,
                 ylim=None, xscale='linear', yscale='linear',
                 ls=['-', '--', '-.', ':'], colors=['C0', 'C1', 'C2', 'C3'],
                 fig=None, axes=None, figsize=(3.5, 2.5), display=True):
        self.save_hyperparameters()

    def draw(self, x, y, label, every_n=1):
        raise NotImplemented

В следующем примере мы нарисуем sin и cos с разной степенью сглаживания. Если вы запустите этот блок кода, вы увидите, как линии анимированно вытягиваются.

board = d2l.ProgressBoard('x')
for x in np.arange(0, 10, 0.1):
    board.draw(x, np.sin(x), 'sin', every_n=2)
    board.draw(x, np.cos(x), 'cos', every_n=10)

3.2.2. Модели

Класс Module является базовым классом для всех моделей, которые мы будем реализовывать. Как минимум нам нужны три метода. Первый, __init__, хранит обучаемые параметры, метод training_step принимает пакет данных и возвращает значение функции потерь, а метод configure_optimizers возвращает метод оптимизации или список таких методов, используемых для обновления обучаемых параметров. Опционально мы можем определить validation_step для отчёта о показателях оценки. Иногда мы помещаем код для вычисления вывода в отдельный метод forward, чтобы сделать его более переиспользуемым.

class Module(nn.Module, d2l.HyperParameters):  #@save
    """The base class of models."""
    def __init__(self, plot_train_per_epoch=2, plot_valid_per_epoch=1):
        super().__init__()
        self.save_hyperparameters()
        self.board = ProgressBoard()

    def loss(self, y_hat, y):
        raise NotImplementedError

    def forward(self, X):
        assert hasattr(self, 'net'), 'Neural network is defined'
        return self.net(X)

    def plot(self, key, value, train):
        """Plot a point in animation."""
        assert hasattr(self, 'trainer'), 'Trainer is not inited'
        self.board.xlabel = 'epoch'
        if train:
            x = self.trainer.train_batch_idx / \
                self.trainer.num_train_batches
            n = self.trainer.num_train_batches / \
                self.plot_train_per_epoch
        else:
            x = self.trainer.epoch + 1
            n = self.trainer.num_val_batches / \
                self.plot_valid_per_epoch
        self.board.draw(x, value.to(d2l.cpu()).detach().numpy(),
                        ('train_' if train else 'val_') + key,
                        every_n=int(n))

    def training_step(self, batch):
        l = self.loss(self(*batch[:-1]), batch[-1])
        self.plot('loss', l, train=True)
        return l

    def validation_step(self, batch):
        l = self.loss(self(*batch[:-1]), batch[-1])
        self.plot('loss', l, train=False)

    def configure_optimizers(self):
        raise NotImplementedError

Вы можете заметить, что Module является подклассом nn.Module, базового класса нейронных сетей в PyTorch. Он предоставляет удобные возможности для работы с нейронными сетями. Например, если мы определим метод forward, такой как forward(self, X), то для объекта a мы можем вызвать этот метод с помощью a(X). Это работает, поскольку вызывается метод forward из встроенного метода __call__. Более подробные сведения и примеры о nn.Module можно найти в Section 6.1.

3.2.3. Данные

Класс DataModule является базовым классом для работы с данными. Метод __init__ часто используется для подготовки данных, что может включать их скачивание и предварительную обработку. Метод train_dataloader возвращает загрузчик данных для обучающего набора данных. Загрузчик данных — это (Python) генератор, который выдает пакет данных при каждом обращении. Этот пакет затем подается в метод training_step класса Module для вычисления функции потерь. Существует необязательный метод val_dataloader, возвращающий загрузчик данных для валидационного набора данных. Он работает аналогичным образом, за исключением того, что выдает пакеты данных для метода validation_step в классе Module.

class DataModule(d2l.HyperParameters):  #@save
    """The base class of data."""
    def __init__(self, root='../data', num_workers=4):
        self.save_hyperparameters()

    def get_dataloader(self, train):
        raise NotImplementedError

    def train_dataloader(self):
        return self.get_dataloader(train=True)

    def val_dataloader(self):
        return self.get_dataloader(train=False)

3.2.4. Обучение

Класс Trainer обучает обучаемые параметры класса Module на данных, указанных в DataModule. Ключевой метод — fit, который принимает два аргумента: model, объект класса Module, и data, объект класса DataModule. Затем он проходит по всему набору данных max_epochs раз для обучения модели. Как и ранее, мы отложим реализацию этого метода до следующих глав.

class Trainer(d2l.HyperParameters):  #@save
    """The base class for training models with data."""
    def __init__(self, max_epochs, num_gpus=0, gradient_clip_val=0):
        self.save_hyperparameters()
        assert num_gpus == 0, 'No GPU support yet'

    def prepare_data(self, data):
        self.train_dataloader = data.train_dataloader()
        self.val_dataloader = data.val_dataloader()
        self.num_train_batches = len(self.train_dataloader)
        self.num_val_batches = (len(self.val_dataloader)
                                if self.val_dataloader is not None else 0)

    def prepare_model(self, model):
        model.trainer = self
        model.board.xlim = [0, self.max_epochs]
        self.model = model

    def fit(self, model, data):
        self.prepare_data(data)
        self.prepare_model(model)
        self.optim = model.configure_optimizers()
        self.epoch = 0
        self.train_batch_idx = 0
        self.val_batch_idx = 0
        for self.epoch in range(self.max_epochs):
            self.fit_epoch()

    def fit_epoch(self):
        raise NotImplementedError

3.2.5. Итоги

Чтобы подчеркнуть объектно-ориентированный дизайн нашей будущей реализации глубокого обучения, приведённые выше классы лишь демонстрируют, как их объекты хранят данные и взаимодействуют друг с другом. В остальной части книги мы будем продолжать расширять реализации этих классов, например, с помощью @add_to_class. Кроме того, полностью реализованные классы сохранены в библиотеке D2L — легковесном инструментарии, который упрощает структурированное моделирование в глубоком обучении. В частности, он позволяет повторно использовать многие компоненты в разных проектах без существенных изменений. Например, мы можем заменить только оптимизатор, только модель, только набор данных и т. д.; такая степень модульности окупается на протяжении всей книги за счет лаконичности и простоты (именно поэтому мы её добавили) и может принести ту же пользу вашим собственным проектам.

3.2.6. Упражнения

  1. Полные реализации указанных выше классов сохранены в библиотеке D2L. Мы настоятельно рекомендуем вам внимательно изучить эти реализации, как только вы приобретете некоторый опыт в моделировании с помощью глубокого обучения.

  2. Удалите оператор save_hyperparameters из класса B. Сможете ли вы по-прежнему выводить self.a и self.b? Дополнительно: если вы изучили полную реализацию класса HyperParameters, сможете ли вы объяснить почему?

Обсуждения