news 2026/11/6 4:15:33

pandas DataFrame高频操作与数据清洗避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
pandas DataFrame高频操作与数据清洗避坑指南

我在处理表格类数据时有个习惯:拿到一份新数据,先不着急画图、不着急建模,而是打开一个 Python 会话,用一串 DataFrame 操作把这份数据"摆平"——看清楚每一列到底是什么类型、有多少缺失、哪些字段是重复的键、哪些值明显不合常理。这套动作重复了太多次之后,我发现自己真正高频调用的 pandas 操作其实只有那么几十个,而坑也集中在固定的几处。这篇就按"我实际动手的顺序"把 DataFrame 常用操作捋一遍,重点讲每个操作为什么这么用、什么时候会翻车,以及翻车之后怎么找回来。

1. 数据进内存后的第一轮体检

很多人打开 DataFrame 的第一个动作是df.head(),看一眼前五行觉得"嗯,数据挺正常的",然后直接开始处理。这一步我建议改一改,把info()放到head()前面。原因很直接:head()给你的是值的长相,info()给你的是结构的骨架,而绝大多数后期报错都来自骨架而不是长相。

1.1 三个必看的基线指标

我习惯把体检拆成三个指标:形状、类型分布、缺失分布。形状用df.shape就够了;类型分布用df.dtypes.value_counts()能一眼看出有多少列被识别成了object;缺失分布用df.isna().mean()比isna().sum()更有信息量,因为比例能直接告诉你某一列是不是已经接近报废。

import pandas as pd import numpy as np df = pd.read_csv("orders.csv") print(df.shape) # (行数, 列数) print(df.dtypes.value_counts()) # 各类型分别有几列 print(df.isna().mean().sort_values(ascending=False).head(10))

为什么强调object的列数?因为在 pandas 里object是一个"什么都往里装"的类型标签,它可能是字符串、可能是混合了数字和文本、也可能是本该是日期的字符串。只要一列是object,你在它上面做的排序、比较、聚合基本都是"碰运气"。我见过太多案例,某一列明明是金额,但因为混进了几个"-"占位符,整列被读成object,后面sum()的结果变成字符串拼接,而且不报错,只是结果荒谬。

isna().mean()之后我会补一个动作:对缺失比例超过 60% 的列,先问一句"这列还有业务意义吗"。很多宽表里躺着一堆废弃字段,带着它们往下走只会让后续每一步都变慢、变复杂,还容易在groupby时被莫名其妙地卷进来。

1.2 为什么 info() 比 head() 更值得先看

df.info()一次性给出索引类型、列名、非空计数、dtype 和内存占用。这里面有三个信息是head()完全给不了的。

第一个是非空计数。你看到12345 non-null而总行数是15000,立刻就知道这列有 2655 个缺失,不需要再单独查。第二个是索引类型。如果索引不是RangeIndex,说明这份数据可能是从别的 DataFrame 切片出来的,或者读进来的时候被指定了索引列,后面用loc和iloc时行为会不一样。第三个是内存占用,一个几十万行的表如果吃掉几百 MB,那就要提前考虑后面merge的时候会不会爆。

df.info(memory_usage="deep")

这里的memory_usage="deep"值得单独说一句。默认的memory_usage对object列只算指针大小,不算字符串本身占的空间,所以你会看到一个全是长文本的列只占几 MB,实际可能占几百 MB。加上deep=True才会真正遍历对象内容去统计。我吃过这个亏:本地跑得好好的脚本,丢到内存小一点的机器上就挂了,排查半天才发现是文本列的真实占用被低估了十倍。

体检阶段还有一个容易被忽略的动作:看唯一值数量。

df.nunique().sort_values()

唯一值数量极少的列(比如只有 2 到 20 个),基本可以判定是类别列,非常适合转成category类型;唯一值数量等于行数的列,大概率是主键或 ID;而如果某个"应该是类别"的列出现了几百个唯一值,那十有八九是有脏数据——大小写不一致、多了空格、或者同一个含义用了不同写法。

1.3 用 describe(include="all") 扫一遍量纲

