Также, как и большинство разработчиков, Python привлекает нас своей простотой и лаконичным синтаксисом. Мы используем его для решения задач машинного обучения при помощи искусственных нейронных сетей. Однако, на практике, язык продуктовой разработки не всегда Python и это требует от нас решения дополнительных интеграционных задач.
В этой статье расскажу о тех решениях, к которым мы пришли, когда нам потребовалось связать Keras-модель языка Python с Java.
Чему уделим внимание:
- Особенностям связки Keras модели и Java;
- Подготовке к работе с фрейворком DeepLearning4j (сокращенно DL4J);
- Импорту Keras-модели в DL4J (осторожно, раздел содержит множественные инсайты) — как регистрировать слои, какие есть ограничения у модуля импорта, как проверить результаты своих трудов.
Зачем читать?
- Чтобы сэкономить время на старте, если перед вами будет стоять задача похожей интеграции;
- Чтобы узнать, подходит ли вам наше решение и можете ли вы переиспользовать наш опыт.
Интегральная характеристика по значимости фреймворков глубокого обучения [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.
Начало работы
Перед тем как приступить к работе требуется установить необходимые программы:
- Java версии 1.7 (64-битной версии) и выше.
- Система сборки проектов Apache Maven.
- IDE на выбор: Intellij IDEA, Eclipse, Netbeans. Разработчики рекомендуют первый вариант, да и к тому же имеющиеся обучающие примеры рассмотрены на нем.
- 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 слоев, сводка по слоям приведена на рисунке:
- input_1 — входной слой, принимает на вход 2-мерный тензор (представляется матрицей);
- lambda_1 — пользовательский слой, в нашем случае делает дозаполнение (padding в TF) тензора одинаковыми численными значениями;
- embedding_1 — строит Embedding (векторное представление) для входной последовательности текстовых данных (преобразует 2-D тензор в 3-D);
- conv1d_1 — 1-D сверточный слой;
- lstm_2 — LSTM слой (идет после embedding_1 (№3) слоя);
- lstm_1 — LSTM слой (идет после conv1d (№4) слоя);
- lambda_2 — пользовательский слой, где выполняется усечение тензора после lstm_2 (№5) слоя (операция, противоположная padding в lambda_1 (№2) слое);
- lambda_3 — пользовательский слой, где выполняется усечение тензора после lstm_1 (№6) и conv1d_1 (№4) слоев (операция, противоположная padding в lambda_1 (№2) слое);
- concatenate_1 — склеивание усеченных (№7) и (№8) слоев;
- dense_1 — полносвязный слой из 8 нейронов и экспоненциальной линейной функцией активации «elu»;
- batch_normalization_1 — слой батч-нормализации;
- dense_2 — полносвязный слой из 1 нейрона и сигмоидной функцией активации «sigmoid»;
- lambda_4 — пользовательский слой, где выполняется сжатие предыдущего слоя (squeeze в TF).
4. Функция потерь — binary_crossentropy
5. Метрика качества модели — гармоническое среднее (F-мера)
В нашем случае вопрос метрики качества не так важен, как корректность импорта. Корректность импорта определим по совпадению результатов в 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 | Стало 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 | Стало 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 | Стало 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-модель:
Java-модель:
В действительности же, с импортом моделей все не так просто. Ниже кратко будут отмечены некоторые моменты, которые в отдельных случаях могут быть критичными.
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 еще предстоит активно развиваться для того, чтобы достичь уровня топовых фреймворков для работы с искусственными нейронными сетями.
В любом случае, ребята достойны уважения за свою работу и хотелось бы видеть продолжение развития этого направления.
Ссылки
- Top 5 best Programming Languages for Artificial Intelligence field
- Deep Learning Framework Power Scores 2018
- Comparison of deep-learning software
- Top 9 Frameworks in the World of Artificial Intelligence
- DeepLearning4j. Available models
- DeepLearning4j. Keras model import. Supported features.
- Deeplearning4j. Quickstart
- Lecture 0: Getting started with DeepLearning4j
- Deeplearing4j: Keras model import
- Lecture 7 | Keras Model Import
- Install TensorFlow for Java
- Использование Keras слоев
- DeepLearning4j: Class KerasLayer
- DeepLearning4j: SameDiffLambdaLayer.java
- DeepLearning4j: KerasLambdaTest.java
- DeepLearning4j: BatchNorm with RecurrentInputType
- StackOverFlow: Problem opening a keras model in java with deeplearning4j (https://deeplearning4j.org/)
- GitHub: Полный код для рассмотренной модели
- Skymind: Comparison of AI Frameworks
Комментарии (9)
trolley813
12.11.2019 11:11+1Если у вас нет питона, его не отравит сосед…
А серьезно, почему нельзя просто вызвать Python-код из Java (при необходимости сериализуя входные и выходные данные в какой-нибудь стандартный формат типа Apache Arrow)?usefultool Автор
12.11.2019 12:58Проблема не в вызове, а в том что для Python нужно подтягивать свое окружение под каждую платформу. На практике этот вариант имеет место быть. Тут уже вопрос больше не технический, а финансовый.
roryorangepants
12.11.2019 11:29Некоторые популярные фреймворки для нейронных сетей имеют порты для Java в виде JNI/JNA биндингов, но в таком случае возникает необходимость в сборке проекта под каждую архитектуру и преимущество Java в вопросе кроссплатформенности размывается. Этот нюанс может быть крайне важным в тиражируемых решениях.
Другой альтернативный подход — использование Jython для компиляции в байт-код Java; но и здесь есть недостаток — поддержка только 2-ой версии Python, а также ограниченность по возможности использования сторонних Python-библиотек.
Третий подход — наклепать за день микросервис на фласке и вызывать его.usefultool Автор
12.11.2019 13:01Как вариант. Если создается сервис у которого всего один прод, то можно и сервис с Flask развернуть, можно и вызвать удаленно Python-скрипт, предварительно установив окружение. Микросервисы хороши в проектных решениях. В продуктовых, где предлагается коробочное решение с высокой тиражируемостью это не лучшее решение с точки зрения архитектуры, поддержки, развертывания.
roryorangepants
12.11.2019 13:06+1В продуктовых, где предлагается коробочное решение с высокой тиражируемостью это не лучшее решение с точки зрения архитектуры, поддержки, развертывания.
Это отличное решение при условии, что и остальная часть продукта соответствует этой архитектуре, а поставляется всё это дело контейнерами.
Stantin
12.11.2019 23:49Если вы используете модель, только чтобы inference сделать, разумнее наверное обернуть один метод через JNI, чем продираться через конвертацию всей архитектуры?
usefultool Автор
13.11.2019 11:09Подход с использованием модели через JNI/JNA увеличивает стоимость поддержки (насколько конкретно — в каждом отдельном случае необходимо считать), т.к. необходимо делать сборку и в последующем поддерживать под каждую архитектуру. Если все на одной архитектуре, такой проблемы нет — один раз собрал и протестировал.
Кстати говоря, фрэймворк DL4J не на чистом Java. Имеет декомпилированные классы на C++ и под капотом у него используется как раз JNI.
tzps
Стыдно признать, но Slack-плагин для интеграции с StackOverflow мы подключили лишь пару недель назад. Так что вопроса на SO никто из нас даже не видел :(
Вы тикет на гитхабе не оставляли?
usefultool Автор
Тикеты не оставлял. Только общался на gitter с разработчиками. Вопросов было много, ответы поступают не сразу, поэтому к вопросам приходилось относится очень избирательно.