1 Introdução

Nos últimos anos, as apostas esportivas passaram a fazer parte do cotidiano no Brasil. Estão nas camisas dos clubes, nas transmissões dos jogos e nas redes sociais. Aos poucos, criou-se a ideia de que apostar pode ser uma forma de ganhar dinheiro com futebol, quase como se fosse um investimento. No entanto, a realidade estatística é menos otimista: o mercado de apostas é um sistema desenhado com uma vantagem estrutural para a casa, o que torna a busca por lucro consistente uma tarefa matematicamente improvável para a maioria dos usuários.

2 Definições e pergunta a ser respondida

Este estudo é feito com a base de dados diponibilizado gratuitamente no Github do usuário Adam Gábor link do repositório.

O conjunto de dados contém estatísticas de diversas partidas de diversas ligas de 2012 a 2024. Dentre essas estatísticas, estão as odds do site Bet365, que serão alguns dos principais objetos do estudo. Serão restringidos os jogos da primeira divisão do Campeonato Brasileiro de Futebol ne apostas nos resultados das partidas. Nesta primeira parte, o objetivo é responder a seguinte pergunta: “Dada uma determinada odd para vitória do mandante, com que frequência essa vitória realmente ocorreu?”

Alguns conceitos são retirados do site do jornal Lance!.

Serão analisadas as Odds contra o desempenho real dos clubes.

Odds: “Basicamente, elas indicam exatamente quanto o apostador pode ganhar a cada real apostado. Além disso, as odds apontam uma relação inversa com a probabilidade.”

De uma forma prática, uma odd de 2.0 indica um retorno de duas vezes o valor apostado e de 50% de probabilidade do evento ocorrer (1/2 = 0,5). É o que chamamos de Probabilidade Implícita. Assim, quanto maior a odd, maior o retorno para o apostador, mas também é menos provável que o evento apostado ocorra.

3 Desenvolvimento

3.1 Limpeza de Dados

Anteriormente, foi feito o carregamento das Bibliotecas do tidyverse utilizadas para manipulação e visualização dos dados e do pacote gt para criação das tabelas. Além disso, vamos carregar o arquivo Matches.csv.

Observação: clique em “Show” para ver o código utilizado, caso desejar.

# Carregamento e número de linhas e colunas do conjunto de dados

Matches <- read.csv("~/Matches.csv", header = TRUE)

cat("O dataset original possui", nrow(Matches), "linhas (jogos) e", ncol(Matches), "colunas (variáveis).")
## O dataset original possui 230557 linhas (jogos) e 48 colunas (variáveis).

A primeira coluna é chamada de Division, ela indica a qual campeonato a partida se refere. Queremos apenas analisar partidas do campeonato brasileiro.

# Códigos dos campeonatos do conjunto de dados

Matches$Division %>% 
  unique() 
##  [1] "F1"  "F2"  "T1"  "D1"  "D2"  "B1"  "E2"  "E1"  "N1"  "P1"  "E0"  "I2" 
## [13] "SP2" "SP1" "I1"  "E3"  "SC0" "SC1" "SC2" "SC3" "G1"  "EC"  "USA" "SWE"
## [25] "NOR" "IRL" "BRA" "ARG" "MEX" "JAP" "RUS" "POL" "DEN" "ROM" "AUT" "SUI"
## [37] "FIN" "CHN"

Assim, as partidas do Campeonato Brasileiro são identificadas como “BRA” nesta variável. Além disso, são 48 colunas e não iremos utilizar todas. Por isso, vamos filtrar o Brasileirão e selecionar as seguintes colunas:

  • Data <- MatchDate: dia em que o jogo ocorreu;
  • Mandante <- HomeTeam: Clube que jogou em casa;
  • Visitante <- AwayTeam: Clube visitante;
  • GolsMandante <- FTHome: Gols marcados pelo time da casa ao final do jogo;
  • GolsVisitante <- FTAway: Gols marcados pela equipe visitante ao final do jogo;
  • Resultado <- FTResult: Resultado final, com as seguintes possibilidades “H” vitória do time da casa, “A” vitória do visitante ou “D” empate;
  • OddMandante <- OddHome: Odd da vitória do time mandante no site da Bet365;
  • OddEmpate <- OddDraw: Odd do empate no site da Bet365;
  • OddVisitante <- OddAway: Odd da vitória do time visitante no site da Bet365.

As descrições das demais variáveis estão na página repositório

# Filtro e renomeação das colunas que vamos trabalhar

brasileirao <- Matches %>% 
  filter(Division == "BRA") %>% 
  select(Data = MatchDate,
         Mandante = HomeTeam,
         Visitante = AwayTeam,
         GolsMandante = FTHome,
         GolsVisitante = FTAway,
         Resultado = FTResult,
         OddMandante = OddHome,
         OddEmpate = OddDraw,
         OddVisitante = OddAway
         )

# Visualização das primeiras linhas da tabela selecionada

brasileirao %>%
  head() %>% 
  kable()
