четверг, 27 сентября 2018 г.

TensorFlow: импорт данных - базовая механика, итераторы

Импортирование данных

tf.data API позволяет создавать комплексные пайплайны ввода из простых, переиспользуемых частей. Например, пайплайн для модели изображений может собирать данные из файлов в распределенной файловой системе, применять преобразования к каждому изображению, и соединять взятые в случайном порядке изображения в пакеты для тренировки. Пайплайн для текстовой модели может включать извлечение символов из сырых текстовых данных, их преобразование для встраивания идентификаторов в поисковую таблицу, и пакетизация последовательностей различной длины. tf.data API позволяет легко обрабатывать большие количества данных, различные форматы данных, и выполнять сложные трансформации.

tf.data API вводит две новые абстракции в TensorFlow:

  • tf.data.Dataset представляет последовательность элементов, в которой каждый элемент содержит один или более Tensor объектов. Например, в пайплайне изображений элемент может быть единичным тренировочным примером с парой тензоров, представляющих данные изображения и метку. Существует два разных способа создать набор данных (dataset):
    • Создание источника (Dataset.from_tensor_slices()) конструирует набор данных (dataset) из одного или более tf.Tensor объектов.
    • Применение трансформации (Dataset.batch()) конструирует набор данных (dataset) из одного или более tf.data.Dataset объектов.
  • tf.data.Iterator предоставляет основной способ извлечения элементов из набора данных (dataset). Операция, возвращаемая Iterator.get_next() выводит следующий элемент Dataset, когда исполняется, и обычно действует как интерфейс между пайпланом ввода и моделью. Простейший итератор - это "одноразовый итератор", который связан с отдельным Dataset и итерирует по нему один раз. Для более совершенного использования, Iterator.initializer операция позволяет реинициализировать и параметризировать итератор с различными наборами данных (datasets), так что можно, например, итерировать тренировочные и валидационные данные много раз в той же программе.

Базовая механика

Эта часть описывает основы создания разных видов Dataset и Iterator объектов, и как извлекать данные из них.

Для начала пайплайна ввода необходимо определить источник. Например, чтобы сконструировать Dataset из нескольких тензоров в памяти можно использовать tf.data.Dataset.from_tensors() или tf.data.Dataset.from_tensor_slices(). С другой стороны, если вводные данные на диске в рекомендуемом TFRecord формате, можно сконструировать tf.data.TFRecordDataset.

Как только получили Dataset объект, можно трансформировать его в новый Dataset, используя цепь вызовов методов на tf.data.Dataset объекте. Например, можно применить по-элементные трансформации, такие как Dataset.map() (для применения функции к каждому элементу), и много-элементные трансформации, такие как Dataset.batch().

