数据科学学习指南(二)
原文:
zh.annas-archive.org/md5/9950f82165bee426119f99afc3ab612d译者:飞龙
第七章:使用 SQL 处理关系
在第六章中,我们使用数据框表示数据表。本章介绍了关系,另一种广泛使用的表示数据表的方式。我们还介绍了 SQL,这是处理关系的标准编程语言。以下是一个关于流行狗品种信息的关系示例。
像数据框一样,关系中的每一行表示一个单狗品种记录。每一列表示记录的一个特征,例如,grooming列表示每个狗品种需要多频繁地梳理。
关系和数据框都为表中的每一列都有标签。但是,一个关键区别在于关系中的行没有标签,而数据框中的行有。
本章中,我们演示使用 SQL 进行常见的关系操作。我们首先解释 SQL 查询的结构。然后展示如何使用 SQL 执行常见的数据操作任务,如切片、过滤、排序、分组和连接。
注意
本章复制了第六章中的数据分析,但使用的是关系和 SQL,而不是数据框和 Python。两章的数据集、数据操作和结论几乎相同,以便于在使用pandas和 SQL 执行数据操作时进行比较。
子集化
要使用关系,我们将介绍一种称为SQL(Structured Query Language)的领域特定编程语言。我们通常将“SQL”发音为“sequel”,而不是拼写首字母缩略词。SQL 是一种专门用于处理关系的语言,因此,与 Python 在操作关系数据时相比,SQL 具有不同的语法。
在本章中,我们将在 Python 程序中使用 SQL 查询。这展示了一个常见的工作流程——数据科学家经常在 SQL 中处理和子集化数据,然后将数据加载到 Python 中进行进一步分析。与pandas程序相比,SQL 数据库使处理大量数据变得更加容易。但是,将数据加载到pandas中使得可视化数据和构建统计模型变得更加容易。
注意
为什么 SQL 系统往往更适合处理大型数据集?简而言之,SQL 系统具有用于管理存储在磁盘上的数据的复杂算法。例如,当处理大型数据集时,SQL 系统会透明地一次加载和操作小部分数据;相比之下,在pandas中做到这一点可能会更加困难。我们将在第八章中更详细地讨论这个主题。
SQL 基础知识:SELECT 和 FROM
我们将使用pd.read_sql函数运行 SQL 查询,并将输出存储在pandas数据框中。使用此函数需要一些设置。我们首先导入pandas和sqlalchemy Python 包:
`import` `pandas` `as` `pd`
`import` `sqlalchemy`
我们的数据库存储在名为babynames.db的文件中。这个文件是一个SQLite数据库,因此我们将设置一个可以处理这种格式的sqlalchemy对象:
`db` `=` `sqlalchemy``.``create_engine``(``'``sqlite:///babynames.db``'``)`
注
在本书中,我们使用 SQLite,这是一个非常有用的本地数据存储数据库系统。其他系统做出了不同的权衡,适用于不同的领域。例如,PostgreSQL 和 MySQL 是更复杂的系统,适用于大型 Web 应用程序,在这些应用程序中,许多最终用户同时写入数据。虽然每个 SQL 系统有细微的差异,但它们提供相同的核心 SQL 功能。读者可能还知道 Python 在其标准 sqlite3 库中提供了对 SQLite 的支持。我们选择使用 sqlalchemy 是因为它更容易重用代码,以适用于 SQLite 之外的其他 SQL 系统。
现在我们可以使用 pd.read_sql 在这个数据库上运行 SQL 查询。这个数据库有两个关系:baby 和 nyt。这是一个读取整个 baby 关系的简单示例。我们将 SQL 查询作为 Python 字符串编写,并传递给 pd.read_sql:
`query` `=` `'''`
`SELECT *`
`FROM baby;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | Sex | Count | Year | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 2020719 | Verona | F | 5 | 1880 |
| 2020720 | Vertie | F | 5 | 1880 |
| 2020721 | Wilma | F | 5 | 1880 |
2020722 rows × 4 columns
变量 query 内的文本包含 SQL 代码。SELECT 和 FROM 是 SQL 关键字。我们读取前述查询如下:
SELECT * -- Get all the columns...
FROM baby; -- ...from the baby relation
baby 关系包含与 第六章 中 baby 数据帧相同的数据:所有由美国社会安全管理局注册的婴儿姓名。
什么是关系?
让我们更详细地检查 baby 关系。一个关系有行和列。每一列都有一个标签,如 Figure 7-1 所示。不像数据帧,然而,关系中的个别行没有标签。也不像数据帧,关系的行不是有序的。
图 7-1. baby 关系具有列的标签(用框框起来)
关系有着悠久的历史。对关系的更正式处理使用术语 元组 来指代关系的行,属性 来指代列。还有一种严格的方式使用关系代数来定义数据操作,它源自数学集合代数。
切片
切片 是通过从另一个关系中取出部分行或列来创建新关系的操作。想象切番茄——切片可以垂直和水平进行。要对关系的列进行切片,我们给 SELECT 语句传递我们想要的列:
`query` `=` `'''`
`SELECT Name`
`FROM baby;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | |
|---|---|
| 0 | Liam |
| 1 | Noah |
| 2 | Oliver |
| ... | ... |
| 2020719 | Verona |
| 2020720 | Vertie |
| 2020721 | Wilma |
2020722 rows × 1 columns
`query` `=` `'''`
`SELECT Name, Count`
`FROM baby;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | Count | |
|---|---|---|
| 0 | Liam | 19659 |
| 1 | Noah | 18252 |
| 2 | Oliver | 14147 |
| ... | ... | ... |
| 2020719 | Verona | 5 |
| 2020720 | Vertie | 5 |
| 2020721 | Wilma | 5 |
2020722 rows × 2 columns
要切片出特定数量的行,请使用 LIMIT 关键字:
`query` `=` `'''`
`SELECT Name`
`FROM baby`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | |
|---|---|
| 0 | Liam |
| 1 | Noah |
| 2 | Oliver |
| ... | ... |
| 7 | Lucas |
| 8 | Henry |
| 9 | Alexander |
10 rows × 1 columns
总之,我们使用 SELECT 和 LIMIT 关键字来切片关系的列和行。
过滤行
现在我们转向过滤行—使用一个或多个条件取子集的行。在 pandas 中,我们使用布尔系列对象切片数据帧。在 SQL 中,我们使用带有谓词的 WHERE 关键字。以下查询将 baby 关系过滤为仅包含 2020 年的婴儿姓名:
`query` `=` `'''`
`SELECT *`
`FROM baby`
`WHERE Year = 2020;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 31267 | Zylynn | F | 5 | 2020 |
| 31268 | Zynique | F | 5 | 2020 |
| 31269 | Zynlee | F | 5 | 2020 |
31270 rows × 4 columns
警告
请注意,在比较相等性时,SQL 使用单等号:
SELECT *
FROM baby
WHERE Year = 2020;
-- ↑
-- Single equals sign
然而,在 Python 中,单等号用于变量赋值。语句 Year = 2020 将值 2020 赋给变量 Year。要进行相等比较,Python 代码使用双等号:
`# Assignment`
`my_year` `=` `2021`
`# Comparison, which evaluates to False`
`my_year` `==` `2020`
要向过滤器添加更多谓词,使用 AND 和 OR 关键字。例如,要查找在 2020 年或 2019 年出生的超过 10,000 名婴儿的姓名,我们写道:
`query` `=` `'''`
`SELECT *`
`FROM baby`
`WHERE Count > 10000`
`AND (Year = 2020`
`OR Year = 2019);`
`-- Notice that we use parentheses to enforce evaluation order`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 41 | Mia | F | 12452 | 2019 |
| 42 | Harper | F | 10464 | 2019 |
| 43 | Evelyn | F | 10412 | 2019 |
44 rows × 4 columns
最后,要查找 2020 年最常见的 10 个名字,我们可以使用 ORDER BY 关键字和 DESC 选项(DESC 表示 DESCending)按 Count 降序排序数据框:
`query` `=` `'''`
`SELECT *`
`FROM baby`
`WHERE Year = 2020`
`ORDER BY Count DESC`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Emma | F | 15581 | 2020 |
| ... | ... | ... | ... | ... |
| 7 | Sophia | F | 12976 | 2020 |
| 8 | Amelia | F | 12704 | 2020 |
| 9 | William | M | 12541 | 2020 |
10 rows × 4 columns
我们看到,Liam、Noah 和 Emma 是 2020 年最受欢迎的婴儿名字。
例如:Luna 何时成为流行名字?
正如我们在第六章中提到的,纽约时报文章提到,Luna 这个名字在 2000 年之前几乎不存在,但此后已成为女孩们非常流行的名字。Luna 何时变得流行?我们可以使用 SQL 中的切片和过滤来检查:
`query` `=` `'''`
`SELECT *`
`FROM baby`
`WHERE Name =` `"``Luna``"`
`AND Sex =` `"``F``"``;`
`'''`
`luna` `=` `pd``.``read_sql``(``query``,` `db``)`
`luna`
| 名称 | 性别 | 计数 | 年份 | |
|---|---|---|---|---|
| 0 | Luna | F | 7770 | 2020 |
| 1 | Luna | F | 7772 | 2019 |
| 2 | Luna | F | 6929 | 2018 |
| ... | ... | ... | ... | ... |
| 125 | Luna | F | 17 | 1883 |
| 126 | Luna | F | 18 | 1881 |
| 127 | Luna | F | 15 | 1880 |
128 rows × 4 columns
pd.read_sql 返回一个 pandas.DataFrame 对象,我们可以用它来绘制图表。这展示了一个常见的工作流程 —— 使用 SQL 处理数据,将其加载到 pandas 数据框中,然后可视化结果:
`px``.``line``(``luna``,` `x``=``'``Year``'``,` `y``=``'``Count``'``,` `width``=``350``,` `height``=``250``)`
在本节中,我们介绍了数据科学家对关系进行子集处理的常见方法 —— 使用列标签进行切片和使用布尔条件进行过滤。在下一节中,我们将解释如何将行聚合在一起。
聚合
本节介绍了 SQL 中的分组和聚合。我们将使用与前一节相同的婴儿名数据:
`import` `sqlalchemy`
`db` `=` `sqlalchemy``.``create_engine``(``'``sqlite:///babynames.db``'``)`
`query` `=` `'''`
`SELECT *`
`FROM baby`
`LIMIT 10`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | Sex | Count | Year | |
|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 |
| 1 | Noah | M | 18252 | 2020 |
| 2 | Oliver | M | 14147 | 2020 |
| ... | ... | ... | ... | ... |
| 7 | Lucas | M | 11281 | 2020 |
| 8 | Henry | M | 10705 | 2020 |
| 9 | Alexander | M | 10151 | 2020 |
10 rows × 4 columns
基本的分组聚合使用 GROUP BY
假设我们想找出记录在此数据中的总出生婴儿数。这只是 Count 列的总和。SQL 提供了我们在 SELECT 语句中使用的函数,比如 SUM:
`query` `=` `'''`
`SELECT SUM(Count)`
`FROM baby`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| SUM(Count) | |
|---|---|
| 0 | 352554503 |
在 第六章 中,我们使用分组和聚合来判断随时间是否有上升趋势的美国出生率。我们使用 .groupby() 按年份对数据集进行分组,然后使用 .sum() 在每个组内对计数进行求和。
在 SQL 中,我们使用 GROUP BY 子句进行分组,然后在 SELECT 中调用聚合函数:
`query` `=` `'''`
`SELECT Year, SUM(Count)`
`FROM baby`
`GROUP BY Year`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Year | SUM(Count) | |
|---|---|---|
| 0 | 1880 | 194419 |
| 1 | 1881 | 185772 |
| 2 | 1882 | 213385 |
| ... | ... | ... |
| 138 | 2018 | 3487193 |
| 139 | 2019 | 3437438 |
| 140 | 2020 | 3287724 |
141 rows × 2 columns
与数据框分组一样,注意 Year 列包含唯一的 Year 值 —— 因为我们将它们分组在一起,所以不再有重复的 Year 值。在 pandas 中进行分组时,分组列成为结果数据框的索引。但是,关系没有行标签,所以 Year 值只是结果关系的一列。
这是在 SQL 中进行分组的基本步骤:
SELECT
col1, -- column used for grouping
SUM(col2) -- aggregation of another column
FROM table_name -- relation to use
GROUP BY col1 -- the column(s) to group by
请注意,SQL 语句中子句的顺序很重要。为了避免语法错误,SELECT 需要首先出现,然后是 FROM,接着是 WHERE,最后是 GROUP BY。
在使用 GROUP BY 时,我们需要注意给 SELECT 的列。通常情况下,只有在使用这些列进行分组时,才能包括未经聚合的列。例如,在上述示例中我们按 Year 列进行分组,因此可以在 SELECT 子句中包括 Year。所有其他包含在 SELECT 中的列应该进行聚合,就像我们之前用 SUM(Count) 所做的那样。如果我们包含一个未使用于分组的“裸”列如 Name,SQLite 不会报错,但其他 SQL 引擎会报错,因此建议避免这样做。
多列分组
我们将多列传递给 GROUP BY,以便一次性按多列进行分组。当我们需要进一步细分我们的分组时,这是非常有用的。例如,我们可以按年份和性别分组,以查看随时间变化出生的男女婴儿数量:
`query` `=` `'''`
`SELECT Year, Sex, SUM(Count)`
`FROM baby`
`GROUP BY Year, Sex`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Year | Sex | SUM(Count) | |
|---|---|---|---|
| 0 | 1880 | F | 83929 |
| 1 | 1880 | M | 110490 |
| 2 | 1881 | F | 85034 |
| ... | ... | ... | ... |
| 279 | 2019 | M | 1785527 |
| 280 | 2020 | F | 1581301 |
| 281 | 2020 | M | 1706423 |
282 rows × 3 columns
请注意,上述代码与仅按单列进行分组非常相似,唯一的区别在于它为 GROUP BY 提供了多列,以便按 Year 和 Sex 进行分组。
注意
与 pandas 不同,SQLite 没有提供简单的方法来对关系表进行透视。相反,我们可以在 SQL 中对两列使用 GROUP BY,将结果读取到数据框中,然后使用 unstack() 数据框方法。
其他聚合函数
SQLite 除了 SUM 外,还有几个内置的聚合函数,例如 COUNT、AVG、MIN 和 MAX。有关完整的函数列表,请参阅SQLite 网站。
要使用其他聚合函数,我们在 SELECT 子句中调用它。例如,我们可以使用 MAX 替代 SUM:
`query` `=` `'''`
`SELECT Year, MAX(Count)`
`FROM baby`
`GROUP BY Year`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Year | MAX(Count) | |
|---|---|---|
| 0 | 1880 | 9655 |
| 1 | 1881 | 8769 |
| 2 | 1882 | 9557 |
| ... | ... | ... |
| 138 | 2018 | 19924 |
| 139 | 2019 | 20555 |
| 140 | 2020 | 19659 |
141 rows × 2 columns
注意
内置的聚合函数是数据科学家可能在 SQL 实现中首次遇到差异的地方之一。例如,SQLite 拥有相对较少的聚合函数,而PostgreSQL 拥有更多。尽管如此,几乎所有 SQL 实现都提供 SUM、COUNT、MIN、MAX 和 AVG。
此部分涵盖了使用 SQL 中的 GROUP BY 关键字以一个或多个列对数据进行聚合的常见方法。在接下来的部分中,我们将解释如何将关系表进行连接。
连接
要连接两个数据表之间的记录,可以像数据框一样使用 SQL 关系进行连接。在本节中,我们介绍 SQL 连接以复制我们对婴儿姓名数据的分析。回想一下,第六章提到了一篇纽约时报文章,讨论了某些姓名类别(如神话和婴儿潮时期的姓名)随着时间的推移变得更受欢迎或不受欢迎的情况。
我们已将NYT文章中的姓名和类别放入名为nyt的小关系中。首先,代码建立了与数据库的连接,然后运行 SQL 查询以显示nyt关系:
`import` `sqlalchemy`
`db` `=` `sqlalchemy``.``create_engine``(``'``sqlite:///babynames.db``'``)`
`query` `=` `'''`
`SELECT *`
`FROM nyt;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| nyt_name | 类别 | |
|---|---|---|
| 0 | Lucifer | forbidden |
| 1 | Lilith | forbidden |
| 2 | Danger | forbidden |
| ... | ... | ... |
| 20 | Venus | celestial |
| 21 | Celestia | celestial |
| 22 | Skye | celestial |
23 rows × 2 columns
注
注意到前面的代码在babynames.db上运行查询,这个数据库包含前几节中较大的baby关系。SQL 数据库可以容纳多个关系,当我们需要同时处理多个数据表时非常有用。另一方面,CSV 文件通常只包含一个数据表——如果我们执行一个使用 20 个数据表的数据分析,可能需要跟踪 20 个 CSV 文件的名称、位置和版本。相反,将所有数据表存储在单个文件中的 SQLite 数据库中可能更简单。
要查看姓名类别的受欢迎程度,我们将nyt关系与baby关系连接,以从baby中获取姓名计数。
内连接
就像在第六章中一样,我们制作了baby和nyt表的较小版本,以便更容易地查看在表合并时发生的情况。这些关系被称为baby_small和nyt_small:
`query` `=` `'''`
`SELECT *`
`FROM baby_small;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 姓名 | 性别 | 数量 | 年份 | |
|---|---|---|---|---|
| 0 | Noah | M | 18252 | 2020 |
| 1 | Julius | M | 960 | 2020 |
| 2 | Karen | M | 6 | 2020 |
| 3 | Karen | F | 325 | 2020 |
| 4 | Noah | F | 305 | 2020 |
`query` `=` `'''`
`SELECT *`
`FROM nyt_small;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| nyt_name | 类别 | |
|---|---|---|
| 0 | Karen | boomer |
| 1 | Julius | mythology |
| 2 | Freya | mythology |
要在 SQL 中连接关系,我们使用INNER JOIN子句来指定要连接的表,并使用ON子句来指定表连接的条件。这里是一个示例:
`query` `=` `'''`
`SELECT *`
`FROM baby_small INNER JOIN nyt_small`
`ON baby_small.Name = nyt_small.nyt_name`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 姓名 | 性别 | 数量 | 年份 | nyt_name | 类别 | |
|---|---|---|---|---|---|---|
| 0 | Julius | M | 960 | 2020 | Julius | mythology |
| 1 | Karen | M | 6 | 2020 | Karen | boomer |
| 2 | Karen | F | 325 | 2020 | Karen | boomer |
注意到这个结果与在pandas中进行内连接的结果相同:新表具有baby_small和nyt_small表的列。Noah 的行已消失,并且剩余的行具有它们在nyt_small中的匹配category。
要将两个表连接在一起,我们告诉 SQL 我们想要使用的每个表的列,并使用带有 ON 关键字的谓词进行连接。当连接列中的值满足谓词时,SQL 将行进行匹配,如 Figure 7-2 所示。
与 pandas 不同,SQL 在行连接方面提供了更大的灵活性。pd.merge() 方法只能使用简单的相等条件进行连接,但是 ON 子句中的谓词可以是任意复杂的。例如,在 “Finding Collocated Sensors” 中,我们利用了这种额外的多样性。
图 7-2. 使用 SQL 将两个表连接在一起
左连接和右连接
类似于 pandas,SQL 也支持左连接。我们使用 LEFT JOIN 而不是 INNER JOIN:
`query` `=` `'''`
`SELECT *`
`FROM baby_small LEFT JOIN nyt_small`
`ON baby_small.Name = nyt_small.nyt_name`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | Sex | Count | Year | nyt_name | category | |
|---|---|---|---|---|---|---|
| 0 | Noah | M | 18252 | 2020 | None | None |
| 1 | Julius | M | 960 | 2020 | Julius | mythology |
| 2 | Karen | M | 6 | 2020 | Karen | 潮妈 |
| 3 | Karen | F | 325 | 2020 | Karen | 潮妈 |
| 4 | Noah | F | 305 | 2020 | None | None |
如我们所料,连接的“左”侧指的是出现在 LEFT JOIN 关键字左侧的表。我们可以看到即使 Noah 行在右侧关系中没有匹配时,它们仍然会保留在结果关系中。
请注意,SQLite 不直接支持右连接,但是我们可以通过交换关系的顺序,然后使用 LEFT JOIN 来执行相同的连接:
`query` `=` `'''`
`SELECT *`
`FROM nyt_small LEFT JOIN baby_small`
`ON baby_small.Name = nyt_small.nyt_name`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| nyt_name | category | Name | Sex | Count | Year | |
|---|---|---|---|---|---|---|
| 0 | Karen | 潮妈 | Karen | F | 325.0 | 2020.0 |
| 1 | Karen | 潮妈 | Karen | M | 6.0 | 2020.0 |
| 2 | Julius | mythology | Julius | M | 960.0 | 2020.0 |
| 3 | Freya | mythology | None | None | NaN | NaN |
SQLite 没有内置的外连接关键字。在需要外连接的情况下,我们可以使用不同的 SQL 引擎或通过 pandas 执行外连接。然而,在我们(作者)的经验中,与内连接和左连接相比,外连接在实践中很少使用。
示例:NYT 姓名类别的流行度
现在让我们返回到完整的 baby 和 nyt 关系。
我们想知道 nyt 中姓名类别的流行程度随时间的变化。要回答这个问题,我们应该:
-
使用
ON关键字中指定的列内连接baby和nyt,匹配姓名相等的行。 -
按
category和Year对表进行分组。 -
使用求和对计数进行聚合:
`query` `=` `'''`
`SELECT`
`category,`
`Year,`
`SUM(Count) AS count -- [3]`
`FROM baby INNER JOIN nyt -- [1]`
`ON baby.Name = nyt.nyt_name -- [1]`
`GROUP BY category, Year -- [2]`
`'''`
`cate_counts` `=` `pd``.``read_sql``(``query``,` `db``)`
`cate_counts`
| category | Year | count | |
|---|---|---|---|
| 0 | 潮妈 | 1880 | 292 |
| 1 | 潮妈 | 1881 | 298 |
| 2 | 潮妈 | 1882 | 326 |
| ... | ... | ... | ... |
| 647 | mythology | 2018 | 2944 |
| 648 | mythology | 2019 | 3320 |
| 649 | mythology | 2020 | 3489 |
650 rows × 3 columns
在上述查询中方括号中的数字([1]、[2]、[3])显示了我们计划中的每个步骤如何映射到 SQL 查询的部分。代码重新创建了来自 第六章 的数据框,我们在其中创建了图表以验证 纽约时报 文章的主张。为简洁起见,我们在此省略了重复绘制图表的部分。
注意
请注意,在此示例中的 SQL 代码中,数字的顺序看起来是不正确的——[3]、[1],然后是[2]。对于首次学习 SQL 的人来说,我们通常可以将 SELECT 语句看作查询的最后执行的部分,即使它首先出现。
在本节中,我们介绍了用于关系的连接。当将关系连接在一起时,我们使用 INNER JOIN 或 LEFT JOIN 关键字以及布尔谓词来匹配行。在下一节中,我们将解释如何转换关系中的值。
转换和公共表达式(CTE)
在本节中,我们展示了如何调用内置 SQL 函数来转换数据列。我们还演示了如何使用公共表达式(CTE)从简单查询构建复杂查询。与往常一样,我们首先加载数据库:
`# Set up connection to database`
`import` `sqlalchemy`
`db` `=` `sqlalchemy``.``create_engine``(``'``sqlite:///babynames.db``'``)`
SQL 函数
SQLite 提供了多种标量函数,即用于转换单个数据值的函数。当在数据列上调用时,SQLite 将对列中的每个值应用这些函数。相比之下,像 SUM 和 COUNT 这样的聚合函数以数据列作为输入,并计算单个值作为输出。
SQLite 在其在线文档中提供了内置标量函数的详尽列表。例如,要找出每个名称中的字符数,我们使用 LENGTH 函数:
`query` `=` `'''`
`SELECT Name, LENGTH(Name)`
`FROM baby`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | LENGTH(Name) | |
|---|---|---|
| 0 | Liam | 4 |
| 1 | Noah | 4 |
| 2 | Oliver | 6 |
| ... | ... | ... |
| 7 | Lucas | 5 |
| 8 | Henry | 5 |
| 9 | Alexander | 9 |
10 rows × 2 columns
请注意,LENGTH 函数应用于 Name 列中的每个值。
注意
像聚合函数一样,每个 SQL 实现都提供了不同的标量函数集。SQLite 提供的函数集相对较少,而 PostgreSQL 则更多。尽管如此,几乎所有 SQL 实现都提供了与 SQLite 的 LENGTH、ROUND、SUBSTR 和 LIKE 函数相当的功能。
虽然标量函数与聚合函数使用相同的语法,但它们的行为不同。如果在单个查询中混合使用这两种函数,可能会导致输出结果混乱:
`query` `=` `'''`
`SELECT Name, LENGTH(Name), AVG(Count)`
`FROM baby`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| Name | LENGTH(Name) | AVG(Count) | |
|---|---|---|---|
| 0 | Liam | 4 | 174.47 |
在这里,AVG(Name) 计算了整个 Count 列的平均值,但输出结果令人困惑——读者很容易会认为平均值与名字 Liam 有关。因此,当标量函数和聚合函数同时出现在 SELECT 语句中时,我们必须格外小心。
要提取每个名称的首字母,我们可以使用 SUBSTR 函数(缩写为substring)。如文档中所述,SUBSTR 函数接受三个参数。第一个是输入字符串,第二个是开始子字符串的位置(从 1 开始索引),第三个是子字符串的长度:
`query` `=` `'''`
`SELECT Name, SUBSTR(Name, 1, 1)`
`FROM baby`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | SUBSTR(名称, 1, 1) | |
|---|---|---|
| 0 | Liam | L |
| 1 | Noah | N |
| 2 | Oliver | O |
| ... | ... | ... |
| 7 | Lucas | L |
| 8 | Henry | H |
| 9 | Alexander | A |
10 rows × 2 columns
我们可以使用 AS 关键字重命名列:
`query` `=` `'''`
`SELECT *, SUBSTR(Name, 1, 1) AS Firsts`
`FROM baby`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | 性别 | 数量 | 年份 | 首字母 | |
|---|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 | L |
| 1 | Noah | M | 18252 | 2020 | N |
| 2 | Oliver | M | 14147 | 2020 | O |
| ... | ... | ... | ... | ... | ... |
| 7 | Lucas | M | 11281 | 2020 | L |
| 8 | Henry | M | 10705 | 2020 | H |
| 9 | Alexander | M | 10151 | 2020 | A |
10 rows × 5 columns
在计算每个名称的首字母后,我们的分析旨在了解不同时间段内首字母的流行度。为此,我们希望将这个 SQL 查询的输出用作更长操作链中的单个步骤。
SQL 提供了几种选项来将查询分解为较小的步骤,这在像这样更复杂的分析中非常有帮助。最常见的选项是使用 CREATE TABLE 语句创建新关系,使用 CREATE VIEW 创建新视图,或者使用 WITH 创建临时关系。每种方法都有不同的用例。为简单起见,我们在本节仅描述 WITH 语句,并建议读者查阅 SQLite 文档以获取详细信息。
使用 WITH 子句进行多步查询
WITH 子句允许我们为任何 SELECT 查询指定一个名称。然后我们可以把该查询视为数据库中的一个关系,仅在查询的持续时间内存在。SQLite 将这些临时关系称为公共表达式。例如,我们可以取之前计算每个名称的首字母的查询,并称其为 letters:
`query` `=` `'''`
`-- Create a temporary relation called letters by calculating`
`-- the first letter for each name in baby`
`WITH letters AS (`
`SELECT *, SUBSTR(Name, 1, 1) AS Firsts`
`FROM baby`
`)`
`-- Then, select the first ten rows from letters`
`SELECT *`
`FROM letters`
`LIMIT 10;`
`'''`
`pd``.``read_sql``(``query``,` `db``)`
| 名称 | 性别 | 数量 | 年份 | 首字母 | |
|---|---|---|---|---|---|
| 0 | Liam | M | 19659 | 2020 | L |
| 1 | Noah | M | 18252 | 2020 | N |
| 2 | Oliver | M | 14147 | 2020 | O |
| ... | ... | ... | ... | ... | ... |
| 7 | Lucas | M | 11281 | 2020 | L |
| 8 | Henry | M | 10705 | 2020 | H |
| 9 | Alexander | M | 10151 | 2020 | A |
10 rows × 5 columns
WITH 语句非常有用,因为它们可以链接在一起。我们可以在 WITH 语句中创建多个临时关系,每个关系对前一个结果执行一些工作,这样可以逐步构建复杂的查询。
例如:“L” 名字的流行度
我们可以使用WITH语句来查看以字母 L 开头的名字随时间的流行度。我们将临时letters关系按首字母和年份分组,然后使用求和聚合Count列,然后筛选只获取字母 L 开头的名字:
`query` `=` `'''`
`WITH letters AS (`
`SELECT *, SUBSTR(Name, 1, 1) AS Firsts`
`FROM baby`
`)`
`SELECT Firsts, Year, SUM(Count) AS Count`
`FROM letters`
`WHERE Firsts =` `"``L``"`
`GROUP BY Firsts, Year;`
`'''`
`letter_counts` `=` `pd``.``read_sql``(``query``,` `db``)`
`letter_counts`
| 首字母 | 年份 | 数量 | |
|---|---|---|---|
| 0 | L | 1880 | 12799 |
| 1 | L | 1881 | 12770 |
| 2 | L | 1882 | 14923 |
| ... | ... | ... | ... |
| 138 | L | 2018 | 246251 |
| 139 | L | 2019 | 249315 |
| 140 | L | 2020 | 239760 |
141 rows × 3 columns
这个关系包含与第六章相同的数据。在那一章中,我们制作了Count列随时间变化的图表,这里为了简洁起见省略了。
在这一节中,我们介绍了数据转换。为了转换关系中的值,我们通常使用 SQL 函数如LENGTH()或SUBSTR()。我们还解释了如何使用WITH子句构建复杂查询。
概要
在本章中,我们解释了什么是关系,它们为何有用,以及如何使用 SQL 代码处理它们。SQL 数据库在许多现实世界的场景中非常有用。例如,SQL 数据库通常具有强大的数据恢复机制——如果计算机在 SQL 操作中崩溃,数据库系统可以尽可能恢复数据而不会损坏。如前所述,SQL 数据库还能处理更大规模的数据;组织使用 SQL 数据库来存储和查询那些用pandas代码无法在内存中分析的大型数据库。这些只是 SQL 成为数据科学工具箱中重要一环的几个原因,我们预计许多读者很快会在工作中遇到 SQL 代码。
第三部分:理解数据
第八章:整理文件
在使用 Python 处理数据之前,了解存储数据源的文件是很有帮助的。您想要了解一些基本问题的答案:
-
您有多少数据?
-
源文件的格式是怎样的?
这些问题的答案可能非常有帮助。例如,如果您的文件太大或格式不符合您的期望,您可能无法正确加载它到数据框中。
虽然许多类型的结构都可以表示数据,在本书中,我们主要使用数据表,如 Pandas DataFrames 和 SQL 关系。(但请注意,第十三章研究了结构较少的文本数据,第十四章介绍了分层格式和二进制文件。)我们之所以专注于数据表,有几个原因。研究如何存储和操作数据表已经产生了稳定高效的工具来处理表格。此外,表格格式的数据与矩阵密切相关,矩阵是线性代数领域非常丰富的数学对象。当然,数据表非常常见。
在本章中,我们介绍了纯文本的典型文件格式和编码,描述了文件大小的度量,并使用 Python 工具检查源文件。在本章的后面部分,我们介绍了一种用于处理文件的替代方法:shell 解释器。Shell 命令为我们提供了一种在 Python 环境之外获取文件信息的程序化方式,而且对于大数据,shell 可能非常有用。最后,我们检查数据表的形状(行数和列数)和粒度(行代表什么)。这些简单的检查是清理和分析数据的起点。
我们首先简要描述了我们在本章中始终使用的示例数据集。
数据源示例
我们选择了两个示例来演示文件整理概念:一个关于药物滥用的政府调查,以及旧金山公共卫生部门有关餐馆检查的行政数据。在我们开始整理之前,我们先概述一下这些示例的数据范围(见第二章)。
药物滥用警戒网络(DAWN)调查
DAWN 是一个全国性的医疗保健调查,旨在监测药物滥用趋势。该调查旨在估计药物滥用对国家医疗保健系统的影响,并改善急诊科监测物质滥用危机的方式。DAWN 由药物滥用和精神卫生服务管理局(SAMHSA)于 1998 年至 2011 年每年进行一次。2018 年,由于阿片类药物流行,DAWN 调查得以重新启动。在这个例子中,我们查看了 2011 年的数据,这些数据已通过SAMHSA 数据存档提供。
目标人群包括美国所有药物相关急诊室就诊者。这些访问通过医院急诊室(及其记录)的框架进行访问。医院通过概率抽样进行调查选择(参见第三章),并且样本医院急诊室的所有药物相关访问都包括在调查中。所有类型的药物相关访问都包括在内,例如药物滥用、滥用、意外吞食、自杀企图、恶意中毒和不良反应。对于每次访问,记录可能包含最多 16 种不同的药物,包括非法药物、处方药物和非处方药物。
此数据集的源文件是一个需要外部文档(如代码书)来解释的固定宽度格式的示例。此外,由于它是一个相当大的文件,所以激发了如何找到文件大小的话题。而且,其粒度不同寻常,因为调查的主题是急诊访问,而不是个人。
旧金山餐厅文件具有其他特征,使它们成为本章的良好示例。
旧金山餐厅食品安全
旧金山公共卫生部门定期对餐厅进行未经预先通知的访问,并检查其食品安全情况。检查员根据发现的违规行为计算评分,并提供违规行为的描述。这里的目标人群是旧金山所有餐厅。这些餐厅是通过 2013 年至 2016 年进行的餐厅检查框架来访问的。一些餐厅一年内进行多次检查,而不是所有 7000 多家餐厅每年都接受检查。
食品安全评分可通过城市的开放数据计划获得,称为DataSF。 DataSF 是城市政府公开其数据的一个例子;DataSF 的使命是“在决策和服务交付中使用数据”,旨在改善居民、雇主、员工和访客的生活和工作质量。
旧金山要求餐厅公开展示其评分(参见图 8-1 作为示例标牌)。^(1) 这些数据提供了不同结构、字段和粒度的多个文件的示例。一个数据集包含检查结果的摘要,另一个提供有关发现的违规行为的详细信息,第三个包含有关餐厅的一般信息。违规行为包括与食源性疾病传播有关的严重问题以及像未正确展示检查标牌这样的小问题。
图 8-1. 展示在餐厅中的食品安全评分卡;分数范围在 0 到 100 之间。
DAWN 调查数据和旧金山餐厅检查数据都可以作为纯文本文件在线获取。然而,它们的格式有很大不同,在下一节中,我们将演示如何确定文件格式,以便将数据读入数据框架中。
文件格式
文件格式描述了数据如何存储在计算机的磁盘或其他存储设备上。了解文件格式帮助我们弄清楚如何将数据读入 Python,以便将其作为数据表进行处理。在本节中,我们介绍了几种用于存储数据表的流行格式。这些都是纯文本格式,意味着我们可以使用 VS Code、Sublime、Vim 或 Emacs 等文本编辑器轻松阅读它们。
注意
文件格式和数据的结构是两个不同的事物。我们认为数据结构是数据的一种心理表示,告诉我们可以进行哪些操作。例如,表结构对应于按行和列排列的数据值。但是同一个表可以存储在许多不同类型的文件格式中。
我们描述的第一种格式是分隔文件格式。
分隔格式
分隔格式使用特定字符来分隔数据值。通常,这些分隔符可以是逗号(逗号分隔值,或简称 CSV),制表符(制表符分隔值,或 TSV),空格或冒号。这些格式适合存储具有表结构的数据。文件中的每一行表示一个记录,由换行符(\n或\r\n)分隔。而在一行内,记录的信息则由逗号字符(,)用于 CSV 或制表符字符(\t)用于 TSV 等分隔。这些文件的第一行通常包含表的列名/特征的名称。
旧金山餐厅评分存储在 CSV 格式的文件中。让我们显示inspections.csv文件的前几行。在 Python 中,内置的pathlib库具有一个有用的Path对象,用于指定跨平台的文件和文件夹路径。该文件位于data文件夹中,因此我们使用Path()来创建完整的文件路径名:
`from` `pathlib` `import` `Path`
`# Create a Path pointing to our datafile`
`insp_path` `=` `Path``(``)` `/` `'``data``'` `/` `'``inspections.csv``'`
注意
在处理不同操作系统(OSs)时,路径是棘手的。例如,Windows 中的典型路径可能看起来像C:\files\data.csv,而 Unix 或 macOS 中的路径可能看起来像~/files/data.csv。因此,适用于一个操作系统的代码可能无法在其他操作系统上运行。
pathlib Python 库的创建是为了避免特定于操作系统的路径问题。通过使用它,这里显示的代码更具可移植性 —— 它可以在 Windows、macOS 和 Unix 上运行。
以下代码中的Path对象具有许多有用的方法,例如read_text(),它将整个文件内容作为字符串读取:
`text` `=` `insp_path``.``read_text``(``)`
`# Print first five lines`
`print``(``'``\n``'``.``join``(``text``.``split``(``'``\n``'``)``[``:``5``]``)``)`
"business_id","score","date","type"
19,"94","20160513","routine"
19,"94","20171211","routine"
24,"98","20171101","routine"
24,"98","20161005","routine"
请注意,字段名出现在文件的第一行;这些名称用逗号分隔并带引号。我们看到四个字段:业务标识符、餐厅得分、检查日期和检查类型。文件中的每一行对应一次检查,ID、分数、日期和类型的值用逗号分隔。除了识别文件格式外,我们还希望识别特征的格式。我们注意到两点:分数和日期都显示为字符串。我们希望将分数转换为数字,以便可以计算摘要统计信息并创建可视化图。我们将日期转换为日期时间格式,以便可以制作时间序列图。我们展示如何在第九章中执行这些转换。
显示文件的前几行是我们经常做的事情,因此我们创建一个函数作为快捷方式:
`def` `head``(``filepath``,` `n``=``5``,` `width``=``-``1``)``:`
`'''Prints the width characters of first n lines of filepath'''`
`with` `filepath``.``open``(``)` `as` `f``:`
`for` `_` `in` `range``(``n``)``:`
`(``print``(``f``.``readline``(``)``,` `end``=``'``'``)` `if` `width` `<` `0`
`else` `print``(``f``.``readline``(``)``[``:``width``]``)``)`
注意
人们经常将 CSV 和 TSV 文件与电子表格混淆。部分原因是大多数电子表格软件(如 Microsoft Excel)会自动将 CSV 文件显示为工作簿中的表格。在幕后,Excel 会像我们在本节中所做的那样查看文件格式和编码。然而,Excel 文件与 CSV 和 TSV 文件具有不同的格式,我们需要使用不同的pandas函数将这些格式读入 Python。
所有三个餐厅源文件都是 CSV 格式的。相比之下,DAWN 源文件采用固定宽度格式。我们接下来描述这种格式化方式。
固定宽度格式
固定宽度格式(FWF)不使用定界符来分隔数据值。相反,每行中特定字段的值出现在完全相同的位置。DAWN 源文件采用这种格式。文件中的每一行都非常长。为了显示目的,我们只展示文件中前五行的前几个字符:
`dawn_path` `=` `Path``(``)` `/` `'``data``'` `/` `'``DAWN-Data.txt``'`
`head``(``dawn_path``,` `width``=``65``)`
1 2251082 .9426354082 3 4 1 2201141 2 865 105 1102005 1
2 2291292 5.9920106887 911 1 3201134 12077 81 82 283-8
3 7 7 251 4.7231718669 611 2 2201143 12313 1 12 -7-8
410 8 292 4.0801470012 6 2 1 3201122 1 234 358 99 215 2
5 122 942 5.1777093467 10 6 1 3201134 3 865 105 1102005 1
请注意,值如何从一行到下一行对齐。例如,每行的第 19 个字符处都有一个小数点。还要注意,一些值似乎被挤在一起,我们需要知道每行中每个信息片段的确切位置才能理解它。SAMHSA 提供了一个有 2000 页的代码手册,其中包含所有这些信息,包括一些基本检查,以便我们可以确认我们已正确读取文件。例如,代码手册告诉我们年龄字段出现在 34-35 位置,并以 1 到 11 的间隔编码。前面代码中显示的前两条记录的年龄类别分别为 4 和 11;代码手册告诉我们,4 代表年龄段“6 到 11 岁”,而 11 代表“65 岁及以上”。
其他流行的纯文本格式包括分层格式和松散格式化文本(与直接支持表结构的格式形成对比)。这些在其他章节中有更详细的介绍,但为了完整起见,我们在这里简要描述它们。
注意
一种广泛采用的约定是使用文件名扩展名来指示文件内容的格式,例如 .csv、.tsv 和 .txt。文件名以 .csv 结尾通常包含逗号分隔值,以 .tsv 结尾的文件通常包含制表符分隔值;.txt 通常表示没有指定格式的纯文本。但是,这些扩展名只是建议。即使文件的扩展名为 .csv,实际内容可能格式不正确!在加载到数据框之前检查文件内容是一个好习惯。如果文件不太大,可以使用纯文本编辑器打开和查看。否则,可以使用 .readline() 或 shell 命令查看几行。
分层格式
分层格式以嵌套形式存储数据。例如,JavaScript 对象表示法(JSON)通常用于 Web 服务器的通信,包括可以嵌套的键值对和数组,类似于 Python 字典。XML 和 HTML 是其他常见的用于在互联网上存储文档的格式。与 JSON 类似,这些文件具有分层的键值格式。我们在第十四章中更详细地介绍了这两种格式(JSON 和 XML)。
接下来,我们简要描述了其他不属于先前任何类别但仍具有一定结构以便于读取和提取信息的纯文本文件。
松散格式文本
网络日志、仪器读数和程序日志通常以纯文本形式提供数据。例如,这是网络日志的一行(我们已经将其分成多行以便阅读)。它包含日期、时间和对网站发出的请求类型等信息:
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)"
存在组织模式,但不是简单的分隔格式。这就是我们所说的“松散格式”。我们看到日期和时间出现在方括号之间,并且请求类型(本例中为 GET)跟随日期时间信息,并以引号形式出现。在第十三章中,我们利用这些关于网络日志格式和字符串操作工具的观察,将感兴趣的值提取到数据表中。
作为另一个例子,这是从无线设备日志中获取的单条记录。设备报告时间戳、标识符、位置以及从其他设备接收到的信号强度。此信息使用了多种格式:键值对、分号分隔值和逗号分隔值:
t=1139644637174;id=00:02:2D:21:0F:33;pos=2.0,0.0,0.0;degree=45.5;
00:14:bf:b1:97:8a=-33,2437000000,3;00:14:bf:b1:97:8a=-38,2437000000,3;
就像网络日志一样,我们可以利用字符串操作和记录中的模式将特征提取到表中。
我们主要介绍了用于存储和交换表格的纯文本数据格式。CSV 格式是最常见的,但其他格式,如制表符分隔和固定宽度格式,也很普遍。还有许多种存储数据的文件格式!
到目前为止,我们使用术语 plain text 来广泛覆盖可以在文本编辑器中查看的格式。然而,纯文本文件可能有不同的编码,如果我们没有正确指定编码,数据框中的值可能会包含无意义的内容。接下来我们概述文件编码。
文件编码
计算机将数据存储为 比特 的序列:0 和 1。像 ASCII 这样的 字符编码 告诉计算机如何在比特和文本之间进行转换。例如,在 ASCII 中,比特 100 001 表示字母 A,比特 100 010 表示 B。最基本的纯文本仅支持标准 ASCII 字符,包括大写和小写英文字母、数字、标点符号和空格。
ASCII 编码不包括许多特殊字符或其他语言的字符。其他更现代的字符编码有更多可以表示的字符。文档和网页的常见编码是 Latin-1(ISO-8859-1)和 UTF-8。UTF-8 具有超过一百万个字符,并且向后兼容 ASCII,这意味着它与英文字母、数字和标点的表示方式与 ASCII 相同。
当我们有一个文本文件时,通常需要弄清楚它的编码。如果我们选择错误的编码来读取文件,Python 要么读取错误的值,要么抛出错误。找到编码的最佳方法是检查数据的文档,通常文档会明确指出编码是什么。
当我们不知道编码时,必须猜测。chardet 包有一个名为 detect() 的函数,可以推断文件的编码。由于这些猜测并不完美,该函数还返回一个介于 0 和 1 之间的置信度。我们使用这个函数来查看我们示例中的文件:
`import` `chardet`
`line` `=` `'``{:<25}` `{:<10}` `{}``'``.``format`
`# for each file, print its name, encoding & confidence in the encoding`
`print``(``line``(``'``File Name``'``,` `'``Encoding``'``,` `'``Confidence``'``)``)`
`for` `filepath` `in` `Path``(``'``data``'``)``.``glob``(``'``*``'``)``:`
`result` `=` `chardet``.``detect``(``filepath``.``read_bytes``(``)``)`
`print``(``line``(``str``(``filepath``)``,` `result``[``'``encoding``'``]``,` `result``[``'``confidence``'``]``)``)`
File Name Encoding Confidence
data/inspections.csv ascii 1.0
data/co2_mm_mlo.txt ascii 1.0
data/violations.csv ascii 1.0
data/DAWN-Data.txt ascii 1.0
data/legend.csv ascii 1.0
data/businesses.csv ISO-8859-1 0.73
检测函数非常确信除了一个文件外,所有文件都是 ASCII 编码的。例外是 businesses.csv,它似乎是 ISO-8859-1 编码的。如果我们忽略这种编码并尝试在不指定特殊编码的情况下将业务文件读入 pandas 中,我们将遇到麻烦:
`# naively reads file without considering encoding`
`>>``>` `pd``.``read_csv``(``'``data/businesses.csv``'``)`
`[``.``.``.``stack` `trace` `omitted``.``.``.``]`
`UnicodeDecodeError``:` `'``utf-8``'` `codec` `can``'``t decode byte 0xd1 in`
`position` `8``:` `invalid` `continuation` `byte`
要成功读取数据,我们必须指定 ISO-8859-1 编码:
`bus` `=` `pd``.``read_csv``(``'``data/businesses.csv``'``,` `encoding``=``'``ISO-8859-1``'``)`
| business_id | name | address | postal_code | |
|---|---|---|---|---|
| 0 | 19 | NRGIZE LIFESTYLE CAFE | 1200 VAN NESS AVE, 3RD FLOOR | 94109 |
| 1 | 24 | OMNI S.F. HOTEL - 2ND FLOOR PANTRY | 500 CALIFORNIA ST, 2ND FLOOR | 94104 |
| 2 | 31 | NORMAN’S ICE CREAM AND FREEZES | 2801 LEAVENWORTH ST | 94133 |
| 3 | 45 | CHARLIE’S DELI CAFE | 3202 FOLSOM ST | 94110 |
文件编码可能有点神秘,除非有明确给出编码的元数据,否则就要猜测。当编码没有完全确认时,最好寻找额外的文档。
另一个可能重要的源文件方面是其大小。如果文件很大,那么我们可能无法将其读入数据框架。在下一节中,我们将讨论如何确定源文件的大小。
文件大小
计算机资源是有限的。如果您的计算机因打开太多应用程序而变慢,您可能已经亲身经历了这些限制。我们希望确保在处理数据时不超出计算机的限制,并且可能会根据数据集的大小选择不同的文件查看方法。如果我们知道我们的数据集相对较小,那么使用文本编辑器或电子表格软件查看数据会很方便。另一方面,对于大型数据集,可能需要更多的程序化探索甚至分布式计算工具。
在许多情况下,我们分析从互联网下载的数据集。这些文件存储在计算机的磁盘存储上。为了使用 Python 探索和操作数据,我们需要将数据读入计算机的内存,也称为随机访问存储器(RAM)。无论代码有多短,所有 Python 代码都需要使用 RAM。计算机的 RAM 通常比磁盘存储小得多。例如,2018 年发布的某一款计算机型号的磁盘存储比 RAM 多 32 倍。不幸的是,这意味着数据文件通常比可读入内存的数据量要大得多。
磁盘存储和 RAM 容量都是以字节(八个 0 和 1)为单位测量的。粗略地说,文本文件中的每个字符增加一个字节的文件大小。为了简洁描述较大文件的大小,我们使用表 8-1 中描述的前缀;例如,包含 52,428,800 个字符的文件将占用5 , 242 , 8800 / 1 , 024 2 = 50 mebibytes ,即 50 MiB 的磁盘空间。
表 8-1. 常见文件大小的前缀
| Multiple | Notation | Number of bytes |
|---|---|---|
| Kibibyte | KiB | 1,024 |
| Mebibyte | MiB | 1,024² |
| Gibibyte | GiB | 1,024³ |
| Tebibyte | TiB | 1,024⁴ |
| Pebibyte | PiB | 1,024⁵ |
注意
为什么使用 1,024 的倍数而不是简单的 1,000 倍数来表示这些前缀?这是历史的结果,因为大多数计算机使用二进制数方案,其中 2 的幂更简单表示(1 , 024 = 2 10)。您还会看到用于描述大小的典型 SI 前缀—例如,千字节、兆字节和千兆字节。不幸的是,这些前缀的使用不一致。有时,千字节指的是 1,000 字节;其他时候,千字节指的是 1,024 字节。为了避免混淆,我们坚持使用 kibi-、mebi-和 gibibytes 这些清楚表示 1,024 的倍数的前缀。
如果我们尝试用程序操作一个超出计算机内存容量的数据文件,那么在计算机上快乐存储的数据文件通常会溢出。因此,我们通常会通过使用内置的os库来确保文件的大小可管理:
`from` `pathlib` `import` `Path`
`import` `os`
`kib` `=` `1024`
`line` `=` `'``{:<25}` `{}``'``.``format`
`print``(``line``(``'``File``'``,` `'``Size (KiB)``'``)``)`
`for` `filepath` `in` `Path``(``'``data``'``)``.``glob``(``'``*``'``)``:`
`size` `=` `os``.``path``.``getsize``(``filepath``)`
`print``(``line``(``str``(``filepath``)``,` `np``.``round``(``size` `/` `kib``)``)``)`
File Size (KiB)
data/inspections.csv 455.0
data/co2_mm_mlo.txt 50.0
data/violations.csv 3639.0
data/DAWN-Data.txt 273531.0
data/legend.csv 0.0
data/businesses.csv 645.0
我们看到businesses.csv文件在磁盘上占据了 645 KiB,远低于大多数系统的内存容量。虽然violations.csv文件占据了 3.6 MiB 的磁盘存储空间,但大多数机器也可以轻松将其读入pandas的DataFrame中。但包含 DAWN 调查数据的DAWN-Data.txt文件则要大得多。
DAWN 文件占用大约 270 MiB 的磁盘存储空间,尽管一些计算机可以在内存中处理此文件,但可能会减慢其他系统的速度。为了在 Python 中使此数据更易管理,我们可以选择仅加载部分列而不是全部列。
有时候我们对文件夹的总大小感兴趣,而不是单个文件的大小。例如,我们有三个餐厅文件,我们可能想看看是否可以将所有数据合并到一个单一的数据框架中。在以下代码中,我们计算data文件夹的大小,包括其中所有的文件:
`mib` `=` `1024``*``*``2`
`total` `=` `0`
`for` `filepath` `in` `Path``(``'``data``'``)``.``glob``(``'``*``'``)``:`
`total` `+``=` `os``.``path``.``getsize``(``filepath``)` `/` `mib`
`print``(``f``'``The data/ folder contains` `{``total``:``.2f``}` `MiB``'``)`
The data/ folder contains 271.80 MiB
注意
通常情况下,使用pandas读取文件需要至少五倍于文件大小的可用内存。例如,读取 1 GiB 文件通常需要至少 5 GiB 的可用内存。内存由计算机上运行的所有程序共享,包括操作系统、Web 浏览器和 Jupyter 笔记本本身。具有 4 GiB 总内存的计算机可能只有 1 GiB 可用内存。在只有 1 GiB 可用内存的情况下,pandas可能无法读取 1 GiB 文件。
有几种处理远远大于可加载到内存的数据的策略。接下来我们将介绍其中的一些。
流行的术语大数据通常指的是数据足够大,以至于即使顶级计算机也无法直接读取这些数据到内存中。这在科学领域如天文学中很常见,例如望远镜捕捉的空间图像可以达到 PB( 2 50)级大小。虽然不及如此之大,社交媒体巨头、医疗保健提供者和其他公司也可能面临大量数据的挑战。
从这些数据集中提取见解是数据库工程和分布式计算领域的一个重要研究问题的核心动机。尽管本书不涵盖这些领域,我们提供了基本方法的简要概述:
对数据进行子集处理。
一种简单的方法是处理数据的部分。与加载整个源文件不同,我们可以选择其中的特定部分(例如一天的数据)或随机抽样数据集。由于其简单性,我们在本书中经常使用这种方法。其自然缺点是我们失去了分析大数据集时的许多优势,例如能够研究罕见事件。
使用数据库系统。
如在第七章中讨论的那样,关系数据库管理系统(RDBMSs)专门设计用于存储大型数据集。SQLite 是一个有用的系统,用于处理太大以至于无法完全放入内存但足够小以适合单台机器磁盘的数据集。对于太大以至于无法放入单台机器的数据集,可以使用更可扩展的数据库系统,如 MySQL 和 PostgreSQL。这些系统可以通过 SQL 查询操作无法完全放入内存的数据。由于其优势,RDBMSs 常用于研究和工业设置中的数据存储。其一个缺点是通常需要一个单独的服务器来存储数据,并需要其自己的配置。另一个缺点是 SQL 在计算能力上不如 Python 灵活,尤其在建模方面尤为明显。一种有用的混合方法是使用 SQL 来对数据进行子集化、聚合或抽样,将数据批处理成足够小的批次以便读入 Python。然后我们可以使用 Python 进行更复杂的分析。
使用分布式计算系统。
处理大数据集上复杂计算的另一种方法是使用 MapReduce、Spark 或 Ray 等分布式计算系统。这些系统在能够分解为许多较小部分的任务上效果最好,在这些任务中,它们将数据集分成较小的部分并同时在所有较小数据集上运行程序。这些系统具有很大的灵活性,并可在各种场景中使用。它们的主要缺点是通常需要大量工作来正确安装和配置,因为它们通常安装在需要彼此协调的许多计算机上。
使用 Python 确定文件格式、编码和大小可能很方便。另一个处理文件的强大工具是 shell;shell 广泛使用,其语法比 Python 更为简洁。在接下来的部分中,我们将介绍 shell 中可用的几个命令行工具,以执行在读取到数据帧之前查找文件信息的相同任务。
Shell 和命令行工具
几乎所有计算机都提供对shell 解释器的访问,如sh、bash或zsh。这些解释器通常使用它们自己的语言、语法和内置命令在计算机上执行文件操作。
我们使用术语命令行界面(CLI)工具来指代 shell 解释器中可用的命令。虽然我们在这里只涵盖了一些 CLI 工具,但还有许多有用的 CLI 工具可以对文件执行各种操作。例如,在 bash shell 中,以下命令将列出本章 figures/ 文件夹中的所有文件以及它们的文件大小:
$ ls -l -h figures/
注意
美元符号是 shell 提示符,显示用户在哪里输入。它不是命令本身的一部分。
shell 命令的基本语法是:
command -options arg1 arg2
CLI 工具通常需要一个或多个参数,类似于 Python 函数需要参数。在 shell 中,我们使用空格包裹参数,而不是使用括号或逗号。参数出现在命令行的末尾,它们通常是文件的名称或一些文本。在 ls 示例中,ls 的参数是 figures/。此外,CLI 工具支持标志,提供附加选项。这些标志紧跟在命令名称后面,使用破折号作为分隔符。在 ls 示例中,我们提供了 -l(提供有关每个文件的额外信息)和 -h(以更易读的格式提供文件大小)标志。许多命令具有默认参数和选项,man 工具会打印出任何命令的可接受选项、示例和默认值列表。例如,man ls描述了ls可用的约 30 个标志。
注意
我们在本书中涵盖的所有 CLI 工具都是针对 sh shell 解释器的,这是当前 macOS 和 Linux 系统上 Jupyter 安装的默认解释器。Windows 系统有一个不同的解释器,书中显示的命令可能无法在 Windows 上运行,尽管 Windows 可通过其 Linux 子系统访问 sh 解释器。
本节中的命令可以在终端应用程序中运行,也可以通过 Jupyter 打开的终端运行。
我们从探索包含本章内容的文件系统开始,使用 ls 工具:
$ ls
data wrangling_granularity.ipynb
figures wrangling_intro.ipynb
wrangling_command_line.ipynb wrangling_structure.ipynb
wrangling_datasets.ipynb wrangling_summary.ipynb
wrangling_formats.ipynb
为了更深入地查看并列出 data/ 目录中的文件,我们将目录名称作为 ls 的参数提供:
$ ls -l -L -h data/
total 556664
-rw-r--r-- 1 nolan staff 267M Dec 10 14:03 DAWN-Data.txt
-rw-r--r-- 1 nolan staff 645K Dec 10 14:01 businesses.csv
-rw-r--r-- 1 nolan staff 50K Jan 22 13:09 co2_mm_mlo.txt
-rw-r--r-- 1 nolan staff 455K Dec 10 14:01 inspections.csv
-rw-r--r-- 1 nolan staff 120B Dec 10 14:01 legend.csv
-rw-r--r-- 1 nolan staff 3.6M Dec 10 14:01 violations.csv
我们在命令中添加了 -l 标志以获取有关每个文件的更多信息。文件大小显示在列表的第五列中,并且通过 -h 标志指定的方式更易读。当我们有多个简单选项标志(如 -l、-h 和 -L)时,我们可以将它们组合在一起作为简写:
ls -lLh data/
注意
在本书中处理数据集时,我们的代码通常会为 ls 和其他 CLI 工具使用额外的 -L 标志,如 du。我们这样做是因为我们在书中使用快捷方式(称为符号链接)设置了数据集。通常情况下,您的代码不需要 -L 标志,除非您也在使用符号链接。
用于检查文件大小的其他 CLI 工具是 wc 和 du。wc 命令(缩写为 word count)提供有关文件大小的有用信息,以行数、单词数和文件中的字符数表示:
$ wc data/DAWN-Data.txt
229211 22695570 280095842 data/DAWN-Data.txt
我们可以从输出中看到 DAWN-Data.txt 有 229,211 行和 280,095,842 个字符。(中间值是文件的单词数,对于包含句子和段落的文件有用,但对于包含数据(如 FWF 格式值)的文件并不十分有用。)
ls 工具不计算文件夹内容的累计大小。要正确计算文件夹的总大小(包括文件夹中的文件),我们使用 du(磁盘使用情况的缩写)。默认情况下,du 工具以称为 blocks 的单位显示大小:
$ du -L data/
556664 data/
我们通常会在 du 命令中添加 -s 标志来显示文件和文件夹的大小,并添加 -h 标志以标准 KiB、MiB 或 GiB 格式显示数量。在下面的代码中,data/* 中的星号告诉 du 显示 data 文件夹中每个项的大小:
$ du -Lsh data/*
267M data/DAWN-Data.txt
648K data/businesses.csv
52K data/co2_mm_mlo.txt
456K data/inspections.csv
4.0K data/legend.csv
3.6M data/violations.csv
要检查文件的格式,我们可以使用 head 命令查看前几行,或者使用 tail 命令查看后几行。这些 CLI 对于查看文件内容以确定其是否为 CSV、TSV 等格式非常有用。例如,让我们来看一下 inspections.csv 文件:
$ head -4 data/inspections.csv
"business_id","score","date","type"
19,"94","20160513","routine"
19,"94","20171211","routine"
24,"98","20171101","routine"
默认情况下,head 显示文件的前 10 行。如果我们想显示四行,我们可以在命令中添加选项 -n 4(或者简写为 -4)。
我们可以使用 cat 命令打印文件的全部内容。但是,在使用此命令时需要小心,因为打印大文件可能会导致崩溃。legend.csv 文件很小,我们可以使用 cat 将其内容连接并打印出来:
$ cat data/legend.csv
"Minimum_Score","Maximum_Score","Description"
0,70,"Poor"
71,85,"Needs Improvement"
86,90,"Adequate"
91,100,"Good"
在许多情况下,仅使用 head 或 tail 就足以让我们对文件结构有足够的了解,以便将其加载到数据框中进行进一步处理。
最后,file 命令可以帮助我们确定文件的编码:
$ file -I data/*
data/DAWN-Data.txt: text/plain; charset=us-ascii
data/businesses.csv: application/csv; charset=iso-8859-1
data/co2_mm_mlo.txt: text/plain; charset=us-ascii
data/inspections.csv: application/csv; charset=us-ascii
data/legend.csv: application/csv; charset=us-ascii
data/violations.csv: application/csv; charset=us-ascii
我们再次看到所有文件都是 ASCII 编码,除了 businesses.csv 使用 ISO-8859-1 编码。
注意
通常,我们打开终端程序以启动 shell 解释器。但是,Jupyter 笔记本提供了一个方便的功能:如果 Python 代码单元格中的代码行以 ! 字符开头,则该行将直接发送到系统的 shell 解释器。例如,在 Python 单元格中运行 !ls 将列出当前目录中的文件。
Shell 命令为我们提供了一种程序化处理文件的方式,而不是点-and-click 的“手动”方法。它们对以下情况非常有用:
文档
如果你需要记录你所做的事情。
减少错误
如果你想减少排版错误和其他简单但潜在有害的错误。
可重复性
如果你将来需要重复相同的过程,或者计划与他人分享你的过程。这样可以记录你的操作。
体积
如果你有许多重复操作要执行,你正在处理的文件很大,或者你需要快速完成任务。CLI 工具可以在所有这些情况下帮助你。
在数据加载到数据框之后,我们的下一个任务是弄清楚表格的形状和粒度。我们首先找出表格中的行数和列数(其形状)。然后我们需要理解一行代表什么,然后才能开始检查数据的质量。我们在下一节中讨论这些话题。
表格形状和粒度
正如前面所述,我们将数据集的结构称为数据的心理表示,特别是我们通过将值按行和列排列来表示具有表结构的数据。我们使用术语粒度来描述表中每一行代表的内容,术语形状量化了表的行和列。
现在我们已经确定了与餐厅相关的文件的格式,我们将它们加载到数据框中并检查它们的形状:
`bus` `=` `pd``.``read_csv``(``'``data/businesses.csv``'``,` `encoding``=``'``ISO-8859-1``'``)`
`insp` `=` `pd``.``read_csv``(``"``data/inspections.csv``"``)`
`viol` `=` `pd``.``read_csv``(``"``data/violations.csv``"``)`
`print``(``"` `Businesses:``"``,` `bus``.``shape``,` `"``\t` `Inspections:``"``,` `insp``.``shape``,`
`"``\t` `Violations:``"``,` `viol``.``shape``)`
Businesses: (6406, 9) Inspections: (14222, 4) Violations: (39042, 3)
我们发现餐厅信息表(商业表)有 6,406 行和 9 列。现在让我们来弄清楚这张表的粒度。首先,我们可以看一下前两行:
| business_id | 名称 | 地址 | 城市 | ... | 邮政编码 | 纬度 | 经度 | 电话号码 | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 19 | NRGIZE LIFESTYLE CAFE | 1200 VAN NESS AVE, 3RD FLOOR | 旧金山 | ... | 94109 | 37.79 | -122.42 | +14157763262 |
| 1 | 24 | OMNI S.F. HOTEL - 2ND FLOOR PANTRY | 500 CALIFORNIA ST, 2ND FLOOR | 旧金山 | ... | 94104 | 37.79 | -122.40 | +14156779494 |
2 rows × 9 columns
这两行给我们的印象是每个记录代表一个特定的餐厅。但是,我们无法仅凭两个记录就知道这是否正确。名为business_id的字段暗示它是餐厅的唯一标识符。我们可以通过检查数据框中的记录数是否与字段business_id中的唯一值数目匹配来确认这一点:
`print``(``"``Number of records:``"``,` `len``(``bus``)``)`
`print``(``"``Number of unique business ids:``"``,` `len``(``bus``[``'``business_id``'``]``.``unique``(``)``)``)`
Number of records: 6406
Number of unique business ids: 6406
唯一的business_id数目与表中的行数相匹配,因此可以安全地假设每一行代表一个餐厅。由于business_id在数据框中唯一标识每条记录,我们将business_id视为该表的主键。我们可以使用主键来连接表(参见第六章)。有时主键由两个(或更多)特征组成。这是其他两个餐厅文件的情况。让我们继续检查检查和违规数据框,并找出它们的粒度。
餐厅检查和违规的粒度
我们刚刚看到,检查表中的行比商业表中的行要多得多。让我们仔细看一下前几次检查:
| business_id | 分数 | 日期 | 类型 | |
|---|---|---|---|---|
| 0 | 19 | 94 | 20160513 | 常规 |
| 1 | 19 | 94 | 20171211 | 常规 |
| 2 | 24 | 98 | 20171101 | 常规 |
| 3 | 24 | 98 | 20161005 | 常规 |
`(``insp`
`.``groupby``(``[``'``business_id``'``,` `'``date``'``]``)`
`.``size``(``)`
`.``sort_values``(``ascending``=``False``)`
`.``head``(``5``)`
`)`
business_id date
64859 20150924 2
87440 20160801 2
77427 20170706 2
19 20160513 1
71416 20171213 1
dtype: int64
餐馆 ID 和检查日期的组合在这张表中唯一标识每条记录,除了三家餐馆的 ID-日期组合有两条记录。让我们检查餐馆64859的行:
`insp``.``query``(``'``business_id == 64859 and date == 20150924``'``)`
| business_id | score | date | type | |
|---|---|---|---|---|
| 7742 | 64859 | 96 | 20150924 | 常规 |
| 7744 | 64859 | 91 | 20150924 | 常规 |
这家餐馆在同一天得到了两个不同的检查分数!这怎么可能发生?可能是餐馆在一天内接受了两次检查,或者可能是一个错误。我们在考虑第九章中的数据质量时会解决这类问题。由于这种双重检查只有三次,我们可以在清理数据之前忽略这个问题。因此,如果从表中删除同一天的检查,主键将是餐馆 ID 和检查日期的组合。
请注意,检查表中的business_id字段充当对业务表主键的引用。因此,在insp中的business_id是一个外键,因为它将检查表中的每条记录链接到业务表中的一条记录。这意味着我们可以很容易地将这两个表连接在一起。
接下来,我们来检查第三个表的粒度,即包含违规的表:
| business_id | date | description | |
|---|---|---|---|
| 0 | 19 | 20171211 | 食品安全知识不足或没有... |
| 1 | 19 | 20171211 | 未经批准或未维护的设备或器具 |
| 2 | 19 | 20160513 | 未经批准或未维护的设备或器具... |
| ... | ... | ... | ... |
| 39039 | 94231 | 20171214 | 高危害害虫侵扰... |
| 39040 | 94231 | 20171214 | 中度风险食品保持温度... |
| 39041 | 94231 | 20171214 | 擦拭布不干净或未正确存放... |
39042 rows × 3 columns
查看此表中的前几条记录,我们发现每次检查都有多个条目。粒度似乎是在检查中发现的违规水平。阅读描述,我们看到如果得到纠正,描述中会列出方括号中的日期。
`viol``.``loc``[``39039``,` `'``description``'``]`
'High risk vermin infestation [ date violation corrected: 12/15/2017 ]'
简而言之,我们发现这三个食品安全表格具有不同的粒度。因为我们已经为它们确定了主键和外键,所以我们可以潜在地将这些表格连接起来。如果我们有兴趣研究检查,我们可以使用商业 ID 和检查日期将违规和检查一起连接起来。这将使我们能够将检查中发现的违规数量与检查分数联系起来。
通过选择每个餐厅最近的一次检查,我们还可以将检查表缩减为每个餐厅一个。这种精简的数据表基本上以餐厅为粒度,可能对基于餐厅的分析有用。在第九章中,我们涵盖了这些重塑数据表、转换列并创建新列的操作。
我们通过查看 DAWN 调查数据的形状和粒度来结束本节。
DAWN 调查的形状和粒度
正如本章前面提到的,DAWN 文件采用固定宽度格式,我们需要依靠代码簿查找字段的位置。例如,代码簿中的一个片段在图 8-2 中告诉我们,年龄出现在行的第 34 和 35 位置,并被分为 11 个年龄组:1 表示 5 岁及以下,2 表示 6 至 11 岁,……,11 表示 65 岁及以上。此外,-8 表示缺失值。
图 8-2. DAWN 年龄编码部分的屏幕截图
我们早些时候确定这个文件包含 200,000 行和超过 2.8 亿个字符,因此平均每行约有 1,200 个字符。这可能是他们使用固定宽度而不是 CSV 格式的原因。想象一下,如果每个字段之间都有逗号,文件会变得多么庞大!
鉴于每行都包含大量信息,我们只需将几个特征读入数据框中。我们可以使用pandas.read_fwf方法来完成这个任务。我们指定要提取的字段的确切位置,并为这些字段及其他有关标头和索引的信息提供名称:
`colspecs` `=` `[``(``0``,``6``)``,` `(``14``,``29``)``,` `(``33``,``35``)``,` `(``35``,` `37``)``,` `(``37``,` `39``)``,` `(``1213``,` `1214``)``]`
`varNames` `=` `[``"``id``"``,` `"``wt``"``,` `"``age``"``,` `"``sex``"``,` `"``race``"``,``"``type``"``]`
`dawn` `=` `pd``.``read_fwf``(``'``data/DAWN-Data.txt``'``,` `colspecs``=``colspecs``,`
`header``=``None``,` `index_col``=``0``,` `names``=``varNames``)`
| wt | age | sex | race | type | |
|---|---|---|---|---|---|
| id | |||||
| --- | --- | --- | --- | --- | --- |
| 1 | 0.94 | 4 | 1 | 2 | 8 |
| 2 | 5.99 | 11 | 1 | 3 | 4 |
| 3 | 4.72 | 11 | 2 | 2 | 4 |
| 4 | 4.08 | 2 | 1 | 3 | 4 |
| 5 | 5.18 | 6 | 1 | 3 | 8 |
我们可以将表中的行与文件中的行数进行比较:
`dawn``.``shape`
(229211, 5)
数据框中的行数与文件中的行数相匹配。这很好。由于调查设计的复杂性,数据框的粒度有点复杂。请记住,这些数据是大型科学研究的一部分,具有复杂的抽样方案。一行代表一个急诊室就诊,因此粒度是在急诊室就诊级别。然而,为了反映抽样方案并代表一年内所有与药物相关的急诊室访问的人群,提供了权重。在计算汇总统计数据、构建直方图和拟合模型时,我们必须将权重应用于每个记录。(wt字段包含这些值。)
权重考虑到这种类型的急诊室就诊出现在样本中的几率。所谓“这种类型的”是指具有类似特征的就诊,如访客年龄、种族、就诊地点和时段。让我们来检查wt中的不同值:
`dawn``[``'``wt``'``]``.``value_counts``(``)`
wt
0.94 1719
84.26 1617
1.72 1435
...
1.51 1
3.31 1
3.33 1
Name: count, Length: 3500, dtype: int64
在您的分析中包括调查权重非常关键,以获取代表大多数人口的数据。例如,我们可以比较包含和不包含权重计算的急诊女性比例:
`print``(``f``'``Unweighted percent female:` `{``np``.``average``(``dawn``[``"``sex``"``]` `==` `2``)``:``.1%``}``'``)`
`print``(``f``'` `Weighted percent female:``'``,`
`f``'``{``np``.``average``(``dawn``[``"``sex``"``]` `==` `2``,` `weights``=``dawn``[``"``wt``"``]``)``:``.1%``}``'``)`
Unweighted percent female: 48.0%
Weighted percent female: 52.3%
这些数字相差超过 4 个百分点。加权版本是女性在整个与药物相关的急诊访问人口中比例的更准确估计。
有时,像我们在检查数据中看到的那样,粒度可能很难确定。而其他时候,我们需要考虑抽样权重,比如 DAWN 数据。这些示例表明,在进行分析之前花时间审查数据描述是非常重要的。
总结
数据清洗是数据分析的重要组成部分。没有它,我们可能会忽略数据中可能对未来分析产生重大影响的问题。本章介绍了数据清洗的重要第一步:从纯文本源文件中读取数据到 Python 数据框架并确定其粒度。我们介绍了不同类型的文件格式和编码,并编写了可以从这些格式读取数据的代码。我们检查了源文件的大小,并考虑了用于处理大型数据集的替代工具。
我们还介绍了命令行工具作为检查文件格式、编码和大小的 Python 替代方案。由于其简单的语法,这些 CLI 工具在面向文件系统任务时尤为方便。我们只是触及了 CLI 工具的表面。在实践中,shell 能够进行复杂的数据处理,是值得学习的工具。
理解表的形状和粒度使我们能够洞察数据表中的一行代表什么。这有助于我们确定粒度是否混合,是否需要聚合或是否需要权重。在查看数据集的粒度后,您应该能回答以下问题:
记录代表什么?
弄清这一点将帮助您正确分析数据并陈述您的发现。
表中的所有记录是否以相同的粒度捕获?
有时,表中包含其他摘要行,其粒度不同,您希望仅使用那些具有正确细节级别的行。
如果数据已经聚合,聚合是如何执行的?
汇总和平均值是常见的聚合类型。对于平均化的数据,通常可以减少测量中的变异性,并且关系通常看起来更强。
您可能对数据执行哪些类型的聚合?
聚合可能对将一个数据表与另一个数据表合并非常有用或必要。
确定您的表的粒度是清理数据的第一步,也指导您如何分析数据。例如,我们看到 DAWN 调查的粒度是急诊就诊。这自然引导我们思考病人人口统计数据与整个美国的比较。
本章的数据整理技术帮助我们将数据从源文件导入数据框架,并了解其结构。一旦我们有了数据框架,就需要进一步整理数据,评估和提高数据质量,并为分析准备数据。我们将在下一章中涵盖这些主题。
^(1) 2020 年,该市开始向餐馆提供彩色编码的牌子,指示餐馆是否通过(绿色)、有条件通过(黄色)或未通过(红色)检查。这些新的牌子不再显示数字检查得分。然而,餐馆的得分和违规仍然可以在 DataSF 上查看。
第九章:整理数据框架
我们通常需要在分析之前对数据进行准备工作。准备工作的量可能差异很大,但从原始数据到准备好进行分析的数据,有几个基本步骤。第八章 讨论了从纯文本源创建数据框架的初始步骤。在本章中,我们评估数据的质量。为此,我们对单个数据值和整个列执行有效性检查。除了检查数据的质量外,我们还确定数据是否需要转换和重塑以准备进行分析。质量检查(和修复)以及转换通常是循环的:质量检查指导我们进行必要的转换,当我们检查转换后的列以确认数据准备好进行分析时,我们可能会发现它们需要进一步清理。
根据数据源的不同,我们对质量有不同的期望。一些数据集可能需要大量的整理工作才能使其达到可分析的形式,而其他数据可能已经很干净,我们可以直接进行建模。以下是一些数据源的示例以及我们可能预期进行的整理工作量:
-
来自科学实验或研究的数据通常是干净的,有良好的文档记录,并且具有简单的结构。这些数据被组织成可以广泛分享的形式,以便其他人可以在其基础上建立或重现发现。通常情况下,经过少量或无需整理后,即可进行分析。
-
政府调查数据通常附有非常详细的代码书和描述数据收集及格式化方式的元数据,这些数据集通常也是即开即用的,可以直接进行探索和分析。
-
行政数据可能是干净的,但如果没有关于数据源的内部知识,我们可能需要广泛检查它们的质量。此外,由于我们经常将这些数据用于与最初收集它们的目的不同的用途,我们可能需要转换特征或合并数据表。
-
从网页抓取等非正式收集的数据通常会非常混乱,并且往往缺乏文档记录。例如,文本、推特、博客和维基百科表格通常需要格式化和清理,才能将它们转化为可以分析的信息。
在本章中,我们将数据整理分解为以下几个阶段:评估数据质量,处理缺失值,转换特征,并通过修改其结构和粒度来重塑数据。评估数据质量的重要步骤是考虑其范围。数据范围已在第二章中介绍,我们建议您参考该章节以获取更详细的内容。
要清理和准备数据,我们还依赖探索性数据分析,尤其是可视化。然而,在本章中,我们专注于数据整理,并将更详细地讨论这些其他相关主题,这些内容在第 10 和第十一章节中。
我们使用在第八章中介绍的数据集:DAWN 政府调查与药物滥用有关的急诊室访问情况,以及旧金山餐馆食品安全检查的行政数据。但我们首先通过另一个足够简单且干净的例子介绍各种数据整理概念,以便我们可以在每个整理步骤中集中精力。
示例:从毛纳罗亚观测站收集二氧化碳(CO[2])测量数据
我们在第二章中看到,国家海洋和大气管理局(NOAA)监测毛纳罗亚观测站空气中的 CO[2]浓度。我们继续以此为例,介绍如何进行数据质量检查、处理缺失值、转换特征和重塑表格。这些数据位于文件data/co2_mm_mlo.txt中。在将其加载到数据框之前,让我们先了解源数据的格式、编码和大小(见第八章):
`from` `pathlib` `import` `Path`
`import` `os`
`import` `chardet`
`co2_file_path` `=` `Path``(``'``data``'``)` `/` `'``co2_mm_mlo.txt``'`
`[``os``.``path``.``getsize``(``co2_file_path``)``,`
`chardet``.``detect``(``co2_file_path``.``read_bytes``(``)``)``[``'``encoding``'``]``]`
[51131, 'ascii']
我们发现文件是纯文本,使用 ASCII 编码,大小约为 50 KiB。由于文件并不特别大,因此我们应该可以轻松地将其加载到数据框中,但首先需要确定文件的格式。让我们先看一下文件的前几行:
`lines` `=` `co2_file_path``.``read_text``(``)``.``split``(``'``\n``'``)`
`len``(``lines``)`
811
`lines``[``:``6``]`
['# --------------------------------------------------------------------',
'# USE OF NOAA ESRL DATA',
'# ',
'# These data are made freely available to the public and the',
'# scientific community in the belief that their wide dissemination',
'# will lead to greater understanding and new scientific insights.']
我们看到文件以数据源信息开头。在开始分析之前,我们应该先阅读这些文档,但有时沉浸于分析中的冲动会胜过一切,我们会开始随意地发现数据的各种属性。所以让我们快速找出实际数据值的位置:
`lines``[``69``:``75``]`
['#',
'# decimal average interpolated trend #days',
'# date (season corr)',
'1958 3 1958.208 315.71 315.71 314.62 -1',
'1958 4 1958.292 317.45 317.45 315.29 -1',
'1958 5 1958.375 317.50 317.50 314.71 -1']
我们发现数据从文件的第 73 行开始。我们还发现了一些相关特征:
-
值由空白分隔,可能是制表符。
-
数据以精确的列对齐。例如,每行的第七到第八位置出现了月份。
-
列标题分为两行。
我们可以使用read_csv将数据读入pandas的DataFrame中,并提供参数指定分隔符是空白、没有表头(我们将设置自己的列名),并跳过文件的前 72 行:
`co2` `=` `pd``.``read_csv``(``'``data/co2_mm_mlo.txt``'``,`
`header``=``None``,` `skiprows``=``72``,` `sep``=``'``\``s+``'``,`
`names``=``[``'``Yr``'``,` `'``Mo``'``,` `'``DecDate``'``,` `'``Avg``'``,` `'``Int``'``,` `'``Trend``'``,` `'``days``'``]``)`
`co2``.``head``(``3``)`
| 年 | 月 | 日期 | 平均 | 股息 | 趋势 | 天数 | |
|---|---|---|---|---|---|---|---|
| 0 | 1958 | 3 | 1958.21 | 315.71 | 315.71 | 314.62 | -1 |
| 1 | 1958 | 4 | 1958.29 | 317.45 | 317.45 | 315.29 | -1 |
| 2 | 1958 | 5 | 1958.38 | 317.50 | 317.50 | 314.71 | -1 |
我们已成功将文件内容加载到数据框中,可以看出数据的粒度是 1958 年至 2019 年的月均 CO[2]浓度。此外,表格形状为 738 行 7 列。
由于科学研究往往具有非常干净的数据,我们很容易就跳进去绘制一张 CO[2]月均值如何变化的图表。字段DecDate方便地将月份和年份表示为数值特征,因此我们可以轻松地制作一张折线图:
px.line(co2, x='DecDate', y='Avg', width=350, height=250,
labels={'DecDate':'Date', 'Avg':'Average monthly CO₂'})
哎呀!绘制数据后发现了一个问题。折线图中的四个低谷看起来很奇怪。这里发生了什么?我们可以检查数据帧的一些百分位数,看看是否能找出问题:
`co2``.``describe``(``)``[``3``:``]`
| Yr | Mo | DecDate | Avg | Int | Trend | days | |
|---|---|---|---|---|---|---|---|
| min | 1958.0 | 1.0 | 1958.21 | -99.99 | 312.66 | 314.62 | -1.0 |
| 25% | 1973.0 | 4.0 | 1973.56 | 328.59 | 328.79 | 329.73 | -1.0 |
| 50% | 1988.0 | 6.0 | 1988.92 | 351.73 | 351.73 | 352.38 | 25.0 |
| 75% | 2004.0 | 9.0 | 2004.27 | 377.00 | 377.00 | 377.18 | 28.0 |
| max | 2019.0 | 12.0 | 2019.62 | 414.66 | 414.66 | 411.84 | 31.0 |
这一次,我们更仔细地查看数值范围,发现一些数据有异常值,如-1和-99.99。如果我们仔细阅读文件顶部的信息,我们会发现-99.99表示缺失的月平均值,-1表示设备当月运行天数的缺失值。即使数据相对干净,也应该在进入分析阶段前阅读文档并进行一些质量检查是个好习惯。
质量检查
让我们暂停一会儿,进行一些质量检查。我们可以确认我们拥有预期的观测数量,寻找异常值,并将发现的异常与其他特征的值进行交叉验证。
首先,我们考虑数据的形状。我们应该有多少行数据?从数据框的头部和尾部看,数据按时间顺序排列,从 1958 年 3 月开始,到 2019 年 8 月结束。这意味着我们应该有 12 × ( 2019 − 1957 ) − 2 − 4 = 738 条记录,我们可以与数据框的形状进行对比:
`co2``.``shape`
(738, 7)
我们的计算与数据表中的行数匹配。
接下来,让我们检查特征的质量,从Mo开始。我们期望值在 1 到 12 之间,每个月应有 2019-1957=62 或 61 个实例(因为记录从第一年的三月开始,到最近一年的八月结束):
`co2``[``"``Mo``"``]``.``value_counts``(``)``.``reindex``(``range``(``1``,``13``)``)``.``tolist``(``)`
[61, 61, 62, 62, 62, 62, 62, 62, 61, 61, 61, 61]
如预期,一月、二月、九月、十月、十一月和十二月各有 61 次出现,其余 62 次。
现在让我们用直方图检查名为days的列:
`px``.``histogram``(``co2``,` `x``=``'``days``'``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``days``'``:``'``Days operational in a month``'``}``)`
我们发现有少数几个月份的平均值是基于少于一半天数的测量值。此外,有近 200 个缺失值。散点图可以帮助我们交叉检查缺失数据与记录年份:
`px``.``scatter``(``co2``,` `x``=``'``Yr``'``,` `y``=``'``days``'``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``Yr``'``:``'``Year``'``,` `'``days``'``:``'``Days operational in month``'` `}``)`
图表底部的左侧线条显示,所有缺失数据都在设备运行初期。设备运行天数可能在早期并未收集。此外,从 80 年代中期到 80 年代末,设备可能存在问题。针对这些推测,我们该如何处理呢?我们可以通过查阅历史记录的文件来确认这些推测。如果我们担心缺失设备运行天数的记录对 CO[2]平均值的影响,那么一个简单的解决方案是删除最早的记录。不过,在我们检查时间趋势并评估这些早期天数是否存在潜在问题之后再采取行动会更好。
接下来,让我们再次关注平均 CO[2]测量值中的-99.99值,并从直方图开始我们的检查:
`px``.``histogram``(``co2``,` `x``=``'``Avg``'``,` `width``=``350``,` `height``=``250``,`
`labels``=``{``'``Avg``'``:``'``Average monthly CO₂``'``}``)`
根据我们对二氧化碳(CO[2])水平的研究,记录的数值在 300 至 400 的范围内,这符合我们的预期。我们还注意到只有少量的缺失数值。由于缺失值不多,我们可以检查所有这些值:
`co2``[``co2``[``"``Avg``"``]` `<` `0``]`
| Yr | Mo | DecDate | Avg | Int | Trend | days | |
|---|---|---|---|---|---|---|---|
| 3 | 1958 | 6 | 1958.46 | -99.99 | 317.10 | 314.85 | -1 |
| 7 | 1958 | 10 | 1958.79 | -99.99 | 312.66 | 315.61 | -1 |
| 71 | 1964 | 2 | 1964.12 | -99.99 | 320.07 | 319.61 | -1 |
| 72 | 1964 | 3 | 1964.21 | -99.99 | 320.73 | 319.55 | -1 |
| 73 | 1964 | 4 | 1964.29 | -99.99 | 321.77 | 319.48 | -1 |
| 213 | 1975 | 12 | 1975.96 | -99.99 | 330.59 | 331.60 | 0 |
| 313 | 1984 | 4 | 1984.29 | -99.99 | 346.84 | 344.27 | 2 |
我们面临的问题是如何处理-99.99的数值。我们已经看到在折线图中保留这些数值会带来问题。有几种选择,我们接下来会描述它们。
处理缺失数据
平均 CO[2]水平中的-99.99表示缺失记录。这些值影响了我们的统计摘要和图表。知道哪些值是缺失的很重要,但我们需要采取措施。我们可以删除这些记录,用NaN替换-99.99,或者用一个可能的平均 CO[2]值替换-99.99。让我们逐个检查这三种选择。
注意,表格中已经有了一个替代值来代替-99.99。标记为Int的列中的值与Avg中的值完全相同,只有当Avg为-99.99时,才会使用“合理”的估计值。
为了看清每种选择的影响,让我们放大一个短时间段,比如说 1958 年的测量数据,我们知道在这里有两个缺失值。我们可以为三种情况创建一个时间序列图:删除带有-99.99的记录(左侧图)、使用NaN表示缺失值(中间图)、替换-99.99为估计值(右侧图):
仔细观察时,我们可以看到每个图表之间的差异。最左边的图表连接了一个两个月的时间段内的点,而不是一个月。在中间的图表中,数据缺失处断开了线,而在右边,我们可以看到第 6 和第 10 月现在有值了。总体上来说,由于 738 个月中只有七个值缺失,所有这些选项都有效。然而,右图更吸引人的地方在于季节性趋势更清晰可辨。
用于插值 CO[2] 测量值的方法是考虑到月份和年份的平均处理过程。其思想是反映季节性变化和长期趋势。这一技术在数据文件顶部的文档中有更详细的描述。
这些图表显示数据的粒度为每月测量,但我们还可以选择其他粒度选项。接下来我们将讨论这一点。
数据表重塑
毛纳罗亚观测站获取的 CO[2] 测量数据还有每天和每小时的数据。每小时数据的粒度更细,而每日数据则比每小时数据粗。
为什么不总是使用最精细的数据粒度?在计算层面上,细粒度数据可能会变得非常大。毛纳罗亚观测站从 1958 年开始记录 CO[2] 水平。想象一下,如果该设施每秒提供一次测量,数据表会包含多少行!但更重要的是,我们希望数据的粒度与我们的研究问题相匹配。假设我们想要查看过去 50 多年来 CO[2] 水平是否上升,这与全球变暖预测一致。我们并不需要每秒一次的 CO[2] 测量。事实上,我们可能对年均值感到满意,因为这样可以平滑掉季节模式。我们可以聚合每月测量值,将粒度更改为年均值,并制作一个图表显示总体趋势。我们可以使用聚合来转向更粗粒度——在pandas中,我们使用.groupby()和.agg():
的确,自 1958 年以来,毛纳罗亚观测站记录的 CO[2] 测量值上升了将近 100 ppm。
总结一下,在将空格分隔的纯文本文件读入数据框后,我们开始检查其质量。我们使用数据的范围和上下文来确认其形状是否与收集日期的范围匹配。我们确认了月份的值和计数是否符合预期。我们确定了功能中缺失值的程度,并查找缺失值与其他功能之间的关系。我们考虑了三种处理缺失数据的方法:删除记录、处理NaN值和填补值以获得完整的表格。最后,我们通过将数据框的粒度从每月平均值升级到年度平均值来改变数据的粒度。这种粒度变化消除了季节性波动,并集中在大气中 CO[2]水平的长期趋势上。本章的接下来四个部分将扩展这些操作,将数据整理成适合分析的形式:质量检查、缺失值处理、转换和形状调整。我们从质量检查开始。
质量检查
一旦您的数据进入表格,并且您理解了范围和粒度,就是检查质量的时候了。在您检查和整理文件到数据框时,您可能会发现源数据中的错误。在本节中,我们描述如何继续这一检查,并进行更全面的功能和值质量评估。我们从四个角度考虑数据质量:
范围
数据是否与您对人口的理解相匹配?
测量和值
值是否合理?
关系
相关特征是否一致?
分析
哪些功能可能在未来的分析中有用?
我们依次描述每个点,从范围开始。
基于范围的质量
在第二章中,我们讨论了收集的数据是否能够充分解决当前问题。在那里,我们确定了目标人口、访问框架和样本收集数据。该框架帮助我们考虑可能影响研究结果普适性的潜在限制。
虽然在我们审议最终结论时,这些更广泛的数据范围考虑是重要的,但它们也有助于检查数据质量。例如,在第八章介绍的旧金山餐厅检查数据中,一项侧面调查告诉我们,城市的邮政编码应以 941 开头。但快速检查显示,有几个邮政编码以其他数字开头:
`bus``[``'``postal_code``'``]``.``value_counts``(``)``.``tail``(``10``)`
92672 1
64110 1
94120 1
..
94621 1
941033148 1
941 1
Name: postal_code, Length: 10, dtype: int64
使用范围进行的这种验证有助于我们发现潜在问题。
另一个例子是,在Climate.gov和NOAA上关于大气 CO[2]的背景阅读中,Typical measurements 约为全球 400 ppm。因此,我们可以检查夏威夷火山月均 CO[2]浓度是否介于 300 到 450 ppm 之间。
接下来,我们将数据值与代码手册等进行比对。
测量和记录值的质量
我们还可以通过考虑特征的合理值来检查测量的质量。例如,想象一下餐厅检查中违规数量的合理范围可能是 0 到 5. 其他检查可以基于常识的范围:餐厅检查分数必须在 0 到 100 之间;月份必须在 1 到 12 之间. 我们可以使用文档来告诉我们特征的预期值。例如,在 DAWN 调查中的急诊室访问类型,介绍在第八章,已编码为 1、2、...、8(参见图 9-1)。因此,我们可以确认访问类型的所有值确实是介于 1 到 8 之间的整数。
图 9-1. DAWN 调查中急诊室访问类型(CASETYPE)变量描述的屏幕截图(实际代码书中出现了拼写错误 SUICICDE)
我们还希望确保数据类型符合我们的预期。例如,我们希望价格是一个数字,无论它是存储为整数、浮点数还是字符串。确认测量单位与预期相符可以是另一个有用的质量检查(例如,以磅为单位记录的重量值,而不是公斤)。我们可以为所有这些情况设计检查。
可以通过比较两个相关特征来设计其他检查。
相关特征的质量
有时,两个特征对其值有内置条件,我们可以交叉检查其内部一致性。例如,根据 DAWN 研究的文档,饮酒只被认为是年龄在 21 岁以下的患者急诊访问的有效原因,因此我们可以检查任何记录中“饮酒”类型的访问是否年龄在 21 岁以下。 type 和 age 的交叉表可以确认满足此约束:
display_df(pd.crosstab(dawn['age'], dawn['type']), rows=12)
| 类型 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| 年龄 | ||||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| -8 | 2 | 2 | 0 | 21 | 5 | 1 | 1 | 36 |
| 1 | 0 | 6 | 20 | 6231 | 313 | 4 | 2101 | 69 |
| 2 | 8 | 2 | 15 | 1774 | 119 | 4 | 119 | 61 |
| 3 | 914 | 121 | 2433 | 2595 | 1183 | 48 | 76 | 4563 |
| 4 | 817 | 796 | 4953 | 3111 | 1021 | 95 | 44 | 6188 |
| 5 | 983 | 1650 | 0 | 4404 | 1399 | 170 | 48 | 9614 |
| 6 | 1068 | 1965 | 0 | 5697 | 1697 | 140 | 62 | 11408 |
| 7 | 957 | 1748 | 0 | 5262 | 1527 | 100 | 60 | 10296 |
| 8 | 1847 | 3411 | 0 | 10221 | 2845 | 113 | 115 | 18366 |
| 9 | 1616 | 3770 | 0 | 12404 | 3407 | 75 | 150 | 18381 |
| 10 | 616 | 1207 | 0 | 12291 | 2412 | 31 | 169 | 7109 |
| 11 | 205 | 163 | 0 | 24085 | 2218 | 12 | 308 | 1537 |
交叉表确认所有酒精案例(type为 3)年龄在 21 岁以下(这些编码为 1、2、3 和 4)。数据值符合预期。
最后一种质量检查类型涉及特征中所含信息的量。
分析质量
即使数据通过了之前的质量检查,它的有效性仍然可能存在问题。例如,如果一个特征的几乎所有值都相同,那么这个特征对于理解底层模式和关系的贡献就很少。或者如果存在太多缺失值,尤其是在缺失值中存在可辨识的模式时,我们的发现可能会受限。此外,如果一个特征有许多坏/损坏的值,那么我们可能会质疑即使在适当范围内的那些值的准确性。
我们在下面的代码中看到,旧金山的餐馆检查类型可以是例行或投诉。由于 14,000 多次检查中只有一次是投诉,如果我们放弃这个特征,我们几乎不会损失什么,而且我们可能也想删除那个单独的检查,因为它代表了一个异常:
`pd``.``value_counts``(``insp``[``'``type``'``]``)`
routine 14221
complaint 1
Name: type, dtype: int64
一旦我们发现数据的问题,我们需要弄清楚该如何处理。
数据修复与否
当你揭示数据的问题时,基本上你有四个选择:保留数据如其所是,修改数值,移除特征,或删除记录。
保留数据如其所是
并非数据的每一个异常方面都需要修正。你可能已经发现了数据的一个特征,它将告诉你如何进行分析,而且不需要修正。或者你可能发现问题相对较小,很可能不会影响你的分析,因此你可以保留数据。或者,你可能希望用NaN替换损坏的数值。
修改单个数值
如果你已经找出了问题所在并可以修正数值,那么你可以选择进行更改。在这种情况下,创造一个带有修改数值的新特征,并保留原始特征是一个好的做法,就像二氧化碳(CO[2])的例子中那样。
移除一列
如果一个特征中的许多值存在问题,那么考虑完全消除该特征。与排除一个特征不同,可能存在一种转换可以使你保留该特征同时降低记录的详细级别。
删除记录
一般而言,我们不希望无故从数据集中删除大量观察结果。相反,尝试将你的调查范围缩小到某个明确定义的数据子集,而不是简单地对应着删除带有损坏数值的记录。当你发现一个异常值实际上是正确的时候,你可能仍然决定将该记录排除在你的分析之外,因为它与你的其他数据有显著不同,而你不希望它过度影响你的分析。
无论你采取什么方法,你都需要研究你所做改变对分析的可能影响。例如,尝试确定带有损坏数值的记录是否彼此相似,并且与其他数据不同。
质量检查可以揭示需要在进行分析之前解决的数据问题。一种特别重要的检查类型是查找缺失值。我们建议有时您可能希望将损坏的数据值替换为NaN,因此将其视为缺失。在其他时候,数据可能会缺失。如何处理缺失数据是一个重要的话题,有很多研究在解决这个问题;我们将在下一节中介绍处理缺失数据的方法。
缺失值与记录
在第三章中,我们考虑了当人群和访问框架不对齐时可能出现的问题,因此我们无法访问我们想要研究的所有人。我们还描述了当有人拒绝参与研究时可能出现的问题。在这些情况下,整个记录/行可能会丢失,并且我们讨论了由于缺失记录可能出现的偏差类型。如果未响应者在关键方面与响应者不同,或者非响应率不可忽略,则我们的分析可能会严重有误。第三章中关于选举民意测验的例子表明,增加样本大小而不解决非响应问题并不会减少非响应偏差。此外,在该章中,我们讨论了预防非响应的方法。这些预防措施包括使用激励措施鼓励响应,保持调查简短,编写清晰的问题,培训访问员,并投入广泛的后续程序。不幸的是,尽管这些努力,一定程度的非响应是不可避免的。
当记录不完全丢失,但记录中的特定字段不可用时,我们称之为字段级的非响应。一些数据集使用特殊编码来表示信息丢失的情况。我们发现毛纳罗亚数据使用-99.99表示缺失的 CO[2]测量。在表中的 738 行中,我们只发现了七个这样的值。在这种情况下,我们表明这些缺失值对分析影响不大。
特征的值被称为完全随机缺失,当缺失数据的记录就像随机选择的记录子集时。也就是说,记录是否缺失不依赖于未观察到的特征、其他特征的值或抽样设计。例如,如果有人在毛纳罗亚意外损坏了实验设备,导致某天未记录 CO[2],那么没有理由认为那天的 CO[2]水平与丢失的测量有关。
在其他时候,我们考虑给定协变量缺失随机的值(协变量是数据集中的其他特征)。例如,在 DAWN 调查中,急诊访问类型在给定协变量情况下是随机缺失的,如果,例如,非响应仅依赖于种族和性别(而不依赖于访问类型或其他任何因素)。在这些有限的情况下,可以对观察数据进行加权以适应非响应。
在某些调查中,缺失信息进一步分类为受访者拒绝回答、受访者不确定答案或面试官未问问题。每种类型的缺失值使用不同的值记录。例如,根据代码书,DAWN 调查中的许多问题使用-7表示不适用,-8表示未记录,-9表示缺失。这些编码可以帮助我们进一步完善非响应的研究。
在非响应发生后,有时可以使用模型预测缺失的数据。我们接下来描述这个过程。但请记住,预测缺失的观察结果永远不如首次观察到它们好。
有时,我们会为缺失的值替换一个合理的值,以创建一个“干净”的数据框架。这个过程称为填补。填补值的一些常见方法包括演绎、均值和热卡填补。
在演绎填补中,我们通过与其他特征的逻辑关系填补值。例如,这是旧金山餐馆检查的业务数据框架中的一行。邮政编码错误地标记为“Ca”,纬度和经度缺失:
`bus``[``bus``[``'``postal_code``'``]` `==` `"``Ca``"``]`
| business_id | name | address | city | ... | postal_code | latitude | longitude | phone_number | |
|---|---|---|---|---|---|---|---|---|---|
| 5480 | 88139 | TACOLICIOUS | 2250 CHESTNUT ST | San Francisco | ... | Ca | NaN | NaN | +14156496077 |
1 row × 9 columns
我们可以在 USPS 网站上查找地址以获取正确的邮政编码,并可以使用 Google Maps 查找餐馆的纬度和经度来填补这些缺失值。
均值填补使用数据集中非缺失行的平均值。例如,如果一个测试分数数据集中一些学生的分数缺失,均值填补将使用非缺失分数的平均值填补缺失值。均值填补的一个关键问题是,由于该特征现在具有与均值相同的值,因此填补后特征的变异性将较小。如果不正确处理,这将影响后续分析,例如,置信区间将比预期小(这些主题在第十七章中有详细介绍)。在马乌纳罗亚的 CO[2]的缺失值中,使用了更复杂的平均技术,其中包括邻近的季节性值。
热卡填补使用机会过程从具有值的行中随机选择一个值。例如,热卡填补可以通过随机选择数据集中的另一个测试分数来填补缺失的测试分数。热卡填补的一个潜在问题是,特征之间的关系强度可能会因为我们增加了随机性而减弱。
对于均值和热补卡填补,我们通常基于数据集中具有其他特征中类似值的记录来填补值。更复杂的填补技术使用最近邻方法来找到相似记录子组,其他技术使用回归技术来预测缺失值。
在所有这些填补类型中,我们应该创建一个包含修改后数据的新特征,或者创建一个新特征来指示原始特征中的响应是否已被填补,以便我们可以跟踪我们的更改。
决定保留或丢弃具有缺失值的记录、更改值或删除特征可能看起来微不足道,但它们可能至关重要。一个异常记录可能严重影响您的发现。无论您做出什么决定,都要确保检查删除或更改特征和记录的影响。在报告您对数据所做修改时,一定要透明和彻底。最好通过编程方式进行这些更改,以减少潜在错误,并使其他人能够通过审查您的代码确认您所做的确切更改。
数据转换也需要同样的透明度和可重现性预防措施,接下来我们会讨论这些。
转换和时间戳
有时特征的形式不适合分析,因此我们对其进行转换。特征可能需要转换的原因有很多:值编码可能对分析无用,我们可能想对特征应用数学函数,或者我们可能想从特征中提取信息并创建新特征。我们描述了这三种基本类型的转换:类型转换、数学转换和提取:
类型转换
这种转换发生在我们将数据从一种格式转换为另一种格式以使数据更适合分析时。我们可能会将存储为字符串的信息转换为另一种格式。例如,我们可能希望将报价字符串"$2.17"转换为数字 2.17,以便计算汇总统计数据。或者我们可能希望将存储为字符串的时间,如"1955-10-12",转换为pandas Timestamp对象。另一个示例是在将类别合并在一起时发生,例如将 DAWN 中的 11 个年龄类别减少为 5 个分组。
数学转换
数学转换的一种类型是当我们从一个测量单位,比如从磅到公斤,进行单位转换。我们可能进行单位转换,以便我们的数据统计可以直接与其他数据集的统计进行比较。进行特征转换的另一个原因是使其分布更对称(这个概念在第十章中有更详细的介绍)。处理不对称性最常见的转换是对数。最后,我们可能希望通过算术运算创建一个新的特征。例如,我们可以结合身高和体重,通过计算 height / weight 2 来创建身体质量指数。
提取
有时候我们想通过提取创建一个特征,新特征包含从另一个特征中提取的部分信息。例如,检查违规行为包含违规描述的字符串,我们可能只关心违规是否涉及,比如,害虫。如果违规描述中包含单词 vermin,我们可以创建一个新特征,如果是,则为True,否则为False。将信息转换为逻辑值(或 0-1 值)在数据科学中非常有用。本章中即将介绍的示例为这些二元特征提供了一个具体的用例。
我们在第十章中涵盖了许多其他有用转换的示例。在本节的其余部分,我们解释了与处理日期和时间相关的另一种转换方式。日期和时间出现在许多类型的数据中,因此学习如何处理这些数据类型是值得的。
转换时间戳
时间戳 是记录特定日期和时间的数据值。例如,时间戳可以记录为 Jan 1 2020 2pm 或 2021-01-31 14:00:00 或 2017 Mar 03 05:12:41.211 PDT。时间戳有许多不同的格式!这种信息对于分析非常有用,因为它让我们能够回答诸如“一天中哪个时段的网站流量最高?”的问题。当我们处理时间戳时,通常需要对其进行解析以便于分析。
让我们看一个例子。旧金山餐馆的检查数据包括餐厅检查发生的日期:
`insp``.``head``(``4``)`
| business_id | score | date | type | |
|---|---|---|---|---|
| 0 | 19 | 94 | 20160513 | routine |
| 1 | 19 | 94 | 20171211 | routine |
| 2 | 24 | 98 | 20171101 | routine |
| 3 | 24 | 98 | 20161005 | routine |
默认情况下,pandas将date列读取为整数:
`insp``[``'``date``'``]``.``dtype`
dtype('int64')
这种存储类型使得回答一些有用的数据问题变得困难。假设我们想知道检查是否更频繁发生在周末还是工作日。为了回答这个问题,我们想将date列转换为pandas的Timestamp存储类型,并提取星期几。
日期值似乎采用YYYYMMDD格式,其中YYYY、MM和DD分别对应四位数年份、两位数月份和两位数日期。pd.to_datetime()方法可以将日期字符串解析为对象,我们可以传入日期格式作为日期格式字符串:
`date_format` `=` `'``%Y``%m``%d``'`
`insp_dates` `=` `pd``.``to_datetime``(``insp``[``'``date``'``]``,` `format``=``date_format``)`
`insp_dates``[``:``3``]`
0 2016-05-13
1 2017-12-11
2 2017-11-01
Name: date, dtype: datetime64[ns]
现在我们可以看到insp_dates现在具有datetime64[ns]的dtype,这意味着值已成功转换为pd.Timestamp对象。^(1)
pandas为使用.dt访问器保持时间戳的Series对象提供了特殊方法和属性。例如,我们可以轻松地提取每个时间戳的年份:
`insp_dates``.``dt``.``year``[``:``3``]`
0 2016
1 2017
2 2017
Name: date, dtype: int32
pandas文档详细介绍了.dt访问器的所有细节。通过查看文档,我们可以看到.dt.day_of_week属性获取每个时间戳的星期几(星期一=0,星期二=1,…,星期日=6)。因此,让我们向数据框中分配新列,这些列包含解析的时间戳和星期几:
`insp` `=` `insp``.``assign``(``timestamp``=``insp_dates``,`
`dow``=``insp_dates``.``dt``.``dayofweek``)`
`insp``.``head``(``3``)`
| business_id | score | date | type | timestamp | dow | |
|---|---|---|---|---|---|---|
| 0 | 19 | 94 | 20160513 | routine | 2016-05-13 | 4 |
| 1 | 19 | 94 | 20171211 | routine | 2017-12-11 | 0 |
| 2 | 24 | 98 | 20171101 | routine | 2017-11-01 | 2 |
现在我们可以看出,餐厅检查员是否偏爱某一周的某一天,通过对星期几进行分组来实现:
`insp``[``'``dow``'``]``.``value_counts``(``)``.``reset_index``(``)`
| dow | count | |
|---|---|---|
| 0 | 2 | 3281 |
| 1 | 1 | 3264 |
| 2 | 3 | 2497 |
| 3 | 0 | 2464 |
| 4 | 4 | 2101 |
| 5 | 6 | 474 |
| 6 | 5 | 141 |
正如预期的那样,检查很少在周末进行。我们还发现星期二和星期三是最受欢迎的检查日。
我们已经对检查表执行了许多操作。跟踪这些修改的一种方法是将这些操作从一个操作到下一个进行管道传输。接下来我们将讨论管道的概念。
转换管道
在数据分析中,我们通常对数据应用许多转换,当我们反复变异数据框时,很容易引入错误,部分原因是 Jupyter 笔记本允许我们按任何顺序运行单元格。作为良好的实践,我们建议将转换代码放入具有有用名称的函数中,并使用DataFrame.pipe()方法将转换链接在一起。
让我们将早期的时间戳解析代码重写为函数,并将时间戳作为新列添加回数据框中,同时添加第二列,其中包含时间戳的年份:
`date_format` `=` `'``%Y``%m``%d``'`
`def` `parse_dates_and_years``(``df``,` `column``=``'``date``'``)``:`
`dates` `=` `pd``.``to_datetime``(``df``[``column``]``,` `format``=``date_format``)`
`years` `=` `dates``.``dt``.``year`
`return` `df``.``assign``(``timestamp``=``dates``,` `year``=``years``)`
现在我们可以使用.pipe()方法将insp数据框通过此函数管道化:
`insp` `=` `(``pd``.``read_csv``(``"``data/inspections.csv``"``)`
`.``pipe``(``parse_dates_and_years``)``)`
我们可以链接许多.pipe()调用在一起。例如,我们可以从时间戳中提取星期几:
`def` `extract_day_of_week``(``df``,` `col``=``'``timestamp``'``)``:`
`return` `df``.``assign``(``dow``=``df``[``col``]``.``dt``.``day_of_week``)`
`insp` `=` `(``pd``.``read_csv``(``"``data/inspections.csv``"``)`
`.``pipe``(``parse_dates_and_years``)`
`.``pipe``(``extract_day_of_week``)``)`
`insp`
| business_id | score | date | type | timestamp | year | dow | |
|---|---|---|---|---|---|---|---|
| 0 | 19 | 94 | 20160513 | routine | 2016-05-13 | 2016 | 4 |
| 1 | 19 | 94 | 20171211 | 日常 | 2017-12-11 | 2017 | 0 |
| 2 | 24 | 98 | 20171101 | 日常 | 2017-11-01 | 2017 | 2 |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 14219 | 94142 | 100 | 20171220 | 日常 | 2017-12-20 | 2017 | 2 |
| 14220 | 94189 | 96 | 20171130 | 日常 | 2017-11-30 | 2017 | 3 |
| 14221 | 94231 | 85 | 20171214 | 日常 | 2017-12-14 | 2017 | 3 |
14222 rows × 7 columns
使用 pipe() 的几个关键优势。当在单个数据框上有许多转换时,我们可以更容易地看到发生了哪些转换,因为我们只需读取函数名。此外,我们可以将转换函数重用于不同的数据框。例如,viol 数据框包含有关餐厅安全违规的信息,同时也有一个 date 列。这意味着我们可以使用 .pipe() 重新使用时间戳解析函数,而无需编写额外的代码。方便!
`viol` `=` `(``pd``.``read_csv``(``"``data/violations.csv``"``)`
`.``pipe``(``parse_dates_and_years``)``)`
`viol``.``head``(``2``)`
| business_id | date | description | timestamp | year | |
|---|---|---|---|---|---|
| 0 | 19 | 20171211 | 食品安全知识不足或缺乏 ce… | 2017-12-11 | 2017 |
| 1 | 19 | 20171211 | 未批准或未维护的设备或器具 | 2017-12-11 | 2017 |
另一种转换方式是通过删除不需要的列、获取行的子集或将行滚动到更粗粒度来改变数据框的形状。接下来我们描述这些结构变化。
修改结构
如果一个数据框的结构不方便,我们可能很难进行我们想要的分析。整理过程通常以某种方式重塑数据框,以使分析更容易和更自然。这些变化可以简单地从表中获取一部分行和/或列,或者以更基本的方式改变表的粒度。在本节中,我们使用 第六章 中的技术来展示如何以以下方式修改结构:
简化结构
如果一个数据框有不需要在我们分析中的特征,那么我们可能希望删除这些多余的列,以便更轻松地处理数据框。或者,如果我们想专注于特定时间段或地理区域,我们可能希望获取行的子集(子集在 第六章 中有所介绍)。在 第八章 中,我们将从 DAWN 调查中的数百个特征中读取数据框的一个小集合,因为我们有兴趣了解患者人口学特征对急诊访问类型模式的影响。在 第十章 中,我们将限制对家庭销售价格的分析到一年和几个城市,以减少通货膨胀的影响,并更好地研究位置对销售价格的影响。
调整粒度
在本章的早些示例中,CO[2]测量结果已从月平均值聚合到年平均值,以更好地可视化年度趋势。在接下来的部分中,我们提供另一个示例,其中我们将违规级别数据聚合到检查级别,以便与餐厅检查分数合并。在这两个示例中,我们调整了数据框的粒度,通过分组记录和聚合值来处理更粗略的粒度。对于 CO[2]测量结果,我们对同一年的月值进行了分组然后求平均值。其他常见的组合方式包括记录数、总和、最小值、最大值以及组内的第一个或最后一个值。有关如何调整pandas数据框的详细信息可以在第六章找到,包括如何按多列值进行分组。
处理混合粒度
有时,数据集可能存在混合粒度的情况,即记录处于不同的详细级别。政府机构提供的数据中常见的情况是在同一文件中包含县级和州级的数据。发生这种情况时,我们通常希望将数据框拆分为两个部分,一个是县级的,另一个是州级的。这样可以使县级和州级分析更加容易,甚至可行。
重塑结构
数据,尤其是来自政府来源的数据,可以作为数据透视表进行共享。这些宽表格以数据值作为列名,通常在分析中难以使用。我们可能需要将它们重塑为长格式。图 9-2 展示了相同数据存储在宽和长数据表中的情况。宽数据表的每一行对应长数据表中的三行,如表中所示。请注意,在宽数据表中,每一行有三个值,分别对应每个月。而在长数据表中,每一行只有一个月的值。长数据表通常更容易聚合以供未来分析使用。因此,长格式数据也经常被称为整洁数据。
图 9-2. 宽数据表(顶部)和长数据表(底部)的示例,包含相同的数据
为了演示重塑,我们可以将 CO[2]数据放入一个类似于数据透视表形状的宽数据框中。每个月份都有一列,每年都有一行:
`co2_pivot` `=` `pd``.``pivot_table``(`
`co2``[``10``:``34``]``,`
`index``=``'``Yr``'``,` `# Column to turn into new index`
`columns``=``'``Mo``'``,` `# Column to turn into new columns`
`values``=``'``Avg``'``)` `# Column to aggregate`
`co2_wide` `=` `co2_pivot``.``reset_index``(``)`
`display_df``(``co2_wide``,` `cols``=``10``)`
| Mo | Yr | 1 | 2 | 3 | 4 | ... | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1959 | 315.62 | 316.38 | 316.71 | 317.72 | ... | 314.80 | 313.84 | 313.26 | 314.8 | 315.58 |
| 1 | 1960 | 316.43 | 316.97 | 317.58 | 319.02 | ... | 315.91 | 314.16 | 313.83 | 315.0 | 316.19 |
2 rows × 13 columns
列标题是月份,网格中的单元格值是 CO[2]的月平均值。我们可以将此数据框转换回长格式,其中列名变为一个特征,称为month,并将网格中的值重新组织为第二个特征,称为average:
`co2_long` `=` `co2_wide``.``melt``(``id_vars``=``[``'``Yr``'``]``,`
`var_name``=``'``month``'``,`
`value_name``=``'``average``'``)`
`display_df``(``co2_long``,` `rows``=``4``)`
| 年 | 月 | 平均值 | |
|---|---|---|---|
| 0 | 1959 | 1 | 315.62 |
| 1 | 1960 | 1 | 316.43 |
| ... | ... | ... | ... |
| 22 | 1959 | 12 | 315.58 |
| 23 | 1960 | 12 | 316.19 |
24 rows × 3 columns
注意数据已恢复到其原始形状(尽管行不是原始顺序)。当我们期望读者查看数据表本身时,宽格式数据更常见,例如在经济文章或新闻报道中。但是,长格式数据对数据分析更有用。例如,co2_long允许我们编写简短的pandas代码,按年份或月份分组,而宽格式数据则使按年份分组变得困难。.melt()方法特别适用于将宽格式转换为长格式数据。
这些结构修改已集中在单个表上。然而,我们经常希望将分散在多个表中的信息组合在一起。在下一节中,我们将结合本章介绍的技术来处理餐厅检查数据,并解决表的连接问题。
示例:整理餐厅安全违规
我们在本章结束时通过一个示例展示了许多数据整理技术。回顾第八章,旧金山餐厅检查数据存储在三个表中:bus(企业/餐厅)、insp(检查)和viol(安全违规)。违规数据集包含检查期间发现的详细违规描述。我们希望捕捉部分信息,并将其与检查评分连接,这是一个检查级别的数据集。
我们的目标是找出与较低餐厅安全评分相关的安全违规类型。这个例子涵盖了数据整理中与更改结构相关的几个关键概念:
-
过滤以便专注于数据的较窄部分
-
聚合以修改表的粒度
-
连接以汇总跨表信息
此外,本示例的一个重要部分展示了如何将文本数据转换为数值量进行分析。
作为第一步,让我们通过将数据简化为一年的检查来简化结构。(回想一下,该数据集包含四年的检查信息。)在以下代码中,我们统计了检查表中每年的记录数:
`pd``.``value_counts``(``insp``[``'``year``'``]``)`
year
2016 5443
2017 5166
2015 3305
2018 308
Name: count, dtype: int64
将数据减少到一年的检查将简化我们的分析。稍后,如果需要,我们可以返回并使用所有四年的数据进行分析。
缩小焦点
我们将数据整理限定在 2016 年进行的检查中。在这里,我们可以再次使用pipe函数,以便对检查和违规数据框应用相同的重塑:
`def` `subset_2016``(``df``)``:`
`return` `df``.``query``(``'``year == 2016``'``)`
`vio2016` `=` `viol``.``pipe``(``subset_2016``)`
`ins2016` `=` `insp``.``pipe``(``subset_2016``)`
`ins2016``.``head``(``5``)`
| business_id | score | date | type | timestamp | year | |
|---|---|---|---|---|---|---|
| 0 | 19 | 94 | 20160513 | routine | 2016-05-13 | 2016 |
| 3 | 24 | 98 | 20161005 | routine | 2016-10-05 | 2016 |
| 4 | 24 | 96 | 20160311 | routine | 2016-03-11 | 2016 |
| 6 | 45 | 78 | 20160104 | routine | 2016-01-04 | 2016 |
| 9 | 45 | 84 | 20160614 | routine | 2016-06-14 | 2016 |
在第八章中,我们发现business_id和timestamp共同唯一标识了检查(除了几个例外)。我们还看到这里,餐馆在一年内可能接受多次检查——例如,商家#24 在 2016 年进行了两次检查,分别在三月和十月。
接下来,让我们看看违规表中的几条记录:
`vio2016``.``head``(``5``)`
| business_id | date | description | timestamp | year | |
|---|---|---|---|---|---|
| 2 | 19 | 20160513 | 未批准或未维护的设备或器具... | 2016-05-13 | 2016 |
| 3 | 19 | 20160513 | 地板、墙壁或天花板不洁或破损... | 2016-05-13 | 2016 |
| 4 | 19 | 20160513 | 食品安全证书或食品处理者证未... | 2016-05-13 | 2016 |
| 6 | 24 | 20161005 | 地板、墙壁或天花板不洁或破损... | 2016-10-05 | 2016 |
| 7 | 24 | 20160311 | 地板、墙壁或天花板不洁或破损... | 2016-03-11 | 2016 |
请注意,前几条记录是同一家餐厅的。如果我们想将违规信息带入检查表中,我们需要处理这些表的不同粒度。一种方法是以某种方式聚合违规行为。我们将在接下来讨论这一点。
聚合违规行为
将违规行为的一个简单聚合是计算它们的数量,并将该计数添加到检查数据表中。为了找出检查中的违规次数,我们可以按business_id和timestamp对违规行为进行分组,然后找出每个组的大小。基本上,这种分组将违规行为的粒度变更为检查级别:
`num_vios` `=` `(``vio2016`
`.``groupby``(``[``'``business_id``'``,` `'``timestamp``'``]``)`
`.``size``(``)`
`.``reset_index``(``)`
`.``rename``(``columns``=``{``0``:` `'``num_vio``'``}``)``)``;`
`num_vios``.``head``(``3``)`
| business_id | timestamp | num_vio | |
|---|---|---|---|
| 0 | 19 | 2016-05-13 | 3 |
| 1 | 24 | 2016-03-11 | 2 |
| 2 | 24 | 2016-10-05 | 1 |
现在我们需要将这些新信息与ins2016合并。具体来说,我们想要左连接ins2016和num_vios,因为可能有些检查没有任何违规行为,我们不希望丢失它们:
`def` `left_join_vios``(``ins``)``:`
`return` `ins``.``merge``(``num_vios``,` `on``=``[``'``business_id``'``,` `'``timestamp``'``]``,` `how``=``'``left``'``)`
`ins_and_num_vios` `=` `ins2016``.``pipe``(``left_join_vios``)`
`ins_and_num_vios`
| business_id | score | date | type | timestamp | year | num_vio | |
|---|---|---|---|---|---|---|---|
| 0 | 19 | 94 | 20160513 | routine | 2016-05-13 | 2016 | 3.0 |
| 1 | 24 | 98 | 20161005 | routine | 2016-10-05 | 2016 | 1.0 |
| 2 | 24 | 96 | 20160311 | routine | 2016-03-11 | 2016 | 2.0 |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 5440 | 90096 | 91 | 20161229 | routine | 2016-12-29 | 2016 | 2.0 |
| 5441 | 90268 | 100 | 20161229 | routine | 2016-12-29 | 2016 | NaN |
| 5442 | 90269 | 100 | 20161229 | routine | 2016-12-29 | 2016 | NaN |
5443 rows × 7 columns
在检查时如果没有违规,特征num_vio将会是缺失值(NaN)。我们可以检查有多少缺失的数值:
`ins_and_num_vios``[``'``num_vio``'``]``.``isnull``(``)``.``sum``(``)`
833
关于 2016 年的餐厅检查,约 15%没有记录安全违规。如果餐厅的安全得分为 100,我们可以通过将它们设置为 0 来修正这些缺失值。这是归纳填充的一个例子,因为我们使用领域知识来填补缺失值:
`def` `zero_vios_for_perfect_scores``(``df``)``:`
`df` `=` `df``.``copy``(``)`
`df``.``loc``[``df``[``'``score``'``]` `==` `100``,` `'``num_vio``'``]` `=` `0`
`return` `df`
`ins_and_num_vios` `=` `(``ins2016``.``pipe``(``left_join_vios``)`
`.``pipe``(``zero_vios_for_perfect_scores``)``)`
我们可以再次统计有缺失违规数量的检查次数:
`ins_and_num_vios``[``'``num_vio``'``]``.``isnull``(``)``.``sum``(``)`
65
我们已经纠正了大量缺失的数值。进一步调查后,我们发现一些企业的检查日期接近但不完全匹配。我们可以进行模糊匹配,将日期仅相差一两天的检查归为一类。但目前,我们将它们留为空值NaN。
让我们来研究违规数量与检查得分之间的关系:
正如我们预期的那样,检查得分与违规数量之间存在负相关关系。我们还可以看到得分的变异性。随着违规数量的增加,得分的变异性也增加。似乎某些违规比其他违规更为严重,对得分的影响更大。接下来我们提取违规种类的信息。
从违规描述中提取信息
我们之前看到违规数据框架中的特征描述有很多文本,包括方括号中关于何时纠正违规的信息。我们可以汇总描述并查看最常见的违规情况:
display_df(vio2016['description'].value_counts().head(15).to_frame(), rows=15)
| 描述 | |
|---|---|
| 地板、墙壁或天花板不干净或已磨损 | 161 |
| 未批准或未维护的设备或器具 | 99 |
| 中度风险的食品持有温度 | 95 |
| 清洗不充分或无法接近的洗手设施 | 93 |
| 清洁或消毒不充分的食品接触表面 | 92 |
| 食品存储不当 | 81 |
| 擦拭布不干净或存放不当或消毒剂不足 | 71 |
| 食品安全证书或持证食品处理人员卡不可用 | 64 |
| 中度风险的害虫侵扰 | 58 |
| 食品未受到污染保护 | 56 |
| 非食品接触表面不干净 | 54 |
| 食品安全知识不足或缺乏持证食品安全经理 | 52 |
| 许可证或检查报告未张贴 | 41 |
| 设备、器具或亚麻布存储不当 | 41 |
| 低风险的害虫侵扰 | 34 |
通过阅读这些冗长的描述,我们发现其中一些与设施的清洁有关,另一些与食品存储有关,还有一些与员工的清洁有关。
由于有许多类型的违规行为,我们可以尝试将它们分组到更大的类别中。一种方法是根据文本是否包含特定术语(如害虫、手或高风险)创建一个简单的布尔标志。
通过这种方法,我们为不同类别的违规行为创建了八个新特征。暂时不必担心代码的具体细节——此代码使用了正则表达式,详见第十三章。重要的是,此代码根据违规描述中是否包含特定单词创建包含True或False的特征:
`def` `make_vio_categories``(``vio``)``:`
`def` `has``(``term``)``:`
`return` `vio``[``'``description``'``]``.``str``.``contains``(``term``)`
`return` `vio``[``[``'``business_id``'``,` `'``timestamp``'``]``]``.``assign``(`
`high_risk` `=` `has``(``r``"``high risk``"``)``,`
`clean` `=` `has``(``r``"``clean|sanit``"``)``,`
`food_surface` `=` `(``has``(``r``"``surface``"``)` `&` `has``(``r``"``\``Wfood``"``)``)``,`
`vermin` `=` `has``(``r``"``vermin``"``)``,`
`storage` `=` `has``(``r``"``thaw|cool|therm|storage``"``)``,`
`permit` `=` `has``(``r``"``certif|permit``"``)``,`
`non_food_surface` `=` `has``(``r``"``wall|ceiling|floor|surface``"``)``,`
`human` `=` `has``(``r``"``hand|glove|hair|nail``"``)``,`
`)`
`vio_ctg` `=` `vio2016``.``pipe``(``make_vio_categories``)`
`vio_ctg`
| business_id | timestamp | high_risk | clean | ... | storage | permit | non_food_surface | human | |
|---|---|---|---|---|---|---|---|---|---|
| 2 | 19 | 2016-05-13 | False | False | ... | False | False | False | False |
| 3 | 19 | 2016-05-13 | False | True | ... | False | False | True | False |
| 4 | 19 | 2016-05-13 | False | False | ... | False | True | False | True |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 38147 | 89900 | 2016-12-06 | False | False | ... | False | False | False | False |
| 38220 | 90096 | 2016-12-29 | False | False | ... | False | False | False | False |
| 38221 | 90096 | 2016-12-29 | False | True | ... | False | False | True | False |
15624 rows × 10 columns
现在,我们在vio_ctg中有了这些新特征,我们可以找出某些违规类别是否比其他类别更具影响力。例如,餐厅的评分是否更多受到与害虫相关的违规行为的影响,而不是与许可相关的违规行为?
要做到这一点,我们首先要统计每个企业的违规次数。然后我们可以将此信息与检查信息合并。首先,让我们对每个企业的违规次数进行求和:
`vio_counts` `=` `vio_ctg``.``groupby``(``[``'``business_id``'``,` `'``timestamp``'``]``)``.``sum``(``)``.``reset_index``(``)`
`vio_counts`
| business_id | timestamp | high_risk | clean | ... | storage | permit | non_food_surface | human | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 19 | 2016-05-13 | 0 | 1 | ... | 0 | 1 | 1 | 1 |
| 1 | 24 | 2016-03-11 | 0 | 2 | ... | 0 | 0 | 2 | 0 |
| 2 | 24 | 2016-10-05 | 0 | 1 | ... | 0 | 0 | 1 | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 4803 | 89790 | 2016-11-29 | 0 | 0 | ... | 0 | 0 | 0 | 1 |
| 4804 | 89900 | 2016-12-06 | 0 | 0 | ... | 0 | 0 | 0 | 0 |
| 4805 | 90096 | 2016-12-29 | 0 | 1 | ... | 0 | 0 | 1 | 0 |
4806 rows × 10 columns
再次,我们使用左连接将这些新特征合并到检查级别的数据框中。对于得分为 100 的特殊情况,我们将所有新特征设置为0:
`feature_names` `=` `[``'``high_risk``'``,` `'``clean``'``,` `'``food_surface``'``,` `'``vermin``'``,`
`'``storage``'``,` `'``permit``'``,` `'``non_food_surface``'``,` `'``human``'``]`
`def` `left_join_features``(``ins``)``:`
`return` `(``ins``[``[``'``business_id``'``,` `'``timestamp``'``,` `'``score``'``]``]`
`.``merge``(``vio_counts``,` `on``=``[``'``business_id``'``,` `'``timestamp``'``]``,` `how``=``'``left``'``)``)`
`def` `zero_features_for_perfect_scores``(``ins``)``:`
`ins` `=` `ins``.``copy``(``)`
`ins``.``loc``[``ins``[``'``score``'``]` `==` `100``,` `feature_names``]` `=` `0`
`return` `ins`
`ins_and_vios` `=` `(``ins2016``.``pipe``(``left_join_features``)`
`.``pipe``(``zero_features_for_perfect_scores``)``)`
`ins_and_vios``.``head``(``3``)`
| business_id | timestamp | score | high_risk | ... | storage | permit | non_food_surface | human | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 19 | 2016-05-13 | 94 | 0.0 | ... | 0.0 | 1.0 | 1.0 | 1.0 |
| 1 | 24 | 2016-10-05 | 98 | 0.0 | ... | 0.0 | 0.0 | 1.0 | 0.0 |
| 2 | 24 | 2016-03-11 | 96 | 0.0 | ... | 0.0 | 0.0 | 2.0 | 0.0 |
3 rows × 11 columns
要查看每个违规类别与分数的关系,我们可以制作一系列箱线图,比较包含和不包含每个违规的分数分布。由于我们这里关注的是数据的模式,而不是可视化代码,我们隐藏了代码(您可以在网上查看更大的图像):
概要
数据整理是数据分析的重要组成部分。如果没有数据整理,我们可能会忽略数据中可能导致未来分析严重后果的问题。本章涵盖了几个在几乎每一次分析中都会使用的重要数据整理步骤。
在将数据读入数据框后,我们描述了在数据集中寻找什么。质量检查帮助我们发现数据中的问题。为了找到不良和缺失值,我们可以采取许多方法:
-
检查摘要统计数据、分布和值计数。第十章 提供了如何使用可视化和摘要统计检查数据质量的示例和指导。我们在这里简要提到了几种方法。特征中唯一值计数的表格可以揭示意外编码和倾斜分布,其中一个选项是罕见的。百分位数可以帮助揭示具有异常高(或低)值的比例。
-
使用逻辑表达式来识别数值超出范围或关系失调的记录。仅计算未通过质量检查的记录数量可以快速显示问题的规模。
-
检查具有特定特征中问题值的整个记录。有时,在 CSV 格式文件中逗号放错位置时,整个记录会混乱。或者该记录可能代表一个不寻常的情况(例如在房屋销售数据中包含牧场),您需要决定是否应该包含在您的分析中。
-
参考外部来源以找出异常的原因。
本章的最大收获是对数据保持好奇心。寻找能揭示数据质量的线索。找到的证据越多,您对发现的结果越有信心。如果发现问题,请深入挖掘。尝试理解和解释任何异常现象。对数据的深入了解将帮助您评估您发现的问题是可以忽略或修正的小问题,还是可能严重影响数据可用性的问题。这种好奇心思维与探索性数据分析密切相关,这是下一章的主题。
^(1) 这意味着每个值使用 64 位内存,并且精确到纳秒(简称 ns)。