Основы работы с TensorFlow и Keras на Python. Часть 1

Как известно, TensorFlow - это основанная на Python, бесплатная, open-source платформа машинного обучения, разработанная в основном Google. Keras - это API глубокого обучения на Python, которое облегчает использование TensorFlow. Изначально Keras был построен вокруг библиотеки Theano, затем поддерживал несколько бэкендов (Theano, CNTK, MXNet, Tensorflow), сейчас остался только один бэкенд Tensorflow.

Использовать библиотеки глубокого обучения желательно с GPU для ускорения работы. Для этого есть три возможности:
1. Купить NVIDIA GPU.
2. Использовать Google Cloud Platform или AWS EC2.
3. Использовать бесплатный Colaboratory - сервис "блокнотов" Jupyter от Google.
Colaboratory - самый простой способ для начала, поскольку он не требует покупки оборудования или установки программ. Однако, бесплатно Colaboratory подходит только для небольших тестов.
colab.research.google.com

Считается, что блокноты Jupyter - наиболее предпочтительный способ запуска экспериментов по глубокому обучению. Блокнот - это файл, сгенерированный приложением Jupyter Notebook App (jupyter.org), который можно редактировать в браузере. Вы можете совмещать код на Python и формат Rich Text, чтобы комментировать свои действия. Блокнот так же позволяет разбить длинный эксперимент на меньшие части, которые могут выполняться независимо. Это делает разработку более интерактивной, то есть не нужно перезапускать весь предыдущий код, если что-то в эксперименте не удалось. Кроме блокнотов Jupyter разработку на Keras можно вести и в обычном файле Python/IDE.
Блокноты Colaboratory выглядят так:

Экранная форма сервиса Colaboratory

Экранная форма сервиса Colaboratory

Обучение нейронных сетей вращается вокруг следующих идей:
Во-первых, манипуляции с низкоуровневыми тензорами - инфраструктура, лежащая в основе всего современного машинного обучения. В TensorFlow APIs они представлены так:
1. Тензоры, хранящие состояние сети (переменные).
2. Тензоры-операции, такие как сложение, relu, matmul.
3. Метод обратного распространения ошибки - способ вычислить градиент математических выражений (управляется в TensorFlow через объект GradientTape).

Во-вторых, высокоуровневые концепции глубокого обучения. В Keras APIs они представлены так:

1. Слои, комбинируемые в модель.
2. Функция потерь, которая определяет обратную связь обучения.
3. Оптимизатор, определяющий как обучение продвигается.
4. Метрики для вычисления производительности модели, например точность.
5. Цикл обучения, производящий стохастический градиентный спуск для мини-корзины (подвыборки).

Теперь посмотрим как эти концепции применяются на практике в TensorFlow и Keras.

Tensorflow API

Тензор, заполненный только единицами:

>>> import tensorflow as tf
>>> x = tf.ones(shape=(1, 2))
>>> print(x)
tf.Tensor([[1.] [1.]], shape=(1, 2), dtype=float32)

Тензор, заполненный только нулями:

>>> x = tf.zeros(shape=(2, 1))
>>> print(x)
tf.Tensor( [[0.]
           [0.]], shape=(2, 1), dtype=float32)

Cлучайные тензоры

>>> x = tf.random.normal(shape=(3, 1), mean=0., stddev=1.)
>>> print(x)
tf.Tensor(
[[-0.14208166]
 [-0.95319825]
 [ 1.1096532 ]], shape=(3, 1), dtype=float32)
>>> x = tf.random.uniform(shape=(3, 1), minval=0., maxval=1.)
>>> print(x)
tf.Tensor(
[[0.33779848]
 [0.06692922]
 [0.7749394 ]], shape=(3, 1), dtype=float32)
 

Значительное отличие массивов NumPy от тензоров TensorFlow в том, что тензоры TensorFlow неизменяемые. А ведь чтобы обучить модель, необходимо обновить её состояние, то есть установить новое значение тензора. Но как быть, если тензоры неизменяемые? Здесь нам помогут переменные (tf.Variable). Для создания переменной, необходимо указать некоторое начальное значение, например случайный тензор:

