Полносвязные нейронные сети
Слои подряд
Один слой — это набор гиперплоскостей. Чтобы модель могла строить сложные границы, слои ставят друг за другом, а между ними — нелинейную функцию активации:
Такая сеть называется многослойным перцептроном (MLP) или полносвязной сетью. Промежуточные слои — скрытые: их значения не видны снаружи, сеть сама решает, что в них хранить.
Без нелинейности слои схлопнулись бы в один (мы доказали это в уроке о преобразованиях). Активация «сгибает» пространство, и каждый следующий слой работает с уже изогнутыми данными.
Решаем XOR
Хватит двух скрытых нейронов с ReLU:
- h_1 = \text{ReLU}(x_1 + x_2) — «сколько единиц на входе»;
- h_2 = \text{ReLU}(x_1 + x_2 - 1) — «есть ли обе единицы»;
- \hat y = h_1 - 2h_2.
| x_1 | x_2 | h_1 | h_2 | \hat y = h_1 - 2h_2 |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 0 | 1 | 1 | 0 | 1 |
| 1 | 0 | 1 | 0 | 1 |
| 1 | 1 | 2 | 1 | 0 |
Скрытый слой перевёл четыре точки в новое пространство (h_1, h_2), где классы уже разделимы прямой:
import numpy as np
import vis
X = np.array([[0.0, 0], [0, 1], [1, 0], [1, 1]])
y = np.array([0, 1, 1, 0])
H = np.maximum(0, X @ np.array([[1.0, 1], [1, 1]]) + np.array([0.0, -1]))
print("скрытое представление:\n", H)
vis.scatter(X, labels=y, title="Исходное пространство: прямой не разделить", equal=True)
vis.scatter(H + np.array([[0, 0], [0.03, 0], [-0.03, 0], [0, 0]]), labels=y, lines=[(1, -2, -0.5)],
title="После скрытого слоя: разделяет прямая h₁ − 2h₂ = 0.5", equal=True)
(Точки (0, 1) и (1, 0) попали в одну и ту же точку — их немного раздвинули, чтобы было видно обе.)
А теперь пусть сеть найдёт веса сама:
import numpy as np
import nanotorch as nt
import vis
X = np.array([[0.0, 0], [0, 1], [1, 0], [1, 1]])
y = np.array([0, 1, 1, 0])
nt.manual_seed(3)
model = nt.Sequential(nt.Linear(2, 4), nt.Tanh(), nt.Linear(4, 2))
opt = nt.SGD(model.parameters(), lr=0.5)
for step in range(500):
loss = nt.cross_entropy(model(nt.Tensor(X)), y)
opt.zero_grad(); loss.backward(); opt.step()
with nt.no_grad():
probs = model(nt.Tensor(X)).softmax().data[:, 1]
print("вероятность класса 1:", probs.round(3), " loss =", round(loss.item(), 4))
with nt.no_grad():
vis.boundary(lambda P: model(nt.Tensor(P)).softmax().data[:, 1], X, y, title="XOR выучен сетью 2 → 4 → 2")
Что делают скрытые нейроны
Каждый скрытый нейрон с ReLU — это прямая, по одну сторону которой нейрон молчит (0), а по другую — выдаёт расстояние до прямой. Выходной слой складывает эти «изломанные плоскости» с весами. Из множества прямых получается граница почти любой формы.
import numpy as np
import nanotorch as nt
import vis
X, y = nt.make_circles(300, noise=0.08, seed=0)
nt.manual_seed(1)
model = nt.Sequential(nt.Linear(2, 6), nt.ReLU(), nt.Linear(6, 2))
nt.fit(model, X, y, nt.cross_entropy, nt.Adam(model.parameters(), lr=0.03), epochs=150, batch_size=300, verbose=False)
W, b = model[0].W.data, model[0].b.data
vis.scatter(X, labels=y, lines=[(W[0, j], W[1, j], b[j]) for j in range(6)],
title="6 скрытых нейронов = 6 прямых", equal=True)
with nt.no_grad():
vis.boundary(lambda P: model(nt.Tensor(P)).data.argmax(1), X, y, title="Выходной слой комбинирует их в многоугольник")
print("точность:", nt.accuracy(model(nt.Tensor(X)), y))
Универсальная аппроксимация
Теорема об универсальной аппроксимации (Цыбенко, 1989; Хорник, 1991): сеть с одним скрытым слоем и нелинейной активацией может сколь угодно точно приблизить любую непрерывную функцию на ограниченной области — если нейронов достаточно.
Интуиция для ReLU: каждый нейрон добавляет «излом» в кусочно-линейную функцию. Больше нейронов — больше изломов — точнее приближение.
import numpy as np
import nanotorch as nt
import vis
x = np.linspace(-3, 3, 200).reshape(-1, 1)
target = np.sin(2 * x) + 0.3 * x
curves = {"цель": target.ravel()}
for width in (2, 5, 50):
nt.manual_seed(0)
model = nt.Sequential(nt.Linear(1, width), nt.ReLU(), nt.Linear(width, 1))
# излом нейрона ReLU — там, где w·x + b = 0. При нулевых сдвигах все изломы в точке 0,
# поэтому разбросаем их по отрезку [−3, 3]
model[0].b.data = np.random.default_rng(0).uniform(-3, 3, width) * np.abs(model[0].W.data[0])
opt = nt.Adam(model.parameters(), lr=0.01)
for _ in range(1500):
loss = nt.mse_loss(model(nt.Tensor(x)), target)
opt.zero_grad(); loss.backward(); opt.step()
curves[f"{width} нейронов"] = model(nt.Tensor(x)).data.ravel()
print(f"{width:2} нейронов: MSE = {loss.item():.4f}")
vis.line(x.ravel(), curves, title="Один скрытый слой ReLU разной ширины", xlabel="x")
(Здесь и дальше мы используем оптимизатор Adam — улучшенный градиентный спуск, который обычно сходится быстрее. Как он устроен, разберём в уроке об оптимизаторах.)
Теорема — про существование весов. Она не обещает, что градиентный спуск их найдёт, и не говорит, сколько нейронов нужно. На практике глубокие сети (много слоёв) выражают сложные функции гораздо экономнее широких: каждый слой строит признаки из признаков предыдущего — сначала штрихи, потом части цифр, потом цифры целиком.
Ширина, глубина, параметры
- Ширина — число нейронов в слое; глубина — число слоёв.
- Число параметров MLP — сумма d_{in} d_{out} + d_{out} по слоям.
- Скрытые размеры — гиперпараметры. Для начала хорошо работают 1–3 скрытых слоя по 32–256 нейронов.
import nanotorch as nt
nt.manual_seed(0)
model = nt.Sequential(
nt.Linear(64, 128), nt.ReLU(),
nt.Linear(128, 64), nt.ReLU(),
nt.Linear(64, 10),
)
print(model)
print("параметров:", model.num_params())
for name, p in model.named_parameters():
print(f" {name:12} {p.shape}")
MLP против линейной модели на цифрах
import numpy as np
import nanotorch as nt
import ml_data
import vis
X, y = ml_data.load_digits()
X = X / 16.0
X_train, X_test, y_train, y_test = nt.train_test_split(X, y, test_size=0.25, seed=0)
results = {}
for name, make in [("линейная 64 → 10", lambda: nt.Linear(64, 10)),
("MLP 64 → 128 → 10", lambda: nt.Sequential(nt.Linear(64, 128), nt.ReLU(), nt.Linear(128, 10)))]:
nt.manual_seed(0)
model = make()
h = nt.fit(model, X_train, y_train, nt.cross_entropy, nt.Adam(model.parameters(), lr=0.003),
epochs=30, batch_size=32, X_val=X_test, y_val=y_test, metric=nt.accuracy, verbose=False)
results[name] = h["val_metric"]
print(f"{name:18}: точность на тесте {h['val_metric'][-1]:.1%}")
vis.line(results, title="Точность на тесте по эпохам", xlabel="эпоха")
Скрытый слой поднимает точность с ~95% до ~97–98%: сеть больше не ограничена одним шаблоном на класс.
Итог
- MLP — линейные слои, чередующиеся с нелинейной активацией; без активаций сеть схлопывается в один слой.
- Скрытый слой переводит данные в новое пространство, где классы становятся разделимы; каждый ReLU-нейрон — излом по своей прямой.
- Теорема об универсальной аппроксимации: одного широкого скрытого слоя достаточно в принципе; на практике глубина эффективнее.
- Параметры MLP — \sum (d_{in}d_{out} + d_{out}); размеры слоёв — гиперпараметры.
Практика решено 0 из 6
Сколько параметров у сети Linear(64, 32) → ReLU → Linear(32, 10)?
Что произойдёт, если из сети Linear → ReLU → Linear → ReLU → Linear убрать все ReLU?
Что утверждает теорема об универсальной аппроксимации?
XOR вручную. Сеть: скрытый слой из двух нейронов с ReLU, затем выходной нейрон без активации:
Подберите числа W1 (2×2), b1 (2,), W2 (2×1), b2 (1,) — NumPy-массивы, — чтобы для входов
(0,0), (0,1), (1,0), (1,1) выход был 0, 1, 1, 0.
Подсказка: пусть первый нейрон считает x_1 + x_2, второй — \text{ReLU}(x_1 + x_2 - 1), а выход — первый минус удвоенный второй.
Какие тесты проверяют код (1)
X = np.array([[0.0, 0], [0, 1], [1, 0], [1, 1]])
out = (np.maximum(0, X @ W1 + b1) @ W2 + b2).ravel()
assert np.allclose(out, [0, 1, 1, 0]), f"Выходы {out.round(3)}, нужно [0, 1, 1, 0]"
import numpy as np
W1 = np.array([[1.0, 1.0], [1.0, 1.0]])
b1 = np.array([0.0, -1.0])
W2 = np.array([[1.0], [-2.0]])
b2 = np.array([0.0])
MLP-конструктор. Напишите make_mlp(sizes, activation=nt.ReLU): по списку размеров, например
[2, 16, 16, 3], верните nt.Sequential из линейных слоёв Linear(2, 16), act, Linear(16, 16), act,
Linear(16, 3) — активация между слоями, но не после последнего.
Какие тесты проверяют код (2)
m = make_mlp([2, 16, 16, 3])
kinds = [type(l).__name__ for l in m.layers]
assert kinds == ["Linear", "ReLU", "Linear", "ReLU", "Linear"], f"Слои: {kinds}"
assert m.num_params() == 2 * 16 + 16 + 16 * 16 + 16 + 16 * 3 + 3
m = make_mlp([4, 8, 1], activation=nt.Tanh)
assert [type(l).__name__ for l in m.layers] == ["Linear", "Tanh", "Linear"]
import numpy as np
assert m(nt.tensor(np.ones((7, 4)))).shape == (7, 1)
import nanotorch as nt
def make_mlp(sizes, activation=nt.ReLU):
layers = []
for i, (a, b) in enumerate(zip(sizes, sizes[1:])):
layers.append(nt.Linear(a, b))
if i < len(sizes) - 2:
layers.append(activation())
return nt.Sequential(*layers)
Обучите сеть на спиралях. Три переплетённые спирали линейная модель разделить не может.
Напишите train_spirals(X, y), которая создаёт MLP (например, 2 → 64 → 64 → 3 с ReLU), обучает
её (nt.Adam(model.parameters(), lr=0.01), nt.cross_entropy, полный батч или мини-батчи) и
возвращает модель. Тест требует точность > 95% на обучающих данных.
Какие тесты проверяют код (1)
X, y = nt.make_spirals(300, classes=3, noise=0.15, seed=0)
model = train_spirals(X, y)
with nt.no_grad():
acc = nt.accuracy(model(nt.Tensor(X)), y)
assert acc > 0.95, f"Точность {acc:.1%}, нужно > 95%"
import numpy as np
import nanotorch as nt
def train_spirals(X, y):
nt.manual_seed(0)
model = nt.Sequential(nt.Linear(2, 64), nt.ReLU(), nt.Linear(64, 64), nt.ReLU(), nt.Linear(64, 3))
opt = nt.Adam(model.parameters(), lr=0.01)
for _ in range(300):
loss = nt.cross_entropy(model(nt.Tensor(X)), y)
opt.zero_grad()
loss.backward()
opt.step()
return model
Войдите, чтобы отмечать пройденные уроки.