バスケやデータの雑記帳

バスケやデータ分析について徒然なるままに

FIBA Basketball World Cup 2023をデータ分析してみた(Part.5|因子分析+k-meansクラスタリング)

本記事は、全5回に分けてFIBAバスケットボールワールドカップ2023の全92試合を分析してみたシリーズ第5回目です。

  1. 相関分析・可視化分析|勝敗と相関が高いスタッツの項目は何か?勝利したチームと敗北したチームのスタッツの差はどう違うのか?
  2. 決定木分析|勝利するチームのスタッツの条件は?(その1)
  3. LightGBM+SHAP分析|勝利するチームのスタッツの条件は?(その2)
  4. 次元削減分析(主成分分析・t-SNE・UMAP)|スタッツから見る大会参加チームの特徴は?(その1)
  5. 因子分析|スタッツから見る大会参加チームの特徴は?(その2)

前回は、主成分分析・t-SNE・UMAP等の手法から次元削減を行い、各チームを2次元にプロットしてみました。

bballdatanote.hatenablog.com

今回は因子分析を行うことで、各チームの特徴をスコア化し、更にクラスタリングしてみることで、大会の参加チームの特徴がうまく分かれそうか?そして、日本はどのチームに似ていたのかを分析してみたいと思います。 前回の次元削減分析と因子分析はパッと見は似ていますが、ざっくりと以下のような違いがあります。雑ではありますが、計算の方向性が逆になっていると理解すると良いと思います。

  • 次元削減分析|スタッツをいくつかのスコアに束ねる手法
  • 因子分析|スタッツは、その背後にある因子が表面化したものであると捉え、スタッツから各因子を逆算する手法

※なお、クラスタリング自体は単体で行うことも、次元削減分析と組み合わせて行うこともできます。

目次は次の通りです。

データの準備・前処理

前回同様、大会公式サイトのデータから、因子分析用に前処理しておきます。

summary_team_df.head()
TEAM OREB DREB REB AST PF TO ST BLK FG_MADE ... DREB_OPPOSITE eFG% TO% FTR OREB% 2PTS% 3PTS% FT% RANK TEAM_LABEL
0 ドイツ -0.357255 0.654719 0.281435 0.767749 -0.728020 -1.242350 0.747780 -0.651354 1.302677 ... -1.282535 1.265120 -1.173782 -0.376622 0.353542 1.389546 0.742347 0.727326 1 1_ドイツ
1 セルビア -1.111176 0.213180 -0.522665 1.171533 -1.289234 -1.378872 1.539473 0.057603 1.384190 ... -1.779762 1.705373 -1.193995 0.922567 -0.148243 2.132540 0.676955 0.596071 2 2_セルビア
2 カナダ 0.739356 0.102796 0.535361 0.678020 -0.104448 -0.969306 0.351933 -0.415035 1.343433 ... -0.835031 1.201705 -1.224908 0.878006 1.096495 0.787233 1.174016 0.484411 3 3_カナダ
3 アメリカ合衆国 -0.288717 2.200104 1.508746 1.261262 -0.915091 0.395914 1.440512 2.420790 2.362344 ... -0.735585 1.481867 -0.190144 1.239070 0.116469 1.307604 1.113105 0.795034 4 4_アメリカ合衆国
4 ラトビア -1.590943 -0.228358 -1.157481 1.440722 0.207337 -1.651916 -0.736646 -0.060557 1.139651 ... -0.835031 1.693414 -1.281602 -1.751610 -1.183893 1.218891 1.526417 -0.100256 5 5_ラトビア

5 rows × 27 columns

因子分析

それでは、実際に因子分析を行っていきます。因子分析用のライブラリをインポートしておきます。

poetry add factor_analyzer

因子数の決定

まずは因子数を仮置きしつつ、スクリープロットを描いて固有値を確認します。ガットマン基準より、固有値が1以上である数を因子数とします。 なお、回転法はプロマックス回転、母数推定には最尤法を指定しておきます。

from factor_analyzer import FactorAnalyzer

feature_cols = [
    'OREB',
    'DREB',
    'AST',
    'PF',
    'TO',
    'ST',
    'BLK',
    '2PTS_ATTEMPT',
    '3PTS_ATTEMPT',
    'FT_ATTEMPT',
    'eFG%',
    'TO%',
    'FTR',
    'OREB%',
    '2PTS%',
    '3PTS%',
    'FT%',
]
N = 2 # 仮置き
X = summary_team_df[feature_cols]    
embedded_cols = [f"embedded_{i+1}" for i in range(N)]
    
