Как стать автором
Обновить
Точка
Как мы делаем онлайн-сервисы для бизнеса

Как перестать кидать Jupyter-ноутбуки по почте: гид по работе с данными и моделями для ML-инженеров

Время на прочтение13 мин
Количество просмотров2.6K

Привет, Хабр! На связи команда LLM-dev из Точки. Как несложно догадаться, наша основная миссия — учить и улучшать внутреннюю LLM и модели, связанные с ней. Для этого нужно очень-очень много текстовых данных, которые надо где-то хранить и как-то уметь с ними работать. А ещё нужно ставить эксперименты, которые надо как-то трекать и воспроизводить, писать и отлаживать много кода, и делать всё это в команде. 

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

Данные в ML-проекте

Выбираем место хранения

Так как мы NLP-команда, то почти все наши данные — это большие файлы с текстами. Что-то мы берём и обрабатываем из HuggingFace Hub, что-то генерируем сами.

Казалось бы, самый простой способ переиспользовать данные между проектами — копировать их из какой-нибудь общей директории, но тут возникает две проблемы:

  • Нет контроля версий.

  • Данные дублируются в разных проектах, то есть если кто-то нашел ошибку в данных в одном из них, то и во-втором её тоже надо править. 

И хорошее решение этой проблемы — развёрнутый DVC. Например, мы используем LakeFS. Но это довольно трудозатратно, не любая компания может позволить себе взять и развернуть DVC, поэтому в качестве компромисса между сложностью развёртывания и наличием версионирования можно ограничиться тем, чтобы использовать Git-репозитории с Git LFS. К слову, любой проект в HuggingFace Hub представляет собой Git-репозиторий с подключенным LFS.

Сохраняем айдишники

Когда вы приняли решение, как и где вы храните данные, нужно определиться, как они будут выглядеть. Если просто сохранять текста без айдишников, то в будущем возникнут проблемы: например, когда нужно будет догенерировать или догрузить данные. Так что внешние id (если они, конечно, есть) — это точно тот артефакт, который вы хотите сохранять в довесок к вашим данным. 

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

Ну и следующее, по поводу чего можно заморочиться — это внутренняя библиотека по работе с данными. Здесь не предлагаем писать свою версию Datasets, а имеем в виду библиотеку с базовыми схемами типов данных и прописанными функциями похода в источники данных и их подгрузки. Например, мы завели базовую Pydantic-модель для текстовых данных, от которой наследуются более специфичные: пост, отзыв, Q&A. Подгружаем их единой библиотекой, что позволяет сделать все ETL-пайплайны единообразными, а значит сократить время разработки на этапе подготовки данных.

Выбираем формат

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

Какие есть варианты:

  • SQL database: универсальный и рабочий способ, но мы предпочитаем работать с файлами, которые автоматически версионируются где-то в DVC/Git.

  • .txt / .json (по отдельности): можно сохранять каждый элемент датасета отдельным .txt-файлом, но это создаст трудности при работе с файловой системой. Например, будет проблематично сделать листинг директории с данными или посмотреть git diff, так что вариант не из лучших.

  • .csv / .jsonl: уже не делаем больно инструментам, которые работают с файловой системой, но нет встроенной компрессии. А в случае с .csv придётся договариваться о delimiter-ах в рамках проектов.

  • .parquet: нравится нам больше всего — есть встроенная компрессия и партиционирование; можно прочитать отдельную колонку, не читая весь файл; поддерживает сложные структуры данных. 

Ну и когда вы сохраняете данные, помните, что люди, скорее всего, не будут работать с голым файликом. Они будут использовать какую-нибудь библиотеку, например, Datasets или Pandas. Поэтому сохраняйте данные в тех форматах, которые поддерживаются распространёнными библиотеками. 

Добавляем описания

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

Обязательно прописывайте: 

  • Что за данные.

  • Какая у них специфика.

  • Их объём, и, в идеале, как можно эти данные увеличить.

Безопасность

Слив данных

Когда работаешь с данными, важно помнить о безопасности. Многие просто сливают свои OpenAI-ключи на GitHub. Также если вы что-то загружаете в Kaggle, Colab или любой другой сервис, то помните, что теперь это что-то есть не только у вас. 

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

