Python 数据分析实战:Pandas 多表合并与复杂数据清洗技巧

25次阅读
没有评论

共计 9492 个字符,预计需要花费 24 分钟才能阅读完成。

在当今数据驱动的世界里,数据分析已成为一项不可或缺的核心技能。然而,原始数据往往纷繁复杂、散落在不同来源,且充满缺失、重复、格式不一等问题。面对这样的“数据沼泽”,如何高效地将其转化为可信赖的洞察?这正是 Pandas 这一强大的 Python 库大显身手之处。本文将深入探讨 Pandas 在多表合并与复杂数据清洗方面的实战技巧,助您从容驾驭数据,构建坚实的数据分析基础。

为什么数据合并与清洗是数据分析的基石?

想象一下,您正在分析一家电商平台的运营数据。用户行为数据可能存储在一个数据库中,商品信息在另一个表格里,而订单详情又来自一个独立的 CSV 文件。这些散落的数据犹如盲人摸象,单看一处无法窥其全貌。这时,您需要将它们巧妙地“粘合”起来,才能构建出完整的用户画像、商品销售趋势或订单转化漏斗。这便是数据合并的价值所在。

然而,合并后的数据并非一劳永逸。数据输入错误、系统故障、信息采集不完整等原因,都可能导致数据中充斥着缺失值、异常值、重复记录,甚至不一致的格式。如果直接在这样的“脏数据”上进行分析,轻则导致结果偏差,重则得出完全错误的结论,如同“垃圾进,垃圾出”(Garbage In, Garbage Out)。因此,进行系统性的数据清洗,是确保分析结果准确性、可靠性的前提,也是数据分析师日常工作中耗时最长、却至关重要的一环。掌握 Pandas 的合并与清洗技巧,无疑是您迈向高级数据分析师的必经之路。

Pandas 多表合并核心技巧

Pandas 提供了多种灵活的方法来合并和连接不同的 DataFrame。理解它们的异同与适用场景,是高效数据处理的关键。

1. pd.merge():关系型数据合并利器

pd.merge() 函数是 Pandas 中最常用、功能最强大的合并工具,它模仿了 SQL 数据库中的 JOIN 操作,允许您基于一个或多个键(key)将两个 DataFrame 进行连接。

import pandas as pd
import numpy as np

# 示例数据
df_users = pd.DataFrame({'user_id': [1, 2, 3, 4, 5],
    'username': ['Alice', 'Bob', 'Charlie', 'David', 'Eve'],
    'city': ['New York', 'London', 'Paris', 'New York', 'Berlin']
})

df_orders = pd.DataFrame({'order_id': [101, 102, 103, 104, 105, 106],
    'user_id': [2, 1, 3, 2, 6, 1], # user_id 6 不存在于 df_users
    'amount': [120, 80, 200, 50, 150, 90]
})

print("df_users:")
print(df_users)
print("ndf_orders:")
print(df_orders)

a. 内连接(Inner Join): 默认连接方式,只保留两个 DataFrame 中都有匹配键的行。

# 合并只保留 user_id 在两个表中都存在的记录
merged_inner = pd.merge(df_users, df_orders, on='user_id', how='inner')
print("n 内连接结果 (Inner Join):")
print(merged_inner)

输出会显示 user_id 为 1, 2, 3 的记录,因为它们在两个表中都有。

b. 左连接(Left Join): 保留左侧 DataFrame 的所有行,并匹配右侧 DataFrame 的行。如果右侧没有匹配,则结果中对应列为 NaN。

# 保留所有用户,并查找他们的订单
merged_left = pd.merge(df_users, df_orders, on='user_id', how='left')
print("n 左连接结果 (Left Join):")
print(merged_left)

输出会包含 user_id 4, 5 的行,它们的 order_idamount 将是 NaN。

c. 右连接(Right Join): 保留右侧 DataFrame 的所有行,并匹配左侧 DataFrame 的行。如果左侧没有匹配,则结果中对应列为 NaN。

# 保留所有订单,并查找对应的用户信息
merged_right = pd.merge(df_users, df_orders, on='user_id', how='right')
print("n 右连接结果 (Right Join):")
print(merged_right)

输出会包含 user_id 6 的行,它的 usernamecity 将是 NaN。

d. 外连接(Outer Join): 保留两个 DataFrame 的所有行,不匹配的列将用 NaN 填充。

# 合并所有用户和所有订单
merged_outer = pd.merge(df_users, df_orders, on='user_id', how='outer')
print("n 外连接结果 (Outer Join):")
print(merged_outer)

输出会包含所有 user_id (1, 2, 3, 4, 5, 6) 的记录,不匹配的地方填充 NaN。

