連続する値を任意の境界値で分けることをビン分割やビニングといいます。このようなケースでは、pd.cut()
関数が便利です。
pd.cut()
関数は、次のように使います。
# 指定したSeriesをビン分割する
pd.cut(ビン分割するSeries, 境界値のリスト)
具体的な例を見てみましょう。次のような試験結果のデータについて考えます。
生徒ID | クラス | 点数 |
---|---|---|
ST001 | 1-A | 60 |
ST002 | 1-A | 87 |
ST003 | 1-B | 66 |
ST004 | 1-A | 72 |
ST005 | 1-B | 74 |
ST006 | 1-B | 67 |
このデータを、列点数
の値に応じて次のように分けることにします。
- 0 < 点数 <= 60
- 60 < 点数 <= 70
- 70 < 点数 <= 80
- 80 < 点数 <= 90
この場合、境界値のリストは[0, 60, 70, 80, 90]
なので、次のように記述します。
# 列「点数」を指定した境界値でビン分割
cut_sr = pd.cut(df["点数"], [0, 60, 70, 80, 90])
cut_sr
実行結果
生徒ID
ST001 (0, 60]
ST002 (80, 90]
ST003 (60, 70]
ST004 (70, 80]
ST005 (70, 80]
ST006 (60, 70]
Name: 点数, dtype: category
Categories (4, interval[int64, right]): [(0, 60] < (60, 70] < (70, 80] < (80, 90]]
実行結果は、dtypeがCategorical型のSeriesになります。ここでは、「Categorical型はカテゴリーデータを扱うためのデータ型である」 という点だけ分かっていれば大丈夫です。
生徒ST001
(60点)の結果を見てみましょう。(0, 60]
とは、「0点より大きく60点以下」というカテゴリーを示しています。
丸括弧((
)は 「境界値を含まない」 、角括弧(]
)は 「境界値を含む」 という意味です。
結果を見ると、生徒ST001
(60点)は(0, 60]
、生徒ST002
(87点)は(80, 90]
と、適切なビンが割り当てられていることがわかります。
境界値の調整
さて、今回挙げたデータはテストの点数なので0
点の可能性があります。
現状の分割の仕方だと、(0, 60]
は「0点より大きく60点以下」なので、0が含まれません。このままだと、0点をとった生徒がいた場合に適切なカテゴリーをわりふれません。
このような場合は、最初の境界値に-1
を指定するか、引数include_lowest
でTrue
を指定します。
引数include_lowest
でTrue
を指定すると、最初の境界値を含むように調整してビン分割されます。
# 最初の境界値を含むようにビン分割
cut_sr = pd.cut(df["点数"], [0, 60, 70, 80, 90], include_lowest=True)
cut_sr
実行結果
生徒ID
ST001 (-0.001, 60.0]
ST002 (80.0, 90.0]
ST003 (60.0, 70.0]
ST004 (70.0, 80.0]
ST005 (70.0, 80.0]
ST006 (60.0, 70.0]
Name: 点数, dtype: category
Categories (4, interval[float64, right]): [(-0.001, 60.0] < (60.0, 70.0] < (70.0, 80.0] < (80.0, 90.0]]
生徒ST001
の結果が(0, 60]
ではなく(-0.001, 60.0]
(-0.001より大きく60点以下)になっており、0を含むように分割されていることがわかります。
演習
まずは、今回使う試験結果のデータを読み込みましょう。1行あたり1生徒のデータで、所属するクラス・試験の点数・学習時間を表す列があります。In [1]:
import pandas as pd # 試験結果のデータの読み込み df = pd.read_csv("dataset/score_cut.csv") # 先頭5行を確認 df.head()
Out[1]:
生徒ID | 点数 | 学習時間(分) | |
---|---|---|---|
0 | ST001 | 48 | 226 |
1 | ST002 | 0 | 24 |
2 | ST003 | 80 | 271 |
3 | ST004 | 12 | 45 |
4 | ST005 | 68 | 271 |
(1)列点数の区分
の追加
まずは、点数を20点刻みで分割してみましょう。pd.cut(Series, 境界値のリスト)
のように書くと、指定した境界値でデータを分割できます。
試しに、境界値に[0, 20, 40, 60, 80, 100]
を指定して実行してみましょう。
結果を見ると、1行目のデータは48
点なので、(40, 60]
(40点より大きく、60点以下)の区分になっていることがわかります。しかし、2行目のデータはNaN
になっています。これは、2行目の生徒は0
点なので、今回指定した境界値だと該当する区分がないためです((0, 20]
は「0より高く20点以下」なので、0は含まれない)。In [2]:
# 境界値を指定して、点数によってビン分割 result = pd.cut(df["点数"], [0, 20, 40, 60, 80, 100]) # 先頭5行を確認 result.head()
Out[2]:
0 (40.0, 60.0] 1 NaN 2 (60.0, 80.0] 3 (0.0, 20.0] 4 (60.0, 80.0] Name: 点数, dtype: category Categories (5, interval[int64, right]): [(0, 20] < (20, 40] < (40, 60] < (60, 80] < (80, 100]]
このようなケースでは、引数include_lowest
が便利です。引数include_lowest
でTrue
を指定すると、 最初の区分で境界値(ここでは0
)が含まれるように調整 して分割されます。
次のコードを実行すると、先ほどはNaN
だった2行目のデータが (-0.001, 20.0]
(-0.001点より高く20点以下) の区分に割り当てられてていることがわかります。In [3]:
# 最初の区分では0が含まれるように指定して分割 result = pd.cut(df["点数"], [0, 20, 40, 60, 80, 100], include_lowest=True) # 先頭5行を確認 result.head()
Out[3]:
0 (40.0, 60.0] 1 (-0.001, 20.0] 2 (60.0, 80.0] 3 (-0.001, 20.0] 4 (60.0, 80.0] Name: 点数, dtype: category Categories (5, interval[float64, right]): [(-0.001, 20.0] < (20.0, 40.0] < (40.0, 60.0] < (60.0, 80.0] < (80.0, 100.0]]
ビン分割の結果を、列点数の区分
として元のDataFrameに追加しましょう。In [4]:
# 新しい列として追加 df["点数の区分"] = pd.cut(df["点数"], [0, 20, 40, 60, 80, 100], include_lowest=True) # 先頭5行を確認 df.head()
Out[4]:
生徒ID | 点数 | 学習時間(分) | 点数の区分 | |
---|---|---|---|---|
0 | ST001 | 48 | 226 | (40.0, 60.0] |
1 | ST002 | 0 | 24 | (-0.001, 20.0] |
2 | ST003 | 80 | 271 | (60.0, 80.0] |
3 | ST004 | 12 | 45 | (-0.001, 20.0] |
4 | ST005 | 68 | 271 | (60.0, 80.0] |
(2)列点数の区分
ごとの人数と平均学習時間
次に、列点数の区分
でグループ化して、各区分ごとの人数(生徒IDのユニーク数)を数えましょう。次のことがわかります。
(-0.001, 20]
(-0.001点より高く20点以下): 2人(20, 40]
(20点より高く40点以下): 0人(40, 60]
(40点より高く60点以下): 6人(60, 80]
(60点より高く80点以下): 7人(80, 100]
(80点より高く100点以下): 5人
In [5]:
# 列「点数の区分」でグループ化 grouped = df.groupby("点数の区分") # 点数の区分ごとに、生徒のユニーク数を数える nunique_result = grouped["生徒ID"].nunique() nunique_result
Out[5]:
点数の区分 (-0.001, 20.0] 2 (20.0, 40.0] 0 (40.0, 60.0] 6 (60.0, 80.0] 7 (80.0, 100.0] 5 Name: 生徒ID, dtype: int64
最後に、各区分ごとの学習時間の平均を集計してみましょう。下記コードを実行すると、各区分ごとの学習時間の平均が計算されます。(20, 40]
(20点より高く40点以下)のグループは0人なので、平均値が計算できず結果がNaN
になります。In [6]:
# 点数の区分ごとに、学習時間の平均を計算 mean_result = grouped["学習時間(分)"].mean() mean_result
Out[6]:
点数の区分 (-0.001, 20.0] 34.500000 (20.0, 40.0] NaN (40.0, 60.0] 220.333333 (60.0, 80.0] 271.428571 (80.0, 100.0] 321.800000 Name: 学習時間(分), dtype: float64
補足
pd.cut()
を使うことで、ビン分割ができることを確認しました。
なお今回の写経では、次のように一旦新しい列を追加してからグループ化を行いました。
# 新しい列として追加
df["点数の区分"] = pd.cut(df["点数"], [0, 20, 40, 60, 80, 100], include_lowest=True)
(...中略...)
# 列「点数の区分」でグループ化
grouped = df.groupby("点数の区分")
# 点数の区分ごとに、生徒のユニーク数を数える
nunique_result = grouped["生徒ID"].nunique()
これは、次のように書くことも可能です。新しい列を作らずに、groupby()
にpd.cut()
の結果を直接渡しても区分ごとにグループ化できます。
# ビン分割
result = pd.cut(df["点数"], [0, 20, 40, 60, 80, 100], include_lowest=True)
# 新しい列を作らずに、直接渡す
nunique_result = df.groupby(result)["生徒ID"].nunique()
補足: 区切り方を変えたい場合
「写経」では「20点より大きく40点以下」のように、「小さい方の境界値を含まず、大きい方の境界値を含む」ような区切り方をしました。
# 小さい方の境界値を含まず、大きい方の境界値を含む(左側が開いている)
20 < 点数 <= 40
これに対し、「20点以上40点未満」のような区切り方をしたい時もあります。
# 小さい方の境界値を含み、大きい方の境界値を含まない(右側が開いている)
20 <= 点数 < 40
pd.cut()
は、デフォルトでは前者の区切り方をします。後者の区切り方をしたい場合は、引数right
にFalse
を指定します。
# 小さい方の境界値を含み、大きい方の境界値を含まないように分割
result = pd.cut(df["点数"], [0, 20, 40, 60, 80, 100], right=False)
# 先頭5行を確認
result.head()
実行結果
0 [40, 60)
1 [0, 20)
2 [80, 100)
3 [0, 20)
4 [60, 80)
Name: 点数, dtype: category
Categories (5, interval[int64, left]): [[0, 20) < [20, 40) < [40, 60) < [60, 80) < [80, 100)]
1行目のデータ(48点)の結果を見てみましょう。デフォルトだと (40, 60]
(40点より高く60点以下)でしたが、引数right
でFalse
を指定すると[40, 60)
(40点以上60点未満)になることがわかります。
補足2
各ビンに含まれるデータ数が均等になるよう分割したい場合もあります。今回の写経で使ったデータで例を挙げると、次のような分割の仕方です。
- 列
点数
の中央値でデータを2分割 - 列
点数
の四分位数(25%点、50%点、75%点)でデータを4分割
このように、分割後のビンの数を指定してデータ数が均等になるよう分割する場合は、pd.qcut()
を使います。
# ビン数を指定して各ビンのデータ数が同じになるよう分割
pd.qcut(ビン分割するSeries, ビン数)
具体的に、写経のデータを使って見てみましょう。
写経で使った試験結果のデータでは、列点数
の25%点は50.5
、50%点は68.5
、75%点は80.25
です。
# 試験結果と学習時間のデータを読み込み
df = pd.read_csv("dataset/score_cut.csv")
# 列「点数」の基本統計量を確認
df["点数"].describe()
実行結果
count 20.000000
mean 63.200000
std 24.882037
min 0.000000
25% 50.500000
50% 68.500000
75% 80.250000
max 98.000000
Name: 点数, dtype: float64
pd.qcut()
で4
を指定して実行してみましょう。データ数が等しくなるよう4分割するということは、ちょうど四分位数が境界値になります。実行結果を見ると、先ほど確認した50.5
、68.5
、80.25
が境界値に使われていることがわかります。
# 各ビンのデータ数が均等になるよう4分割
# ※ 四分位数で分割するのと同じ結果になる
result = pd.qcut(df["点数"], 4)
result
実行結果
0 (-0.001, 50.5]
1 (-0.001, 50.5]
2 (68.5, 80.25]
3 (-0.001, 50.5]
4 (50.5, 68.5]
5 (50.5, 68.5]
6 (-0.001, 50.5]
7 (68.5, 80.25]
8 (68.5, 80.25]
9 (-0.001, 50.5]
10 (80.25, 98.0]
11 (80.25, 98.0]
12 (50.5, 68.5]
13 (80.25, 98.0]
14 (50.5, 68.5]
15 (68.5, 80.25]
16 (80.25, 98.0]
17 (50.5, 68.5]
18 (68.5, 80.25]
19 (80.25, 98.0]
Name: 点数, dtype: category
Categories (4, interval[float64]): [(-0.001, 50.5] < (50.5, 68.5] < (68.5, 80.25] < (80.25, 98.0]]
各ビンのデータ数を確認すると、どのビンもデータ数が5件となっており、均等に分割されていることがわかります。
# 各ビンのデータ数を確認
result.value_counts()
実行結果
(80.25, 98.0] 5
(68.5, 80.25] 5
(50.5, 68.5] 5
(-0.001, 50.5] 5
Name: 点数, dtype: int64
コメント