Статистика футбольных матчей
Статистика футбольных матчей

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

Подготовка данных

Источником данных для анализа будет репозиторий, содержащий статистику с веб-сайта fbref.com. Именно эти данные были использованы для проведения анализа футбольной статистики @LesnoyChelovekв статье «Проанализировал более 260 тысяч футбольных матчей, чтобы поспорить с учёными-статистиками».

Так как мы анализируем данные временных рядов, необходимо определиться, какое поле мы будем использовать в качестве метки времени. Дата матча в репозитории с данными недоступна, но она есть на сайте fbref.com, откуда взяты данные для анализа. Решил не заморачиваться с повторным сбором данных, а взять за основу метку времени голов в матче и посмотреть, что из этого выйдет. Недостаток подхода в том, что матчи без голов не попадают в выборку, хотя их было бы неплохо тоже учитывать.

Итак, для начала грузим данные из JSON файлов репозитория в таблицу raw_matches.

Скрипт на python, любезно предоставленный нейросетью DeepSeek, для загрузки данных в PostgreSQL. Я работаю в окружении WSL2 на Windows, база данных в докере.

Загрузка данных в таблицу raw_matches
CREATE TABLE raw_matches (
    id SERIAL PRIMARY KEY,
    season INTEGER,
    data JSONB
);
import json
import os
import psycopg2
import sys
from typing import Any, List

# Database connection
try:
    conn = psycopg2.connect(
        host="localhost",
        port=5432,
        dbname="football",
        user="postgres",
        password="postgres"
    )
    cur = conn.cursor()
except psycopg2.Error as e:
    print(f"Database connection error: {e}")
    sys.exit(1)

# Directory containing JSON files
json_dir = '/mnt/c/Disk/dimension/footballstats/JSON'

def flatten_matches(data: Any) -> List[Any]:
    """Flatten nested match structures"""
    matches = []
    
    if isinstance(data, list):
        for item in data:
            if isinstance(item, dict) and 'home_team' in item and 'away_team' in item:
                # This is a match object
                matches.append(item)
            elif isinstance(item, list):
                # This is a nested list, recursively flatten
                matches.extend(flatten_matches(item))
            else:
                print(f"Unexpected item type: {type(item)}")
    elif isinstance(data, dict):
        # Check if this is a match object
        if 'home_team' in data and 'away_team' in data:
            matches.append(data)
        else:
            # This might be a container with matches inside
            for key, value in data.items():
                if isinstance(value, (list, dict)):
                    matches.extend(flatten_matches(value))
    
    return matches

for filename in os.listdir(json_dir):
    if filename.startswith('match_data') and filename.endswith('.json'):
        filepath = os.path.join(json_dir, filename)
        
        try:
            # Extract season from filename
            season = int(filename[10:14])
        except ValueError as e:
            print(f"Error extracting season from {filename}: {e}")
            continue

        try:
            with open(filepath, 'r', encoding='utf-8') as f:
                raw_data = json.load(f)
                
            # Debug: Print the type and structure of the loaded data
            print(f"DEBUG: {filename} - Type of loaded data: {type(raw_data)}")
            if isinstance(raw_data, list) and len(raw_data) > 0:
                print(f"DEBUG: {filename} - First element type: {type(raw_data[0])}")
            
            # Flatten the data structure to extract all matches
            matches = flatten_matches(raw_data)
            print(f"DEBUG: {filename} - Found {len(matches)} matches after flattening")
            
            if len(matches) > 0:
                print(f"DEBUG: {filename} - First match: {json.dumps(matches[0], indent=2)}")
            
        except Exception as e:
            print(f"Error reading/parsing {filename}: {e}")
            continue

        match_count = 0
        error_count = 0
        
        for match in matches:
            try:
                cur.execute(
                    "INSERT INTO raw_matches (season, data) VALUES (%s, %s)",
                    (season, json.dumps(match))
                )
                match_count += 1
            except psycopg2.Error as e:
                error_count += 1
                print(f"Database error inserting match from {filename}: {e}")
                print(f"Problematic match data: {json.dumps(match)}")
            except Exception as e:
                error_count += 1
                print(f"Unexpected error with match from {filename}: {e}")
                print(f"Problematic match data: {json.dumps(match)}")

        print(f"Loaded {match_count} matches from {filename} with {error_count} errors")
        
        try:
            conn.commit()
        except psycopg2.Error as e:
            print(f"Commit error after {filename}: {e}")
            try:
                conn.rollback()
            except:
                pass

cur.close()
conn.close()

Теперь нужно очистить данные и разложить показатели по меткам времени в соответствующие поля таблицы matches.

Загрузка данных из таблицы raw_matches в matches
CREATE TABLE matches (
    year VARCHAR,
    home_team VARCHAR,
    away_team VARCHAR,
    score VARCHAR,
    goals_minutes_1 TIMESTAMP,
    goals_minutes_2 TIMESTAMP,
    goals_minutes_3 TIMESTAMP,
    goals_minutes_4 TIMESTAMP,
    goals_minutes_5 TIMESTAMP,
    goals_minutes_6 TIMESTAMP,
    goals_minutes_7 TIMESTAMP,
    goals_minutes_8 TIMESTAMP,
    goals_minutes_9 TIMESTAMP,
    goals_minutes_10 TIMESTAMP,
    home_goals INTEGER,
    away_goals INTEGER,
    goals INTEGER,
    home_result VARCHAR,
    away_result VARCHAR
);
import psycopg2
import json
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List

# Set up logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('data_load.log'),
        logging.StreamHandler()
    ]
)

def parse_goal_minute(minute_str: str) -> Optional[int]:
    """
    Parse goal minute string, handling additional time notation
    Examples: 
    - "45+1'" becomes 46
    - "90+2'" becomes 92
    - "30'" becomes 30
    """
    try:
        if not minute_str:
            return None
            
        # Remove any trailing apostrophes/quotes
        minute_str = minute_str.rstrip("'’\"")
        
        if '+' in minute_str:
            parts = minute_str.split('+')
            base_minute = int(parts[0])
            added_minutes = int(parts[1])
            return base_minute + added_minutes
        else:
            return int(minute_str)
    except (ValueError, IndexError, AttributeError) as e:
        logging.error(f"Error parsing goal minute '{minute_str}': {e}")
        return None

def clean_score(score_str: str) -> str:
    """
    Clean score string by removing asterisks and other non-numeric characters
    """
    if not score_str:
        return score_str
    
    # Remove asterisks and other non-standard characters
    cleaned = score_str.replace('*', '').replace('--', '0')
    
    # Handle cases like "0 - 3*" -> "0 - 3"
    if ' - ' in cleaned:
        parts = cleaned.split(' - ')
        # Ensure both parts are numeric or can be converted to 0
        try:
            int(parts[0])
        except ValueError:
            parts[0] = '0'
        try:
            int(parts[1])
        except ValueError:
            parts[1] = '0'
        cleaned = ' - '.join(parts)
    
    return cleaned

