Настройка ваших экспериментов в Python, Numpy и PyTorch

Мотивация

Какой самый страшный кошмар программиста?

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

На самом деле есть известное определение безумия:

«Безумие — это делать одно и то же снова и снова и ожидать разных результатов».

Хотя это часто приписывают Альберту Эйнштейну, исследования показывают, что это не так. Но, если оставить в стороне авторство цитаты, правда остается фактом: ввод одних и тех же входных данных в кусок кода снова и снова и получение каждый раз разных результатов сведет вас с ума :-)

Этот пост содержит частичное воспроизведение содержания из моей книги: Глубокое обучение с помощью PyTorch: шаг за шагом: руководство для начинающих.

(Псевдо-) случайные числа

«Как можно отлаживать и исправлять такую ​​вещь?»

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

Что ты имеешь в виду?

Ну, вы знаете, случайные числа не совсем случайны… Они действительно псевдослучайны, что означает, что генератор чисел выдает последовательность числа, которые выглядят случайными. Но на самом деле это нет.

Преимущество такого поведения в том, что мы можем указать генератору запустить определенную последовательность псевдослучайных чисел. В какой-то степени это работает так, как если бы мы сказали генератору: «пожалуйста, сгенерируйте последовательность № 42», и он выдаст последовательность чисел.

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

То же старое семя, те же старые числа.

Это означает, что у нас есть лучшее из обоих миров: с одной стороны, мы генерируем последовательность чисел, которая во всех смыслах и целях считается быть случайным; с другой стороны, у нас есть возможность воспроизвести любую заданную последовательность. Я уверен, вы можете оценить, насколько это удобно для целей отладки и предотвращения безумия :-)

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

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

Генерация случайных чисел

Хотя начальное число называется случайным, его выбор определенно таковым не является! Часто вы увидите, что выбранное случайное начальное число равно 42, (второе) наименее случайное из всех случайных начальных чисел, которые можно было бы выбрать.

Итак, мы чтим давнюю традицию ставить семена как 42 и в этом посте. В чистом Python вы используете random.seed() для установки начального числа, а затем вы можете использовать random.randint() для рисования случайного целого числа, например:

Видеть? Полностью детерминированный! Как только вы установите случайное начальное число на 42 (очевидно!), первые четыре сгенерированных целых числа будут 10, 1, 0 и 4, в этом порядке, независимо от того, генерируете ли вы их один за другим или внутри понимания списка.

Если вас интересует сама генерация, модуль random Python использует генератор случайных чисел Mersenne Twister, который представляет собой полностью детерминированный алгоритм. Это означает, что алгоритм отлично подходит для решения проблем с воспроизводимостью, но совершенно не подходит для криптографических целей.

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

Вы можете получить (и установить) это состояние, если хотите, используя random.getstate() и random.setstate():

Как и ожидалось, первым числом снова было 10 (поскольку мы использовали одно и то же начальное число). Внутреннее состояние генератора в этот момент фиксирует, что из последовательности было взято только одно число. Мы сохраняем это состояние как first_state.

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

Теперь, если мы нарисуем еще одно число, мы получим число 1 еще раз, потому что мы заставили генератор «забыть» последнее нарисованное число, обновив его внутреннее состояние.

Цифры уже не выглядят такими случайными, а?

Да, но я должен спросить... что в этом состоянии?

Рад, что вы спросили. Это просто кортеж! Первый элемент — версия (3), второй элемент — длинный список из 625 целых чисел (внутреннее состояние), а последний элемент обычно None (пока его можно спокойно игнорировать).

Видите эту «1» в самом конце? Это 625-й элемент списка, и он работает как указатель на другие элементыфактическое внутреннее состояние представлено первыми 624 элементами. Имейте это в виду, мы скоро вернемся к этому!

Итак, у нас все хорошо, и теперь все идеально воспроизводимо?

Мы еще не совсем там... если вы проверите Примечания по воспроизводимости Python, вы увидите это:

«При повторном использовании начального значения одна и та же последовательность должна воспроизводиться от запуска к запуску, пока не запущено несколько потоков».

Так что, если вы используете многопоточность, воспроизводимость уходит в прошлое! С другой стороны, генератор (псевдо)случайных чисел Python (далее будем называть его ГСЧ) имеет две гарантии (переписано из 'Notes'):

  • Если добавляется новый метод заполнения, будет предложено обратно совместимое заполнение.
  • Метод генератора random() будет продолжать создавать ту же последовательность, когда совместимому сеялке дается одно и то же начальное число.

