ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Pandas DataFrame日常操作主线:从读取清洗到聚合提速避坑

Pandas DataFrame日常操作主线:从读取清洗到聚合提速避坑

做数据分析这行久了,打开编辑器第一件事往往不是建模,也不是画图,而是把一张表读进来,然后对着它反复折腾——筛几列、去个重、按维度汇总、跟另一张表拼一下。这些动作听起来简单,可真正每天在写的人都知道,DataFrame 的 Python 操作有一大半时间不是花在“不会写”,而是花在“为什么这么写报错”“为什么结果比预期多了三倍行”“为什么跑了十分钟还没结束”上。我这些年带过几个新人,也 review 过不少分析脚本,发现大家卡住的地方高度重合:不是不熟悉 API,而是不清楚这些 API 背后的数据模型。

这篇内容想解决的正是这个断层。它不打算把文档抄一遍,而是按“日常真实顺序”——读数据、看数据、筛数据、洗数据、聚合、关联、提速——把 DataFrame 常用 Python 操作串成一条能直接照着做的线。每一段我会说清楚三件事:这个操作在干什么、为什么这么设计、以及我踩过的坑长什么样。适合两类人看:刚接触表格数据处理、能看懂语法但总被警告和报错绊住的新手;以及写了半年脚本、开始感觉代码又慢又难维护、想弄清底层逻辑的中级使用者。下面这些内容全部来自实际项目里的反复试错,能抄的地方直接抄,不能抄的地方我也会说明为什么不能。

1. 先把 DataFrame 想清楚:它到底是什么、为什么快

1.1 别把它当二维数组,它是一组对齐的列

很多人上手时的心理模型是“DataFrame 就是一个二维数组,有行有列”,这个理解会让你在后续操作里持续吃亏。更准确的模型是:DataFrame 是由若干个 Series 按列拼起来的容器,每一列有自己独立的 dtype,所有列共享同一个行索引(index)。这个定义听起来只是措辞差别,但它直接解释了大量行为差异。

举个最直观的例子。你有一张十万行的订单表,想算“单价乘数量”得到金额。按列算,是把两列各自当成一段连续内存,做一次向量化乘法,底层是 C 循环,几毫秒结束。而如果你用apply(axis=1)一行一行地算,每一行都要先把两个值从列里取出来、打包成一个临时 Series、调用一次 Python 函数、再把结果塞回去,十万次这样的往返,耗时能差出两个数量级。这不是 pandas 写得不好,而是列式布局天然 favors 列操作。我在一个对账脚本里做过对比,同样的逻辑,逐行 apply 跑了四十几秒,改成列运算后不到一秒。

行索引则是另一个高频坑源。默认的RangeIndex是 0 到 n-1 连续整数,所以你用df[2]取第三行是能跑通的,但这是“位置恰好等于标签”的巧合。一旦你做过一次过滤,索引就变成了[0, 3, 7, 12...]这种带空洞的形态,这时候df[2]直接抛 KeyError。很多新手在这里以为是数据丢了,其实数据好好的,只是标签对不上。

提示:任何经过筛选、排序、去重之后的表,如果你还要按位置取数,先reset_index(drop=True),或者老老实实用iloc。不要用df[i]这种写法去取行,它在 pandas 里的语义是“取列”,行为随版本和数据类型变化过,不值得依赖。

1.2 视图、副本和 dtype:慢和警告的根源都在这

第二个必须建立的认知是视图与副本的区别。当你做df[['a','b']]或者df[mask]这类操作时,返回的可能是原数据的一个视图(共享底层内存),也可能是一份拷贝。pandas 不会每次都用同样的方式处理,它取决于内部块的布局。这就是SettingWithCopyWarning这个烦人警告的来源——你在一个“可能是视图”的对象上赋值,pandas 无法保证这个修改会不会影响原表,于是它警告你。

我的处理原则很简单:只要这个中间结果我要改,就显式.copy()。多一次内存拷贝换来行为确定,这笔账在生产脚本里永远划算。反过来,如果只是读一读、算个统计量,就别 copy,省内存。

dtype 的影响更隐蔽。字符串在 pandas 里默认是object类型,本质是一堆 Python 字符串对象的指针数组,既占内存又没有向量化优势,做个str.contains都比数值运算慢。如果某列取值种类很少(比如状态、渠道、地区),转成category能把内存压到原来的几十分之一,分组聚合也会快不少。数值列则要注意:小整数用int8/int16,浮点如果精度要求不高可以float32,大数据量下这些选择直接影响你能不能在一台普通机器上跑完。

用df.memory_usage(deep=True)看一眼每列实际占用,你会对“哪一列是内存大户”有完全不同于直觉的认识——通常不是你以为的那几个数值列,而是几个长文本列或者高基数的 object 列。这个命令我基本每个新数据集都会跑一次。

2. 数据进来和第一眼:读取与快速探察

2.1 read_csv 的参数,决定了后面顺不顺

绝大多数麻烦不是出在分析阶段,而是出在读取阶段没设好参数。默认的read_csv追求“能读进来”,但业务数据几乎从来不配合默认值。下面这几个参数,是我每次读文件前都会过一遍的清单。

参数作用不设会怎样
dtype指定列类型纯数字编号“00123”被推断成整数 123,前导零丢失
usecols只读需要的列一百多列的表全读进内存,慢且占地方
parse_dates解析日期列日期是字符串,排序按字典序,2 月排在 10 月后面
na_values补充缺失标记“-”“NULL”“N/A”“未知”被当成正常文本,统计全错
encoding文件编码中文列名读进来是乱码,改起来要重读一遍
nrows只读前 N 行想先看看结构,结果等了五分钟
sep分隔符分号或制表符分隔的文件被读成一整列

先说dtype。编号类字段(订单号、工号、商品编码)一定要显式指定成str。这类值的特点是“看起来像数字但绝不参与运算”,一旦被推断成整数,前导零没了,超长数字还可能被转成科学计数法,等到你要跟另一张表按编号关联时,两边对不上,排查能耗掉半天。同理,金额列如果读成 float,做累加会出现0.30000000000000004这种结果,涉及金额的场景要么用整数分做单位,要么在比较时统一 round。

na_values是另一个必设项。业务系统导出的表里,缺失往往不是空单元格,而是各种占位符:短横线、斜杠、“无”、“未知”、“null”、“#N/A”。不统一声明,isna()就识别不出来,后面算均值时这列会因为混着字符串而变成 object 类型,直接算不了。我的习惯是先nrows=1000读一小块,把每列value_counts()扫一遍,看看有没有可疑的占位符,再回去改读取参数。这个动作多花两分钟,能省掉后面两小时的困惑。

2.2 拿到数据先做五件事,别急着写业务逻辑

数据进来之后,我建议按固定顺序跑一遍体检,形成一个肌肉记忆。这五件事分别是:看形状、看类型、看头尾、看分布、看缺失。写成函数大概是这样:

import pandas as pd def quick_profile(df): print("shape:", df.shape) print("\ndtypes:\n", df.dtypes) print("\nhead:\n", df.head()) print("\ntail:\n", df.tail(3)) print("\nmissing ratio:\n", df.isna().mean().sort_values(ascending=False)) print("\nnunique:\n", df.nunique().sort_values(ascending=False)) return None

shape让你对量级有概念——是几千行还是几百万行,决定了后面能不能随便 apply。dtypes是重点,凡是object的列都要问一句“它为什么是 object”,是本来就该是文本,还是混进了脏值。head和tail一起看,是因为有些表的尾部挂着合计行、备注行,这类行会污染统计,必须提前识别并剔掉。

isna().mean()比isna().sum()更有信息量,因为它给的是比例。一列缺 5% 和缺 60%,处理策略完全不同,光看绝对数量在十万行和一百万行的表里感受不到差别。nunique则是判断“这列能不能当分组维度”的快速方法,唯一值只有个位数的适合分组,唯一值接近行数的多半是主键,唯一值在几十到几百之间的可以考虑转category。

再补一个我常用的:df.duplicated().sum()。业务表里重复行非常常见,尤其是多表 union 之后、或者导出时重复点了两次。上来先看一眼重复行数量,能避免后面一堆“为什么汇总数偏高”的疑问。

3. 筛选、排序与赋值:日常最高频的三板斧

3.1 loc、iloc、布尔索引,三者边界别混

