Это третий пост в серии из трех частей о создании многоразового конвейера машинного обучения, который запускается с помощью одного файла конфигурации и пяти пользовательских функций. Конвейер основан на точной настройке для целей классификации, работает на распределенных графических процессорах в AWS Sagemaker и использует Huggingface Transformers, Accelerate, Datasets & Evaluate, PyTorch, wandb и другие.

Этот пост первоначально появился в Блоге VISO Trust

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

Обучение и настройка

Причина, по которой я объединил «Обучение» и «Настройка» в один раздел, заключается в том, что «Настройка» представляет собой набор учебных заданий, производительность которых постепенно повышается за счет изменения гиперпараметров. Таким образом, под прикрытием два типа заданий вызывают один и тот же код. Как и раньше, давайте сначала посмотрим на perform_training() и perform_tuning(), чтобы увидеть, как код взаимодействует с Sagemaker.

Приближаясь к perform_training(), мы сталкиваемся с первым фрагментом внутреннего кода, который обрабатывает вариант использования, который мы еще не обсуждали: сравнение двух моделей. Если вы помните в первой части, одним из мотивов создания этого пайплайна было быстрое тестирование нескольких моделей понимания документов и сравнение производительности между ними. Таким образом, конвейер создан для обработки в одном эксперименте нескольких моделей, передаваемых в файле settings.ini, определяемом экспериментатором. На самом деле, параметр MODEL_NAMES из этого файла может принимать одно или несколько имен моделей, причем последнее подразумевает, что экспериментатор хочет запустить задание сравнения. Задание сравнения не влияет на согласование или подготовку данных; мы хотим, чтобы эти шаги были изоморфны одному заданию модели, поскольку идея состоит в том, что n моделей обучаются и тестируются на одном и том же снимке обучающих данных. С этой преамбулой perform_training() выглядит так:

Цикл здесь перебирает либо список с n именами моделей, либо список с одним именем модели. Для каждого имени модели создается Estimator() и вызывается .fit(), что запускает обучающую работу в Sagemaker. get_estimator_kwargs() будет знакомо всем, кто уже тренировался на Sagemaker:

Настройки берутся из конфига, который мы обсуждали в первом посте серии, самый важный из которых config.docker_image_path. Напомним, что это URL-адрес ECR обучающего изображения, созданного экспериментатором в настройке, которое используется между заданиями Sagemaker Processor/Training/Tuning и содержит все необходимые зависимости. Затем perform_training проверяет логическое значение из файла settings.ini, USE_DISTRIBUTED которое определяет, ожидает ли экспериментатор распределенного обучения GPU. Если это так, он устанавливает некоторые дополнительные параметры Estimator, которые во многом основаны на функции _distribution_configuration из sagemaker-sdk.

Здесь я немного отвлекусь, чтобы рассказать об одном таком параметре, а именно о переменной окружения с именем USE_SMDEBUG. SMDEBUG относится к инструменту отладки под названием Sagemaker Debugger. По причинам, которые я не могу объяснить и на которые AWSlabs не ответил, этот инструмент включен по умолчанию, и распределенное обучение не будет работать для некоторых моделей, создавая таинственные трассировки исключений. Это стало очевидным только для меня, когда я внимательно изучил трассировки и увидел, что в конечном итоге выдает какой-то код в smdebug. Кроме того, есть множество способов отключить smdebug, например, передать 'debugger_hook_config': False, как это было сделано выше, или environment={‘USE_SMDEBUG’:0}. Однако эти методы работают только для учебных заданий. Опять же, по причинам, которые я не могу объяснить, единственный способ отключить SMDEBUG для заданий настройки — установить env var внутри используемого контейнера докеров: ENV USE_SMDEBUG="0"; другие методы, описанные выше, почему-то никогда не доходят до рабочих мест по настройке, составляющих рабочие места для обучения. К сожалению, побочным эффектом этого является то, что экспериментатору сложно настроить эту переменную среды. В любом случае, мы надеемся, что AWSlabs исправит или сделает исключения smdebug более удобными для пользователя.

