Engee 文档
Notebook

神经网络 C 代码生成

在本示例中,我们将探索SymbolicsFlux 库为各种神经网络(全连接、递归和卷积)生成独立 C 代码的功能。

所需库

首先要检查是否安装了所需的库。

In [ ]:
Pkg.add(["Flux", "ChainPlots", "Symbolics"])
In [ ]:
Pkg.add( ["Flux", "Symbolics", "ChainPlots"] )

通过Flux 库可以创建神经网络,其代码由Julia 语言指定。事实上,神经网络是用高级语言而非第三方库或DSL域特定语言,如PyTorch)编写的,这将允许我们使用多态性机制,将神经网络转换为数学表达式,并从中生成代码。

在 Flux 库更新到最新版本之前,以下代码会产生一个无害的错误信息Error during loading of extension DiffEqBaseZygoteExt... ,不会干扰操作。只需右击它并选择Удалить выбранное 即可。

In [ ]:
using Flux, Symbolics, ChainPlots
gr();

小心,神经网络可能包含非常多的参数。将一个包含成百上千个参数的神经网络转化为数学表达式可能需要相当长的时间(几十秒),而且表达式本身也不可能容易理解。

从单个神经元生成代码

让我们使用Flux 库从单个神经元定义一个 "神经网络",然后将其生成数学公式和独立于平台的代码。

我们将重复一次这些库的名称,以便更清楚地说明它们的具体使用位置

In [ ]:
using Flux
model = Dense( 1=>1, relu )
Out[0]:
Dense(1 => 1, relu)  # 2 parameters

这是一个非常简单的可定制算法,有两个参数,可以用符号表示如下:

In [ ]:
using Symbolics
@variables x
model( [x] )
Out[0]:
$$ \begin{equation} \left[ \begin{array}{c} \mathrm{ifelse}\left( \left( - 0.85796064 x < 0 \right), 0, - 0.85796064 x \right) \\ \end{array} \right] \end{equation} $$

激活函数ReLU 是一个片断线性描述的神经元。

该算法的所有系数都是随机选择的,但为了方便起见,我们会给它们分配一些值。

In [ ]:
model.weight .= [10]
model.bias .= [20]
model( [x] )
Out[0]:
$$ \begin{equation} \left[ \begin{array}{c} \mathrm{ifelse}\left( \left( 20.0 + 10.0 x < 0 \right), 0, 20.0 + 10.0 x \right) \\ \end{array} \right] \end{equation} $$

让我们生成代码,看看会得到什么:

In [ ]:
c_model_code = build_function( model( [x] ), [x]; target=Symbolics.CTarget(), fname="neural_net", lhsname=:y, rhsnames=[:x] )
println( """$c_model_code""" )
#include <math.h>
void neural_net(double* y, const double* x) {
  y[0] = ifelse(20.0f0 + 10.0f0 * x[0] < 0, 0, 20.0f0 + 10.0f0 * x[0]);
}

我们可以看到,代码中至少包含了一个非标准函数ifelse ,而参数double 的后缀是f0 。这些指令是Symbolics 库所针对的某种 C 标准中的指令,但由于我们没有自定义代码生成,而是直接生成了代码,因此需要对其进行一些转换。例如

In [ ]:
c_fixed_model_code = replace( c_model_code, "double" => "float", "f0" => "f" );

这些代码已经可以放在模板中编译:

In [ ]:
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
In [ ]:
;gcc -o out_fc neural_net_fc.c -lm 
In [ ]:
;./out_fc
51.415001
In [ ]:
model( [3.1415] )
Out[0]:
1-element Vector{Float32}:
 51.415

在此阶段,您可能已经收到警告:最好不要在神经网络中使用Float64 参数,因为这样做不仅不会提高计算精度,反而会降低性能(尤其是在 GPU 上)。您可以使用model = fmap(f32, model) 命令将所有神经网络参数转换为Float32

我们可以看到,使用 C 代码计算出的值和使用 Julia 中定义的神经网络计算出的值在四舍五入精度上几乎是一样的。

从多层 FC 神经网络生成代码

让我们以一个由两个神经元组成的神经网络为例,检查代码生成情况。现在,这将是一个普通的多层全连接神经网络(FC,fully connected,有时也称为perceptron)。

In [ ]:
using Flux
mlp_model = Chain( Dense( 1=>2), Dense(2=>1) )
Out[0]:
Chain(
  Dense(1 => 2),                        # 4 parameters
  Dense(2 => 1),                        # 3 parameters
)                   # Total: 4 arrays, 7 parameters, 284 bytes.

下面是这种多层神经网络的示意图。

In [ ]:
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 )
Out[0]:

该模型使用随机值进行初始化,但已经可以将向量、矩阵等作为输入。请注意,神经网络始终需要一个参数向量作为输入,即使它们是符号变量。

