С возвращением! В Части 1 мы увидели, как нейронная сеть может складывать два числа, используя базовую структуру прямого распространения. В конце статьи мы даже показали вам полную реализацию на C++, которая включала прямой проход, цикл обучения, расчет потерь и обновление весов — все в коде.

Это естественно приводит к следующему вопросу: как именно нейронная сеть учится корректировать эти веса самостоятельно в процессе обучения? Именно это мы и будем исследовать здесь, в Части 2.

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

#include <iostream>
#include <cmath>
using namespace std;

// Функция активации сигмоида
float sigmoid(float x) {
    return 1.0f / (1.0f + exp(-x));
}

// Производная сигмоида (используется в обратном распространении)
float sigmoid_derivative(float y) {
    return y * (1 - y); // где y = sigmoid(x)
}

int main() {
    // Инициализация весов и смещений для 3 скрытых узлов
    // Эти значения выбраны вручную, чтобы более явно показать процесс обучения.
    // Использование как положительных, так и отрицательных весов помогает предотвратить симметрию и предоставляет разнообразную начальную точку.
    float weight1_node1 = 0.5f, weight2_node1 = 0.5f, bias_node1 = 0.0f;      // Узел 1 начинает с положительного влияния от обоих входов
    float weight1_node2 = -0.5f, weight2_node2 = -0.5f, bias_node2 = 0.0f;    // Узел 2 начинает с отрицательного влияния, способствуя балансу
    float weight1_node3 = 0.25f, weight2_node3 = -0.25f, bias_node3 = 0.0f;   // Узел 3 смешанный, полезный для захвата взаимодействий

    // Инициализация весов и смещения выходного слоя
    float weight_out_node1 = 0.5f, weight_out_node2 = 0.5f, weight_out_node3 = 0.5f, bias_out = 0.0f;

    float learning_rate = 0.01f;
    int epochs = 1000; // Количество раз, когда мы проходим по обучающим данным

    // Цикл обучения
    for (int epoch = 0; epoch < epochs; ++epoch) {
        float total_loss = 0.0f;
        for (int a = 0; a < 100; ++a) {
            for (int b = 0; b < 100; ++b) {
                float x1 = a / 100.0f;
                float x2 = b / 100.0f;
                float target = (a + b) / 200.0f; // Нормализуем целевую сумму до [0, 1]

                // ===== Прямой проход =====
                float z1 = x1 * weight1_node1 + x2 * weight2_node1 + bias_node1;
                float h1 = sigmoid(z1);

                float z2 = x1 * weight1_node2 + x2 * weight2_node2 + bias_node2;
                float h2 = sigmoid(z2);

                float z3 = x1 * weight1_node3 + x2 * weight2_node3 + bias_node3;
                float h3 = sigmoid(z3);

                float y_pred = h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out;

                float error = target - y_pred;
                total_loss += error * error; // Квадрат ошибки (потери)

                // ===== Обратное распространение - Выходной слой =====
                float delta_out = error; // Производная потерь по отношению к y_pred
                float grad_out_w1 = delta_out * h1; // Градиент для выходного веса 1
                float grad_out_w2 = delta_out * h2; // Градиент для выходного веса 2
                float grad_out_w3 = delta_out * h3; // Градиент для выходного веса 3
                float grad_out_b = delta_out;       // Градиент для выходного смещения

                // ===== Обратное распространение - Скрытый слой =====
                // Рассчитываем, как каждый скрытый узел способствовал ошибке
                float delta_h1 = delta_out * weight_out_node1 * sigmoid_derivative(h1);
                float delta_h2 = delta_out * weight_out_node2 * sigmoid_derivative(h2);
                float delta_h3 = delta_out * weight_out_node3 * sigmoid_derivative(h3);

                float grad_w1_node1 = delta_h1 * x1; // Градиент для w1 узла 1
                float grad_w2_node1 = delta_h1 * x2; // Градиент для w2 узла 1
                float grad_b_node1 = delta_h1;       // Градиент для смещения узла 1

                float grad_w1_node2 = delta_h2 * x1;
                float grad_w2_node2 = delta_h2 * x2;
                float grad_b_node2 = delta_h2;

                float grad_w1_node3 = delta_h3 * x1;
                float grad_w2_node3 = delta_h3 * x2;
                float grad_b_node3 = delta_h3;

                // ===== Обновление весов выходного слоя =====
                weight_out_node1 += learning_rate * grad_out_w1;
                weight_out_node2 += learning_rate * grad_out_w2;
                weight_out_node3 += learning_rate * grad_out_w3;
                bias_out += learning_rate * grad_out_b;

                // ===== Обновление весов скрытого слоя =====
                weight1_node1 += learning_rate * grad_w1_node1;
                weight2_node1 += learning_rate * grad_w2_node1;
                bias_node1 += learning_rate * grad_b_node1;

                weight1_node2 += learning_rate * grad_w1_node2;
                weight2_node2 += learning_rate * grad_w2_node2;
                bias_node2 += learning_rate * grad_b_node2;

                weight1_node3 += learning_rate * grad_w1_node3;
                weight2_node3 += learning_rate * grad_w2_node3;
                bias_node3 += learning_rate * grad_b_node3;
            }
        }
        // Логируем потери каждые 100 эпох
        if ((epoch + 1) % 100 == 0 || epoch == 0)
            cout << "[Итог] Эпоха " << epoch + 1 << ": Потери = " << total_loss << endl;
    }

    // ===== Тестируем модель с пользовательским вводом =====
    float a, b;
    cout << "
Тестирование предсказания суммы (a + b)
Введите a: ";
    cin >> a;
    cout << "Введите b: ";
    cin >> b;

    float x1 = a / 100.0f;
    float x2 = b / 100.0f;

    // Прямой проход снова с обученными весами
    float h1 = sigmoid(x1 * weight1_node1 + x2 * weight2_node1 + bias_node1);
    float h2 = sigmoid(x1 * weight1_node2 + x2 * weight2_node2 + bias_node2);
    float h3 = sigmoid(x1 * weight1_node3 + x2 * weight2_node3 + bias_node3);
    float y_pred = h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out;
    float predicted_sum = y_pred * 200.0f;

    // Выводим результат
    cout << "Предсказанная сумма: " << predicted_sum << "
Фактическая сумма: " << (a + b) << endl;
    return 0;
}

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

  • Что на самом деле делают узлы и слои в нейронной сети.
  • Как мы устанавливаем начальные веса и корректируем их в процессе обучения.
  • Роль скорости обучения и эпох в формировании процесса обучения.
  • Что такое функции активации, как они работают и когда использовать сигмоиду, tanh или ReLU.
  • Как обратное распространение и производные позволяют сети учиться на ошибках.
  • Может ли нейронная сеть выйти за пределы простых задач, таких как сложение, и научиться многому другому.

