Cookbook: Hierarchical classification
TL;DR One
Choiceper sibling set, withcriteriamapping opaque keys (c0,c1, …) to the child labels; the answer'sprobabilitiesare the edges. Score a path byproduct(edge_probabilities) ** (1 / decisions)and keep the bestK = 3— beam search matched 4 of 4 expected leaves where greedy matched 2 of 4.
Goal
Classify a document into a deep hierarchy by traversing it node by node to the correct leaf. Greedy search takes the highest-probability child at every node and cannot recover from one early mistake; beam search keeps K plausible paths, classifies every frontier in parallel, and lets deeper evidence repair an ambiguous early decision.
The cookbook argues that decomposing classification into a hierarchy buys observability (which nodes your misclassifications occur in; how often each node and edge is traversed) and testability (unit-test hierarchy updates and measure their impact).
Inputs / state shape
The state is the plain document string. The hierarchy is a nested dict[str, Tree] of direct-child menus, loaded from four pinned sources:
| Hierarchy | Version | Source |
|---|---|---|
| CPC patents | 2026.05 | CPCSchemeXML202605.zip (cooperativepatentclassification.org) |
| Shopify products | 2026-02 | Shopify/product-taxonomy v2026-02 dist/en/categories.txt |
| MeSH biomedical subjects | 2026 | desc2026.zip (nlmpubs.nlm.nih.gov) |
| CookSafe files | snapshot 2026-08-06 | codebase_files.txt (frozen repo listing) |
MeSH is a DAG — one descriptor can appear under multiple parents — so the demo expands its official tree-number paths. The CookSafe listing is a frozen snapshot rather than a live walk, because line order is significant: sibling options are asked in the order they appear, so it is part of the question, not presentation.
The four documents and their expected leaves, verbatim:
| Hierarchy | document |
expected_leaf |
|---|---|---|
| CPC patents | Patent abstract: a freestanding structural wooden perch for poultry or pet birds. The elevated roost has crossbars sized for bird feet and mounts inside an aviary. |
A01K31/12 Perches for poultry or birds, e.g. roosts |
| Shopify products | Furniture listing: a wall-mounted window shelf bed. This padded floating shelf uses suction cups and a washable cushion as a sunny perch for one cat. |
Cat Window Beds & Perches |
| MeSH | Clinical abstract: Crohn disease with transmural ileocolonic inflammation, skip lesions, abdominal pain, and chronic diarrhea. Colonoscopy showed cobblestoning and biopsy found noncaseating granulomas; treatment with infliximab produced remission. |
C06.405.469.432.500 Crohn Disease |
| CookSafe files | Developer search: find the experimental Python module under x/eugene that implements BM25, dense, and fused retrievers for legal RAG. |
retrievers.py |
Questions asked
Exactly one question type, asked once per sibling set. Verbatim:
def child_question(labels: tuple[str, ...]) -> tuple[Choice, dict[str, str]]:
"""Build the direct-child Choice and its reversible option mapping."""
keys = {f"c{i}": label for i, label in enumerate(labels)}
question = Choice(
instructions="Which direct child category best matches this document?",
criteria=keys,
)
return question, keys
type:Choiceinstructions:Which direct child category best matches this document?criteria:{"c0": "<label 0>", "c1": "<label 1>", ...}— the option keys are synthetic (c0,c1, …) and the child labels are the option descriptions, so the mapping is reversible and label text never has to be a valid key.
The same instructions string is reused at every depth; all the node-specific information lives in criteria.
Combining logic in code
import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typesafe_sdk import Choice, RetryPolicy, TypeSafeClient
HIERARCHIES = load_hierarchies()
MODEL, BEAM_WIDTH, MAX_DEPTH, EPSILON = "jev-1.12", 3, 12, 1e-9
client = TypeSafeClient(
api_key=os.environ["TYPESAFE_API_KEY"],
retry=RetryPolicy(max_retries=5, backoff_initial=1.0, backoff_max=20.0),
)
def choose(state: str, labels: tuple[str, ...]) -> dict[str, float]:
"""Ask one atomic direct-child question and return its distribution."""
if len(labels) == 1:
return {labels[0]: 1.0}
question, keys = child_question(labels)
response = client.system_one(
state=state, questions={"child": question}, model=MODEL
)
probabilities = response.answers["child"].probabilities
return {label: probabilities[key] for key, label in keys.items()}
def extend_candidate(candidate: dict, label: str, probabilities: dict[str, float]) -> dict:
"""Append one edge and recompute its geometric-mean path score."""
is_decision: bool = len(probabilities) > 1
probability_product: float = candidate["probability_product"] * (
max(probabilities[label], EPSILON) if is_decision else 1.0
)
decision_count: int = candidate["decision_count"] + is_decision
return {
"path": candidate["path"] + (label,),
"probability_product": probability_product,
"decision_count": decision_count,
"score": probability_product ** (1 / decision_count) if decision_count else 1.0,
}
The beam itself, trimmed of diagram bookkeeping:
def beam_search(hierarchy) -> dict:
"""Parallel width-three beam search using geometric-mean probability."""
beam = [{"path": (), "probability_product": 1.0, "decision_count": 0, "score": 1.0}]
for _ in range(MAX_DEPTH):
expandable = [c for c in beam if subtree(hierarchy.tree, c["path"])]
finished = [c for c in beam if not subtree(hierarchy.tree, c["path"])]
if not expandable:
break
with ThreadPoolExecutor(max_workers=BEAM_WIDTH) as executor:
distributions = list(
executor.map(
lambda c: choose(
hierarchy.document, tuple(subtree(hierarchy.tree, c["path"]))
),
expandable,
)
)
expanded = []
for candidate, probabilities in zip(expandable, distributions, strict=True):
for label in probabilities:
expanded.append(extend_candidate(candidate, label, probabilities))
beam = sorted(finished + expanded, key=lambda c: c["score"], reverse=True)[:BEAM_WIDTH]
return {"beam": sorted(beam, key=lambda c: c["score"], reverse=True)}
Greedy is the same walk with label = max(probabilities, key=probabilities.get) and no beam.
Metrics.
path_score = product(edge_probabilities) ** (1 / decisions)— used for pruning and comparing paths, length-normalized so shallow and deep leaves compare fairly.separation = top_path_score / second_path_score— reported but not used for pruning. Near1×is ambiguous; a large ratio means clear separation.- Alternatives the cookbook names:
min(top_prob/second_top_prob)optimizes for paths with very clear decisions at every node;exp(mean(log(probs)))avoids precision errors for hierarchies deeper than ~10 layers.
Note that a sibling set of size 1 is not a decision: choose short-circuits to {label: 1.0} with no API call, and decision_count is not incremented.
Results / what the cookbook reports
With BEAM_WIDTH = 3, MAX_DEPTH = 12, model jev-1.12: beam search matched 4 of 4 expected leaves; greedy search matched 2 of 4. Keeping three paths recovered the expected classification for CPC patents and Shopify products.
| Hierarchy | Expected leaf | Greedy leaf | Beam K=3 leaf | Greedy correct | Beam correct |
|---|---|---|---|---|---|
| CPC patents | A01K31/12 Perches for poultry or birds, e.g. roosts |
E99Z99/00 Subject matter not otherwise provided for in this section |
A01K31/12 Perches for poultry or birds, e.g. roosts |
no | yes |
| Shopify products | Cat Window Beds & Perches |
Pet Chairs |
Cat Window Beds & Perches |
no | yes |
| MeSH biomedical subjects | C06.405.469.432.500 Crohn Disease |
C06.405.469.432.500 Crohn Disease |
C06.405.469.432.500 Crohn Disease |
yes | yes |
| CookSafe files | retrievers.py |
retrievers.py |
retrievers.py |
yes | yes |
Four examples is the whole evaluation — treat this as a demonstration, not a benchmark (inferred).
Adapting it to a new domain
- Produce a nested
dictof direct-child menus for your taxonomy (filesystem, org chart, moderation policy, LLM skill catalog, ontology). Fix the sibling order and keep it fixed, since order is part of the question. - Leave
child_questionalone unless your labels need more than a name — if a label is cryptic, put a description incriteriainstead of the bare label. - Tune
BEAM_WIDTH(cost per level scales with it, latency barely does because frontiers run in parallel) andMAX_DEPTHto your deepest path. - Log
separation_ratioper document and route low-separation documents to review.
Gotchas
- Choice option limits. Very wide sibling sets will hit the
Choiceoption ceiling (255 options, per Cookbook: Pre-parsed value extraction and Cookbook: Line-by-line search); split a wide menu into chunks and run the same procedure over the winners (inferred for this cookbook, stated in the others). - Deep trees lose precision. The cookbook explicitly recommends
exp(mean(log(probs)))past ~10 layers;EPSILON = 1e-9floors any zero edge so the product never collapses. - A single-child level is free but invisible — it contributes no decision and no probability.
- MeSH is a DAG, so the same descriptor appears on several paths; the demo expands tree numbers into distinct paths rather than deduplicating.
- Retries matter at this call volume. The client is constructed with
RetryPolicy(max_retries=5, backoff_initial=1.0, backoff_max=20.0). - Install line. This cookbook prints no
pip installline; it importsfrom cooksafe import JsonCacheand readsos.environ["TYPESAFE_API_KEY"]. Sibling cookbooks usepip install ... "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/.cooksafelives on TypeSafe's private index andpypi.typesafe.aireturned 404 publicly as of 2026-09-17, so usepip install typesafe-sdkand reimplementJsonCache— a decorator that memoizes a function's return value into a JSON file keyed on its arguments, so re-running replays cached answers with no API spend.
Related
- Choice questions — probabilities over a fixed option set
- Structured instructions, options, levels, criteria — structured instructions, options, levels, criteria
- Speculative fan-out — running every frontier's question concurrently
- Intent routing — the shallow, one-level version of this
- Cookbook: Classification using confidence — reporting a parent class when the child is uncertain
- Cookbook: Skill suggestion — rank-then-re-read as an alternative to beam search
- Cookbooks overview — the full cookbook catalog
Sources
- raw/docs/cookbooks__hierarchical_classification.md (https://docs.typesafe.ai/cookbooks/hierarchical_classification.md)