☰
pandas median 中位数实战:均值陷阱、分组与时序统计
2026/10/26 14:05:56 网站建设 项目流程

1. 为什么中位数值得单独拎出来讲:均值陷阱与 median 的定位

1.1 一组薪资数据把均值和中位数的差距摊开了

我先抛一组我自己在练习里常用的模拟数据,五个人的月薪:8000、12000、15000、18000、90000。你算一下均值,是 28600;算一下中位数,是 12000。差了 2.4 倍。那个 90000 大概率是录错了、或者是老板本人混进了样本,但它对均值的影响是毁灭性的,对中位数的影响几乎为零——你把 90000 改成 9 亿,中位数还是 12000。

这就是pandas median 获取中位数这件事在实际工作中反复被提起的根本原因。Series.median()和DataFrame.median()这两个方法做的事情非常朴素:把一个序列排序后取中间那个值(偶数个则取中间两个的算术平均),它不关心极端值有多极端。而mean()是求和再除个数,每一个样本的权重雷打不动,一个异常点就能把整个结论带偏。

我在处理用户消费金额、页面停留时长、接口响应耗时这几类数据时,基本形成了条件反射:先看 median,再看 mean,两者的比值本身就是个信号。如果 mean 是 median 的三倍以上,说明分布右偏严重,长尾拖得厉害,这时候拿均值去做容量规划、做 SLA 承诺,八成要翻车。中位数告诉你的是"典型用户/典型请求是什么样",均值告诉你的是"总量除以次数",两者回答的问题根本不同。

1.2 pandas 里拿中位数的几条路径,别只记住一条

很多人对 pandas 取中位数的认知停留在df["col"].median()这一行,其实这个家族有好几个入口,适用场景差别不小,混用容易出问题:

调用方式返回结果典型场景需要留意的点
Series.median()一个标量单列快照返回 float,int 列也会变 float
DataFrame.median()一个 Series多列批量统计2.x 下非数值列会报错
df.groupby(k)[c].median()按组索引的 Series分组对比分组键含 NaN 时该组被丢弃
df[c].rolling(n).median()与原长度对齐的 Series平滑、去噪前 n-1 个位置默认是 NaN
df[c].expanding().median()累计中位数流式指标监控结果随样本增长而漂移
df[c].quantile(0.5)一个标量换插值方式时默认插值与 median 一致
df.resample("M")[c].median()时间索引 Series时间序列聚合需要 DatetimeIndex

注意:quantile(0.5)和median()在默认参数下结果完全相同,因为 pandas 的中位数内部就是按分位数线性插值算的。差别在于quantile允许你改interpolation,median不允许。想要"取靠下的那个中间值"而不是平均值,只能走quantile(0.5, interpolation="lower")。

1.3 什么时候不该用中位数

我不太喜欢把中位数说成万能解。它有两个明确的短板,得提前知道。

第一,中位数不可加。你把两个部门的中位数加起来再除以二,得到的不是合并后的中位数;但均值可以这么干(按人数加权)。做分层汇总报表时,如果上游只给了每组的中位数,你是没法还原整体的,必须重新取原始数据。

第二,中位数对样本量小的情况不稳。三五个样本的中位数,换个样本就跳一下,波动比均值大得多。所以做小样本的 A/B 实验对比,我通常还是看均值和置信区间,中位数只作为辅助参考。

另外还有个细节:中位数在离散型数据上可能"取不到真实值"。比如用户评分只有 1 到 5 的整数,偶数个样本的中位数可能是 3.5,这个 3.5 在业务上并不存在。展示的时候要么说明,要么用interpolation="lower"处理。

2. median 的参数面板:每个参数背后都是一次踩坑

2.1 axis 到底在哪个方向上算,别靠猜

DataFrame.median(axis=0)是默认值,含义是"沿着行往下压,对每一列求中位数",返回的 Series 索引是列名。axis=1反过来,对每一行横向求中位数,返回的 Series 索引是原来的行索引。

我见过太多人在这里绕晕。一个记忆方法:axis 指的是"被压缩掉的那个轴"。axis=0压掉行方向,剩下的就是列;axis=1压掉列方向,剩下的就是行。别去想"行还是列",想"哪个维度消失了"就不会错。

import pandas as pd import numpy as np df = pd.DataFrame({ "dept": ["甲", "甲", "乙", "乙", "丙"], "base": [8000, 12000, 15000, 18000, 90000], "bonus": [1000, np.nan, 2000, 2500, 3000], }) print(df[["base", "bonus"]].median(axis=0)) # base 15000.0 # bonus 2500.0 # dtype: float64 print(df[["base", "bonus"]].median(axis=1)) # 0 4500.0 # 1 12000.0 # 2 8500.0 # 3 10250.0 # 4 46500.0 # dtype: float64

