简介:这份资源包以Python实现上市公司新闻文本分析与分类预测,完整覆盖毕业设计常见流程:爬取新浪财经、每经网、金融界、中国证券网、证券时报网等渠道的新闻文本,引入Tushare获取沪深股票日线和基本面数据,对新闻执行去停用词、加载新词、分词等预处理,再通过股票名称匹配为每条新闻关联相关股票代码,依据未来N天股价涨跌自动标注利好或利空,最终利用历史语料训练文本分类模型,并支持实时抓取新闻预测关联股票走势。资源共21个文件,压缩包仅63KB,主体为17个Python脚本,按爬虫与文本分析两类模块分目录组织,辅以停用词表、金融词典、README说明及License文件,结构清晰,便于直接阅读和二次开发。目前已有1702人浏览学习。这套资料既提供可运行的爬虫与分类脚本,也包含报告框架和分词词典等细节,适合计算机专业学生用于毕业设计、课程设计或金融文本挖掘方向的入门实践。
1. 上市公司新闻文本分析与分类预测:先说说这个毕设标配方向到底在解决什么
每年到毕设季,计算机方向都会冒出一大批「文本分析 + 分类预测」的题目,上市公司新闻是其中最典型、最不容易跑偏的载体。它的核心链条并不复杂:把某家上市公司的公开新闻抓下来,清洗成干净的文本,提取特征,再交给分类模型判断这条新闻的情感方向——利好、利空还是中性,放到更大一点的框架里,也可以扩展成「预测下一交易日涨跌倾向」这类带有预测性质的任务。这个方向之所以经久不衰,是因为数据公开、效果可视化强、答辩时讲得清楚,而且代码量和难度对本科生来说刚好踩在「够得着」的线上。
但我要先泼一盆冷水:多数人第一次做这个题目,翻车点根本不在模型,而在数据口径和评测方式。随机打乱数据集、把新闻发布时间和股价涨跌硬凑在一起、用全量分词词典去切金融专名,这几件事做下来,模型指标再好看也都是虚的。本文按这个标题的常见工程方案走一遍,讲清楚每一步的选型理由、参数设置和会踩的坑,让你能拿着思路复现出一套站得住脚的完整项目。
2. 数据从哪来:新闻采集、标注口径与存储设计
2.1 三条拿新闻数据的路径,按可行度排序
上市公司的新闻文本在整个项目里占七成工作量,这里偷懒后面全要还债。我接触到的可行路径有三条,按「能写到毕设报告里」的程度排序:
第一是公开接口和证券交易所公告。交易所官网的公告信息是天然的、带精确时间戳的结构化来源,缺点是公告语言高度模板化,直接分词后特征区分度不高。第二是财经网站的新闻列表页,这类页面通常是静态 HTML 加少量动态加载,用 requests 加 BeautifulSoup 就能稳定抓取,字段包含标题、正文、发布时间、来源。第三是现成的开源数据集,GitHub 和高校开源社区上能找到一些已经标注好情感倾向的财经新闻数据,省去标注时间,但数据新鲜度差,预测近年的事件会失真。
我一般建议主方案用第二条,把第一条作为补充。静态页面抓起来最可控,不会被复杂的反爬机制挡住,而且自己写的爬虫本身就是毕设报告里的「工作量证明」。一个最小抓取脚本长这样:
import requests from bs4 import BeautifulSoup import pandas as pd import time headers = { # 模拟浏览器请求头,降低被拒概率 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" } def fetch_news_list(base_url, page=1): # 按现有分页参数拼接 URL,具体参数名以目标站为准 url = f"{base_url}?page={page}" resp = requests.get(url, headers=headers, timeout=10) resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") items = [] # 每个站点的列表项选择器不同,需要先手动检查一次页面结构 for li in soup.select(".news-item"): title_tag = li.select_one("a") time_tag = li.select_one(".time") items.append({ "title": title_tag.get_text(strip=True), "url": title_tag.get("href"), "publish_time": time_tag.get_text(strip=True) }) return items all_news = [] for p in range(1, 6): # 先抓 5 页,验证结构 all_news.extend(fetch_news_list("https://example.com/news", p)) time.sleep(1) # 合理限速,避免压力过大 df = pd.DataFrame(all_news) df.to_csv("news_raw.csv", index=False, encoding="utf-8-sig")这段代码的逻辑是「先小规模验证,再全量抓取」。参数上最值得说的是time.sleep(1),限速不是胆小,是防止对方服务被瞬时流量打爆导致封 IP;如果一次只跑少量页面,则可以去掉。另外encoding="utf-8-sig"是为了让 CSV 在 Windows 上用 Excel 打开时中文不乱码,这类小细节在答辩演示时会让你少很多尴尬。
2.2 标注不能靠拍脑袋:规则预标注加人工抽检
抓回来的新闻是没标签的,分类预测必须有监督信号,所以标注是绕不开的环节。常见做法是用「规则预标注 + 人工抽检」而不是让一个人从头听到尾读几百条新闻。规则标注的思路是建立一组金融情感词表,例如「增长、盈利、突破、中标」偏向利好,「亏损、违规、处罚、减持」偏向利空,「发布、召开、回应」这类动词通常中性,然后按命中的正负词数量给新闻打初标。
def rule_label(text, pos_words, neg_words): # 统计文本中出现的情感词次数,包含标题加权 pos_hits = sum(text.count(w) for w in pos_words) neg_hits = sum(text.count(w) for w in neg_words) if pos_hits > neg_hits: return "pos" elif neg_hits > pos_hits: return "neg" return "neu"这里的参数说明:pos_words 和 neg_words 不建议自己现编,可以从知网情感词典的金融子集起步,再根据你抓的新闻逐条补充「质押、解禁、商誉减值」这类财经专词;标题的权重没有体现在这个简单函数里,如果你想加,可以对标题命中词乘 1.5 的系数。规则标注的目的是快速获得候选集,让它去替代人工二分类的粗活,最后一定抽 200 条做人工复核,计算规则标注和人工标注的一致率,这个一致率你在报告里写成「标注一致性检验」,是能加分的细节。
2.3 存储别上重武器:CSV 够用,SQLite 可选
毕设项目的数据量级通常几千到几万条新闻,这个规模下 SQLite 都嫌重,CSV 就是最务实的存储方案。但要注意 CSV 的一个坑:新闻正文里常出现换行和逗号,直接用to_csv()存储再read_csv()读取,行数会错乱。规避方法是写读都用 pandas 的默认转义机制,不要手动拼接字符串,同时在读取时指定quoting=1让所有字段都带引号:
import pandas as pd # 带引号写出,避免正文换行破坏列结构 df.to_csv("news_clean.csv", index=False, encoding="utf-8-sig", quoting=1) df_read = pd.read_csv("news_clean.csv", encoding="utf-8-sig", quoting=1)quoting=1对应csv.QUOTE_ALL,意思是所有字段一律加引号。牺牲一点文件体积,换来回读时的绝对稳定,在这类文本项目里非常值得。如果你后续要按股票代码或时间范围频繁查询,再迁到 SQLite 不迟,一开始就上 MySQL 属于给自己制造环境负担。
3. 文本预处理与特征工程:金融文本的清洗边界
3.1 清洗规则不是删 HTML 标签那么简单
抓下来的新闻文本带着各种噪声:网页转义符、来源署名、记者名字、「点击查看」这类引导语、还有大量重复的公告模板头。常规清洗是去 HTML 标签、去空白、去特殊符号,但金融文本有两条额外的清洗纪律。第一,不要把数字全删光——「净利润同比增长 23.5%」里的数字是强特征,删了之后模型只能靠形容词猜方向,清洗时要保留数字和百分号。第二,不能把停用词表直接套中文通用版本,「ST、股权、交易」这类词在金融语料里都是有效信号,盲目去停用词等于自废武功。
import re def clean_text(text): # 去除 HTML 标签与脚本块 text = re.sub(r"<[^>]+>", "", text) # 去除 URL text = re.sub(r"http\S+", "", text) # 保留中文、英文、数字、百分号、小数点、常见标点 text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9.%,。!?、:;]", "", text) # 压缩连续空白 text = re.sub(r"\s+", "", text) return text.strip()这套清洗的正则参数说明:第三个sub是整套清洗的核心,它把文本收窄到「中英文、数字、百分号、小数点和常用中文标点」组成的集合,小于这个集合会丢掉关键数字,大于这个集合会把「○×△」这类符号变成无效特征。清洗后建议人工打印 50 条结果看一遍,确认没有把「1.2 亿元」切坏,这个检查花五分钟,却能避免你把一堆残缺文本送进模型。
3.2 分词:jieba 之外还要做两件事
中文分词用 jieba 基本是事实标准,但直接jieba.lcut(text)在金融新闻上效果一般,问题出在「质押、解禁、回购、商誉」这类词可能被切成单字,或者「净利润」被切成「净利」和「润」。两个补救措施:加载自定义词典,以及把停用词表替换成金融场景适配版。
import jieba # 金融专名词典:每一行一个词,可附带词频与词性 # jieba.load_userdict("fin_dict.txt") # fin_dict.txt 示例内容: # 净利润 50 n # 股权质押 50 n # 商誉减值 50 n # 限售解禁 50 n def tokenize(text): words = jieba.lcut(text) # 过滤单字和纯标点,保留有实义的双字及以上词 tokens = [w for w in words if len(w.strip()) > 1 and not w.isspace()] return tokens自定义词典的文件格式是「词语 词频 词性」,词频给 50 以上就能保证优先匹配。这里有一个参数取舍:不要贪多,加 50 到 100 个高频金融词足够,加得太多会把「公司、股份」这类泛词也锁成专词,导致特征过度稀疏。分词后可以用collections.Counter跑一下词频分布,看到「质押」「减持」出现在 Top 词表里,说明词典生效了。
3.3 TF-IDF 和 Word2Vec 怎么选:按样本量决定
特征工程是文本分类预测的分水岭。样本量在 5000 以下时,TF-IDF 加线性模型的效果通常优于 Word2Vec 加深度学习模型,因为词向量需要大量语料才能学出语义,小样本下强行上深度模型只会过拟合。TF-IDF 的两个关键参数是max_features和ngram_range:
from sklearn.feature_extraction.text import TfidfVectorizer vectorizer = TfidfVectorizer( max_features=8000, # 控制特征维度,防止维度爆炸 ngram_range=(1, 2), # 使用单个词加双词组合 min_df=3, # 至少在 3 条文档中出现,过滤噪声词 max_df=0.8, # 在 80% 以上文档出现的词,多为无区分度词 sublinear_tf=True # 对词频做 log 变换,压制高频词 ) X = vectorizer.fit_transform(corpus)这组参数是几轮调试后的稳健组合,说下每个值的改动方向:max_features降到 5000 会让训练更快但高区分度长尾词可能被截掉,升到 15000 则会让矩阵变稀疏、逻辑回归变慢;ngram_range=(1, 2)是关键,因为「不减持」和「减持」只有一字之差,双词组合能保留一部分否定结构,如果你想强化否定语义,可以尝试(1, 3),但特征数会翻倍。sublinear_tf=True一般建议恒开,它等价于把原始词频换成1 + log(tf),对抑制「公司、目前、情况」这类高频噪声词非常有效。Word2Vec 在这个项目里更适合作为辅助特征——训练一个 100 维的向量,然后和 TF-IDF 特征拼接喂给模型,但对毕设来说 TF-IDF 单通道已经足够形成完整的故事线。
4. 模型训练与分类预测流程:从训练集到可交付的预测结果
4.1 数据集切分:这一步决定你的评估可不可信
文本分类项目最常见的硬伤是随机切分数据集。新闻数据有强时间相关性,同一家公司一周内的新闻话题高度重复,随机切分会把二月和三月关于同一事件的新闻同时放进训练集和测试集,模型做的不是预测,而是「回忆」。正确的切分方式是按时间顺序切:
from sklearn.model_selection import train_test_split # 先按时间排序,再按时间切分,保证训练集时间早于测试集 df_sorted = df.sort_values("publish_time") train_df = df_sorted.iloc[:int(len(df_sorted) * 0.8)] test_df = df_sorted.iloc[int(len(df_sorted) * 0.8):] # 但文本特征需要从全量数据学习,避免测试集信息泄漏 # 因此在切分前先fit vectorizer,再transform两边 X_train = vectorizer.transform(train_df["text"]) X_test = vectorizer.transform(test_df["text"]) y_train = train_df["label"] y_test = test_df["label"]上面代码里有一个需要刻意强调的细节:fit向量化器用全量数据,但transform只在各自集合上执行。这样做是合理的,因为词表统计不是监督信号。真正的监督泄漏点是publish_time本身的一份字段——如果你把「发布时间是星期几」当作特征,就有意义;但如果你一不小心把「当日股价涨跌幅」也拼进了特征矩阵,哪怕只是放在 DataFrame 里忘了删,模型就真的在作弊了。训练前打印一次X_test.shape,确认特征数等于词表长度,再用assert set(train_df["stock_code"]) & set(test_df["stock_code"]) == set()检查同一只股票有没有横跨两个集合,这一步我建议写进固定流程。
4.2 模型选型:逻辑回归打底,LightGBM 提上限
在这个任务上,模型的地位远低于数据质量。我的默认组合是「逻辑回归 / 朴素贝叶斯 / LightGBM」三选一对比。朴素贝叶斯训练最快,适合做基线;逻辑回归对 TF-IDF 稀疏矩阵最友好,且可以输出置信度,方便展示预测结果;LightGBM 如果特征是 TF-IDF 加一些数值特征(新闻长度、标题占比、发布时间段),往往能再提 2 到 3 个点的 F1。下面贴逻辑回归的完整训练与评估代码,这也是报告里最好讲解的一个模型:
from sklearn.linear_model import LogisticRegression from sklearn.metrics import classification_report, confusion_matrix # 对不均衡类别做权重修正,rare类获得更高惩罚权重 model = LogisticRegression( C=1.0, # 正则强度,越大越拟合训练集 max_iter=1000, class_weight="balanced", # 自动按类别频率加权 solver="liblinear" # 小样本稀疏矩阵推荐 ) model.fit(X_train, y_train) y_pred = model.predict(X_test) # 打印完整的精确率、召回率、F1 print(classification_report(y_test, y_pred)) print(confusion_matrix(y_test, y_pred)) # 输出带置信度的预测结果,便于写入报告 pred_proba = model.predict_proba(X_test)[:, 1] result_df = test_df[["title", "publish_time", "label"]].copy() result_df["pred_label"] = y_pred result_df["pred_proba"] = pred_proba result_df.to_csv("prediction_result.csv", index=False, encoding="utf-8-sig")参数说明:C=1.0是逻辑回归的正则强度的倒数,越小越防过拟合,如果你的训练集在 3000 条以下,建议C降到 0.5 附近;32.class_weight="balanced"是必须开的,因为上市公司新闻里「利好」和「中性」占比远高于「利空」,不开这个参数模型会学会无脑预测多数类;solver="liblinear"针对小规模稀疏矩阵收敛更快,换成"lbfgs"在 TF-IDF 特征下经常要调高max_iter到 3000 才到收敛。predict_proba输出的置信度在答辩里很有用——可以挑几条预测置信度最高的新闻做案例分析,这是报告中最生动的素材。
4.3 三个模型的对比表:让报告有说服力
| 模型 | 精确率 | 召回率 | F1 | 训练耗时(千条级) | 适用情况 |
|---|---|---|---|---|---|
| 朴素贝叶斯 | 0.82 | 0.79 | 0.80 | < 1s | 快速基线,适合验证特征有效 |
| 逻辑回归 | 0.84 | 0.82 | 0.83 | 2s ~ 5s | 首选方案,解释性强 |
| LightGBM | 0.86 | 0.84 | 0.85 | 10s ~ 20s | 输入含数值特征时建议尝试 |
这三个模型的精度数据会随你的数据集浮动,不要照抄,关键是理解差异来源:LightGBM 在特征工程充分时更高,但它对类别不平衡更敏感,需要设置scale_pos_weight或is_unbalance=True。我的建议是:报告里至少放出两张图,一张混淆矩阵,一张 PR 曲线,比一堆指标数字直观得多。
5. 避坑常见问题:从数据到预测的六个典型踩坑现场
5.1 新闻发布时间和标签时间错位
现象:模型在测试集上 F1 高达 0.92,但换一批新数据后断崖式跌到 0.7 以下。
原因:新闻本身携带「前瞻性」和「滞后性」。一家公司发布业绩预报是利好,但财报正式披露后的解读文章往往是「利好出尽」的利空。用当期新闻预测当期情绪是有效的,但一旦把目标改成「预测次日涨跌」,新闻发布 30 分钟后和发布两天后对股价的影响方向完全不同,强行混合训练会制造大量矛盾样本。
解决:先明确你的「预测对象」。按标题的常规做法,分类预测的对象是「新闻情感类别」,不要碰股价预测。如果你确实想扩展到股价方向,必须按事件窗口重新标注,例如把 T 日新闻与 T+1 日相对涨跌幅关联,并用严格的时间窗切分,不能用随机切分。
5.2 同一事件的多篇报道导致数据泄漏
现象:测试集里出现和训练集几乎一模一样的新闻,模型准确率虚高,答辩时一追问就露馅。
原因:上市公司新闻存在「通稿现象」——同一事件被多家媒体转载,内容高度相似。这些近似重复样本被随机切分后,训练集和测试集里各出现一份,模型等于在抄答案。
解决:切分前先做去重,用 SimHash 或直接按标题归一化后去重。简单做法是df.drop_duplicates(subset=["title"]),但更稳的做法是计算余弦相似度,相似度大于 0.85 的保留最早一篇。这条在报告里写「基于文本相似度的去重策略」,可以算作创新点。
5.3 自定义词典加词过于激进
现象:jieba 加载自定义词典后,原本正常的句子被切得七零八落,模型效果反而下降。
原因:词典里加了一些短词或低频词,它们覆盖了原本正确的切分结果。例如把「增」加进词典会导致「增加」被切成单字,特征直接变成了噪声。
解决:加词遵循「长词优先、拒绝单字」原则,只加长度大于等于 2 的词。每次加完词典后用jieba.lcut测试十句真实新闻,看切分后的结果是否符合直觉。词频参数低于 30 的专词会被 jieba 忽略,建议统一设为 50。
5.4 类别不平衡导致模型摆烂
现象:模型把所有新闻都预测成「中性」,因为中性样本占 70%,这样准确率是 70%,而且实现简单。
原因:默认的多数类别优先策略在金融文本里极其严重,中性新闻太多了,且很多中性新闻里也含有少量疑似情感词。
解决:不管用什么模型,固定开启类别平衡——逻辑回归用class_weight="balanced",LightGBM 设置is_unbalance=True或手动传入scale_pos_weight。另外还要检查标注本身:如果规则标注把「发布半年报」这类中性事件标成利好,模型也会学到这种偏置。标注环节可以设计成两轮独立标注做一致性比对,这是答辩时非常经得住问的细节。
5.5 报告里的评估指标和代码实现对不上
现象:报告里写「测试集准确率 95%」,但复现时只能得到 78%,老师要求现场跑代码。
原因:很多时候报告里的 95% 是用训练集评估甚至用同一批数据训练测试的,这在几乎每个毕设复核现场都会穿帮。
解决:事先固定三条:一是训练测试切分比例和随机种子写死在代码里,例如random_state=42;二是以测试集上宏平均 F1 为主指标,不要只报准确率;三是把评价代码单独放在evaluate.py里,每次运行输出同样的结果,保证报告数字可复现。如果你改了数据或模型,必须同步更新报告数字,这是最常见的学术规范问题。
5.6 爬虫过程不设限,被反爬拦下
现象:爬取 200 页后被服务端识别,后续请求全部返回 403 或验证码页面。
原因:请求频率过高、请求头缺失、没有带 Referer。
解决:固定睡眠时间time.sleep(0.5)到 1 秒;每次请求使用同一个稳定的请求头;不要在短时间内重复抓取。如果目标站反爬较强,不要硬顶,换另一个源站或直接改用公开数据集。毕设的目的是完整流程,不是验证反爬对抗。
6. 预测效果验证技巧:用时间外验证判断模型是不是在背答案
验证分类预测是否可靠,最实操的方法是「时间外验证」,也叫 walk-forward 验证。思路很简单:把数据按时间切成多段,例如五段,第一次用前一段训练、第二段测试;第二次用前两段训练、第三段测试;以此类推,最终模型的可靠度取决于它在每一次「只预测未来」的表现。这比单次切分更能反映模型的真实泛化能力,也是这个题目作为毕设最有技术含量的一步。
具体写法上,我通常用一个循环完成:
import numpy as np from sklearn.metrics import f1_score df_sorted = df.sort_values("publish_time").reset_index(drop=True) fold_metrics = [] for i in range(3, len(df_sorted)): # 以每新增一块数据为新测试集,前面的全部作为训练集 train_part = df_sorted.iloc[:i] test_part = df_sorted.iloc[i:i+1] if test_part.empty: break X_train_fold = vectorizer.transform(train_part["text"]) X_test_fold = vectorizer.transform(test_part["text"]) y_train_fold = train_part["label"] y_test_fold = test_part["label"] fold_model = LogisticRegression(C=1.0, class_weight="balanced", max_iter=1000) fold_model.fit(X_train_fold, X_test_fold) # 注意这里是fit训练,测试用predict # 实际使用时把上面两行改成:fold_model.fit(X_train_fold, y_train_fold) y_pred_fold = fold_model.predict(X_test_fold) fold_metrics.append(f1_score(y_test_fold, y_pred_fold, average="macro")) print("时间外 F1 均值:", np.mean(fold_metrics)) print("F1 标准差:", np.std(fold_metrics))上面代码第 20 行我故意写了一个训练集对象错位的坏例子,真实使用时请改成fold_model.fit(X_train_fold, y_train_fold),这里的意图是提醒你:在很多翻车现场,错误往往出在「眼镜盯着跑通忽略了变量名」这种低级问题上。跑通后看两个数:均值和标准差。均值代表预测能力的整体水平,标准差代表稳定性;标准差大,说明模型只在某些时间段生效,这时回到特征层面检查是否有「时间标签」被模型偷偷学到。
最后说一个我做这个方向沉淀下来的习惯:我会在交付前把「时间外验证的 F1 曲线」画成折线图放进报告附录,这段图比任何文字描述都更有说服力,因为它直接证明了模型在你采样的这段历史区间里,持续对未来新闻有效。以后你把这个方向扩成更大项目时,记住这条——预测类任务,验证永远比训练重要。希望这篇笔记能帮你把这个经典题目做成一个经得起追问的项目。
本文还有配套的精品资源,点击获取