ОК, ТЕПЕРЬ мы в порядке?

Извините, но не! Собственный генератор случайных чисел Python не единственный, для которого вам, вероятно, потребуется установить начальное число.

Нампи

Если вы также используете Numpy, вам нужно будет установить начальное число для собственного ГСЧ. Вы можете использовать np.random.seed()для этого:

Из приведенного выше примера вы можете видеть, что RNG Numpy ведет себя так же, как RNG Python: после установки начального числа генератор выводит ту же самую последовательность чисел: 6, 3, 7 и 4.

Хотя приведенный выше код является наиболее часто встречающимся «в дикой природе», и многие люди продолжают использовать его таким образом (включая меня, виновного в предъявлении обвинения), он считается устаревшим кодом. уже.

В более поздних версиях Numpy, начиная с 1.17, используется другой способ генерации (псевдо)случайных чисел: сначала создается генератор, а затем извлекается из него числа. Генератор по умолчанию можно создать с помощью np.random.default_rng():

Подождите, теперь цифры другие?

Да, они различны, хотя мы используем одно и то же начальное число, 42.

Почему это?

Цифры отличаются, потому что другой генератор, то есть он использует другой алгоритм. Устаревший код Numpy использует алгоритм Mersenne Twister (MT), как и модуль random в Python, а новый генератор Numpy по умолчанию использует Permute Congruential Generator (PCG) алгоритм.

Но оказывается, даже несмотря на то, что устаревший код Numpy и модуль random в Python используют один и тот же алгоритм, и мы используем одно и то же начальное число в обоих из них, сгенерированные числа все равно отличаются!

Ты должно быть разыгрываешь меня! Почему?!

Я понимаю, что вы можете быть расстроены, и разница сводится к способу, которым случайный модуль Python и Numpy обрабатывают этот надоедливый «индекс» во внутреннем состоянии генератора. Если вас интересуют более подробные сведения об этом, проверьте отступление ниже — в противном случае не стесняйтесь пропустить его.

Соответствие внутренним состояниям

Если мы используем один и тот же список из 624 чисел для обновления состояний обоих генераторов, установив для этого «индекса» значение 624 (как это делает Numpy по умолчанию), вот что мы получим: совпадающие последовательности!

Как видно из приведенного выше кода, также можно получить или установить внутреннее состояние генератора Numpy, используя set_state() и get_state() соответственно, а само состояние содержит еще несколько элементов в своем кортеже (MT19937 означает Mersenne Twister (MT) и его диапазон (2¹⁹⁹³⁷-1), между прочим), но мы не будем углубляться в это. В конце концов, маловероятно, что вам КОГДА-ЛИБО понадобится изменять внутреннее состояние генератора в Numpy…

Следует отметить еще одну вещь, взятую из документации Numpy Generator, раздел под названием «No Compatibility Guarantee»:

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

Кто сказал, что обеспечить воспроизводимость легко? Не я!

Имейте в виду: для реальной воспроизводимости вам нужно использовать одни и те же случайные семена, одни и те же модули/пакеты и одни и те же версии!

Пора переходить на другой пакет!

ПиТорч

Как и в Numpy, в PyTorch есть собственный метод установки начального числа torch.manual_seed(), который устанавливает начальное значение для всех устройств (CPU и GPU/CUDA):

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

Но это еще не все! Если вы создаете последовательность на другом устройстве, например на графическом процессоре ('cuda'), вы получаете еще одну последовательность!

На данный момент это не должно быть для вас сюрпризом, верно? Кроме того, документация PyTorch о воспроизводимости довольно проста:

Полностью воспроизводимые результаты не гарантируются для выпусков PyTorch, отдельных коммитов или разных платформ. Кроме того, результаты могут быть невоспроизводимыми между исполнениями ЦП и ГП, даже при использовании идентичных начальных значений.

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

Имейте в виду: для реальной воспроизводимости вам нужно использовать одни и те же случайные семена, одни и те же модули/пакеты, одни и те же версии, одни и те же платформы, одни и те же устройства и, возможно, одни и те же драйверы (например, версию CUDA для вашего графического процессора). !

Возможно, вы заметили Generator в выходных данных выше… неудивительно, что PyTorch также использует генераторы, как и Numpy, и этот генератор является генератором PyTorch по умолчанию. Мы можем получить его с помощью torch.default_generator и установить его начальное значение с помощью метода manual_seed():

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

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

