TS TypeSafe 文档中文版 原文 ↗

使用置信度进行分类

把 SEC 年度报告分类到 75 个行业组,每份文档用一个 Choice,然后读取答案自身的置信度,以决定是报告该组,还是报告它上一层更宽泛的门类。

每一家向 SEC 提交年度报告的公司都会在其中描述自己的业务。我们把这些描述归入标准行业分类(Standard Industrial Classification):75 个行业组,每份文档一个 Choice 问题。

大多数申报文件都很简单。一家地区性银行就是一家地区性银行。有些则不然:一家刚刚卖掉两个业务分部之一的公司,或者一家初创公司,它描述的是自己计划进入的业务,而不是正在运营的业务。无论如何,模型都必须选出一个组,而困难情形的答案与简单情形的答案看起来毫无区别。区分困难情形与简单情形通常正是成本所在:第二个模型、额外的调用、人工复核。

Choice 本身就会告诉你。除了胜出的选项,它还返回 confidence:当几乎所有概率都落在一个选项上时它很高,当概率分散在多个选项上时它很低。这一个数字就把你能信任的答案与不能信任的答案区分开来。

如何处理不可信的答案取决于你的标签。SIC 标签构成一个层级:行业组向上归入更宽泛的门类。这让一次响应几乎不额外花费。当模型对组没有把握时,就报告它所属的门类。宽泛标签可以从窄标签推出,因此不需要第二次调用。

在 60 份申报文件上,0.9 的置信度阈值把它们分成两半。有把握的那一半有 90% 的时候是对的;另一半则是 40%。上移一层来报告,那 40% 就变成 70%。最后我们得到一个 classify() 函数,它返回一个标签以及该标签的具体程度,每份文档只需一次请求。

flowchart LR doc["来自一份 10-K 的<br/>Item 1 'Business'"] subgraph request["一次请求"] q["Choice<br/>75 个行业组"] end sure{"confidence<br/>&ge; 0.9?"} grp["报告行业组<br/><i>例如 28</i>"] div["报告其门类<br/><i>例如 manufacturing</i>"] doc --> request --> sure %% both branches leave the test, so they share a rank and stack on their own sure -- "yes" --> grp sure -- "no" --> div

环境准备

bash
pip install ipython matplotlib "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
 

然后设置 TYPESAFE_API_KEY。每次 API 调用都会缓存到 json_cache.json,该文件随实践手册一同提供,因此重新渲染会重放已发布的数字,而无需调用 API。删除该文件即可全部实时重跑。

下面的数字来自 2026-08-12 的 jev-1.12。

python
import json
from collections import defaultdict
from pathlib import Path
 
import matplotlib
import matplotlib.pyplot as plt
from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, TypeSafeClient
 
matplotlib.use("Agg")  # headless render
 
import os  # noqa: E402
 
TYPESAFE_MODEL = "jev-1.12"
CONFIDENT = 0.9  # above this the group is reported; below it, the division
 
client = TypeSafeClient(
    api_key=os.environ.get(
        "TYPESAFE_API_KEY", "cache-only"
    ),  # keyless kernels replay the cache
    base_url=os.environ.get("TYPESAFE_ENDPOINT"),
    timeout=120.0,
)
json_cache = JsonCache(Path("json_cache.json"))
 

构建分类体系的两个层级

sic_codes.tsv 是 SEC 发布、供申报人从中自行选取代码的行业列表,抓取于 2026-08-10:444 个四位代码,每个都带有一个行业名称。这些数字构成一个层级。前两位是主要组(这里共 75 个,从 01 agricultural production 到 99 non-classifiable),而主要组的固定区间构成了十个门类,即 SIC 最宽泛的划分。

两个层级都出自那一个文件,且不涉及模型:按前两位数字把代码分组,再把这些数字映射到一个门类。

