Перейти к содержимому
Ступень 33.3 PyTorch

Тензоры и autograd в PyTorch

Переходим от micrograd к PyTorch: тензоры, dtype и device, автоматические градиенты через requires_grad и backward(), и когда GPU действительно быстрее CPU.

УРОКstage-3/3-3-pytorch/01-tensors-autograd.mdx1 ч 30 минУрок 1 из 3
После урока вы сможете
  • Создавать тензоры, менять их форму и тип, переносить данные между NumPy, CPU и GPU
  • Считать градиенты через requires_grad и .backward() и объяснять, какой граф при этом строит autograd
  • Сопоставить каждую деталь PyTorch с вашим micrograd и назвать, ради чего нужен «настоящий» фреймворк
  • Оценивать память под веса в разных dtype и правильно замерять скорость на GPU

В модуле 3.2 вы написали свой автоградавтоматическое дифференцирование (автоград)EN automatic differentiation (autograd)Программа записывает операции в граф и сама считает производные по цепному правилу. Для loss → параметры используют обратный режим (reverse mode) — это и есть backprop.: класс Value, который помнит, из чего он получен, и умеет делать backward(). Это и есть ядро любого фреймворка глубокого обученияфреймворк глубокого обученияEN deep learning frameworkБиблиотека, которая даёт тензоры, автоматическое дифференцирование, слои и оптимизаторы и умеет считать на GPU: PyTorch, JAX, TensorFlow; в Китае — PaddlePaddle, MindSpore, Jittor.. Но у micrograd три ограничения. Он работает с отдельными числами, а не с матрицами. Он медленный: каждая операция — это Python-объект. И он умеет считать только на процессоре.

PyTorch снимает все три. Идея та же — вычислительный графвычислительный графEN computational graphСхема вычисления — узлы-операции и рёбра-значения. По нему идёт прямой проход (значения) и обратный (градиенты). и обратный проход, — но единица вычислений теперь тензортензорEN tensorВ ML — просто многомерный массив чисел. torch.Tensor устроен как np.ndarray, но умеет работать на GPU и считать градиенты.: таблица из тысяч или миллиардов чисел. Операции написаны на C++ и CUDA, а одна строка x.to("cuda") переносит вычисления на видеокарту. На PyTorch написаны MiniMind, nanoGPT, открытый код инференса DeepSeek-V3 и обучающие скрипты большинства открытых LLM. Всё, что вы сделаете дальше в курсе, будет на нём.

Это принцип «сначала руками, потом фреймворк» в действии. Вы уже знаете, что происходит внутри loss.backward(). Осталось выучить, как это пишется в PyTorch.

Тензор в PyTorch почти неотличим от массива NumPy из урока 0.3.1: та же индексация, срезы, броадкастинг и векторизованные операции. Слово «тензор» пугает зря: это просто таблица чисел любой размерности — число, вектор, матрица или «стопка матриц». Отличий от NumPy три:

  1. Device — тензор может жить на видеокарте.
  2. Autograd — тензор может запоминать операции, чтобы потом посчитать градиент.
  3. dtype под GPU — по умолчанию float32 (у NumPy float64), есть bfloat16 и float8.
import torch
a = torch.tensor([[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0]]) # из вложенного списка Python
print(a)
print(a.shape, a.dtype, a.device) # форма, тип чисел, устройство
z = torch.zeros(2, 3) # нули формы (2, 3)
o = torch.ones(2, 3, dtype=torch.int64) # единицы целого типа
r = torch.randn(2, 3) # случайные числа из N(0, 1) — «колокол» из урока 0.3.3
i = torch.arange(5) # 0, 1, 2, 3, 4
print(i, i.dtype) # целые → int64
print(torch.tensor([1, 2, 3]) / 2) # деление целых даёт float
tensor([[1., 2., 3.],
[4., 5., 6.]])
torch.Size([2, 3]) torch.float32 cpu
tensor([0, 1, 2, 3, 4]) torch.int64
tensor([0.5000, 1.0000, 1.5000])