数值列的describe()大家都会看,但我更建议加上include="all",让字符串列也输出一份统计:count、unique、top、freq。top和freq能告诉你这列最常见的值是什么、占了多少比例。如果某一列最常见的值占了 95%,那这列基本没有区分度,建模时可以直接扔掉。

df.describe(include="all").T

.T转置一下,是为了让每一列变成一行,输出更好读。数值列重点看min和max是否离谱——比如年龄出现 999、金额出现负数、时间戳出现 1970 年。这些异常值如果不提前发现,后面做归一化或者算均值时会把整个分布带偏。

2. 取数与筛选:loc、iloc、布尔索引、query 四条路的分工

DataFrame 最让人迷糊的地方就是"取数"这件事居然有四种写法,而且它们在某些情况下结果一样、某些情况下结果完全不同。我最初学的时候就是把它们当成同义替换,结果在链式赋值上栽了个大跟头。

2.1 行标签、列标签与位置:loc 和 iloc 认的东西不一样

一句话概括:loc认的是标签,iloc认的是位置。当你的索引是默认的RangeIndex时,两者看起来没区别,因为标签恰好等于位置。但只要索引被改过,区别立刻显现。

df2 = df.sort_values("amount", ascending=False).head(10) # 此时 df2 的索引是乱序的整数,比如 [873, 12, 4402, ...] df2.loc[0] # 报错 KeyError,因为没有标签为 0 的行 df2.iloc[0] # 正常,取第一行(标签是 873 的那行)

这个差异在sort_values之后特别容易踩。我的习惯是:只要做了排序、过滤、切片,立刻reset_index(drop=True),把索引复位成干净的连续整数,让loc和iloc的行为重新对齐,省掉大量心智负担。代价是多一次拷贝,但这个代价比起调试"为什么取出来的行不对"要划算得多。

loc的切片是闭区间,iloc的切片是左闭右开,这也是一个反直觉的点:

df.loc[0:3] # 包含标签 0、1、2、3,共 4 行 df.iloc[0:3] # 包含位置 0、1、2,共 3 行

我见过有同事写df.loc[0:1000]以为取 1000 行,实际取了 1001 行,在分页逻辑里差一行就可能引发对不上账的问题。

2.2 布尔索引的括号陷阱和它对性能的影响

布尔索引是日常用得最多的筛选方式,也是最容易写错的一种。核心规则只有一条:多个条件之间必须用&、|、~,不能用and、or、not,并且每个条件都要用括号包起来。

# 正确 mask = (df["amount"] > 100) & (df["status"] == "paid") subset = df[mask] # 错误:运算符优先级会导致先算 & 再算 >,直接报 TypeError subset = df[df["amount"] > 100 & df["status"] == "paid"]

为什么必须用&?因为and是 Python 的短路求值运算符,它需要拿到一个明确的布尔值才能继续,而 Series 是一整个数组,没法压缩成单个 True/False。pandas 重载了&让它做逐元素运算,所以只能用&。

除了正确性,布尔索引还有个性能层面的细节:把 mask 单独存成一个变量。

mask = (df["amount"] > 100) & (df["status"] == "paid") print(mask.sum()) # 先看命中多少行 print(df.loc[mask, "user_id"].nunique()) # 再基于同一个 mask 做多次查询

这样做的好处是 mask 只计算一次,后面反复复用。如果每次筛选都重新写一遍条件表达式,大数据量下重复计算的开销很可观。而且单独看mask.sum()能让你在真正取数之前就知道筛出来的量级对不对——筛出 0 行或者筛出全表,通常都说明条件写错了。

2.3 query 适合什么、不适合什么

df.query()用字符串表达式写筛选条件,好处是可读性,尤其是条件很长的时候:

df.query("amount > 100 and status == 'paid' and region in @regions")

注意这里可以用and、or、in,跟布尔索引的语法不一样,因为 query 内部是走表达式解析的,不是 Python 的运算符。@变量名用来引用外部 Python 变量,这个特性很实用。