Data Mandante Visitante GolsMandante GolsVisitante Resultado OddMandante OddEmpate OddVisitante
2012-01-07 Bahia Internacional 1 1 D 2.90 3.16 2.38
2012-01-07 Coritiba Sport Recife 2 3 A 1.66 3.58 5.01
2012-01-07 Portuguesa Santos 0 0 D 3.52 3.32 2.02
2012-01-07 Flamengo RJ Atletico GO 3 2 H 1.59 3.71 5.44
2012-01-07 Gremio Atletico-MG 0 1 A 1.93 3.31 3.80
2012-01-07 Palmeiras Figueirense 3 1 H 2.00 3.28 3.64
# Visão geral das variáveis

brasileirao %>%
  summary()
##      Data             Mandante          Visitante          GolsMandante  
##  Length:4850        Length:4850        Length:4850        Min.   :0.000  
##  Class :character   Class :character   Class :character   1st Qu.:1.000  
##  Mode  :character   Mode  :character   Mode  :character   Median :1.000  
##                                                           Mean   :1.414  
##                                                           3rd Qu.:2.000  
##                                                           Max.   :7.000  
##                                                           NA's   :1      
##  GolsVisitante     Resultado          OddMandante       OddEmpate     
##  Min.   :0.0000   Length:4850        Min.   : 1.070   Min.   : 2.510  
##  1st Qu.:0.0000   Class :character   1st Qu.: 1.670   1st Qu.: 3.150  
##  Median :1.0000   Mode  :character   Median : 2.030   Median : 3.300  
##  Mean   :0.9602                      Mean   : 2.231   Mean   : 3.485  
##  3rd Qu.:2.0000                      3rd Qu.: 2.530   3rd Qu.: 3.660  
##  Max.   :6.0000                      Max.   :20.230   Max.   :10.820  
##  NA's   :1                                                            
##   OddVisitante   
##  Min.   : 1.150  
##  1st Qu.: 2.860  
##  Median : 3.755  
##  Mean   : 4.327  
##  3rd Qu.: 5.280  
##  Max.   :26.580  
## 

Pelo resumo temos que:

  • A variável data está como texto e precisa ser ajustada para a manipulação dos dados;
  • A variável resultado pode ser melhorada para facilitar futuramente, vamos ajustar para factor;
  • Quando olhamos os gols marcados entre mandantes e visitantes, temos semelhanças na mediana, primeiro e terceito quartil. A média de gols do mandate é maior que a do visitante, o que é esperado porque no futebol se considera uma vantagem ser mandante.
  • Nas Odds, temos valores menores para o mandante (cerca de 50% de probabilidade implícita), seguidos pelo empate (cerca de 30% de probabilidade implícita) e o maiores valores de odds para o visitante (cerca de 25% de probabilidade implícita em média). Como apontado na introdução, a soma da probabilidade implícita média ser maior que 100% é o que garante a margem de lucro da empresa ao longo do tempo. Para essas considerações, foram consideradas as medianas, uma vez que as três odds têm médias maiores que as medianas, o que é sinal de outliers com odds muito altas, o que é populamente chamado de zebra no meio do futebol.

Vamos verificar qual partida é a que tem dado faltante em GolsMandante.

# Verificação do jogo com dado faltante

brasileirao %>% 
  filter(is.na(GolsMandante)) %>% 
  kable()
Data Mandante Visitante GolsMandante GolsVisitante Resultado OddMandante OddEmpate OddVisitante
2016-11-12 Chapecoense-SC Atletico-MG NA NA 2.85 3.3 2.67

O jogo é Chapecoense contra Atlético Mineiro, pelo Brasileirão de 2026. Ao pesquisar o resultado para preencher os dados, foi encontrado que o jogo foi registrado como duplo W.O. após o trágico acidente aéreo envolvendo a equipe catarinense. Como é um caso de duplo W.O., preencher como um empate não é correto, assim, vamos apenas remover este registro.

Esse é um exemplo de como dados carregam contexto. Muitas vezes, um valor ausente não é apenas um problema técnico de preenchimento, mas representa um evento histórico.

# Remoção do jogo Chapecoense vs Atlético MG em 2016 (único registro faltante em GolsMandante)

brasileirao <- brasileirao %>% 
  filter(!is.na(GolsMandante))

Além do dado faltante, também foi possível verificar que a coluna “Data” está no formato char que não ajuda muito na manipulação dos dados. Assim, vamos utilizar ferramentas do pacote lubridate (incluído no tidyverse).

# Conversão de char para date

brasileirao <- brasileirao %>%
  mutate(Data = ymd(Data))

Também vamos ajustar a variável Resultado de char para factor para facilitar nas futuras análises.

# Ajuste de Resultado como factor

brasileirao <- brasileirao %>%
  mutate(Resultado = factor(case_when(
    Resultado == "H" ~ "Vitória",
    Resultado == "D" ~ "Empate",
    Resultado == "A" ~ "Derrota"
  ), levels = c("Vitória", "Empate", "Derrota")))

