ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从唐宋诗分析看数据科学实践:特征工程、聚类算法与跨学科解读

从唐宋诗分析看数据科学实践:特征工程、聚类算法与跨学科解读 1. 项目概述从一道赛题到一套完整的数据科学实践去年带学生备赛时我重新梳理了2022年认证杯SPSSPRO杯数学建模B题第二阶段的题目发现它远不止是一道竞赛题而是一个绝佳的、从数据获取到模型解释的完整数据科学项目范本。题目要求对唐宋两代的诗歌进行定量分析与比较这听起来像是文科课题实则是对参赛者数据思维、算法应用和跨学科解读能力的综合考验。很多初次接触这类题目的同学容易陷入两个极端要么一头扎进复杂的诗词理论里出不来要么只盯着几个聚类算法跑一遍了事最后得出的结论要么空洞无物要么与文学常识相去甚远。这道题的核心价值在于它逼着你用理性的、可量化的方法去处理感性的、充满模糊性的文本对象。你需要把一首首意境深远的诗歌拆解成一个个可以计算的特征向量比如词频、意象、情感倾向、格律特征等然后运用统计学和机器学习的方法去寻找隐藏在数据背后的模式。最终的目标不是证明某个算法多厉害而是通过这些模式去回答一些文学史上关心的问题唐诗和宋诗在主题偏好、情感基调、语言风格上究竟有何异同这些差异能否用数据清晰地呈现出来整个过程就是一个标准的“问题定义 - 数据采集与清洗 - 特征工程 - 模型选择与验证 - 结果分析与可视化”的数据科学工作流。接下来我将结合这道赛题的求解全过程拆解其中每一个关键环节并分享我们团队在实战中总结出的文档组织心法和编程技巧。2. 解题核心思路与整体设计面对“唐宋诗定量分析与比较”这样一个开放性问题首要任务是将其转化为一系列可操作、可量化的子问题。直接问“唐诗和宋诗有什么不同”太模糊了。我们的思路是将其分解为三个层次的分析整体风格轮廓对比、主题群落发现、以及代表性诗人风格的微观透视。这分别对应了描述性统计、无监督聚类和典型案例分析。在工具选型上我们选择了Python作为主力工具链。原因很简单生态丰富、灵活性强。对于文本处理Jieba用于中文分词和词性标注SnowNLP或BosonNLP的情感词典可用于简单的情感分析更复杂的可以用预训练模型如BERT。对于统计分析、矩阵运算和可视化Pandas,NumPy,Matplotlib和Seaborn是黄金组合。而核心的聚类分析我们重点评估了K-Means、DBSCAN和谱聚类Spectral Clustering。这里有一个关键的取舍K-Means简单高效但它假设簇是凸形的且大小相近对于高维稀疏的文本向量如TF-IDF矩阵效果不一定好且需要预先指定K值聚类数量。而DBSCAN的优势在于不需要预设簇数能发现任意形状的簇并能识别噪声点那些不属于任何主流风格的“另类”诗作这非常符合文学分析的直觉——总有那么些特立独行的作品。它的核心参数是邻域半径eps和最小样本数min_samples调参需要技巧。谱聚类在处理非凸数据和高维数据时理论上更优但它计算复杂度高对相似度矩阵的构建非常敏感。我们的策略是组合使用。先用层次聚类或基于密度的DBSCAN对样本进行初步探索大致了解数据中可能存在多少个自然聚集的群组以及它们的分布密度这可以为K-Means的K值选择提供参考。然后可能会用K-Means在降维后的数据上进行清晰划分以便于解释。整个分析流程的蓝图如下构建唐宋诗语料库 - 文本清洗与分词 - 特征提取词频、TF-IDF、情感词表、自定义意象词典- 特征降维PCA/t-SNE- 聚类分析多算法对比- 统计检验与可视化 - 文学意义解读。注意切忌一开始就陷入复杂的深度学习模型。数学建模竞赛时间有限且评阅更看重方法应用的合理性和结果解释的清晰度而不是模型的复杂度。一个用TF-IDFDBSCAN得出的、能自圆其说的结论远比一个用了BERT却解释不清的“黑箱”结果得分高。3. 数据获取、清洗与特征工程实战3.1 语料库构建与数据清洗理想的数据源是《全唐诗》和《全宋诗》的数字化版本。网络上可以找到一些开源的数据集但质量参差不齐。我们当时从一个学术网站爬取了约5万首唐诗和20万首宋诗宋诗数量明显更多。原始数据问题很多含有大量注释、标点不统一、存在异体字和繁体字、还有不少残句或伪作。清洗是枯燥但至关重要的一步。我们的清洗管道Pipeline包括编码统一确保全部文本为UTF-8编码。去除元信息用正则表达式剔除诗题、作者、朝代标记之外的所有内容如“卷123”、“注...”。繁体转简体使用opencc库进行转换保证分词一致性。标点与特殊字符处理删除所有现代标点。等但保留诗歌内部的顿号、逗号可视为断句因为它们可能影响分词和节奏分析。同时删除数字、空格等无关字符。非诗行过滤基于长度和规则过滤掉明显过短如少于8字或不符合诗歌格式的行。import re import opencc import pandas as pd def clean_poem_text(raw_text): 清洗单首诗歌文本 # 1. 移除标题、作者、注释等元数据假设格式为“标题|作者|正文” lines raw_text.split(\n) # 简单示例取最后两行作为正文根据实际数据格式调整 poem_body \n.join(lines[-2:]) if len(lines) 2 else raw_text # 2. 繁体转简体 converter opencc.OpenCC(t2s) poem_body converter.convert(poem_body) # 3. 移除特定标点保留句读 # 删除现代标点保留古诗句读如、。 poem_body re.sub(r[“”‘’【】《》…—\s], , poem_body) # 可以将逗号、顿号替换为特殊标记或空格便于后续处理 poem_body re.sub(r[、], , poem_body) # 4. 移除数字和英文 poem_body re.sub(r[a-zA-Z0-9], , poem_body) return poem_body.strip() # 示例读取原始数据并清洗 df pd.read_csv(raw_poems.csv) df[cleaned_text] df[raw_content].apply(clean_poem_text)3.2 分词与自定义词典使用Jieba进行分词。默认词典对于古汉语效果不佳必须引入自定义词典。我们从《汉语大词典》和诗歌意象研究论文中整理了一份“诗歌意象词典”包含“明月”、“清风”、“孤舟”、“浊酒”、“烽火”、“阑干”等数百个高频且富有文学意义的词汇将它们加入Jieba词典确保这些关键意象不会被切碎。import jieba # 加载自定义词典 jieba.load_userdict(poetry_imagery_dict.txt) def segment_poem(poem_text): # 使用精确模式并开启HMM以识别未登录词 words jieba.lcut(poem_text, cut_allFalse, HMMTrue) # 过滤掉单字除非是特定的重要单字意象如“山”、“水” # 这里我们先不过滤在特征提取时再处理 return words df[segmented] df[cleaned_text].apply(segment_poem)3.3 特征提取从文字到数字这是定量分析的基石。我们提取了四类特征词频与TF-IDF特征这是最核心的。将整个语料库唐诗宋诗的所有分词结果构建一个大的词袋Bag-of-Words然后计算每首诗在各个词上的TF-IDF值。TF-IDF能削弱高频常见词如“之”、“乎”、“者”、“也”的权重提升有区分度词汇的重要性。意象密度特征基于自定义的意象词典统计每首诗中出现的意象词数量并除以诗歌总词数得到“意象密度”。我们假设唐诗可能更注重意象的密集排列。情感倾向特征使用SnowNLP计算每首诗的情感极性得分0-1越接近1越积极。虽然古汉语情感分析不准但作为一个粗糙的对比指标仍有参考价值。更精细的做法是构建一个古汉语情感词典。简单格律特征计算每首诗的字数、句数、平均句长。宋诗在形式上更为散文化平均句长可能更长。from sklearn.feature_extraction.text import TfidfVectorizer from snownlp import SnowNLP # 1. TF-IDF特征 # 将分词列表重新组合成以空格分隔的字符串 df[segmented_str] df[segmented].apply(lambda x: .join(x)) vectorizer TfidfVectorizer(max_features5000) # 限制特征维度防止维度灾难 tfidf_matrix vectorizer.fit_transform(df[segmented_str]) # tfidf_matrix 是一个稀疏矩阵每行代表一首诗每列代表一个词的TF-IDF值 # 2. 意象密度特征 imagery_dict set([line.strip() for line in open(poetry_imagery_dict.txt, r, encodingutf-8)]) def calculate_imagery_density(word_list): imagery_count sum([1 for word in word_list if word in imagery_dict]) return imagery_count / len(word_list) if len(word_list) 0 else 0 df[imagery_density] df[segmented].apply(calculate_imagery_density) # 3. 情感得分特征 (仅供参考) def get_sentiment_score(text): try: return SnowNLP(text).sentiments except: return 0.5 # 中性默认值 df[sentiment] df[cleaned_text].apply(get_sentiment_score) # 4. 格律特征 df[word_count] df[segmented].apply(len) df[line_count] df[cleaned_text].apply(lambda x: len([c for c in x if c in [ , , 。]])) 1 # 粗略估算句数 df[avg_line_len] df[word_count] / df[line_count]4. 降维、聚类分析与可视化4.1 降维从高维空间到可视平面TF-IDF矩阵可能有数千维无法直接观察。我们使用t-SNE进行降维因为它特别擅长在二维或三维空间中保持高维数据的局部结构适合可视化聚类效果。PCA虽然计算快但它是线性降维对于文本这种复杂结构t-SNE通常能展示出更清晰的簇状分布。from sklearn.manifold import TSNE import matplotlib.pyplot as plt import seaborn as sns # 假设我们已经有了特征矩阵 X可以是TF-IDF矩阵也可以是融合了其他特征的矩阵 # 这里以TF-IDF矩阵为例 X tfidf_matrix.toarray() # 如果内存足够可以转成稠密矩阵。否则用PCA先降到50维再用t-SNE # 使用t-SNE降维到2D tsne TSNE(n_components2, random_state42, perplexity30, n_iter1000) X_tsne tsne.fit_transform(X) df[tsne_x] X_tsne[:, 0] df[tsne_y] X_tsne[:, 1]4.2 DBSCAN聚类实战与参数调优我们决定先用DBSCAN做探索性分析因为它能告诉我们数据中“自然”形成的核心群体和噪声。from sklearn.cluster import DBSCAN from sklearn.neighbors import NearestNeighbors import numpy as np # **关键步骤如何确定DBSCAN的eps参数** # 使用k-距离图k-distance graph来辅助选择 def find_optimal_eps(X, min_samples5, k4): 通过k近邻距离图寻找eps的拐点 min_samples: DBSCAN的min_samples参数 k: 计算第k近邻的距离通常取min_samples - 1 neigh NearestNeighbors(n_neighborsk) nbrs neigh.fit(X) distances, indices nbrs.kneighbors(X) # 取每个点到其第k近邻的距离并排序 k_distances np.sort(distances[:, k-1]) # 绘制k-距离图 plt.plot(range(len(k_distances)), k_distances) plt.xlabel(Points sorted by distance) plt.ylabel(f{k}-th nearest neighbor distance) plt.title(K-Distance Graph for Eps Selection) plt.grid(True) plt.show() # 拐点处曲线突然上升对应的y值可以作为eps的参考 # 通常选择拐点之后的某个平坦区域的起始值 return k_distances # 假设我们使用降维后的特征如PCA的前50个主成分进行聚类效果更好 from sklearn.decomposition import PCA pca PCA(n_components50) X_pca pca.fit_transform(X.toarray()) k_distances find_optimal_eps(X_pca, min_samples10, k9) # 观察图像假设拐点在距离1.5附近 eps_guess 1.5 # 应用DBSCAN dbscan DBSCAN(epseps_guess, min_samples10, metriceuclidean) df[dbscan_label] dbscan.fit_predict(X_pca) # 分析聚类结果 n_clusters len(set(df[dbscan_label])) - (1 if -1 in df[dbscan_label] else 0) n_noise list(df[dbscan_label]).count(-1) print(f估计的聚类数量: {n_clusters}) print(f噪声点数量: {n_noise}) print(f噪声点比例: {n_noise/len(df):.2%})实操心得DBSCAN的eps和min_samples需要联动调整。min_samples越大对核心点的要求越严格形成的簇越少噪声越多。对于文本数据由于维度高且稀疏直接在高维TF-IDF空间计算欧氏距离效果可能不好。一个有效的技巧是先进行PCA降维保留85%-95%的方差在降维后的空间进行DBSCAN距离度量会更稳定。另外务必可视化聚类结果在t-SNE图上用颜色标注DBSCAN的标签直观检查聚类是否合理。4.3 K-Means聚类与轮廓系数评估DBSCAN给出了自然簇的参考数量后我们可以用K-Means进行更规整的划分并使用轮廓系数Silhouette Score评估聚类质量。from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score # 基于DBSCAN的结果或肘部法则确定K值范围 k_range range(2, 15) silhouette_scores [] inertias [] for k in k_range: kmeans KMeans(n_clustersk, random_state42, n_initauto) cluster_labels kmeans.fit_predict(X_pca) # 同样在降维后的数据上操作 silhouette_avg silhouette_score(X_pca, cluster_labels) silhouette_scores.append(silhouette_avg) inertias.append(kmeans.inertia_) # 绘制轮廓系数和肘部图 fig, (ax1, ax2) plt.subplots(1, 2, figsize(14, 5)) ax1.plot(k_range, silhouette_scores, bo-) ax1.set_xlabel(Number of clusters (K)) ax1.set_ylabel(Silhouette Score) ax1.set_title(Silhouette Score for different K) ax1.grid(True) ax2.plot(k_range, inertias, ro-) ax2.set_xlabel(Number of clusters (K)) ax2.set_ylabel(Inertia (Within-cluster SSE)) ax2.set_title(Elbow Method for Optimal K) ax2.grid(True) plt.show() # 选择轮廓系数较高且 inertia 下降趋势变缓的K值 optimal_k 8 # 假设根据图表选择8 final_kmeans KMeans(n_clustersoptimal_k, random_state42, n_initauto) df[kmeans_label] final_kmeans.fit_predict(X_pca)4.4 可视化让数据自己说话将降维后的点按朝代、聚类标签着色是发现模式的关键。plt.figure(figsize(16, 6)) # 子图1按朝代着色唐 vs 宋 plt.subplot(1, 2, 1) scatter plt.scatter(df[tsne_x], df[tsne_y], cdf[dynasty].map({唐:0, 宋:1}), cmapcoolwarm, alpha0.6, s10) plt.colorbar(scatter, labelDynasty (0:Tang, 1:Song)) plt.title(t-SNE Visualization Colored by Dynasty) plt.xlabel(t-SNE 1) plt.ylabel(t-SNE 2) # 子图2按K-Means聚类标签着色 plt.subplot(1, 2, 2) scatter plt.scatter(df[tsne_x], df[tsne_y], cdf[kmeans_label], cmaptab20, alpha0.6, s10) plt.colorbar(scatter, labelK-Means Cluster) plt.title(ft-SNE Visualization Colored by K-Means Clusters (K{optimal_k})) plt.xlabel(t-SNE 1) plt.ylabel(t-SNE 2) plt.tight_layout() plt.show()通过对比左右两图你可以直观地看到某些颜色的簇来自右图是否明显更多地分布在唐朝点集或宋朝点集左图中。这初步揭示了与朝代相关的风格聚类。5. 结果分析与文学解读从数字回到诗歌聚类完成后最重要的是解读每个簇的“文学意义”。我们为每个簇K-Means的结果做了以下分析提取簇内高频词统计每个簇所有诗歌的TF-IDF特征向量的均值找出权重最高的前20个词。这些词定义了这个簇的“主题词汇表”。查看簇的朝代构成计算每个簇中唐诗和宋诗的比例。寻找代表性诗作对于每个簇找到距离簇中心最近的那几首诗作为该风格的典型代表。# 1. 提取每个簇的高频词 feature_names vectorizer.get_feature_names_out() cluster_centers final_kmeans.cluster_centers_ # 形状为 (n_clusters, n_features_pca) # 注意cluster_centers_ 是在PCA空间中的中心。要反推回原始TF-IDF空间的高频词需要复杂转换。 # 更简单直接的方法针对每个簇从原始TF-IDF矩阵中筛选出属于该簇的诗歌然后对它们的TF-IDF向量求平均。 top_n 15 for cluster_id in range(optimal_k): cluster_indices df[df[kmeans_label] cluster_id].index if len(cluster_indices) 0: # 获取该簇所有诗的TF-IDF向量原始空间 cluster_tfidf tfidf_matrix[cluster_indices] # 计算平均向量 avg_tfidf np.asarray(cluster_tfidf.mean(axis0)).flatten() # 获取权重最高的词索引 top_indices avg_tfidf.argsort()[-top_n:][::-1] top_words [feature_names[i] for i in top_indices] print(f\nCluster {cluster_id} (Size: {len(cluster_indices)})) print(fTop Words: {, .join(top_words)}) # 2. 计算朝代比例 tang_ratio (df.loc[cluster_indices, dynasty] 唐).mean() print(fTang Poem Ratio: {tang_ratio:.2%})基于这些分析我们可能得到如下解读示例簇A边塞军旅高频词为“沙场”、“烽火”、“胡马”、“玉门关”、“将军”。唐诗比例高达85%。这与文学史认知相符边塞诗是唐诗的一大高峰。簇B山水田园高频词为“青山”、“流水”、“白云”、“樵夫”、“幽静”。唐诗和宋诗比例相当但细看高频词唐诗中“空山”、“明月”等更显空灵宋诗中“理趣”、“寻常”等词权重稍高暗示宋诗山水可能更富哲理思考。簇C个人抒怀与哲理高频词包含“人生”、“世事”、“百年”、“愁”、“笑”。宋诗比例超过70%。这印证了宋诗“以议论为诗”、“言理”的特点。簇D咏物诗高频词为“梅”、“竹”、“菊”、“兰”、“咏”。宋诗比例略高且用词更加精细、具体可能与宋代文人画和“格物”精神有关。DBSCAN识别出的噪声点分析这些“离群诗”发现其中包含一些形式特别如长篇叙事诗、风格极其怪异如某些禅诗、或者内容俚俗的打油诗。这恰恰说明了DBSCAN的价值——它帮我们过滤掉了不适合参与主流风格分析的样本让核心聚类更纯净。6. 建模过程常见问题与调试实录在实际操作中我们遇到了不少坑这里总结出来希望能帮你绕过。问题1特征矩阵维度爆炸内存不足或计算极慢。现象使用TF-IDF时词表超过10万维导致矩阵巨大后续聚类和t-SNE无法进行。排查与解决限制最大特征数TfidfVectorizer(max_features5000)只保留最重要的5000个词。加大词频过滤TfidfVectorizer(min_df10, max_df0.8)忽略在少于10首诗或超过80%的诗中出现的词。使用哈希向量化HashingVectorizer可以固定维度但不可逆不利于后续的词频分析。先做PCA这是最有效的。先用PCA将高维TF-IDF降至50-200维保留大部分方差再进行聚类和t-SNE。计算速度会大幅提升且噪声被抑制。问题2DBSCAN将所有样本都标记为噪声-1。现象eps值太小或min_samples太大。排查与解决绘制k-距离图选择一个拐点后的平缓区域值作为eps。对于文本PCA降维后的数据eps通常在0.5到3之间尝试。逐步减小min_samples。对于文本数据由于噪声多min_samples不宜过大从3、5、10开始尝试。检查距离度量。在高维稀疏文本空间余弦距离通常比欧氏距离更适合。可以尝试DBSCAN(metriccosine)但注意eps的取值范围会变为[0,2]。确保输入数据已经过标准化StandardScaler特别是当你融合了不同量纲的特征如TF-IDF值和意象密度时。问题3聚类结果在文学解释上说不通。现象算法分出的簇其高频词杂乱无章或与朝代毫无关联。排查与解决特征质量问题回头检查分词和特征工程。自定义词典是否够全是否引入了太多无意义的停用词情感分析特征是否噪音太大特征融合方式直接将TF-IDF向量、意象密度、情感值拼接在一起由于量纲和重要性不同会扭曲距离计算。应该先对数值型特征进行标准化或者尝试为不同类型特征赋予权重如TF-IDF权重为1意象密度权重为0.2或者分别聚类再集成。算法选择问题K-Means可能不适合你的数据分布。尝试换用谱聚类它对于发现非凸形状的簇更有效。或者使用层次聚类通过树状图Dendrogram来观察数据的层次结构手动决定切割距离。降维失真t-SNE的perplexity参数对结果影响巨大。尝试不同的值通常在5到50之间观察聚类结构是否稳定。问题4结果不稳定每次运行聚类标签会变。现象K-Means由于初始中心随机选择可能导致结果有轻微差异。排查与解决设置random_state参数以保证可复现性。对于K-Means增加n_init参数如设为10或‘auto’让算法用不同的初始中心多跑几次选择最优解。更根本的方法是使用聚类稳定性作为评估指标。多次运行聚类算法检查同一样本被分到同一簇的频率。频率高的簇更可靠。问题5如何定量比较唐宋诗的差异现象只有可视化感觉缺乏统计说服力。排查与解决卡方检验针对某个高频词如“春风”列一个2x2的列联表唐诗出现/未出现宋诗出现/未出现进行卡方检验判断该词在两个朝代的分布是否有显著差异。T检验/Mann-Whitney U检验对于数值特征如平均句长、意象密度、情感得分可以分别计算唐诗和宋诗的均值然后进行独立样本T检验若数据符合正态分布或Mann-Whitney U检验非参数检验判断差异是否显著。可视化增强除了散点图可以绘制小提琴图来对比两个朝代在某个特征如情感得分上的分布差异。整个项目做下来我的体会是数学建模的魅力不在于用了多炫酷的算法而在于如何严谨地构建一条从原始问题到数学表达再到合理解释的通道。这道唐宋诗的题目就是一个完美的练手场。它要求你既懂点文学知道该提取什么特征又懂点数据科学知道怎么处理和分析最后还得能把冷冰冰的聚类结果翻译成有温度的文学洞察。最后给准备类似比赛的同学一个建议尽早开始可视化让图表引导你的分析思路而不是等到最后才画图。很多时候一个t-SNE图带来的灵感比闷头调参要有用得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进