データ取得から機械学習モデル作成まで全て行ってマンチェスターユナイテッドの得点数を予測してみた

2024/04/02に公開

※このブログはAidemy Premiumのカリキュラムの一環で、受講修了条件を満たすために公開しています

はじめに

私は昔からサッカーが好きで、学生時代はサッカー部に所属してサッカー漬けの日々を過ごし、大人になってからは実際にプレイすることはなくなりましたが、今でも毎週欠かさず試合を見ています。

サッカーというのは1点の重みが非常に大きなスポーツで、基本的に2点取ることができれば大体の試合で負けることはありません。

だからこそ得点が非常に重要になっているのですが、優勢なチームの点が取れず、劣勢のチームが点を取って勝つなど、試合の流れによって予想外の結果になることは珍しくありません。
そんな試合を見るたびに、「選手のパフォーマンスや試合の状況から得点数を予測できないのか」ということをずっと疑問に思っていました。

そこで今回は、 各選手のシュート数、パス数、出場時間などのスタッツから、その試合の得点数予測するモデルを作成してみたいと思います。
長年の疑問に答えられるような結果が出せればうれしいです。

なお、分析環境はこちらにrequirements.txtを貼っていますので適宜参照してください。
私はvscodeでjupyterを使うという変なことをしているので少し汚れていますが、基本的には本分析で使うものしかインストールしていません。

requirements.txt

asttokens==2.4.1
attrs==23.2.0
beautifulsoup4==4.12.3
certifi==2024.2.2
cffi==1.16.0
colorama==0.4.6
comm==0.2.2
contourpy==1.2.0
cycler==0.12.1
debugpy==1.8.1
decorator==5.1.1
executing==2.0.1
fonttools==4.50.0
h11==0.14.0
idna==3.6
ipykernel==6.29.4
ipython==8.22.2
jedi==0.19.1
joblib==1.3.2
jupyter_client==8.6.1
jupyter_core==5.7.2
kiwisolver==1.4.5
lightgbm==4.3.0
matplotlib==3.8.3
matplotlib-inline==0.1.6
nest-asyncio==1.6.0
numpy==1.26.4
outcome==1.3.0.post0
packaging==24.0
pandas==2.2.1
parso==0.8.3
pillow==10.2.0
platformdirs==4.2.0
prompt-toolkit==3.0.43
psutil==5.9.8
pure-eval==0.2.2
pycparser==2.21
Pygments==2.17.2
pyparsing==3.1.2
PySocks==1.7.1
python-dateutil==2.9.0.post0
pytz==2024.1
pywin32==306
pyzmq==25.1.2
scikit-learn==1.4.1.post1
scipy==1.12.0
seaborn==0.13.2
selenium==4.19.0
six==1.16.0
sniffio==1.3.1
sortedcontainers==2.4.0
soupsieve==2.5
stack-data==0.6.3
threadpoolctl==3.4.0
tornado==6.4
traitlets==5.14.2
trio==0.25.0
trio-websocket==0.11.1
typing_extensions==4.10.0
tzdata==2024.1
urllib3==2.2.1
wcwidth==0.2.13
wsproto==1.2.0

先行研究の調査

オッズ

サッカーの母国であるイングランドはベッティングの文化があるので、試合結果や得点数、失点数を予想してオッズを決定するという取り組みは古くから行われています。
サッカーベッティングのオッズは、主に以下の要因を考慮して決定されます。

  1. チームの実力差: 両チームの過去の成績、現在のフォーム、ランキングなどを比較し、勝敗の可能性を予測します。
  2. 主力選手の状態: 怪我や出場停止などにより、主力選手が欠場する場合、チームの勝率に影響を与えます。
  3. ホームアドバンテージ: ホームチームはアウェイチームよりも有利とされ、オッズに反映されます。
  4. 天候や会場の状況: 悪天候や不慣れなピッチコンディションは、チームのパフォーマンスに影響を与える可能性があります。
  5. 過去の対戦成績: 両チームの直接対決の結果も考慮されます。
  6. ベッターの行動: 多くのベッターが一方のチームに偏ってベットした場合、ブックメーカーはリスク分散のためにオッズを調整することがあります。
  7. 専門家の意見: サッカーアナリストや専門家の予想も、オッズ設定に影響を与える場合があります。

これらの要因を総合的に判断し、ブックメーカーはオッズを設定します。

xG(ゴール期待値)

最近は xG(ゴール期待値) という指標がよく使われています。
xGは「エクスペクテッドゴールズ(Expected Goals)」の略で、サッカーにおいてシュートがゴールになる確率を数値化したものです。
xGの値は数値が大きいほどゴールの可能性が高いことを示します。

xGは以下のような要素から計算されます。

  • シュートの位置(ピッチ上のどこから打たれたか)
  • シュートの角度(ゴールに対する角度)
  • 守備プレイヤーの数(シューターとゴールの間にいる守備選手の数)
  • シュートの種類(ヘディング、足でのシュートなど)
  • アシストの種類(セットプレイからのものか、オープンプレイからのものかなど)

このように、そのシュートがどのような場面で実行されたものなのかという情報から期待値を計算しています。
xGはそれ自体で比較されることもありますが、xGと実際のゴール数を比較して、
「このチームxGでは1試合平均1.5点だが、実際は1点しか取れていないので運に見放されている」というようにチーム状況を説明する際に使われることも多いです。

ちなみに、ある程度サッカーに詳しい人のためにxGの例をご紹介します。
ペナルティーのxGが思ったより低いことに驚くのではないでしょうか。

  • ペナルティーのxG: 0.783
  • ビックチャンスのxG: 0.387
  • ボックス内のその他のシュートのxG: 0.070
  • ボックス外からのシュートのxG: 0.036

Andrew Beasleyの2017年の記事ではxGを使ったゴール数の予想モデルについて説明されています。
その記事ではゴール数の予測の難しさについて以下のように述べられています。

  • プレミアリーグでの過去5シーズンの1試合平均ゴール数が2.73得点であることを考慮するとゴールは比較的まれな事象であり、運やボールのはずみ、審判の判断に左右されやすい
  • ある試合結果の最大で50%が運、ボールのはずみ、審判によって左右される
  • そのため予測にはより多くのデータサンプル数が必要
  • ゴール数の平均値だけでなく、ゴール枠内シュートや合計シュート数などのデータを活用する必要がある
  • 2016/17シーズンのデータとポアソン分布を用いて、プレミアリーグ第29節の試合結果とオッズを予測したところ、本命チームが勝利したのは10試合中6試合。
  • 選手の負傷、マネージャーの交代、ヨーロッパでのプレーによる疲労などの要因は考慮されていない
  • 約半分の試合では2.5ゴール未満となるため、高得点試合の予測が難しい場合がある

ここからわかるように、xGはシュートの状況に重きを置いているため、選手の疲労、審判、開催地、ボールポゼッションなどの要素は考慮されていません。

オッズは様々な要素を総合的に判断して決められるため、試合結果の予測には一定の信頼性がありますが、あくまで確率的なものです。
一方、xGは各シュートのゴール確率を積み上げたもので、シュートの質を評価するには優れた指標ですが、選手の疲労や調子、相性など、他の重要な要素は考慮されていません。
本分析では、xGで考慮されていない要因もデータに取り入れることで、より精度の高い得点予測を目指します。

分析の方針

本研究では、マンチェスターユナイテッドの過去5シーズンの試合データを用いて、得点数を予測するモデルを構築します。
得点数は離散的な数値ですが、0点から4点以上まで幅があるため、回帰分析の手法を用います。

評価指標としてMAE(平均絶対誤差)を使用したいと思います。
予測値と実際の値の差を直感的に理解しやすいためです。
MAEは以下の計算式であらわされる指標で、予測値と実際の値の誤差を計測する指標です。

\text{MAE} = \frac{1}{n} \sum_{i=1}^{n} |y_i - \hat{y}_i|

また、モデルの汎化性能を評価するため、テストデータだけでなく、モデルにとって未知のデータも用意します。
2024/1/1以降の試合を未知のデータとして準備し、それより前の試合をモデルに学習させます。

ちなみに未知データにおける実際のゴール数とxGのMAEは0.94だったので、0.94よりもいい数字が出るかを精度を評価する基準にしていきます。

データの準備

サイトの構造を確認

プレミアリーグのスタッツをまとめているサイトは多くありますが、FBref.comのサイトが一番表形式で情報を取得しやすいので、こちらを利用させてもらいます。

スクレイピングのために、まずは取得したいデータがどこにあるか、どんなHTMLの構造をしているか把握します。
マンチェスターユナイテッドのホームページにアクセスします。
ページをみると様々な表が並んでいますが、今回は試合ごとのデータを全部取得したいので、中段にあるScores & Fixturesという表を利用します。

まずこの表にあるデータで、開催日、時間、ホーム&アウェイ、結果、得点数などの概観がわかります。

次に右端のMatch Reportをクリックしてみます。
遷移後のページを少し下にスクロールすると、Player Statsの表が現れます。

ここに選手ごとの詳細なデータが表示されているのでこのデータも使用したいと思います。
また、表の上部にある、Summary、Passing等のタブをクリックすると別のその他のデータも表示されるので、これらも取得したいと思います。

それでは今回のスクレイピングの方針をまとめてみます。

  1. ブラウザー(今回はEdge)を起動して、マンチェスターユナイテッドのホームページにアクセス。
  2. Scores&Fixturesのテーブルで1行目の試合の概観データを取得する。
  3. 概観データを保持したままMatch Reportリンクをクリックして詳細ページに遷移する。
  4. Player Statsのデータを取得し、概観のデータを連結する。
  5. ホームページに戻って2行目の概観データを取得する。
  6. 以下繰り返し

こんな感じですね。

スクレイピングの実装

では早速、完成したスクレイピングのコードはこちらになります。

from selenium import webdriver
import time
from bs4 import BeautifulSoup
import pandas as pd


# EdgeDriverを起動
options = webdriver.EdgeOptions()
# ブラウザのGUIを表示せずに動作させる
options.add_argument('--headless')
driver = webdriver.Edge(options=options)
#driver = webdriver.Edge()

# スクレイピング対象のURLを指定
url = 'https://fbref.com/en/squads/19538871/2023-2024/all_comps/Manchester-United-Stats-All-Competitions'

# URLにアクセス
driver.get(url)

# テーブル要素が表示されるまで待機
time.sleep(5)