Вызов .fit() фактически вызывает API AWS. Параметр config.training_data_uri указывает S3 URI закодированных обучающих данных из шага подготовки данных; обучающий экземпляр загрузит эти данные на локальный диск перед выполнением, где они могут быть легко доступны для нескольких процессов GPU. Как задание узнает, какой код выполнять? Это указано в базовом док-контейнере, который расширяется экспериментатором:

Эти переменные среды используются библиотекой sagemaker-training для запуска сценария обучения. На этом этапе мы хотели бы погрузиться в train.py, но, поскольку он также используется заданием настройки, давайте посмотрим, как мы запускаем задание настройки. Начало задания «Настройка» почти идентично заданию «Обучение»:

Но теперь вместо вызова .fit() нам нужно настроить еще несколько параметров, которые требуются для задания Tuning. Для задания настройки требуется набор постоянных гиперпараметров и настраиваемых гиперпараметров. Таким образом, вот пример того, что экспериментатор может написать в файле settings.ini, чтобы представить это:

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

Теперь у нас есть список настраиваемых параметров, которые мы можем передать объекту HyperparameterTuner:

Это должно выглядеть несколько похоже на то, что мы только что сделали для обучения с несколькими дополнительными параметрами. На данный момент объект HyperparameterTuner принимает сконструированный объект Estimator(), который будет повторно использоваться для каждого составного задания обучения и настраиваемых гиперпараметров, которые мы только что обсуждали. Задание настройки должно измерять метрику, чтобы решить, лучше ли один набор гиперпараметров, чем другой. objective_metric_name — это имя этой метрики. Это значение также используется в параметре metric_definitions, который явно определяет, как задание HyperparameterTuner может извлекать значение целевой метрики из журналов для сравнения. Чтобы сделать это более конкретным, вот как эти значения определены в примере файла settings.ini:

Наконец, параметр max_jobs определяет, сколько заданий обучения будет составлять задание настройки, а параметр max_parallel_jobs определяет, сколько заданий может выполняться параллельно в данный момент времени. Как и Estimator в задании "Обучение", мы вызываем fit(), чтобы запустить задание "Настройка" и передать ему training_data_uri, как мы это делали ранее. После этого мы можем теперь посмотреть на train.py и посмотреть, что выполняется при выполнении задания обучения или настройки.

Цель train.py — настроить загруженную модель с помощью набора распределенных графических процессоров, вычислить ряд метрик, определить лучшую модель, извлечь state_dict этой модели, преобразовать эту модель в torchscript и сохранить эти файлы вместе с ряд графов к S3. Библиотеки Huggingface Accelerate, Evaluate и Transformers используются для значительного упрощения этого процесса. Прежде чем продолжить, я должен кратко поблагодарить разработчиков Accelerate, которые очень быстро реагировали, пока я создавал этот пайплайн.

Обратите внимание, что в распределенной среде каждый процесс GPU будет выполнять один и тот же файл train.py. Хотя большая часть этой координации может быть передана Accelerate, полезно понимать это, работая внутри него. Погружаясь на уровень глубже, train.py собирается:

  • Прочтите гиперпараметры и определите, является ли выполняемое задание заданием по настройке, обучением или заданием сравнения.
  • Определите, будет ли использоваться градиентное накопление
  • Создайте объект `Accelerator()`, который обрабатывает распределение
  • Инициализировать трекеры wandb
  • Загрузите раздельные обучающие данные и создайте Dataloader() для обучения и проверки.
  • Настройте оптимизатор с планированием скорости обучения
  • Выполнение цикла обучения и проверки, вычисление метрик и сохранение истории метрик и определение лучшей модели.
  • График кривых для метрик
  • Извлеките кривые, статистику и лучшую модель из петель
  • Запишите все эти данные в S3

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

