Глубокое обучение с Julia и Flux: 60-минутный краткий курс

Это краткое введение во Flux, в общих чертах повторяющее руководство PyTorch. В нем представлены основы программирования на Julia и работы с Zygote, фреймворком автоматического дифференцирования с преобразованием исходного текста в исходный код в Julia. Мы используем эти инструменты для построения простейшей нейронной сети.

Массивы

Отправной точкой для всех моделей является тип Array (в других фреймворках иногда называемый Tensor). Это попросту список чисел, которые могут быть выстроены в определенной форме, например квадратной. Давайте напишем массив с тремя элементами.

x = [1, 2, 3]

А так создается матрица — квадратный массив с четырьмя элементами.

x = [1 2; 3 4]

Часто приходится работать с массивами из тысяч элементов, но записывать каждый из них вручную было бы слишком долго. Вот как можно создать массив из 5×3 = 15 элементов, каждый из которых — это случайное число от нуля до единицы.

x = rand(5, 3)

Есть несколько таких функций: попробуйте изменить rand на ones, zeros или randn и посмотрите, что произойдет.

По умолчанию числа хранятся в Julia в формате высокой точности, который называется Float64. При машинном обучении столько цифр не нужно, и достаточно использовать формат Float32. Но можно и, наоборот, увеличить точность с помощью BigFloat.

x = rand(BigFloat, 5, 3)

x = rand(Float32, 5, 3)

Мы можем узнать, сколько элементов в массиве.

length(x)

Либо узнать, какого он размера.

size(x)

Иногда нужно обратиться к отдельным элементам массива.

x

x[2, 3]

Это означает получение элемента во второй строке третьем столбце. Мы также можем получить элементы из каждой строки в третьем столбце.

x[:, 3]

Массивы можно складывать и вычитать: при этом складываются или вычитаются все элементы массивов.

x + x

x - x

Julia поддерживает возможность, которая называется трансляцией, посредством точечного синтаксиса (.). Она позволяет заполнить большой массив массивом меньшего размера (или отдельным числом).

x .+ 1

Вот так в Julia можно заполнить вектором-столбцом 1:5 все строки массива.

zeros(5,5) .+ (1:5)

Синтаксис x' служит для транспонирования столбца 1:5 в эквивалентную строку, которой будут заполнены все столбцы.

zeros(5,5) .+ (1:5)'

Таким образом можно построить таблицу умножения.

(1:5) .* (1:5)'

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

W = randn(5, 10)
x = rand(10)
W * x

В Julia возможности работы с массивами очень широки. Узнать о них больше можно здесь.

Массивы CUDA

Возможности CUDA предоставляются отдельно в пакете CUDA. Если у вас есть GPU и CUDA, вы можете получить его, выполнив в REPL или IJulia команду ] add CUDA.

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

using CUDA
x = cu(rand(5, 3))

Автоматическое дифференцирование

В школе вы, вероятно, учились находить производные. Мы начнем с простейшей математической функции:

f(x) = 3x^2 + 2x + 1

f(5)

В простых случаях определить градиент достаточно легко вручную — в данном случае это 6x+2. Но эту работу за нас может сделать Flux!

using Flux: gradient

df(x) = gradient(f, x)[1]

df(5)

Можно попробовать использовать разные входные значения, чтобы убедиться в том, что результат соответствует выражению 6x+2. Можно и повторить операцию (однако вторая производная будет попросту 6).

ddf(x) = gradient(df, x)[1]

ddf(5)

Механизм автоматического дифференцирования Flux может обработать любой предоставленный код Julia, включая циклы, рекурсию и пользовательские слои, при условии, что вызываемые математические функции поддаются дифференцированию. Например, мы можем продифференцировать аппроксимацию Тейлора для функции sin.

mysin(x) = sum((-1)^k*x^(1+2k)/factorial(1+2k) for k in 0:5)

x = 0.5

mysin(x), gradient(mysin, x)

sin(x), cos(x)

Как и следовало ожидать, вычисленная производная очень близка к cos(x).

Ситуация становится любопытнее в случае с функциями, которые принимают на входе массивы, а не отдельные числа. Например, вот функция, принимающая матрицу и два вектора (определение само по себе произвольное):

myloss(W, b, x) = sum(W * x .+ b)

W = randn(3, 5)
b = zeros(3)
x = rand(5)

gradient(myloss, W, b, x)

Теперь мы получаем градиенты для каждого из входных объектов W, b и x, что удобно при обучении моделей.

