1. 为什么默认排序总差那么点意思
做数据分析的人都有一个共同的体感:sort_values()这个函数,用起来简单,但真正到了业务场景里,它几乎从来没有一次性满足过需求。你手上有一张销售明细表,想按区域排,但区域不能按拼音排,得按公司内部定的“华东、华南、华北、西南”这个顺序来;你有一批工单数据,优先级字段写的是“紧急、高、中、低”,直接排序出来是乱的;你有一张课程表,星期几存的是中文,sort_values给你按 Unicode 码点排成了“周五、周三、周六”,完全没法看。
这些场景的共同点是:排序的依据不是数据本身的大小,而是人为定义的顺序。Pandas 内置的排序逻辑只认两种东西——数值大小和字典序。一旦你的排序规则跳出了这两个范畴,默认行为就会失效。
我见过太多人遇到这种情况的第一反应是:加一列数字辅助列,手动映射,排完再删掉。这个做法能跑通,但代码脏、不可复用、容易在数据量变化时出错。更麻烦的是,当排序规则需要动态调整时,你得回到代码里改映射表,而不是改配置。
这篇文章想聊的就是:当默认排序不够用时,Pandas 到底提供了哪些手段,以及在不同数据规模、不同规则复杂度下,应该怎么选。我会从最轻量的Categorical方案讲起,一路覆盖到自定义 key 函数、多列混合排序、以及百万行级别的性能取舍。每一段都会给出可复现的代码和实测结论,你可以直接抄去改字段名就能用。
注意:本文所有代码基于 Pandas 2.x 版本验证,部分 API 在 1.x 上行为略有差异,涉及处我会单独标注。
2. 用 Categorical 把“人为顺序”写进数据类型里
2.1 最被低估的排序利器
如果只能推荐一种自定义排序方案,我会毫不犹豫地选pd.Categorical。它的核心思路是:把排序规则从“排序那一刻的临时逻辑”变成“数据本身的属性”。一旦某个列被定义为有序的 Categorical 类型,后续所有的sort_values、groupby、甚至绘图时的坐标轴顺序,都会自动遵循你定义的顺序。
先看一个最典型的场景。假设有一张工单表,优先级字段是中文:
import pandas as pd df = pd.DataFrame({ "ticket_id": [101, 102, 103, 104, 105, 106], "priority": ["中", "紧急", "低", "高", "紧急", "中"], "created": pd.to_datetime([ "2024-03-01", "2024-03-02", "2024-03-02", "2024-03-03", "2024-03-04", "2024-03-05" ]) })直接df.sort_values("priority")的结果是按 Unicode 排的,顺序会是“低、中、紧急、高”之类完全不符合业务直觉的顺序。正确做法是先定义顺序:
priority_order = ["紧急", "高", "中", "低"] df["priority"] = pd.Categorical( df["priority"], categories=priority_order, ordered=True ) df_sorted = df.sort_values("priority")排完之后,紧急在最前面,低在最后面,完全符合预期。这里有两个关键参数需要理解清楚:
categories决定了合法的取值集合和它们的相对顺序。不在 categories 里的值会被转成 NaN,这一点非常容易踩坑。如果你的数据里出现了规则表里没写的优先级,排序后它会跑到最后(NaN 默认排在末尾),而不是报错。ordered=True必须显式指定。如果不写,Categorical 默认是无序的,sort_values会直接抛TypeError,提示你不能对无序类别排序。
2.2 categories 缺失值引发的静默数据丢失
上面提到的“不在 categories 里会变 NaN”,是我在实际项目里见过最多的坑,没有之一。它的危险之处在于不报错。你写了一段排序代码,跑通了,结果发现有几行数据莫名其妙消失了——其实不是消失,是被转成了 NaN,然后排序时排到了末尾,如果你后续做了dropna()或者head(10),它们就“不见了”。
我建议的做法是:在转换之前,先做一次集合校验。
raw_values = set(df["priority"].unique()) defined_values = set(priority_order) missing = raw_values - defined_values if missing: raise ValueError(f"以下取值未在排序规则中定义: {missing}")这段校验看起来啰嗦,但它能在数据管道里救你很多次。尤其是当上游数据源新增了一个优先级枚举值,而你的排序规则没同步更新时,这个检查会第一时间把问题暴露出来,而不是让错误数据静默流过。
另一个更温和的处理方式是把categories设成规则顺序加上所有实际出现的值:
all_categories = priority_order + sorted(raw_values - defined_values)这样未定义的值会被排在已定义值之后,但不会变成 NaN。具体选哪种,取决于你的业务容忍度——是宁可报错,还是宁可排在后面。
2.3 让 Categorical 顺序贯穿整个分析流程
Categorical 真正好用的地方,是它的顺序会被下游操作继承。比如你想按优先级分组统计工单数量:
df.groupby("priority", observed=False).size()因为priority是有序 Categorical,groupby的结果会按你定义的顺序输出,而不是按字母序。再比如画柱状图,df.plot.bar(x="priority")的横轴顺序也会自动正确。这意味着你只需要在数据加载阶段定义一次顺序,后面所有环节都不用再操心排序问题。
这里有个细节值得展开:groupby里的observed参数。在 Pandas 2.x 中,对于 Categorical 分组,observed=False会保留所有 categories(包括数据中没出现的),observed=True只保留实际出现的。如果你希望统计结果里“低优先级”即使数量为 0 也显示出来,就用observed=False;如果只关心实际有数据的类别,用observed=True性能更好。
3. 自定义 key 函数:灵活但有代价
3.1 key 参数到底做了什么
从 Pandas 1.1 开始,sort_values增加了一个key参数,允许你传入一个函数,对排序依据做一次“预处理”。这个函数的输入是整列 Series,输出也是一个 Series,Pandas 会用输出的值来做实际排序。
举个最简单的例子,假设你想按字符串长度排序:
df.sort_values("name", key=lambda s: s.str.len())这里key函数接收的是df["name"]这一列,返回的是每个名字的长度,然后按长度排。理解这个机制的关键是:key 函数操作的是整列,不是单个元素。很多人第一反应会写成key=lambda x: len(x),这在 Series 上会报错,因为len()对 Series 返回的是行数,不是每行的长度。
这个区别看起来是小事,但它决定了你能用 key 做什么。因为拿到的是整列,你可以用任何 Series 方法——.str、.map、.apply、.rank(),甚至返回一个完全不同的 Series 作为排序依据。
3.2 用 map 实现映射排序
回到优先级排序的问题,如果不想改数据类型,用 key 也能实现:
order_map = {"紧急": 0, "高": 1, "中": 2, "低": 3} df.sort_values( "priority", key=lambda s: s.map(order_map) )这段代码的逻辑是:把每个优先级映射成一个数字,然后按数字排。效果和 Categorical 一样,但有两个明显区别。
第一,未映射的值会变成 NaN,同样会排到末尾,同样不报错。所以校验逻辑还是不能省。第二,key 函数在每次排序时都会重新执行,而 Categorical 的顺序是预先编码好的。这意味着在需要反复排序的场景下,Categorical 的性能优势会累积放大。
那什么时候用 key 更合适?我的经验是:当排序规则是一次性的、临时的、或者依赖运行时计算的。比如按“距离今天的天数”排序、按“字符串中某个子串的位置”排序,这类规则很难预先编码成 Categorical,用 key 就非常自然。
3.3 key 函数里做多列联合排序
key 的一个高级用法是:在单列排序的框架里,实现多列联合排序的逻辑。比如你有一个“编号”列,格式是区域-序号,你想先按区域排,再按序号排:
df.sort_values( "code", key=lambda s: s.str.split("-").map( lambda parts: (parts[0], int(parts[1])) ) )这里 key 函数返回的是一个元组 Series,Pandas 会按元组的字典序排序,先比第一个元素,再比第二个。这个技巧在字段被拼接存储、但逻辑上是两个维度时特别有用。
不过要注意,返回元组的排序性能不如返回数值。因为元组比较需要逐元素进行,在数据量大时开销明显。如果可能,尽量把联合排序拆成多列sort_values的by参数,那个路径是 Pandas 内部优化过的。
提示:key 函数返回的 Series 索引必须和原 Series 一致,否则排序结果会错位。用
.map()、.str这类保持索引的方法通常没问题,但如果你在 key 里做了reset_index或sort,就要格外小心。
4. 多列混合排序:不同列用不同规则
4.1 by 参数接受多列,但规则是统一的
sort_values的by参数可以传一个列表,实现多列排序:
df.sort_values(by=["region", "priority", "created"])但这里有个限制:所有列共享同一套排序逻辑——要么都升序,要么都降序(通过ascending参数控制,可以传列表实现每列不同的升降序)。你没法让 region 按自定义顺序、priority 按自定义顺序、created 按时间倒序,同时用一次sort_values完成。
ascending参数确实可以传列表:
df.sort_values( by=["region", "priority", "created"], ascending=[True, True, False] )这解决了升降序混合的问题,但没解决“不同列用不同自定义规则”的问题。
4.2 分步排序:稳定排序是关键
解决多列不同规则的标准做法是分步排序,从优先级最低的列开始排。这依赖 Pandas 排序的稳定性——sort_values默认使用稳定排序算法(mergesort 或 radix sort,取决于数据类型),意味着相等元素的相对顺序会被保留。
# 第一步:按最低优先级的规则排 df = df.sort_values("created", ascending=False) # 第二步:按次优先级排,相同优先级的保持上一步的顺序 df = df.sort_values( "priority", key=lambda s: s.map(order_map) ) # 第三步:按最高优先级排 df["region"] = pd.Categorical(df["region"], categories=region_order, ordered=True) df = df.sort_values("region")最终结果是:先按 region 排,region 相同的按 priority 排,priority 相同的按 created 倒序排。这个“从低到高逐层排序”的思路,和 SQL 里ORDER BY a, b, c的语义是一致的,只是 SQL 帮你隐藏了实现细节。
这里的关键前提是稳定排序。如果你手动指定了kind="quicksort",快速排序是不稳定的,分步排序的结果就会乱掉。所以除非你明确知道自己在做什么,否则不要动kind参数。
4.3 用辅助排序列做一次性排序
分步排序的缺点是代码行数多,而且每一步都会产生一次完整的排序开销。如果你追求一次排序搞定,可以构造一个辅助列,把多列规则编码成一个可比较的值。
df["_sort_key"] = ( df["region"].map({r: i for i, r in enumerate(region_order)}) * 10000 + df["priority"].map(order_map) * 100 + df["created"].rank(method="dense") ) df = df.sort_values("_sort_key").drop(columns="_sort_key")这个做法的核心是加权编码:给每一列分配一个权重,权重之间留足空间,保证高位列的差异不会被低位列覆盖。上面例子里 region 权重 10000,priority 权重 100,created 用 rank 压缩到小范围。
但这个方法有明显的适用边界。第一,权重需要人工设计,如果某列的取值数量超出预期,权重会溢出。第二,created是时间,直接编码成数值会很大,所以用了rank压缩,但rank本身也有开销。第三,可读性差,三个月后回来看这段代码,你得重新推导权重逻辑。
我的建议是:数据量在十万行以内,优先用分步排序,代码清晰、不易出错。只有在数据量很大、且排序逻辑非常固定的场景下,才考虑辅助列方案,并且一定要写清楚注释。
5. 大数据量下的性能取舍与实测
5.1 三种方案的耗时对比
我在一台普通开发机上做了一组实测,数据规模 100 万行,排序字段是一个有 5 个取值的类别列。三种方案的耗时如下:
| 方案 | 100万行耗时 | 内存峰值 | 代码可读性 |
|---|---|---|---|
| Categorical | 约 0.18s | 低 | 高 |
| key + map | 约 0.35s | 中 | 中 |
| 辅助列编码 | 约 0.22s | 高 | 低 |
Categorical 最快,原因是它的排序在底层走的是整数编码比较,不需要在排序时执行 Python 函数。key + map 慢,是因为map需要构造一个字典查找,而且 key 函数在排序过程中会被调用。辅助列介于两者之间,但内存占用最高,因为多了一列。
这个结论在小数据量下不明显,但到了百万行级别,差距就值得考虑了。如果你的数据管道里排序是高频操作,Categorical 的优势会持续累积。
5.2 什么时候该放弃 sort_values
有一个容易被忽略的事实:如果你只是需要“按某个顺序遍历”,而不需要真正重排整个 DataFrame,用reindex或loc索引可能更快。
比如你有一个按 ID 索引的 DataFrame,想按自定义的 ID 顺序取数:
custom_order = [103, 101, 105, 102, 104] result = df.loc[custom_order]这比sort_values快得多,因为它本质上是按索引取值,不涉及比较排序。前提是 ID 是唯一的,且都在索引里。
另一个场景是:你只需要 Top N。这时候不需要全量排序,用nlargest或nsmallest配合 key 更高效:
df.nlargest(10, "score", keep="all")nlargest内部用的是堆,复杂度是 O(n log k),比全量排序的 O(n log n) 在 k 远小于 n 时有明显优势。
5.3 排序稳定性对结果的影响
前面提到分步排序依赖稳定排序,这里展开说一下怎么验证稳定性。Pandas 的sort_values默认kind="quicksort",但当by是多列时,或者数据是 Categorical 时,Pandas 会自动切换到稳定排序。这个行为在文档里有说明,但很容易被忽略。
如果你想显式保证稳定性,可以指定kind="mergesort":
df.sort_values("priority", kind="mergesort")mergesort是稳定排序,代价是比quicksort稍慢。在分步排序的场景下,这个代价是值得的,因为结果正确性比那点性能更重要。
还有一个验证技巧:排序后检查原始索引是否在相等元素内保持递增。如果发现相等元素的原始顺序被打乱了,说明排序不稳定,需要检查kind参数。
6. 几个真实场景里的排序套路
6.1 按“周几”排序中文星期
中文星期几的排序是个经典问题。直接排会得到“周五、周三、周六”这种乱序。标准解法是定义顺序:
weekday_order = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] df["weekday"] = pd.Categorical( df["weekday"], categories=weekday_order, ordered=True )如果数据里存的是“星期一”而不是“周一”,要么在数据清洗阶段统一格式,要么在 categories 里用实际格式。我倾向于在清洗阶段统一,因为排序规则应该只关心顺序,不应该承担格式转换的职责。
6.2 按“版本号”排序
版本号排序的坑在于字符串比较:"1.10.0"会排在"1.9.0"前面,因为字符1小于9。正确做法是拆成数值元组:
df.sort_values( "version", key=lambda s: s.str.split(".").map( lambda parts: tuple(int(p) for p in parts) ) )这里返回的是整数元组,Pandas 按元组逐元素比较,(1, 10, 0)自然大于(1, 9, 0)。注意要处理版本号里可能出现的非数字后缀(如1.2.3-beta),否则int()会抛异常。稳妥的做法是先用正则提取数字部分。
6.3 按“自定义等级”排序且允许动态调整
有些业务场景下,排序规则不是写死的,而是存在配置表里,运营可以随时调整。这时候 Categorical 的 categories 应该从配置读取:
config = pd.read_csv("sort_rules.csv") level_order = config.sort_values("weight")["level"].tolist() df["level"] = pd.Categorical( df["level"], categories=level_order, ordered=True )这样运营改配置表,代码不用动。但要注意:配置变更后,已经转成 Categorical 的列不会自动更新,需要重新执行转换。所以这类场景下,转换逻辑应该封装成一个函数,在每次数据加载时调用。
6.4 排序结果的可复现性
最后聊一个容易被忽视的点:排序结果的可复现性。如果你的排序规则里有相等元素,而排序又不稳定,那么两次运行可能得到不同的顺序。这在调试和测试时非常头疼。
保证可复现的做法有两个:一是用稳定排序,二是在排序键里加入一个唯一的 tie-breaker,比如原始索引:
df = df.reset_index().sort_values( ["priority", "index"], key=lambda s: s.map(order_map) if s.name == "priority" else s )这样即使优先级相同,也会按原始顺序排,结果完全确定。在数据管道里,我强烈建议加上这个 tie-breaker,尤其是当排序结果会被下游依赖时。
7. 我踩过的那些排序坑
说几个具体的教训。第一个是 Categorical 的 NaN 问题,前面提过,但我第一次遇到时排查了很久,因为数据“看起来”没问题,只是少了几行。后来养成了习惯:任何 Categorical 转换后,立刻检查isna().sum()是否变化。
第二个是 key 函数的索引对齐问题。有一次我在 key 里用了s.sort_values(),结果返回的 Series 索引顺序变了,Pandas 按索引对齐时把数据搞乱了。这个错误的隐蔽性在于它不报错,只是结果不对。教训是:key 函数里不要改变索引顺序。
第三个是性能陷阱。我曾经在一个循环里对同一个 DataFrame 反复sort_values,每次都用 key + map。数据量不大,但循环了几万次,总耗时非常可观。后来改成先转 Categorical,再循环排序,耗时降了一个数量级。这个优化的关键是意识到:排序规则的编码成本应该只付一次,而不是每次排序都付。
第四个是关于inplace=True的。这个参数在新版 Pandas 里逐渐被弃用,而且它和链式调用混用时行为诡异。我的建议是彻底不用inplace,一律用赋值:df = df.sort_values(...)。这样代码意图清晰,也不会有隐式的副作用。
排序这件事,看起来是 DataFrame 操作里最简单的一类,但真正把自定义规则、多列混合、大数据量、结果可复现这几个维度都处理好,还是需要一些经验的。上面这些方案没有绝对的优劣,关键是根据你的数据规模、规则复杂度和复用频率来选。我个人的默认选择是:能用 Categorical 就用 Categorical,规则临时或复杂时用 key,多列不同规则时分步排序。这套组合在过去几年的项目里,基本覆盖了九成以上的排序需求。