Тонкая настройка обучения моделей через метод compile()

В наших занятиях по пакету Keras мы часто использовали метод compile(), через который связывали модель с оптимизатором, функцией потерь и метриками:

model = keras.Sequential([
    layers.Input(shape=(784,)),
    layers.Dense(128, activation='relu'),
    layers.Dense(64, activation='relu'),
    layers.Dense(10, activation='softmax'),
])
 
model.compile(optimizer='adam',
             loss='categorical_crossentropy',
             metrics=['accuracy'])

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

  • SGD – стохастический градиентный спуск (с моментами, в том числе и Нестерова);
  • RMSprop – оптимизатор RMSprop;
  • Adam – оптимизатор Adam;
  • Adadelta – оптимизатор Adadelta;
  • Adagrad – оптимизатор Adagrad.

У каждого из этих оптимизаторов есть свой набор параметров, подробнее о них можно почитать в официальной документации:

https://keras.io/api/optimizers/

Например, класс наиболее популярного оптимизатора Adam имеет следующий синтаксис:

tf.keras.optimizers.Adam(learning_rate=0.001, beta_1=0.9, beta_2=0.999, epsilon=1e-07, amsgrad=False, name="Adam", **kwargs)

Именно с такими параметрами он вызывается по умолчанию. Если требуются другие значения, то мы должны создать экземпляр этого класса и указать нужные параметры, например, так:

model.compile(optimizer=keras.optimizers.Adam(learning_rate=0.01),
             loss='categorical_crossentropy',
             metrics=['accuracy'])

То же самое и с функциями потерь. Существует их стандартный набор в пакете Keras, например:

  • BinaryCrossentropy – бинарная кросс-энтропия;
  • CategoricalCrossentropy – категориальная кросс-энтропия;
  • KLDivergence – дивергенция Кульбака-Лейблера;
  • MeanSquaredError – средний квадрат ошибки;
  • MeanAbsoluteError – средняя абсолютная ошибка.

Полный перечень этих классов можно посмотреть на странице:

https://keras.io/api/losses/

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

Соответственно, если нам нужно обратиться непосредственно к классу, то это можно реализовать, следующим образом:

model.compile(optimizer=keras.optimizers.Adam(learning_rate=0.01),
             loss=keras.losses.CategoricalCrossentropy(),
             metrics=['accuracy'])

Наконец, метрики также можно задавать с помощью различных предопределенных классов:

  • Accuracy
  • BinaryAccuracy
  • CategoricalAccuracy

Полный перечень этих классов можно посмотреть на странице:

https://keras.io/api/metrics/

Применяются они очевидным образом:

model.compile(optimizer=keras.optimizers.Adam(learning_rate=0.01),
             loss=keras.losses.CategoricalCrossentropy(),
             metrics=[keras.metrics.CategoricalAccuracy()])

Собственные функции потерь

Конечно, при необходимости, оптимизаторы, функции потерь и метрики мы можем конструировать свои собственные. Чаще всего это относится к функциям потерь и реже к метрикам. Создавать свои «хорошие» оптимизаторы – это уже удел избранных и мы оставим это за рамками текущего курса (в принципе, встроенных вполне достаточно для большинства реальных проектов).

Итак, собственные потери в Kerasможно определить двумя способами:

  • непосредственно через функцию;
  • с помощью класса, унаследованного от keras.losses.Loss.

Начнем с наиболее простого варианта – определение своей функции потерь. Для этого достаточно объявить функцию с двумя аргументами y_true, y_pred и математическими операциями над этими тензорами, например, так:

def myloss(y_true, y_pred):
    return tf.reduce_mean(tf.square(y_true - y_pred))

Здесь мы вычисляем средний квадрат рассогласования значений между требуемым выходом y_true и спрогнозированным сетью y_pred. Все вычисления лучше всего делать через функции Tensorflow, так как они, затем, будут участвовать в автоматическом дифференцировании для вычисления градиентов. А эта операция гарантированно корректно работает именно с функциями пакета Tensorflow (например, функции NumPy здесь использовать нельзя).

После того, как функция объявлена, мы должны передать ссылку на нее в методе compile, следующим образом:

model.compile(optimizer=keras.optimizers.Adam(learning_rate=0.001),
             loss=myloss,
             metrics=[keras.metrics.CategoricalAccuracy()])

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

Второй способ определения потерь – это описание дочернего класса от базового keras.losses.Loss. Зачем это нужно?Например, если мы хотим передавать дополнительные параметры для вычисления потерь. Давайте предположим, что функция потерь имеет вид:

то есть, здесь два дополнительных параметра α и β. Как раз их можно определить, используя класс в качестве функции потерь, следующим образом:

class MyLoss(keras.losses.Loss):
    def __init__(self, alpha=1.0, beta=1.0):
        super().__init__()
        self.alpha = alpha
        self.beta = beta
 
    def call(self, y_true, y_pred):
        return tf.reduce_mean(tf.square(self.alpha * y_true - self.beta * y_pred))

Здесь в конструкторе создаются два локальных свойства alpha и beta, а затем, в методе call() производится вычисление среднего квадрата ошибок с учетом этих параметров.

В методе compile() мы можем воспользоваться этим классом, например, так:

model.compile(optimizer=keras.optimizers.Adam(learning_rate=0.001),
             loss=MyLoss(0.5, 2.0),
             metrics=[keras.metrics.CategoricalAccuracy()])

Опять же, это лишь пример того, как можно задавать пользовательские потери в пакете Keras. Необходимость в этом возникает редко – в нестандартных задачах. Как правило, мы пользуемся уже встроенными функциями или классами.

Пользовательские метрики

Наряду с потерями можно создавать и собственные метрики. Для этого описывается класс на основе базового tf.keras.metrics.Metric со следующим набором методов:

  • __init__(self) – конструктор для инициализации метрики;
  • update_state(self, y_true, y_pred, sample_weight=None) – обновление переменных состояния, которые, затем, используются в методе result() для окончательного вычисления метрики;
  • result(self) – метод для вычисления метрики на основе переменных состояния;
  • reset_states(self) – сброс переменных состояния (например, при переходе к новой эпохе при обучении нейронной сети).

Давайте на конкретном примере посмотрим, как это все работает. Я взял за основу довольно удачный пример из документации:

https://www.tensorflow.org/guide/keras/train_and_evaluate

и видоизменил его для подсчета доли правильно классифицированных данных, то есть, фактически, повторил метрику CategoricalAccuracy. Сам класс имеет следующий вид:

class CategoricalTruePositives(keras.metrics.Metric):
    def __init__(self, name="my_metric"):
        super().__init__(name=name)
        self.true_positives = self.add_weight(name="acc", initializer="zeros")
        self.count = tf.Variable(0.0)
 
    def update_state(self, y_true, y_pred, sample_weight=None):
        y_pred = tf.reshape(tf.argmax(y_pred, axis=1), shape=(-1, 1))
        y_true = tf.reshape(tf.argmax(y_true, axis=1), shape=(-1, 1))
        values = tf.cast(y_true, "int32") == tf.cast(y_pred, "int32")
 
        if sample_weight is not None:
            sample_weight = tf.cast(sample_weight, "float32")
            values = tf.multiply(values, sample_weight)
 
        values = tf.cast(values, "float32")
 
        self.true_positives.assign_add(tf.reduce_mean(values))
        self.count.assign_add(1.0)
 
    def result(self):
        return self.true_positives / self.count
 
    def reset_states(self):
        self.true_positives.assign(0.0)
        self.count.assign(0.0)

Смотрите, вначале в конструкторе мы формируем две переменные состояния: true_positives – сумма долей верной классификации; count – общее число долей. Затем, в методе update_state() мы для каждого мини-батча вычисляем вектор верной классификации (values), определяем долю правильной классификации и увеличиваем счетчик count на единицу. В методе result() делаем окончательные вычисления для метрики (вычисляем среднюю долю верной классификации), а в методе reset_states() сбрасываем переменные true_positives и count в ноль.

После этого, мы можем добавить наш класс метрики к списку метрик:

model.compile(optimizer=keras.optimizers.Adam(learning_rate=0.001),
             loss=keras.losses.CategoricalCrossentropy(),
             metrics=[keras.metrics.CategoricalAccuracy(), CategoricalTruePositives()])

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

Настройка сети с несколькими выходами

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

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

