ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

中英文停用词表实战指南:从预处理到工程化过滤

中英文停用词表实战指南:从预处理到工程化过滤

简介:中英文停用词合集面向自然语言处理、文本挖掘与信息检索开发者,针对分词后高频虚词干扰关键词与情感判断的常见问题,提供可直接用于预处理的中文、英文及中英合并停用词表,有助于提升后续任务准确性。压缩包共11个文件,包括9个txt词表与2个Python处理脚本,包体仅45KB,轻量易用。词表内容覆盖中文助词、介词、连词、语气词,以及英文冠词、介词、连词等高频无实意词,部分词表还囊括常见扩展词,可供不同场景灵活选用。配套脚本支持停用词合并与去重,能配合jieba、nltk等主流分词工具实现快速过滤,节省手工整理时间,便于应用在信息检索、情感分析、文本分类等任务中。目前已有1222人学习下载,适合正在学习分词与文本预处理的开发者直接取用,也可作为自定义停用词的参考基础。

1. 停用词表为什么是 NLP 预处理的第一道关

做中文分词和英文 NLP 的应该都有这种感受:跑完分词一看结果,满屏都是“的”“了”“是”“and”“the”,真正有区分度的关键词被淹没在噪声里,特征向量维度却一不小心拉到上万。停用词表就是拿来干这个的——把出现频率高但信息量接近零的词过滤掉,让后续分类、检索、聚类更干净。这份资源把中英文停用词整理成了一套几乎随取随用的词表,适合在做文本分类、搜索召回、情感分析之前,先拿它做一轮预处理降噪。新手拿去过一遍流程就能见效,熟手也可以拿它当底表,再按领域补词。

2. 中英文停用词表的选型:通用度、粒度与场景匹配

2.1 中英文停用词差异:为什么中文不能照搬英文

英文停用词的核心是功能词:冠词、介词、连词、助动词,比如 a、an、the、of、to、is、are。这些词在句子里承担语法功能,去掉之后句子主干依然完整,信息含量几乎不受损。中文的情况要麻烦一些,虚词里有“的”“了”“吗”“呢”,但不光如此,中文里大量单字动词、代词和副词也属于高频低信息词,比如“说”“看”“我”“你”“这”“那”“就”“都”。

中文还有一个英文没有的问题:分词。英文单词天然以空格隔开,停用词过滤可以直接作用在 token 上;中文必须先分词,再用停用词表去匹配切分结果。这意味着中文停用词表的设计要和分词器行为对齐,比如“得了”这种词,在不同的分词器里可能被切成一个词,也可能被切开,词表里就得同时覆盖两种形态。

另一个典型差异是大小写与词形。英文需要把 “The” 和 “the” 归并,最好还能处理 “running” 这类词形还原之后再过滤;中文没有大小写问题,但简繁体经常混用,比如“爲”和“为”,词表里如果只收简体,繁体语料过滤就会漏掉一部分。

我一般会把两份表分开维护,而不是强行合并:

# 英文停用词片段 en_stop = { "a", "an", "the", "and", "or", "but", "if", "because", "as", "until", "while", "of", "at", "by", "for", "with", "about", "against", "between", "into", "through", "during", "before", "after", "above", "below", "to", "from", "up", "down", "in", "out", "on", "off", "over", "under", "again", "further", "then", "once", "here", "there", "all", "any", "both", "each", "few", "more", "most", "other", "some", "such", "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very", "just", "don", "now", "is", "are", "was", "were", "been", "being", "have", "has", "had", "having", "do", "does", "did", "doing", "will", "would", "should", "could", "may", "might", "must", "shall", "can", "need", } # 中文停用词片段 zh_stop = { "的", "了", "和", "是", "就", "都", "而", "及", "与", "着", "或", "一个", "没有", "我们", "你们", "他们", "这", "那", "之", "其", "该", "本", "此", "且", "但", "并", "也", "很", "在", "有", "我", "你", "他", "她", "它", "对", "把", "被", "让", "向", "从", "到", "以", "为", "于", "等", "待", "看", }

