От чисел к тензорам: библиотека nanotorch

⏱ 50 мин · 🧩 заданий: 6

Почему тензоры

Движок из прошлого урока создаёт объект Python на каждое число. Слой 784 → 128 на батче из 64 картинок — это 6,4 миллиона умножений, то есть миллионы объектов Value на один шаг. Безнадёжно медленно.

Решение: пусть узел графа хранит не число, а целый массив (тензор), а операции будут операциями NumPy над массивами. Граф становится маленьким: вместо миллионов узлов — несколько: X @ W, + b, relu, cross_entropy. А тяжёлая арифметика выполняется на C. Именно так устроены PyTorch и все остальные библиотеки.

Принципы остаются прежними:

  • каждый тензор хранит data (массив) и grad (массив той же формы);
  • каждая операция запоминает входы и умеет пробросить градиент назад;
  • backward() обходит граф в обратном топологическом порядке.

Меняются только локальные производные — теперь это операции с массивами.

Поэлементные операции

Для Y = A \odot B (поэлементное умножение) всё как у чисел, только поэлементно: \frac{\partial L}{\partial A} = G \odot B. Для \text{ReLU}: G \odot [A > 0]. Для e^A: G \odot e^A.

Матричное умножение

Для Y = AB, где A — (n \times k), B — (k \times m), и пришедшего G = \frac{\partial L}{\partial Y} формы (n \times m):

\frac{\partial L}{\partial A} = G\,B^T \quad (n\times m)(m \times k) = (n \times k), \qquad \frac{\partial L}{\partial B} = A^T G \quad (k \times n)(n \times m) = (k \times m)

Как запомнить: градиент должен иметь форму самого тензора, а составить её из G и другого множителя можно только одним способом. Проверим численно:

import numpy as np

rng = np.random.default_rng(0)
A, B = rng.normal(size=(3, 4)), rng.normal(size=(4, 2))
C = rng.normal(size=(3, 2))
L = lambda A, B: ((A @ B) * C).sum()     # тогда ∂L/∂Y = C

G = C
dA, dB = G @ B.T, A.T @ G

h = 1e-6
E = np.zeros_like(A); E[1, 2] = h
print("∂L/∂A[1,2]: формула", round(dA[1, 2], 6), " численно", round((L(A + E, B) - L(A - E, B)) / (2 * h), 6))
E = np.zeros_like(B); E[3, 0] = h
print("∂L/∂B[3,0]: формула", round(dB[3, 0], 6), " численно", round((L(A, B + E) - L(A, B - E)) / (2 * h), 6))

Для линейного слоя Y = XW это значит: \frac{\partial L}{\partial W} = X^T G — входы, умноженные на градиенты выходов, сложенные по батчу. Та же формула, что для одного нейрона (\frac{\partial L}{\partial w} = x \cdot \frac{\partial L}{\partial z}), только для всех нейронов и объектов сразу.

Broadcasting и редукции

Когда сдвиг b формы (4,) прибавляется к матрице (32, 4), он «используется» 32 раза — по разу в каждой строке. Градиенты ветвлений складываются, поэтому \frac{\partial L}{\partial b} = \sum_i G_i — сумма по оси батча. В общем случае градиент нужно свернуть к исходной форме операнда, суммируя по растянутым осям. Это делает функция unbroadcast (её вы напишете в задании).

Обратная ситуация — редукция: s = \sum_{ij} A_{ij}. Каждый элемент влияет на сумму с коэффициентом 1, поэтому \frac{\partial s}{\partial A} — массив единиц формы A, умноженный на пришедший градиент. Для среднего — то же, делённое на число элементов.

Мини-тензор своими руками

Соберём минимальный класс Tensor с тремя операциями — и обучим на нём линейную регрессию:

import numpy as np

def unbroadcast(grad, shape):
    while grad.ndim > len(shape):
        grad = grad.sum(axis=0)
    for axis, size in enumerate(shape):
        if size == 1 and grad.shape[axis] != 1:
            grad = grad.sum(axis=axis, keepdims=True)
    return grad

