跳转至

数据清洗与规整

在上一章中,我们学习了如何将数据从源文件加载到 DataFrames 中。然而,直接读取的数据往往还不能直接用于分析。我们需要对其进行“规整” (Wrangling),包括评估数据质量、处理异常值、以及转换数据结构。

这个过程往往是循环往复的:质量检查会揭示我们需要做什么转换,而在转换后我们可能又会发现新的质量问题。

1. 数据质量预期

不同的数据来源,其初始质量差异巨大。了解数据来源有助于我们预估清洗工作量:

  • 科学实验数据
    • 通常非常干净且文档齐全。
    • 结构简单,旨在共享和复现。
    • 一般只需极少的清洗即可直接分析。
  • 政府调查数据
    • 通常附带详细的代码簿 (Codebooks) 和元数据。
    • 解释了数据的收集方式和格式。
    • 通常也是“开箱即用”的。
  • 行政管理数据 (Administrative Data):
    • 数据本身可能很规整,但缺乏只有内部人员才知道的背景知识
    • 由于我们使用数据的目的往往与最初收集数据的目的不同(Secondary Use),通常需要大量的质量检查、特征转换或多表合并。
  • 非正式数据 (Informal Data):
    • 例如:网络爬虫数据、推文、博客、维基百科表格。
    • 通常非常杂乱,且几乎没有文档。
    • 需要大量的格式化和清洗工作才能转换为可用信息。

2. 清洗流程概览

在本章中,我们将数据清洗分解为以下几个阶段:

  1. 评估数据质量 (Assess Data Quality):检查单个值或整列数据的有效性。
  2. 处理缺失值 (Handle Missing Values):决定是填充、删除还是保留缺失数据。
  3. 特征转换 (Transform Features):将原始字段转换为更有分析价值的形式(例如从日期字符串提取年份)。
  4. 重塑数据 (Reshape Data):修改数据的结构和粒度(例如宽表转长表)。

范围与可视化

评估数据质量的一个重要步骤是考虑数据的范围 (Scope)(即总体是谁,样本代表了谁,我们在第 2 章讨论过)。 此外,探索性数据分析 (EDA),特别是可视化技术,是发现数据脏点的重要手段(我们将在后续章节详细介绍)。

3. 案例数据集

为了讲解这些概念,我们将继续使用上一章介绍的两个数据集:

  1. DAWN 调查数据(关于药物滥用的急诊记录)
  2. 旧金山食品安全数据(餐厅检查记录)

不过,为了更清晰地演示每个步骤,我们将从一个更简单、更干净的示例开始入手。

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']

我们可以观察到:

  1. 数据起始:实际数据大约从第 73 行(索引 72)开始。
  2. 分隔符:值之间通过空格分隔(whitespace)。
  3. 表头:列名分散在两行注释中,直接读取比较麻烦,不如手动指定。

根据这些信息,我们可以使用 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 代表缺失值

我们进行以下几个维度的质量检查:

  1. 检查行数 (Shape): 数据涵盖 1958.3 到 2019.8。大致有 \(2019 - 1957 = 62\) 年。 \(62 \times 12 \approx 744\) 行。实际 738 行,且没有任何月份的记录数异常(大部分月份出现 62 次,首尾年份的月份出现 61 次),基本符合预期。

  2. 检查 days 列 (Days Operational): 这一列表示设备当月运行的天数。文档指出 -1 代表该信息缺失。 绘制直方图或散点图会发现,所有的 -1 (缺失值) 都集中在早期年份。这提示我们早期的数据记录可能不如后期完整。

  3. 检查 Avg 列 (CO2 Measurement): 我们已经发现 -99.99 是异常值。

    # 查看所有平均值为负数的记录
    co2[co2["Avg"] < 0]
    
    结果显示只有 7 行数据的 Avg 是 -99.99。

4.3 处理缺失数据 (Address Missing Data)

我们确定了 -99.99 是缺失值,那么该如何处理它们?常见的策略有三种:

  1. 删除 (Drop):直接丢弃这些行。
    • 后果:折线图会在这些点断开(或者直接连接前后点,掩盖了中间缺失的事实)。
  2. 标记 (NaN):将 -99.99 替换为 Pandas 的 NaN
    • 后果:绘图时线条会中断,明确表示这里没有数据。
  3. 插值/填补 (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)。

总结

在这个案例中,我们经历了数据清洗的典型流程:

  1. 加载:根据文本结构自定义 read_csv 参数。
  2. 检查:通过可视化和统计描述发现异常值 (-99.99)。
  3. 清洗:理解异常值的含义(缺失数据)并选择处理方案(使用插值列)。
  4. 转换:通过聚合改变数据粒度,以适应分析目标(长期趋势)。

5. 深入数据质量检查

