Проанализировал более 260 тысяч футбольных матчей, чтобы поспорить с учёными-статистиками

от автора

Блуждая по бескрайним просторам интернета, я наткнулся на любопытное исследование под названием Temporal dynamics of goal scoring in soccer. Авторы статьи, вооружившись данными о 3 433 футбольных матчах из 21 лиги, попытались ответить на вопрос: подчиняются ли голы в футболе строгим закономерностям или же являются результатом чистого случая?

Их выводы оказались весьма интересными:

  • «Голы — не случайность». Вероятность забить гол возрастает по ходу матча. В начале каждого тайма забивают меньше, чем можно было бы ожидать при равномерном распределении голов.

  • «Взрывной характер». Если команда забила, вероятность того, что она же забьёт снова в ближайшее время, выше, чем если бы голы были распределены случайно. Этот феномен получил название burstiness (взрывной характер).

  • «Мотивация на финише». Последний гол матча чаще забивается ближе к концу игры.

  • «Голы „пачками“». Большинство голов забиваются вскоре после другого гола, что, впрочем, может объясняться и чисто математическими причинами, а не только психологией игроков.

Чтобы прийти к выводам, учёные собрали данные о времени каждого гола в тысячах матчей, создали «нулевую модель» — симуляцию, где голы забивались абсолютно случайно, — и сравнили реальную статистику с этой моделью. Они также проанализировали временны́е интервалы между голами, обращая внимание на то, одна и та же команда забивала оба раза или разные.

Но, как человек, который сам уже два десятка лет не только наблюдает за футболом, но и активно пинает мяч на любительском уровне, я привык к непредсказуемости этой игры. Кажется, что в футболе слишком много хаоса, слишком много не поддающихся учёту факторов — от настроения команды и везения до судейских решений и рикошетов, — чтобы его можно было уложить в рамки строгих статистических моделей. Поэтому выводы учёных вызвали у меня здоровый скептицизм и желание самостоятельно проверить, насколько «случаен» футбол, и действительно ли можно говорить о каких-то предопределённых закономерностях.

Для этого я решил пойти по стопам авторов, но сконцентрироваться исключительно на анализе имеющейся статистики, оставив пока в стороне сложные математические выкладки и компьютерное моделирование. Хочется, так сказать, «пощупать» данные руками и понять, действительно ли они подтверждают тезисы исследователей, или же любительский взгляд на футбол, закалённый годами игры и просмотра матчей, окажется ближе к истине. В конце концов, кто лучше знает футбол: учёные-статистики или простой любитель, проводящий выходные на поле? Ответ на этот вопрос, как и мяч в воротах, покажет только игра… точнее, анализ данных.

Ищем матчи

Я решил не ограничиваться тем количеством лиг и матчей, которые были у больших учёных. Поэтому начал искать сайты, где можно просто спарсить информацию о матчах. Благо есть такой архив футбольной статистики, который содержит множество матчей. https://fbref.com/en/matches/ Поэтому вооружившись Gemini быстренько накидал скрипт для парсинга.

При попытке парсинга столкнулся с ограничением, которое, как потом выяснилось, прописано на самом сайте. Что ж, решил разделить парсинг на два этапа: сначала собираем ссылки на матчи помесячно, а потом объединяем их в файл с годом и уже парсим данные по конкретному матчу. Мне было достаточно названий команды, времени забитого гола домашней и гостевой командой.

Почему стал парсить помесячно? Чтобы видеть, где появляется ошибка и перепарсить год, когда это понадобится. Если кому-то нужно тело парсера, то оно под спойлером:

Скрытый текст
import requests from bs4 import BeautifulSoup from datetime import date, timedelta import random import time import os import logging  # Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')  BASE_URL = "https://fbref.com" MATCHES_URL_PATH = "/en/matches/" MATCH_REPORT_TEXT = 'Match Report' OUTPUT_FILENAME_MONTH_FORMAT = "match_{year}_{month:02}.txt" OUTPUT_FILENAME_YEAR_FORMAT = "match_{year}.txt" USER_AGENT_LIST = [     "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",     "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15",     "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0",     "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:89.0) Gecko/20100101 Firefox/89.0",     "Mozilla/5.0 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59",     "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.1",     "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3",     "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.3",     "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133",     "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115",     "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0",     "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Herring/97.1.8280.8",     "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3",     "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 OPR/115.0.0",     "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 AtContent/95.5.5462.5",     "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.1958",     "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.3 0.93",     "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0",     "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.3", ] MAX_RETRIES = 3 RETRY_DELAY_SECONDS = 5   def parse_match_links(url, user_agents):     """     Парсит ссылки на страницы матчей со страницы fbref.      Args:         url (str): URL страницы с матчами (например, https://fbref.com/en/matches/2025-02-02).         user_agents (list): Список User-Agent строк для имитации браузера.      Returns:         list: Список полных URL страниц матчей.                 Возвращает пустой список, если ссылки не найдены или произошла ошибка.     """     for retry in range(MAX_RETRIES):         try:             headers = {'User-Agent': random.choice(user_agents)}             response = requests.get(url, headers=headers)             response.raise_for_status()  # Проверка на ошибки HTTP (например, 404)              soup = BeautifulSoup(response.content, 'html.parser')              match_links = []             match_report_links = soup.find_all('a', string=MATCH_REPORT_TEXT) # Ищем теги <a> с текстом "Match Report"              for link in match_report_links:                 match_url = BASE_URL + link['href'] # Формируем полный URL                 match_links.append(match_url)              return match_links          except requests.exceptions.RequestException as e:             log_message = f"Ошибка при запросе страницы: {e} URL: {url}, Попытка {retry + 1}/{MAX_RETRIES}"             if retry < MAX_RETRIES - 1:                 logging.warning(f"{log_message}. Повторная попытка через {RETRY_DELAY_SECONDS} секунд...")                 time.sleep(RETRY_DELAY_SECONDS)             else:                 logging.error(f"{log_message}. Превышено максимальное количество попыток.")                 return [] # Возвращаем пустой список после всех неудачных попыток         except Exception as e:             logging.error(f"Произошла ошибка при парсинге: {e} URL: {url}", exc_info=True) # Логируем полную инфу об ошибке             return []  def generate_month_urls(year, month):     """     Генерирует URL-адреса для каждого дня указанного месяца года.      Args:         year (int): Год для генерации URL-адресов.         month (int): Месяц (1-12) для генерации URL-адресов.      Returns:         list: Список URL-адресов для каждого дня месяца.     """     try:         start_date = date(year, month, 1)     except ValueError:         logging.error(f"Ошибка: Некорректный месяц: {month}. Месяц должен быть от 1 до 12.")         return []      if month == 12:         end_date = date(year + 1, 1, 1) - timedelta(days=1)     else:        end_date = date(year, month + 1, 1) - timedelta(days=1)      urls = []     current_date = start_date      while current_date <= end_date:         date_str = current_date.strftime("%Y-%m-%d")         url = BASE_URL + MATCHES_URL_PATH + date_str         urls.append(url)         current_date += timedelta(days=1)     return urls  if __name__ == '__main__':     min_delay_seconds = 7     max_delay_seconds = 15     year_to_parse = 2010  # год который нужно парсить      yearly_match_urls = [] # Список для хранения всех URL за год      logging.info(f"Начинаем парсинг за {year_to_parse} год.")      for month_to_parse in range(1, 13): # Цикл по месяцам от 1 до 12         month_urls = generate_month_urls(year_to_parse, month_to_parse) # Генерируем список URL-адресов для месяца         if not month_urls:             logging.warning(f"Не удалось сгенерировать URL-адреса для {month_to_parse} месяца. Пропускаем месяц.")             continue # Переходим к следующему месяцу, если не удалось сгенерировать URL          all_match_urls = []         logging.info(f"Парсинг {month_to_parse} месяца {year_to_parse} года...")         for day_url in month_urls:             match_urls = parse_match_links(day_url, USER_AGENT_LIST)             if match_urls:                 all_match_urls.extend(match_urls) # Добавляем все ссылки матчей в список для текущего месяца              # Задержка между запросами             delay = random.uniform(min_delay_seconds, max_delay_seconds)             time.sleep(delay)          if all_match_urls:             # Создаем имя файла с указанием месяца и года (резервный файл)             output_filename = OUTPUT_FILENAME_MONTH_FORMAT.format(year=year_to_parse, month=month_to_parse)              # Сохраняем ссылки в файл для текущего месяца             with open(output_filename, "w") as file:                 for url in all_match_urls:                     file.write(url + "\n") # Записываем каждую ссылку на новой строке             logging.info(f"Ссылки на матчи за {month_to_parse} месяц {year_to_parse} года сохранены в файл {output_filename}")              yearly_match_urls.extend(all_match_urls) # Добавляем ссылки текущего месяца к общему списку за год         else:            logging.warning(f"Ссылки на матчи за {month_to_parse} месяц {year_to_parse} года не найдены.")      if yearly_match_urls:         # Создаем имя файла для общего файла за год         yearly_output_filename = OUTPUT_FILENAME_YEAR_FORMAT.format(year=year_to_parse)         # Сохраняем все ссылки за год в единый файл         with open(yearly_output_filename, "w") as file:             for url in yearly_match_urls:                 file.write(url + "\n") # Записываем каждую ссылку на новой строке         logging.info(f"Все ссылки на матчи за {year_to_parse} год сохранены в файл {yearly_output_filename}")     else:         logging.warning(f"Ссылки на матчи за {year_to_parse} год не найдены.")      logging.info("Парсинг завершен.")

Далее в работу вступал второй парсер, который переходил по собранным ссылкам и забирал информацию о матчах и минутах забитых голов. Поле чего информация складывалась в json и txt файлы. Если кому нужен код тела парсера, то вот он:

Скрытый текст
import requests from bs4 import BeautifulSoup import time import random import json  # Импортируем модуль json  def parse_match_details(url, headers):     """     Парсит детали матча со страницы fbref.     """     try:         response = requests.get(url, headers=headers) # Передаем headers в requests.get()         response.raise_for_status()         soup = BeautifulSoup(response.content, 'html.parser')          match_info = {}          # 1. Лига (остается без изменений)         content_div = soup.find('div', id='content', role='main', class_='box')         if content_div:             league_block = content_div.find('div') # Первое упоминание <div> внутри content             if league_block:                 league_link = league_block.find('a')                 if league_link:                     match_info['league'] = league_link.text.strip()                 else:                     match_info['league'] = "Лига не найдена"             else:                 match_info['league'] = "Блок лиги не найден"         else:             match_info['league'] = "Контейнер контента не найден"          # 2. Блок scorebox (остается без изменений)         scorebox = soup.find('div', class_='scorebox')         if scorebox:             team_blocks = scorebox.find_all('div', recursive=False) # Находим прямые <div> потомки scorebox             if len(team_blocks) >= 2: # Проверяем, что есть хотя бы 2 блока, чтобы избежать ошибки индексации                 # 2.1. Домашняя команда (первый блок)                 home_team_block = team_blocks[0]                 home_team_strong = home_team_block.find('strong')                 if home_team_strong:                     home_team_link = home_team_strong.find('a')                     if home_team_link:                         match_info['home_team'] = home_team_link.text.strip()                     else:                         match_info['home_team'] = "Домашняя команда не найдена"                 else:                     match_info['home_team'] = "Блок названия домашней команды не найден"                  home_score_div = home_team_block.find('div', class_='scores')                 if home_score_div:                     home_score_element = home_score_div.find('div', class_='score')                     if home_score_element:                         match_info['home_goals'] = home_score_element.text.strip()                     else:                         match_info['home_goals'] = "Голы домашней команды не найдены"                 else:                     match_info['home_goals'] = "Блок голов домашней команды не найден"                  # 2.2. Гостевая команда (второй блок)                 away_team_block = team_blocks[1]                 away_team_strong = away_team_block.find('strong')                 if away_team_strong:                     away_team_link = away_team_strong.find('a')                     if away_team_link:                         match_info['away_team'] = away_team_link.text.strip()                     else:                         match_info['away_team'] = "Гостевая команда не найдена"                 else:                     match_info['away_team'] = "Блок названия гостевой команды не найден"                  away_score_div = away_team_block.find('div', class_='scores')                 if away_score_div:                     away_score_element = away_score_div.find('div', class_='score')                     if away_score_element:                         match_info['away_goals'] = away_score_element.text.strip()                     else:                         match_info['away_goals'] = "Голы гостевой команды не найдены"                 else:                     match_info['away_goals'] = "Блок голов гостевой команды не найден"             else:                 match_info['scorebox_teams_error'] = "Недостаточно блоков команд в scorebox" # Изменили сообщение об ошибке         else:             match_info['scorebox_error'] = "Блок scorebox не найден" # Помечаем ошибку, если scorebox не найден          # 6. Голы и минуты          home_goals_events = soup.find('div', class_='event', id='a')         away_goals_events = soup.find('div', class_='event', id='b')          match_info['home_goal_details'] = []         if home_goals_events:             goal_events = home_goals_events.find_all('div') # Ищем все div внутри блока событий             for event in goal_events:                 goal_icon = event.find('div', class_='event_icon goal') # Ищем иконку обычного гола                 own_goal_icon = event.find('div', class_='event_icon own_goal') # Ищем иконку автогола                 penalty_goal_icon = event.find('div', class_='event_icon penalty_goal') # Ищем иконку пенальти                 if goal_icon or own_goal_icon or penalty_goal_icon: # Если иконка гола, автогола или пенальти найдена                     player_link = event.find('a')                     text_parts = event.text.split('·') # Разделяем текст по символу '·'                     if player_link and len(text_parts) > 1: # Проверяем, что есть ссылка и минута                         player_name = player_link.text.strip()                         minute = text_parts[-1].strip().replace("'", "") # Берем последнюю часть и убираем апостроф                         match_info['home_goal_details'].append(f"{player_name} - {minute}'") # Убрали пометку (P)          match_info['away_goal_details'] = []         if away_goals_events:             goal_events = away_goals_events.find_all('div') # Ищем все div внутри блока событий             for event in goal_events:                 goal_icon = event.find('div', class_='event_icon goal') # Ищем иконку обычного гола                 own_goal_icon = event.find('div', class_='event_icon own_goal') # Ищем иконку автогола                 penalty_goal_icon = event.find('div', class_='event_icon penalty_goal') # Ищем иконку пенальти                 if goal_icon or own_goal_icon or penalty_goal_icon: # Если иконка гола, автогола или пенальти найдена                     player_link = event.find('a')                     text_parts = event.text.split('·') # Разделяем текст по символу '·'                     if player_link and len(text_parts) > 1: # Проверяем, что есть ссылка и минута                         player_name = player_link.text.strip()                         minute = text_parts[-1].strip().replace("'", "") # Берем последнюю часть и убираем апостроф                         match_info['away_goal_details'].append(f"{player_name} - {minute}'") # Убрали пометку (P)          return match_info      except requests.exceptions.RequestException as e:         print(f"Ошибка при запросе страницы: {e}")         return {}     except Exception as e:         print(f"Произошла ошибка при парсинге: {e}")         return {}   if __name__ == '__main__':     match_file = "match.txt"     output_file_txt = "match_bd.txt"     output_file_json = "match_data.json" # Имя для JSON файла     min_delay_seconds = 7     max_delay_seconds = 15      user_agents = [         "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",         "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15",         "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0",         "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:89.0) Gecko/20100101 Firefox/89.0",         "Mozilla/5.0 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59",             "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.1",         "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3",         "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.3",         "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133",         "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115",         "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0",         "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Herring/97.1.8280.8",         "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3",         "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 OPR/115.0.0",         "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 AtContent/95.5.5462.5",         "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.1958",         "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.3 0.93",         "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0",         "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.3",     ]      all_matches_data = [] # Список для хранения данных всех матчей для JSON      try:         with open(match_file, "r") as f_matches, open(output_file_txt, "w") as f_output_txt: # Открываем txt файл для записи             match_urls = [line.strip() for line in f_matches]             total_matches = len(match_urls)             matches_parsed = 0              print(f"Всего матчей в файле: {total_matches}")              for match_url in match_urls:                 random_user_agent = random.choice(user_agents)                 headers = {'User-Agent': random_user_agent}                  match_details = parse_match_details(match_url, headers)                  if match_details:                     home_goals_str = ", ".join(match_details.get('home_goal_details', []))                     away_goals_str = ", ".join(match_details.get('away_goal_details', []))                      output_string = f"Матч: {match_details.get('home_team', 'Не найдено')} - {match_details.get('away_team', 'Не найдено')}\n"                     output_string += f"Лига: {match_details.get('league', 'Не найдено')}\n"                     output_string += f"Счет: {match_details.get('home_goals', '?')} - {match_details.get('away_goals', '?')}\n"                     output_string += f"Голы {match_details.get('home_team', 'Хозяева')}: {home_goals_str if home_goals_str else 'Голов не было'}\n"                     output_string += f"Голы {match_details.get('away_team', 'Гости')}: {away_goals_str if away_goals_str else 'Голов не было'}\n"                     output_string += "---\n"                      f_output_txt.write(output_string) # Записываем в txt файл                      matches_parsed += 1                     matches_remaining = total_matches - matches_parsed                     print(f"Матчей осталось спарсить: {matches_remaining}")                      # Подготовка данных для JSON                     match_json_data = {                         "home_team": match_details.get('home_team', 'Не найдено'),                         "away_team": match_details.get('away_team', 'Не найдено'),                         "score": f"{match_details.get('home_goals', '?')} - {match_details.get('away_goals', '?')}",                         "home_goals_minutes": [goal.split(' - ')[-1] for goal in match_details.get('home_goal_details', [])], # Извлекаем только минуты                         "away_goals_minutes": [goal.split(' - ')[-1] for goal in match_details.get('away_goal_details', [])]  # Извлекаем только минуты                     }                     all_matches_data.append(match_json_data) # Добавляем данные матча в список                  else:                     print(f"Не удалось получить информацию о матче по ссылке: {match_url}")                  delay = random.uniform(min_delay_seconds, max_delay_seconds)                 time.sleep(delay)              print(f"Парсинг завершен. Информация о матчах сохранена в файл: {output_file_txt} и {output_file_json}")      except FileNotFoundError:         print(f"Ошибка: Файл '{match_file}' не найден.")     except Exception as e:         print(f"Произошла общая ошибка: {e}")     finally: # Блок finally для сохранения JSON даже при ошибках в основном цикле         try:             with open(output_file_json, 'w', encoding='utf-8') as f_json: # Открываем JSON файл для записи                 json.dump(all_matches_data, f_json, ensure_ascii=False, indent=4) # Записываем JSON данные в файл         except Exception as e:             print(f"Ошибка при сохранении в JSON файл: {e}")

