Как создать нейросеть с нуля: рабочий пример на Python

Как создать нейросеть с нуля: рабочий пример на Python Полезное

Нейросеть (искусственная нейронная сеть) — это математическая модель, которая преобразует входные числа в выходные через слои простых элементов-нейронов, а нужное преобразование не программируют вручную, а подбирают в процессе обучения на примерах. В этой статье вы напишете такую сеть на Python и numpy, обучите ее решать задачу XOR и увидите по числам, как от эпохи к эпохе падает ошибка, а затем проверите, как отличить обученную сеть от сети, которая просто запомнила примеры.

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

Из чего состоит нейросеть: нейрон, слой, веса, активация

Каждый термин ниже в примере — конкретная строка кода, поэтому к таблице удобно возвращаться при чтении скрипта.

Термин Что это В коде примера
Нейрон умножает каждый вход на свой вес, складывает, прибавляет смещение и пропускает сумму через функцию активации один столбец результата sigmoid(X @ W1 + b1)
Веса коэффициенты у входов нейрона, их сеть меняет при обучении матрицы W1, W2
Смещение число, которое прибавляют к взвешенной сумме, тоже подбирается при обучении b1, b2
Слой группа нейронов над одним входом: одна матрица весов и одно матричное умножение sigmoid(X @ W1 + b1)
Функция активации нелинейное преобразование суммы, у нас сигмоида; без нее сеть из любого числа слоев осталась бы линейной функция sigmoid
Эпоха один проход по всем обучающим примерам с обновлением весов одна итерация цикла for

Наша сеть — сеть прямого распространения (feed-forward). Когда она считает ответ, данные идут в одну сторону: вход -> скрытый слой -> выход, без циклов. При обучении есть и обратное направление: ошибку от выхода пробрасывают назад к входу, чтобы понять, как поправить каждый вес. Это и есть обратное распространение (backprop), оно разобрано в шаге 3.

Такие сети выросли из перцептрона — модели, которую Фрэнк Розенблатт описал в 1957 году. Однослойный перцептрон с пороговой активацией делит данные на два класса только прямой линией, поэтому XOR ему не по силам. Скрытый слой в нашем примере нужен именно для этого.

Минимальный рабочий пример на Python

Задача — классический XOR: сеть должна выдавать 1, когда два входа разные, и 0, когда одинаковые. Одной прямой такие точки не разделить, поэтому в сети есть скрытый слой. Архитектура 2-2-1: два входа, два нейрона в скрытом слое, один выход. np.random.seed(1) фиксирует случайные начальные веса, чтобы результат повторялся.

Как запустить:

  1. Сохраните код ниже в файл xor.py.
  2. Установите NumPy: pip install numpy.
  3. Выполните python xor.py (в некоторых системах команда называется python3 xor.py) и сверьте вывод с блоком после кода.
import numpy as np

np.random.seed(1)

# Игрушечная задача XOR: 4 примера, 2 входа -> 1 выход
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
Y = np.array([[0], [1], [1], [0]])

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

def sigmoid_deriv(a):        # a = sigmoid(z)
    return a * (1 - a)

# Веса двух слоев: 2->2 (скрытый) и 2->1 (выходной)
W1 = np.random.uniform(-1, 1, (2, 2))
b1 = np.zeros((1, 2))
W2 = np.random.uniform(-1, 1, (2, 1))
b2 = np.zeros((1, 1))

lr = 0.5
for epoch in range(1, 20001):
    # forward: прямой проход
    h = sigmoid(X @ W1 + b1)      # выход скрытого слоя
    out = sigmoid(h @ W2 + b2)    # выход сети

    # loss: среднеквадратичная ошибка
    loss = np.mean((Y - out) ** 2)

    # backprop: обратное распространение ошибки
    d_out = (out - Y) * sigmoid_deriv(out)
    d_h = (d_out @ W2.T) * sigmoid_deriv(h)

    # обновление весов (градиентный спуск)
    W2 -= lr * (h.T @ d_out)
    b2 -= lr * d_out.sum(axis=0, keepdims=True)
    W1 -= lr * (X.T @ d_h)
    b1 -= lr * d_h.sum(axis=0, keepdims=True)

    if epoch in (1, 2000, 20000):
        print(f"epoch {epoch:5d}  loss {loss:.4f}")

print("\nПредсказания сети:")
for x, p in zip(X, out):
    print(f"{x} -> {p[0]:.3f}")

Вывод программы (в другой версии Python или NumPy последние знаки могут немного отличаться):

epoch     1  loss 0.2761
epoch  2000  loss 0.0029
epoch 20000  loss 0.0001

