Машинное обучение + REPL = ❤

Clojure - это LISP. Так что, будучи LISP, в нем много скобок. Теперь, когда у нас это не получилось, мы можем переходить к разговору о более серьезных вещах.

Почему именно Clojure?

Вы, вероятно, никогда не слышали о Clojure, не говоря уже о науке о данных и машинном обучении. Так почему вам было бы интересно использовать его для этих целей? Я скажу вам почему: чтобы сделать то, что имеет значение (данные), первоклассно!

В Clojure мы не имеем дело с классами, объектами и т. Д., Все - просто данные. И эти данные неизменны. Это означает, что если вы испортите свои преобразования, с данными все будет в порядке, и вам не придется начинать все заново.

Предыдущее - лишь одна из причин, выпадающих из головы, другой может быть JVM. Да, мы все в какой-то степени его ненавидим, но не заблуждайтесь: он разрабатывается с 1991 года и всегда был производственным классом. Вот почему компании по всему миру до сих пор запускают и разрабатывают программное обеспечение на JVM. Учитывая это, Clojure может быть принят даже самыми консервативными корпорациями, потому что они уже знают о нем.

Второй момент, на котором я хотел бы остановиться, - это REPL. Это не похоже на обычную языковую оболочку (например, Python REPL), которая обычно очень проста и раздражает, но у нее есть суперспособности! Было бы неплохо увидеть вживую, что происходит с этим сервисом в рабочем режиме? "Выполнено!" Хотели бы вы иметь службу построения графиков, которую можно было бы использовать как для производства, так и для экспериментов? "Выполнено!" У вас есть вложенные структуры данных, и было бы неплохо изучить их визуально, чтобы лучше понять их? "Выполнено!"

Это означает, что вам действительно не нужно что-то вроде записной книжки Jupyter, хотя, если вы чувствуете себя более комфортно в такой среде, есть варианты, которые работают без проблем: clojupyter - это ядро ​​Clojure для ноутбуков Jupyter, а Gorilla REPL - это родное решение для ноутбука Clojure.

Чтение данных

Если вы никогда не видели код Clojure или только начинаете, я советую взглянуть на Изучите Clojure за Y минут, чтобы просто иметь возможность следить за этим, тогда один из моих любимых источников, когда я начал учиться, - Clojure для смелых и правдивых . В любом случае я подробно объясню каждый шаг в коде, чтобы каждый мог без проблем следовать ему.

Это всего лишь введение, поэтому мы будем работать с печально известным набором данных Iris. Если у вас еще нет Leiningen, скачайте его и установите, он действительно прост в использовании и де-факто является инструментом сборки Clojure. Теперь вы можете создать новый скелет проекта из командной строки, запустив:

lein new clj-boost-demo
cd clj-boost-demo

У вас должна быть следующая структура каталогов:

.
├── CHANGELOG.md
├── doc
│   └── intro.md
├── LICENSE
├── project.clj
├── README.md
├── resources
├── src
│   └── clj_boost_demo
│       └── core.clj
└── test
└── clj_boost_demo
└── core_test.clj

Больше всего нас интересуют файлы project.clj, которые являются основой для правильной подготовки и сборки проекта Leiningen, src куда мы помещаем код для нашего приложения или библиотеки и resources куда мы помещаем наш CSV-файл Iris, который вы можете получить здесь ». Теперь мы можем определить project.clj файл, что является еще одним разумным аспектом Clojure: вы должны явно объявлять библиотеки и версии, что всегда хорошо.

(defproject clj-boost-demo "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.9.0"]
                 [org.clojure/data.csv "0.1.4"]
                 [clj-boost "0.0.3"]])

Если мы сохраним project.clj и запустим lein run в оболочке, Leiningen получит все необходимые зависимости и запустит REPL. Мы можем перейти к загрузке и преобразованию данных, открыв и изменив файл src/clj-boost-demo/core.clj. Мы найдем там какой-то код-заполнитель, мы можем избавиться от него и начать писать наш код.