4 Análise Exploratória de Dados

4.1 Número de jogos por ano

Nos anos analisados (de 2012 a 2024) o Campeonato Brasileiro seguiu o mesmo formato de disputa: pontos corridos com 20 participantes, com jogos de ida e volta. Ou seja, cada clube enfrenta 19 adversários duas vezes. Assim, cada campeonato tem 38 rodadas com 10 jogos por rodada, portanto 380 jogos no total. Deveríamos ter 4940 partidas.

No entanto, temos 4849 registros (já considerando o jogo removido na seção anterior).

# Parâmetros gráficos

theme_set(
  theme_minimal() +
    theme(
      plot.title = element_text(size = 18, face = "bold", hjust = 0.5, color = "#1B4F72"),
      plot.subtitle = element_text(size = 11, hjust = 0.5, color = "#566573", margin = margin(b = 20)),
      axis.line.x = element_line(color = "#2C3E50", size = 0.8),
      axis.text = element_text(color = "#2C3E50"),
      panel.grid.major = element_blank(),
      panel.grid.minor = element_blank(),
      plot.margin = unit(c(1, 1, 1, 1), "cm"),
      legend.position = "bottom"
    )
)


Cor1 <- "#1B4F72"
Cor2 <- "#2E86C1"
Cor3 <- "#AED6F1"

#  Número de jogos por ano

JogosAno <- brasileirao %>%
  count(Ano = year(Data)) %>% 
  mutate(Ano = factor(Ano))


ggplot(JogosAno, aes(x = n, y = Ano)) +
  geom_col(fill = Cor2, width = 0.7) +
  geom_text(aes(label = n), 
            hjust = -0.3, 
            size = 4, 
            fontface = "bold", 
            color = "#2C3E50") +
  scale_x_continuous(expand = expansion(mult = c(0, 0.15))) +
  labs(title = "Quantidade de Jogos por Ano",
       x = "Número de Jogos",
       y = NULL) +
   theme_minimal() +
  theme(
    plot.title = element_text(size = 18, face = "bold", hjust = 0.5, color = "#1B4F72"),
    plot.subtitle = element_text(size = 11, hjust = 0.5, color = "#566573", margin = margin(b = 20)),
    axis.line.y = element_line(color = "#2C3E50", size = 0.8),
    axis.text.y = element_text(size = 10, face = "bold", color = "#2C3E50"),
    axis.text.x = element_blank(),
    panel.grid.major = element_blank(),
    panel.grid.minor = element_blank(),
    plot.margin = unit(c(1, 1, 1, 1), "cm")
  )

Assim, devemos considerar que:

  • Em 2016 houve uma partida com W.O. que foi removida;
  • Em 2020 muitos jogos foram remarcados devido à Pandemia de Coronavírus e disputados em 2021 (560 jogos disputados se somarmos os dois anos);
  • O conjunto de dados não registra todos os jogos do campeonato de 2024, o que pode causar distorções em análises por ano.

4.2 Taxa de vitórias do Mandante

# Preparação dos dados

WinrateMandante <- brasileirao %>%
  drop_na() %>% 
  count(Resultado) %>%
  mutate(Percentual = (n / sum(n)) * 100)

# Gráfico Winrate do Mandante

ggplot(WinrateMandante, aes(x = Resultado, y = Percentual, fill = Resultado)) +
  geom_col(width = 0.7, color = "white", size = 0.5) + # Borda branca sutil entre as barras
  geom_text(aes(label = paste0(round(Percentual, 1), "%")), 
            vjust = -0.8, 
            size = 5, 
            fontface = "bold", 
            color = "#2C3E50") +
  scale_fill_manual(values = c("Vitória" = Cor1, 
                               "Empate" = Cor2, 
                               "Derrota" = Cor3)) +
  labs(title = "Desempenho dos Mandantes",
       x = NULL, 
       y = "Porcentagem dos Jogos") +
  scale_y_continuous(limits = c(0, max(WinrateMandante$Percentual) + 10),
                     breaks = seq(0, 100, 10)) +
  theme(axis.text.y = element_blank(),
        legend.position = "none")

Quase metade dos jogos são vencidos de fatos com os mandantes, o número dos outros resultados está bastante próximo. Jogar em casa é de fato relevante para tentar prever o resultado da partida. Vamos comparar essas média observadas com as probabilidades implícitas da mediana (uma vez que as médias são influenciadas pelas odds altas), vistas na seção anterior:

# Winrate em Tabela

tabela_winrate <- data.frame(
  Resultado = c("Vitória", "Empate", "Derrota"),
  Observado = c("48,5%", "26,9%", "24,6%"),
  Odd = c("49,2%", "30,3%", "26,6%")
)

