Pandas数据处理实战:掌握DataFrame、groupby与merge核心用法

发布时间:2026/10/20 3:01:53
Pandas数据处理实战:掌握DataFrame、groupby与merge核心用法 1. Pandas排在一堆表格折腾什么名堂——先用一个真实场景把底层需求讲透大概三年前我处理一份十几万行的渠道订单明细时一开始用的是纯Python循环加字典统计。那段代码跑起来慢得离谱一个分组求和能转好几分钟而且写着写着就乱套一会儿是for row in rows一会儿把列表套进列表后面接手的人看了直接想骂人。后来一个同事递给我一句话“别硬写了用pandas。”当时我对pandas的理解还停留在“一个能存表格的库”真上手之后才意识到这东西根本不是“存表格”那么简单。它是把“对表格数据的思考方式”整个换了一个维度从“逐行遍历、逐个判断”换成了“整列运算、一次性广播”。难听点说以前你在给Excel写宏之后你在用一种更接近数据库的方式操作内存里的表。这篇文章的目标读者我默认是这么几类人Python基础语法已经过关、写过几十行处理数据的代码但总觉得别扭的同学已经从文本文件或者接口里拿到过一批数据、想要做清洗聚合但不知道该怎么组织的朋友还有那些看了不少教程、能跟着敲出df.groupby(城市).mean()但内心其实不明白“为什么groupby之后不直接返回一个DataFrame”的人。如果你的水平已经能熟练操作DataFrame的索引切片、合并、透视那这篇对你来说前面几节可以快速扫一眼重点看后面关于groupby底层逻辑重建、merge易错点、以及性能习惯的部分——那才是这篇文章里我认为最值钱的内容。先说清楚pandas到底“解决了什么问题”。用一句话概括Python原生语法处理表格数据时代码复杂度和数据量呈线性恶化pandas把二维表抽象成带轴标签的结构让行、列、标签、值四者之间的操作可以批量完成。这句话拆开有两层意思。第一层Python原生处理二维表本质上是在“列表套列表”或者“列表套字典”里手动维护下标关系。你想要“按城市分组算平均销售额”你得先找出全部城市再为每个城市开一个列表往里塞数值最后再对每个列表求均值。这套动作写出来大概二十行跑起来效率还低因为每次都重新扫描。第二层pandas把“行”和“列”都给了名字索引和列名操作的时候不用再记着“第零列是城市、第三列是销售额”而是直接说“我要处理df[销售额]这一列”。当你想把两组数据按某个键合并在一起直接merge当你想把长表拉成宽表直接pivot_table。这些操作每一件都相当于原生语法下十几行到几十行的体力活。所以我在带新人的时候经常说别看pandas的API又多又杂真正理解它的门槛只有一个元认知——所有操作都可以想象成“整条数据流在管道的各个节点上被变换”而不是“一行一行地手动加工”。这个开头的结论如果大家能消化掉后面所有章节都变得顺理成章。2. Series与DataFrame两个底层结构得掰开揉碎——对轴的理解决定你会不会用用pandas的第一步永远是搞清楚它的两个核心容器长什么样。我见过不止一个新手把DataFrame当Excel表格理解之后就万事大吉结果遇到axis0和axis1就开始蒙圈遇到set_index和reset_index就乱套。说到底就是没有真正理解“轴”这个概念。2.1 Series不是“一列数字”是“带标签的一维数组”Series最简单的理解是一个“带标签的数组”。它有两个组成部分左边的index索引和右边的values值。普通Python列表[10, 20, 30]只有值想访问第二个元素只能用位置values[1]Series则允许你给它每个元素起个名字import pandas as pd s pd.Series([10, 20, 30], index[一月份, 二月份, 三月份]) print(s[二月份]) # 20看起来很简单的功能但它的意义是革命性的你不再依赖“位置”来访问数据而是依赖“语义”来访问数据。这个转变真的很关键因为真实业务里的数据顺序经常变化但如果每个值都带着自己的标签无论怎么排序、过滤你只要按照标签取数就不会取错。Series的索引不要求是唯一的。两个索引可以相同此时用s[二月份]会返回一个小Series而不是标量这个特性在处理重复标签数据时非常有用。初始学习阶段我不建议去深究重复索引的取数逻辑但是心里要有数不要以为索引就是主键。Series和Python字典的区别也值得说一下。字典是“键值对的容器”Series是一维带标签数组它除了取数之外还支持算术运算、统计函数、自动对齐。比如两个Series相加pandas会按照索引自动对齐而不是简单地把数值按位置相加a pd.Series({x: 1, y: 2}) b pd.Series({y: 3, z: 4}) print(a b) # x NaN # y 5.0 # z NaN这里就产生了一个非常核心的概念数据对齐。两个数据集的索引没有重叠的部分运算结果会给NaN缺失值而不是报错。这个特性从Series延伸到DataFrame是pandas区别于纯数组计算的最重要机制之一。2.2 DataFrame的二维本质一串Series拼成的表DataFrame的官方定义是“带标签的二维数据结构”听起来抽象但你可以把它想象成“一个由多个Series组成的字典”每个Series就是表里的一列Series的索引就是DataFrame的行索引。很多人忽略了一件事DataFrame的行索引和列索引是两个独立的概念。列索引是列名行索引默认是0到n-1的整数但你可以把任何一个有业务含义的列设成行索引比如日期、订单号这就是set_index干的事情。我在实际项目里最常用的一个习惯是拿到数据后先看df.info()确认列名、非空值数量和数据类型再看df.head()观察样例数据然后立刻判断哪一列适合做行索引。这一步看着简单却决定了之后所有操作的顺滑程度。比如时间序列数据我一律把日期列设为索引这样后续按时间切片直接df[2024-01:2024-03]就完成了比先用布尔掩码筛选要舒服得多。DataFrame的另一个特点是可以容纳多种数据类型一列是整数一列是字符串一列是浮点一列是日期对象完全没问题。整张表在底层被拆成多个独立的一维数组按列存储每列类型独立优化这也是为什么DataFrame能高效处理异构表格数据的原因。2.3 索引、切片、布尔过滤——最常用却最容易混淆的操作组DataFrame的取数操作大概可以分三类。第一类是按列取df[列名]返回Seriesdf[[列名1, 列名2]]返回DataFrame注意中括号里面是单引号还是列表返回结果完全不一样。这里的坑是你写df[列名]得到的是Series但有些函数比如apply处理Series没问题处理DataFrame时需要额外的轴参数很多人第一次踩坑就踩在这里。第二类是按行取.loc[]按行索引的标签取.iloc[]按行索引的位置取。这两个我必须建议所有初学者强制记忆区分loc的括号里写的是“标签”或者“布尔数组”iloc的括号里写的是“整数位置”。比如df.iloc[0]永远是第一行而df.loc[0]取的是行索引标签为0的那一行——如果行索引不是整数df.loc[0]很可能直接报错。第三类是布尔过滤df[df[销售额] 10000]里面那一整串df[销售额] 10000会生成一个布尔Series再把它作为筛选条件。这张“布尔面具”是pandas选项式数据筛选的灵魂几乎所有复杂筛选最终都归结为“构造一个正确长度的布尔数组”。我把这三类操作称为“取数的三板斧”大多数业务需求——比如“保留华东区域且销售额超过1万的订单里的前五十条”——就是这三类操作的组合。很多人问过我为什么pandas要搞出[]、.loc、.iloc三种取数方式直接用一种统一的不行吗答案是历史原因加实用性考量。[]是Python对象的原生方法语法简洁但容易歧义到底是取列还是取行看参数类型.loc和.iloc是pandas后来强化的显式接口一眼就能看出你是按标签取还是按位置取。面对正在运行的生产代码我强烈的建议是能写df.loc[df[城市] 上海, 销售额]就千万不要写df[df[销售额]][城市]这样语义模糊的链式取数后者不仅可读性差还可能触发不可预期的视图/复制问题。3. 数据读进来先别急着分析——数据读写的关键参数和清洗环节必须过一遍pandas最强能打的地方不是“存数据”而是“把各种格式的数据拉进来变成DataFrame”。CSV、Excel、JSON、SQL、剪贴板都有一行读入的接口。看起来区别就是read_csv和read_excel不同函数但真正决定后续操作顺不顺利的是读入时那些容易被跳过的参数。3.1 read_csv里的几个真正值钱的参数pd.read_csv(路径/文件名.csv)是最基础的用法但实际业务里一份CSV文件很难这么干净。最常见的坑是编码问题。一个CSV文件可能是utf-8可能是gbk也可能是带BOM的utf-8-sig。读出来乱码的时候换编码是个笨办法更靠谱的是先看一眼文件前几行或者直接用file命令看一下编码。我的习惯是读取时不指定encoding先报错或乱码再用encodinggbk试再不行试encodingutf-8-sig。第二个高频参数是parse_dates。如果你的数据里有一列是“2024-06-01 12:30:00”这种字符串默认情况下它会被读成object类型后面的时间运算比如减一天、按周分组统统做不了。你需要提前告诉pandas把它解析成时间类型df pd.read_csv(订单.csv, parse_dates[下单时间])这样读进来的df[下单时间]才是datetime64类型才可以直接做时间差计算、按月聚合、画时间序列图。这一步是很多人“数据看起来没错但就是没法分析”的根源。第三个参数是usecols。一张十几列的表你只需要其中四五列完全可以在读取时只保留需要的列大幅减少内存占用也能让数据预览更清爽。还有nrows大文件调试的时候先读前一千行验证代码逻辑别动不动就全量读入。第四个常用参数是dtype。有些列是ID类型但保存成了数字比如“订单编号1234567890”默认读成int64但真实的业务编号应当作字符串处理因为它们不参与数学运算甚至可能被pandas的数值推断误伤比如编号“00123”读进来变成123丢失了前导零。此时读入时指定dtype{订单编号: str}就可以保持原样。3.2 缺失值、重复行和类型转换——清洗三板斧读进来的数据很少直接能用于分析通常逃不掉三类问题。缺失值是最大的一类。一个DataFrame里出现NaN要么是数据本身就没采集到要么是在合并时对齐失败产生的。应对手段就两个方向扔掉或者填上。dropna()默认丢到有缺失值的行fillna()可以用默认值、前一条值、后一条值或者该列均值填充。什么时候该填、什么时候该丢没有绝对标准只能根据分析目的来。比如算销售额总和的时候如果某行的数值列缺失保留不填会导致总和偏小但如果该行其他列信息还有用直接整行删除又可惜。这时候可以先填充0再做汇总或者先做汇总再单独评估缺失的影响。重复行的处理很多人会用df.drop_duplicates()但我要提醒一个细节这个函数默认判断的是整行完全相同才算重复。如果你只想按“订单号”去重要写df.drop_duplicates(subset[订单号])。另外还有一个选择是keep参数保留第一条重复记录、最后一条还是都不保留。业务里常用的场景是“同一订单出现多次配送记录只取最后一次状态”这就是keeplast。类型转换是隐藏的坑王。df[金额]看起来是数字但df[金额].dtype可能是object因为原始数据里混入了一个空字符串或者“12,000”这样的带逗号文本。此时astype(float64)直接报错正确的处理方式是先把这类字符清洗掉用pd.to_numeric(series, errorscoerce)把无法转换的非数字强制变成NaN然后再统一填充。3.3 数据写出的注意事项——别让辛辛苦苦清洗完的数据载在最后一步跟读取对称写出也有几个常见坑。df.to_csv(结果.csv, indexFalse)是必须养成习惯的基础操作不然你的输出文件里会莫名其妙多出一列Unnamed: 0白白浪费别人下载文件后第一轮洁癖发作的时间。如果需要保存带格式的Excel多工作表可以用pd.ExcelWriterwith pd.ExcelWriter(结果.xlsx) as writer: df1.to_excel(writer, sheet_name汇总, indexFalse) df2.to_excel(writer, sheet_name明细, indexFalse)ExcelWriter的好处是能把多张表写进同一个工作簿这在交周报、出报表场景下非常实用。还有一个很少被提到但特别影响体验的细节写出CSV时本身含有中文的DataFrame最好指定encodingutf-8-sig。普通utf-8在部分版本的Excel里打开会乱码utf-8-sig多带一个BOM头Excel能正确识别中文编码。这个小改动在跨部门协作时能省掉无数个“为什么我打开是乱码”的问答。数据清洗这块我想强调的是流程意识。不要指望一段代码解决所有脏数据问题。我自己的惯例是读入数据后在项目笔记本里先做一版“体检”——打印列名、行数、缺失值统计、各列类型再动手清洗。体检结果决定了清洗策略而不是凭着感觉一顿dropna瞎操作。清洗过程的每一步最好都另存一个新列名或者新DataFrame保留原始数据随时回溯这也是经验之谈。4. groupby拆分合并的那一套逻辑才是Pandas的灵魂——搞清楚它在底层究竟干了什么如果让我只选一个pandas里最值钱的功能我会毫不犹豫选groupby。它是“分组-聚合”这套数据思维在Python里最经典、最优雅的实现也是很多从Excel转过来的人最难理解透彻的一块。4.1 groupby不是“一个函数”是“拆分-应用-合并”三步范式很多教程讲groupby一笔带过告诉你“按城市分组、求平均销售额用这个就行了”但完全没有解释为什么df.groupby(城市).mean()能直接得到结果。如果你的理解也停留在这个层面一旦遇到更复杂的需求比如“每个城市、每月、各品类合计”立刻就会卡住。groupby的底层逻辑是“拆分-应用-合并”英文叫 split-apply-combine。第一步是按分组键把数据拆成若干个小组第二步是把你指定的聚合函数应用到每个小组上第三步是把所有结果合并回一张表。举个例子df.groupby(城市)[销售额].mean()这个表达式就可以展开成三个动作按“城市”列把整张表分成若干子DataFrame每个子表包含某一个城市的所有行从每个子表里取出“销售额”这一列计算平均值最后把每个城市的平均值汇总成一个索引为城市名、值为平均销售额的Series。所以groupby之后如果不紧跟着聚合函数它并不会返回一个你肉眼可见的结果而是一个GroupBy对象——你可以把它理解成“这堆数据已经被分组了但还没做任何计算”。如果你尝试print(df.groupby(城市))得到的是“DataFrameGroupBy”对象的描述而不是数据本身这就是很多新手困惑的点。4.2 agg这个名字不太好听但它是聚合操作的核心开关实际业务里的聚合需求往往不止一个函数既要总和又要均值还想要最大值、最小值甚至还想看数量有多少。你有没有想过df.groupby(城市)[销售额].agg([sum, mean, max, min, count])为什么能一次性算出这么多结果因为agg允许你传一个“函数列表”pandas会对每一列分别应用这些函数然后把结果拼成一张新DataFrame。列名默认是test_name_original这种(函数名, 原列名)的多级索引不太好看你可以在agg后面紧接着.round()或者重命名列名让结果变得直观。聚合时还有一个很容易被忽略的维度groupby的默认行为是“以分组键作为新DataFrame的索引”。如果你不希望城市名变成索引而是保持为普通列可以在groupby后加as_indexFalse。这个参数在后续merge或者to_excel的时候影响很大因为很多人不习惯看索引经常把索引列当成普通列操作结果出现KeyError。老实说我早期写代码就吃过这个亏groupby后的DataFrame索引列没还原直接to_csv输出结果缺了一列“城市”还找了半天。4.3 透视表与交叉表——多维分组的便利表达如果你觉得groupby之后还需要用unstack才能把多维分组变成宽表太麻烦那么pivot_table和crosstab就是专门为你设计的。pivot_table可以看作是“带维度布局的分组聚合”pd.pivot_table(df, values销售额, index城市, columns月份, aggfuncsum)这一行代码做的事情等价于先按城市和月份分组求和再把月份那一维给展开成多列。产出是一个二维表格行是城市列是月份格子是销售额非常直观。透视表底层仍然是groupby但它的优势在于处理的结果直接呈矩阵布局特别适合出报表和进一步画热力图。我的经验是如果要快速“扫一眼数据在各维度的分布形态”透视表比groupby更顺手如果是继续做链式分析比如过滤、合并、接下一步操作groupby的结果更合适。crosstab专门用于统计两个离散列的出现频数行是第一个变量的取值列是第二个变量的取值。它统计的是数量而不是数值的和所以在做“订单状态与支付方式的分布分析”时特别好用。它其实是pivot_table在aggfunccount场景下的快捷方式但用起来是真的方便。4.4 一个复杂的实战例子——把每一步都拆开看单独讲概念不如直接看一个贯穿的分组案例。假设我们的DF里有三列城市、品类、销售额要把每个城市下每个品类的销售额总和算出来并按城市总销售额排序。result ( df.groupby([城市, 品类], as_indexFalse)[销售额] .sum() ) city_total result.groupby(城市, as_indexFalse)[销售额].sum() city_total.columns [城市, 城市总销售额] result result.merge(city_total, on城市) result result.sort_values(城市总销售额, ascendingFalse)第一步按两个键分组得到每个城市-品类的细粒度总和第二步按城市再聚合一次得到每个城市的总盘第三步把这两个结果按城市合并起来让每行既能看到品类的贡献也能看到城市的体量最后按城市总销售额排序。这样一张结果表就能直接回答“哪些城市的哪个品类贡献最大、哪些城市的盘子本来就大”这类问题。这个例子想说明的是groupby可以连续运作先细粒度分组再粗粒度分组最后通过merge把不同层次的结果合并。掌握这个链路比学一百个孤立函数都管用——因为大多数业务分析都是由“不同粒度的汇总指标”组合而成的。5. 连接合并与重塑——想把多张表玩成一张表必须读懂merge的几个隐藏规则做数据分析的人迟早会遇到这样一个场景你有两张表一张是订单明细订单号、商品、金额、时间另一张是客户信息客户ID、地区、等级要把两张表按客户ID对应起来。这就是“表连接”pandas里最常用的方法是merge和concat。5.1 merge的四种连接方式——on、how、left_on、right_on到底怎么配pd.merge(df1, df2, on客户ID)是最标准的写法意思是“两张表都有一列叫客户ID以这一列相等作为条件把两行拼成一行”。on只能用在两边的键列名完全一致的场合如果不一致就要用left_on和right_on分别指定pd.merge(订单表, 客户表, left_on客户编号, right_on客户ID, howleft)这里的how参数是另一大重点。它是把哪张表作为基准howinner只要两边匹配得上的记录交集howleft保留左边表的所有行匹配不上的右边字段填充NaNhowright保留右边表的所有行匹配不上的左边字段填充NaNhowouter两边的全部记录都保留匹配不上的填NaN实际业务中使用频次最高的是left。比如“所有订单都保留客户信息能查到就带出来查不到也不丢订单”这种需求正好对应howleft。如果你误用了inner那客户信息缺失的订单会整个消失结果表行数变少后续汇总全是错的。这个错误我用肉眼都见过好多次教训很深。5.2 merge时两个最常见的翻车现场——键不唯一和数据膨胀第一个翻车现场是键不唯一。假设订单表里同一个客户有10笔订单客户表里这个客户只有一行信息这时候merge很聪明会给这10笔订单都复制一份客户信息结果行数不变。但反过来就危险了如果客户表里同一个客户出现了2行比如一个人维护成两行等级信息不一致那订单表里这个客户的10笔订单会和客户表的2行进行笛卡尔积变成20行。行数不可思议地膨胀了而你往往过了很久才发现总和怎么翻倍了。这类问题很难用肉眼察觉最管用的排查手段是merge之前对键列检查唯一性print(客户表[客户ID].duplicated().sum())一旦发现客户表有重复ID先去掉重复再merge。这个习惯真的救了我很多次。第二个翻车现场是merge后出现名称冲突的列。pd.merge(订单表, 客户表, on客户ID)如果两张表里都有“创建时间”这一列pandas会自动把它们改名为“创建时间_x”和“创建时间_y”。这种列名毫无可读性后续你计算时间差时特别容易拿错列。解决方案是merge前用suffixes参数指定后缀或者干脆在merge前把重复列名rename掉。5.3 concat和merge的区别——一个按行堆一个按列拼pd.concat([df1, df2])默认是按“行方向”往下堆要求两表的列名一致。它的典型场景是把多个月份的数据文件读进来后纵向拼接成一张全年总表。pd.concat([df1, df2], axis1)则是横向拼接像在Excel里把两张表并排放连接的条件是行索引对齐。concat不关心行之间的“业务关联”它只做物理上的拼接。所以什么时候用merge、什么时候用concat判断标准很简单是否需要根据某个键把两张表的行对应起来需要就用merge只是把结构相同的几块数据连起来就用concat。5.4 长表宽表互相转——pandas里tap最容易被小看的换形能力除了连接重塑是另一种高频操作。Excel里有“数据透视表”pandas里对应的就是上面提到的pivot_table反过来宽表变长表的操作叫melt。melt的使用场景很典型你手上有一张表每一列是不同月份的销售额1月、2月、3月...但做时间序列分析时需要一个“长表”每行是一个日期点的单一数值。这时long_df df.melt(id_vars[城市], value_vars[1月, 2月, 3月], var_name月份, value_name销售额)id_vars是需要保留不变的标识列value_vars是哪些列要被“折叠”成两列一列是变量名一列是变量值。这个函数在处理多期数据的业务报表时非常常用但教程很少重点讲。stack和unstack也是表形转换的工具它们是围绕“索引的层级”做变换的功能上跟pivot_table有重叠但适用场景稍微专业些。日常数据分析中melt和pivot_table的组合基本可以覆盖九成需求不用非得把四个函数全都用熟。6. 写了半年Pandas之后我几乎不再用apply了——性能优化的两三个核心习惯文章接近尾声我想聊点“API文档上不会写、但生产环境迟早教你做人”的经验。很多新手学了apply之后非常兴奋可以传Lambda表达式对每一行做自定义操作灵活又方便。这个函数确实很好但也是最容易让代码变慢的元凶。apply本质上仍然是“逐行或逐列Python循环”它的形式很简练但性能远不如内置的向量化操作。pandas的设计初衷是尽量把运算下沉到底层C或NumPy的数组级别而不是每次在Python层跑循环。比如“对销售额每行加10%提成再做向下取整”正确做法是df[调整后] np.floor(df[销售额] * 1.1)这里的*1.1和np.floor都会沿着整个Column一次性向量化执行几十万行数据瞬间完成。而这段代码用apply写df[调整后] df[销售额].apply(lambda x: math.floor(x * 1.1))写法一样短性能可能慢几十倍。另一个被忽视的性能点是用好query和eval。如果你的DataFrame有几十列、几百万行而你需要做多条件筛选df[(df[销售额] 1000) (df[城市] 上海)]这种写法每一步都会生成一个中间布尔Series占用额外内存。改用df.query(销售额 1000 and 城市 上海)pandas会调用底层的表达式求值引擎在不用动整张表的情况下完成筛选内存和速度都有改善。还有一点是尽量别在循环里反复拼接DataFrame。我曾经见过一段代码在for循环里逐行pd.concat跑几百次就要好几分钟。正确的做法是把每一次的结果放进一个列表循环结束后一次性pd.concatpieces [] for chunk in chunks: result process(chunk) pieces.append(result) final pd.concat(pieces, ignore_indexTrue)一次拼接的开销远小于多次拼接的开销之和这个规则几乎适用于任何数据处理场景。内存方面如果你的表足够大读进来发现内存吃紧可以检查每列类型把那些取值种类很少的列转成category类型df[等级] df[等级].astype(category)类别型列在底层用整数编码存储能省下不少内存尤其当某个列只有十几个不重复值时效果拔群。性能优化这块归根结底就是一个观念转变**能用一次性向量化操作解决的就不要写循环能整列整列处理的就不要逐行逐行地去想。**你在Excel里拖动公式的时候也是拉一列一列的没有人会一行一行地双击右下角对吧pandas的思维方式跟这个完全同构。但我也不是完全否定apply。当逻辑确实需要访问多列、并且包含无法向量化的条件分支时apply是合理的工具。关键是要有取舍意识先问自己“这个操作能不能拆成纯数组运算”能就绝不apply。这篇文章里讲的都是平时最容易碰到、也最容易出问题的点从Series和DataFrame的轴概念到读数和清洗习惯再到groupby的拆分-应用-合并本质最后是merge的键和性能习惯。我个人这几年的体会可以浓缩成一句pandas不是一个需要背API的库而是一套“把表格思考方式搬进代码里”的思维框架你越早建立这个框架后面遇到的每一个数据问题都会轻松很多。