Documentação

Classificação usando a confiança

Classifique relatórios anuais da SEC em 75 grupos industriais com um Choice cada e depois leia a confiança da própria resposta para decidir se informa esse grupo ou a divisão mais ampla acima dele.

Toda empresa que apresenta um relatório anual à SEC descreve o próprio negócio nele. Classificamos essas descrições segundo a Classificação Industrial Padrão (SIC): 75 grupos industriais, uma pergunta Choice por documento.

A maioria dos relatórios é fácil. Um banco regional é um banco regional. Alguns não são: uma empresa que acabou de vender um de seus dois segmentos, ou uma startup descrevendo um negócio que pretende iniciar em vez de um que já opera. O modelo precisa escolher um grupo de qualquer forma, e a resposta para um caso difícil não parece diferente da resposta para um caso fácil. Distinguir os casos difíceis dos fáceis é normalmente onde o custo vai embora: um segundo modelo, chamadas extras, revisão humana.

Um Choice já te diz. Junto à opção vencedora ele retorna confidence, alta quando quase toda a probabilidade caiu numa opção e baixa quando se espalhou por várias. Esse único número separa as respostas em que você pode confiar das que não pode.

O que fazer com uma resposta não confiável depende dos seus rótulos. Os rótulos SIC formam uma hierarquia: os grupos industriais se agrupam em divisões mais amplas. Isso torna uma resposta quase gratuita. Quando o modelo está inseguro quanto ao grupo, informe a divisão a que ele pertence. O rótulo amplo decorre do estreito, então não há uma segunda chamada.

Ao longo de 60 relatórios, um corte de confiança de 0.9 os divide ao meio. A metade confiante acerta 90% das vezes; a outra metade, 40%. Informada um nível acima, esses 40% viram 70%. Terminamos com uma função classify() que retorna um rótulo mais o quão específico ele é, com uma requisição por documento.

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

Configuração

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

depois defina TYPESAFE_API_KEY. Toda chamada à API é armazenada em cache em json_cache.json, que acompanha o cookbook, então re-renderizar reproduz os números publicados sem chamar a API. Apague esse arquivo para executar tudo ao vivo de novo.

Os números abaixo vieram do jev-1.12 em 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"))

Construa os dois níveis da taxonomia

sic_codes.tsv é a lista de indústrias que a SEC publica para que quem apresenta um relatório escolha o próprio código, obtida em 2026-08-10: 444 códigos de quatro dígitos, cada um com um título de indústria. Os dígitos são uma hierarquia. Os dois primeiros são o grupo principal (75 aqui, de 01 produção agrícola a 99 não classificável), e faixas fixas de grupos principais formam as dez divisões, a divisão mais ampla que a SIC tem.

Ambos os níveis saem desse único arquivo sem nenhum modelo envolvido: agrupe os códigos pelos dois primeiros dígitos e depois mapeie esses dígitos para uma divisão.

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

Uma pergunta Choice precisa de algo que descreva cada opção, e o nome próprio de um grupo nem sempre existe: 42 dos 75 carregam um título guarda-chuva na lista da SEC, e o resto não carrega nenhum. Então cada grupo é descrito pelas indústrias dentro dele, que é com o que alguém que lê o relatório compararia de qualquer forma.

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

Os relatórios

filings.jsonl contém 60 relatórios anuais (10-K), cada um reduzido ao Item 1 “Business”, a seção onde uma empresa descreve a que se dedica, que é a única parte a que um código de indústria se refere. Eles abrangem de 1993 a 2024 e vão de 700 a 2,200 palavras. Cada um carrega o código SIC que quem o apresentou escolheu, mais o número de acesso para consultá-lo no EDGAR.

De onde vem esse rótulo importa antes de qualquer número de precisão. Ele é autodeclarado: quem preparou o relatório o escolheu uma vez, e ele fica obsoleto quando uma empresa vende o negócio que o código nomeia e mantém o código. Esses 60 foram filtrados até sobrar os relatórios cujo próprio texto sustenta o código que carregam, então os números aqui medem a receita, e não o estado dos metadados do 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

Faça uma pergunta Choice e leia a confiança

Uma pergunta Choice cujas opções são os 75 grupos. Toda a taxonomia cabe numa única requisição: um Choice funciona de forma confiável até cerca de 240 opções, e 75 fica bem dentro disso.

A resposta volta com choice, o grupo vencedor; probabilities, o peso de cada um dos 75; e confidence, que diz quão concentrado aquele espalhamento estava. A receita lê confidence em vez da probabilidade do próprio vencedor. Um vencedor com 0.45 e um segundo colocado com 0.44, e um vencedor com 0.45 com o resto do peso espalhado em migalhas, são situações diferentes, e confidence é o que as separa.

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

Retorne o grupo quando tiver certeza, a divisão quando não

As quatro linhas abaixo são a receita inteira. Com confiança de 0.9 ou mais, a resposta é informada como grupo industrial; abaixo disso, a mesma resposta é informada como a divisão em que aquele grupo está.

Todo relatório ainda volta com um rótulo utilizável. Um que o modelo não conseguiu classificar com confiança volta um nível acima em vez de ser descartado ou repassado. Se uma divisão for grossa demais para sua aplicação agir sobre ela, este ramo é onde você a entrega a uma pessoa.

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)

As confianças correspondem a quão difícil é classificar cada relatório. Os três com 1.00 são um fabricante farmacêutico, uma seguradora de vida e uma concessionária de serviços públicos; os três são empresas holding no papel, mas cada um tem um negócio dominante que o relatório nomeia sem rodeios. Os três de baixo são mais difíceis por razões que dá para ler no texto. Dois são empresas em fase de desenvolvimento descrevendo um negócio que pretendem iniciar (a Nevaeh “intends to operate as a software developer”, a Barricode was “organized to enter into the computer security software industry”), e a terceira tinha dois segmentos e vendeu um deles semanas antes de apresentar o relatório. Esses três voltam como divisão em vez de grupo.

classify() é a receita inteira. Aponte ask() para seus próprios documentos e reescreva describe() para sua própria taxonomia, e o resto continua valendo.

O que a resposta mais ampla oferece

Todos os 60 relatórios, pontuados contra o código que cada empresa escolheu, sob as duas políticas: nomear um grupo sempre, ou informar a divisão sempre que a confiança ficar abaixo de 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

Onde o modelo estava seguro, o grupo que ele nomeou acerta nove em cada dez vezes. Onde não estava, nomear um grupo errou mais do que acertou, com 40%. Informar essas mesmas respostas como divisão as leva a 70%.

O gráfico coloca as duas políticas lado a lado, separadas por o modelo estar seguro ou não.

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)
saída

Abra no playground

Este link compartilhado contém um relatório e a pergunta de 75 opções, para você ver a distribuição e a confiança que ela produz sem escrever código.

playground_link = make_playground_link(
    example["text"], questions(), models=[TYPESAFE_MODEL]
)
display(
    Markdown(
        f"🔗 [Open the filing + question in the TypeSafe playground]({playground_link})"
    )
)
Abra o relatório + a pergunta no playground do TypeSafe →