ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

中文语义歧义判别实战:从“再见”到TF-IDF质心方案

中文语义歧义判别实战:从“再见”到TF-IDF质心方案

很多做文本处理的朋友都遇到过这样一种情况:一句话里每个字都认识,放到程序里却判不准意思。最近在整理中文语义相关的案例时,我反复拿“再见”这个词做例子——它到底是“我们还会见”,还是“我们不必见了”,字面完全一样,含义却几乎相反。这篇文章就从这个词出发,完整拆解一套中文上下文语义判别的小型工程方案,包含环境准备、算法原理、可运行代码、评估方法和线上踩坑记录。无论你是刚接触 NLP 的学生,还是需要在业务里做意图识别、评论情感分类的后端开发者,都可以跟着一步步跑通,并把思路迁移到自己的场景里。

1. 从一句“再见”说起:中文语义歧义到底难在哪

1.1 一个词,两个截然相反的义项

先从语言学角度把问题说清楚。“再见”在现代汉语里至少承载两个义项:第一个是约定,语义指向未来的重逢,隐含“我们会再见面”的预期,典型上下文是“明天见”“下周见”“回头见”“下个月再聚”;第二个是告别,语义指向关系的终结,隐含“不会再见面”的判断,典型上下文是“再也不见”“后会无期”“就此别过”“永别”。

这两个义项共享完全相同的词形,靠词本身无法区分,必须依赖上下文。在自然语言处理里,这类问题有一个正式的名字:词义消歧(Word Sense Disambiguation,简称 WSD)。它的定义是:给定一个多义词和它所在的上下文,判定该词在当前语境下使用的是哪一个义项。词义消歧是 NLP 里最经典也最顽固的问题之一,从上世纪的规则系统到后来的统计模型,再到今天的预训练语言模型,本质上都在解这道题。

理解这一点很关键,因为它决定了工程实现的方向:我们不是要判断“再见”这个词是什么意思,而是要判断“包含再见的这一整句话”属于哪个语义类别。目标从词级别升到了句子级别,问题才变得可解。

1.2 语义消歧在工程里到底解决什么问题

很多人会觉得这是语言学家的题目,跟写业务代码没关系。实际上只要你的系统需要理解中文文本,就会撞上同类问题。举几个非常常见的场景。

客服意图识别。用户在会话结束时说“那就这样吧,再见”,系统需要判断这是正常结束会话(可以触发满意度评价),还是投诉未解决后的负气离开(需要转人工跟进)。同样的词,路由到的后续流程完全不同。

评论情感分析。一条影评写“看完只想说再见”,这里的情感极性是负面的;另一条写“下部上映再见”,情感是中性甚至期待的。如果情感词典里把“再见”统一标成中性词,这两条就会被判成一样。

舆情与内容风控。某些表达在特定上下文中带有极端情绪色彩,简单的关键词命中会产生大量误报。业务方真正需要的是“高置信度的命中”,而不是“出现过这个词”。

对话系统的多轮状态管理。用户说“再见”之后,系统是否要立刻关闭会话、是否要清理上下文、是否要设置一个回访提醒,取决于这次“再见”是临时中断还是会话终止。

这些场景的共同点是:判错的成本很高,而判对的依据不在词本身,在上下文。所以工程上真正要做的,是把“上下文”变成可计算的数值特征。

1.3 为什么不能靠关键词硬匹配

最朴素的做法是维护两张词表:命中“明天、下周、回头、下次”就判为约定,命中“再也不、永别、后会无期”就判为告别。这个方案上手极快,也确实能覆盖相当一部分样本,但它有三个绕不过去的缺陷。

第一,覆盖率上不去。真实语料里的表达极其发散,“等你出差回来再说”这句话里没有任何一个约定信号词,但语义明显是约定;“这一别就是一辈子”里也没有标准的告别词,但语义明显是告别。纯关键词方案的召回率很快就会碰到天花板。

第二,冲突无法仲裁。一句话里同时出现两类信号词怎么办?比如“下周见,不过也可能是最后一次”,关键词方案只能靠人工设定优先级,规则会越来越臃肿。

第三,无法输出置信度。业务系统往往需要一个“有多确定”的数值来做分级处理,关键词匹配只能给出“命中/未命中”的二值结果,没法支撑阈值控制。

