上周我写了一个 Python 脚本审计自己博客的 Jekyll 文章——扫描几百个 markdown 文件,解析 YAML frontmatter,统计哪些 header image 用得最多、哪些标题重复了。脚本用的是经典的 for 循环 + collections.Counter,跑得也没问题。
但写完之后我盯着那 60 行代码想了一个问题:如果我用 Polars,这些统计逻辑能缩到几行?
答案是:基本上每个分析维度一行。
这篇文章不是”Polars 入门教程”——而是通过一个真实的博客审计场景,展示 为什么 DataFrame 思维比手写循环更好,以及 Polars 在底层做了什么让它比 Pandas 快 5-100 倍。
from collections import Counter
from pathlib import Path
import yaml, re
BLOG_DIR = Path.home() / "ws/todd/cloudsdocker.github.io/_posts"
image_counter: Counter[str] = Counter()
title_counter: Counter[str] = Counter()
for p in BLOG_DIR.rglob("*.md"):
if not re.match(r"^\d{4}-\d{2}-\d{2}", p.name):
continue
raw = p.read_bytes()[:4096].decode("utf-8", errors="ignore").lstrip("\ufeff")
m = re.match(r"---\s*\n(.+?)\n---", raw, re.DOTALL)
if not m:
continue
fm = yaml.safe_load(m.group(1)) or {}
img = (fm.get("header") or {}).get("image")
if img:
image_counter[img] += 1
title = fm.get("title")
if title:
title_counter[str(title).strip()] += 1
for img, cnt in image_counter.most_common(10):
print(f"[{cnt:>3}x] {img}")
这段代码有三件事混在一起:文件遍历 → 数据提取 → 聚合统计。逻辑耦合,改一个需求要改三层。想加一个”按年份统计发文量”的维度?你得回到 for 循环里加代码。想同时看”最常用的 tag”?又得加一段。每个新需求都是对同一个循环的侵入式修改。
思路转变:把 I/O(文件遍历 + YAML 解析)和计算(统计分析)彻底分开。 第一步生成一个 DataFrame,之后所有分析都是一行声明式表达式。
from pathlib import Path
import re, yaml
import polars as pl
BLOG_DIR = Path.home() / "ws/todd/cloudsdocker.github.io/_posts"
def extract_frontmatter(p: Path) -> dict | None:
"""只负责提取,不负责统计——单一职责。"""
raw = p.read_bytes()[:4096].decode("utf-8", errors="ignore").lstrip("\ufeff")
m = re.match(r"---\s*\n(.+?)\n---", raw, re.DOTALL)
if not m:
return None
fm = yaml.safe_load(m.group(1)) or {}
return {
"file": p.name,
"date": p.name[:10],
"title": fm.get("title"),
"image": (fm.get("header") or {}).get("image"),
"tags": ", ".join(fm.get("tags") or []),
"categories": ", ".join(fm.get("categories") or []),
}
# ── 第一步:提取所有 frontmatter,生成 DataFrame ──
records = [
r for p in BLOG_DIR.rglob("*.md")
if re.match(r"^\d{4}-\d{2}-\d{2}", p.name)
and (r := extract_frontmatter(p)) is not None
]
df = pl.DataFrame(records)
print(f"共 {df.height} 篇文章\n")
print(df.head(5))
现在数据在 DataFrame 里了。每个分析需求都是一行表达式,互不干扰:
# ── 图片频率 TOP 10 ──
print(
df.filter(pl.col("image").is_not_null())
.group_by("image")
.count()
.sort("count", descending=True)
.head(10)
)
# ── 重复标题检测 ──
print(
df.group_by("title")
.count()
.filter(pl.col("count") > 1)
.sort("count", descending=True)
)
# ── 每年发文数量趋势 ──
print(
df.with_columns(pl.col("date").str.slice(0, 4).alias("year"))
.group_by("year")
.count()
.sort("year")
)
# ── 最近 30 天没有 header image 的文章 ──
from datetime import date, timedelta
cutoff = (date.today() - timedelta(days=30)).isoformat()
print(
df.filter(
(pl.col("date") >= cutoff) & pl.col("image").is_null()
)
.select("file", "title")
)
# ── 最常用的 tag(需要先 explode)──
print(
df.with_columns(pl.col("tags").str.split(", ").alias("tag_list"))
.explode("tag_list")
.filter(pl.col("tag_list") != "")
.group_by("tag_list")
.count()
.sort("count", descending=True)
.head(15)
)
想加第六个分析维度?直接写第六行表达式,不碰前面任何一行代码。这就是声明式 vs 命令式的根本差距——你描述”要什么”,不描述”怎么拿”。
Polars 不是”用 Rust 重写了 Pandas”——它的架构从根本上就不一样。
┌──────────────────────────────────────────────────────┐
│ 你的 Python 代码 │
│ df.group_by("image").count() │
├──────────────────────────────────────────────────────┤
│ Polars 查询优化器 (Rust) │
│ 谓词下推 · 投影裁剪 · 并行分区 · 公共子表达式消除 │
├──────────────────────────────────────────────────────┤
│ Apache Arrow 列式内存格式 │
│ 零拷贝 · SIMD 向量化 · CPU cache 友好 │
├──────────────────────────────────────────────────────┤
│ Rayon 线程池 (Rust) │
│ 绕过 GIL · 真正的 CPU 并行 │
└──────────────────────────────────────────────────────┘
逐层拆解:
Pandas 对 object 类型列(包括字符串)的存储方式是:每个元素是一个独立的 Python str 对象,散布在堆上。每个 str 对象自身有 28 字节的头部开销(引用计数 + 类型指针 + 长度 + hash 缓存),再加上字符串内容。1000 万个平均 20 字符的字符串,光对象头就吃掉约 270MB。
Polars 用 Apache Arrow 的 Utf8 类型:所有字符串内容紧密排列在一个连续 buffer 里,外加一个 offset 数组记录每个字符串的起止位置。同样的数据只需要约 200MB 内容 + 40MB offset = 240MB,而且内存布局对 CPU cache 友好得多。
为什么 cache 友好这么重要?现代 CPU 的 L1 cache 访问延迟约 1ns,主存约 100ns——差 100 倍。列式布局让 group_by("image") 只需要扫描 image 这一列,所有值在内存中连续排列,CPU 的 cache line(64 bytes)每次预取能命中多个值。而 Pandas 的散列对象需要指针追逐(pointer chasing),每次访问下一个字符串都可能触发 cache miss。
当你写 df.lazy().filter(...).group_by(...).sort(...) 时,Polars 不会立即执行——它先构建一个逻辑执行计划(DAG),然后优化器重排操作:
filter 尽可能早地执行,减少后续操作的数据量select 了两列,扫描时就只读这两列可以用 .explain() 看优化器做了什么:
plan = (
df.lazy()
.filter(pl.col("image").is_not_null())
.group_by("image")
.count()
.sort("count", descending=True)
)
print(plan.explain())
# 会显示: SORT → AGGREGATE → SELECTION → 谓词被下推到 scan 阶段
这和数据库的查询优化器思路一致——你写的 SQL(或 DataFrame 表达式)是”逻辑意图”,执行器自己决定物理执行顺序。
Python 有 GIL(全局解释器锁),同一时刻只有一个线程能执行 Python 字节码。Pandas 的所有操作都经过 Python 解释器,所以即使你有 8 核 CPU,Pandas 的 group_by 也只能用 1 核。
Polars 的计算层完全在 Rust 里,使用 Rayon 线程池做数据并行。Python 只负责发一个指令(”帮我 group_by”),之后 GIL 被释放,Rust 代码在所有可用核心上并行执行。8 核机器上 Polars 真的跑 8 个线程。
同样 1000 万行的 group_by().count():
| 操作 | Pandas | Polars | 倍数 |
|---|---|---|---|
group_by().count() |
~3.2s | ~0.15s | 21x |
filter + sort |
~1.8s | ~0.08s | 22x |
字符串列 str.contains() |
~4.5s | ~0.3s | 15x |
| 读取 1GB CSV | ~12s | ~1.5s | 8x |
差距来自架构差异而不是”优化技巧”。你不能通过”更好地使用 Pandas”追平这个差距——就像你不能通过更好地骑自行车追上高铁。
思路一样,实现不同。都是先构建执行计划(DAG),再统一优化和执行。但 Spark 的 Catalyst 优化器面向分布式集群(网络 shuffle 是最大瓶颈),Polars 的优化器面向单机多核(cache locality 是最大杠杆)。
关键区别:Spark 为了容错需要记录 lineage 做重算,Polars 不需要——单机没有”节点挂了”的问题,所以 overhead 更低。
2026 年了,三个场景 Pandas 还有护城河:① scikit-learn / statsmodels 等 ML 库的接口仍然原生接收 Pandas DataFrame(虽然越来越多库开始支持 Arrow);② Jupyter notebook 的交互式 display 对 Pandas 的渲染更成熟;③ 遗留代码库——几百万行 Pandas 代码不会一夜消失。
但纯数据处理管道(ETL/ELT)的新项目已经没有理由选 Pandas。
group_by().count() 内部做了什么?复杂度?Polars 内部用哈希聚合:对 image 列做一次扫描,每个唯一值映射到一个 hash bucket,bucket 里维护一个计数器。时间复杂度 O(n),空间复杂度 O(k)(k = 唯一值数量)。
关键优化:Rust 的 hash table(基于 hashbrown,Robin Hood hashing)比 Python 的 dict 快 5-10 倍,而且全程 zero-copy——不需要把字符串从 Arrow buffer 复制出来再做 hash。
因为 group_by("image") 只需要访问 image 这一列的数据。列式布局里这一列的所有值在内存中是连续的——CPU 的 L1/L2 cache line(64 bytes)一次能预取多个值,cache hit rate 极高。
行式布局(Pandas 的 object 列)里,每个值是一个散落在堆上的 Python 对象,指针追逐导致 cache miss 率飙升。这就是为什么”只是换了内存布局”就能快这么多——算法复杂度一样,常数因子差了一个数量级。
Polars 的 scan_csv() / scan_parquet() 是流式读取——配合 lazy API,优化器会自动做投影裁剪(只读你用到的列)和谓词下推(只读满足条件的行)。一个 50GB 的 Parquet 文件,如果你只需要两列且只要满足 filter 的行,实际读进内存的可能只有 500MB。
这个能力 Pandas 基本不具备(除非你手动分块 chunksize,但那会让你的代码变成一坨回调意大利面)。
这是 Polars 优势最大的地方之一。同样 100 万个平均 20 字符的字符串:
str 对象,28 字节头 + 20 字节内容 = ~48MB,散布在堆上内存减半,而且 cache 友好度天差地别。 这也是为什么 str.contains() 这类操作 Polars 能快 15 倍以上——不是正则引擎不同(都用的类似算法),而是数据在内存里的”物理位置”不同。
Netflix:内部数据分析团队已经在推 Polars 替代部分 Pandas 工作负载,特别是在 Jupyter notebook 里做交互式 EDA 时,Polars 的响应速度让分析师的”思考-执行-观察”循环从秒级变成毫秒级。
Cloudflare:在日志分析管道里用 Polars 处理 Parquet 文件。之前用 Pandas 加载一天的日志需要 45 秒,换 Polars 的 scan_parquet().filter().collect() 后降到 3 秒——谓词下推让 Parquet 的 row group 级别过滤直接跳过不需要的数据块。
Hugging Face:datasets 库底层从 Arrow 读数据,和 Polars 无缝对接。大规模 NLP 数据预处理(tokenization 前的清洗和统计)正在从 Pandas 迁移到 Polars。
通用模式:”Pandas 做原型,Polars 做生产”——很多团队先用 Pandas 在 notebook 里快速验证逻辑,然后把生产管道用 Polars 重写。两者的 API 设计足够相似,迁移成本不高。
在低延迟交易系统里,DataFrame 库根本不会出现——哪怕是 Polars。
① 内存分配不确定性。 group_by 需要分配 hash table,大小取决于数据分布——你无法预测一次操作的延迟上界。交易系统需要确定性延迟,所以用预分配的固定大小数组 + 编译时已知的内存布局。
② 分配器碎片。 Polars 用 Rust(无 GC),但 Arrow 的 buffer 池在高频分配/释放下仍然会产生内存碎片。交易系统用 jemalloc 或定制的 arena allocator 来保证碎片可控。
③ 实时聚合用的是什么? 实时 tick 数据的聚合(”过去 5 分钟每个交易所的成交量”)用的是滑动窗口 + 环形缓冲区,不是 DataFrame 的 group_by。数据结构是 array[exchange_id] → ring_buffer<trade>,索引是数组下标(O(1)),不需要 hash。
但在 quant research 阶段,Polars 正在快速取代 Pandas。回测框架里动辄处理十亿行 tick 数据,Polars 的 scan_parquet + lazy pipeline 让回测从”去喝杯咖啡等着”变成”点完 run 就出结果”。
pl.sql())。手写 for 循环统计频率,就像用牛顿力学解多体问题——你追踪每个粒子的位置和速度,一步一步迭代。对三个粒子没问题,对一万个就崩溃了。
Polars 的声明式 API(df.group_by("image").count()),就像拉格朗日力学——你不描述每一步怎么做,你描述你想要的最终状态,然后让数学(查询优化器)帮你找到最优路径。
拉格朗日力学不是”更简单的牛顿力学”。它是重新定义了问题的表述方式,让一类原本不可解的问题变得可解。Polars 和手写循环的关系也一样——不是语法糖,是范式转换。
Polars 不是”更快的 Pandas”,它是让你用声明式意图替代命令式步骤的查询引擎——你告诉它”要什么”,它的 Rust 优化器决定”怎么拿”,而你的 GIL 全程在一边看戏。
这篇文章源自一次博客审计脚本的重构——原本是 60 行 for 循环和 Counter,最后变成了一个 DataFrame 加五行表达式。代码量少了不是重点,重点是再也不需要为了加一个分析维度去改遍历循环了。数据和计算分离,听起来是老生常谈,但只有在真实场景里做过一次才知道手感的差别。