# BeautifulSoupオブジェクトを作成
soup = BeautifulSoup(driver.page_source, 'html.parser')

# テーブルを取得
table = soup.find('div', {'id': 'div_matchlogs_for'})

# 結果を格納するデータフレームを初期化
df_main = pd.DataFrame()

# テーブルの行を順番に処理
for row in table.find_all('tr'):
    row_data = {}
    
    # 行内の各セルからデータを取得
    for cell in row.find_all(['th', 'td']):
        if cell.name == 'th' and cell.has_attr('data-stat'):
            row_data[cell['data-stat']] = cell.text.strip()
        elif cell.name == 'td' and cell.has_attr('data-stat'):
            row_data[cell['data-stat']] = cell.text.strip()
    
    # "Match Report"リンクを取得
    match_report_link = row.find('a', string='Match Report')
    
    if match_report_link:
        link_url = match_report_link['href']
        # 新しいタブを開いてリンクを開く
        driver.execute_script(f"window.open('{link_url}', '_blank');")
        # 新しいタブに切り替え
        driver.switch_to.window(driver.window_handles[-1])
        
        # 新しいタブでのスクレイピング処理を行う
        try:
            # ページが完全に読み込まれるまで待機
            time.sleep(5)
            MUN_base_element = 'stats_19538871_'
            target_element = ['summary', 'passing', 'passing_types', 'defense', 'possesion', 'misc']
            
            df_match = pd.DataFrame()
            
            # 指定された要素をループで処理
            for element in target_element:
                time.sleep(3)  # クリック後の待機時間を適宜調整
                print(element)
                # 新しいタブのHTMLを取得
                new_tab_html = driver.page_source
                new_tab_soup = BeautifulSoup(new_tab_html, 'html.parser')
                print(driver.title)
                # 指定された要素のテーブルを取得
                tables = new_tab_soup.find('table', {'id': MUN_base_element + element})
                
                if tables is not None:
                    # テーブルのヘッダー情報を取得
                    headers = []
                    for th in tables.find_all('th'):
                        if 'data-stat' in th.attrs:
                            headers.append(th['data-stat'])
                        else:
                            pass

                    # テーブルの各行からデータを取得
                    data = []
                    for table_row in tables.find_all('tr'):
                        table_row_data = {}
                        for header in headers:
                            cell = table_row.find('td', attrs={'data-stat': header})
                            if cell:
                                table_row_data[header] = cell.text.strip()
                        
                        # <th>要素からplayerを取得してデータフレームに追加
                        player_cell = table_row.find('th', attrs={'data-stat': 'player'})
                        if player_cell:
                            player_link = player_cell.find('a')
                            if player_link:
                                table_row_data['player'] = player_link.text.strip()
                        
                        if table_row_data:
                            data.append(table_row_data)
                            
                    # 一時的なデータフレームを作成し、メインのデータフレームに連結
                    temp_df = pd.DataFrame(data)
                    df_match = pd.concat([df_match, temp_df], axis=1)
            
            # match reportごとのループの最後に、最初のページから取得したデータを連結
            match_report_df = pd.DataFrame(row_data, index=df_match.index)
            df_match = pd.concat([match_report_df, df_match], axis=1)
            df_main = pd.concat([df_main, df_match], axis=0)
        
        except Exception as e:
            print(f"スクレイピング中にエラーが発生しました: {e}")

        
        # 新しいタブを閉じる
        driver.close()
        # 元のタブに戻る
        driver.switch_to.window(driver.window_handles[0])
        
# EdgeDriverを終了
driver.quit()

少しずつ分けて説明していきます。

# ライブラリのインポート
from selenium import webdriver
import time
from bs4 import BeautifulSoup
import pandas as pd

まずはライブラリのインポートです。
今回はリンクをクリックする動作も必要なので、seleniumもインポートしておきます。

# EdgeDriverを起動
options = webdriver.EdgeOptions()
options.add_argument('--headless')# ブラウザのGUIを表示せずに動作させる
driver = webdriver.Edge(options=options)

# スクレイピング対象のURLを指定
url = 'https://fbref.com/en/squads/19538871/2023-2024/all_comps/Manchester-United-Stats-All-Competitions'

# URLにアクセス
driver.get(url)

# テーブル要素が表示されるまで待機(秒数は適宜修正)
time.sleep(5)

# BeautifulSoupオブジェクトを作成
soup = BeautifulSoup(driver.page_source, 'html.parser')

# テーブルを取得
table = soup.find('div', {'id': 'div_matchlogs_for'})

次にEdgeを起動してURLにアクセスします。
options--headlessを指定するとブラウザのGUIが立ち上がらなくなります。

実際に動かすときは煩わしいので非表示をおすすめしますが、テストの段階ではこのオプションはオフにしてGUIの画面で挙動を確認してもいいと思います。
テーブルが表示されるまでの秒数はご自身の環境によると思うので適宜修正が必要です。

開発者ツールで確認すると、ここで取得したいテーブルはdiv_mathclogs_forというidを持っていることが分かったので、soup.findでテーブルを取得します。

次に具体的に表の内容を取得します。

# テーブルの行を順番に処理
for row in table.find_all('tr'):
    row_data = {}  # 行データを格納する辞書を初期化
    
    # 行内の各セルからデータを取得
    for cell in row.find_all(['th', 'td']):
        # セルがヘッダー(<th>タグ)であり、'data-stat'属性を持つ場合
        if cell.name == 'th' and cell.has_attr('data-stat'):
            # 'data-stat'属性の値をキーとし、セルのテキストを値として辞書に追加
            row_data[cell['data-stat']] = cell.text.strip()
        
        # セルがデータセル(<td>タグ)であり、'data-stat'属性を持つ場合
        elif cell.name == 'td' and cell.has_attr('data-stat'):
            # 'data-stat'属性の値をキーとし、セルのテキストを値として辞書に追加
            row_data[cell['data-stat']] = cell.text.strip()

注意点として、<th><td>を単純に取得すると不要なものまで取得されてしまうことがあったので、data-stat属性を持つという条件を追加しています。

少し細かいですが、自分がこの箇所で結構躓いたので、詳細な解説を残しておきます。

  1. for row in table.find_all('tr'):
    • このループは、table変数に格納されたBeautifulSoupオブジェクトから、すべての<tr>タグ(テーブルの行)を見つけ出し、それぞれの行をrow変数に代入します。
  2. for cell in row.find_all(['th', 'td']):
    • このループは、現在の行(row)内のすべての<th>タグ(テーブルのヘッダーセル)と<td>タグ(テーブルのデータセル)を見つけ出し、それぞれのセルをcell変数に代入します。
  3. if cell.name == 'th' and cell.has_attr('data-stat'):
    • このif文は、現在のセル(cell)がヘッダーセル(<th>タグ)であり、'data-stat'属性を持つ場合に実行されます。
  4. row_data[cell['data-stat']] = cell.text.strip()
    • ヘッダーセルの場合、'data-stat'属性の値をキーとし、セルのテキストを値としてrow_data辞書に追加します。strip()メソッドは、セルのテキストの前後の空白を削除します。
  5. elif cell.name == 'td' and cell.has_attr('data-stat'):
    • このelif文は、現在のセル(cell)がデータセル(<td>タグ)であり、'data-stat'属性を持つ場合に実行されます。
  6. row_data[cell['data-stat']] = cell.text.strip()
    • データセルの場合、'data-stat'属性の値をキーとし、セルのテキストを値としてrow_dataに追加します。

次に先ほど取得したrowからMatch Reportの文字を持つリンクを取得しています。
そして、新しいタブでリンクを開いたうえで、新しいタブにカーソルを移動します。

    # "Match Report"リンクを取得
    match_report_link = row.find('a', string='Match Report')
    
    if match_report_link:
        link_url = match_report_link['href']
        # 新しいタブを開いてリンクを開く
        driver.execute_script(f"window.open('{link_url}', '_blank');")
        # 新しいタブに切り替え
        driver.switch_to.window(driver.window_handles[-1])

次に新しく開いたタブで目的の表データにアクセスします。

        # 新しいタブでのスクレイピング処理を行う
        try:
            # ページが完全に読み込まれるまで待機
            time.sleep(5)
            MUN_base_element = 'stats_19538871_'
            target_element = ['summary', 'passing', 'passing_types', 'defense', 'possesion', 'misc']
            
            df_match = pd.DataFrame()
            
            # 指定された要素が存在する場合はクリックする
            for element in target_element:
                time.sleep(3)  # クリック後の待機時間を適宜調整
                print(element)
                # 新しいタブのHTMLを取得
                new_tab_html = driver.page_source
                new_tab_soup = BeautifulSoup(new_tab_html, 'html.parser')
                print(driver.title)
                tables = new_tab_soup.find('table', {'id': MUN_base_element + element})

MUN_base_element = 'stats_19538871_'が重要です。
ページの構成を調べたところ、マンチェスターユナイテッドの表はすべて、stats_19538871_という一意のIDを持っていることがわかりました。
その後ろにsummaryなどをつなげることで表が特定されます。

なので、target_element = ['summary', 'passing', 'passing_types', 'defense', 'possesion', 'misc']を用意して、
tables = new_tab_soup.find('table', {'id': MUN_base_element + element})に順番にelementを代入することで、今回取得したいテーブルを取得することができます。
これはどの年度の、どの試合のページでも共通です。

これ以降は表の中身を取得するだけです。

                if tables is not None:
                    headers = []
                    for th in tables.find_all('th'):
                        if 'data-stat' in th.attrs:
                            headers.append(th['data-stat'])
                        else:
                            pass
                    
                    data = []
                    for table_row in tables.find_all('tr'):
                        table_row_data = {}
                        for header in headers:
                            cell = table_row.find('td', attrs={'data-stat': header})
                            if cell:
                                table_row_data[header] = cell.text.strip()
                        
                        # <th>要素からplayerを取得してデータフレームに追加
                        player_cell = table_row.find('th', attrs={'data-stat': 'player'})
                        if player_cell:
                            player_link = player_cell.find('a')
                            if player_link:
                                table_row_data['player'] = player_link.text.strip()
                        
                        if table_row_data:
                            data.append(table_row_data)
                    
                    temp_df = pd.DataFrame(data)
                    df_match = pd.concat([df_match, temp_df], axis=1)
            
            # match reportごとのループの最後に、最初のページから取得したデータを連結
            match_report_df = pd.DataFrame(row_data, index=df_match.index)
            df_match = pd.concat([match_report_df, df_match], axis=1)
            df_main = pd.concat([df_main, df_match], axis=0)
        
        except Exception as e:
            print(f"スクレイピング中にエラーが発生しました: {e}")

        
        # 新しいタブを閉じる
        driver.close()
        # 元のタブに戻る
        driver.switch_to.window(driver.window_handles[0])
        
