Пишем свой PyTorch на NumPy. Часть 3. Строим граф вычислений

от автора

PyTorch — это мощный и гибкий фреймворк для машинного обучения, широко используемый для создания нейронных сетей. Он особенно популярен благодаря простоте использования, динамическим вычислительным графам и богатой экосистеме инструментов для обучения моделей. Для использования этого фреймворка, часто достаточно поверхностно понимать работу алгоритмов машинного обучения.

Но Андрей Карпаты, известный исследователь в области ИИ, считает, что реализация алгоритмов с нуля позволяет понять их суть и детали работы, что сложно осознать, используя только готовые библиотеки. Это помогает развить интуицию для дальнейшего применения и улучшения методов. Андрей посвящает много собственного времени, чтобы объяснять ключевые принципы работы нейросетей в своих блогах и на своём ютуб-канале. Он также не раз подчеркивал, что на его курсе в Cтэнфорде есть задачи по реализации различных алгоритмов, например, обратное распространение.

Я хотел бы посвятить данную статью этой идеи, потому что мне самому особенно интересно копаться в алгоритмах глубокого обучения. Эта статья продолжение второй статьи

Сегодня мы:

  • представим аналог pytorch.tensor()

  • переведём все вычисления на динамический вычислительный граф

  • проведём рефакторинг библиотеки

Поехали!

Note!
Перед началом чтения статьи я крайне рекомендую к просмотру нескольких видео по теме вычислительный граф и чтению статьи

Я буду использовать идеи из этой статьи как базу для своих реализаций

Давайте еще раз глянем отрывок нашего последнего кода!

class SimpleConvNet(Module):     def __init__(self):         ...              def forward(self, x):         x = self.conv1(x)         x = self.relu(x)         x = self.maxpool1(x)         x = self.conv2(x)         x = self.relu(x)         x = self.maxpool2(x)         x = self.flatten(x)         x = self.linear1(x)         x = self.relu(x)         x = self.linear2(x)         return x 

Подумайте, какие потенциальные случаи мы можем упустить из рассмотрения?

Я предложу несколько идей:

  1. Что если мы производим вычисления вне определенных нами слоёв? Например:

class SimpleConvNet(Module):     def __init__(self):         ...              def forward(self, x):         x = self.conv1(x)         x = self.relu(x)         x = self.maxpool1(x)         x = self.conv2(x)         x = self.relu(x)         x = x * 2          x = self.maxpool2(x)         x = x ** 2         x = self.flatten(x)         x = self.linear1(x)         x = self.relu(x)         x = x + 1         x = self.linear2(x)         return x 

Если вы вглядитесь в метод .backward() класса CrossEntropyLoss, то поймете, что мы не умеем обрабатывать случаи когда наши значения изменяются вне классов Linear, Conv2d, BatchNorm2d и т.д. Если запустим градиентный спуск, то он будет работать так, как будто этих промежуточных вычислений не было, что очевидно не приведёт ни к чему хорошему. А если мы еще каким-то образом меняли размер наших матриц, то программа вообще упадёт с ошибкой.

  1. Вспомните, как мы производили алгоритм вычисления градиентов? Мы их считали на бумаге, а потом переписывали в коде. Так было с линейными слоями, свёрточными слоями, слоями нормализации. Но как только вы захотим что-то более сложное реализовать, мы просто утоним в бесконечных вычислениях градиентов! Вот, например, я считал градиенты для RNN.

    И это только самая простая реализация рекуррентных слоев, для LSTM и GRU — у которых ещё более сложные зависимости, я сомневаюсь что-то вообще реально выписать формулы. А использовать их очень хочется! Значит надо что-то придумать!

  2. Нет гибкости! Для каждой операции нам нужно продумать градиент!

    Нам хочется, чтобы программа сама считала градиенты! Точно также, как мы возложили вычисление градиентов на метод .backward() в слоях, теперь на .backward() — мы хотим возложить вообще все вычисления!

Все наши проблемы поможет решить граф вычислений!

Computational Graph

Давайте представлять все наши вычисления в виде графа, например!

В узлах будут храниться значения при инициализации либо результат действия операции. Узел в синем квадратике будет хранить в себе информацию, что была произведена операция «сложение», двух чисел a и b, и полученный результат — число c.
Узел в красном квадратике будет хранить в себе информацию, что была произведена операция «умножение», двух чисел b и c, и полученный результат — число f.
Посчитаем производные такой функции.

На самом деле мы просто получили красивую визуализацию chain rule!

Ключевая идея заключается в том, чтобы после каждой операции получать не только значение, но и сразу считать производные.

Например, c = a + b. Тогда в c мы будем хранить само значение c, а также производные от a и b, то есть (c, 1, 1)
Для c = a * b будем хранить (c, b, a), для c = a / b, будем хранить (c, 1 / b, -a / b^2) — логика я надеюсь стала понятна.
Как только мы определим все базовые операции и их производные, мы сможем производить любые вычисления и брать любые производные от них, потому что мы на каждом этапе считаем свой локальный градиент — а это задача сильно проще, чем брать градиент от итогового выражения. Пример из жизни. Вы читаете много книг разных жанров. Но вы также хотите, чтобы каждый жанр лежал в своей полке. Что проще — после прочтения каждой книги класть в нужную полку или перебирать целую стопку накопившихся книг? Также например и здесь

