apuestin3 / src /streamlit_app.py
spjasper's picture
Update src/streamlit_app.py
99a5427 verified
#!/usr/bin/env python3
"""
โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
DIXON-COLES POISSON MODEL โ€” Streamlit App
Modelo bivariado con correcciรณn de baja puntuaciรณn para predicciรณn de fรบtbol
Fuente de datos: football-data.co.uk
โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
Instalaciรณn:
pip install streamlit pandas numpy scipy requests reportlab plotly
Ejecutar:
streamlit run dixon_coles_app.py
"""
import math
import io
import base64
from datetime import datetime
from io import StringIO
import numpy as np
import pandas as pd
import streamlit as st
import plotly.graph_objects as go
import plotly.express as px
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm, cm
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak, HRFlowable
)
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# CONFIGURACIร“N
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
st.set_page_config(
page_title="Dixon-Coles Engine",
page_icon="โšฝ",
layout="wide",
initial_sidebar_state="expanded",
)
LEAGUES = {
"E0": ("Premier League", "England", "๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ"),
"E1": ("Championship", "England", "๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ"),
"E2": ("League One", "England", "๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ"),
"E3": ("League Two", "England", "๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ"),
"EC": ("Conference", "England", "๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ"),
"SC0": ("Premiership", "Scotland", "๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ"),
"SC1": ("Championship", "Scotland", "๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ"),
"SC2": ("League One", "Scotland", "๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ"),
"SC3": ("League Two", "Scotland", "๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ"),
"D1": ("Bundesliga", "Germany", "๐Ÿ‡ฉ๐Ÿ‡ช"),
"D2": ("2. Bundesliga", "Germany", "๐Ÿ‡ฉ๐Ÿ‡ช"),
"SP1": ("La Liga", "Spain", "๐Ÿ‡ช๐Ÿ‡ธ"),
"SP2": ("Segunda Divisiรณn", "Spain", "๐Ÿ‡ช๐Ÿ‡ธ"),
"I1": ("Serie A", "Italy", "๐Ÿ‡ฎ๐Ÿ‡น"),
"I2": ("Serie B", "Italy", "๐Ÿ‡ฎ๐Ÿ‡น"),
"F1": ("Ligue 1", "France", "๐Ÿ‡ซ๐Ÿ‡ท"),
"F2": ("Ligue 2", "France", "๐Ÿ‡ซ๐Ÿ‡ท"),
"N1": ("Eredivisie", "Netherlands", "๐Ÿ‡ณ๐Ÿ‡ฑ"),
"B1": ("Jupiler Pro League", "Belgium", "๐Ÿ‡ง๐Ÿ‡ช"),
"P1": ("Primeira Liga", "Portugal", "๐Ÿ‡ต๐Ÿ‡น"),
"T1": ("Sรผper Lig", "Turkey", "๐Ÿ‡น๐Ÿ‡ท"),
"G1": ("Super League", "Greece", "๐Ÿ‡ฌ๐Ÿ‡ท"),
}
SEASONS = {"2526": "2025/26", "2425": "2024/25", "2324": "2023/24"}
BASE_URL = "https://www.football-data.co.uk"
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# CSS PERSONALIZADO
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
.stApp { background-color: #f7f8fc; }
.main-header {
font-family: 'DM Sans', sans-serif;
font-size: 2.2rem;
font-weight: 800;
background: linear-gradient(130deg, #1a1a2e 0%, #4f46e5 60%, #059669 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0;
}
.sub-header {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: #6b7280;
margin-top: -8px;
}
.metric-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.metric-value {
font-family: 'JetBrains Mono', monospace;
font-size: 1.8rem;
font-weight: 800;
}
.metric-label {
font-family: 'JetBrains Mono', monospace;
font-size: 0.65rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.15em;
}
.match-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 20px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.match-teams {
font-family: 'DM Sans', sans-serif;
font-size: 1.1rem;
font-weight: 700;
color: #1f2937;
}
.prob-bar-bg {
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
margin: 4px 0 8px 0;
}
.prob-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.6s ease;
}
div[data-testid="stSidebar"] {
background-color: #f0f1f6;
}
.stTabs [data-baseweb="tab-list"] {
gap: 4px;
}
.stTabs [data-baseweb="tab"] {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
}
</style>
""", unsafe_allow_html=True)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# DESCARGA DE DATOS (con manejo robusto de CSV)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def _robust_read_csv(content_bytes: bytes) -> pd.DataFrame:
"""
Lee CSV manejando:
- BOM UTF-8 (\\ufeff)
- Mรบltiples secciones con headers repetidos dentro del mismo archivo
- Encoding Windows-1252 vs UTF-8
"""
# Intentar decodificar
for enc in ["utf-8-sig", "utf-8", "latin-1", "cp1252"]:
try:
text = content_bytes.decode(enc)
break
except (UnicodeDecodeError, UnicodeError):
continue
else:
text = content_bytes.decode("utf-8", errors="replace")
# El fixtures.csv de football-data.co.uk a veces tiene headers repetidos
# (una secciรณn por liga). Filtrar las filas que son headers duplicados.
lines = text.strip().split("\n")
if not lines:
return pd.DataFrame()
header = lines[0].strip().replace("\ufeff", "")
clean_lines = [header]
for line in lines[1:]:
stripped = line.strip()
if not stripped:
continue
# Saltar filas que sean headers duplicados
if stripped.startswith("Div,Date,") or stripped.startswith("\ufeffDiv,Date,"):
continue
clean_lines.append(stripped)
clean_text = "\n".join(clean_lines)
df = pd.read_csv(StringIO(clean_text))
df.columns = [c.strip().replace("\ufeff", "") for c in df.columns]
return df
@st.cache_data(ttl=600, show_spinner=False)
def fetch_results(league_code: str, season: str) -> pd.DataFrame:
"""Descarga resultados histรณricos"""
import requests
url = f"{BASE_URL}/mmz4281/{season}/{league_code}.csv"
resp = requests.get(url, timeout=30)
resp.raise_for_status()
df = _robust_read_csv(resp.content)
required = ["HomeTeam", "AwayTeam", "FTHG", "FTAG", "Date"]
missing = [c for c in required if c not in df.columns]
if missing:
raise ValueError(f"Columnas faltantes: {missing}. Disponibles: {list(df.columns[:10])}")
df = df.dropna(subset=["FTHG", "FTAG"])
df["FTHG"] = df["FTHG"].astype(int)
df["FTAG"] = df["FTAG"].astype(int)
return df
@st.cache_data(ttl=600, show_spinner=False)
def fetch_fixtures(league_code: str) -> pd.DataFrame:
"""Descarga prรณximos partidos"""
import requests
url = f"{BASE_URL}/fixtures.csv"
resp = requests.get(url, timeout=30)
resp.raise_for_status()
df = _robust_read_csv(resp.content)
if "Div" not in df.columns:
raise ValueError(f"Columna 'Div' no encontrada. Columnas: {list(df.columns[:10])}")
df = df[df["Div"] == league_code].copy()
return df
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# MODELO DIXON-COLES
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def parse_date(date_str: str) -> datetime:
try:
parts = str(date_str).strip().split("/")
if len(parts) == 3:
d, m, y = int(parts[0]), int(parts[1]), int(parts[2])
if y < 100:
y += 2000
return datetime(y, m, d)
except Exception:
pass
return datetime.now()
def poisson_pmf(k: int, lam: float) -> float:
if lam <= 0:
return 1.0 if k == 0 else 0.0
return (lam ** k) * math.exp(-lam) / math.factorial(k)
def dixon_coles_tau(x, y, lam_h, lam_a, rho):
if x == 0 and y == 0:
return 1.0 - lam_h * lam_a * rho
elif x == 0 and y == 1:
return 1.0 + lam_h * rho
elif x == 1 and y == 0:
return 1.0 + lam_a * rho
elif x == 1 and y == 1:
return 1.0 - rho
return 1.0
def time_decay_weight(days_ago, xi=0.003):
return math.exp(-xi * max(days_ago, 0))
class DixonColesModel:
def __init__(self, xi=0.003, max_iter=80):
self.xi = xi
self.max_iter = max_iter
self.attack = {}
self.defense = {}
self.home_adv = 0.25
self.rho = -0.05
self.teams = []
self.n_matches = 0
def fit(self, df, progress_callback=None):
matches = []
now = datetime.now()
for _, row in df.iterrows():
h, a = row["HomeTeam"], row["AwayTeam"]
hg, ag = int(row["FTHG"]), int(row["FTAG"])
d = parse_date(row["Date"])
days_ago = (now - d).days
w = time_decay_weight(days_ago, self.xi)
matches.append({"h": h, "a": a, "hg": hg, "ag": ag, "w": w})
self.n_matches = len(matches)
self.teams = sorted(set(m["h"] for m in matches) | set(m["a"] for m in matches))
n_teams = len(self.teams)
if n_teams < 4 or len(matches) < 10:
raise ValueError(f"Datos insuficientes: {len(matches)} partidos, {n_teams} equipos")
attack = {t: 1.0 for t in self.teams}
defense = {t: 1.0 for t in self.teams}
home_adv = 0.25
rho = -0.05
best_ll = -float("inf")
for iteration in range(self.max_iter):
if progress_callback:
progress_callback(iteration / self.max_iter)
new_attack, new_defense = {}, {}
for team in self.teams:
att_num = att_den = def_num = def_den = 0.0
for m in matches:
w = m["w"]
if m["h"] == team:
att_num += m["hg"] * w
att_den += defense[m["a"]] * math.exp(home_adv) * w
def_num += m["ag"] * w
def_den += attack[m["a"]] * w
if m["a"] == team:
att_num += m["ag"] * w
att_den += defense[m["h"]] * w
def_num += m["hg"] * w
def_den += attack[m["h"]] * math.exp(home_adv) * w
new_attack[team] = att_num / max(att_den, 1e-8)
new_defense[team] = def_num / max(def_den, 1e-8)
geo_att = math.exp(sum(math.log(max(new_attack[t], 1e-8)) for t in self.teams) / n_teams)
geo_def = math.exp(sum(math.log(max(new_defense[t], 1e-8)) for t in self.teams) / n_teams)
for t in self.teams:
new_attack[t] /= geo_att
new_defense[t] /= geo_def
ha_num = sum(m["hg"] * m["w"] for m in matches)
ha_den = sum(new_attack[m["h"]] * new_defense[m["a"]] * m["w"] for m in matches)
new_ha = math.log(max(ha_num / max(ha_den, 1e-8), 0.5))
best_rho = rho
best_ll_iter = -float("inf")
for r in np.arange(-0.15, 0.06, 0.01):
ll = 0.0
for m in matches:
lh = new_attack[m["h"]] * new_defense[m["a"]] * math.exp(new_ha)
la = new_attack[m["a"]] * new_defense[m["h"]]
tau = dixon_coles_tau(m["hg"], m["ag"], lh, la, r)
p1 = poisson_pmf(m["hg"], lh)
p2 = poisson_pmf(m["ag"], la)
if tau > 0 and p1 > 0 and p2 > 0:
ll += m["w"] * (math.log(p1) + math.log(p2) + math.log(tau))
if ll > best_ll_iter:
best_ll_iter = ll
best_rho = r
attack, defense, home_adv, rho = new_attack, new_defense, new_ha, best_rho
best_ll = best_ll_iter
self.attack = attack
self.defense = defense
self.home_adv = home_adv
self.rho = rho
self.log_likelihood = best_ll
if progress_callback:
progress_callback(1.0)
return self
def predict(self, home_team, away_team, max_goals=7):
if home_team not in self.attack or away_team not in self.attack:
return None
lam_h = self.attack[home_team] * self.defense[away_team] * math.exp(self.home_adv)
lam_a = self.attack[away_team] * self.defense[home_team]
matrix = np.zeros((max_goals + 1, max_goals + 1))
for i in range(max_goals + 1):
for j in range(max_goals + 1):
tau = dixon_coles_tau(i, j, lam_h, lam_a, self.rho)
matrix[i][j] = poisson_pmf(i, lam_h) * poisson_pmf(j, lam_a) * tau
total = matrix.sum()
matrix /= total
pH = sum(matrix[i][j] for i in range(max_goals+1) for j in range(max_goals+1) if i > j)
pD = sum(matrix[i][i] for i in range(max_goals+1))
pA = sum(matrix[i][j] for i in range(max_goals+1) for j in range(max_goals+1) if i < j)
o25 = sum(matrix[i][j] for i in range(max_goals+1) for j in range(max_goals+1) if i+j > 2)
btts = sum(matrix[i][j] for i in range(1, max_goals+1) for j in range(1, max_goals+1))
scores = []
for i in range(min(6, max_goals+1)):
for j in range(min(6, max_goals+1)):
scores.append((i, j, matrix[i][j]))
scores.sort(key=lambda x: x[2], reverse=True)
return {
"home": home_team, "away": away_team,
"lambda_h": lam_h, "lambda_a": lam_a,
"p_home": pH, "p_draw": pD, "p_away": pA,
"over_25": o25, "under_25": 1 - o25,
"btts_yes": btts, "btts_no": 1 - btts,
"odds_home": 1/max(pH,.001), "odds_draw": 1/max(pD,.001), "odds_away": 1/max(pA,.001),
"odds_over25": 1/max(o25,.001), "odds_under25": 1/max(1-o25,.001),
"top_scores": scores[:8], "matrix": matrix,
"atk_home": self.attack[home_team], "def_home": self.defense[home_team],
"atk_away": self.attack[away_team], "def_away": self.defense[away_team],
}
def predict_fixtures(self, fixtures_df):
preds = []
for _, row in fixtures_df.iterrows():
pred = self.predict(row["HomeTeam"], row["AwayTeam"])
if pred:
pred["date"] = row.get("Date", "")
pred["time"] = row.get("Time", "")
preds.append(pred)
return preds
def get_rankings(self):
rows = []
for t in self.teams:
atk, defe = self.attack[t], self.defense[t]
rows.append({
"Equipo": t, "ATK": atk, "DEF": defe,
"Power": atk / max(defe, 0.01),
"xG/90 (H)": atk * math.exp(self.home_adv),
"xGA/90": defe,
})
df = pd.DataFrame(rows).sort_values("Power", ascending=False).reset_index(drop=True)
df.index += 1
df.index.name = "#"
return df
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# GENERADOR DE PDF
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def generate_pdf(model, predictions, rankings_df, league_name, season_label):
"""Genera un reporte PDF profesional con los resultados del modelo"""
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf, pagesize=A4,
topMargin=20*mm, bottomMargin=15*mm,
leftMargin=15*mm, rightMargin=15*mm
)
styles = getSampleStyleSheet()
styles.add(ParagraphStyle(
"CustomTitle", parent=styles["Title"],
fontSize=22, spaceAfter=4, textColor=colors.HexColor("#1a1a2e"),
fontName="Helvetica-Bold"
))
styles.add(ParagraphStyle(
"CustomSubtitle", parent=styles["Normal"],
fontSize=10, textColor=colors.HexColor("#666680"),
spaceAfter=14, fontName="Helvetica"
))
styles.add(ParagraphStyle(
"SectionHead", parent=styles["Heading2"],
fontSize=14, textColor=colors.HexColor("#1a1a2e"),
spaceBefore=16, spaceAfter=8, fontName="Helvetica-Bold"
))
styles.add(ParagraphStyle(
"CellText", parent=styles["Normal"],
fontSize=8, fontName="Helvetica", leading=10
))
styles.add(ParagraphStyle(
"CellBold", parent=styles["Normal"],
fontSize=8, fontName="Helvetica-Bold", leading=10
))
styles.add(ParagraphStyle(
"SmallText", parent=styles["Normal"],
fontSize=7, textColor=colors.HexColor("#888888"), leading=9
))
story = []
# โ”€โ”€ PORTADA โ”€โ”€
story.append(Spacer(1, 30*mm))
story.append(Paragraph("Dixon-Coles Poisson Model", styles["CustomTitle"]))
story.append(Paragraph(
f"{league_name} | Temporada {season_label} | "
f"Generado: {datetime.now().strftime('%d/%m/%Y %H:%M')}",
styles["CustomSubtitle"]
))
story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#e0e0e0")))
story.append(Spacer(1, 6*mm))
# Parรกmetros del modelo
params_data = [
["Parรกmetro", "Valor", "Descripciรณn"],
["Partidos analizados", str(model.n_matches), "Total de partidos histรณricos usados"],
["Equipos", str(len(model.teams)), "Equipos en la liga"],
["rho (p)", f"{model.rho:.4f}", "Correcciรณn Dixon-Coles para marcadores bajos"],
["Home Advantage", f"{math.exp(model.home_adv):.3f}x", "Factor multiplicativo de ventaja local"],
["xi (decay)", f"{model.xi}", "Parรกmetro de decaimiento temporal"],
["Iteraciones", str(model.max_iter), "Iteraciones MLE para convergencia"],
["Log-Likelihood", f"{model.log_likelihood:.1f}", "Log-verosimilitud del modelo calibrado"],
]
params_table = Table(params_data, colWidths=[40*mm, 30*mm, 100*mm])
params_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1a1a2e")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 8),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f8f8fc")]),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e0e8")),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 6),
]))
story.append(params_table)
# โ”€โ”€ POWER RANKINGS โ”€โ”€
story.append(Spacer(1, 8*mm))
story.append(Paragraph("Power Rankings", styles["SectionHead"]))
rank_header = ["#", "Equipo", "ATK (a)", "DEF (b)", "Power", "xG/90 (H)", "xGA/90"]
rank_data = [rank_header]
for i, row in rankings_df.iterrows():
rank_data.append([
str(i),
row["Equipo"],
f"{row['ATK']:.3f}",
f"{row['DEF']:.3f}",
f"{row['Power']:.3f}",
f"{row['xG/90 (H)']:.3f}",
f"{row['xGA/90']:.3f}",
])
col_w = [10*mm, 38*mm, 22*mm, 22*mm, 22*mm, 25*mm, 22*mm]
rank_table = Table(rank_data, colWidths=col_w, repeatRows=1)
rank_style = [
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1a1a2e")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 7),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("ALIGN", (1, 0), (1, -1), "LEFT"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f8f8fc")]),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e0e8")),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 4),
]
# Top 3 verde, 4-7 amarillo
for row_i in range(1, min(4, len(rank_data))):
rank_style.append(("TEXTCOLOR", (0, row_i), (0, row_i), colors.HexColor("#16a34a")))
rank_style.append(("FONTNAME", (0, row_i), (0, row_i), "Helvetica-Bold"))
for row_i in range(4, min(8, len(rank_data))):
rank_style.append(("TEXTCOLOR", (0, row_i), (0, row_i), colors.HexColor("#ca8a04")))
rank_table.setStyle(TableStyle(rank_style))
story.append(rank_table)
# โ”€โ”€ PREDICCIONES โ”€โ”€
if predictions:
story.append(PageBreak())
story.append(Paragraph("Predicciones - Proximos Partidos", styles["SectionHead"]))
pred_header = [
"Fecha", "Local", "Visitante", "xG H", "xG A",
"P(1)", "P(X)", "P(2)", "O2.5", "BTTS", "Score"
]
pred_data = [pred_header]
for p in predictions:
winner = "1" if p["p_home"] > max(p["p_draw"], p["p_away"]) else \
"2" if p["p_away"] > max(p["p_home"], p["p_draw"]) else "X"
pred_data.append([
str(p.get("date", "")),
p["home"], p["away"],
f"{p['lambda_h']:.2f}", f"{p['lambda_a']:.2f}",
f"{p['p_home']*100:.0f}%", f"{p['p_draw']*100:.0f}%", f"{p['p_away']*100:.0f}%",
f"{p['over_25']*100:.0f}%", f"{p['btts_yes']*100:.0f}%",
f"{p['top_scores'][0][0]}-{p['top_scores'][0][1]}",
])
pred_col_w = [18*mm, 28*mm, 28*mm, 14*mm, 14*mm, 14*mm, 14*mm, 14*mm, 14*mm, 14*mm, 14*mm]
pred_table = Table(pred_data, colWidths=pred_col_w, repeatRows=1)
pred_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1a1a2e")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 7),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("ALIGN", (3, 0), (-1, -1), "CENTER"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f8f8fc")]),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e0e8")),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 3),
]))
story.append(pred_table)
# Detalle por partido
story.append(Spacer(1, 6*mm))
story.append(Paragraph("Detalle por Partido", styles["SectionHead"]))
for idx, p in enumerate(predictions):
if idx > 0 and idx % 3 == 0:
story.append(PageBreak())
story.append(Spacer(1, 3*mm))
story.append(Paragraph(
f"<b>{p['home']}</b> vs <b>{p['away']}</b> "
f"<font color='#888888'>| {p.get('date','')} {p.get('time','')}</font>",
styles["Normal"]
))
story.append(Spacer(1, 2*mm))
detail_data = [
["Mercado", "Prob.", "Cuota", "", "Mercado", "Prob.", "Cuota"],
["1 (Local)", f"{p['p_home']*100:.1f}%", f"{p['odds_home']:.2f}", "",
"Over 2.5", f"{p['over_25']*100:.1f}%", f"{p['odds_over25']:.2f}"],
["X (Empate)", f"{p['p_draw']*100:.1f}%", f"{p['odds_draw']:.2f}", "",
"Under 2.5", f"{p['under_25']*100:.1f}%", f"{p['odds_under25']:.2f}"],
["2 (Visit.)", f"{p['p_away']*100:.1f}%", f"{p['odds_away']:.2f}", "",
"BTTS Si", f"{p['btts_yes']*100:.1f}%", ""],
]
# Scores
scores_str = " | ".join(f"{s[0]}-{s[1]} ({s[2]*100:.1f}%)" for s in p["top_scores"][:4])
detail_data.append(["Scores", scores_str, "", "", "", "", ""])
# xG row
detail_data.append([
f"xG {p['home']}", f"{p['lambda_h']:.3f}", "",
"", f"xG {p['away']}", f"{p['lambda_a']:.3f}", ""
])
detail_data.append([
f"ATK/DEF", f"{p['atk_home']:.3f}/{p['def_home']:.3f}", "",
"", "ATK/DEF", f"{p['atk_away']:.3f}/{p['def_away']:.3f}", ""
])
det_col_w = [22*mm, 28*mm, 18*mm, 4*mm, 22*mm, 28*mm, 18*mm]
det_table = Table(detail_data, colWidths=det_col_w)
det_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e8e8f0")),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 7),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#e0e0e8")),
("TOPPADDING", (0, 0), (-1, -1), 2),
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("SPAN", (1, 4), (6, 4)), # scores row span
]))
story.append(det_table)
# โ”€โ”€ METODOLOGรA โ”€โ”€
story.append(PageBreak())
story.append(Paragraph("Metodologia Dixon-Coles", styles["SectionHead"]))
method_text = f"""
<b>Base:</b> Distribucion Poisson Bivariada โ€” P(X=k) = (lambda^k x e^(-lambda)) / k!<br/><br/>
<b>Parametros por equipo</b> estimados por MLE iterativo ({model.max_iter} iteraciones):<br/>
- alpha (Ataque): capacidad ofensiva relativa. alpha > 1 = mejor que el promedio.<br/>
- beta (Defensa): vulnerabilidad defensiva. beta &lt; 1 = mejor defensa.<br/>
- gamma (Home Advantage): {math.exp(model.home_adv):.3f}x<br/>
- rho (Dixon-Coles): {model.rho:.4f}<br/><br/>
<b>Goles esperados:</b><br/>
lambda_local = alpha_local x beta_visitante x e^gamma<br/>
lambda_visitante = alpha_visitante x beta_local<br/><br/>
<b>Correccion Dixon-Coles (tau):</b> Ajusta P(0-0), P(1-0), P(0-1), P(1-1) para capturar
la dependencia real entre goles. Con rho &lt; 0 los empates son mas probables.<br/><br/>
<b>Decaimiento temporal:</b> w(t) = e^(-xi x t), xi={model.xi}.
Partidos recientes pesan mas.<br/><br/>
<b>Fuente de datos:</b> football-data.co.uk
"""
story.append(Paragraph(method_text, styles["Normal"]))
# Footer
story.append(Spacer(1, 10*mm))
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#cccccc")))
story.append(Paragraph(
"Dixon-Coles (1997) | Solo fines analiticos | Generado con Dixon-Coles Engine",
styles["SmallText"]
))
doc.build(story)
buf.seek(0)
return buf
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# INTERFAZ STREAMLIT
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def main():
# โ”€โ”€ SIDEBAR โ”€โ”€
with st.sidebar:
st.markdown("### โšฝ Dixon-Coles Engine")
st.markdown("---")
# Liga
league_options = {code: f"{info[2]} {info[1]} โ€” {info[0]}" for code, info in LEAGUES.items()}
league = st.selectbox("Liga", options=list(LEAGUES.keys()),
format_func=lambda x: league_options[x], index=0)
# Temporada
season = st.selectbox("Temporada", options=list(SEASONS.keys()),
format_func=lambda x: SEASONS[x])
# Parรกmetros avanzados
with st.expander("Parรกmetros avanzados", expanded=False):
xi = st.slider("ฮพ (Time Decay)", 0.0, 0.02, 0.003, 0.001,
help="Controla cuรกnto peso tienen los partidos recientes vs antiguos. "
"Mayor = mรกs peso a partidos recientes.")
max_iter = st.slider("Iteraciones MLE", 20, 150, 80, 10,
help="Nรบmero de iteraciones para la estimaciรณn de parรกmetros.")
st.markdown("---")
fetch_btn = st.button("โšก Obtener Datos y Calcular", type="primary", use_container_width=True)
# Upload local
st.markdown("---")
st.markdown("##### ๐Ÿ“‚ O carga archivos locales")
uploaded_results = st.file_uploader("CSV Resultados", type="csv", key="res")
uploaded_fixtures = st.file_uploader("CSV Fixtures", type="csv", key="fix")
local_btn = st.button("๐Ÿ“Š Calcular con archivos locales", use_container_width=True)
# โ”€โ”€ HEADER โ”€โ”€
st.markdown('<h1 class="main-header">Dixon-Coles Poisson Model</h1>', unsafe_allow_html=True)
league_info = LEAGUES[league]
st.markdown(
f'<p class="sub-header">{league_info[2]} {league_info[0]} ({league_info[1]}) ยท '
f'Temporada {SEASONS[season]} ยท football-data.co.uk</p>',
unsafe_allow_html=True
)
# โ”€โ”€ Lร“GICA PRINCIPAL โ”€โ”€
model = None
predictions = []
rankings_df = None
if fetch_btn:
try:
with st.spinner("๐Ÿ“ฅ Descargando resultados..."):
results_df = fetch_results(league, season)
st.success(f"โœ… {len(results_df)} partidos descargados")
with st.spinner("๐Ÿ“ฅ Descargando fixtures..."):
fixtures_df = fetch_fixtures(league)
st.success(f"โœ… {len(fixtures_df)} fixtures encontrados")
progress = st.progress(0, text="โš™๏ธ Calibrando modelo Dixon-Coles...")
model = DixonColesModel(xi=xi, max_iter=max_iter)
model.fit(results_df, progress_callback=lambda p: progress.progress(p, text=f"โš™๏ธ Iteraciรณn {int(p*max_iter)}/{max_iter}"))
progress.empty()
predictions = model.predict_fixtures(fixtures_df)
rankings_df = model.get_rankings()
st.session_state["model"] = model
st.session_state["predictions"] = predictions
st.session_state["rankings_df"] = rankings_df
st.session_state["league_name"] = f"{league_info[2]} {league_info[0]}"
st.session_state["season_label"] = SEASONS[season]
except Exception as e:
st.error(f"โŒ Error: {e}")
return
elif local_btn and uploaded_results:
try:
results_df = pd.read_csv(uploaded_results, encoding="utf-8-sig")
results_df.columns = [c.strip().replace("\ufeff", "") for c in results_df.columns]
results_df = results_df.dropna(subset=["FTHG", "FTAG"])
results_df["FTHG"] = results_df["FTHG"].astype(int)
results_df["FTAG"] = results_df["FTAG"].astype(int)
st.success(f"โœ… {len(results_df)} partidos cargados")
fixtures_df = pd.DataFrame()
if uploaded_fixtures:
fixtures_df = pd.read_csv(uploaded_fixtures, encoding="utf-8-sig")
fixtures_df.columns = [c.strip().replace("\ufeff", "") for c in fixtures_df.columns]
if "Div" in fixtures_df.columns:
fixtures_df = fixtures_df[fixtures_df["Div"] == league]
st.success(f"โœ… {len(fixtures_df)} fixtures cargados")
progress = st.progress(0, text="โš™๏ธ Calibrando modelo...")
model = DixonColesModel(xi=xi, max_iter=max_iter)
model.fit(results_df, progress_callback=lambda p: progress.progress(p))
progress.empty()
predictions = model.predict_fixtures(fixtures_df) if len(fixtures_df) > 0 else []
rankings_df = model.get_rankings()
st.session_state["model"] = model
st.session_state["predictions"] = predictions
st.session_state["rankings_df"] = rankings_df
st.session_state["league_name"] = f"{league_info[2]} {league_info[0]}"
st.session_state["season_label"] = SEASONS[season]
except Exception as e:
st.error(f"โŒ Error: {e}")
return
# Recuperar del session state
if "model" in st.session_state:
model = st.session_state["model"]
predictions = st.session_state["predictions"]
rankings_df = st.session_state["rankings_df"]
if model is None:
st.info("๐Ÿ‘ˆ Selecciona una liga y pulsa **โšก Obtener Datos y Calcular** para empezar.")
st.markdown("---")
# Metodologรญa estรกtica
with st.expander("๐Ÿ“– ยฟCรณmo funciona el modelo Dixon-Coles?", expanded=True):
st.markdown("""
**El modelo Dixon-Coles (1997)** es una extensiรณn del modelo Poisson bivariado
que corrige la subestimaciรณn de empates y marcadores bajos.
**Parรกmetros por equipo:**
- **ฮฑ (Ataque):** Capacidad ofensiva relativa. ฮฑ > 1 = mejor que el promedio.
- **ฮฒ (Defensa):** Vulnerabilidad defensiva. ฮฒ < 1 = mejor defensa.
**Goles esperados:**
- `ฮป_local = ฮฑ_local ร— ฮฒ_visitante ร— e^ฮณ`
- `ฮป_visitante = ฮฑ_visitante ร— ฮฒ_local`
**Correcciรณn ฯ„ (tau):** Ajusta probabilidades de 0-0, 1-0, 0-1, 1-1
para capturar la dependencia real entre goles de ambos equipos.
**Fuente de datos:** [football-data.co.uk](https://www.football-data.co.uk)
""")
return
# โ”€โ”€ Mร‰TRICAS GLOBALES โ”€โ”€
cols = st.columns(6)
metrics = [
("Partidos", str(model.n_matches), "#4f46e5"),
("Equipos", str(len(model.teams)), "#059669"),
("ฯ (rho)", f"{model.rho:.4f}", "#d97706"),
("Home Adv", f"{math.exp(model.home_adv):.3f}x", "#dc2626"),
("ฮพ Decay", f"{model.xi}", "#0891b2"),
("LogLik", f"{model.log_likelihood:.0f}", "#7c3aed"),
]
for col, (label, value, color) in zip(cols, metrics):
col.markdown(f"""
<div class="metric-card">
<div class="metric-label">{label}</div>
<div class="metric-value" style="color: {color};">{value}</div>
</div>
""", unsafe_allow_html=True)
st.markdown("")
# โ”€โ”€ TABS โ”€โ”€
tab1, tab2, tab3, tab4 = st.tabs([
f"๐Ÿ“Š Predicciones ({len(predictions)})",
"๐Ÿ† Power Rankings",
"๐Ÿ”ฌ Simulador",
"๐Ÿ“– Metodologรญa"
])
# โ”€โ”€ TAB: PREDICCIONES โ”€โ”€
with tab1:
if not predictions:
st.warning("No hay fixtures disponibles para esta liga. "
"Los fixtures se publican normalmente la semana del partido.")
else:
# Tabla resumen
sum_rows = []
for p in predictions:
w = "1" if p["p_home"] > max(p["p_draw"], p["p_away"]) else \
"2" if p["p_away"] > max(p["p_home"], p["p_draw"]) else "X"
sum_rows.append({
"Fecha": p.get("date", ""),
"Local": p["home"],
"Visitante": p["away"],
"xG H": f"{p['lambda_h']:.2f}",
"xG A": f"{p['lambda_a']:.2f}",
"Pred": w,
"P(1)": f"{p['p_home']*100:.0f}%",
"P(X)": f"{p['p_draw']*100:.0f}%",
"P(2)": f"{p['p_away']*100:.0f}%",
"O2.5": f"{p['over_25']*100:.0f}%",
"BTTS": f"{p['btts_yes']*100:.0f}%",
"Score": f"{p['top_scores'][0][0]}-{p['top_scores'][0][1]}",
})
st.dataframe(pd.DataFrame(sum_rows), use_container_width=True, hide_index=True)
# Detalle por partido
st.markdown("### Detalle por Partido")
for i, p in enumerate(predictions):
winner = "๐ŸŸข LOCAL" if p["p_home"] > max(p["p_draw"], p["p_away"]) else \
"๐Ÿ”ด VISITANTE" if p["p_away"] > max(p["p_home"], p["p_draw"]) else "๐ŸŸก EMPATE"
with st.expander(f"**{p['home']}** vs **{p['away']}** โ€” {p.get('date','')} | {winner}", expanded=(i == 0)):
c1, c2 = st.columns(2)
with c1:
st.markdown("#### Resultado 1X2")
fig = go.Figure(go.Bar(
x=[p["p_home"]*100, p["p_draw"]*100, p["p_away"]*100],
y=["1 (Local)", "X (Empate)", "2 (Visitante)"],
orientation="h",
marker_color=["#059669", "#d97706", "#dc2626"],
text=[f"{p['p_home']*100:.1f}%", f"{p['p_draw']*100:.1f}%", f"{p['p_away']*100:.1f}%"],
textposition="auto",
))
fig.update_layout(
height=180, margin=dict(l=0, r=0, t=10, b=10),
plot_bgcolor="rgba(255,255,255,1)", paper_bgcolor="rgba(255,255,255,1)",
font_color="#1f2937", xaxis=dict(visible=False), yaxis=dict(autorange="reversed"),
)
st.plotly_chart(fig, use_container_width=True, key=f"pred_bar_{i}")
st.markdown("**Cuotas implรญcitas:**")
q1, q2, q3 = st.columns(3)
q1.metric("1", f"{p['odds_home']:.2f}")
q2.metric("X", f"{p['odds_draw']:.2f}")
q3.metric("2", f"{p['odds_away']:.2f}")
with c2:
st.markdown("#### Mercados")
m1, m2 = st.columns(2)
m1.metric("Over 2.5", f"{p['over_25']*100:.1f}%")
m2.metric("Under 2.5", f"{p['under_25']*100:.1f}%")
m1.metric("BTTS Sรญ", f"{p['btts_yes']*100:.1f}%")
m2.metric("BTTS No", f"{p['btts_no']*100:.1f}%")
st.markdown("**Marcadores mรกs probables:**")
scores_str = " | ".join(
f"**{s[0]}-{s[1]}** ({s[2]*100:.1f}%)" for s in p["top_scores"][:5]
)
st.markdown(scores_str)
# Heatmap de la matriz
st.markdown("#### Matriz de Probabilidades")
mat = p["matrix"][:6, :6] * 100
fig_hm = go.Figure(go.Heatmap(
z=mat, x=[str(j) for j in range(6)], y=[str(i) for i in range(6)],
colorscale="Viridis", text=np.round(mat, 1),
texttemplate="%{text}%", textfont=dict(size=10),
hovertemplate="Local %{y} - Visitante %{x}: %{z:.1f}%<extra></extra>",
))
fig_hm.update_layout(
height=300, margin=dict(l=0, r=0, t=30, b=0),
xaxis_title=f"Goles {p['away']}", yaxis_title=f"Goles {p['home']}",
plot_bgcolor="rgba(255,255,255,1)", paper_bgcolor="rgba(255,255,255,1)",
font_color="#1f2937", yaxis=dict(autorange="reversed"),
)
st.plotly_chart(fig_hm, use_container_width=True, key=f"pred_hm_{i}")
# โ”€โ”€ TAB: RANKINGS โ”€โ”€
with tab2:
st.markdown("### Power Rankings")
st.markdown("**Power = ATK / DEF** โ€” Mayor es mejor. ATK > 1 = ataque superior al promedio. DEF < 1 = defensa superior.")
# Tabla
fmt_df = rankings_df.copy()
for col in ["ATK", "DEF", "Power", "xG/90 (H)", "xGA/90"]:
fmt_df[col] = fmt_df[col].map(lambda x: f"{x:.3f}")
st.dataframe(fmt_df, use_container_width=True)
# Grรกfico ATK vs DEF
st.markdown("### Ataque vs Defensa")
fig_scatter = go.Figure()
for _, row in rankings_df.iterrows():
color = "#059669" if row["Power"] > 1.3 else "#dc2626" if row["Power"] < 0.7 else "#4f46e5"
fig_scatter.add_trace(go.Scatter(
x=[row["DEF"]], y=[row["ATK"]],
mode="markers+text", text=[row["Equipo"]],
textposition="top center", textfont=dict(size=9, color="#374151"),
marker=dict(size=row["Power"]*10, color=color, line=dict(width=1, color="#d1d5db")),
hovertemplate=f"<b>{row['Equipo']}</b><br>ATK: {row['ATK']:.3f}<br>DEF: {row['DEF']:.3f}<br>Power: {row['Power']:.3f}<extra></extra>",
showlegend=False,
))
fig_scatter.update_layout(
height=500, margin=dict(l=20, r=20, t=30, b=20),
xaxis_title="DEF (ฮฒ) โ€” menor = mejor defensa โ†’",
yaxis_title="ATK (ฮฑ) โ€” mayor = mejor ataque โ†’",
plot_bgcolor="rgba(247,248,252,1)", paper_bgcolor="rgba(255,255,255,1)",
font_color="#1f2937",
shapes=[
dict(type="line", x0=1, x1=1, y0=0, y1=3, line=dict(color="#d1d5db", dash="dash")),
dict(type="line", x0=0, x1=3, y0=1, y1=1, line=dict(color="#d1d5db", dash="dash")),
]
)
st.plotly_chart(fig_scatter, use_container_width=True, key="rankings_scatter")
# โ”€โ”€ TAB: SIMULADOR โ”€โ”€
with tab3:
st.markdown("### Simulador de Partido")
st.markdown("Selecciona dos equipos para generar una predicciรณn personalizada.")
c1, c2 = st.columns(2)
with c1:
home_team = st.selectbox("Equipo Local", model.teams, index=0)
with c2:
away_options = [t for t in model.teams if t != home_team]
away_team = st.selectbox("Equipo Visitante", away_options, index=min(1, len(away_options)-1))
if st.button("๐Ÿ”ฎ Predecir", use_container_width=True):
pred = model.predict(home_team, away_team)
if pred:
winner = "๐ŸŸข LOCAL" if pred["p_home"] > max(pred["p_draw"], pred["p_away"]) else \
"๐Ÿ”ด VISITANTE" if pred["p_away"] > max(pred["p_home"], pred["p_draw"]) else "๐ŸŸก EMPATE"
st.markdown(f"## {home_team} vs {away_team} โ€” {winner}")
m1, m2, m3, m4 = st.columns(4)
m1.metric("xG Local", f"{pred['lambda_h']:.3f}")
m2.metric("xG Visitante", f"{pred['lambda_a']:.3f}")
m3.metric("Over 2.5", f"{pred['over_25']*100:.1f}%")
m4.metric("BTTS", f"{pred['btts_yes']*100:.1f}%")
c1, c2 = st.columns(2)
with c1:
fig = go.Figure(go.Bar(
x=[pred["p_home"]*100, pred["p_draw"]*100, pred["p_away"]*100],
y=["1 (Local)", "X (Empate)", "2 (Visitante)"],
orientation="h",
marker_color=["#059669", "#d97706", "#dc2626"],
text=[f"{pred['p_home']*100:.1f}%", f"{pred['p_draw']*100:.1f}%", f"{pred['p_away']*100:.1f}%"],
textposition="auto",
))
fig.update_layout(
height=180, margin=dict(l=0, r=0, t=10, b=10),
plot_bgcolor="rgba(255,255,255,1)", paper_bgcolor="rgba(255,255,255,1)",
font_color="#1f2937", xaxis=dict(visible=False), yaxis=dict(autorange="reversed"),
)
st.plotly_chart(fig, use_container_width=True, key="sim_bar")
with c2:
mat = pred["matrix"][:6, :6] * 100
fig_hm = go.Figure(go.Heatmap(
z=mat, x=[str(j) for j in range(6)], y=[str(i) for i in range(6)],
colorscale="Viridis", text=np.round(mat, 1),
texttemplate="%{text}%", textfont=dict(size=10),
))
fig_hm.update_layout(
height=300, margin=dict(l=0, r=0, t=10, b=0),
xaxis_title=f"Goles {away_team}", yaxis_title=f"Goles {home_team}",
plot_bgcolor="rgba(255,255,255,1)", paper_bgcolor="rgba(255,255,255,1)",
font_color="#1f2937", yaxis=dict(autorange="reversed"),
)
st.plotly_chart(fig_hm, use_container_width=True, key="sim_hm")
st.markdown("**Marcadores mรกs probables:**")
for s in pred["top_scores"][:6]:
st.markdown(f"- **{s[0]}-{s[1]}**: {s[2]*100:.1f}%")
# โ”€โ”€ TAB: METODOLOGรA โ”€โ”€
with tab4:
st.markdown("### Modelo Dixon-Coles: Explicaciรณn Completa")
st.markdown("""
#### 1. Base: Distribuciรณn Poisson Bivariada
Los goles en fรบtbol se modelan como eventos que siguen una distribuciรณn de Poisson:
`P(X = k) = (ฮป^k ร— e^(-ฮป)) / k!`
Donde **ฮป** es el nรบmero esperado de goles (xG del modelo).
#### 2. Parรกmetros por Equipo
Cada equipo tiene dos parรกmetros estimados iterativamente:
- **ฮฑ (Ataque):** Capacidad ofensiva relativa. Valores > 1 indican ataque superior al promedio.
- **ฮฒ (Defensa):** Vulnerabilidad defensiva. Valores < 1 indican defensa superior.
""")
st.info(f"""
**Parรกmetros globales del modelo actual:**
- **ฮณ (Home Advantage):** {math.exp(model.home_adv):.3f}x
- **ฯ (Rho Dixon-Coles):** {model.rho:.4f}
- **ฮพ (Time Decay):** {model.xi}
- **Log-Likelihood:** {model.log_likelihood:.1f}
""")
st.markdown(f"""
#### 3. Cรกlculo de Goles Esperados
```
ฮป_local = ฮฑ_local ร— ฮฒ_visitante ร— e^ฮณ
ฮป_visitante = ฮฑ_visitante ร— ฮฒ_local
```
#### 4. Correcciรณn Dixon-Coles (ฯ„)
```
ฯ„(0,0) = 1 - ฮป_h ร— ฮป_a ร— ฯ
ฯ„(1,0) = 1 + ฮป_a ร— ฯ
ฯ„(0,1) = 1 + ฮป_h ร— ฯ
ฯ„(1,1) = 1 - ฯ
ฯ„(x,y) = 1 para otros marcadores
```
#### 5. Decaimiento Temporal
```
w(t) = e^(-ฮพ ร— t) donde ฮพ = {model.xi}
```
- Hace 30 dรญas: peso = {time_decay_weight(30, model.xi):.3f}
- Hace 90 dรญas: peso = {time_decay_weight(90, model.xi):.3f}
- Hace 180 dรญas: peso = {time_decay_weight(180, model.xi):.3f}
#### 6. Proceso de Estimaciรณn
Se inicializan ฮฑ=1, ฮฒ=1. En cada iteraciรณn ({model.max_iter} total) se recalcula
cada parรกmetro como la razรณn entre goles observados y esperados (ponderados).
Se normalizan, se re-estima ฮณ, y se optimiza ฯ por grid search.
#### 7. Fuente de Datos
- **Resultados:** `football-data.co.uk/mmz4281/{{season}}/{{league}}.csv`
- **Fixtures:** `football-data.co.uk/fixtures.csv`
""")
# โ”€โ”€ DESCARGA PDF โ”€โ”€
st.markdown("---")
st.markdown("### ๐Ÿ“„ Descargar Reporte PDF")
if st.button("๐Ÿ“ฅ Generar y Descargar PDF", type="primary", use_container_width=True):
with st.spinner("Generando PDF..."):
league_name = st.session_state.get("league_name", f"{league_info[2]} {league_info[0]}")
season_label = st.session_state.get("season_label", SEASONS[season])
pdf_buf = generate_pdf(model, predictions, rankings_df, league_name, season_label)
b64 = base64.b64encode(pdf_buf.read()).decode()
filename = f"Dixon_Coles_{league}_{season}_{datetime.now().strftime('%Y%m%d_%H%M')}.pdf"
st.download_button(
label=f"โฌ‡๏ธ Descargar {filename}",
data=pdf_buf.getvalue(),
file_name=filename,
mime="application/pdf",
use_container_width=True,
)
st.success(f"โœ… PDF generado: {filename}")
if __name__ == "__main__":
main()