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

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

7fae395d05ca2daf506b6288ac3e780b.jpg

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

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

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

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

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

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

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

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

Ищем матчи

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

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

3e1221ff7742135c91123c7d66e766f6.jpg

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

Скрытый текст
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) # Ищем теги  с текстом "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') # Первое упоминание 
внутри 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) # Находим прямые
потомки 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%.

3247a1112dd57eac00677664fc8077ce.png

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

9cc62fda2f9b87c2221224b7e18e6468.png

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

Ищем истину

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

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

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

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

© Habrahabr.ru