前回は、Voting、Bagging、ランダムフォレスト、Boosting、Stacking などのアンサンブル学習を見ました。
今回は、ポルトガルの銀行が実施した電話マーケティングの公開データを使い、データの確認、探索的データ分析、前処理、交差検証、複数モデルの比較、ROC 曲線による評価までを一通り実践します。
扱う課題は、「顧客が定期預金を申し込むかどうか」を予測する2値分類です。
目次
- 分析の目的とデータセットの出典
- 各変数の意味
- データを読み込み、全体像を確認する
- 目的変数の偏りと
unknownの扱い - 探索的データ分析
-
durationが引き起こす情報漏洩 - 数値変数とカテゴリ変数を前処理する
-
DataFrameSelectorとクラス継承 - 訓練データとテストデータを分ける
- 交差検証で8種類の分類器を比較する
- ROC 曲線と AUC でモデルを評価する
- 分析結果をマーケティング施策へつなげる
- 実装と評価で注意すべき点
- まとめ
1. 分析の目的とデータセットの出典
このデータは、ポルトガルの銀行が実施したダイレクトマーケティングの記録です。マーケティングは電話を中心に行われ、同じ顧客へ複数回連絡する場合もありました。
銀行が販売したい商品は定期預金です。目的変数 y は、顧客が最終的に定期預金を申し込んだかどうかを表します。
-
yes:定期預金を申し込んだ -
no:定期預金を申し込まなかった
したがって、今回の課題は次のように整理できます。
1.1 公開元
データセットは UCI Machine Learning Repository で公開されています。
- データセットページ:Bank Marketing
- DOI:10.24432/C5K306
- 公開ZIPファイル:bank+marketing.zip
関連論文として、次の研究が紹介されています。
S. Moro, P. Cortez, and P. Rita,
“A Data-Driven Approach to Predict the Success of Bank Telemarketing,”
Decision Support Systems, 2014.
DOI: 10.1016/j.dss.2014.03.001
また、データに同梱されている説明文では、旧版データに関する次の論文も引用されています。
S. Moro, R. Laureano, and P. Cortez,
“Using Data Mining for Bank Direct Marketing: An Application of the CRISP-DM Methodology,”
ESM 2011.
1.2 どのファイルを使うのか
UCI の配布物には、似た名前のデータが複数あります。
| ファイル | 行数 | 入力変数 | 位置付け |
|---|---|---|---|
bank-additional-full.csv |
41,188 | 20 | 追加変数を含む新版の全件データ |
bank-additional.csv |
4,119 | 20 | 上記から抽出した小規模版 |
bank-full.csv |
45,211 | 16 | 旧版の全件データ |
bank.csv |
4,521 | 16 |
bank-full.csv から抽出した小規模版 |
この記事では bank-full.csv を使用します。データ規模は次の通りです。
- レコード数:45,211件
- 説明変数:16個
- 目的変数:1個
- 合計列数:17列
ファイル名がよく似ていますが、41,188件なのは bank-additional-full.csv です。bank-full.csv と混同しないように注意してください。
2. 各変数の意味
変数は、顧客属性、今回の電話接触、過去のキャンペーン履歴、目的変数の4つに分けると理解しやすくなります。
2.1 顧客に関する情報
| 列名 | 種類 | 意味 |
|---|---|---|
age |
数値 | 年齢 |
job |
カテゴリ | 職業 |
marital |
カテゴリ | 婚姻状況 |
education |
カテゴリ | 最終学歴 |
default |
カテゴリ | 債務不履行の有無 |
balance |
数値 | 年間平均残高。単位はユーロ |
housing |
カテゴリ | 住宅ローンの有無 |
loan |
カテゴリ | 個人ローンの有無 |
2.2 今回のキャンペーンにおける最後の接触
| 列名 | 種類 | 意味 |
|---|---|---|
contact |
カテゴリ | 連絡手段 |
day |
数値 | 最後に連絡した日 |
month |
カテゴリ | 最後に連絡した月 |
duration |
数値 | 最後の通話時間。単位は秒 |
2.3 その他のキャンペーン履歴
| 列名 | 種類 | 意味 |
|---|---|---|
campaign |
数値 | 今回のキャンペーンで当該顧客へ連絡した回数 |
pdays |
数値 | 前回のキャンペーンで連絡してから経過した日数。-1 は過去に連絡していないことを表す |
previous |
数値 | 今回より前のキャンペーンで連絡した回数 |
poutcome |
カテゴリ | 前回のキャンペーン結果 |
2.4 目的変数
| 列名 | 種類 | 意味 |
|---|---|---|
y |
2値 | 定期預金を申し込んだか |
なお、このデータには年齢、職業、婚姻状況、学歴といった個人属性が含まれます。分析結果を実際の施策に使う場合は、精度だけでなく、公平性、説明責任、利用目的の妥当性も確認する必要があります。
3. データを読み込み、全体像を確認する
まず、分析に必要なライブラリを読み込みます。
import time
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
average_precision_score,
RocCurveDisplay,
roc_auc_score,
)
from sklearn.model_selection import (
StratifiedKFold,
cross_val_score,
train_test_split,
)
from sklearn.naive_bayes import GaussianNB
from sklearn.neighbors import KNeighborsClassifier
from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
データファイルを data フォルダに置いた場合は、次のように読み込めます。
bank = pd.read_csv("./data/bank-full.csv", sep=";")
bank.head()
このCSVはカンマではなくセミコロンで区切られているため、sep=";" が必要です。
データの大きさも確認します。
bank.shape
(45211, 17)
3.1 数値変数の要約
describe() を使うと、数値変数の件数、平均、標準偏差、最小値、四分位点、最大値を確認できます。
bank.describe()
この段階では、次のような点を見ます。
- 年齢や残高の範囲が不自然ではないか
-
campaignのような回数変数に極端な値がないか -
pdays=-1のように、特別な意味を持つ値がないか -
durationが右に長い裾を持つ分布になっていないか
3.2 カテゴリ変数の要約
カテゴリ変数については、include=["object", "string"] を指定します。
bank.describe(include=["object", "string"])
各列について、異なる値の数、最頻値、その出現回数が表示されます。
3.3 データ型と欠損値
bank.info()
info() の結果では、すべての列が45,211件の値を持っており、通常の NaN はありません。
ただし、カテゴリ変数には文字列の unknown が含まれています。これは機械的には欠損値ではありませんが、「情報が不明」という意味を持つため、分析上は欠損に近い値として確認する必要があります。
4. 目的変数の偏りと unknown の扱い
4.1 目的変数の分布
target_counts = bank["y"].value_counts()
target_rates = bank["y"].value_counts(normalize=True)
print(target_counts)
print(target_rates)
結果は次の通りです。
| クラス | 件数 | 割合 |
|---|---|---|
no |
39,922 | 88.30% |
yes |
5,289 | 11.70% |
円グラフで確認するなら、次のように書けます。
colors = ["#FA5858", "#64FE2E"]
ax = bank["y"].value_counts().plot.pie(
figsize=(5, 5),
explode=[0, 0.25],
autopct="%1.2f%%",
shadow=True,
colors=colors,
startangle=25,
)
ax.set_title("定期預金申込の有無")
ax.set_ylabel("")
plt.show()
このデータでは no が圧倒的に多いため、単純な正解率だけでモデルを評価すると危険です。
たとえば、何も学習せず、すべての顧客を no と予測するだけでも約88.3%の正解率になります。しかし、そのモデルは申込み可能性のある顧客を1人も見つけられません。
そこで今回は、主に ROC-AUC を使ってモデルを比較します。陽性クラスが少ないことを考えると、適合率と再現率の関係を見る PR-AUC、Scikit-learn では average_precision_score も併せて確認するとよいでしょう。
4.2 unknown の件数
for column in bank.select_dtypes(include=["object", "string"]).columns:
count = (bank[column] == "unknown").sum()
print(f"{column}: {count}")
主な結果は次の通りです。
| 列名 |
unknown の件数 |
|---|---|
job |
288 |
marital |
0 |
education |
1,857 |
default |
0 |
housing |
0 |
loan |
0 |
contact |
13,020 |
month |
0 |
poutcome |
36,959 |
y |
0 |
unknown への代表的な対応は、次の4つです。
-
unknownが少ない行を削除する - 最頻値などで補完する
- 情報がある行から
unknownの値を予測する -
unknown自体を1つのカテゴリとして残す
ここでは、4番目の方法を採用します。
これは妥当な選択肢です。特に poutcome の unknown は、単に値が欠けているのではなく、「過去のキャンペーンに参加していないため結果がない」という状態を含んでいる可能性があります。その場合、unknown を独立したカテゴリとして残すことで、状態そのものをモデルに伝えられます。
bank["poutcome"].value_counts()
ただし、unknown の意味が列ごとに同じとは限りません。欠損処理は件数だけで決めず、データが作られた業務プロセスまで確認することが大切です。
5. 探索的データ分析
5.1 数値変数の分布
数値変数をまとめて確認するには、DataFrame の hist() が便利です。
bank.hist(bins=25, figsize=(14, 10))
plt.tight_layout()
plt.show()
DataFrame 全体に対して hist() を呼び出しても、文字列で保存されているカテゴリ変数は表示されません。数値変数だけが自動的に選ばれます。
ヒストグラムを見ると、balance、duration、campaign、previous などには、値が小さい側へ多くのサンプルが集まり、一部の大きな値が右側へ長く伸びる分布が見られます。
5.2 学歴と申込率
学歴と申込結果の関係を確認します。ここで知りたいのは学歴ごとの件数ではなく、各グループの中で yes が占める割合です。crosstab() を使うと、その割合を直接計算できます。
education_rate = pd.crosstab(
bank["education"],
bank["y"],
normalize="index",
).mul(100)
education_rate[["yes"]].sort_values("yes", ascending=False).plot.bar(
figsize=(8, 5),
legend=False,
color="tab:blue",
)
plt.title("学歴別の定期預金申込率")
plt.xlabel("学歴")
plt.ylabel("申込率(%)")
plt.xticks(rotation=0)
plt.show()
学歴別の申込率は、おおむね次のようになります。
| 学歴 | 申込率 |
|---|---|
tertiary |
15.01% |
unknown |
13.57% |
secondary |
10.56% |
primary |
8.63% |
このデータでは、高等教育を受けたグループの申込率が比較的高くなっています。
ただし、これは学歴が申込みを直接引き起こしたことを意味しません。年齢、職業、残高、接触時期などが同時に関係している可能性があります。探索的分析で確認できるのは、まず「関連が見える」というところまでです。
5.3 職業、年齢、婚姻状況
職業別の申込率を見ると、学生と退職者が比較的高くなります。
job_rate = (
bank.assign(target=(bank["y"] == "yes").astype(int))
.groupby("job", observed=True)["target"]
.mean()
.sort_values(ascending=False)
.mul(100)
)
job_rate.plot.bar(figsize=(10, 5), color="tab:green")
plt.title("職業別の定期預金申込率")
plt.xlabel("職業")
plt.ylabel("申込率(%)")
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
plt.show()
主なグループの申込率は次の通りです。
| 職業 | 申込率 |
|---|---|
student |
28.68% |
retired |
22.79% |
unemployed |
15.50% |
management |
13.76% |
blue-collar |
7.28% |
年齢を職業や婚姻状況と組み合わせると、次のような傾向も確認できます。
- 退職者では、高年齢層に申込者が多く見られる
- 自営業者では、年齢分布の違いが結果に表れている
- 既婚者や離婚経験者では、単身者とは異なる年齢分布が見られる
このような交互作用を見るには、単一変数の棒グラフだけでなく、箱ひげ図や層別集計を使います。
plt.figure(figsize=(12, 6))
sns.boxplot(data=bank, x="job", y="age", hue="y")
plt.xticks(rotation=45, ha="right")
plt.title("職業・年齢・申込結果の関係")
plt.tight_layout()
plt.show()
5.4 数値変数間の相関
目的変数 y を 0 と 1 に変換し、数値変数との相関を確認します。
bank_for_corr = bank.copy()
bank_for_corr["y"] = (bank_for_corr["y"] == "yes").astype(int)
numeric_bank = bank_for_corr.select_dtypes(include="number")
corr_numeric = numeric_bank.corr()
plt.figure(figsize=(12, 8))
sns.heatmap(
corr_numeric,
annot=True,
vmin=-1,
vmax=1,
cmap="Blues",
fmt=".2f",
)
plt.title("数値変数の相関行列")
plt.tight_layout()
plt.show()
ここでは、通話時間 duration と目的変数 y の関係が目立ちます。
通話が長いほど、顧客が商品に関心を示している可能性は高くなります。一方、通話時間が0秒なら、定期預金を申し込むことは考えにくいでしょう。
ただし、この変数には実務上の大きな問題があります。
6. duration が引き起こす情報漏洩
duration は最後の通話が何秒続いたかを表します。
顧客へ電話をかける前に申込確率を予測したい場合、将来の通話時間はまだ分かりません。つまり、予測時点では利用できない情報です。
UCI の公式説明にも、duration は予測結果へ強い影響を与える一方、現実的な事前予測モデルでは除外すべきだと明記されています。
6.1 平均通話時間の上下で比較する
通話時間を平均以下と平均超に分けてみます。平均通話時間は約258.16秒です。
average_duration = bank["duration"].mean()
duration_group = np.where(
bank["duration"] <= average_duration,
"平均以下",
"平均超",
)
duration_rate = pd.crosstab(
duration_group,
bank["y"],
normalize="index",
).mul(100)
duration_rate
結果は次の通りです。
| 通話時間 | no |
yes |
|---|---|---|
| 平均以下 | 95.16% | 4.84% |
| 平均超 | 74.54% | 25.46% |
平均を超える通話では申込率が大きく上がっています。ただし、「平均を超えると95%が申し込む」という意味ではありません。約95%という数値は、平均以下のグループで申し込まなかった割合です。
6.2 ベンチマーク用と実運用用を分ける
duration を含めるかどうかは、モデルの目的によって決めます。
| モデル | duration |
用途 |
|---|---|---|
| ベンチマークモデル | 含める | 公開研究などと条件をそろえて性能比較する |
| 事前予測モデル | 含めない | 電話をかける前に、連絡対象者の優先順位を決める |
実運用を想定するなら、duration を含まないモデルを主モデルにします。
numeric_features_without_duration = [
"age",
"balance",
"day",
"campaign",
"pdays",
"previous",
]
「学習時に存在する列だから使う」のではなく、「予測を行う時点で本当に利用できる列か」を考えることが、情報漏洩を防ぐ基本です。
7. 数値変数とカテゴリ変数を前処理する
多くの機械学習モデルは、文字列のカテゴリをそのまま扱えません。
今回のデータには、次の2種類の変数があります。
- 数値変数:標準化する
- カテゴリ変数:ワンホットエンコーディングする
7.1 列を分ける
まず、duration を含むベンチマーク用の列構成を定義します。
numeric_features = [
"age",
"balance",
"day",
"campaign",
"pdays",
"previous",
"duration",
]
categorical_features = [
"job",
"education",
"marital",
"default",
"housing",
"loan",
"contact",
"month",
"poutcome",
]
7.2 ColumnTransformer で処理をまとめる
現在の Scikit-learn では、列ごとに異なる処理を適用する場合、ColumnTransformer が分かりやすい方法です。
preprocessor = ColumnTransformer(
transformers=[
(
"numeric",
StandardScaler(),
numeric_features,
),
(
"categorical",
OneHotEncoder(
handle_unknown="ignore",
sparse_output=False,
),
categorical_features,
),
]
)
StandardScaler は、各数値変数を平均0、標準偏差1に近いスケールへ変換します。
$$
z=\frac{x-\mu}{\sigma}
$$
ロジスティック回帰、SVM、k近傍法、ニューラルネットワークなどは変数のスケールに影響を受けやすいため、標準化が有効です。
OneHotEncoder は、カテゴリを0と1の列へ展開します。たとえば housing が yes と no を取る場合、それぞれに対応する列を作ります。
handle_unknown="ignore" を指定しておくと、訓練データになかったカテゴリがテストデータに現れても、変換時にエラーになりません。
8. DataFrameSelector とクラス継承
Scikit-learn の独自変換器を理解するため、DataFrame から指定した列だけを選ぶクラスを見てみましょう。
class DataFrameSelector(BaseEstimator, TransformerMixin):
def __init__(self, attribute_names):
self.attribute_names = attribute_names
def fit(self, X, y=None):
return self
def transform(self, X):
return X[self.attribute_names]
8.1 このクラスは何を継承しているのか
class DataFrameSelector(BaseEstimator, TransformerMixin):
この書き方は多重継承です。DataFrameSelector は、次の2つのクラスを継承しています。
BaseEstimatorTransformerMixin
BaseEstimator は、Scikit-learn の推定器としてパラメータを取得・設定する仕組みを提供します。これにより、get_params() や set_params() が使えるようになり、Pipeline やパラメータ探索と連携しやすくなります。
TransformerMixin は、fit() と transform() を実装したクラスへ fit_transform() を提供します。
8.2 各メソッドの役割
__init__() では、選択する列名を保存します。
def __init__(self, attribute_names):
self.attribute_names = attribute_names
fit() では学習する情報がないため、自分自身をそのまま返します。
def fit(self, X, y=None):
return self
transform() で、指定した列だけを DataFrame から取り出します。
def transform(self, X):
return X[self.attribute_names]
たとえば、次のように使えます。
selector = DataFrameSelector(["age", "balance"])
selected = selector.fit_transform(bank)
selected.head()
8.3 Pipeline と FeatureUnion を使う方法
数値変数用とカテゴリ変数用の Pipeline を別々に作り、FeatureUnion で結合することもできます。
from sklearn.pipeline import FeatureUnion
numerical_pipeline = Pipeline([
(
"select_numeric",
DataFrameSelector([
"age",
"balance",
"day",
"campaign",
"pdays",
"previous",
"duration",
]),
),
("std_scaler", StandardScaler()),
])
categorical_pipeline = Pipeline([
(
"select_categorical",
DataFrameSelector([
"job",
"education",
"marital",
"default",
"housing",
"loan",
"contact",
"month",
"poutcome",
]),
),
(
"onehot",
OneHotEncoder(
handle_unknown="ignore",
sparse_output=False,
),
),
])
preprocess_pipeline = FeatureUnion([
("numeric", numerical_pipeline),
("categorical", categorical_pipeline),
])
Pipeline は処理を直列につなぎます。
FeatureUnion は、複数の処理結果を横方向へ結合します。
この構造を理解することには意味がありますが、現在は ColumnTransformer を使えば、列の選択と処理の振り分けをより短く表現できます。
9. 訓練データとテストデータを分ける
目的変数を0と1へ変換します。
X = bank.drop(columns="y")
y = (bank["y"] == "yes").astype(int)
次に、訓練データ80%、テストデータ20%へ分割します。
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.20,
stratify=y,
random_state=44,
)
stratify=y を指定すると、訓練データとテストデータの両方で、yes と no の割合が元データに近くなるように分割されます。
今回のような不均衡データでは、偶然一方のデータに陽性例が偏ることを防ぐため、層化分割が重要です。
9.1 前処理は分割後に学習する
全データに対して前処理を fit_transform() してから訓練データとテストデータに分けると、情報漏洩が発生します。
# 推奨しない順序(悪い例として変数名を分けている)
X_transformed = preprocess_pipeline.fit_transform(X)
X_train_bad, X_test_bad, y_train_bad, y_test_bad = train_test_split(
X_transformed,
y,
test_size=0.20,
random_state=44,
)
この順序では、StandardScaler の平均と標準偏差、OneHotEncoder のカテゴリ一覧を計算するときに、テストデータの情報も使われます。これはデータリークです。
安全な方法は、前処理と分類器を1本の Pipeline に入れ、分割後の訓練データだけに対して fit() することです。
model_pipeline = Pipeline([
("preprocess", preprocessor),
("model", LogisticRegression(max_iter=2000)),
])
model_pipeline.fit(X_train, y_train)
9.2 時系列に近い評価も検討する
bank-full.csv は、2008年5月から2010年11月までの記録を日付順に並べたデータです。
ランダム分割はモデル比較の基礎として便利ですが、本番で「過去のデータから将来のキャンペーンを予測する」なら、古い期間を訓練用、新しい期間を評価用にする方法も検討すべきです。
ランダム分割で高い性能が出ても、顧客行動やキャンペーン条件が時間とともに変化すれば、将来の性能は下がる可能性があります。
10. 交差検証で8種類の分類器を比較する
今回は、次の8種類を比較します。
- ロジスティック回帰
- サポートベクターマシン
- k近傍法
- 決定木
- 勾配ブースティング
- ランダムフォレスト
- ニューラルネットワーク
- Gaussian Naive Bayes
10.1 cross_val_score と K-fold の関係
cross_val_score は、交差検証を実行して、各分割の評価値を返す関数です。
K-fold は、データをどのように分割するかを定める方法です。
分類問題では、各分割のクラス比率を保つ StratifiedKFold がよく使われます。
cv = StratifiedKFold(
n_splits=3,
shuffle=True,
random_state=42,
)
n_splits=3 なら、訓練データを3つに分けます。毎回1つを検証用、残り2つを学習用にし、役割を交代しながら3回評価します。
10.2 モデルを定義する
models = {
"ロジスティック回帰": LogisticRegression(
max_iter=2000,
random_state=42,
),
"SVM": SVC(),
"k近傍法": KNeighborsClassifier(),
"決定木": DecisionTreeClassifier(
random_state=42,
),
"勾配ブースティング": GradientBoostingClassifier(
random_state=42,
),
"ランダムフォレスト": RandomForestClassifier(
n_estimators=100,
n_jobs=-1,
random_state=42,
),
"ニューラルネットワーク": MLPClassifier(
alpha=0.01,
max_iter=300,
random_state=42,
),
"Gaussian Naive Bayes": GaussianNB(),
}
10.3 前処理を含めて交差検証する
交差検証の各分割で前処理を学習し直すため、preprocessor と分類器を Pipeline に入れます。
cv = StratifiedKFold(
n_splits=3,
shuffle=True,
random_state=42,
)
results = []
for model_name, model in models.items():
pipeline = Pipeline([
("preprocess", preprocessor),
("model", model),
])
start = time.perf_counter()
scores = cross_val_score(
pipeline,
X_train,
y_train,
cv=cv,
scoring="roc_auc",
n_jobs=1,
)
elapsed = time.perf_counter() - start
results.append({
"モデル": model_name,
"ROC-AUC平均": scores.mean(),
"ROC-AUC標準偏差": scores.std(),
"処理時間(秒)": elapsed,
})
result_df = (
pd.DataFrame(results)
.sort_values("ROC-AUC平均", ascending=False)
.reset_index(drop=True)
)
result_df
n_jobs=1 としているのは、モデル間の処理時間を比較しやすくするためです。処理時間を重視しない場合は、対応している処理で並列化を検討できます。
10.4 実行結果の例
Python 3.13、Scikit-learn 1.9 の環境で、duration を含めて実行した結果の一例です。
| 順位 | モデル | ROC-AUC平均 | 標準偏差 | 処理時間の例 |
|---|---|---|---|---|
| 1 | ランダムフォレスト | 0.9271 | 0.0012 | 5.63秒 |
| 2 | 勾配ブースティング | 0.9246 | 0.0019 | 9.91秒 |
| 3 | ニューラルネットワーク | 0.9115 | 0.0019 | 50.72秒 |
| 4 | SVM | 0.9083 | 0.0027 | 24.35秒 |
| 5 | ロジスティック回帰 | 0.9063 | 0.0014 | 0.36秒 |
| 6 | k近傍法 | 0.8323 | 0.0031 | 3.74秒 |
| 7 | Gaussian Naive Bayes | 0.8069 | 0.0023 | 0.27秒 |
| 8 | 決定木 | 0.7022 | 0.0086 | 0.69秒 |
処理時間は、CPU、ライブラリのバージョン、並列化設定によって大きく変わります。絶対値ではなく、同じ環境内での目安として見てください。
単体の決定木よりも、ランダムフォレストや勾配ブースティングの性能が大きく上回っています。これは前回見た通り、複数の木を組み合わせることで、単体の木の不安定さや過学習を抑えやすくなるためです。
一方、ニューラルネットワークは学習に時間がかかり、収束警告が出ることがあります。精度だけでなく、学習時間、安定性、説明しやすさも含めてモデルを選ぶ必要があります。
11. ROC 曲線と AUC でモデルを評価する
交差検証で候補を絞ったら、最後にテストデータで評価します。
11.1 ROC 曲線
ROC 曲線は、分類しきい値を変えたときの偽陽性率と真陽性率の関係を表します。
$$
\mathrm{TPR}
=\frac{\mathrm{TP}}{\mathrm{TP}+\mathrm{FN}}
$$
$$
\mathrm{FPR}
=\frac{\mathrm{FP}}{\mathrm{FP}+\mathrm{TN}}
$$
ROC-AUC は ROC 曲線の下側の面積です。
- 1.0に近い:陽性と陰性をよく順位付けできている
- 0.5付近:ランダムな予測と同程度
11.2 上位モデルをテストデータで比較する
top_models = {
"ランダムフォレスト": RandomForestClassifier(
n_estimators=100,
n_jobs=-1,
random_state=42,
),
"勾配ブースティング": GradientBoostingClassifier(
random_state=42,
),
"ニューラルネットワーク": MLPClassifier(
alpha=0.01,
max_iter=300,
random_state=42,
),
}
fig, ax = plt.subplots(figsize=(8, 6))
for model_name, model in top_models.items():
pipeline = Pipeline([
("preprocess", preprocessor),
("model", model),
])
pipeline.fit(X_train, y_train)
RocCurveDisplay.from_estimator(
pipeline,
X_test,
y_test,
name=model_name,
ax=ax,
)
ax.plot([0, 1], [0, 1], "k--", label="ランダム予測")
ax.set_title("上位モデルのROC曲線")
ax.legend()
plt.show()
11.3 duration の有無で性能を比較する
duration を含めたモデルと除外したモデルでは、性能が大きく変わります。numeric_features_without_duration を使って、duration を除いた特徴量構成でも同じ3モデルを比較します。
feature_variants = {
"あり": numeric_features,
"なし": numeric_features_without_duration,
}
duration_models = {
"ロジスティック回帰": LogisticRegression(
max_iter=2000,
random_state=42,
),
"勾配ブースティング": GradientBoostingClassifier(
random_state=42,
),
"ランダムフォレスト": RandomForestClassifier(
n_estimators=100,
n_jobs=-1,
random_state=42,
),
}
duration_results = []
for variant_label, numeric_subset in feature_variants.items():
variant_preprocessor = ColumnTransformer(
transformers=[
("numeric", StandardScaler(), numeric_subset),
(
"categorical",
OneHotEncoder(
handle_unknown="ignore",
sparse_output=False,
),
categorical_features,
),
]
)
for model_name, model in duration_models.items():
pipeline = Pipeline([
("preprocess", variant_preprocessor),
("model", model),
])
cv_scores = cross_val_score(
pipeline,
X_train,
y_train,
cv=cv,
scoring="roc_auc",
n_jobs=1,
)
pipeline.fit(X_train, y_train)
probability = pipeline.predict_proba(X_test)[:, 1]
duration_results.append({
"モデル": model_name,
"duration": variant_label,
"交差検証ROC-AUC": cv_scores.mean(),
"テストROC-AUC": roc_auc_score(y_test, probability),
"テストPR-AUC": average_precision_score(y_test, probability),
})
duration_result_df = pd.DataFrame(duration_results)
duration_result_df
同じ分割条件で実行した結果の一例は、次の通りです。
| モデル | duration |
交差検証ROC-AUC | テストROC-AUC | テストPR-AUC |
|---|---|---|---|---|
| ロジスティック回帰 | あり | 0.9063 | 0.9051 | 0.5473 |
| 勾配ブースティング | あり | 0.9246 | 0.9237 | 0.5980 |
| ランダムフォレスト | あり | 0.9271 | 0.9262 | 0.6056 |
| ロジスティック回帰 | なし | 0.7639 | 0.7648 | 0.4063 |
| 勾配ブースティング | なし | 0.7921 | 0.7902 | 0.4362 |
| ランダムフォレスト | なし | 0.7777 | 0.7766 | 0.4195 |
duration を含めると性能は高くなります。しかし、電話前の顧客選定には使えません。
低い数値でも、実際の予測時点で利用可能な情報だけを使ったモデルの方が、運用上は信頼できます。
11.4 AUC が高ければ施策が決まるわけではない
ROC-AUC はモデルの順位付け能力を評価しますが、どの顧客へ連絡するかを決めるには、最終的なしきい値が必要です。
しきい値は、次のような業務条件で決めます。
- 1日に電話できる件数
- 電話1件あたりの費用
- 定期預金1件あたりの期待利益
- 見込み客を取り逃す損失
- 頻繁な連絡による顧客体験の悪化
たとえば、上位5%の顧客だけへ連絡するのであれば、全体の正解率よりも「予測確率上位5%に実際の申込者がどれだけ含まれるか」が重要です。
12. 分析結果をマーケティング施策へつなげる
探索結果をもとに銀行のマーケティング方針を考えます。ただし、因果関係と相関関係を混同しないことが大切です。
12.1 同じ顧客への連絡回数
campaign ごとの申込率を見ると、連絡回数が少ないグループほど申込率が高い傾向があります。
| 今回の連絡回数 | 件数 | 申込率 |
|---|---|---|
| 1 | 17,544 | 14.60% |
| 2 | 12,505 | 11.20% |
| 3 | 5,521 | 11.19% |
| 4 | 3,522 | 9.00% |
| 5 | 1,764 | 7.88% |
| 6 | 1,291 | 7.13% |
この結果から、同じ顧客へ何度も電話するより、新しい見込み客へ接触した方が効率的ではないか、という仮説を立てられます。
ただし、「4回目の電話をかけたから申込率が下がった」とは限りません。もともと申込みにくい顧客だから、連絡回数が増えた可能性もあります。
連絡回数の上限を決めるには、ランダム化比較、過去施策の因果分析、顧客からの苦情や拒否情報も含めて検討する必要があります。
12.2 学生と退職者
このデータでは、学生と退職者の申込率が比較的高くなっています。
- 学生:28.68%
- 退職者:22.79%
- 全体:11.70%
若年層と高年齢層に注目すると、学生と退職者の傾向は年齢分布とも整合しています。
この結果は顧客層を理解する手掛かりになりますが、年齢や職業だけを理由に機械的な勧誘対象を決めるのではなく、商品適合性や公平性を確認する必要があります。
12.3 住宅ローンと個人ローン
ローンの有無でも申込率に差があります。
| 変数 | 状態 | 申込率 |
|---|---|---|
housing |
住宅ローンなし | 16.70% |
housing |
住宅ローンあり | 7.70% |
loan |
個人ローンなし | 12.66% |
loan |
個人ローンあり | 6.68% |
ローン返済がある顧客は、定期預金へ資金を回しにくい可能性があります。
もっともらしい仮説ですが、収入、残高、年齢、世帯状況などの影響も考えられます。この表だけで理由まで断定しないことが大切です。
12.4 残高
残高が多い顧客ほど、定期預金を利用する余力がある可能性があります。
ただし、balance は外れ値が多く、単純な平均だけでは傾向を読み違えることがあります。分位点でグループ化すると確認しやすくなります。
balance_group = pd.qcut(
bank["balance"],
q=5,
duplicates="drop",
)
balance_rate = (
bank.assign(
balance_group=balance_group,
target=(bank["y"] == "yes").astype(int),
)
.groupby("balance_group", observed=True)["target"]
.agg(["mean", "count"])
)
balance_rate
12.5 通話時間
通話時間が長い顧客ほど申込率が高いことは、このデータで最も分かりやすい関係の1つです。
ただし、duration は電話前には分かりません。そのため、次の2段階に分けて考えます。
-
電話前:
durationを除いたモデルで、連絡対象者の優先順位を決める - 電話後:通話内容、顧客の明示的な意向、フォローアップ希望などを記録し、次の対応を決める
電話後の判断では、通話時間だけに頼るべきではありません。長電話になった理由が苦情や説明の難しさである可能性もあるからです。
12.6 実務向けの簡単な流れ
モデルの予測は、誰に何度電話するかを自動的に決定する命令ではありません。担当者の判断を支援する優先順位として使い、費用、顧客体験、公平性、コンプライアンスと合わせて運用します。
13. 実装と評価で注意すべき点
13.1 使用するデータを確認する
bank-full.csv は45,211件、bank-additional-full.csv は41,188件です。ファイルによって列数や内容も異なるため、分析前に shape と列名を確認します。
13.2 前処理のデータリーク
全データを fit_transform() してから訓練・テストに分割してはいけません。分割後、Pipeline の中で前処理を学習します。
13.3 層化分割
目的変数に大きな偏りがあるため、train_test_split() では stratify=y を指定します。交差検証には StratifiedKFold を使います。
13.4 duration の扱い
電話前の予測モデルでは duration を除外します。含めた結果は、実運用性能ではなく、ベンチマークとして扱います。
13.5 グループ別割合の読み方
約95%という値は、「平均以下の通話時間だった顧客のうち、申し込まなかった割合」です。「平均超の通話では95%が申し込んだ」という意味ではありません。
カテゴリ別の申込傾向を見るなら、グループ内の yes の割合を計算します。件数構成比や残高の平均を申込率として読まないようにします。
13.6 処理時間の順位
モデルの処理時間は、実装、データ表現、CPU、並列化、ライブラリのバージョンで変わります。1回の実行結果から「常にこのモデルが最速」と一般化しないようにします。
13.7 最終テストデータはモデル選択に使わない
交差検証は訓練データの中で行い、モデルやパラメータを決めます。テストデータは、選択が終わったモデルを最後に一度評価するために残します。
14. まとめ
今回の内容をまとめます。
- Bank Marketing は、ポルトガルの銀行による電話マーケティングの公開データである
- 今回使う
bank-full.csvは、45,211件、16個の説明変数、1個の目的変数を持つ - 目的は、顧客が定期預金を申し込むかどうかを予測する2値分類である
-
yesは全体の約11.7%しかなく、クラスに大きな偏りがある -
unknownは単純な欠損とは限らず、業務上の状態を表している場合がある - 数値変数は標準化し、カテゴリ変数はワンホットエンコーディングする
-
DataFrameSelectorは、BaseEstimatorとTransformerMixinを多重継承した独自変換器である -
ColumnTransformerを使うと、列の選択と前処理を簡潔に書ける - 前処理はデータ分割後、Pipeline の中で学習し、テストデータからの情報漏洩を防ぐ
-
cross_val_scoreは交差検証を実行する関数で、K-fold はデータの分割方法である - 不均衡分類では、正解率だけでなく ROC-AUC や PR-AUC を確認する
- ランダムフォレストや勾配ブースティングは、単体の決定木より高い性能を示した
-
durationは予測力が高いが、電話前には利用できないため、実運用モデルから除外する - モデルの結果は因果関係を証明するものではなく、施策を考えるための仮説として扱う
今回のケースで最も大切なのは、単に最も高い AUC を出すことではありません。
「いつ予測するのか」「その時点で使える情報は何か」「予測をどの業務判断へつなげるのか」を先に決める必要があります。
機械学習モデルは、手元の列から高い精度を出せば完成というものではありません。データが作られた背景、評価方法、情報漏洩、施策の費用、顧客への影響まで含めて設計して初めて、実務で意味のある分析になります。