Pandas 进阶五招搞定复杂数据处理,效率飙升!

216 阅读10分钟

一、链式操作 (Method Chaining):优雅的数据流

链式操作,也称为流式接口 (Fluent Interface),是将一系列 Pandas 方法串联起来,形成一个清晰、连贯的数据处理管道。这种风格不仅代码更易读、易维护,还能有效避免创建不必要的中间变量,优化内存使用。

1. 为什么选择链式操作?

  • 可读性强:代码从上到下或从左到右,清晰地展示了数据转换的每一步。
  • 简洁性高:减少了中间变量的声明,使代码更加紧凑。
  • 易于调试:虽然长链可能看起来复杂,但可以通过逐个添加方法或使用 pipe() 方法(见后文)进行调试。
  • 性能潜力:在某些情况下,Pandas 内部可能对链式操作进行优化。

2. 示例:传统方式 vs. 链式操作

假设我们需要对一个销售数据 DataFrame (df) 进行如下操作:

  • 筛选出 'Category' 为 'Electronics' 的记录。
  • 按 'Sub-Category' 分组。
  • 计算每个子类的总销售额 ('Sales') 和平均利润 ('Profit')。
  • 按总销售额降序排序。
  • 取销售额最高的前 5 个子类。

(1) 传统方式 (创建中间变量):

# 假设 df 是已加载的销售数据 DataFrame
electronics_df = df[df['Category'] == 'Electronics']
grouped_df = electronics_df.groupby('Sub-Category')
agg_df = grouped_df.agg(
    TotalSales=('Sales', 'sum'),
    AverageProfit=('Profit', 'mean')
)
sorted_df = agg_df.sort_values(by='TotalSales', ascending=False)
top_5_subcategories = sorted_df.head(5)
print(top_5_subcategories)

(2) 链式操作:

# 假设 df 是已加载的销售数据 DataFrame
top_5_subcategories_chained = (
    df[df['Category'] == 'Electronics']  # 1. 筛选
    .groupby('Sub-Category')             # 2. 分组
    .agg(                                # 3. 聚合
        TotalSales=('Sales', 'sum'),
        AverageProfit=('Profit', 'mean')
    )
    .sort_values(by='TotalSales', ascending=False) # 4. 排序
    .head(5)                             # 5. 取前5
)
print(top_5_subcategories_chained)

注释:链式操作通过将每个方法调用的结果直接作为下一个方法调用的对象,形成了一个流畅的管道。使用圆括号 () 包裹整个链条可以方便地进行多行书写,提高可读性。

3. 提升链式操作可读性的技巧

  • 适当换行和缩进:如上例所示,每个 .method() 调用占一行。
  • 添加注释:在每个步骤后添加简短注释说明其作用。
  • 使用 pipe() 方法:对于需要传递 DataFrame 给自定义函数或不易直接链式调用的函数,pipe() 非常有用(详见技巧二)。

二、pipe() 方法:自定义函数的无缝融入

当链式操作中需要应用一个自定义函数,或者某个库函数不直接支持在 DataFrame/Series 对象上调用时,pipe() 方法就派上了用场。它允许你将 DataFrame 或 Series 作为第一个参数传递给指定的函数,并将函数的返回值作为 pipe() 的结果,从而保持链式操作的流畅性。

1. pipe() 的优势

  • 保持链式结构:即使需要调用外部函数,也能维持代码的流式风格。
  • 封装复杂逻辑:可以将复杂的数据处理步骤封装在自定义函数中,然后通过 pipe() 调用。
  • 传递额外参数:pipe() 允许向目标函数传递额外的参数。

2. 示例:使用 pipe() 进行自定义数据清洗

假设我们有一个自定义函数 clean_text_column(df, column_name) 用于清洗 DataFrame 中的某个文本列(例如转换为小写、去除特殊字符)。

import pandas as pd
import re

# 示例 DataFrame
data = {'ID': [1, 2, 3],
        'Description': ['Product A - NEW!', 'Item B (Old Model)', 'Widget C*']}
df_text = pd.DataFrame(data)

# 自定义清洗函数
def clean_text_column(dataframe, column_to_clean, remove_chars_pattern=r'[^a-zA-Z0-9\s]'):
    """清洗指定文本列:转小写,移除特定字符"""
    df_copy = dataframe.copy() # 避免修改原始 DataFrame
    df_copy[column_to_clean] = (
        df_copy[column_to_clean]
        .str.lower() # 转小写
        .str.replace(remove_chars_pattern, '', regex=True) # 移除特定字符
        .str.strip() # 去除首尾空格
    )
    return df_copy