# EdgeDriverを終了
driver.quit()

Playerという列だけ少し構造が違うので、別のループで取得して連結しています。
最後に、最初のページで取得したデータを連結して一つのループが完了です。
ループが終わったらデータフレームをCSVに保存しておきます。

ネットワーク環境によって変わると思いますが、ひとつの年度を実行するのに大体30分くらいかかります。

また、https://fbref.com/en/squads/19538871/2023-2024/all_comps/Manchester-United-Stats-All-Competitions2023-2024の部分を2022-2023のように変更することで、前年度のデータも取得できます。

今回私は過去5シーズンのデータを取得しておきましたので、このデータをもとに分析を開始したいと思います。

データ探索

データの読み込み

まずはデータの読み込みです。
5年分のデータを別々のデータフレームにしたうえで、行列数を確認してみます。

import pandas as pd
# データの読み込み
current_df = pd.read_csv(r'C:\dev\23_24_MUN_player_stats.csv', encoding='utf-8')
previous_df1 = pd.read_csv(r'C:\dev\22_23_MUN_player_stats.csv', encoding='utf-8')
previous_df2 = pd.read_csv(r'C:\dev\21_22_MUN_player_stats.csv', encoding='utf-8')
previous_df3 = pd.read_csv(r'C:\dev\20_21_MUN_player_stats.csv', encoding='utf-8')
previous_df4 = pd.read_csv(r'C:\dev\19_20_MUN_player_stats.csv', encoding='utf-8')

# データの行列数を確認
print(f"23-24:{current_df.shape}")
print(f"22-23:{previous_df1.shape}")
print(f"21-22:{previous_df2.shape}")
print(f"20-21:{previous_df3.shape}")
print(f"19-20:{previous_df4.shape}")
# 出力結果
23-24:(544, 144)
22-23:(785, 144)
21-22:(688, 143)
20-21:(783, 143)
19-20:(671, 143)

プレミアリーグは基本的に8月に始まって翌年5月に終わります。
リーグ戦は38試合あって、それ以外の国内、欧州のカップ戦もあるので、マンチェスターユナイテッドの場合は50~60試合程度あるのが一般的です。

今回取得したデータは出場選手ごとのデータになるので、検算してみましょう。
1試合の先発は11人で、平均して3人選手交代すると考えると1試合平均14行のデータになります。
各年度のデータ数を14で割ってあげると大体50~60くらいの間に収まるので今回取得した5年分のデータは特におかしな点はないと考えていいでしょう。
※それまでは1試合3人までの交代枠でしたが、コロナ期間から5人程度に増えました。

次にすべてのデータを連結します。

#データを縦に連結
all_df = pd.concat([current_df, previous_df1, previous_df2, previous_df3, previous_df4])
print(all_df.shape)
# 出力結果
(3471, 144)

すべてのデータを縦に連結してall_dfを作成しました。
今後はall_dfをベースに分析していきます。

次に取得したデータのカラムについて確認してみます。

# カラム名を取得して出力
print(list(all_df.columns))
# 出力結果
[ 'date', 'start_time', 'comp', 'round', 'dayofweek', 'venue', 'result', 'goals_for', 'goals_against', 'opponent', 'xg_for', 'xg_against', 'possession', 'attendance', 'captain', 'formation', 'referee', 'match_report', 'notes', 'shirtnumber', 'nationality', 'position', 'age', 'minutes', 'goals', 'assists', 'pens_made', 'pens_att', 'shots', 'shots_on_target', 'cards_yellow', 'cards_red', 'touches', 'tackles', 'interceptions', 'blocks', 'xg', 'npxg', 'xg_assist', 'sca', 'gca', 'passes_completed', 'passes', 'passes_pct', 'progressive_passes', 'carries', 'progressive_carries', 'take_ons', 'take_ons_won', 'player', 'shirtnumber.1', 'nationality.1', 'position.1', 'age.1', 'minutes.1', 'passes_completed.1', 'passes.1', 'passes_pct.1', 'passes_total_distance', 'passes_progressive_distance', 'passes_completed_short', 'passes_short', 'passes_pct_short', 'passes_completed_medium', 'passes_medium', 'passes_pct_medium', 'passes_completed_long', 'passes_long', 'passes_pct_long', 'assists.1', 'xg_assist.1', 'pass_xa', 'assisted_shots', 'passes_into_final_third', 'passes_into_penalty_area', 'crosses_into_penalty_area', 'progressive_passes.1', 'player.1', 'shirtnumber.2', 'nationality.2', 'position.2', 'age.2', 'minutes.2', 'passes.2', 'passes_live', 'passes_dead', 'passes_free_kicks', 'through_balls', 'passes_switches', 'crosses', 'throw_ins', 'corner_kicks', 'corner_kicks_in', 'corner_kicks_out', 'corner_kicks_straight', 'passes_completed.2', 'passes_offsides', 'passes_blocked', 'player.2', 'shirtnumber.3', 'nationality.3', 'position.3', 'age.3', 'minutes.3', 'tackles.1', 'tackles_won', 'tackles_def_3rd', 'tackles_mid_3rd', 'tackles_att_3rd', 'challenge_tackles', 'challenges', 'challenge_tackles_pct', 'challenges_lost', 'blocks.1', 'blocked_shots', 'blocked_passes', 'interceptions.1', 'tackles_interceptions', 'clearances', 'errors', 'player.3', 'shirtnumber.4', 'nationality.4', 'position.4', 'age.4', 'minutes.4', 'cards_yellow.1', 'cards_red.1', 'cards_yellow_red', 'fouls', 'fouled', 'offsides', 'crosses.1', 'interceptions.2', 'tackles_won.1', 'pens_won', 'pens_conceded', 'own_goals', 'ball_recoveries', 'aerials_won', 'aerials_lost', 'aerials_won_pct', 'player.4']

144列もあるので見づらいですが、goals_forという列が自チームの得点数を示すので、これを目的変数とします。

また、shirtnumber.1shirtnumber.2のように番号がついているものがありまが、実はこの列は重複しています。
試合の詳細データを取得するときに同じ列があったものを特に処理をせずに連結したので、このように重複してしまっています。

数字がついているものは基本的に同じデータが入っているので、1だけ残して他の列は除外したいと思います。

# 列の重複を処理するための処理
# 元のデータフレームの列名を取得
columns = all_df.columns

# 重複している列の数を格納する変数を初期化
duplicate_count = 0

# 新しい列名と対応する値を格納する辞書を初期化
new_columns_dict = {}

# 処理された列名を格納するリストを初期化
removed_columns = []

# 各列名について処理を行う
for col in columns:
    if '.' in col:
        # ドットを含む列名の場合、ドットより前の部分を新しい列名とする
        new_col = col.split('.')[0]
        if new_col in new_columns_dict:
            # 新しい列名がすでに辞書に含まれている場合、重複カウントを増やす
            duplicate_count += 1
            # 処理された列名をリストに追加
            removed_columns.append(col)
        else:
            # 新しい列名がまだ辞書に含まれていない場合のみ、辞書に追加
            new_columns_dict[new_col] = all_df[col]
    else:
        # ドットを含まない列名の場合、重複チェックを行う
        if col in new_columns_dict:
            # 列名がすでに辞書に含まれている場合、重複カウントを増やす
            duplicate_count += 1
            # 処理された列名をリストに追加
            removed_columns.append(col)
        else:
            # 列名がまだ辞書に含まれていない場合のみ、辞書に追加
            new_columns_dict[col] = all_df[col]

# 重複している列の数を出力
print(f"重複している列の数: {duplicate_count}")


# 新しい列名と値を使ってデータフレームを作成
all_df = pd.DataFrame(new_columns_dict)
print(f"処理後の行列数:{all_df.shape}")

# 削除された列名を出力
print("処理された列名:")
for col in removed_columns:
    print(col)

このコードでは、ドット(.)を含む列名に対して、ドットより前の部分で重複をチェックし、ドットを含まない列名に対してはそのまま重複をチェックします。
なので、例えば列名が shirtnumbershirtnumber.1shirtnumber.2 のようにある場合、shirtnumber が採用され、ドット以降が異なる列は重複とみなされ、削除または統合されます。

# 出力結果
重複している列の数: 40 
処理後の行列数:(3471, 104) 
処理された列名: shirtnumber.1 nationality.1 position.1 age.1.....

出力結果を見ると40行が重複行として削除されています。
処理された列名を見ると、適切に重複している列が削除されていそうです。

それでは具体的にデータの中身を見ていきたいと思います。

データ型

まずはデータ型についてみていきます。
データ型によっては欠損値の処理方法も変わってきます。

int型やfloat型は特に問題ないですが、object型のデータはモデル作成時に注意が必要なのでよく見ていきます。

# dtypeがobjectのカラムのみ出力
object_columns = all_df.select_dtypes(include=['object'])
object_columns.info()
<class 'pandas.core.frame.DataFrame'>
Index: 3471 entries, 0 to 670
Data columns (total 19 columns):
 #   Column        Non-Null Count  Dtype 
---  ------        --------------  ----- 
 0   date          3471 non-null   object
 1   start_time    3471 non-null   object
 2   comp          3471 non-null   object
 3   round         3471 non-null   object
 4   dayofweek     3471 non-null   object
 5   venue         3471 non-null   object
 6   result        3471 non-null   object
 7   opponent      3471 non-null   object
 8   attendance    2591 non-null   object
 9   captain       3471 non-null   object
 10  formation     3471 non-null   object
 11  referee       3471 non-null   object
 12  match_report  3471 non-null   object
 13  notes         339 non-null    object
 14  nationality   3247 non-null   object
 15  position      3247 non-null   object
 16  age           3247 non-null   object
 17  minutes       3471 non-null   object
 18  player        3247 non-null   object
dtypes: object(19)
memory usage: 542.3+ KB

少し気になるのはdatestart_timeattendanceageminutesです。

まず、dateですが、これは日付なのでdatetime型にしたいと思います。

