Классическое машинное обучение

Системы предсказаний¶

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

alttext

Экспертные системы (Rule-based systems)¶

Самым первым подходом к созданию системы, способной на основе входных данных делать какие-то выводы, были так называемые Rule-based systems. В этих системах и за описание объекта — выделение значимых признаков, и за выработку правил, по которым система должна принимать то или иное решение, отвечал человек.

В современности такие системы до сих пор используются, например, в определителях растений. Такие определители представляют собой набор утверждений, например: "Растение имеет стержневую корневую систему", "Плод растения — костянка", на основе согласия/несогласия с которыми книга отсылает вас к другим утверждениям и, в конце концов, к названию растения.

Очевидно, что результативность такого подхода зависит:

  1. От наличия базовых знаний у того, кто определителем пользуется;
  2. От качества и охвата самого набора правил — к примеру, может ли этот набор правил справиться с ситуацией, когда какой-то признак отсутствует. В приведенном выше примере растение плодоносит не весь год, поэтому не всегда можно точно ответить, какой у него плод.

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

Игрушечный пример такой схемы:

alttext

Классическое машинное обучение¶

Классическое машинное обучение избавляет нас от необходимости вручную составлять какие-то правила.

alttext

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

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

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

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

И, очевидно, на больших объемах данных, когда в принципе можно использовать очень малоинформативные признаки (данных-то много, все коэффициенты при них оценим), которые и не придут исследователю в голову, классическое машинное обучение будет проигрывать.

Глубокое машинное обучение¶

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

alttext

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

alttext

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

Необходимость методов классического машинного обучения¶

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

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

В ситуации, когда у вас есть просто таблица с признаками каждого объекта (данные табличного формата), каждый признак не особо связан как-то с другими и взаиморасположение признаков в таблице не играет никакой роли:

  1. По крайней мере, вначале вам придется использовать полносвязные слои, что очень сильно увеличивает число параметров нейросети.
  2. Полученные нейросети, как правило, не сильно превосходят методы классического обучения, а могут и вести себя хуже (из-за переобучения).

Кроме того, стоит учитывать, что часто для того, чтобы достигнуть качества state-of-the-art методов классического машинного обучения, необходимо долгое время подбирать архитектуры нейросети и настраивать ее параметры.

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

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

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

Деревья решений¶

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

Когда требовалось принять решение по проблеме, для которой построено дерево, человек брал и проходился по этому дереву.

Предсказание типа линз для человека

Как оно устроено? В каждом узле есть какой-то вопрос. Например, нормальное ли у человека слезоотделение, есть ли у него астигматизм и так далее. И, отвечая на каждый из этих вопросов, мы подбираем человеку линзы. Такое дерево решений можно построить без использования моделей машинного обучения, просто на основании опыта многих врачей. По сути это как раз те самые экспертные системы.

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

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

"As long as Kaggle has been around, Anthony says, it has almost always been ensembles of decision trees that have won competitions".

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

Где побеждают ансамбли деревьев решений?

  • Recomendation systems (Netflix Prize 2009);
  • Learning to rank (Yahoo Learning to rank challenge 2010);
  • Crowdflower Search Results Relevance (2015);
  • Avito Context Ad Clicks (2015);

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

Принцип работы дерева решений¶

Принцип работы дерева решений можно проиллюстрировать на данном примере. Есть 2 признака, в данном случае вещественных. Для каждой точки мы создаем вопрос: признак $x_1$ — больше 0.7 или меньше? Если больше 0.7, то это красная точка. Если меньше 0.7, то идем во второй внутренний узел T2 и спрашиваем: признак $x_2$ меньше 0.5 или больше? Если меньше 0.5, то точка будет красная, в другом случае — синяя.

Разбиение пространства

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

Деревья решений (классификация)¶

Деревья решений, как правило, используются при решении 2-х типов задач — классификации и регрессии. Первая — классификация. Например, предсказание типа линз.

Допустим, у нас уже есть построенное дерево. Как предсказывать вероятности классов?

Предсказание типа линз для человека

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

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

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

Это согласуется с тем, как мы делаем в статистике. При построении дерева мы видим только обучающую выборку. А хотим делать выводы о генеральной совокупности. Конкретно здесь мы оцениваем, какая доля из объектов, попадающих в данный узел, принадлежит нужному классу.

Оценка доли объектов классов в популяции — здесь $$\hat{p} = \dfrac n N$$

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

$$D(\hat{p}) = \dfrac {p \cdot (1 - p)} {N}$$

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

Как построить дерево решений?¶

Для бинарных признаков¶

Допустим, у нас есть табличка, в которой пока нет никаких вещественных признаков. Также есть много данных. Мы хотим построить дерево решений по этой табличке. Предсказывать будем, есть у человека инфаркт или нет. Возьмем в качестве признака боль в груди. В 1-ом и во 2-ом листе получается разное распределение людей. В левом листе инфаркт более вероятен, в правом — менее вероятен. Другим признаком может быть “как хорошо циркулирует кровь”, в таком случае тоже получится неплохое разделение. Последний признак — “есть ли атеросклероз”.

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

Что с этим делать? Как из этих трёх разделений что-то выбрать? Логично выбрать такое разбиение, которое дает нам "хорошие" узлы — те, в которых преимущественно сосредоточенны объекты одного класса

Одна из используемых метрик называется Gini. Она считается по следующей формуле:

$$Gini = 1 - \sum_ip_i^2$$

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

Посчитаем Gini для признака "Боль в груди":

$\displaystyle Gini_1 = 1- (\frac{105}{105+33})^2 - (\frac{33}{105+33})^2 = 0.364$

$\displaystyle Gini_2 = 1- (\frac{34}{34+125})^2 - (\frac{125}{34+125})^2 = 0.336$

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

$\displaystyle Impurity \ decrease = Gini_0 - (\frac{n_1}{n_1+n_2}Gini_1 + \frac{n_2}{n_1+n_2}Gini_2),$

где $n_1, n_2$ — число объектов в листьях,

$ \quad\ Gini_0$ — чистота исходного узла.

Выбираем это разбиение как приводящее к наибольшему уменьшению Impurity

Оказывается, что наибольший impurity_decrease в признаке “боль в груди”. Значит, мы возьмем “боль в груди”, как признак, на основании которого продолжим строить дерево.

Теперь каждое из этих листьев, которые у нас получились, мы можем разбить. Имеет ли смысл их бить, используя тот же признак (“боль в груди”)? Очевидно, нет, потому что мы по нему уже все разделили. Но можно использовать 2 других признака.

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

Можно ли остановиться раньше? В принципе да. Например, когда у нас получатся листья, в которых есть объекты только 1 класса. В этом случае не имеет смысла использовать другие разбиения. Либо может сложиться ситуация, когда разбиение по признаку, которое мы дополнительно взяли, ситуацию никак не улучшает, и дерево предсказывает настолько же плохо, как если бы мы его не использовали. Но это редкая ситуация.

Для вещественных признаков¶

Как работать с вещественными переменными?

Допустим, у нас есть вещественные примеры (например, измерения). Что делать с ними? Берем сетевой угол переменной, для каждого порога делаем разбиение (как делали ранее для бинарного признака) и считаем impurity_decrease. Из всех этих разбиений можно выбрать то, которое дает наилучший результат.

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

Для категориальных признаков¶

Мы разобрали бинарные и вещественные признаки.

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

Нейронные сети работают с категориальными признаками плохо, обычно необходимо специальным образом предобработать данные, чтобы нейронная сеть смогла с ними работать.

Если у нас есть категориальной признак, у которого N возможных значений (например, N цветов), то мы можем разбить наши объекты на 2 группы $2^N$ возможных способов. То есть красный и синий идут в одну сторону, а белый и желтый в другую. Таким образом, мы можем выбрать, идет какой-то цвет в определенный лист или нет. Отсюда получается порядка 2^N возможных разбиений. Перебирать все возможные разбиения — долго и для категориальных признаков с большим числом возможных категорий неприменимо.

Среднее значение $y$ для объектов, у которых категориальная переменная $x_i$ равна данному значению

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

Деревья решений (Регрессия)¶

Для решения задач регрессии дерево строится практически точно так же, но есть несколько нюансов. Во-первых, в листе нужно предсказывать не класс, а какое-то значение. Тут опять возникает вопрос: в области пространства мы знаем какие-то объекты. Им соответствует какое-то значение. Как лучше всего одним числом охарактеризовать все эти объекты?

Метрики регрессии

Если мы вспомним статистику, то у нас нет однозначного ответа. Мы можем для распределения всех этих объектов в листе предсказывать наиболее частое значение, медиану, среднее значение. Обычно предсказывают среднее значение, потому что с ним легче всего работать и, опять же, среднее чаще всего используют в статистике.

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

$$\overline{x} = \dfrac {\sum_i X_i} {n}$$

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

$$D(\overline{x}) = \dfrac {\sigma^2} {n} $$

Полученная формула говорит нам о том, что желательно иметь в каждом листе достаточное число объектов, чтобы дисперсия оценки была меньше

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

Какую меру можно использовать? Для этого подумаем, а как мы в итоге обычно оцениваем качество регрессии? При помощи MSE. MSE на тренировочной выборке, если мы предсказываем ее среднее, будет таким:

$$\frac 1 n \sum (X_i - \overline{X})^2 = \dfrac {n} {n - 1} D(X)$$

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

Разбиение регрессионного дерева:

$$\displaystyle R_1(j, s) = \{ X | X_j \leq s \} \ and \ R_2(j, s) = \{ X | X_j > s \}$$

Мера качества узла — дисперсия оценки $R_0$.

Остальное так же, как с классификацией:

$$\displaystyle \frac{D_{R_1} \cdot N_1 + D_{R_2} \cdot N_2}{N_1 + N_2} < D_{R_0} $$

Деревья решений и работа с пропущенными значениями¶

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

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

  1. На место пропущенных значений записать какие-то числа (заполнить пропущенные значения);
  2. Удалить все объекты с пропущенными значениями.

Чаще всего в науке так сделать нельзя, потому что данные стоят очень дорого, и поэтому выбрасывают объекты только из-за того, что у них нет какого-либо признака, достаточно редко.

Поэтому обычно прибегают к первому варианту. Как это можно сделать?

  • Можно предположить, что данные не очень сложные, признаки не зависят друг от друга, и заполнить пропущенные значения средними значениями соответствующего признака. Например, если средний возраст людей 30 лет, то для людей, чей возраст неизвестен, будем писать это же значение. Но бывают случаи, когда этот способ (среднее, медиана или любая другая взятая величина, которую мы считаем по известным значениям признаков) не работает.

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

  • Использовать алгоритм, который умеет справляться с пропуском. Один из таких алгоритмов — k-NN. Можно брать ближайших соседей по известным признакам и на место неизвестного признака поставить среднее значение. Можно сделать нейросеть, которая будет устойчива к отсутствию какого-то признака. Способ рабочий.

Деревья решений могут бороться с этой проблемой двумя способами:

  1. Деревья могут информацию о том, что для какого-то значения признака нет, трактовать как особое правило (выделим такие объекты в особую группу). Это действительно будет работать.
  2. Если мы в этом узле разбивали объекты по данному признаку и для текущего объекта он неизвестен, то можно поискать среди нашей обучающей выборки признаки, которые дают разбиение, похожее на признак с неизвестным значением. Это не надо делать вручную, в некоторых пакетах это реализовано за вас. Это работающее решение, которое явно лучше, чем заполнение пропусков вручную.

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

Дерево решений — суррогатные сплиты

Преимущества и недостатки деревьев решений¶

Почему деревья — очень мощный метод?¶

Взгляд с точки зрения функционального анализа

$\displaystyle \qquad \qquad h(x) = \sum \sigma(\dots \sum(w^Tx)) \qquad \qquad \qquad \qquad \qquad \qquad \qquad \qquad h(x) = \sum_dc_dI\{x \in R_d\}$

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

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

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

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

Неустойчивость деревьев решений

Деревья решений не используется в чистом виде, потому что они неустойчивы. Если у нас есть данные, и мы выкинем из данных 2 объекта, то дерево решений может очень сильно поменяться. Красивого дерева, как на примере в самом начале, у нас не получится.

Продемонстрируем неустойчивость решения, получаемого при помощи деревьев решений, на примере датасета Iris (ирисы Фишера).

Ирисы Фишера состоят из данных о 150 экземплярах ириса, по 50 экземпляров трёх видов: Ирис щетинистый (Iris setosa), Ирис виргинский (Iris virginica) и Ирис разноцветный (Iris versicolor).

Для каждого экземпляра измерялись четыре характеристики (в сантиметрах):

  1. Длина наружной доли околоцветника (англ. sepal length);
  2. Ширина наружной доли околоцветника (англ. sepal width);
  3. Длина внутренней доли околоцветника (англ. petal length);
  4. Ширина внутренней доли околоцветника (англ. petal width).

Будем учиться отделять Ирис виргинаский (versicolor) от остальных видов.

In [1]:
from sklearn.datasets import load_iris
import pandas as pd

dataset = load_iris()
df = pd.DataFrame(dataset.data, columns=dataset.feature_names)
df["target"] = dataset.target != 1  # 0 for setosa, 1 - versicolor, 2 - virginica

Сделаем два разных разбиения на обучение и тест. И посмотрим, будут ли отличаться деревья, построенные для данных разбиений.

In [2]:
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
import matplotlib.pyplot as plt
from sklearn import tree

# first set of points
x_train1, x_test1, y_train1, y_test1 = train_test_split(
    df[dataset.feature_names], df["target"], random_state=0
)
clf1 = DecisionTreeClassifier(max_depth=3)
clf1.fit(x_train1, y_train1)

# second set of points
x_train2, x_test2, y_train2, y_test2 = train_test_split(
    df[dataset.feature_names], df["target"], random_state=42
)
clf2 = DecisionTreeClassifier(max_depth=3)
clf2.fit(x_train2, y_train2)

fn = ["sepal length (cm)", "sepal width (cm)", "petal length (cm)", "petal width (cm)"]
cn = ["setosa", "versicolor", "virginica"]
fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(15, 5), dpi=100)
tree.plot_tree(clf1, feature_names=fn, class_names=cn, filled=True, ax=axes[0])
tree.plot_tree(clf2, feature_names=fn, class_names=cn, filled=True, ax=axes[1])
plt.show()

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

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

In [3]:
# first set of points
clf1 = DecisionTreeClassifier(max_depth=10, random_state=0)
clf1.fit(x_train1, y_train1)

# second set of points
clf2 = DecisionTreeClassifier(max_depth=10, random_state=42)
clf2.fit(x_train2, y_train2)


fn = ["sepal length (cm)", "sepal width (cm)", "petal length (cm)", "petal width (cm)"]
cn = ["setosa", "versicolor", "virginica"]
fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(15, 5), dpi=100)
tree.plot_tree(clf1, feature_names=fn, class_names=cn, filled=True, ax=axes[0])
tree.plot_tree(clf2, feature_names=fn, class_names=cn, filled=True, ax=axes[1])
plt.show()

Переобучение деревьев¶

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

Покажем это на синтетическом датасете:

In [4]:
# handson-ml
import numpy as np
from matplotlib.colors import ListedColormap


def plot_decision_boundary(
    clf, x, y, axes=[-1.5, 2.5, -1, 1.5], alpha=0.85, contour=True, bolded=False
):
    x1s = np.linspace(axes[0], axes[1], 100)
    x2s = np.linspace(axes[2], axes[3], 100)
    x1, x2 = np.meshgrid(x1s, x2s)
    x_new = np.c_[x1.ravel(), x2.ravel()]
    y_pred = clf.predict(x_new).reshape(x1.shape)
    custom_cmap = ListedColormap(["#FEE7D0", "#bea6ff", "#B8E1EC"])
    plt.contourf(x1, x2, y_pred, alpha=0.3, cmap=custom_cmap)
    if contour:
        custom_cmap2 = ListedColormap(["#FEE7D0", "#5D5DA6", "#B8E1EC"])
        if bolded:
            custom_cmap2 = ListedColormap(["#FEE7D0", "#5D5DA6", "#000000"])
        plt.contour(x1, x2, y_pred, cmap=custom_cmap2)
    plt.plot(x[:, 0][y == 0], x[:, 1][y == 0], "D", c="#F9B041", alpha=alpha)
    plt.plot(x[:, 0][y == 1], x[:, 1][y == 1], "o", c="#2DA9E1", alpha=alpha)
    plt.axis(axes)
    plt.xlabel(r"$x_1$", fontsize=18)
    plt.ylabel(r"$x_2$", fontsize=18, rotation=0)
In [5]:
import sklearn

x, y = sklearn.datasets.make_moons(n_samples=500, noise=0.30, random_state=42)

plt.figure(figsize=(8, 6))
plt.plot(x[:, 0][y == 0], x[:, 1][y == 0], "D", c="#F9B041")
plt.plot(x[:, 0][y == 1], x[:, 1][y == 1], "o", c="#2DA9E1")
plt.show()
In [6]:
x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=42)

plt.figure(figsize=(8, 6))
clf = DecisionTreeClassifier(max_depth=20, random_state=42)
clf.fit(x_train, y_train)
plot_decision_boundary(clf, x, y)
plt.title("Decision border", fontsize=14)
plt.show()

В областях, покрашенных в оранжевый, модель будет классифицировать точки как точки из 0 класса. В синих — как точки из 1-ого класса.

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

Что произойдет, если мы возьмем точки из того же датасета, но другие?

In [7]:
# first set of points
x_train1, x_test1, y_train1, y_test1 = train_test_split(x, y, random_state=1)
clf1 = DecisionTreeClassifier(max_depth=20, random_state=42)
clf1.fit(x_train1, y_train1)

# second set of points
x_train2, x_test2, y_train2, y_test2 = train_test_split(x, y, random_state=2)
clf2 = DecisionTreeClassifier(max_depth=20, random_state=42)
clf2.fit(x_train2, y_train2)

plt.figure(figsize=(16, 6))
plt.subplot(121)
plot_decision_boundary(clf1, x, y)
plt.title("Decision border 1", fontsize=14)
plt.subplot(122)
plot_decision_boundary(clf2, x, y)
plt.title("Decision border 2", fontsize=14)
plt.show()

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

Говорят, что у нашего дерева высокий variance.

