Всем привет! В построении ML-моделей Python сегодня занимает лидирующее положение и пользуется широкой популярностью сообщества Data Science специалистов [1].

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

В этой статье расскажу о тех решениях, к которым мы пришли, когда нам потребовалось связать Keras-модель языка Python с Java.

Чему уделим внимание:

  • Особенностям связки Keras модели и Java;
  • Подготовке к работе с фрейворком DeepLearning4j (сокращенно DL4J);
  • Импорту Keras-модели в DL4J (осторожно, раздел содержит множественные инсайты) — как регистрировать слои, какие есть ограничения у модуля импорта, как проверить результаты своих трудов.

Зачем читать?

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

image alt

Интегральная характеристика по значимости фреймворков глубокого обучения [2].

Сводку по наиболее популярным фреймворкам глубокого обучения можно посмотреть здесь [3] и здесь [4].

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

В действительности же, множество языков разработки гораздо обширнее. Лидером в продуктовой разработке для крупных предприятий и организаций является Java. Некоторые популярные фреймворки для нейронных сетей имеют порты для Java в виде JNI/JNA биндингов, но в таком случае возникает необходимость в сборке проекта под каждую архитектуру и преимущество Java в вопросе кроссплатформенности размывается. Этот нюанс может быть крайне важным в тиражируемых решениях.

Другой альтернативный подход — использование Jython для компиляции в байт-код Java; но и здесь есть недостаток — поддержка только 2-ой версии Python, а также ограниченность по возможности использования сторонних Python-библиотек.

Для упрощения разработки нейросетевых решений на Java развивается фреймворк DeepLearning4j (сокращенно DL4J). DL4 в дополнение к Java API предлагает набор предобученных моделей [5]. В целом, этому инструменту по скорости развития сложно конкурировать с TensorFlow. TensorFlow выигрывает у DL4J более подробной документацией и количеством примеров, техническими возможностями, размерами сообщества, быстрой динамикой развития. Тем не менее, тренд, которого придерживается Skymind, вполне перспективен. Весомых конкурентов на Java у этого инструмента пока не видно.

Библиотека DL4J одна из немногих (если не единственная) дает возможность импорта Keras-моделей, расширяется по функционалу привычными для Keras слоями [6]. В библиотеке DL4J содержится каталог с примерами реализации нейросетевых ML-моделей (dl4j-example). В нашем случае тонкости реализации этих моделей на Java не так интересны. Более подробное внимание будет уделено импорту обученной Keras/TF модели в Java методами DL4J.

Начало работы


Перед тем как приступить к работе требуется установить необходимые программы:

  1. Java версии 1.7 (64-битной версии) и выше.
  2. Система сборки проектов Apache Maven.
  3. IDE на выбор: Intellij IDEA, Eclipse, Netbeans. Разработчики рекомендуют первый вариант, да и к тому же имеющиеся обучающие примеры рассмотрены на нем.
  4. Git (для клонирования проекта на свой ПК).

Подробное описание с примером запуска можно посмотреть здесь [7] или в видео [8].

Для импорта модели DL4J-разработчики предлагают воспользоваться модулем импорта KerasModelImport (появился в октябре 2016 года). Функционал модуля поддерживает обе архитектуры моделей из Keras — это Sequential (аналог в java — класс MultiLayerNetwork) и Functional (аналог в java — класс ComputationGraph). Модель импортируется либо целиком в формате HDF5, либо 2-мя отдельными файлами — веса модели с расширением h5 и json-файла, содержащего архитектуру нейросети.

Для быстрого старта разработчики DL4J подготовили пошаговый разбор простого примера на наборе данных ирисов Фишера для модели типа Sequential [9]. Еще один обучающий пример рассмотрен с позиции импорта моделей двумя способами (1: целиком в формате HDF5; 2: отдельными файлами — веса модели (расширение h5) и архитектура (расширение json)) с последующим сравнением результатов Python и Java моделей [10]. На этом рассмотрение практических возможностей модуля импорта заканчивается.

Также существует TF на Java, но он находится в экспериментальном состоянии и разработчики не дают никаких гарантий его стабильной работы [11]. Здесь присутствуют проблемы с версионностью, а также TF на Java имеет неполный API — именно поэтому этот вариант здесь не будет рассмотрен.

Особенности исходной Keras/TF модели:


Ипортирование нейронной сети простой архитектуры сложностей не вызывает. Подробнее в коде разберем пример интеграции нейронной сети с архитектурой посложнее.

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

Характеристики модели:

1. Тип модели — Functional (сеть с ветвлением);

2. Параметры обучения (размер батча, количество эпох) выбраны небольшими: размер батча — 100, количество эпох — 10, шагов на эпоху — 10;

3. 13 слоев, сводка по слоям приведена на рисунке:

image alt

Краткое описание слоев
  1. input_1 — входной слой, принимает на вход 2-мерный тензор (представляется матрицей);
  2. lambda_1 — пользовательский слой, в нашем случае делает дозаполнение (padding в TF) тензора одинаковыми численными значениями;
  3. embedding_1 — строит Embedding (векторное представление) для входной последовательности текстовых данных (преобразует 2-D тензор в 3-D);
  4. conv1d_1 — 1-D сверточный слой;
  5. lstm_2 — LSTM слой (идет после embedding_1 (№3) слоя);
  6. lstm_1 — LSTM слой (идет после conv1d (№4) слоя);
  7. lambda_2 — пользовательский слой, где выполняется усечение тензора после lstm_2 (№5) слоя (операция, противоположная padding в lambda_1 (№2) слое);
  8. lambda_3 — пользовательский слой, где выполняется усечение тензора после lstm_1 (№6) и conv1d_1 (№4) слоев (операция, противоположная padding в lambda_1 (№2) слое);
  9. concatenate_1 — склеивание усеченных (№7) и (№8) слоев;
  10. dense_1 — полносвязный слой из 8 нейронов и экспоненциальной линейной функцией активации «elu»;
  11. batch_normalization_1 — слой батч-нормализации;
  12. dense_2 — полносвязный слой из 1 нейрона и сигмоидной функцией активации «sigmoid»;
  13. lambda_4 — пользовательский слой, где выполняется сжатие предыдущего слоя (squeeze в TF).

4. Функция потерь — binary_crossentropy

$loss =- \frac{1}{N} \sum_{1}^{N}(y_{true}\cdot log(y_{pred})+ (1-y_{true})\cdot log(1-y_{pred})) $



5. Метрика качества модели — гармоническое среднее (F-мера)

$F = 2 \frac{Precision \times Recall}{Precision + Recall} $


В нашем случае вопрос метрики качества не так важен, как корректность импорта. Корректность импорта определим по совпадению результатов в Python и Java NN-моделях, работающих в режиме Inference.

Импорт Keras-модели в DL4J:


Используемые версии: Tensorflow 1.5.0 и Keras 2.2.5. В нашем случае модель из Python была выгружена целиком HDF5 файлом.

# saving model
model1.save('model1_functional.h5')  

При импорте модели в DL4J в модуле импорта не предусмотрены API-методы для передачи дополнительных параметров: название модуля tensorflow (откуда импортировались функции при построении модели).

Вообще говоря, DL4J работает только с функциями Keras, исчерпывающий перечень приведен в разделе Keras Import [6], поэтому если модель создавалась на Keras с привлечением методов из TF (как в нашем случае), модуль импорта не сможет их идентифицировать.

Общие рекомендации по импорту модели


