Created
July 1, 2026 22:34
-
-
Save tulior/cbdb3f961595e45252d10d95bec5ad1a to your computer and use it in GitHub Desktop.
Live football match prediction model with player priors and Dixon-Coles correction
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| football_live_match_model.py | |
| Football Live Match Model | |
| Estimate live or pre-match football probabilities from player-level priors, | |
| current score, time remaining, Dixon-Coles low-score correction, optional | |
| knockout/penalty logic, and optional market-odds comparison. | |
| Generic live/pre-match football prediction model using: | |
| 1. Player-prior log-goal intensities | |
| 2. Time-scaled Poisson score simulation | |
| 3. Dixon-Coles low-score correction | |
| 4. Optional penalty shootout resolution for knockout matches | |
| ================================================================================ | |
| QUICK USE | |
| ================================================================================ | |
| Run with the built-in example: | |
| python generic_live_football_model.py | |
| Run with your own config: | |
| python generic_live_football_model.py --config match_config.json | |
| Create a starter config: | |
| python generic_live_football_model.py --write-template match_config_template.json | |
| ================================================================================ | |
| WHAT VALUES YOU NEED AND WHERE TO PULL THEM FROM | |
| ================================================================================ | |
| This script is intentionally generic. It does not scrape websites by itself. | |
| You provide the parameters from your data sources. | |
| Required inputs: | |
| 1. MATCH STATE | |
| Pull from: | |
| - Official match centre | |
| - FIFA / tournament match centre | |
| - Sportradar / Opta / StatsPerform feed | |
| - SofaScore / Flashscore / FotMob | |
| - Your sportsbook live screen if necessary | |
| Needed fields: | |
| current_home_goals | |
| current_away_goals | |
| minutes_remaining | |
| phase | |
| Examples: | |
| Pre-match: | |
| score = 0-0 | |
| minutes_remaining = 90 | |
| duration_factor = 1.0 | |
| Live at 67': | |
| score = 1-0 | |
| minutes_remaining = 23 plus stoppage estimate | |
| duration_factor = remaining_minutes / 90 | |
| Extra-time halftime: | |
| score = 2-2 | |
| minutes_remaining = 15 | |
| duration_factor = 15 / 90 | |
| 2. ACTIVE PLAYERS / LINEUPS | |
| Pull from: | |
| - Official lineup release | |
| - FIFA / federation lineup card | |
| - Reuters / AP match report | |
| - SofaScore / FotMob lineups | |
| - Your live tracker after substitutions | |
| You need one list for each team. | |
| For live in-game / extra-time: | |
| Use only players currently on the pitch. | |
| Set weight = 1.0 for each active player. | |
| The team weight sum should equal 11.0. | |
| For pre-match: | |
| Use expected-minutes weights. | |
| Example: | |
| GK and CBs: 1.00 | |
| Fullbacks: 0.85-1.00 | |
| Central mids: 0.70-0.95 | |
| Wingers: 0.60-0.85 | |
| Striker: 0.70-0.90 | |
| Bench attackers: 0.20-0.40 | |
| The total team weight should still equal 11.0. | |
| Better: enforce positional-slot weights where each tactical slot sums to 1.0. | |
| 3. PLAYER PRIORS: gamma_attack and delta_defense | |
| Pull or generate from: | |
| - Your player-prior pipeline | |
| - Event-level data such as Opta, Wyscout, StatsBomb, StatsPerform | |
| - Rolling out-of-sample per-90 metrics | |
| - Market-value / transfer-value cold-start model | |
| - League-adjusted xG, xA, xT, OBV, VAEP, defensive RAPM | |
| Interpretation: | |
| gamma_attack is offensive contribution on the log-goal scale. | |
| delta_defense is defensive suppression on the log-goal scale. | |
| gamma = 0.10 means roughly: | |
| exp(0.10) - 1 = +10.5% attacking-rate lift | |
| delta = 0.10 means roughly: | |
| 1 - exp(-0.10) = 9.5% opponent scoring-rate reduction | |
| Practical ranges: | |
| Average player: gamma 0.00, delta 0.00 | |
| Good attacker: gamma 0.04 to 0.08 | |
| Elite attacker: gamma 0.10 to 0.18 | |
| Weak attacker: gamma -0.03 to -0.08 | |
| Good defender: delta 0.04 to 0.08 | |
| Elite defender / goalkeeper:delta 0.10 to 0.16 | |
| Defensive liability: delta -0.03 to -0.08 | |
| 4. BASELINE mu | |
| Best source: | |
| - Fit on historical match/team xG using your training data. | |
| Meaning: | |
| exp(mu) is baseline team xG per 90 before player effects. | |
| Common practical values: | |
| exp(mu) = 1.15 to 1.35 | |
| mu = log(1.25) is a reasonable neutral default. | |
| Market-assisted fallback: | |
| If you do not have a trained base rate, derive total-goal expectation | |
| from Over/Under lines, then choose mu so the model's total xG roughly | |
| matches that expectation. | |
| Be careful: if mu is calibrated directly to the market, your output is | |
| no longer an independent pure-model prediction. It becomes a market- | |
| anchored allocation model. | |
| 5. rho: Dixon-Coles low-score parameter | |
| Best source: | |
| - Fit from historical match results using your Dixon-Coles likelihood. | |
| Typical range: | |
| -0.15 to +0.05 | |
| A common default: | |
| rho = -0.05 | |
| Interpretation: | |
| Negative rho tends to increase low-score draw mass, especially useful | |
| in cautious or late knockout states. | |
| 6. fatigue_multiplier / tempo_multiplier | |
| Pull or infer from: | |
| - Game state | |
| - Phase | |
| - Live xG / shot tempo | |
| - Tactical context | |
| - Red cards | |
| - Need-to-chase state | |
| Typical values: | |
| Normal pre-match: 1.00 | |
| Late game, cautious: 0.80-0.95 | |
| Extra time, tired/cautious: 0.85-0.95 | |
| Team chasing aggressively: 1.05-1.25 | |
| Open chaotic game: 1.15-1.35 | |
| 7. Penalty shootout edge | |
| Pull or generate from: | |
| - Goalkeeper penalty-saving data | |
| - Penalty taker quality | |
| - Market "to qualify" vs 90-min line | |
| - Historical shootout model | |
| - Simple 50/50 if uncertain | |
| Typical values: | |
| No edge: 0.50 | |
| Small edge: 0.53-0.56 | |
| Strong edge: 0.58-0.62 | |
| 8. Market odds, optional | |
| Pull from: | |
| - Bet365 | |
| - Pinnacle | |
| - Betfair Exchange | |
| - DraftKings / FanDuel / Caesars | |
| - Odds aggregators | |
| Use for comparison only: | |
| - 1X2 odds | |
| - To qualify odds | |
| - Over/Under goals | |
| - Next goal / extra-time / penalties markets | |
| ================================================================================ | |
| MATH SUMMARY | |
| ================================================================================ | |
| For each team: | |
| attack_team = sum_p weight_p * gamma_p | |
| defense_team = sum_p weight_p * delta_p | |
| Expected goals per 90: | |
| log(lambda_home_90) = mu + home_adv + attack_home - defense_away | |
| log(lambda_away_90) = mu + attack_away - defense_home | |
| Time scaling: | |
| lambda_home_remaining = | |
| lambda_home_90 * duration_factor * fatigue_multiplier | |
| lambda_away_remaining = | |
| lambda_away_90 * duration_factor * fatigue_multiplier | |
| Poisson score probability: | |
| P(H=h, A=a) | |
| = Pois(h | lambda_home_remaining) | |
| * | |
| Pois(a | lambda_away_remaining) | |
| Dixon-Coles correction: | |
| tau(0,0) = 1 - lambda_home * lambda_away * rho | |
| tau(0,1) = 1 + lambda_home * rho | |
| tau(1,0) = 1 + lambda_away * rho | |
| tau(1,1) = 1 - rho | |
| tau(x,y) = 1 otherwise | |
| Corrected score matrix: | |
| M[h,a] = Pois(h) * Pois(a) * tau(h,a) | |
| Then normalize: | |
| M <- M / sum(M) | |
| For knockout matches: | |
| P(home qualifies) | |
| = P(home ahead after simulated period) | |
| + P(tied after simulated period) * home_penalty_win_prob | |
| ================================================================================ | |
| CONFIG FORMAT | |
| ================================================================================ | |
| A config JSON should look like this: | |
| { | |
| "match": { | |
| "home_team": "Team A", | |
| "away_team": "Team B", | |
| "current_home_goals": 0, | |
| "current_away_goals": 0, | |
| "minutes_remaining": 90, | |
| "duration_factor": 1.0, | |
| "fatigue_multiplier": 1.0, | |
| "mu": 0.22314355131420976, | |
| "rho": -0.05, | |
| "home_adv": 0.0, | |
| "home_penalty_win_prob": 0.50, | |
| "max_goals_remaining": 10, | |
| "knockout": true | |
| }, | |
| "home_players": [ | |
| {"name": "Home GK", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.12} | |
| ], | |
| "away_players": [ | |
| {"name": "Away GK", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.10} | |
| ], | |
| "market_odds": { | |
| "home_90": 2.10, | |
| "draw_90": 3.40, | |
| "away_90": 3.50, | |
| "home_qualify": 1.70, | |
| "away_qualify": 2.20 | |
| } | |
| } | |
| market_odds is optional and only used for edge/EV comparison. | |
| ================================================================================ | |
| LIMITATIONS | |
| ================================================================================ | |
| This script is a modeling engine, not a data pipeline. | |
| It does not: | |
| - scrape lineups | |
| - scrape odds | |
| - estimate player priors from raw events | |
| - fit mu/rho from historical data | |
| - infer substitutions automatically | |
| You should treat this as the final computation layer after your data has already | |
| been cleaned and mapped into model-ready parameters. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| from dataclasses import dataclass | |
| from math import exp, lgamma, log | |
| from pathlib import Path | |
| from typing import Dict, Iterable, List, Optional, Tuple | |
| import numpy as np | |
| @dataclass(frozen=True) | |
| class Player: | |
| """ | |
| A player contribution object. | |
| weight: | |
| Expected participation weight. | |
| Live active XI: | |
| Usually 1.0 for each player currently on the pitch. | |
| Pre-match: | |
| Usually expected_minutes / 90, with slot accounting. | |
| gamma_attack: | |
| Offensive player prior on log-goal scale. | |
| delta_defense: | |
| Defensive suppression prior on log-goal scale. | |
| """ | |
| name: str | |
| weight: float | |
| gamma_attack: float | |
| delta_defense: float | |
| @dataclass(frozen=True) | |
| class MatchState: | |
| """ | |
| State and global model parameters. | |
| """ | |
| home_team: str | |
| away_team: str | |
| current_home_goals: int | |
| current_away_goals: int | |
| minutes_remaining: float | |
| duration_factor: float | |
| fatigue_multiplier: float | |
| mu: float | |
| rho: float | |
| home_adv: float | |
| home_penalty_win_prob: float | |
| max_goals_remaining: int | |
| knockout: bool = True | |
| def poisson_pmf(lam: float, max_goals: int) -> np.ndarray: | |
| """ | |
| Vectorized Poisson PMF for k=0..max_goals. | |
| Uses log-probabilities for numerical stability: | |
| log P(X=k) = k log(lambda) - lambda - log(k!) | |
| and: | |
| log(k!) = lgamma(k+1) | |
| """ | |
| if lam < 0 or not np.isfinite(lam): | |
| raise ValueError(f"lambda must be finite and non-negative, got {lam}") | |
| goals = np.arange(max_goals + 1) | |
| if lam == 0: | |
| pmf = np.zeros(max_goals + 1, dtype=float) | |
| pmf[0] = 1.0 | |
| return pmf | |
| log_pmf = goals * log(lam) - lam - np.array([lgamma(int(g) + 1) for g in goals]) | |
| return np.exp(log_pmf) | |
| def dixon_coles_score_matrix( | |
| lambda_home: float, | |
| lambda_away: float, | |
| rho: float, | |
| max_goals: int, | |
| ) -> np.ndarray: | |
| """ | |
| Build Dixon-Coles corrected score matrix. | |
| Rows: | |
| home goals in the modeled remaining period. | |
| Columns: | |
| away goals in the modeled remaining period. | |
| Important: | |
| lambda_home/lambda_away here are for the remaining period, not full match. | |
| """ | |
| home_pmf = poisson_pmf(lambda_home, max_goals) | |
| away_pmf = poisson_pmf(lambda_away, max_goals) | |
| matrix = np.outer(home_pmf, away_pmf) | |
| tau = np.ones_like(matrix) | |
| if max_goals >= 1: | |
| tau[0, 0] = 1.0 - lambda_home * lambda_away * rho | |
| tau[0, 1] = 1.0 + lambda_home * rho | |
| tau[1, 0] = 1.0 + lambda_away * rho | |
| tau[1, 1] = 1.0 - rho | |
| # Prevent impossible log/normalization states if a bad rho is supplied. | |
| tau = np.maximum(tau, 1e-10) | |
| matrix *= tau | |
| total = matrix.sum() | |
| if not np.isfinite(total) or total <= 0: | |
| raise FloatingPointError("Invalid score matrix normalization.") | |
| return matrix / total | |
| def aggregate_players(players: Iterable[Player]) -> Tuple[float, float, float]: | |
| """ | |
| Compute: | |
| attack_sum = sum(weight * gamma_attack) | |
| defense_sum = sum(weight * delta_defense) | |
| weight_sum = sum(weight) | |
| These are the direct inputs to the log-goal model. | |
| """ | |
| players = list(players) | |
| attack_sum = sum(p.weight * p.gamma_attack for p in players) | |
| defense_sum = sum(p.weight * p.delta_defense for p in players) | |
| weight_sum = sum(p.weight for p in players) | |
| return attack_sum, defense_sum, weight_sum | |
| def validate_weight_sum( | |
| players: List[Player], | |
| team_name: str, | |
| target: float = 11.0, | |
| tolerance: float = 1e-6, | |
| mode: str = "raise", | |
| ) -> List[Player]: | |
| """ | |
| Check or normalize team weight sum. | |
| mode="raise": | |
| Error if total weight does not equal target. | |
| mode="normalize": | |
| Rescale all weights proportionally to make the sum equal target. | |
| Recommendation: | |
| Use "raise" for serious live work. Normalize only for quick testing. | |
| """ | |
| total = sum(p.weight for p in players) | |
| if abs(total - target) <= tolerance: | |
| return players | |
| if mode == "raise": | |
| raise ValueError( | |
| f"{team_name} weight sum is {total:.6f}, expected {target:.6f}. " | |
| "Fix player weights or use mode='normalize'." | |
| ) | |
| if mode == "normalize": | |
| if total <= 0: | |
| raise ValueError(f"{team_name} has non-positive total weight.") | |
| scale = target / total | |
| return [ | |
| Player( | |
| name=p.name, | |
| weight=p.weight * scale, | |
| gamma_attack=p.gamma_attack, | |
| delta_defense=p.delta_defense, | |
| ) | |
| for p in players | |
| ] | |
| raise ValueError("mode must be 'raise' or 'normalize'") | |
| def model_probabilities( | |
| state: MatchState, | |
| home_players: List[Player], | |
| away_players: List[Player], | |
| *, | |
| weight_mode: str = "raise", | |
| ) -> Dict[str, object]: | |
| """ | |
| Run the generic model. | |
| Returns: | |
| components | |
| probabilities | |
| fair_odds | |
| final_score_probs | |
| score_matrix | |
| """ | |
| home_players = validate_weight_sum( | |
| home_players, | |
| state.home_team, | |
| target=11.0, | |
| mode=weight_mode, | |
| ) | |
| away_players = validate_weight_sum( | |
| away_players, | |
| state.away_team, | |
| target=11.0, | |
| mode=weight_mode, | |
| ) | |
| home_attack, home_defense, home_weight = aggregate_players(home_players) | |
| away_attack, away_defense, away_weight = aggregate_players(away_players) | |
| log_lambda_home_90 = state.mu + state.home_adv + home_attack - away_defense | |
| log_lambda_away_90 = state.mu + away_attack - home_defense | |
| lambda_home_90 = exp(log_lambda_home_90) | |
| lambda_away_90 = exp(log_lambda_away_90) | |
| lambda_home_remaining = ( | |
| lambda_home_90 | |
| * state.duration_factor | |
| * state.fatigue_multiplier | |
| ) | |
| lambda_away_remaining = ( | |
| lambda_away_90 | |
| * state.duration_factor | |
| * state.fatigue_multiplier | |
| ) | |
| score_matrix = dixon_coles_score_matrix( | |
| lambda_home=lambda_home_remaining, | |
| lambda_away=lambda_away_remaining, | |
| rho=state.rho, | |
| max_goals=state.max_goals_remaining, | |
| ) | |
| p_home_wins_period = 0.0 | |
| p_away_wins_period = 0.0 | |
| p_tied_after_period = 0.0 | |
| final_score_probs: List[Tuple[str, float]] = [] | |
| for h_add in range(score_matrix.shape[0]): | |
| for a_add in range(score_matrix.shape[1]): | |
| p = float(score_matrix[h_add, a_add]) | |
| final_home = state.current_home_goals + h_add | |
| final_away = state.current_away_goals + a_add | |
| if final_home > final_away: | |
| p_home_wins_period += p | |
| elif final_home < final_away: | |
| p_away_wins_period += p | |
| else: | |
| p_tied_after_period += p | |
| label = ( | |
| f"{final_home}-{final_away}, tied" | |
| if final_home == final_away | |
| else f"{final_home}-{final_away}" | |
| ) | |
| final_score_probs.append((label, p)) | |
| if state.knockout: | |
| p_home_qualifies = ( | |
| p_home_wins_period | |
| + p_tied_after_period * state.home_penalty_win_prob | |
| ) | |
| p_away_qualifies = ( | |
| p_away_wins_period | |
| + p_tied_after_period * (1.0 - state.home_penalty_win_prob) | |
| ) | |
| else: | |
| p_home_qualifies = None | |
| p_away_qualifies = None | |
| final_score_probs.sort(key=lambda item: item[1], reverse=True) | |
| probs = { | |
| "home_ahead_after_period": p_home_wins_period, | |
| "tied_after_period": p_tied_after_period, | |
| "away_ahead_after_period": p_away_wins_period, | |
| "home_qualifies": p_home_qualifies, | |
| "away_qualifies": p_away_qualifies, | |
| } | |
| fair_odds = { | |
| key: (1.0 / value if value and value > 0 else None) | |
| for key, value in probs.items() | |
| } | |
| return { | |
| "components": { | |
| "home_attack": home_attack, | |
| "home_defense": home_defense, | |
| "home_weight_sum": home_weight, | |
| "away_attack": away_attack, | |
| "away_defense": away_defense, | |
| "away_weight_sum": away_weight, | |
| "log_lambda_home_90": log_lambda_home_90, | |
| "log_lambda_away_90": log_lambda_away_90, | |
| "lambda_home_90": lambda_home_90, | |
| "lambda_away_90": lambda_away_90, | |
| "lambda_home_remaining": lambda_home_remaining, | |
| "lambda_away_remaining": lambda_away_remaining, | |
| "total_lambda_remaining": lambda_home_remaining + lambda_away_remaining, | |
| }, | |
| "probabilities": probs, | |
| "fair_odds": fair_odds, | |
| "final_score_probs": final_score_probs, | |
| "score_matrix": score_matrix, | |
| } | |
| def devig_decimal_odds(odds: Dict[str, float]) -> Dict[str, float]: | |
| """ | |
| Convert decimal odds to no-vig probabilities. | |
| Example: | |
| odds = {"home": 2.0, "draw": 3.5, "away": 4.0} | |
| Raw implied: | |
| p_i_raw = 1 / odds_i | |
| No-vig: | |
| p_i = p_i_raw / sum(p_raw) | |
| """ | |
| raw = {k: 1.0 / v for k, v in odds.items() if v and v > 1.0} | |
| total = sum(raw.values()) | |
| if total <= 0: | |
| return {} | |
| return {k: v / total for k, v in raw.items()} | |
| def expected_value(probability: float, decimal_odds: float) -> float: | |
| """ | |
| Expected value per unit stake: | |
| EV = p * odds - 1 | |
| Positive EV means the offered price is above your fair price. | |
| """ | |
| return probability * decimal_odds - 1.0 | |
| def compare_market( | |
| result: Dict[str, object], | |
| market_odds: Optional[Dict[str, float]], | |
| ) -> Dict[str, Dict[str, Optional[float]]]: | |
| """ | |
| Compare model fair probabilities to market odds. | |
| Supported market_odds keys: | |
| home_90 | |
| draw_90 | |
| away_90 | |
| home_qualify | |
| away_qualify | |
| In a live extra-time state, home_90/draw_90/away_90 may not be meaningful. | |
| Use home_qualify/away_qualify or period-specific markets instead. | |
| """ | |
| if not market_odds: | |
| return {} | |
| probs = result["probabilities"] | |
| comparison: Dict[str, Dict[str, Optional[float]]] = {} | |
| mapping = { | |
| "home_qualify": "home_qualifies", | |
| "away_qualify": "away_qualifies", | |
| } | |
| for market_key, prob_key in mapping.items(): | |
| odds_value = market_odds.get(market_key) | |
| prob = probs.get(prob_key) | |
| if odds_value is None or prob is None: | |
| continue | |
| comparison[market_key] = { | |
| "model_probability": prob, | |
| "market_decimal_odds": odds_value, | |
| "model_fair_odds": 1.0 / prob if prob > 0 else None, | |
| "ev": expected_value(prob, odds_value), | |
| } | |
| return comparison | |
| def load_config(path: Path) -> Tuple[MatchState, List[Player], List[Player], Optional[Dict[str, float]]]: | |
| """ | |
| Load model config from JSON. | |
| """ | |
| data = json.loads(path.read_text(encoding="utf-8")) | |
| m = data["match"] | |
| state = MatchState( | |
| home_team=m["home_team"], | |
| away_team=m["away_team"], | |
| current_home_goals=int(m["current_home_goals"]), | |
| current_away_goals=int(m["current_away_goals"]), | |
| minutes_remaining=float(m["minutes_remaining"]), | |
| duration_factor=float(m["duration_factor"]), | |
| fatigue_multiplier=float(m["fatigue_multiplier"]), | |
| mu=float(m["mu"]), | |
| rho=float(m["rho"]), | |
| home_adv=float(m.get("home_adv", 0.0)), | |
| home_penalty_win_prob=float(m.get("home_penalty_win_prob", 0.5)), | |
| max_goals_remaining=int(m.get("max_goals_remaining", 10)), | |
| knockout=bool(m.get("knockout", True)), | |
| ) | |
| home_players = [ | |
| Player( | |
| name=p["name"], | |
| weight=float(p["weight"]), | |
| gamma_attack=float(p["gamma_attack"]), | |
| delta_defense=float(p["delta_defense"]), | |
| ) | |
| for p in data["home_players"] | |
| ] | |
| away_players = [ | |
| Player( | |
| name=p["name"], | |
| weight=float(p["weight"]), | |
| gamma_attack=float(p["gamma_attack"]), | |
| delta_defense=float(p["delta_defense"]), | |
| ) | |
| for p in data["away_players"] | |
| ] | |
| market_odds = data.get("market_odds") | |
| return state, home_players, away_players, market_odds | |
| def template_config() -> Dict[str, object]: | |
| """ | |
| Generic starter config. | |
| Replace names and coefficients with real values pulled from: | |
| - lineups/subs: official lineup source / live tracker | |
| - gamma/delta: player-prior pipeline | |
| - mu/rho: fitted historical model | |
| - market odds: sportsbook / exchange | |
| """ | |
| return { | |
| "match": { | |
| "home_team": "Home Team", | |
| "away_team": "Away Team", | |
| "current_home_goals": 0, | |
| "current_away_goals": 0, | |
| "minutes_remaining": 90, | |
| "duration_factor": 1.0, | |
| "fatigue_multiplier": 1.0, | |
| "mu": log(1.25), | |
| "rho": -0.05, | |
| "home_adv": 0.0, | |
| "home_penalty_win_prob": 0.50, | |
| "max_goals_remaining": 10, | |
| "knockout": True, | |
| }, | |
| "home_players": [ | |
| {"name": "Home GK", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.12}, | |
| {"name": "Home RB", "weight": 1.0, "gamma_attack": 0.02, "delta_defense": 0.06}, | |
| {"name": "Home CB1", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.09}, | |
| {"name": "Home CB2", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.09}, | |
| {"name": "Home LB", "weight": 1.0, "gamma_attack": 0.02, "delta_defense": 0.06}, | |
| {"name": "Home DM", "weight": 1.0, "gamma_attack": 0.02, "delta_defense": 0.08}, | |
| {"name": "Home CM", "weight": 1.0, "gamma_attack": 0.05, "delta_defense": 0.04}, | |
| {"name": "Home AM", "weight": 1.0, "gamma_attack": 0.08, "delta_defense": 0.01}, | |
| {"name": "Home RW", "weight": 1.0, "gamma_attack": 0.08, "delta_defense": 0.00}, | |
| {"name": "Home ST", "weight": 1.0, "gamma_attack": 0.11, "delta_defense": 0.00}, | |
| {"name": "Home LW", "weight": 1.0, "gamma_attack": 0.08, "delta_defense": 0.00}, | |
| ], | |
| "away_players": [ | |
| {"name": "Away GK", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.10}, | |
| {"name": "Away RB", "weight": 1.0, "gamma_attack": 0.02, "delta_defense": 0.05}, | |
| {"name": "Away CB1", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.08}, | |
| {"name": "Away CB2", "weight": 1.0, "gamma_attack": 0.00, "delta_defense": 0.08}, | |
| {"name": "Away LB", "weight": 1.0, "gamma_attack": 0.02, "delta_defense": 0.05}, | |
| {"name": "Away DM", "weight": 1.0, "gamma_attack": 0.02, "delta_defense": 0.07}, | |
| {"name": "Away CM", "weight": 1.0, "gamma_attack": 0.04, "delta_defense": 0.04}, | |
| {"name": "Away AM", "weight": 1.0, "gamma_attack": 0.07, "delta_defense": 0.01}, | |
| {"name": "Away RW", "weight": 1.0, "gamma_attack": 0.07, "delta_defense": 0.00}, | |
| {"name": "Away ST", "weight": 1.0, "gamma_attack": 0.10, "delta_defense": 0.00}, | |
| {"name": "Away LW", "weight": 1.0, "gamma_attack": 0.07, "delta_defense": 0.00}, | |
| ], | |
| "market_odds": { | |
| "home_qualify": 1.80, | |
| "away_qualify": 2.10, | |
| }, | |
| } | |
| def print_result( | |
| state: MatchState, | |
| result: Dict[str, object], | |
| market_comparison: Dict[str, Dict[str, Optional[float]]], | |
| top_scores: int = 10, | |
| ) -> None: | |
| """ | |
| Human-readable console output. | |
| """ | |
| c = result["components"] | |
| p = result["probabilities"] | |
| o = result["fair_odds"] | |
| def pct(x: Optional[float]) -> str: | |
| if x is None: | |
| return "n/a" | |
| return f"{100.0 * x:.2f}%" | |
| def odd(x: Optional[float]) -> str: | |
| if x is None: | |
| return "n/a" | |
| return f"{x:.2f}" | |
| print(f"{state.home_team} vs {state.away_team}") | |
| print("=" * 60) | |
| print(f"Current score: {state.current_home_goals}-{state.current_away_goals}") | |
| print(f"Minutes remaining modeled: {state.minutes_remaining:.1f}") | |
| print(f"Duration factor: {state.duration_factor:.3f}") | |
| print(f"Fatigue/tempo multiplier: {state.fatigue_multiplier:.3f}") | |
| print(f"mu: {state.mu:.4f} | exp(mu): {exp(state.mu):.3f}") | |
| print(f"rho: {state.rho:.4f}") | |
| print(f"Home adv: {state.home_adv:.4f}") | |
| print() | |
| print("Aggregates") | |
| print("-" * 60) | |
| print(f"{state.home_team} attack sum: {c['home_attack']:.3f}") | |
| print(f"{state.home_team} defense sum: {c['home_defense']:.3f}") | |
| print(f"{state.home_team} weight sum: {c['home_weight_sum']:.2f}") | |
| print(f"{state.away_team} attack sum: {c['away_attack']:.3f}") | |
| print(f"{state.away_team} defense sum: {c['away_defense']:.3f}") | |
| print(f"{state.away_team} weight sum: {c['away_weight_sum']:.2f}") | |
| print() | |
| print("Expected goals") | |
| print("-" * 60) | |
| print(f"{state.home_team} lambda 90-equivalent: {c['lambda_home_90']:.3f}") | |
| print(f"{state.away_team} lambda 90-equivalent: {c['lambda_away_90']:.3f}") | |
| print(f"{state.home_team} lambda remaining: {c['lambda_home_remaining']:.3f}") | |
| print(f"{state.away_team} lambda remaining: {c['lambda_away_remaining']:.3f}") | |
| print(f"Total lambda remaining: {c['total_lambda_remaining']:.3f}") | |
| print() | |
| print("Outcome probabilities for modeled period") | |
| print("-" * 60) | |
| print( | |
| f"{state.home_team} ahead after period: " | |
| f"{pct(p['home_ahead_after_period'])} | fair odd {odd(o['home_ahead_after_period'])}" | |
| ) | |
| print( | |
| f"Tied after period: " | |
| f"{pct(p['tied_after_period'])} | fair odd {odd(o['tied_after_period'])}" | |
| ) | |
| print( | |
| f"{state.away_team} ahead after period: " | |
| f"{pct(p['away_ahead_after_period'])} | fair odd {odd(o['away_ahead_after_period'])}" | |
| ) | |
| if state.knockout: | |
| print() | |
| print("Qualification probabilities") | |
| print("-" * 60) | |
| print( | |
| f"{state.home_team} qualifies: " | |
| f"{pct(p['home_qualifies'])} | fair odd {odd(o['home_qualifies'])}" | |
| ) | |
| print( | |
| f"{state.away_team} qualifies: " | |
| f"{pct(p['away_qualifies'])} | fair odd {odd(o['away_qualifies'])}" | |
| ) | |
| if market_comparison: | |
| print() | |
| print("Market comparison") | |
| print("-" * 60) | |
| for key, row in market_comparison.items(): | |
| print( | |
| f"{key}: model {pct(row['model_probability'])}, " | |
| f"market odd {row['market_decimal_odds']:.2f}, " | |
| f"fair odd {odd(row['model_fair_odds'])}, " | |
| f"EV {100.0 * row['ev']:.2f}%" | |
| ) | |
| print() | |
| print("Most likely final scores") | |
| print("-" * 60) | |
| for label, prob in result["final_score_probs"][:top_scores]: | |
| print(f"{label}: {pct(prob)}") | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description="Generic player-prior Dixon-Coles football model." | |
| ) | |
| parser.add_argument( | |
| "--config", | |
| type=Path, | |
| help="Path to JSON config file.", | |
| ) | |
| parser.add_argument( | |
| "--write-template", | |
| type=Path, | |
| help="Write a starter JSON template and exit.", | |
| ) | |
| parser.add_argument( | |
| "--weight-mode", | |
| choices=["raise", "normalize"], | |
| default="raise", | |
| help="How to handle team weight sums not equal to 11.0.", | |
| ) | |
| args = parser.parse_args() | |
| if args.write_template: | |
| args.write_template.write_text( | |
| json.dumps(template_config(), indent=2), | |
| encoding="utf-8", | |
| ) | |
| print(f"Wrote template config to {args.write_template}") | |
| return | |
| if args.config: | |
| state, home_players, away_players, market_odds = load_config(args.config) | |
| else: | |
| # Built-in example only. Replace with --config for real use. | |
| cfg = template_config() | |
| tmp_path = Path("_embedded_template_config.json") | |
| tmp_path.write_text(json.dumps(cfg), encoding="utf-8") | |
| state, home_players, away_players, market_odds = load_config(tmp_path) | |
| tmp_path.unlink(missing_ok=True) | |
| result = model_probabilities( | |
| state, | |
| home_players, | |
| away_players, | |
| weight_mode=args.weight_mode, | |
| ) | |
| market_comparison = compare_market(result, market_odds) | |
| print_result(state, result, market_comparison) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment