Полносвязные нейронные сети

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

Слои подряд

Один слой — это набор гиперплоскостей. Чтобы модель могла строить сложные границы, слои ставят друг за другом, а между ними — нелинейную функцию активации:

h_1 = \text{ReLU}(XW_1 + b_1), \quad h_2 = \text{ReLU}(h_1W_2 + b_2), \quad \hat y = h_2W_3 + b_3

Такая сеть называется многослойным перцептроном (MLP) или полносвязной сетью. Промежуточные слои — скрытые: их значения не видны снаружи, сеть сама решает, что в них хранить.

вход скрытый слой 1 скрытый слой 2 выход (2) ──▶ Linear → ReLU ──▶ Linear → ReLU ──▶ Linear ──▶ логиты (3) (16 нейронов) (16 нейронов)

Без нелинейности слои схлопнулись бы в один (мы доказали это в уроке о преобразованиях). Активация «сгибает» пространство, и каждый следующий слой работает с уже изогнутыми данными.

Решаем 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

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

Сколько параметров у сети Linear(64, 32) → ReLU → Linear(32, 10)?

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

Что произойдёт, если из сети Linear → ReLU → Linear → ReLU → Linear убрать все ReLU?

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

Что утверждает теорема об универсальной аппроксимации?

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

XOR вручную. Сеть: скрытый слой из двух нейронов с ReLU, затем выходной нейрон без активации:

h = \text{ReLU}(xW_1 + b_1), \qquad \hat y = hW_2 + b_2

Подберите числа 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]"

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

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)

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

Обучите сеть на спиралях. Три переплетённые спирали линейная модель разделить не может. Напишите 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%"

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