NBA 24-25シーズンの2024年中におけるグリズリーズの各選手の得点力をベイズモデリングしてみる(河村くんフォーカスしたい)
はじめに
今シーズンのNBAはみなさん見ていますか。
僕はというとバスケ歴はそこそこ長いにも関わらず今まであんまりリアルタイムで試合を見てこなかったのですが、河村勇輝くんがグリズリーズとの2wayを獲得しシーズン帯同しているのを見て隙あらばNBAを見ている次第です。
Rakuten Mobile会員なのでNBA Rakutenで無料で見れることに大感謝です。
ところで、普段からデータ分析をしている身として、そしてバスケ歴が20年弱というのもありバスケのプレイに対してそこそこ詳しい身として、昨今のスポーツは本当にデータ分析とともに進化して来ているなと感じますし、バスケも漏れなくその潮流に乗っているスポーツだと思います。
NBAバスケにおけるスタッツの評価としての項目は280項目あるとされており、かなりスポーツアナリティクスが進んでいるなと感じます。
一方で日本は28項目ということで10分の1の評価項目数です。
多いからといって必ずしも良いというわけではないのですが、判断軸が増やせるのは悪くはないことです。
基本的なバスケのスタッツは平均得点や出場時間あたりの成績など、実際に得られた数字から単純集計や計算をしている場合がほとんどです。
勝ち負けのあるスポーツですし、また選手それぞれのプレイの質なども関わるスポーツなので、選手によっては出場時間も異なればポジションによってもその評価項目の良し悪しも変わってきます。
また出場回数も選手によってばらつきがあり、少ないサンプルサイズで選手を評価することはバイアスを生む場合もあります。
今回着目したいポイントはサンプルサイズが少なくても推定ができるベイズを用いて、よりシンプルな評価項目で選手を評価して、最終的に河村くんが他の選手と比べてどのようなモデリングになるのか、というのを見ていきたいと思います。
ベイズモデリングをするにあたって
今回何を推定するかですが、シンプルにまず「攻撃力」にしたいと思います。
言い換えると「どれだけ得点できるか」「効率的に点を稼げるか」「試合ごとにばらつきがあるか」「パフォーマンスとして攻撃力に定評が持てるか」などです。
時間ごとに得点が得られるようなデータになるので、ポアソン分布に従うと仮定します。
例えば、選手 (
「試合での得点 (
と仮定します。
-
ポアソン分布のイメージ:
y_{i,g} \sim \mathrm{Poisson}(\lambda_i) ただし
=λ_i (f )θ_i
( からスケール変換などして推定してもよい)θ_i -
正規分布のイメージ:
y_{i,g} \sim \mathcal{N}(\mu_i, \sigma^2) ただし
=μ_i としてシンプルに置くθ_i
ここで、
事前分布を設定します(ベイズ的にはこれが “事前分布”)。
すべての選手に同じ事前分布を課すことで、初期状態としては
「選手はみんな同程度の実力かもしれないが、多少のバラつきがあるはず」
という仮定を置けます。(前シーズンからのデータがあるのでそんなことはないんですが、初期状態として)
具体的な考えとStep
- 事前分布の設定
(平均 0, 分散
- 尤度関数
ポアソン分布を使う場合:
ただし
- 事後分布
(ここでは各試合
- 推定
基本的にMCMC (Markov Chain Monte Carlo) で推定します
データ量としてもそこまで推定に時間もかからないし、パラメータも今回はシンプルなのでMCMCが良き。
- 結果の解釈
- 得られた
の分布(事後分布)から「平均値」「95%信頼区間」などを見る\theta_i -
が実際の「期待得点(平均得点)」になる場合が多いので、そこを比較\exp(\theta_i) - 選手Aのほうが期待得点が高そうだが、サンプル数が少なく信頼区間が広い…
などといった分析を行う
実際にデータを取得する
しかし今回は河村くんが出ている24-25の最新の試合のデータが欲しいので、こちらから取得しています。
以下は参考までに
参考コード
import requests
from bs4 import BeautifulSoup
import pandas as pd
base_url = "https://www.basketball-reference.com/players/"
# スクレイピング対象のプレイヤーIDリスト
players = [
"a/aldamsa01", # Santi Aldama
"p/pippesc02", # Scotty Pippen Jr.
"l/laravja01", # Jake LaRavia
"c/clarkbr01", # Brandon Clarke
"j/jacksja02", # Jaren Jackson Jr.
"h/huffja01", # Jay Huff
"b/banede01", # Desmond Bane
"m/moranja01", # Ja Morant
"e/edeyza01", # Zach Edey
"k/kennalu01", # Luke Kennard
"s/smartma01", # Marcus Smart
"k/konchjo01", # John Konchar
"w/willivi01", # Vince Williams Jr.
"j/jacksgg01", # GG Jackson II
"k/kawamyu01", # Yuki Kawamura
"w/wellsja01", # Jaylen Wells
"c/castlco01", # Colin Castleton
"s/spencca01", # Cam Spencer
]
# 各試合の列名(シーズン進行で増減する可能性があるので必要に応じ調整)
columns = [
"Rk", "G", "Date", "Age", "Tm", "Opp", "Result",
"GS", "MP", "FG", "FGA", "FG%", "3P", "3PA", "3P%",
"FT", "FTA", "FT%", "ORB", "DRB", "TRB", "AST",
"STL", "BLK", "TOV", "PF", "PTS", "GmSc", "+/-"
]
# すべての選手のデータをまとめるリスト
all_data = []
# リクエスト時のUser-Agent(付与しないとブロックされることがある)
headers = {
"User-Agent": "Mozilla/5.0"
}
# 全選手をループしてスクレイピング
for pid in players:
# 例: "https://www.basketball-reference.com/players/a/aldamsa01/gamelog/2025/"
url = f"{base_url}{pid}/gamelog/2025/"
print(f"Scraping {url} ...")
# リクエストを投げてHTMLをパース
r = requests.get(url, headers=headers)
soup = BeautifulSoup(r.text, "html.parser")
# Game Logテーブルを取得 (id="pgl_basic")
table = soup.find("table", id="pgl_basic")
if not table:
print(f" -> {pid} のテーブルが見つかりません。Inactiveか未登録の可能性があります。")
continue
# <tbody> 内の各試合行を取得
tbody = table.find("tbody")
rows = tbody.find_all("tr")
for row in rows:
# 実際の試合データは trタグに id属性がある
if row.get("id"):
tds = row.find_all("td")
# Inactive / DNP などで列数が足りない可能性もある
if tds:
# テキストを取り出す
stats = [td.get_text(strip=True) for td in tds]
# 列数が columns より少なければ埋める
if len(stats) < len(columns):
stats += [""] * (len(columns) - len(stats))
# "PlayerID"列として pid を先頭に追加
all_data.append([pid] + stats)
# 最終的な列名: [PlayerID] + 各試合の列
final_columns = ["PlayerID"] + columns
df_MEM = pd.DataFrame(all_data, columns=final_columns)
df_MEM
エラー429を出さないために工夫することもできるので、利用するときはそれを追加するのがいいでしょう。以下のように書き換えもできます。
改良参考コード
def fetch_with_retry(url, headers=None, max_retries=5):
"""
429 (Too Many Requests) を受け取ったら自動で待機してリトライする関数。
max_retries 回まで再試行し、成功(ステータス200)でループを抜ける。
Args:
url (str): アクセス先URL
headers (dict): リクエストヘッダ
max_retries (int): 最大リトライ回数
Returns:
response (requests.Response): 成功時のレスポンスオブジェクト
Raises:
Exception: リトライ上限を超えても成功しない場合
"""
attempt = 0
while attempt < max_retries:
attempt += 1
response = requests.get(url, headers=headers)
if response.status_code == 200:
# 正常に取得できた
return response
elif response.status_code == 429:
# アクセス過多エラー
print(f"[{attempt}/{max_retries}] 429 Too Many Requests 受信")
retry_after = response.headers.get("Retry-After")
if retry_after:
# サーバーから「何秒後に再試行してほしいか」指定がある
wait_time = int(retry_after)
print(f" -> 'Retry-After' ヘッダに従い {wait_time} 秒待機します。")
time.sleep(wait_time)
else:
# 指定がない場合は指数的バックオフ
wait_time = 2 ** (attempt - 1)
print(f" -> 指数的バックオフにより {wait_time} 秒待機します。")
time.sleep(wait_time)
else:
# 429 以外のステータスコードの場合
# 必要に応じて再試行するか、raiseで終わらせるか決める
print(f"[{attempt}/{max_retries}] Unexpected status code: {response.status_code}")
# 例: ステータスが403や404の場合はその場で終了したいなら raise
# response.raise_for_status()
# ここでは再試行せずに終了する例を示す
return response
# リトライ上限を超えても成功しなかった場合は例外を投げる
raise Exception(f"Failed to fetch {url} after {max_retries} retries.")
# 全選手をループしてスクレイピング
for pid in players:
# 例: "https://www.basketball-reference.com/players/a/aldamsa01/gamelog/2025/"
url = f"{base_url}{pid}/gamelog/2025/"
print(f"Scraping {url} ...")
# ここで fetch_with_retry を呼び出し、429なら自動で待機&再試行
response = fetch_with_retry(url, headers=headers, max_retries=5)
# ステータスが 200 以外だけれど 429 でもないケース(403, 404等)は fetch_with_retry のコードにより
# そのまま return してきます。ここでチェックして抜ける/continue するなどを行う。
if response.status_code != 200:
print(f" -> {pid} のページ取得に失敗しました。ステータス: {response.status_code}")
continue
# BeautifulSoupでパース
soup = BeautifulSoup(response.text, "html.parser")
# Game Logテーブルを取得 (id="pgl_basic")
table = soup.find("table", id="pgl_basic")
if not table:
print(f" -> {pid} のテーブルが見つかりません。Inactiveか未登録の可能性があります。")
continue
# <tbody> 内の各試合行を取得
tbody = table.find("tbody")
rows = tbody.find_all("tr")
for row in rows:
# 実際の試合データは trタグに id属性がある
if row.get("id"):
tds = row.find_all("td")
# Inactive / DNP などで列数が足りない可能性もある
if tds:
# テキストを取り出す
stats = [td.get_text(strip=True) for td in tds]
# 列数が columns より少なければ埋める
if len(stats) < len(columns):
stats += [""] * (len(columns) - len(stats))
# "PlayerID"列として pid を先頭に追加
all_data.append([pid] + stats)
実際に得られるデータは以下のようなデータになります
現時点が12-28で当日のゲームのスタッツは含まれていません
カラムのそれぞれの説明は以下の通りです。
G : シーズン中の第何試合目か (Game Number)
Date : 試合日の日付
Age : 選手の年齢を「年-日数」の形式で表記したもの
例: 「23-186」は 23歳と186日経過
Tm : 選手の所属チーム (Team)
Opp : 対戦相手チーム (Opponent)
「@ XXX」となっている場合はアウェイ(敵地)での試合
GS : 先発出場 (Start) したかどうか(0=先発なし, 1=先発など)
MP : 出場時間 (Minutes Played)
例: 「15:47」は 15分47秒
FG : フィールドゴール(2点・3点シュートを含む)成功数 (Field Goals)
FGA : フィールドゴール試投数 (Field Goal Attempts)
FG% : フィールドゴール成功率 (FG ÷ FGA)
3P : 3ポイントシュート成功数 (3-Point Field Goals)
3PA : 3ポイントシュート試投数 (3-Point Field Goal Attempts)
3P% : 3ポイントシュート成功率 (3P ÷ 3PA)
FT : フリースロー成功数 (Free Throws Made)
FTA : フリースロー試投数 (Free Throws Attempted)
FT% : フリースロー成功率 (FT ÷ FTA)
ORB : オフェンスリバウンド (Offensive Rebounds)
DRB : ディフェンスリバウンド (Defensive Rebounds)
TRB : トータルリバウンド (Total Rebounds) = ORB + DRB
AST : アシスト (Assists)
STL : スティール (Steals)
BLK : ブロック (Blocks)
TOV : ターンオーバー (Turnovers)
PF : 個人ファウル (Personal Fouls)
PTS : 得点 (Points)
GmSc : Game Score の略。
ジョン・ホリンジャー考案の単一試合向け指標で、選手の貢献度を数値化したもの
+/- : プラス・マイナス (Plus/Minus)。
選手が出場していた間にチームが何点リード (またはビハインド) したか
推定の前に
まずは基本的な得点に関する統計を見ます。
ベイズモデリングせずにわかる情報はまとめておきたいということです。
まずは選手ごとの得点に関するスタッツの平均まとめ(用語は上参照)
データセットに追加で情報を入れています
Ht cm : 高さをcmで変換したもの
Pos : ポジション
河村くんは平均3分の出場の平均得点1点、3Pの成功率は14%、ただしフィールドゴールの確率は24%
同じポジションのScotty Pippen Jr. やMarcus Smartと比べると出場時間もシュートの成功率も平均得点もまだ及びません。
また高さの観点からも10センチ以上彼らの方が高いのでコート上では優位です。
こういう数字にしてしまうと彼がグリズリーズに2wayとしてロスター入りしているのが驚異的なことだと思います。
数字以上の力と影響力を河村くんはNBAでも発揮しているので、もっと出場機会が増えればこの辺りをもっと数字伸ばしていくことが予想できます。
ハッスルズ(グリズリーズ下部組織)でのスタッツはこの身長ながら驚異的ですが、今回はNBAでのパフォーマンスということで、焦点には当てません。
得点に着目して推定
最初に脳死でPTS
、つまり得点だけを見ます。
得点だけを見ても一応の参考にはなります。(後ほどもう少しどうやって得点したかなどを含めても推定していきます)
データでは出場のないデータは含められないので出場したデータでのみ参考にします。
ここではMPが0以上のデータのみに着目。
ただし注意として、出場バイアスや出場時間バイアスがあることには注意。
これに関しては後ほど段階的にバイアスを考慮して見ていく。
詳しいコードはここでは省きます(必要な人がいれば掲載するが長大な記事になるのを避けるため省きます)
pymc
を用いてMCMCで推定します。
推定結果
単純な評価
- Jaren Jackson Jr. と Ja Morant は点取り屋で平均的に20pt以上で得点のブレも少なく毎試合これくらいの点取の期待が高い
- 対して河村くんはまだ出場時間なども十分でないのと、ガべージタイム(得点差が明らかかつ逆転するには時間的に足りない4Qの時間のことを指す)での出場も多いので平均だと1ptではあるものの、平均の近いColin Castleton と比べるとブレが少ないので、期待としては河村くんの方が高そうに見える。
時間あたりの得点に着目する
先ほども言及したように得点だけを見ると出場時間が長いほど取りやすいので、もう少しフェアな見方をしたい。
その場合に時間あたりの得点を見てみる。
時間あたりの得点にすると
PTS_per_MIN
= PTS
/ MP
1分あたりの得点は連続値なので、簡単のため「正規分布 + ログリンク」などを考える。
各プレイヤーの1分あたりの得点の期待値
各プレイヤーの1分あたりの得点
ここで、
推定結果
もちろん1分ごとに1点をとるわけではないので大体どのメンバーも1を下回ります。しかもバスケは基本2点からなので1を下回るという概念もやや違和感ではありますが。
出場時間に対しての効率的な点の取り方の指標の一つくらいの感覚です。
この指標では点取得の効率性でいくとColin Castletonを上回りましたね。
Vince Williams Jr.は逆にBrandon ClarkeやJake LaRaviaより平均点は高いが、得点に関してはブレが大きいので(サンプルサイズが少ないため)、平均的にはBrandon ClarkeやJake LaRaviaの方が期待値は高い
ここで驚異的なのはJay Huffです。Baneよりも実は出場時間に対して得点率は高いみたいです。
ただし、ポジションが違うので一概に同じ括りで見ることはできませんが、2wayから本契約に至ったHuffはかなり効率屋であるのは間違いないです。
もう少し別の指標でも見てみる
NBAの評価指標でよく用いられる、効果的なフィールドゴール成功率を表すeFG%と、真のシュート成功率を表すTS%について説明します。
1. eFG% (Effective Field Goal Percentage)
eFG%は、3ポイントシュートが2ポイントシュートよりも価値があることを考慮に入れた、フィールドゴール成功率の指標です。
計算式
ここで、
- FG: フィールドゴール成功数
- 3P: 3ポイントシュート成功数
- FGA: フィールドゴール試投数
注意点
- FGA > 0 の行についてのみ計算します。
- 小サンプル(例:1本だけ3Pを打って成功し、eFG%=1.5)の場合、1を超える値が出ることがあります。
- ここでは、以下のような対応が考えられます。
-
max(0, min(eFG%, 1))
などで [0, 1] にクリップする。 -
eFG% > 1
が出た行をモデルに入れない。ただしシュート1本に対して3Pだけ決めてる場合もあるのでこの辺りは別途考慮が必要。例えば正規化してしまうなど。
-
2. TS% (True Shooting Percentage)
TS%は、フリースローを含むすべてのシュート試投を考慮した、真のシュート成功率の指標です。
計算式
ここで、
- PTS: 総得点
- FGA: フィールドゴール試投数
- FTA: フリースロー試投数
注意点
- 分母が0にならない行だけを対象にします(FGA=0 かつ FTA=0 の場合は除外)。
- TS%も極端に1を超える値があればクリップや除外を考慮します。
推定していく
形式的にどちらの指標も1を超えた場合は1として丸め込みます。(データとして失うのは勿体無いので)
また端点が多く存在するとモデルが発散してしまい推論ができないエラーが起きるので、形式的に下限を0.001
、上限を0.9999
としています。
その場合に、この指標では[0,1]区間の指標を使うので、Beta分布による事前分布の想定になります。
eFG%
ここでは「選手
と仮定しています。
このモデルでは、選手
-
: 選手\\alpha_{efg}[i] の logit(eFG) の部分プーリング。i - ここで、
logit
関数とは、以下のように定義されます。
\text{logit}(p) = \log\left(\frac{p}{1-p}\right) - つまり、
は、選手\\alpha_{efg}[i] の平均的な eFG% の対数オッズを表します。i
- ここで、
-
: すべての選手に共通の「精度パラメータ」で、分散を制御します。\\phi_{efg} - この値が大きいほど、各選手のeFG%はその平均値
の近くに集中します。つまり、選手間の eFG% のばらつきが小さくなります。\\μ_{efg} - 逆に、この値が小さいと、各選手の eFG% はより広い範囲にばらつくようになります。
- この値が大きいほど、各選手のeFG%はその平均値
-
μ_efg = sigmoid(alpha_efg[player_ids])
: 各行(試合)の eFG% の平均(事後期待値)に対応します。- ここで、
sigmoid
関数とは、以下のように定義され、ロジット関数の逆関数です。
\text{sigmoid}(x) = \frac{1}{1 + \exp(-x)} -
alpha_efg[player_ids]
は、各試合に出場した選手の 値を取り出すことを意味します。\\alpha_{efg} - つまり、
μ_efg
は、各試合の出場選手の 値を sigmoid 関数で変換することで、各試合の平均的な eFG% を算出しています。\\alpha_{efg}
- ここで、
このモデルは、各選手の平均的な eFG% を、選手固有のパラメータ μ_efg
は、各試合の出場選手に基づいて計算される、その試合における平均的な eFG% の期待値を表します。
推定結果
以下が推定結果です。
こうすると見方が変わりますね。得点をただ平均的に高く入れているよりも、より効率的に3ptとっているか、Attemptの回数に対して成功できているのか、という見え方になります。
河村くんはNBAでのこの効率的な得点力という点ではまだ課題が残る形に見えます。(出場時間や回数にもよりますが)
TS%
TS%もeFG%と同様の考え方で推定していきます。
-
: 選手\\alpha_{ts}[i] の平均TS%の対数オッズ。i -
α_ts[i] → sigmoid → TS%
のように、 をシグモイド関数で変換することで、選手\\alpha_{ts}[i] の平均TS% を算出できます。i
-
-
: 全選手に共通の分散パラメータ。\\phi_{ts} - この値が大きいほど、各選手のTS%はその平均値の近くに集中します。つまり、選手間の TS% のばらつきが小さくなります。
- 逆に、この値が小さいと、各選手の TS% はより広い範囲にばらつくようになります。
-
選手ごとの “平均 TS%” の事後分布を得たい場合:
alpha_ts
を取り出し、シグモイド変換します。-
alpha_ts
はサンプリングされたデータ(trace_ts
など)に含まれており、trace_ts["alpha_ts"]
のようにしてアクセスできます。 -
alpha_ts
を選手ごとに取り出し、各サンプルにシグモイド関数を適用することで、各選手の平均 TS% の事後分布が得られます。
-
推定結果
推定結果は以下です。
こうやってみると河村くんはまだまだシュートを打っていないということが見えてきます。
正直これが5割を超えていれば優秀だと言っても過言ではないでしょう。
1点面白いのはColin Castletonがかなりブレが起きいことです。8試合出ているのでサンプルサイズが極端に少ないわけではありません。
eFG%を見るに3Pをそこまで打っているわけではないが(センターなので)、総合的なシュート力を見るに、試合によってかなり上振れと下振れがあるように見えます。実際にはシュートを打ったが0点の試合の日が6試合いくつかあるから、というのが理由です。
ここもこの指標とベイズモデリングの面白いところですね。
今回のまとめ
さて試合を見ている方々も感じているとは思いますが、まだまだ河村くんにはシュート意欲がNBAの試合においてはセルフィッシュになりきれていないところがあります。
だからと言ってなのですが、ノールックパスやそれによる華麗なアシストはまた別評価になる部分なので、今回の得点項目に対しての評価でいくと他の選手にまだまだ劣る結果には見えてしまいます。
次はアシストも含めた評価を見てみたいところです。
また相手との相性の部分や相手が格上・同格・格上という点でも評価を入れていきたいところです。
それによって選手の評価はもう少し修正されるべき点がいくつかあります。
例えば強豪相手に点がいつもより低くても、他のメンバーと比べて大きくとっていれば選手の勝負強さの部分の評価は再考されるべきです。逆に、格下相手に多く点をとっていればそれも再考されるべきです。
この辺りは探索的に行う必要もありますが、ベイズであれば更新されてよりもっともらしい分布に仕上がるので、ここが主観を入れる面白さかなとも思います。
今年は最後の執筆ですが、みなさん良いお年を。
Discussion