# 因子分析の実行
fa = FactorAnalyzer(n_factors=N, rotation="promax", method="ml")
fa.fit(X)

# スクリープロットによる因子数の確認(ガットマン基準の場合、固有値1以上の因子を採用)
eigen_values = fa.get_eigenvalues()
eigen_x = list(range(1, len(eigen_values[0])+1))

print("スクリープロット")
plt.figure(figsize=(12, 8))
plt.plot(eigen_x, eigen_values[0], marker="o")
plt.hlines(y=1, xmin=0, xmax=20, linestyles="dashed")
plt.ylabel("固有値")
plt.title("スクリープロット")
plt.show()

因子数は6因子を想定することにします。

因子負荷量の確認

それでは、6因子を仮定して、因子分析を進めていきます。まずは因子負荷量を確認し、各因子がどのような因子なのかを解釈していきます。

N = 6
    
# 因子分析の実行
fa = FactorAnalyzer(n_factors=N, rotation="promax", method="ml")
fa.fit(X)

# 因子負荷量の確認
loading_df = pd.DataFrame(fa.loadings_, columns=embedded_cols, index=feature_cols)
plt.figure(figsize=(12, 8))
sns.heatmap(loading_df, cmap="coolwarm", annot=True, fmt=".2f")
plt.title("因子負荷量")
plt.show()

絶対値が比較的大きいスタッツを見比べてみると、各因子は次のような意味を持っているようです。

  • embedded_1|この因子が高いとTOが増える。TOのしやすさ
  • embedded_2|この因子が高いとDREB・BLK・2PTS%が高くなる。また、PFが少なくなる。インサイドの強さ(高さ?)
  • embedded_3|この因子が高いとFTが増える。FTの多さ
  • embedded_4|この因子が高いとAST・eFG%・3PTS%が高くなる。シュート効率の良さ
  • embedded_5|この因子が高いとOREBが増える。OREBのアグレッシブさ
  • embedded_6|この因子が高いと2PTS_ATTEMPTが増え、2PTS%が小さくなる。2PTS偏重

寄与率の確認

各因子の寄与率も見ておきます。

# 因子寄与・因子寄与率・累積寄与率の確認
variance_df = pd.DataFrame(fa.get_factor_variance(), index=["因子寄与", "因子寄与率", "累積寄与率"], columns=embedded_cols)
print("因子の寄与")
display(variance_df)
embedded_1 embedded_2 embedded_3 embedded_4 embedded_5 embedded_6
因子寄与 2.333270 2.329368 2.206963 2.155444 1.918768 1.009655
因子寄与率 0.137251 0.137022 0.129821 0.126791 0.112869 0.059391
累積寄与率 0.137251 0.274273 0.404094 0.530885 0.643754 0.703145

6因子で元のスタッツの分散の約7割を説明できているようです。

因子得点の確認

それでは、各チームの因子得点を取得します。この得点を元に、クラスタリングにかけていきます。

