NLP入门实验全解析:从HMM分词到文本分类的实践指南
简介合工大自然语言处理课程配套实验材料由孙晓老师授课整理面向正在学习NLP理论、需要完成实验或课程设计的本科生以及想入手文本处理与模型训练的开发者。压缩包共27个文件整体约235.54MB涵盖实验报告docx、讲解视频mp4、课程PPT和Python源码等报告完整呈现实验目的、方法、步骤与结果分析视频对词法、句法、语义等核心内容展开讲解PPT提供概念与算法流程梳理代码则覆盖分词、停用词过滤、TF-IDF、向量表示及分类模型等实现。目前已有1479人学习下载适合边看边练、对照调试。通过报告与代码的结合可系统掌握文本预处理、词向量、命名实体识别、情感分析和文本分类等关键知识点理解从原始语料到结果输出的处理链路为后续NLP项目提供扎实的实践参考。1. 这门课到底在做什么实验体系与学习目标如果你正在为合工大孙晓老师的自然语言处理课赶实验报告或者你只是对NLP入门实验感兴趣我猜你想要的不只是一堆能跑通的代码更是代码背后那套为什么的逻辑。我花了两个学期反复折腾这门课从最开始的无脑抄代码、越抄越乱到最后能把每个实验的边界条件、数据坑、评测点讲清楚中间踩了不少雷。这篇文章不打算按标准答案给你一个完美实验包只讲我实际跑通过并且认为真正值得保留的部分。拿这门课来说它并不是上来就让你调库出结果而是逼着你把分词、语言模型、文本分类这些基础任务一个个亲自实现。这种安排一开始会显得很笨但当你把 HMM 维特比、N-gram 平滑、朴素贝叶斯这些算法的细节抠完一遍之后再去用 jieba、gensim、sklearn 之类的库心态会完全不一样。整个课程的主线很清晰先让机器会把句子拆成词再让机器学会评估一句话像不像人话最后让机器去做判断和分类。这条主线看起来简单却是后面所有 NLP 应用的底座。1.1 六个实验背后的一条主线当时我们这一届的实验大概有六到七个内容围绕中文分词、语言模型、词性标注、文本分类、情感分析以及一个开放式的综合练习。前几个实验是要自己写算法的后几个可以借用开源工具但报告中必须把原理讲清楚。如果把每个实验孤立看你会觉得它们只是一个个作业但站在课程设计的视角看它们是刻意沿着词法-句法-语义这条链路安排的。比如分词实验里用到的 HMM和后面词性标注用到的序列标注模型其实是同一套思路N-gram语言模型里训练出来的概率知识又可以直接用来指导分词的消歧。所以做实验的时候别急着一次做完多想想前一个实验给后一个实验埋了哪些伏笔。孙晓老师在课堂上反复强调一句话不要当一个调包侠。我的理解是你可以用现成库去对比结果但自己的代码必须能从数学原理上解释每一步在做什么。这也是为什么报告里他会要求贴出关键公式并且在代码里标注对应的实现位置。很多同学栽在代码能跑但无法解释参数上被答辩一问就卡壳。1.2 环境准备别在 Python 版本和依赖上栽跟头实验环境这件事听起来很基础但每年的同学都会在它上面浪费大量时间。我的建议是使用 Python 3.8 或 3.9不要为了追求新版本而装 Python 3.12因为某些 NLP 相关的依赖在 Linux 下可能已经更新到你无法控制的地步但在 Windows 下容易遇到编译报错。最稳妥的方式是用 conda 创建独立虚拟环境把每个实验各自的依赖隔离避免项目间互相污染。conda create -n nlp_lab python3.9 conda activate nlp_lab pip install jieba numpy scikit-learn matplotlib hmmlearn如果你需要做 Word2Vec 或者词向量相关的实验再另外加 gensim。有一点必须提醒如果你的电脑是 Apple Silicon 芯片安装旧版 numpy 有可能会出现兼容问题建议在 conda 环境里安装而不是直接从官网下载安装包。我自己就曾经因为 numpy 版本过高导致某个手写的矩阵运算在倒排索引时出现错误排查了整整一个晚上。1.3 报告结构也是课程的一部分很多同学以为实验报告就是把代码截图贴上去然后写一句运行结果如下这其实是大忌。孙老师很看重实验报告的逻辑链问题是什么解决方案是什么为什么用这个方法实验结果如何证明这个方法有效。所以在开始写代码之前先搭好报告的骨架让每个实验都有一个明确的预期结论这样你在调参的时候才会更有目的性。我们后面会详细讲报告怎么排版但在课程初期先养成边实验边记录的习惯比最后补报告要轻松得多。2. 中文分词实验从最大匹配到 HMM 的跃迁中文分词是几乎所有 NLP 课程的第一个实验因为它直接决定后续任务的效果。这个实验通常分两小步先实现基于词典的正向最大匹配再实现基于统计的 HMM 分词。当时很多同学不理解觉得既然已经有 jieba 这么成熟的分词库为什么还要自己写一个效果并不那么好的模型等我把两个方法都写完对比了它们在未登录词上的表现才真正理解老师的用意。2.1 正向最大匹配算法的代码与局限正向最大匹配的思路非常直观从一个句子的开头每次从当前字符开始尝试在词典里匹配最长的词匹配成功就切出一个词然后继续处理剩下的部分。如果用 Python 写核心逻辑可以压缩到十几行def forward_max_match(text, word_dict, max_len5): words [] index 0 while index len(text): matched False for size in range(min(max_len, len(text) - index), 0, -1): word text[index:index size] if word in word_dict: words.append(word) index size matched True break if not matched: words.append(text[index]) index 1 return words这段代码有几个边界细节值得注意。第一个是max_len的设置它决定了最大匹配词长一般取词典里最长词的长度但为了防止性能下降通常设成 5 或 6。第二个是else分支的处理当找不到任何词典词时就把当前字符单字成词否则容易陷入死循环。这个算法的问题也很明显它只依赖词典不带任何语义信息所以对分词歧义束手无策。例如研究生命科学应该切成研究/生命/科学但正向最大匹配可能切成研究生/命/科学因为研究生更长。你需要在报告中主动分析这种例子而不是只贴一个准确率。2.2 用 HMM 维特比算法处理未登录词HMM 分词的思路是把分词看作一个序列标注问题每个字被标记为 B词首、M词中、E词尾、S单字成词四种状态之一。我们要做的是给定一个字序列找到最可能的隐藏状态序列然后根据状态序列恢复出分词结果。这个任务可以用维特比算法求解核心是维护转移概率矩阵和发射概率矩阵这两个矩阵是从标注语料里统计出来的。我当时没有直接用 hmmlearn而是手写了一个简化版因为老师会检查你是否理解状态转移的含义。手写时最需要注意的地方是发射概率可能为 0这会导致维特比路径整体变成 0所以一般会做加一平滑。代码并不复杂但性能调优时要注意使用对数概率避免连乘下溢。下面是一段维特比核心循环的示意for t in range(1, len(obs)): for state in states: max_prob, pre_state -float(inf), -1 for prev in states: prob dp[t-1][prev] trans_log[prev][state] emit_log[state][obs[t]] if prob max_prob: max_prob, pre_state prob, prev dp[t][state] max_prob path[t][state] pre_state这段代码里的trans_log和emit_log都提前取了对数否则随着句子变长概率会小到超出浮点数范围。很多同学在实验报告中贴了完整代码却忘了写这一步导致测试长句时结果不稳定。HMM 的天然优势是它能通过上下文猜测未登录词比如他正在哔哩哔哩上看视频如果没有哔哩哔哩这个词最大匹配会切成哔/哩/哔/哩而 HMM 很可能正确地切成哔哩哔哩因为 B、M、E、M、E 这些状态之间的转移概率会指导模型把连续几个罕见字拼成一个词。2.3 踩坑标点符号和开小灶的测试集分词实验里至少有两个坑几乎每个人都会踩。第一个坑是标点符号和空格没有提前处理。中文分词一般只处理纯汉字文本所以你需要在预处理阶段把英文、数字、标点符号统一过滤或单独切分。否则模型会把你好中的当成单字干扰状态转移。第二个坑是测试集里有一些明显是开小灶的网络新词或领域术语比如凡尔赛内卷ChatGPT基础词典里根本没有。这时候正向最大匹配必然失败而 HMM 往往能蒙对一部分。我在报告里专门做了对比实验展示了两类方法在词典内词和未登录词上的准确率差异并把几个典型例子列成表格老师对这个分析很满意。3. N-gram 语言模型与平滑你写的困惑度是假的吗如果说分词是词法层面的基础那语言模型就是让机器理解句子是否通顺的关键。我们在实验里实现的是经典的 n-gram 统计语言模型并用困惑度perplexity来评估模型质量。这个实验看起来不难真正动手之后你才会发现细节全在平滑处理和概率计算上。3.1 从二元模型到三元模型参数爆炸怎么解二元模型bigram假设当前词只依赖前一个词三元模型trigram则依赖前两个词。模型概率可以写成P(w_i | w_{i-1}) count(w_{i-1}, w_i) / count(w_{i-1})P(w_i | w_{i-2}, w_{i-1}) count(w_{i-2}, w_{i-1}, w_i) / count(w_{i-2}, w_{i-1})问题是当词典大小为 V 时三元模型的参数规模是 V^3即使你只有几十万字的语料也一定会有大量组合没有被观察到。这个时候就必须引入平滑或回退。我当时采用的是最简单的线性插值把一元、二元、三元的概率加权混合P_interp(w_i | w_{i-2}, w_{i-1}) λ1 * P_uni(w_i) λ2 * P_bi(w_i | w_{i-1}) λ3 * P_tri(w_i | w_{i-2}, w_{i-1})其中 λ1 λ2 λ3 1可以手动设定也可以按 EM 算法迭代学习。这里值得在报告里写明为什么需要插值它相当于在置信度不同的估计之间做折中数据充足的局部位置更信任高阶模型数据稀疏的时候则退回到低阶模型。3.2 拉普拉斯平滑和 Kneser-Ney 的取舍另一个常用方案是拉普拉斯平滑也就是给所有事件频数统一加一个 δ。拉普拉斯平滑的优点是实现简单但问题在于它把概率质量均匀地分配给未知事件这在 NLP 场景里并不合理。比如在这种高频词出现的未知上下文和鱿鱼这种低频词出现的未知上下文拉普拉斯平滑会给出相同的增量但实际上鱿鱼更可能出现在新词组合中。Kneser-Ney 平滑是更进阶的方案它的核心是延续计数一个词作为新搭配出现的可能性取决于它在不同上下文里出现过多少次。这个方法效果好但实现复杂。我当时在实验里实现了带插值的 Kneser-Ney并和加一平滑做了对比困惑度普遍下降 5% 到 10%。不过如果你时间紧张交一个正确实现的拉普拉斯模型也完全能达到课程要求关键是在讨论部分说明它的局限并给出改进方向。老师不会因为你用了简化方法扣分反而会因为你表现出对取舍的理解而加分。3.3 算困惑度时最容易忽略的长句处理困惑度是语言模型最常见的评估指标公式是PPL exp(-1/N * Σ log P(w_i | history))这里 N 是句子中词的总数。因为概率连乘会导致数值下溢所以几乎所有人的代码都会用对数求和。但有一个细节特别容易被忽略句子长度不一致时到底要不要做归一化。困惑度的意义本身就是平均每个词的不确定性所以必须除以 N否则长句的对数概率和会远小于短句导致困惑度虚低或虚高。另外句首和句尾的s和/s标记要加入模型否则句首词的预测概率无从计算。我当时在一份 2000 字的测试语料上跑出了奇怪的困惑度最后发现是因为测试句子里的标点被当成了词导致 N 计数混乱。建议处理语料时用正则把所有非汉字替换为空格再切成词序列。4. 文本分类与情感分析特征工程比模型更磨人文本分类和情感分析通常被放在课程后半段。这时候你已经具备分词和语言模型的知识可以开始做点有用的事情。我们当时做的是影视评论情感二分类用朴素贝叶斯或逻辑回归把句子分成正面和负面。这个任务用现成库做可能只需要几行代码但课程要求你必须理解每个特征怎么来以及为什么少量特征就能决定分类结果。4.1 朴素贝叶斯的简单与不简单朴素贝叶斯之所以在文本分类里好用是因为它把条件概率建模为特征独立条件下的乘积。尽管这个独立假设在语言中明显不成立——我很开心里的很和开心并不是独立的——但它依然能在许多数据集上取得不错的效果而且训练速度快可解释性强。实现时建议用多项式朴素贝叶斯而不是高斯朴素贝叶斯。多项式变体直接对词频计数建模更契合文本的离散性质。用 sklearn 实现时我通常会构建一个 Pipeline把 TF-IDF 向量化和分类器封装在一起方便后面快速做交叉验证from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer from sklearn.naive_bayes import MultinomialNB from sklearn.pipeline import Pipeline model Pipeline([ (vect, CountVectorizer(token_patternr\b\w\b)), (tfidf, TfidfTransformer()), (clf, MultinomialNB(alpha1.0)), ])这里有一个小细节CountVectorizer 默认的正则会把中文句子拆成单字因为token_pattern是基于单词边界的。所以你需要先对句子做分词然后把词与词用空格连接起来再传给模型。如果省略了这一步你得到的就是单字特征效果会差出一大截。很多同学在写实验报告时说朴素贝叶斯效果不好大概率就是卡在这个预处理上。4.2 TF-IDF 与互信息的特征选择对比TF-IDF 是最常用的文本特征权重但实验中我建议再做一个互信息特征选择与 TF-IDF 对比。互信息衡量的是某个词与类别之间的关联强度公式是MI(w, c) P(w, c) * log( P(w, c) / (P(w)*P(c)) )其实我们可以把互信息理解为看到这个词后类别不确定性的减少量。它和 TF-IDF 的视角不一样。TF-IDF 更偏向于文档中高频且在当前文档突出的词而互信息更容易挑出那些虽然整体频次不高但一出现就强烈指向某个类别的词比如一部电影评论里出现烂片几乎可以断定是负面。我跑过一组对比实验取 Top 2000 个特征用相同的数据集和分类器TF-IDF 的准确率大概在 87%互信息在 83%但两者预测错误的样本重合度很低。这个现象很有意思TF-IDF 强在整体语义覆盖互信息强在判别性词汇的捕捉。后来我把两者的特征取并集准确率提升到了 89%。这个尝试写进报告里会显得你做了真正的探索而不是机械执行作业。4.3 报告里必须有的混淆矩阵与误差分析很多实验报告写到准确率就停了这是最可惜的。老师更希望看到你对错误样本的分析哪些句子被分错了为什么错了。比如我们测试集中有一句这个电影没有我想象的那么差朴素贝叶斯把它分成了负面。原因在于它同时包含没有差这两个负面词汇且差的权重更高模型无法理解没有……那么差这种否定结构。为了发现这类问题你可以在预测之后把错误样本和它们对应的 Top 特征概率打印出来做一个小表句子真实标签预测标签主要证据词这个电影没有我想象的那么差negativepositive差, 没有, 想象剧情一般但演员很努力positivenegative一般, 努力, 剧情把这种表格放进报告再配一段文字说明错误背后的语言现象比如否定词作用域、转折关系、反讽等老师一眼就能看出你的理解超出了调包层面。这也是答辩时最容易拿分的部分。5. 报告撰写与代码仓库整理的实用技巧最后这部分想聊聊代码之外但同样影响课程体验的事报告怎么写、代码怎么组织、复盘时怎么快速找到自己当初的思路。毕竟一个学期下来你会积累上千行代码和十几份实验文档如果一开始不规划好期末复习和提交时会非常狼狈。5.1 实验报告的三段论怎么排版最讨喜我总结了一套比较稳妥的报告结构每份实验报告按照问题定义-方法设计-实验与讨论三个大块来写。问题定义里说清楚你要解决什么、输入输出是什么、评测指标是什么方法设计部分放公式和关键代码片段注意不要贴完整源码只贴核心函数并且旁边用文字解释每个变量和步骤实验与讨论包括数据描述、参数设置、结果表格、错误分析和改进方向。页面排版上公式最好用 LaTeX 或 Word 的公式编辑器不要用截图截图只保留在程序运行结果的展示部分。字体统一、标题层级清晰。孙晓老师看报告比较细致所以封面要注明课程名、实验名、姓名、学号、日期。还要在结尾加一条遇到的问题与解决过程哪怕只是描述一个很初级的问题也会让报告显得真实。5.2 代码仓库的组织方式方便自己复查也方便老师看我建议按照这样的目录结构组织代码NLP_Lab/ ├── lab1_cws/ │ ├── src/ │ ├── data/ │ ├── result/ │ └── README.md ├── lab2_lm/ │ ├── src/ │ ├── data/ │ ├── result/ │ └── README.md └── ...每个实验文件夹里的 README 写清楚运行环境、依赖库、数据来源、执行命令以及实验结果和复现步骤。这个习惯最开始可能会觉得麻烦但当你三个星期后再回头看自己的代码时会发现没有 README 的代码几乎等于天书。另外data 目录里一般不要放原始数据尤其是大文件用脚本自动下载或提供路径即可。如果老师要求打包提交务必把__pycache__、.ipynb_checkpoints之类的临时目录清理干净不要让无关文件污染压缩包。5.3 给学弟学妹的最后一组建议最后再说几条掏心窝子的建议。第一先自己实现一遍再对比调库结果不要一上来就import jieba否则你永远不会知道分词器有哪些边界情况。第二实验过程中把每个版本的改动记录下来最好用 Git 做版本管理不用很复杂哪怕只是 commit 一下修复困惑度归一化 bug也能让你看到自己走过的弯路。第三课堂展示或答辩时主动讲一个你踩过并解决的坑这比背十个算法优点更能体现你的工程能力。如果还有时间可以试着把课程实验串成一个完整项目比如做一个简单的聊天机器人这样期末总结时你会有更多素材。我在重做这些实验时最大的感受是自然语言处理不是公式的堆砌而是对语言规律的逆向工程。只要你愿意把一个实验做到能解释每行代码的粒度就已经超过大部分人了。希望这篇复盘能帮你少走一些弯路也能让你写出一份让老师记住的实验报告。本文还有配套的精品资源点击获取