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

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

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

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

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

  • «Взрывной характер». Если команда забила, вероятность того, что она же забьёт снова в ближайшее время, выше, чем если бы голы были распределены случайно. Этот феномен получил название 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("Парсинг завершен.")

Далее в работу вступал второй парсер, который переходил по собранным ссылкам и забирал информацию о матчах и минутах забитых голов. Поле чего информация складывалась в 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%.

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

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

Ищем истину

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

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

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

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

 

Источник

Читайте также