Можно ли что-то поправить? У нас в настройках максимальная глубина дерева поставлена равной 20. Это очевидно много. Давайте сделаем дерево глубины 1

In [8]:
# first set of points
clf1 = DecisionTreeClassifier(max_depth=1, random_state=42)
clf1.fit(x_train1, y_train1)

# second set of points
clf2 = DecisionTreeClassifier(max_depth=1, random_state=42)
clf2.fit(x_train2, y_train2)

plt.figure(figsize=(16, 6))
plt.subplot(121)
plot_decision_boundary(clf1, x, y)
plt.title("Decision border 1", fontsize=14)
plt.subplot(122)
plot_decision_boundary(clf2, x, y)
plt.title("Decision border 2", fontsize=14)
plt.show()

Или глубины 2

In [9]:
# first set of points
clf1 = DecisionTreeClassifier(max_depth=2, random_state=42)
clf1.fit(x_train1, y_train1)

# second set of points
clf2 = DecisionTreeClassifier(max_depth=2, random_state=42)
clf2.fit(x_train2, y_train2)

plt.figure(figsize=(16, 6))
plt.subplot(121)
plot_decision_boundary(clf1, x, y)
plt.title("Decision border 1", fontsize=14)
plt.subplot(122)
plot_decision_boundary(clf2, x, y)
plt.title("Decision border 2", fontsize=14)
plt.show()

Теперь полученные границы решений (почти) совпадают. Но что мы видим? Наше дерево абсолютно не в состоянии (в обоих случаях) уловить закономерность в исходных данных. Если мы отложим только тренировочный датасет, то увидим следующее

In [10]:
x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=42)

clf1 = DecisionTreeClassifier(max_depth=2, random_state=42)
clf1.fit(x_train, y_train)

clf2 = DecisionTreeClassifier(max_depth=20, random_state=42)
clf2.fit(x_train, y_train)

plt.figure(figsize=(16, 6))
plt.subplot(121)
plot_decision_boundary(clf1, x_train, y_train)
plt.title("Decision border, depth=2, train only", fontsize=14)
plt.subplot(122)
plot_decision_boundary(clf2, x_train, y_train)
plt.title("Decision border, depth=20, train only", fontsize=14)
plt.show()

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

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

Bias, Variance, Irreducible error¶

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

$$ Model\ error = Bias + Variance + Irreducible\ error $$

Bias¶

Обычно, высокий bias имеют модели, которые недостаточно сложны по сравнению с реальной закономерностью, которая заложена в данных. Например, реальная зависимость, которую мы наблюдаем, нелинейная, а мы пытаемся аппроксимировать ее прямой линией. В этом случае наше решение заведомо смещено (biased) в сторону линейной модели, и мы будем систематически ошибаться в сравнении с реальной моделью данных.

Variance¶

Можно получить и обратную ситуацию. Если модель будет слишком сложная (в смысле своей выразительной способности) и "гибкая", то она сможет слишком сильно подстроиться под данные и выучить тренировочную выборку полностью. В этом случае модель будет подстраиваться под любой шум в данных и пытаться объяснить его какой-то сложной закономерностью. Малое изменение в данных будет приводить к большим изменениям в прогнозе модели.

Иногда bias и variance представляют еще таким образом:

  1. Можно быть очень точным и попадать всегда в центр мишени — это соответствует низкому bias и низкому variance.
  2. Можно попадать примерно в центр мишени, но при этом с большим разбросом — низкий bias, но высокий variance.
  3. Можно стрелять кучно, но не в центр — это высокий bias и низкий variance.
  4. Можно просто стрелять наугад — это высокий bias и высокий variance.

Irreducible error¶

Если удачно подобрать модель и ее гиперпараметры, то гипотетически можно точно предсказать среднее значение ожидаемой величины, то есть получить и низкий $Bias$, и низкий $Variance$.

В реальности при измерении физической величины есть случайные непредсказуемые погрешности — отклонения от среднего. Из-за этого предсказания всегда будут иметь определенный уровень ошибки, ниже которого опуститься нельзя — это и есть $Irreducible\ error$.

Bias vs variance¶

В практических задачах, когда невозможно подобрать реальную модель данных, не получается бесконечно уменьшать и Bias, и Variance — приходится искать компромисс (bias-variance tradeoff). С какого-то момента при уменьшении Bias начнет увеличиваться Variance, и наоборот. Задача исследователя — найти точку оптимума.

Можно построить зависимость этих величин от сложности модели (capacity). По мере увеличения сложности Variance имеет тенденцию к возрастанию, а Bias — к убыванию. Более сложные модели подстраиваются под случайные шумы обучающей выборки, а более простые — не могут воспроизвести реальные закономерности.

Управлять эффектом variance и bias можно с помощью выбора как модели, так и гиперпараметров модели.

Продемонстрируем источники компонент Bias и Variance на примере регрессии зашумленной косинусоиды методом k-NN. Создадим функцию для генерации небольшой обучающей выборки и отобразим ее на графике

In [11]:
np.random.seed(42)

num_points = 300
num_grid = 500
x_max = 3.14
plt.figure(figsize=(10, 6))


def get_sample(num_points, x_max, std=0.3, x_sample=None):
    if x_sample is None:
        x_sample = (np.random.rand(num_points) - 0.5) * 2 * x_max
    y_sample = np.cos(x_sample.flatten()) + np.random.randn(x_sample.shape[0]) * std
    return x_sample.reshape(-1, 1), y_sample


x_grid = np.linspace(-x_max, x_max, num_grid).reshape(-1, 1)
x_sample, y_sample = get_sample(num_points=num_points, x_max=x_max)
_, y_true = get_sample(num_points=num_points, x_max=x_max, std=0, x_sample=x_grid)

plt.scatter(x_sample, y_sample, c="#bea6ff", label="Noise")
plt.plot(x_grid, y_true, "b--", linewidth=4, label="Real func")
plt.xlabel("X")
plt.ylabel("Y")
plt.legend()
plt.show()

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

In [12]:
from sklearn.neighbors import KNeighborsRegressor

np.random.seed(42)

num_points = 30
num_models = 3
plt.figure(figsize=(24, 6))

model = KNeighborsRegressor(n_neighbors=1)
y_pred = np.zeros((num_models, num_grid))
sample_color = ["#00E134", "#FF9100", "#FF00B3"]
for model_num in range(num_models):
    x_sample, y_sample = get_sample(num_points=num_points, x_max=x_max)
    model.fit(x_sample, y_sample)
    y_pred[model_num] = model.predict(x_grid)
    _, y_true = get_sample(num_points=num_points, x_max=x_max, std=0, x_sample=x_grid)

    plt.subplot(1, 3, model_num + 1)
    plt.scatter(
        x_sample, y_sample, c=sample_color[model_num], label=f"sample {model_num+1}"
    )
    plt.plot(
        x_grid,
        y_pred[model_num],
        c=sample_color[model_num],
        alpha=0.8,
        label=f"model trained on sample {model_num+1}",
    )
    plt.plot(x_grid, y_true, "b--", linewidth=4, label="real mean")
    plt.xlabel("X")
    plt.ylabel("Y")
    plt.ylim(-1.5, 1.8)
    plt.legend(loc="lower center")

Предсказания моделей отличаются друг от друга и от истинной кривой средних значений косинуса.

Обучим 1000 моделей, для разного количества соседей ($1, 3, 25$) на разных подвыборках наших данных. Выберем одну тестовую точку и посмотрим, как предсказания моделей в этой точке зависят от гиперпараметра — количества соседей.

In [13]:
import matplotlib.gridspec as gridspec

num_models = 1000

