前回は、銀行の電話マーケティングデータを使い、データの確認から前処理、交差検証、モデル評価までの流れを見ました。
今回は、クレジットカード取引の不正検知を扱います。
クレジットカードの取引は、ほとんどが正常です。不正取引は全体のごく一部しかありません。そのため、通常の分類問題と同じ感覚で正解率を最大化しても、実務で役立つモデルにはなりません。
今回の中心テーマは、次の3点です。
- 極端に少ない不正取引を、どの評価指標で測るか
- RandomOverSampler、SMOTE、SMOTETomek をどう使うか
- 再サンプリングによる情報漏洩をどう防ぐか
目次
- 不正検知の目的とデータセットの出典
- データの構成と各変数の意味
- データを読み込み、クラス分布を確認する
- なぜ正解率だけでは評価できないのか
- 訓練データとテストデータを層化分割する
- RandomOverSampler:少数クラスを複製する
- SMOTE:少数クラスの合成サンプルを作る
- SMOTETomek:合成と境界整理を組み合わせる
- 再サンプリングを交差検証の内側へ入れる
- 決定木で再サンプリング方法を比較する
- ロジスティック回帰、ランダムフォレスト、GBDT を比較する
- PR-AUC、ROC-AUC、再現率、適合率を読む
- ハイパーパラメータを探索する
- 不正検知モデルを実運用へつなげる
- まとめ
1. 不正検知の目的とデータセットの出典
クレジットカード会社には、不正な取引を早期に見つけ、カード会員が購入していない商品やサービスの代金を請求されないようにする役割があります。
今回の課題は、1件ごとの取引を次の2クラスへ分類することです。
-
Class = 0:正常取引 -
Class = 1:不正取引
1.1 公開元
データセットは Kaggle で公開されています。
- 公開ページ:Credit Card Fraud Detection
- データセット所有者:Machine Learning Group - ULB
- ライセンス:Open Database License / Database Contents License
このデータは、決済サービス企業 Worldline と、ベルギーのブリュッセル自由大学 ULB の Machine Learning Group による共同研究の中で収集・分析されました。
関連研究として、公開ページでは次の論文などが紹介されています。
Andrea Dal Pozzolo, Olivier Caelen, Reid A. Johnson, Gianluca Bontempi,
“Calibrating Probability with Undersampling for Unbalanced Classification,”
IEEE Symposium Series on Computational Intelligence, 2015.
DOI: 10.1109/SSCI.2015.33
1.2 データの概要
このデータには、2013年9月に欧州のカード会員が行った、約2日間の取引が収録されています。
| 項目 | 内容 |
|---|---|
| 取引件数 | 284,807件 |
| 正常取引 | 284,315件 |
| 不正取引 | 492件 |
| 不正取引の割合 | 約0.1727% |
| 列数 | 31列 |
不正取引は約579件に1件しかありません。
$$
\frac{492}{284807}
\approx 0.001727
=0.1727%
$$
これは、極端なクラス不均衡を持つ2値分類問題です。
2. データの構成と各変数の意味
データには、Time、V1 から V28、Amount、Class が含まれます。
| 列名 | 意味 |
|---|---|
Time |
データ内の最初の取引から、その取引までに経過した秒数 |
V1〜V28
|
PCA によって変換された28個の主成分 |
Amount |
取引金額 |
Class |
正常なら0、不正なら1 |
2.1 V1 から V28 が匿名化されている理由
クレジットカード取引には、カード会員、加盟店、端末、地域、時刻など、機密性の高い情報が含まれます。
そのため、元の特徴量名や具体的な内容は公開されていません。V1 から V28 は、元の変数を PCA で変換した主成分です。
PCA により、元の特徴量空間は互いに相関の小さい新しい軸へ写されています。
$$
Z=XW
$$
ここで、$X$ は元の特徴量、$W$ は主成分方向、$Z$ は変換後の特徴量です。
この匿名化には、次の制約があります。
-
V1が業務上何を意味するかを説明できない - 取引地域や加盟店種別といった元の情報を直接分析できない
- モデルの説明は、主成分に対する説明に限られる
一方、Time と Amount は PCA 変換されていません。
2.2 Time が表す期間
Time の最大値は172,792秒です。
$$
\frac{172792}{3600}
\approx47.998
$$
したがって、データは約48時間、つまり約2日間の取引を表しています。
3. データを読み込み、クラス分布を確認する
3.1 ライブラリを読み込む
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
from imblearn.combine import SMOTETomek
from imblearn.over_sampling import RandomOverSampler, SMOTE
from imblearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import (
GradientBoostingClassifier,
RandomForestClassifier,
)
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
ConfusionMatrixDisplay,
PrecisionRecallDisplay,
RocCurveDisplay,
average_precision_score,
classification_report,
roc_auc_score,
)
from sklearn.model_selection import (
GridSearchCV,
StratifiedKFold,
StratifiedShuffleSplit,
cross_validate,
train_test_split,
)
from sklearn.preprocessing import RobustScaler
from sklearn.tree import DecisionTreeClassifier
再サンプリングには imbalanced-learn を使います。未導入の場合は、次のコマンドでインストールできます。
pip install imbalanced-learn
3.2 CSVを読み込む
creditcard.csv を data フォルダに置いた場合は、次のように読み込めます。
data = pd.read_csv("./data/creditcard.csv")
print(data.shape)
data.head()
(284807, 31)
欠損値も確認します。
data.isna().sum().sum()
このデータに欠損値はありません。
3.3 正常取引と不正取引の件数
class_counts = data["Class"].value_counts().sort_index()
class_rates = data["Class"].value_counts(normalize=True).sort_index()
print(class_counts)
print(class_rates)
Class
0 284315
1 492
棒グラフでも確認します。
fig, ax = plt.subplots(figsize=(7, 5))
sns.countplot(
data=data,
x="Class",
hue="Class",
legend=False,
ax=ax,
)
ax.set_yscale("log")
ax.set_title("正常取引と不正取引の件数")
ax.set_xlabel("Class")
ax.set_ylabel("件数(対数目盛)")
plt.show()
通常の目盛では不正取引の棒がほとんど見えないため、ここでは縦軸を対数目盛にしています。
3.4 取引金額を確認する
amount_summary = (
data.groupby("Class")["Amount"]
.agg(["count", "mean", "median", "max"])
)
amount_summary
結果は次の通りです。
| Class | 件数 | 平均 | 中央値 | 最大値 |
|---|---|---|---|---|
| 0 | 284,315 | 88.29 | 22.00 | 25,691.16 |
| 1 | 492 | 122.21 | 9.25 | 2,125.87 |
不正取引は正常取引より平均金額が高い一方、中央値は低くなっています。分布が大きく歪んでいるため、平均値だけで特徴を判断しないようにします。
4. なぜ正解率だけでは評価できないのか
すべての取引を「正常」と予測するモデルを考えます。
このモデルの正解率は、次の通りです。
$$
\mathrm{Accuracy}
=\frac{284315}{284807}
\approx0.998273
$$
約99.83%という非常に高い正解率です。
しかし、このモデルは492件の不正取引を1件も検知できません。実務上は役に立たないモデルです。
4.1 混同行列
不正取引を陽性クラスとすると、混同行列は次の4要素で表せます。
| 不正と予測 | 正常と予測 | |
|---|---|---|
| 実際に不正 | TP | FN |
| 実際に正常 | FP | TN |
- TP:不正を正しく検知した
- FN:不正を見逃した
- FP:正常取引を誤って止めた
- TN:正常取引を正しく通した
4.2 再現率
再現率は、実際の不正取引のうち、どれだけ検知できたかを表します。
$$
\mathrm{Recall}
=\frac{\mathrm{TP}}{\mathrm{TP}+\mathrm{FN}}
$$
再現率を高めると、不正の見逃しを減らせます。
4.3 適合率
適合率は、不正と判定した取引のうち、実際に不正だった割合です。
$$
\mathrm{Precision}
=\frac{\mathrm{TP}}{\mathrm{TP}+\mathrm{FP}}
$$
適合率が低いと、多くの正常取引を止めることになり、顧客体験や調査コストが悪化します。
4.4 F1スコア
F1スコアは、適合率と再現率の調和平均です。
$$
\mathrm{F1}
=2\frac{\mathrm{Precision}\times\mathrm{Recall}}
{\mathrm{Precision}+\mathrm{Recall}}
$$
4.5 PR-AUC
Precision-Recall 曲線は、しきい値を変えたときの適合率と再現率の関係を表します。
このデータのように陽性クラスが極端に少ない場合、ROC-AUC が高く見えても、実際の適合率が低いことがあります。そのため、公開元でも Precision-Recall 曲線の面積、つまり PR-AUC を使うことが推奨されています。
Scikit-learn では、PR-AUC に近い要約値として Average Precision を計算できます。
average_precision_score(y_true, fraud_probability)
ランダムな順位付けにおける Average Precision の基準値は、ほぼ陽性率と同じです。このデータでは約0.001727です。
5. 訓練データとテストデータを層化分割する
5.1 説明変数と目的変数を分ける
X = data.drop(columns="Class")
y = data["Class"]
Time と Amount も予測時に利用できる取引情報なので、ここでは説明変数に含めます。
5.2 train_test_split で層化分割する
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.20,
stratify=y,
random_state=42,
)
stratify=y を指定すると、訓練データとテストデータで不正取引の割合がほぼ同じになります。
print(y_train.value_counts(normalize=True))
print(y_test.value_counts(normalize=True))
今回の分割では、テストデータに約98件の不正取引が含まれます。件数が少ないため、わずか数件の予測結果でも再現率や適合率が大きく変化します。
5.3 StratifiedShuffleSplit を使う方法
層化分割を明示的に制御したい場合は、StratifiedShuffleSplit も使えます。
splitter = StratifiedShuffleSplit(
n_splits=1,
test_size=0.20,
random_state=42,
)
for train_index, test_index in splitter.split(X, y):
X_train = X.iloc[train_index]
X_test = X.iloc[test_index]
y_train = y.iloc[train_index]
y_test = y.iloc[test_index]
StratifiedShuffleSplit はデータを無作為に並べ替えながら、各分割のクラス比率を保ちます。
5.4 Time と Amount をスケーリングする
V1 から V28 は PCA で変換されていますが、Time と Amount は変換されていません。
外れ値の影響を受けにくい RobustScaler を使います。
scale_features = ["Time", "Amount"]
preprocessor = ColumnTransformer(
transformers=[
(
"scale",
RobustScaler(),
scale_features,
),
],
remainder="passthrough",
)
remainder="passthrough" により、V1 から V28 はそのまま後段へ渡されます。
6. RandomOverSampler:少数クラスを複製する
RandomOverSampler は、少数クラスからランダムにサンプルを選び、複製する方法です。
random_sampler = RandomOverSampler(
sampling_strategy="auto",
random_state=42,
)
sampling_strategy="auto" を指定すると、多数クラスと少数クラスの件数が同じになるまで少数クラスを増やします。
6.1 長所
- 仕組みが単純で分かりやすい
- 元の少数クラスの分布を勝手に変形しない
- 計算が比較的速い
6.2 注意点
- 同じ少数サンプルを何度も学習するため、過学習しやすい
- 1対1まで増やすとデータ量がほぼ2倍になる
- 元の不正率が変わるため、予測確率が実際の発生確率と一致しにくくなる
完全に1対1へそろえる必要はありません。たとえば、少数クラスを多数クラスの10%まで増やすなら、次のようにします。
random_sampler = RandomOverSampler(
sampling_strategy=0.10,
random_state=42,
)
7. SMOTE:少数クラスの合成サンプルを作る
SMOTE は Synthetic Minority Over-sampling Technique の略です。
RandomOverSampler が既存サンプルを複製するのに対し、SMOTE は少数クラスの近傍サンプル同士を結ぶ線分上に、新しい合成サンプルを作ります。
少数クラスのサンプル $x_i$ と、その近傍 $x_j$ を使うと、合成サンプルは次のように表せます。
$$
x_{\mathrm{new}}
=x_i+\lambda(x_j-x_i),
\qquad 0\leq\lambda\leq1
$$
smote = SMOTE(
sampling_strategy="auto",
k_neighbors=5,
random_state=42,
)
7.1 長所
- 同じデータを単純に複製するより、少数クラスの分布を広げられる
- 決定境界を学習しやすくなる場合がある
7.2 注意点
- 少数クラスの外れ値の周囲にも合成サンプルを作る可能性がある
- クラス境界が重なる領域に不自然なサンプルを作る可能性がある
- カテゴリ変数を単純な数値として補間してはいけない
- 距離計算を使うため、特徴量のスケールに注意が必要
今回の V1 から V28 は連続値の主成分なので、SMOTE を適用できます。ただし、合成された点が現実の取引として自然かどうかは、匿名化データから判断できません。
8. SMOTETomek:合成と境界整理を組み合わせる
SMOTETomek は、SMOTE と Tomek Links を組み合わせた方法です。
まず SMOTE で少数クラスを増やし、その後、異なるクラス同士で互いに最近傍となる境界付近のペアを整理します。
smote_tomek = SMOTETomek(
sampling_strategy="auto",
random_state=42,
)
8.1 長所
- 少数クラスを増やしながら、境界の重なりも整理できる
- 単純な過剰サンプリングより、決定境界が明確になる場合がある
8.2 注意点
- SMOTE 単体より計算量が大きい
- Tomek Links で削除される点が、必ずしもノイズとは限らない
- 不正検知では、境界付近の難しい取引こそ重要な場合がある
9. 再サンプリングを交差検証の内側へ入れる
再サンプリングで最も重要なのは、訓練データだけに適用することです。
9.1 分割前に再サンプリングしてはいけない
全データを先に過剰サンプリングしてから訓練・テストへ分けると、複製された同じサンプルや、非常に近い合成サンプルが両方へ入る可能性があります。
これでは、未知データに対する性能を正しく評価できません。
9.2 交差検証の前に一度だけ再サンプリングしてもいけない
訓練データ全体を再サンプリングしてから K-fold 交差検証へ渡す方法にも、同じ問題があります。
合成サンプルを作るときに、後で検証用になるデータの情報まで使ってしまうからです。
正しい順序は次の通りです。
9.3 imblearn.pipeline.Pipeline を使う
Scikit-learn の通常の変換器は fit() と transform() を使いますが、サンプラーは fit_resample() を使います。
そのため、再サンプリングを Pipeline に入れる場合は、sklearn.pipeline.Pipeline ではなく imblearn.pipeline.Pipeline を使います。
fraud_pipeline = Pipeline([
("preprocess", preprocessor),
(
"sampler",
RandomOverSampler(
sampling_strategy=0.10,
random_state=42,
),
),
(
"model",
LogisticRegression(
max_iter=2000,
random_state=42,
),
),
])
この Pipeline を cross_validate() や GridSearchCV へ渡せば、各foldの学習部分だけが再サンプリングされます。
検証データとテストデータは、現実の不均衡な比率のまま評価されます。
10. 決定木で再サンプリング方法を比較する
再サンプリング方法だけを比較するため、分類器を同じ決定木に固定します。
比較対象は次の4つです。
- 再サンプリングなし
- RandomOverSampler
- SMOTE
- SMOTETomek
10.1 評価用の交差検証
cv = StratifiedKFold(
n_splits=3,
shuffle=True,
random_state=42,
)
scoring = {
"pr_auc": "average_precision",
"roc_auc": "roc_auc",
"precision": "precision",
"recall": "recall",
"f1": "f1",
}
10.2 各サンプリング方法を定義する
samplers = {
"再サンプリングなし": "passthrough",
"RandomOverSampler": RandomOverSampler(
sampling_strategy=0.10,
random_state=42,
),
"SMOTE": SMOTE(
sampling_strategy=0.10,
random_state=42,
),
"SMOTETomek": SMOTETomek(
sampling_strategy=0.10,
random_state=42,
),
}
不正と正常を完全な1対1にはせず、不正取引を正常取引の10%まで増やします。
10.3 交差検証を実行する
sampling_results = []
for sampler_name, sampler in samplers.items():
pipeline = Pipeline([
("preprocess", preprocessor),
("sampler", sampler),
(
"model",
DecisionTreeClassifier(
max_depth=5,
min_samples_leaf=20,
random_state=42,
),
),
])
scores = cross_validate(
pipeline,
X_train,
y_train,
cv=cv,
scoring=scoring,
n_jobs=-1,
)
sampling_results.append({
"方法": sampler_name,
"PR-AUC": scores["test_pr_auc"].mean(),
"ROC-AUC": scores["test_roc_auc"].mean(),
"適合率": scores["test_precision"].mean(),
"再現率": scores["test_recall"].mean(),
"F1": scores["test_f1"].mean(),
})
sampling_result_df = (
pd.DataFrame(sampling_results)
.sort_values("PR-AUC", ascending=False)
.reset_index(drop=True)
)
sampling_result_df
10.4 比較結果の読み方
Python 3.13、Scikit-learn 1.9.0、imbalanced-learn 0.14.2 の環境で実行した結果の一例です。
| 方法 | PR-AUC | ROC-AUC | 適合率 | 再現率 | F1 |
|---|---|---|---|---|---|
| 再サンプリングなし | 0.7391 | 0.9110 | 0.8748 | 0.7411 | 0.8022 |
| RandomOverSampler | 0.6869 | 0.9041 | 0.4450 | 0.8223 | 0.5690 |
| SMOTE | 0.6860 | 0.9480 | 0.4938 | 0.8198 | 0.6024 |
| SMOTETomek | 0.6860 | 0.9480 | 0.4938 | 0.8198 | 0.6024 |
この条件では、再サンプリングを行わない決定木が PR-AUC と F1 で最良でした。一方、過剰サンプリングを行ったモデルは再現率が上がり、不正の見逃しが減っています。
再サンプリングを行うと、不正クラスへ分類する範囲が広がりやすくなります。
- 再現率は上がりやすい
- 不正と判定する件数が増える
- 偽陽性も増え、適合率が下がる場合がある
最適な方法は、単純に再現率が最も高いものではありません。
不正を1件見逃す損失と、正常取引を1件止める損失の両方を考える必要があります。
SMOTETomek はこの条件で SMOTE とほぼ同じ評価値でしたが、処理時間は大幅に長くなりました。複雑な再サンプリングを選ぶ場合は、精度だけでなく計算コストも比較します。
また、この結果から分かるように、クラス不均衡があるからといって、再サンプリングを行えば必ず性能が上がるわけではありません。再サンプリングなし、class_weight、しきい値調整も比較対象に含めます。
また、サンプリング方法を比較するときにテストデータを繰り返し使ってはいけません。方法の選択は訓練データ内の交差検証で行い、テストデータは最後の確認にだけ使います。
11. ロジスティック回帰、ランダムフォレスト、GBDT を比較する
次に、同じ再サンプリング条件で3種類のモデルを比較します。
11.1 モデルを定義する
models = {
"ロジスティック回帰": LogisticRegression(
C=1.0,
l1_ratio=1.0,
solver="liblinear",
max_iter=2000,
random_state=42,
),
"ランダムフォレスト": RandomForestClassifier(
n_estimators=200,
max_depth=12,
min_samples_split=8,
max_features="sqrt",
n_jobs=1,
random_state=42,
),
"GBDT": GradientBoostingClassifier(
learning_rate=0.10,
n_estimators=100,
max_depth=3,
min_samples_split=20,
random_state=42,
),
}
11.2 交差検証で比較する
model_results = []
for model_name, model in models.items():
pipeline = Pipeline([
("preprocess", preprocessor),
(
"sampler",
RandomOverSampler(
sampling_strategy=0.10,
random_state=42,
),
),
("model", model),
])
scores = cross_validate(
pipeline,
X_train,
y_train,
cv=cv,
scoring=scoring,
n_jobs=-1,
)
model_results.append({
"モデル": model_name,
"PR-AUC": scores["test_pr_auc"].mean(),
"ROC-AUC": scores["test_roc_auc"].mean(),
"適合率": scores["test_precision"].mean(),
"再現率": scores["test_recall"].mean(),
"F1": scores["test_f1"].mean(),
})
model_result_df = (
pd.DataFrame(model_results)
.sort_values("PR-AUC", ascending=False)
.reset_index(drop=True)
)
model_result_df
11.3 モデルの特徴
同じ環境で実行した結果の一例です。
| モデル | PR-AUC | ROC-AUC | 適合率 | 再現率 | F1 |
|---|---|---|---|---|---|
| ランダムフォレスト | 0.8491 | 0.9790 | 0.9228 | 0.7843 | 0.8474 |
| ロジスティック回帰 | 0.7553 | 0.9780 | 0.3910 | 0.8452 | 0.5311 |
| GBDT | 0.7516 | 0.9803 | 0.6586 | 0.8223 | 0.7313 |
ROC-AUC だけを見ると GBDT が最も高い一方、主指標の PR-AUC と F1 ではランダムフォレストが最も高くなりました。
ロジスティック回帰は不正取引を広く拾うため再現率は高いものの、適合率が低く、正常取引の誤検知が多いことが分かります。
ロジスティック回帰
- 学習が速い
- 係数を確認しやすい
- 線形な境界を学習する
- 強いベースラインとして使いやすい
ランダムフォレスト
- 非線形な関係を表現できる
- 外れ値や特徴量スケールの影響を比較的受けにくい
- 木を並列に学習できる
- 単体の決定木より過学習を抑えやすい
GBDT
- 前の木が残した誤差を、後の木が順番に補う
- 表形式データで高い性能を出しやすい
- 学習率と木の本数の調整が重要
- 木を順番に作るため、ランダムフォレストほど単純には並列化できない
11.4 classification_report の読み方
最終モデルを学習し、テストデータで評価します。
final_pipeline = Pipeline([
("preprocess", preprocessor),
(
"sampler",
RandomOverSampler(
sampling_strategy=0.10,
random_state=42,
),
),
(
"model",
RandomForestClassifier(
n_estimators=200,
max_depth=12,
min_samples_split=8,
max_features="sqrt",
n_jobs=-1,
random_state=42,
),
),
])
final_pipeline.fit(X_train, y_train)
test_prediction = final_pipeline.predict(X_test)
print(
classification_report(
y_test,
test_prediction,
target_names=["正常", "不正"],
digits=4,
)
)
classification_report には、クラスごとの適合率、再現率、F1スコアに加え、macro avg と weighted avg が表示されます。
正常取引が99.8%以上を占めるため、weighted avg は正常クラスの性能に強く引っ張られます。
不正検知では、Class = 1 の行に表示される適合率、再現率、F1スコアを必ず確認します。
この分割におけるテスト結果の一例は次の通りです。
| 指標 | 値 |
|---|---|
| PR-AUC | 0.8718 |
| ROC-AUC | 0.9823 |
| 不正クラスの適合率 | 0.9101 |
| 不正クラスの再現率 | 0.8265 |
| 不正クラスのF1 | 0.8663 |
混同行列は次のようになりました。
| 不正と予測 | 正常と予測 | |
|---|---|---|
| 実際に不正 | 81 | 17 |
| 実際に正常 | 8 | 56,856 |
98件の不正取引のうち81件を検知し、17件を見逃しています。また、正常取引8件を不正と誤判定しています。
12. PR-AUC、ROC-AUC、再現率、適合率を読む
12.1 予測確率を使う
ROC-AUC や PR-AUC は、0か1の確定予測ではなく、予測確率を使って計算します。
fraud_probability = final_pipeline.predict_proba(X_test)[:, 1]
test_pr_auc = average_precision_score(
y_test,
fraud_probability,
)
test_roc_auc = roc_auc_score(
y_test,
fraud_probability,
)
print("テスト PR-AUC:", test_pr_auc)
print("テスト ROC-AUC:", test_roc_auc)
確定ラベルを roc_auc_score() に渡すと、1つのしきい値における結果しか反映されず、モデルの順位付け能力を十分に評価できません。
12.2 Precision-Recall 曲線
fig, ax = plt.subplots(figsize=(7, 5))
PrecisionRecallDisplay.from_predictions(
y_test,
fraud_probability,
name="ランダムフォレスト",
ax=ax,
)
ax.set_title("Precision-Recall 曲線")
plt.show()
PR曲線では、不正取引を多く見つけようとして再現率を上げると、適合率がどれくらい下がるかを確認できます。
12.3 ROC曲線
fig, ax = plt.subplots(figsize=(7, 5))
RocCurveDisplay.from_predictions(
y_test,
fraud_probability,
name="ランダムフォレスト",
ax=ax,
)
ax.plot([0, 1], [0, 1], "k--")
ax.set_title("ROC 曲線")
plt.show()
ROC-AUC は、無作為に選んだ不正取引へ、無作為に選んだ正常取引より高いスコアを付けられる確率として解釈できます。
ただし、正常取引が非常に多いため、偽陽性率が低く見えても、偽陽性の件数自体は多くなる場合があります。
12.4 混同行列
ConfusionMatrixDisplay.from_predictions(
y_test,
test_prediction,
display_labels=["正常", "不正"],
cmap="Blues",
)
plt.title("テストデータの混同行列")
plt.show()
混同行列では、割合だけでなく件数を確認します。
たとえば偽陽性率が0.1%でも、正常取引が約56,000件あれば、約56件が誤って止められる計算です。
12.5 しきい値を調整する
predict() は通常0.5をしきい値として使いますが、不正検知で0.5が最適とは限りません。
threshold = 0.20
adjusted_prediction = (
fraud_probability >= threshold
).astype(int)
print(
classification_report(
y_test,
adjusted_prediction,
target_names=["正常", "不正"],
digits=4,
)
)
しきい値を下げると、一般に次の変化が起こります。
- 不正の見逃しが減り、再現率が上がる
- 正常取引の誤検知が増え、適合率が下がる
しきい値は、テストデータを見ながら決めてはいけません。訓練データ内に検証データを用意するか、Out-of-Fold 予測を使って決めます。
13. ハイパーパラメータを探索する
木モデルは調整できるパラメータが多く、その設定が性能や計算量に影響します。
13.1 GridSearchCV の考え方
GridSearchCV は、指定したパラメータの組み合わせをすべて試し、交差検証の平均評価値が最も高い組み合わせを選びます。
探索範囲の外に、さらに良い値が存在する可能性はあります。グリッド探索が見つけるのは、あくまで指定した候補内での最良値です。
13.2 ランダムフォレストを探索する
rf_pipeline = Pipeline([
("preprocess", preprocessor),
(
"sampler",
RandomOverSampler(
random_state=42,
),
),
(
"model",
RandomForestClassifier(
n_jobs=1,
random_state=42,
),
),
])
rf_param_grid = {
"sampler__sampling_strategy": [0.05, 0.10],
"model__criterion": ["gini", "entropy"],
"model__n_estimators": [100, 200],
"model__max_depth": [8, 12],
"model__max_features": ["sqrt", 0.5],
"model__min_samples_split": [4, 8],
}
rf_search = GridSearchCV(
estimator=rf_pipeline,
param_grid=rf_param_grid,
scoring="average_precision",
cv=cv,
n_jobs=-1,
refit=True,
)
rf_search.fit(X_train, y_train)
print(rf_search.best_params_)
print(rf_search.best_score_)
パラメータ名は、Pipeline のステップ名とパラメータ名を __ でつなぎます。
model__max_depth
sampler__sampling_strategy
13.3 GBDTを探索する
gbdt_pipeline = Pipeline([
("preprocess", preprocessor),
(
"sampler",
RandomOverSampler(
random_state=42,
),
),
(
"model",
GradientBoostingClassifier(
random_state=42,
),
),
])
gbdt_param_grid = {
"sampler__sampling_strategy": [0.05, 0.10],
"model__learning_rate": [0.05, 0.10, 0.20],
"model__n_estimators": [50, 100],
"model__max_depth": [2, 3],
"model__min_samples_split": [10, 20],
}
gbdt_search = GridSearchCV(
estimator=gbdt_pipeline,
param_grid=gbdt_param_grid,
scoring="average_precision",
cv=cv,
n_jobs=-1,
refit=True,
)
gbdt_search.fit(X_train, y_train)
print(gbdt_search.best_params_)
print(gbdt_search.best_score_)
候補数を増やすと、計算量は組み合わせ数に比例して増えます。
たとえば、各パラメータに3候補ずつあり、5個のパラメータを探索するなら、組み合わせ数は次の通りです。
$$
3^5=243
$$
3分割交差検証なら、合計729回の学習が必要です。
候補が多い場合は、すべての組み合わせを試す GridSearchCV ではなく、指定回数だけ無作為に試す RandomizedSearchCV も検討します。
13.4 テストデータで最後に一度だけ評価する
best_model = rf_search.best_estimator_
test_probability = best_model.predict_proba(X_test)[:, 1]
print(
"テスト PR-AUC:",
average_precision_score(
y_test,
test_probability,
),
)
print(
"テスト ROC-AUC:",
roc_auc_score(
y_test,
test_probability,
),
)
モデル、再サンプリング方法、パラメータ、しきい値を選ぶ途中でテストデータを使わないことが重要です。
14. 不正検知モデルを実運用へつなげる
公開データで高い評価値を出すことと、実際の決済システムで不正を防ぐことは同じではありません。
14.1 時系列分割
今回のデータには取引順を表す Time があります。
本番では過去の取引から将来の不正を予測するため、最終評価では古い取引を訓練用、新しい取引をテスト用に分ける方が現実に近くなります。
ランダム分割では、時間的に近い似た取引が訓練データとテストデータの両方へ入る可能性があります。
14.2 重複行
このデータには完全に同じ値を持つ行が1,081件あります。
ただし、取引IDが公開されていないため、同じ取引が重複登録されたのか、別々の同一条件取引なのかは判断できません。
機械的に削除するのではなく、実データでは取引IDや業務ルールを確認します。重複レコードと確認できた場合は、訓練・テスト分割の前に除外します。
14.3 過剰サンプリングと予測確率
過剰サンプリングを行うと、学習データ内の不正率が現実より高くなります。
そのため、predict_proba() が返す値を、そのまま「実際に不正である確率」と解釈できない場合があります。
正確な確率が必要なら、実際のクラス比率を保った検証データで確率較正を行います。Scikit-learn では CalibratedClassifierCV を使用できます。
14.4 誤分類コスト
不正検知では、すべての誤りのコストが同じではありません。
- 高額な不正取引の見逃しは損失が大きい
- 少額の正常取引を止めても顧客体験を損なう
- 手動調査には人件費がかかる
- 本人認証の追加には離脱リスクがある
Amount を使えば、取引ごとの損失額を考慮したコスト依存学習も検討できます。
14.5 概念ドリフト
不正の手口は、検知ルールや社会状況に合わせて変化します。
過去に高性能だったモデルでも、時間がたつと性能が下がる可能性があります。これを概念ドリフトと呼びます。
実運用では、次の項目を継続的に監視します。
- 不正率
- PR-AUC
- 不正クラスの再現率
- 適合率
- 誤検知件数
- 取引金額別の損失
- 特徴量分布の変化
14.6 ラベル確定の遅れ
不正取引は、カード会員からの申告や調査によって後から判明する場合があります。
つまり、直近データには「まだ不正と確定していない不正取引」が含まれる可能性があります。
学習データを作るときは、ラベルが十分に確定するまでの待機期間を設ける必要があります。
14.7 実運用の流れ
モデルは、不正か正常かを最終決定する唯一の仕組みではありません。
予測スコアに応じて、通常承認、追加認証、手動調査、取引保留など、複数の対応へ振り分ける方が実務的です。
15. まとめ
今回の内容をまとめます。
- Credit Card Fraud Detection データには、284,807件の取引が含まれる
- 不正取引は492件で、全体の約0.1727%しかない
-
V1からV28は、機密保持のため PCA で変換された主成分である -
Timeは最初の取引からの経過秒数、Amountは取引金額である - すべてを正常と予測しても約99.83%の正解率になるため、正解率だけでは評価できない
- 極端な不均衡分類では、PR-AUC、適合率、再現率、F1スコア、混同行列を確認する
- RandomOverSampler は、既存の少数クラスを複製する
- SMOTE は、少数クラスの近傍間に合成サンプルを作る
- SMOTETomek は、SMOTE と境界付近の整理を組み合わせる
- 再サンプリングは訓練データだけに適用する
- 交差検証では、再サンプリングを各foldの学習部分だけで行う
- 再サンプリングを Pipeline に入れる場合は
imblearn.pipeline.Pipelineを使う - ROC-AUC や PR-AUC は、確定ラベルではなく予測確率から計算する
- モデルやサンプリング方法の選択は訓練データ内で行い、テストデータは最後に一度だけ使う
- 過剰サンプリング後の予測確率は、実際の不正発生確率と一致しない場合がある
- 実運用では、時系列評価、誤分類コスト、概念ドリフト、ラベル確定の遅れも考慮する
不正検知では、「不正を何件当てたか」だけでなく、「正常取引を何件止めたか」も同じくらい重要です。
不正の見逃しを減らすためにしきい値を下げれば、誤検知は増えます。誤検知を減らそうとすれば、不正の見逃しが増える可能性があります。
このトレードオフを、PR-AUC、適合率、再現率、取引金額、調査コスト、顧客体験を使って設計することが、クレジットカード不正検知の中心です。