數分專案-基於Cox風險比例模型的流失會員使用者預測

孜然灬發表於2024-03-31

一、問題

你是某銀行的資料分析師,近日運營部發現使用者的次日留存率較市場平均使用者留存率相比偏低且持續下降,於是向你詢問,分析是什麼原因導致這批使用者的每日留存率持續下降?

二、方法論

明確目標:使用者的次日留存率持續下降

目標拆解:5W2H

* what:使用者次日留存率是否真的呈持續下降走勢?
資料異動分析:
1. 資料傳輸是否異常
2. 季節效應
3. 營銷活動、重大事件影響
4. 統一口徑

* who:什麼樣的使用者次日留存率下降了?
流失使用者定義:核心使用者/非核心使用者
使用者標籤體系
使用者屬性分析:
1. 社會屬性:年齡、職業、性別、學歷、區域
2. 商業屬性:付費錢數、付費次數、付費頻率
3. 行為屬性:點選行為、瀏覽行為、搜尋行為
4. 內容屬性:產品數量、產品喜好
5. 裝置屬性:品牌、機型

* where/when:什麼時候使用者流失了?哪一步流失了?
使用者指標體系:
使用者生命週期AARRR:獲取->啟用->留存->變現->推薦
使用者行為路徑UJM:註冊->登入->加購->購買->復購
漏斗分析
流失前N步分析

* why:因為什麼(特徵)流失了?
因果推斷
歸因分析

* how:怎麼做?(怎麼分析流失使用者)
OSM(Object-Strategy-Measure)拆解
ML(Machine Learning)機器學習:Kmeans,XGBoost,CoxPH,SHAP,......

* how much:怎麼驗證結論?(衡量結果)
結果最佳化:使用者畫像
1. 使用者屬性分析
2. 使用者產品體驗:
① 表現層:產品語言設計、佈局
② 框架層:操作體驗、重新整理、頁面跳轉
③ 結構層:常規功能、特色功能、使用者流程
④ 範圍層:核心功能,滿足使用者什麼樣的需求
⑤ 戰略層:產品定位、商業模式及其後續發展
結果驗證:AB test

三、舉例

(1) 作為一名資料分析師,我會首先判斷該使用者次日留存率下降的主人公以及事件的真實性,就以核心使用者次日留存率下降來說,
① 是否因為季節週期效應或者最近一段時間的營銷活動等造成的區域性次日留存率下降。這可以透過對比分析如同比、環比等判斷最近幾月與最近幾年的核心使用者留存率情況;
② 資料來源的統一口徑是否一致。這可以透過核對資料埋點設計的計算方式與口徑是否一樣,比如對於DAU(日活使用者)的統計,以全域性唯一使用者ID為最小維度進行統計和以裝置唯一ID最小維度進行統計都會帶來一定的偏差。
結果發現都不是這些因素引起的,需進一步分析;




(2) 我流失的這部分核心使用者群的使用者畫像是什麼樣的,核心流失使用者是怎麼定義的。
① 核心使用者畫像可以透過已經存在的標籤體系,如透過RFM(Recently-Frequence-Money)模型確立的優質/良好/普通使用者進行初步劃分,也可以透過使用者屬性的5個維度進行精細劃分,比如社會屬性的年齡、性別、地區等,商業屬性的賬戶餘額、預估年薪等,行為屬性的信用評分、是否活躍使用者等,內容屬性的產品數量、是否持有信用卡等。這裡簡單地將辦過銀行產品的使用者都稱之為核心使用者(會員使用者)。
② 使用者流失的定義有兩種,一種是拐點法,一種是分位數法:
所謂拐點法,即判斷N日使用者迴歸率或者N日使用者留存率隨N遞增構建的曲線逐漸趨於平緩的點就是拐點,拐點對應的N天就是使用者的流逝週期。其中,

數分專案-基於Cox風險比例模型的流失會員使用者預測
數分專案-基於Cox風險比例模型的流失會員使用者預測


所謂分位數法,即統計一段時間內的所有使用者活躍時間間隔長度,從該段時間內的第一天算起,累加每天的使用者活躍時間間隔佔比,直到第N天的累加佔比達到90%,就認為N天是使用者的流逝週期。

數分專案-基於Cox風險比例模型的流失會員使用者預測

比如使用拐點法定義會員流失使用者週期,透過計算N日的流失使用者迴歸率,當N=20及以後迴歸率趨於平緩,那麼會員使用者的流失週期就是20天。而這裡則是會員使用者關閉了該產品的服務,就定義為使用者流失了。之後可以根據時間週期,如每天、20天、一個月,統計使用者的流失率。
數分專案-基於Cox風險比例模型的流失會員使用者預測


(3) 透過以上分析確定了會員使用者的使用者畫像,以及流失的週期,接下來需要分析會員使用者到底是在哪一步流失了。這裡透過OSM+AARRR的方式分析拆解流失使用者。
OSM(Object-Strategy-Measure)即目標O-策略S-指標M。
① 既然使用者已經流失了,那麼明確目標O:提升會員使用者的召回率;
② 使用者在第一次瞭解產品到最後停止產品服務就是一整個使用者生命週期,所以制定的策略就是AARRR:獲取-啟用-留存-付費-推薦;
③ 最後需要對每一步進行指標的拆解,也就是指標M:新使用者數量--日/周/月活躍使用者數--留存使用者數--付費使用者數--分享使用者數
對應的二級指標具體到個體使用者則可以是:註冊賬戶時長--活躍時間間隔時長、點選率--活躍時長、次日活躍時長--付費次數、付費金額--分享次數



(4) 按以上方法找到了能提升會員使用者的召回率的一些相關指標,這一步就是對這些指標進行分析,會員使用者的流失到底和什麼因素(特徵)有關。這裡採用機器學習(Machine Learning)的方法來進行特徵的歸因。
① XGBoost:構建基於樹的整合學習演算法模型,分析影響會員使用者流失的輸入特徵有哪些,並輸出對應的特徵重要度;
② 為了驗證建立模型的正確率,採用交叉驗證的方式檢驗該模型的魯棒性,並透過精確率、召回率和F1值這些評價指標給模型打分。



(5) 透過機器學習,即使我們能正確分析出是由哪些特徵因素導致的會員使用者流失情況偏高,但如果使用者已經流失了,那麼再去召回的機率也很小了。所以還得預測當前的會員使用者在未來是否會流失,什麼時候流失。
① Cox風險比例模型:生存分析中的半引數模型,其相比於傳統的機器學習模型來說,不僅有預測流失使用者的能力,也可以預測使用者的具體流失時間。其評價標準是一致性指數(Concordance Index),其越接近於1,表示模型訓練效果越好;
② SHAP分析:發展於博弈論中正負貢獻的Shapley值,在機器學習模型預測正確的情況下,相比於整體的特徵重要度,能更精確地反應每個使用者的不同特徵影響程度,比如使用者A流失的很大程度上是因為因素x,使用者B流失的很大程度上是因為因素y,從而實現不同使用者的精確召回,為運營部門做資料支撐與業務建議。




(6) 最終,我們找到了具有某些特徵的會員使用者群體,其流失率較大,比如年齡在40~60歲之間,在銀行只辦過一種產品的會員使用者,其流失率較高。
① 定製召回策略:針對這類使用者,可以進一步分析具體會員流失使用者到底辦的是什麼樣的產品,如果大部分辦的都是產品A,針對該產品提出一些有助於使用者召回的合理改進建議,比如,發現大部分會員使用者辦理的都是某一款定期存款產品,那麼在保證利潤不變或ROI能提高的情況下,是否能為會員使用者免費升級該產品,提升更多的儲存金額額度以及利息。否則,需進一步分析使用者的行為路徑和使用者的產品體驗。
② 衡量策略指標:ABtest。針對提出的召回策略,進行AB實驗,步驟如下:
1. 對照組為當前產品不變的會員使用者,實驗組為升級當前產品的會員使用者;
2. 原假設為升級當前產品的會員使用者平均流失率與不升級沒有差別,備擇假設為升級當前產品的會員使用者平均流失率與不升級有差別;
3. 視原產品的每日使用者流失率為總體,那麼均值和方差都是已知的,採用Z檢驗統計量(總體與樣本),定義置信度α=0.05,以及期望能提升的每日流失率E=0.1%,計算所需的最小天數n;
4. 為實驗組和對照組分配相同的流量,統計每組n天內會員使用者流失率的均值和方差;
5. 計算Z檢驗/T檢驗統計量(樣本與樣本),查表獲取p值,若p<α=0.05,則小機率事件成立,拒絕原假設,接收備擇假設,即認為升級當前產品,會員使用者的流失率有顯著性降低;反之則接收原假設。

四、實操

根據所述方法論,包括但不限於使用者畫像、使用者屬性、使用者指標、使用者標籤等,編寫SQL進行多表聯查,提取一段時間內如最近一年的會員使用者的相關特徵。
本次資料集來源於Kaggle官網公開資料集:Kaggle, Shubham K. Churn Modelling-Deep Learning Artificial Neural Network Used[EB/OL],總共10000 rows × 14 columns,每個欄位的解釋為如下:
RowNumber (行號):資料行的序號。
CustomerId (客戶ID):客戶的唯一標識ID。
Surname (姓氏):客戶的姓氏。
CreditScore (信用評分):客戶的信用評分,用於衡量客戶的信用狀況。
Geography (地理位置):客戶所在地理位置或國家/地區。
Gender (性別):客戶的性別,通常為男性或女性。
Age (年齡):客戶的年齡。
Tenure (任期):客戶在銀行(或其他機構)已經持有賬戶的時間長度。
Balance (餘額):客戶在賬戶中的餘額金額。
NumOfProducts (產品數量):客戶持有的產品數量,可以是銀行產品或其他相關產品。
HasCrCard (是否有信用卡):客戶是否持有信用卡,通常用二元變數表示是否持有信用卡。
IsActiveMember (是否活躍會員):客戶是否是活躍會員的標識,通常用二元變數表示是否活躍會員。
EstimatedSalary (預估工資):客戶的預估年收入或工資。
Exited (是否已退出):一個標識變數,表示客戶是否已經退出了銀行的服務(例如關閉賬戶或取消產品)。其中0是非流失使用者,1是流失使用者
程式碼如下:

# # 匯入需要的包
import numpy as np
import pandas as pd
import scipy
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.preprocessing import LabelEncoder
import warnings
warnings.filterwarnings("ignore")
from sklearn.model_selection import train_test_split
from lifelines import CoxPHFitter
import xgboost as xgb
from sklearn.utils.class_weight import compute_class_weight
from sklearn.metrics import classification_report
from sklearn.inspection import permutation_importance
import shap
import scipy.stats
import gc
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler```
```python
# # 讀取資料
df_data=pd.read_csv("Churn_Modelling.csv")
df_data

image

# # 檢視標籤分佈
ax = sns.countplot(x='Exited', data=df_data)
# 新增標題
plt.title('Exited Value Counts')
# 在每個條形的頂部新增具體數值
for p in ax.patches:
    ax.annotate(f'{p.get_height()}', (p.get_x() + p.get_width() / 2., p.get_height()),
                ha='center', va='center', fontsize=11, color='black', xytext=(0, 5),
                textcoords='offset points')
plt.show()

image
從圖中我們可以看到,會員流失使用者和非流失使用者是一個資料不平衡的情況,後續應該做相應的處理。

# # 類別變數轉換為數值型變數
df_data.loc[:, 'Geography'] = LabelEncoder().fit_transform(df_data.loc[:, 'Geography'])
df_data.loc[:, 'Gender'] = LabelEncoder().fit_transform(df_data.loc[:, 'Gender'])
df_data = df_data.drop(columns=['RowNumber', 'CustomerId', 'Surname'])

# # 繪製關於Exited標籤的資料分佈直方圖
statuses = set(df_data['Exited'])
plt.figure(figsize=(15,14))
for i, col in enumerate(df_data.columns.drop('Exited')):
    plt.subplot(4, 3, i+1)
    for status in statuses:
        sns.distplot(df_data.loc[df_data.Exited==status, col], label=f'{status} is {len(df_data[df_data.Exited == status])} counts', kde=True)
    plt.legend()
plt.show()

image
image
從資料分佈圖來看,初步認為年齡Age特徵和持有產品數量NumOfProducts特徵對是否是流失使用者的判斷有較大的影響。
因為資料都是處理好的,沒有缺失值情況且客戶ID唯一,可以直接用於訓練集構建以及預測,實際上應該對提取的資料集做進一步的特徵最佳化以及衍生特徵構建。