注意axis=1那一次,第 1 行因为 bonus 是 NaN,实际上只用了 base 一个值,中位数就等于 12000 本身。单值的中位数就是它自己,这点很多人在做行级聚合时没意识到,结果误以为 NaN 行会被整行丢掉。

axis还接受"index"和"columns"这两个字符串,效果分别等价于 0 和 1。我建议新代码统一写字符串,可读性明显更好,尤其是几个月后回来看的时候。

2.2 skipna:NaN 的传染性开关

skipna=True是默认值,意思是计算前先把 NaN 剔掉,用剩下的有效值求中位数。skipna=False则完全不同:只要该列(或该行)里存在一个 NaN,结果直接就是 NaN。

这两个行为在实际项目里对应两种完全不同的业务语义:

  • 用skipna=True,你问的是"在已知的有效数据里,中位数是多少";
  • 用skipna=False,你问的是"这一列数据完整吗?不完整我就不给结论"。

我做数据质量巡检时经常反过来用skipna=False当探针。跑一遍df.median(axis=0, skipna=False),哪一列返回 NaN,哪一列就存在缺失值,比单独写df.isna().sum()省事,还能顺手拿到完整列的统计量。

print(df[["base", "bonus"]].median(skipna=False)) # base 15000.0 # bonus NaN

还有个容易忽略的点:全 NaN 列在skipna=True下也返回 NaN,不会报错,也不会返回 0。这意味着下游如果用fillna去补,必须显式处理这种"整列为空"的退化情况,否则填充值本身就是 NaN。

提示:float类型里的np.inf不会被skipna剔除,它会被当作一个超大值参与排序,直接顶到序列末端。如果数据源里混进了除零产生的 inf,中位数会被悄无声息地污染,先做一次df.replace([np.inf, -np.inf], np.nan)更稳妥。

2.3 numeric_only 与多索引场景的正确姿势

pandas 2.x 之后,DataFrame.median()碰到 object 或字符串列默认直接抛TypeError,不再像 1.x 那样默默忽略。这是好事,逼着你明确意图,但也确实让一批老代码升级时炸了。

df.median() # 2.x 下会报错,因为 dept 是字符串列 # TypeError: unsupported operand type(s) for /: 'str' and 'int' df.median(numeric_only=True) # 只统计数值列,正常返回

numeric_only=True的语义是"只对能算的列算,其余列从结果里消失"。我一般的做法是提前把列选出来,df.select_dtypes(include="number").median(),这样结果里包含哪些列一目了然,也顺手规避了 bool 列被当成 0/1 参与计算的问题。

多级列索引的场景,老版本有个level参数能指定在哪一层聚合,但 pandas 2.0 已经把它移除了,官方推荐改成groupby(level=...):

# 旧写法(已移除) # df.median(level=0) # 新写法 df.groupby(level=0).median(numeric_only=True)

如果你的环境还是 1.x,代码里又用到了level,升级前一定要先搜一遍全项目,别等到 CI 挂了才发现。

2.4 返回类型与 dtype 的那些小意外

Series.median()永远返回一个 Python float(或者 numpy 的 float64),哪怕原列是干净的 int64、哪怕结果是整数 12000.0,类型也是浮点。DataFrame.median()返回的 Series 同样是 float64 dtype。

这个设计有个直接影响:如果你要把中位数拿去做后续的整数运算或者写回整数字段,得自己转。而用astype(int)是截断而不是四舍五入,中位数 15000.5 会变成 15000,做报表对账时对不上,我吃过这个亏,后来一律写round(0).astype(int)或者用int(round(x))。

还有个细节,nullable类型(Int64、Float64)下的表现和 numpy 类型不完全一样,空结果可能是pd.NA而不是np.nan。如果后续要做is np.nan判断,这里会漏判,用pd.isna()才是安全的。

3. 从单列到分组:一份可以照着敲的实操流程

3.1 单列中位数与基线快照

任何一次分析我都会先建立基线。一堆统计量里,中位数和四分位距(IQR)是我最先看的两组数,因为它们对异常值不敏感,能快速勾勒出分布的主体区间。

base = df["base"] print("中位数:", base.median()) # 15000.0 print("均值:", base.mean()) # 28600.0 q1, q3 = base.quantile([0.25, 0.75]) print("IQR:", q3 - q1) # 6000.0 # 异常值边界(Tukey 方法) low, high = q1 - 1.5 * (q3 - q1), q3 + 1.5 * (q3 - q1) outliers = df[(base < low) | (base > high)]

这段代码里,low = 6000,high = 30000,90000 那条稳稳落在边界外。用中位数配合 IQR 做异常检测,比"均值 ± 3 倍标准差"稳得多,因为标准差本身就受异常值污染——极端值会把标准差撑大,然后极端值自己躲进 3 倍标准差里面,形成自我掩护。这个现象有个说法叫掩蔽效应,用中位数体系就没这个问题。

3.2 多列批量统计:一次性把中位数铺开

需要给一整张表出描述性统计时,我不太爱用describe(),它默认把 mean、std 和几个分位数一股脑塞进来,列多了以后根本看不清。手搓一张只有中位数的表反而更好用:

num = df.select_dtypes(include="number") summary = pd.DataFrame({ "median": num.median(), "mean": num.mean(), "p25": num.quantile(0.25), "p75": num.quantile(0.75), "count": num.count(), }) summary["mean_over_median"] = summary["mean"] / summary["median"]

最后的mean_over_median比值列是我自己最常用的偏态指示器。经验阈值是这样:低于 1.2 基本对称,1.2 到 2 之间轻度右偏,超过 3 就说明长尾相当重,做线性回归、做均值对比时都得小心,考虑先做对数变换。如果是左偏(比值小于 1),那通常是存在下限截断,比如"0 元订单"被大量填充。

3.3 groupby 分组中位数:这才是日常工作量的主力

真实需求里,直接取全局中位数的情况其实不多,绝大多数是"按渠道/按类目/按地区看中位数"。分组写法有两种,别搞混:

# 写法一:只对指定列算 g1 = df.groupby("dept")["base"].median() # 写法二:对全部数值列算 g2 = df.groupby("dept").median(numeric_only=True) # 多级分组 g3 = df.groupby(["dept", "level"])["base"].median().unstack("level")

这里有个必须知道的默认行为:groupby 默认dropna=True,分组键为 NaN 的行会被整行丢弃。如果你的分组键本身有缺失,加不加dropna=False会得到完全不同的总和,做数据核对时这个坑非常隐蔽。

df.groupby("dept", dropna=False)["base"].median()

还有一个组合技巧值得记住:groupby之后接transform("median"),拿到的是与原始行数一致的结果,可以直接用来做组内填充:

df["base_filled"] = df["base"].fillna( df.groupby("dept")["base"].transform("median") )

这比"先算一张组中位数表、再 merge 回去"省事得多,性能也更好。代价是如果某个组的 base 全是 NaN,transform也救不了,结果还是 NaN,需要再加一层全局中位数的兜底。

3.4 滚动、扩展与重采样:时间维度上的中位数

时序数据里,中位数最大的价值是抗噪平滑。均值滑动窗口会被单个尖刺带飞一整个窗口,中位数窗口不会。

s = pd.Series( [10, 12, 11, 13, 400, 12, 11, 10, 12, 11], index=pd.date_range("2024-01-01", periods=10, freq="D"), ) print(s.rolling(window=3).median().tolist()) # [nan, nan, 11.0, 12.0, 13.0, 13.0, 12.0, 11.0, 11.0, 11.0] print(s.rolling(window=3, min_periods=1).median().tolist()) # [10.0, 11.0, 11.0, 12.0, 13.0, 13.0, 12.0, 11.0, 11.0, 11.0]

第 5 个位置原本是 400 这个毛刺,滚动中位数给出的结果是 13.0,几乎没受影响;如果换成rolling(3).mean(),那个位置会变成 142,后面两个位置也连带被污染。

min_periods的选择是个业务问题而不是技术问题。默认min_periods=window,前window-1个位置全是 NaN,好处是每个值都基于完整的窗口、可比性强;坏处是开头一段没法用。我一般按"至少要有半数样本才出数"的原则设成window // 2,兼顾可用性和稳定性。

expanding().median()是累计版本,第 n 个位置用的是前 n 个样本的中位数。它的特点是随着样本增加会缓慢漂移,早期的值影响一直存在但权重被稀释。监控场景里我用它来观察"指标的长期中枢有没有发生结构性变化"。

重采样就直白多了,是对时间桶内的所有点求中位数:

s.resample("3D").median() # 2024-01-01 11.0 # 2024-01-04 12.5 # 2024-01-07 11.0 # 2024-01-10 11.0 # Freq: 3D, dtype: float64

注意resample的空桶默认是 NaN,不会补 0。如果你要画图,得决定这些空桶是dropna掉还是interpolate,两种处理出来的曲线形状差别很大。

