☰
用Python实现中文书名短文本标签分类:从N-gram到FastText
2026/10/3 3:03:08 网站建设 项目流程

简介:面向Python自然语言处理初学者及课程设计开发者,这套资源提供了一种通过作品名称预测小说类型(标签分类)的完整实现方案。项目基于FastText文本分类模型,配合爬虫采集小说网站收藏榜数据作为训练语料,使用jieba分词提升准确率,可帮助读者快速掌握从数据采集、预处理到模型训练与评估的典型流程。

压缩包共15个文件,大小7.21MB,主要包含txt格式的原始语料与模型文件、py格式的爬虫与训练脚本、docx格式的设计报告以及md说明文档,结构清晰,便于按模块学习。目前已有319人学习下载。

资源附带完整设计报告Word和项目源码,涵盖数据集收集、FastText训练、分词优化等关键环节,既可用作课程设计参考,也能为后续文本分类任务提供可直接改造的代码基础。

1. 用书名判断小说类型:一个短文本标签分类问题,值得认真做

给一千个书名判断小说类型,人工能猜个七七八八,但把它写成规则就会翻车:以“XX之XX”命名的既可能是玄幻也可能是古言,《我在异界开网吧》这种书名更是让关键字规则直接失灵。这个需求本质上是一个短文本标签分类问题——输入小说作品名字,输出玄幻、都市、悬疑、科幻等类型标签,用于书库打标、推荐系统冷启动和编辑审稿辅助。用 Python 做这件事不需要上重模型,关键是数据口径、短文本特征和置信度兜底这三件事,下文按这个顺序展开。适合的读者是刚接触标签分类的入门者,以及被书名打标方案选型卡住的一线开发。

2. 攒训练数据:三种来源、一套清洗脚本

标签分类的第一道坎不是模型,而是没有标签。书名分类不像通用情感分析有现成数据集,网文平台的公开榜单就是最容易被忽略的免费资源。这三条路可以并行:公开榜单抓取、种子书单人工标注、多源合并清洗。数据量不需要很大,几千本带标签的书名已经能训练出一个可用的分类器,重点在标签口径统一。

2.1 公开榜单抓取:把平台分类直接当训练标签

起点、晋江、番茄这些平台的公开榜单页面会同时给出书名和分类,抓取思路完全一样:按分类页拉书名,把分类当标签存下来。要注意抓取频率和对方页面的结构,示例里用一个示意 JSON 接口,实际接入时按目标站点的真实返回改解析部分。