In [ ]:
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 )
#include <math.h>
void neural_net(double* y, const double* x) {
  y[0] = 0.0068449117f0 * x[0];
}

注意,多层网络的表达式被简化为单个神经元的表达式。你应该在神经网络的第一层之后一个激活函数 (sigmoid,tanh...) 而不是线性函数 (identity 或 -skip-)。仅由 "线性层 "组成的多层神经网络类似于单个神经元Symbolics 库只是缩短了表达式。

经过一些转换后,这些代码可以放在Engee画布上的C Function 块中,也可以添加main 函数并编译成可执行的二进制文件。

从递归神经网络生成代码

递归神经网络适用于分析时间序列和生成序列。它们也可以是多层的,但神经元拓扑结构略有不同。让我们看看会得到什么样的代码。

In [ ]:
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 ) # Этой командой можно выполнять нейросеть и одновременно обновлять состояние
Out[0]:
$$ \begin{equation} \left[ \begin{array}{c} \tanh\left( 0.002 + 0.02 x_1 + 0.05 x_2 + 0.2 h_1 + 0.5 h_2 + 0.8 h_3 \right) \\ \tanh\left( 0.003 + 0.03 x_1 + 0.06 x_2 + 0.3 h_1 + 0.6 h_2 + 0.9 h_3 \right) \\ \tanh\left( 0.004 + h_3 + 0.04 x_1 + 0.07 x_2 + 0.4 h_1 + 0.7 h_2 \right) \\ \end{array} \right] \end{equation} $$
In [ ]:
p = plot( rnn_model, titlefontsize=10, size=(300,300), markersize=8, xticks=((0,1,2),["Входной\nслой", "Рекуррентный\nслой"]), markerstrokewidth=2, linewidth=1 )
Out[0]:
In [ ]:
c_model_code = build_function( rnn_model( X ), [X; H]; target=Symbolics.CTarget(), fname="neural_net", lhsname=:LHS, rhsnames=[:RHS] )
println( c_model_code )
#include <math.h>
void neural_net(double* LHS, const double* RHS) {
  LHS[0] = tanh(0.002f0 + 0.02f0 * RHS[0] + 0.05f0 * RHS[1] + 0.2f0 * tanh(0.002f0 + 0.02f0 * RHS[0] + 0.05f0 * RHS[1] + 0.2f0 * RHS[2] + 0.5f0 * RHS[3] + 0.8f0 * RHS[4]) + 0.5f0 * tanh(0.003f0 + 0.03f0 * RHS[0] + 0.06f0 * RHS[1] + 0.3f0 * RHS[2] + 0.6f0 * RHS[3] + 0.9f0 * RHS[4]) + 0.8f0 * tanh(0.004f0 + RHS[4] + 0.04f0 * RHS[0] + 0.07f0 * RHS[1] + 0.4f0 * RHS[2] + 0.7f0 * RHS[3]));
  LHS[1] = tanh(0.003f0 + 0.03f0 * RHS[0] + 0.06f0 * RHS[1] + 0.3f0 * tanh(0.002f0 + 0.02f0 * RHS[0] + 0.05f0 * RHS[1] + 0.2f0 * RHS[2] + 0.5f0 * RHS[3] + 0.8f0 * RHS[4]) + 0.6f0 * tanh(0.003f0 + 0.03f0 * RHS[0] + 0.06f0 * RHS[1] + 0.3f0 * RHS[2] + 0.6f0 * RHS[3] + 0.9f0 * RHS[4]) + 0.9f0 * tanh(0.004f0 + RHS[4] + 0.04f0 * RHS[0] + 0.07f0 * RHS[1] + 0.4f0 * RHS[2] + 0.7f0 * RHS[3]));
  LHS[2] = tanh(0.004f0 + 0.04f0 * RHS[0] + 0.07f0 * RHS[1] + 0.4f0 * tanh(0.002f0 + 0.02f0 * RHS[0] + 0.05f0 * RHS[1] + 0.2f0 * RHS[2] + 0.5f0 * RHS[3] + 0.8f0 * RHS[4]) + 0.7f0 * tanh(0.003f0 + 0.03f0 * RHS[0] + 0.06f0 * RHS[1] + 0.3f0 * RHS[2] + 0.6f0 * RHS[3] + 0.9f0 * RHS[4]) + tanh(0.004f0 + RHS[4] + 0.04f0 * RHS[0] + 0.07f0 * RHS[1] + 0.4f0 * RHS[2] + 0.7f0 * RHS[3]));
}

这种特殊的结构会将状态向量h 作为输出变量返回(对于它来说,yh 是相同的变量)。

