去年接了个景区舆情分析的需求手头是一万六千多条游客评论从点评站、问卷开放题和几个社媒渠道拼起来的。清洗完丢进 LDA跑出来五个主题三个几乎长得一模一样景区 / 人 / 多 / 排队 / 不错。当时第一反应是调参把主题数从 5 试到 15alpha 从 0.1 调到 1.0跑了七八轮主题词还是那几张老面孔。真正的问题根本不在 LDA 那一侧而是喂进去的词本身就已经碎掉了——玻璃栈道被切成玻璃和栈道摆渡车被切成摆渡和车检票口被切成检票和口。这些碎片在文档-词矩阵里高频出现却毫无语义承载主题自然就糊成一团。这篇东西讲的是怎么用一份自建的本地词库把 Jieba 的中文分词从通用场景够用掰到景区评论够准再让下游的 LDA 真正跑出可读的主题。适合正在做中文文本挖掘、被分词结果折磨过、或者跑完 LDA 发现主题没法向业务方解释的人看。前置要求不高会写 Python、装过 jieba 和 gensim 就能跟下来但里面的词表构建流程和评估方法是我踩了好几轮坑之后才理顺的直接抄能省不少时间。1. 景区评论这类语料难点到底卡在哪先把语料本身的性质说清楚后面所有的设计都是从这儿推出来的。景区评论属于典型的短文本 强领域 高噪声三合一语料它跟新闻语料、论文摘要完全是两个物种拿通用分词器直接处理效果落差会比想象中大得多。我见过不少人拿 THUCNews 上跑通的一套流程直接套到点评数据上然后困惑为什么主题一致性分数低得离谱。1.1 短文本、口语化和噪声的三重夹击单条评论的平均长度我统计过中位数只有 23 个字大量样本就是还行吧就是人太多风景不错索道排队一小时这种。短文本的致命问题在于共现信息极度稀疏——一条评论里能同时出现的实词可能就五六个LDA 靠的是词共现来推断主题分布样本一短共现矩阵就大面积是零模型只能抓住最高频的那几个通用词主题自然塌缩。口语化带来的麻烦同样不小。索道排队排到怀疑人生厕所干净得不像话性价比一般般这类表达里的程度词、语气词、网络化缩写占比很高如果不清洗一般般、不像话、怀疑人生这类词会占据大量词表位置。更麻烦的是同一件事有无数种说法讲排队有人写人挤人有人写摩肩接踵有人写排队两小时还有人直接甩一个劝退。这些表达在语义上高度接近但在词表层面上完全不同LDA 无法自动把它们归并。噪声部分则是老生常谈但躲不开的表情符号转成的文字、重复标点、错别字揽车代缆车、做代坐、夹杂的英文缩写、以及大量无意义的单字。我在清洗阶段做过一次统计原始语料里单字 token 占比接近 31%这些单字进入词表后基本只贡献噪声。提示短文本语料不建议直接上 LDA先把长度低于 8 个字的评论单独拿出来看一眼很多时候它们的价值在于情感极性而不是主题归属混进去反而拖累主题质量。1.2 专有名词才是真正的分词黑洞如果说短文本和口语化是普遍性困难那专有名词就是景区这个垂直领域特有的深坑。我大致把踩过的坑分成三类你看完就明白为什么通用词典在这里几乎失效类型典型例子通用分词器的切法造成的后果景点/设施名玻璃栈道、观光车、游客中心、母婴室玻璃/栈道、观光/车、游客/中心、母婴/室语义单元被打散主题词表里全是后半截组合式评价短语性价比高、体验感差、拍照出片、遛娃友好性/价比/高、体验/感/差评价维度无法聚合成类方言与本地表达巴适、闹热、耍起、蛮好逐字切分或误合成HMM 兜底容易切出奇怪的双字组合第一类最要命。景点名和设施名恰恰是区分主题的核心线索——讲索道体验的、讲餐饮的、讲住宿的、讲停车配套的本来就该是不同的主题簇。一旦索道被切成索和道道这个字在语料里到处都是步道、车道、味道、难道模型根本没法把它和索道体验关联起来。我在第一版结果里就看到过主题词是道 / 走 / 累 / 山 / 上去完全没法向业务方交代。第二类属于评价维度词它们在评论里出现频率极高是主题聚类的天然锚点。性价比被切成性、价比之后性价比高和性价比低这两类完全对立的评价反而在词表里合并成了噪声这就不是精度问题了是方向性错误。第三类的影响面小一些但在做地域性景区分析时会明显暴露。HMM 的未登录词识别是靠字序规律猜的它不认识耍起这种方言搭配经常两个字合成一个不存在的词或者干脆拆成两个单字。2. Jieba 切词到底按什么逻辑走想改得准先得知道它在干什么。很多人对 Jieba 的印象停留在加个自定义词典就行但词频怎么生效、HMM 什么时候介入、词性标注是独立的还是绑定的这些细节直接决定了你的词库该怎么写。2.1 前缀词典、DAG 与动态规划的主干路径Jieba 的核心流程分三步走我用一个具体例子过一遍。假设输入是玻璃栈道太刺激了第一步构建前缀词典。词典里所有的词连同它们的词频一起被组织成前缀树结构这样查玻璃栈道的时候能快速判断玻、玻璃、玻璃栈、玻璃栈道哪些是词。这一步的输入就是dict.txt加上你后续加载的用户词典。第二步生成 DAG有向无环图。对句子里的每个起始位置找出从这儿出发所有可能的成词终点。对玻璃栈道可能得到玻、玻璃、玻璃栈、玻璃栈道等多条路径每条边带着对应词频。第三步动态规划求最大概率路径。它用的是log(freq) - log(total)也就是把词频转成对数概率后相加取总和最大的那条切分。这一步才是真正的决策点词频数据直接决定了哪条路径胜出。假设通用词典里玻璃的词频是 8000栈道是 1200而玻璃栈道这个词压根不存在那么玻璃/栈道这条路径的总概率必然高过玻/璃/栈/道结果就固定死了。理解这一点非常关键你要让一个领域词被正确切出来光把它加进词典还不够还得让它的词频高到足以在概率竞争中胜出。这是后面调词频的理论依据。2.2 HMM 兜底与未登录词的边界DAG 走完之后句子会被切成一堆词和单字的混合序列。所有连续的单字会被重新拼起来丢给 HMM 做二次切分。这就是 Jieba 的未登录词识别机制用的是 BMES 四个状态的隐马尔可夫模型靠字与字之间的转移概率判断该不该成词。这个机制是一把双刃剑。好处是能识别出摆渡车检票口这类词典里没有但字面上常见的组合坏处是它会误伤已经被正确切开的单字。我碰过一个特别典型的案例厕和所在某些上下文里被 HMM 判定为两个独立语素结果厕所这个词在部分评论里消失了。原因是这几个字在训练语料里的转移概率不站在这一侧。所以结论很明确只要是你确定要保留的领域词最好显式加进词典不要指望 HMM 兜底。HMM 是用来处理长尾未知词的它的判断标准是统计规律不是领域知识指望它理解母婴室是一个完整设施不太现实。注意Jieba 还有个HMMTrue/False的总开关。关掉 HMM 会让切分完全由词典决定结果是极端的确定性——好的一面是不会有意外合并坏的一面是所有未登录词都会碎成单字。我的做法是保持 HMM 开启同时把核心领域词全部显式收进词表两边都占。2.3 词频、词性与 suggest_freq 的真实作用词频的默认值Jieba 是按词频 int(1e8 / 词长)自动估的也就是说你加一个三字词它给个约 3300 万的词频比通用词典里的常用词还高。这个默认值在多数场景下是好事但也可能引发过度切分比如你把停车场入口这样一整个短语加进去它就可能把入口这个本身有独立意义的词从其他句子里一并吞掉。suggest_freq是个更精细的工具它可以在不修改词典的前提下动态把某个词的词频调到刚好能切开的位置。我有两个典型用法强制切开某个短语在特定语境下需要拆开比如不要/走路和不要走/路用jieba.suggest_freq((不要, 走), True)调整。强制合并某个词共现极高但你不想动词典用jieba.suggest_freq(玻璃栈道, True)在运行时提权。词性方面jieba.posseg走的是另一套标注基于 ICTCLAS 的标注集它和分词是并行的不是先切后标。给领域词加词性是有实际价值的你可以只保留n名词、nr人名、ns地名、vn动名词这类有实义的词性把d副词、p介词、u助词批量过滤掉比维护一份庞大的停用词表更省事。3. 本地词库怎么攒从原始评论里捞词词典是整件事的地基地基歪了后面全是白费。我的做法是不靠拍脑袋列词而是从语料里用统计方法捞候选再走人工复核两个阶段分工明确。3.1 领域词的三类来源与优先级我一般从三个地方收集第一来源是业务侧提供的名词表。景区官网、导览图、票务页面上的设施名、景点名、服务项目名这是最干净也最权威的一批。这部分词优先级最高直接全量入库。这类数据往往就在一些不起眼的角落里比如导览页面的锚点文字、票种说明里的短语抓一次能用很久。第二来源是高频 n-gram 抽取。把全部评论做 2-gram 到 5-gram 的频次统计取出现次数超过阈值的组合。这里会出现大量有用的领域词也会混进一堆很好很不错不太推荐非常满意这种通用短语。判别方法是看这个词是否有领域指向性游客中心有非常好没有。第三来源是新词发现算法的输出。前两个来源覆盖不到的就得靠统计方法挖。下一节展开讲。三者的优先级我是这样排的业务名词表 高频 n-gram人工筛后 新词发现人工筛后。越靠后噪声越大人工复核成本越高。3.2 用互信息加邻接熵挖新词新词发现这套方法的核心思想很朴素一个真正的词内部字与字应该粘得紧外部搭配应该散得开。前者用互信息衡量后者用左右邻接熵衡量两个指标都过关的候选才算数。互信息的算法是PMI(x,y) log2( P(xy) / (P(x) * P(y)) )。直觉上理解如果栈和道只是碰巧挨在一起它们共现的概率应该接近各自出现概率的乘积PMI 接近 0如果它们真的构成一个词共现概率会显著高于随机期望PMI 就会明显为正。阈值一般取 1.0 到 2.0 之间起步具体数字要看你语料规模我是用了一个从 0.8 到 2.5 的扫描人工看每个阈值下的输出质量再定的。邻接熵是另一侧它衡量的是候选词的左右两侧能接多少种不同的字。计算公式是H -Σ p(c) * log2 p(c)其中 p(c) 是每个邻接字出现的概率。熵值越高说明这个词越独立能自由应用在各种上下文里熵值低说明它总是固定出现在某个大短语里那它可能只是更大短语的一部分。给个简化实现能直接跑from collections import defaultdict import math def extract_candidates(texts, min_count20, pmi_thr1.5, entropy_thr1.2, max_len5): # 统计单字、n-gram、左右邻接 word_cnt defaultdict(int) left_chars defaultdict(lambda: defaultdict(int)) right_chars defaultdict(lambda: defaultdict(int)) total_words 0 for text in texts: chars list(text) n len(chars) for i in range(n): for L in range(2, max_len 1): if i L n: break w .join(chars[i:i L]) word_cnt[w] 1 total_words 1 if i 0: left_chars[w][chars[i - 1]] 1 if i L n: right_chars[w][chars[i L]] 1 def entropy(counter): total sum(counter.values()) if total 0: return 0.0 return -sum((c / total) * math.log2(c / total) for c in counter.values()) results [] for w, cnt in word_cnt.items(): if cnt min_count or len(w) 2: continue # 计算最小 PMI字切成两半取最低的那一刀 pmi_min float(inf) for cut in range(1, len(w)): a, b w[:cut], w[cut:] pa word_cnt.get(a, 0) / total_words pb word_cnt.get(b, 0) / total_words pw cnt / total_words if pa 0 or pb 0: pmi_min -1 break pmi_min min(pmi_min, math.log2(pw / (pa * pb))) h min(entropy(left_chars[w]), entropy(right_chars[w])) if pmi_min pmi_thr and h entropy_thr: results.append((w, cnt, round(pmi_min, 2), round(h, 2))) return sorted(results, keylambda x: -x[1])跑完之后按频次倒序看前 300 条人工过一遍能留下的通常有几十个高质量领域词。这一步不要偷懒交给模型自动筛我试过用规则直接过滤结果漏掉了好几个低频率但极关键的词比如无障碍通道出现不到 30 次但它是配套设施这个主题的核心线索。3.3 词典文件的格式与人工复核清单Jieba 的用户词典格式很朴素词语 词频 词性用空格分隔词频和词性都可以省略。我一般写全三项玻璃栈道 800 n 观光车 650 n 游客中心 600 n 母婴室 200 n 排队时长 300 n 性价比 900 n 出片 400 a人工复核的时候我会对照一份自检清单这几条是我筛了几千个候选词之后沉淀下来的经验是否有独立语义像各种各这种明显是跨词边界截断的直接删。是否会被更长的词覆盖游客中心已经在库里再加一个游客就没意义了反而可能干扰。是否会引起误切加很不这种高频组合词会把很不错从短语里拆出来得不偿失。词频是否合理名词类给 200 到 900 之间比较稳太高的会压制其他词的正常切分太低的话在概率竞争里打不过通用词加了等于没加。提示词频不要一次性写在最终值先用默认值跑一遍看看哪些词还是被切碎了再针对性地往上调。我一般从 200 起步每轮加 200 测试两三轮就能收敛。4. 把词库接进 Jieba加载顺序与实测对比词表攒好了接下来是工程接入。这块看着简单实际上有几个细节不注意的话词库加载了但不生效排查起来很费时间。4.1 load_userdict 与 add_word 的差异两个方法的功能都是加词但适用场景完全不同方式是否修改全局词典词频处理适用场景jieba.load_userdict(path)是一次载入按文件里写的词频无则自动估工程化、词表稳定、批量维护jieba.add_word(word, freq, tag)是逐个添加显式指定可动态调整临时调试、少量动态补充suggest_freq(name, tune)是自动调优到能切开的临界值精准控制某个词的合并/拆分第一个坑是加载顺序。如果代码里先调用了jieba.cut触发了惰性初始化Jieba 首次使用时会加载主词典之后再load_userdict虽然词是加进去了但在此之前已经切过的文本不会重算。所以务必要在模块初始化阶段、任何分词调用之前完成加载。第二个坑是并行分词模式。jieba.enable_parallel()会把语料切片分给多进程主进程加载的用户词库在子进程里不一定同步。我一开始就栽在这儿——单进程测试明明切得对开了并行之后结果不一致调了半天才发现是这个问题。要么不开并行要么在子进程的初始化函数里再加载一次。第三个坑是词性标注的缓存。posseg是有内部缓存的如果你在同一个进程里先跑了不带用户词典的标注后续加了词再跑缓存命中会导致结果不变。稳妥的做法是每次实验都重启进程别在同一个 kernel 里反复起停。4.2 一条能复用的预处理流水线下面这段是我在实际项目里用了挺久的一个版本从原始文本到最终分词结果中间几个环节的顺序是有讲究的import re import jieba import jieba.posseg as pseg DOMAIN_DICT dict/domain_words.txt STOPWORDS_PATH dict/stopwords.txt KEEP_POS {n, nr, ns, nt, nz, vn, v, a, an} def init_tokenizer(): # 必须在任何切分动作之前完成 jieba.load_userdict(DOMAIN_DICT) # 对几条最容易误切的短语做精准干预 jieba.suggest_freq((不, 推荐), True) jieba.suggest_freq(游客中心, True) return {w.strip() for w in open(STOPWORDS_PATH, encodingutf-8) if w.strip()} def clean(text): text re.sub(rhttp\S|www\.\S, , text) # 去掉链接 text re.sub(r\[.*?\], , text) # 去掉表情文本标记 text re.sub(r(\d)\1{2,}, r\1, text) # 压缩重复数字 text re.sub(r([!?。])\1, r\1, text) # 压缩重复标点 text re.sub(r[a-zA-Z]{1,2}, , text) # 去掉短英文噪声 return text.strip() def tokenize_batch(texts, stopwords): out [] for t in texts: t clean(t) if len(t) 6: continue words [] for w, flag in pseg.cut(t): w w.strip() if len(w) 2: continue if w in stopwords: continue if flag in KEEP_POS: words.append(w) if len(words) 3: # 过短的文档对 LDA 无贡献 out.append(words) return out几个设计点值得单独说一下。过滤长度小于 2 的词是因为单字在中文里歧义太严重山、水、道这类字进入词表只会拉低主题可读性代价是损失了少量信息但收益明显更大。过滤词性用的是白名单策略而不是黑名单因为黑名单永远列不全。要求文档至少保留 3 个词这是为了保证进入 LDA 的每篇文档都有基本的共现结构低于这个数的文档对主题分布的贡献接近随机噪声。4.3 分词效果怎么验证不能只靠眼睛看着顺眼不是评估标准。我一般做两件事一是抽样人工核对。随机抽 100 条评论把优化前后的分词结果并排打印出来逐条看关键领域词有没有被正确切出。这个动作成本不高但极有效我在第二轮就靠它发现检票口在某种句式下仍然被切开的问题。二是看词表层面的指标。统计几个数字领域词在分词结果中的完整保留率词表里的词在语料中实际被完整切出的比例、平均词长优化后应该明显上升、单字 token 占比应该显著下降。我这次的数据是完整保留率从 61% 提到 94%平均词长从 1.42 涨到 1.89单字占比从 31% 降到 12%。这三个数字比任何主观判断都可靠。5. 分词改完之后LDA 那边要跟着调的事这是最容易被忽略的一环。很多人以为分词优化完就万事大吉直接把新词表扔进原来的建模脚本结果分数没涨多少还以为是分词没用。词表变了文档-词矩阵的形状就变了LDA 的超参和过滤阈值必须跟着重调。5.1 词表膨胀带来的稀疏度变化分词优化最直接的结果是词表规模下降但有效词增多。原来玻璃、栈道是两个词现在合并成一个玻璃栈道词表里少了一个但这个词的语义完整度反而提升了。我这次词表从 1.8 万降到 1.1 万看起来是降了但有效名词的覆盖率反而上升了。对应的处理是filter_extremes的重设。原来我用no_below5, no_above0.5优化后因为短文档被筛掉了一批实际文档数减少no_below需要相应下调否则会误删大量领域词。经验做法是按文档总数的千分之一到千分之二来定no_below一万篇文档就取 10 到 20 之间。no_above我一般放在 0.3 到 0.6 之间太严会把景区游客这类必要的高频词删掉太松又会让它们主导所有主题。这里有个反直觉的点值得强调no_above设得太高比如 0.9会让 LDA 的主题严重同质化。因为景区这个词在几乎每篇文档里都出现模型会发现把它放进任意主题都不会降低似然于是所有主题都带上了它。我一般会显式地把这类背景词单独列出来在no_above之外再手动剥离一次。5.2 主题数与 alpha/beta 的重新拿捏分词质量提升之后主题数是可以适当增加的。原因很直接原来因为词碎片化而被合并在一起的语义簇现在能分开了。我这次从 5 个主题试到 9 个用 c_v 一致性打分扫描主题数优化前 c_v优化后 c_v备注40.3820.447主题过少语义被强行合并60.4110.508优化后出现明显的体验/配套分野80.3980.531优化后最优主题边界清晰100.3710.492开始出现重复主题120.3520.461明显过拟合注意优化前的最优点在 6优化后在 8这个位移本身就是分词质量提升的证据——原来模型区分不出那么多语义簇现在能了。如果你优化完分词发现最优主题数没变那大概率是词表改得不够到位。alpha 和 beta 这块我用的是alphaauto让 gensim 自己学beta 保持默认的 1/K 或者 0.01。短文本语料建议 alpha 往小了调因为每篇文档涉及的主题本来就少稀疏的先验更贴合实际。beta 太小会导致主题词表过度集中在少数几个词上1/K 是个相对稳的起点。5.3 主题词可读性的前后对比光看分数不够最终还得看主题词能不能讲成人话。我挑了一组对比放这儿左边是优化前右边是优化后你能直观感受到差别在哪主题编号优化前的主题词优化后的主题词T1人 多 排队 时间 小时 感觉排队时长 索道 高峰 建议 提前T2玻 璃 栈 道 刺 激 走玻璃栈道 刺激 恐高 拍照 出片T3车 停 场 找 半天停车场 车位 收费 接驳车 引导T4吃 饭 贵 味道 一般餐饮 价格 味道 性价比 分量T5厕 所 干 净卫生间 母婴室 干净 无异味 无障碍优化前的 T1 到 T5基本没法用优化后每个主题都有明确指向索道体验网红打卡停车配套餐饮价格卫生设施这五个维度能直接对应到景区的管理条线。这就是分词优化真正的价值——不是提高了多少分而是让模型输出变得可交付。顺便说个细节优化后的主题词里出现了恐高和出片这种词在通用词典里根本不存在全靠本地词库补进去的。这类词恰恰是区分细分人群的关键恐高的人在意的是安全感和路线提示出片的人在意的是打卡点布局两类人的诉求完全不同。没有定制词库LDA 永远发现不了这种细分。6. 踩过的坑和上线前的自检这节讲几个我确实栽过的坑都是那种事后看很明显、当时就是想不通的类型。6.1 词表不是越长越好最开始我的思路是能想到的都加上词表搞到 3000 多条结果跑出来效果反而变差。排查后发现两个原因一是短词被长词吞并我加了游客服务中心结果游客中心这个更通用的说法在文本里也被优先匹配成长词反而降低了覆盖二是低频词稀释了概率分布3000 个词里有一大半在全语料中出现不到 5 次它们的存在只是增加了 DAG 的分支数让动态规划的选择空间变大反而更容易选错路径。后来我把词表压到 800 条左右只保留真正高频且语义独立的词效果立刻回升。这个经验我后来反复验证过词表规模和质量的关系不是线性的超过某个临界点之后每加一个词带来的边际收益都是负的。判断标准我总结成一句话这个词如果在语料里出现不到 30 次加它的意义就不大除非它在业务上极端重要比如无障碍通道这种。对于这种低频但关键的词正确的处理方式不是加进主词库而是单独做一份小词表用add_word显式指定一个对得起它地位的词频。6.2 停用词和领域词的冲突常规停用词表里都有不没无这类否定词直接过滤会出大事。推荐和不推荐在情感上是完全相反的两极把不当停用词过滤掉之后这两类评论在 LDA 里就变得无法区分了。我在第一版结果里就看到过一个主题主题词是推荐 / 值得 / 建议点进去看原文一半是好评一半是差评主题直接失效。处理方式有两种我倾向于第一种把否定词和相邻词组合成整体加进词库不推荐、没意思、不值得、不会再来各成一条这样切分时它们就是不可分割的整体不会被停用词表误伤。第二种是保留否定词、但从 LDA 词表里剔除缺点是它们仍然占据文档长度影响主题分布的归一化。6.3 交付前的一份自检清单跑完整个流程之后我会对着这几条过一遍基本能保证不留明显的问题领域词完整率抽检随机 100 条评论人工数一遍关键领域词被完整切出的比例低于 90% 就回去补词表。单字占比超过 15% 说明清洗不够或者词频设置有问题。文档长度分布过滤后的文档平均长度掉到 5 个词以下说明过滤太狠LDA 学不到东西。主题词有无明显重复两个主题的前十个词重叠超过一半说明主题数偏多或者 alpha 太大。有无背景词污染景区、游客、感觉这类词如果出现在超过 60% 的主题里需要单独剥离。否定词是否生效抽查差评样本看它们的主题归属是否明显偏离好评。最后再分享一个小技巧。做完一轮优化之后别急着删掉旧的分词脚本保留一份基线后面每次调参都拿它做对照。我在第三轮的时候一度觉得是不是改坏了因为一致性分数掉了 0.02回头跟基线一比才发现是主题数选错了分词那边其实是进步的。有个稳定的对照物在手很多判断会变得清晰得多也省掉大量来回试错的时间。
阅读完成 · 觉得有帮助?