Как избежать распространенных ошибок и глубже изучить наши модели

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

Если вы хотите увидеть весь блокнот, посмотрите его → здесь

Библиотеки

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

import sys
import os
import pandas as pd
import numpy as np

import matplotlib.pyplot as plt
import seaborn as sns
from IPython.display import display
%matplotlib inline

import plotly.offline as py
import plotly.graph_objs as go
import plotly.tools as tls
py.init_notebook_mode(connected=True)

import warnings
warnings.filterwarnings('ignore')

from pandas import set_option
from pandas.plotting import scatter_matrix
from sklearn.preprocessing import StandardScaler, MinMaxScaler, QuantileTransformer, RobustScaler
from sklearn.model_selection import train_test_split, KFold, StratifiedKFold, cross_val_score, GridSearchCV
from sklearn.feature_selection import RFECV, SelectFromModel, SelectKBest, f_classif
from sklearn.metrics import classification_report, confusion_matrix, balanced_accuracy_score, ConfusionMatrixDisplay, f1_score
from sklearn.pipeline import Pipeline
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier, ExtraTreesClassifier, VotingClassifier
from scipy.stats import uniform

from imblearn.over_sampling import ADASYN

import swifter

# Always good to set a seed for reproducibility
SEED = 8
np.random.seed(SEED)

Данные

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

Характеристики набора данных:

Образцы в этом наборе данных соответствуют участкам леса размером 30 × 30 м в США, собранным для задачи прогнозирования типа покрова каждого участка, то есть доминирующих видов деревьев. Существует семь типов укрытий, что делает задачу классификации нескольких классов. Каждый образец имеет 54 функции, описанные на домашней странице набора данных. Некоторые функции являются булевыми индикаторами, а другие — дискретными или непрерывными измерениями.

Количество экземпляров: 581 012

Информация об объекте (название/тип данных/измерение/описание)

  • Высота / количественная / метры / Высота в метрах
  • Аспект / количественный / азимут / Аспект в градусах азимута
  • Наклон / количественный / градусы / наклон в градусах
  • Горизонтальное_расстояние_до_гидрологии/количественное/метры/горизонтальное расстояние до ближайших объектов поверхностных вод
  • Вертикальное_расстояние_до_гидрологии/количественное/метры/вертикальное расстояние до ближайших объектов поверхностных вод
  • Horizontal_Distance_To_Roadways / количественный / метры / Horz Расстояние до ближайшей проезжей части
  • Hillshade_9am / количественный / индекс от 0 до 255 / индекс Hillshade в 9:00, летнее солнцестояние
  • Hillshade_Noon / количественный / индекс от 0 до 255 / индекс отмывки в полдень, летнее солнцестояние
  • Hillshade_3pm / количественный / индекс от 0 до 255 / индекс Hillshade в 15:00, летнее солнцестояние
  • Horizontal_Distance_To_Fire_Points / количественный / метры / Horz Расстояние до ближайших точек возгорания лесных пожаров
  • Wilderness_Area (4 бинарных столбца) / качественный / 0 (отсутствие) или 1 (наличие) / обозначение дикой природы
  • Soil_Type (40 бинарных столбцов) / качественный / 0 (отсутствие) или 1 (наличие) / обозначение типа почвы

Количество классов:

  • Cover_Type (7 типов) / целое число / от 1 до 7 / Обозначение типа лесного покрова

Загрузить набор данных

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

