JevCode / 생태계 사례

신뢰도 기반 분류

SEC 연간 보고서를 각 그룹당 하나의 선택지로 구성된 75개 산업군으로 분류한 후, 답변의 자체 신뢰도를 확인하여 해당 그룹을 보고할지 아니면 그 상위 더 넓은 divisions을 보고할지 결정한다.

이 문서는 en에서 기계 번역되었으며 교정되지 않았습니다. 빠른 참고용으로만 사용하세요.

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

SEC에 연간 보고서를 제출하는 모든 기업은 그 보고서에 자사의 사업을 설명합니다. 우리는 이러한 설명을 표준 산업 분류 체계 하에 분류합니다. 75개 산업 그룹, 문서당 Choice 하나의 질문.

대부분의 신고는 간단하다. 지역 은행은 지역 은행일 뿐이다. 하지만 그렇지 않은 경우도 있다: 두 개의 사업 부문 중 하나를 방금 매각한 기업이나, 현재 운영 중인 사업이 아니라 향후 진입할 사업을 설명하는 스타트업 같은 경우다. 모델은 어쨌든 그룹을 선택해야 하며, 어려운 사례에 대한 답변은 쉬운 사례에 대한 답변과 외관상 차이가 없다. 어려운 사례와 쉬운 사례를 구분하는 데 일반적으로 비용이 발생한다: 두 번째 모델, 추가 호출, 인간 검토 등.

A Choice는 이미 당신에게 말해줍니다. 승자 옵션과 함께 confidence를 반환하는데, 이는 거의 모든 확률이 한 옵션에 집중되었을 때는 높고, 여러 옵션에 분산되었을 때는 낮습니다. 그 하나의 숫자가 신뢰할 수 있는 답변과 그렇지 않은 답변을 구분합니다.

신뢰할 수 없는 답변을 어떻게 처리할지는 라벨에 따라 달라집니다. SIC 라벨은 계층 구조를 형성합니다: 산업 그룹은 더 넓은 부문으로 집계됩니다. 이로 인해 거의 추가 비용 없이 하나의 응답을 얻을 수 있습니다. 모델이 그룹을 확신하지 못할 경우, 해당 부문으로 보고하면 됩니다. 넓은 라벨은 좁은 라벨에서 파생되므로 두 번째 호출은 필요하지 않습니다.

60건의 출원 전반에 걸쳐 0.9의 신뢰도 임계값은 이를 반으로 나눕니다. 신뢰도 높은 절반은 90%의 확률로 정확하며, 나머지 절반은 40%입니다. 보고된 상위 한 단계에서, 이 40%는 70%가 됩니다. 우리는 라벨과 그 특이도를 반환하는 classify() 함수로 마무리하며, 이는 문서당 한 번의 요청으로 이루어집니다.

흐름 방향: LR

노드 설명 그룹
doc 항목 1 ‘비즈니스’ / 단일 10-K 보고서에서 —
request 단일 요청 단일 요청
q Choice / 75개 산업 그룹 단일 요청
sure 신뢰도 / ≥ 0.9? —
grp 산업 그룹 보고 / 예: 28 —
div 해당 부문 보고 / 예: 제조업 —
From Condition To
doc — request
sure 예 grp
sure 아니요 div

설정

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

그다음으로 TYPESAFE_API_KEY을 설정합니다. 모든 API 호출은 쿡북과 함께 제공되는 json_cache.json에 캐싱되므로, 다시 렌더링하면 API를 호출하지 않고도 게시된 수치를 재생합니다. 모든 것을 다시 실시간으로 실행하려면 해당 파일을 삭제하세요.

아래 숫자는 2026-08-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"))

분류의 두 단계 구축

sic_codes.tsv는 SEC가 발행한 산업 목록으로, 제출자는 이 목록에서 자신의 코드를 선택하며, 2026-08-10 기준 조회 시 4자리 코드 444개와 각각의 산업 제목이 포함됩니다. 이 숫자들은 계층 구조를 이룹니다. 첫 두 자리는 대분류(여기서는 75개, 01 농업 생산부터 99 비분류 가능까지)이며, 고정된 대분류 범위가 SIC에서 가장 넓은 분류인 10개 소분류를 구성합니다.

두 레벨은 모두 해당 단일 파일에서 모델 없이 생성됩니다: 코드를 첫 두 자리 숫자로 그룹화한 후, 해당 숫자를 부서에 매핑합니다。

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개는 umbrella 제목을 가지고 있으며, 나머지는 아무것도 가지고 있지 않습니다. 따라서 각 그룹은 그 안에 있는 산업들로 설명되며, 이는 제출서를 읽는 사람이 매칭할 것과 anyway 일치합니다.

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

Choice 질문을 하나 던지고, 신뢰도를 읽어라

Choice 안에 있는 75개 그룹을 옵션으로 하는 질문 하나. 전체 분류 체계는 하나의 요청으로 충분합니다. 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),
    }

확실할 때는 그룹을, 그렇지 않을 때는 divisions를 반환하세요

아래 네 줄이 전체 레시피입니다. 신뢰도 0.9 이상일 경우, 답변은 산업 그룹으로 보고되며, 그 미만일 경우 동일한 답변은 해당 그룹이 속한 부서로 보고됩니다.

모든 제출은 여전히 사용 가능한 레이블을 받아 반환됩니다. 모델이 확신 있게 분류하지 못한 항목은 삭제되거나 다음 단계로 넘어가는 대신 한 단계 위로 반환됩니다. divisions가 귀하의 애플리케이션에서 조치하기에 너무 광범위하다면, 이 분기가 바로 사람에게 넘기는 지점입니다.

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 점인 세 곳은 제약 제조사, 생명보험사 및 유틸리티 회사이며, 세 곳 모두 법적으로는 지주회사이지만 각자 서류에서 명시하는 우위 사업 하나를 보유하고 있습니다. 하단 세 곳은 텍스트에서 확인할 수 있는 이유로 분류가 더 어렵습니다. 두 곳은 “소프트웨어 개발업체로 운영할 의도”라고 설명하는 개발 단계 기업이며, Barricode는 “컴퓨터 보안 소프트웨어 산업에 진입하기 위해 설립”되었고, 세 번째 곳은 두 개의 사업 부문을 가지고 있었으나 서류 제출 몇 주 전에 그 중 하나를 매각했습니다. 이 세 곳은 그룹이 아닌 부문으로 분류됩니다.

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

모델이 확실했을 때, 지명한 그룹은 열 번 중 아홉 번 맞습니다. 모델이 확신이 없었을 때는 그룹을 지명하는 것이 맞을 때보다 틀릴 때가 더 많았습니다(40%). 같은 답변을 분할로 보고하면 그 비율은 70%로 올라갑니다.

차트는 모델이 확실했는지 여부에 따라 두 정책을 나란히 비교하며 구분합니다.

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

플레이그라운드에서 열기

이 공유 링크에는 하나의 제출 파일과 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})"
    )
)

TypeSafe 플레이그라운드에서 제출 + 질문 열기 →