Clasificación usando la confianza
Clasifica informes anuales de la SEC en 75 grupos industriales con un Choice cada uno y luego lee la confianza de la propia respuesta para decidir si informar ese grupo o la división más amplia que lo contiene.
Toda empresa que presenta un informe anual ante la SEC describe su propio negocio en él.
Clasificamos esas descripciones según la Clasificación Industrial Estándar (SIC): 75 grupos
industriales, una pregunta Choice por documento.
La mayoría de los informes son fáciles. Un banco regional es un banco regional. Algunos no lo son: una empresa que acaba de vender uno de sus dos segmentos, o una startup que describe un negocio que planea iniciar en vez de uno que ya opera. El modelo tiene que elegir un grupo de todos modos, y la respuesta para un caso difícil no se ve distinta de la de uno fácil. Distinguir los casos difíciles de los fáciles es normalmente donde se va el costo: un segundo modelo, llamadas extra, revisión humana.
Un Choice ya te lo dice. Junto a la opción ganadora devuelve confidence, alta cuando casi toda
la probabilidad recayó en una opción y baja cuando se repartió entre varias. Ese único número
separa las respuestas en las que puedes confiar de las que no.
Qué hacer con una respuesta no confiable depende de tus etiquetas. Las etiquetas SIC forman una jerarquía: los grupos industriales se agrupan en divisiones más amplias. Eso hace que una respuesta sea casi gratuita. Cuando el modelo no está seguro del grupo, informa la división a la que pertenece. La etiqueta amplia se sigue de la estrecha, así que no hay una segunda llamada.
A lo largo de 60 informes, un corte de confianza de 0.9 los parte en dos. La mitad segura acierta
el 90% de las veces; la otra mitad, el 40%. Informada un nivel más arriba, ese 40% pasa a 70%.
Terminamos con una función classify() que devuelve una etiqueta más su grado de especificidad,
con una petición 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
Configuración
pip install ipython matplotlib 'cooksafe>=0.2.0,<0.3.0'
luego define TYPESAFE_API_KEY. Cada llamada a la API se cachea en json_cache.json, que viene
con el cookbook, así que volver a renderizar reproduce los números publicados sin llamar a la
API. Borra ese archivo para volver a ejecutarlo todo en vivo.
Los números de abajo vienen de jev-1.12 el 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"))
Construye los dos niveles de la taxonomía
sic_codes.tsv es la lista de industrias que la SEC publica para que quienes presentan informes
elijan su propio código, obtenida el 2026-08-10: 444 códigos de cuatro dígitos, cada uno con un
título de industria. Los dígitos son una jerarquía. Los dos primeros son el grupo principal
(75 aquí, desde 01 producción agrícola hasta 99 no clasificable), y rangos fijos de grupos
principales forman las diez divisiones, la división más amplia que tiene la SIC.
Ambos niveles salen de ese único archivo sin ningún modelo de por medio: agrupa los códigos por sus dos primeros dígitos y luego mapea esos dígitos a una división.
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 ...
Una pregunta Choice necesita algo que describa cada opción, y el nombre propio de un grupo no siempre está: 42 de los 75 llevan un título paraguas en la lista de la SEC, y el resto no llevan ninguno. Así que cada grupo se describe por las industrias que contiene, que es con lo que compararía de todos modos alguien que leyera el informe.
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
Los informes
filings.jsonl contiene 60 informes anuales (10-K), cada uno recortado al Item 1 “Business”, la
sección donde una empresa describe a qué se dedica, que es la única parte a la que se refiere un
código de industria. Abarcan de 1993 a 2024 y van de 700 a 2,200 palabras. Cada uno lleva el
código SIC que eligió quien lo presentó, más el número de acceso para consultarlo en EDGAR.
De dónde viene esa etiqueta importa antes de cualquier cifra de precisión. Es autodeclarada: quien preparó el informe la eligió una vez, y queda obsoleta cuando una empresa vende el negocio que nombra el código y conserva el código. Estos 60 se filtraron hasta dejar los informes cuyo propio texto respalda el código que llevan, así que los números de aquí miden la receta y no el estado de los metadatos de 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
Haz una pregunta Choice y lee la confianza
Una pregunta Choice cuyas opciones son los 75 grupos. Toda la taxonomía cabe en una sola
petición: un Choice funciona de forma fiable hasta unas 240 opciones, y 75 entra bien dentro de
ese límite.
La respuesta vuelve con choice, el grupo ganador; probabilities, el peso de cada uno de los
75; y confidence, que dice cuán concentrado estaba ese reparto. La receta lee confidence en
vez de la probabilidad del propio ganador. Un ganador con 0.45 y un segundo con 0.44, y un
ganador con 0.45 y el resto del peso repartido en migajas, son situaciones distintas, y
confidence es lo que las 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),
}
Devuelve el grupo cuando está seguro y su división cuando no
Las cuatro líneas de abajo son toda la receta. Con una confianza de 0.9 o más, la respuesta se informa como grupo industrial; por debajo, esa misma respuesta se informa como la división en la que está ese grupo.
Todo informe sigue devolviendo una etiqueta utilizable. Uno que el modelo no pudo clasificar con confianza vuelve un nivel más arriba en vez de descartarse o pasarse. Si una división es demasiado gruesa para que tu aplicación actúe sobre ella, esta rama es donde se la pasas a una persona.
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)
Las confianzas se corresponden con lo difícil que es clasificar cada informe. Los tres con 1.00 son un fabricante farmacéutico, una aseguradora de vida y una empresa de servicios públicos; los tres son sociedades holding sobre el papel, pero cada una tiene un negocio dominante que el informe nombra sin rodeos. Los tres de abajo son más difíciles por razones que puedes leer en el texto. Dos son empresas en fase de desarrollo que describen un negocio que piensan iniciar (Nevaeh “intends to operate as a software developer”, Barricode was “organized to enter into the computer security software industry”), y la tercera tenía dos segmentos y vendió uno de ellos semanas antes de presentar el informe. Esos tres vuelven como división en vez de grupo.
classify() es toda la receta. Apunta ask() a tus propios documentos y reescribe describe()
para tu propia taxonomía, y el resto se traslada.
Qué aporta la respuesta más amplia
Los 60 informes, puntuados contra el código que eligió cada presentador, bajo ambas políticas: nombrar siempre un grupo, o informar la división cuando la confianza quede por debajo 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
Donde el modelo estaba seguro, el grupo que nombró acierta nueve de cada diez veces. Donde no lo estaba, nombrar un grupo falló más de lo que acertó: un 40%. Informar esas mismas respuestas como división las lleva al 70%.
El gráfico pone las dos políticas una al lado de la otra, separadas por si el modelo estaba seguro.
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)
Ábrelo en el playground
Este enlace compartido contiene un informe y la pregunta de 75 opciones, para que veas la distribución y la confianza que produce sin escribir 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 el informe + la pregunta en el playground de TypeSafe →