但我不会在所有场合都用 query,原因有三点。一是列名含特殊字符时很麻烦,比如列名带空格、带点、带中文标点,需要用反引号包起来,写起来反而更丑。二是字符串拼接容易出错,如果条件是从外部拼进来的,容易变成注入式的写法,也不利于静态检查。三是调试信息不如布尔索引直观,出问题时我得先怀疑表达式解析,再怀疑数据本身。

我的实际选择是:简单条件用布尔索引,条件超过三个且都是数值或简单字符串比较时用 query,涉及复杂函数调用(比如.str.contains()、.dt.时间属性)时回到布尔索引。

2.4 按列取子表的几种姿势和它们的拷贝语义

取子表看起来简单,但拷贝和视图的区别会直接影响后面能不能赋值:

sub = df[["a", "b"]] # 新对象,通常是拷贝 sub = df.loc[:, ["a", "b"]] # 同样是新对象 sub = df.filter(regex="^amt_") # 按列名正则批量选 sub = df.select_dtypes("number") # 按类型批量选

后两种在列特别多的时候非常好用。select_dtypes尤其在"我只要数值列来算相关性"这种场景下,比手动列清单可靠得多,不会漏。而filter支持like、regex、items三种模式,做列名前缀匹配时比手写列表省事。

提示:df[["a"]]返回的是 DataFrame,df["a"]返回的是 Series,df[["a"]]和df["a"].to_frame()结果等价但前者更直接。这个细节在给函数传参时经常出问题——函数内部期望 DataFrame,结果传进去一个 Series,在后面用df["col"]时直接报错。

3. 列级操作:派生新列、类型转换与那个烦人的警告

数据清洗里大概有一半的工作量在"造新列"上:从日期列里拆出年月、从金额列里算出区间、从多个字段拼出一个唯一键、根据规则打标签。这些操作的写法差异极大,写得好的几行搞定,写得差的嵌套十几层 if-else 还慢。

3.1 用 np.select 和 np.where 替掉逐行 if-else

新手最常见的写法是apply加一个自定义函数,函数里写一长串if/elif/else。这种写法逻辑清楚,但性能很差,因为apply本质上是逐行调 Python 函数,绕过了 pandas 的向量化。

更合适的做法是分层处理。只有两个分支时用np.where:

df["is_large"] = np.where(df["amount"] >= 1000, 1, 0)

np.where(条件, 真值, 假值)是向量化的,速度比apply快一到两个数量级。注意"假值"那个位置如果不写,会报参数错误,所以要么写np.where(cond, 1, 0),要么写np.where(cond, 1, np.nan)。

分支超过两个时用np.select:

conditions = [ df["amount"] < 100, (df["amount"] >= 100) & (df["amount"] < 1000), df["amount"] >= 1000, ] choices = ["small", "medium", "large"] df["size_label"] = np.select(conditions, choices, default="unknown")

np.select是按顺序匹配的,第一个命中的条件就返回对应的值,后面的不再判断,所以条件的顺序很重要。另外default一定要显式写上,否则未命中的行会返回0,这个默认值非常隐蔽,容易让后面做字符串处理时报错或者产出奇怪的类别。

注意:np.select的choices里所有元素类型要一致或者能自动对齐。混着放字符串和数字,结果列会变成object,后续做聚合时又得多一步转换。

3.2 astype 的类型选择:为什么有些列该转 category,有些该转 Int64

类型转换是提升性能和减少内存最直接的手段,但很多人只会在报错时临时astype(str)一下,转完就不管了。我一般会按下面这个表来做判断:

原始情况推荐类型主要收益注意点
低基数文本列(唯一值 < 50)category内存降到原来的十分之一左右排序按类别顺序还是字典序要显式指定
带缺失的整数列Int64(大写 I)保留整数语义又能存缺失不能用int64,会报错
带缺失的布尔列boolean同上不能用bool
高精度金额float64或整数分避免精度误差别用float32存金额
日期字符串datetime64[ns]能用.dt访问器必须指定format,否则解析慢且易错

Int64和int64的区别值得单独提醒。传统numpy.int64没有缺失值表示,一列整数只要有一个 NaN,整列就会被迫升级成float64,于是1变成1.0,做 ID 匹配时"1"和"1.0"对不上。pandas 提供的可空类型Int64(注意大写 I)就是为了解决这个问题,它用pd.NA表示缺失,同时保持整数语义。

