В мире Data Science написание нейронных сетей, кажется чем‑то очень трудоёмким, доступным для понимания лишь математикам с многолетним опытом. Многие руководства, начинаются со сложных объяснений backpropagation, градиентного спуска и т.п, от которых у новичков складывается впечатление, что написание нейросетей — им не по силам. В данной статье, я хочу развеять подобные убеждения и показать пример, написания простейшей нейронной сети на python. Мы не будем углубляться в теоретические основы высшей математики. Вместо этого, мы просто возьмем данные, напишем код, посмотрим на результат и проанализируем его.

Наша простейшая нейросеть будет решать классическую задачу — предсказание результата логической операции XOR. Использовать мы будем только один базовый пакет: numpy.

Нейросеть. Что у неё под капотом?

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

Из чего же состоит наш «черный ящик»? Всего из трех слоёв:

1. Входной слой (Input Layer): это наши два нейрона X1 и X2 (A и B на картинке). Они не производят вычислений, а просто принимают данные.

2. Скрытый слой (Hidden Layer): это наш мозг. Мы возьмем 4 нейрона. Каждый нейрон этого слоя связан с каждым нейроном входного слоя. Именно здесь происходят основные преобразования.

3. Выходной слой (Output Layer): это наш результат — один нейрон, который даёт окончательный ответ Y (Q).

У связей между нейронами есть свои веса (weights) ̶ это числовые коэффициенты, определяющие, насколько сильно влияет сигнал от одного нейрона на другой. Изначально, эти веса инициализируются случайными значениями. Обучение нейросети — это поиск идеальных значений для этих весов.

Инструментарий: что нам понадобится?

  • Python 3.x: язык программирования, на котором мы будем писать сам код

  • NumPy: библиотека для эффективных численных вычислений. Позволяет работать с многомерными массивами и матрицами, что идеально ложится на структуру нейронных сетей

Опционально, можно использовать библиотеку Matplotlib, для визуализации процесса обучения (построение графиков ошибки).

Установить данные библиотеки можно через pip. В данной статье мы не будем углубляться в тонкости установки, в интернете много подробной информации о том, как это делается.

Шаг 1: Подготовка данных

В нашем деле всё всегда начинается с данных. Подготовим наш датасет, а именно создадим масcив, отражающий входные (x) и выходные данные (y).

import numpy as np 
# определяем входные данные (X) и целевую переменную (y) 
# таблицы истинности для XOR 
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) 
y = np.array([[0], [1], [1], [0]]) 
print("Входные данные (X):") 
print(X) 
print("\nЦелевые значения (y):") 
print(y)

Этот код создает два массива NumPy: X содержит все комбинации входных сигналов, как показано на картинке в начале статьи, а y — правильные ответы, которые мы ожидаем от нейросети.

Шаг 2: Создаем архитектуру нейросети

Теперь создадим функции, которые определят структуру нашей нейросети и её поведение.

Для начала создадим две матрицы весов:

  • weights1: для связи между входным и скрытым слоем (размер 2×4)

  • weights2: для связи между скрытым и выходным слоем (размером 4×1)

Как уже говорилось выше, изначально, веса инициализируются случайными числами. Это отправная точка в обучении нейросети.

def initialize_weights(input_size, hidden_size, output_size): 
np.random.seed(42)  
    weights1 = np.random.randn(input_size, hidden_size) 
    weights2 = np.random.randn(hidden_size, output_size) 
    return weights1, weights2 
 
# Задаем размеры слоев 
input_size = 2 
hidden_size = 4 
output_size = 1 
 
# Инициализируем веса 
weights1, weights2 = initialize_weights(input_size, hidden_size, 
output_size) 
 
print("Веса W1 (между входным и скрытым слоем):") 
print(weights1) 
print("\nВеса W2 (между скрытым и выходным слоем):") 
print(weights2)

Функции активации

Если бы мы просто перемножали матрицы, наша нейросеть была бы обычным линейным преобразователем. Используем функции активации, чтобы сделать нашу нейросеть нелинейной.

В нашем скрытом слое используем сигмоиду (sigmoid) — функцию, которая сжимает любое число в интервал от 0 до 1. Это помогает сети более плавно обучаться.

def sigmoid(x): 
    return 1 / (1 + np.exp(-x)) 
 
# производная сигмоиды (нужна для обратного распространения 
ошибки, об этом написано далее) 
def sigmoid_derivative(x): 
    return x * (1 - x) 

Шаг 3: Прямое распространение (Forward Pass)

Это процесс обработки данных от входа к выходу сети. Мы будем последовательно умножать матрицы входных данных на веса и применять функцию активации.

def forward_pass(X, weights1, weights2): 
    #умножаем входные данные на веса между входным и скрытым 
слоем 
    hidden_layer_input = np.dot(X, weights1) 
    #применяем функцию активации к скрытому слою 
    hidden_layer_output = sigmoid(hidden_layer_input) 
     
    #умножаем выход скрытого слоя на веса между скрытым и 
выходным слоем 
    output_layer_input = np.dot(hidden_layer_output, weights2) 
    # применяем функцию активации к выходному слою (здесь тоже 
сигмоида) 
    predicted_output = sigmoid(output_layer_input) 
     
    return hidden_layer_output, predicted_output

Зачем это нужно? Без функции активации, какие бы сложные вычисления мы ни делали, наша нейросеть была бы просто одним большим линейным уравнением. Если упростить, сигмоида ̶ это привратник. Она берёт любое число (‑ inf, + inf) и сжимает его в диапазон от 0 до 1. Она добавляет нелинейность — это и есть тот самый волшебный ингредиент, позволяющий нейросети обучаться сложным вещам.

Мы прошли от входа до выхода, и получили какой‑то ответ. Можете скопировать данные куски кода и запустить его на своём ПК. Скорее всего, в первый раз ответ будет совершенно неверный, т. к. веса изначально случайные. Но мы получили предсказание, которое можем сравнить с правдой.

Шаг 4: Обратное распространение ошибки (Backpropagation)

Это самый важный шаг. Если объяснять простым языком, то тут мы отвечаем на вопрос «Насколько каждый вес в нейросети виноват в общей ошибке?». После того как нейросеть подумала и выдала ответ, мы смотрим насколько она ошиблась, и идём обратно по всем слоям, чтобы аккуратно подкрутить веса так, чтобы в следующий раз ответ был ближе к истине.

Распишем алгоритм:

  1. Считаем ошибку

    Сравниваем предсказание сети predicted_output с правильным ответом y

  2. «Идем назад»:

Распределяем эту ошибку обратно по сети, от выходного слоя к входному, как вы наверное уже догадались, отсюда и взялось название данного процесса.

3. Обновляем веса

Корректируем веса в направлении, которое уменьшает ошибку. Для этого мы используем градиентный спуск и производную функции активации.

def backward_pass(X, y, hidden_layer_output, predicted_output, weights1, 
weights2, learning_rate): 

#вычисляем ошибку на выходном слое 
output_error = y - predicted_output 

#вычисляем дельту для выходного слоя (ошибка * производную 
функции активации) 
output_delta = output_error * sigmoid_derivative(predicted_output) 

# вычисляем ошибку на скрытом слое 
hidden_layer_error = output_delta.dot(weights2.T) 

# вычисляем разницу или дельту для скрытого слоя 
hidden_layer_delta = hidden_layer_error * 
sigmoid_derivative(hidden_layer_output) 

#обновляем веса: добавляем долю от произведения входного 
сигнала и дельты 
weights2 += hidden_layer_output.T.dot(output_delta) * learning_rate 
weights1 += X.T.dot(hidden_layer_delta) * learning_rate 

return weights1, weights2 

Сейчас я распишу подробнее, что происходит в коде. Сначала мы считаем ошибку, т. е. вычитаем из правильного ответа y ответ который выдала нейросеть predicted_output . Записываем получившийся ответ в ouput_error.

Далее мы вычисляем разницу для выходного слоя. Мы не можем просто взять и прибавить ошибку к весам. Нам требуется понять, насколько каждый вес повлиял на ошибку. Для этого, мы используем производную функции активации. На всякий случай, сделаем небольшое математическое отступление на тему производной. Производная показывает скорость роста функции или же скорость изменения функции. Таким образом, если наклон ф‑ии производной крутой, маленькое изменение в сумме, вызовет большое изменение на выходе. Если наклон пологий — вес почти не влиял на результат, менять его нужно совсем немножко. То есть Разница_выхода = Ошибка * Чувствительность_выхода (output_error * sigmoid_derivative(predicted_output)).

Теперь мы знаем ошибку на выходе. Чтобы исправить веса между входом и скрытым слоем, нужно понять, какая ошибка была в скрытом слое, смотрим какие нейроны скрытого слоя больше всего повлияли на тот выход, где была допущена ошибка. Далее, точно так же как и для выхода, умножаем ошибку скрытого слоя на производную его активации, чтобы получить чувствительность. Дельта_скрытого = Ошибка_скрытого_слоя * Чувствительность_скрытого_выхода.

Теперь мы можем обновлять веса. Мы берем исходный сигнал, который шёл на этот вес и умножаем его на разницу (дельту), которую только что посчитали. Новый_Вес = Старый_Вес + (Исходный_Сигнал Дельта * Learning_Rate).

Learning Rate — очень важный параметр. Он определяет, насколько сильно, мы корректируем веса. Слишком большой lr — нейросеть будет перепрыгивать правильное решение. Ну а если слишком маленький, то обучение будет слишком долгим.

Шаг 5: Цикл обучения и запуск нейросети

Теперь соберем все функции вместе и запустим цикл обучения на множество итераций (эпох).

# гиперпараметры (настраиваются экспериментально) 
learning_rate = 0.1 
epochs = 10000 
 
# инициализируем веса 
weights1, weights2 = initialize_weights(input_size, hidden_size, 
output_size) 
 
#цикл обучения 
for i in range(epochs): 
    #прямой проход 
    hidden_layer_output, predicted_output = forward_pass(X, weights1, 
weights2) 
     
    #обратный проход и обновление весов 
    weights1, weights2 = backward_pass(X, y, hidden_layer_output, 
predicted_output, weights1, weights2, learning_rate)

#периодический вывод ошибки для отслеживания процесса 
    if i % 1000 == 0: 
        error = np.mean(np.abs(y - predicted_output)) 
        print(f"Эпоха {i}, Ошибка: {error:.6f}") 
 
# финальное предсказание после обучения 
print("\nРезультат после обучения:") 
hidden_layer_output, predicted_output = forward_pass(X, weights1, 
weights2) 
print("Округленные предсказания:") 
print(np.round(predicted_output))

Итог

После тысяч итераций наша простая нейросеть научилась успешно предсказывать XOR. Вывод в консоли будет выглядеть примерно так:

>>> Эпоха 0, Ошибка: 0.495235 
    Эпоха 1000, Ошибка: 0.125678 
    Эпоха 2000, Ошибка: 0.056321 
    ... 
    Эпоха 9000, Ошибка: 0.002451 
 
    Результат после обучения: 
    Округленные предсказания: 
    [[0.] 
     [1.] 
     [1.] 
     [0.]] 

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

Работу нейросети вы можете посмотреть на ютубе по ссылке.

Кстати, для генерации таких иллюстраций идеально подойдет Bothub.

Новым пользователям дарят 100 000 бесплатных капсов (внутренней валюты для генераций) при регистрации по этой ссылке. Этого хватит надолго даже для активного использования. Просто переходите по ссылке и используйте её возможности для ваших проектов.

Комментарии (0)


  1. Wallor
    19.09.2025 04:24

    А при распознавании картинок алгоритмы надо полагать усложняются кратно? Я сомневаюсь, что там идет анализ картинки попиксельно, но можно как-то общими словами объяснить принцип? Вот есть 100000 картинок котов и собак. Как строиться код?


    1. freeExec
      19.09.2025 04:24

      Идет анализ не только попиксельно, но и "послойно". На входе будет 3 слоя-канала - красный, зелёный, синий. А дальше с помощью операции свертка (которая выделяет паттерны и обычно уменьшает размер) количество слоев-фич увеличивается.

      В итоге в конце у нас из картинки 128 на 128 и 3 цвета получаются 256 фич размером 2 на 2. Фичу можно воспринимать как некий анализатор, например фича два покажет были ли глаза на картинке. А фича 5 скажет, что неба не было. Но на самом деле такого понятного нам разделения там нет.

      Затем это разворачивают в плоскую модель где у нас получается 1024 (25622) нейрона. Затем обрабатывают их как в классической сети.


      1. Wallor
        19.09.2025 04:24

        То есть еще туда модуль распознавания изображений добавляется?


        1. freeExec
          19.09.2025 04:24

          Нет, не добавляется он сам им будет.


      1. strvv
        19.09.2025 04:24

        я бы сказал что не 3 слоя rgb... а hsv - т.е. градации серого (яркость), цвет и насыщенность.
        так будет ближе к реальности.


        1. freeExec
          19.09.2025 04:24

          Надо тогда уточнить в какой реальности. Взять хотя бы те же yolo, там на входе rgb


          1. strvv
            19.09.2025 04:24

            Возможно, но если уйти на яркость, насыщенность, цвет, то тогда более правильные результаты будут именно если не по фону, а по образам.
            А YOLO, возможно просто "симулянт", оценка не по образу, а по вторичным спектральным признакам, как ты указал - трава или диваны.


    1. ZvoogHub
      19.09.2025 04:24

      ну как бы ребёнок объяснял, кот на картинке или собака?

      Про форму ушей, цвет и пр.

      У перцептрона (вики https://ru.wikipedia.org/wiki/Перцептрон) всё сводится к поиску зависимостей массива значений цвета и ответа "собака/кошка".

      Зависимости могут быть очень простыми - например если в массиве много значений зелёного цвета, значит вероятность что это собака больше.

      Почему? Можно открыть поиск по картинкам в гугле. Собак часто фотают на улице на фоне травы и кустов. Котов чаще дома на диване.

      Ничего сложного тут нет, просто статистика. Никакого анализа в человеческом понимании тут нет. Но при огромных объёмах входящих фото по которым расчитаны тысячи таких простых зависимостей результат получается хороший.


  1. Sapsan_Sapsanov
    19.09.2025 04:24

    А чтоб идентифицировать котиков можно?


  1. Robgnokfar
    19.09.2025 04:24

    Спасибо за простой пример с XOR-ом

    1. Я бы пошёл ещё дальше и не использовал бы библиотеки numpy, чтобы даже школьник понял, как образуются веса для связей.

    2. Кажется, вот тут опечатка:

    • weights1: для связи между входным и скрытым слоем (размер 2х4)

    • weights2: для связи между скрытым и входным (?) слоем (размером 1х4)

    3. если код будет корректно отформатирован, то его будет легче воспроизвести без лишних правок. Я имею в виду вот это:

    def initialize_weights(input_size, hidden_size, output_size): 
    np.random.seed(42)  

    4. На картинке у вас сначала A и B как вход для XOR, потом вы говорите, что вход для XOR это X и Y, а затем в коде программы обозначаете X - входом нейросети, а Y - выходом. Новичку очень легко запутаться!

    Очень жду правок статьи, и потом буду рекомендовать её моим знакомым школьникам)


    1. taratorin Автор
      19.09.2025 04:24

      Спасибо большое, статья исправлена, бывают опечатки :)


  1. kaini
    19.09.2025 04:24

    вот здесь:

    weights2: для связи между скрытым и входным слоем (размером 1×4)
    

    случайно не опечатка?


    1. kaini
      19.09.2025 04:24

      не должно быть: между скрытым и выходным слоем?


  1. riv2
    19.09.2025 04:24

    Спасибо (͡°͜ʖ͡°)