tabela_winrate %>%
  gt() %>%
  cols_label(
    Resultado = "Resultado do Mandante",
    Observado = "Taxa de Vitória Observada",
    Odd = "Mediana da Probabilidade Implícita"
  ) %>%
  tab_style(
    style = list(
      cell_fill(color = "#1B4F72"),
      cell_text(color = "white", weight = "bold")
    ),
    locations = cells_column_labels()
  ) %>%
  tab_options(
    table.width = pct(80),
    heading.title.font.size = px(22),
    column_labels.background.color = "#1B4F72",
    table.border.top.color = "transparent",
    table.border.bottom.color = "#1B4F72",
    heading.align = "center"
  ) %>%
  cols_align(
    align = "center",
    columns = everything()
  ) %>%
  opt_row_striping()
Resultado do Mandante Taxa de Vitória Observada Mediana da Probabilidade Implícita
Vitória 48,5% 49,2%
Empate 26,9% 30,3%
Derrota 24,6% 26,6%

Como esperado, todas as medianas das Probabilidades Implícitas são maiores que as médias observadas. No entanto, a maior diferença entre as comparações está no empate, ou seja, é neste cenário que a casa de apostas aplica sua maior margem de segurança, indicando que o mercado de empates é o menos eficiente para o apostador e o mais lucrativo para a banca.

4.3 Melhores Clubes Mandantes e Visitantes

Vamos visualizar quais os clubes melhor exercem o seu mando de campo e quais são os visitantes mais incômodos no período analisado.

# Melhores mandantes.Auxiliar e gráfico.

top_10_mandantes <- brasileirao %>%
  drop_na() %>%
  group_by(Mandante) %>%
  summarise(
    TotalJogos = n(),
    VitoriasCasa = sum(Resultado == "Vitória"),
    PercentualVitoria = (VitoriasCasa / TotalJogos) * 100
  ) %>%
  slice_max(PercentualVitoria, n = 10) 


ggplot(top_10_mandantes, aes(x = reorder(Mandante, PercentualVitoria), y = PercentualVitoria)) +
  geom_col(fill = Cor2, width = 0.7) + 
  coord_flip() +
  geom_text(aes(label = paste0(round(PercentualVitoria, 1), "%")), 
            hjust = -0.2, 
            size = 4.5, 
            fontface = "bold", 
            color = "#2C3E50") +
  labs(title = "Top 10 Mandantes do Brasileirão (2012 - 2024)",
       subtitle = "Times com maior percentual de vitória jogando em seus domínios",
       x = NULL, 
       y = "Percentual de Vitória em Casa") +
  scale_y_continuous(expand = expansion(mult = c(0, 0.15))) 

# Melhores visitantes. Auxiliar e gráfico.

top_10_visitantes <- brasileirao %>%
  drop_na() %>%
  group_by(Visitante) %>%
  summarise(
    TotalJogos = n(),
    VitoriasFora = sum(Resultado == "Derrota"),
    PercentualVitoria = (VitoriasFora / TotalJogos) * 100
  ) %>%
  slice_max(PercentualVitoria, n = 10) 


ggplot(top_10_visitantes, aes(x = reorder(Visitante, PercentualVitoria), y = PercentualVitoria)) +
  geom_col(fill = Cor2, width = 0.7) + 
  coord_flip() +
  geom_text(aes(label = paste0(round(PercentualVitoria, 1), "%")), 
            hjust = -0.2, 
            size = 4.5, 
            fontface = "bold", 
            color = "#2C3E50") +
  labs(title = "Top 10 Visitantes do Brasileirão (2012 - 2024)",
       subtitle = "Times com maior percentual de vitória jogando fora de casa",
       x = NULL, 
       y = "Percentual de Vitória Fora de Casa") +
  scale_y_continuous(expand = expansion(mult = c(0, 0.15))) 

Assim, considerando todo o período, os melhores visitantes venceram entre cerca de 50% a 60%. Já os melhores visitantes venceram entre cerca de 25% a 40% dos jogos fora de casa. Isso é um outro exemplo do peso do “Fator Casa”, mesmo entre os melhores clubes do país.

4.4 Favoritismo

Vamos considerar que o favorito segundo a casa de aposta é o resultado com menor odd e, portanto, maior probabilidade implícita.

# Vamos tratar o caso que a menor odd pode ser igual para dois resultados

df_favoritos <- brasileirao %>%
  drop_na() %>%
  mutate(
    MenorOdd = pmin(OddMandante, OddEmpate, OddVisitante),
    QtdMinimos = (OddMandante == MenorOdd) + (OddEmpate == MenorOdd) + (OddVisitante == MenorOdd)
  ) %>%
  filter(QtdMinimos == 1) %>%
  mutate(
    Favorito = case_when(
      OddMandante == MenorOdd ~ "Vitória",
      OddEmpate == MenorOdd ~ "Empate",
      OddVisitante == MenorOdd ~ "Derrota"
    ),
    Favorito = factor(Favorito, levels = c("Vitória", "Empate", "Derrota")),
    FavoritoVenceu = (Favorito == Resultado)
  )


resumo_favoritos <- df_favoritos %>%
  group_by(Favorito) %>%
  summarise(
    Total_Jogos = n(),
    Vitorias_Favorito = sum(FavoritoVenceu),
    Taxa_Acerto = (Vitorias_Favorito / Total_Jogos) * 100
  ) %>%
  mutate(Distribuicao_Favorito = (Total_Jogos / sum(Total_Jogos)) * 100,
         Favorito = case_when(
           Favorito == "Vitória" ~ "Mandante",
           Favorito == "Empate" ~ "Empate",
           Favorito == "Derrota" ~ "Visitante"
         ))


# Tabela

taxa_geral <- (sum(resumo_favoritos$Vitorias_Favorito) / sum(resumo_favoritos$Total_Jogos)) * 100

paste0("De um modo geral,", formatC(taxa_geral, format = "f", digits = 1, decimal.mark = ","), "% dos favoritos vencem seus jogos.")
## [1] "De um modo geral,51,3% dos favoritos vencem seus jogos."
resumo_favoritos %>%
  select(Favorito, Total_Jogos, Distribuicao_Favorito, Taxa_Acerto) %>%
  gt() %>%
  fmt_integer(columns = Total_Jogos) %>%
  fmt_number(
    columns = c(Distribuicao_Favorito, Taxa_Acerto),
    decimals = 1,
    dec_mark = ",",
    pattern = "{x}%"
  ) %>%
  cols_label(
    Favorito = "Favorito",
    Total_Jogos = "Nº de Partidas",
    Distribuicao_Favorito = "% Vezes Favorito",
    Taxa_Acerto = "Taxa de Vitória Real"
  ) %>%
  tab_style(
    style = list(
      cell_fill(color = "#1B4F72"),
      cell_text(color = "white", weight = "bold")
    ),
    locations = cells_column_labels()
  ) %>%
  tab_options(
    table.width = pct(80),
    column_labels.background.color = "#1B4F72",
    table.border.top.color = "transparent",
    table.border.bottom.color = "#1B4F72",
    heading.align = "center"
  ) %>%
  cols_align(
    align = "center",
    columns = everything()
  ) %>%
  opt_row_striping()
Favorito Nº de Partidas % Vezes Favorito Taxa de Vitória Real
Mandante 3,875 80,1% 53,6%
Empate 2 0,0% 50,0%
Visitante 963 19,9% 42,2%

Nesta divisão de favoritismo, foram desconsidarados os jogos que tinham dois ou mais resultados com a mesma odd.

Com esses resultados, temos que:

  • De um modo, geral em pouco mais da metade dos jogos, o resultado com menor odd e maior probabilidade implícita aconteceu. No entanto, o negócio da casa de aposta não é acertar o ganhador, mas trabalhar com a margem e volumes das apostas ao longo do tempo;
  • A casa de aposta considera o time mandante Favorito em 4 a cada 5 partidas, mas vimos que nessas partidas o mandante venceu 48,5% dos jogos;
  • Nas cerca de 20% de vezes em que o time visitante foi considerado favorito, em menos da metade dos jogos esse favoritismo se confirmou;
  • O empate quase nunca é considerada como maior probabilidade implícita. No entanto, vimos que cerca de 1/4 dos jogos acabaram empatados. Isso mostra que a empresa prioriza o ajuste contra os resultados mais apostados em detrimento da frequência real dos eventos.

4.5 Distribuição das Odds

# Preparação

odds_longo <- brasileirao %>%
  select(OddMandante, OddEmpate, OddVisitante) %>%
  pivot_longer(everything(), names_to = "Tipo_Odd", values_to = "Valor") %>%
  mutate(Tipo_Odd = case_when(
    Tipo_Odd == "OddMandante" ~ "Mandante",
    Tipo_Odd == "OddEmpate" ~ "Empate",
    Tipo_Odd == "OddVisitante" ~ "Visitante"
  ),
  Tipo_Odd = factor(Tipo_Odd, levels = c("Mandante", "Empate", "Visitante")))

# Gráfico Boxplot com Outliers

ggplot(odds_longo, aes(x = Tipo_Odd, y = Valor, fill = Tipo_Odd)) +
  geom_boxplot(staplewidth = 0.5, 
               linewidth = 0.7, 
               outlier.color = "#E74C3C", 
               outlier.alpha = 0.4,
               outlier.shape = 16) +
  stat_summary(fun = mean, geom = "point", shape = 18, size = 3, color = "white") +
  scale_fill_manual(values = c("Mandante" = Cor1, "Empate" = Cor2, "Visitante" = Cor3)) +
  labs(title = "Dispersão e Outliers das Odds",
       x = NULL,
       y = "Valor da Odd") +
  theme(
    legend.position = "none",
    axis.line.y = element_line(color = "#2C3E50", size = 0.8),
    axis.line.x = element_line(color = "#2C3E50", size = 0.8)
  )

Como era esperado, pela diferença de valores entre média e mediana, temos vários outliers à direita. Vamos repetir este gráfico sem os outilers. Mas antes vamos ver os jogos das 5 maiores odds de cada resultado, por curiosidade.

