ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

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

简介:面向逆向分析与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(r'https?://\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_min=2, n_max=6): 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_len=2, max_num_ratio=0.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, alpha=1.0, beta=1.0, top_n=100): 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(key=lambda x: x[1], reverse=True) return scored[:top_n]

逻辑说明:norm_freq 用最大频次归一化,让频次信号落在 0 到 1 区间;凝固度和熵值本身没有固定上界,用指数参数 alpha 和 beta 来控制它们对最终分数的影响幅度。排序后取 Top-N 输出。参数说明:alpha 初始取 1.0,如果结果里高频泛化词过多,说明凝固度权重偏低,把 alpha 调到 1.5 到 2.0;beta 初始取 1.0,如果结果里出现大量被截断的半截词,说明边界约束不够,把 beta 调高。top_n 按业务需要设置,做标签预分析一般取 100 到 200 就够。

4.4 定位回填:把关键词映射回原始文本

排序结果只是候选词列表,落地时通常还需要知道每个词出现在哪些原始文本的哪个位置。这个步骤叫定位回填。实现上不需要重新扫描统计,直接对每条原始文本做一次子串搜索即可。但如果候选词有交叉重叠,需要优先匹配较长词,否则“阿里巴巴”会被“阿里”先匹配掉。

def locate_keywords(original_texts, keywords, max_look=6): matched = defaultdict(list) keywords_sorted = sorted(keywords, key=len, reverse=True) 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_trials=3, ratio=0.8, top_k=50): 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 关键词按分数转成权重向量,拼进下游的分类或聚类特征里,而不是直接当“标准答案”用。纯算解析的价值在于快速建立对数据的感知——知道这批短文本里出现频次高、内部紧密、边界清晰的片段是什么,至于这些片段是不是最终业务标签,还要靠下游验证。这些年落地下来,纯算解析给我最大的教训是:它解决的是“没有词表时怎么起步”的问题,而不是“词表可以不要了”的问题。先靠纯算抽词,再人工审核构建词表,后续迭代时把词表作为先验融入统计,这才是高效路径。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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