Skip to content

typedecide.export.prune

LM-head pruning, slot_map.json, and the savings arithmetic. The numbers are explained in Making the readout fast.

typedecide.export.prune

Vocabulary pruning: slicing the unembedding down to the answer letters.

A typed readout never generates text. It reads the logits at one position and compares at most len(LETTERS) of them. Every other row of the unembedding matrix is computed, copied out of the GPU, and thrown away on every single call.

So we slice the matrix. For Qwen/Qwen3-0.6B the table is 151936 x 1024 = 155,582,464 parameters -- 26.1% of the model's 596,049,920 -- and the returned logits tensor drops from [1, seq, 151936] to [1, seq, K]. In a browser that tensor is copied out of GPU memory on every call, which is the part that actually hurts. docs/SPEED.md carries the full arithmetic and is explicit about which numbers were measured and which were derived.

What a pruned model can no longer do

A pruned model cannot generate text. There is no row for any token other than the kept ones, so sampling, beam search, perplexity, and every other generative use are gone permanently. Re-export from the unpruned checkpoint if you need them.

A pruned model also cannot report what share of the total vocabulary mass landed on the answer letters. The softmax denominator over the full vocabulary no longer exists. The probabilities you get are a softmax over the K kept rows and nothing else, which is exactly what the readout uses -- but it means the diagnostic "the model actually wanted to say something that is not an option" is no longer available from a pruned model. Keep an unpruned copy if that number matters to you.

The correctness trap

Qwen3 at small sizes sets config.tie_word_embeddings = True: lm_head.weight and model.embed_tokens.weight are the same tensor object. Slicing it in place would leave the input embedding with K rows, so every input token id above K would index out of bounds -- or, worse, silently read the wrong row. So we untie first: build a fresh K-row tensor, rebind it to the head only, clear config.tie_word_embeddings and model._tied_weights_keys (otherwise save_pretrained drops the head as a "tied" duplicate), and then verify the input embedding is unchanged before returning. prune_lm_head refuses to return a model that failed that check.

Note that config.vocab_size is deliberately not changed. The input embedding still needs a row for every token id in the prompt; only the output side shrank.

SLOT_MAP_FILENAME module-attribute

SLOT_MAP_FILENAME = 'slot_map.json'

SLOT_MAP_VERSION module-attribute

SLOT_MAP_VERSION = 1

PROBE_STATE module-attribute

PROBE_STATE = "The applicant submitted the form on Tuesday and attached two documents."

PROBE_QUESTION module-attribute

PROBE_QUESTION = 'Which option applies?'

SlotMap dataclass

SlotMap(
    model_id: str,
    separator: str | None,
    letters: str,
    keep: tuple[int, ...],
    original_vocab_size: int | None = None,
)

Which original vocabulary id each row of the pruned head came from.

After pruning, the readout indexes rows 0..K-1. It must not index vocabulary ids any more -- those rows are gone. Anything that consumes a pruned model (the browser runtime, an evaluation harness, a debugging script) needs this file to know that row 2 used to be token 33.

keep[row] -> original token id and token_to_row[token id] -> row are the two directions, and both are written to slot_map.json so the consumer does not have to invert anything.

model_id instance-attribute

model_id: str

separator instance-attribute

separator: str | None

letters instance-attribute

letters: str

keep instance-attribute

keep: tuple[int, ...]

original_vocab_size class-attribute instance-attribute

original_vocab_size: int | None = None

rows property

rows: int

K -- the number of rows the pruned unembedding has.

token_to_row property

token_to_row: dict[int, int]

row_of

row_of(token_id: int) -> int

The pruned row holding token_id's logit.

Source code in src/typedecide/export/prune.py
def row_of(self, token_id: int) -> int:
    """The pruned row holding `token_id`'s logit."""
    try:
        return self.keep.index(token_id)
    except ValueError:
        raise ExportError(
            f"Token id {token_id} is not in this slot map, so the pruned model has no "
            f"row for it. The map covers {list(self.keep)} (letters {self.letters!r}); "
            "a token outside that set was dropped by pruning and cannot be recovered."
        ) from None

token_of

