and one+

IT系の雑なメモを残していくブログです

【Python】国内プロバスケリーグ「Bリーグ」のプレイバイプレイを処理する

f:id:Gyrokawai:20211119132430j:plain

「+/- スタッツの処理」を中心にプレイバイプレイ取得後の処理についての解説。

 

はじめに

筆者は B リーグスタッツを解析しながら Python を学んでいます。よくある「アヤメの見分けかた」の例題における「アヤメ」が私にとっては「B リーグスタッツ」という状況。

なので最適解でもありませんし、Python の書き方として正しいかも全く別です。写経したい方の参考にはなりませんよっていう注意書き。

 

謝辞

note.nkmk.me

記法分からねってなった時はここの情報を最初に参照します。

各サンプルの書き方が分かりやすく、他の解決方法も提示してどちらが最適かを検討してくださっていたりして、とても助かります。

 

[対象者]

  • B リーグスタッツ分析班
  • Python 利用者(任意)
  • 初学者を 暖かい目で見れるひと(必須)

 

[対象レベル]

  • B リーグの公式ページから必要なデータをスクレイピングできる
  • Pandas の DataFrame 概念がわかる
  • 内包表記がわかる(任意)

 

処理概要

プレイバイプレイを処理する上での概要は以下の通り。

 

用意するもの

hyoukiYure.csv (任意)

プレイバイプレイに現れる表記ゆれ置換リスト。通常は選手名は苗字のみだが、たまにフルネームが出てきたものを置換したり、シーズンごとにプレイの表記方法が変わった時に次に用意する actionName の表記に合わせて置換したりするためのリスト。

 

actionName.csv

プレイバイプレイに現れるプレイを列挙したもの。ヘッダと思ってもらうとよい。

 

gameinfo.csv

公式ページからスクレイピングした試合情報。

 

BoxScore_****.csv (任意)

同 ボックススコア。

 

PXP_****.csv

同 プレイバイプレイ。

 

ロジック

・プレイバイプレイの「プレイ内容」を分解する。

・各プレイごとに、標準プレイ内容に該当すればその列にフラグを立てる。

・プレイを最初から順番に見ていって集計したいカテゴリごとに分類する。分類の際はプレイの対象の選手やオンコートの選手の背番号を追加する。

・分類したカテゴリ内で背番号ごとにプレイのフラグをカウントする -> スタッツ。

・スタッツを基に得点を計算する。

・自スタッツと被スタッツの差を +/- スタッツとして計算する。

 

手順

任意処理は番号に () をつけています。本筋の処理にはあまり必要がありません。

 

1. プレイバイプレイファイル(PXP_****.csv)を Pandas 配列(DataFrame)で読み込む。

 

(2.) actionName.csv で未定義のプレイの見落としを防ぐためにプレイ内容をコピーした列を用意する。以降はコピーした列に対して処理を加え、コピーされたほうには手を加えない。

 

(3.) hyoukiYure.csv を読み出し、1 列目の内容を 2 列目に置換する。(補足 1)

 

4. プレイ内容から「背番号」「選手名」「プレイ備考スタッツ」を抽出する。

 

5. actionName.csv を読み込み、プレイバイプレイ DataFrame に列を追加しつつ、プレイ内容に該当するプレイがあれば 'TRUE' フラグを立てる。プレイ内容は消込みをする。(補足 2)

 

(6.) 4 で読み込んだ内容をプレイ内容から削除する。

 

(7.) この時点でプレイ内容に値が残っている場合は、未定義のプレイが存在するため警告を出す。

 

(8.) 抽出したプレイバイプレイを csv に書き出す。

 

9. オーバータイムに対応するため、ピリオドの表記からクォータをリストで取り出す。(補足 3)

 

10. 取得したプレイバイプレイは 新 -> 旧 の時系列になっているので逆順ソートする。

 

11. 各種入れ物を用意する。

  • 現在コートにいるメンバーとコートイン時間を記録する辞書(ホーム/アウェイ)
  • プレイタイムを記録する DataFrame(ホーム/アウェイ)
  • スタッツを記録する DataFrame(ホーム/アウェイ x 自スタッツ/オンコート自チームスタッツ/オンコート相手チームスタッツ)

 