Очевидно, что работа с Keras-моделью подразумевает ее неоднократное обучение. С этой целью для экономии времени были выставлены параметры на обучение (1 эпоха (epoch) и 1 шаг на эпоху (steps_per_epoch).

При первом импорте модели, в частности, с уникальными пользовательскими слоями и редкими комбинациями слоев, успех маловероятен. Поэтому рекомендуется проводить процесс импорта итерационно: уменьшать количество слоев Keras-модели до тех пор, пока не получится импортировать и запустить модель в Java без ошибок. Далее последовательно добавлять по одному слою в Keras-модель и импортировать полученную модель в Java, разрешая возникающие ошибки.

Использование функции потерь от TF


В доказательство того, что при импорте в Java функция потерь обученной модели в обязательном порядке должна быть от Keras, воспользуемся log_loss от tensorflow (как самой похожей на custom_loss-функцию). В консоль получим следующую ошибку:

Exception in thread "main"
org.deeplearning4j.nn.modelimport.keras.exceptions.UnsupportedKerasConfigurationException: Unknown Keras loss function log_loss.

Замена методов TF на Keras


В нашем случае функции из модуля TF используются 2 раза и во всех случаях они встречаются только в lambda-слоях.

Слои lambda — это пользовательские слои, которые используются для добавления произвольной функции.

В нашей модели заложено всего 4 lambda-слоя. Дело в том, что в Java необходимо регистрировать эти lambda-слои вручную через KerasLayer.registerLambdaLayer (в противном случае получим ошибку [12]). При этом функция, определяемая внутри lambda слоя, должна быть функцией из соответствующих Java библиотек. В Java нету примеров регистрации этих слоев, а также исчерпывающей документации для этого; пример — здесь [13]. Общие соображения были заимствованы из примеров [14, 15].

Последовательно рассмотрим регистрацию всех lambda-слоев модели в Java:

1) Lambda слой для добавления к тензору(матрице) констант конечное количество раз вдоль заданных направлений (в нашем случае справа и слева):

Вход данного слоя соединен со входом модели.

1.1) Python-слой:

padding = keras.layers.Lambda(lambda x: tf.pad(x, paddings=[[0, 0], [10, 10]],
                                                       constant_values=1))(embedding)

Для наглядности работы функции этого слоя, численные значения в python-слои подставим явно.

Таблица с примером произвольного тензора 2x2
Было 2x2 Стало 2x22
[[1,2],
[3,4]]
[[37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 1, 2, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37],
[37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 3, 4, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37]]


1.2) Java-слой:

KerasLayer.registerLambdaLayer("lambda_1", new SameDiffLambdaLayer()
{
    @Override
    public SDVariable defineLayer(SameDiff sameDiff, SDVariable sdVariable)
    {
        return sameDiff.nn().pad(sdVariable, new int[][]{ { 0, 0 }, { 10, 10 }}, 1);
    }
    @Override
    public InputType getOutputType(int layerIndex, InputType inputType)
    {
        return InputType.feedForward(20);
    }
});

Во всех регистрируемых lambda слоях в Java происходит переопределение 2-х функций:
1-ая функция «definelayer» отвечает за используемый метод (совсем не очевидный факт: этот метод может использоваться только из-под nn() backend); getOutputType отвечает за выход регистрируемого слоя, аргумент — числовой параметр (здесь 20, а вообще допускается любое целочисленное значение). Выглядит неконсистентно, но работает так.

2) Lambda слой для усечения тензора (матрицы) вдоль заданных направлений (в нашем случае справа и слева):

В данном случае на вход lambda слоя поступает LSTM слой.

2.1) Python-слой:

slicing_lstm = keras.layers.Lambda(lambda x: x[:, 10:-10])(lstm)

Таблица с примером произвольного тензора 2x22x5
Было 2x22x5 Стало 2x2x5
[[[1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5]],

[[1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5], [1,2,3,4,5]]]
[[[1,2,3,4,5], [1,2,3,4,5]],
[[1,2,3,4,5], [1,2,3,4,5]]]


2.2) Java-слой:

KerasLayer.registerLambdaLayer("lambda_2", new SameDiffLambdaLayer()
{
    @Override
    public SDVariable defineLayer(SameDiff sameDiff, SDVariable sdVariable)
    {
        return sameDiff.stridedSlice(sdVariable, new int[]{ 0, 0, 10 }, new int[]{ (int)sdVariable.getShape()[0], (int)sdVariable.getShape()[1], (int)sdVariable.getShape()[2]-10}, new int[]{ 1, 1, 1 });
    }
    @Override
    public InputType getOutputType(int layerIndex, InputType inputType)
    {
        return InputType.recurrent(60);
    }
});

В случае этого слоя параметр InputType сменился с feedforward(20) на recurrent(60). В аргументе recurrent число может быть любым целочисленным (ненулевым), но его сумма с аргументом recurrent следующего lambda слоя должны давать 160 (т. е. в следующем слое должно быть аргумент должен быть равен 100). Число 160 обусловлено тем, что на вход concatenate_1 слоя должен поступить тензор с размерностью (None, None, 160).

Первые 2 аргумента — переменные, зависящие от размера входной строки.

