📊 第9章 网站 App 平台注册用户分析
📄 一句话概括

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 秒实验

  1. 把 duplicated 的 subset 去掉,看能查出几条重复。
  2. 把 fillna 换成 dropna,看最后剩多少条,想想少掉的是哪些人。
  3. 想一想:如果重复记录的 reg_time 差了一秒,subset 还查得出来吗?

常见误区

  1. duplicated 不写 subset:主键不同就判不出重复,报 0 条最坑。
  2. 缺失一律 dropna:能填的该填,删一行就少一个人的信息。
  3. 异常按 eyeball 判断:2099 年这种好认,差 10% 的数值就难了,得用分布。
  4. 直接覆盖原表:出了问题连对照的原始数据都没有了。
自测三问(点开对答案) 1. 为什么 duplicated 要写 subset? —— 默认比较所有列,重复记录的 user_id 不一样,整行就不相等,一条都查不出来。 2. 缺失值一定删掉吗? —— 不一定。渠道没填仍然是有效用户,填成「未知」比删掉好。 3. 为什么洗完另存一张表? —— 原表留着对照。出问题时能回头查,覆盖了就再也对不上了。

数据干净了,开始分析。下一篇先按年看:五年到底涨了多少。

相关笔记