import requests import time HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; book-corpus/0.1)"} def fetch_books_by_label(url_tpl, label, pages=5, delay=1.5): """url_tpl 是带 page 占位符的分类页模板,label 是要存储的标签。""" books = [] for page in range(1, pages + 1): try: resp = requests.get(url_tpl.format(page=page), headers=HEADERS, timeout=10) except requests.RequestException as exc: print(f"[warn] page {page} error: {exc}") break if resp.status_code != 200: print(f"[warn] page {page} status={resp.status_code}") break # 以 JSON 接口为例:实际页面可能是 HTML,需要按结构解析 for item in resp.json().get("books", []): books.append({"title": item["title"], "label": label}) time.sleep(delay) # 对榜单页保持节奏,别集中打 return books samples = fetch_books_by_label( "https://example.com/rank?cat=xuanhuan&page={}", label="玄幻", pages=3, delay=1.5, )

代码逻辑很简单:把“按标签拉书名”封装成函数,错误处理是必要的,榜单页随时可能改结构或暂时不可达。delay 参数控制请求间隔,小批量抓取时 1 到 2 秒足够,再慢一点损失也不大。抓回来的数据统一成(title, label)的结构,后续清洗、合并都围绕这个结构做。抓取前先看对方有没有提供公开导出或开放接口,优先用官方渠道而不是硬抓页面。

2.2 种子书单人工标注:小样本起步的最快路径

人工标注不丢人。抓来的榜单分类是平台口径,平台分的“玄幻”可能还混着“奇幻”,你需要一份干净的小种子集做验证集。人工标 500 个书名的成本大约半小时到一小时,换来一份可信的测试集,这个投入一定值得。

# seed_labels.csv # title,label # 斗破苍穹,玄幻 # 庆余年,架空历史 # 白夜行,悬疑 # 三体,科幻
import pandas as pd seed = pd.read_csv("seed_labels.csv") print(seed["label"].value_counts())

先value_counts()看一眼每个类别的样本量,少于 20 条的类别建议先合并到上级类。种子集不要贪多,保证准确即可;标注时注意时间跨度,尽量混入老书和新书,不然模型会学到“某个时期的命名习惯”而不是“这类书的命名习惯”。这份种子集还有个用途——作为多源数据冲突时的仲裁标准,比任何平台口径都干净。

2.3 清洗与合并:标签映射、去重与文件组织

书名里混着“(全本)”“番外”“第一部”这类噪声,标签又有“东方玄幻”“玄幻魔法”这种同义差异。写一套清洗脚本统一口径,这一步直接决定模型上限。

import re import unicodedata def normalize_label(label: str) -> str: """把平台标签映射到统一口径,是标签分类的命门。""" mapping = { "东方玄幻": "玄幻", "玄幻魔法": "玄幻", "都市生活": "都市", "现代都市": "都市", "科幻末世": "科幻", "未来世界": "科幻", } return mapping.get(label.strip(), label.strip()) def clean_title(raw: str) -> str: raw = unicodedata.normalize("NFKC", raw) # 全角转半角 raw = re.sub(r"(.*?)|\(.*?\)", "", raw) # 去掉括号说明 raw = re.sub(r"[【】「」\[\]]", "", raw) # 去掉装饰性括号 raw = re.sub(r"(番外|外传|第[一二三四五六七八九十百0-9]+[卷章篇部]).*", "", raw) return raw.strip()

逻辑说明:normalize_label做标签层面的合并,比在特征层面解决同义标签问题便宜得多;clean_title的顺序有讲究,先 NFKC 全角转半角,再删括号,最后删“番外”后缀,顺序反了容易误伤“第 X 卷”在书名中部的合法用法。清洗完之后抽 20 条人工检查,确认没把“凡人修仙传之仙界篇”这种系列作切坏。

多源合并时同一本书可能出现在两个平台,标签还互相矛盾。按规范化后的书名去重,冲突时以种子集的人工标签为准;没有种子标签的,多数来源投哪个就取哪个。去重逻辑单独输出一份dedup_log.csv,方便回溯哪些样本被合并了。文件组织一般用data/raw/存抓取原始结果,data/clean/存清洗后的 CSV,label_map.json存标签映射表,模型产物放model/。组织清楚了,后面迭代时才不用重新爬数据。

3. 特征工程:书名太短,分词反而帮倒忙

给长文本做分类,分词加词频是标准动作,但换到书名上,分词器基本派不上用场。书名通常只有 2 到 15 个字,分词器对“斗破苍穹”“大王饶命”“赘婿”这类专名和自造词基本无能为力,一句话没有就没有上下文语境。这个阶段的核心决定是:放弃词级特征,改用字符级特征。

3.1 为什么不在书名上分词:缺少语境,词表形同虚设

拿一段十万字的小说明显做分类,分词后每个词都有稳定的统计量,词频能支撑模型训练。但书名只有十个字左右时,词的统计量稀疏到无法训练,而且分词结果大多是不可解释的碎片:一部“斗破苍穹”被切成“斗破”“苍穹”,两个字组本身不具备类型区分度。字符 n-gram 不一样,它把“斗破苍穹”切出“斗破”“破苍”“苍穹”三组二元片段,“苍穹”这种字组在玄幻里出现频率远高于都市文,模型能慢慢学会这类模式。

还有一个隐藏优势是泛化能力。新书用了完全没见过的自造词,分词器会把它切成单字丢失语义,而字符 n-gram 仍然能和训练样本共享一部分片段。比如“斗破”没出现过,但“破苍穹”里的“苍穹”出现过,特征就有了。短文本分类里,字符级方案比词级稳定得多,这是经验里最值得先确认的结论。

3.2 字符 n-gram + TF-IDF:短文本分类的默认组合

选用TfidfVectorizer直接做字符 n-gram 特征化,参数设定对最终效果影响很大,下面这组参数是短文本场景下的常见起点。

from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer( analyzer="char_wb", ngram_range=(2, 4), min_df=2, max_df=0.9, sublinear_tf=True, lowercase=True, ) X = vectorizer.fit_transform(cleaned_title_list) print(X.shape) # (样本数, 特征数),特征全是字符 n-gram

参数说明:

  • analyzer="char_wb":在词边界内滑动字符窗口,避免跨词拼出不存在的字组。中文书名没有空格分词,效果和char接近,但输出更干净。
  • ngram_range=(2, 4):2-gram 能捕捉“重生”“系统”“赘婿”,3-gram 能捕捉“重生之”“赘婿的”,4-gram 捕捉一些整体结构。再往上在短文本里重复率太低,基本上属于噪声。
  • min_df=2:只出现一次的字组过滤掉,能明显降维并防过拟合。
  • max_df=0.9:几乎所有书名都含的通用字组,比如“之”,在 TF-IDF 里权重已经很低,这里再兜底一次。
  • sublinear_tf=True:用 1+log(tf) 压低高频字的绝对优势,对短文本尤其重要。

fit_transform后打印X.shape是个好习惯。书名样本一般在几千到几万条,特征数常常到几万维,属于合理范围,不需要额外降维。

3.3 加入长度、标点与关键词后缀等手工特征

光靠 n-gram 会丢失一些全局信号,比如书名长度、“之”字结构、是否含数字、是否以“记/传/录”结尾。这些信号对人工判断很直觉,模型却容易被 n-gram 稀释掉,显式拼进去更直接。

import numpy as np import re from scipy.sparse import hstack def handmade_features(titles): n = len(titles) feats = np.zeros((n, 5)) for i, t in enumerate(titles): feats[i, 0] = len(t) # 书名长度 feats[i, 1] = 1 if "之" in t else 0 # “之”字结构常见于古言、玄幻 feats[i, 2] = 1 if re.search(r"\d", t) else 0 # 数字常见于悬疑、末世 feats[i, 3] = 1 if any(k in t for k in ["重生", "系统", "开局"]) else 0 feats[i, 4] = 1 if any(t.endswith(e) for e in ["记", "传", "录", "志"]) else 0 return feats H = handmade_features(cleaned_title_list) X_full = hstack([X, H]) print(X_full.shape)

逻辑说明:手工特征不是必须的,但加上之后在少量样本上通常能提升 1 到 3 个点的 macro-F1。hstack要求两侧样本数一致、顺序对齐,数据清洗后不要重新打乱再拼。拼完的特征矩阵直接存成.npz,下次训练直接加载,避免每次重复计算。特征做到这步,调整空间已经不大,下一步把精力放到模型选型上。

4. 建模与调参:逻辑回归打底,FastText收尾

书名标签分类的特征维度不高、样本量不大,模型选型的核心不是追求多复杂的结构,而是“在小数据上稳定、可解释、好部署”。实际中我最常用的是两套方案:sklearn 的逻辑回归作为基线,FastText 作为最终生产模型。

4.1 多分类逻辑回归:小数据上的可靠基线

逻辑回归在小样本、高维稀疏特征上表现稳定,而且训练速度快,参数解释直观。

from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.metrics import classification_report X_train, X_test, y_train, y_test = train_test_split( X_full, y, test_size=0.2, stratify=y, random_state=42 ) clf = LogisticRegression( C=1.0, solver="liblinear", max_iter=2000, class_weight="balanced", random_state=42, ) clf.fit(X_train, y_train) y_pred = clf.predict(X_test) print(classification_report(y_test, y_pred, zero_division=0))

参数说明:solver="liblinear"对几千样本几万特征在几秒内跑完;class_weight="balanced"用类别频率的倒数给样本加权,是处理类别不平衡最省事的一招;C 先用 1.0,等看过 F1 再往 0.1 到 10 的方向搜。classification_report要逐类看 precision 和 recall,书名分类通常会出现某类 precision 高但 recall 低的组合,原因不是模型坏,而是这个类的命名特征太窄,具体要看混淆矩阵。

4.2 FastText:自带的子词就是书名特征

FastText 在短文本分类上是性价比很高的选择,它把字符子词嵌入学进向量里,相当于把上一章的字符 n-gram 思路内置到模型训练中,而且对未登录词更加友好。

import fasttext # 训练数据格式:__label__玄幻 斗破苍穹 with open("train_fasttext.txt", "w", encoding="utf-8") as f: for title, label in zip(train_titles, y_train): f.write(f"__label__{label} {title}\n") model = fasttext.train_supervised( input="train_fasttext.txt", lr=0.5, epoch=25, dim=100, wordNgrams=2, minn=2, maxn=4, loss="softmax", labelPrefix="__label__", thread=4, )

参数说明:lr=0.5是默认值,可以直接用;epoch 在短文本任务 20 到 30 轮通常收敛,按验证集 F1 早停即可。wordNgrams=2让模型把相邻词作为特征,等价于 2-gram;minn和maxn控制子词范围,读入 2 到 4 个字符的片段,和 sklearn 那边的char_wb思路一致。loss="softmax"适合标签数几十个的情况,标签上百个再考虑hs层次 softmax。训练后model.test("valid.txt")会输出样本数、P@1、R@1,对单标签分类来说这两个值相等。

两个方案的差异如下:

对比项sklearn 逻辑回归FastText
特征来源Tfidf 字符 n-gram子词嵌入 + 词 n-gram
训练速度秒级到分钟级秒级
模型体积稀疏矩阵几 MB10 MB 左右
解释性系数可查、可调试语义向量,解释弱
适合场景调特征、看误判原因批量打标、部署简单

4.3 评估别只盯准确率:macro-F1、混淆矩阵与置信度分布

标签分类最常见的评估误区是只看整体准确率。类型分布天然不均衡,“都市”类样本多,模型只要全猜都市就能拿到不错的准确率,但玄幻、悬疑的召回会很难看。所以要看 macro-F1,也就是每个类的 F1 取平均,类别不平衡下不会虚高。

from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import matplotlib.pyplot as plt cm = confusion_matrix(y_test, y_pred, labels=clf.classes_) disp = ConfusionMatrixDisplay(cm, display_labels=clf.classes_) disp.plot(xticks_rotation=45) plt.tight_layout() plt.savefig("confusion_matrix.png", dpi=150)

看混淆矩阵比看总分重要。玄幻和仙侠的混淆、悬疑和科幻的混淆是用户最容易挑刺的地方。如果错误大量集中在某一对类,考虑合并标签。还要看置信度分布:从predict_proba里取出每个预测的最大概率画直方图,低置信度样本比例高说明特征不足,这时候的优先级是补数据、调特征,最后才是换模型。换更大的模型在书名分类上收益有限,因为输入的信息量就这么多。

FastText 和 sklearn 的对比要公平,固定同一个训练/验证划分,两份模型分别测试,直接对比 macro-F1。这一点很容易被忽视,换特征、换模型时划分一变,对比结果就失真了。

5. 避坑清单:从 zip 解压到上线的六个翻车点与对策

这章是实际落地过程中的血泪经验。问题分布在全链路:数据包解压、标签口径、类别边界、数据增强、类别不平衡、模型选型。每一条都按现象、原因、解决三步写。

5.1 zip 解压后中文乱码,训练脚本读不到文件

现象:Windows 上把项目 zip 包解压后,data/raw下中文文件名的 CSV 变成乱码,或者打开 CSV 后中文标题全部变成“锟斤拷”。更隐蔽的情况是包被打了伪加密标记,普通解压工具直接弹密码框。

原因:打包时用了 GBK 编码存文件名,Python 的 zipfile 模块默认按 cp437 解码文件名,遇到中文就错乱;内容本身如果是 GBK 写盘的旧 CSV,用 UTF-8 读取也会乱码。伪加密则是 zip 的加密标志位被置位但实际没有加密,工具会误判。

解决:解压时用 zipfile 手动逐条解出,按编码恢复文件名。

import zipfile with zipfile.ZipFile("book-type-classifier.zip") as zf: for info in zf.infolist(): # 文件名可能是 gbk 编码存进 zip 的,先尝试解码修复 try: name = info.filename.encode("cp437").decode("gbk") except (UnicodeDecodeError, UnicodeEncodeError): name = info.filename zf.extract(info, "unpacked") # 若 name 与实际路径不一致,再按需重命名

更省事的做法是用 7-Zip 打开自动识别编码,解压一次完事;但流程要脚本化时,上面的处理躲不掉。CSV 读取用encoding="utf-8-sig"或encoding="gbk"都试一遍,看哪种不乱码,没有绝对规律,取决于打包人的环境。

5.2 多源标签口径不一致,模型学出“来源特征”

现象:从两个平台抓数据,训练出的模型对某一类特别准,换到另一个平台的新书上一测掉点严重。

原因:平台 A 把“玄幻魔法”叫玄幻,平台 B 可能把它叫奇幻;数据的来源差异被模型学成了隐藏特征,模型其实在记来源而不是记类型。

解决:进入训练前强制统一标签映射表。把合并后的数据按来源、标签列个透视表,肉眼排查同一批书名在两个来源下的标签差异,冲突样本以种子集为准。训练完再做一次按来源划分的评估,如果某个来源的准确率明显偏低,回查映射表。标签映射这种基础文件用 JSON 单独维护,不要散落在脚本里。

5.3 类型边界模糊:玄幻与仙侠、悬疑与科幻互相混淆

现象:混淆矩阵里玄幻和仙侠互相错认,悬疑里混入科幻;编辑人工看也觉得“这本书两类都算”。

原因:类型边界本身是主观的,跨类作品真实存在。这时候不是模型坏了,而是标签体系设得太细。

解决:两个方向选一个。要么合并近似类为“幻想”“悬疑科幻”等上位类;要么接受这种不确定性,把 top3 预测展示给人工,而不是只给一个结果。标签分类的交付物不是唯一正确答案,而是一组候选加置信度,这比硬要模型给一个高置信度的“正确”标签更务实。

5.4 数据增强做过头,生成一堆不像书名的书名

现象:为了凑样本对“爽文”类做同义词替换增强,结果生成“赘婿:从退货开始逆袭”这类平台根本不会上架的书名,模型在真实新书上反而掉点。

原因:书名的“像不像书名”本身就是模型要学的分布,随机替换破坏了原始命名分布,模型被带偏。

解决:书名增强只做低风险操作,比如后缀“记/传/录”互换、在已有种子书名的前后缀里做拼接;增强样本占总样本比例控制在 20% 以内,每 50 条人工抽检一次。短文本增强宁缺毋滥,这是经验里最深刻的一条。

5.5 小样本类别被大类别吞掉,class_weight 救不回来

现象:“体育”“游戏”类只有几十个样本,开class_weight="balanced"后 recall 涨了,precision 却跌到看不懂;或者模型干脆把所有书都判成“都市”。

原因:类别不平衡时加权只是改变了决策阈值,特征本身不够时小类别会被噪声主导。几十个样本很难撑起一个类别的分布。

解决:最务实的做法是给样本量低于下限的类别合并或标记为“其他”,让主类别先做准;等以后数据攒够了再拆出来训练。小类别不是目标输出时,坚决不硬训。

5.6 全量调用大模型打标:效果好,但账单和延迟不划算

现象:有人会绕过训练,直接把书名丢给大模型 API,回传一个标签,首日效果惊艳,到了百万级书库时发现 API 费用和限流撑不住。

原因:标签分类是高频低价值动作,单次判断不值得单次 API 调用。大模型真正的价值在处理边界歧义,而不是批量重复劳动。

解决:批量用本地小模型跑,只有当置信度低于阈值时才丢给大模型或人工做二次判断,这样既能用上大模型的语义能力,又把调用量压到总量的 10% 到 20%。这个思路在下一章展开。

6. 落地技巧:置信度门槛配合 top3 候选,让编辑做兜底

训练完模型只是第一步,如何接入业务才是决定这个方案值不值得投入的关键。我现在的做法是:永远不返回单一标签,而是返回 top3 候选和各自的置信度,置信度低于阈值时直接标记为“待人工确认”。

import numpy as np TOPK = 3 CONF_THRESHOLD = 0.6 def predict_topk(model, vectorizer, title, classes, topk=TOPK): vec = vectorizer.transform([title]) proba = model.predict_proba(vec)[0] top_idx = np.argsort(proba)[::-1][:topk] return [(classes[i], round(float(proba[i]), 3)) for i in top_idx] def tag_book(model, vectorizer, title): candidates = predict_topk(model, vectorizer, title) if candidates[0][1] < CONF_THRESHOLD: return {"decision": "needs_review", "candidates": candidates} return {"decision": candidates[0][0], "candidates": candidates}

这段代码把单标签模型的输出改造成结构化结果,方便接进后台的待审核列表。阈值设 0.6 是经验值,数据噪声大时调到 0.7 更稳,代价是“待人工确认”的比例会上升,需要结合编辑部的人力成本来衡量。输出直接落成 JSON,入库、审核、展示都能复用。

每次训练完都固定划分和阈值,把混淆矩阵图和 top3 抽检结果一起归档,下次调特征后直接对比。书名标签分类做得再漂亮,最终检验标准不是测试集多个零点几个点,而是随机抽 50 本新书人工盲测能猜对多少。这个习惯帮我发现过不少次“测试集虚高、新书掉点”的问题,根源基本都是数据口径漂移。

如果只能从这篇笔记带走一个技巧,就是置信度门槛加 top3 候选。它能同时解决类型边界模糊和人工审核成本两个问题,也是这个标签分类方案能真正用起来的最后一环。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询