【地方競馬データ検証】昇級初戦は本当に成績が落ちるのか?

分析・検証

導入

競馬では「昇級初戦は厳しい」と言われることがあります。前走で好走してクラスが上がった馬は、相手が強くなるため苦戦しやすいという考え方です。

しかし、本当に昇級初戦は成績が下がるのでしょうか。地方競馬の過去データを使い、昇級初戦の馬と同級継続馬・降級馬を比較して検証します。

検証に使ったデータ

  • 使用期間:2017/01/01〜2025/12/19
  • 前走情報付き対象頭数:1,127,825
  • 対象レース数:115,739
  • 除外条件:取消・失格・除外・中止・単勝オッズ欠損・前走情報なし
  • 使用カラム:レース名(クラス抽出)・着順・人気・単勝・距離・場所

クラス表記の内訳:A1:8399件 A2:16366件 A3:1045件 A4:1397件 B1:38004件 B2:55599件 B3:29265件 B4:10696件 C1:172654件 C2:261644件 C3:142247件 C4:23934件
クラスが抽出できないレース(重賞・特別戦・年齢限定戦等)は「判定不能」として別集計しています。
複勝払戻データはDBに含まれていないため、複勝回収率は算出できません。

昇級初戦の定義

地方競馬のクラス(A1〜A4, B1〜B4, C1〜C4)をレース名から正規表現で抽出し、以下の順に数値ランク化しました。

  • C4(1) < C3(2) < C2(3) < C1(4) < B4(5) < B3(6) < B2(7) < B1(8) < A4(9) < A3(10) < A2(11) < A1(12)
  • 今走ランク > 前走ランク → 昇級初戦
  • 今走ランク = 前走ランク → 同級継続
  • 今走ランク < 前走ランク → 降級初戦
  • クラスが抽出できないレース → 判定不能(参考集計のみ)

全体結果

昇級初戦・同級継続・降級初戦の成績を比較しました。

グループ対象頭数対象レース数勝率連対率3着内率平均人気平均単勝オッズ単勝回収率
昇級初戦651732713414.28%26.27%37.01%4.8135.420.71
同級継続555759708098.53%17.74%27.31%6.0756.470.70
降級初戦544672364610.10%19.65%28.95%5.8251.690.72
判定不能4524266499510.34%20.61%30.86%5.5347.460.70
昇級初戦_前走1着260041702021.80%37.24%49.20%3.4314.180.72
昇級初戦_前走2着以下39169186479.30%18.98%28.92%5.7349.520.70

人気グループ別に見た結果(昇級初戦)

昇級初戦馬でも、今走の人気によって成績が大きく変わります。

人気グループ対象頭数勝率連対率3着内率
①1番人気1108946.56%65.70%76.51%
②2〜3番人気1636716.89%37.22%53.09%
③4〜6番人気185415.94%15.61%28.40%
④7番人気以下191761.47%4.42%8.77%

全グループ × 人気グループ比較

昇級・同級・降級それぞれで、人気グループ別の成績がどう違うかを確認します。

クラス変化人気グループ対象頭数勝率連対率3着内率
同級継続①1番人気4450341.93%62.49%74.47%
同級継続②2〜3番人気9999315.81%35.64%52.40%
同級継続③4〜6番人気1625305.77%15.32%27.72%
同級継続④7番人気以下2487331.42%4.12%8.52%
昇級初戦①1番人気1108946.56%65.70%76.51%
昇級初戦②2〜3番人気1636716.89%37.22%53.09%
昇級初戦③4〜6番人気185415.94%15.61%28.40%
昇級初戦④7番人気以下191761.47%4.42%8.77%
降級初戦①1番人気549544.40%64.71%75.32%
降級初戦②2〜3番人気1062516.66%35.40%51.35%
降級初戦③4〜6番人気155456.14%15.61%27.01%
降級初戦④7番人気以下228021.48%4.21%8.66%

前走着順別に見た結果(昇級初戦)

前走でどの順位だった馬が昇級初戦で通用しているかを見ます。

前走着順対象頭数勝率連対率3着内率
12600421.80%37.24%49.20%
2871715.88%30.54%43.50%
3584210.78%22.99%35.18%
446639.09%19.19%30.13%
540317.10%16.03%26.67%
629396.81%13.51%22.56%
729076.81%14.24%22.36%
826615.75%11.76%18.60%
923435.59%11.91%18.35%
1019165.48%10.07%15.61%
1114243.72%8.71%13.41%
129814.08%9.68%15.39%

