LoginSignup
1
4

文系Python初心者が機械学習で退会予測をしてみた。

Last updated at Posted at 2023-06-13

はじめに

Aidemyのデータ分析講座の集大成としてkaggleのデータを用いて退会予測を成果物として行いました。
私は文系でプログラミングはほぼ未経験、現職もプログラミングやデータ分析とは関係ない職種についています。強いて言うならガジェット好きでせっかくガジェットにこだわりを持っているなら使いこなせるスキルを持っていたいと思い、PowerAppsやExcelVBAを齧ったくらいのレベルです。
そんな私がなぜ、Aidemyのデータ分析講座を受講したのかと言うと、

将来を見据え、自分の価値を上げたい

ITやDXに興味があり、それに関する仕事がしたい

とある機会に機械学習を用いた需要予測という話を聞き、機械学習の存在に出会う

これだ!!(機械学習やデータ分析の世界を目指す)

という流れでデータサイエンティストやAIエンジニアになれるくらいのスキルを身につけたいと思った次第です。
本講座の最後の課題として成果物(ポートフォリオ)を作成し、本ブログにまとめます。
成果物作成にあたり、冗長な部分、逆に不足な部分が出てしまうことがあるかと思いますがご容赦ください。

概要

Kaggleから「telecom_Churn」というデータを使用し、通信サービスの退会予測をしてみました。
https://www.kaggle.com/datasets/ritikasaini/telecom-churn

  • telecom_Churnは通信サービスの会員情報と退会したかどうかの結果が格納されたデータセットです。
  • 会員IDや性別、契約しているサービスの種類が説明変数(特徴量)であり、Churn(退会したかどうか)が目的変数です。
  • 今回はChurnがYesかNoのどちらに分類されるか予測するモデルを作り、テストデータで予測することがゴールとなります。
  • 自分のような機械学習初心者の方でもやっていることがわかるようにまとめていきますので、少しでも参考になれば幸いです。

開発環境

  • MacBook Pro 13(M1)
  • Mac OS Ventura
  • Google Colaboratory

分析の手順

  1. データ取得・準備
  2. 取得したデータの確認と可視化
  3. 説明変数と目的変数(Churn)の相関を確認
  4. モデルの作成と評価
  5. 不均衡な目的変数を均す
  6. ハイパーパラメータのチューニング
  7. まとめ

1. データ取得・準備

# データ(telecom_Churn)をGoogle Driveに保存したので、読み込むための設定
from google.colab import drive
drive.mount('/content/drive')
# 基本的なライブラリのインポート
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
sns.set()

2. 取得したデータの確認と可視化

2-1 データの確認

  • 各カラムの説明
    customerID : 顧客ID
    gender : 性別
    SeniorCitizen : 高齢者
    Partner : 配偶者の有無
    Dependents : 扶養の有無
    tenure : 利用期間
    PhoneService : 電話サービスの契約
    MultipleLines : 複数回線契約(電話サービスを契約することで選択可能)
    InternetService : インターネットサービス
    OnlineSecurity : オンラインセキュリティオプション(インターネットサービスを契約することで選択可能)
    OnlineBackup : オンラインバックアップオプション(インターネットサービスを契約することで選択可能)
    DeviceProtection : 端末保証オプション(インターネットサービスを契約することで選択可能)
    TechSupport : 技術サポートオプション(インターネットサービスを契約することで選択可能)
    StreamingTV : ストリーミングテレビサービス(インターネットサービスを契約することで選択可能)
    StreamingMovies : ストリーミング映画サービス(インターネットサービスを契約することで選択可能)
    Contract : 契約形態(毎月契約、1年契約、2年契約)
    PaperlessBilling : ペーパーレス明細
    PaymentMethod : 支払い方法
    MonthlyCharges : 月額料金
    TotalCharges : 総額料金
    Churn : 退会したかどうか(←目的変数)
# データを読み込む
df = pd.read_csv("drive/My Drive/Colab Notebooks/成果物/Churn.csv")
df.head(10)

スクリーンショット 2023-06-11 21.00.26.png

  • データとなるcsvファイルが格納されているパスは各自の環境によって変わります。
# データの行列数を確認
df.shape

(7043, 21)

# 各カラムのデータ型を確認
df.dtypes

customerID object
gender object
SeniorCitizen int64
Partner object
Dependents object
tenure int64
PhoneService object
MultipleLines object
InternetService object
OnlineSecurity object
OnlineBackup object
DeviceProtection object
TechSupport object
StreamingTV object
StreamingMovies object
Contract object
PaperlessBilling object
PaymentMethod object
MonthlyCharges float64
TotalCharges object
Churn object
dtype: object

2-2 統計量の確認

# カテゴリカルデータを数値から文字列に変換
df = df.astype(
    {
        'SeniorCitizen' : str
    }
)

# データの統計量を表示
display(df.describe()) 

スクリーンショット 2023-06-11 21.01.20.png

# カテゴリカルデータの統計量を表示
display(df.describe(exclude = 'number'))