这块是新手上手后第一周必然踩的地方。简单归位一下:loc按标签取,iloc按位置取,df[条件]是布尔索引,at/iat取单个值。记住一句话就够了——只要你的索引不是默认的连续整数,就别用df[...]取行。

一个必须知道的细节:loc的切片是包含右端点的,iloc的切片是不包含右端点的。这个不一致源于设计传统,改不了,只能记住。df.loc['A':'C']会取出 A、B、C 三行,而df.iloc[0:3]取的是第 0、1、2 三行,看起来结果一样,但语义完全不同。当你哪天遇到索引是字符串日期的时候,这个差别就会突然冒出来给你一击。

# 按标签,含右端点 df.loc['2024-01-01':'2024-01-31'] # 按位置,不含右端点 df.iloc[0:31]

还有at/iat。如果你只改一个单元格,df.at[行标签, '列名'] = 值比loc快得多,因为它跳过了大量索引对齐的检查。在循环里改单元格这种写法本身就不推荐,但如果确实有这种场景,at是唯一还能接受的选择。

3.2 多条件筛选和排序的实用组合

多条件筛选的语法坑集中在括号上。&、|、~的优先级高于比较运算符,所以df[df.a > 1 & df.b < 2]会报错或者得到离谱结果,必须写成df[(df.a > 1) & (df.b < 2)]。这个错误太常见了,我甚至见过有人为了避开括号,把每个条件单独存成变量——这倒也是个好办法,可读性反而更好。

mask = (df['amount'] > 100) & (df['status'].isin(['paid', 'shipped'])) sub = df.loc[mask, ['order_id', 'amount', 'status']].copy()

几个搭配使用频率极高的方法:isin做集合匹配,比一串|干净得多;between处理区间,注意默认两端都包含;str.contains('关键词', na=False)做文本模糊匹配,一定要带na=False,否则只要有缺失值,整列结果就变成 NaN 而不是布尔值,筛选直接失效;query适合条件较长的场景,语法更接近自然表达,写起来清爽,但列名里有空格或特殊字符时要用反引号包起来。

排序方面,sort_values支持多列和不同的升降序,ascending传一个列表就行。na_position决定缺失值排前面还是后面,默认是last。这里有个实战经验:排序最好在筛选之后做,先筛掉大部分数据再排,比全量排序再筛快得多,逻辑上也更清楚。

3.3 赋值与 SettingWithCopyWarning 的真实来源

这个警告把无数人劝退过。它的本质是:你拿到的是一个来路不明的对象(可能是视图也可能是副本),在它上面赋值,pandas 不敢保证结果。触发它的典型写法是链式索引:

# 会警告,且可能不生效 df[df['amount'] > 100]['flag'] = 1 # 正确写法 df.loc[df['amount'] > 100, 'flag'] = 1

第二种写法用一次loc同时完成“选行”和“选列”,语义明确,pandas 知道你要改原表。第一种是两个独立操作:先取子集(得到一个可能是副本的新对象),再在新对象上赋值,原表根本没被碰。

除了改写法,还有个更根本的处理方式:明确区分“我要一个能改的表”和“我只是看看”。需要改的,从一开始就.copy(),后面怎么写都不会有歧义。我现在的习惯是,任何从大表切出来的、后续还要进一步加工的中间表,一律.copy(),把不确定性消灭在源头。

另外提一个容易忽略的点:inplace=True在多数场景下并不比重新赋值快多少,而且会破坏链式调用、让代码难读、出问题时难回滚。我早就全面弃用了,宁可多敲一个df = df.xxx()。

4. 清洗与变换:缺失值、类型、字符串、日期

4.1 缺失值处理,先问“为什么缺”

看到缺失就fillna(0),是新手最典型的错误,也是我 review 代码时最先拦下的写法。缺失值背后的原因决定了处理方式,大致分三类。

第一类是业务上本来就没有。比如新用户的“首单金额”字段,人家还没下单,没有是正常的。这类缺失填 0 会误导——你会把“未下单”和“下单金额为零”混为一谈。更好的做法是单独造一个布尔列标记“是否有首单”,数值列保留 NaN,聚合时用mean会自动跳过缺失,不会污染结果。