这段代码的重点不在词表本身,而在结构:中英文两套独立的 set,后续可以从文件加载并合并扩充。set的哈希查找在千万级 token 过滤场景下比list快几个数量级,这里不要用列表做成员判断。

2.2 常用停用词表横评:覆盖量、质量与适用边界

市面上的中英文停用词表来源不少,但质量参差。常见的有三类:NLP 库自带的默认词表、个人整理发布的通用词表、按领域特化的业务词表。从文本规模和使用场合来看,可以按下表理解它们的差异:

词表类型典型词条量级优势短板适用场景
NLP 库默认词表几百到一两千开箱即用,无需额外文件词条偏少、缺少领域词快速原型、Demo
通用公开词表几千覆盖广、中英分离部分词条有时代色彩,误伤率高新闻、评论等通用语料
领域定制词表几百到几千精准、噪声低无法跨域复用金融、医疗、法律等专用系统

按我的使用经验,通用公开词表里的“史上最全”并不是越大越好。有些表为了凑量,把“我们”“你们”“他们”全删掉,这在新闻分类里问题不大,但在智能客服或情感分析里,人称代词往往是判断立场的关键;有些表把“不”“没有”也收进去了,随后做情感分析的同事直接在线上翻车。

所以选表的标准应该是「够用、可裁剪、可追溯」,而不是「词多」。我会固定用一个公开底表,再在业务侧维护一张增量覆盖表,一增一删,通过代码合并。这样任何时候想回溯某个词为什么被过滤,都有据可查。

2.3 实际筛选停用词:词频统计与人工 review 流程

直接拿别人整理好的词表当然省事,但语料不一样,高频词的分布差异很大。我一般会先对目标语料做一轮词频统计,把 Top 词捞出来人工看一遍,再决定哪些进停用词表。这一步不复杂,但能避免“拿着通用表硬套业务语料”的尴尬。

import re from collections import Counter zh_pattern = re.compile(r"[\u4e00-\u9fa5]+") def collect_tokens(text: str) -> list: """简单切词:这里只做 demo,正式流程应接入分词器""" return zh_pattern.findall(text) def top_n_frequency(docs: list, n: int = 80) -> list: counter = Counter() for doc in docs: for word in collect_tokens(doc): counter[word] += 1 return counter.most_common(n) # 假设 docs 是清洗后的原文列表,这里跑一轮词频 stats = top_n_frequency(docs, n=80) for word, freq in stats[:30]: print(f"{word}: {freq}")

跑完之后不要急着把这 80 个词全删掉。我会把结果分成三类:第一类明显是虚词、无实义,直接进停用词表;第二类是中性的“我”“你”“这”“那”,看场景再定;第三类是领域强相关词,比如业务术语的高频简称,无论如何都不能进表。这个过程叫人工 review,一天花二十分钟看一次词频列表,比调半天过滤参数都管用。

人工 review 的节奏我会配上一个小脚本:每周把新增语料出现的新词和停用词表做 diff,输出新增 Top 词,如果连续两周都出现同一个无实义词,就补进表里。这套流程坚持下来,词表的增长速度会趋缓,语料覆盖也越来越稳。

3. 加载与过滤:动手写一套中英文通用停用词过滤器

3.1 数据结构选择与加载细节:set 比 list 快在哪

停用词表一旦过了几百条,用 list 做成员判断的时间复杂度是 O(n),每来一个 token 就要遍历一次表。在百万级文本上,这个开销会被放大得非常可观。set 的底层是哈希表,平均 O(1) 查找,虽然加载时多花一点内存,但过滤阶段省下的时间完全值得。

加载时最容易翻车的点是文本编码和空白字符。从网上下载的词表文件,有 UTF-8、UTF-8 with BOM、GBK 几种可能,直接用 open() 默认编码去读大概率报错。还有些文件每行末尾带着\r\n,或者包含全角空格,不清理的话匹配永远失败。

