Всем привет. Меня зовут Владислав Козлов, я тимлид аналитиков в кластере Антифрод в Авито. В наши задачи входит разработка подходов к противодействию недобросовестным пользователям. Хочу поделиться с вами опытом оформления исследовательских проектов, который помогает нам сохранять своё и чужое душевное равновесие.
Существует точка зрения, что в жизни надо не столько стремиться к удовольствиям, сколько избегать страданий. Эта статья посвящена тому, как сделать мир лучше, уменьшив количество боли.
Что внутри статьи:
Реализация принципов в сложном проекте
Работая аналитиком, вы наверняка сталкивались с такими проявлениями несовершенства нашего мира, как:
-
невозможность проверить цифры в чужом исследовании, потому что исходные данные и способы их получения утеряны, а расчеты оформлены так, что гордиев узел «грустит в уголке»;
-
наличие всего, что нужно для воспроизведения исследования, сопровождаемое упорным нежеланием результатов сойтись с описанными в документе;
-
сильнейшая боль ниже спины, когда спустя месяцы после завершения исследования вас просят «вот тут чуть-чуть досчитать»;
-
чувство неуверенности и треск тонкого льда под ногами, когда вы пытаетесь объяснить кому-то, как получили описываемые результаты;
-
нежелание лишний раз смотреть на исследование из опасения найти ошибку, которая обнулит недели работы.
Подход, который мы используем в своей работе, призван обезопасить вас от таких неприятностей.
Общие принципы
Рассмотрим общие и всегда актуальные принципы. Мы все пользуемся разными инструментами и делаем исследования различной сложности. Рекомендации по оформлению во многом зависят от характера исследования и выбранных инструментов. Тем не менее кое-что объединяет их все.
Список пожеланий, в общем-то, короткий:
-
надежность: мы хотим избежать элементарных ошибок, вызванных невнимательностью или ленью;
-
интерпретируемость: хочется, чтобы исследование легко было показать другим людям — в том числе и себе — спустя полгода, и от этих людей не потребовалось бы больших усилий, чтобы понять как и что сделано;
-
воспроизводимость: результаты исследования должны быть легко воспроизводимы, чтобы, посчитав все еще раз неделю спустя, мы не получили совершенно другой результат;
-
изменяемость: мы должны быть способны легко и быстро что-то добавить или переделать в расчетах.
Чтобы удовлетворить пожелания, мы следуем таким общим рекомендациям:
-
сохраняй все, что можно сохранить: скрипты, файлы с данными, артефакты;
-
связывай источники и результаты: глядя на цифру, диаграмму, таблицу, пользователь должен быстро понять, на основании каких данных и с помощью каких расчетов они получены;
-
пиши код понятно: лучше делать это сразу и не лениться почистить его перед сохранением. Разбивай его на блоки, добавляй комментарии;
-
проверь себя: убедись, что в расчетах нет аномалий, если есть — объясни их. Убедись, что результаты бьются с ранее известными и проверенными цифрами;
-
подумай о воспроизводимости: не поленись сохранить исходные данные. Пиши запросы так, чтобы в будущем результат их выполнения не изменился. Указывай соль при генерации случайных чисел;
-
структурируй проект: не сваливай все в одну кучу, ведь данные, расчеты, артефакты не должны смешиваться;
-
веди лог исследования: во время исследования полезно вести простой текстовый документ, в котором можно фиксировать ход исследования – что делается, какие результаты наблюдаются, какие идеи возникают. Это здорово поможет при заполнении документа с отчетом и позволит в будущем воспроизвести ход мыслей.
Реализация принципов в сложном проекте
Ахиллесова пята любого подобного проекта — это, ожидаемо, его сложность. Много данных, расчетов, несколько итераций при проверке гипотез — все это грозит чередой проблем. Наши усилия будут направлены на снижение сложности везде, где это возможно.
В первую очередь, отделим данные от расчетов. Поэтому в проекте будет два места хранения: одно — для хранения данных, другое — для файлов с расчетами.
Проект:
-
data — данные;
-
notebooks — ноутбуки.
Разделим ноутбуки на два типа: используемые для получения наборов данных и используемые для расчетов.
Проект:
-
data — данные;
-
notebooks — ноутбуки:
-
etl — ноутбуки для получения сырых данных;
-
research — ноутбуки с расчетами.
-
Договоримся, что данные бывают трех видов:
-
сырые: только что полученные, еще не обработанные;
-
обработанные в процессе расчетов, агрегированные, очищенные и преобразованные;
-
чистые: те, которые мы хотим кому-то отдать. Например, для ручной разметки.
Проект приобретает вид:
-
data — данные:
-
raw — сырые данные;
-
processed — обработанные данные;
-
clean — чистые данные;
-
-
notebooks — ноутбуки:
-
etl — ноутбуки для получения сырых данных;
-
research — ноутбуки с расчетами.
-
Данные в проекте создаются и используются следующим образом:
-
ноутбуки etl выгружают данные и сохраняют их в data/raw;
-
ноутбуки research читают данные из data/raw и в них происходят вычисления. Они могут сохранять результаты вычислений в data/processed;
-
данные из data/processed могут переиспользоваться другими research ноутбуками;
-
если данные являются артефактом, то есть предназначены для передачи как конечный продукт, они сохраняются в data/clean.
Наложим вето на изменение файлов с данными: их можно только создавать и читать, а менять и перезаписывать — нельзя.
Следуя принципу «сохраняй все, что можно сохранить», мы договариваемся о том, что каждый раз, когда нам предстоит сделать вычисления, мы выгружаем исходные сырые данные и сохраняем их в data/raw. Принцип «подумай о воспроизводимости» требует от нас указать временные метки при сохранении данных. Хорошее название файла выглядит так: «my_data_file_YYYY-MM-DD-hh-mm-ss.parquet». Например, «user_churn_data_2024-10-11-18-35-44.parquet».
Наличие временной метки и разных версий файлов позволит достичь сразу нескольких целей:
-
легко понять, на какой именно версии основаны расчеты. Это особенно ценно, если вы делаете несколько итераций вычислений с немного разными данными;
-
легко сравнить файлы с данными разных версий и понять, в чем их отличия.
Не забываем об удобном и понятном нейминге файлов. Коллеги будут благодарны, если либо в названии data/processed, либо в его содержимом будет указание на породивший его ноутбук.
Упростим содержимое файлов:
-
scripts — скрипты и запросы;
-
artifacts — артефакты (модели, сложные визуализации, производные наших усилий).
Пример сложного проекта
Рассмотрим, как это работает на практике. Поскольку концепция сложная и без бутылки в ней не разобраться, будем разбираться с бутылкой. С тысячами бутылок. Будем анализировать набор данных о вине. Цель исследования: определить качество вина на основе результатов физико-химических тестов.
Код можно посмотреть тут
Проект создан с целью демонстрации подхода к оформлению исследования, поэтому мы не будем преследовать цель сделать исследование хорошим по существу. Это — всего лишь демонстрация подхода, а не попытка действительно хорошо предсказать качество вина.
Рассмотрим движение данных и артефактов. На схеме ниже синие прямоугольники — ноутбуки, а светло-голубые шестиугольники — пути к директориям для сохранения файлов.
-
ноутбук wine_data.ipynb загружает данные в data/raw/wine_data_{ts}.parquet
-
wine_data_{ts}.parquet из data/raw попадает в ноутбук eda.ipynb, где проводится базовый анализ
-
wine_data_{ts}.parquet из data/raw попадает в ноутбук enrich_features.ipynb, где проводится поиск новых фич и генерируются два файла с данными:
-
coefficients_metrics_{ts}.xlsx с оценкой предсказательной силы новых фич сохраняется в data/clean, готовый к обмену как самостоятельный артефакт
-
enrich_features_{ts}.parquet с датасетом, разделенным на обучающую и контрольную выборки и дополненный новыми фичами сохраняется в data/processed
-
-
enrich_features_{ts}.parquet читается из data/processed ноутбуком classifier.ipynb, после чего обучается модель-классификатор и сохраняется в artifacts как classifier_{ts}.joblib
Таким образом понятно, что и как получилось - от момента загрузки данных до момента сохранения артефактов. Если бы мы захотели, мы могли бы копировать несколько раз ноутбук classifier.ipynb, добавить версию в название, например, classifier_catboost_v1.ipynb, и таким образом обучить другую модель, сохранив полную прозрачность.
Далее рассмотрим отдельные этапы. В начале описания каждого ноутбука будет приведена схема, аналогичная той, которая находится в начале этого раздела. Единственным отличием будет то, что описываемый ноутбук будет выделен оранжевым цветом.
Более подробно ознакомиться с кодом можно тут.
Мы хотим использовать шаблоны для выполнения типовых задач, поскольку однотипность означает порядок. Ordo ab chao, как говорили горячие итальянские парни.
Поэтому рекомендуем структрировать нотбук для загрузки данных так:
-
импорт библиотек и настройки — тут импорты, создание логгера, кое‑какой технический код;
-
определение констант и настроек — тут, например, константы с путями к директориям с данными;
-
вспомогательный код — функции и классы, которые могут быть полезны дальше;
-
загрузка данных — непосредственно загрузка данных;
-
проверки — базовые проверки на то, что мы получили те данные, которые ожидали
-
сохранение — сохранение данных.
Импорт библиотек не содержит ничего неожиданного:
from ucimlrepo import fetch_ucirepo # источник данных, тут могла быть sqlalchemy
import pandas as pd # библиотека для работы с датафреймами
import ipytest # запуск тестов в ноутбуках
from pathlib import Path # работа с путями
import logging # логирование
from datetime import datetime # работа с датами
import ipynbname # для получения названия ноутбука
from typing import Optional # для аннотации типов
Настройки содержат создание логгера:
logging.basicConfig(
format='%(asctime)s %(levelname)s %(message)s', # Set the log message format
datefmt='%Y-%m-%d %H:%M:%S', # Set the date format
level=logging.INFO # Set the logging level to INFO
)
# Create a logger object
logger = logging.getLogger(__name__)
logger.info("Logger has been configured successfully.")
Такой логгер позволяет выводить сообщения вот в таком виде:
2024-11-04 19:36:07 INFO Logger has been configured successfully.
Далее определим константы. Они позволят нам указывать место сохранения данных, независимо от конкретного места расположения проекта.
ROOT = Path(".").absolute().parent.parent # абсолютный путь к папке проекта
DATA = ROOT / "data" # папка с данными
DATA_RAW = DATA / "raw" # папка для хранения сырых данных
Библиотека pathlib позволяет работать с путями, независимо от платформы. Например, выражение Path(".").absolute() вернет путь к директории, в которой находится ноутбук. В нашем случае - ~/projects/wine_project/notebooks/etl. А Path(".").absolute().parent.parent - путь к директории двумя уровнями выше. То есть - к ~/projects/wine_project/. Это - корень проекта, поэтому константа называется ROOT.
Далее мы добавляем аналогичные константы для директории с данными и с сырыми наборами данных, ~/projects/wine_project/data/raw.
В блоке вспомогательного кода определим пару очень важных функций.
get_timestamp помогает сформировать временную метку в формате год-месяц-день-час-минута-секунда.
def get_timestamp() -> str:
"""
Generates a timestamp string in the format YYYY-MM-DD-HH-MM-SS.
Returns:
str: The formatted timestamp.
"""
dt = datetime.now()
return dt.strftime("%Y-%m-%d-%H-%M-%S")
get_filename — собирает имя файла с данными, который мы собираемся сохранить. Оно по умолчанию собирается из названия ноутбука и временной метки. Ноутбук называется wine_data.ipynb, следовательно, по умолчанию функция вернет название вроде wine_data_2024-11-04-19-36-13.parquet. Такой подход позволит понять в каком ноутбуке и когда сформирован файл с сырыми данными. Таким образом реализуется принцип «Связывай источники и результаты».
def get_filename(
fname: Optional[str] = None,
extension: str = 'parquet',
) -> str:
"""
Generates a filename based on the provided name and current timestamp.
Args:
fname (Optional[str]): The base filename. If None, the current notebook name is used.
extension (str): The file extension to use. Default is 'parquet'.
Returns:
str: The generated filename with timestamp and extension.
"""
base_filename: str = fname if fname else ipynbname.name()
return f"{base_filename}_{get_timestamp()}.{extension}"
Для, собственно, загрузки данных мы используем функцию, получающую набор из archive.ics.uci.edu. На ее месте мог быть запрос к базе данных. Способ получения данных неважен, поэтому не будем разбирать конкретную реализацию.
Когда данные выгружены в датафрейм, посмотрим на несколько первых строк, удалим дубликаты строк и проверим, что сделали это успешно.
Проверять можно на количество строк, наличие нужных полей, пустых значений. Для этого используем ipytest. Ниже мы проверяем датафрейм на отсутствие дублирующихся строк. Это, отчасти, следование принципу «Проверь себя».
%%ipytest
def test_no_duplicates():
assert df.duplicated().sum() == 0
Наконец, переходим к сохранению данных:
-
получим название файла с данными с помощью упомянутой выше функции get_filename;
-
сохраним данные в data/raw в формате parquet.
fname = get_filename() # название файла
logger.info("Saving data to a file: %s", fname)
try:
df.to_parquet(DATA_RAW / fname) # сохранение
logger.info("Data saved to %s", DATA_RAW)
except:
logger.error("Error. Data was not saved.")
При успешном сохранении логгер выведет сообщения:
2024-11-04 19:36:13 INFO Saving data to a file: wine_data_2024-11-04-19-36-13.parquet
2024-11-04 19:36:14 INFO Data saved to E:eduwine_projectdataraw
Таким образом, видно где и с каким названием создан файл.
В последующих разделах мы опустим уже рассмотренные тут шаги, вроде объявления констант или функций для сохранения данных.
Исследование
Ноутбуки с расчетами создаются из шаблона, который можно увидеть тут.
Он включает такие разделы:
-
импорты - импорт библиотек;
-
константы - объявление констант с путями;
-
гипотеза - описание того, какую гипотезу проверяем;
-
результат - краткие итоги расчетов;
-
анализ - расчеты.
В файлах мы стремимся к краткости, лучше сделать несколько небольших файлов для разных гипотез, чем один большой.
Перед сохранением стараемся убедиться, что файл работает, если запустить его полностью, от начала до конца.
Использование шаблона работает сразу в двух направлениях, реализця принципы:
-
структурируй проект — шаблон облегчает создание ноутбуков с единой структурой для проверки гипотез и создания артефактов;
-
пиши код понятно — единообразие облегчает чтение и анализ ноутбуков.
EDA
Код можно увидеть тут.
Ноутбук читает сырые данные из data/raw и ничего не сохраняет. Этот ноутбук нужен для первичного разведочного анализа, в рамках которого мы пытаемся понять, есть ли в данных аномалии, разобраться с пустыми значениями, найти базовые взаимосвязи. Проверь себя во всей красе.
В разделе «Гипотеза» приводится чеклист с проведенными проверками проверками:
-
[x] описательная статистика;
-
[x] анализ пустых значений;
-
[x] анализ выбросов;
-
[ ] поиск дисбалансов;
-
[x] поиск взаимосвязей.
В разделе код приведены используемые для визуализации функции plot_distributions и plot_boxplots. Они просто помогают уместить диаграммы, описывающие фичи, на одном графике.
В разделе «Результаты» фиксируются краткие результаты расчетов.
-
в наборе данных нет пустых значений;
-
датасет содержит только числовые фичи;
-
в основном, они волатильны. Дисперсия фич заметно отличается;
-
все фичи содержат выбросы. В некоторых случаях выбросы связаны с таргетом, но в большинстве случаев явной связи нет;
-
есть фичи с достаточно высоким значением коэффициента корреляции.
Добавление фич
Код можно увидеть тут.
В этом ноутбуке мы разобьем набор данных на две части, обучающую и валидационную, и добавим несколько потенциально полезных фич.
Ноутбук получает сырые данные из data/raw, после чего разбивает таблицу на две части: обучающую и валидационную.
train = df.sample(frac=0.7,random_state=123).copy()
test = df[~df.index.isin(train.index)].copy()
Обратите внимание на указание параметра random_state: который обеспечит воспроизводимость («Подумай о воспроизводимости», да-да) при перезапуске ноутбука. Сколько бы раз мы его не перезапускали, строки будут разделены на выборки одинаково.
После чего на обучающей выборке отбираются наиболее перспективные соотношения фич, для обучения классификатора. Результатом процесса становится функция add_coefficients, с помощью которой можно обогатить датасет новыми фичами. В будущем ее можно переиспользовать в пайплайне вместе с моделями. Также, добавление новых фич происходит в одном месте, что упрощает выполнение просьб «еще кое‑что чуть‑чуть досчитать». Добавляете кортеж, и вуаля! Изменяемость обеспечена!
def add_coefficients(df: pd.DataFrame) -> pd.DataFrame:
selected_pairs = set([
('fixed_acidity', 'alcohol'),
('volatile_acidity', 'alcohol'),
('chlorides', 'pH'),
('chlorides', 'alcohol'),
('total_sulfur_dioxide', 'sulphates'),
('density', 'alcohol'),
('pH', 'alcohol'),
('residual_sugar', 'chlorides'),
('residual_sugar', 'density'),
('residual_sugar', 'sulphates'),
('total_sulfur_dioxide', 'density'),
('pH', 'sulphates'),
('fixed_acidity', 'free_sulfur_dioxide'),
('fixed_acidity', 'alcohol'),
('volatile_acidity', 'free_sulfur_dioxide'),
('density', 'alcohol'),
('pH', 'alcohol'),
])
_df = df.copy()
for f1, f2 in selected_pairs:
_df[f"{f1}_DIV_{f2}"] = _df[f1] / _df[f2]
return _df
После оценки полезности новых фич, данные о том, насколько они хорошо разделяют классы, сохраняются в data/clean, поскольку являются финальным артефактом сами по себе.
fname = get_filename('coefficients_mentrcs', extension='xlsx')
coef_metrics.to_excel(DATA_CLEAN / fname)
logger.info("%s saved to data/clean", fname)
В конце-концов, обогащенный метками выборок и новыми фичами датасет сохраняется для последующего анализа в data/processed.
fname = get_filename()
df_to_save.to_parquet(DATA_PROCESSED / fname)
logger.info("%s saved to data/processed", fname)
Обучение модели
Код можно увидеть тут.
Финальный этап, на котором мы обучим классификатор и сохраним его.
Ноутбук читает обогащенный набор данных из data/processed и делит строки на обучающую и валидационную выборки так же, как это делалось на этапе работы над фичами. Это важно, чтобы полностью исключить утечку данных. Таким образом мы убедимся, что действительно используем валидационную выборку только для валидации. Мы ведь думаем о Надежности!
df = pd.read_parquet(DATA_PROCESSED / 'enrich_features_2024-11-06-11-28-24.parquet')
train = df[df.is_train==1].copy()
test = df[df.is_train==0].copy()
Далее мы обучаем модель:
x_columns = ['fixed_acidity', 'volatile_acidity', 'citric_acid', 'residual_sugar',
'chlorides', 'free_sulfur_dioxide', 'total_sulfur_dioxide', 'density',
'pH', 'sulphates', 'alcohol', 'density_DIV_alcohol', 'residual_sugar_DIV_density',
'volatile_acidity_DIV_free_sulfur_dioxide', 'fixed_acidity_DIV_alcohol',
'residual_sugar_DIV_sulphates', 'pH_DIV_sulphates',
'chlorides_DIV_alcohol', 'total_sulfur_dioxide_DIV_density',
'fixed_acidity_DIV_free_sulfur_dioxide', 'chlorides_DIV_pH',
'pH_DIV_alcohol', 'total_sulfur_dioxide_DIV_sulphates',
'residual_sugar_DIV_chlorides', 'volatile_acidity_DIV_alcohol']
X_train = train[x_columns].copy()
y_train = train.quality.values.copy()
X_test = test[x_columns].copy()
y_test = test.quality.values.copy()
pipe = DecisionTreeClassifier(random_state=123, max_depth=4, min_impurity_decrease=0.001)
pipe.fit(X_train, y_train)
Оцениваем ее характеристики с помощью confusion matrix и отчета о качестве классификации, приходим в недоумение и трепет от низкого качества моделей, на ходу придумываем пять способов сделать модель лучше, вспоминаем, что проект демонстрационный, с облегчением машем рукой и сохраняем модель как артефакт.
«Сохраняй все, что можно сохранить» — не устает твердить наш внутренний параноик.
Обратите внимение на то, как сохраняется артефакт:
fname = get_filename(extension='joblib')
dump(pipe, ARTIFACTS / fname)
logger.info('saved to %s', fname)
>> 2024-12-01 17:56:41 INFO saved to classifier_2024-12-01-17-56-41.joblib
Функция get_filename
самостоятельно определяет название ноутбука и на его основе называет файл с артефактом. Таким образом, можно было бы сделать еще несколько копий этого же ноутбука с разными типами моделей или с разными наборами гиперпараметров, назвать эти ноутбуки по-разному, и при этом четко понимать какой артефакт в каком ноутбуке обучен. Таким образом, обеспечивается Изменяемость и выполнение сразу нескольких рекомендаций:
-
подумай о воспроизводимости — можно перезапускать ноутбуки, обучающие отдельные версии моделей сколько угодно, результат будет тот же;
-
структурируй проект — нет больших ноутбуков с обучениям множества моделей;
-
связывай источники и результаты — глядя на конкретную модель, легко достать ноутбук, в котором она обучена.
В итоге, в директории artifacts появляется дамп классификатора.
Итоги
Мы рассмотрели подход к тому, как можно оформлять исследование таким образом, чтобы обеспечить его воспроизводимость и прозрачность.
Предложенный подход позволяет значительно понизить степень энтропии, упростить обмен исследованиями, и, надеюсь, повысить их качество.
Если у Вас появятся какие-то идеи или замечания, буду рад обсудить их в комментариях или лично.
Весь код доступен в репозитории.
Больше о работе в Авито вы можете узнать здесь. А еще подписывайтесь на наш канал в Telegram, там мы рассказываем о профессиональном опыте наших инженеров, а также анонсируем митапы и статьи.
Автор: crazyjedi89