Если вы хотите понять, как работает современная библиотека машинного обучения, нет лучшей альтернативы, чем Flux.

Я мог бы писать о более известных библиотеках машинного обучения, таких как TensorFlow, Keras или PyTorch. Но преимущество разговора о Flux заключается в том, что это довольно небольшая и современная библиотека машинного обучения.

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

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

Может показаться загадочным, как Flux может это делать, а другие библиотеки - нет. На самом деле волшебство - это просто язык программирования Julia. Именно Джулия действительно делает всю тяжелую работу и позволяет писать небольшие библиотеки машинного обучения.

Что делает библиотека машинного обучения?

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

Он должен предоставлять некоторые возможности для выполнения следующего:

  1. Определите математическую модель и параметры этой модели.
  2. Способ определения функции потерь / ошибок / затрат. Функция потерь вычисляет разницу между желаемым результатом модели и фактическим прогнозом, сделанным моделью.
  3. Вычислить градиент функции потерь по отношению к ее параметрам. Параметры используются для определения модели.
  4. Способ определения различных оптимизаторов или стратегий обучения.
  5. Обучающая функция, которая пытается минимизировать функцию потерь, настраивая параметры модели с помощью вычисленного градиента и оптимизатора.

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

Что такое математическая модель?

Математическая модель - это модель, определенная в математике или в нашем случае в коде. Назначение во многом такое же, как и у физической модели.

Чтобы описать концепцию или идею моделей, мне нравится использовать пример разработки Palm Pilot. Это был КПК, и для тех из вас, кто слишком молод, чтобы знать, что такое КПК, вы можете думать о нем как о смартфоне без возможности позвонить кому-либо.

Одна из первых моделей, сделанных из него компанией Palm, была просто деревянным блоком. Как это подходящая модель? Он ничего не может или может?

У них было несколько деревянных блоков разной формы. Они хотели проверить, насколько хорошо он помещается в кармане джинсов. Сотрудники целый день ходили с одним из таких бревен в кармане.

Идея заключалась в том, чтобы определить оптимальную форму и размер. Форма и размер, удобные для ношения в кармане.

Так что это ключевой момент, о котором следует помнить при работе с моделями. Вы включаете в свою модель только те свойства, которые помогают вам ответить на вопрос, на который вы хотите получить ответ. В данном случае вопрос был: «Комфортно ли носить весь день в кармане?»

Чтобы ответить на этот вопрос, нам нужны только внешние размеры. Математические модели такие же. Мы можем попытаться смоделировать солнечную систему, например, Тогда возникает вопрос, на какие вопросы мы хотим получить ответы об этой солнечной системе?

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

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

Что такое модель в движении?

В библиотеке машинного обучения Flux модель - это просто функция, которая принимает один ввод. Входными данными является матрица, которую необходимо организовать определенным образом. Каждая строка представляет собой отдельное свойство. В машинном обучении мы называем эти свойства функциями. Другой способ взглянуть на это - смоделировать функцию с несколькими входами, рассматривая каждую строку как отдельный входной аргумент.

Вместо того, чтобы оценивать модель 3 раза с тремя разными входными данными, вы можете просто оценить модель, объединив их по горизонтали. Таким образом, в основном каждый столбец представляет собой отдельный вход.

Вот очень простая модель, определенная в Flux:

W = rand(2, 5)
b = rand(2)

model(x) = W*x .+ b

Конечно, настоящие модели сложнее. Вот пример определяемой глубокой нейронной сети (DNN):

model = Chain(
      Dense(784, 64, relu),
      Dense(64, 64, relu),
      Dense(32, 10)
    )

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

Параметры модели

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

Flux.train!(loss, params, data, optimizer)

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

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

params = Flux.params(W, b)
Flux.train!(loss, params, data, optimizer)

При использовании готовых строительных блоков, таких как Dense, для создания более сложной модели, вы можете просто использовать саму модель в качестве входных данных для Flux.params, чтобы получить параметры модели.

params = Flux.params(model)

Я не буду здесь рассказывать, как мы делаем это с нуля с помощью наших нестандартных моделей. Для нестандартных моделей вы все равно знаете параметры.

Давайте посмотрим, как Flux реализует параметры модели.

Реализация Flux параметров модели

Params содержит массив order, содержащий список добавленных к нему параметров. член params в основном используется для проверки того, что параметр (обычно объект массива) еще не был добавлен к объекту Params.

struct Params
 order::Buffer{Any, Vector{Any}}
 params::IdSet{Any}
 Params() = new(Buffer([], false), IdSet())
end

Вы можете видеть, что добавление значения x в params означает просто добавление его в массив order, который поддерживает добавленные элементы порядка. params - это набор, который существует только для того, чтобы не добавлять один и тот же элемент дважды в массив order.

function Base.push!(ps::Params, x)
 if !(x in ps.params)
   push!(ps.order, x)
   push!(ps.params, x)
 end
 return ps
end

Это более типичное использование Params. Вы создаете Params объект, в который добавляете элементы.

Params(xs) = push!(Params(), xs...)

Мы устанавливаем параметры из Flux с помощью Flux.params вызова

function params(m...)
  ps = Params()
  params!(ps, m)
  return ps
end

Но на самом деле это не сильно отличается от Zygote.Params.

Обновление весов и оптимизаторов

Параметры нашей модели обычно называют весами в машинном обучении. Стратегию обучения часто называют оптимизатором.

Причина в том, что мы действительно пытаемся оптимизировать функцию. Итак, оптимизатор - это стратегия оптимизации функции. В нашем случае мы пытаемся оптимизировать функцию потерь.

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

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

function update!(opt, x, x̄)
 x .-= apply!(opt, x, x̄)
end

Вы можете видеть, что эта функция принимает оптимизатор opt в качестве аргумента, некоторые входные данные, один или несколько весов x и некоторое изменение (градиент) , которое приведет к увеличению значения функции. Мы хотим получить от этого негатив, так как хотим, чтобы он уменьшился.

В Flux каждый оптимизатор добавляет функцию apply!. Давайте посмотрим, как работает градиентный спуск, например Градиентный спуск основан на движении в противоположном направлении градиента со скоростью обучения η. Скорость обучения позволяет нам двигаться быстрее или медленнее к минимуму.

Это определяется следующим образом:

mutable struct Descent
  eta::Float64
end

function apply!(o::Descent, x, Δ)
  Δ .*= o.eta
end

Если мы встроим это приложение в update!, будет легче увидеть, как оно работает.

function update!(opt, x, x̄)
  x .-= (x̄ .*= opt.eta)
end

Но вам, вероятно, будет легче, если написать в две строки

function update!(opt, x, x̄)
  x̄ .*= opt.eta 
  x .-= x̄
end

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

function update!(opt, x, x̄)
  x .-= (x̄ .* opt.eta)
end

Теперь может показаться странным, почему apply! принимает вес / параметр x в качестве аргумента, когда он никогда не используется. Однако нам нужен общий интерфейс для оптимизаторов. Тот факт, что градиентный спуск не использует вес при вычислении значения обновления, не означает, что другие оптимизаторы не используют его. Например, оптимизатор Momentum использует его.

То, что мы до сих пор рассматривали, на самом деле является просто вспомогательными функциями. Функция update!, которую пользователь вызовет в Flux, определяется следующим образом.

function update!(opt, xs::Params, gs)
 for x in xs
   if gs[x] == nothing
       continue
   end
   update!(opt, x, gs[x])
 end
end

Здесь opt - это оптимизатор, такой как Descent, а xs - это параметры нашей модели, также известные как веса. gs - градиент функции потерь относительно этих параметров. Таким образом, gs выражает, как функция потерь увеличивается или уменьшается в значении, когда параметры увеличиваются или уменьшаются в значении.

Принцип обновления заключается в том, что мы перебираем каждый параметр x в модели. Мы ищем производную gs[x] функции потерь по этому параметру x.

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

Функция обучения

Обычно пользователь не вызывает функцию update! напрямую. Вместо этого update! вызывается обучающей функцией train!.

train! принимает loss(x, y) функцию, вычисляя разницу между тем, что наша модель предсказывает на основе входных x и ожидаемых выходных y.

Набор параметров ps, используемых нашей моделью типа Params. data = [(x1, y1), (x2, y2), ...] - это список пар входных и ожидаемых выходов. И x, и y будут массивами.

Затем у нас есть оптимизатор opt, который является объектом, для которого была определена функция apply!(opt, x, dx). Descent является примером такого типа.

Обратный вызов cb является необязательным и может быть вектором функций обратного вызова. Вот о чем вся третья строка cb = runall(cb). Он принимает массив функций и превращает его в одну функцию, последовательно вызывая каждую из этих функций. Если массива нет, он просто возвращает cb.

function train!(loss, ps, data, opt; cb = () -> ())
   ps = Params(ps)
   cb = runall(cb)
   for d in data
       gs = gradient(ps) do
           loss(d...)
       end
       update!(opt, ps, gs)
       cb()
   end
end

Давайте рассмотрим эту реализацию более подробно. Мы перебираем данные. В каждой итерации мы работаем с парой входных и ожидаемых выходных значений d = (x, y).

На каждой итерации параметры ps нашей модели изменяются с помощью update!(opt, ps, gs). Таким образом, на каждой итерации нам нужно пересчитывать градиенты, потому что модель изменилась. Вот почему цикл for начинается с вычисления градиента с помощью gs = gradient(ps). gs - это, по сути, словарь, использующий идентичность объекта параметров для сопоставления производной функции потерь по этому параметру.

Это реализовано с помощью IdDict. Мы не можем использовать обычный словарь, так как мы не хотим искать по значению параметра, а по его идентичности.

Итак, gs[x] дайте мне производную функции потерь по параметру x.

После обновления весов / параметров мы вызываем cb(), который является дополнительной функцией обратного вызова. Как уже упоминалось, это может быть несколько функций. Это дает пользователям возможность наблюдать за тренировочным процессом. Функция обратного вызова может, например, распечатать, как значение функции потерь изменяется на каждой итерации.

Заключительные замечания

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

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