В итоге удалось собрать более 267 000 матчей различных лиг. Что ж, с этим уже можно и поработать. Кстати, все спарсенные матчи в json можно найти тут: https://github.com/LesnoyChelovek/footballstats/tree/main

Анализируем матчи

Что ж, у нас теперь есть список матчей. Теперь нужно проанализировать, на каких минутах забиваются мячи в первом и во втором тайме. Кстати, с этим возникла небольшая сложность, так как Gemini никак не понимал, что в футболе есть ещё дополнительное время у таймов. Поэтому пришлось пошагово объяснять ему правила футбола.

Тут до меня стало доходить, почему, по мнению учёных-статистиков, последний гол матча чаще забивается ближе к концу игры — они могли просто учитывать дополнительное время, как 90-ю минуту. И тогда мы бы и наблюдали нужный всплеск, особенно, если анализируем таймы по 5- или 10-минуткам. Поэтому я решил сделать графики забития мячей поминутными, чтобы не было погрешности.

Заодно решил узнать, какова вероятность, что после первого мяча забьют следующий, какая разница между забитыми голами в минутах, какой самый вероятный счёт. Благо для этого нам как раз и хватает данных по забитым минутам.

Минуты первого тайма, в которых забиваются голы

Минуты первого тайма, в которых забиваются голы

