セリエAの4位争いをPythonで統計的に予想してみた(1万回シミュレーション)
混迷を極める来季CL権争い
セリエAの来季CL権争い(=4位争い)が混迷を極めている。第35節終了時点で、4位ユベントス(63pts)から8位フィオレンティーナ(59pts)までの5クラブが、わずか勝点4差の中にひしめく状況になっている。
とりわけ、35節の結果、4位ユベントス、5位ローマ、6位ラツィオが勝点63で並ぶことになり、どう転ぶかわからない状況だ。

Pythonで統計的に予測してみることに
本記事では、セリエAにおける残り1枠のチャンピオンズリーグ出場権(=4位)を巡る5クラブの争いについて、Pythonを用いたモンテカルロシミュレーション(1万回)で順位決定を行った際の処理の全体像とプロセスを詳しく解説する。
対象クラブ:
• Juventus
• Roma
• Lazio
• Bologna
• Fiorentina
全体概要:何をしているのか?
現在の勝点と直接対決成績を前提として、当該5チームの残り試合(3節分)を1万回ランダムにシミュレーション
各試行ごとに全チームの勝点を再計算
勝点が並んだ場合には、該当クラブのみで直接対決成績を集計(=クラシフィカ・アヴルサ)し、順位を決定
最終的に、5クラブの中で最も上位=セリエA全体での「4位」とみなして確率を算出
注 : 1-3位をインテル、ナポリ、アタランタが占めると仮定し、残1枠をユベントス、ローマ、ラツィオ、ボローニャ、フィオレンティーナで争うと仮定している。
1 : 現在の順位状況を定義
まず第35節終了時点の順位、勝点、総得点、総失点、得失点差を前提にする。

# 初期勝ち点
initial_points = {
"Juventus": 63,
"Roma": 63,
"Lazio": 63,
"Bologna": 62,
"Fiorentina": 59,
}
# 第35節終了時点の得点・失点
initial_GF = {
"Juventus": 52,
"Roma": 50,
"Lazio": 58,
"Bologna": 53,
"Fiorentina": 53,
}
initial_GA = {
"Juventus": 32,
"Roma": 32,
"Lazio": 45,
"Bologna": 38,
"Fiorentina": 35,
}2 : 今後の対戦カード
各チームのラスト3節の対戦カードを全てインプットする。ユベントスであれば以下。ほか4チーム分も同様。

3 : ラスト3節の対戦相手との過去の戦績から各カードの想定勝率を定義する。
ラスト3節の対戦相手との、セリエAにおける過去の対戦成績(H/A別)をインプットする。ユベントスであれば以下のように。ほか4チームも同様。この戦績をもって当該カードごとの勝率を概算する。



# 残り試合の勝率(home win, draw, away win)
fixture_probs = {
("Lazio", "Juventus"): (0.25, 0.25, 0.50),
("Juventus", "Udinese"): (0.75, 0.15, 0.10),
("Venezia", "Juventus"): (0.10, 0.30, 0.60),
("Atalanta", "Roma"): (0.50, 0.25, 0.25),
("Roma", "Milan"): (0.30, 0.30, 0.40),
("Torino", "Roma"): (0.10, 0.20, 0.70),
("Inter", "Lazio"): (0.65, 0.25, 0.10),
("Lazio", "Lecce"): (0.65, 0.25, 0.10),
("Milan", "Bologna"): (0.60, 0.30, 0.10),
("Fiorentina", "Bologna"): (0.60, 0.25, 0.15),
("Bologna", "Genoa"): (0.40, 0.35, 0.25),
("Venezia", "Fiorentina"): (0.20, 0.30, 0.50),
("Udinese", "Fiorentina"): (0.20, 0.30, 0.50),
("Juventus", "Fiorentina"): (0.50, 0.30, 0.20),
}4 : 同一勝点時の順位決定方法
最終勝点が並んだ場合は、FIGCが定めた「クラシフィカ・アヴルサ(当該クラブ間ミニ順位表)」の5段階基準で順位が決まる。このルールを前提とする。
1. 直接対決での勝点
2. 直接対決での得失点差
3. シーズン全体の得失点差
4. シーズン全体の総得点
5. 抽選(FIGC本部で公開)
# ミニリーグ順位決定関数(クラシフィカ・アヴルサ+GD+GF)
def apply_tiebreaker(matches, teams, full_gd, full_gf):
pts, gf, ga = Counter(), Counter(), Counter()
for h, a, hg, ag in matches:
if h in teams and a in teams:
if hg > ag: pts[h] += 3
elif hg < ag: pts[a] += 3
else: pts[h] += 1; pts[a] += 1
gf[h] += hg; ga[h] += ag
gf[a] += ag; ga[a] += hg
gd = {t: gf[t] - ga[t] for t in teams}
return sorted(teams, key=lambda t: (-pts[t], -gd[t], -gf[t], -full_gd[t], -full_gf[t], random.random()))5 : 今季の消化済みの直接対決の結果
4位候補5チームの今季の対戦結果を考慮する。シミュレーション後の同勝点の場合における順位決定のため。
Juventus 0-0 Roma
Roma 1-1 Juventus
Juventus 1-0 Lazio
Juventus 2-2 Bologna
Bologna 1-1 Juventus
Juventus 2-2 Fiorentina
Fiorentina 3-0 Juventus
Roma 2-0 Lazio
Lazio 1-1 Roma
Roma 2-3 Bologna
Bologna 2-2 Roma
Roma 1-0 fForentina
Fiorentina 5-1 Roma
Lazio 3- Bologna
Bologna 5-0 Lazio
Lazio 1-2 fForentina
Fiorentina 2-1 Lazio
Bologna 1-0 Forentina
# 確定済みの直接対決スコア(home, away, home_goals, away_goals)
confirmed_matches = [
("Juventus", "Roma", 0, 0),
("Roma", "Juventus", 1, 1),
("Juventus", "Lazio", 1, 0),
("Juventus", "Bologna", 2, 2),
("Bologna", "Juventus", 1, 1),
("Juventus", "Fiorentina", 2, 2),
("Fiorentina", "Juventus", 3, 0),
("Roma", "Lazio", 2, 0),
("Lazio", "Roma", 1, 1),
("Roma", "Bologna", 2, 3),
("Bologna", "Roma", 2, 2),
("Roma", "Fiorentina", 1, 0),
("Fiorentina", "Roma", 5, 1),
("Lazio", "Bologna", 3, 0),
("Bologna", "Lazio", 5, 0),
("Lazio", "Fiorentina", 1, 2),
("Fiorentina", "Lazio", 2, 1),
("Bologna", "Fiorentina", 1, 0),
]6. 残3節のシミュレーション実行時におけるスコア生成
試合の勝敗結果(H:ホーム勝ち、D:引き分け、A:アウェイ勝ち)に基づいて、現実的なスコア(得点)をランダムに生成する。
「試合結果(H/D/A)だけでは勝ち点計算はできても、得失点や総得点がわからない」という課題を解決するために使われる。
# ランダムスコア生成
def generate_random_score(result):
if result == "H": return random.randint(1, 3), random.randint(0, 1)
elif result == "D": g = random.randint(0, 2); return g, g
else: return random.randint(0, 1), random.randint(1, 3)7. モンテカルロ法によるシミュレーション(10,000回試行)
あとは、モンテカルロ法を用いて、残り3節の対戦を10,000回シミュレーションし、クラシフィカ・アヴルサも踏まえた最終順位を算出。10,000回のうち各チームが何回ずつ4位になったかを集計する。
モンテカルロ法(Monte Carlo method)とは、
確率的な手法を用いて、複雑な現象や不確実性のある問題を多数の試行(シミュレーション)によって統計的に近似・予測する手法。
# メイン処理
ranking_counts = defaultdict(Counter)
N = 10000
for _ in range(N):
pts = initial_points.copy()
gf = initial_GF.copy()
ga = initial_GA.copy()
direct = confirmed_matches[:]
for (home, away), prob in fixture_probs.items():
r = random.random()
result = "H" if r < prob[0] else "D" if r < prob[0] + prob[1] else "A"
hg, ag = generate_random_score(result)
if home in pts: pts[home] += 3 if hg > ag else 1 if hg == ag else 0
if away in pts: pts[away] += 3 if ag > hg else 1 if hg == ag else 0
if home in gf: gf[home] += hg; ga[home] += ag
if away in gf: gf[away] += ag; ga[away] += hg
if home in pts and away in pts:
direct.append((home, away, hg, ag))
sorted_pts = sorted(pts.items(), key=lambda x: -x[1])
final_ranks = {}
i = 0
while i < len(sorted_pts):
group = [sorted_pts[i][0]]
score = sorted_pts[i][1]
i += 1
while i < len(sorted_pts) and sorted_pts[i][1] == score:
group.append(sorted_pts[i][0])
i += 1
if len(group) == 1:
final_ranks[group[0]] = len(final_ranks) + 1
else:
ordered = apply_tiebreaker(direct, group, {t: gf[t]-ga[t] for t in group}, gf)
for t in ordered:
final_ranks[t] = len(final_ranks) + 1
for t in final_ranks:
ranking_counts[t][final_ranks[t]] += 1