columns = ['Elevation', 'Aspect', 'Slope', 'Horizontal_Distance_To_Hydrology', 'Vertical_Distance_To_Hydrology', 'Horizontal_Distance_To_Roadways',
 'Hillshade_9am', 'Hillshade_Noon', 'Hillshade_3pm', 'Horizontal_Distance_To_Fire_Points', 'Wilderness_Area_0', 'Wilderness_Area_1', 'Wilderness_Area_2',
 'Wilderness_Area_3', 'Soil_Type_0', 'Soil_Type_1', 'Soil_Type_2', 'Soil_Type_3', 'Soil_Type_4', 'Soil_Type_5', 'Soil_Type_6', 'Soil_Type_7', 'Soil_Type_8',
 'Soil_Type_9', 'Soil_Type_10', 'Soil_Type_11', 'Soil_Type_12', 'Soil_Type_13', 'Soil_Type_14', 'Soil_Type_15', 'Soil_Type_16', 'Soil_Type_17', 'Soil_Type_18',
 'Soil_Type_19', 'Soil_Type_20', 'Soil_Type_21', 'Soil_Type_22', 'Soil_Type_23', 'Soil_Type_24', 'Soil_Type_25', 'Soil_Type_26', 'Soil_Type_27', 'Soil_Type_28',
 'Soil_Type_29', 'Soil_Type_30', 'Soil_Type_31', 'Soil_Type_32', 'Soil_Type_33', 'Soil_Type_34', 'Soil_Type_35', 'Soil_Type_36', 'Soil_Type_37', 'Soil_Type_38',
 'Soil_Type_39']  

from sklearn import datasets
def sklearn_to_df(sklearn_dataset):
    df = pd.DataFrame(sklearn_dataset.data, columns=columns)
    df['target'] = pd.Series(sklearn_dataset.target)
    return df

df = sklearn_to_df(datasets.fetch_covtype())
df_name=df.columns
df.head(3)

Используя df.info() и df.describe(), чтобы лучше узнать наши данные, мы видим, что недостающих данных нет, и они состоят из количественных переменных. Набор данных также довольно большой (> 580 000 строк). Первоначально я пытался запустить это на всем наборе данных, но это заняло НАВСЕГДА, поэтому я рекомендую использовать часть данных.

Что касается целевой переменной, которая является классом лесного покрова, с помощью df.target.value_counts() мы видим следующее распределение (в порядке убывания):

Класс 2 = 283 301
Класс 1 = 211 840
Класс 3 = 35 754
Класс 7 = 20 510
Класс 6 = 17 367
Класс 5 = 9 493
> Класс 4 = 2747

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

Подготовьте свои данные

Одно из самых распространенных недоразумений при работе с моделями машинного обучения — это обработка наших данных перед разделением. Почему это проблема?

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

Ex1 StandardScaler()

z = (x — u) / s

Ex2 MinMaxScaler()

X_std = (X - X.min()) / (X.max() - X.min())
X_scaled = X_std * (max - min) + min

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

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

Таким образом, первый шаг после знакомства с нашим набором данных — разделить его и сохранить ваш тестовый набор невидимым до самого конца. В приведенном ниже коде мы разделили данные на 80% (обучающий набор) и 20% (тестовый набор). Вы также заметите, что я сохранил всего 50 000 образцов, чтобы сократить время, необходимое для обучения и оценки наших моделей. Поверьте мне, вы будете благодарить меня позже!

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

# here we are first separating our df into features (X) and target (y)
X =  df[df_name[0:54]]
Y = df[df_name[54]]

# now we are separating into training (80%) and test (20%) sets. The test set won't be seen until we want to test our top model!
X_train, X_test, y_train, y_test =train_test_split(X,Y,
                                                   train_size = 40_000,
                                                   test_size=10_000,
                                                   random_state=SEED,
                                                   stratify=df['target']) # we stratify to ensure similar distribution in train/test

Разработка функций

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

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

По этой причине я проконсультировался с [1] [2] [3], которые лучше меня разбираются в этой области. Я объединил знания из этих ссылок, чтобы создать функции, которые вы найдете ниже.

