文本清洗与规范化:停用词过滤、正则表达式与词干提取

📂 所属阶段:第一阶段 — 文本预处理(基石篇)
🔗 相关章节:分词技术 · 文本特征工程


1. 为什么需要文本清洗?

我们接触到的非结构化文本,很少是“拿来就能用”的。比如爬取的网页、社交媒体动态、客服聊天记录,总充斥着各种干扰信息——

原始网页推广文本:
"<p>【年度大促】🔥 手慢无!!!👉 跳转 https://example-discount.com 🎁 仅限新粉哦~</p>"

这些“脏东西”会分散模型注意力、降低计算效率、干扰结果质量,比如:

  • 链接/表情完全不包含业务语义
  • HTML标签只是排版标记
  • 重复的标点/语气词(“!!!”、“哦~哦~”)会引入冗余

清洗后理想状态是保留核心词序列: ["年度", "大促", "手慢", "无", "仅限", "新粉"]


2. 基础清洗“工具箱”

这部分的操作大多用Python正则+标准库就能完成,遇到复杂场景再考虑第三方库。

2.1 去除 HTML 标签

简单标签用正则就能处理,嵌套/残缺标签推荐用 lxml 更健壮。

import re
from html import unescape
from lxml import html

def remove_html(text, use_lxml=False):
    """去除 HTML 标签 + 转义字符(如 &lt; → <)"""
    # 先统一转义
    text = unescape(text)
    if use_lxml:
        # lxml 处理嵌套/残缺更稳定
        try:
            return html.fromstring(text).text_content()
        except Exception:
            pass
    # 回退方案:正则
    return re.sub(r'<[^>]+>', '', text)

text = "<p>这是一段<strong>嵌套HTML</strong>文本,还有<未闭合标签"
print(remove_html(text, use_lxml=True))
# 这是一段嵌套HTML文本,还有

2.2 清理 URL、邮箱与冗余占位符

这些信息在通用NLP任务(如分类、摘要)中通常可以直接删。

def clean_noise(text):
    """组合清理 URL、邮箱、占位数字(可选)"""
    # 清理 http/https/www 开头的 URL
    text = re.sub(r'https?://\S+|www\.\S+', '', text)
    # 清理标准格式邮箱
    text = re.sub(r'\S+@\S+\.\S+', '', text)
    # 可选:清理占位符类数字(比如工单ID、身份证片段提示)
    # text = re.sub(r'\d{4,}', '', text)
    return text

text = "工单处理进度可戳 https://xxx.com/ticket ,或发邮件至 service@xxx.com"
print(clean_noise(text))
# 工单处理进度可戳 ,或发邮件至 

2.3 全角转半角 + Unicode 规范化

处理跨平台/输入法带来的字符歧义,比如“HELLO”和“HELLO”应该是一个意思。

import unicodedata

def full_to_half(text):
    """全角英数/空格/符号转半角"""
    result = []
    for char in text:
        code = ord(char)
        # 全角空格 → 普通空格
        if code == 12288:
            code = 32
        # 全角字符区间(从!到~)
        elif 65281 <= code <= 65374:
            code -= 65248
        result.append(chr(code))
    return "".join(result)

def normalize_text(text):
    """统一字符编码(NFC最常用),还可以组合消除连笔/装饰符"""
    # NFC:组合字符标准化(比如 "a" + "~" → "ã")
    text = unicodedata.normalize("NFC", text)
    # 可选:删除非间距装饰符(比如 "ñ" → "n",慎用,会丢失语言特征)
    # text = "".join([c for c in text if not unicodedata.combining(c)])
    return text

text = "HELLO~123,我今年25岁啦"
text = full_to_half(normalize_text(text))
print(text)
# HELLO~123,我今年25岁啦

2.4 去除重复冗余 + 多余空格

比如社交媒体的“啊啊啊啊啊啊开心”、连续换行制表符。

