每家向 SEC 提交年报的公司都会在报告中描述自己的业务。我们把这些描述归入标准行业分类(Standard Industrial Classification):75 个行业组,每份文档一个 Choice 问题。
大多数报备文件很容易分类。一家地区银行就是一家地区银行。有些则不然:刚卖掉两个业务部门之一的公司,或者描述自己计划进入而非正在经营的业务的初创阶段公司。无论如何模型都必须选一个组,而困难案例的答案看上去和容易案例的答案毫无差别。区分困难案例和容易案例通常正是成本所在:第二个模型、额外调用、人工审核。
一个 Choice 本身就能告诉你。它在返回获胜选项的同时还返回 confidence:当几乎所有概率都落在一个选项上时它很高,当概率分散到多个选项上时它很低。这一个数字就能把可信的答案和不可信的答案区分开。
拿到一个不可信的答案后该怎么做,取决于你的标签。SIC 标签构成一个层级结构:行业组向上归入更宽泛的部门。这让另一种响应几乎免费。当模型对行业组不确定时,报告该组所属的部门。宽泛标签可以由细分标签直接推出,因此不需要第二次调用。
在 60 份报备文件上,0.9 的置信度阈值把它们对半分开。自信的那一半有 90% 的次数是正确的;另一半是 40%。上提一级报告后,这 40% 变成了 70%。我们最终会得到一个 classify() 函数,每份文档一次请求,返回一个标签以及它的具体程度。
环境准备
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。
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 个四位代码,每个附带一个行业标题。这些数字构成一个层级。前两位是大类(major group)(这里有 75 个,从 01 农业生产到 99 不可分类),固定的大类区间组成十个部门(division),这是 SIC 最宽泛的划分。
两个层级都从这一个文件中得出,无需任何模型参与:按前两位数字给代码分组,再把这些数字映射到部门。
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])} ..."
)
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 清单里带有统称标题,其余的没有。因此每个组用其内部的行业来描述,阅读报备文件的人要匹配的本来也是这些。
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]}")
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 上查询的受理编号。
在看任何准确率数字之前,这个标签的来源很重要。它是自报的:准备报备文件的人一次性选定了它,而当公司卖掉代码所命名的业务却保留代码时,它就过时了。这 60 份已被筛选为其自身文本支持所带代码的报备文件,因此这里的数字衡量的是这套方法,而不是 EDGAR 元数据的现状。
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']]}")
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。
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 及以上时,答案作为行业组报告;低于该值时,同一个答案作为该组所在的部门报告。
每份报备文件仍然会带回一个可用的标签。模型无法自信分类的那份会上提一级返回,而不是被丢弃或转交。如果部门对你的应用来说太粗而无法据此行动,这个分支就是你把它交给人工的地方。
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)
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 就报告部门。
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"
)
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%。
图表把两种策略并排放置,按模型是否自信分开。
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)
在 Playground 中打开
这个分享链接包含一份报备文件和这个 75 选项的问题,无需编写任何代码即可看到它产生的分布和置信度。
playground_link = make_playground_link(
example["text"], questions(), models=[TYPESAFE_MODEL]
)
display(
Markdown(
f"🔗 [Open the filing + question in the TypeSafe playground]({playground_link})"
)
)