python
DIVISIONS = [
    (1, 9, "agriculture, forestry and fishing"),
    (10, 14, "mining"),
    (15, 17, "construction"),
    (20, 39, "manufacturing"),
    (40, 49, "transportation, communications and utilities"),
    (50, 51, "wholesale trade"),
    (52, 59, "retail trade"),
    (60, 67, "finance, insurance and real estate"),
    (70, 89, "services"),
    (91, 99, "public administration"),
]
 
INDUSTRIES: dict[str, str] = {}
for line in Path("sic_codes.tsv").read_text().splitlines()[1:]:
    code, _office, title = line.split("\t")
    INDUSTRIES[code] = title.lower()
 
GROUPS: dict[str, list[str]] = defaultdict(list)
for code in sorted(INDUSTRIES):
    GROUPS[code[:2]].append(code)
 
 
def division(group: str) -> str:
    number = int(group)
    return next(name for low, high, name in DIVISIONS if low <= number <= high)
 
 
print(
    f"{len(INDUSTRIES)} industries -> {len(GROUPS)} major groups -> {len(DIVISIONS)} divisions"
)
print(
    f"  group 35 = {division('35')} / {', '.join(INDUSTRIES[c] for c in GROUPS['35'][:3])} ..."
)
 
text
444 industries -> 75 major groups -> 10 divisions
  group 35 = manufacturing / engines & turbines, farm machinery & equipment, lawn & garden tractors & home lawn & gardens equip ...
 

一个 Choice 问题需要有东西来描述每个选项,而一个组自身的名称并不总是存在:75 个组中有 42 个在 SEC 的列表里带有统括性名称,其余的则没有。因此每个组由它内部的行业来描述,而这本来也是阅读申报文件的人会去对照的东西。

python
MAX_NAMED = (
    8  # industries listed per group; enough to characterise it without a wall of text
)
 
 
def describe(group: str) -> str:
    umbrella = INDUSTRIES.get(f"{group}00")
    inside = [INDUSTRIES[c] for c in GROUPS[group] if c != f"{group}00"][:MAX_NAMED]
    listed = "; ".join(inside)
    return (
        f"{umbrella} — includes: {listed}"
        if umbrella and listed
        else (umbrella or listed)
    )
 
 
print(f"group 20: {describe('20')[:150]}")
print(f"\ngroup 65: {describe('65')[:150]}")
 
text
group 20: food and kindred products — includes: meat packing plants; sausages & other prepared meat products; poultry slaughtering and processing; dairy product
 
group 65: real estate — includes: real estate operators (no developers) & lessors; operators of nonresidential buildings; operators of apartment buildings; less
 

申报文件

filings.jsonl 包含 60 份年度报告(10-K),每份都裁剪到 Item 1 "Business",即公司描述自身业务的那一节,也是行业代码唯一涉及的部分。它们跨越 1993–2024 年,长度为 700 到 2,200 词。每一份都带有其申报人选择的 SIC 代码,以及用于在 EDGAR 上查询它的 accession number。

那个标签来自哪里,在任何准确率数字之前都很重要。它是自报的:编制申报文件的人选了一次,而当一家公司卖掉代码所指的业务却保留代码时,它就会过时。这 60 份被筛选为自身文本支持其所带代码的申报文件,因此这里的数字衡量的是这套做法,而不是 EDGAR 元数据的状态。

python
FILINGS = [json.loads(line) for line in Path("filings.jsonl").read_text().splitlines()]
example = FILINGS[7]
print(
    f"{len(FILINGS)} filings, {sum(f['words'] for f in FILINGS) // len(FILINGS)} words on average"
)
print(f"\n{example['id']} (filed {example['year']}, accession {example['accession']}):")
print(f"  {example['text'][:230]}...")
print(f"  filer's code: {example['sic']} {INDUSTRIES[example['sic']]}")
 
text
60 filings, 1438 words on average
 
1389870_2008 (filed 2008, accession 0001079974-09-000155):
  Item 1. DESCRIPTION OF BUSINESS. NARRATIVE DESCRIPTION OF THE BUSINESS Across America Financial Services, Inc. is a corporation which was formed under the laws of the State of Colorado on December 1, 2005. Until March 23, 2007, we...
  filer's code: 6163 loan brokers
 