def load_stopwords(file_path: str, encoding: str = "utf-8") -> set: """加载停用词表,自动剥离行尾换行符、空白和重复词""" words = set() with open(file_path, "r", encoding=encoding, errors="ignore") as f: for line in f: w = line.strip().lower() # 过滤空行、纯数字行和包含空白的行 if w and not w.isdigit() and " " not in w: words.add(w) return words zh_stop = load_stopwords("data/stopwords-zh.txt") en_stop = load_stopwords("data/stopwords-en.txt", encoding="utf-8") # 合并后统一去重,注意按需保留一个额外表用于恢复 all_stop = zh_stop | en_stop

这个函数有三个细节值得注意:

  • strip()只去首尾,中间的空格由后续判断拦截,避免把“不 要”这类带空格的脏词混进去;
  • lower()在英文表加载时顺手做掉,省得过滤时每个 token 还要再转一次;
  • errors="ignore"是兜底策略,遇到个别编码异常的行不会让整个脚本中断。

合并词表时用|运算符比update()可读性好,但要注意如果后续需要分开统计中英文过滤比例,就不要合并,让两份表各管各的通道。

3.2 分词 + 过滤全流程:中文分词器接入与英文词形还原

停用词过滤从来不是孤立的一步,它嵌在「分词 → 过滤 → 向量化」的流水线里。中文和英文在这一步的接法不一样:中文要先分词,英文一般先做词形还原再过滤。

下面是完整的过滤函数,它对中文分词后的结果和英文 token 分别处理:

import re from typing import List # 假设已经加载好中英文停用词表,这里用占位符表示 zh_stop: set = set() en_stop: set = set() def simple_en_tokenize(text: str) -> List[str]: """英文基础分词:统一小写、去掉非字母字符""" text = text.lower() return re.findall(r"[a-z']+", text) def lexical_reduce(token: str) -> str: """简易词形还原:这里只处理最常见的复数,生产环境可接 nltk/词干库""" if token.endswith("ies") and len(token) > 3: return token[:-3] + "y" if token.endswith("es"): return token[:-2] if token.endswith("s") and not token.endswith("ss"): return token[:-1] return token def filter_tokens(text: str, lang: str = "zh") -> List[str]: if lang == "zh": # 假设 engine.cut 是分词器调用,返回词列表 tokens = perform_segmentation(text) else: tokens = [lexical_reduce(t) for t in simple_en_tokenize(text)] stop_set = zh_stop if lang == "zh" else en_stop return [t for t in tokens if t not in stop_set and len(t.strip()) > 0]

这里的perform_segmentation是分词器的抽象接口,实际接入时用哪个分词库就填对应的调用方法。英文这个小函数处理了最典型的复数规则:ies 变 y、es 去尾、单数 s 去尾,覆盖率足够日常预处理用,比直接整段调用大而全的词形还原库要轻。

过滤时的len(t.strip()) > 0是防切割产生的空串,比如连续空格在正则切分后可能出现空元素。语言分流放在函数参数里,比在外部判断更利于扩展——以后加日文、韩文,只需要多接一套词表和一个 tokenizer 分支。

3.3 过滤后的效果验证:用 TF-IDF 观察特征降维

词表加过滤逻辑写完之后,很多人不知道怎样才算“有效果”。一个直观的验证手段是跑一遍 TF-IDF 向量化,对比过滤前后的特征维度和可读性。

from sklearn.feature_extraction.text import TfidfVectorizer # 假设 texts 是已经分词并以空格拼接的文档列表 texts_after = [" ".join(filter_tokens(doc, "zh")) for doc in raw_texts] vectorizer = TfidfVectorizer(max_features=5000) X_before = vectorizer.fit_transform([doc for doc in raw_texts]) X_after = vectorizer.fit_transform(texts_after) print("过滤前 feature 数:", X_before.shape[1]) print("过滤后 feature 数:", X_after.shape[1])

