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
Подумайте, какие потенциальные случаи мы можем упустить из рассмотрения?
Я предложу несколько идей:
-
Что если мы производим вычисления вне определенных нами слоёв? Например:
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 и т.д. Если запустим градиентный спуск, то он будет работать так, как будто этих промежуточных вычислений не было, что очевидно не приведёт ни к чему хорошему. А если мы еще каким-то образом меняли размер наших матриц, то программа вообще упадёт с ошибкой.
-
Вспомните, как мы производили алгоритм вычисления градиентов? Мы их считали на бумаге, а потом переписывали в коде. Так было с линейными слоями, свёрточными слоями, слоями нормализации. Но как только вы захотим что-то более сложное реализовать, мы просто утоним в бесконечных вычислениях градиентов! Вот, например, я считал градиенты для
RNN.
И это только самая простая реализация рекуррентных слоев, для LSTM и GRU — у которых ещё более сложные зависимости, я сомневаюсь что-то вообще реально выписать формулы. А использовать их очень хочется! Значит надо что-то придумать!
-
Нет гибкости! Для каждой операции нам нужно продумать градиент!
Нам хочется, чтобы программа сама считала градиенты! Точно также, как мы возложили вычисление градиентов на метод
.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, и так далее.)
Градиент переменной относительно дочерней переменной вычисляется с использованием следующих правил:
-
Для каждого пути от переменной к дочерней переменной умножьте значения рёбер пути (что даёт
path_value). -
Сложите все
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 — это постепенно усложнять его добавляя новые операции и функционал!
Как вы помните, буквально ВСЁ в нейронках — это произведения матриц. Реализуем её в нашем новом классе. Производные мы уже знаем! Если ,
Для вычисления производной по матрице (где
):
Таким образом:
Аналогично для производной по матрице :
Но есть одна загвоздочка! Посмотрим ещё раз на реализацию метода .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, тем самым показав, что мы в достаточной степени овладели мастерством машинного обучения!
ссылка на оригинал статьи https://habr.com/ru/articles/870426/

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