for n_neighbors in [1, 3, 25]:
    model = KNeighborsRegressor(n_neighbors=n_neighbors)

    y_pred = np.zeros((num_models, num_grid))

    plt.figure(figsize=(10, 4))
    gs = gridspec.GridSpec(1, 2, width_ratios=[2, 1])
    plt.subplot(gs[0])

    for model_num in range(num_models):
        x_sample, y_sample = get_sample(num_points=num_points, x_max=x_max)
        model.fit(x_sample, y_sample)
        y_pred[model_num] = model.predict(x_grid)
        plt.plot(x_grid, y_pred[model_num], alpha=0.01, c="g", linewidth=5)

    _, y_true = get_sample(num_points=num_points, x_max=x_max, std=0, x_sample=x_grid)
    plt.plot(x_grid, y_true, c="b", linewidth=3, label="real mean")
    plt.axvline(x=x_grid[num_grid // 2], c="r", linewidth=1, label="X text point")
    plt.xlim((-x_max, x_max))
    plt.ylim((-1, 2))
    plt.xlabel("X")
    plt.ylabel("Y")
    plt.gca().set_title(f"{num_models} models: {n_neighbors} nearest neighbours")
    plt.legend(loc="upper right")

    plt.subplot(gs[1])
    var = y_pred[:, num_grid // 2].var()
    bias = np.abs(y_true[num_grid // 2] - y_pred[:, num_grid // 2].mean())
    plt.hist(
        y_pred[:, num_grid // 2],
        bins=15,
        color="g",
        alpha=0.5,
        orientation="horizontal",
        label=f"predictions: \nvar = {var:.2f}\nbias = {bias:.2f}",
    )
    plt.axhline(y=y_true[num_grid // 2], c="b", linewidth=3, label="real mean")
    plt.ylim((-1, 2))
    plt.xlabel("hist counts")
    plt.ylabel("Y")
    plt.gca().set_title(f"predictions at test point")
    plt.legend(loc="upper left")
    plt.tight_layout()
    plt.show()

По мере увеличения числа соседей:

  • повышается абсолютное значение сдвига среднего значения предсказаний моделей относительно истинного значения (Bias) — в среднем предсказания моделей становятся менее точными
  • снижается дисперсия предсказаний модели (Variance) — предсказания моделей становятся устойчивее и слабее зависят от конкретной выборки

Применительно к деревьям¶

Дерева малой глубины имеет малую сложность и высокий bias. Дерево большой глубины имеет высокую сложность и высокий variance.

Можно подобрать для дерева идеальную capacity, когда Bias и Variance будут суммарно давать наименьший вклад в ошибку. Этим мы занимаемся при подборе параметров. Но, оказывается, есть и другие способы борьбы с variance и/или bias, которые мы разберем позже.

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

Если наложить решающие границы 100 решающих деревьев, построенных на разных выборках из $x, y$, то мы увидим, что "хорошие области", соответствующие реальному разделению данных, будут общими между деревьями, а плохие — индивидуальны. К сожалению, в реальности мы не можем брать бесконечное число наборов данных из генеральной совокупности (представленной в данном случае $x, y$)

In [14]:
plt.figure(figsize=(8, 6))

for i in range(1, 101):
    x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=i)
    clf = DecisionTreeClassifier(max_depth=20, random_state=0)
    clf.fit(x_train, y_train)
    plot_decision_boundary(clf, x, y, alpha=0.02, contour=False)
plt.show()

Бутстрэп¶

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

Есть много подходов к тому, как получить такую оценку, и один из них — бутстрэп.

Что мы делаем:

  1. Делаем из нашего исходного датасета N выборок такого же размера с повторениями.

  2. Для каждой полученной выборки (обычно их называют псевдовыборками) считаем характеристику, для которой хотим получить оценку.

  3. В результате такой процедуры получаем N значений характеристики. Строим гистограмму этих значений. Получаем примерное распределение нашей характеристики.

  4. Можем построить 95% доверительный интервал для нашей характеристики. Для этого отрезаем 2.5% самых больших значений и самых малых.

Давайте попробуем сделать это на двух практических примерах.

Корреляция и построение доверительного интервала для нее¶

Часто мы хотим понять, как взаимосвязаны две переменные. И часто для оценки этой взаимосвязи используется корреляция. Например, корреляция Пирсона позволяет оценить линейную зависимость между двумя переменными.

Пример 1¶

Подсчет корреляции Пирсона реализован в Python с помощью функции scipy.stats.pearsonr. Посчитаем при помощи этой функции корреляцию между длиной наружной доли околоцветника и внутренней доли околоцветника.

In [15]:
import pandas as pd
from sklearn.datasets import load_iris

dataset = load_iris()
df = pd.DataFrame(dataset.data, columns=dataset.feature_names)
df["target"] = dataset.target != 1
In [16]:
import scipy.stats

cor_value, pval = scipy.stats.pearsonr(df["sepal length (cm)"], df["petal length (cm)"])
print(f"Correlation coefficient is {cor_value:.3f}")
print(f"P-value is {pval:.2e}")
Correlation coefficient is 0.872
P-value is 1.04e-47

Функция выдает нам одновременно и коэффициент, и его значимость — вероятность наблюдать такое или более критическое значение. Однако на основе чего она считала p-value?

Можно обратиться к документации

In [17]:
?scipy.stats.pearsonr

p-value считается в предположении, что наши переменные распределены нормально.

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

In [18]:
import numpy as np


def pearsonr_ci(x, y, alpha=0.05):
    """calculate Pearson correlation along with the confidence interval using scipy and numpy
    Parameters
    ----------
    x, y : iterable object such as a list or np.array
      Input for correlation calculation
    alpha : float
      Significance level. 0.05 by default
    Returns
    -------
    r : float
      Pearson's correlation coefficient
    pval : float
      The corresponding p value
    lo, hi : float
      The lower and upper bound of confidence intervals
    """

    r, p = scipy.stats.pearsonr(x, y)
    r_z = np.arctanh(r)
    se = 1 / np.sqrt(x.size - 3)
    z = scipy.stats.norm.ppf(1 - alpha / 2)
    lo_z, hi_z = r_z - z * se, r_z + z * se
    lo, hi = np.tanh((lo_z, hi_z))
    return lo, hi
In [19]:
lo, hi = pearsonr_ci(df["sepal length (cm)"], df["petal length (cm)"])

print(f"Lower bound of confidence interval: {lo:.3f}")
print(f"Upper bound of confidence interval: {hi:.3f}")
Lower bound of confidence interval: 0.827
Upper bound of confidence interval: 0.906

А если мы не хотим делать предположения о нормальности? Или знаем, что они не выполняются? Или не хотим писать сложных функций на каждый случай: если $x$ распределено так-то, то считай так-то, если так-то, то так-то и т.д.?

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

In [20]:
def bootstrap_metric(x, y, metric_fn, samples_cnt=1000, random_state=42):
    np.random.seed(random_state)
    b_metric = np.zeros(samples_cnt)
    for it in range(samples_cnt):
        poses = np.random.choice(x.shape[0], size=x.shape[0], replace=True)

        x_boot = x[poses]
        y_boot = y[poses]
        m_val = metric_fn(x_boot, y_boot)
        b_metric[it] = m_val

    return b_metric

Посчитаем значение корреляции Пирсона на бутстрэп-репликах:

In [21]:
import matplotlib.pyplot as plt
import seaborn as sns

boot_cor = bootstrap_metric(
    x=df["sepal length (cm)"],
    y=df["petal length (cm)"],
    metric_fn=lambda x, y: scipy.stats.pearsonr(x, y)[0],
)

# plot histogram of the obtained values:
plt.figure(figsize=(10, 6))
sns.histplot(boot_cor)
plt.show()

Чтобы получить bootstrap 1-alpha доверительный интервал, просто посчитаем alpha/2 и 1-alpha квантили в полученном массиве:

In [22]:
alpha = 0.05
lo_2, hi_2 = np.quantile(boot_cor, q=[alpha / 2, 1 - alpha / 2])

print(f"Lower bound of confidence interval: {lo:.3f}, bootstap: {lo_2:.3f}")
print(f"Upper bound of confidence interval: {hi:.3f}, bootstap: {hi_2:.3f}")
Lower bound of confidence interval: 0.827, bootstap: 0.836
Upper bound of confidence interval: 0.906, bootstap: 0.903

Получили доверительный интервал при помощи бутстрэпа. Заметим, что точность интервала зависит от числа реплик, которые вы сделаете: чем больше число реплик, тем точнее интервал.

Сравнивая полученные интервалы с интервалом, подсчитанным в предположении нормальности, видим, что они почти друг от друга не отличаются.

Пример 2¶

Загрузим датасет Heart Disease, содержащий данные о людях с подозрением на сердечные заболевания. Допустим, мы хотим узнать, есть ли связь между уровнем холестерина (колонка chol) и возрастом (age).

In [23]:
heart_dataset = pd.read_csv(
    "https://edunet.kea.su/repo/EduNet-web_dependencies/datasets/heart.csv"
)
In [24]:
lo, hi = pearsonr_ci(heart_dataset["age"], heart_dataset["chol"])

print(f"Lower bound of confidence interval: {lo:.3f}")
print(f"Upper bound of confidence interval: {hi:.3f}")
Lower bound of confidence interval: 0.103
Upper bound of confidence interval: 0.319
In [25]:
cor_value, p_value = scipy.stats.pearsonr(heart_dataset["age"], heart_dataset["chol"])

print(f"Correlation coefficient is: {cor_value:.3f}")
print(f"P-value: {p_value:.2e}")
Correlation coefficient is: 0.214
P-value: 1.79e-04

Видим, что корреляция есть (доверительный интервал не включает 0). Или где-то есть подвох? На самом деле, да. Возраст точно не распределен нормально. Потому считать доверительный интервал с предположениями о нормальности переменных не очень хорошо. Посмотрим, что скажет нам bootstrap-интервал:

In [26]:
boot_cor = bootstrap_metric(
    x=heart_dataset["age"],
    y=heart_dataset["chol"],
    metric_fn=lambda x, y: scipy.stats.pearsonr(x, y)[0],
)
lo_2, hi_2 = np.quantile(boot_cor, q=[alpha / 2, 1 - alpha / 2])

print(f"Lower bound of confidence interval(bootstap): {lo_2:.3f}")
print(f"Upper bound of confidence interval(bootstap): {hi_2:.3f}")
Lower bound of confidence interval(bootstap): 0.101
Upper bound of confidence interval(bootstap): 0.313

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

Построение доверительного интервала для качества метрики¶

Представим теперь, что мы сравним поведение двух моделей на тестовом датасете. Допустим, нас интересует F1-score. Как нам это сделать?

Пример 1 (искусственный)¶

In [27]:
size = 1500
y = np.random.choice([0, 1], size=size, replace=True)
print(f"shape y: {y.shape}")
print(f"First 10 values: {y[0:10]}")
shape y: (1500,)
First 10 values: [0 0 0 0 0 1 1 1 1 0]

Напишем функцию, которая имитирует поведение модели, угадывающей правильный класс в $p$ процентах случаев:

In [28]:
def guess_model(y_real, p):
    guessed = np.random.choice([True, False], size=size, replace=True, p=[p, 1 - p])
    y_pred = np.zeros_like(y_real)
    y_pred[guessed] = y_real[guessed]
    y_pred[~guessed] = 1 - y_real[~guessed]
    return y_pred

Пусть у нас две модели обладают одинаковым качеством, а третья — лучшим:

In [29]:
model1 = lambda y: guess_model(y, p=0.7)
model2 = lambda y: guess_model(y, p=0.7)
model3 = lambda y: guess_model(y, p=0.75)

np.random.seed(42)
y_pred1 = model1(y)
y_pred2 = model2(y)
y_pred3 = model3(y)

Первый вариант — просто посчитать F1-score каждой модели и проранжировать их в соответствии с F1-score:

In [30]:
from sklearn.metrics import f1_score

qual1 = f1_score(y_true=y, y_pred=y_pred1)
qual2 = f1_score(y_true=y, y_pred=y_pred2)
qual3 = f1_score(y_true=y, y_pred=y_pred3)

print(f" qual1: {qual1:.3f}\n qual2: {qual2:.3f}\n qual3: {qual3:.3f}")
 qual1: 0.694
 qual2: 0.698
 qual3: 0.765

Очевидная проблема: да, нужная нам модель выбрана, но почему мы считаем различия между первой и второй моделью значимыми, а между второй и третьей — нет?

In [31]:
print(f"qual2 - qual1: {(qual2 - qual1):.3f}\nqual3 - qual2: {(qual3 - qual2):.3f}")
qual2 - qual1: 0.004
qual3 - qual2: 0.067

Получается, такой способ (при помощи точечной оценки), может привести к ошибке, так как не дает нам судить о значимости отличий.

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

Способ состоит в применении бутстрэпа к предсказаниям модели и реальным меткам.

In [32]:
boot_f1score_m1 = bootstrap_metric(
    y, y_pred1, metric_fn=lambda x, y: f1_score(y_true=x, y_pred=y)
)
boot_f1score_m2 = bootstrap_metric(
    y, y_pred2, metric_fn=lambda x, y: f1_score(y_true=x, y_pred=y)
)
boot_f1score_m3 = bootstrap_metric(
    y, y_pred3, metric_fn=lambda x, y: f1_score(y_true=x, y_pred=y)
)

Построим 90% доверительный интервал качества для каждой модели:

In [33]:
alpha = 0.10
print(
    "F1 score for the 1st model: ",
    np.quantile(boot_f1score_m1, q=[alpha / 2, 1 - alpha / 2]),
)
print(
    "F1 score for the 2st model: ",
    np.quantile(boot_f1score_m2, q=[alpha / 2, 1 - alpha / 2]),
)
print(
    "F1 score for the 3st model: ",
    np.quantile(boot_f1score_m3, q=[alpha / 2, 1 - alpha / 2]),
)
F1 score for the 1st model:  [0.6706605  0.71665785]
F1 score for the 2st model:  [0.67613252 0.71965134]
F1 score for the 3st model:  [0.74469469 0.78326985]

Теперь мы видим, что доверительные интервалы для качества первой и второй модели практически одинаковы, в то время как доверительный интервал для качества третьей модели от них сильно отличается и не пересекается.

Можем построить боксплот:

In [34]:
plt.figure(figsize=(16, 6))
sns.boxplot(
    y=np.concatenate([boot_f1score_m1, boot_f1score_m2, boot_f1score_m3]),
    x=["model1"] * 1000 + ["model2"] * 1000 + ["model3"] * 1000,
)
plt.ylabel("f1 score", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.show()

Пример 2 (классифицируем людей с больным сердцем и нет)¶

In [35]:
from sklearn.model_selection import train_test_split

x = heart_dataset.drop("target", axis=1)
y = heart_dataset["target"] > 0
x_train, x_test, y_train, y_test = train_test_split(x, y.values, random_state=42)
In [36]:
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import GridSearchCV

svc_model = GridSearchCV(
    SVC(), {"kernel": ("linear", "rbf"), "C": [0.01, 0.1, 1, 10]}
).fit(x_train, y_train)

logr_model = GridSearchCV(
    LogisticRegression(solver="liblinear", max_iter=100000),
    {"penalty": ("l1", "l2"), "C": [0.01, 0.1, 1, 10, 100]},
).fit(x_train, y_train)

# few objects in the leaf - poor estimates of class probabilities - the model is overfitting
dt_model = GridSearchCV(
    DecisionTreeClassifier(),
    {"max_depth": [1, 3, 5, 7, 10], "min_samples_leaf": [1, 3, 5, 10]},
).fit(x_train, y_train)
In [37]:
from sklearn.metrics import average_precision_score  # PR-AUC

y_pred1 = svc_model.decision_function(
    x_test
)  # by default, SVM gives score to each object instead of probabilities
y_pred2 = logr_model.predict_proba(x_test)[:, 1]
y_pred3 = dt_model.predict_proba(x_test)[:, 1]

qual1 = average_precision_score(y_true=y_test, y_score=y_pred1)
qual2 = average_precision_score(y_true=y_test, y_score=y_pred2)
qual3 = average_precision_score(y_true=y_test, y_score=y_pred3)
In [38]:
print(f"Logistic regression pr-auc: {qual1:.03f}")
print(f"SVC pr-auc: {qual2:.03f}")
print(f"DecisionTreeClassifier pr-auc: {qual3:.03f}")
Logistic regression pr-auc: 0.893
SVC pr-auc: 0.902
DecisionTreeClassifier pr-auc: 0.801

Теперь подсчитаем бутстрэп-оценки. Обратите внимание на то, что теперь мы передаем не предсказания, а вероятности.

In [39]:
boot_score_logreg = bootstrap_metric(
    y_test, y_pred1, metric_fn=lambda x, y: average_precision_score(y_true=x, y_score=y)
)
boot_score_svc = bootstrap_metric(
    y_test, y_pred2, metric_fn=lambda x, y: average_precision_score(y_true=x, y_score=y)
)
boot_score_dt = bootstrap_metric(
    y_test, y_pred3, metric_fn=lambda x, y: average_precision_score(y_true=x, y_score=y)
)

alpha = 0.10
print(
    "Logistic regression pr-auc 90%-ci: ",
    np.quantile(boot_score_logreg, q=[alpha / 2, 1 - alpha / 2]),
)
print("SVC pr-auc 90%-ci:", np.quantile(boot_score_svc, q=[alpha / 2, 1 - alpha / 2]))
print(
    "DecisionTreeClassifier pr-auc 90%-ci:",
    np.quantile(boot_score_dt, q=[alpha / 2, 1 - alpha / 2]),
)
Logistic regression pr-auc 90%-ci:  [0.80793003 0.97363727]
SVC pr-auc 90%-ci: [0.8276533  0.97115084]
DecisionTreeClassifier pr-auc 90%-ci: [0.70049707 0.90513571]

Видим, что качество SVC и логистической регрессии почти не отличается, а дерево решений уступает обеим моделям.

In [40]:
plt.figure(figsize=(16, 6))
sns.boxplot(
    y=np.concatenate([boot_score_logreg, boot_score_svc, boot_score_dt]),
    x=["Log-reg"] * 1000 + ["SVC"] * 1000 + ["DT"] * 1000,
)
plt.ylabel("PR-AUC", size=20)
plt.xlabel("Base models", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.show()

Ансамбли¶

Корректирующий код¶

Пусть у нас есть сигнал:

1110110011

Но при передаче на другое устройство в нем могут возникать ошибки.

1010111011

Самое простое решение возникшей проблемы:

  1. Шум, который вносит ошибки, скорее всего не зависит от места в сигнале
  2. Передадим 3 раза один и тот же сигнал

1010111011

1110010011

11001101 11

  1. Усредним, что получилось (в каждом случае возьмем наиболее часто встречающуюся цифру)

1110110011

  1. С большой долей вероятности итоговый сигнал восстановится
  2. Чем больше копий сигналов передастся, тем выше вероятность, что сигнал восстановится полностью корректно

Напишем код, чтобы удостовериться в наших выводах.

In [41]:
import numpy as np


def get_signal(size, random_state=42):
    signal = np.random.choice([0, 1], size, replace=True)
    return signal


def compare_signs(sig1, sig2):
    return (sig1 != sig2).sum()


def add_noise(sig, noise_p=0.20):
    sig = sig.copy()
    changed = np.random.choice(
        [True, False], sig.shape[0], replace=True, p=[noise_p, 1 - noise_p]
    )
    sig[changed] = 1 - sig[changed]
    return sig


def average_signals(sigs):
    sig = np.mean(sigs, axis=0)
    sig = np.round(sig, 0)
    return sig


def send_signal(signal, tries):
    passed_sigs = [add_noise(signal) for _ in range(tries)]
    fin_signal = average_signals(passed_sigs)
    return fin_signal
In [42]:
import matplotlib.pyplot as plt

np.random.seed(42)
repeats = 1000
signals_cnt_rng = range(1, 30, 2)

signal = get_signal(10)
mistakes = np.zeros((repeats, len(signals_cnt_rng)))

for j, sig_cnt in enumerate(signals_cnt_rng):
    for i in range(repeats):
        rec_sig = send_signal(signal, sig_cnt)
        mistakes[i, j] = compare_signs(rec_sig, signal)


mn = mistakes.mean(axis=0)
sd = mistakes.std(axis=0)
plt.figure(figsize=(10, 5))
plt.title("Number of error in signal", fontsize=14)
plt.ylabel("Number of errors", fontsize=14)
plt.xlabel("Number of signals passed at once", fontsize=14)
plt.plot(signals_cnt_rng, mn)
plt.fill_between(signals_cnt_rng, mn - sd, mn + sd, facecolor="blue", alpha=0.1)
plt.show()

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

Усреднение предсказания классификаторов¶

Постановка задачи:

Есть 10 объектов, в реальности все принадлежат классу 1

1111111111

Пусть у нас есть три независимых классификатора A, B и C. Каждый предсказывает 1 в 70% случаев.

Мы хотим получить общий классификатор на основании этих трех.

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

Будем просто усреднять предсказание наших классификаторов

$$ \large h(x) = \dfrac 1 T \sum_{i=1}^{T}a_i(x) $$

Простое голосование

Допустим, у нас 3 классификатора, которые предсказывают независимо друг от друга.

Посчитаем вероятность того, что:

  1. Все три классификатора верны: $0.7 * 0.7 * 0.7 = 0.3429$
  2. Два классификатора верны: $0.7 * 0.7 * 0.3 + 0.7 * 0.3 * 0.7 + 0.3 * 0.7 * 0.7 = 0.4409$

Таким образом, если брать большинство голосов, то мы будем в 78% случаев предсказывать верно. Мы взяли 3 классификатора, которые сами по себе были не очень хорошими, и получили классификатор лучшего качества. Очевидно, что если взять 4, 5, 10 классификаторов, то ситуация будет становиться лучше.

Пусть теперь у нас три классификатора, выдающие следующие предсказания

1111111100 — 80% точность

1111111100 — 80% точность

1011111100 — 70% точность

Если объединим предсказания, то получим:

1111111100 — 80% точность

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

А вот если возьмем такие классификаторы, то все получится:

1111111100 — 80% точность

0111011101 — 70% точность

1000101111 — 60% точность

Усреднение:

1111111101 — 90% точность

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

In [43]:
def get_predictions(y_real, p, cnt):
    size = y_real.shape[0]
    guessed = np.random.choice([True, False], (cnt, size), p=[p, 1 - p])
    y = np.repeat(y_real.reshape(1, -1), cnt, axis=0)
    y[~guessed] = 1 - y[~guessed]
    return y
In [44]:
import pandas as pd
import seaborn as sns

size = 1000
reps = 10

cnt_base_predictors = [1] + list(range(5, 105, 5))
single_qual = [0.45, 0.5, 0.51, 0.55, 0.6, 0.75, 0.9]

dt = {"cnt": [], "single_qual": [], "accuracy": []}

for i in range(reps):
    y_real = np.random.choice([0, 1], size)
    for cnt in cnt_base_predictors:
        for p in single_qual:
            preds = get_predictions(y_real, p, cnt)
            voting = np.round(preds.mean(axis=0))
            accuracy = (y_real == voting).mean()
            dt["cnt"].append(cnt)
            dt["single_qual"].append(f"{p:.02}")
            dt["accuracy"].append(accuracy)

results = pd.DataFrame(dt)

plt.figure(figsize=(16, 6))

sns.lineplot(data=results, x="cnt", y="accuracy", hue="single_qual", lw=3, alpha=0.5)
plt.xlabel("Number of base classifiers", size=20)
plt.ylabel("Accuracy", size=20)
plt.legend(loc="best", fontsize=12, title="Single classifier quality")
plt.show()

Видим:

  1. Чем лучше базовый классификатор, тем меньше нужно классификаторов при прочих равных условиях для достижения большего качества
  2. Если качество базового классификатора даже чуть больше 0.5, то качество ансамбля растет с увеличением числа моделей в ансамбле
  3. Если качество базового классификатора неотличимо от случайного (0.5), то качество ансамбля будет оставаться равным 0.5
  4. Если качество базового классификатора ниже случайного (0.5), то качество случайного классификатора стремится к 0

Коррелированность моделей¶

Посмотрим, как зависит качество предсказания от коррелированности предсказателей. Конкретно — от ожидаемой коррелированности вероятностей ошибиться на данном объекте для любой взятой пары классификаторов из ансамбля.

In [45]:
import scipy


def get_correlated_predictions(y_real, p, cnt, r):
    size = y_real.shape[0]
    x1 = np.random.uniform(0, 1, size)
    x2 = np.random.uniform(0, 1, (cnt, size))
    q = np.sqrt(r)
    y = (
        q * x1 + (1 - q**2) ** 0.5 * x2
    )  # y variables now correlated with correlation=r
    y_mod = np.zeros_like(y)
    for i in range(y.shape[0]):
        y_mod[i] = scipy.stats.rankdata(y[i])

    y = y_mod / size  # back to uniform, slightly affects correlations

    y_pred = np.repeat(y_real.reshape(1, -1), cnt, axis=0)
    y_pred[y < 1 - p] = 1 - y_pred[y < 1 - p]  # to predictions, affects correlations
    return y_pred
In [46]:
np.random.seed(42)
x = np.arange(0, 1, 0.05)
accuracy = np.zeros_like(x)
p = 0.7
cnt = 100
for ind, r in enumerate(x):
    preds = get_correlated_predictions(y_real, p, cnt, r)
    voting = np.round(preds.mean(axis=0))
    accuracy[ind] = (y_real == voting).mean()

plt.figure(figsize=(16, 6))
plt.title(f"Accuracy of {cnt} classifiers ensemble", size=20)
plt.xlabel("Correlation among classifiers", size=20)
plt.ylabel("Accuracy", size=20)
plt.axhline(y=p, color="red", lw=5, ls="--", label="Single classifier")
sns.lineplot(x=x, y=accuracy, lw=5, label="Ensemble")
plt.legend(fontsize=20)
plt.show()

Видим, что, по мере увеличения коррелированности моделей, качество все больше и больше приближается к качеству одной базовой модели.

Bagging = Bootstrap aggregation¶

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

Первый вариант — нам поможет уже рассмотренный bootstrap:

  1. Делаем из нашего исходного датасета N выборок такого же размера с повторениями.

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

  3. Получаем N слабо зависимых моделей.

  4. Когда нам нужно сделать предсказание для нового объекта, делаем предсказание каждой из N моделей, а затем усредняем предсказание.

В sklearn для бэггинга можно использовать класс BaggingClassifier из sklearn.ensemble. Мы напишем свой код для бэггинга для большей наглядности происходящего. Кроме того, это вам понадобится при выполнении заданий.

Пишем свой bagging¶

In [47]:
import sklearn


def get_bootstrap_sample(x, y):
    size = x.shape[0]
    poses = np.random.choice(size, size=size, replace=True)
    x_boot = x[poses]
    y_boot = y[poses]
    return x_boot, y_boot


class BaggingBinaryClassifierEnsemble:
    def __init__(self, base_classifier, ensemble_size, random_state=42):
        self.base_classifier = base_classifier
        self.ensemble_size = ensemble_size
        self.random_state = random_state
        self.ensemble = []

    def fit(self, x, y):
        np.random.seed(self.random_state)
        for est_id in range(self.ensemble_size):
            x_boot, y_boot = get_bootstrap_sample(x, y)
            model = sklearn.clone(self.base_classifier)  # create new base model
            model.fit(x_boot, y_boot)
            self.ensemble.append(model)

    def predict_proba(self, x):
        if not self.ensemble:
            raise Exception("Unfitted model")

        y_pred = 0
        for est in self.ensemble:
            y_pred += est.predict(x)
        y_pred = y_pred / self.ensemble_size
        return y_pred

    def predict(self, x):
        y_proba = self.predict_proba(x)
        y_pred = np.round(y_proba)
        return y_pred

Классифицируем людей с больным сердцем и нет, используя bagging¶

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

In [48]:
heart_dataset = pd.read_csv(
    "https://edunet.kea.su/repo/EduNet-web_dependencies/datasets/heart.csv"
)
In [49]:
from sklearn.model_selection import train_test_split

x = heart_dataset.drop("target", axis=1)
y = heart_dataset["target"] > 0
x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=42)
In [50]:
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import GridSearchCV

svc_model = GridSearchCV(
    SVC(), {"kernel": ("linear", "rbf"), "C": [0.01, 0.1, 1, 10]}
).fit(x_train, y_train)

logr_model = GridSearchCV(
    LogisticRegression(solver="liblinear", max_iter=100000),
    {"penalty": ("l1", "l2"), "C": [0.01, 0.1, 1, 10, 100]},
).fit(x_train, y_train)

# few objects in the leaf - poor estimates of class probabilities - the model is overtraining
dt_model = GridSearchCV(
    DecisionTreeClassifier(),
    {"max_depth": [1, 3, 5, 7, 10], "min_samples_leaf": [1, 3, 5, 10]},
).fit(x_train, y_train)

Опробуем модель:

In [51]:
bagging_dt = BaggingBinaryClassifierEnsemble(
    dt_model.best_estimator_, ensemble_size=100
)

bagging_logreg = BaggingBinaryClassifierEnsemble(
    logr_model.best_estimator_, ensemble_size=100
)

bagging_svc = BaggingBinaryClassifierEnsemble(
    svc_model.best_estimator_, ensemble_size=100
)
In [52]:
bagging_dt.fit(x_train.values, y_train.values)
bagging_logreg.fit(x_train.values, y_train.values)
bagging_svc.fit(x_train.values, y_train.values)
In [53]:
y_pred_blr = bagging_logreg.predict_proba(x_test.values)
y_pred_bsvc = bagging_svc.predict_proba(x_test.values)
y_pred_bdt = bagging_dt.predict_proba(x_test.values)
In [54]:
from sklearn.metrics import average_precision_score  # PR-AUC

qual_blr = average_precision_score(y_true=y_test, y_score=y_pred_blr)
qual_bsvc = average_precision_score(y_true=y_test, y_score=y_pred_bsvc)
qual_bdt = average_precision_score(y_true=y_test, y_score=y_pred_bdt)
print(f"Bagged Logistic regression pr-auc: {qual_blr:.03f}")
print(f"Bagged SVC pr-auc: {qual_bsvc :.03f}")
print(f"Bagged DecisionTreeClassifier pr-auc: {qual_bdt:.03f}")
Bagged Logistic regression pr-auc: 0.872
Bagged SVC pr-auc: 0.900
Bagged DecisionTreeClassifier pr-auc: 0.898
In [55]:
def bootstrap_metric(x, y, metric_fn, samples_cnt=1000, alpha=0.05, random_state=42):
    size = len(x)
    np.random.seed(random_state)
    b_metric = np.zeros(samples_cnt)
    for it in range(samples_cnt):
        poses = np.random.choice(x.shape[0], size=x.shape[0], replace=True)

        x_boot = x[poses]
        y_boot = y[poses]

        m_val = metric_fn(x_boot, y_boot)
        b_metric[it] = m_val

    return b_metric
In [56]:
boot_score_blogreg = bootstrap_metric(
    y_test.values,
    y_pred_blr,
    metric_fn=lambda x, y: average_precision_score(y_true=x, y_score=y),
)
boot_score_bsvc = bootstrap_metric(
    y_test.values,
    y_pred_bsvc,
    metric_fn=lambda x, y: average_precision_score(y_true=x, y_score=y),
)
boot_score_bdt = bootstrap_metric(
    y_test.values,
    y_pred_bdt,
    metric_fn=lambda x, y: average_precision_score(y_true=x, y_score=y),
)
alpha = 0.10
print(
    "Bagged Logistic regression pr-auc 90%-ci: ",
    np.quantile(boot_score_blogreg, q=[alpha / 2, 1 - alpha / 2]),
)
print(
    "Bagged SVC pr-auc 90%-ci:",
    np.quantile(boot_score_bsvc, q=[alpha / 2, 1 - alpha / 2]),
)
print(
    "Bagged DecisionTreeClassifier pr-auc 90%-ci:",
    np.quantile(boot_score_bdt, q=[alpha / 2, 1 - alpha / 2]),
)
Bagged Logistic regression pr-auc 90%-ci:  [0.78262498 0.95069418]
Bagged SVC pr-auc 90%-ci: [0.81964811 0.9761168 ]
Bagged DecisionTreeClassifier pr-auc 90%-ci: [0.83053272 0.95963424]
In [57]:
y_pred1 = svc_model.decision_function(
    x_test
)  # by default, SVM gives score to each object instead of probabilities
y_pred2 = logr_model.predict_proba(x_test)[:, 1]
y_pred3 = dt_model.predict_proba(x_test)[:, 1]

qual1 = average_precision_score(y_true=y_test, y_score=y_pred1)
qual2 = average_precision_score(y_true=y_test, y_score=y_pred2)
qual3 = average_precision_score(y_true=y_test, y_score=y_pred3)
In [58]:
boot_score_logreg = bootstrap_metric(
    y_test.values,
    y_pred1,
    metric_fn=lambda x, y: average_precision_score(y_true=x, y_score=y),
)
boot_score_svc = bootstrap_metric(
    y_test.values,
    y_pred2,
    metric_fn=lambda x, y: average_precision_score(y_true=x, y_score=y),
)
boot_score_dt = bootstrap_metric(
    y_test.values,
    y_pred3,
    metric_fn=lambda x, y: average_precision_score(y_true=x, y_score=y),
)
In [59]:
plt.figure(figsize=(11, 6))

result_arrays = [
    boot_score_logreg,
    boot_score_svc,
    boot_score_dt,
    boot_score_blogreg,
    boot_score_bsvc,
    boot_score_bdt,
]
base_models = ["Log-Reg", "SVC", "DT"] * 2
ensemble_types = ["Single"] * 3 + ["Bagged"] * 3

dfs = []
for i, res in enumerate(result_arrays):
    df = pd.DataFrame(res, columns=["pr_auc"])
    df["base_model"] = base_models[i]
    df["ensemble_method"] = ensemble_types[i]
    dfs.append(df)

sns.boxplot(data=pd.concat(dfs), y="pr_auc", x="base_model", hue="ensemble_method")
plt.xlabel("Base models", size=20)
plt.ylabel("PR-AUC", size=20)
plt.legend(fontsize=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.show()

Главному аутсайдеру — дереву решений — при ансамблировании удается получить качество не хуже других базовых моделей.

Сравним разделяющие плоскости дерева решений и бэггинга на деревьях решений¶

In [60]:
from sklearn import datasets

x, y = sklearn.datasets.make_moons(n_samples=500, noise=0.30, random_state=42)
x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=42)
In [61]:
from matplotlib.colors import ListedColormap


def plot_decision_boundary(
    clf, x, y, axes=[-1.5, 2.5, -1, 1.5], alpha=0.85, contour=True, bolded=False
):
    x1s = np.linspace(axes[0], axes[1], 100)
    x2s = np.linspace(axes[2], axes[3], 100)
    x1, x2 = np.meshgrid(x1s, x2s)
    x_new = np.c_[x1.ravel(), x2.ravel()]
    y_pred = clf.predict(x_new).reshape(x1.shape)
    custom_cmap = ListedColormap(["#FEE7D0", "#bea6ff", "#B8E1EC"])
    plt.contourf(x1, x2, y_pred, alpha=0.3, cmap=custom_cmap)
    if contour:
        custom_cmap2 = ListedColormap(["#FEE7D0", "#5D5DA6", "#B8E1EC"])
        if bolded:
            custom_cmap2 = ListedColormap(["#FEE7D0", "#5D5DA6", "#000000"])
        plt.contour(x1, x2, y_pred, cmap=custom_cmap2)
    plt.plot(x[:, 0][y == 0], x[:, 1][y == 0], "D", c="#F9B041", alpha=alpha)
    plt.plot(x[:, 0][y == 1], x[:, 1][y == 1], "o", c="#2DA9E1", alpha=alpha)
    plt.axis(axes)
    plt.xlabel(r"$x_1$", fontsize=18)
    plt.ylabel(r"$x_2$", fontsize=18, rotation=0)
In [62]:
plt.figure(figsize=(16, 6))
plt.subplot(121)
clf = DecisionTreeClassifier(max_depth=10, random_state=42)
clf.fit(x_train, y_train)
plot_decision_boundary(clf, x, y)
plt.title("Single Decision Tree", fontsize=14)

plt.subplot(122)
bagging_dt = BaggingBinaryClassifierEnsemble(
    DecisionTreeClassifier(max_depth=10), ensemble_size=100
)
bagging_dt.fit(x_train, y_train)
plot_decision_boundary(bagging_dt, x, y)
plt.title("Bagged Decision Tree", fontsize=14)
plt.show()

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

До этого мы говорили (обсуждая bias-variance trade-off), что хорошо бы взять решающие границы большого числа деревьев, построенных на разных тренировочных датасетах, полученных из генеральной совокупности, и усреднить эти границы, получив более хорошую решающую границу. К сожалению, мы не можем брать бесконечное число тренировочных датасетов. Но у нас есть бутстрэп. И он тоже достаточно хорошо аппроксимирует границу, которую мы бы получили, будь у нас много разных тренировочных датасетов:

In [63]:
plt.figure(figsize=(16, 6))
plt.subplot(121)
plt.title("Decision borders of DT trained on different train datasets", fontsize=14)
for i in range(100):
    x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=i)
    clf = DecisionTreeClassifier(max_depth=20, random_state=42)
    clf.fit(x_train, y_train)
    plot_decision_boundary(clf, x, y, alpha=0.02, contour=False)

plt.subplot(122)
plt.title(
    "Decision borders of DT trained on different  bootstrap datasets", fontsize=14
)
x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=42)
bagging_dt = BaggingBinaryClassifierEnsemble(
    DecisionTreeClassifier(max_depth=10), ensemble_size=100, random_state=42
)

bagging_dt.fit(x_train, y_train)

for base_dt in bagging_dt.ensemble:
    plot_decision_boundary(base_dt, x, y, alpha=0.02, contour=False)

Метод случайных подпространств (RSM, random subspace method)¶

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

Обычно для каждой модели выбирают:

для задач классификации:$$\sqrt{feature\_cnt}$$ для задач регрессии: $$ \frac {feature\_cnt} {3}$$ Хотя строгих правил нет, этот параметр можно подбирать на кросс-валидации.

Пишем свой RSM¶

Опять же, напишем метод случайных подпространств сами:

In [64]:
def get_rsm_sample(x, y, f_num=None):
    size = x.shape[1]
    f_num = f_num or int(np.sqrt(size)) + 1
    f_num = min(size, f_num)

    f_poses = np.random.choice(size, size=f_num, replace=False)
    x_rsm = x[:, f_poses]
    y_rsm = y.copy()
    return x_rsm, y_rsm, f_poses


class RSMBinaryClassifierEnsemble:
    def __init__(
        self, base_classifier, ensemble_size, random_state=42, max_features=None
    ):
        self.base_classifier = base_classifier
        self.ensemble_size = ensemble_size
        self.random_state = random_state
        self.max_features = max_features

        self.ensemble = []
        self.feature_poses = []
        # we had to keep track of features selected. In sklearn Random Forest, discussed below,
        # another, more stable implementation is used.
        # they use `f_num` random features but in case no good split found, they try other features too.

    def fit(self, x, y):
        np.random.seed(self.random_state)
        for est_id in range(self.ensemble_size):
            x_boot, y_boot, f_poses = get_rsm_sample(x, y, f_num=self.max_features)
            self.feature_poses.append(f_poses)
            model = sklearn.clone(self.base_classifier)  # create new base model
            model.fit(x_boot, y_boot)
            self.ensemble.append(model)

    def predict_proba(self, x):
        if not self.ensemble:
            raise Exception("Unfitted model")

        y_pred = 0
        for ind, est in enumerate(self.ensemble):
            y_pred += est.predict(x[:, self.feature_poses[ind]])
        y_pred = y_pred / self.ensemble_size
        return y_pred

    def predict(self, x):
        y_proba = self.predict_proba(x)
        y_pred = np.round(y_proba)
        return y_pred

Классифицируем людей с больным сердцем и нет, используя RSM¶

In [65]:
x = heart_dataset.drop("target", axis=1)
y = heart_dataset["target"] > 0
x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=42)

rsm_dt = RSMBinaryClassifierEnsemble(dt_model.best_estimator_, ensemble_size=100)

rsm_logreg = RSMBinaryClassifierEnsemble(logr_model.best_estimator_, ensemble_size=100)

rsm_svc = RSMBinaryClassifierEnsemble(svc_model.best_estimator_, ensemble_size=100)
In [66]:
rsm_dt.fit(x_train.values, y_train.values)
rsm_logreg.fit(x_train.values, y_train.values)
rsm_svc.fit(x_train.values, y_train.values)
In [67]:
y_pred_rlr = rsm_logreg.predict_proba(x_test.values)
y_pred_rsvc = rsm_svc.predict_proba(x_test.values)
y_pred_rdt = rsm_dt.predict_proba(x_test.values)
In [68]:
boot_score_rlogreg = bootstrap_metric(
    y_test.values,
    y_pred_rlr,
    metric_fn=lambda x, y: average_precision_score(y_true=x, y_score=y),
)

boot_score_rsvc = bootstrap_metric(
    y_test.values,
    y_pred_rsvc,
    metric_fn=lambda x, y: average_precision_score(y_true=x, y_score=y),
)

boot_score_rdt = bootstrap_metric(
    y_test.values,
    y_pred_rdt,
    metric_fn=lambda x, y: average_precision_score(y_true=x, y_score=y),
)

alpha = 0.10
print(
    "RSM Logistic regression pr-auc 90%-ci: ",
    np.quantile(boot_score_blogreg, q=[alpha / 2, 1 - alpha / 2]),
)
print(
    "RSM SVC pr-auc 90%-ci:", np.quantile(boot_score_bsvc, q=[alpha / 2, 1 - alpha / 2])
)
print(
    "RSM DecisionTreeClassifier pr-auc 90%-ci:",
    np.quantile(boot_score_bdt, q=[alpha / 2, 1 - alpha / 2]),
)
RSM Logistic regression pr-auc 90%-ci:  [0.78262498 0.95069418]
RSM SVC pr-auc 90%-ci: [0.81964811 0.9761168 ]
RSM DecisionTreeClassifier pr-auc 90%-ci: [0.83053272 0.95963424]
In [69]:
plt.figure(figsize=(14, 6))

result_arrays = [
    boot_score_logreg,
    boot_score_svc,
    boot_score_dt,
    boot_score_blogreg,
    boot_score_bsvc,
    boot_score_bdt,
    boot_score_rlogreg,
    boot_score_rsvc,
    boot_score_rdt,
]
base_models = ["Log-Reg", "SVC", "DT"] * 3
ensemble_types = ["Single"] * 3 + ["Bagged"] * 3 + ["RSM"] * 3

dfs = []
for i, res in enumerate(result_arrays):
    df = pd.DataFrame(res, columns=["pr_auc"])
    df["base_model"] = base_models[i]
    df["ensemble_method"] = ensemble_types[i]
    dfs.append(df)

sns.boxplot(data=pd.concat(dfs), y="pr_auc", x="base_model", hue="ensemble_method")
plt.xlabel("Base model", size=20)
plt.ylabel("PR-AUC", size=20)
plt.legend(fontsize=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.show()

RSM значительно помогает только дереву решений. Остальные методы почти не улучшают своего качества.

Комбинация RSM и Bagging¶

Можно объединить оба способа: применяем bootstrap к объектам (получается выборка одного размера, но с повторяющимися объектами, а каких-то объектов не будет), и, кроме этого, выкидываем часть признаков. Зачем это нужно? В этом случае мы получим еще более сильно отличающиеся друг от друга случайные выборки.

sklearn.ensemble.BaggingClassifier и sklearn.ensemble.BaggingRegressor вопреки названию может поддерживать оба способа.

Почему для одних классов моделей работает, а для других — нет¶

Теперь будем использовать BaggingClassier из стандартной библиотеки:

In [70]:
from sklearn.ensemble import BaggingClassifier

models = {}

logr_model = GridSearchCV(
    LogisticRegression(solver="liblinear", max_iter=100000),
    {"penalty": ("l1", "l2"), "C": [0.01, 0.1, 1, 10, 100]},
).fit(x_train, y_train)
models["LogReg"] = logr_model

svc_model = GridSearchCV(
    SVC(), {"kernel": ("linear", "rbf"), "C": [0.01, 0.1, 1, 10]}
).fit(x_train, y_train)
models["SVC"] = svc_model

# few objects in the leaf - poor estimates of class probabilities - the model is overtraining
dt_model = GridSearchCV(
    DecisionTreeClassifier(),
    {"max_depth": [1, 3, 5, 7, 10], "min_samples_leaf": [1, 3, 5, 10]},
).fit(x_train, y_train)
models["DT"] = dt_model

bagging_logr = BaggingClassifier(
    logr_model.best_estimator_, n_estimators=100, random_state=42
)
models["Bagging LogReg"] = bagging_logr

bagging_svc = BaggingClassifier(
    svc_model.best_estimator_, n_estimators=100, random_state=42
)
models["Bagging SVC"] = bagging_svc

bagging_dt = BaggingClassifier(
    dt_model.best_estimator_, n_estimators=100, random_state=42
)
models["Bagging DT"] = bagging_dt

sqrt_features = int(np.sqrt(x.shape[1])) + 1

rsm_logreg = BaggingClassifier(
    logr_model.best_estimator_,
    n_estimators=100,
    bootstrap=False,
    max_features=sqrt_features,
    random_state=42,
)
models["RSM LogReg"] = rsm_logreg

rsm_svc = BaggingClassifier(
    svc_model.best_estimator_,
    n_estimators=100,
    bootstrap=False,
    max_features=sqrt_features,
    random_state=42,
)
models["RSM SVC"] = rsm_svc

rsm_dt = BaggingClassifier(
    dt_model.best_estimator_,
    n_estimators=100,
    bootstrap=False,
    max_features=sqrt_features,
    random_state=42,
)
models["RSM DT"] = rsm_dt

# Both Bagging and RSM
bag_rsm_logreg = BaggingClassifier(
    logr_model.best_estimator_,
    n_estimators=100,
    bootstrap=True,
    max_features=sqrt_features,
    random_state=42,
)
models["BagRSM LogReg"] = bag_rsm_logreg


bag_rsm_svc = BaggingClassifier(
    svc_model.best_estimator_,
    n_estimators=100,
    bootstrap=True,
    max_features=sqrt_features,
    random_state=42,
)
models["BagRSM SVC"] = bag_rsm_svc

bag_rsm_dt = BaggingClassifier(
    dt_model.best_estimator_,
    n_estimators=100,
    bootstrap=True,
    max_features=sqrt_features,
    random_state=42,
)
models["BagRSM DT"] = bag_rsm_dt
In [71]:
for name, model in models.items():
    model.fit(x_train, y_train)
In [72]:
predictions = {}

for name, model in models.items():
    if name != "SVC":
        y_pred = model.predict_proba(x_test)[:, 1]
    else:
        y_pred = model.decision_function(x_test)
    predictions[name] = y_pred
In [73]:
boot_scores = {}

for name, y_pred in predictions.items():
    boot_score = bootstrap_metric(
        y_test.values,
        y_pred,
        metric_fn=lambda x, y: average_precision_score(y_true=x, y_score=y),
    )
    boot_scores[name] = boot_score
In [74]:
plt.figure(figsize=(16, 6))

base_models = ["Log-Reg", "SVC", "DT"] * 4
ensemble_types = ["Single"] * 3 + ["Bagged"] * 3 + ["RSM"] * 3 + ["BagRSM"] * 3

dfs = []
for i, model_name in enumerate(boot_scores):
    df = pd.DataFrame(boot_scores[model_name], columns=["pr_auc"])
    df["base_model"] = base_models[i]
    df["ensemble_method"] = ensemble_types[i]
    dfs.append(df)

sns.boxplot(data=pd.concat(dfs), y="pr_auc", x="base_model", hue="ensemble_method")
plt.xlabel("Base model", size=20)
plt.ylabel("PR-AUC", size=20)
plt.legend(fontsize=20, loc="lower right")
plt.tick_params(axis="both", which="major", labelsize=14)
plt.show()

Существенное улучшение качества наблюдается только при использовании решающего дерева в качестве базовой модели.

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

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

In [75]:
import itertools


def base_model_pair_correlation(ensemble, x):
    corrs = []
    for (i, est1), (j, est2) in itertools.combinations(
        enumerate(ensemble.estimators_), 2
    ):
        xi_test = x.values[:, ensemble.estimators_features_[i]]
        xj_test = x.values[:, ensemble.estimators_features_[j]]

        if not isinstance(est1, sklearn.svm.SVC):
            y_pred_t1 = est1.predict_proba(xi_test)[:, 1]

            y_pred_t2 = est2.predict_proba(xj_test)[:, 1]
        else:
            y_pred_t1 = est1.decision_function(xi_test)
            xj_test = x_test.values[:, ensemble.estimators_features_[j]]
            y_pred_t2 = est2.decision_function(xj_test)
        corrs.append(scipy.stats.pearsonr(y_pred_t1, y_pred_t2)[0])
    return np.array(corrs)
In [76]:
pair_correlations = {}
for name, model in models.items():
    if not "Bagging" in name and not "RSM" in name:
        continue
    pair_correlations[name] = base_model_pair_correlation(model, x_test)
In [77]:
cor_res = pd.DataFrame(pair_correlations)
cor_res = cor_res.melt(
    value_vars=cor_res.columns, value_name="paircor", var_name="model"
)


# get base models and ensembling methods from names
def read_base(s):
    if "dt" in s.lower():
        return "DT"
    elif "svc" in s.lower():
        return "SVC"
    else:
        return "Log-Reg"


def read_ensemble(s):
    bag, rsm = False, False
    if "bag" in s.lower():
        bag = True
    if "rsm" in s.lower():
        rsm = True
    if bag and rsm:
        return "BagRSM"
    if bag:
        return "Bagged"
    if rsm:
        return "RSM"
    return "Single"


cor_res["base_model"] = cor_res["model"].apply(read_base)
cor_res["ensemble_method"] = cor_res["model"].apply(read_ensemble)

Кроме того, посчитаем качество базовых моделей, входящих в каждый из ансамблей.

In [78]:
def base_model_prauc(ensemble, x, y):
    qual = np.zeros(ensemble.n_estimators)
    for ind, est in enumerate(ensemble.estimators_):
        x_test = x.values[:, ensemble.estimators_features_[i]]
        if not isinstance(est, sklearn.svm.SVC):
            y_pred = est.predict_proba(x_test)[:, 1]
        else:
            y_pred = est.decision_function(x_test)
        qual[ind] = average_precision_score(y_score=y_pred, y_true=y)
    return qual
In [79]:
base_prauc = {}
for name, model in models.items():
    if not "Bagging" in name and not "RSM" in name:
        continue
    base_prauc[name] = base_model_prauc(model, x_test, y_test)
In [80]:
base_prauc_res = pd.DataFrame(base_prauc)
base_prauc_res = base_prauc_res.melt(
    value_vars=base_prauc_res.columns, value_name="pr_auc", var_name="model"
)
base_prauc_res["base_model"] = base_prauc_res["model"].apply(read_base)
base_prauc_res["ensemble_method"] = base_prauc_res["model"].apply(read_ensemble)
In [81]:
plt.figure(figsize=(16, 12))
plt.subplot(211)
sns.boxplot(data=cor_res, y="paircor", x="base_model", hue="ensemble_method")
plt.title("Pairwise correlations in ensembles", size=25)
plt.xlabel("", size=20)
plt.ylabel("Pairwise correlation", size=20)
plt.legend(fontsize=20, loc="lower left")
plt.tick_params(axis="both", which="major", labelsize=14)

plt.subplot(212)
sns.boxplot(data=base_prauc_res, y="pr_auc", x="base_model", hue="ensemble_method")
plt.title("Base model quality", size=25)
plt.xlabel("", size=20)
plt.ylabel("PR-AUC", size=20)
plt.subplots_adjust()
plt.legend(fontsize=20, loc="lower left")
plt.tick_params(axis="both", which="major", labelsize=14)
plt.show()

Можно сделать следующие выводы:

  1. Bagging, RSM и их комбинация помогают уменьшать корреляцию между базовыми моделями. Причем, комбинация Bagging и RSM работают лучшее, чем каждый из них поодиночке;
  2. В случае просто Bagging дерево решений получает существенный прирост качества за счет того, что модели на его основе коррелированы значительно слабее, чем модели на основе логистической регрессии и метода опорных векторов;
  3. В остальных случаях корреляция моделей на основе деревьев решений все равно немного ниже, чем у других моделей, а качество базовых моделей сравнимо. Из-за этого ансамбль на основе деревьев решений опять показывает лучший результат.

Случайный лес¶

Из предыдущей части мы увидели, что использование bagging или RSM для SVM или подобных моделей не несет большого смысла.

И если использовать bagging или RSM для SVM, то значимого улучшения качества не будет.

Но для деревьев решений это не так. Будем брать деревья большой глубины. Незначительные изменения в данных приводят к значительным изменениям в топологии таких деревьев. Таким образом, мы приходим к случайному лесу = Bagging + RSM над деревом решений.

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

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

Зависимость качества случайного леса от числа деревьев¶

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

Проверим это на задаче регрессии California Housing dataset

In [82]:
calif_housing = sklearn.datasets.fetch_california_housing()
x = calif_housing.data
y = calif_housing.target
x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=42)

Данные — средние характеристики квартиры в доме. Цель — предсказать цену на дом.

Создадим несколько случайных лесов с различным количеством деревьев и простое дерево решений для сравнения с ним (baseline).

In [83]:
from sklearn.ensemble import RandomForestRegressor
from sklearn.tree import DecisionTreeRegressor

models_rf = {}

# add single decision tree for comparison
models_rf["DT"] = GridSearchCV(
    DecisionTreeRegressor(),
    {"max_depth": [1, 3, 5, 7, 10], "min_samples_leaf": [1, 3, 5, 10]},
)

# this can be done faster, see warm_start parameter for this
# (https://stackoverflow.com/questions/42757892/how-to-use-warm-start)
for n_estimators in [3, 5, 10, 50, 100, 150, 250]:
    models_rf[f"RF{n_estimators}"] = RandomForestRegressor(
        n_estimators=n_estimators, random_state=42, n_jobs=-1
    )  # run in parallel

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

In [84]:
from sklearn.metrics import mean_squared_error


def train_and_test_regressor(models, x_train, y_train, x_test, y_test, verb=True):
    boot_scores = {}
    for name, model in models.items():
        model.fit(x_train, y_train)  # train the model
        y_pred = model.predict(x_test)  # get predictions
        boot_scores[name] = bootstrap_metric(  # calculate bootstrap score
            y_test,
            y_pred,
            metric_fn=lambda x, y: mean_squared_error(y_true=x, y_pred=y),
        )
        if verb:
            print(f"Fitted {name} with bootstrap score {boot_scores[name].mean():.3f}")

    results = pd.DataFrame(boot_scores)
    # cast to long format https://pandas.pydata.org/docs/reference/api/pandas.melt.html
    results = results.melt(
        value_vars=results.columns, value_name="mse", var_name="model"
    )
    return results


results_rf = train_and_test_regressor(models_rf, x_train, y_train, x_test, y_test)
Fitted DT with bootstrap score 0.395
Fitted RF3 with bootstrap score 0.338
Fitted RF5 with bootstrap score 0.302
Fitted RF10 with bootstrap score 0.277
Fitted RF50 with bootstrap score 0.257
Fitted RF100 with bootstrap score 0.254
Fitted RF150 with bootstrap score 0.252
Fitted RF250 with bootstrap score 0.252

Выведем результат в виде box-plot

In [85]:
plt.figure(figsize=(16, 6))
sns.boxplot(data=results_rf, y="mse", x="model")
plt.ylabel("MSE", size=20)
plt.xlabel("Models", size=20)
plt.title("Number of estimators vs MSE", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.show()

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

Зависимость качества случайного леса от глубины дерева¶

Чем больше глубина дерева, тем большая нескорелированность базовых моделей будет получаться. В общем случае в случайном лесе важно использовать именно глубокие деревья, причем в большинстве случаев их глубину не надо ограничивать (или ограничивать большими значениями порядка 10-12).

Помимо этого, будем добавлять кроме случайного леса с заданной глубиной еще одиночное дерево такой же глубины.

In [86]:
dt_depth = {}
rf_depth = {}

for depth in range(1, 20, 2):
    dt_depth[depth] = DecisionTreeRegressor(max_depth=depth, random_state=42)

    rf_depth[depth] = RandomForestRegressor(
        n_estimators=100, max_depth=depth, random_state=42, n_jobs=-1
    )  # run in parallel

dt_res = train_and_test_regressor(
    dt_depth, x_train, y_train, x_test, y_test, verb=False
)
rf_res = train_and_test_regressor(
    rf_depth, x_train, y_train, x_test, y_test, verb=False
)

dt_res = dt_res.rename(columns={"model": "tree_depth"})
rf_res = rf_res.rename(columns={"model": "tree_depth"})
dt_res["model"] = "DT"
rf_res["model"] = "RF"
depth_res = pd.concat((dt_res, rf_res))
In [87]:
plt.figure(figsize=(12, 8))
sns.boxplot(data=depth_res, x="tree_depth", y="mse", hue="model")
plt.xlabel("Tree depth", size=20)
plt.ylabel("MSE", size=20)
plt.title("Tree depth vs MSE", size=20)
plt.legend(fontsize=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.show()

Лучшие результаты показывает случайный лес из наиболее глубоких деревьев.

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

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

Минимальное число объектов в листе¶

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

In [88]:
dt_models_min_samples = {}
rf_models_min_samples = {}

for mn_sm in [1, 3, 5, 7, 10]:
    dt_models_min_samples[mn_sm] = DecisionTreeRegressor(
        max_depth=None, min_samples_leaf=mn_sm, random_state=42
    )

    rf_models_min_samples[mn_sm] = RandomForestRegressor(
        n_estimators=100,
        max_depth=None,
        min_samples_leaf=mn_sm,
        random_state=42,
        n_jobs=-1,
    )  # run in parallel

dt_results_mn_samples = train_and_test_regressor(
    dt_models_min_samples, x_train, y_train, x_test, y_test
)
rf_results_mn_samples = train_and_test_regressor(
    rf_models_min_samples, x_train, y_train, x_test, y_test
)

dt_results_mn_samples = dt_results_mn_samples.rename(columns={"model": "min_samples"})
rf_results_mn_samples = rf_results_mn_samples.rename(columns={"model": "min_samples"})
dt_results_mn_samples["model"] = "DT"
rf_results_mn_samples["model"] = "RF"
leaf_res = pd.concat((dt_results_mn_samples, rf_results_mn_samples))
Fitted 1 with bootstrap score 0.528
Fitted 3 with bootstrap score 0.446
Fitted 5 with bootstrap score 0.428
Fitted 7 with bootstrap score 0.405
Fitted 10 with bootstrap score 0.388
Fitted 1 with bootstrap score 0.254
Fitted 3 with bootstrap score 0.256
Fitted 5 with bootstrap score 0.260
Fitted 7 with bootstrap score 0.266
Fitted 10 with bootstrap score 0.274
In [89]:
plt.figure(figsize=(12, 8))
sns.boxplot(data=leaf_res, x="min_samples", y="mse", hue="model")
plt.xlabel("Min samples in leaf", size=20)
plt.ylabel("MSE", size=20)
plt.title("Min samples in leaf vs MSE", size=20)
plt.legend(fontsize=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.show()

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

Переобучается ли случайный лес?¶

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

Возьмем, к примеру, наш игрушечный пример на плоскости.

In [90]:
from sklearn.ensemble import RandomForestClassifier

x, y = sklearn.datasets.make_moons(n_samples=500, noise=0.30, random_state=42)
x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=42)
plt.figure(figsize=(16, 6))
plt.subplot(121)

clf = DecisionTreeClassifier(max_depth=10, random_state=42)
clf.fit(x_train, y_train)
plot_decision_boundary(clf, x, y)
plt.title("Single Decision Tree", fontsize=14)

plt.subplot(122)
rf = RandomForestClassifier(n_estimators=1000)
rf.fit(x_train, y_train)
plot_decision_boundary(rf, x, y)
plt.title("Random forest", fontsize=14)
plt.show()

Видны области, в которых случайный лес переобучился.

Добавление ограничения на число объектов в листьях улучшает ситуацию.

In [91]:
plt.figure(figsize=(16, 6))

plt.subplot(121)
rf1 = RandomForestClassifier(n_estimators=1000)
rf1.fit(x_train, y_train)
plot_decision_boundary(rf1, x, y)
plt.title("Random forest", fontsize=14)

plt.subplot(122)
rf2 = RandomForestClassifier(n_estimators=1000, min_samples_leaf=5)
rf2.fit(x_train, y_train)
plot_decision_boundary(rf2, x, y)
plt.title("Random forest, min_samples_leaf=5", fontsize=14)
plt.show()

Лес без ограничения на минимальное число объектов в листе переобучился — достиг максимального качества на обучающей выборке, но на тестовой имеет качество ниже, чем лес с ограничением.

In [92]:
y_score = rf1.predict(x_train)
q = average_precision_score(y_true=y_train, y_score=y_score)
print(f"RF1 Train: {q:.02}")
y_score = rf1.predict(x_test)
q = average_precision_score(y_true=y_test, y_score=y_score)
print(f"RF1 Test: {q:.02}")

y_score = rf2.predict(x_train)
q = average_precision_score(y_true=y_train, y_score=y_score)
print(f"RF2 Train: {q:.02}")
y_score = rf2.predict(x_test)
q = average_precision_score(y_true=y_test, y_score=y_score)
print(f"RF2 Test: {q:.02}")
RF1 Train: 1.0
RF1 Test: 0.87
RF2 Train: 0.91
RF2 Test: 0.91

Валидиация случайного леса на Out-Of-Bag (OOB) объектах¶

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

За это отвечает параметр oob_score (по умолчанию False)

In [93]:
from sklearn.model_selection import cross_validate

rf_oob = RandomForestClassifier(n_estimators=1000, min_samples_leaf=5, oob_score=True)
rf_oob.fit(x, y)
print(f"RF OOB score: {rf_oob.oob_score_:.02}")

scores = cross_validate(rf_oob, x, y, cv=5)
print(f"RF CV score: {scores['test_score'].mean():.02}")

rf_oob.fit(x_train, y_train)
print(f"RF Test set score: {rf_oob.score(x_test, y_test):.02}")
RF OOB score: 0.92
RF CV score: 0.91
RF Test set score: 0.92

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

Случайный лес и bias-variance tradeoff¶

Случайный лес — инструмент для уменьшения variance нашей модели. Можно показать, что при стремлении числа моделей в ансамбле к бесконечности, а их коррелированности — к 0, variance ансамбля стремится к нулю. Однако при этом bias ансамбля будет равен bias базовой модели.

Продемонстрируем это:

In [94]:
plt.figure(figsize=(16, 8))

# Decision trees
for i, max_depth in enumerate([1, 3, 5, 12]):
    plt.subplot(241 + i)
    dt = DecisionTreeClassifier(max_depth=max_depth)
    dt.fit(x_train, y_train)
    plot_decision_boundary(dt, x, y, alpha=0.5, bolded=True)
    plt.xticks([], [])
    plt.yticks([], [])
    plt.title(f"Decision tree, max_depth={max_depth}", fontsize=14)
    plt.xlabel("")
    plt.ylabel("")
    plt.xticks([], [])
    plt.yticks([], [])

# Random forests
for i, max_depth in enumerate([1, 3, 5, 12]):
    plt.subplot(245 + i)
    rf = RandomForestClassifier(max_depth=max_depth, n_estimators=500, n_jobs=-1)
    rf.fit(x_train, y_train)
    plot_decision_boundary(rf, x, y, alpha=0.5, bolded=True)
    plt.title(f"Random forest, max_depth={max_depth}", fontsize=14)
    plt.xlabel("")
    plt.ylabel("")
    plt.xticks([], [])
    plt.yticks([], [])

Видим, что общая сложность решающей границы меняется не сильно: bias случайного леса по сравнению с bias дерева решений не меняется. А вот гладкость границы увеличивается, тем самым, уменьшается variance

Boosting¶

Бустинг — это еще один способ ансамблирования моделей. В отличие от случайного леса, где каждая модель в ансамбле строится независимо, в бустинге построение очередной модели зависит от уже состоящих в ансамбле. Каждая следующая модель в ансамбле стремится улучшить предсказание всего ансамбля.

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

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

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

Gradient boosting (градиентный бустинг)¶

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

Цель $i$-той модели в ансамбле — скорректировать ошибки предыдущих ${i-1}$ моделей. В результате, когда мы суммируем вклады всех моделей ансамбля, получается хорошее предсказание.

Демонстрация понимания интуиции градиентного бустинга

Давайте формализуем, что нам нужно, чтобы обучить алгоритм градиентного бустинга:

  • набор данных $\left\{\left(x_i, y_i\right)\right\}_{i=1, \ldots, n}$
  • число итераций $M$ (оно же количество моделей)
  • выбор дифференцируемой функции потерь $L(y, f)$
  • выбор семейства функций базовых алгоритмов $h(x, \theta)$ с процедурой их обучения.

Минимизировать ошибку будем с помощью градиентного спуска.

Рзаберем пошагово:

Начинаем с первого предсказания, можем выбрать на самом деле любое число, например среднее значение

$$ \large \hat{f}(x)=\hat{f}_0, \hat{f}_0=\gamma, \gamma \in \mathbb{R} \tag{1}$$

Либо подобрать с наименьшей ошибкой

$$\large \hat{f}_0=\underset{\gamma}{\arg \min } \sum_{i=1}^n L\left(y_i, \gamma\right) $$

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

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

$$\large r_{i t}=-\frac{\partial L\left(y_i, f\left(x_i\right)\right)}{\partial f\left(x_i\right)} \quad \tag{2}$$

Строим новый базовый алгоритм $h_t(x)$, который обучается на то, чтобы уменьшить ошибку от текущего состояния ансамбля, и в качестве целевой переменной для него берем антиградиент функции потерь $\left\{\left(x_i, r_{i t}\right)\right\}_{i=1, \ldots, n}$

$$\large\underset{\theta}{\arg \min } \sum_{i=1}^n L\left(h(x_i, \theta), r_{it}\right) \tag{3}$$

Подбираем оптимальный параметр $\large \rho$. Этот параметр будет разным у каждого дерева в нашем ансамбле, в отличие от learning rate:

$$\large \rho_t=\underset{\rho}{\arg \min } \sum_{i=1}^n L\left(y_i, \hat{f}\left(x_i\right)+\rho \cdot h\left(x_i, \theta\right)\right) \tag{4}$$

Сдвигаем предсказание в сторону уменьшения ошибки, где $\lambda$ — это наш learning rate:

$$\large \hat{f}_t(x)= \lambda \cdot \rho_t \cdot h_t(x) \tag{5}$$

Обновляем текущее приближение $\hat{f}(x)$: $$ \large \hat{f}(x) \leftarrow \hat{f}(x)+\hat{f}_t(x)=\sum_{i=0}^t \hat{f}_i(x) \tag{6} $$

Далее повторяем шаги 2–6, пока не получим требуемое качество, и собираем итоговый ансамбль $\hat{f}(x)$:

$$ \large \hat{f}(x)=\sum_{i=0}^M \hat{f}_i(x) $$

Подробнее о градиентном бустинге

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

In [95]:
calif_housing = sklearn.datasets.fetch_california_housing()
x = calif_housing.data
y = calif_housing.target
x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=42)

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

In [96]:
from sklearn.metrics import mean_squared_error


def train_and_test_regressor(models, x_train, y_train, x_test, y_test):
    for name, model in models.items():
        print(f"Fitting {name}")
        model.fit(x_train, y_train)
    predictions = {}
    for name, model in models.items():
        y_pred = model.predict(x_test)
        predictions[name] = y_pred

    boot_scores = {}

    for name, y_pred in predictions.items():
        print(f"Calculating bootstrap score for {name}")
        boot_score = bootstrap_metric(
            y_test,
            y_pred,
            metric_fn=lambda x, y: mean_squared_error(y_true=x, y_pred=y),
        )
        boot_scores[name] = boot_score

    results = pd.DataFrame(boot_scores)
    # cast to long format
    results = results.melt(
        value_vars=results.columns, value_name="mse", var_name="model"
    )
    return results
In [97]:
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

models = {}

# make pipeline for normalization
models["LinReg"] = make_pipeline(StandardScaler(with_mean=False), LinearRegression())

models["RF"] = RandomForestRegressor(
    n_estimators=250,  # for better result set to 1000
    max_depth=None,
    min_samples_leaf=1,
    n_jobs=-1,
    random_state=42,
)

models["GradientBoosting"] = GradientBoostingRegressor(
    learning_rate=0.1,  # for better result set to 0.05
    n_estimators=250,  # for better result set to 1000
    random_state=42,
)

results_boost = train_and_test_regressor(models, x_train, y_train, x_test, y_test)
Fitting LinReg
Fitting RF
Fitting GradientBoosting
Calculating bootstrap score for LinReg
Calculating bootstrap score for RF
Calculating bootstrap score for GradientBoosting
In [98]:
plt.figure(figsize=(12, 4))
ax = sns.boxplot(data=results_boost, y="mse", x="model")
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("LR vs RF vs GB", size=25)
plt.xticks(size=20)
plt.show()

Видим, что сразу мы побили случайный лес и линейную регрессию.

Переобучение¶

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

In [99]:
gbtree = GradientBoostingRegressor(
    n_estimators=300, learning_rate=1.0  # faster learning rate to force ovefitting
)
gbtree.fit(x_train, y_train)
Out[99]:
GradientBoostingRegressor(learning_rate=1.0, n_estimators=300)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
GradientBoostingRegressor(learning_rate=1.0, n_estimators=300)

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

In [100]:
error_train = []
error_test = []
for it, (y_train_pred, y_test_pred) in enumerate(
    zip(gbtree.staged_predict(x_train), gbtree.staged_predict(x_test))
):
    ertr = mean_squared_error(y_true=y_train, y_pred=y_train_pred)
    error_train.append(ertr)
    erte = mean_squared_error(y_true=y_test, y_pred=y_test_pred)
    error_test.append(erte)


plt.figure(figsize=(10, 5))
plt.plot(error_train, label="train", c="#2DA9E1", linewidth=4)
plt.plot(error_test, label="test", c="#4AAE4D", linewidth=4)
plt.xlabel("n_estimators", size=20)
plt.ylabel("mse", size=20)
plt.xticks(fontsize=14)
plt.yticks(fontsize=14)
plt.legend(fontsize=20)
plt.show()

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

Давайте об этом learning rate и поговорим.

Shrinkage (learning rate)¶

Как у градиентного спуска есть learning rate, который определяет силу каждого нашего следующего шага, так и у градиентного бустинга есть параметр, который называется shrinkage или learning rate — это дополнительный параметр, на который мы домножаем вес, с которым мы добавляем новые модели в ансамбль.

Мы не хотим сильно скакать по пространству решений, мы хотим спускаться медленно и сойтись к какому-то хорошему минимуму. Потому мы берем и вес каждой модели, которую мы рассчитываем по специальному алгоритму, еще домножаем на маленький коэффициент, который называется shrinkage или learning rate. Фактически, мы берем и умножаем градиент на некий альфа (это то же, что было в градиентном спуске, ничего нового мы не добавили).

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

Как и в случае с градиентным спуском, learning rate влияет не только на то, как быстро мы станем переобучаться, но и на глубину минимума, который мы найдем.

In [101]:
# here anb below in the cell can be set to 1000 for better visualization

gbtrees_list = []

for lr in [1, 0.5, 0.1, 0.05, 0.01]:
    gbtree = GradientBoostingRegressor(n_estimators=500, learning_rate=lr)
    gbtree.fit(x_train, y_train)
    gbtrees_list.append(gbtree)
In [102]:
lr = []
step = []
mse = []
for gb_tree in gbtrees_list:
    for it, y_test_pred in enumerate(gb_tree.staged_predict(x_test)):
        erte = mean_squared_error(y_true=y_test, y_pred=y_test_pred)
        mse.append(erte)
        lr.append(str(gb_tree.learning_rate))
        step.append(it)

df = pd.DataFrame({"learning_rate": lr, "n_estimators": step, "mse": mse})

plt.figure(figsize=(16, 6))
sns.lineplot(data=df, x="n_estimators", y="mse", hue="learning_rate", lw=3)
plt.xlabel("n_estimators", size=20)
plt.ylabel("mse", size=20)
plt.xticks(fontsize=14)
plt.yticks(fontsize=14)
plt.ylim((0.2, 0.6))
plt.legend(fontsize=20)
plt.show()

В данном случае мы видим, что значение learning rate, установленное по умолчанию, позволяет получить наименьшую ошибку на валидации. Слишком большие значения learning rate (1 и 0.5) приводят к тому, что мы не достигаем таких глубоких минимумов и начинаем переобучаться. Слишком малое значение learning rate может привести к тому, что нам понадобится очень большое число деревьев, чтобы достигнуть минимума (если мы его вообще достигнем).

Число деревьев в ансамбле¶

Число деревьев, как было видно из предыдущего графика, зависит от установленного learning rate. Потому обычно ставят learning_rate равным 0.1 и подбирают оптимальное значение числа деревьев в ансамбле. Делают это на кросс-валидации, но для экономии времени просто дополнительно разобьем train датасет:

In [103]:
x_learn, x_valid, y_learn, y_valid = train_test_split(x_train, y_train, random_state=42)
In [104]:
gbtree = GradientBoostingRegressor(n_estimators=1000, learning_rate=0.1)
gbtree.fit(x_learn, y_learn)

error_train = []
error_test = []
for it, (y_learn_pred, y_valid_pred) in enumerate(
    zip(gbtree.staged_predict(x_learn), gbtree.staged_predict(x_valid))
):
    ertr = mean_squared_error(y_true=y_learn, y_pred=y_learn_pred)
    error_train.append(ertr)
    erte = mean_squared_error(y_true=y_valid, y_pred=y_valid_pred)
    error_test.append(erte)

plt.figure(figsize=(10, 5))
plt.plot(error_train, label="train", c="#2DA9E1", linewidth=4)
plt.plot(error_test, label="test", c="#4AAE4D", linewidth=4)
plt.xlabel("n_estimators", size=20)
plt.ylabel("mse", size=20)
plt.xticks(fontsize=14)
plt.yticks(fontsize=14)
plt.legend(fontsize=20)
plt.show()

Видим, что ошибка продолжает медленно уменьшаться, но в принципе увеличение идет очень маленькое, потому в целях демонстрации выберем число деревьев равным 500.

Глубина деревьев в градиентном бустинге¶

В случае бустинга нам нужны слабые модели. Поэтому очень глубокие деревья в бустинге встречаются редко — бустинг с ними проиграет по качеству. Обычно глубина дерева выбирается вместе с минимальным числом объектов в листе min_samples_leaf или весом листа min_weight_fraction_leaf, так как эти параметры взаимосвязаны и вместе влияют на сложность полученных деревьев. Здесь же в целях демонстрации мы подберем сначала глубину дерева, а потом минимальное число объектов.

In [105]:
models = {}
for depth in (1, 2, 3, 5, 10):
    models[depth] = GradientBoostingRegressor(
        n_estimators=500, learning_rate=0.1, max_depth=depth, random_state=42
    )

depth_boost = train_and_test_regressor(models, x_learn, y_learn, x_valid, y_valid)
Fitting 1
Fitting 2
Fitting 3
Fitting 5
Fitting 10
Calculating bootstrap score for 1
Calculating bootstrap score for 2
Calculating bootstrap score for 3
Calculating bootstrap score for 5
Calculating bootstrap score for 10
In [106]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(data=depth_boost, y="mse", x="model")
plt.xlabel("GB depth", size=20)
plt.ylabel("MSE", size=20)
plt.title("GB depth vs MSE", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.show()

Искомая глубина находится между 3 и 10. Добавим на график точки 4, 6 и 7.

In [107]:
models_add = {}
for depth in (4, 6, 7):
    models_add[depth] = GradientBoostingRegressor(
        n_estimators=500, learning_rate=0.1, max_depth=depth, random_state=42
    )

depth_boost_add = train_and_test_regressor(
    models_add, x_learn, y_learn, x_valid, y_valid
)
Fitting 4
Fitting 6
Fitting 7
Calculating bootstrap score for 4
Calculating bootstrap score for 6
Calculating bootstrap score for 7
In [108]:
depth_boost_joined = pd.concat([depth_boost, depth_boost_add])

plt.figure(figsize=(16, 6))
ax = sns.boxplot(
    data=depth_boost_joined, y="mse", x="model", order=[1, 2, 3, 4, 5, 6, 7, 10]
)
plt.xlabel("GB depth", size=20)
plt.ylabel("MSE", size=20)
plt.title("GB depth vs MSE", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.show()

Значит, оптимальная глубина — 5.

Минимальное число объектов в листе¶

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

In [109]:
models = {}
for min_samples_leaf in (1, 3, 5, 7, 9, 11):
    models[min_samples_leaf] = GradientBoostingRegressor(
        n_estimators=500,
        learning_rate=0.1,
        max_depth=5,
        min_samples_leaf=min_samples_leaf,
        random_state=42,
    )

mns_boost = train_and_test_regressor(models, x_learn, y_learn, x_valid, y_valid)
Fitting 1
Fitting 3
Fitting 5
Fitting 7
Fitting 9
Fitting 11
Calculating bootstrap score for 1
Calculating bootstrap score for 3
Calculating bootstrap score for 5
Calculating bootstrap score for 7
Calculating bootstrap score for 9
Calculating bootstrap score for 11
In [110]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(data=mns_boost, y="mse", x="model")
plt.xlabel("Min samples in leaf", size=20)
plt.ylabel("MSE", size=20)
plt.title("GB min samples leaf vs MSE", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.show()

Поставим число объектов в листе равное 9.

Параметры subsampling и max_features¶

Можно каждому дереву в ансамбле давать только часть объектов из выборки — получим стохастический градиентный бустинг. За это отвечает параметр subsample.

Аналогично, можно давать каждому дереву в ансамбле лишь часть признаков — max_features.

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

Кроме того, мы можем каждое дерево еще немножко рандомизировать. Можем давать ему не всю выборку, не все признаки. Это работает не всегда, гиперпараметры, контролирующие это поведение, нужно подбирать.

Понижение learning rate¶

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

Опять же, этот этап пропустим.

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

In [111]:
gbtree = GradientBoostingRegressor(
    n_estimators=1000,
    max_depth=5,
    min_samples_leaf=9,
    learning_rate=0.1,
    random_state=42,
)
gbtree.fit(x_learn, y_learn)

error_train = []
error_test = []
for it, (y_learn_pred, y_valid_pred) in enumerate(
    zip(gbtree.staged_predict(x_learn), gbtree.staged_predict(x_valid))
):
    ertr = mean_squared_error(y_true=y_learn, y_pred=y_learn_pred)
    error_train.append(ertr)
    erte = mean_squared_error(y_true=y_valid, y_pred=y_valid_pred)
    error_test.append(erte)

plt.figure(figsize=(10, 5))
plt.plot(error_train, label="train", c="#2DA9E1", linewidth=4)
plt.plot(error_test, label="test", c="#4AAE4D", linewidth=4)
plt.xlabel("n_estimators", size=20)
plt.ylabel("mse", size=20)
plt.xticks(fontsize=14)
plt.yticks(fontsize=14)
plt.legend(fontsize=20)
plt.show()

Возьмем 500 деревьев.

In [112]:
models = {}

models["LinReg"] = make_pipeline(StandardScaler(with_mean=False), LinearRegression())

models["RF"] = RandomForestRegressor(
    n_estimators=250, max_depth=None, min_samples_leaf=1, n_jobs=-1, random_state=42
)

models["GBR"] = GradientBoostingRegressor(
    learning_rate=0.1, n_estimators=250, random_state=42
)

models["GBR tuned"] = GradientBoostingRegressor(
    learning_rate=0.1,
    n_estimators=500,
    max_depth=5,
    min_samples_leaf=9,
    random_state=42,
)

tuned_boost = train_and_test_regressor(models, x_train, y_train, x_test, y_test)
Fitting LinReg
Fitting RF
Fitting GBR
Fitting GBR tuned
Calculating bootstrap score for LinReg
Calculating bootstrap score for RF
Calculating bootstrap score for GBR
Calculating bootstrap score for GBR tuned
In [113]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(data=tuned_boost, y="mse", x="model")
plt.setp(ax.get_xticklabels(), rotation=90)
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("Boosting and others", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.xticks(fontsize=20)
plt.show()

Видим, что мы дополнительно уменьшили ошибку нашего метода

Градиентный бустинг и bias-variance tradeoff¶

В то время как случайный лес только уменьшал variance модели, бустинг стремится уменьшить и bias, и variance.

In [114]:
from sklearn.ensemble import GradientBoostingClassifier

x, y = sklearn.datasets.make_moons(n_samples=500, noise=0.30, random_state=42)
x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=42)

plt.figure(figsize=(16, 12))

# Decision trees
for i, max_depth in enumerate([1, 3, 5, 12]):
    plt.subplot(3, 4, 1 + i)
    dt = DecisionTreeClassifier(max_depth=max_depth)
    dt.fit(x_train, y_train)
    plot_decision_boundary(dt, x, y, alpha=0.4, bolded=True)
    plt.xticks([], [])
    plt.yticks([], [])
    plt.title(f"Decision tree, max_depth={max_depth}", fontsize=13)
    plt.xlabel("")
    plt.ylabel("")

# Random forests
for i, max_depth in enumerate([1, 3, 5, 12]):
    plt.subplot(3, 4, 5 + i)
    rf = RandomForestClassifier(max_depth=max_depth, n_estimators=500, n_jobs=-1)
    rf.fit(x_train, y_train)
    plot_decision_boundary(rf, x, y, alpha=0.4, bolded=True)
    plt.title(f"Random forest, max_depth={max_depth}", fontsize=13)
    plt.xlabel("")
    plt.ylabel("")
    plt.xticks([], [])
    plt.yticks([], [])

# Gradient boostings
for i, max_depth in enumerate([1, 3, 5, 12]):
    plt.subplot(3, 4, 9 + i)
    boost = GradientBoostingClassifier(max_depth=max_depth, n_estimators=250)
    boost.fit(x_train, y_train)
    plot_decision_boundary(boost, x, y, alpha=0.4, bolded=True)
    plt.title(f"Gradient boosting, max_depth={max_depth}", fontsize=13)
    plt.xlabel("")
    plt.ylabel("")
    plt.xticks([], [])
    plt.yticks([], [])

Модификации градиентного бустинга¶

Есть много модификаций градиентного бустинга. В отличие от реализации в sklearn, большая часть из них умеет параллелиться на CPU или даже на GPU.

Поэтому при работе с реальными данными использовать градиентный бустинг из sklearn не стоит. XGBoost и/или LigthGBM дадут результат как правило лучше и быстрее.

XGBoost¶

Мы хотим спускаться медленно и не хотим, чтобы модели по пути были сложными. Пусть минимально отличающиеся от случайного, но при этом не очень сложными.

Поэтому XGBoost вводит специальный штраф за сложные деревья (большей глубины, чем 2 — 3). За счет того, что в градиентном бустинге можно минимизировать любую дифференцируемую функцию ошибок, мы просто добавляем штраф напрямую в функцию ошибок исходного градиентного бустинга.

In [115]:
calif_housing = sklearn.datasets.fetch_california_housing()
x = calif_housing.data
y = calif_housing.target
x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=42)

Скопируем параметры, которые совпадают между градиентным бустингом и XGBoost. Посмотрим, вдруг получится еще улучшить качество.

In [116]:
import xgboost

models_add = {}
models_add["xgb"] = xgboost.XGBRegressor(
    n_estimators=500,
    learning_rate=0.1,
    max_depth=5,
    random_state=42,
    min_child_weight=9,  # not exact analogue for min_samples_leaf
    n_jobs=-1,  # can be constructed in parrallel, much!!! faster)
    objective="reg:squarederror",
)

xgb_add = train_and_test_regressor(models_add, x_train, y_train, x_test, y_test)
Fitting xgb
Calculating bootstrap score for xgb
In [117]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(data=pd.concat([tuned_boost, xgb_add]), y="mse", x="model")
plt.setp(ax.get_xticklabels(), rotation=90)
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("Boosting and others", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.xticks(size=20)
plt.show()

Пусть и незначимо, но XGBoost лучше GBR. И, главное — в разы быстрее.

Параметр min_child_weight¶

XGBoost немного иначе определяет важность листа: не сколько именно объектов попало в лист, а насколько объекты в листе имеют сильно разные предсказания. Из-за этого способа определения min_child_weight может принимать нецелые значения, в том числе меньше 1 (к примеру, в случае задачи классификации).

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

In [118]:
models = {}
for min_child_weight in (1, 2, 3, 5, 7, 9, 11, 13, 15):
    models[f"xGB_mnw{min_child_weight}"] = xgboost.XGBRegressor(
        n_estimators=2000,
        learning_rate=0.1,
        max_depth=5,
        random_state=42,
        min_child_weight=min_child_weight,
        n_jobs=-1,
        objective="reg:squarederror",
    )

xgb_mw = train_and_test_regressor(models, x_train, y_train, x_test, y_test)
Fitting xGB_mnw1
Fitting xGB_mnw2
Fitting xGB_mnw3
Fitting xGB_mnw5
Fitting xGB_mnw7
Fitting xGB_mnw9
Fitting xGB_mnw11
Fitting xGB_mnw13
Fitting xGB_mnw15
Calculating bootstrap score for xGB_mnw1
Calculating bootstrap score for xGB_mnw2
Calculating bootstrap score for xGB_mnw3
Calculating bootstrap score for xGB_mnw5
Calculating bootstrap score for xGB_mnw7
Calculating bootstrap score for xGB_mnw9
Calculating bootstrap score for xGB_mnw11
Calculating bootstrap score for xGB_mnw13
Calculating bootstrap score for xGB_mnw15
In [119]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(data=xgb_mw, y="mse", x="model")
plt.setp(ax.get_xticklabels(), rotation=90)
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("XGB min_child_weight", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.xticks(size=20)
plt.show()

Возьмем 9 (можно было бы подобрать чуть точнее).

In [120]:
models_add2 = {}
models_add2["xgb_mcw"] = xgboost.XGBRegressor(
    n_estimators=2000,
    learning_rate=0.1,
    max_depth=5,
    random_state=42,
    min_child_weight=9,
    n_jobs=-1,
    objective="reg:squarederror",
)

xgb_add2 = train_and_test_regressor(models_add2, x_train, y_train, x_test, y_test)
Fitting xgb_mcw
Calculating bootstrap score for xgb_mcw
In [121]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(data=pd.concat([tuned_boost, xgb_add, xgb_add2]), y="mse", x="model")
plt.setp(ax.get_xticklabels(), rotation=90)
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("Boosting and others", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.xticks(size=20)
plt.show()

Почти ничего не изменилось.

LightGBM¶

Он был изначально разработан для того, чтобы работать очень быстро. В него добавили много ухищрений, связанных с этим. Кроме этого, LightGBM по умолчанию строит дерево немного иначе, нежели XGBoost.

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

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

По умолчанию он еще быстрее (хотя в XGBoost тоже есть опции для ускорения).

Из-за особенностей построения им деревьев надо задавать не высоту дерева, а максимальное число листьев. Поставим пока так, чтобы число листьев было равно числу листьев в дереве высоты 6.

In [122]:
import lightgbm

models_add3 = {}
models_add3["lightgbm"] = lightgbm.LGBMRegressor(
    n_estimators=2000,  # can use more estimators due to SPEEEEEED
    learning_rate=0.1,
    max_depth=-1,
    num_leaves=2**5,
    random_state=42,
    min_child_weight=9,
    n_jobs=-1,
)

lgb_add = train_and_test_regressor(models_add3, x_train, y_train, x_test, y_test)
Fitting lightgbm
Calculating bootstrap score for lightgbm
In [123]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(
    data=pd.concat([tuned_boost, xgb_add, xgb_add2, lgb_add]), y="mse", x="model"
)
plt.setp(ax.get_xticklabels(), rotation=90)
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("Boosting and others", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.xticks(size=20)
plt.show()

На уровне с XGBoost. Зато быстрее.

Максимальное число листьев в дереве¶

Попробуем все же отдельно подобрать ограничение на число листьев. По словам создателей, оно не должно быть больше числа листьев в соответствующем дереве глубины 5, то есть 32.

In [124]:
models = {}
for num_leaves in (8, 16, 24, 32, 40):
    models[f"LGB_lvn{num_leaves}"] = lightgbm.LGBMRegressor(
        n_estimators=2000,
        learning_rate=0.1,
        max_depth=-1,
        num_leaves=num_leaves,
        random_state=42,
        min_child_weight=10,
        n_jobs=-1,
    )

lgb_nl = train_and_test_regressor(models, x_learn, y_learn, x_valid, y_valid)
Fitting LGB_lvn8
Fitting LGB_lvn16
Fitting LGB_lvn24
Fitting LGB_lvn32
Fitting LGB_lvn40
Calculating bootstrap score for LGB_lvn8
Calculating bootstrap score for LGB_lvn16
Calculating bootstrap score for LGB_lvn24
Calculating bootstrap score for LGB_lvn32
Calculating bootstrap score for LGB_lvn40
In [125]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(data=lgb_nl, y="mse", x="model")
plt.setp(ax.get_xticklabels(), rotation=90)
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("LGB num_leaves", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.xticks(size=20)
plt.show()

Исходя из этого графика, оптимальное число листьев находится в районе 8.

In [126]:
models = {}
for num_leaves in (8, 12, 16, 20):
    models[f"LGB_nl{num_leaves}"] = lightgbm.LGBMRegressor(
        n_estimators=2000,
        learning_rate=0.1,
        max_depth=-1,
        num_leaves=num_leaves,
        random_state=42,
        min_child_weight=10,
        n_jobs=-1,
    )

lgb_nl = train_and_test_regressor(models, x_learn, y_learn, x_valid, y_valid)
Fitting LGB_nl8
Fitting LGB_nl12
Fitting LGB_nl16
Fitting LGB_nl20
Calculating bootstrap score for LGB_nl8
Calculating bootstrap score for LGB_nl12
Calculating bootstrap score for LGB_nl16
Calculating bootstrap score for LGB_nl20
In [127]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(data=lgb_nl, y="mse", x="model")
plt.setp(ax.get_xticklabels(), rotation=90)
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("LGB num_leaves", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.xticks(size=20)
plt.show()

Выберем 12 листьев

In [128]:
models_add4 = {}
models_add4["lightgbm lv12"] = lightgbm.LGBMRegressor(
    n_estimators=2000,
    learning_rate=0.1,
    max_depth=-1,
    num_leaves=12,
    random_state=42,
    min_child_weight=9,
    n_jobs=-1,
)

lgb_add2 = train_and_test_regressor(models_add4, x_train, y_train, x_test, y_test)
Fitting lightgbm lv12
Calculating bootstrap score for lightgbm lv12
In [129]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(
    data=pd.concat([tuned_boost, xgb_add, xgb_add2, lgb_add, lgb_add2]),
    y="mse",
    x="model",
)
plt.setp(ax.get_xticklabels(), rotation=90)
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("Boosting and others", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.xticks(size=20)
plt.show()

Качество почти не изменилось

CatBoost¶

Разработал Яндекс.

CatBoost:

  1. Хорошо умеет работать с категориальными признаками. Если у вас много категориальных признаков, он может дать существенный выигрыш.
  2. По умолчанию использует в качестве модели модификацию обычного дерева решения — Symmetric Tree, которое менее склонно к переобучению.
In [130]:
!pip install -q catboost
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 98.6/98.6 MB 10.1 MB/s eta 0:00:00
In [131]:
import catboost

models_add4 = {}
models_add4["catboost"] = catboost.CatBoostRegressor(
    iterations=2000,
    learning_rate=0.1,
    depth=5,
    random_state=42,
    min_data_in_leaf=9,
    verbose=0,
)
# task_type="GPU") # can use gpu, but no parallel-cpu option

cat_add = train_and_test_regressor(models_add4, x_train, y_train, x_test, y_test)
Fitting catboost
Calculating bootstrap score for catboost
In [132]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(
    data=pd.concat([tuned_boost, xgb_add, xgb_add2, lgb_add, cat_add]),
    y="mse",
    x="model",
)
plt.setp(ax.get_xticklabels(), rotation=90)
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("Boosting and others", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.xticks(size=20)
plt.show()

Стало лучше

Про другие реализации случайного леса¶

В XGBoost и LightGBM есть свои, более быстрые и иногда более эффективные реализации случайного леса. Но их надо дополнительно настраивать.

In [133]:
models_rf = {}

models_rf["xgb_rf"] = xgboost.XGBRFRegressor(
    n_estimators=250,
    colsample_bytree=0.8,
    subsample=0.8,
    reg_lambda=0.001,  # to get deeper, less regularized trees
    max_depth=20,  # trees must be deep
    n_jobs=-1,
    objective="reg:squarederror",
)

models_rf["lgb_rf"] = lightgbm.LGBMRegressor(
    n_estimators=250,
    subsample_freq=1,     # for lgb random forest must be set to 1
    num_leaves=2**14,     # don't forget to change the number of leaves to smth big
    boosting_type="rf",   # set boosteer type
    colsample_bytree=0.8, # for lgb random forest must be less then 1
    subsample=0.8,        # for lgb random forest must be less then 1
    min_child_samples=1,  # to get deeper trees
    n_jobs=-1,
)

rf_add = train_and_test_regressor(models_rf, x_train, y_train, x_test, y_test)
Fitting xgb_rf
Fitting lgb_rf
Calculating bootstrap score for xgb_rf
Calculating bootstrap score for lgb_rf
In [134]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(
    data=pd.concat([tuned_boost.query('model == "RF"'), rf_add]), y="mse", x="model"
)
plt.setp(ax.get_xticklabels(), rotation=90)
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("Boosting and others", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.xticks(size=20)
plt.show()

Заметьте, что в данном случае они показывают себя иначе, нежели sklearn.RandomForest. Это частое явление, так как деревья в пакетах XGBoost и LightGBM строятся отличным от sklearn образом. Это может как улучшать качество, так и ухудшать.

Про подбор параметров модифицированных бустингов¶

Каждый из модифицированных бустингов имеет свои особые параметры и рекомендации от создателей и участников Kaggle, как и в каком порядке выбирать параметры для конкретного бустинга. Если вы решили использовать тот или иной вид бустинга в своей задаче, ОЗНАКОМЬТЕСЬ c этими советами.

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

Кроме того, все три бустинга имеют настройки, ускоряющие их работу (у LightGBM эти настройки выставлены по умолчанию), и все три бустинга умеют работать на GPU.

Здесь мы бегло пробежались по самым верхам, копируя подобранные на предыдущих этапах параметры.

Блендинг и Стэкинг¶

Смесь экспертов

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

$$ h(x) = \dfrac 1 k \sum_{i=i}^k a_i (x) $$

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

$$ h(x) = \sum_{i=i}^k b_i a_i (x) $$

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

$$ h(x) = \sum_{i=i}^k b_i(x) a_i (x) $$

Получается ситуация, в которой то, насколько хорошо будет предсказывать модель, зависит от самого объекта. Фактически это означает то, что мы бы хотели для каждого объекта получать какой-то вектор весов, а дальше суммировать предсказания моделей $a_i$, используя эти веса.

Как получать эти веса для каждого объекта? Обучим еще одну модель, которая явно или неявно предсказывает веса алгоритмов и агрегирует их предсказания. Называться она будет метаалгоритмом.

Обычно для метаалгоритма используется сравнительно простые модели, например, линейную модель (в этом случае веса предсказаний базовых моделей не будут зависеть от объекта).

Как получить такую модель? Есть два распространенных подхода — stacking и blending

Blending (Блендинг)¶

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

Source: Netflix Technology Blog

У нас есть обучающая и тестовая выборки.

  1. Бьем обучающую выборку на две подвыборки — A (побольше) и B.

  2. Обучаем набор базовых алгоритмов на подвыборке A.

  3. Получаем предсказания этих базовых алгоритмов на объектах из выборки B.

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

  5. Для того, чтобы сделать предказание на тесте:

    1. Получаем предсказания базовых моделей на тесте,
    2. Агрегируем их при помощи метаалгоритма.

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

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

Для улучшения качества можно сделать несколько блендингов (по-разному разбивая обучающую выборку на выборки A и B), а дальше усреднять предсказания разных блендингов.

Попробуем сделать простой блендинг. Единственное, немного схитрим. Для обучения метаалгоритма будем использовать датасет, который использовали при подборе гиперпараметров алгоритмов

In [135]:
class BlendingRegressor:
    def __init__(self, first_layer_models, second_layer_model):
        self.first_layer_models = {
            name: sklearn.clone(model) for name, model in first_layer_models.items()
        }
        self.second_layer_model = sklearn.clone(second_layer_model)

    def fit_1st_layer(self, x, y):
        for name, model in self.first_layer_models.items():
            print(f"Fitting {name}")
            model.fit(x, y)

    def predict_1st_layer(self, x):
        features = np.zeros((x.shape[0], len(self.first_layer_models)))
        for ind, model in enumerate(self.first_layer_models.values()):
            features[:, ind] = model.predict(x)
        return features

    def fit_2st_layer(self, x, y):
        features = self.predict_1st_layer(x)
        self.second_layer_model.fit(features, y)

    def predict(self, x):
        features = self.predict_1st_layer(x)
        y_pred = self.second_layer_model.predict(features)
        return y_pred
In [136]:
first_layer_models = {}

first_layer_models["linreg"] = make_pipeline(
    StandardScaler(with_mean=False), LinearRegression()
)

first_layer_models["rf"] = RandomForestRegressor(
    n_estimators=250, max_depth=None, min_samples_leaf=1, n_jobs=-1, random_state=42
)

first_layer_models["xgb"] = xgboost.XGBRegressor(
    n_estimators=500,
    learning_rate=0.1,
    max_depth=5,
    random_state=42,
    min_child_weight=10,
    n_jobs=-1,
    objective="reg:squarederror",
)

first_layer_models["lgb_tuned"] = lightgbm.LGBMRegressor(
    n_estimators=2000,
    learning_rate=0.1,
    max_depth=-1,
    num_leaves=12,
    random_state=42,
    min_child_weight=7,
    n_jobs=-1,
)

first_layer_models["catboost"] = catboost.CatBoostRegressor(
    iterations=2000,
    verbose=0,
    learning_rate=0.1,
    depth=5,
    random_state=42,
    min_data_in_leaf=5,
)
In [137]:
x_learn, x_valid, y_learn, y_valid = train_test_split(x_train, y_train, random_state=42)
blend_reg = BlendingRegressor(first_layer_models, LinearRegression())

blend_reg.fit_1st_layer(x_learn, y_learn)
blend_reg.fit_2st_layer(x_valid, y_valid)
y_pred = blend_reg.predict(x_test)
blend_boot = bootstrap_metric(
    y_test, y_pred, metric_fn=lambda x, y: mean_squared_error(y_true=x, y_pred=y)
)
Fitting linreg
Fitting rf
Fitting xgb
Fitting lgb_tuned
Fitting catboost
In [138]:
blend_data = pd.DataFrame({"mse": blend_boot})
blend_data["model"] = "SingleBlending"
In [139]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(
    data=pd.concat([tuned_boost, xgb_add, xgb_add2, lgb_add, cat_add, blend_data]),
    y="mse",
    x="model",
)
plt.setp(ax.get_xticklabels(), rotation=90)
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("Boosting and others", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.xticks(size=20)
plt.show()

Одиночный блендинг работает даже хуже, чем один catboost

Попробуем обучить 10 блендингов с разными разбиениями x_train на x_learn и x_valid

In [140]:
from IPython.display import clear_output

blending_ensemble = []

# takes some time to run
for i in range(1, 11):
    print(f"Training blender {i}")
    x_learn, x_valid, y_learn, y_valid = train_test_split(
        x_train, y_train, random_state=i * 7 % 13
    )
    blend_reg = BlendingRegressor(first_layer_models, LinearRegression())

    blend_reg.fit_1st_layer(x_learn, y_learn)
    blend_reg.fit_2st_layer(x_valid, y_valid)
    blending_ensemble.append(blend_reg)
    clear_output()
In [141]:
y_pred = 0
for blend_reg in blending_ensemble:
    y_pred += blend_reg.predict(x_test)
y_pred /= len(blending_ensemble)


eblend_boot = bootstrap_metric(
    y_test, y_pred, metric_fn=lambda x, y: mean_squared_error(y_true=x, y_pred=y)
)
eblend_data = pd.DataFrame({"mse": eblend_boot})
eblend_data["model"] = "EnsembleBlending"
In [142]:
np.quantile(eblend_boot, q=[0.025, 0.975])
Out[142]:
array([0.17014789, 0.20072305])
In [143]:
np.quantile(cat_add["mse"], q=[0.025, 0.975])
Out[143]:
array([0.17445492, 0.20549425])
In [144]:
plt.figure(figsize=(16, 6))
ax = sns.boxplot(
    data=pd.concat(
        [tuned_boost, xgb_add, xgb_add2, lgb_add, cat_add, blend_data, eblend_data]
    ),
    y="mse",
    x="model",
)
plt.setp(ax.get_xticklabels(), rotation=90)
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("Boosting and others", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.xticks(size=20)
plt.show()

Качество по сравнению с лучшей моделью (CatBoost) существенно не отличается.

Стэкинг¶

Так же можно использовать другой способ, который позволяет использовать всю обучающую выборку

Вместо того, чтобы бить обучающую выборку на выборки А и Б случайным образом, давайте использовать кросс-валидацию.

    1. Для каждого из базовых алгоритмов, которые хотим объединять:
    • 1.1 Бьем обучающую выборку на блоки
    • 1.2 На каждом кросс-валидационном разбиении учим базовый алгоритм. На блоке, который не вошел в обучение делаем предсказание.
    • 1.3. В результате получаем предсказания для всей обучающей выборки.
    • 1.4 Для предсказания на тестовой выборке, обучаем наш базовый алгоритм на всей выборке
    1. Обучаем на полученных предсказаниях мета-алгоритм

    1. Для того, чтобы сделать предсказание на тесте, делаем предсказания моделями, полученными на шаге 1.4. И агрегируем их предсказания при помощи мета-алгоритма, полученного на шаге 2.

Стэкинг, в отличие от блендинга, использует все обучение — мы не теряем часть объектов безвозвратно. Но этим мы вносим несколько проблем, одной из которых является то, что модели, на предсказаниях которых мы учили мета-алгоритм, и модели, предсказания которых мы в итоге им агрегируем, немного отличаются. Если в одном случае вероятность 0.2, предсказанная моделью, значила: «я не совсем уверена в предсказании», то теперь это может значить «я очень уверена в предсказании».

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

Также, по причине того, что нам надо делать предсказание на кроссвалидации, и работать этот метод будет дольше Blending. Зато иногда он работает лучше

В sklearn уже реализован свой StackingRegressor, поэтому в этой лекции мы его реализовывать не будем.

Можете посмотреть пример реализации, например, здесь. В принципе, если вам действительно понадобится стэкинг, полезно иметь свою собственную реализацию, чтобы легко менять в ней некоторые детали (вариантов стекинга великое множество)

Возьмем в нашу новую модель только быстро учащиеся модели

In [145]:
base_models = []

base_models.append(
    ["linreg", make_pipeline(StandardScaler(with_mean=False), LinearRegression())]
)

base_models.append(
    [
        "xgb",
        xgboost.XGBRegressor(
            n_estimators=500,
            learning_rate=0.1,
            max_depth=5,
            random_state=42,
            min_child_weight=10,
            n_jobs=-1,
            objective="reg:squarederror",
        ),
    ]
)

base_models.append(
    [
        "lgb",
        lightgbm.LGBMRegressor(
            n_estimators=2000,
            learning_rate=0.1,
            max_depth=-1,
            num_leaves=12,
            random_state=42,
            min_child_weight=7,
            n_jobs=-1,
        ),
    ]
)

base_models.append(
    [
        "cgb",
        catboost.CatBoostRegressor(
            iterations=2000,
            verbose=0,
            learning_rate=0.1,
            depth=5,
            random_state=42,
            min_data_in_leaf=5,
        ),
    ]
)
In [146]:
from sklearn.ensemble import StackingRegressor

stacking_reg = StackingRegressor(
    base_models, LinearRegression(), cv=3  # level-two model is Linear Regression
)

stacking_reg.fit(x_train, y_train)
clear_output()
In [147]:
y_pred = stacking_reg.predict(x_test)
print(mean_squared_error(y_true=y_test, y_pred=y_pred))
0.18633737889209778
In [148]:
stack_boot = bootstrap_metric(
    y_test, y_pred, metric_fn=lambda x, y: mean_squared_error(y_true=x, y_pred=y)
)

stack_data = pd.DataFrame({"mse": stack_boot})
stack_data["model"] = "Stacking"
In [149]:
plt.figure(figsize=(18, 6))
ax = sns.boxplot(
    data=pd.concat(
        [
            tuned_boost,
            xgb_add,
            xgb_add2,
            lgb_add,
            cat_add,
            blend_data,
            eblend_data,
            stack_data,
        ]
    ),
    y="mse",
    x="model",
)
plt.setp(ax.get_xticklabels(), rotation=90)
plt.xlabel("", size=20)
plt.ylabel("MSE", size=20)
plt.title("Boosting and others", size=20)
plt.tick_params(axis="both", which="major", labelsize=14)
plt.xticks(size=20)
plt.show()
In [150]:
np.quantile(stack_data["mse"], q=[0.025, 0.975])
Out[150]:
array([0.17123641, 0.20178981])
In [151]:
np.quantile(cat_add["mse"], q=[0.025, 0.975])
Out[151]:
array([0.17445492, 0.20549425])

Видим, что стэкинг в данной задаче не принес существенного улучшения.

Стэкинг vs блендинг¶

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

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

Пример того, как выглядит решающая граница для стэкинга и блендинга.

Задача — разделить синее и зеленое облако точек. На изображении выше показано, как разные алгоритмы справляются с этим — есть K-Nearest Neighbors, ridge регрессия и случайный лес с разной глубиной деревьев. Все три алгоритма как-то справляются, но лучшие результаты показывает K-Nearest Neighbors. Ridge регрессия работает не очень хорошо, потому что разделение не линейное. Случайный лес угадал тенденцию, но ему либо недостаточно данных, либо надо добавить ещё деревья, либо поменять настройки параметров.

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

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

Некоторые практические рекомендации¶

Для решения абсолютного большинства задач на неоднородных данных на практике чаще всего применяют методы бэггинга и бустинга над деревьями решений (соответственно Random Forests — RF и Gradient boosting on decision trees — GBDT). Для изображений, звуковых последовательностей, текстов и прочих однородных данных часто оказываются предпочтительными более сложные нейростетевые подходы, с чем мы ещё подробно познакомимся в ходе нашего курса.

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

Какой именно ML алгоритм (какую реализацию) нам следует выбрать для решения конкретной задачи? Как следует настраивать выбранную модель?

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

Тем не менее, попробуем сформулировать ряд упрощенных рекомендаций по применению RF и GBDT, которые могут дать необходимую начальную интуицию для работы с данными моделями.

Какой ML алгоритм в общем случае выбрать: RF или GBDT?¶

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

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

  1. Обучить на имеющихся данных "простую" модель случайного леса, оценку её качества взять за отправную точку при дальнейшей работе.
  2. Провести эксперименты по отбору и конструированию признаков (feature engineering) (благодаря гипотетической большей скорости обучения случайного леса, разумно проводить это исследование именно с этой моделью).
  3. Повторять эксперименты с feature engineering, подбирая оптимальные основные гиперпараметры случайного леса (об этом ниже).
  4. Отобрав лучшие варианты наборов признаков из экспериментов с решением на случайных лесах, использовать их при обучении модели одной из реализаций градиентного бустинга (если в данных доминируют категориальные признаки, то в первую очередь следует воспользоваться реализацией бустинга от CatBoost, в противном случае можно воспользоваться любой популярной реализацией — CatBoost, XGBoost, LightGBM).
  5. Произвести настройку гиперпараметров модели (об этом ниже).
  6. Если не получилось достичь желаемого качества решения, следует вернуться к экспериментам с feature engineering, но уже сразу проводить их для модели бустинга. Вернуться к этапу подбора гиперпараметров.

Какие деревья решений использовать в качестве элементов случайного леса, а на каких строить градиентный бустинг?¶

Как мы уже успели установить выше, справедливо разложение:

ошибка предсказания модели = bias + variance

и имеет место явление bias-variance tradeoff, принципиально не позволяющие одинаково сильно минимизировать оба вклада в ошибку предсказания модели.

  • Ансамблирование в виде бэггинга, использующееся при построении RF, заточено на уменьшение variance путём "усреднения" ответов элементарных предсказателей. Использование бэггинга существенно не позволяет улучшить bias -- этот показатель наследуется от базового эстиматора. Таким образом, при построении случайного леса следует взять относительно глубокие деревья решений (можно говорить о глубине в 6-10 ветвлений), обладающие собственным низким показателем bias.

  • Ансамблирование в виде бустинга, наоборот, в первую очередь заточено на улучшение показателя bias итоговой модели путём взаимного улучшения большого числа стабильных, но слабых эстиматоров (то есть обладающих высоким показателем bias и низким variance). Таким образом, при построении градиентного бустинга над деревьями решений следует взять в качестве базовой модели неглубокие деревья (вплоть до решающих пней — деревьев решений с единственным ветвлением).

У RF значительно меньше гиперпараметров, чем у GBDT. Но как их выбрать?¶

Фактически, все реализации алгоритма случайного леса будут содержать три важных гиперпараметра:

  • Глубина деревьев. Мы ожидаем, что каждое из деревьев в составе леса обладает достаточной обобщающей способностью, чтобы быть принципиально способным самостоятельно предсказывать данные. То есть во время подбора гиперпараметров случайного леса данный параметр стоит постепенно увеличивать, контролируя качество на тренировочной и валидационной выборках — слишком глубокие деревья могут привести к переобучению модели.

  • Количество деревьев в лесу Бэггинг деревьев решений позволяет нам минимизировать variance итоговой модели, но понятно, что ресурс для такого улучшения не безграничен. Количество деревьев в следу можно постепенно увеличивать до тех пор, пока это не перестанет приводить к существенному улучшению качества предсказания модели.

  • Какая доля признаков подаётся отдельным деревьям решений в составе случайного леса. Подавая на вход одиночным деревьям решений большее число признаков, мы делаем их более "сильными предсказателями", но взамен увеличиваем их скоррелированность (тем самым нарушаем процедуру бэггинга). Для конкретных данных положение оптимума можно наверняка установить только полным перебором параметра, но общей практикой является использование трети от общего числа свойств для задачи регрессии и округленного вниз квадратного корня из числа свойств для задач классификации.

Какие гиперпараметры GBDT реализаций следует подбирать в первую очередь?¶

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

Среди таких наиболее важных параметров, выделим два класса:

  • параметры, влияющие на контроль переобучения модели;
  • параметры, определяющие, с какой скоростью будет происходить процесс обучения модели.

Названия параметров в приведённой ниже таблице являются ссылками на актуальную версию документации:

param type CatBoost XGBoost LightGBM
overfitting control learning_rate
depth
l2_leaf_reg
eta
max_depth
min_child_weight
learning_rate
max_depth
num_leaves
min_data_in_leaf
speed of the training rsm
iterations
colsample_bytree
subsample
n_estimators
feature_fraction
bagging_fraction

Применение нейронных сетей к табличным данным¶

Есть попытки построить архитектуры нейронных сетей таким образом, чтобы они могли работать с табличными данными. Можно отметить архитектуру от Яндекса NODE и TabNet(TabNet git) от Гугл.

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

  1. Как и любая другая нейросетевая архитектура, она дифференцируема. Можно вставить обученную нейросеть как часть другой и использовать transfer learning и прочее.
  2. Нейросеть автоматически выбирает признаки, поэтому feature importance можно получить без дополнительных ухищрений.
  3. Ее можно обучать в semisupervised манере: сначала предобучить на неразмеченных данных, а потом уже использовать размеченные.

Установим пакет, реализующий эту нейросеть на PyTorch.

Так как это нейросеть, следует сначала поменять среду исполнения на среду с GPU (если поменять после, установленные пакеты в Colab слетят)

In [152]:
from IPython.display import clear_output

!pip install -q pytorch-tabnet
clear_output()
In [153]:
import sklearn
import sklearn.datasets
from sklearn.model_selection import train_test_split

calif_housing = sklearn.datasets.fetch_california_housing()
x = calif_housing.data
y = calif_housing.target
x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=42)
x_learn, x_valid, y_learn, y_valid = train_test_split(x_train, y_train, random_state=42)

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

Заметьте, что у XGBoost и LightGBM тоже есть такая возможность early_stopping, которая позволяет легко подбирать число базовых моделей. Подробнее смотрите в документации.

Нейросеть принимает y в формате (предсказываемая величина, число предсказываемых величин), поэтому нам потребуется reshape.

In [154]:
from pytorch_tabnet.tab_model import TabNetRegressor
from warnings import simplefilter

simplefilter("ignore", category=UserWarning)

tabnet = TabNetRegressor()
tabnet.fit(
    x_learn, y_learn.reshape(-1, 1), eval_set=[(x_valid, y_valid.reshape(-1, 1))]
)
epoch 0  | loss: 3.51207 | val_0_mse: 1076.38071|  0:00:03s
epoch 1  | loss: 0.92624 | val_0_mse: 5.24164 |  0:00:04s
epoch 2  | loss: 0.71727 | val_0_mse: 329.54894|  0:00:04s
epoch 3  | loss: 0.58757 | val_0_mse: 325.48701|  0:00:05s
epoch 4  | loss: 0.51347 | val_0_mse: 80.16469|  0:00:06s
epoch 5  | loss: 0.48811 | val_0_mse: 651.67741|  0:00:06s
epoch 6  | loss: 0.45026 | val_0_mse: 193.50969|  0:00:07s
epoch 7  | loss: 0.44329 | val_0_mse: 112.20831|  0:00:08s
epoch 8  | loss: 0.41462 | val_0_mse: 168.46524|  0:00:09s
epoch 9  | loss: 0.40728 | val_0_mse: 318.11486|  0:00:10s
epoch 10 | loss: 0.39702 | val_0_mse: 575.0163|  0:00:10s
epoch 11 | loss: 0.37777 | val_0_mse: 459.37161|  0:00:11s

Early stopping occurred at epoch 11 with best_epoch = 1 and best_val_0_mse = 5.24164

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

In [155]:
tabnet = TabNetRegressor()
tabnet.fit(x_train, y_train.reshape(-1, 1), max_epochs=53)
epoch 0  | loss: 2.72954 |  0:00:00s
epoch 1  | loss: 0.77319 |  0:00:01s
epoch 2  | loss: 0.6015  |  0:00:02s
epoch 3  | loss: 0.53045 |  0:00:02s
epoch 4  | loss: 0.49272 |  0:00:03s
epoch 5  | loss: 0.4568  |  0:00:04s
epoch 6  | loss: 0.44634 |  0:00:04s
epoch 7  | loss: 0.42965 |  0:00:05s
epoch 8  | loss: 0.42114 |  0:00:06s
epoch 9  | loss: 0.42466 |  0:00:07s
epoch 10 | loss: 0.39701 |  0:00:07s
epoch 11 | loss: 0.40175 |  0:00:08s
epoch 12 | loss: 0.38597 |  0:00:09s
epoch 13 | loss: 0.39648 |  0:00:10s
epoch 14 | loss: 0.37438 |  0:00:11s
epoch 15 | loss: 0.35803 |  0:00:12s
epoch 16 | loss: 0.35664 |  0:00:12s
epoch 17 | loss: 0.3555  |  0:00:13s
epoch 18 | loss: 0.353   |  0:00:14s
epoch 19 | loss: 0.35966 |  0:00:15s
epoch 20 | loss: 0.34629 |  0:00:15s
epoch 21 | loss: 0.34293 |  0:00:16s
epoch 22 | loss: 0.33906 |  0:00:17s
epoch 23 | loss: 0.32974 |  0:00:17s
epoch 24 | loss: 0.32725 |  0:00:18s
epoch 25 | loss: 0.33235 |  0:00:19s
epoch 26 | loss: 0.33473 |  0:00:20s
epoch 27 | loss: 0.33017 |  0:00:20s
epoch 28 | loss: 0.32486 |  0:00:21s
epoch 29 | loss: 0.33013 |  0:00:22s
epoch 30 | loss: 0.33028 |  0:00:23s
epoch 31 | loss: 0.33704 |  0:00:24s
epoch 32 | loss: 0.33452 |  0:00:25s
epoch 33 | loss: 0.32133 |  0:00:25s
epoch 34 | loss: 0.32139 |  0:00:26s
epoch 35 | loss: 0.32358 |  0:00:27s
epoch 36 | loss: 0.31803 |  0:00:28s
epoch 37 | loss: 0.31429 |  0:00:28s
epoch 38 | loss: 0.3166  |  0:00:29s
epoch 39 | loss: 0.31205 |  0:00:30s
epoch 40 | loss: 0.3133  |  0:00:30s
epoch 41 | loss: 0.30814 |  0:00:31s
epoch 42 | loss: 0.30986 |  0:00:32s
epoch 43 | loss: 0.30849 |  0:00:33s
epoch 44 | loss: 0.31149 |  0:00:33s
epoch 45 | loss: 0.30858 |  0:00:34s
epoch 46 | loss: 0.30869 |  0:00:35s
epoch 47 | loss: 0.32698 |  0:00:36s
epoch 48 | loss: 0.31701 |  0:00:37s
epoch 49 | loss: 0.30686 |  0:00:38s
epoch 50 | loss: 0.31026 |  0:00:38s
epoch 51 | loss: 0.30128 |  0:00:39s
epoch 52 | loss: 0.30753 |  0:00:40s
In [156]:
from sklearn.metrics import mean_squared_error

y_pred = tabnet.predict(x_test)
print(mean_squared_error(y_true=y_test, y_pred=y_pred))
0.4447459669316955

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

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

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

Ссылки на материалы для самостоятельного изучения

Общие источники

Про разделение на классическое и глубокое машинное обучение

Один из лучших учебников по классическому машинному обучению

Хорошая книга по машинному обучению

Про деревья решений

Деревья решений

Про ансамбли

Kaggle Ensembling guide

Comprehensive guide for ensemble models

Про стэкинг и блендинг

XGBoost

Отличие XGBoost от обычного градиентного бустинга

Оригинальная статья

Как подбирать параметры XGBoost

CatBoost

Решение задач классификации при помощи CatBoost

Официальная документация

LightGBM

Очень подробная и удобная документация

Описание параметров

Новый бустинг с деревьями, содержащими в листах линейные регрессии

Дисбаланс классов

Обучение в случае дисбаланса классов

Bagging и случайные леса для обучения с дисбалансом классов

Коэффициент корреляции Мэтьюса

Нейронные сети и бустинг

TabNet

TabNet, реализация на PyTorch

NODE