层级分类
很多数据天然就是一棵树:行业分类、商品类目、图书分类法、疾病编码、网站栏目、代码仓库的目录,还有组织架构和审核策略。层级分类要做的,是从根节点一路走到正确的叶子,叶子就是最终类别。
这正适合用象信的 Choice:在每个节点上,问「下一层选哪个子类」,再沿着概率最高的子类往下走,直到叶子为止。这就是贪心搜索。
象信一次请求可以带多个问题,多次请求也可以并行发出,所以还可以同时探索几条路径,也就是束搜索。每一轮都保留得分最高的 K 条路径,它们各自的下一层问题并行发出,其余路径剪掉。路径得分取各条边概率的几何平均:
path_score = (∏ 边概率) ^ (1 / 决策次数)几何平均按路径长度做了归一化,深的叶子和浅的叶子可以公平比较。
把分类拆成一棵树来问,还有两个额外的好处:
- 可观测:能统计错误集中在哪些节点、哪一层,每个节点和每条边被走过多少次。
- 可测试:分类树改版(增删、合并类目)以后,可以单独测它对准确率的影响。
本文用的分类树是申万行业分类(2021 版):31 个一级、134 个二级、346 个三级行业。这里还有一个实际约束:一个 Choice 最多只能有 255 个选项,而 346 个三级行业放不进一个 Choice,只能按层往下走。
在 100 家 A 股上市公司上,贪心搜索的三级准确率是 56%。K=3 的束搜索提到 59%:救回 6 家,同时把 3 家原本对的弄错了。另外,束搜索给出的「第一名与第二名的得分比」本身就是一个很好的置信度信号。
方法
- 贪心搜索:每层只选概率最高的子类,其余全部丢掉。只要早期有一步走错,后面就回不来了。
- 束搜索:每层保留
K条最可能的路径,所有待扩展的节点并行提问。更深层的证据有机会纠正早期模棱两可的判断。最终取几何平均分最高的那条路径的叶子。 - 象信 Choice:每个节点就是一个
Choice问题,它返回的完整概率分布就是这个节点所有出边的权重。 - 分离度:
top_path_score / second_path_score,即最优路径与次优路径的得分比。接近 1× 说明两条路难分高下,数值越大越明确。束搜索不用它来剪枝,但它可以用来决定要不要转人工。
环境准备
pip install xiangxin-sdk pandas
# 准备数据还需要:
pip install akshare pypdf
export XIANGXIN_API_KEY="sk-xx-..."每个「公司 × 节点」的问题只问一次,结果缓存在 results/cache.json。贪心和束搜索会共用这份缓存:两者走到同一个节点时,不重复调用。下文数字来自 xiangxin-1.0.0,运行日期 2026-09-25。
数据
- 分类树:
data/taxonomy_sw.csv,来自巨潮资讯公开的申万行业分类(分类标准编码 008003),只保留未终止的类目:31 个一级、134 个二级、346 个三级。 - 文档与标签:与基于置信度的分类共用同一份数据。
prepare_data.py从沪深 A 股里固定种子随机抽样,下载每家公司的《2025 年年度报告摘要》,截取「报告期主要业务或产品简介」一节,并删掉所有自报行业分类的句子(例如「公司属于……(C15)」),防止答案泄露。标签是巨潮资讯登记的该公司当前申万三级行业,也就是它的真实归类,不是我们标注的。本文取前 100 家,每家送入前 1,500 字。
样例(浙江华远,标注为 汽车 / 汽车零部件 / 其他汽车零部件):
(一)主营业务情况公司是一家长期专注于定制化汽车系统连接件的研发、生产及销售的高新技术企业,
公司的主要产品为异型紧固件和座椅锁,广泛应用于汽车车身底盘及动力系统、汽车安全系统、……每个节点一个 Choice
一个节点的直接下级就是这道题的选项。选项的键直接用类目名,因为键名本身也会被模型读到。非叶子类目的描述里,列出它下面的类目,例如「电子」的描述是「包括:半导体、元件、光学光电子、其他电子Ⅱ、消费电子、电子化学品Ⅱ」,这样模型在高层就能看到低层的线索。只有一个子类时不用问。
from xiangxin import Choice, RetryPolicy, XiangxinClient
client = XiangxinClient(retry=RetryPolicy(max_retries=12, backoff_max=30.0))
def child_question(node: str) -> tuple[Choice, dict[str, str]]:
keys, criteria = {}, {}
for c in children(node):
below = [NAME[g] for g in children(c)][:10]
criteria[NAME[c]] = ("包括:" + "、".join(below)) if below else None
keys[NAME[c]] = c
question = Choice(
instructions="按申万行业分类,这家公司的主营业务最符合下面哪一个类目?",
criteria=criteria,
)
return question, keys
def choose(company: dict, node: str) -> dict[str, float]:
"""问一次「下一层选哪个」,返回 {子节点编码: 概率}。"""
kids = children(node)
if len(kids) == 1:
return {kids[0]: 1.0}
question, keys = child_question(node)
resp = client.system_one(
state=company["text"][:1500], questions={"child": question}, model="xiangxin-latest"
)
return {keys[k]: v for k, v in resp.answers["child"].probabilities.items()}贪心搜索与束搜索
from concurrent.futures import ThreadPoolExecutor
BEAM_WIDTH, EPSILON = 3, 1e-9
def extend(candidate: dict, label: str, probabilities: dict[str, float]) -> dict:
"""在路径末尾接上一条边,重新计算几何平均分。"""
is_decision = len(probabilities) > 1
product = candidate["product"] * (max(probabilities[label], EPSILON) if is_decision else 1.0)
decisions = candidate["decisions"] + is_decision
return {
"path": candidate["path"] + (label,),
"product": product,
"decisions": decisions,
"score": product ** (1 / decisions) if decisions else 1.0,
}
def beam_search(company: dict) -> list[dict]:
beam = [{"path": (), "product": 1.0, "decisions": 0, "score": 1.0}]
node = lambda c: c["path"][-1] if c["path"] else ROOT
while any(children(node(c)) for c in beam):
expandable = [c for c in beam if children(node(c))]
finished = [c for c in beam if not children(node(c))]
with ThreadPoolExecutor(max_workers=BEAM_WIDTH) as ex: # K 个节点并行提问
dists = list(ex.map(lambda c: choose(company, node(c)), expandable))
expanded = [extend(c, label, probs) for c, probs in zip(expandable, dists) for label in probs]
beam = sorted(finished + expanded, key=lambda c: -c["score"])[:BEAM_WIDTH]
return beam
def greedy_search(company: dict) -> dict:
cand, node = {"path": (), "product": 1.0, "decisions": 0, "score": 1.0}, ROOT
while children(node):
probs = choose(company, node)
node = max(probs, key=probs.get)
cand = extend(cand, node, probs)
return cand申万分类只有三层,所以束搜索每家公司最多问 1 + 3 + 3 = 7 次。三层之间有先后依赖,但同一层里的 K 次请求是并行发出的,总耗时约等于三次串行请求。
结果
对 100 家公司分别跑贪心和束搜索,在每一层上对照标注路径计分:
| 层级 | 贪心 | 束搜索 K=3 |
|---|---|---|
| 一级(31 类) | 81/100 | 82/100 |
| 二级(134 类) | 67/100 | 70/100 |
| 三级(346 类) | 56/100 | 59/100 |
束搜索救回 6 家,弄错 3 家(贪心对、束搜索错)
束搜索第一次走错的层级: {1: 18, 2: 12, 3: 11}
共 638 次象信调用,输入 611,859 token,约 ¥0.0257;单次模型耗时中位数 1526 ms束搜索救回的 6 家:
| 公司 | 贪心 | 束搜索(= 标注) |
|---|---|---|
| 信维通信 | 通信 / 通信设备 / 通信终端及配件 | 电子 / 消费电子 / 消费电子零部件及组装 |
| 外高桥 | 房地产 / 房地产服务 / 房地产综合服务 | 房地产 / 房地产开发 / 产业地产 |
| 博腾股份 | 医药生物 / 化学制药 / 原料药 | 医药生物 / 医疗服务 / 医疗研发外包 |
| 华海清科 | 机械设备 / 自动化设备 / 其他自动化设备 | 电子 / 半导体 / 半导体设备 |
| 欣锐科技 | 电力设备 / 其他电源设备Ⅱ / 其他电源设备Ⅲ | 汽车 / 汽车零部件 / 汽车电子电气系统 |
| 北玻股份 | 机械设备 / 专用设备 / 其他专用设备 | 建筑材料 / 玻璃玻纤 / 玻璃制造 |
束搜索弄错的 3 家,第一步都选了一个说得通的相邻门类,而且得分都只比第二名高一点(分离度 1.04–1.06):
| 公司 | 标注(= 贪心) | 束搜索 |
|---|---|---|
| 保立佳 | 基础化工 / 化学制品 / 涂料油墨 | 建筑材料 / 装修建材 / 涂料 |
| 南风股份 | 机械设备 / 专用设备 / 其他专用设备 | 环保 / 环境治理 / 综合环境治理 |
| 中国建筑 | 建筑装饰 / 房屋建设Ⅱ / 房屋建设Ⅲ | 建筑装饰 / 基础建设 / 基建市政工程 |
下面两张图画出了实际走过的节点。橙色是贪心的路线,绿色是束搜索胜出的路线,虚线框是被剪掉或没有展开的分支,✓ 标出标注路径,边上的数字是该节点 Choice 给出的概率。
信维通信:束搜索纠正了第一步
第一层「通信」得到 0.44,「电子」得到 0.35,贪心走进了通信。到了第三层,「通信终端及配件」只有 0.37,和另外两个子类几乎打平,所以整条路径的几何平均只有 (0.44 × 0.93 × 0.37)^(1/3) ≈ 0.53。电子这条路在后两层都很明确(0.68、0.93),几何平均是 0.60,于是反超。这就是束搜索的用处:更深层的证据可以纠正早期模棱两可的判断。
浙江华远:两种方法一致
分离度可以当置信度用
把束搜索第一名与第二名的得分比分成三档:
| 分离度 | 公司数 | 三级正确 |
|---|---|---|
| < 1.5× | 69 | 32(46%) |
| 1.5×–3× | 28 | 24(86%) |
| ≥ 3× | 3 | 3(100%) |
分离度 ≥ 1.5× 的 31 家里有 27 家分对,其余 69 家只对了不到一半。所以可以这样用:分离度高的自动通过,分离度低的交给人工,或者退回到更高一层报告。后一种做法见基于置信度的分类。
讨论
- 这次的结果不如 TypeSafe 原文漂亮。 原文用 4 棵树、每棵 1 个手写示例,束搜索 4/4,贪心 2/4。我们用的是 100 家真实公司和官方登记的标签,三级准确率 59%,束搜索只比贪心多对 3 家(救回 6 家、弄错 3 家)。原因有三个:
- 标签本身有歧义。 申万按收入结构归类,年报文字却常常先讲技术和应用领域。比如一家做碳纤维的公司,按材料可以归「化工」,按客户可以归「国防军工」,申万选了后者。
- 第一层错误最多。 100 家里,束搜索有 18 家在一级就走错了。K=3 只能保住前三名,真正的类目如果在第一层就掉到第四名以后,就再也回不来。
- 象信一号 1.0 是 9B 模型。 它在几十个相近选项之间的区分力有限,概率会摊开,见已知短板。
- 怎样做得更好。
- 加大 K。代价是每层多几次并行请求,延迟几乎不变。
- 在第一层同时问两种选项顺序,取平均,见首位偏好。
- 把非叶子类目的描述写得更有区分度,例如写明「按收入最大的业务归类」。
- 用分离度做门控:低于 1.5× 的转人工。
- 什么时候不该用层级。 如果叶子不超过 255 个、彼此差别又明显,一个扁平的 Choice 就够了,还只需要一次请求。层级适合叶子太多、放不进一个问题的情况,或者你需要逐层观察和调试的时候。
完整代码
完整代码、数据与运行结果:https://github.com/xiangxinai/xiangxin-cookbooks/tree/main/hierarchical_classification
prepare_data.py:抽样、下载年报摘要、截取与去泄露(与「基于置信度的分类」相同);main.py:分类树加载、贪心 / 束搜索、计分与 SVG 遍历图;results/:cache.json(638 个「公司 × 节点」的概率分布)、summary.json(每家公司的三条路径与分数)、run.log、tree_1.svg、tree_2.svg。

