数据科学学习指南(四)
原文:
zh.annas-archive.org/md5/9950f82165bee426119f99afc3ab612d译者:飞龙
第四部分:其他数据来源
第十三章:处理文本
数据不仅可以以数字形式存在,还可以以文字形式存在:狗品种的名称、餐馆违规描述、街道地址、演讲、博客文章、网评等等。为了组织和分析文本中包含的信息,我们经常需要执行以下一些任务:
将文本转换为标准格式
这也被称为规范化文本。例如,我们可能需要将字符转换为小写,使用常见拼写和缩写,或删除标点和空格。
提取文本片段以创建特征
举例来说,一个字符串可能包含嵌入其中的日期,我们希望从字符串中提取出来以创建一个日期特征。
将文本转换为特征
我们可能希望将特定词语或短语编码为 0-1 特征,以指示它们在字符串中的存在。
分析文本
为了一次性比较整个文档,我们可以将文档转换为单词计数的向量。
这一章介绍了处理文本数据的常用技术。我们展示了简单的字符串操作工具通常足以将文本整理成标准形式或提取字符串的部分内容。我们还介绍了正则表达式,用于更通用和稳健的模式匹配。为了演示这些文本操作,我们使用了几个例子。我们首先介绍这些例子,并描述我们想要为分析准备文本的工作。
文本和任务示例
对于刚刚介绍的每种任务,我们提供一个激励性的例子。这些例子基于我们实际完成的任务,但为了专注于概念,我们已经将数据简化为片段。
将文本转换为标准格式
假设我们想要研究人口统计数据与选举结果之间的联系。为此,我们从维基百科获取了选举数据,从美国人口普查局获取了人口数据。数据的粒度是以县为单位的,我们需要使用县名来连接这两个表格。不幸的是,这两个表格中的县名并不总是匹配的:
| 县 | 州 | 投票数 | |
|---|---|---|---|
| 0 | 德维特县 | 伊利诺伊州 | 97.8 |
| 1 | 拉克奎帕尔县 | 明尼苏达州 | 98.8 |
| 2 | 列威斯和克拉克县 | 蒙大拿州 | 95.2 |
| 3 | 圣约翰大洗礼者教区 | 路易斯安那州 | 52.6 |
| 县 | 州 | 人口 | |
| --- | --- | --- | --- |
| 0 | 德威特 | 伊利诺伊州 | 16,798 |
| 1 | 拉克奎帕尔 | 明尼苏达州 | 8,067 |
| 2 | 列威斯和克拉克 | 蒙大拿州 | 55,716 |
| 3 | 圣约翰大洗礼者 | 路易斯安那州 | 43,044 |
我们无法将表格连接起来,直到我们将字符串清理为县名的共同格式为止。我们需要更改字符的大小写,使用常见的拼写和缩写,并处理标点符号。
提取文本片段以创建特征
文本数据有时具有很多结构,特别是当它是由计算机生成时。例如,以下是一个 Web 服务器的日志条目。注意条目中有多个数据片段,但这些片段没有一致的分隔符——例如,日期出现在方括号中,但数据的其他部分出现在引号和括号中:
169.237.46.168 - -
[26/Jan/2004:10:47:58 -0800]"GET /stat141/Winter04 HTTP/1.1" 301 328
"http://anson.ucdavis.edu/courses"
"Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.1.4322)"
尽管文件格式与我们在 第八章 中看到的简单格式之一不符,但我们可以使用文本处理技术提取文本片段以创建特征。
将文本转换为特征
在 第九章 中,我们基于字符串内容创建了一个分类特征。在那里,我们检查了餐厅违规描述,并为特定词语的存在创建了名义变量。这里展示了一些示例违规行为:
unclean or degraded floors walls or ceilings
inadequate and inaccessible handwashing facilities
inadequately cleaned or sanitized food contact surfaces
wiping cloths not clean or properly stored or inadequate sanitizer
foods not protected from contamination
unclean nonfood contact surfaces
unclean or unsanitary food contact surfaces
unclean hands or improper use of gloves
inadequate washing facilities or equipment
这些新功能可以用于食品安全评分的分析。以前,我们制作了简单的特征,标记了描述中是否包含诸如 手套 或 头发 这样的词语。在本章中,我们更正式地介绍了我们用来创建这些特征的正则表达式工具。
文本分析
有时我们想比较整个文档。例如,美国总统每年都会发表国情咨文演讲。以下是第一次演讲的前几行:
***
State of the Union Address
George Washington
January 8, 1790
Fellow-Citizens of the Senate and House of Representatives:
I embrace with great satisfaction the opportunity which now presents itself
of congratulating you on the present favorable prospects of our public …
我们可能会想:国情咨文演讲随时间如何变化?不同政党是否专注于不同的主题或在演讲中使用不同的语言?为了回答这些问题,我们可以将演讲转换为数字形式,以便使用统计方法进行比较。
这些示例用来说明字符串操作、正则表达式和文本分析的思想。我们从描述简单的字符串操作开始。
字符串操作
当我们处理文本时,有几种基本的字符串操作工具我们经常使用。
-
将大写字符转换为小写(或反之)。
-
用另一个子字符串替换或删除子字符串。
-
将字符串在特定字符处分割成片段。
-
在指定的位置切片字符串。
我们展示了如何组合这些基本操作来清理县名数据。请记住,我们有两张表需要连接,但县名的写法不一致。
让我们首先将县名转换为标准格式。
使用 Python 字符串方法将文本转换为标准格式
我们需要解决两张表中县名之间的以下不一致性:
-
大小写问题:
qui与Qui。 -
省略词语:
County和Parish在census表中不存在。 -
不同的缩写约定:
&与and。 -
不同的标点符号约定:
St.与St。 -
使用空白符:
DeWitt与De Witt。
当我们清理文本时,通常最容易的方法是先将所有字符转换为小写。全使用小写字符比尝试跟踪大写和小写的组合要容易。接下来,我们想通过将&替换为and并删除County和Parish来修复不一致的单词。最后,我们需要修复标点符号和空格的不一致。
只需使用两个 Python 字符串方法,lower和replace,我们就可以执行所有这些操作并清理县名。这些方法被合并到一个名为clean_county的方法中:
`def` `clean_county``(``county``)``:`
`return` `(``county`
`.``lower``(``)`
`.``replace``(``'``county``'``,` `'``'``)`
`.``replace``(``'``parish``'``,` `'``'``)`
`.``replace``(``'``&``'``,` `'``and``'``)`
`.``replace``(``'``.``'``,` `'``'``)`
`.``replace``(``'` `'``,` `'``'``)``)`
尽管简单,这些方法是我们可以组合成更复杂的字符串操作的基本构建块。这些方法方便地定义在所有 Python 字符串上,无需导入其他模块。值得熟悉的是字符串方法的完整列表,但我们在表格 13-1 中描述了一些最常用的方法。
表格 13-1. 字符串方法
| 方法 | 描述 |
|---|---|
str.lower() | 返回字符串的副本,所有字母都转换为小写 |
str.replace(a, b) | 将str中所有子字符串a替换为子字符串b |
str.strip() | 从str中移除前导和尾随的空格 |
str.split(a) | 返回在子字符串a处分割的str的子字符串 |
str[x:y] | 切片 str,返回从索引 x(包括)到 y(不包括)的部分 |
接下来,我们验证clean_county方法是否生成匹配的县名:
`(``[``clean_county``(``county``)` `for` `county` `in` `election``[``'``County``'``]``]``,`
`[``clean_county``(``county``)` `for` `county` `in` `census``[``'``County``'``]``]``)`
(['dewitt', 'lacquiparle', 'lewisandclark', 'stjohnthebaptist'],
['dewitt', 'lacquiparle', 'lewisandclark', 'stjohnthebaptist'])
自从县名现在有了统一的表示方式,我们可以成功地将这两个表格连接起来。
pandas 中的字符串方法
在上述代码中,我们使用循环来转换每个县名。pandas的Series对象提供了一种便捷的方法,可以将字符串方法应用于系列中的每个项。
在pandas的Series上,.str属性公开了相同的 Python 字符串方法。在.str属性上调用方法会在系列中的每个项上调用该方法。这使我们能够在不使用循环的情况下转换系列中的每个字符串。我们将转换后的县名保存回其原始表中。首先,我们在选举表中转换县名:
`election``[``'``County``'``]` `=` `(``election``[``'``County``'``]`
`.``str``.``lower``(``)`
`.``str``.``replace``(``'``parish``'``,` `'``'``)`
`.``str``.``replace``(``'``county``'``,` `'``'``)`
`.``str``.``replace``(``'``&``'``,` `'``and``'``)`
`.``str``.``replace``(``'``.``'``,` `'``'``,` `regex``=``False``)`
`.``str``.``replace``(``'` `'``,` `'``'``)``)`
我们还将人口普查表中的名称转换,以便这两个表格包含相同的县名表示。我们可以连接这些表格:
`election``.``merge``(``census``,` `on``=``[``'``County``'``,``'``State``'``]``)`
| 县名 | 州名 | 投票率 | 人口 | |
|---|---|---|---|---|
| 0 | dewitt | IL | 97.8 | 16,798 |
| 1 | lacquiparle | MN | 98.8 | 8,067 |
| 2 | lewisandclark | MT | 95.2 | 55,716 |
| 3 | stjohnthebaptist | LA | 52.6 | 43,044 |
注意
注意,我们根据县名和州名两列进行了合并。这是因为一些州有同名的县。例如,加利福尼亚州和纽约州都有一个名为金县的县。
要查看完整的字符串方法列表,我们建议查看Python 关于str方法的文档和pandas关于.str访问器的文档。我们只使用了str.lower()和多次调用str.replace()来完成规范化任务。接下来,我们使用另一个字符串方法str.split()提取文本。
分割字符串以提取文本片段
假设我们想从网络服务器的日志条目中提取日期:
`log_entry`
169.237.46.168 - - [26/Jan/2004:10:47:58 -0800]"GET /stat141/Winter04 HTTP/1.1"
301 328 "http://anson.ucdavis.edu/courses""Mozilla/4.0 (compatible; MSIE 6.0;
Windows NT 5.0; .NET CLR 1.1.4322)"
字符串分割可以帮助我们定位构成日期的信息片段。例如,当我们在左括号上分割字符串时,我们得到两个字符串:
`log_entry``.``split``(``'``[``'``)`
['169.237.46.168 - - ',
'26/Jan/2004:10:47:58 -0800]"GET /stat141/Winter04 HTTP/1.1" 301 328 "http://anson.ucdavis.edu/courses""Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.1.4322)"']
第二个字符串包含日期信息,为了获取日、月和年,我们可以在该字符串上以冒号分割:
`log_entry``.``split``(``'``[``'``)``[``1``]``.``split``(``'``:``'``)``[``0``]`
'26/Jan/2004'
要分开日、月和年,我们可以在斜杠上分割。总共我们分割原始字符串三次,每次只保留我们感兴趣的部分:
`(``log_entry``.``split``(``'``[``'``)``[``1``]`
`.``split``(``'``:``'``)``[``0``]`
`.``split``(``'``/``'``)``)`
['26', 'Jan', '2004']
通过反复使用split(),我们可以提取日志条目的许多部分。但是这种方法很复杂——如果我们还想获取活动的小时、分钟、秒钟和时区,我们需要总共使用split()六次。有一种更简单的方法来提取这些部分:
`import` `re`
`pattern` `=` `r``'``[` `\``[/:``\``]]``'`
`re``.``split``(``pattern``,` `log_entry``)``[``4``:``11``]`
['26', 'Jan', '2004', '10', '47', '58', '-0800']
这种替代方法使用了一个称为正则表达式的强大工具,我们将在下一节中介绍。
正则表达式
正则表达式(或简称regex)是我们用来匹配字符串部分的特殊模式。想想社会安全号码(SSN)的格式,例如134-42-2012。为了描述这种格式,我们可以说 SSN 由三位数字、一个短划线、两位数字、另一个短划线,然后是四位数字。正则表达式让我们能够在代码中捕获这种模式。正则表达式为我们提供了一种紧凑而强大的方式来描述这种数字和短划线的模式。正则表达式的语法非常简单,我们在本节中几乎介绍了所有的语法。
在介绍这些概念时,我们解决了前一节中描述的一些示例,并展示了如何使用正则表达式执行任务。几乎所有编程语言都有一个库,用于使用正则表达式匹配模式,这使得正则表达式在任何编程语言中都很有用。我们使用 Python 内置的re模块中的一些常见方法来完成示例中的任务。这些方法在本节末尾的表格 13-7 中进行了总结,其中简要描述了基本用法和返回值。由于我们只涵盖了一些最常用的方法,您可能会发现参考官方文档关于re模块的信息也很有用。
正则表达式基于逐个字符(也称为字面量)搜索模式的字符串。我们称这种概念为字面量的连接。
字面量的连接
串联最好通过一个基本示例来解释。假设我们在字符串cards scatter!中寻找模式cat。图 13-1 包含了一个图表,展示了搜索如何逐个字符地进行。请注意,在字符串的第一个位置找到了“c”,接着是“a”,但没有“t”,所以搜索会回到字符串的第二个字符,并开始再次搜索“c”。模式cat在字符串cards scatter!中的位置为 8 到 10。一旦你掌握了这个过程,你可以继续进行更丰富的模式;它们都遵循这个基本的范例。
图 13-1. 为了匹配文字模式,正则引擎沿着字符串移动,并逐个检查是否符合整个模式。请注意,模式在单词scatters中找到,并且在cards中找到部分匹配。
注意
在前面的例子中,我们观察到正则表达式可以匹配出现在输入字符串中的任何模式。在 Python 中,这种行为根据用于匹配正则表达式的方法而异——某些方法仅在字符串开头匹配正则表达式时返回匹配;其他方法则在字符串中的任何位置返回匹配。
这些更丰富的模式由字符类和通配符等元字符组成。我们将在接下来的小节中描述它们。
字符类
我们可以通过使用字符类(也称为字符集)使模式更加灵活,它允许我们指定要匹配的一组等效字符。这使我们能够创建更宽松的匹配。要创建一个字符类,请将所需的字符集合包含在方括号[ ]中。例如,模式[0123456789]表示“匹配方括号内的任何文字”—在这种情况下是任何单个数字。然后,以下正则表达式匹配三个数字:
[0123456789][0123456789][0123456789]
这是一个常用的字符类,有一个数字范围的简写表示法[0-9]。字符类允许我们创建一个匹配社保号码(SSNs)的正则表达式:
[0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9][0-9][0-9]
另外两个常用的字符类范围是小写字母[a-z]和大写字母[A-Z]。我们可以将范围与其他等效字符结合使用,并使用部分范围。例如,[a-cX-Z27]等效于字符类[abcXYZ27]。
让我们回到我们最初的模式cat,并修改它以包含两个字符类:
c[oa][td]
这个模式匹配cat,但也匹配cot,cad和cod:
Regex: c[oa][td]
Text: The cat eats cod, cads, and cots, but not coats.
Matches: *** *** *** ***
每次仍然逐个字符地移动字符串的核心概念仍然存在,但现在在哪个文字被视为匹配方面有了更多的灵活性。
通配符字符
当我们真的不关心文字是什么时,我们可以用.,即句号字符来指定。这可以匹配除换行符以外的任何字符。
否定字符类
否定字符类 匹配方括号内除了那些字符外的任何字符。要创建否定字符类,请在左方括号后面放置插入符号。例如,[⁰-9] 匹配除数字外的任何字符。
字符类的简写形式
有些字符集如此常见,以至于有它们的简写形式。例如,\d代表[0-9]。我们可以使用这些简写来简化对社会安全号码的搜索:
\d\d\d-\d\d-\d\d\d\d
我们的社会安全号码的正则表达式并不是完全可靠的。如果字符串在我们寻找的模式开头或结尾有额外的数字,那么我们仍然能够匹配到。注意,我们在引号前添加r字符以创建原始字符串,这样可以更容易地编写正则表达式:
Regex: \d\d\d-\d\d-\d\d\d\d
Text: My other number is 6382-13-38420.
Matches: ***********
我们可以通过不同类型的元字符来修正这种情况:一个可以匹配单词边界的元字符。
锚点和边界
有时我们想要匹配字符之前、之后或之间的位置。一个例子是定位字符串的开头或结尾;这些称为锚点。另一个是定位单词的开头或结尾,我们称之为边界。元字符\b表示单词的边界。它长度为 0,并且匹配模式边界上的空白或标点符号。我们可以用它来修复我们的社会安全号码的正则表达式:
Regex: \d\d\d-\d\d-\d\d\d\
Text: My other number is 6382-13-38420.
Matches:
Regex: \b\d\d\d-\d\d-\d\d\d\d\b
Text: My reeeal number is 382-13-3842.
Matches: ***********
转义元字符
我们现在已经见过几个特殊字符,称为元字符:[ 和 ] 表示字符类,^ 切换到否定字符类,. 表示任何字符,- 表示范围。但有时我们可能想要创建一个匹配其中一个这些文字的模式。当这种情况发生时,我们必须用反斜杠进行转义。例如,我们可以使用正则表达式 \[ 来匹配字面上的左方括号字符:
Regex: \[
Text: Today is [2022/01/01]
Matches: *
接下来,我们展示量词如何帮助创建更紧凑和清晰的正则表达式来匹配社会安全号码。
量词
要创建一个用于匹配社会安全号码的正则表达式,我们写道:
\b[0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9][0-9][0-9]\b
这匹配由三个数字、一个破折号、另外两个数字、一个破折号和另外四个数字组成的“单词”。
量词允许我们匹配多个连续出现的字符。我们通过在花括号{ }中放置数字来指定重复的次数。
让我们使用 Python 的内置re模块来匹配这种模式:
`import` `re`
`ssn_re` `=` `r``'``\``b[0-9]``{3}``-[0-9]``{2}``-[0-9]``{4}``\``b``'`
`re``.``findall``(``ssn_re``,` `'``My SSN is 382-34-3840.``'``)`
['382-34-3840']
我们的模式不应匹配电话号码。让我们试试:
`re``.``findall``(``ssn_re``,` `'``My phone is 382-123-3842.``'``)`
[]
量词总是修改其左侧的字符或字符类。Table 13-2 显示了量词的完整语法。
Table 13-2. 量词示例
| Table 13-3. 简写量词 | | --- | --- | | {m, n} | 匹配前面的字符 m 到 n 次。 | | {m} | 匹配前面的字符恰好 m 次。 | | {m,} | 匹配前面的字符至少 m 次。 | | {,n} | 匹配前面的字符最多 n 次。 |
一些常用的量词有简写形式,如 Table 13-3 所示。
Table 13-3. 简写量词
| Symbol | 量词 | 含义 |
|---|---|---|
* | {0,} | 匹配前一个字符 0 或多次。 |
+ | {1,} | 匹配前一个字符 1 次或多次。 |
? | {0,1} | 匹配前一个字符 0 或 1 次。 |
量词是贪婪的,会返回可能的最长匹配。这有时会导致意想不到的行为。由于社会安全号码以数字开头和结尾,我们可能认为以下较短的正则表达式将是查找 SSN 的简单方法。你能想出匹配出现错误的原因吗?
`ssn_re_dot` `=` `r``'``[0-9].+[0-9]``'`
`re``.``findall``(``ssn_re_dot``,` `'``My SSN is 382-34-3842 and hers is 382-34-3333.``'``)`
['382-34-3842 and hers is 382-34-3333']
注意,我们使用元字符.匹配任意字符。在许多情况下,使用更具体的字符类可以避免这些虚假的“超匹配”。我们之前包含单词边界的模式就是这样做的:
`re``.``findall``(``ssn_re``,` `'``My SSN is 382-34-3842 and hers is 382-34-3333.``'``)`
['382-34-3842', '382-34-3333']
有些平台允许关闭贪婪匹配并使用惰性匹配,返回最短的字符串。
文字连接和量词是正则表达式中的两个核心概念。接下来,我们介绍另外两个核心概念:选择和分组。
利用选择和分组创建特征
字符类允许我们匹配单个文字的多个选项。我们可以使用选择来匹配一组文字的多个选项。例如,在第九章中的食品安全示例中,我们标记与身体部位相关的违规行为,通过检查违规行为是否包含hand、nail、hair或glove子串。我们可以在正则表达式中使用|字符指定这种选择:
`body_re` `=` `r``"``hand|nail|hair|glove``"`
`re``.``findall``(``body_re``,` `"``unclean hands or improper use of gloves``"``)`
['hand', 'glove']
`re``.``findall``(``body_re``,` `"``Unsanitary employee garments hair or nails``"``)`
['hair', 'nail']
使用括号我们可以定位模式的部分,这称为正则表达式组。例如,我们可以使用正则表达式组从 Web 服务器日志条目中提取日期、月份、年份和时间:
`# This pattern matches the entire timestamp`
`time_re` `=` `r``"``\``[[0-9]``{2}``/[a-zA-z]``{3}``/[0-9]``{4}``:[0-9:``\``- ]*``\``]``"`
`re``.``findall``(``time_re``,` `log_entry``)`
['[26/Jan/2004:10:47:58 -0800]']
`# Same regex, but we use parens to make regex groups...`
`time_re` `=` `r``"``\``[([0-9]``{2}``)/([a-zA-z]``{3}``)/([0-9]``{4}``):([0-9:``\``- ]*)``\``]``"`
`# ...which tells findall() to split up the match into its groups`
`re``.``findall``(``time_re``,` `log_entry``)`
[('26', 'Jan', '2004', '10:47:58 -0800')]
正如我们所见,re.findall 返回一个包含日期和时间各个组件的元组列表。
我们已经介绍了许多术语,因此在下一节中,我们将它们整合到一组表格中,以便轻松查阅。
参考表格
我们通过几个总结操作顺序、元字符和字符类速记的表格来结束本节。我们还提供了总结在本节中使用的re Python 库少数方法的表格。
正则表达式的四个基本操作——连接、量化、选择和分组——具有优先顺序,在表格 13-4 中我们明确说明了这一点。
表格 13-4. 操作顺序
| 操作 | 顺序 | 示例 | 匹配 |
|---|---|---|---|
| 连接 | 3 | cat | cat |
| 选择 | 4 | cat|mouse | cat 和 mouse |
| 量化 | 2 | cat? | ca 和 cat |
| 分组 | 1 | c(at)? | c 和 cat |
表格 13-5 提供了本节介绍的元字符列表,以及一些额外的内容。标有“不匹配”的列举了示例正则表达式不匹配的字符串。
表格 13-5. 元字符
| 字符 | 描述 | 示例 | 匹配 | 不匹配 |
|---|---|---|---|---|
| . | 除 \n 外的任意字符 | ... | abc | ab |
| [ ] | 方括号内的任意字符 | [cb.]ar | car .ar | jar |
| [^ ] | 方括号内 不 包含的任意字符 | [^b]ar | car par | bar ar |
| * | ≥ 0 或更多前一个符号,简写为 {0,} | [pb]*ark | bbark ark | dark |
| + | ≥ 1 或更多前一个符号,简写为 {1,} | [pb]+ark | bbpark bark | dark ark |
| ? | 0 或 1 个前一个符号,简写为 {0,1} | s?he | she he | the |
| {n} | 前一个符号恰好 n 次 | hello{3} | hellooo | hello |
| | | 竖线前后的模式 | we|[ui]s | we us
is | es e
s |
| \ | 转义下一个字符 | \[hi\] | [hi] | hi |
|---|---|---|---|---|
| ^ | 行首 | ^ark | ark two | dark |
| $ | 行尾 | ark$ | noahs ark | noahs arks |
| \b | 单词边界 | ark\b | ark of noah | noahs arks |
此外,在 表 13-6 中,我们为一些常用字符集提供了简写。这些简写不需要 [ ]。
表 13-6. 字符类简写
| 描述 | 方括号形式 | 简写 |
|---|---|---|
| 字母数字字符 | [a-zA-Z0-9_] | \w |
| 非字母数字字符 | [^a-zA-Z0-9_] | \W |
| 数字 | [0-9] | \d |
| 非数字 | [⁰-9] | \D |
| 空白字符 | [\t\n\f\r\p{Z}] | \s |
| 非空白字符 | [\t\n\f\r\p{z}] | \S |
我们在本章中使用了以下 re 方法。方法名称指示了它们执行的功能:在字符串中 搜索 或 匹配 模式;在字符串中 查找 所有模式的情况;将模式的所有出现 替换 为子字符串;以及在模式处 分割 字符串。每个方法都需要指定一个模式和一个字符串,并且一些还有额外的参数。表 13-7 提供了方法使用的格式以及返回值的描述。
表 13-7. 正则表达式方法
| 方法 | 返回值 |
|---|---|
re.search(pattern, string) | 如果模式在字符串任何位置找到,则为匹配对象,否则为 None |
re.match(pattern, string) | 如果模式在字符串开头找到,则为匹配对象,否则为 None |
re.findall(pattern, string) | 字符串 string 中所有 pattern 的匹配项列表 |
re.sub(pattern, replacement, string) | 字符串 string 中所有 pattern 的出现都被 replacement 替换的字符串 |
re.split(pattern, string) | 围绕 pattern 出现的 string 片段列表 |
正如我们在前一节中看到的,pandas 的 Series 对象具有一个 .str 属性,支持使用 Python 字符串方法进行字符串操作。方便地,.str 属性还支持 re 模块的一些函数。表 13-8 展示了与 re 方法的 表 13-7 相似的功能。每个都需要一个模式。请参阅 pandas 文档 获取完整的字符串方法列表。
表 13-8. pandas 中的正则表达式
| 方法 | 返回值 |
|---|---|
str.contains(pattern, regex=True) | 指示 pattern 是否找到的布尔序列 |
str.findall(pattern, regex=True) | 匹配 pattern 的所有结果的列表 |
str.replace(pattern, replacement, regex=True) | Series with all matching occurrences of pattern replaced by replacement |
str.split(pattern, regex=True) | 给定 pattern 周围字符串列表的序列 |
正则表达式是一个强大的工具,但它们以难以阅读和调试著称。最后我们提出了一些建议来使用正则表达式:
-
在简单的测试字符串上开发你的正则表达式,看看模式匹配的情况。
-
如果一个模式没有匹配到任何内容,尝试通过减少模式的部分来削弱它。然后逐步加强它,看看匹配的进展。(在线正则表达式检查工具在这里非常有帮助。)
-
让模式尽可能具体以适应手头的数据。
-
尽可能使用原始字符串以获得更清晰的模式,特别是当模式包含反斜杠时。
-
当你有很多长字符串时,考虑使用编译后的模式,因为它们可以更快速地匹配(见
re库中的compile())。
在下一节中,我们进行一个示例文本分析。我们使用正则表达式和字符串操作清理数据,将文本转换为定量数据,并通过这些派生数量分析文本。
文本分析
到目前为止,我们使用 Python 方法和正则表达式来清理短文本字段和字符串。在本节中,我们将使用一种称为文本挖掘的技术来分析整个文档,该技术将自由形式的文本转换为定量表达,以揭示有意义的模式和洞见。
文本挖掘是一个深奥的主题。我们不打算进行全面的讲解,而是通过一个例子介绍几个关键思想,我们将分析从 1790 年到 2022 年的国情咨文演讲。每年,美国总统向国会发表国情咨文演讲。这些演讲谈论国家当前事件,并提出国会应考虑的建议。美国总统项目 在线提供这些演讲。
让我们从打开包含所有演讲的文件开始:
`from` `pathlib` `import` `Path`
`text` `=` `Path``(``'``data/stateoftheunion1790-2022.txt``'``)``.``read_text``(``)`
在本章的开头,我们看到数据中每篇演讲都以三个星号开头的一行:***。我们可以使用正则表达式来计算字符串 *** 出现的次数:
`import` `re`
`num_speeches` `=` `len``(``re``.``findall``(``r``"``\``*``\``*``\``*``"``,` `text``)``)`
`print``(``f``'``There are` `{``num_speeches``}` `speeches total``'``)`
There are 232 speeches total
在文本分析中,文档 指的是我们想要分析的单个文本。在这里,每篇演讲都是一个文档。我们将 text 变量分解为其各个文档:
`records` `=` `text``.``split``(``"``***``"``)`
然后我们可以把演讲放入一个数据框中:
`def` `extract_parts``(``speech``)``:`
`speech` `=` `speech``.``strip``(``)``.``split``(``'``\n``'``)``[``1``:``]`
`[``name``,` `date``,` `*``lines``]` `=` `speech`
`body` `=` `'``\n``'``.``join``(``lines``)``.``strip``(``)`
`return` `[``name``,` `date``,` `body``]`
`def` `read_speeches``(``)``:`
`return` `pd``.``DataFrame``(``[``extract_parts``(``l``)` `for` `l` `in` `records``[``1``:``]``]``,`
`columns` `=` `[``"``name``"``,` `"``date``"``,` `"``text``"``]``)`
`df` `=` `read_speeches``(``)`
`df`
| 名称 | 日期 | 文本 | |
|---|---|---|---|
| 0 | 乔治·华盛顿 | 1790 年 1 月 8 日 | 参议院和众议院的同胞们... |
| 1 | 乔治·华盛顿 | 1790 年 12 月 8 日 | 参议院和众议院的同胞们... |
| 2 | 乔治·华盛顿 | 1791 年 10 月 25 日 | 参议院和众议院的同胞们... |
| ... | ... | ... | ... |
| 229 | 唐纳德·J·特朗普 | 2020 年 2 月 4 日 | 非常感谢。谢谢。非常感谢你... |
| 230 | 约瑟夫·R·拜登 | 2021 年 4 月 28 日 | 谢谢你。谢谢你。谢谢你。很高兴回来... |
| 231 | 约瑟夫·R·拜登 | 2022 年 3 月 1 日 | 女士们,众议长,女副总统,我们的第一... |
232 rows × 3 columns
现在我们已经将演讲加载到数据框中,我们希望转换演讲,以查看它们随时间的变化。我们的基本思想是查看演讲中的单词 - 如果两个演讲包含非常不同的单词,我们的分析应告诉我们这一点。通过某种文档相似度的度量,我们可以看到演讲彼此之间的差异。
文档中有一些问题,我们需要先解决:
-
大小写不应该影响:
Citizens和citizens应被视为相同的单词。我们可以通过将文本转换为小写来解决这个问题。 -
文本中有未发表的言论:
[laughter]指出观众笑了的地方,但这些不应该算作演讲的一部分。我们可以通过使用正则表达式来删除方括号内的文本:\[[^\]]+\]。请记住,\[和\]匹配文字的左右括号,[^\]]匹配任何不是右括号的字符。 -
我们应该去掉不是字母或空格的字符:一些演讲谈到财务问题,但金额不应该被视为单词。我们可以使用正则表达式
[^a-z\s]来删除这些字符。这个正则表达式匹配任何不是小写字母 (a-z) 或空格字符 (\s) 的字符:`def` `clean_text``(``df``)``:` `bracket_re` `=` `re``.``compile``(``r``'``\``[[^``\``]]+``\``]``'``)` `not_a_word_re` `=` `re``.``compile``(``r``'``[^a-z``\``s]``'``)` `cleaned` `=` `(``df``[``'``text``'``]``.``str``.``lower``(``)` `.``str``.``replace``(``bracket_re``,` `'``'``,` `regex``=``True``)` `.``str``.``replace``(``not_a_word_re``,` `'` `'``,` `regex``=``True``)``)` `return` `df``.``assign``(``text``=``cleaned``)` `df` `=` `(``read_speeches``(``)` `.``pipe``(``clean_text``)``)` `df`名字 日期 文本 0 乔治·华盛顿 1790 年 1 月 8 日 参议院和众议院的同胞们... 1 乔治·华盛顿 1790 年 12 月 8 日 参议院和众议院的同胞们... 2 乔治·华盛顿 1791 年 10 月 25 日 参议院和众议院的同胞们... ... ... ... ... 229 唐纳德·J·特朗普 2020 年 2 月 4 日 非常感谢。谢谢。非常感谢你... 230 约瑟夫·R·拜登 2021 年 4 月 28 日 谢谢你。谢谢你。谢谢你。很高兴回来... 231 约瑟夫·R·拜登 2022 年 3 月 1 日 女士们,众议长,女副总统,我们的第一... 232 rows × 3 columns
接下来,我们看一些更复杂的问题:
-
停用词,如
is、and、the和but出现得太频繁,我们希望将它们删除。 -
argue和arguing应被视为相同的单词,尽管它们在文本中显示不同。为了解决这个问题,我们将使用词干提取,将两个单词转换为argu。
要处理这些问题,我们可以使用nltk 库中的内置方法。
最后,我们将演讲转换为词向量。词向量使用一组数字来表示一个文档。例如,一种基本的词向量类型统计了文本中每个词出现的次数,如图 13-2 所示。
图 13-2。三个小示例文档的词袋向量
这种简单的转换被称为词袋模型,我们将其应用于所有的演讲稿。然后,我们计算词频-逆文档频率(tf-idf简称)来规范化计数并测量单词的稀有性。tf-idf 会对只出现在少数文档中的单词给予更多的权重。其思想是,如果只有少数文档提到了制裁这个词,那么这个词对于区分不同文档就非常有用。我们使用的scikit-learn 库完整描述了这一转换和实现。
应用这些转换后,我们得到了一个二维数组,speech_vectors。该数组的每一行是一个转换为向量的演讲:
`import` `nltk`
`nltk``.``download``(``'``stopwords``'``)`
`nltk``.``download``(``'``punkt``'``)`
`from` `nltk``.``stem``.``porter` `import` `PorterStemmer`
`from` `sklearn``.``feature_extraction``.``text` `import` `TfidfVectorizer`
`stop_words` `=` `set``(``nltk``.``corpus``.``stopwords``.``words``(``'``english``'``)``)`
`porter_stemmer` `=` `PorterStemmer``(``)`
`def` `stemming_tokenizer``(``document``)``:`
`return` `[``porter_stemmer``.``stem``(``word``)`
`for` `word` `in` `nltk``.``word_tokenize``(``document``)`
`if` `word` `not` `in` `stop_words``]`
`tfidf` `=` `TfidfVectorizer``(``tokenizer``=``stemming_tokenizer``)`
`speech_vectors` `=` `tfidf``.``fit_transform``(``df``[``'``text``'``]``)`
`speech_vectors``.``shape`
(232, 13211)
我们有 232 篇演讲,每篇演讲都被转换为一个长度为 13,211 的向量。为了可视化这些演讲,我们使用一种称为主成分分析的技术,将 13,211 个特征的数据表通过一组新的正交特征重新表示。第一个向量解释了原始特征的最大变化,第二个向量解释了与第一个正交的最大方差,依此类推。通常,我们可以将前两个主成分作为点对进行绘制,从而揭示聚类和异常值。
接下来,我们绘制了前两个主成分。每个点代表一个演讲,我们根据演讲的年份对点进行了颜色编码。靠近一起的点表示相似的演讲,而远离的点表示不同的演讲:
我们可以清晰地看到随时间变化的演讲之间存在明显的差异——19 世纪的演讲使用了与 21 世纪后的演讲非常不同的词汇。同一时间段的演讲聚集在一起也是非常有趣的现象。这表明,同一时期的演讲在语言风格上相对相似,即使演讲者来自不同的政党。
本节对文本分析进行了简要介绍。我们使用了前几节的文本操作工具来清理总统演讲稿。然后,我们采用了更高级的技术,如词干提取、tf-idf 转换和主成分分析来比较演讲。尽管本书无法详细介绍所有这些技术,但我们希望这一部分能引起您对文本分析这一激动人心领域的兴趣。
摘要
本章介绍了处理文本以清洁和分析数据的技术,包括字符串操作、正则表达式和文档分析。文本数据包含了关于人们生活、工作和思考方式的丰富信息。但这些数据对计算机来说也很难使用——想想人们为了表达同一个词而创造出的各种形式。本章的技术使我们能够纠正打字错误,从日志中提取特征,并比较文档。
我们不建议您使用正则表达式来:
-
解析层次结构,如 JSON 或 HTML;请使用解析器
-
搜索复杂属性,如回文和平衡的括号
-
验证复杂功能,比如有效的电子邮件地址
正则表达式虽然功能强大,但在这类任务中表现很差。然而,根据我们的经验,即使是基本的文本处理技能也能实现各种有趣的分析——少量技巧也能带来长远影响。
我们还要特别提醒一下正则表达式:它们可能会消耗大量计算资源。在将它们用于生产代码时,您需要权衡这些简洁明了的表达式与它们可能带来的开销。
下一章将讨论其他类型的数据,如二进制格式的数据以及 JSON 和 HTML 中高度结构化的文本。我们将重点放在将这些数据加载到数据框和其他 Python 数据结构中。
第十四章:数据交换
数据可以以许多不同的格式存储和交换。到目前为止,我们专注于纯文本分隔和固定宽度格式(第八章)。在本章中,我们稍微扩展了视野,并介绍了几种其他流行的格式。虽然 CSV、TSV 和 FWF 文件有助于将数据组织成数据框架,但其他文件格式可以节省空间或表示更复杂的数据结构。二进制文件(binary是指不是纯文本格式的格式)可能比纯文本数据源更经济。例如,在本章中,我们介绍了 NetCDF,这是一种用于交换大量科学数据的流行二进制格式。其他像 JSON 和 XML 这样的纯文本格式可以以更通用和有用于复杂数据结构的方式组织数据。甚至 HTML 网页,作为 XML 的近亲,通常包含我们可以抓取并整理以进行分析的有用信息。
在本章中,我们介绍了这些流行格式,描述了它们组织的心智模型,并提供了示例。除了介绍这些格式外,我们还涵盖了在线获取数据的程序化方法。在互联网出现之前,数据科学家必须亲自搬移硬盘驱动器才能与他人共享数据。现在,我们可以自由地从世界各地的计算机中检索数据集。我们介绍了 HTTP,这是 Web 的主要通信协议,以及 REST,一种数据传输的架构。通过了解一些关于这些 Web 技术的知识,我们可以更好地利用 Web 作为数据来源。
本书始终为数据整理、探索和建模提供了可重复的代码示例。在本章中,我们将讨论如何以可重复的方式获取在线数据。
我们首先介绍 NetCDF,然后是 JSON。接着,在概述用于数据交换的 Web 协议之后,我们通过介绍 XML、HTML 和 XPath,一个从这些类型文件中提取内容的工具,来结束本章。
NetCDF 数据
网络通用数据格式(NetCDF)是存储面向数组的科学数据的方便高效的格式。该格式的心智模型通过多维值网格表示变量。图 14-1 展示了这个概念。例如,降雨量每天在全球各地记录。我们可以想象这些降雨量值排列成一个立方体,其中经度沿着立方体的一边,纬度沿着另一边,日期沿着第三个维度。立方体中的每个单元格存储了特定位置每天记录的降雨量。NetCDF 文件还包含我们称之为元数据的有关立方体尺寸的信息。在数据框中,同样的信息会以完全不同的方式组织,对于每次降雨测量,我们需要经度、纬度和日期三个特征。这将意味着重复大量数据。使用 NetCDF 文件,我们不需要为每天重复经度和纬度值,也不需要为每个位置重复日期。
图 14-1. 该图表代表了 NetCDF 数据的模型。数据组织成一个三维数组,其中包含了时间和位置(纬度、经度和时间)上的降雨记录。“X”标记了特定位置在特定日期的一个降雨测量。
NetCDF 除了更紧凑之外,还有其他几个优点:
可扩展的
它提供了对数据子集的高效访问。
可附加的
您可以轻松地添加新数据而无需重新定义结构。
可共享
它是一种独立于编程语言和操作系统的常见格式。
自描述的
源文件包含了数据组织的描述和数据本身。
社区
这些工具是由用户社区提供的。
注意
NetCDF 格式是二进制数据的一个例子 —— 这类数据不能像 CSV 这样的文本格式一样直接在文本编辑器如 vim 或 Visual Studio Code 中读取。还有许多其他二进制数据格式,包括 SQLite 数据库(来自第 7 章)、Feather 和 Apache Arrow。二进制数据格式提供了存储数据集的灵活性,但通常需要特殊工具来打开和读取。
NetCDF 变量不仅限于三个维度。例如,我们可以添加海拔到我们的地球科学应用程序中,以便我们在时间、纬度、经度和海拔上记录温度等数据。维度不一定要对应物理维度。气候科学家经常运行几个模型,并将模型号存储在维度中以及模型输出。虽然 NetCDF 最初是为大气科学家设计的,由大气研究公司(UCAR)开发,但这种格式已经广受欢迎,并且现在在全球数千个教育、研究和政府网站上使用。应用程序已扩展到其他领域,如天文学和物理学,通过史密森尼/ NASA 天体物理数据系统(ADS),以及医学成像通过医学图像 NetCDF(MINC)。
NetCDF 文件有三个基本组件:维度、变量和各种元数据。变量包含我们认为的数据,例如降水记录。每个变量都有名称、存储类型和形状,即维度的数量。维度组件给出每个维度的名称和网格点的数量。坐标提供了其他信息,特别是测量点的位置,例如经度,在这里这些可能是0.0 , 0.25 , 0.50 , … , 359.75。其他元数据包括属性。变量的属性可以包含有关变量的辅助信息,其他属性包含关于文件的全局信息,例如发布数据集的人员、其联系信息以及使用数据的权限。这些全局信息对确保可重复结果至关重要。
以下示例检查了特定 NetCDF 文件的组件,并演示了如何从变量中提取数据的部分。
气候数据存储提供了来自各种气候部门和服务的数据集合。我们访问了他们的网站,并请求了 2022 年 12 月两周的温度和总降水量的测量数据。让我们简要检查这些数据的组织结构,如何提取子集,并进行可视化。
数据位于 NetCDF 文件CDS_ERA5_22-12.nc中。让我们首先弄清楚文件有多大:
`from` `pathlib` `import` `Path`
`import` `os`
`file_path` `=` `Path``(``)` `/` `'``data``'` `/` `'``CDS_ERA5_22-12.nc``'`
`kib` `=` `1024`
`size` `=` `os``.``path``.``getsize``(``file_path``)`
`np``.``round``(``size` `/` `kib``*``*``3``)`
2.0
尽管只有三个变量(总降水量、降雨率、温度)的两周数据,但文件的大小达到了 2 GiB!这些气候数据通常会相当庞大。
xarray包对处理类似数组的数据非常有用,尤其是 NetCDF 格式的数据。我们使用它的功能来探索我们气候文件的组件。首先我们打开文件:
`import` `xarray` `as` `xr`
`ds` `=` `xr``.``open_dataset``(``file_path``)`
现在让我们检查文件的维度组件:
`ds``.``dims`
Frozen(SortedKeysDict({'longitude': 1440, 'latitude': 721, 'time': 408}))
就像在 图 14-1 中一样,我们的文件有三个维度:经度、纬度和时间。 每个维度的大小告诉我们有超过 400,000 个数据值单元格(1440 × 721 × 408)。 如果这些数据在数据框中,则数据框将具有 400,000 行,其中包含大量重复的纬度、经度和时间列! 相反,我们只需要它们的值一次,坐标组件就会给我们提供它们:
`ds``.``coords`
Coordinates:
* longitude (longitude) float32 0.0 0.25 0.5 0.75 ... 359.0 359.2 359.5 359.8
* latitude (latitude) float32 90.0 89.75 89.5 89.25 ... -89.5 -89.75 -90.0
* time (time) datetime64[ns] 2022-12-15 ... 2022-12-31T23:00:00
我们文件中的每个变量都是三维的。 实际上,一个变量不一定要有所有三个维度,但在我们的示例中确实有:
`ds``.``data_vars`
Data variables:
t2m (time, latitude, longitude) float32 ...
lsrr (time, latitude, longitude) float32 ...
tp (time, latitude, longitude) float32 ...
变量的元数据提供单位和较长的描述,而源的元数据则为我们提供诸如检索数据的时间等信息:
`ds``.``tp``.``attrs`
{'units': 'm', 'long_name': 'Total precipitation'}
`ds``.``attrs`
{'Conventions': 'CF-1.6',
'history': '2023-01-19 19:54:37 GMT by grib_to_netcdf-2.25.1: /opt/ecmwf/mars-client/bin/grib_to_netcdf.bin -S param -o /cache/data6/adaptor.mars.internal-1674158060.3800251-17201-13-c46a8ac2-f1b6-4b57-a14e-801c001f7b2b.nc /cache/tmp/c46a8ac2-f1b6-4b57-a14e-801c001f7b2b-adaptor.mars.internal-1674158033.856014-17201-20-tmp.grib'}
通过将所有这些信息保存在源文件中,我们不会冒丢失信息或使描述与数据不同步的风险。
就像使用 pandas 一样,xarray 提供了许多不同的方法来选择要处理的数据部分。 我们展示了两个例子。 首先,我们专注于一个特定的位置,并使用线性图来查看时间内的总降水量:
`plt``.``figure``(``)`
`(``ds``.``sel``(``latitude``=``37.75``,` `longitude``=``237.5``)``.``tp` `*` `100``)``.``plot``(``figsize``=``(``8``,``3``)``)`
`plt``.``xlabel``(``'``'``)`
`plt``.``ylabel``(``'``Total precipitation (cm)``'``)`
`plt``.``show``(``)``;`
<Figure size 288x216 with 0 Axes>
接下来,我们选择一个日期,2022 年 12 月 31 日,下午 1 点,并将纬度和经度缩小到美国大陆范围内,以制作温度地图:
`import` `datetime`
`one_day` `=` `datetime``.``datetime``(``2022``,` `12``,` `31``,` `13``,` `0``,` `0``)`
`min_lon``,` `min_lat``,` `max_lon``,` `max_lat` `=` `232``,` `21``,` `300``,` `50`
`mask_lon` `=` `(``ds``.``longitude` `>` `min_lon``)` `&` `(``ds``.``longitude` `<` `max_lon``)`
`mask_lat` `=` `(``ds``.``latitude` `>` `min_lat``)` `&` `(``ds``.``latitude` `<` `max_lat``)`
`ds_oneday_us` `=` `ds``.``sel``(``time``=``one_day``)``.``t2m``.``where``(``mask_lon` `&` `mask_lat``,` `drop``=``True``)`
就像对于数据框的 loc 一样,sel 返回一个新的 DataArray,其数据由沿指定维度的索引标签确定,对于本例来说,即日期。 而且就像 np.where 一样,xr.where 根据提供的逻辑条件返回元素。 我们使用 drop=True 来减少数据集的大小。
让我们制作一个温度色彩地图,其中颜色代表温度:
`ds_oneday_us``.``plot``(``figsize``=``(``8``,``4``)``)`
从这张地图中,我们可以看出美国的形状、温暖的加勒比海和更冷的山脉。
我们通过关闭文件来结束:
`ds``.``close``(``)`
这个对 NetCDF 的简要介绍旨在介绍基本概念。 我们的主要目标是展示其他类型的数据格式存在,并且可以比纯文本读入数据框更具优势。 对于感兴趣的读者,NetCDF 拥有丰富的软件包和功能。 例如,除了 xarray 模块之外,NetCDF 文件还可以使用其他 Python 模块(如 netCDF4 和 gdal)进行读取。 NetCDF 社区还提供了与 NetCDF 数据交互的命令行工具。 制作可视化和地图的选项包括 matplotlib、iris(建立在 netCDF4 之上)和 cartopy。
接下来我们考虑 JSON 格式,它比 CSV 和 FWF 格式更灵活,可以表示分层数据。
JSON 数据
JavaScript 对象表示法(JSON)是在 web 上交换数据的流行格式。 这种纯文本格式具有简单灵活的语法,与 Python 字典非常匹配,易于机器解析和人类阅读。
简而言之,JSON 有两种主要结构,对象和数组:
对象
像 Python 的dict一样,JSON 对象是一个无序的名称-值对集合。这些对包含在大括号中;每个对都格式为"name":value,并用逗号分隔。
数组
像 Python 的list一样,JSON 数组是一个有序的值集合,包含在方括号中,其中值没有名称,并用逗号分隔。
对象和数组中的值可以是不同类型的,并且可以嵌套。也就是说,数组可以包含对象,反之亦然。原始类型仅限于双引号中的字符串,文本表示中的数字,true 或 false 作为逻辑,以及 null。
以下简短的 JSON 文件演示了所有这些语法特性:
{"lender_id":"matt",
"loan_count":23,
"status":[2, 1, 3],
"sponsored": false,
"sponsor_name": null,
"lender_dem":{"sex":"m","age":77 }
}
在这里,我们有一个包含六个名称-值对的对象。值是异构的;其中四个是原始值:字符串,数字,逻辑和 null。status值由三个(有序的)数字数组组成,而lender_dem是包含人口统计信息的对象。
内置的json包可用于在 Python 中处理 JSON 文件。例如,我们可以将这个小文件加载到 Python 字典中:
`import` `json`
`from` `pathlib` `import` `Path`
`file_path` `=` `Path``(``)` `/` `'``data``'` `/` `'``js_ex``'` `/` `'``ex.json``'`
`ex_dict` `=` `json``.``load``(``open``(``file_path``)``)`
`ex_dict`
{'lender_id': 'matt',
'loan_count': 23,
'status': [2, 1, 3],
'sponsored': False,
'sponsor_name': None,
'lender_dem': {'sex': 'm', 'age': 77}}
字典与 Kiva 文件的格式相匹配。这种格式并不自然地转换为数据框。json_normalize方法可以将这种半结构化的 JSON 数据组织成一个平面表:
`ex_df` `=` `pd``.``json_normalize``(``ex_dict``)`
`ex_df`
| lender_id | loan_count | status | sponsored | sponsor_name | lender_dem.sex | lender_dem.age | |
|---|---|---|---|---|---|---|---|
| 0 | matt | 23 | [2, 1, 3] | False | None | m | 77 |
注意,在这个单行数据框中,第三个元素是一个列表,而嵌套对象被转换为两列。
JSON 中数据结构的灵活性非常大,这意味着如果我们想要从 JSON 内容创建数据框,我们需要了解 JSON 文件中数据的组织方式。我们提供了三种结构,这些结构可以轻松地转换为数据框。
在第十二章中使用的 PurpleAir 站点列表是 JSON 格式的。在那一章中,我们没有注意到格式,只是使用json库的load方法将文件内容读入字典,然后转换为数据框。在这里,我们简化了该文件,同时保持了一般结构,以便更容易进行检查。
我们首先检查原始文件,然后将其重新组织成另外两种可能用于表示数据框的 JSON 结构。通过这些示例,我们旨在展示 JSON 的灵活性。 图 14-2 中的图表显示了这三种可能性的表示。
图 14-2. JSON 格式文件存储数据框的三种不同方法。
图表中最左侧的数据框按行组织。每一行都是具有命名值的对象,其中名称对应于数据框的列名。然后将行收集到数组中。这种结构与原始文件的结构相吻合。在下面的代码中,我们显示文件内容:
{"Header": [
{"status": "Success",
"request_time": "2022-12-29T01:48:30-05:00",
"url": "https://aqs.epa.gov/data/api/dailyData/...",
"rows": 4
}
],
"Data": [
{"site": "0014", "date": "02-27", "aqi": 30},
{"site": "0014", "date": "02-24", "aqi": 17},
{"site": "0014", "date": "02-21", "aqi": 60},
{"site": "0014", "date": "01-15", "aqi": null}
]
}
我们看到文件包含一个对象,有两个元素,名为Header和Data。Data元素是一个数组,每一行数据框中都有一个元素,正如前面描述的,每个元素都是一个对象。让我们将文件加载到字典中并检查其内容(详见第八章有关查找文件路径和打印内容的更多信息):
`from` `pathlib` `import` `Path`
`import` `os`
`epa_file_path` `=` `Path``(``'``data/js_ex/epa_row.json``'``)`
`data_row` `=` `json``.``loads``(``epa_file_path``.``read_text``(``)``)`
`data_row`
{'Header': [{'status': 'Success',
'request_time': '2022-12-29T01:48:30-05:00',
'url': 'https://aqs.epa.gov/data/api/dailyData/...',
'rows': 4}],
'Data': [{'site': '0014', 'date': '02-27', 'aqi': 30},
{'site': '0014', 'date': '02-24', 'aqi': 17},
{'site': '0014', 'date': '02-21', 'aqi': 60},
{'site': '0014', 'date': '01-15', 'aqi': None}]}
我们可以快速将对象数组转换为数据框,只需进行以下调用:
`pd``.``DataFrame``(``data_row``[``"``Data``"``]``)`
| 网站 | 日期 | 空气质量指数 | |
|---|---|---|---|
| 0 | 0014 | 02-27 | 30.0 |
| 1 | 0014 | 02-24 | 17.0 |
| 2 | 0014 | 02-21 | 60.0 |
| 3 | 0014 | 01-15 | NaN |
图中的中间图表在图 14-2 中采用了列的方法来组织数据。这里列被提供为数组,并收集到一个对象中,名称与数据框的列名相匹配。以下文件展示了该概念:
`epa_col_path` `=` `Path``(``'``data/js_ex/epa_col.json``'``)`
`print``(``epa_col_path``.``read_text``(``)``)`
{"site":[ "0014", "0014", "0014", "0014"],
"date":["02-27", "02-24", "02-21", "01-15"],
"aqi":[30,17,60,null]}
由于pd.read_json()期望这种格式,我们可以直接将文件读入数据框,而不需要先加载到字典中:
`pd``.``read_json``(``epa_col_path``)`
| 网站 | 日期 | 空气质量指数 | |
|---|---|---|---|
| 0 | 14 | 02-27 | 30.0 |
| 1 | 14 | 02-24 | 17.0 |
| 2 | 14 | 02-21 | 60.0 |
| 3 | 14 | 01-15 | NaN |
最后,我们将数据组织成类似矩阵的结构(图中右侧的图表),并分别为特征提供列名。数据矩阵被组织为一个数组的数组:
{'vars': ['site', 'date', 'aqi'],
'data': [['0014', '02-27', 30],
['0014', '02-24', 17],
['0014', '02-21', 60],
['0014', '01-15', None]]}
我们可以提供vars和data来创建数据框:
`pd``.``DataFrame``(``data_mat``[``"``data``"``]``,` `columns``=``data_mat``[``"``vars``"``]``)`
| 网站 | 日期 | 空气质量指数 | |
|---|---|---|---|
| 0 | 0014 | 02-27 | 30.0 |
| 1 | 0014 | 02-24 | 17.0 |
| 2 | 0014 | 02-21 | 60.0 |
| 3 | 0014 | 01-15 | NaN |
我们包含这些示例是为了展示 JSON 的多功能性。主要的收获是 JSON 文件可以以不同的方式排列数据,因此我们通常需要在成功将数据读入数据框之前检查文件。JSON 文件在存储在网络上的数据中非常常见:本节中的示例是从 PurpleAir 和 Kiva 网站下载的文件。尽管在本节中我们手动下载了数据,但我们经常希望一次下载多个数据文件,或者我们希望有一个可靠且可重现的下载记录。在下一节中,我们将介绍 HTTP,这是一个协议,让我们能够编写程序自动从网络上下载数据。
HTTP
HTTP(超文本传输协议)是访问网络资源的通用基础设施。互联网上提供了大量的数据集,通过 HTTP 我们可以获取这些数据集。
互联网允许计算机彼此通信,而 HTTP 则对通信进行结构化。 HTTP 是一种简单的请求-响应协议,其中客户端向服务器提交一个特殊格式的文本请求,服务器则返回一个特殊格式的文本响应。 客户端可以是 Web 浏览器或我们的 Python 会话。
HTTP 请求由两部分组成:头部和可选的正文。 头部必须遵循特定的语法。 请求获取在图 14-3 中显示的维基百科页面的示例如下所示:
GET /wiki/1500_metres_world_record_progression HTTP/1.1
Host: en.wikipedia.org
User-Agent: curl/7.65.2
Accept: */*
{blank_line}
第一行包含三个信息部分:以请求的方法开头,这是GET;其后是我们想要的网页的 URL;最后是协议和版本。 接下来的三行每行提供服务器的辅助信息。 这些信息的格式为名称: 值。 最后,空行标志着头部的结束。 请注意,在前面的片段中,我们用{blank_line}标记了空行;实际消息中,这是一个空行。
图 14-3. 维基百科页面截图,显示 1500 米赛跑的世界纪录数据
客户端的计算机通过互联网将此消息发送给维基百科服务器。 服务器处理请求并发送响应,响应也包括头部和正文。 响应的头部如下所示:
< HTTP/1.1 200 OK
< date: Fri, 24 Feb 2023 00:11:49 GMT
< server: mw1369.eqiad.wmnet
< x-content-type-options: nosniff
< content-language: en
< vary: Accept-Encoding,Cookie,Authorization
< last-modified: Tue, 21 Feb 2023 15:00:46 GMT
< content-type: text/html; charset=UTF-8
...
< content-length: 153912
{blank_line}
第一行声明请求成功完成;状态代码为 200。 接下来的行提供了客户端的额外信息。 我们大大缩短了这个头部,仅关注告诉我们正文内容为 HTML,使用 UTF-8 编码,并且内容长度为 153,912 个字符的几个信息。 最后,头部末尾的空行告诉客户端,服务器已经完成发送头部信息,响应正文随后而来。
几乎每个与互联网交互的应用程序都使用 HTTP。 例如,如果您在 Web 浏览器中访问相同的维基百科页面,浏览器会执行与刚刚显示的基本 HTTP 请求相同的操作。 当它接收到响应时,它会在浏览器窗口中显示正文,该正文看起来像图 14-3 中的屏幕截图。
在实践中,我们不会手动编写完整的 HTTP 请求。 相反,我们使用诸如requests Python 库之类的工具来为我们构建请求。 以下代码为我们构造了获取图 14-3 页面的 HTTP 请求。 我们只需将 URL 传递给requests.get。 名称中的“get”表示正在使用GET方法:
`import` `requests`
`url_1500` `=` `'``https://en.wikipedia.org/wiki/1500_metres_world_record_progression``'`
`resp_1500` `=` `requests``.``get``(``url_1500``)`
我们可以检查我们的请求状态,以确保服务器成功完成它:
`resp_1500``.``status_code`
200
我们可以通过对象的属性彻底检查请求和响应。 例如,让我们看一看我们请求的头部中的键值对:
`for` `key` `in` `resp_1500``.``request``.``headers``:`
`print``(``f``'``{``key``}``:` `{``resp_1500``.``request``.``headers``[``key``]``}``'``)`
User-Agent: python-requests/2.25.1
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
虽然我们在函数调用中没有指定任何头信息,但request.get为我们提供了一些基本信息。如果需要发送特殊的头信息,我们可以在调用中指定它们。
现在让我们来查看从服务器收到的响应头:
`len``(``resp_1500``.``headers``)`
20
正如我们之前看到的,响应中有大量的头信息。我们仅显示date、content-type和content-length:
`keys` `=` `[``'``date``'``,` `'``content-type``'``,` `'``content-length``'` `]`
`for` `key` `in` `keys``:`
`print``(``f``'``{``key``}``:` `{``resp_1500``.``headers``[``key``]``}``'``)`
date: Fri, 10 Mar 2023 01:54:13 GMT
content-type: text/html; charset=UTF-8
content-length: 23064
最后,我们显示响应体的前几百个字符(整个内容过长,无法在此完整显示):
`resp_1500``.``text``[``:``600``]`
'<!DOCTYPE html>\n<html class="client-nojs vector-feature-language-in-header-enabled vector-feature-language-in-main-page-header-disabled vector-feature-language-alert-in-sidebar-enabled vector-feature-sticky-header-disabled vector-feature-page-tools-disabled vector-feature-page-tools-pinned-disabled vector-feature-toc-pinned-enabled vector-feature-main-menu-pinned-disabled vector-feature-limited-width-enabled vector-feature-limited-width-content-enabled" lang="en" dir="ltr">\n<head>\n<meta charset="UTF-8"/>\n<title>1500 metres world record progression - Wikipedia</title>\n<script>document.documentE'
我们确认响应是一个 HTML 文档,并且包含标题1500 metres world record progression - Wikipedia。我们已成功获取了图 14-3 中显示的网页。
我们的 HTTP 请求已成功,服务器返回了状态码200。还有数百种其他 HTTP 状态码。幸运的是,它们被分组到不同的类别中,以便记忆(见表 14-1)。
表 14-1。响应状态码
| Code | Type | Description |
|---|---|---|
| 100s | 信息性 | 需要客户端或服务器进一步输入(100 Continue、102 Processing 等)。 |
| 200s | 成功 | 客户端请求成功(200 OK、202 Accepted 等)。 |
| 300s | 重定向 | 请求的 URL 位于其他位置,可能需要用户进一步操作(300 Multiple Choices、301 Moved Permanently 等)。 |
| 400s | 客户端错误 | 发生了客户端错误(400 Bad Request、403 Forbidden、404 Not Found 等)。 |
| 500s | 服务器错误 | 发生了服务器端错误或服务器无法执行请求(500 Internal Server Error、503 Service Unavailable 等)。 |
一个常见的错误代码可能看起来很熟悉,即 404,表示我们请求的资源不存在。我们在这里发送这样的请求:
`url` `=` `"``https://www.youtube.com/404errorwow``"`
`bad_loc` `=` `requests``.``get``(``url``)`
`bad_loc``.``status_code`
404
我们发出的请求是用GET HTTP 请求获取网页。有四种主要的 HTTP 请求类型:GET、POST、PUT和DELETE。最常用的两种方法是GET和POST。我们刚刚使用GET来获取网页:
`resp_1500``.``request``.``method`
'GET'
POST请求用于将特定信息从客户端发送到服务器。在下一节中,我们将使用POST来从 Spotify 获取数据。
REST
网络服务越来越多地采用 REST(表述性状态转移)架构,供开发人员访问其数据。这些包括像 Twitter 和 Instagram 这样的社交媒体平台,像 Spotify 这样的音乐应用,像 Zillow 这样的房地产应用,像气候数据存储这样的科学数据源,以及世界银行的政府数据等等。REST 背后的基本思想是,每个 URL 标识一个资源(数据)。
REST 是无状态的,意味着服务器不会在连续的请求中记住客户端的状态。REST 的这一方面具有一些优势:服务器和客户端可以理解任何收到的消息,不必查看先前的消息;可以在客户端或服务器端更改代码而不影响服务的操作;访问是可伸缩的、快速的、模块化的和独立的。
在本节中,我们将通过一个示例来从 Spotify 获取数据。
我们的示例遵循Steven Morse 的博客文章,我们在一系列请求中使用POST和GET方法来检索The Clash的歌曲数据。
注意
在实践中,我们不会自己为 Spotify 编写GET和POST请求。相反,我们会使用spotipy库,该库具有与Spotify web API交互的功能。尽管如此,数据科学家通常会发现自己想要访问的数据只能通过 REST 获得,而没有 Python 库可用。因此,本节展示了如何从类似 Spotify 的 RESTful 网站获取数据。
通常,REST 应用程序会提供带有如何请求其数据的示例的文档。Spotify 提供了针对想要构建应用程序的开发者的广泛文档,但我们也可以仅仅用来探索数据访问服务。为此,我们需要注册开发者帐号并获取客户端 ID 和密钥,然后在我们的 HTTP 请求中使用它们来识别自己给 Spotify。
注册后,我们可以开始请求数据。此过程分两步:认证和请求资源。
要进行身份验证,我们发出一个 POST 请求,将我们的客户端 ID 和密钥提供给 Web 服务。我们在请求的标头中提供这些信息。作为回报,我们从服务器接收到一个授权我们进行请求的令牌。
我们开始流程并进行身份验证:
`AUTH_URL` `=` `'``https://accounts.spotify.com/api/token``'`
`import` `requests`
`auth_response` `=` `requests``.``post``(``AUTH_URL``,` `{`
`'``grant_type``'``:` `'``client_credentials``'``,`
`'``client_id``'``:` `CLIENT_ID``,`
`'``client_secret``'``:` `CLIENT_SECRET``,`
`}``)`
我们在 POST 请求的标头中以键值对的形式提供了我们的 ID 和密钥。我们可以检查请求的状态以查看是否成功:
`auth_response``.``status_code`
200
现在让我们检查响应体中的内容类型:
`auth_response``.``headers``[``'``content-type``'``]`
'application/json'
响应体包含我们需要在下一步获取数据时使用的令牌。由于此信息格式为 JSON,我们可以检查键并检索令牌:
`auth_response_data` `=` `auth_response``.``json``(``)`
`auth_response_data``.``keys``(``)`
dict_keys(['access_token', 'token_type', 'expires_in'])
`access_token` `=` `auth_response_data``[``'``access_token``'``]`
`token_type` `=` `auth_response_data``[``'``token_type``'``]`
请注意,我们隐藏了我们的 ID 和密钥,以防其他人模仿我们。没有有效的 ID 和密钥,此请求将无法成功。例如,在这里,我们编造了一个 ID 和密钥并尝试进行身份验证:
`bad_ID` `=` `'``0123456789``'`
`bad_SECRET` `=` `'``a1b2c3d4e5``'`
`auth_bad` `=` `requests``.``post``(``AUTH_URL``,` `{`
`'``grant_type``'``:` `'``client_credentials``'``,`
`'``client_id``'``:` `bad_ID``,` `'``client_secret``'``:` `bad_SECRET``,`
`}``)`
我们检查此“坏”请求的状态:
`auth_bad``.``status_code`
400
根据表 14-1,400 码表示我们发出了一个错误请求。作为一个例子,如果我们花费太多时间进行请求,Spotify 会关闭我们。在撰写本节时,我们遇到了这个问题几次,并收到了以下代码,告诉我们我们的令牌已过期:
res_clash.status_code
401
现在进行第二步,让我们获取一些数据。
对 Spotify 的资源可以通过 GET 进行请求。其他服务可能需要 POST。请求必须包括我们从 web 服务认证时收到的令牌,我们可以一次又一次地使用。我们将访问令牌传递到我们的 GET 请求的头部。我们将名称-值对构造为字典:
`headers` `=` `{``"``Authorization``"``:` `f``"``{``token_type``}` `{``access_token``}``"``}`
开发者 API 告诉我们,艺术家的专辑可在类似于 api.spotify.com/v1/artists/… 的 URL 上找到,其中 artists/ 和 /albums 之间的代码是艺术家的 ID。这个特定的代码是 The Clash 的。有关专辑上音轨的信息可在类似于 api.spotify.com/v1/albums/4… 的 URL 上找到,这里的标识符是专辑的。
如果我们知道艺术家的 ID,我们可以检索其专辑的 ID,进而可以获取关于专辑上音轨的数据。我们的第一步是从 Spotify 的网站获取 The Clash 的 ID:
`artist_id` `=` `'``3RGLhK1IP9jnYFH4BRFJBS``'`
我们的第一个数据请求检索了组的专辑。我们使用 artist_id 构建 URL,并在头部传递我们的访问令牌:
`BASE_URL` `=` `"``https://api.spotify.com/v1/``"`
`res_clash` `=` `requests``.``get``(`
`BASE_URL` `+` `"``artists/``"` `+` `artist_id` `+` `"``/albums``"``,`
`headers``=``headers``,`
`params``=``{``"``include_groups``"``:` `"``album``"``}``,`
`)`
`res_clash``.``status_code`
200
我们的请求成功了。现在让我们检查响应主体的content-type:
`res_clash``.``headers``[``'``content-type``'``]`
'application/json; charset=utf-8'
返回的资源是 JSON,因此我们可以将其加载到 Python 字典中:
`clash_albums` `=` `res_clash``.``json``(``)`
经过一番搜索,我们可以发现专辑信息在 items 元素中。第一个专辑的键是:
`clash_albums``[``'``items``'``]``[``0``]``.``keys``(``)`
dict_keys(['album_group', 'album_type', 'artists', 'available_markets', 'external_urls', 'href', 'id', 'images', 'name', 'release_date', 'release_date_precision', 'total_tracks', 'type', 'uri'])
让我们打印几个专辑的专辑 ID、名称和发行日期:
`for` `album` `in` `clash_albums``[``'``items``'``]``[``:``4``]``:`
`print``(``'``ID:` `'``,` `album``[``'``id``'``]``,` `'` `'``,` `album``[``'``name``'``]``,` `'``----``'``,` `album``[``'``release_date``'``]``)`
ID: 7nL9UERtRQCB5eWEQCINsh Combat Rock + The People's Hall ---- 2022-05-20
ID: 3un5bLdxz0zKhiZXlmnxWE Live At Shea Stadium ---- 2008-08-26
ID: 4dMWTj1OkiCKFN5yBMP1vS Live at Shea Stadium (Remastered) ---- 2008
ID: 1Au9637RH9pXjBv5uS3JpQ From Here To Eternity Live ---- 1999-10-04
我们看到一些专辑是重新混音的,而另一些是现场演出。接下来,我们循环遍历专辑,获取它们的 ID,并为每个专辑请求有关音轨的信息:
`tracks` `=` `[``]`
`for` `album` `in` `clash_albums``[``'``items``'``]``:`
`tracks_url` `=` `f``"``{``BASE_URL``}``albums/``{``album``[``'``id``'``]``}``/tracks``"`
`res_tracks` `=` `requests``.``get``(``tracks_url``,` `headers``=``headers``)`
`album_tracks` `=` `res_tracks``.``json``(``)``[``'``items``'``]`
`for` `track` `in` `album_tracks``:`
`features_url` `=` `f``"``{``BASE_URL``}``audio-features/``{``track``[``'``id``'``]``}``"`
`res_feat` `=` `requests``.``get``(``features_url``,` `headers``=``headers``)`
`features` `=` `res_feat``.``json``(``)`
`features``.``update``(``{`
`'``track_name``'``:` `track``.``get``(``'``name``'``)``,`
`'``album_name``'``:` `album``[``'``name``'``]``,`
`'``release_date``'``:` `album``[``'``release_date``'``]``,`
`'``album_id``'``:` `album``[``'``id``'``]`
`}``)`
`tracks``.``append``(``features``)`
在这些音轨上有超过十几个功能可供探索。让我们以绘制 The Clash 歌曲的舞蹈性和响度为例结束本示例:
本节介绍了 REST API,它提供了程序下载数据的标准化方法。这里展示的示例下载了 JSON 数据。其他时候,来自 REST 请求的数据可能是 XML 格式的。有时我们想要的数据没有 REST API 可用,我们必须从 HTML 中提取数据,这是一种与 XML 类似的格式。接下来我们将描述如何处理这些格式。
XML、HTML 和 XPath
可扩展标记语言(XML)可以表示各种类型的信息,例如发送到和从 Web 服务传送的数据,包括网页、电子表格、SVG 等可视显示、社交网络结构、像微软的 docx 这样的文字处理文档、数据库等等。对于数据科学家来说,了解 XML 会有所帮助。
尽管它的名称是 XML,但它不是一种语言。相反,它是一个非常通用的结构,我们可以用它来定义表示和组织数据的格式。XML 提供了这些“方言”或词汇表的基本结构和语法。如果你读过或撰写过 HTML,你会认出 XML 的格式。
XML 的基本单位是元素,也被称为节点。一个元素有一个名称,可以有属性、子元素和文本。
下面标注的 XML 植物目录片段提供了这些部分的示例(此内容改编自W3Schools):
<catalog> The topmost node, aka root node.
<plant> The first child of the root node.
<common>Bloodroot</common> common is the first child of plant.
<botanical>Sanguinaria canadensis</botanical>
<zone>4</zone> This zone node has text content: 4.
<light>Mostly Shady</light>
<price curr="USD">$2.44</price> This node has an attribute.
<availability date="0399"/> Empty nodes can be collapsed.
</plant> Nodes must be closed.
<plant> The two plant nodes are siblings.
<common>Columbine</common>
<botanical>Aquilegia canadensis</botanical>
<zone>3</zone>
<light>Mostly Shady</light>
<price curr="CAD">$9.37</price>
<availability date="0199"/>
</plant>
</catalog>
我们为此 XML 片段添加了缩进以便更容易看到结构。实际文件中不需要缩进。
XML 文档是纯文本文件,具有以下语法规则:
-
每个元素都以开始标签开始,例如
<plant>,并以相同名称的结束标签关闭,例如</plant>。 -
XML 元素可以包含其他 XML 元素。
-
XML 元素可以是纯文本,例如
<common>Columbine</common>中的“Columbine”。 -
XML 元素可以具有可选的属性。元素
<price curr="CAD">具有属性curr,其值为"CAD"。 -
特殊情况下,当节点没有子节点时,结束标签可以折叠到开始标签中。例如
<availability date="0199"/>。
当它遵循特定规则时,我们称 XML 文档为良好格式的文档。其中最重要的规则是:
-
一个根节点包含文档中的所有其他元素。
-
元素正确嵌套;开放节点在其所有子节点周围关闭,不再多余。
-
标签名称区分大小写。
-
属性值采用
name=“value”格式,可以使用单引号或双引号。
有关文档为良好格式的其他规则。这些与空白、特殊字符、命名约定和重复属性有关。
XML 的分层结构使其可以表示为树形结构。图 14-4 展示了植物目录 XML 的树形表示。
图 14-4. XML 文档的层次结构;浅灰色框表示文本元素,按设计,这些元素不能有子节点。
与 JSON 类似,XML 文档是纯文本。我们可以用纯文本查看器来读取它,对于机器来说读取和创建 XML 内容也很容易。XML 的可扩展性允许内容轻松合并到更高级别的容器文档中,并且可以轻松地与其他应用程序交换。XML 还支持二进制数据和任意字符集。
如前所述,HTML 看起来很像 XML。这不是偶然的,事实上,XHTML 是 HTML 的子集,遵循良好格式 XML 的规则。让我们回到之前从互联网上检索的维基百科页面的例子,并展示如何使用 XML 工具从其表格内容创建数据框架。
示例:从维基百科抓取赛时
在本章的早些时候,我们使用了一个 HTTP 请求从维基百科检索了 HTML 页面,如图 14-3 所示。这个页面的内容是 HTML 格式的,本质上是 XML 词汇。我们可以利用页面的分层结构和 XML 工具来访问其中一个表格中的数据,并将其整理成数据框。特别是,我们对页面中的第二个表格感兴趣,其中的一部分显示在图 14-5 的截图中。
图 14-5. 网页中包含我们想要提取的数据的第二个表格的截图
在我们处理这个表格之前,我们先快速总结一下基本 HTML 表格的格式。这是一个带有表头和两行三列的表格的 HTML 格式:
<table>
<tbody>
<tr>
<th>A</th><th>B</th><th>C</th>
</tr>
<tr>
<td>1</td><td>2</td><td>3</td>
</tr>
<tr>
<td>5</td><td>6</td><td>7</td>
</tr>
</tbody>
</table>
注意表格是如何以<tr>元素为行布局的,每行中的每个单元格是包含在<td>元素中的文本,用于在表格中显示。
我们的第一个任务是从网页内容中创建一个树结构。为此,我们使用lxml库,它提供了访问 C 库libxml2来处理 XML 内容的功能。回想一下,resp_1500包含了我们请求的响应,页面位于响应体中。我们可以使用lxml.html模块中的fromstring方法将网页解析为一个分层结构:
`from` `lxml` `import` `html`
`tree_1500` `=` `html``.``fromstring``(``resp_1500``.``content``)`
`type``(``tree_1500``)`
lxml.html.HtmlElement
现在我们可以使用文档的树结构来处理文档。我们可以通过以下搜索找到 HTML 文档中的所有表格:
`tables` `=` `tree_1500``.``xpath``(``'``//table``'``)`
`type``(``tables``)`
list
`len``(``tables``)`
7
这个搜索使用了 XPath //table 表达式,在文档中的任何位置搜索所有表格节点。
我们在文档中找到了六个表格。如果我们检查网页,包括通过浏览器查看其 HTML 源代码,我们可以发现文档中的第二个表格包含 IAF 时代的时间。这是我们想要的表格。图 14-5 中的截图显示,第一列包含比赛时间,第三列包含名称,第四列包含比赛日期。我们可以依次提取这些信息。我们使用以下 XPath 表达式完成这些操作:
`times` `=` `tree_1500``.``xpath``(``'``//table[3]/tbody/tr/td[1]/b/text()``'``)`
`names` `=` `tree_1500``.``xpath``(``'``//table[3]/tbody/tr/td[3]/a/text()``'``)`
`dates` `=` `tree_1500``.``xpath``(``'``//table[3]/tbody/tr/td[4]/text()``'``)`
`type``(``times``[``0``]``)`
lxml.etree._ElementUnicodeResult
这些返回值的行为类似于列表,但每个值都是树的元素。我们可以将它们转换为字符串:
`date_str` `=` `[``str``(``s``)` `for` `s` `in` `dates``]`
`name_str` `=` `[``str``(``s``)` `for` `s` `in` `names``]`
对于时间,我们希望将其转换为秒。函数get_sec可以完成这个转换。而我们希望从日期字符串中提取比赛年份:
`def` `get_sec``(``time``)``:`
`"""convert time into seconds."""`
`time` `=` `str``(``time``)`
`time` `=` `time``.``replace``(``"``+``"``,``"``"``)`
`m``,` `s` `=` `time``.``split``(``'``:``'``)`
`return` `float``(``m``)` `*` `60` `+` `float``(``s``)`
`time_sec` `=` `[``get_sec``(``rt``)` `for` `rt` `in` `times``]`
`race_year` `=` `pd``.``to_datetime``(``date_str``,` `format``=``'``%Y``-``%m``-``%d``\n``'``)``.``year`
我们可以创建一个数据框并绘制图表,以展示比赛时间随年份的变化情况:
正如你可能已经注意到的那样,从 HTML 页面中提取数据需要仔细检查源代码,找到我们需要的数字在文档中的位置。我们大量使用 XPath 工具进行提取。它的语言优雅而强大。我们接下来介绍它。
XPath
当我们处理 XML 文档时,通常希望从中提取数据并将其带入数据框中。XPath 可以在这方面提供帮助。XPath 可以递归地遍历 XML 树以查找元素。例如,在前面的示例中,我们使用表达式 //table 定位网页中所有表格节点。
XPath 表达式作用于良构 XML 的层次结构。它们简洁并且格式类似于计算机文件系统中目录层次结构中定位文件的方式。但它们更加强大。XPath 与正则表达式类似,我们指定要匹配内容的模式。与正则表达式一样,撰写正确的 XPath 表达式需要经验。
XPath 表达式形成逻辑步骤,用于识别和过滤树中的节点。结果是一个节点集,其中每个节点最多出现一次。节点集也具有与源中节点出现顺序匹配的顺序;这一点非常方便。
每个 XPath 表达式由一个或多个位置步骤组成,用“/”分隔。每个位置步骤有三个部分——轴、节点测试和可选的谓词:
-
轴指定查找的方向,例如向下、向上或横向。我们专门使用轴的快捷方式。默认是向下一步查找树中的子节点。
//表示尽可能向下查找整个树,..表示向上一步到父节点。 -
节点测试标识要查找的节点的名称或类型。通常只是标签名或者对于文本元素是
text()。 -
谓词像过滤器一样作用于进一步限制节点集。这些谓词以方括号表示,例如
[2],保留节点集中的第二个节点,以及[ @date ],保留具有日期属性的所有节点。
我们可以将位置步骤连接在一起,以创建强大的搜索指令。表 14-2 提供了一些涵盖最常见表达式的示例。请参考 图 14-4 中的树进行跟踪。
表 14-2. XPath 示例
| 表达式 | 结果 | 描述 |
|---|---|---|
| ‘//common’ | Two nodes | 在树中向下查找任何共同节点。 |
| ‘/catalog/plant/common’ | Two nodes | 从根节点 catalog 沿特定路径向所有植物节点遍历,并在植物节点中的所有共同节点中查找。 |
| ‘//common/text()’ | Bloodroot, Columbine | 定位所有共同节点的文本内容。 |
| ‘//plant[2]/price/text()’ | $9.37 | 在树的任何位置定位植物节点,然后过滤并仅获取第二个节点。从此植物节点进入其价格子节点并定位其文本。 |
| ‘//@date’ | 0399, 0199 | 定位树中任何名为“date”的属性值。 |
| ‘//price[@curr=“CAD”]/text()’ | $9.37 | 具有货币属性值“CAD”的任何价格节点的文本内容。 |
您可以在目录文件中的表中尝试 XPath 表达式。我们使用etree模块将文件加载到 Python 中。parse方法读取文件到一个元素树中。
`from` `lxml` `import` `etree`
`catalog` `=` `etree``.``parse``(``'``data/catalog.xml``'``)`
lxml库让我们能够访问 XPath。让我们试试吧。
这个简单的 XPath 表达式定位树中任何<light>节点的所有文本内容:
`catalog``.``xpath``(``'``//light/text()``'``)`
['Mostly Shady', 'Mostly Shady']
注意返回了两个元素。虽然文本内容相同,但我们的树中有两个<light>节点,因此返回了每个节点的文本内容。以下表达式稍微有些复杂:
`catalog``.``xpath``(``'``//price[@curr=``"``CAD``"``]/../common/text()``'``)`
['Columbine']
该表达式定位树中所有<price>节点,然后根据它们的curr属性是否为CAD进行过滤。然后,对剩余节点(在本例中只有一个)在树中向上移动一步至父节点,然后返回到任何子“common”节点,并获取其文本内容。非常复杂的过程!
接下来,我们提供一个示例,使用 HTTP 请求检索 XML 格式的数据,并使用 XPath 将内容整理成数据框。
示例:访问 ECB 的汇率
欧洲央行(ECB)提供了在线 XML 格式的汇率信息。让我们通过 HTTP 请求获取 ECB 的最新汇率:
`url_base` `=` `'``https://www.ecb.europa.eu/stats/eurofxref/``'`
`url2` `=` `'``eurofxref-hist-90d.xml?d574942462c9e687c3235ce020466aae``'`
`resECB` `=` `requests``.``get``(``url_base``+``url2``)`
`resECB``.``status_code`
200
同样地,我们可以使用lxml库解析从 ECB 接收到的文本文档,但这次内容是从 ECB 返回的字符串,而不是文件:
`ecb_tree` `=` `etree``.``fromstring``(``resECB``.``content``)`
为了提取我们想要的数据,我们需要了解它的组织方式。这是内容的一部分片段:
<gesmes:Envelope xmlns:gesmes="http://www.gesmes.org/xml/2002-08-01"
xmlns="http://www.ecb.int/vocabulary/2002-08-01/eurofxref">
<gesmes:subject>Reference rates</gesmes:subject>
<gesmes:Sender>
<gesmes:name>European Central Bank</gesmes:name>
</gesmes:Sender>
<Cube>
<Cube time="2023-02-24">
<Cube currency="USD" rate="1.057"/>
<Cube currency="JPY" rate="143.55"/>
<Cube currency="BGN" rate="1.9558"/>
</Cube>
<Cube time="2023-02-23">
<Cube currency="USD" rate="1.0616"/>
<Cube currency="JPY" rate="143.32"/>
<Cube currency="BGN" rate="1.9558"/>
</Cube>
</Cube>
</gesmes:Envelope>
这份文档在结构上与植物目录有很大不同。代码片段展示了三个层次的标签,它们都有相同的名称,且没有文本内容。所有相关信息都包含在属性值中。根<Envelope>节点中有xmlns和gesmes:Envelope等奇怪的标签名,这些与命名空间有关。
XML 允许内容创建者使用自己的词汇,称为命名空间。命名空间为词汇提供规则,例如允许的标签名和属性名,以及节点嵌套的限制。XML 文档可以合并来自不同应用程序的词汇。为了保持一致,文档中提供了有关命名空间的信息。
ECB 文件的根节点是<Envelope>。标签名中的额外gesmes:表示这些标签属于 gesmes 词汇,这是一个用于时间序列信息交换的国际标准。<Envelope>中还有另一个命名空间。它是文件的默认命名空间,因为它没有像“gesmes:”那样的前缀。如果在标签名中未提供命名空间,则默认为此命名空间。
这意味着我们需要在搜索节点时考虑这些命名空间。让我们看看在提取日期时的运作方式。从片段中,我们看到日期存储在“time”属性中。这些 <Cube> 是顶层 <Cube> 的子节点。我们可以给出一个非常具体的 XPath 表达式,从根节点步进到其 <Cube> 子节点,然后进入下一级的 <Cube> 节点:
`namespaceURI` `=` `'``http://www.ecb.int/vocabulary/2002-08-01/eurofxref``'`
`date` `=` `ecb_tree``.``xpath``(``'``./x:Cube/x:Cube/@time``'``,` `namespaces` `=` `{``'``x``'``:``namespaceURI``}``)`
`date``[``:``5``]`
['2023-07-18', '2023-07-17', '2023-07-14', '2023-07-13', '2023-07-12']
表达式中的 . 是一个快捷方式,表示“从这里”,因为我们位于树的顶部,它相当于“从根节点”。我们在表达式中指定了命名空间为“x:”。尽管 <Cube> 节点使用了默认命名空间,但我们必须在 XPath 表达式中指定它。幸运的是,我们可以简单地将命名空间作为参数传递,并用我们自己的标签(在这种情况下是“x”)来保持标记名称的简短性。
与 HTML 表格类似,我们可以将日期值转换为字符串,再从字符串转换为时间戳:
`date_str` `=` `[``str``(``s``)` `for` `s` `in` `date``]`
`timestamps` `=` `pd``.``to_datetime``(``date_str``)`
`xrates` `=` `pd``.``DataFrame``(``{``"``date``"``:``timestamps``}``)`
至于汇率,它们也出现在 <Cube> 节点中,但这些节点有一个“rate”属性。例如,我们可以使用以下 XPath 表达式访问所有英镑的汇率(目前我们忽略命名空间):
//Cube[@currency = "GBP"]/@rate
这个表达式表示在文档中的任何位置查找所有 <Cube> 节点,根据节点是否具有货币属性值“GBP”进行过滤,并返回它们的汇率属性值。
由于我们想要提取多种货币的汇率,我们对这个 XPath 表达式进行了泛化。我们还想将汇率转换为数字存储类型,并使它们相对于第一天的汇率,以便不同的货币处于相同的比例尺上,这样更适合绘图:
`currs` `=` `[``'``GBP``'``,` `'``USD``'``,` `'``CAD``'``]`
`for` `ctry` `in` `currs``:`
`expr` `=` `'``.//x:Cube[@currency =` `"``'` `+` `ctry` `+` `'``"``]/@rate``'`
`rates` `=` `ecb_tree``.``xpath``(``expr``,` `namespaces` `=` `{``'``x``'``:``namespaceURI``}``)`
`rates_num` `=` `[``float``(``rate``)` `for` `rate` `in` `rates``]`
`first` `=` `rates_num``[``len``(``rates_num``)``-``1``]`
`xrates``[``ctry``]` `=` `[``rate` `/` `first` `for` `rate` `in` `rates_num``]`
我们以汇率的折线图作为这个示例的结束。
结合对 JSON、HTTP、REST 和 HTML 的知识,我们可以访问网上可用的大量数据。例如,在本节中,我们编写了从维基百科页面抓取数据的代码。这种方法的一个关键优势是我们可以在几个月后重新运行此代码,自动更新数据和图表。一个关键缺点是我们的方法与网页结构紧密耦合——如果有人更新了维基百科页面,而表格不再是页面上的第二个表格,我们的代码也需要一些修改才能工作。尽管如此,掌握从网页抓取数据的技能打开了广泛数据的大门,使各种有用的分析成为可能。
摘要
互联网上存储和交换的数据种类繁多。在本章中,我们的目标是让您领略到可用格式的多样性,并基本理解如何从在线来源和服务获取数据。我们还解决了以可重复的方式获取数据的重要目标。与其从网页复制粘贴或手工填写表单,我们演示了如何编写代码来获取数据。这些代码为您的工作流程和数据来源提供了记录。
每种介绍的格式,我们都描述了其结构模型。对数据集组织的基本理解有助于您发现质量问题,读取源文件中的错误,以及最佳处理和分析数据的方法。从长远来看,随着您继续发展数据科学技能,您将接触到其他形式的数据交换,我们期待这种考虑组织模型并通过一些简单案例动手实践的方法能为您服务良好。
我们仅仅触及了网络服务的表面。还有许多其他有用的主题,比如在发出多个请求或批量检索数据时保持与服务器的连接活动,使用 Cookie 和进行多个连接。但是理解此处介绍的基础知识可以让您走得更远。例如,如果您使用一个库从 API 获取数据但遇到错误,可以查看 HTTP 请求来调试代码。当新的网络服务上线时,您也会知道可能性。
网络礼仪是我们必须提及的一个话题。如果您计划从网站抓取数据,最好检查您是否有权限这样做。当我们注册成为 Web 应用的客户时,通常会勾选同意服务条款的框。
如果您使用网络服务或抓取网页,请注意不要过度请求网站。如果网站提供了类似 CSV、JSON 或 XML 格式的数据版本,最好下载并使用这些数据,而不是从网页抓取。同样,如果有一个 Python 库提供对 Web 应用的结构化访问,请使用它而不是编写自己的代码。在发送请求时,先从小处开始测试您的代码,并考虑保存结果,以免不必要地重复请求。
本章的目标不是使您成为这些特定数据格式的专家。相反,我们希望为您提供学习更多关于数据格式所需的信心,评估不同格式的优缺点,并参与可能使用您之前未见过的格式的项目。
现在您已经有了使用不同数据格式的经验,我们将回到我们在第四章中引入的建模主题,认真地继续讨论。