「1着差」カラムは各馬と1着馬のタイム差(秒)であり、勝ち馬は0になります。 前走での勝ち幅(2着との差)はDBから直接取得できないため、前走圧勝・辛勝の分類は今回省略しています。

場所別に見た結果(昇級初戦)

場所対象頭数勝率連対率3着内率
園田1137215.47%28.56%39.35%
金沢665416.82%29.77%41.49%
大井636412.43%23.21%32.89%
門別621614.17%26.06%36.95%
水沢462913.67%25.17%35.84%
川崎442111.51%21.24%30.90%
盛岡441213.83%26.45%37.49%
船橋393013.56%25.24%35.67%
浦和370111.46%22.40%32.56%
高知351919.69%33.59%44.64%
名古屋348910.00%19.55%29.61%
佐賀276320.05%35.25%46.94%
笠松144311.78%23.49%35.97%
姫路138914.47%27.07%39.88%
帯広8719.53%17.22%26.98%

対象数が少ない競馬場の数値は変動が大きいため、参考程度にご覧ください。

距離帯別に見た結果(昇級初戦)

距離帯対象頭数勝率連対率3着内率
マイル前後(1300〜1700m)4813514.88%27.29%38.19%
中距離(1800m〜)38259.86%19.11%29.20%
短距離(〜1200m)1321313.39%24.64%34.96%

データから分かったこと

  • 昇級初戦の勝率は14.28%(65,173頭)、同級継続は8.53%(555,759頭)でした。昇級初戦馬の勝率は数値上高く見えますが、昇級馬は前走好走の実力馬が多く人気も低め(平均人気 4.81番人気 vs 同級継続 6.07番人気)なため、単純な比較には注意が必要です。
  • 前走1着で昇級した馬の勝率は21.80%・3着内率は49.20%(26,004頭)と高い水準を維持していました。
  • 前走2着以下で昇級した馬の勝率は9.30%・3着内率は28.92%(39,169頭)と大きく下がる傾向が確認できます。
  • 単勝回収率(昇級初戦 0.71円 / 同級継続 0.70円)はいずれも100円以下で、昇級初戦だから回収率が特別高いわけではないことが分かります。
  • 競馬場・距離帯によっても傾向が異なるため、一律に評価することは難しいです。

注意点・限界

  • クラス表記が競馬場・時期によって異なる可能性があり、クラスを抽出できないレースは判定不能に分類されます。
  • 年齢限定戦・重賞など、単純なクラス比較が難しいレースは除外しています。
  • 昇級理由や出走編成のルールを完全には反映できていない可能性があります。
  • 前走圧勝・辛勝の判定に必要な「前走2着馬との差」はDBから直接取得できないため、前走着順のみで代替しています。
  • 単勝回収率はサンプル数や高配当の影響を受けます。
  • 成績差には馬の能力・相手関係・馬場・展開なども影響します。

まとめ

地方競馬データ(2017/01/01〜2025/12/19)を使い、昇級初戦・同級継続・降級初戦の成績を比較しました。

  • 昇級初戦の勝率(14.28%)は同級継続(8.53%)より高い数値ですが、昇級馬は人気馬が多く、単純な比較には注意が必要です。
  • 最も差が大きかったのは前走着順で、前走1着→昇級は勝率21.80%・3着内率49.20%と好成績なのに対し、前走2着以下→昇級は勝率9.30%・3着内率28.92%と苦戦する傾向が見られます。
  • 「昇級初戦はすべて消し」とは言えませんが、前走2着以下での昇級は今走人気に関わらず苦戦する傾向がデータから確認できます。

分析スクリプト

"""
【地方競馬データ検証】昇級初戦は本当に成績が落ちるのか?
"""
import sqlite3
import pandas as pd
import numpy as np
import re
import html as html_lib
from pathlib import Path

SCRIPT_DIR = Path(__file__).parent
DB_PATH    = SCRIPT_DIR.parents[2] / 'common.db'
OUTPUT_DIR = SCRIPT_DIR
DATA_DIR   = SCRIPT_DIR / 'data'
DATA_DIR.mkdir(parents=True, exist_ok=True)

