数据清洗与规整
在上一章中,我们学习了如何将数据从源文件加载到 DataFrames 中。然而,直接读取的数据往往还不能直接用于分析。我们需要对其进行“规整” (Wrangling),包括评估数据质量、处理异常值、以及转换数据结构。
这个过程往往是循环往复的:质量检查会揭示我们需要做什么转换,而在转换后我们可能又会发现新的质量问题。
1. 数据质量预期
不同的数据来源,其初始质量差异巨大。了解数据来源有助于我们预估清洗工作量:
- 科学实验数据:
- 通常非常干净且文档齐全。
- 结构简单,旨在共享和复现。
- 一般只需极少的清洗即可直接分析。
- 政府调查数据:
- 通常附带详细的代码簿 (Codebooks) 和元数据。
- 解释了数据的收集方式和格式。
- 通常也是“开箱即用”的。
- 行政管理数据 (Administrative Data):
- 数据本身可能很规整,但缺乏只有内部人员才知道的背景知识。
- 由于我们使用数据的目的往往与最初收集数据的目的不同(Secondary Use),通常需要大量的质量检查、特征转换或多表合并。
- 非正式数据 (Informal Data):
- 例如:网络爬虫数据、推文、博客、维基百科表格。
- 通常非常杂乱,且几乎没有文档。
- 需要大量的格式化和清洗工作才能转换为可用信息。
2. 清洗流程概览
在本章中,我们将数据清洗分解为以下几个阶段:
- 评估数据质量 (Assess Data Quality):检查单个值或整列数据的有效性。
- 处理缺失值 (Handle Missing Values):决定是填充、删除还是保留缺失数据。
- 特征转换 (Transform Features):将原始字段转换为更有分析价值的形式(例如从日期字符串提取年份)。
- 重塑数据 (Reshape Data):修改数据的结构和粒度(例如宽表转长表)。
范围与可视化
评估数据质量的一个重要步骤是考虑数据的范围 (Scope)(即总体是谁,样本代表了谁,我们在第 2 章讨论过)。 此外,探索性数据分析 (EDA),特别是可视化技术,是发现数据脏点的重要手段(我们将在后续章节详细介绍)。
3. 案例数据集
为了讲解这些概念,我们将继续使用上一章介绍的两个数据集:
- DAWN 调查数据(关于药物滥用的急诊记录)
- 旧金山食品安全数据(餐厅检查记录)
不过,为了更清晰地演示每个步骤,我们将从一个更简单、更干净的示例开始入手。
4. 实例:处理 Mauna Loa CO2 数据
我们曾在第 2 章提到由 NOAA 在 Mauna Loa 观测站监测的空气 CO2 浓度数据。这个数据集相对规整,非常适合用来演示数据清洗的全过程:质量检查、处理缺失值和数据重塑。
数据文件位于 data/co2_mm_mlo.txt。
4.1 初始检查与加载
在直接加载数据之前,按照上一章的经验,我们先检查文件的格式、编码和大小。
from pathlib import Path
import os
import chardet
co2_file_path = Path('data') / 'co2_mm_mlo.txt'
# 检查文件大小和编码
print(os.path.getsize(co2_file_path))
print(chardet.detect(co2_file_path.read_bytes())['encoding'])
51131
ascii
文件大约 50 KiB,纯文本 ASCII 编码。这种大小完全可以轻松读入内存。接下来,我们使用 read_text() 查看文件的前几行,以确定其内部结构:
# 读取文本并按行分割
lines = co2_file_path.read_text().split('\n')
print(len(lines)) # 811 行
print(lines[:6])
输出:
['# --------------------------------------------------------------------',
'# USE OF NOAA ESRL DATA',
'# ',
'# These data are made freely available to the public and the',
...
文件开头包含大量注释(以 # 开头)。我们需要找到实际数据开始的位置:
# 查看第 69 到 75 行
print(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 行(索引 72)开始。
- 分隔符:值之间通过空格分隔(whitespace)。
- 表头:列名分散在两行注释中,直接读取比较麻烦,不如手动指定。
根据这些信息,我们可以使用 pd.read_csv 加载数据:
import pandas as pd
co2 = pd.read_csv('data/co2_mm_mlo.txt',
header=None, # 不使用文件中的表头
skiprows=72, # 跳过前 72 行注释
sep='\s+', # 使用正则表达式 \s+ 匹配任意空白字符
names=['Yr', 'Mo', 'DecDate', 'Avg', 'Int', 'Trend', 'days']) # 手动命名
co2.head()
4.2 质量检查 (Quality Checks)
数据加载成功后,我们得到了一个 1958 年至 2019 年的 CO2 月度平均值表格。形状为 (738, 7)。
虽然科学数据通常很干净,但我们不能掉以轻心。让我们直接绘制 Avg(月平均值)随时间的变化:
import plotly.express as px
fig = px.line(co2, x='DecDate', y='Avg', labels={'DecDate':'Date', 'Avg':'Average CO2'})
fig.show()
发现了问题!
图表中出现了几个剧烈的向下尖峰。查看数据描述 co2.describe() 会发现 Avg 列的最小值为 -99.99。
# 查看数据的五数概括(最小值、四分位数、最大值)
# [3:] 表示切片掉前三行 (count, mean, std),只看分布情况
co2.describe()[3:]
这显然不是正常的 CO2 浓度。查阅文件开头的文档可知:-99.99 代表缺失值。
我们进行以下几个维度的质量检查:
-
检查行数 (Shape): 数据涵盖 1958.3 到 2019.8。大致有 \(2019 - 1957 = 62\) 年。 \(62 \times 12 \approx 744\) 行。实际 738 行,且没有任何月份的记录数异常(大部分月份出现 62 次,首尾年份的月份出现 61 次),基本符合预期。
-
检查
days列 (Days Operational): 这一列表示设备当月运行的天数。文档指出 -1 代表该信息缺失。 绘制直方图或散点图会发现,所有的 -1 (缺失值) 都集中在早期年份。这提示我们早期的数据记录可能不如后期完整。 -
检查
Avg列 (CO2 Measurement): 我们已经发现 -99.99 是异常值。结果显示只有 7 行数据的# 查看所有平均值为负数的记录 co2[co2["Avg"] < 0]Avg是 -99.99。
4.3 处理缺失数据 (Address Missing Data)
我们确定了 -99.99 是缺失值,那么该如何处理它们?常见的策略有三种:
- 删除 (Drop):直接丢弃这些行。
- 后果:折线图会在这些点断开(或者直接连接前后点,掩盖了中间缺失的事实)。
- 标记 (NaN):将 -99.99 替换为 Pandas 的
NaN。- 后果:绘图时线条会中断,明确表示这里没有数据。
- 插值/填补 (Impute):使用合理的估计值填补。
- 幸运的是,数据集中已经提供了一个
Int(Interpolated) 列。NOAA 的科学家通过考虑季节性周期和长期趋势,计算出了插值。 - 检查:我们可以看到,当
Avg为 -99.99 时,Int列都有正常的数值。
- 幸运的是,数据集中已经提供了一个
对于这个数据集,使用 Int (插值) 列通常是分析长期趋势的最佳选择,因为它保证了数据的连续性,且插值方法科学合理。
4.4 重塑数据表 (Reshaping)
数据的 粒度 (Granularity) 是月度平均值。 如果我们关注的是:“过去 60 年全球变暖的总体趋势”,月度数据的季节性波动(夏天植物光合作用强吸收 CO2,冬天反之)可能会干扰视线。
我们可以通过 聚合 (Aggregation) 将粒度变得更粗,例如按年计算平均值:
# 按年份分组并计算平均值
co2_annual = co2.groupby('Yr').mean()
# 绘制年度趋势
px.line(co2_annual, y='Avg', labels={'index':'Year', 'Avg':'Annual Average CO2'})
重塑后的数据消除了季节性波动,清晰地展示了 CO2 浓度在过去半个多世纪里持续上升的趋势(增加了近 100 ppm)。
总结
在这个案例中,我们经历了数据清洗的典型流程:
- 加载:根据文本结构自定义
read_csv参数。 - 检查:通过可视化和统计描述发现异常值 (-99.99)。
- 清洗:理解异常值的含义(缺失数据)并选择处理方案(使用插值列)。
- 转换:通过聚合改变数据粒度,以适应分析目标(长期趋势)。
5. 深入数据质量检查
在将文件读入 DataFrame 并大致了解了数据的范围和粒度后,我们需要进行更全面的质量检查。我们可以从以下四个维度来评估数据质量:
- 范围 (Scope):数据是否符合我们对总体的理解?
- 值 (Values):数值是否合理?是否在预期范围内?
- 关系 (Relationships):相关联的特征之间是否一致?
- 分析价值 (Analysis):哪些特征对分析有用?
5.1 基于“范围”的检查 (Scope)
我们先前的章节讨论了数据范围(目标总体、抽样框、样本)。这些概念不仅用于评估结论的普适性,也能帮助我们发现数据质量问题。
-
案例:旧金山邮政编码 我们知道旧金山的邮政编码应该以 941 开头。但检查数据发现:
结果中出现了bus['postal_code'].value_counts().tail()92672,64110等明显不属于旧金山的邮编,甚至还有941033148这种异常格式。这提示我们需要清洗邮编字段。 -
案例:CO2 浓度 根据背景知识(如 NOAA 或 Climate.gov),全球 CO2 浓度典型值在 300 到 450 ppm 之间。 之前我们发现的
-99.99明显超出了这个物理范围,从而被识别为异常值。
5.2 基于“值”的检查 (Measurements and Values)
每个特征都有其合理的取值范围或数据类型。我们可以对照常识或代码簿 (Codebook) 进行检查。
-
常识检查:
- 餐厅违规数:应该是 0 到 5 这样的小整数,不可能是负数或几千。
- 月份:必须在 1 到 12 之间。
- 价格:必须是数字,且通常为正数。
- 体重:单位是磅还是公斤?数值是否符合人类体重范围?
-
代码簿检查:
- DAWN 数据中的就诊类型:代码簿定义了类型及其对应的整数代码 (1-8)。我们可以验证数据中是否只包含这些整数。
5.3 基于“关系”的检查 (Cross-Checking)
有些特征之间存在逻辑约束,我们可以通过交叉检查来验证一致性。
-
案例:DAWN 数据中的年龄与就诊原因 代码簿指出:“酒精 (Alcohol)” 只有在患者年龄 小于 21 岁 时才被视为有效的急诊原因(可能与法定饮酒年龄有关)。 我们可以通过交叉表 (Crosstab) 来验证这一点:
如果数据质量良好,我们应该看到# 交叉统计年龄(行)和就诊类型(列) # 假设 type=3 代表酒精相关 display_df(pd.crosstab(dawn['age'], dawn['type']))type=3(酒精) 的列中,只有age小于 21 (代码为 1-4) 的行有数据,而 21 岁以上 (代码 5+) 的行为 0。这能确认数据的内部逻辑是一致的。
5.4 基于“分析价值”的检查 (Analysis)
即使数据是“正确”的,也不一定对分析“有用”。
- 单一值特征:如果某列 99.9% 的值都相同,它对区分样本几乎没有帮助。
- 例如旧金山检查数据的
type列,14221 行是 "routine"(常规检查),只有 1 行是 "complaint"(投诉)。 - 这不仅信息量低,那唯一的 "complaint" 甚至可能被视为异常点。在这种情况下,我们可能会选择删除该列,或者删除那条异常记录。
- 例如旧金山检查数据的
- 缺失过多:如果某列缺失值太多,且缺失本身没有规律,可能无法用于分析。
5.5 修复策略
发现问题后,我们通常有四种处理方案:
- 保持原样 (Leave as is):
- 如果异常值不仅是错误的,而且反映了某种真实情况(或者影响微乎其微),可以保留。
- 或者将其转换为标准的缺失值标记 (NaN)。
- 修改特定值 (Modify):
- 如果你确切知道错误原因(例如单位错误、拼写错误),可以修正它。
- 建议:创建一个新列存储修正后的值,保留原始列以备查证。
- 删除特征 (Remove Feature):
- 如果某列大部分数据都损坏或无用,直接删除整列。
- 删除记录 (Drop Records):
- 慎用。除非你有充分理由认为这些记录是单纯的错误且无法修复。
- 不要仅仅因为数据“看起来不顺眼”就删除,这通常会引入偏差。
6. 处理缺失值与记录
在第 3 章中,我们讨论了因未响应 (Nonresponse) 导致的偏差。如果未响应者在关键方面与响应者不同,仅仅增加样本量是无法消除偏差的。同样,在数据框中,缺失值也是一个棘手的问题。
6.1 缺失的类型
了解缺失值的性质对于选择处理方法至关重要:
-
完全随机缺失 (MCAR - Missing Completely at Random):
- 缺失与任何数据特征(包括自身)都无关。
- 例如:实验设备因停电随机故障了一天。
- 影响:数据量减少,但不会引入偏差。可以安全删除。
-
随机缺失 (MAR - Missing at Random):
- 缺失依赖于其他观测变量,但不依赖于缺失值本身。
- 例如:在医疗调查中,男性可能更倾向于不回答某些问题(但也取决于种族等其他因素)。如果在控制种族和性别后,回答与否是随机的,则为 MAR。
- 处理:可以通过加权或插值来处理。
-
非随机缺失 (NMAR - Not Missing at Random):
- 缺失值本身就包含信息(例如:因为收入太高而不愿意透露收入)。
- 影响:这是最棘手的情况,直接删除会引入严重偏差。
6.2 缺失值的标记
缺失值在数据中可能有多种表现形式:
- NaN (Not a Number):Python/Pandas 的标准缺失标记。
- 特定代码:如 DAWN 数据中的
-7(不适用),-8(未记录),-9(缺失)。 - 空白或特殊字符:如
NULL,?,--。
6.3 插值 (Imputation)
插值是指用合理的估计值填补缺失值的过程。常见的插值方法包括:
演绎插值 (Deductive Imputation)
通过逻辑关系推断缺失值。 * 案例:旧金山餐厅数据中,某记录的邮编填成了 "Ca",经纬度缺失。 * 修复:我们可以根据其地址("2250 CHESTNUT ST"),通过查询 USPS 网站或 Google Maps 找到正确的邮编和经纬度填入。
均值插值 (Mean Imputation)
用该列非缺失值的平均值填充。 * 优点:简单。 * 缺点:会减少数据的变异性 (Variability)。填充后的数据方差变小,可能导致置信区间过窄。
热卡插值 (Hot-deck Imputation)
从类似记录中随机抽取一个值来填充。 * 优点:保留了随机性。 * 缺点:可能会削弱特征间的相关性。
高级方法
- KNN 插值 (Nearest Neighbor):寻找各方面最相似的“邻居”记录,用它们的值来填补。
- 回归插值 (Regression):建立模型预测缺失值。
6.4 最佳实践
无论采用哪种处理方式(删除、保留、修改、插值):
- 标记修改:最好创建一个新列(如
is_imputed)来标记哪些值是插补的。 - 透明度:在最终报告中明确说明你做了哪些修改以及原因。
- 程序化操作:使用代码进行数据清洗,而不是手动在 Excel 中修改,以确保过程可复现。
- 影响评估:比较处理前后的数据分布,确保没有引入意想不到的偏差。
7. 数据转换 (Transformations)
有时,特征的原始形式并不适合直接分析。我们需要对其进行转换。常见的转换包括:
-
类型转换 (Type Conversion):
- 将字符串价格
"$2.17"转换为浮点数2.17。 - 将分类变量合并(例如将 11 个年龄组归并为 5 个)。
- 最常见:将字符串日期
"1955-10-12"转换为时间戳对象。
- 将字符串价格
-
数学转换 (Mathematical Transformation):
- 单位换算(磅转公斤)。
- 对数变换(Logarithm):处理偏态分布,使其更接近正态分布(后文详述)。
- 算术运算:如计算 BMI = 体重 / 身高^2。
-
信息提取 (Extraction):
- 从复杂字符串中提取部分信息。
- 例如:从违规描述中提取是否包含“老鼠” (Vermin),生成一个 True/False 的新特征。
7.1 时间戳转换 (Timestamps)
时间戳 (Timestamp) 记录了特定的日期和时间。它们格式繁多(如 Jan 1 2020 或 2021-01-31),通常需要解析 (Parse) 才能进行分析。
案例:旧金山餐厅检查日期
insp 数据框中的 date 列默认被读取为整数:
# 默认读取结果
print(insp['date'].head(3))
# 0 20160513
# 1 20171211
# Name: date, dtype: int64
这种 int64 格式很难回答诸如“周末检查是否更频繁?”这样的问题。我们需要将其转换为 Pandas 的 Timestamp 对象。
我们可以使用 pd.to_datetime() 并指定格式字符串:
# 指定格式:YYYYMMDD
date_format = '%Y%m%d'
# 转换为 datetime 对象
insp['timestamp'] = pd.to_datetime(insp['date'], format=date_format)
# 现在可以使用 .dt 访问器提取时间信息
print(insp['timestamp'].dt.year.head(3))
# 0 2016
# 1 2017
# Name: date, dtype: int32
Pandas 的 .dt 访问器非常强大,可以提取年、月、日、星期几等信息。让我们看看检查员是否偏好在某些日子进行检查:
# 提取星期几 (0=周一, 6=周日)
insp['dow'] = insp['timestamp'].dt.dayofweek
# 统计每天的检查次数
insp['dow'].value_counts().sort_index()
7.2 管道化转换 (Piping)
在分析过程中,我们会对数据进行一系列转换。为了避免代码混乱和引入 bug(特别是在 Jupyter Notebook 中反复运行不同单元格),建议将转换逻辑封装为函数,并使用 pipe() 方法链接起来。
重构前的代码:
insp['timestamp'] = pd.to_datetime(insp['date'], format='%Y%m%d')
insp['year'] = insp['timestamp'].dt.year
insp['dow'] = insp['timestamp'].dt.dayofweek
重构后使用 Pipe:
# 定义原子转换函数
def parse_dates_and_years(df, column='date'):
dates = pd.to_datetime(df[column], format='%Y%m%d')
return df.assign(timestamp=dates, year=dates.dt.year)
def extract_day_of_week(df, col='timestamp'):
return df.assign(dow=df[col].dt.dayofweek)
# 链式调用
insp = (pd.read_csv("data/inspections.csv")
.pipe(parse_dates_and_years)
.pipe(extract_day_of_week))
管道化的优势:
1. 可读性:通过函数名就能看懂数据流经历了哪些处理。
2. 复用性:可以轻松应用到其他结构相似的数据框(如 viol 表也有 date 列,可以直接复用 parse_dates_and_years)。
8. 修改数据结构 (Modifying Structure)
如果数据结构不方便,分析也会变得困难。清洗过程通常包括重塑数据结构,使其更适合分析。
8.1 简化结构 (Simplify)
如果数据包含无关特征或只需关注特定子集,我们可以:
- 删除列:移除分析不需要的冗余特征。
- 筛选行 (Subsetting):只关注特定时间段或地理区域的数据。
8.2 调整粒度 (Adjust Granularity)
我们在 CO2 案例中已经见过,通过 groupby 和 aggregate 将月度数据聚合为年度数据。
- 目的:使数据粒度变“粗”,以平滑波动或匹配研究问题。
- 常用聚合:计数 (size), 求和 (sum), 均值 (mean), 极值 (min/max)。
8.3 处理混合粒度 (Mixed Granularity)
有时数据集会混合不同层级的记录。
- 例如:政府数据文件中同时包含“县级”数据和“州级”汇总数据。
- 处理:通常需要将它们拆分为两个表,一个用于县级分析,一个用于州级分析。
8.4 重塑结构:宽表与长表 (Reshape)
数据(尤其是 Excel 报表或政府统计年鉴)常以透视表 (Pivot Table) 的形式提供,这种格式被称为宽数据 (Wide Data)。
- 宽数据 (Wide Data):
- 值作为列名(例如:
2019,2020作为列名)。 - 易于人类阅读,但不适合计算机分析。
- 值作为列名(例如:
- 长数据 (Long Data):
- 每一行代表一个观测值。
- 通常包含
Variable(变量名)和Value(值)两列。 - 也称为 Tidy Data (整洁数据),是 Pandas 和绘图库的首选格式。
案例:CO2 数据的宽长转换
为了演示,我们先制造一个宽表(模拟 Excel 透视表):
# 创建宽表:年份为索引,月份为列名,值为 CO2 平均值
co2_wide = co2.pivot_table(index='Yr', columns='Mo', values='Avg')
# 重置索引,让 Yr 变成一列
co2_wide = co2_wide.reset_index()
# 显示前两行(列名变成了 1, 2, 3... 12)
print(co2_wide.head(2))
输出(宽表):
Mo Yr 1 2 3 4 ... 9 10 11 12
0 1959 315.62 316.38 316.71 317.72 ... 313.84 313.26 315.58 315.58
1 1960 316.43 316.97 317.58 319.02 ... 314.16 313.83 315.00 316.19
虽然这种格式适合阅读,但如果我们想画出随时间变化的曲线,或者按月份分组,这就很麻烦了。我们需要使用 melt() 方法将其“熔化”回长表:
# 将宽表转换为长表
co2_long = co2_wide.melt(id_vars=['Yr'], # 保持不变的标识列
var_name='month', # 原列名变成的新列名
value_name='average') # 原单元格值变成的新列名
print(co2_long.head())
输出(长表):
Yr month average
0 1959 1 315.62
1 1960 1 316.43
...
关键点:melt() 是将宽数据转换为长数据的利器。长数据让我们可以轻松地进行 groupby('Yr') 或 groupby('month'),而在宽数据中这几乎是不可能的。
9. 实例:处理多表数据 (Joining Tables)
到目前为止,我们只讨论了单个表格的处理。在实际应用中,数据往往分散在多个表中。
本节我们将综合运用本章学到的技术(筛选、聚合、连接),来处理此前章节中介绍的旧金山餐厅检查数据。我们的目标是:找出哪些类型的违规行为与较低的检查评分相关。
数据分散在三张表中:
bus: 餐厅信息 (Businesses)insp: 检查评分 (Inspections) - 粒度为“检查”viol: 违规详情 (Violations) - 粒度为“违规项”
9.1 简化与筛选 (Narrowing the Focus)
为了简化演示,我们只关注 2016 年的数据。我们可以定义一个筛选函数,并使用 pipe 应用到两个数据框:
def subset_2016(df):
return df.query('year == 2016')
# 假设原数据已有 year 列
vio2016 = viol.pipe(subset_2016)
ins2016 = insp.pipe(subset_2016)
9.2 聚合违规数据 (Aggregating Violations)
viol 表的每一行代表一个“违规项”,而 insp 表每一行代表一次“检查”。为了将二者结合,我们需要将违规数据聚合到检查级别。
简单的聚合:计算违规数量
我们可以按 business_id 和 timestamp 分组,计算每次检查的违规次数:
# 计算每次检查的违规次数
num_vios = (vio2016
.groupby(['business_id', 'timestamp'])
.size()
.reset_index()
.rename(columns={0: 'num_vio'}))
# 查看结果
print(num_vios.head(3))
9.3 连接表 (Joining Tables)
现在我们有了每次检查的违规次数 num_vios,可以将其合并回 ins2016 表。
注意:我们要使用 左连接 (Left Join),因为有些检查可能不仅没有违规记录(在 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)
处理连接后的缺失值:
左连接后,那些没有违规记录的检查,其 num_vio 列会变成 NaN。根据领域知识,如果我们知道分数为 100 分代表完美,那么其违规数应当为 0。
def zero_vios_for_perfect_scores(df):
df = df.copy()
# 如果分数为 100,将违规数设为 0
df.loc[df['score'] == 100, 'num_vio'] = 0
return df
# 完整的处理管道
final_df = (ins2016.pipe(left_join_vios)
.pipe(zero_vios_for_perfect_scores))
代码详解:
这段代码展示了如何结合领域知识来处理缺失值,以及如何通过 pipe 保持代码整洁。
-
df = df.copy():- 在函数内部创建副本是一个好习惯。这确保了我们不会意外修改传入的原始
df,避免产生副作用(Side Effects),这在调试复杂的数据处理管道时尤为重要。
- 在函数内部创建副本是一个好习惯。这确保了我们不会意外修改传入的原始
-
df.loc[df['score'] == 100, 'num_vio'] = 0:- 这里使用了 Pandas 的
.loc[行标签, 列标签]语法进行赋值。 - 行选择器
df['score'] == 100:选中所有评分为 100(满分)的行。 - 列选择器
'num_vio':我们要修改的目标列。 - 逻辑:如果一家餐厅评分是 100 分,逻辑上意味着它没有任何违规行为。因此,即使在其违规记录缺失(即
NaN)的情况下,我们也完全有理由将其填补 (Impute) 为 0。这是一个典型的根据业务逻辑进行“演绎插值”的例子。
- 这里使用了 Pandas 的
-
.pipe(...):- 将
ins2016数据框传递给left_join_vios函数。 - 将上一步的结果直接传递给
zero_vios_for_perfect_scores函数。 - 这种链式调用让数据处理流程像流水线一样清晰。
- 将
9.4 提取文本特征 (Feature Extraction)
只知道“违规次数”还不够,我们想知道是“什么样”的违规。viol 表中的 description 包含了详细文本。
我们可以利用正则表达式提取关键词,生成布尔特征(Boolean Features):
def make_vio_categories(vio):
def has(term):
return vio['description'].str.contains(term, case=False, na=False)
return vio[['business_id', 'timestamp']].assign(
high_risk = has(r"high risk"),
clean = has(r"clean|sanit"),
vermin = has(r"vermin"), # 害虫
human = has(r"hand|glove|hair|nail"), # 人员卫生
)
# 生成分类特征
vio_ctg = vio2016.pipe(make_vio_categories)
这些新特征同样需要聚合(例如求和或取最大值),然后再次 Merge 到主表中。最终,我们可以分析特定类型的违规(如即使只有一次“害虫”违规)对评分的具体影响。
10. 本章总结
数据清洗 (Data Wrangling) 是数据分析中必不可少的一环。如果缺少这一步,我们极有可能忽略数据中的关键问题,从而导致后续分析得出错误的结论。本章涵盖了几乎每次分析都会用到的几个核心步骤。
主要收获
我们学习了在将数据读入 DataFrame 后,如何寻找潜在问题。质量检查是发现问题的关键。我们可以通过以下几种方式来发现错误值和缺失值:
-
检查统计摘要和分布:
- 使用
describe(),value_counts()等工具。 - 查看唯一值的计数表可以发现意外的编码或极度不平衡的分布(例如某个选项极少出现)。
- 百分位数有助于发现异常高或异常低的值。
- (第 10 章将详细介绍如何使用可视化和统计数据进一步检查数据质量)。
- 使用
-
使用逻辑表达式:
- 识别超出合理范围的记录(如年龄 > 150)。
- 识别关系异常的记录(如未成年人饮酒)。
- 简单计算一下“未通过质量检查的记录数”,就能快速了解问题的严重程度。
-
检查此类记录的完整信息:
- 有时整个记录都乱了(例如 CSV 中逗号放错了位置)。
- 有时记录代表了一种特殊情况(例如房屋销售数据中混入了牧场),你需要决定是否要在分析中包含它们。
-
参考外部来源:
- 查阅文档或代码簿,弄清楚异常值是否有特殊含义(如 -99.99)。
核心思维:保持好奇
本章最大的启示是要对你的数据保持好奇。寻找那些能揭示数据质量的线索。你发现的证据越多,你对结论就越有信心。
当你发现问题时,深挖下去。尝试理解并解释任何不正常的现象。深入理解数据能帮助你判断某个问题是微不足道(可以忽略或修正),还是会严重限制数据的可用性。
这种好奇心的思维方式与探索性数据分析 (Exploratory Data Analysis, EDA) 紧密相连,这正是我们下一章的主题。