QA Fest — конференция №1 по тестированию и автоматизации. Финальная программа уже на сайте >>
×Закрыть

Как дебажить код на TensorFlow: болезненные ошибки и их решения

Привет, меня зовут Галина Олейник, я занимаюсь решением задач в сфере 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 модели и сталкиваются с трудностями в понимании неочевидного поведения частей фреймворка. Основная идея, которую я хотела донести: делать много ошибок во время работы с этой библиотекой — совершенно нормально, как и задавать вопросы, углубляться в документацию и дебажить каждую строчку кода.

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

LinkedIn

7 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Проблема TF в том что его писали не софт. девелоперы. От сего такая куча очень странных и болезненых решений, тоже касается и tf.contrib. Про документацию вообще молчу. Увы это именно тот момент когда человек должен иметь опыт в обеих областях.

Что касаеться дебага кода то тут все достаточно просто — подход KISS еще ни разу и нигде не подводил. Если конкретнее то:
1) Код должен быть максимально просто для чтения, даже если он будет избыточен и длинее. Времена извращенной оптимизации прошли и пора писать так что бы это было в первую очередь читаемо.
2) Работаем с шейпами очень окуратно, так как это очень частая проблема.
3) Проверяем все генераторы и пайпы до входа в модель. Большинство траблов с GANами что я видел уходили корнями в траблы при написании генераторов данных(да и не только).
4) Для сохранения модели в TF есть спец скрипты по фризу и оптимизации для инверенса, их стоит юзать(ну или можно написать свои)
5) не юзайте сторонний код если не понимаете как он работает.
6) если вы проводите инференс на железе которое отличается в меньшую сторону по точнности от того на котором тренили, не забудьте включить оптимизацию для модели на тренировке и потом понизить точность Float при фризе.
7) если система большая бейте ее на те куски которые можно логически проверить раздельно.

Params Reuse юзается исключителньо для того для чего оно задумано, для написание моделей с шерингом весов. Самый простой пример — сиамская модель. Убивать этим методом какието переменные крайне странно и до добра недоведет.

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

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

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

Ну и не забываем подписываться на t.me/ml_world для полезных новостей/пейперов/статей ))

По поводу причины проблем, как и по поводу первых трех пунктов - полностью согласна, в случае любого достаточно сложного и не_совсем_естественного для данного языка фреймворка, приходится использовать все возможные практики написания кода, которые могли бы упростить его понимание, при этом еще и будучи крайне внимательным, делая это.
На счет фриза модели и всех сопутствующих пунктов - я ранее сталкивалась с таким подходом (насколько я помню, сохранив модель, ее можно еще и дополнительно зафризить), но не совсем понимаю суть данного решения, точнее, его причину. По сути, мы избавляемся от лишних метаданных, если я правильно понимаю саму концепцию, но так важно ли это, если все 3 файла в сумме весят до 100 MB (или даже меньше)? Безусловно, в случае крайне ограниченного количества доступной памяти, такое решение является очень и очень полезным, но, как по мне, это скорее just a nice hint, а не обязательная практика.
Пятый пункт и седьмой - как мне кажется, это касается не только TensorFlow :)
По поводу установки с pip не знала, что такое там указано, возможно, невнимательно читала, спасибо за подсказку.
Ну и, как я уже раньше говорила - не люблю TensorBoard из-за его избыточности (как и Python IDE за это очень не люблю), но это скорее мое личное предпочтение.
Спасибо большое за комментарий, был очень полезным и интересным.

1) Суть фриза в том что все вся модель переходит из разряда переменных в константы. Так же убирается все лишнее(метаданные, переменные оптимайзера например). Так же во время фриза обычно проводиться оптимизация разного вида, для ускорения инференса (ну типа там Float 32 -> float 16). Ну и плюс вместо трех файлов выходит один.
100Мб модель это не так и мало, если ранить нужно быстро.

2) Насчет ТензорБорда, увы более чегото удобного сча нет(( Можно конечно все сейвить и на ФС, только при тренировке 100 паралельных моделей с кросс сравнением это будет крайне напряжно.
Python IDE я тоже не люблю как и все тяжелые, а вот Visual Studio Code очень даже ок.

3) насчет pip ворнинг с доков убрали.. но проблема осталась(

Спасибо, буду знать, что есть такие важные преимущества фриза (если честно, то до этого момента какой-то весомй разницы не ощущала, да и не особо вникала в именно эту тему).
По поводу Visual Studio Code — а на маке он ок себя ведет? В моей голове сложно подружить VS и Apple, я пока предпочитаю Sublime Text + терминал ;)

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

Дякую за статтю, хороше поповнення в добірку технічних статей по фреймворкам для машинного навчання!)

А тепер мої 5 центів по змісту :)

По завантаженні натренованої моделі — важливо не забувати з якою саме версією Tensorflow доводиться мати справу (що стосується і решти пунктів). Окрім того, що формат файлів моделі змінювався з розвитком бібліотеки, також підтримуються різні цілі збереження моделі — для продовження тренування чи тільки предікшину (e.g. frozen graph, інтерфейс saved_model, etc). Перший спосіб дійсно один з найпоширеніших, 2й здається використовувався в основному в академічному середовищі для tf.0.11, коли ваги якось мережі на Caffe портували в TF)) Це якщо не згадувати про нове апі з Estimator, де відновлення натренованих моделей з відрізанням останнього шару взагалі жах.

Unit тести для TF це досить цікаве явище, особливо тести розмірностей та тих частин графу, які одразу викидають ValueError: Shapes (?, 1) and (?,) are incompatible (має отримати медаль TF bug #1 )

Основне питання яке залишилося — чи буде 2га частина? З моєї практики найважливіші інструменти при дебагу це Tensorboard та tf.Print(), проте їх чомусь взагалі не згадано. І якраз при правильно неймінгу читання графа в Tensorboard дуже полегшує життя, а прінт дозволяє подивитися на реальні значення в будь-якому вузлі графа.

Спасибо большое за столь большой и подробный комментарий.
По поводу разных форматов файлов модели по мере развития TF — полностью с Вами согласна, но ни я не помню, какие разширения у них были раньше, ни полезности в этом я не столь много вижу ;) С новым API еще не сталкивалась, но уже наслышана о возможных проблемах.
А этот баг с размерностями — вообще отдельная история, бессмысленная и беспощадная :)
Пока сложно сказать, будет ли вторая часть, скорее это зависит от того, насколько тесно мне придется взаимодействовать с TF на работе в будущем. Я не слишком люблю Tensorboard как инструмент сам по себе (с моей точки зрения он часто очень избыточен, а избыточность я не люблю больше всего) и работать мне с ним совсем-совсем не нравится, поэтому и не упоминала. tf.Print() — безусловно, важный функционал, в будущем обязательно напишу и о нем (даже в голову как-то не пришел он в качестве отдельного пункта/предложения).

Подписаться на комментарии