В Clojure мы работаем с пространствами имен, обычно файл содержит одно пространство имен, в котором мы определяем наш импорт:

(ns clj-boost-demo.core
  (:require [clj-boost.core :as boost]
            [clojure.java.io :as io]
            [clojure.data.csv :as csv]))
(def iris-path "resources/iris.csv")

ns определяет новое пространство имен, и рекомендуется импортировать библиотеки из определения пространства имен, как мы делаем здесь. :require это ключевое слово, которое само по себе является типом в Clojure, и позже мы увидим, почему они важны, [clj-boost.core :as boost] означает, что мы хотим использовать пространство имен core из библиотеки clj-boost, но мы хотим сослаться на все имена под ним с именем boost. Если вы знаете Python, это то же самое, что и import library as lbr.

С def мы создаем новые переменные глобально в текущем пространстве имен. В этом случае мы указываем на строку, представляющую путь, по которому находится наш набор данных. Обычно в Clojure мы не определяем много глобальных имен, кроме имен функций. Фактически iris-path будет единственным глобальным именем, которое мы будем использовать в этой демонстрации!

Чтобы прочитать Iris csv, мы используем этот код:

(defn generate-iris
  [iris-path]
  (with-open [reader (io/reader iris-path)]
    (into []
          (comp (drop 1) (map #(split-at 4 %)))
          (csv/read-csv reader))))

Этот код определяет (defn) функцию с именем generate-iris, которая принимает в качестве аргумента путь к csv Iris. Затем мы открываем соединение с файлом по заданному пути, которое будет закрыто, когда мы закончим (with-open). Когда после вызова функции вы видите вектор с символом, за которым следует некоторый код - [reader (io/reader iris-path)] - это локальная привязка.

Локальные привязки полезны, чтобы избежать загромождения глобального пространства имен и разделить выполнение кода на крошечные биты. В этом случае мы используем программу чтения Java, а точнее BufferedReader, чтобы открыть и прочитать файл. Как видите, мы используем импортированное пространство имен clojure.java.io, выполняя io/reader, а name/ - это синтаксис для доступа к именам, находящимся в импортированном пространстве имен.

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

(with-open [reader (io/reader iris-path)] 
  (csv/read-csv reader)) 
;IOException Stream closed java.io.BufferedReader.ensureOpen

Приведенный выше код вызывает ошибку, потому что csv/read-csv ленив. Лень по умолчанию - еще одна особенность Clojure: большинство функций Clojure ничего не возвращают, пока вам не понадобятся эти значения. Это хорошо, если вы задумаетесь: если бы у нас был очень большой файл, нам не пришлось бы загружать его все в память для его обработки, но мы могли бы читать и обрабатывать его построчно, записывая результат в другой файл.

Чтобы сделать функцию активной, мы можем использовать doall:

(with-open [reader (io/reader iris-path)] 
  (doall (csv/read-csv reader))) 
;(["sepal_length" "sepal_width" "petal_length" "petal_width" "species"] 
; ["5.1" "3.5" "1.4" "0.2" "setosa"] 
; ["4.9" "3" "1.4" "0.2" "setosa"]...)

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

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

(def a [1 2 3]) 
(println a) 
;[1 2 3] 
(conj a 4) 
;[1 2 3 4] 
(println a) 
;[1 2 3]

Как вы можете видеть, мы создали вектор a, затем conj (который добавляет элементы к векторам) с 4 по a, и в результате получилась совершенно новая структура данных, фактически a по-прежнему имеет начальное значение.

Обработка данных

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

(defn demo-reader [] 
  (with-open [reader (io/reader iris-path)] 
    (doall (csv/read-csv reader))))

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

(->> (demo-reader) (take 3)) 
;(["sepal_length" "sepal_width" "petal_length" "petal_width" "species"] 
; ["5.1" "3.5" "1.4" "0.2" "setosa"] 
; ["4.9" "3" "1.4" "0.2" "setosa"])

->> позволяет нам передавать значение в качестве последнего аргумента последующих функций: вы наверняка помните, что в школе вас учили, что для решения f (g (x)) вы должны начать с решения g (x) = x ′, а затем f ( х ′) = х ». Макрос многопоточности - это просто синтаксический сахар, делающий код более читабельным.

Вот простой пример:

(inc 1) 
;2 
(dec (inc 1)) 
;1 
(-> 1 inc dec) 
;1

Мы inc уменьшаем 1 на 1, затем (dec (inc 1)) означает incсглаживание 1, а затем decсглаживание результата - 2 - на 1, и это дает 1. В основном мы читаем справа налево, чтобы понять порядок применения функций. С (-> 1 inc dec) мы можем вернуться к чтению операций слева направо. Для получения дополнительной информации о макросах потоковой передачи обратитесь к официальной документации Clojure.

Возвращаясь к (->> (demo-reader) (take 3)), как вы видите, в результате мы получаем только первые 3 вектора из csv. take делает именно то, что вы думаете: он лениво берет n значения из данной коллекции. Это очень полезно при экспериментировании, иначе нам пришлось бы работать со всей последовательностью.

Чтобы удалить заголовок из последовательности, мы можем drop первую строку из результатов:

(->> (demo-reader) (take 3) (drop 1)) 
;(["5.1" "3.5" "1.4" "0.2" "setosa"] 
; ["4.9" "3" "1.4" "0.2" "setosa"])

Теперь, когда мы хотим отделить значения X от нашего Y (классов, которые мы хотим предсказать), было бы неплохо сделать это за один раз. Если вы пришли из Python или других языков, подобных C, у вас может возникнуть соблазн использовать для этого цикл, но в Clojure мы делаем все по-другому.

С map мы можем применить функцию ко всем значениям в коллекции:

(map inc [1 2 3]) 
;(2 3 4)

В этом случае мы хотим разделить значения, и угадайте, что нас ждет split-at функция!

(split-at 2 [1 2 3])
;[(1 2) (3)]

Это именно то, что нам нужно, поэтому мы определим анонимную функцию и map ее над нашими векторами:

(->> (demo-reader) 
     (take 3) 
     (drop 1) 
     (map #(split-at 4 %))) 
;([("5.1" "3.5" "1.4" "0.2") ("setosa")] 
; [("4.9" "3" "1.4" "0.2") ("setosa")])

Для определения именованных функций мы используем defn, который является макросом, чтобы мы не набирали каждый раз (def my-func (fn [arg] "do something")), поэтому, выполняя просто (fn [arg] "I have no name"), мы получаем анонимную функцию.

#(split-at 4 %) - это еще одно сокращение, которое решает то же самое, что и (fn [x] (split-at 4 x)), так что это просто способ сэкономить время на вводе текста.

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

(def xf (comp (drop 1) (map #(split-at 4 %)))) 
(into [] xf [[1 2 3 4 5] [1 2 3 4 5]]) 
;[[(1 2 3 4) (5)]]

С comp мы составляем функции вместе, возвращая только одну функцию, а с into мы циклически перебираем коллекцию и перетаскиваем результаты в коллекцию, указанную в качестве первого аргумента. Мне нравится думать об этом процессе таким образом: это похоже на то, как будто мы перетаскиваем значения из одной коллекции в другую, но при этом мы применяем функцию - xf - ко всем из них.

Результатом стала функция generate-iris, с которой мы начали:

(defn generate-iris
  [iris-path]
  (with-open [reader (io/reader iris-path)]
    (into []
          (comp (drop 1) (map #(split-at 4 %)))
          (csv/read-csv reader))))

Теперь мы хотим перейти от этого ([("5.1" "3.5" "1.4" "0.2") ("setosa")] [("4.9" "3" "1.4" "0.2") ("setosa")]) к тому, что мы можем обработать более простым способом: ([5.1 3.5 1.4 0.2 0] [4.9 3.0 1.4 0.2 0]). Обычно мы разбираем строки в числа и преобразуем классы (setosa, virginica и versicolor) в целые числа.

Начнем с абстрагирования необходимых преобразований:

(defn parse-float
  [s]
  (Float/parseFloat s))
(->> (generate-iris iris-path) 
     (take 2) 
     (map first)) ; first returns the first element of a collection ;(("5.1" "3.5" "1.4" "0.2") ("4.9" "3" "1.4" "0.2")) 
(->> (generate-iris iris-path) 
     (take 2) 
     (map first) 
     (map #(map parse-float %))) 
; ((5.1 3.5 1.4 0.2) (4.9 3.0 1.4 0.2)) 
(->> (generate-iris iris-path) 
     (take 2) 
     (map first) 
     (map #(map parse-float %)) 
     (map vec)) 
; ([5.1 3.5 1.4 0.2] [4.9 3.0 1.4 0.2])

С помощью этих преобразований мы можем построить X для нашей модели. Давайте создадим именованный трансформатор:

(def transform-x
  (comp
   (map first)
   (map #(map parse-float %))
   (map vec)))

Вместо этого для нашего Y:

(->> (generate-iris iris-path) 
     (take 2) 
     (map last)) ; last takes the last item from a collection 
;(("setosa") ("setosa")) 
(let [l (first '("setosa"))] 
  (case l "setosa" 0 "versicolor" 1 "virginica" 2)) 
;0

Остановитесь на секунду, с let мы можем создавать локальные привязки, то есть давать имена данным или функциям, которые существуют только в локальном пространстве, а не глобально. case это способ избежать вложенности (if condition "this" "else this"). Мы говорим: если l равно = "setosa", тогда верните 0, если =, то "versicolor" верните 1, а если =, то "virginica" верните 2.

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

(->> (generate-iris iris-path) 
     (take 2) 
     (map last) 
     (map (fn [label] 
       (let [l (first label)] 
         (case l "setosa" 0 "versicolor" 1 "virginica" 2))))) 
;(0 0)
(def transform-y
  (comp
   (map last)
   (map (fn [label]
          (let [l (first label)]
            (case l
              "setosa"     0
              "versicolor" 1
              "virginica"  2))))))
(defn munge-data
  [iris-data]
  (let [x (into [] transform-x iris-data)
        y (into [] transform-y iris-data)]
    (map conj x y)))

Поезд - тестовый сплит

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

(defn train-test-split
  [n dataset]
  (let [shuffled (shuffle dataset)]
    (split-at n shuffled)))

train-test-split принимает коллекцию и необходимое количество экземпляров в обучающем наборе. Функция shuffle просто перемешивает коллекцию, поэтому мы каждый раз получаем случайный результат и можем легко избежать повторения.

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

(defn train-set
  [split-set]
  (let [set (first split-set)]
    {:x (mapv drop-last set)
     :y (mapv last set)}))

(defn test-set
  [split-set]
  (let [set (last split-set)]
    {:x (mapv drop-last set)
     :y (mapv last set)}))

С помощью вышеуказанных функций мы генерируем наборы поездов и тестов в виде 2 карт с 2 ключами: :x и :y. Карты в Clojure - первоклассные граждане и обладают очень хорошими свойствами:

; Create a map with mixed types 
{:a 1 "key" "string" 2 1/3}
 
; Maps are functions 
({:a 1} :a) 
;1 
; Keywords are functions as well 
(:a {:a 1}) 
;1 
; Access other kinds of keys by using the map... 
({1 2} 1) 
;2 
; ...or the get function 
(get {"key" 1} "key") 
;1

Карты - это гораздо больше, если вы не знаете о них, вам следует взглянуть здесь.

Обучение и прогнозирование

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

clj-boost дает вам интерфейс Clojure к базовой реализации Java библиотеки, таким образом мы можем избежать взаимодействия с Java и получить те же результаты. Чтобы обучить модель, мы должны создать DMatrix из данных, которые мы хотим использовать в алгоритме для обучения. Это не мой выбор, и я мог бы спрятать трансформацию данных за реализацией API, но есть проблема: как только вы помещаете свои данные в DMatrix, вы не можете трогать или смотреть на их больше.

(defn train-model
  [train-set]
  (let [data   (boost/dmatrix train-set)
        params {:params         {:eta       0.00001
                                 :objective "multi:softmax"
                                 :num_class 3}
                :rounds         2
                :watches        {:train data}
                :early-stopping 10}]
    (boost/fit data params)))

С dmatrix мы сериализуем наш обучающий набор, есть различные способы создания DMatrix, поэтому я советую вам взглянуть на docs или на README. Карта :params действует как конфигурация для XGBoost, и это очень минимальный пример того, что мы можем с ней сделать. Чтобы узнать все возможные варианты, всегда обращайтесь к официальной документации.

Здесь мы говорим, что XGBoost должен проводить обучение с скоростью обучения - :eta - 0,00001, поскольку мы проводим классификацию по 3 классам - setosa, versicolor и virginica - мы устанавливаем :objectiveto multi: softmax и сообщаем XGBoost, сколько классов у нас есть с :num_class.

XGBoost выполнит 2 :rounds повышения, оценит точность самого обучающего набора (не очень хорошая практика, но это всего лишь пример), передав его в :watches карту и в случае, если точность начнет увеличиваться в течение 10 последовательных итераций, обучение остановится из-за параметра :early-stopping.

Вызов fit по определенным данным и params обучает модель XGBoost с нуля и возвращает экземпляр Booster. Мы можем использовать Booster для прогнозирования, мы можем сохранить его на диске или передать его в качестве базовой линии другой модели XGBoost.

(defn predict-model
  [model test-set]
  (boost/predict model (boost/dmatrix test-set)))

(defn accuracy
  [predicted real]
  (let [right (map #(compare %1 %2) predicted real)]
    (/ (count (filter zero? right))
       (count real))))

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

Давайте объединим все в -main функцию, чтобы мы могли запустить весь анализ как из REPL, так и из командной строки.

(defn -main
  []
  (let [split-set    (->> iris-path
                          generate-iris
                          munge-data
                          (train-test-split 120))
        [train test] (map #(% split-set) [train-set test-set])
        model     (train-model train)
        result    (predict-model model test)]
    (println "Prediction:" (mapv int result))
    (println "Real:      " (:y test))
    (println "Accuracy:  " (accuracy result (:y test)))))

Мы генерируем split-set, а на его основе используем небольшой map трюк: мы сопоставляем несколько функций одной коллекции, а не наоборот. Затем мы обучаем модель XGBoost и получаем прогнозы.

(-main) 
Prediction: [1 1 2 0 2 2 2 2 2 1 1 0 1 2 0 1 1 1 0 1 0 2 1 1 0 0 1 2 1 1] 
Real: [1 1 2 0 2 2 2 2 2 1 1 0 1 2 0 1 1 1 0 1 0 2 1 1 0 0 1 2 2 1]
Accuracy: 29/30

Гибкость против производства

Вы, вероятно, не заметили этого, но хотя код, который мы написали, был простым и достаточно гибким, чтобы его можно было использовать для анализа и экспериментов, это также готовый код. Если вы выполните lein run из корня проекта в командной строке, вы получите те же результаты, что и (-main) из REPL. Таким образом, было бы тривиально добавить функциональность в программу, например, вы можете захотеть передать в нее новые данные, когда она изменится, и вы захотите переобучить свою модель.

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

Теперь вы можете немного поиграть с clj-boost, не забывайте, что есть документы и что это все еще в стадии разработки, поэтому, пожалуйста, дайте мне знать, если есть проблемы, идеи, способы сделайте его лучше или даже просто так, что вы его используете и вам это нравится.

Вы можете найти полный сценарий на clj-boost repo, не забывайте, что clj-boost - это СОПРОТИВЛЕНИЕ, и любые предложения принимаются!

Этот пост изначально был опубликован на rDisorder (проверьте его, особенно для получения лучших фрагментов кода)