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

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

  • Модели глубокого обучения композиционны. Функциональное программирование - это построение цепочек функций высшего порядка для работы с простыми структурами данных. Нейронные сети спроектированы таким же образом, объединяя преобразования функций от одного уровня к другому для работы с простой матрицей входных данных. Фактически, весь процесс глубокого обучения можно рассматривать как оптимизацию набора составных функций, то есть сами модели по своей сути функциональны.
  • Компоненты глубокого обучения неизменяемы. Когда функции работают с входными данными, данные не изменяются, выводится и передается новый набор значений. Более того, когда веса обновляются, их не нужно «изменять» - их можно просто заменить новым значением. Теоретически обновления весов могут применяться в любом порядке (т.е. они не зависят друг от друга), поэтому нет необходимости отслеживать последовательное изменяемое состояние.
  • Функциональное программирование предлагает простой параллелизм. Самое главное, что чистые и компонуемые функции легко распараллеливать. Параллелизм означает большую скорость и большую вычислительную мощность. Функциональное программирование обеспечивает параллелизм и параллелизм практически бесплатно, что значительно упрощает работу с большими распределенными моделями при глубоком обучении.

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

Основы Clojure

Прежде чем мы продолжим наше руководство по Cortex, я хочу познакомить вас с некоторыми основами Clojure. Clojure - это функциональный язык программирования, который действительно хорош в двух вещах: параллелизм и обработка данных. К счастью для нас, обе эти вещи невероятно полезны для машинного обучения. Фактически, одна из основных причин, по которой мы используем Clojure для машинного обучения, заключается в том, что повседневная работа по подготовке наборов данных для обучения (манипулирование данными, обработка и т. Д.) Может легко перевесить работу по реализации алгоритмов, особенно когда у нас есть солидная библиотека, такая как Cortex для обучения. Используя Clojure и .edn (вместо C ++ и protobuf), мы можем повысить эффективность и скорость проектов ML.

Для более глубокого знакомства с языком ознакомьтесь с руководством сообщества здесь.

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

(+ 2 3)          ; => 5
(if false 1 0)   ; => 0

Существует 4 базовых структуры данных коллекции: векторы, списки, хэш-карты и наборы. Запятые рассматриваются как пробелы, поэтому их обычно опускают.

[1 2 3]            ; vector (ordered)
'(1 2 3)           ; list (ordered)
{:a 1 :b 2 :c 3}   ; hashmap or map (unordered)
#{1 2 3}           ; set (unordered, unique values)

Одиночная кавычка перед списком просто предотвращает его оценку как выражение.

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

(count [a b c])              ; => 3
(range 5)                    ; => (0 1 2 3 4)
(take 2 (drop 5 (range 10))) ; => (5 6)
(:b {:a 1 :b 2 :c 3})        ; use keyword as function => 2
(map inc [1 2 3])            ; map and increment => (2 3 4)
(filter even? (range 5))     ; filter collection based off predicate => (0 2 4)
(reduce + [1 2 3 4])         ; apply + to first two elements, then apply + to that result and the 3rd element, and so forth => 10

Конечно, мы также можем писать наши собственные функции на Clojure, используя defn. Определения функций Clojure следуют форме (defn fn-name [params*] expressions) и всегда возвращают значение последнего выражения в теле.

(defn add2
  [x]
  (+ x 2))
(add2 5)     ; => 7

let выражения создают и связывают переменные в пределах лексической области действия «let». То есть в выражении (let [a 4] (...)) переменная «a» принимает значение 4 внутри (и только внутри) внутренних скобок. Эти переменные называются «местными».

(defn square-and-add
  [a b]
  (let [a-squared (* a a)
        b-squared (* b b)]
    (+ a-squared b-squared)))
(square-and-add 3 4)       ; => 25

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