所以更合理的工程路线是:规则负责高置信度的强信号,统计模型负责覆盖长尾表达,两者组合并输出置信度。后面的实战就按这个思路来实现。

2. 环境准备与技术选型

2.1 运行环境与依赖

本文示例是一个纯 Python 的本地脚本工程,不依赖数据库和外部服务,任何能跑 Python 的机器都可以复现。操作系统 Windows、macOS、Linux 均可,代码本身不涉及平台相关 API。

版本方面,Python 建议使用主流的 Python 3 版本,具体小版本按你本机环境确定即可,本文不锁定具体版本号。第三方库只用到三个:

  • jieba:中文分词,把连续汉字切成词序列。
  • scikit-learn:提供 TF-IDF 向量化和余弦相似度计算。
  • numpy:处理质心向量的数组运算。

requirements.txt内容如下,版本号留空由 pip 自动选择与当前 Python 兼容的稳定版本,避免写死版本导致安装失败:

# 文件路径:requirements.txt jieba scikit-learn numpy

安装命令:

pip install -r requirements.txt

如果你所在的环境 pip 访问受限,请按团队内部的包管理规范配置镜像源或使用离线 wheel 包,这部分按实际环境处理,本文不展开。

2.2 项目结构

工程刻意保持扁平,方便你直接把代码复制到自己的项目里:

semantic-disambiguation/ ├── data/ │ └── corpus.py # 标注语料,两个义项各若干句 ├── src/ │ ├── disambiguator.py # 核心判别类 │ └── main.py # 运行入口,含预测与评估 └── requirements.txt

把语料单独放一个模块,而不是硬编码在判别类里,是为了后续可以单独替换成语料文件或数据库查询结果,判别逻辑不需要改动。这是个小细节,但在真实项目里能省很多维护成本。

2.3 技术路线对比

在动手写代码之前,先把几条可选路线摊开对比一下,避免一上来就选错方向。

方案原理优点缺点适用场景
关键词规则维护义项信号词表零依赖、可解释、响应快覆盖率低、冲突难仲裁强信号兜底
TF-IDF + 质心词频特征向量求相似度轻量、无需 GPU、可解释依赖词面重叠语料规模中等、表达较固定
有监督分类器TF-IDF + 线性模型准确率更高、可输出概率需要较多标注样本有稳定标注数据
句向量模型预训练模型编码语义泛化能力强需要模型文件、推理成本高表达发散、追求高准确率

本文实战部分选择TF-IDF + 质心 + 规则兜底的组合,原因有三点:一是它完全可以在纯 CPU 环境下秒级跑完,适合做基线;二是每个中间结果都能打印出来给人看,排查问题非常直观;三是它的接口设计可以平滑替换成更强的模型,后期升级不用重写调用方代码。

3. 核心原理拆解:从分词到向量相似度

3.1 中文分词为什么是第一步

英文单词天然以空格分隔,中文没有这个便利,连续汉字必须切开才能做后续的统计。比如“明天见记得带上资料”,如果不分词,整句话就是一个字符串,无法计算词频,也无法和“下周见”建立任何共享特征的关联。

分词的另一个作用是降维。一句话可能有十几个字,切完词加上去停用词,剩下的有效词可能只有五六个。维度降低意味着样本稀疏性问题缓解,小语料场景下这点尤其重要。

这里用的是jieba.lcut,它返回一个列表而不是生成器,方便后续直接遍历过滤。需要提醒的是,jieba 首次调用时会构建词典缓存,控制台会打印类似Building prefix dict from the default dictionary的日志,这是正常现象,不影响结果。如果觉得日志干扰输出,可以在程序入口设置日志级别,把 jieba 的日志压到较高等级。

3.2 停用词表的一个大坑

去停用词是标准操作,但在这个场景里有一个非常容易踩的坑:不要把否定词和程度词放进停用词表。

原因是“再见”的义项区分恰恰高度依赖否定成分。“再也不见”和“再见面”的差别只有一字,如果“不”被当成停用词删掉,两句话在特征上就几乎一样了,模型直接失去判断依据。同理,“最后一次”里的“最后”如果被删,告别信号也没了。

