Интерпретация случайных лесов

Магия случайных лесов их тайны и сокровенные смыслы

Всеобъемлющее руководство по алгоритмам Случайного Леса и их интерпретации

Фото от Sergei A на Unsplash

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

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

Самые значительные преимущества алгоритмов Случайного Леса:

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

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

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

Мы узнаем, как найти ответы на следующие вопросы:

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

Предобработка

Мы будем использовать набор данных Wine Quality. Он показывает связь между качеством вина и физико-химическими тестами для разных португальских вин “Vinho Verde”. Мы попытаемся предсказать качество вина на основе его характеристик.

С деревьями решений нам не нужно делать много предобработки данных:

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

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

К счастью, в нашем наборе данных нет отсутствующих значений.

df.isna().sum().sum()0

И нам только нужно преобразовать переменную type (‘red’ или ‘white’) из string в integer. Мы можем использовать преобразование Categorical в pandas для этого.

categories = {}  cat_columns = ['type']for p in cat_columns:    df[p] = pd.Categorical(df[p])        categories[p] = df[p].cat.categoriesdf[cat_columns] = df[cat_columns].apply(lambda x: x.cat.codes)print(categories){'type': Index(['red', 'white'], dtype='object')}

Теперь, df['type'] равно 0 для красных вин и 1 для белых вин.

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

import sklearn.model_selectiontrain_df, val_df = sklearn.model_selection.train_test_split(df,     test_size=0.2) train_X, train_y = train_df.drop(['quality'], axis = 1), train_df.qualityval_X, val_y = val_df.drop(['quality'], axis = 1), val_df.qualityprint(train_X.shape, val_X.shape)(5197, 12) (1300, 12)

Мы завершили этап предобработки и готовы перейти к самой интересной части – обучению моделей.

Основы деревьев решений

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

Случайный лес – это ансамбль деревьев решений. Поэтому мы должны начать с элементарного строительного блока – дерева решений.

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

Дерево решений: Регрессия

Давайте подгоним модель дерева решений по умолчанию.

import sklearn.treeimport graphvizmodel = sklearn.tree.DecisionTreeRegressor(max_depth=3)# Я ограничил max_depth в основном для визуализацииmodel.fit(train_X, train_y)

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

dot_data = sklearn.tree.export_graphviz(model, out_file=None,                                       feature_names = train_X.columns,                                       filled = True) graph = graphviz.Source(dot_data) # сохраняем дерево в файл pngpng_bytes = graph.pipe(format='png')with open('decision_tree.png','wb') as f:    f.write(png_bytes)
График от автора

Как видите, дерево решений состоит из двоичных разбиений. На каждом узле мы делим наш набор данных на 2 части.

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

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

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

Предположим, у нас есть признак с четырьмя уникальными значениями: 1, 2, 3 и 4. Затем есть три возможных порога между ними.

График от автора

Последовательно мы можем взять каждый порог и рассчитать прогнозируемые значения для наших данных как среднее значение для листовых узлов. Затем мы можем использовать эти прогнозируемые значения для получения среднеквадратической ошибки (MSE) для каждого порога. Лучшее разделение будет то, у которого наименьшая MSE. По умолчанию, DecisionTreeRegressor из библиотеки scikit-learn работает похожим образом и использует MSE в качестве критерия.

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

def get_binary_split_for_param(param, X, y):    uniq_vals = list(sorted(X[param].unique()))        tmp_data = []        for i in range(1, len(uniq_vals)):        threshold = 0.5 * (uniq_vals[i-1] + uniq_vals[i])                 # разделяем набор данных по порогу        split_left = y[X[param] <= threshold]        split_right = y[X[param] > threshold]                # вычисляем прогнозируемые значения для каждого разделения        pred_left = split_left.mean()        pred_right = split_right.mean()        num_left = split_left.shape[0]        num_right = split_right.shape[0]        mse_left = ((split_left - pred_left) * (split_left - pred_left)).mean()        mse_right = ((split_right - pred_right) * (split_right - pred_right)).mean()        mse = mse_left * num_left / (num_left + num_right) \            + mse_right * num_right / (num_left + num_right)        tmp_data.append(            {                'param': param,                'threshold': threshold,                'mse': mse            }        )                return pd.DataFrame(tmp_data).sort_values('mse')get_binary_split_for_param('sulphates', train_X, train_y).head(5)| param     |   threshold |      mse ||:----------|------------:|---------:|| sulphates |       0.685 | 0.758495 || sulphates |       0.675 | 0.758794 || sulphates |       0.705 | 0.759065 || sulphates |       0.715 | 0.759071 || sulphates |       0.635 | 0.759495 |

