From ccb4ae6b3f3db30dd276698e31d02c890da113bd Mon Sep 17 00:00:00 2001 From: Hitoshi-Hub Date: Tue, 30 Jun 2026 10:49:28 +0300 Subject: [PATCH] =?UTF-8?q?new=5Fmain.py=20=D1=82=D0=B5=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D1=8C=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 4 +- new_main.py | 379 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 new_main.py diff --git a/main.py b/main.py index 99e7032..ff951f2 100644 --- a/main.py +++ b/main.py @@ -293,5 +293,5 @@ def main(): show_table(west, 'Конференция Запад ' + season) show_table(east, 'Конференция Восток ' + season) - -main() +if __name__ == "__main__": + main() diff --git a/new_main.py b/new_main.py new file mode 100644 index 0000000..4522b77 --- /dev/null +++ b/new_main.py @@ -0,0 +1,379 @@ +''' +new_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. Для таблиц конференций учитываются дивизионы: в нужных сезонах победители + дивизионов получают места 1 и 2 внутри своей конференции. + 6. show_table(...) печатает таблицу: общую по чемпионату и отдельно по двум + конференциям (Запад / Восток). + +ВХОДНЫЕ ДАННЫЕ (столбцы CSV): + Команда_1 - хозяева, Команда_2 - гости; + Период_1/2/3 - счёт по периодам в формате "3:1" (хозяева:гости); + Овертайм, Буллиты - счёт за овертайм и серию буллитов (или ":" если их не было). + +Зависимости: pandas (таблицы), numpy (нумерация мест). +''' + +import sys # для аргументов командной строки +import os # чтобы достать имя файла из пути +import pandas as pd +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}) + return d + + +# ключ - это код сезона (например, 2018_19) +CONFERENCES = { + '2018_19': make_conf( + west=['ЦСКА', 'СКА', 'Локомотив', 'Йокерит', 'Динамо М', 'ХК Сочи', + 'Спартак', 'Витязь', 'Динамо Р', 'Северсталь', 'Динамо Мн', 'Слован'], + east=['Автомобилист', 'Барыс', 'Металлург Мг', 'Авангард', 'Ак Барс', + 'Салават Юлаев', 'Торпедо НН', 'Трактор', 'Сибирь', 'Нефтехимик', + 'Куньлунь РС', 'Адмирал', 'Амур'], + ), + '2019_20': make_conf( + west=['ЦСКА', 'СКА', 'Йокерит', 'Динамо М', 'Спартак', 'Локомотив', + 'Витязь', 'Торпедо НН', 'ХК Сочи', 'Северсталь', 'Динамо Р', 'Динамо Мн'], + east=['Ак Барс', 'Барыс', 'Авангард', 'Автомобилист', 'Сибирь', + 'Салават Юлаев', 'Металлург Мг', 'Нефтехимик', 'Амур', 'Куньлунь РС', + 'Трактор', 'Адмирал'], + ), + '2020_21': make_conf( + west=['ЦСКА', 'СКА', 'Динамо М', 'Локомотив', 'Йокерит', 'Северсталь', + 'Динамо Мн', 'Спартак', 'Витязь', 'ХК Сочи', 'Динамо Р'], + east=['Ак Барс', 'Авангард', 'Металлург Мг', 'Салават Юлаев', 'Трактор', + 'Барыс', 'Автомобилист', 'Торпедо НН', 'Сибирь', 'Амур', 'Нефтехимик', + 'Куньлунь РС'], + ), + '2021_22': make_conf( + west=['СКА', 'Йокерит', 'ЦСКА', 'Динамо М', 'Северсталь', 'Локомотив', + 'Спартак', 'Динамо Мн', 'Торпедо', 'Витязь', 'ХК Сочи', 'Динамо Р'], + east=['Металлург Мг', 'Трактор', 'Салават Юлаев', 'Ак Барс', 'Авангард', + 'Сибирь', 'Нефтехимик', 'Барыс', 'Автомобилист', 'Амур', 'Адмирал', + 'Куньлунь РС'], + ), +} + + +def make_div(bobrov, tarasov, kharlamov, chernyshev): + ''' + Собрать словарь {название команды -> дивизион} из четырёх списков команд. + + Дивизионы Боброва и Тарасова относятся к Западу, Харламова и Чернышёва - + к Востоку. Нужны для финального посева победителей дивизионов в таблицах + конференций. + ''' + d = {} + d.update({team: 'Боброва' for team in bobrov}) + d.update({team: 'Тарасова' for team in tarasov}) + d.update({team: 'Харламова' for team in kharlamov}) + d.update({team: 'Чернышёва' for team in chernyshev}) + return d + + +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=['Авангард', 'Амур', 'Барыс', 'Сибирь', 'Салават Юлаев', 'Адмирал'], + ), +} + + +SEED_BY_DIVISION = { + '2018_19': True, + '2019_20': True, + '2020_21': True, + '2021_22': False, +} + + +def seed_conference(conf_table): + ''' + Поставить победителей двух дивизионов на места 1 и 2 в конференции. + + На вход приходит таблица одной конференции, уже отсортированная по основным + правилам КХЛ. Поэтому первая команда каждого дивизиона - победитель + дивизиона, а порядок между двумя победителями уже правильный. + ''' + winners = conf_table.groupby('Див', sort=False).head(1) + rest = conf_table.drop(winners.index) + return pd.concat([winners, rest]) + + +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 + + +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') # второй + 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 # сколько гости забили за три периода + + # Определяем ТИП исхода каждого матча через сравнение столбцов + # (любое сравнение возвращает столбец False/True) + + tie = (reg_h == reg_a) # True там, где после 3 периодов была ничья + + win_home_reg = (reg_h > reg_a) # хозяева победили в основное время + win_away_reg = (reg_a > reg_h) # гости победили в основное время + + win_home_ot = tie & (oth > ota) # ничья в основное + хозяева забили в ОТ = победа в ОТ + win_away_ot = tie & (ota > oth) # ничья в основное + гости забили в ОТ + + 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 + + ''' + Считаем заброшенные шайбы для итоговой таблицы. + К голам в основное время прибавляем голы овертайма, а победителю серии буллитов + добавляем ровно 1 решающую шайбу. astype(int) переводит True/False в 1/0. + ''' + goals_home = reg_h + oth + win_home_so.astype(int) # все шайбы хозяев + goals_away = reg_a + ota + win_away_so.astype(int) # все шайбы гостей + + ''' + Конвертируем "по одной строке на матч" -> "по две строки на матч". + В каждом матче участвует 2 команды. Делаем две таблицы: + home - взгляд со стороны хозяев, away - со стороны гостей, + а потом склеиваем и группируем по названию команды. + Столбцы В, ВО, ВБ, ПБ, ПО, П - это булевы маски (выиграл/проиграл). + ''' + home = pd.DataFrame({ + 'Команда': df['Команда_1'], # имя хозяев + 'В': win_home_reg, # победа в основное время + 'ВО': win_home_ot, # победа в овертайме + 'ВБ': win_home_so, # победа по буллитам + 'ПБ': win_away_so, # поражение по буллитам (гости выиграли буллиты) + 'ПО': win_away_ot, # поражение в овертайме + 'П': win_away_reg, # поражение в основное время + 'ГЗ': goals_home, # голы, забитые хозяевами + 'ГП': goals_away, # голы, пропущенные (забитые гостями) + }) + away = pd.DataFrame({ + 'Команда': df['Команда_2'], + 'В': win_away_reg, + 'ВО': win_away_ot, + 'ВБ': win_away_so, + 'ПБ': win_home_so, + 'ПО': win_home_ot, + 'П': win_home_reg, + 'ГЗ': goals_away, + 'ГП': goals_home, + }) + + # pd.concat склеивает две таблицы одну под другой (как два списка матчей подряд). + # ignore_index=True перенумеровывает строки заново, чтобы номера не повторялись. + both = pd.concat([home, away], ignore_index=True) + + # Булевы столбцы (True/False) переводим в числа (1/0), чтобы их можно было суммировать. + flag_cols = ['В', 'ВО', 'ВБ', 'ПБ', 'ПО', 'П'] + both[flag_cols] = both[flag_cols].astype(int) + + # Голы тоже переводим в целые числа (после fillna(0) они стали дробными). + both[['ГЗ', 'ГП']] = both[['ГЗ', 'ГП']].astype(int) + + # Суммируем всё по каждой команде. + # groupby('Команда').sum() - сгруппируй строки по названию команды и сложи числа. + table = both.groupby('Команда').sum() + + # Производные показатели: + + # И - количество игр = сумма всех исходов (каждый матч - ровно один из шести исходов) + table['И'] = table[flag_cols].sum(axis=1) # axis=1 - складывать вдоль строки + # О - очки по системе КХЛ: любая победа = 2, поражение в ОТ/буллитах = 1, в осн. время = 0 + table['О'] = 2 * (table['В'] + table['ВО'] + table['ВБ']) + (table['ПБ'] + table['ПО']) + # Р - разница шайб (дополнительный критерий при равенстве) + table['Р'] = table['ГЗ'] - table['ГП'] + + ''' + %О - процент набранных очков = очки / максимально возможные (2 за каждую игру). + По этому проценту КХЛ расставляет места, когда команды сыграли разное число игр. + Сайт КХЛ НЕ округляет, а отбрасывает знаки после второго (72.448 -> 72.44), + поэтому считаем в целых числах: умножаем на 10000 и делим нацело (//) на 2*И - + остаток (дробная часть) отбрасывается, затем делим на 100. Целочисленная + арифметика без float, поэтому ровные значения (57.00) не съезжают в 56.99. + ''' + table['%О'] = (table['О'] * 10000 // (2 * table['И'])) / 100 + # Подписываем каждой команде её конференцию. .map(словарь) проходит по индексу + # (названиям команд) и подставляет значение из словаря. + table['Конф'] = pd.Series(table.index, index=table.index).map(conf_map) + + ''' + Сортировка: по проценту очков, затем при равенстве - больше побед в основное время, + затем в овертайме, затем по буллитам, затем лучше разница шайб, затем больше забито. + ''' + table = table.sort_values( + by=['%О', 'В', 'ВО', 'ВБ', 'Р', 'ГЗ'], + ascending=False, + ) + return table + + +def show_table(table, title): + ''' + Напечатать турнирную таблицу в консоль в виде текста. + + Принимает: table - готовая (уже отсортированная) таблица команд; + title - заголовок над таблицей. + Ничего не возвращает - только печатает. + + Что делает: оставляет нужные столбцы в порядке, как на сайте КХЛ, добавляет + слева колонку "Место" (1..N по порядку строк) и колонку "Команда" + (название берётся из индекса таблицы), затем выводит всё через to_string. + ''' + # Колонки в том же порядке и с теми же подписями, что на сайте КХЛ. + cols = ['И', 'В', 'ВО', 'ВБ', 'ПБ', 'ПО', 'П', 'ГЗ', 'ГП', 'Р', 'О', '%О'] + view = table[cols].copy() # берём только нужные столбцы (копия, чтобы не трогать оригинал) + # Место по порядку строк. np.arange(1, N+1) создаёт массив [1..N]. + view.insert(0, 'Место', np.arange(1, len(view) + 1)) + # Имя команды (индекс таблицы) ставим отдельным столбцом, чтобы оно было видно при выводе. + view.insert(1, 'Команда', view.index) + + print('\n' + '=' * 80) + print(title) + print('=' * 80) + print(view.to_string(index=False)) + + +def main(): + ''' + Точка входа. Связывает все шаги вместе: + читает путь к CSV из аргумента командной строки -> по имени файла определяет + сезон -> строит таблицу через build_standings -> печатает общую таблицу и две + таблицы по конференциям (Запад/Восток), отфильтрованные по столбцу 'Конф'. + Для сезонов с правилом посева победители дивизионов поднимаются на места 1-2. + ''' + path = sys.argv[1] # получаем путь до csv + name = os.path.basename(path) # из khl_3/khl_*.csv берём только khl_*.csv + season = name.replace('khl_', '').replace('.csv', '') + conf_map = CONFERENCES[season] + div_map = DIVISIONS[season] + + df = pd.read_csv(path) + table = build_standings(df, conf_map) + table['Див'] = pd.Series(table.index, index=table.index).map(div_map) + + # Итоговая таблица чемпионата + show_table(table, 'Итоговая таблица чемпионата ' + season) + + # Итоговые таблицы по конференциям (фильтруем по столбцу Конф) + west = table[table['Конф'] == 'Запад'] + east = table[table['Конф'] == 'Восток'] + if SEED_BY_DIVISION[season]: + west = seed_conference(west) + east = seed_conference(east) + + show_table(west, 'Конференция Запад ' + season) + show_table(east, 'Конференция Восток ' + season) + +if __name__ == "__main__": + main()