(fn [x] (* 5 x))          ; anonymous function
#(* 5 %)                  ; equivalent anonymous function, where the % represents the function's argument
(map #(* 5 %) [1 2 3])    ; => (5 10 15)

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

Кора

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

Предварительная обработка данных

В нашем наборе данных будут данные об обнаружении мошенничества с кредитными картами, предоставленные Kaggle здесь. Оказывается, этот набор данных невероятно несбалансирован и содержит только 492 положительных случая мошенничества из 284 807. Это 0,172%. Позже это вызовет у нас проблемы, но сначала давайте просто взглянем на данные и посмотрим, как работает модель.

Чтобы обеспечить анонимность личных данных, все исходные функции, кроме «времени» и «суммы», уже преобразованы в компоненты PCA (где каждая запись представляет новую переменную, которая содержит наиболее актуальную информацию из необработанных данных). Небольшое исследование данных покажет, что первая переменная «время» довольно неинформативна, поэтому мы опустим ее по мере чтения данных. Вот как выглядит наш исходный код:

Нейронные сети Cortex ожидают входных данных в виде карт, так что каждая карта представляет одну помеченную точку данных. Например, набор данных классификации может выглядеть как [{:data [12 10 38] :label “cat”} {:data [20 39 3] :label “dog“} ... ]. В нашей функции создания набора данных мы читаем файл данных csv, назначаем все столбцы, кроме последнего, как «данные» (или функции), а последний столбец назначаем метками. В процессе мы превращаем метки в горячие векторы (например, [0 1 0 0]) на основе класса классификации, потому что последний слой softmax нашей нейронной сети возвращает вектор вероятностей классов, а не фактическую метку. Наконец, мы создаем карту из этих двух переменных и возвращаем ее как набор данных.

Описание модели

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

network-description - вектор слоев нейронной сети. Наша модель состоит из:

  • входной слой
  • полносвязный (линейный) слой с функцией активации ReLU
  • слой отсева
  • еще один полностью связанный слой ReLU
  • выходной слой размером 2, который передается через функцию softmax.

И в первом, и в последнем слоях нам нужно указать :id. Этот идентификатор относится к ключу в карте данных, на которую должна смотреть наша сеть. (Напомним, что карта данных выглядит как {:data [...] :label [...]}). Для нашего входного слоя мы передаем :data id, чтобы модель получила обучающие данные для своих прямых проходов. В нашем последнем сетевом уровне мы предоставляем :label как :id, поэтому модель может использовать истинную метку для вычисления нашей ошибки.

Обучение и оценка

Здесь становится немного сложнее. Сама функция обучения на самом деле не так уж и сложна - Cortex предоставляет хороший высокоуровневый вызов для обучения, поэтому все, что нам нужно сделать, это передать наши параметры (сеть, набор данных для обучения и тестирования и т. Д.). Единственное предостережение заключается в том, что система ожидает фактически «бесконечного» набора данных для обучения, но Cortex предоставляет функцию (infinite-class-balanced-dataset), которая поможет нам преобразовать его.

Сложная часть - это f1-test-fn. Вот в чем дело: во время обучения функция train-n ожидает получить :test-fn, который оценивает, насколько хорошо работает модель, и определяет, следует ли сохранять ее как «лучшую сеть». Существует функция тестирования по умолчанию, которая оценивает потери кросс-энтропии, но это значение потерь не так просто интерпретировать, и оно не очень хорошо подходит для нашего несбалансированного набора данных. Чтобы обойти эту проблему, мы напишем нашу собственную тестовую функцию.

Но как мы собираемся протестировать производительность модели? Стандартной метрикой в ​​задачах классификации является точность, но в таком несбалансированном наборе данных, как наш, точность - довольно бесполезная метрика. Поскольку положительные (мошеннические) примеры составляют всего 0,172% нашего набора данных, даже модель, которая прогнозирует исключительно отрицательные примеры, может достичь точности 99,828%. 99,828% - это чертовски хорошая точность, но если Amazon действительно использовала эту модель, мы все можем обратиться к преступной жизни и мошенничеству с кредитными картами.

К счастью, Amazon не использует такую ​​модель, и мы тоже. Гораздо более понятный набор показателей - это точность, отзывчивость и оценка F1 (или, в более общем смысле, F-бета).

С точки зрения непрофессионала, точность задает вопрос: «Из всех примеров, которые, как я предположил, были положительными, какая доля была на самом деле положительной?» и напомним, задается вопрос: «Из всех примеров, которые были действительно положительными, какую долю я правильно предположил как положительных?»

Оценка F-beta (обобщение традиционной оценки F1) представляет собой средневзвешенное значение точности и запоминания, также измеряемое по шкале от 0 до 1:

Когда бета = 1, мы получаем стандартную меру F1 2 * (precision * recall) / (precision + recall). В общем, бета показывает, во сколько раз важнее помнить, чем точность. Для нашей модели обнаружения мошенничества мы будем использовать показатель F1 в качестве рекорда для отслеживания, но мы также будем регистрировать показатели точности и отзыва, чтобы проверить баланс. Это наш f1-test-fn:

Функция запускает текущую сеть на тестовом наборе, вычисляет оценку F1 и соответствующим образом обновляет / сохраняет сеть. Он также распечатывает наши оценочные показатели для каждой эпохи. Если мы сейчас запустим (train) в REPL, мы получим высокий балл, который выглядит примерно так:

Epoch: 30
Precision: 0.2515923566878981
Recall: 0.9186046511627907
F1: 0.395

Ха-ха. Это досадно плохо.

Увеличение данных

Вот в чем проблема. Помните, как я сказал, что наш крайне несбалансированный набор данных вызовет у нас проблемы в дальнейшем? В настоящее время модель не имеет достаточного количества положительных примеров, на которых можно было бы учиться. Когда мы вызываем experiment-util/infinite-class-balanced-dataset в нашей функции обучения, мы фактически создаем сотни копий каждого положительного обучающего экземпляра, чтобы сбалансировать набор данных. В результате модель эффективно запоминает эти значения функций и не изучает различия между классами.

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

Вот наш код для увеличения данных:

augment-train-ds берет наш исходный набор данных поезда, вычисляет количество дополнений, которые необходимо сделать, чтобы достичь баланса классов 50/50, и применяет эти дополнения к нашим существующим выборкам, добавляя вектор случайного шума (add-rand-variance) на основе допустимой дисперсии (get-scaled-variances) . В конце концов, мы объединяем расширенные примеры обратно в исходный набор данных и возвращаем сбалансированный набор данных.

Во время обучения модель будет видеть нереально большое количество положительных примеров, в то время как набор тестов по-прежнему будет содержать только 0,172% положительных примеров. В результате, хотя модель может лучше изучить различия между двумя классами, она будет переоценивать положительные примеры во время тестирования. Чтобы исправить это, нам может потребоваться более высокий порог уверенности для прогнозирования «положительного результата» во время тестирования. Другими словами, вместо того, чтобы требовать, чтобы модель была по крайней мере на 50% уверена в том, что пример является положительным, чтобы классифицировать его как таковой, мы можем потребовать, чтобы он был уверен как минимум на 70%. После некоторого тестирования я обнаружил, что оптимальное значение должно быть установлено на 90%. Код для этого можно найти в функции vec->label в исходном коде, и он вызывается в строке 31 файла f1-test-fn.

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

Epoch: 25
Precision: 0.8658536585365854
Recall: 0.8255813953488372
F1: 0.8452380952380953

Намного лучше!

Заключение

Как всегда, модель еще можно улучшить. Вот несколько идей для следующих шагов:

  • Все ли функции PCA информативны? Взгляните на распределение значений для положительных и отрицательных примеров по функциям и отбросьте все функции, которые не помогают различать два класса.
  • Существуют ли другие архитектуры нейронных сетей, функции активации и т. Д., Которые работают лучше?
  • Существуют ли другие методы увеличения данных, которые будут работать лучше?
  • Как производительность модели в Cortex сравнивается с Keras / Tensorflow / Theano / Caffe?

Исходный код проекта можно найти полностью здесь. Я рекомендую вам попробовать следующие шаги, протестировать новые наборы данных и изучить различные сетевые архитектуры (у нас есть отличный пример классификации изображений для справки по сверточным сетям). Cortex продвигается к выпуску 1.0, поэтому, если у вас есть какие-либо мысли, рекомендации или отзывы, обязательно сообщите нам об этом. Удачного взлома!