Files

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()