3.5 加权中位数:pandas 没有现成方法,得自己写

median()不支持权重参数,遇到"每个样本代表不同规模"的场景(比如每个门店的中位数要按门店销量加权),只能手写。逻辑不复杂:按值排序、权重累加、找到累计权重首次越过总权重一半的位置。

def weighted_median(values, weights): v = np.asarray(values, dtype="float64") w = np.asarray(weights, dtype="float64") mask = ~(np.isnan(v) | np.isnan(w)) v, w = v[mask], w[mask] if v.size == 0: return np.nan order = np.argsort(v) v, w = v[order], w[order] cum = np.cumsum(w) half = cum[-1] / 2.0 idx = np.searchsorted(cum, half, side="left") return float(v[idx])

这段函数有两个细节值得说。一是side="left",它保证累计权重恰好等于一半时,取的是靠前的那个值而不是下一个,这和"偶数个样本取中间两个的平均"是有区别的,业务上更接近"下中位数"。二是np.argsort默认用快排,不稳定,值相同但权重不同的样本顺序不确定;对中位数影响不大,但如果要严格可复现,加个kind="stable"并附带一个次序键更稳。

我还经常用中位数绝对偏差(MAD)来衡量离散程度,公式是median(|x - median(x)|),pandas 里一行就够:

mad = (s - s.median()).abs().median()

MAD 有个经验换算:乘以 1.4826 就近似于正态分布下的标准差。做稳健的 z-score 时我用的是0.6745 * (x - median) / MAD,超过 3.5 视为异常点,这个判据在数据量不大、异常点又比较脏的场景下比标准差版本可靠得多。

4. 报错、返回 NaN、算得慢:问题排查实录

4.1 常见现象速查表

我把这些年被问到最多的、自己踩过的问题整理成一张表,遇到对不上的情况先扫一眼:

现象最可能的原因处理方式
TypeError: unsupported operand表里有字符串/object 列加numeric_only=True或select_dtypes
结果全是 NaN整列缺失,或skipna=False先isna().sum()看缺失比例
结果比预期大很多混入了 inf 或极端脏值replace([np.inf,-np.inf], np.nan)
分组结果少了几组分组键有 NaN 被丢弃groupby(..., dropna=False)
滚动结果前面全是 NaNmin_periods默认等于窗口显式设min_periods
结果变成 float 但不是想要的int 列中位数天然是小数round(0).astype(int)
升级后level参数报错2.0 移除该参数换groupby(level=...)
median比mean慢十倍需要 partition/排序数据量小时可接受,大表先聚合

4.2 返回 NaN 的几种情况,逐个对号入座

median()返回 NaN 不是错误,是合法的退化结果,但原因可能完全不同,处理方式也不一样。

第一种,整列全是 NaN。这时候skipna=True也救不回来,因为有效样本数为 0。

第二种,空 DataFrame 或空 Series。pd.Series(dtype="float64").median()返回nan,不抛异常。很多人在数据过滤后拿空表去算统计,程序不报错,结果悄悄变成 NaN,一路传到报表里,再想做清洗错误处理已经晚了。

第三种,skipna=False且存在缺失。这个前面说过,是有意的行为。

第四种,分组后某组基础列为空。groupby().transform("median")遇到全空组会返回 NaN。

我一般会在统计之后加一道断言式的自检:

m = df["base"].median() if pd.isna(m): raise ValueError("base 列中位数无效,检查是否整列为空或全被过滤")

用一个显式的失败替代一个静默的 NaN,能省掉大量下游排查时间。

4.3 性能:median 比 mean 贵在哪,什么时候要绕开

mean是一次线性扫描加累加,O(n) 且常数极小。median需要numpy.partition做快速选择,平均 O(n) 但常数大得多,最坏情况退化到 O(n log n)。同样一千万行,我实测mean大约 4 毫秒级别,median要到 20 毫秒上下,五倍左右的差距,具体数字随机器和数据类型浮动,但量级关系是稳定的。

什么时候这个差距会变成问题?两种情况。

一是高频调用。比如在流式处理里对每个批次算一次全量中位数,单次看不出来,累计起来很可观。这时可以改成维护一个小顶堆加一个大顶堆的双堆结构,插入 O(log n)、查询 O(1),适合增量场景。

二是多列全表统计。DataFrame.median()会按列循环,列数上千时开销线性叠加。如果能确定最终只需要几列,提前df[cols].median()比df.median()再切列要快不少,因为前者根本不去碰其他列。

还有个隐蔽的性能坑:object dtype 的数值列。从 CSV 读进来的时候如果混了空格或者千分位逗号,pandas 会把整列判成 object,这时候每一步数值计算都要走 Python 层面的类型转换,慢得离谱。我习惯在读入后立刻跑一次df.dtypes检查,把该转的全转干净:

df["amount"] = pd.to_numeric( df["amount"].astype(str).str.replace(",", "", regex=False), errors="coerce", )

errors="coerce"会把转不动的值变成 NaN,比直接astype(float)抛异常更宽容,适合处理脏数据。但代价是转换失败的数量你得单独统计一次,否则那些被默默变成 NaN 的行会在后面"人间蒸发"。

4.4 和 numpy、SQL 里的中位数对齐

同一个中位数,三种实现经常给出不同结果,跨系统核对时要特别小心。

np.median不支持 NaN,序列里有一个 NaN 整体就是 NaN。所以千万不要拿np.median(df["col"])去和df["col"].median()比,前者报 nan 或者给出完全不同的值,不是 bug,是语义不同。要对比就用np.nanmedian,它和 pandas 默认行为一致。

SQL 侧的标准做法是percentile_cont(0.5) WITHIN GROUP (ORDER BY x),这是连续分位数,结果与 pandas 默认的线性插值一致;而percentile_disc是离散版本,取的是实际存在的样本值。如果你用 pandas 算出来是 15000.5,SQL 里用disc版算出来是 15000,别急着怀疑哪边错了,先确认插值方式。

导数仓里最常见的分歧来自NULL 的处理:SQL 的percentile_cont会忽略 NULL,相当于 pandas 的skipna=True;但如果先用COALESCE把 NULL 补成 0 再算,中位数就彻底变味了。我在做离线指标和数仓对账时,会先把两边的"有效样本数"单独拉出来比一遍,样本数对上再看中位数,能省掉一半的来回扯皮。

5. 我在实际用 median 时踩出来的几条经验

5.1 中位数填充不是万能膏药

用中位数补缺失值是最常见的入门用法,df["col"].fillna(df["col"].median())一行就完事。它确实有两个优点:不会改变中位数本身,而且比均值填充更抗异常值。但我现在用得非常克制,原因有三个。

第一,填充会系统性压缩方差。所有缺失值都被塞到同一个点,分布中间会凸起一个尖峰,下游做相关性分析、做回归时系数会失真。缺失比例超过 5% 时我基本不考虑单一值填充了。

第二,分组填充比全局填充合理得多。同样的"月薪缺失",用全局中位数补和用同部门中位数补,业务含义完全不同。前面给过的groupby().transform("median")写法就是这个思路,多花一行代码,结果的解释性强很多。

第三,补完之后一定要留个标记列,记录哪些位置是被填充的。这样后续做敏感性分析时能随时把填充行挑出来单独看,也方便回答"这个结论有多少依赖填充值"这类质询。

df["base_is_filled"] = df["base"].isna() df["base"] = df["base"].fillna(df.groupby("dept")["base"].transform("median"))

5.2 一个可复用的小封装,顺手把边界情况都盖住

最后给一段我日常直接复用的函数。它把前面提到的几个坑——非数值列、全 NaN、无穷值、类型转换——都收在了里面,返回结果里同时带上有效样本数,方便判断这个中位数可不可信。

def safe_median(df, cols=None, by=None): num = df.select_dtypes(include="number") if cols is None else df[cols] num = num.replace([np.inf, -np.inf], np.nan) if by is None: out = pd.DataFrame({ "median": num.median(), "count": num.count(), }) else: out = num.groupby(df[by], dropna=False).agg( median="median", count="count" ) out["reliable"] = out["count"] >= 30 return out

count那一列是我特意加的。中位数在样本数少于 30 的时候波动很大,用它做分组排序、做阈值判断都不够稳。加个reliable标记,比事后靠记忆去回想"这组到底几个人"要靠谱得多。至于 30 这个门槛,是从中心极限定理的经验值里借来的,不是硬性规定,业务上样本天然稀少的场景可以下调到 10,但心里要清楚这时候的中位数只是个粗略方位。

还有一个我自己的习惯:凡是涉及中位数的关键报表,我都会额外跑一次quantile(0.45)和quantile(0.55)。如果这三个数差距很大,说明中位数附近的数据很稀疏,分布在这一段是"空"的,那这个中位数的代表性就要打个问号。这一步在分布呈双峰时尤其有用——双峰数据的均值往往落在两个峰之间的低谷,看起来"很正常",实际上完全不具代表性,中位数有时也逃不掉这个问题。

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

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

立即咨询