У каждого тензора три главных атрибута: форма массиваформа массиваEN shapeКортеж размеров по осям, например (batch, seq_len, d_model). (форма), тип элементов массиватип элементов массива (dtype)EN data type (dtype)float32, float16, int64, bool… Определяет точность и сколько байт занимает каждое число. (тип чисел) и устройствоустройство (device)EN deviceГде хранится тензор и где выполняются операции с ним: "cpu", "cuda", "mps" (Apple), "npu" (Huawei Ascend через плагин torch_npu). Операнды одной операции должны быть на одном устройстве. (где он хранится). Когда что-то не работает, первым делом печатайте все три.

import numpy as np
import torch
x_np = np.array([1.0, 2.0, 3.0])
shared = torch.from_numpy(x_np) # ОБЩАЯ память с массивом NumPy
copied = torch.tensor(x_np) # независимая копия
x_np[0] = 100.0 # меняем исходный массив...
print(shared) # ...и тензор изменился вместе с ним
print(copied) # а копия — нет
tensor([100., 2., 3.], dtype=torch.float64)
tensor([1., 2., 3.], dtype=torch.float64)

Заметьте dtype=torch.float64: NumPy по умолчанию работает в float64, и тензор унаследовал этот тип. Слои PyTorch хранят веса во float32. Подайте им такой тензор — получите одну из самых частых ошибок новичка:

RuntimeError: mat1 and mat2 must have the same dtype, but got Double and Float

Double — это float64, Float — float32. Лечится явным приведением: x.float(). Обратно в NumPy — t.numpy() (только для тензоров на CPU и без графа).

Почти всё, что вы делали в NumPy, работает так же. Главное отличие в названии: вместо axis пишут dim.

x = torch.tensor([[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0]]) # форма (2, 3)
print(x.sum()) # всё: 21
print(x.sum(dim=0)) # сворачиваем ось 0 (строки): (3,)
print(x.sum(dim=1, keepdim=True)) # сворачиваем ось 1, форма (2, 1)
print(x.argmax(dim=1)) # индекс максимума в каждой строке
print(x @ x.T) # матричное умножение (2,3)@(3,2) → (2,2)
print(x.unsqueeze(0).shape) # добавить ось: (1, 2, 3)
print(x[1, 2].item()) # одно число → обычный float Python
tensor(21.)
tensor([5., 7., 9.])
tensor([[ 6.],
[15.]])
tensor([2, 2])
tensor([[14., 32.],
[32., 77.]])
torch.Size([1, 2, 3])
6.0
NumPyPyTorchКомментарий
np.array(lst)torch.tensor(lst)копирует данные
a.sum(axis=0)t.sum(dim=0)keepdim вместо keepdims
a.astype(np.float32)t.float() или t.to(torch.float32)
np.concatenatetorch.cattorch.stack — с новой осью
a.T, a.transpose(...)t.T, t.transpose(0, 1), t.permute(...)permute — любая перестановка осей
a.reshape(...)t.reshape(...) или t.view(...)view иногда падает с ошибкой там, где reshape работает; новичку проще всегда писать reshape
float(a[0])t[0].item()достать число из тензора

Для LLM тип чисел — вопрос не стиля, а денег: он определяет, сколько видеопамяти займут веса и насколько быстро идут вычисления. Сравним три главных типа:

for dt in [torch.float32, torch.float16, torch.bfloat16]:
f = torch.finfo(dt) # параметры формата
size = torch.tensor([], dtype=dt).element_size() # байт на число
print(f"{str(dt):15} байт={size} max={f.max:.3g} eps={f.eps:.3g}")
torch.float32 байт=4 max=3.4e+38 eps=1.19e-07
torch.float16 байт=2 max=6.55e+04 eps=0.000977
torch.bfloat16 байт=2 max=3.39e+38 eps=0.00781