Проще посчитать производную всей функции или сначала от x1/x2, потом от sin(x), потом exp(x2) и в конце просто перемножить их по правилу chain rule?

Заглянем сюда

Переменная (или узел) содержит две части данных:

  • value — значение переменной.

  • local_gradients — дочерние переменные и соответствующие «локальные производные».

Функция get_gradients использует данные из local_gradients переменных для рекурсивного обхода графа, вычисляя градиенты. (Т. е. local_gradients содержит ссылки на дочерние переменные, у которых есть свои local_gradients, которые содержат ссылки на дочерние переменные, у которых есть свои local_gradients, и так далее.)

Градиент переменной относительно дочерней переменной вычисляется с использованием следующих правил:

  1. Для каждого пути от переменной к дочерней переменной умножьте значения рёбер пути (что даёт path_value).

  2. Сложите все path_value для каждого пути.

… Это даёт частную производную первого порядка переменной относительно дочерней переменной.

Давайте реализуем скелет будущего класса!

class Tensor:     def __init__(self, value, local_gradients=None):         self.value = value         self.local_gradients = local_gradients 

Попробуем перезагрузить операцию сложения!

class Tensor:     def __init__(self, value, local_gradients=None):         self.value = value         self.local_gradients = local_gradients              def __add__(self, other):         value = self.value + other.value         local_gradients = ((self, 1), (other, 1))         return Tensor(value, local_gradients) 

Посмотрим на работу

a = Tensor(5) b = Tensor(10) c = a + b c, c.value, c.local_gradients >>> (<__main__.Tensor at 0x7d85aad85810>,  15,  ((<__main__.Tensor at 0x7d85aad87160>, 1),   (<__main__.Tensor at 0x7d85aad85660>, 1))) 

Попробуем теперь посчитать производные! Добавим метод .backward()

from collections import defaultdict      class Tensor:     def backward(self):     # словарь в котором будем хранить градиенты для всех переменных         gradients = defaultdict(lambda: 0)         # рекурсивно вызываемая функция для вычисления градиентов у детей, потом у их детей и т.д.         def compute_gradients(obj, path_value):             if obj.local_gradients: # проверяем не является ли узел листом (leaf)             # получаем ссылку на ребенка и его предпосчитанный градиент                 for child, local_grad_value in obj.local_gradients:                 # используем chain rule и умножаем накопленный градиент на градиент child                     path_value_to_child = path_value * local_grad_value                     # добавляем градиенты от разных листьев                     gradients[child] += path_value_to_child                     # считаем градиенты для детей текущего child                     compute_gradients(child, path_value_to_child)                      compute_gradients(self, path_value=1)          return gradients 

Смотрим

a = Tensor(5) b = Tensor(10) c = a + b gradients = c.backward() gradients[a], gradients[b] >>> (1, 1)

Теперь перегрузим операцию умножения

def __mul__(self, other): value = self.value * other.value local_gradients = ((self, other.value), (other, self.value)) return Tensor(value, local_gradients) 

Обратите внимание, в качестве производной по первому объекту, будет значение второго и наоборот!

a = Tensor(4) b = Tensor(3) c = a + b # = 4 + 3 = 7 d = a * c # = 4 * 7 = 28  gradients = d.backward()  print('d.value =', d.value) print("The partial derivative of d with respect to a =", gradients[a]) >>> d.value = 28 The partial derivative of d with respect to a = 11 
print('gradients[b] =', gradients[b]) print('gradients[c] =', gradients[c]) >>> gradients[b] = 4 gradients[c] = 4 

Посмотрим на промежуточные градиенты

print('dict(d.local_gradients)[a] =', dict(d.local_gradients)[a]) print('dict(d.local_gradients)[c] =', dict(d.local_gradients)[c]) print('dict(c.local_gradients)[a] =', dict(c.local_gradients)[a]) print('dict(c.local_gradients)[b] =', dict(c.local_gradients)[b]) >>> dict(d.local_gradients)[a] = 7 dict(d.local_gradients)[c] = 4 dict(c.local_gradients)[a] = 1 dict(c.local_gradients)[b] = 1 

Всё верно, можете перепроверить на бумаге!

Добавим еще несколько базовых операций!

# вычитание def __sub__(self, other): value = self.value - other.value local_gradients = ((self, 1), (other, -1)) return Tensor(value, local_gradients)  # унарный минус def __neg__(self):  value = -self.value local_gradients = ((self, -1),) return Tensor(value, local_gradients)  # деление def __truediv__(self, other):  value = self.value / other.value local_gradients = ((self, 1 / other.value), (other, - self.value / (other.value**2))) return Tensor(value, local_gradients) 

Посчитаем производную по аргументам более сложной функции

def f(a, b):     return (a / b - a) * (b / a + a + b) * (a - b)  a = Tensor(230.3) b = Tensor(33.2) y = f(a, b)  gradients = y.backward()  print("The partial derivative of y with respect to a =", gradients[a]) print("The partial derivative of y with respect to b =", gradients[b]) >>> The partial derivative of y with respect to a = -153284.83150602411 The partial derivative of y with respect to b = 3815.0389441500956 

Мы можем использовать численные оценки, чтобы проверить правильность получаемых результатов.

