《Python编程入门》17.3 数据分析入门

把 pandas 与 polars 放在一起讲:用同一份 50 万行销售数据各跑一遍 groupby 聚合,对比代码量与实测耗时,再实测 dtype 与内存占用、链式调用与惰性 API、apply 与向量化的性能差,并说清缺失值、NaN 与链式赋值的坑。

本节目标:分清 pandas 与 polars 各自的定位,用同一份数据各写一遍「读取 → 清洗 → 分组聚合 → 排序 → 导出」,并实测内存、惰性 API 与向量化带来的差异。
适用版本:Python 3.12+(实测 3.14.6)

17.3 数据分析入门

上一节我们把网页抓成了一行行记录,但「一堆记录」还不是「结论」。要算出「哪个区域、哪类商品卖得最好」,就得靠表格数据结构。Python 里干这活的两大主力是 pandas 和 polars。本节不铺用法清单,而是用同一份数据、同一个问题,把两者各跑一遍,让你看清它们的分工与取舍。数据全部由代码生成,不下载任何外部数据。

17.3.1 pandas 与 polars 的定位差异

维度pandaspolars
语言 / 内核Python + NumPyRust + Apache Arrow
API 风格索引导向(df.loc / df[...])表达式导向(pl.col)
惰性求值有限(query 等局部)一等公民(.lazy() / .collect())
内存格式列存但字符串为 objectArrow 列存,字符串紧凑
多线程受 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 章「实用场景」就把文件、网络、数据三条最常用的自动化链路走通了。下一章我们把这些零件组装成一个从零到一的完整项目。

阅读导航:上一节:网络爬虫基础与合规边界 · 下一节:从零构建一个完整项目 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「python」更多文章

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