316 条原始数据里,8 条重复、5 条渠道为空、3 条日期是 2099 年。 去重必须写 subset 排除主键,否则 user_id 不一样,根本判不出重复。 洗完 305 条,另存一张干净表,原表不动。
上一篇读进来的数据太干净了。真实导库出来一定有毛刺。 这一篇故意弄脏再洗干净:先查三个老毛病,再按顺序处理。
一、脏数据是怎么来的
三种常见的脏法,都对得上真实场景: 一是导库脚本重跑了一次,同一批记录插了两遍——产生重复。 二是表单里渠道不是必填项,用户没选——产生缺失。
三是手工录入时年份敲错,2023 打成 2099——产生异常值。 316 条里,300 条正常,8 条重复,5 条渠道空,3 条年份离谱。
二、查:三个老毛病
| 毛病 | 怎么查 | 这次的结果 |
|---|---|---|
| 缺失 | isnull().sum() | channel 缺 5 个 |
| 重复 | duplicated(subset=...) | 8 条 |
| 异常 | 看年份范围 | 3 条 2099 年 |
三步都查完,才知道该洗掉多少。上来就 drop 是赌博。 顺序也有讲究:先查缺失和重复,最后才查异常——异常判断往往要基于分布。
三、去重那个坑
duplicated() 默认比较所有列。这次重复记录的 user_id 是新的(1000 起),
所以整行不相等,默认查法一条都查不出来。
必须写 subset,把主键排除在外。
这是重复值处理最常见的翻车点:看到「0 条重复」就放心了,其实一条都没查出来。
四、洗:三步清干净
第一步去重,316 减 8 得 308。 第二步把 channel 的空值填成「未知」——这是填,不是删。 第三步删掉年份大于 2025 的三条,剩 305。
清掉 11 条,保住 305 条。其中那 5 条只是渠道没填,属于有效数据。 「缺失就删」是最偷懒的做法。能填就填,删掉一行就少一个人的信息。
五、洗完落回库里
洗完的数据写回库里的 users_clean 表,用 to_sql。
原始表一个字不动。出问题时还能回头对,比原地覆盖安全得多。
to_sql 三个关键点:连接、表名、index=False。
index 不关,pandas 会把行号当成一列写进去,多出一个莫名其妙的字段。
六、动手 Demo:数据准备与检测
同一份 300 条数据,加 8 条重复、5 条空渠道、3 条 2099 年。 然后一步步查、一步步洗,最后写回 users_clean。
import sqlite3
import numpy as np
import pandas as pd
rng = np.random.default_rng(2026)
plan = [(2021, 30), (2022, 42), (2023, 58), (2024, 78), (2025, 92)]
w = np.array([0.5, 0.6, 1.5, 1.0, 0.9, 0.8, 0.9, 1.1, 1.4, 1.0, 0.9, 1.0])
w = w / w.sum()
rows = []
uid = 1
for year, n in plan:
for m in rng.choice(np.arange(1, 13), size=n, p=w):
rows.append((uid, "用户%03d" % uid,
"%04d-%02d-%02d %02d:%02d:00"
% (year, m, int(rng.integers(1, 29)),
int(rng.integers(0, 24)), int(rng.integers(0, 60))),
"搜索引擎", "北京"))
uid += 1
dup = [(1000 + i, rows[i][1], rows[i][2], rows[i][3], rows[i][4])
for i in range(8)]
bad = [(2000 + i, "用户%03d" % (2000 + i),
"2023-06-%02d 10:00:00" % (10 + i), None, "其他") for i in range(5)]
odd = [(3000 + i, "用户%03d" % (3000 + i),
"2099-%02d-01 08:00:00" % (i + 1), "自然访问", "其他")
for i in range(3)]
df = pd.DataFrame(rows + dup + bad + odd,
columns=["user_id", "user_name", "reg_time",
"channel", "city"])
print("原始 %d 条" % len(df))
print("缺失 %d,重复 %d"
% (df["channel"].isnull().sum(),
df.duplicated(subset=["user_name", "reg_time", "channel", "city"]).sum()))
d = df.drop_duplicates(subset=["user_name", "reg_time", "channel", "city"])
d = d.copy()
d["channel"] = d["channel"].fillna("未知")
d["year"] = d["reg_time"].str.slice(0, 4).astype(int)
d = d[d["year"] <= 2025]
print("洗完 %d 条" % len(d))
conn = sqlite3.connect(":memory:")
d[ ["user_id", "user_name", "reg_time", "channel", "city"]].to_sql(
"users_clean", conn, index=False)
print("写回 users_clean:%d 条"
% pd.read_sql("select count(*) as n from users_clean", conn)["n"][0])
conn.close()
实跑输出:
--------------------------------------------------------------------
数据准备与检测
--------------------------------------------------------------------
① 故意弄脏
--------------------------------------------------------------------
原始 316 条:正常 300 条,重复 8 条,渠道为空 5 条,日期离谱 3 条。
! 脏数据是造的,但脏法是照真实来的:导库重跑出重复、表单没填必填项、日期输错年份。
② 查:三个老毛病
--------------------------------------------------------------------
缺失值:isnull().sum(),这次 channel 缺 5 个。
重复值:按「用户名 + 注册时间 + 渠道 + 城市」查,8 条完全重复。
注意 duplicated() 默认比较所有列,user_id 不一样就判不出重复。
所以要去重必须写 subset,把主键排除在外。
异常值:有三条时间是 2099 年,明显是输错年份。
③ 洗:三步清干净
--------------------------------------------------------------------
| 步骤 | 剩多少条 | 去掉几条 |
|---|---|---:|
| 原始 | 316 | — |
| 去重 | 308 | 8 |
| 填缺失后删异常年份 | 305 | 3 |
316 条里清掉 11 条(8 条重复加 3 条异常年份),剩 305 条。
另有 5 条只是渠道没填,那不是脏数据,填成「未知」留着就行。
! 去重放在最前面:重复行会让后面每一步都多算一遍,先清掉再谈别的。
④ 转换类型
--------------------------------------------------------------------
reg_time 现在是文本,直接排序也能对,因为写法是「年-月-日 时:分:秒」,字典序等于时间序。
但要做「相差几天」这种计算就必须转成真正的时间类型。
转完之后年份是 int32 整型,可以直接做减法。
文本能排序不等于能算。要算间隔就得转,这是第 4 章讲过的老规矩。
! 转换之后再取年份用 .dt.year,比切字符串稳,也不会被格式变体坑。
⑤ 洗完要落回去
--------------------------------------------------------------------
洗完的 305 条写回库里的 users_clean 表。
原始表不动,另建一张干净表。出问题时还能回头对,比原地覆盖安全得多。
! to_sql 三句话:连接、表名、index=False。index 不关会把行号写成一列。
d.copy() 不能省。不加它,后面 fillna 会报 SettingWithCopyWarning。
subset 那四个字段就是「什么叫同一个人重复注册」的定义。定义写错,去重就白做。
index=False 是 to_sql 最常漏的参数,漏了会多出一列 index。
30 秒实验
- 把 duplicated 的 subset 去掉,看能查出几条重复。
- 把 fillna 换成 dropna,看最后剩多少条,想想少掉的是哪些人。
- 想一想:如果重复记录的 reg_time 差了一秒,subset 还查得出来吗?
常见误区
- duplicated 不写 subset:主键不同就判不出重复,报 0 条最坑。
- 缺失一律 dropna:能填的该填,删一行就少一个人的信息。
- 异常按 eyeball 判断:2099 年这种好认,差 10% 的数值就难了,得用分布。
- 直接覆盖原表:出了问题连对照的原始数据都没有了。
自测三问(点开对答案)
1. 为什么 duplicated 要写 subset? —— 默认比较所有列,重复记录的 user_id 不一样,整行就不相等,一条都查不出来。 2. 缺失值一定删掉吗? —— 不一定。渠道没填仍然是有效用户,填成「未知」比删掉好。 3. 为什么洗完另存一张表? —— 原表留着对照。出问题时能回头查,覆盖了就再也对不上了。数据干净了,开始分析。下一篇先按年看:五年到底涨了多少。
- ☐ (本篇完,下一篇 9.4 年度注册用户分析)