Как Google победил Skynet или подготовка обучения модели на мобильном устройстве

По мере того, как я погружался в тему машинного обучения на мобильных устройствах, я все больше ощущал какой-то заговор. Как я уже писал, простые обучалки начали исчезать из интернета несколько лет назад. А простые обучалки — это те, в которых простые модели, то есть то, что делают люди, которые только начинают разбираться в теме. Вместо этого сейчас предлагается использовать готовые датасеты вполне определенным образом, и от этого остается один шаг до использования готовых моделей. А еще, примеры для мобильных устройств на главном сайте TensorFlow устарели и не работают на современных версиях библиотеки, причем уже давно! Ну и в качестве бонуса: похоже, что скоро NPU, которые есть в каждом современном телефоне, станут для нас абсолютно бесполезными.

неожиданно

неожиданно

Насколько я понимаю, Hexagon — это Neural Processing Unit в устройствах на базе Snapdragon. Прекрасная новость для Android 15! То есть понятно, что разработчики прошивок найдут способ использовать API производителя железа для обработки фотографий (я даже не знаю для чего еще). А вот простые программисты помучаются и забьют. В связи с этим возможны две теории:

  1. Сейчас ценность имеют именно модели. Поэтому лицензирование использования моделей и готовых API для работы с ними стало еще одним способом привязки пользователей к экосистемам мега-компаний типа Гугла, и соответственно, заработка. Действительно, распознавание цифр я добавил без проблем в свою самую первую программу для Android пару лет назад (моя судоку умела загружать игру по фотографии). Только этот код не работал на планшете Huawei без GAPS’ов. А еще если ты контролируешь модели, то контролируешь процентное содержание лесбиянок и трансгендеров в выдаче результатов. А то мало ли что юзеры напрограммируют. Не, вот вам толерантая и инклюзивная модель, пользуйтесь на здоровье, не забудьте только пол поменять после использования.

  2. Теория номер 2 мне нравится больше. Джон и Сара Коннор в одной из серий догадались как победить Skynet — и даже не понадобилось никого убивать. Они подкинули базовую технологию мобильного искусственного интеллекта в компанию Google, понадеявшись, что ее разработчики сделают работу с ИИ настолько невыносимой (как эти же самые разработчики Google уже сделали с другими технологиями десятки раз), что тема просто загнется. Профит — терминатор Т800 (мобильный автономный искуственный интеллект, между прочим) так и не появился, и планета была спасена уже в следующей серии.

История разработки

Мне деваться было некуда, я разрабатываю свое приложение уже больше года и вся история есть на Хабре:

  • Здесь описано, как я участвовал в конкурсе «Инженеры будущего».

  • Здесь описание проекта и мысли о том, почему нужно добавить машинное обучение. Мысли, как оказалось, не всегда правильные.

  • Здесь у меня появляется модель на Python.

  • Здесь я запускаю предсказание на мобильном устройстве самым простым образом — с готовой моделью и классом-хелпером из Android Studio.

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

Шаг первый: в поисках TensorFlow 2.8

На сайте TensorFlow на самом деле есть обучалка для тренировки модели на устройстве, даже доступен ноутбук на платформе Colab по ссылке. Проблема в том, что код не работает с текущей версией TensorFlow. Исходя из сохранившегося вывода в первой ячейке, делаем вывод, что код создавался для версии 2.8. Я не нашел способа установить эту версию из репозиториев PyCharm, поэтому попробуем даунгрейдить версию TF в самом Google Colab. Для этого используем команду

!pip install tensorflow==2.8.0

И после этого в Colab начинается ад. Я так понимаю, что с каждым запуском сессии я закачиваю полгигабайта дистрибутивов, но это работает!

Скрытый текст

