![見出し画像](https://assets.st-note.com/production/uploads/images/95012226/rectangle_large_type_2_dc6293573b0f2ed63ad0fb6fe8d58517.png?width=1200)
現役カスタマーサクセスが、プログラミング知識0から解約予測をしてみた
はじめに
こんにちは。都内のとあるIT企業でカスタマーサクセス(以後CS)として働くケイと申します。今後益々必要とされるDX人材として、またCSとしてキャリアアップしたいと思い、Pythonによるデータ分析を学んでおりますが、今回はその学びをブログという形でアウトプットしたいと思います。
ご存じの方も多いかと思いますが、CSとは主にSaaSを提供する企業において、顧客に継続してサービスを利用していただくことに責任を持つ職種になります。
なので、顧客のもつさまざまな情報やサービスの利用状況などのデータをもとに、どのような顧客がサービスの利用を継続/解約しているかを分析し、どのような施策が継続率に影響しそうかや、解約しそうな顧客を予測して継続してもらえるようフォローするなど、データ分析を学ぶことでCSとしての業務に活用できそうと考え、学びをスタートしました。
分析フロー
サンプルデータを用意する
データの大枠の確認と前処理
学習用とテスト用にデータを分割
複数のモデルを試す
まとめ
1. サンプルデータを用意する
今回はKaggleにあるTelco Customer Churn(ある通信業者の顧客情報)のサンプルデータを使いたいと思います。
まず、サンプルデータをGoogle colaboratoryに読み込みます。
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
df = pd.read_csv('WA_Fn-UseC_-Telco-Customer-Churn.csv')
df.head(10)
![](https://assets.st-note.com/img/1674799019545-PZdT58VP2g.png?width=1200)
おー。プログラミング初学者にとっては、読み込んだデータがちゃんと表示されるだけで感動ものです。。
2. データの大枠の確認と前処理
次はデータの詳細を確認していきます。
print(df.columns)
df.shape
Index(['customerID', 'gender', 'SeniorCitizen', 'Partner', 'Dependents',
'tenure', 'PhoneService', 'MultipleLines', 'InternetService',
'OnlineSecurity', 'OnlineBackup', 'DeviceProtection', 'TechSupport',
'StreamingTV', 'StreamingMovies', 'Contract', 'PaperlessBilling',
'PaymentMethod', 'MonthlyCharges', 'TotalCharges', 'Churn'],
dtype='object')
(7043, 21)
先ほど読み込んだデータフレームからカラムを取り出したところ、性別、年齢層、パートナーの有無などの顧客情報や、顧客が利用したサービス内容、利用期間、利用料金、支払い方法などがわかり、最後のカラムに「'Churn'=解約」がありました。
解約状況をプロットしてみたいと思います。
#解約状況を可視化
x = [df.Churn[df['Churn']=="No"].count(), df.Churn[df['Churn']=='Yes'].count()]
labels = ['No', 'Yes']
colorlist = ['mistyrose', 'red']
plt.title("Churn Status")
plt.pie(x, startangle=90, counterclock=True, autopct='%.1f%%', pctdistance=0.8, labels=labels, colors=colorlist)
plt.show()
![](https://assets.st-note.com/img/1674799882981-7qIrvoCjUq.png)
Yes = 解約した人が26.5%
No = 解約しなかった人が73.5%
ということがわかりました。
次に、解約と関連がありそうなデータはどれだろうと考え、一旦、「'tenure'=利用期間、'MonthlyCharges',=月額利用料金、'TotalCharges'=合計利用料金」の3つだと仮説を立てました。
解約した人と解約しなかった人の平均利用期間を比較してみます。
#解約した顧客としていない顧客の平均利用期間を見てみる
df.groupby('Churn')['tenure'].mean().plot(kind='bar', color=['mistyrose', 'red'])
plt.xlabel("Churn Status")
plt.ylabel("Average Tenure (months)")
![](https://assets.st-note.com/img/1674801109305-HONaS0DOnr.png)
解約した人は解約しなかった人より、平均の利用期間が半分ほどである(早期解約している)ということがわかりました。
月額利用料金の平均についても比較してみます。
df.groupby('Churn')['MonthlyCharges'].mean().plot(kind='bar', color=['mistyrose', 'red'])
plt.xlabel("Churn Status")
plt.ylabel("MonthlyCharges")
![](https://assets.st-note.com/img/1674801387928-LhPvw2JRcc.png)
解約した人の方が解約しなかった人に比べ、月額利用料金が少し高いということがわかりました。
合計利用料金の平均についても見てみます。
df.groupby('Churn')['TotalCharges'].mean().plot(kind='bar', color=['mistyrose', 'red'])
plt.xlabel("Churn Status")
plt.ylabel("TotalCharges")
TypeError: Could not convert 29.851889.51840.751949.4301.93487.95587.45326.85681.12686.057895.151022.957382.251862.9202.253505.12970.31530.66369.456766.95181.651874.4520.245.257251.73548.3475.74872.35418.254861.45981.453906.7974217.84254.13838.751752.654456.356311.27076.35894.37853.74707.15450.72962957.1244.13650.352497.2930.9887.3549.051090.6570991424.6177.46139.52688.85482.252111.31216.6565.354327.5973.35918.752215.451057927.11009.252570.274.75714.2571077459.054748.71107.220.219.453605.63027.25100.27303.05927.653921.31363.253042.253954.13423.5248.41126.35835.152151.65515.45112.75350.3562.93027.651723.9519.753985.351215.653260.11188.21778.51277.751170.556425.655971.255289.051756.26416.761.351929.951071.4564.357930.555215.25113.51152.81821.95419.91024251.6764.55135.23958.25233.91363.456254.45321.43539.251181.75654.55780.21559.25125245.32453.31023.8582.15244.82379.13173.351375.48129.31192.71901.65587.46519.758041.6520.752681.151112.37405.51033.952958.952684.854179.26654.125.251124.2540.051975.853437.453139.83789.25324.5624.61836.920.2219.351288.752545.752723.154107.255760.654747.51566.9702299.051305.95284.356350.57878.33187.656126.15731.3273.42531.84298.454619.552633.3193.054103.97008.155791.11228.654925.351520.15032.255526.751195.252007.251732.953450.152172.051339.8771.95244.75322.9498.2525.43687.751783.6927.152021.21940.8567.8220.3520.255436.453437.53015.751509.8356.6541093141.71229.12054.43741.853682.4519.251886.254895.1341.65686.41355.13236.35426444.8422.3417...
エラーです。。
データフレームに欠損がないか見ていきます。
df.isnull().sum()
customerID 0
gender 0
SeniorCitizen 0
Partner 0
Dependents 0
tenure 0
PhoneService 0
MultipleLines 0
InternetService 0
OnlineSecurity 0
OnlineBackup 0
DeviceProtection 0
TechSupport 0
StreamingTV 0
StreamingMovies 0
Contract 0
PaperlessBilling 0
PaymentMethod 0
MonthlyCharges 0
TotalCharges 0
Churn 0
dtype: int64
問題なさそう?ですね。
エラーがないか見てみます。
df['TotalCharges'] = pd.to_numeric(df.TotalCharges, errors='coerce')
df.isnull().sum()
customerID 0
gender 0
SeniorCitizen 0
Partner 0
Dependents 0
tenure 0
PhoneService 0
MultipleLines 0
InternetService 0
OnlineSecurity 0
OnlineBackup 0
DeviceProtection 0
TechSupport 0
StreamingTV 0
StreamingMovies 0
Contract 0
PaperlessBilling 0
PaymentMethod 0
MonthlyCharges 0
TotalCharges 11
Churn 0
dtype: int64
エラーが11ありました。
このデータを知る術がないので、'TotalCharges'の平均値を入れることとします。
#先ほどエラーを発見したコードをコメントアウトする
#df['TotalCharges'] = pd.to_numeric(df.TotalCharges, errors='coerce')
#df.isnull().sum()
empty_list = []
list_=[]
for i , value in enumerate(df["TotalCharges"]):
try:
list_.append(float(value))
except:
empty_list.append(i)
mean_TotalCharges = np.nanmean(list_)
mean_TotalCharges = round(mean_TotalCharges, 1)
df.loc[df["TotalCharges"].index.isin(empty_list),"TotalCharges"] = mean_TotalCharges
df["TotalCharges"] = df["TotalCharges"].astype("float")
改めて合計利用料金の平均を見てみます。
#再度掲載
df.groupby('Churn')['TotalCharges'].mean().plot(kind='bar', color=['mistyrose', 'red'])
plt.xlabel("Churn Status")
plt.ylabel("TotalCharges")
![](https://assets.st-note.com/img/1674878446518-90S8DvQbVq.png)
今度はちゃんと表示してくれました。。
月額利用料金の比較とは逆で、解約した人の方がトータルでは利用料金が少ないということがわかりました。
ある程度傾向が見えたので、「'tenure'=利用期間、'MonthlyCharges',=月額利用料金、'TotalCharges'=合計利用料金」を説明変数として使い、「'Churn'=解約」を目的変数として、重回帰分析による予測をしてみようと思います。
「'Churn'=解約」は「Yes or No」で入力されているので、Yesを1にNoを0に変換します。
# 'Churn'のYesを1、NOを0にしたい
df['Churn'].replace('Yes', '1').replace('No', '0').astype(int)
0 0
1 0
2 1
3 0
4 1
..
7038 0
7039 0
7040 0
7041 1
7042 0
Name: Churn, Length: 7043, dtype: int64
これで前処理が完了です。
3. 学習用とテスト用にデータを分割
次に、学習用データとテスト用データに分割してみます。
モデルは試しにロジスティック回帰を使ってみます。
from sklearn.linear_model import LogisticRegression
from sklearn.linear_model import LinearRegression as LR
from sklearn.model_selection import train_test_split
X_name = ['tenure', 'MonthlyCharges', 'TotalCharges']
X = df[X_name]
y = df['Churn']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=0)
# モデルを構築、学習、スコアリング
model = LogisticRegression()
model.fit(X_train, y_train)
model.score(X_test, y_test)
0.7789872219592996
ある程度高いスコアは出ましたが、ほかのモデルも試して一番良いモデルとスコアを見ていきたいと思います。
4. 複数のモデルを試す
前項では試しにロジスティック回帰を使ってみましたが、別のモデルでも試してみることにします。また、適切なハイパーパラメータを取得するためにランダムサーチを行いました。
%%time
import scipy.stats
from sklearn.datasets import load_digits
from sklearn.linear_model import LogisticRegression
from sklearn.svm import LinearSVC
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import RandomizedSearchCV
from sklearn.model_selection import train_test_split
from sklearn.metrics import f1_score
model_param_set_random = {
LogisticRegression(): {
"C": [10 ** i for i in range(-5, 5)],
"random_state": [42]
},
LinearSVC(): {
"C": [10 ** i for i in range(-5, 5)],
"multi_class": ["ovr", "crammer_singer"],
"random_state": [42]
},
SVC(): {
"kernel": ["linear", "poly", "rbf", "sigmoid"],
"C": [10 ** i for i in range(-5, 5)],
"decision_function_shape": ["ovr", "ovo"],
"random_state": [42]
},
DecisionTreeClassifier(): {
"max_depth": [i for i in range(1, 20)],
},
RandomForestClassifier(): {
"n_estimators": [i for i in range(10, 20)],
"max_depth": [i for i in range(1, 10)],
},
KNeighborsClassifier(): {
"n_neighbors": [i for i in range(1, 10)]
}
}
max_score = 0
best_param = None
best_param = None
for model, param in model_param_set_random.items():
clf = RandomizedSearchCV(model, param)
clf.fit(X_train, y_train)
pred_y = clf.predict(X_test)
score = f1_score(y_test, pred_y, average="micro")
if max_score < score:
max_score = score
best_model = model.__class__.__name__
best_param = clf.best_params_
print("学習モデル:{},\nパラメーター:{}".format(best_model, best_param))
print("ベストスコア:",max_score)
学習モデル:RandomForestClassifier,
パラメーター:{'n_estimators': 18, 'max_depth': 9}
ベストスコア: 0.7865593942262186
CPU times: user 51min 8s, sys: 4.63 s, total: 51min 13s
Wall time: 51min 2s
一番良い精度が出たのはランダムフォレストということがわかりました。
また、スコアも少し上がりました。(にしても時間は結構かかりますね・・)
5. まとめ
今回の分析で一番時間がかかったのがデータクレンジングの部分でした。扱ったデータは比較的きれいで欠損の少ないデータだったと思いますが、それでも全体の7~8割くらいの時間を使ったと思います。
データサイエンティストの仕事の大半はデータの整理とお聞きしていましたが、正にそれを実感することとなりました。しかし時間をかけた分、結果が反映されたときの喜びは大きく、学習を続けるモチベーションにもなりました。
結果として、約79%の正答率のモデルを作成することができましたが、顧客の解約を予測するという意味では、十分活用できそうなスコアになったかなと思います。
今回の学習により、Pythonのさまざまなスキルを学ぶことができ、今後のビジネスに活かせていけそうです。しかし、学んだスキルを活かすためには、それぞれのデータの持つ意味をしっかりと考えること、つまり、データ分析はあくまで手段であり、本当に解決したい問題の本質を考えることが重要ということを学びました。