# dateを日付型に変換
all_df['date'] = pd.to_datetime(all_df['date'], format='mixed')
all_df['date'].info()

日付の型が認識されないときは、日付の形式がyyyy/mm/ddやyyyy-mm-ddが混在していることが原因になっていることが多いので、format引数にmixedを指定します。

出力結果は以下です。

<class 'pandas.core.series.Series'>
Index: 3471 entries, 0 to 670
Series name: date
Non-Null Count  Dtype         
--------------  -----         
3471 non-null   datetime64[ns]
dtypes: datetime64[ns](1)
memory usage: 54.2 KB

きちんとdatetime64型で認識されました。

次にstart_timeです。
データの中を確認すると、20:00 (04:00)というようなデータになっています。
データ元のサイトを確認すると()の中は日本時間とのことです。

サッカーでは12時頃に開始する試合をランチタイムキックオフと呼び、選手の熱があまり入らない試合になると一般的に言われています。
そのため、試合開始時刻は予測に使えるかもしれませんが、日本時間は全く関係ないので現地時刻だけにしてしまいましょう。

import re
# 括弧とその中身を削除
all_df['start_time'] = all_df['start_time'].apply(lambda x: re.sub(r'\s*\(.*\)', '', x))

print(all_df['start_time'].unique())
# 出力結果
['20:00' '17:30' '15:00' '16:30' '21:00' '15:30' '12:30' '20:45' '20:15'
 '14:00' '19:45' '19:00' '16:15' '18:45' '16:00' '20:55' '18:55' '17:55'
 '19:15' '19:30' '14:05' '18:00' '21:50']

このように現地時刻だけのデータにすることができました。
ただ、これだと少しの時間の違いが別のデータ扱いになってしまいます。
私が知りたいのはランチタイムキックオフとそれ以外の差なので、後ほどその点を特徴量として修正したいと思います。

最後にattendanceageminutesですが、これは数値型が正しいので修正したいと思います。
まずはどのようなデータがあるのか見てみましょう。

# データの中身を確認
print(all_df['attendance'].head())
print(all_df['age'].head())
print(all_df['minutes'].head())
0    73,358
1    73,358
2    73,358
3    73,358
4    73,358
Name: attendance, dtype: object
0    25-287
1    26-249
2    19-044
3    23-142
4    24-216
Name: age, dtype: object
0    87
1     3
2    67
3    23
4    67
Name: minutes, dtype: object

このようにみるとattendanceはカンマが、ageはハイフンが問題を引き起こしているようですね。

これらを削除してfloat型に修正したいと思います。

def convert_to_float(x):
    if isinstance(x, (int, float)):
        return x
    try:
        return float(x.replace(',', ''))
    except (AttributeError, ValueError):
        return x

def convert_age(x):
    try:
        return float(x.split('-')[0])
    except (AttributeError, ValueError, IndexError):
        return None

# 'attendance' 列を float 型に変換(カンマを除外できない要素はそのまま)
all_df['attendance'] = all_df['attendance'].apply(convert_to_float)

# 'age' 列をハイフンの前の部分だけ残して float 型に変換
all_df['age'] = all_df['age'].apply(convert_age)

# 'minutes' 列を float 型に変換(カンマを除外できない要素はそのまま)
all_df['minutes'] = all_df['minutes'].apply(convert_to_float)

基本的にカンマとハイフンを削除してfloat型に変換しているのですが、エラーが起きないようにエラーハンドリングを入れています。

print(all_df['attendance'].head())
print(all_df['age'].head())
print(all_df['minutes'].head())
0    73358.0
1    73358.0
2    73358.0
3    73358.0
4    73358.0
Name: attendance, dtype: float64
0    25.0
1    26.0
2    19.0
3    23.0
4    24.0
Name: age, dtype: float64
0    87.0
1     3.0
2    67.0
3    23.0
4    67.0
Name: minutes, dtype: float64

無事に変換できました。

欠損値

次に欠損値の確認です。

# 欠損の確認
# 全ての列のnull数を表示
null_df = pd.DataFrame(all_df.isnull().sum(), columns=['number'])
null_df.loc[null_df['number'] > 0]
number
attendance 880
notes 3132
shirtnumber 224
nationality 224
position 224
age 224
passes_pct 44
player 224
passes_pct_short 96
passes_pct_medium 189
passes_pct_long 638
challenge_tackles_pct 1503
aerials_won_pct 1110
それでは欠損値の処理を考えていきます。

まずattendanceですが、これには思い当たる節があるのでattendanceに欠損が発生している年度を見てみます。

# attendanceの欠損がいつ発生しているか
all_df.loc[all_df['attendance'].isnull()]['date'].unique()
# 出力結果
array(['2020/9/19', '2020/9/26', '2020/10/17', '2020/10/20', '2020/10/24',
       '2020/11/1', '2020/11/7', '2020/11/21', '2020/11/29', '2020/12/2',
       '2020/12/8', '2020/12/12', '2020/12/17', '2020/12/20',
       '2020/12/26', '2020/12/29', '2021/1/1', '2021/1/12', '2021/1/17',
       '2021/1/20', '2021/1/27', '2021/1/30', '2021/2/2', '2021/2/6',
       '2021/2/14', '2021/2/18', '2021/2/21', '2021/2/25', '2021/2/28',
       '2021/3/3', '2021/3/7', '2021/3/11', '2021/3/14', '2021/3/18',
       '2021/4/4', '2021/4/8', '2021/4/11', '2021/4/15', '2021/4/18',
       '2021/4/25', '2021/4/29', '2021/5/6', '2021/5/9', '2021/5/11',
       '2021/5/13', '2020-03-12', '2020-06-19', '2020-06-24',
       '2020-06-30', '2020-07-04', '2020-07-09', '2020-07-13',
       '2020-07-16', '2020-07-22', '2020-07-26', '2020-08-05',
       '2020-08-10', '2020-08-16'], dtype=object)

見ていただくと分かるように、2020年から2021年にかけて欠損値が発生しています。
実はこの期間、コロナ禍でプレミアリーグは無観客試合となっていました。
なので、この欠損値は0で置き換えるのが適切です。

次に'shirtnumber','nationality', 'position', 'age', 'player'ですが、欠損値の数が同じなので、特定の行で欠損が発生している可能性があります。

# 'shirtnumber','nationality', 'position', 'age', 'player'の欠損確認
all_df.loc[all_df['shirtnumber'].isnull()][['nationality', 'position', 'age', 'player']]
nationality position age player
16 NaN NaN NaN NaN
33 NaN NaN NaN NaN
48 NaN NaN NaN NaN
64 NaN NaN NaN NaN
81 NaN NaN NaN NaN
... ... ... ... ...
604 NaN NaN NaN NaN
620 NaN NaN NaN NaN
637 NaN NaN NaN NaN
654 NaN NaN NaN NaN
670 NaN NaN NaN NaN

やはり同じ行で重複が発生しているようです。
224行とそれなりの数がありますが、playerという一意のidが欠損しているデータなのですべての行を削除しようと思います。

notes,passes_pct_long,challenge_tackles_pct,aerials_won_pctの4つについては、欠損値の数が多いので列ごと削除してしまいます。
残ったpasses_pct,passes_pct_short,passes_pct_mediumは平均値で補完しようと思います。

# 欠損値の処理

# attendanceの欠損を0で置換
all_df['attendance'] = all_df['attendance'].fillna(all_df['attendance'].mean())

# playerが欠損している行を削除
all_df = all_df.dropna(subset='player')

# 欠損が多い列を削除
all_df = all_df.drop(['notes','passes_pct_long','challenge_tackles_pct','aerials_won_pct'], axis=1)

# 残りを平均で置換
mean_replace_cols= ['passes_pct','passes_pct_short','passes_pct_medium']

for col in mean_replace_cols:
    all_df[col] = all_df[col].fillna(all_df[col].mean())
# 欠損の確認
null_df = pd.DataFrame(all_df.isnull().sum(), columns=['number'])
null_df.loc[null_df['number'] > 0]
# 出力結果
number

欠損値をすべて処理することができました。

さてここまでで欠損値の処理、データ型の修正を行うことができました。
それでは次に特徴量の生成とモデルの作成を行っていきたいと思います。

特徴量の作成

まず、特徴量の絞り込みをおこないます。
今回取得したデータには直接的にゴール数に結びつく指標が入っています。
もちろんそのデータがあれば予測精度は上がるのですが、それでは意味がないので削除します。

# ゴール数に直接関連する列を削除
all_df = all_df.drop(['result','xg_for', 'xg_against', 'goals', 'assists', 'xg', 'npxg', 'xg_assist','gca','pens_won'], axis=1)

また、match_reportは明らかに不要な指標なので削除します。

# 明らかに分析に不要な列を削除
all_df = all_df.drop(['match_report'], axis=1)

次に特徴量の生成ですが、ひとまず数値型のデータは基本的にそのまま使用したいと思います。
もともと試合データとしてきれいに整形されているデータですし、どの数値が影響するかわからないのでそのままにしておきます。

それ以外のデータを考えます。

まず、start_timeを使ってランチタイムキックオフかそれ以外かを分けたいと思います。
ここでは17時を基準にします。

# 'start_time'列を時間型に変換
all_df['start_time'] = pd.to_datetime(all_df['start_time'], format='%H:%M')
# 17時以降の場合は1、それ以前の場合は0を割り当てる新しい列を作成
all_df['is_after_17'] = (all_df['start_time'].dt.hour >= 17).astype(int)

結果を見てみます。

all_df.loc[all_df['is_after_17'] == 0]['start_time'].unique()
<DatetimeArray>
['1900-01-01 15:00:00', '1900-01-01 16:30:00', '1900-01-01 15:30:00',
 '1900-01-01 12:30:00', '1900-01-01 14:00:00', '1900-01-01 16:15:00',
 '1900-01-01 16:00:00', '1900-01-01 14:05:00']
Length: 8, dtype: datetime64[ns]

きちんとランチタイムキックオフに0を割り振ることができています。
処理後のstart_time列は不要なので削除しておきます。

# start_time列を削除
all_df = all_df.drop('start_time', axis=1)

次はcompです

# compの確認
all_df['comp'].unique()
array(['Premier League', 'Champions Lg', 'Europa Lg'], dtype=object)

これは3つに絞られていますし、コンペティション毎に対戦相手のレベルが異なるのでこのまま使用しましょう。