df["user_id"] = df["user_id"].astype("Int64") df["status"] = df["status"].astype("category") df["created_at"] = pd.to_datetime(df["created_at"], format="%Y-%m-%d %H:%M:%S", errors="coerce")

pd.to_datetime里的format参数建议永远写上。不写的话 pandas 会尝试自动推断格式,遇到混合格式时不仅慢,还可能把03/04/2024解析成 3 月 4 日还是 4 月 3 日完全看运气。errors="coerce"的作用是把解析失败的置为NaT,而不是整列报错——这个参数在清洗脏日期时几乎是必加的。

3.3 SettingWithCopyWarning 的真实成因和三种解法

这个警告大概是 pandas 使用者遇到频率最高的一个,很多人被它烦到直接pd.options.mode.chained_assignment = None一键关掉。关掉能清净,但问题还在:你的赋值可能完全没有生效。

警告的触发条件是"链式索引 + 赋值"。所谓链式索引,就是df[mask]["col"] = value或者df.loc[mask]["col"] = value这种用两个方括号连着取的结构。问题在于第一个方括号返回的到底是一个视图还是一个拷贝,pandas 无法保证,于是它警告你"我不确定你这个赋值改的是原表还是临时对象"。

# 危险:可能改不到原表 df[df["amount"] > 100]["flag"] = 1 # 安全:一次 loc 完成定位和赋值 df.loc[df["amount"] > 100, "flag"] = 1

正确写法是用一次loc,把行条件和列名放在同一个方括号里。这样 pandas 明确知道你要改的是原表的哪一部分,既不会警告,也能保证生效。

还有一种情况的警告来自切片后的操作:

sub = df[df["amount"] > 100] sub["flag"] = 1 # 这里会警告 sub = df[df["amount"] > 100].copy() sub["flag"] = 1 # 加上 copy() 就不警告了

.copy()明确告诉 pandas"我要一份独立的数据",代价是多一次内存拷贝。如果sub后面还要做一堆修改,这个拷贝是值得的;如果只是临时看一眼,那就别赋值,直接读就行。

第三种解法是用assign,它返回新对象,天然不会触发警告:

sub = df[df["amount"] > 100].assign(flag=1, amount_cny=lambda d: d["amount"] * 7.2)

assign里可以用lambda d: ...引用当前表,做到"基于新列算新列"的链式写法,读起来很顺。我在做特征工程时经常用一整串assign把派生列一次性造完,代码比一行行赋值干净得多。

4. 分组聚合:agg、transform、apply 三兄弟的边界

分组聚合是 DataFrame 真正发挥价值的地方,也是我觉得最需要理解"形状"这个概念的地方。同样是按某个键分组算统计量,agg、transform、apply返回的结果形状完全不同,用错了要么报错,要么得到一张对不上的表。

4.1 groupby 的拆分-应用-合并心智模型

groupby的内部过程可以拆成三步:按 key 把行拆成若干组、对每组独立应用一个函数、把各组结果合并回一个对象。理解这三步之后,很多"为什么结果长这样"的疑问就自动消解了。

g = df.groupby("region", observed=True) g.size() # 每组多少行 g["amount"].sum() # 每组金额合计 g["amount"].mean() # 每组金额均值

这里的observed=True是给category类型准备的参数。当分组键是category且类别很多时,默认行为会把所有类别组合都列出来(包括数据里根本没出现的),生成大量全 NaN 的行,既慢又占内存。加上observed=True只输出实际观察到的组合,这是我踩过的一个实实在在的坑——一张几万行的表分组之后变成几十万行,排查半天才发现是未观测类别在作怪。

分组键也可以是多个列、一个 Series、甚至一个函数:

df.groupby(["region", "status"], observed=True)["amount"].sum() df.groupby(df["created_at"].dt.month)["amount"].sum() df.groupby(lambda i: i % 10)["amount"].sum()

按函数分组在日常中用得不多,但在做"按行号分桶"这类操作时很方便。

4.2 transform 与 agg 的区别:形状决定命运

