1. はじめに
前回は、NCAAのRPI(Rating Percentage Index)を参考に、大学野球チームの“強さ”を WP / OWP / OOWP から算出するプログラム。
- 前回:RPIを使った大学野球チーム強さ比較の実装
今回の続編では、前回のプログラムを実運用で回してみて出てきた「バクではないけれどもこれは困る」という挙動を潰し、 複数年のリーグ戦のデータ+全国大会(トーナメント)のデータ を含む状況でも、ランキングが納得しやすい形になるように設計を見直してみました。
そして、この記事では2022~2025年までの4年間の結果を用いてRPIを試算し、ランキングだしています。なお、全国大会や各リーグの結果は以下のサイトで公開されている内容を参照しています。
2. 背景:前回のRPIを複数年+全国大会に拡張すると起きる問題
前回のRPIは、基本的に「勝率+対戦相手の強さ」で値を算出しました。ただ、大学野球で「全国大会(全日本や明治神宮大会)」「リーグ戦(1部)」「リーグ戦(2部)」などを一緒に扱い、さらに複数年分をまとめて回すと、次の問題が目立ちました。
問題1:全国大会は試合数が少なく、過去の“上振れ”が残り続ける
大学野球は社会人野球と違ってあまり交流戦もありません。そして全国大会は出場校が限られ、試合数も少ないです。
- 例えば数年前に全国大会で優勝して「勝率100%」になった
- それ以降全国大会に出ていなくても、複数年集計だと 全国の勝率がずっと高いまま残る
この状態だと、「現在の強さ」より「過去の少数試合の上振れ」が効きやすくなります。
問題2:OWP/OOWPで“全国未出場”を0扱いすると、相手強度が歪む
OWPは「相手の勝率」、OOWPは「相手の相手の勝率」です。
ここで「相手が全国大会を戦っていない」場合、勝率を0扱いすると、
- 全国大会に出ていない相手が多いと、OWPが不自然に下がる
- 逆に、計算が欠損だらけで、意図しない方向に寄る
…といった歪みが起きます。
3. 方針:何を守って、何を変えるか
今回の方針はシンプルに次の3つです。
方針1:全国>リーグ(1部)>リーグ(2部)の重要度を入れる(カテゴリ混合)
main(全国)、league(1部)、minor_league(2部)の重要度を
MIX_W = {"main": 0.6, "league": 0.3, "minor_league": 0.1}
で反映します。ここで大事なのは、単に試合の重みを掛けるのではなく、各カテゴリでWP/OWP/OOWPを作ってから混合することです。こうすることによって「どのカテゴリで強いか」を分解して見られると考えられます。
なお、この記事の2部リーグは東都の2部と東海地区大学野球連盟の各県リーグ、南部九州大学野球連盟の各県リーグを2部扱いとしています。
方針2:OWP/OOWPの欠損は0にしない
「相手がそのカテゴリを戦っていない」は“弱い”ではなく“情報がない”です。
なので、OWP/OOWPの平均では 欠損は除外します。
-
Noneを返す -
Noneは平均に入れない
という扱いにします。
方針3:時間減衰(半減期)で“過去の上振れ”を自然に薄める
「数年前の勝率100%が永遠に効く」問題は、重みだけでは解決しにくいです。
そこで、試合に“鮮度”を入れます。
各試合に指数減衰の重みを付けます:
$w = 0.5^{\Delta t/H}$
- $\Delta t$:評価日から何日前か
- $H$:半減期(今回は 半年)
最近の試合は重く、古い試合は薄くなるので、「昔だけ強かった」が自然に目減りします。
4. 入力データ
入力CSVは前回と同様に以下を想定します。
date,team,opponent,team_score,opp_score,tournament,stage
列名 内容 例
date 試合日(YYYY/MM/DD) 2025/06/10
team チーム名(大学名) 慶應義塾大
opponent 対戦相手 東洋大
team_score チームの得点 3
opp_score 相手の得点 2
tournament 大会名(大会名+年度) 第73回全日本大学野球選手権大会
stage 試合区分 league, main, semifinal, final など
🧩 CSVの例
2024/05/25,東洋大,中央大,4,2,東都大学野球春季リーグ戦,league
2024/05/25,明治大,法政大,3,1,東京六大学春季リーグ戦,league
2024/06/12,慶應義塾大,東洋大,2,3,全日本大学野球選手権大会,main
2024/06/14,明治大,近畿大,5,4,全日本大学野球選手権大会,main
2024/11/20,青山学院大,法政大,4,1,明治神宮野球大会,final
勝敗は勝ち点にします(勝=1, 引分=0.5, 負=0)。
5. 実装
ここが今回の“メイン”の部分になります。
プログラム全体は以下になります。
import pandas as pd
import time
# ==== 複数年入力(必要に応じてファイルを増減) ====
INPUT_CSV_LIST = [
"daigaku_yakyu_2022.csv",
"daigaku_yakyu_2023.csv",
"daigaku_yakyu_2024.csv",
"daigaku_yakyu_2025.csv",
]
# ==== 列名 ====
DATE_COL = "Date"
TEAM_A_COL = "Team A"
TEAM_B_COL = "Team B"
SCORE_A_COL = "Score A"
SCORE_B_COL = "Score B"
STAGE_COL = "stage"
# ===== stage 想定 =====
VALID_STAGES = {"main", "league", "minor_league"}
STAGES = ["main", "league", "minor_league"]
# ===== カテゴリ混合係数(main>league>minor)=====
MIX_W = {
"main": 0.5,
"league": 0.4,
"minor_league": 0.1,
}
# ===== 時間減衰(全ステージ共通:半減期半年)=====
HALF_LIFE_DAYS = 182
# ===== 進捗表示設定 =====
PROGRESS_EVERY = 20
SHOW_TOP_RPI = False
t0_all = time.time()
print("=== Loading CSVs ===")
raw_list = []
for path in INPUT_CSV_LIST:
print(f" - read: {path}")
df_tmp = pd.read_csv(path)
df_tmp["source_file"] = path
raw_list.append(df_tmp)
raw = pd.concat(raw_list, ignore_index=True)
print(f" rows(raw) = {len(raw):,}")
# 日付を datetime に
raw[DATE_COL] = pd.to_datetime(raw[DATE_COL], format="%Y/%m/%d", errors="coerce")
raw = raw.sort_values(DATE_COL)
# 型整備
raw[SCORE_A_COL] = pd.to_numeric(raw[SCORE_A_COL], errors="coerce").fillna(0).astype(int)
raw[SCORE_B_COL] = pd.to_numeric(raw[SCORE_B_COL], errors="coerce").fillna(0).astype(int)
raw[STAGE_COL] = raw[STAGE_COL].fillna("league").astype(str).str.strip().str.lower()
# 想定外のstageは league 扱い
raw.loc[~raw[STAGE_COL].isin(VALID_STAGES), STAGE_COL] = "league"
# ==== 1試合 → 2行(両視点展開)====
print("=== Expanding games (1 game -> 2 rows) ===")
records = []
for _, r in raw.iterrows():
team_a = str(r[TEAM_A_COL]).strip()
team_b = str(r[TEAM_B_COL]).strip()
records.append({
"date": r[DATE_COL],
"team": team_a,
"opp": team_b,
"team_score": int(r[SCORE_A_COL]),
"opp_score": int(r[SCORE_B_COL]),
"stage": r[STAGE_COL],
})
records.append({
"date": r[DATE_COL],
"team": team_b,
"opp": team_a,
"team_score": int(r[SCORE_B_COL]),
"opp_score": int(r[SCORE_A_COL]),
"stage": r[STAGE_COL],
})
df = pd.DataFrame(records).sort_values("date")
print(f" rows(df expanded) = {len(df):,}")
# 勝ち点(勝=1, 引分=0.5, 負=0)
def outcome(row):
if row["team_score"] > row["opp_score"]:
return 1.0
elif row["team_score"] < row["opp_score"]:
return 0.0
else:
return 0.5
df["team_point"] = df.apply(outcome, axis=1)
all_dates = sorted(df["date"].dropna().unique())
print("=== Ready ===")
print(f" unique dates = {len(all_dates):,}")
print(f" unique teams = {df['team'].nunique():,}")
print(f" MIX_W = {MIX_W}")
print(f" HALF_LIFE_DAYS = {HALF_LIFE_DAYS} (2 years)")
print()
# ==== 時間減衰重みを df_sub に付与(基準日=cutoff日 d)====
def add_time_weight(df_sub: pd.DataFrame, cutoff_date: pd.Timestamp) -> pd.DataFrame:
# df_sub: date列はdatetime
out = df_sub.copy()
days_ago = (cutoff_date - out["date"]).dt.days.clip(lower=0)
out["time_w"] = 0.5 ** (days_ago / HALF_LIFE_DAYS)
return out
# ==== サブデータに対して カテゴリ別WP/OWP/OOWP と 混合RPI を計算(案B:欠損は除外 + 時間減衰)====
def compute_rpi_for_df_split(df_sub: pd.DataFrame):
"""
df_sub には 'time_w' 列が必要(add_time_weightで付与)
"""
teams_sub = df_sub["team"].unique()
# ★案B:該当stageの試合が無い場合は 0.0 ではなく None
def wp_stage(team, stage, exclude_opp=None):
g = df_sub[(df_sub["team"] == team) & (df_sub["stage"] == stage)]
if exclude_opp is not None:
g = g[g["opp"] != exclude_opp]
if g.empty:
return None
w = g["time_w"]
# 念のため
if w.sum() == 0:
return None
return (g["team_point"] * w).sum() / w.sum()
# stage別WP(Noneあり)
wp = {t: {s: wp_stage(t, s) for s in STAGES} for t in teams_sub}
def mix(vals_by_stage: dict):
# Noneは「そのカテゴリの情報なし」なので 0 として混合(係数は固定)
return sum(
MIX_W[s] * (0.0 if vals_by_stage.get(s) is None else vals_by_stage.get(s, 0.0))
for s in STAGES
)
# stage別OWP(Noneは平均から除外)
owp = {t: {s: None for s in STAGES} for t in teams_sub}
for t in teams_sub:
for s in STAGES:
g_ts = df_sub[(df_sub["team"] == t) & (df_sub["stage"] == s)]
vals = []
for _, r in g_ts.iterrows():
opp = r["opp"]
v = wp_stage(opp, s, exclude_opp=t)
if v is None:
continue
vals.append(v)
owp[t][s] = (sum(vals) / len(vals)) if vals else None
# stage別OOWP(Noneは平均から除外)
oowp = {t: {s: None for s in STAGES} for t in teams_sub}
for t in teams_sub:
for s in STAGES:
g_ts = df_sub[(df_sub["team"] == t) & (df_sub["stage"] == s)]
vals = []
for _, r in g_ts.iterrows():
opp = r["opp"]
v = owp.get(opp, {}).get(s, None)
if v is None:
continue
vals.append(v)
oowp[t][s] = (sum(vals) / len(vals)) if vals else None
# 混合(Noneは0扱い)
wp_mix = {t: mix(wp[t]) for t in teams_sub}
owp_mix = {t: mix(owp[t]) for t in teams_sub}
oowp_mix = {t: mix(oowp[t]) for t in teams_sub}
rpi = {t: 0.25 * wp_mix[t] + 0.50 * owp_mix[t] + 0.25 * oowp_mix[t] for t in teams_sub}
return wp, owp, oowp, wp_mix, owp_mix, oowp_mix, rpi
# ==== 最終日に出す「試合数」集計(total + stage別) ====
def game_counts(df_sub: pd.DataFrame) -> pd.DataFrame:
g_total = df_sub.groupby("team").size().rename("G_total")
g_main = df_sub[df_sub["stage"] == "main"].groupby("team").size().rename("G_main")
g_league = df_sub[df_sub["stage"] == "league"].groupby("team").size().rename("G_league")
g_minor = df_sub[df_sub["stage"] == "minor_league"].groupby("team").size().rename("G_minor")
out = pd.concat([g_total, g_main, g_league, g_minor], axis=1).fillna(0).astype(int)
out = out.reset_index()
out = out.rename(columns={"team": "Team", "index": "Team"})
return out
# ==== 日付ごとの時系列RPI =====
rows_ts = []
t0_loop = time.time()
print("=== Computing RPI time series ===")
n_dates = len(all_dates)
# 時系列は「その日までの試合」+「その日を基準に時間減衰」を適用
for i, d in enumerate(all_dates, start=1):
base = df[df["date"] <= d][["date", "team", "opp", "stage", "team_point"]].copy()
df_sub = add_time_weight(base, pd.to_datetime(d))
wp_s, owp_s, oowp_s, wp_m, owp_m, oowp_m, rpi_d = compute_rpi_for_df_split(df_sub)
for t in sorted(wp_m.keys()):
rows_ts.append({
"date": d.date().isoformat(),
"Team": t,
"WP_mix": round(wp_m[t], 4),
"OWP_mix": round(owp_m[t], 4),
"OOWP_mix": round(oowp_m[t], 4),
"RPI": round(rpi_d[t], 4),
})
if (i == 1) or (i % PROGRESS_EVERY == 0) or (i == n_dates):
elapsed = time.time() - t0_loop
teams_now = df_sub["team"].nunique()
rows_now = len(df_sub)
msg = f"[{i:>5}/{n_dates}] date={d.date().isoformat()} sub_rows={rows_now:,} teams={teams_now:,} elapsed={elapsed:,.1f}s"
if SHOW_TOP_RPI:
top_team = max(rpi_d, key=rpi_d.get)
msg += f" top={top_team}({rpi_d[top_team]:.4f})"
print(msg)
print("=== Writing CSVs ===")
ts_df = pd.DataFrame(rows_ts)
ts_df.to_csv("rpi_timeseries_multi_year.csv", index=False, encoding="utf-8-sig")
last_date = ts_df["date"].max()
final_df = ts_df[ts_df["date"] == last_date].copy()
# 最終日までのデータ(最終日基準で時間減衰)
base_last = df[df["date"] <= pd.to_datetime(last_date)][["date", "team", "opp", "stage", "team_point"]].copy()
df_last = add_time_weight(base_last, pd.to_datetime(last_date))
# 最終日内訳(1回だけ計算)
wp_s, owp_s, oowp_s, wp_m, owp_m, oowp_m, rpi_last = compute_rpi_for_df_split(df_last)
# ---- 内訳DataFrame(Noneは空欄にしたいのでNaNに)----
breakdown_rows = []
for t in wp_m.keys():
def n2f(x):
return None if x is None else float(x)
breakdown_rows.append({
"Team": t,
"WP_main": n2f(wp_s[t]["main"]),
"WP_league": n2f(wp_s[t]["league"]),
"WP_minor_league": n2f(wp_s[t]["minor_league"]),
"OWP_main": n2f(owp_s[t]["main"]),
"OWP_league": n2f(owp_s[t]["league"]),
"OWP_minor_league": n2f(owp_s[t]["minor_league"]),
"OOWP_main": n2f(oowp_s[t]["main"]),
"OOWP_league": n2f(oowp_s[t]["league"]),
"OOWP_minor_league": n2f(oowp_s[t]["minor_league"]),
})
breakdown_df = pd.DataFrame(breakdown_rows)
# 寄与列(NaNは0扱いで寄与だけ作る)
for s in STAGES:
breakdown_df[f"WP_{s}_contrib"] = (breakdown_df[f"WP_{s}"].fillna(0.0) * MIX_W[s]).round(4)
breakdown_df[f"OWP_{s}_contrib"] = (breakdown_df[f"OWP_{s}"].fillna(0.0) * MIX_W[s]).round(4)
breakdown_df[f"OOWP_{s}_contrib"] = (breakdown_df[f"OOWP_{s}"].fillna(0.0) * MIX_W[s]).round(4)
# 試合数(時間減衰とは独立なので base_last で数える)
cnt = game_counts(base_last)
# マージして出力
final_df = final_df.merge(cnt, on="Team", how="left")
final_df = final_df.merge(breakdown_df, on="Team", how="left")
final_df = final_df.sort_values("RPI", ascending=False)
final_df.to_csv("rpi_final_multi_year.csv", index=False, encoding="utf-8-sig")
t_all = time.time() - t0_all
print(f"📈 時系列RPI → rpi_timeseries_multi_year.csv")
print(f"🏁 最終日RPI → rpi_final_multi_year.csv(date={last_date})")
print(" 追加列: G_total, G_main, G_league, G_minor")
print(" 追加列: WP/OWP/OOWP の stage別値(欠損は空欄) + 各寄与(contrib)")
print(f"done. total elapsed = {t_all:,.1f}s")
時間減衰(半減期:半年の場合)
評価日(cutoff日)を基準に、過去の試合ほど軽くします。
HALF_LIFE_DAYS = 182 # 半減期
days_ago = (cutoff_date - date).days
time_w = 0.5 ** (days_ago / HALF_LIFE_DAYS)
この time_w を WP/OWP/OOWP の計算に使います。
実装:WP/OWP/OOWP(欠損除外+時間重み)
ここでは「カテゴリ別に作る → 混合する」の流れに沿って説明します。
WP(カテゴリ別)
- チームtがカテゴリsで戦った試合の勝ち点平均(ただし時間重み付き)
- そのカテゴリの試合が無い場合は
None
if g.empty:
return None
return (g["team_point"] * g["time_w"]).sum() / g["time_w"].sum()
OWP(カテゴリ別、欠損除外)
OWPは「対戦相手のWP(自分戦除外)の平均」です。
- 相手がそのカテゴリを戦っていない →
None -
Noneは平均に入れない
v = wp_stage(opp, s, exclude_opp=t)
if v is None:
continue
vals.append(v)
OOWP(カテゴリ別、欠損除外)
OOWPは「対戦相手のOWPの平均」です。これも欠損は除外します。
混合してRPIを出す(内訳も出す)
カテゴリ別に出した WP/OWP/OOWP を混合します。
mix = 0.6*main + 0.3*league + 0.1*minor
最終RPIは前編と同じ係数です。
$$
RPI = 0.25 \times WP + 0.50 \times OWP + 0.25 \times OOWP
$$
さらに、結果の説明ができるように 内訳(WP_main, OWP_main…)や寄与(*_contrib)も出力します。(6章にて説明)
6. 出力されるcsv:
出力は2種類です。
-
rpi_timeseries_multi_year.csv(時系列) -
rpi_final_multi_year.csv(最終日スナップショット)
rpi_final_multi_year.csv(最終日スナップショットのCSV)は、RPIだけでなく次の項目をみることもできます。
| 列名 | 内容 | 補足説明 |
|---|---|---|
| WP_mix | 混合WP | main / league / minor の WPを重み付き合成した値 |
| OWP_mix | 混合OWP | 相手の強さ(WP)の 重み付き合成値 |
| OOWP_mix | 混合OOWP | 相手の相手の強さ(OWP)の 重み付き合成値 |
| RPI | RPI |
0.25×WP + 0.50×OWP + 0.25×OOWP で算出した最終指標 |
| G_total | 総試合数 | 全ステージ合計の試合数 |
| G_main | 全国大会試合数 | stage = main の試合数 |
| G_league | リーグ戦(1部)試合数 | stage = league の試合数 |
| G_minor | リーグ戦(2部)試合数 | stage = minor_league の試合数 |
| WP_main | 全国大会WP | 全国大会(main)の勝率(時間減衰考慮、未出場は空欄) |
| WP_league | リーグ戦(1部)WP | リーグ戦(1部)(league)の勝率 |
| WP_minor_league | リーグ戦(2部)WP | リーグ戦(2部)(minor_league)の勝率 |
| OWP_main | 全国大会OWP | 全国大会で対戦した相手のWP平均 |
| OWP_league | リーグ戦(1部)OWP | リーグ戦(1部)で対戦した相手のWP平均 |
| OWP_minor_league | リーグ戦(2部)OWP | リーグ戦(2部)で対戦した相手のWP平均 |
| OOWP_main | 全国大会OOWP | 全国大会における相手のOWP平均 |
| OOWP_league | リーグ戦(1部)OOWP | リーグ戦(1部)における相手のOWP平均 |
| OOWP_minor_league | 下位大会OOWP | リーグ戦(2部)における相手のOWP平均 |
| WP_main_contrib | 全国大会WP寄与 | WP_main × 重み(main) |
| OWP_main_contrib | 全国大会OWP寄与 | OWP_main × 重み(main) |
| OOWP_main_contrib | 全国大会OOWP寄与 | OOWP_main × 重み(main) |
| WP_league_contrib | リーグ戦(1部)WP寄与 | WP_league × 重み(league) |
| OWP_league_contrib | リーグ戦(1部)OWP寄与 | OWP_league × 重み(league) |
| OOWP_league_contrib | リーグ戦(1部)OOWP寄与 | OOWP_league × 重み(league) |
| WP_minor_league_contrib | リーグ戦(2部)WP寄与 | WP_minor_league × 重み(minor) |
| OWP_minor_league_contrib | リーグ戦(2部)OWP寄与 | OWP_minor_league × 重み(minor) |
| OOWP_minor_league_contrib | リーグ戦(2部)OOWP寄与 | OOWP_minor_league × 重み(minor) |
「順位が直感と違う」と感じたときも、寄与を見ると原因が特定できます。
7. 得られたランキングとまとめ
今回は2022年~2025年の全国大会(全日本大学野球、明治神宮野球大会)、春季リーグ、秋季リーグの入力のみに基づく試算結果でのTOP20です。なお、対象にした試合数は全部で9016試合でした。(2025年明治神宮野球大会が終了した時点でのRPIランキング)
半減期は半年で設定した時の結果です。
| 順位 | Team | WP | OWP | OOWP | RPI |
|---|---|---|---|---|---|
| 1 | 東北福祉大学 | 0.8360 | 0.5762 | 0.5406 | |
| 2 | 青山学院大学 | 0.7662 | 0.5545 | 0.4787 | |
| 3 | 福井工業大学 | 0.6736 | 0.5614 | 0.5449 | |
| 4 | 西南学院大学 | 0.6036 | 0.6244 | 0.4845 | |
| 5 | 鹿屋体育大学 | 0.6976 | 0.5011 | 0.5477 | |
| 6 | 慶應義塾大学 | 0.7281 | 0.5361 | 0.4460 | |
| 7 | 東海大学 | 0.5855 | 0.5701 | 0.4791 | |
| 8 | 立命館大学 | 0.6623 | 0.5179 | 0.4761 | |
| 9 | 中京大学 | 0.5607 | 0.5443 | 0.4910 | |
| 10 | 白鷗大学 | 0.5941 | 0.5391 | 0.4665 | |
| 11 | 北海学園大学 | 0.6065 | 0.5148 | 0.4428 | |
| 12 | 八戸学院大学 | 0.5066 | 0.5661 | 0.4356 | |
| 13 | 天理大学 | 0.5691 | 0.4833 | 0.4871 | |
| 14 | 中部学院大学 | 0.5678 | 0.4789 | 0.4844 | |
| 15 | 帝京大学 | 0.4722 | 0.6074 | 0.3110 | |
| 16 | 近畿大学 | 0.5251 | 0.5281 | 0.4153 | |
| 17 | 亜細亜大学 | 0.7465 | 0.3707 | 0.4624 | |
| 18 | 創価大学 | 0.5010 | 0.4842 | 0.4380 | |
| 19 | 大阪産業大学 | 0.2029 | 0.5734 | 0.5569 | |
| 20 | 青森大学 | 0.2061 | 0.5466 | 0.5859 |
なお、東京六大学野球の各大学の順位は以下でした。
- 慶應義塾大学:6位/232チーム中
- 早稲田大学:24位/232チーム中
- 明治大学:25位/232チーム中
- 立教大学:107位/232チーム中
- 法政大学:116位/232チーム中
- 東京大学:199位/232チーム中
直近のリーグ戦の結果を見ると、このランキングにはやや違和感を覚えます。しかし、全日本大学野球選手権や明治神宮野球大会といった主要大会(mainの大会)の結果に大きな比重が置かれていること、さらに評価の半減期が半年であっても依然として長めであることが、その要因として考えられます。
8. まとめ
この記事では、前回のRPI算出プログラムを複数年で算出したときに出るクセに対して、次の3点を入れて納得感のある算出ができることを目指しました。
- 全国/リーグ/2部をカテゴリ別に計算して混合(説明可能に)
- OWP/OOWPの欠損を0扱いせず除外(歪みを抑制)
- 半減期(今回は半年)で、過去の上振れが永続しないようにする
ただ、ランキングとしてはちょっと違和感のあるものになっており、まだ改善の余地があると思います。 次の発展としては、半減期の最適化や、全国大会に関する最小試合数条件の導入なども考えています。