【因果推論×LLM】GeminiでPGAツアーRDDを実装:前処理・推定・可視化の全コード
前回の記事では、欧州の男子プロゴルフツアーのデータを用いて前戦の予選通過が今大会の予選通過及びストローク数に影響を与えることを示した論文の外的妥当性をチェックするため、同様のRDD(回帰不連続デザイン)の推定を全米男子ゴルフツアーのデータを用いて行いました。
元論文はこちら。
この推定を行うためには、前回大会の個人の成績、とりわけ2日目終了時点でのストローク数をカットラインとの差分で標準化するなど、スクレイピングで得たデータを加工する必要があります。また推定を行うだけでなく、共変量が閾値付近でジャンプしていないことなどを確認する必要があります。
近年ビジネスにおいて生成AIを活用して因果推論を自動化する動きがあるようです。私は生成AIをつかってコーディングを勉強したりエラーコードを解読してもらったりするなど、コーディングAIのおかげでコーディングをストレスレスに進められていると感じます。GPT-5がリリースされるなど、日進月歩の生成AIの発達を目にする我々にとって、業務の効率化のためにその活用方法を探ることはもはや競合との競争の一環に位置付けられているといっても過言ではないでしょう。
今回は、前述のRDDのレプリケーションについて、その前処理、推定、可視化を行うコードをGeminiに作ってもらおうと思います。プロンプトでは使うデータ構造や推定で用いる変数を指定し、アウトプットを確かめながらより正しいコードを出力させるプロンプトを書くための工夫を考察しようと思います。
今回のゴールと前処理のポイント
本記事では、スクレイピングで取得したPGAツアーの成績データとツアー参加選手のプロフィールデータを使用して、前大会において予選を通過したことが、次戦の予選通過確率を引き上げるか、および2日目終了時点でのストローク数を引き下げるかを推定する一連のコーディングをGeminiにすべて作成してもらうことを目指します。
本推定作業は、RDDの推定や可視化よりも、前処理の複雑さに難しさがあります。おそらく多くの推定でも、本推定ではなくそのまえの前処理に時間がかかると思います。それをAIの力で効率化させたいという気持ちがこの記事作成の動機です。
前処理では、ざっくりと以下のような作業が必要になります。
- 2日目までの合計ストローク数の算出
- 各大会の予選通過カットラインの算出
- 各大会が2値目に予選落ち/予選通過を設定しているかどうか
- 各参加者が、直前に開催された大会に参加しているかどうかの判断
- 各選手について、前回大会の成績(2日目までの合計ストローク数(カットラインを基準に標準化))を今大会の成績レコードに付与する
推定に使用するレコードかどうかの判断が複雑で、どの作業から手を付けるべきか判断を下すのに時間がかかるような作業です。
これに対処するために、生成AIでコーディングの全体像を出力してもらえれば効率的に作業を進められるのでは?と考え、まずプロンプトの検討から始めました。
プロンプト
今回使用したプロンプトは以下の通りです。
以下の指示に従って、データの読み込みから前処理、推定、可視化を行うPythonコードを出力させてください。
【目的】
- 推定したい効果:前回大会のストローク数(カットオフとの差分)が今大会の予選通過ダミーおよびストローク数(カットオフとの差分)に与える局所的平均処置効果
- 想定する因果枠組み:RDD(回帰不連続デザイン)
【データ】
- 位置と形式:
- players_links.csv:各選手の大会成績が格納されている。
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 44605 entries, 0 to 44604
Data columns (total 10 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Year 44605 non-null int64
1 DateRange 44605 non-null object
2 Tournament 44605 non-null object
3 Player 44605 non-null object
4 ProfileURL 44605 non-null object
5 Score 44605 non-null object
6 1st 44605 non-null object
7 2nd 44605 non-null object
8 3rd 44605 non-null object
9 4th 44605 non-null object
dtypes: int64(1), object(9)
Year DateRange Tournament Player ProfileURL Score 1st 2nd 3rd 4th
0 2005 Jan 6 - 9 Mercedes Championships Stuart Appleby https://www.espn.com/golf/player/_/id/11/stuar... -21 74 64 66 67
1 2005 Jan 6 - 9 Mercedes Championships Jonathan Kaye https://www.espn.com/golf/player/_/id/241/jona... -20 68 67 66 71
2 2005 Jan 6 - 9 Mercedes Championships Tiger Woods https://www.espn.com/golf/player/_/id/462/tige... -19 68 68 69 68
3 2005 Jan 6 - 9 Mercedes Championships Ernie Els https://www.espn.com/golf/player/_/id/123/erni... -19 69 65 68 71
4 2005 Jan 6 - 9 Mercedes Championships Adam Scott https://www.espn.com/golf/player/_/id/388/adam... -18 69 72 68 65
... ... ... ... ... ... ... ... ... ... ...
44600 2019 Aug 15 - 18 BMW Championship Cameron Champ https://www.espn.com/golf/player/_/id/11098/ca... E 71 68 78 71
44601 2019 Aug 15 - 18 BMW Championship J.B. Holmes https://www.espn.com/golf/player/_/id/1067/jb-... E 69 71 76 72
44602 2019 Aug 15 - 18 BMW Championship Adam Long https://www.espn.com/golf/player/_/id/6015/ada... E 72 70 71 75
44603 2019 Aug 15 - 18 BMW Championship Harold Varner III https://www.espn.com/golf/player/_/id/6991/har... +1 72 74 71 72
44604 2019 Aug 15 - 18 BMW Championship Nate Lashley https://www.espn.com/golf/player/_/id/1600/nat... +3 72 73 70 76
unique_player_profiles.csv:各選手の誕生日が格納されている。ProfileURLをキーにplayers_links.csvとジョインすることができる。
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 2053 entries, 0 to 2052
Data columns (total 3 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 ProfileURL 2053 non-null object
1 Country 1794 non-null object
2 Birthday 1350 non-null object
dtypes: object(3)
ProfileURL Country Birthday
0 https://www.espn.com/golf/player/_/id/5284/jul... Dominican Republic NaN
1 https://www.espn.com/golf/player/_/id/4566/jam... England 1/24/1985 (40)
2 https://www.espn.com/golf/player/_/id/1295/yos... Japan 8/24/1969
3 https://www.espn.com/golf/player/_/id/11406/ty... United States NaN
4 https://www.espn.com/golf/player/_/id/10162/bl... Canada NaN
... ... ... ...
2048 https://www.espn.com/golf/player/_/id/6001/sea... Ireland 3/4/1987 (38)
2049 https://www.espn.com/golf/player/_/id/786/troy... United States NaN
2050 https://www.espn.com/golf/player/_/id/709/john... United States 6/25/1975 (50)
2051 https://www.espn.com/golf/player/_/id/10/billy... United States 1/25/1964 (61)
2052 https://www.espn.com/golf/player/_/id/1650/ben... NaN NaN
- 目的変数(アウトカム):今大会の予選通過ダミー、および今大会の
- 新しく作る必要がある
- ランニング変数:前回に参加した大会における、2日目終了時点での合計ストローク数(カットラインとの差分)
- 新しく作る必要がある
- 0より小さいと予選通過を意味する
- 共変量(コントロール):大会初日時点での誕生日からの経過日数
- 経過日数を共変量に含めた推定と、共変量に含めない推定との2通りを行いたい
- 単位と時間軸:各行は各大会の個人の成績を表しています。
【識別戦略】
- 手法:RDD(カットオフは0点)
- 前提・仮定:共変量の連続性仮定、ランニング変数の操作不能仮定
【前処理】
- 欠損処理:drop
- 変数変換:ストローク数はカットラインを基準に標準化
- 共変量調整:なし
- サンプル制約:
- 直前の大会に参加した参加者のみ採用する
- 直前の大会の参加者の60%以上が参加する大会の成績のみ採用する
- 2日目に予選落ちが存在しない大会は採用しない。なお、3日目のスコアと4日目のスコアの値がともに--で、ScoreがCUTとなっている大会は2日目に予選落ちが設定されている大会。一方で、ScoreがMDFとなる選手が一人でも存在する大会は採用しない。
【推定・検証設計】
- 推定法:RDD(rdrobust)
- 検証:記述統計量の算出、McCrary検定、共変量バランス(誕生日からの経過日数)
- ただし、誕生日からの経過日数を共変量とするrdplotは作図しなくてよいです。
【出力】
- 推定結果:回帰表(rdrobust)、推定グラフ(rdplot)
- 検証結果:記述統計量の算出、McCrary検定、共変量バランス(誕生日からの経過日数)
- 保存物:推定グラフ
- ただし、誕生日からの経過日数を共変量とするrdplotは作図しなくてよいです。
【環境】
- Pythonバージョン:3.11.4 | packaged by Anaconda, Inc. | (main, Jul 5 2023, 13:38:37) [MSC v.1916 64 bit (AMD64)]
【コード形式】
- ジュピターノートブック
- 必要に応じて関数化
各csvの内容を簡単に添付しています。データ型などのデータ構造を記載することで、必要な型の変更も出力内容に含んでくれることを期待しています。このデータ構造に関しては、df.info()で出力されたものを添付しています。この作業自体pythonをつかったjupyter notebook上での作業ですので、ここまでできなくてもエクセルで当該データの先頭10行を添付することで代替できるのではないかと思います。ただし、その場合はデータ型が明示されないので、その旨をプロンプトで記載し、データ型に注意するよう命令する必要があると思います。
また新しく作る必要がある変数に関しては、その旨も記載してあります。さらに2つのcsvをジョインさせるときに使ってほしいキーも指定してあります。
出力結果を観察
出力結果は以下の通りです。
出力コード全文
import pandas as pd
import numpy as np
import re
from tqdm import tqdm
import matplotlib.pyplot as plt
from rdrobust import rdrobust, rdplot
import rdd
# tqdmの進捗表示をpandasのapplyで使えるように設定
tqdm.pandas()
def load_and_merge_data(players_file, profiles_file):
"""
データを読み込み、結合する関数
"""
print("データの読み込みと結合を開始します...")
try:
df_players = pd.read_csv(players_file)
df_profiles = pd.read_csv(profiles_file)
df = pd.merge(df_players, df_profiles, on='ProfileURL', how='left')
print("データの読み込みと結合が完了しました。")
return df
except FileNotFoundError as e:
print(f"エラー: ファイルが見つかりません。'{e.filename}' を確認してください。")
return None
def clean_score(score):
"""
スコアから数値のみを抽出する関数
"""
if isinstance(score, str):
if score in ['CUT', 'WD', 'DQ', 'MDF']:
return np.nan
if score == 'E':
return 0
if '+' in score:
return int(score.replace('+', ''))
return pd.to_numeric(score, errors='coerce')
def preprocess_data(df):
"""
データの前処理を行う関数
"""
if df is None:
return None
print("データの前処理を開始します...")
# データ型の変換とクリーニング
score_cols = ['Score', '1st', '2nd', '3rd', '4th']
for col in score_cols:
df[col] = df[col].replace('--', np.nan)
if col == 'Score':
df[col] = df[col].apply(clean_score)
else:
df[col] = pd.to_numeric(df[col], errors='coerce')
# 日付関連の処理
df['StartDate'] = pd.to_datetime(df['DateRange'].str.split(' - ').str[0] + ', ' + df['Year'].astype(str), errors='coerce')
df['Birthday'] = df['Birthday'].apply(lambda x: re.search(r'\d{1,2}/\d{1,2}/\d{4}', str(x)).group(0) if re.search(r'\d{1,2}/\d{1,2}/\d{4}', str(x)) else np.nan)
df['Birthday'] = pd.to_datetime(df['Birthday'], errors='coerce')
# MDFが存在する大会を除外
mdf_tournaments = df[df['Score'].notna() & df['3rd'].notna() & df['4th'].isna()][['Year', 'Tournament']].drop_duplicates()
if not mdf_tournaments.empty:
df = df.merge(mdf_tournaments, on=['Year', 'Tournament'], how='left', indicator=True)
df = df[df['_merge'] == 'left_only'].drop(columns=['_merge'])
# カットラインの計算
df['TotalScore_2days'] = df['1st'] + df['2nd']
df['is_cut'] = (df['Score'].isna()) & (df['3rd'].isna()) & (df['4th'].isna())
cutoff_scores = df[df['is_cut']].groupby(['Year', 'Tournament'])['TotalScore_2days'].max().reset_index()
cutoff_scores.rename(columns={'TotalScore_2days': 'CutoffLine'}, inplace=True)
df = pd.merge(df, cutoff_scores, on=['Year', 'Tournament'], how='left')
# サンプル制約
df.dropna(subset=['CutoffLine', 'TotalScore_2days'], inplace=True)
# 変数作成
df['Strokes_diff_2days'] = df['TotalScore_2days'] - df['CutoffLine']
df['Made_cut'] = (df['Strokes_diff_2days'] <= 0).astype(int)
# 前回大会の成績を特定
df.sort_values(by=['Player', 'StartDate'], inplace=True)
for col in ['Strokes_diff_2days', 'Tournament', 'Year', 'StartDate']:
df[f'Prev_{col}'] = df.groupby('Player')[col].shift(1)
df.dropna(subset=['Prev_Strokes_diff_2days'], inplace=True)
# サンプル制約: 参加率
players_per_tournament = df.groupby(['Year', 'Tournament'])['Player'].apply(set)
valid_tournaments = []
for (year, tournament), group in tqdm(df.groupby(['Year', 'Tournament']), desc="Filtering tournaments"):
current_players = players_per_tournament.get((year, tournament), set())
if group.empty or 'Prev_Year' not in group.columns or 'Prev_Tournament' not in group.columns:
continue
prev_tournament_counts = group.dropna(subset=['Prev_Year', 'Prev_Tournament']).groupby(['Prev_Year', 'Prev_Tournament']).size()
if prev_tournament_counts.empty:
continue
prev_year, prev_tournament_name = prev_tournament_counts.idxmax()
prev_tournament_players = players_per_tournament.get((prev_year, prev_tournament_name), set())
if len(prev_tournament_players) > 0:
rate = len(current_players.intersection(prev_tournament_players)) / len(prev_tournament_players)
if rate >= 0.6:
valid_tournaments.append((year, tournament))
valid_tournaments_set = set(valid_tournaments)
df_filtered = df[df.apply(lambda row: (row['Year'], row['Tournament']) in valid_tournaments_set, axis=1)].copy()
# 共変量(年齢)の計算
df_filtered['Age_at_tournament_start'] = (df_filtered['StartDate'] - df_filtered['Birthday']).dt.days
df_filtered.dropna(subset=['Age_at_tournament_start'], inplace=True)
print(f"データの前処理が完了しました。最終的なサンプルサイズ: {len(df_filtered)}")
return df_filtered
def run_rdd_analysis(df):
"""
RDD分析を実行し、結果を出力する関数
"""
if df is None or df.empty:
print("分析に使用できるデータがありません。")
return
print("\nRDD分析を開始します...")
# 変数定義とデータ準備
outcome_made_cut = 'Made_cut'
outcome_strokes_diff = 'Strokes_diff_2days'
running_variable = 'Prev_Strokes_diff_2days'
covariate = 'Age_at_tournament_start'
cutoff = 0
df.dropna(subset=[outcome_made_cut, outcome_strokes_diff, running_variable, covariate], inplace=True)
if df.empty:
print("欠損値を除去した結果、分析に使用できるデータがありません。")
return
# 1. 記述統計量
print("\n--- 1. 記述統計量 ---")
print(df[[outcome_made_cut, outcome_strokes_diff, running_variable, covariate]].describe())
# 2. McCrary検定
print("\n--- 2. McCrary検定 ---")
try:
density_est = rdd.rdd_density(df[running_variable].to_numpy(), cut=cutoff)
print(density_est)
fig, ax = plt.subplots()
ax.plot(density_est.X, density_est.Y, label='Density')
ax.axvline(x=cutoff, color='r', linestyle='--', label='Cutoff')
ax.set_title('McCrary Density Test')
ax.set_xlabel('Running Variable (Previous Strokes Difference)')
ax.set_ylabel('Density')
ax.legend()
plt.savefig('mccrary_test.png')
plt.show()
print("McCrary検定のプロットを 'mccrary_test.png' として保存しました。")
except Exception as e:
print(f"McCrary検定中にエラーが発生しました: {e}")
# 3. 共変量バランスの確認
print(f"\n--- 3. 共変量バランスの確認 ({covariate}) ---")
cov_balance_est = rdrobust(y=df[covariate], x=df[running_variable], c=cutoff)
print(cov_balance_est)
print("結果の解釈: p > |z| が有意水準(例: 0.05)より大きい場合、共変量はバランスしていると解釈できます。")
# 4. RDD推定
print("\n--- 4. RDD推定 ---")
print(f"\n[モデル1] アウトカム: {outcome_made_cut}, 共変量なし")
est1 = rdrobust(y=df[outcome_made_cut], x=df[running_variable], c=cutoff)
print(est1)
print(f"\n[モデル2] アウトカム: {outcome_strokes_diff}, 共変量なし")
est2 = rdrobust(y=df[outcome_strokes_diff], x=df[running_variable], c=cutoff)
print(est2)
covs = df[[covariate]]
print(f"\n[モデル3] アウトカム: {outcome_made_cut}, 共変量あり")
est3 = rdrobust(y=df[outcome_made_cut], x=df[running_variable], c=cutoff, covs=covs)
print(est3)
print(f"\n[モデル4] アウトカム: {outcome_strokes_diff}, 共変量あり")
est4 = rdrobust(y=df[outcome_strokes_diff], x=df[running_variable], c=cutoff, covs=covs)
print(est4)
# 5. 可視化
print("\n--- 5. 結果の可視化 (rdplot) ---")
try:
fig1, ax1 = plt.subplots()
rdplot(y=df[outcome_made_cut], x=df[running_variable], c=cutoff, ax=ax1,
title='RDD: Making the Cut',
x_label='Previous Strokes Relative to Cutoff',
y_label='Probability of Making the Cut')
plt.savefig('rdd_plot_made_cut.png')
print("予選通過ダミーの推定グラフを 'rdd_plot_made_cut.png' として保存しました。")
plt.show()
fig2, ax2 = plt.subplots()
rdplot(y=df[outcome_strokes_diff], x=df[running_variable], c=cutoff, ax=ax2,
title='RDD: Strokes Relative to Cutoff',
x_label='Previous Strokes Relative to Cutoff',
y_label='Current Strokes Relative to Cutoff')
plt.savefig('rdd_plot_strokes_diff.png')
print("ストローク数の推定グラフを 'rdd_plot_strokes_diff.png' として保存しました。")
plt.show()
except Exception as e:
print(f"プロット作成中にエラーが発生しました: {e}")
if __name__ == '__main__':
players_file = 'players_links.csv'
profiles_file = 'unique_player_profiles.csv'
df_raw = load_and_merge_data(players_file, profiles_file)
df_processed = preprocess_data(df_raw)
run_rdd_analysis(df_processed)
if df_processed is not None and not df_processed.empty:
df_processed.to_csv('processed_golf_data.csv', index=False)
print("\n前処理済みのデータを 'processed_golf_data.csv' として保存しました。")
流し見した所感としては、共通する変数処理、前処理、推定、可視化のそれぞれで関数を定義していて最後に実行する流れとなっており、とても見やすく感じます。
それでは、一つ一つコードを見ながら、こちらが意図したコーディングが実装されているか確認してみましょう。
データ読み込み
データ読み込みコード
def load_and_merge_data(players_file, profiles_file):
"""
データを読み込み、結合する関数
"""
print("データの読み込みと結合を開始します...")
try:
df_players = pd.read_csv(players_file)
df_profiles = pd.read_csv(profiles_file)
df = pd.merge(df_players, df_profiles, on='ProfileURL', how='left')
print("データの読み込みと結合が完了しました。")
return df
except FileNotFoundError as e:
print(f"エラー: ファイルが見つかりません。'{e.filename}' を確認してください。")
return None
まず使用する2つのcsvを読み込んでいます。これは後続のコードにも共通して言えることですが、想定したcsvが存在しないときにエラーメッセージが出せるようになっていて、勉強になります。また、
エラー: ファイルが見つかりません。'{e.filename}' を確認してください。
のように、エラー時にどのファイルが存在しないかを出力するようになっていて、自分でコーディングするときには作らないような細かいエラーメッセージの仕様にもAIコーディングの利点があらわれているように見えます。
今回はプロンプト内でエラー発生時の対処方法を指定していなかったのですが、エラー発生時にどのような出力を指せるのかについてプロンプトで指定をしておくと、こういったエラー内容を出力させるコードが再現性高く出力されると思います。これは最後にプロンプトを修正するときの工夫点に組み込もうと思います。
前処理
前処理コード
def clean_score(score):
"""
スコアから数値のみを抽出する関数
"""
if isinstance(score, str):
if score in ['CUT', 'WD', 'DQ', 'MDF']:
return np.nan
if score == 'E':
return 0
if '+' in score:
return int(score.replace('+', ''))
return pd.to_numeric(score, errors='coerce')
def preprocess_data(df):
"""
データの前処理を行う関数
"""
if df is None:
return None
print("データの前処理を開始します...")
# データ型の変換とクリーニング
score_cols = ['Score', '1st', '2nd', '3rd', '4th']
for col in score_cols:
df[col] = df[col].replace('--', np.nan)
if col == 'Score':
df[col] = df[col].apply(clean_score)
else:
df[col] = pd.to_numeric(df[col], errors='coerce')
# 日付関連の処理
df['StartDate'] = pd.to_datetime(df['DateRange'].str.split(' - ').str[0] + ', ' + df['Year'].astype(str), errors='coerce')
df['Birthday'] = df['Birthday'].apply(lambda x: re.search(r'\d{1,2}/\d{1,2}/\d{4}', str(x)).group(0) if re.search(r'\d{1,2}/\d{1,2}/\d{4}', str(x)) else np.nan)
df['Birthday'] = pd.to_datetime(df['Birthday'], errors='coerce')
# MDFが存在する大会を除外
mdf_tournaments = df[df['Score'].notna() & df['3rd'].notna() & df['4th'].isna()][['Year', 'Tournament']].drop_duplicates()
if not mdf_tournaments.empty:
df = df.merge(mdf_tournaments, on=['Year', 'Tournament'], how='left', indicator=True)
df = df[df['_merge'] == 'left_only'].drop(columns=['_merge'])
# カットラインの計算
df['TotalScore_2days'] = df['1st'] + df['2nd']
df['is_cut'] = (df['Score'].isna()) & (df['3rd'].isna()) & (df['4th'].isna())
cutoff_scores = df[df['is_cut']].groupby(['Year', 'Tournament'])['TotalScore_2days'].max().reset_index()
cutoff_scores.rename(columns={'TotalScore_2days': 'CutoffLine'}, inplace=True)
df = pd.merge(df, cutoff_scores, on=['Year', 'Tournament'], how='left')
# サンプル制約
df.dropna(subset=['CutoffLine', 'TotalScore_2days'], inplace=True)
# 変数作成
df['Strokes_diff_2days'] = df['TotalScore_2days'] - df['CutoffLine']
df['Made_cut'] = (df['Strokes_diff_2days'] <= 0).astype(int)
# 前回大会の成績を特定
df.sort_values(by=['Player', 'StartDate'], inplace=True)
for col in ['Strokes_diff_2days', 'Tournament', 'Year', 'StartDate']:
df[f'Prev_{col}'] = df.groupby('Player')[col].shift(1)
df.dropna(subset=['Prev_Strokes_diff_2days'], inplace=True)
# サンプル制約: 参加率
players_per_tournament = df.groupby(['Year', 'Tournament'])['Player'].apply(set)
valid_tournaments = []
for (year, tournament), group in tqdm(df.groupby(['Year', 'Tournament']), desc="Filtering tournaments"):
current_players = players_per_tournament.get((year, tournament), set())
if group.empty or 'Prev_Year' not in group.columns or 'Prev_Tournament' not in group.columns:
continue
prev_tournament_counts = group.dropna(subset=['Prev_Year', 'Prev_Tournament']).groupby(['Prev_Year', 'Prev_Tournament']).size()
if prev_tournament_counts.empty:
continue
prev_year, prev_tournament_name = prev_tournament_counts.idxmax()
prev_tournament_players = players_per_tournament.get((prev_year, prev_tournament_name), set())
if len(prev_tournament_players) > 0:
rate = len(current_players.intersection(prev_tournament_players)) / len(prev_tournament_players)
if rate >= 0.6:
valid_tournaments.append((year, tournament))
valid_tournaments_set = set(valid_tournaments)
df_filtered = df[df.apply(lambda row: (row['Year'], row['Tournament']) in valid_tournaments_set, axis=1)].copy()
# 共変量(年齢)の計算
df_filtered['Age_at_tournament_start'] = (df_filtered['StartDate'] - df_filtered['Birthday']).dt.days
df_filtered.dropna(subset=['Age_at_tournament_start'], inplace=True)
print(f"データの前処理が完了しました。最終的なサンプルサイズ: {len(df_filtered)}")
return df_filtered
まずスコアに関する列を数値に変換することから始めています。
Scoreには4日間の合計ストローク数、1st,2nd,3rd,4thには各日のストローク数が格納されています。ただし、予選落ちやケガでの途中棄権が発生した場合、プレーしていない日には -- という値が格納されています。またScore列には、4日間プレーした場合にはトータルのストローク数が、予選落ちが起こった場合にはCUTが格納され、それ以外でも 'WD', 'DQ', 'MDF'などの値が格納されているケースが散見されます。これを数値として使用する場合に必要な加工が実施できるコードになっていると思います。このあたりは、プロンプト内で変数ごとのデータ型を記載したり、「新しく作る必要がある」と記載したおかげかもしれません。
日付の処理に関しても同様に、date型に処理できています。
他方、次の「MDFが存在する大会を除外」する処理は、こちらの想定と異なるコードが出力されてしまいました。
私の想定としては、Score列に'MDF'という値が格納されている行が一行でもある場合、その大会を推定で使用しないようにしたい、という要望でした。
しかし結果として、MDFを「3日目はプレーしたが、4日目にプレーしなかった」という定義通りに汲み取り、以下のように抽出しています。
mdf_tournaments = df[df['Score'].notna() & df['3rd'].notna() & df['4th'].isna()][['Year', 'Tournament']].drop_duplicates()
これは間違いではなく、むしろ定義に忠実なコードとなっています。
続いての「カットラインの計算」は、若干問題をはらんでいます。
# カットラインの計算
df['TotalScore_2days'] = df['1st'] + df['2nd']
df['is_cut'] = (df['Score'].isna()) & (df['3rd'].isna()) & (df['4th'].isna())
cutoff_scores = df[df['is_cut']].groupby(['Year', 'Tournament'])['TotalScore_2days'].max().reset_index()
cutoff_scores.rename(columns={'TotalScore_2days': 'CutoffLine'}, inplace=True)
df = pd.merge(df, cutoff_scores, on=['Year', 'Tournament'], how='left')
is_cutという変数は、3,4日目に進めなかった選手であるか否かをTrue/Falseで表す変数となっていて、cutodd_scoresではYearとTournamentごとにis_cutがTrueである(3,4日目に進めなかった)選手に限って1,2日目の合計ストローク数(TotalScore_2days)の最大値を算出しており、これがカットラインとされています。
しかしながらこれは、カットラインの定義と異なります。
原因は2つあります。
1つは、is_cutの条件に df['Score'].isna() があることです。前述の通り、Score列は4日間の合計ストローク数が格納されていますが、元のcsvでは数字以外に 'CUT', 'WD', 'DQ', 'MDF' といった値が格納されています。
本来、Score列に'CUT'が格納されている列のみを抽出したいはずですが、すでにこの列は数字以外の値が格納されていた場合欠損値になっているため、予選落ち以外で途中棄権した場合なども抽出されてしまいます。このとき、本来is_cutでTRUEが格納されていてほしい行以外もTRUEが格納されてしまう可能性があります。
原因の2つ目は、cutoff_scoresの誤った算出方法です。おそらくここには大会ごとのカットラインを算出し格納されていたいと考察します。しかしながら、is_cutがTRUE(2日目終了時点で予選落ち)の行の最大値をとっているため、カットラインの打数ではなく最も成績が悪い人の打数が格納されてしまいます。
カットラインのストローク数の算出方法は、厳密にはCUTとなってしまったプレイヤーの2日目終了時点での合計ストトーク数からは算出できず、むしろ3日目以降に進出したプレイヤーの2日目終了時点での合計ストトーク数の最大値を求めることで取得することができます。
これはプロンプトでできるだけ詳細な算出方法を指定することで解決できるのではないかと想像します。
その後の前処理コードは、特に問題が無いように見えます。
特に前回大会の参加率を基にサンプルを選択するコードは、算出方法が大変参考になりました。
ただしこの部分は元論文でオープンアクセスとなっていないので、ここで粒立てて取り立てることはしません。
共変量の算出に関しては、事前に誕生日と大会開催日をdate型に直しておいたため、以下のように簡単に算出できています。
df_filtered['Age_at_tournament_start'] = (df_filtered['StartDate'] - df_filtered['Birthday']).dt.days
またこの前処理の工程は大きな問題をはらんでいます。それは 「直前の大会に参加しているかどうか」を適切に判断できていない という問題です。
この処理では早い段階で「MDFが存在する大会を除外」しており、それを行った後に各参加者が参加した直前の大会の成績を取得しています。この手順だと、取得できるは前回大会ではなく、MDFが存在しない大会に絞ったうえでの直前の大会になってしまいます。これも修正する必要があります。
推定と仮定の確認
続いて、記述統計量の算出、McCrary検定、共変量のバランス確認、RDD推定、可視化を行うコードをチェックします。RDDにおいては、閾値前後で共変量が連続していることや、ランニング変数の操作が起こっていないことなどが仮定となっています。共変量の連続性を確認するため、多くの論文でMcCrary検定や共変量のバランスを確認しています。これらをチェックしたら必ず仮定が満たされると断言できるわけではないようですが、確認するに越したことはないと思います。
推定と仮定の確認
def run_rdd_analysis(df):
"""
RDD分析を実行し、結果を出力する関数
"""
if df is None or df.empty:
print("分析に使用できるデータがありません。")
return
print("\nRDD分析を開始します...")
# 変数定義とデータ準備
outcome_made_cut = 'Made_cut'
outcome_strokes_diff = 'Strokes_diff_2days'
running_variable = 'Prev_Strokes_diff_2days'
covariate = 'Age_at_tournament_start'
cutoff = 0
df.dropna(subset=[outcome_made_cut, outcome_strokes_diff, running_variable, covariate], inplace=True)
if df.empty:
print("欠損値を除去した結果、分析に使用できるデータがありません。")
return
# 1. 記述統計量
print("\n--- 1. 記述統計量 ---")
print(df[[outcome_made_cut, outcome_strokes_diff, running_variable, covariate]].describe())
# 2. McCrary検定
print("\n--- 2. McCrary検定 ---")
try:
density_est = rdd.rdd_density(df[running_variable].to_numpy(), cut=cutoff)
print(density_est)
fig, ax = plt.subplots()
ax.plot(density_est.X, density_est.Y, label='Density')
ax.axvline(x=cutoff, color='r', linestyle='--', label='Cutoff')
ax.set_title('McCrary Density Test')
ax.set_xlabel('Running Variable (Previous Strokes Difference)')
ax.set_ylabel('Density')
ax.legend()
plt.savefig('mccrary_test.png')
plt.show()
print("McCrary検定のプロットを 'mccrary_test.png' として保存しました。")
except Exception as e:
print(f"McCrary検定中にエラーが発生しました: {e}")
# 3. 共変量バランスの確認
print(f"\n--- 3. 共変量バランスの確認 ({covariate}) ---")
cov_balance_est = rdrobust(y=df[covariate], x=df[running_variable], c=cutoff)
print(cov_balance_est)
print("結果の解釈: p > |z| が有意水準(例: 0.05)より大きい場合、共変量はバランスしていると解釈できます。")
# 4. RDD推定
print("\n--- 4. RDD推定 ---")
print(f"\n[モデル1] アウトカム: {outcome_made_cut}, 共変量なし")
est1 = rdrobust(y=df[outcome_made_cut], x=df[running_variable], c=cutoff)
print(est1)
print(f"\n[モデル2] アウトカム: {outcome_strokes_diff}, 共変量なし")
est2 = rdrobust(y=df[outcome_strokes_diff], x=df[running_variable], c=cutoff)
print(est2)
covs = df[[covariate]]
print(f"\n[モデル3] アウトカム: {outcome_made_cut}, 共変量あり")
est3 = rdrobust(y=df[outcome_made_cut], x=df[running_variable], c=cutoff, covs=covs)
print(est3)
print(f"\n[モデル4] アウトカム: {outcome_strokes_diff}, 共変量あり")
est4 = rdrobust(y=df[outcome_strokes_diff], x=df[running_variable], c=cutoff, covs=covs)
print(est4)
# 5. 可視化
print("\n--- 5. 結果の可視化 (rdplot) ---")
try:
fig1, ax1 = plt.subplots()
rdplot(y=df[outcome_made_cut], x=df[running_variable], c=cutoff, ax=ax1,
title='RDD: Making the Cut',
x_label='Previous Strokes Relative to Cutoff',
y_label='Probability of Making the Cut')
plt.savefig('rdd_plot_made_cut.png')
print("予選通過ダミーの推定グラフを 'rdd_plot_made_cut.png' として保存しました。")
plt.show()
fig2, ax2 = plt.subplots()
rdplot(y=df[outcome_strokes_diff], x=df[running_variable], c=cutoff, ax=ax2,
title='RDD: Strokes Relative to Cutoff',
x_label='Previous Strokes Relative to Cutoff',
y_label='Current Strokes Relative to Cutoff')
plt.savefig('rdd_plot_strokes_diff.png')
print("ストローク数の推定グラフを 'rdd_plot_strokes_diff.png' として保存しました。")
plt.show()
except Exception as e:
print(f"プロット作成中にエラーが発生しました: {e}")
McCrary検定に関しては、rddensityパッケージを使ってほしかったのですが、こちらでは誤ったコードが出力されています。
またrdplotに関しては、どうやらaxの指定が想定されていないようなので、この部分は削除したいです。
これらを踏まえて、最初のプロンプトに以下の要素を追加しようと思います。
【要注意事項】
- 「MDFが存在する大会を除外」する処理は、Score列に'MDF'という値が格納されている行が一行でもある場合、その大会を推定で使用しないようにしたい、という要望です
- カットラインのストローク数の算出方法は、Score列の値が'CUT'であるプレイヤーの2日目終了時点での合計ストトーク数からは算出できず、Score列の値が'CUT', 'WD', 'DQ', 'MDF'のいずれの値でもない場合において、3日目以降に進出したプレイヤーの2日目終了時点での合計ストトーク数の最大値を求めることで取得してください
- McCrary検定は、rddensityライブラリを使用する。import rddensityとして
- rdplotではaxを使用しない
- rdrobust()では、all = Trueを指定して
- サンプル制約でサンプルを除外してから直前の大会に参加しているかどうかを判断するのは間違った工程です。サンプルを除外する前に、直前の大会に参加しているかどうかを判断して。そのうえで、直前の大会のカットラインとの差分を取得し、それが終わったらサンプル制約を考慮してサンプルを除外して
- 「直前の大会に参加した参加者のみ採用する」とは、Year, Tournament, StartDateの値を抽出して重複を除き、YearとStartDateで並び替えを行ったうえで、shift(1)を行って得られたYear, Tournamentと、Playerでgroupbyしてshift(1)して得られたYear, Tournamentとが一致するサンプルのみを使用する、という意味です。
これを【要注意事項】以下に追加したプロンプトを入力し出力されたコードは以下の通りです。
修正したプロンプトを入力して得た出力結果
import pandas as pd
import numpy as np
import re
from tqdm import tqdm
from rdrobust import rdrobust, rdplot
import rddensity
import matplotlib.pyplot as plt
# tqdmの進捗表示をpandasのapplyで使えるように設定
tqdm.pandas()
def load_and_merge_data(players_file, profiles_file):
"""
データを読み込み、結合する関数
"""
print("データの読み込みと結合を開始します...")
df_players = pd.read_csv(players_file)
df_profiles = pd.read_csv(profiles_file)
df = pd.merge(df_players, df_profiles, on='ProfileURL', how='left')
print("データの読み込みと結合が完了しました。")
return df
def clean_score(score):
"""
スコアから数値のみを抽出する関数
"""
if isinstance(score, str):
if score in ['CUT', 'WD', 'DQ', 'MDF']:
return np.nan
if score == 'E':
return 0
if '+' in score:
return int(score.replace('+', ''))
return pd.to_numeric(score, errors='coerce')
def preprocess_data(df):
"""
データの前処理を行う関数
"""
print("データの前処理を開始します...")
# --- ステップ1: 全データの初期化と変数作成 ---
df['Score_str'] = df['Score'].astype(str)
df['Score'] = df['Score'].apply(clean_score)
score_cols = ['1st', '2nd', '3rd', '4th']
for col in score_cols:
df[col] = df[col].replace('--', np.nan)
df[col] = pd.to_numeric(df[col], errors='coerce')
df['StartDate'] = pd.to_datetime(df['DateRange'].str.split(' - ').str[0] + ', ' + df['Year'].astype(str), errors='coerce')
df['Birthday'] = df['Birthday'].apply(lambda x: re.search(r'\d{1,2}/\d{1,2}/\d{4}', str(x)).group(0) if re.search(r'\d{1,2}/\d{1,2}/\d{4}', str(x)) else np.nan)
df['Birthday'] = pd.to_datetime(df['Birthday'], errors='coerce')
df['TotalScore_2days'] = df['1st'] + df['2nd']
made_cut_players = df[df['3rd'].notna()].copy()
cutoff_scores = made_cut_players.groupby(['Year', 'Tournament'])['TotalScore_2days'].max().reset_index()
cutoff_scores.rename(columns={'TotalScore_2days': 'CutoffLine'}, inplace=True)
df = pd.merge(df, cutoff_scores, on=['Year', 'Tournament'], how='left')
df['Strokes_diff_2days'] = df['TotalScore_2days'] - df['CutoffLine']
# --- ステップ2: サンプル制約適用「前」に前回大会の情報を取得 ---
print("サンプル制約適用前に、全データから前回大会の情報を取得します...")
df.sort_values(by=['Player', 'StartDate'], inplace=True)
df['Prev_Strokes_diff_2days'] = df.groupby('Player')['Strokes_diff_2days'].shift(1)
df['Prev_Tournament'] = df.groupby('Player')['Tournament'].shift(1)
df['Prev_Year'] = df.groupby('Player')['Year'].shift(1)
# この時点で前回大会の情報がないデータは除外
df.dropna(subset=['Prev_Strokes_diff_2days', 'Prev_Tournament', 'Prev_Year'], inplace=True)
# --- ステップ3: 新しいサンプル制約「直前の大会」の定義を適用 ---
print("厳密な定義に基づき、「直前の大会」に参加したサンプルを抽出します...")
# 1. 公式の大会スケジュールを作成
tournaments_schedule = df[['Year', 'Tournament', 'StartDate']].drop_duplicates().sort_values(by=['Year', 'StartDate'])
# 2. 各大会の「公式な前回大会」を特定
tournaments_schedule['Official_Prev_Year'] = tournaments_schedule['Year'].shift(1)
tournaments_schedule['Official_Prev_Tournament'] = tournaments_schedule['Tournament'].shift(1)
# 3. 元のデータに公式スケジュールをマージ
df = pd.merge(df, tournaments_schedule.drop(columns='StartDate'), on=['Year', 'Tournament'], how='left')
# 4. 選手の個人的な前回大会と、公式な前回大会が一致するサンプルのみに絞り込む
original_rows = len(df)
df = df[(df['Prev_Year'] == df['Official_Prev_Year']) & (df['Prev_Tournament'] == df['Official_Prev_Tournament'])].copy()
print(f"「直前の大会」の厳密な定義により {original_rows - len(df)} 行を除外しました。")
# --- ステップ4: その他のサンプル制約を適用 ---
print("残りのサンプル制約を適用します...")
# 制約: MDFが存在する大会を除外
mdf_tournaments = df[df['Score_str'] == 'MDF'][['Year', 'Tournament']].drop_duplicates()
if not mdf_tournaments.empty:
mdf_tournament_set = set(zip(mdf_tournaments['Year'], mdf_tournaments['Tournament']))
original_rows = len(df)
df = df[~df.apply(lambda row: (row['Year'], row['Tournament']) in mdf_tournament_set, axis=1)]
print(f"{original_rows - len(df)} 行({len(mdf_tournaments)}大会分)をMDFのため除外しました。")
# 制約: 60%ルール
players_per_tournament = df.groupby(['Year', 'Tournament'])['Player'].apply(set)
valid_tournaments = []
for (year, tournament), group in tqdm(df.groupby(['Year', 'Tournament']), desc="Filtering by 60% rule"):
current_players = players_per_tournament.get((year, tournament), set())
prev_tournament_counts = group.dropna(subset=['Prev_Year', 'Prev_Tournament']).groupby(['Prev_Year', 'Prev_Tournament']).size()
if prev_tournament_counts.empty: continue
most_common_prev = prev_tournament_counts.idxmax()
prev_year, prev_tournament_name = most_common_prev
prev_tournament_players = players_per_tournament.get((prev_year, prev_tournament_name), set())
if len(prev_tournament_players) > 0:
rate = len(current_players.intersection(prev_tournament_players)) / len(prev_tournament_players)
if rate >= 0.6:
valid_tournaments.append((year, tournament))
original_rows = len(df)
valid_tournaments_set = set(valid_tournaments)
df = df[df.apply(lambda row: (row['Year'], row['Tournament']) in valid_tournaments_set, axis=1)].copy()
print(f"60%ルールでフィルタリングした結果、{original_rows - len(df)} 行を除外しました。")
# --- ステップ5: 最終的な変数作成とクリーニング ---
df['Made_cut'] = (df['Strokes_diff_2days'] <= 0).astype(int)
df['Age_at_tournament_start'] = (df['StartDate'] - df['Birthday']).dt.days
final_cols = ['Strokes_diff_2days', 'Made_cut', 'Prev_Strokes_diff_2days', 'Age_at_tournament_start']
df.dropna(subset=final_cols, inplace=True)
print(f"データの前処理が完了しました。最終的なサンプルサイズ: {len(df)}")
return df
def run_rdd_analysis(df):
"""
RDD分析を実行し、結果を出力する関数
"""
if df is None or df.empty:
print("分析に使用できるデータがありません。")
return
print("\nRDD分析を開始します...")
# 変数定義
outcome_made_cut = 'Made_cut'
outcome_strokes_diff = 'Strokes_diff_2days'
running_variable = 'Prev_Strokes_diff_2days'
covariate = 'Age_at_tournament_start'
cutoff = 0
# 1. 記述統計量
print("\n--- 1. 記述統計量 ---")
print(df[[outcome_made_cut, outcome_strokes_diff, running_variable, covariate]].describe())
# 2. McCrary検定
print("\n--- 2. McCrary検定 ---")
try:
density_est = rddensity.rddensity(X=df[running_variable], c=cutoff)
print(density_est)
except Exception as e:
print(f"McCrary検定中にエラーが発生しました: {e}")
# 3. 共変量バランスの確認
print(f"\n--- 3. 共変量バランスの確認 ({covariate}) ---")
cov_balance_est = rdrobust(y=df[covariate], x=df[running_variable], c=cutoff, all=True)
print(cov_balance_est)
print("結果の解釈: p > |z| が有意水準(例: 0.05)より大きい場合、共変量はバランスしていると解釈できます。")
# 4. RDD推定
print("\n--- 4. RDD推定 ---")
print(f"\n[モデル1] アウトカム: {outcome_made_cut}, 共変量なし")
est1 = rdrobust(y=df[outcome_made_cut], x=df[running_variable], c=cutoff, all=True)
print(est1)
print(f"\n[モデル2] アウトカム: {outcome_strokes_diff}, 共変量なし")
est2 = rdrobust(y=df[outcome_strokes_diff], x=df[running_variable], c=cutoff, all=True)
print(est2)
covs = df[[covariate]]
print(f"\n[モデル3] アウトカム: {outcome_made_cut}, 共変量あり")
est3 = rdrobust(y=df[outcome_made_cut], x=df[running_variable], c=cutoff, covs=covs, all=True)
print(est3)
print(f"\n[モデル4] アウトカム: {outcome_strokes_diff}, 共変量あり")
est4 = rdrobust(y=df[outcome_strokes_diff], x=df[running_variable], c=cutoff, covs=covs, all=True)
print(est4)
# 5. 可視化 (axを使用しない)
print("\n--- 5. 結果の可視化 (rdplot) ---")
try:
print("予選通過ダミーの推定グラフを作成・保存します...")
rdplot(y=df[outcome_made_cut], x=df[running_variable], c=cutoff,
title='RDD: Making the Cut',
x_label='Previous Strokes Relative to Cutoff',
y_label='Probability of Making the Cut')
plt.savefig('rdd_plot_made_cut.png')
plt.show()
print("グラフを 'rdd_plot_made_cut.png' として保存しました。")
print("ストローク数の推定グラフを作成・保存します...")
rdplot(y=df[outcome_strokes_diff], x=df[running_variable], c=cutoff,
title='RDD: Strokes Relative to Cutoff',
x_label='Previous Strokes Relative to Cutoff',
y_label='Current Strokes Relative to Cutoff')
plt.savefig('rdd_plot_strokes_diff.png')
plt.show()
print("グラフを 'rdd_plot_strokes_diff.png' として保存しました。")
except Exception as e:
print(f"プロット作成中にエラーが発生しました: {e}")
if __name__ == '__main__':
players_file = 'players_links.csv'
profiles_file = 'unique_player_profiles.csv'
df_raw = load_and_merge_data(players_file, profiles_file)
df_processed = preprocess_data(df_raw)
run_rdd_analysis(df_processed)
if df_processed is not None and not df_processed.empty:
df_processed.to_csv('processed_golf_data.csv', index=False)
print("\n前処理済みのデータを 'processed_golf_data.csv' として保存しました。")
これまで指摘していた問題点は解決しているように見えます。やはり一発で求めているコードが出てくるということはなく、(こちらのプロンプトエンジニアリング力の問題もありますが、)何度かやり取りしながら求めているコードにたどり着くという方向性が現実的だと思われます。
推定結果はおおむね前回記事と同じでした。ただし前処理の部分で細かい加工方法の違いがあり、サンプルで用いられたレコードが異なったことが原因と思われますが、推定結果には若干の違いが見られました。しかし得られた推定量の正負の方向の違いや大きな数値の違いはありませんでした。
まとめ
Geminiに前処理・推定・可視化までのPythonコード生成を依頼し、PGAツアー版のRDDレプリケーションを実装・検証したところ、プロンプトの修正(MDF大会の除外基準、カットライン算出、直前大会の厳密な定義、rddensity利用など)で狙いどおりのパイプラインに到達し、推定方向は概ね前回記事と整合しました。環境依存でrddensity周りにエラーが出た点以外は、関数分割とログ出力で可読性も良好でした。
データ構造・新規作成変数・結合キーを明示すると、生成AIは型変換や欠損処理まで含めた“動く”骨組みを高精度で返すことは大きな学びの一つでした。一方で、MDF除外・カットライン定義・「直前大会」の判定順序のようなある種のドメイン特有の業務ルールは、プロンプトに具体的な手順で書き切らないと誤差が出ることも学びになりました。さらに識別仮定の検証(rddensity・共変量バランス)やrdrobust(all=True)の採用など診断の自動化は有効だが、ライブラリ依存関係の再現性確保(環境固定)は必須であることもこの記事の示唆の一つです。
手作業+部分的に生成AIを使ったコーディングを行った前回記事作成には、前処理コードの作成にかなり時間がかかり、作業時間は10時間前後かかりました。準作業時間以上にストレスフルな時間を過ごしました。一方今回は、プロンプトの検討を含め、1.5時間ほどでチェック、プロンプトの修正、再チェックまで完了しました。複雑な前処理が必要となる作業ほど、生成AIを利用してコーディングの全体像を示してもらうことで、コーディングを効率的に進められるのではないかと思います。
今後はRDDに限らず、A/Bテストやそれに派生した手法の生成AIでのコーディング実装も試してみたいです。その際にはドメイン特有の知識を多めにプロンプトに記載するなど工夫を施したいと思います。
Discussion