token_of(row: int) -> int

The original vocabulary id that row of the pruned head came from.

Source code in src/typedecide/export/prune.py
def token_of(self, row: int) -> int:
    """The original vocabulary id that `row` of the pruned head came from."""
    if not 0 <= row < len(self.keep):
        raise ExportError(
            f"Row {row} is outside the pruned head, which has {len(self.keep)} rows "
            f"(0..{len(self.keep) - 1})."
        )
    return self.keep[row]

letter_of

letter_of(row: int) -> str

The answer letter row scores.

Source code in src/typedecide/export/prune.py
def letter_of(self, row: int) -> str:
    """The answer letter `row` scores."""
    if not 0 <= row < len(self.letters):
        raise ExportError(
            f"Row {row} is outside the {len(self.letters)} letters this map covers."
        )
    return self.letters[row]

to_dict

to_dict() -> dict[str, Any]

The on-disk shape. JSON object keys are strings, so token_to_row is keyed by the decimal token id -- consumers must parse them back to ints.

Source code in src/typedecide/export/prune.py
def to_dict(self) -> dict[str, Any]:
    """The on-disk shape. JSON object keys are strings, so `token_to_row` is
    keyed by the decimal token id -- consumers must parse them back to ints."""
    return {
        "version": SLOT_MAP_VERSION,
        "model_id": self.model_id,
        "separator": self.separator,
        "letters": self.letters,
        "rows": self.rows,
        "original_vocab_size": self.original_vocab_size,
        "keep": list(self.keep),
        "token_to_row": {str(token): row for row, token in enumerate(self.keep)},
        "can_generate_text": False,
        "note": (
            "Row index, not vocabulary id. A model pruned with this map cannot "
            "generate text and cannot report the share of total vocabulary mass on "
            "the answer letters."
        ),
    }

from_dict classmethod

from_dict(payload: dict[str, Any]) -> SlotMap
Source code in src/typedecide/export/prune.py
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> SlotMap:
    if not isinstance(payload, dict):
        raise ExportError(
            f"A slot map must be a JSON object, got {type(payload).__name__}."
        )
    version = payload.get("version", SLOT_MAP_VERSION)
    if version != SLOT_MAP_VERSION:
        raise ExportError(
            f"This slot map is version {version!r} but this build of typedecide writes "
            f"and reads version {SLOT_MAP_VERSION}. Re-export the model."
        )
    missing = {"model_id", "letters", "keep"} - payload.keys()
    if missing:
        raise ExportError(
            f"A slot map needs {sorted({'model_id', 'letters', 'keep'})}; "
            f"this one is missing {sorted(missing)}."
        )
    keep = payload["keep"]
    if not isinstance(keep, list):
        raise ExportError("A slot map's 'keep' must be a list of token ids in row order.")
    slot_map = build_slot_map(
        keep,
        model_id=str(payload["model_id"]),
        separator=None if payload.get("separator") is None else str(payload["separator"]),
        letters=str(payload["letters"]),
        original_vocab_size=payload.get("original_vocab_size"),
    )
    stored = payload.get("token_to_row")
    if isinstance(stored, dict):
        expected = {str(token): row for row, token in enumerate(slot_map.keep)}
        try:
            actual: dict[str, int] | None = {str(k): int(v) for k, v in stored.items()}
        except (TypeError, ValueError):
            actual = None  # a row that is not a number cannot agree with anything
        if actual != expected:
            raise ExportError(
                "This slot map's 'token_to_row' disagrees with its 'keep' order. One of "
                "the two was edited by hand; the file cannot be trusted. Re-export."
            )
    return slot_map

build_slot_map

build_slot_map(
    keep: Sequence[int],
    *,
    model_id: str,
    separator: str | None,
    letters: str | None = None,
    original_vocab_size: int | None = None
) -> SlotMap

Validate keep and record what it means.

keep is ordered: row i of the pruned head scores letters[i]. Order is preserved exactly as given, never sorted -- sorting would silently permute the answer letters, which is the kind of bug that shows up as a model that is confidently wrong rather than as a crash.

