Dokumentation

Klassifizierung mit Konfidenz

Klassifiziere SEC-Jahresberichte mit je einem Choice in 75 Industriegruppen und lies dann die Konfidenz der Antwort ab, um zu entscheiden, ob du die Gruppe oder die übergeordnete Division meldest.

Jedes Unternehmen, das bei der SEC einen Jahresbericht einreicht, beschreibt darin sein eigenes Geschäft. Wir klassifizieren diese Beschreibungen unter der Standard Industrial Classification: 75 Industriegruppen, eine Choice-Frage pro Dokument.

Die meisten Einreichungen sind einfach. Eine Regionalbank ist eine Regionalbank. Manche sind es nicht: ein Unternehmen, das gerade eines seiner zwei Segmente verkauft hat, oder ein Startup, das ein Geschäft beschreibt, in das es eintreten will, statt eines, das es betreibt. Das Modell muss trotzdem eine Gruppe wählen, und die Antwort für einen schwierigen Fall sieht nicht anders aus als die für einen einfachen. Schwierige von einfachen Fällen zu unterscheiden ist normalerweise der Punkt, an dem die Kosten anfallen: ein zweites Modell, zusätzliche Aufrufe, menschliche Überprüfung.

Ein Choice sagt es dir bereits. Zusammen mit der gewinnenden Option gibt es confidence zurück, hoch, wenn fast die ganze Wahrscheinlichkeit auf einer Option landete, und niedrig, wenn sie sich über mehrere verteilte. Diese eine Zahl trennt die Antworten, denen du trauen kannst, von denen, denen du nicht trauen kannst.

Was mit einer nicht vertrauenswürdigen Antwort zu tun ist, hängt von deinen Labels ab. SIC-Labels bilden eine Hierarchie: Industriegruppen rollen in breitere Divisionen auf. Das macht eine Antwort nahezu kostenlos. Wenn das Modell bei der Gruppe unsicher ist, melde die Division, zu der sie gehört. Das breite Label folgt aus dem engen, es gibt also keinen zweiten Aufruf.

Über 60 Einreichungen teilt eine Konfidenz-Schwelle von 0.9 sie in zwei Hälften. Die sichere Hälfte hat in 90% der Fälle recht; die andere in 40%. Eine Ebene höher gemeldet werden aus diesen 40% 70%. Wir enden mit einer Funktion classify(), die ein Label plus dessen Spezifität zurückgibt, mit einer Anfrage pro Dokument.

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

Einrichtung

pip install ipython matplotlib 'cooksafe>=0.2.0,<0.3.0'

setze dann TYPESAFE_API_KEY. Jeder API-Aufruf wird in json_cache.json zwischengespeichert, das mit dem Cookbook ausgeliefert wird, sodass ein erneutes Rendern die veröffentlichten Zahlen ohne API-Aufrufe wiedergibt. Lösche diese Datei, um alles live neu auszuführen.

Die Zahlen unten stammen aus jev-1.12 vom 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"))

Die zwei Ebenen der Taxonomie aufbauen

sic_codes.tsv ist die Branchenliste, die die SEC für Einreicher veröffentlicht, damit sie ihren eigenen Code wählen, abgerufen am 2026-08-10: 444 vierstellige Codes, jeder mit einem Branchentitel. Die Ziffern sind eine Hierarchie. Die ersten beiden sind die major group (hier 75, von 01 landwirtschaftliche Produktion bis 99 nicht klassifizierbar), und feste Bereiche von Hauptgruppen bilden die zehn Divisionen, die breiteste Aufteilung, die SIC hat.

Beide Ebenen kommen aus dieser einen Datei, ohne dass ein Modell beteiligt ist: gruppiere die Codes nach ihren ersten zwei Ziffern und ordne diese Ziffern dann einer Division zu.

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 ...

Eine Choice-Frage braucht etwas, das jede Option beschreibt, und der eigene Name einer Gruppe ist nicht immer vorhanden: 42 der 75 tragen einen Sammeltitel in der Liste der SEC, und die übrigen tragen keinen. Also wird jede Gruppe durch die Branchen in ihr beschrieben, wogegen jemand, der die Einreichung liest, ohnehin abgleichen würde.

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

Die Berichte

filings.jsonl enthält 60 Jahresberichte (10-K), jeder auf Item 1 “Business” gekürzt, den Abschnitt, in dem ein Unternehmen beschreibt, was es tut, der einzige Teil, um den es bei einem Branchencode geht. Sie umfassen 1993–2024 und reichen von 700 bis 2,200 Wörtern. Jeder trägt den SIC-Code, den sein Einreicher gewählt hat, plus die Accession-Nummer, um ihn bei EDGAR nachzuschlagen.

