Flux.jl на MNIST — Вариации на тему

Flux.jl — это стек машинного обучения, предлагающий легкие компоненты для создания моделей и их обучения. Используя набор данных MNIST, мы увидим, как легко создавать различные подходы для классификации этого набора данных, просто соединяя такие компоненты вместе.

Обзор

Flux.jl

Flux.jl — это пакет, написанный на 100% Юлией. Он нацелен на построение моделей, которые обычно обучаются с использованием итеративного подхода, основанного на автоматизированном дифференцировании. Наиболее распространенным классом такого рода моделей, вероятно, являются нейронные сети (NN), которые обучаются с использованием варианта алгоритма градиентного спуска (GD).

В отличие от некоторых других библиотек машинного обучения, которые состоят из готового к использованию «механизма» для создания и обучения моделей, Flux скорее предлагает набор легких кубиков лего, которые вы можете соединить вместе в соответствии с вашими конкретными потребностями.

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

Набор данных MNIST

Для этой цели мы будем использовать известный набор данных MNIST, который состоит из 70 000 изображений рукописных (и помеченных) цифр. 60 000 из этих изображений служат данными для обучения, а остальные 10 000 экземпляров будут использоваться для тестирования моделей. Каждое изображение состоит из 28x28 пикселей в оттенках серого.

Весь набор данных можно легко загрузить с помощью пакета MLDatasets:

Первое выражение (строка 3) автоматически выбирает 60 000 обучающих изображений с соответствующими метками и вариант в строке 4 с split = :test остальными для тестирования. Таким образом, train_x_raw представляет собой массив из 28x28x60000 элементов, а test_x_raw — массив из 28x28x10000 элементов после этой операции. Метки хранятся в train_y_raw и test_y_raw, которые представляют собой массивы размером 60 000 и 10 000 соответственно, содержащие целые числа от 0 до 9.

Функция convert2image позволяет нам отображать такое изображение. Мы получаем, например. 10-е обучающее изображение, вызвав convert2image(MNIST, train_x_raw[:,:,10]):

Соответствующая метка (4) находится в train_y_raw[10].

Данные, лежащие в основе этого изображения (в train_x_raw[:,:,10]), представляют собой просто матрицу 28x28 элементов, содержащую числа от 0 до 1, каждое из которых представляет оттенок серого.

Модели

Как указано выше, наша цель — создать различные модели, способные классифицировать такие изображения. т.е. входом для наших моделей будет изображение рукописной цифры (в виде матрицы 28x28), а выходом будет число от 0 до 9, говорящее нам, какую цифру содержит изображение.

Данные

К сожалению, НС не обрабатывают матрицы напрямую и не выводят цифры так, как мы хотим. Поэтому нам нужно немного адаптировать наши данные.

Ввод для NN должен быть вектором (столбцом), содержащим все значения. Поэтому нам нужно преобразовать наши матрицы 28x28 и расположить 28 столбцов каждого изображения друг над другом. Результатом является вектор из 784 элементов (28 x 28 = 784). Функция Flux flatten() делает именно это:

train_x = Flux.flatten(train_x_raw)
test_x  = Flux.flatten(test_x_raw)

Это приводит к массиву элементов 784x60000 и 784x10000 соответственно.

Вывод NN для такой задачи классификации обычно представляет собой так называемый однократный вектор. Это битовый вектор (в нашем случае с 10 элементами, поскольку у нас есть 10 разных классов), где ровно один бит равен 1, а все остальные биты равны 0. Элемент со значением 1 представляет соответствующий класс, т.е. если первый бит равен 1, это представляет «цифру 0», если 4-й бит равен 1, он представляет «цифру 3» и т. д.

Функция Flux onehotbatch() выполняет это преобразование для всего массива меток:

train_y = Flux.onehotbatch(train_y_raw, 0:9)
test_y  = Flux.onehotbatch(test_y_raw, 0:9)

Аргумент 0:9 сообщает о (возможном) диапазоне чисел, которые должны быть представлены результирующими однократными векторами. Результатом этих операторов является массив битов 10x60000 и 10x10000 соответственно.

Итак, наш адаптированный конвейер обработки для предсказания изображений выглядит так:

Примечание. В реальном приложении NN редко когда-либо создает «настоящий» однократный вектор. Вместо этого будет создан вектор, содержащий вероятности, и, если НС работает хорошо, одно из этих значений будет близко к 1, а все остальные значения будут близки к 0.

Многослойные сети персептрона

Мы хотим использовать модели так называемых многослойных персептронных сетей (MLP). Это «классические» нейронные сети, состоящие из нескольких слоев нейронов, где каждый нейрон одного слоя связан со всеми нейронами следующего слоя (также называемого полносвязные или плотные сети):

Различные MLP различаются по

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

Модели для классификации MNIST

В нашем случае количество входных значений в первом слое уже зафиксировано входными данными (до 784), а последний слой должен иметь 10 нейронов (что дает 10 выходных значений), потому что у нас 10 классов. Но мы свободны в выборе остальных характеристик наших моделей.