# Seleção das maiores odds de cada caso

top_odd_mandante <- brasileirao %>%
  mutate(Ano = year(Data)) %>%
  slice_max(OddMandante, n = 5, with_ties = FALSE) %>%
  mutate(Categoria = "Maiores Odds Mandante")

top_odd_empate <- brasileirao %>%
  mutate(Ano = year(Data)) %>%
  slice_max(OddEmpate, n = 5, with_ties = FALSE) %>%
  mutate(Categoria = "Maiores Odds Empate")

top_odd_visitante <- brasileirao %>%
  mutate(Ano = year(Data)) %>%
  slice_max(OddVisitante, n = 5, with_ties = FALSE) %>%
  mutate(Categoria = "Maiores Odds Visitante")


tabela_outliers <- bind_rows(top_odd_mandante, top_odd_empate, top_odd_visitante)

# Tabela de maiores odds

tabela_outliers %>%
  select(Categoria, Mandante, Visitante, Ano, GolsMandante, GolsVisitante, OddMandante, OddEmpate, OddVisitante) %>%
  gt(groupname_col = "Categoria") %>%
  cols_label(
    GolsMandante = "Gols (M)",
    GolsVisitante = "Gols (V)",
    OddMandante = "Odd M",
    OddEmpate = "Odd E",
    OddVisitante = "Odd V"
  ) %>%
  tab_style(
    style = list(cell_fill(color = "#F4F6F7"), cell_text(weight = "bold")),
    locations = cells_row_groups()
  ) %>%
  tab_options(
    table.width = pct(100),
    column_labels.background.color = "#1B4F72",
    column_labels.font.weight = "bold"
  ) %>%
  cols_align(align = "center", columns = everything()) %>%
  opt_row_striping()
Mandante Visitante Ano Gols (M) Gols (V) Odd M Odd E Odd V
Maiores Odds Mandante
Parana Palmeiras 2018 1 1 20.23 6.98 1.15
CSA Flamengo RJ 2019 0 2 11.74 5.64 1.24
Sport Recife Flamengo RJ 2021 0 3 8.36 5.06 1.34
Goias Sao Paulo 2020 0 3 8.28 4.78 1.36
Chapecoense-SC Flamengo RJ 2021 2 2 8.18 4.87 1.36
Maiores Odds Empate
Flamengo RJ CSA 2019 1 0 1.07 10.82 26.58
Flamengo RJ Avai 2019 6 1 1.10 9.53 21.17
Flamengo RJ Avai 2022 1 2 1.16 7.56 16.07
Palmeiras Avai 2022 3 0 1.16 7.47 17.28
Gremio CSA 2019 2 1 1.15 7.37 17.73
Maiores Odds Visitante
Flamengo RJ CSA 2019 1 0 1.07 10.82 26.58
Flamengo RJ Avai 2019 6 1 1.10 9.53 21.17
Gremio CSA 2019 2 1 1.15 7.37 17.73
Palmeiras Avai 2022 3 0 1.16 7.47 17.28
Palmeiras Chapecoense-SC 2019 1 0 1.16 6.89 17.20

Dentre esses 15 jogos, a única grande “zebra” de fato foi a vitória do visitante em Flamengo x Avaí, com 6,2% de probabilidade implícita.

Agora, vamos retirar os outliers e repetir o gráfico de boxplots.

# Gráfico de Boxplots sem Outliers

ggplot(odds_longo, aes(x = Tipo_Odd, y = Valor, fill = Tipo_Odd)) +
  geom_boxplot(staplewidth = 0.5, 
               linewidth = 0.7, 
               outlier.alpha = 0.4,
               outlier.shape = NA) +
  coord_cartesian(ylim = c(0,9)) +
  stat_summary(fun = mean, geom = "point", shape = 18, size = 3, color = "white") +
  scale_fill_manual(values = c("Mandante" = Cor1, "Empate" = Cor2, "Visitante" = Cor3)) +
  labs(title = "Dispersão das Odds sem Outliers",
       x = NULL,
       y = "Valor da Odd") +
    theme(
    legend.position = "none",
    axis.line.x = element_line(color = "#2C3E50", size = 0.8),
    axis.line.y = element_line(color = "#2C3E50", size = 0.8)
  )

Em conssonância com que foi visto até agora, temos uma variação maior nas odds dos visitantes. Esse fato reflete uma tática de mercado: embora o empate ocorra quase com a mesma frequência que a vitória do visitante, ele raramente é o alvo do grande volume de apostas. A casa de apostas oferece odds mais agressivas para o time visitante para atrair o interesse do público, que vê na vitória fora de casa uma oportunidade de ganho mais clara e emocionante do que no empate, ainda que o risco real seja estatisticamente equivalente.

A análise da pergunta principal será feita apenas sob a ótica da vitória do mandante. Assim, vamos plotar apenas o histograma destas odds.

