Классификация с помощью уверенности
Классифицирует годовые отчёты SEC по 75 отраслевым группам, по одному Choice на каждый, а затем читает собственную уверенность ответа, чтобы решить, указать эту группу или более широкий раздел над ней.
Каждая компания, подающая годовой отчёт в SEC, описывает в нём свой бизнес. Мы классифицируем эти описания по Стандартной отраслевой классификации: 75 отраслевых групп, один вопрос Choice на документ.
Большинство отчётов просты. Региональный банк — это региональный банк. Но некоторые нет: компания, только что продавшая один из двух своих сегментов, или стартап, описывающий бизнес, в который он только планирует войти, а не тот, который ведёт. Модель должна выбрать группу в любом случае, и ответ для сложного случая внешне не отличается от ответа для простого. Различение сложных и простых случаев обычно и съедает бюджет: вторая модель, дополнительные вызовы, проверка человеком.
Choice уже сам всё сообщает. Вместе с победившим вариантом он возвращает confidence — высокую, когда почти вся вероятность пришлась на один вариант, и низкую, когда она разошлась по нескольким. Это одно число отделяет ответы, которым можно доверять, от тех, которым нельзя.
Что делать с ответом, которому нет доверия, зависит от ваших меток. Метки SIC образуют иерархию: отраслевые группы сводятся в более широкие разделы. Это делает один ответ почти бесплатным. Когда модель не уверена в группе, укажите раздел, к которому она относится. Широкая метка следует из узкой, так что второй вызов не нужен.
На 60 отчётах порог уверенности 0.9 делит их пополам. Уверенная половина верна в 90% случаев; другая половина — в 40%. Указанная на уровень выше, эта половина превращается в 70%. В итоге у нас есть функция classify(), которая возвращает метку и то, насколько она конкретна, при одном запросе на документ.
flowchart LR
doc["Item 1 'Business'<br/>from one 10-K"]
subgraph request["one request"]
q["Choice<br/>75 industry groups"]
end
sure{"confidence<br/>≥ 0.9?"}
grp["report the industry group<br/><i>e.g. 28</i>"]
div["report its division<br/><i>e.g. 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
Установка
pip install ipython matplotlib 'cooksafe>=0.2.0,<0.3.0'
затем задайте TYPESAFE_API_KEY. Каждый вызов API кэшируется в json_cache.json, а он поставляется вместе с cookbook, поэтому повторный рендеринг воспроизводит опубликованные числа без обращения к API. Удалите этот файл, чтобы перезапустить всё вживую.
Числа ниже получены на jev-1.12 2026-08-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 четырёхзначных кода, каждый с названием отрасли. Цифры образуют иерархию. Первые две — это основная группа (их здесь 75, от 01 сельскохозяйственное производство до 99 неклассифицируемое), а фиксированные диапазоны основных групп составляют десять разделов, самое широкое деление в 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 нужно чем-то описать каждый вариант, а собственного названия у группы есть не всегда: 42 из 75 несут зонтичное название в списке 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, выбранный его подателем, плюс accession number, чтобы найти его в 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})"
)
)
Откройте отчёт и вопрос в playground TypeSafe →