Compare commits

...

10 commits

6 changed files with 1285 additions and 24 deletions

152
3.py Normal file
View file

@ -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_<team>.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()

157
README.md
View file

@ -1,2 +1,157 @@
# summer_practice # Летняя практика — Турнирные таблицы КХЛ
Проект анализирует результаты матчей регулярного чемпионата КХЛ за сезоны
**2018/19 2021/22** и строит по ним турнирные таблицы и график.
Основные инструменты — библиотека **pandas** (работа с таблицами) и
**matplotlib** (график).
---
## 1. Структура проекта
| Файл / папка | Назначение |
|---|---|
| `main.py` | Основная программа: строит полную турнирную таблицу чемпионата и таблицы по конференциям (Запад / Восток). |
| `divisions.py` | Отдельный модуль: добавляет деление на **дивизионы** и **правило посева** победителей дивизионов. Логику расчёта берёт из `main.py`, сам `main.py` не меняет. |
| `3.py` | Задание №3: **график** разницы заброшенных и пропущенных шайб одной команды по ходу сезона. |
| `khl_3/` | Исходные данные — CSV-файлы матчей (4 сезона, кодировка UTF-8). |
### Формат исходных данных (CSV)
```
,Номер,Дата,Команда_1,Команда_2,Период_1,Период_2,Период_3,Овертайм,Буллиты
0,1,2018-09-01,Ак Барс,СКА,0:1,1:2,0:3,:,:
```
* `Команда_1` — хозяева, `Команда_2` — гости.
* ериод_1..3`, `Овертайм`, `Буллиты` — счёт в формате `хозяева:гости`.
* Пустой `:` означает, что период не игрался (нет овертайма/буллитов).
* **Технические результаты** записаны знаками: `+ — -` (техпобеда хозяев) и
`- — +` (техпобеда гостей). Встречаются в сезоне 2020/21.
---
## 2. Установка и запуск
```bash
pip install pandas numpy matplotlib
# Полная турнирная таблица + таблицы по конференциям
python3 main.py khl_3/khl_2018_19.csv
# Таблицы по конференциям с дивизионами и посевом победителей дивизионов
python3 divisions.py khl_3/khl_2020_21.csv
# График разницы шайб одной команды по датам (сохраняется в diff_<команда>.png)
python3 3.py khl_3/khl_2018_19.csv "ЦСКА"
```
> Имя файла важно: программы достают код сезона (`2018_19`) из имени
> `khl_2018_19.csv`, чтобы понять, какие команды в какой конференции (и дивизионе)
> играли.
---
## 3. Как работает `main.py` (ядро проекта)
Логика построена на **векторных операциях pandas**: вычисления делаются сразу
над целым столбцом, без циклов по строкам.
**Шаг 1. `parse_score(df, col)`** — разбирает строку счёта `"3:1"` на два числа.
`str.split(':', expand=True)` раскладывает столбец в два, `to_numeric(..., errors='coerce')`
превращает текст в числа, а пустой период (`:`) или знаки техрезультата через
`fillna(0)` считаются как `0`.
**Шаг 2. `build_standings(df, conf_map)`** — превращает список матчей в таблицу команд:
1. Считает голы в основное время (сумма трёх периодов).
2. Определяет **тип исхода** каждого матча сравнением столбцов:
победа в основное время / в овертайме / по буллитам — и аналогично поражения.
Победителю по буллитам добавляется ровно 1 решающая шайба.
3. Отдельно обрабатывает **технические результаты** (`+ — -` / `- — +`):
засчитывает их как победу/поражение в основное время, но шайбы не начисляет (0:0).
4. **«Разворачивает» матч в две строки** — взгляд хозяев и взгляд гостей —
и склеивает их (`pd.concat`). Теперь у каждой команды по строке на матч.
5. `groupby('Команда').sum()` суммирует все показатели по командам.
6. Считает производные показатели:
* **И** — игры, **О** — очки (победа = 2, поражение в ОТ/буллитах = 1),
* **Р** — разница шайб, **%О** — процент набранных очков.
* `%О` считается **в целых числах** (`* 10000 // (2*И) / 100`) с отбрасыванием
знаков, как на сайте КХЛ, — без округления и без ошибок float (57.00 не съезжает в 56.99).
7. Сортирует по регламенту КХЛ: `%ОВВО → ВБ → Р → ГЗ`.
**Шаг 3. `show_table` и `main`** — печатают полную таблицу, затем отдельно
конференции Запад и Восток (фильтр по столбцу `Конф`).
### Обозначения столбцов
`И` — игры · `В` — победы в основное время · `ВО` — победы в овертайме ·
`ВБ` — победы по буллитам · `ПБ` — поражения по буллитам · `ПО` — поражения в овертайме ·
`П` — поражения в основное время · `ГЗ` — голы забитые · `ГП` — голы пропущенные ·
`Р` — разница · `О` — очки · `%О` — процент очков.
---
## 4. Дополнительные файлы
### `divisions.py` — дивизионы и посев
В части сезонов КХЛ (2018/192020/21) действовало правило: **победители двух
дивизионов получают места 1 и 2 в конференции**, даже если по очкам они не в топ-2.
Например, в 2020/21 на Западе СКА (82 очка) стоит выше Динамо М (84) и
Локомотива (83) — СКА выиграл дивизион Боброва. В сезоне 2021/22 это правило
отменили (чистый порядок по %О).
`divisions.py`:
* переиспользует `build_standings` и `CONFERENCES` из `main.py` (не дублируя логику и
**не изменяя** `main.py`);
* словарь `DIVISIONS` задаёт состав четырёх дивизионов (Боброва, Тарасова — Запад;
Харламова, Чернышёва — Восток) по каждому сезону;
* `SEED_BY_DIVISION` включает посев только там, где он применялся;
* `seed_conference` поднимает победителей дивизионов на места 12;
* выводит таблицы конференций с дополнительным столбцом `Див`.
### `3.py` — график разницы шайб
Задание №3: строит **график** разницы заброшенных и пропущенных шайб одной команды
по датам матчей. Считает голы в каждом матче (`match_goals`, та же логика подсчёта,
что в `main.py`, включая зануление технических результатов), разносит матч на взгляд
хозяев и гостей, оставляет нужную команду, рисует линию «забито пропущено» по дате
и сохраняет картинку `diff_<команда>.png`.
---
## 5. Материал к защите
**О чём проект.** По «сырым» результатам матчей КХЛ автоматически строится
официальная турнирная таблица, таблицы с дивизионами и график. Цель — освоить
обработку табличных данных в pandas и визуализацию в matplotlib.
**Ключевые идеи, которые стоит назвать на защите:**
1. **Векторизация вместо циклов.** Все расчёты — операции над столбцами целиком
(сложение, сравнение, маски `True/False`). Это и быстрее, и короче, чем `for` по строкам.
2. **Приём «одна строка матча → две строки команд».** Чтобы посчитать статистику
команды, каждый матч дублируется: одна запись со стороны хозяев, другая — гостей.
После этого `groupby` сводит всё в таблицу команд.
3. **Булевы маски.** Тип исхода (победа/поражение и как именно) — это столбцы `True/False`,
которые переводятся в `1/0` через `.astype(int)` и суммируются.
4. **Точное соответствие регламенту КХЛ.** Система очков (2/1/0), добавление шайбы
за буллиты, технические результаты, целочисленный (без округления) процент очков,
порядок критериев сортировки, посев победителей дивизионов.
5. **Переиспользование кода.** `divisions.py` и `3.py` опираются на логику из `main.py`,
чтобы не дублировать разбор счёта и расчёт таблицы.
**Возможные вопросы и ответы:**
* *Почему пустой период = 0:0?* — Овертайм/буллиты есть не во всех матчах; `fillna(0)`
делает расчёт единообразным.
* *Почему %О считается в целых числах, а не через `round`?* — Сайт КХЛ отбрасывает
знаки после второго (а не округляет), и целочисленная арифметика не даёт ошибок
float (57.00 вместо 56.99...).
* *Как обрабатываются технические результаты?*`+ — -` / `- — +` засчитываются как
победа/поражение в основное время, но без шайб (0:0), как в КХЛ.
* *Почему дивизионы в отдельном файле?* — чтобы не усложнять `main.py`; `divisions.py`
переиспользует его функции, ничего в нём не меняя.
* *Как добавить новый сезон?* — добавить CSV в `khl_3/`, описать конференции в
`CONFERENCES``main.py`) и при необходимости дивизионы в `DIVISIONS``divisions.py`).

