1. 背景
私はスポーツ観戦が好きなのですが、その中でも特に社会人野球が好きで、都市対抗野球にはここ10年ほど毎年足を運んでいます(今年の都市対抗は、6試合も観戦しました。)
ただ、社会人野球はリーグ戦のように年間を通して戦うわけではないため、「どのチームが本当に強いのか」が分かりにくいと感じていました。もちろん強豪と呼ばれるチームは存在しますし、一発勝負ならではの魅力もあるのですが、もう少し客観的に強さを測れないだろうかと思いました。
そこで着目したのが、チェスなどで使われている Eloレーティングです。勝敗データを基に計算すれば、社会人野球のチームの強さを数値として表せるのではないかと考え、実際に試してみたというのがこの記事の内容です。
注:Eloレーティングの説明はQiitaの他の記事で優れた解説がなされていますので、ここでは割愛させていただきます。
2. 対象とした大会
今回対象としたのは以下の大会です。
- 2025年度 JABA 日本選手権対象大会
- 2025年度 都市対抗野球大会の予選および本戦
これらの試合結果をCSVにまとめ、Eloレーティングを算出しました。
結果は一球速報の社会人野球のページを見て一つ一つcsvに手で記載してきました。
3. 入力データ(CSV形式)
1試合を1行として記録します。最低限必要なのは以下の5列です。
列名 内容 例
date 試合日 (YYYY-MM-DD) 2024-07-18
team_a チームA Honda
team_b チームB JR東日本
score_a チームAの得点 5
score_b チームBの得点 1
任意で以下の列も追加できるようにしました。
- tournament → 大会名(都市対抗、日本選手権など)
- stage → 本戦/予選/練習試合など(重み付けに利用)
また、重み付けとしてJABA 日本選手権対象大会や都市対抗予選は1.0、都市対抗本戦は1.5、練習試合は0.5(今回は入力データに含んでいません)としました(後述するプログラムを参照ください)。
サンプルCSVは以下の通りです。
2025/3/8,Honda鈴鹿,鷺宮製作所,4,5,第79回JABA東京スポニチ大会,regional
2025/3/8,NTT西日本,JR東日本,3,1,第79回JABA東京スポニチ大会,regional
2025/3/8,Honda熊本,JFE東日本,5,6,第79回JABA東京スポニチ大会,regional
2025/8/29,東芝,JR東日本,8,9,第96回都市対抗野球大会(1回戦),main
2025/8/30,TDK,鷺宮製作所,4,5,第96回都市対抗野球大会(1回戦),main
2025/8/30,JR東日本東北,トヨタ自動車,5,3,第96回都市対抗野球大会(1回戦),main
4. 作成したpythonプログラム
以下が実際に使用したプログラムです。複数年のCSVを読み込み、累積的にレーティングを更新できるようにしています。さらに、各年末時点のスナップショットをCSVとして保存する仕組みも入れています。
ただ今回は2025年度のJABA 日本選手権対象大会と都市対抗野球大会の予選および本戦のみを対象としました。
また今回はすべてのチームを同じスタートラインから評価するため、初期レーティングは1500点で統一しました。その後、各試合の結果に基づいてレートが上下していきます。
# -*- coding: utf-8 -*-
"""
社会人野球 Elo レーティング計算(複数CSV対応・累積・年度別スナップショット出力)
- 入力: INPUT_FILES にリスト指定(1つでも可)
- 年度ごとの「その年最後の試合後のレーティング」を CSV で出力
- シーズンリセットなし(累積)
"""
import math
import pandas as pd
# ===== 入力CSVをここで指定 =====
INPUT_FILES = [
# "shakaijin_yakyu_2020.csv",
# "shakaijin_yakyu_2021.csv",
# "shakaijin_yakyu_2022.csv",
# "shakaijin_yakyu_2023.csv",
# "shakaijin_yakyu_2024.csv",
"shakaijin_yakyu_2025.csv",
]
OUTPUT_PREFIX = "shakaijin_elo"
# ===== 基本設定 =====
INITIAL_RATING = 1500.0
BASE_K = 20.0
ELO_SCALE = 400.0
# stage 列の重み(必要に応じて調整)
STAGE_K_WEIGHT = {
"main": 1.5, "regional": 1.0, "friendly": 0.5,
"本戦": 1.5, "予選": 1.0, "練習試合": 0.5, # 日本語運用もOK
}
def margin_of_victory_multiplier(score_diff: int, rating_diff: float) -> float:
"""点差補正(FiveThirtyEight風)"""
return math.log(abs(score_diff) + 1.0) * 2.2 / ((abs(rating_diff) * 0.001) + 2.2)
def expected_score(ra: float, rb: float) -> float:
"""A側の期待勝率"""
return 1.0 / (1.0 + 10.0 ** ((rb - ra) / ELO_SCALE))
def normalize_name(name) -> str:
return str(name).strip()
def load_games(files):
"""複数CSVを結合し、日付昇順に整える"""
frames = []
rename_map = {
"Date": "date",
"Team A": "team_a",
"Team B": "team_b",
"Socre A": "score_a", # typo対策
"Score A": "score_a",
"Score B": "score_b",
"tournament": "tournament",
"Tournament": "tournament",
"stage": "stage",
"Stage": "stage",
}
for f in files:
df = pd.read_csv(f)
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
frames.append(df)
games = pd.concat(frames, ignore_index=True)
# 必須列チェック
required = {"date", "team_a", "team_b", "score_a", "score_b"}
missing = required - set(games.columns)
if missing:
raise ValueError(f"必須列が不足しています: {missing}")
# 型整形
games["date"] = pd.to_datetime(games["date"], errors="coerce")
games["team_a"] = games["team_a"].astype(str).str.strip()
games["team_b"] = games["team_b"].astype(str).str.strip()
games["score_a"] = pd.to_numeric(games["score_a"], errors="coerce").astype("Int64")
games["score_b"] = pd.to_numeric(games["score_b"], errors="coerce").astype("Int64")
# 無効行除外
games = games.dropna(subset=["date", "team_a", "team_b", "score_a", "score_b"])
games = games.sort_values("date").reset_index(drop=True)
return games
def snapshot_year_df(year: int, rating_dict: dict) -> pd.DataFrame:
"""指定年のスナップショット DataFrame を作る"""
df = pd.DataFrame([{"team": t, "rating": r} for t, r in rating_dict.items()])
if df.empty:
return df
df = df.sort_values("rating", ascending=False, ignore_index=True)
df.insert(0, "year", year)
return df
def main():
# 読み込み
df = load_games(INPUT_FILES)
rating = {} # 累積レート
history_rows = [] # 試合ごとのレーティング履歴
per_year_tables = [] # 年度別スナップショット格納
# 現在の年(先頭行の年で初期化)
if len(df) == 0:
print("入力データが空です。")
return
current_year = int(df.iloc[0]["date"].year)
for idx, row in df.iterrows():
a, b = normalize_name(row["team_a"]), normalize_name(row["team_b"])
sa, sb = int(row["score_a"]), int(row["score_b"])
stage = row["stage"] if "stage" in df.columns else None
# 初期レート
if a not in rating:
rating[a] = INITIAL_RATING
if b not in rating:
rating[b] = INITIAL_RATING
ra, rb = rating[a], rating[b]
ea = expected_score(ra, rb)
# 実績スコア
if sa > sb:
score_a = 1.0
elif sa < sb:
score_a = 0.0
else:
score_a = 0.5
# K値
k = BASE_K
if isinstance(stage, str):
k *= STAGE_K_WEIGHT.get(stage, 1.0)
# 点差補正
if sa != sb:
diff = abs(sa - sb)
rdiff_for_mov = (ra - rb) if sa > sb else (rb - ra)
mov = margin_of_victory_multiplier(diff, rdiff_for_mov)
else:
mov = 1.0
# 更新
delta_a = k * mov * (score_a - ea)
rating[a] = ra + delta_a
rating[b] = rb - delta_a
# 履歴
history_rows.append({
"date": row["date"].date(),
"match_index": idx + 1,
"team": a,
"opponent": b,
"score_for": sa,
"score_against": sb,
"rating": rating[a],
"stage": stage,
})
history_rows.append({
"date": row["date"].date(),
"match_index": idx + 1,
"team": b,
"opponent": a,
"score_for": sb,
"score_against": sa,
"rating": rating[b],
"stage": stage,
})
# 年度境界を検知(次の行が別年 or 最終行)→ スナップショット保存
this_year = int(row["date"].year)
next_is_new_year = False
if idx == len(df) - 1:
next_is_new_year = True
else:
next_year = int(df.iloc[idx + 1]["date"].year)
next_is_new_year = (next_year != this_year)
if next_is_new_year:
snap_df = snapshot_year_df(this_year, rating)
if not snap_df.empty:
per_year_tables.append(snap_df)
# 最終ランキング(全試合後)
final_table = snapshot_year_df(int(df.iloc[-1]["date"].year), rating)
final_table = final_table.drop(columns=["year"]).rename(columns={"rating": "rating"}) # 見やすく
history = pd.DataFrame(history_rows).sort_values(["team", "match_index"]).reset_index(drop=True)
# 出力:全体
out_final = f"{OUTPUT_PREFIX}_final_ratings.csv"
out_history = f"{OUTPUT_PREFIX}_rating_history.csv"
final_table.to_csv(out_final, index=False)
history.to_csv(out_history, index=False)
print(f"[DONE] 最終レーティング: {out_final}")
print(f"[DONE] レーティング履歴: {out_history}")
# 出力:年度別(個別ファイル & まとめ)
if per_year_tables:
all_years_df = pd.concat(per_year_tables, ignore_index=True)
all_years_path = f"{OUTPUT_PREFIX}_yearly_snapshots.csv"
all_years_df.to_csv(all_years_path, index=False)
print(f"[DONE] 年度別スナップショット(まとめ): {all_years_path}")
# 年ごとに分割保存
for y, g in all_years_df.groupby("year"):
path_y = f"{OUTPUT_PREFIX}_final_ratings_{y}.csv"
g.drop(columns=["year"]).to_csv(path_y, index=False)
print(f" └ {y} 年末レーティング: {path_y}")
# サマリの出力
print("\n--- 最新Top 10 ---")
print(final_table.head(10).to_string(index=False))
if __name__ == "__main__":
main()
5. 結果(2025年TOP20)
計算結果は以下になります。やはり対象大会の2大会で優勝しており、都市対抗でもベスト4に入っているヤマハが1位でした。
| 順位 | チーム | レーティング |
|---|---|---|
| 1 | ヤマハ | 1687.0 |
| 2 | 王子 | 1676.7 |
| 3 | トヨタ自動車 | 1650.4 |
| 4 | SUBARU | 1625.8 |
| 5 | NTT西日本 | 1624.3 |
| 6 | 三菱自動車岡崎 | 1608.7 |
| 7 | ENEOS | 1606.0 |
| 8 | Honda熊本 | 1595.5 |
| 9 | 日本通運 | 1595.3 |
| 10 | 鷺宮製作所 | 1594.5 |
| 11 | JFE西日本 | 1585.0 |
| 12 | 東芝 | 1577.4 |
| 13 | 日本生命 | 1566.2 |
| 14 | Honda | 1565.8 |
| 15 | 西濃運輸 | 1556.2 |
| 16 | 三菱重工East | 1554.5 |
| 17 | JR東日本東北 | 1553.9 |
| 18 | JR東日本 | 1551.5 |
| 19 | Honda鈴鹿 | 1549.2 |
| 20 | JFE東日本 | 1549.0 |
6. まとめと今後の課題
今回は2025年度のJABA 日本選手権対象大会と都市対抗予選・本戦の結果のみでレーティングを算出しましたが、やはり強豪と言われているチームが上位に入ってきているなと感じました。ただ、他にもまだ有名なチームもありますし、年度によってだいぶ変わると思います。
次は過去5〜10年分の結果を集計して、年度ごとのレーティング推移を出したと考えています。