Недавно рассказывал о многомерном анализе данных временных рядов с помощью 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).
Для установки значения диапазона для всех полей, просто выбираем их в интерфейсе Column и в Range жмем Apply – все изменения применятся глобально и все графики будут в одном масштабе времени. Настройки Time range и Normalization тоже надо сделать для каждого показателя отдельно, они между запусками в рамках приложения сохраняются – сделать это нужно только один раз.
Для удобства, screencast с первой настройкой приложения 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).
Попробуем узнать, с каким счетом заканчивались матчи, вторые голы которых забивали в начале второго тайма. Перевес в сторону команд которые выступают дома, 2-1 и далее – что подтверждается анализом статистики HOME_RESULT.
Хорошо, а если посмотреть всплеск вторых голов в конце матче, в районе 1:30? Тут в топе ничьи и это тоже видно по HOME_RESULT по выбранному диапазону.
Посмотрим на статистику по все голам, с первого до десятого, в одном интерфейсе:
Статистика по голам с первого по десятый
Статистика по командам
Возьмем для примера испанский чемпионат и его грандов:Барселону и Реал Мадрид. Посмотрим на их статистику первых забитых мячей, когда они играют между собой в домашнем матче или на выезде.
Реал Мадрид больше проигрывает на своем поле, у Барселоны – наоборот.
Видно и еще одна закономерность, Реал Мадрид играет примерно на равных если первый гол забивается в первые 15 минут, если же первый гол забивается (неважно кем) в середине первого тайма – гарантированно приводит к поражению в домашнем матче.
Перенесемся на родину футбола, в Англию. Посмотрим активное количество первых голов добавив в выборку грандов Liverpool, Chelsea и Arsenal, Manchester United, Manchester City, Everton. Наименьшее число голов было забито на 6-й, 11-й и 15-й минутах.
Немецкая бундеслига. Мюнхенская Бавария и Дортмундская Боруссия не любят забивать на 14 и 20 минутах в начале первого тайма. А вот между этими периодами — явный всплеск активности. Все это в домашних матчах.
Итальянский чемпионат, Juventus и Milan. Эти команды в первой половине тайма показывают небольшой всплеск активности забитых первых голов в районе 13 минуты.
И напоследок, нидерландский профессиональный футбольный клуб 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) и быстрым удалением/добавлением фильтров, чтобы не листать весь список.
Успехов в анализе данных и не засиживайтесь за компьютером — обязательно делайте перерывы на спорт: футбол, бег или что вам по душе.
Вроде все, спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/articles/942352/
Добавить комментарий