Análise Estatística de Futebol com Python:
Os três modelos estatísticos que casas de apostas usam pra precificar odds de futebol — explicados com código Python funcional e exemplos reais do Brasileirão.
Por que esses 3 modelos importam
Análise Estatística de Futebol com Python:. Os três modelos estatísticos que casas de apostas usam pra precificar odds de futebol — explicados com código Python funcional e exemplos reais do Brasileirão.
Os 3 modelos que as casas de apostas usam
Galera, se você acha que casas de apostas definem odds olhando pra torcida ou pra fase do time, está enganado. Os departamentos de trading das casas grandes como Pinnacle, Bet365 e Betfair usam modelos quantitativos pra precificar cada mercado. Os três pilares pra futebol são:
Primeiro, a distribuição de Poisson — que modela a quantidade de gols que cada time deve fazer num jogo. Segundo, o sistema ELO — que mede a força relativa de cada time com base nos resultados recentes. Terceiro, o Expected Goals (xG) — que avalia a qualidade das chances criadas, não só o resultado final.
Nenhum modelo sozinho é perfeito. A Poisson ignora contexto tático. O ELO não diferencia uma vitória dominante de uma vitória no sufoco. O xG depende de dados detalhados que nem sempre estão disponíveis. Mas combinados, eles formam uma base analítica sólida. E o melhor: dá pra implementar tudo em Python com menos de 200 linhas de código.
Poisson
Modela a probabilidade de cada placar possível baseado na média de gols.
+ Prós
- • Simples de implementar — uma fórmula
- • Funciona bem pra mercados de over/under e placar exato
- • Precisa só de média de gols como input
− Contras
- • Assume que gols são eventos independentes (não são sempre)
- • Não captura contexto tático ou emocional
- • Superestima empates em jogos de times muito desiguais
ELO Rating
Sistema de ranking que atualiza a força do time a cada resultado.
+ Prós
- • Captura momentum e forma recente
- • Ajusta automaticamente pra mandante vs visitante
- • Funciona com dados mínimos — só precisa dos resultados
− Contras
- • Não modela placares, só quem ganha
- • Sensível à escolha do K-factor
- • Lento pra reagir a mudanças bruscas de elenco
Expected Goals (xG)
Mede a qualidade das finalizações, não só a quantidade de gols.
+ Prós
- • Melhor preditor de performance futura que gols reais
- • Detecta times que estão rendendo acima ou abaixo do esperado
- • Usado por todos os grandes clubes europeus
− Contras
- • Requer dados granulares (posição do chute, ângulo, etc.)
- • Modelos de xG variam entre provedores — não existe um padrão único
- • Dados gratuitos são limitados (StatsBomb é uma exceção)
Poisson para previsão de gols
A ideia da Poisson é simples: se um time marca em média 1.5 gols por jogo em casa, qual a probabilidade de ele marcar 0, 1, 2, 3 ou mais gols? A distribuição de Poisson responde exatamente isso. É uma distribuição de probabilidade discreta que modela o número de vezes que um evento raro ocorre num intervalo fixo.
Pra futebol: o evento raro é gol, o intervalo fixo é 90 minutos. A fórmula é P(k) = (lambda^k * e^(-lambda)) / k!, onde lambda é a média de gols esperados e k é o número de gols que você quer calcular a probabilidade.
Implementação Python
import numpy as np
from scipy.stats import poisson
import pandas as pd
def calculate_attack_defense_ratings(results_df):
"""Calcula força de ataque e defesa de cada time
baseado na média de gols marcados e sofridos."""
league_avg_home = results_df['home_goals'].mean()
league_avg_away = results_df['away_goals'].mean()
home_stats = results_df.groupby('home_team').agg(
goals_scored=('home_goals', 'mean'),
goals_conceded=('away_goals', 'mean')
)
away_stats = results_df.groupby('away_team').agg(
goals_scored=('away_goals', 'mean'),
goals_conceded=('home_goals', 'mean')
)
attack_home = home_stats['goals_scored'] / league_avg_home
defense_home = home_stats['goals_conceded'] / league_avg_away
attack_away = away_stats['goals_scored'] / league_avg_away
defense_away = away_stats['goals_conceded'] / league_avg_home
return {
'attack_home': attack_home,
'defense_home': defense_home,
'attack_away': attack_away,
'defense_away': defense_away,
'league_avg_home': league_avg_home,
'league_avg_away': league_avg_away,
}
def predict_match_poisson(home_team, away_team, ratings, max_goals=6):
"""Prevê probabilidades de cada placar usando Poisson."""
lambda_home = (
ratings['attack_home'].get(home_team, 1.0)
* ratings['defense_away'].get(away_team, 1.0)
* ratings['league_avg_home']
)
lambda_away = (
ratings['attack_away'].get(away_team, 1.0)
* ratings['defense_home'].get(home_team, 1.0)
* ratings['league_avg_away']
)
# Matriz de probabilidades de cada placar
score_matrix = np.zeros((max_goals + 1, max_goals + 1))
for i in range(max_goals + 1):
for j in range(max_goals + 1):
score_matrix[i][j] = (
poisson.pmf(i, lambda_home) * poisson.pmf(j, lambda_away)
)
# Probabilidades de resultado
prob_home = np.tril(score_matrix, -1).sum()
prob_draw = np.trace(score_matrix)
prob_away = np.triu(score_matrix, 1).sum()
# Over/Under 2.5
prob_over_25 = sum(
score_matrix[i][j]
for i in range(max_goals + 1)
for j in range(max_goals + 1)
if i + j > 2
)
return {
'lambda_home': round(lambda_home, 2),
'lambda_away': round(lambda_away, 2),
'prob_home': round(prob_home * 100, 1),
'prob_draw': round(prob_draw * 100, 1),
'prob_away': round(prob_away * 100, 1),
'prob_over_25': round(prob_over_25 * 100, 1),
'fair_odd_home': round(1 / prob_home, 2) if prob_home > 0 else None,
'fair_odd_draw': round(1 / prob_draw, 2) if prob_draw > 0 else None,
'fair_odd_away': round(1 / prob_away, 2) if prob_away > 0 else None,
}
# Exemplo: Flamengo x Palmeiras
# (use dados reais do Brasileirão carregados num DataFrame)
results = pd.DataFrame({
'home_team': ['Flamengo', 'Palmeiras', 'Flamengo', 'São Paulo'],
'away_team': ['São Paulo', 'Flamengo', 'Palmeiras', 'Palmeiras'],
'home_goals': [2, 1, 3, 0],
'away_goals': [1, 1, 1, 2],
})
ratings = calculate_attack_defense_ratings(results)
pred = predict_match_poisson('Flamengo', 'Palmeiras', ratings)
print(f"Flamengo x Palmeiras")
print(f"Lambda home: {pred['lambda_home']}, Lambda away: {pred['lambda_away']}")
print(f"Vitória Fla: {pred['prob_home']}%, Empate: {pred['prob_draw']}%, Vitória Pal: {pred['prob_away']}%")
print(f"Over 2.5: {pred['prob_over_25']}%")
print(f"Fair odds: {pred['fair_odd_home']} / {pred['fair_odd_draw']} / {pred['fair_odd_away']}")As fair odds calculadas pela Poisson são as odds sem margem — a odd justa matematicamente. Se a casa oferece uma odd maior que a fair odd, você tem um value bet. Se oferece menor, a casa tem vantagem. Simples assim. Compare essas fair odds com as odds reais do mercado e você tem seu primeiro sinal de trading.
ELO adaptado para futebol
O sistema ELO foi criado pro xadrez — pra ranquear jogadores com base nos resultados dos jogos. A adaptação pro futebol funciona assim: cada time começa com um rating (geralmente 1500), e a cada jogo o rating é ajustado com base no resultado e na expectativa prévia.
Se o Flamengo, com rating 1700, ganha de um time com rating 1400, o ajuste é pequeno — o resultado era esperado. Mas se o time de rating 1400 ganha do Flamengo, o ajuste é grande — resultado surpreendente. É assim que o sistema captura a forma recente dos times.
Implementação do ELO
class EloSystem:
def __init__(self, k_factor=32, home_advantage=100):
self.ratings = {} # team_name -> rating
self.k = k_factor
self.home_adv = home_advantage
self.history = [] # pra análise posterior
def get_rating(self, team):
if team not in self.ratings:
self.ratings[team] = 1500
return self.ratings[team]
def expected_score(self, rating_a, rating_b):
"""Probabilidade esperada de vitória de A contra B."""
return 1 / (1 + 10 ** ((rating_b - rating_a) / 400))
def update(self, home_team, away_team, home_goals, away_goals):
"""Atualiza ratings com base no resultado do jogo."""
r_home = self.get_rating(home_team) + self.home_adv
r_away = self.get_rating(away_team)
exp_home = self.expected_score(r_home, r_away)
exp_away = 1 - exp_home
# Resultado real: 1 = vitória, 0.5 = empate, 0 = derrota
if home_goals > away_goals:
actual_home, actual_away = 1, 0
elif home_goals == away_goals:
actual_home, actual_away = 0.5, 0.5
else:
actual_home, actual_away = 0, 1
# Fator de margem de gols (quanto maior a goleada, maior o ajuste)
goal_diff = abs(home_goals - away_goals)
margin_factor = max(1, np.log(goal_diff + 1) + 1)
# Atualiza ratings
delta_home = self.k * margin_factor * (actual_home - exp_home)
self.ratings[home_team] = self.get_rating(home_team) + delta_home
self.ratings[away_team] = self.get_rating(away_team) - delta_home
self.history.append({
'home': home_team, 'away': away_team,
'exp_home': round(exp_home, 3),
'result': f'{home_goals}x{away_goals}',
'rating_home': round(self.ratings[home_team], 1),
'rating_away': round(self.ratings[away_team], 1),
})
def predict(self, home_team, away_team):
"""Retorna probabilidade de vitória do mandante."""
r_home = self.get_rating(home_team) + self.home_adv
r_away = self.get_rating(away_team)
prob_home = self.expected_score(r_home, r_away)
return {
'prob_home': round(prob_home * 100, 1),
'prob_away': round((1 - prob_home) * 100, 1),
'fair_odd_home': round(1 / prob_home, 2),
'fair_odd_away': round(1 / (1 - prob_home), 2),
'rating_home': round(self.get_rating(home_team), 1),
'rating_away': round(self.get_rating(away_team), 1),
}
def ranking(self):
"""Retorna ranking ordenado dos times."""
return sorted(self.ratings.items(), key=lambda x: x[1], reverse=True)
# Exemplo de uso com dados do Brasileirão
elo = EloSystem(k_factor=40, home_advantage=80)
# Processa resultados da temporada
for _, row in results.iterrows():
elo.update(row['home_team'], row['away_team'],
row['home_goals'], row['away_goals'])
# Previsão pro próximo jogo
pred = elo.predict('Flamengo', 'Palmeiras')
print(f"ELO Flamengo: {pred['rating_home']}")
print(f"ELO Palmeiras: {pred['rating_away']}")
print(f"Prob vitória Fla: {pred['prob_home']}%")O K-factor controla a velocidade de reação do sistema. K=20 é conservador — o sistema muda devagar. K=40 é mais agressivo — reage rápido a resultados recentes. Pra futebol brasileiro, K entre 30 e 40 funciona bem porque o campeonato tem viradas de forma mais bruscas que ligas europeias.
O home_advantage é um bônus fixo de ELO dado ao mandante. No futebol brasileiro, mando de campo vale bastante — algo entre 60 e 100 pontos de ELO, dependendo do estádio e da torcida. Time grande jogando no Maracanã lotado é diferente de time jogando em estádio vazio.
xG: Expected Goals explicado
O xG é a métrica que mais mudou a análise de futebol nos últimos 10 anos. A ideia: nem todo chute é igual. Um chute de dentro da pequena área, sem marcação, tem 70% de chance de virar gol. Um chute de 30 metros, com dois zagueiros na frente, tem 3%. O xG atribui um valor de probabilidade pra cada finalização baseado na posição, ângulo, tipo de passe anterior, parte do corpo usada e outros fatores.
Por que isso importa pra apostas? Porque gols são voláteis. Um time pode dominar o jogo, criar 2.5 xG de chances, e perder de 1x0 com um contra-ataque do adversário que tinha 0.3 xG. No curto prazo, isso acontece o tempo todo. Mas no longo prazo, o xG converge com os gols reais. Então se um time tem xG consistentemente alto mas poucos gols, a tendência é que a performance real se aproxime do xG — e vice-versa.
Onde conseguir dados de xG
A StatsBomb libera dados gratuitos de xG pra competições selecionadas via GitHub (statsbomb/open-data). A FBref tem xG de todas as grandes ligas europeias e do Brasileirão, acessível via scraping. A Understat tem uma API não-oficial que retorna xG por jogo pra as 5 grandes ligas. Pra começar, FBref é a melhor opção — dados mais completos e atualizados.
import requests
from bs4 import BeautifulSoup
import pandas as pd
import time
def scrape_fbref_xg(league_url, season='2025-2026'):
"""Coleta dados de xG do FBref.
Respeite o rate limit: 1 request a cada 5 segundos."""
headers = {'User-Agent': 'Mozilla/5.0 (research project)'}
response = requests.get(league_url, headers=headers)
soup = BeautifulSoup(response.text, 'html.parser')
# Tabela de stats do time
table = soup.find('table', {'id': 'stats_squads_shooting'})
if not table:
print('Tabela nao encontrada')
return pd.DataFrame()
rows = []
for tr in table.find('tbody').find_all('tr'):
cols = tr.find_all('td')
if len(cols) < 10:
continue
team_cell = tr.find('th')
rows.append({
'team': team_cell.text.strip() if team_cell else '',
'goals': int(cols[1].text.strip() or 0),
'shots': int(cols[2].text.strip() or 0),
'shots_on_target': int(cols[3].text.strip() or 0),
'xg': float(cols[7].text.strip() or 0),
'xg_per_90': float(cols[9].text.strip() or 0),
})
return pd.DataFrame(rows)
def analyze_xg_overperformance(df):
"""Identifica times com performance acima ou abaixo do xG."""
df['diff_goals_xg'] = df['goals'] - df['xg']
df['overperforming'] = df['diff_goals_xg'] > 0
print('Times com mais gols que xG (regressão provável):')
over = df[df['diff_goals_xg'] > 2].sort_values('diff_goals_xg', ascending=False)
for _, row in over.iterrows():
print(f" {row['team']}: {row['goals']} gols vs {row['xg']:.1f} xG "
f"(+{row['diff_goals_xg']:.1f})")
print('\nTimes com menos gols que xG (melhora provável):')
under = df[df['diff_goals_xg'] < -2].sort_values('diff_goals_xg')
for _, row in under.iterrows():
print(f" {row['team']}: {row['goals']} gols vs {row['xg']:.1f} xG "
f"({row['diff_goals_xg']:.1f})")Times com muitos gols acima do xG tendem a regredir — vão começar a fazer menos gols do que o ritmo atual. Times com poucos gols em relação ao xG tendem a melhorar — a qualidade das chances está lá, os gols vão aparecer. Essa análise de regressão à média é uma das formas mais confiáveis de encontrar value bets no mercado de over/under e resultado.
Ensemble: combinando os 3 modelos
Usar um modelo só é limitante. A mágica acontece quando você combina os três. A técnica mais simples é fazer uma média ponderada das probabilidades de cada modelo. Mas os pesos não devem ser iguais — eles devem refletir a precisão histórica de cada modelo.
- Rode os 3 modelos separadamente pra todos os jogos da temporada passada (backtesting)
- Calcule o Brier Score de cada modelo — quanto menor, melhor a calibração
- Use os scores invertidos como pesos: modelo mais preciso ganha peso maior
- Pra cada jogo novo, combine as probabilidades com esses pesos
- Recalibre os pesos a cada mês com dados novos — a performance dos modelos muda
from sklearn.metrics import brier_score_loss
def ensemble_predict(poisson_prob, elo_prob, xg_prob, weights=None):
"""Combina previsões dos 3 modelos com pesos."""
if weights is None:
weights = [0.35, 0.30, 0.35] # Poisson, ELO, xG
combined = (
poisson_prob * weights[0]
+ elo_prob * weights[1]
+ xg_prob * weights[2]
)
return round(combined, 3)
def calibrate_weights(predictions_df):
"""Calcula pesos com base no Brier Score histórico."""
brier_poisson = brier_score_loss(
predictions_df['actual_result'],
predictions_df['poisson_prob']
)
brier_elo = brier_score_loss(
predictions_df['actual_result'],
predictions_df['elo_prob']
)
brier_xg = brier_score_loss(
predictions_df['actual_result'],
predictions_df['xg_prob']
)
# Inverte: menor Brier = melhor = peso maior
inv_scores = [1/brier_poisson, 1/brier_elo, 1/brier_xg]
total = sum(inv_scores)
weights = [s / total for s in inv_scores]
print(f'Brier Scores -> Poisson: {brier_poisson:.4f}, '
f'ELO: {brier_elo:.4f}, xG: {brier_xg:.4f}')
print(f'Pesos calculados: {[round(w, 3) for w in weights]}')
return weights
# Exemplo de uso
# prob_home_poisson = 0.52
# prob_home_elo = 0.48
# prob_home_xg = 0.55
# combined = ensemble_predict(0.52, 0.48, 0.55)
# fair_odd = 1 / combinedO ensemble consistentemente supera qualquer modelo individual em backtests. No futebol brasileiro, pesos na faixa de 30-35% Poisson, 25-30% ELO e 35-40% xG têm dado os melhores resultados — mas isso varia por competição e temporada. A calibração periódica é o que garante que os pesos continuem fazendo sentido.
Com as fair odds do ensemble calculadas, compare com as odds do mercado usando o dashboard (artigo sobre React + Chart.js) e receba alertas quando detectar valor usando o bot de Telegram. Os três artigos juntos formam um sistema completo.
Monte seu pipeline de análise completo
Esses modelos são a base. Pra ir além, conecte com o dashboard de React pra visualizar as previsões, use o bot de Telegram pra receber alertas de value bets, e aplique gestão de banca com o Critério de Kelly pra definir quanto apostar em cada oportunidade. Cada peça do sistema potencializa as outras. A CrazyStack ensina a construir sistemas desse nível com Node.js, React e Python.