信頼度に基づく分類
SECの年次報告書を75の業種グループに分類し、各グループに1つの選択肢を割り当てる。その後、回答自体の信頼度を確認し、そのグループを報告するか、上位のより広範な部門を報告するかを判断する。
本文はenからの機械翻訳です。校正は未実施で、参考情報としてのみご利用ください。

SECに年次報告書を提出するすべての企業は、その中で自社の事業を説明している。私たちはそれらの説明を標準産業分類の下に分類する:75の産業グループ、文書ごとに1つのChoice質問。
ほとんどの届出は簡単だ。地方銀行は地方銀行である。しかし、すべてがそうではない:2つの事業部門のうち1つを売却しただけの企業や、現在運営している事業ではなく、参入予定の事業を説明するスタートアップなどである。モデルは関係なくグループを選ばねばならず、難しいケースに対する回答は、簡単なケースに対する回答と見た目は変わらない。難しいケースと簡単なケースを見分けることに、通常、コストがかかる:2番目のモデル、追加の呼び出し、人間のレビュー。
A Choice はすでにあなたに伝えています。正解の選択肢とともに、confidenceを返します。これは、確率がほぼ一つの選択肢に集中している場合は高く、複数の選択肢に分散している場合は低くなります。この一つの数値が、信頼できる回答とできない回答を区別します。
信頼できない回答の扱い方は、ラベルに依存します。SICラベルは階層構造を形成します: 産業グループはより広範な部門に集約されます。これにより、ほぼ無料で済む回答が一つ得られます。モデルがグループを特定できない場合は、所属する部門を報告してください。広範なラベルは狭義のラベルから導かれるため、二回目の呼び出しは不要です。
60件の出願全体で、信頼度のカットオフ値0.9はそれらを半々に分ける。高い信頼度の半分は90%の確率で正しく、もう半分は40%である。1段階上位で報告されると、その40%は70%になる。私たちは、ラベルとその特定度を返すclassify()関数で締めくくる。これは1つのリクエストにつき1つのドキュメントに対して行われる。
フロー方向:LR
| ノード | 説明 | グループ |
|---|---|---|
doc |
アイテム1 ‘Business’ / 1つの10-Kから | — |
request |
1つのリクエスト | 1つのリクエスト |
q |
Choice / 75の業種グループ | 1つのリクエスト |
sure |
信頼度 / ≥ 0.9? | — |
grp |
業種グループを報告 / 例:28 | — |
div |
その部門を報告 / 例:製造業 | — |
| 元 | 条件 | 先 |
|---|---|---|
doc |
— | request |
sure |
はい | grp |
sure |
いいえ | div |
セットアップ
pip install ipython matplotlib "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
then set TYPESAFE_API_KEY. すべての API 呼び出しは json_cache.json にキャッシュされ、これはクックブックに同梱されているため、再レンダリングしても API を呼び出すことなく公開済みの数値が再生されます。このファイルを削除すると、すべての処理がライブで再実行されます。
以下の数値は2026年8月12日時点のjev-1.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"))
分類体系の2つのレベルを構築する
sic_codes.tsvは、SECが公開する業界リストであり、提出者が自らのコードを選択するために使用します。2026-08-10に取得:4桁のコード444件、それぞれに業界タイトルが付いています。数字は階層構造を持っています。最初の2桁は主要グループ(ここでは175件、01農業生産から99分類不能まで)を表し、主要グループの固定範囲がSICの最も広い区分である10の部門を構成します。
両方のレベルは、モデルを介さず1つのファイルから生成されます:コードを最初の2桁でグループ分けし、その桁数を部門にマッピングします。
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 ...
A Choiceの質問では、各選択肢を説明する何かが必要であり、グループ固有の名前が必ずしも存在するわけではありません:SECのリストにある75のうち42は上位タイトルを有しており、残りは何も持っていません。したがって、各グループは内部の産業によって説明され、これは申請書を閲覧する人が実際に照合する内容です。
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
提出書類
filings.jsonlは、60件の年次報告書(10-K)を保持しており、それぞれが「Item 1: ビジネス」のセクションに絞り込まれています。このセクションでは企業が自社の事業内容を説明しており、産業コードが関わる部分はこの唯一の箇所です。これらは1993年から2024年までをカバーし、文字数は700語から2,200語です。各報告書には、提出者が選択したSICコードと、EDGARで検索するためのアクセッション番号が記載されています。
そのラベルがどこから来るかは、正確性という数値よりも前に重要である。それは自己申告である: 提出書類を準備した者がそれを一度選び、企業がコード名を伴う事業を売却し、コード自体を保持した場合、それは古くなる。これら60件は、そのコード名を付与された書類の本文がそのコード名を支持しているものへと絞り込まれたため、ここでの数値は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
1つのChoice質問を行い、自信度を確認する
Choiceの選択肢が75グループである1つの質問。全体の分類体系は1回のリクエストに収まります:Choiceは約240個の選択肢まで信頼して動作し、75は明らかにその範囲内に収まります。
正解はchoice(勝利したグループ)、probabilities(各75に対する重み)、そしてconfidence(その分布がいかに集中していたかを示すもの)として返される。レシピは勝者の確率そのものではなくconfidenceとして解釈される。0.45の勝者と0.44の2位、そして0.45の勝者と残りの重みが薄く散らばっているケースは異なる状況であり、それらを区別するのがconfidenceである。
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),
}
確信できるときはグループを、確信できないときは部門を返す
以下の4行がレシピ全体です。信頼度が0.9以上の場合、回答は業界グループとして報告されます。それ未満の場合、同じ回答はそのグループが属する部門として報告されます。
すべての提出物は、使用可能なラベルを付けて返却されます。モデルが確信を持って分類できなかったものは、削除されたり先送りされたりするのではなく、一段階上位に戻されます。部門があなたのアプリケーションが行動を起こすには粗すぎる場合、このブランチで人間に引き渡します。
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)
各申請書の分類難易度と信頼スコアが一致している。1.00の3社は、製薬メーカー、生命保険会社、そして公益事業会社であり、これら3社は形式的には持株会社だが、それぞれが申請書で明確に名指しする支配的な事業を1つ抱えている。下部の3社は、テキストから読み取れる理由で分類が難しい。2社は、開始予定の事業を説明する開発段階の企業であり(Nevaehは「ソフトウェア開発業者として運営することを意図している」、Barricodeは「コンピュータセキュリティソフトウェア業界への参入を目的として設立された」)、残り1社は2つのセグメントを持ち、申請の数週間前にそのうちの1つを売却していた。これら3社は、グループではなく部門として認識される。
classify()が全体レシピです。ask()を自前の文書に向け、describe()を自前の分類体系に合わせて書き換えれば、残りの部分はそのまま引き継がれます。
広範な回答がもたらすもの
各出願者が選択したコードに対してスコアリングされた60件の出願すべてについて、両方のポリシーの下で:グループを毎回名前付けするか、信頼度が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
モデルが確信を持っていた場合、そのグループを名指す正解率は90%です。確信を持てなかった場合、グループを名指すのは正解より不正解の方が多い、40%でした。同じ回答を「割り当て」として報告すると、正解率は70%になります。
チャートは、モデルが確信を持っていたかどうかで分け、2つのポリシーを並べて示している。
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)
プレイグラウンドで開く
この共有リンクには1件の提出データと75問の質問が含まれており、コードを一切記述せずに、その分布とそこから得られる信頼度を直接確認することができます。
playground_link = make_playground_link(
example["text"], questions(), models=[TYPESAFE_MODEL]
)
display(
Markdown(
f"🔗 [Open the filing + question in the TypeSafe playground]({playground_link})"
)
)