よちよちpython

独習 python/Qpython/Pydroid3/termux/Linux

【Scikit-learn】k-平均法(k-means)を使って成績表からおまかせクラス編成する

k-means法(k-平均法)による、お任せクラス編成

前回の投稿では、Pandasで学校のテストの成績表のようなものを適当に作り、その合計点を算出して順位付けを行いました。
また、Pandasのグラフ作成機能を使って、積み上げ棒グラフを作成しました。

その合計100人の成績表をもとに、25人ずつ4クラスに分けたい。
その際、なるべく成績を平均化したい。成績表の合計点を使って平均的に分けたとして、ひょっとすると数学・理科が高い人が片寄ったりするかもしれない。教科ごとでも適度に分散し、合計でも平均的にしたい。

と、その前に、
今回は趣旨を変えて、機械学習の勉強がてら k-means法(k-平均法) による「お任せクラス編成」をやってみたいと思います。
気分屋クラスタです。



【実行環境】

  • Windows10
  • WSL:Ubuntu:Anaconda
  • Python3.8
  • Jupyter Notebook
  • 使用ライブラリ
    • numpy、pandas、matplotlib、scikit-learn、japanize-matplotlib(グラフの日本語表示用)



Anacondaをインストールすると今回使うライブラリは最初から全部入っているようなので(japanize-matplotlib以外は)インストールは不要です。
入ってないようならcondapip

$ pip install notebook numpy pandas matplotlib sklearn japanize-matplotlib

など環境に合わせてインストールしておきます。

  • ヴァージョン確認
# 使用ライブラリのインポート(確認用)
import numpy as np
import pandas as pd
import matplotlib
import sklearn

print('・Anacondaのヴァージョン')
!conda -V
print('・Pythonのヴァージョン')
!python -V
print('・Jupyter Notebookのヴァージョン')
!jupyter-notebook -V
print('・numpyのヴァージョン')
print(np.__version__)
print('・pandasのヴァージョン')
print(pd.__version__)
print('・matplotlibのヴァージョン')
print(matplotlib.__version__)
print('・scikit-learnのヴァージョン')
print(sklearn.__version__)
・Anacondaのヴァージョン
conda 4.10.1
・Pythonのヴァージョン
Python 3.8.10
・Jupyter Notebookのヴァージョン
6.4.0
・numpyのヴァージョン
1.20.2
・pandasのヴァージョン
1.2.4
・matplotlibのヴァージョン
3.3.4
・scikit-learnのヴァージョン
0.24.2



成績表をランダム生成する

前回と同様に、国・数・社・理・英、各100点満点のテストの成績表を100人分、一様分布のランダムな値で生成します。
5教科の合計点を個人ごとに算出し、その値の大きい順から順位付けを行います。

python日本語の文字を変数として使えるので、横着して・・・

import numpy as np
import pandas as pd
np.random.seed(0) # ランダムの固定

# 教科ごとのランダムな配列を生成
国 = np.random.randint(0,101,100)
数 = np.random.randint(0,101,100)
社 = np.random.randint(0,101,100)
理 = np.random.randint(0,101,100)
英 = np.random.randint(0,101,100)

# 成績表データフレームの作成
df = pd.DataFrame({
    "国語" : 国,
    "数学" : 数,
    "社会" : 社,
    "理科" : 理,
    "英語" : 英
})

# 5教科合計列の追加
df['合計'] = df.sum(axis=1)

# 合計点順位列の追加 (降順で順位付け、同一順位は最小値を取る、順位を整数値に変換)
df['順位'] = df['合計'].rank(method='min', ascending=False).astype('int')

# 表示
df
国語 数学 社会 理科 英語 合計 順位
0 44 48 70 18 51 231 64
1 47 49 85 17 30 228 66
2 64 69 31 93 53 310 17
3 67 41 13 84 58 263 43
4 67 35 71 2 43 218 72
... ... ... ... ... ... ... ...
95 58 61 67 91 98 375 2
96 23 83 35 51 97 289 24
97 79 33 30 99 43 284 29
98 13 32 29 18 3 95 98
99 85 100 33 34 12 264 39

100 rows × 7 columns

