![](https://habrastorage.org/getpro/habr/upload_files/a53/b8e/421/a53b8e421b413dc2134c3061063626d0.png)
Саша начинает свой карьерный путь в качестве аналитика. Директор ставит задачу: подготовить отчёт по эффективности сотрудников. Саша решает выполнять задачу с помощью Python. У аналитика есть минимальный опыт погружения в программирование.
Саша выгружает данные по первому отделу из таск трекера и пишет код для обработки данных. Код работает, хоть и состоит на 70% из неуниверсальных полуавтоматизированных фрагментов. При выгрузке данных по другим подразделениям, формат файла меняется. Код требует постоянных ручных изменений, а срок сдачи отчёта поджимает.
Эта статья о том, какие ошибки допускает Саша при написании кода и как исправляет их. Расскажем, как сделать код более универсальным, чтобы он подходил к меняющимся файлам. Статья подойдёт для начинающих аналитиков, которые только знакомятся с Python.
Задача
Необходимо обработать выгрузку данных из Jira, чтобы собрать всё зафиксированное время сотрудников в одном формате. Нужно сравнить зафиксированное время с рабочим временем в производственном календаре. Выгрузка – широкая таблица с изменчивым набором полей.
Широкая таблица – таблица, в которой для значений каждой переменной используется отдельный столбец с названием переменной.
![Широкая таблица Широкая таблица](https://habrastorage.org/getpro/habr/upload_files/461/c22/21e/461c2221e6b3bb15015fac13d752c707.png)
Длинная таблица – таблица, в которой один столбец содержит все значения переменных, а другой – все названия переменных.
![Длинная таблица Длинная таблица](https://habrastorage.org/getpro/habr/upload_files/8d1/cdd/ea3/8d1cddea35efc2a65beb55b13358e5a9.png)
Задачу Саша разделила на 4 этапа:
Выделить из выгрузки только нужные столбцы;
Отделить собственно задачи от остальных видов заданий;
Разобрать комбинированные поля с фиксацией времени (формат: «дата; сотрудник; потраченное время») на 3 отдельные колонки;
Посчитать количество спринтов, в которых задача была в работе.
Задачу можно решить в Power Query, Excel или другой программе. Но Саша стремится больше программировать, поэтому использует Python. Пока аналитик знаком с Python весьма поверхностно, свои самые первые ошибки Саша совершает тут.
1. Чума: перечислять множество столбцов в явном виде
![](https://habrastorage.org/getpro/habr/upload_files/fe2/b5c/100/fe2b5c10015b1921e468d97a6af5f62a.png)
Первым делом Саша решает сократить датасет и оставить только нужные столбцы. Для этого аналитик хочет удалить все ненужные поля из выгрузки, используя функцию drop. Ненужных полей более 50, поэтому получается такой код:
df.drop(columns = {'Watchers','Watchers.1','Watchers.2',
'Watchers.3','Attachment','Comment','Comment.1',
'Comment.2','Comment.3','Comment.4','Comment.5',
'Comment.6','Comment.7','Comment.8', 'DoD.1','DoD.2',
'DoD.3','DoD.4','DoD.5','DoD.6','DoD.7','DoD.8','DoR',
'DoR.1','DoR.2','DoR.3','DoR.4','DoR.5', ...}, inplace = True)
Код работает, Саша запускает его для другой выгрузки, но появляются ошибки. Так происходит, потому что часть полей, которые перечислены в функции, отсутствуют в выгрузке. Из-за этого приходится вручную корректировать код при каждом изменении структуры выгрузки. Аналогичные трудности возникают, если в выгрузке появляются новые поля. На каждое такое изменение может уходить до 5 минут, что при большом числе файлов критично.
Ошибка
Перечисление десятков столбцов в явном виде может привести к ошибкам и необходимости ручных корректировок, особенно если набор столбцов в файле не фиксирован.
Как исправить
Есть несколько вариантов решения этой проблемы, перечислим лишь некоторые из них. Отметим, что нужный вариант зависит от конкретной структуры файлов и аналитической задачи.
Например, можно пойти от обратного. Чтобы каждый раз не удалять все ненужные столбцы, можно создать датасет только с нужными столбцами. Такой способ подойдёт, если:
нужных столбцов немного (оптимально – менее 10, логично – меньше, чем ненужных);
названия столбцов фиксированные;
аналитик точно знает, какие столбцы нужны, а какие нет.
Вот код:
df_сut = df[['Issue id','Issue Type','Summary','Status',
'Assignee','Created','Time Spent','Custom field (Deadline)']]
Если не понятно, нужен ли столбец или нет, то для начала стоит проверить столбец на пустоту. Высока вероятность, что пустой столбец может не понадобится. Посмотреть, насколько заполнены столбцы можно так:
pd.DataFrame(round(df.isna().mean()*100,)).style.background_gradient('coolwarm')
Результат:
![0 - столбец заполнен полностью, 100 - столбец полностью пуст 0 - столбец заполнен полностью, 100 - столбец полностью пуст](https://habrastorage.org/getpro/habr/upload_files/897/1c1/59a/8971c159aa7c4519a934c308925cffac.png)
Функция mean вычисляет среднее, функция isna определяет пустые значения, а функция round выполняет округление. pd.DataFrame превращает результат в датафрейм, style.background_gradient('coolwarm') добавляет цветную заливку.
Если пустые столбцы действительно ни к чему, то удалить их все можно командой:
df.dropna(axis='columns',how='all', inplace=True)
Если есть сомнения в необходимости колонки, то можно взглянуть на содержимое. Например, посмотреть, какие значения и как часто встречаются в столбцах, можно так:
df['Issue Type'].value_counts()
Результат: Task – 924, Story – 39, Report – 1.
Есть еще вариант удаления столбцов с использование функции iloc и/или range, но он подойдет только в том случае, если:
количество столбцов фиксировано;
порядок столбцов фиксирован;
аналитик точно знает, какие столбцы нужны, а какие нет.
Например, удалить первые 2 столбца можно так:
df_cut = df.drop(columns=df.iloc[:, range(2)])
2. Голод: не использовать функцию apply и lambda
![](https://habrastorage.org/getpro/habr/upload_files/fea/de5/0a4/feade50a451b85bd72739a17214d8373.png)
Саша выбрала нужные столбцы и приступает к их обработке. Теперь нужно отделить задачи («Task») от остальных видов заданий. Аналитику нужно преобразовать столбец Issue Type: для задач проставить 1, для историй и отчетов проставить 0. На структуру столбца мы посмотрели в предыдущем пункте.
Саша уже знакома с циклами и пишет такой код:
task = []
for i in range(df.shape[0]):
if df['Issue Type'][i] == 'Task':
task.append(1)
else:
task.append(0)
df['Issue Type New'] = task
Проверим результат:
df['Issue Type New'].value_counts()
Результат: 1 – 924, 0 – 40. Отлично, все верно.
Код работает, преобразование правильное, аналитик доволен. Саша использует циклы в более сложных преобразованиях и начинает замечать, что они долго обрабатываются. Циклы подъедают оперативную память, но все равно остаются голодным. Если цикл длинный, то приходится ждать до 30 секунд.
Ошибка
Будем честны, приведенный код не содержит явных ошибок. Но все же создает дополнительные объекты и использует цикл, что может сказаться на производительности кода.
Как исправить
Чтобы лишний раз не использовать цикл, напишем функцию apply с использованием lambda, получается так:
df['Issue Type'] = df['Issue Type'].apply(lamba x: 1 if x == 'Task' else 0)
Нам потребовалась одна строка кода вместо 7, мы не создали ни одного нового объекта, а обработка прошла быстрее.
Lambda функция – функция без имени (анонимная), которая принимает на вход переменные и выполняет вычисления. Структура такова: lambda [аргументы] : выражение.
В нашем случае переменная – «x». Выражение (вычисление) – замена «Task» на 1, а остальных значений – на 0. Apply применяет lambda-функцию к столбцу df['Issue Type']. Таким образом lambda-функцию применяется ко всем значениям в столбце 'Issue Type' (условно x представляет каждое значение столбца).
3. Война: не применять пользовательские функции
Аналитику нужно посчитать зафиксированное время по сотрудникам. Оно содержится в столбцах Log Work. Саша пишет код, выбирающий из столбцов с логами дату фиксации, имя сотрудника и зафиксированное время. Аналитик уже использует apply, lambda и получается так:
df['day_Log Work']=df['Log Work'].apply(lambda x: str(x).partition(';')[2].partition(' ')[0])
df['analyst_Log Work']=df['Log Work'].apply(lambda x: str(x).partition(';')[2].partition(';')[2].partition(';')[0])
df['time_Log Work']=df['Log Work'].apply(lambda x: str(x).partition(';')[2].partition(';')[2].partition(';')[2])
Неплохо, но необходимо повторить эти три функции для каждого столбца Log Work. Скрипт работает, но в файлах разных отделов количество столбцов Log Work отличается. Саша воюет с кодом, но появляются новые ошибки, приходится переписывать скрипт снова и снова. Борьба затягивается, не менее 5 минут для каждой выгрузки на этом шаге.
Ошибка
Одинаковые функции применяются к разным столбцам через копирование, вставку и замену имени столбца. Это трудоемко, удлиняет код, приводит к ошибкам.
Как исправить
Используем пользовательскую функцию.
Пользовательская функция – функция, которую пользователь пишет самостоятельно. Для задания функции используется def.
Запишем все три операции в одну функцию:
def worklog(df_column, name_column):
df[f"day_{name_column}"]=df_column.apply(lambda x: str(x).partition(';')[2].partition(' ')[0])
df[f"analyst_{name_column}"]=df_column.apply(lambda x: str(x).partition(';')[2].partition(';')[2].partition(';')[0])
df[f"time_{name_column}"]=df_column.apply(lambda x: str(x).partition(';')[2].partition(';')[2].partition(';')[2])
Функция str преобразует переменную в строку. Функция partition разбивает строку на на три составляющие: строка до разделителя [0], разделитель [1], строка после разделителя [2].
Теперь чтобы применить функции, нам останется указать имя колонки в датасете для преобразования и постфикс в названии новой колонки:
worklog(df['Log Work'], '1’)
worklog(df['Log Work.1'], '2')
worklog(df['Log Work.2'], '3')
Читаемость и универсальность кода выросла, нет необходимости писать длинные функции для каждого столбца Log Work. Внимательный читатель заметит, что в данном примере проблема с изменением количества полей Log Work не решена. Чтобы это исправить, нам потребуется решение из следующего пункта.
4. Смерть: обрабатывать наборы столбцов без автоматизации
Саша хочет посчитать количество спринтов, в которых задача была в работе. Для этого аналитик заменяет название спринта в каждом столбце на цифру 1 так:
df['sprint.1_count']=df['Sprint.1'].apply(lambda x: 0 if pd.isna(x) else 1)
df['sprint.2_count']=df['Sprint.2'].apply(lambda x: 0 if pd.isna(x) else 1)
df['sprint.3_count']=df['Sprint.3'].apply(lambda x: 0 if pd.isna(x) else 1)
Затем аналитик суммирует результаты цифры в трех колонках и получает итоговое количество спринтов:
df['Sprint_count']=df['sprint.1_count']+df['sprint.2_count']+df['sprint.3_count']
Код работает, Саша использует его для всех выгрузок. И в самом конце работы со спринтами замечает, что сумма спринтов некорректна. Это смертельный удар для аналитика, ведь всё придется переделывать сначала. Так произошло, потому что в некоторых файлах количество спринтов больше, чем в том, для которого был написан изначальный код. Поэтому сумма спринтов не включила часть полей и оказалась неверной.
Ошибка
Обрабатывать столбцы с одной структурой, используя одинаковые функции для каждого отдельного столбца, очень трудоемко. Длина кода возрастает, а читаемость – снижается. Кроме того, как и в первой ошибке такой способ приводит к проблемам при изменении структуры выгрузки.
Как исправить
Используем универсальный подход. Добавим все столбцы со спринтами, которые нужно обрабатывать одинаково, в отдельный датасет. Например, так:
df_sprint = df[[x for x in df.columns if 'Sprint' in x]]
Применим функцию ко всем столбцам в датасете:
for x in df_sprint:
df[f"{x}_count"] = df[f"{x}"].apply(lambda x: 0 if pd.isna(x) else 1)
df[f"{x}"] – универсальная конструкция. df – датасет, x – переменная. f"{x}" помогает использовать в качестве названия столбца значение переменной x.
В нашем примере df[f"{x}"] будут по очереди df['Sprint.1'], df['Sprint.2'], df['Sprint.3'] и т.д. df[f"{x}_count"] – df['Sprint.1_count'], df['Sprint.2_count'], df['Sprint.3_count'] и т.д.
Посчитаем количество спринтов:
df_sprint_count = df[[x for x in df.columns if '_count' in x]]
df['Total_sprint_count'] = df_sprint_count.sum(axis=1)
Такое решение будет работать вне зависимости от количества столбцов со спринтами в файле, поэтому это более универсальное решение.
Задачу с зафисированным временем из ошибки 3 решим аналогично. Добавим все логи в отдельный датасет:
work_df = df[[x for x in df.columns if 'Log' in x]]
А сами функции для выделения даты, сотрудника и зафиксированного времени добавим в пользовательскую функцию:
def worklog(df_column, i):
df[f"day_{i}"]=df_column.apply(lambda x: str(x).partition(';')[2].partition(' ')[0])
df[f"analyst_{i}"]=df_column.apply(lambda x: str(x).partition(';')[2].partition(';')[2].partition(';')[0])
df[f"time_{i}"]=df_column.apply(lambda x: str(x).partition(';')[2].partition(';')[2].partition(';')[2])
Применим новую функцию worlog к датасету work_df:
for x in work_df:
worklog(df[f"{x}"],x)
Заключение
Отчёт был сдан вовремя благодаря тому, что Саша познакомилась с базовыми приемами для повышения универсальности кода:
способами исключить перечисление имен множества столбцов в явном виде;
lambda-функциями;
пользовательскими функциями;
объединением однотипных столбцов в датасет и применение функций к ним.
Это лишь первые наивные решения начинающего аналитика. Конечно, есть и более изящные способы справиться с поставленными задачами. Наверняка, пользователи Хабра с удовольствием поделятся ими в комментариях.