数据预处理(一):文本清洗、去除噪声、统一编码

好,咱们正式开始进入数据预处理阶段。这一步,说白了就是把“脏数据”洗成模型能读懂的样子。

我记得刚做 NLP 项目那会,接过一批用户评论数据。一看,满屏的 HTML 标签、特殊符号、乱码……当时我就意识到,数据清洗不是可选的,是必须的。

这一章,我挑三个最核心的预处理动作来讲:文本清洗、去除噪声、统一编码。这三个动作做好了,后面模型训练的很多坑,你根本不用踩。

数据预处理核心流程 原始文本数据 文本清洗 去除噪声 统一编码 全角 → 半角 统一大小写 繁体 → 简体 去除数字/标点 去除HTML标签 去除URL/邮箱 去除特殊符号 去除不可见字符 检测原始编码 统一转 UTF-8 处理 BOM 头 转义字符还原 标准化干净文本

文本清洗:从全角半角到简繁转换

先说文本清洗。很多人一上来就是正则表达式哗哗一顿删,结果删错了。

我习惯按顺序来:全角转半角 → 大小写统一 → 繁体转简体 → 按需去除数字和标点。

全角与半角 :英文、数字、基本标点统一用半角。全角字母(例如 'A')要转成半角 'A'。这一步不做,模型会认为 'hello' 和 'hello' 是两个词。

举个例子,Python 里可以用 str.maketrans 做映射,也可以直接写个函数遍历字符。

import unicodedata

def fullwidth_to_halfwidth(s: str) -> str:
    result = []
    for ch in s:
        if unicodedata.east_asian_width(ch) == 'F':
            # 全角字符偏移量为 65248
            result.append(chr(ord(ch) - 65248))
        else:
            result.append(ch)
    return ''.join(result)

text = "hello,world!"
print(fullwidth_to_halfwidth(text))
# 输出:hello,world!

大小写统一没什么好说的,转成小写就行。为什么?因为敏感词列表里通常是小写。你想想看,'LOAN' 和 'loan' 在语义上没有区别,统一后模型就不必学两套模式了。

繁体转简体,我个人常用 zhconv 或 OpenCC。这里注意一个坑:一简对多繁。例如「干」在繁体中可能是「幹」或「乾」,反向转换时得用词级别判断。但如果是正向(繁→简),基本是安全的。

from zhconv import convert

def trad_to_simp(s: str) -> str:
    return convert(s, 'zh-cn')

text = "數據預處理很重要"
print(trad_to_simp(text))
# 输出:数据预处理很重要

去除噪声:该删的不该删的,心里要有数

噪声包括:HTML 标签、URL、邮箱、@提及、特殊符号、控制字符、冗余空格……

噪声类型 示例 处理方案
HTML 标签 <div>...</div> BeautifulSoup(text).get_text()
URL https://example.com/path 正则 r'https?://\S+' 移除
邮箱 user@domain.com 正则 r'\S+@\S+\.\S+' 移除
特殊符号 ★ ♥ ➜ ❤ 保留标点,其余 Unicode 符号过滤
不可见字符 \x00 \t \r str.strip() + unicodedata.category 过滤

有一次,我从爬虫拿到的数据里混了 \xa0(不换行空格),肉眼完全看不出来。模型训练后精确率低了 5%。查了半天才发现是这些隐式字符在作怪。嗯,这里要注意——推荐用 unicodedata.category 扫一遍,直接扔掉 'Cc' 和 'Cf' 类别的字符。

一个小技巧:正则匹配前,先把 \n \t 替换成空格,再统一 re.sub(r'\s+', ' ', text)。分段拼接后,很多正则表达式会简单很多。

统一编码:从乱码到 UTF-8

说到编码,我真是又爱又恨。我遇到过一份来自银行的数据,源文件是 GB2312,接口又说是 UTF-8,结果读到 Python 里全是 锟斤拷……

重要:获取任何数据源时,必须明确原始编码。常见的数据源编码包括:UTF-8、GBK、GB2312、ISO-8859-1、Shift-JIS 等。

我的处理流程很简单:

  1. 检测编码:用 chardet 或 cchardet 先检测。虽然不 100% 准确,但足够当作第一猜测。
  2. 解码:用检测到的编码解码,bytes → str。
  3. 统一转成 UTF-8:内部处理一律用 UTF-8,写入文件时也固定为 UTF-8(不带 BOM)。
  4. 显式处理异常:用 errors='replace' 或 'ignore' 兜底,但我会记录日志,方便回查。
import chardet

def detect_and_decode(raw_bytes: bytes) -> str:
    result = chardet.detect(raw_bytes)
    encoding = result['encoding'] or 'utf-8'
    confidence = result['confidence']

    print(f"检测到编码: {encoding}, 置信度: {confidence:.2%}")

    try:
        text = raw_bytes.decode(encoding, errors='replace')
    except LookupError:
        # 遇到不认识的编码,回退到 utf-8
        text = raw_bytes.decode('utf-8', errors='replace')
    return text

# 使用场景
with open('user_comment.dat', 'rb') as f:
    content = f.read()

clean_text = detect_and_decode(content)
print(clean_text[:100])

个人习惯:所有数据清洗步骤写成一个 pipeline 函数,传入原始字符串,返回清洗后的字符串。每步做原子操作,方便调试和复用。举个例子:

def clean_pipeline(text: str) -> str:
    # 1. 全角转半角
    text = fullwidth_to_halfwidth(text)
    # 2. 统一小写
    text = text.lower()
    # 3. 繁体转简体
    text = trad_to_simp(text)
    # 4. 去除 HTML 标签
    text = BeautifulSoup(text, 'html.parser').get_text()
    # 5. 去除 URL
    text = re.sub(r'https?://\S+', '', text)
    # 6. 去除邮箱
    text = re.sub(r'\S+@\S+\.\S+', '', text)
    # 7. 去除杂噪符号(保留汉字、字母、数字、基本标点)
    text = re.sub(r'[^\w\s\u4e00-\u9fff,。!?、]', '', text)
    # 8. 合并连续空白
    text = re.sub(r'\s+', ' ', text).strip()
    return text

我曾经维护过一个旧项目,洗数据的逻辑散落在 5 个不同的脚本里,互相覆盖。后来花了两个下午全部重构成 pipeline。嗯,重构后的代码不仅运行快了一倍,排查问题也轻松多了。

一点补充:不要过度清洗

有两点我想提一下。

第一,保留必要的标点。像句号、问号、叹号、逗号,对句子边界判别很有用。如果你的模型是序列标注模型(比如 BiLSTM-CRF),这些标点可以作为天然的分段特征。

第二,数字不一定都要删。敏感电话号码、身份证号里的数字是模式,但是普通数字(如“100 元”)中的 100 可能保留更好。我的做法是:先把疑似敏感数据(连续 8 位以上数字)替换成占位符 [NUM],再清掉其他杂散数字。

一个原则:清洗标准要跟你的敏感数据定义对齐。如果你要识别银行卡号,那保留数字连续片段才有意义;如果你要识别人名,数字全删了也无妨。

好,数据预处理的第一个阶段就先聊到这儿。文本清洗、去除噪声、统一编码这三板斧,配合成一个 pipeline,就能解决 80% 的脏数据问题。下一阶段我们还会深入分词、去停用词等动作,但那些都是建立在这套干净数据之上的事了。