_tuning_objective_metric — это гиперпараметр, установленный Sagemaker, который позволяет нам легко различать задания обучения и настройки. Как мы упоминали ранее, run_num — это важный параметр, который позволяет нам систематизировать наши результаты и версии наших моделей в производственной среде, чтобы они легко подключались к тренировочным прогонам. Наконец, job_type_str позволяет нам дополнительно организовывать наши прогоны как задания по обучению/настройке и сравнению.

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

Теперь управление переходит к настройке объекта Accelerator(), который является инструментом для управления распределенной обработкой:

Здесь мы сталкиваемся с основной концепцией Accelerate, is_main_process. Это логическое значение обеспечивает простой способ выполнения кода в одном из распределенных процессов. Это полезно, если мы хотим запускать код так, как будто мы находимся в одном процессе; например, если мы хотим сохранить историю метрик по мере выполнения цикла обучения. Мы используем это логическое значение для настройки wandb, чтобы мы могли легко регистрировать метрики в wandb. Кроме того, accelerator.print() похож на if accelerator.is_main_process print(...), он гарантирует, что любой оператор будет напечатан только один раз.

Напомним, что мы передали config.training_data_uri вызову .fit() для заданий обучения и настройки. Это загрузит все обучающие данные на локальный диск экземпляра Sagemaker. Таким образом, мы можем использовать функцию Datasets load_from_disk() для загрузки этих данных. Обратите внимание, что в следующем коде SAGEMAKER_LOCAL_TRAINING_DIR — это просто путь к каталогу, в который загружаются данные.

Каждый процесс загружает набор данных, файл id2label, метрики и создает загрузчики данных. Обратите внимание на использование библиотеки оценок Huggingface для загрузки метрик; их можно использовать в тандеме с Accelerate, чтобы упростить отслеживание метрик во время распределенного обучения. Вскоре мы увидим, как Accelerator предоставляет одну простую функцию для управления распределенным обучением.

В этом блоке кода мы сначала вызываем пользовательскую функцию load_model, чтобы получить загруженную модель, определенную экспериментатором. До сих пор эта функция обычно выглядела как вызов функции Transformers from_pretrained(), хотя это и не соблюдается.

Общий оптимизатор скорости обучения создается и используется для создания планировщика скорости обучения. Наконец, мы сталкиваемся с еще одним ключевым понятием в Accelerator, а именно с wait_for_everyone(). Эта функция гарантирует, что все процессы дошли до этой точки, прежде чем перейти к следующей строке кода. Его нужно вызывать перед функцией prepare(), которая подготавливает все созданные нами значения для обучения (в нашем случае для распределенного обучения). wait_for_everyone() регулярно используется в коде ускорителя; например, это хорошо иметь, когда все графические процессоры завершили цикл обучения. После шага prepare() код входит в функцию для выполнения цикла обучения и проверки. Далее мы рассмотрим, как ускоритель работает внутри этого цикла.

В начале цикла мы инициализируем ряд значений, которые нужно отслеживать на протяжении всего обучения. Здесь мы снова используем is_main_process, чтобы создать единую версию истории метрик, которую мы будем использовать для построения графиков. В этом примере мы отслеживаем только потери при обучении, точность проверки и f1, но здесь можно отслеживать любое количество показателей. Далее входим в цикл, ставим модель в режим train() и входим в функцию train():

Когда выполнение входит в пакет, сначала необходимо проверить, выполняем ли мы задание сравнения. Если это так, необходимо извлечь соответствующие параметры для функции forward() текущей модели. Если вы помните, для заданий сравнения на этапе подготовки данных мы объединили все входные данные в одном и том же формате pyarrow, но добавили model_name (например, longformer_input_ids). get_model_specific_batch() просто возвращает те параметры пакета, которые соответствуют текущему model_name.

Далее мы сталкиваемся с with accelerator.accumulate(model), менеджером контекста, который недавно появился в Accelerate и управляет накоплением градиента. Эта простая оболочка уменьшает накопление градиента до одной строки. Под этим менеджером обратное распространение должно показаться знакомым читателям, которые раньше писали код ML, единственное большое отличие заключается в вызове accelerator.backward(loss) вместо loss.backward().

