ML на Scala с улыбкой, для тех, кто не боится экспериментов

n4cqgunchqczcws7cj09vm0kpiq.png

Всем привет! Сегодня будем говорить о реализации машинного обучения на Scala. Начну с объяснения, как мы докатились до такой жизни. Итак, наша команда долгое время использовала все возможности машинного обучения на Python. Это удобно, есть много полезных библиотек для подготовки данных, хорошая инфраструктура для разработки, я имею в виду Jupyter Notebook. Всё бы ничего, но столкнулись с проблемой распараллеливания вычислений в production, и решили использовать в проде Scala. Почему бы и нет, подумали мы, там есть куча библиотек, даже Apache Spark написан на Scala! При этом, сегодня модели мы разрабатываем на Python, а затем повторяем обучение на Scala для дальнейшей сериализации и использования в production. Но, как говорится, дьявол кроется в деталях.

Сразу хочу внести ясность, дорогой читатель, эта статья написана не с целью пошатнуть репутацию Python в вопросах машинного обучения. Нет, основная цель — приоткрыть дверь в мир машинного обучения на Scala, сделать небольшой обзор альтернативного подхода, вытекающего из нашего опыта, и рассказать, с какими трудностями мы столкнулись.
На практике оказалось не так уж всё и радостно: не так много библиотек, реализующих классические алгоритмы машинного обучения, а те, что есть — это, зачастую, OpenSource-проекты без поддержки крупных вендоров. Да, безусловно, есть Spark MLib, но он сильно привязан к экосистеме Apache Hadoop, да и тащить его в микросервисную архитектуру уж очень не хотелось.

Нужно было решение, которое спасёт мир и вернёт спокойный сон, и оно было найдено!

Что нужно?


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

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


Что мы посмотрели?


  • Apache Spark MLib: нам не подошёл. Как было сказано выше, этот набор библиотек сильно привязан к стеку Apache Hadoop и самому Spark Core, который слишком много весит, чтобы строить микросервисы на его основе.
  • Apache PredictionIO: интересный проект, много контрибьюторов, есть документация с примерами. По сути, это REST-сервер, на котором крутятся модели. Есть готовые модели, например, классификация текста, запуск которых описан в документации. В документации описано, как можно добавлять и обучать свои модели. Нам не подошёл, так как под капотом используется Spark, и это больше из области монолитного решения, а не микросервисная архитектура.
  • Apache MXNet: интересный фреймворк для работы с нейронными сетями, есть поддержка Scala и Python — это удобно, можно обучать нейронную сеть на Python, а потом сохранённый результат подгружать из Scala при создании production-решения. Мы его используем в production-решениях, об этом есть отдельная статья тут.
  • Smile: очень похож на пакет scikit-learn для Python. Есть много реализаций классических алгоритмов машинного обучения, хорошая документация с примерами, поддержка на github, встроенный визуализатор (работает на базе Swing), для разработки моделей можно использовать Jupyter Notebook. Это как раз то, что нужно!


Подготовка окружения


Итак, мы выбрали Smile. Расскажу, как запустить его в Jupyter Notebook на примере алгоритма кластеризации k-means. Первое, что нам нужно сделать — установить Jupyter Notebook с поддержкой Scala. Это можно сделать через pip, или использовать уже собранный и настроенный Docker-образ. Я за более простой, второй вариант.

Чтобы подружить Jupyter со Scala, я хотел воспользоваться BeakerX, входящим в состав Docker-образа, доступного в официальном репозитории BeakerX. Этот образ рекомендован в документации Smile, и запустить его можно так:

# Официальный образ BeakerX
docker run -p 8888:8888 beakerx/beakerx


Но здесь поджидала первая неприятность: на момент написания статьи внутри образа beakerx/beakerx был установлен BeakerX 1.0.0, а в официальном github проекта уже доступна версия 1.4.1 (точнее, последний релиз 1.3.0, но в мастере лежит 1.4.1, и она работает :-)).

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

# Запускаем образ и монтируем в него рабочую директорию
mkdir -p /tmp/my_code
docker run -it \
    -p 8888:8888 \
    -v /tmp/my_code:/workspace/my_code \
    entony/jupyter-scala:1.4.1


Кстати, для тех, кто будет использовать мой образ, будет небольшой бонус: в директории examples есть пример k-means для случайной последовательности с построением графика (это не совсем тривиальная задача для Scala notebooks).

Загрузка Smile в Jupyter Notebook


Отлично, окружение подготовили! Создаём в папке в нашей директории новый Scala notebooks, далее необходимо выкачать из Maven библиотеки для работы Smile.

%%classpath add mvn
com.github.haifengl smile-scala_2.12 1.5.2


После исполнения кода в его блоке вывода появится список загруженных jar-файлов.

Следующий шаг: импортирование необходимых пакетов для работы примера.

import java.awt.image.BufferedImage
import java.awt.Color
import javax.imageio.ImageIO
import java.io.File
import smile.clustering._


Подготовка данных для кластеризации


Теперь решим следующую задачу: генерирование изображения, состоящего из зон трёх основных цветов — красного, зелёного и синего (R, G, B). Один из цветов на картинке будет преобладать. Кластеризуем пиксели изображения, возьмём кластер, в котором будет больше всего пикселей, изменим их цвет на серый и построим новое изображение из всех пикселей. Ожидаемый результат: зона преобладающего цвета станет серой, остальный зоны не изменят свой цвет.

// Размер изображения будет 640 х 360
val width = 640
val hight = 360

// Создаём пустое изображение нужного размера
val testImage = new BufferedImage(width, hight, BufferedImage.TYPE_INT_RGB)

// Заполняем изображение пикселями. Преобладающим будет синий цвет.
for {
    x <- (0 until width)
    y <- (0 until hight)
    color = if (y <= hight / 3 && (x <= width / 3 || x > width / 3 * 2)) Color.RED
    else if (y > hight / 3 * 2 && (x <= width / 3 || x > width / 3 * 2)) Color.GREEN
    else Color.BLUE
} testImage.setRGB(x, y, color.getRGB)

// Выводим созданное изображение
testImage


В результате выполнения этого кода выводится вот такая картинка:

499b59811386f85ef8c0cf68dd01a396.png

Следующий шаг: преобразуем картинку в набор пикселей. Под пикселем будем понимать сущность с такими свойствами:

  • координата по широкой стороне (x);
  • координата по узкой стороне (y);
  • значение цвета;
  • опциональное значение класса/номера кластера (до выполнения кластеризации будет пустым).


В качестве сущности удобно использовать case class:

case class Pixel(x: Int, y: Int, rgbArray: Array[Double], clusterNumber: Option[Int] = None)


Здесь для значений цвета используется массив rgbArray из трёх значений красного, зелёного и синего (например, для красного цвета Array(255.0, 0, 0)).

// Перегоняем изображение в коллекцию пикселей (Pixel)
val pixels = for {
    x <- (0 until testImage.getWidth).toArray
    y <- (0 until testImage.getHeight)
    color = new Color(testImage.getRGB(x, y))
} yield Pixel(x, y, Array(color.getRed.toDouble, color.getGreen.toDouble, color.getBlue.toDouble))

// Выводим первый 10 элементов коллекции
pixels.take(10)


На этом подготовка данных закончена.

Кластеризация пикселей по цветам


Итак, у нас есть коллекция из пикселей трёх основных цветов, поэтому кластеризовать пиксели мы будем на три класса.

// Количество кластеров
val countColors = 3

// Выполняем кластеризацию
val clusters = kmeans(pixels.map(_.rgbArray), k = countColors, runs = 20)


В документации рекомендуется задавать параметр runs в диапазоне от 10 до 20.

При выполнении этого кода будет создан объект типа KMeans. В блоке вывода будет информация о результатах кластеризации:

K-Means distortion: 0.00000
Clusters of 230400 data points of dimension 3:
  0    50813 (22.1%)
  1    51667 (22.4%)
  2    127920 (55.5%)


Один из кластеров действительно содержит больше пикселей, чем остальные. Теперь нужно разметить нашу коллекцию пикселей классами от 0 до 2.

// Разметка коллекции пикселей
val clusteredPixels = (pixels zip clusters.getClusterLabel()).map {case (pixel, cluster) => pixel.copy(clusterNumber = Some(cluster))}

// Выводим 10 размеченных пикселей
clusteredPixels.take(10)


Перекрашиваем изображение


Осталось дело за малым — выделить кластер с наибольшим количеством пикселей и перекрасить все пиксели, входящие в этот кластер, в серый цвет (изменить значение массива rgbArray).

// Серый цвет
val grayColor = Array(127.0, 127.0, 127.0)

// Определяем кластер с наибольшим количеством пикселей
val blueClusterNumber = clusteredPixels.groupBy(pixel => pixel.clusterNumber)
    .map {case (clusterNumber, pixels) => (clusterNumber, pixels.size) }
    .maxBy(_._2)._1

// Перекрашиваем все пиксели кластера в серый
val modifiedPixels = clusteredPixels.map {
    case p: Pixel if p.clusterNumber == blueClusterNumber => p.copy(rgbArray = grayColor)
    case p: Pixel => p
}

// Выводим 10 элементов из новой коллекции пикселей
modifiedPixels.take(10)


Тут нет ничего сложного, просто группируем по номеру кластера (это у нас Option:[Int]), считаем количество элементов в каждой группе и вытаскиваем кластер с максимальным количеством элементов. Далее меняем цвет на серый только у тех пикселей, которые относятся к найденному кластеру.

Создаём новое изображение и сохраняем результаты


Собираем из коллекции пикселей новое изображение:

// Создаём пустое изображение такого же размера
val modifiedImage = new BufferedImage(testImageWidth, testImageHight, BufferedImage.TYPE_INT_RGB)

// Наполняем его перекрашенными пикселями
modifiedPixels.foreach { 
    case Pixel(x, y, rgbArray, _) => 
        val r = rgbArray(0).toInt
        val g = rgbArray(1).toInt
        val b = rgbArray(2).toInt
        modifiedImage.setRGB(x, y, new Color(r, g, b).getRGB)
}

// Выводим новое изображение
modifiedImage


Вот что, в итоге, у нас получилось.

950d1569a1bc51920f4758499a9bbe26.png

Сохраняем оба изображения.

ImageIO.write(testImage, "png", new File("testImage.png"))
ImageIO.write(modifiedImage, "png", new File("modifiedImage.png"))


Заключение


Машинное обучение на Scala существует. Для реализации базовых алгоритмов не обязательно тащить какую-то огромную библиотеку. Представленный выше пример показывает, что при разработке можно не отказываться от привычных средств, тот же Jupyter Notebook можно, без особого труда, подружить со Scala.

Конечно же, для полного обзора всех возможностей Smile не хватит одной статьи, да это и не входило в планы. Основную задачу — приоткрыть дверь в мир машинного обучения на Scala — считаю выполненной. Пользоваться ли этими инструментами, и, уж тем более, тащить их в production или нет, решать вам!

Ссылки


© Habrahabr.ru