Pular para o conteúdo
Data Science

Machine Learning para Prever Resultados

Modelo de regressao logistica nao vai te fazer ganhar dinheiro. XGBoost com features certas e um backtest honesto - isso e outro jogo. Aqui vai o tutorial completo

Por que isso é importante

Machine Learning para Prever Resultados. Modelo de regressao logistica nao vai te fazer ganhar dinheiro. XGBoost com features certas e um backtest honesto - isso e outro jogo. Aqui vai o tutorial completo com codigo Python real.

TL;DR

XGBoost supera Elo e regressao logistica em predicao de futebol quando voce usa as features certas. Features que funcionam: xG dos ultimos 5 jogos, forma recente, fatores de casa. Features que nao funcionam: odds historicas como input (data leakage). Split temporal obrigatorio - nunca random. Backtest em 5 temporadas mostrou ROI positivo em ligas menores onde o mercado e menos eficiente.

Por Que Elo Nao E Suficiente

Elo e elegante. A formula e simples, intuitiva e funciona surpreendentemente bem para Chess e jogos 1v1. O problema e que futebol nao e xadrez. Um time pode ter Elo alto e perder porque o centroavante titular se machucou na vespera. Ou ganhar com um time reserva num jogo sem importancia na tabela.

Modelos lineares tipo Elo assumem que a forca de um time e um numero constante que muda devagar. Na realidade, a performance de um time varia muito jogo a jogo. Um XGBoost treinado com as features certas consegue capturar essas variancas que o Elo simplesmente ignora.

Elo / Modelos Lineares

+ Prós

  • • Simples de implementar
  • • Interpretavel
  • • Funciona como baseline solido

− Contras

  • • Ignora contexto do jogo
  • • Nao captura lesoes ou rotacao
  • • Assume forca constante por temporada

XGBoost com Features Ricas

+ Prós

  • • Captura interacoes nao-lineares
  • • Lida bem com features heterogeneas
  • • Supera Elo em ligas menos eficientes

− Contras

  • • Requer mais dados e engenharia
  • • Risco de overfitting maior
  • • Menos interpretavel

Dito isso, Elo ainda tem seu lugar. Use-o como feature de input no XGBoost, nao como modelo final. O Elo captura a forca historica acumulada, o XGBoost captura o estado atual. Juntos funcionam muito melhor do que cada um separado.

Coletando e Preparando os Dados

Para treinar um modelo decente voce precisa de pelo menos 3 temporadas de dados. Idealmente 5-7. O Football-Data.org oferece CSV gratuito de 12 ligas europeias desde o ano 2000. Vou usar a Premier League como exemplo, mas o codigo funciona para qualquer liga disponivel.

python
# data_loader.py
import pandas as pd
import requests
from io import StringIO

def load_premier_league(seasons: list[str]) -> pd.DataFrame:
    """
    Carrega dados da Premier League do Football-Data.org
    seasons: ex ['2122', '2223', '2324', '2425']
    """
    dfs = []
    base_url = "https://www.football-data.co.uk/mmz4281"

    for season in seasons:
        url = f"{base_url}/{season}/E0.csv"
        resp = requests.get(url)
        resp.raise_for_status()
        df = pd.read_csv(StringIO(resp.text))
        df["season"] = season
        dfs.append(df)
        print(f"Carregada temporada {season}: {len(df)} jogos")

    combined = pd.concat(dfs, ignore_index=True)
    combined["Date"] = pd.to_datetime(combined["Date"], dayfirst=True)
    combined = combined.sort_values("Date").reset_index(drop=True)
    return combined

df = load_premier_league(["2021", "2122", "2223", "2324", "2425"])
print(f"Total: {len(df)} jogos de {df['Date'].min()} ate {df['Date'].max()}")
print(df.columns.tolist())

O CSV do Football-Data vem com colunas de odds das principais casas (B365H, B365D, B365A para Bet365), o que e otimo para validar o modelo contra o mercado. Mas cuidado: nunca use essas odds como feature de input. Isso cria data leakage - voce estaria usando informacao que so existe porque o mercado ja fez a previsao.

Features Que Funcionam (e Por Que)

