☰
自然语言处理从零实现:Python手写TF-IDF与朴素贝叶斯
2026/10/26 20:18:51 网站建设 项目流程

最近把一份用了很多年的自然语言处理讲义翻出来重写,起因很现实:团队里新来的同事拿到一堆现成工具包,跑一个情感分类 demo 不到一小时就通了,可等他把同一个流程搬到新数据集上立刻抓瞎——不知道为什么要分词、不知道特征从哪来、不知道报错是因为概率下溢还是平滑参数没调。这大概是所有靠自然语言处理吃饭的人都经历过的阶段。于是有了这套以 Python 实现为主线、把每个环节亲手写一遍的教材,第 2 版(一)先覆盖从文本预处理到经典分类模型的完整链路:清洗与分词、词袋与 TF-IDF、朴素贝叶斯,最后用一个最小的隐马尔可夫分词把序列建模的门推开。目标读者是有 Python 基础、想系统理解自然语言处理而非止步于调包的开发者,也适合要做课程设计但拿到题目不知道从哪下手的人。整篇不需要高深数学,公式尽量用代码展示,能跑就行。

1. 为什么第 2 版仍然坚持"从零实现"

1.1 调包一时爽,接手火葬场

很多教程教自然语言处理,开场就是from sklearn.feature_extraction.text import TfidfVectorizer,然后fit_transform,模型出来,完事。这套流程英文文本没问题,可一旦迁到中文数据集,问题立刻冒出来:要不要分词?分完词要不要过滤?向量里那些小数到底代表什么?等你想给业务加一个自定义特征时,不知道向量里每一维从哪来,就只能继续在库的外围打转。

我常用做饭类比:超市里的复合调味料确实方便,但你想把辣度调成家里老人能接受的口味,就必须知道底料里有什么。TF-IDF、朴素贝叶斯这类方法,生产环境里未必需要你重写,可它内部的三个决定——用什么做词元、词频分母怎么算、平滑参数加在哪——你必须能对着代码解释。这一版教材的使命,就是把你从"会调用"推到"能解释、能改、能调"这一步。

1.2 这套笔记的组织方式与适合人群

第 2 版(一)的整体思路是:先处理语料,再抽特征,然后跑一个统计分类器,最后把序列关系补上。每一章都给出可运行代码,代码旁边标注容易翻车的点。

读者背景建议重点
刚入门 NLP 的同学第 2、3 章是地基,务必亲手敲一遍再玩模型
从其他语言转 Python 的老手重点对比文档里对平滑、归一化、评估指标的说明
做课程设计、新产品预研的人直接参考第 4 章分类器和第 6 章工程化结构

这套"(一)"只覆盖经典统计方法,不碰深度学习。倒不是故弄玄虚,而是向量化、概率模型这套底子,在词向量、注意力机制满天飞的今天仍然是理解后面所有内容的前提。你把 TF-IDF 和朴素贝叶斯完全跑通了,再去看词嵌入和 Transformer,理解成本会低很多。

2. 文本预处理:清洗、归一化与分词的三个真实选择

2.1 清洗规则:不要一上来就堆正则

文本清洗最常见的错误,是把"清洗"理解成"删东西"。实践里更合理的做法是"归一化":把形式不同、含义相同的内容统一成同一种表示。拿原始评论、新闻、聊天记录来说,至少有四类东西要处理:HTML 标签、URL、数字、全角/半角差异。

import re import unicodedata def clean_text(text: str) -> str: # 统一小写,注意中文没有大小写,但这个步骤对英文语料很重要 text = text.lower() # 去除 HTML 标签 text = re.sub(r'<[^>]+>', '', text) # 全角转半角、统一 Unicode 码位 text = unicodedata.normalize('NFKC', text) # URL 和数字替换成占位符,而不是直接删除 text = re.sub(r'https?://\S+', ' URL ', text) text = re.sub(r'\d+', ' NUM ', text) # 压缩连续空白 text = re.sub(r'\s+', ' ', text).strip() return text

这里最关键的一步是 URL 和数字用占位符替换,而不是删除。很多入门代码喜欢把数字直接去掉,结果"房价1200万"和"房价两套"都变成了"房价",语义信息没了。占位符的作用是保留"这里出现了一个数字"这个结构性特征,至于具体数值,后续模型需要时再细化也不迟。

