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

Традиционная прогнозная аналитика предлагает две парадигмы для рассмотрения большинства проблем: точечная оценка и классификация. Современная наука о данных в значительной степени связана с последним, формулируя многие вопросы с точки зрения категоризации (подумайте о том, как страховщик может попытаться определить, какие клиенты будут генерировать высокие затраты, а не прогнозировать затраты для каждого клиента; или как маркетолог может быть более заинтересован в котором реклама будет возвращать положительный ROI, а не предсказывать конкретный ROI каждого объявления). Учитывая это, специалисты по данным разработали и знакомы с рядом методов классификации, от логистической регрессии до методов на основе дерева и леса до нейронных сетей. Однако есть загвоздка — многие из этих методов лучше всего работают с данными с примерно сбалансированными классами результатов, которые редко доставляются в реальных приложениях. В этой части я покажу, как использовать методы обнаружения аномалий для смягчения проблем, создаваемых несбалансированными классами результатов в обучении с учителем.

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

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

import pandas as pd
import numpy as np
from sklearn.compose import make_column_transformer
from sklearn.ensemble import GradientBoostingClassifier, IsolationForest
from sklearn.metrics import accuracy_score, confusion_matrix
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder

# read in data
airlines2022 = pd.read_csv('myPath/Combined_Flights_2022.csv')
print(airlines2022.shape)
# (4078318, 61)

# subset by my target departure city
airlines2022PIT = airlines2022[airlines2022.Origin == 'PIT']
print(airlines2022PIT.shape)
# (24078, 61)

# combine cancellations, diversions, and 30+ minute delays into one Bad Flight outcome
airlines2022PIT = airlines2022PIT.assign(arrDel30 = airlines2022PIT['ArrDelayMinutes'] >= 30)
airlines2022PIT = (airlines2022PIT
                   .assign(badFlight = 1 * (airlines2022PIT.Cancelled 
                                            + airlines2022PIT.Diverted
                                            + airlines2022PIT.arrDel30))
                  )
print(airlines2022PIT.badFlight.mean())
# 0.15873411412908048

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

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

# categorize columns by feature type
toFactor = ['Airline', 'Dest', 'Month', 'DayOfWeek'
            , 'Marketing_Airline_Network', 'Operating_Airline']
toScale = ['Distance']

# drop fields that don't look helpful for prediction
airlines2022PIT = airlines2022PIT[toFactor + toScale + ['badFlight']]
print(airlines2022PIT.shape)
# (24078, 8)

# split original training data into training and validation sets
train, test = train_test_split(airlines2022PIT
                               , test_size = 0.2
                               , random_state = 412)
print(train.shape)
# (19262, 8)
print(test.shape)
# (4816, 8)

# manually scale distance feature
mn = train.Distance.min()
rng = train.Distance.max() - train.Distance.min()
train = train.assign(Distance_sc = (train.Distance - mn) / rng)
test = test.assign(Distance_sc = (test.Distance - mn) / rng)
train.drop('Distance', axis = 1, inplace = True)
test.drop('Distance', axis = 1, inplace = True)

# make an encoder
enc = make_column_transformer(
    (OneHotEncoder(min_frequency = 0.025, handle_unknown = 'ignore'), toFactor)
    , remainder = 'passthrough'
    , sparse_threshold = 0)

# apply it to the training dataset
train_enc = enc.fit_transform(train)

# convert it back to a Pandas dataframe for ease of use
train_enc_pd = pd.DataFrame(train_enc, columns = enc.get_feature_names_out())

# encode the test set in the same way
test_enc = enc.transform(test)
test_enc_pd = pd.DataFrame(test_enc, columns = enc.get_feature_names_out())

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

