Документация

Классификация с помощью уверенности

Классифицирует годовые отчёты 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/>&ge; 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 →