100人分の成績表ができました。



機械におまかせ、K-平均法(means)でのクラスター分析

k-means法(k-平均法)は、全体のデータの中から似たり寄ったりの特徴のデータを持つメンバーで構成されたグループを指定個数で分類するデータ分析・機械学習アルゴリズムで、「教師ラベル」を使わず分類モデルを作るので教師なし学習のクラスタリング手法の一つ。
まず指定したk個のクラスタにランダムでわけ、重心点をランダムに決定した後クラスタの平均を用いて分類されるように調整されるのでk-平均法と呼ばれるそうです。なんのこっちゃです。
グループ分けされた後で、どういう分類がされたかを検証しなくてはなりません。
最初にランダムなクラス分けが為されるがそのランダムさによってクラス分けの結果に影響が出るとか、クラスターの最適な個数に気を付ける等々、いくつか注意点があるようですが、とりあえずsklearnでk-meansのクラスター分類やってみます。

上で作ったデータフレームをk-means法で4つのクラスタに分類します。

# ライブラリのインポート (k-平均法によるクラスター分析)
from sklearn.cluster import KMeans

# データを4クラスに分割、乱数固定
model_kmeans = KMeans(n_clusters=4, random_state=0)
# 学習させる フィッティング
model_kmeans.fit(df.values)
KMeans(n_clusters=4, random_state=0)
# 学習済みデータの重心点
model_kmeans.cluster_centers_
array([[ 31.64705882,  32.58823529,  27.23529412,  22.70588235,
         27.29411765, 141.47058824,  92.        ],
       [ 67.75      ,  56.        ,  44.57142857,  80.35714286,
         66.42857143, 315.10714286,  16.42857143],
       [ 64.92857143,  55.32142857,  62.03571429,  26.32142857,
         51.96428571, 260.57142857,  42.92857143],
       [ 23.03703704,  45.51851852,  43.2962963 ,  70.33333333,
         42.7037037 , 224.88888889,  66.51851852]])
# 重心点の形状
model_kmeans.cluster_centers_.shape
(4, 7)
# 出来たモデルで分割予測(クラスタリング)
cluster  = model_kmeans.predict(df.values)
cluster
array([2, 2, 1, 1, 2, 3, 2, 2, 1, 1, 1, 2, 2, 3, 2, 2, 0, 1, 1, 2, 1, 3,
       3, 2, 2, 3, 3, 0, 1, 1, 3, 1, 1, 2, 1, 2, 0, 1, 3, 0, 2, 1, 0, 3,
       3, 0, 0, 1, 3, 1, 1, 2, 0, 3, 3, 3, 0, 3, 3, 3, 3, 1, 3, 0, 0, 0,
       3, 2, 3, 2, 2, 0, 1, 2, 1, 3, 0, 1, 3, 0, 2, 2, 2, 1, 3, 3, 2, 0,
       2, 1, 2, 2, 3, 3, 1, 1, 1, 1, 0, 2], dtype=int32)

おまかせ分類して頂きました。100人が指定通り4つのグループに分類され、0~3の値にラベル付けされています。リストの個数は100個あるかと思います。

# クラスタリングのラベル個数
len(cluster)
100



では、この配列をデータフレームに「クラス番号」として列を追加します。

# データフレームをコピー
df_class = df.copy()
# 列追加
df_class['クラス番号'] = cluster
# 表示
df_class
国語 数学 社会 理科 英語 合計 順位 クラス番号
0 44 48 70 18 51 231 64 2
1 47 49 85 17 30 228 66 2
2 64 69 31 93 53 310 17 1
3 67 41 13 84 58 263 43 1
4 67 35 71 2 43 218 72 2
... ... ... ... ... ... ... ... ...
95 58 61 67 91 98 375 2 1
96 23 83 35 51 97 289 24 1
97 79 33 30 99 43 284 29 1
98 13 32 29 18 3 95 98 0
99 85 100 33 34 12 264 39 2

100 rows × 8 columns



クラスター分析結果の検証

さて、どういう観点でクラス分けされたんでしょうか? テストの点数の良しあしで4段階に分類されてるだろうとは思いますが。

クラスの振り分けの中身