这是我认为最值得讲清楚的一点。agg返回的是每组一行的汇总表,行数等于组数;transform返回的是与原始表等长的序列,每个位置填的是该行所属组的统计值。

# agg:每个 region 一行 df.groupby("region", observed=True)["amount"].agg(["sum", "mean"]) # transform:与 df 等长,每行都带上自己所在组的均值 df["region_avg"] = df.groupby("region", observed=True)["amount"].transform("mean")

transform的典型用途是"去均值化"和"组内占比":

df["amount_ratio"] = df["amount"] / df.groupby("region", observed=True)["amount"].transform("sum") df["amount_cmp_avg"] = df["amount"] - df.groupby("region", observed=True)["amount"].transform("mean")

如果用agg去做这件事,就得先算出一张汇总表,再merge回原表,多两步操作还容易在键上出错。transform一步到位,索引自动对齐,是我在特征工程里用得最多的 groupby 方法。

4.3 命名聚合和多级索引的展平

pandas 支持在agg里用命名聚合的写法,直接给结果列起名字,避免多级列索引:

result = df.groupby("region", observed=True).agg( total_amount=("amount", "sum"), avg_amount=("amount", "mean"), order_cnt=("order_id", "nunique"), first_time=("created_at", "min"), )

这种写法的好处是结果直接是扁平的单层列名,result["total_amount"]就能取到值,不需要再处理("amount", "sum")这种元组列名。如果不用命名聚合,结果会带上多级列索引,取值要写成result[("amount", "sum")],或者用result["amount"]["sum"],后者在新版本里已经不推荐。

行方向的多级索引也要处理:

result = result.reset_index()

groupby之后行索引会变成分组键组成的多级索引,如果要把结果存成 CSV 或者传给别的系统,基本都要reset_index()把它变回普通列。我习惯在groupby链条的末尾固定加上这一步,形成肌肉记忆。另外还有个as_index=False参数可以在分组时就直接避免多级索引,效果等价:

result = df.groupby("region", as_index=False, observed=True).agg( total_amount=("amount", "sum"), )

我更倾向于用as_index=False,因为它让整条链从源头就是扁平的,后面接sort_values、merge都不会因为索引而出现意外。

5. 表拼接与重塑:merge 的连接语义与 concat 的轴

单表操作再熟练,实际工作里也躲不开"把两张表拼起来"。拼接出问题的概率比单表操作高得多,因为涉及行数变化,一旦错了往往不会报错,只会得到一张"看起来正常但数字全错"的表。

5.1 四种 join 的语义区别和一个真实的对不上账案例

merge的how参数有inner、left、right、outer四种,外加较新版本的cross。它们的区别在于"键不匹配时保留谁":

how保留左表未匹配行保留右表未匹配行典型用途
inner否否只关心两边都有的记录
left是否以左表为主,补充右表字段
right否是反过来
outer是是做全量比对,看差异
cross笛卡尔积笛卡尔积生成组合场景,慎用

我遇到过的一个典型问题是这样的:一张订单表按order_id左连接一张支付表,连接之后行数从 10 万涨到了 12 万。原因不是连接写错了,而是支付表里同一个order_id有多条记录(用户重试支付、部分退款),一对多连接天然会扩行。表面上表还是那张表,但金额列被重复计算了,汇总出来的总额比实际高出两成。

排查这类问题我有个固定动作:

before = len(df) merged = df.merge(pay, on="order_id", how="left", validate="one_to_many") after = len(merged) print(before, after)

validate参数是 pandas 提供的"连接前校验",取值有one_to_one、one_to_many、many_to_one、many_to_many。如果实际关系不符合声明,它会直接抛MergeError,而不是默默扩行。这个参数我建议只要写merge就带上,尤其是生产脚本里——它相当于一个免费的数据质量断言。

5.2 用 indicator 查明"为什么匹配不上"

连接之后发现某些字段全是 NaN,第一反应往往是"右表没这个字段",但更常见的原因是键对不上。这时候indicator=True就非常有用:

merged = df.merge(pay, on="order_id", how="left", indicator=True) print(merged["_merge"].value_counts())