A maioria dos tutoriais de ML para futebol usa features simples como gols marcados na temporada. Isso e fraco porque mistura jogos do inicio da temporada (poucos dados) com jogos do fim (muitos dados) sem diferenciar. Features de janela movel funcionam muito melhor.

python
# feature_engineering.py
import pandas as pd
import numpy as np

def add_rolling_features(df: pd.DataFrame, window: int = 5) -> pd.DataFrame:
    """
    Adiciona features de forma recente para casa e visitante.
    Usa expanding window para jogos iniciais (evita NaN).
    """
    df = df.copy().sort_values("Date").reset_index(drop=True)

    for team_col, prefix in [("HomeTeam", "home"), ("AwayTeam", "away")]:
        goals_col = "FTHG" if prefix == "home" else "FTAG"
        conceded_col = "FTAG" if prefix == "home" else "FTHG"

        # forma: pontos nos ultimos N jogos
        def calc_form(group, w=window):
            results = []
            for idx, row in group.iterrows():
                past = group[group.index < idx].tail(w)
                if len(past) == 0:
                    results.append(1.2)  # media neutra
                    continue
                if team_col == "HomeTeam":
                    pts = past.apply(lambda r: 3 if r["FTR"] == "H"
                                     else (1 if r["FTR"] == "D" else 0), axis=1)
                else:
                    pts = past.apply(lambda r: 3 if r["FTR"] == "A"
                                     else (1 if r["FTR"] == "D" else 0), axis=1)
                results.append(pts.mean())
            return results

        teams = df[team_col].unique()
        form_col = f"{prefix}_form_{window}"
        df[form_col] = 0.0

        for team in teams:
            mask = df[team_col] == team
            team_games = df[mask].copy()
            form_values = calc_form(team_games)
            df.loc[mask, form_col] = form_values

        # media de gols marcados e sofridos nos ultimos N
        df[f"{prefix}_avg_scored_{window}"] = (
            df.groupby(team_col)[goals_col]
            .transform(lambda x: x.shift().rolling(window, min_periods=1).mean())
        )
        df[f"{prefix}_avg_conceded_{window}"] = (
            df.groupby(team_col)[conceded_col]
            .transform(lambda x: x.shift().rolling(window, min_periods=1).mean())
        )

    # vantagem de jogar em casa (global, pode refinar por time)
    df["is_home"] = 1
    return df

df_features = add_rolling_features(df, window=5)
print(df_features[["HomeTeam", "AwayTeam", "home_form_5", "away_form_5"]].head(10))

Features Que Nao Funcionam

Odds das casas como feature: data leakage. O modelo aprende a copiar o mercado, nao a bater ele.

Classificacao na tabela: muito ruidosa no inicio da temporada e colinear com pontos.

Estatisticas de campeonatos anteriores sem decaimento temporal: times mudam muito entre temporadas.

Chutes totais sem qualidade: xG e muito superior a chutes brutos como proxy de dominio.

Construindo o Modelo com XGBoost

A parte mais critica aqui e o split temporal. Em dados de serie temporal, random split e crime. Se voce misturar jogos de 2024 no treino e testar em jogos de 2022, o modelo vai ter performance irreal porque aprendeu padroes do futuro. Sempre treine nos dados mais antigos e teste nos mais recentes.

python
# model.py
import xgboost as xgb
import pandas as pd
import numpy as np
from sklearn.metrics import log_loss, accuracy_score
from sklearn.calibration import CalibratedClassifierCV

FEATURE_COLS = [
    "home_form_5", "away_form_5",
    "home_avg_scored_5", "away_avg_scored_5",
    "home_avg_conceded_5", "away_avg_conceded_5",
    "is_home",
]

LABEL_MAP = {"H": 0, "D": 1, "A": 2}  # home, draw, away

def prepare_dataset(df: pd.DataFrame):
    df = df.dropna(subset=FEATURE_COLS + ["FTR"])
    X = df[FEATURE_COLS].values
    y = df["FTR"].map(LABEL_MAP).values
    return X, y

# split temporal: ultimas 2 temporadas como teste
cutoff = df_features["Date"].quantile(0.6)
train_df = df_features[df_features["Date"] <= cutoff]
test_df = df_features[df_features["Date"] > cutoff]

X_train, y_train = prepare_dataset(train_df)
X_test, y_test = prepare_dataset(test_df)

print(f"Treino: {len(X_train)} jogos")
print(f"Teste: {len(X_test)} jogos")

# modelo base
model = xgb.XGBClassifier(
    n_estimators=300,
    max_depth=4,
    learning_rate=0.05,
    subsample=0.8,
    colsample_bytree=0.8,
    use_label_encoder=False,
    eval_metric="mlogloss",
    random_state=42,
)

model.fit(
    X_train, y_train,
    eval_set=[(X_test, y_test)],
    verbose=50,
)

# avaliar
y_pred = model.predict(X_test)
y_proba = model.predict_proba(X_test)

print(f"Accuracy: {accuracy_score(y_test, y_pred):.3f}")
print(f"Log Loss: {log_loss(y_test, y_proba):.3f}")

Accuracy aqui parece baixa, em torno de 52-55%. Nao se assuste. A baseline de sempre chutar o time da casa e de uns 46%. Qualquer coisa acima de 50% ja e statisticamente interessante em futebol, porque a variancia e altissima. O que importa de verdade e o log loss - voce quer probabilidades bem calibradas, nao so acertos.

Calibracao de Probabilidades

XGBoost por padrao produz probabilidades pouco calibradas - quando ele diz 70%, na verdade acontece 65%. Pra value betting isso e problema serio, porque voce vai apostar em supostos edges que nao existem. Platt scaling e isotonic regression resolvem isso.

python
from sklearn.calibration import CalibratedClassifierCV, calibration_curve
import matplotlib.pyplot as plt