Для простоты я выбрал три модели, которые можно найти в других местах в Интернете: Одна из фантастических видео Гранта Сандерсона о нейронных сетях на его YouTube-канале 3Blue1Brown (здесь вы можете найти первую модель с ее замечательные визуализации), вторую (адаптированную) из этого углубления нашей темы и третью из Документации Flux.

Подробно у нас есть следующие модели (которые я назвал в соответствии с их основными характеристиками)

  • 4LS: четырехуровневая модель с 16 узлами во внутренних слоях и сигмоидной функцией активации.
  • 3LS: трехслойная модель с 60 узлами во внутренних слоях и сигмоидной функцией активации.
  • 2LR: двухслойная модель, использующая 32узла во внутреннем слое и функцию активации relu.

Примечание: я не считал входные и выходные данные отдельными слоями (как это делается в других местах), чтобы быть ближе к следующим определениям модели в Flux.

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

Третья модель (model2LR) использует функцию relu-активации только на первом слое. На втором уровне такая функция не указана. Это означает, что используется Flux-по умолчанию (идентификация). Как следствие, этот слой может выдавать значения в диапазоне [-∞, ∞], что нам не нужно. Поэтому к результатам применяется функция softmax, стандартизирующая их в диапазоне от 0 до 1 и гарантирующая, что их сумма равна 1.

Первые две модели, использующие sigmoid-функцию для активации, являются довольно «традиционными» NN, тогда как в настоящее время sigmoid в основном заменено на relu.

Тренировка с градиентным спуском

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

Функция стоимости

Чтобы измерить, насколько хорошо конкретный набор параметров подходит для этой цели, нам нужна так называемая функция стоимости. Эта функция стоимости C сравнивает прогнозы ŷ, сделанные с определенным набором параметров, с реальными классами y в обучении. данных и вычисляет на их основе показатель для этого «расстояния» (= C(ŷ, y)). т.е. чем меньше результат функции стоимости, тем лучше выбранные параметры. Итак, наша цель — найти набор параметров, который минимизирует функцию стоимости.

Типичные функции затрат, используемые в этом контексте:

mse обычно используется с моделями, которые имеют sigmoid-функцию для активации, тогда как ce обычно используется в сочетании с relu-функцией активации. Обоснование этих пар заключается в том, что в этих сочетаниях функция стоимости может быть гарантированно выпуклой, т. е. иметь ровно один минимум. Поэтому алгоритм нахождения (глобального) минимума не будет отвлекаться, возможно, на другие (локальные) минимумы.

При этом не буду скрывать, что наличие локальных минимумов в функции потерь не кажется серьезной проблемой на практике (что является некоторой теоретической загадкой), как LeCun et al. пишут в своей статье Обучение на основе градиента, применяемое к распознаванию документов» (протокол IEEE, ноябрь 1998 г.). Таким образом, другие пары также используются.

Градиентный спуск

Основная идея алгоритма градиентного спуска для обучения модели (т.е. для нахождения набора параметров модели, который минимизирует функцию стоимости C) заключается в следующем:

  1. Начните с произвольно выбранного набора параметров W₀
  2. Вычислите новый набор улучшенных параметров W₁, переместив небольшой шаг (α) в направлении, противоположном градиенту C в положении W₀ (∇C(W₀)):
    W₁ = W₀ – α ∇C(W₀)

Поскольку градиент C (вектор) в позиции W₀ указывает в направлении, где C является самым крутым, мы должны двигаться в противоположном направлении. чтобы добраться до минимума (поэтому минус в формуле).

Шаг 2 повторяется итеративно до тех пор, пока C не сойдется к минимуму (или пока у нас не будет набора или параметров Wi, которые обеспечивают достаточную точность для приложения, которое мы имеем в виду):

Размер шага α называется скоростью обучения. Он выбирается эвристически. Если он слишком велик, алгоритм может «перескочить». Если он слишком мал, алгоритм сходится очень медленно. На практике рекомендуется поэкспериментировать с такими значениями, как 0,1, 0,01 и 0,001, и адаптировать их в соответствии с результатами.

Реализация в Flux.jl

Итак, как реализовать эти концепции в Julia с помощью Flux.jl? Модели (model4LS, model3LS и model2LR) уже определены, а обучающие данные готовы к использованию (в train_x и train_y).

Осталось определить функцию стоимости (которую Flux называет функцией потерь) и реализовать алгоритм градиентного спуска. В основе каждой реализации GD в Flux лежит функция Flux.train!(). Он вызывается на каждой итерации GD и вычисляет (слегка) улучшенную версию параметров модели (как объяснялось выше). Следующие аргументы должны быть переданы в train!():

  • функция стоимостиloss
  • параметры моделиparams(Wiиз последнего шага итерации или исходные параметры при первом запуске)
  • данные обученияdatatrain_x и train_y)
  • так называемый оптимизатор opt; это формула, используемая для создания новой (улучшенной) версии параметров модели, как показано выше (Этап итерации алгоритма градиентного спуска). Приведенная выше формула является самым основным вариантом. В последние годы были разработаны более сложные варианты. Основное отличие от базового варианта в том, что они динамически адаптируют скорость обучения α на каждом шаге итерации.
    Помимо базового варианта, который мы получаем во Flux с Descent(α), мы применим одну из самых продвинутых версий: ADAM (= оценка ADaptive Moment), который доступен в Flux с ADAM(α).

