LESSON

閾値最適化

「モデルの性能はそこそこ出てきた。だが、最終的なビジネス価値を決めるのは閾値だ。」

田中VPoEが強調する。

「同じモデルでも、閾値の設定一つでコストが数百万円変わる。ここを科学的にやるのがプロの仕事だ。」

閾値が決定に与える影響

モデルが出力する確率スコアを、最終的に「正常」か「不正」かの二値判定に変換するのが閾値の役割である。

確率スコア → [閾値] → 判定

閾値を下げると:
  Recall ↑(より多くの不正を検知)
  Precision ↓(誤検知が増える)
  FNコスト ↓
  FPコスト ↑

閾値を上げると:
  Recall ↓(見逃しが増える)
  Precision ↑(誤検知が減る)
  FNコスト ↑
  FPコスト ↓

コストベース最適化

総コスト関数

import numpy as np
from sklearn.metrics import confusion_matrix

def total_cost(y_true, y_prob, threshold, cost_fn=50000, cost_fp=500):
    """指定閾値での総ビジネスコスト"""
    y_pred = (y_prob >= threshold).astype(int)
    tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
    return fn * cost_fn + fp * cost_fp

def find_optimal_threshold(y_true, y_prob,
                            cost_fn=50000, cost_fp=500,
                            n_thresholds=1000):
    """コスト最小化閾値を探索"""
    thresholds = np.linspace(0.001, 0.999, n_thresholds)
    costs = [total_cost(y_true, y_prob, t, cost_fn, cost_fp)
             for t in thresholds]

    best_idx = np.argmin(costs)
    return thresholds[best_idx], costs[best_idx], thresholds, costs

optimal_t, min_cost, thresholds, costs = find_optimal_threshold(
    y_test, y_prob
)
print(f"最適閾値: {optimal_t:.4f}")
print(f"最小コスト: {min_cost:,.0f}円")

コスト曲線の詳細分析

import matplotlib.pyplot as plt

fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# 総コスト
axes[0, 0].plot(thresholds, costs)
axes[0, 0].axvline(x=optimal_t, color='r', linestyle='--',
                    label=f'最適閾値={optimal_t:.3f}')
axes[0, 0].set_xlabel('閾値')
axes[0, 0].set_ylabel('総コスト(円)')
axes[0, 0].set_title('総コスト')
axes[0, 0].legend()

# FN/FPコストの内訳
fn_costs_list = []
fp_costs_list = []
for t in thresholds:
    y_pred = (y_prob >= t).astype(int)
    tn, fp, fn, tp = confusion_matrix(y_test, y_pred).ravel()
    fn_costs_list.append(fn * 50000)
    fp_costs_list.append(fp * 500)

axes[0, 1].plot(thresholds, fn_costs_list, label='FNコスト', color='red')
axes[0, 1].plot(thresholds, fp_costs_list, label='FPコスト', color='blue')
axes[0, 1].axvline(x=optimal_t, color='gray', linestyle='--')
axes[0, 1].set_xlabel('閾値')
axes[0, 1].set_ylabel('コスト(円)')
axes[0, 1].set_title('FN/FPコストの内訳')
axes[0, 1].legend()

# Recall/Precision
recalls = []
precisions = []
for t in thresholds:
    y_pred = (y_prob >= t).astype(int)
    tn, fp, fn, tp = confusion_matrix(y_test, y_pred).ravel()
    recalls.append(tp / (tp + fn) if (tp + fn) > 0 else 0)
    precisions.append(tp / (tp + fp) if (tp + fp) > 0 else 0)

axes[1, 0].plot(thresholds, recalls, label='Recall')
axes[1, 0].plot(thresholds, precisions, label='Precision')
axes[1, 0].axvline(x=optimal_t, color='gray', linestyle='--')
axes[1, 0].set_xlabel('閾値')
axes[1, 0].set_title('Recall / Precision')
axes[1, 0].legend()

# F1 Score
f1_scores = [2*p*r/(p+r) if (p+r) > 0 else 0
             for p, r in zip(precisions, recalls)]
