- 本书赞誉
- 前言
- 目标读者
- 不适合阅读本书的读者
- 本书结构
- 什么是数据处理
- 遇到困难怎么办
- 排版约定
- 使用代码示例
- 致谢
- 第 1 章 Python 简介
- 第 2 章 Python 基础
- 第 3 章 供机器读取的数据
- 第 4 章 处理 Excel 文件
- 第 5 章 处理 PDF 文件 以及用 Python 解决问题
- 第 6 章 数据获取与存储
- 第 7 章 数据清洗:研究、匹配与格式化
- 第 8 章 数据清洗:标准化和脚本化
- 第 9 章 数据探索和分析
- 第 10 章 展示数据
- 第 11 章 网页抓取:获取并存储网络数据
- 第 12 章 高级网页抓取:屏幕抓取器与爬虫
- 第 13 章 应用编程接口
- 第 14 章 自动化和规模化
- 第 15 章 结论
- 附录 A 编程语言对比
- 附录 B 初学者的 Python 学习资源
- 附录 C 学习命令行
- 附录 D 高级 Python 设置
- 附录 E Python 陷阱
- 附录 F IPython 指南
- 附录 G 使用亚马逊网络服务
- 关于作者
- 关于封面
7.2 数据清洗基础知识
如果已经完成了前面几章的代码练习,你就已经用到了一些数据清洗的概念。在第 4 章,我们从 Excel 工作表里导出数据,并创建一个字典来表示这些数据。修改数据使其满足新的标准化数据格式,这个过程就是数据清洗。
我们已经研究过 UNICEF 发布的与童工有关的数据集(见 6.5.4 节),下面我们深入研究 UNICEF 的原始数据。大部分 UNICEF 报告的原始数据集都来自多指标类集调查(Multiple Indicator Cluster Surveys,MICS,http://mics.unicef.org/surveys)。这些调查是由 UNICEF 工作人员和志愿者做的家庭层面调查,用于对世界各地妇女和儿童生活状况的研究。浏览最新的几份调查,我们提取出津巴布韦最新的 MICS 数据进行分析。
在开始分析之前,我们首先需要以教育和研究为目的请求访问 UNICEF 网站,然后下载最新的调查。获取访问权限之后(大约需要一天的时间),我们就可以下载原始数据集。大多数 MICS 原始数据是 SPSS 格式或 .sav 文件。SPSS 是社会科学家用来保存并分析数据的程序。对于某些社会科学统计数据来说,它是很好用的工具,但并不太适合用 Python 来处理。
为了将 SPSS 文件转换成可用的格式,我们首先使用开源项目 PSPP(https://www.gnu.org/software/pspp/)来查看数据,然后用几个简单的 R 命令将 SPSS 数据转换成 .csv 文件(http://bethmcmillan.com/blog/?p=1073),这样 Python 处理起来会比较方便。还有许多优秀的项目,可以用 Python 与 SPSS 文件交互(https://pypi.python.org/pypi/savReaderWriter),但其安装和操作都要比 R 命令复杂。你可以在本书仓库(https://github.com/jackiekazil/data-wrangling)中找到生成的 CSV 文件。
仔细观察文件及其包含的数据,我们从这里开始数据清洗过程。数据清洗的第一步通常是简单的目视分析。我们仔细观察文件,看看能发现什么!
7.2.1 找出需要清洗的数据
数据清洗的第一步是,观察数据字段,仔细寻找不一致的地方。如果在数据清洗之初就可以使数据看起来更加干净的话,你会更容易找到在数据归一化过程中需要解决的最初问题。
我们来看一下 mn.csv 文件。文件中包含原始数据,并用首字母缩写作为标题,这些缩写的含义可能很好翻译。我们来看一下 mn.csv 文件的列标题:
"","HH1","HH2","LN","MWM1","MWM2", ...
每一项都代表调查中的一个问题或数据,我们想要的是可读性更强的版本。通过谷歌搜索,我们在分享 MICS 数据的世界银行网站(http://microdata.worldbank.org/index.php/catalog/1794/datafile/F5)上找到了这些标题的具体含义。
花点时间研究世界银行网站上有没有缩写一览表,可以帮你更好地完成数据清洗工作。你还可以给该机构打电话,询问是否有易于使用的缩写列表。
利用在第 11 章即将学到的一些网络抓取技术,我们可以得到一个 CSV 文件,里面包含这些标题及其英文释义,以及世界银行在采集这些 MICS 数据时所问的问题。我们已将网络抓取生成的新标题放在本书仓库中(mn-headers.csv)。我们希望将这些标题与调查数据一一对应,这样就有了可读性较强的问题和答案。我们有几种方法可以做到这一点。
01. 替换标题
想要提高标题可读性,最明显而直接的方法就是将短标题替换成易于理解的长标题。如何用 Python 做标题替换呢?首先,需要用第 3 章学过的 csv 模块导入 mn.csv 和 mn-headers.csv 两个文件(参见下面的导入代码)。在本章和下一章中,你可以在脚本中写代码,也可以在终端里(比如 IPython)写代码。这样你可以先与数据交互,然后再将其保存到文件中:
from csv import DictReader data_rdr = DictReader(open('data/unicef/mn.csv', 'rb')) header_rdr = DictReader(open('data/unicef/mn_headers.csv', 'rb')) data_rows = [d for d in data_rdr] ➊ header_rows = [h for h in header_rdr] print data_rows[:5] ➋ print header_rows[:5]
❶ 本行代码将可迭代对象 DictReader 写入一个新列表,这样我们可以保存数据并重复使用。我们用到了列表生成式,只需要一行简单、清晰、易读的代码即可实现。
❷ 本行代码打印出数据的一个切片,用的是 Python 列表的 slice 方法,显示新列表的前 5 个元素,对列表内容有一个初步了解。
在第 4 行代码中,我们用到了 Python 的列表生成式(list comprehension)函数。Python 列表生成式的格式如下:
[func(x) for x in iter_x]
列表生成式的首尾是列表的方括号。它用到一个可迭代对象(iter_x),将 iter_x 的每一行或每一个值传入 func(x),用返回值创建一个新列表。这里我们没有用到列表生成式的函数,只用到了每一行的当前值。在后续章节中,我们会将可迭代对象的每一行或每一个值传入一个函数,对数据进行清洗或修改,然后再添加到新列表中。列表生成式是易读易用的语法一个很好的例子,这也是 Python 广为人知的特点。用 for 循环也可以实现相同的功能,但是代码量更大:
new_list = [] for x in iter_x: new_list.append(func(x))
可以看出,列表生成式的代码比较简短,性能更好,也更节省内存。
我们希望将 data_rows 中字典标题替换为文件里可读性较强的标题。从输出里可以看出, header_rows 字典里同时包含短标题和长标题。短标题包含在 Name 字段,可读性更强的长标题包含在 Label 字段。利用一些 Python 字符串方法可以将二者轻松匹配在一起:
for data_dict in data_rows: ➊ for dkey, dval in data_dict.items(): ➋ for header_dict in header_rows: ➌ for hkey, hval in header_dict.items(): if dkey == hval: ➍ print 'match!'
❶ 遍历每一条数据记录。我们将用每一个字典的键与标题匹配。
❷ 遍历每一行数据的键和值,这样就可以将所有键替换成可读性更强的标题标签(要查看数据字典里每一个键值对,我们用的是 Python 字典的 items 方法)。
❸ 遍历所有标题行,这样我们可以得到可读性更强的标签。这并不是最快的方法,但可以保证不会有遗漏。
❹ 如果数据列表的键(MWB3、MWB7、MWB4、MWB5…)和标题字典的值相同,那么打印 'match!' 表示二者相互匹配。
运行代码,我们发现有很多匹配项。下面我们来看一下,能否用类似的逻辑将标题替换成更好的标题。我们知道将二者匹配是相当容易的。但我们只找到了想要匹配的那些行。我们来看一下能否找到一种方法,将 data_rows 每一项的键与 header_rows 每一项的值相匹配:
new_rows = [] ➊ for data_dict in data_rows: new_row = {} ➋ for dkey, dval in data_dict.items(): for header_dict in header_rows: if dkey in header_dict.values(): ➌ new_row[header_dict.get('Label')] = dval ➍ new_rows.append(new_row) ➎
❶ 创建一个新列表,里面包含的是清洗过的行数据。
❷ 为每一行创建一个新字典。
❸ 这里我们用的是字典的 values 方法,而不是遍历标题行的所有键值对。这一方法返回的是仅由字典的值组成的列表。我们还用到了 Python 的 in 方法,用来测试一个对象是否包含在某个列表中。在本行代码中,对象是我们的键,或缩写字符串,而列表是标题字典的值组成的列表(里面包含缩写短标题)。如果本行代码为真,我们就找到了一个匹配行。
❹ 每找到一个匹配,都将其添加到 new_row 字典中。将字典的键设置为标题行中 Label 对应的值,也就是将短标题(Name 对应的值)替换为可读性更好的长标题(Label 对应的值),并将字典的值设置为数据行的值(dval)。
❺ 将清洗过的新数据添加到新列表中。这样做是为了保证我们找到了所有的匹配,然后再运行后面的代码。
将新列表第一项打印出来,可以看出,我们已经成功提高了数据的可读性:
In [8]: new_rows[0] Out[8]: { 'AIDS virus from mother to child during delivery': 'Yes', 'AIDS virus from mother to child during pregnancy': 'DK', 'AIDS virus from mother to child through breastfeeding': 'DK', 'Age': '25-29', 'Age at first marriage/union': '29',...
要检查函数的缩进是否正确,一个简单的方法是观察具有相同缩进的其他代码。不停问自己:其他代码这一步的逻辑是什么?我的代码应该何时继续下一步?
对于数据清洗问题,解决方法远不止一种。那么对于我们标题可读性较差的问题,我们来看一下能否用其他方法解决。
02. 合并问题与答案
修复标签问题的另一种方法是 Python 的 zip 方法:
from csv import reader ➊ data_rdr = reader(open('data/unicef/mn.csv', 'rb')) header_rdr = reader(open('data/unicef/mn_headers.csv', 'rb')) data_rows = [d for d in data_rdr] header_rows = [h for h in header_rdr] print len(data_rows[0]) ➋ print len(header_rows)
❶ 这次我们用的是简单的 reader 类,而不是 DictReader。reader 为每一行创建的是一个列表,而不是一个字典。由于我们要用的是 zip 方法,需要的是列表而不是字典,这样我们就可以将标题列表与数据列表合并在一起。
❷ 这几行代码创建标题列表和数据列表,并查看它们的长度是否相同。
啊呀——打印结果显示,数据列表长度与标题列表长度并不相同!我们的数据有 159 行,而标题列表中有 210 个标题。这说明,与津巴布韦数据集相比,MICS 在其他国家可能问了更多问题,或者提供了更多的备选问题。
我们需要进一步研究数据集中都用了哪些标题,哪些标题是不需要的。我们仔细观察一下,找出没有正确对应的那些标题:
In [22]: data_rows[0] Out[22]: ['', 'HH1', 'HH2', 'LN', 'MWM1', 'MWM2', 'MWM4', 'MWM5', 'MWM6D', 'MWM6M', 'MWM6Y', ... ] In [23]: header_rows[:2] Out[23]: [ ['Name', 'Label', 'Question'], ['HH1', 'Cluster number', '']]
好吧,我们可以很清楚地看到,data_rows 的第二行与 header_rows 中索引数为 1 的值对应。找出不匹配的那些行,然后将它们从 header_rows 中删除,这样我们就可以将数据正确地合并:
bad_rows = [] for h in header_rows: if h[0] not in data_rows[0]: ➊ bad_rows.append(h) ➋ for h in bad_rows: header_rows.remove(h) ➌ print len(header_rows)
❶ 测试标题行的第一个元素(标题的缩写版本)是否包含在数据列表的第一行中(所有的标题缩写)。
❷ 将不匹配的标题行添加到新列表 bad_rows 中。下一步我们将利用这个列表来判断需要删除哪些行。
❸ 利用列表的 remove 方法从列表中删除指定的一行。当你能够指出想从列表中删除的某一行(或某些行)时,这个方法往往是很有用的。
啊哈!现在我们已经几乎完成匹配了。我们的数据列表中有 159 个值,标题列表中有 150 个值。下面我们来看一下,为什么标题列表里不需要这 9 个匹配的标题:
all_short_headers = [h[0] for h in header_rows] ➊ for header in data_rows[0]: ➋ if header not in all_short_headers: ➌ print 'mismatch!', header ➍
❶ 利用 Python 列表生成式采集每一个标题行的第一个元素,生成一个由所有标题缩写组成的列表。
❷ 遍历数据集中的所有标题,检查哪些没有包含在清洗后的标题列表中。
❸ 挑出不包含在缩写列表中的标题。
❹ 用 print 语句打印出所有的不匹配项。如果你需要将两个字符串打印在同一行中,只需要在中间加一个 ,,就可以用空格将两个字符串连接在一起。
运行代码,输出应该是这样的:
mismatch! mismatch! MDV1F mismatch! MTA8E mismatch! mwelevel mismatch! mnweight mismatch! wscoreu mismatch! windex5u mismatch! wscorer mismatch! windex5r
从我们目前所知和输出结果来看,只有几个不匹配标题(带有大写字母的那几个)是需要处理的。小写的那些标题是用于 UNICEF 内部方法的,与我们要研究的问题无关。
由于我们创建的从世界银行网站采集标题的网络爬虫没有找到 MDV1F 和 MTA8E 这两个变量,所以我们需要用 SPSS 阅读器来查看它们的含义。(另一种方法是删掉这几行,继续下一步。)
在处理原始数据时,有时你会发现,想要将数据转换成可用的格式,你需要舍弃不需要的数据或难以清洗的数据。归根结底,决定因素并不在于是否懒惰,而在于数据对你的问题是否重要。
打开 SPSS 阅读器后可以看到,MDV1F 对应的标签是“如果妻子不忠,殴打妻子是否合理?”,同时还与关于家庭暴力的其他一系列长问题相对应。其他问题也有一些是关于家庭暴力的,所以最好保留这个问题。研究 MTA8E 这个标题发现,它对应的一系列不同的问题,都是关于某人吸食烟草的类型。我们已经将两个标题都添加到新的文件(mn_headers_updated.csv)中。
现在重新运行之前的代码,这次用的是修改后的标题文件。
我们来看一下所有的代码,修改其中几处,这样就可以将标题和数据合并在一起。下面这个脚本需要大量内存,如果你计算机的 RAM 小于 4GB 的话,我们建议在 IPython 终端或 IPython notebook 中运行,这样可以避免出现段错误(segmentation fault):
from csv import reader data_rdr = reader(open('data/unicef/mn.csv', 'rb')) header_rdr = reader(open('data/unicef/mn_headers_updated.csv', 'rb')) data_rows = [d for d in data_rdr] header_rows = [h for h in header_rdr if h[0] in data_rows[0]] ➊ print len(header_rows) all_short_headers = [h[0] for h in header_rows] skip_index = [] ➋ for header in data_rows[0]: if header not in all_short_headers: index = data_rows[0].index(header) ➌ skip_index.append(index) new_data = [] for row in data_rows[1:]: ➍ new_row = [] for i, d in enumerate(row): ➎ if i not in skip_index: ➏ new_row.append(d) new_data.append(new_row) ➐ zipped_data = [] for drow in new_data: zipped_data.append(zip(header_rows, drow)) ➑
❶ 使用列表生成式快速删除不匹配的标题。可以看到,我们还在列表生成式中使用了 if 语句。本行代码的作用是,如果标题行第一个元素(标题缩写)包含在数据行的标题中,那么将该标题行添加到新列表中。
❷ 创建新列表,用来保存我们不希望保存的数据行的索引编号。
❸ 利用 Python 列表的 index 方法,返回我们不需要的索引编号,因为这些索引编号对应的标题没有包含在缩写列表中。下一行代码会将不匹配标题行的索引编号保存下来,这样我们就可以不采集这些数据。
❹ 对保存调查数据的列表做切片,只选取其中的数据行(除了第一行外的所有行),然后对其进行遍历。
❺ 利用 enumerate 函数找出不需要保存的那些数据行。这个函数接受一个可迭代对象(本例中是数据行列表),返回每一个元素的索引编号和值。该函数将返回的第一个值(索引编号)赋值给 i,将数据值赋值给 d。
❻ 检查并确保索引编号不在我们不希望保存的列表中。
❼ 检查完数据行中的每一项(或每一“列”)之后,将新的数据条目添加到 new_data 列表中。
❽ 将完全匹配的标题和数据合并在一起,并添加到新数组 zipped_data 中。
现在我们可以将新数据集的一行打印出来,看看与我们的预期是否相同:
In [40]: zipped_data[0] Out[40]: [(['HH1', 'Cluster number', ''], '1'), (['HH2', 'Household number', ''], '17'), (['LN', 'Line number', ''], '1'), (['MWM1', 'Cluster number', ''], '1'), (['MWM2', 'Household number', ''], '17'), (['MWM4', "Man's line number", ''], '1'), (['MWM5', 'Interviewer number', ''], '14'), (['MWM6D', 'Day of interview', ''], '7'), (['MWM6M', 'Month of interview', ''], '4'), (['MWM6Y', 'Year of interview', ''], '2014'), (['MWM7', "Result of man's interview", ''], 'Completed'), (['MWM8', 'Field editor', ''], '2'), (['MWM9', 'Data entry clerk', ''], '20'), (['MWM10H', 'Start of interview - Hour', ''], '17'), ....
我们已经将所有的问题和回答保存在元组中,每一行中所有的标题和数据都是匹配好的。为了检查这些匹配是否正确,我们来看一下该行的结尾:
(['TN11', 'Persons slept under mosquito net last night', 'Did anyone sleep under this mosquito net last night?'], 'NA'), (['TN12_1', 'Person 1 who slept under net', 'Who slept under this mosquito net last night?'], 'Currently married/in union'), (['TN12_2', 'Person 2 who slept under net', 'Who slept under this mosquito net last night?'], '0'),
数据看起来很奇怪。似乎出现了一些匹配错误。我们来做一个真实性核查(reality check),利用刚学过的 zip 方法来检查标题是否正确匹配:
data_headers = [] for i, header in enumerate(data_rows[0]): ➊ if i not in skip_index: ➋ data_headers.append(header) header_match = zip(data_headers, all_short_headers) ➌ print header_match
❶ 遍历数据列表中的所有标题。
❷ 利用 if...not in... 语句,只有当索引编号不包含在 skip_index 中时才会返回 True。
❸ 将两个标题列表合并在一起,这样我们可以观察寻找不匹配项。
啊哈!你发现错误了吗?
.... ('MHA26', 'MHA26'), ('MHA27', 'MHA27'), ('MMC1', 'MTA1'), ('MMC2', 'MTA2'), ....
在这一项之前的匹配都是正确的,在这一项之后,我们的标题文件和数据文件的问题顺序出现了不一致。由于 zip 方法不会改变列表元素的顺序,我们必须首先调整标题的顺序,使其与数据集的顺序一致。下面是修改后的代码,尝试将数据正确匹配:
from csv import reader data_rdr = reader(open('data/unicef/mn.csv', 'rb')) header_rdr = reader(open('data/unicef/mn_headers_updated.csv', 'rb')) data_rows = [d for d in data_rdr] header_rows = [h for h in header_rdr if h[0] in data_rows[0]] all_short_headers = [h[0] for h in header_rows] skip_index = [] final_header_rows = [] ➊ for header in data_rows[0]: if header not in all_short_headers: index = data_rows[0].index(header) skip_index.append(index) else: ➋ for head in header_rows: ➌ if head[0] == header: ➍ final_header_rows.append(head) break ➎ new_data = [] for row in data_rows[1:]: new_row = [] for i, d in enumerate(row): if i not in skip_index: new_row.append(d) new_data.append(new_row) zipped_data = [] for drow in new_data: zipped_data.append(zip(final_header_rows, drow)) ➏
❶ 创建新列表,包含顺序正确的最终标题行。
❷ 利用 else 语句,只将匹配的列添加到列表中。
❸ 遍历 header_rows,直到找到匹配为止。
❹ 检查标题缩写是否匹配。我们用 == 来检查匹配。
❺ 找到匹配后,利用 break 退出 for head in header_rows 循环。这样速度更快,而且不会对结果造成影响。
❻ 将新的 final_header_rows 列表与标题行按顺序正确地合并在一起。
运行新代码,我们看一下第一个数据条目的结尾:
(['TN12_3', 'Person 3 who slept under net', 'Who slept under this mosquito net last night?'], 'NA'), (['TN12_4', 'Person 4 who slept under net', 'Who slept under this mosquito net last night?'], 'NA'), (['HH6', 'Area', ''], 'Urban'), (['HH7', 'Region', ''], 'Bulawayo'), (['MWDOI', 'Date of interview women (CMC)', ''], '1372'), (['MWDOB', 'Date of birth of woman (CMC)', ''], '1013'), (['MWAGE', 'Age', ''], '25-29'),
看起来匹配得很好。我们可以写出更加清晰的代码,但我们已经找到一个好方法,可以保存绝大部分数据并将数据与标题合并在一起,而且速度还很快。
关于你需要的数据完整性,以及在你的项目中需要为数据清洗花费多少精力,你总是需要作出评估。如果你只用其中一部分数据,可能就不需要保存所有数据。如果数据集是你的主要研究来源,那么值得你花费时间和精力来保证数据完整性。
本节我们学习了一些新的工具和方法,可以发现错误或需要清洗的数据,并结合我们学过的 Python 知识和解决问题的方法来解决这些问题。数据清洗第一步工作(替换标题文本)舍弃了一些数据列,保存了其余的数据列,并没有显示标题有缺失。但只要最后得到的数据集中包含我们需要的数据列,这种方法就能满足我们的要求,速度更快,代码量也更少。
在数据清洗过程中思考这几类问题。保持数据完整性是不是很重要?如果是的话,值得花几个小时?有没有简单的方法,既可以适当清洗数据,又可以保存你需要的所有内容?有没有可重复的方法?在清洗数据集时这些问题可以为你提供指导。
现在我们已经有了一个良好的数据列表,我们可以继续学习其他类型的数据清洗。
7.2.2 数据格式化
数据清洗最常见的形式之一,就是将可读性很差或根本无法读取的数据和数据类型转换成可读性较强的格式。特别是如果需要用数据或可下载文件来撰写报告,你需要将其由机器可读转换成人类可读。如果你的数据需要与 API 一起使用,你可能需要特殊格式的数据类型。
对于格式化字符串和数字,Python 为我们提供了大量方法。在第 5 章讲到调试并显示结果时,我们用过 %r,作用是以字符串格式或 Unicode 格式给出对象的 Python 表示。 Python 还有字符串格式化方法 %s 和 %d,分别代表字符串和数字。它们通常与 print 命令一起使用。
将对象转换成字符串或 Python 表示还有一个更高级的方法,就是利用 format 方法。正如 Python 官方文档(https://docs.python.org/2/library/stdtypes.html#str.format)所述,这个方法可以定义一个字符串,并把数据作为参数或关键字参数传入字符串。我们来仔细看一下 format 方法:
for x in zipped_data[0]: print 'Question: {}\nAnswer: {}'.format( ➊ x[0], x[1]) ➋
❶ format 用 {} 表示数据传入的位置,用 \n 换行符来表示换行。
❷ 这里我们传入的是问题和答案组成的元组的前两个值。
你应该会看到像这样的输出:
Question: ['MMT9', 'Ever used Internet', 'Have you ever used the Internet?'] Answer: Yes Question: ['MMT10', 'Internet usage in the last 12 months', 'In the last 12 months, have you used the Internet?'] Answer: Yes
很难读懂这是什么意思。我们试着对其进一步清洗。从输出中可以看出,在问题列表中,索引编号为 0 的是缩写,索引编号为 1 的是问题描述。我们希望只用列表的第二部分,可以作为一个很好的标题。我们再试一次:
for x in zipped_data[0]: print 'Question: {[1]}\nAnswer: {}'.format( x[0], x[1]) ➊
❶ 这次我们用格式语法 1 来挑出对应索引编号的数据,使输出结果可读性更强。
我们再来看一下输出结果:
Question: Frequency of reading newspaper or magazine Answer: Almost every day Question: Frequency of listening to the radio Answer: At least once a week Question: Frequency of watching TV Answer: Less than once a week
现在可读性就很好了。好耶!下面我们看一下 format 方法的其他用法。当前的数据集中没有很多数字,所以我们用几个示例数字,展示不同数字类型的更多格式化方法:
example_dict = { 'float_number': 1324.321325493, 'very_large_integer': 43890923148390284, 'percentage': .324, } string_to_print = "float: {float_number:.4f}\n" ➊ string_to_print += "integer: {very_large_integer:,}\n" ➋ string_to_print += "percentage: {percentage:.2%}" ➌ print string_to_print.format(**example_dict) ➍
❶ 这里用到了字典,利用键访问字典的值。我们用 : 来分隔键名和格式。.4f 的意思是,将数字转换成浮点数(f),保留四位小数(.4)。
❷ 数字格式不变(键名和冒号),插入逗号(,)作为千位分隔符。
❸ 数字格式不变(键名和冒号),插入百分号(%),小数部分保留两位有效数字(.2)。
❹ 对长字符串调用 format 方法,并传入数据字典,用 ** 将字典拆包(unpack)。将 Python 字典拆包,也就是将字典的键 / 值拆开,拆包后的键和值被传递给 format 方法。
阅读 Python 格式化的文档和实例(https://docs.python.org/2/library/string.html#format-string-syntax)可以了解更多高级格式化的内容,例如利用 format 方法删除不必要的空格、按长度对齐数据和解数学方程。
除了字符串和数字,用 Python 格式化日期也很容易。Python 的 datetime 模块中有许多方法,可以格式化 Python 中已有(或生成)的日期,也可以读取任意日期格式,然后创建 Python 对象(date 对象、datetime 对象、time 对象)。
Python 中日期格式化最常用的方法是 strftime 和 strptime,将字符串转换成日期最常用的也是这两个方法,如果你用其他编程语言做过日期格式化的话,可能会熟悉这两种格式化方法。想了解更多内容,请阅读关于“strftime 和 strptime 特性”(https://docs.python.org/2/library/datetime.html#strftime-strptime-behavior)的官方文档。
datetime 模块的 strptime 方法可以将字符串或数字转换成 Python 的 datetime 对象。如果你希望将日期和时间保存到数据库中,或者需要调整时区或增加一个小时,这个方法都很有用。转换成 Python 对象之后,你可以充分利用 Python 处理日期的功能,很容易将其重新转换成人类可读或机器可读的字符串。
我们来看一下 zipped_data 列表中保存的采访起止时间数据。首先我们来回顾一下,打印出前几条数据记录,熟悉一下我们即将使用的数据:
for x in enumerate(zipped_data[0][:20]): ➊ print x ..... (7, (['MWM6D', 'Day of interview', ''], '7')) (8, (['MWM6M', 'Month of interview', ''], '4')) (9, (['MWM6Y', 'Year of interview', ''], '2014')) (10, (['MWM7', "Result of man's interview", ''], 'Completed')) (11, (['MWM8', 'Field editor', ''], '2')) (12, (['MWM9', 'Data entry clerk', ''], '20')) (13, (['MWM10H', 'Start of interview - Hour', ''], '17')) (14, (['MWM10M', 'Start of interview - Minutes', ''], '59')) (15, (['MWM11H', 'End of interview - Hour', ''], '18')) (16, (['MWM11M', 'End of interview - Minutes', ''], '7'))
❶ 利用 Python 的 enumerate 函数来查看我们需要处理哪些行的数据。
有了全部数据,现在我们需要找到采访开始和结束的具体时间。利用类似的数据,我们可以判断采访时间更可能出现在早上还是晚上,以及采访时长是否会影响回答的数量。我们还可以找出哪个是第一次采访,哪个是最后一次采访,然后计算平均每次采访所用的时间。
我们尝试用 strptime 将数据导入 Python 的 datetime 对象:
from datetime import datetime start_string = '{}/{}/{} {}:{}'.format( ➊ zipped_data[0][8][1], zipped_data[0][7][1], zipped_data[0][9][1], ➋ zipped_data[0][13][1], zipped_data[0][14][1]) print start_string start_time = datetime.strptime(start_string, '%m/%d/%Y %H:%M') ➌ print start_time
❶ 创建一个字符串模板(base string),用于解析多个数据条目中的数据。本行代码使用的是美国人常用的日期格式:月、日、年,然后是小时和分钟。
❷ 数据读取格式如下:zipped_data[ 第一个数据条目 ][ 数据行编号(由 enumerate 得到)] [ 数据本身 ]。利用第一个数据条目测试,索引号为 8 的那一行是月,索引号为 7 的那一行是日,索引号为 9 的那一行是年。每个元组的第二个元素([1])是我们需要的数据。
❸ 调用 strptime 方法,输入的是一个日期字符串和一个格式字符串,格式字符串的语法可参见 Python 官方文档(https://docs.python.org/2/library/datetime.html#strftime-strptime-behavior)。%m/%d/%Y 代表月、日、年,%H:%M 代表小时和分钟。这个方法返回一个 Python 的 datetime 对象。
如果你用 IPython 运行代码,你不需要用 print 来查看你感兴趣的每一行内容。常见的做法是,只需输入变量名,就可以在交互式终端中查看输出的内容。你甚至还可以用 Tab 键自动补全。
有了上面的代码,我们可以创建一个通用的日期字符串,然后利用 datetime 库的 strptime 方法来解析。在我们的数据集里,由于日期数据的每一项都是单独的元素,我们还可以自己创建 Python 的 datetime 对象,不必使用 strptime 方法。我们来看一下:
from datetime import datetime end_time = datetime( ➊ int(zipped_data[0][9][1]), int(zipped_data[0][8][1]), ➋ int(zipped_data[0][7][1]), int(zipped_data[0][15][1]), int(zipped_data[0][16][1])) print end_time
❶ 将整数直接传递给 datetime 模块的 datetime 类,生成一个日期对象。我们传入整数作为参数,整数之间用逗号隔开。
❷ 由于 datetime 只接受整数,本行代码将所有的数据转换成整数。datetime 输入参数的顺序是年、月、日、时、分,所以我们必须相应调整数据的顺序。
如你所见,利用更少的代码(见上面的例子),我们可以得到采访结束时间的 datetime 对象。现在我们有了两个 datetime 对象,可以对它们做一些数学运算了!
duration = end_time - start_time ➊ print duration ➋ print duration.days ➌ print duration.total_seconds() ➍ minutes = duration.total_seconds() / 60.0 ➎ print minutes
❶ 用结束时间减去开始时间,计算出采访时长。
❷ 打印一个新的 Python 日期类型。这是一个 timedelta 对象。timedelta 会给出两个时间对象的时间差,还可用于改变时间对象,详见 datetime 文档(https://docs.python.org/2/library/datetime.html?highlight=timedelta#datetime.timedelta)。
❸ 利用内置的 days 属性来查看 timedelta 对象里包含了多少天。
❹ 调用 timedelta 对象的 total_seconds 方法,计算时间差包含多少秒。结果精确到微秒。
❺ 计算分钟数,因为 timedelta 对象没有分钟属性。
运行代码,我们知道第一次采访时长 8 分钟——但这是不是采访的平均时长呢?利用刚学的 datetime 模块中的方法解析整个数据集,我们可以计算出采访平均时长。我们前面做了一些简单的 datetime 数学运算,并学习了如何利用数据集创建 Python 的 datetime 对象。下面我们来看一下,能否将这些新的 datetime 对象重新转换成格式化字符串,用在报告中可以提高可读性:
print end_time.strftime('%m/%d/%Y %H:%M:%S') ➊ print start_time.ctime() ➋ print start_time.strftime('%Y-%m-%dT%H:%M:%S') ➌
❶ strftime 只能输入一个参数,就是你希望显示的日期格式。本行代码输出美国标准时间格式。
❷ Python 的 datetime 对象有一个 ctime 方法,可以根据 C 语言的 ctime 标准输出 datetime 对象。
❸ Python 的 datetime 对象可以用你能想到的任意格式输出字符串。本行代码用的是 PHP 语言常用的时间格式。如果你需要用特殊字符串格式与 API 交互,datetime 模块可以帮到你。
Python 的 datetime 对象非常有用,处理、导入和导出(通过格式化的方法)这种对象都非常简单。根据数据集的不同,你可以利用这些新方法将所有的字符串或 Excel 数据导入并转换成 datetime 对象,做统计分析或取平均值,然后再重新转换成字符串用在报告中。
我们学到了很多格式化的方法和技巧。下面我们开始学习更加细致的数据清洗方法。我们将学习如何轻松发现数据中的坏种子(bad seed),以及如何处理它们。
7.2.3 找出离群值和不良数据
在数据集中寻找离群值和不良数据,可能是数据清洗中最难的任务之一,需要一段时间才能做好。即使你对统计学有很深刻的理解,也完全了解离群值可能会对数据造成的影响,在研究这一话题时也要谨慎小心。
你要做的是清洗数据,不是处理数据或修改数据,所以在需要删除离群值或不良数据记录时,多花点时间思考如何处理这些数据。如果你剔除离群值使数据归一化,应该在最终结论中明确说明这一点。
第 9 章中我们会讲到更多寻找离群值的方法,但我们先考虑一些简单的方法,检查你的数据集中是否有不良数据。
判断数据有效性的第一个线索是数据来源。我们在第 6 章中说过,你需要仔细审核数据源,确保数据可信。你还需要咨询数据的采集方法,以及数据是否已经清洗过或处理过。
对于我们这里使用的数据样本来说,我们知道 UNICEF 调查有一套标准的问题格式。我们知道这些普查是定期进行的。我们还知道,他们在培训员工如何正确采访方面有一套标准规程。这些证据都说明,数据是一个好的样本,并不是事先选定的样本。相反,如果我们发现 UNICEF 只采访大城市的家庭,而忽略了农村人口,这可能会导致选择偏差或抽样误差。根据你的数据来源,你应该可以找出数据集可能具有的偏差。
你不可能每次都得到完美的数据。但你应该知道你的数据可能会有哪些取样偏差,确保你不会根据片面的数据集做出全面性的结论。
除了数据源和数据偏差,你还可以通过以下问题发现数据中可能存在的错误:“这些数据是否有不一致的地方?”发现错误数据的一个简单方法就是,查看数据值里是否有错误。例如,你可以浏览整个数据集,查看重要的数据值是否有缺失。你还可以浏览整个数据集,判断数据类型(例如整数、日期、字符串)是否正确匹配。在我们的数据集里尝试寻找缺失数据,看看里面是否有这些问题:
for answer in zipped_data[0]: ➊ if not answer[1]: ➋ print answer
❶ 遍历第一个数据条目的所有行。
❷ 测试某个值是否“存在”。我们要测试的值是元组的第二个元素,我们可以用 if not 语句来测试。
if not 语句
Python 可以用简单的 if not 语句来测试某个值是否存在。试着输入 if not None: print True。发生了什么?试着在 if not 后面加一个空字符串或零。发生了什么?
我们知道,我们的数据是字符串,而且不同于其他数据集,UNICEF 用空字符串来表示缺失数据(而不是 -- 等),所以我们可以通过测试字符串是否存在来测试值是否存在。
根据你的数据类型和数据集不同,你可能需要测试 if x is None 或 if len(x) < 1。你需要在代码的可读性和简洁之间做出平衡,保证代码具体而又明确。记得要遵循 Python 之禅(见 8.4 节)。
从代码的输出中可以看出,第一行没有明显缺失的数据。我们要如何测试整个数据集呢?
for row in zipped_data: ➊ for answer in row: ➋ if answer[1] is None: ➌ print answer
❶ 这次我们不仅仅遍历第一行,而是遍历数据集的每一行。
❷ 我们删除了前面例子中的 [0],因为我们要对每一行进行遍历。
❸ 作为例子,我们在这里测试是否有 None 类型的数据。我们可以知道是否有空的数据点,但无法知道是否有零或空字符串。
可以看出,整个数据集中没有明显缺失的数据,但我们来大致浏览其中一些数据,观察是否有不易发现的缺失数据。在前面的 print 语句中,你可能记得 NA 代表“不适用”(Not Applicable)。
虽然数据没有缺失,我们可能想知道到底有多少回答是 NA,或者某个问题的回答中 NA 的比例是否过高。如果样本量太小(大多数回答都是 NA),我们可能无法根据现有数据得出更一般性的结论。但如果大部分的回答都是 NA,我们可能会发现有趣的事情(为什么这个问题不适用于群体中的大多数人呢?)。
对于每一个具体的问题,我们来看一下回答是否以 NA 为主:
na_count = {} ➊ for row in zipped_data: for resp in row: question = resp[0][1] ➋ answer = resp[1] if answer == 'NA': ➌ if question in na_count.keys(): ➍ na_count[question] += 1 ➎ else: na_count[question] = 1 ➏ print na_count
❶ 定义一个字典,保存回答中包含 NA 的那些问题。将数据保存在哈希对象(比如字典)中,Python 可以快速方便地对数据进行查询。字典的键是问题,字典的值是包含 NA 的回答个数。
❷ 将元组第一部分的第二个元素(问题描述)保存在变量 question 中。第一个元素([0])是短标题,最后一个元素([2])是调查者的问题,有时这一项是缺失的。
❸ 利用 Python 的相等性测试找出值为 NA 的回答。如果注意到 NA 有多种写法,可以利用 if answer in ["NA", "na", "n/a"]: 语句判断具有相同含义的多个回答。
❹ 判断问题是否包含在字典的键中,从而判断问题是否已经包含在字典中。
❺ 如果问题已经包含在字典的键中,利用 Python 的 += 方法将字典的值加 1。
❻ 如果问题尚未包含在字典中,则将其添加到字典中,并将其对应的值设置为 1。
哇!我们的数据集中有好多 NA 回答啊。我们大约有 9000 行数据,其中一些问题有超过 8000 个 NA 回答。可能这些问题与调查的人群或年龄组无关,或者与特定的国家和文化没有太大关系。不管怎样,使用这些 NA 问题的意义不大,无法得出人口调查的任何一般性结论。
在判断数据集是否适用于你的研究目的时,寻找数据集中的 NA 是很有用的。如果发现你想要的问题有大量类似 NA 的回答,你可能需要继续寻找其他数据源,或者重新思考你的问题。
前面我们讲到了缺失数据,现在我们来看一下能否找到类型离群值(type outlier)。比如说,如果年份数据栏中出现了类似 'missing' 或 'NA' 这样的字符串,我们就说出现了类型离群值。如果只有几个数据的类型不匹配的话,我们可能需要处理离群值或几个不良数据。如果大部分数据的类型都不匹配的话,我们可能要重新思考是否要使用这些数据,或者找出这些数据看似“不良数据“的原因。
如果很容易就可以解释这些不一致的原因(比如这个回答只适用于女性,而调查样本中男女都有),那么我们就可以用这些数据。如果这些不一致没有明确的解释,而这个问题对我们的结果又很重要,我们需要继续研究当前数据集,或开始寻找能够对不一致作出解释的其他数据集。
在第 9 章中我们会讲到寻找离群值的更多内容,但现在我们来分析一下数据类型,看能否在当前数据集中找出明显的不一致之处。例如,我们应该检查一下,那些应该以数字作答的数据类型(比如出生年份)是否正确。
我们来看一下回答的类型分布。我们将用到前面计算 NA 回答数目的部分代码,但这次而我们要计算数据类型的数目:
datatypes = {} ➊ start_dict = {'digit': 0, 'boolean': 0, 'empty': 0, 'time_related': 0, 'text': 0, 'unknown': 0 } ➋ for row in zipped_data: for resp in row: question = resp[0][1] answer = resp[1] key = 'unknown' ➌ if answer.isdigit(): ➍ key = 'digit' elif answer in ['Yes', 'No', 'True', 'False']: ➎ key = 'boolean' elif answer.isspace(): ➏ key = 'empty' elif answer.find('/') > 0 or answer.find(':') > 0: ➐ key = 'time_related' elif answer.isalpha(): ➑ key = 'text' if question not in datatypes.keys(): ➒ datatypes[question] = start_dict.copy() ➓ datatypes[question][key] += 1 print datatypes
❶ 第一行代码初始化一个字典,因为用字典保存问题数据是一种快速、可靠的方法。
❷ 本行代码创建一个 start_dict 字典,用于检查数据集中每一个问题的数据类型是否相同。字典中包含所有可能的数据类型,方便我们对比。
❸ 这里我们将变量 key 设置为默认值 unknown。如果变量 key 在下面的 if 或 elif 语句中没有被修改的话,它的值还是 unknown。
❹ Python 字符串类有许多判断数据类型的方法。这里我们用的是 isdigit 方法:如果字符串中只包含数字,本行代码返回 True。
❺ 要判断数据是否与布尔逻辑相关,我们这里测试回答是否包含在一个由布尔回答组成的列表中,列表中包括 Yes/No 和 True/False。虽然我们可以创建一个更加全面的列表,但现在用这个列表就足够了。
❻ 如果字符串中只包含空格,Python 字符串类的 isspace 方法将返回 True。
❼ 字符串的 find 方法返回第一个匹配结果的索引编号。如果在字符串中没有找到匹配,则返回 -1。本行代码同时测试 / 和 :,这是时间字符串中的两个常用符号。这个检查并不全面,但可以作为初步检查。
❽ 如果字符串中只包含字母,字符串的 isalpha 方法将返回 True。
❾ 与计算 NA 回答数目的代码类似,我们这里判断问题是否包含在 datatypes 字典的键中。
❿ 如果问题没有包含在 datatypes 字典中,本行代码将问题添加到字典中,并将 start_dict 的副本作为对应的值。字典的 copy 方法为每一条数据创建一个独立的字典对象。如果将 start_dict 作为每一个问题对应的值,我们将会得到包含所有总数的一个字典,而不是每一个问题都对应一个新字典。
⓫ 将我们找到的键对应的值加 1。这样对于每一个问题和回答,我们对数据类型有了一个“猜测”。
从代码运行结果中已经可以发现不一致之处!有些问题以一种“类型”的回答为主,而其他问题则有许多种回答类型的猜测。我们可以从这里继续,因为它们只是粗略的猜测。
利用这一新信息的一种方法是,找到回答中大部分都是数字类型的问题,观察那些非数字的回答,看它们的值都是什么。我们认为可能会是 NA 或错误插入的值。如果这些值与我们关心的问题有关,我们可以将其归一化。一种做法是将 NA 或错误值替换为 None 或空值。如果你需要对列数据做统计分析的话,这一方法是很有用的。
在对数据集的后续处理过程中,你还会发现数据类型的离群值或 NA 回答。处理这些不一致数据的最佳做法取决于你对该话题和数据集的熟悉程度,也取决于你想要回答的问题。如果你要合并数据集,有时你可以舍弃那些离群值和不良数据,但注意不要忽视微小的趋势。
现在我们已经初步找出了数据集中的离群值及其规律,下面我们继续清除另一种不良数据——重复值,即使是我们自己也可能会创建重复值。
7.2.4 找出重复值
如果你要处理的是同一调查数据的多个数据集,或者是可能包含重复值的原始数据,删除重复数据是确保数据准确可用的重要步骤。如果你的数据集有唯一标识符,你可以利用这些 ID,确保没有误插入重复数据或获取重复数据。如果你的数据集没有索引,你可能需要找到判断数据唯一性的好方法(例如创建一个可索引的键)。
Python 内置库中有几个判断数据唯一性的好方法。我们首先介绍一些概念:
list_with_dupes = [1, 5, 6, 2, 5, 6, 8, 3, 8, 3, 3, 7, 9] set_without_dupes = set(list_with_dupes) print set_without_dupes
输出应该是这样的:
{1, 5, 6, 2, 6, 3, 6, 7, 3, 7, 9,}
这里发生了什么?集合(set)和 frozenset 都是 Python 的内置类型,输入一个可迭代对象(比如列表、字符串或元组),返回一个包含唯一值的集合。
要使用集合和 frozenset,输入的值需要是可哈希的(hashable)。对于可哈希的数据类型,我们可以使用哈希方法,得到的结果总是相同的。例如,我们可以认为代码中的每一个 3 都是完全相同的。
大部分 Python 对象都是可哈希的——只有列表和字典不是。对于任意可哈希类型(整数、浮点数、小数、字符串、元组等),我们可以用 set 创建集合。集合和 frozenset 的另一个巧妙之处在于,它们有一些可以快速比较的属性。我们来看几个例子:
first_set = set([1, 5, 6, 2, 6, 3, 6, 7, 3, 7, 9, 10, 321, 54, 654, 432]) second_set = set([4, 6, 7, 432, 6, 7, 4, 9, 0]) print first_set.intersection(second_set) ➊ print first_set.union(second_set) ➋ print first_set.difference(second_set) ➌ print second_set - first_set ➍ print 6 in second_set ➎ print 0 in first_set
❶ 集合的 intersection 方法返回两个集合的交集(即两个集合共有的元素)。内置的维恩图哦!
❷ 集合的 union 方法将第一个集合的值与第二个集合的值合并在一起。
❸ difference 方法给出的是第一个集合和第二个集合的差集。从下一行代码可以看出,运算顺序很重要。
❹ 用一个集合去减另一个集合,得出二者的差集。改变集合的顺序会改变结果(与数学中的减法类似)。
❺ in 判断元素是否包含在集合中(速度很快)。
你的输出应该是像这样的:
set([432, 9, 6, 7]) set([0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 321, 432, 654, 54]) set([1, 2, 3, 5, 321, 10, 654, 54]) set([0, 4]) True False
在定义唯一数据集和集合对比方面,集合有许多实用的特性。在数据处理过程中,我们经常需要计算一系列值的最大值和最小值,或者需要唯一键的合并。集合可以帮我们完成这些任务。
除了集合,Python 还有一些库可以轻松测试唯一性。你可以用 numpy 库来测试唯一性,这是 Python 中一个强大的数学库,包含很多科学和统计的方法和类。与 Python 核心库相比, numpy 拥有出色的数组功能、数值计算功能和数学功能。numpy 数组还有一个好用的方法,叫作 unique。你可以这样安装 numpy:
pip install numpy
我们来看一下 numpy 库的 unique 的工作原理:
import numpy as np list_with_dupes = [1, 5, 6, 2, 5, 6, 8, 3, 8, 3, 3, 7, 9] print np.unique(list_with_dupes, return_index=True) ➊ array_with_dupes = np.array([[1, 5, 7, 3, 9, 11, 23], [2, 4, 6, 8, 2, 8, 4]]) ➋ print np.unique(array_with_dupes) ➌
❶ numpy 库的 unique 方法会保存索引编号。设置 return_index=True,返回的是由数组组成的元组:第一个是唯一值组成的数组,第二个是由索引编号组成的扁平化数组——只包含每一个数字第一次出现时的索引编号。
❷ 为了展示 numpy 的更多功能,本行代码创建了一个 numpy 矩阵。矩阵是由(长度相同的)数组组成的数组。
❸ unique 将矩阵转换成由唯一值组成的集合。
你的输出是这样的:
(array([1, 2, 3, 5, 6, 7, 8, 9]), array([ 0, 3, 7, 1, 2, 11, 6, 12])) [ 1 2 3 4 5 6 7 8 9 11 23]
如果没有唯一键,你可以编写函数来创建唯一集合。写法和列表生成式一样简单。用 Python 集合在我们的数据集上试验一下。首先,我们观察数据集中哪些数据是唯一的,然后找出唯一数:
for x in enumerate(zipped_data[0]): print x ..... (0, (['HH1', 'Cluster number', ''], '1')) (1, (['HH2', 'Household number', ''], '17')) (2, (['LN', 'Line number', ''], '1')) (3, (['MWM1', 'Cluster number', ''], '1')) (4, (['MWM2', 'Household number', ''], '17')) (5, (['MWM4', "Man's line number", ''], '1'))
可以看出,每一行前五个元素可能包含了唯一标识符。假设我们对数据的理解是正确的,那么类群编号(Cluster number)、家庭编号(Household number)和男性家庭成员编号(Man's line number)三者应该是一个唯一组合。家庭成员编号(Line number)可能也是唯一编号。我们来看一下这是否正确:
set_of_lines = set([x[2][1] for x in zipped_data]) ➊ uniques = [x for x in zipped_data if not set_of_lines.remove(x[2][1])] ➋ print set_of_lines
❶ 首先,我们创建一个集合,里面包含调查中的所有家庭成员编号。家庭成员编号是每一个回答中的第三个元素,而编号值是里面第二个元素(x[2][1])。我们使用列表生成式来提高代码的运行速度。
❷ set_of_lines 现在保存的是唯一键。我们可以利用集合对象的 remove 方法,判断数据集中每个键出现的次数是否多于一次。如果家庭成员编号是唯一的,那么每一个键只会删除一次。如果有重复值,remove 将会引发 KeyError,说明这个键已经不在集合中了。
嗯。运行代码时的确出现了错误,所以我们关于家庭成员编号是唯一的假设是错误的。如果仔细观察我们创建的集合,家庭成员编号似乎是从 1 到 16,然后依次重复。
你经常需要处理混乱的数据集,或者类似上面的数据集,没有明确的唯一键。遇到这种情况我们的建议是,找到唯一键,然后用这个唯一键来做对比。
创建唯一键的方法有很多。我们可以用采访的开始时间作为唯一键。但我们不确定 UNICEF 是否同时安排了多个调查组。如果是的话,我们可能会将事实上不是重复值的元素当作重复值删掉。我们可以用被采访人的出生日期和采访时间一起做唯一键,这样不太可能有重复值,但如果有字段缺失的话就麻烦了。
一种优雅的解决方法是,检查类群编号、家庭编号和家庭成员编号三者是否构成唯一键。如果是的话,我们可以将这个方法应用到整个数据集上——即使没有采访起止时间也可以。我们来试一下!
set_of_keys = set([ '%s-%s-%s' % (x[0][1], x[1][1], x[2][1]) for x in zipped_data]) ➊ uniques = [x for x in zipped_data if not set_of_keys.remove( '%s-%s-%s' % (x[0][1], x[1][1], x[2][1]))] ➋ print len(set_of_keys) ➌
❶ 利用类群编号、家庭编号和家庭成员编号创建一个字符串,我们认为这三个编号的组合是唯一的。我们将三个编号用 - 隔开,这样方便区分。
❷ 利用 remove 方法重新创建我们用到的唯一键。这样会一个个删除所有数据,uniques 列表包含每一个唯一数据。如果有重复数据的话,代码还会抛出错误。
❸ 计算唯一键列表的长度。我们可以知道数据集中有多少个唯一值。
太好了!这一次没有报错。从列表的长度中可以看出,每一行都是唯一的。这也符合我们对这个数据集的预期,因为 UNICEF 在发布数据之前会做一些数据清洗工作,确保没有重复值。如果我们要将这些数据与其他 UNICEF 数据合并的话,我们可能需要将 M 添加到唯一键中,因为这是男性组的调查。然后我们可以对相同编号的家庭做交叉对照。
唯一键可能并不容易发现,这取决于你所用的数据。出生日期和地址可能是一个好的唯一键组合。两个 24 岁女性住在同一地方,出生日期又恰好相同的可能性是很小的,虽然也不是完全不可能,比如她们是住在一起的双胞胎!
讲完重复值,下面我们来讲模糊匹配,这是寻找重复值的好方法,尤其是杂乱的数据集。
7.2.5 模糊匹配
如果你要处理不止一个数据集,或者是未标准化的脏数据,可以用模糊匹配来寻找和合并重复值。模糊匹配可以判断两个元素(通常是字符串)是否“相同”。模糊匹配并不像自然语言处理或机器学习在处理大型语言数据集时那么深入,它可以帮我们判断“My dog & I”和“me and my dog”的意思相近。
模糊匹配有很多种做法。一个由 SeatGeek(http://chairnerd.seatgeek.com/fuzzywuzzy-fuzzy-string-matching-in-python/)开发的 Python 库,使用很酷的内置方法来匹配多种场景的线上售票。安装方法如下:
pip install fuzzywuzzy
比如说你要处理一些脏数据。可能是输入时粗心,也可能是用户输入的,导致数据中包含拼写错误和较小的语法错误或句法偏移。你要怎么处理这种情况呢?
from fuzzywuzzy import fuzz my_records = [{'favorite_book': 'Grapes of Wrath', 'favorite_movie': 'Free Willie', 'favorite_show': 'Two Broke Girls', }, {'favorite_book': 'The Grapes of Wrath', 'favorite_movie': 'Free Willy', 'favorite_show': '2 Broke Girls', }] print fuzz.ratio(my_records[0].get('favorite_book'), my_records[1].get('favorite_book')) ➊ print fuzz.ratio(my_records[0].get('favorite_movie'), my_records[1].get('favorite_movie')) print fuzz.ratio(my_records[0].get('favorite_show'), my_records[1].get('favorite_show'))
➊ 这里我们用的是 fuzz 模块的 ratio 函数,接受两个字符串作比较。返回的是两个字符串序列的相似程度(一个介于 1 和 100 之间的值)。
根据我们自己对流行文化和英语的理解,这两组数据的爱好是相同的,但是二者拼写却不同。FuzzyWuzzy 可以帮我们处理这些无心之错。可以看出,ratio 的匹配得分相当高。这给了我们一些信心,相信这两个字符串是相似的。
我们再尝试另一个 FuzzyWuzzy 方法,看结果如何。为了简化问题并方便对比,我们采用的是相同的数据:
print fuzz.partial_ratio(my_records[0].get('favorite_book'), my_records[1].get('favorite_book')) ➊ print fuzz.partial_ratio(my_records[0].get('favorite_movie'), my_records[1].get('favorite_movie')) print fuzz.partial_ratio(my_records[0].get('favorite_show'), my_records[1].get('favorite_show'))
➊ 这里我们调用的是 fuzz 模块的 partial_ratio 函数,接受两个字符串作比较。返回的是匹配程度最高的子字符串序列的相似程度(一个介于 1 和 100 之间的值)。
哇,我们的得分更高了! partial_ratio 函数可以比较子字符串,这样我们就不必担心某人漏掉一个字母(像上面书的例子)或拼写不同。这样所有字符串的匹配度都会更高。
如果你的数据有一些简单的不一致之处,这些函数都可以帮你找到不匹配的地方。但如果你的数据只有几个字符的差别,但在含义上却有很大不同,你可能想要同时测试相似性和不同之处。例如“does”和“doesn't”的含义截然不同,但在拼写上差别不大。在第一个 ratio 例子中,这两个字符串的得分不高,但子字符串却可以匹配。一定要了解你的数据及其内部的复杂性!
FuzzyWuzzy 还有其他很酷的功能。我们来研究其中几个,它们可能适用于你的数据清洗任务:
from fuzzywuzzy import fuzz my_records = [{'favorite_food': 'cheeseburgers with bacon', 'favorite_drink': 'wine, beer, and tequila', 'favorite_dessert': 'cheese or cake', }, {'favorite_food': 'burgers with cheese and bacon', 'favorite_drink': 'beer, wine, and tequila', 'favorite_dessert': 'cheese cake', }] print fuzz.token_sort_ratio(my_records[0].get('favorite_food'), ➊ my_records[1].get('favorite_food')) print fuzz.token_sort_ratio(my_records[0].get('favorite_drink'), my_records[1].get('favorite_drink')) print fuzz.token_sort_ratio(my_records[0].get('favorite_dessert'), my_records[1].get('favorite_dessert'))
➊ 这里我们调用的是 fuzz 模块的 token_sort_ratio 函数,在匹配字符串时不考虑单词顺序。对于格式不限的调查数据来说,这个方法是很好用的,比如“I like dogs and cats”和“I like cats and dogs”的含义相同。每个字符串都是先排序然后再比较,所以如果包含相同的单词但顺序不同,也是可以匹配的。
从输出中可以看出,使用标记(这里是单词)有相当大的可能匹配顺序不同的单词。我们发现二者最爱的饮料相同,只是顺序不同。如果标记的顺序不会改变含义,我们就可以用这个方法。对于 SeatGeek 来说,“Pittsburgh Steelers vs. New England Patriots”与“New England Patriots vs. Pittsburgh Steelers”是完全相同的(只是主场优势不同)。
利用相同的数据,我们来看 FuzzyWuzzy 中另一个与标记有关的函数:
print fuzz.token_set_ratio(my_records[0].get('favorite_food'), ➊ my_records[1].get('favorite_food')) print fuzz.token_set_ratio(my_records[0].get('favorite_drink'), my_records[1].get('favorite_drink')) print fuzz.token_set_ratio(my_records[0].get('favorite_dessert'), my_records[1].get('favorite_dessert'))
➊ 这里我们调用的是 fuzz 模块的 token_set_ratio 函数,同样用的是标记方法,但比较的是标记组成的集合,得出两个集合的交集和差集。这个函数对排序后的标记尝试寻找最佳匹配,返回这些标记相似的比例。
这里可以看出,如果我们不知道数据集中的相似和不同之处,可能会有意想不到的副作用。其中一个答案的拼写是错误的。我们知道芝士蛋糕(cheesecake)和奶酪(cheese)是不同的东西,但利用标记集合方法,这两者却错误地匹配(false positive)了。我们没能正确匹配包含芝士汉堡(cheeseburger)的回答,即使二者是完全相同的。你能用我们已经学过的另一个方法来做到这一点么?
FuzzyWuzzy 提供的最后一个匹配方法是 process 模块。如果你只有有限的几个选项和杂乱的数据,这个模块是很有用的。比如说回答只有 yes、no、maybe 和 decline to comment 四种。我们看一下如何对其匹配:
from fuzzywuzzy import process choices = ['Yes', 'No', 'Maybe', 'N/A'] process.extract('ya', choices, limit=2) ➊ process.extractOne('ya', choices) ➋ process.extract('nope', choices, limit=2) process.extractOne('nope', choices)
❶ 利用 FuzzyWuzzy 的 extract 方法,将字符串与可能匹配的列表依次比较。函数返回的是 choices 列表中两个可能的匹配。
❷ 利用 FuzzyWuzzy 的 extractOne 方法,返回 choices 列表中与我们的字符串对应的最佳匹配。
啊哈!给定几个单词,我们事先知道其“含义”相同,process 可以找出最佳猜测——在上面的例子中也是正确的猜测。extract 返回的是带有比例的元组,代码对回答字符串进行解析,并对其相似之处和不同之处作比较。extractOne 函数仅返回最佳匹配及其比例组成的元组。根据需求的不同,你可以选择 extractOne 仅找出最佳匹配,然后继续下一步。
现在你已经学过所有字符串匹配的内容了,下面我们来学习如何自己编写类似的字符串匹配函数。
7.2.6 正则表达式匹配
模糊匹配不一定总能满足你的需求。如果你只需要匹配字符串的一部分,应该怎么办?如果你只想匹配电话号码或电子邮件地址呢?在抓取数据时(我们将在第 11 章中学习),或编译多个来源的原始数据时,这些都是你会遇到的问题。正则表达式可以帮你解决上述大部分问题。
利用正则表达式,计算机可以对代码中的字符串或数据的模式进行匹配、查找或删除。开发人员往往对正则表达式怀有恐惧之心,因为它们可能会变得异常复杂、难以理解。但它们是很有用的,当你需要正则表达式来帮你解决问题时,一些简单的基础知识就可以帮你阅读、编写和理解它们。
虽然正则表达式的名声不太好,但其基本语法是相当简单易学的。表 7-1 给出了正则表达式的基础知识。
表7-1:正则表达式基础知识
字符/模式 | 文字说明 | 匹配实例 |
\w | 匹配任意一个字母字符或数字字符,包括下划线 | a、0 或 _ |
\d | 匹配任意一个数字 | 1、2 或 4 |
\s | 匹配任意一个空格字符 | ' ' |
+ | 匹配一个或多个(贪婪)模式或字符 | \d+ 可以匹配 476373 |
\. | 匹配 . 字符 | . |
* | 匹配零个或多个(贪婪)字符或模式(与 if 的作用几乎相同) | \d* 可以匹配 03289 和 '' |
| | 匹配多个模式中的一个(类似 OR) | \d|\w 可以匹配 0 或 a |
[] 或 () | 字符类(将你希望匹配的字符放在一个字符空间里)和字符组(将你希望匹配的字符放在一个组里) | [A-C] 或 (AlBlC) 都可以匹配 A |
- | 合并字符组 | [0-9]+ 匹配 \d+ |
想查看更多实例,我们推荐将这个优秀的正则表达式备忘单(http://www.virtu-al.net/2009/04/30/powershell-regex-cheat-sheet/)添加到书签里。
作为一名 Python 开发人员,没有必要记住正则表达式的语法,但语法规范的正则表达式可以在很多方面帮到你。利用 Python 内置的正则表达式模块 re,你可以轻松查找基本的匹配和分组方法。
我们来看一下正则表达式的几个用法:
import re word = '\w+' ➊ sentence = 'Here is my sentence.' re.findall(word, sentence) ➋ search_result = re.search(word, sentence) ➌ search_result.group() ➍ match_result = re.match(word, sentence) ➎ match_result.group()
❶ 定义一个普通字符串的基本模式。这个模式可以匹配包含字母和数字、但不包含空格和标点的字符串。这个模式会一直匹配,直到无法匹配为止(+ 表示贪婪匹配!)。
❷ re 模块的 findall 方法可以找出这个模式在字符串中的所有匹配。成功匹配了句子中的每一个单词,但没有匹配句号。在这个例子中我们用的模式是 \w,所以不会匹配标点和空格。
❸ search 方法可以在整个字符串中搜索匹配。发现匹配后,则返回匹配对象。
❹ 匹配对象的 group 方法会返回匹配的字符串。
❺ match 方法只从字符串开头开始搜索。它的工作原理与 search 不同。
我们可以轻松匹配句子中的单词,根据我们的需要,我们还可以改变寻找匹配的方式。在上面的例子中我们看到,findall 返回的是所有匹配组成的列表。比如说你只想提取长文本中的网站。你可以利用正则表达式模式找到链接,然后利用 findall 从文本中提取出所有链接。或者你可以查找电话号码或日期。如果你能够将想要寻找的内容转换成简单的模式,并将其应用到字符串数据上,你就可以使用 findall 方法。
我们还用到了 search 和 match 方法,在上面的例子中二者返回的结果相同——它们匹配的都是句子中的第一个单词。我们返回的是一个匹配对象,然后可以利用 group 方法获取数据。group 方法还可以接受参数。用 .group(0) 试一下。发生了什么?你觉得 0 是什么意思?(提示:想想列表!)
search 和 match 实际上大不相同。我们再多看几个例子,才能发现二者的不同之处:
import re number = '\d+' ➊ capitalized_word = '[A-Z]\w+' ➋ sentence = 'I have 2 pets: Bear and Bunny.' search_number = re.search(number, sentence) search_number.group() ➌ match_number = re.match(number, sentence) match_number.group() ➍ search_capital = re.search(capitalized_word, sentence) search_capital.group() match_capital = re.match(capitalized_word, sentence) match_capital.group()
❶ 定义一个数字模式。加号表示贪婪匹配,所以它会尽可能匹配所有数字,直到遇到一个非数字字符为止。
❷ 定义一个大写单词的匹配。这个模式使用方括号来定义更长模式的一部分。方括号的意思是,我们希望第一个字母是大写字母。后面紧跟着的是一个连续的单词。
❸ 我们这里调用 group 时发生了什么?可以看到,search 方法返回的是匹配对象。
❹ 你认为这里的结果会是什么?可能是数字,但实际上出现了错误。match 返回的是 None,而不是匹配对象。
现在我们可以更清楚地看到 search 和 match 的区别。利用 match 我们无法找到一个好的匹配,尽管事实上我们尝试的每一次搜索都有许多匹配。为什么会这样?前面说过,match 从字符串的开头开始搜索,如果没有找到匹配,它会返回 None。与此相反,search 会继续向后搜索,直到找到匹配为止。只有到达字符串末尾还没有找到匹配时,search 才会返回 None。如果你需要匹配以特定模式开头的字符串,用 match 比较好。如果你只想在字符串中找到第一个匹配或任意匹配,最好选择 search。
关于正则表达式,这里还有一点需要注意:你注意到了吗?你预期找到的第一个大写单词是什么?是“I”还是“Bear”?为什么我们没有找到“I”?什么模式能够同时匹配二者?(提示:参考上面的表格,看看你都能使用哪些通配符变量!)
现在我们更多地了解了正则表达式的语法,以及 match、search 和 findall 的用法。下面我们看一下能否创建可以匹配多组的模式。在上面的例子中,我们只有一个模式组,所以我们对匹配结果调用 group 方法,只得到一个结果。但利用正则表达式你可以找到不止一个模式,你还可以给找到的匹配组起一个变量名,这样可以提高代码的可读性,还可以确保每组匹配正确。
让我们来试一下!
import re name_regex = '([A-Z]\w+) ([A-Z]\w+)' ➊ names = "Barack Obama, Ronald Reagan, Nancy Drew" name_match = re.match(name_regex, names) ➋ name_match.group() name_match.groups() ➌ name_regex = '(?P<first_name>[A-Z]\w+) (?P<last_name>[A-Z]\w+)' ➍ for name in re.finditer(name_regex, names): ➎ print 'Meet {}!'.format(name.group('first_name')) ➏
❶ 这里我们用的是相同的大写单词语法,用了两次,分别放在括号里。括号的作用是分组。
❷ 这里我们在 match 方法里用的模式包含多个正则表达式组。如果找到匹配的话,将返回多个匹配组。
❸ 对匹配结果调用 groups 方法,返回找到的所有匹配组构成的列表。
❹ 为各组命名可以让代码清晰明确。在这个模式中,第一组叫 first_name,第二组叫 last_name。
❺ finditer 的作用与 findall 类似,但返回的是一个迭代器(iterator)。利用这个迭代器,我们可以逐个查看字符串中的匹配。
❻ 利用我们学过的字符串格式化的知识,打印出我们的数据。这里我们从每个匹配中仅提取名字(first name)。
利用 ?P<variable_name> 为模式组命名,这样写出的代码更容易理解。从上面的例子中可以看出,创建两个(或多个)特定模式构成的组并找到匹配数据也是相当容易的。有了这些方法,在阅读别人写的(或者你六个月前写的)正则表达式时就不用费力猜测了。你能自己写一个正则表达式实例来匹配中间名缩写吗(如果有的话)?
利用强大的正则表达式,你可以快速识别字符串的内容,并轻松解析字符串中的数据。在解析特别杂乱的数据集时,比如网络抓取的数据,正则表达式的作用是无可替代的。想阅读更多正则表达式的内容,我们推荐在 RegExr 网站(http://www.regexr.com/)上试用交互式正则表达式解析器,还可以通读免费的正则表达式教程(http://www.regular-expressions.info/tutorial.html)。
前面学过了这么多匹配方法,现在你可以轻松找出重复值。下面我们来看一下,在我们的数据集中遇到重复值时可以有哪些做法。
7.2.7 如何处理重复记录
根据你的数据状态,你可能希望合并重复记录。如果你的数据集只有重复行,那就无需担心数据存储的问题,这些数据已经包含在最终数据集内,你只需在清洗后的数据中删除或舍弃这些行即可。但如果你要合并不同的数据集,并希望保存每一条重复数据,那你需要思考用 Python 实现的最佳做法。
在第 9 章我们将会讲到合并数据的一般性方法,用到一些新库。但合并数据行还有简单的方法,和你解析数据的方法相同。如果你用 DictReader 提取数据的话,我们用一个例子来讲解这个方法。我们将合并男性数据集的一些数据行。这次我们希望基于家庭来合并数据,所以我们关注的是每一家的调查,而不是每个人的调查:
from csv import DictReader mn_data_rdr = DictReader(open('data/unicef/mn.csv', 'rb')) ➊ mn_data = [d for d in mn_data_rdr] def combine_data_dict(data_rows): ➋ data_dict = {} ➌ for row in data_rows: key = '%s-%s' % (row.get('HH1'), row.get('HH2')) ➍ if key in data_dict.keys(): data_dict[key].append(row) ➎ else: data_dict[key] = [row] ➏ return data_dict ➐ mn_dict = combine_data_dict(mn_data) ➑ print len(mn_dict)
❶ 我们用到 DictReader 模块,方便解析我们想要的所有字段。
❷ 我们定义了一个函数,还可以用在其他 UNICEF 数据集上。我们之所以将函数命名为 combine_data_dict,是因为函数的作用是将 data_rows 合并,然后返回一个字典。
❸ 定义一个新的数据字典,用于函数的返回值。
❹ 在前面的例子中我们利用类群编号、家庭编号和家庭成员编号创建一个唯一键,与之类似,本行代码也创建一个唯一键。“HH1”代表类群编号,“HH2”代表家庭编号。本行代码用这两个编号来表示唯一家庭。
❺ 如果已经添加过这个家庭,本行代码将当前数据行添加到数据列表中。
❻ 如果这个家庭尚未添加,本行代码新建一个列表,列表元素为当前数据行。
❼ 在函数末尾,返回新的数据字典。
❽ 现在传入数据行运行该函数,并将新生成的字典赋值给一个变量,以供后续使用。本行代码将新生成的字典命名为 mn_dict,我们可以利用这个字典来查看有多少个唯一家庭,以及每个家庭分别做了多少份调查。
如果函数结尾没有 return 的话,函数将会返回 None。在你开始编写自己的函数时,一定要注意返回值的错误。
我们找到了约 7000 个唯一家庭,这说明采访中有 2000 多的男性与其他男性属于同一家庭。本次采访每个家庭平均有 1.3 个男性。像这样的简单计算可以让我们对数据有更深入的了解,还可以帮我们思考数据的含义,并发现基于现有数据我们可以回答哪些问题。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论