# 使用 pipe() 调用自定义函数
cleaned_df = (
    df_text
    .pipe(clean_text_column, column_to_clean='Description') # 将 df_text 作为第一个参数传递
    # 可以在这里继续链接其他 Pandas 操作
    # .assign(DescriptionLength=lambda x: x['Description'].str.len())
)
print(cleaned_df)

注释:df_text.pipe(clean_text_column, column_to_clean='Description') 实际上等同于 clean_text_column(df_text, column_to_clean='Description'),但它允许我们将其嵌入到链式操作中。column_to_clean='Description' 是传递给 clean_text_column 函数的额外命名参数。

三、explode() 与 stack()/unstack():处理复杂结构数据

当 DataFrame 中的某一列包含列表、元组或其他可迭代对象,或者当需要重塑多级索引的数据时,explode()、stack() 和 unstack() 方法非常有用。

1. explode():将列表式数据展开为多行

如果 DataFrame 的某一列包含列表或类似列表的条目,而你希望将每个列表元素扩展成单独的行,保留其他列的值,explode() 是理想选择。

示例;

data_explode = {'OrderID': [1, 2],
                'Products': [['Apple', 'Banana'], ['Orange', 'Grape', 'Apple']]}
df_explode_before = pd.DataFrame(data_explode)
print("原始 DataFrame:\n", df_explode_before)

# 使用 explode() 展开 'Products' 列
df_exploded = df_explode_before.explode('Products').reset_index(drop=True)
print("\nExplode 之后:\n", df_exploded)

注释:explode('Products') 将 Products 列中的每个列表元素拆分成新行,OrderID 的值会相应复制。reset_index(drop=True) 用于重置索引,避免因展开产生重复索引。

2. stack() 与 unstack():重塑多级索引

stack() 和 unstack() 主要用于处理具有多级索引 (MultiIndex) 的 DataFrame,在宽格式 (wide format) 和长格式 (long format) 数据之间进行转换。

  • stack(): 将 DataFrame 的列“堆叠”到索引中,通常会使 DataFrame 变得更“长”(行数增加,列数减少)。
  • unstack(): stack() 的逆操作,将索引的某个级别“解堆”到列中,通常使 DataFrame 变得更“宽”。

示例:

# 创建一个带有多级列索引的 DataFrame
header = pd.MultiIndex.from_product([['Year1', 'Year2'], ['Sales', 'Profit']])
data_multiindex = [[100, 10, 120, 15], [150, 20, 130, 18]]
df_wide = pd.DataFrame(data_multiindex, index=['StoreA', 'StoreB'], columns=header)
print("原始宽格式 DataFrame (多级列索引):\n", df_wide)

# 使用 stack() 将最内层的列索引级别 (Sales, Profit) 堆叠到行索引
df_long_stacked = df_wide.stack(level=-1) # level=-1 表示最内层列索引
print("\nStack 之后 (长格式):\n", df_long_stacked)

# 使用 unstack() 将刚刚堆叠的级别解堆回列
df_unstacked_back = df_long_stacked.unstack(level=-1) # level=-1 表示最内层行索引
print("\nUnstack 回去:\n", df_unstacked_back)
1.2.3.4.5.6.7.8.9.10.11.12.13.

注释:stack() 和 unstack() 的 level 参数指定了要操作的索引级别(可以是名称或整数位置)。这些操作对于时间序列分析、面板数据处理以及准备用于特定可视化或统计模型的数据非常关键。

四、assign():动态创建新列的利器

在链式操作中,如果需要基于现有列计算并添加新列,assign() 方法提供了一种非常优雅和函数式的方式。它返回一个新的 DataFrame,包含原始列和新添加的列,而不会修改原始 DataFrame。

1. assign() 的特点

  • 链式友好:完美融入链式操作。
  • 函数式编程风格:可以使用 lambda 函数动态定义新列的计算逻辑。
  • 可读性高:新列的名称和计算方式一目了然。
  • 创建多个列:可以一次性创建多个新列。

2. 示例:使用 assign() 计算衍生指标

data_assign = {'Item': ['A', 'B'], 'Price': [10, 20], 'Quantity': [5, 3]}
df_sales = pd.DataFrame(data_assign)

df_with_revenue = (
    df_sales
    .assign(
        Revenue=lambda x: x['Price'] * x['Quantity'], # 新列 Revenue
        DiscountedPrice=lambda x: x['Price'] * 0.9   # 新列 DiscountedPrice
    )
    .assign(
        # 可以在后续的 assign 中使用前面 assign 创建的列
        FinalRevenue=lambda x: x['DiscountedPrice'] * x['Quantity']
    )
)
print(df_with_revenue)