3) Lambda слой для усечения тензора (матрицы) вдоль заданных направлений (в нашем случае справа и слева):

На вход этого слоя поступает LSTM слой, перед которым находится conv1_d слой

3.1) Python-слой:

slicing_convolution = keras.layers.Lambda(lambda x: x[:,10:-10])(lstm_conv)

Это операция полностью идентична операции в пункте 2.1

3.2) Java-слой:

KerasLayer.registerLambdaLayer("lambda_3", new SameDiffLambdaLayer()
{
    @Override
    public SDVariable defineLayer(SameDiff sameDiff, SDVariable sdVariable)
    {
        return sameDiff.stridedSlice(sdVariable, new int[]{ 0, 0, 10 }, new int[]{ (int)sdVariable.getShape()[0], (int)sdVariable.getShape()[1], (int)sdVariable.getShape()[2]-10}, new int[]{ 1, 1, 1 });
    }
    @Override
    public InputType getOutputType(int layerIndex, InputType inputType)
    {
        return InputType.recurrent(100);
    }
});

Этот lambda слой повторяет предыдущий lambda слой за исключением параметра recurrent(100). Почему взято «100» отмечено в описании предыдущего слоя.

В пунктах 2 и 3 lambda-слои находятся после LSTM слоев, поэтому используется тип recurrent. Но если бы перед lambda-слоем был не LSTM, а conv1d_1, то по-прежнему необходимо устанавливать recurrent (выглядит неконсистентно, но работает так).

4) Lambda слой для сжатия предыдущего слоя:

На вход этого слоя поступает полносвязный слой.

4.1) Python-слой:

 squeeze = keras.layers.Lambda(lambda x: tf.squeeze(
        x, axis=-1))(dense)

Таблица с примером произвольного тензора 2x4x1
Было 2x4x1 Стало 2x4
[[[1], [2], [3], [4]],

[[1], [2], [3], [4]]]
[[1, 2, 3, 4],
[1, 2, 3, 4]]


4.2) Java-слой:

KerasLayer.registerLambdaLayer("lambda_4", new SameDiffLambdaLayer()
{
    @Override
    public SDVariable defineLayer(SameDiff sameDiff, SDVariable sdVariable)
    {
        return sameDiff.squeeze(sdVariable, -1);
    }
    @Override
    public InputType getOutputType(int layerIndex, InputType inputType)
    {
        return InputType.feedForward(15);
    }
});

На вход этого слоя поступает полносвязный слой, InputType для этого слоя feedForward(15), параметр 15 не влияет на модель (допускается любое целочисленное значение).

Загрузка импортируемой модели


Модель загружается через модуль ComputationGraph:

ComputationGraph model = org.deeplearning4j.nn.modelimport.keras.KerasModelImport.importKerasModelAndWeights("/home/user/Models/model1_functional.h5");

Вывод данных в консоль Java


В Java, в частности в DL4J тензоры записываются в виде массивов из высокопроизводительной библиотеки Nd4j, которую можно считать аналогом библиотеки Numpy в Python.

Допустим, наша входная строка состоит из 4-х символов. Символы представляются в виде целых чисел (как индексы), например, согласно некоторой нумерации. Для них создается массив соответствующей размерности (4).

Например, мы имеем 4 закодированных индексами символа: 1, 3, 4, 8.

Код в Java:

INDArray myArray = Nd4j.zeros(1,4); // one row 4 column array
myArray.putScalar(0,0,1);
myArray.putScalar(0,1,3);
myArray.putScalar(0,2,4);
myArray.putScalar(0,3,8);
INDArray output = model.outputSingle(myArray);

System.out.println(output);

В консоль будут выведены вероятности для каждого входного элемента.

Импортированные модели


Архитектура исходной нейронной сети и весовые коэффициенты импортируются без ошибок. Обе нейросетевых модели Keras и Java в режиме Inference дают согласие результатов.

Python-модель:

image alt

Java-модель:

image alt

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

1) Слой батч-нормализации не работает после реккурентных слоев. По этому вопросу на GitHub открыто Issue почти год [16]. К примеру, если добавить этот слой к модели (после слоя контактенации), то получим следующую ошибку:

Exception in thread "main" java.lang.IllegalStateException: Invalid input type: Batch norm layer expected input of type CNN, CNN Flat or FF, got InputTypeRecurrent(160) for layer index -1, layer name = batch_normalization_1

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

2) После полносвязного слоя установка слоя Flatten приводит к ошибке. Похожая ошибка упомянута на Stackoverflow [17]. За полгода никакой обратной связи.

Определенно это далеко не все ограничения, с которыми можно встретиться при работе с DL4J.
Итоговые наработки по модели здесь [18].

Заключение


В заключении можно отметить, что безболезненно импортировать обученные Keras-модели в DL4J можно только для простых кейсов (разумеется, если у Вас за плечами нет подобного опыта, да и вообще уверенного владения Java).

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

Документальное сопровождение развиваемого модуля импорта, количество сопутствующих примеров, показалось довольно сыроватым. На каждом этапе возникают новые вопросы — как регистрировать Lambda-слои, осмысленность параметров и т. д.

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

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

Ссылки

  1. Top 5 best Programming Languages for Artificial Intelligence field
  2. Deep Learning Framework Power Scores 2018
  3. Comparison of deep-learning software
  4. Top 9 Frameworks in the World of Artificial Intelligence
  5. DeepLearning4j. Available models
  6. DeepLearning4j. Keras model import. Supported features.
  7. Deeplearning4j. Quickstart
  8. Lecture 0: Getting started with DeepLearning4j
  9. Deeplearing4j: Keras model import
  10. Lecture 7 | Keras Model Import
  11. Install TensorFlow for Java
  12. Использование Keras слоев
  13. DeepLearning4j: Class KerasLayer
  14. DeepLearning4j: SameDiffLambdaLayer.java
  15. DeepLearning4j: KerasLambdaTest.java
  16. DeepLearning4j: BatchNorm with RecurrentInputType
  17. StackOverFlow: Problem opening a keras model in java with deeplearning4j (https://deeplearning4j.org/)
  18. GitHub: Полный код для рассмотренной модели
  19. Skymind: Comparison of AI Frameworks

Комментарии (9)


  1. tzps
    12.11.2019 11:09

    Стыдно признать, но Slack-плагин для интеграции с StackOverflow мы подключили лишь пару недель назад. Так что вопроса на SO никто из нас даже не видел :(

    Вы тикет на гитхабе не оставляли?


    1. usefultool Автор
      12.11.2019 12:53

      Тикеты не оставлял. Только общался на gitter с разработчиками. Вопросов было много, ответы поступают не сразу, поэтому к вопросам приходилось относится очень избирательно.


  1. trolley813
    12.11.2019 11:11
    +1

    Если у вас нет питона, его не отравит сосед…
    А серьезно, почему нельзя просто вызвать Python-код из Java (при необходимости сериализуя входные и выходные данные в какой-нибудь стандартный формат типа Apache Arrow)?


    1. usefultool Автор
      12.11.2019 12:58

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


  1. roryorangepants
    12.11.2019 11:29

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

    Другой альтернативный подход — использование Jython для компиляции в байт-код Java; но и здесь есть недостаток — поддержка только 2-ой версии Python, а также ограниченность по возможности использования сторонних Python-библиотек.

    Третий подход — наклепать за день микросервис на фласке и вызывать его.


    1. usefultool Автор
      12.11.2019 13:01

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


      1. roryorangepants
        12.11.2019 13:06
        +1

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

        Это отличное решение при условии, что и остальная часть продукта соответствует этой архитектуре, а поставляется всё это дело контейнерами.


  1. Stantin
    12.11.2019 23:49

    Если вы используете модель, только чтобы inference сделать, разумнее наверное обернуть один метод через JNI, чем продираться через конвертацию всей архитектуры?


    1. usefultool Автор
      13.11.2019 11:09

      Подход с использованием модели через JNI/JNA увеличивает стоимость поддержки (насколько конкретно — в каждом отдельном случае необходимо считать), т.к. необходимо делать сборку и в последующем поддерживать под каждую архитектуру. Если все на одной архитектуре, такой проблемы нет — один раз собрал и протестировал.

      Кстати говоря, фрэймворк DL4J не на чистом Java. Имеет декомпилированные классы на C++ и под капотом у него используется как раз JNI.