Classificação com confiança
Classifica relatórios anuais da SEC em 75 grupos industriais com um Choice cada, e depois lê a confiança da própria resposta para decidir se reporta esse grupo ou a divisão mais ampla acima dele.
Todas as empresas que entregam um relatório anual à SEC descrevem nele o seu próprio
negócio. Classificamos essas descrições pela Standard Industrial Classification: 75 grupos
industriais, uma pergunta Choice por documento.
A maioria das entregas é fácil. Um banco regional é um banco regional. Algumas não são: uma empresa que acabou de vender um dos seus dois segmentos, ou uma startup que descreve um negócio que planeia entrar em vez de um que já opera. O modelo tem de 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 vai o custo: um segundo modelo, chamadas extra, revisão humana.
Um Choice já te diz. A par da opção vencedora devolve 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 podes confiar das que não podes.
O que fazer com uma resposta não fidedigna depende dos teus rótulos. Os rótulos SIC formam uma hierarquia: os grupos industriais agregam-se em divisões mais amplas. Isso torna uma resposta quase gratuita. Quando o modelo está inseguro quanto ao grupo, reporta a divisão a que pertence. O rótulo amplo decorre do estreito, por isso não há segunda chamada.
Em 60 entregas, um corte de confiança de 0,9 divide-as ao meio. A metade com confiança
acerta 90% das vezes; a outra metade, 40%. Reportada um nível acima, esses 40% passam a 70%.
Terminamos com uma função classify() que devolve um rótulo mais o quão específico ele é,
a um pedido 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 define TYPESAFE_API_KEY. Todas as chamadas à API são guardadas em cache em
json_cache.json, que acompanha o cookbook, por isso voltar a renderizar reproduz os
números publicados sem chamar a API. Apaga esse ficheiro para voltar a correr tudo ao vivo.
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"))
Constrói os dois níveis da taxonomia
O sic_codes.tsv é a lista de indústrias que a SEC publica para que os declarantes escolham
o seu 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 intervalos
fixos de grupos principais compõem as dez divisões, a divisão mais ampla que a SIC tem.
Ambos os níveis saem desse único ficheiro sem qualquer modelo envolvido: agrupa os códigos pelos seus dois primeiros dígitos e depois mapeia 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 do próprio grupo nem sempre existe: 42 dos 75 têm um título abrangente na lista da SEC, e os restantes não têm nenhum. Por isso cada grupo é descrito pelas indústrias dentro dele, que é aquilo com que quem lê a entrega se iria confrontar 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
As entregas
O filings.jsonl contém 60 relatórios anuais (10-K), cada um reduzido ao Item 1 “Business”,
a secção onde uma empresa descreve o que faz, que é a única parte a que um código de
indústria diz respeito. Vão de 1993 a 2024 e têm entre 700 e 2 200 palavras. Cada um
transporta o código SIC que o seu declarante escolheu, mais o número de acesso para o
procurar no EDGAR.
De onde vem esse rótulo é importante antes de qualquer número de exatidão. É autodeclarado: quem preparou a entrega escolheu-o uma vez, e fica desatualizado quando uma empresa vende o negócio que o código nomeia e mantém o código. Estas 60 foram filtradas para entregas cujo próprio texto sustenta o código que transportam, por isso 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
Faz uma pergunta Choice, e lê a confiança
Uma pergunta Choice cujas opções são os 75 grupos. Toda a taxonomia cabe num único
pedido: um Choice funciona de forma fiável até cerca de 240 opções, e 75 está bem dentro
disso.
A resposta volta com choice, o grupo vencedor; probabilities, o peso em cada um dos 75;
e confidence, que indica quão concentrada foi essa distribuição. A receita lê confidence
em vez da probabilidade do próprio vencedor. Um vencedor a 0,45 com um segundo a 0,44, e um
vencedor a 0,45 com o resto do peso espalhado de forma ténue, 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),
}
Devolve o grupo quando tem a certeza, a sua divisão quando não
As quatro linhas abaixo são a receita completa. A partir de 0,9 de confiança, a resposta é reportada como um grupo industrial; abaixo disso, a mesma resposta é reportada como a divisão em que esse grupo se insere.
Cada entrega continua a voltar com um rótulo utilizável. Uma que o modelo não conseguiu classificar com confiança volta um nível acima, em vez de ser descartada ou reencaminhada. Se uma divisão for demasiado grosseira para a tua aplicação agir sobre ela, é neste ramo que a entregas 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 alinham-se com o quão difícil é classificar cada entrega. As três a 1,00 são um fabricante farmacêutico, uma seguradora de vida e uma empresa de serviços públicos; as três são holdings no papel, mas cada uma tem um negócio dominante que a entrega nomeia diretamente. As três de baixo são mais difíceis por razões que se leem no texto. Duas são empresas em fase de desenvolvimento a descrever um negócio que tencionam iniciar (a Nevaeh “intends to operate as a software developer”, a Barricode foi “organized to enter into the computer security software industry”), e a terceira tinha dois segmentos e vendeu um deles semanas antes de entregar. Essas três voltam como uma divisão em vez de um grupo.
classify() é a receita completa. Aponta o ask() para os teus próprios documentos e
reescreve o describe() para a tua própria taxonomia, e o resto mantém-se.
O que a resposta mais ampla traz
As 60 entregas, pontuadas contra o código que cada declarante escolheu, sob as duas políticas: nomear um grupo sempre, ou reportar a divisão sempre que a confiança fique 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 tinha a certeza, o grupo que nomeou acerta nove em cada dez vezes. Onde não tinha, nomear um grupo estava errado mais vezes do que certo, com 40%. Reportar essas mesmas respostas como uma divisão leva-as a 70%.
O gráfico coloca as duas políticas lado a lado, divididas pelo facto de o modelo ter tido a certeza 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)
Abre no playground
Este link de partilha contém uma entrega e a pergunta de 75 opções, para que possas ver a distribuição e a confiança que produz sem escrever qualquer 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})"
)
)
Abre a entrega + pergunta no playground do TypeSafe →