Chapter 02無料公開

動画中のソースコード(第1回〜第4回)

競馬予想で始めるデータ分析・機械学習
競馬予想で始めるデータ分析・機械学習
2024.11.30に更新

競馬予想AIシリーズの動画を第1回から見ながら勉強している視聴者向けに、動画中のソースコードを載せています。また動画中で触れられていない注意点や、つまずきやすいポイントについても解説します。

2024/11/30追記:user-agentの設定

2024年11月からnetkeiba.comに仕様変更があり、そのままスクレイピングすると以下のようなエラーが発生します。

HTTPError: HTTP Error 400: Bad Request

これを防ぐにはuser-agentの設定をする必要があるため、以下のようなリストを設定しておき、ランダムに選択するようにします。

import random

USER_AGENTS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:115.0) Gecko/20100101 Firefox/115.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:115.0) Gecko/20100101 Firefox/115.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.0.0",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 OPR/85.0.4341.72",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 OPR/85.0.4341.72",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Vivaldi/5.3.2679.55",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Vivaldi/5.3.2679.55",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Brave/1.40.107",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Brave/1.40.107",
]

random.choice(USER_AGENTS)
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Vivaldi/5.3.2679.55'

この設定でも上記のエラーが出る場合は、しばらく間を空けてから(1日間ほど)再度アクセスしてください。スクレイピング間隔も2〜3秒間に伸ばすことが望ましいです。

第1回:Pythonで競馬データをスクレイピングする

netkeiba.comから、メインとなるレース結果データを取得するところからスタートします。

動画

ソースコード

import pandas as pd
import time
#from tqdm import tqdm_notebook as tqdm
#現在ではインポートの仕方が下のように変わっています
from tqdm.notebook import tqdm

def scrape_race_results(race_id_list, pre_race_results={}):
    #race_results = pre_race_results
    race_results = pre_race_results.copy() #正しくはこちら。注意点で解説。
    for race_id in tqdm(race_id_list):
        if race_id in race_results.keys():
            continue
        time.sleep(1)
        try:
            url = "https://db.netkeiba.com/race/" + race_id
            # 2024/11/30追記:netkeiba.comの仕様変更により追加
            headers = {'User-Agent': random.choice(USER_AGENTS)}
            html = requests.get(url, headers=headers)
            html.encoding = "EUC-JP"
            race_results[race_id] = pd.read_html(html.text)[0]
        except IndexError:
            continue
        except AttributeError: #存在しないrace_idでAttributeErrorになるページもあるので追加
            continue
        #この部分は動画中に無いですが、捕捉できるエラーは拾った方が、エラーが出たときに分かりやすいです
        except Exception as e:
            print(e)
            break
        except:
            break
    return race_results

#レースIDのリストを作る
race_id_list = []
for place in range(1, 11, 1):
    for kai in range(1, 6, 1):
        for day in range(1, 13, 1):
            for r in range(1, 13, 1):
                race_id = "2019" + str(place).zfill(2) + str(kai).zfill(2) +\
                str(day).zfill(2) + str(r).zfill(2)
                race_id_list.append(race_id)

#スクレイピングしてデータを保存
test3 = scrape_race_results(race_id_list)
for key in test3: #.keys()は無くても大丈夫です
    test3[key].index = [key] * len(test3[key])
results = pd.concat([test3[key] for key in test3], sort=False) 
results.to_pickle('results.pickle')