57
individual_4.py Normal file
View file

@ -0,0 +1,57 @@
import sys
import pandas as pd
import matplotlib.pyplot as plt
# разбираем счёт "3:1" на два числа, пустой период считаем как 0
def parse_score(df, col):
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 task4(filename):
df = pd.read_csv(filename)
# счёт по периодам, овертайму и буллитам
h1, a1 = parse_score(df, 'Период_1')
h2, a2 = parse_score(df, 'Период_2')
h3, a3 = parse_score(df, 'Период_3')
oth, ota = parse_score(df, 'Овертайм')
soh, soa = parse_score(df, 'Буллиты')
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)
goals_home = reg_h + oth + win_home_so.astype(int)
goals_away = reg_a + ota + win_away_so.astype(int)
# собираем шайбы обеих команд и суммируем по каждой
home = pd.DataFrame({'Команда': df['Команда_1'], 'ГЗ': goals_home})
away = pd.DataFrame({'Команда': df['Команда_2'], 'ГЗ': goals_away})
both = pd.concat([home, away], ignore_index=True)
totals = both.groupby('Команда')['ГЗ'].sum().astype(int)
totals = totals.sort_values(ascending=False)
# рисуем гистограмму
plt.figure(figsize=(12, 6))
plt.bar(totals.index, totals.values, color='steelblue')
plt.title('Всего заброшено шайб за сезон')
plt.xlabel('Команда')
plt.ylabel('Заброшено шайб')
plt.xticks(rotation=90)
for i, value in enumerate(totals.values):
plt.text(i, value, str(value), ha='center', va='bottom')
plt.tight_layout()
plt.show()
if __name__ == '__main__':
task4(sys.argv[1])