清洗规则还有个顺序问题:先抠标签、再归一化字符、最后压缩空白。如果先压缩空白再处理标签,一旦标签之间有换行,正则就可能漏掉,我在这上面吃过亏。

2.2 中文分词:最大匹配算法与它的尴尬地带

分词是中文自然语言处理绕不开的坎。英文单词天然用空格隔开,中文没有这个福利。最朴素的实现是正向最大匹配:维护一个词典,从左往右扫描句子,每次都尝试匹配最长的词。

def build_max_match(word_dict): max_len = max(len(w) for w in word_dict) def segment(sentence: str): result = [] i = 0 while i < len(sentence): matched = False for L in range(max_len, 0, -1): if sentence[i:i+L] in word_dict: result.append(sentence[i:i+L]) i += L matched = True break if not matched: result.append(sentence[i]) i += 1 return result return segment word_dict = {"南京", "南京市", "长江", "长江大桥", "大桥", "市长", "江"} seg = build_max_match(word_dict) print(seg("南京市长江大桥"))

这个算法原理简单,但真实语料里立刻露怯。经典例子是"南京市长江大桥",最大匹配倾向于切成"南京市/长江大桥";可如果语料里"市长"词频很高,模型可能更希望看到"南京/市长/江大桥"。没有上下文统计信息,纯词典方法根本无法解决这种歧义。所以在工程里,我通常用词典法处理专有名词和领域词,其余交给开源分词器。开源界的 jieba 是一个通用选择,它把词典、隐马尔可夫和动态规划组合在一块,对这个入门阶段足够用。

2.3 停用词不是必须:频率分布有时候更靠谱

停用词表是每个 NLP 项目都会遇到的"迷信"。"的、了、是"这类词过滤掉似乎天经地义,可换个领域,你会发现原来的停用词表根本不匹配。比如电商评价里"质量""不错"是高频词,但它们是关键特征;新闻语料里的"记者""报道"同样高频,过滤了反而把主题线索丢掉。

我的做法是:先不急着过滤,统计语料的词频分布,眼见为实之后再决定。

from collections import Counter def token_frequency(corpus): counter = Counter() for tokens in corpus: counter.update(tokens) return counter freq = token_frequency([["质量", "不错", "价格", "偏贵"], ["质量", "可以", "物流", "很快"]]) print(freq.most_common(10))

频率分布表能直观告诉你:哪些是真正的无意义停用词,哪些是领域背景词。领域背景词要不要留在特征里,取决于任务目标,而不是一刀切删掉。这个判断力,只有等你看过真实统计结果后才会有。

3. 特征工程:自己实现 TF-IDF,顺便搞懂三处参数

3.1 词袋模型:文本变成向量的第一步

机器学习算法只认数值,文本要进去,第一步就是向量化。最简单的是词袋模型:统计每个词在文档里出现的次数,把它放到对应的向量位置上。

from collections import Counter def build_vocab(docs): vocab = set() for doc in docs: vocab.update(doc) return sorted(vocab) def bag_of_words(doc, vocab): counter = Counter(doc) return [counter.get(w, 0) for w in vocab] docs = [["质量", "不错", "价格", "偏贵"], ["物流", "很快", "质量", "可以"]] vocab = build_vocab(docs) print([bag_of_words(doc, vocab) for doc in docs])

词袋模型有个致命弱点:忽略词序。"not bad"和"bad not"在词袋眼里长得一模一样。这就是为什么后面需要 N-gram 和序列模型。但它作为特征工程的第一步,概念清晰,而且很多场景下配合 TF-IDF 已经能取得不错效果,不能因为它简单就瞧不起。

3.2 手写 TF-IDF:平滑、分母和 log 的含义

词袋模型把高频词捧上了天,这可能误导模型。TF-IDF 的核心思想很简单:一个词在当前文档里出现得多(TF 高),但在整个语料里很少出现(IDF 高),说明它对区分这篇文章很有价值。手写一遍,比读十篇原理文章都管用。

import math from collections import Counter def compute_tf(doc, vocab): total = len(doc) counter = Counter(doc) return {w: counter[w] / total for w in vocab} def compute_idf(docs, vocab): n_docs = len(docs) df = Counter() for doc in docs: for w in set(doc): # 注意用 set,避免同一个文档里重复计数 df[w] += 1 # 这里的 +1 和外面的 +1,是平滑项 return {w: math.log((n_docs + 1) / (df[w] + 1)) + 1 for w in vocab} def tfidf_vector(doc, idf, vocab): tf = compute_tf(doc, vocab) return [tf[w] * idf[w] for w in vocab]