Готовы? Поехали!

Узлы и слои: Что на самом деле происходит?

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

Слой — это коллекция нейронов, которые работают на одном уровне сети. Информация передается от одного слоя к следующему. Три основных типа слоев это:

  • Входной слой: получает сырые данные (например, числа, которые вы хотите сложить).
  • Скрытые слои: выполняют преобразования входных данных, обучаясь распознавать шаблоны через взвешенные соединения.
  • Выходной слой: производит окончательное предсказание или результат.

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

Представьте себе каждый узел (нейрон) в нейронной сети как маленького принимающего решения. Он принимает некоторые входные данные, умножает их на свои "веса", добавляет "смещение", а затем применяет функцию активации (например, сигмоиду, tanh или ReLU).

Больше узлов = больше мозгов:

  • Небольшое количество узлов может быстро решить простые задачи.
  • Большее количество узлов означает, что ваша сеть может понимать более сложные шаблоны, но слишком много может замедлить ее или привести к переобучению!

Что если мы сложим слои? Вы можете совершенно спокойно сложить несколько скрытых слоев — это "глубокая нейронная сеть". Каждый слой учится чему-то немного более абстрактному:

  • Первый слой учится простым шаблонам.
  • Второй слой объединяет эти шаблоны в более сложные идеи.
  • Третий и последующие слои строят все более абстрактное понимание.

Веса и смещения в нейронных сетях

Что такое вес в нейронной сети?

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

Что такое смещение в нейронной сети?

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

Как мы устанавливаем начальные веса?

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

float weight1_node1 = 0.5f, weight2_node1 = 0.5f, bias_node1 = 0.0f;

В более сложных системах веса обычно инициализируются случайным образом, используя небольшие значения (например, от -0.5 до 0.5). Эта случайность помогает предотвратить симметрию — когда все нейроны учатся одному и тому же — и дает сети больше шансов на обнаружение полезных шаблонов. Распространенные стратегии инициализации включают Xavier (Глорота) и He инициализацию, которые предназначены для поддержания стабильного сигнала по мере его прохождения через сеть. Для простых экспериментов, таких как наш, ручные или небольшие случайные значения работают достаточно хорошо.

Как мы корректируем веса?

В процессе обучения корректировка происходит следующим образом:

  1. Сеть делает предсказание, используя текущие веса.
  2. Она сравнивает предсказание с правильным ответом (целевым выходом).
  3. Она вычисляет, насколько она ошиблась — это ошибка или "потери".
  4. Она использует обратное распространение, чтобы вычислить, как каждый вес повлиял на ошибку.
  5. Каждый вес немного корректируется, чтобы уменьшить ошибку в следующий раз — этот шаг выполняется с использованием градиентов (производных) и скорости обучения.

В нашем примере кода на C++ этот процесс происходит внутри вложенных циклов обучения. Вот краткая справка:

float error = target - y_pred;
float delta_out = error;
float grad_out_w1 = delta_out * h1;
float grad_out_w2 = delta_out * h2;
float grad_out_w3 = delta_out * h3;
float grad_out_b = delta_out;

float delta_h1 = delta_out * weight_out_node1 * sigmoid_derivative(h1);
float delta_h2 = delta_out * weight_out_node2 * sigmoid_derivative(h2);
float delta_h3 = delta_out * weight_out_node3 * sigmoid_derivative(h3);

float grad_w1_node1 = delta_h1 * x1;
float grad_w2_node1 = delta_h1 * x2;
float grad_b_node1 = delta_h1;

// Обновление весов
weight_out_node1 += learning_rate * grad_out_w1;
weight_out_node2 += learning_rate * grad_out_w2;
weight_out_node3 += learning_rate * grad_out_w3;
bias_out += learning_rate * grad_out_b;

weight1_node1 += learning_rate * grad_w1_node1;
weight2_node1 += learning_rate * grad_w2_node1;
bias_node1 += learning_rate * grad_b_node1;

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

Функции активации: Придавая вашим узлам индивидуальность

Функции активации решают, должен ли нейрон "сработать" или нет. Они применяются к результату взвешенной суммы и смещения в каждом узле, вводя нелинейность в сеть — без которой сеть могла бы учить только линейные зависимости (что не очень полезно для сложных задач).

Вот некоторые распространенные функции активации:

1. Сигмоида

  • Формула:   1 / (1 + e^(-x))
  • Диапазон выходных значений: 0 до 1
  • Случай использования: Отлично подходит для бинарной классификации или выходных слоев, где нужны значения между 0 и 1.
  • Плюсы: Плавный, ограниченный выход.
  • Минусы: Насыщается на крайностях (близко к 0 или 1), замедляет обучение из-за исчезающих градиентов.

2. Tanh

  • Формула(e^x - e^(-x)) / (e^x + e^(-x))
  • Диапазон выходных значений: -1 до 1
  • Случай использования: Когда вам нужен центрированный ноль выход.
  • Плюсы: Сильные градиенты по сравнению с сигмоидой; более быстрое обучение в скрытых слоях.
  • Минусы: Все еще страдает от исчезающих градиентов на крайностях.

3. ReLU (Rectified Linear Unit)

  • Формула:   max(0, x)
  • Диапазон выходных значений: 0 до бесконечности.
  • Случай использования: По умолчанию для большинства скрытых слоев в глубоком обучении.
  • Плюсы: Быстрое вычисление, помогает с разреженной активацией.
  • Минусы: Может "умереть", если входные данные всегда отрицательные ("проблема умирающего ReLU").

Когда использовать какую функцию активации?

АктивацияДиапазон выходных значенийХорошо дляОбщий тип слояПримечания
Сигмоида0 до 1Бинарный выходВыходной слойМожет вызвать исчезающий градиент
Tanh-1 до 1Центрированные ноль скрытые слоиСкрытый слойСильнее градиенты, чем у сигмоиды
ReLU0 до ∞Большинство глубоких сетей и CNNСкрытый слойБыстро, эффективно, но может "умереть" на отрицательных

В нашем примере кода мы использовали сигмоиду для скрытых слоев. Это работает для небольших демонстраций, но в более крупных сетях ReLU часто предпочтительнее для лучшей производительности и более быстрого обучения.

Производные и обратное распространение: Как сети учатся на ошибках

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

Вот что делают нейронные сети, используя производные — они проверяют, какие небольшие корректировки делают предсказания более точными.

После того как сделано предсказание, сеть вычисляет, насколько она ошиблась (мы называем это "потери"). Затем она использует то, что называется обратным распространением для отслеживания этой ошибки назад через сеть, выясняя, как каждый вес способствовал ошибке.

Вот упрощенный цикл обратного распространения:

  1. Прямой проход: Угадайте ответ.
  2. Вычислить потери: Насколько ошибочным было это предположение?
  3. Обратный проход: Узнайте, как каждый вес повлиял на эту ошибку.
  4. Обновить веса: Немного скорректируйте веса, чтобы уменьшить будущие ошибки.

Этот процесс повторяется тысячи раз, постепенно делая сеть все более умной.

Давайте посмотрим на упрощенную реализацию на C++ этой идеи:

float sigmoid(float x) {
    return 1.0f / (1.0f + exp(-x));
}

float sigmoid_derivative(float y) {
    return y * (1 - y); // y = sigmoid(x)
}

Это наша функция активации и ее производная. Они используются как в прямом, так и в обратном проходах.

Цикл обучения использует два входа (a и b), нормализует их между 0 и 1 и обучает сеть предсказывать их сумму. Мы используем 3 скрытых узла и 1 выходной узел. Вот ключевая часть цикла:

float error = target - y_pred;
total_loss += error * error;

float delta_out = error;
...
float delta_h1 = delta_out * weight_out_node1 * sigmoid_derivative(h1);

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

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

Скорость обучения и эпохи

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

Что такое скорость обучения?

Скорость обучения определяет, насколько велик каждый шаг при обновлении весов. Низкая скорость обучения (например, 0.01) означает, что сеть делает небольшие корректировки каждый раз, что безопаснее, но медленнее. Высокая скорость обучения может ускорить процесс, но если она слишком велика, сеть может перепрыгнуть и не научиться должным образом.

В нашем коде мы используем:

float learning_rate = 0.01f;

Это дает нам баланс между скоростью и стабильностью.

Что такое эпоха?

Эпоха — это один полный проход через весь обучающий набор данных. В нашем примере для каждой эпохи мы обучаем сеть, используя каждую комбинацию a и b от 0 до 99. Затем мы делаем это снова для следующей эпохи.

Мы обучаемся на протяжении нескольких эпох, чтобы позволить сети снова и снова уточнять свои веса.

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

В коде мы используем:

int epochs = 1000;

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

Может ли нейронная сеть научиться многим вещам?

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

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

  • Вычитать числа
  • Классифицировать изображения
  • Предсказывать будущие значения во временном ряде

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

Это улучшенная версия нашей нейронной сети, где мы обучаем ее как складывать, так и вычитать входные числа, используя одну общую сеть:


#include <iostream>
#include <cmath>
using namespace std;

// Функция активации сигмоида (сжимает вход в диапазон [0, 1])
float sigmoid(float x) {
    return 1.0f / (1.0f + exp(-x));
}

// Производная сигмоида (используется для вычисления градиентов во время обратного распространения)
float sigmoid_derivative(float y) {
    return y * (1 - y); // y = sigmoid(x)
}

int main() {
    // === Веса и смещения скрытого слоя (3 скрытых нейрона, по 2 входа каждый) ===
    float w1_n1 = 0.5f, w2_n1 = 0.5f, b1 = 0.0f;
    float w1_n2 = -0.5f, w2_n2 = -0.5f, b2 = 0.0f;
    float w1_n3 = 0.25f, w2_n3 = -0.25f, b3 = 0.0f;

    // === Веса и смещения выходного слоя (2 выходных нейрона: сумма и вычитание) ===
    float out_w1_sum = 0.5f, out_w2_sum = 0.5f, out_w3_sum = 0.5f, out_b_sum = 0.0f;
    float out_w1_sub = -0.5f, out_w2_sub = -0.5f, out_w3_sub = 0.5f, out_b_sub = 0.0f;

    float learning_rate = 0.01f;
    int epochs = 1000;

    // === Цикл обучения ===
    for (int epoch = 0; epoch < epochs; ++epoch) {
        float total_loss = 0.0f;

        for (int a = 0; a < 100; ++a) {
            for (int b = 0; b < 100; ++b) {
                // === Нормализуем входные значения ===
                float x1 = a / 100.0f;
                float x2 = b / 100.0f;

                // === Нормализуем цели ===
                float target_sum = (a + b) / 200.0f;          // Сумма варьируется от 0 до 198 → [0, ~1]
                float target_sub = (a - b + 100.0f) / 200.0f; // Вычитание варьируется от -99 до +99 → [0, ~1]

                // === Прямой проход (Вход → Скрытый слой) ===
                float z1 = x1 * w1_n1 + x2 * w2_n1 + b1;
                float h1 = sigmoid(z1);

                float z2 = x1 * w1_n2 + x2 * w2_n2 + b2;
                float h2 = sigmoid(z2);

                float z3 = x1 * w1_n3 + x2 * w2_n3 + b3;
                float h3 = sigmoid(z3);

                // === Прямой проход (Скрытый → Выходной слой) ===
                float y_pred_sum = h1 * out_w1_sum + h2 * out_w2_sum + h3 * out_w3_sum + out_b_sum;
                float y_pred_sub = h1 * out_w1_sub + h2 * out_w2_sub + h3 * out_w3_sub + out_b_sub;

                // === Вычисляем потери (Квадрат ошибки) ===
                float error_sum = target_sum - y_pred_sum;
                float error_sub = target_sub - y_pred_sub;
                total_loss += error_sum * error_sum + error_sub * error_sub;

                // === Обратное распространение - Выходной слой (Градиенты для каждого выходного узла) ===
                float delta_out_sum = error_sum;
                float grad_out_w1_sum = delta_out_sum * h1;
                float grad_out_w2_sum = delta_out_sum * h2;
                float grad_out_w3_sum = delta_out_sum * h3;
                float grad_out_b_sum = delta_out_sum;

                float delta_out_sub = error_sub;
                float grad_out_w1_sub = delta_out_sub * h1;
                float grad_out_w2_sub = delta_out_sub * h2;
                float grad_out_w3_sub = delta_out_sub * h3;
                float grad_out_b_sub = delta_out_sub;

                // === Обратное распространение - Скрытый слой ===
                float delta_h1 = (delta_out_sum * out_w1_sum + delta_out_sub * out_w1_sub) * sigmoid_derivative(h1);
                float delta_h2 = (delta_out_sum * out_w2_sum + delta_out_sub * out_w2_sub) * sigmoid_derivative(h2);
                float delta_h3 = (delta_out_sum * out_w3_sum + delta_out_sub * out_w3_sub) * sigmoid_derivative(h3);

                float grad_w1_n1 = delta_h1 * x1;
                float grad_w2_n1 = delta_h1 * x2;
                float grad_b1 = delta_h1;

                float grad_w1_n2 = delta_h2 * x1;
                float grad_w2_n2 = delta_h2 * x2;
                float grad_b2 = delta_h2;

                float grad_w1_n3 = delta_h3 * x1;
                float grad_w2_n3 = delta_h3 * x2;
                float grad_b3 = delta_h3;

                // === Обновление весов выходного слоя ===
                out_w1_sum += learning_rate * grad_out_w1_sum;
                out_w2_sum += learning_rate * grad_out_w2_sum;
                out_w3_sum += learning_rate * grad_out_w3_sum;
                out_b_sum += learning_rate * grad_out_b_sum;

                out_w1_sub += learning_rate * grad_out_w1_sub;
                out_w2_sub += learning_rate * grad_out_w2_sub;
                out_w3_sub += learning_rate * grad_out_w3_sub;
                out_b_sub += learning_rate * grad_out_b_sub;

                // === Обновление весов скрытого слоя ===
                w1_n1 += learning_rate * grad_w1_n1;
                w2_n1 += learning_rate * grad_w2_n1;
                b1 += learning_rate * grad_b1;

                w1_n2 += learning_rate * grad_w1_n2;
                w2_n2 += learning_rate * grad_w2_n2;
                b2 += learning_rate * grad_b2;

                w1_n3 += learning_rate * grad_w1_n3;
                w2_n3 += learning_rate * grad_w2_n3;
                b3 += learning_rate * grad_b3;
            }
        }

        // === Печатаем потери каждые 100 эпох ===
        if ((epoch + 1) % 100 == 0 || epoch == 0)
            cout << "[Эпоха " << epoch + 1 << "] Потери: " << total_loss << endl;
    }

    // === Тестовая фаза ===
    float a, b;
    cout << "\nТестирование предсказания\nВведите a: ";
    cin >> a;
    cout << "Введите b: ";
    cin >> b;

    float x1 = a / 100.0f;
    float x2 = b / 100.0f;

    // Прямой проход снова для пользовательского ввода
    float h1 = sigmoid(x1 * w1_n1 + x2 * w2_n1 + b1);
    float h2 = sigmoid(x1 * w1_n2 + x2 * w2_n2 + b2);
    float h3 = sigmoid(x1 * w1_n3 + x2 * w2_n3 + b3);

    float y_sum = h1 * out_w1_sum + h2 * out_w2_sum + h3 * out_w3_sum + out_b_sum;
    float y_sub = h1 * out_w1_sub + h2 * out_w2_sub + h3 * out_w3_sub + out_b_sub;

    // Денормализуем выходные данные
    float predicted_sum = y_sum * 200.0f;
    float predicted_sub = y_sub * 200.0f - 100.0f;

    cout << "Предсказанная сумма:        " << predicted_sum << endl;
    cout << "Фактическая сумма:           " << (a + b) << endl;
    cout << "Предсказанная разность: " << predicted_sub << endl;
    cout << "Фактическая разность:    " << (a - b) << endl;

    return 0;
}

Подводя итоги

Это было много информации, но довольно здорово, не так ли? Мы не просто говорили о теории; мы наблюдали, как нейронная сеть учится делать что-то реальное: складывать и вычитать числа. Никаких жестко закодированных правил — только веса, которые корректируют себя в процессе обучения. По пути мы узнали о скоростях обучения, функциях активации и магии обратного распространения, которая связывает все это вместе.

В следующей части мы сделаем шаг назад от кода и погрузимся в историю всего этого. Откуда взялись нейронные сети? Почему ИИ взорвался всего за последние несколько лет? Что изменилось? Мы исследуем, как все началось — и почему это только начинается.