機械学習のためのデータ前処理:数値データを「グループ分け」するビン分割(離散化)の方法【Pandas入門】
はじめに
機械学習モデルを構築する際、データはさまざまな形式で存在します。その中でも特に、売上、年齢、気温といった数値データは頻繁に扱われます。これらの数値データをそのままモデルに入力することも多いですが、場合によってはデータをいくつかのグループに「分割」することで、モデルの性能向上やデータの解釈性向上が期待できることがあります。
この「数値データをいくつかのグループに分割する」前処理は、「ビン分割(Binning)」または「離散化(Discretization)」と呼ばれます。本記事では、なぜビン分割が必要なのか、そしてデータ前処理でよく用いられるPythonのライブラリであるPandasを使って、どのようにビン分割を行うのかを具体的に解説します。
ビン分割(離散化)とは何か?なぜ必要か?
ビン分割とは、連続する数値データを、定義された境界に基づいていくつかの区間(ビン、Bin)に分け、それぞれの区間をカテゴリとして扱う手法です。例えば、年齢データを「20代未満」「20代」「30代」「40代以上」のようにグループ分けするようなイメージです。
では、なぜこのような処理が機械学習において有効な場合があるのでしょうか。主な理由は以下の通りです。
- 非線形性の考慮: 線形モデル(線形回帰やロジスティック回帰など)は、データと目的変数との関係が線形であると仮定します。しかし、実際の世界では関係性が非線形であることが少なくありません。ビン分割によって非線形の関係を捉えやすくなることがあります。例えば、年齢が特定の区間でのみ大きく影響するような場合などです。
- 外れ値の影響軽減: 極端な値(外れ値)が含まれる場合、モデルの学習に悪影響を与えることがあります。ビン分割をすることで、外れ値が特定のビンにまとめて扱われるようになり、その影響を緩和できる場合があります。
- 解釈性の向上: 数値をそのまま見るよりも、グループ分けされた方が直感的に理解しやすい場合があります。「30代後半の顧客層」「高価格帯の商品グループ」といったように、具体的なカテゴリとしてデータを捉えることで、分析結果の説明が容易になります。
- 特定のモデルとの相性: 決定木のようなツリーベースのモデルは、内部的にデータを分割して学習を進めます。ビン分割は、このようなモデルに対して必ずしも必須ではありませんが、特定の状況で効果を発揮することがあります。一方で、サポートベクターマシンやニューラルネットワークなど、スケーリングを前提とするモデルでは、ビン分割が必要ない場合や、逆に性能を低下させる可能性もあります。
このように、ビン分割はデータの特性や利用するモデルによっては非常に有用な前処理手法となります。
Pandasを使ったビン分割の方法
Pandasライブラリには、ビン分割のための便利な関数が用意されています。ここでは、主に pd.cut()
と pd.qcut()
という2つの関数を紹介します。
まずは、サンプルデータを用意しましょう。ここでは、ある顧客の年齢データを模倣したPandasのSeriesデータを使用します。
import pandas as pd
import numpy as np
# サンプルデータの生成 (年齢を想定)
np.random.seed(42) # 再現性のためシードを固定
ages = pd.Series(np.random.randint(18, 70, size=20)) # 18歳から69歳までのランダムな整数20個
print("元のデータ(年齢):")
print(ages)
実行結果例:
元のデータ(年齢):
0 58
1 37
2 53
3 58
4 60
5 61
6 40
7 64
8 45
9 20
10 27
11 56
12 68
13 60
14 30
15 69
16 53
17 40
18 46
19 49
dtype: int32
pd.cut()
: 値の範囲に基づいて分割する
pd.cut()
関数は、指定した値の境界に基づいてデータを分割します。これは、データを等間隔に分けたり、「〇歳未満」「〇歳~〇歳」「〇歳以上」のようにカスタムの範囲で分けたりしたい場合に便利です。
等間隔での分割
bins
引数に分割したい区間の数を指定すると、データの最小値から最大値までを等間隔に分割します。
# 年齢データを5つの等間隔のビンに分割
# bins=5 と指定すると、データの最小値と最大値の間を5等分します
# right=True は、各区間の右端の値を含む(例: (a, b])ことを意味します。デフォルトはTrueです。
# include_lowest=True は、一番左の区間の左端の値を含む(例: [min, ...])ことを意味します。
age_bins_equal_interval = pd.cut(ages, bins=5, right=True, include_lowest=True)
print("\n等間隔で5つのビンに分割した結果:")
print(age_bins_equal_interval)
print("\n各ビンのデータ数:")
print(age_bins_equal_interval.value_counts().sort_index())
実行結果例:
等間隔で5つのビンに分割した結果:
0 (58.2, 69.0]
1 (35.8, 47.0]
2 (47.0, 58.2]
3 (47.0, 58.2]
4 (58.2, 69.0]
5 (58.2, 69.0]
6 (35.8, 47.0]
7 (58.2, 69.0]
8 (47.0, 58.2]
9 [17.949, 24.6]
10 (24.6, 35.8]
11 (47.0, 58.2]
12 (58.2, 69.0]
13 (58.2, 69.0]
14 (24.6, 35.8]
15 (58.2, 69.0]
16 (47.0, 58.2]
17 (35.8, 47.0]
18 (47.0, 58.2]
19 (47.0, 58.2]
dtype: category
Categories (5, interval[float64, right]): [[17.949, 24.6] < (24.6, 35.8] < (35.8, 47.0] < (47.0, 58.2] < (58.2, 69.0]]
各ビンのデータ数:
[17.949, 24.6] 1
(24.6, 35.8] 2
(35.8, 47.0] 4
(47.0, 58.2] 7
(58.2, 69.0] 6
dtype: int64
結果を見ると、年齢が指定した5つの区間(カテゴリ)に分けられていることがわかります。value_counts()
で各区間にいくつのデータが含まれているかを確認できます。等間隔で分割した場合、各区間に含まれるデータの数は均等にはなりません。
カスタム境界での分割
bins
引数に区間の境界値のリストを指定することで、自由に区間を定義できます。例えば、年齢を「~20歳」「21~40歳」「41~60歳」「61歳~」のように分けたい場合に使用します。
# カスタムの境界値を指定して分割
# 境界値のリストは昇順である必要があります
# 例: 20歳以下, 21歳から40歳, 41歳から60歳, 61歳以上
# 境界値リスト: [最小値, 20, 40, 60, 最大値+1]
# 実際には、区間を分かりやすくするために境界値を指定します。
# 今回は [18, 20, 40, 60, 70] とします。
# 18歳から69歳までのデータなので、区間は (18, 20], (20, 40], (40, 60], (60, 70] となります。
# right=True なので、右側の境界値は区間に含まれます。
# include_lowest=True は最初の区間 [18, 20] の左端を含むようにします。
custom_bins = [17, 20, 40, 60, 70] # 境界値を指定
age_bins_custom = pd.cut(ages, bins=custom_bins, right=True, include_lowest=True)
print("\nカスタム境界で分割した結果:")
print(age_bins_custom)
print("\n各ビンのデータ数:")
print(age_bins_custom.value_counts().sort_index())
# 区間のラベルを分かりやすい文字列にする場合
# labels 引数に区間名(カテゴリ名)のリストを指定します。
# ラベルの数は、区間の数と同じである必要があります。(境界値リストの要素数 - 1)
bin_labels = ["~20歳", "21~40歳", "41~60歳", "61歳~"]
age_bins_labeled = pd.cut(ages, bins=custom_bins, right=True, include_lowest=True, labels=bin_labels)
print("\nカスタム境界&ラベル付きで分割した結果:")
print(age_bins_labeled)
print("\n各ビンのデータ数:")
print(age_bins_labeled.value_counts().sort_index())
実行結果例:
カスタム境界で分割した結果:
0 (40.0, 60.0]
1 (20.0, 40.0]
2 (40.0, 60.0]
3 (40.0, 60.0]
4 (40.0, 60.0]
5 (60.0, 70.0]
6 (20.0, 40.0]
7 (60.0, 70.0]
8 (40.0, 60.0]
9 (17.0, 20.0]
10 (20.0, 40.0]
11 (40.0, 60.0]
12 (60.0, 70.0]
13 (40.0, 60.0]
14 (20.0, 40.0]
15 (60.0, 70.0]
16 (40.0, 60.0]
17 (20.0, 40.0]
18 (40.0, 60.0]
19 (40.0, 60.0]
dtype: category
Categories (4, interval[int64, right]): [(17, 20] < (20, 40] < (40, 60] < (60, 70]]
各ビンのデータ数:
(17.0, 20.0] 1
(20.0, 40.0] 4
(40.0, 60.0] 11
(60.0, 70.0] 4
dtype: int64
カスタム境界&ラベル付きで分割した結果:
0 41~60歳
1 21~40歳
2 41~60歳
3 41~60歳
4 41~60歳
5 61歳~
6 21~40歳
7 61歳~
8 41~60歳
9 ~20歳
10 21~40歳
11 41~60歳
12 61歳~
13 41~60歳
14 21~40歳
15 61歳~
16 41~60歳
17 21~40歳
18 41~60歳
19 41~60歳
dtype: category
Categories (4, object): ['~20歳' < '21~40歳' < '41~60歳' < '61歳~']
各ビンのデータ数:
~20歳 1
21~40歳 4
41~60歳 11
61歳~ 4
dtype: int64
labels
引数を使うことで、ビンの名称をより人間が理解しやすい文字列にすることができます。これは、特に分析結果を報告する際に役立ちます。
pd.qcut()
: データ数が均等になるように分割する
pd.qcut()
関数は、各区間に含まれるデータの数がほぼ均等になるようにデータを分割します。これは、データの分布に関わらず、それぞれのビンに同じくらいのデータ量を含めたい場合に適しています。百分位数(パーセンタイル)に基づいて区間を決定するため、「Quantile-cut」と呼ばれます。
q
引数に分割したい区間の数を指定します。
# 年齢データをデータ数がほぼ均等になるように4つのビンに分割
# q=4 はデータを4つの四分位数で分割することを意味します
age_bins_quantile = pd.qcut(ages, q=4)
print("\nデータ数が均等になるように4つのビンに分割した結果:")
print(age_bins_quantile)
print("\n各ビンのデータ数:")
print(age_bins_quantile.value_counts().sort_index())
実行結果例:
データ数が均等になるように4つのビンに分割した結果:
0 (53.0, 60.0]
1 (30.0, 45.25]
2 (45.25, 53.0]
3 (53.0, 60.0]
4 (53.0, 60.0]
5 (60.0, 69.0]
6 (30.0, 45.25]
7 (60.0, 69.0]
8 (45.25, 53.0]
9 (17.999, 30.0]
10 (17.999, 30.0]
11 (45.25, 53.0]
12 (60.0, 69.0]
13 (53.0, 60.0]
14 (17.999, 30.0]
15 (60.0, 69.0]
16 (45.25, 53.0]
17 (30.0, 45.25]
18 (45.25, 53.0]
19 (45.25, 53.0]
dtype: category
Categories (4, interval[float64, right]): [(17.999, 30.0] < (30.0, 45.25] < (45.25, 53.0] < (53.0, 60.0] < (60.0, 69.0]]
各ビンのデータ数:
(17.999, 30.0] 5
(30.0, 45.25] 5
(45.25, 53.0] 5
(53.0, 60.0] 5
(60.0, 69.0] 5 # 元データのサイズが20なので、各ビンに均等に5個ずつデータが割り振られています
dtype: int64
pd.qcut()
の結果を見ると、各ビンに含まれるデータの数がほぼ均等(この例では正確に均等)になっていることがわかります。ただし、区間の幅は等間隔ではありません。
pd.qcut()
でも、labels
引数を使って分かりやすいラベルを付けることができます。
# データ数が均等になるように4つのビンに分割し、ラベルを付ける
quantile_labels = ["下位25%", "25%~50%", "50%~75%", "上位25%"]
age_bins_quantile_labeled = pd.qcut(ages, q=4, labels=quantile_labels)
print("\nデータ数が均等&ラベル付きで分割した結果:")
print(age_bins_quantile_labeled)
print("\n各ビンのデータ数:")
print(age_bins_quantile_labeled.value_counts().sort_index())
実行結果例:
データ数が均等&ラベル付きで分割した結果:
0 50%~75%
1 25%~50%
2 50%~75%
3 50%~75%
4 50%~75%
5 上位25%
6 25%~50%
7 上位25%
8 50%~75%
9 下位25%
10 下位25%
11 50%~75%
12 上位25%
13 50%~75%
14 下位25%
15 上位25%
16 50%~75%
17 25%~50%
18 50%~75%
19 50%~75%
dtype: category
Categories (4, object): ['下位25%' < '25%~50%' < '50%~75%' < '上位25%']
各ビンのデータ数:
下位25% 5
25%~50% 5
50%~75% 5
上位25% 5
dtype: int64
どちらの関数を選ぶべきか?
pd.cut()
と pd.qcut()
は、それぞれ異なる基準でビン分割を行います。
pd.cut()
は値の範囲に基づいて分割します。年齢層や価格帯のように、特定の数値範囲に意味がある場合や、等間隔で区切りたい場合に適しています。カスタムの境界値を指定できる柔軟性があります。pd.qcut()
はデータの数が均等になるように分割します。データの分布に関わらず、各ビンに同じくらいのデータを含めたい場合(例えば、顧客を売上の下位25%、中間50%、上位25%のように層別化したい場合など)に有効です。
どちらの方法が適切かは、データの特性や解決したい問題によって異なります。両方を試してみて、モデルの性能や解釈性が向上するかどうかを確認することが重要です。
ビン分割の注意点
ビン分割は便利な手法ですが、いくつか注意点があります。
- 情報損失: 数値データをカテゴリに変換するため、元の数値が持っていた細かな情報は失われます。ビンの数を増やしすぎるとオーバーフィッティング(特定のデータに過剰に適合し、未知のデータへの対応力が落ちる状態)のリスクが高まり、ビンの数を減らしすぎると情報が失われすぎてモデルの性能が低下する可能性があります。適切なビンの数を見つけるためには、試行錯誤が必要です。
- 境界値の扱い:
right
引数で区間の右端を含むか含めないかを適切に設定することが重要です。デフォルトはright=True
で右端を含みます(例:(a, b]
)。 - 外れ値の影響 (
pd.cut
):pd.cut
を等間隔で使用する場合、極端な外れ値があると、一つのビンが非常に広範囲をカバーしてしまう可能性があります。カスタム境界を指定したり、外れ値処理を事前に行ったりすることが有効な場合もあります。
まとめ
本記事では、機械学習のためのデータ前処理手法の一つである「ビン分割(離散化)」について解説しました。
- ビン分割は、数値データをいくつかの区間(ビン)にグループ分けする処理です。
- 非線形性の考慮、外れ値の影響軽減、解釈性の向上などの目的で利用されます。
- Pandasの
pd.cut()
関数を使うと、値の範囲(等間隔またはカスタム境界)で分割できます。 - Pandasの
pd.qcut()
関数を使うと、データ数が均等になるように分割できます。 - どちらの関数を選択するかは、データの特性や目的に応じて判断し、試行錯誤が必要です。
ビン分割は、特に数値データの扱いに慣れていない方でも、データをカテゴリとして捉え直すことで、分析の幅を広げ、ビジネス的な解釈を深めるのに役立ちます。ぜひご自身のデータでも試してみてください。
この記事では、データ前処理における数値データのビン分割(離散化)の基本的な考え方と、Pandasを使った実装方法をご紹介しました。次回の記事では、別の前処理手法について解説していく予定です。データ前処理の各ステップを一つずつ理解し、機械学習モデル構築への道を確実に進んでいきましょう。