注释:assign() 的参数是 新列名=计算逻辑。计算逻辑可以是一个标量、一个 Series,或者最常用的是一个接受 DataFrame (通常用 x 或 df_ 表示) 并返回新列值的 lambda 函数。Lambda 函数中的 x 代表调用 assign() 的那个时刻的 DataFrame 状态。

五、优化性能:eval() 与 query(),以及数据类型选择

当处理大型 DataFrame 时,性能成为关键考量。Pandas 提供了一些方法来优化表达式计算和数据筛选,同时合理选择数据类型也能显著提升效率和减少内存占用。

1. eval() 和 query():高效表达式计算与筛选

  • df.eval(expression_string): 使用 Numexpr 库(如果已安装)或 Python 的 eval() 来高效计算字符串形式的列表达式,尤其在涉及多个列的算术运算时,可以避免生成大量中间 Series,从而节省内存和提高速度。
  • df.query(expression_string): 同样使用 Numexpr 进行布尔表达式的求值,用于高效筛选行,语法比传统的布尔索引更简洁。

示例:

# 创建一个较大的示例 DataFrame
import numpy as np
size = 100000
df_large = pd.DataFrame({
    'A': np.random.rand(size),
    'B': np.random.rand(size),
    'C': np.random.rand(size),
    'D': np.random.randint(0, 10, size)
})

# 使用 eval() 计算新列
df_large['E_eval'] = df_large.eval('A + B * C - D/2')

# 传统方式计算新列 (可能较慢且占用更多内存)
# df_large['E_traditional'] = df_large['A'] + df_large['B'] * df_large['C'] - df_large['D']/2

# 使用 query() 进行筛选
threshold_a = 0.5
threshold_d = 5
# 可以在表达式字符串中通过 @ 符号引用外部变量
filtered_df_query = df_large.query('A > @threshold_a and D < @threshold_d and B > C')

# 传统方式筛选 (可能较冗长)
# filtered_df_traditional = df_large[
#     (df_large['A'] > threshold_a) &
#     (df_large['D'] < threshold_d) &
#     (df_large['B'] > df_large['C'])
# ]

print("使用 eval 计算的 E 列的前5行:\n", df_large[['E_eval']].head())
print("\n使用 query 筛选后的 DataFrame 形状:", filtered_df_query.shape)

注释:eval() 和 query() 的表达式是字符串。对于 query(),可以使用 @variable_name 的方式在查询字符串中引用 Python 环境中的变量。这些方法在 DataFrame 较大时性能优势更明显。

2. 合理选择数据类型 (dtypes)

Pandas 默认的数据类型(如 int64, float64, object)可能不是最优的,尤其对于内存有限或追求极致性能的场景。

  • 数值类型:如果整数范围较小,可以使用 int8, int16, int32 代替 int64。浮点数类似,float32 代替 float64。
  • 分类类型 (Category):对于基数(唯一值数量)远小于数据总长度的字符串列(如性别、国家、产品类别),将其转换为 category 类型可以大幅减少内存占用并加快分组等操作。
  • 日期时间类型 (Datetime):确保日期时间字符串被正确解析为 datetime64[ns] 类型,以便利用 Pandas 强大的日期时间功能。

示例:转换数据类型

# 假设 df_large 是上面创建的 DataFrame
print("\n原始数据类型及内存占用:")
df_large.info(memory_usage='deep')

# 转换 'D' 列为更小的整数类型 (如果适用)
# 先检查 D 的取值范围
# print(df_large['D'].min(), df_large['D'].max()) # 0-9,int8 足够
df_large['D_optimized'] = df_large['D'].astype('int8')

# 假设 'Category_String' 是一列基数较低的字符串
df_large['Category_String'] = pd.Series(np.random.choice(['X', 'Y', 'Z'], size=size, p=[0.6, 0.3, 0.1]))
df_large['Category_Optimized'] = df_large['Category_String'].astype('category')

print("\n优化后数据类型及内存占用:")
df_large.info(memory_usage='deep')

注释:使用 astype() 方法转换数据类型。在转换前,最好先了解列中数据的实际范围和特性。使用 df.info(memory_usage='deep') 可以查看更准确的内存占用情况(特别是对于 object 类型的列)。

总结

掌握 Pandas 的进阶技巧对于高效处理日益复杂的数据至关重要。通过运用链式操作提升代码可读性与流畅性,借助 pipe() 无缝集成自定义函数,利用 explode()、stack()/unstack() 巧妙重塑数据结构,使用 assign() 优雅创建新列,以及通过 eval()/query() 和合理选择数据类型 来优化性能,你将能够更自信、更专业地驾驭 Pandas,将数据分析工作提升到新的高度。