Так как модели машинного обучения могут содержать сотни параметров, Flux позволяет записывать функцию gradient немного иначе. Мы можем пометить массивы словом param, чтобы указать, что нужны их производные. W и b представляют соответственно вес и смещение.

using Flux: params

W = randn(3, 5)
b = zeros(3)
x = rand(5)

y(x) = sum(W * x .+ b)

grads = gradient(()->y(x), params([W, b]))

grads[W], grads[b]

Теперь мы можем получить градиенты W и b непосредственно по этим параметрам.

Это оказывается удобно при работе со слоями. Слой — это просто удобный контейнер для некоторых параметров. Например, Dense выполняет линейное преобразование.

using Flux

m = Dense(10, 5)

x = rand(Float32, 10)

Параметры любого слоя или модели с параметрами можно легко получить с помощью params.

params(m)

Это позволяет очень легко вычислить градиент для всех параметров сети, даже если их много.

x = rand(Float32, 10)
m = Chain(Dense(10, 5, relu), Dense(5, 2), softmax)
l(x) = sum(Flux.crossentropy(m(x), [0.5, 0.5]))
grads = gradient(params(m)) do
    l(x)
end
for p in params(m)
    println(grads[p])
end

Слои использовать необязательно, но они могут быть удобны для множества простых моделей и быстрой итерации.

Следующий шаг — обновление весов и выполнение оптимизации. Как вам, возможно, известно, градиентный спуск — это простой алгоритм, который принимает веса и выполняет шаги с использованием скорости обучения и градиентов. weights = weights - learning_rate * gradient.

using Flux.Optimise: update!, Descent
η = 0.1
for p in params(m)
  update!(p, -η * grads[p])
end

Хотя такой способ обновления весов допустим, по мере усложнения алгоритмов ситуация становится все более запутанной.

В состав Flux входит набор готовых оптимизаторов, но можно и достаточно легко создавать собственные. Достаточно указать скорость обучения η:

opt = Descent(0.01)

Обучение (Training) сети сводится к многократному перебору набора данных с выполнением этих шагов по порядку. Чтобы быстро продемонстрировать реализацию, давайте обучим сеть прогнозировать значение 0.5 для каждого входного набора из 10 чисел плавающей запятой. С этой целью во Flux определена функция train!.

data, labels = rand(10, 100), fill(0.5, 2, 100)
loss(x, y) = sum(Flux.crossentropy(m(x), y))
Flux.train!(loss, params(m), [(data,labels)], opt)

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

  for d in training_set # предполагается, что d имеет формат (данные, метки)
    # наша замечательная логика
    gs = gradient(params(m)) do #m — это наша модель
      l = loss(d...)
    end
    update!(opt, params(m), gs)
  end

Обучение классификатора

Чтобы лучше разобраться в процессе, будет полезно посмотреть, как работает реальный классификатор. CIFAR10 — это набор данных из 50 тысяч небольших обучающих изображений, разделенных на 10 классов.

Мы выполним по порядку следующие действия:

  • загрузим обучающий и проверочный наборы данных CIFAR10;

  • определим сверточную нейронную сеть;

  • определим функцию потерь;

  • обучим сеть на основе обучающих данных;

  • протестируем сеть на основе проверочных данных.

Загрузка набора данных

using Statistics
using Flux, Flux.Optimise
using MLDatasets: CIFAR10
using Images.ImageCore
using Flux: onehotbatch, onecold
using Base.Iterators: partition
using CUDA

Это изображение дает представление о том, с чем нам предстоит работать.

title
train_x, train_y = CIFAR10.traindata(Float32)
labels = onehotbatch(train_y, 0:9)

train_x содержит 50 000 изображений, преобразованных в массивы размером 32 X 32 X 3, у которых третье измерение — это три канала (R, G, B). Давайте рассмотрим случайное изображение из train_x. Для этого необходимо переставить измерения так, чтобы получился массив 3 X 32 X 32, и с помощью colorview преобразовать его обратно в изображение.

using Plots
image(x) = colorview(RGB, permutedims(x, (3, 2, 1)))
plot(image(train_x[:,:,:,rand(1:end)]))

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

Первые 49 тысяч изображений (в пакетах по 1000) будут обучающим набором, а остальные предназначены для валидации. С помощью функции partition можно легко разделить набор на последовательность частей (в данном случае по 1000 элементов).

train = ([(train_x[:,:,:,i], labels[:,i]) for i in partition(1:49000, 1000)]) |> gpu
valset = 49001:50000
valX = train_x[:,:,:,valset] |> gpu
valY = labels[:, valset] |> gpu

Определение классификатора

Теперь можно определить сверточную нейронную сеть (CNN).