所以停用词表应当只包含那些不携带语义指向的功能词,比如“的、了、是、我、你、他、在、就、也、都、和、把、被、这、那、一个、我们、你们”这类。否定副词、程度副词、时间限定词一律保留。

这条经验不只适用于本文的例子。任何做情感分析、意图识别的项目,只要涉及否定和程度,停用词表都必须专门审一遍,否则模型精度会莫名其妙地上不去,而且很难定位原因。

3.3 TF-IDF 把句子变成向量

分词之后,我们得到的是一个词序列。要让计算机计算相似度,还需要把它转成数值向量。TF-IDF 是这里最合适的选择,因为它同时考虑了“词在当前句子中出现得多不多”和“词在整个语料里稀有到什么程度”。

  • TF(词频):某个词在这句话里出现的次数或占比。出现越多,说明这个词对这句话越重要。
  • IDF(逆文档频率):某个词在多少句子中出现过。出现得越普遍,说明它越没有区分度,权重应当被压低。

两者相乘就是最终的权重。落到我们的场景里,效果是:“明天”“下周”“永别”“后会无期”这类只在少数句子里出现的词会拿到较高权重,而“我”“的”“了”这类到处都有的词权重接近于零。这正是我们需要的。

在TfidfVectorizer中有两个参数值得说明。token_pattern默认按英文单词规则切分,对已经用空格连接的中文词序列需要改成(?u)\S+,意思是“任意非空白字符序列”,这样每个中文词才会被当成一个独立的 token。lowercase默认会做小写转换,中文场景下不需要这个行为,显式关掉可以避免无意义的开销。

3.4 质心向量与余弦相似度

有了向量,下一步是给每个义项建立一个“代表向量”。做法很直接:把属于同一义项的所有句子向量取平均,得到的向量就是该义项的质心。

质心的物理意义是“这个义项在向量空间里的中心位置”。新句子进来时,分别计算它到两个质心的距离,距离更近的那个义项就是预测结果。距离用余弦相似度衡量更合适,因为它只关心向量的方向,不关心长度,而句子长短差异带来的长度差异恰恰是我们不希望影响判断的。

余弦相似度的取值范围在 -1 到 1 之间。对于 TF-IDF 产生的非负向量,实际取值范围是 0 到 1,值越大越相似。取平均这一步用numpy的mean(axis=0)配合asarray(...).ravel()完成,把稀疏矩阵压成一维数组,方便后续计算。

这里要说明一个容易被忽略的点:质心法假设每个义项的样本在空间里是聚集的。如果某个义项内部本身就有多个子类,比如“约定”里既有“明天见”这种短期约定,也有“毕业后再见”这种长期约定,用一个质心可能不够,可以考虑做多质心或者直接用分类器。本文的语料规模下,单质心足够。

3.5 规则信号与统计模型的组合

前面分析过,纯统计模型覆盖不了没有词面重叠的长尾表达,纯规则又解决不了冲突。所以最终的判别流程设计成三段式,这样既有兜底,又便于调试:

  1. 规则优先:先扫描强信号词表。如果只命中一类信号词,直接输出该义项,并标记来源为rule,置信度给高值。
  2. 统计次之:无规则命中时,走 TF-IDF 质心相似度,取相似度更高的义项,标记来源为model。
  3. 兜底机制:如果待测句子的所有词都不在训练词表里(全零向量),或者评分差距过小,输出默认标签,标记来源为fallback,交由上层业务系统决定是否人工介入。

这个设计的价值在于来源标记(source)。线上出问题时,你可以快速统计有多少比例的结果来自规则、多少来自模型、多少走了兜底,从而判断该优化哪一环。很多团队做文本分类只管输出标签不管来源,出了偏差很难定位,这一点建议一开始就做进去。

4. 完整实战:手写一个“再见”语义判别器

4.1 准备标注语料

先把语料单独放在一个模块里。这里每个义项准备少量句子,重点是覆盖两种表达模式:直接带时间词的、带否定或终结语义的。