第二类是采集环节漏了。这类缺失通常随机分布,占比不高,可以直接删行,或者用中位数、众数、分组均值填补。用分组均值填补比全局均值靠谱,比如按城市分组后填收入,比用全国平均填要合理得多。

第三类是字段本身不可靠,缺失率可能超过一半。这种情况不要硬填,填出来的值会凭空制造出大量虚假的规律。我的处理是保留该列但明确标注不可用于关键结论,或者干脆在分析范围里排除。

注意:fillna用固定值填补会压缩方差。如果这列后面要参与方差、相关性计算,填补后的结果会系统性偏低,结论要相应打折。必须填的话,可以考虑加一列标记“是否被填补”,分析时区分对待。

dropna有两个参数值得记住:how='all'表示所有列都缺才删,how='any'是任一列缺就删(默认)。thresh=n表示至少有 n 个非缺失值才保留。用thresh通常比how='any'温和,因为后者在宽表上会一口气删掉大部分行。至于ffill/bfill向前向后填充,它适合有明确顺序的数据,比如按时间排列的状态字段,用在无序数据上会制造出毫无意义的传播,慎用。

4.2 类型转换里那些静悄悄的错误

astype是最直接的转换手段,但它有个行为需要知道:当一列整数存在缺失时,pandas 无法用整数类型表示 NaN,会自动把它变成 float。所以你会看到1, 2, NaN转换后变成1.0, 2.0, NaN,这是符合预期的,不用慌。真正要注意的是反向操作——把一个含缺失的 float 列直接astype(int)会抛错。

文本转数值,推荐用pd.to_numeric(series, errors='coerce')。errors='coerce'会把无法解析的值变成 NaN,而不是抛异常中断整个流程。这个行为的价值在于它能帮你找出脏值:转换后对比一下原列的缺失数,多出来的 NaN 就是那些“看起来像数字其实不是”的条目,通常是“约 100”“100元”“1,000”这类。把它们拎出来看看,往往能发现上游系统的数据质量问题。

raw = df['amount'] parsed = pd.to_numeric(raw, errors='coerce') bad = df.loc[parsed.isna() & raw.notna(), 'amount'] print(bad.unique()[:20])

日期同理,用pd.to_datetime(errors='coerce'),然后看哪些行变 NaT 了。日期格式混乱是常态,一个字段里同时存在2024-01-05、2024/1/5、05-01-2024这几种写法,不coerce就得一条条手工处理。带上format参数能显著加快解析速度,格式统一的时候务必指定。

4.3 字符串与日期:用访问器,别用循环

字符串操作统一走.str访问器,它是向量化的,比逐行循环快一个量级。常用组合:str.strip()去首尾空白(导入数据几乎必做,肉眼看不出来但会导致分组变成两组)、str.lower()统一大小写、str.replace(旧, 新, regex=True)做模式替换、str.contains(关键词, na=False)做匹配、str.split(分隔符, expand=True)拆列、str.extract(正则)抽结构。

拆列这个技巧值得单独说。经常遇到“地址”字段里塞了省市区三级、用短横线连着,正确做法是df['地址'].str.split('-', expand=True)一次拆成多列,再重命名。用apply加函数也能做,但慢且容易在异常值上崩掉。expand=True这个参数是关键,不加的话得到的是一个列表列,后续操作全是麻烦。

日期部分,.dt访问器提供了year/month/day/hour/dayofweek等一系列属性,dt.to_period('M')可以把日期压到月份粒度用于月度汇总,dt.normalize()把时间部分清零。这些比手写格式化成字符串再分组要快得多,而且保留了类型信息。做时间序列汇总时,resample比先造月份列再 groupby 更简洁,注意它要求索引是 DatetimeIndex。

5. 分组聚合与表连接:分析工作的两条主干道

5.1 groupby 的本质是拆分、计算、合并

理解 groupby 最好的方式是把它拆成三步:按 key 把数据分成若干块,对每块独立算,再把结果拼回去。这个模型解释了它的所有行为,包括为什么某些操作快、某些操作慢。

最常用的三个出口是agg、transform、apply。agg把每组压成一个值,行数变少,适合做汇总表。transform把结果广播回原来的行数,适合做“组内占比”“组内排名”这类需要保留明细的场景,比如算每个用户消费额占该城市总额的比例,用 transform 一步到位,不用先算汇总再 merge 回去。