Загрузчики данных

При создании загрузчика данных для обучающего набора мы обычно устанавливаем его аргумент shuffle равным True (поскольку перетасовка точек данных в большинстве случаев улучшает производительность градиентного спуска). Это очень удобный способ перетасовки данных, реализованный с помощью RandomSampler под капотом. Каждый раз, когда запрашивается новый мини-пакет, он случайным образом выбирает некоторые индексы, и возвращаются точки данных, соответствующие этим индексам.

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

Начиная с PyTorch 1.7, чтобы обеспечить воспроизводимость, нам нужно назначить генератор DataLoader, чтобы он использовался в соответствующем сэмплере (конечно, при условии, что он использует генератор).

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

Именно это мы и сделаем в паре разделов, когда будем писать функцию, использующую «одно семя, чтобы управлять ими всеми» :-)

Назначение генератора загрузчику данных поможет вам, но только до тех пор, пока вы загружаете данные в основной процесс (num_workers=0, по умолчанию). Если вы хотите использовать многопроцессорную обработку для загрузки данных, то есть указать большее количество рабочих процессов, вам также необходимо назначить worker_init_fn() загрузчику данных в чтобы все ваши рабочие не рисовали одну и ту же последовательность чисел. Давайте посмотрим, почему это может произойти!

PyTorch на самом деле может позаботиться о себе в описанной выше ситуации — он задает каждому воркеру свой номер, то есть base_seed + worker_id, но он не может позаботиться о других пакетах (например, Numpy или модуль random Python).

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

Есть два воркера, (0) и (1), и каждый раз, когда воркер вызывается для выполнения своих обязанностей, функция seed_worker() печатает начальные значения, используемые PyTorch, Numpy и случайным модулем Python.

Вы можете видеть, что семена, используемые PyTorch, очень хороши — первый рабочий использует число, оканчивающееся на 55; второй рабочий номер, оканчивающийся на 56, как и ожидалось.

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

К счастью, есть простое решение: вместо того, чтобы печатать операторы, мы включаем некоторые операторы, устанавливающие начальное число, в функцию seed_worker(), используя начальное начальное число PyTorch (и настраивая его, чтобы сделать его 32-битным целым числом):

Теперь каждый рабочий процесс будет использовать разные начальные значения для модулей PyTorch, Numpy и Python.

«Хорошо, я это понимаю, но зачем мне нужно заполнять другие пакеты, если я использую только PyTorch?»

Раздачи PyTorch недостаточно!

Вы можете подумать, что если вы явно не используете модуль Numpy или Python random в своем коде, вам не нужно заботиться об установке для них начальных значений, верно?

Возможно, вы этого не сделаете, но лучше перестраховаться и установить семена для всего: PyTorch, Numpy и даже случайного модуля Python, и это то, что мы сделали в предыдущем разделе.

Почему это?

Оказывается, PyTorch может использовать чужой генератор! Честно говоря, для меня это тоже стало неожиданностью, когда я узнал об этом! Как бы странно это ни звучало, но в версиях Torchvision до 0.8 по-прежнему существовал некоторый код, который зависел от модуля Python random, а не от собственных генераторов случайных чисел PyTorch. Проблема возникла, когда использовались некоторые случайные преобразования для увеличения данных, такие как RandomRotation(), RandomAffine() и другие.

CUDA

Ручная настройка начального числа PyTorch работает как для ЦП, так и для CUDA/GPU, как мы видели пару разделов назад. Но библиотека cuDNN, используемая операциями свертки CUDA, по-прежнему может быть источником недетерминированного поведения.

Оказывается, библиотека пытается использовать самый быстрый возможный алгоритм в зависимости от предоставленных параметров, а также базового оборудования и среды. Но мы можем заставить детерминистически выбирать алгоритм, отключив эту так называемую функцию бенчмаркинга, установив torch.backends.cudnn.benchmark на False.

Хотя выбор алгоритма можно сделать детерминированным с помощью приведенной выше конфигурации, сам алгоритм может не быть таковым!

О, да ладно!

Я слышу тебя. Чтобы исправить это, нам нужно выполнить еще одну настройку: установить для torch.backends.cudnn.deterministic значение True..

Есть и другие последствия для воспроизводимости использования CUDA: слои RNN и LSTM также могут демонстрировать недетерминированное поведение из-за изменения, внесенного в CUDA версии 10.2 (подробности см. в документации).

