Classification par la confiance
Classe des rapports annuels déposés auprès de la SEC en 75 groupes d’activité avec une question Choice chacun, puis lit la confiance propre de la réponse pour décider si l’on rapporte ce groupe ou la division plus large au-dessus.
Chaque entreprise qui dépose un rapport annuel auprès de la SEC y décrit sa propre
activité. Nous classons ces descriptions selon la Standard Industrial Classification : 75
groupes d’activité, une question Choice par document.
La plupart des dépôts sont faciles. Une banque régionale reste une banque régionale. Certains ne le sont pas : une entreprise qui vient de vendre l’un de ses deux segments, ou une jeune pousse qui décrit une activité qu’elle compte lancer plutôt qu’une qu’elle exerce. Le modèle doit choisir un groupe quoi qu’il arrive, et la réponse d’un cas difficile ne se distingue pas de celle d’un cas facile. Distinguer les cas difficiles des cas faciles, c’est normalement là que part le coût : un second modèle, des appels supplémentaires, une revue humaine.
Une question Choice te le dit déjà. À côté de l’option gagnante, elle renvoie confidence,
élevée quand presque toute la probabilité s’est portée sur une option et basse quand elle
s’est répartie sur plusieurs. Ce seul nombre sépare les réponses à qui tu peux te fier de
celles à qui tu ne peux pas.
Ce qu’il faut faire d’une réponse non fiable dépend de tes libellés. Les libellés SIC forment une hiérarchie : les groupes d’activité se regroupent en divisions plus larges. Cela rend une réponse presque gratuite. Quand le modèle n’est pas sûr du groupe, rapporte la division dont il relève. Le libellé large découle du libellé étroit, donc il n’y a pas de second appel.
Sur 60 dépôts, un seuil de confiance de 0,9 les coupe en deux. La moitié sûre a raison 90 %
du temps ; l’autre moitié, 40 %. Rapportée un niveau au-dessus, ces 40 % deviennent
70 %. On termine avec une fonction classify() qui renvoie un libellé plus son degré de
précision, à raison d’une requête par document.
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
Configuration
pip install ipython matplotlib 'cooksafe>=0.2.0,<0.3.0'
puis définis TYPESAFE_API_KEY. Chaque appel API est mis en cache dans json_cache.json,
livré avec le cookbook, donc un nouveau rendu rejoue les chiffres publiés sans appeler l’API.
Supprime ce fichier pour tout réexécuter en direct.
Les chiffres ci-dessous proviennent de jev-1.12, le 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"))
Construire les deux niveaux de la taxonomie
sic_codes.tsv est la liste d’activités que la SEC publie pour que les déposants y choisissent
leur propre code, récupérée le 2026-08-10 : 444 codes à quatre chiffres, chacun avec un
intitulé d’activité. Les chiffres forment une hiérarchie. Les deux premiers constituent le
groupe majeur (75 ici, de 01 production agricole à 99 non classable), et des plages
fixes de groupes majeurs forment les dix divisions, le découpage le plus large dont
dispose la SIC.
Les deux niveaux sortent de ce seul fichier sans aucun modèle : regroupe les codes par leurs deux premiers chiffres, puis associe ces chiffres à une division.
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 ...
Une question Choice a besoin de quelque chose pour décrire chaque option, et le nom propre d’un groupe n’est pas toujours là : 42 des 75 portent un intitulé générique dans la liste de la SEC, et les autres n’en portent aucun. Chaque groupe est donc décrit par les activités qu’il contient, ce que quelqu’un qui lit le dépôt comparerait de toute façon.
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
Les dépôts
filings.jsonl contient 60 rapports annuels (10-K), chacun réduit à l’Item 1 « Business », la
section où une entreprise décrit ce qu’elle fait, seule partie à laquelle un code d’activité
se rapporte. Ils vont de 1993 à 2024 et comptent de 700 à 2 200 mots. Chacun porte le code SIC
choisi par son déposant, plus le numéro d’accession pour le retrouver sur EDGAR.
L’origine de ce libellé compte avant tout chiffre de précision. Il est auto-déclaré : celui qui a préparé le dépôt l’a choisi une fois, et il devient obsolète quand une entreprise vend l’activité que le code désigne et garde le code. Ces 60 dépôts ont été filtrés pour ne garder que ceux dont le texte confirme le code qu’ils portent, donc les chiffres ici mesurent la recette plutôt que l’état des métadonnées d’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
Poser une question Choice et lire la confiance
Une question Choice dont les options sont les 75 groupes. Toute la taxonomie tient dans une
seule requête : une question Choice fonctionne de manière fiable jusqu’à environ 240 options,
et 75 est largement en dessous.
La réponse revient avec choice, le groupe gagnant ; probabilities, le poids sur chacun des
75 ; et confidence, qui dit à quel point cette répartition était concentrée. La recette lit
confidence plutôt que la probabilité propre du gagnant. Un gagnant à 0,45 avec un second à
0,44, et un gagnant à 0,45 avec le reste du poids dispersé finement, sont des situations
différentes, et c’est confidence qui les distingue.
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),
}
Renvoyer le groupe quand on est sûr, sa division sinon
Les quatre lignes ci-dessous constituent toute la recette. À une confiance de 0,9 ou plus, la réponse est rapportée comme un groupe d’activité ; en dessous, la même réponse est rapportée comme la division à laquelle ce groupe appartient.
Chaque dépôt revient malgré tout avec un libellé utilisable. Ceux que le modèle n’a pas pu classer avec assurance reviennent un niveau au-dessus au lieu d’être écartés ou transmis. Si une division est trop grossière pour que ton application agisse dessus, c’est cette branche qui te sert à la confier à une personne.
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)
Les confiances correspondent à la difficulté de classification de chaque dépôt. Les trois à 1,00 sont un fabricant pharmaceutique, un assureur-vie et un service public ; tous trois sont des sociétés holding sur le papier, mais chacun a une activité dominante que le dépôt nomme sans détour. Les trois du bas sont plus difficiles pour des raisons lisibles dans le texte. Deux sont des sociétés au stade du développement qui décrivent une activité qu’elles comptent lancer (Nevaeh « compte opérer comme développeur de logiciels », Barricode a été « organisée pour entrer dans l’industrie du logiciel de sécurité informatique »), et la troisième avait deux segments et en a vendu un quelques semaines avant le dépôt. Ces trois reviennent comme une division plutôt que comme un groupe.
classify() est toute la recette. Pointe ask() vers tes propres documents et réécris
describe() pour ta propre taxonomie, et le reste s’applique.
Ce que la réponse élargie apporte
Les 60 dépôts, notés face au code que chaque déposant a choisi, selon les deux politiques : nommer un groupe à chaque fois, ou rapporter la division dès que la confiance tombe sous 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
Là où le modèle était sûr, le groupe qu’il a nommé est juste neuf fois sur dix. Là où il ne l’était pas, nommer un groupe était faux plus souvent que juste, à 40 %. Rapporter ces mêmes réponses comme une division les fait monter à 70 %.
Le graphique met les deux politiques côte à côte, séparées selon que le modèle était sûr ou non.
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)
Ouvre-le dans le playground
Ce lien de partage contient un dépôt et la question à 75 options, pour que tu voies la distribution et la confiance qu’elle produit sans écrire une ligne de code.
playground_link = make_playground_link(
example["text"], questions(), models=[TYPESAFE_MODEL]
)
display(
Markdown(
f"🔗 [Open the filing + question in the TypeSafe playground]({playground_link})"
)
)
Open the filing + question in the TypeSafe playground →