☰
Pandas实战:apply/map、描述统计与异常值处理全攻略
2026/10/17 4:55:15 网站建设 项目流程

做数据分析的人,大概率都经历过这样一幕:数据拿到手,先打开 Pandas 跑几个describe(),然后开始清洗、变换、找变量关系,最后还要处理那些“一眼看着就不对劲”的异常值。这套流程里,apply()、map()、描述统计、协方差相关性,加上异常值处理,基本就是日常最高频的“组合拳”。这篇文章不搞教科书式罗列,就按实际项目里最常用的套路来拆一遍,代码可以直接拿去用,踩过的坑也一并交代清楚。

1. 为什么这几招值得放到一起练

单独看apply()或者map(),每个函数都不难,但放到一个真实的数据处理流程里,它们是协同工作的:先用map()做字典配对、改编码,再用apply()跑跨行跨列的逻辑,然后用描述统计摸清整体分布,用协方差和相关性找到变量间的关联线索,最后靠异常值处理保证后续建模或汇报的数据质量。

这套链路本身就是一次标准的数据探索性分析(EDA)。很多人学 Pandas 时是“点状学习”,今天学groupby,明天学merge,但真上了项目就发现,问题从来不按函数划分。比如说你拿到一张订单表,里面有商品分类、单价、销量、区域、是否有促销。这时候你最关心的问题无非就是:销量数据有没有录入错误?哪个区域和销量关系更强?促销带来的销量波动是真的还是异常点的幻觉?——这几个问题恰好就是map()做编码、apply()做计算、describe()看分布、corr()找关系、异常值处理做防抖的典型应用场景。

把这几招串起来还有个好处:省时间。数据清洗和特征工程里最容易翻车的,不是不会用某个 API,而是不知道下一步该看什么。有了这套固定动作,你拿到任何结构化数据都能在几分钟内建立起基本认知,先知道数据“长什么样”,再决定下一步怎么建模、怎么分析。

1.1 从数据清洗到探索性分析的完整链路

实际项目里的数据很少是干净的。你可能会遇到:性别列存的是 “M / F / 男 / 女” 四种写法;价格列有负值;年龄列出现 200 这种明显不可能的数字。这种时候map()负责把离散文本统一成规范标签,apply()负责对每一行做组合逻辑判断(比如“销量为负且金额大于某阈值”标记为异常),描述统计负责把数值列的分布形态、缺失情况和离群程度一次性暴露出来,协方差和相关矩阵则帮你快速筛选哪些字段值得进一步分析。

所以我的习惯是:拿到数据先跑一个df.info()加df.describe(),然后立刻检查有没有需要map()的文本列,再看有没有需要自定义函数处理的逻辑列,之后才是相关性分析和异常值处理。这套流程走完,数据能不能用、该怎么用,心里基本就有数了。

1.2 先理清 API 家族:apply、map、applymap 的定位差异

Pandas 里和“函数应用”相关的 API 有好几个,最容易混的是map()、apply()和applymap()。我当时学的时候也被绕了一次,后来总结成一句话:

  • Series.map():只作用于 Series,核心场景是字典映射、值替换、或者对一列做函数变换。
  • DataFrame.apply():作用于整个 DataFrame,默认按列(axis=0)执行函数,也可以按行(axis=1),适合需要跨多列计算的自定义逻辑。
  • DataFrame.applymap():作用于 DataFrame 的每一个元素,做逐元素转换,典型场景是把整个表变成字符串、或者对所有数值统一格式化。

这三者的选择标准很简单:改单列用map,改一行或一列的组合逻辑用apply,全表逐格操作用applymap。下面两个小节直接用例子把map()和apply()的实操拆开讲。

2. apply() 与 map() 的实战拆解

2.1 Series.map():一对一映射与字典配对

map()最舒服的用法就是字典配对。假设你的数据里有“是否促销”列,值为"Y"和"N",建模前通常要变成 1 和 0:

import pandas as pd df = pd.DataFrame({ "商品": ["A", "B", "C", "D"], "是否促销": ["Y", "N", "Y", "N"] }) df["促销编码"] = df["是否促销"].map({"Y": 1, "N": 0}) print(df)

输出:

商品 是否促销 促销编码 0 A Y 1 1 B N 0 2 C Y 1 3 D N 0

这里最关键的一点是:字典里没出现的值会变成NaN。如果说数据里出现了"y"(小写),或者"yes",映射后就会静默变成缺失值,这一点极其坑人。我做过一个项目,前端传过来的性别字段里有 “male” 和 “男” 两种写法,当时只写了{"男": 1, "女": 0},结果所有英文“male”的行全变成了 NaN,后续统计直接偏掉。后来养成了习惯:先df["列"].value_counts()看清楚所有取值,再写映射字典。

map()也可以直接传函数:

df["销量_加1"] = df["销量"].map(lambda x: x + 1)

但这里有一个细节:map()传递函数时是按元素处理的,如果你需要对整列做向量化计算,直接用df["销量"] + 1就行,没必要包一层map,那样反而慢。

2.2 DataFrame.apply():跨行列的函数应用

apply()才是真正的“全能选手”。默认情况下apply()按列遍历,也就是说传入函数的参数是整列 Series。可以用内置函数,比如:

# 对数值列都求最大值 df[["单价", "销量"]].apply(max)

但真正的价值在于自定义复杂逻辑。比如一个零售场景,你想算“是否值得重点跟进”:单价大于 50 且销量大于 100 的行标记为“高价值”。用循环当然能写,但一行apply就搞定了:

df["重点标记"] = df.apply( lambda row: "高价值" if row["单价"] > 50 and row["销量"] > 100 else "普通", axis=1 )

这里axis=1表示按行遍历,row是每一行的 Series,可以通过列名访问取值。这是apply()最核心的用法,请务必记住axis=1是按行这一条,因为很多人第一次用都会忘。

apply()还可以在分组之后配合使用。比如你想对每个品类求销量最高的商品名称:

df.groupby("品类").apply( lambda sub: sub.loc[sub["销量"].idxmax(), "商品"] )

这种“分组后对每个子表做自定义操作”的能力是apply()独有的,agg()和transform()做不到这么灵活,因为后者只能对每一列返回标量或同形状结果,不能返回子表里的一行。

2.3 性能层面的一个提醒:别拿 apply 当万能药

apply()虽然灵活,但它本质上是一个 Python 层级的循环,比 Pandas 内建的向量化操作慢很多。如果你是对一列做简单变换,能写成df["列"] * 2、df["列"].str.upper()这种向量化写法,就别套apply。

我实测过一个十万行的表,对某列做“价格调整”的简单乘法,向量化写法耗时约 2 毫秒,apply(lambda x: x * 1.1)大约要 40 毫秒,差 20 倍。量小无所谓,但上了几十万上百万行,这差距就非常酸爽了。

真需要复杂逻辑且速度敏感,可以考虑几个加速方案:

  • 用df.itertuples()或列表推导式替代apply();
  • 用numpy.where()实现条件判断;
  • 用numba加速纯 Python 函数。

我的个人原则是:“简单变换走向量化,单列逻辑走 map,跨多列复杂逻辑走 apply,实在性能不够再想别的招。”

3. 描述统计:先搞清楚数长什么样

3.1 describe() 输出解读:最小值、分位数与标准差

describe()是 Pandas 里最常用的描述统计入口,一行代码能输出数值列的计数、均值、标准差、最小值、25% 分位数、中位数(50%)、75% 分位数和最大值。

df.describe()

输出示例:

指标单价销量
count100.0100.0
mean42.50120.30
std30.1235.40
min1.0010.00
25%22.0095.00
50%38.50118.00
75%58.00140.00
max350.00300.00

看这张表,注意几个点。第一,count 如果小于总行数,说明有缺失值,得回头处理。第二,max 和 75% 分位数如果差距极大,比如销量 75% 分位数是 140,max 是 300,就要怀疑有异常值。第三,均值远大于中位数时,分布一般右偏(长尾在右边),这在价格类数据里很常见。

describe()默认只统计数值列。如果你需要看分类列的频数情况,可以加参数:

df.describe(include="object")

输出的是分类列的 count、unique、top、freq 四个指标,相当于数据探索阶段快速看一眼有哪些类别、哪个类别最多。

3.2 自定义聚合组合:agg() 比单独跑更高效

describe()给的是标准套餐,但项目里经常要定制化统计指标。这时候用agg()一次性把多个统计量算出来:

df[["单价", "销量"]].agg(["mean", "median", "std", "min", "max", "skew"])

输出中skew是偏度,能直观体现分布是否对称。偏度绝对值超过 1,就说明分布偏得比较厉害,后续做统计建模时需要考虑数据变换。

agg()还能对不同列用不同统计函数:

df.agg({ "单价": ["mean", "std"], "销量": ["sum", "max"], "是否促销": "mean" })

这种写法适合做数据汇总报告,一屏就能看全所有关键指标。使用的时候有个小建议:把常用统计函数存在一个列表变量里,比如stats_list = ["count", "mean", "median", "std", "min", "max"],多张表都需要时直接复用,不用每次敲一遍。

3.3 分组描述统计:groupby 与 describe 的联动

只看全表的统计远远不够,实际分析中几乎都要分组对比。比如“不同品类下的销量差异”,用一句话就能完成:

df.groupby("品类")["销量"].describe()

这会输出每个品类的计数值、均值、标准差、分位数等,直接对比就能看出品类之间的销量差异。如果想更细,还可以叠加多列:

df.groupby("区域")[["单价", "销量"]].describe()

我在做零售分析时特别依赖这个功能:某区域的销量均值高,但标准差也大,说明该区域表现不稳定,可能存在强季节性或者大促干扰;另一个区域均值低但 std 小,那就是平稳的“基本盘”。这种信息在普通汇总表格里根本看不出来,但 groupby + describe 一眼就能发现。

4. 协方差与相关性:找变量之间的关系

4.1 协方差的意义与矩阵:先看方向,再看强弱

描述统计只告诉你单列数据的形态,而协方差和相关性解决的是“列与列之间的关系”。协方差的公式不复杂:

Cov(X, Y) = Σ[(Xᵢ - X̄)(Yᵢ - Ȳ)] / (n - 1)

在 Pandas 里一行代码就能得到协方差矩阵:

df[["单价", "销量"]].cov()

结果大致是:

单价销量
单价907.2-30.5
销量-30.51253.16

协方差的符号表达的是两个变量的变动方向是否一致:正数代表同向变动,负数代表反向变动。但这里有个实际问题:协方差的大小受变量量纲影响极大。单价是几十几百的单位,销量可能是几千,两者数值规模完全不在一个量级,协方差数值看起来可能很大也可能很小,很难凭一个数字判断“关系有多强”。

所以实际分析里,协方差更多的用途是作为中间结果,或者在做多元统计时使用。日常项目里判断变量关系,几乎都看相关系数。

4.2 相关系数选择:Pearson、Spearman、Kendall 怎么选

相关系数把协方差做了“标准化”处理,压到了 -1 到 1 之间,比较起来很直观。Pandas 的df.corr()默认算的是 Pearson 相关系数,它衡量的是线性相关。用法很简单:

df[["单价", "销量", "促销编码"]].corr()

输出一个对称矩阵,对角线全是 1,其他位置就是两两变量间的相关系数。

但注意一个坑:Pearson 对线性关系敏感,如果两个变量之间存在明显的非线性关系(比如价格和销量可能是倒 U 形),Pearson 可能会接近 0,但这不代表它们没关系。这时候可以用 Spearman 秩相关系数,它基于排序而不是原始数值,能捕捉单调关系,对异常值也更稳健:

df[["单价", "销量"]].corr(method="spearman")

不同场景的选择建议可以看这个表:

场景推荐方法
大多数连续变量线性关系判断Pearson
数据明显偏态、有异常值、存在秩次关系Spearman
样本量小且重复值较多(如评分数据)Kendall
快速观察任意变量组合的方向Spearman 或 Pearson 都行,注意对比

实际项目里,我通常 Pearson 和 Spearman 各算一次,两个表一起看。如果两者结论一致,关系基本可信;如果差异很大,说明变量之间可能不是简单线性关系,或者有异常值干扰,需要进一步探查。

4.3 corrwith 与“相关不等于因果”的提醒

你有时不想看整个矩阵,只想看“某一列和其他所有列的相关性”,这时候用corrwith()更方便:

df.corrwith(df["销量"])

这会返回每个其他列与销量的相关系数 Series,非常适合作特征筛选。比如特征有 50 列,用这一行代码就能快速找出与目标变量相关性最高的 Top 10 特征,比逐个corr()清爽得多。

但有一句话必须反复强调:相关性不等于因果。我看到太多人拿着相关系数 0.8 就下结论说“A 导致 B”,这在统计分析里是没法成立的。相关系数高只说明两者有共变趋势,可能是巧合、可能是共同原因驱动、也可能是反向因果。严谨的做法是:用相关性分析缩小候选变量范围,用实验设计或者业务逻辑做因果判断。尤其是做商业分析报告时,给业务方讲“相关系数”可以,但要补一句“这只是关联强度,不等于因果,需要进一步验证”。

5. 异常值处理:把坏数据从数据里揪出来

5.1 Z-Score 方法:适合近似正态分布的数值列

异常值处理动作虽小,但影响很大。为什么?因为均值、标准差、相关系数这些东西对异常值极其敏感,一个极端值就可能把相关系数从 0.5 拉到 0.8,或者把均值扯得完全失真。

最常用的方法之一是 Z-Score。它的思路是计算每个值和均值的距离是几个标准差:

from scipy import stats df["销量_zscore"] = stats.zscore(df["销量"]) df_异常 = df[np.abs(df["销量_zscore"]) > 3]

Z-Score 绝对值大于 3,通常视为异常。这个阈值是有理论依据的:正态分布下,超过 3 个标准差的数据大约只占 0.3%。但注意,Z-Score 方法基于均值和标准差,而这两个指标本身会被异常值污染。如果数据集里已有严重的极端值,Z-Score 的检测效果会打折扣。

所以更稳妥的做法是:先用 IQR 方法识别并剔除极端值,再计算均值和标准差,然后做 Z-Score 检查。这两个方法可以配合使用,互为验证。

5.2 IQR 四分位距方法:对偏态分布更稳

IQR(Interquartile Range)方法的逻辑很简单:计算数据的 25% 分位数(Q1)和 75% 分位数(Q3),两者之差就是 IQR。一般认为,低于Q1 - 1.5 * IQR或者高于Q3 + 1.5 * IQR的值属于异常。

Q1 = df["销量"].quantile(0.25) Q3 = df["销量"].quantile(0.75) IQR = Q3 - Q1 lower_bound = Q1 - 1.5 * IQR upper_bound = Q3 + 1.5 * IQR df_异常 = df[(df["销量"] < lower_bound) | (df["销量"] > upper_bound)]

IQR 方法的优势是它只依赖分位数,不受极端值影响,对偏态分布更稳。实际项目里我会把 IQR 作为首选初筛工具。系数 1.5 是 Tukey 箱线图中经典参数,但并非不可调整。如果你的业务场景波动本身就大(比如电商大促的销量),可以把系数调成 3,尽量减少误杀。这个调整很有必要——我自己就遇到过把大促期间正常的高销量当成异常值删掉,导致后续预测分析直接偏低的情况。一定是先弄清楚业务背景,再定判断口径。

5.3 业务阈值与可视化辅助判断

统计方法给出的是“数学上”的异常点,但业务上的异常往往需要结合场景定义。比如单价为负、销量为负、年龄大于 120,这些不需要任何统计方法,直接用业务规则就能筛出来:

df[(df["单价"] <= 0) | (df["销量"] < 0)]

所以我的处理顺序是:先业务规则打底,再可视化辅助,再统计方法兜底。

可视化这里特别推荐箱线图(boxplot),一图就能看到数据的整体分布和异常点位置:

import matplotlib.pyplot as plt df.boxplot(column=["单价"]) plt.show()

如果箱线图上出现大量远离箱体的点,可以有针对性地观察这些行。还有一种方式是散点图,专门看两个变量的关系时很有用——异常点通常是偏离主体点云的那些“孤立点”。

5.4 处理异常值:删除、截尾、转换还是标记

识别出异常值之后,真正困难的决策是“怎么处理”。我见过不少新手,一看到异常点就drop()删掉,这是有风险的。正确的做法是根据异常值的“成因”来决定:

  • 录入错误(负销量、离谱年龄):直接删除或修正;
  • 真实存在的极端情况(大促爆款销量):不要删,否则丢失重要信息;
  • 测量误差导致的数据噪声:可以用截尾(Winsorize)处理,把极端值压缩到某个分位数上;
  • 建模时担心极端值影响稳定性的:可以用对数变换或 Box-Cox 变换降低极端值的影响。

截尾操作代码示例:

# 将销量列 99% 以上的值截断到 99% 分位数 cap = df["销量"].quantile(0.99) df["销量_截尾"] = df["销量"].clip(upper=cap)

clip()方法在这里非常实用,它不影响非异常值,只把超出阈值的部分压到边界值。

至于“标记”而不处理,也很常见,尤其是分析阶段。你可以加一列df["销量_异常"] = (df["销量"] > upper_bound) | (df["销量"] < lower_bound),后续建模时选择保留或剔除。这样既保留了数据,也保留了判断的灵活性。

6. 常见问题与排查技巧实录

6.1 apply 返回 NaN、map 匹配不上、corr 结果全 NaN

这里整理几个我实际项目里踩过的高频问题,每个都有对应的排查思路。

第一,apply()结果出现 NaN,大概率是函数内部跑出了异常。比如某个字符串列里有None,你用row["列"].strip()时就会抛错,Pandas 有时会把这个转换成 NaN。排查方法是在函数内部加 try-except,或者先.info()确认列有没有缺失值,再决定是否填充。

第二,map()匹配不上导致全是 NaN,几乎都是值不一致的问题。比如有空格、大小写不同、数据类型不同(字典的 key 是字符串,列里是数字),全部会匹配失败。我的习惯是映射前先统一类型、去空格,用df["列"].astype(str).str.strip()清理后再 mapping。

第三,corr()结果出现全 NaN,常见原因有两种:列里有 NaN 且行数太少,Pandas 计算相关性时默认会丢弃缺失值,如果每列的有效配对数量不足就返回 NaN;另一种是列是 object 类型,没法算数值相关性。先用pd.to_numeric()转换类型,再用dropna()删除空值,基本能解决。

6.2 常见问题速查表

现象大概率原因解决方式
apply()结果 NaN函数内部异常或列内缺失值先isna()检查,函数内补充异常处理
map()后大量 NaN值不一致(大小写、空格、类型)先value_counts()列出所有值,统一清理
apply(axis=1)报错行内列名不存在或类型不同先确认列名是否一致,再看各列 dtype
corr()全 NaN列含缺失值或类型是 objectastype(float)+dropna()
describe()没显示某列该列是 object 类型使用describe(include="object")
groupby 后 apply 很慢分组数太多或函数太复杂用transform()或向量化替代

6.3 一些我个人写代码的习惯

最后分享几个让我少走了很多弯路的习惯。

第一个习惯是:任何映射、转换之前,先跑一次value_counts()或者unique()。知道数据里到底有哪些值再动手,能避免大量“静默变 NaN”的问题。这真的是我用血泪换来的经验。

第二个习惯是:在做任何影响数据的操作之前,先备份一份原始数据副本。df_backup = df.copy()这种一行代码的成本很小,但能让你在操作失误时随时回退,不会因为一次性改了 500 行数据而无法恢复。

第三个习惯是:把常用的描述统计、异常值检测逻辑封装成函数。比如我有个eda_report(df)的脚本,输入 DataFrame,自动输出缺失率、描述统计、相关性矩阵、箱线图,一次能省好几十分钟的重复劳动。

第四个习惯是:写 apply 函数时,优先用普通函数而不是 lambda。lambda 写起来快,但如果逻辑复杂一些,可读性会很差,而且没法加注释和调试。我一般写成:

def 标记函数(row): if row["单价"] > 50 and row["销量"] > 100: return "高价值" return "普通" df["重点标记"] = df.apply(标记函数, axis=1)

这样代码保留了,逻辑清晰了,后续要改条件也不用在 lambda 里翻来翻去。

写在最后的体会

这些 Pandas 操作单看都不难,难的是把它们放到一起时,知道每一步在解决什么问题。我在实际项目里最深的一个感受是:数据的质量决定了分析的结论,而异常值处理和描述统计就是保证质量的第一道关。真正做项目时,我一般先跑一遍describe()和箱线图摸清数据底子,再用map()完成文本到数值的转换,用apply()处理跨列逻辑,最后用corr()快速锁定值得深挖的变量关系。这套流程反复用了很久,速度和结论的可靠程度都提升不少。

如果你刚开始接触这些函数,建议不要一次性全上,可以先挑一个真实数据,从头到尾走一遍这篇文章里的代码,遇到 NaN 就查原因,跑出相关性就试着解释一下。几次下来,你自然就明白什么场景该用map、什么时候必须上apply、哪些异常值该删哪些该留。数据分析能力的提升,不在于背多少 API,而在于你踩过多少坑后,形成的那些判断习惯。希望这篇文章能帮你少踩几个坑。

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

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

立即咨询