LDA 主题演化分析:追踪 5 万条政策文件 5 年的热点变迁

LDA 跑出来的主题是”静态的”——只能告诉你这个语料库在讲什么。老板们更想知道的是:主题怎么随时间变?今年和去年比,哪些主题变火、哪些变冷?

这就需要 LDA 主题演化分析——把语料按时间切片,分别跑 LDA,再对比主题强度变化。

我们的 LDA 主题演化图表分析软件 一次性出 7 大分析结果(主题概率分布、强度词分布、演化热力图、折线图、柱状图等),下面拆解原理和代码。

一、核心思路:时间切片 + 主题对齐

1
2
3
4
5
6
7
# 假设语料格式: [(date, tokens), ...]
corpus = [
('2022-01', ['创新', '驱动', '发展']),
('2022-02', ['数字化', '转型', '升级']),
# ...
('2026-05', ['人工智能', '大模型', '产业']),
]

关键步骤

  1. 按时间切片:比如按月/季度/年分成 N 个时间窗
  2. 每个时间窗单独跑 LDA:得到该时间窗的主题-词分布 + 文档-主题分布
  3. 主题对齐:不同时间窗的主题编号是独立的,T1 在 2022 是”创新”,在 2024 可能是别的。用主题相似度(c_v coherence 或 JS 散度)做对齐——把不同年份的”最像”的主题归为同一个演化主题
  4. 算主题强度:每个时间窗里每个主题的平均文档-主题概率,就是该主题在该时间窗的”热度”
  5. 画演化图:横轴时间,纵轴主题(对齐后),颜色深浅 = 强度

二、完整代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import pandas as pd
import numpy as np
import gensim
from gensim.models import LdaModel, CoherenceModel
import matplotlib.pyplot as plt
import seaborn as sns
from collections import defaultdict

# ========== 1. 数据准备 ==========
df = pd.read_csv('policy_docs.csv') # 含 date, content, title
df['date'] = pd.to_datetime(df['date'])
df['year_month'] = df['date'].dt.to_period('M')
df['tokens'] = df['content'].apply(jieba.lcut) # 假设已分词

# 全局词典(必须统一,否则主题无法对齐)
dictionary = gensim.corpora.Dictionary(df['tokens'])
dictionary.filter_extremes(no_below=10, no_above=0.5)

# ========== 2. 按时间切片跑 LDA ==========
time_slices = sorted(df['year_month'].unique())
K = 10 # 主题数
per_slice_models = {}

for period in time_slices:
slice_df = df[df['year_month'] == period]
if len(slice_df) < 50: # 数据太少跳过
continue
slice_corpus = [dictionary.doc2bow(t) for t in slice_df['tokens']]
lda = gensim.models.LdaModel(
corpus=slice_corpus,
id2word=dictionary,
num_topics=K,
passes=15,
random_state=42
)
per_slice_models[period] = {
'model': lda,
'corpus': slice_corpus,
'doc_topic_dist': lda.get_document_topics(slice_corpus, minimum_probability=0)
}

# ========== 3. 算每个时间窗的主题强度 ==========
intensity_matrix = [] # 行:时间窗,列:主题(K 个)
for period in time_slices:
if period not in per_slice_models:
continue
doc_topic = np.array(per_slice_models[period]['doc_topic_dist']) # (N_docs, K)
avg_intensity = doc_topic.mean(axis=0) # 每个主题的平均概率
intensity_matrix.append(avg_intensity)

intensity_df = pd.DataFrame(
intensity_matrix,
index=[str(p) for p in time_slices if p in per_slice_models]
)

三、4 大核心可视化

1. 演化热力图(最直观)

1
2
3
4
5
6
7
8
9
10
11
12
13
fig, ax = plt.subplots(figsize=(14, 8))
sns.heatmap(
intensity_df.T, # 转置:主题为行,时间为列
cmap='YlOrRd',
annot=True, fmt='.2f',
cbar_kws={'label': '主题强度(平均文档-主题概率)'},
ax=ax
)
ax.set_title('LDA 主题强度演化热力图', fontsize=16, pad=15)
ax.set_xlabel('时间')
ax.set_ylabel('主题编号')
plt.tight_layout()
plt.savefig('topic_evolution_heatmap.png', dpi=150)

怎么看:颜色越深,主题在该时间越火。一眼看出”什么火什么冷”——比如电商评论里”物流”主题春节前一定暴涨,疫情期”健康”主题一定暴涨。

2. 主题强度折线图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fig, ax = plt.subplots(figsize=(14, 6))
for topic_id in range(K):
top_words = ' '.join(per_slice_models[time_slices[0]]['model'].show_topic(topic_id, topn=3)[0])
label = f'T{topic_id}: {top_words}'
ax.plot(intensity_df.index, intensity_df[topic_id], 'o-', label=label, linewidth=2)

ax.set_title('LDA 主题强度随时间变化', fontsize=16)
ax.set_xlabel('时间')
ax.set_ylabel('主题强度')
ax.legend(bbox_to_anchor=(1.02, 1), loc='upper left', fontsize=9)
ax.grid(True, alpha=.3)
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig('topic_intensity_lines.png', dpi=150)