>>> v = tf.Variable(initial_value=tf.random.normal(shape=(3, 1)))
>>> print(v)
array([[-0.75133973],
       [-0.4872893 ],
       [ 1.6626885 ]], dtype=float32)>

Состояние переменной можно изменить с помощью метода assign:

>>> v.assign(tf.ones((3, 1)))
array([[1.],
       [1.],
       [1.]], dtype=float32)>

Аналогично работает и для части переменной (значения массива):

>>> v[0, 0].assign(3.)
array([[3.],
       [1.],
       [1.]], dtype=float32)>

Аналогично, assign_add и assign_sub - эффективные эквиваленты += и -=:

>>> v.assign_add(tf.ones((3, 1)))
array([[2.],
       [2.],
       [2.]], dtype=float32)>

Как и NumPy, TensorFlow предлагает коллекцию операций с тензорами для выражения математических формул. Вычисления выполняются сразу же (eager execution). Несколько примеров:

a = tf.ones((2, 2))
b = tf.square(a)
c = tf.sqrt(a)
d = b + c
e = tf.matmul(a, b)
e *= d

Tensorflow может вычислить градиент любого выражения, если оно дифференцируемо. Например наиболее частый способ получения градиентов весов модели с учётом её потерь:

input_var = tf.Variable(initial_value=3.)
with tf.GradientTape() as tape:
   result = tf.square(input_var)
gradient = tape.gradient(result, input_var)

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

input_const = tf.constant(3.)
with tf.GradientTape() as tape:
   tape.watch(input_const)
   result = tf.square(input_const)
gradient = tape.gradient(result, input_const)

Класс GradientTape позволяет вычислять так же градиенты второго порядка, то есть градиент градиента. Например, градиент координаты объекта по времени - это скорость объекта, второй градиент - это ускорение. Подсчитаем ускорение падающего яблока по вертикальной оси с формулой координаты x(t) = 4.9*t^2:

time = tf.Variable(0.)
with tf.GradientTape() as outer_tape:
  with tf.GradientTape() as inner_tape:
    x =  4.9 * time ** 2
  speed = inner_tape.gradient(x, time)
acceleration = outer_tape.gradient(speed, time)
 

Простейшая задача машинного обучения - линейная классификация. Покажем, как она реализуется на чистом TensorFlow. Начнём с генерации синтетических входных данных, которые линейно разделимы на два класса в 2D пространстве:

 
num_samples_per_class = 1000
negative_samples = np.random.multivariate_normal(
    mean=[0, 3], cov=[[1, 0.5],[0.5, 1]], size=num_samples_per_class)
positive_samples = np.random.multivariate_normal(
    mean=[3, 0], cov=[[1, 0.5],[0.5, 1]], size=num_samples_per_class)

negative_samples и positive_samples - два массива формы (1000, 2), то есть двумерный массив с 1000 строк и 2 столбцов. Объединим их в один массив формы (2000, 2):

inputs = np.vstack((negative_samples, positive_samples)).astype(np.float32)

Сгенерируем целевой массив ответов - массив 0 и 1 формы (2000, 1), в котором targets[i, 0] = 0, если inputs[i] относится к классу №0, аналогично для класса №1:

targets = np.vstack((np.zeros((num_samples_per_class, 1), dtype='float32'),
                  np.ones((num_samples_per_class, 1), dtype='float32')))

Нарисуем наши данные библиотекой Matplotlib:

import matplotlib.pyplot as plt
plt.scatter(inputs[:, 0], inputs[:, 1], c=targets[:, 0])
plt.show()
2

Теперь обучим линейный классификатор для разделения этих двух наборов точек. Линейный классификатор - это аффинное преобразорвание вида prediction = W • input + b, тренируемое для минимизации квадрата разности между выводом predictions и целью targets. Создадим переменные W и b, инициализируем их случайными значениями и нулями соответственно:

input_dim = 2
output_dim = 1
W = tf.Variable(initial_value=tf.random.uniform(shape=(input_dim, output_dim)))
b = tf.Variable(initial_value=tf.zeros(shape=(output_dim,)))

