Если у Вас нет Питона, но есть Керас-модель и Джава

    Всем привет! В построении 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

    NAUMEN
    Решаем истинные задачи

    Похожие публикации

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

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

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

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

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

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

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

              Это отличное решение при условии, что и остальная часть продукта соответствует этой архитектуре, а поставляется всё это дело контейнерами.
            0
            Если вы используете модель, только чтобы inference сделать, разумнее наверное обернуть один метод через JNI, чем продираться через конвертацию всей архитектуры?
              0
              Подход с использованием модели через JNI/JNA увеличивает стоимость поддержки (насколько конкретно — в каждом отдельном случае необходимо считать), т.к. необходимо делать сборку и в последующем поддерживать под каждую архитектуру. Если все на одной архитектуре, такой проблемы нет — один раз собрал и протестировал.

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

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

            Самое читаемое