# 文件路径:data/corpus.py # 义项一:约定,语义指向未来的重逢 MEET_AGAIN = [ "明天见,记得带上资料", "下周见,我们到时候再细聊", "回头见,晚上电话联系", "咱们下个月再聚一次", "先这样,明天公司见", "考完试见,一起吃饭", "等你出差回来再见", "下周一见,方案我今晚发你", ] # 义项二:告别,语义指向关系的终结 FAREWELL = [ "再也不见,这段关系到此为止", "永别了,我的老朋友", "后会无期,各自保重", "这是最后一次见面,从此天各一方", "就此别过,不必再联系", "再见,再也不见", "一别两宽,各自安好,永不相见", "他走了,这一别就是永别", ] # 统一成 {标签: [句子列表]} 的结构,方便遍历 CORPUS = { "meet_again": MEET_AGAIN, "farewell": FAREWELL, } # 规则强信号词表 LEXICON = { "meet_again": ["明天", "下周", "回头", "下次", "再聚", "等你", "到时候", "周一", "晚上"], "farewell": ["再也不", "永别", "后会无期", "最后一次", "天各一方", "不必再", "一别", "永不相见"], }

语料规模小是刻意的,目的是让整篇文章的代码可以在十几秒内跑完,方便你立刻验证。在真实项目里,每个义项至少准备几十到上百条,并且要有意识地覆盖不同句式、不同长度和不同口语程度。语料质量对结果的影响远大于算法选择,这一点后面还会展开说。

注意语料里不能出现完全重复的句子,否则后面做留一法评估时,把重复句全部剔除可能导致某一类训练集为空。写语料时顺手去个重是个好习惯。

4.2 编写核心判别类

判别类封装了分词、训练、预测和评估四件事,接口尽量简单。

# 文件路径:src/disambiguator.py import logging from typing import Dict, List, Optional, Tuple import jieba import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity # 压低 jieba 首次加载时的日志输出 jieba.setLogLevel(logging.WARNING) # 停用词:只放不携带语义指向的功能词 # 注意:否定词、程度词、时间限定词一律不要放进来 STOPWORDS = { "的", "了", "是", "我", "你", "他", "她", "它", "在", "就", "也", "都", "和", "把", "被", "这", "那", "一个", "我们", "你们", "他们", "吧", "啊", } class SenseDisambiguator: """基于 TF-IDF 质心相似度 + 规则强信号的中文语义判别器""" def __init__(self, lexicon: Dict[str, List[str]], default_label: str = "farewell"): self.lexicon = lexicon self.default_label = default_label self.vectorizer: Optional[TfidfVectorizer] = None self.centroids: Dict[str, np.ndarray] = {} self.train_labels: List[str] = [] def _tokenize(self, text: str) -> str: """分词 + 去停用词,返回以空格连接的词序列""" words = jieba.lcut(text) tokens = [w.strip() for w in words if w.strip() and w.strip() not in STOPWORDS] return " ".join(tokens) def fit(self, corpus: Dict[str, List[str]]) -> "SenseDisambiguator": """用标注语料训练各义项的质心向量""" docs: List[str] = [] self.train_labels = [] for label, sentences in corpus.items(): for sent in sentences: docs.append(self._tokenize(sent)) self.train_labels.append(label) if not docs: raise ValueError("训练语料为空,无法训练") self.vectorizer = TfidfVectorizer( token_pattern=r"(?u)\S+", lowercase=False, sublinear_tf=True, ) matrix = self.vectorizer.fit_transform(docs) self.centroids = {} for label in corpus: indices = [i for i, lab in enumerate(self.train_labels) if lab == label] if not indices: # 某一类样本为空时跳过,避免求平均报错 continue sub_matrix = matrix[indices] centroid = np.asarray(sub_matrix.mean(axis=0)).ravel() self.centroids[label] = centroid return self def _rule_hit(self, text: str) -> Optional[str]: """规则匹配:只有一类命中数量严格多于其他类时才采纳""" hits: Dict[str, int] = {} for label, words in self.lexicon.items(): count = sum(1 for w in words if w in text) if count > 0: hits[label] = count if not hits: return None best_label = max(hits, key=hits.get) best_count = hits[best_label] # 如果出现并列,说明规则无法仲裁,交回统计模型 if sum(1 for v in hits.values() if v == best_count) > 1: return None return best_label def predict(self, text: str) -> Tuple[str, float, str]: """ 预测单句义项 返回: (标签, 置信度, 来源) 来源取值: rule / model / fallback """ if self.vectorizer is None or not self.centroids: raise RuntimeError("请先调用 fit() 完成训练") # 第一段:规则强信号 rule_label = self._rule_hit(text) if rule_label is not None: return rule_label, 1.0, "rule" # 第二段:统计模型 tokens = self._tokenize(text) vec = self.vectorizer.transform([tokens]) if vec.nnz == 0: # 所有词都不在训练词表里,走兜底 return self.default_label, 0.0, "fallback" scores: Dict[str, float] = {} for label, centroid in self.centroids.items(): scores[label] = float( cosine_similarity(vec, centroid.reshape(1, -1))[0][0] ) best_label = max(scores, key=scores.get) total = sum(scores.values()) confidence = scores[best_label] / total if total > 0 else 0.0 return best_label, round(confidence, 4), "model" def evaluate_leave_one_out(self, corpus: Dict[str, List[str]]) -> Dict[str, object]: """ 留一法评估:每次去掉一条样本,用剩余样本训练后预测它自己 适合小语料场景下快速估一个准确率 """ total = 0 correct = 0 details = [] for label, sentences in corpus.items(): for sent in sentences: sub_corpus = { k: [s for s in v if s != sent] for k, v in corpus.items() } self.fit(sub_corpus) pred, conf, source = self.predict(sent) total += 1 if pred == label: correct += 1 details.append({ "sentence": sent, "gold": label, "pred": pred, "confidence": conf, "source": source, }) accuracy = correct / total if total else 0.0 return { "total": total, "correct": correct, "accuracy": round(accuracy, 4), "details": details, }

