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

Используя пакет Keras на этих занятиях, мы с вами много раз обучали модель путем вызова метода fit(). Пришло время подробнее с ним познакомиться, увидеть его возможности и особенности.

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

import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
 
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
from tensorflow.keras.datasets import mnist
 
tf.random.set_seed(1)
 
(x_train, y_train), (x_test, y_test) = mnist.load_data()
 
x_train = x_train.reshape(-1, 784) / 255.0
x_test = x_test.reshape(-1, 784) / 255.0
 
y_train = keras.utils.to_categorical(y_train, 10)
y_test = keras.utils.to_categorical(y_test, 10)
 
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'])

И, собственно, вызвать метод fit(), например, так:

model.fit(x_train, y_train, batch_size=64, epochs=5)

Это мы делали с вами уже много раз и здесь вам, в целом, должно быть все знакомо.

У метода fit() много вспомогательных параметров, которые полезно знать для обеспечения контроля за процессом обучения нейросети. Рассмотрим наиболее значимые из них. Вначале увидим, как можно определять выборку валидации, по которой мы контролируем процесс переобучения.

Выборка валидации с массивами NumPy

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

validation_split = вещественное число (от 0 до 1)

Например, если указать:

model.fit(x_train, y_train, batch_size=64, epochs=5, validation_split=0.3)

то будут взяты последние 30% наблюденийиз массивов x_train и y_train (до их перемешивания), которые мы указываем в этом же методе для обучения. По умолчанию, метод fit() перетасовывает наблюдения выборок на каждой эпохе. Так вот, 30% наблюдений отбираются до перемешивания, а остальные 70% участвуют в обучении.

Если нас не устраивает такой встроенный механизм разбиения обучающих данных, то мы можем это сделать самостоятельно. Для простоты, возьмем первые 20% наблюдений для валидации, а остальные 80% превратим в набор обучающих данных. Вначале вычислим индекс для разбиения выборки:

validation_split = 0.2
validation_split_index = np.ceil(x_train.shape[0] * validation_split).astype('int32')

А, затем, разделим наборы данных по этому индексу:

# выборка валидации
x_train_val = x_train[:validation_split_index]
y_train_val = y_train[:validation_split_index]
 
# обучающая выборка
x_train_data = x_train[validation_split_index:]
y_train_data = y_train[validation_split_index:]

После этого, в методе fit() достаточно указать выборку для обучения и для проверки:

model.fit(x_train_data, y_train_data, batch_size=64, epochs=5, validation_data=(x_train_val, y_train_val))

Как видите, здесь используется параметр validation_data для указания проверочной выборки.

Выборка валидации с tf.data.Dataset

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

tf.data

Подробная документация по ним доступна по адресу:

https://www.tensorflow.org/guide/data

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

train_dataset = tf.data.Dataset.from_tensor_slices((x_train_data, y_train_data))

На выходе получим экземпляр этого класса с выборкой (x_train, y_train). Далее, мы можем перемешать наблюдения в выборке с помощью метода shuffle и разбить их по мини-батчам, например, размером 64 элемента:

train_dataset = train_dataset.shuffle(buffer_size=1024).batch(64)

Параметр buffer_size определяет число элементов в буфере, из которого случайно выбирается по одному элементу на каждой итерации при перемешивании.

Теперь, достаточно первым параметром передать объект Dataset в метод fit() и дополнительно указать число эпох:

model.fit(train_dataset, epochs=5)

При этом аргумент batch_size уже не имеет смысла, так как мы сделали эту разбивку ранее. Также на каждой эпохе данные будут браться с самого начала (сбрасываются), поэтому сеть будет учиться на одних и тех же наблюдениях от эпохи к эпохе (параметр shuffleздесь не применяется).

Выборка валидации с таким набором данных может быть добавлена только через параметр validation_data. Другой параметр validation_split здесь игнорируется:

val_dataset = tf.data.Dataset.from_tensor_slices((x_train_val, y_train_val))
val_dataset = val_dataset.batch(64)
model.fit(train_dataset, epochs=5, validation_data=val_dataset)

Параметры steps_per_epoch и validation_steps

Если мы хотим, чтобы обучающие данные от эпохи к эпохе не сбрасывались, а последовательно проходили по мини-батчам от начала до конца, то для этого можно воспользоваться параметром

steps_per_epoch

который устанавливает число батчей на эпоху, например, так:

model.fit(train_dataset, epochs=5, steps_per_epoch = 100, validation_data=val_dataset)

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

Как правило, обучение запускают без этого параметра, но если вдруг потребуется более тонкая настройка для исследования процесса обучения, то полезно знать возможности метода fit().

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

