☰
20万条新闻标题数据集:文本分类与趋势分析实战
2026/10/10 6:28:46 网站建设 项目流程

简介:新闻类别数据集是一份面向自然语言处理、数据挖掘与机器学习研究者的实用资源,内容源自HuffPost新闻网站,收录2012年至2018年间约20万条新闻标题,时间跨度大、样本量充足。压缩包内共1个json文件(News_Category_Dataset_v2.json),包体大小约25.44MB,每条记录包含标题文本、发布日期、作者、原文链接及新闻类别标签,字段结构清晰,便于直接加载和后续清洗,如去除标点、过滤停用词、词干提取等预处理操作。该数据集应用场景丰富:既能结合朴素贝叶斯、SVM、CNN或Transformer等模型训练自动新闻分类器,也可对标题进行关键词提取、句式与热门词汇分析;借助时间跨度还能观察新闻热点的季节性、周期性变化,甚至尝试多语言识别与情感分析。目前已有476人学习下载,适合NLP初学者、数据科学家及新闻领域研究者用于教学实践、科研验证或推荐系统开发,是探索新闻文本价值的优质数据基础。

1. 新闻类别数据集:20万条真实标题的文本分类与趋势分析

新闻类别数据集是我做文本分类实验最常拿来当基准的公开资源之一:约20万条真实新闻标题,每条都带类别标签,以JSON格式存成单个文件。它的价值在于“短文本+真实分布”——标题往往只有十几个词,却要预测出几十个新闻类目,这对NLP流程里的清洗、特征提取和模型选择都是很典型的压力测试。无论你是刚入门文本分类,还是想验证类别不均衡下的评测口径,这个数据集都能一次性喂饱这些需求。而且它覆盖了整整六年的发布时间跨度,除了训练分类器,还能顺手做新闻热点演变分析。下面我把拆解、预处理、建模和踩坑过程完整走一遍。

2. 拆解数据集结构:字段、类别分布与时间跨度

2.1 字段说明:headline、category、date,哪些陷阱藏在里面

拿到压缩包后,核心文件是News_Category_Dataset_v2.json。常见读取方式有两种:如果文件是标准 JSON 数组,直接json.load;但更常见的是 JSON Lines 格式,每一行是一个独立的 JSON 对象,这时要按行读取。

head -n 3 News_Category_Dataset_v2.json

如果输出是每行一大段 JSON,那就确认是 JSON Lines。用 Python 读的时候注意编码:

import json import pandas as pd rows = [] with open('News_Category_Dataset_v2.json', 'r', encoding='utf-8') as f: for line in f: line = line.strip() if line: rows.append(json.loads(line)) df = pd.DataFrame(rows) print(df.shape) print(df.columns.tolist())

这段代码逐行解析,避免一次性json.load把整个大文件读进内存后因内存不足而卡死。pd.DataFrame(rows)会把每条新闻转成一行表格数据。接下来看字段:

字段含义使用建议
link新闻原文链接一般不用,注意是否包含跟踪参数
headline新闻标题正文核心输入特征
category新闻类别,如 POLITICS、WELLNESS标签目标
short_description一句话摘要可作额外特征,但小心数据泄露
authors作者列表或字符串可能为空,不适合当特征
date发布日期,形如 2012-05-02用于时间切分和趋势分析

一个容易踩的坑是:short_description不是标题的重复,而更像导语。如果你只想做标题分类,就只取headline列,否则模型会依赖摘要里的显式关键词,导致在新数据上泛化变差。

2.2 类别分布:长尾效应与冷门类目处理

先看类别数量和占比,这一步决定后续是单标签分类还是多标签。这个数据集的每个 headline 只有一个 category,所以是标准单标签多分类问题。

category_counts = df['category'].value_counts() print(category_counts) print(f"总类别数: {len(category_counts)}")

输出会看到类似 POLITICS、ENTERTAINMENT 等大类数量过万,而 CRIME、RELIGION 这类冷门只有几百条。这种长尾分布非常真实,但会造成两个问题:一是少数类样本太少,模型学不住;二是直接按全局随机划分训练/测试集,会让少数类在测试里占比不足,导致评估波动大。

我的习惯是先把数量少于阈值(比如 300 条)的类别单独拎出来,要么合并到上层类目,要么在训练时用分层采样强制保留比例。后面第 3 章会讲具体做法。