提一个 Choice 问题,并读取置信度

一个 Choice 问题,其选项就是这 75 个组。整个分类体系能装进一次请求:Choice 在约 240 个选项以内都能可靠工作,而 75 远在此之内。

返回的答案带有 choice,即胜出的组;probabilities,即 75 个组各自占的权重;以及 confidence,它说明这一分布有多集中。这套做法读取的是 confidence,而不是胜出者自身的概率。胜出者为 0.45 而第二名是 0.44,与胜出者为 0.45 而其余权重稀疏散落,是两种不同的情形,而 confidence 正是把它们区分开的东西。

python
QUESTION = (
    "Which broad industry does this company operate in? Judge the company's own operations "
    "as this filing describes them."
)
 
 
def questions() -> dict:
    return {
        "group": Choice(
            instructions=QUESTION,
            criteria={group: describe(group) for group in sorted(GROUPS)},
        )
    }
 
 
@json_cache
def ask(filing_id: str, text: str) -> dict:
    response = client.system_one(
        state=text, questions=questions(), model=TYPESAFE_MODEL
    )
    answer = response.answers["group"]
    return {
        "group": answer.choice,
        "confidence": answer.confidence,
        "probabilities": dict(answer.probabilities),
    }
 

有把握时返回组,没把握时返回其门类

下面这四行就是整套做法。置信度达到 0.9 及以上时,答案作为行业组报告;低于该值时,同一个答案作为该组所属的门类报告。

每份申报文件仍然会带回一个可用的标签。模型无法有把握地分类的那一份,会以向上一层的标签返回,而不是被丢弃或转交出去。如果某个门类对你的应用来说过于粗糙、无法据此行动,这个分支就是把它交给人工的地方。

python
def classify(filing: dict) -> dict:
    answer = ask(filing["id"], filing["text"])
    sure = answer["confidence"] >= CONFIDENT
    return {
        "level": "group" if sure else "division",
        "label": answer["group"] if sure else division(answer["group"]),
        "confidence": answer["confidence"],
        "group": answer["group"],
    }
 
 
def show(filing: dict) -> None:
    result = classify(filing)
    named = describe(result["group"]).split(" — ")[0][:46]
    print(
        f"  {filing['id']:>13}  conf {result['confidence']:.2f}  -> {result['level']:<8} "
        f"{result['label']:<14} (group {result['group']}: {named})"
    )
 
 
print("three filings the model was sure about:")
for f in sorted(FILINGS, key=lambda f: -ask(f["id"], f["text"])["confidence"])[:3]:
    show(f)
print("\nthree it was not:")
for f in sorted(FILINGS, key=lambda f: ask(f["id"], f["text"])["confidence"])[:3]:
    show(f)
 
text
three filings the model was sure about:
    310158_1996  conf 1.00  -> group    28             (group 28: chemicals & allied products)
     33416_1998  conf 1.00  -> group    63             (group 63: life insurance; accident & health insurance; h)
    352541_1996  conf 1.00  -> group    49             (group 49: electric, gas & sanitary services)
 
three it was not:
   1372167_2013  conf 0.22  -> division manufacturing  (group 38: search, detection, navagation, guidance, aeron)
   1398633_2009  conf 0.23  -> division wholesale trade (group 50: wholesale-durable goods)
     46653_1999  conf 0.29  -> division services       (group 87: services-engineering, accounting, research, ma)
 

这些置信度与每份申报文件的分类难度相符。1.00 的那三份分别是一家制药商、一家人寿保险公司和一家公用事业公司;三家在纸面上都是控股公司,但每一家都有一个申报文件直接点明的主导业务。排在末尾的三份更难,原因可以在文本中读到。有两家是发展阶段公司,描述的是它们打算开展的业务(Nevaeh "intends to operate as a software developer",Barricode 则 "organized to enter into the computer security software industry"),而第三家有两个业务分部,并在申报前几周卖掉了其中之一。这三份返回的是门类而不是组。

