Фильтрация значений и подготовка данных для анализа — Python: Pandas
В библиотеке Pandas реализованы различные подходы, чтобы индексировать элементы. Можно обращаться к ним по порядковому номеру или по метке. При этом есть задачи, в которых нужно отсеивать элементы по условию с использованием специальных масок. В данной задаче указание конкретных интервалов для индексов не всегда подходит.
Для таких задач в Pandas реализован гибкий алгоритм фильтрации. В нем сложность масок определяется только фантазией аналитика и правилами логической арифметики. В этом уроке мы познакомимся с инструментами Pandas для подготовки и первичного анализа данных.
Использование булевых масок
Для работы нам понадобится датасет с недельными показателями кликов на сайтах четырех магазинов:
import pandas as pd df_clicks = pd.read_csv('./data/Cite_clicks_week.csv', index_col=0) print(df_clicks) # => SHOP1 SHOP2 SHOP3 SHOP4 # day # 1 1319.0 -265.0 319.0 NaN # 2 NaN 267.0 333.0 344.0 # 3 283.0 NaN 274.0 283.0 # 4 328.0 364.0 328.0 NaN # 5 391.0 355.0 373.0 337.0 # 6 445.0 -418.0 1409.0 445.0 # 7 481.0 NaN 481.0 409.0
Среди показателей есть NaN -значения, которые указывают на пропуски в данных. Также есть отрицательные значения и показатели, которые существенно выше всех остальных. Это вполне обычная ситуация.
Такие значения могут влиять на точность анализа данных и даже на возможность его проведения. Поэтому аналитику приходится находить их и исправлять.
Структура DataFrame поддерживает операции среза последовательных элементов в данных по аналогии со стандартными списками:
print(df_clicks[1:5]) # => SHOP1 SHOP2 SHOP3 SHOP4 # day # 2 NaN 267.0 333.0 344.0 # 3 283.0 NaN 274.0 283.0 # 4 328.0 364.0 328.0 NaN # 5 391.0 355.0 373.0 337.0
Чтобы извлечь конкретные строки, можно использовать булевы маски. Это массивы значений True и False . Строка берется, если по ее порядковому номеру в булевой маске стоит значение True :
print(df_clicks[[True, True, False, True, False, True, False]]) # => SHOP1 SHOP2 SHOP3 SHOP4 # day # 1 1319.0 -265.0 319.0 NaN # 2 NaN 267.0 333.0 344.0 # 4 328.0 364.0 328.0 NaN # 6 445.0 -418.0 1409.0 445.0
Чтобы извлечь конкретные столбцы в срезе, используется метод loc() , в параметры которого передается срез и массив меток столбцов:
print(df_clicks.loc[1:5, ['SHOP1']]) # => SHOP1 # day # 1 1319.0 # 2 NaN # 3 283.0 # 4 328.0 # 5 391.0 print(df_clicks.loc[1:5, ['SHOP1', 'SHOP3']]) # => SHOP1 SHOP3 # day # 1 1319.0 319.0 # 2 NaN 333.0 # 3 283.0 274.0 # 4 328.0 328.0 # 5 391.0 373.0
Для больших таблиц будет неудобно вручную задавать булевы маски, как мы это делали в примерах выше. В Pandas получить маску можно с помощью логических операторов, примененных к данным:
print(df_clicks['SHOP1'] 300) # => day # 1 False # 2 False # 3 True # 4 False # 5 False # 6 False # 7 False # Name: SHOP1, dtype: bool
Чтобы получить нужные строки согласно булевой маске, достаточно передать ее в качестве индекса в DataFrame :
print(df_clicks[df_clicks['SHOP1'] 300]) # => SHOP1 SHOP2 SHOP3 SHOP4 # day # 3 283.0 NaN 274.0 283.0
Выбор нужных столбцов в таблице осуществляется по аналогии с примером выше:
print(df_clicks.loc[df_clicks['SHOP1'] 300, ['SHOP1', 'SHOP3']]) # => SHOP1 SHOP3 # day # 3 283.0 274.0
Поиск пропусков в данных
Одной из существенных проблем в данных являются пропуски. Многие функции агрегации, обработки и даже простые арифметические операции не могут быть выполнены при их наличии. Чтобы обнаружить пропуски, в Pandas используют метод isna() :
print(df_clicks[df_clicks['SHOP1'].isna()]) # => SHOP1 SHOP2 SHOP3 SHOP4 # day # 2 NaN 267.0 333.0 344.0
Для поиска строк, которые не содержат пропуски, можно использовать оператор тильда для логического отрицания исходной булевой маски:
print(df_clicks[~df_clicks['SHOP1'].isna()]) # => SHOP1 SHOP2 SHOP3 SHOP4 # day # 1 1319.0 -265.0 319.0 NaN # 3 283.0 NaN 274.0 283.0 # 4 328.0 364.0 328.0 NaN # 5 391.0 355.0 373.0 337.0 # 6 445.0 -418.0 1409.0 445.0 # 7 481.0 NaN 481.0 409.0
Данный метод применим не только к столбцам, но и ко всей таблице:
print(df_clicks.isna()) # => SHOP1 SHOP2 SHOP3 SHOP4 # day # 1 False False False True # 2 True False False False # 3 False True False False # 4 False False False True # 5 False False False False # 6 False False False False # 7 False True False False
Замена пропусков на определенные значения
Когда мы находим пропуски в данных, это помогает контролировать их качество. Но часто приходится не только их находить, но и исправлять.
В качестве значения, на которое заменяется пропуск, можно взять среднее по всем данным. Чтобы найти среднее, нужно дважды применить метод mean() , поскольку после первого применения получается массив средних для каждого магазина по отдельности:
df_clicks_mean = df_clicks.mean().mean() print(df_clicks_mean) # => 366.94
Чтобы заполнить пропуски, используется метод fillna() с параметром, на который происходит замена пропущенных значений:
print(df_clicks.fillna(df_clicks_mean)) # => SHOP1 SHOP2 SHOP3 SHOP4 # day # 1 1319.00000 -265.00000 319.0 366.94881 # 2 366.94881 267.00000 333.0 344.00000 # 3 283.00000 366.94881 274.0 283.00000 # 4 328.00000 364.00000 328.0 366.94881 # 5 391.00000 355.00000 373.0 337.00000 # 6 445.00000 -418.00000 1409.0 445.00000 # 7 481.00000 366.94881 481.0 409.00000
Замена значений в данных согласно условию
Помимо пропусков в данных могут быть значения, которые попали туда по ошибке, были некорректно введены или искажены при записи. Такие случаи называют выбросами.
Анализ данных и поиск выбросов приводит к формированию некоторого условия, которому должны удовлетворять элементы. Если элементы ему не удовлетворяют, то данные значения можно заменить на среднее. Для этого используют метод where() :
print(df_clicks.where(df_clicks 1000, df_clicks_mean)) # => SHOP1 SHOP2 SHOP3 SHOP4 # day # 1 366.94881 -265.00000 319.00000 366.94881 # 2 366.94881 267.00000 333.00000 344.00000 # 3 283.00000 366.94881 274.00000 283.00000 # 4 328.00000 364.00000 328.00000 366.94881 # 5 391.00000 355.00000 373.00000 337.00000 # 6 445.00000 -418.00000 366.94881 445.00000 # 7 481.00000 366.94881 481.00000 409.00000
При работе с Pandas это условие формируется в виде булевой маски. Для удобства ее выносят в отдельную переменную:
mask = (0 df_clicks) & (df_clicks 1000) print(mask) # => SHOP1 SHOP2 SHOP3 SHOP4 # day # 1 False False True False # 2 False True True True # 3 True False True True # 4 True True True False # 5 True True True True # 6 True False False True # 7 True False True True
print(df_clicks.where( mask, df_clicks_mean, ) ) # => SHOP1 SHOP2 SHOP3 SHOP4 # day # 1 366.94881 366.94881 319.00000 366.94881 # 2 366.94881 267.00000 333.00000 344.00000 # 3 283.00000 366.94881 274.00000 283.00000 # 4 328.00000 364.00000 328.00000 366.94881 # 5 391.00000 355.00000 373.00000 337.00000 # 6 445.00000 366.94881 366.94881 445.00000 # 7 481.00000 366.94881 481.00000 409.00000
Открыть доступ
Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно
- 130 курсов, 2000+ часов теории
- 1000 практических заданий в браузере
- 360 000 студентов
Наши выпускники работают в компаниях:
pandas.DataFrame.isna#
Return a boolean same-sized object indicating if the values are NA. NA values, such as None or numpy.NaN , gets mapped to True values. Everything else gets mapped to False values. Characters such as empty strings » or numpy.inf are not considered NA values (unless you set pandas.options.mode.use_inf_as_na = True ).
Mask of bool values for each element in DataFrame that indicates whether an element is an NA value.
Boolean inverse of isna.
Omit axes labels with missing values.
Show which entries in a DataFrame are NA.
>>> df = pd.DataFrame(dict(age=[5, 6, np.nan], . born=[pd.NaT, pd.Timestamp('1939-05-27'), . pd.Timestamp('1940-04-25')], . name=['Alfred', 'Batman', ''], . toy=[None, 'Batmobile', 'Joker'])) >>> df age born name toy 0 5.0 NaT Alfred None 1 6.0 1939-05-27 Batman Batmobile 2 NaN 1940-04-25 Joker
>>> df.isna() age born name toy 0 False True False True 1 False False False False 2 True False False False
Show which entries in a Series are NA.
>>> ser = pd.Series([5, 6, np.nan]) >>> ser 0 5.0 1 6.0 2 NaN dtype: float64
>>> ser.isna() 0 False 1 False 2 True dtype: bool
Ошибки начинающего аналитика при обработке данных на Python: 4 всадника апокалипсиса

