Привет, меня зовут Галина Олейник, я занимаюсь решением задач в сфере natural language processing в компании 1touch.io. Сегодня я хотела бы рассказать об актуальной теме работы data scientist’а с фреймворком TensorFlow, а также углубиться в детали решения наиболее частых проблем, которые возникают при взаимодействии с ним.
Когда речь заходит о написании кода на TensorFlow, зачастую это заканчивается его сравнением с PyTorch, разговорами о том, насколько сложен этот фреймворк и почему некоторые части tf.contrib
работают так плохо. Более того, я знаю многих data scientist’ов, которые взаимодействуют с TensorFlow только как с зависимостью уже написанного Github’овского репозитория. Причины такого отношения к этому фреймворку очень разные, и они заслуживают написания еще одного лонгрида. Сегодня я предлагаю сфокусироваться на более прагматичных проблемах: дебаг кода на TensorFlow и понимание его основных особенностей.
Ключевые абстракции
Вычислительный граф
Первая абстракция, которая делает фреймворк таким сложным для понимания и позволяет взаимодействовать с парадигмой lazy evaluation — это вычислительный граф tf.Graph
. По сути, этот подход позволяет разработчику создавать тензоры tf.Tensor
(грани) и tf.Operation
(ноды), которые не вычисляются сразу же, а только когда граф выполняется. Такой метод создания моделей машинного обучения распространен во многих фреймворках и имеет различные недостатки и достоинства, которые становятся очевидными во время написания и запуска кода.
Основным и наиболее важным преимуществом является то, что dataflow-графы позволяют использовать возможности параллелизма и распределенного выполнения достаточно легко, без явной работы с модулем multiprocessing
. На практике, хорошо написанная модель TensorFlow использует ресурсы всех ядер, как только она запущена (без какой-либо дополнительной конфигурации).
Однако недостатком такого подхода является то, что до тех пор, пока мы создадим граф, но не запустим его, мы не можем быть уверенными, что он не сломается.
Оговорюсь, что в этой статье не будут рассмотрены возможности tf.enable_eager_execution()
, поскольку хоть этот режим и является встроенной возможностью TensorFlow, он в какой-то мере противоречит самой сути вычислительного графа и его дефолтным возможностям.
Более того, до тех пор, пока мы не выполнили граф, мы также не можем оценить приблизительное время его выполнения.
Основные компоненты вычислительного графа, которые стоит упомянуть, — коллекции графа и структура графа. Строго говоря, структура графа — это определенный набор нод и граней, упомянутых ранее, а коллекции графа — это наборы переменных, которые могут быть сгруппированы логическим образом. К примеру, распространенный способ получения тренируемых переменных графа: tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES)
.
Сессия
Вторая абстракция тесно связана с первой и имеет чуть более сложную трактовку: сессия TensorFlow tf.Session
используется для связи между клиентской программой и C++ runtime. Почему С++? Ответ заключается в том, что математические вычисления, реализованные с помощью этого языка, могут быть очень хорошо оптимизированы. В результате операции графа могут быть обработаны с высокой производительностью.
При использовании стандартного низкоуровневого TensorFlow API, сессия вызывается в качестве контекстного менеджера: использован синтаксис with tf.Session() as sess:
. Если не передать в конструктор ни одного аргумента, сессия использует только ресурсы локальной машины и дефолтный глобальный TensorFlow граф. Если передать в конструктор сессии какие-то аргументы, она также может иметь доступ к удаленным устройствам с помощью распределенного TensorFlow runtime. На практике, граф не может существовать без сессии (без сессии он не может быть выполнен), и сессия всегда имеет указатель на глобальный граф.
Углубляясь в детали запуска сессии, отмечу, что основным пунктом является его синтаксис: tf.Session.run()
. Он может иметь fetch в качестве аргумента (или их список), который может быть тензором, операцией или производным от тензора объектом. К тому же feed_dict может быть передан вместе со списком необязательных опций. Этот необязательный аргумент является мэппингом (словарем) объектов tf.placeholder
к их значениям.
Возможные проблемы и их наиболее вероятные решения
Загрузка сессии и создание предсказаний с помощью натренированной модели
Чтобы понять, отдебажить и пофиксить этот bottleneck, у меня ушло несколько недель. Я бы хотела максимально сконцентрироваться на этой проблеме и описать два возможных подхода к загрузке натренированной модели и дальнейшем ее использовании.
Что мы имеем в виду, когда говорим о загрузке модели? Сперва мы ее тренируем и сохраняем. Последнее, как правило, достигается с помощью функционала tf.train.Saver.save
. В результате мы имеем 3 бинарных файла с расширениями .index
, .meta
и .data-00000-of-00001
, которые содержат в себе все необходимые данные для восстановления сессии и графа.
Чтобы загрузить сохраненную таким образом модель, мы должны восстановить граф с помощью tf.train.import_meta_graph()
(аргументом является файл с расширением .meta
). Если следовать описанным шагам, все переменные (включая так называемые «скрытые»), будут портированы в текущий граф. Чтобы получить определенный тензор, имея его имя, выполняем graph.get_tensor_by_name()
. Как мы помним, имя может отличаться от того, которое было использовано при инициализации в зависимости от скоупа и операции, результатом которой тензор является. Это первый подход.
Второй подход — более явный и сложный для реализации. В случае архитектуры модели, над которой я работала в последний раз, мне не удалась его использовать. Его основная идея — сохранить грани графа (тензоров) в .npy
и .npz
файлы. Будущая их загрузка обратно в граф происходит вместе с присваиванием должных имен в соответствии со скоупом, где они были созданы. Такой подход также не лишен недостатков. Во-первых, когда архитектура модели становится сложной, нам тяжело контролировать и держать на своих местах все матрицы весов. Во-вторых, есть определенный вид «скрытых» тензоров, которые создаются без их явной инициализации. К примеру, когда мы создаем tf.nn.rnn_cell.BasicLSTMCell
, она создает все необходимые веса и байесы «под капотом». Названия переменных также присваиваются автоматически.
Такое поведение выглядит нормальным, ведь пока 2 тензора являются весами, мы можем не создавать их вручную, а позволить фреймворку создать их. На самом деле, зачастую это решение не оптимальное. Основная проблема этого подхода в том, что не ясно, что именно мы должны сохранять и где загружать. Ведь глядя на коллекцию графа, мы видим огромное количество переменных неизвестного происхождения. Помещать «скрытые» переменные в соответствующие места графа и оперировать ими должным образом очень сложно. Сложнее, чем это могло бы быть.
Создание тензора с таким же именем дважды без какого-либо предупреждения (с помощью автоматического добавления окончания _index)
Эта проблема не настолько важна, как и предыдущая, но она результирует во множество ошибок выполнения графа. Чтобы объяснить ее лучше, приведем пример.
Допустим, мы создаем тензор с помощью tf.get_variable(name='char_embeddings', dtype=…)
, а после сохраняем его и загружаем обратно в новую сессию. Мы забыли о том, что эта переменная была тренируемой, и создали ее еще раз с помощью такого же функционала tf.get_variable()
. Во время выполнения графа мы получим следующую ошибку: FailedPreconditionError (see above for traceback): Attempting to use uninitialized value char_embeddings_2
. Все дело в том, что мы создали пустую переменную и не портировали ее в соответствующем месте в модели, хотя она может быть портирована, поскольку уже содержится в графе.
При этом в тексте ошибки ничто не указывает на то, что разработчик создал тензор с одинаковым именем дважды. Возможно, этот пункт очень важен только для меня, но эту особенность TensorFlow и его поведения я не люблю.
Сброс графа вручную при написании unit-тестов и другие проблемы с ними
Тестировать код, написанный на TensorFlow, всегда сложно по ряду причин. О первой — и наиболее очевидной — уже шла речь в начале этого раздела. Из-за того, что по умолчанию существует только один TensorFlow граф для всех тензоров всех модулей, к которым есть доступ во время runtime, невозможно тестировать тот же функционал, к примеру, с разными параметрами, без того, чтобы сбрасывать граф. Это всего одна строчка кода tf.reset_default_graph()
. Учитывая, что она должна быть написана наверху большинства методов, это решение превращается в monkey job и есть явным примером дубликации кода.
Я не нашла ни одного из возможных путей решения этой проблемы (кроме использования параметра скоупа reuse
), поскольку все тензоры привязаны к дефолтному графу, и нет никакого способа изолировать их. Несмотря на то, что существует возможность создать отдельный граф для каждого из методов, с моей точки зрения, это не лучшая практика.
Есть еще одна особенность кода на TensorFlow, которая меня беспокоит. Когда граф был создан, но не должен быть выполнен (он имеет неинициализированные тензоры внутри, потому как модель еще не была натренирована), никто не может сказать, что мы должны тестировать. Я имею в виду то, что аргументы к self.assertEqual()
не ясны. Мы должны тестировать имена выходящих тензоров и их форму? Что, если формой является None
? Что, если имя тензора или его форма — не достаточное условие, чтобы заключить, что код работает соответствующим образом? В моем случае, я просто делаю assert
для имен тензоров, их форм и размерностей. К сожалению, я уверена, что в случае, когда граф не был выполнен, недостаточно проверить только эту часть функционала.
Запутанные названия тензоров
Многие люди скажут, что этот комментарий по поводу работы TensorFlow является изощренным видом недовольства или нытья, но никто наверняка не может сказать имя результирующего тензора после выполнения над ним определенной операции. Достаточно ли понятно для вас имя bidirectional_rnn/bw/bw/while/Exit_4:0
? Для меня — нет. Я понимаю, что этот тензор — результат определенной операции, сделанной над backward ячейкой динамической двусвязной RNN, но без того, чтобы дебажить эту часть кода, порядок и названия произведенных операций неочевиден. Кроме того, окончания в форме индексов также не понятны. Чтоб разобраться, откуда появилось число 4, необходимо прочесть документацию TensorFlow и углубиться в детали работы вычислительного графа.
Такая же ситуация и для «скрытых» переменных, упомянутых ранее: почему имя kernel
? Как по мне, такие случаи для дебага крайне неестественны.
tf.AUTO_REUSE
, тренируемые переменные, рекомпиляция библиотеки и другие неприятные моменты
Последний подпункт в этом списке позволяет быстро взглянуть на мелкие детали, которые можно выучить только методом проб и ошибок.
Во-первых, параметр скоупа reuse=tf.AUTO_REUSE
, который позволяет автоматически управлять уже созданными переменными и не создавать их дважды, если они уже существуют. Во многих случаях это может решить проблему, описанную во втором пункте этого раздела.
Однако на практике этот параметр следует использовать взвешенно, и только когда разработчик знает, что определенная часть кода должна быть выполнена два раза и больше.
Во-вторых, тренируемые переменные. Важно запомнить, что все тензоры являются тренируемыми по умолчанию. Иногда это настоящая головная боль, ведь такое поведение не всегда желательно, и легко забыть о том, что они все могут быть натренированы.
И в-третьих, хочу поделиться трюком для оптимизации кода. Часто используя библиотеку, установленную с помощью pip, мы видим предупреждение по типу Your CPU supports instructions that this TensorFlow binary was not compiled to use: AVX AVX2
. В таком случае лучше всего удалить TensorFlow, а потом перекомпилировать его с помощью Bazel с нужными опциями. В результате получаем преимущество в виде увеличенной скорости вычислений и общей производительности фреймворка на нашей машине.
Выводы
Я надеюсь, что этот лонгрид будет полезным для тех data scientist’ов, которые разрабатывают свои первые TensorFlow модели и сталкиваются с трудностями в понимании неочевидного поведения частей фреймворка. Основная идея, которую я хотела донести: делать много ошибок во время работы с этой библиотекой — совершенно нормально, как и задавать вопросы, углубляться в документацию и дебажить каждую строчку кода.
Как и с танцами или плаванием, все приходит с практикой. Надеюсь, что я смогла сделать эту практику чуть более интересной и приятной.