Генерация C-кода для нейросетей¶
В этом примере мы изучим возможности библиотек Symbolics
и Flux
на предмет создания независимого кода на Си для различных нейросетей – полносвязанных, рекуррентных и сверточных.
Необходимые библиотеки¶
Первым делом проверьте, что необходимые библиотеки у вас установлены.
Pkg.add( ["Flux", "Symbolics", "ChainPlots"] )
Библиотека Flux
позволяет создавать нейросети, код которых задан на языке Julia
. То, что нейросеть написана на высокоуровневом языке, а не на третьесторонней библиотеке и не на DSL (Domain Specific Language, такой как PyTorch), позволит нам использовать механизм полиморфизма и преобразовать нейросеть в математическое выражение из которого можно сгенерировать код.
До обновления библиотеки Flux до последней версии следующий код будет выдавать безвредное сообщение об ошибке
Error during loading of extension DiffEqBaseZygoteExt...
, которое не мешает работе. Просто нажмите на него правой кнопкой и выберитеУдалить выбранное
.
using Flux, Symbolics, ChainPlots
gr();
Осторожно, нейросети могут содержать очень много параметров. Перевод нейросети, содержащей сотни или тысячи параметров в математическое выражение может занять существенное время (несколько десятков секунд), а само выражение вряд ли будет удобно для восприятия.
Генерация кода из одного нейрона¶
Зададим "нейросеть" из одного нейрона при помощи библиотеки Flux
, а затем получим ее в виде математического уравнения и в виде платформо-независимого кода.
Мы один раз повторим названия библиотек чтобы было понятнее, где именно они применяются
using Flux
model = Dense( 1=>1, relu )
Это очень простой настраиваемый алгоритм с двумя параметрами, который можно увидеть в символьной форме следующим образом:
using Symbolics
@variables x
model( [x] )
Функция активации ReLU
, вполне ожидаемо, создает нейрон с кусочно-линейным описанием.
Все коэффициенты этого алгоритма выбраны случайным образом, но ради удобства восприятия назначим им какие-нибудь значения.
model.weight .= [10]
model.bias .= [20]
model( [x] )
Сгенерируем код и посмотрим, что у нас получится:
c_model_code = build_function( model( [x] ), [x]; target=Symbolics.CTarget(), fname="neural_net", lhsname=:y, rhsnames=[:x] )
println( """$c_model_code""" )
Мы видим, что, как минимум, в коде присутствует нестандартная функция ifelse
, а параметры double
имеют суффикс f0
. Эти инструкции есть в некотором стандарте Си, на который нацелена библиотека Symbolics
, но поскольку мы не настраивали генерацию кода под себя, а сгенерировали код "из коробки", нужно будет немного его преобразовать. Например так:
c_fixed_model_code = replace( c_model_code, "double" => "float", "f0" => "f" );
Такой код уже можно поместить в шаблон и скомпилировать:
c_code_standalone = """
#include <stdio.h>
#include <stdbool.h>
float ifelse(bool cond, float a, float b) { return cond ? a : b; }
$c_fixed_model_code
int main(int argc, char const *argv[]){
float out[1];
float in[] = {3.1415};
neural_net( out, in );
printf( "%f\\n", out[0] );
return 0;
}""";
# Сохраняем в файл доработанный код
open("$(@__DIR__)/neural_net_fc.c", "w") do f
println( f, "$c_code_standalone" )
end
;gcc -o out_fc neural_net_fc.c -lm
;./out_fc
model( [3.1415] )
На этом этапе могло прийти предупреждение о том, что лучше не использовать параметры
Float64
в нейросети, это не дает выигрыша в точности расчетов, но вредит производительности (особенно на GPU). Перевести все параметры нейросети вFloat32
можно командойmodel = fmap(f32, model)
.
Как мы видим, значение, вычисленное при помощи кода на Си и при помощи нейросети, заданной в Julia, почти одинаковы, с точностью до округления.
Генерация кода из многослойной FC-нейросети¶
Проверим генерацию кода на примере нейросети из двух нейронов. Пока это будет обычная многослойная полносвязанная нейросеть (fc, fully connected, иногда называемая перцептроном).
using Flux
mlp_model = Chain( Dense( 1=>2), Dense(2=>1) )
Вот иллюстрация этой многослойной нейросети.
using ChainPlots
p = np = plot( mlp_model, titlefontsize=10, size=(300,300), markersize=8, xticks=((0,1,2),["Входной\nслой", "Скрытый\nслой", "Выходной\nслой"]), markerstrokewidth=2, linewidth=1 )
Эта модель инициализирована случайными значениями, но уже может принимать на вход векторы, матрицы и многое другое. Обратим внимание – нейросети всегда требуется, чтобы на вход был передан вектор из параметров, пусть это даже символьные переменные.
using Symbolics
@variables x
c_model_code = build_function( mlp_model( [x] ), [x]; target=Symbolics.CTarget(), fname="neural_net", lhsname=:y, rhsnames=[:x] )
println( c_model_code )
Что не так с этим кодом? Обратите внимание на то, что выражение для многослойной сети сократилось до выражения для одного нейрона. Следовало после первого слоя нейросети поставить функцию активации (
sigmoid
,tanh
...), отличную от линейной (identity
или -пропуск-). Многослойная нейросеть, состоящая только из "линейных слоёв", аналогична одному нейрону, и библиотекаSymbolics
просто сократила выражение.
После некоторых преобразований, этот код можно поместить в блок C Function
на холст Engee, или добавить к нему функцию main
и скомпилировать в исполняемый бинарный файл.
Генерация кода из рекуррентной нейросети¶
Рекуррентные нейросети хорошо использовать для анализа временных рядов и порождения последовательностей. Они тоже могут быть многослойными, но имеют немного другую топологию нейрона. Посмотрим, какой код у нас получится.
input_channels_nb = 2
hidden_state_size = 3
rnn = Flux.RNNCell(input_channels_nb, hidden_state_size)
# Эти параметры настраиваются в процессе обучения
# - для наглядности, назначим каждой матрице параметров некоторые значения
# - к сожалению, они не могут быть символьными
rnn.Wh .= 0.1 .+ reshape(1:hidden_state_size*hidden_state_size, hidden_state_size, hidden_state_size) ./ 10
rnn.Wi .= 0.01 .+ reshape(1:hidden_state_size*input_channels_nb, hidden_state_size, input_channels_nb) ./ 100
rnn.b .= 0.001 .+ collect(1:hidden_state_size) ./ 1000
X = Symbolics.variables( :x, 1:input_channels_nb )
H = Symbolics.variables( :h, 1:hidden_state_size )
rnn_model = Flux.Recur(rnn, H) # Объект, который берет на себя работу с переменными состояния нейросети
y = rnn_model( X ) # Этой командой можно выполнять нейросеть и одновременно обновлять состояние
p = plot( rnn_model, titlefontsize=10, size=(300,300), markersize=8, xticks=((0,1,2),["Входной\nслой", "Рекуррентный\nслой"]), markerstrokewidth=2, linewidth=1 )
c_model_code = build_function( rnn_model( X ), [X; H]; target=Symbolics.CTarget(), fname="neural_net", lhsname=:LHS, rhsnames=[:RHS] )
println( c_model_code )
Эта конкретная архитектура в качестве выходной переменой возвращает вектор состояния h
(для нее y
и h
– одинаковые переменные).
Другие архитектуры потребуют более сложного процесса генерации кода. Например, обучение модели может производиться обычными средствами библиотеки
Flux
, но для этапа генерации кода будет разработана суррогатная архитектура нейросети в виде Julia-функции (например, функцию, которая будет нам возвращатьy
иh
).
Количество доработок для генерации кода из рекуррентной нейросети можно оценить по следующему шаблону, который вряд ли можно назвать простым:
c_fixed_model_code = replace( c_model_code, "double" => "float", "f0" => "f" );
c_code_standalone = """
#include <stdio.h>
$c_fixed_model_code
int main(int argc, char const *argv[]){
float out[3];
float in[] = {0, 1, 2, 3, 4};
for( int i=0; i<5; i++ ){
neural_net( out, in );
printf( "%f %f %f\\n", out[0], out[1], out[2] );
/* Подставим выходное значения в входной вектор в качестве вектора состояния */
in[2] = out[0];
in[3] = out[1];
in[4] = out[2];
}
return 0;
}""";
# Сохраняем в файл доработанный код
open("$(@__DIR__)/neural_net_rnn.c", "w") do f
println( f, "$c_code_standalone" )
end
Скомпилируем и запустим код этой нейросети.
;gcc -o out_rnn neural_net_rnn.c -lm
;./out_rnn
Мы вызвали рекуррентную нейросеть 5 раз с одинаковыми входными данными и каждый раз получили разные результаты, поскольку у этой нейросети обновлялся вектор состояния.
У нас получился не слишком яркий пример, отчасти поскольку входные данные не меняются, отчасти из-за того, что мы использовали функцию активации
tanh
, а она имеет тенденцию к насыщению выходного значения (нейросеть имеет тенденцию возвращать числа ближе к1
– типичная проблема для RNN).
Генерация кода из сверточной нейросети¶
in_channels = 1
out_channels = 1
filter_x_size = 2
filter_y_size = 1
signal_size = 10
cnn_model = Chain(
Conv((filter_x_size,filter_y_size), in_channels => out_channels, tanh; bias = false),
Dense(signal_size - filter_x_size + 1 => 1)
)
Для визуализации этой нейросети нам нужно указать пример входного вектора xs
с правильной размерностью (ширина, высота, каналы, батчи).
xs = rand( signal_size );
p = plot( cnn_model, reshape(xs, :, 1, 1, 1), titlefontsize=10, size=(300,300), markersize=8, xticks=((0,1,2),["Входной\nслой", "Сверточный\nслой", "Выходной\nслой"]), markerstrokewidth=2, linewidth=1 )
Для наглядности, установим последнему (FC) слою нейросети определенные значения весов и смещения, которые мы увидим в сгенерированном коде.
cnn_model[2].weight .= reshape( collect(1:(signal_size - filter_x_size + 1)), 1, :);
cnn_model[2].bias .= [0.1];
Эта модель имеет достаточно сложную организацию входных данных. Продемонстрируем, как ее выполнить:
cnn_model( reshape(xs, :, 1, 1, 1) )[:] # w, h, channels, batch
В этом сценарии нам как раз придется реализовать операцию свёртки в виде функции Julia. При простой попытке подставить четырехмерную матрицу из символьных переменных в модель, мы получаем ошибку UndefRefError: access to undefined reference
. Поэтому посмотрим на эту нейросеть и создадим функцию, которая использует параметры ядра свертки, а полносвязанный слой мы можем взять из исходной модели.
function my_convolution_net( c_in )
c_out = [tanh( sum(c_in[i:i+1] .* reverse(cnn_model[1].weight[:]) )) for i in 1:length(c_in)-1]
out = cnn_model[2]( c_out ); # Второй слой модели мы используем в нашем коде без изменений
return out
end;
Как будет выглядеть код после генерации?
X = Symbolics.variables( :x, 1:signal_size )
c_model_code = build_function( my_convolution_net( X ), X; target=Symbolics.CTarget(), fname="neural_net", lhsname=:LHS, rhsnames=[:RHS] )
println( c_model_code )
С виду код выглядит вполне работоспособным. Поместим его в наш устоявшийся шаблон, выполним, и сравним с результатами предыдущих функций.
c_fixed_model_code = replace( c_model_code, "double" => "float", "f0" => "f" );
c_code_standalone = """
#include <stdio.h>
#include <stdbool.h>
float ifelse(bool cond, float a, float b) { return cond ? a : b; }
$c_fixed_model_code
int main(int argc, char const *argv[]){
float out[1];
float in[] = {$(join(xs, ","))};
neural_net( out, in );
printf( "%f\\n", out[0] );
return 0;
}""";
# Сохраняем в файл доработанный код
open("$(@__DIR__)/neural_net_cnn.c", "w") do f
println( f, "$c_code_standalone" )
end
;gcc -o out_cnn neural_net_cnn.c -lm
;./out_cnn
cnn_model( reshape(xs, :, 1, 1, 1) )[:] # w, h, channels, batch
my_convolution_net( xs )
Очевидно, результаты схожи с точностью до округления.
Заключение¶
Мы сгенерировали код из трех наиболее популярных видов нейросетей: полносвязанных (FC, MLP), рекуррентных (RNN) и сверточных (CNN).
Только для полносвязанных нейросетей генерация кода происходит сравнительно просто и прямолинейно, что позволяет существенно автоматизировать этот процесс.
В остальных случаях при автоматизации создания кода нейросетей полезно отталкиваться от задачи (1D или 2D свертки, использование скрытого слоя рекуррентной нейросети). По количеству ручных этапов настройки процесса генерации кода из CNN или RNN очевидно, что автоматизация этого процесса требует закрепления конкретных сценариев.