Саша начинает свой карьерный путь в качестве аналитика. Директор ставит задачу: подготовить отчёт по эффективности сотрудников. Саша решает выполнять задачу с помощью Python. У аналитика есть минимальный опыт погружения в программирование.
Саша выгружает данные по первому отделу из таск трекера и пишет код для обработки данных. Код работает, хоть и состоит на 70% из неуниверсальных полуавтоматизированных фрагментов. При выгрузке данных по другим подразделениям, формат файла меняется. Код требует постоянных ручных изменений, а срок сдачи отчёта поджимает.
Эта статья о том, какие ошибки допускает Саша при написании кода и как исправляет их. Расскажем, как сделать код более универсальным, чтобы он подходил к меняющимся файлам. Статья подойдёт для начинающих аналитиков, которые только знакомятся с Python.
Задача
Необходимо обработать выгрузку данных из Jira, чтобы собрать всё зафиксированное время сотрудников в одном формате. Нужно сравнить зафиксированное время с рабочим временем в производственном календаре. Выгрузка – широкая таблица с изменчивым набором полей.
Широкая таблица – таблица, в которой для значений каждой переменной используется отдельный столбец с названием переменной.

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

Задачу Саша разделила на 4 этапа:
- Выделить из выгрузки только нужные столбцы;
- Отделить собственно задачи от остальных видов заданий;
- Разобрать комбинированные поля с фиксацией времени (формат: «дата; сотрудник; потраченное время») на 3 отдельные колонки;
- Посчитать количество спринтов, в которых задача была в работе.
Задачу можно решить в Power Query, Excel или другой программе. Но Саша стремится больше программировать, поэтому использует Python. Пока аналитик знаком с Python весьма поверхностно, свои самые первые ошибки Саша совершает тут.
1. Чума: перечислять множество столбцов в явном виде

