我这几年代人做数据清洗,最常听的一句话就是:"把重复的删掉就行。" 说这话的时候大家都很轻松,仿佛重复值是什么一眼就能看穿的东西。可真动手之后才发现,drop_duplicates()一行代码确实能跑,但跑完之后的结果到底对不对,完全是另一回事。删除重复值这件事,表面上是 pandas 的基础操作,实际上牵扯到一个核心问题:什么样的行,在你的业务场景里才算"重复"?是订单号相同就算重复?还是用户ID加下单时间完全一致才算重复?甚至更进一步,同一用户下了一模一样的单,到底该保留第一次的,还是保留最新的?这些决策直接决定了你去重之后的数据质量,也决定了后面做的所有统计、分析和报表是不是靠谱。这篇文章我只讲一件事:pandas 里去除重复值的完整打法。我会把drop_duplicates()和duplicated()的每一个参数聊透,把"什么叫重复"讲明白,把索引错乱、性能退化、NaN参与去重这类坑一个个排掉。内容适合刚接触 pandas 的初学者,也适合已经写了半年一年数据处理脚本、但偶尔还会被重复值坑一把的同学。全文用订单数据和用户行为数据做示例,代码都是可以直接拿去改着用的。1. 去重不是删行,是回答"什么叫相同"先别急着写代码。我一直觉得,pandas 的drop_duplicates()之所以容易用错,是因为大家把它理解成了"删掉长得一样的行",于是参数随手一填就完事。实际上,去重动作背后藏着一个业务定义:你要认定哪些列相同,才算同一条记录。举个例子。某次我在处理一份用户浏览日志,字段包括user_id、page_url、visit_time、device。这份日志里出现了大量"用户在一分钟内反复点击同一个链接"的数据,产品经理想统计独立访问量,希望把这些"短时间重复"去掉。这时候如果直接:import pandas as pd df = pd.read_csv("user_log.csv") df = df.drop_duplicates()那等于什么都没做——因为两次点击的visit_time精确到秒,几乎不可能完全一致,pandas 默认按"所有列完全相等"来判断,结果一条都不会删。正确的做法是先想清楚业务上的重复定义。对于"短时间重复点击"这个场景,应该按user_id和page_url来判定,跟visit_time和device没关系:df_dedup = df.drop_duplicates(subset=["user_id", "page_url"])这样才算把"同一用户在同一页面上的重复访问"去掉了。这里你可能会问,那保留哪一行呢?是保留第一次访问,还是保留最后一次?这又牵涉到下一个参数。但至少从判断标准上看,subset决定了什么叫做"重复"。所以每次写去重代码之前,先花十秒钟回答三个问题:以哪些列作为唯一标识?如果有多列,是这几列都相同才算重复,还是任意一列相同就算重复?(pandas 默认是"都相同",大多数业务也是这个逻辑)重复的行里,保留哪一条?这三个问题的答案,就是drop_duplicates()的subset和keep两个参数。别小看这两个参数,后面所有复杂场景都是它们的组合。2. drop_duplicates 的参数语义:不只是 keep first 和 keep last2.1 subset 的边界:列少了一个,结果差一个数量级subset参数接受一个列名列表。它的作用是告诉 pandas:我只拿这些列做比较,其他列我不管。我用一个很经典的订单表场景来说。假设有一份订单明细,结构是这样的:order_iduser_idproduct_idorder_timediscountA001U01P1002024-03-01 10:00:000.9A002U01P1002024-03-01 10:00:000.85A003U01P1002024-03-01 10:00:000.9这三行里,user_id、product_id、order_time都相同,但order_id和discount不同。它们算不算重复?如果业务上认为"同一用户在同一时间买了同一商品只算一次",那么它们就是重复;如果认为"这是两笔独立的订单",那它们就是正常业务数据。这不只是技术问题。我当时处理这类数据时,业务方常用的口径是"用户在同一秒内买同一个商品,大概率是前端重复提交",所以判定标准就是user_id + product_id + order_time。于是去重代码是:df_orders_dedup = df_orders.drop_duplicates( subset=["user_id", "product_id", "order_time"] )关键点是:subset是"业务唯一键"的映射。你在数据分析里见过primary key的概念吧,subset就是你在 pandas 里临时定义的一个"复合主键"。还有一个容易忽略的细节:subset里列的先后顺序不影响判断结果。["user_id", "product_id"]和["product_id", "user_id"]的判断逻辑完全一样,因为 pandas 是按多列的联合值来比较的,而不是按顺序先后比较。这一点和某些数据库索引不同,不必担心。2.2 keep 的真正含义:保留哪一行,取决于你的"最新"定义keep参数有三个可选值:"first"、"last"、False。keep="first":保留相同记录里第一次出现的那一行。keep="last":保留相同记录里最后一次出现的那一行。keep=False:把所有重复行全部删掉,保留的全都是只出现一次的行。很多教程会这样解释,但没告诉你的是,keep="first"和keep="last"并不是简单的"保留第一行还是最后一行",而是以数据当前的排列顺序为准。如果你想按"时间最新的那条"来保留,就需要先排序,否则keep="last"保留的可能只是 DataFrame 里排序靠后的行,而不是业务上最新的一条。举个我实际遇到的例子。业务方需要生成一份"每个商品的当前有效配置",数据来自一份历史上 30 天内每天更新一次的配置快照。同一个product_id会有多行,每行带update_date,我们要保留的是最新一次更新的配置。如果数据本身是按update_date从小到大排列的,那直接写:latest_cfg = df_cfg.drop_duplicates( subset=["product_id"], keep="last" )是没问题的。但如果原始数据没有按时间排,甚至顺序是乱的,那这个写法就危险了。正确姿势是先把时间排序,再去重:df_cfg_sorted = df_cfg.sort_values( ["product_id", "update_date"], ascending=[True, True] ) latest_cfg = df_cfg_sorted.drop_duplicates( subset=["product_id"], keep="last" )先排序、再去重、再取last,这三步是一个组合拳。我这里按update_date升序排,排完后每一组重复记录里,最后一行就是最新配置。你问为什么不能直接drop_duplicates之后再排序?能,但那样你筛选出来的行可能不是你想要的那条,再去排序只是给错误结果排了个序而已。顺序问题一定要放在去重之前解决。2.3 keep=False 的威力:把重复行变成"可追溯的异常"keep=False的价值很多人低估了。它的作用是把出现多次的记录全部剔除,保留下来的每一行在指定的subset上都是唯一的。这个参数在"清洗脏数据"场景里特别好用。比如说你有一份用户基础信息表,理论上user_id应该是唯一的,但导入过程中出了问题,某些user_id出现了两三次。如果你只保留其中一条,万一保留了错误的那条,后续发消息、打标签全都会出错。这时候更稳妥的策略是:先把重复的用户全部揪出来,单独做人审。dup_marks = df_user.duplicated(subset=["user_id"], keep=False) df_dup_users = df_user[dup_marks]这段代码里,duplicated(keep=False)会把这组重复记录的所有行都标记为True,然后用布尔索引把它们全取出来。这样你拿到的就是"所有异常行",而不是"其中一行"。等人工核对完,决定哪条是有效数据之后,再单独放回去。这种做法比默认的keep="first"更稳妥。默认的keep="first"会直接保留第一条,如果第一条本身就是坏数据,你连发现的机会都没有。做数据清洗,宁可多跑一步检查,也不要让脏数据悄悄漏过去。3. 不只是删行:用 duplicated 做标记、统计和异常发现3.1 duplicated() 才是数据质量审计的核心工具很多同学用到drop_duplicates()就去干别的了,但我会建议你在删之前,先花三秒钟用duplicated()看一眼数据里到底有多少重复。duplicated()返回的是一个布尔型的 Series:每一行如果是重复行(即当前行之前出现过一次或多次),就标记为True。它不是用来删数据的,而是用来做标记的。这个标记功能在数据质量审计里极为有用。比如我在每次跑批任务之前,都会生成一份"重复率快报":dup_count = df.duplicated(subset=["user_id", "log_date"]).sum() total_count = len(df) dup_rate = dup_count / total_count print(f"待核查重复记录数: {dup_count}") print(f"重复率: {dup_rate:.2%}")重复率超过某个阈值,我会先停下手里的分析,回头查数据源。因为一条正常的数据流水,重复率通常不会很高,一旦超过 5% 或者 10%,大概率不是业务问题,而是上游同步任务重复执行了。你要是闷头去重,等于把上游的 bug 掩盖掉了。3.2 把"重复标记"变成一列,保留完整证据链有一种更实用的做法:不去删行,而是把是否重复的结果作为新的一列保存下来。这样后续分析时可以随时筛选,也可以作为"这条数据被清洗过"的凭证。df["is_dup"] = df.duplicated(subset=["user_id", "order_id"], keep=False) df_suspicious = df[df["is_dup"]]加上keep=False后,是重复的每一行都会标成True。我把这些True行单独拉出来看的时候,可以同时看到重复的每一行长什么样,确认到底哪一列有差异,为什么会被判成重复,以及是否有合并的可能。这个"证据链"思路在正式项目里非常重要。很多人做数据清洗是闷头删,删完之后老板问"你删了什么,为什么删",答不上来。保留一个标记列,随时能给出一个清单:哪些行是重复的、判定依据是哪几列、保留的是哪一行。这一套下来,数据团队和业务方的信任感会好很多。3.3 用分组统计识别"同款但不同价"的疑似重复还有一种情况比完全重复更隐蔽:业务上高度疑似重复,但没有哪几列是完全相等的。比如同一用户同一个收货地址,在 10 分钟内连续下了两单,都买了同一个商品。这种情况下order_id不同、order_time不同,drop_duplicates()根本没法定义它们重复。但业务方会觉得这就是重复下单。遇到这种场景,pandas 去重的常规参数不够用,我一般配合分组统计来找出疑似记录:suspect = ( df.groupby(["user_id", "receiver", "product_id"], as_index=False) .agg( order_cnt=("order_id", "nunique"), min_time=("order_time", "min"), max_time=("order_time", "max") ) ) suspect = suspect[ (suspect["order_cnt"] >= 2) & (suspect["max_time"] - suspect["min_time"] <= pd.Timedelta(minutes=10)) ]这段代码先按用户、收货人和商品分组,统计下单次数和最早最晚时间,再筛出"10 分钟内同人同地址同商品下过两单以上"的记录。这些是疑似重复,通常需要业务方确认是否只保留一单。我特别想强调这一点:去重不永远是技术问题,很多时候是口径问题。工具能帮你执行规则,但规则本身得靠业务确认。所以我做这类任务的习惯是,先跑duplicated()看全量重复情况,再跑分组统计找疑似重复,最后把两份结果汇总成一张问题清单,再动手删除。这样每一步都有据可查。4. 去重之后的连锁反应:索引、就地修改和性能4.1 索引错乱:删完行后为什么不连续了drop_duplicates()删掉行之后,DataFrame 的行索引并不会自动重新排列,被删掉的行的索引位置会直接消失。比如一份 1000 行的数据,去重后剩 800 行,索引可能是0, 1, 3, 5, 6...,中间缺了很多。这在后续做df.loc[某个索引]的时候会出大问题。最常见的就是你根据一个索引列表去另一个表里取数据,结果索引对不上,取回了一堆NaN。解决方案简单粗暴:去重后立刻重置索引。df_clean = df.drop_duplicates(subset=["user_id"]).reset_index(drop=True)这里的drop=True表示不要把原来的索引变成新列。如果你不加,pandas 会把旧的索引保留为一个叫index的列,你的 DataFrame 里就多出一列莫名其妙的数据。这个小细节很多人第一次都会遇到。4.2 inplace=True 真的省内存吗?别被名字骗了关于inplace=True,网上的说法很混乱。我的建议是:没事别用。df.drop_duplicates(subset=["user_id"], inplace=True)这行代码确实能就地修改df。但 pandas 底层并不是完全不创建新对象,很多inplace操作依然会先算出结果再替换,内存上的好处没有想象中大。更麻烦的是,inplace=True会让代码的链式调用和回溯变得很难检查——你没法清楚地知道当前这个 DataFrame 到底是什么状态。我自己的习惯是:始终显式赋值。df_processed = df.drop_duplicates(subset=["user_id"]).copy()这里多说一句copy()。drop_duplicates()返回的数据可能是原始数据的视图,也可能是副本,具体取决于 pandas 内部优化。如果你去重之后还要继续修改列,为了避免SettingWithCopyWarning的警告,提前.copy()是最省心的做法。这个警告不致命,但留着很烦人,而且说明你的代码可能在修改一个副本,改了等于没改。4.3 上百万行的 DataFrame 去重为什么慢,怎么提速当数据量到百万行以上,drop_duplicates()有时会变得很慢。原因在于 pandas 需要把所有行进行两两比较,或者内部构建哈希结构,而这个过程中如果subset包含文本类列(比如很长的 URL、备注文本),哈希计算成本就会很高。几个实测有效的提速方案:先删无用列再去重。如果分析根本不需要某些列,先用df[["user_id", "page_url"]]取出子集再去重,可以大幅减少比较的列数。把分类列转成 category 类型。对于一些取值种类有限的列,转成category之后 pandas 的比较会走整数路径,速度会快不少。df["user_id"] = df["user_id"].astype("category") df_dedup = df.drop_duplicates(subset=["user_id", "page_url"])不过要小心,转category之后如果后续要和其他 DataFrame 做合并,可能遇到类别不一致的问题,用完建议再转回object或者string。先排序再去重。对于部分数据,先sort_values()把相同记录聚到一起,再去重,底层比较的局部性变好,速度也会改善。但这不是所有场景都有效,我在字符串列上试过,有时反而更慢。建议拿真实数据做个基准测试再决定。用dask处理超大文件。如果数据量已经到了几十个 G,单机 pandas 就不太合适了。dask 的drop_duplicates接口和 pandas 几乎一致,可以先把dask.dataframe跑起来,最后转回 pandas。这类场景我碰到的不多,但每次碰到都是直接用 dask 解决,不用硬扛。这些性能优化说白了是在跟 pandas 的内部实现打交道,不同版本、不同数据特性下结论会不一样。所以我会建议:先按默认方式跑一遍,用time记录耗时,再针对性地优化。不要一上来就把代码写得很复杂。5. 我踩过的那些坑:NaN、空格、浮点数和类型陷阱5.1 NaN 参与去重时的微妙行为最典型的翻车现场是:数据里有一列是空值,而你恰好把它放进了subset。pandas 的规则是,两个NaN在去重判断时会当成"相等"——也就是说,同一用户 ID 下多行都是空值的时候,这些行会被判定为重复。这在业务上可能不对。比如你统计用户收货地址时,有些用户还没填地址,地址列全是NaN,但你依然希望保留每个用户的一条记录——不,这里情况更复杂:如果按["user_id", "address"]去重,一个用户所有没填地址的记录会被合并成一条。如果业务想保留其中一条带具体时间信息的记录,那么这种去重就会丢掉信息。我的处理方式非常直接:把需要参与去重的字段先填充,或者把空值字段排除在 subset 之外。df["address_filled"] = df["address"].fillna("__EMPTY__") df_dedup = df.drop_duplicates(subset=["user_id", "address_filled"])暂时用一个统一占位符把空值标记出来,既不会让多个空值直接合并,又能一眼看出哪些是缺失数据。如果你确认缺失值不需要保留,那也可以直接先dropna(subset=["address"])再做去重。关键在于:做去重之前,你对缺失值要有明确的策略,而不是把判断全部交给 pandas 的默认行为。5.2 字符串里藏空格,去重结果怎么看都怪某次我去重一份渠道名称表,发现怎么去都去不干净。明明channel_name打印出来都叫"官网注册",但duplicated()就是认为不重复。排查到最后才发现,一部分记录写的是"官网注册",另一部分写的是"官网注册 "(末尾多了一个空格),print 出来肉眼看毫无区别,实际上是两个不同的字符串。从此我养成了一个习惯:对字符串列做去重前,先统一格式化。df["channel_name"] = df["channel_name"].astype(str).str.strip().str.lower()strip()去掉首尾空格,lower()把小写统一,大小写不同造成的"假不重复"也就解决了。再遇到更复杂的,比如全角和半角括号混用、中文和英文标点混用,那还要做更细致的归一化,但空格和大小写这两条是最常见的。想快速发现这类问题,可以去重前跑一遍检查:# 找出长度异常或者包含空格的唯一值 odd_vals = df[df["channel_name"].str.contains(r"\s", regex=True)]["channel_name"].unique() print(odd_vals)用正则把带空白的值全揪出来看一遍,看得多了,基本上就知道这种表格的"脏"集中在哪几类。5.3 浮点数的精度问题会让"明显相同"的值判为不同浮点数和去重的关系也是个大坑。举个最简单的例子:0.1 + 0.2 == 0.3这个表达式在 Python 里是False,因为浮点数在计算机里是二进制近似存储的。类似的,如果你的数据里有一个amount字段是浮点型,两行业务上都是0.3,但一个是通过0.1 + 0.2算出来的,一个是直接输入0.3,它们底层存储的二进制值是不一样的。drop_duplicates(subset=["amount"])就会漏掉这个"重复"。处理这类问题的思路是:不要直接对浮点列去重,先格式化。比如金额保留两位小数,可以转成整数分处理:df["amount_cents"] = (df["amount"] * 100).round().astype("int64") df_dedup = df.drop_duplicates(subset=["user_id", "amount_cents"])或者直接用round()统一精度:df["amount_rounded"] = df["amount"].round(6) df_dedup = df.drop_duplicates(subset=["user_id", "amount_rounded"])按多少位精度取决于业务,但思路是一样的:先把"看起来差不多的数"变成"完全相等的数",再做去重。这属于数据标准化的一部分,做数据清洗时间长了你会发现,很多去重问题本质上是类型和精度问题。5.4 类型不一致导致的假不重复浮点数之外,还有一类问题是:同一列里,有的值是int,有的是str。最典型的场景是从 Excel 导入数据后,某些单元格被 pandas 读成了1这种整数,某些被读成了"1"这种字符串。drop_duplicates(subset=["customer_id"])会认为1和"1"不是重复。我的排查方法很朴素:去重之前,先打印这一列的dtype,再用value_counts()看看有没有异常值。print(df["customer_id"].dtype) print(df["customer_id"].value_counts(dropna=False).head(20))一旦发现类型混乱,统一转成字符串:df["customer_id"] = df["customer_id"].astype(str).str.strip()转完之后再去重,那些"隐藏的重复"就现出原形了。所有类型的坑,总结成一句话就是:让同一列的取值先处于同一个语义和存储水平,再去谈去重。这个水平不统一,用什么参数都是白搭。6. 一份可以直接抄作业的去重决策清单每次做数据清洗,我会拿出下面这张表在脑子里过一遍。它不是为了教条,而是为了在重复决策时提醒自己:当前场景更偏向哪种逻辑。业务场景subset 怎么定keep 怎么选额外动作按用户去重,保留首次记录["user_id"]"first"先按时间升序排序按用户去重,保留最新记录["user_id"]"last"先按时间升序排序去除完全相同的数据行默认全列"first"先统一字符串空格和大小写漏斗统计独立用户数["user_id", "event_date"]"first"确认是否按天统计清除疑似重复下单["user_id", "product_id"]视业务而定先确认时间窗和重复判定口径把重复行全部剔出,人工审核["user_id"]用duplicated(keep=False)标记保留标记列,别删金额或分数类字段去重["user_id", "score"]视业务而定先 round 统一精度再比大批量日志去重["user_id", "page_url", "ts"]"first"可考虑按时间排序或换 dask这张表不是标准答案,每个项目的唯一键和保留策略都有差异。我之所以整理成表格,是因为大多数时候你只需要把业务场景套进去,再微调两三个细节,去重代码基本不会跑偏。如果这篇里只能带走一样东西,我希望是这一点:删行之前,先用量化的方式确认你处理的是重复问题,而不是数据源质量问题。我用duplicated().sum()和duplicated(keep=False)筛出可疑数据的时间,远比直接跑drop_duplicates()的时间多。但正是这一步,让最终交付的数据能经得住业务方的追问。最后分享一个我自己的小习惯:正式脚本里我几乎不用drop_duplicates()直接覆盖原数据,而是保留一步.assign(is_dup=...)的中间状态,把重复标记留下来。哪怕后面确认所有逻辑都没问题,这列也不会对生产造成负担,但万一哪天要回溯清洗过程,这列就是最完整的现场记录。处理完记得reset_index(drop=True)——这都是老手刻进肌肉记忆的细节了。
我这几年代人做数据清洗,最常听的一句话就是:"把重复的删掉就行。" 说这话的时候大家都很轻松,仿佛重复值是什么一眼就能看穿的东西。可真动手之后才发现,drop_duplicates()一行代码确实能跑,但跑完之后的结果到底对不对,完全是另一回事。删除重复值这件事,表面上是 pandas 的基础操作,实际上牵扯到一个核心问题:什么样的行,在你的业务场景里才算"重复"?是订单号相同就算重复?还是用户ID加下单时间完全一致才算重复?甚至更进一步,同一用户下了一模一样的单,到底该保留第一次的,还是保留最新的?这些决策直接决定了你去重之后的数据质量,也决定了后面做的所有统计、分析和报表是不是靠谱。这篇文章我只讲一件事:pandas 里去除重复值的完整打法。我会把drop_duplicates()和duplicated()的每一个参数聊透,把"什么叫重复"讲明白,把索引错乱、性能退化、NaN参与去重这类坑一个个排掉。内容适合刚接触 pandas 的初学者,也适合已经写了半年一年数据处理脚本、但偶尔还会被重复值坑一把的同学。全文用订单数据和用户行为数据做示例,代码都是可以直接拿去改着用的。1. 去重不是删行,是回答"什么叫相同"先别急着写代码。我一直觉得,pandas 的drop_duplicates()之所以容易用错,是因为大家把它理解成了"删掉长得一样的行",于是参数随手一填就完事。实际上,去重动作背后藏着一个业务定义:你要认定哪些列相同,才算同一条记录。举个例子。某次我在处理一份用户浏览日志,字段包括user_id、page_url、visit_time、device。这份日志里出现了大量"用户在一分钟内反复点击同一个链接"的数据,产品经理想统计独立访问量,希望把这些"短时间重复"去掉。这时候如果直接:import pandas as pd df = pd.read_csv("user_log.csv") df = df.drop_duplicates()那等于什么都没做——因为两次点击的visit_time精确到秒,几乎不可能完全一致,pandas 默认按"所有列完全相等"来判断,结果一条都不会删。正确的做法是先想清楚业务上的重复定义。对于"短时间重复点击"这个场景,应该按user_id和page_url来判定,跟visit_time和device没关系:df_dedup = df.drop_duplicates(subset=["user_id", "page_url"])这样才算把"同一用户在同一页面上的重复访问"去掉了。这里你可能会问,那保留哪一行呢?是保留第一次访问,还是保留最后一次?这又牵涉到下一个参数。但至少从判断标准上看,subset决定了什么叫做"重复"。所以每次写去重代码之前,先花十秒钟回答三个问题:以哪些列作为唯一标识?如果有多列,是这几列都相同才算重复,还是任意一列相同就算重复?(pandas 默认是"都相同",大多数业务也是这个逻辑)重复的行里,保留哪一条?这三个问题的答案,就是drop_duplicates()的subset和keep两个参数。别小看这两个参数,后面所有复杂场景都是它们的组合。2. drop_duplicates 的参数语义:不只是 keep first 和 keep last2.1 subset 的边界:列少了一个,结果差一个数量级subset参数接受一个列名列表。它的作用是告诉 pandas:我只拿这些列做比较,其他列我不管。我用一个很经典的订单表场景来说。假设有一份订单明细,结构是这样的:order_iduser_idproduct_idorder_timediscountA001U01P1002024-03-01 10:00:000.9A002U01P1002024-03-01 10:00:000.85A003U01P1002024-03-01 10:00:000.9这三行里,user_id、product_id、order_time都相同,但order_id和discount不同。它们算不算重复?如果业务上认为"同一用户在同一时间买了同一商品只算一次",那么它们就是重复;如果认为"这是两笔独立的订单",那它们就是正常业务数据。这不只是技术问题。我当时处理这类数据时,业务方常用的口径是"用户在同一秒内买同一个商品,大概率是前端重复提交",所以判定标准就是user_id + product_id + order_time。于是去重代码是:df_orders_dedup = df_orders.drop_duplicates( subset=["user_id", "product_id", "order_time"] )关键点是:subset是"业务唯一键"的映射。你在数据分析里见过primary key的概念吧,subset就是你在 pandas 里临时定义的一个"复合主键"。还有一个容易忽略的细节:subset里列的先后顺序不影响判断结果。["user_id", "product_id"]和["product_id", "user_id"]的判断逻辑完全一样,因为 pandas 是按多列的联合值来比较的,而不是按顺序先后比较。这一点和某些数据库索引不同,不必担心。2.2 keep 的真正含义:保留哪一行,取决于你的"最新"定义keep参数有三个可选值:"first"、"last"、False。keep="first":保留相同记录里第一次出现的那一行。keep="last":保留相同记录里最后一次出现的那一行。keep=False:把所有重复行全部删掉,保留的全都是只出现一次的行。很多教程会这样解释,但没告诉你的是,keep="first"和keep="last"并不是简单的"保留第一行还是最后一行",而是以数据当前的排列顺序为准。如果你想按"时间最新的那条"来保留,就需要先排序,否则keep="last"保留的可能只是 DataFrame 里排序靠后的行,而不是业务上最新的一条。举个我实际遇到的例子。业务方需要生成一份"每个商品的当前有效配置",数据来自一份历史上 30 天内每天更新一次的配置快照。同一个product_id会有多行,每行带update_date,我们要保留的是最新一次更新的配置。如果数据本身是按update_date从小到大排列的,那直接写:latest_cfg = df_cfg.drop_duplicates( subset=["product_id"], keep="last" )是没问题的。但如果原始数据没有按时间排,甚至顺序是乱的,那这个写法就危险了。正确姿势是先把时间排序,再去重:df_cfg_sorted = df_cfg.sort_values( ["product_id", "update_date"], ascending=[True, True] ) latest_cfg = df_cfg_sorted.drop_duplicates( subset=["product_id"], keep="last" )先排序、再去重、再取last,这三步是一个组合拳。我这里按update_date升序排,排完后每一组重复记录里,最后一行就是最新配置。你问为什么不能直接drop_duplicates之后再排序?能,但那样你筛选出来的行可能不是你想要的那条,再去排序只是给错误结果排了个序而已。顺序问题一定要放在去重之前解决。2.3 keep=False 的威力:把重复行变成"可追溯的异常"keep=False的价值很多人低估了。它的作用是把出现多次的记录全部剔除,保留下来的每一行在指定的subset上都是唯一的。这个参数在"清洗脏数据"场景里特别好用。比如说你有一份用户基础信息表,理论上user_id应该是唯一的,但导入过程中出了问题,某些user_id出现了两三次。如果你只保留其中一条,万一保留了错误的那条,后续发消息、打标签全都会出错。这时候更稳妥的策略是:先把重复的用户全部揪出来,单独做人审。dup_marks = df_user.duplicated(subset=["user_id"], keep=False) df_dup_users = df_user[dup_marks]这段代码里,duplicated(keep=False)会把这组重复记录的所有行都标记为True,然后用布尔索引把它们全取出来。这样你拿到的就是"所有异常行",而不是"其中一行"。等人工核对完,决定哪条是有效数据之后,再单独放回去。这种做法比默认的keep="first"更稳妥。默认的keep="first"会直接保留第一条,如果第一条本身就是坏数据,你连发现的机会都没有。做数据清洗,宁可多跑一步检查,也不要让脏数据悄悄漏过去。3. 不只是删行:用 duplicated 做标记、统计和异常发现3.1 duplicated() 才是数据质量审计的核心工具很多同学用到drop_duplicates()就去干别的了,但我会建议你在删之前,先花三秒钟用duplicated()看一眼数据里到底有多少重复。duplicated()返回的是一个布尔型的 Series:每一行如果是重复行(即当前行之前出现过一次或多次),就标记为True。它不是用来删数据的,而是用来做标记的。这个标记功能在数据质量审计里极为有用。比如我在每次跑批任务之前,都会生成一份"重复率快报":dup_count = df.duplicated(subset=["user_id", "log_date"]).sum() total_count = len(df) dup_rate = dup_count / total_count print(f"待核查重复记录数: {dup_count}") print(f"重复率: {dup_rate:.2%}")重复率超过某个阈值,我会先停下手里的分析,回头查数据源。因为一条正常的数据流水,重复率通常不会很高,一旦超过 5% 或者 10%,大概率不是业务问题,而是上游同步任务重复执行了。你要是闷头去重,等于把上游的 bug 掩盖掉了。3.2 把"重复标记"变成一列,保留完整证据链有一种更实用的做法:不去删行,而是把是否重复的结果作为新的一列保存下来。这样后续分析时可以随时筛选,也可以作为"这条数据被清洗过"的凭证。df["is_dup"] = df.duplicated(subset=["user_id", "order_id"], keep=False) df_suspicious = df[df["is_dup"]]加上keep=False后,是重复的每一行都会标成True。我把这些True行单独拉出来看的时候,可以同时看到重复的每一行长什么样,确认到底哪一列有差异,为什么会被判成重复,以及是否有合并的可能。这个"证据链"思路在正式项目里非常重要。很多人做数据清洗是闷头删,删完之后老板问"你删了什么,为什么删",答不上来。保留一个标记列,随时能给出一个清单:哪些行是重复的、判定依据是哪几列、保留的是哪一行。这一套下来,数据团队和业务方的信任感会好很多。3.3 用分组统计识别"同款但不同价"的疑似重复还有一种情况比完全重复更隐蔽:业务上高度疑似重复,但没有哪几列是完全相等的。比如同一用户同一个收货地址,在 10 分钟内连续下了两单,都买了同一个商品。这种情况下order_id不同、order_time不同,drop_duplicates()根本没法定义它们重复。但业务方会觉得这就是重复下单。遇到这种场景,pandas 去重的常规参数不够用,我一般配合分组统计来找出疑似记录:suspect = ( df.groupby(["user_id", "receiver", "product_id"], as_index=False) .agg( order_cnt=("order_id", "nunique"), min_time=("order_time", "min"), max_time=("order_time", "max") ) ) suspect = suspect[ (suspect["order_cnt"] >= 2) & (suspect["max_time"] - suspect["min_time"] <= pd.Timedelta(minutes=10)) ]这段代码先按用户、收货人和商品分组,统计下单次数和最早最晚时间,再筛出"10 分钟内同人同地址同商品下过两单以上"的记录。这些是疑似重复,通常需要业务方确认是否只保留一单。我特别想强调这一点:去重不永远是技术问题,很多时候是口径问题。工具能帮你执行规则,但规则本身得靠业务确认。所以我做这类任务的习惯是,先跑duplicated()看全量重复情况,再跑分组统计找疑似重复,最后把两份结果汇总成一张问题清单,再动手删除。这样每一步都有据可查。4. 去重之后的连锁反应:索引、就地修改和性能4.1 索引错乱:删完行后为什么不连续了drop_duplicates()删掉行之后,DataFrame 的行索引并不会自动重新排列,被删掉的行的索引位置会直接消失。比如一份 1000 行的数据,去重后剩 800 行,索引可能是0, 1, 3, 5, 6...,中间缺了很多。这在后续做df.loc[某个索引]的时候会出大问题。最常见的就是你根据一个索引列表去另一个表里取数据,结果索引对不上,取回了一堆NaN。解决方案简单粗暴:去重后立刻重置索引。df_clean = df.drop_duplicates(subset=["user_id"]).reset_index(drop=True)这里的drop=True表示不要把原来的索引变成新列。如果你不加,pandas 会把旧的索引保留为一个叫index的列,你的 DataFrame 里就多出一列莫名其妙的数据。这个小细节很多人第一次都会遇到。4.2 inplace=True 真的省内存吗?别被名字骗了关于inplace=True,网上的说法很混乱。我的建议是:没事别用。df.drop_duplicates(subset=["user_id"], inplace=True)这行代码确实能就地修改df。但 pandas 底层并不是完全不创建新对象,很多inplace操作依然会先算出结果再替换,内存上的好处没有想象中大。更麻烦的是,inplace=True会让代码的链式调用和回溯变得很难检查——你没法清楚地知道当前这个 DataFrame 到底是什么状态。我自己的习惯是:始终显式赋值。df_processed = df.drop_duplicates(subset=["user_id"]).copy()这里多说一句copy()。drop_duplicates()返回的数据可能是原始数据的视图,也可能是副本,具体取决于 pandas 内部优化。如果你去重之后还要继续修改列,为了避免SettingWithCopyWarning的警告,提前.copy()是最省心的做法。这个警告不致命,但留着很烦人,而且说明你的代码可能在修改一个副本,改了等于没改。4.3 上百万行的 DataFrame 去重为什么慢,怎么提速当数据量到百万行以上,drop_duplicates()有时会变得很慢。原因在于 pandas 需要把所有行进行两两比较,或者内部构建哈希结构,而这个过程中如果subset包含文本类列(比如很长的 URL、备注文本),哈希计算成本就会很高。几个实测有效的提速方案:先删无用列再去重。如果分析根本不需要某些列,先用df[["user_id", "page_url"]]取出子集再去重,可以大幅减少比较的列数。把分类列转成 category 类型。对于一些取值种类有限的列,转成category之后 pandas 的比较会走整数路径,速度会快不少。df["user_id"] = df["user_id"].astype("category") df_dedup = df.drop_duplicates(subset=["user_id", "page_url"])不过要小心,转category之后如果后续要和其他 DataFrame 做合并,可能遇到类别不一致的问题,用完建议再转回object或者string。先排序再去重。对于部分数据,先sort_values()把相同记录聚到一起,再去重,底层比较的局部性变好,速度也会改善。但这不是所有场景都有效,我在字符串列上试过,有时反而更慢。建议拿真实数据做个基准测试再决定。用dask处理超大文件。如果数据量已经到了几十个 G,单机 pandas 就不太合适了。dask 的drop_duplicates接口和 pandas 几乎一致,可以先把dask.dataframe跑起来,最后转回 pandas。这类场景我碰到的不多,但每次碰到都是直接用 dask 解决,不用硬扛。这些性能优化说白了是在跟 pandas 的内部实现打交道,不同版本、不同数据特性下结论会不一样。所以我会建议:先按默认方式跑一遍,用time记录耗时,再针对性地优化。不要一上来就把代码写得很复杂。5. 我踩过的那些坑:NaN、空格、浮点数和类型陷阱5.1 NaN 参与去重时的微妙行为最典型的翻车现场是:数据里有一列是空值,而你恰好把它放进了subset。pandas 的规则是,两个NaN在去重判断时会当成"相等"——也就是说,同一用户 ID 下多行都是空值的时候,这些行会被判定为重复。这在业务上可能不对。比如你统计用户收货地址时,有些用户还没填地址,地址列全是NaN,但你依然希望保留每个用户的一条记录——不,这里情况更复杂:如果按["user_id", "address"]去重,一个用户所有没填地址的记录会被合并成一条。如果业务想保留其中一条带具体时间信息的记录,那么这种去重就会丢掉信息。我的处理方式非常直接:把需要参与去重的字段先填充,或者把空值字段排除在 subset 之外。df["address_filled"] = df["address"].fillna("__EMPTY__") df_dedup = df.drop_duplicates(subset=["user_id", "address_filled"])暂时用一个统一占位符把空值标记出来,既不会让多个空值直接合并,又能一眼看出哪些是缺失数据。如果你确认缺失值不需要保留,那也可以直接先dropna(subset=["address"])再做去重。关键在于:做去重之前,你对缺失值要有明确的策略,而不是把判断全部交给 pandas 的默认行为。5.2 字符串里藏空格,去重结果怎么看都怪某次我去重一份渠道名称表,发现怎么去都去不干净。明明channel_name打印出来都叫"官网注册",但duplicated()就是认为不重复。排查到最后才发现,一部分记录写的是"官网注册",另一部分写的是"官网注册 "(末尾多了一个空格),print 出来肉眼看毫无区别,实际上是两个不同的字符串。从此我养成了一个习惯:对字符串列做去重前,先统一格式化。df["channel_name"] = df["channel_name"].astype(str).str.strip().str.lower()strip()去掉首尾空格,lower()把小写统一,大小写不同造成的"假不重复"也就解决了。再遇到更复杂的,比如全角和半角括号混用、中文和英文标点混用,那还要做更细致的归一化,但空格和大小写这两条是最常见的。想快速发现这类问题,可以去重前跑一遍检查:# 找出长度异常或者包含空格的唯一值 odd_vals = df[df["channel_name"].str.contains(r"\s", regex=True)]["channel_name"].unique() print(odd_vals)用正则把带空白的值全揪出来看一遍,看得多了,基本上就知道这种表格的"脏"集中在哪几类。5.3 浮点数的精度问题会让"明显相同"的值判为不同浮点数和去重的关系也是个大坑。举个最简单的例子:0.1 + 0.2 == 0.3这个表达式在 Python 里是False,因为浮点数在计算机里是二进制近似存储的。类似的,如果你的数据里有一个amount字段是浮点型,两行业务上都是0.3,但一个是通过0.1 + 0.2算出来的,一个是直接输入0.3,它们底层存储的二进制值是不一样的。drop_duplicates(subset=["amount"])就会漏掉这个"重复"。处理这类问题的思路是:不要直接对浮点列去重,先格式化。比如金额保留两位小数,可以转成整数分处理:df["amount_cents"] = (df["amount"] * 100).round().astype("int64") df_dedup = df.drop_duplicates(subset=["user_id", "amount_cents"])或者直接用round()统一精度:df["amount_rounded"] = df["amount"].round(6) df_dedup = df.drop_duplicates(subset=["user_id", "amount_rounded"])按多少位精度取决于业务,但思路是一样的:先把"看起来差不多的数"变成"完全相等的数",再做去重。这属于数据标准化的一部分,做数据清洗时间长了你会发现,很多去重问题本质上是类型和精度问题。5.4 类型不一致导致的假不重复浮点数之外,还有一类问题是:同一列里,有的值是int,有的是str。最典型的场景是从 Excel 导入数据后,某些单元格被 pandas 读成了1这种整数,某些被读成了"1"这种字符串。drop_duplicates(subset=["customer_id"])会认为1和"1"不是重复。我的排查方法很朴素:去重之前,先打印这一列的dtype,再用value_counts()看看有没有异常值。print(df["customer_id"].dtype) print(df["customer_id"].value_counts(dropna=False).head(20))一旦发现类型混乱,统一转成字符串:df["customer_id"] = df["customer_id"].astype(str).str.strip()转完之后再去重,那些"隐藏的重复"就现出原形了。所有类型的坑,总结成一句话就是:让同一列的取值先处于同一个语义和存储水平,再去谈去重。这个水平不统一,用什么参数都是白搭。6. 一份可以直接抄作业的去重决策清单每次做数据清洗,我会拿出下面这张表在脑子里过一遍。它不是为了教条,而是为了在重复决策时提醒自己:当前场景更偏向哪种逻辑。业务场景subset 怎么定keep 怎么选额外动作按用户去重,保留首次记录["user_id"]"first"先按时间升序排序按用户去重,保留最新记录["user_id"]"last"先按时间升序排序去除完全相同的数据行默认全列"first"先统一字符串空格和大小写漏斗统计独立用户数["user_id", "event_date"]"first"确认是否按天统计清除疑似重复下单["user_id", "product_id"]视业务而定先确认时间窗和重复判定口径把重复行全部剔出,人工审核["user_id"]用duplicated(keep=False)标记保留标记列,别删金额或分数类字段去重["user_id", "score"]视业务而定先 round 统一精度再比大批量日志去重["user_id", "page_url", "ts"]"first"可考虑按时间排序或换 dask这张表不是标准答案,每个项目的唯一键和保留策略都有差异。我之所以整理成表格,是因为大多数时候你只需要把业务场景套进去,再微调两三个细节,去重代码基本不会跑偏。如果这篇里只能带走一样东西,我希望是这一点:删行之前,先用量化的方式确认你处理的是重复问题,而不是数据源质量问题。我用duplicated().sum()和duplicated(keep=False)筛出可疑数据的时间,远比直接跑drop_duplicates()的时间多。但正是这一步,让最终交付的数据能经得住业务方的追问。最后分享一个我自己的小习惯:正式脚本里我几乎不用drop_duplicates()直接覆盖原数据,而是保留一步.assign(is_dup=...)的中间状态,把重复标记留下来。哪怕后面确认所有逻辑都没问题,这列也不会对生产造成负担,但万一哪天要回溯清洗过程,这列就是最完整的现场记录。处理完记得reset_index(drop=True)——这都是老手刻进肌肉记忆的细节了。