Обучаем XOR с нуля: backprop вживую

Кульминация курса: собираем всё вместе и обучаем сеть решать XOR с нуля, наблюдая, как падает ошибка.

Эта программа — целая нейросеть на чистом Python: инициализация весов, forward pass, обратное распространение и градиентный спуск в одном цикле обучения.

Постановка

В первом разделе мы доказали, что один нейрон не решает XOR. Теперь у нас есть всё, чтобы решить его сетью 2 → 2 → 1: forward pass, функция потерь, backprop и градиентный спуск. Веса инициализируем случайно (с фиксированным seed для воспроизводимости), и сеть сама найдёт решение за тысячи маленьких шагов.

Полный цикл обучения

import math, random

def sigmoid(z): return 1.0 / (1.0 + math.exp(-z))
def dsigmoid(a): return a * (1.0 - a)   # производная через выход a

random.seed(1)
rw = lambda: random.uniform(-1, 1)

# веса: скрытый слой (2 нейрона по 2 входа) и выходной (1 нейрон, 2 входа)
W1 = [[rw(), rw()] for _ in range(2)]
b1 = [rw() for _ in range(2)]
W2 = [rw(), rw()]
b2 = rw()

data = [((0,0),0), ((0,1),1), ((1,0),1), ((1,1),0)]
lr = 0.5

def forward(x):
    h = [sigmoid(W1[j][0]*x[0] + W1[j][1]*x[1] + b1[j]) for j in range(2)]
    out = sigmoid(W2[0]*h[0] + W2[1]*h[1] + b2)
    return h, out

for epoch in range(1, 10001):
    total_loss = 0.0
    for x, y in data:
        h, out = forward(x)
        total_loss += (out - y) ** 2
        # --- backprop ---
        d_out = (out - y) * dsigmoid(out)
        gW2 = [d_out * h[0], d_out * h[1]]
        d_h = [d_out * W2[0] * dsigmoid(h[0]),
               d_out * W2[1] * dsigmoid(h[1])]
        gW1 = [[d_h[0]*x[0], d_h[0]*x[1]],
               [d_h[1]*x[0], d_h[1]*x[1]]]
        # --- шаг градиентного спуска ---
        for j in range(2):
            W1[j][0] -= lr * gW1[j][0]
            W1[j][1] -= lr * gW1[j][1]
            b1[j]    -= lr * d_h[j]
        W2[0] -= lr * gW2[0]
        W2[1] -= lr * gW2[1]
        b2    -= lr * d_out
    if epoch == 1 or epoch % 2000 == 0:
        print("Эпоха", epoch, "потеря:", round(total_loss, 4))

print("\nПроверка обученной сети:")
for x, y in data:
    _, out = forward(x)
    print(x, "-> предсказание", round(out, 3), "(цель", y, ")")

Вывод:

Эпоха 1 потеря: 1.0778
Эпоха 2000 потеря: 0.0086
Эпоха 4000 потеря: 0.0033
Эпоха 6000 потеря: 0.002
Эпоха 8000 потеря: 0.0014
Эпоха 10000 потеря: 0.0011

Проверка обученной сети:
(0, 0) -> предсказание 0.018 (цель 0 )
(0, 1) -> предсказание 0.984 (цель 1 )
(1, 0) -> предсказание 0.984 (цель 1 )
(1, 1) -> предсказание 0.017 (цель 0 )

Что мы увидели

Это — настоящее обучение нейросети. Стартовав со случайных весов (потеря 1.08), сеть за 10 000 эпох снизила ошибку почти до нуля и теперь уверенно решает XOR: для (0,1) и (1,0) выдаёт ~0.98, для (0,0) и (1,1) — ~0.02. Задачу, непосильную одному нейрону, два скрытых нейрона освоили полностью — и никто не подсказывал им веса.

Разберём цикл по косточкам

  1. Forward считает h и out, запоминая активации скрытого слоя.
  2. d_out — дельта выхода: ошибка (out - y) на производную sigmoid.
  3. d_h — дельты скрытого слоя: дельта выхода, протащенная назад через веса W2.
  4. gW1, gW2 — градиенты: дельта приёмника на активацию источника.
  5. Шаг спуска вычитает lr * градиент из каждого веса.

Что делает скрытый слой внутри

Если после обучения распечатать активации скрытого слоя, обнаружится красивая вещь: два нейрона научились вычислять промежуточные признаки, близкие к логическим OR и AND. Выходной нейрон затем комбинирует их в XOR (грубо говоря, «OR, но не AND»). Никто не задавал эти признаки руками — сеть сама нашла такое разбиение задачи, минимизируя потерю. Это в миниатюре и есть то, ради чего нужны скрытые слои: они автоматически изобретают полезные промежуточные представления, из которых складывается ответ.

Заметьте также форму кривой потери: резкое падение вначале, затем долгое медленное дошлифовывание. Это типичный профиль обучения нейросетей — большинство прогресса приходится на первые эпохи, а дальше идёт тонкая настройка. Поэтому на практике следят за кривой и останавливаются, когда улучшения становятся пренебрежимо малы.

Поэкспериментируйте

Запустите код, затем попробуйте: уменьшить lr до 0.1 (сходимость замедлится) или число эпох до 500 (сеть не успеет обучиться, потеря останется большой). Так вы почувствуете роль гиперпараметров на живом примере.

Итог

  • Цикл обучения = forward + backprop + шаг спуска, повторённые много раз.
  • Сеть 2-2-1 с нуля выучивает XOR, снижая потерю с ~1.08 до ~0.001.
  • Падение потери по эпохам — наглядный «пульс» обучения.
Проверьте себя
1. Что демонстрирует падение потери с 1.08 до 0.001 по эпохам?
AСеть переобучилась
BСеть успешно обучается: предсказания приближаются к целям
CОшибку в градиентах
DСлишком большой learning rate
2. Почему здесь обязателен скрытый слой, а одного нейрона мало?
AСкрытый слой ускоряет код
BXOR не линейно разделим — нужны скрытые нейроны для нелинейной границы
CИначе sigmoid не работает
DЧтобы было больше весов
3. Зачем в коде задан random.seed(1)?
AДля случайности результата
BЧтобы инициализация весов была воспроизводимой и вывод совпадал
CЭто ускоряет обучение
DЧтобы отключить обучение
Поддержать проект