Предсказания сети:
[0 0] -> 0.011
[0 1] -> 0.988
[1 0] -> 0.988
[1 1] -> 0.012

Что видно в этих числах. На первой эпохе ошибка 0.2761: веса случайные, и сеть почти на любой вход отвечает одно и то же — от 0.31 до 0.37. К 2000-й эпохе ошибка упала почти в сто раз — основное обучение проходит в начале. Дальше она уменьшается медленно, а в конце ответы близки к правильным: около 0 для одинаковых входов и около 1 для разных. Сеть решила XOR.

Разбор по шагам

1. Forward: прямой проход

На прямом проходе сеть считает свой ответ:

h = sigmoid(X @ W1 + b1)      # выход скрытого слоя
out = sigmoid(h @ W2 + b2)    # выход сети

X @ W1 — матричное умножение, которое за одну операцию считает взвешенные суммы обоих нейронов скрытого слоя сразу для всех четырех строк входа. Прибавляем смещение b1 и пропускаем через сигмоиду — получаем выход скрытого слоя h. Тот же прием повторяем для второго слоя и получаем ответ сети out, числа от 0 до 1.

2. Loss: насколько сеть ошиблась

loss = np.mean((Y - out) ** 2)

Это среднеквадратичная ошибка (MSE): разница между ожидаемым Y и полученным out, возведенная в квадрат и усредненная. Чем меньше loss, тем ближе ответы сети к правильным. Эту величину обучение и уменьшает.

3. Backprop: обратное распространение ошибки

d_out = (out - Y) * sigmoid_deriv(out)
d_h = (d_out @ W2.T) * sigmoid_deriv(h)

Backprop отвечает на вопрос, как каждый вес повлиял на ошибку. Сначала считаем «дельту» выходного слоя d_out: насколько и в какую сторону ошибся выход, с поправкой на производную активации. Затем пробрасываем эту дельту назад через веса W2 на скрытый слой и получаем его дельту d_h.

4. Обновление весов: градиентный спуск

W2 -= lr * (h.T @ d_out)
W1 -= lr * (X.T @ d_h)

Дельты и входы каждого слоя дают направление, в котором ошибка растет быстрее всего. Чтобы ошибку уменьшить, делаем шаг в противоположную сторону: вычитаем это направление, умноженное на скорость обучения lr. Смещения b1 и b2 обновляются так же. Повторяем 20 000 эпох — и веса подстраиваются так, что сеть решает XOR.

Одна оговорка для тех, кто сверяет формулы с учебником. Точная производная MSE содержит постоянный множитель 2/N, а здесь он опущен, и вклад четырех примеров суммируется, а не усредняется. Направление шага от этого не меняется, меняется только его длина, а ее и так задает lr: множитель как бы входит в скорость обучения.

Скорость обучения lr — это размер шага. Слишком большой — ошибка скачет, слишком маленький — сеть учится медленно. Значение 0.5 для этой задачи дает устойчивое обучение.

Частая ошибка: забыть нелинейность

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

h = X @ W1 + b1                  # прямой проход: без sigmoid
d_h = d_out @ W2.T               # обратный проход: без sigmoid_deriv(h)

Результат при seed(1): ошибка останавливается на loss 0.2500 и дальше не падает, а на все четыре входа сеть отвечает 0.500. Это лучшее, что может сделать линейная модель на XOR: точки (0, 1) и (1, 0) нельзя отделить от (0, 0) и (1, 1) одной прямой, и сеть выдает середину. Без нелинейности два слоя сводятся к одному линейному преобразованию перед сигмоидой, поэтому скрытый слой ничего не добавляет. Исправление — вернуть sigmoid в обе строки.

Обучающая, валидационная и тестовая выборки: как заметить переобучение

В примере с XOR сеть училась на всех четырех возможных входах. Код и ответы на эти входы проверить можно, но других комбинаций у XOR нет, поэтому нельзя проверить, как сеть справится с примерами, которых она не видела. На реальных данных это главный вопрос: сеть видит лишь часть возможных примеров и должна правильно отвечать на новые. Если оценивать ее на тех же данных, на которых она училась, вы узнаете, насколько хорошо она их запомнила, а не умеет ли она решать задачу.

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

Выборка Для чего Когда на нее смотрят
Обучающая (train) по ней считают ошибку и обновляют веса на каждом шаге обучения
Валидационная (validation) по ней выбирают эпоху, архитектуру, скорость обучения периодически во время обучения
Тестовая (test) итоговая оценка на данных, которые не влияли ни на веса, ни на выбор настроек один раз, в самом конце

