7.13 向量化字符串操作
原文:Vectorized String Operations 译者:飞龙 协议:CC BY-NC-SA 4.0 本节是《Python 数据科学手册》(Python Data Science Handbook)的摘录。
Python 的一个优点是它在处理和操作字符串数据方面相对容易。Pandas 构建于此之上,并提供了一套全面的向量化字符串操作,它们成为处理(阅读“清理”部分)实际数据时所需的重要部分。在本节中,我们将介绍一些 Pandas 字符串操作,然后使用它们来部分清理从互联网收集的,非常混乱的食谱数据集。
Pandas 字符串操作简介
我们在前面的部分中看到,NumPy 和 Pandas 等工具如何扩展算术运算,使我们可以在许多数组元素上轻松快速地执行相同的操作。 例如:
代码语言:javascript复制import numpy as np
x = np.array([2, 3, 5, 7, 11, 13])
x * 2
# array([ 4, 6, 10, 14, 22, 26])
这种向量化操作简化了操作数据数组的语法:我们不再需要担心数组的大小或形状,而只需要关心我们想要做什么操作。对于字符串数组,NumPy 不提供这样简单的访问,因此你使用更详细的循环语法:
代码语言:javascript复制data = ['peter', 'Paul', 'MARY', 'gUIDO']
[s.capitalize() for s in data]
# ['Peter', 'Paul', 'Mary', 'Guido']
这可能足以处理一些数据,但如果有任何缺失值,它将会中断。例如:
代码语言:javascript复制data = ['peter', 'Paul', None, 'MARY', 'gUIDO']
[s.capitalize() for s in data]
'''
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
<ipython-input-3-fc1d891ab539> in <module>()
1 data = ['peter', 'Paul', None, 'MARY', 'gUIDO']
----> 2 [s.capitalize() for s in data]
<ipython-input-3-fc1d891ab539> in <listcomp>(.0)
1 data = ['peter', 'Paul', None, 'MARY', 'gUIDO']
----> 2 [s.capitalize() for s in data]
AttributeError: 'NoneType' object has no attribute 'capitalize'
'''
Pandas 包含的功能可以解决向量化字符串操作的这种需求,以及通过包含字符串的 Pandas Series
和Index
对象的str
属性,来正确处理缺失数据。因此,例如,假设我们使用以下数据创建 Pandas 序列:
import pandas as pd
names = pd.Series(data)
names
'''
0 peter
1 Paul
2 None
3 MARY
4 gUIDO
dtype: object
'''
我们现在可以调用一个方法来大写所有条目,同时跳过任何缺失值:
代码语言:javascript复制names.str.capitalize()
'''
0 Peter
1 Paul
2 None
3 Mary
4 Guido
dtype: object
'''
在此str
属性上使用制表符补全,将列出 Pandas 可用的所有向量化字符串方法。
Pandas 字符串方法的表格
如果你对 Python 中的字符串操作有很好的理解,那么大多数 Pandas 字符串语法都足够直观,只需列出一个可用方法表即可。 我们将从这里开始,然后深入探讨一些细微之处。本节中的示例使用以下名称序列:
代码语言:javascript复制monte = pd.Series(['Graham Chapman', 'John Cleese', 'Terry Gilliam',
'Eric Idle', 'Terry Jones', 'Michael Palin'])
相似于 Python 字符串方法的方法
几乎所有 Python 的内置字符串方法都对应了 Pandas 向量化字符串方法。 这是一个对应 Python 字符串方法的 Pandas str
方法列表:
len() | lower() | translate() | islower() |
ljust() | upper() | startswith() | isupper() |
rjust() | find() | endswith() | isnumeric() |
center() | rfind() | isalnum() | isdecimal() |
zfill() | index() | isalpha() | split() |
strip() | rindex() | isdigit() | rsplit() |
rstrip() | capitalize() | isspace() | partition() |
lstrip() | swapcase() | istitle() | rpartition() |
请注意,它们具有各种返回值。 有些像lower()
那样返回字符串序列:
monte.str.lower()
'''
0 graham chapman
1 john cleese
2 terry gilliam
3 eric idle
4 terry jones
5 michael palin
dtype: object
'''
但是一些返回数字:
代码语言:javascript复制monte.str.len()
'''
0 14
1 11
2 13
3 9
4 11
5 13
dtype: int64
'''
或布尔值:
代码语言:javascript复制monte.str.startswith('T')
'''
0 False
1 False
2 True
3 False
4 True
5 False
dtype: bool
'''
还有一些为每个元素返回列表或其他复合值:
代码语言:javascript复制monte.str.split()
'''
0 [Graham, Chapman]
1 [John, Cleese]
2 [Terry, Gilliam]
3 [Eric, Idle]
4 [Terry, Jones]
5 [Michael, Palin]
dtype: object
'''
在我们继续讨论的过程中,我们将看到这种列表序列对象的进一步操作。
使用正则表达式的方法
此外,有几种方法可以接受正则表达式,来检查每个字符串元素的内容,并遵循 Python 内置的re
模块的一些 API 约定:
方法 | 描述 |
---|---|
match() | 在每个元素上调用re.match(),返回布尔值 |
extract() | 在每个元素上调用re.match(),返回作为字符串的每个分组 |
findall() | 在每个元素上调用re.findall() |
replace() | 将模式串的每次出现替换为一些其它字符串 |
contains() | 在每个元素上调用re.search(),返回布尔值 |
count() | 统计模式串的出现次数 |
split() | 等价于str.split(),但是接受正则表达式 |
rsplit() | 等价于str.rsplit(),但是接受正则表达式 |
有了这些,你可以进行各种有趣的操作。例如,我们可以提取每个元素的名字,通过在每个元素的开头要求一组连续字符:
代码语言:javascript复制monte.str.extract('([A-Za-z] )', expand=False)
'''
0 Graham
1 John
2 Terry
3 Eric
4 Terry
5 Michael
dtype: object
'''
或者我们可以做一些更复杂的事情,比如查找所有以辅音开头和结尾的名字,利用字符串开头(^
)和字符串结尾($
)正则表达式字符:
monte.str.findall(r'^[^AEIOU].*[^aeiou]$')
'''
0 [Graham Chapman]
1 []
2 [Terry Gilliam]
3 []
4 [Terry Jones]
5 [Michael Palin]
dtype: object
'''
在Series
或Dataframe
条目中简洁地应用正则表达式的能力,为分析和清理数据提供了许多可能性。
杂项方法
最后,有一些杂项方法可以执行其他方便的操作:
方法 | 描述 |
---|---|
get() | 索引每个元素 |
slice() | 对每个元素切片 |
slice_replace() | 用传递的值替换每个元素的切片 |
cat() | 连接字符串 |
repeat() | 重复值 |
normalize() | 返回字符串的 Unicode 形式 |
pad() | 在字符串的左侧,右侧或两侧添加空格 |
wrap() | 将长字符串拆分为长度小于给定宽度的行 |
join() | 使用传递的分隔符连接每个元素中的字符串 |
get_dummies() | 将虚拟变量提取为数据帧 |
向量化的项目访问和切片
特别是get()
和slice()
操作,可以在每个数组中执行向量化元素访问。例如,我们可以使用str.slice(0, 3)
来获取每个数组的前三个字符的切片。请注意,这种行为也可以通过 Python 的常规索引语法执行 - 例如,df.str.slice(0, 3)
相当于df.str[0:3]
:
monte.str[0:3]
'''
0 Gra
1 Joh
2 Ter
3 Eri
4 Ter
5 Mic
dtype: object
'''
通过df.str.get(i)
和df.str[i]
执行索引也是类似的。这些get()
和slice()
方法也允许你访问由split()
返回的数组元素。例如,要提取每个条目的姓氏,我们可以组合split()
和get()
:
monte.str.split().str.get(-1)
'''
0 Chapman
1 Cleese
2 Gilliam
3 Idle
4 Jones
5 Palin
dtype: object
'''
指示符变量
需要一些额外解释的另一种方法是get_dummies()
方法。当你的数据带有一列,它包含某种编码指示符时,这非常有用。例如,我们可能有一个数据集,包含代码形式的信息,例如A
是“在美国出生”,B
时候“在英国出生”,C
是“喜欢奶酪”,D
是“喜欢垃圾邮件”:
full_monte = pd.DataFrame({'name': monte,
'info': ['B|C|D', 'B|D', 'A|C',
'B|D', 'B|C', 'B|C|D']})
full_monte
info | name | |
---|---|---|
0 | B|C|D | Graham Chapman |
1 | B|D | John Cleese |
2 | A|C | Terry Gilliam |
3 | B|D | Eric Idle |
4 | B|C | Terry Jones |
5 | B|C|D | Michael Palin |
get_dummies()
例程允许你快速将这些指示符变量拆分为DataFrame
:
full_monte['info'].str.get_dummies('|')
A | B | C | D | |
---|---|---|---|---|
0 | 0 | 1 | 1 | 1 |
1 | 0 | 1 | 0 | 1 |
2 | 1 | 0 | 1 | 0 |
3 | 0 | 1 | 0 | 1 |
4 | 0 | 1 | 1 | 0 |
5 | 0 | 1 | 1 | 1 |
通过将这些操作作为积木,你可以在清理数据时构建无穷无尽的字符串处理过程。
我们不会在这里深入探讨这些方法,但我鼓励你阅读 Pandas 在线文档中的“处理文本数据”,或参考“更多资源”中列出的资源。
示例:食谱数据库
在清理凌乱的真实数据的过程中,这些向量化字符串操作变得最有用。 在这里,我将使用从 Web 上的各种来源编译的开放式食谱数据库,来说明这一点。 我们的目标是,将食谱数据解析为成分列表,这样我们就可以根据手头的一些成分,快速找到配方。
用于编译它的脚本可以在 https://github.com/fictivekin/openrecipes 找到,同时也可以找到当前版本数据库的链接。
从 2016 年春季开始,这个数据库大约 30MB,可以使用以下命令下载和解压缩:
代码语言:javascript复制# !curl -O http://openrecipes.s3.amazonaws.com/recipeitems-latest.json.gz
# !gunzip recipeitems-latest.json.gz
数据库采用 JSON 格式,因此我们将尝试pd.read_json
来读取它:
try:
recipes = pd.read_json('recipeitems-latest.json')
except ValueError as e:
print("ValueError:", e)
'''
ValueError: Trailing data
'''
哦!我们得到了ValueError
,提到有“尾随数据”。在互联网上搜索此错误的文本,似乎是由于使用了一个文件,其中每行本身是一个有效的 JSON,但完整文件不是。让我们检查一下这种解释是否正确:
with open('recipeitems-latest.json') as f:
line = f.readline()
pd.read_json(line).shape
# (2, 12)
是的,显然每一行都是有效的 JSON,所以我们需要将它们串在一起。我们可以这样做的一种方法是,实际构造一个包含所有这些 JSON 条目的字符串表示,然后用pd.read_json
加载整个东西:
# 将整个文件读入 Python 数组中
with open('recipeitems-latest.json', 'r') as f:
# 提取每一行
data = (line.strip() for line in f)
# 重新格式化,使每一行是列表的元素
data_json = "[{0}]".format(','.join(data))
# 将结果读取为 JSON
recipes = pd.read_json(data_json)
recipes.shape
# (173278, 17)
我们看到有近 20 万个食谱和 17 个列。让我们看看一行,看看我们有什么:
代码语言:javascript复制recipes.iloc[0]
'''
_id {'$oid': '5160756b96cc62079cc2db15'}
cookTime PT30M
creator NaN
dateModified NaN
datePublished 2013-03-11
description Late Saturday afternoon, after Marlboro Man ha...
image http://static.thepioneerwoman.com/cooking/file...
ingredients Biscuitsn3 cups All-purpose Flourn2 Tablespo...
name Drop Biscuits and Sausage Gravy
prepTime PT10M
recipeCategory NaN
recipeInstructions NaN
recipeYield 12
source thepioneerwoman
totalTime NaN
ts {'$date': 1365276011104}
url http://thepioneerwoman.com/cooking/2013/03/dro...
Name: 0, dtype: object
'''
这里有很多信息,但其中很多都是非常混乱的形式,就像从 Web 上抓取的数据一样。特别是,成分列表是字符串格式;我们将不得不仔细提取我们感兴趣的信息。让我们首先仔细看看成分:
代码语言:javascript复制recipes.ingredients.str.len().describe()
'''
count 173278.000000
mean 244.617926
std 146.705285
min 0.000000
25% 147.000000
50% 221.000000
75% 314.000000
max 9067.000000
Name: ingredients, dtype: float64
'''
成分列表平均长度为 250 个字符,最小值为 0,最多为 10,000 个字符!出于好奇,让我们看看哪个食谱有最长的成分列表:
代码语言:javascript复制recipes.name[np.argmax(recipes.ingredients.str.len())]
# 'Carrot Pineapple Spice & Brownie Layer Cake with Whipped Cream & Cream Cheese Frosting and Marzipan Carrots'
当然看起来像复杂的食谱。
我们可以执行其他聚合探索;例如,让我们看看有多少食谱是早餐食品:
代码语言:javascript复制recipes.description.str.contains('[Bb]reakfast').sum()
# 3524
或者有多少食谱将肉桂列为成分:
代码语言:javascript复制recipes.ingredients.str.contains('[Cc]innamon').sum()
# 10526
我们甚至可以看看,是否有任何食谱将这种成分拼错为cinamon
:
recipes.ingredients.str.contains('[Cc]inamon').sum()
# 11
这是使用 Pandas 字符串工具可以实现的基本数据探索类型。这是 Python 真正擅长的数据整理。
一个简单的食谱推荐器
让我们再进一步,开始研究一个简单的食谱推荐系统:给出成分列表,找到使用所有这些成分的食谱。虽然概念上很简单,但由于数据的异质性,任务变得复杂:例如,从每一行中提取干净的成分列表并不容易。
所以我们用一些手段:我们先从一系列常见成分开始,然后仅仅搜索它们是否在每个配方的成分列表中。为简单起见,我们暂时仅仅使用草药和香料:
代码语言:javascript复制spice_list = ['salt', 'pepper', 'oregano', 'sage', 'parsley',
'rosemary', 'tarragon', 'thyme', 'paprika', 'cumin']
然后我们可以构建一个由True
和False
值组成的布尔DataFrame
,指示该成分是否出现在列表中:
import re
spice_df = pd.DataFrame(dict((spice, recipes.ingredients.str.contains(spice, re.IGNORECASE))
for spice in spice_list))
spice_df.head()
cumin | oregano | paprika | parsley | pepper | rosemary | sage | salt | tarragon | thyme | |
---|---|---|---|---|---|---|---|---|---|---|
0 | False | False | False | False | False | False | True | False | False | False |
1 | False | False | False | False | False | False | False | False | False | False |
2 | True | False | False | False | True | False | False | True | False | False |
3 | False | False | False | False | False | False | False | False | False | False |
4 | False | False | False | False | False | False | False | False | False | False |
现在,作为一个例子,假设我们想要找到一种使用欧芹(parsley),辣椒粉(paprika)和龙蒿(tarragon)的食谱。我们可以使用DataFrame
的query()
方法快速计算,在“高性能 Pandas:eval()
和query()
”中讨论:
selection = spice_df.query('parsley & paprika & tarragon')
len(selection)
# 10
我们发现这种组合只有 10 种食谱;让我们使用此选择返回的索引,来发现具有此组合的食谱的名称:
代码语言:javascript复制recipes.name[selection.index]
'''
2069 All cremat with a Little Gem, dandelion and wa...
74964 Lobster with Thermidor butter
93768 Burton's Southern Fried Chicken with White Gravy
113926 Mijo's Slow Cooker Shredded Beef
137686 Asparagus Soup with Poached Eggs
140530 Fried Oyster Po’boys
158475 Lamb shank tagine with herb tabbouleh
158486 Southern fried chicken in buttermilk
163175 Fried Chicken Sliders with Pickles Slaw
165243 Bar Tartine Cauliflower Salad
Name: name, dtype: object
'''
现在我们已经将食谱选择范围缩小了近 2 万倍,我们能够在晚餐时做出更明智的决定。
进一步探索食谱
希望这个例子为你提供了一些能在 Pandas 字符串方法中有效使用的数据清理操作类型。当然,建立一个非常强大的食谱推荐系统需要更多的工作!
从每个食谱中提取完整的成分列表,是该任务的重要部分;遗憾的是,各种所使用格式使得这是一个相对耗时的过程。这表明,在数据科学中,清理和修改现实世界的数据通常包含大部分工作,而 Pandas 提供的工具可以帮助你有效地完成这项工作。