График первого тайма говорит нам, что действительно, чем ближе к концу тайма — тем чаще забиваются голы. При этом в дополнительное время забивается не так много голов, да и само дополнительное время назначается хоть и в большинстве матчей, но часто ограничивается 3–5 минутами.

Минуты второго тайма, в которых забиваются голы

Минуты второго тайма, в которых забиваются голы

А вот данные по второму тайму стали открытием. Напомню, авторы Temporal dynamics of goal scoring in soccer говорили, что последний гол часто забивается в конце матча. Но по графику видно, что в целом второй тайм проходит более или менее ровно и вероятность забить гол практически одинакова, проседая только в первые 5 минут и в дополнительное время.

Ради интереса я дополнительно посчитал вероятность гола после 70-й минуты (среди всех матчей с голами) — 39,29%. А при счёте 0:0 — она же равна 34,16%.

Ок, а что происходит после забитого гола? 

Во-первых, вероятность того, что в матче увидим второй гол около 80%, а третий — 55%. Четвёртый гол будет забит с вероятностью примерно 32%.

Во-вторых, чаще всего второй гол в матче забивается в течение 20 минут после первого. При этом пик голеодорства придётся через 5–14 минут после первого мяча. Чисто психологически, это можно объяснить, что команды, пропустившая мяч, хочет быстрее отыграться, а значит побежит вперёд и усилит натиск. А вот соперник в этот момент может поймать на ошибке.