def process_matches():
    conn = None
    cursor = None
    processed_count = 0
    error_count = 0
    skipped_count = 0
    
    try:
        # Database connection - update with your credentials
        conn = psycopg2.connect(
            host="localhost",
            database="football",
            user="postgres",
            password="postgres",
            port=5432	
        )

        cursor = conn.cursor()
        logging.info("Connected to database successfully")

        # Fetch raw matches
        cursor.execute("SELECT id, season, data FROM raw_matches ORDER BY id")
        raw_matches = cursor.fetchall()
        logging.info(f"Fetched {len(raw_matches)} raw matches")

        # Process each match
        for match_id, season, data in raw_matches:
            try:
                # data is already a dict from psycopg2 for JSONB fields
                match_data = data
                
                # Log full data for problematic matches (for debugging)
                if match_id in [3177, 4941, 5002, 5640, 9710, 14831, 15279, 17657, 20733, 
                              27177, 28565, 28633, 32717, 36030, 36052, 39788, 39794, 39862, 40158]:
                    logging.warning(f"DEBUG - Problematic match {match_id} full data: {match_data}")
                
                # Validate required fields
                required_fields = ['score', 'home_team', 'away_team']
                missing_fields = [field for field in required_fields if field not in match_data]
                if missing_fields:
                    logging.warning(f"Match {match_id} missing required fields: {missing_fields}. Full data: {match_data}")
                    error_count += 1
                    continue
                
                # Parse score safely with cleaning
                try:
                    original_score = match_data['score']
                    cleaned_score = clean_score(original_score)
                    
                    score_parts = cleaned_score.split(' - ')
                    if len(score_parts) != 2:
                        raise ValueError(f"Invalid score format after cleaning: {cleaned_score} (original: {original_score})")
                    
                    home_goals = int(score_parts[0])
                    away_goals = int(score_parts[1])
                    total_goals = home_goals + away_goals
                    
                    # Skip matches with more than 10 goals
                    if total_goals > 10:
                        logging.info(f"Skipping match {match_id} with {total_goals} goals (more than 10)")
                        skipped_count += 1
                        continue
                    
                    # Determine results
                    home_result = 'Wins' if home_goals > away_goals else 'Draws' if home_goals == away_goals else 'Losses'
                    away_result = 'Wins' if away_goals > home_goals else 'Draws' if away_goals == home_goals else 'Losses'
                    
                except (ValueError, IndexError) as e:
                    logging.error(f"Error parsing score for match {match_id}: {e}. Original score: '{match_data['score']}'. Full data: {match_data}")
                    error_count += 1
                    continue
                
                # Collect all goal times from both teams
                all_goal_times = []
                
                # Process home goals
                home_goals_minutes = match_data.get('home_goals_minutes', [])
                for minute_str in home_goals_minutes:
                    minute = parse_goal_minute(minute_str)
                    if minute is not None:
                        goal_time = datetime(2000, 1, 1) + timedelta(minutes=minute)
                        all_goal_times.append(goal_time)
                
                # Process away goals
                away_goals_minutes = match_data.get('away_goals_minutes', [])
                for minute_str in away_goals_minutes:
                    minute = parse_goal_minute(minute_str)
                    if minute is not None:
                        goal_time = datetime(2000, 1, 1) + timedelta(minutes=minute)
                        all_goal_times.append(goal_time)
                
                # Sort goal times chronologically
                all_goal_times.sort()
                
                # Prepare goal columns
                goal_columns = [None] * 10
                for i, goal_time in enumerate(all_goal_times):
                    if i < 10:  # Only store up to 10 goals
                        goal_columns[i] = goal_time
                
                # Insert the match record
                cursor.execute("""
                    INSERT INTO matches (
                        year, home_team, away_team, score, 
                        goals_minutes_1, goals_minutes_2, goals_minutes_3, 
                        goals_minutes_4, goals_minutes_5, goals_minutes_6, 
                        goals_minutes_7, goals_minutes_8, goals_minutes_9, 
                        goals_minutes_10,
                        home_goals, away_goals, goals,
                        home_result, away_result
                    ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                """, (
                    str(season), 
                    match_data['home_team'], 
                    match_data['away_team'], 
                    match_data['score'],
                    goal_columns[0], goal_columns[1], goal_columns[2], 
                    goal_columns[3], goal_columns[4], goal_columns[5], 
                    goal_columns[6], goal_columns[7], goal_columns[8], 
                    goal_columns[9],
                    home_goals, 
                    away_goals, 
                    total_goals,
                    home_result, 
                    away_result
                ))
                
                processed_count += 1
                if processed_count % 1000 == 0:
                    logging.info(f"Progress: {processed_count} matches processed")
                
            except Exception as e:
                logging.error(f"Error processing match {match_id}: {e}. Full data: {match_data}")
                error_count += 1
                continue
        
        # Commit the transaction
        conn.commit()
        logging.info(f"Data load completed: {processed_count} matches processed, {skipped_count} skipped, {error_count} errors")
        
    except Exception as e:
        logging.error(f"Database connection error: {e}")
        if conn:
            conn.rollback()
    finally:
        if cursor:
            cursor.close()
        if conn:
            conn.close()
        logging.info("Database connection closed")