Downloading tensorflow-2.8.0-cp310-cp310-manylinux2010_x86_64.whl (497.6 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 497.6/497.6 MB 3.1 MB/s eta 0:00:00
Downloading tf_estimator_nightly-2.8.0.dev2021122109-py2.py3-none-any.whl (462 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 462.5/462.5 kB 18.1 MB/s eta 0:00:00
Downloading keras-2.8.0-py2.py3-none-any.whl (1.4 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.4/1.4 MB 29.8 MB/s eta 0:00:00
Downloading Keras_Preprocessing-1.1.2-py2.py3-none-any.whl (42 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 42.6/42.6 kB 2.7 MB/s eta 0:00:00
Downloading tensorboard-2.8.0-py3-none-any.whl (5.8 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 5.8/5.8 MB 52.1 MB/s eta 0:00:00
Downloading google_auth_oauthlib-0.4.6-py2.py3-none-any.whl (18 kB)
Downloading tensorboard_data_server-0.6.1-py3-none-manylinux2010_x86_64.whl (4.9 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4.9/4.9 MB 48.1 MB/s eta 0:00:00
Downloading tensorboard_plugin_wit-1.8.1-py3-none-any.whl (781 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 781.3/781.3 kB 31.2 MB/s eta 0:00:00

Шаг второй: создаем код для новой модели с необходимыми функциями

Как выяснилось в процессе написания предыдущей статьи, по умолчанию в моделях TensorFlow Lite содержится единственный метод для предсказания одного значения за раз. Именно поэтому нельзя было передать всю таблицу на вход модели, возникала ошибка. Мы поправим эту особенность. Кроме того, чтобы модель могла обучаться на устройстве, нам придется добавить к предсказанию одного значения дополнительные функции, а именно тренировку, сохранение и загрузку весов модели. Создадим модель с дополнительными функциями, а потом предоставим доступ к ним извне при экспорте в формат модели TensorFlow Lite.

Сейчас мы сделаем модель TensorFlow со следующими функциями:

  • тренировка модели (train),

  • предсказание результата (infer). Уберем ограничение в параметрах функции. Если раньше его форма была (1, 4) — то есть один ряд, 4 параметра, сейчас сделаем (None, 4) — неизвестное число рядов по 4 параметра. Модель сможет принимать произвольное количество наборов данных для предсказания результата,

  • сохранение весов (save), полученных в результате обучения на мобильном устройстве,
    загрузка сохраненных весов (restore) в модель для предсказания,

  • загрузка сохраненных весов (restore) в модель для предсказания.

class Model(tf.Module):

    def __init__(self):
        super().__init__()
        self.model = tf.keras.Sequential([
            tf.keras.layers.Dense(64, activation='relu'),
            tf.keras.layers.Dense(64, activation='relu'),
            tf.keras.layers.Dense(1)
        ])

        self.model.compile(loss=tf.keras.losses.MeanAbsoluteError(),
                           optimizer=tf.keras.optimizers.Adam(0.001),
                           run_eagerly=True)

    @tf.function(input_signature=[
        tf.TensorSpec([None, 4], tf.float32),
        tf.TensorSpec([None, ], tf.float32),
    ])
    def train(self, x, y):
        with tf.GradientTape() as tape:
            prediction = self.model(x)
            loss = self.model.loss(y, prediction)
        gradients = tape.gradient(loss, self.model.trainable_variables)
        self.model.optimizer.apply_gradients(
            zip(gradients, self.model.trainable_variables))
        result = {"loss": loss}
        return result

    @tf.function(input_signature=[
        tf.TensorSpec([None, 4], tf.float32),
    ])
    def infer(self, x):
        return self.model(x)

    @tf.function(input_signature=[tf.TensorSpec(shape=[], dtype=tf.string)])
    def save(self, checkpoint_path):
        tensor_names = [weight.name for weight in self.model.weights]
        tensors_to_save = [weight.read_value() for weight in self.model.weights]
        tf.raw_ops.Save(
            filename=checkpoint_path, tensor_names=tensor_names,
            data=tensors_to_save, name='save')
        return {
            "checkpoint_path": checkpoint_path
        }


    @tf.function(input_signature=[tf.TensorSpec(shape=[], dtype=tf.string)])
    def restore(self, checkpoint_path):
        restored_tensors = {}
        for var in self.model.weights:
            restored = tf.raw_ops.Restore(
                file_pattern=checkpoint_path, tensor_name=var.name, dt=var.dtype,
                name='restore')
            var.assign(restored)
            restored_tensors[var.name] = restored
        return restored_tensors

    def get_version(self):
        return "1.0"

Если вы читали предыдущие статьи, вы обратите внимание, что из модели исчез слой нормализации. Я не нашел способа, как сохранять ее параметры. То есть способ, конечно есть: при начальном обучении передавать весь датасет в конструктор для вычисления в слое нормализации этих значений, тогда они сохранятся автоматически. Но как быть с переобучением сети? Мы не можем перекомпилировать модель на мобильном устройстве. Кроме того, в моем приложении может быть множество курсов. Для каждого из них параметры будут разные. Хранить несколько моделей? нет, будет не так, но сейчас мы просто временно получим константы нормализации для дальнейших тестов.

import keras
layer = keras.layers.Normalization(axis=-1)
X = df[['id', 'cur_rating', 'n_repeat', 's_lapsed']]
y = df['result']
layer.adapt(X)
print(f"среднее значение по колонкам: \n{layer.adapt_mean.numpy()}")
print(f"отклонение по колонкам: \n{layer.adapt_variance.numpy()}")
print("не забудьте извлечь квадратный корень из отклонения, иначе вас ждут часы расследований, почему все работает не так :)")
X_norm = layer(X).numpy()
print(f"пример нормализованных значений: \n{X_norm}")

Скрытый текст

среднее значение по колонкам: 
[1.3949933e+03 1.4210526e+01 3.5873375e+00 3.0993320e+06]
отклонение по колонкам: 
[1.1811639e+06 1.2539240e+00 1.4706143e+01 4.6547405e+12]
пример нормализованных значений: 
[[-1.2826425   0.7050209  -0.6746891   1.5294912 ]
 [-1.2817223   0.7050209  -0.6746891   0.24558088]
 [-1.2808022   0.7050209  -0.6746891   0.24507335]
 ...
 [ 2.5515018  -0.18800573  0.36837405 -0.8482623 ]
 [ 2.559783   -0.18800573  0.62913984 -0.8701883 ]
 [ 2.560703   -0.18800573  1.672203   -0.87582266]]

Тестируем функции модели

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

NUM_EPOCH = 100
m = Model()
losses = np.zeros(NUM_EPOCH)
epochs = np.arange(1, NUM_EPOCH + 1, 1)
for i in range(NUM_EPOCH):
    result = m.train(x_train, y_train)
    losses[i] = result['loss']
plt.plot(epochs, losses, label='Первичное обучение')
plt.ylim([0, max(plt.ylim())])
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.legend();

вроде норм

вроде норм

После обучения используем функцию save нашей новой модели. Мы сохраняем результаты в формате TensorFlow Сheckpoint. Checkpoint позволяет загрузить в пустую модель сохраненные веса и использовать их для предсказаний или продолжить обучение с того момента, где мы остановились в момент сохранения. То есть когда мы будем в дальнейшем обновим данные в нашей таблице, модель не обязательно будет переучивать заново.

m.save('model.ckpt')
{'checkpoint_path': }

Конвертация и сохранение модели в формате TensorFlow Lite

Мы добавили новые функции в модель TensorFlow и провели ее начальное обучение. Далее мы сохраним модель, конвертируем ее в формат TF Lite c набором функций, которые будет использовать TensorFlow Lite на Android: train, infer, save, restore. Обратите внимание — если в классе модели будут методы, не начинающиеся со строчки @tf.function, то они не будут экспортированы в модель (например, def get_version ()). Все общение с окружающим миром идет через буферы TensorFlow.

И последний шаг — сохранение модели в файл, который мы потом перенесем в телефон.

SAVED_MODEL_DIR = "saved_model"
#m = Model() если нужно будет сохранить модель без весов

tf.saved_model.save(
    m,
    SAVED_MODEL_DIR,
    signatures={
        'train':
            m.train.get_concrete_function(),
        'infer':
            m.infer.get_concrete_function(),
        'save':
            m.save.get_concrete_function(),
        'restore':
            m.restore.get_concrete_function(),
    })

# Convert the model
converter = tf.lite.TFLiteConverter.from_saved_model(SAVED_MODEL_DIR)
converter.target_spec.supported_ops = [
    tf.lite.OpsSet.TFLITE_BUILTINS,  # enable TensorFlow Lite ops.
    tf.lite.OpsSet.SELECT_TF_OPS  # enable TensorFlow ops.
]
converter.experimental_enable_resource_variables = True
tflite_model = converter.convert()
with open('ruLearnModel.tflite', 'wb') as f:
    f.write(tflite_model)

Теперь можно протестировать разницу в работе предсказаний одной модели в двух форматах. Исходные значения возьмем из ячейки, где мы нормализовывали данные (напомню — модель не работает без предварительной нормализации). Результаты должны совпасть.

test_data = np.array([[-1.2826425, 0.7050209, -0.6746891, 1.5294912],
                      [2.559783, -0.18800573,  0.62913984, -0.8701883 ],
                      [ 2.560703,   -0.18800573,  1.672203,   -0.87582266],
                      [-1.2808022, 0.7050209,  -0.6746891, 0.24507335]],dtype='float32')
y_lite = infer(x=test_data)
print(f"в TF Lite получили: \n {list(y_lite.values())[0]}")
y_original = m.infer(x=test_data)
print(f"в TF получили: \n {y_original.numpy()}")

Скрытый текст

в TF Lite получили: 
 [[1.0156401 ]
 [1.0106058 ]
 [0.92316914]
 [1.0001839 ]]
в TF получили: 
 [[1.0156401 ]
 [1.0106058 ]
 [0.92316914]
 [1.0001839 ]]

Как мы видим, обе модели дают одинаковый результат. Можно переносить модель в телефон и поставить закладку, чтобы прочитать вторую часть статьи :)

© Habrahabr.ru