流畅但不地道:智能体将pandas转换为Polars

对越来越多开发者而言,他们第一次看到的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()则用组级值丰富原数据框的每一行。全部映射策略见窗口函数指南。 (窗口函数指南)

Side-by-side comparison: .over() preserves the frame shape and broadcasts the group mean to every row, while group_by().agg() reduces the frame to one row per group

有趣的是,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个案例

脚注

  1. PDS-H基准(2025年5月)测得:规模因子10时,Polars约比pandas快两个数量级;在更大规模下,pandas因内存不足失败,因此规模因子10是pandas仍能完成的规模。另见Polars与PySpark基准,对比单节点与分布式处理。 (PDS-H基准(2025年5月);Polars与PySpark基准)

  2. 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)

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容