八村塁(Rui Hachimura)の NBA初年度成績を予測する
いやー、日本人初の NBAドラフト指名、震えましたね。
指名されるだけならまだしも、1巡目9位指名って!
世界で 4億5千万人がプレイすると言われるバスケットボールという競技において、その中でトップリーグに日本人がドラフトされることは驚くべきことです。
ちなみにかの Kobe Bryant でさえ1巡目13位です。
https://en.wikipedia.org/wiki/Kobe_Bryant
まぁチーム事情とか色々あるので一概に順位が高いほどすごいというわけではないんですが、それでも驚愕です。
さて、今回はそのような知識を "あまり" 入れずに、純粋に科学(特に計算機科学)の力だけをもって、八村選手がどのような成績を残せるのかを予測してみたいと思います。
"あまり" と書いているのは、ちょっとデータが与えられていなくてどうしようもなくて、、、とか色々な事情を考慮してです。
以下、文体が敬体から常体に変わります。
基本的なアイディア
大学時代に八村選手と同じようなスタッツを残した選手を見つけ出し、その選手が NBA 初年度に残した成績を参照することで、八村選手の活躍もある程度推測できるのではないか?
データ
Google BigQuery に bigquery-public-data/ncaa_basketball
という公開データがあるのでこれを使用する。
もともと kaggle で使用された課題用のデータだが、実際の NCAA のスタッツが実名で得られる。
SELECT
*
FROM
`bigquery-public-data.ncaa_basketball.mbb_teams`
WHERE
market="Gonzaga"
LIMIT 1;
として、チームID を取得したあとで、
SELECT
*
FROM
`bigquery-public-data.ncaa_basketball.mbb_players_games_sr`
WHERE
team_id="2f4d21f8-6d5f-48a5-abca-52a30583871a" AND
first_name="Rui" AND
tournament = "NCAA"
LIMIT 1000;
とすると八村選手の登録されている試合の結果が取得できる。
BigQuery上で集計まで行っても良いが、できる限り python側に raw に近い状態で持ってきた方が扱いが楽なため、試合ごとのデータをダウンロードする。
なお、クエリ結果は 1GB までであればそのまま直接 json で Google Drive上に保存できる。
そして、まずは八村選手と身長、体重、ポジションの近いプレイヤーを抜き出す。
SELECT
*
FROM
`bigquery-public-data.ncaa_basketball.mbb_players_games_sr`
WHERE
height BETWEEN 75 AND 85 AND
weight BETWEEN 205 AND 245 AND
position="F" AND
season BETWEEN 2011 AND 2015 AND
tournament = "NCAA"
LIMIT 100000;
BigQuery上では 2003年のデータから取得できるが、あまり昔のデータを引っ張ってきてもルールや文化が変わってしまっているので 2011年から 2015年に大学でプレーしていた選手に限定する。
また、身長は ±5-inch、体重は ±20-pounds とし、ポジションはフォワード(F)のプレイヤーだけを取得する。
ただ、BigQuery上にあるデータが 2017年までのものなので、八村選手がメインでプレーした 2018年のデータはない。
そこで、 ESPN から 2018年のデータを取得する。
https://www.espn.com/mens-college-basketball/team/stats/_/id/2250
実はこの ESPN から取得したデータは kaggle で用いられたものよりも粒度が荒く(とはいえバスケットボールで通常用いられる指標である)、このまま結合しようとすると色々と不都合があるのであとで再整形する。
データ読み込み
BigQuery から取得したデータは jsonl形式(一行ずつが独立した jsonフォーマットになっているテキスト形式)なので、これを python で読み込んで pandas.DataFrame の形に変換する。
import pandas as pd
DATA_PATH_RUI = './data/bq-results-20190710-040500-jn11labq5atn.json'
DATA_PATH_SIM = './data/bq-results-20190711-072404-txneu5c4yv68.json'
# jsonl を読み込んで pandas.DataFrame に変換する関数
def read_jsonl(path):
with open(path, 'r') as f:
data = f.readlines()
data = map(lambda x: x.rstrip(), data)
data_json_str = "[" + ','.join(data) + "]"
return pd.read_json(data_json_str)
# 八村選手のデータを読み込む
df_rui = read_jsonl(DATA_PATH_RUI)
# 他の選手のデータを読み込む
df_sim = read_jsonl(DATA_PATH_SIM)
ちなみにこの時点で何人くらいの候補プレイヤーがいるかというと、
names = df_sim.abbr_name.unique()
print(f'The number of similar players to Rui Hachimura = {len(names)}')
# => The number of similar players to Rui Hachimura = 1300
ということで 1,300人が該当した。
八村選手の身長は 2m を超え、体重は 100kg 近いが、5年間で同じような体格の選手がこれだけいるということである。
アメリカの大学バスケ市場の凄まじさがこれだけで伝わってくる。
前処理(preprocess)
さて、BigQuery から取得したデータは各試合ごとのスコア(ボックススコアと呼ばれる)なので、これを各年で集計する必要がある。
基本的にバスケットボールの世界では統計値には平均値のみを使用し、分散その他統計値はあまり用いられない。
ESPN のデータも例外ではなく平均値のみなので、平均を計算する関数で簡単に済ませられる。
さて、DataFrame.mean()
メソッドを使用したいのだが、ここで注意しなければならない点がいくつかある。
- 文字列は
mean()
によって無視される - 割合の平均は全体の割合に等しくない
- 欠損値の扱い
- (カラム毎のスケール)
今回使用するデータは非常に綺麗なフォーマットである。
綺麗とはどういうことかというと、文字列はちゃんと文字列として格納してあるし、数値は数値として格納してある、...ということである。
「何を当たり前のことを...?」と思う人もいるかもしれない。
それが、こと日本国内においてはこの当たり前でないデータが数多くある。
原理的に仕方ないものとしては、元号が一つの例として挙げられる。
元号は知っての通り、令和元年
などと表記される。
この 元年
とは 1st year
のことであり、当然 2nd year
と連続する値である。
しかし、多くのソフトウェア、ミドルウェアでは 令和元年
と 令和2年
は連続する整数ではなく、ただの文字列として扱われる。
そこで、年次毎の売上推移を見たければ元号から西暦に変換する必要がある。
原理的に仕方無くないものに関しては、ここでは言及しない。
"ネ申エクセル" と揶揄されるものは大方これを含む。
話はそれたが、綺麗なフォーマットにおいては、mean()
は正しく機能する。
何も考えずに DataFrame.groupby(...).mean()
とすれば、groupby()
した中での平均値を求めてくれる。
今回の例で言えば、player_id
、season
、full_name
が固有なデータ行について集計してくれ、というものである。
多くの場合、player_id
と full_name
は一致するので、どちらか一方を指定すればよいのだが、あとでデータを見るときにどちらもあった方が便利なため残しておく。
ここまでで、綺麗なフォーマットであれば mean()
で機械的に平均が計算できるとのべたが、割合の平均は全体の割合に等しくない という点に注意する必要がある。
例えば、一試合目で 2本打って 1本入り 50%、二試合目で 3本打って 2本入り 66.6..% とする。
このとき、割合の平均をとると、 (50 + 66.6...) / 2 = 116.6... / 2= 58.3...
となる。
一方、全体では 5本打って 3本入ったのだから、3 / 5 = 0.6 = 60%
である。
このように、割合の平均を取ると、全体の割合から歪んでしまうことに注意しなければならない。
最後に、欠損値について考えなければならない。
欠損値とはその名の通り、記録できていないデータである。
例えば、出場機会がなく得点が 0点のとき、そのまま 0 としてしまうとあとで困る(特に平均や除算を行う場合)ので、
NA や NaN といった疑似的な数値を入れることがある。
この欠損値をそのままにしても上手くいくアルゴリズムはいくつかある(How to deal with Missing Value - xgboostのだが、大抵はそうでない。
かといって、前述のようにただ 0 としてしまうのはあまり良くない。
そこで、「何もしない」、「平均値または中央値で埋める」、「最頻値で埋める」などといったヒューリスティックが使われている(6 Different Ways to Compensate for Missing Values In a Dataset (Data Imputation with examples))。
しかし、これをすれば良いというものはなく、データセットや問題に応じてどの方法で欠損値を扱うかを毎回検討せねばならず、このことがデータサイエンティストが当たる最初の壁となる。
さらにもう一つ、処理するアルゴリズムによっては、カラムごとの値のスケールを調整しなければならない場合もある。
例えば、平均得点が 5点違うのと、平均ブロック数が 5つ違うのとでは、意味合いが全く異なる。
選手の類似性という点に着目すると決めている以上、この差異が十分よく反映されるように変換を施すべきである。
さて、前置きが長くなったが、今回の場合に照らすと
- パーセンテージの平均は使わない(ちゃんと計算する)
- 欠損値を埋める必要がある
- 値を正規化、標準化する必要がある
となる。以下に具体的な処理を示す。
使用するカラムを指定する
DataFrame を扱う際、カラムを指定して処理を行いたい場合が多いので、あらかじめ処理対象のカラムを記憶しておくと良い。
ここでは、num_labels
という変数に数値型の値を格納している。
str_labels = [
# プレイヤーID
'player_id',
# プレイヤー名
'full_name',
# シーズン
'season',
]
num_labels = [
# アシスト数
'assists',
# ブロック数
'blocks',
# ディフェンスリバウンド数
'defensive_rebounds',
# フィールドゴール試投数
'field_goals_att',
# フィールドゴール成功数
'field_goals_made',
# フリースロー試投数
'free_throws_att',
# フリースロー成功数
'free_throws_made',
# 身長
'height',
# 出場時間
'minutes',
# オフェンスリバウンド数
'offensive_rebounds',
# 得点数
'points',
# リバウンド数
'rebounds',
# スティール数
'steals',
# 3P試投数
'three_points_att',
# 3P成功数
'three_points_made',
# ターンオーバー数
'turnovers',
# 2P試投数
'two_points_att',
# 2P成功数
'two_points_made',
# 体重
'weight',
]
欠損値処理
今回は、欠損値はそのカラムの平均値で埋めるという処理を施す。
# 値が数値か否かを判定するメソッド
def is_number(x):
return (type(x) is int or type(x) is float) and not np.isnan(x)
# 「値が数値ならそのままの値を、そうでなければ平均値を返すメソッド」を返すメソッド
def value_or_mean(df, label):
mean = df[label].dropna().mean()
def f(row):
v = None
if is_number(row[label]):
v = row[label]
else:
v = mean
return v
return f
# 欠損値を埋める処理
def fillna(df):
_df = df.copy()
neighbors = []
for label in num_labels:
_df[label] = df.apply(value_or_mean(df, label), axis=1)
return _df
df_sim = fillna(df_sim)
統計処理
ここまで来て、やっと統計処理を行うことができる。
# 八村選手のスタッツをシーズンごとに計算
df_rui_stats = df_rui.groupby(['player_id', 'season', 'full_name']).mean()
# 他のプレイヤーのスタッツをシーズンごとに計算
df_sim_stats = df_sim.groupby(['player_id', 'season', 'full_name']).mean()
標準化
今回のケースでは、平均を引いて標準偏差で割る標準化を行う。
なぜ閉区間[0, 1]
へのフラットな射影ではなく標準化をするかというと、今回重要なのは平均に比べてどのくらい異質なスコアを各プレイヤーが示しているかという点だからである。
例えば、平均得点が 30点のプレイヤーがいたとしよう。このとき、他のプレイヤーも同様に 30点近くを記録しているのであれば、そのプレイヤーは平均的な得点力、と見れるかも知れない。一方、他のプレイヤーの平均得点が高々10点近くなのであれば、そのプレイヤーの得点力は並外れていると評価できる。
このように、平均からどのくらい離れているか、異常であるかを示す変換として標準化を使用できる。
def standardize(df, labels):
_df = df.copy()
for label in labels:
# 平均と標準偏差を計算
mean = df[label].mean()
std = df[label].std()
values = []
for val in df[label].values:
# 標準偏差が 0 でなければ標準化する
if std > 0:
values.append((val - mean) / std)
# 標準偏差が 0 の場合は、どのプレイヤーも同じ値をとるということなので、
# とりあえず 0 を入れておけば問題ない(厳密には平均を入れるが、類似度計算にはあまり関係ない)
else:
values.append(0.0)
# ラベルに示されるカラムを更新
_df[label] = values
return _df
df = standardize(df, num_labels)
さて、ここまでが前処理である。
次節では、いよいよ類似度計算に移る。
類似度計算(Cosine類似度)
ある 2つのベクトルが類似しているとはどういうことだろうか?
例えば各ベクトルの大きさをとって、それぞれが近い値なら類似しているといえるかもしれない。
2つのベクトルを超平面上にプロットして、その距離が近くても類似しているといえるかもしれない。
さて、ここでの類似とは、「2人のプレイヤーが似たタイプのプレイを得意とする選手である」と定義する。
すなわち、似たプレイスタイルであれば、その強弱は考えない、ということである。
なぜこのように定義するかというと、NBA にドラフトされるような選手はその時点である程度プレイスタイルが完成されており、リーグに入ってもそれほど大きくプレイスタイルを変えないだろう、という知見による。
プレイスタイルが変わらないのであれば、異なるのはその洗練度であり、その選手がどう成長していくかも含めて参考になるだろう。
これらを踏まえ、類似度には Cosine類似度を採用する。
Cosine類似度は、ベクトルの内積をそれぞれの大きさの積で割ったものとして定義される。
https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise.cosine_similarity.html
幾何学的には、2つのベクトルの角度が小さいほど、値が大きくなる指標である。
Cosine類似度は自分で実装しても良いのだが、python では scikit-learn
に実装されているため、それを使用する。
このような指標(metric)は意外とハマるので極力ありものを使ったほうが良い。
ハマりどころは境界値(0 や inf)が多い。
from sklearn.metrics.pairwise import cosine_similarity
def calc_cos(row, df):
cosines = []
for _, x in df.iterrows():
X = row.values.reshape(1, -1)
Y = x.values.reshape(1, -1)
cosines.append(cosine_similarity(X, Y)[0][0])
return cosines
# iloc[0] は八村選手のデータ
cos = calc_cos(df.iloc[0], df)
df['cosine'] = cos
Cosine類似度が計算できたら、後はソートするだけである。
df_similar = df.sort_values('cosine', ascending=False)
この結果は、公開した jupyter notebook に載せているので興味があればご覧いただきたい。
結果
さて、以上より、Cosine類似度において類似していると思しき選手のリストを手に入れた。
そこで、類似度の高い選手について、NBA でどんな成績を残しているか見ていく。
ここで注意すべきなのは、Cosine類似度を用いているため、類似性はあくまでプレイスタイルに関してであり、実際にはそれほど活躍していない選手も載っているということだ。
そのような選手と、八村選手のようにリーグから注目されている選手とを区別するために、ドラフトされているか否かを指標に入れる。
ただし、このデータは配布されていないため、名前を Google検索して目でドラフト順位を記載したものである。
また、ドラフト外の選手は上述の理由で取り上げないこととする。
1st pick of Rui-like draft, Montrezl Harrell(Houston Rockets, 32nd)
まずは最もプレイスタイルが近いと判定された選手、Montrezl Harrell。
https://www.basketball-reference.com/players/h/harremo01.html
現在は LA Clippers に所属しており、カンファレンス・ファーストラウンドで昨季の王者である Golden State Warriors を苦しめた。
初年度成績はまずまずといったところ。
全体32位での指名なので、八村選手はこれよりも良いスタッツが期待できる。
エースとまではいかないが、若いチームで安定した数字を残しており、4年目でこのくらいの数字であれば NBA に残り続けることができるだろう、という成績だ。
ちなみに契約金は 600万ドル(およそ7億円)と報じられている。
https://www.espn.com/nba/team/roster/_/name/lac/la-clippers
ただし今シーズンは大型選手補強が行われており、プレイタイムは減るかも知れない。
初年度成績(Houston Rockets)
- 39ゲーム出場(1ゲーム先発)
- 平均9.7分プレイ
- 平均3.6得点
- 平均1.7リバウンド
- 平均0.3スティール
- 平均0.3ブロック
- 平均0.4ターンオーバー
- 平均1.2パーソナルファウル
最新シーズン成績(4年目:LA Clippers)
- 82ゲーム出場(5ゲーム先発)
- 平均26.3分プレイ
- 平均16.6得点
- 平均6.5リバウンド
- 平均0.9スティール
- 平均1.3ブロック
- 平均1.6ターンオーバー
- 平均3.1パーソナルファウル
2nd pick of Rui-like draft, T.J. Leaf(Indiana Pacers, 18th)
イスラエル出身のプレイヤー T.J. Leaf。
https://www.basketball-reference.com/players/l/leaftj01.html
八村選手と同じく 1ラウンドでの指名(18位)であるが、正直なところ、それほど目立つ活躍はできていない。
趣旨が変わってしまうのであまり詳しくは調査していないが、もしかするとコミュニケーションの問題があったのかもしれない。
初年度成績(Indiana Pacers)
- 53ゲーム出場(0ゲーム先発)
- 平均8.7分プレイ
- 平均2.9得点
- 平均1.5リバウンド
- 平均0.1スティール
- 平均0.1ブロック
- 平均0.2ターンオーバー
- 平均0.8パーソナルファウル
最新シーズン成績(2年目:Indiana Pacers)
- 58ゲーム出場(1ゲーム先発)
- 平均9.0分プレイ
- 平均3.9得点
- 平均2.2リバウンド
- 平均0.2スティール
- 平均0.3ブロック
- 平均0.2ターンオーバー
- 平均0.6パーソナルファウル
3rd pick of Rui-like draft, Marquese Chriss(Sacramento Kings, 8th)
全体8位指名で、今回最も八村選手と指名順が近い Marquese Chriss選手。
https://www.basketball-reference.com/players/c/chrisma01.html
初年度はなんと全ゲームに出場、内75ゲームで先発するという期待のされ方。
また、スタッツも初年度にしては非常に良く、上々のスタートだったが、現在はトレードされて出場機会もぐっと減ってしまっている。
初年度成績(Sacramento Kings)
- 82ゲーム出場(75ゲーム先発)
- 平均21.3分プレイ
- 平均9.2得点
- 平均4.2リバウンド
- 平均0.8スティール
- 平均0.9ブロック
- 平均1.3ターンオーバー
- 平均3.2パーソナルファウル
最新シーズン成績(5年目:Cleveland Cavaliers)
- 27ゲーム出場(2ゲーム先発)
- 平均14.6分プレイ
- 平均5.7得点
- 平均4.2リバウンド
- 平均0.6スティール
- 平均0.3ブロック
- 平均0.9ターンオーバー
- 平均2.4パーソナルファウル
考察
チームの育成、起用方針に大きく左右されるので一概には言えないが、実力や期待度的には Marquese Chriss と同じくらいのプレイタイムが与えられ、成績を残してもおかしくない。
一方で、やはりそれほど甘くない世界、成績が伸び悩めばトレード、最悪解雇されることもありうる。
八村選手は渡邊選手とともに日本の期待を大きく背負っているが、直近の成績を重視せず、 Montrezl Harrell のように成長を続けて欲しい。
結論(予想)
Washinton Wizards の事情も考えて、だいたい Marquese Chriss の上位互換となるような成績を残すのではないだろうか。
- 70ゲーム出場(56ゲーム先発)
- 平均25.0分プレイ
- 平均14.0得点
- 平均6.0リバウンド
- 平均0.8スティール
- 平均2.0ブロック
- 平均1.3ターンオーバー
- 平均3.2パーソナルファウル
おまけ
使用した jupyter notebook は github で公開中
https://github.com/chase0213/rui8_prediction