Документация PyTorch предлагает установить переменную среды CUBLAS_WORKSPACE_CONFIG либо в :16:8 , либо в :4096:2, чтобы обеспечить детерминированное поведение.

Подпевайте: у старого МакТорча была модель

У старого McTorch была модель E-I-E-I-O.

И на его модели было несколько семян, И-И-Е-И-О

С семенем здесь, с семенем там

Здесь семя, там семя, Везде семя семя

У старого McTorch была модель E-I-E-I-O.

Как вам песня выше из «Детские стишки для программистов»? Кстати, я шучу, это не настоящая книга, я ее придумал! Может быть, мне стоит написать такую ​​книгу… но я отвлекся!

Возвращаясь к нашей основной теме, это может звучать точно так же, как в песне — семена и еще семена — семена повсюду!

Лишь бы был…

Одно семя, чтобы править всеми!

Такой вещи нет, но мы можем попробовать следующую лучшую вещь: наша собственная функция, чтобы установить как можно больше семян! Приведенный ниже код устанавливает начальные значения для PyTorch, Numpy, случайного модуля Python и генератора сэмплера; помимо настройки бэкэнда PyTorch, чтобы сделать операции свертки CUDA детерминированными.

Этого достаточно?

Не обязательно, нет. Некоторые операции могут быть недетерминированными, что делает ваши результаты не полностью воспроизводимыми. Однако можно заставить PyTorch использовать ТОЛЬКО детерминированные алгоритмы, установив torch.use_deterministic_algorithms(True), но есть одна загвоздка…

Я знал это!

Возможно, некоторые операции, которые вы выполняете, имеют ТОЛЬКО недетерминированные алгоритмы, и тогда ваш код будет выдавать RuntimeError при вызове. По этой причине я не включил это в функцию set_seed выше — мы останавливаемся перед тем, чтобы сломать код, чтобы обеспечить его воспроизводимость.

Более того, если вы используете CUDA (версия 10.2 или выше), помимо настройки torch.use_deterministic_algorithms(True) вам также потребуется установить переменную среды CUBLAS_WORKSPACE_CONFIG, как указано в предыдущем разделе.

Эти недетерминированные алгоритмы могут возникать из самых неожиданных мест. Например, в документации PyTorch есть предупреждение о возможных проблемах воспроизводимости при использовании padding в изображениях:

При использовании бэкенда CUDA эта операция может вызвать недетерминированное поведение при обратном проходе, которое нелегко отключить. См. примечания о воспроизводимости для фона.

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

Случайная настройка начального числа

(Правильное) случайное начальное число — это все, что вам нужно!

Это похоже на шутку, но выбор случайного начального числа может повлиять на обучение модели. Некоторым начальным значениям повезло больше, чем другим, в том смысле, что они позволяют модели обучаться быстрее или достигать меньших потерь. Конечно, нельзя сказать это заранее, и нет, 42 НЕ является ответом на вопрос «что такое правильное случайное начальное число» :-)

Если вам интересна эта тема, вы можете ознакомиться со статьей Дэвида Пикарда: Torch.manual_seed(3407) — это все, что вам нужно: о влиянии случайных начальных значений в архитектурах глубокого обучения для компьютерного зрения '. Вот аннотация:

В этой статье я исследую влияние случайного выбора начального числа на точность при использовании популярных архитектур глубокого обучения для компьютерного зрения. Я сканирую большое количество семян (до 104) в CIFAR 10, а также сканирую меньше семян в Imagenet, используя предварительно обученные модели для исследования крупномасштабных наборов данных. Выводы таковы, что даже если дисперсия не очень велика, на удивление легко найти выброс, который работает намного лучше или намного хуже, чем среднее значение.

Последние мысли

Воспроизводимость сложна!

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

Мы просто фокусируемся на генераторах (псевдо)случайных чисел, и даже в этом случае необходимо учитывать множество различных источников (псевдо)случайности, чтобы обеспечить воспроизводимость . Это большая работа, но она того стоит.

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

Пусть ваши будущие эксперименты будут полностью воспроизводимыми!

Если у вас есть какие-либо мысли, комментарии или вопросы, оставьте комментарий ниже или свяжитесь с моей страницей bio.link.

Если вам нравятся мои сообщения, рассмотрите возможность прямой поддержки моей работы, подписавшись на членство в Medium с помощью моей реферальной страницы. За каждого нового пользователя я получаю небольшую комиссию от Medium :-)