ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Pandas数据处理核心技巧:从Series/DataFrame到分组聚合与性能优化

Pandas数据处理核心技巧:从Series/DataFrame到分组聚合与性能优化

1. 为什么要用Pandas做数据处理:一个老Python用户的心路历程

最早接触数据操作的时候,我用纯Python写循环处理几千行数据,当时觉得CSV就够用了。直到某天接手一份几十万行的业务流水,那次等程序跑完结果的过程让我彻底意识到,纯Python处理数据的方式在这个量级下根本不现实。换个思路用Pandas之后,同样的统计需求从几分钟缩短到了几秒,这个反差让我再也没回头写过手工循环。

Pandas解决的核心问题,就是让“二维表数据的读入、清洗、筛选、聚合、输出”变成一套有标准接口的操作流程。它的定位非常明确:内存中的结构化数据处理工具,重点在于"怎么方便地拿到你想要的那批数据"和"怎么对这批数据做快速的统计变换"。它不擅长分布式大数据处理,也不应该被拿去替代数据库做持久化存储,但在单机数据分析和预处理阶段,基本是绕不开的默认选项。

这篇文章不打算做成文档翻译的合集,而是想把我在实际项目里最常用的那套思路拆开讲清楚。核心数据结构Series和DataFrame到底是怎么组织的、索引的底层逻辑是什么、分组聚合和表格合并的典型玩法、以及那些踩过坑之后才明白的性能细节,都会结合真实场景来说明。适合刚接触Pandas的新手通读一遍建立全景认知,也适合已经写过一些但总感觉逻辑不够清晰的人对照查漏补缺。

2. 从底层理解Series和DataFrame:看懂这两类对象才算入门

2.1 Series是带标签的一维数组,关键在索引对齐

很多人把Series理解成“Python列表加了个索引”,这样理解不算错,但会漏掉它最核心的机制:索引对齐。创建一个Series最常见的做法是传入一个字典,键自动成为索引,值成为数据:

import pandas as pd score_series = pd.Series({"数学": 92, "语文": 88, "英语": 95}) print(score_series) # 输出: # 数学 92 # 语文 88 # 英语 95 # dtype: int64

此时score_series["数学"]拿到的是92,score_series.values拿到的是NumPy数组[92, 88, 95]。可以看到,Series内部实际由两块组成:一块是索引对象index,一块是数值部分values。索引的意义不仅在于"给每个值起个名字",更在于操作时系统会按索引自动匹配。

举个例子,我有两个Series分别表示两个班部分学生的成绩:

class_a = pd.Series({"张三": 90, "李四": 87, "王五": 78}) class_b = pd.Series({"李四": 92, "王五": 80, "赵六": 86}) total = class_a + class_b print(total) # 输出: # 张三 NaN # 李四 179.0 # 王五 158.0 # 赵六 NaN # dtype: float64

注意这个结果:张三和赵六变成了NaN。原因就是Pandas在做加法时,不是按位置逐项相加,而是按索引标签对齐——只有两边都存在的索引才会被计算,单边出现的索引在另一边视为缺失值。这个行为是Pandas整个库的基石,也是新手最容易困惑的地方。习惯了它之后,你会觉得这比按位置相加合理得多,因为数据在真实世界里本来就是"按名字对齐"的。

2.2 DataFrame是带行列双重标签的二维表,本质是Series的容器

DataFrame可以理解成一组共享索引的Series被并排放在一起。每一列是一个Series,所有列共享同一个行索引。这种设计带来的直接好处是:整列操作与单列操作在API上高度统一。

最常见的创建方式是传一个字典,字典的键是列名,值是列表或Series:

import numpy as np df = pd.DataFrame({ "姓名": ["张三", "李四", "王五"], "语文": [90, 87, 78], "数学": [85, 92, 88], "是否及格": [True, True, True] }) print(df)

列与列之间的数据类型可以不同,这点和NumPy的二维数组有本质区别——NumPy强调同质,Pandas只要每列内部类型一致就可以。查看基础信息用以下三个方法:

df.shape # 行数和列数,返回元组 df.dtypes # 每列的数据类型 df.info() # 整体信息:索引范围、列名、非空值数量、内存占用

df.info()在诊断大表时非常实用,它直接告诉你每列有多少个非空值,空值一眼就能看出分布情况,内存占用也能帮你判断是不是该考虑数据裁剪了。

2.3 索引机制的三个层次:标签索引、位置索引、布尔索引

Pandas面试题里出现频率极高的一句话是“loc和iloc有什么区别”,实际工作中这句话同样关键。把所有索引方式归拢起来,其实就是三个层次。

第一层是标签索引loc,按行索引名和列名取数据:

df.loc[0, "语文"] # 第一行、语文列 df.loc[0:1, ["语文", "数学"]] # 注意索引标签的切片是闭区间,包含端点

第二层是位置索引iloc,按行号和列号取数据:

df.iloc[0, 1] # 第0行第1列 df.iloc[:, 0:2] # 所有行,前两列,位置切片是左闭右开

第三层是布尔索引,本质是筛选,用一个布尔Series作为遮罩:

df[df["语文"] >= 85] # 只保留语文成绩不低于85的行

这里有个极其常见的坑:用df[df["语文"] >= 85]["语文"]能选出成绩列,但会触发链式赋值的隐患;正确做法是用df.loc[df["语文"] >= 85, "语文"]一次到位。务必要养成这个习惯,因为它直接影响你是否会遇到SettingWithCopyWarning警告。这个警告的意思是:你正在试图通过一个副本去修改数据,结果可能根本改不到原表上。规避方法很简单,筛选加赋值全部走loc,不要分两步走。

提示:loc的切片是闭区间(包含结束标签),iloc是左闭右开(不包含结束位置),这是新手最常记混的规则。

3. 读入数据到完成清洗:一份表格从磁盘到可分析状态的完整链路

3.1 read_csv的正确姿势:编码、分隔符、类型指定

实际业务中拿到的CSV远比教科书里的规整,带BOM头的、以分号分隔的、日期列被读成字符串的,样样都有。read_csv最常用的参数组合是这样的:

df = pd.read_csv( "data.csv", encoding="utf-8-sig", # 带BOM的Excel导出文件尤其需要 sep=",", # 如果是分号分隔就换成";" dtype={"用户ID": "str"}, # 避免ID被读成科学计数法 parse_dates=["下单时间"], # 指定哪些列需要解析为时间 thousands="," # 数字列中包含千分位逗号时指定 )

强调一下dtype参数。CSV里的用户ID如果是纯数字,Pandas默认会读成int64,一旦ID长度超过16位,就会变成科学计数法并丢失精度。更稳的做法是直接写成dtype={"用户ID": "str"},从源头杜绝精度问题。很多人等到数据已经读进来才发现ID变了样,再回头重新读,白白浪费调试时间。

3.2 空值处理的三个维度:怎么发现、怎么判断严重性、怎么填充

发现空值用df.isnull().sum(),这个组合能输出每列缺失值数量。进一步分析缺失比例用df.isnull().mean(),返回的是每列缺失值占比。

判断严重性这件事经常被忽略。一个10万行的表,某列缺失500行,占比0.5%,直接删掉这些行通常没有影响;但如果某列缺失率达到30%,你就要思考这列数据是不是本来就不是必填项,直接删除整列可能比填充更合理。处理策略可以按这张表来:

情况推荐做法理由
缺失比例极低且为随机缺失df.dropna()删除缺失行不会引入错误信息
数值列且分布大致均匀df["列"].fillna(df["列"].median())中位数对异常值不敏感,比均值更稳
时间序列数据df["列"].ffill()或.bfill()用相邻值填充更符合时序规律
类别列单独填充为"未知"标签保留缺失状态本身可能携带信息

这里特别强调一点:不要一上来就fillna(0)。0在很多场景下是有实际意义的数值,填充之后会污染统计结果。比如薪水列,空值填充0和实际薪水为0是完全不同的语义,后者可能表示离职或其他状态,前者只会把你的均值拉得离谱。

3.3 类型处理与重复值:为了让数据真正"可被计算"

重复值用df.drop_duplicates(subset=["订单号"], keep="first")就能处理。subset参数指定判断重复的列,keep参数控制保留哪一个。不少人会忽略subset,导致整行完全一致才被去重,而真正需要按业务主键去重的场景往往只需要几列相同就判定重复。

类型转换是另一个高频操作。Pandas读取数据后,日期列经常是字符串,数值列里混着"1,234"这样的文本,需要逐个清理:

df["下单时间"] = pd.to_datetime(df["下单时间"]) # 字符串转日期 df["销售额"] = df["销售额"].str.replace(",", "").astype(float) # 清理千分位再转数值 df["是否VIP"] = df["是否VIP"].map({"是": 1, "否": 0}) # 类别映射为数值

还有一个常用技巧:批量查看列类型。df.select_dtypes(include=["object"])能快速找出所有文本列,数据清洗的下一步往往就是处理这些列。

注意:如果某列在.astype("int64")时报错,多半是里面混入了无法转换为整数的内容,比如空字符串或"未知"。先用pd.to_numeric(df["列"], errors="coerce")把它强制转成数值,无法转换的部分会变成NaN,之后再统一处理。

4. 分组聚合与多表连接:从"看到数据"到"得出结论"的实战跳板

4.1 groupby的语义拆解:Split-Apply-Combine三步曲

Pandas的分组聚合思想可以用一句话概括:先按某列分组,再对每组应用函数,最后把结果合并回来。这就是经典的Split-Apply-Combine模式。理解了这个模式,groupby的用法就不难记了。

假设有一份销售明细,包含城市、品类、销售额三列,想要各城市各品类的总销售额:

result = df.groupby(["城市", "品类"])["销售额"].sum().reset_index()

这里groupby(["城市", "品类"])完成了Split,["销售额"].sum()完成了Apply,reset_index()把分组索引转回普通列,相当于Combine之后整理输出。

更复杂的需求可以用agg方法一次性传多个聚合函数:

stat = df.groupby("城市")["销售额"].agg(["sum", "mean", "count", "max"])

返回的stat里,行是城市,列是聚合函数名,直接就能拿去画图或写报告。

如果你需要对不同列应用不同的聚合方式,比如销售额求总和、订单数求总数、用户ID统计去重数量,用字典传入agg:

stat = df.groupby("城市").agg({ "销售额": "sum", "订单数": "sum", "用户ID": "nunique" })

4.2 transform与apply的差异:什么时候该用哪个

agg的结果是“压缩后的汇总表”,行数会变少;而transform保持原行数不变,把计算结果广播回每一行。典型场景是计算每笔订单占所在城市的销售额比例:

df["城市销售额"] = df.groupby("城市")["销售额"].transform("sum") df["占比"] = df["销售额"] / df["城市销售额"]

这里如果用agg,结果会变成一个短表,没法原样赋回给每个订单行。记住一句话:要汇总结果用agg,要逐行填充用transform。

apply则是更通用的螺丝刀。agg只能应用预设聚合函数或接受单列函数,apply可以接受任意函数并保留更多灵活性。不过apply的性能通常低于向量化或transform,能用内置聚合用内置,能向量化操作就别轻易上apply,这是Pandas性能优化的第一原则。

4.3 merge连接表:理解键的匹配逻辑比记参数更管用

多表连接最典型的场景是把订单表和用户表通过用户ID合并。merge的经典写法:

merged = pd.merge( orders_df, users_df, on="用户ID", how="left" # 左连接,保留orders_df全部行 )

how参数决定连接方式:inner只保留两边都匹配上的行,left保留左表全部行,right保留右表全部行,outer保留两边所有行,匹配不上的部分置NaN。实际业务里left用得最多,因为订单表通常是主表,用户信息是补充。

还有一种容易混淆的操作是concat,它负责的是"堆叠"而不是"匹配":

vertical = pd.concat([df1, df2], axis=0) # 上下拼接,要求列名一致 horizontal = pd.concat([df1, df2], axis=1) # 左右拼接,要求行索引对齐

concat是行或列层面的合并,merge是按列的值匹配行的关系型操作,两者解决的问题完全不同,放在一起对比记忆不容易混。

4.4 apply实现更复杂的逻辑:以客户分层为例

真实场景中,分组之后往往要做多条件判断。比如按用户累计消费金额分登记级别:

def customer_level(total): if total > 10000: return "高价值" elif total > 5000: return "中价值" else: return "普通" df["等级"] = df.groupby("用户ID")["销售额"].transform("sum").apply(customer_level)

这段代码先算出每个用户的总消费,然后逐行映射成等级。对于初步分层来说够用,如果数据量大到百万行以上,这段apply会明显变慢,届时可以考虑用pd.cut配合分组结果做分箱,或者用NumPy的select替代,速度能快一个量级。

5. 性能优化与排查:解决“代码跑得太慢”和“结果不对”的两类老大难

5.1 向量化操作:把循环从代码里拿掉,性能问题就解决了一半

Pandas性能优化里最立竿见影的一条就是“别写循环”。对行做条件赋值时,新手倾向于用for循环逐行判断,但正确做法是直接用np.where:

import numpy as np df["销售额等级"] = np.where(df["销售额"] > 1000, "高", "低")

多条件时用np.select:

conditions = [ df["销售额"] > 5000, df["销售额"] > 1000, df["销售额"] > 0 ] choices = ["高", "中", "低"] df["销售额等级"] = np.select(conditions, choices, default="无")

这些写法本质上是C语言级别的向量循环,速度远快于Python层级的逐行迭代。另一个提升性能的小技巧是用pandas的category类型压缩重复文本列的内存占用。一个只有几个固定值的城市列,转成category后内存可以降低数倍,代码就一行:

df["城市"] = df["城市"].astype("category")

5.2 链式方法的取舍:可读性和性能怎么平衡

我喜欢把连续操作串成链式写法,比如一次调用完成筛选、分组、聚合、排序:

result = ( df[df["下单时间"] >= "2024-01-01"] .groupby(["城市", "品类"])["销售额"] .sum() .reset_index() .sort_values("销售额", ascending=False) )

这种写法表达能力很强,也很接近自然语言的顺序:先过滤,再分组,再聚合,再排序。但它有个缺点:链太长的时候,排查中间步骤就比较费劲。我的经验是,超过四个环节就拆成两个变量,中间加一行注释,比追求"一行到底"的炫耀式代码对维护更友好。

5.3 结果不对的常见元凶:索引错位、链式赋值、inplace误用

先看一个非常典型的错误场景:

df[df["是否VIP"] == 1]["平均消费"] = 0

这行代码在绝大多数情况下不会修改原始df,而是试图给一个临时副本赋值。解决方式在前面提过,用loc:

df.loc[df["是否VIP"] == 1, "平均消费"] = 0

索引错位是另一个隐蔽的问题。用条件筛选得到子表后,索引保持了原表的编号,如果直接对这个子表做reset_index()前的赋值运算,可能因为索引重叠而覆盖错误数据。排查思路很简单:凡是发现“结果对不上”“某列全是NaN”“明明筛选了却多出来很多行”,先检查索引。养成分组聚合后主动reset_index()的习惯,可以避免大量这类问题。

inplace=True这个参数建议少用。很多老教程推荐df.dropna(inplace=True)这种写法,但Pandas官方已经在逐步弃用这个参数的赋值语义。更好的习惯是写成df = df.dropna(),接受返回值,这样逻辑也更明确——你到底是在修改原对象还是替换引用,一目了然。

5.4 用一个小型项目串起所有技能:某公司销售订单分析

把前面所有内容串到一个场景里。假设手里有一份某公司销售订单表,字段包括订单号、用户ID、下单时间、城市、品类、销售额,目标是产出三个结果:各城市总销售额Top10、各品类月度趋势、高价值用户名单。

清洗和准备的链路如下:

# 加载并处理基本类型 df = pd.read_csv("orders.csv", dtype={"用户ID": "str"}, parse_dates=["下单时间"]) # 剔除无效订单:销售额为空或者为0的 df = df.dropna(subset=["销售额"]) df = df[df["销售额"] > 0] # 新增月份列方便按月聚合 df["月份"] = df["下单时间"].dt.to_period("M").astype(str) # 结果一:城市总销售额Top10 city_top10 = ( df.groupby("城市")["销售额"] .sum() .reset_index() .sort_values("销售额", ascending=False) .head(10) ) # 结果二:品类月度趋势 monthly_trend = df.groupby(["品类", "月份"])["销售额"].sum().unstack() # 结果三:高价值用户名单(累计消费Top100) user_value = ( df.groupby("用户ID")["销售额"] .sum() .reset_index() .sort_values("销售额", ascending=False) .head(100) )

这个案例覆盖了导入、清洗、派生列、分组聚合、排序截断这五个高频步骤。注意uni的用法——如果还要统计每个用户的订单数、首单时间、最近一单时间,就在agg里一次算完,不要分组三次再合并,那样既慢又容易出错。

6. 一些用久了才领悟的Pandas习惯

前面把Pandas的主要技能点过了一遍,最后想分享几条我踩过坑之后固化的习惯。第一条是永远先看df.info()再动手写逻辑。任何一份新拿到的数据,先用这条命令看列名、类型、非空数量、内存占用,比直接跑分析少踩一半的坑。第二是常量条件和切片全部走loc,既能避免链式赋值警告,也能让代码意图更清楚。第三是不要迷信一个技巧打天下,merge解决不了所有关系问题,有时候groupby之后用transform补列,再配合布尔筛选,反而是更简单的路径。

还有一条关于学习路径的建议:不要急着把所有函数背下来,把DataFrame、Series、groupby、merge、loc/iloc这五个核心概念弄透彻,剩下的函数都能靠官方文档解决。真正能提高效率的不是记住更多API,而是清楚自己手头的数据是什么结构、要变到什么结构、中间需要经历哪几步。

我个人的感受是,Pandas在数据处理链条里的位置有点像厨房里的案板——食材先在这里处理干净、改刀成形,才能交给下一个环节烹饪。理解透了它的数据结构和操作习惯,后面学任何处理引擎都会轻松很多。希望这篇指南能帮你把这块案板用得顺手。

RELATED READING

延伸阅读

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