1. 图书分类任务到底在做什么
图书分类这个事,乍一听像是图书馆管理员才关心的活计,但放到机器学习语境里,它其实是一个非常典型的多分类任务。你手头有一堆书,每本书有书名、作者、简介、标签、出版社、出版年份等信息,目标是预测这本书属于哪个类别——文学、科技、历史、艺术、少儿等等。听起来简单,但真正动手做一遍,你会发现从数据清洗到特征工程、从模型选型到调参优化,每一步都有坑。
我之所以拿图书分类来练手,是因为它的数据形态足够丰富:既有结构化字段(出版年份、页数),又有文本字段(书名、简介),还有类别型字段(出版社、作者国籍)。这意味着你可以在同一个任务里同时练到特征工程、传统机器学习模型和深度学习模型三条技术路线,性价比极高。
这篇文章适合谁看?如果你已经学过逻辑回归、决策树这些基础算法,但不知道真实项目里怎么把它们串起来,那这篇内容就是写给你的。如果你已经做过一些Kaggle入门赛,想看看工业界实际做文本分类时有哪些细节考量,同样可以拿走不少东西。我会从最朴素的特征工程开始,一步步过渡到GBDT,再切到深度学习方案,每一步都给出可复现的操作和踩坑记录。
2. 数据理解与预处理:别急着上模型
2.1 先搞清楚你手里有什么
拿到图书数据的第一件事不是写代码,而是看数据。我见过太多人一上来就train_test_split然后跑模型,结果准确率卡在60%上不去,回头一看发现类别标签里有重复项、文本字段里混着HTML标签、出版年份有负数。
假设我们的数据集包含以下字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| title | 文本 | 书名 |
| author | 类别 | 作者 |
| publisher | 类别 | 出版社 |
| pub_year | 数值 | 出版年份 |
| pages | 数值 | 页数 |
| summary | 文本 | 内容简介 |
| category | 类别 | 目标标签 |
第一步操作:统计每个类别的样本数量。如果发现某个类别只有十几本书,而最多的类别有几千本,那这就是典型的类别不平衡问题。处理方式后面会讲,但你必须先知道它存在。
第二步:检查缺失值。文本字段缺失和数值字段缺失的处理策略完全不同。summary缺失率超过30%的话,这个字段可能就不太能用了;pages缺失可以用中位数填充,但要注意填充后是否引入偏差。
2.2 文本清洗的实操细节
图书简介这类文本,常见的问题包括:
- HTML标签残留:
<p>这是一本...</p> - 特殊符号:
&、 - 多余空白:连续空格、换行符
- 中英文混排:有些简介里夹杂英文原版书名
清洗代码大概长这样:
import re def clean_text(text): if not isinstance(text, str): return "" text = re.sub(r'<[^>]+>', '', text) # 去HTML标签 text = re.sub(r'&[a-zA-Z]+;', ' ', text) # 去HTML实体 text = re.sub(r'\s+', ' ', text) # 合并空白 text = text.strip() return text注意:不要过度清洗。有些人会把所有标点符号都去掉,结果把“科幻小说”和“科幻小说集”之间的区别也抹掉了。标点在某些场景下是有信息量的。
2.3 标签处理与数据集划分
标签如果是中文类别名,建议先映射成整数编码。但要注意保存映射关系,后面预测结果要还原回可读标签。
划分数据集时,分层采样是必须的。sklearn的train_test_split自带stratify参数,直接用就行。如果类别极度不平衡,还要考虑是否对少数类做上采样或对多数类做下采样。我的经验是:先不采样,跑一版基线看看效果,如果少数类的召回率惨不忍睹,再考虑采样策略。
3. 特征工程:传统方法的灵魂所在
3.1 文本特征:从词袋到TF-IDF
图书分类里最强的信号往往来自文本字段。最朴素的做法是词袋模型,但词袋的问题是高频词会主导特征。TF-IDF通过引入逆文档频率来压制这种情况。
from sklearn.feature_extraction.text import TfidfVectorizer tfidf = TfidfVectorizer( max_features=5000, ngram_range=(1, 2), min_df=3, max_df=0.9, sublinear_tf=True )参数解释一下:max_features=5000控制特征维度,太大容易过拟合,太小信息不够;ngram_range=(1,2)表示同时考虑单个词和相邻词对,能捕捉“科幻 小说”这样的组合;min_df=3过滤掉出现少于3次的词,降噪;max_df=0.9过滤掉出现在90%以上文档中的词,这些词没有区分度;sublinear_tf=True用对数缩放词频,避免某个词重复出现太多次导致权重爆炸。
3.2 类别特征编码:目标编码的坑与技巧
出版社、作者这类高基数类别特征,直接用One-Hot会导致维度爆炸。目标编码是常用替代方案:用该类别的目标均值来替换类别值。但直接替换会引入标签泄露,必须配合交叉验证。
from sklearn.model_selection import KFold def target_encode(train_df, test_df, col, target, n_splits=5): kf = KFold(n_splits=n_splits, shuffle=True, random_state=42) train_df[col + '_te'] = 0 for train_idx, val_idx in kf.split(train_df): means = train_df.iloc[train_idx].groupby(col)[target].mean() train_df.loc[val_idx, col + '_te'] = train_df.iloc[val_idx][col].map(means) overall_mean = train_df[target].mean() train_df[col + '_te'].fillna(overall_mean, inplace=True) test_means = train_df.groupby(col)[target].mean() test_df[col + '_te'] = test_df[col].map(test_means).fillna(overall_mean) return train_df, test_df实操心得:目标编码在树模型上效果通常不错,但在线性模型上要谨慎使用,因为它会改变特征的分布形态。
3.3 数值特征处理:分箱还是标准化
出版年份和页数这类数值特征,直接扔给树模型没问题,但给线性模型或神经网络就需要标准化。另外,出版年份可以考虑做分箱,比如按十年为一个区间,因为“2000年出版”和“2001年出版”在类别上的差异可能并不大,但“2000年”和“1990年”可能差异明显。
分箱的另一个好处是能处理非线性关系。比如页数在100-300之间可能是小说,300-500可能是教材,500以上可能是工具书,这种阶梯式关系用分箱比原始数值更容易被模型捕捉。
4. GBDT模型实战:从LightGBM调参到特征重要性分析
4.1 为什么选GBDT而不是随机森林
GBDT和随机森林都是树集成方法,但GBDT通过逐步拟合残差来降低偏差,通常在结构化数据上表现更好。LightGBM作为GBDT的高效实现,训练速度快、内存占用低,还支持类别特征直接输入,非常适合图书分类这种混合了文本和类别特征的任务。
4.2 特征拼接与模型训练
把TF-IDF矩阵、目标编码后的类别特征、数值特征拼在一起:
from scipy.sparse import hstack import lightgbm as lgb X_train = hstack([tfidf_train, other_features_train]).tocsr() X_val = hstack([tfidf_val, other_features_val]).tocsr() params = { 'objective': 'multiclass', 'num_class': num_classes, 'metric': 'multi_logloss', 'boosting_type': 'gbdt', 'num_leaves': 63, 'learning_rate': 0.05, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 5, 'verbose': -1 } dtrain = lgb.Dataset(X_train, label=y_train) dval = lgb.Dataset(X_val, label=y_val, reference=dtrain) model = lgb.train(params, dtrain, num_boost_round=1000, valid_sets=[dval], callbacks=[lgb.early_stopping(50)])4.3 调参的优先级
LightGBM参数很多,但真正影响大的就那么几个。我的调参顺序是:
num_leaves:控制模型复杂度,从31开始试,逐步增加到63、127learning_rate和n_estimators:两者要配合,学习率低就需要更多轮次min_data_in_leaf:防止过拟合的关键,类别不平衡时适当调大feature_fraction和bagging_fraction:增加随机性,提升泛化
踩坑记录:有一次我把
num_leaves设到255,训练集准确率99%,验证集只有72%。后来降到63,验证集反而升到78%。树模型不是越复杂越好。
4.4 特征重要性分析与迭代
训练完之后用model.feature_importance()看哪些特征贡献大。通常TF-IDF里的某些关键词会排在前列,但也要关注类别特征和目标编码特征。如果发现某个特征重要性异常高,要检查是否存在标签泄露。
5. 深度学习方案:从TextCNN到预训练模型微调
5.1 什么时候该上深度学习
传统方法在几千到几万条数据上通常够用。但如果数据量超过十万条,或者文本语义复杂(比如简介里大量使用隐喻、反讽),深度学习的优势就体现出来了。另外,深度学习方案对特征工程的依赖更低,端到端训练更省心。
5.2 TextCNN快速上手
TextCNN是文本分类里性价比很高的架构:嵌入层 + 多尺度卷积 + 全局池化 + 全连接。代码不长,但效果往往比TF-IDF+GBDT好一截。
import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, filter_sizes, num_filters): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (fs, embed_dim)) for fs in filter_sizes ]) self.dropout = nn.Dropout(0.5) self.fc = nn.Linear(num_filters * len(filter_sizes), num_classes) def forward(self, x): x = self.embedding(x) # (B, L, D) x = x.unsqueeze(1) # (B, 1, L, D) x = [torch.relu(conv(x)).squeeze(3) for conv in self.convs] x = [torch.max_pool1d(f, f.size(2)).squeeze(2) for f in x] x = torch.cat(x, dim=1) x = self.dropout(x) return self.fc(x)filter_sizes一般取2、3、4,分别对应二元、三元、四元词组;num_filters取64或128。嵌入维度128或256都行,数据量小的时候用128。
5.3 预训练模型微调:效果最好但成本最高
如果算力允许,用预训练语言模型做微调通常能拿到最好的效果。做法很简单:加载预训练模型,把分类头换成你的类别数,然后用小学习率微调。
from transformers import AutoModelForSequenceClassification, Trainer model = AutoModelForSequenceClassification.from_pretrained( 'bert-base-chinese', num_labels=num_classes )注意:微调时学习率要设得很小,通常2e-5到5e-5。太大容易灾难性遗忘,把预训练学到的语言知识全冲掉。
5.4 深度学习方案的坑
- 序列长度:图书简介长短不一,截断太短丢信息,太长显存吃不消。建议统计一下长度分布,取95分位数作为max_len。
- 类别不平衡:深度学习对不平衡更敏感,可以在损失函数里加类别权重,或者用Focal Loss。
- 过拟合:小数据集上微调预训练模型极易过拟合,早停和权重衰减要配上。
6. 模型评估与常见问题排查
6.1 别只看准确率
图书分类里类别不平衡是常态,准确率会被多数类主导。必须看宏平均F1和加权平均F1,以及每个类别的混淆矩阵。如果某个类别的召回率特别低,说明模型根本没学会识别它。
6.2 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 训练集准确率高,验证集低 | 过拟合 | 增加正则、减少模型复杂度、早停 |
| 所有样本预测为多数类 | 类别不平衡严重 | 类别权重、重采样、Focal Loss |
| 验证集准确率波动大 | 数据量小或划分不合理 | 交叉验证、分层采样 |
| 某些类别F1始终为0 | 特征区分度不够 | 检查该类别文本特征、增加外部特征 |
| 深度学习模型不收敛 | 学习率过大或过小 | 学习率预热、网格搜索 |
6.3 我踩过的三个坑
第一个坑:TF-IDF的max_features设太大。我一开始设了50000,结果LightGBM训练慢不说,验证集还过拟合。后来降到5000,效果反而更好。特征不是越多越好,关键是信噪比。
第二个坑:目标编码没做交叉验证。直接在整个训练集上算类别均值,导致标签泄露,验证集F1虚高到0.95,上线后直接掉到0.7。后来改成K折目标编码才正常。
第三个坑:深度学习模型没做学习率预热。前几个epoch损失直接爆炸,后来加了warmup,前10%的步数线性增加学习率,训练就稳了。
7. 方案选型与落地建议
7.1 三条路线的对比
| 维度 | 特征工程+GBDT | TextCNN | 预训练模型微调 |
|---|---|---|---|
| 数据需求 | 几千条即可 | 几万条 | 几千条(靠预训练) |
| 训练成本 | 低(CPU可跑) | 中(需GPU) | 高(需GPU) |
| 特征工程 | 依赖高 | 依赖低 | 几乎不需要 |
| 可解释性 | 强 | 弱 | 弱 |
| 典型F1 | 0.75-0.85 | 0.82-0.90 | 0.88-0.95 |
7.2 我的推荐路径
如果你刚开始做图书分类,我建议按这个顺序走:先用TF-IDF+LightGBM跑一版基线,把特征工程和评估流程跑通;然后上TextCNN,感受一下端到端训练的效果;最后如果算力允许,用预训练模型微调冲一波高分。每一步都要保留可复现的实验记录,方便对比。
7.3 后续扩展方向
图书分类做完之后,可以往几个方向延伸:一是做多标签分类,因为一本书可能同时属于“科幻”和“悬疑”;二是做层次分类,先分大类再分小类;三是引入封面图像特征,用多模态方案进一步提升效果。这些扩展都能在现有代码基础上迭代,不用推倒重来。
最后分享一个小技巧:不管用哪种方案,都先把验证集固定下来,所有实验都在同一个验证集上对比。我见过太多人每次调参都重新划分数据,结果实验之间根本没法比,白白浪费大量时间。