Source code in src/typedecide/export/prune.py
def build_slot_map(
    keep: Sequence[int],
    *,
    model_id: str,
    separator: str | None,
    letters: str | None = None,
    original_vocab_size: int | None = None,
) -> SlotMap:
    """Validate `keep` and record what it means.

    `keep` is ordered: row *i* of the pruned head scores ``letters[i]``. Order is
    preserved exactly as given, never sorted -- sorting would silently permute
    the answer letters, which is the kind of bug that shows up as a model that
    is confidently wrong rather than as a crash.
    """
    if not model_id or not model_id.strip():
        raise ExportError(
            "A slot map must name the model it was built for, so a consumer can refuse a "
            "mismatched pair of weights and map."
        )
    index = validate_keep(keep, vocab_size=original_vocab_size)
    if letters is None:
        if len(index) > len(LETTERS):
            raise ExportError(
                f"Asked to keep {len(index)} rows but only {len(LETTERS)} answer letters "
                f"exist ({LETTERS}). Pass `letters` explicitly if these rows are not "
                "answer letters."
            )
        letters = LETTERS[: len(index)]
    if len(letters) != len(index):
        raise ExportError(
            f"{len(index)} kept rows but {len(letters)} letters ({letters!r}). Each row of "
            "the pruned head scores exactly one letter, so the two must be the same length."
        )
    if len(index) > MAX_OPTIONS:
        log.warning(
            "Keeping %d rows, more than the %d answer letters this library renders. That is "
            "allowed, but nothing in typedecide will read the rows past %d.",
            len(index),
            MAX_OPTIONS,
            MAX_OPTIONS - 1,
        )
    return SlotMap(
        model_id=model_id,
        separator=separator,
        letters=letters,
        keep=tuple(index),
        original_vocab_size=None if original_vocab_size is None else int(original_vocab_size),
    )

validate_keep

validate_keep(
    keep: Sequence[int], *, vocab_size: int | None = None
) -> list[int]

The kept token ids as plain ints, in the order given.

Raises ExportError for an empty set, a non-integer, a negative id, an id at or beyond vocab_size, or a duplicate. A duplicate is not a harmless quirk: two letters would share one row, the readout would tie forever, and nothing downstream would notice.

Source code in src/typedecide/export/prune.py
def validate_keep(keep: Sequence[int], *, vocab_size: int | None = None) -> list[int]:
    """The kept token ids as plain ints, in the order given.

    Raises `ExportError` for an empty set, a non-integer, a negative id, an id at
    or beyond `vocab_size`, or a duplicate. A duplicate is not a harmless quirk:
    two letters would share one row, the readout would tie forever, and nothing
    downstream would notice.
    """
    try:
        index = [_as_index(token) for token in keep]
    except TypeError as error:
        raise ExportError(
            f"Kept token ids must be integers; {error}. They are vocabulary ids from "
            "`answer_letter_token_ids`, not letters or strings."
        ) from error

    if not index:
        raise ExportError(
            "Pruning needs at least one row to keep. An empty `keep` would leave the model "
            "with no output at all."
        )
    if len(index) < MIN_OPTIONS:
        log.warning(
            "Pruning to %d row(s); every criterion this library permits has at least %d "
            "options, so this head cannot answer one.",
            len(index),
            MIN_OPTIONS,
        )

    seen: dict[int, int] = {}
    for position, token in enumerate(index):
        if token < 0:
            raise ExportError(
                f"Token id {token} at position {position} is negative. Vocabulary ids start "
                "at 0; a negative id usually means a tokenizer probe failed and returned a "
                "sentinel."
            )
        if vocab_size is not None and token >= vocab_size:
            raise ExportError(
                f"Token id {token} at position {position} is outside the model's vocabulary "
                f"of {vocab_size} (valid ids are 0..{vocab_size - 1}). The token ids and the "
                "model do not come from the same tokenizer."
            )
        if token in seen:
            detail = ""
            if position < len(LETTERS):
                detail = (
                    f" Letters {LETTERS[seen[token]]!r} and {LETTERS[position]!r} would share "
                    "one row of the pruned head and could never be told apart."
                )
            raise ExportError(
                f"Token id {token} appears twice in `keep`, at positions {seen[token]} and "
                f"{position}. Each kept row must come from a distinct vocabulary id.{detail}"
            )
        seen[token] = position
    return index

write_slot_map

write_slot_map(
    slot_map: SlotMap, destination: Path | str
) -> Path

Write slot_map.json. destination may be the file or the directory holding it.

Source code in src/typedecide/export/prune.py
def write_slot_map(slot_map: SlotMap, destination: Path | str) -> Path:
    """Write `slot_map.json`. `destination` may be the file or the directory holding it."""
    path = Path(destination)
    if path.suffix.lower() != ".json":
        path = path / SLOT_MAP_FILENAME
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(slot_map.to_dict(), indent=2) + "\n", encoding="utf-8")
    log.info("wrote %s (%d rows, letters %s)", path, slot_map.rows, slot_map.letters)
    return path

read_slot_map

read_slot_map(source: Path | str) -> SlotMap

Read slot_map.json. source may be the file or the directory holding it.

Source code in src/typedecide/export/prune.py
def read_slot_map(source: Path | str) -> SlotMap:
    """Read `slot_map.json`. `source` may be the file or the directory holding it."""
    path = Path(source)
    if path.is_dir():
        path = path / SLOT_MAP_FILENAME
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError as error:
        raise ExportError(
            f"No slot map at {path}. A pruned model is unreadable without one, because its "
            "rows are 0..K-1 rather than vocabulary ids. Re-run the export."
        ) from error
    except (json.JSONDecodeError, UnicodeDecodeError) as error:
        raise ExportError(f"{path} is not valid JSON: {error}.") from error
    except OSError as error:
        raise ExportError(f"Could not read the slot map at {path}: {error}.") from error
    return SlotMap.from_dict(payload)

answer_letter_slots

answer_letter_slots(
    tokenizer: Tokenizer, n_options: int
) -> tuple[list[int], str]

The answer-letter token ids and the separator that produced them.

Probes at the real prompt boundary, because whether the natural continuation is " A" or "A" depends on the character before it. Takes a tokenizer object so tests can pass a fake one instead of downloading a real model.

Source code in src/typedecide/export/prune.py
def answer_letter_slots(tokenizer: Tokenizer, n_options: int) -> tuple[list[int], str]:
    """The answer-letter token ids and the separator that produced them.

    Probes at the real prompt boundary, because whether the natural continuation
    is ``" A"`` or ``"A"`` depends on the character before it. Takes a tokenizer
    object so tests can pass a fake one instead of downloading a real model.
    """
    if not MIN_OPTIONS <= n_options <= MAX_OPTIONS:
        raise ExportError(
            f"n_options={n_options} is outside the {MIN_OPTIONS}..{MAX_OPTIONS} options this "
            f"library renders letters for ({LETTERS})."
        )
    criterion = Criterion(
        key="probe",
        question=PROBE_QUESTION,
        options=tuple(
            Option(id=f"opt{i}", description=f"Option {LETTERS[i]}") for i in range(n_options)
        ),
    )
    return answer_slots(tokenizer, full_prompt(PROBE_STATE, criterion), n_options)

answer_letter_token_ids

answer_letter_token_ids(
    model_id: str, n_options: int
) -> list[int]

The vocabulary ids of A.. for model_id, in letter order.

This is the keep list for prune_lm_head. Loading the tokenizer needs transformers.

Source code in src/typedecide/export/prune.py
def answer_letter_token_ids(model_id: str, n_options: int) -> list[int]:
    """The vocabulary ids of `A`..  for `model_id`, in letter order.

    This is the `keep` list for `prune_lm_head`. Loading the tokenizer needs
    `transformers`.
    """
    transformers = _require("transformers")
    try:
        tokenizer = transformers.AutoTokenizer.from_pretrained(model_id)
    except Exception as error:  # noqa: BLE001 - hub/network/config failures are all the same here
        raise ExportError(
            f"Could not load the tokenizer for {model_id!r}: {error}. The answer-letter ids "
            "come from the tokenizer, so the export cannot continue without it."
        ) from error
    slots, separator = answer_letter_slots(tokenizer, n_options)
    log.info(
        "answer letters for %s: %s (separator %r)", model_id, slots[:n_options], separator
    )
    return slots