三处参数值得停下来想明白。第一处,set(doc)保证文档频率 DF 只统计"多少篇文档出现这个词",而不是"出现多少次"。很多人写错成把所有出现次数加起来,导致高频词 DF 虚高,IDF 被拉低。第二处,平滑项+1是为了防止某个词在所有文档都没出现过时出现除零,主流的库默认也是这个策略。第三处,最外层+ 1是保证语料中每个词都出现的"的""了"这类词,IDF 不会变成 0,保留一点点信号而不是完全抹杀。

3.3 维度爆炸与特征选择:只保留有区分度的词

词袋和 TF-IDF 做出来的向量,维度等于词表大小,动辄几万维。大部分维度是噪声,还会拖慢训练、增加过拟合风险。特征选择的思路是:保留对类别区分贡献最大的词。常见做法有卡方检验、互信息、信息增益,原理各异,本质都是量化"这个词和类别标签的相关性"。

实操要点就一个:特征选择只能在训练集上计算,不能先看全量数据再选。否则相当于把测试集信息泄露给了模型,评估分数会虚高,上线后就原形毕露。我见过太多人在这上面栽跟头,数据集切分和特征选择的顺序一旦搞反,后面所有结果都要推倒重来。

4. 朴素贝叶斯:把条件概率公式跑成分类器

4.1 生成式模型的直觉:猜盒子问题

朴素贝叶斯是自然语言处理入门绕不开的分类器。名字吓人,直觉其实很简单。想象面前有两个盒子,盒子里装着不同的词,一个盒子对应正面评论,一个对应负面评论。现在有人递给你一个词串,让你猜它来自哪个盒子。你看到"好快"这个词,想到正面盒子里这个词出现得多,于是猜它出自正面盒子——这就是朴素贝叶斯在做的事。

它叫"朴素",是因为它假设一个词出现与否和其他词没有关系。明知道这个假设在语言里不成立,可它在很多任务上依然能打。原因在于:我们只要比较哪个类别的得分更高,而不是精确计算真实的联合概率。就算每个概率都有误差,只要误差方向一致,排序结果往往还是对的。

4.2 训练:拉普拉斯平滑为什么必须加在分母

训练朴素贝叶斯,本质上是统计每个类别下每个词的条件概率。直接统计会出现一个致命问题:某个词在训练集的某个类别里一次都没出现过,概率就变成 0,取对数直接是负无穷,整个预测直接废掉。解决办法是拉普拉斯平滑:分子加alpha,分母加上alpha乘以词表大小。

import math from collections import Counter class NaiveBayes: def __init__(self, alpha=1.0): self.alpha = alpha self.prior = {} self.cond = {} def fit(self, docs, labels): class_counter = Counter(labels) total = len(labels) for c, n in class_counter.items(): self.prior[c] = n / total vocab = set() for doc in docs: vocab.update(doc) self.cond = {} for c in class_counter: words = [] for doc, label in zip(docs, labels): if label == c: words.extend(doc) word_counter = Counter(words) total_words = len(words) self.cond[c] = { w: (word_counter[w] + self.alpha) / (total_words + self.alpha * len(vocab)) for w in vocab } def predict(self, doc): best = None best_score = -math.inf for c in self.cond: score = math.log(self.prior[c]) for w in doc: if w in self.cond[c]: score += math.log(self.cond[c][w]) if score > best_score: best_score = score best = c return best

alpha 默认取 1,意思是为每个词“凭空”多记一次出现,相当于对没见过的情况保留一点余地。alpha 太小,模型对新词过于敏感,容易把稀有词当成强证据;alpha 太大,所有概率都被拉向均匀分布,模型失去区分度。实际调参时,我在文本分类任务里常用 0.1 到 2 这个区间做网格搜索,效果比死磕其他超参数明显得多。

4.3 预测:对数空间与防止下溢

预测阶段最容易翻车的是概率计算。一个文档可能包含几十个词,每个词的概率都接近小数,连乘之后数值小到超出浮点数精度,直接变成 0。对策是在对数空间相加:把乘法变成加法,数值范围安全得多,且 log 是单调函数,不影响排序比较。