Наиболее общий путь для получения данных от Dataset - это сделать объект-итератор, который предоставляет доступ к одному элемену набора (dataset'а) за один раз (например, вызвав Dataset.make_one_shot_iterator()). tf.data.Iterator предоставляет две операции: Iterator.initializer, который позволяет (ре)инициализировать состояние итератора; и Iterator.get_next(), который возвращает tf.Tensor объекты, которые соответствуют символически следующему элементу.

Структура Dataset

Dataset включает в себя элементы, каждый из которых имеет одинаковую структуру. Элемент содержит один или более tf.Tensor объектов, называемых компонентами. Каждый компонент имеет tf.DType, представляющий тип элементов в тензоре, и tf.TensorShape представляющий (возможно частично определенную) статическую форму каждого элемента. Dataset.output_types и Dataset.output_shapes свойства позволяют проверять типы и формы каждого компонента элемента набора данных (dataset). Вложенная структура этих свойств соотносится со структурой элемента, который может быть единичным тензором, таплом (tuple - тип структуры данных в python) тензоров, или вложенным таплом тензоров. Например:

dataset1 = tf.data.Dataset.from_tensor_slices(tf.random_uniform([4, 10]))
print(dataset1.output_types)  # ==> "tf.float32"
print(dataset1.output_shapes)  # ==> "(10,)"

dataset2 = tf.data.Dataset.from_tensor_slices(
   (tf.random_uniform([4]),
    tf.random_uniform([4, 100], maxval=100, dtype=tf.int32)))
print(dataset2.output_types)  # ==> "(tf.float32, tf.int32)"
print(dataset2.output_shapes)  # ==> "((), (100,))"

dataset3 = tf.data.Dataset.zip((dataset1, dataset2))
print(dataset3.output_types)  # ==> (tf.float32, (tf.float32, tf.int32))
print(dataset3.output_shapes)  # ==> "(10, ((), (100,)))"

Часто удобно давать имена каждому компоненту элемента, например если они представляют разные свойства тренировочного примера. Вдобавок к таплам (tuples) можно использовать collections.namedtuple или словарь, картирующий строки к тензорам для представления одного элемента в Dataset.

dataset = tf.data.Dataset.from_tensor_slices(
   {"a": tf.random_uniform([4]),
    "b": tf.random_uniform([4, 100], maxval=100, dtype=tf.int32)})
print(dataset.output_types)  # ==> "{'a': tf.float32, 'b': tf.int32}"
print(dataset.output_shapes)  # ==> "{'a': (), 'b': (100,)}"

Dataset трансформации поддерживают наборы данных (datasets) любой структуры. При использовании Dataset.map(), Dataset.flat_map(), и Dataset.filter() трансформаций, которые применяют функцию к каждому элементу, структура элементов определяется аргументами функции:

dataset1 = dataset1.map(lambda x: ...)

dataset2 = dataset2.flat_map(lambda x, y: ...)

dataset3 = dataset3.filter(lambda x, (y, z): ...)

Создание итератора

Как только построен Dataset для представления вводных данных, следующий шаг - создать Iterator для доступа к элементам из этого набора данных (dataset). tf.data API на данный момент поддерживает следующие итераторы, в порядке увеличения сложности:

  • одноразовый (one-shot)
  • инициализируемый (initializable)
  • реинициализируемый (reinitializable)
  • загрузочный (feedable)

Одноразовый (one-shot) итератор - это простейшая форма итератора, которая поддерживает только однократное итерирование по набору данных (dataset), без необходимости явной инициализации. Одноразовые итераторы обрабатывают почти все случаи, которые существуют в поддерживаемых пайплайнах ввода, основанных на очереди. Но они не поддерживают параметризацию. Пример использования Dataset.range():

dataset = tf.data.Dataset.range(100)
iterator = dataset.make_one_shot_iterator()
next_element = iterator.get_next()

for i in range(100):
  value = sess.run(next_element)
  assert i == value

Инициализируемый (initializable) итератор требует выполнения явной iterator.initializer операции перед его использованием. В обмен на такое неудобство это позволяет параметризировать определение набора данных (dataset), используя один или более tf.placeholder() тензоров, которые могут быть переданы при инициализации итератора. Продолжая Dataset.range() пример:

max_value = tf.placeholder(tf.int64, shape=[])
dataset = tf.data.Dataset.range(max_value)
iterator = dataset.make_initializable_iterator()
next_element = iterator.get_next()

# Инициализируем итератор для набора данных с 10 элементами.
sess.run(iterator.initializer, feed_dict={max_value: 10})
for i in range(10):
  value = sess.run(next_element)
  assert i == value

# Инициализируем тот же итератор для набора данных со 100 элементами.
sess.run(iterator.initializer, feed_dict={max_value: 100})
for i in range(100):
  value = sess.run(next_element)
  assert i == value

Реинициализируемый (reinitializable) итератор может быть инициализирован из нескольких различных Dataset объектов. Например, может существовать тренировочный пайплайн ввода, который использует преобразования изображений ввода, применяемые в случайном порядке, для улучшения генерализации, и валидационный пайплайн ввода, который оценивает прогнозы на немодифицированных данных. Эти пайплайны обычно используют разные Dataset объекты с одной и той же структурой (то есть одинаковые типы и совместимые формы для каждого компонента).

# Определяем тренировочный и валидационный наборы данных 
# с одинаковой структурой.
training_dataset = tf.data.Dataset.range(100).map(
    lambda x: x + tf.random_uniform([], -10, 10, tf.int64))
validation_dataset = tf.data.Dataset.range(50)

# Реинициализируемый итератор определяется этими структурами.
# Можно использовать output_types и output_shapes свойства
# объектов training_dataset или validation_dataset здесь,
# поскольку они совместимы.
iterator = tf.data.Iterator.from_structure(
                                     training_dataset.output_types,
                                     training_dataset.output_shapes)
next_element = iterator.get_next()

training_init_op = iterator.make_initializer(training_dataset)
validation_init_op = iterator.make_initializer(validation_dataset)

# Выполняем 20 эпох, в которых тренировочный набор данных итерируется,
# а следом и валидационный набор.
for _ in range(20):
  # Инициализируем итератор для тренировочного набора.
  sess.run(training_init_op)
  for _ in range(100):
    sess.run(next_element)

  # Инициализируем итератор для валидационного набора.
  sess.run(validation_init_op)
  for _ in range(50):
    sess.run(next_element)

Загружаемый (feedable) итератор может быть использован вместе с tf.placeholder для выбора того какой Iterator использовать в каждом вызове tf.Session.run посредством feed_dict механизма. Он предоставляет ту же функциональность, что и реинициализируемый итератор, но он не требует инициализации итератора со старта набора данных, когда переключаемся между итераторами. Например, используя те же тренировочный и валидационный пример как выше, можно использовать tf.data.Iterator.from_string_handle, чтобы определить загружаемый итератор, который позволяет переключаться между двумя наборами данных:

# Определяем тренировочный и валидационный датасеты 
# с одинаковой структурой
training_dataset = tf.data.Dataset.range(100).map(
    lambda x: x + tf.random_uniform([], -10, 10, tf.int64)).repeat()
validation_dataset = tf.data.Dataset.range(50)

# Загружаемый итератор определен обработчиком placeholder
# и его структурой.
# Можно использовать output_types и output_shapes свойства
# объектов training_dataset или validation_dataset здесь,
# поскольку они имеют одинаковую структуру.
handle = tf.placeholder(tf.string, shape=[])
iterator = tf.data.Iterator.from_string_handle(
    handle, training_dataset.output_types, 
            training_dataset.output_shapes)
next_element = iterator.get_next()

# Можно использовать загружаемый итератор 
# с различными другими итераторами,
# такими как одноразовый и инициализируемый
training_iterator = training_dataset.make_one_shot_iterator()
validation_iterator = validation_dataset.make_initializable_iterator()

# Метод `Iterator.string_handle()` возвращает тензор, 
# который может быть оценен
# и использован для наполнения `handle` placeholder.
training_handle = sess.run(training_iterator.string_handle())
validation_handle = sess.run(validation_iterator.string_handle())

# Итерируем постоянно, переключаясь между тренировкой и валидацией.
while True:
  # Проходим 200 шагов, используя тренировочный датасет.
  # Отметим, что тренировочный датасет бесконечный,
  # и мы продолжаем с того места, где остановились на
  # предыдущей итерации `while` цикла.
  for _ in range(200):
    sess.run(next_element, feed_dict={handle: training_handle})

  # Делаем один проход по валидационному датасету.
  sess.run(validation_iterator.initializer)
  for _ in range(50):
    sess.run(next_element, feed_dict={handle: validation_handle})

Получение значений из итератора

Метод Iterator.get_next() возвращает один или более tf.Tensor объектов, которые соотвествуют символически следующему элементу итератора. Каждый раз эти тензоры оцениваются, они принимают значение следующего элемента в подлежащем датасете. (Отметим что, как и другие объекты состояния в TensorFlow, вызываемый Iterator.get_next() не сразу продвигает итератор. Необходимо использовать возвращаемые tf.Tensor объекты в TensorFlow выражении, и передавать результат этого выражения в tf.Session.run(), чтобы получить следующиь элементы и продвинуть итератор.)

Если итератор достигает конца датасета (набора данных), исполнение Iterator.get_next() операции вызовет tf.errors.OutOfRangeError исключение. После этой точки итератор будет в неиспользуемом состоянии, и для дальнейшего использования его необходимо инициализировать снова.

dataset = tf.data.Dataset.range(5)
iterator = dataset.make_initializable_iterator()
next_element = iterator.get_next()

# Обычно `result` будет выводом модели,
# или тренировочной операцией оптимизиатора.
result = tf.add(next_element, next_element)

sess.run(iterator.initializer)
print(sess.run(result))  # ==> "0"
print(sess.run(result))  # ==> "2"
print(sess.run(result))  # ==> "4"
print(sess.run(result))  # ==> "6"
print(sess.run(result))  # ==> "8"
try:
  sess.run(result)
except tf.errors.OutOfRangeError:
  print("End of dataset")  # ==> "End of dataset"

Общепринятный паттерн - обернуть "тренировочный цикл" в try-except блок:

sess.run(iterator.initializer)
while True:
  try:
    sess.run(result)
  except tf.errors.OutOfRangeError:
    break

Если каждый элемент датасета имеет вложенную структуру, то возвращаемое значение Iterator.get_next() будет одним или более tf.Tensor объектов в той же вложенной структуре:

dataset1 = tf.data.Dataset.from_tensor_slices(
                                       tf.random_uniform([4, 10]))
dataset2 = tf.data.Dataset.from_tensor_slices(
            (tf.random_uniform([4]), tf.random_uniform([4, 100])))
dataset3 = tf.data.Dataset.zip((dataset1, dataset2))

iterator = dataset3.make_initializable_iterator()

sess.run(iterator.initializer)
next1, (next2, next3) = iterator.get_next()

Отметим, что next1, next2, и next3 - это тензоры, произведенные одним и тем же опом(op)/узлом(node), созданным Iterator.get_next(). Поэтому оценка любого из этих тензоров продвинет итератор для всех компонентов. Обычный приемник будет включать все компоненты в одном выражении.

Сохранение состояние итератора

Функция tf.contrib.data.make_saveable_from_iterator создает SaveableObject из итератора, который может быть использован для сохранения и восстановления текущего состояния итератора (и целого пайплайна ввода). Сохраняемый объект, созданный таким образом, может быть добавлен в tf.train.Saver лист переменных или tf.GraphKeys.SAVEABLE_OBJECTS коллекцию для сохранения и восстановления в той же манере как tf.Variable.

# Создаем сохраняемый объект из итератора.
saveable = tf.contrib.data.make_saveable_from_iterator(iterator)

# Сохраняем состояние итератора добавлением его 
# к коллекции сохраняемых объектов.
tf.add_to_collection(tf.GraphKeys.SAVEABLE_OBJECTS, saveable)
saver = tf.train.Saver()

with tf.Session() as sess:

  if should_checkpoint:
    saver.save(path_to_checkpoint)

# Восстанавливаем состояние итератора.
with tf.Session() as sess:
  saver.restore(sess, path_to_checkpoint)

О дальнейших возможностях импорта данных в TensorFlow читайте в следующих постах.