NLP实战这个系列我其实拖了很久才动笔。市面上的NLP教程并不缺缺的是那种真正把新手从第一行代码带到最后一个评估数字的完整指南。这一篇就从最经典的中文新闻文本分类开始用Python一步步实现语料读取、中文分词、特征构建、模型训练、结果评估最后让程序能自动把一篇新闻稿归入体育、财经、娱乐、科技等类别。任务不复杂但它把NLP实践里最重要的几个环节都串起来了而且不需要显卡不需要处理标注完全是普通笔记本就能跑起来的入门路线。这篇文章适合刚会用Python做数据分析、想进入NLP领域实践但一直不知道从哪下手的同学。1. 动手之前新闻文本分类到底在解一道什么题1.1 从新闻编辑部的日常说起如果你去过新闻网站的后台一定会对编辑打标签的场景有印象。每天几百篇稿件涌入待处理池编辑需要把它们归入不同的栏目体育、财经、娱乐、科技、房产、教育、健康、游戏、汽车、旅游。这个动作看似简单却要消耗大量人力。假设一个人处理一篇稿子平均花一分钟两千篇就是三十多个小时。遇到赛事密集的日子稿件量翻倍后台积压的未分类稿件越来越多。于是能不能让程序自动完成分类这个问题几乎会出现在每一个做内容的团队里。这个需求落到技术层面就是文本分类问题。所谓文本分类本质是给计算机一堆文章内容和正确类别的样本让它学到文章特征和类别标签之间的映射以后给一篇新文章就能自动预测类别。不需要一开始就啃很深的数学先把输入和输出的关系记住后面的步骤都是围绕它展开的。1.2 三个关键词语料、特征、模型整个新闻分类项目可以拆成三个关键词。第一是语料。语料就是训练模型用的数据也就是新闻文本本身。没有数据模型就是无米之炊。NLP实战很多问题会先死于数据环节这句话不是夸张。第二是特征。计算机不认识汉字它只认识数字。我们得想办法把一篇新闻里的文字转换成向量这个转换过程就是特征工程。特征做得好不好直接决定了模型的上限。第三是模型。拿到数字特征之后用算法学习类别之间的决策边界这叫训练模型。模型就像一位根据化验单判断病情的医生输入特征输出诊断——也就是带概率的类别预测。把这三个词记牢后面的步骤其实都是在反复填充这三个环节。1.3 为什么新闻分类最适合当中文NLP第一课我选择新闻分类作为NLP实战系列的第一篇有三个具体原因。第一数据不需要自己标注。公开的中文新闻语料随处可见类别标签天然存在几乎零成本就能得到一个可以训练的数据集。对比那些需要人工标注情感、实体的任务新闻分类的动手门槛低得多。第二结果可视化强。模型训练完之后准确率、F1、混淆矩阵这些指标一目了然马上就能看到自己的进展。对学习者来说看到数字变好是最直接的激励。第三流程完整且不依赖昂贵算力。新闻文本长度适中中文预处理要点齐全传统机器学习模型训练快普通电脑绰绰有余。先把这整套流程吃透后面再切换成深度学习模型你会发现只是换了特征和模型这两层整体流程依然成立。2. 环境准备与数据读取先把手里的牌摸清楚2.1 组建一套足够跑通全流程的Python环境开始写代码前先把环境装顺了。我在这次实战中使用的是Python 3.9依赖库主要包括做分词的jieba、做特征和模型的scikit-learn、处理表格数据的pandas、画图用的matplotlib。安装命令很简单pip install jieba scikit-learn pandas matplotlib很多刚接触NLP的同学会问现在不都是深度学习吗为什么不直接从PyTorch入门我的建议是先别急着上深度学习。第一传统机器学习在中小规模文本分类上效果丝毫不差而且训练速度快到可以随时试错第二把文本如何变成数字这个核心思路搞清楚比一开始就糊进神经网络的黑盒里重要得多。先能用最简工具把流程跑通后面上深度学习的时候迁移成本很低。2.2 一份结构规整的中文新闻语料文本分类的最优起点是一份结构规整的中文新闻语料。很多人的困惑不是工具而是不知道拿什么数据练手。我在这次实战中使用了一份高校公开的中文新闻语料里面已经按分类目录组织好了文件类别包括体育、财经、娱乐、科技、房产、教育、健康、游戏、汽车、旅游十个方向每个类别有四千篇左右总文本量约五万篇。这个规模对入门实验来说已经足够。为了保持训练速度每类取两千篇做一个约两万篇的平衡数据集。顺带提一个知识点类别之间样本量差距过大就叫类别不平衡。新手做实验时尽量保证各类别样本量接近这样得到的评估指标更能反映模型真实水平不会一开始就被数据坑了。2.3 写一个脚本把语料变成表格语料的存储形式通常是类别目录下的若干txt文件。第一步就是遍历目录把所有文本和它所在的文件夹名读取出来组装成表格。这样后面所有操作都可以基于pandas的DataFrame进行。import os import pandas as pd data_path path/to/news_dataset # 替换成你的语料路径 texts, labels [], [] for label_name in os.listdir(data_path): label_dir os.path.join(data_path, label_name) if not os.path.isdir(label_dir): continue files os.listdir(label_dir)[:2000] # 每个类别截取前2000篇 for file_name in files: file_path os.path.join(label_dir, file_name) with open(file_path, r, encodingutf-8, errorsignore) as f: texts.append(f.read()) labels.append(label_name) df pd.DataFrame({text: texts, label: labels}) print(df.shape) print(df[label].value_counts())这里用errorsignore原因很实际从网上下载的语料里时不时会混进非UTF-8编码的文件不处理的话程序会直接报错中断加上这个参数就能跳过那些损坏字节保证读取阶段不崩溃。跑完之后df.shape大概是(20000, 2)value_counts()能看到每个类别的篇数。拿到数据之后我强烈建议先打印几条文本看看比如print(df.iloc[0][text][:500])亲眼确认内容是正常的新闻稿件再继续往下走。这个习惯能避免你对着乱码数据折腾半天。3. 中文文本预处理分词与去停用词绕不开的两条路3.1 中文凭什么要先分词英文却不用英文文本自带空格比如I love natural language processing程序按空格一拆就得到了一个个单词中文没有这种天然分隔整个句子就是连续的字我爱自然语言处理。如果不分词按单字拆开就成了我 爱 自 然 语 言 处 理这样的特征几乎没法表达语义模型自然学不到东西。所以中文NLP的第一步几乎都是分词把句子切分成语义完整的词我 爱 自然语言处理。打个比方英文文本是超市已经切好的西瓜盒打开就能吃中文文本是整只西瓜必须先拿刀切开才能吃到瓜瓤。分词就是那把刀而程序里的jieba是最常用的那把。3.2 jieba的三种模式和默认选型jieba支持三种分词模式精确模式、全模式、搜索引擎模式。对文本分类任务来说默认用精确模式就够了。全模式会尝试把所有可能的词语组合都切出来结果里出现大量冗余词搜索引擎模式主要面向搜索场景。精确模式简洁且贴近真实语义。import jieba text 某厂商今天发布了新的笔记本电脑搭载了自研处理器 words jieba.lcut(text, cut_allFalse) print(words)输出结果大致是[某厂商, 今天, 发布, 了, 新, 的, 笔记本电脑, 搭载, 自研处理器]。这里我特意用lcut而不是cut因为lcut直接返回列表处理起来更直观。看输出可以注意到里面出现了了的这类没有实际区分度的词它们几乎出现在每一篇文章里对分类决定几乎没有贡献。这些词就是停用词下一步我们要把它们过滤掉。如果分词结果里缺了专业词汇比如自研处理器被切成了自研和处理器可以用jieba.add_word(自研处理器)补充词汇量多时则使用load_userdict加载自定义词典。3.3 停用词表之外真正有效的预处理组合停用词表是网上很容易找到的资源核心作用是把的、了、在、是、和、与、中这类高频无意义词过滤掉。但我建议在使用别人的停用词表时结合自己语料实际情况增补。新闻文本里常常出现的报道记者新闻等词在几乎所有类别里都很高频保留它们对分类没什么帮助往往也可以忽略。同时预处理还要处理HTML标签和噪声字符。许多新闻语料里残留着网页爬取时的HTML标签、网址、乱码符号。用一个正则先清理再做分词和过滤整套逻辑封装成一个函数import re def load_stopwords(stopwords_file): with open(stopwords_file, r, encodingutf-8) as f: return set(line.strip() for line in f) stopwords load_stopwords(stopwords.txt) def preprocess(text): text re.sub(r[^], , text) # 去掉HTML标签 text re.sub(rhttps?://\S, , text) # 去掉网址 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , text) # 保留中文、英文、数字 words jieba.lcut(text) filtered [ w for w in words if w.strip() and w not in stopwords and not w.isdigit() and len(w) 1 ] return .join(filtered)这里有几个从实战里沉淀下来的细节。第一纯数字用w.isdigit()过滤。新闻里的年份、统计数字经常出现但它们很难单独承担类别判别的职责反而容易污染特征。第二len(w) 1过滤单字词。单字词在中文新闻里大多缺乏区分能力偶尔出现的姓名缩写、量词噪声大于信号。第三正则清洗要在分词之前做否则杂质会影响切词结果。清洗完成后对df[text]整列执行预处理存成新列df[clean_text]。两万篇文本的处理耗时通常在几十秒到一两分钟这个阶段不用太焦虑。跑完之后随手打印一条结果检查有没有出现空文档。如果有的文章清洗后变成空字符串后面在特征工程时要用fillna()或过滤空文档兜底不然模型训练时会报错。4. 特征工程把人话翻译成数字4.1 词袋模型到TF-IDF为什么要绕这么大一圈分词完成后每篇文章就是一个长长的词序列。计算机不能直接理解词语它只认数字。特征工程要解决的问题就是把一个词序列变成一根有固定长度的数字向量。最原始的思路是词袋模型。想象把所有出现过的词收集起来做成一个字典每个词对应一个维度对某一篇文章来说这个维度上填的就是这个词在这篇文章里出现的次数。比如全语料里出现了科技财经娱乐三万多个词那每篇文章都能表示成一个三万维的向量里面大部分值是0少数维度上有词频。但词袋模型有一个明显缺陷高频词对向量的贡献太大而这些高频词往往不携带类别信息。比如新闻记者报道几乎哪类文章都有按词频算它们的权重最高对分类却毫无帮助。TF-IDF就是来解决这个问题的——它把单纯的词频替换成一个词频 × 逆文档频率的加权值。逆文档频率的含义是一个词如果出现在很多文档里它的区分能力就弱权重就低一个词如果只在少数几类文档中出现它的区分能力就强权重就高。这样融资在财经新闻里就会比记者拿到高得多的权重。4.2 TfidfVectorizer的正确打开方式scikit-learn封装的TF-IDF实现是TfidfVectorizer。使用之前必须先把分词结果用空格拼接成字符串让它按空格识别一个个词。初始化时设置这些参数from sklearn.feature_extraction.text import TfidfVectorizer vectorizer TfidfVectorizer( ngram_range(1, 2), # 同时使用单词和相邻双词 max_features50000, # 特征总数上限 min_df2, # 至少在2篇文档中出现过 max_df0.8, # 在超过80%文档中出现则忽略 sublinear_tfTrue # 词频做对数平滑 )参数逐个说明一下。ngram_range(1, 2)表示既保留单词投资也保留相邻两词的组合风险投资这样对财经新闻来说能抓住重要短语max_features50000限制特征数量防止维度太高拖慢训练min_df2过滤只在单个文档里出现过一次的词这种词大多是错别字和噪声max_df0.8过滤在八成以上文档中都出现的词这类词已经接近停用词sublinear_tfTrue对词频取对数避免单篇文章里某个词反复出现导致权重被过分放大。这里注意先只初始化不急于fit。严格的实验流程应该先切分数据再在训练集上fit在测试集上transform。我们会在模型训练那一章按照正确顺序走这里先理解向量化器的配置逻辑。4.3 特征矩阵的形态和查看方法如果想提前看到特征长什么样可以拿一小批文本做一个临时演示。比如把两个句子喂给一个临时向量化器然后打印它的词表demo_vectorizer TfidfVectorizer( ngram_range(1, 2), max_features50000, min_df1, max_df1.0, sublinear_tfTrue ) sample [ 自动驾驶 产业 迎来 融资 高峰, 房价 利率 市场 周期 政策 出台 ] demo_vectorizer.fit(sample) print(demo_vectorizer.get_feature_names_out())输出会是自动驾驶产业迎来融资这些词以及自动驾驶 产业这样的相邻双词组合。如果看到一堆的了是或者莫名其妙的乱码说明预处理或参数设置有问题需要回到上一步排查。这个排查习惯能让问题早暴露而不是等到模型训练完才发现效果不对。正式训练时生成的特征矩阵是一个稀疏矩阵形状一般是(文章数, 特征数)。比如(20000, 50000)。这么大规模的稠密矩阵会占据惊人的内存而稀疏矩阵只存储非零位置实际占用极小。这也是为什么即使特征多达几万维线性模型依然训练迅速。5. 训练两个经典模型朴素贝叶斯与逻辑回归5.1 先拆分数据再构造特征不要让模型作弊特征构造完成后第一个动作是拆分数据。很多教程习惯先把全量文本做特征化再切分这其实是一个隐患训练集和测试集本应完全隔离如果特征化阶段用了全量数据来统计词频和IDF相当于模型提前见过测试集的信息得到的评估分数虚高。正确顺序是先拆原始文本再在训练集上fit_transform学习特征参数然后在测试集上只用transform套用同一套参数。代码如下from sklearn.model_selection import train_test_split df_train, df_test train_test_split( df, test_size0.2, random_state42, stratifydf[label] ) # 在训练集上学习特征参数测试集只做转换 X_train vectorizer.fit_transform(df_train[clean_text]) X_test vectorizer.transform(df_test[clean_text]) y_train df_train[label] y_test df_test[label] print(训练特征矩阵, X_train.shape) print(测试特征矩阵, X_test.shape)random_state42让每次随机拆分的结果固定保证实验可复现stratifyy让训练集和测试集中各类别比例和原始数据一致避免某个类别在测试集中意外变少导致评估偏差。5.2 多项式朴素贝叶斯为什么它总是NLP入门标配朴素贝叶斯基于贝叶斯定理给定一篇文档的特征计算出它属于每个类别的概率选择概率最大的那个类别作为预测结果。它朴素的地方在于假设所有特征相互独立这与现实显然不符但在文本这种高维稀疏的数据上这个简化反而让它计算飞快、表现不俗。scikit-learn里有三种朴素贝叶斯变体文本分类固定用多项式版本MultinomialNB因为它针对的是特征为次数这种分布特别契合词频和TF-IDF的统计特性。from sklearn.naive_bayes import MultinomialNB nb_model MultinomialNB(alpha1.0) nb_model.fit(X_train, y_train) nb_pred nb_model.predict(X_test)alpha是平滑参数常用1.0。文本数据里经常会遇到某个词在训练集中没见过概率算出来是0平滑就是给所有词垫一个最小计数值避免概率变成0。如果训练文本很短、特征很稀疏适当提高alpha可以略微缓解极端概率问题但默认1.0已经够用不需要一上来就调它。5.3 逻辑回归朴素假设不成立后线性模型来救场逻辑回归虽然名字里带回归实际上是个分类模型。它不再假设特征独立而是为每个特征学一个权重再用的函数把得分映射成概率。对高维稀疏的文本特征逻辑回归往往比朴素贝叶斯更稳表现在准确率和F1上的差距可能在2到5个点。from sklearn.linear_model import LogisticRegression lr_model LogisticRegression(max_iter1000, C1.0) lr_model.fit(X_train, y_train) lr_pred lr_model.predict(X_test)我第一次跑逻辑回归时碰到过一个警告提示ConvergenceWarning——模型在默认最大迭代次数下没有收敛。原因是特征多达几万维、样本量又大优化算法需要更多轮次。把max_iter调到1000后警告消失训练也就十几秒。C是正则化强度的倒数影响模型复杂度入门阶段保持默认的1.0即可不要过早陷入调参泥潭。两个模型都训练好后下一步就是评估。记住不要直接在训练集上打分因为模型对训练数据天然拟合得很好那个分数没有任何参考价值必须用测试集的结果。6. 评估与结果解读不要只盯着准确率6.1 准确率之外精确率与召回率才是诊断工具先给结论多分类任务先看准确率但模型调优要看F1、精确率、召回率和混淆矩阵。准确率是预测正确的样本数除以总样本数。它对整体效果好坏的感知是粗粒度的。进一步对每个类别分别定义精确率和召回率。精确率问的是模型说这篇是财经它靠谱吗召回率问的是真正的财经文章模型漏了多少。一个是宁缺毋滥一个是宁滥勿缺两者天然存在冲突。在多分类任务里对每个类别分别计算指标后取平均得到宏平均F1它是衡量多分类整体性能的常用指标。具体实现可以直接用classification_reportfrom sklearn.metrics import classification_report print(classification_report(y_test, lr_pred, target_nameslr_model.classes_))我这一版实验里逻辑回归的宏平均F1大约是0.92朴素贝叶斯约0.87。差距主要集中在边界模糊的几个类上比如汽车和科技、房产和财经原因后文会说。6.2 混淆矩阵一眼看出哪两类新闻长得最像只看前面的数字你还是不知道模型到底错在哪。混淆矩阵能直接告诉你真实类别为A的样本有多少被预测成了A又有多少被预测成了B、C、D。画混淆矩阵的代码import matplotlib.pyplot as plt from sklearn.metrics import ConfusionMatrixDisplay ConfusionMatrixDisplay.from_estimator(lr_model, X_test, y_test) plt.show()从混淆矩阵里最值得关注的是对角线以外数值较大的格子。我跑的时候汽车类新闻有相当一部分被分到了科技类原因是文本里频繁出现自动驾驶智能座舱芯片这些词它们和纯科技新闻高度重叠。另一个典型是房产和财经都绕不开利率贷款市场这些词。这类混淆不是bug而是新闻内容本身确实存在交叉。如果某个类别的样本量本来就少混淆矩阵会显示它的整行数字都偏小连带影响召回率。因此做实验时应尽量保证各类别样本量接近或者加入类别权重来平衡。6.3 从基线到改进一份可以照着做的调参清单入门阶段最容易踩的坑是一上来就想着用多复杂的模型然后陷入调参泥潭。更高效的做法是先跑一个默认参数的基线让所有环节都正常跑通再去逐个改动变量每次只改一个。给一份基于我这次实验的调参顺序参考第一步ngram_range从(1,1)改成(1,2)通常F1能提升1到3个点。双词组合捕捉了更多短语信息。第二步max_features从一万提高到五万看看F1随特征数增加是否仍然上涨如果增长趋缓就停在那个区间。第三步max_df和min_df调得更狠一些过滤更多常见词和垃圾词观察噪声去掉之后的波动。第四步如果某个类表现仍然差检查它的样本量和特征词考虑补充该类的样本或调整类别权重。每次调整都记录F1、准确率和训练时间你自己很快就能整理出一套对该数据有效的参数区间。不要乱调更不要同时改两个变量否则分辨不出是哪一个改动带来了提升。7. 实战踩坑记录从第一行代码到稳定输出我替你趟过的雷7.1 内存暴涨不要把所有分词结果一次性塞进列表我第一次处理五万篇新闻时把分词结果全部append到一个列表再转成DataFrame结果内存飙升到好几个G机器直接卡死。原因很简单每个中文词都是一个字符串对象几百万个字符串对象在Python里占据了大量堆空间。后来我把流程改成读取文件后立刻清洗和分词处理结果以空格拼接的字符串形式存进列表不再保留中间分词列表如果数据量更大干脆每处理一批就写进磁盘缓存把内存峰值降下来。这个方法对二三十万篇以内都是有效的。合理使用生成器而不是一次装载是处理大规模语料的基本功。7.2 编码错误公开语料里总有那么几个坏文件从网上下载的新闻语料极少数文件的编码并不规范。直接用open(file, r)去读碰到UnicodeDecodeError整个遍历就中断。我的解决方式是with open(file_path, r, encodingutf-8, errorsignore) as f: texts.append(f.read())errorsignore会忽略无法解码的字节保证程序继续。代价是个别文件可能丢失少量内容但在批量语料里这是可以接受的成本。处理真实数据时先保证流程不崩再谈数据质量是非常务实的原则。7.3 顺序问题先拆数据还是先做特征这是两套流程如果你直接把全量文本做了TF-IDF再去拆分训练测试集表面看起来没毛病实际上测试集的统计信息已经参与了特征计算。比如IDF是拿全量文档统计出来的测试集文本也在里面模型在训练时见过测试集的词分布信息最终评估分数会比真实场景虚高给后续上线埋下隐患。正确做法我在前面也强调过先拆分再在训练集上fit_transform测试集只执行transform。如果你发现自己的流程里没注意这个顺序建议从现在开始养成先切分再fit的习惯。7.4 类别不平衡为什么模型老是把稿子往大类里塞当语料里某个类别的样本远多于其他类别时分类器为了整体准确率更漂亮往往会倾向于把不确定的样本预测到大类导致小类召回率很低。这在新闻分类的多个公开语料里非常常见。应对方法按优先级排列第一尽量均衡采集各类别样本这是最本质的解法第二切分数据集时使用stratifyy避免测试集类别比例失真第三对线性模型可以设置class_weightbalanced给小类更高的误判代价第四如果条件允许加大整体数据量。对入门项目来说先理解现象再用最基础的方式去缓解足够支撑你完成一次结构完整的实验。行文到这里一个完整的中文新闻文本分类项目就结束了。从我自己的实践体会来说这类入门项目的收获不在我训练了一个模型而在于你亲手把语料、特征、模型、评估这几块拼图拼完整中间踩的每一个坑都会变成之后做更复杂项目的经验。最后给一个对我来说最有效的习惯每跑完一个实验就在自己的实验表记一行——数据集版本、预处理方式、特征参数、模型、准确率、F1、训练时长。这个习惯我坚持了很多年回头翻看时能很清楚地看到每个决策对结果的真实影响。下一篇实战我大概率会带着同样的流程往更深处走比如换一种文本表示方式或者做一个带实时预测的小应用。中文NLP的门槛并没有想象中那么高把第一篇跑通后面就是一步一个脚印的事情了。
阅读完成 · 觉得有帮助?