简介:合工大自然语言处理课程配套实验材料,由孙晓老师授课整理,面向正在学习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 python=3.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_len=5): 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_pattern=r'\b\w+\b')), ('tfidf', TfidfTransformer()), ('clf', MultinomialNB(alpha=1.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 特征概率打印出来,做一个小表:
| 句子 | 真实标签 | 预测标签 | 主要证据词 |
|---|---|---|---|
| 这个电影没有我想象的那么差 | negative | positive | 差, 没有, 想象 |
| 剧情一般但演员很努力 | positive | negative | 一般, 努力, 剧情 |
把这种表格放进报告,再配一段文字说明错误背后的语言现象,比如否定词作用域、转折关系、反讽等,老师一眼就能看出你的理解超出了"调包"层面。这也是答辩时最容易拿分的部分。
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",也能让你看到自己走过的弯路。第三,课堂展示或答辩时,主动讲一个你踩过并解决的坑,这比背十个算法优点更能体现你的工程能力。如果还有时间,可以试着把课程实验串成一个完整项目,比如做一个简单的聊天机器人,这样期末总结时你会有更多素材。
我在重做这些实验时,最大的感受是:自然语言处理不是公式的堆砌,而是对语言规律的逆向工程。只要你愿意把一个实验做到"能解释每行代码"的粒度,就已经超过大部分人了。希望这篇复盘能帮你少走一些弯路,也能让你写出一份让老师记住的实验报告。
本文还有配套的精品资源,点击获取