'''
交叉驗證
'''
for i in range(1,4): 
    print('====================================')
    print(f'第{i}輪交叉驗證')
    train_data, valid_data = train_test_split(df_data, test_size=0.2, stratify=df_data.loc[:, 'Exited'])

    def balanced_weight(y_series):
        # 假設有n個類別,訓練資料的標籤為 y_train
        n_classes = len(set(y_series))
        # 計算類別權重
        class_weights = compute_class_weight(class_weight="balanced", classes=np.unique(y_series), y=y_series)
        print('類別權重:', class_weights)
        # 將類別權重轉換為樣本權重(sample weight)
        sample_weights = np.zeros(len(y_series))
        for class_index, weight in enumerate(class_weights):
                sample_weights[y_series == class_index] = weight
        return sample_weights

    def model_fit(X_df,y_series,sample_weights):
        if len(set(y_series))==2:
            objective = 'binary:logistic'
        else:
            objective = 'multi:softmax'
        params = {
            'objective':objective #目標函式
        }

        # 定義XGBoost模型
        xgb_model = xgb.XGBClassifier(
            **params
        ).fit(X_df, y_series,sample_weight=sample_weights)

        return xgb_model

    '''
    模型訓練
    '''
    label = 'Exited'
    X_train = train_data.drop(columns=label)
    y_train = train_data.loc[:, label]
    sample_weights = balanced_weight(y_train)
    xgb_model = model_fit(X_train,y_train,sample_weights)
    print(f'第{i}輪模型訓練完成,xgb引數:',xgb_model.get_params())

    '''
    排序重要度
    '''
    def feature_importance(xgb_model, X_train, y_train):
        # 計算排列重要性
        result = permutation_importance(xgb_model, X_train, y_train, n_repeats=5, random_state=42, scoring='neg_mean_squared_error')
        # 輸出特徵重要性結果
        importance = result.importances_mean
        feature_names = X_train.columns
        dict_values = dict(zip(feature_names, importance))
        # 使用 sorted 函式和匿名函式對字典的值進行排序
        sorted_dict_values = sorted(dict_values.items(), key=lambda x: x[1], reverse=True)[:20]
        keys = [item[0] for item in sorted_dict_values]
        values = [item[1] for item in sorted_dict_values]
        plt.figure(figsize=(14,4))
        sns.barplot(x=values, y=keys)
        plt.xlabel('Value')
        plt.ylabel('Key')
        plt.title('Top 20 Permutation Importance')
        for i, v in enumerate(values):
            plt.text(v + 0.001, i, str(np.round(v,4)), color='black', fontweight='bold')
        plt.tight_layout()
        plt.show()

    feature_importance(xgb_model, X_train, y_train)

    '''
    模型預測
    '''
    X_valid = valid_data.drop(columns=label)
    y_valid = valid_data.loc[:, label]
    y_pred_valid = xgb_model.predict(X_valid)
    # 生成分類報告
    report = classification_report(y_valid, y_pred_valid)
    print(f'第{i}輪模型預測報告:')
    print(report)

image
image
image
image
image
image
透過3輪的交叉驗證(80%作為訓練集,20%作為驗證集)可以知道,其中Age和NumOfProducts特徵的重要度最高,這和我們初步觀察的資料分佈圖得出的結論一致。從模型預測報告來說,對於流失使用者(1)的預測談不上特別好,但至少比隨機預測的機率要高(f1-score>(407/2000=20.35%))。

'''
劃分訓練集和驗證集
'''
train_data, valid_data = train_test_split(df_data, test_size=0.2, stratify=df_data.loc[:, 'Exited'])
print(df_data.columns)

'''
Cox風險比例模型
'''
formula = 'CreditScore + Geography + Gender + Age + Balance+ NumOfProducts+ HasCrCard + IsActiveMember + EstimatedSalary'
cox_model = CoxPHFitter(penalizer=0.01, l1_ratio=0)
cox_model = cox_model.fit(train_data, duration_col='Tenure', event_col='Exited',formula=formula)
cox_model.print_summary() # 一致性指數Concordance越接近1,預測效果越好