groupby()で各クラスを見てみます。

# 各クラス番号の中身
print(df_class.groupby('クラス番号').groups)
{0: [16, 27, 36, 39, 42, 45, 46, 52, 56, 63, 64, 65, 71, 76, 79, 87, 98], 1: [2, 3, 8, 9, 10, 17, 18, 20, 28, 29, 31, 32, 34, 37, 41, 47, 49, 50, 61, 72, 74, 77, 83, 89, 94, 95, 96, 97], 2: [0, 1, 4, 6, 7, 11, 12, 14, 15, 19, 23, 24, 33, 35, 40, 51, 67, 69, 70, 73, 80, 81, 82, 86, 88, 90, 91, 99], 3: [5, 13, 21, 22, 25, 26, 30, 38, 43, 44, 48, 53, 54, 55, 57, 58, 59, 60, 62, 66, 68, 75, 78, 84, 85, 92, 93]}

学籍番号(インデックス番号)のリストがクラスごとに分けられた辞書型データっぽいものが返ってきました。

グループごとの平均値をgroupby()で算出

groupby()メソッドを使って「クラス番号」のカテゴリーで平均値を算出してみます。

# クラス番号の平均値を算出
df_class.groupby('クラス番号').mean()
国語 数学 社会 理科 英語 合計 順位
クラス番号
0 31.647059 32.588235 27.235294 22.705882 27.294118 141.470588 92.000000
1 67.750000 56.000000 44.571429 80.357143 66.428571 315.107143 16.428571
2 64.928571 55.321429 62.035714 26.321429 51.964286 260.571429 42.928571
3 23.037037 45.518519 43.296296 70.333333 42.703704 224.888889 66.518519

合計点平均でクラス分けされているっぽい。
成績の合計点平均が高い順に、クラス番号が「1」⇒「2」⇒「3」⇒「0」と分かれている。
しかし、教科によっては若干偏りがあるようです。
パッと見、

  • 理科以外の教科は「1,2,3,0」の順番で平均点が高い。
  • 「0組」の各教科ごとの平均点が赤点ですね。5教科を捨てた芸術家か体育系グループか何かかな。
  • 「2組」の合計点平均は2番目に高いが、理科の平均点は最も低い。
  • 「3組」は理科の点数だけ高い。



もう一度実行すれば別のクラス編成になるんでしょうが、後ほど実行してみます。



各クラスを個別に抽出する

個別のデータフレームを取得したい場合はPandasのメソッドのdf.groupby('カテゴリ列').get_group('カテゴリ名') のようにやると抽出できる。

# 0組だけ抽出する
df_class.groupby('クラス番号').get_group(0)
国語 数学 社会 理科 英語 合計 順位 クラス番号
16 39 98 1 17 26 181 85 0
27 80 2 61 21 13 177 86 0
36 29 20 37 0 81 167 88 0
39 14 27 27 16 89 173 87 0
42 65 58 23 24 16 186 84 0
45 32 10 46 51 24 163 90 0
46 31 86 20 8 4 149 92 0
52 28 32 9 19 25 113 97 0
56 36 19 3 13 23 94 99 0
63 42 24 10 36 7 119 96 0
64 58 2 27 25 9 121 95 0
65 31 3 45 32 54 165 89 0
71 11 19 44 82 3 159 91 0
76 14 52 5 7 68 146 93 0
79 12 14 75 13 10 124 94 0
87 3 56 1 4 9 73 100 0
98 13 32 29 18 3 95 98 0

この組は合計点が低い人が集まっていますね。順位もみな80位以下です。
他のクラスのメンバーの順位を見てみます。

# 4クラスごとのメンバーの順位をリストで抽出
for i in range(4):
    print(f'{i}組のメンバーの順位', df_class.groupby('クラス番号').get_group(i)['順位'].tolist())
0組のメンバーの順位 [85, 86, 88, 87, 84, 90, 92, 97, 99, 96, 95, 89, 91, 93, 94, 100, 98]
1組のメンバーの順位 [17, 43, 9, 44, 15, 7, 22, 5, 13, 1, 3, 12, 9, 26, 13, 8, 29, 31, 16, 26, 9, 4, 19, 6, 18, 2, 24, 29]
2組のメンバーの順位 [64, 66, 72, 62, 46, 36, 25, 36, 34, 39, 57, 26, 19, 38, 59, 45, 21, 23, 55, 32, 34, 50, 53, 39, 33, 49, 50, 39]
3組のメンバーの順位 [47, 58, 83, 76, 81, 71, 56, 72, 53, 61, 80, 47, 77, 79, 65, 52, 62, 82, 70, 78, 60, 72, 69, 39, 67, 67, 72]

合計点の平均値が高い順で「1」⇒「2」⇒「3」⇒「0」となっていましたが、メンバーの順位も同じようなカタマリになってるようです。
同一順位がいるので1組に9位が複数いたり、2組や3組に39位が何人もいたりします。
ついでに同一順位の人数を見ておこ。

# 成績表
df_class.head()
国語 数学 社会 理科 英語 合計 順位 クラス番号
0 44 48 70 18 51 231 64 2
1 47 49 85 17 30 228 66 2
2 64 69 31 93 53 310 17 1
3 67 41 13 84 58 263 43 1
4 67 35 71 2 43 218 72 2
# 順位列の要素ごとをカウント
df_class.value_counts('順位')
順位
72     4
39     4
26     3
9      3
13     2
      ..
2      1
57     1
58     1
59     1
100    1
Length: 80, dtype: int64
# 同一順位のかぶり最大個数
df_class.value_counts('順位').max()
4



各グループの形状

クラスタリングされた各クラスタのメンバー数は均一ではありません。
形状を見てみましょう。shapeで取り出します。

# 各クラスのデータの形状
for i in range(4):
    print(df_class.groupby('クラス番号').get_group(i).shape)
(17, 8)
(28, 8)
(28, 8)
(27, 8)

「0組」は人数が少ない。



じゃあ、もう一度クラス編成をやってもらいます。どんな分類になるかな?

##### k-平均法によるクラスター分析 2回目 #####

# データを4クラスに分割、乱数固定
model_km2 = KMeans(n_clusters=4, random_state=1)
# 学習させる フィッティング
model_km2.fit(df.values)

# 出来たモデルで分割予測(クラスタリング)
cluster2  = model_km2.predict(df.values)

# データフレームをコピー
df_class2 = df.copy()
# 列追加
df_class2['クラス番号'] = cluster2
# 表示
df_class2
国語 数学 社会 理科 英語 合計 順位 クラス番号
0 44 48 70 18 51 231 64 0
1 47 49 85 17 30 228 66 0
2 64 69 31 93 53 310 17 3
3 67 41 13 84 58 263 43 3
4 67 35 71 2 43 218 72 0
... ... ... ... ... ... ... ... ...
95 58 61 67 91 98 375 2 3
96 23 83 35 51 97 289 24 3
97 79 33 30 99 43 284 29 3
98 13 32 29 18 3 95 98 2
99 85 100 33 34 12 264 39 0

100 rows × 8 columns

# 各クラス番号の中身
print(df_class2.groupby('クラス番号').groups)

print('='*20)

# 各クラスのデータの形状
for i in range(4):
    print(df_class2.groupby('クラス番号').get_group(i).shape)

print('='*20)

# クラス番号の平均値を算出
df_class2.groupby('クラス番号').mean()
{0: [0, 1, 4, 6, 7, 11, 12, 14, 15, 19, 23, 24, 33, 35, 40, 51, 67, 69, 70, 73, 80, 81, 82, 86, 88, 90, 91, 99], 1: [5, 13, 21, 22, 25, 26, 30, 38, 43, 44, 48, 53, 54, 55, 57, 58, 59, 60, 62, 66, 68, 75, 78, 84, 85, 92, 93], 2: [16, 27, 36, 39, 42, 45, 46, 52, 56, 63, 64, 65, 71, 76, 79, 87, 98], 3: [2, 3, 8, 9, 10, 17, 18, 20, 28, 29, 31, 32, 34, 37, 41, 47, 49, 50, 61, 72, 74, 77, 83, 89, 94, 95, 96, 97]}
====================
(28, 8)
(27, 8)
(17, 8)
(28, 8)
====================
国語 数学 社会 理科 英語 合計 順位
クラス番号
0 64.928571 55.321429 62.035714 26.321429 51.964286 260.571429 42.928571
1 23.037037 45.518519 43.296296 70.333333 42.703704 224.888889 66.518519
2 31.647059 32.588235 27.235294 22.705882 27.294118 141.470588 92.000000
3 67.750000 56.000000 44.571429 80.357143 66.428571 315.107143 16.428571