其他架构需要更复杂的代码生成过程。例如,可以使用常用的库工具Flux 来训练模型,但在代码生成阶段将开发一个 Julia 函数形式的代用神经网络架构(例如,一个将返回yh 的函数)。

从递归神经网络生成代码所需的改进量可通过以下模式估算,这种模式很难被称为简单:

In [ ]:
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

让我们编译并运行这个神经网络的代码。

In [ ]:
;gcc -o out_rnn neural_net_rnn.c -lm
In [ ]:
;./out_rnn
0.914112 0.952953 0.974463
0.901624 0.944008 0.968435
0.901236 0.943730 0.968246
0.901224 0.943721 0.968240
0.901223 0.943721 0.968239

我们用相同的输入数据调用递归神经网络 5 次,每次得到的结果都不同,因为这个神经网络的状态向量已经更新。

我们得到了一个不是很亮眼的例子,部分原因是输入数据没有变化,部分原因是我们使用了激活函数tanh ,而它趋向于使输出值饱和(神经网络趋向于返回更接近1 的数字--这是 RNN 的典型问题)。

从卷积神经网络生成代码

In [ ]:
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)
    )
Out[0]:
Chain(
  Conv((2, 1), 1 => 1, tanh, bias=false),  # 2 parameters
  Dense(9 => 1),                        # 10 parameters
)                   # Total: 3 arrays, 12 parameters, 496 bytes.

要可视化这个神经网络,我们需要指定一个具有正确维度(宽、高、通道、批次)的输入向量xs

In [ ]:
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 )
Out[0]:

为清晰起见,我们将神经网络的最后一层(FC)设置为特定的权重和偏移量,我们将在生成的代码中看到这些权重和偏移量。

In [ ]:
cnn_model[2].weight .= reshape( collect(1:(signal_size - filter_x_size + 1)), 1, :);
cnn_model[2].bias .= [0.1];

这个模型的输入数据组织相当复杂。让我们演示一下如何执行它:

In [ ]:
cnn_model( reshape(xs, :, 1, 1, 1) )[:] # w, h, channels, batch
Out[0]:
1-element Vector{Float32}:
 33.048317

在这种情况下,我们只需将卷积运算作为 Julia 函数来实现。当我们简单地尝试将符号变量的二次矩阵代入模型时,会得到一个错误UndefRefError: access to undefined reference 。因此,让我们来看看这个神经网络,创建一个使用卷积核参数的函数,我们可以从原始模型中提取全连接层

In [ ]:
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;

生成后的代码会是什么样子?

In [ ]:
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 )
#include <math.h>
void neural_net(double* LHS, const double* RHS) {
  LHS[0] = 0.1f0 + 9.0f0 * tanh(1.0765818f0 * RHS[9] + 0.64346087f0 * RHS[8]) + 2.0f0 * tanh(0.64346087f0 * RHS[1] + 1.0765818f0 * RHS[2]) + 3.0f0 * tanh(0.64346087f0 * RHS[2] + 1.0765818f0 * RHS[3]) + 4.0f0 * tanh(0.64346087f0 * RHS[3] + 1.0765818f0 * RHS[4]) + 5.0f0 * tanh(0.64346087f0 * RHS[4] + 1.0765818f0 * RHS[5]) + 6.0f0 * tanh(0.64346087f0 * RHS[5] + 1.0765818f0 * RHS[6]) + 7.0f0 * tanh(0.64346087f0 * RHS[6] + 1.0765818f0 * RHS[7]) + 8.0f0 * tanh(0.64346087f0 * RHS[7] + 1.0765818f0 * RHS[8]) + tanh(0.64346087f0 * RHS[0] + 1.0765818f0 * RHS[1]);
}

代码看起来相当可行。让我们把它放入已建立的模板中,执行它,并与前面的函数结果进行比较。

In [ ]:
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
In [ ]:
;gcc -o out_cnn neural_net_cnn.c -lm
In [ ]:
;./out_cnn
33.048321
In [ ]:
cnn_model( reshape(xs, :, 1, 1, 1) )[:] # w, h, channels, batch
Out[0]:
1-element Vector{Float32}:
 33.048317
In [ ]:
my_convolution_net( xs )
Out[0]:
1-element Vector{Float32}:
 33.048317

很明显,在四舍五入精度方面,结果是相似的。

结论

我们已经生成了三种最常用神经网络的代码:全连接(FC、MLP)、递归(RNN)和卷积(CNN)。

只有全连接神经网络的代码生成相对简单明了,这使我们能够大幅提高这一过程的自动化程度。

在其他情况下,当自动生成神经网络代码时,根据任务(一维或二维卷积,使用递归神经网络的隐藏层)来生成代码是非常有用的。从设置 CNN 或 RNN 代码生成过程的手动步骤数量来看,自动化这一过程显然需要锚定特定场景。