and one+

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

【Python】国内プロバスケリーグ「Bリーグ」のスタッツを集計して月間MVPを選定する

「月間MVPを決める」ロジックを自動化した処理についての解説。

今回は処理の流れというより、個別の処理の覚え書きとして記載します。

 

はじめに

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

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

 

謝辞

note.nkmk.me

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

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

 

[対象者]

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

 

[対象レベル]

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

 

処理概要

主に以下の内容で処理を行います。

  1. 対象期間のコンソール入力
  2. スタッツ合計
  3. 評価値計算

 

import

import os
import csv
import pandas as pd
from datetime import datetime
from dateutil.relativedelta import relativedelta
import glob
import pathlib

 

実行本体

def statsCalc():
    startYear,startMonth,endYear,endMonth = inputConsole()

    B1_mergeScore,B2_mergeScore,B1gameCount,B2gameCount = readFiles(startYear,startMonth,endYear,endMonth)

    B1_mergeScore = calcEIOG(B1_mergeScore,endYear,endMonth,B1gameCount)
    B2_mergeScore = calcEIOG(B2_mergeScore,endYear,endMonth,B2gameCount)
    
    subsetList = ['選手ID','チーム名']
    B1_fileName = returnFilePath(outputFolder + startYear + startMonth.zfill(2) + '-' + endYear + endMonth.zfill(2), '/B1_monthlyMVP.csv')
    B2_fileName = returnFilePath(outputFolder + startYear + startMonth.zfill(2) + '-' + endYear + endMonth.zfill(2), '/B2_monthlyMVP.csv')
    writeFile(B1_fileName, B1_mergeScore, subsetList, False, 'wn')
    writeFile(B2_fileName, B2_mergeScore, subsetList, False, 'wn')
    
    print('月度集計の書き出しが完了しました')

各関数を呼び出すプログラム本体です。説明のためここに記載しますが他の関数よりも下に置きます。

各関数は1回しか呼び出されないため本体に全てを書いても全く同じ動作はするのですが、例えば、初期値を別途コマンドライン引数で与えた場合は入力をパスするなどの条件制御を与えたい場合は、本体にif文を書いて関数を呼び出さないようにした方が 私は分かりやすいので 処理は極力分割しています。

subsetList は重複排除に使用するキー列です。(選手IDのみだと月度内でのチーム移籍に対応できないため)

 

対象期間のコンソール入力(InputConsole)

def inputConsole():
    startYear = input('集計対象開始の年を入力してください: ')
    startMonth = input('集計対象開始の月を入力してください: ')
    endYear = input('集計対象終了の年を入力してください: ')
    endMonth = input('集計対象終了の月を入力してください: ')

    if startYear == endYear and startMonth == endMonth:
        print('{0}/{1} の期間を集計します'.format(startYear,startMonth))
    else:
        print('{0}/{1}~{2}/{3} の期間を集計します'.format(startYear,startMonth,endYear,endMonth))
    
    return startYear,startMonth,endYear,endMonth

月間MVPの集計のため月単位の入力を想定。

 

スタッツ合計(readFiles)

スクレイピングした生データから対象のファイルを取得してスタッツを合計します。

 

対象試合の抽出
def readFiles(startYear,startMonth,endYear,endMonth):
    df_infoFile = pd.read_csv(info_file, header=0)
    df_infoFile['試合日'] = pd.to_datetime(df_infoFile['試合日'], format='%Y.%m.%d')
    startDay = datetime.strptime(startYear + '-' + startMonth + '-01','%Y-%m-%d')
    endDay = datetime.strptime(endYear + '-' + endMonth + '-01','%Y-%m-%d') + relativedelta(months=1)
    temp_df = df_infoFile.query('@startDay <= 試合日 < @endDay')
    
    def countGameNum(df):
        s_gameTeamCount = df['ホームチーム名(略式)'].value_counts().add(df['アウェイチーム名(略式)'].value_counts(), fill_value=0)
        print('期間中の試合数 = \n{0}'.format(s_gameTeamCount))
        return s_gameTeamCount

    # B1
    B1_temp_df = temp_df[temp_df['ディビジョン'].str.match('.*B1.*')]
    B1gameIdList = B1_temp_df['試合ID'].values.tolist()
    B1gameCount = countGameNum(B1_temp_df)
    # B2
    B2_temp_df = temp_df[temp_df['ディビジョン'].str.match('.*B2.*')]
    B2gameIdList = B2_temp_df['試合ID'].values.tolist()
    B2gameCount = countGameNum(B2_temp_df)

試合一覧から 期間内に開催された試合を抽出します。

試合一覧の試合日列を日付フォーマットで呼び出し、入力された月度の1日以上翌月1日未満の試合情報を取り出します。月間MVP集計を行いたいために 1日指定 にしていますが、日付も任意に設定できるので少し改変すると柔軟に期間集計できます。

この後の関数内の処理で必要となるのは '試合ID' のみです。

試合数での不公平を解消するため、後ほど評価値を計算する際にチームの試合数を利用しますので計算します。リーグごとに開催スケジュールが異なるので、リーグ単位で持っておきます。

 

ボックススコアの取り出し
    def concatBoxScore(gameIdList):
        df_concat = pd.DataFrame()

        for gameId in gameIdList:
            #-- Box Score を読み込む
            df_boxScore = pd.read_csv(all_files + 'BoxScore_'+ str(gameId) +'.csv',
                                header=0,
                                usecols=lambda x: x not in ['選手名2','FG%','3FG%','FT%'],
                                dtype={'背番号': str})
            df_boxScore = df_boxScore.dropna(subset=['選手ID']).query('ピリオド == "ALL"')
            df_boxScore = df_boxScore.replace({'STARTER': {'〇': 1}}).fillna({'STARTER': '0'})
            df_boxScore = df_boxScore.astype({'出場時間': int})

            # OnCoart +/- とマージ
            df_temp_concat = pd.DataFrame()
            advancedStatsFile = glob.glob(advancedStatsFiles + str(gameId) + '*' + 'ALL_Player-AdvanceStats.csv')
            
            for file in advancedStatsFile:
                df_advancedStats = pd.read_csv(file,
                                    header=0,
                                    usecols=['選手名1','ONpm'])
                df_temp_concat = pd.concat([df_temp_concat, df_advancedStats])
            
            df_boxScore = pd.merge(df_boxScore, df_temp_concat, on=['選手名1'], how='left')
            df_concat = pd.concat([df_concat, df_boxScore])
                
        return df_concat
    
    B1_df = concatBoxScore(B1gameIdList)
    B2_df = concatBoxScore(B2gameIdList)

内部関数で対象の試合IDを基にボックススコアファイルを読み出して、(クォータごとのデータを対象外とし)最終スタッツのみに絞ります。スターターを 1 に直していますがこのプログラム内ではうまく集計できていませんので注意。

評価値に「オンコート+/-」を使用するのでこちらを別途読み出してマージします。「オンコート+/-」はその選手が出場していた間に何点得点し何点失点かを合算したものです(自チーム得点 - 相手チーム得点)。「オンコート+/-」はリーグのボックスコアには含まれませんので別途抽出し計算しました。

ここまでで、期間内の評価対象のスタッツを とりあえずくっつけた データフレームができました。

 

スタッツの合算
    def sumBoxScore(df):
        # 選手IDリスト
        df_nameList = df[['選手ID','チーム名','選手名1','背番号','ポジション']]
        df_nameList.drop_duplicates(subset=['選手ID','チーム名'], keep='last', inplace=True)
        # 合計スコア
        df_temp_score = df.drop(['試合ID','ピリオド','背番号','選手名1','ポジション'], axis=1)
        df_score = df_temp_score.groupby(['選手ID','チーム名']).sum()
        df_score['2FGA'] = df_score['FGA'] - df_score['3FGA']
        df_score['2FGM'] = df_score['FGM'] - df_score['3FGM']
        df_score['2FG%'] = 100 * df_score['2FGM'] / df_score['2FGA']
        df_score['FG%'] = 100 * df_score['FGM'] / df_score['FGA']
        df_score['3FG%'] = 100 * df_score['3FGM'] / df_score['3FGA']
        df_score['FT%'] = 100 * df_score['FTM'] / df_score['FTA']

        df_mergeScore = pd.merge(df_score, df_nameList, on=['選手ID','チーム名'], how='left')
    
        return df_mergeScore
    
    B1_mergeScore = sumBoxScore(B1_df)
    B2_mergeScore = sumBoxScore(B2_df)

    return B1_mergeScore,B2_mergeScore,B1gameCount,B2gameCount