注意点

  1. 動画中では「9日目以降は無いだろう」と雑な判断をしてしまい、dayは8までしか回していませんが、実際には12日目まであるようです。 しかもタチの悪いことに、自分が実際にスクレイピングした時は12まで回していたようです。なので、動画よりデータ数が少ないと思ったら原因はこれです。
  2. 【重要】race_idが存在しないページについてもページの読み込み自体は行われているので、動画中ではtry文の最後にtime.sleep(1)を入れていますが、必ずtry文の最初に書くようにしてください。
  3. 少し高度な話なのですが、実は動画中のコードだとrace_resultsが変更されるとpre_race_resultsも変更されてしまいます。 これを回避するには、関数内1行目をrace_results = pre_race_results.copy()に変更します。(参考:https://qiita.com/Kaz_K/items/a3d619b9e670e689b6db)
  4. 動画ではIndexError以外のエラーが起きた場合、全てbreakをしていますが、これだとコードが間違っていた時にどんなエラーが起きているのか分かりません。 そこで現在ではExcept文の中でエラー内容を表示するようにしています。ただし、Jupyterの停止ボタンを押す操作はExceptionクラスのエラーに含まれないので、その場合は次のexceptに引っかかってbreakしてくれるようにしています
  5. 動画中では関数を実行すると辞書型が返ってきますが、どっちにしろ辞書型のままだと使えず、毎回DataFrame型に直すのでこれは関数の中に書いてしまった方が楽です。なので、Chapter03のソースコードでは関数の中にこの処理を組み込んでいます。

よくあるエラー・質問など

TypeError: list indices must be integers or slices, not str

「リスト型のインデックスは整数である必要があります。しかし文字列型が入力されましたよ。」と言われています。第1回の動画は画質を低くアップロードしてしまったせいもあり、scrape_race_results関数の1行目

race_results = {}

race_results = []

と、見間違ってリスト型で定義してしまうと上のエラーが起きるので注意してください。

エディターは何を使っていますか?

この動画では「Jupyter Notebook」を使っています。現在ではもっと使いやすい「Jupyter Lab」がリリースされているのでそちらをご利用ください。(参考: https://youtu.be/zhr_MWfZahc)

db.netkeibaのサイトでは"上り"や"通過"などの列がありますが、read_htmlで取得したデータにはなぜ表示されないのでしょうか?

この問題を解決するには正規表現についての知識が必要な他、BeautifulSoupというスクレイピングツールを使う必要があるので、第7回のソースコードの解説をご覧ください。

第2回・第3回 Pythonで競馬データを加工する

第1回で取得したデータを使える形に加工します。データ分析の前処理とよばれる段階です。

動画

ソースコード

def preprocessing(results):
    df = results.copy()

    # 着順に数字以外の文字列が含まれているものを取り除く
    df = df[~(df["着順"].astype(str).str.contains("\D"))]
    df["着順"] = df["着順"].astype(int)

    # 性齢を性と年齢に分ける
    df["性"] = df["性齢"].map(lambda x: str(x)[0])
    df["年齢"] = df["性齢"].map(lambda x: str(x)[1:]).astype(int)

    # 馬体重を体重と体重変化に分ける
    df["体重"] = df["馬体重"].str.split("(", expand=True)[0].astype(int)
    df["体重変化"] = df["馬体重"].str.split("(", expand=True)[1].str[:-1].astype(int)

    # データをint, floatに変換
    df["単勝"] = df["単勝"].astype(float)

    # 不要な列を削除
    df.drop(["タイム", "着差", "調教師", "性齢", "馬体重"], axis=1, inplace=True)

    return df

よくあるエラー・質問など

preprocessing(results)を実行すると、KeyError:'性齢'が出てしまいます。

ここで、一般的なエラーへの対処の仕方を説明しておきます。

このKeyErrorは良く出てくるエラーで、データに存在しないものを指定した時に起こるエラーです。今回であれば、データに'性齢'列が無いということです。なので例えば、出力されたエラーの文章に

---> 10 df['性'] = df['性齢'].map(lambda x: str(x)[0])

とある場合、dfという変数に'性齢'列が無いために起こっているエラーであることが分かります。この場合、preprocessing関数を

    # 性齢を性と年齢に分ける
    print(df)
    df["性"] = df["性齢"].map(lambda x: str(x)[0])
    df["年齢"] = df["性齢"].map(lambda x: str(x)[1:]).astype(int)

などと変更して、このdfという変数に何が入っているのかを確認することでエラーに対処していきます。このように、

  1. エラーがどこで起きているのか、出力文から確認
  2. printを使うなどしてエラー直前の処理が正しく行われているか確認

というステップを踏むのが、基本的なエラーへの対処の仕方になります。

馬体重に「計測不能」などの文字列がある場合、astypeで上手く変換できないです

astype(int)は整数型に変換できない文字列が一つでも含まれているとエラーになります。そういう場合はpd.numeric()を次のように使います。

df["体重"] = df["馬体重"].str.split("(", expand=True)[0]
df["体重"] = pd.to_numeric(df['体重'], errors='coerce')

errors='coerce'を指定することで、「変換できない場合は欠損値NaNにする」という処理ができます。これはよく起こることなので覚えておくととても便利です!

第4回 ロジスティック回帰で競馬予想してみた

この回では機械学習の導入として、ロジスティック回帰を使って予測モデルを作っています。ですがこの後の動画からは、より精度の高いLightGBMを使っていくことになるので、競馬予想AIの作り方だけを追いたい方はこの動画は飛ばしてもらっても大丈夫です。

動画

ソースコード

#4着以下を全て4にする
clip_rank = lambda x: x if x < 4 else 4
#動画中のresultsは、preprocessing関数で前処理が行われた後のデータ
results["rank"] = results["着順"].map(clip_rank)
results.drop(["着順", "馬名"], axis=1, inplace=True)

#カテゴリ変数をダミー変数化
results_d = pd.get_dummies(results)

#訓練データとテストデータに分ける
from sklearn.model_selection import train_test_split

X = results_d.drop(["rank"], axis=1)
y = results_d["rank"]
X_train, X_test, y_train, y_test = train_test_split(
    X, y, stratify=y, test_size=0.3, random_state=0
)

#アンダーサンプリング
from imblearn.under_sampling import RandomUnderSampler

rank_1 = y_train.value_counts()[1]
rank_2 = y_train.value_counts()[2]
rank_3 = y_train.value_counts()[3]

rus = RandomUnderSampler(
    #ratio={1: rank_1, 2: rank_2, 3: rank_3, 4: rank_1},
    sampling_strategy={1: rank_1, 2: rank_2, 3: rank_3, 4: rank_1},
    random_state=71
)

#X_train_rus, y_train_rus = rus.fit_sample(X_train.values, y_train.values)
X_train_rus, y_train_rus = rus.fit_resample(X_train.values, y_train.values)

#訓練
from sklearn.linear_model import LogisticRegression

model = LogisticRegression()
model.fit(X_train_rus, y_train_rus)

#スコアを表示
print(model.score(X_train, y_train), model.score(X_test, y_test))

#予測結果を確認
y_pred = model.predict(X_test)
pred_df = pd.DataFrame({"pred": y_pred, "actual": y_test})
pred_df[pred_df["pred"] == 1]["actual"].value_counts()

#回帰係数の確認
coefs = pd.Series(model.coef_[0], index=X.columns).sort_values()
coefs[["枠番", "馬番", "斤量", "単勝", "人気", "年齢", "体重", "体重変化"]]

注意点

  1. 動画中では、RandamUnderSamplerの引数としてratioを使っていますが、ratioの代わりにsampling_strategyを、fit_sampleの代わりにfit_resampleを使うようにRandomUnderSamplerの仕様が変わっています
  2. この動画中では変数resultsを、「preprocessing関数を通して前処理が行われた後のデータ」として使っています。なので、一度Jupyterを閉じるなどして最初からやるときは、preprocessing関数が書かれたセルを実行した後、
results = pd.read_pickle('results.pickle')
results = preprocessing(results)

として最初に前処理をしてください。