model.fit(train_dataset, epochs=5, validation_data=val_dataset, validation_steps=5)

в конце каждой эпохи будет использовано пять мини-батчей из выборки валидации для оценки качества работы модели на текущем этапе обучения.

Параметры class_weight и sample_weight

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

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

class_weight = {
    0: 1.0,
    1: 1.0,
    2: 1.0,
    3: 1.0,
    4: 1.0,
    5: 1.0,
    6: 1.0,
    7: 1.0,
    8: 1.0,
    9: 1.0,
}

Здесь полагается, что M=10 (выходы с 0 до 9 – десять значений). В этом случае прогнозное значение тензора сети для функций потерь будет вычисляться как:

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

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

model.fit(train_dataset, epochs=5, class_weight=class_weight)

Наверное, вы уже догадались, что если веса взять неравномерными (допустим, для первого выхода зададим вес в 1000, а остальные оставим прежними), то сеть будет обучаться, в основном, на распознавание цифры 0. И для нашей сбалансированной выборки общее качество классификации снизится до 87,7%. Поэтому такой подход полезен именно для несбалансированных выборок, где классам следует присваивать разные веса (чем меньше наблюдений, тем выше вес).

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

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

В нашем примере мы сделаем более простую манипуляцию – всем изображениям единичек присвоим вес 5.0, а остальным изображениям – вес 1.0. Через NumPy сделать это достаточно просто:

sample_weight = np.ones(shape=(len(x_train),))
sample_weight[y_train == 1] = 5.0

А, затем, в методе fit() указать этот параметр со сформированной коллекцией весов:

model.fit(x_train, y_train, sample_weight=sample_weight, epochs=5)

Обратите внимание, что веса наблюдений можно устанавливать только для массивов NumPy.

Статистика обучения

Метод fit() возвращает объект, в котором хранятся значения функций потерь для тренировочной и проверочной выборок, а также вычисляемые показатели метрик:

history = model.fit(x_train, y_train, epochs=3, validation_split=0.2)
print(history.history)

После выполнения программы в консоли увидим:

{'loss': [0.2670852541923523, 0.11203867942094803, 0.07508320361375809],
'accuracy': [0.9209374785423279, 0.9661250114440918, 0.9769583344459534],
'val_loss': [0.14193743467330933, 0.11101816594600677, 0.10447544604539871],
'val_accuracy': [0.9585000276565552, 0.9664999842643738, 0.9704166650772095]}

То есть, свойство history объекта history содержит списки соответствующих показателей качества процесса обучения. Затем, их удобно отображать в виде графиков, как это мы не раз делали в курсе по нейронным сетям.

Использование генератора выборки keras.utils.Sequence

С помощью класса keras.utils.Sequence можно реализовать свой собственный генератор последовательности для обучающей выборки. Давайте посмотрим на конкретном примере как это можно сделать.

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

class DigitsLimit(keras.utils.Sequence):
    def __init__(self, x, y, batch_size, max_len = -1):
        self.batch_size = batch_size
        self.x = x[:max_len]
        self.y = y[:max_len]
 
    def __len__(self):
        return int(np.ceil(self.x.shape[0] / self.batch_size))
 
    def __getitem__(self, idx):
        batch_x = self.x[idx * self.batch_size:(idx + 1) * self.batch_size]
        batch_y = self.y[idx * self.batch_size:(idx + 1) * self.batch_size]
 
        return batch_x, batch_y

В конструкторе класса сохраняем переданные аргументы и формируем ограниченные выборки x и y размером в max_len наблюдений. Затем, определяем два магических метода, обязательных для определения генератора:

  • __len__() – возвращает число мини-батчей обучающей выборки;
  • __getitem__() – возвращает мини-батч по индексу idx.

После этого, достаточно создать экземпляр этого класса и указать его первым аргументом в метод fit():

sequence = DigitsLimit(x_train, y_train, 64, 10000)
history = model.fit(sequence, epochs=3)

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

  • многопроцессорность;
  • параметр shuffle=True в методе fit().
history = model.fit(sequence, epochs=3, shuffle=True)

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

def on_epoch_end(self):
        p = np.random.permutation(len(self.x))
        self.x = self.x[p]
        self.y = self.y[p]
        print("on_epoch_end")

Здесь мы выполняем перетасовку наблюдений выборки после каждой эпохи (фактически, повторяем работу параметра shuffle=True).

Использование обратных вызовов (callbacks)