在将文件读入 DataFrame 并大致了解了数据的范围和粒度后,我们需要进行更全面的质量检查。我们可以从以下四个维度来评估数据质量:

  1. 范围 (Scope):数据是否符合我们对总体的理解?
  2. 值 (Values):数值是否合理?是否在预期范围内?
  3. 关系 (Relationships):相关联的特征之间是否一致?
  4. 分析价值 (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 修复策略

发现问题后,我们通常有四种处理方案:

  1. 保持原样 (Leave as is)
    • 如果异常值不仅是错误的,而且反映了某种真实情况(或者影响微乎其微),可以保留。
    • 或者将其转换为标准的缺失值标记 (NaN)。
  2. 修改特定值 (Modify)
    • 如果你确切知道错误原因(例如单位错误、拼写错误),可以修正它。
    • 建议:创建一个新列存储修正后的值,保留原始列以备查证。
  3. 删除特征 (Remove Feature)
    • 如果某列大部分数据都损坏或无用,直接删除整列。
  4. 删除记录 (Drop Records)
    • 慎用。除非你有充分理由认为这些记录是单纯的错误且无法修复。
    • 不要仅仅因为数据“看起来不顺眼”就删除,这通常会引入偏差。

6. 处理缺失值与记录

在第 3 章中,我们讨论了因未响应 (Nonresponse) 导致的偏差。如果未响应者在关键方面与响应者不同,仅仅增加样本量是无法消除偏差的。同样,在数据框中,缺失值也是一个棘手的问题。

6.1 缺失的类型

了解缺失值的性质对于选择处理方法至关重要:

  1. 完全随机缺失 (MCAR - Missing Completely at Random)

    • 缺失与任何数据特征(包括自身)都无关。
    • 例如:实验设备因停电随机故障了一天。
    • 影响:数据量减少,但不会引入偏差。可以安全删除。
  2. 随机缺失 (MAR - Missing at Random)

    • 缺失依赖于其他观测变量,但不依赖于缺失值本身。
    • 例如:在医疗调查中,男性可能更倾向于不回答某些问题(但也取决于种族等其他因素)。如果在控制种族和性别后,回答与否是随机的,则为 MAR。
    • 处理:可以通过加权或插值来处理。
  3. 非随机缺失 (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 最佳实践

无论采用哪种处理方式(删除、保留、修改、插值):

  1. 标记修改:最好创建一个新列(如 is_imputed)来标记哪些值是插补的。
  2. 透明度:在最终报告中明确说明你做了哪些修改以及原因。
  3. 程序化操作:使用代码进行数据清洗,而不是手动在 Excel 中修改,以确保过程可复现。
  4. 影响评估:比较处理前后的数据分布,确保没有引入意想不到的偏差。

7. 数据转换 (Transformations)

有时,特征的原始形式并不适合直接分析。我们需要对其进行转换。常见的转换包括:

  1. 类型转换 (Type Conversion)

    • 将字符串价格 "$2.17" 转换为浮点数 2.17
    • 将分类变量合并(例如将 11 个年龄组归并为 5 个)。
    • 最常见:将字符串日期 "1955-10-12" 转换为时间戳对象。
  2. 数学转换 (Mathematical Transformation)

    • 单位换算(磅转公斤)。
    • 对数变换(Logarithm):处理偏态分布,使其更接近正态分布(后文详述)。
    • 算术运算:如计算 BMI = 体重 / 身高^2。
  3. 信息提取 (Extraction)

    • 从复杂字符串中提取部分信息。
    • 例如:从违规描述中提取是否包含“老鼠” (Vermin),生成一个 True/False 的新特征。

7.1 时间戳转换 (Timestamps)

时间戳 (Timestamp) 记录了特定的日期和时间。它们格式繁多(如 Jan 1 20202021-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 案例中已经见过,通过 groupbyaggregate 将月度数据聚合为年度数据。

  • 目的:使数据粒度变“粗”,以平滑波动或匹配研究问题。
  • 常用聚合:计数 (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_idtimestamp 分组,计算每次检查的违规次数:

# 计算每次检查的违规次数
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 保持代码整洁。

  1. df = df.copy()

    • 在函数内部创建副本是一个好习惯。这确保了我们不会意外修改传入的原始 df,避免产生副作用(Side Effects),这在调试复杂的数据处理管道时尤为重要。
  2. df.loc[df['score'] == 100, 'num_vio'] = 0

    • 这里使用了 Pandas 的 .loc[行标签, 列标签] 语法进行赋值。
    • 行选择器 df['score'] == 100:选中所有评分为 100(满分)的行。
    • 列选择器 'num_vio':我们要修改的目标列。
    • 逻辑:如果一家餐厅评分是 100 分,逻辑上意味着它没有任何违规行为。因此,即使在其违规记录缺失(即 NaN)的情况下,我们也完全有理由将其填补 (Impute) 为 0。这是一个典型的根据业务逻辑进行“演绎插值”的例子。
  3. .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 后,如何寻找潜在问题。质量检查是发现问题的关键。我们可以通过以下几种方式来发现错误值和缺失值:

  1. 检查统计摘要和分布

    • 使用 describe(), value_counts() 等工具。
    • 查看唯一值的计数表可以发现意外的编码或极度不平衡的分布(例如某个选项极少出现)。
    • 百分位数有助于发现异常高或异常低的值。
    • (第 10 章将详细介绍如何使用可视化和统计数据进一步检查数据质量)。
  2. 使用逻辑表达式

    • 识别超出合理范围的记录(如年龄 > 150)。
    • 识别关系异常的记录(如未成年人饮酒)。
    • 简单计算一下“未通过质量检查的记录数”,就能快速了解问题的严重程度。
  3. 检查此类记录的完整信息

    • 有时整个记录都乱了(例如 CSV 中逗号放错了位置)。
    • 有时记录代表了一种特殊情况(例如房屋销售数据中混入了牧场),你需要决定是否要在分析中包含它们。
  4. 参考外部来源

    • 查阅文档或代码簿,弄清楚异常值是否有特殊含义(如 -99.99)。

核心思维:保持好奇

本章最大的启示是要对你的数据保持好奇。寻找那些能揭示数据质量的线索。你发现的证据越多,你对结论就越有信心。

当你发现问题时,深挖下去。尝试理解并解释任何不正常的现象。深入理解数据能帮助你判断某个问题是微不足道(可以忽略或修正),还是会严重限制数据的可用性。

这种好奇心的思维方式与探索性数据分析 (Exploratory Data Analysis, EDA) 紧密相连,这正是我们下一章的主题。