357 lines
12 KiB
Python
357 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
2026 March Madness Bracket Picker
|
|
Three-layer model: Consensus Vote + Entropy/Chaos Upsets + Contrarian Game Theory
|
|
"""
|
|
|
|
import csv
|
|
import math
|
|
import os
|
|
from statistics import mean, stdev
|
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
DATA_DIR = os.path.join(SCRIPT_DIR, "data")
|
|
|
|
ESPN_TO_MASSEY = {
|
|
"UConn": "Connecticut",
|
|
"Iowa State": "Iowa St",
|
|
"Ohio State": "Ohio St",
|
|
"Utah State": "Utah St",
|
|
"Kennesaw St": "Kennesaw",
|
|
"McNeese": "McNeese St",
|
|
"Miami": "Miami FL",
|
|
"Queens": "Queens NC",
|
|
"Saint Louis": "St Louis",
|
|
"Saint Mary's": "St Mary's CA",
|
|
"CA Baptist": "Cal Baptist",
|
|
"Hawai'i": "Hawaii",
|
|
"Long Island": "LIU Brooklyn",
|
|
}
|
|
|
|
# Points per round in standard bracket scoring
|
|
ROUND_POINTS = [1, 2, 4, 8, 16, 32]
|
|
|
|
ROUND_NAMES = [
|
|
"ROUND OF 64",
|
|
"ROUND OF 32",
|
|
"SWEET 16",
|
|
"ELITE 8",
|
|
"FINAL FOUR",
|
|
"CHAMPIONSHIP",
|
|
]
|
|
|
|
# Final Four pairings: (region_index_a, region_index_b)
|
|
# East(0) vs South(1), West(2) vs Midwest(3)
|
|
FINAL_FOUR_PAIRINGS = [(0, 1), (2, 3)]
|
|
|
|
# Historical seed advance rates (approximate public pick proxy)
|
|
# Maps seed -> probability of advancing to each round (R32, S16, E8, F4, Champ, Winner)
|
|
PUBLIC_ADVANCE = {
|
|
1: [0.99, 0.85, 0.60, 0.40, 0.25, 0.15],
|
|
2: [0.94, 0.65, 0.40, 0.22, 0.12, 0.06],
|
|
3: [0.85, 0.50, 0.25, 0.12, 0.05, 0.02],
|
|
4: [0.80, 0.42, 0.20, 0.08, 0.03, 0.01],
|
|
5: [0.65, 0.30, 0.12, 0.04, 0.015, 0.005],
|
|
6: [0.63, 0.28, 0.11, 0.04, 0.01, 0.004],
|
|
7: [0.60, 0.22, 0.08, 0.03, 0.01, 0.003],
|
|
8: [0.50, 0.18, 0.06, 0.02, 0.007, 0.002],
|
|
9: [0.50, 0.18, 0.06, 0.02, 0.007, 0.002],
|
|
10: [0.40, 0.15, 0.05, 0.015, 0.005, 0.001],
|
|
11: [0.37, 0.13, 0.04, 0.01, 0.004, 0.001],
|
|
12: [0.35, 0.12, 0.04, 0.01, 0.003, 0.001],
|
|
13: [0.20, 0.05, 0.02, 0.005, 0.001, 0.0005],
|
|
14: [0.15, 0.04, 0.01, 0.003, 0.001, 0.0003],
|
|
15: [0.06, 0.02, 0.005, 0.001, 0.0003, 0.0001],
|
|
16: [0.01, 0.003, 0.001, 0.0003, 0.0001, 0.00003],
|
|
}
|
|
|
|
|
|
def parse_massey():
|
|
"""Parse Massey composite rankings. Returns {team: [list of ranks across systems]}."""
|
|
path = os.path.join(DATA_DIR, "massey.csv")
|
|
teams = {} # team_name -> list of int ranks (None for missing)
|
|
team_cmp = {} # team_name -> composite rank
|
|
|
|
with open(path, newline="", encoding="utf-8-sig") as f:
|
|
reader = csv.reader(f)
|
|
header = next(reader)
|
|
for row in reader:
|
|
name = row[0].strip()
|
|
cmp = int(row[4]) if row[4].strip().isdigit() else 999
|
|
ranks = []
|
|
for val in row[6:]:
|
|
val = val.strip().strip('"')
|
|
if val and val != "--":
|
|
ranks.append(int(val))
|
|
else:
|
|
ranks.append(None)
|
|
teams[name] = ranks
|
|
team_cmp[name] = cmp
|
|
|
|
return teams, team_cmp
|
|
|
|
|
|
def parse_matchups():
|
|
"""Parse region-grouped matchups file. Returns list of (region_name, [(seed1,team1,seed2,team2)...])."""
|
|
path = os.path.join(DATA_DIR, "matchups.csv")
|
|
regions = []
|
|
current_region = None
|
|
current_games = []
|
|
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
if current_region and current_games:
|
|
regions.append((current_region, current_games))
|
|
current_games = []
|
|
current_region = None
|
|
continue
|
|
if "," not in line:
|
|
current_region = line
|
|
continue
|
|
parts = line.split(",")
|
|
s1, t1, s2, t2 = int(parts[0]), parts[1], int(parts[2]), parts[3]
|
|
current_games.append((s1, t1, s2, t2))
|
|
|
|
if current_region and current_games:
|
|
regions.append((current_region, current_games))
|
|
|
|
return regions
|
|
|
|
|
|
def resolve_name(espn_name):
|
|
"""Map ESPN team name to Massey name."""
|
|
return ESPN_TO_MASSEY.get(espn_name, espn_name)
|
|
|
|
|
|
# Historical win rate for the HIGHER seed in each seed matchup
|
|
HIST_HIGHER_SEED_WIN = {
|
|
(1, 16): 0.99, (2, 15): 0.94, (3, 14): 0.85, (4, 13): 0.79,
|
|
(5, 12): 0.65, (6, 11): 0.63, (7, 10): 0.61, (8, 9): 0.51,
|
|
}
|
|
|
|
HISTORY_WEIGHT = 0.35 # how much to blend historical prior vs model
|
|
|
|
|
|
|
|
def consensus_prob(ranks_a, ranks_b, seed_a, seed_b):
|
|
"""Layer 1: Logistic probability from mean rank differential, blended with
|
|
historical seed matchup win rates.
|
|
|
|
The logistic captures what the 54 ranking systems think. The historical prior
|
|
captures that tournament basketball has intrinsic upset rates — 12-seeds beat
|
|
5-seeds 35% of the time regardless of what the rankings say.
|
|
"""
|
|
diffs = []
|
|
for ra, rb in zip(ranks_a, ranks_b):
|
|
if ra is not None and rb is not None:
|
|
diffs.append(ra - rb) # positive = A is worse
|
|
if not diffs:
|
|
return 0.5
|
|
avg_diff = mean(diffs)
|
|
k = 0.05
|
|
model_prob = 1.0 / (1.0 + math.exp(k * avg_diff))
|
|
|
|
# Blend with historical seed matchup rate
|
|
key = (min(seed_a, seed_b), max(seed_a, seed_b))
|
|
if key in HIST_HIGHER_SEED_WIN:
|
|
hist_higher_wins = HIST_HIGHER_SEED_WIN[key]
|
|
# If A is the higher seed (lower number), hist prob favors A
|
|
hist_prob_a = hist_higher_wins if seed_a < seed_b else (1 - hist_higher_wins)
|
|
blended = model_prob * (1 - HISTORY_WEIGHT) + hist_prob_a * HISTORY_WEIGHT
|
|
return blended
|
|
|
|
return model_prob
|
|
|
|
|
|
def chaos_factor(ranks_a, ranks_b):
|
|
"""Compute how much the 54 systems disagree about this matchup."""
|
|
diffs = [ra - rb for ra, rb in zip(ranks_a, ranks_b)
|
|
if ra is not None and rb is not None]
|
|
if len(diffs) < 3:
|
|
return 0.0
|
|
return stdev(diffs) / max(abs(mean(diffs)), 1.0)
|
|
|
|
|
|
def entropy_adjust(base_prob, ranks_a, ranks_b):
|
|
"""Layer 2: Compress probability toward 0.50 proportional to ranking disagreement."""
|
|
cf = chaos_factor(ranks_a, ranks_b)
|
|
|
|
compression = min(0.65, 0.20 * cf)
|
|
return 0.5 + (base_prob - 0.5) * (1 - compression)
|
|
|
|
|
|
# Target number of first-round upsets (historical average is ~8-9)
|
|
TARGET_R64_UPSETS = 8
|
|
|
|
|
|
def upset_score(adj_prob, seed_a, seed_b, cf):
|
|
"""Score how good an upset candidate this matchup is.
|
|
|
|
Combines: historical upset rate for this seed pairing (60%),
|
|
how close the adjusted probability is to a toss-up (20%),
|
|
and how much the ranking systems disagree (20%).
|
|
"""
|
|
key = (min(seed_a, seed_b), max(seed_a, seed_b))
|
|
hist_upset_rate = 1 - HIST_HIGHER_SEED_WIN.get(key, 0.99)
|
|
|
|
closeness = 1 - abs(adj_prob - 0.5) * 2 # 1.0 at 50/50, 0.0 at certainty
|
|
|
|
chaos_norm = min(cf / 3.0, 1.0) # normalize chaos to 0-1 range
|
|
|
|
return hist_upset_rate * 0.6 + closeness * 0.2 + chaos_norm * 0.2
|
|
|
|
|
|
def contrarian_adjust(prob_a, seed_a, seed_b, round_idx):
|
|
"""Layer 3: Prefer contrarian picks with higher pool EV.
|
|
|
|
In a pool, a correct upset pick is worth more than a correct chalk pick
|
|
because fewer people picked it. This applies in ALL rounds, but scales
|
|
with round points (later rounds = bigger payoff for differentiation).
|
|
"""
|
|
points = ROUND_POINTS[round_idx]
|
|
|
|
pub_a = PUBLIC_ADVANCE.get(seed_a, PUBLIC_ADVANCE[16])[round_idx]
|
|
pub_b = PUBLIC_ADVANCE.get(seed_b, PUBLIC_ADVANCE[16])[round_idx]
|
|
|
|
ev_a = prob_a * points / max(pub_a, 0.001)
|
|
ev_b = (1 - prob_a) * points / max(pub_b, 0.001)
|
|
|
|
# Flip to underdog when their EV is meaningfully higher and the game
|
|
# is genuinely competitive (not a heavy favorite being toppled)
|
|
if ev_b > ev_a * 1.3 and 0.35 < prob_a < 0.62:
|
|
return 1 - prob_a # flip
|
|
|
|
return prob_a
|
|
|
|
|
|
def get_ranks(team, massey_teams):
|
|
name = resolve_name(team)
|
|
ranks = massey_teams.get(name)
|
|
if ranks is None:
|
|
print(f" WARNING: {team} ({name}) not found in Massey rankings")
|
|
return [365] * 54
|
|
return ranks
|
|
|
|
|
|
def pick_winner(team_a, seed_a, team_b, seed_b, massey_teams, round_idx, force_upset=False):
|
|
"""Apply all layers to pick a winner. Returns (winner_name, winner_seed, prob)."""
|
|
ranks_a = get_ranks(team_a, massey_teams)
|
|
ranks_b = get_ranks(team_b, massey_teams)
|
|
|
|
# Layer 1: Consensus vote + historical prior
|
|
prob_a = consensus_prob(ranks_a, ranks_b, seed_a, seed_b)
|
|
|
|
# Layer 2: Entropy/Chaos compression
|
|
prob_a = entropy_adjust(prob_a, ranks_a, ranks_b)
|
|
|
|
# Layer 3: Contrarian EV adjustment
|
|
prob_a = contrarian_adjust(prob_a, seed_a, seed_b, round_idx)
|
|
|
|
# Layer 2b: Forced upset from the scored upset selection
|
|
if force_upset and prob_a >= 0.5:
|
|
prob_a = 1 - prob_a # flip to underdog
|
|
|
|
if prob_a >= 0.5:
|
|
return team_a, seed_a, prob_a
|
|
else:
|
|
return team_b, seed_b, 1 - prob_a
|
|
|
|
|
|
def run_bracket():
|
|
massey_teams, massey_cmp = parse_massey()
|
|
regions = parse_matchups()
|
|
|
|
# --- Score all R64 matchups to select best upset candidates ---
|
|
all_r64 = [] # (region_idx, game_idx, team_a, seed_a, team_b, seed_b, adj_prob, cf, score)
|
|
for ri, (region_name, games) in enumerate(regions):
|
|
for gi, (s1, t1, s2, t2) in enumerate(games):
|
|
ranks_a = get_ranks(t1, massey_teams)
|
|
ranks_b = get_ranks(t2, massey_teams)
|
|
prob = consensus_prob(ranks_a, ranks_b, s1, s2)
|
|
prob = entropy_adjust(prob, ranks_a, ranks_b)
|
|
cf = chaos_factor(ranks_a, ranks_b)
|
|
# Score from perspective of higher seed being the favorite
|
|
score = upset_score(prob, s1, s2, cf)
|
|
all_r64.append((ri, gi, t1, s1, t2, s2, prob, cf, score))
|
|
|
|
# Select the top N upset candidates
|
|
ranked = sorted(all_r64, key=lambda x: x[8], reverse=True)
|
|
upset_set = set() # (region_idx, game_idx)
|
|
for entry in ranked:
|
|
if len(upset_set) >= TARGET_R64_UPSETS:
|
|
break
|
|
ri, gi, t1, s1, t2, s2, prob, cf, score = entry
|
|
# Only flip games where the higher seed is currently winning
|
|
if (prob >= 0.5 and s1 < s2) or (prob < 0.5 and s2 < s1):
|
|
upset_set.add((ri, gi))
|
|
|
|
region_champions = []
|
|
|
|
for ri, (region_name, games) in enumerate(regions):
|
|
print(f"\n{'=' * 50}")
|
|
print(f" {region_name.upper()} REGION")
|
|
print(f"{'=' * 50}")
|
|
|
|
current = [(t1, s1, t2, s2) for s1, t1, s2, t2 in games]
|
|
|
|
for round_idx in range(4):
|
|
round_name = ROUND_NAMES[round_idx]
|
|
print(f"\n --- {round_name} ---")
|
|
|
|
winners = []
|
|
for gi, (ta, sa, tb, sb) in enumerate(current):
|
|
force = round_idx == 0 and (ri, gi) in upset_set
|
|
winner, wseed, prob = pick_winner(
|
|
ta, sa, tb, sb, massey_teams, round_idx, force_upset=force
|
|
)
|
|
loser = tb if winner == ta else ta
|
|
upset = " << UPSET" if wseed > min(sa, sb) else ""
|
|
print(f" {winner:<22} over {loser:<22} [{prob:.2f}]{upset}")
|
|
winners.append((winner, wseed))
|
|
|
|
if len(winners) >= 2:
|
|
current = [
|
|
(winners[i][0], winners[i][1], winners[i + 1][0], winners[i + 1][1])
|
|
for i in range(0, len(winners), 2)
|
|
]
|
|
|
|
champ, champ_seed = winners[0]
|
|
region_champions.append((region_name, champ, champ_seed))
|
|
print(f"\n >> {region_name} Champion: ({champ_seed}) {champ}")
|
|
|
|
# Final Four (round 4)
|
|
print(f"\n{'=' * 50}")
|
|
print(f" FINAL FOUR")
|
|
print(f"{'=' * 50}")
|
|
|
|
finalists = []
|
|
for ra_idx, rb_idx in FINAL_FOUR_PAIRINGS:
|
|
ra_name, ta, sa = region_champions[ra_idx]
|
|
rb_name, tb, sb = region_champions[rb_idx]
|
|
print(f"\n {ra_name} vs {rb_name}:")
|
|
winner, wseed, prob = pick_winner(ta, sa, tb, sb, massey_teams, 4)
|
|
loser = tb if winner == ta else ta
|
|
print(f" {winner:<22} over {loser:<22} [{prob:.2f}]")
|
|
finalists.append((winner, wseed))
|
|
|
|
# Championship (round 5)
|
|
print(f"\n{'=' * 50}")
|
|
print(f" CHAMPIONSHIP")
|
|
print(f"{'=' * 50}")
|
|
|
|
ta, sa = finalists[0]
|
|
tb, sb = finalists[1]
|
|
winner, wseed, prob = pick_winner(ta, sa, tb, sb, massey_teams, 5)
|
|
loser = tb if winner == ta else ta
|
|
print(f"\n {winner:<22} over {loser:<22} [{prob:.2f}]")
|
|
|
|
print(f"\n{'=' * 50}")
|
|
print(f" CHAMPION: ({wseed}) {winner}")
|
|
print(f"{'=' * 50}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run_bracket()
|