# engineering new columns from our df
def FeatureEngineering(X):
    
    X['Aspect'] = X['Aspect'] % 360
    X['Aspect_120'] = (X['Aspect'] + 120) % 360

    X['Hydro_Elevation_sum'] = X['Elevation'] + X['Vertical_Distance_To_Hydrology']
                                 
    X['Hydro_Elevation_diff'] = abs(X['Elevation'] - X['Vertical_Distance_To_Hydrology'])

    X['Hydro_Euclidean'] = np.sqrt(X['Horizontal_Distance_To_Hydrology']**2 +
                                   X['Vertical_Distance_To_Hydrology']**2)

    X['Hydro_Manhattan'] = abs(X['Horizontal_Distance_To_Hydrology'] +
                            X['Vertical_Distance_To_Hydrology'])
    
    X['Hydro_Distance_sum'] = X['Horizontal_Distance_To_Hydrology'] + X['Vertical_Distance_To_Hydrology']
                                
    X['Hydro_Distance_diff'] = abs(X['Horizontal_Distance_To_Hydrology'] - X['Vertical_Distance_To_Hydrology'])
    
    X['Hydro_Fire_sum'] = X['Horizontal_Distance_To_Hydrology'] + X['Horizontal_Distance_To_Fire_Points']
                            
    X['Hydro_Fire_diff'] = abs(X['Horizontal_Distance_To_Hydrology'] + X['Horizontal_Distance_To_Fire_Points'])

    X['Hydro_Fire_mean'] = (X['Horizontal_Distance_To_Hydrology'] + X['Horizontal_Distance_To_Fire_Points'])/2
                               
    X['Hydro_Road_sum'] = X['Horizontal_Distance_To_Hydrology'] + X['Horizontal_Distance_To_Roadways']
                            
    X['Hydro_Road_diff'] = abs(X['Horizontal_Distance_To_Hydrology'] + X['Horizontal_Distance_To_Roadways'])

    X['Hydro_Road_mean'] = (X['Horizontal_Distance_To_Hydrology'] + X['Horizontal_Distance_To_Roadways'])/2
    
    X['Road_Fire_sum'] = X['Horizontal_Distance_To_Roadways'] + X['Horizontal_Distance_To_Fire_Points']
                           
    X['Road_Fire_diff'] = abs(X['Horizontal_Distance_To_Roadways'] - X['Horizontal_Distance_To_Fire_Points'])

    X['Road_Fire_mean'] = (X['Horizontal_Distance_To_Roadways'] + X['Horizontal_Distance_To_Fire_Points'])/2
    
    X['Hydro_Road_Fire_mean'] = (X['Horizontal_Distance_To_Hydrology'] + X['Horizontal_Distance_To_Roadways'] + 
                                  X['Horizontal_Distance_To_Fire_Points'])/3

    
    return X

X_train = X_train.swifter.apply(FeatureEngineering, axis = 1) 
X_test = X_test.swifter.apply(FeatureEngineering, axis = 1) 

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

Выбор функции

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

В качестве примера предположим, что наша модель имеет точность 94%, используя все эти функции. Затем представьте, что у нас есть точность 89% только с 4 функциями. Какую цену мы готовы заплатить за более интерпретируемую модель? Всегда взвешивайте производительность и сложность.

Имея это в виду, я выполню выбор функций, чтобы попытаться сразу же уменьшить сложность. Sklearn предоставляет множество вариантов, которые стоит рассмотреть. В этом примере я буду использовать SelectKBest, который выберет заранее заданное количество функций, обеспечивающих наилучшую производительность. Ниже я запросил (и перечислил) 15 наиболее эффективных функций. Это функции, которые я буду использовать для обучения моделей в следующем разделе.

selector = SelectKBest(f_classif, k=15)
selector.fit(X_train, y_train)
mask = selector.get_support()
X_train_reduced_cols = X_train.columns[mask]

X_train_reduced_cols

>>> Index(['Elevation', 'Wilderness_Area_3', 'Soil_Type_2', 'Soil_Type_3',
       'Soil_Type_9', 'Soil_Type_37', 'Soil_Type_38', 'Hydro_Elevation_sum',
       'Hydro_Elevation_diff', 'Hydro_Road_sum', 'Hydro_Road_diff',
       'Hydro_Road_mean', 'Road_Fire_sum', 'Road_Fire_mean',
       'Hydro_Road_Fire_mean'],
        dtype='object')

