Spaces:
Runtime error
Runtime error
| """Modelo two-stage: P(empate) dedicado e redistribuição condicional de 1 vs 2.""" | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from datetime import datetime | |
| import pandas as pd | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.preprocessing import StandardScaler | |
| from pipelines.wc_group_pressure import GroupPressure | |
| from pipelines.wc_hyperparams import get_wc_hyperparams | |
| from pipelines.wc_stats import WcMatchFeatures, row_group_name | |
| DRAW_FEATURE_NAMES = [ | |
| "elo_closeness", | |
| "abs_elo_diff_norm", | |
| "phase_knockout", | |
| "is_neutral", | |
| "h2h_draw_rate", | |
| "wc_draw_rate_diff", | |
| "home_must_win", | |
| "away_must_win", | |
| "secured_diff", | |
| "group_matchday_norm", | |
| ] | |
| class DrawModelMetrics: | |
| train_size: int | |
| draw_rate: float | |
| holdout_accuracy: float | None = None | |
| def _wc_draw_rates( | |
| fixtures_df: pd.DataFrame, | |
| home_team: str, | |
| away_team: str, | |
| before_date: datetime | None, | |
| ) -> tuple[float, float]: | |
| df = fixtures_df.copy() | |
| if before_date is not None: | |
| cutoff = pd.to_datetime(before_date, utc=True) | |
| df = df[pd.to_datetime(df["match_date"], utc=True) < cutoff] | |
| neutral = df[df.get("is_neutral", True).astype(bool) if "is_neutral" in df.columns else True] | |
| if neutral.empty: | |
| neutral = df | |
| def rate(team: str) -> float: | |
| games = neutral[ | |
| (neutral["home_team"] == team) | (neutral["away_team"] == team) | |
| ] | |
| if games.empty: | |
| return 0.2 | |
| draws = sum( | |
| 1 | |
| for _, r in games.iterrows() | |
| if int(r["home_score"]) == int(r["away_score"]) | |
| ) | |
| return draws / len(games) | |
| return rate(home_team), rate(away_team) | |
| def draw_features_to_vector( | |
| features: WcMatchFeatures, | |
| pressure: GroupPressure, | |
| *, | |
| home_draw_rate: float, | |
| away_draw_rate: float, | |
| ) -> list[float]: | |
| closeness = 1.0 / (1.0 + abs(features.elo_diff) / 120.0) | |
| return [ | |
| closeness, | |
| min(abs(features.elo_diff) / 400.0, 2.0), | |
| float(features.phase_knockout), | |
| float(features.is_neutral), | |
| features.h2h_draws / max(features.h2h_total, 1), | |
| home_draw_rate - away_draw_rate, | |
| pressure.home_must_win, | |
| pressure.away_must_win, | |
| pressure.home_secured - pressure.away_secured, | |
| min(pressure.group_matchday / 3.0, 1.0), | |
| ] | |
| def apply_two_stage_probs( | |
| probs: dict[str, float], | |
| p_draw: float, | |
| *, | |
| blend: float, | |
| knockout: bool, | |
| ) -> dict[str, float]: | |
| hp = get_wc_hyperparams() | |
| p_draw_adj = p_draw | |
| if knockout: | |
| p_draw_adj *= hp.knockout_draw_discount | |
| p_x = blend * p_draw_adj + (1.0 - blend) * probs["X"] | |
| p_x = min(max(p_x, 0.05), 0.42) | |
| non_draw = probs["1"] + probs["2"] | |
| if non_draw < 1e-9: | |
| p1_cond = 0.5 | |
| else: | |
| p1_cond = probs["1"] / non_draw | |
| rem = 1.0 - p_x | |
| return {"1": rem * p1_cond, "X": p_x, "2": rem * (1.0 - p1_cond)} | |
| class WcDrawModel: | |
| def __init__(self) -> None: | |
| self.scaler = StandardScaler() | |
| self.model = LogisticRegression( | |
| C=0.5, | |
| class_weight="balanced", | |
| max_iter=2000, | |
| random_state=42, | |
| solver="lbfgs", | |
| ) | |
| self._fitted = False | |
| self.metrics: DrawModelMetrics | None = None | |
| def fit( | |
| self, | |
| *, | |
| feature_rows: list[list[float]], | |
| labels: list[int], | |
| ) -> DrawModelMetrics: | |
| if len(feature_rows) < 40: | |
| raise ValueError(f"Dados insuficientes para modelo de empate ({len(feature_rows)})") | |
| x_scaled = self.scaler.fit_transform(feature_rows) | |
| self.model.fit(x_scaled, labels) | |
| self._fitted = True | |
| draw_rate = sum(labels) / len(labels) | |
| metrics = DrawModelMetrics(train_size=len(labels), draw_rate=round(draw_rate, 4)) | |
| self.metrics = metrics | |
| return metrics | |
| def predict_draw_prob( | |
| self, | |
| feature_vector: list[float], | |
| ) -> float: | |
| if not self._fitted: | |
| return 0.22 | |
| x = self.scaler.transform([feature_vector])[0] | |
| proba = self.model.predict_proba([x])[0] | |
| classes = list(self.model.classes_) | |
| if 1 in classes: | |
| return float(proba[classes.index(1)]) | |
| return float(proba[-1]) | |
| def build_draw_training_rows( | |
| fixtures_df: pd.DataFrame, | |
| train_df: pd.DataFrame, | |
| ) -> tuple[list[list[float]], list[int]]: | |
| from pipelines.wc_stats import build_match_features, group_pressure_from_features, precompute_elo_timeline | |
| elo_timeline = precompute_elo_timeline(fixtures_df) | |
| x_rows: list[list[float]] = [] | |
| y_rows: list[int] = [] | |
| for _, row in train_df.iterrows(): | |
| before = row["match_date"] | |
| gcol = row_group_name(row) | |
| feats = build_match_features( | |
| fixtures_df, | |
| row["home_team"], | |
| row["away_team"], | |
| before_date=before, | |
| phase=row.get("phase", "group"), | |
| is_neutral=bool(row.get("is_neutral", True)), | |
| season=int(row["season"]), | |
| group_name=gcol if gcol is not None and not pd.isna(gcol) else None, | |
| elo_timeline=elo_timeline, | |
| ) | |
| pressure = group_pressure_from_features(feats) | |
| h_rate, a_rate = _wc_draw_rates( | |
| fixtures_df, row["home_team"], row["away_team"], before | |
| ) | |
| x_rows.append( | |
| draw_features_to_vector(feats, pressure, home_draw_rate=h_rate, away_draw_rate=a_rate) | |
| ) | |
| y_rows.append(1 if row["label"] == "X" else 0) | |
| return x_rows, y_rows | |