在数据清洗过程中,我们经常会遇到需要处理重复记录的场景。多数教程会教你如何删除重复行,也就是保留每组重复数据的第一条,这通常通过drop_duplicates实现。然而,实际业务中还存在另一种需求:只保留那些第二次及以后出现的重复行,用来排查重复录入、异常数据或追踪数据变更历史。Pandas并没有直接提供一个保留非首次重复行的方法,但借助duplicated方法可以非常容易地实现这一目标。

我们需要重点关注duplicated方法的返回结果和subset参数的使用方式。默认情况下,duplicated会把每组重复项中第一次出现的行标记为False,后续重复项标记为True。这个布尔序列恰好就是非首次重复行的定位标记,直接用它过滤DataFrame就能得到目标数据。下面将通过几个层次逐步展开说明。
先理解duplicated方法的标记逻辑
DataFrame.duplicated()会返回一个与行数等长的布尔Series,其中True表示该行是前面某个行的重复项,False表示首次出现或唯一行。核心判断依据是所有列的值是否完全相同。例如一个包含三行、两列的数据,前两行完全相同,第三行不同,那么调用df.duplicated()会得到[False, True, False]。这个方法并不会修改原数据,只负责生成标记,因此非常适合用于条件筛选。
与之相对的drop_duplicates()方法默认行为是保留重复组合中的第一条记录,内部实现本质上就是取duplicated()为False的行。所以如果你只需要保留首次重复行,直接使用drop_duplicates即可;但如果你想保留非首次重复行,只需要取duplicated()结果为True的行,也就是直接对布尔Series进行过滤。
这里要注意的是,duplicated默认会对所有列进行比较。当DataFrame包含较多无关列时,基于全列的重复判断可能会漏掉你真正关心的重复情况。例如一个订单表包含订单ID、商品ID和下单时间,你可能只关心同一商品ID在同一订单中是否重复出现,而不需要比较下单时间。这时候就需要用到subset参数来指定参与重复判断的列。
使用subset参数指定判断列
subset参数接受一个列名或列名列表,只有这些列的值被用于判断重复。比如你有一个员工信息表,包含员工编号、姓名、部门和入职日期,想找出同一部门中重复出现的姓名(同名员工),就可以指定subset=['部门', '姓名']。这样,只要部门和姓名相同,即使员工编号或入职日期不同,也会被视为重复行。
示例代码如下:
import pandas as pd
# 构建示例DataFrame
data = {
'员工编号': [101, 102, 103, 104],
'姓名': ['张三', '李四', '张三', '王五'],
'部门': ['技术部', '市场部', '技术部', '人事部'],
'入职日期': ['2023-01-05', '2023-02-10', '2023-03-15', '2023-04-20']
}
df = pd.DataFrame(data)
# 仅根据部门与姓名判断重复
dup_mask = df.duplicated(subset=['部门', '姓名'])
print(dup_mask)
# 输出: 0 False
# 1 False
# 2 True
# 3 False
从输出可以看到,第2行(索引2)被标记为True,因为它与第0行的部门和姓名都相同,尽管员工编号和入职日期不同。这就是subset的作用:将比较范围缩小到关键列,避免了其他动态变化字段干扰重复判断。
在实际使用中,建议将subset参数与业务主键或业务唯一性规则对齐。例如在日志数据中,你可能希望基于用户ID和事件类型找出重复触发的事件,而忽略时间戳的细微差异。通过合理设定subset,可以显著提升重复检测的准确性和针对性。
实战:筛选指定列的非首次重复行
有了duplicated的布尔标记,筛选非首次重复行就变得非常简单。只要把这个布尔Series直接作为DataFrame的行索引条件即可。例如延续上面的员工表,我们想保留技术部中第二次出现的“张三”记录,可以这样写:
import pandas as pd
data = {
'员工编号': [101, 102, 103, 104],
'姓名': ['张三', '李四', '张三', '王五'],
'部门': ['技术部', '市场部', '技术部', '人事部'],
'入职日期': ['2023-01-05', '2023-02-10', '2023-03-15', '2023-04-20']
}
df = pd.DataFrame(data)
# 获取非首次重复行
non_first_duplicates = df[df.duplicated(subset=['部门', '姓名'], keep='first')]
print(non_first_duplicates)
输出结果会只包含索引为2的那一行,也就是第二次出现的“技术部张三”记录。这里显式传入keep='first'是为了清晰表明保留标准,虽然duplicated默认行为就是keep='first'。如果希望保留最后一次出现的重复行,而把之前的重复行标记为True,则可以设置keep='last',这时duplicated会从尾部开始判断,首次出现的概念反转。但需要注意,keep='last'的语义并不能直接用于筛选非首次重复行,需要结合实际情况理解。
如果想把所有重复行都标记出来,包括每个重复组中的第一条,可以设置keep=False。此时duplicated()会对所有有重复的行都返回True,而不管它出现的位置。这个选项在只想查看所有重复记录时非常有用,但不适合用来分离首次与非首次。
另一种常见做法是先分组,再使用groupby结合cumcount来标记出现顺序,这样能更灵活地控制保留第几次出现的记录。例如下面这段代码可以为每个基于姓名和部门的分组生成出现序号:
import pandas as pd
data = {
'员工编号': [101, 102, 103, 104, 105],
'姓名': ['张三', '李四', '张三', '王五', '张三'],
'部门': ['技术部', '市场部', '技术部', '人事部', '技术部'],
'入职日期': ['2023-01-05', '2023-02-10', '2023-03-15', '2023-04-20', '2023-05-25']
}
df = pd.DataFrame(data)
# 生成组内出现序号(从0开始)
df['出现序号'] = df.groupby(['部门', '姓名']).cumcount()
print(df)
# 筛选出第二次及以后出现的记录(序号大于0)
non_first = df[df['出现序号'] > 0]
print(non_first)
这种方式虽然多了一步生成辅助列,但更加直观,也便于后续根据序号做更多定制化处理,比如只保留第三次出现的记录。cumcount()在分组后从0开始递增,通过判断序号是否大于0就能得到非首次重复行。当然,如果只是简单需求,使用duplicated子集过滤会更加简洁。
与drop_duplicates对比及注意事项
很多人容易混淆duplicated和drop_duplicates的输入输出。drop_duplicates返回的是一个新的DataFrame,其中删除了重复行;而duplicated返回的是布尔Series。因此,如果你想保留首次出现的重复行,最直接的方式是:
first_occurrence = df.drop_duplicates(subset=['部门', '姓名'], keep='first')
如果你想保留非首次重复行,则可以用前面介绍的df[df.duplicated(subset=['部门', '姓名'])]。两种操作互为补集(在全列判断且不考虑索引重置的情况下)。但要注意,drop_duplicates默认会重置索引吗?不会,它会保留原始索引,除非你设置ignore_index=True。在后续处理中如果需要连续索引,记得加上reset_index(drop=True)。
此外,处理大型DataFrame时,duplicated和drop_duplicates的性能可能受到列数据类型和内存占用的影响。如果只需要针对少数列判断重复,建议先选取这些列形成一个较小的DataFrame再进行判断,或者利用duplicated的subset参数减少不必要的数据比较。对于包含空值的情况,Pandas默认会把NaN视为相同值进行重复判断,但在某些业务中可能需要将NaN视为不同,此时需要对缺失值先做填充或标记处理。
最后,如果需要在保留非首次重复行的同时保留一些上下文信息,比如出现次数统计,可以结合groupby().size()或transform来实现。但核心思路始终是:先用duplicated或cumcount生成标记,再根据标记过滤行。掌握了这一模式,你就能灵活应对各种重复数据处理需求,而不再局限于简单的去重操作。