Самая простая и довольно безопасная альтернатива — всегда читать секреты из environment variables. Если в проекте используется .env-файл, то тысячу раз проверьте, что он в .gitignore, потому что закоммитить его в репозиторий будет очень грустно. Ну и совсем идеальный вариант — развернуть корпоративный менеджер секретов — например, Vault — и ходить в него.

Выполнение произвольного кода

Выполнение произвольного кода там, где это не подразумевается — зло. Не используйте конструкции eval, exec и подобные.

Допустим, при сохранении кастомной структуры данных в .csv автор забыл сериализовать её, поэтому всё записалось в формате стандартных Python-репрезентаций.

Здесь видим одинарные кавычки вместо двойных, как это было бы при сериализации в JSON
Здесь видим одинарные кавычки вместо двойных, как это было бы при сериализации в JSON

Дальше разработчик захочет прочитать этот файл, увидит Python-репрезентации и додумается сделать загрузку данных через функцию eval:

А потом кто-нибудь очень весёлый изменит табличку и добавит туда конструкцию, которая при такой загрузке данных будет выводить в терминал нечто страшное:

Если сохраняете сложные данные, обязательно сериализуйте их, например, в JSON или Protobuf. Если всё равно очень надо прочитать вот такой корявый .csv, то используйте функцию ast.literal_eval, которая подгрузит только литералы и контейнеры, и не будет выполнять никакие сторонние функции.

SQL-инъекция

Ещё один антипример — произвольный код в рамках SQL-запросов. Допустим, в SELECT мы подставляем значение из файлика filters.txt:

Когда запустим код, увидим, что таблица Users пропала:

Получилось так, что в файл filters.txt воткнули SQL-инъекцию:

Этого можно было избежать, если бы мы написали код, используя parameterized queries. 

Чтобы защититься от SQL-инъекций, никогда не передавайте голые параметры — всегда используйте параметризованные запросы. Об этом знает каждый первый разработчик сервисов, но в DS-среде мы встречали код, который уязвим к SQL-инъекциям.

Pickle-файлы

Избегайте pickle, потому что он тоже позволяет выполнять произвольный код. Допустим, мы читаем pickle-файл: 

И видим, что в stdout опять вывели rm -rf:

В pickle-файл можно встроить абсолютно любой код, в том числе вредоносный. Благодаря тому, как формат устроен внутри, туда можно спрятать даже вызов eval, а в него — вызов любой функции Python. Вот пример:

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

Таким образом, чтобы не налажать:

  • Используйте safetensors, если сохраняете тензоры — формат физически не позволяет поместить ничего, кроме содержимого тензоров, их описания (тип данных, размерность) и метаданных (ключ-значение).

  • Помните, что функция torch.save под капотом тоже использует pickle.

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

  • При работе с данными используйте безопасные форматы — .csv, .json, .parquet (что угодно, но не pickle).

  • Читайте только те pickle-файлы, источнику которых доверяете на 100%, либо читайте их в песочнице, а потом сохраняйте в другой формат.

Ну и не забывайте про общие правила безопасности — не храните данные локально и не скачивайте что попало на рабочий комп.

Воспроизводимость

Используем трекеры

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

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

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

А если хотите делиться логами экспериментов с коллегами, разверните трекер в облаке. Например, мы так развернули AimStack.

Лочим сиды

В большинстве проектов используются генераторы случайных чисел: для инициализации параметров, применения аугментаций к данным и так далее. Не залочил сиды для них — не сможешь воспроизвести свой эксперимент. Также помните обо всех источниках случайности, которые есть в проекте: PyTorch, CUDA, NumPy, Python и т.д. Можно подсмотреть код для блокировки сидов в библиотеке Accelerate:

Ещё держите в голове, что в CUDA и PyTorch существуют недетерминированные операции, которые при одном и том же входе могут давать разный выход. Отличие может быть совсем небольшим, некритичным для большинства проектов, но если, например, вы делаете имплементацию своего оптимизатора и вам очень важно получить стопроцентную воспроизводимость, то можно дёрнуть функцию torch.use_deterministic_algorithms. Она будет использовать детерминированные (но обычно более медленные) реализации различных изначально недетерменированных операций там, где это возможно. А там, где невозможно, выкинет RuntimeError.

Код

Используем терминал

