Классическое машинное обучение
Помимо нейронных сетей, обучение работе с которыми является основной задачей курса, есть и другие подходы к проблеме создания систем, помогающих решать те или иные задачи.
Самым первым подходом к созданию системы, способной на основе входных данных делать какие-то выводы, были так называемые Rule-based systems. В этих системах и за описание объекта — выделение значимых признаков, и за выработку правил, по которым система должна принимать то или иное решение, отвечал человек.
В современности такие системы до сих пор используются, например, в определителях растений. Такие определители представляют собой набор утверждений, например: "Растение имеет стержневую корневую систему", "Плод растения — костянка", на основе согласия/несогласия с которыми книга отсылает вас к другим утверждениям и, в конце концов, к названию растения.
Очевидно, что результативность такого подхода зависит:
Единственной выгодой такой системы по сравнению с экспертом является то, что в большинстве случаев требования к умениям пользователя все же меньше.
Игрушечный пример такой схемы:
Классическое машинное обучение избавляет нас от необходимости вручную составлять какие-то правила.
В этом подходе от нас все равно требуется описание нашего объекта определенными признаками — мы должны получать это описание либо вручную, либо обрабатывая объект какими-то программами. Чтобы отличать такие признаки от признаков, которые автоматически выделяют нейронные сети, их называют hand-designed features.
Но затем эти описанные объекты передаются алгоритму, который уже сам формирует набор правил, по которому он должен решать поставленную задачу. Уже разобранные вами линейная регрессия и SVM сами выбирают, какие признаки и с какими весами им учитывать при принятии финального решения. В некоторых случаях (например, SVM) признаки могут дополнительно (явно или неявно) преобразовываться внутри самой модели, и на их основе могут формироваться новые признаки.
Но главное ограничение всегда остается: за описание объекта отвечает какой-то внешний источник и то, какие признаки в принципе подавать алгоритму на вход решает человек. А набор возможных преобразований признаков, как правило, строго фиксирован.
И составление таких признаков требует от исследователя очень хорошего знания изучаемой темы, знания специфики работы используемого алгоритма — к примеру, подавать алгоритму на вход попарные произведения всех признаков, или алгоритм сам в ходе работы их явно или неявно получит.
И, очевидно, на больших объемах данных, когда в принципе можно использовать очень малоинформативные признаки (данных-то много, все коэффициенты при них оценим), которые и не придут исследователю в голову, классическое машинное обучение будет проигрывать.
В глубоком же машинном обучение признаки за нас выделяет нейросеть.
Нейросеть преобразует признаковое пространство от слоя к слою и сама определяет, какие признаки для данных объектов и для данной задачи важны. Она итеративно выделет сначала простые признаки, а затем комбинирует их в более сложные. За счет этого, в то время как качество подходов классического машинного обучения по мере увеличения размера обучающей выборки со временем выходит на плато, для нейросетей это плато наступает сильно позже или вообще не наступает.
Потенциально такой подход позволяет почти не применять человеческую экспертизу при построении модели под задачу. Однако в реальности человеческая экспертиза теперь переходит на уровень подбора архитектуры нейросети.
Казалось бы, если все так классно, то зачем нам вообще знать и использовать стандартные методы машинного обучения?
Оказывается, что существует целый спектр задач, в которых либо объем данных недостаточен для использования нейронных сетей, либо сама структура данных не предполагает каких-либо внутренних повторяющихся паттернов, на которые и заточены нейронные сети.
В ситуации, когда у вас есть просто таблица с признаками каждого объекта (данные табличного формата), каждый признак не особо связан как-то с другими и взаиморасположение признаков в таблице не играет никакой роли:
Кроме того, стоит учитывать, что часто для того, чтобы достигнуть качества state-of-the-art методов классического машинного обучения, необходимо долгое время подбирать архитектуры нейросети и настраивать ее параметры.
Время же получения решений при помощи классических методов часто исчисляется часами. Таким образом, даже в худшем случае вы сможете оценить, насколько проблема хорошо решаема, и получить хороший бейзлайн для сравнения.
Помимо этого, методы машинного обучения дают более трактуемые предсказания — как правило, из них легче получить важность каждого отдельно взятого признака.
Кроме того, в современных задачах часто используются комбинации нейронных сетей и алгоритмов классического машинного обучения. Например, можно использовать нейронные сети в качестве генераторов признаков для методов классического обучения.
Деревья решений — это одна из первых моделей машинного обучения, которая была известна человеку. Изначально их строили без специальных алгоритмов, а просто вручную.
Когда требовалось принять решение по проблеме, для которой построено дерево, человек брал и проходился по этому дереву.
Как оно устроено? В каждом узле есть какой-то вопрос. Например, нормальное ли у человека слезоотделение, есть ли у него астигматизм и так далее. И, отвечая на каждый из этих вопросов, мы подбираем человеку линзы. Такое дерево решений можно построить без использования моделей машинного обучения, просто на основании опыта многих врачей. По сути это как раз те самые экспертные системы.
Понятно, что вручную такие деревья строить тяжело, для большого объема данных их руками и не построишь. Также возникает вопрос: зачем вообще нужна такая старая модель?
Оказывается, что эти модели могут быть неожиданно эффективны и их можно автоматически строить с помощью специально разработанных алгоритмов. Причем, это можно делать достаточно быстро даже на больших объемах данных.
По словам Энтони Голдблума, основателя и генерального директора Kaggle, на соревнованиях побеждают алгоритмы, основанные на деревьях решений, и нейронные сети.
Где побеждают ансамбли деревьев решений?
В принципе, до недавнего времени деревья решений побеждали практически везде: в рекомендательных системах, в задачах ранжирования, задачах релевантной выдачи для поиска, на предсказаниях, какую рекламу выберет пользователь, и так далее. В любой задаче, где нет какой-то локальный связанности, которая есть в изображениях, текстах и других областях, захваченных нейронными сетями.
Принцип работы дерева решений можно проиллюстрировать на данном примере. Есть 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} $$Очень сложно бороться с пропущенными значениями. Если значение какого-то признака неизвестно, то любая непрерывная модель, в том числе и нейронная сеть, будет без дополнительных ухищрений вести себя очень плохо. Потому что надо как-то объяснить модели, что значения какого-то признака нет.
Это сложно сделать, поскольку с этим признаком должны производиться некие математические операции. И по этой причине обычно отсутствующий признак нельзя оставить так, как есть. В таком случае есть два варианта действий:
Чаще всего в науке так сделать нельзя, потому что данные стоят очень дорого, и поэтому выбрасывают объекты только из-за того, что у них нет какого-либо признака, достаточно редко.
Поэтому обычно прибегают к первому варианту. Как это можно сделать?
Можно предположить, что данные не очень сложные, признаки не зависят друг от друга, и заполнить пропущенные значения средними значениями соответствующего признака. Например, если средний возраст людей 30 лет, то для людей, чей возраст неизвестен, будем писать это же значение. Но бывают случаи, когда этот способ (среднее, медиана или любая другая взятая величина, которую мы считаем по известным значениям признаков) не работает.
Добавляем дополнительные блоки / модели, которые будут предсказывать пропущенные значения. Если пропущено много признаков, то возникает много проблем, связанных с тем, что надо обучить много моделей: каждая из них совершает ошибки, в итоговом датасете получается много зашумленных данных, не факт, что модель вообще обучится.
Использовать алгоритм, который умеет справляться с пропуском. Один из таких алгоритмов — k-NN. Можно брать ближайших соседей по известным признакам и на место неизвестного признака поставить среднее значение. Можно сделать нейросеть, которая будет устойчива к отсутствию какого-то признака. Способ рабочий.
Деревья решений могут бороться с этой проблемой двумя способами:
Бьем по первому известному признаку, который дает наиболее похожее разбиение. Иногда для простоты можно взять признак, наиболее скоррелированный с данными.
Взгляд с точки зрения функционального анализа
$\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).
Для каждого экземпляра измерялись четыре характеристики (в сантиметрах):
Будем учиться отделять Ирис виргинаский (versicolor) от остальных видов.
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
Сделаем два разных разбиения на обучение и тест. И посмотрим, будут ли отличаться деревья, построенные для данных разбиений.
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 уже не совпадают между собой. Конечно, у нас маленький датасет — как правило, чем датасет больше, тем устойчивее будет получаться дерево на первых уровнях. Но часто и деревья используют куда большей глубины.
Если использовать деревья бОльшей глубины, то и структура деревьев (то, как они выглядят, даже если не обращать внимания на конкретные признаки в узлах), будет отличаться.
# 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()
Если алгоритм при небольшом изменении признаков сильно меняет свое решение, это, как правило, указывает на переобучение. Алгоритм сильно реагирует на любой шум в данных— доверять его решениям опасно.
Покажем это на синтетическом датасете:
# 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)
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()
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-ого класса.
Обратите внимание на странные рваные области на рисунке. В этих областях из-за шума, присутствующего в данных, оказались точки неправильного класса. Дерево не смогло их распознать как неправильные и просто выучило очевидно неверные правила.
Что произойдет, если мы возьмем точки из того же датасета, но другие?
# 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
# 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
# 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()
Теперь полученные границы решений (почти) совпадают. Но что мы видим? Наше дерево абсолютно не в состоянии (в обоих случаях) уловить закономерность в исходных данных. Если мы отложим только тренировочный датасет, то увидим следующее
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.
Можно показать, что ошибка любой модели раскладывается в сумму трех компонент:
$$ Model\ error = Bias + Variance + Irreducible\ error $$Обычно, высокий bias имеют модели, которые недостаточно сложны по сравнению с реальной закономерностью, которая заложена в данных. Например, реальная зависимость, которую мы наблюдаем, нелинейная, а мы пытаемся аппроксимировать ее прямой линией. В этом случае наше решение заведомо смещено (biased) в сторону линейной модели, и мы будем систематически ошибаться в сравнении с реальной моделью данных.
Можно получить и обратную ситуацию. Если модель будет слишком сложная (в смысле своей выразительной способности) и "гибкая", то она сможет слишком сильно подстроиться под данные и выучить тренировочную выборку полностью. В этом случае модель будет подстраиваться под любой шум в данных и пытаться объяснить его какой-то сложной закономерностью. Малое изменение в данных будет приводить к большим изменениям в прогнозе модели.
Иногда bias и variance представляют еще таким образом:
Если удачно подобрать модель и ее гиперпараметры, то гипотетически можно точно предсказать среднее значение ожидаемой величины, то есть получить и низкий $Bias$, и низкий $Variance$.
В реальности при измерении физической величины есть случайные непредсказуемые погрешности — отклонения от среднего. Из-за этого предсказания всегда будут иметь определенный уровень ошибки, ниже которого опуститься нельзя — это и есть $Irreducible\ error$.
В практических задачах, когда невозможно подобрать реальную модель данных, не получается бесконечно уменьшать и Bias, и Variance — приходится искать компромисс (bias-variance tradeoff). С какого-то момента при уменьшении Bias начнет увеличиваться Variance, и наоборот. Задача исследователя — найти точку оптимума.
Можно построить зависимость этих величин от сложности модели (capacity). По мере увеличения сложности Variance имеет тенденцию к возрастанию, а Bias — к убыванию. Более сложные модели подстраиваются под случайные шумы обучающей выборки, а более простые — не могут воспроизвести реальные закономерности.
Управлять эффектом variance и bias можно с помощью выбора как модели, так и гиперпараметров модели.
Продемонстрируем источники компонент Bias и Variance на примере регрессии зашумленной косинусоиды методом k-NN. Создадим функцию для генерации небольшой обучающей выборки и отобразим ее на графике
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$ на разных выборках. Сравним предсказания моделей друг с другом и с реальной целевой функцией.
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$) на разных подвыборках наших данных. Выберем одну тестовую точку и посмотрим, как предсказания моделей в этой точке зависят от гиперпараметра — количества соседей.
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.
Можно подобрать для дерева идеальную capacity, когда Bias и Variance будут суммарно давать наименьший вклад в ошибку. Этим мы занимаемся при подборе параметров. Но, оказывается, есть и другие способы борьбы с variance и/или bias, которые мы разберем позже.
Заметим, что если бы могли не просто брать решение дерева, а привязывать к этому какую-то статистику, например, сколько деревьев, построенных по подобной процедуре, приняли такое же решение, то было бы легче.
Если наложить решающие границы 100 решающих деревьев, построенных на разных выборках из $x, y$, то мы увидим, что "хорошие области", соответствующие реальному разделению данных, будут общими между деревьями, а плохие — индивидуальны. К сожалению, в реальности мы не можем брать бесконечное число наборов данных из генеральной совокупности (представленной в данном случае $x, y$)
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()
Часто мы хотим получить какое-то представление о точности какой-либо нашей оценки: медианы выборки, качества модели, корреляции между двумя переменными и т.д. И мы не знаем, как распределена характеристика, которую мы оцениваем.
Есть много подходов к тому, как получить такую оценку, и один из них — бутстрэп.
Что мы делаем:
Делаем из нашего исходного датасета N выборок такого же размера с повторениями.
Для каждой полученной выборки (обычно их называют псевдовыборками) считаем характеристику, для которой хотим получить оценку.
В результате такой процедуры получаем N значений характеристики. Строим гистограмму этих значений. Получаем примерное распределение нашей характеристики.
Можем построить 95% доверительный интервал для нашей характеристики. Для этого отрезаем 2.5% самых больших значений и самых малых.
Давайте попробуем сделать это на двух практических примерах.
Часто мы хотим понять, как взаимосвязаны две переменные. И часто для оценки этой взаимосвязи используется корреляция. Например, корреляция Пирсона позволяет оценить линейную зависимость между двумя переменными.
Подсчет корреляции Пирсона реализован в Python с помощью функции scipy.stats.pearsonr
. Посчитаем при помощи этой функции корреляцию между длиной наружной доли околоцветника и внутренней доли околоцветника.
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
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?
Можно обратиться к документации
?scipy.stats.pearsonr
p-value считается в предположении, что наши переменные распределены нормально.
Аналогично, при тех же предположениях, при помощи приведенной ниже функции можно подсчитать доверительный интервал для нашего коэффициента корреляции, который дает нам интервальную, а не точечную оценку для значения корреляции.
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
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$, выдавать значения этой характеристики, посчитанные на псевдовыборках.
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
Посчитаем значение корреляции Пирсона на бутстрэп-репликах:
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 квантили в полученном массиве:
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
Получили доверительный интервал при помощи бутстрэпа. Заметим, что точность интервала зависит от числа реплик, которые вы сделаете: чем больше число реплик, тем точнее интервал.
Сравнивая полученные интервалы с интервалом, подсчитанным в предположении нормальности, видим, что они почти друг от друга не отличаются.
Загрузим датасет Heart Disease, содержащий данные о людях с подозрением на сердечные заболевания. Допустим, мы хотим узнать, есть ли связь между уровнем холестерина (колонка chol) и возрастом (age).
heart_dataset = pd.read_csv(
"https://edunet.kea.su/repo/EduNet-web_dependencies/datasets/heart.csv"
)
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
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-интервал:
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. Как нам это сделать?
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$ процентах случаев:
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
Пусть у нас две модели обладают одинаковым качеством, а третья — лучшим:
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:
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
Очевидная проблема: да, нужная нам модель выбрана, но почему мы считаем различия между первой и второй моделью значимыми, а между второй и третьей — нет?
print(f"qual2 - qual1: {(qual2 - qual1):.3f}\nqual3 - qual2: {(qual3 - qual2):.3f}")
qual2 - qual1: 0.004 qual3 - qual2: 0.067
Получается, такой способ (при помощи точечной оценки), может привести к ошибке, так как не дает нам судить о значимости отличий.
Существует много способов посчитать значимость данного отличия. Мы рассмотрим способ сравнения на основе bootstrap. Об остальных можете почитать в обзоре, приведенном в списке литературы.
Способ состоит в применении бутстрэпа к предсказаниям модели и реальным меткам.
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% доверительный интервал качества для каждой модели:
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]
Теперь мы видим, что доверительные интервалы для качества первой и второй модели практически одинаковы, в то время как доверительный интервал для качества третьей модели от них сильно отличается и не пересекается.
Можем построить боксплот:
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()
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)
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)
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)
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
Теперь подсчитаем бутстрэп-оценки. Обратите внимание на то, что теперь мы передаем не предсказания, а вероятности.
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 и логистической регрессии почти не отличается, а дерево решений уступает обеим моделям.
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
Самое простое решение возникшей проблемы:
1010111011
1110010011
11001101 11
1110110011
Напишем код, чтобы удостовериться в наших выводах.
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
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 классификатора, которые предсказывают независимо друг от друга.
Посчитаем вероятность того, что:
Таким образом, если брать большинство голосов, то мы будем в 78% случаев предсказывать верно. Мы взяли 3 классификатора, которые сами по себе были не очень хорошими, и получили классификатор лучшего качества. Очевидно, что если взять 4, 5, 10 классификаторов, то ситуация будет становиться лучше.
Пусть теперь у нас три классификатора, выдающие следующие предсказания
1111111100 — 80% точность
1111111100 — 80% точность
1011111100 — 70% точность
Если объединим предсказания, то получим:
1111111100 — 80% точность
Потому что очень высокая зависимость предсказаний. Выше видно, что два классификатора предсказывают абсолютно одинаково. Вероятность, что они делают это случайно, очень мала.
А вот если возьмем такие классификаторы, то все получится:
1111111100 — 80% точность
0111011101 — 70% точность
1000101111 — 60% точность
Усреднение:
1111111101 — 90% точность
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
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()
Видим:
Посмотрим, как зависит качество предсказания от коррелированности предсказателей. Конкретно — от ожидаемой коррелированности вероятностей ошибиться на данном объекте для любой взятой пары классификаторов из ансамбля.
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
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()
Видим, что, по мере увеличения коррелированности моделей, качество все больше и больше приближается к качеству одной базовой модели.
Нам нужны классификаторы, которые сами по себе предсказывают лучше, чем случайное число, при этом они должны быть не коррелированы. На самом деле это нетривиальная задача: откуда нам брать классификаторы, учитывая, что у нас 1 датасет?
Первый вариант — нам поможет уже рассмотренный bootstrap:
Делаем из нашего исходного датасета N выборок такого же размера с повторениями.
На каждой полученной выборке (обычно их называют псевдовыборками) строим отдельную модель. Чтобы полученным модели были слабо зависимы, будем использовать алгоритм, который чувствителен к небольшим изменениям в выборке.
Получаем N слабо зависимых моделей.
Когда нам нужно сделать предсказание для нового объекта, делаем предсказание каждой из N моделей, а затем усредняем предсказание.
В sklearn
для бэггинга можно использовать класс BaggingClassifier
из sklearn.ensemble
. Мы напишем свой код для бэггинга для большей наглядности происходящего. Кроме того, это вам понадобится при выполнении заданий.
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
Для простоты здесь и далее будем использовать параметры моделей, подобранные ранее, когда мы использовали их вне ансамбля. В данном случае это не имеет значения, но в случае разбираемых далее случайного леса и градиентного бустинга параметры подбирают вместе с построением ансамбля.
heart_dataset = pd.read_csv(
"https://edunet.kea.su/repo/EduNet-web_dependencies/datasets/heart.csv"
)
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)
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)
Опробуем модель:
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
)
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)
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)
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
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
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]
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)
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),
)
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()
Главному аутсайдеру — дереву решений — при ансамблировании удается получить качество не хуже других базовых моделей.
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)
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)
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), что хорошо бы взять решающие границы большого числа деревьев, построенных на разных тренировочных датасетах, полученных из генеральной совокупности, и усреднить эти границы, получив более хорошую решающую границу. К сожалению, мы не можем брать бесконечное число тренировочных датасетов. Но у нас есть бутстрэп. И он тоже достаточно хорошо аппроксимирует границу, которую мы бы получили, будь у нас много разных тренировочных датасетов:
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)
Второй вариант получения псевдовыборок — сэмплировать не объекты, а признаки. При этом бесполезно иметь в выборке два одинаковых признака, так как потом мы делаем выборки меньшего размера, чем исходное число признаков и без повторений.
Обычно для каждой модели выбирают:
для задач классификации:$$\sqrt{feature\_cnt}$$ для задач регрессии: $$ \frac {feature\_cnt} {3}$$ Хотя строгих правил нет, этот параметр можно подбирать на кросс-валидации.
Опять же, напишем метод случайных подпространств сами:
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
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)
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)
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)
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]
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 значительно помогает только дереву решений. Остальные методы почти не улучшают своего качества.
Можно объединить оба способа: применяем bootstrap к объектам (получается выборка одного размера, но с повторяющимися объектами, а каких-то объектов не будет), и, кроме этого, выкидываем часть признаков. Зачем это нужно? В этом случае мы получим еще более сильно отличающиеся друг от друга случайные выборки.
sklearn.ensemble.BaggingClassifier
и sklearn.ensemble.BaggingRegressor
вопреки названию может поддерживать оба способа.
Теперь будем использовать BaggingClassier
из стандартной библиотеки:
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
for name, model in models.items():
model.fit(x_train, y_train)
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
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
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()
Существенное улучшение качества наблюдается только при использовании решающего дерева в качестве базовой модели.
Чтобы простое голосование повышало качество ансамбля, необходимо, чтобы ошибки базовых моделей не коррелировали между собой.
Попробуем оценить попарную корреляцию в ошибках базовых моделей в ансамблях.
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)
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)
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)
Кроме того, посчитаем качество базовых моделей, входящих в каждый из ансамблей.
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
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)
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)
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()
Можно сделать следующие выводы:
Из предыдущей части мы увидели, что использование bagging или RSM для SVM или подобных моделей не несет большого смысла.
И если использовать bagging или RSM для SVM, то значимого улучшения качества не будет.
Но для деревьев решений это не так. Будем брать деревья большой глубины. Незначительные изменения в данных приводят к значительным изменениям в топологии таких деревьев. Таким образом, мы приходим к случайному лесу = Bagging + RSM над деревом решений.
При этом RSM в классическом случайном лесе делается не на уровне дерева, а на уровне узла. В каждом узле дерева, когда мы выбираем лучшее разбиение его на два дочерних, мы просматриваем не все признаки, а только определенное их количество.
Обычно случайный лес работает лучше, чем отдельно случайно взятое дерево. Но главное, что случайный лес намного более устойчив к шуму. Оказывается, что это свойство до сих пор позволяет случайному лесу успешно использоваться в областях с шумными данными.
Для случайного леса верно следующее: когда мы берем множество базовых классификаторов (в данном случае деревьев) и усредняем их, то результат этих усреднений стремится к идеальному дереву решений, причем построенному на идеальных, а не на исходных признаках.
Проверим это на задаче регрессии California Housing dataset
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).
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
Обучим все модели. Для этого напишем вспомогательную функцию, которая будет обучать переданные ей модели и считать для них качество на тесте.
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
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).
Помимо этого, будем добавлять кроме случайного леса с заданной глубиной еще одиночное дерево такой же глубины.
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))
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), потому усреднение их предсказаний почти не улучшает качество.
Отметим, что в случае малых выборок ограничения на глубину дерева могут дать выигрыш.
Иногда качество случайного леса можно улучшить, если поставить небольшое ограничение на минимальное число объектов в листе, чтобы запретить явно переобученные деревья.
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
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()
В данном случае, однако, ограничение на количество объектов в листе ухудшает качество ансамбля. Одна из причин заключается в том, что с увеличением числа ограничений на дерево уменьшается непохожесть деревьев друг на друга, и качество случайного леса так же начинает уменьшаться.
Существует мнение, что случайный лес не переобучается. Это не так. Можно подобрать такие два набора параметров, что первый даст лучшее качество на тренировочной выборке, а второй — на тестовой. При этом увеличение числа деревьев в ансамбле с этим справиться поможет слабо.
Возьмем, к примеру, наш игрушечный пример на плоскости.
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()
Видны области, в которых случайный лес переобучился.
Добавление ограничения на число объектов в листьях улучшает ситуацию.
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()
Лес без ограничения на минимальное число объектов в листе переобучился — достиг максимального качества на обучающей выборке, но на тестовой имеет качество ниже, чем лес с ограничением.
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
Благодаря механизму обучения случайного леса у нас появляется еще один способ валидировать модель. Для каждого дерева используются не все объекты нашей выборки, тогда мы можем получать предсказания деревьев на объектах, которые не использовались для обучения этих деревьев.
За это отвечает параметр oob_score
(по умолчанию False
)
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 не заменяет другие способы валидации, а является дополнительным способом, который используется, в основном, когда данных мало.
Случайный лес — инструмент для уменьшения variance нашей модели. Можно показать, что при стремлении числа моделей в ансамбле к бесконечности, а их коррелированности — к 0, variance ансамбля стремится к нулю. Однако при этом bias ансамбля будет равен bias базовой модели.
Продемонстрируем это:
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
Бустинг — это еще один способ ансамблирования моделей. В отличие от случайного леса, где каждая модель в ансамбле строится независимо, в бустинге построение очередной модели зависит от уже состоящих в ансамбле. Каждая следующая модель в ансамбле стремится улучшить предсказание всего ансамбля.
Бустинг позволяет нам на основе большого числа "слабых" моделей получить одну "сильную". Под слабыми моделями подразумеваем модели, точность которых может быть лишь немногим выше случайного угадывания.
В качестве моделей традиционно используются деревья решений, но не большой глубины, а наоборот — маленькой, чтобы каждое из них не могло выучить выборку хорошо.
Из всех бустингов больше всего прославился и доказал свою эфективность градиентный бустинг. Он позволяет получить решение, которое сложно побить другими видами моделей.
Начнем с интуиции. Предположим, что мы играем в гольф. Наша задача — загнать мячик в лунку, причем, когда мячик далеко от нее, мы можем ударить посильнее, чтобы сократить расстояние, но когда мы уже близко к лунке, то стараемся бить клюшкой аккуратнее, чтобы не промахнуться. После каждого удара мы оцениваем расстояние до лунки и приближаем мячик в ее сторону. Применительно к нашей теме удар клюшкой по мячику — это каждая модель в градиентном бустинге.
Цель $i$-той модели в ансамбле — скорректировать ошибки предыдущих ${i-1}$ моделей. В результате, когда мы суммируем вклады всех моделей ансамбля, получается хорошее предсказание.
Давайте формализуем, что нам нужно, чтобы обучить алгоритм градиентного бустинга:
Минимизировать ошибку будем с помощью градиентного спуска.
Рзаберем пошагово:
Начинаем с первого предсказания, можем выбрать на самом деле любое число, например среднее значение
$$ \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) $$Например, запустим градиентный бустинг без подбирания параметров:
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)
И напишем функцию, которая будет автоматически обучать переданные ей модели и считать для них качество на тесте, чтобы избавиться от необходимости копировать код между ячейками:
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
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
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, в отличие от случайного леса, может сильно переобучиться. Это важно понимать. Для небольших датасетов часто может оказаться, что случайный лес дает более надежные результаты.
gbtree = GradientBoostingRegressor(
n_estimators=300, learning_rate=1.0 # faster learning rate to force ovefitting
)
gbtree.fit(x_train, y_train)
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.
GradientBoostingRegressor(learning_rate=1.0, n_estimators=300)
Используем метод staged_predict
, который есть во многих реализациях бустинга в том или ином виде. Он получает предсказание от первых $i$ деревьев ансамбля, что позволяет быстро строить график качества градиентного бустинга в зависимости от числа базовых моделей:
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 и поговорим.
Как у градиентного спуска есть learning rate, который определяет силу каждого нашего следующего шага, так и у градиентного бустинга есть параметр, который называется shrinkage или learning rate — это дополнительный параметр, на который мы домножаем вес, с которым мы добавляем новые модели в ансамбль.
Мы не хотим сильно скакать по пространству решений, мы хотим спускаться медленно и сойтись к какому-то хорошему минимуму. Потому мы берем и вес каждой модели, которую мы рассчитываем по специальному алгоритму, еще домножаем на маленький коэффициент, который называется shrinkage или learning rate. Фактически, мы берем и умножаем градиент на некий альфа (это то же, что было в градиентном спуске, ничего нового мы не добавили).
Если не домножать вес каждой модели дополнительно на этот параметр, то мы можем попасть в ситуацию, которая будем пролетать мимо минимума функции ошибки (та же опасность, что и в обычном градиентном спуске).
Как и в случае с градиентным спуском, learning rate влияет не только на то, как быстро мы станем переобучаться, но и на глубину минимума, который мы найдем.
# 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)
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 датасет:
x_learn, x_valid, y_learn, y_valid = train_test_split(x_train, y_train, random_state=42)
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
, так как эти параметры взаимосвязаны и вместе влияют на сложность полученных деревьев.
Здесь же в целях демонстрации мы подберем сначала глубину дерева, а потом минимальное число объектов.
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
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.
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
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.
Этот параметр влияет на сложность полученных деревьев. Как уже отмечалось ранее, стоит подбирать его вместе с глубиной, но мы ограничены по времени.
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
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.
Можно каждому дереву в ансамбле давать только часть объектов из выборки — получим стохастический градиентный бустинг. За это отвечает параметр subsample
.
Аналогично, можно давать каждому дереву в ансамбле лишь часть признаков — max_features
.
Это может давать дополнительный прирост качества. Сейчас мы этот этап опускаем, так как у нас мало признаков и не очень много объектов, а также в целях экономии времени.
Кроме того, мы можем каждое дерево еще немножко рандомизировать. Можем давать ему не всю выборку, не все признаки. Это работает не всегда, гиперпараметры, контролирующие это поведение, нужно подбирать.
После того, как мы подобрали остальные параметры, можно попытаться выиграть дополнительное качество за счет понижения learning_rate
и одновременного увеличения числа деревьев в ансамбле.
Опять же, этот этап пропустим.
Построим график качества для текущего learning_rate
— мы же брали не оптимальное число предсказателей, посмотрим, можно ли уже на финальном этапе взять побольше.
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 деревьев.
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
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()
Видим, что мы дополнительно уменьшили ошибку нашего метода
В то время как случайный лес только уменьшал variance модели, бустинг стремится уменьшить и bias, и variance.
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 вводит специальный штраф за сложные деревья (большей глубины, чем 2 — 3). За счет того, что в градиентном бустинге можно минимизировать любую дифференцируемую функцию ошибок, мы просто добавляем штраф напрямую в функцию ошибок исходного градиентного бустинга.
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. Посмотрим, вдруг получится еще улучшить качество.
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
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. И, главное — в разы быстрее.
XGBoost немного иначе определяет важность листа: не сколько именно объектов попало в лист, а насколько объекты в листе имеют сильно разные предсказания. Из-за этого способа определения min_child_weight
может принимать нецелые значения, в том числе меньше 1 (к примеру, в случае задачи классификации).
Попробуем затюнить только этот параметр. Заметьте, мы можем спокойно использовать 2000 деревьев, не боясь ждать результата расчетов долгое время.
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
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 (можно было бы подобрать чуть точнее).
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
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 по умолчанию строит дерево немного иначе, нежели XGBoost.
XGBoost по умолчанию строит дерево по уровням — на каждом уровне если узел можно разбить так, чтобы улучшить значение функции ошибки, то мы это делаем. Ограничены мы только максимальной глубиной дерева.
LightGBM же строит дерево по узлам. На каждом шаге бьется тот узел, разбиение которого сильнее всего минимизирует функцию ошибки. И ограничения на глубину нет. В LightGBM вводится ограничение не на глубину дерева, а на общее число листьев в итоговом дереве.
По умолчанию он еще быстрее (хотя в XGBoost тоже есть опции для ускорения).
Из-за особенностей построения им деревьев надо задавать не высоту дерева, а максимальное число листьев. Поставим пока так, чтобы число листьев было равно числу листьев в дереве высоты 6.
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
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.
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
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.
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
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 листьев
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
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:
!pip install -q catboost
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 98.6/98.6 MB 10.1 MB/s eta 0:00:00
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
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 есть свои, более быстрые и иногда более эффективные реализации случайного леса. Но их надо дополнительно настраивать.
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
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
На соревновании Netflix Prize была поставлена задача — предсказать, как люди оценят тот или иной фильм. Победил подход, основанный на таком объединении модели, при котором у вас каждая модель получает вес в зависимости от объекта. Подход называется блендинг.
У нас есть обучающая и тестовая выборки.
Бьем обучающую выборку на две подвыборки — A (побольше) и B.
Обучаем набор базовых алгоритмов на подвыборке A.
Получаем предсказания этих базовых алгоритмов на объектах из выборки B.
Используем эти предсказания в качестве признаков для метаалгоритма, который будет предсказывать веса алгоритмов. Обучаем его на выборке B.
Для того, чтобы сделать предказание на тесте:
Тестовую выборку не трогаем. Дополнительно разбиваем обучение и обучаем алгоритмы на разбиении, каждый алгоритм дает какое-то предсказание. На отложенной части обучения делаем то же предсказание, то есть используем предсказания алгоритмов как дополнительные признаки. Получается датасет, в котором есть изначальные признаки и предсказания моделей. Дальше можно обучить мета-алгоритм либо на объединении всех этих признаков, либо только на этих признаках. Получится, что алгоритм будет брать предсказания других алгоритмов, обрабатывать их внутри себя и выдавать итоговое предсказание.
Недостатком блендинга является то, что мы дополнительно разбиваем обучающую выборку, так как нельзя учить и базовые алгоритмы, и основной на одних и тех же данных.
Для улучшения качества можно сделать несколько блендингов (по-разному разбивая обучающую выборку на выборки A и B), а дальше усреднять предсказания разных блендингов.
Попробуем сделать простой блендинг. Единственное, немного схитрим. Для обучения метаалгоритма будем использовать датасет, который использовали при подборе гиперпараметров алгоритмов
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
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,
)
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
blend_data = pd.DataFrame({"mse": blend_boot})
blend_data["model"] = "SingleBlending"
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
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()
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"
np.quantile(eblend_boot, q=[0.025, 0.975])
array([0.17014789, 0.20072305])
np.quantile(cat_add["mse"], q=[0.025, 0.975])
array([0.17445492, 0.20549425])
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) существенно не отличается.
Так же можно использовать другой способ, который позволяет использовать всю обучающую выборку
Вместо того, чтобы бить обучающую выборку на выборки А и Б случайным образом, давайте использовать кросс-валидацию.
Стэкинг, в отличие от блендинга, использует все обучение — мы не теряем часть объектов безвозвратно. Но этим мы вносим несколько проблем, одной из которых является то, что модели, на предсказаниях которых мы учили мета-алгоритм, и модели, предсказания которых мы в итоге им агрегируем, немного отличаются. Если в одном случае вероятность 0.2, предсказанная моделью, значила: «я не совсем уверена в предсказании», то теперь это может значить «я очень уверена в предсказании».
Кроме того, по наблюдениям, такой подход более склонен к переобучению. Для борьбы с ним можно, к примеру, подмешивать к предсказаниям базовых моделей шум.
Также, по причине того, что нам надо делать предсказание на кроссвалидации, и работать этот метод будет дольше Blending. Зато иногда он работает лучше
В sklearn уже реализован свой StackingRegressor
, поэтому в этой лекции мы его реализовывать не будем.
Можете посмотреть пример реализации, например, здесь. В принципе, если вам действительно понадобится стэкинг, полезно иметь свою собственную реализацию, чтобы легко менять в ней некоторые детали (вариантов стекинга великое множество)
Возьмем в нашу новую модель только быстро учащиеся модели
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,
),
]
)
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()
y_pred = stacking_reg.predict(x_test)
print(mean_squared_error(y_true=y_test, y_pred=y_pred))
0.18633737889209778
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"
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()
np.quantile(stack_data["mse"], q=[0.025, 0.975])
array([0.17123641, 0.20178981])
np.quantile(cat_add["mse"], q=[0.025, 0.975])
array([0.17445492, 0.20549425])
Видим, что стэкинг в данной задаче не принес существенного улучшения.
На самом деле эти подходы часто объединяют в один или путают местами. Например, улучшая блендинг в сторону использования всей обучающей выборки, можно получить стэкинг.
Оба подхода имеют множество модификаций, к примеру, с добавлением признаков-предсказаний базовых моделей к исходным признакам и т.д. Оба подхода показывают схожие результаты. И оба подхода улучшают свое качество по мере увеличения размера обучающей выборки.
Пример того, как выглядит решающая граница для стэкинга и блендинга.
Задача — разделить синее и зеленое облако точек. На изображении выше показано, как разные алгоритмы справляются с этим — есть K-Nearest Neighbors
, ridge регрессия и случайный лес с разной глубиной деревьев.
Все три алгоритма как-то справляются, но лучшие результаты показывает K-Nearest Neighbors
. Ridge регрессия работает не очень хорошо, потому что разделение не линейное. Случайный лес угадал тенденцию, но ему либо недостаточно данных, либо надо добавить ещё деревья, либо поменять настройки параметров.
Но если мы берем блендинг и стэкинг этих моделей, то оба этих подхода проводят хорошую разделяющую границу (намного лучше, чем эти подходы по отдельности).
Известно, что нейронные сети хорошо работают с изображениями. Зачастую поступают следующим образом: учат много разных нейросетей на изображениях, либо берут уже обученные архитектуры, затем делают с ними блендинг или стэкинг на какой-то отложенной выборке. Результаты получаются лучше, чем от использования одной только нейросети
Для решения абсолютного большинства задач на неоднородных данных на практике чаще всего применяют методы бэггинга и бустинга над деревьями решений (соответственно Random Forests — RF и Gradient boosting on decision trees — GBDT). Для изображений, звуковых последовательностей, текстов и прочих однородных данных часто оказываются предпочтительными более сложные нейростетевые подходы, с чем мы ещё подробно познакомимся в ходе нашего курса.
Предположим, мы хотим построить модель классификации или регрессии, а наш датасет представляет собой неоднородные табличные данные. Естественно возникает вопрос:
Какой именно ML алгоритм (какую реализацию) нам следует выбрать для решения конкретной задачи? Как следует настраивать выбранную модель?
Несмотря на некоторые успехи в построении универсального ответа на такой вопрос (AutoML), судя по всему, удовлетворительный общий ответ отсутствует. При построении решения в каждом конкретном случае мы вынуждены учитывать особенности имеющихся данных, какие метрики качества модели для нас имеют наибольшее значение, каковы наши располагаемые вычислительные или (и) временные ресурсы.
Тем не менее, попробуем сформулировать ряд упрощенных рекомендаций по применению RF и GBDT, которые могут дать необходимую начальную интуицию для работы с данными моделями.
Из практики соревнований по машинному обучению известно, что использование градиентного бустинга для решения задач с табличными данными даёт возможность получить максимально качественное решение. Тем не менее, следует понимать, что это гипотетическое лучшее качество решения достигается путём сложного подбора параметров и финальной настройки модели.
В качестве короткой практической рекомендации можно сформулировать следующий пайплайн для решения задачи на реальных табличных данных:
Как мы уже успели установить выше, справедливо разложение:
ошибка предсказания модели = bias + variance
и имеет место явление bias-variance tradeoff, принципиально не позволяющие одинаково сильно минимизировать оба вклада в ошибку предсказания модели.
Ансамблирование в виде бэггинга, использующееся при построении RF, заточено на уменьшение variance
путём "усреднения" ответов элементарных предсказателей. Использование бэггинга существенно не позволяет улучшить bias
-- этот показатель наследуется от базового эстиматора. Таким образом, при построении случайного леса следует взять относительно глубокие деревья решений (можно говорить о глубине в 6-10 ветвлений), обладающие собственным низким показателем bias
.
Ансамблирование в виде бустинга, наоборот, в первую очередь заточено на улучшение показателя bias
итоговой модели путём взаимного улучшения большого числа стабильных, но слабых эстиматоров (то есть обладающих высоким показателем bias
и низким variance
). Таким образом, при построении градиентного бустинга над деревьями решений следует взять в качестве базовой модели неглубокие деревья (вплоть до решающих пней — деревьев решений с единственным ветвлением).
Фактически, все реализации алгоритма случайного леса будут содержать три важных гиперпараметра:
Глубина деревьев. Мы ожидаем, что каждое из деревьев в составе леса обладает достаточной обобщающей способностью, чтобы быть принципиально способным самостоятельно предсказывать данные. То есть во время подбора гиперпараметров случайного леса данный параметр стоит постепенно увеличивать, контролируя качество на тренировочной и валидационной выборках — слишком глубокие деревья могут привести к переобучению модели.
Количество деревьев в лесу
Бэггинг деревьев решений позволяет нам минимизировать variance
итоговой модели, но понятно, что ресурс для такого улучшения не безграничен. Количество деревьев в следу можно постепенно увеличивать до тех пор, пока это не перестанет приводить к существенному улучшению качества предсказания модели.
Современные реализации 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. Стоит отметить, что победы над древесными моделями не одержано. Но полученная модель иногда достигает их качества и обладает рядом интересных особенностей:
Установим пакет, реализующий эту нейросеть на PyTorch.
Так как это нейросеть, следует сначала поменять среду исполнения на среду с GPU (если поменять после, установленные пакеты в Colab слетят)
from IPython.display import clear_output
!pip install -q pytorch-tabnet
clear_output()
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
.
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
Обучим теперь на всем тренировочном датасете, поставив число эпох обучения равным тому, что нам сообщила выдача функции
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
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, модель все же чаще всего уступает хорошо затюненным бустингам.
Ссылки на материалы для самостоятельного изучения
Общие источники
Про разделение на классическое и глубокое машинное обучение
Один из лучших учебников по классическому машинному обучению
Хорошая книга по машинному обучению
Про деревья решений
Про ансамбли
Comprehensive guide for ensemble models
XGBoost
Отличие XGBoost от обычного градиентного бустинга
Как подбирать параметры XGBoost
CatBoost
Решение задач классификации при помощи CatBoost
LightGBM
Очень подробная и удобная документация
Новый бустинг с деревьями, содержащими в листах линейные регрессии
Дисбаланс классов
Обучение в случае дисбаланса классов
Bagging и случайные леса для обучения с дисбалансом классов
Коэффициент корреляции Мэтьюса
Нейронные сети и бустинг