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.
# 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.
# 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.
# 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.
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.
# 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.
- Look-ahead bias: garantir que nenhuma feature usa dados que ainda nao existiam no momento do jogo
- Vig nao considerada: as odds ja incluem margem da casa. Reduza cada odd por ~4% no backtest para ser honesto
- Slippage de odds: quando voce identifica um value bet, a odd ja pode ter mudado. Sempre use odds de fechamento, nao de abertura
- Overfitting por ligas: um modelo que funciona em Premier pode falhar na Ligue 1. Teste em ligas que nao estiveram no treino
- Drawdown emocional: mesmo estrategias lucrativas podem ter 20-30 apostas perdedoras seguidas. Seu modelo esta pronto para isso?
- 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.