Машинное обучение + 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 - мы устанавливаем :objective
to 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 (проверьте его, особенно для получения лучших фрагментов кода)