CiteSpace关键词突现分析指标实战:从数据清洗到可视化呈现
背景痛点:突现分析为何总卡在第一步
科研团队在 WoS 导出“全记录与引文”后,常遇到三类尴尬:
- 字段错位:同一列混有作者关键词、增补关键词、标题词,CiteSpace 直接报错“Empty keyword field”。
- 时间缺失:早期文献缺“Early Date”或“Publication Year”,导致突现检测无法滑动时间窗。
- 强度不透明:CiteSpace 的 Burst 强度公式藏在 C 源码,参数 γ、λ 调多少全凭经验,复现困难。
结果往往是“软件跑一晚,图谱全红条”,却解释不了哪段突现真正代表热点转折。
技术方案:一条 Python 流水线拆坑
数据清洗:三行代码统一格式
Pandas 先解决“字段错位”与“时间缺失”:
import pandas as pd def normalize_wos(path: str) -> pd.DataFrame: """ 读取 WoS 导出的纯文本或 Excel,返回统一字段的 DataFrame """ # 自动识别分隔符 try: df = pd.read_csv(path, sep='\t') except ValueError: df = pd.read_excel(path) # 合并三种关键词列 kw_cols = ['Author Keywords', 'Keywords Plus', 'Article Title'] df['keywords'] = ( df[kw_cols] .fillna('') .agg('; '.join, axis=1) .str.lower() .str.replace('-', ' ', regex=False) ) # 统一时间字段 df['year'] = pd.to_datetime( df['Publication Date'].fillna(df['Early Access Date']), errors='coerce' ).dt.year # 丢掉无年份或无关键词的记录 return df.dropna(subset=['year', 'keywords']).reset_index(drop=True)调用示例:
raw_df = normalize_wos('wos_raw.txt') raw_df.to_csv('clean.csv', index=False, encoding='utf-8-sig')共现网络:用 NetworkX 搭“骨架”
把清洗后的关键词拆成列表,再滑窗统计共现:
from itertools import combinations import networkx as nx def build_cooccurrence(df: pd.DataFrame, window: int = 2) -> nx.Graph: """ 按年份滑窗构建共现网络,window 为单侧年数 """ G = nx.Graph() min_y, max_y = int(df['year'].min()), int(df['year'].max()) for y in range(min_y + window, max_y - window + 1): sub = df[(df['year'] >= y - window) & (df['year'] <= y + window)] kw_list = [set(kw.split('; ')) for kw in sub['keywords']] for kws in kw_list: for a, b in combinations(kws, 2): if G.has_edge(a, b): G[a][b]['weight'] += 1 else: G.add_edge(a, b, weight=1) return G生成邻接矩阵备用:
adj = nx.to_pandas_adjacency(G, weight='weight') adj.to_csv('adj_matrix.csv')突现强度算法:滑动 Kleinberg Burst
Kleinberg 的 γ-β 模型把关键词出现序列看成自动机状态,突现强度等于“高状态”与“低状态”的对数似 ratio。下面给出核心循环,省去数学推导,直接上代码:
import numpy as np from scipy.special import logsumexp def burst_intensity(series: np.ndarray, gamma: float = 1.0) -> float: """ 输入:某关键词逐年出现次数的一维数组 输出:该关键词的突现强度 """ T = len(series) if T < 3: return 0.0 # 状态 0=低,1=高 log_p = np.zeros((T, 2)) # 初始状态 log_p[0, 0 = np.log(0.9) log_p[0, 1] = np.log(0.1) for t in range(1, T): # 转移概率 stay0 = np.log(0.9) up01 = np.log(0.1) stay1 = np.log(0.9) down10 = np.log(0.1) # 发射概率:泊松近似 lam0, lam1 = 1, series[t] + gamma emit0 = -lam0 + series[t] * np.log(lam0 + 1e-9) emit1 = -lam1 + series[t] * np.log(lam1 + 1e-9) # 前向累加 log_p[t, 0] = emit0 + logsumexp([log_p[t-1, 0] + stay0, log_p[t-1, 1] + down10]) log_p[t, 1] = emit1 + logsumexp([log_p[t-1, 0] + up01, log_p[t-1, 1] + stay1]) # 强度取最大似然状态差 return float(np.max(log_p[:, 1]) - np.min(log_p[:, 0]))对全体关键词批量计算:
yearly = df.groupby(['year', 'keywords']).size().unstack(fill_value=0) burst_map = {kw: burst_intensity(yearly[kw].values) for kw in yearly.columns}可视化实践:让突现“自己说话”
Matplotlib 动态热力图
把突现强度、时间段一起画进热力图,一眼锁定“红得发紫”的关键词:
import matplotlib.pyplot as plt import seaborn as sns # 构造矩阵:行=关键词,列=年份,值=出现次数 heat = df.groupby(['keywords', 'year']).size().unstack(fill_value=0) # 标注突现区间 burst_df = [] for kw, s in heat.iterrows(): idx = pd.Series(s.values, index=s.index) # 简单阈值法:连续 2 年大于均值 2 倍即视为突现 meanv = idx.mean() burst_y = idx[idx > meanv * 2].index.tolist() if burst_y: burst_df.append({'kw': kw, 'start': min(burst_y), 'end': max(burst_y)}) burst_df = pd.DataFrame(burst_df) # 绘图 plt.figure(figsize=(12, 8)) sns.heatmap(heat.loc[burst_df['kw']], cmap='Reds', cbar_kws={'label': 'freq'}) # 用竖线标突现段 for _, row in burst_df.iterrows(): y_pos = heat.index.get_loc(row.kw) plt.axvspan(row.start - 0.4不言自明,row.end + 0.4不言自明, ymin=(y_pos-0.3不言自明)/heat.shape[0], ymax=(y_pos+0.3不言自明)/heat.shape[0], color='blue', alpha=0.3) plt.title('Keyword Burst Heatmap') plt.xlabel('Year') plt.ylabel('Keywords') plt.tight_layout() plt.savefig('burst_heatmap.png', dpi=300)PyVis 交互网络
把突现强度映射为节点大小,时间段映射为颜色,拖拽即可过滤:
from pyvis.network import Network net = Network(height='750px', width='100%', bgcolor='#ffffff', font_color='black') # 节点属性 for n in G.nodes: net.add_node(n, value=burst_map.get(n, 0), title=f"burst={burst_map.get(n, 0):.2f}", color='lightblue' if burst_map.get(n, 0) < 2 else 'red') # 边权重 for a, b, w in G.edges(data='weight'): net.add_edge(a, b, width=np.log1p(w)) net.show('burst_network.html')浏览器打开burst_network.html,可直接放大、隐藏低突现节点,迅速定位“核心枢纽”。
避坑指南:让大数据集也能跑通
中文关键词编码
- 读文件时统一
encoding='utf-8-sig',写文件再用utf-8-sig带 BOM,Excel 打开不乱码。 - 若遇到“锟斤拷”,八成是 Windows 控制台下回显问题,把终端切到
chcp 65001或在 Jupyter 里运行即可。
时间切片参数
- 窗口并非越大越好。文献少于 5000 篇时,建议
window=1;万级文献可试window=2,否则网络边数指数膨胀,内存炸。 - 若领域发展快(如 AI 会议),把切片单位改成“季度”:将
year换成year-quarter组合字段,其余代码不变。
内存优化技巧
- 先
df.astype({'year': 'int16'}),年份列占内存立降 75%。 - 共现统计用
pandas.concat + groupby替代嵌套 for,速度 ×3。 - NetworkX 吃内存凶,万级节点可换
igraph或graph-tool,再转回nx.Graph()仅做可视化。
代码规范小结
- 全程遵守 PEP8,行宽 ≤88,函数名小写加下划线,常量全大写。
- 每个函数附 docstring,关键行写“# 说明”,拒绝神秘数字。
- 绘图标签一律英文,方便直接投刊;中文需求可在
plt.xticks里再fontproperties指定宋体。
延伸思考:把脚本搬到 VOSviewer
VOSviewer 的.txt输入格式仅三列:Label、Weight、Score。把burst_map导出成对应结构,即可用同一套清洗数据先做 VOS 密度图,再回 CiteSpace 做突现,对比双工具的热点差异。迁移步骤:
- 清洗阶段完全一致,仍用
normalize_wos。 - 计算完突现强度后,输出
vos_bur.txt:
(pd.DataFrame({'Label': list(burst_map.keys()), 'Weight': [G.degree[n] for n in burst_map.keys()], 'Score': list(burst_map.values())}) .to_csv('vos_bur.txt', sep='\t', index=False))- VOSviewer → Create → Map based on text file,即可得到“突现强度地图”,与 CiteSpace 的红条互相验证。
写在最后
整条流程跑下来,从“原始 WoS 导出”到“交互式突现网络”大约 120 行代码,全程开源库,Windows / macOS 都能复现。把脚本扔进服务器,设定好路径和参数,新文献一键更新,图谱自动刷新,真正省下通宵点鼠标的手速。至于参数 γ、窗口大小,不妨先按文中默认值跑通,再在小范围调参看敏感曲线,逐步找到适合自己领域的“黄金组合”。祝各位科研搬砖少踩坑,热点一眼看穿。