Линейный слой

⏱ 35 мин · 🧩 заданий: 5

От нейрона к слою

Один нейрон выдаёт одно число. Но обычно нам нужно несколько выходов: 10 вероятностей для 10 цифр, или много «признаков» для следующего слоя. Возьмём m нейронов, каждый со своими весами, и подадим им одни и те же входы. Это линейный слой (он же полносвязный, dense, fully connected): каждый вход соединён с каждым нейроном.

входы нейроны слоя x₁ ───┬───────▶ z₁ = w₁·x + b₁ ├───────▶ z₂ = w₂·x + b₂ x₂ ───┤ каждый вход связан ├───────▶ z₃ = w₃·x + b₃ с каждым нейроном x₃ ───┴───────▶ z₄ = w₄·x + b₄

Сложим векторы весов нейронов столбцами в матрицу W размера d_{in} \times d_{out}, а сдвиги — в вектор b. Тогда все выходы для одного объекта — z = xW + b, а для батча (объекты в строках X):

Z = XW + b, \qquad (N \times d_{in}) \cdot (d_{in} \times d_{out}) = (N \times d_{out})

Это мы уже видели в уроке о матрицах. Теперь — как часть нейросети.

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}:

\frac{\partial L}{\partial W} = X^T G, \qquad \frac{\partial L}{\partial b} = \sum_{\text{батч}} G, \qquad \frac{\partial L}{\partial X} = G W^T

Последняя формула — градиент по входу слоя: его слой передаёт назад, предыдущему слою. Так градиент и проходит через всю сеть. Проверим, что 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

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

Сколько параметров у линейного слоя 784 → 128 (со сдвигами)?

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

Батч из 64 объектов по 20 признаков прошёл через слой Linear(20, 5). Какая форма выхода?

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

Что хранит столбец j матрицы весов W формы (d_in, d_out)?

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

Свой линейный слой. Напишите модуль 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]), "Градиент сдвига — сумма по батчу"

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

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%"

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