ggplot(brasileirao, aes(x = OddMandante)) +
  geom_histogram(aes(y = after_stat(density)), 
                 binwidth = 1, 
                 fill = Cor3, 
                 color = "white", 
                 alpha = 0.9) +
  labs(
    title = "Histograma das Odds dos Mandantes",
    x = "Odd do Mandante",
    y = "Densidade"
  ) 


Como já havíamos visto, a maior parte dos dados está nas odds entre 1.25 e 2.50 (entre 40% e 80% de probabilidade implícita de vitória do mandante).

5 Calibração das Odds dos Mandantes

Entramos no conceito central desta análise: a calibração. Em Estatística, dizemos que um modelo está bem calibrado quando a probabilidade prevista para um evento converge para a frequência real observada no longo prazo. Por exemplo, se uma casa de apostas define diversas partidas com uma probabilidade de vitória de 70%, espera-se que o time mandante vença de fato aproximadamente 70% dessas partidas.

Assim, a calibração é a métrica que nos diz o quão precisa é a precificação do mercado. No contexto das apostas esportivas, uma calibração perfeita raramente ocorre devido à margem de lucro da casa de apostas, mas a tendência dos dados deve seguir uma linha lógica. Se a frequência real de vitórias for sistematicamente muito menor do que a probabilidade indicada pela Odd, temos uma evidência clara de um mercado ineficiente para o apostador, mas lucrativa para a casa.

Dessa forma, para responder à pergunta norteadora deste projeto, agrupamos as partidas em faixas de probabilidade de 10% em 10%. O objetivo é confrontar a média da probabilidade vendida pela Bet365 com a taxa de sucesso real dos clubes mandantes em cada uma dessas faixas, conforme apresentado na tabela e no gráfico a seguir:

# Separação por faixas de probabilidade

faixas_prob <- brasileirao %>%
  drop_na(OddMandante, Resultado) %>%
  mutate(
    Prob_Implicita = (1 / OddMandante) * 100,
    Faixa_Prob = cut(Prob_Implicita, 
                     breaks = seq(0, 100, by = 10),
                     include.lowest = TRUE,
                     labels = c("0-10%", "10-20%", "20-30%", "30-40%", "40-50%", 
                                "50-60%", "60-70%", "70-80%", "80-90%", "90-100%")
    ),
    VitoriaMandante = (Resultado == "Vitória")
  ) %>%
  drop_na(Faixa_Prob)


resumo_faixas <- faixas_prob %>%
  group_by(Faixa_Prob) %>%
  summarise(
    Jogos = n(),
    Vitorias = sum(VitoriaMandante),
    Prob_Observada = mean(VitoriaMandante),
    Prob_Implicita_Media = mean(1 / OddMandante)
  ) %>%
  mutate(
    Diferenca = Prob_Observada - Prob_Implicita_Media
  )

resumo_faixas %>%
  mutate(
    Prob_Observada = Prob_Observada * 100,
    Prob_Implicita_Media = Prob_Implicita_Media * 100,
    Diferenca = Diferenca * 100
  ) %>%
  gt() %>%
  fmt_integer(columns = c(Jogos, Vitorias)) %>%
  fmt_number(
    columns = c(Prob_Observada, Prob_Implicita_Media, Diferenca),
    decimals = 1,
    dec_mark = ",",
    pattern = "{x}%"
  ) %>%
  cols_label(
    Faixa_Prob = "Faixa da Odd (Mandante)",
    Jogos = "Nº de Jogos",
    Vitorias = "Vitórias do Mandante",
    Prob_Observada = "Probabilidade Observada",
    Prob_Implicita_Media = "Probabilidade Implícita Média",
    Diferenca = "Diferença (Obs - Impl)"
  ) %>%
  tab_style(
    style = list(
      cell_fill(color = "#1B4F72"),
      cell_text(color = "white", weight = "bold")
    ),
    locations = cells_column_labels()
  ) %>%
  tab_options(
    table.width = pct(100),
    column_labels.background.color = "#1B4F72",
    table.border.top.color = "transparent",
    table.border.bottom.color = "#1B4F72"
  ) %>%
  cols_align(align = "center", columns = everything()) %>%
  opt_row_striping()
Faixa da Odd (Mandante) Nº de Jogos Vitórias do Mandante Probabilidade Observada Probabilidade Implícita Média Diferença (Obs - Impl)
0-10% 2 0 0,0% 6,7% −6,7%
10-20% 61 5 8,2% 16,4% −8,2%
20-30% 330 70 21,2% 25,8% −4,6%
30-40% 893 310 34,7% 35,6% −0,9%
40-50% 1,254 572 45,6% 45,2% 0,5%
50-60% 1,098 588 53,6% 55,0% −1,5%
60-70% 836 529 63,3% 64,5% −1,3%
70-80% 329 241 73,3% 74,0% −0,7%
80-90% 44 37 84,1% 82,9% 1,2%
90-100% 2 2 100,0% 92,2% 7,8%
# Gráfico de Calibração

