「Accuracy(正解率)は99%を超えているのに、肝心の異常検知がまったくできていない」
AI開発の現場、特に製造業の予知保全や金融の不正検知といったプロジェクトでは、頻繁にこの壁に直面します。いわゆる「不均衡データ(Imbalanced Data)」の問題です。
多くのエンジニアは、この問題に対してすぐに「SMOTEでデータを増やそう」や「損失関数に重みを付けよう」といった対策に飛びつきがちです。しかし、ちょっと待ってください。そのデータ拡張、本当にデータの分布に適していると言い切れるでしょうか?
長年の開発現場で培った知見から言えるのは、「見えていないデータは制御できない」という事実です。数値上の件数比率だけを見て対策を講じるのは、目隠しをして手術をするようなものです。まずは手を動かし、データを可視化して仮説を検証するプロトタイプ思考が、ビジネス価値を生む最短距離となります。
本記事では、Pythonの可視化ライブラリであるMatplotlibを使い、単にグラフを描くのではなく、「不均衡データの性質を診断し、最適な処方箋(データ拡張戦略)を決定する」ための技術リファレンスを提供します。コードはそのまま現場で使えるレベルに落とし込んでいますので、ぜひ実際のプロジェクトで試してみてください。
1. 数値の罠:不均衡データ可視化の重要性
開発現場では普段、Pandasの value_counts() で得られる数値を見て安心しがちです。「正例が100件、負例が10,000件か。1:100だな」と。しかし、この数値情報だけでは、モデルが直面する学習の難易度は分かりません。
データ不均衡がもたらすバイアス
機械学習モデル、特にディープラーニングや勾配ブースティング決定木などは、基本的に「全体の損失を最小化」しようと学習します。圧倒的多数派であるクラスのパターンを学習するほうが、手っ取り早く損失を下げられるため、少数派(マイノリティ)クラスはノイズとして無視されやすくなります。
ここで重要なのは、「件数の比率」以上に「分布の重なり」が問題であるという点です。
- ケースA: 正例と負例が特徴量空間できれいに分かれている(分離可能)。
- ケースB: 正例が負例の中に埋もれている、あるいは境界が曖昧(オーバーラップ)。
ケースAなら、件数に差があってもシンプルな重み付け(Class Weight)で解決することが多いです。一方、ケースBで無闇にデータを増やすと、逆にノイズを増幅させて決定境界を歪めるリスクがあります。
可視化による戦略決定プロセス
推奨されるアプローチは、以下の3ステップです。
- 量的な不均衡を確認する: どの程度極端な偏りか(対数スケールが必要か)。
- 質的な分布を確認する: 特徴量空間でクラス同士がどう位置しているか。
- 戦略を選択する: 診断結果に基づき、サンプリング手法やアルゴリズムを選択する。
これから紹介するMatplotlibのテクニックは、この診断プロセスをスピーディーに実行するためのツールです。
2. クラス分布の可視化:対数軸で見逃さない極端な偏り
まずは基本となるクラス分布の確認です。しかし、不正検知などの極端な不均衡データ(例:1:10000)の場合、通常の棒グラフではマイノリティクラスが表示すらされないことがあります。
plt.bar():頻度比較の基本パラメータ
極端な差がある場合、Y軸を対数スケールに設定することが「診断」の第一歩です。
import matplotlib.pyplot as plt
import numpy as np
# サンプルデータ生成(極端な不均衡)
classes = ['Normal', 'Fraud']
counts = [10000, 50]
plt.figure(figsize=(8, 6))
# log=True が診断のポイント
bars = plt.bar(classes, counts, color=['#1f77b4', '#d62728'], log=True)
plt.title('Class Distribution (Log Scale)', fontsize=14)
plt.ylabel('Count (Log Scale)', fontsize=12)
# 数値をバーの上に表示
for bar in bars:
height = bar.get_height()
plt.text(bar.get_x() + bar.get_width()/2., height,
f'{int(height)}',
ha='center', va='bottom', fontsize=12)
plt.grid(axis='y', linestyle='--', alpha=0.7)
plt.show()
log=True: これを設定しないと、50件のFraudデータは10000件のNormalデータの隣では視認できません。対数軸にすることで、存在自体を認識させ、オーダー(桁数)の違いを直感的に把握できます。- 配色の工夫: マイノリティクラスには警告色(赤など)を使い、視覚的な注意を喚起します。
plt.pie():構成比率の直感的把握
円グラフは嫌われがちですが、全体の構成比を直感的に伝えるには有効です。ただし、微小なスライスが見えなくなるのを防ぐため、エクスプロード(切り出し)を使います。
plt.figure(figsize=(8, 8))
explode = (0, 0.1) # マイノリティクラスを少し切り出す
plt.pie(counts, labels=classes, autopct='%1.1f%%',
startangle=90, colors=['#1f77b4', '#d62728'],
explode=explode, textprops={'fontsize': 14})
plt.title('Class Ratio', fontsize=16)
plt.show()
ここで経営者視点からも重要なのは、「0.5%」という数字のインパクトをチーム全体で共有することです。この0.5%の異常を見つけるために、残りの99.5%をどう扱うか、というビジネス上の議論の出発点になります。
3. 特徴量空間の分布可視化:データ拡張か重み付けか、戦略の分岐点
ここからが本題です。データの「質」を見るためには、特徴量空間での分布を見る必要があります。高次元データの場合はPCAやt-SNEで2次元に圧縮してからプロットするのが一般的ですが、ここでは原理を理解するために2つの特徴量に注目した散布図を考えます。
plt.scatter():クラス間の境界線確認
散布図を描く際、最も注目すべきは「クラス間のオーバーラップ(重なり)」です。
# サンプルデータ生成(2次元特徴量)
np.random.seed(42)
# 多数派クラス
X_maj = np.random.normal(0, 1, (1000, 2))
# 少数派クラス(少しずらして配置、一部重なる)
X_min = np.random.normal(2, 1, (50, 2))
plt.figure(figsize=(10, 8))
# alphaパラメータで密度を表現
plt.scatter(X_maj[:, 0], X_maj[:, 1], c='#1f77b4', label='Majority', alpha=0.3, s=20)
plt.scatter(X_min[:, 0], X_min[:, 1], c='#d62728', label='Minority', alpha=0.8, s=50, edgecolors='k')
plt.title('Feature Space Distribution', fontsize=16)
plt.xlabel('Feature 1')
plt.ylabel('Feature 2')
plt.legend()
plt.grid(True, linestyle=':', alpha=0.6)
plt.show()
診断のポイント
分離性(Separability): 赤い点(少数派)と青い点(多数派)の間に明確な隙間があるか?
- 隙間がある場合: データ拡張よりも、決定境界を調整する手法(Class Weightや閾値調整)が効果的です。
- 混ざり合っている場合: 単純な重み付けでは、重なっている部分で誤検知が増えます。ここで初めて、データの分布を変えるようなアプローチ(非線形モデルの採用や、より高度な特徴量エンジニアリング)を検討します。
クラス内クラスタ(Sub-clusters): 少数派クラスがひと固まりか、それとも散らばっているか?
- 散らばっている場合(Small Disjuncts問題)、単純なSMOTE(近傍点の中間を埋める)を行うと、本来データが存在しない空間にノイズデータを生成してしまうリスクがあります。
alphaパラメータによる密度表現
コード内の alpha=0.3 は非常に重要です。多数派クラスの点は密集しているため、不透明度を下げることで「どこが最も高密度か」を濃淡で表現できます。これにより、少数派クラスが多数派の「中心」に埋もれているのか、「裾野」に位置しているのかを判別できます。
4. ヒストグラムによる特徴量分布の比較
散布図は2次元の関係性を見ますが、個々の特徴量がクラス判別にどれだけ寄与しているかを確認するにはヒストグラムが最適です。
plt.hist():クラス別特徴量の重ね合わせ
feature_maj = X_maj[:, 0]
feature_min = X_min[:, 0]
plt.figure(figsize=(10, 6))
# density=True で正規化し、件数差の影響を排除して形状を比較
plt.hist(feature_maj, bins=30, alpha=0.5, label='Majority', density=True, color='#1f77b4')
plt.hist(feature_min, bins=15, alpha=0.5, label='Minority', density=True, color='#d62728')
# 境界線の可視化(KDE風に見せるためstepを使用)
plt.hist(feature_maj, bins=30, histtype='step', density=True, color='#1f77b4', linewidth=2)
plt.hist(feature_min, bins=15, histtype='step', density=True, color='#d62728', linewidth=2)
plt.title('Feature Distribution Comparison (Normalized)', fontsize=16)
plt.xlabel('Feature Value')
plt.ylabel('Density')
plt.legend()
plt.show()
診断のポイント
density=True: これが必須です。件数が100倍違うデータをそのままヒストグラムにすると、少数派の分布はX軸に張り付いて見えなくなります。正規化(確率密度化)することで、分布の「形状」を比較できます。- オーバーラップの面積: 2つのヒストグラムが重なっている面積が大きいほど、その特徴量単体での分類は困難です。逆に、ピークの位置がずれていれば、その特徴量は有力な判断材料になります。
5. データ拡張戦略への接続と効果検証
可視化で診断がついたら、いよいよ処方箋(データ拡張)を選びます。そして実践において重要なのは、拡張後のデータを再度可視化して検証することです。仮説を即座に形にして確認するサイクルが、精度の高いモデルを生み出します。
可視化パターン別推奨戦略
| 可視化で見た状況 | 推奨される戦略 | 理由 |
|---|---|---|
| 分離が良い | Class Weight | 境界は明確なので、学習時のペナルティ調整だけで十分対応可能。データを人工的に増やす必要性が薄い。 |
| 境界付近で混在 | SMOTE + Tomek Links | 境界を明確にするため、SMOTEでマイノリティを増やしつつ、Tomek Linksで境界付近の紛らわしいデータを削除する。 |
| 完全に埋没 | 特徴量エンジニアリング | 現在の特徴量では分離不可能。データをいじる前に、新しい特徴量を作るか、異常検知(One-class SVM等)への切り替えを検討。 |
Before/Afterの比較プロット
データ拡張ライブラリ imbalanced-learn を使った結果をMatplotlibで確認するコードです。
from imblearn.over_sampling import SMOTE
# SMOTEによるデータ拡張
smote = SMOTE(random_state=42)
X_res, y_res = smote.fit_resample(np.vstack([X_maj, X_min]),
np.hstack([np.zeros(len(X_maj)), np.ones(len(X_min))]))
# 可視化設定
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
# Before
axes[0].scatter(X_maj[:, 0], X_maj[:, 1], c='#1f77b4', alpha=0.3, label='Majority')
axes[0].scatter(X_min[:, 0], X_min[:, 1], c='#d62728', alpha=0.8, label='Minority')
axes[0].set_title('Before Resampling')
axes[0].legend()
# After (Resampled)
# 拡張されたデータを抽出
X_res_maj = X_res[y_res == 0]
X_res_min = X_res[y_res == 1]
axes[1].scatter(X_res_maj[:, 0], X_res_maj[:, 1], c='#1f77b4', alpha=0.3, label='Majority')
axes[1].scatter(X_res_min[:, 0], X_res_min[:, 1], c='#d62728', alpha=0.8, label='Minority')
axes[1].set_title('After SMOTE Resampling')
axes[1].legend()
plt.show()
このプロットを見て確認すべきは、「生成されたデータ(新しい赤い点)が不自然な場所にないか」です。例えば、多数派クラスの密集地帯を突き抜けて線状にデータが生成されている場合(SMOTEの特性)、それはモデルにとって有害なノイズになる可能性があります。その場合は、ADASYN など他のアルゴリズムを試すか、k_neighbors パラメータを調整する必要があります。
6. 実装用コードスニペット集
最後に、実務の現場で頻繁に活用される、汎用的な診断関数を紹介します。コピーしてそのまま使ってみてください。
def plot_imbalanced_diagnosis(X, y, feature_indices=(0, 1), feature_names=None):
"""
不均衡データの診断プロットを一括生成する関数
Parameters:
X: 特徴量行列
y: ラベルベクトル
feature_indices: 散布図に使用する特徴量のインデックス (idx1, idx2)
feature_names: 特徴量名のリスト
"""
if feature_names is None:
feature_names = [f'Feature {i}' for i in range(X.shape[1])]
f1, f2 = feature_indices
fig = plt.figure(figsize=(15, 10))
gs = fig.add_gridspec(2, 2)
# 1. クラス分布 (Bar Plot)
ax1 = fig.add_subplot(gs[0, 0])
unique, counts = np.unique(y, return_counts=True)
bars = ax1.bar(unique, counts, color=['#1f77b4', '#d62728'], log=True)
ax1.set_title('Class Distribution (Log Scale)')
ax1.set_xticks(unique)
for bar in bars:
height = bar.get_height()
ax1.text(bar.get_x() + bar.get_width()/2., height, f'{int(height)}',
ha='center', va='bottom')
# 2. 散布図 (Scatter Plot)
ax2 = fig.add_subplot(gs[0, 1])
for label, color, name in zip(unique, ['#1f77b4', '#d62728'], ['Majority', 'Minority']):
mask = y == label
ax2.scatter(X[mask, f1], X[mask, f2], c=color, label=name, alpha=0.5, edgecolors='none')
ax2.set_title(f'Scatter Plot ({feature_names[f1]} vs {feature_names[f2]})')
ax2.set_xlabel(feature_names[f1])
ax2.set_ylabel(feature_names[f2])
ax2.legend()
# 3. ヒストグラム (Feature 1)
ax3 = fig.add_subplot(gs[1, 0])
for label, color, name in zip(unique, ['#1f77b4', '#d62728'], ['Majority', 'Minority']):
ax3.hist(X[y==label, f1], bins=30, density=True, alpha=0.5, color=color, label=name)
ax3.hist(X[y==label, f1], bins=30, density=True, histtype='step', color=color, linewidth=2)
ax3.set_title(f'Distribution of {feature_names[f1]}')
ax3.legend()
# 4. ヒストグラム (Feature 2)
ax4 = fig.add_subplot(gs[1, 1])
for label, color, name in zip(unique, ['#1f77b4', '#d62728'], ['Majority', 'Minority']):
ax4.hist(X[y==label, f2], bins=30, density=True, alpha=0.5, color=color, label=name)
ax4.hist(X[y==label, f2], bins=30, density=True, histtype='step', color=color, linewidth=2)
ax4.set_title(f'Distribution of {feature_names[f2]}')
ax4.legend()
plt.tight_layout()
plt.show()
この関数を使えば、データセットを読み込んだ直後に plot_imbalanced_diagnosis(X, y) と一行書くだけで、データの健康状態を概観できます。プロジェクトの初期段階、EDA(探索的データ分析)のフェーズで必ず実行することをお勧めします。
まとめ:データという「原石」を磨くのはエンジニアの眼
不均衡データへの対処は、AutoMLツールに任せれば解決するものではありません。データの分布形状、クラス間の距離、そしてビジネス上のリスク(見逃しコスト vs 誤検知コスト)を総合的に判断する必要があります。
Matplotlibによる可視化は、その判断を下すための「エンジニアの眼」を拡張する強力な武器です。
- 対数軸で規模感を掴む
- 散布図で分離可能性を見極める
- ヒストグラムで特徴量の有効性を測る
- 拡張前後の変化を目視確認する
これらのステップを踏むことで、闇雲なトライ&エラーから脱却し、根拠のあるモデル改善が可能になります。
あなたのAIプロジェクトが、データという原石から確かな価値を引き出し、ビジネスの成功へと直結することを願っています。
コメント