掌握Pandas DataFrame:数据分析主战场的核心操作实战

发布时间:2026/10/9 8:21:37
掌握Pandas DataFrame:数据分析主战场的核心操作实战
在数据分析这条路上摸爬滚打了一段时间后我越来越觉得 Pandas 里的 DataFrame 才是真正的核心战场。很多人学 Pandas 是从 Series 开始的觉得它像数组好理解但真正处理业务数据、做特征工程、清洗数据的时候你手里拿的、脑子里想的、代码里写的几乎全是 DataFrame。可以说DataFrame 是数据分析的主战场掌握了它你就掌握了用 Python 处理表格数据的主动权。这篇内容不是照着官方文档念一遍而是把我自己从会用到用好 DataFrame 的过程中总结出来的关键点、易错点、以及那些文档里不会明说但你迟早会踩的坑都梳理出来。无论你是刚接触 Pandas 的初学者还是已经写了几个月 Pandas 但总觉得哪里没打通的朋友这篇文章应该都能给你一些实质性的帮助。1. 为什么所有数据分析教程都绕不开 DataFrame先说个直觉上的对比。你用 Python 原生代码处理一批数据比如一个装着用户信息的列表里面有姓名、年龄、城市。如果你想算出平均年龄或者筛出某个城市的用户你得写循环、自己维护索引、自己处理空值。代码一长逻辑就乱。DataFrame 解决的就是这件事它把数据和对数据的操作封装到了一个统一的结构里让你用几行声明式的代码就能完成过去十几行甚至几十行命令式代码才能做到的事情。DataFrame 在 Pandas 里的定位其实可以理解成一张带列名和行索引的 Excel 表格。列可以有不同的数据类型行有索引可以定位整张表可以切片、筛选、合并、分组、透视。这种结构天然贴近真实业务数据的样子所以几乎所有数据分析任务的第一步都是把数据读进 DataFrame最后一步往往也是从 DataFrame 输出结果。可以把它拆成三个层次来看底层存储DataFrame 由若干个 Series 组成每个 Series 就是一列。Series 有自己的索引DataFrame 的行索引是由所有 Series 共享的索引对齐之后的结果。操作维度你可以按列操作选中一列、生成新列、按行操作筛选满足条件的记录、按表操作合并、连接、透视。扩展能力DataFrame 能无缝衔接 Pandas 的聚合、分组、时间序列、可视化等高级功能是整条数据分析链路的中枢。我说句实话很多教程喜欢用一个学生成绩表或者销售记录表来演示 DataFrame这没错但你要留意真实场景中的数据往往比教学示例脏得多——列名有空格、日期是字符串、数值里混着缺失值、同一列里居然有 int 和 str 两种类型。学习 DataFrame 的时候如果只盯着干净数据的操作那遇到真实数据时还是会懵。所以我下面所有讲解都会带着数据可能是脏的这个前提来展开。2. 从零构建一个 DataFrame先搞清楚数据和索引的对应关系2.1 最常用的几种构建方式我第一次用 DataFrame 的时候直接pd.DataFrame(data)一把梭结果遇到各种报错后来才慢慢理解了关键在于你传入的 data 是什么结构以及你希望它成为什么样的一张表。最常见的几种构建方式我都给你列出来import pandas as pd # 方式一从字典构建key 是列名value 是列数据 data_dict { city: [北京, 上海, 广州, 深圳], avg_price: [58000, 62000, 45000, 72000], population: [2189, 2487, 1868, 1756] } df1 pd.DataFrame(data_dict) # 方式二从列表的列表构建需要自己指定列名 data_rows [ [北京, 58000, 2189], [上海, 62000, 2487], [广州, 45000, 1868], [深圳, 72000, 1756] ] df2 pd.DataFrame(data_rows, columns[city, avg_price, population]) # 方式三从 Series 字典构建列与列之间会自动按索引对齐 s1 pd.Series([58000, 62000, 45000], index[北京, 上海, 广州]) s2 pd.Series([2189, 2487], index[北京, 上海]) df3 pd.DataFrame({avg_price: s1, population: s2})方式三值得多说一句因为它在构建的时候就体现了 DataFrame 一个核心特性——索引对齐。s1 有北京、上海、广州三行s2 只有北京、上海两行构建出来的 df3 会是三行广州对应的 population 是 NaN。这个行为不是 bug而是特征。等你做多表合并的时候就能体会到它的威力。2.2 索引到底有什么用新手最容易忽略的就是索引。列名大家都记得因为操作的时候要写df[列名]但行索引容易被当成隐形的编号。其实行索引承担了三类作用定位通过df.loc[索引标签]快速找到某一行。对齐两个 DataFrame 做运算或合并时索引标签一致的行才会匹配上。分组groupby 之后分组键会变成索引方便后续透视。我建议你拿到一张表的第一时间先print(df.index)看看索引是什么是默认的 RangeIndex0、1、2...还是某一列被设成了索引。很多误操作源头都是索引没有理解清楚。实际操作中创建完 DataFrame 后我一般会做三件事看一眼df.info()确认每列的类型和非空情况看一眼df.head()确认数据的形态看一眼df.columns.tolist()确认列名有没有脏字符比如空格、特殊符号。这三板斧做完这张表的基本脾气你就摸清了。3. 行列筛选与切片loc 和 iloc 的区别必须刻进脑子里3.1 loc 按名字取iloc 按位置取这是新手的一道坎也是面试里高频出现的考点。两个函数长得像但哲学完全不同df.loc[行索引, 列名]用的是标签也就是你能看得见的名字。行用索引标签列用列名。df.iloc[行位置, 列位置]用的是位置也就是数学上的序号从 0 开始左闭右开。我举个例子你就明白了# 沿用上文 df1 df1 pd.DataFrame(data_dict) # loc 用法按标签取 df1.loc[0, city] # 第 0 行标签为 0city 列的值 - 北京 df1.loc[0:2, [city, avg_price]] # 注意loc 切片是闭区间包含 2 # iloc 用法按位置取 df1.iloc[0, 0] # 第 0 行第 0 列 - 北京 df1.iloc[0:2, 0:2] # 行 0 到 1列 0 到 1iloc 切片是左闭右开关键坑就在切片上loc 的切片是包含末尾的闭区间iloc 的切片是不包含末尾的左闭右开。我见过不止一个同事用 loc 取 0 到 10 行以为取出来 11 行结果是 11 行没错包含两端反过来用 iloc 取 0 到 10取出来只有 10 行然后怎么都找不到最后一行的数据最后 debug 半天才想起来右开这个事。3.2 布尔筛选是日常工作大头真正在业务里用最多的其实是布尔条件筛选。需求常常是这样的找出价格大于 50000 且人口大于 2000 的城市或者排除 city 为空的记录。# 单个条件 df1[df1[avg_price] 50000] # 多个条件组合注意运算符的括号 df1[(df1[avg_price] 50000) (df1[population] 2000)] # 反向筛选 df1[~df1[city].isin([北京, 上海])]布尔筛选的核心逻辑是df[布尔Series]里面放一个长度和行数一致的布尔序列True 的行保留False 的行丢弃。这里最常见的报错是写and而不是。在 Pandas 里是按位与用在数组比较上and是 Python 原生的逻辑运算符不能用在 Series 上。新手在这里报错报得特别多ValueError: The truth value of a Series is ambiguous看到这行报错基本就是用了and或者or。3.3 修改列和删除列的常见操作# 新增列 df1[price_per_person] df1[avg_price] / df1[population] # 修改已有列 df1[city] df1[city].str.strip() # 去掉 city 前后的空格 # 删除列 df1 df1.drop(columns[price_per_person]) # 就地删除在副本上操作还会踩坑我建议多用复制 df1 df1.copy() df1.drop(columns[price_per_person], inplaceTrue)inplaceTrue这个参数我想单独拿出来说。早期我看教程很多人喜欢写df.drop(columnsxxx, inplaceTrue)觉得省内存、写法简短。但后来我养成了尽量少用 inplace 的习惯原因是它容易让人意识不到操作是否真的生效了尤其在链式操作连续多个方法调用里inplace 的行为会很微妙。Pandas 官方也倾向于返回新对象而非就地修改所以我现在基本都写成df df.drop(...)这种重新赋值的形式语义清晰、不容易出 bug。4. 数据清洗中的高频操作缺失值、重复值、异常值4.1 缺失值处理真实数据里没有缺失值才是稀奇事。DataFrame 里缺失值统一用 NaNNot a Number来表示它是个 float 类型这就带来一个经典坑某列原本是整数有缺失值后整列会被自动转成 float比如 1、2、NaN、4你dtype一看是 float64。这在后续处理时经常让人意外。我处理缺失值的通常顺序是# 1. 先看缺失规模 df.isna().sum() # 每列缺失数量 df.isna().mean() # 每列缺失比例 # 2. 按业务判断策略缺失少就删行缺失多就看能不能填填不了就删列 df_clean df.dropna(subset[city]) # 只删 city 为空的行 # 3. 数值列用均值/中位数填充类别列用众数或特殊标记填充 df[avg_price] df[avg_price].fillna(df[avg_price].median()) df[city] df[city].fillna(未知城市)缺失值处理没有绝对正确的标准答案它的本质是 你怎么看待这些空缺信息。如果是随机缺失填充均值不会引入系统性偏差如果是非随机缺失比如只有高价房源的记录才缺均价那你填充出来的结果反而是有偏的。所以每次填充之前我都会反问自己一句这个缺失有没有规律4.2 重复值处理重复值最容易出现在两种场景一是源数据重复采集二是两张表 join 之后产生笛卡尔积式的重复。处理起来倒是直接# 查看重复 df.duplicated().sum() # 删除完全重复的行 df df.drop_duplicates() # 按指定列去重比如城市 年份相同就算重复 df df.drop_duplicates(subset[city, year], keeplast)keep参数默认是first意思是保留首次出现的那行其余删除。如果业务上希望保留最新的记录就把keep设为last或者用sort_values先按时间排序再drop_duplicates。我遇到过一次这样的需求一张产品表每天全量同步一次产品 ID 同一天只能出现一条但历史分区里产品 ID 会重复要去重成每个 ID 每天一条做法就是df.drop_duplicates(subset[product_id, date])这个非常常用。4.3 类型转换让数据变得顺手这就是很多人搜得最多的一块内容——Pandas 数据类型转换。DataFrame 加载进来之后列类型经常会和你预期的不一样日期列是字符串、数值列是带千分位逗号的文本、布尔列是 0 和 1 的数字。这些都要手动纠正。# 字符串转数值 df[avg_price] pd.to_numeric(df[avg_price], errorscoerce) # 字符串转日期 df[trade_date] pd.to_datetime(df[trade_date], format%Y-%m-%d) # 数值转字符串 df[year] df[year].astype(str) # 数值转类别类型减少内存占用 df[city] df[city].astype(category)类型转换有几个细节我要重点说pd.to_numeric的errorscoerce一旦列里有一两个非数值的脏数据比如58000元这种带单位的直接astype(float)会报错to_numeric配合coerce会把非法值转成 NaN而不是让你整个程序崩溃。这是暴力但有效的办法。pd.to_datetime的format参数你最好明确指定日期的格式。如果不指定Pandas 会靠猜猜对了还好猜错时碰上01/02/2024这种双方约定的格式它会默认按MM/DD/YYYY还是DD/MM/YYYY谁也不敢保证。所以我在读外部数据时format一定会写死。astype(category)对城市、性别、商品分类这种低基数的类别列转成 category 类型能大幅减少内存。如果你处理的是几千万行数据这一个操作能省下不少内存代码执行速度也会变快。类型转换这块我自己的习惯是给每一列都建一张期望类型表哪列应该是 int、哪列应该是 datetime、哪列应该是 category在数据管道的最前面统一转一遍。后置处理时你就不用再担心类型不匹配的问题。5. 聚合与分组从明细数据到结论数据5.1 groupby 的本质思维转换平时我们处理的数据大多是明细数据每一行是一条记录比如一次交易、一个用户浏览行为。但分析的时候我们往往要的是结论数据每个城市的平均价格、每月的总销售额、每个品类的订单数。从明细到结论靠的就是 groupby agg。groupby 的思路分三步拆分 - 应用 - 合并。按某个键把行分组然后对各组应用统计函数最后把结果汇总成一张新表。# 按城市分组计算价格均值和人口总和 summary df.groupby(city).agg( avg_price_by_city(avg_price, mean), total_population(population, sum) ).reset_index()这里我要提一个新的函数写法agg里可以传元组列表每个元组是(新列名, (原始列名, 聚合函数))。这种写法在复杂场景下比单纯的df.groupby(city).mean()要灵活得多因为你可以对不同的列用不同的聚合函数比如价格求中位数人口求总和然后一次性生成一张汇总表。5.2 常见聚合函数的选择新手喜欢什么都是 mean均值但业务上不一定合适。价格这类受极端值影响大的数据应该看中位数成交量这种累加性质的指标应该用 sum数量这类指标count 和 nunique 是两个概念count 是统计非空值个数nunique 是统计去重后的个数二者一混结果完全不一样。df.groupby(city).agg( avg_price_median(avg_price, median), total_deal(deal_cnt, sum), unique_users(user_id, nunique), record_count(user_id, count) )注意一下这里的区别count是数数每行只要有值就 1nunique是数去重后的用户数。如果你想知道有多少用户产生了交易用 nunique如果你想知道有多少条交易记录用 count。这个区别在业务指标里特别常见很多数据对不上就是对这两个函数理解不到位。5.3 多列分组和多重索引的处理当分组键不止一列时问题就来了。比如你要按城市 年份分组summary df.groupby([city, year]).agg( avg_price(avg_price, mean) )得到的结果 DataFrame 的行索引是 MultiIndex也就是两层索引。这种对象直接reset_index()才是干净的表结构。很多人在这一步被搞懵明明分组了看着输出却像分裂了一样。我的建议是groupby 后只要你还想继续当普通表用就立刻reset_index()。不然带着 MultiIndex 的表后面每次筛选和切片都要多写一层索引很容易出错。分组之后如果想再在里面做排序可以先sort_values再分组或者分组后apply排序。日常用得最多的还是sort_values对最终结果排序比如输出价格最高的 5 个城市df.groupby(city)[avg_price].median().sort_values(ascendingFalse).head(5)6. 两个 DataFrame 的合并与连接on 和 how 的排列组合6.1 merge 是在复制 SQL 的 join 语义DataFrame 的merge方法对应 SQL 里的 join这是学 Pandas 的人必须跨过的一道门槛。常见的用法df_merge pd.merge( df_city, # 左表 df_price, # 右表 oncity, # 关联键两表共有 howinner # inner/left/right/outer )howinner只保留两边都匹配得上的行。这是默认值。howleft保留左表全部行右表没匹配到的部分填 NaN。howright保留右表全部行左表没匹配到的部分填 NaN。howouter两边的行都保留没匹配到的都填 NaN。这里的坑主要出在一对多和多对一上。如果左表的一个 key 对应右表的多行merge 后左表的行会复制多份这叫笛卡尔积放大。结果行数一旦暴涨首先怀疑的就是一对多关联。我见过一个真实事故员工表和部门表关联部门表里有重复的部门记录最后 merge 出来的数据行数比实际多出了好几倍检查半天才发现是部门去重没做好。6.2 concat 是纵向或横向拼表merge 是按列合并横向扩展字段concat 则是更通用的拼接工具# 纵向拼接两张结构相同的表上下叠起来 df_all pd.concat([df_q1, df_q2, df_q3], ignore_indexTrue) # 横向拼接按索引对齐 df_wide pd.concat([df_info, df_price], axis1)纵向拼接的坑就是索引两张表各自带自己的索引直接 concat 后索引会重复所以一般要带上ignore_indexTrue重新从 0 生成索引。横向拼接时如果两张表的行索引不对齐会自动产生 NaN这时通常可以先set_index成同一个键再拼或者用 merge 更可控。6.3 关联之后的数据验证合并、拼接之后我强烈建议做一次验证看看行数是否符合预期看看有没有出现意外的 NaN 列。随手记一个验证公式merge 前左表行数不变一对多场景则放大merge 后关键字段的缺失比例不应突变。用代码说assert len(df_merged) len(df_left) # 如果是左连接且无一对多 assert df_merged[key_field].isna().sum() 0这种断言看起来简单但在调试数据管道的时候它能在第一时间拦住数据形态不对的问题比等下游报表出来再发现要划算得多。7. 高效处理大数据的几个实用思路当你数据量上了千万行DataFrame 的一些默认行为就会开始拖后腿。这里分享几个我实践下来靠谱的思路。第一个尽早在源头减少数据量。读取文件时不要一股脑全读进来用usecols指定需要的列dtype指定类型能省一大把内存df pd.read_csv(big_file.csv, usecols[city, avg_price, trade_date], dtype{city: category}, parse_dates[trade_date])parse_dates把日期列一次性解析掉比读进来后再pd.to_datetime要快也更省内存。第二个能筛选就先筛选。先做行筛选再做 groupby否则大数据集上的分组操作会很慢。这个顺序听起来简单但很多人习惯先分组、后筛选白白多算了半天。第三个熟悉inplace与链式操作的内存影响。链式操作写起来很爽但每一步都可能产生中间副本。对大 DataFrame 来说我一般避免连续多次链式调用而是分步骤赋值中间用del清理不再用的引用。第四个用pd.options.display.max_columns和max_rows控制显示大数据时别整天 head() 全体打印费时费眼。当然这是使用习惯对性能影响没那么大但对你自己的交互体验影响很大。8. 数据类型的隐形影响和性能细节8.1 内存占用怎么查对 DataFrame 的内存心里有数是调优第一步df.info(memory_usagedeep)memory_usagedeep会统计得更细包括字符串对象实际占用的内存。如果你发现某列是 object 类型里面是几十万个重复的字符串值比如城市名、用户等级那就是典型的 category 优化场景。把 object 转成 category内存占用能下降一个数量级groupby 的速度也会更快。8.2 为什么列类型不统一会静默出错Pandas 有一个贴心但容易造成隐患的特性如果一列里混了字符串和数字它会被统一成 object 类型不会报错。然后你对该列做mean()它要么报错要么返回 NaN。等你排查半天才发现当年读数据的时候有一行多了一个引号或者空格。我的做法是任何列在做数值运算前先pd.to_numeric(..., errorscoerce)强制转一遍然后用df[col].isna().sum()看看转换后产生了多少 NaN。如果原来没有缺失、转完出现了缺失那就说明这列里有脏数据。这个检查十次有九次能快速定位到脏数据。8.3 字符串操作的小技巧DataFrame 对字符串列的操作不是直接df[city].upper()而是要通过.str访问器df[city] df[city].str.strip() # 去空格 df[city] df[city].str.replace(市, ) # 去掉市字 df[city] df[city].str.contains(北) # 返回布尔 Series可以用作筛选条件注意str.contains默认是正则表达式匹配如果只是想要普通字面匹配可以加regexFalse不然碰上括号、点号这些字符时会得到匪夷所思的匹配结果。这是我踩过的一个真实坑用户备注里含有邮箱地址想筛出带.com的结果.被当成通配符匹配出了大量含其他字符的记录。后来加上regexFalse才恢复正常。9. 导出与持久化数据处理的最后一公里9.1 to_csv 的编码坑DataFrame 写完分析之后最后一步通常是导出。最常用的是to_csv但中文环境下编码问题非常典型df.to_csv(result.csv, indexFalse, encodingutf-8-sig)这里我坚持用utf-8-sig而不是utf-8。不为什么就因为 Excel 打开 utf-8 编码的 csv 时中文列名会变成乱码。utf-8-sig会带一个 BOM 头Excel 能正确识别。如果你只是给程序读那utf-8就够了。indexFalse几乎是必须养的肌肉记忆。你不写的话导出文件里会多出一列无名索引下游同事读到这张表时会莫名多一列还得自己删。9.2 高效列筛选和保存格式如果数据量大csv 不是最合适的格式建议用 parquetdf.to_parquet(result.parquet, indexFalse)parquet 是列式存储读写都远快于 csv而且自带类型信息导出去再读回来类型不会丢。对内部流程、中间结果、多表关联后的落盘parquet 都是更好的选择。csv 更多时候是给外部交付用的嫌重就 gzip 压缩一下pd.read_csv(data.csv.gz)Pandas 原生支持。10. 实战中的一条龙数据集操作示例光讲零散操作不够我最后用一个综合案例把它们串起来。假设我们接到一份订单数据要做的事情是清洗、提取城市、按日聚合、计算客单价然后输出一张结果表。# 1. 读取先限定列避免内存浪费 df pd.read_csv(orders.csv, usecols[order_id, city, amount, created_at], parse_dates[created_at]) # 2. 清洗去重、去空、城市列去空格 df df.drop_duplicates(subset[order_id]) df df.dropna(subset[order_id, city]) df[city] df[city].str.strip() # 3. 类型修正金额转数值脏数据转 NaN 后删除 df[amount] pd.to_numeric(df[amount], errorscoerce) df df.dropna(subset[amount]) # 4. 提取日期列 df[date] df[created_at].dt.date # 5. 按城市和日期聚合 daily df.groupby([city, date]).agg( order_cnt(order_id, count), total_amount(amount, sum), avg_amount(amount, mean) ).reset_index() # 6. 计算有交易的日期数判断活跃度 city_active_days df.groupby(city)[date].nunique().reset_index() city_active_days.columns [city, active_days] # 7. 合并汇总 final_result pd.merge(daily, city_active_days, oncity, howleft) # 8. 导出 final_result.to_csv(final_result.csv, indexFalse, encodingutf-8-sig)这个流程覆盖了读取、清洗、类型转换、筛选、分组、聚合、合并、导出基本上就是我在日常工作中反复使用的套路。每一步单独看不难串起来就是一条成熟的数据处理流水线。我个人的经验是刚接触 DataFrame 时不要贪多求全先把建表、取数、筛选、清洗、聚合、合并、导出这七件事吃透剩下的函数基本都是这些骨架上的血肉。等你写多了会发现 DataFrame 的操作风格高度一致——先定位数据再变换数据最后汇总数据每个环节的 API 都是围绕这个逻辑设计的。把这条主线摸清楚后续学什么pivot_table、apply、rolling都只是锦上添花。