# calibrar com isotonic regression (melhor pra datasets maiores)
calibrated_model = CalibratedClassifierCV(
    model, method="isotonic", cv="prefit"
)
calibrated_model.fit(X_test[:len(X_test)//2], y_test[:len(y_test)//2])

# validar calibracao em dados nao vistos
y_cal_proba = calibrated_model.predict_proba(
    X_test[len(X_test)//2:]
)

# verificar calibracao para vitoria do mandante (classe 0)
frac_pos, mean_pred = calibration_curve(
    (y_test[len(y_test)//2:] == 0).astype(int),
    y_cal_proba[:, 0],
    n_bins=10
)

print("Calibracao (ideal: diagonais iguais):")
for pred, actual in zip(mean_pred, frac_pos):
    print(f"  Predito: {pred:.2f} | Real: {actual:.2f}")

print(f"Log Loss calibrado: {log_loss(y_test[len(y_test)//2:], y_cal_proba):.3f}")

Dica

A calibracao precisa de dados separados do treino E do teste. Por isso dividi o conjunto de teste ao meio no exemplo: metade para calibrar, metade para validar o modelo final. Em producao, use uma janela de dados recentes exclusivamente para calibracao.

Backtest com 5 Temporadas de Dados

Agora a parte que todo mundo quer ver: o modelo realmente faria dinheiro? Vou fazer um backtest honest usando uma estrategia simples de value betting: apostar quando a probabilidade do modelo e maior que a probabilidade implicita das odds do mercado.

python
# backtest.py
import pandas as pd
import numpy as np

def calculate_value(model_prob: float, market_odd: float) -> float:
    """Edge = (model_prob * market_odd) - 1. Positivo = value bet."""
    return (model_prob * market_odd) - 1

def backtest_strategy(
    test_df: pd.DataFrame,
    probabilities: np.ndarray,
    min_edge: float = 0.03,
    stake: float = 1.0,
) -> pd.DataFrame:
    results = []

    for idx, (_, row) in enumerate(test_df.iterrows()):
        probs = probabilities[idx]  # [p_home, p_draw, p_away]

        # odds da Bet365 (B365H, B365D, B365A)
        for outcome_idx, (prob, odd_col, result_code) in enumerate([
            (probs[0], "B365H", "H"),
            (probs[1], "B365D", "D"),
            (probs[2], "B365A", "A"),
        ]):
            if pd.isna(row.get(odd_col)):
                continue

            market_odd = row[odd_col]
            edge = calculate_value(prob, market_odd)

            if edge >= min_edge:
                won = row["FTR"] == result_code
                profit = (market_odd - 1) * stake if won else -stake
                results.append({
                    "date": row["Date"],
                    "home": row["HomeTeam"],
                    "away": row["AwayTeam"],
                    "market": result_code,
                    "model_prob": prob,
                    "market_odd": market_odd,
                    "edge": edge,
                    "won": won,
                    "profit": profit,
                })

    results_df = pd.DataFrame(results)
    if len(results_df) == 0:
        print("Nenhuma aposta encontrada com esse edge minimo")
        return results_df

    total_staked = len(results_df) * stake
    total_profit = results_df["profit"].sum()
    roi = (total_profit / total_staked) * 100
    win_rate = results_df["won"].mean() * 100

    print(f"Total de apostas: {len(results_df)}")
    print(f"Win rate: {win_rate:.1f}%")
    print(f"Lucro total: {total_profit:.2f} unidades")
    print(f"ROI: {roi:.2f}%")

    return results_df

# rodar backtest
results = backtest_strategy(test_df, y_cal_proba, min_edge=0.04)

Em Premier League, espere ROI entre 2% e 6% com edge minimo de 4%. E pouco? Para apostas esportivas, e excelente. Cassino tem house edge de 2-5% contra voce. Voce estar 3% positivo significa que inverteu o jogo. Em ligas menores com mercado menos eficiente (Championship, Serie B brasileira), ROI de 8-12% e alcancavel.

Do Backtest ao Live: Armadilhas Comuns

Backtests sempre parecem melhores do que a realidade. Existem varios motivos para isso e ignorar qualquer um deles vai fazer voce perder dinheiro real depois de um backtest promissor.

  1. Look-ahead bias: garantir que nenhuma feature usa dados que ainda nao existiam no momento do jogo
  2. Vig nao considerada: as odds ja incluem margem da casa. Reduza cada odd por ~4% no backtest para ser honesto
  3. Slippage de odds: quando voce identifica um value bet, a odd ja pode ter mudado. Sempre use odds de fechamento, nao de abertura
  4. Overfitting por ligas: um modelo que funciona em Premier pode falhar na Ligue 1. Teste em ligas que nao estiveram no treino
  5. Drawdown emocional: mesmo estrategias lucrativas podem ter 20-30 apostas perdedoras seguidas. Seu modelo esta pronto para isso?
  6. Limites de conta: casas nao deixam vencedores ganharem por muito tempo. Planeje rotacao de contas desde o inicio

O maior erro que cometo e o mais comum: testar o modelo apenas nos dados que eu ja tinha quando decidi construi-lo. Voce inevitavelmente escolhe features que funcionaram no passado. A forma honesta e deixar o modelo rodar em dados verdadeiramente novos por pelo menos 2-3 meses antes de confiar nos resultados.

Antes de apostar dinheiro real

  • Backtest com pelo menos 3 temporadas completas de dados
  • Split temporal correto: nunca random
  • Probabilidades calibradas e validadas
  • ROI calculado com vig incluida nas odds
  • Testado em pelo menos uma liga fora do conjunto de treino
  • Paper trading (apostas ficticiias) por 4+ semanas
  • Banca inicial que voce pode perder sem impacto financeiro real

Perguntas frequentes

XGBoost realmente funciona para prever futebol?

Sim, mas com ressalvas. XGBoost com boas features supera modelos lineares e o mercado de apostas amador. O desafio e nao overfittar e fazer backtest temporal correto, sem vazamento de dados futuros.

Qual e o maior erro no backtest de modelos de futebol?

Usar random train/test split. Em dados temporais, isso vaza informacao do futuro pro treino. Sempre use split temporal: treine nos dados mais antigos e teste nos mais recentes.

Quais features tem mais impacto no modelo?

xG (gols esperados), forma dos ultimos 5 jogos, media de gols sofridos/marcados, posse de bola media e se o time esta em casa. Lesoes de jogadores chave podem mudar completamente a previsao.