Woher dieses Label kommt, ist vor jeder Genauigkeitszahl wichtig. Es ist selbst gemeldet: Wer die Einreichung vorbereitete, hat es einmal gewählt, und es veraltet, wenn ein Unternehmen das Geschäft verkauft, das der Code benennt, und den Code behält. Diese 60 wurden auf Einreichungen gefiltert, deren eigener Text den Code stützt, den sie tragen, sodass die Zahlen hier das Rezept messen und nicht den Zustand der Metadaten von 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

Eine Choice-Frage stellen und die Konfidenz lesen

Eine Choice-Frage, deren Optionen die 75 Gruppen sind. Die ganze Taxonomie passt in eine Anfrage: Ein Choice funktioniert zuverlässig bis etwa 240 Optionen, und 75 liegt weit darunter.

Die Antwort kommt mit choice, der gewinnenden Gruppe; probabilities, dem Gewicht auf jeder der 75; und confidence, das sagt, wie konzentriert diese Verteilung war. Das Rezept liest confidence statt der eigenen Wahrscheinlichkeit des Gewinners. Ein Gewinner bei 0.45 mit einem Zweitplatzierten bei 0.44 und ein Gewinner bei 0.45, dessen restliches Gewicht dünn gestreut ist, sind verschiedene Situationen, und confidence ist es, was sie trennt.

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),
    }

Die Gruppe zurückgeben, wenn sicher, sonst ihre Division

Die vier Zeilen unten sind das ganze Rezept. Bei 0.9 Konfidenz oder darüber wird die Antwort als Industriegruppe gemeldet; darunter wird dieselbe Antwort als die Division gemeldet, in der die Gruppe sitzt.

Jede Einreichung kommt weiterhin mit einem brauchbaren Label zurück. Eine, die das Modell nicht sicher klassifizieren konnte, kommt eine Ebene höher zurück, statt verworfen oder weitergeschickt zu werden. Wenn eine Division für deine Anwendung zu grob ist, um darauf zu handeln, ist dieser Zweig die Stelle, an der du sie an eine Person übergibst.

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)

Die Konfidenzen passen dazu, wie schwer jede Einreichung zu klassifizieren ist. Die drei bei 1.00 sind ein Pharmahersteller, ein Lebensversicherer und ein Versorgungsunternehmen; alle drei sind auf dem Papier Holdings, aber jedes hat ein dominantes Geschäft, das die Einreichung direkt benennt. Die drei am unteren Ende sind aus Gründen schwieriger, die du im Text lesen kannst. Zwei sind Entwicklungsphasen-Unternehmen, die ein Geschäft beschreiben, das sie zu starten beabsichtigen (Nevaeh “intends to operate as a software developer”, Barricode war “organized to enter into the computer security software industry”), und das dritte hatte zwei Segmente und verkaufte eines davon Wochen vor der Einreichung. Diese drei kommen als Division statt als Gruppe zurück.

classify() ist das ganze Rezept. Richte ask() auf deine eigenen Dokumente und schreibe describe() für deine eigene Taxonomie neu, und der Rest überträgt sich.

Was die breitere Antwort bringt

Alle 60 Einreichungen, bewertet gegen den Code, den jeder Einreicher gewählt hat, unter beiden Strategien: jedes Mal eine Gruppe nennen oder die Division melden, wenn die Konfidenz unter 0.9 landet.

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

Wo das Modell sicher war, ist die genannte Gruppe in neun von zehn Fällen richtig. Wo es das nicht war, war das Nennen einer Gruppe öfter falsch als richtig, bei 40%. Dieselben Antworten als Division gemeldet bringt sie auf 70%.

Das Diagramm stellt die beiden Strategien nebeneinander, geteilt danach, ob das Modell sicher war.

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)
Ausgabe

Im Playground öffnen

Dieser Share-Link enthält eine Einreichung und die 75-Optionen-Frage, sodass du die Verteilung und die Konfidenz sehen kannst, die sie erzeugt, ohne Code zu schreiben.

playground_link = make_playground_link(
    example["text"], questions(), models=[TYPESAFE_MODEL]
)
display(
    Markdown(
        f"🔗 [Open the filing + question in the TypeSafe playground]({playground_link})"
    )
)
Den Bericht + die Frage im TypeSafe-Playground öffnen