enc_input = layers.Input(shape=(28, 28, 1))
x = layers.Conv2D(32, 3, activation='relu')(enc_input)
x = layers.MaxPooling2D(2, padding='same')(x)
x = layers.Conv2D(64, 3, activation='relu')(x)
x = layers.MaxPooling2D(2, padding='same')(x)
x = layers.Flatten()(x)
hidden_output = layers.Dense(8, activation='linear')(x)
 
x = layers.Dense(7 * 7 * 8, activation='relu')(hidden_output)
x = layers.Reshape((7, 7, 8))(x)
x = layers.Conv2DTranspose(64, 5, strides=(2, 2), activation="relu", padding='same')(x)
x = layers.BatchNormalization()(x)
x = layers.Conv2DTranspose(32, 5, strides=(2, 2), activation="linear", padding='same')(x)
x = layers.BatchNormalization()(x)
dec_output = layers.Conv2DTranspose(1, 3, activation="sigmoid", padding='same', name="dec_output")(x)
 
x2 = layers.Dense(128, activation='relu')(hidden_output)
class_output = layers.Dense(10, activation='softmax', name="class_output")(x2)
 
model = keras.Model(enc_input, [dec_output, class_output])

Смотрите, здесь классификатор представляет собой обычную полносвязную сеть с десятью выходами. Затем, с помощью класса Model мы создаем модель сети с одним входом и двумя выходами.

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

  • для первого выхода – средний квадрат ошибок рассогласований между входом и выходом;
  • для второго выхода – категориальную кросс-энтропию.

Сделать это можно, по крайней мере, двумя способами. В первом случае в методе compile() указать список соответствующих функций для параметра loss:

model.compile(optimizer='adam',
             loss=['mean_squared_error', 'categorical_crossentropy']
)

Тогда средний квадрат ошибок будет связан с первым выходом модели, а категориальная кросс-энтропия – со вторым. Кроме того, при обучении в методе fit() также нужно будет указать список требуемых выходных данных для обоих выходов:

model.fit(x_train, [x_train, y_train], epochs=1)

Во втором случае мы можем в методах compile() и fit() словари, вместо списков, что может быть несколько удобнее. Например, функции потерь можно связать с выходами по их именам, следующим образом:

model.compile(optimizer='adam',
             loss={
                 'dec_output': 'mean_squared_error',
                 'class_output': 'categorical_crossentropy'
             }
)

И с метриками:

model.compile(optimizer='adam',
            loss={
                 'dec_output': 'mean_squared_error',
                 'class_output': 'categorical_crossentropy'
            },
            metrics={
                'dec_output': None,
                'class_output': 'acc'
            }
)

Обратите внимание, мы для первого выхода указали метрику None, то есть, она отсутствует. По идее, можно было бы вообще не указывать первую строчку в словаре для metrics, а оставить только вторую.

И то же самое в методе fit() – для указания выходных значений воспользуемся словарем:

model.fit(x_train, {'dec_output': x_train, 'class_output': y_train}, epochs=1)

Как видите, все достаточно удобно. При этом, общая функция потерь будет складываться из значений функция для обоих выходов. Если нам нужно дополнительно указать веса loss_weightsпри их сложении:

то это можно сделать через параметр loss_weights, следующим образом:

model.compile(optimizer='adam',
            loss={
                 'dec_output': 'mean_squared_error',
                 'class_output': 'categorical_crossentropy'
            },
            loss_weights = [1.0, 0.5],
            metrics={
                'dec_output': None,
                'class_output': 'acc'
            }
)

Здесь использован список, но также можно указать словарь.

Давайте в конце этой программы пропустим через обученный автоэнкодер первое тестовое изображение и оценим полученные выходы:

p = model.predict(tf.expand_dims(x_test[0], axis=0))
 
print( tf.argmax(p[1], axis=1).numpy() )
 
plt.subplot(121)
plt.imshow(x_test[0], cmap='gray')
plt.subplot(122)
plt.imshow(p[0].squeeze(), cmap='gray')
plt.show()

После запуска (с весами loss_weights = [1.0, 1.0]), увидим максимальное значение на 7-м выходе классификатора и следующее восстановленное изображение на декодере:

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

Видео по теме