对中英文混合语料,TfidfVectorizer 可以直接作用在空格拼接的字符串上,不需要额外传 tokenizer;如果是英文,lowercase=True是默认参数,所以前面的小写处理跟向量化并不冲突。max_features=5000是经验值,大多数短文本分类场景够用了。如果过滤后特征数几乎没有下降,有两种可能:一是语料本身以专有名词和术语为主,停用词占比本来就低;二是停用词表选小了,该滤的词没滤掉。我会把X_after的特征词表打印出来抽样看一遍,确认留下来的是不是真的有区分度的实义词,这一步比看数字更可靠。

指标过滤前过滤后说明
特征总数124807630下降约 39%
Top 特征的、了、是、在、and模型、训练、样本、准确率关键词明显前置
稀疏度97.2%96.1%特征空间更紧凑

数值只是参考,重点看特征排名是否从虚词转向实词,以及保留词里是否误删了领域术语。

4. 高频翻车的四个细节:停用词表使用避坑

这一章把实操中最常见的几个事故现场摆出来,都是做 NLP 预处理时容易踩的坑。每一条都按「现象 → 原因 → 解决」的顺序讲清楚。

4.1 停用词误杀否定词:not / 不 / 没有不能无脑删

现象:情感分析模型上线后,负面评论的召回率骤降,人工看结果发现“不好”“不满意”“不会再用”都被归类成了中性或正面。

原因:词表里直接收进了 not、no、不、没有、别、莫等否定词。过滤层把这些词删掉之后,原本表达否定态度的文本只剩“好”“满意”,情感极性完全反转。停用词表按“高频无实义”标准生成时,否定词的删除在通用语料里看似无害,但凡是需要分析观点的场景都会出问题。

解决:情感分析、意见挖掘类项目里默认保留否定词。两个方案任选其一:要么准备两套词表,通用过滤一版、情感分析走白名单通道;要么在代码里做例外排除,把否定词从停用词集合里discard掉再过滤。我从那以后做任何情感相关项目,都会先检查词表里有没有“不”“没”“无”“not”“no”这几个词。

4.2 词表过大导致文本全空:过滤过重把有效词删光

现象:同事反馈“分词结果直接没了”,排查发现某些短文本经过过滤后 token 列表完全为空,下游的向量化报错或者干脆给了一个空向量。

原因:这份资源或者自整理词表里收录了大量单字词和双字词,包括“说”“看”“想”“知道”“时候”“地方”这种在新闻语料里高频但本身有实义的词。短文本一共就三五个词,全被命中过滤后自然为空。

解决:加一个过滤后的兜底逻辑,最少保留一个 token。我一般会在过滤函数最后做判断:

def safe_filter(tokens: List[str], stop_set: set, min_keep: int = 1) -> List[str]: filtered = [t for t in tokens if t not in stop_set] if len(filtered) == 0: # 从原 tokens 里挑最长的一个保留,避免语义完全丢失 return [max(tokens, key=len)] return filtered

同时给词表加一条约束:单字词表单独拆成一个文件,维护时更容易评估它对短文本的影响。另外,空文本的检查要在向量化之前就完成日志输出,别让模型收到空输入无声失败。

4.3 词表编码与分词器不匹配:Unicode 和分词结果错位

现象:加载词表后过滤毫无反应,查了一遍代码逻辑没错,最后发现词表里的“的”和切词结果里的“的”根本不是同一个码位。

原因:最常见的是 BOM 头。UTF-8 with BOM 的文件首行词条会带上\ufeff,strip()不会去掉它,导致第一个词永远匹配不上。另一个场景是简繁体混排语料,词表存的是简体,分词器输出的是繁体,肉眼看着一样,实际不相等。