クラス番号の順番が入れ替わっただけで、中身自体は変わってないっぽい。
合計平均点の高い順に、クラス番号は

「1」⇒「2」⇒「3」⇒「0」
から
「3」⇒「0」⇒「1」⇒「2」

になった。クラスのメンバーはどうだろう?

# クラスのメンバー内容確認
for i, j in [(1, 3), (2, 0), (3, 1), (0, 2)]:
    print(df_class.groupby('クラス番号').groups[i] == df_class2.groupby('クラス番号').groups[j])
[ True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True]
[ True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True]
[ True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True]
[ True  True  True  True  True  True  True  True  True  True  True  True
  True  True  True  True  True]

クラスメンバーの中身は一緒のようです。

k-平均法の引数は色々あって、その値によってクラス編成がもっと変わるかもしれません。今回はそこまでやりません。

集計データで積み上げ棒グラフを作成する

前回と同様に、Pandasのグラフ作成機能を利用して積み上げ棒グラフを描きます。df.plot.bar(stacked=True)で簡単にイケます。
データは、Pandasのgroupby().mean()メソッドでクラスごとの平均値を集計したデータフレームを用います。
それぞれ教科ごと平均値で色分けされ積み上げられた、4クラス分の棒があるグラフが出来る予定です。

# 棒グラフに使用する集計データ
data = df_class.groupby('クラス番号').mean()
# 教科の列に絞る
data = data.iloc[:, :5]
data
国語 数学 社会 理科 英語
クラス番号
0 31.647059 32.588235 27.235294 22.705882 27.294118
1 67.750000 56.000000 44.571429 80.357143 66.428571
2 64.928571 55.321429 62.035714 26.321429 51.964286
3 23.037037 45.518519 43.296296 70.333333 42.703704
##### 積み上げ棒グラフを作成 #####
import matplotlib.pyplot as plt
import japanize_matplotlib   #(グラフの日本語表示用 個人的事情でインポートしています)

# グラフのサイズと背景色(白)設定 (jupyterの背景色を黒にしている個人的事情により)
fig, ax = plt.subplots(figsize=(12,8), facecolor='w')

# 積み上げ棒グラフ
data.plot.bar(y=data.columns, ax=ax, alpha=1.0, stacked=True)
# グラフのタイトル
plt.title('クラスごと積み上げグラフ(5教科各平均点)')
# ラベルの設置
plt.xlabel('クラス名')
plt.ylabel('平均点別合計点')
# 目盛りの回転 (デフォルトは90度反時計回り)
plt.xticks(rotation=0)
# グリッド線の設置
plt.grid()
# グラフ画像の保存
plt.savefig('クラス毎積み上げ平均点.jpg')
# グラフ表示
plt.show()

f:id:chayarokurokuro:20210702063700j:plain

各クラスごとの各教科平均点を各クラスごとに積み上げています。

おわりに

さらっとk-平均法のクラスタリングに触れましたが面白い。引数もいろいろあるので気が向いたらもう少し掘り下げる気分屋クラスタ
クラスター分析では各クラスターがどういった分布になっているかを図示するために主成分分析や次元削減と言われる手法を同時に用いるようです。厳密な評価基準に基づいて分類してはいないので、大雑把にタイプを振り分けて分類したい時に使うのでしょう。分類後の検証も必要なので分析者の主観や観点の依存度が高そうな感じ。客観的な何かが出てくる魔法の玉手箱ではないね。データ分析や機械学習全般に言えることだろうけど。

今回は以上です。



【Numpy・Pandas・Scikit-learn】成績表のDataFrameを行でシャッフルし、クラス分けする - よちよちpythonにつづく。