# feature selection - drop low importance terms|
lowimp = ['onehotencoder__Airline_Delta Air Lines Inc.'
          , 'onehotencoder__Dest_IAD'
          , 'onehotencoder__Operating_Airline_AA'
          , 'onehotencoder__Airline_American Airlines Inc.'
          , 'onehotencoder__Airline_Comair Inc.'
          , 'onehotencoder__Airline_Southwest Airlines Co.'
          , 'onehotencoder__Airline_Spirit Air Lines'
          , 'onehotencoder__Airline_United Air Lines Inc.'
          , 'onehotencoder__Airline_infrequent_sklearn'
          , 'onehotencoder__Dest_ATL'
          , 'onehotencoder__Dest_BOS'
          , 'onehotencoder__Dest_BWI'
          , 'onehotencoder__Dest_CLT'
          , 'onehotencoder__Dest_DCA'
          , 'onehotencoder__Dest_DEN'
          , 'onehotencoder__Dest_DFW'
          , 'onehotencoder__Dest_DTW'
          , 'onehotencoder__Dest_JFK'
          , 'onehotencoder__Dest_MDW'
          , 'onehotencoder__Dest_MSP'
          , 'onehotencoder__Dest_ORD'
          , 'onehotencoder__Dest_PHL'
          , 'onehotencoder__Dest_infrequent_sklearn'
          , 'onehotencoder__Marketing_Airline_Network_AA'
          , 'onehotencoder__Marketing_Airline_Network_DL'
          , 'onehotencoder__Marketing_Airline_Network_G4'
          , 'onehotencoder__Marketing_Airline_Network_NK'
          , 'onehotencoder__Marketing_Airline_Network_WN'
          , 'onehotencoder__Marketing_Airline_Network_infrequent_sklearn'
          , 'onehotencoder__Operating_Airline_9E'
          , 'onehotencoder__Operating_Airline_DL'
          , 'onehotencoder__Operating_Airline_NK'
          , 'onehotencoder__Operating_Airline_OH'
          , 'onehotencoder__Operating_Airline_OO'
          , 'onehotencoder__Operating_Airline_UA'
          , 'onehotencoder__Operating_Airline_WN'
          , 'onehotencoder__Operating_Airline_infrequent_sklearn']
lowimp = [x for x in lowimp if x in train_enc_pd.columns]
train_enc_pd = train_enc_pd.drop(lowimp, axis = 1)
test_enc_pd = test_enc_pd.drop(lowimp, axis = 1)

# separate potential predictors from outcome
train_x = train_enc_pd.drop('remainder__badFlight', axis = 1); train_y = train_enc_pd['remainder__badFlight']
test_x = test_enc_pd.drop('remainder__badFlight', axis = 1); test_y = test_enc_pd['remainder__badFlight']
print(train_x.shape)
print(test_x.shape)

# (19262, 25)
# (4816, 25)

# build model
gbt = GradientBoostingClassifier(learning_rate = 0.1
                                 , n_estimators = 100
                                 , subsample = 0.7
                                 , max_depth = 5
                                 , random_state = 412)

# fit it to the training data
gbt.fit(train_x, train_y)

# calculate the probability scores for each test observation
gbtPreds1Test = gbt.predict_proba(test_x)[:,1]

# use a custom threshold to convert these to binary scores
gbtThresh = np.percentile(gbtPreds1Test, 100 * (1 - obsRate))
gbtPredsCTest = 1 * (gbtPreds1Test > gbtThresh)

# check accuracy of model
acc = accuracy_score(gbtPredsCTest, test_y)
print(acc)
# 0.7742940199335548

# check lift
topDecile = test_y[gbtPreds1Test > np.percentile(gbtPreds1Test, 90)]
lift = sum(topDecile) / len(topDecile) / test_y.mean()
print(lift)
# 1.8591454794381614

# view confusion matrix
cm = (confusion_matrix(gbtPredsCTest, test_y) / len(test_y)).round(2)
print(cm)
# [[0.73 0.11]
# [0.12 0.04]]

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

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

# build an isolation forest
isf = IsolationForest(n_estimators = 800
                      , max_samples = 0.15
                      , max_features = 0.1
                      , random_state = 412)

# fit it to the same training data
isf.fit(train_x)

# calculate the anomaly score of each test observation (lower values are more anomalous)
isfPreds1Test = isf.score_samples(test_x)

