Python 数据分析:Pandas 与 Polars 实战

Python 数据分析核心技能:Pandas DataFrame 操作、数据清洗、聚合统计、时间序列分析,以及 Polars 高性能查询引擎的对比与迁移指南。

1. Pandas 核心操作

1.1 DataFrame 基础

Pandas 的 DataFrame 本质上是一个带有标签的二维表格结构,其底层由多个一维的 Series 对象按列组合而成。每个 Series 共享同一组行索引(Index),这使得按列进行向量化运算和按行进行切片查询都变得高效且直观。理解 DataFrame 的内存布局对后续的性能调优至关重要:默认情况下,各列在内存中是连续存储的,因此列级别的操作远比逐行循环要快得多。

import pandas as pd
import numpy as np

# 通过字典创建 DataFrame,键为列名,值为等长列表或数组
df = pd.DataFrame({
    'name': ['Alice', 'Bob', 'Charlie', 'Diana'],
    'age': [25, 30, 35, 28],
    'city': ['Beijing', 'Shanghai', 'Beijing', 'Shenzhen'],
    'salary': [15000, 20000, 25000, 18000]
})

# 查看数据的前几行、结构信息和统计摘要
print(df.head(3))      # 默认前5行,此处取前3行
print(df.info())       # 输出每列的非空计数、数据类型及内存占用
print(df.describe())   # 对数值列输出计数、均值、标准差、分位数等统计摘要
print(df.shape)        # 返回 (行数, 列数) 的元组
print(df.dtypes)       # 查看每列的数据类型

最佳实践:在数据导入阶段就应该通过 dtype 参数显式指定列类型,避免 Pandas 自动推断所带来的内存浪费。例如,对类别型字符串列指定 dtype='category',对整型列使用 int32 而非默认的 int64,这在处理百万级数据时能够显著降低内存占用。

1.2 数据筛选与查询

数据筛选是数据分析中最频繁的操作之一。Pandas 提供了布尔索引、query 方法以及 loc / iloc 定位器三种主要方式,它们各有适用场景。布尔索引最为直观,适合简单条件;query 方法的语法更接近 SQL,在条件复杂时代码可读性更强;loc 按标签访问,iloc 按整数位置访问,二者是精确定位行列的黄金标准。

import pandas as pd
import numpy as np

df = pd.DataFrame({
    'name': ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'],
    'age': [25, 30, 35, 28, 42],
    'city': ['Beijing', 'Shanghai', 'Beijing', 'Shenzhen', 'Shanghai'],
    'salary': [15000, 20000, 25000, 18000, 32000]
})

# 条件筛选:选出薪水高于一万八的记录
high_earners = df[df['salary'] > 18000]

# 多条件组合:使用 &(与)、|(或),每个条件必须用括号包裹
beijing_employees = df[(df['city'] == 'Beijing') & (df['age'] > 25)]

# query 语法:字符串形式的条件表达式,支持外部变量引用
min_salary = 18000
result = df.query('salary > @min_salary and city == "Beijing"')

# loc 按标签定位;iloc 按整数位置定位
df.loc[0, 'name']             # 第0行的 name 列:Alice
df.iloc[0, 0]                 # 第0行第0列:Alice
df.loc[0:2, ['name', 'salary']]  # 包含最后一端的切片 + 多列选择

# 通过 isin 进行多值匹配
df[df['city'].isin(['Beijing', 'Shenzhen'])]

# 通过 between 进行范围筛选
df[df['age'].between(25, 35)]

常见陷阱:很多初学者习惯性地使用 df[df.name == 'Alice'] 进行列访问,这在列名与 DataFrame 方法名冲突时会引发错误。推荐统一使用 df['name'] 或 df.loc[:, 'name'] 的形式,既安全又明确。此外,链式赋值如 df[df.city == 'Beijing']['salary'] = 30000 会产生 SettingWithCopyWarning,正确做法是使用 df.loc[df.city == 'Beijing', 'salary'] = 30000。

1.3 数据清洗

原始数据往往包含缺失值、重复记录、类型错误以及格式不一致等问题。数据清洗的目标不仅是"把数据弄干净",更是要确保清洗后的数据在统计意义上仍然能够代表真实业务场景,避免因过度清洗而引入偏差。Pandas 提供了丰富的方法来处理这些常见问题。

import pandas as pd
import numpy as np

