把 Selenium 和文本挖掘串起来做留言数据分析这件事,我断断续续折腾了大概两个月,踩了不少坑,也攒下了一套可以复用的流程。这套方案不依赖现成的数据分析平台,完全用 Python 生态自建,从数据采集、清洗,到情感分析、主题建模、关键词提取、文本分类,一条线打通。
这次就把这套完整思路分享出来,内容包括架构设计、Selenium 采集环节的避坑要点、文本挖掘各个环节的模型选型和代码示例,最后附带一份高频踩坑实录。适合刚接触爬虫和 NLP 的开发者照着复现,也适合已经在做留言分析但想优化流程的朋友参考。
1. 整体设计与方案选型
1.1 先想清楚:为什么要用 Selenium 做数据采集
留言数据的来源通常分三类:一是公开的评论接口,直接返回 JSON 数据;二是服务端渲染的 HTML 页面,用 Requests 就能拿到完整页面;三是前端 JS 动态渲染的页面——这个才是 Selenium 真正发挥作用的场景。
现在主流的内容平台、社区论坛、商品评价页面,大量数据都是前端渲染的。Requests 拿到的 HTML 里根本没有评论内容,数据是通过 JS 异步请求加载后渲染出来的,甚至很多是经过 JS 混淆或者加密参数生成的。这时候用 Selenium 模拟真实浏览器请求,是最稳定的方案。
有人可能说太笨重了。我承认 Selenium 启动浏览器、加载页面的开销确实很大,并发能力也比不上纯 HTTP 请求。但做留言分析这个场景,数据量不会像搜索引擎那样动辄数亿,一天处理万级甚至十万级留言,单机完全够用。我更看重的是它有两个好处:
第一,不需要逆向解析加密的 XHR 接口参数。很多平台的接口参数带 timestamp、sign 之类的签名逻辑,但 Selenium 是真实浏览器操作,这些参数根本不用管。
第二,能够稳定处理登录态、翻页、滚动加载这类需要真实行为的场景。像一些需要登录后查看评论的平台,Requests 加 Cookie 很容易失效,Selenium 只要保持浏览器会话,整个过程自动维护。
1.2 文本挖掘选型逻辑:轻量优先,重模型兜底
留言数据的文本挖掘,常见任务有四种:情感分析、关键词提取、主题建模、文本分类。不同任务的成熟度和工具选型差异很大,我按照“轻量优先、重模型兜底”的原则来做选型。
首先明确一个判断标准:数据量是千级、万级还是十万级以上?标注资源有没有?业务对实时性要求如何?这三个问题直接决定技术路线。
如果数据量在万级以内、对准确率要求不是极端敏感,传统机器学习方案完全够用。比如情感分析用 SnowNLP 或者基于情感词典的规则方法,文本分类用朴素贝叶斯或者 SVM,关键词提取和主题建模用 TF-IDF、TextRank、LDA。这类方案的好处是环境依赖低、速度快、结果可解释,适合业务冷启动。
如果数据量大、准确率要求高、有标注数据,那就得上深度学习模型。情感分析用预训练模型做微调(比如基于 BERT 的中文模型),文本分类用 TextCNN 或者 FastText。这里有一个折中方案:用预训练模型做 embedding 层,下游接一个简单的分类器,训练成本不高,效果却有明显提升。
提示:我自己的建议是——不要一开始就上大模型。先把传统方案跑通,产出一个基线(baseline),再评估是否需要上更强的模型。很多留言数据的情感分布和主题分布其实比较集中,传统方案已经能解决大部分问题。
1.3 整体技术架构
这里放一个我在项目中使用的完整架构,可以做参考:
- 数据采集层:Python + Selenium,负责打开页面、模拟登录、滚动加载、翻页、抓取留言内容。
- 数据清洗层:Pandas + jieba + 正则表达式,负责去重、去噪、分句、分词。
- 分析层:分成四个模块——
- 情感分析:初版用 SnowNLP,后续基于标注数据微调了一个 FastText 模型。
- 关键词提取:jieba 内置的 TF-IDF 算法和 TextRank 算法。
- 主题建模:用 gensim 的 LDA 模型。
- 文本分类:先用朴素贝叶斯跑通全流程,后用 FastText 提升准确率。
- 可视化与展示:Matplotlib + WordCloud,实现词云、主题分布、情感占比的可视化。
这套架构的核心思路是专门为留言数据定制的,数据量级集中在几千到十万条之间,全部部署在单机上也可以流畅运行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与关键依赖配置
2.1 必装环境清单与版本选择
开始之前,先把环境打造好。我用的是 Python 3.9,这个版本对 Selenium 4.x、gensim、transformers 都有不错的兼容性。以下几个库是标配:
bash复制pip install selenium pandas jieba gensim scikit-learn wordcloud matplotlib
这里补充一个细节:如果你打算跑深度学习模型,建议把 numpy 固定到 1.23.5 或者 1.24.x,不要直接装 numpy 2.x。很多旧版本的 gensim、scikit-learn 对 numpy 2.x 存在兼容问题,报错信息五花八门,排查起来非常痛苦。
Selenium 方面,我用的是 4.10 以上版本。有一个比较大的体验变化是 Selenium 4 内置了 webdriver_manager 这样的第三方驱动管理库,不再需要手动下载 Driver。不过依然要理解 Driver 和浏览器版本之间的匹配逻辑——这个坑很常见,后面会细说。
2.2 Selenium 浏览器驱动的正确姿势
关于浏览器驱动的选择,很多人第一反应是去下载 ChromeDriver。这里有个核心认知:
ChromeDriver 的版本必须和 Chrome 浏览器的主版本保持一致。
什么意思呢?比如你的 Chrome 是 120.0.6099.109,那 ChromeDriver 必须是 120.x.x.x,小版本可以不严格一致,但大版本必须匹配。不匹配的时候,Selenium 会报 SessionNotCreatedException。
下载驱动的正确姿势有两种:
方式一:手动下载。访问 ChromeDriver 官方下载页(或国内镜像站)下载对应版本的驱动,解压后把可执行文件放到 Python 的 Scripts 目录或者项目根目录。
方式二:用第三方库自动管理。安装 webdriver-manager 之后,每次运行代码自动检测浏览器版本并下载匹配的驱动:
python复制from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()))
方式二对新手来说更省心,但如果你用的是公司内部网络,无法访问外网下载驱动,方式一更稳妥。我自己的习惯是手动下载并放到固定目录,然后通过 Service 指定路径,这样部署到服务器时不会因为网络问题卡壳。
2.3 关键配置参数
Selenium 启动浏览器时有一些参数,对稳定性和效率影响很大。
python复制from selenium.webdriver.chrome.options import Options
options = Options()
options.add_argument("--headless=new") # 无头模式
options.add_argument("--disable-gpu")
options.add_argument("--no-sandbox")
options.add_argument("--ignore-certificate-errors")
options.add_argument("--window-size=1920,1080")
options.add_argument('--blink-settings=imagesEnabled=false') # 禁用图片加速加载
options.add_experimental_option("excludeSwitches", ["enable-automation"])
driver = webdriver.Chrome(options=options)
这里说下几个参数的作用:
--headless=new:新版无头模式,比旧版更稳定,很多 JS 渲染异常在无头模式下也能正常执行。--blink-settings=imagesEnabled=false:留言页面上的图片、头像这类资源对数据分析毫无价值,禁用之后页面加载速度提升 30% 以上,这是实打实的效果。window-size:模拟浏览器的窗口尺寸。有些页面在窄屏情况下只渲染部分数据,设置成桌面端尺寸可以避免漏抓。excludeSwitches=["enable-automation"]:防止页面通过navigator.webdriver属性检测到自动化环境。遇到一些防护较强的站点时,这个参数能大大降低被拦截的概率。
另外,如果抓取过程遇到滑块验证,通常是因为访问频率太高触发了风控,或者登录态的 cookie 失效。我在实际项目中的做法是降低翻页速度、模拟人工行为(随机睡眠、随机滚动),而不是去对抗验证码机制。
3. 数据采集:Selenium 操作留言区页面
3.1 定位留言框与等待策略
留言区页面最大的特点是动态加载。页面打开的时候,留言可能只加载了第一页,后面的数据需要滚动、点击“加载更多”、或者翻页才能出现。
针对这个问题,核心策略是——不要用固定 sleep,用显式等待。
python复制from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import time
def scroll_and_wait(driver, max_count=1000, scroll_pause=1.5):
"""滚动窗口加载数据,直至无法加载更多"""
last_height = driver.execute_script("return document.body.scrollHeight")
loaded_count = 0
while loaded_count < max_count:
# 滚动到底部
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
time.sleep(scroll_pause)
# 模拟人工停顿,避免被风控
time.sleep(random.uniform(0.3, 1.0))
new_height = driver.execute_script("return document.body.scrollHeight")
if new_height == last_height:
break
last_height = new_height
loaded_count += 1
return loaded_count
等待元素出现时,用 WebDriverWait 更为可靠:
python复制wait = WebDriverWait(driver, 10)
comment_items = wait.until(
EC.presence_of_all_elements_located((By.CSS_SELECTOR, ".comment-list .comment-item"))
)
这里 CSS_SELECTOR 选择器非常关键。合适的做法是在浏览器开发者工具中,对目标元素右键选择“检查”,然后从 HTML 结构中确定唯一且稳定的 CSS 路径。千万不要使用那种从 <html> 开始超级长的绝对路径,页面改版一次就会崩。
3.2 翻页与加载更多的处理机制
不同类型的留言区,加载方式有所不同。常见的三种情况:
第一种是传统的分页按钮。这种情况定位“下一页”按钮,点击即可。难点是最后一页怎么判断——比较可靠的方式是观察“下一页”按钮是否带有 disabled 属性,或者页码元素是否还有下一个。
第二种是“加载更多”按钮。点一次加载一批,定位按钮,点击,然后等待列表长度变化。这里有一个关键的操作逻辑:先记录当前列表数量,点击后再等待列表数量增加,增加说明加载成功,不增加说明到底了。
python复制def click_load_more(driver, button_xpath, comment_list_xpath, max_clicks=50):
for _ in range(max_clicks):
try:
btn = driver.find_element(By.XPATH, button_xpath)
if "disabled" in btn.get_attribute("class"):
break
before_count = len(driver.find_elements(By.CSS_SELECTOR, comment_list_xpath))
driver.execute_script("arguments[0].click();", btn)
WebDriverWait(driver, 5).until(
lambda d: len(d.find_elements(By.CSS_SELECTOR, comment_list_xpath)) > before_count
)
except:
break
第三种是滚动加载。前面代码里已经体现了这种处理方式。
3.3 采集数据解析与入库
留言区的数据结构通常包括:用户名、留言内容、时间、点赞数、回复数等字段。用 find_elements 定位所有留言节点,然后对每个节点逐一提取子元素,最后组装成结构化数据。
python复制import pandas as pd
def parse_comments(driver):
items = driver.find_elements(By.CSS_SELECTOR, ".comment-list .comment-item")
records = []
for item in items:
try:
content = item.find_element(By.CSS_SELECTOR, ".comment-content").text.strip()
except:
continue
try:
author = item.find_element(By.CSS_SELECTOR, ".author-name").text.strip()
except:
author = "unknown"
try:
timestamp = item.find_element(By.CSS_SELECTOR, ".comment-time").text.strip()
except:
timestamp = ""
records.append({"author": author, "content": content, "time": timestamp})
return pd.DataFrame(records)
数据建议先保存成 CSV,后续所有分析环节从 CSV 读取。这个过程有两点要格外注意:
第一,保留原始文本在单独的列,检查或重新分词时如果只保留分词结果,很多信息就丢了,前期的清洗工作等于白费。
第二,记得做去重。留言数据中,重复内容包括用户连续发相同内容、平台自动生成的内容、以及用户复制粘贴的内容。按照“用户 + 内容 + 时间”三维去重,或者直接对内容本身做 hash 去重,都可以。
3.4 数据清洗:留言文本的特殊处理
留言文本和新闻、论文这类相对规整的文本差异很大,清洗环节需要处理的内容特别多:
- 特殊符号:表情符号、emoji、各种装饰符,需要统一处理。注意 emoji 是 UTF-8 编码的宽字符,直接正则替换可能失败,建议用
emoji库来处理。 - URL 链接:留言中的外链,因为加密参数和重定向的问题,对分析没有价值,直接剔除。
- @提醒和话题标签:像“@张三”“#某某话题#”这类内容,提取关键词时作为整体保留,做情感分析时可以保留,做分类时可以保留。
- 广告和灌水内容:典型代表是“加微信领红包”“点击链接领取优惠”等,这类留言对业务分析是噪声,需要根据样本人工总结规则后过滤。
- 过长或过短的内容:只有一两个字(如“哈哈”“+1”)的留言没有分析价值;超长文本(1000字以上)可能是复制粘贴的文章,也可能含广告,视情况截断或剔除。
下面是清洗函数的一个例子:
python复制import re
import pandas as pd
def clean_comment(text):
if not isinstance(text, str):
return ""
# 去掉 URL
text = re.sub(r'http[s]?://\S+', '', text)
# 去掉 @ 用户名
text = re.sub(r'@[\w\u4e00-\u9fa5\-]+', '', text)
# 去掉话题标签,但保留话题中的汉字
text = re.sub(r'#([^#]+)#', r'\1', text)
# 去掉多余的空白
text = re.sub(r'\s+', ' ', text).strip()
return text
df['content_clean'] = df['content'].apply(clean_comment)
# 去掉清洗后为空的记录
df = df[df['content_clean'] != ""]
清洗规则需要根据你的数据来源迭代。我建议把清洗规则做成独立函数,并保留清洗前后的对比样本,这样排查问题的时候能看到规则是不是过度清洗了。
3.5 分词是文本挖掘的地基
中文文本挖掘和英文最大的区别就是需要分词。分词的准确性直接决定了关键词提取、主题建模、文本分类的整体效果。
分词我用的是 jieba,这是最常用的选择。核心代码如下:
python复制import jieba
import jieba.analyse
# 加载自定义词典
jieba.load_userdict("custom_words.txt")
# 停用词表
stopwords = set()
with open("stopwords.txt", "r", encoding="utf-8") as f:
for line in f:
word = line.strip()
if word and not word.startswith("#"):
stopwords.add(word)
def tokenize(text):
words = jieba.cut(text)
return [w for w in words if w not in stopwords
and w.strip()
and len(w) > 1
and not w.isdigit()]
关于自定义词典和停用词表,这里补充一些个人心得。
自定义词典是 jieba 分词效果的关键,不同领域的专业词、品牌词、产品名、人名,必须手动加入。举例来说,如果分析的是商品评论,“性价比”“客服”“物流”这些高频词 jieba 默认是能正确切分的,但如果是某个数码产品的型号比如“iPhone15ProMax”,不加入自定义词典就直接切碎了。我的习惯是每分析一个新数据源,先抽取 500—1000 条文本查看分词结果,把切错的词批量加入自定义词典,迭代 2—3 轮之后效果就会稳定下来。
停用词表的构建也同样是迭代的过程。标准停用词表可以网上下载,但一定要根据自己的数据追加。留言场景里,“哈哈哈”“呜呜呜”“卧槽”“!!!!!”这类内容,对主题建模和关键词提取都是干扰,需要过滤。停用词表建议用普通文本文件管理,每一行一个词,方便随时追加。
4. 情感分析:判断留言背后的情绪倾向
4.1 情感分析的实现路径
留言情感分析常见有三条技术路线,各有优劣。
最简单的路线是情感词典打分。使用现成的情感词典结合领域扩展词典,为文本中的情感词匹配权重,计算出整体的情感分数。这类方案无需训练、可解释性强,但语序和多义词处理不了。
更省事的路线是用现成工具。中文圈里最常用的就是 SnowNLP。它自带情感判断能力,SnowNLP(text).sentiments 会返回 0 到 1 之间的小数,大于 0.5 表示积极,小于 0.5 表示消极。它的优点是不需要学习成本,几行代码就能跑通,缺点是默认模型基于电商评论语料训练,换一个场景(比如体育赛事留言、游戏评论),准确率会明显下滑。
最可靠但是成本最高的路线是训练自己的模型。先人工标注一批数据,再训练分类模型,从朴素贝叶斯到 Bert 微调都可以。准确率最高,但需要足够多的标注样本和时间成本。
我个人的建议是:先用 SnowNLP 或者词典方法跑一轮,采样挑出预测错误的数据,做错误分析。如果误判集中在某几类数据上,再针对性调整规则,依然不够再考虑训练模型。
4.2 使用 SnowNLP 开启第一版情感分析
python复制from snownlp import SnowNLP
def sentiment_score(text):
try:
s = SnowNLP(text)
return s.sentiments # 0~1
except:
return 0.5
df['sentiment'] = df['content_clean'].apply(sentiment_score)
df['sentiment_label'] = df['sentiment'].apply(
lambda x: 'positive' if x >= 0.6 else ('negative' if x <= 0.4 else 'neutral')
)
默认阈值按 0.4 和 0.6 分三段,这只是初始设定,实际可以根据你数据的分布调整。有一个常见的偏差:SnowNLP 在微博、新闻评论等非电商语料上,倾向于把内容打成中性或者正向,负向识别率偏低。如果遇到这种情况,建议做两类操作:一是针对负向表达的词做词典扩充,二是人工标注 500 条数据重新训练 SnowNLP 的贝叶斯模型(它本身支持训练)。
这里修正一个使用误区:不要直接拿着 sentiments 做细粒度分析。0.73 和 0.78 之间的差异没有实际意义,只需要把它映射到正负中三个类别上就够了。
4.3 基于 FastText 的二次优化
当规则和 SnowNLP 无法满足准确率需求时,FastText 是一个性价比非常高的选择。它是 Facebook 开源的文本分类工具,既有传统机器学习的速度,又有神经网络的效果。
FastText 的使用步骤是这样的:
第一步,准备标注数据。格式为每行一句文本,加上 __label__positive 或 __label__negative 前缀标记,文件编码必须 UTF-8。
第二步,划分训练集和测试集。
第三步,训练模型:
python复制import fasttext
model = fasttext.train_supervised(
input="train.txt",
lr=0.8,
epoch=25,
wordNgrams=2,
minCount=1,
bucket=200000,
dim=100,
loss="softmax",
thread=4
)
model.save_model("comment_sentiment.bin")
参数选择上,wordNgrams=2 表示使用二元词组特征,能捕捉“不怎么样”这样的否定短语;dim=100 是词向量维度,对短文本来说够用了;lr=0.8 配合 epoch=25 是我试下来比较稳的组合。
第四步,预测:
python复制def predict_sentiment(text):
label, prob = model.predict(text)
return label[0].replace("__label__", ""), prob[0]
FastText 的缺点是解释性差,你不知道模型到底学了什么特征,只能通过 F1 值看综合效果。但作为生产级的情感分类方案,它足够稳定、足够快速。
4.4 情感分析结果验证
不管用哪种模型,上线之前必须做验证。推荐做法是:随机抽 200-300 条预测结果,逐条人工判断是否准确,然后计算准确率。
这里有一个容易被忽略的坑:直接看整体准确率可能被“倾向性分布”欺骗。比如 70% 的数据都是正向的,模型把所有内容都判成正向你也能得到 70% 准确率,但这个模型等于没用。所以必须单独看负向样本的召回率。我记得在自己一个项目里,第一版模型整体准确率 76%,负向召回率只有 38%,意味着超过六成的负面留言都被漏掉了。优化了这个指标之后,整个分析才真正有业务价值。
如果负向召回率太低,优先补充负向样本的标注数据,而不是盲目增加模型复杂度。
5. 关键词提取:快速定位留言核心话题
5.1 TF-IDF 与 TextRank 两种方案对比
关键词提取是整个流程里最立竿见影的模块,做出来的效果也最容易向别人展示。常用的两种方法各有特点,需要考虑好再选。
TF-IDF 是统计方法,核心思想是“某个词在一篇文档中出现频率高,但在其他文档中出现频率低,这个词就有区分度”。计算速度快,适合整体性的数据盘点。比如统计 10000 条留言中大家最关心的关键词,TF-IDF 就很合适。
TextRank 是图算法,更像是把整批文本中的词汇关系组织成语义网络,根据共现关系给每个词打分。它的优点是考虑了词汇间的上下联系,提取出的关键词更连贯,适合针对单条长文本做关键词提取。
实际使用中,两个算法我都在用,对应不同的分析目标:想做整体词云和热点分析,用 TF-IDF;想了解某一类主题的内在讨论结构,用 TextRank。
5.2 使用 jieba 快速提取关键词
jieba 内置算法直接调用非常简单:
python复制import jieba.analyse
# TF-IDF 关键词提取
top_k = 20
keywords_tfidf = jieba.analyse.extract_tags(text, topK=top_k, withWeight=True)
# 基于 TextRank 的关键词提取
keywords_textrank = jieba.analyse.textrank(text, topK=top_k, withWeight=True)
withWeight=True 会返回权重值,方便后续排序。topK 是返回数量,这个值取决于你的文本长度。留言一般比较短,每条提取 3-5 个关键短语即可,如果做整体词云,就从整个文档集上提取 top 50-100 个。
这段代码的问题在于——单纯对整批文本做关键词提取,提取出来的大概率是“评论”“大家”“感觉”“东西”这类高频但信息量低的词。我自己的经验是:结合 TF-IDF 对文档集整体分析的效果会更客观,等价于把所有文档拼成一个大文档跑一遍,这样的关键词才是整体热点。
5.3 全局关键词提取的正确用法
我的做法是这样的:
把所有清洗后的文本拼接成一个大的文档,然后在这个大文档上做 TF-IDF 关键词提取。因为 jieba 的 extract_tags 本身支持传入一个大字符串。
换一个更合理的做法是,把语料构建成“每篇文档是一类样本”,比如按天划分、按用户划分、按正负情绪划分,分别提取该子集的关键词。这样能看到不同时间段、不同用户群、不同情绪类型下的话题分布差异,分析价值更高。
5.4 词云可视化
关键词提取之后生成词云是最直观的展示方式。常用的是 wordcloud 库。
python复制from wordcloud import WordCloud
import matplotlib.pyplot as plt
text_for_cloud = " ".join(high_freq_words)
wc = WordCloud(
font_path="msyh.ttc", # 中文字体必填,不填就是方块
width=800,
height=600,
max_words=100,
background_color="white"
).generate(text_for_cloud)
plt.figure(figsize=(10, 8))
plt.imshow(wc, interpolation="bilinear")
plt.axis("off")
plt.savefig("comment_wordcloud.png", dpi=300)
这里有一个最基本的避坑点:中文字体必须指定,否则词云全是乱码方块。Linux 服务器上一般没有微软雅黑,建议用 wqy-microhei.ttc 或者你在项目里放任意一个中文字体文件,直接指定绝对路径即可,实际操作很省事。
6. 主题建模:用 LDA 自动发现留言主题
6.1 为什么选 LDA 而不是 K-Means
留言分析中,最常问的问题是“大家都在讨论什么”,主题建模就是为了回答这个问题。
LDA(Latent Dirichlet Allocation,隐含狄利克雷分配)是经典的主题模型,把每篇文档看成多个主题的混合分布,每个主题看成多个词汇的概率分布,最终得到“文本—主题”和“主题—词汇”两层概率分布,可解释性强。相比之下,K-Means 这类聚类方法需要先把文本转成向量,聚类结果难以直观映射到语义主题,所以主题建模场景 LDA 更常用。
LDA 模型的优势是对于短文本(比如一句留言)也能给出主题归属,因为它不只看单篇文档的词频,还利用了全语料的词共现信息。缺点是要求数据量够大(低于几百条时主题不稳定),以及主题数量需要预先设定。主题数量怎么选,下面说一说。
6.2 主题数怎么确定:困惑度 + 一致性 + 人工可读性
选择主题数目的常用方法有三种。
第一种是困惑度(Perplexity)最小化。困惑度越低表示模型对语料的拟合越好,但 LDA 的困惑度会随着主题数增多一直下降,过度拟合后主题就变成噪声很多了,所以单独使用不够可靠。
第二种是主题一致性(Coherence Score)。这是衡量主题内词汇共现一致性的指标,一般认为一致性越高主题越容易理解。常见做法是计算多个候选主题数的一致性分数,取峰值对应的主题数。
第三种是我最推荐的方式——人工可读性检查。把不同主题数跑出来的结果打印出来,直接看每个主题下 top 词汇在语义上是否连贯。如果某个主题的 top 10 词没有任何语义关联,说明主题数多了或者语料有问题。
实际项目中,我会用一致性分数圈定一个候选区间,比如当主题数在 5-12 之间时一致性最好,就把 K=5、K=6、K=7…都跑一遍,人工看结果选一个最能解释业务问题的 K。模型好坏最终服务的还是业务解释,数学指标只做参考。
6.3 LDA 完整实操代码
python复制from gensim import corpora, models
# 把每一条留言分词后的结果作为一篇文档
documents = [tokenize(text) for text in df['content_clean']]
# 保留至少出现在2篇文档中的词,过滤极端高频低频词
dictionary = corpora.Dictionary(documents)
dictionary.filter_extremes(no_below=2, no_above=0.5)
corpus = [dictionary.doc2bow(doc) for doc in documents]
# 训练LDA
lda_model = models.LdaModel(
corpus=corpus,
id2word=dictionary,
num_topics=8,
passes=20,
alpha='auto',
per_word_topics=True
)
# 打印每个主题
for idx, topic in lda_model.print_topics(num_words=8):
print(f"Topic {idx}: {topic}")
filter_extremes(no_below=2, no_above=0.5) 这个过滤非常关键。no_below=2 表示词至少要在 2 篇文档中出现,过滤只出现一次的噪声;no_above=0.5 表示词不能在超过 50% 的文档中出现,过滤“评论”“大家”这类全语料通用的词。
passes=20 是 LDA 模型遍历语料的次数。默认值偏小,主题可能不够稳定,我习惯设置 20-30 次,训练速度不算慢,但效果更稳。
6.4 给留言打上主题标签
训练好 LDA 模型后,可以把每条留言划分到最可能的主题:
python复制topic_id = max(lda_model.get_document_topics(bow), key=lambda x: x[1])[0]
df['topic_id'] = df['content_clean'].apply(
lambda x: max(lda_model.get_document_topics(dictionary.doc2bow(tokenize(x))),
key=lambda item: item[1])[0]
)
然后统计每个主题的占比:
python复制topic_share = df['topic_id'].value_counts(normalize=True).sort_index()
for tid, share in topic_share.items():
print(f"Topic {tid}: {share:.2%}")
最后可以为每个主题取一个可读的名字。命名方式是看该主题 top 15 个词,抓取核心语义。比如某个主题的 top 词是“客服 回复 不行 态度 敷衍”,这个主题就可以叫做“客服态度吐槽”。
7. 文本分类:将留言自动归类到业务板块
7.1 分类体系设计
留言分类的目标是把无标留言自动划分到预置的类别中,比如产品咨询、售后问题、物流投诉、内容建议、价格异议、无关闲聊等。分类体系和业务目标强相关,没有通用的分类标准。
分类体系设计有个核心原则:类别数量控制在 5-10 个之间。太少,归并太粗,信息量不足;太多,标注成本高,分类模型容易混淆。
设计类别时注意两点:
第一点,每个类别必须有足够的区分度。比如“咨询”和“询问”在语义上近似,实际分类时模型很难区分,可以考虑合并。
第二点,设定一个“其他”类别。留言数据一定会有无法归类的噪声内容,设置一个垃圾桶类别能有效保护分类器的准确率。
7.2 标注策略与模型训练流程
文本分类是有监督学习,第一步要做的是标注好数据。数量上,每个类别 300-800 条文本是较稳妥的起步量。如果每个类别只有 100 条,模型效果会很不稳定,但超过 1000 条再增加带来的收益就不太明显了。
这里提供一个分类模型的完整训练流程。以下是一个朴素贝叶斯分类器的实现:
python复制from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import train_test_split
X = df['content_clean'].values
y = df['category'].values
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
pipeline = make_pipeline(
TfidfVectorizer(max_features=5000, ngram_range=(1, 2)),
MultinomialNB(alpha=0.8)
)
pipeline.fit(X_train, y_train)
print("准确率:", pipeline.score(X_test, y_test))
stratify=y 是一个我特别想强调的细节,它保证了划分后的训练集和测试集中每个类别的比例和原始数据一致。不做这个设置,遇到少数类样本容易在测试集中消失,或者训练集里根本学不到该类特征。
7.3 特征工程三件套
文本分类的特征工程核心是 TF-IDF 向量化,有几个参数值得细细调整:
第一个是 max_features。默认值可能高达几十万维,对朴素贝叶斯这类简单模型来说信息冗余,设置 5000-20000 之间通常就能覆盖大部分有用特征。
第二个是 ngram_range=(1,2)。加入二元词组特征,让模型能捕捉到“不贵”“没到货”“性价比特高”这类短语信号,提升效果立竿见影。
第三个是 min_df 和 max_df。设置 min_df=2 过滤只在 1 条文本中出现的词,设置 max_df=0.8 过滤在超过 80% 文本中出现的通用词种,能有效减少噪声特征。
如果你嫌朴素贝叶斯效果不够好,可以换成线性 SVM 试试,代码变动很小,把分类器换成 SGDClassifier(loss='hinge') 或者 LinearSVC() 即可。实践中 LinearSVC 在短文本分类中比朴素贝叶斯表现好,但训练速度稍慢一些。
8. 常见问题与排查技巧实录
8.1 高频问题速查表
下面的表格是按实际排查频率排序的:
| 问题现象 | 常见原因 | 解决方案 |
|---|---|---|
SessionNotCreatedException: This version of ChromeDriver only supports Chrome version X |
ChromeDriver 和 Chrome 浏览器版本不匹配 | 下载匹配的驱动版本,或者用 webdriver-manager 自动匹配 |
ElementNotInteractableException |
元素被遮挡或不可点击,通常因为页面未完全加载 | 改用显式等待,等待元素可点击后再操作;用 JS 强制点击 |
TimeoutException |
页面加载太慢或反爬机制拦截 | 提高等待时间;降低翻页速度;检查网络 |
找不到元素 NoSuchElementException |
选择器失效,页面结构调整 | 重新检查页面 HTML 结构,改用 XPath 或多重候选选择 |
| LDA 主题全部是通用词 | 没有过滤停用词或没有过滤高频词 | 检查停用词表;调整 no_above 参数 |
| 分词把品牌词切碎 | jieba 词典中没有领域词 | 维护自定义词典,加入品牌词、产品名 |
| 情感分析全偏向中性 | SnowNLP 默认模型和语料分布不匹配 | 微调模型或改用 FastText 训练分类器 |
8.2 Selenium 采集阶段的真实踩坑
爬取过程中遇到最典型的几个坑如下。
第一个坑是翻页时元素定位失败。多数原因不是选择器错了,而是页面现实状态还没准备好。比如点击“下一页”之后立刻查找下一页的元素,页面正在加载渲染,原来的 DOM 已经变化,但新 DOM 还未就绪,这时就会报错。我的解决思路是等列表元素数量发生变化后再执行下一步,这是最稳的信号。
第二个坑是隐藏元素无法定位。有些留言列表使用懒加载,滚动前部分的 DOM 不存在,直接用 find_element 找不到。解决方案是先触发滚动,再等待元素出现。另外滑块验证出现时,暂停让浏览器保持一段时间,接下来再继续操作,比直接切换 IP 更有效。
第三个坑是点击加载更多后页面卡死。我发现多半是页面弹出了遮罩层或者广告遮住了按钮,点击没有落在按钮上,反而点到遮罩层。解决办法是用 JavaScript 直接触发点击:
python复制driver.execute_script("arguments[0].click();", btn)
这能绕过元素被遮挡问题。
第四个坑是“浏览器打开后马上闪退”。常见原因是 Chrome 的沙箱权限不足,在 root 用户运行时加上 --no-sandbox 参数能解决。
8.3 文本处理中的易踩深坑
文本处理里的问题比采集更隐蔽,因为代码没有报错,只在结果上看不出来。比如:
第一个坑是 emoji 全角符号没有清洗干净。很多直接手写的正则 [\W] 并不能覆盖 emoji 范围,分词之后会出现一堆独立的奇怪符号词。建议用专门的 emoji 库:
python复制import emoji
text = emoji.replace_emoji(text, replace='')
第二个坑是繁体中文未做转换。很多留言平台允许繁体输入,如果不做转换,“品質”和“质量”会被当两个词处理,关键词提取和主题建模都会受到干扰。处理方法是用 opencc-python:
python复制from opencc import OpenCC
cc = OpenCC('t2s')
text = cc.convert(text)
第三个坑是分词结果的“自定义词典”没生效。自定义词典分词后,下载的是 GBK 编码的 dict.txt 之类的文本,新一代版本的 Windows 记事本默认编码是 UTF-8 带 BOM。加载带 BOM 的自定义词典,第一个词会带上不可见字符,因为这个原因导致第一个词永远分不出来。解决方式是把词典文件另存为 UTF-8 无 BOM 格式。
8.4 关于分析结果可靠性的提醒
最后一块内容想聊一下我对分析结果可靠性的理解。
留言数据天然存在分布偏差。愿意留言的用户本来就比沉默用户更有表达欲,极端情绪更容易发声;平台统计口径不透明,推荐算法会让某些留言获得更多曝光。所以分析出的“主题占比”“情感比例”反映的是“留言用户的表达分布”,并不完全等于“全体用户的态度分布”。
在做业务决策或向团队汇报时,我会区分两类结论:一类是“留言中吐槽客服的占比达到多少”,另一类是“客服满意度整体下降了多少”。前者可以直接从数据得到,后者需要结合业务数据和抽样验证。把结论限制在数据实际能支撑的范围内。
做完一轮分析后,建议抽 100-200 条原始留言逐条阅读一遍。这个动作不是为了验证模型,而是为了防止分析结果和真实语感脱节。机器打分、主题归类、关键词排序这些都是辅助手段,最终判断还是要落在人对语境的理解上。
最后分享一个我实践中改进效果最明显的技巧:不要贪多,一次只优化一个环节。分词不准就先调词典;情感负向召回率不够就只补负向样本;主题不清晰就调主题数和过滤参数。整个流程里每个模块都调一遍之后,整体效果自然就上来了。
