import pandas as pd
import os
os.makedirs("data_raw", exist_ok=True)
os.makedirs("Fig", exist_ok=True)
os.makedirs("output", exist_ok=True)
# ── 模拟券商研报摘要数据 ──────────────────────────────────────────
# 每条摘要约 80-150 字,覆盖不同行业,情感倾向明确
reports = [
# ── 看多(bullish)× 5 ──
{"report_id": "R01", "firm": "宁德时代", "industry": "新能源", "label": "bullish",
"title": "宁德时代:储能业务超预期,上调目标价至380元",
"summary": "公司三季度业绩大幅超出市场预期,储能业务收入同比增长87%,海外市场加速拓展。动力电池市占率持续提升,成本管控能力行业领先。我们上调2025年盈利预测15%,目标价从320元上调至380元,维持强烈推荐评级。当前估值具有较高吸引力,建议积极配置。"},
{"report_id": "R02", "firm": "招商银行", "industry": "银行", "label": "bullish",
"title": "招商银行:零售业务韧性突出,资产质量优于同业",
"summary": "公司零售客户数量持续增长,AUM规模再创历史新高。净息差虽有所收窄但优于行业均值,不良贷款率同比下降0.08个百分点,拨备覆盖率充裕。在行业整体承压背景下,公司基本面明显优于可比同业。我们给予买入评级,目标价42元,较当前股价有25%上涨空间。"},
{"report_id": "R03", "firm": "贵州茅台", "industry": "白酒", "label": "bullish",
"title": "贵州茅台:直销渠道占比提升,盈利质量持续改善",
"summary": "公司直销渠道收入占比提升至45%,毛利率同比提高2.1个百分点,现金流状况极为健康。高端白酒消费需求保持稳定,茅台酒出厂价上调预期持续强化品牌溢价。我们认为公司长期竞争优势无可撼动,上调目标价至2200元,重申买入评级,是消费板块的压舱石标的。"},
{"report_id": "R04", "firm": "中芯国际", "industry": "半导体", "label": "bullish",
"title": "中芯国际:产能利用率回升,国产替代进程加速",
"summary": "公司产能利用率已回升至85%以上,成熟制程需求旺盛,28nm及以上制程订单排期充足。国产替代需求持续释放,功率器件、MCU等领域客户粘性增强。我们预计公司将进入新一轮盈利上行周期,给予增持评级,目标价68港元,建议逢低布局。"},
{"report_id": "R05", "firm": "中国海油", "industry": "能源", "label": "bullish",
"title": "中国海油:储量替代率超预期,高分红持续验证价值",
"summary": "公司年度储量替代率达到182%,远超行业平均水平,油气储量持续扩大夯实长期成长基础。公司承诺维持40%以上分红比例,当前股息率超过5%,具备显著配置价值。在油价中枢维持高位的背景下,公司自由现金流充裕,我们给予买入评级,目标价28港元。"},
# ── 看空(bearish)× 5 ──
{"report_id": "R06", "firm": "恒大汽车", "industry": "汽车", "label": "bearish",
"title": "恒大汽车:流动性危机加剧,下调至减持",
"summary": "公司流动性压力持续恶化,多条生产线停工,供应商账款逾期金额大幅上升。核心管理团队出现变动,战略方向不明朗。我们大幅下调盈利预测,预计未来两年持续亏损,资产减值风险不可忽视。将评级从中性下调至减持,目标价下调至0.8港元,建议规避。"},
{"report_id": "R07", "firm": "碧桂园", "industry": "地产", "label": "bearish",
"title": "碧桂园:销售持续低迷,债务重组前景不明",
"summary": "公司合同销售额同比下滑超过60%,去化压力严峻。境外债务重组谈判进展迟缓,市场信心脆弱。在行业整体下行、政策托底效果不及预期的背景下,公司经营改善路径不清晰。我们认为当前风险未能充分计价,维持减持评级,投资者应保持谨慎,等待债务重组方案明朗后再做评估。"},
{"report_id": "R08", "firm": "东方财富", "industry": "券商", "label": "bearish",
"title": "东方财富:交易量萎缩拖累业绩,估值仍有压缩空间",
"summary": "市场成交量持续低迷,公司经纪业务收入同比下滑28%,基金代销规模亦明显萎缩。互联网证券赛道竞争加剧,费率持续下行压力难以缓解。当前估值相对历史均值仍偏高,我们下调盈利预测,将评级从中性调整为卖出,目标价15元,较当前股价有20%下行空间。"},
{"report_id": "R09", "firm": "爱尔眼科", "industry": "医疗", "label": "bearish",
"title": "爱尔眼科:并购商誉减值风险上升,增速预期下修",
"summary": "公司历年并购积累的商誉规模庞大,在部分区域门店盈利不达预期的背景下,商誉减值风险显著上升。医疗服务消费边际走弱,客单价提升空间收窄。我们下调2025年净利润预测18%,认为市场对公司增速预期仍偏乐观,将评级从增持下调至中性,目标价18元。"},
{"report_id": "R10", "firm": "新城控股", "industry": "地产", "label": "bearish",
"title": "新城控股:商业运营承压,流动性风险不容忽视",
"summary": "公司吾悦广场出租率同比下降,租金收入增速放缓,而住宅销售回款不及预期导致现金流持续紧张。短期债务偿还压力较大,再融资渠道受限。在市场信心尚未恢复的背景下,公司估值修复缺乏催化剂,我们给予减持评级,建议等待行业政策进一步明确后再考虑介入。"},
# ── 中性(neutral)× 5 ──
{"report_id": "R11", "firm": "美的集团", "industry": "家电", "label": "neutral",
"title": "美的集团:业绩符合预期,海外扩张值得关注",
"summary": "公司三季度收入和净利润基本符合我们此前预期,国内家电需求稳中有升,出口业务受汇率波动影响有所承压。海外自有品牌建设进展值得持续跟踪,但短期贡献尚不显著。我们维持盈利预测不变,维持中性评级和目标价62元,建议关注四季度旺季销售数据。"},
{"report_id": "R12", "firm": "万科A", "industry": "地产", "label": "neutral",
"title": "万科A:经营稳健但行业拖累难以回避",
"summary": "公司在行业整体下行中保持相对稳健,去化率优于同业,融资渠道畅通。但在市场需求整体偏弱的背景下,公司难以独善其身,销售回款速度有所放缓。我们认为公司基本面相对扎实,但行业系统性风险仍是主要压制因素,维持持有评级,目标价9元,等待行业政策进一步落地。"},
{"report_id": "R13", "firm": "海螺水泥", "industry": "建材", "label": "neutral",
"title": "海螺水泥:价格磨底阶段,静待需求回暖",
"summary": "水泥行业价格仍处于底部磨底阶段,公司吨毛利同比有所收窄,但成本管控能力支撑盈利底线。基建投资节奏将是决定后续价格修复的关键变量。公司财务状况健康,账上现金充裕,具备穿越周期的能力。我们维持中性评级,目标价25元,等待价格拐点信号出现。"},
{"report_id": "R14", "firm": "平安银行", "industry": "银行", "label": "neutral",
"title": "平安银行:零售转型效果显现,但资产质量仍需观察",
"summary": "公司零售贷款不良率有所上升,部分消费贷和信用卡资产质量承压,需持续跟踪。另一方面,对公业务表现稳健,净息差降幅好于预期。整体来看,公司转型方向正确但效果显现需要时间,当前股价已反映较多悲观预期。我们维持中性评级,目标价12元,建议观望。"},
{"report_id": "R15", "firm": "比亚迪", "industry": "新能源汽车", "label": "neutral",
"title": "比亚迪:销量高增但价格战加剧盈利压力",
"summary": "公司销量持续创历史新高,市占率进一步提升,品牌影响力显著增强。但行业价格战持续,单车盈利空间受到压缩,高端化进程面临一定阻力。海外市场扩张速度超出预期,但贸易政策不确定性构成潜在风险。综合来看,我们认为当前估值基本合理,维持中性评级,目标价270元。"}
]
df_reports = pd.DataFrame(reports)
df_reports.to_csv("data_raw/analyst_reports.csv", index=False, encoding="utf-8-sig")
print(f"已生成 {len(df_reports)} 条模拟研报数据")
print(df_reports.groupby("label").size().to_string())
df_reports[["report_id", "firm", "industry", "label", "title"]].head(6)26 文本分析:情感分析与主题建模
27 金融文本分析应用
上一章我们完成了「文本变成数据」这件事:读取、清洗、分词、词频统计、TF-IDF。这些都是在问同一个问题——这份文本里有什么词?
这一章要问更进一步的问题:这份文本在表达什么态度?在讨论什么主题?
这两个问题对应两类核心方法:
- 情感分析:判断文本的情感倾向——正面、负面还是中性。在金融场景里,这意味着:研报在看多还是看空?央行报告的语气在收紧还是放松?新闻报道是利好还是利空?
- 主题分析:从大量文档中自动发现隐藏的主题结构。在金融场景里,这意味着:这批年报主要在讨论哪几类议题?某个主题的热度随时间怎么变化?主题权重能不能作为回归分析的变量?
本章的数据安排:
- 情感分析:使用模拟的券商研报摘要(情感标签已知,便于验证方法效果)
- 综合实战:用上一章处理好的人民银行新闻,构建政策语气的月度情感指数
- 主题分析:用人民银行问答文本(篇幅较长,适合主题提取)
本章各节概览
| 节 | 内容 | 核心工具 |
|---|---|---|
| 情感分析:词典法 | 自定义词典、pandas | |
| 情感分析:AI 方法 | Anthropic API | |
| 两种方法对比与讨论 | — | |
| 主题模型:LDA 与 LLM 方法 | gensim、API | |
| 综合实战:政策语气情感指数 | pandas、matplotlib | |
| 本章小结与练习 | — |
27.1 准备模拟数据
本节生成情感分析所需的模拟券商研报摘要数据。这些数据会保存到 data_raw/analyst_reports.csv,后续各节直接读取使用。
研报数据共 15 条,分为三类: - 5 条明确看多:用词积极,包含「上调目标价」「买入」「强烈推荐」等 - 5 条明确看空:用词消极,包含「下调评级」「减持」「盈利压力」等 - 5 条中性:措辞审慎,包含「维持中性」「符合预期」「观望」等
这种设计的好处是:情感标签已知,可以直接验证两种分析方法(词典法、AI 法)的准确率。
27.2 情感分析:词典法
27.2.1 什么是情感分析,金融场景里用来做什么
情感分析(Sentiment Analysis)的核心任务是判断一段文本的情感倾向——正面、负面还是中性。这在金融分析里有非常直接的应用:
- 研报分析:这篇研报在看多还是看空?目标价上调还是下调?
- 新闻监控:这条新闻对某家公司是利好还是利空?
- 政策解读:央行报告的整体语气是偏鸽还是偏鹰?相比上一期有没有变化?
- 年报分析:管理层对未来的表述是乐观的还是保守的?
在学术研究里,情感分析通常用于构造「文本情感指标」,作为自变量或控制变量纳入回归模型。比如,用年报 MD&A 的情感得分预测未来股票收益,或者用央行声明的情感变化研究货币政策传导。
27.2.2 词典法的原理
词典法(Dictionary-based Method)是情感分析中最直接、也最透明的方法。原理很简单:
- 准备一份正面词表和一份负面词表
- 对文本分词,统计正面词和负面词各出现了多少次
- 计算情感得分
\[ \text{Sentiment}_d = \frac{P_d - N_d}{P_d + N_d + 1} \]
其中 \(P_d\) 是正面词数,\(N_d\) 是负面词数,分母加 1 是为了避免除以零。得分范围在 \((-1, +1)\) 之间,越接近 +1 越正面,越接近 -1 越负面。
词典法的最大优势是透明可解释——你可以清楚地看到是哪几个词触发了正面或负面判断,这在需要向监管机构或客户解释分析逻辑的场景里非常重要。
学术背景:Loughran-McDonald 词典
在金融文本分析领域,最权威的词典是 Loughran & McDonald(2011)专门为金融文本构建的英文情感词典。他们发现,通用情感词典(如 Harvard General Inquirer)在金融场景下表现很差——因为很多在日常语境里是负面的词(如「liability」「risk」「volatile」),在金融报告里是中性的专业术语。
这个教训对中文金融文本同样适用:通用情感词典直接用于金融文本,准确率会明显下降,需要使用领域专属词典。
本章使用的词典已针对金融场景做了初步筛选,存放在 dicts/ 目录下。
27.2.3 加载词典与数据
import pandas as pd
import jieba
import os
# 加载情感词典
def load_wordlist(filepath):
"""读取词表文件,每行一个词,返回集合。"""
with open(filepath, "r", encoding="utf-8") as f:
return set(line.strip() for line in f if line.strip())
positive_words = load_wordlist("dicts/positive_words.txt")
negative_words = load_wordlist("dicts/negative_words.txt")
print(f"正面词:{len(positive_words)} 个,示例:{list(positive_words)[:6]}")
print(f"负面词:{len(negative_words)} 个,示例:{list(negative_words)[:6]}")
# 读取研报数据
df_r = pd.read_csv("data_raw/analyst_reports.csv", encoding="utf-8-sig")
print(f"\n已读取研报数据:{len(df_r)} 条")27.2.4 计算情感得分
def sentiment_score(text, pos_words, neg_words):
"""
词典法情感得分。
返回:(得分, 正面词列表, 负面词列表)
保留触发词是为了方便后续排查和解释。
"""
if not isinstance(text, str):
return 0.0, [], []
tokens = jieba.lcut(text)
pos_hits = [w for w in tokens if w in pos_words]
neg_hits = [w for w in tokens if w in neg_words]
p, n = len(pos_hits), len(neg_hits)
score = (p - n) / (p + n + 1) # +1 避免除以零
return round(score, 4), pos_hits, neg_hits
# 对每条研报摘要计算情感得分
results = df_r["summary"].apply(
lambda t: sentiment_score(t, positive_words, negative_words)
)
df_r["dict_score"] = results.apply(lambda x: x[0])
df_r["dict_pos_hits"] = results.apply(lambda x: x[1])
df_r["dict_neg_hits"] = results.apply(lambda x: x[2])
# 查看结果
df_r[["report_id", "firm", "label", "dict_score", "dict_pos_hits", "dict_neg_hits"]]27.2.5 验证准确率与可视化
import matplotlib.pyplot as plt
import matplotlib
plt.rcParams["font.family"] = "SimHei"
# plt.rcParams["font.family"] = "PingFang SC" # macOS
plt.rcParams["axes.unicode_minus"] = False
# 按人工标注分组,看词典法得分分布
label_order = ["bullish", "neutral", "bearish"]
label_names = {"bullish": "看多", "neutral": "中性", "bearish": "看空"}
colors = {"bullish": "#2196F3", "neutral": "#9E9E9E", "bearish": "#F44336"}
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# 左图:各组得分散点
ax = axes[0]
for label in label_order:
subset = df_r[df_r["label"] == label]
ax.scatter(
[label_names[label]] * len(subset),
subset["dict_score"],
color=colors[label], s=80, zorder=3
)
ax.axhline(0, color="black", linewidth=0.8, linestyle="--")
ax.set_ylabel("词典法情感得分")
ax.set_title("各类研报的词典法得分分布")
ax.set_ylim(-1, 1)
# 右图:各组平均得分柱状图
ax2 = axes[1]
means = df_r.groupby("label")["dict_score"].mean().reindex(label_order)
bars = ax2.bar(
[label_names[l] for l in label_order],
means.values,
color=[colors[l] for l in label_order]
)
ax2.axhline(0, color="black", linewidth=0.8, linestyle="--")
ax2.set_ylabel("平均情感得分")
ax2.set_title("词典法平均得分:各类研报")
ax2.set_ylim(-0.5, 0.5)
plt.tight_layout()
plt.savefig("Fig/sentiment_dict_results.png", dpi=150, bbox_inches="tight")
plt.show()
print("\n各组平均情感得分:")
print(means.round(3).to_string())27.2.6 词典法的局限:否定词问题
词典法最常见的失误,来自否定词 + 正面词的组合:
- 「不稳健」→ 词典法把「稳健」计为正面词 ✗
- 「未改善」→ 词典法把「改善」计为正面词 ✗
- 「难以回避」→ 词典法可能把「回避」或其他词误判 ✗
这在金融文本里尤其常见,因为研报和公告措辞往往很谨慎,大量使用否定句式。
否定词修正:一个实用的简单方案
完整处理否定词需要句法分析,但一个实用的近似方法是:在统计词频之前,先检测否定词(「不」「无」「未」「非」「没有」)之后紧接的正面词,将其情感极性翻转。
import re
# 检测「否定词 + 正面词」模式,临时替换为「[NEG]」标记
negation_prefix = r"(不|无|未|非|没有?)"
for pos_word in positive_words:
pattern = negation_prefix + pos_word
text = re.sub(pattern, "[NEG]", text)这个方法不完美,但在大规模批量处理时,能显著减少误判率。
我有一个中文金融文本 DataFrame,summary 列是研报摘要,已准备好正面词表(positive_words)和负面词表(negative_words)。请编写一个改进版的情感分析函数,要求:
- 在分词前,先用正则表达式检测「不/无/未/非/没有」等否定前缀后紧跟正面词的情况,将其标记为负面
- 计算情感得分:
(正面词数 - 负面词数) / (正面词数 + 负面词数 + 1) - 返回:得分、触发的正面词列表、触发的负面词列表(含被翻转的词)
- 对比加入否定词修正前后,结果有哪些变化
- 所有代码加详细中文注释
27.3 情感分析:AI 方法
27.3.1 为什么要用 AI 方法
词典法的核心局限,不只是否定词。更根本的问题是:它不理解语境。
考虑这句话:「公司盈利能力有所改善,但仍低于行业平均水平。」
词典法会抓到「改善」(正面),但可能忽略整句话的真实含义——这其实是一个偏负面的判断(低于均值)。大语言模型能理解完整语境,判断这句话整体上是保守的。
AI 方法的适用场景:文本数量几十到几千条、需要理解语境、可以承受一定的 API 费用。
词典法的适用场景:文本数量几万条以上、需要完全可复现、对计算成本敏感、需要向他人解释每个判断的依据。
27.3.2 调用 Anthropic API 做情感分析
下面演示如何用 Claude API 对研报摘要做情感分析,并要求模型返回结构化的 JSON 输出,方便后续直接进入 DataFrame。
# !pip install anthropic # 第一次使用时取消注释
import anthropic
import json
import time
import pandas as pd
# API Key 建议存放在环境变量里,不要硬编码在代码中
# 在终端运行:set ANTHROPIC_API_KEY=your_key_here (Windows)
# 或:export ANTHROPIC_API_KEY=your_key_here (macOS/Linux)
client = anthropic.Anthropic() # 自动读取环境变量 ANTHROPIC_API_KEY
SYSTEM_PROMPT = """你是一位专业的金融分析师,专门分析券商研报的情感倾向。
你的任务是判断研报摘要的整体情感倾向,并给出结构化的 JSON 输出。
输出格式(只输出 JSON,不要有任何其他文字):
{"sentiment": "bullish/neutral/bearish", "score": 数字(-1到1之间), "reason": "简短理由(20字以内)"}
判断标准:
- bullish(看多):维持/上调目标价、买入/增持/强烈推荐评级、盈利超预期
- bearish(看空):下调目标价/评级、减持/卖出、盈利不达预期、流动性风险
- neutral(中性):维持中性/持有评级、符合预期、观望"""
def analyze_sentiment_ai(text, client, max_retries=3):
"""
用 Claude API 分析单条文本的情感。
包含重试逻辑,避免网络波动导致批量任务中断。
"""
for attempt in range(max_retries):
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=100,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": text}]
)
raw = response.content[0].text.strip()
result = json.loads(raw) # 解析 JSON
return result
except json.JSONDecodeError:
# 模型偶尔会输出格式不符合 JSON 的文本,重试
if attempt == max_retries - 1:
return {"sentiment": "error", "score": 0, "reason": "解析失败"}
except Exception as e:
if attempt == max_retries - 1:
return {"sentiment": "error", "score": 0, "reason": str(e)[:30]}
time.sleep(2 ** attempt) # 指数退避重试
# 批量分析(加间隔避免触发速率限制)
ai_results = []
for _, row in df_r.iterrows():
result = analyze_sentiment_ai(row["summary"], client)
ai_results.append(result)
time.sleep(0.5) # 每条间隔 0.5 秒
df_r["ai_sentiment"] = [r["sentiment"] for r in ai_results]
df_r["ai_score"] = [r["score"] for r in ai_results]
df_r["ai_reason"] = [r["reason"] for r in ai_results]
print("AI 分析完成")
df_r[["report_id", "firm", "label", "ai_sentiment", "ai_score", "ai_reason"]]我有一批券商研报摘要,需要判断每条的情感倾向。请按以下要求设计提示词和代码:
- 数据背景:中文券商研报摘要,每条约 100-150 字,涵盖不同行业
- 任务:判断每条研报的情感倾向(bullish/neutral/bearish),给出 -1 到 1 的得分,并说明判断依据(20 字以内)
- 输出格式:严格 JSON,字段为
sentiment、score、reason,不要有任何额外文字 - 工程要求:① 包含重试逻辑(最多 3 次);② 每条请求间隔 0.5 秒;③ 解析失败时记录错误而不是中断整个循环;④ 最终结果合并回原始 DataFrame
- 请同时说明如何估算这批数据的 API 费用
所有代码加详细中文注释。
27.4 两种方法对比与讨论
27.4.1 准确率对比
import pandas as pd
# 把词典法得分也转换为三分类标签,方便比较
def score_to_label(score, threshold=0.05):
"""将连续得分转为三分类标签,threshold 控制中性区间宽度。"""
if score > threshold: return "bullish"
if score < -threshold: return "bearish"
return "neutral"
df_r["dict_label"] = df_r["dict_score"].apply(score_to_label)
# 计算两种方法的准确率
dict_acc = (df_r["dict_label"] == df_r["label"]).mean()
ai_acc = (df_r["ai_sentiment"] == df_r["label"]).mean()
print(f"词典法准确率:{dict_acc:.1%}")
print(f"AI 方法准确率:{ai_acc:.1%}")
# 完整对比表
comparison = df_r[["report_id", "firm", "label",
"dict_score", "dict_label",
"ai_score", "ai_sentiment", "ai_reason"]].copy()
comparison["dict_correct"] = comparison["dict_label"] == comparison["label"]
comparison["ai_correct"] = comparison["ai_sentiment"] == comparison["label"]
comparisonimport matplotlib.pyplot as plt
# 两种方法的得分散点对比
fig, ax = plt.subplots(figsize=(8, 6))
colors_map = {"bullish": "#2196F3", "neutral": "#9E9E9E", "bearish": "#F44336"}
label_cn = {"bullish": "看多", "neutral": "中性", "bearish": "看空"}
for label, group in df_r.groupby("label"):
ax.scatter(
group["dict_score"], group["ai_score"],
color=colors_map[label], label=label_cn[label],
s=80, zorder=3
)
ax.axhline(0, color="grey", linewidth=0.8, linestyle="--")
ax.axvline(0, color="grey", linewidth=0.8, linestyle="--")
ax.set_xlabel("词典法得分")
ax.set_ylabel("AI 方法得分")
ax.set_title("词典法 vs AI 方法:情感得分对比")
ax.legend(title="真实标签")
ax.set_xlim(-1, 1)
ax.set_ylim(-1, 1)
plt.tight_layout()
plt.savefig("Fig/sentiment_comparison.png", dpi=150, bbox_inches="tight")
plt.show()27.4.2 两种方法的系统性对比
| 维度 | 词典法 | AI 方法 |
|---|---|---|
| 准确率 | 中等,受否定词影响 | 高,能理解语境 |
| 可解释性 | 强,可追溯到具体词 | 弱,模型内部不透明 |
| 可复现性 | 完全可复现 | 受模型更新影响 |
| 处理规模 | 百万条级别 | 受 API 速率和成本限制 |
| 成本 | 几乎为零 | 按 token 计费 |
| 适合场景 | 大规模批量、学术研究 | 小批量、语境复杂的文本 |
在实际工作中,两种方法往往配合使用:
先用词典法跑全量数据(快、便宜),再用 AI 方法抽查 10%-20% 的样本做验证。如果两种方法高度一致(相关系数 > 0.7),可以对词典法结果有较高信心;如果分歧较大,说明该语料的情感信号比较复杂,需要更多人工核查。
27.4.3 将情感结果保存
# 保存完整分析结果,供后续综合实战使用
df_r.to_csv("output/analyst_reports_sentiment.csv", index=False, encoding="utf-8-sig")
print("已保存到 output/analyst_reports_sentiment.csv")27.5 主题模型:LDA 与 LLM 方法
27.5.1 主题模型要解决什么问题
词频和 TF-IDF 告诉你「哪些词重要」,情感分析告诉你「文本的态度是什么」,但还有一类问题它们都回答不了:这批文档在讨论哪几个核心议题?
想象你拿到了过去五年中国所有上市公司年报的 MD&A 章节,共 20000 份文档。你想知道:
- 这些文档自动可以归纳成几个主题?
- 哪些公司更多讨论「数字化转型」,哪些更多讨论「成本控制」?
- 某个主题的热度是否与宏观周期相关?
- 主题权重能不能作为变量,预测公司未来的盈利表现?
这正是主题模型(Topic Model) 的应用场景。
27.5.2 LDA 的直觉
LDA(Latent Dirichlet Allocation,潜在狄利克雷分配)是最经典的主题模型,由 Blei、Ng 和 Jordan 于 2003 年提出。它的核心假设非常直觉:
每篇文档是多个主题的混合,每个主题是一组词的分布。
举个例子,假设我们对年报做 LDA,设定 3 个主题,模型可能发现:
- 主题 1(词:风险、不确定性、下行、压力、挑战)→ 你命名为「风险披露」
- 主题 2(词:研发、创新、专利、技术、数字化)→ 你命名为「技术战略」
- 主题 3(词:收入、增长、利润、市场、客户)→ 你命名为「业绩展望」
同时,每篇文档会得到一个主题分布向量,比如:
\[ \theta_i = (0.6,\ 0.3,\ 0.1) \quad \text{(风险披露 60\%,技术战略 30\%,业绩展望 10\%)} \]
这个向量就是文档 \(i\) 的「主题特征」,可以直接作为变量使用。
LDA 在金融研究中的典型应用
- Hansen, McMahon & Prat(2018):对美联储 FOMC 会议记录做 LDA,构造政策不确定性指标,发现主题变化能预测市场波动(Econometrica)
- Hoberg & Phillips(2016):用年报文本相似度和主题分布重新定义行业边界,发现比 SIC 行业分类更能解释企业竞争行为(Journal of Political Economy)
- 国内研究:对上市公司年报 MD&A 做 LDA,提取「风险话语」主题权重,研究其与未来股价崩盘风险的关系
27.5.3 用 gensim 实现 LDA(快速演示)
# !pip install gensim # 第一次使用时取消注释
import pandas as pd
import jieba
import os
from gensim import corpora, models
# 读取人民银行问答数据(篇幅较长,适合主题提取)
df_qa = pd.read_csv("data_raw/pbc_qa_raw.csv", encoding="gbk")
# 加载停用词
with open("dicts/stopwords_cn_finance.txt", "r", encoding="utf-8") as f:
stopwords = set(line.strip() for line in f if line.strip())
# 分词(复用上一章的 tokenize 逻辑)
def tokenize_for_lda(text):
if not isinstance(text, str):
return []
words = jieba.lcut(text)
return [w for w in words
if len(w) >= 2 and w not in stopwords and not w.isdigit()]
df_qa["tokens"] = df_qa["text"].apply(tokenize_for_lda)
# 过滤掉 token 数量过少的文档(LDA 对极短文本效果差)
df_qa = df_qa[df_qa["tokens"].apply(len) >= 20].copy()
print(f"有效文档数:{len(df_qa)}")# 构建词典和语料库
dictionary = corpora.Dictionary(df_qa["tokens"])
# 过滤极高频词和极低频词:出现次数少于 3 次、或出现在超过 50% 文档中的词去掉
dictionary.filter_extremes(no_below=3, no_above=0.5)
# 将每篇文档转为词袋(BoW)格式:[(词id, 词频), ...]
corpus = [dictionary.doc2bow(tokens) for tokens in df_qa["tokens"]]
print(f"词典大小:{len(dictionary)} 个词项")
print(f"语料规模:{len(corpus)} 篇文档")# 训练 LDA 模型
# num_topics:主题数,这是 LDA 最重要的超参数,需要根据语料规模和业务理解来设定
# passes:训练轮数,越大结果越稳定,但耗时更长
# random_state:固定随机种子,保证结果可复现
NUM_TOPICS = 5
lda_model = models.LdaModel(
corpus=corpus,
id2word=dictionary,
num_topics=NUM_TOPICS,
passes=20,
random_state=42
)
# 查看每个主题的 Top 10 词
print("各主题 Top 10 词(需要人工命名):\n")
for topic_id in range(NUM_TOPICS):
top_words = lda_model.show_topic(topic_id, topn=10)
words_str = " | ".join([f"{w}({p:.3f})" for w, p in top_words])
print(f"主题 {topic_id}:{words_str}")import numpy as np
# 提取每篇文档的主题分布向量——这是 LDA 真正可用于后续分析的产出
def get_topic_vector(bow, model, num_topics):
"""将单篇文档的 BoW 表示转为主题分布向量,长度 = num_topics。"""
topic_dist = dict(model.get_document_topics(bow, minimum_probability=0))
return [topic_dist.get(i, 0.0) for i in range(num_topics)]
topic_vectors = [get_topic_vector(bow, lda_model, NUM_TOPICS) for bow in corpus]
topic_df = pd.DataFrame(
topic_vectors,
columns=[f"topic_{i}" for i in range(NUM_TOPICS)]
)
# 合并回原始 DataFrame
df_qa_with_topics = pd.concat(
[df_qa[["title", "date"]].reset_index(drop=True), topic_df],
axis=1
)
print("主题分布向量(前 3 条):")
df_qa_with_topics.head(3)27.5.4 把主题权重纳入回归分析
上面得到的 topic_df 每行是一篇文档、每列是该文档在各主题上的权重。这个矩阵可以直接作为变量纳入后续分析。
典型的使用方式:
import statsmodels.formula.api as smf
# 假设你有一个包含收益率数据的 DataFrame df_stock
# 先把主题向量和股票数据按日期合并
df_merged = df_stock.merge(df_qa_with_topics[["date", "topic_0", "topic_1"]],
on="date", how="left")
# 以主题权重为自变量,股票超额收益为因变量,做 OLS 回归
model = smf.ols("excess_return ~ topic_0 + topic_1 + controls", data=df_merged).fit()
print(model.summary())这里有几个实践中的要点需要注意:
主题数的选择没有唯一答案。 常见做法是:从较小的主题数(3-5)开始,逐步增加,同时用 Coherence Score(主题连贯性得分)评估模型质量,选择连贯性最高且主题仍然可解释的那个数量。对于本章的 185 条问答,5 个主题是合理的起点。
主题必须人工命名。 LDA 只输出词的组合,「主题 2」是什么含义,需要你根据 Top 词来判断。这个命名过程是主观的,不同研究者可能给同一组词起不同的名字。在论文里,通常需要详细说明命名依据,并请同行评审确认。
注意主题分布的加总约束。 每篇文档的主题权重之和为 1(\(\sum_k \theta_{ik} = 1\)),如果把所有主题权重都放进回归,会产生完全多重共线性。实践中通常省略一个主题(作为基准组),或者用主成分分析(PCA)降维后再进入回归。
27.5.5 LDA 的局限与 LLM 方法的兴起
LDA 有几个在实际使用中很难绕开的问题:
- 主题数需要预先指定:你必须告诉模型「有几个主题」,但这个数字往往不清楚
- 主题需要人工命名:模型只给词,含义靠你猜,不同研究者的解读可能不一致
- 短文本效果差:新闻标题、公告标题这类短文本,LDA 往往无法收敛到有意义的主题
- 结果不稳定:不同随机种子、不同训练轮数,可能得到差异较大的结果
2023 年以来,越来越多的金融文本分析研究开始用 LLM 替代 LDA 做主题提取。基本流程是:
- 预先定义一个主题列表(基于业务理解或文献)
- 把每篇文档发给 LLM,让它从预设列表中选择最匹配的主题(单选或多选)
- 汇总统计,得到每个主题的文档占比或每篇文档的主题标签
这种方法的优势是结果直接可解释、不需要调参、对短文本也有效。代价是 API 费用和可复现性问题。
下面是一个用 LLM 做主题分类的完整演示:
import anthropic, json, time, pandas as pd
# 预定义主题列表(基于对人民银行问答内容的业务判断)
TOPIC_LIST = [
"货币政策操作", # MLF、逆回购、降准等具体操作
"汇率与跨境资本", # 人民币汇率、外汇管理、跨境支付
"金融稳定与风险", # 系统性风险、银行业监管、房地产金融
"信贷与实体经济", # 贷款投放、小微企业、绿色金融
"国际金融合作", # 双边货币合作、SWIFT、数字货币国际化
]
TOPIC_PROMPT = f"""你是一位中央银行研究专家。请判断以下人民银行答记者问的主要议题。
从下面的主题列表中选择 1-2 个最匹配的主题(如果只有一个主题明显匹配,只选一个):
{chr(10).join(f'- {t}' for t in TOPIC_LIST)}
严格按以下 JSON 格式输出,不要有其他文字:
{{"topics": ["主题名称"], "confidence": 0.0到1.0之间的数字}}"""
client = anthropic.Anthropic()
def classify_topic(text, client):
"""用 LLM 对单篇文档做主题分类,返回主题列表和置信度。"""
try:
# 只取前 500 字,节省 token(通常开头已经包含核心议题)
snippet = str(text)[:500]
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=80,
system=TOPIC_PROMPT,
messages=[{"role": "user", "content": snippet}]
)
return json.loads(response.content[0].text.strip())
except Exception as e:
return {"topics": ["解析失败"], "confidence": 0.0}
# 演示:只跑前 10 条(避免耗费过多 API 费用)
sample = df_qa.head(10).copy()
topic_results = []
for _, row in sample.iterrows():
result = classify_topic(row["text"], client)
topic_results.append(result)
time.sleep(0.5)
sample["llm_topics"] = [r["topics"] for r in topic_results]
sample["llm_confidence"] = [r["confidence"] for r in topic_results]
sample[["title", "llm_topics", "llm_confidence"]]27.5.6 LLM 主题分析面临的主要挑战
在把 LLM 方法用于正式研究之前,有几个问题需要认真考虑:
① 用哪个模型?
目前学术研究中常用的有 GPT-4(OpenAI)、Claude(Anthropic)、以及开源模型(Llama、Qwen)。开源模型的优势是可以本地部署,完全可控、成本可预期;但在中文金融文本的理解质量上,目前仍弱于 GPT-4 和 Claude。国内研究还需考虑数据合规问题——把上市公司未公开信息发给境外 API,存在一定的合规风险。
② 成本和效率
以 Claude Sonnet 为例,每 1000 个输入 token 约 $0.003。一篇年报 MD&A 约 3000-5000 字,对应约 2000-3500 token。如果要处理 5000 份年报,成本大约在 30-50 美元之间——对学术研究来说可以接受,但如果处理全 A 股 20 年的年报(约 10 万份),成本将达到数百至上千美元。
实践中的节省策略:只取每篇文档的前 500-800 字(通常包含核心信息);对相似文档做去重;先用词典法筛选出关键文档,再用 LLM 精分析。
③ 如何应对可复现性质疑
这是目前学术界争议最大的问题。同一段文本,用同一个模型,在不同时间调用可能得到不同的结果(即使温度参数设为 0,模型更新也会改变输出)。审稿人和编辑越来越关注这个问题。
目前学界的常见做法:
可复现性的实践建议
- 记录模型版本:在论文中明确写出使用的模型和版本号(如
claude-sonnet-4-6),不要只写「Claude」或「GPT-4」 - 固定温度参数:
temperature=0会让输出更确定,虽然不能完全保证跨时间一致,但减少随机性 - 保存原始输出:把模型的每一条原始输出保存到文件,作为研究数据的一部分留存,以便审稿人核查
- 人工抽查验证:随机抽取 5%-10% 的样本,人工逐条核对模型输出是否准确,在论文附录中报告抽查准确率
- 使用开源模型:如果可复现性是核心要求,考虑使用固定版本的开源模型(如 Llama-3.1-8B)本地部署,完全控制模型版本
- 与传统方法对比:同时报告 LDA 或词典法的结果,说明两种方法的结论一致,增强结果的稳健性
④ LDA vs LLM:如何选择
| 场景 | 推荐方法 | 原因 |
|---|---|---|
| 探索性分析,不知道有几个主题 | LDA | 无监督,自动发现主题结构 |
| 已有预设主题框架,需要大规模分类 | LLM | 结果直接可解释,短文本也有效 |
| 数据量极大(>10万篇),成本敏感 | LDA | 几乎零边际成本 |
| 顶刊投稿,可复现性要求严格 | LDA 为主 + LLM 验证 | 两种方法互相印证 |
| 企业内部分析,时效性优先 | LLM | 无需调参,结果即时可解读 |
27.6 综合实战:构建政策语气情感指数
27.6.1 任务说明
这一节把前面所有方法串联起来,用人民银行新闻数据完成一个完整的分析任务:
构建月度政策语气情感指数(2025 年 11 月 — 2026 年 3 月),观察央行对外传递的信息基调是否在这段时间内发生变化。
这个任务在学术研究中有真实对应:用央行沟通文本的情感变化预测货币政策操作方向,是货币经济学领域的成熟研究范式(参见 Schmeling & Wagner, 2019; Hansen et al., 2018)。
工作流:
\[ \text{读取新闻} \xrightarrow{\text{复用第 3 章}} \text{分词+停用词} \rightarrow \text{词典法情感} \rightarrow \text{月度聚合} \rightarrow \text{时序图} \]
import pandas as pd
import jieba
import re
# 读取新闻数据(复用第 3 章的清洗和分词逻辑)
df_news = pd.read_csv("data_raw/pbc_news_raw.csv", encoding="gbk")
df_news["date_std"] = pd.to_datetime(df_news["date"], errors="coerce")
df_news = df_news.dropna(subset=["date_std", "text"]).copy()
# 加载停用词和情感词典
with open("dicts/stopwords_cn_finance.txt", "r", encoding="utf-8") as f:
stopwords = set(line.strip() for line in f if line.strip())
positive_words = load_wordlist("dicts/positive_words.txt")
negative_words = load_wordlist("dicts/negative_words.txt")
# 基础文本清洗
def clean_text(text):
if not isinstance(text, str): return ""
text = re.sub(r"http[s]?://\S+|www\.\S+", "", text)
text = re.sub(r"<[^>]+>", "", text)
text = re.sub(r"\s+", " ", text.replace("\n", " "))
return text.strip()
df_news["text_clean"] = df_news["text"].apply(clean_text)
# 计算每条新闻的情感得分(直接复用 4.1 节的函数)
results = df_news["text_clean"].apply(
lambda t: sentiment_score(t, positive_words, negative_words)
)
df_news["sentiment"] = results.apply(lambda x: x[0])
print(f"情感得分计算完成,共 {len(df_news)} 条")
print(f"得分分布:mean={df_news['sentiment'].mean():.3f}, "
f"min={df_news['sentiment'].min():.3f}, max={df_news['sentiment'].max():.3f}")import matplotlib.pyplot as plt
import matplotlib.dates as mdates
plt.rcParams["font.family"] = "SimHei"
plt.rcParams["axes.unicode_minus"] = False
# 按月聚合情感指数:取均值,并统计当月新闻条数(条数越少,指数可信度越低)
df_news["month"] = df_news["date_std"].dt.to_period("M")
monthly = df_news.groupby("month").agg(
sentiment_mean=("sentiment", "mean"),
sentiment_std=("sentiment", "std"),
news_count=("sentiment", "count")
).reset_index()
monthly["month_dt"] = monthly["month"].dt.to_timestamp()
# 绘制情感指数时序图
fig, ax1 = plt.subplots(figsize=(12, 5))
# 主轴:情感指数折线
ax1.plot(monthly["month_dt"], monthly["sentiment_mean"],
marker="o", color="steelblue", linewidth=2, label="月均情感得分")
# 误差带(±1 std)
ax1.fill_between(
monthly["month_dt"],
monthly["sentiment_mean"] - monthly["sentiment_std"],
monthly["sentiment_mean"] + monthly["sentiment_std"],
alpha=0.15, color="steelblue", label="±1 标准差"
)
ax1.axhline(0, color="black", linewidth=0.8, linestyle="--")
ax1.set_ylabel("情感得分(正=偏正面,负=偏负面)")
ax1.set_xlabel("月份")
# 次轴:新闻条数柱状图
ax2 = ax1.twinx()
ax2.bar(monthly["month_dt"], monthly["news_count"],
alpha=0.2, color="grey", width=20, label="当月新闻数")
ax2.set_ylabel("新闻条数")
# 合并图例
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left")
ax1.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
plt.title("人民银行新闻月度情感指数(2025.11—2026.03)")
plt.tight_layout()
plt.savefig("Fig/pbc_sentiment_index.png", dpi=150, bbox_inches="tight")
plt.show()
print("\n月度情感指数汇总:")
print(monthly[["month", "sentiment_mean", "news_count"]].to_string(index=False))27.6.2 结果解读与延伸思考
运行完上面的代码,你会看到一条月度情感指数的时序曲线。有几个问题值得带着数据去思考:
- 哪个月的情感指数最高/最低?这与当时的宏观经济背景是否吻合?
- 情感指数的变动,是否在央行随后的政策操作(降准、降息等)中得到了印证?
- 新闻条数较少的月份(误差带会更宽),情感指数的可信度如何?
这个分析框架在学术研究中可以直接扩展:
# 扩展思路(不需要当场运行,了解方向即可)
# 1. 把情感指数与利率数据合并,看领先-滞后关系
# df_merged = monthly.merge(df_rates, on='month', how='left')
# df_merged['sentiment_lag1'] = df_merged['sentiment_mean'].shift(1)
# 2. Granger 因果检验:情感指数是否 Granger 导致利率变化
# from statsmodels.tsa.stattools import grangercausalitytests
# grangercausalitytests(df_merged[['rate_change', 'sentiment_mean']], maxlag=3)
# 3. 把情感指数作为控制变量纳入资产定价模型
# model = smf.ols('stock_return ~ mkt_rf + smb + hml + sentiment_lag1', data=df).fit()我有一个带日期的中文新闻 DataFrame(date_std 列为标准日期,text_clean 列为清洗后正文),以及正面词表和负面词表。请完成:
- 用词典法计算每条新闻的情感得分:
(正面词数 - 负面词数) / (正面词数 + 负面词数 + 1) - 按月聚合:计算月均情感得分、标准差、当月新闻条数
- 绘制时序图:主轴是情感得分折线(含 ±1std 误差带),次轴是当月新闻条数柱状图
- 在图上标注情感得分最高和最低的月份
- 输出月度汇总表,并用 3-5 句话解读趋势
要求:图表保存到 Fig/ 文件夹,所有代码加中文注释。
27.7 本章小结与练习
27.7.1 本章技能清单
| 任务 | 工具/方法 | 关键注意点 |
|---|---|---|
| 词典法情感分析 | 自定义词表 + jieba | 处理否定词;词典要针对金融场景 |
| AI 情感分析 | Anthropic API | 设计好提示词;包含重试逻辑;保存原始输出 |
| 两种方法对比 | pandas | 先词典法跑全量,再 AI 方法抽查验证 |
| LDA 主题模型 | gensim | 主题数需要试验;主题必须人工命名 |
| LLM 主题分类 | API + 预设主题列表 | 记录模型版本;保存原始输出;人工抽查 |
| 情感时序指数 | pandas + matplotlib | 注意样本量少的月份,误差带会变宽 |
27.7.2 两章合在一起的完整工作流
\[ \underbrace{\text{读取} \to \text{清洗} \to \text{分词} \to \text{词频/TF-IDF}}_{\text{第 3 章:文本变成数据}} \quad\longrightarrow\quad \underbrace{\text{情感分析} \to \text{主题提取} \to \text{指标构造} \to \text{纳入分析}}_{\text{第 4 章:数据变成洞察}} \]
这两章合在一起,构成了一套完整的中文金融文本分析流水线。学完之后,你应该能够独立完成从「一批原始文本」到「可以进入回归分析的文本指标」的全过程。
27.7.3 下一章预告
下一章:网络爬虫
本章和上一章使用的数据,是已经整理好的 120 条新闻和 185 条问答。但如果你想分析过去 5 年所有的人民银行沟通文本,或者某行业所有上市公司的年报,这些数据需要自动化地从网页上采集。
下一章将介绍网络爬虫的基本原理和实现,采集目标正是本章和上一章的数据来源——人民银行官网和巨潮资讯网。你会看到这两章的分析方法,在有了更大规模的数据之后,能发现哪些更有价值的规律。
27.7.4 课后练习
用本章的词典法,对
pbc_qa_raw.csv里的答记者问计算情感得分。按年份分组,观察人民银行问答语气的年度变化趋势,并结合当年的货币政策背景(降准/加息时期)解读结果。修改情感词典:在
positive_words.txt和negative_words.txt中各补充 5 个你认为在金融语境中重要的词,重新计算研报情感得分,对比修改前后准确率的变化。对 15 条模拟研报,用 AI 方法进行主题分类(从「公司基本面」「行业前景」「估值分析」「评级变化」中选择),统计各主题的分布,并与你自己阅读后的判断对比。
用 gensim 对
pbc_qa_raw.csv做 LDA,分别试验 3、5、7 个主题,比较各个主题数下的 Top 词,判断哪个数量的主题分割最合理,并为每个主题命名。进阶练习:把月度情感指数与同期的 10 年期国债收益率(或 DR007 利率)数据合并,画散点图观察两者的关系。尝试用滞后一期的情感指数解释当期利率变化,讨论这个关系在经济上是否有意义。
27.8 参考文献
- Loughran, T., & McDonald, B. (2011). When is a liability not a liability? Textual analysis, dictionaries, and 10-Ks. The Journal of Finance, 66(1), 35–65.
- Blei, D. M., Ng, A. Y., & Jordan, M. I. (2003). Latent Dirichlet allocation. Journal of Machine Learning Research, 3, 993–1022.
- Hansen, S., McMahon, M., & Prat, A. (2018). Transparency and deliberation within the FOMC: A computational linguistics approach. Quarterly Journal of Economics, 133(2), 801–870.
- Hoberg, G., & Phillips, G. (2016). Text-based network industries and endogenous product differentiation. Journal of Political Economy, 124(5), 1423–1465.
- Schmeling, M., & Wagner, C. (2019). Does central bank tone move asset prices? SSRN Working Paper.