Очистка данных занимает значительную часть процесса анализа данных. При использовании pandas существует несколько методов очистки текстовых полей для подготовки к дальнейшему анализу. По мере того, как наборы данных увеличиваются, важно использовать эффективные методы.
В этой статье будут показаны примеры очистки текстовых полей в большом файле и даны советы по эффективной очистке неструктурированных текстовых полей с помощью Python и pandas.
Оригинал статьи Криса по ссылке
Предположим, что у вас есть новый крафтовый виски, который вы хотели бы продать. Ваша территория включает Айову, и там есть открытый набор данных, который показывает продажи спиртных напитков в штате. Это кажется отличной возможностью, чтобы посмотреть, у кого самые большие счета в штате. Вооружившись этими данными, можно спланировать процесс продаж в магазины.
В восторге от этой возможности, вы загружаете данные и понимаете, что они довольно большие. В этой статье я буду использовать данные, включающие продажи за 2019 год
.
Выборочный набор данных представляет собой CSV-файл размером 565 МБ
с 24
столбцами и 2,3 млн
строк, а весь датасет занимает 5 Гб
(25 млн
строк). Это ни в коем случае не большие данные, но они достаточно большие для обработки в Excel и некоторых методов pandas.
Давайте начнем с импорта модулей и чтения данных.
Я также воспользуюсь пакетом sidetable
для обобщения данных. Он не требуется для очистки, но может быть полезен для подобных сценариев исследования данных.
#!pip3 install sidetable
Загрузим данные:
import pandas as pd
import numpy as np
import sidetable
!wget https://www.dropbox.com/s/9e88whmc03nkouz/2019_Iowa_Liquor_Sales.csv
df = pd.read_csv('2019_Iowa_Liquor_Sales.csv')
Посмотрим на них:
df.head()
Invoice/Item Number | Date | Store Number | Store Name | Address | City | Zip Code | Store Location | County Number | County | ... | Item Number | Item Description | Pack | Bottle Volume (ml) | State Bottle Cost | State Bottle Retail | Bottles Sold | Sale (Dollars) | Volume Sold (Liters) | Volume Sold (Gallons) | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | INV-16681900011 | 01/02/2019 | 5286 | Sauce | 108, College | Iowa City | 52240.0 | NaN | 52.0 | JOHNSON | ... | 48099 | Hennessy VS | 24 | 200 | 6.24 | 9.36 | 24 | 224.64 | 4.8 | 1.26 |
1 | INV-16681900027 | 01/02/2019 | 5286 | Sauce | 108, College | Iowa City | 52240.0 | NaN | 52.0 | JOHNSON | ... | 89191 | Jose Cuervo Especial Reposado Mini | 12 | 500 | 11.50 | 17.25 | 12 | 207.00 | 6.0 | 1.58 |
2 | INV-16681900018 | 01/02/2019 | 5286 | Sauce | 108, College | Iowa City | 52240.0 | NaN | 52.0 | JOHNSON | ... | 8824 | Lauder's | 24 | 375 | 3.21 | 4.82 | 24 | 115.68 | 9.0 | 2.37 |
3 | INV-16685400036 | 01/02/2019 | 2524 | Hy-Vee Food Store / Dubuque | 3500 Dodge St | Dubuque | 52001.0 | NaN | 31.0 | DUBUQUE | ... | 35917 | Five O'Clock Vodka | 12 | 1000 | 4.17 | 6.26 | 12 | 75.12 | 12.0 | 3.17 |
4 | INV-16690300035 | 01/02/2019 | 4449 | Kum & Go #121 / Urbandale | 12041 Douglas Pkwy | Urbandale | 50322.0 | NaN | 77.0 | POLK | ... | 36304 | Hawkeye Vodka | 24 | 375 | 1.86 | 2.79 | 24 | 66.96 | 9.0 | 2.37 |
5 rows × 24 columns
Первое, что можно сделать, это посмотреть, сколько закупает каждый магазин, и отсортировать их по убыванию. У нас ограниченные ресурсы, поэтому мы должны сосредоточиться на тех местах, где мы получим максимальную отдачу от вложенных средств. Нам будет проще позвонить паре крупных корпоративных клиентов, чем множеству семейных магазинов.
Модуль sidetable
позволяет обобщать данные в удобочитаемом формате и является альтернативой методу groupby
с дополнительными преобразованиями.
df.stb.freq(['Store Name'], value='Sale (Dollars)', style=True, cum_cols=False)
Похоже, во всех трех случаях
Hy-Vee #3 / BDI / Des Moines
Hy-Vee Wine and Spirits / Iowa City
Hy-Vee Food Store / Urbandale
речь идет об одном и том же магазине. Очевидно, что названия магазинов в большинстве случаев уникальны для каждого местоположения.
В идеале мы хотели бы сгруппировать вместе все продажи Hy-Vee
, Costco
и т.д.
Нам нужно очистить данные!
Первый подход, который мы рассмотрим, - это использование .loc
плюс логический фильтр с аксессором str
для поиска соответствующей строки в столбце Store Name
.
df.loc[df['Store Name'].str.contains('Hy-Vee', case=False), 'Store_Group_1'] = 'Hy-Vee'
Этот код будет искать строку Hy-Vee
без учета регистра и сохранять значение Hy-Vee
в новом столбце с именем Store_Group_1
. Данный код эффективно преобразует такие названия, как Hy-Vee # 3 / BDI / Des Moines
или Hy-Vee Food Store / Urbandale
, в обычное Hy-Vee
.
Вот, что %timeit
говорит об эффективности:
%timeit df.loc[df['Store Name'].str.contains('Hy-Vee', case=False), 'Store_Group_1'] = 'Hy-Vee'
1.52 s ± 11.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Можем использовать параметр regex=False
для ускорения вычислений:
%timeit df.loc[df['Store Name'].str.contains('Hy-Vee', case=False, regex=False), 'Store_Group_1'] = 'Hy-Vee'
811 ms ± 5.96 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Вот значения в новом столбце:
df['Store_Group_1'].value_counts(dropna=False)
NaN 1617777 Hy-Vee 762568 Name: Store_Group_1, dtype: int64
Мы очистили Hy-Vee
, но теперь появилось множество других значений, с которыми нам нужно разобраться.
Подход .loc
включает много кода и может быть медленным. Поищем альтернативы, которые быстрее выполнять и легче поддерживать.
Другой очень эффективный и гибкий подход - использовать np.select
для запуска нескольких совпадений и применения указанного значения при совпадении.
Есть несколько хороших ресурсов, которые я использовал, чтобы узнать про np.select
. Эта статья от Dataquest - хороший обзор, а также презентация Натана Чивера (Nathan Cheever). Рекомендую и то, и другое.
Самое простое объяснение того, что делает np.select
, состоит в том, что он оценивает список условий и применяет соответствующий список значений, если условие истинно.
В нашем случае условиями будут разные строки для поиски (string lookups), а в качестве значений нормализованные строки, которые хотим использовать.
После просмотра данных, вот список условий и значений в списке store_patterns
. Каждый кортеж в этом списке представляет собой поиск по str.contains()
и соответствующее текстовое значение, которое мы хотим использовать для группировки похожих счетов.
store_patterns = [
(df['Store Name'].str.contains('Hy-Vee', case=False, regex=False), 'Hy-Vee'),
(df['Store Name'].str.contains('Central City', case=False, regex=False), 'Central City'),
(df['Store Name'].str.contains("Smokin' Joe's", case=False, regex=False), "Smokin' Joe's"),
(df['Store Name'].str.contains('Walmart|Wal-Mart', case=False), 'Wal-Mart'),
(df['Store Name'].str.contains('Fareway Stores', case=False, regex=False), 'Fareway Stores'),
(df['Store Name'].str.contains("Casey's", case=False, regex=False), "Casey's General Store"),
(df['Store Name'].str.contains("Sam's Club", case=False, regex=False), "Sam's Club"),
(df['Store Name'].str.contains('Kum & Go', regex=False, case=False), 'Kum & Go'),
(df['Store Name'].str.contains('CVS', regex=False, case=False), 'CVS Pharmacy'),
(df['Store Name'].str.contains('Walgreens', regex=False, case=False), 'Walgreens'),
(df['Store Name'].str.contains('Yesway', regex=False, case=False), 'Yesway Store'),
(df['Store Name'].str.contains('Target Store', regex=False, case=False), 'Target'),
(df['Store Name'].str.contains('Quik Trip', regex=False, case=False), 'Quik Trip'),
(df['Store Name'].str.contains('Circle K', regex=False, case=False), 'Circle K'),
(df['Store Name'].str.contains('Hometown Foods', regex=False, case=False), 'Hometown Foods'),
(df['Store Name'].str.contains("Bucky's", case=False, regex=False), "Bucky's Express"),
(df['Store Name'].str.contains('Kwik', case=False, regex=False), 'Kwik Shop')
]
Одна из серьезных проблем при работе с np.select
заключается в том, что легко получить несоответствие условий и значений. Я решил объединить в кортеж, чтобы упростить отслеживание совпадений данных.
Из-за такой структуры приходится разбивать список кортежей на два отдельных списка.
Используя zip
, можем взять store_patterns
и разбить его на store_criteria
и store_values
:
store_criteria, store_values = zip(*store_patterns)
df['Store_Group_1'] = np.select(store_criteria, store_values, 'other')
Этот код будет заполнять каждое совпадение текстовым значением. Если совпадений нет, то присвоим ему значение other
.
Вот как это выглядит сейчас:
df.stb.freq(['Store_Group_1'], value='Sale (Dollars)', style=True, cum_cols=False)
Store_Group_1 | Sale (Dollars) | percent | |
---|---|---|---|
0 | Hy-Vee | 126,265,195 | 36.16% |
1 | other | 112,733,367 | 32.28% |
2 | Fareway Stores | 23,146,939 | 6.63% |
3 | Wal-Mart | 22,641,682 | 6.48% |
4 | Sam's Club | 19,604,085 | 5.61% |
5 | Central City | 14,108,944 | 4.04% |
6 | Casey's General Store | 11,351,935 | 3.25% |
7 | Kum & Go | 6,019,449 | 1.72% |
8 | Walgreens | 2,942,270 | 0.84% |
9 | Target | 2,904,611 | 0.83% |
10 | Smokin' Joe's | 2,049,536 | 0.59% |
11 | Kwik Shop | 1,431,142 | 0.41% |
12 | Quik Trip | 1,140,374 | 0.33% |
13 | CVS Pharmacy | 795,303 | 0.23% |
14 | Hometown Foods | 787,840 | 0.23% |
15 | Yesway Store | 741,863 | 0.21% |
16 | Bucky's Express | 465,757 | 0.13% |
17 | Circle K | 90,049 | 0.03% |
Так лучше, но 32,28%
выручки по-прежнему приходится на other
счета.
Далее, если есть счет, который не соответствует шаблону, то используем Store Name
вместо того, чтобы объединять все в other
.
Вот как мы это сделаем:`
df['Store_Group_1'] = np.select(store_criteria, store_values, None)
df['Store_Group_1'] = df['Store_Group_1'].combine_first(df['Store Name'])
Здесь используется функция comb_first
, чтобы заполнить все None
значения Store Name
. Это удобный прием, о котором следует помнить при очистке данных.
Проверим наши данные:
df.stb.freq(['Store_Group_1'], value='Sale (Dollars)', style=True, cum_cols=False)
Выглядит лучше, т.к. можем продолжать уточнять группировки по мере необходимости. Например, можно построить поиск по строке для Costco
.
Производительность не так уж и плоха для большого набора данных:
13.2 s ± 328 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Гибкость данного подхода в том, что можно использовать np.select
для числового анализа и текстовых примеров.
Единственная проблема, связанная с этим подходом, заключается в большом количестве кода.
Есть ли другой подход, который мог бы иметь аналогичную производительность, но был бы немного чище?
Следующее решение основано на этом примере кода от Мэтта Харрисона (Matt Harrison). Он разработал функцию generalize
, которая выполняет сопоставление и очистку за нас!
Я внес некоторые изменения, чтобы привести ее в соответствие с этим примером, но хочу отдать должное Мэтту. Я бы никогда не подумал об этом решении, если бы оно не выполняло 99%
всей работы!
def generalize(ser, match_name, default=None, regex=False, case=False):
""" Поиск в серии текстовых совпадений.
На основе кода из https://www.metasnake.com/blog/pydata-assign.html
ser: серии pandas для поиска
match_name: кортеж, содержащий текст для поиска и текст для нормализации
default: Если совпадений нет, используйте это, чтобы указать значение по умолчанию,
в противном случае используйте оригинальный текст
regex: Логическое значение, указывающее, содержит ли match_name регулярное выражение
case: Поиск с учетом регистра
Возвращает серию pandas с совпадающим значением
"""
seen = None
for match, name in match_name:
mask = ser.str.contains(match, case=case, regex=regex)
if seen is None:
seen = mask
else:
seen |= mask
ser = ser.where(~mask, name)
if default:
ser = ser.where(seen, default)
else:
ser = ser.where(seen, ser.values)
return ser
Эта функция может быть вызвана для серии pandas и ожидает список кортежей.
Первый элемент следующего кортежа - это значение для поиска, а второй - значение, которое нужно заполнить для совпадающего значения.
Вот список эквивалентных шаблонов:
store_patterns_2 = [('Hy-Vee', 'Hy-Vee'),
("Smokin' Joe's", "Smokin' Joe's"),
('Central City', 'Central City'),
('Costco Wholesale', 'Costco Wholesale'),
('Walmart', 'Walmart'),
('Wal-Mart', 'Walmart'),
('Fareway Stores', 'Fareway Stores'),
("Casey's", "Casey's General Store"),
("Sam's Club", "Sam's Club"),
('Kum & Go', 'Kum & Go'),
('CVS', 'CVS Pharmacy'),
('Walgreens', 'Walgreens'),
('Yesway', 'Yesway Store'),
('Target Store', 'Target'),
('Quik Trip', 'Quik Trip'),
('Circle K', 'Circle K'),
('Hometown Foods', 'Hometown Foods'),
("Bucky's", "Bucky's Express"),
('Kwik', 'Kwik Shop')]
Преимущество этого решения состоит в том, что поддерживать данный список намного проще, чем в предыдущем примере store_patterns
.
Другое изменение, которое я внес с помощью функции generalize
, заключается в том, что исходное значение будет сохранено, если не указано значение по умолчанию. Теперь вместо использования combine_first
функция generalize
позаботится обо всем.
Наконец, я отключил сопоставление регулярных выражений по умолчанию для улучшения производительности.
Теперь, когда все данные настроены, вызвать их очень просто:
df['Store_Group_2'] = generalize(df['Store Name'], store_patterns_2)
Как насчет производительности?
15.5 s ± 409 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Немного медленнее, но думаю, что это более элегантное решение и я бы использовал его в будущем.
Обратной стороной этого подхода является то, что он предназначен для очистки строк. Решение np.select
более полезно, поскольку его можно применять и к числовым значениям.
В последних версиях pandas есть специальный тип string
. Я попытался преобразовать Store Name
в строковый тип pandas, чтобы увидеть, есть ли улучшение производительности. Никаких изменений не заметил. Однако не исключено, что в будущем скорость будет повышена, так что имейте это в виду.
Тип category
показал многообещающие результаты.
Обратитесь к моей предыдущей статье за подробностями о типе данных категории.
Можем преобразовать данные в тип category
с помощью astype
:
df['Store Name'] = df['Store Name'].astype('category')
Теперь повторно запустите пример np.select
точно так же, как мы делали ранее:
df['Store_Group_3'] = np.select(store_criteria, store_values, None)
df['Store_Group_3'] = df['Store_Group_1'].combine_first(df['Store Name'])
786 ms ± 108 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Мы перешли с 13
до менее 1 секунды
, сделав одно простое изменение. Удивительно!
Причина, по которой это произошло, довольно проста. Когда pandas преобразует столбец в категориальный тип, функция str.contains()
будет вызываться для каждого уникального текстового значения. Поскольку этот набор данных содержит много повторяющихся данных, мы получаем огромный прирост производительности.
Посмотрим, работает ли это для нашей функции generalize
:
df['Store_Group_4'] = generalize(df['Store Name'], store_patterns_2)
К сожалению, получаем ошибку:
ValueError: Cannot setitem on a Categorical with a new category, set the categories first
Эта ошибка подчеркивает некоторые проблемы, с которыми я сталкивался в прошлом при работе с категориальными (Categorical) данными. При merging и joining категориальных данных вы можете столкнуться с подобными типами проблем.
Я попытался найти хороший способ изменить работу generalize()
, но не смог.
Тем не менее есть способ воспроизвести категориальный подход (Category approach), построив таблицу поиска (lookup table).
Как мы узнали из категориального подхода, данный набор содержит много повторяющихся данных.
Мы можем построить таблицу поиска и запустить ресурсоемкую функцию только один раз для каждой строки.
Чтобы проиллюстрировать, как это работает со строками, давайте преобразуем значение обратно в строковый тип вместо категории:
df['Store Name'] = df['Store Name'].astype('string')
df.head()
Invoice/Item Number | Date | Store Number | Store Name | Address | City | Zip Code | Store Location | County Number | County | ... | Bottle Volume (ml) | State Bottle Cost | State Bottle Retail | Bottles Sold | Sale (Dollars) | Volume Sold (Liters) | Volume Sold (Gallons) | Store_Group_1 | Store_Group_2 | Store_Group_3 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | INV-16681900011 | 01/02/2019 | 5286 | Sauce | 108, College | Iowa City | 52240.0 | NaN | 52.0 | JOHNSON | ... | 200 | 6.24 | 9.36 | 24 | 224.64 | 4.8 | 1.26 | Sauce | Sauce | Sauce |
1 | INV-16681900027 | 01/02/2019 | 5286 | Sauce | 108, College | Iowa City | 52240.0 | NaN | 52.0 | JOHNSON | ... | 500 | 11.50 | 17.25 | 12 | 207.00 | 6.0 | 1.58 | Sauce | Sauce | Sauce |
2 | INV-16681900018 | 01/02/2019 | 5286 | Sauce | 108, College | Iowa City | 52240.0 | NaN | 52.0 | JOHNSON | ... | 375 | 3.21 | 4.82 | 24 | 115.68 | 9.0 | 2.37 | Sauce | Sauce | Sauce |
3 | INV-16685400036 | 01/02/2019 | 2524 | Hy-Vee Food Store / Dubuque | 3500 Dodge St | Dubuque | 52001.0 | NaN | 31.0 | DUBUQUE | ... | 1000 | 4.17 | 6.26 | 12 | 75.12 | 12.0 | 3.17 | Hy-Vee | Hy-Vee | Hy-Vee |
4 | INV-16690300035 | 01/02/2019 | 4449 | Kum & Go #121 / Urbandale | 12041 Douglas Pkwy | Urbandale | 50322.0 | NaN | 77.0 | POLK | ... | 375 | 1.86 | 2.79 | 24 | 66.96 | 9.0 | 2.37 | Kum & Go | Kum & Go | Kum & Go |
5 rows × 27 columns
Сначала мы создаем DataFrame
поиска, который содержит все уникальные значения, и запускаем функцию generalize
:
lookup_df = pd.DataFrame()
lookup_df['Store Name'] = df['Store Name'].unique()
lookup_df['Store_Group_5'] = generalize(lookup_df['Store Name'], store_patterns_2)
lookup_df.head()
Store Name | Store_Group_5 | |
---|---|---|
0 | Sauce | Sauce |
1 | Hy-Vee Food Store / Dubuque | Hy-Vee |
2 | Kum & Go #121 / Urbandale | Kum & Go |
3 | IDA Liquor | IDA Liquor |
4 | Lake View Foods | Lake View Foods |
Можем объединить (merge) его обратно в окончательный DataFrame
:
df = pd.merge(df, lookup_df, how='left')
1.38 s ± 15.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Он работает медленнее, чем подход np.select
для категориальных данных, но влияние на производительность может быть уравновешено более простой читабельностью для ведения списка поиска.
Кроме того, промежуточный lookup_df
может стать отличным выходом для аналитика, который поможет очистить больше данных. Эту экономию можно измерить часами работы!
Этот информационный бюллетень Рэнди Ау (Randy Au) - хорошее обсуждение важности очистки данных и отношения любви / ненависти, которое многие специалисты по данным чувствуют при выполнении данной задачи. Я согласен с предположением Рэнди о том, что очистка данных - это анализ.
По моему опыту, вы можете многое узнать о своих базовых данных, взяв на себя действия по очистке, описанные в этой статье.
Я подозреваю, что в ходе повседневного анализа вы найдете множество случаев, когда вам нужно очистить текст, аналогично тому, что я показал выше.
Вот краткое изложение рассмотренных решений:
Решение | Время исполнения | Примечания |
---|---|---|
np.select |
13 с |
Может работать для нетекстового анализа |
generalize |
15 с |
Только текст |
Категориальные данные и np.select |
786 мс |
Категориальные данные могут быть сложными при merging и joining |
Таблица поиска и generalize |
1.3 с |
Таблица поиска может поддерживаться кем-то другим |
Для некоторых наборов данных производительность не является проблемой, поэтому выбирайте то, что вам ближе.
Однако по мере увеличения размера данных (представьте, что вы проводите анализ для 50
штатов), вам нужно будет понять, как эффективно использовать pandas для очистки текста.