Саша начинает свой карьерный путь в качестве аналитика. Директор ставит задачу: подготовить отчёт по эффективности сотрудников. Саша решает выполнять задачу с помощью Python. У аналитика есть минимальный опыт погружения в программирование. 

Саша выгружает данные по первому отделу из таск трекера и пишет код для обработки данных. Код работает, хоть и состоит на 70% из неуниверсальных полуавтоматизированных фрагментов. При выгрузке данных по другим подразделениям, формат файла меняется. Код требует постоянных ручных изменений, а срок сдачи отчёта поджимает.

Эта статья о том, какие ошибки допускает Саша при написании кода и как исправляет их. Расскажем, как сделать код более универсальным, чтобы он подходил к меняющимся файлам. Статья подойдёт для начинающих аналитиков, которые только знакомятся с Python.

Задача

Необходимо обработать выгрузку данных из Jira, чтобы собрать всё зафиксированное время сотрудников в одном формате. Нужно сравнить зафиксированное время с рабочим временем в производственном календаре. Выгрузка – широкая таблица с изменчивым набором полей.

Широкая таблица – таблица, в которой для значений каждой переменной используется отдельный столбец с названием переменной.

Широкая таблица
Широкая таблица

Длинная таблица – таблица, в которой один столбец содержит все значения переменных, а другой – все названия переменных.

Длинная таблица
Длинная таблица

Задачу Саша разделила на 4 этапа:

  1. Выделить из выгрузки только нужные столбцы;

  2. Отделить собственно задачи от остальных видов заданий;

  3. Разобрать комбинированные поля с фиксацией времени (формат: «дата; сотрудник; потраченное время») на 3 отдельные колонки;

  4. Посчитать количество спринтов, в которых задача была в работе.

Задачу можно решить в Power Query, Excel или другой программе. Но Саша стремится больше программировать, поэтому использует Python. Пока аналитик знаком с Python весьма поверхностно, свои самые первые ошибки Саша совершает тут

1. Чума: перечислять множество столбцов в явном виде

Первым делом Саша решает сократить датасет и оставить только нужные столбцы. Для этого аналитик хочет удалить все ненужные поля из выгрузки, используя функцию 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 - столбец полностью пуст

Функция 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

Саша выбрала нужные столбцы и приступает к их обработке. Теперь нужно отделить задачи («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-функциями;

  • пользовательскими функциями;

  • объединением однотипных столбцов в датасет и применение функций к ним.

Это лишь первые наивные решения начинающего аналитика. Конечно, есть и более изящные способы справиться с поставленными задачами. Наверняка, пользователи Хабра с удовольствием поделятся ими в комментариях.

Комментарии (7)


  1. abcdsash
    00.00.0000 00:00

    Помню, как то еще в университете была тема про то, что иногда (а может и часто) замена одного высокоуровневого кода на другой почти ничего не меняет внутри исполняемой программы... вы просто одно прячете внутрь другого. Это тонкие вещи, но они есть.

    Например: в коде вызывается какая то функция, написанная вами, а в другой версии аналогичная функция стала конструкцией самого языка (или его библиотеки) и вот вопрос: а повысится ли эффективность кода от того, что теперь вы исключите вызов своей и станете вызывать уже готовую или по сути при исполнении это ни на что не повлияет?

    ну и так далее... вопрос весьма философский зачастую


    1. Hungryee
      00.00.0000 00:00

      Все что упрощает код - хорошо.

      замена даже трехстрочной функции на вызов одного метода из стандартной библиотеки языка / сторонней библиотеки - это упрощение кода, а значит это хорошо. (Это конечно, при условии что сторонняя библиотека более-менее известна.)

      Это упрощает чтение и содержание кода, а так же снижает когнитивную нагрузку на читателя кода.

      Замена одного высокоуровнего участка на другой - это чаще всего обертки, которые заменяют собой часто встречающиеся фрагменты / операции, что тоже отлично


  1. economist75
    00.00.0000 00:00

    В целом все верно написано, но можно немного добавить, если хочется короче и быстрее:

    1) Если заменить точки и пробелы в заголовках полей df - можно в 5+ раз сократить число набираемых символов при обращении к каждому полю df['col'] -> df.col

    Также не придется нигде использовать f-строки

    2) пустоты лучше всего визуализировать чем-то готовым, типа:

    import missingno as msno
    msno.matrix(df)

    3) вместо .apply(lambda x: 0 if pd.isna(x) else 1) итп - в десятки раз быстрее будет np.where(col.isna(), 1, 0) Но в контексте анализа - названия спринтов могут пригодиться. Есть категоризация данных: df.col.astype('category') Получаем возможность работать с числовыми кодами и строками категорий, уменьшаем данные в RAM в 5-20 раз, ускоряем отбор в 4-8 раз, появляется возможность упорядочить спринты в опр. (вашем) порядке.

    4) df.filter() умеет работать и со строками, и со столбцами, а значит все списковые включения вида df[[x for x in df.columns if '_count' in x]] можно заменить на df.filter(like='_count')


  1. hostbest
    00.00.0000 00:00

    Фраза "У аналитика есть минимальный опыт погружения в программирование" как-то резанула. И на неё обидится большинство аналитиков ибо в большинстве случаев всё ровно наоборот))). Предлагаю заменить её на  "У Саши есть минимальный опыт погружения в программирование"


  1. Kryptonets
    00.00.0000 00:00
    +1

    Я один из тех аналитиков,которые не знают Python, от слова "совсем". Зашёл в аналитику со стороны VBA,Power Query,Power BI, MDX, DAX, SQL.

    Вышеперечисленного стека хватает на все задачи с лихвой,но я хочу немного уйти в сторону прогнозирования и расширить свои знания и начал немного учить R в прошлом году, но не хватило самодисциплины,хотя кое-чему научился)))),сейчас пытаюсь подступиться к Python,пока читаю книгу Byte of Python.


    1. Ananiev_Genrih
      00.00.0000 00:00

      Твоего перечисленного стэка хватит с лихвой лишь на ETL и BI. То что ты забросил R и хорошо и плохо одновременно: в R есть всё необходимое для анализа данных , и каждый из этих тулзов гораздо производительнее и не менее функционален чем в python (а зачастую - более). Но если смотреть на рынок труда то питон у работодателей востребованней в анализе данных : заслуга чисто за счёт всей оставшейся инфраструктуры (бэкэнд, web, etc) на нём же.


  1. Ananiev_Genrih
    00.00.0000 00:00

    Ошибки начинающего аналитика при обработке данных на Python: учить пандас на табличках 3*33 вместо производительного и современного polars/pyarrow и потом на реальных бизнес задачах плакать натягивая свой пандас_код на даск