'''
一致性檢驗
'''
# 如果變數的影響程度越高(p值越顯著),變數的風險機率也越高,那麼模型的一致性指數就越高
plt.figure(figsize=(7,6))
cox_model.plot(hazard_ratios=True)
plt.xlabel('Hazard Ratios (95% CI)')
plt.title('Hazard Ratios')
plt.show()

image
image
image
可以看到,CoxPH模型對於Gender、Age、Balance、IsActiveMember特徵的顯著性檢驗p值<0.005,表明這些變數對於模型的預測結果來說具有很高的影響力。
Concordance是一致性指數,越接近1表明模型訓練效果越好,這裡的Concordance=0.71;模型的一致性指數可以理解為:如果變數的影響程度越高(p值越顯著),變數的風險機率(Hazard Ratios)也越高。
該CoxFH模型的訓練效果為良。

'''
Cox流失使用者預測
'''
df_predict_0 = cox_model.predict_survival_function(valid_data.loc[valid_data['Exited']==0, :])
df_predict_0

df_predict_1 = cox_model.predict_survival_function(valid_data.loc[valid_data['Exited']==1, :])
df_predict_1

print('正常使用者一致性:', np.round(1 - (df_predict_0.iloc[-1]<0.5).sum()/len(df_predict_0.iloc[-1]),2)*100, '%')
print('流失使用者一致性:', np.round((df_predict_1.iloc[-1]<0.5).sum()/len(df_predict_1.iloc[-1]), 2)*100, '%')
plt.figure(figsize=(6, 6))
observe_index = 5
i = observe_index
observe_df = df_predict_1
while i < observe_index + 5:
    plt.plot(observe_df.loc[:, observe_df.columns[i]], label=observe_df.columns[i])
    i = i + 1
plt.axhline(y=0.5, color='black', linestyle='--')
plt.text(10, 0.47, 'Threshold=0.5', color='black', fontsize=12)  # 在y=0.5處新增文字
plt.xlabel('Timeline')
plt.ylabel('Retain probability')
plt.title('The Churn Trend of Samples')
plt.xticks(range(0, 11, 1))
plt.legend(loc='best')
plt.show()

image
從生存函式圖得知預測結果,當retain probability<0.5的時候就認為該會員使用者有流失的風險了。這裡隨機取了5個會員使用者預測他的流失時序情況,比如202使用者沒有流失,其他使用者均流失了。實際上這幅生存函式圖是基於已經知道的流失使用者群體來繪製的,理論情況是圖中所有會員使用者都呈現流失情況,但202使用者正好相反,所以透過一定的計算方式即一致性指標來評價CoxPH模型的預測正確率,其中預測非流失使用者的一致性為74%,預測流失使用者的一致性為63%。

五、調優

結合上述XGBoost模型和CoxPH模型的訓練和預測結果,我們可以知道模型整體效果為良,且都有在Age、NumOfProducts、Balance等特徵上表現一定的重要度和影響性,於是可以針對這些特徵做進一步的資料探索和挖掘,比如將年齡劃分為多個維度:0~20歲、20~40歲、40~60歲、60歲及以上,分析不同使用者群的流失情況及特徵互動影響等。

# # 劃分年齡維度
bins = [0, 20, 40, 60, 90]
labels = ['0~20','20~40','40~60','60~']
df_0 = df_data.loc[df_data.Exited==0, :]
df_0.loc[:, 'Age'] = pd.cut(df_0.loc[:, 'Age'], bins=bins, labels=labels)
print('正常使用者:')
print(df_0.loc[:, 'Age'].value_counts())
df_1 = df_data.loc[df_data.Exited==1, :]
df_1.loc[:, 'Age'] = pd.cut(df_1.loc[:, 'Age'], bins=bins, labels=labels)
print('流失使用者:')
print(df_1.loc[:, 'Age'].value_counts())

image
圖中正常使用者大部分集中在20~40歲之間,而流失使用者大部分集中在40~60歲之間。

# # 正常使用者基於年齡維度的持有產品數量對比
plt.figure(figsize=(14,4))
for i, age in enumerate(df_0.Age.unique()):
    plt.subplot(1, len(df_0.Age.unique()), i+1)
    sns.distplot(df_0.loc[df_0.Age==age, 'NumOfProducts'], label=f'{age} is {len(df_0[df_0.Age == age])} counts', kde=True)
    plt.legend()