classify() 就是整套做法。把 ask() 指向你自己的文档,并为自己的分类体系重写 describe(),其余部分都可以照搬。

更宽泛的答案带来了什么

全部 60 份申报文件,按每个申报人所选的代码评分,在两种策略下进行:每次都说一个组,或者只要置信度落在 0.9 以下就报告门类。

python
def correct(filing: dict, result: dict) -> bool:
    gold_group = filing["sic"][:2]
    if result["level"] == "group":
        return result["label"] == gold_group
    return result["label"] == division(gold_group)
 
 
results = [(f, classify(f)) for f in FILINGS]
sure = [(f, r) for f, r in results if r["level"] == "group"]
unsure = [(f, r) for f, r in results if r["level"] == "division"]
 
forced = sum(r["group"] == f["sic"][:2] for f, r in results)
broadened = sum(correct(f, r) for f, r in results)
 
print(f"forced to name a group every time      {forced}/{len(results)} right")
print(
    f"  of those, the {len(sure)} it was sure about  "
    f"{sum(r['group'] == f['sic'][:2] for f, r in sure)}/{len(sure)} right"
)
print(
    f"  and the {len(unsure)} it was not           "
    f"{sum(r['group'] == f['sic'][:2] for f, r in unsure)}/{len(unsure)} right"
)
print(
    f"\nletting it answer coarsely when unsure  {broadened}/{len(results)} useful answers"
)
 
text
forced to name a group every time      39/60 right
  of those, the 30 it was sure about  27/30 right
  and the 30 it was not           12/30 right
 
letting it answer coarsely when unsure  48/60 useful answers
 

在模型有把握的地方,它说出的组十次里有九次是对的。在它没有把握的地方,说出一个组的错误多于正确,正确率为 40%。把这些相同的答案按门类来报告,则提升到 70%。

这张图把两种策略并排展示,并按模型是否有把握来拆分。

python
labels = ["sure\n(group reported)", "unsure\n(division reported)"]
forced_split = [
    sum(r["group"] == f["sic"][:2] for f, r in sure) / len(sure),
    sum(r["group"] == f["sic"][:2] for f, r in unsure) / len(unsure),
]
broad_split = [
    sum(correct(f, r) for f, r in sure) / len(sure),
    sum(correct(f, r) for f, r in unsure) / len(unsure),
]
 
fig, ax = plt.subplots(figsize=(7, 3.6))
x = range(len(labels))
ax.bar(
    [i - 0.19 for i in x],
    forced_split,
    0.38,
    label="always name a group",
    color="#c8ccd4",
)
ax.bar(
    [i + 0.19 for i in x],
    broad_split,
    0.38,
    label="answer broadly when unsure",
    color="#3b6ea5",
)
for i, (a, b) in enumerate(zip(forced_split, broad_split)):
    ax.text(i - 0.19, a + 0.02, f"{a:.0%}", ha="center", fontsize=9)
    ax.text(i + 0.19, b + 0.02, f"{b:.0%}", ha="center", fontsize=9)
ax.set_xticks(list(x))
ax.set_xticklabels(
    [f"{lab}\nn={n}" for lab, n in zip(labels, [len(sure), len(unsure)])]
)
ax.set_ylabel("labels that are right")
ax.set_ylim(0, 1.12)
ax.set_title("Where the broader answer helps: the filings it was unsure about")
ax.legend(frameon=False, loc="upper right")
ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout()
display(fig)
 
output

在 playground 中打开

这个分享链接包含一份申报文件和那个 75 个选项的问题,因此你无需写任何代码,就能看到它产生的分布和置信度。

python
playground_link = make_playground_link(
    example["text"], questions(), models=[TYPESAFE_MODEL]
)
display(
    Markdown(
        f"🔗 [Open the filing + question in the TypeSafe playground]({playground_link})"
    )
)
 

在 TypeSafe playground 中打开该申报文件 + 问题 →