Документация Engee
Notebook

Классификация при помощи многослойной нейросети

В этом примере мы обучим нейросеть задаче классификации и разместим ее внутри блока Engee Function, что позволит нам легко переносить обученный алгоритм из одной модели в другую.

Обучение нейросети

В этой задаче мы создадим алгоритм классификации для данных стандартной задачи XOR. Создадим вектор noisy с набором входных данных x1 и x2, распределенных от 0 до 1, и вектор truth – результат прогноза, который мы ждем от нейросети (операция xor( (x1>0.5, x2>0.5)).

Выполним всю работу в одной ячейке, а потом перенесем обученную нейросеть на холст и дадим все пояснения к коду.

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 );
optim = Flux.Adam( 0.01 );                                   # Настройки процедуры оптимизации и конкретную функцию потерь
loss(x, y) = Flux.crossentropy( model( x ), y )
accuracy(x, y) = mean( Flux.onecold( model( x )) .== Flux.onecold( y ))
loss_history, accuracy_history = [], []                      # Производим обучение, записывая результаты
for epoch in 1:5000
    Flux.train!( loss, Flux.params( model ), data, optim )
    push!( loss_history, loss( inputs, targets ) )           # Запомним значение функции потерь и точности прогноза
    push!( accuracy_history, accuracy( inputs, targets ) )
end

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

# Выведем график, по которому можно оценить качество обучения
gr()
plot( [ loss_history, accuracy_history], size=(300,200), label=["loss" "accuracy"] )
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 Function

Мы сгенерируем Julia-код для этой нейросети, подставив символьные переменные на ее вход и получив вместо выхода символьное выражение.

Поместим его прямо внутрь блока Engee Function чтобы получить блок, который можно скопировать и вставить в любую другую модель.

In [ ]:
# Создадим код нейросети
using Symbolics
@variables x1 x2
s = model( [x1, x2] );

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");

# Загрузим модель, если она еще не открыта на холсте
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, "ExeCode" => code_strings)

# Сохраним модель после изменения 
engee.save( "neural_classification", "$(@__DIR__)/neural_classification.engee"; force = true )
Out[0]:
Model(
	name: neural_classification
	id: 96fcae51-80b4-42b9-a07a-eaf441762ca3
)

Адрес нужного блока можно скопировать в настройках этого блока, из поля Путь в модели на панели Информация.

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

Разберем несколько особенностей процесса обучения нейросетей для классификации, а именно:

  • функцию софтмакс,
  • кодирование one-hot,
  • создание загрузчика данных,
  • лосс-функцию "кросс-энтропия",
  • расчет точности прогноза (accuracy).
model = Chain(
      Dense(2 => 3, tanh),
      Dense(3 => 2),
      softmax )

Во-первых, можно увидеть что наша нейросеть не выполняет чистую операцию XOR – не переводит два входных аргумента в выходную переменную. Она определяет, к какому классу должен приналдежать объект, описанный данной парой входных переменных.

Обратите внимание на то, что последний FC слой имеет линейную активацию, а потом нейросеть заканчивается некоторым "слоем softmax". Эта операция над числами часто преподносится как активация, а не как отдельный слой, но в Flux принято иначе. В чем ее суть операции softmax?

Ее роль – дать чуть более корректные входные данные для лосс-функции "бинарная кросс-энтропия" (см. ниже). Грубо говоря, она переводит выходные значения нейросети в вероятности.

Предположим, что у нейросети 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$ и на выходе мы получаем логиты – строго положительные числа, сумма которых равна 1.

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

Наша задача классификаици организована так, что сеть возвращает нам либо [1, 0], либо [0, 1]. Почему так?

Представьте себе, что нейросеть должна вернуть вам номер класса, один из десяти. Если нейросеть ошибается и вместо 1 возвращает 2, MSE вернет нам ошибку (2-1)=1. Если нейросеть ошибается и вместо 1 возвращает 10, MSE вернет нам ошибку (10-1)=9, хотя эта ошибка в общем не является более грубой ошибкой, чем все остальные. Нужно основываться на чем-то другом. Кодирование one hot позволяет уйти от сравнения номеров классов и сравнивать распределение "уверенности" сети в том или ином классе.

Но в обучающей выборке значения выходной переменой у нас всё-таки скалярные: true и false. Функция 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(x, y) = Flux.crossentropy( model( x ), y )

Как уже было сказано ваше, есть правило – не использовать средний квадрат ошибки (MSE) в задачах классификации. Нейросети с ним учатся очень медленно, особенно если классификация многоклассовая. Что мы делаем взамен?

Если задача классификации организована так, как у нас в примере, то в качестве функции потерь часто используют кросс-энтропию (crossentropy), или ее бинарный вариант (binarycrossentropy) если класса только два. Иногда из нейросети исключают операцию softmax ради ускорения ее работы, тогда можно указать чтобы она выполнялась в функции потерь, указав logitcrossentropy или logitbinarycrossentropy.

accuracy(x, y) = mean( Flux.onecold( model( x )) .== Flux.onecold( y ))

Мы сохраняем точность прогноза (accuracy) – процент правильно угаданных значений. Функция onecold выполняет обратную операцию по отношению к onehot. Операция onehot кодирования находит в векторе наибольший элемент и представляет его числом 1 в выходном векторе, где все остальные позиции равняются 0. В свою очередь, операция onecold находит наибольший элемент во входном векторе и выдает на выходе одно значение – соответствующую этому элементу метку класса (либо порядковое число, если метки не заданы).

Эту функцию можно определить гораздо проще, но высокоуровневые функции обычно позволяют избежать множества ошибок или, по крайней мере, получить более ценные сообщения об ошибке.

Не каждый запуск приводит к хорошему результату, поэтому в начале этого примера мы установили некоторый конкретный seed. Полезно бывает в автоматическом режиме обучить несколько моделей с разной инициализацией и выбрать наилучшую. Либо, если обучение планируется производить очень часто и на немного различающейся выборке (как в случае цифрового двойника), то лучше потратить больше времени на создание более устойчивой процедуры обучения. Например, настроить синусоидальное управление скоростью обучения или добавить батч-нормализацию.

Заключение

Мы обучили нейросеть для классификации и расположили ее на холсте в качестве еще одного блока внутри модели.

Код процедуры обучения очень лаконичен, его можно сократить до 7 строчек. Код нейросети внутри блока был сгенерирован автоматически.

Изученный нами код для обучения нейросети имеет не слишком много "гиперпараметров" (параметров, настраиваемых проектировщиком), их можно перебирать вручную, а экспрессивная способность многослойной нейросети очень высока.

Блоки, использованные в примере