В заключение этого занятия кратко рассмотрим возможности реализаций обратных вызовов функций в процессе обучения модели сети. Конечно, первый вопрос, что это такое и зачем они нужны?Представьте, что мы бы хотели останавливать досрочно процесс обучения, если показатель качества (функция потерь) снижается очень медленно. Возможно, мы оказались на каком-то высоком плато и алгоритм backpropagationне может выйти с этого уровня на более низкий и достичь лучших показателей при обучении. В частности, такое действие можно реализовать с помощью встроенного класса callbackEarlyStopping, следующим образом:

callbacks = [
    keras.callbacks.EarlyStopping(monitor="loss", min_delta=0.5, patience=1, verbose=1)
]
 
model.fit(x_train, y_train, epochs=3, callbacks=callbacks)

Смотрите, мы вначале определили коллекцию из экземпляров обратных вызовов и передали ее в метод fit через параметр callbacks. В результате, после каждой эпохи будет отслеживаться параметр loss (значение функции потерь) и если его изменение окажется меньше, чем 0,5 на протяжении одной эпохи, то процесс обучения досрочно прервется. Конечно, я здесь специально поставил такое большое значение (в 0,5), чтобы показать как сработает этот callback. В реальности, параметр min_delta составляет от 0,01 и проверяется на протяжении, как минимум, двух эпох. Здесь же приведены тестовые значения параметров.

После запуска увидим строчку:

Epoch 00002: early stopping

Это означает, что процесс обучения был прерван после второй эпохи.

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

  • BaseLogger – для сбора средних значений по показателям метрик;
  • History – для записи какого-либо события в историю (статистику) во время обучения сети;
  • ModelCheckpoint – для сохранения модели (или весов) с некоторой регулярностью;
  • TerminateOnNaN – для досрочной остановки процесса обучения, если значение функции потерь станет не числовым (NaN).

Существуют и другие стандартные классы для callbacks, о которых подробно можно почитать на странице документации:

https://www.tensorflow.org/api_docs/python/tf/keras/callbacks/

Из всего этого списка довольно часто применяется класс ModelCheckpoint для сохранения весов модели. Это полезно на случай возникновения какого-либо сбоя в процессе обучения. Представьте, что сеть обучалась в течение недели и из-за сбоя в аппаратуре, или даже банального отключения света все данные оказались потеряны. Чтобы этого не происходило, рекомендуется при таких длительных процессах создавать контрольные точки, сохраняя текущее состояние модели.

В качестве примера запишем класс ModelCheckpoint, со следующими параметрами:

callbacks = [
    keras.callbacks.EarlyStopping(monitor="loss", min_delta=0.01, patience=2, verbose=1),
    keras.callbacks.ModelCheckpoint(filepath="mymodel_{epoch}", save_best_only=True, monitor="loss", verbose=1),
]
  • filepath – путь к папке с данными (вместо {epoch} будет подставлен номер текущей эпохи);
  • save_best_only – флаг, указывающий производить сохранение, если только показатель качества улучшится;
  • monitor – отслеживаемый показатель качества.

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

model = keras.models.load_model('mymodel_1')

и использовать:

print(model.evaluate(x_test, y_test) )

Создание пользовательских классов вызовов

Помимо стандартных классов для обратных вызовов можно создавать свои собственные. Для этого достаточно описать класс, унаследованный от базового keras.callbacks.Callback, и определить нужный метод:

  • on_(train|test|predict)_begin(self, logs=None) – вызывается при запуске обучения, тестирования или прогнозирования;
  • on_(train|test|predict)_end(self, logs=None) – вызывается в конце соответствующих методов;
  • on_(train|test|predict)_batch_begin(self, batch, logs=None) – вызывается вначале обработки мини-батча;
  • on_(train|test|predict)_batch_end(self, batch, logs=None) – вызывается в после обработки очередного мини-батча;
  • on_epoch_begin(self, epoch, logs=None) – вызывается вначале каждой эпохи;
  • on_epoch_end(self, epoch, logs=None) – вызывается в конце каждой эпохи.

Подробное описание по существующим методам и написанию собственных классов callback’ов смотрите в документации:

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

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

class CustomCallback(keras.callbacks.Callback):
    def on_train_begin(self, logs):
        self.per_batch_losses = []
 
    def on_batch_end(self, batch, logs):
        self.per_batch_losses.append(logs.get("loss"))
 
    def on_train_end(self, logs):
        print( self.per_batch_losses[:5] )

Затем, создаем список из callback’ов:

callbacks = [
     CustomCallback(),
]

и запускаем процесс обучения:

model.fit(x_train, y_train, epochs=3, callbacks=callbacks)

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

Как видите, это достаточно полезный, простой и вместе с тем, гибкий инструмент для гибкого контроля за процессом обучения и эксплуатации нейронных сетей. На следующем занятии мы продолжим тему тонкой настройки обучения моделей и поговорим о методе compile().

Видео по теме