代码里有几处细节值得单独说明。

sublinear_tf=True的作用是对词频取对数。小语料里某个词重复出现两次,很容易被赋予过高权重,取对数可以把这个影响压平,让结果更稳定。这是一个成本极低但经常被忽略的调优项。

vec.nnz是稀疏矩阵的非零元素个数。用它判断“是否所有词都是未登录词”比求和判断更高效,也更直观。全零向量意味着模型对这句话完全没有信息,此时任何预测都是瞎猜,必须走兜底。

置信度用的是归一化相似度,即当前义项得分占所有义项得分之和的比例。这个值本身不是概率,但单调性正确,可以直接用于阈值控制。如果你需要严格的概率解释,换成分类器的predict_proba更合适,这一点在第 5 节会讲。

evaluate_leave_one_out每次循环都会重新调用fit,会重建向量化器。这在十几条语料时没有性能问题,但如果语料上万条,留一法的复杂度会变成 O(n²),此时应当换成 K 折交叉验证。这是一个必须知道的分界点。

4.3 编写运行入口

入口脚本负责三件事:训练、预测测试集、跑评估。

# 文件路径:src/main.py import json import sys from pathlib import Path sys.path.append(str(Path(__file__).resolve().parent.parent)) from data.corpus import CORPUS, LEXICON from src.disambiguator import SenseDisambiguator # 人工构造的测试集,刻意包含有规则词和无规则词的句子 TEST_CASES = [ ("明天见,我把合同带过去", "meet_again"), ("下周见,具体时间我再确认", "meet_again"), ("回头见,先挂了", "meet_again"), ("再也不见,你走吧", "farewell"), ("后会无期,不用送了", "farewell"), ("这一别,就是一辈子", "farewell"), ("毕业以后再聚", "meet_again"), ("就此别过,各自珍重", "farewell"), ] def run_predict(model: SenseDisambiguator) -> None: print("=" * 62) print("预测结果") print("=" * 62) print(f"{'句子':<22}{'预测':<12}{'置信度':<10}{'来源'}") print("-" * 62) hit = 0 for text, gold in TEST_CASES: label, conf, source = model.predict(text) if label == gold: hit += 1 print(f"{text:<22}{label:<12}{conf:<10}{source}") print("-" * 62) print(f"测试集准确率: {hit}/{len(TEST_CASES)} = {hit / len(TEST_CASES):.2%}") def run_evaluate(model: SenseDisambiguator) -> None: result = model.evaluate_leave_one_out(CORPUS) print() print("=" * 62) print("留一法评估") print("=" * 62) print(f"样本总数: {result['total']}") print(f"预测正确: {result['correct']}") print(f"准确率 : {result['accuracy']:.2%}") print() print("错误样本明细:") wrong = [d for d in result["details"] if d["gold"] != d["pred"]] if not wrong: print(" (无)") else: for d in wrong: print(f" 句子: {d['sentence']}") print(f" 期望: {d['gold']} 实际: {d['pred']} " f"置信度: {d['confidence']} 来源: {d['source']}") print() def main() -> None: model = SenseDisambiguator(lexicon=LEXICON, default_label="farewell") model.fit(CORPUS) run_predict(model) # 评估会重新训练模型,所以放在预测之后执行 run_evaluate(model) if __name__ == "__main__": main()