plt.show()

image

# # 流失使用者基於年齡維度的持有產品數量對比
plt.figure(figsize=(14,4))
for i, age in enumerate(df_1.Age.unique()):
    plt.subplot(1, len(df_1.Age.unique()), i+1)
    sns.distplot(df_1.loc[df_1.Age==age, 'NumOfProducts'], label=f'{age} is {len(df_1[df_1.Age == age])} counts', kde=True)
    plt.legend()
plt.show()

image
正常使用者的持有產品數量在每個年齡段都呈“雙峰”分佈,而流失使用者呈“小提琴”分佈,這意味著流失使用者群體的大部分使用者都只持有1種產品。
透過更深層次地資料探勘,最終確立流失使用者群體為:

40~60歲之間沒有辦理額外業務卡的不活躍會員使用者

# # 嘗試根據['Age', 'NumOfProducts', 'Balance']透過KMeans聚類
train_data, valid_data = train_test_split(df_data, test_size=0.2, stratify=df_data.loc[:, 'Exited'])
def cluster_train(X_df):
    cols = X_df.select_dtypes(include=['float','int']).columns.tolist()
    # 建立標準化器物件
    scaler = StandardScaler()
    # 提取需要標準化的浮點型變數並轉換為NumPy陣列
    data = X_df[cols].values
    # 對資料進行標準化處理
    scaled_data = scaler.fit_transform(data)
    # 將標準化後的資料更新回DataFrame中的相應列
    X_df[cols] = scaled_data
    # 使用 k-means++ 聚類演算法
    kmeans = KMeans(n_clusters=2, init='k-means++', max_iter=300, n_init=10, random_state=0)
    X_result = kmeans.fit_predict(X_df)
    return X_result, scaler, kmeans

def cluster_transform(X_df, scaler, kmeans):
    cols = X_df.select_dtypes(include=['float','int']).columns.tolist()
    data = X_df[cols].values
    scaled_data = scaler.transform(data)
    X_df[cols] = scaled_data
    X_result = kmeans.predict(X_df)
    return X_result

train_f = train_data.loc[:, ['Age', 'NumOfProducts', 'Balance']]
valid_f = valid_data.loc[:, ['Age', 'NumOfProducts', 'Balance']]
train_K, scaler, kmeans = cluster_train(train_f)
valid_K = cluster_transform(valid_f, scaler, kmeans)
train_data.loc[:, 'cluster_K'] = train_K
valid_data.loc[:, 'cluster_K'] = valid_K
display(train_data.loc[:, 'cluster_K'].value_counts())
display(valid_data.loc[:, 'cluster_K'].value_counts())

image
嘗試根據Age、NumOfProducts、Balance特徵構建KMeans聚類演算法,將聚類結果作為衍生特徵進入模型學習。