select_rows

select_rows(weight: Any, keep: Sequence[int]) -> Any

Rows keep of weight, in that order, as a fresh object.

Works on a torch tensor and on plain nested lists, which is what lets the untie logic be tested without torch installed. The result is always a copy: a view onto a tied embedding would defeat the whole point of untying.

Source code in src/typedecide/export/prune.py
def select_rows(weight: Any, keep: Sequence[int]) -> Any:
    """Rows `keep` of `weight`, in that order, as a fresh object.

    Works on a torch tensor and on plain nested lists, which is what lets the
    untie logic be tested without torch installed. The result is always a copy:
    a view onto a tied embedding would defeat the whole point of untying.
    """
    index = [int(i) for i in keep]
    if hasattr(weight, "detach") and hasattr(weight, "index_select"):
        # torch: advanced indexing already copies, clone() makes that a promise.
        return weight.detach()[index].clone()
    return [list(weight[i]) for i in index]

prune_lm_head

prune_lm_head(model: Any, keep: Sequence[int]) -> Any

Replace the unembedding with the keep rows of itself, and return model.

After this the model emits [batch, seq, len(keep)] logits. Row i holds what used to be the logit of token keep[i]; pair the model with the SlotMap that build_slot_map makes from the same keep, because nothing in the weights records it.

The pruned model cannot generate text. Rows for every other token are gone. It also cannot report the share of total vocabulary mass that landed on the answer letters -- the full-vocabulary softmax denominator no longer exists, so probabilities are a softmax over the K kept rows only.

Tied embeddings are handled by untying first. Qwen3 ties at 0.6B, 1.7B and 4B, so lm_head.weight is embed_tokens.weight; slicing it in place would corrupt the input embedding and every prompt token id above K would read the wrong row or fail. We rebind the head to a fresh tensor, clear config.tie_word_embeddings and model._tied_weights_keys so save_pretrained does not drop the new head as a tied duplicate, and then check the input embedding still has its original rows and values. If it does not, we raise rather than hand back a quietly broken model.

config.vocab_size is left alone on purpose: the input side still needs a row per token id.

Source code in src/typedecide/export/prune.py
def prune_lm_head(model: Any, keep: Sequence[int]) -> Any:
    """Replace the unembedding with the `keep` rows of itself, and return `model`.

    After this the model emits ``[batch, seq, len(keep)]`` logits. Row *i* holds
    what used to be the logit of token ``keep[i]``; pair the model with the
    `SlotMap` that `build_slot_map` makes from the same `keep`, because nothing
    in the weights records it.

    **The pruned model cannot generate text.** Rows for every other token are
    gone. It also **cannot report the share of total vocabulary mass** that landed
    on the answer letters -- the full-vocabulary softmax denominator no longer
    exists, so probabilities are a softmax over the K kept rows only.

    Tied embeddings are handled by untying first. Qwen3 ties at 0.6B, 1.7B and 4B, so
    ``lm_head.weight is embed_tokens.weight``; slicing it in place would corrupt
    the *input* embedding and every prompt token id above K would read the wrong
    row or fail. We rebind the head to a fresh tensor, clear
    ``config.tie_word_embeddings`` and ``model._tied_weights_keys`` so
    ``save_pretrained`` does not drop the new head as a tied duplicate, and then
    check the input embedding still has its original rows and values. If it does
    not, we raise rather than hand back a quietly broken model.

    ``config.vocab_size`` is left alone on purpose: the input side still needs a
    row per token id.
    """
    head = _output_embeddings(model)
    inputs = _input_embeddings(model)
    head_rows = _row_count(head.weight)
    index = validate_keep(keep, vocab_size=head_rows)

    config = getattr(model, "config", None)
    declared_tied = bool(getattr(config, "tie_word_embeddings", False))
    shared_storage = _shares_storage(head.weight, None if inputs is None else inputs.weight)
    if declared_tied and inputs is not None and not shared_storage:
        log.warning(
            "config.tie_word_embeddings is True but the head and the input embedding are "
            "separate tensors. Untying anyway; the config was stale."
        )

    input_rows_before = None if inputs is None else _row_count(inputs.weight)
    input_fingerprint_before = None if inputs is None else _row_fingerprint(inputs.weight, index[0])

    # Rebind rather than mutate. Mutating would reach through to the input
    # embedding whenever the two are tied, which is the whole trap.
    head.weight = _as_parameter(select_rows(head.weight, index))
    if getattr(head, "bias", None) is not None:
        head.bias = _as_parameter(_select_entries(head.bias, index))
    if hasattr(head, "out_features"):
        head.out_features = len(index)

    if config is not None:
        config.tie_word_embeddings = False
    if getattr(model, "_tied_weights_keys", None):
        # Left set, save_pretrained treats lm_head.weight as a duplicate of the
        # input embedding and omits it, and the reloaded model has no head.
        model._tied_weights_keys = []

    _verify_untied(
        model,
        head,
        inputs,
        index,
        input_rows_before=input_rows_before,
        input_fingerprint_before=input_fingerprint_before,
        was_tied=declared_tied or shared_storage,
    )

    log.info(
        "pruned the unembedding from %d rows to %d (%s), input embedding left at %s rows",
        head_rows,
        len(index),
        "untied first" if (declared_tied or shared_storage) else "already untied",
        input_rows_before,
    )
    return model