# 核心就在 predict 里那行: # score = math.log(self.prior[c]) # score += math.log(self.cond[c][w])

这段代码里还有一个隐蔽的工程点:预测时只遍历文档里实际出现的词,而不是遍历整个词表。因为文档里没出现过的词,条件概率再高也不参与求和,遍历整个词表只会白费时间。数据集大了以后,这个差别可能就是几十毫秒和几秒的差别。

4.4 评估:准确率不够,F1 才是硬指标

分类模型训练完,随手打印一个 accuracy 是常见操作,但它非常误导人。二分类任务里 99% 都是负样本时,模型只要永远预测负样本,准确率就有 99%,可它完全没用。垃圾评论、故障工单、异常检测这类场景,正样本往往稀少,这时候要盯着精确率、召回率和 F1。

def evaluate(preds, labels, pos_label=1): tp = sum(1 for p, l in zip(preds, labels) if p == pos_label and l == pos_label) fp = sum(1 for p, l in zip(preds, labels) if p == pos_label and l != pos_label) fn = sum(1 for p, l in zip(preds, labels) if p != pos_label and l == pos_label) precision = tp / (tp + fp) if tp + fp else 0 recall = tp / (tp + fn) if tp + fn else 0 f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0 return precision, recall, f1

我自己的习惯是,训练结束先打印完整混淆矩阵,再看 F1。混淆矩阵能一眼看出分类器到底是把正样本漏掉了,还是把负样本误伤了,这直接影响后续是加数据、调阈值还是改特征。

5. 从词袋到序列:N-gram 与一个最小 HMM 分词器

5.1 语言模型的最小形态:bigram 与 add-one 平滑

词袋模型丢掉了词序,自然语言处理是不会满足于这种表示的。一个比词袋多一点点信息的最小改进,就是 N-gram 语言模型。它的目标很朴素:给定前一个词,预测下一个词出现的概率。bigram 只看前一个词,实现最小,也能引出平滑思想。

from collections import defaultdict, Counter def train_bigram(texts): unigram = Counter() bigram = defaultdict(Counter) for text in texts: tokens = ['<s>'] + text.split() + ['</s>'] for i in range(len(tokens) - 1): unigram[tokens[i]] += 1 bigram[tokens[i]][tokens[i+1]] += 1 return unigram, bigram def bigram_prob(next_token, prev_token, unigram, bigram, vocab_size, smoothing=1): numerator = bigram[prev_token].get(next_token, 0) + smoothing denominator = unigram[prev_token] + smoothing * vocab_size return numerator / denominator

<s>和</s>是句首句尾标记,它们的作用是让模型学会"一句话该以什么词开头、用什么词收尾"。没有这两个标记,模型只会在词的海洋里迷路。分母使用unigram[prev_token] + smoothing * vocab_size,而不是unigram[prev_token],因为所有可能的下一个词构成一个完整概率分布,分母必须覆盖所有可能性,加上平滑后总体才归一。

5.2 汉字序列标注:B/M/E/S 与 Viterbi 解码

生成式分类器解决的是"文本属于哪一类",而分词要解决的是"每个字属于什么位置"。这个转变很关键:分词被建模成序列标注问题。每个字有四种状态:B(词首)、M(词中)、E(词尾)、S(单字成词)。句子"我爱自然语言处理",正确的标注是"我/S 爱/S 自/B 然/M 语/M 言/M 处/M 理/E"。

有了标注,就能用隐马尔可夫模型,把问题拆成两部分:状态之间的转移概率,以及状态生成某个字的发射概率。解码阶段用 Viterbi 算法,动态规划找出概率最大的标注序列。下面是一个精简版实现:

def viterbi(obs, start_p, trans_p, emit_p, states): V = [{}] path = {} for s in states: V[0][s] = start_p[s] * emit_p[s].get(obs[0], 1e-10) path[s] = [s] for t in range(1, len(obs)): V.append({}) new_path = {} for s in states: prob, prev = max( (V[t-1][s0] * trans_p[s0][s] * emit_p[s].get(obs[t], 1e-10), s0) for s0 in states ) V[t][s] = prob new_path[s] = path[prev] + [s] path = new_path prob, end_state = max((V[-1][s], s) for s in states) return path[end_state], prob states = ['B', 'M', 'E', 'S']