# 因子得点の確認
score_df = pd.DataFrame(fa.transform(X), columns=embedded_cols, index=label_txt)
print("因子得点")
display(score_df)
embedded_1 embedded_2 embedded_3 embedded_4 embedded_5 embedded_6
1_ドイツ -1.300328 1.179297 -0.244862 0.934883 0.062245 -0.555002
2_セルビア -1.372017 1.672746 0.926903 1.157072 -0.625907 -1.059549
3_カナダ -1.262478 0.699717 1.095309 1.279031 1.028844 0.151513
4_アメリカ合衆国 0.126095 1.976963 1.630710 1.190871 -0.092600 1.740469
5_ラトビア -1.518292 0.978369 -1.650575 1.576361 -1.171244 -1.346630
6_リトアニア -0.586678 0.313404 -0.603971 1.413674 0.967860 -0.782937
7_スロベニア -0.198929 0.079474 1.652097 0.567348 0.190069 -1.185422
8_イタリア -0.696013 -0.022033 -0.969312 -0.908474 0.382515 0.201561
9_スペイン -0.262429 0.187576 0.731657 0.665695 1.153473 -1.247843
10_オーストラリア -0.727880 1.143794 -0.344179 0.560399 0.050008 0.692050
11_モンテネグロ -0.058929 -0.084426 -0.560019 -1.193360 1.423290 0.881684
12_プエルトリコ 0.336869 -0.224440 0.255571 0.553201 0.560770 1.336374
13_ブラジル -0.672562 -0.034382 0.173693 -0.153772 0.611894 -0.775313
14_ドミニカ共和国 0.263081 -0.395316 1.087932 -0.281374 0.038062 0.730990
15_ギリシャ -2.009215 -0.669822 -0.929280 -0.335854 -0.418754 -0.328830
16_ジョージア 2.026440 -0.117894 0.085884 -0.946016 -0.714204 -0.143693
17_南スーダン -0.997016 -0.194253 0.721846 1.198105 -0.699037 -0.061825
18_フランス 1.409952 1.476136 0.072627 0.247088 0.818498 -1.355281
19_日本 0.440371 0.691697 0.025749 -0.502517 -1.569685 0.496960
20_エジプト 1.270005 1.105493 0.038276 -0.730704 0.257615 1.372383
21_フィンランド 0.877145 0.869527 -1.176184 0.181314 0.793089 -0.368637
22_ニュージーランド 1.515544 -1.078821 1.044124 0.679003 0.057723 0.652533
23_レバノン 1.011710 -0.440044 -0.682162 0.759718 -0.776703 -1.314634
24_フィリピン 0.716270 0.270888 -0.226830 -0.901639 0.086573 1.068112
25_メキシコ 0.348967 -0.738434 -0.253861 0.388717 -1.425465 0.286682
26_アンゴラ -0.927790 -1.298743 1.633542 -2.649551 2.652439 1.937343
27_コートジボワール -0.396090 -2.066069 -0.838843 -0.199135 -1.093058 -0.474282
28_カーボベルデ 0.556580 -0.601142 -0.107800 -1.750730 1.098031 1.293462
29_中国 0.407826 -0.535705 0.668903 -0.338006 -2.390146 -1.115700
30_ベネズエラ 0.112124 -0.796972 -2.586102 -0.156477 -0.036876 -0.534536
31_イラン -0.195358 -2.133336 -1.545377 -1.431229 -0.239887 -0.737360
32_ヨルダン 1.763028 -1.213248 0.874536 -0.873640 -0.979431 0.545358

クラスタリング

それではクラスタリングを行っていきます。今回はk-means++という手法を用いていきます。まず最初に、クラスタ数を決める必要があるため、エルボー法とシルエット法の2つの手法から、適切なクラスタ数を判別していきます。

エルボー法の場合は折れ線グラフがガクッと落ちこむあたりをクラスタ数として設定します。また、シルエット法の場合は、各クラスタのシルエットが綺麗に分かれて描画されたクラスタ数を採用します。

# クラスタリング用ライブラリ
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_samples

# シルエット法の描画用関数
def show_silhouette(X, fitted_model):
    cluster_labels = np.unique(fitted_model.labels_)
    num_clusters = cluster_labels.shape[0]
    silhouette_vals = silhouette_samples(X, fitted_model.labels_)  # シルエット係数の計算
    # 可視化
    y_ax_lower, y_ax_upper = 0, 0
    y_ticks = []

    for idx, cls in enumerate(cluster_labels):
        cls_silhouette_vals = silhouette_vals[fitted_model.labels_==cls]
        cls_silhouette_vals.sort()
        y_ax_upper += len(cls_silhouette_vals)
        cmap = cm.get_cmap("Spectral")
        rgba = list(cmap(idx/num_clusters))  # rgbaの配列
        rgba[-1] = 0.7  # alpha値を0.7にする
        plt.barh(
            y=range(y_ax_lower, y_ax_upper), 
            width=cls_silhouette_vals,
            height=1.0,
            edgecolor='none',
            color=rgba)
        y_ticks.append((y_ax_lower + y_ax_upper) / 2.0)
        y_ax_lower += len(cls_silhouette_vals)

    silhouette_avg = np.mean(silhouette_vals)
    plt.axvline(silhouette_avg, color='orangered', linestyle='--')
    plt.xlabel('silhouette coefficient')
    plt.ylabel('cluster')
    plt.yticks(y_ticks, cluster_labels + 1)
    plt.show()

# 2~10のクラスタ数でクラスタリング
sse_list = [] # エルボー法用
min_n = 2
max_n = 10

for i in range(min_n, max_n):
    model = KMeans(n_clusters=i, random_state=0, init="k-means++")
    model.fit(score_df)

    sse_list.append(model.inertia_)
    show_silhouette(score_df.to_numpy(), model) # シルエット法
    
