做计算机毕业设计,最怕的不是不会写代码,而是选题本身没搞清楚要做什么。我当年选的是“基于机器学习的中文书目自动分类的设计与实现”,听起来像是一个老掉牙的NLP课题,但真正动手之后才发现,这里面从数据、特征到模型和系统,每一环都有值得较真的细节。这个项目解决的是一个很现实的问题:图书管理系统里每天新增大量中文书目,人工按分类法打标签不仅慢,而且不同人理解还不一样,能不能训练一个模型,输入书名甚至加上一句内容简介,自动给出它所属的中图法大类。如果你也在做类似的文本分类毕设,或者想找一个数据量适中、可解释性强、又能完整演示的机器学习项目,这篇记录应该能帮上忙。
1. 选题背景与需求拆解
1.1 为什么选这个题目:书目分类到底难在哪
中文书目分类,说通俗点,就是给每一本书判定它属于哪个学科门类。标准做法是参照《中国图书馆分类法》,它把图书分成五大基本部类和二十二个大类,像文学是I类,工业技术是T类,自动化与计算机技术是TP类。人工分类看似简单——书名里通常有“机器学习”“电工基础”这类关键词,但真到了实际操作层面,问题就来了。
第一个问题是规模。一个稍大点的图书馆或者线上书库,每天新增的不仅是正书,还有教材、期刊、内部资料,人工维护分类是一个持续消耗人力的事。第二个问题是标准漂移。同一个分类员上午和下午的理解可能都不完全一致,更不要说换一个人,不同批次录入的书目,分类口径经常对不齐。第三个问题是长尾。很多书名写得比较“艺术”,比如《活着》《围城》《白夜行》,光看书名根本猜不到分类,还有一些交叉学科的书,人工判断都需要翻内容简介。
所以这个课题的定位就很清楚了:把“图书分类”这个业务问题,抽象成一个“文本多分类”的机器学习问题。模型输入是一本书的书名(可选加作者、简介等文本信息),输出是中图法下某个大类的标签。这样做的好处是,它有明确的业务落地场景,又不过分依赖复杂的工程环境,一个人一台电脑就能做完整套实验,非常适合作为计算机毕设题目。
1.2 需求边界与验收指标:做到什么程度算完成
写需求的时候我给自己定了几个明确边界,这也是后来答辩时被反复问到的点。
功能上,系统至少要能完成三件事。第一,能接受一条书目文本;第二,能通过训练好的模型给出分类类别,并附带置信度;第三,能把新输入的书目追加到待标注列表中,方便后期人工校对和增量训练。做成Web界面还是命令行取决于答辩形式,我自己是做成了简单网页,因为演示的时候一张截图加一段现场输入,比纯终端输出直观太多了。
性能上,一个大类别的分类任务,我认为评估指标至少应该盯住宏观F1值。多分类问题只看准确率会骗人——如果某个大类占了数据集的60%,模型全部预测成这个类,准确率也有60%,但这显然不可用。所以要用Macro-F1或者加权F1来平衡各类别。我当时定的目标是F1不低于85%,这个目标不算激进,但足够支撑一次合格的答辩。
还有一个容易被忽略的需求是“可解释”。答辩老师和评审专家一般不会只满足于“模型跑通了”,他们会追问:为什么这个类别分错了?为什么选这个模型?所以项目里最好保留一份错误案例分析,后面我会讲具体怎么做。
2. 整体方案设计与技术选型
2.1 技术栈选型:Python生态是首选
这个项目的技术栈基本上没有悬念。编程语言用Python,理由不用多说,文本处理库和机器学习库最全。分词用jieba,中文分词的事实标准,支持自定义词典。向量化和模型训练用scikit-learn,里面TF-IDF、朴素贝叶斯、SVM、逻辑回归、随机森林都是现成的。Web服务用Flask,轻量,写一个预测接口只需要几十行代码。前端就是单个HTML页面加一个AJAX请求,不引入框架。
可能有人会问:为什么不用PyTorch或者TensorFlow做深度学习?我用一张表来说明当时的想法:
| 方案 | 优点 | 缺点 | 本项目适配度 |
|---|---|---|---|
| 经典机器学习 + TF-IDF | 训练快、可解释性强、无GPU也能跑 | 难以捕捉深层语义 | 高 |
| Word2Vec + 深度学习模型 | 能学语义相似性 | 需要大语料预训练、调参成本高 | 中 |
| 预训练模型微调 | 效果通常最好 | 权重文件大、推理慢、答辩容易说不清原理 | 低 |
不是深度学习一定不行,而是毕设项目讲究“投入产出比”。经典机器学习方案课上讲过、原理能推导、代码能复现,而且五天之内就能完成一轮完整的实验,后期有精力再往深度学习方向扩展。实际做的时候我也试着加了一个TextCNN做对比,后面实验结果会说明差异。
2.2 核心算法选型:四个候选模型对比
我在实验中对比了四类模型,都在scikit-learn里直接调用。朴素贝叶斯用的是MultinomialNB,适合高维稀疏文本特征,训练极快;支持向量机用的是LinearSVC或者带RBF核的SVC,对小样本文本分类效果好;逻辑回归用的是LogisticRegression,输出有概率解释,方便做置信度;随机森林用RandomForestClassifier,用来验证树模型在高维稀疏特征上的表现。
为什么选这四个?因为它们覆盖了生成式模型、判别式模型、线性模型、非线性模型几大类,对比起来才有说服力。答辩的时候老师问“你为什么用SVM而不用神经网络”,你就可以从数据规模、特征维度、可解释性三个角度回答,而不是只回一句“网上说效果好”。
2.3 系统架构设计
整个系统的架构我画得很简单,但职责分得很清。数据层保存原始书目数据、清洗后的训练集和测试集;特征层负责分词、去停用词、TF-IDF向量化;模型层负责训练脚本、模型文件、评估报告;应用层负责Flask接口、前端页面和结果展示。
分层的好处是,训练和推理解耦。模型用joblib保存成文件,Web服务启动时一次性加载到内存,每个请求只需要走“分词 → 向量化 → 模型预测”这条链路,单条预测耗时基本在几十毫秒级别。这个架构也方便后期替换模型——只要保持输入输出格式不变,把模型文件换掉就可以了。
以训练和推理解耦为主线,我把各模块之间的数据格式也做了约定。比如特征模块的输出永远是空格分隔的词序列,模型模块接收这种序列并返回类别ID和概率。这样改任何一环,其他环都不用动。后面我写接口、做前端时,基本没有返工。
3. 数据获取与预处理实操
3.1 数据集构建:类别体系与数据量
构建数据集是这个项目最花时间的一步。这一节我按做这类题目最通用的一条实际路线来讲,很多细节来自公开经验和自己调试的结合。书目分类的类别体系我直接参照中图法的一级大类,但不是22个全用,而是选了10个常见大类:文学、历史地理、哲学宗教、经济管理、政治法律、教育科学、语言文字、数理化、工业技术(含计算机)、艺术。这样既覆盖了常见图书,又避免了一些冷门类别样本太少导致模型根本没法学。
数据来源可以爬取线上图书平台或者使用整理好的书目数据。要注意合规问题:爬取公开数据仅用于学习研究,不要涉及版权内容,书籍的基础书目信息(书名、作者、分类)本身是元数据,相对安全。我当时收集了大概8000条书目,每个类别控制在700到1000条之间。为什么不直接用原始分布?因为线上平台的图书类别分布极不均衡,小说类可能占了40%,工业技术类也不少,但宗教哲学就很少。类别不平衡会让模型严重偏向多数类,所以我在构建数据集时先做了均衡化处理,每类抽700到1000条。这个做法后期要用验证集重新评估,因为线上真实分布比测试集更倾斜。
数据标注这里有一个坑:不能完全相信来源平台的分类。平台分类有时是粗粒度标签,有时是商家自己随便选的。我当时的处理办法是,对每条样本同时保留“书名+简介”,如果书名和简介里出现了非常明确的领域词(比如“Python”“算法”“数据结构”),人工确认归入工业技术类;如果光靠书名无法判断,就结合简介用规则先粗筛,再人工抽检。8000条数据全部人工检查不现实,但抽检500条是可以做到的,抽检发现标注错误率低于1%才放心继续。
3.2 预处理流水线:清洗、分词、去停用词
中文文本和英文最大的不同在于没有天然的词边界,所以分词是绕不开的一步。我的预处理流水线如下:
- 清洗。去掉书名和简介中的HTML标签、全角半角混乱的空格、连续标点。书名部分保留括号内容,比如《深入理解计算机系统(原书第三版)》,括号里的版本信息对计算机类识别是有帮助的。
- 分词。用jieba分词,加载自定义词典,把“机器学习”“深度学习”“操作系统”“数据结构”这类领域词切成整词。不加载自定义词典的话,这些词大概率被切成“机器/学习”,虽然影响不大,但会让特征变得更碎。
- 去停用词。停用词表用的是通用中文停用词表,去掉“的”“了”“是”“和”这类虚词。要注意的是,类别判断往往依赖少量强特征词,“数据”“系统”这种泛领域词如果出现在书名里,反而会误导分类,所以我实验后把部分高频泛词也加进了停用词表。这个属于经验调优,不是标准流程。
预处理代码给大家看一下:
import jieba import re STOP_WORDS = set() with open('stopwords.txt', 'r', encoding='utf-8') as f: for line in f: word = line.strip() if word: STOP_WORDS.add(word) def clean_text(text): text = re.sub(r'<[^>]+>', '', text) text = re.sub(r'\s+', ' ', text) text = text.strip() return text def cut_text(text): text = clean_text(text) words = jieba.lcut(text) return [w for w in words if w.strip() and w not in STOP_WORDS and len(w) > 1]这里有个细节:len(w) > 1会把单字词滤掉。有些单字其实是领域强特征,比如“诗”“画”“法”这类。所以我后面改成了对停用词表和长度分别处理:长度过滤只用于简介,书名保留单字词。这个选择对结果的影响大概一两个百分点的F1,但属于值得讲给答辩老师听的优化点。
3.3 特征工程:TF-IDF参数怎么调
特征工程的核心是把分词结果转成向量。我用的TF-IDF,这是信息检索和文本分类里最经典的特征表示。为什么不直接用词频?因为“的”“是”这些词在每类文档里都频繁出现,词频给它们的权重太高;TF-IDF用逆文档频率压低常见词的权重,突出那些只在某一类文档中出现的高区分度词——比如“算法”在TP类里大量出现,但在文学类里几乎没有,它的IDF就很高。
关键的参数如下:
max_features=8000:限制特征维度,减少稀疏和高噪声。ngram_range=(1, 2):加入二元词组。像“操作系统”这样的词,分词后如果是“操作/系统”,二元词组合并后能还原语义。min_df=2:至少在2篇文档中出现过,否则视为噪声。sublinear_tf=True:对词频做对数变换,压缩高频词的影响力。
有文章建议把max_features设到几万,但我在8000条数据上试验下来,1万以上F1几乎不涨,训练时间却明显变长。所以8000到10000是一个性价比比较高的区间。另外要提醒一点:TF-IDF必须在训练集上先fit再transform,验证集和测试集只能transform,绝对不能对整个数据集先fit再划分,否则就是数据泄漏,测试结果会虚高得很难看。
4. 模型训练、调参与评估
4.1 训练流程与模型对比
数据集按7:2:1划分成训练集、验证集、测试集,并且用分层抽样保证各类别在三个集合里的分布一致。模型训练脚本很简单,以逻辑回归为例:
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( texts, labels, test_size=0.2, stratify=labels, random_state=42 ) pipeline = Pipeline([ ('tfidf', TfidfVectorizer(max_features=8000, ngram_range=(1, 2), min_df=2, sublinear_tf=True)), ('clf', LogisticRegression(C=1.0, max_iter=1000)) ]) pipeline.fit(X_train, y_train)我这里为了简化没写GridSearchCV,实际调参时是用GridSearchCV跑了一轮。四个模型的实验结果如下,以测试集宏观F1为准:
| 模型 | 准确率 | 宏平均F1 | 说明 |
|---|---|---|---|
| 朴素贝叶斯 | 85.2% | 84.1% | 训练最快,但类别间区分较差 |
| 线性SVM | 87.6% | 86.9% | 稳定,适合TF-IDF稀疏特征 |
| 逻辑回归 | 88.1% | 87.5% | 与SVM相当,输出概率更方便 |
| 随机森林 | 83.0% | 81.8% | 高维稀疏特征下不如线性模型 |
| TextCNN(参考) | 88.9% | 88.2% | 小胜,但训练和调参成本高 |
这个表在答辩时一放,非常直观。可以看到经典模型的F1基本都在85%上下,已经够用了。TextCNN虽然略高,但需要调整embedding维度、卷积核大小、学习率一堆东西,收益却只有1个百分点左右,这正好支撑了“经典方案为主、深度学习作为扩展”的选型判断。
4.2 关键参数与调参心得
参数这部分,我记录几个实操结论,都是网格搜索之后得到的实际感受。
朴素贝叶斯的平滑系数alpha默认是1.0,但我在验证集上试了0.1、0.5、1.0、2.0,alpha=0.5时F1略好。原理上alpha越大越平滑,越适合样本很少的词;alpha变小则更依赖频率统计。对于数据量不算大的场景,0.5更合适。
线性SVM的C值,C越小正则化越强。网格搜索发现C=1.0附近就够,C调大到10,F1几乎没变化,反而增加过拟合风险。逻辑回归的正则化强度C是重点,scikit-learn中的C是正则化强度的倒数,C越小惩罚越强。文本特征维度高,不加以正则化很容易过拟合,C=1.0是个比较稳健的起点,实际调参时从0.1到10各跑一遍,选验证集F1最高的。
随机森林在文本特征上不太吃香,即使把n_estimators从100加到500,效果也上不去,原因是文本TF-IDF特征高维稀疏,树模型对这种输入不敏感。这个结论可以用来回答“为什么不用XGBoost”这类问题,本质上是一样的。对了,还有一个容易忽略的坑:LinearSVC没有predict_proba方法,如果你需要输出置信度,要么用逻辑回归,要么用SVC带上probability=True,否则接口会直接报错。
4.3 评估结果与错误案例分析
光看F1还不够,我建议每个做这个题的人都输出一份混淆矩阵。我用的是10个类别,混淆矩阵能直接暴露模型在哪两类之间经常搞混。我当时的错误样本集中在这几组:经济管理类和政治法律类之间,书名里出现“金融”“政策”这类词的时候,两类特征高度重叠;工业技术类和数理化类之间,涉及“物理”“化学”“工程”的书,界定本身就模糊;文学类和历史地理类之间,类似《明朝那些事儿》这种叙事性历史书,模型更倾向分到文学。
对这类错误,我的处理方法是:把模糊类别的训练样本补上更多带上下文的简介,同时考虑在输出层设置“低置信度转人工”的兜底规则。也就是说,如果最大预测概率低于0.6,接口返回“无法确定,建议人工复核”。这个设计在答辩时非常加分,因为它体现了对业务风险的考虑,而不是只会调模型。
5. 系统实现与演示
5.1 后端分类接口设计
模型训练完,我把它保存成本地文件,然后写一个Flask服务。接口设计遵循两个原则:输入格式简单,输出信息完整。请求只需要一个JSON,字段是title和desc,简介可以缺省;响应当中除了预测类别,还要带上各类别的前三名概率,方便前端做可视化。
from flask import Flask, request, jsonify import joblib app = Flask(__name__) model = joblib.load('model.pkl') vectorizer = joblib.load('vectorizer.pkl') label_map = joblib.load('label_map.pkl') @app.route('/predict', methods=['POST']) def predict(): data = request.get_json() text = data.get('title', '') + ' ' + data.get('desc', '') words = cut_text(text) vec = vectorizer.transform([' '.join(words)]) proba = model.predict_proba(vec)[0] top_indices = proba.argsort()[::-1][:3] result = { 'category': label_map[model.predict(vec)[0]], 'confidence': float(proba.max()), 'top3': [ {'label': label_map[i], 'prob': float(proba[i])} for i in top_indices ] } return jsonify(result) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)这个接口还做了一件比较实用的事:把预测概率低于阈值的结果标记成建议人工复核。实际返回的JSON大概是这样的:
{ "category": "工业技术", "confidence": 0.873, "top3": [ {"label": "工业技术", "prob": 0.873}, {"label": "数理化", "prob": 0.102}, {"label": "经济管理", "prob": 0.025} ], "suggest_review": false }前端根据confidence和suggest_review字段改变显示颜色,用户可以一眼看出哪些是机器拿得准的、哪些是机器拿不准的。
5.2 前端页面与交互
前端我不建议写得太复杂,一个输入框加一个结果卡片就够了。用户在输入框填书名,可选填内容简介,点“分类”按钮后发AJAX请求,页面展示预测类别、置信度、TOP3概率条。如果你想更贴近真实业务,还可以加一个“保存到待标注库”的按钮,把用户输入和模型结果写入后端的一个CSV文件,这些数据可以积累下来作为后续增量训练的数据源。
页面实现的细节不多,但有一个点值得说:置信度展示的数值不要用百分比整数,保留两位小数,同时用一段文字解释这个置信度的含义,比如“模型对这条预测的把握为87.3%”。答辩现场演示的时候,输入一条边界模糊的书名,模型输出低置信度并提示人工复核,这个交互比任何图表都更有说服力。
5.3 整套流程如何串联演示
演示前我建议准备三条用例。一条典型用例,比如“Python机器学习实战”,模型应高置信度输出工业技术类;一条边界用例,比如“人类简史:从动物到上帝”,模型和人都需要纠结历史还是文学;一条异常用例,比如输入乱码或者空文本,系统应给出友好提示而不是报500。
这三条用例分别对应“模型有效”“模型有判断边界”“系统有容错”三个层次,答辩演示时讲完这三条,基本就能把评委的关注点从“准确率多少”引导到“系统设计是否完整”上来。演示页面不用花哨,但要把这三条用例提前存成快捷按钮,现场演示的时候点击即可,避免因为网络缓慢或手误影响节奏。不建议现场临时打字输入,哪怕只是几秒钟的空窗,也会让答辩气氛变得尴尬。
6. 避坑指南与答辩准备
6.1 实操中踩过的坑
这个项目从头到尾,我踩过不少坑,挑几个最典型的分享。
第一个是编码问题。数据从网页爬下来时,很多中文是GBK编码,Python里读文件会直接报错或者出现乱码。统一在读取时指定encoding='utf-8'或者'gbk',并且保存清洗结果时全部转成UTF-8。看似基础,但卡住过不少时间。
第二个是jieba加载自定义词典的路径问题。如果代码里写的是相对路径而Flask从别的目录启动,会找不到词典文件。当时我把所有资源文件放到同一个文件夹,用os.path.join(os.path.dirname(__file__), ...)来定位,彻底解决了路径地狱。
第三个是类别不平衡被低估。之前说数据构建时做了均衡化,但线上真实场景是偏斜的,所以测试时我又单独构造了一个模拟真实分布的测试集,结果F1从87%掉到了81%。这说明均衡训练集上表现好不等于真实场景好用。答辩的时候我主动把这个差距讲出来,并说明后续用重采样、类别权重等手段来缓解,老师普遍认可这个思路。
第四个是模型文件加载慢。joblib保存的模型和TF-IDF向量器合计不到100MB,但每次启动都加载一遍,本地开发用Flask的debug模式会有重复加载的问题。可以用一个全局字典做缓存或者写成懒加载,接口首调慢一点,后续就快了。
6.2 答辩高频问题与回答思路
准备工作做完,最后一步就是答辩。这里我整理了一些评委大概率会问到的问题,以及我当时回答的核心思路:
| 问题 | 回答要点 |
|---|---|
| 为什么不用BERT/深度学习? | 数据量和算力限制,经典模型效果已达标;深度学习作为扩展对比实验已做过,收益小于成本 |
| 你的准确率是不是数据泄漏刷出来的? | 强调训练/验证/测试严格隔离,TF-IDF已在Pipeline里只对训练折fit |
| 类别不平衡怎么处理? | 数据构建阶段均衡采样;模型层面用类别权重;真实分布测试集上主动评估差距 |
| 模型可解释性怎么理解? | 逻辑回归/SVM的权重可以反查重要特征词;错误案例有归因分析 |
| 系统上线还差什么? | 增量学习、低置信度人工复核闭环、数据监控与告警 |
| 如果数据量扩大100倍,方案要改什么? | 经典模型可能遇到训练瓶颈,需要引入分布式训练或预训练模型,特征层也可以考虑BM25等替代 |
最后一个问题尤其重要,因为评委想要确认你是真懂而不是照着教程抄。我当时把“数据量扩大后方案怎么演进”作为自己的扩展思路主动讲出来,明显感觉到答辩气氛变得轻松了。
还有一个答辩硬技巧:PPT里的实验结果表格,一定要把“失败”的实验也列上去。比如我对比了Word2Vec的均值池化特征,效果比TF-IDF差,这个结果本身也是有效的实验结论。展示失败尝试可以让评委看到你的实验过程是真实的,而不是只挑好结果说。
做完这个项目,我个人最深的体会是,文本分类这件事,80%的时间花在数据和特征上,20%的时间花在模型上。很多同学一上来就追新模型,结果被数据预处理卡住了好几个星期。这个题目的好就好在,它难度适中,每一层都有可以深挖的空间:数据层可以谈清洗和分布,特征层可以谈TF-IDF参数和词粒度,模型层可以谈对比实验和误差分析,系统层可以谈接口设计和容错机制,答辩时可以讲的东西非常多。如果你也选了类似的题目,我的建议是先定一条主线,比如“经典机器学习为主、深度学习扩展为辅”,然后每一步都保留实验记录,最后你会发现,答辩其实是一个把你走过的弯路讲清楚的过程。