Базовые модели

В этом разделе я сравню три разных классификатора:

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

# baseline models
def GetBaseModels():
    baseModels = []
    baseModels.append(('KNN'  , KNeighborsClassifier()))
    baseModels.append(('RF'   , RandomForestClassifier()))
    baseModels.append(('ET'   , ExtraTreesClassifier()))
    
       
    return baseModels
def ModelEvaluation(X_train, y_train,models):
    # define number of folds and evaluation metric
    num_folds = 10
    scoring = "f1_weighted" #This is suitable for imbalanced classes

    results = []
    names = []
    for name, model in models:
        kfold = StratifiedKFold(n_splits=num_folds, random_state=SEED, shuffle = True)
        cv_results = cross_val_score(model, X_train, y_train, cv=kfold, scoring=scoring, n_jobs = -1)
        results.append(cv_results)
        names.append(name)
        msg = "%s: %f (%f)" % (name, cv_results.mean(), cv_results.std())
        print(msg)
        
    return names, results

Во второй функции есть несколько ключевых элементов, которые стоит обсудить подробнее. Первый из них — StratifiedKFold. Напомним, мы разделили исходный набор данных на 80% обучающих и 20% тестовых. Тестовый набор будет зарезервирован для окончательной оценки нашей наиболее эффективной модели.

Использование перекрестной проверки позволит нам лучше оценить наши модели. В частности, я настроил 10-кратную перекрестную проверку. Для тех, кто не знаком, модель обучается на k-1 сгибах и проверяется на оставшихся сгибах на каждом шаге. В конце у вас будет доступ к среднему и варианту k моделей, что даст вам лучшее понимание, чем простая оценка обучающего теста. Стратифицированная K-кратность, о которой я умолчал ранее, используется для обеспечения того, чтобы каждая кратность имела приблизительно равное представление целевых классов.

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

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

Очень распространенной метрикой классификации является точность, которая представляет собой процент правильных классификаций. Хотя это может показаться отличным вариантом, предположим, что у нас есть бинарная классификация, в которой целевые классы неравномерны (т. е. группа 1 = 90, группа 2 = 10). Можно иметь точность 90%, и это здорово, но если мы продолжим исследование, мы правильно классифицируем всю группу 1 и не сможем классифицировать ни одну из группы 2. В этом случае наша модель не очень информативна.

Если бы мы использовали взвешенную оценку F1, мы бы получили результат 42,6%. Если вам интересно узнать больше о счете F1 → здесь статья, объясняющая, как он рассчитывается.

После обучения базовых моделей я нанес на график результаты каждой из них. Все базовые модели работали относительно хорошо. Помните, что на данный момент я ничего не делал с данными (т.е. преобразовывал, удалял выбросы). Классификатор дополнительных деревьев имел самый высокий взвешенный балл F1 - 86,9%.

Преобразование данных

На следующем этапе этого проекта мы рассмотрим влияние преобразования данных на производительность модели. В то время как многие алгоритмы на основе деревьев решений не чувствительны к величине данных, разумно ожидать, что модели, измеряющие расстояние между выборками, такие как KNN, работают по-разному при масштабировании [4] [5]. В этом разделе мы будем масштабировать наши данные, используя StandardScaler и MinMaxScaler, как описано выше. Ниже вы найдете функцию, описывающую конвейер, который применит скейлер, а затем обучит модель, используя масштабированные данные.

def GetScaledModel(nameOfScaler):
    
    if nameOfScaler == 'standard':
        scaler = StandardScaler()
    elif nameOfScaler =='minmax':
        scaler = MinMaxScaler()

    pipelines = []
    pipelines.append((nameOfScaler+'KNN' , Pipeline([('Scaler', scaler),('KNN' , KNeighborsClassifier())])))
    pipelines.append((nameOfScaler+'RF'  , Pipeline([('Scaler', scaler),('RF'  , RandomForestClassifier())])))
    pipelines.append((nameOfScaler+'ET'  , Pipeline([('Scaler', scaler),('ET'   , ExtraTreesClassifier())])))
    
    
    return pipelines 