Делайте проект легко запускаемым и конфигурируемым из терминала. Тут можно использовать как встроенную библиотеку argparse, так и библиотеки с бОльшим количеством сахара, например, click или typer. Так мы получаем сразу несколько бонусов:

  • Можно легко указать возможные параметры запуска, вместе с их типами и значениями по умолчанию.

  • Автоматическое создание –help.

  • Все параметры, которые можно настроить при запуске проекта, будут лежать в одном месте.

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

Пишем пути

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

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

Ну и хочется добавить, что если вы пишете пути от корня и хотите запускать их из консоли, это все ещё не мешает вам пользоваться стандартными инструментами IDE. Просто пропишите в настройках запуска Working Directory и укажите путь до корня. Вот как это сделать в PyCharm:

Пишите все пути не просто строчками, а оборачивайте их в pathlib.Path. Это:

  • упрощает навигацию по дереву файлов — вместо os.path.join(...) можно делать my_file / ‘some_sub_directory’ / ‘some_file’.

  • позволяет открывать и писать файлы без написания лишнего boilerplate-кода — my_file.read_text(encoding=’utf-8’).

  • позволяет в будущем начать использовать universal-pathlib, чтобы работать не с локальной файловой системой, а с удалённой, без переписывания кода.

Пишем тайпинги

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

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

  1. Тайпинг Tuple[torch.Tensor] говорит о том, что мы ожидаем получить кортеж, содержащий в себе один элемент-тензор. На практике же функция возвращает кортеж, в котором несколько тензоров. Валидным тайпингом в данном случае было бы Tuple[torch.Tensor, …]

  1. Посмотрев дальше по коду, где используется функция из сниппета выше, видим, что почему-то мы хотим брать выход функции только с одного элемента. Возникает вопрос — что не так с нулевым? А с нулевым не так то, что это вообще что-то отличное от всех остальных элементов кортежа. Читается такая штука довольно плохо — как минимум, придётся слазить в исходник, чтобы разобраться, что происходит. А виной тому запихивание разнородных данных в один output-кортеж. 

Решение проблемы с кортежами — DTO (DTO = Data Transfer Object). Писать их можно  при помощи дефолтных питоновских инструментов: например, библиотеки dataclasses. Или использовать что-то более продвинутое, например, Pydantic — это клёвая библиотека, которая тоже позволяет делать DTO, но при этом умеет в сериализацию, валидацию данных и позволяет легко подгружать объекты из JSON или dict. 

Следим за обновлениями

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

  • Следить за changelog-ами питона.

  • Слушать подкаст MoscowPython.

  • Пить пиво с разработчиками :)

Например, версия Python 3.10 вышла уже довольно давно и там появились конструкции StrEnum и match case, но далеко не все среди DS-ов про них знают.

ML-разработка 

Передача модели разработчику

Всегда думайте о том, в каком виде вы передаёте модель разработчику сервиса, и как они дальше едут в прод. Какие есть варианты: 

  • Просто отдать Jupyter notebook с обучением модели. Худший выбор — скорее всего, разработчик перепишет половину кода с ноутбука, чтобы модель не только обучалась, но и производился её инференс, а по пути с большой вероятностью, так как ранее с этим кодом не работал, сделает пару ошибок. В итоге, модель будет работать не так, как задумано.

  • Скинуть скрипт с примером инференса и файлик с весами в safetensors. Вариант чуть лучше. Здесь разработчику уже понятно, как примерно должен выглядеть инференс, остаётся лишь оптимизировать его под продовый сетап.

  • Конвертировать веса в ONNX. Такой способ возможен не всегда. Например, генеративные модели в ONNX сконвертировать тяжело, но какой-нибудь классификатор — пожалуйста. ONNX-файл представляет собой граф вычислений модели и встроенные в него параметры. Это избавит инференс-сервис от кучи зависимостей (от PyTorch, Transformers, …) и сэкономит нервы всем участникам процесса. Разработчику достаточно будет разобраться с входами и выходами модели.

Пишем тест-кейсы

Даже если вы делаете всё правильно и сконвертируете модельку в ONNX, всё равно что-то может сломаться, например, на этапе подготовки данных. Допустим, вы подставляете какой-то специальный токен в начало последовательности, а разработчик сервиса — нет.