Функция прямого обхода:

def model(inputs):
    return tf.matmul(inputs, W) + b

Поскольку наш линейный классификатор оперирует 2D точками, W в сущности - два коэффициента-скаляра w1 и w2: W = [[w1], [w2]]. В тоже время, b - просто скалярный коэффициент. Следовательно формула prediction принимает вид prediction = [[w1], [w2]] • [x, y] + b = w1 * x + w2 * y + b.

Такая будет функция потерь:

def square_loss(targets, predictions):
    per_sample_losses = tf.square(targets - predictions)
    return tf.reduce_mean(per_sample_losses)

Наконец, шаг обучения, который обновляет веса W and b таким образом, чтобы минимизировать функцию loss:

learning_rate = 0.1

def training_step(inputs, targets):
    with tf.GradientTape() as tape:
        predictions = model(inputs)
        loss = square_loss(predictions, targets)
    grad_loss_wrt_W, grad_loss_wrt_b = tape.gradient(loss, [W, b])
    W.assign_sub(grad_loss_wrt_W * learning_rate)
    b.assign_sub(grad_loss_wrt_b * learning_rate)
    return loss

Для простоты, мы обучаем на всей входной выборке (batch training), вместо того, чтобы обучать по частям (mini-batch training). Каждый шаг обучения будет дольше, но эффективнее уменьшать функцию потерь. Следовательно потребуется меньше обучающих итераций и более высокая скорость обучения (в нашем примере 0.1), чем она была бы для разбитой на части входной выборки (mini-batch).

for step in range(20):
    loss = training_step(inputs, targets)
    print('Loss at step %d: %.4f' % (step, loss))

Вывод следующий:

Loss at step 0: 4.0583
Loss at step 1: 0.6692
Loss at step 2: 0.2139
Loss at step 3: 0.1441
...
Loss at step 27: 0.0267
Loss at step 28: 0.0266
Loss at step 29: 0.0264

Через 30 итераций значение функции потерь будет 0.0264. Поскольку наши целевые значения только 0 и 1, то всё, что ниже 0.5 относится к классу №0, выше - к классу №1. Нарисуем результат обучения линейного классификатора:

predictions = model(inputs)
plt.scatter(inputs[:, 0], inputs[:, 1], c=predictions[:, 0] >= 1.0)
plt.show()
3

Итак, всё, что ниже 0.5 относится к классу №0, а всё, что выше - к классу №1. Это означает, что мы можем провести разделяющую линию с формулой w1 * x + w2 * y + b = 0.5. Преобразовав формулу линии к виду y = a * x + b, получаем y = - w1 / w2 * x + (0.5 - b) / w2. Нарисуем эту линию:

x = np.linspace(-1, 4, 100)
y = - W[0] /  W[1] * x + (0.5 - b) / W[1]
plt.plot(x, y, '-r')
plt.scatter(inputs[:, 0], inputs[:, 1], c=predictions[:, 0] > 0.5)
4
Keras API

Перейдём к Keras API. Фундаментальная структура данных нейронной сети - слой. Слой обрабатывает входные данные от одного или нескольких входных тензоров и выводит один или более тензоров. Некоторые слои не имеют состояний, но чаще у слоёв есть веса, один или несколько обученных тензоров методом стохастического градиентного спуска. Всё это вместе образует "знания" сети. Разные виды слоёв пригодны для своих форматов тензоров и типов обрабатываемых данных. Например, простой вектор с данными, хранящий 2D тензор формы shape (samples, features) как правило обрабатывается полносвязными слоями (класс Dense в Keras). Последовательные данные, хранящиеся в 3D тензорах формы shape (samples, timesteps, features) как правило обрабатываются рекуррентными слоями (например LSTM) или 1D свёртывающими слоями (Conv1D). Данные изображений, хранящиеся в 4D тензорах, как правило, обрабатываются 2D свёртывающими слоями (Conv2D). Построение моделей глубокого обучения в Keras выполняется путём объединения слоёв в конвейер (pipeline).

