summer_practice/main.py
2026-06-30 10:49:28 +03:00

297 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'''
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
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 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 -> печатает общую таблицу и две
таблицы по конференциям (Запад/Восток), отфильтрованные по столбцу 'Конф'.
'''
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]
df = pd.read_csv(path)
table = build_standings(df, conf_map)
# Итоговая таблица чемпионата
show_table(table, 'Итоговая таблица чемпионата ' + season)
# Итоговые таблицы по конференциям (фильтруем по столбцу Конф)
west = table[table['Конф'] == 'Запад']
east = table[table['Конф'] == 'Восток']
show_table(west, 'Конференция Запад ' + season)
show_table(east, 'Конференция Восток ' + season)
if __name__ == "__main__":
main()