结果里_merge列有三个可能值:both(两边都有)、left_only(只有左表)、right_only。看一眼left_only的比例,就知道有多少行没匹配上。如果比例高得离谱,接下来要怀疑的对象依次是:两边键的类型是否一致(一边int64一边object,1和"1"匹配不上)、是否有前后空格、是否有大小写差异、日期精度是否一致。

键类型不一致是最隐蔽的。因为merge在这种情况下的行为是"尽力而为",有时能匹配上、有时匹配不上,取决于具体值。我的习惯是在merge之前强制把两边的键都转成同一种类型:

df["order_id"] = df["order_id"].astype("string").str.strip() pay["order_id"] = pay["order_id"].astype("string").str.strip()

用string而不是object存文本,好处是缺失值统一用pd.NA,.str访问器的行为也更一致。

5.3 concat 的轴选择与"上下拼"和"左右拼"

concat处理的是同构数据的拼接,不像merge那样有键的概念。axis=0是纵向堆叠(加行),axis=1是横向拼接(加列)。

# 纵向:多个月的表叠起来 full = pd.concat([jan, feb, mar], axis=0, ignore_index=True) # 横向:按索引对齐拼列 wide = pd.concat([features, labels], axis=1)

ignore_index=True在纵向拼接时几乎必加,否则每段数据的原索引会被保留,结果里出现重复索引,后面用loc就会取到多行。横向拼接则依赖索引对齐,如果两个表的索引顺序不一样,concat(axis=1)会按索引匹配而不是按位置匹配,这一点和np.hstack完全不同。我一般会在横向拼接前确认两个表的索引是一致且唯一的,否则就先reset_index(drop=True)。

纵向拼接还有个keys参数可以生成多级索引标记数据来源:

full = pd.concat([jan, feb, mar], keys=["2024-01", "2024-02", "2024-03"], names=["month", "row"])

这样拼完之后可以用full.loc["2024-02"]单独取出某个月的数据,做分段分析时很方便。

5.4 pivot_table 和 melt:宽表与长表的双向转换

这两种形态的转换在报表场景里出现频率极高。pivot_table把长表转宽,melt把宽表转长。

# 长转宽:每个 region 一行,每个月份一列 wide = df.pivot_table( index="region", columns="month", values="amount", aggfunc="sum", fill_value=0, ) # 宽转长 long = wide.reset_index().melt( id_vars="region", value_vars=["2024-01", "2024-02", "2024-03"], var_name="month", value_name="amount", )

pivot_table的aggfunc参数是它和pivot的关键区别。pivot要求(index, columns)组合唯一,一旦有重复就报错;pivot_table默认用mean聚合,遇到重复会自动合并。很多人搞不清两者区别,用pivot遇到ValueError: Index contains duplicate entries就懵了,其实换成pivot_table并显式指定aggfunc就解决了。不过要记住:自动聚合意味着你在不知道的情况下丢掉了明细,所以用pivot_table时一定要想清楚聚合函数是不是你想要的。

melt时要注意value_vars的顺序决定输出行的顺序,如果需要保持特定顺序,就按顺序列出列名,不要图省事用默认值。

6. 缺失值、重复值和内存:那些跑得慢又跑错的场景

前面几节讲的都是"让结果对",这一节讲"让结果对的同时别把机器跑死"。清洗类操作最容易写出看似正确但性能极差的代码,因为每一步单独看都合理,叠加起来就变成了指数级开销。

6.1 NaN、None、NaT、NA 到底有什么区别

pandas 里表示缺失的符号有四个,混淆它们会导致各种奇怪的行为:

符号类型出现场景特点
np.nanfloat数值列缺失是 float,nan != nan为 True
NoneNoneTypeobject 列缺失Python 原生空值
pd.NaTdatetime时间列缺失Not a Time 的缩写
pd.NANABoolean可空类型缺失pandas 1.0 引入的统一缺失值

判断缺失统一用isna()或notna(),不要用== np.nan。因为np.nan == np.nan的结果是False,这个反直觉的设定来自 IEEE 浮点标准,用等于号判断缺失永远判断不出来。

df.isna().sum() # 各列缺失数 df.isna().any(axis=1) # 哪些行有缺失 df.dropna(subset=["order_id"]) # 只按关键列删 df.fillna({"amount": 0, "region": "unknown"}) # 按列分别填充

fillna按列传字典比全表统一填一个值靠谱得多。全表填 0 会把文本列也变成0,产生一堆语义错误的记录。填充策略上我的原则是:数值列填 0 只在"0 有业务含义"时成立,比如"缺失的金额"确实应该算 0 元;如果缺失代表"没记录",那填 0 会污染均值,这时候填median或者保留缺失、在建模时单独处理更稳妥。文本列填"unknown"而不是空字符串,因为空字符串在groupby时会被算作一个正常类别,而"unknown"语义明确得多。

6.2 drop_duplicates 的 subset 参数与"全列去重"的陷阱

df.drop_duplicates()不加参数时是按所有列组合去重,只要有一列不同就算不同行。这个默认行为在宽表里经常失效,因为时间戳精确到毫秒、或者某个随机 ID 列每行都不同,导致一行重复记录都删不掉。

正确的做法是显式指定业务主键:

df.drop_duplicates(subset=["order_id", "sku_id"], keep="last")

keep有三个取值:"first"(保留第一次出现)、"last"(保留最后一次)、False(把所有重复的全部删掉,一条不留)。第三者在做"只保留唯一记录"的严格场景下有用,但很容易误伤——如果某个order_id出现了三次,keep=False会把三条全部删掉,而不是保留一条。

去重之前我喜欢先看一眼重复情况:

dup_mask = df.duplicated(subset=["order_id", "sku_id"], keep=False) print(dup_mask.sum()) print(df.loc[dup_mask].sort_values("order_id").head(20))

把重复的行按主键排好序打印出来,能直观看到重复的原因——是不是时间戳不同、是不是状态不同、是不是真的完全重复。这一步花不了一分钟,但能避免"盲目去重导致丢数据"。

6.3 用 category、分块读取和 downcast 把内存压下来

内存优化的收益在大表上非常明显。我做过一次实测:一张 500 万行、30 列的订单明细,原始占用约 2.3 GB,经过三步优化降到 700 MB 左右,脚本的稳定性和速度都明显改善。

第一步是把低基数文本列转category:

for col in df.select_dtypes("object").columns: if df[col].nunique() / len(df) < 0.05: df[col] = df[col].astype("category")

这里的判断标准是"唯一值占行数的比例小于 5%"。比例越低,category的收益越大,因为它把重复的字符串只存一份,其余位置存整数编码。反过来,如果一列几乎每行都不同(比如订单号),转category反而会额外增加编码表的内存,得不偿失。

第二步是数值降精度:

df["quantity"] = pd.to_numeric(df["quantity"], downcast="integer") df["price"] = pd.to_numeric(df["price"], downcast="float")

downcast会在不溢出的前提下自动选最小的类型,比如把int64降成int16。对于数量、次数这类数值,收益很可观。但金额列不要降成 float32,因为 float32 只有约 7 位有效数字,累加几百万行之后误差会显现出来,对账的时候对不上。金额我一般用整数分存储,或者保持 float64。

第三步是读取时就分块处理:

chunks = [] for chunk in pd.read_csv("big.csv", chunksize=200_000, dtype={"order_id": "string"}): chunk = chunk[chunk["status"] == "paid"] chunks.append(chunk) df = pd.concat(chunks, ignore_index=True)

分块读取的核心价值不是"省内存",而是"能在读的同时过滤"。全量读进来再筛选,峰值内存是整表大小;分块读加筛选,峰值内存只有单块大小加上结果集大小。dtype参数在读取时指定类型,比读进来再astype更省,因为省掉了中间态。

注意:分块读取时如果有跨块才能计算的操作(比如全局去重、全局排序),需要先把结果攒起来,最后统一做一次。我一开始想当然地在每个 chunk 里做drop_duplicates,结果跨块的重复根本没被删掉,白忙一场。

7. 我在长期使用中固化下来的几个习惯

踩过的坑多了之后,有些动作变成了条件反射,写脚本时不加反而觉得别扭。这里说三个我觉得最值钱的。