Мы видим, что для сульфатов лучшим порогом является 0,685, так как это дает наименьшую среднеквадратическую ошибку (MSE).

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

def get_binary_split(X, y):    tmp_dfs = []    for param in X.columns:        tmp_dfs.append(get_binary_split_for_param(param, X, y))            return pd.concat(tmp_dfs).sort_values('mse')get_binary_split(train_X, train_y).head(5)| param   |   threshold |      mse ||:--------|------------:|---------:|| alcohol |      10.625 | 0.640368 || alcohol |      10.675 | 0.640681 || alcohol |      10.85  | 0.641541 || alcohol |      10.725 | 0.641576 || alcohol |      10.775 | 0.641604 |

Мы получили абсолютно такой же результат, как и с нашим первичным деревом решений с первым разделением на алкоголь <= 10,625.

Чтобы построить все дерево решений, мы можем рекурсивно вычислять лучшие разделения для каждого из наборов данных алкоголь <= 10.625 и алкоголь > 10.625 и получить следующий уровень дерева решений. Затем повторить.

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

model = sklearn.tree.DecisionTreeRegressor(min_samples_leaf = 420)
Графика от автора

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

import sklearn.metricsprint(sklearn.metrics.mean_absolute_error(model.predict(val_X), val_y))0.5890557338155006

Дерево решений: Классификация

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

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

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

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

Если наша модель классификации идеальна, коэффициент Джини равен 0. В худшем случае (p = 0.5), коэффициент Джини равен 0,5.

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

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

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

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

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

Как мы можем получить много независимых моделей? Это довольно просто: мы можем обучать Деревья Решений на случайных подмножествах строк и признаков. Это будет Случайный Лес.

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

import sklearn.ensembleimport sklearn.metricsmodel = sklearn.ensemble.RandomForestRegressor(100, min_samples_leaf=100)model.fit(train_X, train_y)print(sklearn.metrics.mean_absolute_error(model.predict(val_X), val_y))0.5592536196736408

С использованием случайного леса мы достигли гораздо лучшего качества, чем с одним Деревом Решений: 0.5592 против 0.5891.

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

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

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

График автора

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

Ошибка Out-of-bag

Поскольку для каждого дерева в Случайном Лесу используется только часть строк, мы можем использовать оставшиеся строки для оценки ошибки. Для каждой строки мы можем выбрать только деревья, в которых эта строка не использовалась, и использовать их для создания прогнозов. Затем мы можем рассчитать ошибки на основе этих прогнозов. Такой подход называется “ошибкой out-of-bag”.

Мы видим, что ошибка OOB гораздо ближе к ошибке на валидационном наборе, чем к ошибке на тренировочном наборе, что означает, что это хорошая аппроксимация.

# нужно указать oob_score = True, чтобы получить возможность рассчитать OOB ошибкуmodel = sklearn.ensemble.RandomForestRegressor(100, min_samples_leaf=100,      oob_score=True)model.fit(train_X, train_y)# ошибка для валидационного набораprint(sklearn.metrics.mean_absolute_error(model.predict(val_X), val_y))0.5592536196736408# ошибка для тренировочного набораprint(sklearn.metrics.mean_absolute_error(model.predict(train_X), train_y))0.5430398596179975# ошибка out-of-bagprint(sklearn.metrics.mean_absolute_error(model.oob_prediction_, train_y))0.5571191870008492

Интерпретация модели

Как я упоминал ранее, большим преимуществом Деревьев Решений является их простота интерпретации. Давайте попытаемся лучше понять нашу модель.

Важность признаков

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

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

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

Если вы используете scikit-learn, вам не нужно рассчитывать важность признаков вручную. Вы можете использовать model.feature_importances_.

def plot_feature_importance(model, names, threshold = None):    feature_importance_df = pd.DataFrame.from_dict({'feature_importance': model.feature_importances_,                                                    'feature': names})\            .set_index('feature').sort_values('feature_importance', ascending = False)    if threshold is not None:        feature_importance_df = feature_importance_df[feature_importance_df.feature_importance > threshold]    fig = px.bar(        feature_importance_df,        text_auto = '.2f',        labels = {'value': 'важность признака'},        title = 'Важность признаков'    )    fig.update_layout(showlegend = False)    fig.show()plot_feature_importance(model, train_X.columns)

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

График автора

Частичная зависимость

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

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