df = pd.DataFrame({
    'name': ['Alice', 'Bob', 'Charlie', None, 'Eve'],
    'age': [25, np.nan, 35, 28, 42],
    'salary': [15000, 20000, np.nan, 18000, 32000],
    'date': ['2024-01-15', '2024-02-20', '2024-03-10', None, '2024-05-01'],
    'email': ['alice@test.com', 'bob@work.cn', 'charlie@test.com', 'diana@test.com', 'eve@work.cn']
})

# 检测缺失值:isna 返回布尔矩阵,sum 统计每列缺失数量
print(df.isna().sum())

# 填充缺失值:均值填充适用于近似正态分布的数值列
df['age'] = df['age'].fillna(df['age'].mean())

# 删除含缺失值的行;subset 限定只检查指定列
df_clean = df.dropna(subset=['salary'])

# 类型转换:确保数值列参与运算时不会意外转为字符串
df['salary'] = df['salary'].astype(float)
df['date'] = pd.to_datetime(df['date'], errors='coerce')
# errors='coerce' 将无法解析的字符串转为 NaT,避免程序崩溃

# 去重:根据 name 列去重,保留首次出现的记录
df.drop_duplicates(subset=['name'], keep='first')

# 字符串向量化操作:str 访问器将 Python 字符串方法应用到整列
df['name_upper'] = df['name'].str.upper()
df['email_domain'] = df['email'].str.split('@').str[1]
df['is_test_domain'] = df['email'].str.endswith('@test.com')

最佳实践:清洗顺序通常为先去重、再处理缺失值、最后做类型转换。原因是重复行可能会影响均值填充的计算结果,而类型转换应该在数据内容已确定之后进行,避免转换失败。对于日期解析,强烈建议使用 errors='coerce' 参数,这样异常值会被标记为 NaT 而不是直接抛出异常,便于后续统一处理。

1.4 分组聚合

分组聚合是数据分析中的核心统计操作,其本质是按照某个或某几个维度的取值将数据集划分为若干子集,然后对每个子集应用聚合函数。Pandas 的 groupby 实现了"拆分-应用-合并"(Split-Apply-Combine)模式,底层通过哈希表对分组键进行高效映射,聚合过程尽可能调用 C 级别的优化实现,因此比纯 Python 循环快数十倍。

import pandas as pd
import numpy as np

np.random.seed(42)
df = pd.DataFrame({
    'city': np.random.choice(['Beijing', 'Shanghai', 'Shenzhen'], 100),
    'department': np.random.choice(['Tech', 'Sales', 'HR'], 100),
    'age': np.random.randint(22, 50, 100),
    'salary': np.random.randint(8000, 50000, 100)
})

# 单维度分组聚合:统计每个城市的薪资分布
city_stats = df.groupby('city').agg({
    'salary': ['mean', 'max', 'min', 'std'],
    'age': 'mean'
}).round(2)
print(city_stats)

# 多层级分组:先按城市、再按部门分组
dept_stats = df.groupby(['city', 'department'])['salary'].agg(['sum', 'mean', 'count'])
print(dept_stats)

# 透视表:二维交叉分析,行为城市,列为部门,值为平均薪资
pivot = pd.pivot_table(
    df,
    values='salary',
    index='city',
    columns='department',
    aggfunc='mean',
    margins=True,        # 添加总计行/列
    margins_name='Total'
)
print(pivot)

# transform:保持原始行数,将聚合结果广播回每条记录
df['city_avg_salary'] = df.groupby('city')['salary'].transform('mean')
df['salary_vs_city_avg'] = df['salary'] - df['city_avg_salary']

# filter:按组级别的条件过滤整个组
large_depts = df.groupby('department').filter(lambda x: len(x) > 30)

原理说明:agg 方法接受一个列到聚合函数的字典映射,聚合函数可以是字符串名称(如 'mean')、NumPy 函数或自定义的 Callable。transform 与 agg 的关键区别在于前者返回与输入等长的 Series,常用于计算组内标准化、排名等场景;后者返回每个组一条记录的结果。


2. 时间序列分析

时间序列是按时间顺序排列的数据点集合,在金融、物联网、运维监控等领域无处不在。Pandas 的时间序列模块建立在 DatetimeIndex 之上,内部使用 Timestamp 对象表示时间点,支持纳秒级精度。理解 date_range 的频率参数(如 'D' 日、'W' 周、'M' 月末、'MS' 月初、'Q' 季度末)是正确使用时间功能的前提。

import pandas as pd
import numpy as np

np.random.seed(0)

# 创建时间序列:生成从 2024-01-01 开始的 100 个日期,频率为日
dates = pd.date_range('2024-01-01', periods=100, freq='D')
ts = pd.Series(np.random.randn(100).cumsum(), index=dates)

# 重采样:将高频数据转为低频聚合(降采样),或低频插值为高频(升采样)
monthly = ts.resample('M').mean()    # 月末为锚点,计算月均值
weekly_sum = ts.resample('W').sum()  # 周日为锚点,计算周合计
daily_ffill = ts.resample('D').ffill()  # 升采样后用前值填充

# 移动平均:rolling 窗口在序列上滑动计算统计量
ts_ma_7 = ts.rolling(window=7).mean()    # 7日移动平均
ts_ma_30 = ts.rolling(window=30).mean()  # 30日移动平均
ts_std_7 = ts.rolling(window=7).std()    # 7日滚动标准差

# 滞后与差分:常用于趋势分析和自回归建模
ts_lag1 = ts.shift(1)       # 滞后1期,即前一天的数据
ts_lag7 = ts.shift(7)       # 滞后7期,常用于发现周周期性
ts_diff1 = ts.diff(1)       # 一阶差分,消除线性趋势
ts_pct_change = ts.pct_change(1)  # 环比增长率

# 时间索引的便捷属性
print(ts.index.dayofweek)   # 星期几,0=周一
print(ts.index.is_month_end)  # 是否为月末
print(ts['2024-02'])        # 直接按年月切片

最佳实践:在进行移动窗口计算之前,应当先检查时间索引是否单调且没有重复值。如果数据中存在缺失的日期,rolling 方法仍然会在固定大小的窗口内计算,这可能导致窗口实际覆盖的时间跨度不一致。建议先用 resample('D').mean() 确保日期的连续性,再进行滚动计算。此外,对于存在明显季节性模式的数据,可以结合 shift(7) 与 diff() 来检测周期性波动。


3. Polars:高性能替代方案

Polars 是一个使用 Rust 语言编写的高性能 DataFrame 库,其查询引擎基于 Apache Arrow 的内存格式,天然支持列式存储和 SIMD 向量化运算。与 Pandas 相比,Polars 最大的优势在于查询优化器能够在惰性求值(Lazy Evaluation)模式下对执行计划进行重组和剪枝,从而避免加载和处理无关的数据列。这意味着即使面对数十 GB 的数据文件,Polars 也能够在普通笔记本电脑上完成分析任务。

import polars as pl
import numpy as np

#  eager 模式:立即加载整个数据集到内存
df = pl.read_csv('large_file.csv')
df = pl.read_parquet('data.parquet')  # Parquet 是列式存储,与 Polars 配合效果最佳

# 惰性求值:只定义计算逻辑,不立即执行,查询优化器会重写执行计划
df_lazy = (
    pl.scan_csv('large_file.csv')      # scan 系列方法返回 LazyFrame
    .filter(pl.col('salary') > 10000)
    .group_by('city')
    .agg(pl.col('salary').mean().alias('avg_salary'))
    .sort('avg_salary', descending=True)
)

# collect 触发实际执行,将结果物化为 DataFrame
result = df_lazy.collect()

# 与 Pandas 互转:利用 PyArrow 作为中间层,转换过程零拷贝
df_pandas = result.to_pandas()
df_polars = pl.from_pandas(df_pandas)

# 创建示例数据用于演示
pl_df = pl.DataFrame({
    'name': ['Alice', 'Bob', 'Charlie', 'Diana'],
    'age': [25, 30, 35, 28],
    'salary': [15000, 20000, 25000, 18000]
})
print(pl_df.filter(pl.col('salary') > 18000))

3.1 Pandas vs Polars 对比

场景PandasPolars
小数据(小于 1GB)简单直观,生态完善同样很快,语法现代化
大数据(大于 10GB)容易内存溢出或处理极慢惰性求值避免全量加载
复杂查询成熟但可能链式调用冗长查询优化器自动重写执行计划
数据流处理不支持原生流式处理支持 Streaming 模式逐块处理
多线程受 GIL 限制,多线程效果有限无 GIL 束缚,天然多核并行
学习成本低,社区资料丰富中,语法与 Pandas 有差异

迁移建议:对于日常探索性数据分析(EDA),Pandas 的成熟生态和丰富文档仍然是不二之选。当数据集超过可用内存的一半,或需要进行复杂的过滤、连接、分组组合查询时,应当考虑使用 Polars。二者并非互斥关系,可以通过 to_pandas() 和 pl.from_pandas() 在分析流程的不同阶段灵活切换。


4. 数据合并进阶

在实际业务中,数据往往分散在多个文件或表中,需要按照关键列将它们合并成一个统一的分析视图。Pandas 提供了 merge、concat、join 等多种合并工具,它们在语义上等价于 SQL 中的各种 JOIN 操作。理解每种合并方式的行为边界和性能特征,是处理多表数据的基本功。

import pandas as pd

# 员工基础信息表
employees = pd.DataFrame({
    'emp_id': [1, 2, 3, 4, 5],
    'name': ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'],
    'dept_id': [10, 20, 10, 30, 20]
})

# 部门信息表
departments = pd.DataFrame({
    'dept_id': [10, 20, 40],
    'dept_name': ['Tech', 'Sales', 'HR'],
    'location': ['Beijing', 'Shanghai', 'Shenzhen']
})

# merge 等效于 SQL JOIN,默认 inner join 只保留两边都存在的键
inner = pd.merge(employees, departments, on='dept_id', how='inner')

# left join 保留左表全部记录,右表不匹配的部分填充 NaN
left = pd.merge(employees, departments, on='dept_id', how='left')

# right join 保留右表全部记录
right = pd.merge(employees, departments, on='dept_id', how='right')

# outer join 保留所有键的并集
outer = pd.merge(employees, departments, on='dept_id', how='outer')

# 当键列名不一致时使用 left_on / right_on
# suffixes 处理两边同名列的命名冲突
merged = pd.merge(
    employees,
    departments,
    left_on='dept_id',
    right_on='dept_id',
    how='left',
    suffixes=('_emp', '_dept')
)

# concat 用于按行或按列堆叠数据,轴方向由 axis 参数控制
df1 = pd.DataFrame({'A': [1, 2], 'B': [3, 4]})
df2 = pd.DataFrame({'A': [5, 6], 'B': [7, 8]})
row_stack = pd.concat([df1, df2], axis=0, ignore_index=True)  # 纵向拼接
col_stack = pd.concat([df1, df2], axis=1)                     # 横向拼接

# merge_asof:按最近键值合并,常用于时间序列对齐
# 比如将交易记录与最近一次的汇率快照匹配
trades = pd.DataFrame({
    'time': pd.to_datetime(['2024-01-01 09:00', '2024-01-01 09:05', '2024-01-01 09:10']),
    'price': [100, 105, 102]
})
quotes = pd.DataFrame({
    'time': pd.to_datetime(['2024-01-01 09:00', '2024-01-01 09:07']),
    'quote': [99, 104]
})
asof = pd.merge_asof(trades, quotes, on='time', direction='backward')
print(asof)

最佳实践:合并之前务必检查两边的关键列是否存在重复值,一对多关系和多对多关系会显著增加结果集的规模,甚至导致内存爆炸。如果关键列存在空值,Pandas 默认会将 NaN 视为普通值进行匹配,这通常不是预期行为,建议先 dropna(subset=['key_col'])。对于大表的合并,确保关键列已排序能够加速 merge_asof 等算法的执行效率。


5. 数据透视与重塑

数据透视表是业务分析中最强大的工具之一,它允许分析师从多个维度快速汇总和交叉对比指标。Pandas 的 pivot_table 在功能上与 Excel 的数据透视表等价,但提供了更灵活的聚合函数和多级索引支持。数据重塑(Reshaping)则涉及长格式(long)与宽格式(wide)之间的相互转换,这在准备绘图数据和机器学习特征矩阵时是常见需求。

import pandas as pd
import numpy as np

np.random.seed(42)
df = pd.DataFrame({
    'date': pd.date_range('2024-01-01', periods=12, freq='MS'),
    'product': np.tile(['A', 'B', 'C'], 4),
    'region': np.repeat(['North', 'South'], 6),
    'sales': np.random.randint(100, 1000, 12),
    'profit': np.random.randint(10, 200, 12)
})

# pivot_table:带聚合的数据透视;若每组只有一条记录也可用 pivot
pivot = pd.pivot_table(
    df,
    values='sales',
    index='product',
    columns='region',
    aggfunc='sum',
    fill_value=0,
    margins=True
)
print(pivot)

# 多级透视:index 和 columns 均可传入列表创建多级索引
multi_pivot = pd.pivot_table(
    df,
    values=['sales', 'profit'],
    index=['product', 'region'],
    aggfunc={'sales': 'sum', 'profit': 'mean'}
)
print(multi_pivot)

# stack:将列索引的最低级别旋转为行索引,实现宽转长
stacked = pivot.stack()
print(stacked)

# unstack:将行索引的最低级别旋转为列索引,实现长转宽
unstacked = stacked.unstack()
print(unstacked)

# melt:将宽格式转为长格式,id_vars 保持不变的标识列
wide = pd.DataFrame({
    'id': [1, 2, 3],
    'math': [90, 85, 88],
    'english': [92, 78, 95],
    'physics': [88, 91, 84]
})
long = pd.melt(wide, id_vars=['id'], value_vars=['math', 'english', 'physics'],
               var_name='subject', value_name='score')
print(long)

# MultiIndex 多级索引的创建与访问
arrays = [
    ['Tech', 'Tech', 'Sales', 'Sales'],
    ['Junior', 'Senior', 'Junior', 'Senior']
]
mi = pd.MultiIndex.from_arrays(arrays, names=('dept', 'level'))
salaries = pd.Series([8000, 15000, 7000, 12000], index=mi)
print(salaries.loc['Tech'])       # 选择 Tech 部门下的所有级别
print(salaries.loc[('Tech', 'Senior')])  # 精确选择多级索引

常见陷阱:pivot 方法要求组合后的索引值唯一,如果同一组合存在多条记录会抛出 ValueError,此时应改用 pivot_table 并指定聚合函数。stack 默认会过滤掉缺失值,如果希望保留完整的网格结构,应使用 stack(dropna=False)。多级索引虽然强大,但在导出到 CSV 时默认只保留最底层级别,建议先用 reset_index() 将其展平为普通列。


6. 缺失值处理策略

缺失值处理是数据科学中最棘手的问题之一。简单粗暴地删除含缺失的行虽然方便,但可能损失大量信息并引入选择偏差。更合理的做法是先分析缺失机制:是"完全随机缺失(MCAR)"、“随机缺失(MAR)“还是"非随机缺失(MNAR)"?不同机制对应不同的处理策略。Pandas 提供了多种填充和插值方法,适用于不同的数据分布和业务场景。

import pandas as pd
import numpy as np

np.random.seed(0)

# 构造一个含缺失值的示例数据集
df = pd.DataFrame({
    'temperature': [22.5, np.nan, 24.0, 23.5, np.nan, 25.0, np.nan, 22.0],
    'humidity': [60, 65, np.nan, 62, 58, np.nan, 61, np.nan],
    'pressure': [1013, 1010, 1015, np.nan, 1012, 1011, np.nan, 1014]
}, index=pd.date_range('2024-01-01', periods=8, freq='D'))

# 检测缺失值
print(df.isna().sum())           # 每列缺失数量
print(df.isna().mean() * 100)    # 缺失比例(百分比)

# 前向填充:用上一个有效观测值填充,适合时间序列
# ffill 等同于 fillna(method='ffill')
df_ffill = df['temperature'].ffill()

# 后向填充:用下一个有效观测值填充
df_bfill = df['temperature'].bfill()

# 线性插值:根据相邻有效点之间的线性关系估算缺失值
# 对单调变化的时间序列尤为适用
df_linear = df['temperature'].interpolate(method='linear')

# 时间感知插值:如果索引是 DatetimeIndex,可按时间距离加权
# method='time' 会考虑实际时间间隔而非等距索引
df_time_interp = df['temperature'].interpolate(method='time')

# 多项式插值:捕获非线性趋势
# order=2 表示二阶多项式拟合
df_poly = df['temperature'].interpolate(method='polynomial', order=2)

# 不同列采用不同策略的批量处理
fill_strategy = {
    'temperature': df['temperature'].mean(),
    'humidity': df['humidity'].median(),
    'pressure': df['pressure'].ffill()
}
# 注意:直接对列应用 ffill 返回 Series,若用于字典值需确保类型匹配

# 实际更常见的做法是分列处理
df_clean = df.copy()
df_clean['temperature'] = df_clean['temperature'].interpolate(method='linear')
df_clean['humidity'] = df_clean['humidity'].fillna(df_clean['humidity'].median())
df_clean['pressure'] = df_clean['pressure'].ffill().bfill()  # 先向前再向后

print(df_clean)

策略建议:对于时间序列,优先使用 interpolate(method='time'),因为它能正确处理不等间隔的时间戳。对于类别型变量,使用众数或 “Unknown” 标签填充通常比均值填充更合理。如果某列缺失比例超过百分之五十,应当考虑该列是否还有保留价值,或者是否应将其转换为一个"是否缺失"的二元指示特征,这在某些预测模型中可能比填充值本身更有信息量。


7. Pandas 性能优化

Pandas 虽然便捷,但在处理大规模数据时容易成为性能瓶颈。其背后有两个主要原因:一是 Python 全局解释器锁(GIL)限制了真正的并行计算;二是对象的动态类型和频繁的内存分配带来了额外开销。通过采用列式向量化运算、优化内存类型以及分块处理策略,通常可以获得数倍乃至数十倍的性能提升。

import pandas as pd
import numpy as np

# 向量化操作 vs Python 循环:百万级数据的对比
n = 1_000_000
df = pd.DataFrame({
    'a': np.random.randn(n),
    'b': np.random.randn(n)
})

# 错误做法:逐行循环,速度极慢且代码冗长
# result = []
# for i in range(len(df)):
#     result.append(df.iloc[i]['a'] + df.iloc[i]['b'])
# df['sum_loop'] = result

# 正确做法:向量化操作,底层调用 C 编译的 NumPy 运算
df['sum_vec'] = df['a'] + df['b']

# apply 虽然比纯循环快,但仍比向量化慢,尽量避免在 apply 中使用逐元素 Python 函数
# df['sum_apply'] = df.apply(lambda row: row['a'] + row['b'], axis=1)  # 慢!

# Categorical 类型:字符串列的去重值很少时,用 category 替代 object 大幅节省内存
df['category_str'] = np.random.choice(['A', 'B', 'C', 'D'], n)
print(df['category_str'].memory_usage(deep=True))          # object 类型

df['category_cat'] = df['category_str'].astype('category')
print(df['category_cat'].memory_usage(deep=True))          # category 类型

# 分块读取:处理远超内存的 CSV 文件
chunk_size = 100000
aggregated = []
# for chunk in pd.read_csv('huge_file.csv', chunksize=chunk_size):
#     chunk_agg = chunk.groupby('category')['value'].sum()
#     aggregated.append(chunk_agg)
# final_result = pd.concat(aggregated).groupby(level=0).sum()

# 使用更紧凑的数值类型
df['int_col'] = np.random.randint(0, 100, n)
df['int_col'] = df['int_col'].astype('int8')  # int8 比 int64 节省 87.5% 内存

# query 方法在大型 DataFrame 上的速度优势
# Pandas 的 query 解析器会将表达式编译为 NumPy / C 层面的操作
large_df = pd.DataFrame({
    'x': np.random.randn(5_000_000),
    'y': np.random.randn(5_000_000),
    'flag': np.random.choice([0, 1], 5_000_000)
})
# query 在大型数据集上通常比布尔索引更快
filtered = large_df.query('x > 0 and y < 0 and flag == 1')
print(f"Filtered rows: {len(filtered)}")

最佳实践:优化的第一原则是先写对、再写快。过早优化可能引入不必要的复杂性。当确认存在性能瓶颈后,按以下优先级进行优化:首先检查是否使用了向量化操作替代循环;其次压缩数据类型,尤其是将 object 转为 category、将 float64 转为 float32;最后考虑分块读取或迁移到 Polars / Dask 等专为大数据设计的框架。


8. Polars 深度实战

在前文的对比基础上,本节通过具体的基准测试和高级特性演示,展示 Polars 在真实场景中的优势。Polars 的查询引擎会在 Lazy 模式下对过滤条件和投影操作进行谓词下推(Predicate Pushdown)和投影下推(Projection Pushdown),这意味着即使原始文件有上百列,如果查询只用到其中三列,Polars 也只会解析这三列的数据,从而极大减少 I/O 和内存消耗。

import polars as pl
import numpy as np
import time

# 创建一个中等规模的示例数据集用于基准测试
np.random.seed(42)
n = 5_000_000

large_df = pl.DataFrame({
    'id': np.arange(n),
    'category': np.random.choice(['A', 'B', 'C', 'D', 'E'], n),
    'value1': np.random.randn(n),
    'value2': np.random.randn(n),
    'region': np.random.choice(['North', 'South', 'East', 'West'], n)
})

# 惰性查询:过滤 + 分组聚合 + 排序
lazy_query = (
    large_df.lazy()
    .filter(pl.col('value1') > 0)
    .group_by('category')
    .agg([
        pl.col('value1').mean().alias('avg_v1'),
        pl.col('value2').sum().alias('sum_v2'),
        pl.count().alias('count')
    ])
    .filter(pl.col('count') > 1000)
    .sort('avg_v1', descending=True)
)

# 查看优化后的执行计划
print(lazy_query.explain(optimized=True))

# 执行并计时
start = time.time()
result = lazy_query.collect()
print(f"Lazy query time: {time.time() - start:.4f}s")
print(result)

# Streaming 模式:数据量极大时启用流式处理,降低峰值内存
# streaming=True 会让 Polars 分批次处理数据而非一次性加载
# result_stream = lazy_query.collect(streaming=True)

# PyArrow 集成:Polars 底层就是 Arrow 格式,与 pyarrow 库无缝对接
import pyarrow.parquet as pq
# 直接从 PyArrow Table 创建 Polars DataFrame
arrow_table = pq.read_table('data.parquet')  # 假设文件存在
# pl_df = pl.from_arrow(arrow_table)

# 窗口函数:Polars 的 over 语法等价于 SQL 的窗口函数
window_result = large_df.with_columns([
    pl.col('value1').mean().over('region').alias('region_avg'),
    pl.col('value1').rank(descending=True).over('category').alias('cat_rank')
])
print(window_result.head())

深度对比:在实际测试中,对于五百万行数据的过滤加聚合操作,Polars 的惰性模式通常比 Pandas 快三到十倍,且内存占用仅为 Pandas 的一半左右。Streaming 模式虽然会牺牲一部分执行速度,但能将内存峰值控制在几百兆字节以内,是处理超出物理内存数据集时的救命稻草。对于已经与 Arrow 生态集成的项目(如 Spark、Feather、Parquet),Polars 的零拷贝互操作能力让迁移成本几乎为零。


9. 可视化集成

数据分析的最终价值往往通过可视化来呈现。Pandas 本身不直接提供绘图能力,但它与 Matplotlib 深度集成,通过 .plot() 方法可以快速生成基础图表。对于需要交互式探索的场景,Plotly 和 Seaborn 分别提供了动态交互和统计美学两种路线。本节展示如何将 Pandas 的分析结果与这三种主流可视化库结合使用。

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# 构造时间序列数据用于可视化
np.random.seed(42)
dates = pd.date_range('2024-01-01', periods=90, freq='D')
df = pd.DataFrame({
    'sales': np.random.randint(100, 500, 90).cumsum(),
    'orders': np.random.randint(10, 50, 90).cumsum()
}, index=dates)

# Matplotlib 快速绘图:Pandas 的 plot 是对 Matplotlib 的封装
df['sales'].plot(figsize=(10, 4), title='Daily Sales Trend', color='steelblue')
plt.xlabel('Date')
plt.ylabel('Cumulative Sales')
plt.tight_layout()
plt.show()

# Seaborn:统计图表更美观,与 Pandas DataFrame 无缝配合
import seaborn as sns

monthly = df.resample('M').last().reset_index()
monthly['month'] = monthly['index'].dt.strftime('%Y-%m')

plt.figure(figsize=(8, 5))
sns.barplot(data=monthly, x='month', y='sales', palette='viridis')
plt.title('Monthly Sales Summary')
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()

# Plotly 交互式图表:支持缩放、悬停提示、图例筛选
import plotly.express as px

# 构造一个多维度数据集用于交互式展示
plot_df = pd.DataFrame({
    'product': np.repeat(['A', 'B', 'C'], 30),
    'date': list(pd.date_range('2024-01-01', periods=30, freq='D')) * 3,
    'revenue': np.random.randint(1000, 5000, 90),
    'cost': np.random.randint(500, 3000, 90)
})
plot_df['profit'] = plot_df['revenue'] - plot_df['cost']

fig = px.line(
    plot_df,
    x='date',
    y='revenue',
    color='product',
    title='Revenue Trend by Product',
    labels={'revenue': 'Revenue (CNY)', 'date': 'Date'},
    hover_data=['cost', 'profit']
)
fig.show()

# Plotly Express 快速绘制散点图矩阵查看多变量关系
fig_scatter = px.scatter(
    plot_df,
    x='cost',
    y='profit',
    color='product',
    size='revenue',
    title='Cost vs Profit Analysis',
    hover_data=['date']
)
fig_scatter.show()

最佳实践:对于数据探索阶段,Seaborn 的统计图表能够快速揭示分布和关联;对于向业务方汇报或部署在 Web 仪表盘上,Plotly 的交互特性让受众能够自主挖掘数据细节;Matplotlib 则在论文插图和精确定制化排版场景中仍不可替代。一个高效的工作流是:先用 Seaborn 做 EDA 绘图,发现关键模式后用 Plotly 制作可交互的汇报版本。


10. 实战:销售数据分析

本节综合运用前面介绍的清洗、合并、透视、时间序列和可视化技术,完成一个完整的销售数据分析案例。从原始 CSV 的加载到最终 Excel 报表的导出,覆盖实际业务分析师日常工作的完整链路。

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

# 模拟加载销售数据(实际分析中替换为 pd.read_csv)
dates = pd.date_range('2024-01-01', periods=500, freq='D')
products = ['Laptop', 'Phone', 'Tablet', 'Monitor', 'Keyboard']
categories = {'Laptop': 'Electronics', 'Phone': 'Electronics', 'Tablet': 'Electronics',
              'Monitor': 'Accessories', 'Keyboard': 'Accessories'}
regions = ['North', 'South', 'East', 'West']

sales = pd.DataFrame({
    'date': np.random.choice(dates, 500),
    'product': np.random.choice(products, 500),
    'region': np.random.choice(regions, 500),
    'quantity': np.random.randint(1, 10, 500),
    'unit_price': np.random.choice([2000, 5000, 3000, 1500, 400], 500)
})

# 数据清洗:模拟少量缺失和异常值
sales.loc[np.random.choice(sales.index, 10), 'quantity'] = np.nan
sales['amount'] = sales['quantity'] * sales['unit_price']

sales = sales.dropna(subset=['quantity', 'amount'])
sales['amount'] = sales['amount'].abs()  # 修正可能的负数金额

# 新增分析维度
sales['month'] = sales['date'].dt.month
sales['quarter'] = sales['date'].dt.quarter
sales['category'] = sales['product'].map(categories)
sales['year_month'] = sales['date'].dt.to_period('M')

# 核心指标计算
monthly_revenue = sales.groupby('month')['amount'].sum().sort_index()
top_products = sales.groupby('product')['amount'].sum().nlargest(10)
category_share = sales.groupby('category')['amount'].sum() / sales['amount'].sum()
region_perf = sales.groupby('region').agg({'amount': 'sum', 'quantity': 'sum'}).sort_values('amount', ascending=False)

# 数据透视:产品 × 区域的销售额矩阵
pivot_sales = pd.pivot_table(
    sales,
    values='amount',
    index='product',
    columns='region',
    aggfunc='sum',
    fill_value=0,
    margins=True
)

# 月度趋势可视化
monthly_revenue.plot(kind='bar', figsize=(10, 5), title='Monthly Revenue 2024', color='teal')
plt.ylabel('Revenue (CNY)')
plt.xticks(rotation=0)
plt.tight_layout()
plt.show()

# 导出多工作表 Excel 报表
with pd.ExcelWriter('sales_report.xlsx', engine='openpyxl') as writer:
    monthly_revenue.to_frame(name='revenue').to_excel(writer, sheet_name='月度营收')
    top_products.to_frame(name='revenue').to_excel(writer, sheet_name='Top10产品')
    category_share.to_frame(name='share').to_excel(writer, sheet_name='品类占比')
    pivot_sales.to_excel(writer, sheet_name='产品区域矩阵')
    region_perf.to_excel(writer, sheet_name='区域业绩')

print("销售报表已导出至 sales_report.xlsx")

实战要点:在实际项目中,数据源的质量往往比分析技巧更重要。建议在分析流程开头建立一份"数据质量检查清单”,包括:缺失值比例是否可接受、日期范围是否合逻辑、金额字段是否全为非负数、关键维度的枚举值是否完整。这些检查可以通过 Pandas 的断言函数(如 assert df['amount'].min() >= 0)嵌入到代码中,防止脏数据污染后续的分析结论。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时