第一个是每一步都打印形状。merge之后打行数、dropna之后打行数、groupby之后打行数,这些print看起来很啰嗦,但一旦某一步行数变化超出预期,立刻就能定位。我维护过一个几百行的清洗脚本,出问题时全靠这些行数日志在十分钟内找到原因。

第二个是不滥用inplace=True。这个参数能少写一次赋值,但代价是返回值是None,一旦写成df = df.dropna(inplace=True),df直接变成None,后面所有操作全崩。而且要区分清楚,dropna这类方法也支持inplace=False的嵌套,所以链式写法要避免inplace=True混入。我的做法是彻底不用inplace,全部用df = df.xxx()的形式,可读性和安全性都更好。

第三个是把重复出现的清洗逻辑封成函数。比如"标准化订单号"这套动作(转字符串、去空格、转大写、去重)我在好几个脚本里都用到,写成函数之后既不用反复抄,又能保证口径统一。函数参数上我习惯加一个inplace=False的风格,返回新表而不是改原表,这样在调试时可以随时对比前后差异。

对于超大的表,我还会额外加一步"抽样验证":改动逻辑先在df.sample(10000)上跑一遍,确认结果对不对,再上全量。这一步能省掉大量"跑了二十分钟才发现逻辑写错"的时间浪费。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/6 4:14:29

Claude Code实时协作编程:提升团队效能的实践与思考

1. 项目概述"Claude Code"这个术语在技术社区中通常指代一种新兴的协作编程范式或工具链。作为一名经历过多次技术变革的团队技术负责人&#xff0c;我亲眼目睹了这类工具如何重塑开发团队的协作方式。不同于传统的版本控制系统或简单的代码审查工具&#xff0c;Clau…

作者头像 李华
网站建设 2026/11/6 4:14:26

局域网大文件分块上传与断点续传技术详解

1. 局域网大文件分块上传方案概述在局域网环境中实现大文件上传&#xff0c;WebUploader是一个经过验证的可靠方案。与公网环境相比&#xff0c;局域网传输具有带宽高、延迟低的优势&#xff0c;但也面临一些特殊挑战&#xff1a;浏览器兼容性&#xff1a;企业内网常存在老旧浏…

作者头像 李华
网站建设 2026/11/6 4:13:26

使用Docker部署4ga Boards看板工具全指南

1. 项目概述&#xff1a;为什么选择4ga Boards看板工具在团队协作和项目管理领域&#xff0c;看板工具已经成为现代工作流程中不可或缺的一部分。4ga Boards作为一款轻量级的看板工具&#xff0c;以其简洁的界面和高效的任务管理能力赢得了不少团队的青睐。相比那些功能臃肿的商…

作者头像 李华
网站建设 2026/11/6 4:13:26

Java+SSM与Flask混合架构在少儿编程系统中的应用

1. 项目概述&#xff1a;少儿编程在线培训系统的核心价值这个少儿编程在线培训系统项目&#xff0c;是我在2022年为某教育科技公司开发的线上教学平台。当时国内少儿编程教育市场规模已达300亿元&#xff0c;年增长率超过20%&#xff0c;但市面上大多数系统要么功能单一&#x…

作者头像 李华
网站建设 2026/11/6 4:13:06

高可用架构设计:从五个九到抗脆弱系统

1. 项目背景与核心观点"2025互联网崩塌启示录"这个标题背后反映的是当前互联网基础设施面临的系统性风险。作为一名经历过多次大规模服务中断的运维工程师&#xff0c;我深刻体会到传统高可用架构的局限性。所谓"五个九"&#xff08;99.999%可用性&#xf…

作者头像 李华
网站建设 2026/11/6 4:13:06

基于MSP430F149与LM2576的并联供电均流系统设计

简介&#xff1a;面向电子设计竞赛备战者的一份2011年全国大学生电子设计竞赛A题完整论文&#xff0c;聚焦开关电源模块并联供电系统的设计与实现&#xff0c;适合需要参考赛题方案、均流控制思路和硬件选型依据的参赛学生与电子设计人员。文档围绕MSP430单片机与LM2576电源芯片…

作者头像 李华