对越来越多开发者而言,他们第一次看到的Polars代码,是语言模型写的。有些人只是询问如何完成某些转换;另一些人已经几个月没有亲手编写Polars查询了。
一个主要商业用例,是LLM让迁移变成快速收益项目。开发者输入pandas脚本,拿回Polars代码,再验证输出是否一致。结果通常是流水线快了一个数量级,同时能运行在更小、更便宜的机器上。转换成本降低,投资回报也会更快实现。
这一商业用例是否成功,取决于LLM的转换质量。Polars API表达能力很强,好的转换应该选择最直接表达意图的结构,而不仅是能够运行的代码。我们想知道,当前模型是否会这么做,还是退回到恰好在Polars中也合法的pandas式习惯。
我们让Claude Opus 4.8把一批pandas代码转换为Polars,再检查是否符合Polars惯用写法。每个案例都在新会话中一次性转换。多数代码能够运行、输出一致,读起来也符合Polars习惯。但仍有两类结构模式:模型照搬已有pandas结构,而不是使用直接表达意图的Polars结构。我们把修正方法打包成技能,加载技能重新执行实验并测量差异。技能能引导模型改进这些模式,但并非万能解法。Anthropic最新模型Fable 5在美国之外尚不可用,因此我们未能纳入实验。
实验设置
为了衡量当前水平,我们转换了三组pandas代码:
- 22个PDS-H基准查询。这是由TPC-H衍生的基准,使用polars-benchmark中的pandas实现。输入只有pandas查询,而已有等价Polars查询可用,因此很容易验证结果。 (开发者链接;polars-benchmark)
- 从三个公开pandas教学仓库抽取的11个探索性数据分析(EDA)案例,涵盖窗口函数、重采样、as-of连接、重塑、字符串处理、分箱与缺失数据。这些高级模式并未包含在PDS-H基准中。
- 两个来自Kaggle的真实ETL笔记本,用于最初的人工探索,提供更大的实际案例供比较。
每个案例都由Claude Opus 4.8在新会话中一次性转换,提示词明确要求采用Polars惯用写法。我们用polars.testing.assert_frame_equal,将每份转换结果与原始pandas输出对比;PDS-H也与参考答案比较。我们还让Claude Sonnet 4.6处理同一代码集进行对照,结果见附录。
当下的转换质量
一年前,LLM经常混用pandas与Polars API,调用groupby、with_column等已弃用方法,生成无法运行的代码。现在,Opus 4.8的33份转换中有31份正确运行,并通过输出一致性验证。两处例外较小:一个案例缺少导入,另一个出现列引用错误。Opus比早期模型更愿意重构而非逐行转换,使代码更符合Polars习惯,但偶尔仍引入小错误。
过去有问题的模式,如今都顺利完成转换:
- group_by、with_columns、is_in等基础用法都正确。
- 适合的地方会使用半连接与反连接。
- 条件聚合保持原生实现。PDS-H与EDA转换中均未出现map_elements。
- 正确区分惰性与立即执行,collect()出现在应该出现的位置。在惰性API中先构建查询,调用collect()时Polars才应用优化并真正执行。 (lazy/eager distinction)
- 没有出现列表包裹参数、Python侧标量运算、map_elements回退。这些是早期模型常见的迹象;附录Sonnet 4.6结果展示了它们的样子。
剩余模式更加微妙。代码能够运行,输出正确,但少数方案的结构仍照搬原始pandas代码。模型熟悉Polars API,却不总能识别另一种结构能更直接表达意图。
模式1:转换结构,而非意图
这是我们发现最清楚、也最难消除的情况。示例源笔记本为逐分钟股票价格添加5分钟窗口聚合。它采用常见pandas做法:先生成重采样到5分钟窗口的第二个数据框,再用merge_asof合并回去。as-of连接按键将每行匹配到另一数据框中最近的前一行,这里使用时间戳作为键。
LLM忠实转换这一结构,使用group_by_dynamic(Polars类似重采样的时间分桶)以及as-of连接:
# The LLM's Polars translation
five_min = minute.group_by_dynamic("datetime", every="5m").agg(
pl.col("close").mean().alias("window_price"),
pl.col("volume").sum().alias("window_volume"),
)
result = minute.join_asof(
five_min,
on="datetime",
strategy="backward",
tolerance=timedelta(minutes=5),
)
# What idiomatic Polars looks like
result = minute.with_columns(
pl.col("close")
.mean()
.over(pl.col("datetime").dt.truncate("5m"))
.alias("window_price"),
pl.col("volume")
.sum()
.over(pl.col("datetime").dt.truncate("5m"))
.alias("window_volume"),
)
dt.truncate(“5m”)将每个时间戳向下取整到所属5分钟桶的起点,.over()按桶聚合。真正意图是“给每一行添加其所属5分钟桶的聚合值”,.over()在单一上下文中恰好表达这一点。
.over()是Polars的窗口函数
它相当于SQL的OVER (PARTITION BY …),计算组级表达式,再把结果广播到该组每一行,全部发生在with_columns内部。Polars有意将窗口函数与聚合区分:group_by().agg()把数据框缩减到每组一行,.over()则用组级值丰富原数据框的每一行。全部映射策略见窗口函数指南。 (窗口函数指南)
有趣的是,pandas源代码使用transform时,模型能顺利映射到.over()。pandas也能通过groupby(pd.Grouper(key=”datetime”, freq=”5min”)).transform(…)一步表达同一任务;这样编写的笔记本很可能直接转换为.over()。当源代码先构建聚合数据框再连接回去时,模型转换的是变通做法,而非意图。这种模式出现在33个案例中的3个。
模式2:在流水线中途提取标量
一个PDS-H查询使用从数据本身推导的阈值筛选行:小于某个零件平均数量的一定比例。在转换中,LLM先解析阈值,提取为Python浮点数,再用于过滤。
# What LLMs produce
threshold = lf.select(pl.col("revenue").quantile(0.9)).collect().item()
result = lf.filter(pl.col("revenue") > threshold).collect()
# What idiomatic Polars looks like
result = lf.filter(
pl.col("revenue") > pl.col("revenue").quantile(0.9)
).collect()
第二个版本能够工作,因为聚合值会在filter内部广播。广播意味着标量值被扩展到与比较列长度一致,因此分位数只计算一次,再与每一行比较。
.item()有合适的位置:流水线末尾,需要Python值用于Polars之外的工作时。但.item()只存在于已经物化的DataFrame上,因此在中途使用会迫使查询提前collect()。本应是一个查询,变成两个:数据源扫描两次,中间结果被物化,优化器只能分别看到两半。
另一个一致观察是:在.agg()内部,模型使用完整形式pl.col(“x”).sum(),而不是顶层简写pl.sum(“x”)。两者都合法,只是风格选择。代码集中出现48次,技能把它减少到40次。
为什么这种“口音”仍然存在
这些模式的共同点,是模型一步一步转换pandas源代码,而不是退后重新思考查询意图。每一步最稳妥的做法,是把pandas操作映射为执行同一件事的Polars操作。这让结果固守原有结构,因此转换结果常常酷似原pandas脚本,连中间数据框和聚合后回连都保留。围绕单一.over()重新表述整个查询,需要脱离眼前代码行、识别意图;模型并不总能做到。非惯用代码只要能运行并返回正确答案,就不会产生错误或警告,也不会发出促使模型换一种转换方式的次优信号。按这种衡量方式,通过验证的忠实逐语句转换就是成功。
这种口音也不是语言模型独有的。使用多年pandas后转向Polars的开发者,通常会带着旧模式,直到学会更惯用的Polars查询写法。任何工具迁移都从源代码出发,逐行转换都会把源结构带入结果,与目标库无关。库作者有几种选择:发布能进入LLM训练数据的惯用示例(比如本文);像PolarsInefficientMapWarning那样输出机器可处理警告;以及用智能体可加载的形式交付指导。接下来我们探索最后一种方式。
修正口音
上述模式可以修正。我们把这些模式,以及PDS-H和EDA实验中更广泛的转换说明,打包为Polars技能。智能体加载后,就能引导模型采用正确模式,无需你专门提示。技能免费公开,托管于GitHub仓库。 (GitHub仓库)
它有帮助吗?
我们加载技能后,用同一模型重新运行全部33份转换,每个案例仍使用新会话。这是小规模实验:一个模型、每个案例转换一次。因此,请把数字看作方向性信号,而非基准测试。效果体现在全部33个案例的整体结果中。
技能并不能解决所有问题,但能引导LLM朝正确方向前进。结构模式有所改善:回连案例从3个降到1个,中途.item()的唯一案例改为内联广播。正确结果从31个增加到32个,但也出现一项由技能引起的回归:惰性API指导导致模型对已经立即执行的数据框调用.collect()。聚合简写迹象变化很小,从48次降到40次,减少17%。
下面展示结构改善的实际效果。PDS-H查询17按零件推导阈值,筛选明细项,阈值为该零件平均数量的20%。未加载技能时,模型先构建每零件平均值的独立数据框,再连接回去。加载技能后,组级阈值变成filter内部的.over()窗口;独立聚合数据框和回连合并为一次处理:
# Without the skill
jn = filtered_part.join(
lineitem_ds, left_on="p_partkey", right_on="l_partkey"
)
avg_qty = jn.group_by("p_partkey").agg(
(0.2 * pl.col("l_quantity").mean()).alias("avg_quantity")
)
avg_yearly = (
jn.join(avg_qty, on="p_partkey")
.filter(pl.col("l_quantity") < pl.col("avg_quantity"))
.select((pl.col("l_extendedprice").sum() / 7.0).round(2).alias("avg_yearly"))
)
# With the skill
avg_yearly = (
filtered_part
.join(lineitem_ds.lazy(), left_on="p_partkey", right_on="l_partkey")
.filter(
pl.col("l_quantity") < 0.2 * pl.col("l_quantity").mean().over("p_partkey")
)
.select((pl.col("l_extendedprice").sum() / 7.0).round(2).alias("avg_yearly"))
)
结构模式改善,但未完全消失。三个回连案例解决了两个,股票价格案例仍先构建桶聚合再回连。完整聚合写法大体保留。语法表达层面的规则比重构规则更容易落实,而减少聚合写法迹象需要重构.agg()内部。完整结果见附录。
如何使用
安装技能
技能由SKILL.md文件和配套参考文件组成。我们将它作为Claude Code插件分发,让助手按需发现并加载;手动克隆是备用方案。源码和问题追踪位于技能仓库。 (技能仓库)
Claude Code:通过插件市场安装(推荐)
/plugin marketplace add polars-inc/skills
/plugin install polars@polars
启动会话。任务符合技能描述时,Claude Code会加载技能。输入/polars:polars可以显式调用。
Claude Code:手动安装
克隆仓库,将polars/目录复制到技能文件夹:
# Project-level (this project only)
git clone https://github.com/polars-inc/skills
cp -r skills/polars .claude/skills/
改用~/.claude/skills/可在全部项目启用。手动安装后的技能命令是/polars,不带插件命名空间。
Cursor、Codex与Copilot
技能是一个目录(SKILL.md加参考文件),因此这些工具通过把polars/目录复制到各自技能文件夹进行安装。每种工具读取不同路径。技能README提供Cursor、Codex与Copilot的精确步骤。 (技能README)
加载之后,请求转换代码或审查现有Polars代码即可,无需额外提示。
这是根据一组实验提炼出的当前模型错误快照,随着模型改善和Polars发展,它也会逐渐过时。如果用在自己的pandas代码上,模型仍带着口音,请提交议题描述发现的模式。这些反馈将直接用于下一版技能。 (提交议题)
结论
Claude Opus 4.8把三组pandas代码转换为Polars,在33个案例中的31个可以运行且输出与原文一致,两处是小失误。多数转换符合Polars惯用写法。仍然留下轻微口音:适合.over()时将聚合结果连接回去,以及在流水线中途提取标量,这两类结构模式。
技能可以减少结构模式。聚合情况很难完全消除,因为规则需要重构.agg()内部,而重构规则不如语法层面的规则可靠。带口音代码能运行并返回正确结果,但可能更难阅读维护,非惯用形式也可能没有发挥全部性能。
为改善结构模式,值得加载这个技能。无论是否加载技能,我们都建议用assert_frame_equal验证转换。技能位于公开仓库,我们将继续维护。你可以提交议题报告错误,也可以创建拉取请求,帮助修复转换中遇到的问题。 (技能仓库)
后续文章将探讨流水线迁移策略,包括使用与不使用LLM的情况。
附录
技能内容
技能中的部分规则示例:
| 错误写法 | 正确写法 | 说明 |
|---|---|---|
| .agg([expr, expr])列表形式 | .agg(expr, expr) | 各处均使用位置参数 |
| .agg()内使用pl.col(“x”).sum() | pl.sum(“x”) | 顶层简写 |
| 流水线中途使用.select(…).item() | 对单行聚合结果做交叉连接 | 保持查询计划惰性 |
| map_elements(lambda v: mapping[v]) | .replace_strict(mapping) | 我们的500万行测试中约快19倍,运算保持在引擎内部 |
发展轨迹:Claude Sonnet 4.6
Sonnet 4.6处理同一代码集。全部转换都能够运行,33个案例的输出全部一致。但口音更重:除Opus仍有的结构模式外,Sonnet还生成了一些Opus已经摆脱的语法层面迹象。
用列表包裹参数。select、group_by、agg和with_columns都接受位置参数,但33份Sonnet转换中有21份(64%)传入列表。
# What Sonnet produces
result = df.group_by(["l_returnflag", "l_linestatus"]).agg(
[
pl.col("l_quantity").sum().alias("sum_qty"),
pl.col("l_quantity").count().alias("count_order"),
]
)
# What idiomatic Polars looks like
result = df.group_by("l_returnflag", "l_linestatus").agg(
pl.sum("l_quantity").alias("sum_qty"),
pl.len().alias("count_order"),
)
列表是合法的,但额外括号是代码从pandas机器转换而来的可靠迹象。加载技能后,这种情况完全消失。
回退到map_elements。一个ETL笔记本通过Python字典把国家名称映射为ISO代码。Sonnet把字典查找保留在Python中:
# What Sonnet produces
df = df.with_columns(
pl.col("country_name")
.map_elements(lambda name: country_codes[name], return_dtype=pl.String)
.alias("country_code")
)
# What idiomatic Polars looks like
df = df.with_columns(
pl.col("country_name").replace_strict(country_codes).alias("country_code")
)
map_elements版本让每个值都往返Python解释器。replace_strict使用同一个字典,却在引擎内完成查找:在我们的测量中,500万行时约快19倍。Polars甚至能在运行时识别这种情况,输出PolarsInefficientMapWarning,指出精确替代方法。但在智能体工作流中,代码只运行一次,警告滚过屏幕,没有人采取行动。加载技能后,这个案例在新会话中转换为replace_strict。
Python侧标量运算。两个Sonnet查询对提取的标量使用Python round(),而使用.round()表达式可以把结果保留在引擎内。加载技能后,两者都改为原生表达式。
技能对Sonnet的帮助更大。全部33个案例中的错误模式总实例从80个降到23个,减少71%。大部分改善来自语法层面迹象:列表包裹、map_elements回退以及Python侧round()都消失。结构模式有所改善,但某些案例仍保留,与Opus实验表现相同。同一技能作用于较早模型时效果更大,因为Sonnet的大部分迹象在语法层面,而这些规则能够可靠落实。
发现是否存在合适表达式的最快方式,是搜索Polars API参考,它按表达式处理的对象分类。在运行时,如果存在替代方案,PolarsInefficientMapWarning会指出精确方法,因此值得在警告可见的情况下运行一次转换脚本。 (Polars API reference)
结果
我们在两个模型上,以有技能和无技能两组运行全部33份转换(22个PDS-H查询与11个EDA案例)。每个案例一次转换、新会话,技能是两组唯一差异。代码集较小,每个案例只运行一次,以下计数是方向性信号,不是基准。两个ETL笔记本因没有自动验证工具,未计入统计。
| 模式 | Sonnet:无技能 | Sonnet:有技能 | Opus:无技能 | Opus:有技能 |
|---|---|---|---|---|
| 正确结果(共33个) | 33/33 | 33/33 | 31/33 | 32/33 |
| 适合.over()时先聚合再回连 | 5个案例 | 2个案例 | 3个案例 | 1个案例 |
| 流水线中途使用.item() | 3个案例 | 2个案例 | 1个案例 | 0个案例 |
| 存在原生表达式时回退到Python | 1个案例 | 0个案例 | 0个案例 | 0个案例 |
| select/group_by/agg/with_columns参数用列表包裹 | 21个案例 | 0个案例 | 0个案例 | 0个案例 |
| .agg()内用pl.col(“x”).sum()代替pl.sum(“x”) | 48次 | 19次 | 48次 | 40次 |
| 对提取标量使用Python侧round() | 2个案例 | 0个案例 | 0个案例 | 0个案例 |
脚注
-
PDS-H基准(2025年5月)测得:规模因子10时,Polars约比pandas快两个数量级;在更大规模下,pandas因内存不足失败,因此规模因子10是pandas仍能完成的规模。另见Polars与PySpark基准,对比单节点与分布式处理。 (PDS-H基准(2025年5月);Polars与PySpark基准)
-
wesm/pydata-book(MIT,4个案例);stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition(MIT,3个案例);guipsamora/pandas_exercises(BSD-3-Clause,2个案例)。 (wesm/pydata-book;stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition;guipsamora/pandas_exercises)











暂无评论内容