Предисловие

Всем привет!

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

Со всеми корректными замечаниями по поводу кода жду в комментариях.

Решаемая задача

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

Примеры корректных входных данных
Примеры корректных входных данных

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

Пример входных данных с шумом
Пример входных данных с шумом

Теоретическая составляющая

Для начала определимся с основными условными обозначениями.

y^k_i - выход нейрона i слоя k
n(k - 1) - количество нейронов в слое с номером k - 1
t_i - ожидаемое значение выхода i сети
N - номер последнего слоя сети
w^i_{ij} - вес нейрона в соответствующей связи, k - номер слоя, i - номер нейрона, j - номер входа из предыдущего слоя

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

Каждое значение нейрона на выходном слое соответствует вероятности того, что на картинке нарисована соответствующая фигура. Поскольку возможных фигур у нас 3, то выходных нейронов будет тоже 3. На входе же у нас картинки размером 7 на 7, поэтому входов будет 49, соответствующих каждому пикселю изображения соответственно.

Логика работы сети предельно простая - нам надо рассчитать значения всех нейронов.

Считаются значения нейронов по следующей формуле: f(\sum^{n(k-1)}_{j=1}{y^{k-1}_jw^k_{ij}})

Объясняю эту формулу - мы берем все нейроны на предыдущем уровне и умножаем их на соответствующие веса, которые выходят из нейронов на прошлом уровне и доходят до нашего нейрона. Затем мы полученную сумму загоняем в функцию активации, это нужно для того, чтобы значения нейронов не превышали 1. В нашем случае, в качестве функции активации будем использовать сигмоиду:


f(x) = \frac{1}{1 + e^{-x}}

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

Для подстройки весов используем следующую формулу: w^k_{ij} = w^k_{ij} + \alpha*\delta^k_i*y^{k-1}_j

Альфа - коэффициент обучения, чем ближе фактические значения к ожидаемым, тем меньше нужно делать альфа.

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

Для выходного слоя формула: \delta^N_i = y^N_i(1 - y^N_i)(t_i - y^N_i)

Для всех остальных слоев формула основывается на значениях предыдущего слоя:

\delta^k_i = y^k_i(1 - y^k_i)\sum^{n(k+1)}_{j=1}(\delta^{k+1}_jw^{k+1}_{ji})

В общем, тут алгоритм подсчета дельт примерно такой же, как для подсчёта нейронов, только с другими формулами и в обратную сторону.

Практическая составляющая

Наконец-то перейдем к написанию кода!

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

class NeuralNet
{
public:
    void set_input(vector<vector<double>> input); // Передаем в сеть картинку
    void set_expected(vector<double> input); // Передаем в сеть ожидаемые значения выхода
    void train(void); // Запуск итерации обучения
    size_t apply(void); // Запуск подсчёта сети без обучения (для валидации результатов)

private:
    double activation(double x); // Функция активации
    void count_neural_net(void); // Подсчёт значений нейронов
    void clear_neural_net(void); // Обнуление значений нейронов
    void recal_alpha(void); // Обновляем коэф обучения в зависимости от ошибки
    void adj_weight(void); // Подстройка весов
    size_t output_size; // Количество выходных нейронов = 3
    size_t input_size; // Размер стороны входной картинки = 7
    size_t neuron_size; // Количество нейронов в промежуточных слоях
    size_t layer_count; // Количество промежуточных слоёв
    double alpha; // Коэффициент обучения
    vector<double> expected; // Для хранения ожидаемых значений
    vector<vector<double>> layers; // Слои нейронов
    vector<vector<double>> delta; // Значения дельта для соответствующих слоёв
    vector<vector<vector<double>>> weight; // Веса сети
};

Начнем с простого и будем двигаться к более сложному, напишем функции для обучения и валидации:

void NeuralNet::train(void)
{
    clear_neural_net(); // Обнуляем веса
    count_neural_net(); // Счиатем веса
    recal_alpha(); // В зависимости от результатов подстраиваем коэф обучение
    if (err() > MAX_ERR) // Если ошибка достаточно большая, то подстраиваем веса
        adj_weight();
}
size_t NeuralNet::apply(void)
{
    clear_neural_net();
    count_neural_net();
    // То же, что и в обучении, только без подстройки весов 
    // и возвращаем номер наиболее вероятной фигуры
    return distance(layers[layer_count + 1].begin(), 
      max_element(
        layers[layer_count + 1].begin(), 
        layers[layer_count + 1].end()));
}

Разберем пересчёт коэффициента обучения:

void NeuralNet::recal_alpha(void)
{
    double e = err(); // получаем ошибку
    double rel_e = 2 * abs(e) / output_size; // Получаем среднее значение ошибки
    // Подстраиваем коэф обучения под диапазон возможных значений alpha
    alpha = rel_e * (MAX_ALPHA - MIN_ALPHA) + MIN_ALPHA; 
}

Далее посмотрим на подсчёт значений нейронов:

void NeuralNet::count_neural_net(void)
{
    // Перебираем по очереди все слои
    for (size_t layer = 0; layer <= layer_count; layer++)
    {
        // В каждом слое перебираем все нейроны
        for (size_t neuron = 0; neuron < weight[layer].size(); neuron++)
        {
            // Для каждого нейрона перебираем все нейроны предыдущего уровня
            for (size_t input = 0; input < weight[layer][neuron].size(); input++)
            {
                // Сначала считаем сумму произведений нейронов прошлого уровня на их веса
                layers[layer + 1][neuron] += layers[layer][input] * weight[layer][neuron][input];
            }
            // А теперь применяем к сумме функцию активации
            layers[layer + 1][neuron] = activation(layers[layer + 1][neuron]);
        }
    }
}

Ну и самое сложное - подстройка весов:

void NeuralNet::adj_weight(void)
{
    // Сначала рассчитываем все дельты для выходного слоя, чтобы было от чего отталкиваться
    for (size_t exp = 0; exp < output_size; exp++)
    {
        double t = expected[exp], y = layers[layer_count + 1][exp];
        delta[layer_count][exp] = y * (1 - y) * (t - y);
    }
    // Теперь перебираем остальные слои (кроме входного) и считаем дельту для них
    for (int layer = layer_count - 1; layer >= 0; layer--)
    {
        // Перебираем все нейроны в слое
        for (size_t input = 0; input < layers[layer + 1].size(); input++)
        {
            double next_sum = 0;
            // Для каждого нейрона перебираем все дельты на следующем уровне
            for (size_t next_neuron = 0; next_neuron < layers[layer + 2].size(); next_neuron++)
            {
                // Суммируем все взвешенные значения дельт на следующем уровне
                next_sum += delta[layer + 1][next_neuron] * weight[layer + 1][next_neuron][input];
            }
            // Домножаем на коэффициент y * (1 - y)
            delta[layer][input] = layers[layer + 1][input] * (1 - layers[layer + 1][input]) * next_sum;
        }
    }
    // Наконец можно подсчитать все новые веса
    for (size_t layer = 0; layer < layer_count + 1; layer++)
    {
        for (size_t output = 0; output < weight[layer].size(); output++)
        {
            for (size_t input = 0; input < weight[layer][output].size(); input++)
            {
                // Домножаем дельты на коэф обучения и значение самого нейрона
                weight[layer][output][input] += alpha * delta[layer][output] * layers[layer][input];
            }
        }
    }
}

Заключение

Не так страшна задача, когда декомпозируешь её на более мелкие задачи.

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

Далее приведу ссылку на исходный код, в котором количество нейронов в сети, количество слоёв и количество эпох являются параметризуемыми величинами, для того, чтобы вы смогли подобрать наиболее эффективные их значения. У меня получилось так, что наиболее эффективным оказалась сеть всего с одним скрытым слоем из 14 нейронов.

Если кому-то помог разобраться в теме, лайки и подписки приветствуются!

Исходный код

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


  1. codecity
    25.09.2024 17:39

    Смотрели ли С++ -либу ATen - математическую основу PyTorch?


    1. Mankeyy Автор
      25.09.2024 17:39
      +1

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


  1. killyself
    25.09.2024 17:39

    Спасибо за статью. Хотелось бы итоговую статистику - процент правильных ответов, возможно скорость работы


    1. Mankeyy Автор
      25.09.2024 17:39

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

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

      Время обучения на 50 эпохах 0.1 секунда
      Время валидации 0.01 секунда

      Ошибка у меня возникла только в случае, когда картинка валидации вида: круг с двумя зашумленными углами (или он же квадрат с двумя отсутствующими углами), все остальные картинки корректно определяются, то есть процент ошибок где-то 0-2%


  1. codecity
    25.09.2024 17:39

    Смотрели ли С++ -либу ATen - математическую основу PyTorch?


  1. Gay_Lussak
    25.09.2024 17:39
    +24

    За нейронку - 5, за С++ - 2.
    1. Вы передаете вектора в функцию не по ссылке.
    2. void аргументах это уже не нужно даже в современном С, не говоря уже о С++.
    3. for (size_t layer = 0; layer <= layer_count; layer++) - здесь UB.
    4. Часть циклов можно переписать на алгоритмы типа:
    std::transform(expected.begin(), expected.end(),
    layers[layer_count + 1].begin(), delta[layer_count].begin(),
    [](double t, double y) {return y * (1 - y) * (t - y);});

    Я к тому, что С++ - в данном случае неудачный вариант для изучения нейросети. Потому что здесь нельзя так расслаблено писать. Если бы на питоне дали пример, вопросов бы не было.

    P.S. Открыл исходники. Вы в целом недавно начали изучать плюсы.
    using namespace std да еще и в хедере - это расстрельная статья.

    Вы бросаете исключения, а где вы их перехватываете? Или у вас план вызывать std::terminate при каждом исключении? Тогда вопросов нет. И там где надо не бросаете, например при делении.

    У вас магические числа при том что есть дефайны. Кстати о них. Дефайны - это С, но ни как не С++. Для констант лучше constexpr и enum class.

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


    1. maxcat
      25.09.2024 17:39
      +1

      Вот этих ваших std::transform(expected.begin(), expected.end() нету в других сиподобных языках - так что с ними было бы непонятнее. И этот ваш питон непонятен знающим сиподобные языки.

      Вообще в идеале надо было писать на c# или java, но в нейтральном стиле (например, не используя linq) чтобы всем было понятно. Там и в обоих языках много схожести, и плюсовики поймут (так как эти языки просто обезжиренный cpp), и не надо особо заморачиваться с хедерами и ссылками на вектора, и нету наследия от си чтобы спорить о его уместности


      1. Gay_Lussak
        25.09.2024 17:39
        +17

        Я дал советы человеку, чтобы он лучше знал плюсы, а не чтобы читателю было легче. Тем более всегда можно сделать пояснения в непонятных моментах. Ну и понять что transform - это аналог map или zip в любом другом языке не сложно.


        ...не надо особо заморачиваться с хедерами и ссылками на вектора...

        В плюсах либо сразу писать нормально либо никак.


        1. akryukov
          25.09.2024 17:39
          +6

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


          1. Gay_Lussak
            25.09.2024 17:39
            +9

            Не знал, что код-ревью называется самоутверждением. Если человек выкладывает статью, следует ожидать критику.


          1. voldemar_d
            25.09.2024 17:39
            +1

            Можете пояснить, какое из замечаний относится к самоутверждению?


        1. Nansch
          25.09.2024 17:39
          +1

          С таким подходом вы ничего так и не напишете.


          1. voldemar_d
            25.09.2024 17:39
            +1

            Вектор передавать по значению - это сильно. Если в нём миллиард значений будет - ничего, и так сойдёт?


            1. perfect_genius
              25.09.2024 17:39

              Если нет приоритета производительности, то сойдёт.


              1. voldemar_d
                25.09.2024 17:39
                +1

                Ну и если так рассуждать, что угодно сойдёт. Зачем тогда вообще C++?


                1. perfect_genius
                  25.09.2024 17:39

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


                  1. voldemar_d
                    25.09.2024 17:39
                    +1

                    В плюсах либо сразу писать нормально либо никак.

                    Я всё-таки поддержу утверждение в цитате. А то люди пишут целые книги про оптимизацию программ и с рекомендациями про то, как следует писать код - не на ровном месте же это делается.


      1. perhana
        25.09.2024 17:39

        Почему вы считаете, что Python непонятен знающим C-подобные языки? Мне, например, вполне понятен.

        Я согласен со всеми замечаниями ув. @Gay_Lussak. Раз взялся за C++, то нужно писать именно на нём, а не на пресловутом "C с классами". Благо, современный C++ предоставляет много полезных инструментов. Помимо упомянутых алгоритмов есть также ranges, позволяющие писать ещё более компактный и выразительный код.


    1. Mankeyy Автор
      25.09.2024 17:39
      +3

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

      Можете пояснить в чем заключается UB в строке с циклами?

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


      1. tunez
        25.09.2024 17:39

        Если правая граница цикла максимальное значение.


      1. yappari
        25.09.2024 17:39
        +3

        Можете пояснить в чем заключается UB в строке с циклами?

        Нет там УБ, просто товарищ скор на расправу. Ваш грех в том, что ограничивающая переменная не очень корректно названа и реальное количество слоёв на самом деле больше.


      1. voldemar_d
        25.09.2024 17:39
        +1

        Не знаю, о какой аппаратуре речь, но для весьма многих устройств код можно писать на C++. Arduino IDE, например, поддерживает C++20.


        1. sdore
          25.09.2024 17:39

          Я подозреваю, там Verilog.


  1. wolodik
    25.09.2024 17:39
    +3

    Прям на ностальгию пробило.. 98й год, разбирался с нейросетями для дипломной работы и в качестве примера написал как раз на плюсах распознавание букв алфавита. Такие же монохромные буковки, проверка устойчивости распознавания на зашумлённой картинке и рукописных символах.. В качестве мануала были какие-то ксеры пары английских статей про многослойные персептроны :)


  1. diegomaczuro
    25.09.2024 17:39

    Извините, а где вы учитесь и на какую специальность? Где нынче такие интересные лабораторки дают?


    1. Mankeyy Автор
      25.09.2024 17:39

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


      1. Skyl1ne32
        25.09.2024 17:39

        По ЕГЭ поступали?


        1. voldemar_d
          25.09.2024 17:39

          А в какой ВУЗ нынче можно поступить не по ЕГЭ?


      1. da-nie
        25.09.2024 17:39

        Вообще-то, всё это делается матричным умножением. Особенно для аппаратного ускорителя. О чём много-много раз уже писалось. Можете посмотреть реализацию для CUDA.


        1. drVit
          25.09.2024 17:39

          Для таких маленьких сеток, скорее всего, быстрее на ЦПУ посчитать, чем по шине на карту с ускорителем данные гонять.


    1. Kergan88
      25.09.2024 17:39
      +1

      Последние лет 20 практически у всех прикладников курс по сеткам есть. За исключением какихто университетов села кукуево разве что.


  1. SeroBuro
    25.09.2024 17:39
    +2

    w^i_{ij}

    Подправьте в начале, там сверху k должно быть