# ================================================================
# 1. データ読み込み
# ================================================================
cols = [
    '日付', '場所', 'レースID', 'レース番号', 'レース名', '馬名', '馬番',
    '着順', '人気', '単勝', '1着差', '距離',
    '失', '取', '除', '中', '取消レース', '失格レース', '除外レース', '中止レース',
]
cols_quoted = [f'"{c}"' for c in cols]
query = 'SELECT ' + ','.join(cols_quoted) + ' FROM result_table'

with sqlite3.connect(DB_PATH) as conn:
    df = pd.read_sql_query(query, conn)

df['日付'] = pd.to_datetime(df['日付'].astype(str), format='%Y%m%d', errors='coerce')

# ================================================================
# 2. 特殊成績除外
# ================================================================
special_flags = ['失', '取', '除', '中', '取消レース', '失格レース', '除外レース', '中止レース']
for flag in special_flags:
    df[flag] = df[flag].fillna(0).astype(int)

mask_valid = (
    df[special_flags].sum(axis=1) == 0
) & df['着順'].between(1, 99) & df['単勝'].notna() & (df['単勝'] > 0)

df_valid = df[mask_valid].copy()

# ================================================================
# 3. クラス抽出・ランク化
# ================================================================
# 実DBに A3, A4, B4 が存在するため完全順序で定義
CLASS_ORDER = {
    'C4': 1, 'C3': 2, 'C2': 3, 'C1': 4,
    'B4': 5, 'B3': 6, 'B2': 7, 'B1': 8,
    'A4': 9, 'A3': 10, 'A2': 11, 'A1': 12,
}

def extract_class(race_name: str):
    """レース名から最初に出現する [ABC][1-4] パターンを返す。"""
    if not isinstance(race_name, str):
        return None
    m = re.search(r'([ABC][1-4])', race_name)
    return m.group(1) if m else None

df_valid['クラス'] = df_valid['レース名'].apply(extract_class)

# ================================================================
# 4. 前走情報を付与(馬名でグループ化してshift)
# ================================================================
df_valid = df_valid.sort_values(['馬名', '日付', 'レースID', 'レース番号'])

for col in ['クラス', '着順', '人気', '1着差', '距離', '場所']:
    df_valid[f'prev_{col}'] = df_valid.groupby('馬名')[col].shift(1)

# 前走情報が揃っている行のみ対象
df_p = df_valid[df_valid['prev_日付' if 'prev_日付' in df_valid.columns else 'prev_着順'].notna()].copy() \
    if 'prev_日付' not in df_valid.columns else df_valid[df_valid['prev_着順'].notna()].copy()

# prev_日付を個別に計算
df_valid['prev_日付'] = df_valid.groupby('馬名')['日付'].shift(1)
df_p = df_valid[df_valid['prev_日付'].notna()].copy()

# ================================================================
# 5. 昇降級判定
# ================================================================
def judge_class_change(curr_cls, prev_cls):
    if (not isinstance(curr_cls, str) or not isinstance(prev_cls, str)
            or curr_cls not in CLASS_ORDER or prev_cls not in CLASS_ORDER):
        return '判定不能'
    diff = CLASS_ORDER[curr_cls] - CLASS_ORDER[prev_cls]
    if diff > 0:   return '昇級初戦'
    elif diff == 0: return '同級継続'
    else:           return '降級初戦'

df_p['クラス変化'] = df_p.apply(
    lambda r: judge_class_change(r['クラス'], r['prev_クラス']), axis=1
)

# ================================================================
# 6. 成績フラグ・グループ変数
# ================================================================
df_p['勝ち']    = (df_p['着順'] == 1).astype(int)
df_p['連対']    = (df_p['着順'] <= 2).astype(int)
df_p['3着内']   = (df_p['着順'] <= 3).astype(int)
df_p['単勝回収'] = np.where(df_p['着順'] == 1, df_p['単勝'], 0.0)

def pop_group(p):
    try:
        p = int(p)
    except:
        return 'その他'
    if p == 1:   return '①1番人気'
    if p <= 3:   return '②2〜3番人気'
    if p <= 6:   return '③4〜6番人気'
    return '④7番人気以下'

def dist_group(d):
    try:
        d = int(d)
    except:
        return '不明'
    if d <= 1200:  return '短距離(〜1200m)'
    if d <= 1700:  return 'マイル前後(1300〜1700m)'
    return '中距離(1800m〜)'