df['city_share'] = df['amount'] / df.groupby('city')['amount'].transform('sum')

agg现在支持命名聚合,写出来非常清爽,而且列名可控:

result = df.groupby('city').agg( 订单数=('order_id', 'nunique'), 总金额=('amount', 'sum'), 客单价=('amount', 'mean'), 最大单笔=('amount', 'max') ).reset_index()

分组里有一个高频陷阱:分组列含缺失值时,默认会被丢弃,结果里根本看不到 NaN 那一组。如果缺失本身有业务含义(比如“渠道未填写”),这就等于悄悄漏掉了一部分数据。加dropna=False可以保留。另一个陷阱是分组前没去重导致金额翻倍,这个错误极其隐蔽,因为结果看起来完全正常,只是数值偏大。

5.2 merge 的四种连接和行数爆炸

merge 是把两张表按 key 拼起来的操作,how参数四种取值的行为差异必须清楚。

how保留哪边典型用途
inner只保留两边都有的 key求交集,最常用
left保留左表全部 key给主表补字段
right保留右表全部 key较少用,通常换成 left 调换顺序
outer两边全保留对账,找差异

行数爆炸是 merge 最常见的翻车点。合并后行数变多,只有两个原因:右表的 key 有重复值,或者你漏写了on参数导致按同名列全量笛卡尔积。养成习惯:merge 前后都打印一下行数。如果合并后行数比左表多,先查右表 key 是否有重复,右表['key'].duplicated().sum()一行就能验证。

print('左表:', len(left), '右表:', len(right)) merged = left.merge(right, on='order_id', how='left', validate='m:1') print('合并后:', len(merged))

validate参数是很多人不知道的好东西。传'm:1'表示“左表 key 可以重复、右表 key 必须唯一”,一旦违反直接抛错,把问题拦在当场而不是等到下游发现数字不对。indicator=True会多一列_merge,标出每行来自哪边,对账场景下非常实用。

还有一个细节:两边有同名的非 key 列时,默认会加_x_y后缀,很多人事后分不清哪个是哪边的。用suffixes=('_左', '_右')自定义后缀,成本几乎为零,可读性提升巨大。

6. apply、map 与向量化:性能的分水岭

6.1 apply 什么时候能用,什么时候必须改

apply不是洪水猛兽,但它确实是最容易被滥用的方法。判断标准很简单:如果这个函数在每一行/每一组上做的事,能用列运算表达,就不该用 apply。

df.apply(func, axis=1)是逐行调用,本质是一个 Python for 循环,还带着 Series 构造的开销。一万行以内通常感觉不到,十万行开始明显,百万行基本不能接受。相比之下,groupby().apply()的代价小一些,因为它的调用次数等于组数而不是行数,如果你的分组只有几十个,即使数据有几百万行,用起来也还好。

map适合做值的映射替换,尤其是把编码转成中文名。用字典 map 比写一个函数再用 apply 快得多,而且没匹配到的值会变成 NaN,方便你立刻发现遗漏的编码。replace和map类似,但它会保留未匹配的原值,两者行为差异要留意。

我见过一个典型的重构案例:有个脚本用 apply 逐行判断金额区间打标签,跑一百万行要两分多钟。改成pd.cut之后,同样的逻辑不到一秒。pd.cut做等宽分箱,pd.qcut做等频分箱,配合labels参数直接得到标签列。条件判断则可以用np.select,传入条件列表和对应取值,比 apply 里写一长串 if-elif 快出两个数量级,还更容易改。

6.2 向量化的几个常用工具

除了np.where(两分支)和np.select(多分支),还有几个值得收进工具箱的。

mask和where是一对:mask在条件为真时替换,where在条件为假时替换。处理“超出范围的值置为空”这类需求非常直接,不用构造复杂的嵌套条件。

query用来写筛选条件,语法接近自然语言,列名可以直接写。它比布尔索引快一点(因为底层做了优化),但更多是赢在可读性,尤其是条件很长的时候。

eval用来算多列参与的表达式,比如df.eval('amount * qty * (1 - discount)')。它在列多、表达式复杂时有性能优势,因为它避免了中间临时对象的创建。不过说实话,日常场景下这个优势不明显,我更多是拿它来写整洁的公式。

