30 KiB
Разбор кода построчно (для защиты)
Здесь разобраны три файла проекта — 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 — построчно
import sys # доступ к аргументам командной строки (sys.argv)
import os # работа с путями файлов (os.path.basename)
import pandas as pd # таблицы (DataFrame); pd — общепринятое сокращение
import numpy as np # числовые операции (np.arange для нумерации мест)
Функция make_conf — собирает словарь «команда → конференция»
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
CONFERENCES = {
'2018_19': make_conf(west=[...], east=[...]), # для каждого сезона свой состав конференций
'2019_20': make_conf(...),
...
}
- Ключ — код сезона (
'2018_19'), значение — словарь «команда → конференция». - Зачем: составы конференций по годам менялись, поэтому для каждого сезона свой список.
Функция parse_score — разбор счёта «3:1»
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 — главный расчёт таблицы
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 = ... — распаковка кортежа: функция вернула два значения, кладём их в две переменные.
reg_h = h1 + h2 + h3 # голы хозяев в основное время (поэлементная сумма столбцов)
reg_a = a1 + a2 + a3 # голы гостей в основное время
Сложение двух Series складывает их построчно (матч за матчем).
tie = (reg_h == reg_a) # столбец True/False: True там, где в основное время ничья
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) # буллиты выиграли гости
& — поэлементное «И» для булевых столбцов (обе маски должны быть True в одной строке).
Технические результаты (неявка/отмена матча):
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
~tech— поэлементное «НЕ» (инвертирует маску): «это НЕ технический матч».- КХЛ засчитывает техрезультат как победу/поражение в основное время (2 и 0 очков), но шайбы не начисляет — поэтому ниже голы у такого матча останутся 0:0.
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, поэтому шайб нет.
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,
})
Главный приём: один матч превращаем в две записи — статистику хозяев и статистику гостей.
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() # группируем по команде и складываем все числа
После этого индекс таблицы — названия команд, а в ячейках — суммы за сезон.
table['И'] = table[flag_cols].sum(axis=1) # игры = сумма всех 6 исходов (axis=1 = вдоль строки)
table['О'] = 2*(table['В']+table['ВО']+table['ВБ']) + (table['ПБ']+table['ПО']) # очки: победа=2, пораж. в ОТ/буллитах=1
table['Р'] = table['ГЗ'] - table['ГП'] # разница шайб
table['%О'] = (table['О'] * 10000 // (2 * table['И'])) / 100
Процент очков = очки / максимум (2 за игру). Считается в целых числах: умножаем на 10000, делим нацело (//) на 2*И — дробная часть отбрасывается (как на сайте КХЛ), потом делим на 100. Целочисленная арифметика не даёт ошибок float, поэтому ровные значения (57.00) не съезжают в 56.99.
table['Конф'] = pd.Series(table.index, index=table.index).map(conf_map)
Берём названия команд (индекс), и .map(conf_map) подставляет каждой её конференцию из словаря.
table = table.sort_values(by=['%О', 'В', 'ВО', 'ВБ', 'Р', 'ГЗ'], ascending=False)
return table
Сортировка по регламенту: сначала по %очков, при равенстве — больше побед в осн. время, затем в ОТ, по буллитам, разница шайб, забитые. ascending=False = по убыванию.
Функция show_table — печать таблицы
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 — точка входа
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 не трогается.
import sys
import os
import pandas as pd
import numpy as np
Переиспользование логики из main.py
_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 — словарь «команда → дивизион»
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
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 — посев победителей дивизионов
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
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 — график разницы шайб команды по датам
import sys
import pandas as pd
import numpy as np # импортирован, но напрямую не используется
import matplotlib.pyplot as plt # построение графика
Функция parse_score
Точная копия из main.py (тот же разбор счёта «3:1» на два числовых столбца) — правило
подсчёта шайб одно и то же.
Функция match_goals — голы хозяев и гостей в каждом матче
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 — построение и сохранение графика
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
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 правило отменили.