emit_p[s].get(obs[t], 1e-10)是处理未登录字的关键:某个字在训练集里从没出现在状态 B 下,就给它一个很小的概率而不是 0。这里用乘法,是因为 II 类概率都可以归一,严格实现要在对数空间进行以避免下溢,上面的代码为了演示可读性用了原始概率,数据量小够用,真实项目务必改成 log 累加。

5.3 实测:这个最小分词器能用到哪一步

我用这个纯手工 HMM 分词器在几千条标注语料上训过,效果比纯词典法好不少,能捕获一些词典里没有的组合词,但对人名、地名、数字混合串非常头疼。姓名往往是单字成词的呆板组合,而 HMM 倾向于把相邻的 S 状态合并成词;英文单词、网址、电话号码这类非中文片段,也会把整个状态链路搅乱。更麻烦的是,训练语料的标注风格直接决定模型行为,标注标准不统一,模型就会学到混乱的规律。

所以我的结论是:这个最小的 HMM 分词器是绝佳的教学实现,但生产环境里我会用它做基础,然后叠加快路径词典匹配和规则修正。理解它的原理,不是为了代替开源工具,而是为了在工具表现失常时,你有能力定位到底是词典问题、标注问题还是平滑参数问题。

6. 工程化收尾:让笔记里的代码变成能复用的脚本

6.1 目录结构:把实验和代码分开

教材里的代码,大多数人是在 Jupyter 里跑的,这没问题。但项目一旦有多个环节、多份数据,笔记本形式很快会失控。我建议从写第一行代码起,就保持一个简单但清晰的目录结构:

nlp_demo/ ├── config.py # 超参数、文件路径集中管理 ├── data/ # 原始数据与训练集、测试集 ├── features.py # 清洗、分词、向量化 ├── models/ # 分类器、序列模型 │ ├── naive_bayes.py │ └── hmm_seg.py ├── pipeline.py # 串起整个流程 └── main.py # 命令行入口

config.py 单独存在的意义,是把"改参数"和"改代码"分开。超参数集中在一个文件里,想跑实验就改配置,不会出现调参时不小心把逻辑改坏的问题。

6.2 三个翻车现场:编码、内存和死循环

脚本化之后,第一现场是编码。读取中文数据时,务必显式指定编码:

with open('data/train.txt', 'r', encoding='utf-8') as f: lines = f.readlines()

encoding参数不能省。Windows 默认编码可能是 gbk,Linux 默认是 utf-8,不写清楚,同一段代码换个机器就崩,报错还很隐蔽。

第二现场是内存。bigram 计数看起来简单,但词表几万时,defaultdict(Counter)会膨胀得很快。如果数据量大,改用dict套dict,或者直接上numpy矩阵稀疏存储,能省掉大量内存。我给学生的建议是:先把数据规模缩小十分之一把流程跑通,再慢慢放大,不要一上来就全量数据撞内存上限。

第三现场是死循环。正向最大匹配的 while 循环,如果词典为空或者词典里最大词长等于 0,会一直i += 1直到越界,但这种循环错误通常不会立刻报错,而是跑出错误结果。写这类代码时,最好加一个迭代上限做保护:

while i < len(sentence) and guard < len(sentence) * 2: ... guard += 1

6.3 可复现性:随机种子和一次性切分数据

机器学习项目里,"结果可复现"是容易被忽略的工程要求。昨天跑出 F1=0.86,今天什么都没改却变成 0.85,多半是数据切分或模型初始化里带了随机性。解决办法很朴素:固定随机种子,并且切分数据集时先把结果存成文件,以后每次都用同一份切分结果。

import random random.seed(42) # 把切分后的索引或文本直接写盘 with open('data/train_indices.txt', 'w') as f: for idx in train_idx: f.write(str(idx) + '\n')

我自己的体会是,把这套第 2 版(一)里的代码全部跑通之后,最花时间的反而不是模型本身,而是清洗和评估这两段。很多初学者喜欢把精力花在模型调参上,却在数据预处理和评估代码上草草了事,结果就是实验结论不可靠。建议拿到代码后先做两件事:故意往清洗函数里塞脏数据,看它会不会崩;故意构造一个全是负样本的测试集,看评估指标会不会现原形。把这两段吃透了,后面你接触任何新的自然语言处理模型,心里都会有一个稳定的坐标系。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询