Результаты с использованием StandardScaler представлены ниже. Мы видим, что наша гипотеза относительно масштабирования данных, по-видимому, верна. Оба классификатора случайного леса и дополнительных деревьев работали почти одинаково, тогда как KNN улучшил производительность примерно на 4%. Несмотря на это увеличение, два древовидных классификатора по-прежнему превосходят масштабированный KNN.

Аналогичные результаты можно увидеть при использовании MinMaxScaler. Результаты всех моделей практически идентичны результатам, полученным при использовании StandardScaler.

Здесь стоит отметить, что я также проверил эффект удаления выбросов. Для этого я удалил значения, выходящие за пределы +/- 3 SD для каждой функции. Я не представляю результаты здесь, потому что не было никаких значений вне этого диапазона. Если вам интересно посмотреть, как это было выполнено, пожалуйста, не стесняйтесь проверить блокнот, найденный по ссылке, указанной в начале этой статьи.

Настройка гиперпараметров с помощью GridSearchCV

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

Я решил использовать GridSearchCV (CV для перекрестной проверки). Ниже вы найдете функцию, которая выполняет 10-кратную перекрестную проверку моделей, которые мы использовали. Одна дополнительная деталь здесь заключается в том, что нам нужно предоставить список гиперпараметров, которые мы хотим оценить.

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

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

scaler = StandardScaler()
X_train_scaled = pd.DataFrame(scaler.fit_transform(X_train_reduced), columns=X_train_reduced.columns)
X_test_scaled = pd.DataFrame(scaler.transform(X_test_reduced), columns=X_test_reduced.columns)
class GridSearch(object):
    
    def __init__(self,X_train,y_train,model,hyperparameters):
        
        self.X_train = X_train
        self.y_train = y_train
        self.model = model
        self.hyperparameters = hyperparameters
        
    def GridSearch(self):
        
        cv = 10
        clf = GridSearchCV(self.model,
                                 self.hyperparameters,
                                 cv=cv,
                                 verbose=0,
                                 n_jobs=-1,
                                 )
        # fit grid search
        best_model = clf.fit(self.X_train, self.y_train)
        message = (best_model.best_score_, best_model.best_params_)
        print("Best: %f using %s" % (message))

        return best_model,best_model.best_params_
    
    def BestModelPredict(self,X_train):
        
        best_model,_ = self.GridSearch()
        pred = best_model.predict(X_train)
        return pred

Далее я предоставил параметры поиска по сетке, которые были протестированы для каждой из моделей.

# 1) KNN
model_KNN = KNeighborsClassifier()
neighbors = [1,3,5,7,9,11,13,15,17,19] # Number of neighbors to use by default for k_neighbors queries
param_grid = dict(n_neighbors=neighbors)

# 2) RF
model_RF = RandomForestClassifier()
n_estimators_value = [50,100,150,200,250,300] # The number of trees 
criterion = ['gini', 'entropy', 'log_loss'] # The function to measure the quality of a split
param_grid = dict(n_estimators=n_estimators_value, criterion=criterion)

# 3) ET
model_ET = ExtraTreesClassifier()
n_estimators_value = [50,100,150,200,250,300] # The number of trees 
criterion = ['gini', 'entropy', 'log_loss'] # The function to measure the quality of a split
param_grid = dict(n_estimators=n_estimators_value, criterion=criterion)

Классификатор ансамбля голосования

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

Наилучшие параметры для каждой модели перечислены ниже. Выходные данные классификатора голосования показывают, что мы получили взвешенную оценку F1 87,5% на тренировочном наборе и 88,4% на тестовом наборе. Неплохо!

param = {'n_neighbors': 1}
model1 = KNeighborsClassifier(**param)