lm_head_savings

lm_head_savings(
    vocab_size: int,
    hidden_size: int,
    kept_rows: int,
    *,
    tied: bool = False,
    bits_per_weight: float = 4.5,
    logit_bytes: int = 4
) -> dict[str, float]

The arithmetic behind every size and speed claim in docs/SPEED.md.

bits_per_weight defaults to 4.5: 4-bit weights plus one fp16 scale per block of 32, which is what quantize(mode="q4f16") produces.

When tied is true, weight_bytes_removed is zero. That is not a bug and it is the single most misunderstood thing about this optimisation: a tied model has one table serving both the input embedding and the unembedding, the input side still needs every row, so the table cannot leave the file. The win on a tied model is the matmul and the logits tensor, not the download.

Source code in src/typedecide/export/prune.py
def lm_head_savings(
    vocab_size: int,
    hidden_size: int,
    kept_rows: int,
    *,
    tied: bool = False,
    bits_per_weight: float = 4.5,
    logit_bytes: int = 4,
) -> dict[str, float]:
    """The arithmetic behind every size and speed claim in `docs/SPEED.md`.

    `bits_per_weight` defaults to 4.5: 4-bit weights plus one fp16 scale per
    block of 32, which is what `quantize(mode="q4f16")` produces.

    When `tied` is true, `weight_bytes_removed` is **zero**. That is not a bug
    and it is the single most misunderstood thing about this optimisation: a
    tied model has one table serving both the input embedding and the
    unembedding, the input side still needs every row, so the table cannot leave
    the file. The win on a tied model is the matmul and the logits tensor, not
    the download.
    """
    for name, value in (("vocab_size", vocab_size), ("hidden_size", hidden_size)):
        if value <= 0:
            raise ExportError(f"{name} must be positive, got {value}.")
    if not 0 < kept_rows <= vocab_size:
        raise ExportError(
            f"kept_rows must be between 1 and vocab_size ({vocab_size}), got {kept_rows}."
        )
    head_parameters = vocab_size * hidden_size
    kept_parameters = kept_rows * hidden_size
    removed = head_parameters - kept_parameters
    return {
        "head_parameters": float(head_parameters),
        "kept_parameters": float(kept_parameters),
        "removed_parameters": float(removed),
        "weight_bytes_removed": 0.0 if tied else removed * bits_per_weight / 8.0,
        "logit_bytes_per_position_before": float(vocab_size * logit_bytes),
        "logit_bytes_per_position_after": float(kept_rows * logit_bytes),
        "matmul_flops_per_position_before": float(2 * hidden_size * vocab_size),
        "matmul_flops_per_position_after": float(2 * hidden_size * kept_rows),
    }