简介
数据清洗是数据分析过程中的一个重要部分,这已经不是什么秘密。在使用pandas时,有多种技术可以清理文本字段,为进一步分析做准备。随着数据集的增长,重要的是找到有效的方法,在合理的时间内执行,并且可以维护,因为文本清理过程会随着时间的推移而发展。
本文将展示清理大数据文件中的文本字段的例子,并说明如何使用Python和pandas有效清理非结构化文本字段的技巧。
问题
就本文而言,假设你有一种全新的手工威士忌,你想出售。你的地区包括爱荷华州,而恰好有一个开放的数据集,显示该州所有的酒类销售情况。这似乎是一个很好的机会,你可以利用你的分析技能,看看谁是该州最大的客户。有了这些数据,你就可以为每个客户规划你的销售流程。
对这个机会感到兴奋,你下载了数据,发现它非常大。这个案例的数据集是一个565MB的 CSV文件,有24列,230行。这决不是大数据,但它大到可以让Excel爬行。它也足够大,以至于一些pandas方法在你的笔记本电脑上会变得相对缓慢。
对于这篇文章,我将使用包括所有2019年销售的数据。由于大小的原因,你可以从国家网站上下载不同时间段的数据。
让我们开始吧,导入我们的模块并读取数据。我还将使用sidetable包来总结数据。这不是清理的必要条件,但我想强调它对这些数据探索场景是多么有用。
数据
让我们来获取我们的数据。
import pandas as pd
import numpy as np
import sidetable
df = pd.read_csv('2019_Iowa_Liquor_Sales.csv')
下面是数据的样子。

我们可能想做的第一件事是看看每个商店的采购量,并从大到小进行排名。我们的资源有限,所以我们应该把重点放在那些能获得最佳收益的地方。对我们来说,拜访几个大公司的客户而不是很多妈妈和爸爸的商店会更容易。
sidetable 这是一个以可读格式总结数据的捷径。另一个选择是做一个 groupby,再加上额外的操作。
df.stb.freq(['Store Name'], value='Sale (Dollars)', style=True, cum_cols=False)

有一点很明显,在大多数情况下,商店的名称是唯一的。 理想情况下,我们希望看到Hy-Vee、Costco、Sam's等的所有销售都被分组。
看来,我们需要清理数据。
清理尝试#1
我们可以研究的第一个方法是使用 .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告诉我们的这个性能。
1.43 s ± 31.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
通常情况下,我们不想在过程中过早地进行优化,但我们可以做的一件事是使用 regex=False参数来给予加速。
df.loc[df['Store Name'].str.contains('Hy-Vee', case=False, regex=False), 'Store_Group_1'] = 'Hy-Vee'
804 ms ± 27.9 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的方法包含大量的代码,而且速度会很慢。我们可以使用这个概念,但要寻找一些执行速度更快、更容易维护的替代品。
清洁尝试#2
另一种方法是使用 np.select来运行多个匹配,并在匹配后应用指定的值,这种方法非常具有性能和灵活性。
有几个好的资源,我用来学习如何使用 np.select。这篇来自Dataquest的文章是一个很好的概述。我还发现Nathan Cheever的这个演讲非常有趣,信息量很大。我鼓励你去看看这两份资料。
对于 np.select,最简单的解释是,它评估一个条件列表,如果条件为真,则应用一个相应的值列表。
在我们的例子中,我们的条件将是不同的字符串查找,而我们想要使用的规范化的字符串将是值。
看完数据后,这里是 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')
这段代码将在每个匹配项中填入文本值。如果没有匹配,我们将给它赋值为'其他'。
下面是它现在的样子。
df.stb.freq(['Store_Group_1'], value='Sale (Dollars)', style=True, cum_cols=False)

这看起来好些了,但仍有32.28%的收入在 "其他 "账户中。
最好的办法是,如果有一个账户不匹配,我们就使用 Store Name,而不是把所有的账户都归入其他。下面是我们如何做的。
df['Store_Group_1'] = np.select(store_criteria, store_values, None)
df['Store_Group_1'] = df['Store_Group_1'].combine_first(df['Store Name'])
这使用 combine_first函数,用 Store Name填补所有 None的值。这是一个方便的技巧,在清理你的数据时要牢记。
让我们检查一下我们的数据。
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进行数字分析,也可以使用这里显示的文本例子。它非常灵活。
这种方法的一个挑战是,有很多代码。如果你有一个大的数据集需要清理,在这个解决方案中就会有很多数据和代码交织在一起。
有没有另一种方法,可能有类似的性能,但更干净一点?
清洁尝试#3
下一个解决方案是基于Matt Harrison的这个优秀的代码例子,他开发了一个 generalize函数,为我们进行匹配和清理。我做了一些修改,使之与这个例子一致,但我想把功劳归于Matt。如果没有他所做的99%的工作,我永远不会想到这个解决方案
def generalize(ser, match_name, default=None, regex=False, case=False):
""" Search a series for text matches.
Based on code from https://www.metasnake.com/blog/pydata-assign.html
ser: pandas series to search
match_name: tuple containing text to search for and text to use for normalization
default: If no match, use this to provide a default value, otherwise use the original text
regex: Boolean to indicate if match_name contains a regular expression
case: Case sensitive search
Returns a pandas series with the matched value
"""
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,该函数将处理这一切。最后,我在默认情况下关闭了regex匹配,以提高性能。
现在,数据都已经设置好了,调用它很简单。
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类型在这个数据集上显示了很大的前景。关于类别数据类型的细节,请参考我之前的文章。
我们可以使用 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将一个列转换为分类类型时,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
这个错误突出了我过去在处理分类数据时遇到的一些挑战。当合并和连接分类数据时,你可能会遇到这些类型的挑战。
我试图找出一个很好的方法来修改 generalize(),但没有想出办法。任何能想出这个办法的读者都可以得到奖励。
然而,我们有一个方法可以通过建立一个查找表来复制分类方法。
检索表
正如我们从 "分类 "方法中学到的那样,这个数据集有很多重复的数据。 我们可以建立一个查找表,对每个字符串只处理一次资源密集型函数。
为了说明这在字符串上的作用,让我们把值转换回字符串类型而不是类别。
df['Store Name'] = df['Store Name'].astype('string')
首先我们建立一个包含所有唯一值的查找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)

我们可以把它合并回一个最终的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的这篇简讯很好地讨论了数据清理的重要性以及许多数据科学家对这项工作的爱恨情仇。我同意Randy的前提,即数据清理就是分析。
根据我的经验,你可以通过采取本文所概述的那种清理活动来了解你的基础数据。
我猜想你会在日常分析中发现很多需要进行类似于本文所展示的文本清理的情况。
下面是对我们所看到的解决方案的一个快速总结。
文本清理选项
| 解决方案 | 执行时间 | 备注 |
|---|---|---|
np.select | 13s | 可用于非文本分析 |
generalize | 15s | 仅限文本 |
分类数据和 np.select | 786ms | 分类数据在合并和连接时可能会变得很棘手 |
查询表和 generalize | 1.3s | 一个查找表可以由其他人来维护 |
对于一些数据集来说,性能不是问题,所以要选择你的大脑所能接受的。
然而,随着数据规模的增长(想象一下为价值50个州的数据做这样的分析),你将需要了解如何以高效的方式使用pandas进行文本清理。我希望你把这篇文章收藏起来,当你遇到类似的问题时再来看看。
像往常一样,如果你有一些其他的提示,可能对朋友们有用,请在评论中告诉我。如果你想出了如何使我的 generalize函数适用于分类数据,也让我知道。