class Tensor:
    def __init__(self, data, _children=()):
        self.data = np.asarray(data, dtype=float)
        self.grad = np.zeros_like(self.data)
        self._backward = lambda: None
        self._prev = _children

    def __add__(self, other):
        out = Tensor(self.data + other.data, (self, other))
        def _backward():
            self.grad += unbroadcast(out.grad, self.data.shape)
            other.grad += unbroadcast(out.grad, other.data.shape)
        out._backward = _backward
        return out

    def __matmul__(self, other):
        out = Tensor(self.data @ other.data, (self, other))
        def _backward():
            self.grad += out.grad @ other.data.T
            other.grad += self.data.T @ out.grad
        out._backward = _backward
        return out

    def mse(self, target):
        diff = self.data - target
        out = Tensor((diff ** 2).mean(), (self,))
        def _backward():
            self.grad += 2 * diff / diff.size * out.grad
        out._backward = _backward
        return out

    def backward(self):
        topo, seen = [], set()
        def build(t):
            if id(t) not in seen:
                seen.add(id(t))
                for c in t._prev:
                    build(c)
                topo.append(t)
        build(self)
        self.grad = np.ones_like(self.data)
        for t in reversed(topo):
            t._backward()

rng = np.random.default_rng(0)
X = rng.normal(size=(100, 3))
y = X @ np.array([[2.0], [-1.0], [0.5]]) + 3

W, b = Tensor(np.zeros((3, 1))), Tensor(np.zeros(1))
for step in range(200):
    loss = (Tensor(X) @ W + b).mse(y)
    W.grad[:] = 0; b.grad[:] = 0
    loss.backward()
    W.data -= 0.1 * W.grad
    b.data -= 0.1 * b.grad
print("W =", W.data.ravel().round(3), " b =", b.data.round(3), " loss =", round(float(loss.data), 6))

Граф на каждом шаге — всего 4 узла, а вычисляются градиенты для всего батча из 100 объектов.

Библиотека nanotorch

Полная версия этого класса со всеми нужными операциями лежит в модуле nanotorch — нашей мини-библиотеке, похожей на PyTorch. Её Tensor устроен в точности как выше, только операций больше: + - * / ** @, sum, mean, max, exp, log, relu, tanh, sigmoid, softmax, reshape, индексация… У каждой — свой backward, и все проверены численным градиентом. Код открыт: его можно прочитать целиком.

import inspect
import nanotorch as nt

print(inspect.getsource(nt.Tensor.__matmul__))
print(inspect.getsource(nt.Tensor.relu))

Tensor и requires_grad

Градиенты нужны не всем тензорам: входные данные мы не обучаем. Флаг requires_grad=True говорит «следи за этим тензором». Граф строится, только если хотя бы один вход операции требует градиента.

import numpy as np
import nanotorch as nt

x = nt.tensor([[1.0, 2.0], [3.0, 4.0]])                 # данные: градиент не нужен
w = nt.tensor([[0.5], [-1.0]], requires_grad=True)      # параметр

y = (x @ w).relu().sum()
y.backward()
print("y =", y.item())
print("∂y/∂w =", w.grad.ravel())
print("x.grad =", x.grad)                               # None — за x не следили

with nt.no_grad():                                      # граф не строится
    z = x @ w
print("z требует градиент:", z.requires_grad)

no_grad() используют при предсказаниях и при обновлении весов: там граф не нужен.

Проверка градиентов

import numpy as np
import nanotorch as nt

rng = np.random.default_rng(1)
A = nt.tensor(rng.normal(size=(4, 3)), requires_grad=True)

def f(a):
    return ((a.tanh() @ a.T).softmax(axis=1) * 2).log().mean()

out = f(A)
out.backward()
num = nt.numerical_grad(lambda arr: f(nt.tensor(arr)).item(), A.data)
print("максимальная разница с численным градиентом:", float(np.abs(A.grad - num).max()))

Module и Parameter

Чтобы не собирать параметры вручную, используем ту же схему, что в PyTorch:

  • Parameter — тензор с requires_grad=True, обучаемый параметр;
  • Module — базовый класс для слоёв и моделей. Метод forward задаёт вычисление, parameters() сам находит все Parameter — и в атрибутах модуля, и во вложенных модулях.
import numpy as np
import nanotorch as nt

class MyLinear(nt.Module):
    def __init__(self, n_in, n_out):
        self.W = nt.Parameter(np.random.default_rng(0).normal(0, 0.1, size=(n_in, n_out)))
        self.b = nt.Parameter(np.zeros(n_out))

    def forward(self, x):
        return x @ self.W + self.b

class TwoLayer(nt.Module):
    def __init__(self):
        self.l1 = MyLinear(2, 16)
        self.l2 = MyLinear(16, 1)

    def forward(self, x):
        return self.l2(self.l1(x).relu())

model = TwoLayer()
print("параметров:", model.num_params())
for name, p in model.named_parameters():
    print(f"  {name:6} {p.shape}")
out = model(nt.tensor(np.ones((5, 2))))
print("выход:", out.shape)

Оптимизатор

Шаг градиентного спуска тоже упакован в объект: SGD(model.parameters(), lr). У него два метода: zero_grad() обнуляет градиенты, step() делает шаг \theta \leftarrow \theta - \eta \nabla\theta. В следующем модуле мы напишем и более умные оптимизаторы.

Стандартный цикл обучения

Вот он — цикл, который в той или иной форме есть в каждой программе обучения нейросетей:

import numpy as np
import nanotorch as nt
import vis

nt.manual_seed(0)
X, y = nt.make_moons(300, noise=0.15, seed=0)

model = nt.Sequential(nt.Linear(2, 32), nt.ReLU(), nt.Linear(32, 32), nt.ReLU(), nt.Linear(32, 2))
optimizer = nt.SGD(model.parameters(), lr=0.1)

for epoch in range(300):
    logits = model(nt.Tensor(X))               # 1. прямой проход
    loss = nt.cross_entropy(logits, y)         # 2. функция потерь
    optimizer.zero_grad()                      # 3. обнулить градиенты
    loss.backward()                            # 4. обратный проход
    optimizer.step()                           # 5. шаг оптимизатора
    if epoch % 75 == 0 or epoch == 299:
        print(f"эпоха {epoch:3}: loss = {loss.item():.4f}, точность = {nt.accuracy(logits, y):.1%}")

with nt.no_grad():
    predict = lambda pts: model(nt.Tensor(pts)).data.argmax(axis=1)
    vis.boundary(predict, X, y, title="Сеть на nanotorch: 2 → 32 → 32 → 2")

Сравните с прошлым уроком: та же идея, но сеть больше в 10 раз, данных в 5 раз больше, а обучение занимает доли секунды.

Это почти PyTorch

В PyTorch тот же цикл выглядит так: logits = model(X), loss = F.cross_entropy(logits, y), optimizer.zero_grad(), loss.backward(), optimizer.step(). Разница в именах модулей и в том, что PyTorch умеет считать на видеокарте. Поняв nanotorch, вы понимаете, что происходит внутри PyTorch.

Для удобства в nanotorch есть функция nt.fit(model, X, y, loss_fn, optimizer, epochs, batch_size), которая выполняет этот цикл мини-батчами и возвращает историю потерь. Ею мы будем пользоваться дальше, когда цикл уже понятен.

Итог

  • Тензорный движок: узел графа — массив, операции — NumPy, граф маленький, вычисления быстрые.
  • Градиент тензора имеет его форму. Для Y = AB: \partial A = GB^T, \partial B = A^TG.
  • После broadcasting градиент сворачивают суммированием по растянутым осям; для b в XW + b — сумма по батчу.
  • nanotorch: Tensor, requires_grad, no_grad, Parameter, Module, SGD.
  • Цикл обучения: forward → loss → zero_grad → backward → step.

Практика решено 0 из 6

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Один вариант ответа

X имеет форму (32, 10), W — (10, 4), Y = X @ W, и пришёл градиент G = ∂L/∂Y. Какая форма у ∂L/∂W?