关键参数:

  • on: 指定用于合并的列名,如果左右两边键名相同。
  • left_on, right_on: 当左右两边键名不同时使用。
  • how: 指定连接类型 (‘inner’, ‘left’, ‘right’, ‘outer’)。
  • suffixes: 当两个 DataFrame 中有相同列名(非键)时,用于区分这些列的后缀。

2. pd.concat():堆叠与拼接的艺术

pd.concat() 函数用于沿着某个轴(行或列)简单地堆叠或拼接多个 DataFrame 或 Series。它更像是 SQL 中的 UNION ALL 操作。

# 示例数据
df_q1 = pd.DataFrame({'product': ['A', 'B'], 'sales_q1': [100, 150]})
df_q2 = pd.DataFrame({'product': ['C', 'D'], 'sales_q2': [200, 180]})
df_q3 = pd.DataFrame({'product': ['A', 'E'], 'sales_q3': [120, 90]})

df_sales_north = pd.DataFrame({'product_id': [1, 2], 'region': ['North', 'North'], 'sales': [1000, 1200]})
df_sales_south = pd.DataFrame({'product_id': [3, 4], 'region': ['South', 'South'], 'sales': [800, 950]})

a. 垂直堆叠(按行合并,axis=0): 将具有相同列结构的 DataFrame 堆叠起来,通常用于合并不同时间段或不同区域的同类数据。

# 垂直堆叠第一季度和第二季度数据
all_sales_rows = pd.concat([df_q1, df_q2], axis=0, ignore_index=True)
print("n 垂直堆叠 ( 按行合并):")
print(all_sales_rows)

ignore_index=True 会重置索引,避免索引重复。

b. 水平拼接(按列合并,axis=1): 将具有相同索引的 DataFrame 并排拼接,通常用于为现有数据框添加新的列。

# 假设 df_q1 和 df_q3 具有相同的产品顺序或索引
df_products_info = pd.DataFrame({'product': ['A', 'B'], 'price': [10, 15]})
df_inventory_info = pd.DataFrame({'stock': [50, 70], 'warehouse': ['W1', 'W2']})

# 水平拼接产品信息和库存信息
# 注意:这里需要确保索引对齐,否则结果可能不是预期的
# 如果产品顺序不一致,需要先进行 merge
concatenated_cols = pd.concat([df_products_info, df_inventory_info], axis=1)
print("n 水平拼接 ( 按列合并):")
print(concatenated_cols)

关键参数:

  • objs: 要连接的 DataFrame 或 Series 列表。
  • axis: 连接轴,0 为按行(默认),1 为按列。
  • join: ‘inner’ 或 ‘outer’,指定连接方式(对于不同列名的 DataFrame)。
  • ignore_index: 如果为 True,则不使用原始索引,生成新的整数索引。

3. DataFrame.join():索引合并的简洁之道

DataFrame.join() 方法是 pd.merge() 的一个便捷包装,默认基于索引进行连接。当您需要根据 DataFrame 的索引而不是特定列进行合并时,它非常方便。

df_users_indexed = df_users.set_index('user_id')
df_orders_indexed = df_orders.set_index('user_id')

print("ndf_users_indexed:")
print(df_users_indexed)
print("ndf_orders_indexed:")
print(df_orders_indexed)

# 将订单信息通过索引与用户信息连接
joined_data = df_users_indexed.join(df_orders_indexed, how='left', lsuffix='_user', rsuffix='_order')
print("n 使用 .join() 进行索引合并:")
print(joined_data)

lsuffixrsuffix 参数用于处理左右 DataFrame 中存在的同名非连接列。

Pandas 复杂数据清洗策略

数据清洗是数据分析过程中不可避免但至关重要的一步。以下是 Pandas 中处理各种数据问题的常用技巧。

# 示例“脏”数据
df_dirty = pd.DataFrame({'ID': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1], # ID 1 为重复项
    'Name': ['Alice', 'Bob', 'Charlie', np.nan, 'David', 'Eve', 'Frank', 'Grace', 'Heidi', 'Ivan', 'Alice'],
    'Age': [25, 30, np.nan, 35, 28, 40, 22, 30, 25, 500, 25], # 500 是异常值,Age 3 缺失
    'Gender': ['Male', 'Female', 'Male', 'Female', 'Male', 'Female', 'Male', 'Female', 'Male', 'Unspecified', 'Male'],
    'City': ['New York', 'London', 'Paris', 'New York', 'London', 'Paris', 'Berlin', 'Tokyo', 'London', 'New York', 'New York'],
    'Salary': [50000, 60000, 55000, np.nan, 70000, 62000, 58000, 80000, 50000, 65000, 50000],
    'Join_Date': ['2020-01-15', '2019-03-20', '2021-07-01', '2018-11-10', '2022-02-28', '2019-09-01', '2020-05-01', '2021-01-01', '2019-03-20', '2023-01-01', '2020-01-15'],
    'Email': ['[email protected]', '[email protected]', 'charlie@example', '[email protected]', '[email protected]', '[email protected]', '[email protected]', '[email protected]', '[email protected]', '[email protected]', '[email protected]']
})
print("n 原始“脏”数据:")
print(df_dirty)

