☰
Pandas DataFrame 排名全解析:method、空值与分组排名避坑
2026/10/27 6:03:04 网站建设 项目流程

第一次被排名坑到,是给一份销售明细做业绩榜。数据丢进 DataFrame,一列rank()下去,前十名里冒出三个并列第五,负责人拿着表格问为什么没有第六名。那时候才反应过来,Pandas 里的排名远不是“排序后打个序号”这么简单——它默认按平均值处理并列,另外还有 min、max、first、dense 四种策略,选错了,整套名次体系就是错的。

这份笔记把 DataFrame 排名这件事拆开讲:method五个选项的实际差异、ascending和axis的组合、空值到底算不算名次、百分位排名的适用边界,以及分组排名、多列加权排名这些业务里天天要用的写法。只写过df['x'].rank()的,看完能把剩下的参数都用明白;已经用了两三年的,第四节那些坑大概也值得扫一眼。代码基于 pandas 2.x,个别 API 在 1.x 上有差异我会单独标出来。

1. 先想清楚 rank 在算什么

1.1 一个必须先扭过来的认知:rank 不打乱行顺序

新手最常见的误解,是把rank()当成sort_values()的近亲。实际上这两个函数的目标完全相反:sort_values()改变行的物理顺序,返回一张重排后的表;rank()一行都不挪,返回的是与原索引严格对齐的“名次值”。换句话说,它在回答“这个值在整列里排第几”,而不是“把它排到第几行”。

这个区别决定了很多后续写法。比如你有一张按时间先后排列的订单表,想在旁边加一列“本单金额在全部订单中的名次”,直接用rank()就对了,原来按什么顺序展示还是什么顺序。要是先sort_values再reset_index手工编号,原顺序就丢了,还得再排回来,凭空多两步操作,还容易在索引上出错。

我一般把rank()的输出理解成“一张与原始数据一一对应的名次标签表”。它保留索引,长度和原数据一致,值域取决于method和pct的设定。正因为对齐的是索引而非位置,后面第四节会提到,索引重复时会出很隐蔽的问题。

注意:rank()不会去重、不会过滤、不会改变行数。任何需要“只保留前 N 名”的需求,都是 rank 之后再套一层布尔筛选,或者配合nlargest。

1.2 默认参数长什么样,先记住这五个

DataFrame.rank()的签名里,真正影响结果的就这么几个:axis=0、method='average'、numeric_only=False、na_option='keep'、ascending=True、pct=False。默认值组合起来的意思是:按列方向、小值排第一、并列取平均名次、空值原样保留、返回绝对名次而不是百分比。

这里最容易被忽略的是method='average'。它意味着当两个值相等时,它们会平分这两个名次。比如四个值[10, 20, 20, 30],两个 20 分别占第 2 和第 3 位,取平均就是 2.5,于是结果变成[1.0, 2.5, 2.5, 4.0]。整数名次突然变成小数,很多人第一次见到会愣一下,其实这是统计学里“秩”的标准处理方式——它保证了所有名次之和恒等于 n(n+1)/2,后续做斯皮尔曼相关系数之类的计算时,这个性质是必需的。

问题在于,业务方看不懂 2.5 名。所以真正落地前,先想清楚这张榜是给人看的还是给算法用的。给人看,就换method;给算法用,average反而是默认里最稳的选择。

1.3 五种 method 的差异,用一个例子说透

还是那组[10, 20, 20, 30],五种策略的结果摆在一起看:

method结果并列处理逻辑典型场景
average1, 2.5, 2.5, 4平分占据的名次统计检验、相关性计算
min1, 2, 2, 4都取并列区间的最小名次体育竞赛、成绩榜
max1, 3, 3, 4都取并列区间的最大名次淘汰筛选、末位判定
first1, 2, 3, 4按出现顺序依次分配名额分配、抽奖
dense1, 2, 2, 3并列共享名次,后续名次不跳号排行榜、等级划分

选择逻辑其实很清晰,不用背表格,问自己三个问题就行。