Валидация нужна потому, что настройки, подобранные по тесту, подгоняются и под него, и итоговая оценка выходит слишком оптимистичной. Частая пропорция — 60/20/20 или 70/15/15, но это ориентир, а не правило: при небольшом объеме данных вместо одной валидационной части используют кросс-валидацию. Главное правило не зависит от пропорции: тестовую часть не используют при подборе модели.

Покажу эффект на той же механике, что и в примере с XOR. Задача — по координатам точки определить, лежит ли она внутри круга. У 15% точек правильный ответ — внутри или снаружи — намеренно заменен на противоположный. Так выглядят ошибки разметки в реальных данных, и большая сеть способна выучить их наизусть. Сеть 2-64-1 нарочно взята с запасом параметров, чтобы ей было что запоминать.

import numpy as np

rng = np.random.default_rng(0)

# 200 точек: класс 1 - внутри круга, 0 - снаружи; у 15% точек ответ перепутан (шум)
X = rng.uniform(-1, 1, (200, 2))
Y = (X[:, 0] ** 2 + X[:, 1] ** 2 < 0.5).astype(float).reshape(-1, 1)
flip = rng.random(200) < 0.15
Y[flip] = 1 - Y[flip]

# Разбиение 60/20/20: обучающая, валидационная, тестовая выборки
idx = rng.permutation(200)
tr, va, te = idx[:120], idx[120:160], idx[160:]

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

def predict(Xs, p):
    return sigmoid(sigmoid(Xs @ p["W1"] + p["b1"]) @ p["W2"] + p["b2"])

def mse(rows, p):
    return np.mean((Y[rows] - predict(X[rows], p)) ** 2)

# Сеть 2-64-1: параметров заведомо больше, чем нужно для круга
p = {"W1": rng.normal(0, 1, (2, 64)), "b1": np.zeros((1, 64)),
     "W2": rng.normal(0, 1, (64, 1)), "b2": np.zeros((1, 1))}
lr, n = 1.0, len(tr)
best_val, best_epoch, best_p = np.inf, 0, None

for epoch in range(1, 40001):
    h = sigmoid(X[tr] @ p["W1"] + p["b1"])
    out = sigmoid(h @ p["W2"] + p["b2"])
    d_out = (out - Y[tr]) * out * (1 - out)
    d_h = (d_out @ p["W2"].T) * h * (1 - h)
    p["W2"] -= lr * (h.T @ d_out) / n
    p["b2"] -= lr * d_out.mean(axis=0, keepdims=True)
    p["W1"] -= lr * (X[tr].T @ d_h) / n
    p["b1"] -= lr * d_h.mean(axis=0, keepdims=True)

    if epoch % 1000 == 0:
        val = mse(va, p)
        if val < best_val:   # запоминаем веса с лучшей ошибкой на валидации
            best_val, best_epoch = val, epoch
            best_p = {k: v.copy() for k, v in p.items()}
        if epoch % 8000 == 0:
            print(f"epoch {epoch:5d}  train {mse(tr, p):.4f}  val {val:.4f}")

acc = np.mean((predict(X[te], best_p) > 0.5) == Y[te])
print(f"\nлучшая эпоха по валидации: {best_epoch}, val {best_val:.4f}")
print(f"точность на тестовой выборке: {acc:.2f}")

Цикл обучения тот же, что раньше, только шаг усреднен по примерам. Раз в 1000 эпох скрипт считает ошибку на валидации и запоминает веса с лучшим значением. Тест участвует один раз — в конце. Вывод программы (случайные числа здесь задает default_rng(0), и в другой версии NumPy их последовательность, а с ней и цифры, может отличаться):

epoch  8000  train 0.1027  val 0.1098
epoch 16000  train 0.0988  val 0.1089
epoch 24000  train 0.0950  val 0.1167
epoch 32000  train 0.0913  val 0.1287
epoch 40000  train 0.0882  val 0.1350

лучшая эпоха по валидации: 15000, val 0.1088
точность на тестовой выборке: 0.95

Ошибка на обучающей выборке падает до конца, а на валидационной после 16 000-й эпохи растет — это и есть переобучение: сеть подгоняет границу под перепутанные ответы. Если бы мы смотрели только на train, то взяли бы веса последней эпохи, то есть худшие для новых данных. Скрипт же берет веса лучшей валидационной эпохи. Цикл при этом доходит до конца; если добавить остановку после нескольких проверок без улучшения, получится ранняя остановка (early stopping) в ее обычном виде. Другие способы бороться с переобучением — регуляризация, dropout, больше данных.

Числа относятся к этому прогону: в тесте всего 40 точек, поэтому точность 0.95 — грубая оценка. Важен порядок: обучение, выбор по валидации, в конце однократная проверка на тесте.

Если хотите посмотреть, как такое разбиение и проверку делают на реальных данных, а не на синтетических точках, посмотрите расписание бесплатных открытых уроков по машинному обучению и выберите интересную тему: вопросы на уроке можно задать преподавателю.

Почему эти скрипты нельзя переносить в рабочий проект

Сеть для XOR обучена на четырех точках, сеть для круга — на 120 синтетических. В них нет регуляризации, обучения по мини-батчам и современных оптимизаторов. Оба набора маленькие и синтетические, а во втором мы специально добавили ошибки разметки. Скрипты показывают, как сеть учится и как заметить переобучение, но на реальных данных начинать стоит с фреймворка.

В реальных задачах производные не выводят руками. На 2026 год стандартные инструменты — PyTorch и TensorFlow (часто через надстройку Keras). Разработчик по-прежнему сам описывает слои, прямой проход и функцию потерь, а фреймворк берет на себя остальное: автоматическое дифференцирование (в PyTorch — autograd) считает производные, оптимизатор обновляет веса, вычисления можно перенести на GPU. Четыре шага из примера остаются теми же, меняется то, кто их выполняет.

Обучить сеть с нуля или настроить готовую модель

Иногда под «своей нейросетью» имеют в виду своего ИИ-ассистента: чат-бота, который отвечает по документам компании. Это другая задача. Большую языковую модель (LLM) с нуля не обучают — это требует огромных данных и вычислений. Берут готовую модель и настраивают: уточняют промпт, подключают поиск по своим документам (RAG) или дообучают на своих примерах. Как устроен RAG, разобрано в статье «RAG: что это и как работает». Своя сеть с нуля, как в этой статье, нужна для учебных задач и для небольших моделей на своих табличных или сенсорных данных.

Что из примера пригодится на реальных данных

Возьмем задачу: по истории заказов клиента предсказать, уйдет ли он в следующем месяце. Входы — числа о клиенте (сколько заказов, какой средний чек, сколько дней с последней покупки), ответ — 1 или 0, как в XOR. Цикл обучения тот же: прямой проход, ошибка, обратное распространение, шаг по весам. А проверка — как во втором примере: часть клиентов откладывают в валидационную выборку и смотрят, насколько точно модель предсказывает уход тех, кого она не видела при обучении. По ней следят за ошибкой и выбирают модель: если ошибка на валидационных клиентах растет, а на обучающих падает, модель запоминает, а не находит закономерность. Еще одну часть клиентов оставляют тестовой и проверяют на ней модель один раз, в самом конце.

Выводы

  • XOR решается только потому, что в скрытом слое есть нелинейная активация: если ее убрать в прямом и обратном проходе, сеть застревает на ответе 0.5 для всех входов.
  • Четыре точки XOR доказывают, что механика работает, но не доказывают, что сеть справится с новыми данными — для этого нужна отложенная выборка.
  • Перед обучением на своих данных разделите их на обучающую, валидационную и тестовую части и выбирайте веса по валидации: рост ошибки на ней при падении на обучающей части — признак переобучения.

Освойте тему на практике

Если хотите пройти путь от такого примера до своей модели на реальных данных, записывайтесь на курс «Машинное обучение. Базовый уровень»: там готовят данные, обучают и оценивают модели на практических задачах.

Смежные темы: Нейронные сети: описание, особенности, виды, Keras: описание и особенности, Искусственный интеллект: описание, характеристика, создание.

FAQ

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

Сколько данных нужно для обучения?
Универсального порога нет. Для XOR хватает четырех примеров, потому что задача ими исчерпывается. Для реальных задач объем зависит от сложности задачи и от того, учите ли вы модель с нуля или настраиваете готовую. Хватает ли данных, оценивают по кривой обучения (как меняется ошибка на обучающей и валидационной выборках при росте объема данных), по качеству на валидации и по требованиям задачи. Если ошибка на валидации перестала падать, это еще не значит, что данных достаточно: мешать могут сама модель, признаки или шум в ответах.

Почему у меня выходят другие числа, чем в примере?
Результат зависит от начальных случайных весов и немного от версий Python и NumPy. Если убрать или изменить np.random.seed(1), стартовые веса будут другими, и конкретные значения ошибки и предсказаний изменятся. Если ошибка при этом перестала падать, проверьте скорость обучения, число эпох и формулы обновления весов.

OTUS Журнал
1+0=2 (поп-ап)