简介:哈工大停用词表以单份 DOC 文档形式提供,面向自然语言处理入门者、中文分词与文本挖掘实践者,用于在分词、词性标注及后续语义分析前过滤高频但语义贡献有限的虚词、符号与数字串。压缩包内共 1 个 doc 文件,整体约 39KB,体量轻便,可直接并入分词流程或作为停用词库的补充底表。该表与哈工大 LTP 语言技术平台的中文处理任务相呼应,涵盖“的、和、是”等常见虚词以及标点、编号、特殊符号等噪声项,便于在信息检索、情感分析、文本分类和机器学习建模中减少无关词干扰。已有 3261 人学习,说明其在中文文本预处理场景中具有较稳定的参考价值。使用者可以据此完成停用词过滤、特征筛选与语料清洗,降低模型复杂度,把注意力集中到携带实际语义的词汇上。
1. 一份被反复复制的中文停用词表,先用文本编辑器验一次货
做中文分词的项目,绕不开停用词。后面接的是词性标注、TF-IDF 还是句向量,总会有人在群里甩一句“停用词表用哪份”,然后有人丢出这份两千行上下的高校版中文停用词表——它差不多成了国内中文分词流水线里的默认件。它的价值不在“全”,而在于覆盖面:口语助词、语气词、全角标点、编号符号这几类高频噪声基本都在里面,拿来就能过滤。适合正在搭中文分词或文本分类基线、被“的了吗呢”干扰到特征权重、又不想从零攒词表的人。先给个结论:这份文件是 .doc,直接读会翻车;转成文本后还会看到大量非词行,不做清洗直接用,分词结果会比不用更糟。
2. 从 .doc 到干净词表:转换、归一与粘连切分的四步清洗
这份资源拿到手是个旧版 Word 二进制文件,不是 .docx。很多人第一反应是python-docx,然后在Document()那一行直接吃一个BadZipFile——它只认 OOXML 包结构。这一步卡住的人不少,所以先把转换这条路走通,再谈清洗。
2.1 先做文件体检:判断是不是真 .doc
不要看到扩展名就当结论,先读文件头。这是个一秒钟的动作,能省掉后面半小时的猜谜。
from pathlib import Path p = Path("stopwords.doc") head = p.read_bytes()[:8] # 旧版 Word(CFB 复合文档):D0 CF 11 E0 A1 B1 1A E1 # 新版 Word(OOXML,zip):50 4B 03 04 # 纯文本或 RTF:能看到可打印字符或 {\rtf print(head) print(p.stat().st_size)如果头是D0CF11E0,说明是复合文档容器,得用转换工具;如果是504B0304,那其实是个 docx 被改了后缀,python-docx直接能读,只是要注意它返回的是段落对象,不是纯文本。判断这一步别偷懒,我见过有人拿 .doc 走了三遍python-docx,最后怀疑是文件损坏。
转换我一般用两条路,按环境选:
# 路线 A:LibreOffice 无头模式,跨平台,输出编码可指定为 UTF-8 libreoffice --headless \ --convert-to "txt:Text (encoded):UTF8" \ --outdir ./raw stopwords.doc # 路线 B:antiword,Linux 下轻量,但中文要靠映射文件,容易缺 antiword -m UTF-8.txt stopwords.doc > raw/stopwords.txt参数说明:--convert-to后面那串txt:Text (encoded):UTF8是关键,不写过滤器参数时 LibreOffice 可能按本地默认编码落盘,中文环境出来就是 GBK,后面read_text(encoding="utf-8")会抛UnicodeDecodeError。--outdir必须是一个已存在的目录,否则它会静默失败或者写到当前目录,我踩过一次,找了半天文件在哪。路线 B 的-m UTF-8.txt依赖系统里装了对应的 mapping 文件,容器镜像里经常缺,缺了也不报错,直接输出乱码,所以生产环境我更倾向路线 A。
转换完先别急着重命名成 stopwords.txt,就叫stopwords_raw.txt,语义上区分“机器转出来的脏文本”和“我清洗后的词表”。
2.2 体检脏行:先分类,再动手
转换出来的文本大概率长这样:一堆全角方括号编号段([①a]、[②③])、连续的点线和波浪线、孤立的标点、罗马数字、希腊字母、还有本来应该是多个词但被粘连成一行的汉字串。直接set(open(...).read().split())然后拿去过滤,会得到两个后果:一是大量噪声词进集合,二是真正的停用词被粘连串带偏,一个都匹配不上。
所以第二步是统计,不是清洗。先看清楚各类脏行各占多少,再决定规则写多狠。
import re import unicodedata from collections import Counter from pathlib import Path text = Path("raw/stopwords_raw.txt").read_text(encoding="utf-8", errors="replace") lines = text.splitlines() CJK = re.compile(r"[\u4e00-\u9fff]") BRACKET = re.compile(r"[[^]]{0,10}]") stat = Counter() samples = {} for ln in lines: s = ln.strip() if not s: stat["empty"] += 1 continue if BRACKET.search(s): stat["bracket_no"] += 1 samples.setdefault("bracket_no", s[:60]) elif not CJK.search(s): stat["no_cjk"] += 1 samples.setdefault("no_cjk", s[:60]) elif len(s) > 6: stat["glued"] += 1 samples.setdefault("glued", s[:60]) else: stat["clean"] += 1 print(stat.most_common()) for k, v in samples.items(): print(k, "->", repr(v))逻辑说明:empty是空行,bracket_no是带全角方括号编号的行,no_cjk是完全不含汉字(纯符号、纯拉丁、纯希腊字母)的行,glued是长度超过 6 的纯汉字串——中文停用词绝大多数是 1 到 3 个字,超过 6 个字基本可以判定是多行被合并或者原本就该切分。这套分类不是为了精确,是为了让你知道规则该往哪儿使劲。参数上,len(s) > 6这个阈值可以调,我一般用 6,因为四个字的停用词(比如“无论如何”)已经很少见了,再长必然是脏的。
跑完你会看到bracket_no和no_cjk加起来的量级不小,这说明这份表的原始排版里混了大量公式排版残留和编号脚注,不是纯粹的词条列表。
2.3 清洗四步:编号剥离、全半角归一、粘连切分、类型过滤
顺序很重要,错了会漏。我的固定顺序是:先剥全角方括号编号段,再做 NFKC 归一,再按分隔符粗切,最后按“纯中文 + 长度 1~4”过一遍。为什么先剥括号再归一?因为unicodedata.normalize("NFKC", ...)会把[变成[、把①变成1,归一之后你就分不清“这是编号”还是“这是正文里的数字”了,规则会误伤。先把成对的编号段整段删掉,再归一,剩下的数字就都是真数字,可以直接丢。
import re import unicodedata from pathlib import Path import jieba RAW = Path("raw/stopwords_raw.txt") OUT_ZH = Path("stopwords_zh.txt") OUT_PUNCT = Path("stopwords_punct.txt") # 全角方括号编号段,如 [①a] [②③] [] BRACKET_FULL = re.compile(r"[[^]]{0,10}]") # 连续填充符号:...... ~~~~ ---- ==== **** FILLER = re.compile(r"[..·~~\-—─_=**]{2,}") # 行内分隔符,用于把一行里的多个候选粗切开 SPLITTER = re.compile(r"[\s,,、;;//||]+") def normalize(s: str) -> str: # NFKC 把全角字母数字、圈号、% 等统一成半角/常规形式 return unicodedata.normalize("NFKC", s) def is_punct_only(s: str) -> bool: # P* 是标点类,Sm 是数学符号 return all( unicodedata.category(ch).startswith("P") or unicodedata.category(ch) == "Sm" for ch in s ) def is_zh_word(s: str, max_len: int = 4) -> bool: if not (1 <= len(s) <= max_len): return False return all("\u4e00" <= ch <= "\u9fff" for ch in s) def cut_glued(s: str, max_len: int = 4): # 短串直接返回;长串先让 jieba 试切,切不动就退化成按字拆 if len(s) <= max_len: return [s] parts = [p for p in jieba.lcut(s) if p.strip()] return parts if len(parts) > 1 else list(s) def clean(raw_text: str): seen = set() zh, punct = [], [] for line in raw_text.splitlines(): line = BRACKET_FULL.sub(" ", line) # 第一步:剥编号段 line = FILLER.sub(" ", line) # 第二步:剥填充符号 for chunk in SPLITTER.split(line): # 第三步:粗切 chunk = normalize(chunk).strip() # 第四步:归一 if not chunk: continue if is_punct_only(chunk): # 标点单独成表:分词后标点通常也成 token,需要一并过滤 if len(chunk) == 1 and chunk not in seen: seen.add(chunk) punct.append(chunk) continue for w in cut_glued(chunk): if is_zh_word(w) and w not in seen: seen.add(w) zh.append(w) return zh, punct raw_text = RAW.read_text(encoding="utf-8", errors="replace") zh, punct = clean(raw_text) OUT_ZH.write_text("\n".join(zh) + "\n", encoding="utf-8") OUT_PUNCT.write_text("\n".join(punct) + "\n", encoding="utf-8") print(len(zh), len(punct), zh[:20])逻辑说明几处关键点。errors="replace"是必要的,转换产物里偶尔混进无法解码的字节,硬读会中断整个流程,替换成占位符后这些行会被is_zh_word自然滤掉。cut_glued用 jieba 做切分是个“借用现成分词器解决脏数据”的做法,因为粘连串本身在原始排版里可能是多个停用词被空格或制表符吃掉造成的,jieba 对这种常见虚词串的切分准确率还不错;切不动的退化策略是按字拆,虽然会把“哎呀”拆成两个词,但两个都是有效停用词,损失可以接受。is_punct_only用 Unicode 类别判断而不是手写字符串,是因为这份表里的标点横跨全角、半角、数学符号三大类,手写枚举必然漏。
参数上可调的两个:is_zh_word的max_len默认 4,想更保守可以设 3,会丢掉极少数四字停用词;BRACKET_FULL里的{0,10}控制编号段最大长度,如果发现漏网的长编号,放宽到 16 即可,别设太大,否则会跨行误删。
2.4 落盘格式与加载约定
输出两份文件:stopwords_zh.txt和stopwords_punct.txt,都是一行一个词、UTF-8 无 BOM。BOM 这个坑很隐蔽——用某些 Windows 编辑器另存为 UTF-8 会带 BOM,读进来第一行是\ufeff的,跟的不相等,于是这个词永远过滤不掉,而你在调试时打印出来看是一模一样的。
from pathlib import Path def load_wordlist(path): words = set() with open(path, encoding="utf-8-sig") as f: # utf-8-sig 兼容有无 BOM for ln in f: w = ln.strip() if not w or w.startswith("#"): continue words.add(w) return words STOP_ZH = load_wordlist("stopwords_zh.txt") STOP_PUNCT = load_wordlist("stopwords_punct.txt") STOP = STOP_ZH | STOP_PUNCT print(len(STOP))utf-8-sig这个编码名值得记住,它能同时吃下带 BOM 和不带 BOM 的文件,代价几乎为零。用set而不是list,是因为过滤是逐 token 查表,list的in是 O(n),几万条文本跑下来差距非常明显。#开头的行留作注释位,后面做领域白名单扩展时用得上。
3. 接进分词流水线:jieba、sklearn 与自建 tokenizer 的三处对齐
词表洗干净只是原料,真正决定效果的是它插在流水线的哪个位置。这一章讲三件事:过滤时机、jieba 侧的接法、以及 sklearn 特征侧的一致性对齐。第三件事最容易出错,因为它不报错,只是悄悄让你的模型精度掉几个点。
3.1 过滤时机:分词后过滤,不要分词前删
一个很常见的错误写法是先text.replace(w, "")把停用词从原文里删掉,再送去分词。这个做法在英文里勉强能用(有空格做边界),在中文里是灾难:的确会被的干掉变成确,不要会被不干掉变成要,语义直接反转。停用词表的作用域是 token 级别,不是字符级别。
正确顺序是:归一化 → 分词 → 逐 token 过滤 → 长度/类型兜底过滤。归一化放在分词前,是因为全角半角混排会让同一个词出现两种形态,(和(在词表里是两条,在文本里也是两种,不归一就得在词表里放两份。NFKC 还顺带处理了圈号、上标、连字,对这份表转换出来的文本尤其有效。
import unicodedata import jieba def build_tokenizer(stop): def tokenize(text): # 1) 归一:全角转半角、圈号转数字、连字展开 text = unicodedata.normalize("NFKC", text) # 2) 分词:精确模式,保留原始 token tokens = jieba.lcut(text, cut_all=False, HMM=True) # 3) 过滤:去空白、去停用词、去单字符数字 out = [] for t in tokens: t = t.strip() if not t or t in stop: continue if t.isdigit(): continue out.append(t) return out return tokenize参数说明:cut_all=False是精确模式,也是唯一适合做特征抽取的模式,全模式和搜索引擎模式会产出重叠 token,在 TF-IDF 里等于人为放大子串权重。HMM=True让 jieba 用隐马模型处理未登录词,对含新词的短文本有帮助;如果你的语料是规范化的长文档、且已经配了领域词典,关掉 HMM 反而更稳,因为 HMM 会偶尔把固定搭配切碎。t.isdigit()那一条是踩坑后加的:数字在多数任务里是噪声,但如果你做的是金额、日期、编号抽取,就必须删掉这一行。
build_tokenizer返回闭包而不是把stop做成全局变量,是为了让不同任务能持有不同的停用词集合,同时避免多进程里全局状态被复制的开销。
3.2 jieba 侧:预热、领域词与自定义词典
jieba 首次调用会加载前缀词典,在批处理脚本里这一下能吃掉一两秒,放在服务里就是首请求超时。我的习惯是模块导入时就jieba.initialize(),把加载成本前移到启动阶段。
领域词和停用词是一对反向操作:停用词告诉你“什么该丢”,自定义词典告诉你“什么不能被切碎”。这两件事必须同时做,否则会出现“停用词表里没有、但被切成了两个碎片,两个碎片又恰好各自在停用词表里”的情况,整段语义消失。
import jieba jieba.initialize() jieba.setLogLevel(20) # 关掉 DEBUG 级日志,避免刷屏 # 领域词典:一行 "词 词频 词性",词频给个够大的数保证不被切开 for w in ["知识图谱", "语义角色", "依存句法", "命名实体"]: jieba.add_word(w, freq=100000, tag="n") # 也可以批量从文件加载,格式同上,UTF-8 # jieba.load_userdict("domain_dict.txt")add_word的freq参数决定这个词被当成完整词的概率,给到十万量级基本能压过默认词典里的短词组合。tag只在需要词性时有用,jieba.posseg会读它。注意add_word是全局生效的,多任务共用进程时要小心互相污染,稳妥做法是每个任务用jieba.Tokenizer()实例,别用模块级单例。
顺带说一句性能:单进程 jieba 处理十万级短文本大概在分钟量级,够用。要并行就自己用multiprocessing.Pool包一层,每个 worker 内initialize()一次。不要依赖某些教程里提到的并行开关,那东西在不同平台上的行为不一致,出问题很难定位。
3.3 sklearn 侧:tokenizer 与 vectorizer 必须成对保存
这是全篇最容易翻车的地方。TfidfVectorizer有两种用法:一种是传tokenizer可调用对象,一种是传stop_words列表让它内部切词。两种都行,但不能混着来,更不能训练用一个、推理用另一个。
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression import joblib tok = build_tokenizer(STOP) vec = TfidfVectorizer( tokenizer=tok, lowercase=False, # 中文无大小写,关掉省一次遍历 token_pattern=None, # 传了 tokenizer 就必须置 None,否则默认正则仍然生效 ngram_range=(1, 2), min_df=2, # 丢掉只在单篇出现过的词,等同低频噪声过滤 max_df=0.85, # 丢掉在 85% 以上文档出现的词,与停用词表功能重叠 sublinear_tf=True, ) pipe = Pipeline([("tfidf", vec), ("clf", LogisticRegression(max_iter=1000))]) pipe.fit(train_texts, train_labels) # 整个 pipeline 一起存,不要只存 vectorizer joblib.dump(pipe, "clf_pipeline.pkl")参数逐个说。token_pattern=None是硬性要求,sklearn 在传了tokenizer之后如果还留着默认的token_pattern,行为是未定义的,不同版本表现不同,有的告警有的静默覆盖。min_df=2和max_df=0.85跟停用词表的功能有重叠:max_df干的就是“高频词降权”,和停用词表删高频词是一个目的,两个一起用容易过度删词——这一点在第 4 章展开。sublinear_tf=True把 tf 换成1+log(tf),对长文档里反复出现的词做抑制,和停用词表配合时能减少“删不干净”的残留影响。
lowercase=False是个小优化,中文文本没有大小写概念,开着等于白跑一遍str.lower(),几十万条文本上能省几秒。
最后强调一遍:Pipeline整体序列化。只存vectorizer再在推理时重建一个,只要停用词表文件有任何一行差异,特征维度就对不上,模型要么报维度错误,要么更糟——不报错但把特征错位喂进去,输出一堆看似正常的垃圾。这个坑我在线上环境见过一次,排查花了大半天。
4. 全量加载是陷阱:DF 阈值、领域白名单与否定词保留
拿到一份两千行的停用词表,最直觉的做法是全量加载,一个不落地过滤。这个做法在通用语料上勉强能跑,在真实业务语料上经常把效果做负。原因不复杂:停用词表是通用领域的产物,而你的语料有自己的高频词分布,两者并不重合。
4.1 为什么全量过滤会让准确率掉下去
第一个原因是过度删除。这份表里除了“的了吗呢”这类纯功能词,还混着大量实词性的通用词,比如“工作”“问题”“情况”“方面”。它们在通用语料里确实高频低信息量,但在你的任务里可能正是关键特征。做投诉文本分类时把“问题”删了,做招聘文本匹配时把“工作”删了,模型就失去了最强的判别信号。
第二个原因是破坏搭配。ngram_range=(1, 2)会生成二元组,而停用词过滤发生在分词阶段,过滤后相邻的两个词会被拼成原本不存在的二元组。原文“不满意”切成不 / 满意,删掉不之后剩下满意,二元组层面还会和后面的词组合出错误搭配。这类错误不会报错,只会让模型学到假的共现关系。
第三个原因是否定词。这份表里包含“不、没、无、非、未、别”这类否定词,它们在高频词统计里排名很靠前,所以被收进了表。但对情感分析、意图识别、评论打标这类任务,否定词是决定极性翻转的关键,删掉等于把“不满意”和“满意”变成同一个输入。
我的做法是分三层:核心停用词(必删)、可配置停用词(按任务开关)、领域白名单(强制保留,优先级最高)。
| 层级 | 典型内容 | 处理方式 |
|---|---|---|
| 核心停用词 | 的、了、吗、呢、啊、全角标点 | 无条件删除 |
| 可配置停用词 | 我们、可以、进行、方面、情况 | 按任务 A/B 决定 |
| 领域白名单 | 不、没、无、判、症、款、订单 | 强制保留,覆盖前两层 |
4.2 用文档频率反推:哪些词该删,让数据说话
与其拍脑袋决定,不如算一遍 DF(文档频率)。DF 的定义是“包含该词的文档数 / 总文档数”,它跟停用词表的判定逻辑本质一致——高频低信息。区别在于,DF 是在你的语料上算的,停用词表是在别人的语料上攒的。
from collections import Counter def df_stats(texts, raw_tokenize): """texts: 文本列表;raw_tokenize: 不做停用词过滤的分词函数""" df = Counter() for t in texts: # set 去重:同一篇里出现多次只算一次文档频率 df.update(set(raw_tokenize(t))) n = len(texts) return {w: c / n for w, c in df.items()}, n def raw_tokenize(text): import unicodedata, jieba text = unicodedata.normalize("NFKC", text) return [t.strip() for t in jieba.lcut(text) if t.strip()] df, n = df_stats(train_texts, raw_tokenize) # 按 DF 降序看前 40 个,人工扫一遍就知道该删哪些 for w, r in sorted(df.items(), key=lambda x: -x[1])[:40]: flag = "IN_STOP" if w in STOP else "NOT_IN_STOP" print(f"{w}\t{r:.3f}\t{flag}") # 反向检查:停用词表里有、但语料里 DF 很低的词,多半是无效条目 low = [(w, df.get(w, 0.0)) for w in STOP if df.get(w, 0.0) < 0.001] print("低效停用词条数:", len(low))这段代码的输出有两类用法。正向看:DF 在 0.9 以上但不在停用词表里的词,是这份表漏掉的,可以补进去。反向看:停用词表里有、但在你语料里 DF 接近 0 的词,说明这个任务根本用不上它,加了只是增加查表开销、增大误删风险。我一般会在启动时打印这个“低效词条数”,如果比例超过一半,说明这份通用表和你的语料严重不匹配,得重新裁剪。
阈值怎么定:短文本任务(句子级、评论级)DF 超过 0.7 基本可以删;长文档任务(论文、合同)阈值可以放到 0.9,因为长文档里“我们”“可以”这类词的高频是合理的,删了会影响篇章级特征。这个 0.7 / 0.9 不是定论,是起点,最终还得看下游指标。
4.3 白名单的实现与优先级
白名单必须在代码层面保证优先级最高,不能靠“记得别把它加进停用词表”这种纪律来维持——纪律会失守,代码不会。
def load_stopwords_with_whitelist(stop_path, white_path=None): stop = set() with open(stop_path, encoding="utf-8-sig") as f: for ln in f: w = ln.strip() if w and not w.startswith("#"): stop.add(w) if white_path: with open(white_path, encoding="utf-8-sig") as f: for ln in f: w = ln.strip() if w and not w.startswith("#"): stop.discard(w) # 白名单直接从停用集合里摘掉 return stop # 否定词 + 领域词,一条一行,随任务维护 open("whitelist.txt", "w", encoding="utf-8").write( "\n".join(["不", "没", "无", "非", "未", "别", "问题", "工作", "订单", "退款"]) ) STOP = load_stopwords_with_whitelist("stopwords_zh.txt", "whitelist.txt")用discard而不是remove,避免白名单里写了停用词表中不存在的词时抛KeyError。白名单文件单独维护、随任务版本走,比在代码里硬编码列表好审。我在实际项目里会把白名单按业务线拆成多个文件(whitelist_aftersale.txt、whitelist_recruit.txt),加载时按任务合并,这样一个停用词表能服务多条业务线,不用为每条线复制一份。
还有一条经验:白名单要定期反向审查。业务变了,原来必须保留的词可能变成噪声,白名单只增不减的话,几年后会变成一张比停用词表还大的补丁表,谁也说不清每条为什么在里面。加注释行记录加入原因和日期,成本很低。
5. 五个翻车现场:从编码乱码到否定词被误删
下面这五条都是我在真实项目里遇到并记录下来的,按“现象 → 原因 → 解决”写,能省掉你重复踩的时间。
5.1 转出来的文本全是问号
现象:LibreOffice 转换后打开stopwords_raw.txt,满屏????,或者 Python 读出来是一堆\ufffd。
原因:转换时没指定输出编码,工具按系统 locale 落盘。Windows 中文环境下常见是 GBK,而读取端用 UTF-8 解码,双向不匹配。
解决:转换命令里显式写txt:Text (encoded):UTF8;读取端用errors="replace"兜底,并在清洗前打印前 20 行repr()人工确认。别用errors="ignore",它会把脏字节静默吞掉,你根本不知道少了什么。
5.2 停用词表明明有“的”,就是过滤不掉
现象:"的" in STOP返回False,但打开文件肉眼可见第一行就是“的”。
原因:三种可能。文件带 UTF-8 BOM,第一行实际是\ufeff的;换行符是\r\n,splitlines()处理后残留\r;某个词条里混了全角空格\u3000。
解决:读文件统一用encoding="utf-8-sig",每行strip()之后再用unicodedata.normalize("NFKC", w)过一遍,能同时干掉全角空格和不可见字符。写个断言assert "的" in STOP放在模块加载处,早失败早发现。
5.3 训练集和推理集的停用词表版本不一致
现象:离线评估 F1 有 0.87,上线后接口返回的分类结果明显不对,但也不报错。
原因:训练时用的是裁剪过的停用词表,推理服务加载的是仓库里另一份旧文件。词表不同 → 分词结果不同 → 特征维度错位,模型照常输出,只是全是错的。
解决:把停用词表内容做哈希,跟模型文件一起存进joblib的元数据里,加载时比对哈希,不一致直接拒绝启动。更彻底的做法是把tokenizer和vectorizer一起序列化进Pipeline,推理端不重新构造任何东西。
5.4 空字符串混进词表,过滤逻辑整体失效
现象:if not t or t in stop这条判断在某些样本上把所有 token 都放过了。
原因:清洗时把空行也add进了集合(比如if w not in seen忘了先判空),集合里有了""。如果过滤逻辑写成if t in stop: continue而t恰好是空串,行为就取决于判断顺序,很容易写反。
解决:加载函数里if not w: continue放在最前面,且seen只在写文件时用,不要跟stop集合混用。加一条单元测试:assert "" not in STOP and " " not in STOP。
5.5 情感分析里“不满意”被判成正面
现象:模型对“这个功能不满意”输出正面概率 0.8。
原因:停用词表里的“不”被无条件删除了,不满意和满意分词后剩同一个 token。这份通用表是为分词和信息检索场景攒的,它默认否定词低信息量,这个假设在情感任务上不成立。
解决:把否定词加进白名单强制保留,同时把二元组范围设成(1, 2),让不 满意这个组合进入特征空间。更稳的做法是做一次否定词作用域处理,把“不”和它后面一到两个词绑成一个复合 token,但这会增加特征稀疏性,得用指标验证值不值。
6. 用留存率和 F1 做对照:把“要不要删”变成可回滚的实验
停用词表用不用、删多少,本质是个超参问题,靠感觉调没有说服力。我的固定做法是搭一个最小对照实验,三组配置跑同一套交叉验证,看三个数:token 留存率、特征维度、下游指标。留存率告诉你删得狠不狠,维度告诉你稀疏性,指标告诉你值不值。
import numpy as np import unicodedata import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score from sklearn.pipeline import Pipeline def make_tokenizer(stop, keep_digits=False): def tok(text): text = unicodedata.normalize("NFKC", text) out = [] for t in jieba.lcut(text): t = t.strip() if not t or t in stop: continue if not keep_digits and t.isdigit(): continue out.append(t) return out return tok def build_pipe(stop): return Pipeline([ ("tfidf", TfidfVectorizer( tokenizer=make_tokenizer(stop), lowercase=False, token_pattern=None, ngram_range=(1, 2), min_df=2, sublinear_tf=True, )), ("clf", LogisticRegression(max_iter=1000)), ]) # 三组配置:空表 / 全量 / 全量 + 白名单 CONFIGS = { "no_stop": set(), "full_stop": load_wordlist("stopwords_zh.txt") | load_wordlist("stopwords_punct.txt"), "stop_whitelist": load_stopwords_with_whitelist("stopwords_zh.txt", "whitelist.txt"), } for name, stop in CONFIGS.items(): pipe = build_pipe(stop) scores = cross_val_score(pipe, train_texts, train_labels, cv=5, scoring="f1_macro") pipe.fit(train_texts, train_labels) vocab_size = len(pipe.named_steps["tfidf"].vocabulary_) keep_ratio = np.mean([ len(make_tokenizer(stop)(t)) / max(1, len(jieba.lcut(unicodedata.normalize("NFKC", t)))) for t in train_texts[:500] ]) print(f"{name}\tf1={scores.mean():.4f}±{scores.std():.4f}\tvocab={vocab_size}\tkeep={keep_ratio:.2%}")逻辑说明:keep_ratio是留存率,分子是过滤后的 token 数,分母是过滤前的 token 数,抽前 500 条估个均值就够看趋势。vocab_size是特征维度,能直观看出停用词表压了多少稀疏性。f1_macro在类别不均衡时比 accuracy 更可靠,如果你的数据严重不均衡,换成f1_weighted或者按业务看召回。
判读方式:如果full_stop的 F1 比no_stop低,说明这份通用表和你的语料不匹配,直接退回裁剪方案。如果stop_whitelist明显高于另外两组,说明白名单起作用了,接下来该做的是继续扩白名单而不是调模型。如果三组差距在标准差以内,那就选 token 数最少的那组,省算力。
我一般还会在实验里加一个“按 DF 动态裁剪”的第四组,用第 4 章的df_stats算出 DF 在 0.9 以上的词作为停用集合,跟静态表对比。多数情况下动态裁剪能比静态表高出一点点,但差距往往在噪声范围内,这时候我更倾向用静态表——它可复现、可版本化、不依赖训练数据,工程上省心。
一个具体技巧:把上面这段跑成一个 CSV 报告,每次改动词表或白名单都重跑一遍,文件名带上词表的哈希前八位。三个月后回头看这条曲线,能清楚知道哪次改词表把效果改坏了。这也是我后来养成的一个习惯——任何跟停用词相关的改动,先跑对照实验,跑完把留存率、维度、F1 三个数记进版本说明,不凭记忆下结论。有一次临时往白名单里加了十几个词救一个线上 case,两周后有人问起为什么这里效果掉了,翻记录五分钟定位到是那次改动的连带影响,删掉三个词就恢复了。没有那份记录,这事得排查一整天。
希望这套流程能帮到你。
本文还有配套的精品资源,点击获取