提示:判断要不要重构成向量化,别凭感觉。用%%timeit在真实数据量上测一次,几秒钟的事。我见过太多人凭直觉优化,把能读的代码改成了难读的代码,结果快不了多少,维护成本却翻倍。

7. 常见问题与排查实录

7.1 高频报错速查表

下面这张表覆盖了我实际遇到过八成的报错,按出现频率排序。

报错/警告真实原因处理方式
KeyError: 'col'列名有空格、大小写不对、或多了一层引号df.columns.tolist()打印确认,用strip()批量清理列名
SettingWithCopyWarning链式索引赋值改df.loc[行条件, 列名] = 值,或提前.copy()
ValueError: Length mismatch赋值的列表长度和行数不一致检查右侧长度,或改用标量广播
ValueError: cannot reindex from a duplicate axis索引有重复值df.index.duplicated().sum()检查,reset_index(drop=True)
TypeError: unsupported operandobject 列里混着数字和字符串pd.to_numeric(errors='coerce')先清洗
InvalidIndexError用了重复索引做对齐同上,先处理索引重复
PerformanceWarning: fragmented反复插入列导致内存碎片一次性构造列,或用pd.concat合并

KeyError里最冤的一种是列名带不可见字符。从别处复制粘贴列名、或者从有格式的文件读进来,列名首尾可能带着空格或换行,肉眼完全看不出来。df.columns = df.columns.str.strip()这一行我现在几乎是条件反射地加在读取之后。

索引重复的问题更隐蔽。有些操作会悄悄产生重复索引,比如concat几张表而不ignore_index。之后任何涉及索引对齐的操作都可能出错,包括赋值、merge、reindex。判断方法就是一行df.index.duplicated().sum(),不为零就reset_index。

7.2 内存和性能:从“能跑”到“跑得动”

数据量上一个量级之后,原来的写法会突然变得不可用。几个最有效的优化手段,按性价比排序。

第一是读的时候只读需要的列。usecols这个参数经常被忽略,但一个两百列的宽表,如果分析只用其中二十列,光读取就能省掉九成时间和内存。第二是改 dtype,把高基数的重复文本列转成category,把大整数降级成int32。pd.to_numeric(series, downcast='integer')可以自动选最小的合适类型。

第三是避免在循环里 append。df = df.append(row)这种写法在旧代码里很常见,每加一行都要复制一次整个表,复杂度是平方级的。换成把每行存进列表、最后pd.concat(列表),或者更进一步,先攒成 list of dict 再一次性构造。append方法在新版本里已经被移除了,看到就得改。

第四是分块处理。真的遇到几个 G 的文件,read_csv(chunksize=100000)返回一个迭代器,一块一块地读、一块一块地算、最后合并结果。这个模式下要注意“需要全局信息”的操作(比如算全局均值)不能直接做,得先算每块的局部和与计数,最后再合并。这个思路跟分布式计算里的分区聚合是一回事。

total, count = 0, 0 for chunk in pd.read_csv('big.csv', chunksize=100_000, usecols=['amount']): total += chunk['amount'].sum() count += chunk['amount'].count() mean = total / count

最后一条经验是关于代码组织的。脚本写到几百行之后,最难的不是写新功能,而是搞不清哪一列在哪个阶段被改成了什么类型、哪些行被筛掉了。我现在的做法是:每做完一个阶段性操作,就打印一次shape和关键列的dtypes,或者直接存一个中间文件。看起来啰嗦,但出问题时能立刻定位到是哪一步引入的偏差。

我个人在长期写这类脚本的过程中体会到的是:DataFrame 的 API 数量确实多,但真正撑起日常工作的其实就那么二三十个。剩下的那些,很多是特定场景下的优化选项。与其把文档通读一遍,不如把上面这几条主线练到不假思索——读数据时条件反射地设 dtype 和 na_values,筛选时下意识地加括号和 copy,分组前先查一遍键有没有重复,写完 apply 之后问自己一句“这能不能改成列运算”。这四五个习惯养成了,绝大部分坑自然就绕开了。至于那些更冷门的方法,等你真的需要为了性能抠那几秒的时候再去查,那时候带着具体问题去看文档,效率反而比现在硬背高得多。

返回列表