スクリーンショット 2023-06-11 21.01.51.png

「TotalCharges」についてのデータを確認する。

  • topのTotalCharges列は空白となっている。

  • float型に変換ができないため、文字列や欠損がないか確認

  • 空白値が存在する?(freqが11のため11個あるということか?)

df[df['TotalCharges'].isna()]
print(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

欠損値は検出されない。
最頻値を取得してみる。

# TotalChargesの最頻値を取得
df.loc[:,'TotalCharges'].mode()

0
1 20.2
Name: TotalCharges, dtype: object

  • 下記コードを実行することにより、空白となっている値を削除し、float型に変換できる。
df= df[~pd.to_numeric(df["TotalCharges"], errors="coerce").isna()]
df["TotalCharges"] = df["TotalCharges"].astype(float)

df.dtypes

customerID object
gender object
SeniorCitizen object
Partner object
Dependents object
tenure int64
PhoneService object
MultipleLines object
InternetService object
OnlineSecurity object
OnlineBackup object
DeviceProtection object
TechSupport object
StreamingTV object
StreamingMovies object
Contract object
PaperlessBilling object
PaymentMethod object
MonthlyCharges float64
TotalCharges float64
Churn object

dtype: object

# 再度データ全体の行列を確認
df.shape

(7032, 21)

  • 空白となっていた数値が削除されたため(11個)、7032行のデータになった。
  • 約7000行のデータの内、削除されたデータは11個なので、今回は補完をせず、7032行でデータ分析を行うことにする。

再度統計量を表示する。

# データの統計量を表示
display(df.describe())
# カテゴリカルデータの統計量を表示
display(df.describe(exclude = 'number'))

2-3 統計量から各カラムについて分析する

数値データから
  • tenure(利用期間)は平均が32ヶ月、中央値は29ヶ月の利用であり、最小1ヶ月、最大が72ヶ月である。標準偏差は24ヶ月。
    →最大値と最小値の差や平均値との差から外れ値がある可能性あり。
  • MonthlyCharegrs(月額料金)は平均で64ドル、中央値は70ドルであり、最大が118.75ドル、最小が18.25ドルである。標準偏差は約30ドル。
    →平均が中央値より低いため、利用料金が平均以下の利用者が多いか、100ドル以上の利用客がいることから、外れ値が存在する可能性もある。
  • TotalChargesは利用期間との相関が強いと推測される。契約形態とオプションサービスによって、毎月の利用額は変動する可能性が考えられるため、利用期間と月額料金の掛け算とイコールではないことも考慮に入れる。
カテゴリカルデータから
  • 顧客属性は男性と非高齢者、独身が多い。
  • 利用サービスでは電話、インターネット回線両方ともオプションサービスを利用していない数の方が多い。
  • 契約形態はmonth to monthが多い(月決め)。
  • 支払い方法はElectric check(電子小切手)が多い。
  • 値の種類を確認するためにも全体的に可視化したほうが良さそう。

2-4 データの可視化

A. カテゴリカルデータの可視化
# 会員属性について可視化
fig, axes = plt.subplots(2, 2, figsize=(15, 10))
 
sns.countplot(x='gender', data=df, ax=axes[0,0])
sns.countplot(x='SeniorCitizen', data=df, ax=axes[0,1])
sns.countplot(x='Partner', data=df, ax=axes[1,0])
sns.countplot(x='Dependents', data=df, ax=axes[1,1])

会員属性可視化1.png

可視化の結果から分析する
  • 性別は男性の方が多いがほぼ同じ比率。
  • 高齢者の割合は全体の14%ほどである。
  • 未婚、既婚の割合は性別と同様にほぼ同じ比率だが、未婚者の方がわずかに多い。
  • 扶養に関しては入っていない割合が多い。
# 契約について可視化
fig, axes = plt.subplots(3, 4, figsize=(50, 20))
 
sns.countplot(x='PhoneService', data=df, ax=axes[0,0])
sns.countplot(x='MultipleLines', data=df, ax=axes[0,1])
sns.countplot(x='InternetService', data=df, ax=axes[0,2])
sns.countplot(x='OnlineSecurity', data=df, ax=axes[0,3])
sns.countplot(x='OnlineBackup', data=df, ax=axes[1,0])
sns.countplot(x='DeviceProtection', data=df, ax=axes[1,1])
sns.countplot(x='TechSupport', data=df, ax=axes[1,2])
sns.countplot(x='StreamingTV', data=df, ax=axes[1,3])
sns.countplot(x='StreamingMovies', data=df, ax=axes[2,0])
sns.countplot(x='Contract', data=df, ax=axes[2,1])
sns.countplot(x='PaperlessBilling', data=df, ax=axes[2,2])
sns.countplot(x='PaymentMethod', data=df, ax=axes[2,3])

会員属性可視化2.png

  • PhoneServiceとInternetServiceが基本サービスとなる。ほとんどが電話サービスに加入しているがインターネットサービスのみに加入している会員もいる。
  • 電話サービスを複数回線契約している会員半分に満たない。
  • インターネットサービスはFiber optic(光ファイバー回線)を契約している件数が最も多く、次いでDSLである。
  • インターネットサービスを契約していない会員は約1500人である。
  • 各種オプションサービスは契約していない会員がどのカラムも多いが、StreamingTVとStreamingMoviesはほぼ同率である。→セット契約?念の為、数を抽出してみる。
  • 月極契約の会員が圧倒的に多いが、1年契約より2年契約をする会員の方が多い。
  • 請求書をペーパーレスにしている会員の方が多い。
  • 決済方法は電子小切手による方法が最も多く、他の手段は似たような割合となっている。
# 念の為、StreamingTVとStreamingMoviesはセット契約かどうか確認
print(df[df['StreamingTV'] == 'Yes'].count())
print()
print(df[df['StreamingMovies'] == 'Yes'].count())

customerID 2703
gender 2703
SeniorCitizen 2703
Partner 2703
Dependents 2703
tenure 2703
PhoneService 2703
MultipleLines 2703
InternetService 2703
OnlineSecurity 2703
OnlineBackup 2703
DeviceProtection 2703
TechSupport 2703
StreamingTV 2703
StreamingMovies 2703
Contract 2703
PaperlessBilling 2703
PaymentMethod 2703
MonthlyCharges 2703
TotalCharges 2703
Churn 2703
dtype: int64

customerID 2731
gender 2731
SeniorCitizen 2731
Partner 2731
Dependents 2731
tenure 2731
PhoneService 2731
MultipleLines 2731
InternetService 2731
OnlineSecurity 2731
OnlineBackup 2731
DeviceProtection 2731
TechSupport 2731
StreamingTV 2731
StreamingMovies 2731
Contract 2731
PaperlessBilling 2731
PaymentMethod 2731
MonthlyCharges 2731
TotalCharges 2731
Churn 2731
dtype: int64

  • StreamingTVとStreamingMoviesはセット契約ではないと思われる。
目的変数である「Churn」について

退会していない会員の方が多い。
ここで「Churn」の値を0と1の二値に変換する。

Churnのグラフ.png

# Churnの数値化
Churn_mapping = {'No':0, 'Yes':1}
df['Churn'] = df['Churn'].map(Churn_mapping)

# 可視化
sns.countplot(x='Churn', data=df)

Churnの数値化.png

# 0と1をint型に変換

df = df.astype(
    {
        'Churn' : int
    }
)
B. 数量データの可視化
# tenureの可視化
sns.distplot(df['tenure'])

tenure.png

# MonthlyChargesの可視化
sns.distplot(df['MonthlyCharges'],kde = False)

MonthlyChargesの可視化.png

# TotalChargesの可視化
sns.distplot(df['TotalCharges'])

TotalChargesの可視化.png

  • tenureは短い会員と長い会員が多く、中間が少ない分布となっている。
  • MonthlyChargesは階級が20ドルの度数が最も多く、階級が80ドル付近にも山の頂点がある分布となっている。
  • TotalChargesはMonthlyChargesと連動している部分もあるが、最初に山の頂点がきてからは裾野が広く分布している。

3.目的変数と各カラムの相関を確認

  • 数値データ→ヒートマップとカウントプロット
  • カテゴリカルデータ→カウントプロット
    でそれぞれ可視化する
# ヒートマップの作成
sns.heatmap(
    df[['Churn', 'tenure', 'MonthlyCharges', 'TotalCharges']].corr(),vmax = 1, vmin = -1, annot = True
    )

ヒートマップ.png

  • tenureとTotalCharges、MonthlyChargesとTotalChargeの関係は強い正の相関がある→当然と言えば当然
  • Churnに注目すると、tenureには弱い負の相関がある。MonthlyChargesとTotalChargesはほぼ相関がないと言える。
# Churnとの関係性の可視化①
fig, axes = plt.subplots(2, 2, figsize=(15, 10))
#genderとChurn
sns.countplot(x='gender', hue = 'Churn', data=df, ax=axes[0,0])
#SeniorCitizenとChurn
sns.countplot(x='SeniorCitizen', hue = 'Churn', data=df, ax=axes[0,1])
#PartnerとChurn
sns.countplot(x='Partner', hue = 'Churn', data=df, ax=axes[1,0])
#DependentsとChurn
sns.countplot(x='Dependents', hue = 'Churn', data=df, ax=axes[1,1])

Churnとの関係性1.png

  • 性別による差はなさそう。
  • 高齢者の方が退会する割合が多い。
  • 未婚者の方が退会する割合が多い。
  • 扶養に入っていない会員の方が退会する割合が多い。
# Churnとの関係性の可視化②(契約について可視化)
fig, axes = plt.subplots(3, 4, figsize=(50, 20))
 
sns.countplot(x='PhoneService', hue = 'Churn', data=df, ax=axes[0,0])
sns.countplot(x='MultipleLines', hue = 'Churn', data=df, ax=axes[0,1])
sns.countplot(x='InternetService', hue = 'Churn', data=df, ax=axes[0,2])
sns.countplot(x='OnlineSecurity', hue = 'Churn', data=df, ax=axes[0,3])
sns.countplot(x='OnlineBackup', hue = 'Churn', data=df, ax=axes[1,0])
sns.countplot(x='DeviceProtection', hue = 'Churn', data=df, ax=axes[1,1])
sns.countplot(x='TechSupport', hue = 'Churn', data=df, ax=axes[1,2])
sns.countplot(x='StreamingTV', hue = 'Churn', data=df, ax=axes[1,3])
sns.countplot(x='StreamingMovies', hue = 'Churn', data=df, ax=axes[2,0])
sns.countplot(x='Contract', hue = 'Churn', data=df, ax=axes[2,1])
sns.countplot(x='PaperlessBilling', hue = 'Churn', data=df, ax=axes[2,2])
sns.countplot(x='PaymentMethod', hue = 'Churn', data=df, ax=axes[2,3])

Churnとの関係性2.png

  • 電話サービスは複数回線契約している会員または電話サービス自体契約していない会員の退会率が高い。
  • インターネットサービスを契約している会員に注目すると、光ファイバー回線を契約している会員の退会率が顕著に高い。
  • オプションサービスを契約していない会員の退会率が高い。
  • ストリーミングサービスの契約の有無はあまり差がない。
  • 月極めの会員の方が退会率は高い。
  • 請求書をペーパーレスにしている会員の方が退会率は高い。
  • 電子小切手を利用している会員の方が退会率は高い。他の決済手段を利用している会員同士は差がない。
# Churnとtenureの関係性
fig = sns.FacetGrid(df, col = 'Churn', hue = 'Churn', height = 5)
fig.map(sns.histplot, 'tenure', kde = False)

Churnとtenureの関係性.png

  • 利用期間が短い会員の方が退会率は高い。逆に利用期間が長い方が退会率が低い。
# ChurnとMonthlyChargesの関係性
fig_MonthlyC = sns.FacetGrid(df, col = 'Churn', hue = 'Churn', height = 5)
fig_MonthlyC.map(sns.histplot, 'MonthlyCharges', kde = False)

ChurnとMonthlyCharges.png

  • 月額料金が60ドル以上の会員の退会率が高い。
# ChurnとTotalChargesの関係性
fig_TotalC = sns.FacetGrid(df, col = 'Churn', hue = 'Churn', height = 5)
fig_TotalC.map(sns.histplot, 'TotalCharges', kde = False)

ChurnとTotalChargesの関係性.png

  • 両方とも同じような分布をしている。

可視化による分析結果まとめ

  • 性別とストリーミングサービス、TotalChargesは説明変数として、目的変数に与える影響は少ないと思われる。
  • 一方で、高齢者、未婚者、利用期間、インターネットサービス、オプションサービス、契約形態、支払い手段は退会率に影響する可能性が高い。

4.モデルの作成と評価

4-1 モデルを作成するための前処理

  • カテゴリカル変数のダミー変数化
    カテゴリカル変数をダミー変数化し、モデルに学習させる準備を行う。今回はOne-Hotエンコーディングを用いる。
  • 使用しないカラムの削除
    3の可視化によって目的変数に与える影響が少ないと推定した性別(gender)、ストリーミングサービス(SteramingTV、StreamingMovies)、TotalChargesは取り除き、残ったカテゴリカル変数にOne-Hotエンコーディングを施した新しいDataFrameを作成する。
#不要なカラムの削除
df_cleansing = df.drop(['customerID', 'gender', 'StreamingTV', 'StreamingMovies', 'TotalCharges'], axis=1)
df_cleansing
#カテゴリカル変数のダミー変数化
df_cleansing = pd.get_dummies(df_cleansing)
df_cleansing

4-2 テストデータの作成

  • ホールドアウト法を用いて訓練データとテストデータを作成する。
  • 4-1までで作成した「df_cleansing」の変数に格納したデータを用いる。
#正解ラベルの作成
target = df_cleansing['Churn']
target

0 0
1 0
2 1
3 0
4 1
..
7038 0
7039 0
7040 0
7041 1
7042 0
Name: Churn, Length: 7032, dtype: int64

#df_cleansingにChurn(正解ラベル)が含まれているのでChurnを削除
df_cleansing = df_cleansing.drop(['Churn'], axis = 1)
df_cleansing
#scikit-learnをインポートし、データを分割(8:2)
from sklearn.model_selection import train_test_split

train_X, test_X, train_y, test_y = train_test_split(df_cleansing, target, test_size = 0.2, random_state = 42)
  • 分割後の訓練データの説明変数と正解ラベルを確認
print(train_X.shape)
print(train_y.shape)
print(test_X.shape)
print(test_y.shape)

(5625, 37)
(5625,)
(1407, 37)
(1407,)

  • 約80%に訓練データが分割されていることがわかる。
  • 分割前:7032行、分割後:5625行

4-3 ロジスティック回帰を用いた学習と評価

記念すべき第一回目はロジスティック回帰モデルを用いて学習を行う

#コードの記述
#ロジスティック回帰モデルをインポート
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score

#モデルを定義し、学習
model = LogisticRegression(max_iter=1000)
model.fit(train_X, train_y)

#訓練データに対して予測
y_pred = model.predict(train_X)
#テストデータに対して予測
y_test_pred = model.predict(test_X)

#モデルの予測結果の評価指標
print("訓練データ正解率:{:.3f}".format(accuracy_score(train_y, y_pred)))
print("訓練データ精度/適合率:{:.3f}".format(precision_score(train_y, y_pred)))
print("訓練データ再現率:{:.3f}".format(recall_score(train_y, y_pred)))
print("訓練データF値:{:.3f}".format(f1_score(train_y, y_pred)))
print()
print("テストデータ正解率:{:.3f}".format(accuracy_score(test_y, y_test_pred)))
print("テストデータ精度/適合率{:.3f}".format(precision_score(test_y, y_test_pred)))
print("テストデータ再現率:{:.3f}".format(recall_score(test_y, y_test_pred)))
print("テストデータF値:{:.3f}".format(f1_score(test_y, y_test_pred)))

訓練データ正解率:0.804
訓練データ精度/適合率:0.657
訓練データ再現率:0.551
訓練データF値:0.599

テストデータ正解率:0.790
テストデータ精度/適合率0.626
テストデータ再現率:0.519
テストデータF値:0.567

  • パラメータ「max_iter」が100(デフォルト)だと下記のwarningが出る。
    /usr/local/lib/python3.10/dist-packages/sklearn/svm/_base.py:1244: ConvergenceWarning: Liblinear failed to converge, increase the number of iterations.
    warnings.warn(

  • そのため、「max_iter」を1000にしたところ、warningは消えた。

4-4 その他のモデルで予測を行う。

A.サポートベクタマシンで予測を行う
#線形SVMをインポート
from sklearn.svm import LinearSVC

#モデルを定義して学習
model = LinearSVC(max_iter=1000000)
model.fit(train_X, train_y)

#訓練データに対して予測
y_pred = model.predict(train_X)
#テストデータに対して予測
y_test_pred = model.predict(test_X)

#モデルの予測結果の評価指標
print("訓練データ正解率:{:.3f}".format(accuracy_score(train_y, y_pred)))
print("訓練データ精度/適合率:{:.3f}".format(precision_score(train_y, y_pred)))
print("訓練データ再現率:{:.3f}".format(recall_score(train_y, y_pred)))
print("訓練データF値:{:.3f}".format(f1_score(train_y, y_pred)))
print()
print("テストデータ正解率:{:.3f}".format(accuracy_score(test_y, y_test_pred)))
print("テストデータ精度/適合率{:.3f}".format(precision_score(test_y, y_test_pred)))
print("テストデータ再現率:{:.3f}".format(recall_score(test_y, y_test_pred)))
print("テストデータF値:{:.3f}".format(f1_score(test_y, y_test_pred)))

訓練データ正解率:0.805
訓練データ精度/適合率:0.659
訓練データ再現率:0.548
訓練データF値:0.599

テストデータ正解率:0.790
テストデータ精度/適合率0.631
テストデータ再現率:0.508
テストデータF値:0.563

  • こちらは約80%の正解率となった。

  • パラメータ「max_iter」が100(デフォルト)だと下記ののwarningが出る。
    /usr/local/lib/python3.10/dist-packages/sklearn/svm/_base.py:1244: ConvergenceWarning: Liblinear failed to converge, increase the number of iterations.
    warnings.warn(

  • これは学習が収束しないことによるもので、max_iterを1000000に設定すると消える。その代わり、計算の時間が3分ほどかかるようになった。

B.非線形SVMで予測を行う
#非線形SVMをインポート
from sklearn.svm import SVC

#モデルを定義して学習
model = SVC()
model.fit(train_X, train_y)

#訓練データに対して予測
y_pred = model.predict(train_X)
#テストデータに対して予測
y_test_pred = model.predict(test_X)

#モデルの予測結果の評価指標
print("訓練データ正解率:{:.3f}".format(accuracy_score(train_y, y_pred)))
print("訓練データ精度/適合率:{:.3f}".format(precision_score(train_y, y_pred)))
print("訓練データ再現率:{:.3f}".format(recall_score(train_y, y_pred)))
print("訓練データF値:{:.3f}".format(f1_score(train_y, y_pred)))
print()
print("テストデータ正解率:{:.3f}".format(accuracy_score(test_y, y_test_pred)))
print("テストデータ精度/適合率{:.3f}".format(precision_score(test_y, y_test_pred)))
print("テストデータ再現率:{:.3f}".format(recall_score(test_y, y_test_pred)))
print("テストデータF値:{:.3f}".format(f1_score(test_y, y_test_pred)))

訓練データ正解率:0.792
訓練データ精度/適合率:0.682
訓練データ再現率:0.407
訓練データF値:0.509

テストデータ正解率:0.785
テストデータ精度/適合率0.665
テストデータ再現率:0.388
テストデータF値:0.490

  • 非線形SVMによる予測の結果は約78%となった。
  • ここで、線形SVMで出ていた「Warning」が出なくなった。
C.ランダムフォレストを用いて予測を行う
#ランダムフォレストのインポート
from sklearn.ensemble import RandomForestClassifier

#モデルを定義し学習
model = RandomForestClassifier()
model.fit(train_X, train_y)

#訓練データに対して予測
y_pred = model.predict(train_X)
#テストデータに対して予測
y_test_pred = model.predict(test_X)

#モデルの予測結果の評価指標
print("訓練データ正解率:{:.3f}".format(accuracy_score(train_y, y_pred)))
print("訓練データ精度/適合率:{:.3f}".format(precision_score(train_y, y_pred)))
print("訓練データ再現率:{:.3f}".format(recall_score(train_y, y_pred)))
print("訓練データF値:{:.3f}".format(f1_score(train_y, y_pred)))
print()
print("テストデータ正解率:{:.3f}".format(accuracy_score(test_y, y_test_pred)))
print("テストデータ精度/適合率{:.3f}".format(precision_score(test_y, y_test_pred)))
print("テストデータ再現率:{:.3f}".format(recall_score(test_y, y_test_pred)))
print("テストデータF値:{:.3f}".format(f1_score(test_y, y_test_pred)))

訓練データ正解率:0.997
訓練データ精度/適合率:0.994
訓練データ再現率:0.995
訓練データF値:0.994

テストデータ正解率:0.775
テストデータ精度/適合率0.596
テストデータ再現率:0.481
テストデータF値:0.533

  • 過学習している?
D.k-NNを用いて予測を行う。
#k-NNのインポート
from sklearn.neighbors import KNeighborsClassifier

#モデルを定義し学習
model = KNeighborsClassifier()
model.fit(train_X, train_y)

#訓練データに対して予測
y_pred = model.predict(train_X)
#テストデータに対して予測
y_test_pred = model.predict(test_X)

#モデルの予測結果の評価指標
print("訓練データ正解率:{:.3f}".format(accuracy_score(train_y, y_pred)))
print("訓練データ精度/適合率:{:.3f}".format(precision_score(train_y, y_pred)))
print("訓練データ再現率:{:.3f}".format(recall_score(train_y, y_pred)))
print("訓練データF値:{:.3f}".format(f1_score(train_y, y_pred)))
print()
print("テストデータ正解率:{:.3f}".format(accuracy_score(test_y, y_test_pred)))
print("テストデータ精度/適合率{:.3f}".format(precision_score(test_y, y_test_pred)))
print("テストデータ再現率:{:.3f}".format(recall_score(test_y, y_test_pred)))
print("テストデータF値:{:.3f}".format(f1_score(test_y, y_test_pred)))

訓練データ正解率:0.846
訓練データ精度/適合率:0.735
訓練データ再現率:0.656
訓練データF値:0.694

テストデータ正解率:0.751
テストデータ精度/適合率0.534
テストデータ再現率:0.481
テストデータF値:0.506

  • どのモデルもテストデータでは75%〜80%の範囲の正解率となった。
  • 精度は52%~66%となった。
  • 今回採用したモデルを正解率の高成績順に並べると
  1. サポートベクタマシン
    ロジスティック回帰
  2. 非線形SVM
  3. ランダムフォレスト
  4. k-NN
    という結果になった。

5. 不均衡な目的変数を均す

  • Churnは0(退会しない)が約5000件、1(退会)が約2000件と差がある。そのため、モデルが0と予測すれば約70%は正解することになり、良いモデルとは言えなくなる。そこで、Churnの0と1の割合を同じにしてモデルに学習させてみる。
  • 0(退会しない)を1(退会)の件数と同じ数にダウンサンプリングする。また、サンプリングはランダムに行う。
  • ダウンサンプリングの後、1(退会)のみのデータと結合して新しいDataFrameを作成する。
# 目的変数の各値の個数を確認
target.value_counts()

0 5163
1 1869
Name: Churn, dtype: int64

  • 0(退会しない)が5163件
  • 1(退会)が1869件
# Churnが0のデータを抽出
df_Churn_0 = df.query('Churn == 0')
df_Churn_0

スクリーンショット 2023-06-11 21.03.36.png

# Churnが0のデータをダウンサンプリング
df_sample_0 = df_Churn_0.sample(1869, random_state=0)
df_sample_0

スクリーンショット 2023-06-11 21.03.36.png

#同じようにChurnが1の行のみ抽出
df_Churn_1 = df.query('Churn == 1')
df_Churn_1

スクリーンショット 2023-06-11 21.03.55.png

#抽出したデータを結合
df3=pd.concat([df_sample_0,df_Churn_1],ignore_index=True)
df3

スクリーンショット 2023-06-11 21.04.08.png

# 結合した結果の可視化
sns.countplot(x='Churn', data=df3)

Churnダウンサンプリング.png

データのダウンサンプリングが完了した。

#不要なカラムの削除
df_cleansing2 = df3.drop(['customerID', 'gender', 'StreamingTV', 'StreamingMovies', 'TotalCharges'], axis=1)

#カテゴリカル変数のダミー変数化
df_cleansing2 = pd.get_dummies(df_cleansing2)
df_cleansing2
#正解ラベルの作成
target2 = df_cleansing2['Churn']
target2
#df_cleansingにChurn(正解ラベル)が含まれているのでChurnを削除
df_cleansing2 = df_cleansing2.drop(['Churn'], axis = 1)
df_cleansing2
#scikit-learnをインポートし、データを分割(8:2)
from sklearn.model_selection import train_test_split

train_X, test_X, train_y, test_y = train_test_split(df_cleansing2, target2, test_size = 0.2, random_state = 42)

print(train_X.shape)
print(train_y.shape)
print(test_X.shape)
print(test_y.shape)

(2990, 37)
(2990,)
(748, 37)
(748,)

4と同じようにモデルを定義し学習させる。コードは省略。
結果は以下のようになった。

ロジスティック回帰

訓練データ正解率:0.763
訓練データ精度/適合率:0.748
訓練データ再現率:0.795
訓練データF値:0.771

テストデータ正解率:0.754
テストデータ精度/適合率0.737
テストデータ再現率:0.780
テストデータF値:0.758

線形SVM

訓練データ正解率:0.761
訓練データ精度/適合率:0.744
訓練データ再現率:0.799
訓練データF値:0.770

テストデータ正解率:0.751
テストデータ精度/適合率0.735
テストデータ再現率:0.775
テストデータF値:0.755

非線形SVM

訓練データ正解率:0.737
訓練データ精度/適合率:0.726
訓練データ再現率:0.763
訓練データF値:0.744

テストデータ正解率:0.743
テストデータ精度/適合率0.729
テストデータ再現率:0.764
テストデータF値:0.746

ランダムフォレスト

訓練データ正解率:0.999
訓練データ精度/適合率:0.997
訓練データ再現率:1.000
訓練データF値:0.999

テストデータ正解率:0.742
テストデータ精度/適合率0.730
テストデータ再現率:0.756
テストデータF値:0.743

k-NN

訓練データ正解率:0.798
訓練データ精度/適合率:0.781
訓練データ再現率:0.830
訓練データF値:0.805

テストデータ正解率:0.741
テストデータ精度/適合率0.716
テストデータ再現率:0.786
テストデータF値:0.749

6. ハイパーパラメータのチューニング

少しでも精度を上げるためにハイパーパラメータのチューニングにチャレンジする。

  • ランダムサーチによって精度の良いパラメータの組み合わせを探索する。
#ランダムサーチのモジュールをインポート
from sklearn.model_selection import RandomizedSearchCV
#scipy.statsもインポート
import scipy.stats
ロジスティック回帰のハイパーパラメーター探索
#ハイパーパラメーターの値の候補を設定
model_param_set_random = {
    LogisticRegression() : {
        "penalty" : ["l1", "l2"],
        "C" : scipy.stats.uniform(0.00001, 1000),
        "solver" : ["newton-cg", "lbfgs", "liblinear", "sag", "saga"],
        "multi_class" : ["ovr", "multinomial", "auto"],
        "max_iter" : [2000],
        "random_state": [42]
    }
}

max_score = 0
best_param = None

#ランダムサーチでハイパーパラメーター探索
for model, param in model_param_set_random.items():
  clf = RandomizedSearchCV(model, param)
  clf.fit(train_X, train_y)
  pred_y = clf.predict(test_X)
  score = accuracy_score(test_y, pred_y)
  if max_score < score:
    max_score = score
    best_param = clf.best_params_
  

print("ハイパーパラメーター:{}".format(best_param))
print("ベストスコア:", max_score)
LogisticRegression = LogisticRegression()
LogisticRegression.fit(train_X, train_y)
print()
print('調整なし')
print(LogisticRegression.score(test_X, test_y))

ハイパーパラメーター:{'C': 370.00524221133185, 'max_iter': 2000, 'multi_class': 'auto', 'penalty': 'l1', 'random_state': 42, 'solver': 'saga'}
ベストスコア: 0.7526737967914439

調整なし
0.7513368983957219

  • 割愛しているが、warningも出力された。これはランダムサーチでパラメータの組み合わせる上で学習がうまくいかない組み合わせも存在するためである。
非線形SVMのハイパーパラメーター探索
#ハイパーパラメーターの値の候補を設定
model_param_set_random = {
    SVC() : {
        "kernel": ["linear", "poly", "rbf", "sigmoid"],
        "C": scipy.stats.uniform(0.00001, 1000),
        "decision_function_shape": ["ovr", "ovo"],
        "random_state": [42]
    }
}

max_score = 0
best_param = None

#ランダムサーチでハイパーパラメーター探索
for model, param in model_param_set_random.items():
  clf = RandomizedSearchCV(model, param)
  clf.fit(train_X, train_y)
  pred_y = clf.predict(test_X)
  score = accuracy_score(test_y, pred_y)
  if max_score < score:
    max_score = score
    best_param = clf.best_params_
  

print("ハイパーパラメーター:{}".format(best_param))
print("ベストスコア:", max_score)

svm = SVC()
svm.fit(train_X, train_y)
print()
print('調整なし')
print(svm.score(test_X, test_y))

ハイパーパラメーター:{'C': 819.7528770216127, 'decision_function_shape': 'ovr', 'kernel': 'rbf', 'random_state': 42}
ベストスコア: 0.7647058823529411

調整なし
0.7433155080213903

ランダムフォレストのハイパーパラメーター探索
#ハイパーパラメーターの値の候補を設定
model_param_set_random = {
    RandomForestClassifier() : {
        "n_estimators": [i for i in range(1, 21)],
        "criterion": ["gini", "entropy"],
        "max_depth":[i for i in range(1, 5)],
        "random_state": [42]
    }
}

max_score = 0
best_param = None

#ランダムサーチでハイパーパラメーター探索
for model, param in model_param_set_random.items():
  clf = RandomizedSearchCV(model, param)
  clf.fit(train_X, train_y)
  pred_y = clf.predict(test_X)
  score = accuracy_score(test_y, pred_y)
  if max_score < score:
    max_score = score
    best_param = clf.best_params_
  

print("ハイパーパラメーター:{}".format(best_param))
print("ベストスコア:", max_score)

RandomForest = RandomForestClassifier()
RandomForest.fit(train_X, train_y)
print()
print('調整なし')
print(RandomForest.score(test_X, test_y))

ハイパーパラメーター:{'random_state': 42, 'n_estimators': 17, 'max_depth': 4, 'criterion': 'entropy'}
ベストスコア: 0.7513368983957219

調整なし
0.7406417112299465

k-NNのハイパーパラメーター探索
#ハイパーパラメーターの値の候補を設定
model_param_set_random = {
    KNeighborsClassifier() : {
        "n_neighbors": [i for i in range(1, 51)],
        "weights": ["uniform" , "distance"],
        "algorithm":["ball_tree", "kd_tree", "brute", "auto"]
    }
}

max_score = 0
best_param = None



#ランダムサーチでハイパーパラメーター探索
for model, param in model_param_set_random.items():
  clf = RandomizedSearchCV(model, param)
  clf.fit(train_X, train_y)
  pred_y = clf.predict(test_X)
  score = accuracy_score(test_y, pred_y)
  if max_score < score:
    max_score = score
    best_param = clf.best_params_
  

print("ハイパーパラメーター:{}".format(best_param))
print("ベストスコア:", max_score)

KNeighborsClassifier = KNeighborsClassifier()
KNeighborsClassifier.fit(train_X, train_y)
print()
print('調整なし')
print(KNeighborsClassifier.score(test_X, test_y))

ハイパーパラメーター:{'weights': 'distance', 'n_neighbors': 48, 'algorithm': 'auto'}
ベストスコア: 0.75

調整なし
0.7406417112299465

  • どのモデルも調整なしの場合より結果が良くなった。
    ※線形SVMのランダムサーチは省略した。

7. まとめ

今回のデータセットを用いて学習を行った結果、正解率は75%前後となった。ダウンサンプリング実施前に比べ、正解率は下がる結果となった。また、ランダムサーチの結果は大きな改善は得られなかった。
ランダムサーチの結果、最もベストな正解率となったのは非線形SVM(0.7647058823529411)となった。

今後の活用

今回は特徴量エンジニアリングは行いませんでした。今回採用しなかった特徴量に注目してみたり、新たな特徴量を作ってみたりすることでモデルの精度がアップする可能性も残されているので、やってみるのもありだと思います。
YesかNoの二値データだけが集まったデータというのはあまり良いデータではないというのも学びました。今はまだデータを選べる側なので、データの選定の仕方という面も大事であることも勉強になりました。
データ分析とは別の話になりますが、Colabや本記事を作成するにあたりマークダウン記法も学ぶことができ、これも良い経験になりました。

終わりに

これまでは用意されたカリキュラムやデータを基に書いてある通りにコードを書いて、データ分析がどういう流れで行われ、機械学習がどんな仕組みで行われるのかを学んできました。今回、講座の仕上げとして自分でデータを探し、コーディング、前処理、可視化、モデルの作成、ハイパーパラメータチューニングを実践しました。結果はあまり良くはありませんでしたが、自分で考え、手を動かすことが一番理解が進むと思いました。今回は二値分類にチャレンジしましたが、回帰や画像認識などの分野も挑戦して、スキルアップをしていきたいです。また、G検定やE資格をはじめとしたAI系の資格やデータサイエンティスト検定、統計検定の資格取得を目指して努力を継続していきます。
的確なアドバイスやコーディング指導でサポートしてくださったアイデミーのチューターさんには感謝申し上げます。ありがとうございました。

1
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
4