Наша команда недавно перенесла управление конфигурацией на Hydra и довольно долго работала с ней. В этой статье я в основном буду говорить о группах конфигурации, так как считаю их наиболее важными функциями. И я хочу поделиться лучшими практиками для Config Groups.

Если вы не знакомы с Hydra, прочитайте их прекрасную статью здесь:
https://medium.com/pytorch/hydra-a-fresh-look-at-configuration-for-machine-learning-projects-50583186b710

Группы конфигурации Hydra

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

Идея в том, что не только код, но и конфигурация должны быть модульными. Вместо одной монолитной конфигурации, охватывающей все случаи, мы можем составить несколько меньших конфигураций.

Чтобы понять преимущества, давайте рассмотрим простой пример конфигурации наборов данных. Представьте, что у нас есть два разных набора данных изображений. У одного есть дополнительные лидарные данные, а у другого доступны только данные о позе. Без концепции групп конфигурации вы могли бы придумать что-то вроде этого:

batch: 2
mode: train

dataset:
   name: image_lidar_dataset
   image_path: ./image_folder
   lidar_path: ./lidar_folder
   pose_path: None
   image_size: [30,30]
   lidar_size: 300
batch: 2
mode: train

dataset:
   name: image_pose_dataset
   image_path: ./image_folder
   lidar_path: None
   pose_path: ./pose_folder
   image_size: [30,30]
   lidar_size: None

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

Hydra решает эту проблему, составляя несколько меньших конфигураций. Это отличный способ справиться со сложностью. Теперь вы можете переключать наборы данных с помощью одного аргумента командной строки datasets=image_pose_dataset или перезаписывать его в файле yaml.

# Main configuration
defaults:
    # Or overwrite with image_lidar_dataset
    - dataset: image_pose_dataset 

batch: 2
mode: train
# dataset/image_lidar_dataset.yaml
name: image_lidar_dataset
image_path: ./image_folder
lidar_path: ./lidar_folder
image_size: [30,30]
lidar_size: 300
# dataset/image_pose_dataset.yaml
name: image_pose_dataset
image_path: ./image_folder
pose_path: ./pose_folder
image_size: [30,30]

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

Лучшие практики

То, как вы настраиваете группы конфигурации, влияет на архитектуру вашего программного обеспечения. Хотя в некоторых случаях может быть просто идентифицировать группы конфигурации, часто все не так очевидно, и есть много вариантов, и вы можете спросить себя, когда и где вы должны ввести группу конфигурации. Вы заметите, что это похоже на проблему того, какие обязанности должны быть у классов в ООП.

Это набор основных принципов эффективного управления конфигурацией.

Стремитесь к одному классу на группу конфигурации

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

class ImageLidarDataset:
    def __init__(self, image_path, lidar_path, image_size, lidar_size):
        pass
# dataset/image_lidar_dataset.yaml
name: image_lidar_dataset
image_path: ./image_folder
lidar_path: ./lidar_folder
image_size: [30,30]
lidar_size: 300

Используйте фабрики для динамического создания экземпляров классов

Если у вас есть несколько конфигураций, вам понадобится некоторая логика, которая сопоставляет конфигурацию с классом. Фабрики являются подходящим шаблоном здесь:

class DatasetFactory:
    
    def create_dataset(config):
        if config.name == "image_lidar_dataset":
            return ImageLidarDataset(image_path=config.image_path, ...)
        elif config.name == "image_pose_dataset":
            return ImagePoseDataset(...)

Предпочитайте независимость, а не совместное использование кода

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

class GenericDataset:
    def __init__(self,
                 image_path,
                 lidar_path=None,
                 image_size=None,
                 lidar_size=None,
                 pose_path=None):
        pass

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

Четко отличайте интерфейс конфигурации от интерфейса вывода

Как только вы введете фабрику, у вас будет два интерфейса. Одним из них является интерфейс конфигурации. Это то, что ожидается в качестве входных данных фабрики наборов данных и каждого отдельного набора данных. Могут быть некоторые поля, такие как image_path, которые ожидаются от каждого класса. Это также часть интерфейса конфигурации.

Выходной интерфейс — это то, что ожидается на выходе класса Dataset. Обычно это словарь с определенными полями или список тензоров.

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

Наследование конфигураций для предотвращения дублирования конфигураций

Еще одна приятная особенность Гидры — в некотором роде реализовано наследование. Это отлично подходит для предотвращения дублирования конфигураций.

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

# dataset/image_lidar_dataset.yaml
name: image_lidar_dataset
image_path: ./path/to/test_data
lidar_path: ./path/to/test_data
image_size: [10,10] # Reduced test data size
lidar_size: 300

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

# dataset/image_lidar_dataset.yaml
name: image_lidar_dataset
image_path: ./image_folder
lidar_path: ./lidar_folder
image_size: [30,30]
lidar_size: 300
hyperparameter: 42
# dataset/test.yaml
defaults:
- image_lidar_dataset

image_path: ./path/to/test_data
lidar_path: ./path/to/test_data
image_size: [10,10]

В этом есть несколько преимуществ. Во-первых, теперь вы можете использовать одну строку для переключения между вашими фактическими и тестовыми данными с помощью datasets=test по сравнению с перезаписью трех полей вручную. Во-вторых, test.yaml по-прежнему отслеживает не перезаписанные поля, поэтому он всегда актуален, если другие параметры изменяются. Это отлично подходит для тестирования изменений гиперпараметров. Это также лучшая альтернатива полному дублированию конфигурации, поскольку у вас будет две независимые конфигурации, которые вам необходимо поддерживать.

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

# dataset/base.yml
# Three question marks means required field
image_path: ???
# dataset/test.yaml
defaults:
- base
- image_lidar_dataset

image_path: ./path/to/test_data
lidar_path: ./path/to/test_data
image_size: [10,10]

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

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

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

Не перезаписывайте конфигурации во время выполнения и не злоупотребляйте ими как глобальным состоянием.

Это большой антипаттерн, и он должен быть очевиден. В основном это сводится к «не использовать глобальные переменные». У некоторых разработчиков может возникнуть соблазн использовать объект конфигурации для хранения некоторого состояния. Но обычно объект конфигурации передается очень часто. Незнание того, где вносятся изменения, очень затрудняет отслеживание состояния. И очень обескураживает, что нельзя полагаться на то, что написано в конфигурации, так как любая функция могла уже изменить конфигурацию во время выполнения.

Краткое содержание

Группы конфигурации Hydra — это мощный инструмент для управления сложностью. Помните об этих рекомендациях при работе с группами конфигурации.

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