delta = Tensor(1e-10) numerical_grad_a = (f(a + delta, b) - f(a, b)) / delta numerical_grad_b = (f(a, b + delta) - f(a, b)) / delta print("The numerical estimate for a =", numerical_grad_a.value) print("The numerical estimate for b =", numerical_grad_b.value) >>> The numerical estimate for a = -153258.44287872314 The numerical estimate for b = 3837.0490074157715 

Добавим еще несколько базовых математических функций

# возведение в степень def __pow__(self, power): value = self.value ** power local_gradients = ((self, power * (self.value**(power-1))),) return Tensor(value, local_gradients)  @classmethod def sin(cls, obj): value = np.sin(obj.value) local_gradients = ((obj, np.cos(obj.value)),) return Tensor(value, local_gradients)  @classmethod     def cos(cls, obj): value = np.cos(obj.value) local_gradients = ((obj, -np.sin(obj.value)),) return Tensor(value, local_gradients)  @classmethod def exp(cls, obj): value = np.exp(obj.value) local_gradients = ((obj, value),) return Tensor(value, local_gradients)  @classmethod def log(cls, a): value = np.log(a.value) local_gradients = ( ('log', a, lambda x: x * 1. / a.value), ) return Tensor(value, local_gradients) 

И ещё раз проверим!

def f(a, b):     return ((a ** 2) / Tensor.sin(b) - a) * (b / a + Tensor.cos(a) + b) * (a - Tensor.exp(b))  a = Tensor(230.3) b = Tensor(33.2) y = f(a, b)  gradients = y.backward()  print("The partial derivative of y with respect to a =", gradients[a]) print("The partial derivative of y with respect to b =", gradients[b])  delta = Tensor(1e-10) numerical_grad_a = (f(a + delta, b) - f(a, b)) / delta numerical_grad_b = (f(a, b + delta) - f(a, b)) / delta print("The numerical estimate for a =", numerical_grad_a.value) print("The numerical estimate for b =", numerical_grad_b.value)  >>> The partial derivative of y with respect to a = -1.5667411882581273e+19 The partial derivative of y with respect to b = -5.795077766989229e+20  The numerical estimate for a = -1.566703616e+19 The numerical estimate for b = -5.79518464e+20 

Круто! Мы с вами только что реализовали одну из самых фундаментальных идей в нейронных сетях — граф вычислений. Дальше мы будем опираться полностью на него, всё что нам останется сделать с полученным классом Tensor — это постепенно усложнять его добавляя новые операции и функционал!

Как вы помните, буквально ВСЁ в нейронках — это произведения матриц. Реализуем её в нашем новом классе. Производные мы уже знаем! Если C = AB,

\frac{dL}{dA} = \frac{dL}{dC} \cdot \frac{dC}{dA}

Для вычисления производной по матрице C (где C = AB):

\frac{dC}{dA} = B^T

Таким образом:

\frac{dL}{dA} = \frac{dL}{dC} \cdot B^T

Аналогично для производной по матрице B:

\frac{dL}{dB} = A^T \cdot \frac{dL}{dC}

Но есть одна загвоздочка! Посмотрим ещё раз на реализацию метода .backward()

for child, local_grad_value in obj.local_gradients: path_value_to_child = path_value * local_grad_value gradients[child] += path_value_to_child compute_gradients(child, path_value_to_child)

Для получения нового значения, мы умножаем path_value и local_grad_value, проблема в том, что в случае матриц нам нужен не оператор *, а оператор @. Можно конечно обрабатывать каждый случай отдельно, но предлагаю поступить более умно. Покажу на примере:

def __add__(self, other): value = self.value + other.value local_gradients = ((self, lambda x: x), (other, lambda x: x)) return Tensor(value, local_gradients=local_gradients)

Давайте хранить в local_gradients — не само значение производной, а функцию, которая будет получать значение с предыдущего шага и преобразовывать его для следующего шага. В данном случае функция получает x и возвращает также x, так как производная равна 1. Для вычитания

def __sub__(self, other): value = self.value + other.value local_gradients = ((self, lambda x: x), (other, lambda x: -x)) return Tensor(value, local_gradients=local_gradients) 

Для первого слагаемого она получает x и возвращает также x, а для второго она получает x а возвращает -x, так как производная равна -1. Также предлагаю хранить название операции, это будет очень полезно для отладки!

def __add__(self, other): value = self.value + other.value local_gradients = (('add', self, lambda x: x), ('add', other, lambda x: x)) return Tensor(value, local_gradients=local_gradients) 

Итак, матричное умножение будет выглядеть в конечном итоге так!

def __matmul__(self, other): value = self.value @ other.value local_gradients = (('matmul', self, lambda x: x @ other.value.T), ('matmul', other, lambda x: self.value.T @ x)) return Tensor(value, local_gradients=local_gradients) 

И метод .backward() тоже немного преобразится с учётом наших изменений.

for operation, child, child_gradient_func in obj.local_gradients: # child_gradient_func как раз та самая lambda функция path_value_to_child = child_gradient_func(path_value) gradients[child] += path_value_to_child compute_gradients(child, path_value_to_child) 

Теперь мы готовы переходить к нейронным слоям. Нужно будет немного перестроить логику!
Вернемся к самому первому примеру с «изображением» собаки. Теперь это будет не объект numpy.ndarray, а объект нашего нового класса Tensor