max — самое большое представимое число, eps — расстояние от 1 до следующего числа, то есть относительная точность. Видно, чем различаются два 16-битных формата:

  • float16 точнее (eps ≈ 0.001), но тесный: всё больше 65 504 превращается в бесконечность. torch.tensor(70000.0, dtype=torch.float16) даёт inf.
  • bfloat16 держит тот же диапазон, что float32 (до 3.4⋅10383.4 \cdot 10^{38}), зато точность грубая: примерно 2–3 значащие десятичные цифры. Поэтому LLM обучают именно в нём: переполнения почти не бывает.

Грубая точность bfloat16 коварна при накоплении. Сложим 0.01 тысячу раз:

s16 = torch.zeros((), dtype=torch.bfloat16) # форма () — тензор из одного числа (скаляр)
s32 = torch.zeros(()) # то же во float32
for _ in range(1000):
s16 = s16 + 0.01
s32 = s32 + 0.01
print(s16.item(), s32.item())
4.0 10.000133514404297

Вместо 10 получилось 4! Почему: компьютер хранит не любые дробные числа, а только числа с «сетки», и у bfloat16 эта сетка редкая. На значащие цифры (мантиссу, урок 0.0.2) у него отведено всего 7 бит, поэтому между 4 и 8 помещается лишь 27=1282^7 = 128 равноотстоящих значений с шагом 4⋅2−7=0.031254 \cdot 2^{-7} = 0.03125. Когда сумма доросла до 4, ближайшие соседи — 4 и 4.03125. Прибавка 0.01 меньше половины этого шага, и 4+0.014 + 0.01 округляется обратно к 4 — сумма застревает. Поэтому суммы, нормализации и softmax в LLM считают в float32, даже если веса хранятся в bf16. Подробно о форматах — в уроке «Смешанная точность».

Память под веса — простая арифметика: число параметров × байт на число. Модель на 7 млрд параметров:

dtypeБайт на параметрВеса 7B
float32428 ГБ
bfloat16214 ГБ
int8 / float817 ГБ
4 бита0.53.5 ГБ

Это только веса. При обучении добавятся градиенты, состояние оптимизатора и активации — об этом в следующем уроке.

центральный процессорцентральный процессор (CPU)EN central processing unit (CPU)Универсальный «мозг» компьютера — от нескольких до десятков мощных ядер; каждое быстро выполняет любые инструкции одну за другой. — универсальный процессор с десятками ядер. графический процессорграфический процессор (GPU, видеокарта)EN graphics processing unit (GPU)Тысячи простых вычислительных ядер. Идеально подходит для матричных умножений, поэтому на GPU обучают нейросети. — тысячи простых ядер, которые одновременно делают одно и то же над разными числами. Умножение матриц идеально ложится на GPU. Видеокарты NVIDIA в PyTorch называются устройством "cuda" — по имени платформы CUDACUDAEN CUDA (Compute Unified Device Architecture)Платформа NVIDIA для вычислений на видеокартах. В PyTorch видеокарта NVIDIA — это устройство "cuda" ("cuda:0", "cuda:1"…)..

device = "cuda" if torch.cuda.is_available() else "cpu" # стандартная строка в начале скрипта
x = torch.randn(1000, 1000, device=device) # создать сразу на нужном устройстве
y = torch.randn(1000, 1000).to(device) # или перенести существующий тензор
z = x @ y # считается там, где лежат операнды
print(z.device)
result = z.cpu() # вернуть на CPU, например для NumPy

Правило одно: все операнды одной операции — на одном устройстве. Иначе получите ошибку, которую рано или поздно видит каждый (точная формулировка зависит от операции):

RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu!

.to(device) для тензора возвращает новый тензор: x.to("cuda") без присваивания ничего не меняет. У модели (nn.Module) .to() работает на месте — это тема следующего урока.

GPU выигрывает, когда работы много и она однородна. Умножение двух матриц n×nn \times n — это 2n32n^3 операций. Для n=4096n = 4096 — 137 миллиардов. Замерим:

import time
import torch
def bench(device, n=4096, repeats=5):
a = torch.randn(n, n, device=device)
b = torch.randn(n, n, device=device)
a @ b # прогрев: первый запуск всегда медленнее
if device == "cuda":
torch.cuda.synchronize() # ждём, пока GPU реально досчитает
t0 = time.perf_counter()
for _ in range(repeats):
c = a @ b
if device == "cuda":
torch.cuda.synchronize()
dt = (time.perf_counter() - t0) / repeats
tflops = 2 * n**3 / dt / 1e12 # триллионов операций в секунду
print(f"{device:4}: {dt * 1000:7.1f} мс на умножение, {tflops:.2f} TFLOPS")
bench("cpu")
if torch.cuda.is_available():
bench("cuda")

Числа сильно зависят от железа, поэтому запустите сами и запишите результат. Ориентиры такие. Процессор выдаёт от десятков гигафлопс на ноутбуке до единиц терафлопс на многоядерном сервере, и одно такое умножение занимает от десятков миллисекунд до секунд. Видеокарта уровня RTX 4080 по паспорту даёт около 49 TFLOPS во float32 (реальное умножение матриц достигает лишь части пика), то есть единицы миллисекунд на умножение. В bfloat16 включаются тензорные ядра — специальные блоки GPU для умножения матриц, и это ещё быстрее. Во сколько раз, проверьте сами. Как устроены тензорные ядра и память видеокарты — в уроке 5.7.1.

Три ловушки замеров и выбора устройства:

  1. Асинхронность. Python только ставит операцию на GPU в очередь и сразу идёт дальше. Без torch.cuda.synchronize() вы измерите постановку в очередь — получится фантастически быстро и неправда.
  2. Прогрев. Первая операция на GPU инициализирует CUDA и подбирает алгоритмы. Её не замеряют.
  3. Мелкие задачи. Запуск каждой операции на GPU стоит единицы–десятки микросекунд, а копирование CPU → GPU идёт по шине PCIe (канал связи между процессором и видеокартой), а это небыстро. Сложить два вектора по 10 чисел быстрее на CPU. Правило: GPU окупается на больших матрицах и батчах, а данные стоит переносить на видеокарту один раз и надолго.

Теперь главное. Возьмём крошечную модель — один нейрон без активации — и квадратичную ошибку:

pred=wx+b,L=(pred−y)2\text{pred} = w x + b, \qquad L = (\text{pred} - y)^2

При x=2x = 2, w=3w = 3, b=1b = 1, y=5y = 5 получаем pred=7\text{pred} = 7 и L=(7−5)2=4L = (7 - 5)^2 = 4. Градиенты посчитаем руками по цепному правилу:

∂L∂w=2(pred−y)⋅x=2⋅2⋅2=8,∂L∂b=2(pred−y)⋅1=4\begin{gathered} \frac{\partial L}{\partial w} = 2(\text{pred} - y) \cdot x = 2 \cdot 2 \cdot 2 = 8, \\ \frac{\partial L}{\partial b} = 2(\text{pred} - y) \cdot 1 = 4 \end{gathered}

То есть, если чуть увеличить ww, loss вырастет примерно в 8 раз быстрее, а если bb — в 4 раза. Теперь то же самое на micrograd и на PyTorch:

from engine import Value # ваш engine.py из ДЗ s3-hw01-micrograd (или micrograd.engine)
x = Value(2.0)
w = Value(3.0)
b = Value(1.0)
y = Value(5.0)
pred = w * x + b
loss = (pred - y) ** 2
loss.backward()
print(w.grad, b.grad) # 8.0 4.0

Код почти построчно совпадает. Разница в одном: micrograd следит за каждым Value, а PyTorch — только за тензорами, где вы явно попросили requires_grad=True, и за всем, что из них вычислено. Это экономия: градиент по входным данным (картинкам, токенам) обычно не нужен, и PyTorch не тратит на него ни память, ни время.

Во время прямого прохода PyTorch записывает граф. У каждого результата операции есть атрибут grad_fn — «как меня получили и как через меня пропустить градиент назад». Это ровно ваши _prev и _backward из micrograd.

