- Vorverarbeitung
- Leistung vs. Geschäftsmetriken
- Implementierung der tatsächlichen Klassifikation
- Bestimmung der einflussreichen Merkmale
- Bonus: Vergleich der Ergebnisse mit AutoML
- Sources
⚠️ Dieser Text wurde aus dem Englischen Original maschinell übersetzt.
In diesem Beitrag möchte ich mich darauf konzentrieren, Klassifikationsalgorithmen hinsichtlich geschäftlicher Kennzahlen zu vergleichen. Außerdem werden wir einige Methoden zur Verbesserung des ML-Arbeitsablaufs betrachten.
Unsere Daten stammen von hier. Sie enthalten einige Merkmale von Kunden eines Telekommunikationsanbieters und - als Klassifikationsvariable - die Information, ob ein Kunde im nächsten Monat nach der Aufzeichnung der Merkmale abgewandert ist, d.h. ob er seinen Vertrag gekündigt hat.
Unser Ziel heute ist es, herauszufinden, welche Faktoren die Abwanderung in unserer Kundenbasis verhindern und welche anderen Faktoren sie fördern. Wir werden versuchen, eine Klassifikation zu erstellen, die akzeptable Ergebnisse liefert, und dann herausfinden, welche Merkmale am meisten für die Entscheidung des Algorithmus verantwortlich sind, einen Datensatz als Abwanderer oder Nicht-Abwanderer zu klassifizieren.
Darüber hinaus werden wir definieren, was ein akzeptables Ergebnis ausmacht, indem wir unsere eigene Erfolgsmetrik berechnen, die eng mit unserem Geschäftsfall verknüpft ist, anstatt uns ausschließlich auf technische Standardmetriken wie Genauigkeit zu verlassen.
Vorverarbeitung Link to heading
Der Datenverarbeitungsteil ist nicht besonders relevant für die Bewertung der auf die Daten angewandten Algorithmen, aber wir werden uns einige Tipps und Tricks zur Verbesserung des Datenverarbeitungsteils ansehen, um die Dinge interessant zu gestalten.
from functools import partial
from IPython import get_ipython
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pandas.io.formats.style
from sklearn.decomposition import PCA
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.metrics import make_scorer, accuracy_score
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
import warnings
%matplotlib inline
pd.set_option("display.precision", 2)
warnings.filterwarnings('ignore')
def add_raw_tag(df):
return "
\n" + df.to_html(classes="nb-table") + "\n\n"
html_formatter = get_ipython().display_formatter.formatters['text/html']
html_formatter.for_type(
pd.DataFrame,
lambda df: add_raw_tag(df)
)
html_formatter.for_type(
pd.io.formats.style.Styler,
lambda df: add_raw_tag(df)
)
<function __main__.<lambda>(df)>
Lassen Sie uns die Daten in ein Pandas-Datenframe einlesen und sie uns zunächst einmal ansehen.
Tipp: Wenn Sie versuchen, dieses Notebook zu reproduzieren, legen Sie eine Kopie der Daten in ein Unterverzeichnis “/data/” im Ordner ab, in dem sich Ihr Notebook befindet.
df = pd.read_csv('./data/WA_Fn-UseC_-Telco-Customer-Churn.csv', decimal='.')
df.head().T # transposed for readability
0 | 1 | 2 | 3 | 4 | |
---|---|---|---|---|---|
customerID | 7590-VHVEG | 5575-GNVDE | 3668-QPYBK | 7795-CFOCW | 9237-HQITU |
gender | Female | Male | Male | Male | Female |
SeniorCitizen | 0 | 0 | 0 | 0 | 0 |
Partner | Yes | No | No | No | No |
Dependents | No | No | No | No | No |
tenure | 1 | 34 | 2 | 45 | 2 |
PhoneService | No | Yes | Yes | No | Yes |
MultipleLines | No phone service | No | No | No phone service | No |
InternetService | DSL | DSL | DSL | DSL | Fiber optic |
OnlineSecurity | No | Yes | Yes | Yes | No |
OnlineBackup | Yes | No | Yes | No | No |
DeviceProtection | No | Yes | No | Yes | No |
TechSupport | No | No | No | Yes | No |
StreamingTV | No | No | No | No | No |
StreamingMovies | No | No | No | No | No |
Contract | Month-to-month | One year | Month-to-month | One year | Month-to-month |
PaperlessBilling | Yes | No | Yes | No | Yes |
PaymentMethod | Electronic check | Mailed check | Mailed check | Bank transfer (automatic) | Electronic check |
MonthlyCharges | 29.85 | 56.95 | 53.85 | 42.3 | 70.7 |
TotalCharges | 29.85 | 1889.5 | 108.15 | 1840.75 | 151.65 |
Churn | No | No | Yes | No | Yes |
Wir können sehen, dass der Datensatz größtenteils kategoriale Variablen enthält, wie beispielsweise die Abonnements eines bestimmten Dienstes im Portfolio des Telekommunikationsanbieters. Es gibt einige kontinuierliche Merkmale wie ’tenure’ und ‘TotalCharges’. Schließlich gibt es eine Spalte ‘Churn’, die uns mitteilt, ob der Kunde seinen Vertrag im letzten Monat gekündigt hat. Unser Ziel ist es nun, diejenigen Merkmale zu identifizieren, die in der Entscheidung eines Benutzers zur Kündigung signifikant sind, und vorherzusagen, ob ein Benutzer im nächsten Monat kündigen wird.
Eine Anmerkung zur Realitätsnähe Link to heading
Obwohl der Datensatz viele Merkmale enthält und wir einige weitere entwickeln werden, gibt es einige Probleme, die berücksichtigt werden müssen:
- Jemand, der nicht gekündigt hat, könnte ‘morgen’ kündigen oder in ausgefallenen Begriffen: Die Daten sind rechtszensiert. Das bedeutet, unser Kunde könnte immer noch als aktiv gekennzeichnet sein, obwohl seine Entscheidung zur Kündigung möglicherweise bereits getroffen wurde.
- Es sind keine externen Faktoren bekannt: Die Preise der Wettbewerber sind unbekannt, ob überhaupt lokale Wettbewerber vorhanden sind, ist unbekannt.
- Es ist unbekannt, ob der Kunde technische oder Abrechnungsprobleme hatte, und ob es laufende oder abgeschlossene Kundendienstprobleme gibt.
- Der Preisniveau der einzelnen Dienste ist unbekannt, ebenso wie deren Nutzungshäufigkeit durch den Kunden.
- Es ist unbekannt, ob Kundenbindungsmethoden wie Rabatte angewendet wurden.
- Es liegen keine Informationen zur Dienstqualität wie Bandbreite vor.
- Der Datensatz ist statisch. In einer realen Situation gäbe es eine Zeitleiste mit bestimmten Ereignissen wie Kundendienstproblemen und Vertragsverlängerungen.
Der Datensatz hat also nur wenig gemeinsam mit den Daten, die einem Telekommunikationsanbieter in der Realität zur Verfügung stehen würden. Er ist realistisch im Sinne dessen, dass es sich nicht um simulierte Daten handelt (soweit ich das beurteilen kann) und es gibt keine klare, konstruierte Beziehung zwischen den Merkmalen und dem Ergebnis. Daher wissen wir derzeit nicht, ob überhaupt ein interpretierbares Ergebnis erzielt werden kann.
Unser Hauptaugenmerk liegt jedoch auf der Leistung und den Optimierungsmöglichkeiten mehrerer Machine-Learning-Algorithmen. Diese Probleme mit den Daten sind daher nicht unser Hauptanliegen.
Logging Link to heading
Später werden wir einen Arbeitsablauf definieren, der den Pandas Pipe-Operator verwendet, um verschiedene Funktionen zu verketten, die Vorverarbeitungsschritte enthalten werden.
Zunächst einmal werden wir jedoch eine Dekorationsfunktion schreiben, die Metainformationen zu jedem einzelnen Vorverarbeitungsschritt zurückgibt. Dies dient wiederum als vorbeugende Maßnahme gegen das Einführen von Fehlern in unseren Code, wenn sie auf die Schritte der Datenverarbeitungspipeline angewendet wird.
Es wird die Anzahl der Zeilen und Spalten des vorverarbeiteten Dataframes ausgeben, zusammen mit der Anzahl und dem Beispielinhalt von Zeilen, die ‘NaN’-Werte enthalten.
def logger(function):
def wrap(df, *args, **kwargs):
# do the actual preprocessing step
result = function(df, *args, **kwargs)
# print rows, columns and no. of rows with nans
# dunder variable name is built-in
name = function.__name__
print(
f'{name:20}: shape = {result.shape} nan rows = {(result.shape[0] - result.dropna().shape[0])} '
)
# print sample content of nan rows, if there are any
nan_rows = result[result.isnull().any(axis=1)]
if len(nan_rows) > 0:
print(nan_rows.head())
return result
return wrap
Vorverarbeitungspipeline Link to heading
Mit dem abgeschlossenen Protokollierungsdekorator können wir uns der eigentlichen Datenverarbeitung zuwenden.
Um später schneller auf die Merkmale zugreifen zu können, listen wir verschiedene Teilmengen von Spalten auf. Wir gruppieren sie nach Thema und Typ der Spalten, damit wir ähnliche Operationen auf jede Gruppe oder auf die Gruppe als Ganzes anwenden können. Das wird die Vorverarbeitung erleichtern.
# list all the feature category columns
cat_cols = [
'gender', 'Partner', 'Dependents', 'PhoneService', 'MultipleLines',
'InternetService', 'OnlineSecurity', 'OnlineBackup', 'DeviceProtection',
'TechSupport', 'StreamingTV', 'StreamingMovies', 'Contract',
'PaperlessBilling', 'PaymentMethod', 'Churn'
]
# list all service options
service_cols = [ 'PhoneService', 'MultipleLines',
'InternetService', 'OnlineSecurity', 'OnlineBackup', 'DeviceProtection',
'TechSupport', 'StreamingTV', 'StreamingMovies'
]
# list the numeric columns
float_cols = ['tenure','MonthlyCharges', 'TotalCharges']
Wir verwerfen die Kunden-ID, da sie keine Informationen enthält. Außerdem gibt es einige Zeilen, die ein leeres ‘TotalCharges’-Feld haben, daher werden wir diese entfernen, da unsere Algorithmen vollständige Zeilen erfordern. Beachten Sie, dass wir diese auch imputieren könnten, aber unser Datensatz groß genug ist, um ein paar Zeilen zu verschonen.
Wir führen auch einige Datenbereinigungsoperationen durch, um sicherzustellen, dass alle Spalten, die numerische Werte enthalten, nicht als Zeichenketten behandelt werden. Außerdem codieren und skalieren wir die Daten, um den Spalten ‘Vertragsdauer’, ‘Monatliche Gebühren’ und ‘Gesamtkosten’ nicht zu viel Gewicht zu geben, da sie im Tausenderbereich liegen können, während die Dienstspalten Werte bis zu “6” ergeben können.
@logger
def copy_df(df):
# deep copy of DataFrame as not to alter the original data
return df.copy()
@logger
def drop_id(df):
# this does not carry any information
df = df.drop(['customerID'], axis=1)
return df
@logger
def remove_whitespace(df):
# some entries seem to be empty,but for some reason they
# are not "nan" but just some spaces
df = df[~(df['TotalCharges'] == ' ')]
# strip out all leading and following
# whitespaces if there are any
for col in df.columns:
if df.dtypes[col] == object:
df[col] = df[col].str.strip()
return df
@logger
def ensure_formats(df, float_cols=float_cols, cat_cols=cat_cols):
for col in float_cols:
df[col] = df[col].astype(float)
for col in cat_cols:
df[col] = df[col].astype('category')
return df
@logger
def encode_and_scale(df, float_cols=float_cols, cat_cols=cat_cols):
# create one hot encoded version of the categorical variables
encoded_columns = pd.get_dummies(df[cat_cols], drop_first=True)
# scale the float columns
scaler = StandardScaler()
scaled_columns = scaler.fit_transform(df[float_cols])
# rebuild the dataframe with the modified columns
df = pd.DataFrame(np.concatenate(
(np.array(scaled_columns), np.array(encoded_columns)), axis=1),
columns=list(float_cols) + list(encoded_columns.columns))
return df
Dann führen wir eine Pipeline auf dem ursprünglichen DataFrame aus, wobei eine Funktion nach der anderen angewendet wird. Unsere Protokollfunktion zeigt, dass wir auf dem Weg nur 9 Zeilen verloren haben, was nur leicht mehr als 1% des Datensatzes ausmacht.
df_work = (df
.pipe(copy_df)
.pipe(drop_id)
.pipe(remove_whitespace)
.pipe(ensure_formats)
.pipe(encode_and_scale)
)
copy_df : shape = (7043, 21) nan rows = 0
drop_id : shape = (7043, 20) nan rows = 0
remove_whitespace : shape = (7032, 20) nan rows = 0
ensure_formats : shape = (7032, 20) nan rows = 0
encode_and_scale : shape = (7032, 30) nan rows = 0
Unser df_work, auf dem wir den Rest dieses Experiments durchführen werden, während die ursprünglichen Daten vollständig unberührt bleiben, sieht jetzt so aus:
df_work.head(3).T # transposed again for better readability
0 | 1 | 2 | |
---|---|---|---|
tenure | -1.28 | 0.06 | -1.24 |
MonthlyCharges | -1.16 | -0.26 | -0.36 |
TotalCharges | -0.99 | -0.17 | -0.96 |
gender_Male | 0.00 | 1.00 | 1.00 |
Partner_Yes | 1.00 | 0.00 | 0.00 |
Dependents_Yes | 0.00 | 0.00 | 0.00 |
PhoneService_Yes | 0.00 | 1.00 | 1.00 |
MultipleLines_No phone service | 1.00 | 0.00 | 0.00 |
MultipleLines_Yes | 0.00 | 0.00 | 0.00 |
InternetService_Fiber optic | 0.00 | 0.00 | 0.00 |
InternetService_No | 0.00 | 0.00 | 0.00 |
OnlineSecurity_No internet service | 0.00 | 0.00 | 0.00 |
OnlineSecurity_Yes | 0.00 | 1.00 | 1.00 |
OnlineBackup_No internet service | 0.00 | 0.00 | 0.00 |
OnlineBackup_Yes | 1.00 | 0.00 | 1.00 |
DeviceProtection_No internet service | 0.00 | 0.00 | 0.00 |
DeviceProtection_Yes | 0.00 | 1.00 | 0.00 |
TechSupport_No internet service | 0.00 | 0.00 | 0.00 |
TechSupport_Yes | 0.00 | 0.00 | 0.00 |
StreamingTV_No internet service | 0.00 | 0.00 | 0.00 |
StreamingTV_Yes | 0.00 | 0.00 | 0.00 |
StreamingMovies_No internet service | 0.00 | 0.00 | 0.00 |
StreamingMovies_Yes | 0.00 | 0.00 | 0.00 |
Contract_One year | 0.00 | 1.00 | 0.00 |
Contract_Two year | 0.00 | 0.00 | 0.00 |
PaperlessBilling_Yes | 1.00 | 0.00 | 1.00 |
PaymentMethod_Credit card (automatic) | 0.00 | 0.00 | 0.00 |
PaymentMethod_Electronic check | 1.00 | 0.00 | 0.00 |
PaymentMethod_Mailed check | 0.00 | 1.00 | 1.00 |
Churn_Yes | 0.00 | 0.00 | 1.00 |
Als nächstes werden wir Trainings- und Testdatensätze aus unseren Daten erstellen. Obwohl wir die Kreuzvalidierung bei der Schulung der Klassifikatoren verwenden werden, ist es immer besser, eine weitere Gruppe von Daten separat vom Schulungsprozess als ultimativen Test aufzubewahren.
Die Klassen im Datensatz sind ziemlich unausgewogen, daher ist eine Stratifikation notwendig, d.h. nicht 33% der Zeilen werden zufällig ausgewählt, sondern es ist garantiert, dass 33% der Zeilen von Kunden, die gekündigt haben, und 33% der Nicht-Kündiger ausgewählt werden. In der Realität würden Sie wahrscheinlich eine noch größere Diskrepanz erwarten.
Normalerweise verlieren Sie nicht 36% Ihrer Kundenbasis in einem Monat (hoffentlich).
yes_no_ratio=len(df[df['Churn']=='Yes'])/len(df[df['Churn']=='No'])
print(yes_no_ratio)
0.36122922303826827
y=df_work['Churn_Yes']
X=df_work.drop(['Churn_Yes'], axis=1)
X_train, X_test, y_train, y_test = train_test_split(X,
y,
test_size=0.33,
random_state=42,
stratify=y)
Beachten Sie, dass der Name der Spalte, die die Information darüber enthält, ob der Kunde gekündigt hat oder nicht, von “Churn” auf “Churn_Yes” geändert wurde, als Ergebnis der One-Hot-Codierung. Wir könnten ihn natürlich zurückändern, aber ich bevorzuge es, ihn als Erinnerung an die Codierung zu behalten, für potenzielle Debugging-Zwecke.
Als nächster Schritt möchte ich ein qualitatives Gefühl für den Datensatz bekommen. Dieser Schritt ist völlig optional, gibt uns jedoch eine Vorstellung davon, wie gut Klassifikationsalgorithmen abschneiden könnten.
Ein Gefühl für die Topologie des Merkmalssets bekommen Link to heading
Das Merkmalsset ist hochdimensional, und wir können nicht leicht erkennen, wie gut wir die Kündiger von den Nicht-Kündigern trennen können. Um den Datensatz zu approximieren, können wir eine Hauptkomponentenanalyse (PCA) und eine lineare Diskriminanzanalyse (LDA) verwenden.
Wir beginnen mit einer PCA, die versucht, die gesamte Varianz des Merkmalssets in einer definierten Anzahl von kombinierten Variablen zusammenzufassen. Sie eliminiert Merkmale, die “im Gleichklang” mit anderen Merkmalen verlaufen und daher keine zusätzlichen Informationen liefern. Daher können sie ohne großen Informationsverlust weggelassen werden.
pca = PCA(n_components=2)
X_r = pca.fit(X_train).transform(X_train)
# Percentage of variance explained for each components
print('explained variance ratio (first two components): %s'
% str(pca.explained_variance_ratio_))
plt.figure()
colors = ['navy', 'orange']
labels = ['non-churners', 'churners']
for color, i, target_name in zip(colors, [0, 1], y):
plt.scatter(X_r[y_train == i, 0], X_r[y_train == i, 1], color=color, alpha=.8, lw=1,facecolors="None",
label=labels[i])
plt.title('PCA of dataset')
plt.xlabel('PC 1')
plt.ylabel('PC 2')
plt.legend()
plt.show()
explained variance ratio (first two components): [0.40020818 0.18929489]
Wir erhalten zwei Bündel, die sehr deutlich voneinander zu unterscheiden sind, aber beide eine erhebliche Anzahl von Kündigern und Nicht-Kündigern enthalten. Es gibt hier einige Bereiche, die eindeutig zu den (blauen) Nicht-Kündigern gehören, aber fast keiner der Bereiche, die von den (orangefarbenen) Kündigern belegt sind, ist klar abgegrenzt. Es gibt immer Nicht-Kündiger, die ähnliche Merkmale wie die Kündiger aufweisen. Schließlich war ein Kündiger in diesem Monat im letzten Monat noch ein Nicht-Kündiger.
Es wäre interessant zu wissen, was die sehr klare Unterscheidung zwischen den beiden Bündeln verursacht. Ein Blick auf die Zusammensetzung der Hauptkomponenten könnte uns hier wahrscheinlich Hinweise geben, aber lassen Sie uns nicht ablenken.
Versuchen wir als nächstes die LDA, die versucht, den Merkmalsraum auf eine “Anzahl der Klassen minus 1”-dimensionale Hyperebene abzubilden und maximale lineare Separierbarkeit zu erreichen.
lda = LinearDiscriminantAnalysis(n_components=1)
X_r2 = lda.fit(X_train, y_train).transform(X_train)
plt.hist((X_r2[y_train == 0, 0],X_r2[y_train == 1, 0]), color=colors, density=True,
label=labels)
#plt.legend(loc='best', shadow=False, scatterpoints=1)
plt.title('LDA of dataset')
plt.xlabel('LDA Value')
plt.ylabel('Density')
plt.legend()
plt.show()
Ähnlich wie bei einer Naive-Bayes-Klassifikation können wir jetzt versuchen, den optimalen Wert im abgebildeten Merkmalsraum zu finden, um Orange von Blau zu trennen, d.h. einen Schwellenwert entlang der x-Achse im obigen Graphen.
Offensichtlich führt die Auswahl eines beliebigen Werts entlang der x-Achse zu einer erheblichen Anzahl von Fehlklassifikationen in entweder der Kündigungs- oder Nicht-Kündigungsklasse.
Wenn wir uns die Topologie des Datensatzes ansehen, können wir vorhersehen, dass die Leistung eines beliebigen Klassifikators möglicherweise begrenzt ist, da Kündiger anscheinend sehr ähnlich zu einigen Nicht-Kündigern sind, obwohl dies umgekehrt nicht unbedingt zutrifft.
Leistung vs. Geschäftsmetriken Link to heading
Während die Modelle von sklearn sich auf reine Leistungsmetriken konzentrieren, ist es jetzt - bevor wir irgendwelche Klassifikationsergebnisse erhalten, die unsere Bewertung beeinflussen könnten - an der Zeit, die geschäftlichen Auswirkungen eines Modells zu berücksichtigen, falls es in die Produktion gehen sollte:
- Fehlklassifikation eines Nicht-Kündigers als Kündiger
- Eine falsch positive Klassifikation bedeutet, dass jemand als potenzieller Kündiger gekennzeichnet wird, obwohl er keine Absicht hat, seinen Vertrag tatsächlich zu kündigen.
- Dies kann verschiedene Prozesse auslösen, wie beispielsweise den Kundenservice, der sich mit dem Kunden in Verbindung setzt, und Rabatte, die angeboten werden. Dies verursacht Kosten für das Personal und Opportunitätskosten für die Rabatte.
- Fehlklassifikation eines Kündigers als Nicht-Kündiger
- Ein falsch negatives Ergebnis führt offensichtlich zu den Opportunitätskosten des Verlusts des Kunden, ohne die Möglichkeit zur Intervention.
- Es besteht immer die Möglichkeit, dass der Kunde trotz Intervention kündigt. Die Erfolgsrate der Kundenbindung muss in die Berechnungen einbezogen werden.
Nehmen wir an, ein Kunde zahlt etwa 780 US-Dollar pro Jahr für seinen Vertrag. Dies bedeutet, dass eine falsch positive Klassifikation Kosten von 20 % Rabatt und vernachlässigbaren Kosten für das Personal und die Infrastruktur zur Durchführung der Intervention verursacht, d.h. 155 US-Dollar.
Eine falsch negative Klassifikation hingegen bedeutet einen vollständigen Verlust von 780 US-Dollar, sodass solange unser Erfolgsverhältnis für die Kundenbindung besser ist als 1 zu 5 (offensichtlich das Gegenteil des gewährten Rabatts), es bevorzugt ist, eine falsch positive Klassifikation bis zu einem bestimmten Verhältnis zu haben.
Natürlich handelt es sich bei all diesen Werten um Annahmen und müssten durch die Auswertung von Daten aus den Bereichen Marketing, Call Center, Kundenbindungsexperten usw. überprüft werden.
In Bezug auf unsere heutige Übung bedeutet dies, dass wir uns speziell darauf konzentrieren, die Kosten für Fehlklassifikationen zu minimieren. In Bezug auf die KPIs des Modells entspricht dies der Maximierung der Recall-Rate in der Kündiger-Klasse, während die Precision-Rate in der Nicht-Kündiger-Klasse über einem bestimmten Schwellenwert gehalten wird.
df['MonthlyCharges'].mean()*12
777.1403095271901
df['MonthlyCharges'].mean()*12*0.2
155.42806190543803
Um den direkten geschäftlichen Einfluss zu messen, werden wir einen benutzerdefinierten Scorer mit der Methode make_scorer
erstellen. Dieser kann dann als Kostenfunktion für den Trainingsprozess der Klassifikationsalgorithmen verwendet werden.
def cost_metric(y_test,
y_pred,
retention_success=0.33,
discount=0.2,
yearly_base=777):
cost_false_negative = retention_success * yearly_base
cost_false_positive = discount * yearly_base
misclass = np.subtract(y_test, y_pred)
fp = np.count_nonzero(misclass < 0) # false positives
fn = np.count_nonzero(misclass > 0) # false negatives
score = fp * cost_false_positive + fn * cost_false_negative
return score
# Make scorer and define that higher scores are better
# since we want to minimize our cost, greater is worse
# therefore the scorer simply multiplies with -1
score = make_scorer(cost_metric, greater_is_better=False)
Wir könnten diese Idee noch weiter ausbauen, indem wir Buckets für die monatlichen Gebühren der Kunden erstellen und sie gewichten, sodass Fehler bei Kunden mit höherem Wert stärker bestraft werden.
Nun, da wir eine Methode haben, um die Leistung unserer Algorithmen zu beurteilen, und saubere und geordnete Daten haben, können wir zur tatsächlichen Durchführung der Klassifikation übergehen.
Implementierung der tatsächlichen Klassifikation Link to heading
Führen wir unser Experiment mit diesen Klassifikationsalgorithmen durch:
classifiers = [
SVC, DecisionTreeClassifier, RandomForestClassifier, LogisticRegression
]
Wir werden sie zuerst einfach so verwenden, wie sie sind, d.h. ohne weitere Feinabstimmung. Dann werden wir eine Hyperparameter-Optimierung durchführen und schließlich das Ganze mit einem erweiterten Merkmalsatz wiederholen. Dies gibt uns 16 verschiedene Strategien, die wir hinsichtlich ihrer technischen und geschäftlichen Metrikleistung vergleichen können.
Um sich nicht zu wiederholen, erstellen wir zunächst eine Hilfsfunktion, die das Training und die Klassifikation durchführt, sowie eine Genauigkeitsbewertung und eine Bewertung gemäß der von uns erstellten Geschäftsbewertung oben.
def train_and_classify(classifier, *args, **kwargs):
# classifier class is first class object
# we can use it a function variable
clf_churn = classifier(*args, **kwargs)
clf_churn.fit(X_train, y_train)
acc = accuracy_score(y_test, clf_churn.predict(X_test))
business_metric = score(clf_churn, X_test, y_test)
return (clf_churn, acc, business_metric)
# create two dataframes to store the results
results_acc = pd.DataFrame()
results_cost = pd.DataFrame()
Algorithmen “Out of the Box” Link to heading
Dann führen wir die Klassifizierung durch und erfassen die Genauigkeit sowie die Geschäftsmetriken in unserem DataFrame. Es sieht etwas einschüchternd aus, aber die meiste Arbeit besteht darin, die Informationen aus dem trainierten Klassifikator zu extrahieren und das Ergebnis zu formatieren.
for classifier in classifiers:
(clf, acc, business_metric) = train_and_classify(classifier)
# extract name of classfier, save accuracy and cost to dataframes
# out of the box-classifiers
results_acc.loc[str.split(str(clf), '(')[0], 'OOTB'] = acc
results_cost.loc[str.split(str(clf), '(')[0], 'OOTB'] = business_metric
#also print results here, to motivate us to keep going
print(
f"{str.split(str(clf),'(')[0]:30} acc= {acc:10.3} cost={business_metric:10.7}"
)
SVC acc= 0.799 cost= -104895.0
DecisionTreeClassifier acc= 0.724 cost= -129456.0
RandomForestClassifier acc= 0.785 cost= -110978.9
LogisticRegression acc= 0.8 cost= -99533.7
Algorithmen mit Hyperparameter-Optimierung Link to heading
Als nächsten Schritt werden wir sklearn eine Reihe von Parametern für jeden Klassifikationsalgorithmus zur Verfügung stellen und dann eine Rastersuche über all diesen Parametern durchführen. Anschließend wählen wir den Satz von Parametern aus, der uns für jeden Algorithmus das beste Ergebnis liefert.
Auch hier erstellen wir wieder einen Wrapper, der die Rastersuche und das Training behandelt, um das Kopieren und Einfügen zu vermeiden.
def train_optimize_classify(classifier, *args, **kwargs):
clf_churn_search = classifier()
# Here we can use the custom scorer
# to ask the GridSearch to return
# the hyperparameters with the
# lowest opportunity cost
search = GridSearchCV(clf_churn_search,
*args,
**kwargs,
cv=5,
verbose=0,
n_jobs=8,
scoring=score)
search.fit(X_train, y_train)
acc = accuracy_score(y_test, search.predict(X_test))
business_metric = score(search, X_test, y_test)
#print(classification_report(y_test, search.predict(X_test)))
#print("Accuracy Score: ", acc)
return (search, acc, business_metric)
Übrigens: Das Ersetzen der Bewertung in dem obigen Aufruf durch ‘recall’ sollte zu ähnlichen Ergebnissen führen, da das Erinnern an möglichst viele Abwanderer (ohne zu stark darauf zu fokussieren) bedeutet, die Kostenfunktion zu minimieren.
Wir definieren alle Parameter, die wir pro Methode durchlaufen möchten. Da die Methoden in ihrem Konzept stark variieren, haben wir hier mit einer breiten Vielfalt von Parametern zu tun.
# choosing some of the weights proportional to the cost in order to
# see if the additional weighting improves anything
param_grid_svc = {
'C': [1, 2, 4, 8],
'kernel': ['rbf', 'sigmoid'],
'class_weight': [None, {
0: 1.35,
1: 2.44
}],
'decision_function_shape': ['ovo', 'ovr'],
'random_state': [42]
}
param_grid_decision_tree = {
'criterion': ['gini', 'entropy'],
'min_samples_split': [2, 10, 20],
'class_weight': [None, {
0: 1.35,
1: 2.44
}],
'random_state': [42]
}
param_grid_random_forest = {
'criterion': ['gini', 'entropy'],
'min_samples_split': [2, 10, 20],
'class_weight': [None, {
0: 1.35,
1: 2.44
}],
'random_state': [42]
}
param_grid_log_reg = {
'C': [1, 10, 100, 1000],
'penalty': ['l2'],
'dual': [False],
'class_weight': [None, {
0: 1.35,
1: 2.44
}],
'random_state': [42]
}
param_grids = [
param_grid_svc, param_grid_decision_tree, param_grid_random_forest,
param_grid_log_reg
]
Dann durchlaufen Sie alle Klassifizierer und lassen Sie unseren Wrapper die Arbeit des Trainings, der Kreuzvalidierung und der Auswahl des besten Parameter-Sets erledigen.
Wir erfassen die Ergebnisse erneut in unseren Datenrahmen zur späteren Vergleich.
for classifier, param_grid in list(zip(classifiers, param_grids)):
(clf, acc,
business_metric) = train_optimize_classify(classifier,
param_grid=param_grid)
results_acc.loc[str.split(str(clf.__dict__['estimator']), '(')[0],
'Optimized'] = acc
results_cost.loc[str.split(str(clf.__dict__['estimator']), '(')[0],
'Optimized'] = business_metric
print(
f"{str.split(str(clf.__dict__['estimator']),'(')[0]:30} acc= {acc:10.6} cost={business_metric:10.7}"
)
SVC acc= 0.774666 cost= -99759.03
DecisionTreeClassifier acc= 0.758294 cost= -117785.4
RandomForestClassifier acc= 0.792331 cost= -97529.04
LogisticRegression acc= 0.774666 cost= -98445.9
Feature Engineering mit und ohne Hyperparameter-Optimierung Link to heading
Als letzter Abschnitt unserer Reise werden wir ein paar weitere Merkmale entwickeln, bevor wir versuchen, den Datensatz erneut zu klassifizieren. Dazu definieren wir zusätzliche Transformationen, die wir unserer Pipeline hinzufügen, und führen dann die Pipeline erneut auf dem ursprünglichen Datensatz aus.
Als zusätzliche Merkmale verwenden wir:
- die Anzahl der verschiedenen Dienste, die ein Benutzer in seinem Vertrag hat.
- die relative Kosten des Vertrags im Vergleich zu allen Kunden mit der gleichen Kombination von Diensten.
- ob die monatlichen Gebühren über oder unter dem Durchschnitt der monatlichen Gebühren für diesen Vertrag liegen, als Indikator, ob der Kunde den Vertrag während seiner Laufzeit aufgerüstet hat.
@logger
def count_services_and_generate_codes(df):
df['service_count'] = df[service_cols].apply(
lambda x: np.sum([x_el == 'Yes' for x_el in x]), axis=1)
df['all_services_code'] = df[service_cols].astype(str).apply(
lambda x: ''.join(x), axis=1)
return df
@logger
def join_and_calculate_relative_cost(df):
df_look_up_service_charges = df.groupby(['all_services_code']).agg(
{'MonthlyCharges': ['min', 'max']})
df_look_up_service_charges.columns = ['min', 'max']
df = df.join(df_look_up_service_charges, on='all_services_code')
df['rel_price'] = (df['MonthlyCharges'] - df['min']) / (df['max'] -
df['min'])
df['rel_price'] = df['rel_price'].fillna(1)
return df
@logger
def up_or_downgrade(df):
df['difference_monthly_total'] = df['tenure'] * df['MonthlyCharges'] - df[
'TotalCharges']
return df
@logger
def drop_redundant_and_na(df):
df = df.drop(['TotalCharges'], axis=1)
#df = df.drop(['TotalCharges', 'all_services_code', 'max', 'min'], axis=1)
df = df.dropna()
return df
# create an extended version of
# the encode and scale function
# since we now have two more numeric
# features
encode_and_scale_added_features = partial(
encode_and_scale,
float_cols=float_cols + ['difference_monthly_total', 'rel_price'])
df_work = (df
.pipe(copy_df)
.pipe(drop_id)
.pipe(remove_whitespace)
.pipe(count_services_and_generate_codes)
.pipe(join_and_calculate_relative_cost)
.pipe(ensure_formats)
.pipe(up_or_downgrade)
.pipe(encode_and_scale_added_features)
.pipe(drop_redundant_and_na)
)
copy_df : shape = (7043, 21) nan rows = 0
drop_id : shape = (7043, 20) nan rows = 0
remove_whitespace : shape = (7032, 20) nan rows = 0
count_services_and_generate_codes: shape = (7032, 22) nan rows = 0
join_and_calculate_relative_cost: shape = (7032, 25) nan rows = 0
ensure_formats : shape = (7032, 25) nan rows = 0
up_or_downgrade : shape = (7032, 26) nan rows = 0
encode_and_scale : shape = (7032, 32) nan rows = 0
drop_redundant_and_na: shape = (7032, 31) nan rows = 0
Wir müssen auch die Trainings- und Testsets neu erstellen, da sie jetzt auf dem erweiterten Datensatz basieren.
y = df_work['Churn_Yes']
X = df_work.drop(['Churn_Yes'], axis=1)
X_train, X_test, y_train, y_test = train_test_split(X,
y,
test_size=0.33,
random_state=42,
stratify=y)
Dann führen wir einfach die Klassifikation erneut auf dem erweiterten Datensatz für alle Algorithmen durch, zeichnen die Ergebnisse auf und führen den Vorgang erneut mit dem neuen Datensatz und der Hyperparameteroptimierung durch.
# without hyperparameter optimization
for classifier in classifiers:
# trains, classifies and evaluates
(clf, acc, business_metric) = train_and_classify(classifier)
# extract name of classfier, save accuracy and cost to dataframes
results_acc.loc[str.split(str(clf), '(')[0], 'OOTB w/ feature engineering'] = acc
results_cost.loc[str.split(str(clf), '(')[0], 'OOTB w/ feature engineering'] = business_metric
#also print results here, to motivate us to keep going
print(
f"{str.split(str(clf),'(')[0]:30} acc= {acc:10.3} cost={business_metric:10.7}"
)
SVC acc= 0.795 cost= -106239.2
DecisionTreeClassifier acc= 0.721 cost= -131709.3
RandomForestClassifier acc= 0.795 cost= -106643.3
LogisticRegression acc= 0.799 cost= -100093.1
# with hyperparameter optimization
for classifier, param_grid in list(zip(classifiers, param_grids)):
(clf, acc,
business_metric) = train_optimize_classify(classifier,
param_grid=param_grid)
results_acc.loc[str.split(str(clf.__dict__['estimator']), '(')[0],
'Optimized w/ Feature Engineering'] = acc
results_cost.loc[str.split(str(clf.__dict__['estimator']), '(')[0],
'Optimized w/ Feature Engineering'] = business_metric
print(
f"{str.split(str(clf.__dict__['estimator']),'(')[0]:30} acc= {acc:10.6} cost={business_metric:10.7}"
)
SVC acc= 0.770358 cost= -101313.0
DecisionTreeClassifier acc= 0.753555 cost= -120100.9
RandomForestClassifier acc= 0.792762 cost= -97575.66
LogisticRegression acc= 0.77682 cost= -98577.99
Das bringt uns zu den Ergebnissen der gesamten Übung, nämlich darüber zu beraten, welche Strategie anzuwenden ist, um die geschäftliche Wirkung der Ausführung eines Algorithmus zu maximieren, der versucht, die Abbrecher des nächsten Monats vorherzusagen.
Schauen wir uns also den Genauigkeitswert an:
results_acc.style.highlight_max(color='lightgreen', axis=None)
OOTB | Optimized | OOTB w/ feature engineering | Optimized w/ Feature Engineering | |
---|---|---|---|---|
SVC | 0.798794 | 0.774666 | 0.795347 | 0.770358 |
DecisionTreeClassifier | 0.724257 | 0.758294 | 0.720810 | 0.753555 |
RandomForestClassifier | 0.785006 | 0.792331 | 0.795347 | 0.792762 |
LogisticRegression | 0.799655 | 0.774666 | 0.799224 | 0.776820 |
Es scheint, dass die Gesamtgenauigkeit mit der einfachen logistischen Regression am besten ist.
Wenn wir das mit unserer geschäftlichen Metrik vergleichen - den Opportunitätskosten durch Fehlklassifikationen - sehen wir, dass ein völlig anderer Ansatz besser abschneidet:
results_cost.style.highlight_max(color='lightgreen', axis=None)
OOTB | Optimized | OOTB w/ feature engineering | Optimized w/ Feature Engineering | |
---|---|---|---|---|
SVC | -104895.000000 | -99759.030000 | -106239.210000 | -101313.030000 |
DecisionTreeClassifier | -129455.970000 | -117785.430000 | -131709.270000 | -120100.890000 |
RandomForestClassifier | -110978.910000 | -97529.040000 | -106643.250000 | -97575.660000 |
LogisticRegression | -99533.700000 | -98445.900000 | -100093.140000 | -98577.990000 |
Hinweis: Wir berechnen hier die Kosten, aber eine negative Zahl bedeutet nicht, dass wir negative Kosten, d.h. Gewinn, haben. Das negative Vorzeichen tritt auf, weil wir unserem benutzerdefinierten Scorer gesagt haben, dass größere Werte nicht besser sind, wenn die Kosten berechnet werden. Intern wird dies durch die Multiplikation des Ergebnisses der Kostenfunktion mit ‘-1’ behandelt.
Wir schließen daraus, dass es bei der Bewertung verschiedener Strategien zur Bewältigung eines Klassifikationsproblems nicht ratsam ist, sich auf die Standardbewertung zu verlassen, ohne sie näher zu hinterfragen. Stattdessen sollte man jegliches verfügbare Fachwissen nutzen, um eine Auswertungsmetrik zu definieren, die mit den gesetzten Geschäftszielen in Einklang steht.
Bestimmung der einflussreichen Merkmale Link to heading
Wir können auch den logistischen Regressionsklassifikator auf die Werte der Betas untersuchen, die sich auf die Wahrscheinlichkeit eines Mitglieds der Klasse “1” und auf das Abbrechen beziehen. Größere Werte bedeuten höhere Chancen auf eine Kündigung.
Nach dieser Logik scheinen hohe monatliche Gebühren und Mehrjahresverträge die wichtigsten Faktoren für die Entscheidung eines Kunden, nicht zu kündigen, zu sein.
# our clf variable still contains the last classifier, which is the logistic regression
feature_influence = pd.DataFrame(zip(X.columns,clf.best_estimator_.coef_[0]))
feature_influence.columns = ['Feature', 'beta']
feature_influence.sort_values(by='beta').head(3)
Feature | beta | |
---|---|---|
1 | MonthlyCharges | -3.76 |
8 | MultipleLines_No phone service | -2.56 |
25 | Contract_Two year | -1.42 |
Im Gegensatz dazu scheinen das Streaming-Kundensein und die Nutzung einer Internetverbindung auf Basis von Glasfaser die größten Faktoren zu sein, um einen Kunden zur Kündigung zu bewegen. Abhängig davon, wann die Daten gesammelt wurden, könnte dies zur Hochzeit des Trends zum Abschneiden des Kabels gewesen sein, der Netflix & Co. begünstigte. Quelle. Die Telefonverträge könnten dabei Kollateralschäden gewesen sein, aber ohne zusätzliche Daten handelt es sich dabei um reine Spekulation.
feature_influence.sort_values(by='beta').tail(3)
Feature | beta | |
---|---|---|
21 | StreamingTV_Yes | 1.53 |
23 | StreamingMovies_Yes | 1.58 |
10 | InternetService_Fiber optic | 4.04 |
Wir können auch integrierte Funktionen von sklearn verwenden, um die Bedeutung der Merkmale zu bestimmen: Die Permutationsbedeutung wird berechnet, indem die Genauigkeit wie sie ist mit der Genauigkeit des Klassifikators verglichen wird, der auf einem modifizierten Datensatz arbeitet, bei dem eine Merkmalspalte zufällig durcheinandergebracht wird.
Es ist sofort einsichtig, dass ein durcheinandergeworfenes Merkmal, das nichts dazu beiträgt, unsere Genauigkeit oder eine andere Metrik negativ zu beeinflussen, für die Vorhersage im Modell nicht “wichtig” sein darf.
r = permutation_importance(clf,
X_test,
y_test,
n_repeats=30,
random_state=0,
scoring=score)
for i in r.importances_mean.argsort()[::-1]:
if r.importances_mean[i] - 2 * r.importances_std[i] > 0:
print(f"{df_work.columns[i]:<40}"
f"{r.importances_mean[i]:.3f}"
f" +/- {r.importances_std[i]:.3f}")
InternetService_Fiber optic 77651.308 +/- 3415.076
MonthlyCharges 62379.632 +/- 3371.916
tenure 30305.849 +/- 2725.497
StreamingTV_Yes 18698.764 +/- 2478.761
StreamingMovies_Yes 18538.961 +/- 2909.398
MultipleLines_No phone service 12803.406 +/- 1637.567
Contract_Two year 9291.107 +/- 1824.843
MultipleLines_Yes 9128.973 +/- 2065.377
Contract_One year 5778.808 +/- 1505.091
OnlineBackup_Yes 3210.823 +/- 1065.942
Nach dieser Metrik sehen wir, dass die Option für Glasfaser-Internet den größten Einfluss auf die Vorhersagegenauigkeit zu haben scheint, gefolgt von den monatlichen Gebühren. Wir erhalten also Konsistenz in diesen beiden Merkmalen, wobei eines einen großen positiven und das andere einen großen negativen Einfluss auf die Wahrscheinlichkeit der Kunden hat, zu kündigen.
Bonus: Vergleich der Ergebnisse mit AutoML Link to heading
Da ich mit diesen Ergebnissen nicht vollständig zufrieden bin, wollte ich wissen, wie unsere Lösung im Vergleich zu einem Google AutoML-Modell abschneidet. Also habe ich die Daten in Google Cloud geladen und es eine Stunde lang trainieren lassen. Interessanterweise hat AutoML die ganze Stunde genutzt und mir 16$ berechnet, aber die Genauigkeitsergebnisse um 2,1% verbessert.
Das ist auch nicht so toll - es ist insgesamt etwas besser in der Genauigkeit als das Modell, das wir hier trainiert haben, aber wenn wir uns die Verwirrungsmatrix ansehen, ist es praktisch nicht gut darin, unser Geschäftsziel zu erreichen.
Zumindest in Bezug auf die Merkmalswichtigkeit erzielen wir ähnliche Ergebnisse. Beachten Sie, dass ich die Variablen hier one-hot-kodiert habe, sodass jedes Merkmal mit der Anzahl der möglichen Kategorien in diesem Merkmal multipliziert wird.