Как использовать методы обнаружения аномалий для улучшения контролируемого обучения
Традиционная прогнозная аналитика предлагает две парадигмы для рассмотрения большинства проблем: точечная оценка и классификация. Современная наука о данных в значительной степени связана с последним, формулируя многие вопросы с точки зрения категоризации (подумайте о том, как страховщик может попытаться определить, какие клиенты будут генерировать высокие затраты, а не прогнозировать затраты для каждого клиента; или как маркетолог может быть более заинтересован в котором реклама будет возвращать положительный 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]]
Включение статуса выброса в качестве предиктора в контролируемую модель на самом деле повышает ее верхний дециль на несколько пунктов. Кажется, что быть «странным» неопределенным образом достаточно коррелирует с моим желаемым результатом, чтобы обеспечить предсказательную силу.
Конечно, есть пределы полезности этой причуды. Это, конечно, не верно для всех проблем с несбалансированной классификацией и не особенно полезно, если объяснимость очень важна для конечного продукта. Тем не менее, этот альтернативный фрейм может дать полезную информацию о различных проблемах классификации, и его стоит попробовать.