2.3 时间跨度:2012-2018 的新闻热点变化

数据集的另一个优势是带发布日期,跨度从 2012 年到 2018 年。你可以按月汇总发布量,直观看到新闻平台的内容供给波动。

import matplotlib.pyplot as plt df['date'] = pd.to_datetime(df['date']) monthly_counts = df.set_index('date').resample('ME').size() plt.figure(figsize=(12, 4)) monthly_counts.plot() plt.title('Monthly News Volume') plt.xlabel('Date') plt.ylabel('Number of Headlines') plt.show()

这里resample('ME')是按自然月聚合,ME是 pandas 2.x 里“月末频率”的写法。如果用的是旧版本,'M'也能工作但会提示 deprecation。从聚合结果能看到明显的季节性波峰——通常是年底或重大事件期间数量上涨——这提醒我们:如果要做新闻热点分析,必须先做时间序列去趋势,否则模型会把“发布时间”本身当成类别线索。

3. 预处理流水线:清洗、分词与标签整理

3.1 加载与去重:用 pandas 还是 json 逐步读

数据集中可能存在重复标题,尤其是转载或同一事件的连续报道。直接按headline去重,同时保留时间信息:

df = df.drop_duplicates(subset=['headline', 'date'], keep='first') print(df.shape)

subset里同时放headline和date是因为有些爆款标题会周期性重发,如果只按 headline 去重会把不同日期的真实样本误删。keep='first'保留最早出现的那条。

对于内存管理,20 万条记录其实不大,pandas 完全能扛。但如果你在低配环境里跑,建议分块读取:

chunk_iter = pd.read_json('News_Category_Dataset_v2.json', lines=True, chunksize=50000) df = pd.concat([chunk for chunk in chunk_iter])

lines=True告诉 pandas 这是 JSON Lines,chunksize=50000控制每次读入的行数,最后用concat合并。这样能避免一次性读入大文件导致的 OOM。

3.2 文本清洗:去标点、停用词与词干化的取舍

新闻标题要清洗,但不能用力过猛。标题本来就是高度浓缩的信息载体,停用词和标点有时反而携带情感色彩,比如问号和感叹号。我的清洗策略是分两条线:一条是标准清洗,另一条是保留标点符号的版本。

import re import nltk from nltk.corpus import stopwords nltk.download('stopwords') stop_words = set(stopwords.words('english')) def clean_standard(text): text = text.lower() text = re.sub(r'[^a-z\s]', '', text) # 去掉非字母字符 tokens = text.split() tokens = [t for t in tokens if t not in stop_words] return ' '.join(tokens) def clean_keep_punct(text): text = text.lower() text = re.sub(r'[^a-z\s!?]', '', text) # 保留感叹号和问号 return text

clean_standard适合喂给 TF-IDF 和朴素贝叶斯,因为标点会稀释词频统计;clean_keep_punct适合用在深度学习模型里,让模型自己学标点符号的语义。注意nltk.download需要联网,如果离线环境就用sklearn自带的停用词表:

from sklearn.feature_extraction.text import ENGLISH_STOP_WORDS stop_words = ENGLISH_STOP_WORDS

词干化(stemming)在这个数据集上收益不大,因为标题本身短,过度还原会破坏“Trump”这种专有名词。我一般跳过词干化,只做小写和标点处理。

3.3 类别标签处理:单标签分类与样本均衡策略

把类别字符串转成数字编码,同时保留映射关系,方便后面回看混淆矩阵。

df['label_code'], label_mapping = pd.factorize(df['category']) print(label_mapping)

pd.factorize返回(编码数组, 唯一类别数组),顺序按出现频率排列。之后做分层切分:

from sklearn.model_selection import train_test_split train_df, test_df = train_test_split( df, test_size=0.2, random_state=42, stratify=df['label_code'] ) print(train_df['label_code'].value_counts(normalize=True))

stratify参数按label_code标签比例抽样,保证测试集里每个类别的占比和原始数据一致。如果不分层,冷门类别可能在测试集里只出现一两条,模型评估结果会很飘。

对于冷门类别,我还会计算类别权重,在训练损失里给少数类更高权重:

import numpy as np from sklearn.utils.class_weight import compute_class_weight classes = np.unique(df['label_code']) weights = compute_class_weight('balanced', classes=classes, y=df['label_code']) class_weight_dict = dict(zip(classes, weights))

compute_class_weight('balanced')会让少数类的权重反比于其频次。这样即使不重采样,模型也会更关注冷门类别,后面模型代码里可以直接传class_weight=class_weight_dict。

4. 文本分类实战:从朴素贝叶斯到 BERT 的落地路径

4.1 基线模型:TF-IDF + 朴素贝叶斯的参数选择

先用 sklearn 搭一个最稳的基线,便于后续对比深度学习模型的收益。TF-IDF 的参数决定了特征空间的质量。

from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.pipeline import Pipeline vec = TfidfVectorizer( ngram_range=(1, 2), # 保留单词和相邻二元词组 min_df=2, # 至少在2篇文档出现 max_df=0.8, # 忽略在80%以上文档出现的词,去常见噪音 sublinear_tf=True # 用 1+log(tf) 平滑,弱化高频词 ) model = Pipeline([ ('tfidf', vec), ('clf', MultinomialNB(alpha=0.5)), ]) model.fit(train_df['headline'], train_df['label_code'])

ngram_range=(1,2)是新闻标题分类的关键:像 “white house” 这种二元组单独出现时语义更强,但 n 太大容易过拟合。sublinear_tf=True会把原始词频做对数缩放,避免“the”这类高频词在短文本里权重过大。alpha=0.5是拉普拉斯平滑系数,调小一点能让模型对冷门词更敏感。

评估时一定要用宏平均 F1,而不是准确率:

from sklearn.metrics import classification_report pred = model.predict(test_df['headline']) print(classification_report(test_df['label_code'], pred, target_names=label_mapping, zero_division=0))

classification_report直接给出每个类别的精确率、召回率和 F1。我会重点关注macro avg这一行,因为它在类别不均衡时比accuracy诚实得多。

4.2 进阶模型:CNN 文本分类的 embedding 维度与卷积核

当朴素贝叶斯的宏 F1 卡在 0.7 左右时,可以上 CNN。短文本用一维卷积很合适,因为它能捕捉局部 n-gram 特征,而且训练比 BERT 轻量得多。

from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding, Conv1D, GlobalMaxPooling1D, Dense, Dropout max_words = 20000 max_len = 32 tokenizer = Tokenizer(num_words=max_words, oov_token='<UNK>') tokenizer.fit_on_texts(train_df['headline']) train_seq = pad_sequences(tokenizer.texts_to_sequences(train_df['headline']), maxlen=max_len) test_seq = pad_sequences(tokenizer.texts_to_sequences(test_df['headline']), maxlen=max_len) num_classes = len(label_mapping) model_cnn = Sequential([ Embedding(max_words, 100, input_length=max_len), Conv1D(filters=128, kernel_size=3, activation='relu'), GlobalMaxPooling1D(), Dense(128, activation='relu'), Dropout(0.5), Dense(num_classes, activation='softmax') ]) model_cnn.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])

max_words=20000只保留词频最高的 2 万个词,防止 embedding 表太大。max_len=32是因为新闻标题绝大多数不超过 32 个词,超出截断、不足补零。Embedding维度取 100 是经验值,维度太低词向量表达力不足,太高训练慢且容易过拟合。卷积核kernel_size=3相当于看连续 3 个词的窗口(trigram),filters=128表示用 128 个不同卷积核去提取不同特征。GlobalMaxPooling1D取每个特征图的最大值,把变长序列压成固定向量。

训练时要带类别权重,用第 3 章算好的class_weight_dict:

model_cnn.fit( train_seq, train_df['label_code'], validation_data=(test_seq, test_df['label_code']), epochs=5, batch_size=128, class_weight=class_weight_dict )

class_weight_dict直接传进去,Keras 会按权重调整每个样本的梯度。如果不想手动算,也可以在class_weight里直接填'balanced',但 Keras 本身不支持这个字符串,所以必须用 sklearn 的compute_class_weight或自己统计。

4.3 迁移学习:用预训练 Transformer 微调,注意类别不均衡

想要更高精度,直接用预训练语言模型微调。对英文新闻标题,用distilbert-base-uncased这类轻量模型性价比最高,因为它是小模型,20 万条数据也能在消费级 GPU 上跑完。

from transformers import AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments model_name = 'distilbert-base-uncased' tokenizer_bert = AutoTokenizer.from_pretrained(model_name) model_bert = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=num_classes) def tokenize_fn(batch): return tokenizer_bert( batch['headline'], padding='max_length', truncation=True, max_length=32 )

tokenizer_bert会把标题转成 input_ids 和 attention_mask,padding和truncation控制长度对齐。注意max_length=32在这里比 CNN 的max_len更严格,因为 Transformer 的自注意力计算量随序列长度平方增长,短标题没必要拉到 512。

微调时可以用Trainer直接传类别权重,但Trainer不支持class_weight参数,得在损失函数里做——一个更快但稍糙的办法是对少数类过采样:

from sklearn.utils import resample minority_df = train_df[train_df['category'].isin(['CRIME', 'RELIGION'])] oversampled_df = resample(minority_df, replace=True, n_samples=2000, random_state=42) train_df_bal = pd.concat([train_df[~train_df['category'].isin(['CRIME', 'RELIGION'])], oversampled_df])

这里resample把冷门类重复采样到 2000 条,然后再做 tokenize 和训练。如果明显导致多数类被压制,可以按比例控制过采样数量,而不是一味拉平。

5. 避坑与常见问题:数据泄露、类别偏移与评测陷阱

5.1 数据泄露:short_description 抢先给出了答案

现象:模型在验证集上宏 F1 高达 0.92,但换到全新一批新闻标题时只有 0.75,差距非常大。

原因:我在预处理时把headline和short_description拼在一起当输入。short_description是人工写的摘要,里面通常直接包含类别关键词,比如“经济”“大选”等,相当于把答案喂给了模型。模型学会了这些强特征,但真实预测场景里只有标题可用,于是泛化崩盘。

解决:只用headline列作为模型输入。如果你确实想利用摘要信息,就把输入改成“headline + 摘要”,并且保证线上预测时也能拿到同样的摘要字段,否则不要混用。另外检查是否存在重复的 headline 出现在训练和测试集,用train_df.merge(test_df, on='headline', how='inner')查看交集。

5.2 时间偏移:用 2012-2016 训练、2017-2018 测试,宏 F1 大幅下滑

现象:随机切分时宏 F1 是 0.78,按时间前 70% 训练、后 30% 测试时宏 F1 掉到 0.69,某些冷门类别直接崩到 0.1。

原因:新闻用语和热点事件具有强时效性。2012 年的“Obama”相关词汇在 2018 年变成“Trump”,TF-IDF 学到的词汇分布和新数据不匹配。随机切分会把同一时期的相似标题同时放进训练和测试,造成虚高的评估结果。

解决:切分数据时必须按日期排序,绝不能随机。用df.sort_values('date')后取前 N 条做训练。同时,TF-IDF 的vocabulary要固定,不能让 TfidfVectorizer 在测试集上重新拟合,否则测试集的新词会悄悄改变特征空间:

vec.fit(train_df['headline']) test_tfidf = vec.transform(test_df['headline'])

vec.transform只使用训练时学到的词典,测试集里没出现过的词会被忽略,这样才符合真实部署场景。

5.3 类别不均衡:冷门类别完全被模型忽略

现象:混淆矩阵显示 POLITICS、ENTERTAINMENT 等大类识别得很好,但 CRIME、RELIGION、QUEER VOICES 的召回率几乎为 0,模型把所有样本都预测成大类。

原因:少数类样本量可能只有几百条,而多数类有几万条,模型为了降低整体损失,会倾向输出大类概率。单纯用准确率评估时甚至看不出问题。

解决:三层方案。第一,用compute_class_weight给少数类加权;第二,对少数类做过采样,比如用resample把每个冷门类扩到 1000-2000 条;第三,在预测时手动调整阈值——对每个类别用网格搜索找到让 F1 最大的概率阈值,而不是默认的 0.5。第三种做法最常见:

from sklearn.metrics import f1_score best_thresholds = {} proba = model.predict_proba(test_tfidf) for i, cls in enumerate(label_mapping): y_true_bin = (test_df['label_code'] == i).astype(int) y_score = proba[:, i] best_f1, best_thr = 0, 0.5 for thr in np.arange(0.1, 0.9, 0.05): f1 = f1_score(y_true_bin, (y_score >= thr).astype(int), zero_division=0) if f1 > best_f1: best_f1, best_thr = f1, thr best_thresholds[cls] = best_thr