Сверточная нейронная сеть предполагает определение ядра, которое продвигается по матрице для создания промежуточного представления, из которого извлекаются признаки. При погружении в более глубокие слои создаются признаки более высокого порядка, благодаря чему этот метод подходит для изображений, так как класс, к которому они относятся, определяется на основе структуры объекта.

m = Chain(
  Conv((5,5), 3=>16, relu),
  MaxPool((2,2)),
  Conv((5,5), 16=>8, relu),
  MaxPool((2,2)),
  x -> reshape(x, :, size(x, 4)),
  Dense(200, 120),
  Dense(120, 84),
  Dense(84, 10),
  softmax) |> gpu

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

using Flux: crossentropy, Momentum

loss(x, y) = sum(crossentropy(m(x), y))
opt = Momentum(0.01)

Мы можем начать составлять цикл обучения, в котором будут отслеживаться базовые показатели точности модели. Для этого можно определить функцию accuracy.

accuracy(x, y) = mean(onecold(m(x), 0:9) .== onecold(y, 0:9))

Обучение классификатора

На этапе обучения выполняются операции, которые мы определили ранее, и проверяются возможности сети. Набор данных будет циклически обработан 10 раз, входные данные будут подаваться в нейронную сеть и оптимизироваться.

epochs = 10

for epoch = 1:epochs
  for d in train
    gs = gradient(params(m)) do
      l = loss(d...)
    end
    update!(opt, params(m), gs)
  end
  @show accuracy(valX, valY)
end

Представление процедуры обучения в развернутом виде позволяет понять, как обучалась сеть. Так можно поступать с небольшими написанными собственноручно сетями, которые обучались недолго.

Обучение на GPU

Функция gpu, которая местами встречается в этом коде, предписывает Flux перенести объекты на доступное устройство GPU и провести обучение на нем. Никаких лишних усилий! Один и тот же код будет работать на любом оборудовании с небольшими аннотациями.

Тестирование сети

Мы обучили сеть на основе 100 проходов по обучающему набору данных. Но нужно убедиться в том, что сеть хоть чему-нибудь научилась.

Мы проверим это путем прогнозирования метки класса, выдаваемой нейронной сетью, и ее сверки с эталоном. Если прогноз верен, образец добавляется в список правильных прогнозов. Проверка будет проводиться на еще не использовавшейся части данных.

Давайте приступим. Сначала обработаем этот набор точно так же, как обучающий.

test_x, test_y = CIFAR10.testdata(Float32)
test_labels = onehotbatch(test_y, 0:9)

test = gpu.([(test_x[:,:,:,i], test_labels[:,i]) for i in partition(1:10000, 1000)])

Затем отобразим изображение из проверочного набора.

plot(image(test_x[:,:,:,rand(1:end)]))

Выходные данные представляют собой энергии для 10 классов. Чем выше энергия класса, тем вероятнее, по мнению сети, изображение относится к данному классу. Каждый столбец соответствует выходным данным для одного изображения: 10 чисел с плавающей запятой в столбце — это энергии.

Давайте посмотрим, насколько успешно модель справилась с задачей.

ids = rand(1:10000, 5)
rand_test = test_x[:,:,:,ids] |> gpu
rand_truth = test_y[ids]
m(rand_test)

Это очень похоже на ожидаемые результаты. На этом этапе желательно проверить, как хорошо работает сеть на основе заранее приготовленных новых данных.

accuracy(test[1]...)

Результат гораздо лучше, чем шанс случайного попадания, установленный равным 10 % (так как классов всего 10), и весьма неплох для такой небольшой сети.

Давайте посмотрим, как сеть справилась с каждым из классов по отдельности.

class_correct = zeros(10)
class_total = zeros(10)
for i in 1:10
  preds = m(test[i][1])
  lab = test[i][2]
  for j = 1:1000
    pred_class = findmax(preds[:, j])[2]
    actual_class = findmax(lab[:, j])[2]
    if pred_class == actual_class
      class_correct[pred_class] += 1
    end
    class_total[actual_class] += 1
  end
end

class_correct ./ class_total

Распределение выглядит неплохо, но для некоторых классов результат гораздо лучше, чем для других. С чем это связано?

Впервые опубликовано на сайте fluxml.ai 15 ноября 2020 г. Авторы: Сасват Дас (Saswat Das), Майк Иннес (Mike Innes), Эндрю Динхобль (Andrew Dinhobl), Игор Каналли (Ygor Canalli), Судханшу Агравал (Sudhanshu Agrawal), Жуан Фелипе Сантос (João Felipe Santos).