Draft Analytica - Manual detallado de modelos end to end
Fecha de auditoria: 2026-06-30
Este documento explica como estan armados los modelos del repo, desde la data y feature stores hasta el payload de produccion. Esta escrito para poder tocar el sistema con criterio: que cambia cada perilla, que numeros salieron, que quedo en produccion y que quedo como research/shadow.
0. Resumen mental del sistema
El sistema productivo no es un unico modelo. Es una arquitectura por capas:
- `v4_1_weighted_softmax_elo_form`: modelo principal de probabilidades 1X2.
- `p2f_v4_1_elo_conditional_draw_threshold`: capa de decision para mejorar
etiquetas de empate sin cambiar probabilidades.
- `ensemble_v3_1`: baseline Poisson/rating para goles esperados.
- `p3g_2026_06_29`: XGBoost residual de lambdas, no promovido directo.
- `p3h_2026_06_29`: modelo estabilizado de goles/marcadores, en produccion.
- Simulacion de torneo: usa P3H para forma de marcadores y repondera buckets
para respetar probabilidades v4.1.
- Odds/ROI: existe infraestructura de EV, pero P2F no alimenta actualmente el
benchmark de cuotas porque ese benchmark usa probabilidades, no la etiqueta final.
Versiones canonicas en `src/prediction_modes.py`:
| Uso | Version |
|---|---|
| 1X2 principal | `v4_1_weighted_softmax_elo_form` |
| Scorelines/goles | `p3h_2026_06_29` |
| Poisson baseline | `ensemble_v3_1` |
| Torneo | `hybrid_v4_1_p2f_p3h_fast_v1` |
1. Data: que entra al sistema
1.1 Resultados internacionales
Archivo principal:
- `data/processed/v4/international_matches_clean.csv`
Script:
- `scripts/v4/ingest_international_results.py`
Este script normaliza:
- `match_date`
- `home_team`
- `away_team`
- `home_score`
- `away_score`
- `tournament`
- `neutral`
- `country`
- `city`
- `match_importance`
Clasificacion de importancia en `scripts/v4/common.py`:
| Texto del torneo | Clase |
|---|---|
| contiene `friendly` | `friendly` |
| contiene `qualif` | `qualifier` |
| contiene `world cup`, `euro`, `copa america`, `afcon`, `asian cup`, `gold cup` | `major_tournament` |
| contiene `nations league` | `competitive` |
| otro | `other` |
Tambien deriva:
- `is_friendly`
- `is_qualifier`
- `is_major_tournament`
- normalizacion de nombres
- `match_key` para deduplicacion
1.2 Elo dinamico as-of
Archivo:
- `data/processed/v4/team_dynamic_elo_asof.csv`
Script:
- `scripts/v4/build_dynamic_elo_asof.py`
Funcion central:
- `build_dynamic_elo(matches)`
Cada equipo empieza en Elo `1500.0`.
Para cada partido ordenado por fecha:
home_pre = rating previo local, default 1500
away_pre = rating previo visitante, default 1500
home_advantage = 0 si neutral, 50 si no neutral
expected_home = 1 / (1 + 10 ^ (-((home_pre + home_advantage) - away_pre) / 400))
outcome_home = 1 si gana home, 0.5 si empate, 0 si pierde home
change = k * margin_multiplier * (outcome_home - expected_home)
home_post = home_pre + change
away_post = away_pre - change
elo_diff_pre = (home_pre + home_advantage) - away_pre`k` depende de la importancia:
| match_importance | k |
|---|---|
| friendly | 15 |
| qualifier | 30 |
| major_tournament | 45 |
| competitive | 35 |
| other | 25 |
`margin_multiplier`:
| Diferencia de goles | Multiplicador |
|---|---|
| 0 o 1 | 1.00 |
| 2 | 1.25 |
| 3+ | 1.50 |
Punto importante: la fila guarda `elo_pre_*` antes de actualizar. Por eso es leakage-safe: el modelo no ve el resultado del partido que intenta predecir.
1.3 Forma reciente as-of
Archivo:
- `data/processed/v4/team_form_asof_features.csv`
Script:
- `scripts/v4/build_team_form_asof_features.py`
Funcion central:
- `build_form_features(matches, elo)`
Por cada partido, antes de agregar el resultado actual al historial, calcula para cada equipo:
| Feature | Significado |
|---|---|
| `last_5_points` | puntos en ultimos 5 partidos previos |
| `last_10_points` | puntos en ultimos 10 partidos previos |
| `last_20_points` | puntos en ultimos 20 partidos previos |
| `goals_for_last_10` | goles a favor ultimos 10 |
| `goals_against_last_10` | goles en contra ultimos 10 |
| `goal_diff_last_10` | diferencia de gol ultimos 10 |
| `win_rate_last_10` | tasa de victorias ultimos 10 |
| `clean_sheet_rate_last_10` | tasa de porterias a cero ultimos 10 |
| `opponent_adjusted_points_last_10` | puntos ponderados por Elo rival |
| `opponent_adjusted_goal_diff_last_10` | diferencial ponderado por Elo rival |
| `average_opponent_elo_last_10` | fuerza media de rivales recientes |
| `days_since_last_match` | descanso desde ultimo partido |
| `competitive_match_form` | forma en partidos no amistosos |
| `friendly_form` | forma en amistosos |
| `major_tournament_form` | forma en torneos grandes |
| `matches_available_before_match` | cobertura historica previa |
1.4 FIFA ranking as-of
Archivo:
- `data/processed/v4/fifa_rankings_asof.csv`
Script:
- `scripts/v4/ingest_fifa_rankings_history.py`
El feature store busca el ultimo snapshot FIFA con fecha menor o igual al `match_date`. Se marca si es historico real, mixto o snapshot actual forecast-only.
1.5 Market value / squad features
Archivo:
- `data/processed/v4/team_squad_market_asof_features.csv`
Script:
- `scripts/v4/build_squad_market_asof_features.py`
Estas features existen en el feature store, pero para v4.1 productivo no entran al softmax. Se marcan como `forecast_only` cuando son snapshot actual.
1.6 Feature store v4 final
Archivo:
- `data/processed/v4/match_feature_store_asof.csv`
Metadata:
- `rows = 49493`
- generado: `2026-06-28T09:34:14+00:00`
- nota: features leakage-safe con Elo pre-match, forma previa y FIFA historico
as-of cuando existe.
Script:
- `scripts/v4/build_v4_feature_store.py`
Une:
- resultados limpios
- Elo as-of
- forma as-of
- FIFA as-of
- market/squad forecast-only
La fila final de cada partido contiene lo que el modelo puede usar o auditar.
2. v4.1 Weighted Softmax 1X2
2.1 Que es
Modelo productivo principal de probabilidades 1X2:
- `home_win`
- `draw`
- `away_win`
Nombre:
- `v4_1_weighted_softmax_elo_form`
Servicio:
- `backend/services/v4_prediction_service.py`
Entrenamiento:
- `scripts/v4/train_v4_1_weighted_model.py`
Base comun:
- `scripts/v4/train_v4_candidate_model.py`
Tipo de modelo:
- regresion logistica multinomial
- softmax
- perdida cross entropy ponderada por clase
- L2 regularization
- temperature scaling en validacion
2.2 Features exactas
`FEATURE_ORDER`:
| Orden | Feature | Formula de entrenamiento/runtime |
|---|---|---|
| 1 | `elo_diff` | `elo_diff_pre / 400` |
| 2 | `form_points_diff` | `(last_10_points_home - last_10_points_away) / 30` |
| 3 | `form_goal_diff` | `(goal_diff_last_10_home - goal_diff_last_10_away) / 30` |
| 4 | `opponent_elo_diff` | `(avg_opp_elo_home - avg_opp_elo_away) / 400` |
| 5 | `rest_diff` | `(log1p(days_home) - log1p(days_away)) / 4` |
| 6 | `is_neutral` | 1 si neutral, 0 si no |
| 7 | `is_friendly` | 1 si amistoso |
| 8 | `is_qualifier` | 1 si qualifier |
| 9 | `is_major_tournament` | 1 si torneo grande |
En runtime, si el partido no es neutral, se agrega `home_advantage = 50` puntos en la diferencia Elo. En partidos neutrales, `home_advantage = 0`.
2.3 Split temporal
Funcion:
- `split_temporally(dataset)`
Politica:
| Split | Regla |
|---|---|
| Train | year < 2010 |
| Validation | 2010 <= year < 2018 |
| Test | year >= 2018 |
Si alguna particion queda vacia, hay fallback 70/15/15, pero en este run se uso el split temporal real.
Conteos v4.1:
| Split | Partidos |
|---|---|
| Train | 33,588 |
| Validation | 7,709 |
| Test | 8,196 en artifact de entrenamiento |
Nota: algunos backtests posteriores de P2F/XGB usan 8,155 o 8,174 filas porque operan sobre subconjuntos/archivos generados para decision/odds. El modelo base entrenado reporta 8,196.
2.4 Normalizacion
Antes de entrenar:
x_raw = features
mean = media de train
std = desviacion de train, si 0 entonces 1
x_scaled = (x_raw - mean) / std
x_model = [1.0, x_scaled...] # interceptoLas medias/std quedan guardadas dentro del CSV de pesos:
`data/processed/v4/diagnostics/v4_1_weighted_softmax_weights.csv`
2.5 Formula del softmax
Para cada partido:
logits = x_model @ W
logits_temp = logits / temperature
probabilities = softmax(logits_temp)Con:
softmax(z_i) = exp(z_i) / sum_j exp(z_j)La matriz `W` tiene una columna por clase:
- `home_win`
- `draw`
- `away_win`
2.6 Entrenamiento
Funcion:
- `train_weighted_softmax(...)`
Hiperparametros:
| Parametro | Valor |
|---|---|
| epochs | 700 |
| learning_rate | 0.08 |
| l2 | 0.01 |
| class_weight_home_win | 0.82 |
| class_weight_draw | 1.55 |
| class_weight_away_win | 1.10 |
La perdida es cross entropy ponderada:
loss = - sum_i class_weight[y_i] * log(p_i[y_i]) + L2Gradiente:
residual = (probs - y_onehot) * sample_weight
grad = X.T @ residual / sum(sample_weight)
grad[1:] += l2 * weights[1:] # no regulariza intercepto
weights -= learning_rate * gradPor que esos pesos:
- `draw = 1.55`: obliga al modelo a tomar mas en serio la clase empate.
- `home_win = 0.82`: reduce sesgo hacia home win.
- `away_win = 1.10`: levanta un poco away wins.
2.7 Temperature scaling
Funcion:
- `select_acceptance_temperature(...)`
Candidatos:
0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 1.00,
1.10, 1.20, 1.35, 1.50, 1.75, 2.00Regla:
- Filtra candidatos con `ECE <= 0.030`.
- Dentro de esos, escoge menor validation log loss.
- Si ninguno pasa, escoge menor ECE y luego log loss.
Temperatura elegida:
temperature = 0.82.8 Pesos aprendidos
Pesos exactos:
| Feature | home_win | draw | away_win |
|---|---|---|---|
| intercept | 0.166460 | 0.107204 | -0.273664 |
| elo_diff | 0.518408 | -0.027380 | -0.491028 |
| form_points_diff | -0.014540 | 0.003803 | 0.010737 |
| form_goal_diff | 0.220758 | -0.003664 | -0.217094 |
| opponent_elo_diff | 0.147124 | -0.011795 | -0.135329 |
| rest_diff | -0.029782 | -0.013428 | 0.043210 |
| is_neutral | -0.057181 | -0.037040 | 0.094221 |
| is_friendly | -0.069122 | 0.047177 | 0.021945 |
| is_qualifier | 0.018872 | 0.016048 | -0.034920 |
| is_major_tournament | -0.013545 | 0.015080 | -0.001535 |
Lectura humana:
- `elo_diff` es la senal mas fuerte: si A tiene mas Elo, sube `home_win` y baja
`away_win`.
- `form_goal_diff` tambien empuja fuerte al ganador.
- `opponent_elo_diff` premia forma conseguida contra rivales fuertes.
- Los pesos de `draw` son pequenos. El empate es dificil: no se comporta como
"equipo A fuerte" o "equipo B fuerte"; por eso usamos class weighting y P2F.
- `is_friendly` levanta algo el empate: amistosos suelen ser mas ruidosos.
2.9 Metricas v4.1 base
Artifact:
- `data/processed/v4/diagnostics/v4_1_weighted_softmax_metrics.csv`
| Split | Temp | Matches | Accuracy | Balanced acc | Log loss | Brier | RPS | ECE | Draw F1 |
|---|---|---|---|---|---|---|---|---|---|
| train | 1.0 | 33,588 | 0.550732 | 0.492280 | 0.946238 | 0.560080 | 0.539342 | 0.070907 | 0.267261 |
| validation | 0.8 | 7,709 | 0.567129 | 0.507340 | 0.918258 | 0.539524 | 0.501931 | 0.028276 | 0.262139 |
| test | 0.8 | 8,196 | 0.591630 | 0.521036 | 0.880890 | 0.516768 | 0.485189 | 0.019907 | 0.228192 |
2.10 Metricas por clase
Validation:
| Clase | Support | Predichos | Precision | Recall | F1 |
|---|---|---|---|---|---|
| home_win | 3,693 | 4,176 | 0.655412 | 0.741132 | 0.695641 |
| draw | 1,800 | 1,351 | 0.305699 | 0.229444 | 0.262139 |
| away_win | 2,216 | 2,182 | 0.560037 | 0.551444 | 0.555707 |
Test:
| Clase | Support | Predichos | Precision | Recall | F1 |
|---|---|---|---|---|---|
| home_win | 3,923 | 4,677 | 0.661535 | 0.788682 | 0.719535 |
| draw | 1,892 | 1,123 | 0.306322 | 0.181818 | 0.228192 |
| away_win | 2,381 | 2,396 | 0.588898 | 0.592608 | 0.590747 |
Problema: en test el modelo base solo predice 1,123 empates contra 1,892 reales. Por eso se diseno P2F.
2.11 Acceptance gates v4.1
Archivo:
- `data/processed/v4/diagnostics/v4_1_weighted_softmax_acceptance.csv`
| Check | Valor | Target | Paso |
|---|---|---|---|
| validation_non_neutral_hw_argmax | 0.565399 | <= 0.58 | True |
| validation_draw_f1 | 0.262139 | >= 0.22 | True |
| validation_ece | 0.028276 | <= 0.03 | True |
Por eso v4.1 fue aceptable como base probabilistica.
2.12 Runtime v4.1
Funcion:
- `predict_match_v4(...)`
Pasos:
- Carga estado con `_load_v4_state`.
- Reconstruye historial de cada equipo.
- Usa `get_team_state_asof` con `bisect_left`: solo eventos estrictamente
anteriores a `as_of`.
- Calcula features runtime.
- Escala features con medias/std de train.
- Aplica `softmax((x @ W) / temperature)`.
- Si es neutral, calcula A vs B y B vs A, remapea y promedia:
probs_ab = modelo(A, B)
probs_ba = modelo(B, A)
probs_ba_mapped = [away_from_ba, draw_from_ba, home_from_ba]
probs = (probs_ab + probs_ba_mapped) / 2Esto reduce sesgo de orden en partidos neutrales.
- Puede aplicar live overlay WC2026 si la politica lo permite.
- Aplica decision P2F.
- Devuelve probabilidades, decision, features, metadata y warnings.
3. P2F: decision layer de empate
3.1 Que es
Modelo/capa:
- `p2f_v4_1_elo_conditional_draw_threshold`
Version:
- `p2f_2026_06_29`
Codigo:
- `backend/services/v4_prediction_service.py`
Metadata:
- `models/v4/p2f_v4_1_elo_conditional_threshold_metadata.json`
No cambia probabilidades. Solo decide la etiqueta final servida.
3.2 Regla
abs_elo_gap < 50:
segment = close
draw_threshold = 0.325
50 <= abs_elo_gap < 150:
segment = medium
draw_threshold = 0.375
abs_elo_gap >= 150:
segment = lopsided
decision = argmaxSi hay threshold:
if p_draw >= draw_threshold:
pred = draw
else:
pred = max(home_win, away_win)3.3 Por que existe
v4.1 calibraba razonablemente, pero como decision argmax seguia prediciendo pocos empates. P2F aumenta recall de empates sin tocar probabilidades.
3.4 Metricas P2F
Validation:
| Politica | Accuracy | Macro F1 | Draw F1 | Draw precision | Draw recall | Pred draw rate | Actual draw rate |
|---|---|---|---|---|---|---|---|
| argmax | 0.567129 | 0.504496 | 0.262139 | 0.305699 | 0.229444 | 0.175250 | 0.233493 |
| P2F selected | 0.550006 | 0.515568 | 0.336817 | 0.299352 | 0.385000 | 0.300298 | 0.233493 |
Test:
| Politica | Accuracy | Macro F1 | Draw F1 | Draw precision | Draw recall | Pred draw rate | Actual draw rate |
|---|---|---|---|---|---|---|---|
| argmax | 0.591048 | 0.512078 | 0.226667 | 0.304387 | 0.180563 | 0.136971 | 0.230901 |
| P2F selected | 0.571429 | 0.523739 | 0.297025 | 0.289606 | 0.304833 | 0.243041 | 0.230901 |
Lectura:
- baja accuracy global ~1.96 puntos en test
- sube macro F1
- sube mucho draw recall
- P2F predice empates a una tasa mas parecida a la realidad
- log loss/ECE no cambian porque probabilidades no cambian
3.5 Por que no usamos P2E offset
P2E:
- `p2e_v4_1_draw_probability_offset`
Logro:
- validation draw F1 hasta `0.344444`
- selected offset `0.075`, validation draw F1 `0.343273`
- test draw F1 `0.314873`
Pero no fue produccion porque cambiaba probabilidades:
| Split | Offset | Log loss | ECE | Draw calibration gap |
|---|---|---|---|---|
| validation baseline | 0.000 | 0.918258 | 0.028276 | 0.070492 |
| validation P2E | 0.075 | 0.940338 | 0.064111 | 0.119052 |
| test baseline | 0.000 | 0.880950 | 0.019664 | 0.057899 |
| test P2E | 0.075 | 0.901465 | 0.067855 | 0.107518 |
Decision:
- `promote_to_production = false`
- `diagnostic_only = true`
Razon: mejoraba etiqueta dura de empate, pero rompia calibracion.
4. XGBoost 1X2 challengers
4.1 XGBoost 1X2 directo
Reporte:
- `reports/xgboost_1x2_challenger_report.md`
Decision:
- no shadow
- no produccion
Comparacion test:
| Modelo | Accuracy | Balanced acc | Log loss | Brier | ECE | Draw F1 | Draw gap |
|---|---|---|---|---|---|---|---|
| v4.1 | 0.591048 | 0.520411 | 0.880950 | 0.516862 | 0.019664 | 0.226667 | 0.057899 |
| XGBoost | 0.578664 | 0.532265 | 0.891624 | 0.525960 | 0.040433 | 0.279403 | 0.074268 |
XGBoost mejora algo draw F1/balanced accuracy, pero empeora log loss, Brier, ECE y draw calibration gap. No se promueve.
4.2 XGBoost calibrated blend
Reporte:
- `reports/xgboost_calibration_blend_audit.md`
Seleccion:
- calibrator: `xgb_isotonic_ovr`
- alpha v4.1 weight: `0.1`
Resultado:
- `promote_to_shadow = true`
- `promote_to_production = false`
Por que:
- mejora log loss/ECE
- pero destruye draw F1 en test (`0.0`)
- sirve como sombra probabilistica, no como decision publica.
5. Ratings v3 y v31
5.1 Para que sirven
Los ratings no son el modelo 1X2 principal. Son una capa de fuerza de equipo que alimenta Poisson/goles:
ratings -> expected goals -> score matrix -> scorelines/over-under/simulacion5.2 v3
Archivo:
- `src/model_ratings_v3.py`
Formula:
model_score_v3 = 0.95 * fifa_component + 0.05 * supporting_data_component`supporting_data_component` combina:
| Componente | Peso |
|---|---|
| Elo support | 0.40 |
| Historical support | 0.35 |
| Reliability/data quality | 0.25 |
Si no hay FIFA:
model_score_v3 = supporting_data_component * 0.555.3 v31
Archivo:
- `src/model_ratings_v31.py`
Idea:
model_score_v31 = base_v3 * base_weight + xg * xg_weight + market * market_weightCon config seleccionada auditada:
base_v3_weight = 0.78
xg_weight = 0.14
market_weight = 0.08Si no hay xG/market value:
model_score_v31 = model_score_v35.4 Backtest v31 vs v3
Archivo:
- `data/processed/diagnostics/model_v31_backtest_summary.json`
| Modelo | Matches | Accuracy | Log loss | Brier | Calibration error |
|---|---|---|---|---|---|
| v3 | 1,248 | 0.5152 | 1.024682 | 0.606088 | 0.049100 |
| v31 | 1,248 | 0.5264 | 1.019340 | 0.602482 | 0.037001 |
Acceptance rule:
calibration_error <= v3 + 0.02
log_loss <= v3 + 0.03Resultado:
- accepted = true
6. Poisson ensemble_v3_1
6.1 Que es
Modelo:
- `PoissonGoalModel`
Archivo:
- `src/models/poisson.py`
Factory:
- `backend/services/model_service.py`
Version productiva base:
- `ensemble_v3_1`
6.2 Parametros ensemble_v3_1
| Parametro | Valor |
|---|---|
| baseline_goals | 1.28 |
| attack_weight | 0.35 |
| defense_weight | 0.25 |
| rating_weight | 1.05 |
| min_expected_goals | 0.25 |
| max_expected_goals | 3.25 |
| min_rating_adjustment | 0.62 |
| max_rating_adjustment | 1.38 |
| rating_column | `model_score_v31` |
| fallback columns | `model_score_v3`, `model_score_v2`, `ranking_score`, `adjusted_power_rating` |
6.3 Formula expected goals
Para equipo A contra B:
global_avg = media de avg_goals_for
attack_factor_a = avg_goals_for_a / global_avg
defense_factor_b = avg_goals_against_b / global_avg
rating_diff = rating_a - rating_b
rating_adjustment_a = clamp(1 + (rating_diff / 100) * rating_weight, min_adj, max_adj)
lambda_a =
baseline_goals
* attack_factor_a ^ attack_weight
* defense_factor_b ^ defense_weight
* rating_adjustment_aLo mismo para B con rating_diff invertido.
Luego se clampa:
lambda in [0.25, 3.25]6.4 Score matrix Poisson
Para cada marcador `i-j`:
P(A=i) = exp(-lambda_a) * lambda_a^i / i!
P(B=j) = exp(-lambda_b) * lambda_b^j / j!
P(i-j) = P(A=i) * P(B=j)Luego se suman celdas:
home_win = sum(i > j)
draw = sum(i == j)
away_win = sum(i < j)Importante: en produccion, este 1X2 derivado de goles no es el principal. Es auxiliar/diagnostico cuando v4.1 esta activo.
7. P3G learned lambda residual
7.1 Que es
Modelo:
- `p3g_learned_lambda_residual`
Version:
- `p3g_2026_06_29`
Archivos:
- `src/models/p3g_lambda_residual.py`
- `models/goal_model/p3g_home_goals_xgb.pkl`
- `models/goal_model/p3g_away_goals_xgb.pkl`
- `models/goal_model/p3g_goal_model_metadata.json`
Tipo:
- XGBoost `count:poisson`
- un booster para goles home
- un booster para goles away
- aprende residual sobre lambda v31, no reemplaza Poisson desde cero
Formula:
lambda_p3g = lambda_v31 * exp(f(features))Implementacion:
base_margin = log(lambda_v31)
objective = count:poisson7.2 Features P3G
Incluye:
- lambdas v31
- log lambdas v31
- total esperado
- diferencia/ratio de lambdas
- Elo home/away/diff
- ratings home/away/diff
- goles for/against ultimos 10
- total goals ultimos 10
- draw rate ultimos 10
- form points diff
- neutral / tournament / qualifier / friendly
- rest diff
- xG for/against ultimos 10
- cobertura xG
Lista exacta en:
- `src/models/p3g_lambda_residual.py`
7.3 Parametros XGBoost
| Parametro | Valor |
|---|---|
| objective | `count:poisson` |
| eval_metric | `poisson-nloglik` |
| eta | 0.03 |
| max_depth | 2 |
| min_child_weight | 10 |
| subsample | 0.85 |
| colsample_bytree | 0.85 |
| lambda | 10.0 |
| alpha | 1.0 |
| tree_method | `hist` |
| seed | 20260629 |
Caps:
lambda_min = 0.15
lambda_max = 4.50Split:
train before 2014
validation 2014-2019
test 2022+7.4 Resultado P3G
P3G no se promovio.
Razon:
- validation mejora
- test empeora scoreline log loss
- over 2.5 calibration se mueve mal
- xG coverage historica es baja (`0.175481`)
Metricas clave:
| Variant | Split | Scoreline log loss | Exact top1 | Total goals bias | Over 2.5 gap | 1X2 log loss |
|---|---|---|---|---|---|---|
| baseline_v31 | validation | 3.09346 | 0.086207 | 0.042420 | 0.004836 | 0.976725 |
| P3G | validation | 3.09109 | 0.112069 | 0.113866 | 0.010455 | 0.961192 |
| baseline_v31 | test | 2.86714 | 0.093750 | 0.049826 | 0.045362 | 0.942998 |
| P3G | test | 2.88071 | 0.109375 | -0.056103 | 0.018909 | 0.950645 |
Decision:
- `promote_to_shadow = false`
- `promote_to_production = false`
8. P3H stabilized lambda model
8.1 Que es
Modelo:
- `p3h_stabilized_lambda_model`
Version:
- `p3h_2026_06_29`
Archivos:
- `src/models/p3h_stabilized_lambda_model.py`
- `backend/services/p3h_goal_service.py`
- `models/goal_model/p3h_goal_model_metadata.json`
P3H toma P3G, pero lo estabiliza contra Poisson v31.
8.2 Formula P3H
Formula del metadata:
log_lambda_p3h =
log_lambda_v31
+ gamma * clip(log_lambda_p3g - log_lambda_v31, -clip_value, clip_value)
lambda_p3h *= exp(total_delta / 2)Parametros elegidos:
| Parametro | Valor |
|---|---|
| gamma | 0.8 |
| clip_value | 0.75 |
| total_delta | -0.04 |
| rho_strategy | `global_regularized` |
Interpretacion:
- `gamma = 0.8`: toma buena parte de la correccion P3G.
- `clip = 0.75`: evita que P3G haga saltos enormes.
- `total_delta = -0.04`: baja levemente el volumen total de goles.
- `rho`: ajuste Dixon-Coles para dependencia en marcadores bajos.
8.3 Dixon-Coles
P3H primero genera matriz Poisson y luego puede ajustar celdas bajas:
0-0 *= 1 - lambda_home * lambda_away * rho
0-1 *= 1 + lambda_home * rho
1-0 *= 1 + lambda_away * rho
1-1 *= 1 - rhoDespues normaliza la matriz.
Rho se clampa en:
[-0.20, 0.20]8.4 Seleccion
Grid:
- gamma: 0.00 a 1.00
- clip: 0.15 a 0.75
- total_delta: -0.08 a 0.08
- rho: varias estrategias
Politica:
- metrica primaria: rolling mean scoreline log loss
- no aceptar folds materialmente peores
- guardrails en test, total goals, over 2.5, exact score y P2F untouched
8.5 Metricas P3H
Artifact:
- `data/processed/diagnostics/p3h_goal_model_metrics.json`
Selected candidate:
gamma_0.80_clip_0.75_td_-0.04_rho_global_regularized| Metrica | Valor |
|---|---|
| rolling_mean_scoreline_log_loss | 3.121997 |
| rolling_baseline_mean_scoreline_log_loss | 3.149634 |
| validation_scoreline_log_loss | 3.084525 |
| test_scoreline_log_loss | 2.876983 |
| all_scoreline_log_loss | 3.069456 |
| all_exact_score_top1_accuracy | 0.122596 |
| all_total_goals_bias | -0.082618 |
| all_over_2_5_gap_abs | 0.006928 |
| all_one_x_two_log_loss | 0.924768 |
| all_one_x_two_brier | 0.543093 |
Decision:
- `promote_to_shadow = true`
- `promote_to_production = true`
- `production_change_recommended = true`
8.6 Que sirve P3H
P3H sirve:
- expected goals
- total expected goals
- most likely score
- top scorelines
- score matrix
- over/under 1.5, 2.5, 3.5
- goal-derived 1X2 diagnostico
Pero:
goal_derived_1x2_usage = diagnostic_only_not_principalEl 1X2 principal sigue siendo v4.1/P2F.
9. Pipeline productivo de prediccion
Archivo:
- `backend/services/prediction_pipeline.py`
Funcion principal:
- `predict_match_distribution(...)`
Flujo:
- Valida que `model_version` sea v4.
- Llama `predict_match_v4`.
- Si no es historical replay, llama `predict_p3h_goal_distribution`.
- Construye `MatchDistribution`.
- Devuelve:
- probabilidades v4.1
- decision P2F
- argmax original
- features
- score matrix P3H
- expected goals
- top scorelines
- over/under
- warnings/data quality
Separacion clave:
| Capa | Uso |
|---|---|
| v4.1 probabilities | probabilidad oficial 1X2 |
| P2F decision | etiqueta final servida |
| P3H scorelines | marcadores/goles/over-under |
| P3H goal-derived 1X2 | diagnostico, no principal |
10. Simulacion de torneo
Archivo:
- `backend/services/simulation_service.py`
Funcion:
- `simulate_world_cup_2026_official(...)`
Cuando se pide modelo v4.1:
- Usa `ensemble_v3_1` como baseline Poisson.
- Usa `p3h_2026_06_29` como scoreline model.
- Usa motor fast.
- Marca:
scoreline_matrix_source = p3h_stabilized_lambda_model_reweighted_to_v4_1x210.1 Reponderacion v4.1 en simulacion
Archivo:
- `src/simulation/poisson_probability_cache.py`
Funcion:
- `_apply_v4_probabilities_to_score_matrix(package)`
Hace:
- Calcula matriz P3H.
- Suma buckets actuales:
- team_a_win
- draw
- team_b_win
- Obtiene target v4.1:
target_home = v4["prob_team_a_win"]
target_draw = v4["prob_draw"]
target_away = v4["prob_team_b_win"]- Multiplica cada celda del bucket por:
target_bucket / current_bucket_sum- Normaliza.
Resultado:
- se preserva la forma relativa de marcadores P3H dentro de cada bucket
- pero el total home/draw/away respeta v4.1
Nota: P2F es decision servida; la simulacion sigue siendo probability-driven.
11. Odds, EV y ROI
Archivos:
- `scripts/backtests/backtest_market_edge_v4_1.py`
- `scripts/backtests/backtest_worldcup_market_edge_v4_1.py`
- `src/odds/ev.py`
- `src/odds/metrics.py`
Formula EV:
EV = model_prob * decimal_odds - 1
edge_prob = model_prob - market_prob_novigSe apuesta si:
EV >= ev_threshold
edge_prob >= edge_threshold
min_odds <= odds <= max_oddsROI:
profit = stake * (odds - 1) si gana, -stake si pierde
ROI = total_profit / total_stakedEstado actual:
- historico `market_edge_v4_1`: no hubo cuotas cruzadas, ROI no calculable.
- WC2026 actual: future odds only, sin resultados, ROI `null`.
- auditoria P2F/Poisson dice `odds_benchmark_uses_p2f = false`.
Razon: el benchmark de cuotas usa probabilidades, y P2F no cambia probabilidades. P2F solo cambia etiqueta final.
12. Modelos no productivos / shadow
12.1 P2E draw offset
Subia `p_draw` con offset positivo. Mejoraba draw F1 pero rompia calibracion.
Decision:
- diagnostic only
- no produccion
12.2 XGBoost 1X2
Mejoraba algo empate, pero empeoraba probabilidad/calibracion.
Decision:
- no shadow
- no produccion
12.3 XGBoost calibrated blend
Mejoraba log loss/ECE pero casi eliminaba draw F1.
Decision:
- shadow probabilistico
- no produccion
12.4 P3G directo
Aprendia lambdas con XGBoost, pero test y over/under no pasaron guardrails.
Decision:
- no shadow
- no produccion directo
- usado como fuente dentro de P3H estabilizado
12.5 P1A/P1A.2/P1A.4
Hay artefactos P1A/P1A.2/P1A.4 en el repo. Son candidatos/shadow/canary con features as-of mas amplias. No son el default publico de produccion. Sirven como investigacion para futuras versiones, no como el modelo canonico actual.
12.5.1 P1A goal model
Artefacto:
- `models/p1a/goal_model_p1a.json`
Version:
- `goal_model_p1a_independent_poisson_v1`
Familia:
- `independent_poisson_regression`
Features:
| Feature | Uso |
|---|---|
| `elo_home_pre` | Elo home antes del partido |
| `elo_away_pre` | Elo away antes del partido |
| `elo_diff` | diferencia Elo prepartido |
| `attack_form_home` | ataque reciente home |
| `attack_form_away` | ataque reciente away |
| `defense_form_home` | defensa reciente home |
| `defense_form_away` | defensa reciente away |
| `rest_diff` | diferencia de descanso |
| `neutral` | flag cancha neutral |
| `competition_weight` | peso por importancia |
| `is_friendly` | flag amistoso |
| `is_qualifier` | flag eliminatoria |
| `is_major_tournament` | flag torneo grande |
Entrenamiento:
split train < 2010
dos regresiones Poisson independientes: home goals y away goals
L2 = 0.08
max_iterations = 60
standardization aprendida en trainInferencia:
eta_home = beta_home_intercept + X_scaled @ beta_home
eta_away = beta_away_intercept + X_scaled @ beta_away
lambda_home = exp(clip(eta_home, -3, 2))
lambda_away = exp(clip(eta_away, -3, 2))Luego genera matriz 0-10 goles, renormaliza y deriva 1X2 sumando:
home_win = sum(home_goals > away_goals)
draw = sum(home_goals == away_goals)
away_win = sum(home_goals < away_goals)Metricas reportadas:
| Split | N | Goal LL/match | RMSE | MAE | Log Loss 1X2 | Brier | ECE | Draw F1 |
|---|---|---|---|---|---|---|---|---|
| Validation | 7,709 | -2.9147 | 1.3303 | 0.9713 | 0.9049 | 0.5336 | 0.0143 | 0.0000 |
| Test | 8,124 | -2.8915 | 1.2954 | 0.9768 | 0.8776 | 0.5157 | 0.0159 | 0.0000 |
Decision:
benchmark_passed = true
decision = offline_candidate_only_not_default
default = falsePor que no fue default:
- su 1X2 argmax casi no detecta empates (`Draw F1 = 0.0`)
- lambdas podian ser extremas
- no tenia loader productivo completo en MatchDistribution
- no tenia fallback/feature flag publico robusto en esa etapa
- la comparacion favorable contra Poisson v3.1 no era un benchmark ex ante
completamente limpio porque v3.1 usaba ratings snapshot actuales
12.5.2 v4.2 P1A weighted softmax candidate
Artefacto:
- `models/v4_2/v4_2_p1a_candidate.json`
Version:
- `v4_2_p1a_weighted_softmax_candidate`
Seleccion:
- ablation: `plus_decay_form`
Features base de v4.1 mas decay form:
- `weighted_points_diff_5`
- `weighted_points_diff_10`
- `weighted_goal_diff_5`
- `weighted_goal_diff_10`
- `recency_weighted_form_diff`
Entrenamiento:
| Parametro | Valor |
|---|---|
| epochs | 650 |
| learning_rate | 0.07 |
| l2 | 0.015 |
| temperature | 0.9 |
| home_win weight | 0.82 |
| draw weight | 1.55 |
| away_win weight | 1.10 |
Decision:
promotable = false
decision = do_not_promote_keep_v4_1_default
default = falseChecks:
| Check | Paso |
|---|---|
| validation_log_loss | true |
| validation_brier_score | true |
| validation_ece | true |
| test_log_loss | true |
| test_brier_score | true |
| test_ece | false |
Lectura: P1A decay form mejoraba un poco log loss/Brier en algunos segmentos, pero no mejoraba calibracion global de forma suficientemente estable. Por eso v4.1 quedo como default.
12.5.3 P1A.2/P1A.4 serving status
P1A.2 agrego:
- regularizacion/shrinkage
- clipping mas conservador
- calibracion validation
- Dixon-Coles basico
- diagnostics de totals/low score/top-k
- loader de inferencia
- MatchDistribution shadow
- flags/fallback/promotion gates
Estado:
- `P1A.2`: shadow only
- `P1A.4`: canary/internal bajo feature flag
- no public/admin default
Razon general: todavia habia degradacion fuerte en Draw F1 o falta de estabilidad suficiente contra v4.1/P2F.
13. Que significa cada metrica
| Metrica | Significado | Mejor direccion |
|---|---|---|
| Accuracy | porcentaje de partidos donde la clase predicha fue correcta | mayor |
| Balanced accuracy | promedio de recalls por clase; no deja que home_win domine todo | mayor |
| Precision draw | de los empates predichos, cuantos fueron empates reales | mayor |
| Recall draw | de los empates reales, cuantos detectamos | mayor |
| Draw F1 | balance precision/recall del empate | mayor |
| Log loss | castiga probabilidades malas; muy sensible a exceso de confianza | menor |
| Brier score | error cuadratico entre probabilidades y resultado one-hot | menor |
| RPS | version ordinal/cumulativa para 1X2 | menor |
| ECE | error de calibracion; confianza vs acierto observado | menor |
| Scoreline log loss | log loss del marcador exacto | menor |
| Exact score top1 | frecuencia en que el marcador mas probable fue exacto | mayor |
| Total goals bias | goles esperados promedio menos goles reales promedio | cerca de 0 |
| Over 2.5 gap | diferencia entre probabilidad predicha y tasa real de over 2.5 | menor |
| ROI | profit / stake en backtest de apuestas | mayor, pero requiere muestra y no leakage |
| CLV | closing line value, compara cuota tomada vs cierre | mayor |
14. Donde tocar cada cosa
| Quieres cambiar | Toca aqui | Riesgo |
|---|---|---|
| Features 1X2 | `scripts/v4/build_v4_feature_store.py`, `train_v4_candidate_model.py` | alto, puede cambiar calibracion |
| Pesos de clase v4.1 | `scripts/v4/train_v4_1_weighted_model.py` | medio-alto, afecta draw/home/away |
| Temperatura | `select_acceptance_temperature` o metadata/weights regen | alto si se fuerza manualmente |
| Umbrales P2F | `backend/services/v4_prediction_service.py` | medio, no cambia probabilidades |
| Ratings v31 | `src/model_ratings_v31.py` y scripts build ratings | medio-alto, afecta goles |
| Parametros Poisson | `backend/services/model_service.py` | alto, afecta lambdas/simulacion |
| P3G features/params | `src/models/p3g_lambda_residual.py`, train script P3G | alto, overfit posible |
| P3H shrinkage | metadata P3H y `p3h_stabilized_lambda_model.py` | alto, afecta scorelines/over-under |
| Torneo | `simulation_service.py`, `poisson_probability_cache.py` | alto |
| EV/ROI | `scripts/backtests/backtest_*market_edge*` | medio, cuidar leakage |
15. La frase de presentacion correcta
El sistema productivo separa probabilidad, decision y marcador:
v4.1 calcula probabilidades 1X2 calibradas.
P2F ajusta la decision de empate en partidos parejos sin tocar esas probabilidades.
P3H produce goles, marcadores, over/under y matrices para simulacion.
La simulacion usa P3H para forma de marcadores, reponderada para respetar v4.1.Esa separacion es intencional. Nos permite mejorar la decision de empate sin romper calibracion, y mejorar marcadores sin reemplazar el 1X2 principal.
16. Fuentes principales
| Tema | Archivo |
|---|---|
| Versiones canonicas | `src/prediction_modes.py` |
| API/payload match | `backend/services/prediction_pipeline.py` |
| v4.1 runtime | `backend/services/v4_prediction_service.py` |
| v4.1 training | `scripts/v4/train_v4_1_weighted_model.py` |
| v4 dataset/split | `scripts/v4/train_v4_candidate_model.py` |
| feature store | `scripts/v4/build_v4_feature_store.py` |
| dynamic Elo | `scripts/v4/build_dynamic_elo_asof.py` |
| rolling form | `scripts/v4/build_team_form_asof_features.py` |
| metrics helpers | `scripts/v4/common.py` |
| P2F metadata | `models/v4/p2f_v4_1_elo_conditional_threshold_metadata.json` |
| P2E metadata | `models/xgboost/p2e_v4_1_draw_offset_metadata.json` |
| ratings v3 | `src/model_ratings_v3.py` |
| ratings v31 | `src/model_ratings_v31.py` |
| Poisson | `src/models/poisson.py` |
| Poisson factory | `backend/services/model_service.py` |
| P3G | `src/models/p3g_lambda_residual.py` |
| P3H | `src/models/p3h_stabilized_lambda_model.py` |
| P3H service | `backend/services/p3h_goal_service.py` |
| torneo | `backend/services/simulation_service.py` |
| reponderacion score matrix | `src/simulation/poisson_probability_cache.py` |
| odds/EV | `scripts/backtests/backtest_market_edge_v4_1.py` |