summer_practice/РАЗБОР_КОДА.md
2026-06-30 09:54:59 +03:00

437 lines
30 KiB
Markdown
Raw 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`**, **`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 правило отменили.