第一,并列之后的下一个名次该跳几格?跳满(1、2、2、4)说明你在意“有多少人排在我前面”,这是竞赛逻辑,选min。不跳(1、2、2、3)说明你在意“我处在第几档”,这是评级逻辑,选dense。

第二,名次需不需要唯一?如果名次要用来分配有限资源——比如前 10 名各拿一份奖品,第 10 名有两个人并列,奖品就不够分了——那必须选first,让每个人拿到互不相同的序号,哪怕这个序号带有“谁先出现谁靠前”的任意性。

第三,这张表会不会进入统计模型?会,就用average。min和max会破坏秩和的守恒性,虽然对大多数排序类指标影响不大,但既然没有成本,按默认来更省心。

max用得最少,但有一个场景非它不可:判断“是否落在末位若干名”。比如找出每个班组里排名最后 20% 的人做访谈,用max可以保证并列末位的人全部被圈进来,不会因为min把一部分并列者漏掉。

2. 参数逐个拧:方向、空值、百分位

2.1 ascending 与 axis:两个维度别搞混

ascending好理解,False就是大值排第一,做业绩榜、销量榜的默认动作。真正需要多想一步的是axis。

axis=0(默认)是列内比较:每一列各自独立排名,同一列里的行互相比较。axis=1是行内比较:每一行各自独立排名,同一行里的列互相比较。后者在业务里对应的是“横向对比”场景,比如一张学生成绩表,行是学生、列是各科,df[['语文','数学','英语']].rank(axis=1, ascending=False)算出来的就是“这个学生自己哪科最强、哪科最弱”。

这里有个前提:axis=1时,参与排名的列必须同质可比。把分数和人数放一行里排名,结果毫无意义。另外axis=1遇到非数值列会抛TypeError,新版可以传numeric_only=True让非数值列自动排除,但我更建议事先用列名显式切片,比依赖参数更可控。

还有一点值得提醒:axis=1时如果整行都是 NaN,结果整行还是 NaN;只要行内有一个有效值,其余 NaN 按na_option处理。这个行为在做问卷量表、多指标打分时经常被忽略,导致某些空行看起来“没有排名”,其实是数据本身整行为空,得回头查上游。

2.2 na_option:空值的三条路,选错就是事故

na_option只有三个取值,但每一个的语义差别很大。

'keep'是默认值,空值保持空值,不参与排名。此时有效值的名次是按“有效值个数”来算的,不是按总行数。假设一列 5 个值里有一个 NaN,剩下 4 个有效值参与排名,最好的是 1,最差的是 4,NaN 依然是 NaN。这一点很多人算百分位时会算错,pct=True的分母也是有效值个数,不是总行数。

'top'把空值当成最小的值,排在第一名位置。适合“缺失即最差”的语义,比如客户满意度调查里没填问卷的,往往要按最低分处理,避免沉默者反而占了便宜。

'bottom'把空值排到最后一名。适合“缺失即未参与”的语义,比如新入职员工还没分配业绩目标,排名垫底但不影响其他人的名次。

注意:na_option='top'或'bottom'会让空值也拿到一个具体名次,返回结果里不再有 NaN。如果后续要根据“名次是否存在”来判断数据完整性,这个信息就丢了,建议在排名前先单独存一列df['x'].isna()做标记。

另外,na_option只处理真正的缺失值(NaN、None、NaT),对空字符串、'N/A'、-1这类伪缺失无能为力。我在实际项目里踩过这个坑:上游导出的数据用-1表示未填写,排名时这些-1因为“数值最小”被排到了第一,整张榜彻底反了。所以排名之前务必先做一轮缺失值清洗,把伪缺失转成真 NaN。

2.3 pct=True:百分比排名的分母陷阱

pct=True返回的是名次除以有效值个数的结果,值域大致在 (0, 1]。四个值排完是 0.25、0.5、0.75、1.0,最小的那个不会得到 0。这一点跟很多人的直觉不符,但细想合理:第 1 名处在整个分布的 25% 位置上,说的是“有 25% 的样本在你这个位置及以下”。

它有很实用的场景。比如把用户消费金额换算成分位,做分层运营:前 20% 是高价值用户,中间 60% 是普通用户,后 20% 是唤醒对象。用pct=True加ascending=False,得到的值越小表示越靠前,直接和阈值比就行,不用关心总人数是多少。人数变化时阈值不用改,这点比直接用名次方便得多。

分母的坑再说一次:pct的分母是非空值个数,不是总行数。一列 1000 行里有 200 个空值,pct=True的分母就是 800。如果你的业务口径是“占总人数的比例”,得自己用rank(pct=True) * 有效值个数 / 总行数换算,或者干脆自己写rank() / len(df)。

numeric_only也顺带说一句。pandas 1.5 之后的DataFrame.rank()支持这个参数,True时只对数值列排名,非数值列直接从结果里剔除。老版本没有这个参数,遇到字符串列会直接TypeError: unsupported operand type。如果代码要在多版本环境跑,老老实实手动切列最保险。

3. 从原始数据到可交付排名表

3.1 单列排名:一条完整的处理链

先造一份典型数据,模拟某电商团队的区域销售明细,故意留几个空值和一个伪缺失。

import pandas as pd import numpy as np df = pd.DataFrame({ 'region': ['华东', '华北', '华南', '西南', '华东', '华北', '华南', '西南', '华中'], 'sales': [1280, 960, np.nan, 1450, 1280, -1, 720, 1450, 610], 'orders': [320, 240, 180, 400, 320, 90, 150, 400, 120], })

第一步永远是清洗,而不是急着排名。这里的-1是典型的伪缺失,直接参与排名会被当成最小值排到第一。我的习惯是先把业务上定义的非法值转成NaN,再决定空值的排名策略。

df['sales'] = df['sales'].replace(-1, np.nan)

第二步,明确排名口径。这次的需求是“按销售额从高到低,并列取最小名次,空值不参与排名”,对应的就是ascending=False, method='min', na_option='keep'。

df['sales_rank'] = df['sales'].rank(ascending=False, method='min')

跑出来华东两行都是 2.0,西南两行都是 1.0,华南那行是 NaN,华北有效的那行是 4.0。注意名次里有跳号——因为min策略下并列第二占掉了第 2 位,下一个是第 4 位,这符合竞赛逻辑。

第三步决定输出形态。如果这张表要进 Excel 给业务方看,2.0这种浮点会显得很别扭,转成可空整型最干净:

df['sales_rank'] = df['sales_rank'].astype('Int64')

用'Int64'(大写 I)而不是'int64',是因为后者遇到 NaN 会直接报错。可空整型是 pandas 1.0 之后引入的,能够同时容纳整数和pd.NA,在“有缺失的名次列”这个场景里几乎是唯一正确选择。

第四步,取 Top N。这里有个细节值得单独讲:如果第 10 名有并列,你是要 10 行还是 11 行?用名次筛选df[df['sales_rank'] <= 10]会得到 11 行,用nlargest(10, 'sales')会得到 10 行但可能把并列者砍掉一半。前者保公平,后者保数量,没有对错,取决于下游是发奖还是做样本分析。我一般会在代码里写清楚注释,避免下次改需求时又要重新推一遍逻辑。

3.2 多列排名的两种思路,别搞混了

df[['sales','orders']].rank()这行代码,很多人以为在算“综合排名”,实际上它只是对两列各自独立排名,返回的是一张同样形状的名次表。这是 DataFrame 排名里最容易误解的一点。

真正的综合排名有两条路。

第一条是加权打分,适合各指标量纲差异大、且权重有业务含义的场景。核心思路是先把各列转成可比的分数,再加权求和。直接拿原始值加权是错的,销售额动辄上千、订单数只有几百,权重会被量纲吞掉。

w = {'sales': 0.7, 'orders': 0.3} score = ( df[['sales', 'orders']] .rank(pct=True, ascending=True) # 先各自归一化到 (0,1] .mul(pd.Series(w)) .sum(axis=1) ) df['综合得分'] = score df['综合排名'] = score.rank(ascending=False, method='min')

这里用rank(pct=True)做归一化而不是 min-max 缩放,好处是对极端值不敏感。一个区域销售额顶天,min-max 会把其他区域全部压到接近 0,而百分位排名只关心“你排在什么位置”,分布更均匀。缺点是丢失了原始差距信息——第一名和第二名差 10 倍还是差 1%,在百分位里都是一样的。做榜单够用,做精细归因就不合适了。

第二条是字典序多列排序,适合“先比主指标,主指标相同再比次指标”的规则型排名。这种场景用rank无解,得靠sort_values配合手工编号:

df_sorted = df.sort_values(['sales', 'orders'], ascending=[False, False]) df_sorted['名次'] = range(1, len(df_sorted) + 1)

注意这里用的是位置编号而不是rank(),因为它天然保证唯一且连续,代价是不认并列。业务上如果规则明确写了“同分看订单数,再同看别的”,这种写法最直白也最不容易出错。

3.3 分组排名:groupby 加 rank 的正确姿势

“每个区域内部排个名”是最高频的需求,写法看着简单,细节不少。

df['区域内排名'] = ( df.groupby('region')['sales'] .rank(ascending=False, method='dense') )

关键点在于groupby(...)['col'].rank()返回的 Series 索引与原表一致,可以直接赋值成新列,不需要reset_index,也不需要对齐操作。这是rank作为转换类(transform)方法的天然优势,比groupby().apply(lambda x: ...)快得多也干净得多。

几个容易出问题的地方。

分组键含 NaN 时,默认dropna=True会把这些行整个排除在分组之外,返回的 Series 里对应的位置就是 NaN。如果你的数据里分组键可能缺失,要么显式写dropna=False,让缺失键自成一组,要么先补全分组键。这个行为在 pandas 2.x 里可以通过groupby(..., dropna=False)控制,老版本只能事后 fillna。

多列分组时传列表即可:df.groupby(['region','channel'])['sales'].rank(...),逻辑一样。

如果想要“组内百分比排名”,直接加pct=True,分母自动变成该组的有效值个数,不用自己算,这点很方便。

还有一个常见需求是“组内 Top N 筛选”,标准写法是利用method='first'保证名次唯一:

top3 = df[df.groupby('region')['sales'].rank(method='first', ascending=False) <= 3]

这里必须用first,因为用min的话,一组里如果有 5 个人并列第 1,<= 3会把 5 个人全部选进来,Top 3 变成 Top 5。这个坑我在做推荐位分配时踩过一次,配了 3 个坑位结果出来 7 个人,只能手工砍。

3.4 结果回写与展示:让名次列能直接用

排名算完,落地环节还有几个小处理能让表格好看很多。

名次列转可空整型前面说过了。如果确定没有空值,直接astype(int)更省内存。判断方式是df['rank_col'].notna().all(),为 True 就放心转。

展示时给前三名加个标记,用np.where一行搞定,比apply快几个数量级:

df['标记'] = np.where(df['sales_rank'] <= 3, 'TOP', '')

要导出 Excel 并高亮,style在 notebook 里好看,但导出得用to_excel配合条件格式,或者干脆在 Excel 里做。我个人的做法是代码只负责算准,格式交给下游,职责分离,改需求时不用动逻辑。

实操心得:名次列命名加个前缀或后缀,比如sales_rank、rank_sales,别直接叫rank。rank是 DataFrame 的方法名,虽然作为列名不冲突(df.rank拿到的是方法,df['rank']才是列),但混用的时候极易看错,debug 时浪费的时间远超命名多敲几个字符的成本。

4. 踩过的坑与排查实录

4.1 常见问题速查表

下面这些是我和同事真实遇到过的,按出现频率排序:

现象原因解决
名次列全是小数默认method='average',有并列换min/dense/first,或astype('Int64')
名次列出现 NaN原值为空且na_option='keep'清洗原值,或改na_option
名次从 4 开始,没有 2、3min策略下多个并列占了前面位置属正常行为,需要连续名次换dense
两个相等的值名次不同用了method='first'改min或average
TypeError: unsupported operand列里有字符串或混合类型先astype(float)或用numeric_only=True
groupby 后行数变少分组键含 NaN,默认被丢弃groupby(..., dropna=False)
多列 rank 结果不是综合排名每列独立排名,不是加权用rank(pct=True)加权求和
结果赋值后错位索引重复或非唯一先reset_index(drop=True)
pct=True比例总和不是 1分母是有效值个数按业务口径自行换算
大数据量下很慢逐列排序,未利用向量化见 5.1

4.2 索引重复这个坑,值得单独说

rank()返回的 Series 带原索引,赋值时是按索引对齐的。这句话听着平常,但索引有重复时,pandas 的对齐行为会让人怀疑人生。

假设原表有两行索引都是 0(比如从多个文件 concat 后没 reset),你把 rank 结果赋值回去,pandas 会尝试按索引匹配,一个索引对应多行,赋值就可能出现广播或者异常,具体表现取决于版本,但结果一定不是你想要的。而且这种错误不会报错,悄无声息地产生错误数据,比报错可怕得多。

我的固定习惯是:任何要 merge、concat、赋值新列的操作之前,先df = df.reset_index(drop=True)。多敲这十几个字符,能省掉几小时的排查。如果索引本身有业务含义(比如订单号),那就把它set_index或者保留成普通列,别让它同时承担对齐职责。

另外,rank()结果的索引顺序与输入一致,不会因为排名高低重排。想按名次展示,最后再sort_values('rank_col'),别指望rank()帮你排好。

4.3 空值策略和名次连续性的联动

有个组合坑值得提醒:na_option='top'配合method='min'。空值占掉了第 1 名,有效值从第 2 名开始,如果原本你想让最好的有效值拿第 1 名,这里就错位了。

同理,na_option='bottom'配合dense,空值不占名次档位,最后一个有效值拿到的名次会比min策略下更靠前。这两个参数是有耦合的,改一个必须回头验证另一个的效果。

我的做法是拿一份 10 行左右、刻意构造了空值和并列的小样本做单测,任何参数改动都先在这份样本上跑一遍,肉眼确认名次对不对,再上全量数据。全量数据跑完发现名次体系错了,回溯成本极高,尤其结果已经被下游系统消费之后。

4.4 rank 和 sort 混用导致的双重编号

见过不少代码长这样:

df = df.sort_values('sales', ascending=False) df['rank'] = df['sales'].rank(ascending=False)

逻辑上没错,但完全多余。rank()本身就按值大小计算名次,跟当前行顺序无关。先 sort 的唯一作用是让展示顺序好看,那就把 sort 放到最后,别夹在中间。夹在中间还有个隐患:sort_values之后索引顺序变了(索引值没变),如果后面还有依赖位置的操作,容易出错。

顺带说一个反向需求:如果就是想按“出现顺序”编号,比如日志流的行号,那用的不是rank,而是range(1, len(df)+1)或者np.arange。method='first'看着像,但它是在相同值之间按出现顺序分配,不同值之间仍然按大小排,语义完全不同。这两者混淆,会导致日志分析里行号乱跳。

4.5 性能上的几个实测结论

rank()的底层实现是排序,复杂度 O(n log n)。在百万行量级、十几列的表上做全表rank(),实测耗时在秒级,日常分析完全够用。真正拖慢速度的往往不是 rank 本身,而是这几件事。

第一,groupby().apply(lambda x: x.rank())。这是最典型的性能陷阱,apply会把每组数据打包成 DataFrame 再传进函数,开销巨大。换成groupby()['col'].rank(),同样的分组数,实测能快一到两个数量级,因为这是 Cython 实现的组内转换,不经过 Python 函数调用。

第二,对 object 列排名。pandas 得逐元素比较,还经常因为类型混杂报错。提前把列转成数值或category类型,速度快很多。

第三,axis=1在大宽表上。行内排名需要对每行单独排序,列数上千时开销明显。如果只是想找每行的最大值、最小值,用idxmax、max比rank(axis=1)高效得多,只有确实需要完整名次序列时才用 rank。

5. 排名思路的延展用法

5.1 分位分桶:把连续值变成等级

rank(pct=True)最实用的衍生用法是做分桶。业务里经常要“把用户按消费额分成五档”,用qcut可以,但qcut遇到大量重复值会报边界重复的错误。用排名做分桶则完全不受影响:

pct = df['sales'].rank(pct=True, ascending=True) df['tier'] = pd.cut(pct, bins=[0, 0.2, 0.4, 0.6, 0.8, 1.0], labels=['E','D','C','B','A'])

这样得到的是严格的等人数分档,每档人数误差不超过一个并列组。相比按固定金额阈值分档,它自适应数据分布,换一个季度数据量翻倍也不用调阈值。缺点是可解释性差——业务方问“A 档是什么标准”,你只能说“前 20%”,说不出具体金额。实际项目里我一般两套都算,金额阈值给业务看,分位分档给模型用。

同样的思路可以迁移到很多地方:把响应时长转成性能等级、把客单价转成消费力分层、把文章阅读量转成热度分级。只要是“相对位置比绝对值更有意义”的场景,排名分桶都比硬编码阈值稳。

5.2 组内去重取优:排名当筛选器用

有个高频需求是“每个客户只保留最近一笔订单”或者“每个商品取销量最高的那个规格”。标准解法是sort_values加drop_duplicates(keep='first'),但如果排序规则复杂,用排名更清晰:

df['rk'] = df.groupby('customer_id')['order_time'].rank(method='first', ascending=False) latest = df[df['rk'] == 1].drop(columns='rk')

好处是规则显式写在参数里,几个月后回来看一眼就懂。而且可以一次算出多个维度的排名,按需筛选,不用反复排序。method='first'在这里是必需的,保证每组恰好一个 1,否则并列时会保留多行。

再延伸一步,竞赛榜单的“同分同名次、后续跳号”,正好对应method='min';段位划分的“同分同段位、段位连续”,对应dense。这些映射关系理清之后,选参数就变成了一件不需要思考的事。

5.3 时间序列里的滚动排名

rank配合rolling可以算“当前值在过去 N 天里的位置”,比如监控指标异常检测:今天的错误率在过去 30 天里排第几,排进前三就告警。

df['err_rank'] = df['error_rate'].rolling(30).apply(lambda x: x.rank(pct=True).iloc[-1])

这里的apply是不可避免的,因为滚动窗口内排名没有向量化实现。窗口小(几十行)时性能可以接受,窗口上千就得考虑换思路,比如用分位数近似。我实测过 30 天窗口、十万行数据,耗时在两三秒,做离线日更任务完全够。

有意思的是,这种写法和统计里的经验分位数是同一个东西:当前值在历史样本中的百分位。用它做阈值比固定阈值稳健得多,尤其在指标有季节性波动的时候——固定阈值在旺季天天误报,滚动分位能自动适应。

5.4 一个提醒:名次属于描述,不属于特征

最后说个建模上的经验。名次列不要直接塞进模型当特征。原因是名次依赖于当前这批数据的构成,换一批样本,同一个值的名次就变了,模型学到的规律无法迁移。而且名次是全局信息,线上预测单个样本时根本算不出来——你没法为了给一个用户打分,把他和全量用户放一起排一遍。

要用的话,得换成不依赖样本构成的变换,比如对数、分箱后的固定阈值,或者用历史数据的固定分位点做映射。这个坑我在早期项目里踩过,离线 AUC 很漂亮,上线后特征全空,排查半天才发现是名次列没法实时计算。

排名这个操作,看着是 DataFrame 里最不起眼的一个方法,实际上参数之间的耦合挺多:method决定并列怎么处理,na_option决定空值算不算人,pct决定输出是名次还是分位,三个方向组合起来有几十种口径。真正落到业务上,先把“这个榜给谁看、并列怎么办、空值算不算”这三个问题问清楚,代码反而是最后五分钟的事。我现在写任何排名逻辑之前,都会先在纸上画五个数,把预期结果手算出来,再让 pandas 跑一遍对一下。这三十秒的核对,比事后返工一整天划算太多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询