После завершения обучающего пакета выполнение устанавливает модель в режим .eval() и переходит в цикл проверки:

Здесь мы сталкиваемся с другой ключевой функцией ускорения, gather_for_metrics(). Эта недавно добавленная функция значительно упрощает сбор прогнозов в распределенной среде, чтобы их можно было использовать для расчета показателей. Мы передаем возвращенные значения объектам f1_metric и acc_metric, которые мы создали ранее с помощью библиотеки Evaluate. Затем цикл проверки вычисляет оценки и возвращает их.

После отправки пакета через обучение и проверку мы выполняем отслеживание значений, которые мы инициализировали в начале:

Поскольку is_main_process содержит ссылки на наши структуры данных для отслеживания истории, мы используем его для добавления наших новых значений. accelerator.log соединяется с вызовом init_trackers, который мы сделали ранее: .log отправляет эти значения ранее инициализированному трекеру. В нашем случае wandb будет строить графики из этих значений. Наконец, мы используем оценку F1, чтобы определить лучшую модель с течением времени.

После завершения цикла обучения и проверки мы выполняем:

Мы начинаем с проверки того, что все процессы завершили цикл обучения/проверки, а затем вызываем unwrap_model для извлечения модели из ее распределенных контейнеров. Поскольку основной процесс содержит истории наших метрик, мы используем его для построения кривых для каждой метрики и расчета статистики модели; затем мы возвращаем лучшую модель, кривые и статистику.

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

Здесь мы вызываем end_training, поскольку мы используем wandb, и используем is_main_process, поскольку нам больше не нужно распространение. accelerator.save() — это правильный способ сохранить модель на диск, но нам нужно преобразовать его в torchscript, чтобы максимально точно отразить производство. Вкратце, Torchscript — это способ преобразования модели на основе Python в сериализуемый, удобный для производства формат, который не требует зависимости от Python. Таким образом, при тестировании логического вывода на невидимом тестовом наборе лучше всего тестировать модель, которая будет находиться в производстве. Один из способов преобразовать модель — вызвать torch.jit.trace, передав ей модель и пример экземпляра, как мы реализовали преобразование:

Во-первых, мы берем лучшую модель и переводим ее в режим ЦП и оценки. Затем мы берем образец из обучающих данных. Далее мы сталкиваемся с другой определяемой пользователем функцией ordered_input_keys(). Если вы помните, эта функция возвращает имена параметров для функции forward() модели в правильном порядке. Возможно, раньше не имело смысла, зачем нужна эта функция, но теперь она должна быть: параметр example_inputs функции torch.jit.trace принимает кортеж входных значений, которые должны соответствовать точному порядку параметров функции forward().

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

Затем мы повторяем упорядоченные входные ключи и вызываем .unsqueeze(0) для каждого параметра образца экземпляра. Причина этого в том, что функция forward() ожидает размер пакета в качестве первого измерения входных данных; .unsqueeze(0) добавляет размерность 1 к тензорам, представляющим данные каждого параметра.

Теперь мы готовы запустить трассировку, передав модель, входные данные примера и установив для двух параметров значение false. Параметр strict определяет, хотите ли вы, чтобы трассировщик записывал изменяемые контейнеры. Отключив это, вы можете разрешить, например, вашему outputs = model(**batch) оставаться диктофоном, а не кортежем. Но вы должны быть уверены, что изменяемые контейнеры, используемые в вашей модели, на самом деле не изменены. check_trace проверяет, что одни и те же входные данные, проходящие через трассируемый код, дают одинаковые выходные данные; в нашем случае оставление этого True приводило к странным ошибкам, вероятно, из-за каких-то внутренних недетерминированных операций, поэтому мы установили его в False. Опять же, окончательной проверкой производительности модели является этап вывода, который мы обсудим далее.

Наконец, мы сохраняем трассируемую модель на локальный диск, чтобы ее можно было загрузить в s3. Последним шагом файла train.py является загрузка всех этих сгенерированных файлов на S3. В случае задания по настройке мы сохраняем только сгенерированные файлы из запуска с наилучшей объективной метрикой:

На этом мы завершили обсуждение этапа обучения/настройки конвейера машинного обучения. Далее мы рассмотрим этап вывода, на котором мы загружаем модель torchscript, выполняем вывод на невидимом тестовом наборе и собираем статистику.

Вывод

На этапе обучения/настройки мы конвертируем нашу лучшую модель в torchscript, что означает, что ее можно легко запустить на ЦП или в многопроцессорной среде. Это позволяет нам захватить экземпляр процессора Sagemaker для выполнения нашей задачи логического вывода. Как и в предыдущих разделах, мы сначала рассмотрим, как инициируется задание логического вывода. Поскольку мы можем использовать экземпляр процессора, он идентичен нашему шагу подготовки данных, за исключением того, что он указывает на наши данные /test/ и наш файл inference.py.

Обратитесь к разделу Подготовка данных во втором посте, чтобы узнать больше о заданиях Processor/ScriptProcessor. Обратите внимание на различия между input_source_dir, указывающим на /test/, и `code`, указывающим на inference.py. Поскольку они очень похожи, мы перейдем к рассмотрению файла inference.py.

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

inference.py начинается с загрузки файла id2label, чтобы мы могли переводить прогнозы модели в удобочитаемые прогнозы:

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

Этот цикл перебирает имена моделей, загружает/загружает преобразованную модель torchscript и инициализирует отслеживание статистики для каждой из них. Рассмотрим каждую внутреннюю функцию:

Эта функция создает путь, по которому будет проходить файл .pt, и загружает файл .pt. Затем он вызывает torch.jit.load и устанавливает модель в режим оценки, готовую к выводу. init_model_stats инициализирует значения, которые мы будем отслеживать для каждой модели, для каждой метки, которая предоставляет нам факты, которые мы можем использовать для построения статистики:

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

Затем мы получаем тестовые данные на этапе подготовки данных:

Теперь, когда модели и данные загружены, мы готовы запустить логический вывод:

Код вывода будет многократно использовать config.is_comparison для выполнения кода, специфичного для заданий сравнения. Он начинается с инициализации статистики специально для сравнений, которые мы пока пропустим. Затем он входит в основной цикл, который перебирает каждый экземпляр невидимых тестовых данных. Наземная метка истинности извлекается, и выполнение входит во внутренний цикл по именам моделей (в случае одной модели это просто список с одной записью). is_comparison вызывается для извлечения данных, характерных для текущей модели, с использованием той же функции, что и в обучении (get_model_specific_batch). Затем экземпляр подготавливается для функции forward() с использованием той же техники, которую мы использовали в covert_to_torchscript: каждое значение вызывается .unsqueeze(0), чтобы добавить размер пакета 1 в качестве первого измерения тензора.

Затем мы берем текущую загруженную модель и передаем ей экземпляр. Мы извлекаем наиболее достоверный прогноз из возвращенных логитов, вызывая argmax(-1). Теперь давайте посмотрим на оставшуюся часть цикла (обратите внимание, что он начинается внутри внутреннего цикла):

Мы берем прогноз, сделанный моделью, и передаем его и истину нашей точности и метрикам f1. Затем мы увеличиваем счетчики, которые мы инициализировали в начале:

Если inference.py выполняет задание сравнения, мы затем добавляем счетчики в структуру, которую мы инициализировали ранее; мы пропустим эти вызовы и перейдем к process_statistics, который происходит после завершения цикла логического вывода:

Эта функция выглядит устрашающе, но все, что она делает, — это вычисляет оценку F1 и точность для каждой метки, сортирует результаты по убыванию оценки F1, вычисляет общую F1 и точность и загружает результаты в S3 в правильный родительский каталог и run_num.

Если вы до этого момента следили за блогами ML Pipeline, будет дальновидно пересмотреть структуру папок, построенную на S3, в то время как весь конвейер выполняется, как мы изложили в первом блоге:

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

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

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