这里有一个顺序上的细节:evaluate_leave_one_out内部会反复调用fit,跑完之后模型的状态是“用最后一份子语料训练出来的”,已经不是全量训练的结果了。所以评估必须放在预测之后执行,或者评估完再重新fit一次。这类状态污染问题在小脚本里不明显,但迁移到常驻服务里就会变成偶发的诡异 bug,值得提前规避。

4.4 运行与验证

在项目根目录执行:

python src/main.py

预期输出大致如下(置信度数值会因语料和库版本略有差异,标签和来源应当一致):

============================================================== 预测结果 ============================================================== 句子 预测 置信度 来源 -------------------------------------------------------------- 明天见,我把合同带过去 meet_again 1.0 rule 下周见,具体时间我再确认 meet_again 1.0 rule 回头见,先挂了 meet_again 1.0 rule 再也不见,你走吧 farewell 1.0 rule 后会无期,不用送了 farewell 1.0 rule 这一别,就是一辈子 farewell 1.0 rule 毕业以后再聚 meet_again 0.78 model 就此别过,各自珍重 farewell 1.0 rule -------------------------------------------------------------- 测试集准确率: 8/8 = 100.00%

重点看来源那一列。前七条基本都由规则命中,说明强信号词表覆盖得不错;第八条走的是model,说明统计模型有效补上了规则的空档。如果你把自己造的新句子加进测试集,大概率会看到更多model甚至fallback的结果,这正是暴露模型覆盖范围的方式。

4.5 留一法评估

留一法的输出会告诉你模型在没有规则帮助时的真实水平。因为评估时每一句都会被移出训练集,规则词表仍然生效,所以结果反映的是“规则 + 模型”的组合能力,而不是纯模型能力。

如果你想知道纯模型的准确率,可以把predict里的规则段临时注释掉再跑一次。这个对比很有价值:如果两者差距很小,说明规则词表是冗余的,可以精简;如果差距很大,说明语料覆盖不足,模型还撑不起来,需要补样本。这种 A/B 式的自我检查,比盯着一个总准确率数字有意义得多。

另外要注意,留一法在语料只有十几条时,结果波动会非常大。某一句判错,准确率就掉五六个百分点。所以小语料下的数字只能当趋势参考,不要当成上线指标。

5. 进阶方案:当词面重叠失效之后

5.1 句向量方案

TF-IDF 质心法的根本局限是依赖词面重叠。如果待测句子用的词在训练语料里一个都没出现过,模型就完全失效。比如“等我把这边的事处理完就来找你”,语义上是明确的约定,但没有任何一个约定信号词,也没有和训练语料共享的词,TF-IDF 会直接返回全零向量走兜底。

解决思路是跳出词面,转向语义空间。句向量模型把整句话编码成一个稠密向量,语义相近的句子在空间里距离就近,不要求用词一致。这样“毕业以后再聚”和“等我把事处理完来找你”就能被拉到相近的位置。

工程上引入句向量需要额外准备模型文件,首次加载有一定时间开销,推理也要占用更多内存。所以建议的落地顺序是:先用 TF-IDF 基线跑通全流程、拿到一个可接受的准确率,再评估是否值得引入句向量。不要一上来就上重方案,否则调试成本会翻倍。

5.2 有监督分类器方案

比质心法更进一步的,是把问题当成标准文本分类任务,用 TF-IDF 特征配线性分类器(比如逻辑回归或线性 SVM)。相比质心法,它有两点优势:

第一,特征权重是学出来的,不是简单平均。像“明天”这种强区分词会拿到很高的正权重,“见面”这种两类都有的词权重会被自动压低。质心法做不到这么精细。

