Генерируем код из свёрточной нейросети
Встраиваемый код из свёрточной нейросети
Обучим нейросеть игрушечной задаче - предсказывать геометрические фигуры, и проверим, можно ли скомпилировать ее в код на Си, а затем и в бинарную библиотеку чтобы использовать в блоках или как часть другого проекта.
Введение
В этом практическом руководстве мы обучим нейросеть распознавать геометрические фигуры на игрушечном датасете, а затем экспортируем обученную модель в код на C, скомпилируем его в разделяемую библиотеку и проверим возможность интеграции в сторонние проекты или блоки кода на Си в вашем проекте.
В ходе конвертации вы заметите небольшую потерю точности. Это может быть вызвано разницей в реализации некоторых операций в Julia и в C (например, батч-нормализация) или простым округлением коэффициентов при переводе в код, но зато открывает путь к развёртыванию на встраиваемых системах.
Подготовительная работа
На этом этапе мы загружаем необходимые библиотеки, фиксируем генератор случайных чисел, создаём синтетический датасет из квадратов, кругов и треугольников, а затем визуализируем примеры изображений каждого класса.
Генерация контролируемого, сбалансированного датасета с известными свойствами (размер 64×64, нормализация в диапазон [-1,1]) позволяет изолированно проверить каждый этап конвейера без влияния внешних факторов.
Установим нужные библиотеки и инициализируем генератор случайных чисел, чтобы наш эксперимент был легко возпроизводимым:
# # Установка необходимых пакетов
# Pkg.add(["Flux", "BSON", "ImageTransformations"])
using Random
Random.seed!(123);
Создадим игрушечный датасет, состоящий из трех классов. Часть объектов помещается в папку "неизвестно", то есть их класс, хоть он и прописан в названии файлов, системе будет неизвестен. Можно назвать это валидационным датасетом. Остальные - тренировочный и тестовый - разложены по соответствующим папкам.
include("$(@__DIR__)/_scripts/generate_dataset.jl")
Вот образцы объектов из нашего учебного набора данных:
include("$(@__DIR__)/_scripts/show_dataset_samples.jl")
DATA_DIR = "$(@__DIR__)/учебные данные";
gr()
show_dataset_samples(DATA_DIR, samples_per_class=10)
Основная сложность этого этапа заключалась в том, чтобы сгенерировать достаточно разнообразный датасет (с поворотами треугольников и квадратов), но при этом сохранить простоту для быстрой отладки, хотя в целом этап оказался наименее проблемным.
Обучение и анализ модели
Здесь мы запускаем процесс обучения свёрточной нейросети, сохраняем историю метрик, анализируем динамику точности и потерь, а также отображаем мозаику предсказаний на тестовых изображениях.
Мониторинг метрик precision/recall по классам и ранняя остановка по валидационной точности помогают вовремя обнаружить переобучение и выбрать лучшую модель для последующего экспорта.
include("$(@__DIR__)/_scripts/train_model.jl");
DATA_DIR = "$(@__DIR__)/учебные данные";
model, classes = train_model(DATA_DIR; epochs=100, imsize=64, batch_size=32, lr=0.0005, test_split=0.25, patience_limit=8);
Посмотрим на качество проведенного обучения:
include("$(@__DIR__)/_scripts/analyze_training_log.jl")
gr()
df, classes, p = analyze_training_log("training_log.txt")
display(p)
Каждый график интересно интерпретировать по-отдельности. Например, precision рос для всех классов практически одинаково, но показатель recall сразу стал лучше для квадратов, и всегда был позади для треугольников, оставаясь не самым высоким и к концу процесса обучения.
Мы не стали продолжать обучение после достижения качества 100% на тесте, потому что исчез смысл сравнивать реализации между собой. Но нам определенно стоило бы породить больше объектов для датасета, поскольку, в среднем, к концу обучения модель достаточно точно определяла квадраты и круги, но из пяти предложенных треугольников в среднем "не замечала" один из них. Хотя те, которе она отмечала как треугольники действительно ими были (больше ошибок "ложного срабатывания" сеть демонстрировала для класса "круг").
include("$(@__DIR__)/_scripts/simple_mosaic.jl")
UNKNOWN_DIR = "$(@__DIR__)/неизвестно";
gr()
plot(create_simple_mosaic(UNKNOWN_DIR, imsize=64))
Мы видим довольно хорошие предсказания, но это не столько результат успешного обучения, сколько результат долгой работы проектировщика. Самым трудоёмким оказался подбор архитектуры сети (количество слоёв, каналов, использование BatchNorm и Dropout) и гиперпараметров (скорость обучения, размер батча, аугментация), чтобы достичь стабильной сходимости и избежать переобучения на ограниченном наборе данных. В итоге, например, аугментация была перенесена в функцию порождающую датасет, чтобы упростить пример, а также в силу того, что эта процедура нужна только треугольникам.
Экспорт в C и тестирование
Теперь мы конвертируем предварительно обработанные изображения в бинарный формат, генерируем C-код нейросети, компилируем его в исполняемый файл и визуализируем предсказания, полученные от C-реализации. Мы заведомо предполагаем, что код будет работать на платформах, где нет библиотеки PNG. Поэтому переводим изображения в бинарный формат при помощи отдельного скрипта. В этих бинарных файлах лежат матрицы, в качестве элементов которых фигурирует каждый цветовой канал каждого пикселя, представленный одним числом UInt8.
include("$(@__DIR__)/_scripts/convert_png_to_rgb8.jl")
convert_png_to_rgb8("$(@__DIR__)/неизвестно", "$(@__DIR__)/неизвестно_rgb8", 64)
Теперь, когда у нас готов датасет с бинарными изображениями, можно загрузить уже обученную модель и перевести ее в код на Си. Ключевое требование к успешному экспорту — полное согласование форматов данных (RGB8 для изображений, HWC порядок коэффициентов) и порядка обхода весов между Julia и C, что достигается явным контролем индексации и нормализации на всех этапах.
include("$(@__DIR__)/_scripts/generate_cnn_code.jl")
using Flux, BSON
BSON.@load "$(@__DIR__)/model.bson" model classes
model = Flux.testmode!(model)
# Генерируем библиотеку и main программу
generate_shared_lib(model, 64, length(classes))
generate_main_program(64, length(classes))
Саму нейросеть мы скомпилируем в библиотеку. Мы также сгенерировали программу main, которая подаёт в нейросеть изображения из папки "неизвестно_rgb8" и обрабатывает результаты классификации.
;gcc -shared -fPIC neural_net.c -o libneuralnet.so -lm
;gcc main.c -o classify_unknown -ldl -lm
Что любопытно, чтобы запустить эту нейросеть, нам не потребуются никакие библиотеки - ни Julia, ни C. Она выполняется на любой системе, где есть компилятор кода на Си.
;./classify_unknown
При переносе модели в C пришлось решить несколько нетривиальных задач: ручная реализация свёрток и BatchNorm без сторонних библиотек, приведение всех операций к единому формату HWC, точное воспроизведение порядка обхода весов (особенно критичного для многоканальных слоёв), а также работа с бинарными файлами изображений из-за отсутствия библиотеки PNG в целевой среде — все эти трудности были успешно преодолены.
include("$(@__DIR__)/_scripts/create_mosaic_from_c_predictions.jl")
run(pipeline(`./classify_unknown`, stdout="pred.txt"))
UNKNOWN_DIR = "$(@__DIR__)/неизвестно";
gr()
mosaic_grouped = create_mosaic_from_c_predictions("неизвестно", "pred.txt", max_images=8)
Несмотря на перечисленные сложности, мы продемонстрировали полный рабочий конвейер, доказывающий, что экспорт нейросетей из Julia в C возможен даже при ограниченных ресурсах целевой платформы.
include("$(@__DIR__)/_scripts/predict_to_csv.jl")
UNKNOWN_DIR = "$(@__DIR__)/неизвестно";
predict_to_csv(UNKNOWN_DIR, confidence_threshold=0.4, output_csv="$(@__DIR__)/predictions.csv")
run(pipeline(`./classify_unknown`, stdout="pred.txt"))
include("$(@__DIR__)/_scripts/compare_c_and_julia.jl")
df = compare_c_and_julia()
sort(df)
Заключение
Мы показали, как пройти полный цикл создания программы с нейросетью внутри: от создания датасета и обучения модели на Julia до экспорта в C и проверки работоспособности, что подтверждает принципиальную возможность использования сгенерированного кода далеко за пределами инженерной платформы Engee.