Задание 2 Один вариант ответа

b формы (4,) прибавили к матрице Z формы (32, 4) (broadcasting). Пришёл градиент G формы (32, 4). Чему равен ∂L/∂b?

Задание 3 Один вариант ответа

Зачем обновление весов делают внутри with no_grad(): (или напрямую через .data)?

Задание 4 Программа на Python

Градиент матричного умножения. Для Y = AB и пришедшего градиента G = \partial L / \partial Y:

\frac{\partial L}{\partial A} = G B^T, \qquad \frac{\partial L}{\partial B} = A^T G

Напишите matmul_backward(A, B, G), возвращающую пару (dA, dB). Тесты проверят формулы численным градиентом для L = \sum (AB \odot C) — тогда G = C.

Какие тесты проверяют код (1)
rng = np.random.default_rng(0)
A, B, C = rng.normal(size=(3, 4)), rng.normal(size=(4, 2)), rng.normal(size=(3, 2))
L = lambda A, B: ((A @ B) * C).sum()
dA, dB = matmul_backward(A, B, C)
assert np.shape(dA) == A.shape and np.shape(dB) == B.shape, "Формы градиентов должны совпадать с формами A и B"
h = 1e-6
numA = np.zeros_like(A)
for i in range(3):
    for j in range(4):
        E = np.zeros_like(A); E[i, j] = h
        numA[i, j] = (L(A + E, B) - L(A - E, B)) / (2 * h)
numB = np.zeros_like(B)
for i in range(4):
    for j in range(2):
        E = np.zeros_like(B); E[i, j] = h
        numB[i, j] = (L(A, B + E) - L(A, B - E)) / (2 * h)
assert np.allclose(dA, numA, atol=1e-6), "dA неверен"
assert np.allclose(dB, numB, atol=1e-6), "dB неверен"

Задание 5 Программа на Python

Сворачивание градиента после broadcasting. Операнд формы shape растянули broadcasting'ом, и пришёл градиент grad большей формы. Напишите unbroadcast(grad, shape):

  1. пока у grad больше осей, чем в shape, суммируйте по оси 0;
  2. для каждой оси, где в shape размер 1, а в grad нет, суммируйте по ней с keepdims=True.
Какие тесты проверяют код (1)
G = np.arange(12.0).reshape(3, 4)
assert np.array_equal(unbroadcast(G, (4,)), G.sum(0)), "Сдвиг (4,) к матрице (3, 4): сумма по строкам"
assert np.array_equal(unbroadcast(G, (3, 1)), G.sum(1, keepdims=True)), "Столбец (3, 1): сумма по столбцам с keepdims"
assert np.array_equal(unbroadcast(G, (1, 4)), G.sum(0, keepdims=True))
assert np.array_equal(unbroadcast(G, (3, 4)), G)
assert unbroadcast(np.ones((2, 3, 4)), (3, 1)).shape == (3, 1) and unbroadcast(np.ones((2, 3, 4)), (3, 1))[0, 0] == 8

Задание 6 Программа на Python

Цикл обучения на nanotorch. Обучите логистическую регрессию на тензорах. Напишите train(X, y, epochs=200, lr=0.5):

  • W = nt.Parameter(np.zeros((2, 1))), b = nt.Parameter(np.zeros(1));
  • на каждом шаге: logits = nt.Tensor(X) @ W + b, потеря nt.binary_cross_entropy(logits, y);
  • обнулите градиенты (W.grad = None или оптимизатор), loss.backward(), шаг оптимизатора nt.SGD([W, b], lr=lr).

Верните (W, b).

Какие тесты проверяют код (1)
X, y = nt.make_blobs(200, centers=((-2, -1), (2, 1)), std=1.0, seed=3)
W, b = train(X, y)
pred = (X @ W.data + b.data).ravel() > 0
acc = (pred == (y == 1)).mean()
assert acc > 0.95, f"Точность {acc:.1%}, ожидалось > 95%"
assert W.data.shape == (2, 1)

Войдите, чтобы отмечать пройденные уроки.