# # 除了聚類演算法產生衍生特徵之外,也構建了balance_age、salary_age、isOldOneProduct等衍生特徵
'''
XGBoost模型調優
'''
for i in range(1,4): 
    print('====================================')
    print(f'第{i}輪交叉驗證')
    df_raw = df_data
    df_raw.loc[:, 'balance_age'] = df_raw.loc[:, 'Balance']/df_raw.loc[:, 'Age']
    df_raw.loc[:, 'salary_age'] = df_raw.loc[:, 'EstimatedSalary']/df_raw.loc[:, 'Age']
    df_raw.loc[:, 'isOldOneProduct'] = 0
    df_raw.loc[(df_raw.Age>=40)&(df_raw.Age<=60)&(df_raw.NumOfProducts==1), 'isOldOneProduct'] = 1

    train_data, valid_data = train_test_split(df_raw, test_size=0.2, stratify=df_raw.loc[:, 'Exited'])
    def cluster_train(X_df):
        cols = X_df.select_dtypes(include=['float','int']).columns.tolist()
        # 建立標準化器物件
        scaler = StandardScaler()
        # 提取需要標準化的浮點型變數並轉換為NumPy陣列
        data = X_df[cols].values
        # 對資料進行標準化處理
        scaled_data = scaler.fit_transform(data)
        # 將標準化後的資料更新回DataFrame中的相應列
        X_df[cols] = scaled_data
        # 使用 k-means++ 聚類演算法
        kmeans = KMeans(n_clusters=2, init='k-means++', max_iter=300, n_init=10, random_state=0)
        X_result = kmeans.fit_predict(X_df)
        return X_result, scaler, kmeans

    def cluster_transform(X_df, scaler, kmeans):
        cols = X_df.select_dtypes(include=['float','int']).columns.tolist()
        data = X_df[cols].values
        scaled_data = scaler.transform(data)
        X_df[cols] = scaled_data
        X_result = kmeans.predict(X_df)
        return X_result

    train_f = train_data.loc[:, ['Age', 'NumOfProducts']]
    valid_f = valid_data.loc[:, ['Age', 'NumOfProducts']]
    train_K, scaler, kmeans = cluster_train(train_f)
    valid_K = cluster_transform(valid_f, scaler, kmeans)
    train_data.loc[:, 'cluster_K'] = train_K
    valid_data.loc[:, 'cluster_K'] = valid_K

    def balanced_weight(y_series):
        # 假設有n個類別,訓練資料的標籤為 y_train
        n_classes = len(set(y_series))
        # 計算類別權重
        class_weights = compute_class_weight(class_weight="balanced", classes=np.unique(y_series), y=y_series)
        print('類別權重:', class_weights)
        # 將類別權重轉換為樣本權重(sample weight)
        sample_weights = np.zeros(len(y_series))
        for class_index, weight in enumerate(class_weights):
                sample_weights[y_series == class_index] = weight
        return sample_weights

    def model_fit(X_df,y_series,sample_weights):
        if len(set(y_series))==2:
            objective = 'binary:logistic'
        else:
            objective = 'multi:softmax'
        params = {
            'objective':objective #目標函式
        }

        # 定義XGBoost模型
        xgb_model = xgb.XGBClassifier(
            **params
        ).fit(X_df, y_series,sample_weight=sample_weights)

        return xgb_model

    '''
    模型訓練
    '''
    label = 'Exited'
    X_train = train_data.drop(columns=label)
    y_train = train_data.loc[:, label]
    sample_weights = balanced_weight(y_train)
    xgb_model = model_fit(X_train,y_train,sample_weights)

    '''
    模型預測
    '''
    X_valid = valid_data.drop(columns=label)
    y_valid = valid_data.loc[:, label]
    y_pred_valid = xgb_model.predict(X_valid)
    # 生成分類報告
    report = classification_report(y_valid, y_pred_valid)
    print(f'第{i}輪模型預測報告:')
    print(report)

image
image
image
從模型調優後的三輪交叉驗證結果來看,模型預測正確率有略微提升。

'''
CoxPH模型調優
'''
df_raw = df_data
df_raw.loc[:, 'balance_age'] = df_raw.loc[:, 'Balance']/df_raw.loc[:, 'Age']
df_raw.loc[:, 'salary_age'] = df_raw.loc[:, 'EstimatedSalary']/df_raw.loc[:, 'Age']
df_raw.loc[:, 'isOldOneProduct'] = 0
df_raw.loc[(df_raw.Age>=40)&(df_raw.Age<=60)&(df_raw.NumOfProducts==1), 'isOldOneProduct'] = 1

train_data, valid_data = train_test_split(df_raw, test_size=0.2, stratify=df_raw.loc[:, 'Exited'])
def cluster_train(X_df):
    cols = X_df.select_dtypes(include=['float','int']).columns.tolist()
    # 建立標準化器物件
    scaler = StandardScaler()
    # 提取需要標準化的浮點型變數並轉換為NumPy陣列
    data = X_df[cols].values
    # 對資料進行標準化處理
    scaled_data = scaler.fit_transform(data)
    # 將標準化後的資料更新回DataFrame中的相應列
    X_df[cols] = scaled_data
    # 使用 k-means++ 聚類演算法
    kmeans = KMeans(n_clusters=2, init='k-means++', max_iter=300, n_init=10, random_state=0)
    X_result = kmeans.fit_predict(X_df)
    return X_result, scaler, kmeans

def cluster_transform(X_df, scaler, kmeans):
    cols = X_df.select_dtypes(include=['float','int']).columns.tolist()
    data = X_df[cols].values
    scaled_data = scaler.transform(data)
    X_df[cols] = scaled_data
    X_result = kmeans.predict(X_df)
    return X_result