这段代码对每个类别单独扫描概率阈值。注意predict_proba只对支持概率输出的模型有效,如果是 CNN 或 Transformer,就在 softmax 输出层之后自己取概率数组。

5.4 评测口径:准确率是虚假的安全感

现象:报告里准确率 90%,但看每个类别的 F1,少数类全是 0。有人拿这个结果上线,结果真实场景里冷门类新闻全被丢弃。

原因:类别不均衡下,准确率被多数类主导。假设 80% 的样本属于 ENTERTAINMENT,模型全部预测为 ENTERTAINMENT 就能拿到 80% 准确率,但这毫无意义。

解决:宏平均 F1 才是核心指标,classification_report里的macro avg给每个类别同等权重。另外务必画出混淆矩阵,用ConfusionMatrixDisplay.from_predictions可视化,能直观看到哪些类别被互相混淆,比如 POLITICS 经常被错分到 WORLD NEWS,这可能是语义边界重叠,需要增加 n-gram 特征或人工规则。

6. 验证与进阶:用时间序列挖出新闻热点演变

6.1 按周聚合发布量:发现周期性与突发冲击

数据集自带日期,这是一个常被浪费的金矿。除了做时间切分,你还能用它做新闻量趋势分析:

weekly_counts = df.set_index('date').resample('W').size() rolling_mean = weekly_counts.rolling(window=4).mean()

resample('W')按周聚合,rolling(4).mean()计算四周移动平均来平滑短期波动。对比原始周度和移动平均,你能看到明显的高峰——比如某些周突然从 3000 条跳到 6000 条,说明有重大事件发生。

6.2 类别热度随时间的变化:堆叠面积图看趋势

比总发布量更有意思的是“每个类别的占比随时间怎么变”。用透视表把日期按月、类别聚合成占比:

monthly_cat = df.pivot_table( index=df['date'].dt.to_period('M'), columns='category', values='headline', aggfunc='count' ).fillna(0) monthly_cat_pct = monthly_cat.div(monthly_cat.sum(axis=1), axis=0) monthly_cat_pct[['POLITICS', 'ENTERTAINMENT', 'TRAVEL']].plot(figsize=(12, 6))

pivot_table得到的是“每个月每个类别发了几条新闻”,.div(..., axis=0)把频次转成占比,这样能消除新闻总量随季节波动的干扰。你会看到 TRAVEL 在夏季占比升高,POLITICS 在美国大选年份占比飙升——这种洞察没法从单条新闻里看出来。

6.3 一个具体技巧:用分类概率变化做热点突变检测

我想分享一个比较实用的小技巧:与其只看发布量突变,不如用分类器输出的概率序列来检测“类别关注度的突然转向”。

先把数据按周聚合,对每周的所有标题跑一次分类器,得到该周的类别概率均值:

weekly_group = df.set_index('date').resample('W') weekly_prob = {} for week, group in weekly_group: if len(group) < 10: continue seq = tokenizer.texts_to_sequences(group['headline']) seq = pad_sequences(seq, maxlen=max_len) proba = model_cnn.predict(seq, verbose=0).mean(axis=0) weekly_prob[week] = proba prob_df = pd.DataFrame(weekly_prob).T prob_df.columns = label_mapping

然后计算某一类别概率序列的 z-score 来判断突变:

trend = prob_df['POLITICS'].pct_change().rolling(4).mean() zscore = (trend - trend.mean()) / trend.std() spike_weeks = zscore[zscore > 2] print(spike_weeks)

pct_change().rolling(4).mean()先算周与周之间的变化率,再做四周平滑,最后用 z-score 找出超过两个标准差的周。这些周往往对应真实的热点事件,你可以把spike_weeks的日期和对应新闻标题拉出来验证。

这个方法比单纯统计词频更稳,因为它利用的是分类器学习到的语义向量,而不是表面关键词。我后来每次做新闻趋势分析,都强制走一遍“训练分类器 → 按周输出概率 → 计算 z-score”的流程,一次都没翻过车。希望这套拆解能让你少走弯路,直接把这个数据集的价值榨干。

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

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

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

立即咨询