深层级分类:把内容分到类目最深处
💡 一句话总结:用 Jev 层级分类穿透深层类目树 —— 每层一道 Choice 拿全概率分布、K=3 并行 beam search 逐层下走、几何平均分排序截断。
你的类目表可能比你想的深得多。官方 Hierarchical Classification cookbook 拿四棵真实类目树做实验:CPC 专利分类 254,274 个节点、Shopify 零售商品类目 12,378 个、MeSH 生物医学主题 65,373 个、还有一套源码目录树 125 个节点。文档要分到叶子才算分完——比如一条「鸟类栖木」的专利摘要,最终要落到 A01K31/12 Perches for poultry or birds, e.g. roosts 这个具体类目。
一道 Choice 最多 255 个选项,根本装不下 25 万个节点。官方的做法:把类目树当成菜单,一层一层往下问——每个节点只问「哪个直接子类最匹配」,答案的概率分布就是通往下一层的边。这是官方文档点名的真二次请求场景之一:代码必须拿到上一层的答案,才知道下一层该给哪些选项(primitives.md 原话:uses each Choice answer to decide which options the next request offers)。
一层层问还有个隐藏陷阱:浅层判断常常是模糊的(「这条商品描述算宠物用品还是家具?」),每层只押一个答案,错一步就再也回不来。官方的解法是 beam search:保留 K=3 条候选路径,每轮并行展开,用深层的证据修复浅层的歧义。官方实测:greedy 命中 2/4,beam K=3 命中 4/4。本课约 20 分钟带你完整走一遍(官方难度:Intermediate)。
学完你能做什么
- 把任何深层级类目(电商类目、邮件文件夹、工单体系、源码目录)变成嵌套字典,每层一道 Choice 往下问
- 写出 beam search 完整实现:K=3 候选路径、几何平均分排序、
ThreadPoolExecutor并行展开 - 读懂 separation 分离度指标,知道什么时候该转人工而不是硬分
- 讲透官方 4 个数据集的实测结论:greedy 2/4 → beam 4/4,包括 CPC 和 Shopify 上 beam 如何修复浅层歧义
为什么用 Jev,而不是普通 LLM 分层分类?
普通 LLM 也能一层层问「该走哪个子类」,但每层你都要解析它吐的文本,而且它给不出量化的把握。Jev 每层的答案自带概率分布:
| 能力 | 普通 LLM 逐层分类 | Jev 层级分类 |
|---|---|---|
| 每层选项 | 模型自由发挥,类目名天天漂 | 答案永远是当前节点的直接子类之一,无解析 |
| 把握程度 | 要追问「你确定吗」,回答也是文本 | 每条边自带概率,几何平均分直接算 |
| 浅层歧义 | 押一个,错了就往下硬编 | beam search 保留 K=3 条路,深层证据回头修复 |
| 候选路径并行 | 逐层逐路径发请求,延迟叠 K 倍 | beam 里每条路径各发一道题,同一轮并行发出 |
| 观测调试 | 中间过程是黑盒 | 每个节点、每条边的概率都存下来,错在哪一层一目了然 |
最后一个不是小事。官方 cookbook 专门强调:分解成层级本身就送你可观测性(哪个节点的误分类最多、每条边被走过多少次)和可测试性(类目表更新后,单测直接量化对分类质量的影响)。
你现在的困境
- 「商品库 300 万条,类目树 6 层 12,000 个节点,AI 商品分类浅层还行,越深越没人说得准,人工一条条翻」
- 「AI 邮件分类做了三层文件夹,模型每次都硬选一个文件夹,错一层后面全错,还不知道错在哪层」
- 「想让 LLM 输出 JSON 分类结果,格式崩一次管道就断一天;想加『不确定就转人工』,又不知道怎么量化不确定」
什么时候用这一招
当你需要:
- 把文档分进几十层深、几千到几十万节点的类目树,而且要落到叶子
- 每层的判断有歧义时保留备选——深层的证据说了算
- 每一步都留下概率轨迹:错在哪一层、哪条边最模糊,都能查
而且不想:
- 一次塞 255 个以上的选项(Choice 硬上限)
- 每层解析 LLM 吐出来的自由文本
- 类目表一更新就要重调 prompt 重跑全量
🎒 开始前的准备
- 完成了 Choice 选择题:会写
criteria选项 map,读得懂choice和probabilities - 跑通过安装与第一个请求:
.venv可用,TYPESAFE_API_KEY在环境变量里 - (更顺)读过并行提问与投机扇出:本页的 beam 并行展开就是同一条「问题并行评估」的能力
- 一杯水 ☕——本页代码分六步攒成一个文件:一棵树、两种搜索、一组指标,第 6 步才第一次看到输出
跟我做
新建 hierarchical_classification.py,把下面每一步的代码块按顺序贴进同一个文件。本页不下载官方数据集——官方加载代码很长、下载体积也不小;我们直接用官方已验证的结论和数字,代码聚焦在算法本身,换成你自己的类目树就能跑。
第 1 步:把扁平类目树改成嵌套字典
为什么:层级分类的第一件事不是发请求,是把你的类目表变成 Tree = dict[str, Tree]——每个节点只列直接子类。下一层长什么样,要等上一层的答案才知道,这正是「真二次请求」的定义。
先贴入骨架:
import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typesafe_sdk import Choice, RetryPolicy, TypeSafeClient
Tree = dict[str, "Tree"] # 每个节点 = {直接子类名: 它自己的子树};叶子 = {}
# 换成你的类目:邮箱文件夹、电商类目、工单体系都行
TREE: Tree = {
"退货/物流": {
"退货流程": {"退货地址": {}, "退款进度": {}},
"物流查询": {"快递单号": {}, "配送异常": {}},
},
"产品咨询": {
"尺码规格": {},
"使用方法": {},
},
}
DOCUMENT = "客户消息:我上周退的裙子,退款怎么还没到账?"Tree 里每个键就是当前节点的一道题的选项;空字典 {} 就是叶子——分到这里,分类结束。
你应该看到:文件还跑不出任何东西——TREE 和 DOCUMENT 只是数据,发请求的代码第 2 步才贴。
- 我的
TREE里每个节点只列直接子类,没有跨层跳跃
第 2 步:写 child_question:每层一道 Choice 问「哪个直接子类最匹配」
为什么:官方的做法是把每个节点的子类列表组装成一道 Choice:instructions 固定一句话,criteria 是子类映射。keys 用 c0、c1 这种短键名,答案回来再映射回原始 label——类目名常常很长很怪(Cat Window Beds & Perches),短键名让概率分布干净利落。
接着贴入客户端和问题构造:
MODEL, BEAM_WIDTH, MAX_DEPTH, EPSILON = "jev-1.12", 3, 12, 1e-9
client = TypeSafeClient(
api_key=os.environ["TYPESAFE_API_KEY"],
retry=RetryPolicy(max_retries=5, backoff_initial=1.0, backoff_max=20.0),
)
def child_question(labels: tuple[str, ...]) -> tuple[Choice, dict[str, str]]:
"""Build the direct-child Choice and its reversible option mapping."""
keys = {f"c{i}": label for i, label in enumerate(labels)}
question = Choice(
instructions="Which direct child category best matches this document?",
criteria=keys,
)
return question, keys
def subtree(tree: Tree, path: tuple[str, ...]) -> Tree:
"""Return the direct-child menu below ``path``."""
value = tree
for label in path:
value = value[label]
return valueMODEL 用官方 cookbook 采样用的 jev-1.12;换成 jev-latest 也行,概率数字会和官方录制的略有出入。MAX_DEPTH=12 是安全阀——官方 MeSH 的 Crohn Disease 路径有 6 层,够用且防爆栈。
你应该看到:还是没有输出——客户端和 child_question() 只是定义,还没有任何代码调用它们。
- 我能说出
criteria的 keys 为什么用c0/c1而不是原始类目名
第 3 步:实现 greedy:每层只押概率最高的一个子节点
为什么:贪心是最直觉的走法,也是官方对照组。先跑通它,你就亲眼看到层级分类的死穴:一次早错,无法挽回。
接着贴入请求封装和贪心:
def choose(state: str, labels: tuple[str, ...]) -> dict[str, float]:
"""Ask one atomic direct-child question and return its distribution."""
if len(labels) == 1: # 只有一个子类:不是决策,不发请求
return {labels[0]: 1.0}
question, keys = child_question(labels)
response = client.system_one(state=state, questions={"child": question}, model=MODEL)
probabilities = response.answers["child"].probabilities
return {label: probabilities[key] for key, label in keys.items()}
def greedy_search(tree: Tree, document: str) -> dict:
"""Follow only the locally highest-probability child."""
path, probability_product, decision_count = (), 1.0, 0
for _ in range(MAX_DEPTH):
labels = tuple(subtree(tree, path))
if not labels:
break
probabilities = choose(document, labels)
label = max(probabilities, key=probabilities.get)
if len(probabilities) > 1:
probability_product *= max(probabilities[label], EPSILON)
decision_count += 1
path += (label,)
score = probability_product ** (1 / decision_count) if decision_count else 1.0
return {"path": path, "score": score}两个官方细节。单子节点不是决策:choose() 在只有一个子类时直接返回 1.0,不发请求、不进 decision_count——概率连乘除以决策数,才不会被这种「假决策」稀释。EPSILON=1e-9:概率为 0 的边在连乘里会把整条路径打成 0,几何平均分就废了;官方用 1e-9 兜底。
运行(设好 TYPESAFE_API_KEY):
python hierarchical_classification.py你应该看到:还没有输出——贪心函数刚定义好。但你可以先口头走一遍:退货/物流 vs 产品咨询 一问,退货流程 vs 物流查询 一问,退货地址 vs 退款进度 一问,三次请求分到叶子。
- 我能说出为什么单子节点不算一次决策
第 4 步:实现 beam search:保留最好的 3 条路径逐层并行展开
为什么:这是本课的核心。贪心在浅层错了就全错;beam search 保留 K 条候选,每轮把所有可展开路径并行发出去问,用深层的证据给浅层的歧义第二次机会。
接着贴入:
def extend_candidate(candidate: dict, label: str, probabilities: dict[str, float]) -> dict:
"""Append one edge and recompute its geometric-mean path score."""
is_decision = len(probabilities) > 1
probability_product = candidate["probability_product"] * (
max(probabilities[label], EPSILON) if is_decision else 1.0
)
decision_count = candidate["decision_count"] + is_decision
return {
"path": candidate["path"] + (label,),
"probability_product": probability_product,
"decision_count": decision_count,
"score": probability_product ** (1 / decision_count) if decision_count else 1.0,
}
def beam_search(tree: Tree, document: str) -> dict:
"""Parallel width-three beam search using geometric-mean probability."""
beam = [{"path": (), "probability_product": 1.0, "decision_count": 0, "score": 1.0}]
for _ in range(MAX_DEPTH):
expandable = [c for c in beam if subtree(tree, c["path"])]
finished = [c for c in beam if not subtree(tree, c["path"])]
if not expandable:
break
with ThreadPoolExecutor(max_workers=BEAM_WIDTH) as executor:
distributions = list(
executor.map(
lambda c: choose(document, tuple(subtree(tree, c["path"]))),
expandable,
)
)
expanded = []
for candidate, probabilities in zip(expandable, distributions, strict=True):
for label in probabilities:
expanded.append(extend_candidate(candidate, label, probabilities))
beam = sorted(finished + expanded, key=lambda c: c["score"], reverse=True)[:BEAM_WIDTH]
beam = sorted(beam, key=lambda c: c["score"], reverse=True)
return {"beam": beam}读法按官方公式来:
path_score = product(edge_probabilities) ** (1 / decisions)——几何平均。为什么不是连乘:连乘会惩罚深路径,一个 10 层全 0.9 的路径(0.35)会输给 3 层全 0.9 的路径(0.73);几何平均把深度归一,浅叶深叶公平比separation = top_path_score / second_path_score——第一名和第二名的比值,不用于剪枝,是给你看歧义程度的- 每轮
beam按分数排序截断到 K=3;已到叶子的路径带着分数留在finished里继续参与最终排序
💡 这就是 Jev 的独有价值
beam search 本身是老算法,机器翻译解码用了几十年。Jev 给它的是燃料:每一步的完整概率分布。普通 LLM 一层只给你一个「它觉得对的」答案,beam 的 K 条路径无从打分;Jev 每条边都是量化概率,几何平均、排序、剪枝全是纯代码。官方还有一句重要的:beam 里的每条路径作为并行问题一起发,多探索几条路几乎不增加墙钟延迟。
你应该看到:依然没有输出——beam_search() 定义完还没人调用,第 6 步才第一次跑它。
- 我能说出为什么用几何平均而不是连乘给路径打分
第 5 步:跑四棵官方类目树,对比 Jev 层级分类的两种走法
为什么:你的 TREE 太小,看不出差距。官方拿四棵真实类目树各跑一遍,已知答案对答案。
你应该看到(官方录制的对照结果):
| 类目树 | 版本 | 节点数 | 文档示例 | Greedy 结果 | Beam K=3 结果 |
|---|---|---|---|---|---|
| CPC 专利 | 2026.05 | 254,274 | 鸟类栖木专利摘要 | ❌ E99Z99/00(兜底类) | ✅ A01K31/12 |
| Shopify 商品 | 2026-02 | 12,378 | 猫用飘窗床商品描述 | ❌ Pet Chairs | ✅ Cat Window Beds & Perches |
| MeSH 生物医学 | 2026 | 65,373 | 克罗恩病临床摘要 | ✅ Crohn Disease | ✅ Crohn Disease |
| CookSafe 源码 | 快照 2026-08-06 | 125 | 找实验检索模块 | ✅ retrievers.py | ✅ retrievers.py |
两个错例都是同一个死法。看 Shopify 那条「猫用飘窗床」的商品描述怎么死的:
- 第 2 层,贪心在
Pet Supplies下选了Pet Beds(概率 0.53),丢掉了Cat Supplies(0.45)——就是这步 8 个百分点的差距,把整条路径带进了狗窝猫窝不分的世界,最后落到Pet Chairs - beam 保留的另一条路径走
Cat Supplies0.45 →Cat Furniture0.98 →Cat Window Beds & Perches0.92——第 3、4 层连续高概率,几何平均分反超,赢了 - CPC 同款:贪心第 1 层就被
E 固定建筑0.62 带走(正确答案A 人类必需只有 0.34),后面进E99Z 兜底类;beam 留着的 A 路径从第 2 层起 0.95、0.99、0.71、0.99 一路高歌
MeSH 和 CookSafe 两种走法都对——beam 的价值不是必胜,是修复浅层歧义;浅层清晰时它只是多花几道题的 token。
- 我能复述 Shopify 例子中贪心错在哪一层、beam 怎么救回来的
第 6 步:读懂 top/second 分离度,决定要不要转人工
为什么:拿到最终分类,还要回答「这个分类能信吗」。beam 的副产品是 separation:第一名和第二名路径分数的比值。
result = beam_search(TREE, DOCUMENT)
beam = result["beam"]
if len(beam) > 1:
separation = beam[0]["score"] / max(beam[1]["score"], EPSILON)
print(f"top/second = {separation:.2f}x")
print("leaf:", beam[0]["path"][-1] if beam[0]["path"] else "no leaf")你应该看到:top/second 比值和最终叶子各一行(换成你自己的 TREE,数值自然不同)。官方四棵树的录制数字给你校准感:CPC 1.04×、Shopify 1.17×、MeSH 1.81×、CookSafe 2.20×。接近 1× 就是歧义——CPC 那次虽然答对了,第一名和第二名几乎贴着,说明文档在两个深类目之间真模糊。官方对这个指标的定性:near 1× is ambiguous;大比值意味着清晰的分离。
它不用于剪枝——剪枝只看每条路径自己的几何平均分。separation 是给人看的:贴着 1× 的分类别直接写库,转人工或用自一致性再投一次。
- 我的代码输出了 top/second 比值;我知道 1.04× 和 2.20× 的差别在哪
📋 魔法 Prompt
本页的「Prompt」是给 API 的问题模板——抄走、替换【】占位符就能用到你自己的类目树。三件套:逐层提问、打分剪枝、单子节点。
📝 直接子类提问(Choice 版)
预期效果:把任何一个类目节点的「往哪走」变成一道 Choice,答案自带全分布,K 条路径都能拿边概率。
from typesafe_sdk import Choice
# 必填项:【】里换成你当前节点的直接子类
question, keys = child_question(("【子类A】", "【子类B】", "【子类C】"))
# child_question 的核心是:
# Choice(
# instructions="Which direct child category best matches this document?",
# criteria={"c0": "【子类A】", "c1": "【子类B】", "c2": "【子类C】"},
# )
# 输出规范:answer.choice 是最高概率子类的键;answer.probabilities 是全部子类的分布
# 约束条件:
# ✅ 只列直接子类,不要跨层塞孙类
# ✅ 键名用 c0/c1 短名,label 放进 criteria 的值
# ❌ 不要塞超过 255 个选项——Choice 硬上限🔧 路径打分与剪枝(几何平均版)
预期效果:给每条候选路径一个深度归一的分数,按分排序截断到 K 条——浅叶深叶公平比。
# 必填项:【】里换成你的 beam 宽度和深度安全阀
BEAM_WIDTH = 【3 起步,官方示例值;类目树深且浅层模糊可加宽】
MAX_DEPTH = 【12 起步,盖过你最深的路径;防爆栈】
EPSILON = 1e-9
# score = product(edge_probabilities) ** (1 / decision_count)
# 约束条件:
# ✅ 单子节点(len(probabilities)==1)不进连乘、不计决策数
# ✅ 每轮把已到叶子的路径留在 finished 里参与最终排序
# ❌ 不要用连乘打分——深路径被系统性惩罚✅ 深树数值稳定(对数空间版)
预期效果:类目树超过 10 层时,连乘浮点误差会把分数算糊;换对数空间的等价公式。
# 官方原文的替代方案:exp(mean(log(probs))) 等价于 product ** (1/decisions)
import math
def log_space_score(probs: list[float]) -> float:
return math.exp(sum(math.log(max(p, EPSILON)) for p in probs) / len(probs))
# 约束条件:
# ✅ 树深 > 10 层时换这个公式,避免精度损失
# ❌ 不要在浅树上也换——多一层换算,没必要检查点 ✅
hierarchical_classification.py能跑:beam 对我的TREE返回 K=3 条排序路径,打印 top/second 比值和最终叶子- 我能说出 beam 的三个部件:几何平均分、每轮截断到 K、finished 路径保留
- 我能复述官方 4 数据集结论:greedy 2/4、beam 4/4,Shopify 死在第 2 层 0.53 vs 0.45
- 我知道单子节点不算决策(返回 1.0、不进 decision_count)
- 我知道 separation 接近 1× 该转人工,不用于剪枝
踩坑提醒
❌ 一道题塞下整棵类目树
Choice 硬上限 255 个选项(choice.md 原话:A Choice question accepts up to 255 options)。25 万节点的树不是「多分几道题」的事,而是层级结构本身就是答案:每层一题,题数 = 深度,不 = 节点数。官方 MeSH 的 Crohn Disease 路径 6 层,beam K=3 总请求也就十几个。
❌ 用连乘而不是几何平均给路径打分
连乘天然惩罚深路径:全 0.9 的 10 层路径(0.35)输给全 0.9 的 3 层路径(0.73),明明每步一样稳。几何平均 product ** (1/decisions) 把深度归一——官方原话:The probability is length-normalized so that shallow and deep leaves are compared fairly。深树再加保险:exp(mean(log(probs))) 避免精度损失。
❌ 每层串行发请求,不做 beam 内并行
beam 的 K 条路径互相独立,executor.map 一次全发出去——这就是 Jev 并行提问那条「问题并行评估」的能力。逐路径串行等答案,K=3 的墙钟时间直接乘 3,beam 的扩展性就没了。
❌ 把 MeSH 这类 DAG 直接当树走
MeSH 一个描述符可以有多个父节点(DAG)。官方的做法:把官方 tree number 路径展开成树——一个描述符有多条 tree number,就在树里占多个位置。你的类目如果是 DAG(一个商品挂在多个类目下),先想清楚你要的是哪棵展开;直接走会在分叉处漏路径。
❌ 中文文档直接上线
官方明说英语是主要训练语言,CJK 能用但准确度较低。中文商品描述、中文邮件先拿真实数据测一轮,重点看每层概率的分离程度——浅层都贴着 0.5 的树,beam 再宽也救不回来。详见模型与价格。
进阶技巧(可选)
换打分指标。 官方给的替代指标:min(top_prob/second_top_prob)——优化「每个节点都分得很清晰」的路径,而不是「平均清晰」。浅层节点多、单个节点歧义代价大的场景值得一试。
缓存重放。 官方用 cooksafe 库的 JsonCache 装饰 choose()——答案按「state + 问题」的键存进 JSON,重跑零请求零花费。类目表天天调、文档天天加的场景,缓存能省大钱(用法见直播聊天过滤第 1 步)。
看官方图。 cookbook 页面上有四棵树的完整遍历 SVG(官方链接:CPC、Shopify、MeSH、源码)——橙色贪心路线、绿色 beam 赢家、紫色保留路径、虚线剪枝边,本页第 5 步的所有数字都能在图上对上。
类目表更新后跑回归。 官方强调的可测试性:把已知答案的文档存成测试集,类目表每次改版后重跑 beam,命中率和 separation 的变化一目了然——这比「改完 prompt 求好运」踏实得多。
本课小结
✅ Jev 层级分类流水线:类目树 → 嵌套字典 → 每层一道 Choice → K=3 并行 beam search → 几何平均分排序 → separation 判歧义。
✅ 官方实测:CPC 254,274 节点、Shopify 12,378、MeSH 65,373、CookSafe 125——greedy 2/4 命中,beam K=3 4/4;CPC 和 Shopify 都是浅层歧义(0.53 vs 0.45、0.62 vs 0.34)被 beam 的深层证据救回。
✅ 三个关键设计:单子节点不算决策(1.0 且不进 decision_count)、几何平均深度归一(连乘惩罚深路径)、EPSILON=1e-9 兜底零概率边。
✅ 这是官方点名的真二次请求场景:代码必须拿上一层的答案才能构造下一层的选项——和 skill_suggestion、autoformat 并列三个真例。
关键配置字段回顾:
| 字段 | 本页的值 | 说明 |
|---|---|---|
BEAM_WIDTH | 3 | 官方示例值;浅层歧义重可加宽 |
MAX_DEPTH | 12 | 安全阀;MeSH 最深 6 层 |
EPSILON | 1e-9 | 零概率边兜底,保护几何平均 |
MODEL | "jev-1.12" | 官方采样版本;换 jev-latest 数字有出入 |
RetryPolicy | max_retries=5, backoff 1.0→20.0 | 官方示例配置 |
| 每层问题数 | 1 | beam 的多条路径在同一轮并行发出 |
相关教程:
- 75 类行业分类——类目只有两级、75 个选项一次装得下时,用 confidence 门控替代层级遍历
- Choice 选择题——本页的地基:criteria 选项 map、probabilities 分布、255 上限
- 自一致性——separation 贴着 1× 时别硬分,多投几次看一致率
下一课预告
beam 保住了深层准确率,但有些任务浅层就两极分化——答案没把握时,与其硬选不如自动上报一级大类。下一课:75 类行业分类:置信度不够就上报大类。
你会学到:
- 一道 75 选项的 Choice 分进 SEC 全部行业组,答案自带 confidence
- confidence ≥ 0.9 报行业组、不够就查表上报所属大类——零第二次调用
- 官方 60 份年报实测:门控把可用率从 39/60 提到 48/60