次はroundです。

# roundの確認
all_df['round'].unique()
array(['Matchweek 1', 'Matchweek 2', 'Matchweek 3', 'Matchweek 4',
       'Matchweek 5', 'Group stage', 'Matchweek 6', 'Matchweek 7',
       'Matchweek 8', 'Matchweek 9', 'Matchweek 10', 'Matchweek 11',
       'Matchweek 12', 'Matchweek 13', 'Matchweek 14', 'Matchweek 15',
       'Matchweek 16', 'Matchweek 17', 'Matchweek 18', 'Matchweek 19',
       'Matchweek 20', 'Matchweek 21', 'Matchweek 22', 'Matchweek 23',
       'Matchweek 24', 'Matchweek 25', 'Matchweek 26', 'Matchweek 27',
       'Matchweek 28', 'Knockout round play-offs', 'Round of 16',
       'Matchweek 29', 'Matchweek 30', 'Quarter-finals', 'Matchweek 31',
       'Matchweek 33', 'Matchweek 34', 'Matchweek 35', 'Matchweek 36',
       'Matchweek 37', 'Matchweek 32', 'Matchweek 38', 'Round of 32',
       'Semi-finals', 'Final'], dtype=object)

これは何週目の試合か、あるいはカップ戦ならどの時点かを示しています。
あまり意味がなさそうにも思えますが、一般的にカップ戦が進むほど緊張感が増すので点数が少ない試合が多くなります。
また、プレミアリーグも終盤になると疲れがたまってきて試合の内容も変わってくるので、もしかするとこのデータも使えるかもしれません。
なので、いったんこのまま使用してみます。

次にdayofweekです。

# dayofweek
all_df['dayofweek'].unique()
array(['Mon', 'Sat', 'Sun', 'Wed', 'Tue', 'Thu', 'Fri'], dtype=object)

一般的にプレミアリーグの試合は大体週末(土日)に行われ、欧州のカップ戦が火水木に行われます。
それ以外の曜日は基本的にイレギュラーが起きた場合なので、その3パターンで分けてみます。

def categorize_dayofweek(day):
    if day in ['Sat', 'Sun']:
        return 1
    elif day in ['Tue', 'Wed', 'Thu']:
        return 2
    else:
        return 3

# 新しい特徴量 'dayofweek_category' を作成
all_df['dayofweek_category'] = all_df['dayofweek'].apply(categorize_dayofweek)

結果を確認してみます。

all_df['dayofweek_category'].unique()
array([3, 1, 2], dtype=int64)

大丈夫そうですね。

dayofweek列は不要なので削除しておきます。

# dayofweekを削除
all_df = all_df.drop('dayofweek', axis=1)

venue

次はvenueです。

# venueを確認
all_df['venue'].unique()
array(['Home', 'Away', 'Neutral'], dtype=object)

一般的にホームゲームの方が勝率は高くなるので、これはそのまま予測に使えそうです。

次にformationです。

all_df['formation'].unique()
array(['4-1-4-1', '4-2-3-1', '4-1-2-1-2◆', '2004/3/3', '4-2-4-0', '3-5-2',
       '3-4-3', '4-3-1-2', '4-2-2-2', '4-3-3', '3-4-1-2'], dtype=object)

ちょっと変な値が混じっていますね。
まず♦は不要な文字列なので削除します。
また、'2004/3/3'ですが、元データを確認したところ'4-3-3'が日付型に誤認識されているようでした。
これらを修正していきます。

# 日付型を修正
all_df['formation'] = all_df['formation'].apply(lambda x: '4-3-3' if x == '2004/3/3' else x)

# ◆を除外
all_df['formation'] = all_df['formation'].apply(lambda x: x.replace('◆', ''))

結果を確認します。

# 結果の確認
all_df['formation'].unique()
array(['4-1-4-1', '4-2-3-1', '4-1-2-1-2', '4-3-3', '4-2-4-0', '3-5-2',
       '3-4-3', '4-3-1-2', '4-2-2-2', '3-4-1-2'], dtype=object)

大丈夫そうですね。

最後に残りのobjectをチェックします。
サッカーに詳しい人なら見知った名前が出てきて特に問題ないことが理解できると思います。

# 残りのobjectをチェック
check_list = ['opponent', 'captain', 'referee', 'player', 'nationality']

for i in check_list:
    print(all_df[i].unique())
opponent:
['Wolves' 'Tottenham' "Nott'ham Forest" 'Arsenal' 'Brighton'
 'de Bayern Munich' 'Burnley' 'Crystal Palace' 'tr Galatasaray'
 'Brentford' 'Sheffield Utd' 'dk FC Copenhagen' 'Manchester City' 'Fulham'
 'Luton Town' 'Everton' 'Newcastle Utd' 'Chelsea' 'Bournemouth'
 'Liverpool' 'West Ham' 'Aston Villa' 'Southampton' 'Leicester City'
 'es Real Sociedad' 'md Sheriff Tiraspol' 'cy AC Omonia' 'Leeds United'
 'es Barcelona' 'es Betis' 'es Sevilla' 'ch Young Boys' 'es Villarreal'
 'it Atalanta' 'Watford' 'Norwich City' 'es Atlético Madrid'
 'fr Paris S-G' 'de RB Leipzig' 'tr Başakşehir' 'West Brom' 'it Milan'
 'es Granada' 'it Roma' 'kz Astana FK' 'nl AZ Alkmaar' 'rs Partizan'
 'be Club Brugge' 'at LASK']

captain:
['Bruno Fernandes' 'Scott McTominay' 'Harry Maguire' 'Cristiano Ronaldo'
 'Wout Weghorst' 'Nemanja Matić' 'Paul Pogba' 'David de Gea'
 'Ashley Young' 'Jesse Lingard']


referee:
['Simon Hooper' 'Michael Oliver' 'Stuart Attwell' 'Anthony Taylor'
 'Jarred Gillett' 'Glenn Nyberg' 'Tony Harrington' 'Chris Kavanagh'
 'Ivan Kružliak' 'Andy Madley' 'Marco Guida' 'Paul Tierney' 'John Brooks'
 'Donatas Rumšas' 'Graham Scott' 'José Sánchez' 'Robert Jones'
 'Peter Bankes' 'Espen Eskås' 'Craig Pawson' 'Tim Robinson' 'David Coote'
 'Marco Di Bello' 'Paweł Raczkowski' 'João Pinheiro' 'Jérôme Brisard'
 'Anastasios Sidiropoulos' 'Georgi Kabakov' 'Michael Salisbury'
 'Andre Marriner' 'Maurizio Mariani' 'Clément Turpin' 'Daniel Siebert'
 'Srđan Jovanović' 'Felix Zwayer' 'Artur Dias' 'Mike Dean'
 'François Letexier' 'Martin Atkinson' 'Szymon Marciniak' 'Slavko Vinčič'
 'Jonathan Moss' 'Felix Brych' 'Benoît Bastien' 'Darren England'
 'Ovidiu Hațegan' 'Kevin Friend' 'Antonio Matéu Lahoz' 'Matej Jug'
 'Davide Massa' 'Daniele Orsato' 'Sandro Schärer' 'Lawrence Visser'
 'Artur Soares Dias' 'István Kovács' 'Carlos del Cerro' 'Lee Mason'
 'Gediminas Mažeika' 'Javier Estrada' 'Mattias Gestranius'
 'Serdar Gözübüyük']

player:
['Marcus Rashford' 'Scott McTominay' 'Alejandro Garnacho' 'Jadon Sancho'
 'Mason Mount' 'Christian Eriksen' 'Bruno Fernandes' 'Antony'
 'Facundo Pellistri' 'Casemiro' 'Luke Shaw' 'Lisandro Martínez'
 'Victor Lindelöf' 'Raphaël Varane' 'Aaron Wan-Bissaka' 'André Onana'
 'Anthony Martial' 'Diogo Dalot' 'Rasmus Højlund' 'Harry Maguire'
 'Jonny Evans' 'Hannibal Mejbri' 'Sergio Reguilón' 'Sofyan Amrabat'
 'Donny van de Beek' 'Kobbie Mainoo' 'Willy Kambwala' 'Daniel Gore'
 'Amad Diallo' 'Omari Forson' 'Anthony Elanga' 'Fred' 'Cristiano Ronaldo'
 'Tyrell Malacia' 'David de Gea' 'Charlie McNeill' 'Wout Weghorst'
 'Marcel Sabitzer' 'Mason Greenwood' 'Paul Pogba' 'Daniel James'
 'Nemanja Matić' 'Jesse Lingard' 'Edinson Cavani' 'Alex Telles'
 'Eric Bailly' 'Juan Mata' 'Zidane Iqbal' 'Charlie Savage'
 'Shola Shoretire' 'Teden Mengi' 'Dean Henderson' 'Tom Heaton'
 'Phil Jones' 'Timothy Fosu-Mensah' 'Odion Ighalo' 'Axel Tuanzebe'
 'Brandon Williams' 'William Thomas Fish' 'Andreas Pereira' 'Ashley Young'
 'Tahith Chong' 'Angel Gomes' 'Marcos Rojo' 'Sergio Romero' 'James Garner'
 'Ethan Galbraith' "D'Mani Bughail-Mellor" 'Largie Ramazani'
 'Dylan Levitt' "Di'Shon Bernard" 'Ethan Laird' 'Lee Grant']
 
nationality:
['eng ENG' 'sct SCO' 'ar ARG' 'dk DEN' 'pt POR' 'br BRA' 'uy URU' 'se SWE'
 'fr FRA' 'cm CMR' 'nir NIR' 'tn TUN' 'es ESP' 'ma MAR' 'nl NED' 'ci CIV'
 'at AUT' 'wls WAL' 'rs SRB' 'iq IRQ' 'ng NGA' 'be BEL' 'jm JAM']

審判は相性があるような気がするので、どれくらい影響があるのか気になります。

ラベルエンコーディング

最後にobject型のデータをエンコーディングしていきます。

その前にこのタイミングで未知データ(提出データ)を分割しておきます。

# 提出データを分割
submit_df = all_df.loc[all_df['date'] > '2024/01/01']
train_df = all_df.loc[all_df['date'] <= '2024/01/01']

print(all_df.shape)
print(submit_df.shape)
print(train_df.shape)

train_df = train_df.drop('date', axis=1)
(3247, 89)
(119, 89)
(3128, 89)