ボックススコアを合算するプログラムを内部関数で用意します。

集計単位を '選手ID' と '所属チーム' とし、集計に不要なデータをいったん避けます。

ボックススコアを集計単位ごとに合計し、それ以外に必要なスタッツを計算します。

 

評価値計算(calcEIOG)

ブログの独自評価値計算として、各スタッツに重みづけをして係数として掛け合わせ すべてのスタッツ評価値を足し合わせた値を「EIOG」と名づけています。(プログラム上にそういう表記がありますよ、というだけの話)

各スタッツに重みづけする理由は、得点は 係数1、リバウンドは 係数0.5 で評価する(= 1リバウンドは1得点の半分くらいの価値)ことで、選手をトータルで評価・比較するためです。上記の重みづけはあくまで例ですが、評価対象としている各スタッツには 0~1 の間の係数がすべてに設定され、CSV で保存されています。

 

試合数差なしの値の計算
def calcEIOG(df,calcYear,calcMonth,gameCount):
    s_EIOG = pd.read_csv(EIOG_Factor,header=0).iloc[0]
    # print('EIOG 係数 = {0}'.format(s_EIOG))

    # 基本値計算
    df['EIOG'] = (df * s_EIOG).sum(axis=1)

まずは、EIOG を保存した CSV を読み出し、ヘッダを除いた1行目の値を Series とします。これを各選手のスタッツに掛け合わせ全てを足したものが基本値(ボーナスポイントなし)となります。

読み出しは1行、Σ も1行で記載が可能。

 

試合数差を考慮した計算

基本的にリーグの試合数進捗は同じなのですが、土日開催がどうしても不可能なチームなどは金土開催になったり日月開催になったりします。このとき、例えば土曜日が月末で日曜日が月初の場合、集計対象月度の開催日数にズレが出てきてしまいます。月間MVPを決めるのに際し、スタッツの差異の要因が「試合数」によって決まるのは不公平が生じるためです。

そこで、各チームの試合数をカウントした最大値が最小値とイコールで無かった場合、最大試合数のチームに合わせて値を修正します。

    # チームごとの試合数差を適用
    maxGameNum = gameCount.max()

    if gameCount.max() != gameCount.min():
        df_fixedEIOG = df
        df_gameCount = pd.DataFrame(gameCount, columns=['試合数'])
        df_fixedEIOG = pd.merge(df_fixedEIOG, df_gameCount, left_on=['チーム名'], right_index=True, how='left')
        df_fixedEIOG['Fixed'] = maxGameNum / df_fixedEIOG['試合数']
        fixedList = ['出場時間','得点','FGM','FGA','3FGM','3FGA','2FGM','2FGA','FTM','FTA','OR','DR','TR','AS','TO','ST','BS','BSR','F','FD','EFF','ONpm']

        for i in fixedList:
            df_fixedEIOG[i] *= df_fixedEIOG['Fixed']
        
        df['FixedEIOG'] = (df_fixedEIOG * s_EIOG).sum(axis=1)
        df['FixedPlayTime'] = df_fixedEIOG['出場時間']
        
    else:
        df['FixedEIOG'] = df['EIOG']
        df['FixedPlayTime'] = df['出場時間']

計算用にスタッツ一覧をコピーし、各チームの試合数を Excel で言う vlookup を行います(pd.merge の左結合)。調整用の係数として  最大チーム試合数/所属チーム試合数を 'Fixed' として設定し、各スタッツにその値を乗算して更新します。

Σの取り方は同じですが、書き込み先はコピーした先のデータフレームではなく、コピー元のデータフレームにします。コピーした先のデータフレームは計算用なのでここで捨てます。

 

ボーナスポイント計算①
    # ボーナスポイント修正
    calcDay = datetime.strptime(calcYear + '-' + calcMonth + '-01','%Y-%m-%d') + relativedelta(months=1)

    playerInfoFiles = glob.glob(playerInfoFolder +'*.csv')
    df_playerInfo = pd.DataFrame()
    
    for file in playerInfoFiles:
        df_tempPlayerInfo = pd.read_csv(file,
                                    header=0,
                                    usecols=['playerID','生年月日'])
        df_playerInfo = pd.concat([df_playerInfo, df_tempPlayerInfo])

    df_playerInfo.drop_duplicates(subset=['playerID'], keep='last', inplace=True)
    df_playerInfo['生年月日'] = pd.to_datetime(df_playerInfo['生年月日'], format='%Y年%m月%d日')

年齢と出場時間に応じてボーナスポイントを加算します。(年齢が若いほどボーナスポイント、出場時間が長いほどボーナスポイント)

年齢が必要になるので、選手の生年月日一覧を CSV で用意しておきます。1ファイルでの精査が面倒だったので、設定フォルダの中にある CSV ファイルはすべて読みます。後ほど完了後のファイルも CSV ファイルにして保存しておきます。

1行目は計算対象の月度の翌月1日を年齢計算基準日とし、それ以降は CSV の読み取りとマージ処理を行っています。

 

ボーナスポイント計算②
    # 年齢計算関数
    def age_calculator(d0, d1=datetime.today()):
        dy = relativedelta(d1, d0)
        return dy.years

    df_playerInfo['年齢'] = df_playerInfo['生年月日'].apply(lambda date: age_calculator(date,calcDay))
    df = pd.merge(df, df_playerInfo, left_on=['選手ID'], right_on=['playerID'], how='left').drop(columns=['playerID','生年月日'])
    df_noneAge = df[df['年齢'].isnull()]

    if int(df['年齢'].isnull().sum()) >= 1:
        for playerTuple in df_noneAge.itertuples():
            print('選手名 = {0}({1}) を取得してください'.format(playerTuple.選手名1, playerTuple.選手ID))
    else:
        pass

    df_playerInfo['生年月日'] = df_playerInfo['生年月日'].dt.strftime('%Y年%m月%d日')
    df_playerInfo.to_csv(tempPlayerInfoFile, index=False, encoding='utf8', sep=',', mode='w')

    agesMed = df['年齢'].median()
    playtimeMed = df['FixedPlayTime'].median()
    # 年齢取得できない場合の仮調整
    df = df.fillna({'年齢': agesMed})

年齢計算はラムダ関数として内部関数を呼び出しています。基準日が取れないことはないと思いますが、エラー除外用に念のため計算した当日を仕込みます。

上記は一時的なデータフレームを計算後にマージしていますが、本来は必要なデータに絞って年齢を計算するほうが 使わない余分な計算をする必要がなくなると思います(今気づきました)。ただ、その後すぐにある生年月日が CSV に登録していない時の NaN 処理を考慮する必要があるので、いったん内部結合して「生年月日データが存在する集計対象の選手」に絞るなどの処理が必要となりそうです。

生年月日が CSV に登録されていない選手は警告を出します(if~for)。

ボーナスポイントの計算に年齢と出場時間の中央値を使用するので計算します。ついでに生年月日の登録が無かった選手を 仮に年齢の中央値で fillna します。

 

ボーナスポイント計算③
    df['WeightingEIOG'] = df['FixedEIOG'] * ( 1+ ( df['FixedPlayTime'] / playtimeMed ) * ( agesMed / df['年齢'] ) / 10 )
    df['RANK'] = df['WeightingEIOG'].rank(ascending=False, method='min')

    # ヘッダーを読み込む
    with open(headerFile, "r", newline='', encoding='utf-8') as f:
        reader = csv.reader(f)
        header = reader.__next__()  # ヘッダーの読み込み
    f.close()

    df = df.reindex(columns=header)

    return df

ボーナスポイントつき EIOG を計算します。ちなみに計算式に何か後ろ盾があるわけではなく、なにかボーナスが欲しいなと私が思ったからです。

最後に列名を更新します。こちらも予め CSV ファイルを用意しておき、ヘッダを読み出して適用します。プログラム内に直書きするよりも視認性が高いのでお勧めです。

 

メイン呼び出し

if __name__ == '__main__':
    # config
    info_file = '../processTarget/GameInfo/gameInfo.csv'
    all_files = '../processTarget/BoxScores/'
    advancedStatsFiles = '../processedData/advancedGameStats_Player/advancedStats/'
    EIOG_Factor = '../settings/form/EIOG.csv'
    playerInfoFolder = '../processTarget/BasedInfoPlayer/'
    tempPlayerInfoFile = '../processTarget/BasedInfoPlayer/writingPlayerInfo.csv'
    headerFile = '../settings/form/monthlyMVP.csv'
    outputFolder = '../processedData/monthlyMVP/'
    

    # 実行ファイル
    statsCalc()

最も下に配置されるプログラム。

コンフィグの配置と本体のコールのみ。

Git をかじり始めたので、実行環境ごとにパスが異なることを意識しています。相対パス表記にして実行時に必要に際して正規化する方法を取っています。

 

ここまでがプログラムの主要な解説です。

ほか 副次的な外部関数として2つ用意しています。

  • 相対ファイルパスの正規化
  • CSVファイルの書き込み

 

相対ファイルパスの正規化

def returnFilePath(path, filename):
    base = os.path.dirname(os.path.abspath(__file__))
    name = os.path.normpath(os.path.join(base, path + filename))

    return name

必須ではありませんが、相対パスではエラーとなる箇所がありますので、絶対パスを取得する関数を用意します。

 

CSV ファイルの書き込み

def writeFile(originalFile, writeDataFrame, subsetList, indexMode, writeMode):
    # 親フォルダが無ければ作成する
    os.makedirs(pathlib.Path(originalFile).parent, exist_ok=True)
    # 重複排除
    writeDataFrame.drop_duplicates(subset=subsetList, inplace=True)
    
    if os.path.isfile(originalFile):
        df_temp = pd.read_csv(originalFile, header=0)
        df = pd.concat([df_temp, writeDataFrame])
        # 新しいほうが正(Write New)
        if writeMode == 'wn':
            df.drop_duplicates(subset=subsetList, keep='last', inplace=True)
            df.to_csv(originalFile, index=indexMode, encoding='utf8', sep=',', mode='w')
        # 古いほうが正(Write Old)
        elif writeMode == 'wo':
            df.drop_duplicates(subset=subsetList, keep='first', inplace=True)
            df.to_csv(originalFile, index=indexMode, encoding='utf8', sep=',', mode='w')
        # 新しいデータを追記(未作成)
        elif writeMode == 'a':
            pass
        else:
            pass
    else:
        writeDataFrame.to_csv(originalFile, index=indexMode, encoding='utf8', sep=',')

別記事で書いたそのままのものです(作りかけなのもそのまま)。

subsetList で指定した列を使用して列の重複排除、さらにファイルの上書き時にも重複排除を加えています。Bリーグのデータは頻繁に変化点(選手のチーム移籍など)があり かつ過去履歴を捉える必要があるため、重複排除のキー列は重要となります。

 

実行環境

python ver

3.8

 

pip list

Package                   Version
------------------------- ---------
-ip                       20.1.1
altgraph                  0.17
attrs                     19.3.0
beautifulsoup4            4.9.1
certifi                   2020.6.20
cffi                      1.14.4
chardet                   3.0.4
chromedriver-binary-auto  0.1.1
colorama                  0.4.4
configparser              5.1.0
crayons                   0.4.0
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
twitter-text-parser       2.0.0
urllib3                   1.25.10
webdriver-manager         3.5.2

 

まとめ

今のところ全体で250 行ぐらいで書けて、比較的すっきりした印象。

忘れないうちに次を書いていきたい。