Проанализировал более 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) # Ищем теги с текстом "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("Парсинг завершен.")
Анализируем матчи

График первого тайма говорит нам, что действительно, чем ближе к концу тайма — тем чаще забиваются голы. При этом в дополнительное время забивается не так много голов, да и само дополнительное время назначается хоть и в большинстве матчей, но часто ограничивается 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 в вайб-кодинге и получить результаты для статьи на «Хабре»