Статистический анализ помог найти и самый распространённый счёт — 1:1. Так что менее 10% матчей остаются без голов.

Ищем истину

Сравнивая мои выводы и исследовательскую статью можно признать правоту учёных-статистиков, что частота голов систематически возрастает по мере приближения к концу первого тайма, достигая пика в районе 45-й минуты. После этого наблюдается ожидаемое снижение количества голов в компенсированное время, что отражает его ограниченную и переменную продолжительность.

График распределения голов во втором тайме существенно уточняет выводы предыдущего исследования. Вопреки идее о простом нарастании вероятности гола к концу матча, полученные данные показывают относительно платообразное, равномерное распределение количества забитых мячей на протяжении основной части второго тайма (примерно с 50-й по 90-ю минуту). Заметное снижение активности наблюдается лишь в первые минуты после перерыва (46–50) и, аналогично первому тайму, в компенсированное время (90+). Хотя пик активности в районе 90-й минуты существует, он не является частью непрерывного восходящего тренда, как в первом тайме. Это наблюдение, подкреплённое большим размером выборки, предполагает, что основная часть второго тайма характеризуется более стабильной вероятностью гола, чем предполагалось учёными-статистикам. Статистика в 39,3% голов в матчах с голами забиваются после 70-й минуты подтверждает значимость концовок, но не отменяет общей равномерности распределения в предшествующий период.

Тезис про голы «пачками» так же подтвердил свою состоятельность. С большой вероятностью после первого гола можно будет увидеть ещё два, причём не далее, чем через 20-минут после первого. Однако я не стал проверять повышенную вероятности забить для той же самой команды сразу после своего гола, как в исходном исследовании.

Для меня истина оказалась где-то посередине. Правы оказались и статистики с данными про первый тайм и «пачку» голов, а мне же удалось показать, что со вторым таймом не всё так однозначно. Заодно потестировать возможности Gemini в вайб-кодинге и получить результаты для статьи на «Хабре»


ссылка на оригинал статьи https://habr.com/ru/articles/895008/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *