本节目标:分清 pandas 与 polars 各自的定位,用同一份数据各写一遍「读取 → 清洗 → 分组聚合 → 排序 → 导出」,并实测内存、惰性 API 与向量化带来的差异。
适用版本:Python 3.12+(实测 3.14.6)
17.3 数据分析入门
上一节我们把网页抓成了一行行记录,但「一堆记录」还不是「结论」。要算出「哪个区域、哪类商品卖得最好」,就得靠表格数据结构。Python 里干这活的两大主力是 pandas 和 polars。本节不铺用法清单,而是用同一份数据、同一个问题,把两者各跑一遍,让你看清它们的分工与取舍。数据全部由代码生成,不下载任何外部数据。
17.3.1 pandas 与 polars 的定位差异
| 维度 | pandas | polars |
|---|---|---|
| 语言 / 内核 | Python + NumPy | Rust + Apache Arrow |
| API 风格 | 索引导向(df.loc / df[...]) | 表达式导向(pl.col) |
| 惰性求值 | 有限(query 等局部) | 一等公民(.lazy() / .collect()) |
| 内存格式 | 列存但字符串为 object | Arrow 列存,字符串紧凑 |
| 多线程 | 受 GIL 限制,默认单线程 | 无 GIL,天然多核 |
| 生态成熟度 | 极高(文档、教程、周边库) | 快速增长,周边仍偏少 |
| 上手成本 | 低 | 中(表达式语法要重新学) |
一句话分工:探索性分析、生态依赖重,用 pandas;数据大到内存吃紧、或要榨性能,用 polars。两者不是二选一,to_pandas() / pl.from_pandas() 能随时互转。
17.3.2 DataFrame / Series / Index 三件套
pandas 的世界由三个对象组成:
Series:带标签的一维数组,一列数据。DataFrame:带行列标签的二维表,由多个共享同一Index的Series组成。Index:行/列的标签,是「对齐」的依据。
import pandas as pd
df = pd.DataFrame({"region": ["华北", "华东"], "revenue": [1000.0, 2000.0]})
print(df.shape) # (2, 2)
print(df["revenue"]) # Series
print(df.dtypes) # region: object, revenue: float64
polars 没有 Index 这个概念——它的 DataFrame 是纯列集合,靠 pl.col("x") 表达式操作,这一点直接决定了后面的 API 差异。
17.3.3 读写 CSV / Excel / Parquet
三种格式各有用处,两边都能读写:
import pandas as pd, polars as pl
df = pd.DataFrame({"region": ["华北", "华东", "华南"], "revenue": [597.0, 895.0, 2598.0]})
df.to_csv("mini.csv", index=False)
df.to_excel("mini.xlsx", index=False, engine="openpyxl") # Excel 要 openpyxl
df.to_parquet("mini.parquet", index=False) # 要 pyarrow
print(len(pd.read_parquet("mini.parquet"))) # 3
- CSV:纯文本、通用,但无类型、体积大、读写慢。
- Excel:给人看,带公式样式;
pandas读写要openpyxl引擎。 - Parquet:列式二进制,带类型、压缩率高,数据分析首选——同一份 50 万行数据,CSV 有 27.7 MB,Parquet 只有 3.9 MB。
polars 侧对应 pl.read_csv / pl.scan_csv / pl.scan_parquet。
17.3.4 端到端分析:pandas 版
先造一份 50 万行的销售数据(含约 2% 缺失值),存成 CSV 与 Parquet:
import numpy as np, pandas as pd
rng = np.random.default_rng(42)
n = 500_000
cats = {"键盘": "外设", "鼠标": "外设", "显示器": "显示", "主机": "整机", "内存条": "配件"}
df = pd.DataFrame({
"order_id": np.arange(1, n + 1),
"date": pd.to_datetime("2026-01-01") + pd.to_timedelta(rng.integers(0, 180, n), unit="D"),
"region": rng.choice(["华北", "华东", "华南", "西南"], n),
"product": rng.choice(list(cats), n),
"quantity": rng.integers(1, 10, n),
"unit_price": rng.choice([199.0, 89.5, 1299.0, 4999.0, 299.0], n),
})
df["category"] = df["product"].map(cats)
df["amount"] = df["quantity"] * df["unit_price"]
mask = rng.random(n) < 0.02 # 人为制造缺失
df.loc[mask, ["quantity", "amount"]] = np.nan
df.to_csv("sales.csv", index=False)
df.to_parquet("sales.parquet", index=False)
pandas 的分析写法——读入、填缺失、分组聚合、排序,一条链:
import time
t0 = time.perf_counter()
d = pd.read_csv("sales.csv", parse_dates=["date"])
d["amount"] = d["amount"].fillna(d["quantity"].fillna(0) * d["unit_price"])
report = (
d.groupby(["region", "category"], as_index=False)
.agg(orders=("order_id", "count"), revenue=("amount", "sum"), avg_qty=("quantity", "mean"))
.sort_values("revenue", ascending=False)
)
print(f"pandas 耗时: {(time.perf_counter() - t0) * 1000:.1f} ms")
print(report.head(3).to_string(index=False))
pandas 耗时: 368.9 ms
region category orders revenue avg_qty
西南 外设 49888 339983043.5 5.011704
华北 外设 49903 338297854.0 5.014996
华东 外设 49833 337953042.5 4.984452
关键就三个动作:groupby 按维度分组、agg 指定每列聚合函数、sort_values 排序。fillna 那行是缺失值处理:金额缺失时用「数量 × 单价」补,数量也缺失就按 0 算。
17.3.5 同一分析用 polars:代码量与耗时
同一个问题,polars 写成惰性表达式:
import polars as pl, time
t0 = time.perf_counter()
report = (
pl.scan_csv("sales.csv")
.with_columns(
pl.col("amount").fill_null(pl.col("quantity").fill_null(0) * pl.col("unit_price"))
)
.group_by(["region", "category"])
.agg(
orders=pl.col("order_id").count(),
revenue=pl.col("amount").sum(),
avg_qty=pl.col("quantity").mean(),
)
.sort("revenue", descending=True)
.collect() # 到这里才真正执行
)
print(f"polars 耗时: {(time.perf_counter() - t0) * 1000:.1f} ms")
polars 耗时: 62.4 ms
shape: (16, 5)
┌────────┬──────────┬────────┬──────────────┬──────────┐
│ region ┆ category ┆ orders ┆ revenue ┆ avg_qty │
│ 西南 ┆ 外设 ┆ 49888 ┆ 3.3998e8 ┆ 5.011704 │
│ 华北 ┆ 外设 ┆ 49903 ┆ 3.38297854e8 ┆ 5.014996 │
└────────┴──────────┴────────┴──────────────┴──────────┘
同一份数据、同一个结果,本机实测 pandas 368.9 ms vs polars 62.4 ms(墙钟时间,含读取与聚合)。差异来自三处:polars 用 Rust + Arrow 的列式内核、多核并行、且 scan_csv 是惰性的(先规划再执行,能下推过滤与投影)。pandas 的代码量与之相当,但语义更贴近「表格操作」,读起来更直观。注意这只是本机单次测量的量级参考,不是绝对基准。
17.3.6 dtype 与内存占用
数据一大,dtype 就是内存的命门。memory_usage(deep=True) 会真正统计字符串对象的字节:
d = pd.read_csv("sales.csv")
print(round(d.memory_usage(deep=True).sum() / 1024 / 1024, 2), "MB") # 44.44 MB
d2 = d.astype({"region": "category", "product": "category", "category": "category"})
print(round(d2.memory_usage(deep=True).sum() / 1024 / 1024, 2), "MB") # 25.27 MB
print(pl.read_csv("sales.csv").estimated_size("mb")) # 29.3 MB
pandas(字符串 object): 44.44 MB
pandas(转 category): 25.27 MB
polars estimated_size: 29.3 MB
object 字符串每格都要存一个 Python 对象,开销巨大;转成 category(枚举编码)后三列合计省下近 20 MB。deep=True 是关键——不加它只统计指针大小,会严重低估字符串列的真实占用。
17.3.7 链式调用与惰性 API
pandas 的链式调用靠 assign / groupby / query 串起来;polars 的 .lazy() 则把整条链编译成一个可优化的查询计划,.collect() 才执行:
plan = (
pl.scan_csv("sales.csv")
.filter(pl.col("region") == "华东")
.select(["region", "category", "amount"])
.group_by("category")
.agg(pl.col("amount").sum().alias("revenue"))
)
print("\n".join(plan.explain().splitlines()[:6]))
AGGREGATE[maintain_order: false]
[col("amount").sum().alias("revenue")] BY [col("category")]
FROM
simple π 2/2 ["category", "amount"]
Csv SCAN [.../sales.csv]
PROJECT 3/8 COLUMNS
SELECTION: col("region") == "华东"
计划里能清楚看到 投影下推(PROJECT 3/8 COLUMNS,只读 3 列)和 谓词下推(SELECTION,过滤在扫描层完成)——这就是 polars 能少读少算的原因。惰性还带来一个好处:可以先 explain() 看计划、确认合理了再 collect(),调试大型查询时非常有用。
17.3.8 apply 为什么慢
Series.apply 会把每一行的值逐个送进 Python 函数,等于放弃了底层的 C 运算。用 5 万行对比一下:
sub = d.head(50_000).copy()
t0 = time.perf_counter(); sub["tax_apply"] = sub["amount"].apply(lambda x: x * 0.13)
t_apply = time.perf_counter() - t0
t0 = time.perf_counter(); sub["tax_vec"] = sub["amount"] * 0.13
t_vec = time.perf_counter() - t0
print(f"apply {t_apply*1000:.1f} ms | 向量化 {t_vec*1000:.2f} ms | {t_apply/t_vec:.1f}x")
apply: 5.8 ms | 向量化: 0.31 ms | 倍数: 18.8x
而按行 apply(..., axis=1) 更慢——它要把整行拼成一个 Series 再调用函数,实测 5000 行就要 21.5 ms,而列向量化只要 0.49 ms。结论:能用列级运算/向量化表达式表达的,绝不写 apply;axis=1 更是最后手段。
17.3.9 缺失值、NaN 与链式赋值的坑
缺失值有两层坑。其一,整数列一旦出现缺失就会整体升为浮点(int64 → float64),因为 NaN 是浮点数,这会悄悄改变 dtype。其二,NaN 与 None 在对象列里可能并存,比较时要统一用 pd.isna(),别用 == None。
链式赋值是另一个经典陷阱。在 pandas 3.0 里,Copy-on-Write 已成为不可关闭的默认行为(mode.copy_on_write 选项已废弃):
df = pd.DataFrame({"city": ["BJ", "SH", "BJ"], "salary": [1, 2, 3]})
view = df[df["city"] == "BJ"]
view["salary"] = 999 # 链式赋值
print(df["salary"].tolist()) # [1, 2, 3] —— 原 df 没变,也不报警告
df.loc[df["city"] == "BJ", "salary"] = 999 # 正确写法
print(df["salary"].tolist()) # [999, 2, 999]
触发警告数: 0
df 是否被改: [1, 2, 3]
loc 写法结果: [999, 2, 999]
老教程里说的 SettingWithCopyWarning,在 pandas 3.0 上已经不再触发——CoW 让链式赋值变成一个「静默的空操作」,比报警告更隐蔽。所以规矩更简单:要改就用 .loc[行条件, 列名] = 值,永远不要 df[...][...] = 值。
17.3.10 导出结果
分析完把结果写回去。pandas 与 polars 各写各的:
report.to_csv("report.csv", index=False) # pandas
report.write_parquet("report.parquet") # polars
- 要给程序继续用:Parquet(带类型、小、快)。
- 要给同事看:Excel(
report.to_excel("report.xlsx", engine="openpyxl"))。 - 要进版本库、看 diff:CSV。
小结
- pandas 生态成熟、API 贴近表格,适合探索性分析与生态依赖重的场景;polars 用 Rust + Arrow、天然多核、惰性求值,适合大数据与性能敏感场景。两者可互转。
- 三件套:
Series(列)、DataFrame(表)、Index(标签);polars 无Index,用pl.col表达式。 - CSV 通用、Excel 给人看、Parquet 分析首选(50 万行:CSV 27.7 MB vs Parquet 3.9 MB)。
- 同一份 50 万行聚合,本机实测 pandas 368.9 ms vs polars 62.4 ms;polars 靠惰性下推 + 多核取胜,但这是量级参考而非绝对基准。
- 内存看
dtype:字符串转category能省近一半;memory_usage(deep=True)才准。 apply逐行调用 Python,实测比向量化慢 18 倍以上,axis=1更慢——优先向量化。- pandas 3.0 起 CoW 不可关闭,链式赋值静默失效且不报警;改值一律用
.loc。
到这里,第 17 章「实用场景」就把文件、网络、数据三条最常用的自动化链路走通了。下一章我们把这些零件组装成一个从零到一的完整项目。
阅读导航:上一节:网络爬虫基础与合规边界 · 下一节:从零构建一个完整项目 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。