Как стать автором
Обновить

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

Уровень сложностиПростой
Время на прочтение17 мин
Количество просмотров10K

Блуждая по бескрайним просторам интернета, я наткнулся на любопытное исследование под названием 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 в вайб-кодинге и получить результаты для статьи на «Хабре»

Теги:
Хабы:
+39
Комментарии33

Публикации

Истории

Работа

Data Scientist
47 вакансий

Ближайшие события

8 апреля
Конференция TEAMLY WORK MANAGEMENT 2025
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань
20 – 22 июня
Летняя айти-тусовка Summer Merge
Ульяновская область