df_p['人気グループ'] = df_p['人気'].apply(pop_group)
df_p['距離帯']     = df_p['距離'].apply(dist_group)

# ================================================================
# 7. 集計
# ================================================================
def agg_df(df_g):
    n = len(df_g)
    if n == 0:
        return {'対象頭数': 0, '対象レース数': 0, '勝率': np.nan,
                '連対率': np.nan, '3着内率': np.nan,
                '平均人気': np.nan, '平均単勝オッズ': np.nan, '単勝回収率': np.nan}
    return {
        '対象頭数':      n,
        '対象レース数':  df_g[['日付','レースID']].drop_duplicates().shape[0],
        '勝率':          df_g['勝ち'].mean(),
        '連対率':        df_g['連対'].mean(),
        '3着内率':       df_g['3着内'].mean(),
        '平均人気':      df_g['人気'].mean(),
        '平均単勝オッズ': df_g['単勝'].mean(),
        '単勝回収率':    df_g['単勝回収'].sum() / n,
    }

# ---- 全体サマリー ----
main_groups = ['昇級初戦', '同級継続', '降級初戦', '判定不能']
summary_rows = [{'グループ': g, **agg_df(df_p[df_p['クラス変化'] == g])} for g in main_groups]

# 昇級初戦の細分類
df_sho = df_p[df_p['クラス変化'] == '昇級初戦'].copy()
summary_rows += [
    {'グループ': '昇級初戦_前走1着', **agg_df(df_sho[df_sho['prev_着順'] == 1])},
    {'グループ': '昇級初戦_前走2着以下', **agg_df(df_sho[df_sho['prev_着順'] > 1])},
]
summary_df = pd.DataFrame(summary_rows)
summary_df.to_csv(OUTPUT_DIR / 'summary.csv', index=False)

# ---- カテゴリ別テーブル ----
def agg_by(df_g, by_col):
    return (
        df_g.groupby(by_col)
        .agg(
            対象頭数=('勝ち','size'),
            勝率=('勝ち','mean'),
            連対率=('連対','mean'),
            t3着内率=('3着内','mean'),
        )
        .rename(columns={'t3着内率':'3着内率'})
        .reset_index()
    )

category_tables = {}

# 昇級初戦 × 人気グループ
pop_tbl = agg_by(df_sho, '人気グループ').sort_values('人気グループ')
category_tables['昇級初戦_人気グループ別'] = pop_tbl

# 昇級初戦 × 場所
loc_tbl = agg_by(df_sho, '場所').sort_values('対象頭数', ascending=False)
category_tables['昇級初戦_場所別'] = loc_tbl

# 昇級初戦 × 距離帯
dist_tbl = agg_by(df_sho, '距離帯').sort_values('距離帯')
category_tables['昇級初戦_距離帯別'] = dist_tbl

# 昇級初戦 × 前走着順(1〜12着)
prevrank_tbl = (
    df_sho[df_sho['prev_着順'].between(1, 12)]
    .assign(前走着順=lambda x: x['prev_着順'].astype(int))
    .groupby('前走着順')
    .agg(
        対象頭数=('勝ち','size'),
        勝率=('勝ち','mean'),
        連対率=('連対','mean'),
        t3着内率=('3着内','mean'),
    )
    .rename(columns={'t3着内率':'3着内率'})
    .reset_index()
    .sort_values('前走着順')
)
category_tables['昇級初戦_前走着順別'] = prevrank_tbl

# 全グループ × 人気グループ(比較用)
cross_tbl = (
    df_p[df_p['クラス変化'].isin(['昇級初戦','同級継続','降級初戦'])]
    .groupby(['クラス変化','人気グループ'])
    .agg(
        対象頭数=('勝ち','size'),
        勝率=('勝ち','mean'),
        連対率=('連対','mean'),
        t3着内率=('3着内','mean'),
    )
    .rename(columns={'t3着内率':'3着内率'})
    .reset_index()
    .sort_values(['クラス変化','人気グループ'])
)
category_tables['全体_クラス変化x人気グループ'] = cross_tbl

# CSV 保存
for name, tbl in category_tables.items():
    parts = name.split('_', 1)
    def_dir = DATA_DIR / parts[0]
    def_dir.mkdir(parents=True, exist_ok=True)
    tbl.to_csv(def_dir / f'{parts[1]}.csv', index=False)

# ================================================================
タイトルとURLをコピーしました