
简介面向自然语言处理与舆情分析学习者的课程报告资源完整呈现基于微博数据的舆情热点分析与情感挖掘系统。针对微博短文本中的反讽、缩写及网络用语导致情感分类困难以及热点话题识别与热度预测的挑战内容从需求分析到系统测试环环相扣重点展示Scrapy-Redis分布式爬虫、多模型情感分析对比、融合文本与用户行为及时间序列的72小时热度预测等核心技术并给出舆情预警指数模型的设计思路。资源包共3个文件含PDF报告、Markdown实现指南与HTML可视化页面仅1.6MB紧凑易用。已有245人学习适合课程设计、毕业设计或作为构建同类舆情系统的参考。通过报告可快速理解短文本特征稀疏性的处理办法、多维特征融合的预测流程以及动态可视化界面的实现要点对掌握舆情分析全链路具有较高参考价值。1. 从一条负面微博到热点事件系统要做的四件事某公司产品发布后不到两小时微博热搜出现相关负面词条但运营团队直到第二天才看到舆情报告错失了最佳回应窗口。这个场景说明单纯靠人工刷微博或看热搜榜无法应对事件从发酵到扩散的短周期。基于微博数据的舆情热点分析与情感挖掘系统要解决的不只是“搜到相关微博”而是把这四个问题自动化数据从哪来、热点怎么定义、情感如何量化、结果如何让决策者一眼看懂。对 IT 从业者来说这套系统的难点不在算法本身——TF-IDF、LDA、情感分类都是成熟技术——而在于把微博特有的半结构化数据、短文本噪声、时间分布形态和工程化部署串成一条可维护的流水线。下文按照数据接入、热点识别、情感分析、可视化与调优的顺序给出一个能落地复现的工程方案。2. 微博数据接入与预处理从 JSON 垃圾场到干净语料微博数据是典型的海量半结构化数据单条微博包含 ID、正文文本、发布时间、用户信息、转发数、评论数、点赞数等几十个字段。做舆情分析时不可能全量存储所有字段也不能把原始 JSON 直接喂给分词器。这一章讲数据从哪取、怎么取、如何清洗成后续算法能用的格式。2.1 微博数据获取开放 API 与模拟请求的取舍微博开放平台提供 API但个人开发者的权限极有限v2 接口中搜索微博、获取用户时间线等核心能力都需要企业资质且频次限制严格。实践中绝大多数舆情系统使用爬虫方式获取公开页面数据。常见做法是模拟登录后的搜索接口请求直接拿 JSON 数据。2.1.1 搜索接口的请求结构与字段解析以微博移动端搜索页https://m.weibo.cn/api/container/getIndex为例请求参数containerid100103type1q关键词返回的 JSON 中data.cards数组里每条card_type9的项就是微博正文。下面是一个最小请求代码import requests import json headers { User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X), Referer: https://m.weibo.cn/search?containerid100103type%3D1%26q%3D特斯拉, } params { containerid: 100103type1q特斯拉, page_type: searchall, } resp requests.get(https://m.weibo.cn/api/container/getIndex, headersheaders, paramsparams, timeout10) data resp.json() cards data.get(data, {}).get(cards, []) for card in cards: if card.get(card_type) 9: mblog card.get(mblog, {}) print(mblog.get(id), mblog.get(text), mblog.get(created_at))这段代码的关键有两个headers中的 Referer 必须是搜索页地址否则接口返回100003错误page_typesearchall表示综合搜索可同时拿到热门微博和实时微博。返回的text字段是 HTML 源码包含a标签、表情图片span classurl-icon需要进一步清洗。注意爬取微博公开数据时需要控制请求频率单账号每秒超过 1 次很容易触发滑块验证。更稳妥的方案是准备多个账号轮换并将采集结果落库后立即断开会话避免长连接占用资源。2.1.2 增量采集与翻页控制搜索接口每次最多返回 50 条翻页参数为page。但微博对搜索结果页的最大页数有限制一般只能翻到 50 页左右。所以做历史回溯时不能只靠翻页要按时间段切分查询。比如要采集最近 7 天数据先按天分片再对每天的数据按关键词细分。增量采集则通过保存最后一次抓取的最大微博 ID 或时间戳下次启动时只抓新增部分def fetch_since_keyword(keyword, since_id, page1): params { containerid: f100103type1q{keyword}, page: page, } # 将 since_id 拼入 containerid 的 sinceid 参数 if since_id: params[containerid] fsinceid{since_id} ...这里sinceid是搜索接口的游标参数接受上一轮返回的最后一条微博 ID。注意不要把它和page混用——二者指向不同的分页机制。实际项目里我会把since_id存到 Redis 中配合定时任务每小时跑一次就能做到准实时增量。2.2 清洗与分词短文本噪声的定向清理微博文本的最大问题是噪声密集。转发语、话题标签、用户、URL、HTML 标签、表情符号全部混在正文里。清洗顺序错了分词效果会大打折扣。2.2.1 先剥 HTML 再剥特殊符号从接口拿到的text字段是 HTML第一步用正则把标签剥掉。然后处理微博正文里的常见噪声模式import re def clean_weibo_text(raw_html): # 去 HTML 标签 text re.sub(r[^], , raw_html) # 去掉 用户 text re.sub(r[\u4e00-\u9fa5a-zA-Z0-9_-], , text) # 去掉话题保留话题词本身因为它是热点的重要信号 text re.sub(r#([^#])#, r\1, text) # 去掉 URL text re.sub(rhttps?://\S, , text) # 去掉常见表情符号的 base64 占位 text re.sub(r\u200b, , text) return text.strip()其中话题标签#xxx#不能直接删而是去掉两端的井号、保留中间内容。因为舆情分析中话题词往往是事件的身份标识。表情符号如果是图片格式会以[微笑]这类文本形式出现可以保留——它们对情感分析有正向作用比如“好开心[心]”中的中括号内容就是强情感词。2.2.2 分词与停用词表的选择微博文本平均长度不足 40 字但包含大量网络新词、缩写和品牌词。直接使用 jieba 默认词典会把“打工人”切成“打工/人”。我的习惯是先加载用户自定义词典再加入领域专用的情感词典。import jieba import jieba.analyse jieba.load_userdict(weibo_dict.txt) # 每行一个词可带词频和词性 with open(stopwords.txt, encodingutf-8) as f: stop_words set(line.strip() for line in f) text clean_weibo_text(raw_html) words [w for w in jieba.cut(text) if w not in stop_words and w.strip()]停用词表不能直接照搬互联网通用表要自己补充微博特有词“转发微博”“网页链接”“图片评论”“展开全文”等。这些词在数据集中高频出现对 TF-IDF 和 LDA 都是强干扰。更细的做法是统计语料库中词频过高且信息量为零的词人工审查后加入停用词表。2.3 数据存储MongoDB 还是 ES预处理后的数据需要存储供后续分析。通常有两类选择MongoDB 适合存原始文档和清洗后的文本查询灵活Elasticsearch 适合做关键词检索和聚合统计但运维成本高。小规模舆情系统每日少于 10 万条用 MongoDB 足够热点分析需要分组统计时再用 Pandas 读入内存。from pymongo import MongoClient client MongoClient(mongodb://localhost:27017/) db client[weibo_sentiment] collection db[posts] post { mid: mblog[id], text: cleaned_text, user_id: mblog[user][id], followers_count: mblog[user][followers_count], reposts_count: mblog[reposts_count], comments_count: mblog[comments_count], attitudes_count: mblog[attitudes_count], timestamp: mblog[created_at], keyword: keyword } collection.create_index([(mid, 1)], uniqueTrue) # 保证幂等 collection.insert_one(post)mid唯一索引是必须的否则重复采集会污染时间序列统计。时间字段最好统一转为 ISO 时间字符串避免 MongoDB 和 Python 之间的时区错位。后续所有按小时聚合的查询都依赖这个字段。下表是数据接入阶段的三种存储方案对比方案优点缺点适用规模MongoDB结构灵活索引简单聚合分析性能一般日均 10 万条Elasticsearch全文检索快聚合能力强占用内存高需维护集群日均 50 万条ClickHouse列式存储扫描极快不适合频繁单条更新历史库归档3. 舆情热点分析与主题聚类从噪声中发现突发信号有了清洗后的微博语料下一步是回答“现在大家到底在讨论什么”。热点有两层含义一是词频突然上升的关键词二是围绕同一事件聚集的微博群体。前者用突发词检测后者用聚类或主题模型。3.1 关键词抽取TF-IDF 与 TextRank 的适用场景对短文本进行关键词抽取TF-IDF 的效果不稳定因为单条微博字数太少词频分布极度稀疏。针对这种情况我通常把文本拼接成小时级别的文档再计算 TF-IDF。3.1.1 按时间窗口聚合的 TF-IDF将同一小时内所有微博拼接成长文本看作一份文档。这样 24 小时就有 24 份文档计算每个词在当天各时间窗口中的 TF-IDF 权重权重突然升高的词就是候选热点from sklearn.feature_extraction.text import TfidfVectorizer import pandas as pd df pd.DataFrame(list(collection.find( {timestamp: {$gte: start, $lt: end}} ))) df[hour] pd.to_datetime(df[timestamp]).dt.hour docs df.groupby(hour)[text].apply(lambda x: .join(x)) vec TfidfVectorizer(token_patternr(?u)\b\w\b, max_features2000) tfidf_matrix vec.fit_transform(docs) words vec.get_feature_names_out()这里的token_pattern是专门为中文准备的。默认的正则只匹配类似英文单词的 token中文如果不连续会被拆成单个字符。我这里用了\b\w\b配合 jieba 预处理后的空格分词结果效果更好。max_features2000是经验值——微博热点词一般不会超过这个数量设置过大反而会引入低频噪声。3.1.2 TextRank 用于单文本关键词提取如果想从单条热评中提取核心实体TF-IDF 无能为力TextRank 更合适。Python 中 SnowNLP 内置了基于 TextRank 的关键词提取方法写法如下from snownlp import SnowNLP s SnowNLP(text) keywords s.keywords(5) # 返回关键词列表注意 SnowNLP 的 TextRank 是基于字符级的对英文单词支持差但纯中文微博场景效果还行。实际项目中我更推荐 jieba.analyse.textrank底层实现更完整支持词性过滤import jieba.analyse top_k jieba.analyse.textrank( text, topK5, withWeightTrue, allowPOS(ns, n, vn, v) # 地名、名词、动名词、动词 )allowPOS参数是关键。舆情热点往往是“事件主体 行为”的组合比如“产品”“发布”“回应”。加上名词和动词的限制后过滤掉“真的”“怎么”这类虚词准确率能提升不少。3.2 突发词检测用滑动窗口对比历史频率单纯的高频词不代表热点比如某品牌平时每天被提到 1000 次今天 1200 次属于正常波动。热点信号要看同比增长率。常见方法是维护每个词的长期日均频率再用滑动窗口计算当天频率与历史平均值的倍数。from collections import defaultdict, deque import math history defaultdict(float) # 每个词的日均频率 window deque(maxlen7) # 7 天滑动窗口 def is_burst(word, count, today_total): global history, window history[word] history.get(word, 0) * 0.9 count * 0.1 avg history[word] today_freq count / today_total if avg 0 and today_freq / avg 3.0: return True return False这个逻辑用了指数加权移动平均EWMA来更新基础频率比简单累加更适应长期趋势变化。3.0是突发阈值的经验值实际调参时需要观察数据集如果事件型舆情占比高阈值可以降到 2.5如果日常讨论波动大升到 4.0 更稳妥。突发词检测的难点是计算量。每天可能有数万个不同的词逐词维护历史频率很消耗内存。我的优化方案是只对每天 TF-IDF 排名前 500 的词做突发检测其余低频词直接忽略因为连续几天出现的高频词才可能是热点单日暴增的低频词多半是抽样噪声。3.3 主题聚类LDA 的困惑度与可解释性调整突发词只能告诉“什么词变热了”但一个事件可能包含多个子话题产品发布、用户反馈、官方回应、竞品动作。这时候需要对微博做主题聚类。LDA 是经典选择对短文本做主题建模时需要把多条微博聚合成长文档否则效果极差。3.3.1 将微博按时间窗口连接后训练 LDAfrom gensim import corpora, models from gensim.models import CoherenceModel # texts 是每个时间窗口分词后的词列表列表 dictionary corpora.Dictionary(texts) dictionary.filter_extremes(no_below5, no_above0.5) # 过滤低频和高频词 corpus [dictionary.doc2bow(t) for t in texts] lda_model models.LdaModel( corpuscorpus, id2worddictionary, num_topics5, passes20, alphaauto, etaauto )no_below5表示词至少在 5 个文档中出现过no_above0.5表示词在超过 50% 文档中出现则过滤。这两个参数对舆情文本特别重要微博中的“用户”“微博”等词容易出现在每一篇文档里不滤掉会把主题变成噪声。3.3.2 用困惑度和一致性确定主题数LDA 的主题数不能靠经验拍脑袋。我一般先跑 2 到 15 个主题计算困惑度perplexity和主题一致性coherenceimport matplotlib.pyplot as plt coherence_scores [] for k in range(2, 16): model models.LdaModel(corpus, num_topicsk, id2worddictionary, passes20) cm CoherenceModel(modelmodel, textstexts, dictionarydictionary, coherencec_v) coherence_scores.append(cm.get_coherence())主题一致性曲线通常在某个 k 值出现峰值或增速放缓选取这个拐点即可。注意困惑度下降并不代表主题更可解释有时 k 越大困惑度越低但主题内容重叠严重。我倾向于以一致性为主困惑度只作为辅助参考。3.4 热点事件聚合从关键词到事件的关联关键词抽取得到的是孤立词语需要把包含相同突发词的微博聚合到同一事件下。简单做法是用词共现网络把每条微博的 top5 关键词组成集合如果两个事件的词集合重叠度超过 0.6则合并为同一事件。def event_cluster(keyword_sets, threshold0.6): events [] for kw_set in keyword_sets: for event in events: overlap len(kw_set event[keywords]) / len(kw_set) if overlap threshold: event[keywords] | kw_set event[count] 1 break else: events.append({keywords: kw_set, count: 1}) return events这个算法的缺点是 O(n²) 复杂度但事件数量一般远小于微博数量适合每日几万条的规模。如果数据量更大可以用图聚类或 MiniBatch-KMeans 替代。事件聚合是后续舆情分析的基础因为情感分析应该按事件汇总而不是按单个微博。4. 情感挖掘实现从词典基线到分类器调优情感挖掘是整个系统里最需要工程调参的模块。微博语言的表达方式极其多样反讽、缩写、表情符号都会混淆模型。这里给出从零搭建情感分析模块的完整路径先跑基线再逐步提升准确率。4.1 情感词典基线SnowNLP 的局限与改造SnowNLP 的中文情感分析模型基于商品评论训练用在微博语料上有明显偏差。比如“这手机电池真耐用”预测为正向但“这发布会安排得真烂”也可能被误判为正向因为“安排”和“烂”的组合特征在训练集中没有。做舆情项目时我通常先拿 SnowNLP 跑出基础分数再用自定义词典修正。from snownlp import SnowNLP import re def snownlp_sentiment(text): s SnowNLP(text) score s.sentiments # 0~1大于0.5为正 # 反转修正如果文本包含强负向词且score 0.5则强制调低 neg_words [难看, 垃圾, 失望, 维权, 抵制, 起火, 召回] for w in neg_words: if w in text and score 0.5: score 0.4 break return score这种“硬修正”粗糙但有效。更优雅的方式是构建一个领域情感词典把从历史舆情中人工标注的高置信度词语加入词典计算每条微博中正负向词的加权和再与 SnowNLP 结果做加权融合。4.2 训练一个微博专用情感分类器当数据积累到一定规模超过 5 万条我建议训练自己的情感分类器。最常见的做法是微调 BERT但对工程资源要求高。折中方案是使用轻量模型TF-IDF 特征 逻辑回归效果略差于 BERT但训练和推理速度提升两个数量级。from sklearn.linear_model import LogisticRegression from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report # X_train 是清洗后的文本列表y_train 是 0/1 标签1正向 vec TfidfVectorizer(ngram_range(1,2), max_features50000) X_vec vec.fit_transform(X_train) X_train_vec, X_val_vec, y_train, y_val train_test_split( X_vec, y_train, test_size0.2, random_state42 ) clf LogisticRegression(C1.0, class_weightbalanced) clf.fit(X_train_vec, y_train) val_pred clf.predict(X_val_vec) print(classification_report(y_val, val_pred))ngram_range(1,2)对微博很重要。“不怎么样”里的“不”和“怎么样”单独看是中性组合起来才是负向。二元词组保留了这种局部否定关系。class_weightbalanced是为了应对舆情数据中正向样本远少于负向样本的情况——多数事件舆情中抱怨声居多。训练数据从哪里来可以人工标注也可以用情感词典预标注后再抽样修正。我常用的方式是先用 SnowNLP 给全量数据打分选出分数大于 0.8 和小于 0.2 的样本作为高置信度数据再人工检查 1000 条修正其中错误标签。这种做法能快速构建训练集准确率比直接使用 SnowNLP 平均高 8 个百分点。4.3 情感结果聚合与可视化用 pyecharts 输出舆情仪表盘情感分析只是产出分数如何让运营人员快速理解才是重点。我通常用 pyecharts 生成两个核心图表情感分布饼图和时间趋势堆叠面积图。4.3.1 按事件聚类的情感分布饼图from pyecharts.charts import Pie from pyecharts import options as opts # sentiment_stats 是 {pos: 1234, neg: 567, neu: 789} pie ( Pie() .add(, [(正向, sentiment_stats[pos]), (中性, sentiment_stats[neu]), (负向, sentiment_stats[neg])]) .set_global_opts(title_optsopts.TitleOpts(title情感分布)) .set_series_opts(label_optsopts.LabelOpts(formatter{b}: {c} ({d}%))) ) pie.render(sentiment_pie.html)饼图适合事件概览。但注意如果“中性”占比过高比如超过 70%说明分类器阈值设置有问题。我常在分类时引入neutral_threshold将贝叶斯概率或逻辑回归输出概率落在 0.35~0.65 区间的样本标记为中性而不是简单用 0.5 硬切。4.3.2 情感时序堆叠面积图热点事件的情感是随时间变化的分析趋势比只看总量更有价值。堆叠面积图展示每个小时正、负向微博数量变化能清晰看到舆论拐点from pyecharts.charts import Line line ( Line() .add_xaxis(hours) .add_yaxis(正向, pos_trend, is_smoothTrue, stacktotal) .add_yaxis(负向, neg_trend, is_smoothTrue, stacktotal) .set_global_opts( xaxis_optsopts.AxisOpts(type_category), yaxis_optsopts.AxisOpts(name微博量), ) ) line.render(trend.html)stacktotal参数让两条序列堆叠显示能直观看到负向微博从哪个时刻起超过正向。配合之前 LDA 聚类得到的事件关键词可以在图上标记“回应”“道歉”等关键动作发生的时间点验证舆情反转是否与官方回应相关。这些时间点可以从事件聚类的触发词中人工标注也可以从新闻时间线中提取。4.4 负面情感归因误判场景排查情感分类器在舆情应用中最大的坑是反讽和夸张。比如“真是太棒了产品发布会居然推迟了两次”前句“太棒了”是反讽词典或逻辑回归都会判为正向。增加上下文窗口是种解决办法——如果一条微博前半句出现正向词、后半句出现负向词整体倾向于负向。另外转发微博的“转发理由”比原文更有情感倾向处理时应该优先分析转发理由。5. 性能优化与增量更新让系统跑得更稳更省当舆情系统上线后面对的不再是一天 1 万条数据而是实时接入的流式微博流。这时候必须考虑增量计算、模型热更新、缓存策略等问题。这里分享几个实用技巧。5.1 热点检测的增量计算避免全量重跑如果按小时计算 TF-IDF 和突发度全量重跑会越来越慢。一个实用技巧是使用时间分区存储每次只加载最近 24 小时数据进行增量计算。def compute_burst_incremental(last_hour_ts): # 从 MongoDB 读取从 last_hour_ts 到当前的数据 pipeline [ {$match: {timestamp: {$gt: last_hour_ts}}}, {$group: {_id: {word: $keywords}, count: {$sum: 1}}} ] # 结合 Redis 中存储的历史频率更新突发度这类计算优点是只需处理新增数据历史频率可以直接存在 Redis 中。每小时的窗口任务完成后将当前小时词频乘以衰减因子累加到历史频率中不需要遍历全量历史数据。5.2 分词与词典的更新机制微博每天会冒出新词比如品牌名、电视剧名、网络流行语。使用固定词典会导致新词在分词时被拆碎。解决方案是每周末跑一次统计分词从本周新增语料中用 jieba.analyse.extract_tags 提取新词人工筛选后加入自定义词典。另外情感词典也需要补充新词例如某次事故中出现的“维权”“召回”等。更新时间建议在低峰期凌晨 2-4 点更新时先加载新词典到内存再切换模型对象避免服务中断。5.3 可视化页面的缓存策略pyecharts 生成的 HTML 是通过 Python 脚本渲染的如果每次请求都重新生成性能很差。我的做法是定时任务每隔 5 分钟生成一次 HTML 覆盖到静态目录前端通过 Nginx 直接访问文件结合 HTTP 缓存头。如果数据量大可以用pyecharts.Snapshot生成 PNG 图片发到内部工作群但 HTML 保留了交互工具缩放、下钻更适合深度分析。5.4 模型评估与回滚方案情感分类器的准确率不能只看测试集还需要在每天的真实数据上抽样评估。我每天会随机抽取 200 条预测为负向和正向的微博人工复核后记录错误率。如果错误率连续三天超过 10%就要考虑回滚到上一版本模型。回滚策略很简单把训练好的模型文件按版本号命名存放在独立目录models/v3、models/v4加载时读取当前版本号配置切换只需修改一行配置。系统长期运行后要特别留意数据分布漂移。比如某个月因为节假日、重大事件导致语料情感分布异常模型预测结果可能整体偏差。这时候需要重新训练或至少重新校准阈值。每季度做一次全量重训是合理频率可以结合历史表现决定是否调整分类器架构。本文还有配套的精品资源点击获取