# use a custom threshold to convert these to binary scores
isfThresh = np.percentile(isfPreds1Test, 100 * (obsRate / 2))
isfPredsCTest = 1 * (isfPreds1Test < isfThresh)

Объединение оценок аномалий с оценками контролируемой модели обеспечивает дополнительную информацию.

# combine predictions, anomaly scores, and survival data
comb = pd.concat([pd.Series(gbtPredsCTest), pd.Series(isfPredsCTest), pd.Series(test_y)]
                 , keys = ['Prediction', 'Outlier', 'badFlight']
                 , axis = 1)
comb = comb.assign(Correct = 1 * (comb.badFlight == comb.Prediction))

print(comb.mean())
#Prediction    0.159676
#Outlier       0.079942
#badFlight     0.153239
#Correct       0.774294
#dtype: float64

# better accuracy in majority class
print(comb.groupby('badFlight').agg(accuracy = ('Correct', 'mean')))
 #          accuracy
#badFlight          
#0.0        0.862923
#1.0        0.284553

# more bad flights among outliers
print(comb.groupby('Outlier').agg(badFlightRate = ('badFlight', 'mean')))

#        badFlightRate
#Outlier               
#0             0.148951
#1             0.202597

Здесь следует отметить несколько вещей. Во-первых, контролируемая модель лучше предсказывает «хорошие» полеты, чем «плохие» — это обычная динамика в прогнозировании редких событий, и поэтому важно учитывать такие показатели, как точность и полнота, помимо простой точности. Более интересным является тот факт, что частота «плохих полетов» почти в 1,5 раза выше среди полетов, классифицированных изолированным лесом как аномальные. И это несмотря на то, что изолированный лес является неконтролируемым методом и выявляет нетипичные полеты в целом, а не полеты, которые нетипичны в определенном смысле, которого я хотел бы избежать. Похоже, это должна быть ценная информация для модели под наблюдением. Флаг двоичного выброса уже находится в хорошем формате для использования в качестве предиктора в моей модели с учителем, поэтому я добавлю его и посмотрю, улучшит ли он производительность модели.

# build a second model with outlier labels as input features
isfPreds1Train = isf.score_samples(train_x)
isfPredsCTrain = 1 * (isfPreds1Train < isfThresh)

mn = isfPreds1Train.min(); rng = isfPreds1Train.max() - isfPreds1Train.min()
isfPreds1SCTrain = (isfPreds1Train - mn) / rng
isfPreds1SCTest = (isfPreds1Test - mn) / rng

train_2_x = (pd.concat([train_x, pd.Series(isfPredsCTrain)]
                       , axis = 1)
             .rename(columns = {0:'isfPreds1'}))
test_2_x = (pd.concat([test_x, pd.Series(isfPredsCTest)]
                      , axis = 1)
            .rename(columns = {0:'isfPreds1'}))

# build model
gbt2 = GradientBoostingClassifier(learning_rate = 0.1
                                  , n_estimators = 100
                                  , subsample = 0.7
                                  , max_depth = 5
                                  , random_state = 412)

# fit it to the training data
gbt2.fit(train_2_x, train_y)

# calculate the probability scores for each test observation
gbt2Preds1Test = gbt2.predict_proba(test_2_x)[:,1]

# use a custom threshold to convert these to binary scores
gbtThresh = np.percentile(gbt2Preds1Test, 100 * (1 - obsRate))
gbt2PredsCTest = 1 * (gbt2Preds1Test > gbtThresh)

# check accuracy of model
acc = accuracy_score(gbt2PredsCTest, test_y)
print(acc)
#0.7796926910299004

# check lift
topDecile = test_y[gbt2Preds1Test > np.percentile(gbt2Preds1Test, 90)]
lift = sum(topDecile) / len(topDecile) / test_y.mean()
print(lift)
#1.9138477764819217

# view confusion matrix
cm = (confusion_matrix(gbt2PredsCTest, test_y) / len(test_y)).round(2)
print(cm)
#[[0.73 0.11]
# [0.11 0.05]]

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

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