diff --git a/3.py b/3.py new file mode 100644 index 0000000..8a19aee --- /dev/null +++ b/3.py @@ -0,0 +1,152 @@ +''' +3.py - задание №3: график разницы заброшенных и пропущенных шайб одной команды +по ходу сезона (по датам матчей). + +ОБЩАЯ ЛОГИКА: + 1. Запуск: python3 3.py khl_3/khl_2018_19.csv "ЦСКА" + 2. Читаем CSV с матчами. + 3. match_goals(df) считает итоговые голы хозяев и гостей в каждом матче. + 4. plot_team_diff(team, file) оставляет только матчи нужной команды, считает в + каждом разницу (забито - пропущено) и рисует её по оси дат; картинка + сохраняется в файл diff_<команда>.png. + +Это отдельный сценарий от main.py: тут не строится таблица, а рисуется график +для одной команды. Часть кода (parse_score, подсчёт голов) намеренно похожа на +main.py, потому что правило подсчёта шайб одно и то же. + +Зависимости: pandas (таблицы), matplotlib (график). numpy не используется напрямую. +''' + +import sys # для аргументов командной строки +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt # для построения графика + + +def parse_score(df, col): + ''' + Шаг 1. Разобрать столбец со счётом вида "3:1" на два числовых столбца. + + Принимает: df - таблица матчей; col - имя столбца со счётом. + Возвращает: (home, away) - голы хозяев и голы гостей (числовые Series). + + split(':') разбивает "3:1" на "3" и "1"; to_numeric(errors='coerce') делает + из текста числа, а нечисловое (пустое ":" или знаки) превращает в NaN; + fillna(0) ставит 0. Итог: не сыгранный период читается как "0:0". + ''' + parts = df[col].str.split(':', expand=True) + home = pd.to_numeric(parts[0], errors='coerce').fillna(0) + away = pd.to_numeric(parts[1], errors='coerce').fillna(0) + return home, away + + +def match_goals(df): + ''' + Шаг 2. Посчитать итоговые голы хозяев и гостей в КАЖДОМ матче. + + Принимает: df - таблица матчей. + Возвращает: (goals_home, goals_away) - два столбца с числом шайб в каждом матче. + + Правило подсчёта то же, что в основной таблице: голы трёх периодов + голы + овертайма + 1 решающая шайба победителю серии буллитов. Отдельно зануляются + технические результаты (см. блок ниже). + ''' + h1, a1 = parse_score(df, 'Период_1') + h2, a2 = parse_score(df, 'Период_2') + h3, a3 = parse_score(df, 'Период_3') + oth, ota = parse_score(df, 'Овертайм') # овертайм (0/0, если его не было) + soh, soa = parse_score(df, 'Буллиты') # буллиты (0/0, если их не было) + + reg_h = h1 + h2 + h3 # голы хозяев в основное время + reg_a = a1 + a2 + a3 # голы гостей в основное время + + tie = (reg_h == reg_a) # ничья после трёх периодов + # Победа по буллитам = была ничья в основное И в овертайме, и кто-то выиграл буллиты. + win_home_so = tie & (oth == ota) & (soh > soa) + win_away_so = tie & (oth == ota) & (soa > soh) + + # astype(int) переводит True/False в 1/0 - так добавляем ровно 1 решающую шайбу. + goals_home = reg_h + oth + win_home_so.astype(int) + goals_away = reg_a + ota + win_away_so.astype(int) + + ''' + Технические результаты (неявка/отмена): в файле счёт записан знаками + "+ — -" (техпобеда хозяев) или "- — +" (техпобеда гостей), а не цифрами. + КХЛ шайбы за такие матчи НЕ начисляет, поэтому оба значения принудительно 0:0 + (так разница в этом матче будет 0, как на сайте КХЛ). + ''' + p1 = df['Период_1'].astype(str).str.strip() + tech = p1.str.startswith('+') | p1.str.endswith('+') + goals_home = goals_home.where(~tech, 0) + goals_away = goals_away.where(~tech, 0) + return goals_home, goals_away + + +def plot_team_diff(team, filename): + ''' + Шаг 3. Построить и сохранить график разницы шайб одной команды по датам. + + Принимает: team - название команды (как в CSV); filename - путь к CSV. + Ничего не возвращает - сохраняет картинку diff_.png и показывает окно. + + Как работает: + 1) считаем голы в каждом матче через match_goals; + 2) разносим каждый матч на две строки (взгляд хозяев и взгляд гостей), чтобы + "забито/пропущено" были с точки зрения конкретной команды; + 3) оставляем только строки нужной команды, сортируем по дате; + 4) для каждого матча считаем "Разница = ГЗ - ГП" и рисуем линию по датам. + ''' + df = pd.read_csv(filename) # читаем файл с матчами + goals_home, goals_away = match_goals(df) # голы хозяев/гостей в каждом матче + + # Делаем "по две строки на матч": взгляд хозяев и взгляд гостей. + # ГЗ - забито нашей командой, ГП - пропущено (= забито соперником). + home = pd.DataFrame({ + 'Дата': df['Дата'], + 'Команда': df['Команда_1'], + 'ГЗ': goals_home, + 'ГП': goals_away, + }) + away = pd.DataFrame({ + 'Дата': df['Дата'], + 'Команда': df['Команда_2'], + 'ГЗ': goals_away, + 'ГП': goals_home, + }) + both = pd.concat([home, away], ignore_index=True) + both[['ГЗ', 'ГП']] = both[['ГЗ', 'ГП']].astype(int) # из дробных делаем целые + + # Оставляем только матчи нужной команды (булева маска == team). + rows = both[both['Команда'] == team].copy() + # Дату-текст превращаем в настоящую дату, чтобы ось X шла по времени. + rows['Дата'] = pd.to_datetime(rows['Дата']) + rows = rows.sort_values('Дата') # по возрастанию даты + rows['Разница'] = rows['ГЗ'] - rows['ГП'] # забито минус пропущено в матче + + # Рисуем график. + fig, ax = plt.subplots(figsize=(12, 5)) + ax.plot(rows['Дата'], rows['Разница'], marker='o') # линия с точками-матчами + ax.axhline(0, color='gray', linewidth=1) # горизонтальная линия "ноль" для наглядности + ax.set_title('Разница забитых и пропущенных шайб: ' + team) + ax.set_xlabel('Дата матча') + ax.set_ylabel('Забито - пропущено') + ax.grid(True, alpha=0.3) # лёгкая сетка + fig.autofmt_xdate() # автоматически поворачивает подписи дат, чтобы не слипались + + fig.savefig('diff_' + team + '.png', dpi=150, bbox_inches='tight') # сохраняем картинку + print('График сохранён: diff_' + team + '.png') + plt.show() # показываем окно с графиком + + +def main(): + ''' + Точка входа. Берёт из командной строки два аргумента - путь к CSV и название + команды - и вызывает построение графика. + Пример: python3 3.py khl_3/khl_2018_19.csv "ЦСКА" + ''' + filename = sys.argv[1] # первый аргумент - имя файла + team = sys.argv[2] # второй аргумент - имя команды + plot_team_diff(team, filename) + + +main() diff --git a/divisions.py b/divisions.py new file mode 100644 index 0000000..86d41d0 --- /dev/null +++ b/divisions.py @@ -0,0 +1,180 @@ +import sys # аргументы командной строки +import os # путь к main.py и имя csv-файла +import pandas as pd +import numpy as np + +''' +Отдельный модуль: ДИВИЗИОНЫ и ПРАВИЛО ПОСЕВА победителей дивизионов. + +Зачем он нужен. +Базовый main.py сортирует команды в конференции строго по проценту очков (%О) +и тай-брейкам. Но в части сезонов КХЛ в таблице конференции действовало правило: +ПОБЕДИТЕЛИ ДВУХ ДИВИЗИОНОВ получают места 1 и 2 в конференции, даже если по очкам +они не входят в топ-2. Из-за этого, например, в сезоне 2020/21 в Западной +конференции СКА (82 очка) стоит на сайте выше Динамо М (84) и Локомотива (83): +СКА выиграл дивизион Боброва, а те двое - из дивизиона Тарасова, который выиграл ЦСКА. + +main.py этот посев не делает (в нём вообще нет деления на дивизионы, только +Запад/Восток), поэтому правило вынесено сюда, в отдельный файл, и main.py не трогается. +''' + + +''' +Расчёт таблицы берём из main.py, чтобы не дублировать логику (единственный источник +правды - там). main.py в конце сам вызывает main(), поэтому импортировать его напрямую +нельзя - он бы тут же попытался прочитать аргументы и упал. Поэтому читаем его исходник +и выполняем без последней строки `main()`: так получаем готовые функции build_standings +и словарь CONFERENCES, ничего при этом не запуская и не печатая. +''' +_main_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'main.py') +_src = open(_main_path, encoding='utf-8').read() +_src = _src.replace('\nmain()\n', '\n') # убираем автозапуск +_ns = {} +exec(compile(_src, 'main.py', 'exec'), _ns) +build_standings = _ns['build_standings'] +CONFERENCES = _ns['CONFERENCES'] + + +def make_div(bobrov, tarasov, kharlamov, chernyshev): + ''' + Собрать словарь {команда -> дивизион} из четырёх списков команд. + + Принимает: четыре списка - составы дивизионов Боброва и Тарасова (Запад), + Харламова и Чернышёва (Восток). + Возвращает: dict, где каждой команде сопоставлено название её дивизиона. + + Аналог make_conf из main.py, только делений не два (Запад/Восток), а четыре. + ''' + d = {} + d.update({t: 'Боброва' for t in bobrov}) # Запад + d.update({t: 'Тарасова' for t in tarasov}) # Запад + d.update({t: 'Харламова' for t in kharlamov}) # Восток + d.update({t: 'Чернышёва' for t in chernyshev}) # Восток + return d + + +''' +Состав дивизионов по сезонам (имена команд - ровно как в соответствующем csv). +Источники: + 2020/21 - официальное распределение КХЛ; + 2021/22 - Википедия "КХЛ в сезоне 2021/2022"; + 2018/19, 2019/20 - стандартная сетка дивизионов тех сезонов. +Для посева важен только победитель каждого дивизиона; выходные таблицы ниже +сверены с официальным сайтом КХЛ по всем четырём сезонам и совпадают. +''' +DIVISIONS = { + '2018_19': make_div( + bobrov=['СКА', 'Йокерит', 'Динамо М', 'Динамо Мн', 'Динамо Р', 'Слован'], + tarasov=['ЦСКА', 'Локомотив', 'ХК Сочи', 'Спартак', 'Витязь', 'Северсталь'], + kharlamov=['Автомобилист', 'Металлург Мг', 'Ак Барс', 'Трактор', + 'Нефтехимик', 'Куньлунь РС', 'Адмирал'], + chernyshev=['Барыс', 'Авангард', 'Салават Юлаев', 'Сибирь', + 'Торпедо НН', 'Амур'], + ), + '2019_20': make_div( + bobrov=['СКА', 'Йокерит', 'Спартак', 'Динамо М', 'Динамо Мн', 'Динамо Р'], + tarasov=['ЦСКА', 'Локомотив', 'Витязь', 'Торпедо НН', 'ХК Сочи', 'Северсталь'], + kharlamov=['Ак Барс', 'Металлург Мг', 'Автомобилист', 'Нефтехимик', + 'Трактор', 'Адмирал'], + chernyshev=['Авангард', 'Барыс', 'Салават Юлаев', 'Сибирь', + 'Амур', 'Куньлунь РС'], + ), + '2020_21': make_div( + bobrov=['СКА', 'Йокерит', 'Северсталь', 'Спартак', 'Витязь', 'ХК Сочи'], + tarasov=['ЦСКА', 'Динамо М', 'Локомотив', 'Динамо Мн', 'Динамо Р'], + kharlamov=['Автомобилист', 'Ак Барс', 'Металлург Мг', 'Нефтехимик', + 'Трактор', 'Торпедо НН'], + chernyshev=['Авангард', 'Амур', 'Барыс', 'Куньлунь РС', + 'Салават Юлаев', 'Сибирь'], + ), + '2021_22': make_div( + bobrov=['Витязь', 'Йокерит', 'СКА', 'ХК Сочи', 'Торпедо', 'Спартак'], + tarasov=['Динамо М', 'Динамо Мн', 'Динамо Р', 'Локомотив', 'Северсталь', 'ЦСКА'], + kharlamov=['Автомобилист', 'Ак Барс', 'Куньлунь РС', 'Металлург Мг', + 'Нефтехимик', 'Трактор'], + chernyshev=['Авангард', 'Амур', 'Барыс', 'Сибирь', 'Салават Юлаев', 'Адмирал'], + ), +} + +''' +В каких сезонах правило посева победителей дивизионов отражалось в таблице конференции. +2018/19, 2019/20, 2020/21 - да; в 2021/22 КХЛ перешла к чистому порядку по %О +(на сайте за 2021/22 победители дивизионов наверх НЕ поднимаются). +В сезонах 2018/19 и 2019/20 победители дивизионов и так были в топ-2 по очкам, +поэтому правило там виден результат не меняет - но оставляем его включённым, +чтобы логика была единой. +''' +SEED_BY_DIVISION = { + '2018_19': True, + '2019_20': True, + '2020_21': True, + '2021_22': False, +} + + +def seed_conference(conf_table): + ''' + conf_table - часть итоговой таблицы по одной конференции, УЖЕ отсортированная + по критериям КХЛ (%О, затем В, ВО, ВБ, Р, ГЗ) - так её отдаёт build_standings. + + Берём лучшего из каждого дивизиона (это и есть победитель дивизиона, т.к. таблица + отсортирована) и ставим этих двоих на места 1-2; между собой они идут в том же + порядке сортировки. Остальные команды - следом, тоже в порядке сортировки. + ''' + # head(1) внутри каждого дивизиона = первая (а значит, лучшая) команда дивизиона. + winners = conf_table.groupby('Див', sort=False).head(1) + rest = conf_table.drop(winners.index) + return pd.concat([winners, rest]) + + +def show_table(table, title): + ''' + Напечатать таблицу конференции в консоль. + + То же, что show_table в main.py, но дополнительно выводит столбец 'Див' + (дивизион) - чтобы было видно, почему победители дивизионов оказались наверху. + Принимает: table - готовая (уже посеянная) таблица; title - заголовок. + Ничего не возвращает - только печатает. + ''' + # Тот же набор колонок, что на сайте КХЛ, плюс столбец дивизиона. + cols = ['Див', 'И', 'В', 'ВО', 'ВБ', 'ПБ', 'ПО', 'П', 'ГЗ', 'ГП', 'Р', 'О', '%О'] + view = table[cols].copy() + view.insert(0, 'Место', np.arange(1, len(view) + 1)) + view.insert(1, 'Команда', view.index) + + print('\n' + '=' * 86) + print(title) + print('=' * 86) + print(view.to_string(index=False)) + + +def main(): + ''' + Точка входа. Шаги: + 1) определить сезон по имени CSV-файла; + 2) построить таблицу команд через build_standings (логика из main.py); + 3) приписать каждой команде дивизион (столбец 'Див'); + 4) для каждой конференции при необходимости применить посев победителей + дивизионов (seed_conference) и напечатать результат. + Пример: python3 divisions.py khl_3/khl_2020_21.csv + ''' + path = sys.argv[1] + name = os.path.basename(path) + season = name.replace('khl_', '').replace('.csv', '') + + df = pd.read_csv(path) + table = build_standings(df, CONFERENCES[season]) + + # Подписываем каждой команде её дивизион. + table['Див'] = pd.Series(table.index, index=table.index).map(DIVISIONS[season]) + + use_seeding = SEED_BY_DIVISION[season] + for conf in ['Запад', 'Восток']: + part = table[table['Конф'] == conf] + if use_seeding: + part = seed_conference(part) + suffix = ' (посев по дивизионам)' if use_seeding else ' (по очкам, без посева)' + show_table(part, 'Конференция ' + conf + ' ' + season + suffix) + + +main() diff --git a/main.py b/main.py index 0a6bfc4..99e7032 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,25 @@ +''' +main.py - построение турнирной таблицы КХЛ из файла с результатами матчей. + +ОБЩАЯ ЛОГИКА (что происходит, сверху вниз): + 1. Запуск: python3 main.py khl_3/khl_2018_19.csv + 2. По имени файла определяется код сезона (например, "2018_19") и берётся + словарь "команда -> конференция" из CONFERENCES. + 3. CSV читается в таблицу матчей df (одна строка = один матч, счёт по периодам). + 4. build_standings(df, conf_map) превращает матчи в турнирную таблицу команд: + по каждому матчу определяется исход (победа/поражение и каким образом), + результаты разносятся на обе команды, суммируются и сортируются по правилам КХЛ. + 5. show_table(...) печатает таблицу: общую по чемпионату и отдельно по двум + конференциям (Запад / Восток). + +ВХОДНЫЕ ДАННЫЕ (столбцы CSV): + Команда_1 - хозяева, Команда_2 - гости; + Период_1/2/3 - счёт по периодам в формате "3:1" (хозяева:гости); + Овертайм, Буллиты - счёт за овертайм и серию буллитов (или ":" если их не было). + +Зависимости: pandas (таблицы), numpy (нумерация мест). +''' + import sys # для аргументов командной строки import os # чтобы достать имя файла из пути import pandas as pd @@ -5,6 +27,14 @@ import numpy as np def make_conf(west, east): + ''' + Собрать словарь {название команды -> конференция} из двух списков команд. + + Принимает: west, east - списки названий команд Западной и Восточной конференций. + Возвращает: dict, где каждой команде сопоставлена строка 'Запад' или 'Восток'. + + Нужен, чтобы позже одной операцией .map() приписать каждой команде её конференцию. + ''' d = {} d.update({team: 'Запад' for team in west}) d.update({team: 'Восток' for team in east}) @@ -43,32 +73,46 @@ CONFERENCES = { ), } -''' -Шаг 1. - -Формат счёта: 3:1 (хозяева забили 3, гости 1). -Если период не игрался (нет овертайма или буллитов), в файле стоит ":" - пустые числа. - -df[col].str.split(':', expand=True) применяет строковую операцию split(':') -сразу ко всем строкам столбца и раскладывает результат в два столбца (expand=True). - -pd.to_numeric превращает текст "3" в число 3. Параметр errors='coerce' означает: -если это не число (например, пустая строка), сделай NaN, а fillna(0) заменяет такие -пустые места на 0. В итоге не сыгранный период считается как "0:0". -''' - - def parse_score(df, col): + ''' + Шаг 1. Разобрать столбец со счётом вида "3:1" на два числовых столбца. + + Принимает: df - таблица матчей; col - имя столбца ('Период_1', 'Овертайм' и т.п.). + Возвращает: (home, away) - два числовых столбца (Series): голы хозяев и голы гостей. + + Как работает: + - str.split(':', expand=True) разбивает "3:1" на части "3" и "1" сразу по + всему столбцу и раскладывает их в два столбца (expand=True); + - pd.to_numeric(..., errors='coerce') превращает текст в число, а всё, что + числом не является (пустая строка при ":" или знаки "+ — -"), делает NaN; + - fillna(0) заменяет NaN на 0. Итог: не сыгранный период читается как "0:0". + ''' parts = df[col].str.split(':', expand=True) home = pd.to_numeric(parts[0], errors='coerce').fillna(0) away = pd.to_numeric(parts[1], errors='coerce').fillna(0) return home, away -# Шаг 2. Функция, которая из таблицы матчей строит таблицу команд. - - def build_standings(df, conf_map): + ''' + Шаг 2. Превратить таблицу матчей в турнирную таблицу команд. + + Принимает: df - матчи (одна строка = один матч); conf_map - словарь + {команда -> конференция} из make_conf. + Возвращает: таблицу (DataFrame), где индекс - название команды, а столбцы - + И, В, ВО, ВБ, ПБ, ПО, П, ГЗ, ГП, О, Р, %О, Конф; строки уже + отсортированы по правилам КХЛ (лучшая команда сверху). + + Идея целиком: + 1) разбираем счёт по периодам/овертайму/буллитам; + 2) по каждому матчу определяем ОДИН из шести исходов для хозяев и для гостей + (победа в основное/овертайме/буллитах и зеркальные поражения); + 3) отдельно учитываем технические результаты ("+ — -" / "- — +"); + 4) считаем заброшенные/пропущенные шайбы; + 5) разносим каждый матч на две строки (взгляд хозяев и взгляд гостей), + склеиваем и группируем по команде - получаем суммы по сезону; + 6) считаем производные показатели (И, О, Р, %О) и сортируем. + ''' # Достаём счёт по периодам и т.д. h1, a1 = parse_score(df, 'Период_1') # голы хозяев/гостей в 1 периоде h2, a2 = parse_score(df, 'Период_2') # второй @@ -94,6 +138,25 @@ def build_standings(df, conf_map): win_home_so = tie & (oth == ota) & (soh > soa) # ОТ ничейный -> буллиты выиграли хозяева win_away_so = tie & (oth == ota) & (soa > soh) # буллиты выиграли гости + ''' + Технические результаты (неявка/отмена матча). В файле счёт записан не цифрами, + а знаками: "+ — -" - техническая победа хозяев, "- — +" - техническая победа гостей. + КХЛ засчитывает их как победу/поражение в основное время (2 и 0 очков), но шайбы + при этом НЕ начисляются (в графу Ш такой матч идёт как 0:0). + ''' + p1 = df['Период_1'].astype(str).str.strip() + tech_home = p1.str.startswith('+') # техническая победа хозяев + tech_away = p1.str.endswith('+') # техническая победа гостей + tech = tech_home | tech_away + + # Для технических матчей перекрываем обычную классификацию (там сейчас "ничья 0:0"). + win_home_reg = (win_home_reg & ~tech) | tech_home + win_away_reg = (win_away_reg & ~tech) | tech_away + win_home_ot = win_home_ot & ~tech + win_away_ot = win_away_ot & ~tech + win_home_so = win_home_so & ~tech + win_away_so = win_away_so & ~tech + ''' Считаем заброшенные шайбы для итоговой таблицы. К голам в основное время прибавляем голы овертайма, а победителю серии буллитов @@ -160,9 +223,11 @@ def build_standings(df, conf_map): %О - процент набранных очков = очки / максимально возможные (2 за каждую игру). По этому проценту КХЛ расставляет места, когда команды сыграли разное число игр. Сайт КХЛ НЕ округляет, а отбрасывает знаки после второго (72.448 -> 72.44), - поэтому np.floor (округление вниз): умножаем на 10000, отбрасываем дробь, делим на 100. + поэтому считаем в целых числах: умножаем на 10000 и делим нацело (//) на 2*И - + остаток (дробная часть) отбрасывается, затем делим на 100. Целочисленная + арифметика без float, поэтому ровные значения (57.00) не съезжают в 56.99. ''' - table['%О'] = np.floor(table['О'] / (2 * table['И']) * 10000) / 100 + table['%О'] = (table['О'] * 10000 // (2 * table['И'])) / 100 # Подписываем каждой команде её конференцию. .map(словарь) проходит по индексу # (названиям команд) и подставляет значение из словаря. table['Конф'] = pd.Series(table.index, index=table.index).map(conf_map) @@ -178,8 +243,18 @@ def build_standings(df, conf_map): return table -# Вывод таблицы текстом def show_table(table, title): + ''' + Напечатать турнирную таблицу в консоль в виде текста. + + Принимает: table - готовая (уже отсортированная) таблица команд; + title - заголовок над таблицей. + Ничего не возвращает - только печатает. + + Что делает: оставляет нужные столбцы в порядке, как на сайте КХЛ, добавляет + слева колонку "Место" (1..N по порядку строк) и колонку "Команда" + (название берётся из индекса таблицы), затем выводит всё через to_string. + ''' # Колонки в том же порядке и с теми же подписями, что на сайте КХЛ. cols = ['И', 'В', 'ВО', 'ВБ', 'ПБ', 'ПО', 'П', 'ГЗ', 'ГП', 'Р', 'О', '%О'] view = table[cols].copy() # берём только нужные столбцы (копия, чтобы не трогать оригинал) @@ -195,6 +270,12 @@ def show_table(table, title): def main(): + ''' + Точка входа. Связывает все шаги вместе: + читает путь к CSV из аргумента командной строки -> по имени файла определяет + сезон -> строит таблицу через build_standings -> печатает общую таблицу и две + таблицы по конференциям (Запад/Восток), отфильтрованные по столбцу 'Конф'. + ''' path = sys.argv[1] # получаем путь до csv name = os.path.basename(path) # из khl_3/khl_*.csv берём только khl_*.csv season = name.replace('khl_', '').replace('.csv', '')