PyTorch: Learn the Basics, часть 3 — модель и autograd
Перевод разделов «Build the Neural Network» и «Automatic Differentiation» официального курса PyTorch: как собрать сеть из слоёв nn.Module, что делают Flatten, Linear, ReLU, Sequential и Softmax, как torch.autograd строит граф вычислений и считает градиенты.
Третья часть перевода курса Learn the Basics (исходники — pytorch/tutorials,
BSD-3-Clause): разделы «Build the Neural Network» и «Automatic Differentiation with torch.autograd».
Предыдущая часть — Dataset, DataLoader и преобразования, следующая —
цикл обучения и сохранение.
4. Сборка нейросети
Заголовок раздела «4. Сборка нейросети»Нейросети состоят из слоёв (модулей), которые выполняют операции над данными. Пространство имён
torch.nn даёт все строительные блоки, нужные, чтобы собрать свою нейросеть.
Каждый модуль в PyTorch наследует nn.Module.
Нейросеть сама по себе — модуль, который состоит из других модулей (слоёв). Такая вложенная структура позволяет легко
строить сложные архитектуры и управлять ими.
В следующих подразделах мы соберём нейросеть, которая классифицирует изображения из набора FashionMNIST.
import osimport torchfrom torch import nnfrom torch.utils.data import DataLoaderfrom torchvision import datasets, transformsВыбираем устройство для обучения
Заголовок раздела «Выбираем устройство для обучения»Мы хотим обучать модель на ускорителе — CUDA, MPS, MTIA или XPU. Если ускоритель доступен, используем его, иначе — CPU.
device = torch.accelerator.current_accelerator().type if torch.accelerator.is_available() else "cpu"print(f"Using {device} device")Вывод:
Using cuda deviceОпределяем класс
Заголовок раздела «Определяем класс»Мы задаём нейросеть как подкласс nn.Module и создаём её слои в __init__. Каждый подкласс nn.Module
описывает операции над входными данными в методе forward.
class NeuralNetwork(nn.Module): def __init__(self): super().__init__() self.flatten = nn.Flatten() self.linear_relu_stack = nn.Sequential( nn.Linear(28*28, 512), nn.ReLU(), nn.Linear(512, 512), nn.ReLU(), nn.Linear(512, 10), )
def forward(self, x): x = self.flatten(x) logits = self.linear_relu_stack(x) return logitsСоздаём экземпляр NeuralNetwork, переносим его на device и печатаем его структуру.
model = NeuralNetwork().to(device)print(model)Вывод:
NeuralNetwork( (flatten): Flatten(start_dim=1, end_dim=-1) (linear_relu_stack): Sequential( (0): Linear(in_features=784, out_features=512, bias=True) (1): ReLU() (2): Linear(in_features=512, out_features=512, bias=True) (3): ReLU() (4): Linear(in_features=512, out_features=10, bias=True) ))Чтобы воспользоваться моделью, мы передаём ей входные данные. При этом выполняется метод forward модели вместе
с некоторыми фоновыми операциями.
Не вызывайте model.forward() напрямую!
Вызов модели на входе возвращает двумерный тензор: измерение dim=0 соответствует отдельным выходам (по одному
на каждый пример), а dim=1 — десяти «сырым» предсказанным значениям для каждого класса. Вероятности предсказаний
мы получаем, пропуская результат через экземпляр модуля nn.Softmax.
X = torch.rand(1, 28, 28, device=device)logits = model(X)pred_probab = nn.Softmax(dim=1)(logits)y_pred = pred_probab.argmax(1)print(f"Predicted class: {y_pred}")Вывод:
Predicted class: tensor([0], device='cuda:0')(Примечание переводчика: у тензора logits здесь форма [1, 10] — один пример, десять классов. Сеть ещё
не обучена, поэтому предсказанный класс случаен.)
Слои модели
Заголовок раздела «Слои модели»Разберём по слоям модель для FashionMNIST. Для наглядности возьмём мини-батч из трёх изображений размером 28×28 и посмотрим, что с ним происходит при проходе через сеть.
input_image = torch.rand(3,28,28)print(input_image.size())Вывод:
torch.Size([3, 28, 28])nn.Flatten
Заголовок раздела «nn.Flatten»Мы создаём слой nn.Flatten,
чтобы превратить каждое двумерное изображение 28×28 в непрерывный массив из 784 значений пикселей
(измерение мини-батча, dim=0, сохраняется).
flatten = nn.Flatten()flat_image = flatten(input_image)print(flat_image.size())Вывод:
torch.Size([3, 784])nn.Linear
Заголовок раздела «nn.Linear»Линейный слой — это модуль, который применяет к входу линейное преобразование с помощью хранящихся в нём весов и смещений.
layer1 = nn.Linear(in_features=28*28, out_features=20)hidden1 = layer1(flat_image)print(hidden1.size())Вывод:
torch.Size([3, 20])(Примечание переводчика: внутри слой считает , где у матрицы форма , а у вектора смещений — 20 чисел. Всего у такого слоя обучаемых параметров.)
nn.ReLU
Заголовок раздела «nn.ReLU»Нелинейные функции активации создают сложные зависимости между входами и выходами модели. Их применяют после линейных преобразований, чтобы внести нелинейность и помочь нейросети выучить самые разные закономерности.
В этой модели мы ставим nn.ReLU между линейными слоями,
но есть и другие функции активации, которые вносят нелинейность в модель.
print(f"Before ReLU: {hidden1}\n\n")hidden1 = nn.ReLU()(hidden1)print(f"After ReLU: {hidden1}")Вывод (первая строка каждого тензора):
Before ReLU: tensor([[-0.3729, 0.1928, -0.0521, 0.0542, 0.2942, -0.1856, 0.6333, -0.0815, -0.0360, 0.5083, -0.0697, 0.1799, 0.0639, 0.0892, -0.0324, 0.0707, 0.6500, 0.6037, 0.5211, -0.1809], ...], grad_fn=<AddmmBackward0>)
After ReLU: tensor([[0.0000, 0.1928, 0.0000, 0.0542, 0.2942, 0.0000, 0.6333, 0.0000, 0.0000, 0.5083, 0.0000, 0.1799, 0.0639, 0.0892, 0.0000, 0.0707, 0.6500, 0.6037, 0.5211, 0.0000], ...], grad_fn=<ReluBackward0>)(Примечание переводчика: ReLU заменяет отрицательные числа нулями, а положительные оставляет как есть: −0.3729 стало 0.0000, а 0.1928 не изменилось.)
nn.Sequential
Заголовок раздела «nn.Sequential»nn.Sequential — упорядоченный контейнер
модулей. Данные проходят через все модули в том порядке, в котором они заданы. С помощью таких последовательных
контейнеров можно быстро собрать сеть, например seq_modules.
seq_modules = nn.Sequential( flatten, layer1, nn.ReLU(), nn.Linear(20, 10))input_image = torch.rand(3,28,28)logits = seq_modules(input_image)nn.Softmax
Заголовок раздела «nn.Softmax»Последний линейный слой нейросети возвращает логиты (logits) — «сырые» значения в диапазоне , — которые
передаются в модуль nn.Softmax. Логиты
масштабируются в значения из — вероятности каждого класса по мнению модели. Параметр dim указывает
измерение, вдоль которого значения должны в сумме давать 1.
softmax = nn.Softmax(dim=1)pred_probab = softmax(logits)(Примечание переводчика: формула softmax и её связь с функцией потерь разобраны в уроке «Правдоподобие, softmax и cross-entropy».)
Параметры модели
Заголовок раздела «Параметры модели»Многие слои внутри нейросети параметризованы: у них есть веса и смещения, которые оптимизируются при обучении.
Наследование от nn.Module автоматически отслеживает все поля, заданные внутри объекта модели, и делает все параметры
доступными через методы модели parameters() и named_parameters().
В этом примере мы перебираем все параметры и печатаем размер каждого и первые значения.
print(f"Model structure: {model}\n\n")
for name, param in model.named_parameters(): print(f"Layer: {name} | Size: {param.size()} | Values : {param[:2]} \n")Вывод (после структуры модели, которую мы уже видели выше):
Layer: linear_relu_stack.0.weight | Size: torch.Size([512, 784]) | Values : tensor([[-0.0142, 0.0019, 0.0209, ..., 0.0028, -0.0299, 0.0136], [-0.0104, -0.0318, -0.0339, ..., 0.0230, -0.0129, 0.0068]], device='cuda:0', grad_fn=<SliceBackward0>)
Layer: linear_relu_stack.0.bias | Size: torch.Size([512]) | Values : tensor([-0.0064, 0.0003], device='cuda:0', grad_fn=<SliceBackward0>)
Layer: linear_relu_stack.2.weight | Size: torch.Size([512, 512]) | Values : tensor([[ 0.0339, -0.0120, -0.0293, ..., 0.0294, 0.0118, -0.0192], [-0.0193, 0.0286, -0.0279, ..., 0.0342, 0.0013, -0.0320]], device='cuda:0', grad_fn=<SliceBackward0>)
Layer: linear_relu_stack.2.bias | Size: torch.Size([512]) | Values : tensor([-0.0030, 0.0291], device='cuda:0', grad_fn=<SliceBackward0>)
Layer: linear_relu_stack.4.weight | Size: torch.Size([10, 512]) | Values : tensor([[-0.0156, 0.0416, 0.0267, ..., 0.0242, -0.0294, 0.0353], [ 0.0196, -0.0432, 0.0250, ..., 0.0259, 0.0265, 0.0191]], device='cuda:0', grad_fn=<SliceBackward0>)
Layer: linear_relu_stack.4.bias | Size: torch.Size([10]) | Values : tensor([ 0.0317, -0.0178], device='cuda:0', grad_fn=<SliceBackward0>)(Примечание переводчика: сложите размеры —
параметров. Проверить можно одной строкой: sum(p.numel() for p in model.parameters()).)
Что почитать дальше
Заголовок раздела «Что почитать дальше»5. Автоматическое дифференцирование с torch.autograd
Заголовок раздела «5. Автоматическое дифференцирование с torch.autograd»При обучении нейросетей чаще всего используют алгоритм обратного распространения ошибки (back propagation). В нём параметры (веса модели) подстраиваются в соответствии с градиентом функции потерь по этому параметру.
Чтобы вычислять эти градиенты, в PyTorch есть встроенный механизм дифференцирования — torch.autograd
(автоматическое дифференцированиеавтоматическое дифференцирование (автоград)EN automatic differentiation (autograd)Программа записывает операции в граф и сама считает производные по цепному правилу. Для loss → параметры используют обратный режим (reverse mode) — это и есть backprop.). Он умеет автоматически вычислять градиент для любого вычислительного графа.
Возьмём простейшую однослойную нейросеть со входом x, параметрами w и b и некоторой функцией потерь.
В PyTorch её можно задать так:
import torch
x = torch.ones(5) # входной тензорy = torch.zeros(3) # ожидаемый выходw = torch.randn(5, 3, requires_grad=True)b = torch.randn(3, requires_grad=True)z = torch.matmul(x, w)+bloss = torch.nn.functional.binary_cross_entropy_with_logits(z, y)Тензоры, функции и вычислительный граф
Заголовок раздела «Тензоры, функции и вычислительный граф»Этот код задаёт такой вычислительный граф (computational graph):
flowchart LR
x["x"] --> mul(("*"))
w["w (параметр)"] --> mul
mul --> add(("+"))
b["b (параметр)"] --> add
add --> z["z"]
z --> ce["CE"]
y["y"] --> ce
ce --> loss["loss"]В этой сети w и b — параметры, которые нужно оптимизировать. Поэтому нам нужно уметь вычислять градиенты
функции потерь по этим переменным. Для этого мы устанавливаем у этих тензоров свойство requires_grad.
Функция, которую мы применяем к тензорам, чтобы построить вычислительный граф, на самом деле — объект класса
Function. Этот объект знает, как вычислить функцию в прямом направлении и как вычислить её производную на шаге
обратного распространения. Ссылка на функцию обратного распространения хранится в свойстве тензора grad_fn.
Подробнее о Function — в документации.
print(f"Gradient function for z = {z.grad_fn}")print(f"Gradient function for loss = {loss.grad_fn}")Вывод:
Gradient function for z = <AddBackward0 object at 0x7f859f581180>Gradient function for loss = <BinaryCrossEntropyWithLogitsBackward0 object at 0x7f859f582920>Вычисление градиентов
Заголовок раздела «Вычисление градиентов»Чтобы оптимизировать веса параметров нейросети, нужно вычислить производные функции потерь по параметрам, то есть
нам нужны и при некоторых фиксированных
значениях x и y. Чтобы вычислить эти производные, мы вызываем loss.backward(), а затем берём значения из
w.grad и b.grad:
loss.backward()print(w.grad)print(b.grad)Вывод:
tensor([[0.2752, 0.0590, 0.1873], [0.2752, 0.0590, 0.1873], [0.2752, 0.0590, 0.1873], [0.2752, 0.0590, 0.1873], [0.2752, 0.0590, 0.1873]])tensor([0.2752, 0.0590, 0.1873])(Примечание переводчика: почему все строки w.grad одинаковы и совпадают с b.grad? Вход x — пять единиц,
поэтому , и градиент по каждому весу столбца равен градиенту по —
то есть по . Числа у вас будут другими: w и b случайные.)
Отключение отслеживания градиентов
Заголовок раздела «Отключение отслеживания градиентов»По умолчанию все тензоры с requires_grad=True отслеживают историю своих вычислений и поддерживают вычисление
градиента. Но бывают случаи, когда это не нужно: например, когда модель уже обучена и мы просто хотим применить её
к входным данным, то есть выполнить только прямой проход по сети. Остановить отслеживание вычислений можно,
обернув код в блок torch.no_grad():
z = torch.matmul(x, w)+bprint(z.requires_grad)
with torch.no_grad(): z = torch.matmul(x, w)+bprint(z.requires_grad)Вывод:
TrueFalseДругой способ добиться того же — метод detach() у тензора:
z = torch.matmul(x, w)+bz_det = z.detach()print(z_det.requires_grad)Вывод:
FalseПричины отключить отслеживание градиентов:
- чтобы пометить некоторые параметры нейросети как замороженные (frozen parameters);
- чтобы ускорить вычисления, когда нужен только прямой проход: вычисления над тензорами, которые не отслеживают градиенты, эффективнее.
(Примечание переводчика: заморозка параметров — основа дообучения больших моделей. В LoRA веса исходной модели заморожены, а обучаются только маленькие добавочные матрицы.)
Подробнее о вычислительных графах
Заголовок раздела «Подробнее о вычислительных графах»Концептуально autograd хранит запись данных (тензоров) и всех выполненных операций (вместе с получившимися новыми
тензорами) в направленном ациклическом графе (DAG), который состоит из объектов
Function. В этом графе листья — входные
тензоры, а корни — выходные. Проходя граф от корней к листьям, можно автоматически вычислить градиенты по цепному
правилу.
При прямом проходе autograd делает одновременно две вещи:
- выполняет запрошенную операцию и вычисляет тензор-результат;
- сохраняет в графе функцию градиента этой операции.
Обратный проход запускается, когда у корня графа вызывают .backward(). Тогда autograd:
- вычисляет градиенты с помощью каждой
.grad_fn; - накапливает их в атрибуте
.gradсоответствующего тензора; - по цепному правилу распространяет градиенты до самых листовых тензоров.
Дополнительно: градиенты тензоров и произведения с якобианом
Заголовок раздела «Дополнительно: градиенты тензоров и произведения с якобианом»Во многих случаях функция потерь скалярная, и нужно вычислить градиент по некоторым параметрам. Но бывает, что выход функции — произвольный тензор. Тогда PyTorch позволяет вычислить так называемое произведение с якобианом (Jacobian product), а не сам градиент.
Для векторной функции , где и , градиент по задаётся матрицей Якоби:
Вместо самой матрицы Якоби PyTorch позволяет вычислить произведение с якобианом для заданного вектора
. Для этого нужно вызвать backward с в качестве аргумента. Размер должен совпадать
с размером исходного тензора, по которому мы хотим вычислить произведение:
inp = torch.eye(4, 5, requires_grad=True)out = (inp+1).pow(2).t()out.backward(torch.ones_like(out), retain_graph=True)print(f"First call\n{inp.grad}")out.backward(torch.ones_like(out), retain_graph=True)print(f"\nSecond call\n{inp.grad}")inp.grad.zero_()out.backward(torch.ones_like(out), retain_graph=True)print(f"\nCall after zeroing gradients\n{inp.grad}")Вывод:
First calltensor([[4., 2., 2., 2., 2.], [2., 4., 2., 2., 2.], [2., 2., 4., 2., 2.], [2., 2., 2., 4., 2.]])
Second calltensor([[8., 4., 4., 4., 4.], [4., 8., 4., 4., 4.], [4., 4., 8., 4., 4.], [4., 4., 4., 8., 4.]])
Call after zeroing gradientstensor([[4., 2., 2., 2., 2.], [2., 4., 2., 2., 2.], [2., 2., 4., 2., 2.], [2., 2., 2., 4., 2.]])Заметьте: когда мы второй раз вызываем backward с тем же аргументом, значение градиента получается другим.
Так происходит потому, что при обратном распространении PyTorch накапливает градиенты: вычисленные значения
прибавляются к свойству grad всех листовых вершин графа. Чтобы получить правильные градиенты, свойство grad
нужно предварительно обнулить. При реальном обучении это делает за нас оптимизатор.
(Примечание переводчика: проверим числа. Производная равна . На диагонали torch.eye стоят
единицы: ; вне диагонали нули: . Второй вызов прибавил то же самое — получилось
8 и 4.)
Что почитать дальше
Заголовок раздела «Что почитать дальше»Словарик
Заголовок раздела «Словарик»| EN | RU |
|---|---|
| layer, module | слой, модуль |
| linear layer | линейный (полносвязный) слой |
| activation function | функция активации |
| logits | логиты |
| weights, biases | веса, смещения |
| automatic differentiation | автоматическое дифференцирование |
| computational graph | вычислительный граф |
| leaf node | листовая вершина |
| gradient accumulation | накопление градиентов |
| frozen parameters | замороженные параметры |
| Jacobian matrix | матрица Якоби (якобиан) |
Дальше: часть 4 — цикл обучения, сохранение и загрузка модели.