ggplot(resumo_faixas, aes(x = Prob_Implicita_Media, y = Prob_Observada)) +
  geom_point(size = 2, color = Cor2) +
  geom_abline(slope = 1, intercept = 0, linetype = 3) +
  labs(
    title = "Calibração das Odds do Mandante",
    x = "Probabilidade Implícita Média",
    y = "Taxa de Vitória Observada"
  ) +
  theme(
    axis.line.x = element_line(color = "#2C3E50", size = 0.8),
    axis.line.y = element_line(color = "#2C3E50", size = 0.8)
    )


Com esses resultados, chegamos à conclusão que:

  • As probabilidades implícitas e taxa de vitória do mandante estão bem calibradas na grande maioria das faixas de probabilidade;
  • Nas faixas em que está a grande massa dos dados (de 30% a 70%) a diferença é menor que 2%, para mais ou para menos;
  • Para análise, vamos desconsiderar a primeira e última faixa de probabilidade implícita por ter apenas 2 jogos cada;
  • Apesar de acontecerem poucas situações (1,25% dos jogos), a faixa de 10-20% se mostrou a mais lucrativa para a Bet365, uma vez que teve a maior diferença negativa.

Na verdade, para considerar a margem de lucro da casa, devemos considerar não só apenas a odd do Mandante, mas de todos os resultados. Assim, vamos ver como a margem da Bet365 com os jogos do Brasileirão se comportou ao longo dos anos.

# Cálculo da mragem total

df_margem <- brasileirao %>%
  mutate(
    Soma_Probs = (1/OddMandante + 1/OddEmpate + 1/OddVisitante) * 100,
    Margem = Soma_Probs - 100,
    Ano = year(Data)
  )


evolucao_margem <- df_margem %>%
  group_by(Ano) %>%
  summarise(Margem_Media = mean(Margem)) %>%
  filter(Ano >= 2012 & Ano <= 2024)

ggplot(evolucao_margem, aes(x = Ano, y = Margem_Media)) +
  geom_line(color = Cor3) +
  geom_point(color = Cor1, size = 2) +
  labs(
    title = "Evolução Margem da Bet365 nos jogos do Brasileirão",
    x = "Ano",
    y = "Margem (%)"
  ) +
  geom_text(aes(label = paste0(round(Margem_Media, 1), "%")),
            vjust = -0.8,
            size = 3, 
            fontface = "bold", 
            color = "#2C3E50") +
  scale_x_continuous(breaks = 2012:2024) +
  coord_cartesian(ylim = c(0, 10)) +
  theme(
    axis.text.y = element_blank()
  )


Assim, percebemos que a margem real é superior à da obtida apenas analisando as odds dos mandantes. Além disso, há uma tendência de queda dessa margem ao longo dos anos.

6 Conclusão

6.1 Discussão dos resultados

  • É importante lembrar que a modalidade de aposta analisada não é a única. Neste próprio dataset há outras variáveis que são objeto de aposta como número de cartões, escanteios e gols marcados, por exemplo. Assim, a margem calculada aqui não é a real dessa casa de aposta, mas apenas considerando o objeto deste estudo;

  • Quase metade dos jogos termina com vitória do mandante, e isso se reflete nas odds, uma vez o mandante tem maior probabilidade implícita maioria das partidas. A precificação do mercado acompanha esse padrão histórico. Os times que melhor exercem essa vantagem tiveram cerca de 60% de vitória em casa, no período analisado;

  • Quando comparamos probabilidades implícitas com frequências observadas, vemos que a Bet365 de modo geral é bem calibrada. Nas faixas onde está concentrada a maior parte dos jogos, a diferença entre o que foi precificado e o que de fato aconteceu é pequena. Não há evidência de distorções sistemáticas que indiquem erro grosseiro na formação das odds;

  • No entanto, sob a ótica da casa de apostas, o ponto central não está na taxa de acerto, mas na margem. Ao somar as probabilidades implícitas dos três resultados, fica claro que a casa opera consistentemente acima de 100%. Essa diferença é estrutural e aparece em todos os anos analisados, ainda que com leve tendência de redução ao longo do tempo. Ou seja, o mercado pode até estar bem calibrado, mas ele está calibrado com margem embutida.

6.2 O que um apostador casual pode tirar desta análise?

  • A principal conclusão é que acertar mais não significa necessariamente ganhar dinheiro. Cerca de metade dos favoritos vencem, mas isso já está incorporado na odd. A aposta só seria vantajosa se a probabilidade real fosse maior do que a precificada para compensar o risco, algo que não aparece de forma consistente nos dados analisados.

  • Também não há uma faixa de probabilidade claramente mal precificada que indique uma estratégia simples de lucro. Pequenas diferenças existem, mas são pontuais e diluídas pela margem da casa.

  • A análise realizada mostra que o mercado não precisa prever perfeitamente os resultados. Ele precisa apenas precificar risco com uma vantagem estrutural. Para o apostador comum, isso significa começar sempre em desvantagem matemática. Por isso, as casas de apostas tendem a ganhar a longo prazo.