В мире 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: для связи между скрытым и входным слоем (размером 1х4)

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

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. Мы разобрали самый базовый и простейший пример из всех возможных. В реальном мире нейронных сетей всё гораздо сложнее. Но этот фундамент критически важен для понимания.

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

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