Мы можем получить доступ к параметрам каждой модели, используя функцию Flux.params():

params4LS = Flux.params(model4LS)
params3LS = Flux.params(model3LS)
params2LR = Flux.params(model2LR)

Функции стоимости mse и ce также предопределены в Flux (как Flux.Losses.mse() и Flux.Losses.crossentropy()). Поскольку train!() ожидает функцию стоимости, в которую обучающие данные (train_x и train_y) могут передаваться напрямую, тогда как две предопределенные функции потерь ожидают предсказания модели, основанные на train_x в качестве их первого параметра, мы должны определить некоторые «переводы»:

loss4LSmse(x,y) = Flux.Losses.mse(model4LS(x),y)
loss4LSce(x,y)  = Flux.Losses.crossentropy(model4LS(x),y)
loss3LSmse(x,y) = Flux.Losses.mse(model3LS(x),y)
loss3LSce(x,y)  = Flux.Losses.crossentropy(model3LS(x),y)
loss2LRce(x,y)  = Flux.Losses.crossentropy(model2LR(x),y)

Поскольку функция стоимости mse плохо работает с NN, использующей relu, мы определяем для модели 2LR только функцию потерь, основанную на ce.

Теперь мы можем посмотреть, насколько велики потери наших моделей с их начальными (случайными) параметрами, вызывая эти функции. Например. loss4LSmse(x_train, y_train) предоставляет в моей среде значение 0,2304102. Но так как начальные параметры выбираются случайным образом, вы получите другое значение, если попробуете это на своем компьютере. Таким образом, это значение служит только для (относительного) сравнения, чтобы увидеть, насколько оно изменится после нескольких итераций GC.

Реализация вариантов градиентного спуска

Пакетный градиентный спуск

Теперь мы готовы реализовать первую версию GD, используя Julia и Flux.jl:

Наша первая версия градиентного спуска train_batch() принимает в качестве аргументов обучающие данные X и y, функцию потерь loss, оптимизатор opt, параметры модели params и количество итераций, которые мы хотим обучить (epochs). Эта реализация GD по существу представляет собой цикл for, вызывающий train!() на каждой итерации.

Поскольку train!() предполагает, что обучающие данные будут помещены в кортеж в массиве, мы подготавливаем эту структуру в строке 2. Вот и все!

Для обучения, например. нашей модели 4LS с использованием функции стоимости mse и стандартной формулы GD со скоростью обучения 0,1 на 100 итерациях, мы должны написать:

train_batch(train_x, train_y, loss4LSmse, 
            Descent(0.1), params4LS, 100)

После выполнения этого оператора мы должны увидеть улучшение значения потерь. В моей среде я получаю значение 0,12874180, вызывая loss4LSmse(x_train, y_train). Таким образом, мы могли сократить потери почти вдвое за эти 100 итераций.

Этот вариант GD называется пакетным градиентным спуском, так как на каждой итерации градиент функции потерь оценивается в полном обучающем наборе данных. т.е. все 60 000 изображений используются для вычисления этого значения (на каждой итерации!).

Очевидно, что это довольно дорого в вычислительном отношении! Поэтому были разработаны другие варианты GD.

Стохастический градиентный спуск

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

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

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

Реализация выглядит следующим образом:

Мини-пакетный градиентный спуск

Третий вариант GD — хороший компромисс между первыми двумя вариантами, которые мы видели. Он не работает ни со всем набором данных, ни только с одним экземпляром на каждой итерации, а выбирает случайную выборку, например. 100 экземпляров и вычисляет градиент на этой основе. Этот образец называется мини-пакет.

Размер этой мини-партии можно свободно выбирать. Поэтому для этой цели нам нужен дополнительный параметр batchsize:

Здесь мы реализовали простую стратегию извлечения мини-пакета: случайная позиция во всем наборе данных выбирается в строке 4, а затем мы извлекаем мини-пакет размером batchsize, начиная с этой позиции (строка 5+6).

Более продвинутой стратегией будет случайный выбор всех экземпляров мини-пакета. Этого можно добиться, используя Flux DataLoader.

Этот третий вариант GD, конечно, является наиболее общей реализацией, и мы можем выразить первые два варианта в терминах train_minibatch() следующим образом (поэтому нам на самом деле нужна только реализация мини-пакетного GD):

train_batch(X, y, loss, opt, params, epochs) = 
        train_minibatch(X, y, loss, opt, params, epochs, size(X)[2])
train_stochastic(X, y, loss, opt, params, epochs) = 
        train_minibatch(X, y, loss, opt, params, epochs, 1)

Заключение

Мы видели, как Flux.jl можно использовать для предварительной обработки данных и определения моделей. Кроме того, используя строительные блоки, такие как

  • функции потерь
  • оптимизаторы
  • train!()-функция

мы могли бы довольно легко реализовать различные варианты алгоритма GD.

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