Линейный слой
От нейрона к слою
Один нейрон выдаёт одно число. Но обычно нам нужно несколько выходов: 10 вероятностей для 10 цифр, или много «признаков» для следующего слоя. Возьмём m нейронов, каждый со своими весами, и подадим им одни и те же входы. Это линейный слой (он же полносвязный, dense, fully connected): каждый вход соединён с каждым нейроном.
Сложим векторы весов нейронов столбцами в матрицу W размера d_{in} \times d_{out}, а сдвиги — в вектор b. Тогда все выходы для одного объекта — z = xW + b, а для батча (объекты в строках X):
Это мы уже видели в уроке о матрицах. Теперь — как часть нейросети.
import numpy as np
import nanotorch as nt
nt.manual_seed(0)
layer = nt.Linear(3, 4) # 3 входа → 4 нейрона
print(layer)
print("W:", layer.W.shape, " b:", layer.b.shape, " параметров:", layer.num_params())
X = nt.tensor(np.random.default_rng(0).normal(size=(5, 3))) # батч из 5 объектов
Z = layer(X)
print("выход:", Z.shape)
print("столбец 0 матрицы W — веса нейрона 0:", layer.W.data[:, 0].round(3))
Параметры и стоимость
Линейный слой d_{in} \to d_{out} имеет d_{in} \cdot d_{out} + d_{out} параметров. Это много: слой 784 → 128 для картинок 28×28 — уже 100 тысяч чисел. Вычисление стоит N \cdot d_{in} \cdot d_{out} умножений на батч.
| Слой | Параметров |
|---|---|
| 2 → 1 (один нейрон) | 3 |
| 64 → 10 | 650 |
| 784 → 128 | 100 480 |
| 4096 → 4096 (слой языковой модели) | ~16,8 млн |
Градиенты слоя
Из прошлого модуля мы знаем, что для Z = XW + b и пришедшего градиента G = \frac{\partial L}{\partial Z}:
Последняя формула — градиент по входу слоя: его слой передаёт назад, предыдущему слою. Так градиент и проходит через всю сеть. Проверим, что nanotorch считает именно это:
import numpy as np
import nanotorch as nt
nt.manual_seed(0)
layer = nt.Linear(3, 2)
X = nt.tensor(np.random.default_rng(1).normal(size=(4, 3)), requires_grad=True)
Z = layer(X)
G = np.random.default_rng(2).normal(size=(4, 2)) # пусть L = Σ Z ⊙ G, тогда ∂L/∂Z = G
(Z * G).sum().backward()
print("∂L/∂W совпадает с Xᵀ·G:", np.allclose(layer.W.grad, X.data.T @ G))
print("∂L/∂b совпадает с ΣG: ", np.allclose(layer.b.grad, G.sum(axis=0)))
print("∂L/∂X совпадает с G·Wᵀ:", np.allclose(X.grad, G @ layer.W.data.T))
Первая модель на настоящих данных
Возьмём рукописные цифры 8×8. Каждая картинка — вектор из 64 чисел. Линейный слой 64 → 10 выдаёт 10 логитов, softmax превращает их в вероятности, а функция потерь — кросс-энтропия. Такая модель называется softmax-регрессией (или многоклассовой логистической регрессией). По сути это 10 нейронов, каждый «голосует» за свою цифру.
import numpy as np
import nanotorch as nt
import ml_data
import vis
X, y = ml_data.load_digits()
X = X / 16.0 # пиксели в [0, 1]
X_train, X_test, y_train, y_test = nt.train_test_split(X, y, test_size=0.2, seed=0)
nt.manual_seed(0)
model = nt.Linear(64, 10)
opt = nt.SGD(model.parameters(), lr=0.1)
history = nt.fit(model, X_train, y_train, nt.cross_entropy, opt, epochs=40, batch_size=64,
X_val=X_test, y_val=y_test, metric=nt.accuracy)
vis.line({"train": history["loss"], "test": history["val_loss"]}, title="Кросс-энтропия", xlabel="эпоха")
with nt.no_grad():
pred = model(nt.Tensor(X_test)).data.argmax(axis=1)
print(f"точность на тесте: {(pred == y_test).mean():.1%}")
Около 95% — неплохо для модели, которая проводит между классами плоскости (гиперплоскости в 64-мерном пространстве). Для сравнения, случайное угадывание даёт 10%.
Что выучили веса
Каждый столбец W — 64 веса одного нейрона-«голосующего». Свернём их обратно в картинки 8×8: красный — положительный вес (пиксель «за» эту цифру), синий — отрицательный («против»).
import numpy as np
import nanotorch as nt
import ml_data
import vis
X, y = ml_data.load_digits()
X = X / 16.0
nt.manual_seed(0)
model = nt.Linear(64, 10)
nt.fit(model, X, y, nt.cross_entropy, nt.SGD(model.parameters(), lr=0.1), epochs=40, batch_size=64, verbose=False)
vis.images(model.W.data.T.reshape(10, 8, 8), titles=[f"нейрон «{d}»" for d in range(10)], cols=5, diverging=True)
У нейрона «0» видно кольцо с «дыркой» посередине, у «1» — вертикальная полоса. Линейный классификатор выучил по одному шаблону на класс и сравнивает с ним картинку скалярным произведением. В этом и его предел: если «семёрки» пишут двумя разными способами, один шаблон не может хорошо подойти к обоим. Чтобы модель могла выучить много шаблонов и комбинировать их, нужны скрытые слои.
Итог
- Линейный слой — d_{out} нейронов с общими входами: Z = XW + b, столбец W — веса одного нейрона.
- Параметров d_{in}d_{out} + d_{out}; выход для батча — (N, d_{out}).
- Градиенты: \partial W = X^TG, \partial b = \sum G, \partial X = GW^T — последний уходит в предыдущий слой.
- Softmax-регрессия на цифрах даёт ~95%; веса — по одному шаблону на класс.
Практика решено 0 из 5
Сколько параметров у линейного слоя 784 → 128 (со сдвигами)?
Батч из 64 объектов по 20 признаков прошёл через слой Linear(20, 5). Какая форма выхода?
Что хранит столбец j матрицы весов W формы (d_in, d_out)?
Свой линейный слой. Напишите модуль MyLinear(nt.Module):
- в
__init__(self, n_in, n_out)создайтеself.W = nt.Parameter(...)формы(n_in, n_out)со случайными значениямиnp.random.default_rng(0).normal(0, 1 / np.sqrt(n_in), size=(n_in, n_out))иself.b = nt.Parameter(np.zeros(n_out)); - в
forward(self, x)вернитеx @ self.W + self.b.
Какие тесты проверяют код (2)
layer = MyLinear(3, 4)
assert layer.num_params() == 16, f"У слоя 3 → 4 должно быть 16 параметров, найдено {layer.num_params()}"
assert layer.W.shape == (3, 4) and layer.b.shape == (4,)
assert np.abs(layer.W.data).sum() > 0, "Веса не должны быть нулями — используйте случайные значения"
layer = MyLinear(3, 2)
layer.b.data[:] = [1.0, -1.0]
x = nt.tensor(np.ones((5, 3)))
out = layer(x)
assert out.shape == (5, 2)
assert np.allclose(out.data, np.ones((5, 3)) @ layer.W.data + [1, -1])
out.sum().backward()
assert np.allclose(layer.b.grad, [5, 5]), "Градиент сдвига — сумма по батчу"
import numpy as np
import nanotorch as nt
class MyLinear(nt.Module):
def __init__(self, n_in, n_out):
rng = np.random.default_rng(0)
self.W = nt.Parameter(rng.normal(0, 1 / np.sqrt(n_in), size=(n_in, n_out)))
self.b = nt.Parameter(np.zeros(n_out))
def forward(self, x):
return x @ self.W + self.b
Softmax-регрессия на цифрах. Обучите линейный классификатор nt.Linear(64, 10) на цифрах.
Напишите train_digits(X_train, y_train, epochs=30): модель, оптимизатор nt.SGD(..., lr=0.1),
потеря nt.cross_entropy, мини-батчи по 64 через nt.batches(X_train, y_train, 64). Верните модель.
Тест подаст картинки, делённые на 16 (значения от 0 до 1), и проверит точность на отложенных данных: > 90%.
Какие тесты проверяют код (1)
import ml_data
X, y = ml_data.load_digits()
X = X / 16
X_train, X_test, y_train, y_test = nt.train_test_split(X, y, test_size=0.2, seed=0)
model = train_digits(X_train, y_train)
with nt.no_grad():
acc = nt.accuracy(model(nt.Tensor(X_test)), y_test)
assert acc > 0.9, f"Точность на тесте {acc:.1%}, нужно > 90%"
import numpy as np
import nanotorch as nt
def train_digits(X_train, y_train, epochs=30):
nt.manual_seed(0)
model = nt.Linear(64, 10)
opt = nt.SGD(model.parameters(), lr=0.1)
for _ in range(epochs):
for xb, yb in nt.batches(X_train, y_train, 64):
loss = nt.cross_entropy(model(nt.Tensor(xb)), yb)
opt.zero_grad()
loss.backward()
opt.step()
return model
Войдите, чтобы отмечать пройденные уроки.