def clean_duplicate_and_space(text, max_char_repeat=2):
    """压缩连续重复字符 + 合并多余空白"""
    # 压缩重复字符(比如“啊啊啊啊”→“啊啊”)
    text = re.sub(r'(.)\1{%d,}' % max_char_repeat, r'\1' * max_char_repeat, text)
    # 合并换行/制表符/空格,首尾去空
    text = re.sub(r'\s+', ' ', text).strip()
    return text

text = "  啊啊啊啊啊   今天真的!真的!真的!开心到飞起\n\n"
print(clean_duplicate_and_space(text))
# 啊啊 今天真的!真的!开心到飞起

3. 停用词过滤:去掉“语义噪音”

停用词是指高频但无/弱业务语义的词,比如中文的“的、了、和”,英文的“the、a、is”。

3.1 中文停用词基础使用

我们可以用 jieba 分词后,再对照停用词表过滤。

import jieba

# 基础中文停用词表(可根据业务补充,比如“点击、了解”这种通用推广词)
STOPWORDS_ZH = {
    '的', '了', '和', '是', '就', '都', '而', '及', '与', '着',
    '或', '一个', '没有', '我们', '你们', '他们', '这个', '那个',
    '啊', '呀', '呢', '吧', '吗', '哦', '哈', '嗯', '哎',
    '了', '着', '过', '的', '地', '得',
}

def filter_stopwords(tokens, stopwords, min_len=1):
    """过滤停用词 + 过滤过短词(比如单字语气词/标点残留)"""
    return [
        t for t in tokens
        if t not in stopwords and len(t) >= min_len
    ]

# 测试
text = "我今天在省图书馆认真学习自然语言处理技术"
tokens = jieba.lcut(text)
filtered_tokens = filter_stopwords(tokens, STOPWORDS_ZH, min_len=2)
print(filtered_tokens)
# ['今天', '图书馆', '认真', '学习', '自然语言处理', '技术']

3.2 自定义停用词加载

业务场景下,往往需要补充特定停用词(比如医疗场景的“患者、您好”,电商场景的“亲、包邮”),可以从本地文件加载。

def load_custom_stopwords(file_path):
    """从本地UTF-8文件加载停用词(一行一个)"""
    try:
        with open(file_path, 'r', encoding='utf-8') as f:
            return set(line.strip() for line in f if line.strip())
    except FileNotFoundError:
        print(f"⚠️ 停用词文件 {file_path} 不存在,使用默认空表")
        return set()

# 假设 stopwords_zh_ecommerce.txt 存了“亲、包邮、限时”
custom_stopwords = load_custom_stopwords("stopwords_zh_ecommerce.txt")

4. 英文专属:词干提取 vs 词形还原

中文没有严格的“词形变化”,但英文有(比如 runningrunsran),我们需要把它们统一成原型或词干,减少特征维度。

4.1 词干提取(Stemming)

快速但不保证是有效单词,适合对结果要求不高的场景(如关键词提取、粗分类)。

import nltk
from nltk.stem import PorterStemmer, LancasterStemmer

# 首次使用需下载punkt分词器
nltk.download('punkt', quiet=True)

porter = PorterStemmer()  # 温和型,保留更多语义
lancaster = LancasterStemmer()  # 激进型,压缩更狠

words = ["running", "runs", "ran", "runner", "easily", "fairly"]
print("Porter词干:", [porter.stem(w) for w in words])
print("Lancaster词干:", [lancaster.stem(w) for w in words])

# 输出
# Porter词干: ['run', 'run', 'ran', 'runner', 'easili', 'fairli']
# Lancaster词干: ['run', 'run', 'run', 'run', 'eas', 'fair']

4.2 词形还原(Lemmatization)

需要词性标注(POS Tag)才能准确还原,结果是有效单词,适合对精度要求高的场景(如翻译、问答)。

from nltk.stem import WordNetLemmatizer
from nltk.corpus import wordnet

# 首次使用需下载资源
nltk.download('wordnet', quiet=True)
nltk.download('averaged_perceptron_tagger', quiet=True)