Избежать этой проблемы можно при помощи тест-кейсов. Минимальное решение - прописать примеры входа и выхода и проверять работу модели на них после очередного цикла интеграции. Можно даже автоматические тесты под это дело написать.

Не используем макрообёртки (вернее используем, но с умом)

Класс Trainer из HuggingFace Transformers — суперпопулярный класс для тренировки сеток. С одной стороны, он гибкий и удобный, его можно использовать под разные типы моделей и настраивать параметры обучения. 

С другой стороны, если у вас нестандартный процесс обучения, то, однажды обязательно что-то сломается и придётся потратить много сил и времени, чтобы разобрать (возможно) все пять тысяч строк. Поэтому иногда проще написать свой велосипед на сто строчек и сэкономить время на отладку. Здесь мы рекомендуем использовать библиотеку Accelerate: она предоставляет минимальные абстракции, которые можно затащить в свой train loop, но при этом всю основную логику обучения реализовать самостоятельно.

Используем дебаггер

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

У отладчика есть много крутых плюшек:

  • Можно исследовать содержимое всех переменных, гулять по стеку вызовов.

  • Выполнять произвольный код на лету — например, удостовериться, что у какого-нибудь промежуточного тензора значения по определённому измерению суммируются в единицу.

  • Не нужно помнить про отладочные принты и вычищать их.

Инфраструктура

Зеркалируем проекты 

Модели, код и данные обязательно должны быть зеркалированы. Если что-то лежит на GitHub или HuggingFace — оно не ваше, это что-то можно удалить или закрыть доступ в любой момент.

Например, в GitLab в бесплатной версии есть Repository mirroring. Он позволяет зеркалировать любые проекты из GitHub, а также из HuggingFace Hub. 

RunwayML удалил Stable Diffusion с HuggingFace и GitHub

Делаем библиотеки с общим кодом

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

Чтобы этого избежать, нужно делать библиотеки с общим кодом. Обкладывайте библиотеки автотестами и следите за качеством кода чуть больше, чем в обычных проектах. Для хранения этих библиотек можно поднять частный репозиторий через devpi или Nexus

Настраиваем инфраструктуру для удалённой отладки

Часто возникает потребность отладить что-то на нормальном железе с GPU, которое, скорее всего, не стоит у вас дома. Например, когда проект не запускается локально, но запускается на кластере. 

Дебаг-принты и Jupyter — довольно слабые инструменты для отладки, поэтому разработчикам нужно уметь подключаться к нормальному железу из IDE. Отличный вариант — уметь создавать на кластере временные контейнеры, в которые можно загрузить проект и подключиться из PyCharm или VSCode. 

Не используем pip

Допустим, нам надо поставить библиотеки, например scikit-learn и pandas. Причём pandas обязательно от v2.0. 

Ставим библиотеки через pip, записываем requirements.txt и видим, что появилось много лишних строк, а констрейнт (ограничение, что версия не ниже 2.0.0) на pandas вообще потерялся. 

pip freeze ничего не знает об explicit и implicit зависимостях, поэтому трудно читать requirements.txt файлы. Непонятно, какие библиотеки задумывал установить автор, а какие установились транзитивно. Непонятно, какие ограничения на версии библиотек задумывал указать автор. Также в pip нет lock-файлов, поэтому нельзя сверить хэши пакетов. Но, к счастью, есть много альтернатив: poetry, pdm, pip-tools, uv.

Попробуем сделать то же самое через poetry. Он создаст pyproject.toml, в котором будут указаны только explicit-зависимости, т.е. scikit-learn и pandas, причем, констрейт на pandas будет сохранен.

Команда

Пишем в скриптах

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

Во-первых, они влекут человеческий фактор. Ячейки можно запускать в произвольном порядке, поэтому часто возникает путаница. 

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

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

Вводим код-ревью

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

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

Напоследок

Ради повышения культуры разработки абстрактного ML-комьюнити предлагаем следующее:

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

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

  • Помните, что проект должен быть воспроизводимым и на уровне данных, и на уровне кода.

  • Комментируйте то, что делаете — пишите ReadMe, чтобы помочь будущему себе или другим членам команды, которые будут работать с вашими данными.

  • Помните про последствия утечки данных. Пишите безопасный код.

Теги:
Хабы:
+8
Комментарии0

Публикации

Информация

Сайт
tochka.com
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
Сулейманова Евгения