JevCode / Casos del ecosistema

Clasificación mediante confianza

Clasificar los informes anuales de la SEC en 75 grupos industriales con una única opción, y luego leer la confianza de la respuesta para decidir si se reporta ese grupo o la división superior más amplia.

Traducido automáticamente del en, sin revisión. Solo como referencia rápida.

Fuente: docs.typesafe.ai/cookbooks/classification_using_confidencecookbookrecipe
One confident pick among many candidates

Cada empresa que presenta un informe anual a la SEC describe su propia actividad en él. Clasificamos esas descripciones bajo la Clasificación Industrial Estándar: 75 grupos industriales, una Choice pregunta por documento.

La mayoría de los registros son sencillos. 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 en el que planea entrar en lugar de uno que gestiona. El modelo tiene que elegir un grupo de todos modos, y la respuesta para un caso difícil no se parece en nada a la respuesta para uno sencillo. Distinguir los casos difíciles de los sencillos es normalmente donde se incurre en el coste: un segundo modelo, llamadas adicionales, revisión humana.

Una Choice ya te lo indica. Junto a la opción ganadora, devuelve confidence, alto cuando casi toda la probabilidad recaía en una sola opción y bajo cuando se repartía entre varias. Ese único número separa las respuestas en las que puedes confiar de las que no.

Lo que 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. Esto 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 deriva de la estrecha, por lo que no hay una segunda llamada.

En 60 expedientes, un umbral de confianza de 0.9 los divide por igual. La mitad confiada tiene razón el 90 % de las veces; la otra mitad, el 40 %. Informado un nivel más arriba, ese 40 % se convierte en 70 %. Terminamos con una función classify() que devuelve una etiqueta junto con su nivel de especificidad, a razón de una solicitud por documento.

Dirección del flujo: LR

Nodo Descripción Grupo
doc Elemento 1 ‘Negocios’ / de un 10-K —
request una solicitud una solicitud
q Choice / 75 grupos industriales una solicitud
sure confianza / ≥ 0,9? —
grp informar el grupo industrial / p. ej. 28 —
div informar su división / p. ej. manufactura —
De Condición A
doc — request
sure sí grp
sure no div

Configuración

pip install ipython matplotlib "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/

entonces establece TYPESAFE_API_KEY. Cada llamada a la API se almacena en caché en json_cache.json, que se incluye con el libro de recetas, por lo que volver a renderizar reproduce los números publicados sin llamar a la API. Elimina ese archivo para volver a ejecutar todo en tiempo real.

Los números de abajo provienen 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 sectorial que la SEC publica para que los presentadores 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 forman una jerarquía. Los dos primeros son el grupo mayor (75 de ellos aquí, desde 01 producción agrícola hasta 99 no clasificable), y los rangos fijos de grupos mayores conforman las diez divisiones, la división más amplia del SIC.

Ambos niveles provienen de ese único archivo sin intervención de ningún modelo: 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 de Choice necesita algo para describir cada opción, y el nombre propio de un grupo no siempre está presente: 42 de los 75 llevan un título genérico en la lista de la SEC, y el resto no lleva ninguno. Por lo tanto, cada grupo se describe mediante las industrias que contiene, que es precisamente lo que alguien que lee el informe compararía de todos modos.

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 expedientes

filings.jsonl contiene 60 informes anuales (10-K), cada uno recortado al Ítem 1 “Negocio”, la sección donde una empresa describe lo que hace, que es la única parte que interesa a un código de la industria. Cubren el período 1993–2024 y varían entre 700 y 2.200 palabras. Cada uno incluye el código SIC elegido por el presentador, así como el número de acceso para consultarlo en EDGAR.

De dónde proviene esa etiqueta importa antes que cualquier número de precisión. Es autodeclarada: quien preparó el informe la eligió una vez, y se vuelve obsoleta cuando una empresa vende el negocio con los nombres en código y conserva el código. Estos 60 se filtraron hasta obtener informes cuyos texto propio respalda el código que llevan, por lo que los números aquí miden la receta más que 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 de Choice y lee la confianza

Una Choice pregunta cuyas opciones son los 75 grupos. Toda la taxonomía cabe en una solicitud: un Choice funciona de forma fiable hasta aproximadamente 240 opciones, y 75 está muy por debajo de ese límite.

La respuesta vuelve con choice, el grupo ganador; probabilities, el peso en cada uno de los 75; y confidence, que indica cuán concentrada estaba esa distribución. La receta lee confidence en lugar de la propia probabilidad del ganador. Un ganador con 0.45 y un segundo clasificado con 0.44, y un ganador con 0.45 mientras el resto del peso se dispersa tenuemente, 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és seguro, su división cuando no

Las cuatro líneas siguientes son la receta completa. Con una confianza de 0,9 o superior, la respuesta se informa como un grupo de la industria; por debajo de ese umbral, la misma respuesta se informa como la división en la que se encuentra ese grupo.

Cada registro sigue devolviendo una etiqueta utilizable. Uno que el modelo no pudo clasificar con confianza vuelve a subir un nivel en lugar de ser descartado o enviado más allá. Si una división es demasiado gruesa para que tu aplicación pueda actuar sobre ella, esta rama es donde la entregas 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 líneas de confianza coinciden con la dificultad de clasificación de cada presentación. Las tres con 1.00 son una empresa farmacéutica, una aseguradora de vida y una empresa de servicios públicos; las tres son sociedades holding en el papel, pero cada una tiene un negocio dominante que la presentación nombra abiertamente. Las tres en la parte inferior son más difíciles por razones que puedes leer en el texto. Dos son empresas en etapa de desarrollo que describen un negocio que pretenden iniciar (Nevaeh “pretende operar como desarrolladora de software”), Barricode fue “organizada para entrar en la industria del software de seguridad informática”), y la tercera tenía dos segmentos y vendió uno de ellos semanas antes de la presentación. Esas tres aparecen como una división en lugar de un grupo.

classify() es la receta completa. Apunta ask() a tus propios documentos y reescribe describe() para tu propia taxonomía, y el resto se traslada.

Lo que aporta la respuesta más amplia

Las 60 presentaciones, puntuadas frente al código que eligió cada presentador, bajo ambas políticas: nombra un grupo cada vez, o informa de la división siempre que la confianza caiga 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

Cuando el modelo estaba seguro, el grupo que nombraba era correcto nueve de cada diez veces. Cuando no lo estaba, nombrar un grupo era incorrecto más a menudo que correcto, en un 40%. Informar esas mismas respuestas como una división las eleva al 70%.

El gráfico pone las dos políticas lado a lado, dividido 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)
output

Ábrelo en el playground

Este enlace de compartición contiene un único expediente y la pregunta de 75 opciones, por lo que puedes ver la distribución y la confianza que produce sin escribir ningún 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})"
    )
)

Abrir el expediente + pregunta en el playground de TypeSafe →