-- 以降の処理をクォータぶん行う

12. クォータごとの入れ物を用意する。

  • スタッツを記録する DataFrame(ホーム/アウェイ x 自スタッツ/オンコート自チームスタッツ/オンコート相手チームスタッツ)

 

13. 残り時間計算を行う。(OT のあるなし / 4Q までと OT で処理が異なる)

 

14. プレイバイプレイを1行ずつ処理する

  • プレイヤーイン:コートにいるメンバーを増やす。選手と残り時間を 11 で用意した 1つ目の辞書に追加する。
  • プレイヤーアウト:コートにいるメンバーを減らす。プレイヤーインした際の残り時間とアウト時点の残り時間の差異を出場時間として 11 で用意した 2 つ目の DataFrame に 加算する。この時、オンコートの全メンバーに対して処理を行うと「誰と誰が同時に出場したプレイタイム」が取得できる。
  • タイムアウト:なにもしない
  • それ以外:5 で処理したスタッツを 12 で用意したスタッツ記録用 DataFrame に追記する。(補足 4)

 

15. クォータの最後のプレイまで終わったら、12 で用意した DataFrame を 11 で用意した 3 つ目の DataFrame に外部結合する。必要であれば、クォータごとのプレイデータを DB に書き込みする。

-- クォータごとの処理終わり

 

16. 試合の最後でコートに立っていたメンバーに対して出場時間を記録する(残り時間 0 処理)

 

17. 振り分けたプレイバイプレイデータを選手ごとに合算する(補足 5)

 

(18.) ボックススコアの出場時間と取得した出場時間が大差ないかを検査する。だいたい誤差なしか 1 秒程度。

 

19. インデックスをリネームしたり並べ替えたりしたら csv に書き出して完了。

 

オンコート自チームスタッツの得点 から オンコート相手チームスタッツの得点をマイナスすると、オンコート +/- が取得できます。実際には処理の中でやっていますが、書き出した後からでも問題ありません。

 

フローは以下の通り。

f:id:Gyrokawai:20211007161630p:plain

 

補足

補足 1:表記ゆれを置換する

#-- 表記ゆれを変換する
hyoukiYure = {}
with open('../settings/Id/hyoukiYure.csv', "r", encoding='utf-8') as f:
    for src_data in csv.reader(f):
        hyoukiYure[src_data[0]] = src_data[1]
f.close()

for k, v in hyoukiYure.items():
    df['PLAY_COPY'] = df['PLAY_COPY'].str.replace(k, v)

簡単だけど汎用性ありそうだなと思って記載しておきます。

 

補足 2:読み出し~フラグ立て~消込 の処理部分

#-- 標準プレイ内容を読み込む
actionData = []
with open('../settings/Id/actionName.csv', "r", encoding='utf-8') as f:
    for src_data in csv.reader(f):
        actionData.append(src_data[0])
f.close()

#-- PLAY_COPY から標準プレイ内容のマトリクスを生成する
for i in actionData:
    df.at[df['PLAY_COPY'].str.contains(i), i] ='True' #処理高速化のため loc->at に変更
    df['PLAY_COPY'] = df['PLAY_COPY'].str.replace(i, '') #消込処理

マトリクスを生成する発想はよくできたかなと自画自賛

 

補足 3:クォータの取得

quota = df['ピリオド'].drop_duplicates(inplace=False).str.replace('Q','').tolist()
quota = [int(s) for s in quota]
quota.sort()

見落としがちなオーバータイム対応です。

 

補足 4:プレイバイプレイを振り分ける

