
简介这份源码面向具备一定Python基础的金融数据分析学习者与量化研究者提供一套完整的上市公司新闻文本分析与分类预测方案解决财经新闻自动抓取、特征提取与模型分类的实践问题。资源包共21个文件以17个Python源代码文件为核心覆盖爬虫抓取、文本预处理、特征提取、模型训练与评估等模块另含财经词典与中文停用词等3个文本文件及1个许可证文件压缩包约180KB结构清晰便于按模块学习。项目从财经、每经网、金融界、中国证券网、证券时报网等平台采集历史新闻经分词、去停用词与TF-IDF等特征工程后运用SVM与随机森林完成分类预测读者可借此掌握文本挖掘与机器学习在金融场景的落地流程。目前已有350人学习下载适合作为课程设计或实战练手参考。1. 上市公司新闻文本分析与分类预测从标题到可跑通的源码方案手里有一批上市公司新闻文本想做成一个能自动判断利好利空的分类模型这件事听起来像是一个标准的 NLP 入门项目但真正动手之后你会发现坑几乎全在数据这一侧。新闻文本不像 IMDB 影评那样干净它混杂着公告措辞、行业术语、股票代码、时间戳甚至同一件事在不同媒体笔下情绪完全相反。我见过太多人拿着一个现成的 sklearn 分类脚本换了自己的数据之后准确率直接掉到随机水平然后开始怀疑模型选错了——其实问题出在文本清洗和标注策略上。这个方案要解决的核心问题是给定一条上市公司新闻自动输出它属于哪一类比如利好、利空、中性并且整个流程用 Python 实现代码可复现、可替换数据、可调参。适合两类人一是手里已经有新闻数据、想快速搭一个分类 baseline 的从业者二是想拿一个完整 NLP 项目练手、但不想停留在玩具数据集上的学习者。下面我会按数据获取、清洗、特征、模型、避坑、进阶的顺序把每一步的参数和边界讲清楚。2. 新闻文本从哪来、怎么存数据获取与结构化落地2.1 三种常见数据来源的取舍做上市公司新闻分析数据来源决定了后面所有步骤的天花板。常见做法有三种第一种是公开财经媒体的 RSS 或页面抓取。优点是免费、覆盖广缺点是结构不统一、反爬策略随时变。我一般会用 requests BeautifulSoup 做最小化抓取只取标题、发布时间、正文前 500 字因为上市公司新闻的核心信息几乎都在前几段。第二种是交易所公告接口。这类数据结构化程度高标题格式统一比如“XX 股份关于……的公告”但它是公告不是新闻情绪倾向更隐晦适合做事件分类而不是情绪分类。第三种是已有的人工标注数据集。如果你能找到带标签的财经新闻语料直接用是最省事的但要注意标签体系是否和你的业务对齐——别人的“正面”可能包含“中性偏正面”而你需要的是三分类。选哪种取决于你的目标。如果只是验证分类流程能不能跑通第一种就够了如果要上线建议第二种做事件抽取、第一种做情绪判断两条线分开。2.2 用 Python 把新闻落成结构化表不管来源是什么第一步都是把非结构化文本变成一张表。下面是一个最小化的抓取和存储脚本以财经新闻列表页为例import requests from bs4 import BeautifulSoup import pandas as pd import time import re def fetch_news_list(url, headersNone): 抓取新闻列表页返回标题和链接 if headers is None: headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 } resp requests.get(url, headersheaders, timeout10) resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, html.parser) items [] # 这里的选择器需要根据目标站点实际结构调整 for node in soup.select(.news-item): title_node node.select_one(a) time_node node.select_one(.time) if title_node: items.append({ title: title_node.get_text(stripTrue), link: title_node.get(href, ), publish_time: time_node.get_text(stripTrue) if time_node else }) return items def fetch_article_detail(url, headersNone): 抓取单篇新闻正文只取前若干段 if headers is None: headers {User-Agent: Mozilla/5.0} try: resp requests.get(url, headersheaders, timeout10) resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, html.parser) # 正文通常在 article 或 content 类容器里 body soup.select_one(.article-content) or soup.select_one(#content) if body: paragraphs body.find_all(p) text \n.join(p.get_text(stripTrue) for p in paragraphs[:8]) return text except Exception as e: print(f抓取失败: {url}, 原因: {e}) return # 主流程 all_news [] list_url https://example.com/news/stock # 替换为实际列表页 for item in fetch_news_list(list_url): detail fetch_article_detail(item[link]) item[content] detail all_news.append(item) time.sleep(1.5) # 控制频率避免触发风控 df pd.DataFrame(all_news) df.to_csv(stock_news_raw.csv, indexFalse, encodingutf-8-sig) print(f共抓取 {len(df)} 条新闻)这段代码的逻辑很直白先拿列表页的标题和链接再逐条进详情页取正文最后存成 CSV。几个关键参数需要根据实际情况调整timeout设 10 秒是防止某个请求卡死整个流程time.sleep(1.5)是请求间隔低于 1 秒很容易被识别encoding用apparent_encoding是为了自动适配不同站点的编码避免中文乱码。提示抓取之前先确认目标站点的 robots.txt 和使用条款控制请求频率不要对目标服务器造成压力。存成 CSV 之后你的数据就有了最基本的骨架标题、链接、发布时间、正文。后面所有清洗和建模都在这张表上做。3. 中文新闻文本清洗从原始文本到模型可吃的输入3.1 清洗流程的四个必做步骤拿到原始文本之后不能直接丢给模型。中文新闻文本有几类噪声必须处理第一类是 HTML 残留和转义字符。即使 BeautifulSoup 已经解析过正文里仍可能混入nbsp;、amp;这类实体用html.unescape统一处理。第二类是无关符号。股票代码、网址、邮箱、电话号码这些对分类没有帮助反而会增加词表噪声用正则统一替换成占位符或直接删除。第三类是停用词。中文停用词表有很多公开版本但财经领域需要额外补充一批比如“公司”“股份”“有限”“公告”这些词几乎每条新闻都有保留它们只会稀释特征。第四类是长度截断。新闻正文可能很长但分类模型通常只需要前 300 到 500 字就能判断情绪超长部分截断可以显著降低计算量。下面是一个完整的清洗函数import re import html # 基础停用词表实际使用时建议加载更完整的版本 STOPWORDS set([ 的, 了, 在, 是, 我, 有, 和, 就, 不, 人, 都, 一, 一个, 上, 也, 很, 到, 说, 要, 去, 你, 会, 着, 没有, 看, 好, 自己, 这, 那, 他, 她, 它, 们, 公司, 股份, 有限, 公告, 关于, 如下, 以下, 简称 ]) def clean_text(text, max_len500): 中文新闻文本清洗 if not isinstance(text, str): return # 1. HTML 实体还原 text html.unescape(text) # 2. 去除网址、邮箱、股票代码 text re.sub(rhttps?://\S, , text) text re.sub(r\S\S\.\S, , text) text re.sub(r\b\d{6}\b, , text) # 六位股票代码 # 3. 只保留中文、数字和基本标点 text re.sub(r[^\u4e00-\u9fa5\d。、], , text) # 4. 去除多余空白 text re.sub(r\s, , text).strip() # 5. 截断 text text[:max_len] # 6. 去停用词按字切分简单但有效 chars [c for c in text if c not in STOPWORDS] return .join(chars) # 应用清洗 df[clean_content] df[content].apply(clean_text) df[clean_title] df[title].apply(lambda x: clean_text(x, max_len60)) print(df[[title, clean_content]].head(3))这里有几个参数值得说明。max_len500是正文截断长度如果你的新闻普遍较短可以调到 300如果分类任务依赖长距离信息可以调到 800但要注意模型输入维度会随之增加。停用词表里我额外加了“公司”“股份”“公告”这类财经高频词它们对情绪分类几乎没有区分度。按字切分去停用词是一种简化处理更精细的做法是用 jieba 分词后再过滤但按字处理在新闻分类任务上通常也能达到可接受的效果。3.2 标签怎么来三种标注策略的对比清洗完之后下一个问题是标签。没有标签就没有监督学习。常见做法有三种标注策略做法优点缺点人工标注逐条阅读后打标签质量最高成本高5000 条起步规则弱标注用关键词匹配生成标签零成本速度快噪声大需要人工校验模型预标注用大模型或已有模型打标效率高需要验证集校准我一般会先用规则弱标注生成一批初始标签然后人工抽检 10% 做校准。规则可以很简单标题或正文里出现“涨停”“中标”“增持”“业绩预增”就标为利好出现“跌停”“亏损”“减持”“立案调查”就标为利空其余标为中性。这样能快速得到一个可训练的标签集虽然不完美但足以跑通整个流程。注意弱标注的噪声会直接影响模型上限。如果最终准确率不理想优先回头检查标签质量而不是换模型。4. 特征工程与分类模型TF-IDF 加线性模型的完整实现4.1 为什么先上 TF-IDF 而不是 BERT很多人一上来就想用 BERT 做微调觉得这样才“高级”。但在上市公司新闻分类这个场景里TF-IDF 加线性模型往往能给你一个非常扎实的 baseline而且训练速度快、可解释性强、调参直观。BERT 的优势在于捕捉语义和上下文但财经新闻的情绪信号很多时候就是几个关键词决定的——“预增”和“预减”一字之差TF-IDF 完全能抓住。我的建议是先用 TF-IDF 逻辑回归跑出一个 baseline记录准确率和 F1。如果这个 baseline 已经满足业务需求就不用上 BERT如果不满足再用 BERT 做对比看提升幅度是否值得增加的计算成本。4.2 从分词到 TF-IDF 矩阵的完整代码中文 TF-IDF 需要先分词。jieba 是最常用的选择加载自定义词典可以提升财经术语的切分准确率。import jieba import jieba.analyse from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.metrics import classification_report, confusion_matrix import pandas as pd # 加载自定义词典如果有的话 # jieba.load_userdict(finance_dict.txt) def tokenize(text): jieba 分词过滤单字和空白 words jieba.lcut(text) return [w for w in words if len(w) 1 and w.strip()] # 假设 df 已经有 clean_content 和 label 两列 # label 取值0利空1中性2利好 df df.dropna(subset[clean_content, label]) df[tokens] df[clean_content].apply(tokenize) # 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split( df[tokens], df[label], test_size0.2, random_state42, stratifydf[label] ) # TF-IDF 向量化 vectorizer TfidfVectorizer( tokenizerlambda x: x, # 已经分好词直接传入 preprocessorlambda x: x, token_patternNone, max_features8000, # 词表上限 ngram_range(1, 2), # 一元和二元词组 min_df3, # 至少出现在 3 篇文档中 max_df0.9 # 出现在超过 90% 文档中的词丢弃 ) X_train_tfidf vectorizer.fit_transform(X_train) X_test_tfidf vectorizer.transform(X_test) # 逻辑回归分类 clf LogisticRegression( C1.0, # 正则化强度越小正则越强 max_iter1000, class_weightbalanced, # 类别不平衡时自动加权 solverlbfgs, multi_classmultinomial ) clf.fit(X_train_tfidf, y_train) # 评估 y_pred clf.predict(X_test_tfidf) print(classification_report(y_test, y_pred, target_names[利空, 中性, 利好])) print(confusion_matrix(y_test, y_pred))这段代码里有几个参数直接决定效果。max_features8000控制词表大小太小会丢失信息太大会增加过拟合风险一般从 5000 到 20000 之间调。ngram_range(1, 2)让模型同时考虑单词和双词组合比如“业绩预增”作为一个整体比“业绩”和“预增”分开更有区分度。min_df3过滤掉只出现一两次的稀有词这些词往往是噪声。class_weightbalanced在利好利空样本不均衡时非常关键否则模型会倾向于预测多数类。4.3 模型评估不要只看准确率财经新闻分类的类别分布通常是不均衡的利好和利空的样本可能远少于中性。这时候准确率会骗人——一个把所有样本都预测为中性的模型也能拿到 60% 以上的准确率。必须看每个类别的 precision、recall 和 F1。如果利空类的 recall 很低说明模型漏掉了大量真正的利空新闻这在业务上可能比误报更严重。解决办法有两个一是调整class_weight给少数类更高权重二是降低分类阈值让模型更倾向于预测少数类。逻辑回归的predict_proba可以输出概率你可以自己设阈值# 自定义阈值当利空概率超过 0.4 时就判为利空 proba clf.predict_proba(X_test_tfidf) custom_pred [] for p in proba: if p[0] 0.4: # 利空概率阈值降低 custom_pred.append(0) elif p[2] 0.5: # 利好概率阈值 custom_pred.append(2) else: custom_pred.append(1) print(classification_report(y_test, custom_pred, target_names[利空, 中性, 利好]))阈值调低之后利空的 recall 通常会上升但 precision 会下降。具体设多少取决于你的业务容忍度——如果漏掉一个利空比误报一个利空代价更高就把阈值调低。5. 避坑与排查新闻分类项目里最容易翻车的五个地方5.1 现象训练集准确率 95%测试集只有 60%原因最常见的是数据泄漏。比如你在划分训练测试集之前就做了 TF-IDF 向量化导致测试集的词表信息泄漏到了训练过程中。另一个可能是同一条新闻被重复抓取训练集和测试集里出现了重复样本。解决TF-IDF 的fit只能在训练集上做测试集用transform。去重时按标题或正文前 100 字做去重确保没有重复样本跨集出现。5.2 现象模型把所有新闻都预测为中性原因类别极度不均衡中性样本占了 80% 以上模型学到的最优策略就是全部预测为中性。另外如果特征区分度不够模型也没有动力去区分少数类。解决加class_weightbalanced或者对少数类做上采样。同时检查特征——如果利好和利空新闻的 TF-IDF 向量几乎一样说明清洗步骤把关键情感词过滤掉了回头检查停用词表。5.3 现象新抓的新闻预测效果远差于测试集原因训练数据和新数据的时间分布不同。财经新闻的语言风格会随时间变化半年前“利好”的表达方式可能和现在不一样。另外新数据里可能出现训练时没见过的新词。解决定期用新数据重新训练模型或者至少用新数据做增量验证。TF-IDF 的max_features可以适当调大给新词留空间。5.4 现象jieba 分词把关键术语切碎了原因通用词典不认识财经领域的专有名词比如“可转债”“商誉减值”“股权质押”可能被切成更小的片段。解决加载自定义词典。把财经高频术语整理成一个文本文件每行一个词用jieba.load_userdict加载。这个词典需要根据你的数据持续维护。5.5 现象模型训练时间越来越长内存占用越来越高原因max_features设得太大或者ngram_range设到了 (1, 3) 以上导致特征维度爆炸。另外如果每次实验都重新做 TF-IDF没有缓存中间结果也会浪费时间。解决把 TF-IDF 矩阵用 scipy 的稀疏格式存下来下次直接加载。max_features控制在 20000 以内ngram_range一般不超过 (1, 2)。如果确实需要更多特征考虑用特征选择方法先筛一轮。6. 进阶技巧用交叉验证和错误分析把 F1 再提一截6.1 交叉验证选参数而不是拍脑袋单次 train_test_split 的结果波动很大尤其是数据量不大的时候。用 5 折交叉验证来选C值和max_features结果更可靠。from sklearn.model_selection import GridSearchCV from sklearn.pipeline import Pipeline # 把向量化和分类器串成 pipeline避免数据泄漏 pipeline Pipeline([ (tfidf, TfidfVectorizer( tokenizerlambda x: x, preprocessorlambda x: x, token_patternNone, ngram_range(1, 2), min_df3, max_df0.9 )), (clf, LogisticRegression(max_iter1000, class_weightbalanced)) ]) param_grid { tfidf__max_features: [5000, 8000, 12000], clf__C: [0.1, 0.5, 1.0, 2.0] } grid GridSearchCV( pipeline, param_grid, cv5, scoringf1_macro, n_jobs-1, verbose1 ) grid.fit(df[tokens], df[label]) print(最佳参数:, grid.best_params_) print(最佳 F1:, grid.best_score_)用 pipeline 的好处是向量化只在每折的训练集上 fit彻底杜绝数据泄漏。scoringf1_macro对每个类别等权计算 F1比准确率更适合不均衡场景。n_jobs-1用满所有 CPU 核心加速搜索。6.2 错误分析看模型到底错在哪拿到最佳模型之后不要只看一个 F1 数字就结束。把预测错误的样本导出来逐条看你会发现很多可操作的改进点。# 用最佳模型预测 best_model grid.best_estimator_ y_pred_final best_model.predict(df[tokens]) # 找出预测错误的样本 df[pred] y_pred_final errors df[df[label] ! df[pred]] print(f错误样本数: {len(errors)}) # 按错误类型分组看 for true_label in [0, 1, 2]: for pred_label in [0, 1, 2]: if true_label pred_label: continue subset errors[(errors[label] true_label) (errors[pred] pred_label)] if len(subset) 0: print(f\n真实{true_label}, 预测{pred_label}, 数量{len(subset)}) for _, row in subset.head(3).iterrows(): print(f 标题: {row[title][:50]})我自己的习惯是每次模型跑完都做一轮错误分析重点看两类错误一是利好被预测成利空或反过来这类错误通常意味着文本里有反讽或复杂表述二是中性被预测成利好或利空这类错误往往是弱标注噪声导致的。前者需要更复杂的模型后者需要清洗标签。6.3 一个具体技巧把标题和正文分开建模再融合新闻标题和正文的信息密度不同。标题通常已经包含了核心情绪正文则提供细节。一个简单有效的技巧是分别对标题和正文做 TF-IDF然后拼接特征from scipy.sparse import hstack # 标题 TF-IDF tfidf_title TfidfVectorizer(max_features3000, ngram_range(1, 2)) X_title tfidf_title.fit_transform(df[clean_title]) # 正文 TF-IDF tfidf_body TfidfVectorizer(max_features8000, ngram_range(1, 2)) X_body tfidf_body.fit_transform(df[clean_content]) # 拼接 X_combined hstack([X_title, X_body]) # 重新训练 X_train_c, X_test_c, y_train_c, y_test_c train_test_split( X_combined, df[label], test_size0.2, random_state42, stratifydf[label] ) clf_combined LogisticRegression(C1.0, max_iter1000, class_weightbalanced) clf_combined.fit(X_train_c, y_train_c) y_pred_c clf_combined.predict(X_test_c) print(classification_report(y_test_c, y_pred_c, target_names[利空, 中性, 利好]))这个做法在财经新闻上通常能比只用正文提升 2 到 5 个百分点的 F1因为标题里的情感词权重更高单独建模可以避免被正文的长文本稀释。最后说一个我自己的教训做这类项目最大的时间开销永远不在模型上而在数据清洗和标签校验上。我一开始总想快点跳到建模结果每次都是标签有问题导致模型效果差回头返工。后来我养成了一个习惯——先把 200 条样本逐条看过、标过、清洗过确认整个流程没有系统性偏差再批量跑。这个习惯帮我省了很多后悔药。希望帮到你。本文还有配套的精品资源点击获取