input_x = np.array([[ 0.99197708, -0.77980023, -0.8391331 , -0.41970686,  0.72636492],        [ 0.85901409, -0.22374584, -1.95850625, -0.81685145,  0.96359871],        [-0.42707937, -0.50053309,  0.34049477,  0.62106931, -0.76039365],        [ 0.34206742,  2.15131285,  0.80851759,  0.28673013,  0.84706839],        [-1.70231094,  0.36473216,  0.33631525, -0.92515589, -2.57602677]]) target_x = [1.0, 0.0]  input_tensor = Tensor(input_x) target_tensor = Tensor(target_x) 

Класс Module и Linear оставим такими же, только теперь веса модели это также объекты классаTensor

class Module:     def __init__(self):         self._constructor_Parameter = ParameterObj()         global Parameter         Parameter = self._constructor_Parameter              def forward(self):         pass            def __call__(self, x):         return self.forward(x)            def parameters(self):         return self  class Linear:     def __init__(self, input_channels: int, output_channels: int, bias = True):         self.input_channels = input_channels         self.output_channels = output_channels         self.bias_flag = bias         self.backward_list = []         # теперь объекты класса Tensor         self.weight = Tensor(np.random.uniform(- 0.5, 0.5, size=(self.input_channels, self.output_channels)))         self.bias = Tensor(np.random.uniform(- 0.5, 0.5, size=self.output_channels) * bias)         Parameter([self, self.weight, self.bias])              def __call__(self, x: Tensor):         self.x = Tensor(x)         result = x @ Parameter.calling[self][0] + Parameter.calling[self][1]         return result 

Давайте при инициализации объекта класса Tensor, проверять является ли он объектом этого же класса или является объектом другого класса (число или numpy.ndarray) и переводить его в объект numpy.ndarray.Также добавим информацию о форме объекта в атрибут shape

class Tensor:     def __init__(self, value, local_gradients=None):         if isinstance(value, Tensor):             self.value = value.value             self.local_gradients = value.local_gradients         else:             self.value = np.array(value)             self.local_gradients = local_gradients         self.shape = self.value.shape 

Соберём модель

class SimpleNet(Module):     def __init__(self):         super().__init__()         self.linear1 = Linear(input_channels=5, output_channels=10, bias=True)     def forward(self, x):         return self.linear1(x)  model = SimpleNet() model(input_tensor).shape >>> (5, 10) 

Отлично, модель выдаёт значения! Добавим ещё несколько полезных команд!

Метод reshape, он нам пригодится, так как мы часто будем менять размеры наших тензоров:

def reshape(self, *args): local_gradients = (('reshape', self, lambda x: x.reshape(self.shape)),) return Tensor(self.value.reshape(*args), local_gradients=local_gradients)

Отображение:
сейчас вывод нашего тензоры выглядит так, не очень красиво и информативно:
<__main__.Tensor at 0x7c2122fce0e0>
Давайте поправим

def __repr__(self): return np.array_repr(self.value)

Теперь: >>> array(4)
Добавим ещё несколько полезных команд:

# Создать тензор нулей. В целом полезный метод для инициализации тензора @classmethod def zeros(cls, shape): return cls(np.zeros(shape))  # Cоздать тензор нормального распределения. Очень часто используется для инициализации весов @classmethod def randn(cls, shape): return cls(np.random.normal(size=shape))  # Определить знак для каждого значения, будем использовать в relu @classmethod def sign(cls, a): value = np.sign(a.value) return cls(value)  # поможет перевести 5.0 в 5 @classmethod def int_(cls, *args): return cls(np.int_(*args))  # Тоже полезный метод для инициализации последовательности чисел @classmethod def arange(cls, *args): return cls(np.arange(*args))  # Суммирование, одна из самых важных функций. Почти везде используется @classmethod def sum(cls, array, axis=None, keepdims=False): if not keepdims: # Не хотим сохранить размерность if axis is not None: local_gradients = (('sum', array, lambda x: np.expand_dims(np.array(x), axis=axis) + np.zeros(array.shape)),) return Tensor(np.sum(array.value, axis=axis), local_gradients=local_gradients) else: local_gradients = (('sum', array, lambda x: x + np.zeros(array.shape)),) return Tensor(np.sum(array.value, axis=axis), local_gradients=local_gradients)  else: # Хотим сохранить размерность value = np.sum(array.value, axis=axis, keepdims=True) * np.ones_like(array.value) local_gradients = (('sum', array, lambda x: x),) return cls(value, local_gradients=local_gradients) # код может быть немного запутанным из-за того, что нужно учесть разную размерность матриц при расчете градиентов  # классический уже знакомый нам softmax @classmethod def softmax(cls, z, axis=-1,): return cls.exp(z) / cls.sum(cls.exp(z), axis=axis, keepdims=True)

Попробуем более сложную модель, немного поменяв наши слои