param = {'criterion': 'entropy', 'n_estimators': 300}
model2 = RandomForestClassifier(**param)

param = {'criterion': 'gini', 'n_estimators': 300}
model3 = ExtraTreesClassifier(**param)

# create the models based on above parameters
estimators = [('KNN',model1), ('RF',model2), ('ET',model3)]

# create the ensemble model
kfold = StratifiedKFold(n_splits=10, random_state=SEED, shuffle = True)
ensemble = VotingClassifier(estimators)
results = cross_val_score(ensemble, X_train_scaled, y_train, cv=kfold)
print('F1 weighted score on train: ',results.mean())
ensemble_model = ensemble.fit(X_train_scaled,y_train)
pred = ensemble_model.predict(X_test_scaled)
print('F1 weighted score on test:' , (y_test == pred).mean())

>>> F1 weighted score on train:  0.8747
>>> F1 weighted score on test: 0.8836

Анализ ошибок

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

from sklearn.metrics import plot_confusion_matrix
cfm_raw = plot_confusion_matrix(ensemble_model, X_test_scaled, y_test, values_format = '') # add normalize = 'true' for precision matrix or 'pred' for recall matrix
plt.savefig("cfm_raw.png")

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

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

Классификация ошибок машинным обучением

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

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

# add predicted values test_df to compare with ground truth
test_df['predicted'] = pred

# create class 0 = no error , 1 = error
test_df['error'] = (test_df['target']!=test_df['predicted']).astype(int)

# create our error classification set
X_error = test_df[['Elevation', 'Wilderness_Area_3', 'Soil_Type_2', 'Soil_Type_3', 'Soil_Type_9', 'Soil_Type_37', 'Soil_Type_38',
                       'Hydro_Elevation_sum', 'Hydro_Elevation_diff', 'Hydro_Road_sum', 'Hydro_Road_diff', 'Hydro_Road_mean', 'Road_Fire_sum',
                       'Road_Fire_mean', 'Hydro_Road_Fire_mean']]

X_error_names = X_error.columns
y_error = test_df['error']

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

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

import shap
kfold = StratifiedKFold(n_splits=10, random_state=SEED, shuffle = True)

list_shap_values = list()
list_test_sets = list()
for train_index, test_index in kfold.split(X_error, y_error):
    X_error_train, X_error_test = X_error.iloc[train_index], X_error.iloc[test_index]
    y_error_train, y_error_test = y_error.iloc[train_index], y_error.iloc[test_index]
    X_error_train = pd.DataFrame(X_error_train,columns=X_error_names)
    X_error_test = pd.DataFrame(X_error_test,columns=X_error_names)

    #training model
    clf = RandomForestClassifier(criterion = 'entropy', n_estimators = 300, random_state=SEED)
    clf.fit(X_error_train, y_error_train)

    #explaining model
    explainer = shap.TreeExplainer(clf)
    shap_values = explainer.shap_values(X_error_test)
    #for each iteration we save the test_set index and the shap_values
    list_shap_values.append(shap_values)


# flatten list of lists, pick the sv for 1 class, stack the result (you only need to look at 1 class for binary classification since values will be opposite to one another)
shap_values_av = np.vstack([sv[1] for sv in list_shap_values])
sv = np.abs(shap_values_av).mean(0) 
sv_std = np.abs(shap_values_av).std(0) 
sv_max = np.abs(shap_values_av).max(0) 
importance_df = pd.DataFrame({
    "column_name": X_error_names,
    "shap_values_av": sv,
    "shap_values_std": sv_std,
    "shap_values_max": sv_max
})

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

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

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

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

Еще раз, я должен настаивать на том, что мой экспертиза в лесном хозяйстве ограничен парой недель. Я провел небольшое исследование, чтобы понять, что это может означать, и наткнулся на пару статей [6] [7], в которых предполагается, что расстояние до дороги является важным фактором риска лесных пожаров.

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

Краткое содержание

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

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

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

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

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