def test_parse_goal_minute():
    """Test function for goal minute parsing"""
    test_cases = [
        ("45+1'", 46),
        ("90+2'", 92),
        ("30'", 30),
        ("26'", 26),
        ("7'", 7),
        ("", None),
        (None, None),
        ("invalid", None)
    ]
    
    logging.info("Testing goal minute parsing...")
    for input_str, expected in test_cases:
        result = parse_goal_minute(input_str)
        status = "✓" if result == expected else "✗"
        logging.info(f"{status} '{input_str}' -> {result} (expected: {expected})")

def test_clean_score():
    """Test function for score cleaning"""
    test_cases = [
        ("0 - 2*", "0 - 2"),
        ("3* - 0", "3 - 0"),
        ("0 - 3*", "0 - 3"),
        ("-- - --*", "0 - 0"),
        ("1 - 0", "1 - 0"),
        ("2 - 2", "2 - 2"),
    ]
    
    logging.info("Testing score cleaning...")
    for input_str, expected in test_cases:
        result = clean_score(input_str)
        status = "✓" if result == expected else "✗"
        logging.info(f"{status} '{input_str}' -> '{result}' (expected: '{expected}')")

if __name__ == "__main__":
    # Run tests first
    test_parse_goal_minute()
    test_clean_score()
    
    # Process matches
    process_matches()
-- Basic indexes for common filtering
CREATE INDEX idx_matches_year ON matches ("year");
CREATE INDEX idx_matches_home_team ON matches (home_team);
CREATE INDEX idx_matches_away_team ON matches (away_team);
CREATE INDEX idx_matches_home_result ON matches (home_result);
CREATE INDEX idx_matches_away_result ON matches (away_result);

-- Composite indexes for common query patterns
CREATE INDEX idx_matches_team_year ON matches (home_team, "year");
CREATE INDEX idx_matches_away_year ON matches (away_team, "year");
CREATE INDEX idx_matches_year_result ON matches ("year", home_result);

-- Indexes for goal-related queries
CREATE INDEX idx_matches_home_goals ON matches (home_goals);
CREATE INDEX idx_matches_away_goals ON matches (away_goals);
CREATE INDEX idx_matches_total_goals ON matches (goals);

-- Indexes for score column
CREATE INDEX idx_matches_score ON matches (score);

-- Indexes for goals_minutes columns
CREATE INDEX idx_matches_goals_minutes_1 ON matches (goals_minutes_1);
CREATE INDEX idx_matches_goals_minutes_2 ON matches (goals_minutes_2);
CREATE INDEX idx_matches_goals_minutes_3 ON matches (goals_minutes_3);
CREATE INDEX idx_matches_goals_minutes_4 ON matches (goals_minutes_4);
CREATE INDEX idx_matches_goals_minutes_5 ON matches (goals_minutes_5);
CREATE INDEX idx_matches_goals_minutes_6 ON matches (goals_minutes_6);
CREATE INDEX idx_matches_goals_minutes_7 ON matches (goals_minutes_7);
CREATE INDEX idx_matches_goals_minutes_8 ON matches (goals_minutes_8);
CREATE INDEX idx_matches_goals_minutes_9 ON matches (goals_minutes_9);
CREATE INDEX idx_matches_goals_minutes_10 ON matches (goals_minutes_10);

Важно! После загрузки данных не забываем создать индексы.

Теперь в базе данных статистика по 327 584 матчам 5 172 команд.

Далее, необходимо установить приложение Dimension-UI для анализа данных временных рядов:

  • Ставим Java не ниже 21 версии;

  • Cкачиваем Java приложение;

  • Cоздаем JDBC подключение в Configuration;

  • Идем на вкладку Ad-hoc. В интерфейсе Connection -> Schema/Catalog (выбираем схему, у меня public) -> Table (выбираем таблицу matches) -> Вкладка Timestamp (выбираем goals_minutes_1) -> Вкладка Column (выбираем YEAR).

Еще несколько настроек для наведения красоты:

  • Range -Custom в верхней части устанавливаем в 01-01-2000 00:00:00 и 01-01-2000 01:40:00;

  • Time range по Year устанавливаем в Minute;

  • Normalization тоже по Year в None.

Пробуем выбрать диапазон и посмотреть детализацию по счету (SCORE), количеству голов в домашних матчах (HOME_GOALS) или на выезде (AWAY_GOALS) и общему количеству голов (GOALS = HOME_GOALS + AWAY_GOALS).

Статистика футбольных матчей с детализацией по счету в Dimension UI
Статистика футбольных матчей с детализацией по счету в Dimension UI

Для установки значения диапазона для всех полей, просто выбираем их в интерфейсе Column и в Range жмем Apply – все изменения применятся глобально и все графики будут в одном масштабе времени. Настройки Time range и Normalization тоже надо сделать для каждого показателя отдельно, они между запусками в рамках приложения сохраняются – сделать это нужно только один раз.

Для удобства, screencast с первой настройкой приложения Dimension-UI
Настройка Dimension-UI для просмотра статистики футбольных матчей
Настройка Dimension-UI для просмотра статистики футбольных матчей

Общая статистика

Итак, данные загружены, все необходимые настройки сделаны – пришло время проводить анализ данных и их интерпретацию.

Статистика по счету
Статистика по счету

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

Статистика побед, поражений и ничьих
Статистика побед, поражений и ничьих

Статистическое подтверждение известной поговорки: «дома и стены помогают» — командам крайне сложно уверенно побеждать на выезде.

Изменим метку времени с goals_minutes_1 на goals_minutes_2 в интерфейсе Timestamp приложения и убрав с рабочей области текущие графики (они привязаны к Timestamp goals_minutes_1). Так мы посмотрим данные по вторым забитым голам:

Статистика по забитым вторым голам
Статистика по забитым вторым голам

Видим явный всплеск активности по голам в районе 47 минуты матча и небольшие (но отчетливо наблюдаемые невооруженным глазом) в районе 1:30 и 1:20 и 1:10.

Что подвтерждается данными по вторым забитым голам суммарно (по goals_minutes_1 в интерфейсе Timestamp).

Статистика по вторым забитым голам в Gantt представлении
Статистика по вторым забитым голам в Gantt представлении

Попробуем узнать, с каким счетом заканчивались матчи, вторые голы которых забивали в начале второго тайма. Перевес в сторону команд которые выступают дома, 2-1 и далее – что подтверждается анализом статистики HOME_RESULT.

Статистика по счету встреч по вторым голам забитым в начале матче
Статистика по счету встреч по вторым голам забитым в начале матче

Хорошо, а если посмотреть всплеск вторых голов в конце матче, в районе 1:30? Тут в топе ничьи и это тоже видно по HOME_RESULT по выбранному диапазону.

Статистика по счету встреч по вторым голам забитым в конце матче
Статистика по счету встреч по вторым голам забитым в конце матче

Посмотрим на статистику по все голам, с первого до десятого, в одном интерфейсе:

Статистика по голам с первого по десятый
Первый гол
Первый гол
Второй гол
Второй гол
Третий гол
Третий гол
Четвертый гол
Четвертый гол
Пятый гол
Пятый гол
Шестой гол
Шестой гол
Седьмой гол
Седьмой гол
Восьмой гол
Восьмой гол
Девятый гол
Девятый гол
Десятый гол
Десятый гол

Статистика по командам

Возьмем для примера испанский чемпионат и его грандов:Барселону и Реал Мадрид. Посмотрим на их статистику первых забитых мячей, когда они играют между собой в домашнем матче или на выезде.

Реал Мадрид больше проигрывает на своем поле, у Барселоны – наоборот.