class Flatten:     def __init__(self):          Parameter([self, []])     def __call__(self, x):         self.init_shape = x.shape         return x.reshape(self.init_shape[0], -1)  class ReLU:     def __init__(self):          pass     def __call__(self, x):         return x * (Tensor.sign(x) + 1) / 2
class SimpleNet(Module):     def __init__(self):         super().__init__()         self.linear1 = Linear(input_channels=25, output_channels=10, bias=True)         self.linear2 = Linear(input_channels=10, output_channels=2, bias=True)         self.flatten = Flatten()         self.relu = ReLU()      def forward(self, x):         x_1 = self.flatten(x)         x_2 = self.linear1(x_1)         x_3 = self.relu(x_2)         x_4 = self.linear2(x_3)         return x_4 model = SimpleNet() model(input_tensor.reshape(1, -1)) >>> array([[ 0.2440679 , -1.75806267]])

Круто! Всё работает, осталось обучить! Дальше считаем значение loss-функции.

class CrossEntropyLoss:      def __init__(self):         self.predicted = None         self.true = None      def __call__(self, logits, true):         predicted = Tensor.exp(logits) / Tensor.sum(Tensor.exp(logits), axis=1).reshape(-1, 1) # softmax         self.true = true         # вычисляем значение лосс-функции прямо по формуле         self.loss = Tensor.sum(self.true * Tensor.log(predicted + 1e-5), axis=1) * -1         return self

Заметьте, эта та же самая реализация, что и в прошлых статьях, но мы поменяли np на Tensor.

loss = loss_fn(model(input_tensor.reshape(1, -1)), target_tensor)  loss.loss >>> array([0.2581563])

Что ж, мы получили значение и мы уже знаем, что можем вызвать .backward() прямо с тензора loss.loss, чтобы посчитать все градиенты (спойлер: не сможем, у нас вылетит ошибка)! Но открою для вас небольшой секретик. Градиент для кросс-энтропии + softmax можно считать не через граф, а через формулу. Вот так вот! Мы убегали от формульных вычислений, а сами же вернулись к ним. Но здесь это оправдано, ведь вспомните какая там простая производная получается, а значит мы может сделать небольшой трюк

Для него нам потребуется добавить метод detach — он вытаскивает тензор из графа. То есть это просто матрица значений.

def detach(self): return Tensor(self.value)
class CrossEntropyLoss:      def __call__(self, predicted, true):     ### сохраним значения выхода модели         self.logits = Tensor(predicted, local_gradients=predicted.local_gradients)         ###         self.predicted = Tensor.softmax(predicted) # softmax         #number_of_classes = predicted.shape[1]         #self.true = Tensor.int_(Tensor.arange(0, number_of_classes) == true)         self.true = true         # вычисляем значение лосс-функции прямо по формуле         self.loss = Tensor.sum(self.true * Tensor.log(self.predicted + 1e-5), axis=1) * -1         return self      def backward(self):     # Посчитаем градиент по формуле         self.analytics = (self.predicted - self.true)         # Вытащим из графа, то есть по факту просто получим значения и домножим на self.logits, который всё еще находится в графе.          self.analytics = self.analytics.detach() * self.logits         self.gradients = self.analytics.backward()

То есть мы с помощью self.analytics подменили вычисление производной внутри графа. А домножив self.analytics наself.logits, мы вернулись в граф, который был еще до применения softmax и кросс-энтропии, и уже отсюда можем честно считать градиенты внутри графа!

Ещё раз: self.logits.backward() — посчитает градиенты для графа, в котором нет softmax + кросс-энтропии, а ((self.predicted - self.true).detach() * self.logits).backward() — также посчитает градиенты для графа, в котором нет softmax + кросс-энтропии, но при этом неявно учтёт их существование за счет множителя (self.predicted - self.true).detach()

Получаем

loss.backward() loss.gradients[model.linear1.weight].shape, loss.gradients[model.linear1.bias].shape >>> ((25, 10), (1, 10))

Теперь давайте снова ручками сделаем градиентный спуск и обучим модель!

model = SimpleNet() loss_fn = CrossEntropyLoss() lr = 0.01 for i in range(100):     output = model(input_tensor.reshape(1, -1))     loss = loss_fn(output, target_tensor)      loss.backward()     gradients = loss.gradients     for layer in [model.linear1, model.linear2]:         layer.weight.value = layer.weight.value - lr * gradients[layer.weight]         layer.bias.value = layer.bias.value - lr * gradients[layer.bias]      if i % 10 == 0:         print(loss.loss) >>> array([1.29812516]) array([0.46082039]) array([0.21713806]) array([0.13151886]) array([0.0906402]) array([0.06659202]) array([0.05139489]) array([0.04118782]) array([0.03398361]) array([0.0286905])

Ура. Наша модель обучается! Идем дальше и запихнём градиентный спуск в уже знакомый нам SGD

class Tensor:     def __init__(self, value, local_gradients=None):         self.shape = self.value.shape         self.grad = 0          class CrossEntropyLoss     def backward(self):         self.analytics = (self.predicted - self.true)         self.analytics = self.analytics.detach() * self.logits         self.gradients = self.analytics.backward() global Parameter         for index, layer in enumerate(Parameter.layers[::-1]):             if type(layer).__name__ == 'Linear':                 layer.weight.grad += self.gradients[layer.weight] / self.loss.shape[0]                 layer.bias.grad += self.gradients[layer.bias] / self.loss.shape[0]                  class SGD:     def __init__(self, model, lr=2e-4):         self.model = model         self.lr = lr      def step(self):         for index, layer in enumerate(self.model._constructor_Parameter.layers[::-1]):             if type(layer).__name__  == 'Linear':                 layer.weight.value -= self.lr * layer.weight.grad                 layer.bias.value -= self.lr * layer.b.grad.mean(axis=0)

Но посмотрите сюда

layer.weight.grad += self.gradients[layer.weight] / self.loss.shape[0] layer.bias.grad += self.gradients[layer.bias] / self.loss.shape[0]

Это же накопление градиентов! А разве оно нам нужно? Нет! Значит нам нужно обнулять накопленные градиенты в каждой операции, для этого введём новый метод .zero_grad()

class Module:     def zero_grad(self):         for index, layer in enumerate(self._constructor_Parameter.layers):             if type(layer).__name__ == 'Linear':                 layer.weight.grad = 0                 layer.bias.grad = 0

Обучаем!

model = SimpleNet() loss_fn = CrossEntropyLoss() optim = SGD(model.parameters(), lr=1e-3) lr = 0.001 for i in range(100):     output = model(input_tensor.reshape(1, -1))     loss = loss_fn(output, target_tensor)      model.zero_grad()     loss.backward()     optim.step()      if i % 10 == 0:         print(loss.loss) >>> array([0.51065697]) array([0.15970178]) array([0.01386941]) array([0.00090227]) array([4.67924761e-05]) array([-6.95378636e-06]) array([-9.88413122e-06]) array([-9.99684238e-06]) array([-9.99989122e-06]) array([-9.99994927e-06])

Круто! Мы обучили нашу первую нейронку на графе вычислений!

NOTE!

В следующем блоке я буду рассказывать реализацию свёрточной нейронки на графе вычислений. По итогу она работает, но не обучается. Ошибку я не успел найти, но очень постараюсь отладить код и дополнить статью. Я решил оставить эту часть, так как хотел донести именно идейную составляющую моего рассказа. И пусть обучить модель не получится, я надеюсь понимание происходящего у читателя останется!

Conv2d

Оказывается, NumPy позволяет провести свертку при помощи обычного перемножения матриц. Для этого используется numpy.lib.stride_tricks.sliding_window_view
Функция numpy.lib.stride_tricks.sliding_window_view в NumPy используется для создания представления массивов с окнами скользящих данных. Это полезный инструмент для анализа временных рядов, вычисления свёрток и других операций, где требуется работать с подмножествами данных в скользящем окне. В результате каждого окно у нас представимо в вытянутого вектора.
Например, для картинки (2, 5, 5) и фильтра (2, 3, 3) получим представление в виде (3, 3, 2, 3, 3), и для расчета свёртки для позиции (i, j), возьмём [i][j], вытянем в вектор, [i][j].reshape(-1) и умножим на вытянутый вектор фильтра [i][j].reshape(-1) @ kernel.reshape(-1)

Проверим

image = np.array([[0, 50, 0, 29],         [0, 80, 31, 2],          [33, 90, 0, 75],         [0, 9, 0, 95]         ]) kernel = np.ones((3, 3))  v = sliding_window_view(image, (kernel.shape[0], kernel.shape[1]), axis=(-1, -2)) *not_used, a, b = v.shape v = v.reshape(*not_used, -1) kernel_s = kernel.reshape(-1) result = v @ kernel_s np.allclose(result, scipy.signal.fftconvolve(image, kernel, mode='valid')) >>> True

Круто! Усложним задачу

image = np.random.randn(3, 7, 7) kernel = np.ones((3, 3, 3))  v = sliding_window_view(image, kernel.shape, axis=(-1, -2, -3)) *not_used, a, b, c= v.shape v = v.reshape(*not_used, -1) kernel_s = kernel.reshape(-1) result = v @ kernel_s np.allclose(result, scipy.signal.fftconvolve(image, kernel, mode='valid')) >>> True

Продолжаем

image = np.random.randn(10, 3, 7, 7) kernel = np.ones((1, 3, 3, 3))  v = sliding_window_view(image, kernel.shape[1:], axis=(-1, -2, -3)) *not_used, a, b, c = v.shape v = v.reshape(*not_used, -1) kernel_s = kernel.reshape(-1) result = v @ kernel_s np.allclose(result, scipy.signal.fftconvolve(image, kernel, mode='valid')) >>> True

И заключительная проверка с нашей реализацией из предыдущей статьи

image = np.random.randn(11, 1, 4, 7, 7) kernel = np.random.randn(1, 5, 4, 3, 3)  i = image_.shape[-1] f = kernel_.shape[-1] padding = 0 step = 1 m = (i-f + 2*padding) // step +1 number_of_kernels = kernel_.shape[1] number_of_images = image_.shape[0] new_image = np.zeros((number_of_images, number_of_kernels, m, m)) for image_n in range(number_of_images):     for kernel_n in range(number_of_kernels):         for y in range(m):             for x in range(m):                 start_x = x * step                 end_x = start_x + f                 start_y = y * step                 end_y = start_y + f                 new_image[image_n][kernel_n][y][x] = np.sum(image_[image_n, 0, :, start_y:end_y, start_x:end_x] * kernel_[0, kernel_n])  kernel = kernel.squeeze(axis=0) image = image.squeeze(axis=1)  num_images, matrix_z, matrix_y, matrix_x = image.shape num_kernels, kernel_z, kernel_y, kernel_x = kernel.shape result_x, result_y = matrix_x - kernel_x + 1, matrix_y - kernel_y + 1 new_matrix = sliding_window_view(image, (1, kernel_z, kernel_y, kernel_x)) new_kernel = kernel.transpose(1, 2, 3, 0) result =  new_matrix.reshape(num_images, -1, kernel_z * kernel_y * kernel_x) @ new_kernel.reshape(-1, num_kernels) result = result.transpose(0, 2, 1) result = result.reshape(num_images, num_kernels, result_y, result_x)  np.allclose(result, new_image) >>> True

Отлично! Мы научились проводить свёртку с помощью матричного перемножения, теперь добавим эту операцию в наш класс и определим для неё производную!

class Tensor:   @classmethod   def sliding_window_view(cls, matrix, kernel_z, kernel_y, kernel_x):     result = np.lib.stride_tricks.sliding_window_view(matrix.value, (1, kernel_z, kernel_y, kernel_x)).copy()     def multiply_by_locgrad(path_value):         temp = np.zeros(matrix.shape)         np.add.at(np.lib.stride_tricks.sliding_window_view(temp, (1, kernel_z, kernel_y, kernel_x), writeable=True), None, path_value)         return temp      local_gradients = (('slide', matrix, multiply_by_locgrad),)     return cls(result, local_gradients=local_gradients) 
  • Используется метод np.add.at, который позволяет эффективно добавлять значения в массив temp на основе path_value.

  • Для работы с «окнами» в массиве используется ещё одно представление sliding_window_view с параметром writeable=True, что позволяет модифицировать данные.

Как вы также могли увидеть, что мы несколько раз использовали операцию .transpose(), но не определили её в классе, исправим!

def transpose(self, *args): local_gradients = (('transpose', self, lambda x: x.transpose(*args)),) return Tensor(self.value.transpose(*args), local_gradients=local_gradients)

Наконец переопределим класс Conv2d с учётом новых знаний

class Conv2d:      def __init__(self, input_channels: int, output_channels: int, kernel_size: int, bias = True):         self.param = None         self.bias_flag = bias         self.input_channels = input_channels         self.kernel_size = (input_channels, kernel_size, kernel_size)         self.n_filters = output_channels          self.weight = Tensor.randn((self.n_filters, input_channels, kernel_size, kernel_size), )         self.bias = Tensor.randn((self.n_filters, 1, 1))         self.weight.value *= 1e-2 # уменьшаем для стабильности         self.bias.value *= 1e-2          Parameter([self, self.weight, self.bias])      def __call__(self, x):         matrix = x         kernel = self.weight         num_images, matrix_z, matrix_y, matrix_x = matrix.shape         num_kernels, kernel_z, kernel_y, kernel_x = kernel.shape         result_x, result_y = matrix_x - kernel_x + 1, matrix_y - kernel_y + 1                  new_matrix = Tensor.sliding_window_view(matrix, kernel_z, kernel_y, kernel_x)                  tranposed_kernel = kernel.transpose(1, 2, 3, 0)              result = new_matrix.reshape(num_images, -1,  kernel_z * kernel_y * kernel_x) @ tranposed_kernel.reshape(-1, num_kernels)                  result = result.transpose(0, 2, 1)                  return result.reshape(num_images, num_kernels, result_y, result_x) + self.bias 

Собираем модель для обучения на MNIST. Код для подготовки данных возьмём из прошлой статьи.

А вот так примерно будет выглядеть вычисление градиентов!