每条线代表一个主题,标签里写前 3 个 Top 词——老板能直接读懂”哦 T3 是聊手机续航的”。

3. 主题强度柱状图(按时间窗对比)

1
2
3
4
5
6
7
8
fig, axes = plt.subplots(len(intensity_df), 1, figsize=(12, 3*len(intensity_df)), sharex=True)
for i, (period, row) in enumerate(intensity_df.iterrows()):
axes[i].bar(range(K), row.values, color=plt.cm.viridis(row.values / row.values.max()))
axes[i].set_title(f'{period}', loc='left', fontsize=11)
axes[i].set_ylabel('强度')
plt.xlabel('主题编号')
plt.tight_layout()
plt.savefig('topic_intensity_bars.png', dpi=150)

4. 主题-词概率分布表

1
2
3
4
5
6
7
8
9
# 导出每个时间窗的主题-词分布,老板可以打开 Excel 看
with pd.ExcelWriter('topic_word_dist.xlsx', engine='openpyxl') as writer:
for period, data in per_slice_models.items():
lda = data['model']
rows = []
for tid in range(K):
for word, prob in lda.show_topic(tid, topn=20):
rows.append({'主题': f'T{tid}', '词': word, '概率': f'{prob:.4f}'})
pd.DataFrame(rows).to_excel(writer, sheet_name=str(period), index=False)

四、主题强度词分布表

除了主题-词概率还要看主题-时间强度——把两个维度合并成一张表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 每个主题在每个时间窗的"代表词强度"
strength_words = []
for period, data in per_slice_models.items():
lda = data['model']
for tid in range(K):
top_words = lda.show_topic(tid, topn=5)
words = ', '.join([w for w, _ in top_words])
intensity = data['doc_topic_dist'].mean(axis=0)[tid]
strength_words.append({
'时间窗': str(period),
'主题': f'T{tid}',
'强度': round(float(intensity), 4),
'代表词': words
})

pd.DataFrame(strength_words).to_csv('topic_strength_words.csv', index=False, encoding='utf-8-sig')

五、跨时间窗主题对齐(关键难点)

上面的代码有个隐藏 bug——不同时间窗跑 LDA,T1 编号在不同年份含义不一样。比如 2022 年的 T1 讲”创新驱动”,2024 年的 T1 可能讲”数字经济”——这是两个主题,但都被标成 T1,强度曲线就乱套了。

正解:用 主题相似度矩阵 + 贪心匹配 做对齐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from gensim.matutils import hellinger

def align_topics(models_dict, K):
"""用 Hellinger 距离做主题对齐,返回映射 {period: {orig_id: aligned_id}}"""
periods = list(models_dict.keys())
base = periods[0] # 以第一个时间窗为基准
alignment = {base: {i: i for i in range(K)}}

for prev, curr in zip(periods[:-1], periods[1:]):
prev_model = models_dict[prev]['model']
curr_model = models_dict[curr]['model']

# 算所有主题对之间的距离 (K x K 矩阵)
dist_matrix = np.zeros((K, K))
for i in range(K):
for j in range(K):
p = np.zeros(K); q = np.zeros(K)
p[i] = 1; q[j] = 1
# 实际应该用主题的词分布算 Hellinger
p_dist = np.array([prob for _, prob in prev_model.show_topic(i, topn=20)])
q_dist = np.array([prob for _, prob in curr_model.show_topic(j, topn=20)])
dist_matrix[i][j] = hellinger(p_dist, q_dist)

# 贪心匹配:相似度最低的优先配对
mapping = {}
used = set()
pairs = []
for i in range(K):
for j in range(K):
pairs.append((dist_matrix[i][j], i, j))
pairs.sort()
for dist, i, j in pairs:
if i not in mapping and j not in used:
mapping[i] = j
used.add(j)
# 没匹配上的分配新 ID
for i in range(K):
if i not in mapping:
new_id = max(used) + 1
mapping[i] = new_id
used.add(new_id)
alignment[curr] = mapping

return alignment

简化版:很多场景直接用主题词分布的余弦相似度做对齐就够,跳过上面的 Hellinger 复杂度。

六、典型应用场景

1. 政策研究:5 年政策文件 → 看国家在哪些领域加大力度
2. 商业决策:竞品评论 12 个月 → 看竞品口碑的热点漂移
3. 学术研究:某领域 10 年论文 → 看研究热点的演化
4. 舆情监控:突发事件的微博 → 看话题从”震惊”到”反思”的演化

七、踩坑大全

1. 主题对齐:上面对齐代码是简化版,工业级建议用 dynamic topic model (DTM)BERTopic 内置的对齐功能。
2. 切片粒度:太细(按天)= 每片数据太少,模型不稳;太粗(按年)= 演化趋势被抹平。建议按季度起步,根据语料量调。
3. 词典要统一:每个时间窗独立建词典 = 主题无法对齐。必须全局一个 dictionary
4. K 值固定:每个时间窗用相同的 K。如果 K 也变,对齐会非常乱。


我们的 LDA 主题演化图表分析软件 把上面所有代码封装成 7 大一键式分析结果:主题概率分布表、强度词分布表、演化热力图、折线图、柱状图、主题-词分布、词云图。导入 Excel 语料 → 一键出完整分析报告