Bリーグ版TS%を作りたい! 2

*この記事は『Bリーグ版TS%を作りたい!』(https://rnsr0371.boy.jp/2021/04/01/true_shooting_percentage_in_bleague/)の続編です。リンク先の記事のイントロダクションを読んでから、この記事を読むことをオススメいたします。

こんにちは、らんそうるいです。先日『Bリーグ版TS%を作りたい!』という記事で、True Shooting percentage(TS%)の分母のフリースロー試投数(FTA)の係数として、0.38くらいを推定しました。しかし、この分析には問題がありました。それは、①B1だけでなく、B2からもデータをランダムサンプリングしており、母集団がよく分からない。②各クォータの残り時間が同じプレーをまとめてしまっている。たとえば、1Q残り3:00と2Q残り3:00のプレーを一つにまとめてしまっている。③係数の計算方法が合っているのか怪しい。の3点です。今回の分析ではこれらを改善した数値を報告します。結論を先に書くと、これらの問題を改善し、係数を計算したところ、0.394または0.433という数値が得られました。

 Twitterでの情報ですが、2021/03/31時点でのB1の全試合のデータを用いて、0.427という係数を得た方がいらっしゃいます。もし正しい計算方法で得られた数値が0.394だったとしたらとだいぶ違うな……という印象です。また、後述するように、TS%のFTAの係数はPOSSというアドバンスドスタッツの計算に使われるものと同じで、そのPOSSは様々なアドバンスドスタッツの基礎となる重要なスタッツです。こうした、結果の重要性を鑑みて、スクレイピング・クローリングの部分を除いて、分析に使ったコードを公開することにしました。

なんでTS%にそんなにこだわるの?

というご質問をいただいたので、私なりの回答をしたいと思います(怒って反論してるわけではなく、良い質問だと思ったので回答してます)。TS%そのものは攻撃効率の評価の一つのやり方で、このやり方にこだわりがあるわけではありません。むしろ、攻撃効率の評価なら、より解釈しやすく、このブログでよく取り上げているOffensive Efficiencyや、その改悪版である effective Offensive Efficiencyの方に愛着があります。

 それでもTS%について調べ続けている理由は、TS%の定義式にあるFTAの係数を求めることが、POSSというアドバンスドスタッツのFTAの係数を求めることと同じ問題であると途中で気づいたからです。POSSの定義式は次のようなものです。

  • POSS=フィールドゴール試投数+0.44*フリースロー試投数+ターンオーバー数−オフェンスリバウンド
  • *ただしオフェンスリバウンドを引かない場合もあるので注意

 このPOSSはそのゲームのポゼッション数を近似するものです。そして、アドバンスドスタッツはポゼッション100回あたり〇〇という意味合いのものが多く、その計算にPOSSを使うものが多くあります。たとえば、チームの攻撃指標の一つで「100ポゼッションあたりの得点の期待値」である、オフェンスレーティング(ORTG)の定義式は次のようなものです。

  • ORTG=得点/POSS*100

 なぜ単純に得点を攻撃力の指標としてはいけないのか? これは各チームの攻撃ペースの違いによってポゼッション数がばらつき、それによってスタッツがばらつくからです。トランジションゲームを好むチームとセットオフェンス中心のチームがあったとしたら、得点は前者のほうが高くても、ORTGで見ると後者のほうが高いということが起こりえます。得点は試合のペースの影響を受けるので、ポゼッション数を揃えるという発想が必要になります。これと同じロジックで計算されたアドバンスドスタッツは多いです。

 このようにポゼッション数は様々なアドバンスドスタッツの基礎となるため、POSSでポゼッション数を正確に近似することは、スタッツを文字通りの意味で解釈するために重要です。0.44という数字をみんなが使うならチーム間で比較できるので問題は生じないのですが、0.44という係数でポゼッションを近似できないとしたら「ポゼッション100回あたりの」という意味合いが損なわれてしまいます。これが気持ち悪いなと感じたため、TS%のFTAの係数を求めることに執着しています。以上が「なんでTS%にそんなにこだわるの?」に対する私なりの回答です。なお、この考えは以下のツイートの影響を強く受けています。

分析方法

ランダムサンプリング

BリーグのPlayByPlayのURLは例えば、https://www.bleague.jp/game_detail/?ScheduleKey=6337のようにScheduleKeyをクエリパラメータに持っています。このクエリパラメータをランダムに生成することで、ランダムサンプリングを行いました。具体的には、5854~6283の間の整数をランダムに50個生成しました。この数字の範囲はBリーグ2020-21シーズンのボックススコアを公開されているrintaromasuda様(https://github.com/rintaromasuda/bleaguer/tree/master/inst/extdata)のデータで、B1のゲームのScheduleKeyの最小値が5854、最大値が6283だったため、採用しました。

 シードを固定して乱数を発生させ、50個のPlayByPlayのURLを生成しました。そして、B1の50試合分のデータを抽出しました。

データの加工

生成したURLの先で、PlayByPlayから各クォータの残り時間とプレーをスクレイピングしました。こうして得られたPlayByPlayから、「『フリースロー』を含み、かつ『ファウル』を含まない」行に数字の1を割り当て、それ以外の行には0を割り当てました。

 そのうえで、各クォータの残り時間(e.g., 3:00)でデータをgroup byして、数字の合計を算出しました。この加工によって、バスカンでフリースローが与えられた場合には0が、フリースローが2投与えられた場合には2が、3投与えられた場合には3が割り当てられました。ただし、各クォータの残り時間が一致している状態でフリースローが与えられた場合(e.g., 1Qの3:00と2Qの3:00の両方でフリースローが放たれている場合)には、これらをクォータごとに区別せずにgroup byしているため、4や5といった数字が割り当てられたことになります。

 以上の処理でフリースローを1投獲得した場合には1、2投獲得した場合には2、3投獲得した場合には3、残り時間が一致している状態でフリースローが与えられた場合には3~6が割り当てられたデータセットができました。

計算方法

FTAの係数の計算方法については2通りの情報があり、どちらの情報が正しいのか判断できなかったため、どちらの計算結果も載せます。

計算方法1(前回の記事と同じ計算方法)

この方法は英語版WikipediaのTrue Shooting percentageのページに載っていた方法です。

 フリースローを1投獲得した場合には1、2投獲得した場合には2、3投獲得した場合には3、残り時間が一致している状態でフリースローが与えられた場合には3~6が割り当てられたデータセットについて、 for ループを回して一行ずつ調べ、1が割り当てられた行を見つけると0、2が割り当てられた行を見つけると0.5、3が割り当てられた行を見つけると0.3333、4が割り当てられた行を見つけると0.5を2個(つまり同じ残り時間にフリースローを4投放っている場合には2投の機会を2度得たと仮定し)、配列に追加していきました。5以上が割り当てられた行は無視しました。

 この0, 0.5, 0.3333を要素に持つリストについて、要素の合計を算出し、リストの長さで割って、平均値を得ました。たとえば、2Pへのシューティングファウル、3Pへシューティングファウル、バスカンがそれぞれ一回ずつあったゲームでは、

  • 係数=(0.5 * 1 + 0.3333 * 1 + 0 * 1) / 3 = 0.278

となります。この平均値を試合ごとに算出し、50個の平均値を算出しました。さらにこの50個の平均値の平均をとり、計算方法1の結果としました。

計算方法2(takeFTをフリースロー試投数で割る方法)

この方法はTwitterで得た情報です。0.427を算出した方と同じ方法だと思われます。

 フリースローを1投獲得した場合には1、2投獲得した場合には2、3投獲得した場合には3、残り時間が一致している状態でフリースローが与えられた場合には3~6が割り当てられたデータセットについて、for ループを回して一行ずつ調べ、1が割り当てられた行を見つけると0、2,3が割り当てられた行を見つけると1、4が割り当てられた行を見つけると2(つまり同じ残り時間にフリースローを4投放っている場合には2投の機会を2度得たと仮定) を配列に追加していきました。5以上が割り当てられた行は無視しました。

 この0, 1, 2を要素に持つリストについて、要素の合計を算出し、takeFTを求めました。さらに、フリースローを1投獲得した場合には1、2投獲得した場合には2、3投獲得した場合には3、残り時間が一致している状態でフリースローが与えられた場合には3~6が割り当てられたデータセットの合計を算出し、FTAを求めました。FTAの算出においては5以上が割り当てられた行を無視していません。そして、takeFTをFTAで割って、係数を得ました。たとえば、2Pへのシューティングファウル、3Pへシューティングファウル、バスカンがそれぞれ一回ずつあったゲームでは、

  • takeFT=1+1+0=2
  • FTA=2+3+1=6
  • 係数=takeFT / FTA = 2 / 6 =0.3333

となります。この係数を試合ごとに算出し、50個の係数を得ました。さらにこの50個の係数の平均をとり、計算方法2の結果としました。

結果

計算方法1

B1の試合を50試合ランダムサンプリングし、係数の平均をとったところ、0.394という結果が得られました。

計算方法2

B1の試合を50試合ランダムサンプリングし、係数の平均をとったところ、0.433という結果が得られました。

終わりに

B1 2020-21シーズンのゲームからランダムサンプリングで50試合抽出し、Bリーグ版TS%のFTAの係数を求めてみました。TS%のFTAの係数の計算方法については、2つの計算式が挙げられていたため、その両方の計算結果を算出しました。その結果、0.394と0.433という係数を得ました。

 英語版Wikipediaの情報が正しいとすれば、0.394という数字になりますが、計算方法が要出典扱いとなっていたため、計算式が間違っている可能性があります。しかし、これが正しい計算方法だとすると、0.44という広く使われている係数とかなりギャップがあるという結果でした。

 Twitterで得た情報が正しいとすれば、係数は0.433という結果になりました。この数値は0.427にも0.44にも近い数字だと感じています。これなら、NBAの過去のデータを用いて計算された0.44という数字をBリーグで使っても、大きな問題は生じないと思います。

 TS%の係数の意味がイマイチわかっておらず、2つある計算方法のうち、どちらが正しいのか分かりませんでした。ぜひ有識者の皆様にはどちらの計算方法が正しいのか、コメントやリプライをいただけると嬉しいです。よろしくおねがいします。

宣伝

バスケットボールのデータ分析を勉強したくて、Twitterでバスケのデータ分析をされる方のリストを作っています。ご興味のある方はぜひリストのフォローをお願いします。メンバーの自薦・他薦も募集しています。

分析に用いたコード(Python)

計算方法1

from selenium import webdriver
import random
import numpy as np
import pandas as pd


random.seed(20210404)#このシードの平均値0.394, SD=0.049。

games=random.sample(range(5854, 6283, 1), k=50)#この範囲ならB1のデータのみ取得できるはず
games

coef=[]
count=0
daburi=0


for game in games:
    #クローリングに関わるので伏せます

    #プレイバイプレイの取得
    PlayByPlay=[]
    game_time=[]

    #スクレイピングによって、PlayByPlayの中にプレーを、game_timeの中に残り時間をappendしていきます。

    #game_timeとPlayByPlayをDataFrameとして結合
    game_time=pd.DataFrame(game_time,columns=["time"])
    PlayByPlay=pd.DataFrame(PlayByPlay,columns=["play"])
    data=pd.concat([game_time,PlayByPlay],axis=1)

    ft=[]
    for p in data["play"]:
        p=str(p)
        if "フリースロー" in p and "ファウル" not in p:
            ft.append(1)
        else:
            ft.append(0)
    
    ft=pd.DataFrame(ft,columns=["is_ft"])
    data=pd.concat([data,ft],axis=1)

    #同時刻に放たれたFTの数を取得
    fts=data.groupby(data["time"]).sum()

    pos=[]
    for i in fts["is_ft"]:
        if i==2:#2Pへのシューティングファウル
            pos.append(0.5)
        elif i==3:#3Pへのシューティングファウル
            pos.append(0.3333)
        elif i==1:#バスケットカウント
            pos.append(0)
        elif i==4:#ダブリによる誤差があった場合は2Pへのシューティングファウルが2回と仮定する
            pos.append(0.5)
            pos.append(0.5)
        elif i>=5:#ダブリによる誤差が5を超えている場合はdaburiにカウントする
            daburi=daburi+1

    if len(pos)==0:
        ans=np.nan
    else:
        ans=sum(pos)/len(pos)
        
    count=count+1
    print(count)
    
    coef.append(ans)
    driver.quit()


coef2=pd.Series(coef)
coef2=coef2.dropna()
coef2.mean()
coef2.std()
print("FTAが同時刻で丸められた数:",daburi)

計算方法2

from selenium import webdriver
import random
import numpy as np
import pandas as pd


random.seed(20210404)#このシードの平均値0.433, SD=0.032。
games=random.sample(range(5854, 6283, 1), k=50)#この範囲ならB1のデータのみ取得できるはず
games

coef=[]
count=0
daburi=0


for game in games:
    #クローリングに関わるので伏せます

    #プレイバイプレイの取得
    PlayByPlay=[]
    game_time=[]

    #スクレイピングによって、PlayByPlayの中にプレーを、game_timeの中に残り時間をappendしていきます。

    #game_timeとPlayByPlayをDataFrameとして結合
    game_time=pd.DataFrame(game_time,columns=["time"])
    PlayByPlay=pd.DataFrame(PlayByPlay,columns=["play"])
    data=pd.concat([game_time,PlayByPlay],axis=1)

    ft=[]
    for p in data["play"]:
        p=str(p)
        if "フリースロー" in p and "ファウル" not in p:
            ft.append(1)
        else:
            ft.append(0)
    
    ft=pd.DataFrame(ft,columns=["is_ft"])
    data=pd.concat([data,ft],axis=1)

    #同時刻に放たれたFTの数を取得
    fts=data.groupby(data["time"]).sum()

    pos=[]
    takeFT=[]
    for i in fts["is_ft"]:
        if i==2:#2Pへのシューティングファウル
            takeFT.append(1)
        elif i==3:#3Pへのシューティングファウル
            takeFT.append(1)
        elif i==1:#バスケットカウント
            takeFT.append(0)
        elif i==4:#4の場合は2Pへのシューティングファウルが2回と仮定する
            takeFT.append(1)
            takeFT.append(1)
        elif i>=5:#ダブリによる誤差が5を超えている場合はdaburiにカウントする
            daburi=daburi+1

    if len(takeFT)==0:
        ans=np.nan
    else:
        ans=sum(takeFT)/sum(fts["is_ft"])
        
    count=count+1
    print(count)
    
    coef.append(ans)
    driver.quit()


coef2=pd.Series(coef)
coef2=coef2.dropna()
coef2.mean()
coef2.std()
print("FTAが同時刻で丸められた数:",daburi)

Follow me!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA