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?

社会人野球 Eloレーティング(2021〜2025)を複数年で算出し、チーム名統一+バーチャートレースで可視化してみた

Posted at

1. はじめに

前回の記事社会人野球のEloレーティングを算出してみた(2025年版)では、JABA大会や都市対抗野球などの結果データをもとに、社会人野球チームの単年のEloレーティングを算出することに挑戦してみました。

今回の記事ではその続編として、以下の3点を中心に拡張しました。

1️⃣ 複数年(2021〜2025)の試合結果を対象に、レーティングを累積的に算出
2️⃣ 年度をまたいだチーム名変更・表記ゆれを共通辞書で統一処理
3️⃣ レーティング履歴CSVから、バーチャートレース動画を自動生成

これにより、数年間にわたるチームのレーティング(強さ)の推移を視覚的に表現してみました。

こちらが作成したバーチャートレースです

2. 対象とした大会と期間

  • 対象期間:2021年〜2025年
  • 対象大会
    • JABA 日本選手権対象大会(各地区大会)
    • 都市対抗野球大会(予選および本戦)
    • 社会人野球日本選手権(予選および本戦)

ただし、2025年11月上旬時点では日本選手権本戦が進行中のため、
本戦分は後日データ更新時に反映予定です。

なお、結果は一球速報の社会人野球のページを見て一つ一つ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,79JABA東京スポニチ大会,regional
2025/3/8,NTT西日本,JR東日本,3,1,79JABA東京スポニチ大会,regional
2025/3/8,Honda熊本,JFE東日本,5,6,79JABA東京スポニチ大会,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. チーム名統一の考え方

社会人野球では、以下のような企業再編・チーム名変更などが頻繁に発生します。

  • 「A社硬式野球部」 → 「Aホールディングス」
  • 「B工業」 → 「Bテック」

このような変更を正しく扱うため、
旧名 → 統一名 の対応マップ(辞書)をコード内に定義しました。

これにより、同一チームのレーティング履歴が年度をまたいで一貫するようにしました。

5. レーティング計算の前提

複数年をまたいで Elo レーティングを算出する場合、初年度のレートの初期値をどう扱うかについて考えましたが、この記事では

  • 2021年を起点年とし、すべてのチームを初期レート 1500 でスタート
  • 2022年以降は前年の最終レートを引き継ぎ、累積的に更新

という形にしています。また、2021年中に登場しなかった新規チームは、登場時点で自動的にレーティング1500で参入するようにしました。

6. Eloレーティング算出プログラム全文(複数年・チーム名統一対応)

compute_shakaijin_elo_name.py
# -*- coding: utf-8 -*-
import math
import pandas as pd

# ===== 入力CSVをここで指定 =====
INPUT_FILES = [
    "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_K_WEIGHT = {
    "main": 1.5, "regional": 1.0, "friendly": 0.5,
    "本戦": 1.5, "予選": 1.0, "練習試合": 0.5,
}

# ===== チーム名統一マップ =====
TEAM_NAME_MAP = {
    "かずさマジック": "日本製鉄かずさマジック",
    "日本製鉄広畑": "日本製鉄瀬戸内",
    "三菱自動車倉敷": "三菱自動車倉敷オーシャンズ",
}


# ===== 関数群 =====
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) -> str:
    """チーム名の整形+統一"""
    name = str(name).strip()
    return TEAM_NAME_MAP.get(name, name)


def load_games(files):
    """複数CSVを読み込み、結合・日付昇順ソート"""
    frames = []
    rename_map = {
        "Date": "date",
        "Team A": "team_a",
        "Team B": "team_b",
        "Socre A": "score_a",  
        "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().apply(normalize_name)
    games["team_b"] = games["team_b"].astype(str).str.strip().apply(normalize_name)
    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

    for idx, row in df.iterrows():
        this_year = int(row["date"].year)
        a = row["team_a"]
        b = row["team_b"]
        sa, sb = int(row["score_a"]), int(row["score_b"])
        stage = row.get("stage", 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

        # 履歴保存(日単位フォーマットに変更)
        date_str = row["date"].strftime("%Y-%m-%d")
        history_rows.append({
            "date": date_str,
            "match_index": idx + 1,
            "team": a,
            "opponent": b,
            "score_for": sa,
            "score_against": sb,
            "rating": rating[a],
            "stage": stage,
        })
        history_rows.append({
            "date": date_str,
            "match_index": idx + 1,
            "team": b,
            "opponent": a,
            "score_for": sb,
            "score_against": sa,
            "rating": rating[b],
            "stage": stage,
        })

        # 年度変わり目でスナップショット出力
        next_is_new_year = (
            idx == len(df) - 1
            or int(df.iloc[idx + 1]["date"].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_year = int(df.iloc[-1]["date"].year)
    final_table = snapshot_year_df(final_year, rating).drop(columns=["year"])
    history = pd.DataFrame(history_rows).sort_values(["team", "match_index"]).reset_index(drop=True)

    # 出力形式変更:年月日つき履歴CSV
    final_table.to_csv(f"{OUTPUT_PREFIX}_final_ratings.csv", index=False)
    history.to_csv(f"{OUTPUT_PREFIX}_rating_history.csv", index=False, encoding="utf-8-sig")

    if per_year_tables:
        all_years_df = pd.concat(per_year_tables, ignore_index=True)
        all_years_df.to_csv(f"{OUTPUT_PREFIX}_yearly_snapshots.csv", index=False)
        for y, g in all_years_df.groupby("year"):
            g.drop(columns=["year"]).to_csv(f"{OUTPUT_PREFIX}_final_ratings_{y}.csv", index=False)

    print(f"[DONE] 最終レーティング: {OUTPUT_PREFIX}_final_ratings.csv")
    print(f"[DONE] 年度別スナップショット: {OUTPUT_PREFIX}_yearly_snapshots.csv")
    print(f"[DONE] 日単位履歴: {OUTPUT_PREFIX}_rating_history.csv")
    print("\n--- 最新Top 10 ---")
    print(final_table.head(10).to_string(index=False))


if __name__ == "__main__":
    main()

実行後の出力

  • shakaijin_elo_rating_history.csv → 日別Eloレーティング履歴

7. 結果(レーティングの推移 TOP20)

順位 2021年 2022年 2023年 2024年 2025年
1 大阪ガス (1685.01) ENEOS (1778.44) トヨタ自動車 (1849.51) トヨタ自動車 (1889.24) トヨタ自動車 (1903.67)
2 東京ガス (1650.39) トヨタ自動車 (1770.36) ENEOS (1775.90) 三菱重工East (1812.31) ヤマハ (1824.11)
3 ヤマハ (1637.69) 東京ガス (1745.47) 日本通運 (1767.03) Honda (1776.48) 王子 (1791.45)
4 セガサミー (1618.71) NTT東日本 (1711.17) 東京ガス (1743.16) 日本通運 (1773.53) ENEOS (1787.98)
5 NTT東日本 (1618.33) 三菱重工East (1680.25) Honda熊本 (1728.36) ENEOS (1755.95) 三菱重工East (1776.96)
6 日立製作所 (1617.67) 日本通運 (1675.49) ヤマハ (1726.76) ヤマハ (1749.04) Honda (1768.81)
7 東邦ガス (1609.77) Honda熊本 (1674.01) 三菱重工East (1716.50) NTT東日本 (1730.28) 日本通運 (1765.34)
8 三菱重工East (1609.36) 東芝 (1656.47) Honda (1683.57) 東京ガス (1729.91) NTT西日本 (1733.34)
9 日本通運 (1589.36) 日立製作所 (1651.20) 東芝 (1682.28) 東芝 (1700.24) SUBARU (1724.76)
10 トヨタ自動車 (1587.83) 大阪ガス (1633.30) JR東日本 (1680.68) 西部ガス (1698.08) JFE西日本 (1723.87)
11 ENEOS (1587.23) セガサミー (1631.82) NTT東日本 (1663.61) 三菱重工West (1696.90) 東芝 (1722.12)
12 Honda熊本 (1585.20) JR東日本 (1621.18) 三菱重工West (1661.64) JFE西日本 (1686.68) 三菱自動車岡崎 (1713.80)
13 JR東日本東北 (1575.83) 三菱重工West (1616.61) 王子 (1643.13) Honda熊本 (1683.84) 東京ガス (1710.31)
14 JFE東日本 (1571.10) ヤマハ (1613.95) 三菱自動車岡崎 (1641.12) JR東日本東北 (1672.36) Honda熊本 (1707.39)
15 王子 (1570.94) TDK (1607.08) 西部ガス (1634.39) JR東日本 (1665.23) NTT東日本 (1695.20)
16 伯和ビクトリーズ (1565.15) Honda (1602.22) パナソニック (1631.06) 日本製鉄鹿島 (1657.48) JR東日本東北 (1694.38)
17 JR九州 (1564.38) 日本新薬 (1598.35) 日立製作所 (1629.81) 王子 (1655.58) 三菱重工West (1690.66)
18 日本製鉄かずさマジック (1563.98) 日本製鉄鹿島 (1595.17) SUBARU (1626.41) 日本製鉄かずさマジック (1647.96) 西部ガス (1689.87)
19 日本製鉄瀬戸内 (1562.32) 鷺宮製作所 (1594.14) JFE西日本 (1625.56) 明治安田生命 (1645.46) JR東日本 (1678.52)
20 JR東日本 (1561.03) 日本生命 (1590.76) 大阪ガス (1616.97) 日立製作所 (1645.35) 日本生命 (1674.97)

8. バーチャートレースで可視化

複数年分のEloレーティング推移を、日ごとのアニメーションとして表現しました。
使用ライブラリは以下です。

pip install bar_chart_race matplotlib pandas

バーチャートレース化するプログラムは以下になります。

bar_chart.py
# -*- coding: utf-8 -*-
import pandas as pd
import bar_chart_race as bcr
import matplotlib.pyplot as plt

# ===== 設定 =====
INPUT_CSV = "shakaijin_elo_rating_history.csv"
OUTPUT_VIDEO = "elo_barchart_race_daily.mp4"
TOP_N = 10
FPS = 15
TITLE = "社会人野球 Eloレーティング推移(日別)"

# ===== 日本語フォント設定 =====
plt.rcParams['font.family'] = ['Meiryo']   # Windows用
plt.rcParams['axes.unicode_minus'] = False

# ===== データ読み込み =====
df = pd.read_csv(INPUT_CSV, encoding="utf-8")

required = {"date", "team", "rating"}
if not required.issubset(df.columns):
    raise ValueError(f"CSVに必要な列がありません: {required - set(df.columns)}")

df["date"] = pd.to_datetime(df["date"], errors="coerce")
df = df.dropna(subset=["date", "team", "rating"])

# ===== 日ごとの平均レーティング =====
daily = df.groupby(["date", "team"])["rating"].mean().unstack(fill_value=None)

# ===== 初期レーティング補完 =====
first_day = daily.index.min()
daily.loc[first_day] = daily.loc[first_day].fillna(1500)
daily = daily.sort_index()
daily = daily.ffill()

# レーティング変動のないチームを除外(任意)
daily = daily.loc[:, daily.std() > 0]

# ===== X軸の範囲を決める =====
x_min = 1400
x_max = daily.max().max() + 40

# ===== xlimを固定したFigureを作成 =====
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.set_xlim(x_min, x_max)
ax.tick_params(axis='x', labelsize=8)
ax.tick_params(axis='y', labelsize=8)

# ===== バーチャートレース生成(x軸固定)=====
bcr.bar_chart_race(
    df=daily,
    filename=OUTPUT_VIDEO,
    n_bars=TOP_N,
    sort='desc',
    title=TITLE,
    fixed_order=False,
    fixed_max=True,          # 軸範囲を固定
    interpolate_period=False,
    steps_per_period=40,
    period_length=3000 / FPS,
    cmap='dark12',
    bar_size=0.95,
    period_fmt='%Y-%m-%d',
    period_label={'x': 0.92, 'y': 0.1, 'ha': 'right', 'va': 'center'},
    dpi=300,
    fig=fig
)

print(f"[DONE] バーチャートレース動画を出力しました: {OUTPUT_VIDEO}")

実行後の出力

  • elo_barchart_race_daily.mp4 → 日付ごとのレーティング推移アニメーション(1章で示した動画です)

9. まとめ

今回は以下の件を対応し、社会人野球チームのランキングをバーチャートレースで示しました。

1️⃣ 複数年(2021〜2025)の試合結果を対象に、レーティングを累積的に算出
2️⃣ 年度をまたいだチーム名変更・表記ゆれを共通辞書で統一処理
3️⃣ レーティング履歴CSVから、バーチャートレース動画を自動生成

今後は、2025年の社会人野球日本選手権の結果を反映したり、 日本選手権対象大会以外のJABA大会も含むなどしていきたいと考えています。

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?