1. 处理缺失值:数据补全与剔除的智慧

缺失值是数据中最常见的问题。处理方法取决于缺失的性质和业务需求。

a. 检测缺失值:

  • df.isnull()df.isna(): 返回布尔型 DataFrame,指示每个位置是否为 NaN。
  • df.isnull().sum(): 统计每列的缺失值数量。
  • df.info(): 提供 DataFrame 的简洁摘要,包括非空值数量。
print("n 每列缺失值数量:")
print(df_dirty.isnull().sum())

b. 剔除缺失值:dropna()

  • df.dropna(): 删除含有任何缺失值的行。
  • df.dropna(axis=1): 删除含有任何缺失值的列。
  • df.dropna(subset=['列名']): 删除指定列中含有缺失值的行。
  • df.dropna(how='all'): 只删除所有值都为 NaN 的行。
df_cleaned_drop = df_dirty.dropna(subset=['Name', 'Salary'])
print("n 剔除'Name'或'Salary'缺失值的行:")
print(df_cleaned_drop)

c. 填充缺失值:fillna()

  • df.fillna(value): 用指定值填充所有缺失值。
  • df.fillna(method='ffill'): 用前一个有效值填充(前向填充)。
  • df.fillna(method='bfill'): 用后一个有效值填充(后向填充)。
  • df['列名'].fillna(df['列名'].mean()): 用该列的均值填充。
  • df['列名'].fillna(df['列名'].median()): 用该列的中位数填充。
  • df['列名'].fillna(df['列名'].mode()[0]): 用该列的众数填充(注意 mode() 可能返回多个值)。
  • df['列名'].interpolate(): 使用插值法填充缺失值(对数值型数据尤其有用)。
df_cleaned_fill = df_dirty.copy() # 创建副本以避免修改原始数据
# 用 Age 列的平均值填充缺失的年龄
mean_age = df_cleaned_fill['Age'].mean()
df_cleaned_fill['Age'].fillna(mean_age, inplace=True)
# 用特定字符串填充 Name 列的缺失值
df_cleaned_fill['Name'].fillna('Unknown', inplace=True)
# 用 Salary 列的中位数填充缺失的薪水
median_salary = df_cleaned_fill['Salary'].median()
df_cleaned_fill['Salary'].fillna(median_salary, inplace=True)
print("n 填充缺失值后的数据:")
print(df_cleaned_fill)

2. 识别与消除重复值:数据质量的守卫者

重复值可能导致统计结果偏差,需要识别并处理。

a. 检测重复值:duplicated()

  • df.duplicated(): 返回布尔型 Series,指示每行是否为重复行(默认保留第一个)。
  • df.duplicated(subset=['列名']): 在指定列的组合上查找重复行。
  • df.duplicated(keep='last'): 保留最后一个重复项。
print("n 重复行检测:")
print(df_dirty.duplicated())
print("n 基于'ID'和'Name'列的重复行检测:")
print(df_dirty.duplicated(subset=['ID', 'Name']))

b. 消除重复值:drop_duplicates()

  • df.drop_duplicates(): 删除重复行(默认保留第一个)。
  • df.drop_duplicates(subset=['列名'], keep='first'): 删除指定列组合上的重复行,保留第一个。
df_no_duplicates = df_dirty.drop_duplicates(subset=['ID', 'Name'], keep='first')
print("n 消除重复值后的数据:")
print(df_no_duplicates)

3. 数据类型转换:优化存储与确保计算准确性

数据类型不正确会导致计算错误或内存浪费。

a. 转换数据类型:astype()

# 假设 Gender 应该为 Category 类型
df_cleaned_fill['Gender'] = df_cleaned_fill['Gender'].astype('category')
print("n'Gender'列数据类型变为:", df_cleaned_fill['Gender'].dtype)

b. 日期时间转换:pd.to_datetime()

df_cleaned_fill['Join_Date'] = pd.to_datetime(df_cleaned_fill['Join_Date'])
print("n'Join_Date'列数据类型变为:", df_cleaned_fill['Join_Date'].dtype)
print("转换后的'Join_Date'列:")
print(df_cleaned_fill['Join_Date'])

errors='coerce' 参数可以在转换失败时将无效解析设置为 NaT(Not a Time),而不是抛出错误。

4. 字符串数据清洗:文本信息的精炼

文本数据往往需要标准化处理。Pandas 为 Series 提供了 .str 访问器,可以方便地进行字符串操作。

# 清除 Name 列两边的空格,并转换为小写
df_cleaned_fill['Name'] = df_cleaned_fill['Name'].str.strip().str.lower()

