Есть две крайности. Первая - не думать о воспроизводимости вообще, надеясь на память, какие-то записки и содержимое репозитория. Вторая - трекинг софтом типа DVC, который сам записывает какие-то действия, считает хэши, заливает в облако, и все такое.
Мне обе этих крайности не нравятся. Если забить на управление воспроизводимостью, мозг вместо занятия полезными делами должен помнить какую-то чушь, а ошибки съедают иногда часы. При чем начинается это довольно быстро - вот у тебя простой очевидный пайплайн, а уже ты сидишь и думаешь, почему эта модель дала такой крутой результат и не воспроизводится. Если использовать вещи типа DVC или W&B, эти тулзы превращают простую в общем-то задачу в кровавый энтерпрайз с километровыми конфигурациями, где ты должен описать стадии, с md5 хэшами, справками и печатями.
Удобный же подход где-то посередине - ты пишешь код, используя простые правила, а при желании восстанавливаешь весь рабочий процесс от начала и до конца. Я попробовал собрать правила, которые применяю сам.
Есть три сущности в виде файлов: скрипт, конфиг и данные.
Сами правила:
Основной друг здесь - дефолтные значения. Допустим, мы решили протестить модель, у которой в слое не 5 фильтров, а 10. Конфиг загружен в переменную params, которая доступна везде в коде. В коде пишем Conv(params.get(“kolvo_kanalov”, 5), …). Копируем конфиг, добавляем в него строчку “kolvo_kanalov” : 10. Старые конфиги работают, потому params.get не найдет новый ключ и вернет 5, новая модель тоже работает с числом фильтров 10.
Результат работы скрипта складывается в папочку. В скрипте первым делом создаем эту папку, сразу же после парсинга конфига
В эту же папочку копируется конфиг, который был указан при вызове скрипта. Самая важная мелочь. Проснулись 1 января, видим папку, и не помним, откуда она взялась, а внутри там .json файлик, в котором понятно, что перед нами. Копируем файл используя open(…, ‘x’). ‘x’ - создает файл, только если он уже не существует. Поэтому, если конфиг там уже лежит, скрипт упадет и ничего не затрет.
Как защита от дурака, и скрипты, и конфиги сохраняются в репозиторий. Именно как защита от дурака, потому что если все сделано правильно, то откатываться к каким-то коммитам для запуска чего-то не придется. Есть только последняя версия кода. Данные в репозитории не лежат, им там делать нечего, поэтому репозиторий маленький и легкий
Конфиги иммутабельны: если конфиг изменен, это должен быть новый конфиг с новым именем. Правило чисто на словах, потому что при отладке строго его применять не удобно
Типичная ошибка - когда результат выполнения скрипта зависит от параметров командной строки. Например, число фильтров в конфиге есть, а CUDA_VISIBLE_DEVICES нет, и иногда скрипт обучает на одной видюхе с батчсайзом N
, а иногда - на четырех с N*4
. Не должно так быть, все должно определяться только конфигом. Для того, чтобы было удобно, используется трюк, при котором через командную строку можно модифицировать конфиг на лету. Выглядеть это может как python script.py -c config.json --conf '{\"lr\" : 1e-3}'
. В скрипте после чтения config.json нужно примержить к нему строку, переданную в args.conf. Затем измененный конфиг сохранить в папку с результатами.
Самая соблазнительная ошибка - “ну тут идейку проверить, быстренько поменяю код, потом верну”. Добавить лишнее ветвление в код с дефолтным значением занимает меньше минуты: вместо 10 нужно написать params.get(“moynoviyporametr”, 10), далее при запуске нужно добавить –conf ‘{"moynoviyparametr": 10}’. Сильно дольше? Нет, даже если печатать одним пальцем. Поэтому надо прописывать. Если быстренькая идейка оказалась так себе, потом ее можно точно так же удалить. Нервов сэкономит кучу.
Также многие делают, и я считаю ошибочным, ту вещь, что конфиги именуются по их содержимому. Типа model_unet_1024x1024_rgb. Ошибочным, потому что констистентный нейминг так тяжело продумать, и имена имеют тенденцию расползаться. Если у модели нейтральное имя, то проще вызвать отдельный скрипт, который опишет, чем она отличается от самой базовой (см. jsondiff ниже)
Минус подхода с полной обратной совместимостью - иногда код полон лишних ветвлений для запуска древних и не нужных моделей, и захламляет все. Это можно и нужно рефакторить. Например, новую хорошую архитектуру объявлять как “arch” : “ver321” и копировать функцию, вычищая код. Устаревший код можно в другой модуль вынести
Еще одна защита от дурака - все результирующие файлы не должны переписываться. Выше было сказано про конфиг в папочке с результатами, который не должен переписываться, если уже существует. Но вообще нужно всегда открывать файлы как open(…, ‘x’) либо ‘r’.
Конфиг должен быть максимально плоскими парами ключ-значение и без оверинжиниринга. То есть {“loss_l1_weight” : 1, “loss_l2_weight” : 0} вместо {losses : [{“l1”:0}, {“l2:0}]}. Причина - так проще смотреть диффы.
Структура любого скрипта получается такой:
Базовые скрипты:
Соответственно, нужно три кофига: data.json, augmentation.json и model.json.
"dataset/images/**/*.jpg"
заставит перебрать все жпеги в папке рекурсивно. Помимо этого, там же содержатся имена выходных файлов, например, data.bin и metadata.json. Допустим, все это сохраняется в папку dataКаждый последующий конфиг содержит имя предыдущего как один из параметров, таким образом формируется направленный граф/пайплайн, в данном случае data->augmentation->model
.
Помимо этого, есть еще predict.py, evaluate.py, export.py и тому подобные вещи, которые, читая model.json, генерируют что-то более приземленное.
В долгоживущем проекте вся движуха начинается крутиться вокруг правки конфигов, потому что весь код уже написан, и долго тюнятся параметры. Соответственно, здесь есть простор для скриптов, каждый из которых кажется не особо важным, но экономит две минуты времени в консоли.
Уровень паранойи регулируется согласно требованиям проекта. Например, если датасет меняется часто, можно считать чексумму директории и сохранять список файлов. А если редко, то достаточно пути, где этот датасет лежит, и забэкапить его на S3 вручную.
Что с распределенностью? Конфиги можно хранить в монге. Тогда скрипту-воркеру остается подгрузить данные из распределенной ФС, посчитать, что нужно, и сохранить данные в распределенную ФС, апдейтнув монгу, что конфиг посчитан и лежит там-то.
Применение этих правил позволило мне сократить время, затрачиваемое на ресерч, примерно на порядок.