axes[1, 1].plot(thresholds, f1_scores, color='green')
axes[1, 1].axvline(x=optimal_t, color='gray', linestyle='--',
                    label='コスト最適閾値')
best_f1_t = thresholds[np.argmax(f1_scores)]
axes[1, 1].axvline(x=best_f1_t, color='orange', linestyle='--',
                    label='F1最適閾値')
axes[1, 1].set_xlabel('閾値')
axes[1, 1].set_title('F1-Score')
axes[1, 1].legend()

plt.tight_layout()
plt.show()

精度 vs 再現率のトレードオフ

運用シナリオ別の閾値設定

シナリオ1: 高額取引の監視(見逃し厳禁)
  → 低い閾値(例: 0.1)
  → Recall > 95% を目指す
  → 偽陽性が多くても人手で確認

シナリオ2: 通常取引のスクリーニング(バランス重視)
  → 中程度の閾値(例: 0.3〜0.5)
  → F1-Score を最大化
  → 調査リソースとのバランス

シナリオ3: 自動ブロック判定(誤検知厳禁)
  → 高い閾値(例: 0.8)
  → Precision > 80% を目指す
  → 確実な不正のみ自動ブロック

多段階閾値の設計

# 実運用では単一閾値ではなく、多段階で判定する

def multi_threshold_decision(score, thresholds):
    """
    多段階閾値による判定
    thresholds: {'block': 0.8, 'review': 0.3, 'monitor': 0.1}
    """
    if score >= thresholds['block']:
        return 'BLOCK'     # 即座にブロック
    elif score >= thresholds['review']:
        return 'REVIEW'    # 人手による調査
    elif score >= thresholds['monitor']:
        return 'MONITOR'   # モニタリング対象
    else:
        return 'APPROVE'   # 承認

# 各段階の閾値をコスト最適化
# BLOCK:   高確信度の不正 → 自動ブロック
# REVIEW:  疑わしい取引   → 調査チームへ
# MONITOR: 軽度の異常     → ログ記録
# APPROVE: 正常           → 通過

閾値の動的調整

# 時間帯や取引特性に応じて閾値を動的に変更

def dynamic_threshold(base_threshold, transaction):
    """取引特性に基づく動的閾値"""
    threshold = base_threshold

    # 高額取引は閾値を下げる(より厳しく)
    if transaction['amount'] > 100000:
        threshold *= 0.5

    # 深夜帯は閾値を下げる
    hour = transaction['hour']
    if 2 <= hour <= 5:
        threshold *= 0.7

    # 新規顧客は閾値を下げる
    if transaction['account_age_days'] < 30:
        threshold *= 0.8

    return max(threshold, 0.05)  # 下限設定

感度分析

コスト比率の変化が閾値に与える影響

# FN/FPのコスト比率を変えて最適閾値がどう変わるか分析
cost_ratios = [10, 50, 100, 200, 500]
optimal_thresholds = []

for ratio in cost_ratios:
    cost_fn = ratio * 500  # FPコストの ratio 倍
    t, _, _, _ = find_optimal_threshold(
        y_test, y_prob, cost_fn=cost_fn, cost_fp=500
    )
    optimal_thresholds.append(t)
    print(f"FN/FP比率 {ratio}:1 → 最適閾値 = {t:.4f}")

# FN/FPコスト比が大きいほど、最適閾値は低くなる(Recall重視)

まとめ

項目ポイント
コスト最適化FN/FPコストに基づく閾値探索が最も実用的
多段階閾値BLOCK/REVIEW/MONITOR/APPROVEの4段階が実運用向け
動的閾値取引金額・時間帯・顧客属性で調整
感度分析コスト比率の変化が閾値に与える影響を把握しておく

チェックリスト

  • コストベースの閾値最適化を実装できる
  • 多段階閾値の設計意図を説明できる
  • 動的閾値の必要性と実装方法を理解した
  • コスト比率の感度分析を実施できる

次のステップへ

閾値最適化を理解したところで、次は演習で実際に不正検知モデルのスコアを改善しよう。

推定読了時間: 30分