# 替换 'Unspecified' 为 NaN
df_cleaned_fill['Gender'] = df_cleaned_fill['Gender'].replace('Unspecified', np.nan)

# 使用正则表达式从 Email 中提取域名
df_cleaned_fill['Domain'] = df_cleaned_fill['Email'].str.extract(r'@(w+)') # 提取 @ 后的第一个单词

print("n 清洗后的字符串数据:")
print(df_cleaned_fill[['Name', 'Gender', 'Email', 'Domain']])

常用的 .str 方法包括 strip(), lower(), upper(), replace(), contains(), startswith(), endswith(), split(), extract() (配合正则表达式)。

5. 异常值处理:洞察与决策的平衡

异常值(Outliers)是数据集中显著偏离其他观测值的点。它们可能是数据输入错误,也可能代表了真实的稀有事件。处理异常值需谨慎,因为它可能包含重要信息。

a. 检测异常值(简单示例):

  • 统计方法: 基于 Z-score 或 IQR(四分位距)检测。
  • 可视化方法: 箱线图(Box Plot)、散点图等。
# 使用 IQR 方法检测 Age 列的异常值
Q1 = df_cleaned_fill['Age'].quantile(0.25)
Q3 = df_cleaned_fill['Age'].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR

outliers = df_cleaned_fill[(df_cleaned_fill['Age'] < lower_bound) | (df_cleaned_fill['Age'] > upper_bound)]
print("n 检测到的 Age 异常值:")
print(outliers[['ID', 'Name', 'Age']])

b. 处理策略:

  • 删除: 如果异常值是明确的错误数据,且数量不多,可以直接删除。
  • 修正 / 替换: 如果是输入错误,尝试修正。如果是合理但极端的点,可以将其替换为均值、中位数或上下限。
  • 变换: 对数据进行对数变换或开方变换,可以减少异常值的影响。
  • 保留: 如果异常值代表重要的业务洞察(例如,超级用户、天价订单),则应保留。
# 将 Age 列中高于上限的异常值替换为上限值 (数据截断)
df_cleaned_fill.loc[df_cleaned_fill['Age'] > upper_bound, 'Age'] = upper_bound
# 将 Age 列中小于下限的异常值替换为下限值
df_cleaned_fill.loc[df_cleaned_fill['Age'] < lower_bound, 'Age'] = lower_bound
print("n 处理异常值后的'Age'列:")
print(df_cleaned_fill[['ID', 'Name', 'Age']])

真实世界中的数据清洗与合并:最佳实践

数据清洗与合并并非一蹴而就,而是一个迭代、探索性且需要深思熟虑的过程。

  1. 数据探索先行: 在开始清洗之前,花时间理解数据。使用 df.info(), df.describe(), df.value_counts(), df.isnull().sum() 以及可视化工具(如直方图、箱线图)来了解数据的分布、类型、缺失值和潜在问题。

  2. 制定清洗策略: 针对发现的每个问题,明确如何处理。是删除行还是填充缺失值?如何处理异常值?这些决策应基于业务逻辑和分析目标。

  3. 迭代式工作流: 很少有一次性完美的清洗。通常是先进行粗略清洗,然后进行初步分析,根据分析结果再返回进行更精细的清洗。

  4. 保留原始数据: 永远不要直接在原始数据上进行修改。始终创建数据的副本进行操作,例如 df_cleaned = df_original.copy()

  5. 代码注释与版本控制: 详细记录您进行的每一步清洗操作,说明原因。使用版本控制系统(如 Git)来管理您的代码,以便回溯和协作。

  6. 性能考量: 对于大型数据集,某些操作可能非常耗时。考虑使用 Pandas 的优化技巧(如链式方法调用避免创建中间 DataFrame),或者探索 Dask 等库进行大规模数据处理。

  7. 融入业务知识: 最好的数据清洗决策往往不是纯粹的技术决策。深入理解数据的业务背景和含义,可以帮助您做出更明智的清洗选择,避免“过度清洗”或“错误清洗”。

总结与展望

Pandas 在多表合并与复杂数据清洗方面的强大功能,使其成为 Python 数据分析领域的基石。通过本文的详细讲解,您应该对 pd.merge(), pd.concat(), DataFrame.join() 的用法,以及处理缺失值、重复值、数据类型不一致、字符串数据和异常值的策略有了深入的理解。

掌握这些技巧,您就能够将来自不同来源、格式混乱的原始数据,转化为干净、结构化的信息资产,为后续的探索性数据分析、特征工程乃至机器学习建模奠定坚实基础。数据清洗是一门艺术,也是一门科学,它需要耐心、细致和对数据的好奇心。不断实践,持续学习,您将成为数据世界中真正的“炼金术师”。

正文完
 0
评论(没有评论)