От чисел к тензорам: библиотека nanotorch
Почему тензоры
Движок из прошлого урока создаёт объект 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):
Как запомнить: градиент должен иметь форму самого тензора, а составить её из 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
X имеет форму (32, 10), W — (10, 4), Y = X @ W, и пришёл градиент G = ∂L/∂Y. Какая форма у ∂L/∂W?
b формы (4,) прибавили к матрице Z формы (32, 4) (broadcasting). Пришёл градиент G формы (32, 4). Чему равен ∂L/∂b?
Зачем обновление весов делают внутри with no_grad(): (или напрямую через .data)?
Градиент матричного умножения. Для Y = AB и пришедшего градиента G = \partial L / \partial Y:
Напишите 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 неверен"
import numpy as np
def matmul_backward(A, B, G):
return G @ B.T, A.T @ G
Сворачивание градиента после broadcasting. Операнд формы shape растянули broadcasting'ом,
и пришёл градиент grad большей формы. Напишите unbroadcast(grad, shape):
- пока у
gradбольше осей, чем вshape, суммируйте по оси 0; - для каждой оси, где в
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
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
Цикл обучения на 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)
import numpy as np
import nanotorch as nt
def train(X, y, epochs=200, lr=0.5):
W = nt.Parameter(np.zeros((2, 1)))
b = nt.Parameter(np.zeros(1))
opt = nt.SGD([W, b], lr=lr)
for _ in range(epochs):
logits = nt.Tensor(X) @ W + b
loss = nt.binary_cross_entropy(logits, y)
opt.zero_grad()
loss.backward()
opt.step()
return W, b
Войдите, чтобы отмечать пройденные уроки.