train_f = train_data.loc[:, ['Age', 'NumOfProducts']]
valid_f = valid_data.loc[:, ['Age', 'NumOfProducts']]
train_K, scaler, kmeans = cluster_train(train_f)
valid_K = cluster_transform(valid_f, scaler, kmeans)
train_data.loc[:, 'cluster_K'] = train_K
valid_data.loc[:, 'cluster_K'] = valid_K

formula = 'Geography + Gender + Age + Balance + NumOfProducts + EstimatedSalary + IsActiveMember + isOldOneProduct + cluster_K'
cox_model = CoxPHFitter(penalizer=0.01, l1_ratio=1)
cox_model = cox_model.fit(train_data, duration_col='Tenure', event_col='Exited',formula=formula)
cox_model.print_summary() # 一致性指數Concordance越接近1,預測效果越好


# 如果變數的影響程度越高(p值越顯著),變數的風險機率也越高,那麼模型的一致性指數就越高
plt.figure(figsize=(7,6))
cox_model.plot(hazard_ratios=True)
plt.xlabel('Hazard Ratios (95% CI)')
plt.title('Hazard Ratios')
plt.show()

image
image
image

'''
CoxPH模型調優
'''
df_predict_0 = cox_model.predict_survival_function(valid_data.loc[valid_data['Exited']==0, :])
df_predict_0

df_predict_1 = cox_model.predict_survival_function(valid_data.loc[valid_data['Exited']==1, :])
df_predict_1

print('正常使用者一致性:', np.round(1 - (df_predict_0.iloc[-1]<0.5).sum()/len(df_predict_0.iloc[-1]),2)*100, '%')
print('流失使用者一致性:', np.round((df_predict_1.iloc[-1]<0.5).sum()/len(df_predict_1.iloc[-1]), 2)*100, '%')
plt.figure(figsize=(6, 6))
observe_index = 5
i = observe_index
observe_df = df_predict_1
while i < observe_index + 5:
    plt.plot(observe_df.loc[:, observe_df.columns[i]], label=observe_df.columns[i])
    i = i + 1
plt.axhline(y=0.5, color='black', linestyle='--')
plt.text(10, 0.47, 'Threshold=0.5', color='black', fontsize=12)  # 在y=0.5處新增文字
plt.xlabel('Timeline')
plt.ylabel('Retain probability')
plt.title('The Churn Trend of Samples')
plt.xticks(range(0, 11, 1))
plt.legend(loc='best')
plt.show()

image
從結果看,CoxPH模型的預測正確率也有一定的提升。
實際上,對於測試集,CoxPH模型應基於XGBoost的預測結果去擬合,因為本身的目的就是去預測那些未知會員使用者是否是流失使用者的。

#%% SHAP分析(基於樹模型)
def focus_importance_sort(data ,model ,explainer_type = 'tree' ,top_num = 3):
    import shap
    import numpy as np
    import pandas as pd

    '''
    putput top n most important features(positive shap value) of each sample 
    param :: data                !dataset
    param :: model               !model
    param :: explainer_type      !explainer type                      default:TreeExplainer
    param :: top_num             !top n most important features       default:3
    '''
    pass


# 區域性可解釋性
test_data = focus_importance_sort(X_valid.reset_index(drop=True),xgb_model) #測試資料;模型
test_data.loc[test_data.y_pred==1, :]

image
image
最後,基於可解釋性SHAP分析,對會員使用者的流失情況進行歸因,提供給運營部門精細建議,實現對使用者的精確召回。
比如使用者3,符合年齡大、年薪高且只辦了一種產品的使用者畫像,這樣的使用者群體往往會更容易成為流失使用者

六、展望

由於開源資料集的侷限性,很多特徵沒辦法去獲取,就如【三、舉例(6)】中而言,得到了這樣的使用者群體,可以進一步分析產品的類別、使用者的產品體驗等因素。
其實最主要的不是模型要達到一個非常好的效果,因為測試集的不同,模型的每次預測也會產生偏差;而是透過模型識別出人為難以判斷的關鍵業務邏輯,解決最真實的業務問題。

相關文章