pd.cut() 値で区切る方法

連続する値を任意の境界値で分けることをビン分割ビニングといいます。このようなケースでは、pd.cut()関数が便利です。

pd.cut()関数は、次のように使います。

# 指定したSeriesをビン分割する
pd.cut(ビン分割するSeries, 境界値のリスト)

具体的な例を見てみましょう。次のような試験結果のデータについて考えます。


生徒ID
クラス点数
ST0011-A60
ST0021-A87
ST0031-B66
ST0041-A72
ST0051-B74
ST0061-B67

このデータを、列点数の値に応じて次のように分けることにします。

  • 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_lowestTrueを指定します。

引数include_lowestTrueを指定すると、最初の境界値を含むように調整してビン分割されます。

# 最初の境界値を含むようにビン分割
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点数学習時間(分)
0ST00148226
1ST002024
2ST00380271
3ST0041245
4ST00568271

(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_lowestTrueを指定すると、 最初の区分で境界値(ここでは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点数学習時間(分)点数の区分
0ST00148226(40.0, 60.0]
1ST002024(-0.001, 20.0]
2ST00380271(60.0, 80.0]
3ST0041245(-0.001, 20.0]
4ST00568271(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()は、デフォルトでは前者の区切り方をします。後者の区切り方をしたい場合は、引数rightFalseを指定します。

# 小さい方の境界値を含み、大きい方の境界値を含まないように分割
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点以下)でしたが、引数rightFalseを指定すると[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.568.580.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

コメント

タイトルとURLをコピーしました