plt.plot(range(min_n, max_n), sse_list, marker="o")
plt.xlabel("number of cluseters")
plt.ylabel("sum of squared errors")
plt.title("エルボー法")
plt.show()

まず、エルボー法から見てみるとなんとも言えない結果となりました。

次に、シルエット法です。一部結果を抜粋しています。こちらもなんとも言えない結果ではありますが、クラスタ数3が一番きれいにシルエットが出ているため、今回はクラスタ数3を採用することにします。

さて、それでは実際にクラスタリングを行い、結果を解釈していきます。

# クラスタ数3に設定
model = KMeans(n_clusters=3, random_state=0, init="k-means++")
model.fit(score_df)
score_df["cluster"] = model.predict(score_df)
score_df["cluster_str"] = score_df["cluster"].astype("str") # 箱ひげ図のプロット用にクラスタの値を文字列化

# クラスタごとの因子得点の分布を確認
n_rows = 2
n_cols = 3

fig, axes = plt.subplots(nrows=n_rows, ncols=n_cols, figsize=(10, 10), tight_layout=True)
for idx, col in enumerate(embedded_cols):
    i = idx // n_cols
    j = idx % n_cols
    sns.boxplot(x=col, y="cluster_str", data=score_df, ax=axes[i, j])
    axes[i, j].set_title(f"{col} x クラスタ")
plt.show()
score_df = score_df.drop("cluster_str", axis=1) # 以後不要なため、削除

ここで、先程解釈した各因子の情報を再掲しておきます。

  • embedded_1|この因子が高いとTOが増える。TOのしやすさ
  • embedded_2|この因子が高いとDREB・BLK・2PTS%が高くなる。また、PFが少なくなる。インサイドの強さ(高さ?)
  • embedded_3|この因子が高いとFTが増える。FTの多さ
  • embedded_4|この因子が高いとAST・eFG%・3PTS%が高くなる。シュート効率の良さ
  • embedded_5|この因子が高いとOREBが増える。OREBのアグレッシブさ
  • embedded_6|この因子が高いと2PTS_ATTEMPTが増え、2PTS%が小さくなる。2PTS偏重

こうして各クラスタごとに因子の違いを見てみると、

  • クラスタ0|embedded_1(TOの多さ)とembedded_6(2PTS偏重)が高く、embedded_4(シュート効率の良さ)が低いです。ミスが多く、2PTS偏重でシュートの効率が悪いということで、うまく得点ができずに苦しんだチーム(≒下位チーム)が含まれているであろうと解釈できそうです。
  • クラスタ1|基本的にはどの因子でも平均的な値を残していそうです。強いて言うなら、embedded_2(インサイドの強さ?)とembedded_3(FTの多さ)がやや低いので、インサイドで苦労し、FTでの得点が少なかったチームが多そうです。
  • クラスタ2|クラスタ2はembedded_4(シュート効率の良さ)が高く、しっかりと得点を稼いだ上位チームが入っていそうです。

それでは、上記の情報を踏まえ、具体的にどのチームがどのクラスタだったかを因子得点と共に見ていきます。

# クラスタ数と因子得点
score_df = score_df.sort_values(by="cluster")
plt.figure(figsize=(12, 8))
sns.heatmap(score_df, cmap="coolwarm", annot=True, fmt=".2f")
plt.title("クラスタ別因子得点")
plt.show()

事前の解釈通り、クラスタ0は下位リーグに回ったチームが多く、クラスタ2が上位チーム、クラスタ1がその中間という結果になりました。

今回の分析結果としては、日本はクラスタ0に分類されていますが、ヒートマップの色を見てもクラスタ0の中では少し特殊な因子得点となっている(embedded_2が高く、embedded_5が小さい等)ため、クラスタ0ど真ん中という感じではなさそうです。

まとめ

因子分析からクラスタリングまでの一連の流れを通して、参加チームの特徴を分析しました。やはり、一貫してミスが少なく、効率の良いシュートを打てているチームが上位チームに入っているということが、改めて今回の分析からもあらわになりました。

今回でFIBA Basketball World Cup 2023の分析は一段落しました。今後は、他のアドバンスドスタッツも分析対象に含める、他の大会・リーグでも分析してみるなど気が向いたら色々とやっていきたいと思います。

参考にさせていただいた記事