Engee 文档
Notebook

使用多层神经网络进行分类

在本例中,我们将针对分类任务训练一个神经网络,并将其置于程序块Engee Function 中,这样我们就可以轻松地将训练好的算法从一个模型转移到另一个模型。

神经网络训练

在本任务中,我们将为标准 XOR 问题的数据创建一个分类算法。我们将创建一个向量noisy ,其中包含一组从 0 到 1 分布的输入数据x1x2 ,以及一个向量truth - 我们期望从神经网络中得到的预测结果(操作xor( (x1>0.5, x2>0.5) )。

让我们在一个单元格中完成所有工作,然后将训练好的神经网络转移到画布上,并给出代码的所有解释。

In [ ]:
Pkg.add(["Statistics", "Flux", "Symbolics"])
   Resolving package versions...
  No Changes to `~/.project/Project.toml`
  No Changes to `~/.project/Manifest.toml`
In [ ]:
# Pkg.add( "ChainPlots" ) # Использовать осторожно, от версии к версии бывают нестыковки
In [ ]:
using Flux, Statistics, Random
Random.seed!( 2 )           # Обеспечим управляемость процесса обучения

# Архитектура модели: два полносвязанных слоя с небольшим количеством нейронов в каждом.
model = Chain(
      Dense(2 => 3, tanh),
      Dense(3 => 2),        # Сколько классов, столько и выходов нейросети
      softmax )

# Генерация входных данных
inputs = rand( Float32, 2, 1000 );                                   # 2×1000 Matrix{Float32}
truth = [ xor(col[1]>0.5, col[2]>0.5) for col in eachcol(inputs) ];  # Vector{Bool} из 1000 элементов

# Сохраним на будущее прогноз "необученной" модели
probs1 = model( inputs );

# Подготовка данных и обучение
targets = Flux.onehotbatch( truth, [true, false] );          # Раскладываем выходную переменную на логиты и создаем загрузчик данных
data = Flux.DataLoader( (inputs, targets), batchsize=64, shuffle=true );
opt_state = Flux.setup( Adam( 0.01 ), model );               # Настройки процедуры оптимизации и конкретная функция потерь
loss(, y) = Flux.crossentropy( , y )
accuracy(, y) = mean( Flux.onecold(  ) .== Flux.onecold( y ))
loss_history, accuracy_history = [], []                      # Производим обучение, записывая результаты
for i in 1:5000
    Flux.train!( model, data, opt_state) do m, x, y
        loss( m(x), y ) # Функция потерь - ошибка на каждом элементе датасета
    end
    push!( loss_history, loss( model(inputs), targets ) ) # Запомним значение функции потерь и точности прогноза
    push!( accuracy_history, accuracy( model(inputs), targets ) )
end

# Прогноз модели после обучения
probs2 = model( inputs );

# Выведем график, по которому можно оценить качество обучения
gr()
plot( [ loss_history, accuracy_history], size=(300,200), label=["loss" "accuracy"], leg=:right )
Out[0]:

训练结果

我们特意保留了训练前后的模型预测结果:

In [ ]:
println( "Точность прогноза перед обучением: ", 100 * mean( (probs1[1,:] .> 0.5) .== truth ), "%" )
println( "Точность прогноза после обучения: ", 100 * mean( (probs2[1,:] .> 0.5) .== truth ), "%" )
Точность прогноза перед обучением: 50.4%
Точность прогноза после обучения: 97.1%
In [ ]:
# Выведем график исходных данных
p_true = scatter( inputs[1,:], inputs[2,:], zcolor=truth, title="Исходные данные" );
p_raw = scatter( inputs[1,:], inputs[2,:], zcolor=probs1[1,:], title="Прогноз до обучения" );
p_done = scatter( inputs[1,:], inputs[2,:], zcolor=probs2[1,:], title="После обучения" );

plot(p_true, p_raw, p_done, layout=(1,3), size=(700,200), titlefont=font(9), ms=3.5, legend=false, cbar=false )
Out[0]:

将神经网络转入 Engee 功能块

我们将为该神经网络生成 Julia 代码,将符号变量代入其输入,并得到一个符号表达式来代替输出。

让我们把它直接放在Engee Function 的代码块中,得到一个可以复制并粘贴到任何其他模型中的代码块。

In [ ]:
# Сгенерируем новое изображение для размещения на лицевой стороне блока
# (успех зависит от стабильности текущей версии ChainPlots)

# using ChainPlots
# p = plot( model,
#     titlefontsize=10, size=(300,300),
#     xticks=:none, series_annotations="", markersize=8,
#     markercolor="white", markerstrokewidth=4, linewidth=1 )
# savefig( p, "$(@__DIR__)/neural_net_block_mask.png");
In [ ]:
# Создадим код нейросети

using Symbolics
@variables x1 x2
s = model( [x1, x2] );

# Загрузим модель, если она еще не открыта на холсте
if "neural_classification"  getfield.(engee.get_all_models(), :name)
    engee.load( "$(@__DIR__)/neural_classification.engee");
end

# Шаблон кода, который мы поместим в блок Engee Function
code_strings = """
struct Block <: AbstractCausalComponent; end

# У нейросети два выхода: s[1] и s[2]
nn(x1, x2) = ($(s[1]), $(s[2]))

# Вычислим выходы нейросети и вернем результат классификации: 0 или 1
function (c::Block)(t::Real, x1, x2)
    # "Вероятность" каждого из классов
    c1, c2 = nn(x1, x2)
    # Вычисляем выходное значение по результатам классификации
    # - если больше вероятность c1, то будет выбран класс true (1)
    # - если больше – вероятность c2, то будет выбран класс false (2)
    return (c1 > c2) ? 1 : 0
end
"""

# Какой блок модели должен содержать код нейросети?
block_address = "neural_classification/Engee Function"
engee.set_param!( block_address, "StepMethodCode" => code_strings)

# Сохраним модель после изменения 
engee.save( "neural_classification", "$(@__DIR__)/neural_classification.engee"; force = true )
Out[0]:
Model(
	name: neural_classification
	id: 995f8eb6-3102-488a-8dac-5040e7bd1526
)

所需块的地址可以在此块的设置中,从信息面板上的模型中的路径字段中复制。

image.png

让我们运行模型,看看结果如何:

In [ ]:
model_data = engee.run( "neural_classification" );

# Подготовим выходные переменные
model_x1 = model_data["X1"].value;
model_x2 = model_data["X2"].value;
model_y = vec( hcat( model_data["Y"].value... ));

# Построим график
scatter( model_x1, model_x2, model_y, ms=2.5, msw=.5, leg=false, zcolor=model_y, c=:viridis,
         xlimits=(0,1), ylimits=(0,1), title="Прогноз от блока Engee Function", titlefont=font(10) )
Out[0]:

代码说明

In [ ]:
# plot( model, size=(600, 350) )

让我们了解一下神经网络分类训练过程的几个特点,即

  • 软最大特征、
  • 单次编码、
  • 创建数据加载器、
  • 交叉熵损失函数、
  • 计算预测准确率。
model = Chain(
      Dense(2 => 3, tanh)、
      Dense(3 => 2)、
      softmax )

首先,我们可以看到,神经网络的任务是确定一个对象应该属于哪个类别。

我们的神经网络不会将两个输入参数转化为一个输出变量。输出变量的数量等于类的数量。

注意:最后一个 FC 层是线性激活层。在它之后是一些"软最大层 "。Softmax 是对数字的一种运算,有时被称为激活。但Flux 软件包采用了不同的方法。softmax 操作的本质是什么?

Softmax 将神经网络的输出值转换为概率。这些是 "二元交叉熵 "loess 函数(见下文)略微正确的输入数据。

假设神经网络的输出层有N 个输出,在它之后是函数softmax 。它接收每个输入值x_i ,将其指数化为$x_i$ 的阶数($\epsilon_i = e^{x_i}$ ),并对每个值$\epsilon_i$ 计算输出$y_i = \frac{\epsilon_i}{\sum_{i=1}^{N}{\epsilon_i}}$ 。输出函数将每个$\epsilon_i$ 除以所有$\epsilon_i$ 的总和,然后在输出端得到 logits--严格意义上的正数,其总和等于 1。

target = Flux.onehotbatch( truth, [true, false] )

我们的分类任务是这样组织的:网络要么返回[1, 0] ,要么返回[0, 1] 。这是为什么呢?

想象一下,神经网络应该返回一个类号,即十个中的一个。如果神经网络出错,返回的是 2 而不是 1,MSE 将返回误差 (2-1)=1。如果神经网络出错,返回的是 10 而不是 1,那么 MSE 将返回 (10-1)=9 的误差,尽管这个误差一般不会比其他误差更严重。我们需要以其他因素为基础。一个热编码可以让我们不再比较类的数量,而是比较网络在一个或另一个类中的 "置信度 "分布。

但在训练样本中,输出变量的值仍然是标量:truefalse 。函数onehot 将它们转化为两个值的向量:true[1,0]false 到向量[0,1]

data = Flux.DataLoader( (noisy, target) )

DataLoader - 是将数据送入神经网络的一种略为优雅的**方式,在将参数向量送入网络之前,无需对其进行转置。它还可以通过参数shuffle = true 来在每次训练时对样本进行洗牌,并通过batchsize=64 来并行执行。下面是该对象输入神经网络的元素的样子:

In [ ]:
data = Flux.DataLoader( (inputs, targets), batchsize=1 );
first( data )
Out[0]:
(Float32[0.5859486; 0.54989403;;], Bool[0; 1;;])

正如我们所见,DataLoader 对象的第一个元素由两部分组成:

  • 特征向量--输入神经网络的标量值、
  • 预测向量--由one-hot 类型编码的所需类别。
loss(ỹ, y) = Flux.crossentropy( ỹ, y )

正如你已经说过的,有一条经验法则是在分类任务中不使用均方误差 (MSE)。神经网络使用 MSE 的学习速度非常慢,尤其是在多类分类的情况下。那我们该怎么做呢?

如果分类任务的组织形式与我们的例子相同,那么在只有两类的情况下,交叉熵 (crossentropy) 或其二元变体 (binarycrossentropy) 通常被用作**损失函数。有时,为了加快神经网络的工作速度,softmax 操作被排除在神经网络之外,这时可以通过指定logitcrossentropylogitbinarycrossentropy 来指定在损失函数中执行该操作。

accuracy(ỹ, y) = mean( Flux.onecold( ỹ ) .== Flux.onecold( y ))

我们保留预测精度(准确度)--正确猜测值的百分比。函数onecold 执行与onehot 相反的操作。onehot 编码操作会找出向量中最大的元素,并在输出向量中将其表示为数字 1,所有其他位置都等于 0。反过来,onecold 操作会找到输入向量中最大的元素,并输出一个值--该元素对应的类标签(如果没有指定标签,则输出一个序号)。

这个函数的定义可以简单得多,但高级函数通常可以避免许多错误,或者至少产生更有价值的错误信息。

不是每次运行都能得到好结果,因此我们在本例的开头设置了一些特定的seed.自动训练多个具有不同初始化的模型并选择最佳模型可能很有用。或者,如果计划经常在略有不同的样本上进行训练(如数字孪生的情况),最好花更多时间创建一个更强大的训练程序。例如,设置学习率的正弦控制或添加批量归一化。

结论

我们训练了一个用于分类的神经网络,并将其作为模型中的另一个区块放置在画布上。

训练过程的代码非常简洁,只需 7 行。块内神经网络的代码是自动生成的。

我们研究的神经网络训练代码没有太多的"超参数 "设计者可配置的参数),可以手动选择,多层神经网络的表现力非常强。