4.3 筛选、排序与赋值
安全选择为我们提供了坐标契约。现在,可以用三个明确阶段构建分析转换:创建条件,选择并排列记录,然后创建或更新字段。让这些阶段保持可见,会使管道更容易审查与测试。
import pandas as pd
deliveries = pd.DataFrame({
"order_id": ["A104", "A105", "A106", "A107", "A108", "A109"],
"zone": ["North", "South", "West", "South", "North", "Central"],
"minutes": pd.Series([31, 52, 44, 38, 47, None], dtype="Int64"),
"fee": [8.5, 9.0, 7.5, 8.0, 10.0, 6.5],
"cancelled": pd.Series([False, False, False, True, False, False], dtype="boolean"),
})用命名条件组合筛选器
假设运营团队需要查找未取消、位于 North 或 South,并且至少用了 40 分钟的订单。先命名各项条件,再组合它们:
slow = deliveries["minutes"].ge(40)
target_zone = deliveries["zone"].isin(["North", "South"])
active = ~deliveries["cancelled"].fillna(False)
mask = slow & target_zone & active
alerts = deliveries.loc[mask].ge(40) 与 >= 40 等价,但在方法链中有时更自然。.isin(...) 直接表达集合成员关系,无需写一长串相等比较。把缺失的取消标记填成 False 属于策略选择:这里表示“把未知当作仍然有效”。更谨慎的运营策略可能拒绝未知标记,或把它当作已取消。应在代码与测试中命名这个策略。
命名条件还能形成有用的调试轨迹:
audit = deliveries.assign(
slow=slow,
target_zone=target_zone,
active=active,
selected=mask,
)如果某笔订单意外被排除,审计列会指出是哪项条件失败。发布结果时可以删除这些诊断列,但应保留重现它们的能力。
DataFrame.query 可以紧凑表达一些筛选条件:
minimum = 40
alerts = deliveries.query(
"minutes >= @minimum and zone in ['North', 'South'] and not cancelled"
)只有当表达式对团队仍然清楚时才使用 query。当列名是动态的、缺失值策略复杂,或需要检查中间条件时,普通掩码更容易理解。
按声明的键与并列规则排序
sort_values 返回排序后的 DataFrame;除非请求 inplace=True,否则源表顺序不变:
queue = alerts.sort_values(
["minutes", "order_id"],
ascending=[False, True],
na_position="last",
)主键是分钟数降序,次键是订单编号升序,因此相同用时也有确定顺序。若没有声明并列规则,同键行的顺序可能依赖之前的操作。
当只有一个排序键时,kind="stable" 会保留同键行的输入顺序:
stable_by_minutes = deliveries.sort_values(
"minutes",
kind="stable",
na_position="last",
)当标签本身定义了目标顺序时,应使用 sort_index。不要只为让输出“看起来整齐”就排序:行顺序可能编码排名、时间先后或抽样序列。应说明顺序为何存在。
筛选实验运行一门小型政策语言,而不是提供几个阈值控件。你要编写命名条件、用布尔逻辑组合它们并声明排序键。逐行审计轨迹会同时显示每个中间掩码的真假与最终稳定队列,因此意外排除可以被诊断,而不只是被看到。
用向量化表达式创建派生列
整列赋值直接而清楚:
deliveries["cost_per_minute"] = (
deliveries["fee"] / deliveries["minutes"]
)pandas 会按索引把 Series 结果与 DataFrame 对齐。缺失分钟数会传播到派生列。零分钟记录会产生无穷值,而不是有意义的单位费用,因此信任计算结果之前要验证分母。
assign 会返回新的 DataFrame,很适合方法链:
analysis = (
deliveries
.assign(
is_late=lambda frame: frame["minutes"].gt(40),
cost_per_minute=lambda frame: frame["fee"].div(frame["minutes"]),
)
.sort_values("minutes", ascending=False, na_position="last")
)在 assign 中,可调用对象会收到到当前位置为止已经转换过的 DataFrame。后声明的列可以引用同一次调用中更早声明的列,但不要用过长的依赖链隐藏转换顺序。
普通列算术不要使用逐行 .apply(axis=1)。向量化 Series 操作更能表达意图,通常也避免重复调用 Python 函数。第 6 章会深入比较 map、apply 与向量化转换。
在一次索引操作中更新子集
若要更新迟到且未取消订单的状态,应在一次 .loc 操作中同时指定目标行和目标列:
late_active = (
deliveries["minutes"].gt(40)
& ~deliveries["cancelled"].fillna(False)
)
deliveries.loc[late_active, "status"] = "late"这条表达式清楚说明:修改哪个对象、哪些行符合条件、哪个列接收新值。
不要使用链式赋值(chained assignment):
# 错误:赋值目标是中间对象。
deliveries[deliveries["minutes"] > 40]["status"] = "late"在 pandas 3.x 中,写时复制(Copy-on-Write, CoW)默认启用。索引生成的对象在被修改时表现为独立对象,因此链式赋值无法更新原 DataFrame,并会引发 ChainedAssignmentError。旧版 pandas 往往发出 SettingWithCopyWarning,因为当时结果存在歧义。跨版本都适用的长期规则相同:使用一次 .loc 操作完成更新。
如果目标是创建独立工作表,就有意复制,再更新它:
working = deliveries.loc[deliveries["zone"] == "South"].copy()
working.loc[:, "priority"] = "review"这样,保留源表成为代码契约的一部分,而不是意外副作用。
赋值实验是一台有状态的 Copy-on-Write 调试器。你可以输入命令创建筛选对象或显式副本,用 .loc 修改它们,或者故意尝试链式赋值。彼此独立的对象表格与执行轨迹会揭示哪些单元格发生变化,以及原始 df 是否真的成为目标。
验证受影响范围
赋值和计算一样值得测试。保存预期标签,并比较修改前后结果:
target_ids = deliveries.index[late_active]
untouched_ids = deliveries.index[~late_active.fillna(False)]
before_untouched = deliveries.loc[untouched_ids, "status"].copy()
deliveries.loc[late_active, "status"] = "late"
assert deliveries.loc[target_ids, "status"].eq("late").all()
assert deliveries.loc[untouched_ids, "status"].equals(before_untouched)赋入 Series 时要格外小心:pandas 会按照索引标签将它与目标行对齐。标签正确时这很强大,但标签不匹配的 Series 可能填入意外缺失值。当两边标签来自不同来源时,应先检查或显式对齐。
到目前为止,我们都在 Python 内部创建 DataFrame。下一节会把文件当作不可信边界:解析是一组决策,成功导入不等于验证通过,而导出则需要往返契约。