数据清洗与规范化
采集回来的数据一定是不干净的:标题里混着 与零宽字符,价格写成「¥1,299.00 起」,日期有「3 分钟前」「昨天 18:20」「2024/05/01」三种写法,城市一会儿「北京」一会儿「北京市」。清洗不是可选的美化步骤,而是让数据跨源可比的前提。
清洗清单
| 项目 | 脏数据示例 | 处理方式 |
|---|---|---|
| 空白与不可见字符 | 首尾空格、\u3000、零宽空格 \u200b | str.strip() + 正则替换不可见字符 |
| 全角/半角 | 123、(示例)、ABC | unicodedata.normalize("NFKC", s) |
| 日期时间 | 3 分钟前、2024.5.1、05/01/2024 | 先按格式列表解析,失败再按相对时间换算 |
| 金额与数字 | ¥1,299.00 起、1.2万、面议 | 正则提取数字与单位,换算为统一数值 |
| 单位换算 | 1.5GB、500g、3 万/月 | 建单位映射表,统一到基本单位 |
| 枚举归一 | 本科 / 大学本科 / Bachelor | 枚举字典 + 别名表 |
| HTML 残留 | &、<br>、<script> | html.unescape() + 去标签 |
| 编码问题 | 乱码、\ufffd | 按 utf-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 与开放数据。
小结:清洗的目标不是「看起来整齐」,而是让不同来源的同一字段可比可算——先做确定性的文本、金额、时间、枚举归一,再按关键字段处理缺失与异常并留痕,去重分精确、近似、相似三层按成本递进;原始数据永不覆盖,清洗脚本与规则计数一起进版本库。