Publish validated AMOS Core AI conversion with source provenance and runtime adapter
ebef316 verified Download serve/amos/decoder.py from maxffarrell/amos-1-small-CoreAI: direct link, hf CLI and curl.
- Browser
- Download file 43.4 kB
-
https://huggingface.co/maxffarrell/amos-1-small-CoreAI/resolve/main/serve/amos/decoder.py
- Command line
-
hf download hf://maxffarrell/amos-1-small-CoreAI/serve/amos/decoder.py
-
curl -L -o decoder.py https://huggingface.co/maxffarrell/amos-1-small-CoreAI/resolve/main/serve/amos/decoder.py
43.4 kB
| """Cut from amos/decoder.py: only the definitions amos.serve reaches (see sources.json).""" | |
| import argparse | |
| import glob | |
| import hashlib | |
| import itertools | |
| import json | |
| import math | |
| import os | |
| import random | |
| import shutil | |
| import string | |
| import time | |
| from pathlib import Path | |
| import torch | |
| ANSWER = "<answer>" | |
| IMAGE_PAD = "<|image_pad|>" | |
| LETTERS = string.ascii_uppercase + string.ascii_lowercase # the first 52 option codes; runs before amos_codes use these alone | |
| KINDS = ("noul", "choice", "score") # answered by option letters | |
| HEADED = ("span", "region") # answered by Heads | |
| SPAN_LENGTH_PENALTY = 1.6 | |
| SPAN_CAP = 30 | |
| HINTS = {"score": " (levels from lowest to highest)", "span": " (quote the case)", "region": " (point to it in the image)"} | |
| IMAGE_PIXELS = int(os.environ.get("AMOS_IMAGE_PIXELS", 1 << 20)) | |
| HEADS = "heads.pt" | |
| REGION_KEEP = 0.5 # a point's box: cells joined to the peak holding this share of its probability (vision.peak_box) | |
| SYSTEM = ("You make decisions about a case. Read the case, then answer every question with the letter of the option " | |
| "that is most likely right.") | |
| def options(q: dict) -> list[str]: | |
| """Option lines in answer order; yes/no is yes first, so its target is [P(yes), P(no)].""" | |
| if q["type"] == "noul": | |
| c = q.get("criteria") or {} | |
| return [f"yes: {c['true']}" if c.get("true") else "yes", f"no: {c['false']}" if c.get("false") else "no"] | |
| return list(q["options"]) | |
| def render(state: str, questions: list[dict], images=(), codes=LETTERS) -> list[dict]: | |
| """Chat messages for a case. Images (the state's after "Case:", image options where they are listed, a pick-any | |
| option's image after its question) make the user message a list of text and image items; without any it is plain | |
| text.""" | |
| parts = ["Case:\n", *images, f"{state}\n\nQuestions:\n"] | |
| for i, q in enumerate(questions, start=1): | |
| parts += ["\n" if i > 1 else "", f"q{i}. {q['text']}" + HINTS.get(q["type"], "")] | |
| if "subject" in q: | |
| parts += [" ", q["subject"]] | |
| for j, o in enumerate(options(q) if q["type"] in KINDS else ()): | |
| parts += [f"\n{codes[j]}) ", o] | |
| merged = [] | |
| for x in parts: | |
| if isinstance(x, str) and merged and isinstance(merged[-1], str): | |
| merged[-1] += x | |
| else: | |
| merged.append(x) | |
| user = merged[0] if len(merged) == 1 else [{"type": "text", "text": x} if isinstance(x, str) else {"type": "image", "image": x} | |
| for x in merged if x != ""] | |
| answers = "\n".join(f"q{i}: {ANSWER}" for i in range(1, len(questions) + 1)) | |
| reply = {"role": "assistant", "content": answers} | |
| if len(questions) == 1 and questions[0].get("reasoning"): # a trace goes in the thinking block, as think() reads | |
| reply["reasoning_content"] = questions[0]["reasoning"] | |
| return [{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}, reply] | |
| def expand(questions: list[dict]) -> tuple[list[dict], list[int]]: | |
| """Pick-any questions become one yes/no per option ("subject") in the same prompt, each with its own answer -> | |
| (questions, index of each one's original question).""" | |
| flat, owner = [], [] | |
| for k, q in enumerate(questions): | |
| if q["type"] != "multi": | |
| flat.append(q) | |
| owner.append(k) | |
| continue | |
| for j, o in enumerate(q["options"]): | |
| flat.append({"type": "noul", "text": q["text"] + " Does this option apply?", "subject": o, | |
| **({"target": q["target"][j]} if "target" in q else {})}) | |
| owner.append(k) | |
| return flat, owner | |
| PLAIN_TEMPLATE = ("{% for m in messages %}{{ m['role'] }}:\n{% if m['content'] is string %}{{ m['content'] }}{% else %}" | |
| "{% for x in m['content'] %}{{ x['text'] if x['type'] == 'text' else '' }}{% endfor %}{% endif %}" | |
| "\n\n{% endfor %}") | |
| def option_codes(tokenizer, n: int) -> list[str]: | |
| """The first n option codes: the 52 letters, then two-letter codes the tokenizer keeps as one token.""" | |
| two = ("".join(p) for p in itertools.product(string.ascii_uppercase, repeat=2)) | |
| codes = list(LETTERS)[:n] + [c for c in two if len(tokenizer.encode(c, add_special_tokens=False)) == 1][:max(0, n - 52)] | |
| if len(codes) != n: | |
| raise ValueError(f"the tokenizer has only {len(codes)} one-token option codes, not {n}") | |
| return codes | |
| class Codec: | |
| def __init__(self, tokenizer, image_processor=None, n_codes: int = len(LETTERS)): | |
| self.tok, self.image_processor = tokenizer, image_processor | |
| if getattr(tokenizer, "chat_template", "") is None: | |
| tokenizer.chat_template = PLAIN_TEMPLATE | |
| if ANSWER not in tokenizer.get_added_vocab(): | |
| tokenizer.add_special_tokens({"additional_special_tokens": [ANSWER]}) | |
| self.marker = tokenizer.convert_tokens_to_ids(ANSWER) | |
| self.codes = option_codes(tokenizer, n_codes) | |
| ids = [tokenizer.encode(x, add_special_tokens=False) for x in self.codes] | |
| if any(len(i) != 1 for i in ids): | |
| raise ValueError("every option letter must be one token") | |
| self.letters = [i[0] for i in ids] | |
| self.pad = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id | |
| # A unified model (Gemma 4) comes with its whole processor, which turns each image placeholder into its own run | |
| # of tokens; Qwen comes with an image processor alone, and the placeholder is expanded here | |
| self.unified = image_processor is not None and hasattr(image_processor, "image_token") | |
| pad = image_processor.image_token if self.unified else IMAGE_PAD | |
| self.image_pad = tokenizer.convert_tokens_to_ids(pad) if image_processor is not None else None | |
| self.echo = False # set by load_heads when the quote head was trained on an echoed case (see encode_case) | |
| # the tokenizer's added tokens (the answer marker, chat roles, <think>, image placeholders) as user text would | |
| # tokenize as the tokens themselves and break the prompt; defuse() swaps their "<" for a look-alike | |
| self.specials = sorted((t for t in tokenizer.get_added_vocab() if t.startswith("<")), key=len, reverse=True) | |
| def defuse(self, text): | |
| """User text with any added-token string made ordinary text; same length, so character offsets still hold.""" | |
| if not isinstance(text, str) or "<" not in text: | |
| return text | |
| for t in self.specials: | |
| text = text.replace(t, "‹" + t[1:]) | |
| return text | |
| def safe(self, state: str, questions: list[dict]) -> tuple[str, list[dict]]: | |
| """state and questions with every user-written string defused (images and other values pass through).""" | |
| d = self.defuse | |
| qs = [{**q, **{k: d(q[k]) for k in ("text", "subject") if k in q}, | |
| **({"options": [d(o) for o in q["options"]]} if "options" in q else {}), | |
| **({"criteria": {k: d(v) for k, v in q["criteria"].items()}} if isinstance(q.get("criteria"), dict) else {})} | |
| for q in questions] | |
| return d(state), qs | |
| def encode(self, state: str, questions: list[dict]) -> tuple[list[int], list[int]]: | |
| """-> (token ids, the position whose next-token logits answer each question).""" | |
| state, questions = self.safe(state, questions) | |
| text = self.tok.apply_chat_template(render(state, questions, codes=self.codes), tokenize=False, | |
| enable_thinking=False) | |
| ids = self.tok(text, add_special_tokens=False)["input_ids"] | |
| at = [i - 1 for i, t in enumerate(ids) if t == self.marker] | |
| if len(at) != len(questions): | |
| raise ValueError("one answer marker per question") | |
| return ids, at | |
| def reason_span(self, ids: list[int]) -> tuple[int, int]: | |
| """The tokens inside the thinking block, (first, end): the trace a reasoning record teaches the model to write.""" | |
| start, end = (self.tok.convert_tokens_to_ids(t) for t in ("<think>", "</think>")) | |
| return ids.index(start) + 1, ids.index(end) | |
| def encode_case(self, state: str, questions: list[dict], images=()) -> tuple[list[int], list[int], dict]: | |
| """-> (token ids, answer positions, extra); without images the ids are exactly encode()'s. extra["state"] is | |
| (first state token, each state token's char offsets in the state). With images extra also holds the | |
| image processor's pixel_values and image_grid_thw, and "images": (first token, rows, cols) of each image's | |
| merged token grid; each image placeholder is expanded to its token count, as the model's processor does.""" | |
| state, questions = self.safe(state, questions) | |
| messages = render(state, questions, images, self.codes) | |
| user = messages[1]["content"] | |
| pics = [] if isinstance(user, str) else [x["image"] for x in user if x["type"] == "image"] | |
| text = self.tok.apply_chat_template(messages, tokenize=False, enable_thinking=False) | |
| enc = self.tok(text, add_special_tokens=False, return_offsets_mapping=True) | |
| ids, spans = enc["input_ids"], enc["offset_mapping"] | |
| # an empty state has no text to find (and Gemma's template trims the newlines that would surround it) | |
| s0 = text.index(state + "\n\nQuestions:\n") if state else 0 | |
| s1 = s0 + len(state) | |
| toks = [i for i, (a, b) in enumerate(spans) if a < s1 and b > s0] | |
| offsets = [(max(spans[i][0], s0) - s0, min(spans[i][1], s1) - s0) for i in toks] | |
| where, extra = list(range(len(ids))), {} | |
| if pics and self.unified: | |
| ids, where, extra = self._unified_images(text, ids, pics) | |
| elif pics: | |
| if self.image_processor is None: | |
| raise ValueError("this model has no vision tower") | |
| ip = self.image_processor | |
| v = ip(images=[p.convert("RGB") for p in pics], max_pixels=IMAGE_PIXELS, return_tensors="pt") | |
| grid, m = v["image_grid_thw"], ip.merge_size | |
| counts, out, where, starts = iter((grid.prod(-1) // m**2).tolist()), [], [], [] | |
| for t in ids: | |
| where.append(len(out)) | |
| if t == self.image_pad: | |
| starts.append(len(out)) | |
| out += [t] * next(counts) | |
| else: | |
| out.append(t) | |
| if len(starts) != len(pics): | |
| raise ValueError("one image placeholder per image") | |
| ids = out | |
| extra = {"pixel_values": v["pixel_values"], "image_grid_thw": grid, | |
| "images": [(s, int(g[1]) // m, int(g[2]) // m) for s, g in zip(starts, grid)]} | |
| extra["state"] = (where[toks[0]] if toks else 0, offsets) | |
| at = [i - 1 for i, t in enumerate(ids) if t == self.marker] | |
| if len(at) != len(questions): | |
| raise ValueError("one answer marker per question") | |
| if self.echo and toks and any(q["type"] == "span" for q in questions): | |
| # Echo: the case again after every answer marker, and quotes are read from this copy. Reading left to | |
| # right, a case token cannot see what follows it ("Total due" after a line item), but every copied token | |
| # has seen the whole case and each question. Attention only looks back, so no earlier answer changes. | |
| sep = self.tok("\n\nThe case again:\n", add_special_tokens=False)["input_ids"] | |
| copy = self.tok(state, add_special_tokens=False, return_offsets_mapping=True) | |
| extra["state"] = (len(ids) + len(sep), [tuple(o) for o in copy["offset_mapping"]]) | |
| ids = ids + sep + copy["input_ids"] | |
| return ids, at, extra | |
| def _unified_images(self, text: str, ids: list[int], pics) -> tuple[list[int], list[int], dict]: | |
| """A unified model's processor expands each image placeholder into <|image>, its image tokens, <image|>; -> | |
| (expanded ids, each original token's new position, the model's image inputs). The images have no separate | |
| grid of merged tokens here, so "images" is empty and the point and box heads do not apply.""" | |
| ip = self.image_processor | |
| v = ip(text=text, images=[p.convert("RGB") for p in pics], return_tensors="pt", add_special_tokens=False) | |
| new = v["input_ids"][0].tolist() | |
| run = {self.image_pad, self.tok.convert_tokens_to_ids(ip.boi_token), self.tok.convert_tokens_to_ids(ip.eoi_token)} | |
| where, j = [], 0 | |
| for t in ids: | |
| where.append(j) | |
| if t == self.image_pad: | |
| while j < len(new) and new[j] in run: | |
| j += 1 | |
| elif j < len(new) and new[j] == t: | |
| j += 1 | |
| else: | |
| raise ValueError("the processor changed a text token") | |
| if j != len(new): | |
| raise ValueError("the processor added tokens after the text") | |
| return new, where, {"pixel_values": v["pixel_values"], "image_position_ids": v["image_position_ids"], | |
| "mm_token_type_ids": v["mm_token_type_ids"][0], "images": []} | |
| def load_model(path: str, device: str, *, dtype=None, vision=None): | |
| """-> (model, codec). vision=None loads the image-text model when the checkpoint has a vision tower and an image | |
| processor config beside it, else the text model alone. Qwen's image processor runs on PIL, never torchvision | |
| (not installed, and the Auto classes demand it), so training and serving resize alike on every machine.""" | |
| from transformers import ( | |
| AutoConfig, | |
| AutoModelForCausalLM, | |
| AutoModelForImageTextToText, | |
| AutoTokenizer, | |
| Qwen2VLImageProcessorPil, | |
| ) | |
| if device == "cuda": # as in amos.train: cuDNN attention fails to load in bf16 with the pip CUDA wheels | |
| torch.backends.cuda.enable_cudnn_sdp(False) | |
| tok = AutoTokenizer.from_pretrained(path) | |
| config = AutoConfig.from_pretrained(path) | |
| unified = config.model_type.startswith("gemma4") # images through its own processor (which needs torchvision) | |
| if vision is None: | |
| vision = hasattr(config, "vision_config") and (Path(path) / ("processor_config.json" if unified else | |
| "preprocessor_config.json")).exists() | |
| processor = None | |
| if vision: | |
| from transformers import AutoProcessor | |
| processor = AutoProcessor.from_pretrained(path) if unified else Qwen2VLImageProcessorPil.from_pretrained(path) | |
| codec = Codec(tok, processor, getattr(config, "amos_codes", len(LETTERS))) | |
| if dtype is None: | |
| dtype = torch.float32 if getattr(config, "amos_export_dtype", None) == "float32" else torch.bfloat16 | |
| from transformers.models.auto.modeling_auto import MODEL_FOR_CAUSAL_LM_MAPPING_NAMES | |
| # some image-text models (Muse Glimmer) have no causal-LM class: load the whole model, its vision tower unused | |
| causal = config.model_type in MODEL_FOR_CAUSAL_LM_MAPPING_NAMES | |
| model = (AutoModelForImageTextToText if vision or not causal else AutoModelForCausalLM).from_pretrained( | |
| path, config=config, dtype=dtype) | |
| if vision and not unified: | |
| patchify_as_matmul(model.model.visual.patch_embed) | |
| if model.get_input_embeddings().weight.shape[0] < len(tok): | |
| model.resize_token_embeddings(len(tok), mean_resizing=True) | |
| return model.to(device), codec | |
| def patchify_as_matmul(patch_embed): | |
| """Qwen's patch embedding is a conv3d whose kernel is the whole flattened patch, so it is one matmul with the same | |
| weights; run it as one. cuDNN's conv3d fails to load on some hosts and PyTorch's own takes ~5 s per photo.""" | |
| proj = patch_embed.proj | |
| patch_embed.forward = lambda x: torch.nn.functional.linear(x.to(proj.weight.dtype), proj.weight.flatten(1), proj.bias) | |
| def noncausal(model) -> bool: | |
| """Whether the run was trained with bidirectional full attention (train --noncausal), so it must be read that way.""" | |
| return bool(getattr(model.config, "amos_noncausal", False)) | |
| def bidirectional_mask(lengths: list[int], width: int, device) -> torch.Tensor: | |
| """[B, 1, L, L], True where the key is one of its row's real tokens: every query sees the whole case, never pads.""" | |
| keys = torch.arange(width, device=device) < torch.tensor(lengths, device=device)[:, None] | |
| return keys[:, None, None, :].expand(-1, 1, width, -1) | |
| PREFILL_CHUNK = 16384 | |
| def chunked_hidden(decoder, x: torch.Tensor) -> torch.Tensor: | |
| """Last hidden states [1, L, d] for one causal text row, read PREFILL_CHUNK tokens at a time.""" | |
| from transformers import DynamicCache | |
| cache, out = DynamicCache(config=decoder.config), [] | |
| for s in range(0, x.shape[1], PREFILL_CHUNK): | |
| out.append(decoder(input_ids=x[:, s : s + PREFILL_CHUNK], past_key_values=cache, use_cache=True).last_hidden_state) | |
| del cache | |
| return torch.cat(out, 1) | |
| def layerwise_hidden(decoder, x: torch.Tensor) -> torch.Tensor: | |
| """Last hidden states [1, L, d] for one noncausal text row, read a layer at a time. | |
| A noncausal run's full-attention layers let every token see the whole case, so it cannot be read in chunks the way | |
| a causal one can. Instead the whole case goes through each layer before the next, PREFILL_CHUNK tokens at a time: | |
| a linear-attention layer is a forward recurrence, so its chunks carry its conv and recurrent state; a full-attention | |
| layer first takes keys and values for every token, then each chunk of queries attends to all of them. The same | |
| computation as one pass, holding one layer's activations for one chunk instead of every layer's for the whole case.""" | |
| import torch.nn.functional as F | |
| from transformers import DynamicCache | |
| from transformers.models.qwen3_5.modeling_qwen3_5 import apply_rotary_pos_emb | |
| n, C = x.shape[1], PREFILL_CHUNK | |
| h = decoder.embed_tokens(x) | |
| pos = torch.arange(n, device=x.device).view(1, 1, -1).expand(4, 1, -1) | |
| cos, sin = decoder.rotary_emb(h, pos[1:]) | |
| text_pos, cache = pos[0], DynamicCache(config=decoder.config) | |
| for i, layer in enumerate(decoder.layers[: decoder.config.num_hidden_layers]): | |
| if decoder.config.layer_types[i] == "linear_attention": | |
| for s in range(0, n, C): # each chunk is overwritten after it is read; later chunks are still this layer's input | |
| h[:, s : s + C] = layer(h[:, s : s + C], position_embeddings=(cos[:, s : s + C], sin[:, s : s + C]), | |
| attention_mask=None, position_ids=text_pos[:, s : s + C], past_key_values=cache) | |
| continue | |
| at = layer.self_attn | |
| keys, values = [], [] | |
| for s in range(0, n, C): | |
| hn = layer.input_layernorm(h[:, s : s + C]) | |
| shape = (*hn.shape[:-1], -1, at.head_dim) | |
| k = at.k_norm(at.k_proj(hn).view(shape)).transpose(1, 2) | |
| _, k = apply_rotary_pos_emb(k, k, cos[:, s : s + C], sin[:, s : s + C]) | |
| keys.append(k) | |
| values.append(at.v_proj(hn).view(shape).transpose(1, 2)) | |
| keys, values = torch.cat(keys, 2), torch.cat(values, 2) | |
| for s in range(0, n, C): | |
| res = h[:, s : s + C] | |
| hn = layer.input_layernorm(res) | |
| shape = hn.shape[:-1] | |
| q, gate = torch.chunk(at.q_proj(hn).view(*shape, -1, at.head_dim * 2), 2, dim=-1) | |
| q = at.q_norm(q).transpose(1, 2) | |
| q, _ = apply_rotary_pos_emb(q, q, cos[:, s : s + C], sin[:, s : s + C]) | |
| o = F.scaled_dot_product_attention(q, keys, values, scale=at.scaling, enable_gqa=True) | |
| o = at.o_proj(o.transpose(1, 2).reshape(*shape, -1) * torch.sigmoid(gate.reshape(*shape, -1))) | |
| mid = res + o | |
| h[:, s : s + C] = mid + layer.mlp(layer.post_attention_layernorm(mid)) | |
| del keys, values | |
| del cache | |
| for s in range(0, n, C): | |
| h[:, s : s + C] = decoder.norm(h[:, s : s + C]) | |
| return h | |
| def hidden(model, codec, batch: list[tuple]) -> torch.Tensor: | |
| """Last hidden states [B, L, d] for a batch of (ids, answer positions, questions[, extra]). | |
| Sequences are right-padded without a mask: the model is causal (its linear-attention layers are recurrences that | |
| only run forward), so padding after a sequence cannot change anything read from inside it. A noncausal run's | |
| full-attention layers see the whole sequence, so they get a mask hiding the pads; the recurrences stay causal. | |
| Entries with images go through the image-text model, which fills image tokens from the vision tower and gives | |
| them 2-D positions.""" | |
| width = max(len(e[0]) for e in batch) | |
| x = torch.full((len(batch), width), codec.pad, dtype=torch.long, device=model.device) | |
| for b, e in enumerate(batch): | |
| x[b, : len(e[0])] = torch.tensor(e[0], device=model.device) | |
| vis = [e[3] for e in batch if len(e) > 3 and "pixel_values" in e[3]] | |
| kw = {} if not noncausal(model) else {"attention_mask": { | |
| "full_attention": bidirectional_mask([len(e[0]) for e in batch], width, x.device), "linear_attention": None}} | |
| if not vis and len(batch) == 1 and width > PREFILL_CHUNK: | |
| return layerwise_hidden(model.get_decoder(), x) if kw else chunked_hidden(model.get_decoder(), x) | |
| if not vis: | |
| return model.get_decoder()(input_ids=x, **kw).last_hidden_state | |
| if codec.unified and kw: | |
| raise ValueError("noncausal attention is built for Qwen3.5's layer types, not Gemma 4's") | |
| if codec.unified: # Gemma 4: image patches go through the same layers, placed by their token types and positions | |
| types = torch.zeros_like(x) | |
| for b, e in enumerate(batch): | |
| if len(e) > 3 and "mm_token_type_ids" in e[3]: | |
| types[b, : len(e[3]["mm_token_type_ids"])] = e[3]["mm_token_type_ids"].to(x.device) | |
| return model.model(input_ids=x, mm_token_type_ids=types, | |
| pixel_values=torch.cat([v["pixel_values"] for v in vis]).to(model.device, model.dtype), | |
| image_position_ids=torch.cat([v["image_position_ids"] for v in vis]).to(model.device) | |
| ).last_hidden_state | |
| grid, types = torch.cat([v["image_grid_thw"] for v in vis]).to(model.device), (x == codec.image_pad).int() | |
| if kw: # its rope index would read a dict mask as per-row padding, so the positions come unmasked, as causal | |
| kw["position_ids"] = model.model.get_rope_index(x, types, image_grid_thw=grid)[0] | |
| return model.model(input_ids=x, pixel_values=torch.cat([v["pixel_values"] for v in vis]).to(model.device), | |
| image_grid_thw=grid, mm_token_type_ids=types, **kw).last_hidden_state | |
| def finish_logits(model, logits: torch.Tensor) -> torch.Tensor: | |
| """The model's own last step from raw output-layer logits, as its forward would apply it: Granite divides by | |
| logits_scaling, Gemma caps with final_logit_softcapping. Qwen has neither, so its logits pass unchanged.""" | |
| cfg = getattr(model.config, "text_config", None) or model.config | |
| if scaling := getattr(cfg, "logits_scaling", None): | |
| logits = logits / scaling | |
| if cap := getattr(cfg, "final_logit_softcapping", None): | |
| logits = cap * torch.tanh(logits / cap) | |
| return logits | |
| def distributions(model, codec, batch: list[tuple], h: torch.Tensor | None = None) -> list[torch.Tensor]: | |
| """Log-probabilities over the option letters of each yes/no, choice and score question in a batch of (ids, answer | |
| positions, questions[, extra]), in order. Only the 52 letter logits are computed, from the tied output | |
| embeddings.""" | |
| h = hidden(model, codec, batch) if h is None else h | |
| picks = [(b, p, q) for b, e in enumerate(batch) for p, q in zip(e[1], e[2]) if q["type"] in KINDS] | |
| if not picks: | |
| return [] | |
| rows = torch.tensor([b for b, _, _ in picks], device=h.device) | |
| cols = torch.tensor([p for _, p, _ in picks], device=h.device) | |
| logits = finish_logits(model, h[rows, cols].float() @ model.get_output_embeddings().weight[codec.letters].float().T) | |
| return [logits[k, : len(options(q))].log_softmax(-1) for k, (_, _, q) in enumerate(picks)] | |
| class BoxHead(torch.nn.Module): | |
| """A point's box, predicted rather than grown from cells: reads the question's vector, the image features pooled | |
| by the point head's own cell distribution, and that distribution's centre and spread, and moves the centre and | |
| sets the size. Cells alone draw a box around only the few cells the point head is sure of (a car's tail light | |
| rather than the car); this head is trained on overlap with the whole object.""" | |
| def __init__(self, d: int, width: int = 512): | |
| super().__init__() | |
| self.net = torch.nn.Sequential(torch.nn.Linear(2 * d + 4, width), torch.nn.GELU(), torch.nn.Linear(width, 4)) | |
| torch.nn.init.zeros_(self.net[-1].weight) # starts as a half-image box on the cells' centre | |
| torch.nn.init.zeros_(self.net[-1].bias) | |
| def forward(self, hq: torch.Tensor, cells: torch.Tensor, logits: torch.Tensor, rows: int, cols: int) -> torch.Tensor: | |
| p = logits.detach().float().softmax(-1) # the point head stays as it is; only the box learns | |
| ys, xs = torch.meshgrid((torch.arange(rows, device=p.device) + 0.5) / rows, | |
| (torch.arange(cols, device=p.device) + 0.5) / cols, indexing="ij") | |
| xs, ys = xs.flatten(), ys.flatten() | |
| mx, my = (p * xs).sum(), (p * ys).sum() | |
| sx, sy = (p * (xs - mx) ** 2).sum().sqrt(), (p * (ys - my) ** 2).sum().sqrt() | |
| pooled = (p[:, None] * cells.float()).sum(0) | |
| o = self.net(torch.cat([hq.float(), pooled, torch.stack([mx, my, sx, sy])]).to(self.net[0].weight.dtype)).float() | |
| cx, cy = (mx + 0.5 * torch.tanh(o[0])).clamp(0, 1), (my + 0.5 * torch.tanh(o[1])).clamp(0, 1) | |
| w, hgt = torch.sigmoid(o[2]), torch.sigmoid(o[3]) | |
| return torch.stack([cx - w / 2, cy - hgt / 2, cx + w / 2, cy + hgt / 2]).clamp(0, 1) | |
| class Heads(torch.nn.Module): | |
| """Quote and point heads, as amos.model has them: a question's answer-position vector scored against the state's | |
| tokens (start and end of a quote) or against the first state image's tokens (one cell each); `keep` is the | |
| region readout's share (decode_head). With a BoxHead, a region's logits carry four more values: its box.""" | |
| def __init__(self, d: int, kinds=HEADED, keep: float = REGION_KEEP, box: bool = False): | |
| super().__init__() | |
| self.kinds, self.keep = list(kinds), keep | |
| self.span = (SPAN_CAP, SPAN_LENGTH_PENALTY) # the quote decoder's cap and penalty; serving sets the run's own | |
| self.span_q, self.span_k = torch.nn.Linear(d, 2 * d), torch.nn.Linear(d, 2 * d) | |
| self.region_q, self.region_k = torch.nn.Linear(d, d), torch.nn.Linear(d, d) | |
| for m in (self.span_q, self.span_k, self.region_q, self.region_k): | |
| torch.nn.init.normal_(m.weight, std=0.02) | |
| torch.nn.init.zeros_(m.bias) | |
| self.box = BoxHead(d) if box else None | |
| def forward(self, h: torch.Tensor, batch: list[tuple]) -> list[torch.Tensor]: | |
| """Logits of each span ([2, state tokens]) and region ([cells], then the 4 box edges when there is a box head) | |
| question in the batch, in order.""" | |
| h, d, out = h.to(self.span_q.weight.dtype), h.shape[-1], [] | |
| for b, e in enumerate(batch): | |
| for p, q in zip(e[1], e[2]): | |
| if q["type"] == "span": | |
| first, offsets = e[3]["state"] | |
| k = self.span_k(h[b, first : first + len(offsets)]).view(-1, 2, d) | |
| out.append(torch.einsum("cd,lcd->cl", self.span_q(h[b, p]).view(2, d), k) / math.sqrt(d)) | |
| elif q["type"] == "region": | |
| if not e[3].get("images"): | |
| raise ValueError("a region question needs a state image") | |
| start, rows, cols = e[3]["images"][0] | |
| cells = h[b, start : start + rows * cols] | |
| x = self.region_k(cells) @ self.region_q(h[b, p]) / math.sqrt(d) | |
| if self.box is not None: | |
| x = torch.cat([x, self.box(h[b, p], cells, x, rows, cols).to(x.dtype)]) | |
| out.append(x) | |
| return out | |
| def decode_head(q: dict, x: torch.Tensor, extra: dict, state: str, keep: float = REGION_KEEP, | |
| span: tuple[int, float] = (SPAN_CAP, SPAN_LENGTH_PENALTY)) -> dict: | |
| """A span or region result in amos.model.decode's format; a quote is always cut from the state, at most span[0] | |
| tokens long, each token past the start scaled by exp(-span[1]). A point is the most likely cell and the box of the | |
| cells around it holding `keep` of its probability, with the box's mass.""" | |
| from types import SimpleNamespace | |
| from .model import decode_span | |
| from .vision import peak_box | |
| x = x.float().cpu() | |
| if q["type"] == "span": | |
| x = torch.cat([x.new_full((2, 1), -torch.inf), x], 1) # decode_span skips a leading [CLS] slot | |
| a, z, _, p = decode_span(x[0], x[1], SimpleNamespace(offsets=extra["state"][1], state=state), span[1], span[0]) | |
| # Qwen fuses a JSON string's closing quote with the last word ('detail."', 'approves.",'), so a quote cut at | |
| # token edges can end (or start) on an unmatched quote mark; drop it and what follows it | |
| while state[a:z].count('"') % 2: | |
| if state[z - 1] in '",}]': | |
| z -= 1 | |
| elif state[a] in '"{[': | |
| a += 1 | |
| else: | |
| break | |
| t = state[a:z] | |
| a, z = a + len(t) - len(t.lstrip()), z - (len(t) - len(t.rstrip())) | |
| return {"type": "span", "start": a, "end": max(a, z), "text": state[a : max(a, z)], "p": p} | |
| _, rows, cols = extra["images"][0] | |
| cells = x[: rows * cols].softmax(-1) | |
| if len(x) > rows * cols: # a box head's box; its mass is the cells it covers | |
| box = [round(v, 4) for v in x[rows * cols :].tolist()] | |
| r0, r1 = int(box[1] * rows), max(int(box[1] * rows) + 1, math.ceil(box[3] * rows)) | |
| c0, c1 = int(box[0] * cols), max(int(box[0] * cols) + 1, math.ceil(box[2] * cols)) | |
| return {"type": "region", "cell": int(cells.argmax()), "box": box, | |
| "p": float(cells.view(rows, cols)[r0:r1, c0:c1].sum())} | |
| box, p = peak_box(cells, rows, cols, keep) | |
| return {"type": "region", "cell": int(cells.argmax()), "box": box, "p": p} | |
| def load_heads(path, model, codec: Codec | None = None) -> Heads | None: | |
| """The trained heads saved beside a checkpoint, or None. A quote head trained on echoed cases switches the codec's | |
| echo on, since it reads nothing else.""" | |
| f = Path(path) / HEADS | |
| if not f.exists(): | |
| return None | |
| saved = torch.load(f, map_location="cpu", weights_only=True) | |
| heads = Heads(model.get_input_embeddings().weight.shape[1], saved["kinds"], saved.get("keep", REGION_KEEP), | |
| box=any(k.startswith("box.") for k in saved["state"])) | |
| heads.load_state_dict(saved["state"]) | |
| if codec is not None: | |
| codec.echo = bool(saved.get("echo", False)) | |
| return heads.to(model.device).eval() | |
| def tempered(lp: torch.Tensor, t: float | None = None) -> list[float]: | |
| """Probabilities from log-probabilities, sharpened (t < 1) or softened (t > 1) by a fitted temperature.""" | |
| return (lp / t).softmax(-1).tolist() if t else lp.exp().tolist() | |
| def rotate(q: dict, r: int) -> dict: | |
| """A choice question with its options rotated r places; anything else as it is (a scale's order is its meaning).""" | |
| if q["type"] != "choice" or not r: | |
| return q | |
| s = r % len(q["options"]) | |
| return {**q, "options": q["options"][s:] + q["options"][:s]} | |
| def read(model, codec, state: str, questions: list[dict], images=(), max_len: int | None = None, | |
| temperature: dict | None = None): | |
| """-> (option distributions for letter-answered questions, input tokens), one forward pass.""" | |
| ids, at, extra = codec.encode_case(state, questions, images) | |
| if max_len and len(ids) > max_len: | |
| raise ValueError(f"maximum context length: request has {len(ids)} tokens, limit {max_len}") | |
| entry = [(ids, at, questions, extra)] | |
| t = temperature or {} | |
| picked = [q for q in questions if q["type"] in KINDS] | |
| return [tempered(lp, t.get(q["type"])) for lp, q in | |
| zip(distributions(model, codec, entry, hidden(model, codec, entry)), picked)], len(ids) | |
| def wide_choice(model, codec, state: str, q: dict, images=(), max_len: int | None = None, | |
| temperature: dict | None = None): | |
| """A choice over more options than there are letters, in two passes -> (distribution, most input tokens). | |
| First the options are split into chunks, each asked as its own question; then the leading options of every chunk | |
| are asked again together. Finalists share their first-pass mass (each chunk weighted equally) in proportion to the | |
| second pass; every other option keeps its first-pass share, so the distribution sums to one and its top answer is | |
| the second pass's.""" | |
| n, per = len(q["options"]), len(codec.codes) | |
| size = -(-n // -(-n // per)) # balanced chunks of at most `per` | |
| chunks = [list(range(s, min(s + size, n))) for s in range(0, n, size)] | |
| first, t1 = read(model, codec, state, [{**q, "options": [q["options"][i] for i in c]} for c in chunks], images, max_len, | |
| temperature) | |
| prior = [0.0] * n | |
| for c, p in zip(chunks, first): | |
| for i, x in zip(c, p): | |
| prior[i] = x / len(chunks) | |
| keep = per // len(chunks) | |
| finalists = sorted(i for c, p in zip(chunks, first) for i, _ in sorted(zip(c, p), key=lambda t: -t[1])[:keep]) | |
| (second,), t2 = read(model, codec, state, [{**q, "options": [q["options"][i] for i in finalists]}], images, max_len, | |
| temperature) | |
| mass, out = sum(prior[i] for i in finalists), prior[:] | |
| for i, x in zip(finalists, second): | |
| out[i] = mass * x | |
| return out, max(t1, t2) | |
| def sureness(r: dict) -> float: | |
| """How sure a yes/no, choice or score result is: the probability of its answer.""" | |
| return max(r["p_yes"], 1 - r["p_yes"]) if r["type"] == "noul" else max(r["probs"]) | |
| def think_many(model, codec, state: str, qs: list[dict], max_tokens: int, temperature: dict | None = None, | |
| generate=None): | |
| """think for several questions about one case at once: their thoughts are generated as one left-padded batch, so a | |
| case with eight unsure questions costs about one generation instead of eight. generate(prefixes, max_tokens) -> | |
| [(text, tokens)] writes the thoughts elsewhere instead (a vLLM server: serve's AMOS_THINK_URL); the letters are | |
| still read here, so the answers are the same either way.""" | |
| if noncausal(model): | |
| raise ValueError("a noncausal run cannot think: generation reads a causal model's cache") | |
| state, qs = codec.safe(state, qs) | |
| prefixes = [codec.tok.apply_chat_template(render(state, [q], codes=codec.codes)[:2], tokenize=False, | |
| add_generation_prompt=True, enable_thinking=True) for q in qs] | |
| if generate is not None: | |
| thoughts = generate(prefixes, max_tokens) | |
| else: | |
| ids = [codec.tok(p, add_special_tokens=False)["input_ids"] for p in prefixes] | |
| width, pad = max(map(len, ids)), codec.tok.pad_token_id | |
| batch = torch.tensor([[pad] * (width - len(x)) + x for x in ids], device=model.device) | |
| mask = torch.tensor([[0] * (width - len(x)) + [1] * len(x) for x in ids], device=model.device) | |
| out = model.generate(batch, attention_mask=mask, max_new_tokens=max_tokens, do_sample=False, use_cache=True, | |
| pad_token_id=pad) | |
| thoughts = [(codec.tok.decode(row, skip_special_tokens=False).replace(codec.tok.pad_token or "\0", ""), | |
| int((row != pad).sum())) for row in out[:, width:]] | |
| results = [] | |
| for prefix, q, (text, n) in zip(prefixes, qs, thoughts): | |
| reasoning = text.split("</think>")[0].replace("<|im_end|>", "").strip() | |
| full = codec.tok(prefix + reasoning + "\n</think>\n\n" + f"q1: {ANSWER}", add_special_tokens=False)["input_ids"] | |
| at = [i - 1 for i, t in enumerate(full) if t == codec.marker] | |
| lp = distributions(model, codec, [(full, at, [q])])[0] | |
| results.append((tempered(lp, (temperature or {}).get(q["type"])), n)) | |
| return results | |
| def results(model, codec, state: str, questions: list[dict], images=(), heads: Heads | None = None, | |
| max_len: int | None = None, rotations: int = 1, temperature: dict | None = None, | |
| think_below: float | None = None, think_tokens: int = 512, generate=None) -> tuple[list[dict], int]: | |
| """One typed result per question in amos.model.decode's format (noul p_yes; choice/score probs, choice, expected; | |
| multi independent probs; span start/end/text/p; region cell/box/p), and the number of input tokens. A choice over | |
| more options than there are letters is answered on its own (wide_choice). rotations and temperature: see | |
| one_pass. think_below: a yes/no, choice or score answer less sure than this is asked again after the model | |
| thinks (think), as GLiDE spends compute only where a decision is uncertain; the result then carries "thought".""" | |
| if think_below and noncausal(model): # on every request, not only the unsure ones | |
| raise ValueError("a noncausal run cannot think: generation reads a causal model's cache") | |
| out, tokens = _results(model, codec, state, questions, images, heads, max_len, rotations, temperature) | |
| if think_below and not images: | |
| unsure = [k for k, (q, r) in enumerate(zip(questions, out)) | |
| if q["type"] in KINDS and len(options(q)) <= len(codec.codes) and sureness(r) < think_below] | |
| for k, (p, n) in zip(unsure, think_many(model, codec, state, [questions[k] for k in unsure], think_tokens, | |
| temperature, generate) if unsure else []): | |
| q = questions[k] | |
| if q["type"] == "noul": | |
| out[k] = {"type": "noul", "p_yes": p[0], "thought": n} | |
| else: | |
| out[k] = {"type": q["type"], "probs": p, "choice": max(range(len(p)), key=p.__getitem__), "thought": n} | |
| if q["type"] == "score": | |
| out[k]["expected"] = sum(i * x for i, x in enumerate(p)) | |
| return out, tokens | |
| def _results(model, codec, state: str, questions: list[dict], images=(), heads: Heads | None = None, | |
| max_len: int | None = None, rotations: int = 1, temperature: dict | None = None) -> tuple[list[dict], int]: | |
| wide = {k for k, q in enumerate(questions) if q["type"] == "choice" and len(q["options"]) > len(codec.codes)} | |
| out, tokens = [None] * len(questions), 0 | |
| for k in sorted(wide): | |
| p, n = wide_choice(model, codec, state, questions[k], images, max_len, temperature) | |
| out[k], tokens = {"type": "choice", "probs": p, "choice": max(range(len(p)), key=p.__getitem__)}, max(tokens, n) | |
| rest = [k for k in range(len(questions)) if k not in wide] | |
| if rest: | |
| sub, n = one_pass(model, codec, state, [questions[k] for k in rest], images, heads, max_len, rotations, | |
| temperature) | |
| for k, r in zip(rest, sub): | |
| out[k] = r | |
| tokens = max(tokens, n) | |
| return out, tokens | |
| ROTATION_BATCH_TOKENS = 65536 | |
| def one_pass(model, codec, state: str, questions: list[dict], images=(), heads: Heads | None = None, | |
| max_len: int | None = None, rotations: int = 1, temperature: dict | None = None) -> tuple[list[dict], int]: | |
| """results() for questions that fit the letters, in one forward pass. | |
| rotations > 1 (and a choice question present): the case is also read with every choice question's options rotated, | |
| each rotation one more row of the same batched forward, and choice distributions are averaged in the original order. | |
| temperature: {question type: t} fitted by `decoder calibrate` (pick-any's per-option yes/no uses noul's).""" | |
| flat, owner = expand(questions) | |
| rotations = rotations if any(q["type"] == "choice" for q in flat) else 1 | |
| entries = [] | |
| for r in range(rotations): | |
| qs = [rotate(q, r) for q in flat] | |
| ids, at, extra = codec.encode_case(state, qs, images) | |
| if max_len and len(ids) > max_len: | |
| raise ValueError(f"maximum context length: request has {len(ids)} tokens, limit {max_len}") | |
| entries.append((ids, at, qs, extra)) | |
| ids, extra = entries[0][0], entries[0][3] | |
| if heads is None and any(q["type"] in HEADED for q in flat): | |
| raise ValueError("this model has no span or region head") | |
| if sum(len(e[0]) for e in entries) <= ROTATION_BATCH_TOKENS: | |
| h = hidden(model, codec, entries) | |
| lps = distributions(model, codec, entries, h) | |
| else: # a long case: one rotation at a time, so memory stays that of a single row | |
| h, lps = None, [] | |
| for e in entries: | |
| he = hidden(model, codec, [e]) | |
| lps += distributions(model, codec, [e], he) | |
| h = he if h is None else h | |
| head = iter(heads(h[:1], entries[:1]) if heads is not None else []) | |
| picked = [j for j, q in enumerate(flat) if q["type"] in KINDS] | |
| temps = temperature or {} | |
| mean = {} | |
| for r in range(rotations): | |
| for m, j in enumerate(picked): | |
| p, n = tempered(lps[r * len(picked) + m], temps.get(flat[j]["type"])), len(options(flat[j])) | |
| s = r % n if flat[j]["type"] == "choice" else 0 | |
| acc = mean.setdefault(j, [0.0] * n) | |
| for i in range(n): # rotated position (i - s) mod n holds original option i | |
| acc[i] += p[(i - s) % n] / rotations | |
| lps = iter(mean[j] for j in picked) | |
| out = [None] * len(questions) | |
| for q, k in zip(flat, owner): | |
| t = questions[k]["type"] | |
| if q["type"] in HEADED: | |
| out[k] = decode_head(q, next(head), extra, state, heads.keep, heads.span) | |
| continue | |
| p = next(lps) | |
| if t == "multi": | |
| out[k] = out[k] or {"type": t, "probs": []} | |
| out[k]["probs"].append(p[0]) | |
| elif t == "noul": | |
| out[k] = {"type": t, "p_yes": p[0]} | |
| else: | |
| out[k] = {"type": t, "probs": p, "choice": max(range(len(p)), key=p.__getitem__)} | |
| if t == "score": | |
| out[k]["expected"] = sum(i * x for i, x in enumerate(p)) | |
| return out, len(ids) | |
| CALIBRATION = "calibration.json" | |
| def calibration(run) -> dict | None: | |
| """The per-type temperatures `decoder calibrate` fitted for a run, or None.""" | |
| f = Path(run) / CALIBRATION | |
| return json.loads(f.read_text()).get("temperature") if f.exists() else None | |
| def span_decoding(run) -> tuple[int, float]: | |
| """The quote decoder's (cap, penalty) fitted for a run on held-out quote records, or the defaults.""" | |
| f = Path(run) / CALIBRATION | |
| s = (json.loads(f.read_text()).get("span") or {}) if f.exists() else {} | |
| return int(s.get("cap", SPAN_CAP)), float(s.get("penalty", SPAN_LENGTH_PENALTY)) | |
| def multi_cut(run) -> float | None: | |
| """The pick-any cut fitted on our own held-out pick-any questions (an option is picked at p >= cut), or None.""" | |
| f = Path(run) / CALIBRATION | |
| return json.loads(f.read_text()).get("multi_cut") if f.exists() else None | |