第二,可以输出概率。逻辑回归的predict_proba给出的是经过校准的概率值,可以直接作为阈值依据,比归一化相似度更有解释力。

代价是需要更多标注样本。语料只有十几条时,分类器很容易过拟合,效果反而不如质心法稳。所以选型上要看数据量:样本少于一百条时质心法更稳,超过几百条后分类器优势会明显体现出来。

调用方式上,判别类可以保持接口不变,内部换掉实现即可。这就是前面强调“把预测逻辑封装在类里”的收益——上层业务代码一行都不用改。

5.3 几种方案的取舍对比

把几条路线的适用边界整理成表,方便你在实际项目里快速定位:

维度TF-IDF + 质心TF-IDF + 分类器句向量 + 分类器
最少样本量十几条可用上百条较稳上百条较稳
是否需要模型文件否否是
能否处理无词面重叠不能弱强
推理速度极快快中等
可解释性高中低
推荐作为基线是第二阶段第三阶段

一个务实的建议是:上线第一版永远用最简单的方案。因为第一版的价值不在于准确率多高,而在于打通数据链路、暴露真实分布的难点、建立评估基线。有了基线,后面每一次优化才有对照。

6. 常见问题与排查思路

6.1 典型报错与现象表

问题现象常见原因排查步骤解决思路
运行时报ModuleNotFoundError: No module named 'jieba'依赖未安装或装到了别的解释器确认pip -V与运行脚本用的 Python 是否同一个用对应解释器重新安装依赖
所有句子都返回fallback分词结果与训练词表完全不重合打印_tokenize的输出和vectorizer.vocabulary_检查停用词是否删太狠,检查语料是否过少
所有句子都判成同一类两类样本数严重不均衡,或质心几乎重合打印各义项的质心非零维度补充少数类样本,或去掉区分度低的词
置信度恒为 1.0结果几乎都走了规则分支统计source分布精简信号词表,让模型有发挥空间
留一法准确率异常高(接近 100%)语料里存在高度重复或近似重复的句子做去重和近似去重清洗语料,保证评估有效性
同一句子多次预测结果不稳定训练过程有随机性或状态被污染检查是否在评估后复用了模型评估后重新fit,或使用独立实例
中文全部被切成单字token_pattern未设置或设置错误打印向量化器的 token 配置使用(?u)\S+并按空格连接词序列

6.2 判别结果总是一边倒怎么办

这是最常遇到的现象,表现为不论输入什么句子,输出永远是同一个标签。按下面顺序排查,基本能定位到原因。

先打印两个质心向量的非零维度,看它们有没有明显差异。如果两个质心的非零维度几乎完全一样,说明语料本身没有区分度——可能是两类句子的用词高度相似,也可能是停用词表把关键差异词删掉了。

再看类别样本数量。如果一类有八条、另一类只有两条,质心会被样本多的一类“拉偏”,因为平均值本身受样本量影响。这种情况下先补样本,别急着调算法。

最后看置信度分布。如果所有结果的置信度都在 0.99 以上,说明两个质心距离极远,模型过度自信,这通常是语料句式过于模板化导致的。解决办法是增加语料的多样性,让表达更接近真实输入。

6.3 置信度怎么定阈值

归一化相似度不是概率,所以阈值不能套用“0.9 就是 90% 确定”的直觉。正确的做法是用验证集实际跑一遍,画出不同阈值下的准确率和覆盖率曲线,找一个业务能接受的平衡点。

举个实际的思路:如果业务能接受 90% 的自动处理率,那就把阈值定在能满足“高于该阈值的结果准确率不低于某个标准”的位置,低于阈值的部分转人工。这个决策依赖业务对错误的容忍度,不是纯技术问题。

另外建议设置双阈值:高于高阈值自动处理,低于低阈值直接转人工,中间区间走二次确认。这套机制在客服、风控类系统里非常常见,实现成本很低,但对整体准确率的提升很明显。

7. 最佳实践与工程建议

7.1 语料建设比算法选型更重要

在小样本语义任务里,语料质量对结果的影响通常大于算法选择。同样用 TF-IDF 质心法,语料设计得好能到八十几分,设计得差可能连六十分都不到。