flowchart LR
W["w = 3<br/>requires_grad"] --> M["MulBackward0<br/>w·x = 6"]
X["x = 2"] --> M
M --> A["AddBackward0<br/>pred = 7"]
B["b = 1<br/>requires_grad"] --> A
A --> S["SubBackward0<br/>pred − y = 2"]
Y["y = 5"] --> S
S --> P["PowBackward0<br/>loss = 4"]
Граф, который PyTorch записал во время прямого прохода. backward() обходит его справа налево
# продолжаем пример выше
print(w.is_leaf, pred.is_leaf) # True False
print(loss.grad_fn) # <PowBackward0 ...>
print(loss.grad_fn.next_functions) # ((<SubBackward0 ...>, 0),)

Тензоры, которые вы создали сами, — листьялистовой тензорEN leaf tensorТензор, созданный пользователем, а не операцией (is_leaf == True). Обычно это параметры модели и входы. Только у листьев с requires_grad после backward() заполняется .grad. графа. Только у листьев с requires_grad=True после backward() заполняется .grad. У промежуточных (pred) градиент считается, но не сохраняется: так экономится память. Если он всё-таки нужен для отладки, перед backward() вызовите pred.retain_grad().

Граф строится заново на каждом прямом проходе, прямо во время выполнения Python-кода. Это динамический графдинамический граф (define-by-run)EN dynamic computational graph (define-by-run)Граф строится заново при каждом выполнении кода, поэтому работают обычные if и for. Так устроены micrograd и PyTorch; противоположность — статический граф (static graph), который сначала описывают, потом запускают. (define-by-run): внутри forward можно писать обычные if и циклы. После backward() граф удаляется, чтобы освободить память. Повторный backward() по тому же графу даст RuntimeError: Trying to backward through the graph a second time. В обычном обучении это и не нужно: на каждом шаге граф строится заново.

Как и в micrograd, backward() не записывает градиент, а прибавляет его к .grad. В модуле 3.2 вы видели почему: если переменная используется в графе дважды, вклады обоих путей нужно сложить. Обратная сторона — градиенты с прошлого шага никуда не исчезают:

x, y = torch.tensor(2.0), torch.tensor(5.0)
w = torch.tensor(3.0, requires_grad=True) # свежие параметры: w.grad пока None
b = torch.tensor(1.0, requires_grad=True)
for step in range(3):
loss = (w * x + b - y) ** 2 # каждый раз строим граф заново
loss.backward()
print(f"шаг {step}: w.grad = {w.grad.item()}")
w.grad = None # обнуляем — так делает optimizer.zero_grad()
loss = (w * x + b - y) ** 2
loss.backward()
print("после обнуления:", w.grad.item())
шаг 0: w.grad = 8.0
шаг 1: w.grad = 16.0
шаг 2: w.grad = 24.0
после обнуления: 8.0

Забытое обнуление — баг номер один в обучении. Модель при этом не падает, просто учится всё хуже. В уроке «Как отлаживать обучение» вы увидите, как это выглядит на MNIST.

v = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
q = v ** 2 # вектор, а не число
q.backward() # RuntimeError: grad can be implicitly created only for scalar outputs

Градиент определён для одного числа — функции потерь. Если выход — вектор, сначала сверните его: q.sum().backward() даст v.grad = tensor([2., 4., 6.]), то есть ∂(∑vi2)/∂v=2v\partial(\sum v_i^2)/\partial v = 2v. В обучении это происходит естественно: loss всегда усредняют по батчу до одного числа.

Соберём всё вместе и обучим линейную регрессию y=3x+1y = 3x + 1 градиентным спуском. Как в уроке 2.2, только производные теперь считает autograd:

import torch
torch.manual_seed(0)
X = torch.linspace(-1, 1, 50) # 50 точек на отрезке [-1, 1]
Y = 3 * X + 1 + 0.1 * torch.randn(50) # «правда»: y = 3x + 1 плюс шум
w = torch.zeros((), requires_grad=True) # параметры-скаляры, начинаем с нуля
b = torch.zeros((), requires_grad=True)
lr = 0.1
for step in range(101):
loss = ((w * X + b - Y) ** 2).mean() # MSE
loss.backward() # заполняет w.grad и b.grad
with torch.no_grad(): # обновление — не часть графа
w -= lr * w.grad
b -= lr * b.grad
w.grad = None # обнуляем накопленные градиенты
b.grad = None
if step % 20 == 0:
print(f"шаг {step:3d}: loss = {loss.item():.4f}, w = {w.item():.3f}, b = {b.item():.3f}")
шаг 0: loss = 4.2035, w = 0.210, b = 0.201
шаг 20: loss = 0.1907, w = 2.360, b = 0.996
шаг 40: loss = 0.0214, w = 2.870, b = 1.005
шаг 60: loss = 0.0119, w = 2.991, b = 1.005
шаг 80: loss = 0.0113, w = 3.020, b = 1.005
шаг 100: loss = 0.0113, w = 3.027, b = 1.005

Модель нашла w≈3.03w \approx 3.03, b≈1.005b \approx 1.005; отличие от 3 и 1 — из-за шума в данных. Loss остановился около 0.011: это примерно дисперсия шума 0.12=0.010.1^2 = 0.01 (дисперсия — средний квадрат отклонения, урок 1.3.1). Шум случаен, и никакая прямая его не предскажет, поэтому лучше на этих данных не сделать.

Конструкция with ...: вам знакома по with open(...) из урока 0.2.4: она включает особый режим на время выполнения вложенного блока и выключает его после.

Зачем torch.no_grad()? Строка w -= lr * w.grad — тоже операция над тензором с requires_grad. Без no_grad PyTorch попытался бы записать её в граф, а изменять лист графа на месте (это операция «на месте»операция «на месте» (in-place)EN in-place operationМеняет тензор, а не создаёт новый: x += 1, x.zero_(), x.add_(y). В PyTorch такие методы оканчиваются на _.) запрещено:

RuntimeError: a leaf Variable that requires grad is being used in an in-place operation.

torch.no_grad() говорит: «сейчас граф не нужен». Его же используют при оценке модели и генерации текста: граф не строится, и памяти тратится заметно меньше. Близкий родственник — t.detach(): тот же тензор, но отрезанный от графа.

В уроке 3.1.2 вы проверяли backprop численно: f(x+ε)−f(x−ε)2ε\frac{f(x + \varepsilon) - f(x - \varepsilon)}{2\varepsilon}. В PyTorch такая проверка встроена (x.sin() — синус каждого элемента, .sum() сворачивает всё в одно число):

def f(x):
return (x.sin() * x ** 2).sum()
x = torch.randn(5, dtype=torch.float64, requires_grad=True)
print(torch.autograd.gradcheck(f, (x,))) # сравнит autograd с конечными разностями → True

gradcheck требует float64. В float32 ошибки округления в f(x+ε)−f(x−ε)f(x + \varepsilon) - f(x - \varepsilon) огромны: на этом же примере численная производная расходится с точной на десятки процентов, и проверка не проходит. Пригодится, когда будете писать собственные операции.

Тесты micrograd сравнивают его градиенты с PyTorch. Сделаем то же — пример из README micrograd на PyTorch:

import torch
a = torch.tensor(-4.0, dtype=torch.float64, requires_grad=True)
b = torch.tensor(2.0, dtype=torch.float64, requires_grad=True)
c = a + b
d = a * b + b**3
c = c + c + 1
c = c + 1 + c + (-a)
d = d + d * 2 + (b + a).relu()
d = d + 3 * d + (b - a).relu()
e = c - d
f = e**2
g = f / 2.0
g = g + 10.0 / f
g.backward()
print(f"{g.item():.4f} {a.grad.item():.4f} {b.grad.item():.4f}")
24.7041 138.8338 645.5773

Те же числа, что в README micrograd. Алгоритм один и тот же, отличается масштаб:

microgradPyTorch
Единица вычисленийодно число Valueтензор любой формы
Операцийоколо десятка (+, *, **, tanh, exp, relu)тысячи, включая свёртки и attention
ГрафPython-объекты, замыкания _backwardдвижок на C++, узлы grad_fn
Что отслеживаетсякаждое значениетолько тензоры с requires_grad=True
Устройстватолько CPUCPU, GPU NVIDIA и AMD, Apple, NPU
Скоростьединицы микросекунд на одну скалярную операциюодна операция над миллионами чисел за доли миллисекунды

Последняя строка решает всё. Один слой 784 → 128 на батче из 64 картинок — это 64⋅784⋅128≈6.464 \cdot 784 \cdot 128 \approx 6.4 млн умножений и столько же сложений. В micrograd это почти 13 миллионов объектов Value и десятки секунд на один прямой проход. PyTorch делает ту же работу одним вызовом матричного умножения за доли миллисекунды — в десятки тысяч раз быстрее, а на GPU ещё быстрее.

Код урока запускается локально в окружении из урока 0.1 (pip install torch). Выполните три упражнения:

  1. Сверка с micrograd. В ДЗ s3-hw01-micrograd такую сверку за вас делали готовые тесты. Теперь напишите её сами: возьмите свой engine.py и создайте рядом test_vs_torch.py по образцу test/test_engine.py из micrograd. Три выражения (с tanh, exp и делением) считаются в обоих движках, затем assert abs(v.grad - t.grad.item()) < 1e-6. Тензоры PyTorch создавайте в float64, иначе погрешность float32 не уложится в допуск. Запустите через pytest. Если тест упал, ищите ошибку в _backward соответствующей операции.
  2. Регрессия на PyTorch. Измените пример с y = 3x + 1 так, чтобы модель училась на y=−2x2+0.5y = -2x^2 + 0.5 с тремя параметрами ax2+bx+ca x^2 + b x + c. Подберите lr и число шагов, чтобы найти a≈−2a \approx -2, b≈0b \approx 0, c≈0.5c \approx 0.5. Затем уберите строки w.grad = None и опишите, что произошло.
  3. Точность. Повторите опыт «1000 × 0.01» для float16 (должно выйти 9.953). Объясните через eps и шаг сетки чисел около текущей суммы, почему ответ не ровно 10, но и не 4. При какой сумме застрял бы float16, если прибавлять 0.01 дальше? Проверьте расчёт циклом.
Проверь себя6 вопросов
1 После w = torch.tensor(1.0, requires_grad=True) трижды выполнили loss = (2 * w) ** 2; loss.backward() без обнуления. Чему равен w.grad?
2 Что выведет код: a = np.ones(3); t = torch.from_numpy(a); a[0] = 5; print(t[0].item())?
3 Какие утверждения верны?Можно выбрать несколько ответов
4 Вы замерили a @ b на GPU без torch.cuda.synchronize() и получили 0.02 мс для матриц 8192×8192. Почему это неправда?
5 Почему обновление весов w -= lr * w.grad пишут внутри with torch.no_grad():?
6 Сколько гигабайт займут веса модели на 14 млрд параметров в float32? (1 ГБ = 10910^9 байт)
  • Тензор = массив NumPy + device + autograd. Первым делом при любой ошибке печатайте shape, dtype, device.
  • dtype решает, сколько памяти займут веса: 4 байта (fp32), 2 (bf16), 1 (fp8). bfloat16 не переполняется, но точность у него грубая, поэтому суммы и нормализации считают в fp32.
  • Autograd — тот же micrograd, только для тензоров и на C++: requires_grad=True → прямой проход строит граф → backward() складывает градиенты в .grad листьев.
  • Градиенты накапливаются, обновление весов делают в torch.no_grad(), backward() вызывают от скаляра.
  • GPU выигрывает на больших однородных вычислениях. Замеряйте с прогревом и torch.cuda.synchronize().

Дальше: nn.Module, оптимизаторы, DataLoader и стандартный цикл обучения. Обучим классификатор рукописных цифр MNIST на CPU и сохраним чекпоинт.