期末大作業(yè):客戶流失數(shù)據(jù)可視化分析與預測
今天云朵君和大家一起學習一個期末作業(yè)項目。
本文亮點:
- 項目流程完整,從數(shù)據(jù)預處理、特征工程、建模到預測
- 使用Pipline構(gòu)建機器學習管道
- 使用optuna優(yōu)化算法
- 數(shù)據(jù)完整、代碼完整
背景
預測客戶流失是機器學習在行業(yè)中的一種常見用例,特別是在金融和訂閱服務(wù)領(lǐng)域。
流失率是指離開提供商的用戶數(shù)量。它也可以指離開公司的員工(員工保留率)。
因此,銀行客戶流失(又稱客戶流失)是指客戶停止與一家銀行做生意或轉(zhuǎn)向另一家銀行。
數(shù)據(jù)
數(shù)據(jù)字典:
- Customer ID:每個客戶的唯一標識符
- Surname:客戶的姓氏
- Credit Score:代表客戶信用評分的數(shù)值
- Geography:客戶居住的國家/地區(qū)
- Gender:顧客的性別
- Age:顧客的年齡。
- Tenure:客戶在該銀行的服務(wù)年限
- Balance:客戶的賬戶余額
- NumOfProducts:客戶使用的銀行產(chǎn)品數(shù)量(例如儲蓄賬戶、信用卡)
- HasCrCard:客戶是否擁有信用卡
- IsActiveMember:客戶是否為活躍會員
- EstimatedSalary:客戶的預計工資
- Exited:客戶是否流失(目標變量)
目標
這是一個經(jīng)典的二元分類問題。
圖片
在二元問題中,你必須猜測一個示例是否應(yīng)該歸類到特定類別(通常是正類 (1) 和負類 (0)。在本例中,churn 是正類。
預測一個新的y = 0或是y = 1一項常見的任務(wù),但在很多情況下,你必須提供一個概率,特別是在醫(yī)療應(yīng)用中,你必須對不同選項中的積極預測進行排序以做出最佳決策(例如,模型#1預測0.9,模型#2預測0.8)
評估二元分類器模型的最常見指標是預測概率和觀察到的目標之間的 ROC 曲線下面積(ROC-AUC)。
ROC 曲線是評估二元分類器性能和比較多個分類器的圖表。以下是一些示例。
圖片
理想情況下,性能良好的分類器的 ROC 曲線應(yīng)該在假陽性率較低時攀升真陽性率(召回率)。因此,0.9–1 之間的 ROC 非常好。
壞分類器是與圖表對角線相似或相同的分類器,代表純隨機分類器的性能。
如果類別平衡,你可以認為更高的 AUC == 模型能夠輸出更高概率的真陽性結(jié)果。但是,如果陽性結(jié)果很少見,AUC 一開始就很高,增量對于更好地預測罕見類別可能意義不大。平均精度在這里將是一個更有用的指標。
加載數(shù)據(jù)
我們加載給定的生成數(shù)據(jù),以及深度學習模型訓練的原始數(shù)據(jù)集。
train = pd.read_csv("./data/train.csv") # 數(shù)據(jù)獲?。涸诠娞枺簲?shù)據(jù)STUDIO 后臺回復240720 獲取
original = pd.read_csv("./data/Churn_Modelling.csv")
test = pd.read_csv("./data/test.csv")
train.drop(columns=["id"], inplace=True)
test.drop(columns=["id"], inplace=True)
original.drop(columns=["RowNumber"], inplace=True)
train = pd.concat([train, original.dropna()], axis=0)
train.reset_index(inplace=True, drop=True)
target_col = "Exited"
探索性數(shù)據(jù)分析
我們有 175k 個數(shù)據(jù)點可供使用。
train.info()
<class 'pandas.core.frame.DataFrame'>
Index: 175030 entries, 0 to 175030
Data columns (total 13 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 CustomerId 175030 non-null int32
1 Surname 175030 non-null object
2 CreditScore 175030 non-null int16
3 Geography 175030 non-null object
4 Gender 175030 non-null object
5 Age 175030 non-null float16
6 Tenure 175030 non-null int8
7 Balance 175030 non-null float32
8 NumOfProducts 175030 non-null int8
9 HasCrCard 175030 non-null float16
10 IsActiveMember 175030 non-null float16
11 EstimatedSalary 175030 non-null float32
12 Exited 175030 non-null int8
dtypes: float16(3), float32(2), int16(1), int32(1), int8(3), object(3)
memory usage: 9.2+ MB
減少數(shù)據(jù)集的內(nèi)存,以便特征工程和建模更加節(jié)省內(nèi)存。
train = reduce_mem_usage(train)
Mem. usage decreased to 9.18 Mb (50.9% reduction)
這是一個使用prettytable打印數(shù)據(jù)集中缺失數(shù)據(jù)的好函數(shù)
看來我們的數(shù)據(jù)沒有缺失值。
print_missing_table(train, test, target_col)
+-----------------+-----------+-----------------+----------------+
| Feature | Data Type | Train Missing % | Test Missing % |
+-----------------+-----------+-----------------+----------------+
| CustomerId | int64 | 0.0 | 0.0 |
| Surname | object | 0.0 | 0.0 |
| CreditScore | int64 | 0.0 | 0.0 |
| Geography | object | 0.0 | 0.0 |
| Gender | object | 0.0 | 0.0 |
| Age | float64 | 0.0 | 0.0 |
| Tenure | int64 | 0.0 | 0.0 |
| Balance | float64 | 0.0 | 0.0 |
| NumOfProducts | int64 | 0.0 | 0.0 |
| HasCrCard | float64 | 0.0 | 0.0 |
| IsActiveMember | float64 | 0.0 | 0.0 |
| EstimatedSalary | float64 | 0.0 | 0.0 |
| Exited | int64 | 0.0 | NA |
+-----------------+-----------+-----------------+----------------+
以下是我們的數(shù)據(jù)。
train.head()
圖片
為了簡單起見,我們過濾掉分類或連續(xù)的列。
# 每列的唯一值計數(shù)
unique_counts = train.nunique()
# 區(qū)分連續(xù)和分類的閾值
threshold = 12
# 連續(xù)變量只選擇數(shù)字列
numeric_cols = train.select_dtypes(include=[np.number]).columns.tolist()
continuous_vars = unique_counts[(unique_counts > threshold) & unique_counts.index.isin(numeric_cols)].index.tolist()
categorical_vars = unique_counts[(unique_counts <= threshold) | ~unique_counts.index.isin(numeric_cols)].index.tolist()
target_col = 'Exited'
id_col = ['id', 'CustomerId']
if target_col in categorical_vars:
categorical_vars.remove(target_col)
for col in id_col:
if col in continuous_vars:
continuous_vars.remove(col)
print(f"Categorical Variables: {categorical_vars}")
print(f"Continuous/Numerical Variables: {continuous_vars}")
Categorical Variables: ['Surname', 'Geography', 'Gender', 'Tenure', 'NumOfProducts', 'HasCrCard', 'IsActiveMember']
Continuous/Numerical Variables: ['CreditScore', 'Age', 'Balance', 'EstimatedSalary']
繪制出target。
plot_categorical(train, column_name='Exited')
圖片
這里存在明顯的類別不平衡。只有 20% 的數(shù)據(jù)屬于正類:Exited = 1
接下來我們看看連續(xù)變量和目標列的相互作用。
plot_violin_plots(train, continuous_vars, target_col)
圖片
退出的客戶的中位數(shù)age(1) 似乎高于未退出的客戶的中位數(shù) (0) 退出值之間的差異,這表明它可能是預測退出的相關(guān)因素。
分布balance表明,未退出的客戶(0)在 0 左右集中度較大,而退出的客戶(1)的中位數(shù)余額較高。
plot_histograms(train, continuous_vars, target_col)
圖片
圖片
圖片
圖片
我們觀察到大量未退出的年輕客戶,而退出客戶的分布則偏向于老年。這一點在 50 歲左右的峰值中尤為明顯,此時橙線超過了藍線。
plot_correlation_heatmap(train, continuous_vars, target_col)
圖片
“Age”和“Exited”之間呈現(xiàn)出最強的正相關(guān)性(0.3366),這支持了年齡是預測客戶流失的重要因素這一發(fā)現(xiàn)
“Balance”也與“Exited”呈現(xiàn)正相關(guān)(0.1284),表明余額較高的客戶更有可能離開。
plot_pairplot(train, continuous_vars, target_col)
圖片
這兩個類別之間沒有明顯的區(qū)分,這表明單個變量不足以區(qū)分退出的顧客和未退出的顧客。
特征工程
現(xiàn)在是時候創(chuàng)建一些特征了。每當你得到數(shù)據(jù)時,你都可以創(chuàng)建更多特征來提高模型的預測能力。這就像從數(shù)據(jù)中榨取每一點洞察力一樣。
為此,我們將構(gòu)建一個管道,它是對一組數(shù)據(jù)進行操作的對象序列。操作可以包括:
- 關(guān)系探索
- 特征變換
- 處理缺失值
- 創(chuàng)建新特征
- 選擇適合模型
- 預測未知數(shù)據(jù)
這是一個簡單的轉(zhuǎn)換器示例,僅用于刪除列
class DropColumn(BaseEstimator, TransformerMixin):
def __init__(self, cols):
self.cols = cols
def fit(self, X, y=None):
return self
def transform(self, X):
return X.drop(self.cols, axis=1)
另一個用于一次性執(zhí)行 kmeans 聚類、縮放和 PCA。
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans
class KMeansClusterer(BaseEstimator, TransformerMixin):
def __init__(self, features, n_clusters=20, random_state=0, n_compnotallow=None):
self.features = features
self.n_clusters = n_clusters
self.random_state = random_state
self.n_components = n_components
self.kmeans = KMeans(n_clusters=n_clusters, n_init=50, random_state=random_state)
self.scaler = StandardScaler()
self.pca = PCA(n_compnotallow=n_components)
def fit(self, X, y=None):
X_scaled = self.scaler.fit_transform(X.loc[:, self.features])
if self.n_components is not None:
X_scaled = self.pca.fit_transform(X_scaled)
self.kmeans.fit(X_scaled)
return self
def transform(self, X):
X_scaled = self.scaler.transform(X.loc[:, self.features])
# check for NaN and replace with zero
if np.isnan(X_scaled).any():
X_scaled = np.nan_to_num(X_scaled)
if self.n_components is not None:
X_scaled = self.pca.transform(X_scaled)
X_new = pd.DataFrame()
X_new["Cluster"] = self.kmeans.predict(X_scaled)
X_copy = X.copy()
# convert to dense format
X_new["Cluster"] = X_new["Cluster"].values
return pd.concat([X_copy.reset_index(drop=True), X_new.reset_index(drop=True)], axis=1)
clusterer_with_pca = KMeansClusterer(features=["CustomerId","EstimatedSalary","Balance"], n_clusters=10, random_state=123, n_compnotallow=3)
clusterer_with_pca.fit_transform(train)
圖片
一旦定義了構(gòu)建新功能和執(zhí)行某些轉(zhuǎn)換所需的所有轉(zhuǎn)換器,就可以構(gòu)建管道了。因篇幅限制,所有轉(zhuǎn)換器構(gòu)建的完整代碼可以在@公眾號:數(shù)據(jù)STUDIO 后臺回復 240720 即可免費獲取完整代碼。
對于編碼,你需要使用列轉(zhuǎn)換器。我們將輸出設(shè)置為 pandas。
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
preprocessing_pipeline = Pipeline([
('kmeans', KMeansClusterer(features=["CustomerId", "EstimatedSalary", "Balance"], n_clusters=10, random_state=123, n_compnotallow=3)),
('surname_tfid', TFIDFTransformer(column="Surname", max_features=1000, n_compnotallow=5)),
('age_binning', VariableBinning(n_bins=5, column_name="Age")),
('salary_binning', VariableBinning(n_bins=10, column_name="EstimatedSalary")),
('balance_salary_ratio', BalanceSalaryRatioTransformer()),
('geo_gender', GeoGenderTransformer()),
('total_products', BalanceSalaryRatioTransformer()), # Note: Should be TotalProductsTransformer, but not defined above
('tp_gender', TpGenderTransformer()),
('is_senior', IsSeniorTransformer()),
('quality_of_balance', QualityOfBalanceTransformer()),
('credit_score_tier', CreditScoreTierTransformer()),
('is_active_by_credit_card', IsActiveByCreditCardTransformer()),
('products_per_tenure', ProductsPerTenureTransformer()),
('customer_status', CustomerStatusTransformer()),
('drop', DropColumn(cols=['CustomerId', 'Surname'])),
('prep', ColumnTransformer([
('encode', OneHotEncoder(handle_unknown='ignore', sparse_output=False),
['Gender', 'Geography', 'NumOfProducts', 'HasCrCard', 'IsActiveMember', 'Geo_Gender', 'Tp_Gender']),
],
remainder='passthrough').set_output(transform='pandas')),
])
preprocessing_pipeline
圖片
將這個管道應(yīng)用到我們的訓練數(shù)據(jù)集上。
df_train = preprocessing_pipeline.fit_transform(train.drop(['Exited'], axis=1))
df_train.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 175030 entries, 0 to 175029
Data columns (total 49 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 encode__Gender_Female 175030 non-null float64
1 encode__Gender_Male 175030 non-null float64
2 encode__Geography_France 175030 non-null float64
3 encode__Geography_Germany 175030 non-null float64
4 encode__Geography_Spain 175030 non-null float64
5 encode__NumOfProducts_1 175030 non-null float64
6 encode__NumOfProducts_2 175030 non-null float64
7 encode__NumOfProducts_3 175030 non-null float64
8 encode__NumOfProducts_4 175030 non-null float64
9 encode__HasCrCard_0.0 175030 non-null float64
10 encode__HasCrCard_1.0 175030 non-null float64
11 encode__IsActiveMember_0.0 175030 non-null float64
12 encode__IsActiveMember_1.0 175030 non-null float64
13 encode__Geo_Gender_France_Female 175030 non-null float64
14 encode__Geo_Gender_France_Male 175030 non-null float64
15 encode__Geo_Gender_Germany_Female 175030 non-null float64
16 encode__Geo_Gender_Germany_Male 175030 non-null float64
17 encode__Geo_Gender_Spain_Female 175030 non-null float64
18 encode__Geo_Gender_Spain_Male 175030 non-null float64
19 encode__Tp_Gender_1.0Female 175030 non-null float64
20 encode__Tp_Gender_1.0Male 175030 non-null float64
21 encode__Tp_Gender_2.0Female 175030 non-null float64
22 encode__Tp_Gender_2.0Male 175030 non-null float64
23 encode__Tp_Gender_3.0Female 175030 non-null float64
24 encode__Tp_Gender_3.0Male 175030 non-null float64
25 encode__Tp_Gender_4.0Female 175030 non-null float64
26 encode__Tp_Gender_4.0Male 175030 non-null float64
27 encode__Tp_Gender_5.0Female 175030 non-null float64
28 encode__Tp_Gender_5.0Male 175030 non-null float64
29 remainder__CreditScore 175030 non-null int16
30 remainder__Age 175030 non-null float32
31 remainder__Tenure 175030 non-null int8
32 remainder__Balance 175030 non-null float32
33 remainder__EstimatedSalary 175030 non-null float32
34 remainder__Cluster 175030 non-null int32
35 remainder__Surname_tfidf_0 175030 non-null float64
36 remainder__Surname_tfidf_1 175030 non-null float64
37 remainder__Surname_tfidf_2 175030 non-null float64
38 remainder__Surname_tfidf_3 175030 non-null float64
39 remainder__Surname_tfidf_4 175030 non-null float64
40 remainder__QCut5_Age 175030 non-null int64
41 remainder__QCut10_EstimatedSalary 175030 non-null int64
42 remainder__Total_Products_Used 175030 non-null float16
43 remainder__IsSenior 175030 non-null int64
44 remainder__QualityOfBalance 175030 non-null category
45 remainder__CreditScoreTier 175030 non-null category
46 remainder__IsActive_by_CreditCard 175030 non-null float16
47 remainder__Products_Per_Tenure 175030 non-null float64
48 remainder__Customer_Status 175030 non-null int64
dtypes: category(2), float16(2), float32(3), float64(35), int16(1), int32(1), int64(4), int8(1)
memory usage: 56.3 MB
從 14 個特征增加到現(xiàn)在 48 個!
GBT 分類器
我們將訓練以下增強模型:XGBoost、Catboost、LightGBM。
我們使用Optuna來找到此 Catboost 分類器的最佳超參數(shù)。我設(shè)置n_trials=10它是為了讓它完成得更快,如果你時間充足,這里可以設(shè)置大一點(越大時間越久)。
其余模型的構(gòu)建完整代碼:可以在@公眾號:數(shù)據(jù)STUDIO 后臺回復 240720 即可免費獲取完整代碼。
# 過濾警告 (FutureWarnings)
warnings.filterwarnings("ignore",
category=FutureWarning,
module="sklearn.utils.validation")
skf = StratifiedKFold(n_splits=10, shuffle=True, random_state=42)
def objective(trial):
params = {
'iterations': trial.suggest_int('iterations', 500, 1000),
'depth': trial.suggest_int('depth', 10, 16),
'min_data_in_leaf': trial.suggest_int('min_data_in_leaf', 2, 20),
'learning_rate': trial.suggest_float('learning_rate', 1e-4, 0.2, log=True),
}
cb_model = CatBoostClassifier(**params, random_state=42, grow_policy='Lossguide', verbose=0)
cb_pipeline = make_pipeline(modelling_pipeline, cb_model)
cv = abs(cross_val_score(cb_pipeline, X, y, cv=skf, scoring='roc_auc').mean())
return cv
study = optuna.create_study(directinotallow='maximize')
study.optimize(objective, n_trials=10)
best_params_cb = study.best_params
print("Best Hyperparameters for CatBoost:", best_params_cb)
完成后,你可以將最佳參數(shù)傳遞給分類器。
cb_model = CatBoostClassifier(**best_params_cb, random_state=42, verbose=0)
cb_pipeline_optimized = make_pipeline(modelling_pipeline, cb_model)
我們再進行一次 KFold 來檢查 AUC 分數(shù)。
n_splits = 10
stratkf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42)
cv_results = []
for fold, (train_idx, val_idx) in enumerate(stratkf.split(X, y)):
X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]
y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
cb_pipeline_optimized.fit(X_train, y_train)
y_val_pred_prob = cb_pipeline_optimized.predict_proba(X_val)[:, 1]
y_pred = cb_pipeline_optimized.predict(X_val)
f1 = f1_score(y_val, y_pred, average='weighted')
# Evaluating the model
logloss = log_loss(y_val, y_val_pred_prob)
roc_auc = roc_auc_score(y_val, y_val_pred_prob)
print(f'Fold {fold + 1}, AUC-Score on Validation Set: {roc_auc}')
print(f'Fold {fold + 1}, F1 Score on Validation Set: {f1}')
print(f'Fold {fold + 1}, Log Loss Score on Validation Set: {logloss}')
print('-'*70)
cv_results.append(logloss)
average_cv_result = sum(cv_results) / n_splits
print(f'\nAverage Logarithmic Loss across {n_splits} folds: {average_cv_result}')
我們可以使用混淆矩陣檢查模型的性能。
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42)
cb_pipeline_optimized.fit(X = X_train,
y = y_train)
predictions_cb = cb_pipeline_optimized.predict(X_val)
cm_cb = confusion_matrix(y_val, predictions_cb)
disp = ConfusionMatrixDisplay(confusion_matrix=cm_cb, display_labels=['Not Churn', 'Churn'])
disp.plot()
plt.show()
圖片
我們的模型具有較高的真陰性率,這意味著它在識別不會流失的客戶方面比識別會流失的客戶更有效。模型預測不會流失的客戶中,有相當一部分實際上流失了,這可能是一個需要改進的領(lǐng)域。減少假陰性可以幫助公司更有效地采取干預措施來留住客戶。
我們還可以看到 catboost 分類器的特征重要性。
cb_feature_importance = cb_pipeline_optimized.named_steps['catboostclassifier'].feature_importances_
sorted_idx = np.argsort(cb_feature_importance)
fig = plt.figure(figsize=(18, 16))
plt.barh(range(len(sorted_idx)), cb_feature_importance[sorted_idx], align='center')
plt.yticks(range(len(sorted_idx)), np.array(train_X.columns)[sorted_idx])
plt.title('CB_Feature Importance')
plt.show()
圖片
圖表顯示,對于模型的預測來說,最重要的特征包括年齡、信用評分、估計工資和集群。姓氏的 tfidf 特征似乎也與預測特征重要性有關(guān),盡管這可能會導致對這些名字的過度擬合。
集成學習
現(xiàn)在有性能各異的不同模型。通過集成學習,可以將這些模型融合在一起,以實現(xiàn)更高的性能!
這里我們使用帶有“軟”投票的投票分類器,它根據(jù)預測概率總和的 argmax 來預測類標簽。
這些權(quán)重是一個數(shù)字,它告訴分類器在平均之前對類概率賦予多大的重要性(權(quán)重)。它們也可以使用 GridSearch 或 Optuna 進行優(yōu)化。
ensemble_model = VotingClassifier(estimators=[
('xgb', xgb_pipeline_optimized),
('lgb', lgb_pipeline_optimized),
('cb', cb_pipeline_optimized)
], voting='soft', weights = [0.4,0.4,0.2])
ensemble_model
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42)
ensemble_model.fit(X = X_train, y = y_train)
predictions_ensemble = ensemble_model.predict(X_val)
cm_ensemble = confusion_matrix(y_val, predictions_ensemble)
disp = ConfusionMatrixDisplay(confusion_matrix=cm_ensemble, display_labels=['Not Churn', 'Churn'])
disp.plot()
plt.show()
請注意,我們的假陰性略有減少,而真陽性有所增加。