Keras API построен вокруг главной абстракции слоя (Layer). Layer - объект, инкапсулирующий некоторое состояние (веса) и вычисления (прямой обход). Веса, как правило, объявлены в build(), хотя их можно создать и в конструкторе init(), а вычисления объявлены в методе call():

from tensorflow import keras

class SimpleDense(keras.layers.Layer):

    def __init__(self, units, activation=None):
        super(SimpleDense, self).__init__()
        self.units = units
        self.activation = activation

    def build(self, input_shape):
        input_dim = input_shape[-1]
        self.W = self.add_weight(shape=(input_dim, self.units),
                                 initializer='random_normal')
        self.b = self.add_weight(shape=(self.units,),
                                 initializer='zeros')

    def call(self, inputs):
        y = tf.matmul(inputs, self.W) + self.b
        if self.activation is not None:
            y = self.activation(y)
        return y

Как только слой настроен, его можно использовать как обычную функцию, принимающую аргументы в виде тензоров TensorFlow:

>>> my_dense = SimpleDense(units=32, activation=tf.nn.relu)
>>> input_tensor = tf.ones(shape=(2, 784))
>>> output_tensor = my_dense(input_tensor)
>>> print(output_tensor.shape)
(2, 32)

Возможно, возникает вопрос, зачем реализовывать методы call() и build(), если мы используем слой только для вызыва его конструктора и метода  __call__. Это необходимо, работы самого Keras и создания слоёв "налету". Рассмотрим, как это работает. Нам необходимо только объединять подходящие друг другу слои. Подходящие в смысле формы входных-выходных тензоров. Keras же автоматически вычисляет формы тензоров. Например:

 
from tensorflow.keras import layers
layer = layers.Dense(32, activation='relu')

Здесь аргумент, равный 32, - выходная размерность. Этот слой вернёт тензор, в котором первое измерение будет преобразовано к размеру 32. Его можно соединить только с нижеследующим слоем, который принимает на вход вектор размерности 32. С Keras не нужно заботиться о совместимости размерностей в большинстве случаев, потому что слои, добавляемые к модели динамически создаются так, чтобы соответствовать форме предшествующего слоя. Например, мы сделаем следующее:

from tensorflow.keras import models
from tensorflow.keras import layers
model = models.Sequential([
  layers.Dense(32, activation='relu'),
  layers.Dense(32)
])

Слои не принимают информацию о форме их входов, эта информация вычисляется автоматически из формы первого входа. Это становится возможным благодаря методам build() и call(). Функция вызова базового класса схематично выглядит так:

 
def __call__(self, inputs):
    if not self.built:
         self.build(inputs.shape)
         self.built = True
    return self.call(inputs)

Модель глубокого обучения - это граф слоёв. В Keras это класс Model. Пока вы видели только последовательные модели (Sequential, подкласс Model), которые представляют собой набор слоёв с одним входом и выходом. Существуют и другие топологии:

1 Сети с двумя ветвями (Two-branch networks).
2 Сети с множеством "голов" (Multihead networks).
3 Остаточная нейронная сеть (Residual connections).
Топологии могут быть запутанными. Вот, например, топология графа слоёв архитектуры Transformer, применяемая для обработки текстовой информации:

5

Как только архитекрута сети будет выбрана, необходимо ещё 3 вещи:
1. Функция потерь - величина, минимизируемая во время обучения. Она определяет меру успеха задачи.
2. Оптимизатор - алгоритм, определяющий как обновить сеть, исходя из функции потерь. Здесь реализуется специфический вариант стохастического градиентного спуска.
3. Метрики - величины, которые необходимо отслеживать во время обучения и проверки, например точность классификации. В отличие от потерь, при обучении эти величины не оптимизируются напрямую.
Как только выбрана функция потерь, оптимизатор и метрики, можно использовать встроенные методы compile() и fit() для обучения модели. При необходимости можно написать собственный цикл обучения. Использование compile():

model = keras.Sequential([keras.layers.Dense(1)])
model.compile(optimizer='rmsprop',
              loss='mean_squared_error',
              metrics=['accuracy'])