Статистика матчей между Реал Мадрид (home) и Барселона (away)
Статистика матчей между Реал Мадрид (home) и Барселона (away)
Статистика матчей между Реал Мадрид (away) и Барселона (home)
Статистика матчей между Реал Мадрид (away) и Барселона (home)

Видно и еще одна закономерность, Реал Мадрид играет примерно на равных если первый гол забивается в первые 15 минут, если же первый гол забивается (неважно кем) в середине первого тайма – гарантированно приводит к поражению в домашнем матче.

Статистика матчей между Реал Мадрид (home) и Барселона (away) - начало первого тайма
Статистика матчей между Реал Мадрид (home) и Барселона (away) - начало первого тайма
Статистика матчей между Реал Мадрид (home) и Барселона (away) - середина первого тайма
Статистика матчей между Реал Мадрид (home) и Барселона (away) - середина первого тайма

Перенесемся на родину футбола, в Англию. Посмотрим активное количество первых голов добавив в выборку грандов Liverpool, Chelsea и Arsenal, Manchester United, Manchester City, Everton. Наименьшее число голов было забито на 6-й, 11-й и 15-й минутах.

Статистика голов по Liverpool, Chelsea и Arsenal, Manchester United, Manchester City, Everton (home)
Статистика голов по Liverpool, Chelsea и Arsenal, Manchester United, Manchester City, Everton (home)

Немецкая бундеслига. Мюнхенская Бавария и Дортмундская Боруссия не любят забивать на 14 и 20 минутах в начале первого тайма. А вот между этими периодами - явный всплеск активности. Все это в домашних матчах.

Статистика голов Мюнхенская Бавария и Дортмундская Боруссия (home)
Статистика голов Мюнхенская Бавария и Дортмундская Боруссия (home)

Итальянский чемпионат, Juventus и Milan. Эти команды в первой половине тайма показывают небольшой всплеск активности забитых первых голов в районе 13 минуты.

Статистика голов Juventus и Milan (home)
Статистика голов Juventus и Milan (home)

И напоследок, нидерландский профессиональный футбольный клуб Zwolle из одноимённого города – эти ребята, похоже, очень любят число 7 и стараются (как могут конечно) забить на 7 минуте максимальное количество голов и свести встречу в основном к ничье.

Screencast по Zwolle

Известные проблемы и ограничения

  • Если не закрыть всплывающее окно настроек (Filter, Custom -> Range, Range etc) некорректно отрабатывает функция по удалению dashboard-a с рабочей панели. Сделано временное решение, если фокус мыши перемещается – всплывающее окно закрываем, но отрабатывает эта функция не всегда;

  • Настройи Legend, Detail в Ad-Hoc пока отрабатывают не совсем корректно, необходимо переработать логику работы с несколькими источниками данных;

  • При создании в конфигурации подключения, они не отображаются в интерфейсе Connection в Ad-Hoc, надо перезагрузить приложение (почувствуй себя на минуту SRE (senior reboot engeneer));

  • Фильтры внутри измерения применяются по условию OR, несколько фильтров по измерениям по условию AND. Тоже в работе.

Итоги

Думаю, основная идея многомерного анализа данных понятна. В данном случае мы опробовали механику работы текущей реализации Dimension UI на домене данных статистики футбольных матчей. Моё личное ощущение — да, удобно. Некоторые вещи делаются буквально в один-два клика. Пробуйте, предлагайте, как сделать удобнее. Мы будем смотреть, как это можно реализовать, учитывая текущие ограничения (времени, возможности реализации фич на Java Swing и прочее).

Напрашиваются улучшения в части визуализации применённых фильтров на графике и в интерфейсе фильтрации. Скорее всего, мы сделаем отдельный дополнительный интерфейс с визуализацией в виде списка по измерениям и условиям фильтрации, применённым фильтрам (всплывающее окно Filter) и быстрым удалением/добавлением фильтров, чтобы не листать весь список.

Успехов в анализе данных и не засиживайтесь за компьютером — обязательно делайте перерывы на спорт: футбол, бег или что вам по душе.

Вроде все, спасибо за внимание!

Комментарии (0)