適切に分割できていますね。
また、dateは一意のデータでモデル作成には不要なので削除しておきます。

今回は値に順序があるわけではありませんが、one-hotエンコーディングだとさすがに列数が多くなりすぎてしまうと思うので、ラベルエンコーディングを行います。

# train_dfをラベルエンコーディング
from sklearn.preprocessing import LabelEncoder

def label_encode(df):
    # 文字列型の列を選択
    object_columns = df.select_dtypes(include=['object'])
    # 文字列型の列名を取得
    encoding_columns = object_columns.columns

    # ラベルエンコーダーを格納する辞書を初期化
    label_encoders = {}

    # 文字列型の列をループ処理
    for column in encoding_columns:
        # 列ごとにラベルエンコーダーを作成し、辞書に格納
        label_encoders[column] = LabelEncoder()
        # 列をラベルエンコーディング
        df[column] = label_encoders[column].fit_transform(df[column])

    return df

train_df = label_encode(train_df)
submit_df = label_encode(submit_df)

結果を確認してみます。

object_columns = train_df.select_dtypes(include=['object'])
object_columns.info()
<class 'pandas.core.frame.DataFrame'>
Index: 3009 entries, 0 to 669
Empty DataFrame

object型のデータはなくなりました。

LightGBMモデルの作成

それではいよいよモデルを作成していきます。
今回はLightGBMで回帰分析をおこないます。

まずはライブラリのインポートです。

# ライブラリのインポート
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
import lightgbm as lgb
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import mean_absolute_error

次に目的変数と説明変数を設定します。

# 目的変数と説明変数の設定
target = 'goals_for'
features = train_df.columns.tolist()
features.remove(target)

goals_forを目的変数に設定します。

次にtrain_test_splitを使ってデータを分割します。

# 学習データとテストデータに分割
X_train, X_test, y_train, y_test = train_test_split(train_df[features], train_df[target], test_size=0.3, random_state=42)

# LightGBMデータセットの作成
lgb_train = lgb.Dataset(X_train, y_train)
lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)

ハイパーパラメータはいろいろ試した結果この辺りに落ち着きました。

# LightGBMのパラメータ設定
params = {
    'boosting_type': 'gbdt',
    'objective': 'regression',
    'metric': 'mae',
    'num_boost_round': 33,
    'learning_rate': 0.1,
    'verbose': -1,
    'early_stopping_round': 10  
}

次にモデルに学習させます。
特にここは何もありません。

# モデルの学習
gbm = lgb.train(params, lgb_train, num_boost_round=100, valid_sets=lgb_eval)

学習したモデルをもとに、訓練データ、テストデータ、提出データのMAEを計算します。

# 訓練データでの評価指標の確認
y_pred_train = gbm.predict(X_train, num_iteration=gbm.best_iteration)
mae_train = mean_absolute_error(y_train, y_pred_train)
print("訓練データのMAE:", mae_train)

# テストデータでの評価指標の確認
y_pred_test = gbm.predict(X_test, num_iteration=gbm.best_iteration)
mae_test = mean_absolute_error(y_test, y_pred_test)
print("テストデータのMAE:", mae_test)

# submit_dfから特徴量とターゲット変数を取得
X_submit = submit_df[features]
y_submit = submit_df['goals_for']
# モデルを使って予測
y_pred_submit = gbm.predict(X_submit, num_iteration=gbm.best_iteration)
# 平均絶対誤差の計算
mae_submit = mean_absolute_error(y_submit, y_pred_submit)
print("提出データのMAE:", mae_submit)

結果はこちらです。

訓練データのMAE: 0.2693255136836085
テストデータのMAE: 0.3186301850136803
提出データのMAE: 0.811006826653648

若干過学習している感もありますが、提出データのMAEが0.81と、基準となる0.94よりもいい精度を出しています。

実際にどのような結果だったのか見てみます。

# 予測結果と実際のgoals_forを並べて表示
result_df = pd.DataFrame({'actual': y_submit, 'predicted': y_pred_submit})
result_df = result_df.drop_duplicates()

# opponentカラムを追加
result_df['opponent'] = submit_df['opponent']

# opponentごとに集約
opponent_stats = result_df.groupby('opponent').agg({
    'actual': 'mean',
    'predicted': 'mean'
})

# opponentごとのmaeを計算
opponent_stats['mae'] = result_df.groupby('opponent').apply(lambda x: mean_absolute_error(x['actual'], x['predicted']))

# カラム名を変更
opponent_stats.columns = ['actual_mean', 'predicted_mean', 'mae']

# データフレームを表示
display(opponent_stats)
opponent actual_mean predicted_mean mae
0 2.0 2.054646 0.063491
1 2.0 1.538332 0.461668
2 1.0 1.281909 0.281909
3 2.0 1.719025 0.280975
4 1.0 0.954526 0.045474
5 2.0 1.484236 0.515764
6 3.0 0.709863 2.290137
7 4.0 2.232996 1.767004

こうやって見ると、opponent=6のところで大きく外しているだけで、その他はかなりいい精度で予測できていることがわかります。

各変数の重要度を可視化してみます。

# 特徴量の重要度を取得
importance = gbm.feature_importance(importance_type='gain')
feature_importance = pd.DataFrame({'Feature': features, 'Importance': importance})
feature_importance = feature_importance.sort_values('Importance', ascending=False)

# 特徴量の重要度を可視化
plt.figure(figsize=(10, 15))
sns.barplot(x='Importance', y='Feature', data=feature_importance)
plt.title('Feature Importance (Selected Features)')
plt.tight_layout()
plt.show()

posessionはともかくとして、roundattendancerefereeの重要度が非常に高くなっているのは少し違和感がありますね。
今回のデータセットは、選手ごとのデータに試合の概観のデータを連結することで、attendancerefereeが重複していることが影響していると考えられます。
とはいえ良い精度で予測できているので、これでも問題ないと考えます。

さて、思った以上に良い精度で予測できているのでこれで完了してもいいのですが、もう少し調整してみようと思います。

重要度の影響が1%以上の特徴量だけに絞ってみます。

# 特徴量の重要度を取得
importances = gbm.feature_importance()

# 重要度の正規化
normalized_importances = importances / importances.sum()

# 重要度が1%以上の特徴量を選択
importance_threshold = 0.01
top_features = [feat for feat, imp in zip(features, normalized_importances) if imp >= importance_threshold]

print(f"選択された特徴量 ({len(top_features)}):", top_features)

# 選択された特徴量で再度学習データとテストデータに分割
X_train_top, X_test_top, y_train_top, y_test_top = train_test_split(train_df[top_features], train_df[target], test_size=0.3, random_state=42)

# LightGBMデータセットの作成
lgb_train_top = lgb.Dataset(X_train_top, y_train_top)
lgb_eval_top = lgb.Dataset(X_test_top, y_test_top, reference=lgb_train_top)


# モデルの学習
gbm_top = lgb.train(params, lgb_train_top, num_boost_round=100, valid_sets=lgb_eval_top)

# 訓練データでの評価指標の確認
y_pred_train_top = gbm_top.predict(X_train_top, num_iteration=gbm_top.best_iteration)
mae_train_top = mean_absolute_error(y_train_top, y_pred_train_top)
print("訓練データのMAE(上位特徴量のみ):", mae_train_top)

# テストデータでの評価指標の確認
y_pred_test_top = gbm_top.predict(X_test_top, num_iteration=gbm_top.best_iteration)
mae_test_top = mean_absolute_error(y_test_top, y_pred_test_top)
print("テストデータのMAE(上位特徴量のみ):", mae_test_top)

# submit_dfから特徴量とターゲット変数を取得
X_submit_top = submit_df[top_features]
y_submit_top = submit_df['goals_for']

# モデルを使って予測
y_pred_submit_top = gbm_top.predict(X_submit_top, num_iteration=gbm_top.best_iteration)

# 平均絶対誤差の計算
mae_submit_top = mean_absolute_error(y_submit_top, y_pred_submit_top)
print("提出データのMAE(上位特徴量のみ):", mae_submit_top)
選択された特徴量 (10): ['round', 'venue', 'goals_against', 'opponent', 'possession', 'attendance', 'captain', 'referee', 'is_after_17', 'dayofweek_category']
訓練データのMAE(上位特徴量のみ): 0.24147462066062633
テストデータのMAE(上位特徴量のみ): 0.2781900800726434
提出データのMAE(上位特徴量のみ): 0.808885588514893

結果は0.80とほんの少しだけ改善しました。

これまでの通り、LightGBMでも満足できる精度なのですが、一応LightGBM以外のモデルでも分析してみます。

ラッソ回帰

ラッソ回帰とはL1正則化を行いながら線形回帰の適切なパラメータを設定する回帰モデルです。

L1正則化では、データとして余分な情報がたくさん存在するようなデータの回帰分析を行う際に使用します。
そのため、データセットの数(行数)に比べて、各データの数(列数)が多い場合には、ラッソ回帰を利用するのが良いと言われています。

# ライブラリのインポート
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import Lasso
from sklearn.metrics import mean_absolute_error

# 目的変数と説明変数の設定
target = 'goals_for'
features = train_df.columns.tolist()
features.remove(target)

# 学習データとテストデータに分割
X_train, X_test, y_train, y_test = train_test_split(train_df[features], train_df[target], test_size=0.3, random_state=42)

# ラッソ回帰モデルの作成
lasso = Lasso(alpha=0.1)

# モデルの学習
lasso.fit(X_train, y_train)

# 訓練データでの評価指標の確認
y_pred_train = lasso.predict(X_train)
mae_train = mean_absolute_error(y_train, y_pred_train)
print("訓練データのMAE:", mae_train)

# テストデータでの評価指標の確認
y_pred_test = lasso.predict(X_test)
mae_test = mean_absolute_error(y_test, y_pred_test)
print("テストデータのMAE:", mae_test)

# submit_dfから特徴量とターゲット変数を取得
X_submit = submit_df[features]
y_submit = submit_df['goals_for']

# モデルを使って予測
y_pred_submit = lasso.predict(X_submit)

# 平均絶対誤差の計算
mae_submit = mean_absolute_error(y_submit, y_pred_submit)
print("提出データのMAE:", mae_submit)
訓練訓練データのMAE: 1.080539429322991
テストデータのMAE: 1.092863883804813
提出データのMAE: 0.901065477625284

結果を見ると訓練データのスコアと提出データのスコアがあまり変わらないので、うまく汎化できていることがわかります。
ただ、提出データのMAEはLightGBMよりは悪くなってしまいました。

重要度も見てみます。

shots_on_targetの重要度がかなり高くなっています。
枠内シュートの数は得点数に大きく影響する重要なファクターなので、これは納得感のある結果でした。

リッジ回帰

リッジ回帰とはL2正則化を行いながら線形回帰の適切なパラメータを設定する回帰モデルです。
リッジ回帰には、汎化しやすい特徴があると言われています。

# ライブラリのインポート
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error

# 目的変数と説明変数の設定
target = 'goals_for'
features = train_df.columns.tolist()
features.remove(target)

# 学習データとテストデータに分割
X_train, X_test, y_train, y_test = train_test_split(train_df[features], train_df[target], test_size=0.3, random_state=42)

# リッジ回帰モデルの作成
ridge = Ridge(alpha=0.1)

# モデルの学習
ridge.fit(X_train, y_train)

# 訓練データでの評価指標の確認
y_pred_train = ridge.predict(X_train)
mae_train = mean_absolute_error(y_train, y_pred_train)
print("訓練データのMAE:", mae_train)

# テストデータでの評価指標の確認
y_pred_test = ridge.predict(X_test)
mae_test = mean_absolute_error(y_test, y_pred_test)
print("テストデータのMAE:", mae_test)

# submit_dfから特徴量とターゲット変数を取得
X_submit = submit_df[features]
y_submit = submit_df['goals_for']

# モデルを使って予測
y_pred_submit = ridge.predict(X_submit)

# 平均絶対誤差の計算
mae_submit = mean_absolute_error(y_submit, y_pred_submit)
print("提出データのMAE:", mae_submit)
訓練データのMAE: 1.0243863689038577
テストデータのMAE: 1.0773238380992383
提出データのMAE: 0.8520887712348216

こちらもラッソ回帰と同じようにうまく汎化できていますね。
またLightGBMほどではないですが、ラッソ回帰より提出データのスコアは良くなっています。

重要度も見てみます。

こちらも面白い結果になりました。
shots_on_targetが上位に来るのは同様ですが、penns_madepass_xaなどの指標も上位に来ました。
また、様々な指標に重要度が分散していることもわかります。

ElasticNet

最後にElasticNet回帰です。
ElasticNet回帰とは、ラッソ回帰とリッジ回帰を組み合わせて正則化項を作るモデルとなります。

メリットとして、ラッソ回帰で取り扱った余分な情報がたくさん存在するようなデータに対して情報を取捨選択してくれる点と、リッジ回帰で取り扱った 汎化しやすい点が挙げられます。

# ライブラリのインポート
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import ElasticNet
from sklearn.metrics import mean_absolute_error

# 目的変数と説明変数の設定
target = 'goals_for'
features = train_df.columns.tolist()
features.remove(target)

# 学習データとテストデータに分割
X_train, X_test, y_train, y_test = train_test_split(train_df[features], train_df[target], test_size=0.3, random_state=42)

# ElasticNetモデルの作成
elastic_net = ElasticNet(alpha=0.1, l1_ratio=0.5)

# モデルの学習
elastic_net.fit(X_train, y_train)

# 訓練データでの評価指標の確認
y_pred_train = elastic_net.predict(X_train)
mae_train = mean_absolute_error(y_train, y_pred_train)
print("訓練データのMAE:", mae_train)

# テストデータでの評価指標の確認
y_pred_test = elastic_net.predict(X_test)
mae_test = mean_absolute_error(y_test, y_pred_test)
print("テストデータのMAE:", mae_test)

# submit_dfから特徴量とターゲット変数を取得
X_submit = submit_df[features]
y_submit = submit_df['goals_for']

# モデルを使って予測
y_pred_submit = elastic_net.predict(X_submit)

# 平均絶対誤差の計算
mae_submit = mean_absolute_error(y_submit, y_pred_submit)
print("提出データのMAE:", mae_submit)
訓練データのMAE: 1.0646179795708095
テストデータのMAE: 1.0800962437798347
提出データのMAE: 0.9145999074599056

たしかに汎化性能は高いですが、スコアは伸び悩んでしまいました。
重要度もこれまでとは違った結果になっているので面白いですね。

3つのモデルを試してみましたが、LightGBMが一番精度が高くなりました。
ただ、汎化性能や重要度を見るとリッジ回帰もかなりいいモデルになっているのではないかと思います。

そして、どのモデルも基準となる0.94よりはいい数字を出しているので、わざわざ機械学習モデルを作った意味があると言えるかなと思います。

さいごに

今回はLightGBMを用いてサッカーの試合の得点数を予測してみました。
未知のデータで0.8点と最初に予想したより良い精度が出たのが良い意味で驚きでした。
今回使ったデータセットは他の分析にも使用できそうなので、また別の切り口で分析してみようと思います。

※コードまとめ

前処理

import pandas as pd
import re
from sklearn.preprocessing import LabelEncoder


# データの読み込み
current_df = pd.read_csv(r'C:\dev\23_24_MUN_player_stats.csv', encoding='utf-8')
privious_df1 = pd.read_csv(r'C:\dev\22_23_MUN_player_stats.csv', encoding='utf-8')
privious_df2 = pd.read_csv(r'C:\dev\21_22_MUN_player_stats.csv', encoding='utf-8')
privious_df3 = pd.read_csv(r'C:\dev\20_21_MUN_player_stats.csv', encoding='utf-8')
privious_df4 = pd.read_csv(r'C:\dev\19_20_MUN_player_stats.csv', encoding='utf-8')

#データを縦に連結
all_df = pd.concat([current_df, privious_df1, privious_df2, privious_df3, privious_df4])

# 列の重複を処理するための処理
# 元のデータフレームの列名を取得
columns = all_df.columns

# 重複している列の数を格納する変数を初期化
duplicate_count = 0

# 新しい列名と対応する値を格納する辞書を初期化
new_columns_dict = {}

# 処理された列名を格納するリストを初期化
removed_columns = []

# 各列名について処理を行う
for col in columns:
    if '.' in col:
        # ドットを含む列名の場合、ドットより前の部分を新しい列名とする
        new_col = col.split('.')[0]
        if new_col in new_columns_dict:
            # 新しい列名がすでに辞書に含まれている場合、重複カウントを増やす
            duplicate_count += 1
            # 処理された列名をリストに追加
            removed_columns.append(col)
        else:
            # 新しい列名がまだ辞書に含まれていない場合のみ、辞書に追加
            new_columns_dict[new_col] = all_df[col]
    else:
        # ドットを含まない列名の場合、重複チェックを行う
        if col in new_columns_dict:
            # 列名がすでに辞書に含まれている場合、重複カウントを増やす
            duplicate_count += 1
            # 処理された列名をリストに追加
            removed_columns.append(col)
        else:
            # 列名がまだ辞書に含まれていない場合のみ、辞書に追加
            new_columns_dict[col] = all_df[col]

# 新しい列名と値を使ってデータフレームを作成
all_df = pd.DataFrame(new_columns_dict)


# dateを日付型に変換
all_df['date'] = pd.to_datetime(all_df['date'], format='mixed')



# 括弧とその中身を削除
all_df['start_time'] = all_df['start_time'].apply(lambda x: re.sub(r'\s*\(.*\)', '', x))

def convert_to_float(x):
    if isinstance(x, (int, float)):
        return x
    try:
        return float(x.replace(',', ''))
    except (AttributeError, ValueError):
        return x

def convert_age(x):
    try:
        return float(x.split('-')[0])
    except (AttributeError, ValueError, IndexError):
        return None

# 'attendance' 列を float 型に変換(カンマを除外できない要素はそのまま)
all_df['attendance'] = all_df['attendance'].apply(convert_to_float)

# 'age' 列をハイフンの前の部分だけ残して float 型に変換
all_df['age'] = all_df['age'].apply(convert_age)

# 'minutes' 列を float 型に変換(カンマを除外できない要素はそのまま)
all_df['minutes'] = all_df['minutes'].apply(convert_to_float)


# 欠損値の処理
# attendanceの欠損を0で置換
all_df['attendance'] = all_df['attendance'].fillna(all_df['attendance'].mean())

# playerが欠損している行を削除
all_df = all_df.dropna(subset='player')

# 欠損が多い列を削除
all_df = all_df.drop(['notes','passes_pct_long','challenge_tackles_pct','aerials_won_pct'], axis=1)

# 残りを平均で置換
mean_replace_cols= ['passes_pct','passes_pct_short','passes_pct_medium']

for col in mean_replace_cols:
    all_df[col] = all_df[col].fillna(all_df[col].mean())

    # ゴール数に直接関連する列を削除
all_df = all_df.drop(['result','xg_for', 'xg_against', 'goals', 'assists', 'xg', 'npxg', 'xg_assist','gca','pens_won'], axis=1)
# 明らかに分析に不要な列を削除
all_df = all_df.drop(['match_report'], axis=1)

# 'start_time'列を時間型に変換
all_df['start_time'] = pd.to_datetime(all_df['start_time'], format='%H:%M')

# 17時以降の場合は1、それ以前の場合は0を割り当てる新しい列を作成
all_df['is_after_17'] = (all_df['start_time'].dt.hour >= 17).astype(int)


# start_time列を削除
all_df = all_df.drop('start_time', axis=1)

# 新しい特徴量 'dayofweek_category' を作成
def categorize_dayofweek(day):
    if day in ['Sat', 'Sun']:
        return 1
    elif day in ['Tue', 'Wed', 'Thu']:
        return 2
    else:
        return 3

all_df['dayofweek_category'] = all_df['dayofweek'].apply(categorize_dayofweek)
# dayofweekを削除
all_df = all_df.drop('dayofweek', axis=1)

# 日付型を修正
all_df['formation'] = all_df['formation'].apply(lambda x: '4-3-3' if x == '2004/3/3' else x)

# ◆を除外
all_df['formation'] = all_df['formation'].apply(lambda x: x.replace('◆', ''))

# 提出データを分割
submit_df = all_df.loc[all_df['date'] > '2024/01/01']
train_df = all_df.loc[all_df['date'] <= '2024/01/01']


train_df = train_df.drop('date', axis=1)

# train_dfをラベルエンコーディング
from sklearn.preprocessing import LabelEncoder

