В данной статье НЕ будет идти речи о способах построения продвинутых моделей с различными наворотами. Рассказ пойдет о том, как автоматизировать построение типовой модели.
Дело в том, что в отличие от задач из учебника, в реальной работе зачастую приходится строить много вариантов модели, делать различную сопутствующую аналитику. Может варьироваться: таргет, период для семпла разработки (если имеем дело с историческими данными), добавляются разбивки на канал продаж или ещё какие-либо продуктовые разбивки. Также возможен сценарий, что на последнем этапе, когда заказчику представили модель , решили исключить некоторую фичу. Соответственно, нужно заново прогонять часть расчетов. При этом хочется от каждого варианта расчёта сохранить результаты.
Полагаю, что по этому описанию многим на ум уже пришло слово AutoML. Да, пожалуй по смыслу подходит. Однако я предлагаю рассмотреть более узкую задачу, а именно "как это все запрогать, чтобы не утонуть в море кода". Здесь мне на помощь как раз придет ООП.
Как будем подходить к решению?
Понятие AutoML довольно широкое.
Пожалуй, в качестве AutoML, в узком идеализированном случае, можно рассматривать такую систему, которая подразумевала бы, что под заданную задачу нужно лишь прописать некоторые YAML-файлики с конфигами и нажать одну большую красную кнопку. Это в свою очередь подразумевает, что наша система способна обработать любой сценарий, который мы захотим.
Однако, опытные программисты знают, что при разработке сложного ПО, как только возникает уверенность, что мы продумали все сценарии, возникает бизнес-потребность, которая едет перпендикулярно всем осям изменений программы, что мы предусмотрительно продумали.
В целом, любая автоматизация предусматривает компромисс между полнотой (покрытия требований) и гибкостью.
Максимальная гибкость достигается при минимуме этой самой автоматизации. Понятно, что если писать в двоичном коде, можно написать вообще всё, что угодно. Однако будет сложновато править и рефакторить.
Наоборот, максимальная полнота подразумевает переход к «программированию» конфигами. Конфиги, которые нужно составлять, становятся настолько гигантскими, что заполнять их становится всё сложнее. При этом чем больше покрыто разных требуемых сценариев работы, тем больше становится параметров, которые для выбранного сценария заполнять не нужно, поскольку они работают только в других сценариях.
Поэтому зададимся целью найти некоторый компромисс между полнотой и гибкостью.
Вместо построения условного идеального AutoML, который покрывает все нужные сценарии, подумаем, как построить некую библиотеку (можем называть её AutoML, можем как угодно иначе), на которую будем опираться при разработке той или иной модели.
Вместо того, чтобы программировать конфигами под некоторую готовую монолитную систему («идеальный» AutoML), будем писать новый код под каждую новую задачу, но с использованием старых наработок.
При этом мы будем соответствующим образом править и тест-кейсы, чтобы быть уверенными, что если пришлось внести изменения, код работает надежно.
Disclaimer: в готовом виде эту библиотека никуда не выложена. Она есть только в недрах Альфа-Банка. В рамках статьи приводятся лишь ключевые идеи.
Причём здесь ООП?
Какие у нас есть куски, которые мы можем потенциально взять из прошлого проекта для нового? По всей видимости, классы и функции.
Для своих проектов я использую классы. Основное преимущество над функциями это то, что можно создавать уровни абстракции.
Проблема с функциями (если не используем свои кастомные классы вообще) в том, что все параметры, которые мы им собираемся передавать, будут на самом низком уровне абстракции: DataFrame-ы, словари, списки. В связи с этим при росте проекта, когда увеличивается количество сценариев и соответствующих параметров, которые хотим покрыть, начинает распухать количество аргументов в функциях.
С использованием классов можно (в идеале) выстроить уровни абстракции, то есть, собственно, уровни классов.
На самом низком уровне будут те классы, которые менее всего подвержены изменениям. Они реализуют самый простой функционал. Настолько простой, что мы никак не ожидаем чего-то, что мы могли дополнительно от них захотеть.
Цель классов низкого уровня — automate boring stuff.
На более высоких уровнях идут те классы, которые мы будем апдейтить в большей степени, и так далее по уровням.
На самом высоком уровне стоят те классы, которые меняются больше всего. Скорее всего, как наш текущий проект закончится, мы их вообще выкинем, а в новых проектах будем создавать с нуля. Как утверждал Алан Кей, один из отцов-основателей ООП: «You get simplicity by finding a slightly more sophisticated building block to build your theories out of».
При этом наличие некоторой структуры классов и интерфейсов (в Python им соответствуют абстрактные классы или протоколы) даже высокого уровня абстракции уже подспорье. Даже не заглядывая во внутренности имеющихся методов, просто по сигнатуре методов в новом проекте уже становится понятно, куда какие куски кода добавить.
Вообще, если теоретизировать о выборе между классами и функциями для некоторой задачи, задуматься об использовании классов стоит в том случае, когда имеет место некоторая структура в данных, где возможны различные комбинации частей.
Понятно, что функцию, которая вычисляет квадрат от x, нет смысла переделывать в класс. В ней ничего никогда не поменяется. Применительно к рассматриваемой в статье проблематике, можно наблюдать комбинации повсюду, например: биннинг, использование различных алгоритмов и/или настроек для fit-а модели, какие метрики мы хотим посчитать для построенной модели и прочее…
Архитектура классов – теоретические ожидания
Подобрать подходящую архитектуру классов не так просто. Собственно, именно ей я и делюсь в данной статье. Надеюсь, читатель сможет почерпнуть какие-то идеи, особенно если уже сталкивался с подобной задачей.
Что делает программист, если он исповедует ООП?
Если забыть на минуту про синтакс, то: декомпозирует задачу, создаёт необходимые абстракции (классы), и пишет пьесу, в которой разворачиваются сцены из жизни объектов данных классов. Потом понимает, что он уже сам запутался, кто в этой пьесе кто, какие цели у действующих лиц, характеры слишком запутаны, а сюжет полон поворотов. Поэтому на следующем шаге автор всё пару раз переписывает.
Немного про ключевую идею ООП
Собственно, ключевой идеей ООП, когда оно зародилось, была не просто инкапсуляция, а концепция обмена сообщениями между объектами.
Идея была «подсмотрена» в биологии. Объект рассматривался как подобие биологической клетки. Клетка защищает своё содержимое (для объекта это его переменные), и принимает и отдаёт наружу лишь определённый набор возможных сообщений (для объекта — это обращение к его методу или вызов изнутри его метода методов других объектов). Соответственно, можно рассматривать его и как действующее лицо в пьесе.
Хороший ООП код — это максимально скучная пьеса.
В ней садовник никогда не является убийцей. Он даже ни с кем не разговаривал в течение всего действа. Просто тихо делал свою работу, и у нас нет никаких сомнений в том, в чём конкретно она заключалась.
Если код организован подобным образом, снижается его сложность. Под сложностью здесь понимаем сложность восприятия: насколько легко его можно понять при прочтении, насколько легко вносить правки. Есть замечательная популярная статья (The Grug Brained Developer), где очень доступно, практически детским языком, постулируется необходимость бороться со сложностью (читай, запутанностью) программы. Моя любимая цитата оттуда:
«complexity bad … given choice between complexity or one on one against t-rex, grug take t-rex: at least grug see t-rex »
Благо, что на сегодняшний день есть масса наработанной теории в вопросе ООП вообще и снижения сложности кода в частности: GRASP, GoF, SOLID и прочее.
Предложения по решению
В моих проектах выкристаллизовались следующие классы. Начнём как раз с низкого уровня.
Фичи
Создадим классы, чтобы хранить настройки по фичам и список фичей. Кажется, что там не так уже много всего должно быть, не правда ли? Мне тоже сначала так показалось. А получилось следующим образом.
Использована стандартная нотация:
Наследование: класс B унаследован от A.
Композиция: объект класса A состоит из объектов класса B (лишен смысла без них).
Агрегация: объект класса A содержит объекты класса B.
Ассоциация: объект А использует объект B.
Настройки фичи
Для хранения и управления настройками (в том числе бинингом) были созданы следующие интерфейсы и классы:
VarConfig
— интерфейс для представления любой колонки (как фичи, так и вспомогательной переменной).VarConfigData
— класс для хранения настроек по переменной, основан на pydantic.FeatConfig
— класс для представления фичи. Важно, что класс инкапсулирует и саму фичу, и её тип, и её биннинг.FeatConfigData
— хранит настройки по фиче, основан на pydantic.VarAuxConfig
— класс для представления вспомогательных переменных (не фичей).
Что касается бининга фичей, полноценный биннинг потребуется не для каждой модели. Однако для того, чтобы заглянуть в конкретную фичу, этот шаг всё равно понадобится. Потому что мы наверняка захотим посмотреть как основные метрики (доля пропущенных, доля модального значения), так и стабильность распределения по времени.
По следующим классам функционал, в целом, понятен из названия:
VarBinNumeric
— бин для числовой фичи (выделяет интервал значений по фиче).VarBinCategorical
— бин для категориальной фичи (выделяет набор категорий).VarBinOther
— бин для всех остальных значений фичи (которые не попали ни в один из других бинов в данном биннинге).
Я также добавил пару специальных подклассов бинов.
Во-первых, VarBinMissing
для пустых значений.
Во-вторых VarBinSpecial
, который чуть похитрее. Это специальный бин, который выделяет те наблюдения, которые мы хотим пометить не на основании значений фичи, а на основании некоторого внешнего фактора. Этот функционал полезен, когда хотим выделить информативные пропуски по фиче.
В этом случае под них мы создадим бин класса
VarBinSpecial
, который будет означать, например, что некоторая система X (источник данных) недоступна (некоторый флагis_x_available = False
).Поскольку система X недоступна, по фиче, для которой биним, будут в бине класса
VarBinSpecial
пропуски для всех наблюдений.При этом в нашем биннинге с этим бином может соседствовать бин класса
VarBinMissing
, в котором также все значения пустые по данной фиче.Однако мы будем понимать, что наблюдения в
VarBinMissing
не заполнены из-за какой-то проблемы с качеством данных, ведь система X была доступна для данных наблюдений. Обычно при таком раскладе в VarBinMissing попадает очень малое число наблюдений, лишь доля процента от всех наблюдений.
Такой подход, во-первых, позволяет лучше контролировать причины, почему мы имеем некоторую долю пустых значений по фиче. А во-вторых, бывает полезен для базельских моделей вероятности дефолта, поскольку позволяет применять консервативный подход для бина класса VarBinMissing
и не применять его для наблюдений из бина класса VarBinSpecial
.
VarBinSpecial
хранит внутри себя название название категориальной переменной и список категорий, чтобы выделить наблюдения, относящиеся к той ситуации, которую он подсвечивает.
Отдельного внимания заслуживает класс VarBinMixed
. Он позволяет объединять некоторые бины в один.
Для чего он нужен?
Подразумевается, что после автоматического биннинга мы можем захотеть подправить что-то вручную. Я добавил в библиотеку соответствующий метод для класса VarBinning
. Функция принимает на вход id тех бинов, которые нужно объединить.
Также VarBinMixed
пригодится, когда для базельских PD-моделей нужно применить консервативный подход для пустых наблюдений, обусловленных проблемами в данных (VarBinMissing
). На этот случай в класс VarBinning
добавлен метод, который сам находит бин с наименьшим WOE и объединяет его с VarBinMissing
бином.
Дабы не загромождать схему, я не приводил на ней методов для классов VarBin
и VarBinning
. Ключевой функционал класса VarBin
— метод calc_mask(self, feat: str, df: pd.DataFrame) -> pd.Series
, который считает бинарную маску для данного бина на заданном семпле данных. Этот метод имплементирован во всех подклассах VarBin
, поэтому тут мы имеем полиморфизм в действии. Для любого бина мы всегда можем вычислить, какие наблюдения он вычленяет из заданного DataFrame-а.
Класс VarBinning
, несмотря на то, что он абстрактный, содержит множество методов на все случаи жизни. Вот лишь некоторые (аргумент self
опущен):
метод | функционал |
add_bin(bin: VarBin), | Добавить \ удалить бин(ы); удалить бины только определенных типов; удалить бины непосредственно из корня или поискать иерархически внутри бинов класса |
merge_bins(*args) | Смержить бины из заданных списков |
add_prelim_bins( | Создает предварительный бининг – добавляет объекты классов |
calc_other_mask_directly(feat, df) calc_spec_mask_deep(feat, df) calc_missing_mask(feat, df) | Расчет маски для отдельных бинов внутри биннинга (для заданного поля в заданном DataFrame) |
calc_bin_masks(feat, df) | Расчет маски для каждого бина, который есть в биннинге. |
Надо отметить, что внутри методов VarBinning
приходится учитывать множество сценариев расположения бинов и их влияния бинов друг на друга. Например, при расчете масок бинов (метод calc_bin_mask
) нужно учитывать, что бины могут быть вложенными (функционал VarBinMixed)
, а также, что при расчете маски по одному бину, возможно нужно предварительно исключить наблюдения, которые относятся к другим бинам (VarBinMissing
и VarBinSpecial
). Всё это происходит под капотом внутри библиотеки, что освобождает пользователя библиотеки от лишней работы.
Пул фичей
Для работы с фичами я создал следующие классы:
VarPool
— контейнер переменных. По сути, это коллекция фичей и набор методов для работы с ними.VarPoolVerbose
— подклассVarPool
, который содержит logger для логгирования всех действий с фичами (добавление, удаление). Эту информацию потом удобно использовать при документировании модели.VarPoolLogger
— внутренни классVarPool
, ответственный за логирование действий с фичами.
Абстракция для управления охапкой фичей действительно напрашивалась. Практически в каждой задаче на моделирование нам нужно сначала собрать какой-то набор фичей, потом по каким-то критериям исключить некоторые из них.
Теоретически может также возникнуть задача добавить какую-то фичу в список. Например, мы в последний момент догадались, что можно посчитать некоторое отношение (допустим, запрошенный лимит к максимальному выданному за предыдущую истории), и решили докинуть её в общий пул. Класс VarPool
автоматизирует эту рутину. Причем за счет встроенного в него logger-а потом легче отследить какие фичи мы добавляли/убирали и по какой причине.
Итак, для работы с фичами уже получилось довольно много классов, много кода и функционала.
Но это и хорошо. Больше сможем переиспользовать для разных моделей.
Собственно, для данных классов потенциально (возможно) понадобится доработка напильником только в части таргета (бинарный таргет для задачи биномиальной классификации, либо вещественное число для задач регрессии).
Отдельно отметим, что при таком подходе мы в рассматриваемой библиотеке также будем иметь (если постараемся) огромное количество тест-кейсов. Соответственно, их можно также переиспользовать и быть уверенным в правильности кода, если какие-то классы придется как-то изменять.
Кстати, дополнительно для всех этих классов желательно сразу продумать механизм серилизации\десерилизации, чтобы можно было их после окончания разработки сохранить и при необходимости восстановить. То есть подняв из архива объект класса VarPool
у нас вся информация из него будет доступна (при его десерилизации входящие в него объекты будут распаковываться рекурсивно).
Также наличие подобных классов удобно тем, что можно сохранять различные альтернативы (биннинга, конфигурации модели и проч.), не добавляя лишнего в основном коде проекта.
Для биннинга полезно реализовать сериализацию/десериализацию в dict. Этот функционал позволит быстро просматривать как сбинилась переменная.
Семпл
Зачастую в коде нашего проекта мы работаем напрямую с некоторым объемным pandas DataFrame. Условно назовем его df. В этом случае код пестрит обращениями к этому объекту для вытаскивания наблюдений, относящихся то к train, то к OOT, то бывает нужно кросс-валидацией пройтись по нему итеративно. Код обрастает большим количеством использования .loc
с маской. При чтении кода нужно по маске воспринять, что в конкретном случае имелось в виду, например, взяли train часть или взяли test, или взяли recent семпл.
Пример:
df.loc[df[‘smpl’] == ‘train’]
Чтобы сделать код более читаемым и менее многословным, я ввел класс SampleHandler
. Теперь, чтобы обратиться, например, к train семплу, можно писать более явно и кратко:
sh.get_train()
Здесь sh
это экземпляр класса SampleHandler
. Ещё одно достоинство использования такого объекта состоит в том, что все параметры, относящиеся к семплам (даты начала и конца соответствующих периодов), теперь инкапсулированы в этом объекте, а не раскиданы по коду Jupyter-ноутбука в константах.
YAML-конфиги
Для работы с конфигами я выделил класс ConfigGeneral
.
Может возникнуть вопрос, а зачем вообще тут какой-то класс? Ведь можно просто прочитать YAML-файл и конвертировать в dict?
Класс нужен для того, чтобы обеспечить функционал:
во-первых, проверки того, что все необходимые поля были заполнены в файле;
а во-вторых, зафиксировать как их нужно преобразовать в значение (например, какой формат даты использован или в какой тип сохранить — целочисленный или float).
Класс ConfigGeneral
определяет поле класса KEYS_MAP
c dict-ом, в котором можем хранить маппинг параметр -> функция для конвертации. Тогда, когда захотим создать некий реальный конфиг, например, для указания границ семплов, то создадим класс ConfigSampling
, который наследуется от ConfigGeneral
, и переопределим переменную на уровне класса KEYS_MAP
. Перечислим в ней нужные параметры и функции для конвертации. И останется только добавить getter-ов и setter-ов.
В результате получится, что для того, чтобы взять дату начала development семпла мы будем не обращаться к dict-у с конфигом по ключу, и ещё конвертировать это на ходу в дату…
pd.to_datetime(settings[‘min_dt#dev_sample’], format = '%Y-%m-%d')
…,а цивилизованным образом напишем:
config.get_dev_s_date().
А примерно вот так будет выглядеть сам метод get_dev_s_date
в классе ConfigSampling
:
return self.get_elem(['min_dt#dev_sample'])
То есть будет использован метод get_elem
, который заблаговременно реализован в родительском классе ConfigGeneral
. Поэтому писать классы-конфиги будет легко и удобно.
Замечу, что представляется разумным иметь не один большой YAML-файл со всеми настройками, а несколько. Каждый из YAML-файлов будет связан (один к одному) с неким конфигом (класс, наследованный от ConfigGeneral
) и будет служить одной цели. Например, задать параметры семпла или задать параметры одномерного анализа и т.д.
Отчёт — базовый класс
Вне зависимости от того, какую модель будем строить, мы будем генерировать какие-то расчеты метрик и графики, например, в результате одномерного анализа или при валидации построенной модели.
Можно сохранять все результаты непосредственно в .ipynb
файл, из которого мы все запускаем. Однако, это не очень удобно, поскольку графики и таблицы сильно загромождают файл. Поэтому я предпочитаю выносить результаты в отдельный файл с отчетом.
Причем для целей построения модели можно ограничится простой раскладкой (то, что на английском зачастую именуют layout) отчёта. Совсем не нужно наводить много красоты. Наша задача, просто сохранить результаты расчетов. Поэтому можем ограничиться добавлением каждого из результатов один за другим вертикально в отчет.
Будем использовать самые простые и понятные варианты для формата файла с отчетом – markdown, pdf и html. С pdf могут возникать определенные сложности (перенос строк на следующую страницу, выравнивание и проч.) поэтому я пользовался в основном html. При необходимости его можно потом отпечатать в pdf.
Для целей генерации отчетов я также создал классы низкого уровня. Итак, ввиду упомянутых ограничений, задаём следующий набор классов и интерфейсов.
В данной архитектуре заложены принципы, навеянные старой как мир концепцией Model – Viewer – Controller.
Отчёт (
Report
) состоит из элементов (ReportElement
). Класс с отчётом содержит только посчитанные данные, он не отвечает за то, как их нарисовать в какой-то реальный файл с отчетом.Под каждый класс с отчётом создаётся его компаньон — класс, ответственный за отображение —
ReportView.
Объекты классаReportView
принимают на вход в конструкторе (т.е. при создании) в качестве аргументов объект с отчетомReport
, а также объект специального класса (а точнее реализующий интерфейс)ReportWriter
.Таким образом, объект
ReportView
берёт содержимое отчета (объектаReport
) и отображает его выбранным образом. Отображает он его путем передачи инструкций объектуReportWriter
, например, «напиши в отчет следующий заголовок» или «отрисуй в отчете следующую картинку» и прочее.Объект класса
ReportViewElement
состоит из объектов классаReportViewElement
, каждый из которых привязан к соответствующему объекту классаReportElement
.
Таким образом, логика отображения отчёта, описанная выше, выполняется и для элементов отчёта, то есть экземпляры ReportViewElement
читают содержимое экземпляров ReportElement
и передают команды объект-писатель (ReportWriter
). Для интерфейса ReportWriter
есть реализации под разные форматы выходного файла.
Можно говорить, что здесь мы успешно использовали принцип dependency inversion.
Объекты класса ReportViewElement
ничего не знают о том, в какой формат файла мы пишем за счет того, что работаем с объектом, ответственным за запись в файл, через интерфейс. Выбор конкретного подкласса ReportWriter
происходит в отдельной функции на основе аргумента, в который мы передаем строку “html”, “pdf” или “markdown” (не приведен на схеме). Данная функция, по сути, реализует шаблон фабричный метод.
В чём удобство такого подхода? Да всё в том же — можно всю эту «шарманку» перетаскивать из проекта в проект вообще без изменений.
Когда в своём текущем проекте у нас возникает необходимость добавить что-либо в отчет:
мы создаём класс
SomeReport
и наследуемся отReport
;а потом добавляем в него классы
SomeReportElem01
,SomeReportElem02
и т.д., которые наследуем отReportElem
;также создаём
SomeReportViewElem01
,SomeReportViewElem02
и и т.д., которые наследуем отReportViewElem.
Какие-то из элементов отчета мы, возможно, можем взять из старых проектов и доработать напильником, какие-то написать с нуля. Но в любом случае уже есть структура, на которую мы эти новые элементы будем насаживать. Таким образом, мы можем переиспользовать логику отображения содержимого отчета (интерфейс ReportWriter
и его реализации менять не придётся).
В этом коде я дополнительно реализовал всякие плюшки:
отображение времени расчета каждого из элементов;
добавил в выходной отчёт микротекстом имя класса для каждого из элементов, чтобы можно было легко от результаты прыгнуть в нужное место в коде и поправить ошибку.
Отчёт по одномерному анализу
В качестве примера привожу структуру классов, которые я сделал для одномерного анализа для задачи построения аппликативной модели вероятности дефолта для одного из продуктов.
Не могу сказать, что было применено много ООП паттернов. На эту тему лучше, чем в этом меме, не скажешь:
Однако паттерн Builder и паттерн Strategy пригодились.
Класс
FeatUnivAnalysis
имплементирует абстрактный классReport
. Он содержит элементы c результатами расчета той или иной метрики.Класс
FeatReportBuilder
имплементирует паттернBuilder
. Он содержит в себе объект с отчетом (FeatUnivAnalysis
), а также методы для добавления в этот отчет элементов.
Например, метод build_basic_stat()
добавляет элемент класса StatsBeforeBinningNumericElem
или StatsBeforeBinningCategorialElem
в зависимости от того, числовая это фича или категориальная. При этом объекты-строители (FeatReportBuileder
и UnivBuilder
) хранят ссылки на массивный датасет (точнее говоря, на объект класса SampleHandler
, который его инкапсулирует). Поэтому мы их не храним постоянно, а инициализируем в тот момент, когда нужно дополнить отчет, то есть произвести расчеты. Сам отчёт ничего «не знает» про датасет и не хранит ссылок на него.
Использованный подход особенно удобен, когда приходится работать с большим количество фичей.
Под каждую фичу создаётся свой отдельный отчёт, который хранится в html-файле. В рамках одной из задач моделирования мне пришлось создать около 5000 таких отчетов. Отчитались они за несколько часов, а отпечатались в формат html за 5-10 минут, поэтому такое количество дополнительных проблем не принесло. Не представляю, как можно было бы графики по такому количеству фичей попытаться создать прямо в Jupyter-ноутбуке.
Код, который запускает одномерный анализ в нашем Jupyter-ноутбуке, получается совсем короткий:
univ_builder = UnivBuilder(sh, pool_initial_with_spec, univ_config, var_target, vars_early_targets)
univ_builder.univ_step_create_prelim_binning() # предварительный биннинг (выделяем VarBinMissing, VarBinSpecial
univ_builder. univ_step_basic_stat() # расчет основных статистик (доля пропусков, мода)
univ_builder.univ_step_create_standard_binning() # автоматическое разбиение на бины
Модель
Здесь мы переходим к абстракциям более высокого уровня. Это подразумевает, что абстракции (классы) на данном уровне используют абстракции более низкого уровня (например, SampleHandler
, VarPool
и т.д.).
О чем здесь говорится?
Создаём абстрактный класс Model
, который говорит лишь о том, что должны быть имплементированы методы fit
и predict
(плюс ещё некоторые вспомогательные).
Далее создаём класс ModelSimple
c более специфичной интерпретаций того, как должна быть устроена модель. Подразумевается, что объект с моделью должен внутри себя содержать engine (объект класса ModelEngine
) и transformer
— объект, который будет необходимым образом готовить фичи к тому, чтобы их можно было подать на вход engine.
Engine
отвечает за fit
и predict.
Внутри этого объекта инкапсулируется выбранный алгоритм моделирования.
Transformer
преобразует каждую из фичей, которая используется в модели. Это может быть, например, обычное стандартизирование для числовых фичей или назначение среднего таргета для категориальных фичей. Поскольку у нас в выборках как правило представлены и числовые, и категориальные фичи, я добавил класс FeatTransformerMain
, чтобы внутри кода обращаться к одному объекту, который под капотом задействует два отдельных transformer-а в зависимости от типа той или иной фичи.
Далее можно создавать различные варианты engine
. Например, на схеме показан класс ModelEngineLGBMRegr
, который реализует интерфейс ModelEngine
для алгоритма градиентного бустинга в задаче регрессии.
Код, которым мы запускаем обучение модели, становится кратким и лаконичным:
engine = ModelEngineLGBMRegr(engine_params, varpool)
feat_transformer_main = FeatTransformerMain(
feat_transf_map, # заранее подготовленный справочник фича -> типом трансформера
TARGET, # таргет модели
)
model = ModelSimple(varpool, engine, feat_transformer_main)
Валидационный отчёт по модели
Далее дан пример классов для отчета по модели. Использовались описанные выше классы Report
и сопутствующие ему.
В отличие от отчета по одномерному анализу, представленного ранее, здесь не используется Builder. Элементы отчета создаются непосредственно в конструкторе объекта с отчетом (т.е. ModelValidation
). Настройки, зашитые в конфиге ModelValidationConfig
задают в том числе список элементов, которые будут созданы в отчете, чтобы оперативно менять состав отчета (например, при отладке).
Представлены элементы отчета с статистикой по семплу в динамике (target-rate, объёмы — SamleMainStatByTimeElem
), ключевые показатели и метрики по основным семплам (SampleStatElem
), коррелляция predict vs target по семплам (CorrBySampleElem
— использовалась в рамках задачи предсказания утилизации), корреляция predict vs target в динамике по времени.
Отбор фичей и подбор гиперпараметров
Для отбора фичей и подбора гиперпараметров были реализованы классы ещё более высокого уровня абстракции (они опираются в том числе и на Model). На этом уровне остаётся все меньше общности, которую можно выделить и зафиксировать в рамках неких интерфейсов. Поэтому в рамках данной статьи я эти классы не привожу. Этот функционал можно было реализовать и через функции вместо классов. Переиспользовать as is или наследовать и переопределять часть методов не имеет смысла.
Выводы
Образовалась пирамидальная структура. Чем ниже уровень абстракции, тем больше классов, кода, и тем больше можно подготовить тестов и больше переиспользовать.
Соответственно, чем выше уровень, тем больше вероятность, что придётся не использовать класс as is, а частично переписать или вообще написать новый (хорошо, если получится опираться на некоторый интерфейс, который следует имплементировать, т.е. отнаследоваться от него).
Не стоит также исключать, что может возникнуть желание зарефакторить некоторую часть функционала. В этом случае также есть надежда, что нижние этажи этой пирамиды можно будет либо совсем не трогать, либо изменения будут минимальны.
В этой связи, ценность представляет не то, какое разнообразие сценариев работы мы поддерживаем в классах верхних уровней, а то, насколько хорошо мы спроектировали классы нижних уровней, чтобы мы могли их переиспользовать практически без изменений.