0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

普段対戦しない大学野球チームの“強さ”を、RPI(Ratings Percentage Index)で比較してみた(2022年春季~2025秋季で算出編)

Posted at

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. 実装

ここが今回の“メイン”の部分になります。

プログラム全体は以下になります。

rpi_time_minor.py
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_wWP/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扱いせず除外(歪みを抑制)
  • 半減期(今回は半年)で、過去の上振れが永続しないようにする

ただ、ランキングとしてはちょっと違和感のあるものになっており、まだ改善の余地があると思います。 次の発展としては、半減期の最適化や、全国大会に関する最小試合数条件の導入なども考えています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?