Аналогично вместо строк в аргументах можно использовать объекты Python:

model.compile(optimizer=keras.optimizers.RMSprop(),
              loss=keras.losses.MeanSquaredError(),
              metrics=[keras.metrics.BinaryAccuracy()])

Это полезно в случае собственных функций потерь, метрик или более тонкой настройки, например, передачи скорости обучения:

model.compile(optimizer=keras.optimizers.RMSprop(learning_rate=1e-4),
              loss=my_custom_loss,
              metrics=[my_custom_metric_1, my_custom_metric_2])

В общем случае, создавать свои функции потерь, метрики или оптимизаторы не нужно, поскольку Keras предлагает широкий выбор опций.
Оптимизаторы:
- SGD() (с или без моментум)
- RMSprop()
- Adam()
- Adagrad()
...

Функции потерь:
- CategoricalCrossentropy()
- SparseCategoricalCrossentropy()
- BinaryCrossentropy()
- MeanSquaredError()
- KLDivergence()
- CosineSimilarity()
...

Метрики:
- CategoricalAccuracy()
- SparseCategoricalAccuracy()
- BinaryAccuracy()
- AUC()
- Precision()
- Recall()
...

Выбор правильной функции потерь чрезвычайно важен. Если она не будет полностью коррелировать с успехом задачи, сеть может начать делать вещи, которые вы не ожидали. К счастью, для проблем классификации, регрессии и последовательных предсказаний есть простые советы для выбора правильной функции потерь. Например, для проблемы выбора из двух классов подходит бинарная перекрёстная энтропия (binary crossentropy), для проблемы многоклассового выбора категорическая перекрёстная энтропия (categorical crossentropy).

За методом compile() следует fit(). Метод fit() непосредственно реализует обучающий цикл. Ключевые аргументы:
- входные данные и целевые значения. Обычно в форме массивов NumPy объекта Dataset из TensorFlow.
- количество итераций обучения
- размер подвыборки (mini-batch)

history = model.fit(
  inputs,
  targets,
  epochs=5,
  batch_size=128
)

Вызов функции fit () вернёт объект History . Он хранит историю в виде словаря, например, потери или другую метрику для каждой итерации обучения.

>>> history.history
{'binary_accuracy': [0.855, 0.9565, 0.9555, 0.95, 0.951],
 'loss': [0.6573270302042366,
  0.07434618508815766,
  0.07687718723714351,
  0.07412414988875389,
  0.07617757616937161]}

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

model = keras.Sequential([keras.layers.Dense(1)])
model.compile(optimizer=keras.optimizers.RMSprop(learning_rate=0.1),
              loss=keras.losses.MeanSquaredError(),
              metrics=[keras.metrics.BinaryAccuracy()])

indices_permutation = np.random.permutation(len(inputs))
shuffled_inputs = inputs[indices_permutation]
shuffled_targets = targets[indices_permutation]

num_validation_samples = int(0.3 * len(inputs))
val_inputs = shuffled_inputs[-num_validation_samples:]
val_targets = shuffled_targets[-num_validation_samples:]
training_inputs = shuffled_inputs[:num_validation_samples]
training_targets = shuffled_targets[:num_validation_samples]
model.fit(
  training_inputs,
  training_targets,
  epochs=5,
  batch_size=16,
  validation_data=(val_inputs, val_targets)

Метод evaluate вычисляет потери на проверочной выборке и другие метрики после завершению обучения:

loss_and_metrics = model.evaluate(val_inputs, val_targets, batch_size=128)

Когда обучение закончено, вы можете захотеть получить выводы на новых данных (inference). Для этого просто:

predictions = model(new_inputs)

Такой способ вычислит результат для всех входных данных, что не всегда может быть удобно из-за размера входных данных, ограниченности видеопамяти. Другой способ использовать метод predict(), которому можно указать рабочий размер выборки (batch). Этот метод так же принимает на вход объект TensorFlow Dataset.

predictions = model.predict(new_inputs, batch_size=128)