127
main.py
View file

@ -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 sys # для аргументов командной строки
import os # чтобы достать имя файла из пути import os # чтобы достать имя файла из пути
import pandas as pd import pandas as pd
@ -5,6 +27,14 @@ import numpy as np
def make_conf(west, east): def make_conf(west, east):
'''
Собрать словарь {название команды -> конференция} из двух списков команд.
Принимает: west, east - списки названий команд Западной и Восточной конференций.
Возвращает: dict, где каждой команде сопоставлена строка 'Запад' или 'Восток'.
Нужен, чтобы позже одной операцией .map() приписать каждой команде её конференцию.
'''
d = {} d = {}
d.update({team: 'Запад' for team in west}) d.update({team: 'Запад' for team in west})
d.update({team: 'Восток' for team in east}) 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): 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) parts = df[col].str.split(':', expand=True)
home = pd.to_numeric(parts[0], errors='coerce').fillna(0) home = pd.to_numeric(parts[0], errors='coerce').fillna(0)
away = pd.to_numeric(parts[1], errors='coerce').fillna(0) away = pd.to_numeric(parts[1], errors='coerce').fillna(0)
return home, away return home, away
# Шаг 2. Функция, которая из таблицы матчей строит таблицу команд.
def build_standings(df, conf_map): 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 периоде h1, a1 = parse_score(df, 'Период_1') # голы хозяев/гостей в 1 периоде
h2, a2 = parse_score(df, 'Период_2') # второй 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_home_so = tie & (oth == ota) & (soh > soa) # ОТ ничейный -> буллиты выиграли хозяева
win_away_so = tie & (oth == ota) & (soa > soh) # буллиты выиграли гости 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 за каждую игру). %О - процент набранных очков = очки / максимально возможные (2 за каждую игру).
По этому проценту КХЛ расставляет места, когда команды сыграли разное число игр. По этому проценту КХЛ расставляет места, когда команды сыграли разное число игр.
Сайт КХЛ НЕ округляет, а отбрасывает знаки после второго (72.448 -> 72.44), Сайт КХЛ НЕ округляет, а отбрасывает знаки после второго (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(словарь) проходит по индексу # Подписываем каждой команде её конференцию. .map(словарь) проходит по индексу
# (названиям команд) и подставляет значение из словаря. # (названиям команд) и подставляет значение из словаря.
table['Конф'] = pd.Series(table.index, index=table.index).map(conf_map) table['Конф'] = pd.Series(table.index, index=table.index).map(conf_map)
@ -178,8 +243,18 @@ def build_standings(df, conf_map):
return table return table
# Вывод таблицы текстом
def show_table(table, title): def show_table(table, title):
'''
Напечатать турнирную таблицу в консоль в виде текста.
Принимает: table - готовая (уже отсортированная) таблица команд;
title - заголовок над таблицей.
Ничего не возвращает - только печатает.
Что делает: оставляет нужные столбцы в порядке, как на сайте КХЛ, добавляет
слева колонку "Место" (1..N по порядку строк) и колонку "Команда"
(название берётся из индекса таблицы), затем выводит всё через to_string.
'''
# Колонки в том же порядке и с теми же подписями, что на сайте КХЛ. # Колонки в том же порядке и с теми же подписями, что на сайте КХЛ.
cols = ['И', 'В', 'ВО', 'ВБ', 'ПБ', 'ПО', 'П', 'ГЗ', 'ГП', 'Р', 'О', '%О'] cols = ['И', 'В', 'ВО', 'ВБ', 'ПБ', 'ПО', 'П', 'ГЗ', 'ГП', 'Р', 'О', '%О']
view = table[cols].copy() # берём только нужные столбцы (копия, чтобы не трогать оригинал) view = table[cols].copy() # берём только нужные столбцы (копия, чтобы не трогать оригинал)
@ -195,6 +270,12 @@ def show_table(table, title):
def main(): def main():
'''
Точка входа. Связывает все шаги вместе:
читает путь к CSV из аргумента командной строки -> по имени файла определяет
сезон -> строит таблицу через build_standings -> печатает общую таблицу и две
таблицы по конференциям (Запад/Восток), отфильтрованные по столбцу 'Конф'.
'''
path = sys.argv[1] # получаем путь до csv path = sys.argv[1] # получаем путь до csv
name = os.path.basename(path) # из khl_3/khl_*.csv берём только khl_*.csv name = os.path.basename(path) # из khl_3/khl_*.csv берём только khl_*.csv
season = name.replace('khl_', '').replace('.csv', '') season = name.replace('khl_', '').replace('.csv', '')
@ -212,5 +293,5 @@ def main():
show_table(west, 'Конференция Запад ' + season) show_table(west, 'Конференция Запад ' + season)
show_table(east, 'Конференция Восток ' + season) show_table(east, 'Конференция Восток ' + season)
if __name__ == "__main__":
main() main()

379
new_main.py Normal file
View file

@ -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()

437
РАЗБОР_КОДА.md Normal file
View file

@ -0,0 +1,437 @@
# Разбор кода построчно (для защиты)
Здесь разобраны три файла проекта — **`main.py`**, **`divisions.py`**, **`3.py`** —
и **каждая функция из библиотек** (pandas, numpy, matplotlib, sys, os).
Читается сверху вниз — можно отвечать прямо по тексту.
---
## Справочник функций из библиотек
Чтобы не повторять одно и то же, вот что делает каждая чужая функция/метод в проекте:
### `sys` и `os` (стандартная библиотека Python)
* `sys.argv` — список аргументов командной строки. `sys.argv[0]` — имя скрипта, `sys.argv[1]` — первый аргумент и т.д.
* `os.path.basename(path)` — берёт из пути `khl_3/khl_2018_19.csv` только имя файла `khl_2018_19.csv`.
* `os.path.abspath(path)` / `os.path.dirname(path)` — полный путь к файлу / папка, в которой он лежит. Нужны в `divisions.py`, чтобы найти `main.py` рядом с собой.
### `pandas` (сокращённо `pd`) — работа с таблицами
* `pd.read_csv(path)` — читает CSV-файл в **DataFrame** (таблицу).
* **DataFrame** — двумерная таблица (строки × столбцы). `df['Столбец']` — это один столбец (объект **Series**).
* **Series** — один столбец таблицы: массив значений с индексом (номерами/подписями строк).
* `df[col].str.split(':', expand=True)`у строкового столбца разбивает каждую ячейку по `:`; `expand=True` раскладывает результат в несколько столбцов.
* `df[col].astype(str).str.strip()` — приводит столбец к тексту и убирает пробелы по краям.
* `series.str.startswith('+')` / `series.str.endswith('+')` — маска `True/False`: начинается/заканчивается ли строка на `+`.
* `pd.to_numeric(series, errors='coerce')` — превращает текст в числа; `errors='coerce'` ставит `NaN` (пусто), если значение не число.
* `series.fillna(0)` — заменяет пустые значения `NaN` на 0.
* `series.where(условие, 0)` — оставляет значение, где условие `True`, иначе ставит 0.
* `pd.DataFrame({...})` — создаёт новую таблицу из словаря `{'имя столбца': данные}`.
* `pd.concat([df1, df2], ignore_index=True)` — склеивает таблицы одну под другой; `ignore_index=True` перенумеровывает строки заново.
* `series.astype(int)` — переводит тип в целые числа (в т.ч. `True/False → 1/0`).
* `df.groupby('Команда').sum()` — группирует строки по командам и складывает числа внутри каждой группы.
* `df.groupby('Див', sort=False).head(1)` — берёт первую строку каждой группы (`sort=False` — не пересортировывать группы).
* `df[cols].sum(axis=1)` — сумма по строке (вдоль столбцов). `axis=0` — по столбцу.
* `df.sort_values(by=[...], ascending=False)` — сортирует строки по указанным столбцам (по убыванию).
* `series.map(словарь)` — заменяет каждое значение по словарю (ключ → значение).
* `df.copy()` — делает независимую копию таблицы (чтобы не менять оригинал).
* `df.drop(индексы)` — выбрасывает строки с указанными индексами.
* `df.insert(позиция, 'имя', данные)` — вставляет новый столбец на заданную позицию.
* `df.to_string(index=False)` — превращает таблицу в текст для печати; `index=False` — без столбца индекса.
* `df.index` — подписи строк (здесь — названия команд после `groupby`).
* `pd.to_datetime(series)` — превращает текст с датой в настоящую дату (чтобы ось X шла по времени).
### `numpy` (сокращённо `np`) — числовые операции
* `np.arange(1, N+1)` — массив чисел `[1, 2, ..., N]` (для нумерации мест).
### `matplotlib.pyplot` (сокращённо `plt`) — графики
* `plt.subplots(figsize=(ш, в))` — создаёт холст (`fig`) и оси (`ax`) заданного размера в дюймах.
* `ax.plot(x, y, marker='o')` — линия с точками-маркерами.
* `ax.axhline(0, ...)` — горизонтальная линия на уровне 0.
* `ax.set_title / set_xlabel / set_ylabel` — заголовок и подписи осей.
* `ax.grid(True, alpha=...)` — координатная сетка (`alpha` — прозрачность).
* `fig.autofmt_xdate()` — автоматически поворачивает подписи дат, чтобы не слипались.
* `fig.savefig(path, dpi=150, bbox_inches='tight')` — сохранить график в файл (`tight` — обрезать поля).
* `plt.show()` — показать график в окне.
---
## `main.py` — построчно
```python
import sys # доступ к аргументам командной строки (sys.argv)
import os # работа с путями файлов (os.path.basename)
import pandas as pd # таблицы (DataFrame); pd — общепринятое сокращение
import numpy as np # числовые операции (np.arange для нумерации мест)
```
### Функция `make_conf` — собирает словарь «команда → конференция»
```python
def make_conf(west, east):
d = {} # пустой словарь
d.update({team: 'Запад' for team in west}) # каждой команде с Запада ставим метку 'Запад'
d.update({team: 'Восток' for team in east}) # каждой команде с Востока — 'Восток'
return d # отдаём готовый словарь
```
* `{team: 'Запад' for team in west}`**словарное включение** (dict comprehension): пробегает по списку команд и строит словарь `{'ЦСКА': 'Запад', ...}`.
* `d.update(...)` — добавляет пары в словарь `d`.
### Словарь `CONFERENCES`
```python
CONFERENCES = {
'2018_19': make_conf(west=[...], east=[...]), # для каждого сезона свой состав конференций
'2019_20': make_conf(...),
...
}
```
* Ключ — код сезона (`'2018_19'`), значение — словарь «команда → конференция».
* Зачем: составы конференций по годам менялись, поэтому для каждого сезона свой список.
### Функция `parse_score` — разбор счёта «3:1»
```python
def parse_score(df, col):
parts = df[col].str.split(':', expand=True) # "3:1" -> два столбца: ["3"], ["1"]
home = pd.to_numeric(parts[0], errors='coerce').fillna(0)# левое число -> голы хозяев; пусто/знак -> 0
away = pd.to_numeric(parts[1], errors='coerce').fillna(0)# правое число -> голы гостей; пусто/знак -> 0
return home, away # возвращаем два столбца (Series)
```
* `parts[0]` / `parts[1]` — первый и второй столбцы результата split.
* Пустой период `:` или знаки техрезультата (`+ — -`) после split не превращаются в число → `to_numeric` делает `NaN``fillna(0)` ставит 0.
### Функция `build_standings` — главный расчёт таблицы
```python
def build_standings(df, conf_map):
h1, a1 = parse_score(df, 'Период_1') # голы хозяев(h)/гостей(a) в 1-м периоде
h2, a2 = parse_score(df, 'Период_2') # во 2-м
h3, a3 = parse_score(df, 'Период_3') # в 3-м
oth, ota = parse_score(df, 'Овертайм') # в овертайме (0/0, если его не было)
soh, soa = parse_score(df, 'Буллиты') # в серии буллитов (0/0, если её не было)
```
Здесь `h1, a1 = ...`**распаковка кортежа**: функция вернула два значения, кладём их в две переменные.
```python
reg_h = h1 + h2 + h3 # голы хозяев в основное время (поэлементная сумма столбцов)
reg_a = a1 + a2 + a3 # голы гостей в основное время
```
Сложение двух Series складывает их **построчно** (матч за матчем).
```python
tie = (reg_h == reg_a) # столбец True/False: True там, где в основное время ничья
win_home_reg = (reg_h > reg_a)# хозяева выиграли в основное время
win_away_reg = (reg_a > reg_h)# гости выиграли в основное время
```
Сравнение столбцов (`==`, `>`) возвращает **столбец булевых значений** (маску).
```python
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) # буллиты выиграли гости
```
`&` — поэлементное «И» для булевых столбцов (обе маски должны быть True в одной строке).
**Технические результаты** (неявка/отмена матча):
```python
p1 = df['Период_1'].astype(str).str.strip()
tech_home = p1.str.startswith('+') # "+ — -": техническая победа хозяев
tech_away = p1.str.endswith('+') # "- — +": техническая победа гостей
tech = tech_home | tech_away
```
В файле такой матч записан не цифрами, а знаками, поэтому обычная классификация выше сочла его «ничьёй 0:0». Перекрываем её:
```python
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
```
* `~tech` — поэлементное «НЕ» (инвертирует маску): «это НЕ технический матч».
* КХЛ засчитывает техрезультат как победу/поражение в основное время (2 и 0 очков), но шайбы не начисляет — поэтому ниже голы у такого матча останутся 0:0.
```python
goals_home = reg_h + oth + win_home_so.astype(int) # все шайбы хозяев: основное + ОТ + 1 за выигр. буллиты
goals_away = reg_a + ota + win_away_so.astype(int) # все шайбы гостей
```
`win_home_so.astype(int)``True/False` превращаем в `1/0`, чтобы прибавить как число. У технического матча все слагаемые равны 0, поэтому шайб нет.
```python
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,
})
```
**Главный приём:** один матч превращаем в две записи — статистику хозяев и статистику гостей.
```python
both = pd.concat([home, away], ignore_index=True) # склеиваем обе таблицы в одну
flag_cols = ['В', 'ВО', 'ВБ', 'ПБ', 'ПО', 'П'] # столбцы-исходы
both[flag_cols] = both[flag_cols].astype(int) # True/False -> 1/0 (чтобы суммировать)
both[['ГЗ', 'ГП']] = both[['ГЗ', 'ГП']].astype(int)# голы делаем целыми (после fillna были дробными)
table = both.groupby('Команда').sum() # группируем по команде и складываем все числа
```
После этого индекс таблицы — названия команд, а в ячейках — суммы за сезон.
```python
table['И'] = table[flag_cols].sum(axis=1) # игры = сумма всех 6 исходов (axis=1 = вдоль строки)
table['О'] = 2*(table['В']+table['ВО']+table['ВБ']) + (table['ПБ']+table['ПО']) # очки: победа=2, пораж. в ОТ/буллитах=1
table['Р'] = table['ГЗ'] - table['ГП'] # разница шайб
```
```python
table['%О'] = (table['О'] * 10000 // (2 * table['И'])) / 100
```
Процент очков = очки / максимум (2 за игру). Считается **в целых числах**: умножаем на 10000, делим **нацело** (`//`) на `2*И` — дробная часть отбрасывается (как на сайте КХЛ), потом делим на 100. Целочисленная арифметика не даёт ошибок float, поэтому ровные значения (57.00) не съезжают в 56.99.
```python
table['Конф'] = pd.Series(table.index, index=table.index).map(conf_map)
```
Берём названия команд (индекс), и `.map(conf_map)` подставляет каждой её конференцию из словаря.
```python
table = table.sort_values(by=['%О', 'В', 'ВО', 'ВБ', 'Р', 'ГЗ'], ascending=False)
return table
```
Сортировка по регламенту: сначала по %очков, при равенстве — больше побед в осн. время, затем в ОТ, по буллитам, разница шайб, забитые. `ascending=False` = по убыванию.
### Функция `show_table` — печать таблицы
```python
def show_table(table, title):
cols = ['И','В','ВО','ВБ','ПБ','ПО','П','ГЗ','ГП','Р','О','%О'] # порядок столбцов как на сайте КХЛ
view = table[cols].copy() # берём только эти столбцы (копия, чтобы не портить оригинал)
view.insert(0, 'Место', np.arange(1, len(view) + 1)) # столбец «Место» = 1,2,3,... (строки уже отсортированы)
view.insert(1, 'Команда', view.index) # имя команды из индекса делаем видимым столбцом
print('\n' + '=' * 80) # разделитель из 80 знаков «=»
print(title) # заголовок
print('=' * 80)
print(view.to_string(index=False)) # печатаем таблицу без служебного индекса
```
* `len(view)` — количество строк (команд).
* `'=' * 80` — повтор строки 80 раз.
### Функция `main` — точка входа
```python
def main():
path = sys.argv[1] # путь к csv из командной строки
name = os.path.basename(path) # только имя файла: khl_2018_19.csv
season = name.replace('khl_', '').replace('.csv', '') # -> '2018_19'
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)
main() # запуск программы
```
* `str.replace('a','')` — удаляет подстроку (заменяет на пустую).
* `table[table['Конф'] == 'Запад']`**фильтрация по маске**: оставляем строки, где условие True.
---
## `divisions.py` — дивизионы и посев победителей дивизионов
Зачем нужен: в сезонах 2018/192020/21 победители двух дивизионов занимали места 12
в конференции, даже если по очкам были ниже. `main.py` этого не делает, поэтому правило
вынесено в **отдельный файл**, а `main.py` не трогается.
```python
import sys
import os
import pandas as pd
import numpy as np
```
### Переиспользование логики из `main.py`
```python
_main_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'main.py')
_src = open(_main_path, encoding='utf-8').read() # читаем исходник main.py как текст
_src = _src.replace('\nmain()\n', '\n') # убираем последнюю строку с автозапуском
_ns = {}
exec(compile(_src, 'main.py', 'exec'), _ns) # выполняем код -> получаем его функции в _ns
build_standings = _ns['build_standings'] # забираем готовый расчёт таблицы
CONFERENCES = _ns['CONFERENCES'] # и словарь конференций
```
* `main.py` в конце сам вызывает `main()`, поэтому обычный `import main` тут же попытался бы прочитать аргументы и упал. Поэтому читаем текст, убираем строку `main()` и выполняем.
* `exec(compile(...), _ns)` — выполнить код, сложив все его имена (функции, словари) в словарь `_ns`. Так получаем `build_standings` и `CONFERENCES`, ничего не запуская и не печатая.
* **Главное:** логика расчёта не дублируется — единственный источник правды остаётся в `main.py`.
### Функция `make_div` — словарь «команда → дивизион»
```python
def make_div(bobrov, tarasov, kharlamov, chernyshev):
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
```
Аналог `make_conf`, только делений не два (Запад/Восток), а четыре дивизиона.
### Словари `DIVISIONS` и `SEED_BY_DIVISION`
```python
DIVISIONS = {
'2018_19': make_div(bobrov=[...], tarasov=[...], kharlamov=[...], chernyshev=[...]),
...
}
SEED_BY_DIVISION = {'2018_19': True, '2019_20': True, '2020_21': True, '2021_22': False}
```
* `DIVISIONS` — состав четырёх дивизионов по каждому сезону (имена команд ровно как в CSV).
* `SEED_BY_DIVISION` — в каких сезонах посев победителей применялся. В 2021/22 — `False` (КХЛ перешла к чистому порядку по %О).
### Функция `seed_conference` — посев победителей дивизионов
```python
def seed_conference(conf_table):
winners = conf_table.groupby('Див', sort=False).head(1) # лучший из каждого дивизиона = его победитель
rest = conf_table.drop(winners.index) # все остальные
return pd.concat([winners, rest]) # победители наверх, остальные следом
```
* Таблица уже отсортирована по %О, поэтому `head(1)` внутри дивизиона = победитель дивизиона.
* `drop(winners.index)` убирает этих двоих из остального списка, чтобы они не повторились.
* `concat` ставит победителей на места 12, затем — остальные в прежнем порядке.
### Функция `show_table` — печать с дивизионом
То же, что в `main.py`, но в `cols` добавлен столбец `'Див'`, чтобы было видно, почему
победители дивизионов оказались наверху.
### Функция `main`
```python
def main():
path = sys.argv[1]
season = os.path.basename(path).replace('khl_', '').replace('.csv', '')
df = pd.read_csv(path)
table = build_standings(df, CONFERENCES[season]) # таблица из логики main.py
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) # если в этом сезоне посев был — применяем
show_table(part, 'Конференция ' + conf + ' ' + season + ...)
main()
```
---
## `3.py` — график разницы шайб команды по датам
```python
import sys
import pandas as pd
import numpy as np # импортирован, но напрямую не используется
import matplotlib.pyplot as plt # построение графика
```
### Функция `parse_score`
Точная копия из `main.py` (тот же разбор счёта «3:1» на два числовых столбца) — правило
подсчёта шайб одно и то же.
### Функция `match_goals` — голы хозяев и гостей в каждом матче
```python
def match_goals(df):
h1,a1 = parse_score(df,'Период_1'); h2,a2 = parse_score(df,'Период_2'); h3,a3 = parse_score(df,'Период_3')
oth,ota = parse_score(df,'Овертайм'); soh,soa = parse_score(df,'Буллиты')
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)# гости выиграли буллиты
goals_home = reg_h + oth + win_home_so.astype(int) # все шайбы хозяев (+1 за буллиты)
goals_away = reg_a + ota + win_away_so.astype(int) # все шайбы гостей
p1 = df['Период_1'].astype(str).str.strip()
tech = p1.str.startswith('+') | p1.str.endswith('+')# технический результат
goals_home = goals_home.where(~tech, 0) # у техрезультата шайб нет (0:0)
goals_away = goals_away.where(~tech, 0)
return goals_home, goals_away
```
* Та же логика подсчёта шайб, что в `main.py`.
* `series.where(~tech, 0)` — оставить значение, где матч НЕ технический, иначе 0. Так разница у такого матча будет 0, как на сайте КХЛ.
### Функция `plot_team_diff` — построение и сохранение графика
```python
def plot_team_diff(team, filename):
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)
rows = both[both['Команда'] == team].copy() # только матчи нужной команды
rows['Дата'] = pd.to_datetime(rows['Дата']) # текст -> дата (для оси X по времени)
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()
```
* `'ГП'` = пропущено = забито соперником, поэтому в `away` поля `ГЗ`/`ГП` меняются местами.
* `pd.to_datetime` нужен, чтобы matplotlib расставил матчи по оси времени правильно.
### Функция `main`
```python
def main():
filename = sys.argv[1] # путь к CSV
team = sys.argv[2] # название команды
plot_team_diff(team, filename)
main()
```
Запуск: `python3 3.py khl_3/khl_2018_19.csv "ЦСКА"`.
---
## Что точно спросят на защите — короткие ответы
* **Что такое DataFrame/Series?** Таблица и её отдельный столбец в pandas.
* **Почему без циклов?** Векторные операции pandas быстрее и короче: одно действие сразу над всем столбцом.
* **Что такое булева маска?** Столбец True/False из сравнения; по нему фильтруют строки и считают суммы через `.astype(int)`.
* **Зачем `pd.concat` хозяев и гостей?** Чтобы у каждой команды была своя строка на каждый матч и сработал `groupby`.
* **Как считается %очков?** В целых числах с отбрасыванием знаков (`*10000 // (2*И) /100`) — как сайт КХЛ, без округления и без ошибок float.
* **Как обрабатываются технические результаты?** `+ — -` / `- — +` — это победа/поражение в основное время, но без шайб (0:0).
* **Почему дивизионы в отдельном файле?** Чтобы не менять `main.py`; `divisions.py` переиспользует его функции через чтение и `exec` исходника без автозапуска.
* **Зачем посев победителей дивизионов?** В 2018/192020/21 они получали места 12 в конференции независимо от очков; в 2021/22 правило отменили.