资讯详情

无词典纯算解析:短文本关键词抽取的频次、凝固度与左右熵联合排序

📅 2026/10/18 15:33:27 | 华诺云谱 👁 阅读
无词典纯算解析:短文本关键词抽取的频次、凝固度与左右熵联合排序
简介面向逆向分析与Web安全方向开发者的阿里140-n值纯算生成代码包围绕加密参数复现这一核心场景完整还原由Ye、ke、B、u四个数组组装、自定义base64加密并添加140#前缀的生成流程涵盖加密算法、数组来源、时间戳拆分及浏览器数据计算等关键环节。代码包共8个文件以3个PHP核心实现、1个Python脚本及2个HTML演示页面为主搭配环境配置文件PHP文件承担服务端生成逻辑Python脚本便于批量化验证HTML页面可直接在浏览器中观察效果压缩包仅16KB结构清晰。已有155人学习下载适合希望研究阿里系加密参数生成逻辑、提升逆向分析与代码复现能力的开发者。通过源码可对照分析各数组的组成关系理解P数组、T数组、g数组的拼接与拆分思路并结合演示页面验证生成结果是一份轻量而完整的纯算案例参考。1. 纯算解析解决什么问题一百四十字级短文本的无词典切词与关键词定位先看一个常见场景某公司每天从业务系统里捞出一批一百四十字符上下的短文本可能是用户提交的诉求、消息摘要、备注字段也可能是某个跨平台系统里截出来的内容片段。你想自动抽取每一条的主题词用来做聚类、筛选或标签统计但手上没有领域词典人工标注又慢外包标注更不现实。这时最直接的办法就是让算法自己从字符分布里“算出”哪些片段更像词这就是标题里说的“140-n值纯算解析”在短文本集合上以 n 值滑动窗口生成候选片段再用频次、凝固度、左右熵这些纯统计信号打分排序不依赖任何外部词表。它适合先做语料预分析、快速看数据里有什么词的从业者也适合给下游的标签体系做冷启动候选。反直觉的一点是即使完全没有词典这套纯算方案在短文本场景下也能抽出七成以上肉眼可用的关键词代价只是几个小时的代码和参数调试。2. 纯算解析的核心依据频次、凝固度与左右熵如何共同决定一个 n 值2.1 为什么不用词典无监督统计的三个可计算信号做关键词抽取大多数人第一反应是配词典或分词工具。但词典有两个绕不开的问题一是维护成本新词、简称、内部黑话永远比词典更新快二是跨领域迁移差某公司业务线 A 的词表换到业务线 B 基本失效。纯算解析的思路是把“词”的定义降维成三个可计算的统计量出现频次、内部凝固度、外部左右熵。频次解决“哪些片段不是偶然出现”凝固度解决“哪些字组合得更紧密”左右熵解决“哪些片段在上下文里像独立个体”。这三个信号全部来自文本本身不依赖任何外部资源换个领域只要重新跑一遍统计就行。需要注意这里要处理的对象不是整篇长文而是一百四十字左右的短文本。短文本的特点是单条信息密度低、重复模式强、噪声格式多所以算法设计的重心会放在候选生成和过滤上而不是复杂的语言模型。2.2 频次与凝固度先找到“像词”的候选片段频次是纯算方案的第一道门槛。把所有可能的 n 值组合扫一遍出现次数过少的片段直接丢弃因为统计意义太弱。但频次高不代表是词比如“的”“了”“我们”这类高频片段单独看毫无价值所以紧接着要用凝固度衡量片段内部的粘合程度。简单说“阿里”这个词内部的“阿”和“里”经常一起出现而“阿”单独出现的场景很少所以凝固度高反过来“的方”这种片段虽然也可能连续出现但左右两边的字各自都能自由组合凝固度就很低。凝固度的计算通常用点互信息的变体。对于一个长度为 n 的候选片段把它在每一个可能的位置切成左右两段分别计算两段同时出现的概率与各自出现概率乘积的比值取所有切分位置里的最小值作为该片段的凝固度。公式表达为cohesion(gram) min over cut in 1..n-1 of p(gram) / (p(left_part) * p(right_part))取最小值的原因是只要存在一种切分可以让左右两部分“独立成立”就说明这个片段内部结合不够紧应该用最弱的一环来代表它的凝固程度。2.3 左右熵用上下文不确定性卡掉半截词凝固度只能判断内部粘合判断不了边界。举个实际例子“阿里”在语料里高频出现内部凝固度也不低但如果语料里大量出现的是“阿里巴巴”和“阿里云”“阿里”作为完整词出现其实是被截断的半截词。这时候需要用左右熵来看上下文。左右熵衡量的是候选片段左边和右边出现字符的混乱程度。算法是统计该片段左边出现的所有字符及其概率计算信息熵右边同理。计算公式为H_left(gram) - sum over c of p(c | gram) * log(p(c | gram))熵值越高说明上下文越多样化片段越像是一个独立边界熵值低说明它总是被固定的邻居环绕很可能只是某个更长词的一部分。实际应用时常取左右熵的较小值作为该片段的边界分数因为只要有一侧边界固定就说明这个片段切得不干净。2.4 三个信号的合成公式与 n 值含义三个信号合成一个排序分常见做法是加权乘积score(gram) norm_freq(gram) * cohesion(gram)^alpha * min(H_left, H_right)^beta其中 alpha 和 beta 是调节频次、凝固度、左右熵相对权重的超参数。频次要做归一化否则高频片段会碾压一切。alpha 大于 1 会偏向内部更紧的词beta 大于 1 会偏向边界更独立的词。这里要说明一个容易混淆的点标题里的“140”指的是文本体量规模也就是一百四十字符级短文本而不是 n 值本身n 值指的是滑动窗口里的 n-gram 长度通常在 2 到 6 之间取值。把这两个概念分开后面调参才不会乱。把三个信号联合起来纯算解析就能在没有任何词典的情况下从一批短文本里筛出“频次够高、内部够紧、边界够独立”的片段作为候选关键词。下一章开始讲怎么从原始文本一步步算出这些信号。3. 从文本到候选集预处理与 n 值窗口的确定方法3.1 清洗规则去掉什么、保留什么纯算解析对清洗比分词敏感得多。原始短文本里常见的噪声包括URL、连续数字、表情符号、全角标点、重复空格。这些噪声如果不处理会产生两类后果一是把真实词切断比如“阿里-云”中间的横线会破坏候选片段二是制造大量无意义的高频片段比如连续数字的若干子串会霸占频次榜。清洗的核心原则是把不可解析的字符替换成空格作为分隔符而不是直接删除。直接删除会把前后两个字拼在一起制造出根本不存在的伪词。import re def clean_text(raw: str) - str: # 全角转半角 text raw.translate({0xFF01 i: 0x21 i for i in range(94)}) # URL 替换为空格 text re.sub(rhttps?://\S, , text) # 连续数字保留为整体占位避免子串污染 text re.sub(r\d{4,}, NUM , text) # 标点符号统一替换为空格 text re.sub(r[^\w\u4e00-\u9fff], , text) # 合并连续空格 text re.sub(r\s, , text).strip() return text逻辑说明全角转半角是为了避免“”和“,”被当成两种分隔符URL 直接替换成空格因为 URL 内部的字符组合没有语言学意义四位以上连续数字替换成占位符 NUM既避免数字串的子串进入候选集又保留了它在上下文里的边界作用最后把所有非中英文、非数字的字符统一换成空格作为词边界。参数说明四位以上的阈值可以根据业务数据调像订单号、日期戳这类长数字多的话建议保留如果短信验证码这类短数字多可以把阈值降到两位。3.2 n 值扫描窗口1 到 6 的范围与步长策略n 值决定了候选片段的长度。在短文本场景里n 值取 1 没有意义因为单个字符是构成词的基本单元任何词都包含单字单字无法表达“组合”的信息n 值取过大则候选片段数量爆炸而且一百四十字量级的文本里很少出现超过六个字的核心词。常见做法是 n 值从 2 扫到 6步长为 1。这样既能覆盖两个字的标准词也能覆盖四个字的业务词偶尔还能抓住五六个字的专用名词。需要根据语料特性微调。如果业务文本里大量出现品牌名、产品名等固定短语可以把 n_max 加到 8但每加一档候选数量大约翻一倍后续过滤和排序的耗时也随之增加。我一般会先用 2 到 6 跑一遍看结果分布再决定要不要扩展而不是一开始就把窗口开大。3.3 候选生成代码与频次统计候选生成的核心是一个滑动窗口计数器。注意要在清洗后的空格分隔符上做切分避免跨句子拼词。下面是生成候选片段并统计频次的实现from collections import defaultdict def scan_candidates(cleaned_texts, n_min2, n_max6): counter defaultdict(int) for text in cleaned_texts: segments text.split() for seg in segments: chars list(seg) seg_len len(chars) for n in range(n_min, min(n_max, seg_len) 1): for i in range(seg_len - n 1): gram .join(chars[i:i n]) counter[gram] 1 return counter逻辑说明外层循环遍历每一条清洗后的文本按空格切成 segment避免跨段拼接内层循环从一个 segment 内部按 n 值滑动窗口生成候选。min(n_max, seg_len) 的作用是防止段长小于 n 时越界。参数说明n_min 设为 2 是因为单字候选没有“组合”意义n_max 设为 6 是在候选数量和词长覆盖之间取平衡counter 使用 defaultdict(int)键是候选片段字符串值是出现次数。需要注意的是这里的频次统计会受文本长度和重复内容影响后面调频次阈值时要做归一化而不是直接用原始频次对比不同文本规模。3.4 频次阈值初设先看分布再看阈值很多人在这一步直接拍一个阈值比如“频次小于 5 的删掉”这很容易翻车。正确做法是先画出频次分布找到长尾的拐点再决定阈值。一个简单有效的办法是统计频次 Top 500 候选的频次值看分布曲线。如果语料是几千条短文本通常会出现一个明显的断崖前几十个候选频次几百上千后面的候选频次迅速掉到个位数。阈值应该设在断崖之后、尾部之前的位置。太靠近断崖会把大量低频但有用的业务词删掉设得太低则候选集里全是噪声。我常用的经验公式是先取频次分布的中位数作为临时阈值跑一轮然后人工抽查结果里低频词的准确率再上下调整。这一步是后面所有排序的基础阈值不合理后面的凝固度和熵算得再准也白搭。4. 把候选变成结果纯算打分、排序与回填定位4.1 凝固度与左右熵的并行计算频次统计完成后进入打分阶段。先算凝固度再算左右熵。计算凝固度需要用到每个片段内部切分点的频次信息最简单的实现是遍历所有候选对每个候选在其可能的切分位置查频次表。左右熵需要重新遍历原始文本统计每个候选片段的左右邻居字符分布。import math from collections import defaultdict def calc_cohesion(gram_freq, char_freq, total_grams): gram list(gram_freq.keys()) cohesion {} for g, freq in gram_freq.items(): n len(g) if n 2: continue min_pmi float(inf) for cut in range(1, n): left g[:cut] right g[cut:] p_left char_freq[left] / total_grams if left in char_freq else 1e-10 p_right char_freq[right] / total_grams if right in char_freq else 1e-10 p_gram freq / total_grams pmi p_gram / (p_left * p_right) min_pmi min(min_pmi, pmi) cohesion[g] min_pmi return cohesion逻辑说明char_freq 是单字或双字等子串的频次表这里用 char_freq 记录候选片段所有子串的频次计算时直接查表避免重复扫描。total_grams 是所有 n 值候选的总频次和用于概率归一化。pmi 值越大说明左右两部分同时出现越不偶然内部结合越紧密取所有切分位置的最小值保证片段在任何一个位置都不能被轻易拆开。参数说明如果语料非常小p_left 或 p_right 可能为 0这里用 1e-10 作平滑防止除零。左右熵计算需要重新遍历文本记录每个候选的左边和右边出现的字符分布def calc_context_entropy(cleaned_texts, gram_set): left_ctx defaultdict(lambda: defaultdict(int)) right_ctx defaultdict(lambda: defaultdict(int)) for text in cleaned_texts: for seg in text.split(): chars list(seg) for n in range(2, 7): for i in range(len(chars) - n 1): gram .join(chars[i:i n]) if gram not in gram_set: continue if i 0: left_ctx[gram][chars[i - 1]] 1 if i n len(chars): right_ctx[gram][chars[i n]] 1 entropy {} for gram in gram_set: l_total sum(left_ctx[gram].values()) r_total sum(right_ctx[gram].values()) h_l -sum((c / l_total) * math.log(c / l_total) for c in left_ctx[gram].values()) if l_total else 0 h_r -sum((c / r_total) * math.log(c / r_total) for c in right_ctx[gram].values()) if r_total else 0 entropy[gram] min(h_l, h_r) return entropy逻辑说明left_ctx 和 right_ctx 都是双层 defaultdict外层键是候选词内层键是邻居字符。遍历时只统计在 gram_set 中存在且长度在 2 到 6 之间的候选减少无效计算。熵值取左右较小值因为只要有一侧边界固定候选词就不完整。注意边界情况如果某个候选只出现在文本开头它的左邻居为空左熵为 0此时说明它的左侧没有多样性分数会很低这在大部分场景下是合理的但对于文本首部出现大量实体的情况可以考虑把首部边界当作一种特殊字符参与统计。4.2 过滤规则停用字、数字串、交叉覆盖打分之后必须过滤否则高分候选里会混入大量“伪词”。第一类要过滤的是停用字开头的片段比如“的、了、在、是、有”等高频功能字它们和后面一个字组合后凝固度可能不低但没有任何抽取价值。第二类是包含数字占位符 NUM 的片段这类片段通常是订单号、日期等业务数据的碎片。第三类是重叠候选比如“阿里”和“阿里巴巴”同时高分实际只需要保留更完整的那个。def filter_candidates(candidates, stop_chars, min_len2, max_num_ratio0.5): filtered [] for gram, score in candidates: if len(gram) min_len: continue if gram[0] in stop_chars or gram[-1] in stop_chars: continue num_count sum(1 for ch in gram if ch.isdigit()) if num_count / len(gram) max_num_ratio: continue filtered.append((gram, score)) return filtered逻辑说明过滤条件依次是长度、首尾停用字、数字占比。首尾停用字单独判断是因为中文词的开头和结尾功能字往往决定了它是不是一个完整词。数字占比超过一半的候选直接丢弃因为在短文本场景里这类片段几乎都是业务编号。参数说明stop_chars 需要根据语料语言调整中文场景下一开始可以先放二十个常见功能字后续看结果补漏min_len 取 2 是为了只保留多字词max_num_ratio 取 0.5 表示允许“3C 认证”“4S 店”这类词保留半数是数字的也还能接受。4.3 排序与 Top-N 输出过滤完进入合成打分。合成公式采用加权乘积其中频次做归一化到 0 到 1 区间避免不同语料规模造成的量纲差异。def rank_candidates(gram_freq, cohesion, entropy, alpha1.0, beta1.0, top_n100): max_freq max(gram_freq.values()) scored [] for gram in cohesion: if gram not in entropy: continue norm_freq gram_freq[gram] / max_freq score norm_freq * (cohesion[gram] ** alpha) * (entropy[gram] ** beta) scored.append((gram, score)) scored.sort(keylambda x: x[1], reverseTrue) return scored[:top_n]逻辑说明norm_freq 用最大频次归一化让频次信号落在 0 到 1 区间凝固度和熵值本身没有固定上界用指数参数 alpha 和 beta 来控制它们对最终分数的影响幅度。排序后取 Top-N 输出。参数说明alpha 初始取 1.0如果结果里高频泛化词过多说明凝固度权重偏低把 alpha 调到 1.5 到 2.0beta 初始取 1.0如果结果里出现大量被截断的半截词说明边界约束不够把 beta 调高。top_n 按业务需要设置做标签预分析一般取 100 到 200 就够。4.4 定位回填把关键词映射回原始文本排序结果只是候选词列表落地时通常还需要知道每个词出现在哪些原始文本的哪个位置。这个步骤叫定位回填。实现上不需要重新扫描统计直接对每条原始文本做一次子串搜索即可。但如果候选词有交叉重叠需要优先匹配较长词否则“阿里巴巴”会被“阿里”先匹配掉。def locate_keywords(original_texts, keywords, max_look6): matched defaultdict(list) keywords_sorted sorted(keywords, keylen, reverseTrue) for idx, text in enumerate(original_texts): cleaned_len len(text) for kw in keywords_sorted: start 0 while True: pos text.find(kw, start) if pos -1: break matched[kw].append((idx, pos, pos len(kw))) start pos 1 return matched逻辑说明keywords_sorted 按长度从长到短排序确保长词优先匹配避免同一文本里长词被短词截胡。对每个关键词在原始文本里循环 find记录文档编号、开始位置和结束位置。参数说明max_look 参数在这里保留是为了控制匹配深度如果文本很短可以忽略matched 输出为后续做高亮、切分、特征提取都提供了坐标基础。5. 参数调节与避坑n 值、阈值和语料偏差引起的三类翻车5.1 现象一n 值窗口设死长词永远出不来有次做某业务线的短文本主题抽取n_max 固定为 4跑出来结果里全是两字词一些明显是固定品牌名的四字以上词完全没有出现。原因很简单n_max 是 4候选生成阶段五字词根本没进入统计后面打分再准也出不来。原因背后的逻辑是n 值窗口决定了算法的“视野”窗口上限外的东西在统计阶段就已经被丢弃。解决方法是先用 2 到 6 跑一遍看结果里有没有大量长词被截断成短词如果被截断的短词凝固度和熵都很高说明真实词更长。把 n_max 调到 8 或 10 重跑同时把过滤逻辑里的 min_len 同步上调否则刚把长词找出来又被长度过滤删掉等于白调。5.2 现象二语料重复度高频次虚高导致熵值失效一次在导语重复率特别高的数据源上跑左右熵基本全部接近 0排序结果乱成一团。原因是语料里大量文本是同一模板套不同参数比如“XX上线通知XX 已于今日启动”这种模板会让所有候选词的左右邻居都高度固定熵值失去区分度。这种情况下不要指望熵能救回来。解决思路有两个一是做模板归一化先把文本里频繁出现的固定前缀和后缀替换成占位符再做统计二是干脆降权熵的权重把 beta 调到 0.3 以下让排序主要由凝固度和频次决定。如果业务数据本身模板化程度很高建议在预处理阶段先做去重按 MD5 或相似度去重后再进统计否则同一模板的重复文本会把所有信号带偏。5.3 现象三低频好词被频次阈值一刀切有一批语料只有两千条左右频次阈值设在 5结果很多准确的业务词因为只出现三四次被删掉了剩下的全是高频泛化词。这是短文本场景最容易踩的坑。低频不一定不是词尤其是在小语料里一个专有名词可能只出现两三次但它的凝固度和熵值都非常突出。原因是阈值筛选在打分之前就把候选删掉了后面几个信号根本没机会参与判断。解决方法是把频次阈值放得足够低让候选进入打分阶段然后用分数排序而非频次筛选。在两千条语料级别频次阈值设为 2 甚至 1 都可以接受因为凝固度和熵会在排序阶段把低频噪声压下去。如果担心低频词过多先跑一轮看 Top 200 的低频词准确率再决定是否上调阈值。5.4 参数推荐区间与调试顺序从多次实操经验看短文本纯算解析的参数推荐区间如下表但这只是起点最终要按语料和业务反馈调整。参数推荐区间调整方向n_min2一般不动n_max6 到 8长词多往上调噪声多往下调频次阈值语料规模的 1/1000 到 1/100准确率低上调召回低下调alpha1.0 到 2.0结果泛化词多往上调beta0.5 到 1.5半截词多往上调模板语料往下调top_n100 到 300看下游用途调试顺序也要讲究。我一般先固定 n 值和频次阈值只调 alpha 和 beta看排序结果否合理确认打分逻辑没问题后再回头调 n 窗口和频次阈值。反着调会非常痛苦因为打分和候选生成同时变化时很难判断结果变动来自哪一端。另外每次调参后保留一份输出快照方便对比前后差异避免调了半天不知道哪一步把好结果改坏了。6. 用外部信号验证纯算结果的三个技巧纯算解析跑完不等于交付还要验证结果可信度。第一个技巧是稳定性复现检查对同一批语料做多次随机采样每次取 80% 的文本跑完整流程看 Top 50 关键词的集合交集率。交集率高于百分之六十说明抽取结果对语料波动不敏感算法是稳定的如果交集率只有百分之二三十说明很多关键词的出现本身就是偶然要警惕模板噪声或阈值过低。import random def stability_check(texts, run_pipeline, n_trials3, ratio0.8, top_k50): result_sets [] for _ in range(n_trials): sample random.sample(texts, int(len(texts) * ratio)) keywords [kw for kw, _ in run_pipeline(sample)[:top_k]] result_sets.append(set(keywords)) inter set.intersection(*result_sets) return len(inter) / top_k逻辑说明run_pipeline 是前面清洗、扫描、打分、排序的完整流程封装每次采样随机取 80% 文本得到一组 Top 50三个集合的交集除以 50 即交集率。参数说明n_trials 取 3 是因为短文本语料通常不大更多次采样会增加耗时但收益有限ratio 取 0.8 是权衡稳定性和差异性太低会导致低频词大量抖动。第二个技巧是和简单基线对比。比如用一个只按频次排序的规则做基线把纯算结果的 Top 100 和基线的 Top 100 做交叠率对比再人工抽样看纯算多出的词是否更准。如果纯算结果和纯频次结果几乎一样说明凝固度和熵的调节没起效检查一下是不是预处理阶段把边界信息全删掉了。第三个技巧是把纯算结果当特征而不是最终标签。我习惯把 Top 关键词按分数转成权重向量拼进下游的分类或聚类特征里而不是直接当“标准答案”用。纯算解析的价值在于快速建立对数据的感知——知道这批短文本里出现频次高、内部紧密、边界清晰的片段是什么至于这些片段是不是最终业务标签还要靠下游验证。这些年落地下来纯算解析给我最大的教训是它解决的是“没有词表时怎么起步”的问题而不是“词表可以不要了”的问题。先靠纯算抽词再人工审核构建词表后续迭代时把词表作为先验融入统计这才是高效路径。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