pandas 3.0 是一次里程碑式的更新,引入了多项旨在提升代码可读性、性能和数据一致性的重要特性。本文将深入探讨其中的三个核心变更:pd.col 表达式、默认启用的写时复制(Copy-on-Write)以及由 PyArrow 支持的专用字符串数据类型。了解这些变化,尤其是潜在的破坏性变更,对于顺利升级至关重要。
💻 获取代码:本教程的完整源代码和 Jupyter notebook 可在 GitHub https://github.com/khuyentran1401/codecut-blog/blob/main/pandas_3_expressions.ipynb 上获取。克隆仓库即可跟着学习!
环境准备
pandas 3.0 目前处于预发布阶段。要跟随示例操作,请安装发布候选版本:
pip install --upgrade --pre pandas==3.0.0rc1
使用 pd.col 实现更简洁的列操作
传统方法的痛点
在 pandas 中修改或创建新列,你可能会用到以下方法。
方括号表示法是最常见的方式,但它会就地修改原始 DataFrame,导致无法在不制作副本的情况下比较前后状态。
import pandas as pd
df_original = pd.DataFrame({"temp_c": [0, 20, 30]})
df_original['temp_f'] = df_original['temp_c'] * 9/5 + 32
# df_original 现在已被修改 - 无法看到原始状态
df_original
此外,方括号赋值不返回值,因此无法与其他方法进行链式调用(Method Chaining)。方法链允许你将 df.assign(...).query(...).sort_values(...) 写在一个表达式中,而非多个独立语句,这通常更具可读性。
使用 assign 方法可以解决链式调用的问题,因为它会返回一个新的 DataFrame。但它通常依赖于 lambda 函数。
df = pd.DataFrame({"temp_c": [0, 20, 30, 100]})
df = (
df.assign(temp_f=lambda x: x['temp_c'] * 9/5 + 32)
.query('temp_f > 50')
)
df
然而,lambda 函数通过引用捕获变量,在某些场景下会导致意料之外的作用域错误。
df = pd.DataFrame({"x": [1, 2, 3]})
results = {}
for factor in [10, 20, 30]:
results[f'x_times_{factor}'] = lambda df: df['x'] * factor
df = df.assign(**results)
df
发生了什么? 我们期望 x_times_10 乘以 10,x_times_20 乘以 20,x_times_30 乘以 30。但结果显示,所有三列都乘以了 30。
原因:lambda 保存的是变量名 factor,而非其值。循环结束时,factor 的值为 30。当 assign() 执行这些 lambda 时,它们都读取当前的 factor 并得到 30。
pandas 3.0 的解决方案:pd.col
pandas 3.0 引入了 pd.col,让你无需 lambda 函数即可直接引用列。其语法借鉴了 PySpark 和 Polars。
以下是使用 pd.col 重写的温度转换示例:
df = pd.DataFrame({"temp_c": [0, 20, 30, 100]})
df = df.assign(temp_f=pd.col('temp_c') * 9/5 + 32)
df
与方括号表示法不同,pd.col 支持方法链;与 lambda 不同,它按值捕获变量,从而避免了上述作用域错误。使用 pd.col,之前的循环示例能正确工作:
df = pd.DataFrame({"x": [1, 2, 3]})
results = {}
for factor in [10, 20, 30]:
results[f'x_times_{factor}'] = pd.col('x') * factor
df = df.assign(**results)
df
使用表达式进行过滤
传统过滤需要在条件中重复 DataFrame 的名称:
df = pd.DataFrame({"temp_c": [-10, 0, 15, 25, 30]})
df = df.loc[df['temp_c'] >= 0] # df 出现两次
df
使用 pd.col,你可以更直接地引用列,使代码更简洁:
df = pd.DataFrame({"temp_c": [-10, 0, 15, 25, 30]})
df = df.loc[pd.col('temp_c') >= 0] # 更简洁
df
组合多个列
当操作涉及多个列时,lambda 的冗长更为明显:
df = pd.DataFrame({
"price": [100, 200, 150],
"quantity": [2, 3, 4]
})
df = df.assign(
total=lambda x: x["price"] * x["quantity"],
discounted=lambda x: x["price"] * x["quantity"] * 0.9
)
df
使用 pd.col,相同的逻辑更具可读性:
df = pd.DataFrame({
"price": [100, 200, 150],
"quantity": [2, 3, 4]
})
df = df.assign(
total=pd.col("price") * pd.col("quantity"),
discounted=pd.col("price") * pd.col("quantity") * 0.9
)
df
当前限制:与 Polars 和 PySpark 不同,pd.col 目前还不能直接在 groupby 聚合操作中使用(例如 df.groupby("category").agg(pd.col("value").mean()))。此功能可能会在未来的版本中提供。
写时复制(Copy-on-Write)成为默认行为
如果你使用过旧版 pandas,很可能遇到过令人困惑的 SettingWithCopyWarning。当 pandas 无法确定你是在修改一个数据视图(View)还是一个数据副本(Copy)时,就会触发此警告。
# 这种模式在 pandas < 3.0 中导致困惑
df2 = df[df["value"] > 10]
df2["status"] = "high" # SettingWithCopyWarning!
这行代码会修改原始的 df,还是只会修改 df2?答案取决于 df2 是视图还是副本,而 pandas 内部有时难以预测。这就是警告试图提示你的问题。
pandas 3.0 让答案变得简单明确:使用 df[...] 进行筛选将总是返回一个副本。修改 df2 将永远不会影响原始的 df。
这背后的机制称为写时复制(Copy-on-Write, CoW)。当你只是读取 df2 时,pandas 会与 df 共享底层内存以提升效率。只有当你尝试修改 df2 时,pandas 才会真正创建一个独立的副本。
现在,当你执行筛选和修改操作时,既不会有警告,也不会有不确定性:
df = pd.DataFrame({"value": [5, 15, 25], "status": ["low", "low", "low"]})
# pandas 3.0:直接工作,无警告
df2 = df[df["value"] > 10]
df2["status"] = "high" # 仅修改 df2,不修改 df
print("df2 (修改后):")
print(df2)
print("\ndf (保持原样):")
print(df)
输出显示,df 完全没有被更改,整个过程也没有产生任何警告。
破坏性变更:链式赋值不再有效
一种在 CoW 模式下将不再有效的模式是链式赋值。因为 df["foo"] 会返回一个副本,所以直接对其赋值只会修改这个临时副本,而原始数据保持不变。
# 这在 pandas 3.0 中不再修改 df:
df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 6, 8]})
df["foo"][df["bar"] > 5] = 100
df
注意 foo 列的值仍然是 [1, 2, 3]。因为赋值的目标是一个立即被丢弃的副本。
正确的做法是使用 .loc(或 .iloc、.at、.iat)来明确指定修改位置:
df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 6, 8]})
df.loc[df["bar"] > 5, "foo"] = 100 # 正确方式
df
专用的字符串数据类型
在 pandas 2.x 中,字符串通常被存储为 object 类型。这种类型既低效又含义模糊,你无法仅凭数据类型判断一列是否全是字符串。
pd.options.future.infer_string = False # 模拟 pandas 2.x 行为
text = pd.Series(["hello", "world"])
messy = pd.Series(["hello", 42, {"key": "value"}])
print(f"text dtype: {text.dtype}")
print(f"messy dtype: {messy.dtype}")
输出:
text dtype: object
messy dtype: object
pandas 3.0 引入了专用的 str 数据类型(dtype),它仅用于存储字符串,使得类型信息一目了然。
pd.options.future.infer_string = True # pandas 3.0 行为
ser = pd.Series(["a", "b", "c"])
print(f"dtype: {ser.dtype}")
输出:
dtype: str
性能提升
这个新的字符串类型底层由 PyArrow 支持(如果已安装),带来了显著的性能改进:
- 操作更快:字符串操作速度可提升 5-10 倍。这是因为 PyArrow 在连续的内存块中处理数据,而不是操作一个个独立的 Python 对象。
- 内存占用更少:内存使用量可减少高达 50%,因为字符串以紧凑的二进制格式存储,而非携带大量开销的 Python 对象。
Arrow 生态系统互操作性
使用 PyArrow 后端意味着 DataFrame 可以零拷贝地传递给其他基于 Arrow 生态系统的工具(如 Polars 和 DuckDB),无需昂贵的数据转换。
import polars as pl
pandas_df = pd.DataFrame({"name": ["alice", "bob", "charlie"]})
polars_df = pl.from_pandas(pandas_df) # 零拷贝 - 数据已经是 Arrow 格式
polars_df
总结与升级准备
pandas 3.0 的核心改进旨在让你的数据分析工作流更高效、更可靠:
- 更简洁的代码:使用
pd.col 表达式替代繁琐的 lambda 函数,编写更清晰、更易维护的列操作逻辑。
- 更少的心智负担:默认启用的写时复制(CoW)彻底消除了
SettingWithCopyWarning 的困扰,数据修改行为变得完全可预测。
- 更强的性能与互操作性:新的 PyArrow 支持
str 类型不仅带来数倍的性能提升,还打通了与 Polars、DuckDB 等现代数据工具的高效数据交换通道。
你可以在升级到 pandas 3.0 之前,于 pandas 2.3 版本中预先测试和适应这些功能,只需启用相应的 Future 选项:
import pandas as pd
# 启用 PyArrow 支持的字符串
pd.options.future.infer_string = True
# 启用写时复制行为
pd.options.mode.copy_on_write = True
启用后,运行现有代码并修复所有出现的弃用警告,这样你就能为平稳过渡到 pandas 3.0 做好充分准备。对于这些新特性的深入应用和可能遇到的问题,欢迎在云栈社区与其他开发者交流探讨。