语料建设要注意几点。覆盖不同长度:短句如“明天见”,长句如“等我把手头这个版本发出去,明天下午到公司我们再聊”,长度差异会影响词频权重,语料里要有分布。覆盖不同语体:书面语、口语、网络表达都要有,否则线上遇到口语化输入会大面积失效。覆盖边界样本:像“下周见,不过也可能是最后一次”这种同时含两类信号的句子必须收录,它才是模型真正需要学会处理的难点。

还有一个容易被忽略的点是标注一致性。同一个句子让两个人标,如果结论不一致,说明义项定义本身有歧义,需要先把定义写清楚再标注。标注规范文档比标注工具重要得多。

7.2 阈值与兜底策略

任何文本判别系统都必须有兜底。本文的代码里有三层兜底:规则冲突时不采纳规则、全零向量时走默认标签、置信度低时交由上层处理。这三层各自解决不同的问题,缺一层都会在线上暴露。

兜底标签的选择也要讲究。默认标签应当选业务风险更低的那个。就本文的例子而言,如果把“告别”误判成“约定”,用户可能收到一条不合时宜的回访提醒,体验很差;反过来把“约定”误判成“告别”,最多是少发一条提醒。所以默认值设为farewell更稳妥。这种取舍在写代码时就要想清楚,不要等上线后被动调整。

7.3 日志与可观测性

线上的文本判别服务,日志至少要记录四项:原始输入、分词结果、预测标签、置信度和来源。缺了分词结果,你就无法判断是输入有问题还是模型有问题;缺了来源,你就分不清该优化规则还是优化模型。

如果请求量较大,建议对日志做采样,但所有低置信度和所有 fallback 的结果必须全量记录。这些才是优化语料的直接来源。很多团队的做法是把低置信样本定期回流成新的标注数据,形成一个闭环,模型效果会随着时间稳步提升。

7.4 上线前的检查清单

  • 停用词表已人工审核,否定词和程度词均未被误删。
  • 训练语料已去重,且两类样本数量差距不超过三倍。
  • predict对空字符串、纯标点、超长文本均有合理返回,不抛异常。
  • 全零向量的兜底路径已实际触发验证过。
  • 置信度阈值已用独立验证集确定,不是拍脑袋定的。
  • 模型加载失败时有降级方案,不会导致整个服务不可用。
  • 有可复现的评估脚本,任何语料变更都能重跑对比。

这七条看着琐碎,但每一条背后都对应过真实事故。尤其是第一条和第四条,出问题时往往表现为“结果莫名其妙”,定位成本很高。

8. 总结与学习路线

回到最初那个问题:“再见”到底是约定还是告别。从技术角度看,答案不在词里,而在上下文里。本文做的事,本质上是把“上下文”这个模糊概念拆解成可计算的特征:分词得到词序列,TF-IDF 得到权重向量,质心相似度得到量化距离,规则词表补上强信号,置信度支撑业务分级。整条链路没有黑盒,每一步的中间结果都能打印出来看。

从工程角度看,这套方案的真正价值不在算法本身,而在结构。判别逻辑被封装在一个类里,语料、规则、模型三者解耦,接口只有fit和predict两个方法。这意味着后面想换成分类器或者句向量模型,上层调用方完全不用改。这种可替换性,比当前这一版能跑到多少准确率重要得多。

如果你想继续深入,可以按这个顺序往下走。第一步,把语料扩充到每类一百条以上,重新跑一遍留一法,感受样本量对结果的影响。第二步,把质心法换成逻辑回归,对比两者的准确率和置信度分布,理解“学出来的权重”和“算出来的平均”差别在哪。第三步,引入句向量方案,专门构造一批没有词面重叠的测试句,看它能不能解决 TF-IDF 的死角。第四步,把整个流程封装成一个 HTTP 接口,加上日志、阈值和兜底,做成一个真正能挂在业务链路里的小服务。

这个例子虽小,但覆盖了文本分类项目的完整骨架:数据准备、特征工程、模型训练、效果评估、阈值控制、线上兜底。把这套骨架跑熟,换到情感分析、意图识别、垃圾内容检测等场景,只需要换语料和标签体系,流程是一致的。建议你先照着代码原样跑一遍,确认输出符合预期,再动手加自己的句子进去试,看着source那一列从rule变成model再变成fallback,对模型能力边界的理解会比读十篇文章都直观。

RELATED READING

延伸阅读

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