Первым делом Саша решает сократить датасет и оставить только нужные столбцы. Для этого аналитик хочет удалить все ненужные поля из выгрузки, используя функцию drop. Ненужных полей более 50, поэтому получается такой код:
df.drop(columns = , 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')

Функция 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_"]=df_column.apply(lambda x: str(x).partition(';')[2].partition(' ')[0]) df[f"analyst_"]=df_column.apply(lambda x: str(x).partition(';')[2].partition(';')[2].partition(';')[0]) df[f"time_"]=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"_count"] = df[f""].apply(lambda x: 0 if pd.isna(x) else 1)
df[f»»] – универсальная конструкция. df – датасет, x – переменная. f»» помогает использовать в качестве названия столбца значение переменной x.
В нашем примере df[f»»] будут по очереди df[‘Sprint.1’], df[‘Sprint.2’], df[‘Sprint.3’] и т.д. df[f»_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_"]=df_column.apply(lambda x: str(x).partition(';')[2].partition(' ')[0]) df[f"analyst_"]=df_column.apply(lambda x: str(x).partition(';')[2].partition(';')[2].partition(';')[0]) df[f"time_"]=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)
Заключение
Отчёт был сдан вовремя благодаря тому, что Саша познакомилась с базовыми приемами для повышения универсальности кода:
- способами исключить перечисление имен множества столбцов в явном виде;
- lambda-функциями;
- пользовательскими функциями;
- объединением однотипных столбцов в датасет и применение функций к ним.
Это лишь первые наивные решения начинающего аналитика. Конечно, есть и более изящные способы справиться с поставленными задачами. Наверняка, пользователи Хабра с удовольствием поделятся ими в комментариях.
Проверка на наличие NaN в DataFrame в Pandas

Часто при работе с большими наборами данных, особенно теми, которые были получены из внешних источников, могут возникать проблемы с пропущенными или недоступными данными. В Python и библиотеке Pandas такие данные обычно представлены значением NaN (Not a Number).
Рассмотрим пример DataFrame:
import pandas as pd import numpy as np data = < 'A': [1, 2, np.nan], 'B': [5, np.nan, np.nan], 'C': [1, 2, 3] >df = pd.DataFrame(data)
В этом DataFrame в столбцах ‘A’ и ‘B’ есть значения NaN.
Варианты проверки наличия NaN
Вариант 1: isnull() и any()
Один из способов проверить наличие NaN в DataFrame — использовать функцию isnull() . Она возвращает другой DataFrame, где каждое значение это булево значение, указывающее, является ли соответствующее значение в исходном DataFrame NaN.
df.isnull()
Чтобы узнать, есть ли хотя бы одно значение NaN в DataFrame, можно использовать функцию any() , которая возвращает True, если хотя бы одно значение в DataFrame является True.
df.isnull().any().any()
Вариант 2: isna() и values
Альтернативный способ — использовать функцию isna() , которая аналогична isnull() . Однако, вместо второго any() можно использовать свойство values и функцию any() из numpy.
np.any(df.isna().values)
Оба этих способа позволяют эффективно проверить наличие значений NaN в DataFrame. Выбор между ними зависит от конкретной ситуации и личных предпочтений.