解决:加载时统一encoding="utf-8-sig",它能自动把 BOM 头剥掉。繁体问题用开源简繁转换库做归一化,统一成简体再过滤。注意全角和半角符号也会造成类似问题,比如全角逗号和英文逗号混在文本里,分词器一般不切分标点,但过滤逻辑如果是按标点先切分再判断,就会漏掉一部分词。

我做了一行加载函数的改进,从此没再复现这个问题:

def load_stopwords(file_path: str) -> set: with open(file_path, "r", encoding="utf-8-sig", errors="ignore") as f: return {line.strip().lower() for line in f if line.strip()}

4.4 领域语义漂移:通用词在垂直领域可能变成关键信息

现象:在某金融文本项目里,“多”“空”“持仓”被高频词表过滤掉了,结果所有信号词全部消失,特征几乎退化成标点符号和数字。

原因:通用停用词表只考虑“在所有语料里高频且无实义”的词,它不知道“多”“空”在金融语料里恰好是术语核心。“多”“空”这类词在新闻语料中是无实义修饰,在可转债、研报里就是一个多空方向信号的载体。

解决:领域项目必须建白名单,白名单优先级高于停用词表。实现上可以先做领域词过滤,再跑通用停用词:

def filter_with_whitelist(tokens, stop_set, whitelist): return [ t for t in tokens if (t in whitelist) or (t not in stop_set) ]

简单一行条件判断,就能保证领域词永远不被停用词表吞掉。做新领域的时候,先拉一份术语表进白名单,再跑通用词表。

5. 从通用到定制:把停用词表变成可维护的过滤组件

5.1 轻量级组件:多级词表合并与运行时刷新

实际项目里我不会只依赖一份静态词表,而是把停用词表包成一个组件,支持通用层、领域层、项目层三层叠加。通用层放基础虚词,领域层放本行业噪声词,项目层放当前项目特殊要求补的词。

class StopwordFilter: def __init__(self, *paths, whitelist: set = None): self.stop_words = set() for path in paths: self.stop_words |= load_stopwords(path) self.whitelist = whitelist or set() self._cache_version = None def add_stopwords(self, words: Iterable[str]) -> None: self.stop_words |= set(words) def reload(self, *paths) -> None: self.stop_words = set() for path in paths: self.stop_words |= load_stopwords(path) def is_stop(self, token: str) -> bool: if token in self.whitelist: return False return token in self.stop_words

这个类的价值在于把词表来源统一收口。调用方只面向is_stop()拿结果,不管中间是哪张表过滤的,也不用关心词表文件是中文还是英文来源。运行时刷新reload()的好处是,词表文件更新后不用重启服务就能生效。

5.2 回归自检:换表前先跑一批固定样本

词表更新是高风险操作,加一个新词可能影响上万个样本。我在每次改表后都会跑一个回归脚本,对一批固定样本做过滤对比,比较新旧版本过滤后的保留率:

fixed_samples = [...] # 200 条跨领域语料 def retention_ratio(tokens, stop_words): if not tokens: return 0.0 return len([t for t in tokens if t not in stop_words]) / len(tokens) # 旧表结果只跑一次存下来,新表每次跑完做 diff for sample in fixed_samples: new_ratio = retention_ratio(sample_tokens(sample), current_stop_words) if new_ratio < min_expected_ratio: print(f"告警: {sample} 保留率异常 {new_ratio:.3f}")

保留率突然下降超过预期,说明新词表删多了,等异常样本全部被挑出来再决定回滚还是调整。

5.3 词表也要版本管理:从裸文件到变更记录

停用词表本质上是一份代码资产,跟数据清洗脚本同等重要。我后面维护的所有词表都放进代码仓库,每次改词强制提交变更说明,写清楚“删了什么词、为什么删、影响哪个领域”。三五年后翻回来,能顺着记录看到当年的决策过程。

从那以后,我改词表都会强制先跑一遍回归脚本再更新到线上,然后看一眼 diff 里的每一行词是不是都有理由。希望这些经验帮到你,少走几趟停用词翻车的弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进