Чтобы оценить влияние только от алкоголя, мы можем взять все строки в нашем наборе данных и, используя модель машинного обучения, предсказывать качество для каждой строки для различных значений алкоголя: 9, 9.1, 9.2 и т. д. Затем мы можем усреднить результаты и получить фактическую связь между уровнем алкоголя и качеством вина. Таким образом, все данные равны, и мы просто меняем уровни алкоголя.

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

Мы можем использовать модуль sklearn.inspection, чтобы легко построить эти зависимости.

sklearn.inspection.PartialDependenceDisplay.from_estimator(clf, train_X,     range(12))

Мы можем получить много полезной информации из этих графиков, например:

  • качество вина повышается с ростом содержания свободного диоксида серы до 30, но остается стабильным после этого порога;
  • с увеличением уровня алкоголя качество становится лучше.

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

sklearn.inspection.PartialDependenceDisplay.from_estimator(clf, train_X,     [(1, 10)])

Уверенность прогнозов

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

val_df['predictions_mean'] = np.stack([dt.predict(val_X.values)   for dt in model.estimators_]).mean(axis = 0)val_df['predictions_std'] = np.stack([dt.predict(val_X.values)   for dt in model.estimators_]).std(axis = 0)ax = val_df.predictions_std.hist(bins = 10)ax.set_title('Распределение стандартного отклонения прогнозов')

Мы видим, что есть прогнозы с низким стандартным отклонением (то есть ниже 0,15) и те, у которых std выше 0,3.

Если мы используем модель в деловых целях, мы можем обрабатывать такие случаи по-разному. Например, не учитывать прогноз, если std выше X, или показывать заказчику интервалы (например, 25-й и 75-й процентили).

Как был сделан прогноз?

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

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

from treeinterpreter import treeinterpreterfrom waterfall_chart import plot as waterfallrow = val_X.iloc[[7]]prediction, bias, contributions = treeinterpreter.predict(model, row.values)waterfall(val_X.columns, contributions[0], threshold=0.03,           rotation_value=45, formatting='{:,.3f}');

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

Graph by author

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

Уменьшение количества деревьев

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

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

Поскольку в нашем исходном наборе данных о вине было всего 12 признаков, для этого случая мы будем использовать немного больший набор данных – Online News Popularity.

Анализ важности признаков

Сначала давайте построим случайный лес и посмотрим на важность признаков. 34 из 59 признаков имеют важность менее 0,01.

Попробуем удалить их и посмотрим на точность.

low_impact_features = feature_importance_df[feature_importance_df.feature_importance <= 0.01].index.valuestrain_X_imp = train_X.drop(low_impact_features, axis = 1)val_X_imp = val_X.drop(low_impact_features, axis = 1)model_imp = sklearn.ensemble.RandomForestRegressor(100, min_samples_leaf=100)model_imp.fit(train_X_sm, train_y)
  • СРО на проверочном наборе для всех признаков: 2969.73
  • СРО на проверочном наборе для 25 важных признаков: 2975.61

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

Анализ избыточных признаков

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

import fastbookfastbook.cluster_columns(train_X_imp)

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

  • self_reference_avg_sharess и self_reference_max_shares
  • kw_min_avg и kw_min_max
  • n_non_stop_unique_tokens и n_unique_tokens.

Давайте также удалим их.

non_uniq_features = ['self_reference_max_shares', 'kw_min_max',   'n_unique_tokens']train_X_imp_uniq = train_X_imp.drop(non_uniq_features, axis = 1)val_X_imp_uniq = val_X_imp.drop(non_uniq_features, axis = 1)model_imp_uniq = sklearn.ensemble.RandomForestRegressor(100,   min_samples_leaf=100)model_imp_uniq.fit(train_X_imp_uniq, train_y)sklearn.metrics.mean_absolute_error(model_imp_uniq.predict(val_X_imp_uniq),   val_y)2974.853274034488

Качество даже немного улучшено. Таким образом, мы сократили количество характеристик с 59 до 22 и увеличили ошибку всего на 0,17%. Это доказывает, что такой подход работает.

Полный код вы можете найти на GitHub.

Основные положения

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

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

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

Ссылки

Наборы данных

  • Cortez,Paulo, Cerdeira,A., Almeida,F., Matos,T., и Reis,J.. (2009). Wine Quality. UCI Machine Learning Repository. https://doi.org/10.24432/C56S3T
  • Fernandes,Kelwin, Vinagre,Pedro, Cortez,Paulo, и Sernadela,Pedro. (2015). Online News Popularity. UCI Machine Learning Repository. https://doi.org/10.24432/C5NS3V

Источники

Эта статья была вдохновлена Fast.AI Deep Learning Course