ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Pandas数据清洗与聚合:groupby、merge、pivot_table实战

Pandas数据清洗与聚合:groupby、merge、pivot_table实战

前几篇聊完 Series 和 DataFrame 的构造、loc 与 iloc 的区别、CSV/Excel 的读写和 describe 那点基础统计,说白了都是在解决“数据怎么进得来”的问题。但真正干活的时候你会发现,读进来只是万里长征第一步,横在面前的是:一列手机号里混着空格和短横线、日期列是字符串、金额列有“¥”符号、某些行重复了三遍、某几列干脆空了一半。这时候光会read_csv是不够的,得把数据清洗、类型转换、分组聚合、表连接、透视重塑这几套动作连起来打,才能把一份毛坯数据变成能直接算指标、能直接喂模型的规整表。这篇是 Pandas 系列的第四篇,主要就是补上这一段:数据清洗的取舍逻辑、groupby 的三段式心智模型、merge 的行数膨胀排查、pivot_table 的二维视角,顺带把性能优化和几个经典报错的成因讲透。适合已经能读写数据、但在“清洗—聚合—输出”这条链路上还经常卡壳的人,也适合把 Pandas 当日常工具、想少踩几个坑的老手回头对一遍。

1. 先想清楚这一篇解决什么问题

1.1 前三篇留下的账,第四篇来还

系列写到第四篇,得先交代清楚定位,不然读者容易把它当成又一份 API 清单。前三篇覆盖的是“入口”,从数据结构、索引体系到文件读写,都是把外部数据搬进内存的过程。但真实项目里,从搬进来到能算,中间至少还隔着四道坎:数据不干净(缺失、重复、格式脏)、字段不可算(类型不对、需要派生新列)、粒度不对(明细要汇总成维度)、表分散(多个来源要拼成一张宽表)。

这四道坎恰好对应 Pandas 里四组核心能力:清洗类方法、转换类方法、groupby 聚合、merge/concat 合并。它们的共同特点是——单个方法都不难,难的是在正确的位置用正确的那个。我见过太多代码,一个apply套 lambda 跑了三分钟,其实换成向量化写法零点几秒就出结果;也见过合并两张表之后行数从 10 万涨到 43 万,排查半天才发现是右表主键有重复。这些不是 API 不熟,是心智模型没建立。

所以这篇不打算按字母顺序罗列方法,而是按“你手上这份数据现在卡在哪一步”来组织。每一节都会先说清楚这个场景的典型症状,再给方案和取舍理由,最后补上我实际踩过的坑。

1.2 四组能力的定位对照

先把全局地图摆出来,后面每一节都是在这张图上展开。理解每个工具的设计意图比记住参数重要得多,因为参数可以查文档,意图查不到。

能力组代表方法解决的问题输出粒度典型误用
清洗dropnafillnaduplicated数据质量与原表同粒度无脑dropna()把有效样本删光
转换astypemapapplycut字段可用性与原表同粒度用apply做本可向量化的运算
聚合groupby().agg()粒度上卷每个分组一行聚合后列名变成多层索引不知道怎么取
合并mergeconcat多源拼接取决于连接方式不校验主键唯一性,行数悄悄翻倍

这张表我建议新手先存下来。因为在实际写代码时,你判断“该用哪个”的第一反应往往来自这张表——是行数要变少(聚合)、行数要变多(连接)、还是行数不变只是列被改写(转换)。行数变不变,是判断工具选型最快的一个锚点。

注意:清洗和转换这两组,输出粒度必须和输入一致,这是它们的定义决定的。如果你发现某个“清洗”操作让行数变了,那它本质上是过滤(filter)或聚合,要重新审视这一步的意图。

1.3 关于“万字总结”这个标题的实话

说实话,一篇技术总结被叫成“万字总结”,很多时候是标题需要。但字数本身不产生价值,密度才产生价值。我的取舍标准是:每个方法只讲它最容易被用错的那个点,正确的用法文档里都有,不需要我抄一遍;而“什么情况下不该用它”“用了之后报什么错”“报错了怎么定位”,这些文档里基本不写。

举个例子,fillna的参数就那么几个,文档五分钟能看完。但真正会出问题的是:你对一列 float 用fillna(0),然后这列的均值被拉低了;或者你用method='ffill'填时间序列,但没意识到数据没按时间排序,填出来全是错的。这类问题才是这一篇要重点处理的对象。

2. 数据清洗:先搞清楚脏在哪,再决定怎么洗

2.1 缺失值的三种处理路线,选错代价很大

拿到一份数据,第一件事不是急着fillna,而是先量化缺失。df.isna().sum()给出每列缺失数,但更有信息量的是df.isna().mean().sort_values(ascending=False),直接看缺失比例。比例这个数字决定了你的处理路线:

  • 缺失比例低于 1%:直接删行,对总体分布几乎无影响,最省事。
  • 缺失比例 1% 到 30%:优先考虑填补,但要区分缺失机制。随机缺失(MCAR)可以用均值/中位数/众数,非随机缺失要考虑加一个“是否缺失”的指示列。
  • 缺失比例超过 50%:这列基本没有信息量,除非业务上明确知道它有特殊含义,否则直接删列。

这个分档不是死规定,但它能防止两个最常见的错误:一是对 60% 缺失的列做均值填充,等于凭空造了一列噪声;二是对 0.2% 缺失的数据整行删除,结果删掉了 8% 的样本——因为缺失分散在很多列上,任何一列缺就删,累积效应远超单列比例。

dropna的how和thresh两个参数就是为这种情况准备的。how='any'是默认值,任意一列缺就删,最激进;how='all'是全缺才删,最保守;thresh=n是“至少有 n 个非缺失值才保留”,这个在宽表场景下特别好用。我一般会先跑一遍len(df) - len(df.dropna(thresh=int(df.shape[1]*0.7))),看看如果要求每行至少 70% 字段有值,会损失多少行,心里有个数再动手。

# 缺失结构快查:按行统计非缺失字段数分布 row_valid = df.notna().sum(axis=1) print(row_valid.value_counts().sort_index()) # 保守方案:至少保留 70% 字段有值的行 keep_ratio = int(df.shape[1] * 0.7) df_clean = df.dropna(thresh=keep_ratio)

注意:thresh是针对行(axis=0)或列(axis=1)的非缺失值个数,不是比例。很多人直接传 0.7 进去,Pandas 不会报错,但逻辑完全错了,因为浮点数会被当成个数处理,实际等于几乎没删。

2.2 填补策略:均值、前向、还是业务默认值

填什么值,本质上是业务问题,不是技术问题。均值填充看着方便,但它有两个隐性代价:一是压缩了方差,二是对偏态分布不友好。金额、时长、访问次数这类右偏数据,中位数比均值稳健得多。我在一个用户消费金额的项目里试过用均值填充,结果填充进来的值和真实分布差了两个数量级,因为少数大额订单把均值拉得很高。

时间序列类数据要特别小心ffill和bfill。这两个方法依赖行顺序,如果数据没按时间排好,填出来的结果是错的。稳妥的做法是填充之前先sort_values('时间列'),填完再按需恢复原顺序。另外ffill(limit=n)要配合使用,不设上限的前向填充可能在长时间断档里把值“传染”得很远。

还有一个经常被忽略的技巧:用分组内的统计量填充,而不是全局统计量。比如按城市分组,用各城市的房价中位数填充缺失,比全局中位数合理得多。写法是df['房价'] = df.groupby('城市')['房价'].transform(lambda s: s.fillna(s.median()))。transform的关键在于它返回的索引和原表对齐,所以能直接赋回原列,这一点在后面的第 4 节还会细说。

# 分组内中位数填充,避免全局统计量带来的偏差 df['单价'] = df.groupby('城市')['单价'].transform(lambda s: s.fillna(s.median())) # 前向填充前必须保证时间有序 df = df.sort_values('日期') df['余额'] = df['余额'].ffill(limit=3)

2.3 重复值与异常值:判定标准比代码重要

duplicated()和drop_duplicates()语法极简,麻烦的是判定标准。全字段完全一致的重复好处理,df.drop_duplicates()一行搞定。但业务上真正麻烦的是“同一订单号出现多次”这类逻辑重复——这时候要用subset指定判定列,还要用keep决定留哪一条。

keep的三个取值含义要记牢:'first'保留首次出现(默认),'last'保留最后一次,False是一条都不留。第三种容易误用,很多人以为keep=False是“不保留重复项即去重”,其实它的语义是“把重复的行全部删掉”,如果一组数据重复了两次,结果是两条都没了。这个坑我当年踩过,一个 5 万行的表去重后剩 4.8 万,看着挺合理,实际上把 4000 条有重复的记录成对删光了。

异常值更依赖业务判断。统计上常用的 3σ 和 IQR 两个规则,前者假设正态分布,后者更稳健:

q1, q3 = df['金额'].quantile([0.25, 0.75]) iqr = q3 - q1 lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr # 不要直接删,先标记,看清楚有多少再决定 df['is_outlier'] = ~df['金额'].between(lower, upper) print(df['is_outlier'].mean())

我的习惯是先标记、后处理,绝不直接删。因为异常值有两种:一种是真的错(录入错误、单位错了、测试数据),该删或该改;另一种是真的极端值(大客户订单、热点事件),删了就是在隐瞒事实。标记出来,按业务含义逐类处理,比一刀切可靠得多。金额这类右偏数据,1.5 倍 IQR 会标出 5% 到 10% 的点,这些点里绝大多数是正常的业务大单,直接删会造成系统性偏差。

3. 类型转换:字段“不可算”的九成原因在这里

3.1 类型诊断:为什么 sum 出来是字符串拼接

从 CSV 读进来的数据,Pandas 会做类型推断,但推断经常失败。最典型的现象是:某列看起来全是数字,df['金额'].sum()却返回一串首尾相连的字符串。原因通常是这一列里混了极少数非数字字符——一个空格、一个逗号、一个“暂无”——整个列就被推断成 object 类型,求和变成了字符串拼接。

诊断的标准动作是三行:

print(df.dtypes) # 全列类型 print(df['金额'].head().tolist()) # 抽样看真实值 print(df['金额'].map(type).value_counts()) # 混合类型检测

第三行特别有用,它能直接告诉你这一列里 int、float、str 各占多少。如果 str 只有个位数,那基本可以确定是脏数据导致的推断失败,用pd.to_numeric(errors='coerce')一转,脏值变成 NaN,问题就暴露出来了。errors参数有三个取值:'raise'报错、'coerce'转成 NaN、'ignore'原样返回。日常清洗基本都用'coerce',因为它不打断流程,能把问题值定位出来。

注意:'ignore'是个陷阱选项。它不报错也不转换,看起来“安全”,实际上让你在完全不知情的情况下继续用了错误类型。除非你在做类型探测,否则不要用它。

3.2 字符串清洗:str 访问器的正确打开方式

文本列的处理有一套固定的流水线:去空白、统一大小写、替换干扰字符、提取结构化信息。Pandas 的.str访问器把 Python 的字符串方法向量化了,性能比apply好一个数量级。

df['手机号'] = (df['手机号'] .astype('string') .str.replace(r'\D', '', regex=True) # 去掉所有非数字 .str.strip()) df['城市'] = df['城市'].str.strip().str.replace('市', '', regex=False) df['等级'] = df['等级'].str.upper()

几个细节值得说。第一,astype('string')而不是astype(str),前者是 Pandas 的可空字符串类型,遇到 NaN 不会变成字符串'nan',这个区别在处理有缺失的文本列时非常致命。第二,str.replace默认regex=True,特殊字符要转义或显式关掉正则。第三,.str访问器遇到非字符串元素会返回 NaN 而不是报错,这既是优点也是隐患——它会把类型混用的错误悄悄吞掉。

提取结构化信息用str.extract,配合命名分组特别好用:

# 从"北京市朝阳区XX路12号"里提取省市区 pattern = r'(?P<省>.+?省)?(?P<市>.+?市)?(?P<区>.+?区)?' addr = df['地址'].str.extract(pattern) df = pd.concat([df, addr], axis=1)

extract返回的是 DataFrame,列名就是分组名,直接 concat 回去即可。expand=False时返回 Series(只适用于单个分组),这个参数经常被忘掉,导致后面取列时报 KeyError。

3.3 向量化优先:apply 该退居二线的时候

这一条是性能分水岭,值得单独拎出来讲。apply的本质是 Python 层的循环,即使 Pandas 做了优化,它也比原生向量化慢一到两个数量级。判断标准很简单:如果你的操作能用算术、比较、逻辑运算符表达,就一定不要用 apply。

# 慢:apply + lambda,10 万行约 300ms df['含税价'] = df['价格'].apply(lambda x: x * 1.13) # 快:直接向量化,10 万行约 1ms df['含税价'] = df['价格'] * 1.13

条件赋值是apply被滥用最多的地方。np.where处理二选一,np.select处理多选一,mask/where处理条件替换,这三个基本能覆盖 95% 的场景:

import numpy as np # 二选一 df['等级'] = np.where(df['金额'] > 1000, '高', '低') # 多选一,注意条件顺序,先命中先返回 conditions = [df['金额'] > 5000, df['金额'] > 1000, df['金额'] > 0] choices = ['钻石', '黄金', '普通'] df['等级'] = np.select(conditions, choices, default='未知') # 条件替换:满足条件的置为 NaN df['金额'] = df['金额'].mask(df['金额'] < 0)

np.select的性能和可读性都远好于嵌套的apply,唯一要注意的是条件顺序——它按顺序匹配,第一个为真的条件生效,所以条件要从严格到宽松排列。我见过把>0写在>1000前面的代码,结果所有正数都被判成“普通”,排查了半天。

真正必须用apply的场景只有两类:一是逻辑复杂到无法向量化(比如要调用外部函数、要跨多列做字符串解析),二是需要用axis=1做行级操作。这两种情况下用就用,但要心里有数:10 万行以上的数据,apply(axis=1)可能要跑好几秒,能绕开就绕开。

4. groupby:把“拆分—应用—合并”刻进脑子

4.1 三段式模型:为什么 agg 之后列名变了

groupby 的所有行为都能用 Split-Apply-Combine 三段式解释:按 key 把数据切成若干块(split),对每块独立计算(apply),把结果拼回一张表(combine)。这个模型一旦建立,很多“奇怪”的表现就都能解释了。

比如为什么df.groupby('城市')['金额'].sum()返回的是 Series,索引是城市?因为 combine 阶段把每组的结果按 key 对齐摞起来了。为什么df.groupby('城市').agg({'金额': ['sum', 'mean']})的列变成了两层?因为每个分组要放多个统计量,一层列名不够用。

理解这一点,你就知道遇到多层列名该怎么处理了:要么拍平(df.columns = ['_'.join(c) for c in df.columns]),要么用命名聚合(named aggregation)直接生成单层列名。后者更推荐,因为在聚合的同时就把列名定好了:

result = df.groupby('城市', as_index=False).agg( 订单数=('订单号', 'nunique'), 总金额=('金额', 'sum'), 客单价=('金额', 'mean'), )

这种写法在 Pandas 0.25 之后才支持,但现在已经是标准做法了。它的好处是结果直接是一张干净的平表,列名可读,索引规整,不用再reset_index()和改列名。

4.2 as_index、dropna、observed 三个参数,坑最多

as_index默认是 True,分组键会变成结果的索引。这个设计在链式操作里很方便,但如果你要把结果 merge 回原表,就必须reset_index()或者建 groupby 时直接as_index=False。忘了这一步,merge 时会因为找不到列而报错,这是新手最常遇到的报错之一。

dropna默认是 True,意思是分组键里有 NaN 的行会被直接丢弃。这个默认值很危险:如果你的关键维度上有缺失,聚合结果会静默少掉一部分数据,而且不会报错。我现在的习惯是显式写dropna=False,让 NaN 成为一个独立分组,至少能在结果里看到它的存在。

# 城市维度有缺失时,默认会被丢掉 df.groupby('城市')['金额'].sum().sum() # 少了 NaN 组 df.groupby('城市', dropna=False)['金额'].sum().sum() # 完整

observed参数只在分组键是 category 类型时有效,默认 False 会把所有类别组合都列出来,包括数据里根本不存在的组合。当你有两个 category 列做交叉分组时,组合数可能从几百膨胀到几万,内存直接爆掉。这个参数在 3.0 版本可能会改默认值,现在写代码最好显式设成observed=True。

注意:observed引发的内存问题特别隐蔽,因为结果表看起来只是“多了些 0 值行”,不容易联想到是这个参数导致的。如果你的 groupby 结果行数远超预期,先检查分组键的 dtype 是不是 category。

4.3 transform 和 apply 的区别:对齐与不对齐

这两个方法经常被混用,但它们的返回结果完全不同,用错了会得到诡异的结果。

transform的契约是:返回与原数据同长度、同索引的 Series。所以它适合做“组内标准化”“组内填充”“组内排名”这类需要把结果广播回每一行的操作。

# 每行减去所属城市的均值 df['金额_去均值'] = df['金额'] - df.groupby('城市')['金额'].transform('mean') # 组内排名 df['城市内排名'] = df.groupby('城市')['金额'].rank(ascending=False)

apply则自由得多,它可以把每组当成一个小 DataFrame 随便操作,返回什么形状都行。代价是慢,而且返回结果的索引规则比较绕:如果返回 Series,索引会成为结果的索引;如果返回标量,会聚合成一行。

选择的判断标准:结果要不要贴回原表的每一行。要贴回去用transform,只想要每组一个汇总值用agg,需要复杂自定义逻辑(比如组内排序后取前 N 行)才用apply。

# 每组取金额最高的前 2 条:这是 apply 的典型场景 top2 = df.groupby('城市', group_keys=False).apply( lambda g: g.nlargest(2, '金额'), include_groups=False )

这里group_keys=False避免结果多出一层索引,include_groups=False是新版本为了避免分组列重复出现在结果里的参数(旧版本没有这个参数,行为也不同),写的时候要注意版本兼容。

4.4 多列多函数聚合的写法演进

同一列要多个统计量、多列要不同统计量,历史上写法变过好几轮,现在推荐命名聚合,其余写法看懂就行。

# 老写法:字典嵌套,列名变多层 df.groupby('城市').agg({'金额': ['sum', 'mean'], '订单号': 'nunique'}) # 新写法:命名聚合,列名单层,推荐 df.groupby('城市', as_index=False).agg( 总金额=('金额', 'sum'), 平均金额=('金额', 'mean'), 订单数=('订单号', 'nunique'), )

还有一种情况是自定义函数:agg里可以传函数,但函数接收的是 Series,返回标量。如果函数需要多列输入,就得用apply,因为agg传函数时是逐列应用的,拿不到整行。

# agg 传自定义函数:接收单列 def cv(s): return s.std() / s.mean() if s.mean() else np.nan df.groupby('城市')['金额'].agg(变异系数=cv)

这个cv函数里加了除零保护,因为有的分组均值可能为 0(比如全是 0 金额的测试订单),直接除会得到 inf,后续计算全被污染。这类边界保护在实际项目里非常必要,生产环境的定时任务因为一个 inf 导致整批结果异常,是很难查的。

5. merge 与 concat:行数悄悄翻倍的排查法

5.1 四种连接方式的语义差异

merge的how参数有四个常用取值,语义差别在于“保留哪边的键”:

how保留键行数变化规律典型场景
inner两边都有的键通常减少只要匹配得上的记录
left左表全部键通常不变或增加以主表为准补维度
right右表全部键通常不变或增加与 left 对称,少用
outer两边所有键一定增加全量比对,找差异
cross无键,笛卡尔积左行数 × 右行数生成组合、补全日历

日常用得最多的是left,因为业务上通常有一张事实表(订单明细)和若干维度表(城市信息、商品信息),事实表是主体,维度表补充字段。这时候left的语义是“订单一条不少,能匹配上就补维度,匹配不上维度字段为 NaN”。

行数变化的规律必须记牢:left连接的行数 = 左表行数 × 每条左键在右表中的匹配数。如果右表的主键是唯一的,匹配数最多为 1,行数不变;如果右表主键有重复,左表的每一条会被复制多份,行数就膨胀了。这就是行数翻倍的根本原因,跟连接方式无关。

5.2 行数膨胀的三种定位方法

连接后行数不对,是数据工作里最高频的问题之一。我在实际项目里总结了三道检查,按顺序做,基本能定位到所有情况。

第一道,连接前先查两边的键唯一性:

print(df_left['订单号'].duplicated().sum()) print(df_right['商品编码'].duplicated().sum())

第二道,用indicator=True看匹配情况,结果会多一列_merge,取值是both、left_only、right_only。left_only的行数就是没匹配上的量,这个数字如果太大,要么是键格式不一致(前后空格、大小写、带不带前缀),要么是右表本身不全。

第三道,用validate参数强制校验,这是最省事的方法:

result = df_orders.merge( df_products, on='商品编码', how='left', validate='many_to_one', # 明确声明右表键唯一 )

validate的取值有'1:1'、'1:m'、'm:1'、'm:n'(新版本支持'm:m')。一旦实际关系不符合声明,Pandas 会直接抛异常并告诉你哪边重复了。这个参数应该成为写 merge 时的肌肉记忆,它把“事后排查”变成了“事前预防”。

提示:键不匹配的排查有个快捷方式——先看两边的 dtype。一边是 int64、一边是 object,merge会静默匹配不上,全部变成 NaN,但行数看着正常,特别容易漏检。所以连接前统一astype是个好习惯。

5.3 concat 的轴向与索引陷阱

concat用于单纯拼接,不做键匹配。axis=0是纵向摞起来(加行),axis=1是横向并排(加列)。两个容易出问题的地方:

纵向拼接时索引会重复。如果不加ignore_index=True,结果的索引是0,1,2,...,0,1,2,...这种重复状态。虽然后续运算大多按位置或按列名走,但一旦你用了.loc索引,重复索引会一次返回多行,行为完全不可控。所以纵向拼接我基本都带ignore_index=True。

横向拼接是按索引对齐的,不是按位置。两个表如果索引不同(比如一个经过了sort_values,一个经过了reset_index),axis=1拼出来的结果会有一堆 NaN。这个坑在把处理结果拼回原表时特别常见。稳妥的做法是拼之前两边都reset_index(drop=True),或者干脆改用assign加列。

# 纵向:加行,务必忽略原索引 all_rows = pd.concat([df_2023, df_2024], ignore_index=True) # 横向:加列,索引必须对齐 df = pd.concat([df.reset_index(drop=True), new_cols.reset_index(drop=True)], axis=1)

concat还有个keys参数,能给每块数据打上来源标签,结果变成多层索引。做多来源数据合并时这个很有用,因为你可以事后用reset_index(level=0)把来源拆成单独一列。

6. pivot_table:把长表转成一眼能看懂的二维矩阵

6.1 参数虽多,核心只有四个

pivot_table的参数列表很长,但真正决定结果形状的只有四个:values(算什么)、index(行维度)、columns(列维度)、aggfunc(怎么算)。把这四个想清楚,剩下的都是修饰。

pt = pd.pivot_table( df, values='金额', index='城市', columns='月份', aggfunc='sum', fill_value=0, margins=True, margins_name='合计', )

这个调用的含义是:每个城市每个月卖多少钱。fill_value=0把没有交易的组合填成 0 而不是 NaN,margins=True加上行列合计。这四个参数对应的正好是业务问题里的四个要素——看什么指标、按什么维度拆、按什么维度横、怎么汇总。

有一个细节要注意:aggfunc默认是'mean',不是'sum'。这个默认值坑过不少人,因为大多数业务场景想看的是总额,结果出来的是平均值,数量级小了两三个数量级,还以为是数据有问题。

pivot_table默认会丢弃所有值全是 NaN 的列,这是dropna=True的行为。如果你的列维度里有某个类别在某些行维度下完全没数据,这一列会整个消失。想保留就把dropna=False显式写上。

6.2 多层列名的三种拍平写法

pivot_table一旦涉及多个values或多个aggfunc,列名就变成多层索引,取列和导出的都很别扭。拍平有三种常见写法,各有适用场景:

# 写法一:字符串 join,最通用 pt.columns = ['_'.join(map(str, c)).strip('_') for c in pt.columns] # 写法二:列表推导,控制更细 pt.columns = [f'{a}_{b}' if b else a for a, b in pt.columns] # 写法三:用 to_flat_index,适合纯展示 pt.columns = pt.columns.to_flat_index().map(lambda x: '_'.join(x))

第一种最稳,map(str, c)能处理任何层数,strip('_')去掉单层索引时多出来的下划线。第三种写法好看但要求所有列都是元组,如果values只有一个且aggfunc也是一个,列名可能不是元组,会报错。我在生产代码里一般用第一种,因为行为可预测。

6.3 stack / unstack / melt:长宽表互转

透视表是长转宽,反向操作是melt。这两个方向的转换在实际工作里频率很高,因为入库通常存长表(一行一个观测),分析通常要宽表(一行一个主体)。

melt的参数很好理解:id_vars是不动的标识列,value_vars是要融化的列,结果会变成三列——标识列、变量名列、值列。

long = wide.melt( id_vars=['城市'], value_vars=['一月', '二月', '三月'], var_name='月份', value_name='金额', )

stack和unstack处理的是索引层级,跟melt有点像但作用在索引上。unstack把最内层索引变成列,stack反过来。做透视之后想再变回去,或者想把多层列名转成行,这两个方法比melt更直接。

用的时候有个反直觉的点:unstack默认转最内层(level=-1),多层索引下经常转错层,要么显式指定level,要么先reorder_levels调整顺序。这个细节在处理三层以上索引时特别容易出错。

7. 时间序列与窗口:别把日期当字符串

7.1 to_datetime 的三个关键参数

日期列读进来默认是字符串,直接做减法会报错,比较大小会按字典序("2024-1-9" 会大于 "2024-10-1",因为 '1' 后面的 '-' 比 '0' 大)。pd.to_datetime是必经之路,三个参数值得记牢。

format指定格式能大幅提速,也能避免歧义。像01/02/2024这种,到底是 1 月 2 日还是 2 月 1 日,取决于地区习惯,不指定格式 Pandas 只能猜。指定format='%d/%m/%Y'就明确了,速度也能快好几倍。

errors和to_numeric一样,'coerce'把解析失败的变成 NaT,这个能让脏日期暴露出来。dayfirst处理日月顺序歧义。utc处理时区,有跨时区数据时必须显式指定,否则不同来源的时间会混在一起算。

df['日期'] = pd.to_datetime(df['日期'], format='%Y-%m-%d', errors='coerce') print(df['日期'].isna().sum()) # 解析失败的条数 # 提取时间特征 df['月份'] = df['日期'].dt.month df['星期'] = df['日期'].dt.dayofweek df['是否周末'] = df['日期'].dt.dayofweek >= 5

.dt访问器是日期列专用,dt.year、dt.quarter、dt.day_name()、dt.is_month_end这些都很常用。要注意它只能用在 datetime64 类型的列上,object 类型的列会报 "Can only use .dt accessor with datetimelike values",遇到这个报错就是忘了先转换。

7.2 resample 与 rolling 的区别

这两个经常被混淆,因为都跟"时间窗口"有关,但语义完全不同。

resample是改变采样频率,把细粒度数据聚合成粗粒度,行数会变少。按天数据聚合成按周,一周一行。它的用法和 groupby 几乎一样,只是分组键换成了时间频率。

daily = df.set_index('日期')['金额'].resample('W').sum() monthly = df.set_index('日期')['金额'].resample('M').agg(['sum', 'count', 'mean'])

rolling是滑动窗口,行数不变,每一行都算出它之前 N 个点的统计量。做移动平均、累计计算、波动率都用它。

df['7日均'] = df['金额'].rolling(window=7, min_periods=1).mean() df['30日累计'] = df['金额'].rolling(window=30, min_periods=1).sum()

min_periods这个参数很关键。默认情况下窗口不满就返回 NaN,前 6 行全是 NaN。设成 1 表示"有几个算几个",适合数据量少或者不想丢掉开头的场景。但要注意,开了min_periods=1之后前几个点的均值波动会很大,因为它基于的样本太少,做趋势判断时要心里有数。

8. 性能与常见报错:几个必须知道的真相

8.1 SettingWithCopyWarning 到底在警告什么

这个警告几乎是每个 Pandas 使用者都会遇到的,但真正理解成因的人不多。它警告的核心是:你正在对一个可能是副本、也可能是视图的对象赋值,Pandas 无法保证赋值生效到原表。

触发场景基本都是链式索引:

# 触发警告:先选出子表,再对子表赋值 subset = df[df['金额'] > 100] subset['等级'] = '高' # 正确写法一:用 .copy() 明确要副本 subset = df[df['金额'] > 100].copy() subset['等级'] = '高' # 正确写法二:一次 .loc 完成筛选和赋值 df.loc[df['金额'] > 100, '等级'] = '高'

第二种写法是首选,因为它只有一次索引操作,Pandas 明确知道你在改原表。第一种写法也不差,copy()之后改的是独立副本,语义清晰,只是多占一份内存。

要彻底避免这个警告,还有两个设置:pd.options.mode.copy_on_write = True(2.0 之后的推荐方式,能从根本上解决视图/副本的歧义),或者在有明确意图时用chained_assignment = None关掉警告——但我不推荐后者,因为它是把报警器关掉,不是把火灭了。

8.2 内存优化:三招省下七成内存

大数据集跑不动,很多时候不是 CPU 不够,是内存被浪费了。df.memory_usage(deep=True).sum()能看到真实占用,优化空间通常很大。

第一招,category类型。字符串列如果唯一值占比低(比如省份、性别、状态),转成 category 能省 90% 以上内存。但注意分组时配合observed=True,否则交叉组合会爆炸。

for col in ['城市', '等级', '状态']: if df[col].nunique() / len(df) < 0.05: df[col] = df[col].astype('category')

第二招,数值降采样。int64 转 int32、float64 转 float32,只要精度够用就换。金额这类要精确计算的慎用 float32,会有精度损失,但用于特征工程的中间结果问题不大。

df['数量'] = pd.to_numeric(df['数量'], downcast='integer') df['比例'] = pd.to_numeric(df['比例'], downcast='float')

第三招,分块读取。文件比内存还大的时候,read_csv(chunksize=100000)返回迭代器,一块一块处理再汇总。这个技巧配合groupby做增量聚合特别好用。

total = {} for chunk in pd.read_csv('big.csv', chunksize=200000): for k, v in chunk.groupby('城市')['金额'].sum().items(): total[k] = total.get(k, 0) + v

8.3 常见问题速查表

把上面散落的坑汇总一下,方便遇到问题直接对照。

症状最可能的原因快速定位解决
数值列求和变成字符串拼接列里有非数字字符,dtype 为 objectdf[col].map(type).value_counts()pd.to_numeric(errors='coerce')
merge 后行数翻倍右表键不唯一df_right[key].duplicated().sum()去重或validate='m:1'
merge 后整列变 NaN两边键 dtype 不一致df.dtypes连接前统一astype
groupby 结果少了一部分键有 NaN,dropna=True默认丢弃比较聚合前后行数和dropna=False
groupby 结果行数暴涨category 键产生全组合检查键 dtypeobserved=True
.dt访问器报错列还是 object 类型df.dtypes先to_datetime
修改子表原表没变链式索引拿到的是副本看有没有 SettingWithCopyWarning用.loc一次赋值
pivot_table 全是平均值aggfunc默认'mean'检查参数显式写aggfunc='sum'
concat 后索引重复纵向拼接没忽略原索引df.index.is_uniqueignore_index=True
内存不够字符串占大头memory_usage(deep=True)转 category + 数值降采样

9. 几条在实际项目里磨出来的经验

第一条,清洗脚本一定要能重跑。我现在的习惯是把清洗逻辑写成函数,输入原始 DataFrame,输出干净 DataFrame,中间不修改原对象。这样做的好处是:发现某一步处理错了,改完重跑一遍就行,不用回溯找原始文件。而且函数化的清洗逻辑容易做单元测试,拿几行手工构造的边界数据跑一遍,比在几十万行数据里大海捞针快得多。

第二条,中间结果落盘,别怕占磁盘。清洗、聚合、合并每一步都存一份,看起来浪费,实际上省时间。因为下游分析发现问题时,你能立刻定位是哪一步引入的,而不用从头跑一遍全流程。我一般会存成 parquet 格式,比 CSV 小、读写快,还保留 dtype。

第三条,任何dropna和drop_duplicates之前,先打印一下会影响多少行。这个动作只花一秒钟,但能避免掉很多“为什么结果少了几万条”的困惑。我甚至会在关键步骤前后各打印一次len(df),形成流水账,事后对账一目了然。

第四条,别迷信一行流。链式调用写起来爽,但一旦报错,栈里指向的是最后一个方法,你得往回猜是哪一步出了问题。清洗逻辑复杂的时候,拆成多步、每步一个中间变量,调试成本低得多。等逻辑稳定了再考虑要不要合并。

最后分享一个小技巧:如果一个方法你不确定它的行为,别去猜,直接拿几行构造数据试。pd.DataFrame({'a': [1, None, 3]})这种三行数据,两秒钟就能验证fillna的行为、groupby的默认参数、merge的匹配规则。这比翻文档快,也比在真实数据上试错安全。这个习惯我保持了几年,到现在遇到不常用的参数还是会先造两行数据跑一下,比什么都靠谱。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进