#-- それ以外
else:
    getTempDataSeries = pd.Series(list(actionNamedTuple[10:11]) + list(actionNamedTuple[14:]))

    if actionNamedTuple.チーム名 == homeTeam:
        df_additionalStatsHome = df_additionalStatsHome.append(getTempDataSeries, ignore_index=True)

        for key in homeTeamMemberList.keys():
            getTempDataList = [key] + list(actionNamedTuple[14:])
            getTempDataSeries = pd.Series(getTempDataList)
            df_onCoatTeamStatsHome = df_onCoatTeamStatsHome.append(getTempDataSeries, ignore_index=True)

        for key in awayTeamMemberList.keys():
            getTempDataList = [key] + list(actionNamedTuple[14:])
            getTempDataSeries = pd.Series(getTempDataList)
            df_onCoatTeamStatsAway_opponent = df_onCoatTeamStatsAway_opponent.append(getTempDataSeries, ignore_index=True)

    elif actionNamedTuple.チーム名 == awayTeam:
        df_additionalStatsAway = df_additionalStatsAway.append(getTempDataSeries, ignore_index=True)

        for key in awayTeamMemberList.keys():
            getTempDataList = [key] + list(actionNamedTuple[14:])
            getTempDataSeries = pd.Series(getTempDataList)
            df_onCoatTeamStatsAway = df_onCoatTeamStatsAway.append(getTempDataSeries, ignore_index=True)

        for key in homeTeamMemberList.keys():
            getTempDataList = [key] + list(actionNamedTuple[14:])
            getTempDataSeries = pd.Series(getTempDataList)
            df_onCoatTeamStatsHome_opponent = df_onCoatTeamStatsHome_opponent.append(getTempDataSeries, ignore_index=True)

    else:
        errorPrint(actionNamedTuple)

記法としてこれがベストかは分かりませんが、ここでは単純にプレイバイプレイの振り分けを行っています。

見てもらえば分かるとは思いますが、

 df_additionalStatsHome : 自スタッツ

 df_onCoatTeamStatsHome : オンコート自チームスタッツ

 df_onCoatTeamStatsHome_opponent : オンコート相手チームスタッツ

となります。(相手チームは Home と Away を読み替え)

 

補足 5:スタッツを計算する(内部関数)

def outputStatsShape(df, df_boxScore):
    df_temp = df.groupby('Num').count()
    df_temp['SCORE'] = df_temp['3Pシュート○'] *3 + df_temp['アウトサイドペイント○'] *2 + df_temp['インサイドペイント○'] *2 + df_temp['フリースロー○']
    df_temp['FGM'] = df_temp['3Pシュート○'] + df_temp['アウトサイドペイント○'] + df_temp['インサイドペイント○']
    # 中略
    df_temp = df_temp.replace([np.inf, -np.inf], np.nan)
    df_temp.reset_index(inplace=True)
    df_temp = pd.merge(df_temp,
                       df_boxScore[['背番号', '選手名1']],
                       left_on='Num',
                       right_on='背番号',
                       how='left')
    df_temp.drop(columns=['背番号'], inplace=True)

    return df_temp

groupby で背番号ごとにカウントして基本的なスタッツ数は取れます。

あとはプレイバイプレイには載らないが欲しいスタッツを計算します。計算上 0 での除算が出てしまうので NumPy を使用して Nan 処理をします。

 

python ver

3.8

 

pip list

Package                   Version
------------------------- ---------
-ip                       20.1.1
altgraph                  0.17
beautifulsoup4            4.9.1
certifi                   2020.6.20
cffi                      1.14.4
chardet                   3.0.4
chromedriver-binary-auto  0.1.1
cryptography              3.3.1
future                    0.18.2
idna                      2.10
ntlm-auth                 1.5.0
numpy                     1.19.2
oauthlib                  3.1.0
pandas                    1.1.2
pefile                    2019.4.18
pip                       20.1.1
pycparser                 2.20
pyinstaller               4.1
pyinstaller-hooks-contrib 2020.10
PySocks                   1.7.1
python-dateutil           2.8.1
pytz                      2020.1
pywin32-ctypes            0.2.0
requests                  2.24.0
requests-ntlm             1.1.0
requests-oauthlib         1.3.0
selenium                  3.141.0
setuptools                47.1.0
six                       1.15.0
soupsieve                 2.0.1
tweepy                    3.10.0
urllib3                   1.25.10

 

import

import os
import csv
import re
from datetime import datetime
import glob
import pandas as pd
import numpy as np
import copy

 

まとめ

放っておくとすぐに書き方を忘れてしまうので、こうした内容の記事も少しずつ(恥ずかしくない範囲で)アップできればなと思います。