数据清洗与规范化

采集回来的数据一定是不干净的:标题里混着   与零宽字符,价格写成「¥1,299.00 起」,日期有「3 分钟前」「昨天 18:20」「2024/05/01」三种写法,城市一会儿「北京」一会儿「北京市」。清洗不是可选的美化步骤,而是让数据跨源可比的前提。

清洗清单

项目脏数据示例处理方式
空白与不可见字符首尾空格、\u3000、零宽空格 \u200bstr.strip() + 正则替换不可见字符
全角/半角123(示例)ABCunicodedata.normalize("NFKC", s)
日期时间3 分钟前2024.5.105/01/2024先按格式列表解析,失败再按相对时间换算
金额与数字¥1,299.00 起1.2万面议正则提取数字与单位,换算为统一数值
单位换算1.5GB500g3 万/月建单位映射表,统一到基本单位
枚举归一本科 / 大学本科 / Bachelor枚举字典 + 别名表
HTML 残留&amp;<br><script>html.unescape() + 去标签
编码问题乱码、\ufffdutf-8 解码,乱码行单独留存

缺失值与异常值策略

情况建议处理不推荐的做法
关键字段缺失(URL、标题)丢弃并计数告警填空字符串后入库
次要字段缺失(作者、标签)None 或空列表,保留行用均值填充分类字段
数值字段缺失(价格)标记 is_valid=False,不进统计用 0 填充,会拉低均值
数值异常(0 元或 10 倍偏差)与历史分位对比,超阈值剔除并抽检一律截断到阈值
文本超长按业务上限截断并记录原长度静默截断导致信息丢失
日期落在未来视为解析错误,置为 None保留未来日期污染时间序列

原则:清洗只做「有确定规则」的变换;「这条数据可不可信」属于校验环节的判断,要留痕而不是就地猜。

文本与数值清洗

# clean_text.py —— Python 3.10+
import html, re, unicodedata

INVISIBLE = re.compile(r"[\u200b-\u200f\ufeff\u3000]+")
TAG = re.compile(r"<[^>]+>")
MONEY = re.compile(r"(\d+(?:\.\d+)?)\s*(万|千|k|K)?")
UNIT_FACTOR = {"万": 10000, "千": 1000, "k": 1000, "K": 1000}

def clean_text(value: str | None) -> str:
    """去标签、还原实体、全角转半角、压缩空白"""
    if not value:
        return ""
    text = html.unescape(TAG.sub(" ", value))     # 先去标签再还原实体
    text = unicodedata.normalize("NFKC", text)    # 全角转半角,正则才能匹配
    return re.sub(r"\s+", " ", INVISIBLE.sub("", text)).strip()

def parse_money(value: str | None) -> float | None:
    """「¥1,299.00 起」「1.2万」「面议」→ 数值(元)"""
    text = clean_text(value).replace(",", "")
    if not text or text in {"面议", "电议", "详询"}:
        return None                               # 面议保持 NULL,绝不猜成 0
    match = MONEY.search(text)
    return round(float(match.group(1)) * UNIT_FACTOR.get(match.group(2), 1), 2) if match else None

def parse_relative_time(value: str, now=None) -> str | None:
    """「3 分钟前」「昨天 18:20」→ ISO 时间;无法解析返回 None"""
    import datetime as dt
    now = now or dt.datetime.now()
    text = clean_text(value)
    if not text:
        return None
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M", "%Y年%m月%d日"):
        try:
            return dt.datetime.strptime(text, fmt).isoformat(sep=" ")
        except ValueError:
            continue
    m = re.match(r"(\d+)\s*(分钟|小时|天)前", text)
    if m:
        unit = {"分钟": "minutes", "小时": "hours", "天": "days"}[m.group(2)]
        delta = dt.timedelta(**{unit: int(m.group(1))})
        return (now - delta).isoformat(sep=" ", timespec="seconds")
    m = re.match(r"昨天\s*(\d{1,2}):(\d{2})", text)
    if m:
        d = (now - dt.timedelta(days=1)).replace(hour=int(m.group(1)), minute=int(m.group(2)),
                                                 second=0, microsecond=0)
        return d.isoformat(sep=" ")
    return None

用 pandas 做批量清洗

# clean_frame.py
import pandas as pd
from clean_text import clean_text, parse_money, parse_relative_time

df = pd.read_json("output/2024-05-01/news.jsonl", lines=True)
df["title"] = df["title"].fillna("").map(clean_text)
df["price"] = df["price_text"].map(parse_money).astype("Float64")      # 缺失保持 NA
df["pub_time"] = pd.to_datetime(df["pub_time_text"], errors="coerce")  # 解析失败置 NaT
df["city"] = (df["city"].fillna("").str.strip()
              .str.replace(r"(市|地区)$", "", regex=True))             # 「北京市」→「北京」
df["tags"] = df["tags"].map(lambda v: sorted(set(v or [])))            # 标签去重排序

bad = df[df["title"].eq("") | df["url"].isna()]                        # 关键字段缺失单独落盘
bad.to_json("output/bad_rows.jsonl", orient="records", lines=True, force_ascii=False)
clean = (df.dropna(subset=["url"])                # 显式指定 subset,别丢掉整行
           .drop_duplicates(subset=["url_md5"])   # 精确去重
           .sort_values("pub_time", ascending=False))
print(f"原始 {len(df)} 行 → 清洗后 {len(clean)} 行,异常 {len(bad)} 行")

去重的三个层次

层次方法能解决什么代价
精确去重URL 指纹、整串哈希、drop_duplicates同一 URL 被采多次几乎为零
近似去重规范化后再哈希、SimHash 汉明距离同内容不同 URL、正文含推荐位差异需维护规范化规则
相似文本MinHash + LSH、difflib.SequenceMatcher转载、洗稿、标题改写计算成本高,要调阈值

实用顺序:先做「正文长度过滤 + 前 500 字规范化后比较」,低成本挡掉大部分转载;确实需要全量比对时再上 MinHash。

可复现原则与常见坑

  • 原始层不覆盖raw/ 存采集原样数据,clean/ 存清洗结果,脚本可反复重跑。
  • 脚本进版本库:规则变更走代码评审,出问题能定位到是哪次规则调整。
  • 每条规则留计数:丢了多少行、补了多少值,都写进日志,便于对账。
现象原因处理
清洗后条数莫名减少dropna 默认丢掉任一列缺失的行显式指定 subset
数值列变成 object有非数字字符串混入map(parse_money)astype("Float64")
日期只剩 NaT文本格式不规范先正则探测格式,再逐格式解析
全角数字匹配不到未做 NFKC 归一归一化放在正则提取之前
城市枚举越来越多只做了 strip建别名表,无法映射记为「其他」并告警

合规提示:清洗环节最容易顺手「丰富」数据(补全手机号、拼接个人信息),这是明确越界的。只处理采集自公开来源的数据,遵守 robots.txt 与站点服务条款,控制频率与并发;不采集与使用个人隐私信息,不绕过登录、验证码与付费墙。数据使用与对外提供需符合《个人信息保护法》《数据安全法》《著作权法》,优先使用官方 API 与开放数据。

小结:清洗的目标不是「看起来整齐」,而是让不同来源的同一字段可比可算——先做确定性的文本、金额、时间、枚举归一,再按关键字段处理缺失与异常并留痕,去重分精确、近似、相似三层按成本递进;原始数据永不覆盖,清洗脚本与规则计数一起进版本库。

笔记加载中…