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/>≥ 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)
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 →