# Разбор кода построчно (для защиты) Здесь разобраны три файла проекта — **`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/19–2020/21 победители двух дивизионов занимали места 1–2 в конференции, даже если по очкам были ниже. `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` ставит победителей на места 1–2, затем — остальные в прежнем порядке. ### Функция `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/19–2020/21 они получали места 1–2 в конференции независимо от очков; в 2021/22 правило отменили.