lemmatizer = WordNetLemmatizer()

def get_wordnet_pos(tag):
    """把nltk的词性标签转为wordnet可用的"""
    if tag.startswith('J'):
        return wordnet.ADJ
    elif tag.startswith('V'):
        return wordnet.VERB
    elif tag.startswith('N'):
        return wordnet.NOUN
    elif tag.startswith('R'):
        return wordnet.ADV
    else:
        return wordnet.NOUN  # 默认名词

text = "He is running faster than me because he runs every day and ran yesterday"
tokens = nltk.word_tokenize(text)
pos_tags = nltk.pos_tag(tokens)

lemmas = [
    lemmatizer.lemmatize(token, get_wordnet_pos(tag))
    for token, tag in pos_tags
]
print(lemmas)
# ['He', 'be', 'run', 'faster', 'than', 'me', 'because', 'he', 'run', 'every', 'day', 'and', 'run', 'yesterday']

5. 快速搭建预处理管道

把前面的步骤封装成类,方便批量处理文本。

from functools import reduce

class SimpleTextPreprocessor:
    def __init__(self, stopwords=None, lang='zh'):
        self.stopwords = stopwords or set()
        self.lang = lang

    # 中文/英文通用清洗
    def _clean_general(self, text):
        steps = [
            lambda x: remove_html(x, use_lxml=True),
            clean_noise,
            normalize_text,
            lambda x: full_to_half(x) if self.lang == 'zh' else x,
            clean_duplicate_and_space,
        ]
        return reduce(lambda t, fn: fn(t), steps, text)

    # 中文分词过滤
    def _process_zh(self, text):
        tokens = jieba.lcut(text)
        return filter_stopwords(tokens, self.stopwords, min_len=2)

    # 英文分词还原(可选词干)
    def _process_en(self, text, use_lemmatizer=True):
        tokens = nltk.word_tokenize(text.lower())  # 英文一般转小写
        if use_lemmatizer:
            pos_tags = nltk.pos_tag(tokens)
            tokens = [
                lemmatizer.lemmatize(t, get_wordnet_pos(tag))
                for t, tag in pos_tags
            ]
        else:
            tokens = [porter.stem(t) for t in tokens]
        # 英文也有基础停用词,这里简化省略
        return [t for t in tokens if len(t) >= 2]

    # 主入口
    def process(self, text, **kwargs):
        cleaned = self._clean_general(text)
        if self.lang == 'zh':
            return self._process_zh(cleaned)
        else:
            return self._process_en(cleaned, **kwargs)

# 测试中文
zh_processor = SimpleTextPreprocessor(stopwords=STOPWORDS_ZH, lang='zh')
zh_raw = "<p>🔥🔥🔥 年度大促!!!快来 https://xxx.com 抢!抢!抢!亲,仅限新粉哦~</p>"
print(zh_processor.process(zh_raw))
# ['年度', '大促', '快来', '仅限', '新粉']

6. 避坑指南:不要“过度清洗”

文本清洗不是越干净越好,要根据具体任务调整:

  • 情感分析:保留感叹号、问号、表情(如 “!!!”、“😠”、“😊”),它们包含强烈的情感倾向
  • NER(命名实体识别):不要删除数字、全角字符(比如人名中的全角括号、公司名中的全角连字符)
  • 机器翻译:不要过度压缩重复字符、删除标点(比如引号、书名号)

7. 小结速查

操作常用工具/代码片段
去除HTML标签lxml.html.fromstring(text).text_content()
清理URL/邮箱`re.sub(r'https?://\S+
全角转半角自定义 full_to_half 函数
Unicode规范化unicodedata.normalize("NFC", text)
压缩冗余re.sub(r'(.)\1{2,}', r'\1'*2, text) + re.sub(r'\s+', ' ', text)
中文停用词过滤jieba.lcut(text) + 自定义停用词表
英文词形还原WordNetLemmatizer + POS词性标注

🔗 扩展阅读