def label_encode(df):
    # 文字列型の列を選択
    object_columns = df.select_dtypes(include=['object'])
    # 文字列型の列名を取得
    encoding_columns = object_columns.columns

    # ラベルエンコーダーを格納する辞書を初期化
    label_encoders = {}

    # 文字列型の列をループ処理
    for column in encoding_columns:
        # 列ごとにラベルエンコーダーを作成し、辞書に格納
        label_encoders[column] = LabelEncoder()
        # 列をラベルエンコーディング
        df[column] = label_encoders[column].fit_transform(df[column])

    return df

train_df = label_encode(train_df)
submit_df = label_encode(submit_df)

初回のLightGBM

# ライブラリのインポート
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
import lightgbm as lgb
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import mean_absolute_error

# 目的変数と説明変数の設定
target = 'goals_for'
features = train_df.columns.tolist()
features.remove(target)

# 学習データとテストデータに分割
X_train, X_test, y_train, y_test = train_test_split(train_df[features], train_df[target], test_size=0.3, random_state=42)

# LightGBMデータセットの作成
lgb_train = lgb.Dataset(X_train, y_train)
lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)

# LightGBMのパラメータ設定
params = {
    'boosting_type': 'gbdt',
    'objective': 'regression',
    'metric': 'mae',
    'num_boost_round': 33,
    'learning_rate': 0.1,
    'verbose': -1,
    'early_stopping_round': 10  
}

# モデルの学習
gbm = lgb.train(params, lgb_train, num_boost_round=100, valid_sets=lgb_eval)

# 訓練データでの評価指標の確認
y_pred_train = gbm.predict(X_train, num_iteration=gbm.best_iteration)
mae_train = mean_absolute_error(y_train, y_pred_train)
print("訓練データのMAE:", mae_train)

# テストデータでの評価指標の確認
y_pred_test = gbm.predict(X_test, num_iteration=gbm.best_iteration)
mae_test = mean_absolute_error(y_test, y_pred_test)
print("テストデータのMAE:", mae_test)

# submit_dfから特徴量とターゲット変数を取得
X_submit = submit_df[features]
y_submit = submit_df['goals_for']
# モデルを使って予測
y_pred_submit = gbm.predict(X_submit, num_iteration=gbm.best_iteration)
# 平均絶対誤差の計算
mae_submit = mean_absolute_error(y_submit, y_pred_submit)
print("提出データのMAE:", mae_submit)

重要度を取得

# 特徴量の重要度を取得
importance = gbm.feature_importance(importance_type='gain')
feature_importance = pd.DataFrame({'Feature': features, 'Importance': importance})
feature_importance = feature_importance.sort_values('Importance', ascending=False)

# 特徴量の重要度を可視化
plt.figure(figsize=(10, 10))
sns.barplot(x='Importance', y='Feature', data=feature_importance)
plt.title('Feature Importance (Selected Features)')
plt.tight_layout()
plt.show()

重要度の高い指標で再計算

# 特徴量の重要度を取得
importances = gbm.feature_importance()

# 重要度の正規化
normalized_importances = importances / importances.sum()

# 重要度が0.1%以上の特徴量を選択
importance_threshold = 0.01
top_features = [feat for feat, imp in zip(features, normalized_importances) if imp >= importance_threshold]

print(f"選択された特徴量 ({len(top_features)}):", top_features)

# 選択された特徴量で再度学習データとテストデータに分割
X_train_top, X_test_top, y_train_top, y_test_top = train_test_split(train_df[top_features], train_df[target], test_size=0.3, random_state=42)

# LightGBMデータセットの作成
lgb_train_top = lgb.Dataset(X_train_top, y_train_top)
lgb_eval_top = lgb.Dataset(X_test_top, y_test_top, reference=lgb_train_top)

# LightGBMのパラメータ設定
params = {
    'boosting_type': 'gbdt',
    'objective': 'regression',
    'metric': 'mae',
    'num_boost_round': 33,
    'learning_rate': 0.10,
    'verbose': -1,
    'early_stopping_round': 10  
}

# モデルの学習
gbm_top = lgb.train(params, lgb_train_top, num_boost_round=100, valid_sets=lgb_eval_top)

# 訓練データでの評価指標の確認
y_pred_train_top = gbm_top.predict(X_train_top, num_iteration=gbm_top.best_iteration)
mae_train_top = mean_absolute_error(y_train_top, y_pred_train_top)
print("訓練データのMAE(上位特徴量のみ):", mae_train_top)

# テストデータでの評価指標の確認
y_pred_test_top = gbm_top.predict(X_test_top, num_iteration=gbm_top.best_iteration)
mae_test_top = mean_absolute_error(y_test_top, y_pred_test_top)
print("テストデータのMAE(上位特徴量のみ):", mae_test_top)

# submit_dfから特徴量とターゲット変数を取得
X_submit_top = submit_df[top_features]
y_submit_top = submit_df['goals_for']

# モデルを使って予測
y_pred_submit_top = gbm_top.predict(X_submit_top, num_iteration=gbm_top.best_iteration)

# 平均絶対誤差の計算
mae_submit_top = mean_absolute_error(y_submit_top, y_pred_submit_top)
print("提出データのMAE(上位特徴量のみ):", mae_submit_top)

ラッソ回帰

# ライブラリのインポート
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import Lasso
from sklearn.metrics import mean_absolute_error

# 目的変数と説明変数の設定
target = 'goals_for'
features = train_df.columns.tolist()
features.remove(target)

# 学習データとテストデータに分割
X_train, X_test, y_train, y_test = train_test_split(train_df[features], train_df[target], test_size=0.3, random_state=42)

# ラッソ回帰モデルの作成
lasso = Lasso(alpha=0.1)

# モデルの学習
lasso.fit(X_train, y_train)

# 訓練データでの評価指標の確認
y_pred_train = lasso.predict(X_train)
mae_train = mean_absolute_error(y_train, y_pred_train)
print("訓練データのMAE:", mae_train)

# テストデータでの評価指標の確認
y_pred_test = lasso.predict(X_test)
mae_test = mean_absolute_error(y_test, y_pred_test)
print("テストデータのMAE:", mae_test)

# submit_dfから特徴量とターゲット変数を取得
X_submit = submit_df[features]
y_submit = submit_df['goals_for']

# モデルを使って予測
y_pred_submit = lasso.predict(X_submit)

# 平均絶対誤差の計算
mae_submit = mean_absolute_error(y_submit, y_pred_submit)
print("提出データのMAE:", mae_submit)

# 特徴量の重要度を取得
importance = np.abs(lasso.coef_)
feature_importance = pd.DataFrame({'Feature': features, 'Importance': importance})
feature_importance = feature_importance.sort_values('Importance', ascending=False)

# 特徴量の重要度を可視化
plt.figure(figsize=(10, 15))
sns.barplot(x='Importance', y='Feature', data=feature_importance)
plt.title('Feature Importance (Lasso Regression)')
plt.tight_layout()
plt.show()

リッジ回帰

# ライブラリのインポート
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error

# 目的変数と説明変数の設定
target = 'goals_for'
features = train_df.columns.tolist()
features.remove(target)

# 学習データとテストデータに分割
X_train, X_test, y_train, y_test = train_test_split(train_df[features], train_df[target], test_size=0.3, random_state=42)

# リッジ回帰モデルの作成
ridge = Ridge(alpha=0.1)

# モデルの学習
ridge.fit(X_train, y_train)

# 訓練データでの評価指標の確認
y_pred_train = ridge.predict(X_train)
mae_train = mean_absolute_error(y_train, y_pred_train)
print("訓練データのMAE:", mae_train)

# テストデータでの評価指標の確認
y_pred_test = ridge.predict(X_test)
mae_test = mean_absolute_error(y_test, y_pred_test)
print("テストデータのMAE:", mae_test)

# submit_dfから特徴量とターゲット変数を取得
X_submit = submit_df[features]
y_submit = submit_df['goals_for']

# モデルを使って予測
y_pred_submit = ridge.predict(X_submit)

# 平均絶対誤差の計算
mae_submit = mean_absolute_error(y_submit, y_pred_submit)
print("提出データのMAE:", mae_submit)
# 特徴量の重要度を取得
importance = np.abs(ridge.coef_)
feature_importance = pd.DataFrame({'Feature': features, 'Importance': importance})
feature_importance = feature_importance.sort_values('Importance', ascending=False)

# 特徴量の重要度を可視化
plt.figure(figsize=(10, 15))
sns.barplot(x='Importance', y='Feature', data=feature_importance)
plt.title('Feature Importance (Ridge Regression)')
plt.tight_layout()
plt.show()

ElasticNet

# ライブラリのインポート
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import ElasticNet
from sklearn.metrics import mean_absolute_error

# 目的変数と説明変数の設定
target = 'goals_for'
features = train_df.columns.tolist()
features.remove(target)

# 学習データとテストデータに分割
X_train, X_test, y_train, y_test = train_test_split(train_df[features], train_df[target], test_size=0.3, random_state=42)

# ElasticNetモデルの作成
elastic_net = ElasticNet(alpha=0.1, l1_ratio=0.5)

# モデルの学習
elastic_net.fit(X_train, y_train)

# 訓練データでの評価指標の確認
y_pred_train = elastic_net.predict(X_train)
mae_train = mean_absolute_error(y_train, y_pred_train)
print("訓練データのMAE:", mae_train)

# テストデータでの評価指標の確認
y_pred_test = elastic_net.predict(X_test)
mae_test = mean_absolute_error(y_test, y_pred_test)
print("テストデータのMAE:", mae_test)

# submit_dfから特徴量とターゲット変数を取得
X_submit = submit_df[features]
y_submit = submit_df['goals_for']

# モデルを使って予測
y_pred_submit = elastic_net.predict(X_submit)

# 平均絶対誤差の計算
mae_submit = mean_absolute_error(y_submit, y_pred_submit)
print("提出データのMAE:", mae_submit)

# 特徴量の重要度を取得
importance = np.abs(elastic_net.coef_)
feature_importance = pd.DataFrame({'Feature': features, 'Importance': importance})
feature_importance = feature_importance.sort_values('Importance', ascending=False)

# 特徴量の重要度を可視化
plt.figure(figsize=(10, 15))
sns.barplot(x='Importance', y='Feature', data=feature_importance)
plt.title('Feature Importance (ElasticNet)')
plt.tight_layout()
plt.show()

Discussion