mul child shape: (64, 10) obj shape: (64, 10) mul child shape: (64, 10) obj shape: (64, 10) add child shape: (64, 10) obj shape: (64, 10) matmul child shape: (64, 50) obj shape: (64, 10) div child shape: (64, 50) obj shape: (64, 50) mul child shape: (64, 50) obj shape: (64, 50) add child shape: (64, 50) obj shape: (64, 50) matmul child shape: (64, 1296) obj shape: (64, 50) reshape child shape: (64, 4, 18, 18) obj shape: (64, 1296) div child shape: (64, 4, 18, 18) obj shape: (64, 4, 18, 18) mul child shape: (64, 4, 18, 18) obj shape: (64, 4, 18, 18) add child shape: (64, 4, 18, 18) obj shape: (64, 4, 18, 18) reshape child shape: (64, 4, 324) obj shape: (64, 4, 18, 18) transpose child shape: (64, 324, 4) obj shape: (64, 4, 324) matmul child shape: (64, 324, 36) obj shape: (64, 324, 4) reshape child shape: (64, 1, 18, 18, 1, 4, 3, 3) obj shape: (64, 324, 36) slide child shape: (64, 4, 20, 20) obj shape: (64, 1, 18, 18, 1, 4, 3, 3) div child shape: (64, 4, 20, 20) obj shape: (64, 4, 20, 20) mul child shape: (64, 4, 20, 20) obj shape: (64, 4, 20, 20) add child shape: (64, 4, 20, 20) obj shape: (64, 4, 20, 20) reshape child shape: (64, 4, 400) obj shape: (64, 4, 20, 20) transpose child shape: (64, 400, 4) obj shape: (64, 4, 400) matmul child shape: (64, 400, 36) obj shape: (64, 400, 4) reshape child shape: (64, 1, 20, 20, 1, 4, 3, 3) obj shape: (64, 400, 36) slide child shape: (64, 4, 22, 22) obj shape: (64, 1, 20, 20, 1, 4, 3, 3)
class SimpleConvNet(Module):     def __init__(self):         super().__init__()         self.conv1 = Conv2d(input_channels = 1, output_channels = 5, kernel_size=5) #28 -> 24         self.conv2 = Conv2d(input_channels = 5, output_channels = 10, kernel_size=5) #24 -> 20         self.conv3 = Conv2d(input_channels = 10, output_channels = 20, kernel_size=5) #20 -> 16         self.conv4 = Conv2d(input_channels = 20, output_channels = 20, kernel_size=5) #16 -> 12         self.conv5 = Conv2d(input_channels = 20, output_channels = 20, kernel_size=5) #12 -> 8         self.conv6 = Conv2d(input_channels = 20, output_channels = 10, kernel_size=5) #8 -> 4                  self.flatten = Flatten()         self.linear1 = Linear(input_channels= 4 * 4 * 10, output_channels=20, bias=True)         self.linear2 = Linear(input_channels= 20, output_channels=10, bias=True)         self.relu = ReLU()      def forward(self, x):         x = self.conv1(x)         x = self.relu(x)         x = self.conv2(x)         x = self.relu(x)         x = self.conv3(x)         x = self.relu(x)         x = self.conv4(x)         x = self.relu(x)         x = self.conv5(x)         x = self.relu(x)         x = self.conv6(x)         x = self.relu(x)                  x = self.flatten(x)         x = self.linear1(x)         x = self.relu(x)         x = self.linear2(x)         return x  model = SimpleConvNet() loss_fn = CrossEntropyLoss() optim = SGD(model.parameters(), lr=1e-3) for i in range(5):     y_pred_list = []     y_true_list = []     for index, batch in enumerate(data_loader):         input_x, target = batch         input_x = input_x / 255         input_x = np.expand_dims(input_x, axis=1) # (64, 28, 28) -> (64, 1, 28, 28)                  input_tensor = Tensor(input_x)         target_tensor = Tensor(target)                  output = model(input_tensor)         loss = loss_fn(output, target_tensor)          model.zero_grad()         loss.backward()         optim.step()                  print(loss.loss.value.mean())  >>> 2.3434739196082752 2.3261346480555405 2.3450367034537822 2.328755621690293 2.290884864380055 2.3062695760361183 2.312287414927344 2.3049557593729144 2.2829010337160796

Не обучается

Но надеюсь вы хотя бы поняли идею!

Я опустил очень много моментов, например добавление __hash__, __eq__ , работу с градиентами внутри оптимизатора, проверка совпадения размерностей тензоров, обработку broadcasting для всех операций. Все они не несут большой идейной составляющей, но безусловно необходимы для корректной работы всех алгоритмов. Я не стал зацикливать на этом внимание и надеюсь вы поймете меня!

КУЛЬМИНАЦИЯ

Итак, вспоминаем самый первый блок кода из первой статьи!

# Создаем простой набор данных X = torch.randn(100, 3)  # 100 примеров с 3 признаками y = torch.randint(0, 2, (100,))  # 100 меток классов (0 или 1)  # Определим простую нейронную сеть class SimpleNN(nn.Module):     def __init__(self):         super(SimpleNN, self).__init__()         self.fc1 = nn.Linear(3, 5)  # Первый слой: 3 входа, 5 выходов         self.fc2 = nn.Linear(5, 2)  # Второй слой: 5 входов, 2 выхода (классы)         self.softmax = nn.Softmax(dim=1)  # Для получения вероятностей классов      def forward(self, x):         x = torch.relu(self.fc1(x))  # Применяем активацию ReLU         x = self.fc2(x)  # Второй слой         x = self.softmax(x)  # Преобразуем в вероятности         return x  # Создаем модель model = SimpleNN()  # Определяем функцию потерь и оптимизатор criterion = nn.CrossEntropyLoss()  # Кросс-энтропия для многоклассовой классификации optimizer = optim.SGD(model.parameters(), lr=0.01)  # Стохастический градиентный спуск  # Обучаем модель num_epochs = 100 for epoch in range(num_epochs):     # Прямой проход     outputs = model(X)          # Вычисление потерь     loss = criterion(outputs, y)          # Обратный проход     optimizer.zero_grad()  # Обнуляем градиенты     loss.backward()  # Вычисляем градиенты     optimizer.step()  # Обновляем параметры модели

Смотрим и понимаем: Мы с вами разобрались в каждой строчке этого кода. Как я и обещал, собрав знания я одну библиотеку, мы заменим наконец

import torch import torch.nn as nn

на

import <наша библиотека> import <наша библиотека>.nn as nn

А так я могу сделать например со своей библиотекой

import candle import candle.nn as nn

Основная часть моего рассказа подошла к концу. Я надеюсь, что смог достаточно понятно пояснить за работу алгоритмов глубокого обучения и библиотеки PyTorch! Спасибо за внимание!

В следующей финальной статье, я хочу уже воспользовавшись собственной написанной библиотекой реализовать и запустить обученный GPT2, тем самым показав, что мы в достаточной степени овладели мастерством машинного обучения!

Первая версия библиотеки

Вторая версия библиотеки

GPT-2 на этой библиотеке


ссылка на оригинал статьи https://habr.com/ru/articles/870426/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *