Введение: зачем писать нейросеть на C
Язык C остаётся одним из самых востребованных для задач, где критичны производительность и контроль над памятью. Нейросети, написанные на C, работают быстрее интерпретируемых аналогов и могут быть легко портированы на микроконтроллеры или встраиваемые системы. В отличие от высокоуровневых фреймворков, реализация на C даёт полное понимание каждого этапа: от выделения памяти под веса до обратного распространения ошибки. Это особенно полезно для разработчиков, которые хотят не просто использовать готовые библиотеки, а разобраться в механизмах обучения. В этой статье мы разберём пример простой, но работоспособной нейросети — многослойного перцептрона, написанного на C/C++.
Архитектура многослойного перцептрона на C
Многослойный перцептрон (MLP) состоит из входного слоя, одного или нескольких скрытых слоёв и выходного слоя. Каждый нейрон связан со всеми нейронами предыдущего слоя, а каждая связь имеет весовой коэффициент. В нашем примере архитектура задаётся вручную: вы можете указать количество слоёв и ширину каждого из них. Например, для задачи распознавания рукописных цифр можно использовать конфигурацию 100-20-6-3-2, где 100 — размер входного вектора, а 2 — количество выходных классов. Код на C позволяет гибко настраивать эти параметры без перекомпиляции, если считывать их из файла. Важно помнить, что вход текущего слоя должен равняться выходу предыдущего, за исключением первого слоя, который принимает исходные данные.
Структура данных для хранения весов и нейронов
В основе реализации лежит структура nnLay, которая хранит:
inиout— количество входов и выходов слоя;matrix— двумерный массив весов размером(in+1) x out(дополнительная строка для смещения);hidden— массив значений после активации;errors— массив ошибок для обратного распространения.
Память под матрицу выделяется динамически с помощью malloc. Веса инициализируются случайными значениями, нормированными относительно количества входов: randWeight = (rand() / RAND_MAX - 0.5) * pow(out, -0.5). Это предотвращает затухание сигнала при прямом проходе. Если слоёв больше трёх, степень pow рекомендуется увеличить (например, до -0.2), чтобы сохранить энергию сигнала.
Прямое распространение (feedforward)
Прямое распространение — это процесс вычисления выходных значений сети по заданным входным данным. Для каждого слоя выполняется:
- Вычисление взвешенной суммы:
tmpS = sum(inputs[i] * matrix[i][hid]) + matrix[in][hid](последнее слагаемое — смещение). - Применение функции активации:
hidden[hid] = sigmoid(tmpS).
Сигмоида определяется как 1 / (1 + exp(-val)). Её производная val * (1 - val) используется при обучении. Результат каждого слоя передаётся на вход следующему. В коде это реализовано в методе makeHidden(), который вызывается последовательно для всех слоёв. После завершения прямого прохода выходные значения последнего слоя интерпретируются как ответ сети.
Обратное распространение ошибки и обновление весов
Обучение нейросети основано на алгоритме обратного распространения ошибки (backpropagation). Этапы:
- Расчёт ошибки выходного слоя:
errors[ou] = (target[ou] - hidden[ou]) * sigmoidDerivative(hidden[ou]). - Расчёт ошибки скрытых слоёв: для каждого нейрона скрытого слоя ошибка вычисляется как взвешенная сумма ошибок следующего слоя, умноженная на производную активации.
- Обновление весов: каждый вес корректируется по формуле
weight += learnRate * error * input, гдеlearnRate— скорость обучения (в примере 0.1). Смещения обновляются аналогично, но без умножения на вход.
Процесс повторяется для каждого обучающего примера. В коде это реализовано в методах calcOutError, calcHidError и updMatrix. Цикл обучения может выполняться тысячи итераций (эпох), пока ошибка не станет приемлемой.
Пример обучения: различение двух случайных векторов
Рассмотрим конкретный пример из исходного кода. Создаются два случайных входных вектора размером 100, каждому присваивается свой целевой вектор: tar1 = [0.01, 0.99], tar2 = [0.99, 0.01]. До обучения сеть выдаёт близкие значения для обоих векторов, не различая их. После 100 000 итераций обучения (с конфигурацией 100-20-6-3-2) результаты становятся различными: для первого вектора выход приближается к [0.01, 0.99], для второго — к [0.99, 0.01]. Это демонстрирует, что сеть научилась классифицировать два разных входных паттерна. Тест на наборе MNIST показал точность около 97.95% при скорости обучения 0.03 и нескольких эпохах.
Оптимизация и настройка гиперпараметров
Качество обучения зависит от нескольких параметров:
- Скорость обучения (learnRate): слишком высокое значение может привести к расходимости, слишком низкое — к медленной сходимости. Рекомендуется начинать с 0.1 и уменьшать при необходимости.
- Количество слоёв и нейронов: для простых задач достаточно одного скрытого слоя, для сложных — двух-трёх. Слишком много нейронов ведёт к переобучению.
- Инициализация весов: нормировка по
pow(out, -0.5)помогает избежать затухания градиента. Для глубоких сетей можно использовать методы Xavier или He. - Функция активации: сигмоида хороша для задач бинарной классификации, но для регрессии часто используют линейную активацию на выходе.
В коде можно легко менять эти параметры, переопределяя макросы learnRate и randWeight.
Практические советы по отладке и тестированию
При разработке нейросети на C полезно:
- Использовать флаги компилятора
-Wall -Wextra -pedanticдля выявления потенциальных ошибок. - Проверять выделение памяти: после каждого
mallocубедиться, что указатель не NULL. - Логировать значения ошибок и весов на каждой эпохе для отслеживания сходимости.
- Тестировать на простых задачах (например, XOR) перед переходом к реальным данным.
- Для отладки можно попросить нейросеть объяснить код — это помогает найти логические ошибки.
Если сеть не обучается, проверьте: правильность расчёта производной, соответствие размерностей слоёв, отсутствие утечек памяти.
Заключение и дальнейшие шаги
Представленный пример нейросети на C — это минимальная, но полностью работоспособная реализация многослойного перцептрона. Вы можете расширить её, добавив:
- другие функции активации (ReLU, tanh);
- регуляризацию (L1, L2) для борьбы с переобучением;
- мини-батч обучение;
- сохранение и загрузку весов из файла.
Код легко портируется на чистый C и может быть использован в проектах для микроконтроллеров. Главное преимущество такого подхода — полный контроль над каждым этапом вычислений. Начните с простого примера, постепенно усложняя архитектуру, и вы сможете создавать эффективные нейросетевые решения на C.
Вопросы и ответы
Можно ли использовать эту нейросеть для распознавания изображений?
Да, но потребуется предварительная обработка: преобразование изображения в одномерный вектор (например, 28x28 пикселей в 784 значения) и нормализация яркости. В статье приведён пример с набором MNIST, где точность составила около 97.95%.
Как изменить количество слоёв или нейронов в коде?
В файле myNeuro.cpp измените переменные inputNeurons, outputNeurons, nlCount и вызовы list[i].setIO(). Например, для однослойной сети закомментируйте многослойную конфигурацию и раскомментируйте однослойную.
Почему нейросеть не обучается?
Возможные причины: слишком высокая или низкая скорость обучения, неправильная инициализация весов, ошибка в расчёте производной, несоответствие размерностей слоёв. Проверьте, что вход текущего слоя равен выходу предыдущего, и используйте отладочный вывод.
Как сохранить обученные веса для последующего использования?
Добавьте функцию, которая записывает матрицу весов каждого слоя в файл (например, в бинарном или текстовом формате). При загрузке считывайте данные и присваивайте их матрицам. В текущей реализации веса генерируются случайно при каждом запуске.
Подходит ли этот код для микроконтроллеров?
Да, код написан на C и не использует сторонних библиотек (кроме стандартных math.h и stdlib.h). Однако убедитесь, что на целевой платформе доступна функция exp() и достаточно памяти для хранения весов.
Какую функцию активации лучше выбрать для регрессии?
Для регрессии на выходном слое обычно используют линейную активацию (identity), а для скрытых слоёв — ReLU или tanh. В текущем коде замените сигмоиду в выходном слое на hidden[hid] = tmpS.
Сколько времени занимает обучение на 100 000 итераций?
Зависит от размера сети и процессора. Для конфигурации 100-20-6-3-2 на современном CPU обучение занимает несколько секунд. Для больших сетей (например, MNIST) время может достигать минут.