yev 0.2.0
Browse files- README.md +65 -0
- examples/jevbench_adapter.py +48 -0
- pyproject.toml +35 -0
- src/yev/__init__.py +98 -0
- src/yev/answers.py +137 -0
- src/yev/cli.py +176 -0
- src/yev/client.py +564 -0
- src/yev/constants.py +10 -0
- src/yev/errors.py +18 -0
- src/yev/primitives.py +417 -0
- src/yev/training/__init__.py +0 -0
- src/yev/training/comparison.py +111 -0
- src/yev/training/data.py +649 -0
- src/yev/training/images.py +206 -0
- src/yev/training/model.py +309 -0
- src/yev/training/run.py +782 -0
README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
tags:
|
| 3 |
+
- yev
|
| 4 |
+
- decision-model
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
# yev
|
| 8 |
+
|
| 9 |
+
The client for [Yev-Decision-1](https://huggingface.co/HallD/Yev-Decision-1), a local **System One**
|
| 10 |
+
decision model. Give it a state, which can be text, images or both, plus named questions. It returns
|
| 11 |
+
a typed probability distribution for each one: a **Choice** between options, a **Score** on an
|
| 12 |
+
ordered scale, or a **Noul** (yes or no). It generates no text and nothing leaves the machine.
|
| 13 |
+
|
| 14 |
+
## Install
|
| 15 |
+
|
| 16 |
+
```sh
|
| 17 |
+
uv add "yev[inference] @ git+https://huggingface.co/HallD/yev"
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
`pip install "yev[inference] @ git+https://huggingface.co/HallD/yev"` works the same way. Python 3.13
|
| 21 |
+
or later.
|
| 22 |
+
|
| 23 |
+
| Extra | Brings | For |
|
| 24 |
+
| --- | --- | --- |
|
| 25 |
+
| *(none)* | pydantic, huggingface-hub, numpy | Question and answer types only |
|
| 26 |
+
| `inference` | torch, transformers, peft, accelerate, pillow | Answering questions with a model |
|
| 27 |
+
| `quantized` | `inference` plus bitsandbytes | `quantize4bit=True` on a CUDA GPU |
|
| 28 |
+
|
| 29 |
+
## Use
|
| 30 |
+
|
| 31 |
+
```python
|
| 32 |
+
from yev import Choice, Image, Noul, Score, YevClient
|
| 33 |
+
|
| 34 |
+
with YevClient() as client: # YEV_DEFAULT_MODEL, else HallD/Yev-Decision-1
|
| 35 |
+
response = client.system_one(
|
| 36 |
+
state="I was charged twice. Please fix this ASAP.",
|
| 37 |
+
questions={
|
| 38 |
+
"billing": Noul(instructions="Is this ticket about billing?"),
|
| 39 |
+
"tone": Choice(instructions="What is the customer's tone?",
|
| 40 |
+
criteria={"calm": None, "frustrated": None, "angry": None}),
|
| 41 |
+
"urgency": Score(instructions="How urgent is this ticket?",
|
| 42 |
+
criteria=["can wait", "this week", "today"]),
|
| 43 |
+
},
|
| 44 |
+
)
|
| 45 |
+
print(response.nouls["billing"].noul, response.choices["tone"].probabilities)
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
The first call downloads the model and its backbone,
|
| 49 |
+
[`Qwen/Qwen3.5-4B-Base`](https://huggingface.co/Qwen/Qwen3.5-4B-Base) at a pinned revision, into the
|
| 50 |
+
Hugging Face cache. After that it runs offline (`HF_HUB_OFFLINE=1`). The backbone runs in bfloat16;
|
| 51 |
+
a GPU with 12 GB holds it.
|
| 52 |
+
|
| 53 |
+
The call shape follows the TypeSafe SDK (0.7.5): `YevClient` and `AsyncYevClient` take its
|
| 54 |
+
`system_one(state, questions, model=, assets=, response_model=)`, its question objects (TypeSafe's
|
| 55 |
+
own are accepted), and return its answer and response types. It runs in-process, so there are no
|
| 56 |
+
API keys, retries or timeouts; Box questions are refused, and a Score takes at most 10 levels.
|
| 57 |
+
|
| 58 |
+
`yev --export HallD/Yev-Decision-1 --state "I was charged twice." --noul billing "Is this ticket about billing?"`
|
| 59 |
+
asks from the command line.
|
| 60 |
+
|
| 61 |
+
## JevBench
|
| 62 |
+
|
| 63 |
+
`examples/jevbench_adapter.py` is an adapter for the
|
| 64 |
+
[JevBench](https://github.com/fstandhartinger/jevbench) harness: it sends the harness's own question
|
| 65 |
+
to `system_one` and returns the probabilities.
|
examples/jevbench_adapter.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A JevBench adapter for yev: the harness's own question dict goes to ``system_one`` unchanged.
|
| 2 |
+
|
| 3 |
+
from examples.jevbench_adapter import YevAdapter
|
| 4 |
+
from jevbench.adapters.conformance import check_adapter
|
| 5 |
+
print(check_adapter(YevAdapter("HallD/Yev-Decision-1")).summary())
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import time
|
| 9 |
+
|
| 10 |
+
from jevbench.adapters.base import DecisionResult, build_question
|
| 11 |
+
|
| 12 |
+
from yev import Yev
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class YevAdapter:
|
| 16 |
+
name = "yev_local"
|
| 17 |
+
cost_basis = "local_no_provider_tariff"
|
| 18 |
+
price_input_per_m = None
|
| 19 |
+
price_output_per_m = None
|
| 20 |
+
|
| 21 |
+
def __init__(self, model="HallD/Yev-Decision-1"):
|
| 22 |
+
self.model, self.client = model, Yev(model, lazy=False)
|
| 23 |
+
|
| 24 |
+
def reserve_estimate(self, task):
|
| 25 |
+
return None
|
| 26 |
+
|
| 27 |
+
def run(self, task):
|
| 28 |
+
result = DecisionResult(adapter=self.name, ok=False, probs_source="native", model=self.model)
|
| 29 |
+
question = build_question(task)
|
| 30 |
+
result.request_body = {"state": task.state, "questions": {"decision": question}}
|
| 31 |
+
started = time.perf_counter()
|
| 32 |
+
try:
|
| 33 |
+
response = self.client.system_one(task.state, {"decision": question}, windows="mean")
|
| 34 |
+
except Exception as error: # a failed answer is a failed attempt
|
| 35 |
+
result.latency_s = time.perf_counter() - started
|
| 36 |
+
result.error = f"{type(error).__name__}: {str(error)[:300]}"
|
| 37 |
+
return result
|
| 38 |
+
result.latency_s = time.perf_counter() - started
|
| 39 |
+
kind = task.question["type"]
|
| 40 |
+
if kind == "noul":
|
| 41 |
+
p = float(response.nouls["decision"].noul)
|
| 42 |
+
result.probs = {"yes": p, "no": 1.0 - p}
|
| 43 |
+
else:
|
| 44 |
+
answer = response.choices["decision"] if kind == "choice" else response.scores["decision"]
|
| 45 |
+
result.probs = {str(k): float(v) for k, v in answer.probabilities.items()}
|
| 46 |
+
result.ok = True
|
| 47 |
+
result.raw = {"runtime": {"probability_origin": "native-softmax", "windows": "mean"}}
|
| 48 |
+
return result
|
pyproject.toml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "yev"
|
| 3 |
+
version = "0.2.0"
|
| 4 |
+
description = "A local System One decision model: typed Choice, Score and Noul probabilities, no generated text."
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.13"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"huggingface-hub>=0.36.2",
|
| 9 |
+
"numpy>=1.26",
|
| 10 |
+
"pydantic>=2.9,<3",
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
[project.optional-dependencies]
|
| 14 |
+
inference = [
|
| 15 |
+
"torch>=2.6,<3",
|
| 16 |
+
"transformers>=5.17,<6",
|
| 17 |
+
"peft>=0.21,<0.22",
|
| 18 |
+
"accelerate>=1.12,<2",
|
| 19 |
+
"pillow>=11",
|
| 20 |
+
]
|
| 21 |
+
quantized = [
|
| 22 |
+
"yev[inference]",
|
| 23 |
+
"bitsandbytes>=0.50",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
[project.scripts]
|
| 27 |
+
yev = "yev.cli:main"
|
| 28 |
+
|
| 29 |
+
[build-system]
|
| 30 |
+
requires = ["uv_build>=0.12.17,<0.13.0"]
|
| 31 |
+
build-backend = "uv_build"
|
| 32 |
+
|
| 33 |
+
[tool.uv.build-backend]
|
| 34 |
+
module-name = "yev"
|
| 35 |
+
module-root = "src"
|
src/yev/__init__.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""yev: a local System One decision model with the TypeSafe SDK's call shape.
|
| 2 |
+
|
| 3 |
+
The package holds two layers:
|
| 4 |
+
|
| 5 |
+
- The client surface, the TypeSafe SDK's (0.7.5) ported (``YevClient`` and ``AsyncYevClient``, the
|
| 6 |
+
questions, ``Image`` and the answer types), which answers ``system_one(state, questions)`` from a
|
| 7 |
+
locally exported model.
|
| 8 |
+
- ``yev.training``: the model, its loading and export, which the client reads an export with.
|
| 9 |
+
|
| 10 |
+
Importing this module does not load PyTorch; the training stack is imported on first use.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from . import constants as constants
|
| 14 |
+
from .answers import (
|
| 15 |
+
Answer,
|
| 16 |
+
BoxAnswer,
|
| 17 |
+
BoxCoordinates,
|
| 18 |
+
ChoiceAnswer,
|
| 19 |
+
ListModelsResponse,
|
| 20 |
+
ModelMetadata,
|
| 21 |
+
NoulAnswer,
|
| 22 |
+
ScoreAnswer,
|
| 23 |
+
SystemOneResponse,
|
| 24 |
+
Usage,
|
| 25 |
+
)
|
| 26 |
+
from .client import AsyncModels, AsyncYevClient, Models, Yev, YevClient
|
| 27 |
+
from .errors import YevError, YevModelNotFoundError, YevResponseValidationError
|
| 28 |
+
from .primitives import (
|
| 29 |
+
CHOICE_LIMITS,
|
| 30 |
+
SCORE_LIMITS,
|
| 31 |
+
Asset,
|
| 32 |
+
Box,
|
| 33 |
+
BoxModel,
|
| 34 |
+
Choice,
|
| 35 |
+
ChoiceModel,
|
| 36 |
+
Content,
|
| 37 |
+
ContentValue,
|
| 38 |
+
Image,
|
| 39 |
+
JSONContent,
|
| 40 |
+
JSONValue,
|
| 41 |
+
Noul,
|
| 42 |
+
NoulCriteria,
|
| 43 |
+
NoulModel,
|
| 44 |
+
Question,
|
| 45 |
+
QuestionModel,
|
| 46 |
+
Questions,
|
| 47 |
+
Score,
|
| 48 |
+
ScoreModel,
|
| 49 |
+
normalize_question,
|
| 50 |
+
normalize_questions,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
__version__ = "0.2.0"
|
| 54 |
+
|
| 55 |
+
__all__ = [
|
| 56 |
+
"Answer",
|
| 57 |
+
"Asset",
|
| 58 |
+
"AsyncModels",
|
| 59 |
+
"AsyncYevClient",
|
| 60 |
+
"Box",
|
| 61 |
+
"BoxAnswer",
|
| 62 |
+
"BoxCoordinates",
|
| 63 |
+
"BoxModel",
|
| 64 |
+
"CHOICE_LIMITS",
|
| 65 |
+
"Choice",
|
| 66 |
+
"ChoiceAnswer",
|
| 67 |
+
"ChoiceModel",
|
| 68 |
+
"Content",
|
| 69 |
+
"ContentValue",
|
| 70 |
+
"Image",
|
| 71 |
+
"JSONContent",
|
| 72 |
+
"JSONValue",
|
| 73 |
+
"ListModelsResponse",
|
| 74 |
+
"ModelMetadata",
|
| 75 |
+
"Models",
|
| 76 |
+
"Noul",
|
| 77 |
+
"NoulAnswer",
|
| 78 |
+
"NoulCriteria",
|
| 79 |
+
"NoulModel",
|
| 80 |
+
"Question",
|
| 81 |
+
"QuestionModel",
|
| 82 |
+
"Questions",
|
| 83 |
+
"SCORE_LIMITS",
|
| 84 |
+
"Score",
|
| 85 |
+
"ScoreAnswer",
|
| 86 |
+
"ScoreModel",
|
| 87 |
+
"SystemOneResponse",
|
| 88 |
+
"Usage",
|
| 89 |
+
"Yev",
|
| 90 |
+
"YevClient",
|
| 91 |
+
"YevError",
|
| 92 |
+
"YevModelNotFoundError",
|
| 93 |
+
"YevResponseValidationError",
|
| 94 |
+
"__version__",
|
| 95 |
+
"constants",
|
| 96 |
+
"normalize_question",
|
| 97 |
+
"normalize_questions",
|
| 98 |
+
]
|
src/yev/answers.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Typed answers and response objects, ported from the TypeSafe SDK's (0.7.5) response types.
|
| 2 |
+
|
| 3 |
+
The same classes, fields and accessors (``response.nouls``, ``.choices``, ``.scores``, ``.boxes``),
|
| 4 |
+
immutable and tolerant of unknown fields. yev never returns a ``BoxAnswer`` (it has no box head);
|
| 5 |
+
the type is here for ``response_model``s written against the TypeSafe SDK. yev adds ``SystemOneResponse.elapsed_seconds``, the time the
|
| 6 |
+
model took, and its ``Usage.output_tokens`` is always 0: nothing is generated.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from functools import cached_property
|
| 10 |
+
from typing import Annotated, Any, Literal
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class _Schema(BaseModel):
|
| 16 |
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class NoulAnswer(_Schema):
|
| 20 |
+
"""A yes/no answer."""
|
| 21 |
+
|
| 22 |
+
type: Literal["noul"] = "noul"
|
| 23 |
+
noul: float
|
| 24 |
+
"""Probability of a yes answer or a true statement, from 0 to 1."""
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ChoiceAnswer(_Schema):
|
| 28 |
+
"""A selected label and its probabilities."""
|
| 29 |
+
|
| 30 |
+
type: Literal["choice"] = "choice"
|
| 31 |
+
choice: str
|
| 32 |
+
"""The name of the choice with the highest probability among the question's criteria."""
|
| 33 |
+
confidence: float
|
| 34 |
+
"""Confidence in the selected choice, from 0 to 1."""
|
| 35 |
+
probabilities: dict[str, float]
|
| 36 |
+
"""Probability of each choice in criteria, keyed by choice name; values sum to about 1."""
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ScoreAnswer(_Schema):
|
| 40 |
+
"""An expected score with its rubric and probabilities."""
|
| 41 |
+
|
| 42 |
+
type: Literal["score"] = "score"
|
| 43 |
+
score: float
|
| 44 |
+
"""Expected score: the probability-weighted average of the rubric levels."""
|
| 45 |
+
confidence: float
|
| 46 |
+
"""Confidence in the score, from 0 to 1."""
|
| 47 |
+
legend: dict[int, str | dict[str, Any] | list[Any]]
|
| 48 |
+
"""Rubric descriptions keyed by integer score."""
|
| 49 |
+
probabilities: dict[int, float]
|
| 50 |
+
"""Probabilities keyed by integer score."""
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class BoxCoordinates(_Schema):
|
| 54 |
+
"""A box's edges as fractions of the image's height (``top``, ``bottom``) and width (``left``, ``right``)."""
|
| 55 |
+
|
| 56 |
+
top: float
|
| 57 |
+
bottom: float
|
| 58 |
+
left: float
|
| 59 |
+
right: float
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class BoxAnswer(_Schema):
|
| 63 |
+
"""A located box with its confidence."""
|
| 64 |
+
|
| 65 |
+
type: Literal["box"] = "box"
|
| 66 |
+
box: BoxCoordinates
|
| 67 |
+
"""The box's edges as fractions of the image's height and width, from 0 to 1."""
|
| 68 |
+
confidence: float | None
|
| 69 |
+
"""Confidence for the box, from 0 to 1, or ``None`` when the model does not provide it."""
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
Answer = Annotated[NoulAnswer | ChoiceAnswer | ScoreAnswer | BoxAnswer, Field(discriminator="type")]
|
| 73 |
+
"""An answer to a single question, identified by its ``type``."""
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class Usage(_Schema):
|
| 77 |
+
"""Token counts for a request."""
|
| 78 |
+
|
| 79 |
+
input_tokens: int | None = None
|
| 80 |
+
"""Number of input tokens the model read."""
|
| 81 |
+
output_tokens: int | None = 0
|
| 82 |
+
"""Always 0: yev generates no text."""
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class SystemOneResponse(_Schema):
|
| 86 |
+
"""Answers grouped by question type with model and usage metadata.
|
| 87 |
+
|
| 88 |
+
A subclass that declares fields named after questions gets those answers as attributes when it is
|
| 89 |
+
passed as ``response_model``, as in the TypeSafe SDK.
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
model: str
|
| 93 |
+
"""The model used to answer the request."""
|
| 94 |
+
usage: Usage
|
| 95 |
+
"""Token usage for the request."""
|
| 96 |
+
answers: dict[str, Answer] = Field(default_factory=dict)
|
| 97 |
+
"""All answer objects keyed by question name."""
|
| 98 |
+
elapsed_seconds: float | None = None
|
| 99 |
+
"""Seconds the model took to answer (for a batch, the whole batch)."""
|
| 100 |
+
|
| 101 |
+
@cached_property
|
| 102 |
+
def nouls(self) -> dict[str, NoulAnswer]:
|
| 103 |
+
"""Yes/no answers keyed by question name."""
|
| 104 |
+
return {name: answer for name, answer in self.answers.items() if isinstance(answer, NoulAnswer)}
|
| 105 |
+
|
| 106 |
+
@cached_property
|
| 107 |
+
def choices(self) -> dict[str, ChoiceAnswer]:
|
| 108 |
+
"""Choice answers keyed by question name."""
|
| 109 |
+
return {name: answer for name, answer in self.answers.items() if isinstance(answer, ChoiceAnswer)}
|
| 110 |
+
|
| 111 |
+
@cached_property
|
| 112 |
+
def scores(self) -> dict[str, ScoreAnswer]:
|
| 113 |
+
"""Score answers keyed by question name."""
|
| 114 |
+
return {name: answer for name, answer in self.answers.items() if isinstance(answer, ScoreAnswer)}
|
| 115 |
+
|
| 116 |
+
@cached_property
|
| 117 |
+
def boxes(self) -> dict[str, BoxAnswer]:
|
| 118 |
+
"""Box answers keyed by question name; always empty from yev."""
|
| 119 |
+
return {name: answer for name, answer in self.answers.items() if isinstance(answer, BoxAnswer)}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class ModelMetadata(_Schema):
|
| 123 |
+
"""Metadata describing a single available model."""
|
| 124 |
+
|
| 125 |
+
name: str
|
| 126 |
+
"""Model name, as answers report it."""
|
| 127 |
+
description: str
|
| 128 |
+
"""What the model reads: its backbone, token budget and whether it takes images."""
|
| 129 |
+
release_date: str
|
| 130 |
+
"""The date the export was written or downloaded, formatted as YYYY-MM-DD."""
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class ListModelsResponse(_Schema):
|
| 134 |
+
"""The models a client answers with."""
|
| 135 |
+
|
| 136 |
+
models: tuple[ModelMetadata, ...]
|
| 137 |
+
"""The available models."""
|
src/yev/cli.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``yev`` command line: ask an exported model Choice, Score and Noul questions.
|
| 2 |
+
|
| 3 |
+
yev --export path/to/export --state "I was charged twice." \\
|
| 4 |
+
--noul billing "Is this ticket about billing?" \\
|
| 5 |
+
--choice tone "What is the tone?" "calm,frustrated,angry" \\
|
| 6 |
+
--score urgency "How urgent?" "can wait|this week|today"
|
| 7 |
+
|
| 8 |
+
yev --export path/to/export --request request.json --round
|
| 9 |
+
yev --export path/to/export --interactive
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
import sys
|
| 16 |
+
|
| 17 |
+
from .client import Yev
|
| 18 |
+
from .errors import YevError
|
| 19 |
+
from .primitives import Choice, Image, Noul, Score
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def parse_choice_criteria(text):
|
| 23 |
+
"""``"a,b,c"`` or ``"a=description,b=description"`` into an ordered criteria map."""
|
| 24 |
+
criteria = {}
|
| 25 |
+
for part in text.split(","):
|
| 26 |
+
part = part.strip()
|
| 27 |
+
if not part:
|
| 28 |
+
continue
|
| 29 |
+
label, _, description = part.partition("=")
|
| 30 |
+
criteria[label.strip()] = description.strip() or None
|
| 31 |
+
if len(criteria) < 2:
|
| 32 |
+
raise YevError("A Choice needs at least two comma-separated options")
|
| 33 |
+
return criteria
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def parse_score_levels(text):
|
| 37 |
+
"""Pipe-separated level descriptions, lowest first."""
|
| 38 |
+
levels = [level.strip() for level in text.split("|") if level.strip()]
|
| 39 |
+
if not 2 <= len(levels) <= 10:
|
| 40 |
+
raise YevError("A Score needs 2 to 10 levels separated by '|'")
|
| 41 |
+
return levels
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def build_request(arguments):
|
| 45 |
+
"""A ``{"state": ..., "questions": {...}}`` request from a JSON file or the question flags."""
|
| 46 |
+
if arguments.request is not None:
|
| 47 |
+
stream = sys.stdin if str(arguments.request) == "-" else open(arguments.request, encoding="utf-8")
|
| 48 |
+
with stream:
|
| 49 |
+
request = json.load(stream)
|
| 50 |
+
if not isinstance(request, dict) or "state" not in request or not isinstance(request.get("questions"), dict):
|
| 51 |
+
raise YevError("Request JSON needs 'state' and a 'questions' object")
|
| 52 |
+
return request
|
| 53 |
+
if arguments.state is None and not arguments.image:
|
| 54 |
+
raise YevError("--state or --image is required unless --request or --interactive is used")
|
| 55 |
+
questions = {}
|
| 56 |
+
for identifier, instructions, options in arguments.choice or []:
|
| 57 |
+
questions[identifier] = Choice(instructions=instructions, criteria=parse_choice_criteria(options))
|
| 58 |
+
for identifier, instructions, levels in arguments.score or []:
|
| 59 |
+
questions[identifier] = Score(instructions=instructions, criteria=parse_score_levels(levels))
|
| 60 |
+
for identifier, instructions in arguments.noul or []:
|
| 61 |
+
questions[identifier] = Noul(instructions=instructions)
|
| 62 |
+
if not questions:
|
| 63 |
+
raise YevError("Add at least one --choice, --score or --noul question")
|
| 64 |
+
if arguments.image:
|
| 65 |
+
try:
|
| 66 |
+
images = [Image(path) for path in arguments.image]
|
| 67 |
+
except OSError as error:
|
| 68 |
+
raise YevError(f"Cannot read image: {error}") from error
|
| 69 |
+
return {"state": [arguments.state, *images] if arguments.state else images[0] if len(images) == 1 else images,
|
| 70 |
+
"questions": questions}
|
| 71 |
+
return {"state": arguments.state, "questions": questions}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def render(response, digits=None):
|
| 75 |
+
"""The response as a JSON-ready dict, optionally rounded to the API's hundredth grid."""
|
| 76 |
+
payload = response.model_dump()
|
| 77 |
+
if digits is None:
|
| 78 |
+
return payload
|
| 79 |
+
for answer in payload["answers"].values():
|
| 80 |
+
for key in ("noul", "score", "confidence"):
|
| 81 |
+
if key in answer:
|
| 82 |
+
answer[key] = round(answer[key], digits)
|
| 83 |
+
if "probabilities" in answer:
|
| 84 |
+
answer["probabilities"] = {label: round(value, digits) for label, value in answer["probabilities"].items()}
|
| 85 |
+
return payload
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def interactive(client, digits):
|
| 89 |
+
print("Enter a state, then questions as kind id instructions | criteria (blank line answers, Ctrl-D quits).")
|
| 90 |
+
print(" choice tone What is the tone? | calm,frustrated,angry")
|
| 91 |
+
print(" score urgency How urgent? | can wait|this week|today")
|
| 92 |
+
print(" noul billing Is this about billing?")
|
| 93 |
+
while True:
|
| 94 |
+
try:
|
| 95 |
+
state = input("\nstate> ").strip()
|
| 96 |
+
except EOFError:
|
| 97 |
+
return
|
| 98 |
+
if not state:
|
| 99 |
+
continue
|
| 100 |
+
questions = {}
|
| 101 |
+
while True:
|
| 102 |
+
try:
|
| 103 |
+
line = input("question> ").strip()
|
| 104 |
+
except EOFError:
|
| 105 |
+
return
|
| 106 |
+
if not line:
|
| 107 |
+
break
|
| 108 |
+
try:
|
| 109 |
+
kind, identifier, rest = line.split(None, 2)
|
| 110 |
+
instructions, _, criteria = rest.partition("|")
|
| 111 |
+
if kind == "choice":
|
| 112 |
+
questions[identifier] = Choice(instructions=instructions.strip(), criteria=parse_choice_criteria(criteria))
|
| 113 |
+
elif kind == "score":
|
| 114 |
+
questions[identifier] = Score(instructions=instructions.strip(), criteria=parse_score_levels(criteria))
|
| 115 |
+
elif kind == "noul":
|
| 116 |
+
questions[identifier] = Noul(instructions=instructions.strip())
|
| 117 |
+
else:
|
| 118 |
+
raise YevError("kind must be choice, score or noul")
|
| 119 |
+
except (ValueError, YevError) as error:
|
| 120 |
+
print(" ignored:", error)
|
| 121 |
+
if questions:
|
| 122 |
+
print(json.dumps(render(client.system_one(state, questions), digits), indent=2))
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def parse_arguments(argv=None):
|
| 126 |
+
parser = argparse.ArgumentParser(prog="yev", description="Ask an exported yev model typed questions locally.")
|
| 127 |
+
parser.add_argument("--export", required=True, help="Exported model directory, or a Hugging Face 'owner/name'")
|
| 128 |
+
parser.add_argument("--revision", help="Hub branch, tag or commit (with an 'owner/name' export)")
|
| 129 |
+
parser.add_argument("--request", type=Path, help="JSON file with 'state' and 'questions' ('-' for stdin)")
|
| 130 |
+
parser.add_argument("--state", help="State text for flag-built requests")
|
| 131 |
+
parser.add_argument("--choice", nargs=3, action="append", metavar=("ID", "INSTRUCTIONS", "OPTIONS"),
|
| 132 |
+
help="Options as 'a,b,c' or 'a=description,b=description'")
|
| 133 |
+
parser.add_argument("--score", nargs=3, action="append", metavar=("ID", "INSTRUCTIONS", "LEVELS"),
|
| 134 |
+
help="Levels low to high separated by '|'")
|
| 135 |
+
parser.add_argument("--noul", nargs=2, action="append", metavar=("ID", "INSTRUCTIONS"))
|
| 136 |
+
parser.add_argument("--image", type=Path, action="append", default=[],
|
| 137 |
+
help="An image file to add to the state, after its text (v2 exports); repeatable")
|
| 138 |
+
parser.add_argument("--windows", choices=("mean", "max"),
|
| 139 |
+
help="Score a long state in overlapping windows: average them, or take the most likely Yes (Noul only)")
|
| 140 |
+
parser.add_argument("--overlap", type=int, help="Tokens shared by consecutive windows (default: a quarter of a window)")
|
| 141 |
+
parser.add_argument("--round", action="store_true", help="Round probabilities to hundredths, like the hosted API")
|
| 142 |
+
parser.add_argument("--micro-batch", type=int, default=8)
|
| 143 |
+
parser.add_argument("--quantize4bit", action="store_true", help="NF4 4-bit backbone (CUDA and bitsandbytes only)")
|
| 144 |
+
parser.add_argument("--interactive", action="store_true")
|
| 145 |
+
return parser.parse_args(argv)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def run(argv=None):
|
| 149 |
+
"""Execute one invocation and return the response, or ``None`` in interactive mode."""
|
| 150 |
+
arguments = parse_arguments(argv)
|
| 151 |
+
digits = 2 if arguments.round else None
|
| 152 |
+
request = None if arguments.interactive else build_request(arguments)
|
| 153 |
+
client = Yev(arguments.export, quantize4bit=arguments.quantize4bit, micro_batch=arguments.micro_batch,
|
| 154 |
+
revision=arguments.revision)
|
| 155 |
+
if arguments.interactive:
|
| 156 |
+
interactive(client, digits)
|
| 157 |
+
return None
|
| 158 |
+
response = client.system_one(request["state"], request["questions"], windows=arguments.windows, overlap=arguments.overlap)
|
| 159 |
+
print(json.dumps(render(response, digits), indent=2))
|
| 160 |
+
return response
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def main(argv=None) -> int:
|
| 164 |
+
"""Console-script entry point: prints the response and returns a process exit code."""
|
| 165 |
+
try:
|
| 166 |
+
run(argv)
|
| 167 |
+
except YevError as error:
|
| 168 |
+
print(f"yev: {error}", file=sys.stderr)
|
| 169 |
+
return 2
|
| 170 |
+
except KeyboardInterrupt:
|
| 171 |
+
return 130
|
| 172 |
+
return 0
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
if __name__ == "__main__":
|
| 176 |
+
sys.exit(main())
|
src/yev/client.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A local System One client: the TypeSafe SDK's clients and call shape, answered by a local model.
|
| 2 |
+
|
| 3 |
+
``YevClient`` is ``TypeSafeClient`` and ``AsyncYevClient`` is ``AsyncTypeSafeClient``: the same
|
| 4 |
+
constructor ``model`` argument and ``YEV_DEFAULT_MODEL`` environment default, the same
|
| 5 |
+
``system_one(state, questions, *, model=None, response_model=None)``, the same ``models.list()``,
|
| 6 |
+
context managers and ``close()``. ``model`` names a Hugging Face Hub repository or an export
|
| 7 |
+
directory instead of a hosted model. The HTTP settings (API key, base URL, retries, timeouts,
|
| 8 |
+
headers, extra body) have nothing to act on and are not taken. Every question is one forward
|
| 9 |
+
pass; no text is generated and nothing leaves the machine.
|
| 10 |
+
|
| 11 |
+
Questions in one request never see each other: each is an independent sequence, so a fact
|
| 12 |
+
placed in one question cannot influence another. Answers do move by a few thousandths with
|
| 13 |
+
the batch shape, because bf16 kernels are not batch-invariant; that drift is below the
|
| 14 |
+
hosted API's hundredth display grid and is independent of what the neighbouring questions
|
| 15 |
+
say. Pass ``micro_batch=1`` for bitwise-stable answers at the cost of throughput.
|
| 16 |
+
|
| 17 |
+
from yev import Choice, Noul, Score, YevClient
|
| 18 |
+
|
| 19 |
+
with YevClient() as client: # YEV_DEFAULT_MODEL, else Yev-Decision-1 from the Hub
|
| 20 |
+
response = client.system_one(
|
| 21 |
+
state="I was charged twice. Please fix this ASAP.",
|
| 22 |
+
questions={
|
| 23 |
+
"billing": Noul(instructions="Is this ticket about billing?"),
|
| 24 |
+
"tone": Choice(instructions="What is the customer's tone?",
|
| 25 |
+
criteria={"calm": None, "frustrated": None, "angry": None}),
|
| 26 |
+
"urgency": Score(instructions="How urgent is this ticket?",
|
| 27 |
+
criteria=["can wait", "this week", "today"]),
|
| 28 |
+
},
|
| 29 |
+
)
|
| 30 |
+
response.nouls["billing"].noul
|
| 31 |
+
response.choices["tone"].choice
|
| 32 |
+
response.scores["urgency"].score
|
| 33 |
+
|
| 34 |
+
What yev adds:
|
| 35 |
+
|
| 36 |
+
- ``system_one_batch(states, questions)`` answers the same questions about many states in one
|
| 37 |
+
pass over the model, which is where a local model's throughput comes from.
|
| 38 |
+
- A state longer than the export's token budget is cut off after its start by default. Pass
|
| 39 |
+
``windows="mean"`` to score overlapping windows that cover all of it and average them, or
|
| 40 |
+
``windows="max"`` (Noul only) to answer from the window most likely to be Yes, for questions
|
| 41 |
+
such as "does any part of this contain an instruction aimed at the agent?". Make ``overlap``
|
| 42 |
+
longer than the longest span you are looking for, so each one sits whole inside some window.
|
| 43 |
+
- Images, as in the TypeSafe SDK (``Image``, ``assets=``, ``attach_assets``), in the state of a request
|
| 44 |
+
to an export with a vision backbone; yev does not read images in instructions or criteria.
|
| 45 |
+
- A Choice with one option, or a Score with one level, is answered as certain without the model.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
import asyncio
|
| 49 |
+
from collections import Counter
|
| 50 |
+
from collections.abc import Mapping, Sequence
|
| 51 |
+
from contextlib import contextmanager
|
| 52 |
+
from datetime import date
|
| 53 |
+
from functools import cached_property
|
| 54 |
+
import json
|
| 55 |
+
import os
|
| 56 |
+
from pathlib import Path
|
| 57 |
+
import re
|
| 58 |
+
import threading
|
| 59 |
+
import time
|
| 60 |
+
|
| 61 |
+
from pydantic import ValidationError
|
| 62 |
+
|
| 63 |
+
from .answers import ChoiceAnswer, ListModelsResponse, ModelMetadata, NoulAnswer, ScoreAnswer, SystemOneResponse, Usage
|
| 64 |
+
from .constants import DEFAULT_MICRO_BATCH, DEFAULT_MODEL, DEFAULT_MODEL_ENV
|
| 65 |
+
from .errors import YevError, YevModelNotFoundError, YevResponseValidationError
|
| 66 |
+
from .primitives import (
|
| 67 |
+
Choice,
|
| 68 |
+
Noul,
|
| 69 |
+
content_parts,
|
| 70 |
+
content_text,
|
| 71 |
+
holds_image,
|
| 72 |
+
is_image,
|
| 73 |
+
normalize_questions,
|
| 74 |
+
question_content,
|
| 75 |
+
question_labels_and_descriptions,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
WINDOW_MODES = (None, "mean", "max")
|
| 79 |
+
WINDOW_MARGIN = 8 # decoding a slice of tokens and encoding it again can merge or split a few
|
| 80 |
+
HUB_REPO_ID = re.compile(r"[A-Za-z0-9][\w.-]*/[A-Za-z0-9][\w.-]*")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def resolve_export(export, revision=None):
|
| 84 |
+
"""A local export directory, downloading ``owner/name`` from the Hugging Face Hub if needed.
|
| 85 |
+
|
| 86 |
+
A path that exists locally always wins. Otherwise an ``owner/name`` string is fetched with
|
| 87 |
+
``huggingface_hub.snapshot_download`` into the Hub cache, so later calls load offline;
|
| 88 |
+
private repositories use the token saved by ``hf auth login``.
|
| 89 |
+
"""
|
| 90 |
+
local = Path(export)
|
| 91 |
+
if local.is_dir():
|
| 92 |
+
return local
|
| 93 |
+
if isinstance(export, str) and HUB_REPO_ID.fullmatch(export):
|
| 94 |
+
try:
|
| 95 |
+
from huggingface_hub import snapshot_download
|
| 96 |
+
from huggingface_hub.errors import HfHubHTTPError, RepositoryNotFoundError, RevisionNotFoundError
|
| 97 |
+
except ImportError as error:
|
| 98 |
+
raise YevError("Loading from the Hugging Face Hub needs the 'inference' extra") from error
|
| 99 |
+
try:
|
| 100 |
+
return Path(snapshot_download(repo_id=export, revision=revision, repo_type="model"))
|
| 101 |
+
except (RepositoryNotFoundError, RevisionNotFoundError) as error:
|
| 102 |
+
raise YevModelNotFoundError(f"No Hugging Face model repository {export!r} visible to this login") from error
|
| 103 |
+
except HfHubHTTPError as error:
|
| 104 |
+
raise YevError(f"Could not download {export!r} from the Hugging Face Hub: {error}") from error
|
| 105 |
+
raise YevModelNotFoundError(f"No exported model directory at {local}")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def state_windows(tokenizer, text, size, overlap=None):
|
| 109 |
+
"""Overlapping slices of at most ``size`` tokens covering all of ``text``; consecutive slices
|
| 110 |
+
share ``overlap`` tokens, a quarter of a slice by default. Text that fits is returned whole."""
|
| 111 |
+
if type(size) is not int or size < 1:
|
| 112 |
+
raise YevError("The question leaves no room for the state in the token budget")
|
| 113 |
+
shared = size // 4 if overlap is None else overlap
|
| 114 |
+
if type(shared) is not int or not 0 <= shared < size:
|
| 115 |
+
raise YevError(f"overlap must be an integer from 0 to {size - 1} tokens for this question")
|
| 116 |
+
ids = tokenizer.encode(text, add_special_tokens=False)
|
| 117 |
+
if len(ids) <= size:
|
| 118 |
+
return [text]
|
| 119 |
+
starts = [0]
|
| 120 |
+
while starts[-1] + size < len(ids):
|
| 121 |
+
starts.append(starts[-1] + size - shared)
|
| 122 |
+
return [tokenizer.decode(ids[start:start + size]) for start in starts]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _typed_answer(question, labels, probabilities, descriptions):
|
| 126 |
+
"""Build the typed answer for one question from its normalized distribution."""
|
| 127 |
+
count = len(probabilities)
|
| 128 |
+
if isinstance(question, Noul):
|
| 129 |
+
return NoulAnswer(noul=probabilities[1])
|
| 130 |
+
mode = max(range(count), key=probabilities.__getitem__)
|
| 131 |
+
if isinstance(question, Choice):
|
| 132 |
+
confidence = 1.0 if count == 1 else (probabilities[mode] - 1 / count) / (1 - 1 / count)
|
| 133 |
+
return ChoiceAnswer(
|
| 134 |
+
choice=labels[mode],
|
| 135 |
+
probabilities=dict(zip(labels, probabilities)),
|
| 136 |
+
confidence=max(0.0, confidence),
|
| 137 |
+
)
|
| 138 |
+
distance = sum(value * abs(index - mode) for index, value in enumerate(probabilities))
|
| 139 |
+
uniform = sum(abs(index - (count - 1) / 2) for index in range(count)) / count
|
| 140 |
+
return ScoreAnswer(
|
| 141 |
+
score=sum(index * value for index, value in enumerate(probabilities)),
|
| 142 |
+
probabilities={index: value for index, value in enumerate(probabilities)},
|
| 143 |
+
legend=dict(enumerate(question.criteria)),
|
| 144 |
+
confidence=max(0.0, 1 - distance / uniform) if uniform else 1.0,
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def requested_model(model=None):
|
| 149 |
+
"""The model to load: the argument, else ``YEV_DEFAULT_MODEL`` unless it is blank, else ``DEFAULT_MODEL``."""
|
| 150 |
+
return model if model is not None else os.environ.get(DEFAULT_MODEL_ENV, "").strip() or DEFAULT_MODEL
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def field_path(error):
|
| 154 |
+
"""The first validation error's location as the TypeSafe SDK's dotted ``field_path``."""
|
| 155 |
+
location = list(error.errors(include_url=False)[0]["loc"])
|
| 156 |
+
if location[:1] == ["answers"] and len(location) > 2 and location[2] in ("noul", "choice", "score"):
|
| 157 |
+
del location[2] # the union member pydantic tried, not a field
|
| 158 |
+
path = ""
|
| 159 |
+
for segment in location:
|
| 160 |
+
if segment != "[key]":
|
| 161 |
+
path += f"[{segment}]" if isinstance(segment, int) else f".{segment}" if path else str(segment)
|
| 162 |
+
return path
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def typed_response(response_model, model, usage, answers, elapsed):
|
| 166 |
+
"""The response as ``response_model`` reads it: a ``SystemOneResponse`` subclass gets the answers it declares as
|
| 167 |
+
fields lifted to the top level, and any other model validates the plain response body, as in the TypeSafe SDK."""
|
| 168 |
+
if response_model is None:
|
| 169 |
+
return SystemOneResponse(model=model, usage=usage, answers=answers, elapsed_seconds=elapsed)
|
| 170 |
+
body = {"model": model, "usage": usage.model_dump(), "answers": {name: answer.model_dump() for name, answer in answers.items()}}
|
| 171 |
+
if isinstance(response_model, type) and issubclass(response_model, SystemOneResponse):
|
| 172 |
+
body["elapsed_seconds"] = elapsed
|
| 173 |
+
body |= {name: body["answers"][name] for name in set(response_model.model_fields) - set(SystemOneResponse.model_fields)
|
| 174 |
+
if name in answers}
|
| 175 |
+
try:
|
| 176 |
+
return response_model.model_validate_json(json.dumps(body))
|
| 177 |
+
except ValidationError as error:
|
| 178 |
+
raise YevResponseValidationError(field_path(error)) from error
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def model_metadata(name, export):
|
| 182 |
+
"""What ``models.list()`` reports for an export, from its ``metadata.json``."""
|
| 183 |
+
path = export / "metadata.json"
|
| 184 |
+
metadata = json.loads(path.read_text()) if path.exists() else {}
|
| 185 |
+
parts = [f"yev decision model on {metadata.get('base_model', 'an unknown backbone')}"]
|
| 186 |
+
if metadata.get("max_length"):
|
| 187 |
+
parts.append(f"{metadata['max_length']:,} tokens per question")
|
| 188 |
+
parts.append("text and images" if metadata.get("images") else "text")
|
| 189 |
+
written = date.fromtimestamp((path if path.exists() else export).stat().st_mtime)
|
| 190 |
+
return ModelMetadata(name=name, description=", ".join(parts), release_date=written.isoformat())
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
class Models:
|
| 194 |
+
"""The models a client answers with, reached through ``YevClient.models``: the default model and any a call
|
| 195 |
+
named with ``model=``."""
|
| 196 |
+
|
| 197 |
+
def __init__(self, client):
|
| 198 |
+
self._client = client
|
| 199 |
+
|
| 200 |
+
def list(self) -> ListModelsResponse:
|
| 201 |
+
"""List the models this client has: names, descriptions and export dates. Reads files only."""
|
| 202 |
+
clients = [self._client, *self._client._others.values()]
|
| 203 |
+
return ListModelsResponse(models=tuple(model_metadata(client.model, client.export) for client in clients))
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
class AttachedAssets:
|
| 207 |
+
"""A client's attached assets, keyed by reference and shared by its calls from any thread: the TypeSafe SDK's."""
|
| 208 |
+
|
| 209 |
+
def __init__(self):
|
| 210 |
+
self._lock = threading.Lock()
|
| 211 |
+
self._assets = {}
|
| 212 |
+
self._holds = Counter() # overlapping blocks may attach the same asset; it stays until the last one exits
|
| 213 |
+
|
| 214 |
+
@contextmanager
|
| 215 |
+
def attach(self, assets):
|
| 216 |
+
attached = [assets] if is_image(assets) else list(assets)
|
| 217 |
+
for asset in attached:
|
| 218 |
+
if not is_image(asset):
|
| 219 |
+
raise YevError(f"Attached value {asset!r} must be an asset.")
|
| 220 |
+
with self._lock:
|
| 221 |
+
for asset in attached:
|
| 222 |
+
self._assets[str(asset)] = asset
|
| 223 |
+
self._holds[str(asset)] += 1
|
| 224 |
+
try:
|
| 225 |
+
yield
|
| 226 |
+
finally:
|
| 227 |
+
with self._lock:
|
| 228 |
+
for asset in attached:
|
| 229 |
+
self._holds[str(asset)] -= 1
|
| 230 |
+
if not self._holds[str(asset)]:
|
| 231 |
+
del self._holds[str(asset)], self._assets[str(asset)]
|
| 232 |
+
|
| 233 |
+
def snapshot(self):
|
| 234 |
+
with self._lock:
|
| 235 |
+
return dict(self._assets)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def named_assets(assets):
|
| 239 |
+
"""The ``assets`` a call names, checked as the TypeSafe SDK checks them."""
|
| 240 |
+
for name, asset in (assets or {}).items():
|
| 241 |
+
if not isinstance(name, str) or not name.strip():
|
| 242 |
+
raise YevError("Asset aliases must be non-empty strings.")
|
| 243 |
+
if not is_image(asset):
|
| 244 |
+
raise YevError(f"Asset {name!r} must be an asset.")
|
| 245 |
+
return dict(assets or {})
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def checked_request(states, questions, windows, overlap, known=()):
|
| 249 |
+
"""The request's questions as yev questions, once ``states``, the window settings and where images are are
|
| 250 |
+
checked; ``known`` holds the references of the images the request can use."""
|
| 251 |
+
if isinstance(states, (str, Mapping)) or not isinstance(states, Sequence):
|
| 252 |
+
raise YevError("states must be a list of states")
|
| 253 |
+
if any(state is None for state in states):
|
| 254 |
+
raise YevError("State is required.")
|
| 255 |
+
questions = normalize_questions(questions)
|
| 256 |
+
for name, question in questions.items():
|
| 257 |
+
if any(holds_image(value, known) for value in question_content(question)):
|
| 258 |
+
raise YevError(f'Question "{name}" holds an image; yev reads images in the state only. Put the image in the '
|
| 259 |
+
"state and refer to it by name, as in Is the dog in `photo` wearing a collar?")
|
| 260 |
+
if windows not in WINDOW_MODES:
|
| 261 |
+
raise YevError(f"windows must be one of {WINDOW_MODES}")
|
| 262 |
+
if windows is None and overlap is not None:
|
| 263 |
+
raise YevError("overlap applies only when windows is set")
|
| 264 |
+
if windows == "max" and any(not isinstance(question, Noul) for question in questions.values()):
|
| 265 |
+
raise YevError("windows='max' answers Noul questions only")
|
| 266 |
+
return questions
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def question_examples(name, question, state_text, digests, header_text, tokenizer, max_length, windows, overlap):
|
| 270 |
+
"""The sequences one question about one state is answered from: the state, or each window of it. A question with
|
| 271 |
+
one option has none: it is certain, and the heads need two."""
|
| 272 |
+
from .training.data import DecisionExample
|
| 273 |
+
from .training.model import encode_example
|
| 274 |
+
|
| 275 |
+
labels, descriptions = question_labels_and_descriptions(question)
|
| 276 |
+
if len(labels) == 1:
|
| 277 |
+
return []
|
| 278 |
+
instructions = content_text(question.instructions)
|
| 279 |
+
|
| 280 |
+
def example(text):
|
| 281 |
+
return DecisionExample(uid=name, group=name, source="yev", split="test", kind=question.type, state=text,
|
| 282 |
+
instructions=instructions, criteria=descriptions, target=[1 / len(descriptions)] * len(descriptions),
|
| 283 |
+
images=digests) # empty whenever windows are used
|
| 284 |
+
|
| 285 |
+
if windows is None:
|
| 286 |
+
return [example(header_text + state_text)]
|
| 287 |
+
try:
|
| 288 |
+
overhead = len(encode_example(example(header_text), tokenizer, max_length)["input_ids"])
|
| 289 |
+
except ValueError as error:
|
| 290 |
+
raise YevError(f"Question {name!r} does not fit the token budget: {error}") from error
|
| 291 |
+
return [example(header_text + part) for part in state_windows(tokenizer, state_text, max_length - overhead - WINDOW_MARGIN, overlap)]
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def combined_answer(question, rows, windows):
|
| 295 |
+
"""The typed answer from a question's sequence distributions: the window most likely Yes for ``windows="max"``,
|
| 296 |
+
their mean otherwise, and certainty when there are none."""
|
| 297 |
+
labels = question_labels_and_descriptions(question)[0]
|
| 298 |
+
rows = [row[:len(labels)].tolist() for row in rows] or [[1.0]]
|
| 299 |
+
if windows == "max":
|
| 300 |
+
probabilities = max(rows, key=lambda row: row[1])
|
| 301 |
+
else:
|
| 302 |
+
probabilities = [sum(column) / len(rows) for column in zip(*rows)]
|
| 303 |
+
return _typed_answer(question, labels, probabilities, None)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
class YevClient:
|
| 307 |
+
"""A System One client backed by a local exported model: the TypeSafe SDK's ``TypeSafeClient``.
|
| 308 |
+
|
| 309 |
+
Args:
|
| 310 |
+
model: The model: a Hugging Face Hub id such as ``"owner/name"``, or an export directory
|
| 311 |
+
holding ``adapter/``, ``heads.pt``, ``config.json``, ``metadata.json`` and the
|
| 312 |
+
tokenizer. Defaults to ``YEV_DEFAULT_MODEL``, then ``yev.constants.DEFAULT_MODEL``.
|
| 313 |
+
revision: Hub branch, tag or commit to download; ignored for a local directory.
|
| 314 |
+
quantize4bit: Load the backbone's language model in NF4 4-bit (bitsandbytes 0.50 or later, the
|
| 315 |
+
``quantized`` extra) on a CUDA GPU, a CPU or an Apple GPU; the vision encoder stays in bf16.
|
| 316 |
+
micro_batch: Question sequences evaluated per forward pass.
|
| 317 |
+
lazy: Defer loading weights until the first request.
|
| 318 |
+
|
| 319 |
+
Raises:
|
| 320 |
+
YevModelNotFoundError: The directory does not exist, or the Hub has no such repository.
|
| 321 |
+
YevError: ``micro_batch`` is not a positive integer, or the download failed.
|
| 322 |
+
"""
|
| 323 |
+
|
| 324 |
+
def __init__(self, model=None, *, revision=None, quantize4bit=False, micro_batch=DEFAULT_MICRO_BATCH, lazy=True):
|
| 325 |
+
if type(micro_batch) is not int or micro_batch < 1:
|
| 326 |
+
raise YevError("micro_batch must be a positive integer")
|
| 327 |
+
requested = requested_model(model)
|
| 328 |
+
hub = not Path(requested).is_dir() and isinstance(requested, str) and HUB_REPO_ID.fullmatch(requested)
|
| 329 |
+
self.export = resolve_export(requested, revision)
|
| 330 |
+
self.quantize4bit = quantize4bit
|
| 331 |
+
self.micro_batch = micro_batch
|
| 332 |
+
resolved = self.export.resolve()
|
| 333 |
+
default = requested.split("/")[1] if hub else (
|
| 334 |
+
resolved.parent.name if resolved.name in ("export", "model") else resolved.name)
|
| 335 |
+
self.model = default or "yev"
|
| 336 |
+
"""The name answers report."""
|
| 337 |
+
self._requested = str(requested)
|
| 338 |
+
self._model = None
|
| 339 |
+
self._tokenizer = None
|
| 340 |
+
self._others = {}
|
| 341 |
+
self._attached = AttachedAssets()
|
| 342 |
+
if not lazy:
|
| 343 |
+
self._load()
|
| 344 |
+
|
| 345 |
+
@cached_property
|
| 346 |
+
def models(self) -> Models:
|
| 347 |
+
"""An accessor for the models this client answers with: ``client.models.list()``."""
|
| 348 |
+
return Models(self)
|
| 349 |
+
|
| 350 |
+
def _load(self):
|
| 351 |
+
if self._model is None:
|
| 352 |
+
from .training.run import reload_export
|
| 353 |
+
|
| 354 |
+
self._model, self._tokenizer = reload_export(self.export, quantize4bit=self.quantize4bit)
|
| 355 |
+
return self._model, self._tokenizer
|
| 356 |
+
|
| 357 |
+
def _other(self, model):
|
| 358 |
+
"""The client for a model a call names that is not this one's, loaded once and kept until ``close``."""
|
| 359 |
+
if model not in self._others:
|
| 360 |
+
self._others[model] = YevClient(model, quantize4bit=self.quantize4bit, micro_batch=self.micro_batch)
|
| 361 |
+
self._others[model]._attached = self._attached # attached assets hold for every model the client calls
|
| 362 |
+
return self._others[model]
|
| 363 |
+
|
| 364 |
+
@property
|
| 365 |
+
def max_length(self) -> int:
|
| 366 |
+
"""Token budget per question, from the export's training configuration."""
|
| 367 |
+
return self._load()[0].training_config.max_length
|
| 368 |
+
|
| 369 |
+
@property
|
| 370 |
+
def base_model(self) -> str:
|
| 371 |
+
"""Hugging Face id of the frozen backbone this export adapts."""
|
| 372 |
+
return self._load()[0].training_config.model_id
|
| 373 |
+
|
| 374 |
+
def attach_assets(self, assets):
|
| 375 |
+
"""Attach assets to calls made within a ``with`` block, for referencing them inside strings.
|
| 376 |
+
|
| 377 |
+
Until the block exits, a state holding an attached asset's reference, ``str(asset)``, reads the
|
| 378 |
+
asset in its place. Only references to attached assets, assets passed in ``assets`` and assets
|
| 379 |
+
placed in the content are recognised; any other is plain text.
|
| 380 |
+
|
| 381 |
+
Raises:
|
| 382 |
+
YevError: An attached value is not an asset.
|
| 383 |
+
"""
|
| 384 |
+
return self._attached.attach(assets)
|
| 385 |
+
|
| 386 |
+
def system_one(self, state, questions, *, model=None, assets=None, response_model=None, windows=None, overlap=None,
|
| 387 |
+
header=""):
|
| 388 |
+
"""Answer named questions about a state.
|
| 389 |
+
|
| 390 |
+
Args:
|
| 391 |
+
state: Text, a JSON object or an array to evaluate, which may hold ``Image``s.
|
| 392 |
+
questions: Nonempty mapping of names to ``Noul``, ``Choice`` or ``Score`` questions,
|
| 393 |
+
question dictionaries, or TypeSafe SDK question objects.
|
| 394 |
+
model: Model override, a Hub id or export directory; ``None`` uses this client's.
|
| 395 |
+
Another model is loaded on first use and kept until ``close``.
|
| 396 |
+
assets: Images keyed by the reference used for them in the state.
|
| 397 |
+
response_model: Optional Pydantic model for the response body; a ``SystemOneResponse``
|
| 398 |
+
subclass gets the answers it declares as fields, by question name.
|
| 399 |
+
windows: ``None`` answers from the start of a state too long for the token
|
| 400 |
+
budget; ``"mean"`` averages overlapping windows covering all of it; ``"max"``
|
| 401 |
+
takes the window most likely to be Yes (Noul questions only).
|
| 402 |
+
overlap: Tokens shared by consecutive windows; a quarter of a window by default.
|
| 403 |
+
header: Text placed before the state, and before every window of it, whole.
|
| 404 |
+
|
| 405 |
+
Returns:
|
| 406 |
+
A ``SystemOneResponse`` with answers keyed by question name, or a ``response_model``.
|
| 407 |
+
|
| 408 |
+
Raises:
|
| 409 |
+
YevError: The request is invalid before the model reads it: no state or questions, a
|
| 410 |
+
question that is malformed, holds an image or has more options than yev answers, an
|
| 411 |
+
asset that is not an image or not referenced, or window settings that do not fit.
|
| 412 |
+
YevResponseValidationError: The answers do not fit ``response_model``.
|
| 413 |
+
"""
|
| 414 |
+
return self.system_one_batch([state], questions, model=model, assets=assets, response_model=response_model,
|
| 415 |
+
windows=windows, overlap=overlap, header=header)[0]
|
| 416 |
+
|
| 417 |
+
def system_one_batch(self, states, questions, *, model=None, assets=None, response_model=None, windows=None, overlap=None,
|
| 418 |
+
header=""):
|
| 419 |
+
"""Answer the same named questions about each of ``states`` in one pass over the model, a response per state in
|
| 420 |
+
order; the arguments are ``system_one``'s."""
|
| 421 |
+
if model not in (None, self.model, self._requested):
|
| 422 |
+
return self._other(model).system_one_batch(states, questions, assets=assets, response_model=response_model,
|
| 423 |
+
windows=windows, overlap=overlap, header=header)
|
| 424 |
+
named = named_assets(assets)
|
| 425 |
+
known = {**self._attached.snapshot(), **{str(asset): asset for asset in named.values()}, **named}
|
| 426 |
+
questions = checked_request(states, questions, windows, overlap, known)
|
| 427 |
+
from .training.model import collator_for
|
| 428 |
+
|
| 429 |
+
loaded, tokenizer = self._load()
|
| 430 |
+
max_length = loaded.training_config.max_length
|
| 431 |
+
used = set()
|
| 432 |
+
parsed = [content_parts(state, known, used) for state in states]
|
| 433 |
+
unreferenced = sorted(named.keys() - used)
|
| 434 |
+
if unreferenced:
|
| 435 |
+
raise YevError(f"Asset {unreferenced[0]!r} is not referenced in the state.")
|
| 436 |
+
vision = self._vision(loaded, parsed, windows)
|
| 437 |
+
header_text = content_text(header) if header else ""
|
| 438 |
+
prepared, examples, owners = [], [], []
|
| 439 |
+
for index, (state_text, images) in enumerate(parsed):
|
| 440 |
+
digests = [vision.store.put(data) for data in images]
|
| 441 |
+
for name, question in questions.items():
|
| 442 |
+
parts = question_examples(name, question, state_text, digests, header_text, tokenizer, max_length, windows, overlap)
|
| 443 |
+
prepared.append((index, name, question, len(examples), len(parts)))
|
| 444 |
+
examples.extend(parts)
|
| 445 |
+
owners.extend([index] * len(parts))
|
| 446 |
+
started = time.perf_counter()
|
| 447 |
+
distributions, tokens = self._forward(loaded, collator_for(loaded, tokenizer, max_length, vision), examples, owners,
|
| 448 |
+
len(states))
|
| 449 |
+
elapsed = round(time.perf_counter() - started, 4)
|
| 450 |
+
answers = [{} for _ in states]
|
| 451 |
+
for index, name, question, first, count in prepared:
|
| 452 |
+
answers[index][name] = combined_answer(question, distributions[first:first + count], windows)
|
| 453 |
+
return [typed_response(response_model, self.model, Usage(input_tokens=tokens[index], output_tokens=0), answers[index],
|
| 454 |
+
elapsed) for index in range(len(states))]
|
| 455 |
+
|
| 456 |
+
def _vision(self, loaded, parsed, windows):
|
| 457 |
+
"""The export's vision settings, with an image store of this request's own when a state holds images."""
|
| 458 |
+
vision = getattr(loaded, "vision", None)
|
| 459 |
+
if not any(images for _, images in parsed):
|
| 460 |
+
return vision
|
| 461 |
+
if vision is None:
|
| 462 |
+
raise YevError(f"{self.model} reads text only; images need a v2 export (and Pillow installed)")
|
| 463 |
+
if windows is not None:
|
| 464 |
+
raise YevError("windows cannot split a state that holds images")
|
| 465 |
+
from dataclasses import replace
|
| 466 |
+
|
| 467 |
+
from .training.images import ImageStore
|
| 468 |
+
|
| 469 |
+
return replace(vision, store=ImageStore())
|
| 470 |
+
|
| 471 |
+
def _forward(self, loaded, collator, examples, owners, count):
|
| 472 |
+
"""Each example's distribution, in order, and the tokens read for each of ``count`` states."""
|
| 473 |
+
from .training.run import device_batch, evaluation_mode
|
| 474 |
+
|
| 475 |
+
distributions, tokens = [], [0] * count
|
| 476 |
+
if not examples:
|
| 477 |
+
return distributions, tokens
|
| 478 |
+
with evaluation_mode(loaded):
|
| 479 |
+
for start in range(0, len(examples), self.micro_batch):
|
| 480 |
+
batch = device_batch(loaded, collator, examples[start:start + self.micro_batch])
|
| 481 |
+
for owner, read in zip(owners[start:start + self.micro_batch], batch["attention_mask"].sum(-1).tolist()):
|
| 482 |
+
tokens[owner] += int(read)
|
| 483 |
+
distributions.extend(loaded(**batch)["logits"].float().softmax(-1).cpu())
|
| 484 |
+
return distributions, tokens
|
| 485 |
+
|
| 486 |
+
def close(self) -> None:
|
| 487 |
+
"""Release the loaded weights, of every model this client loaded."""
|
| 488 |
+
for other in self._others.values():
|
| 489 |
+
other.close()
|
| 490 |
+
self._others = {}
|
| 491 |
+
self._model = None
|
| 492 |
+
self._tokenizer = None
|
| 493 |
+
|
| 494 |
+
def __enter__(self):
|
| 495 |
+
self._load()
|
| 496 |
+
return self
|
| 497 |
+
|
| 498 |
+
def __exit__(self, *exception):
|
| 499 |
+
self.close()
|
| 500 |
+
return False
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
Yev = YevClient
|
| 504 |
+
"""The name yev's client had first; the same class."""
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
class AsyncModels:
|
| 508 |
+
"""The models an ``AsyncYevClient`` answers with: ``await client.models.list()``."""
|
| 509 |
+
|
| 510 |
+
def __init__(self, client):
|
| 511 |
+
self._client = client
|
| 512 |
+
|
| 513 |
+
async def list(self) -> ListModelsResponse:
|
| 514 |
+
"""List the models this client has: names, descriptions and export dates."""
|
| 515 |
+
return self._client.client.models.list()
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
class AsyncYevClient:
|
| 519 |
+
"""The TypeSafe SDK's ``AsyncTypeSafeClient`` over a ``YevClient``, with the same arguments. Each call runs in a
|
| 520 |
+
worker thread, one at a time (a model answers one batch at a time), so the event loop stays free meanwhile."""
|
| 521 |
+
|
| 522 |
+
def __init__(self, model=None, *, revision=None, quantize4bit=False, micro_batch=DEFAULT_MICRO_BATCH, lazy=True):
|
| 523 |
+
self.client = YevClient(model, revision=revision, quantize4bit=quantize4bit, micro_batch=micro_batch, lazy=lazy)
|
| 524 |
+
"""The synchronous client answering for this one."""
|
| 525 |
+
self._lock = asyncio.Lock()
|
| 526 |
+
|
| 527 |
+
@property
|
| 528 |
+
def model(self) -> str:
|
| 529 |
+
"""The name answers report."""
|
| 530 |
+
return self.client.model
|
| 531 |
+
|
| 532 |
+
@cached_property
|
| 533 |
+
def models(self) -> AsyncModels:
|
| 534 |
+
"""An accessor for the models this client answers with: ``await client.models.list()``."""
|
| 535 |
+
return AsyncModels(self)
|
| 536 |
+
|
| 537 |
+
def attach_assets(self, assets):
|
| 538 |
+
"""``YevClient.attach_assets``: assets referenced inside strings by calls made within a ``with`` block."""
|
| 539 |
+
return self.client.attach_assets(assets)
|
| 540 |
+
|
| 541 |
+
async def system_one(self, state, questions, *, model=None, assets=None, response_model=None, windows=None, overlap=None,
|
| 542 |
+
header=""):
|
| 543 |
+
"""``YevClient.system_one``, awaited."""
|
| 544 |
+
async with self._lock:
|
| 545 |
+
return await asyncio.to_thread(self.client.system_one, state, questions, model=model, assets=assets,
|
| 546 |
+
response_model=response_model, windows=windows, overlap=overlap, header=header)
|
| 547 |
+
|
| 548 |
+
async def system_one_batch(self, states, questions, *, model=None, assets=None, response_model=None, windows=None,
|
| 549 |
+
overlap=None, header=""):
|
| 550 |
+
"""``YevClient.system_one_batch``, awaited."""
|
| 551 |
+
async with self._lock:
|
| 552 |
+
return await asyncio.to_thread(self.client.system_one_batch, states, questions, model=model, assets=assets,
|
| 553 |
+
response_model=response_model, windows=windows, overlap=overlap, header=header)
|
| 554 |
+
|
| 555 |
+
async def aclose(self) -> None:
|
| 556 |
+
"""Release the loaded weights."""
|
| 557 |
+
self.client.close()
|
| 558 |
+
|
| 559 |
+
async def __aenter__(self):
|
| 560 |
+
await asyncio.to_thread(self.client._load)
|
| 561 |
+
return self
|
| 562 |
+
|
| 563 |
+
async def __aexit__(self, *exception):
|
| 564 |
+
await self.aclose()
|
src/yev/constants.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Public environment-variable names and client defaults, as ``typesafe_sdk.constants`` holds them."""
|
| 2 |
+
|
| 3 |
+
DEFAULT_MODEL_ENV = "YEV_DEFAULT_MODEL"
|
| 4 |
+
"""Environment variable for the default model: a Hugging Face Hub id or an export directory."""
|
| 5 |
+
|
| 6 |
+
DEFAULT_MODEL = "HallD/Yev-Decision-1"
|
| 7 |
+
"""Default model: Yev-Decision-1, a 4B trained without Jev."""
|
| 8 |
+
|
| 9 |
+
DEFAULT_MICRO_BATCH = 8
|
| 10 |
+
"""Default number of question sequences scored per forward pass."""
|
src/yev/errors.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exceptions raised by the yev client: ``YevError`` is the TypeSafe SDK's ``TypeSafeError``."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class YevError(Exception):
|
| 5 |
+
"""Base exception for every error raised by this package."""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class YevModelNotFoundError(YevError, FileNotFoundError):
|
| 9 |
+
"""The requested export directory does not exist or is not a model export."""
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class YevResponseValidationError(YevError):
|
| 13 |
+
"""The answers do not fit the ``response_model`` the call asked for."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, field_path: str) -> None:
|
| 16 |
+
super().__init__(f"Invalid response data at {field_path!r}.")
|
| 17 |
+
self.field_path = field_path
|
| 18 |
+
"""Dotted path to the offending field, such as ``answers.tone.confidence``."""
|
src/yev/primitives.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Question primitives and images, ported from the TypeSafe SDK (0.7.5): ``Noul``, ``Choice``, ``Score``,
|
| 2 |
+
``Box`` and ``Image``.
|
| 3 |
+
|
| 4 |
+
The same fields, types, defaults and wire form (an unset optional field is left out of
|
| 5 |
+
``model_dump``), the same question dictionaries (``NoulModel``, ``ChoiceModel``, ``ScoreModel``,
|
| 6 |
+
``BoxModel``) and the same checks, with the same messages. Instructions and criteria accept text,
|
| 7 |
+
JSON objects or arrays; non-string content is rendered as compact JSON before it reaches the
|
| 8 |
+
model. ``normalize_questions`` also takes TypeSafe SDK question objects, and states take TypeSafe
|
| 9 |
+
SDK images, so code written against ``typesafe_sdk`` runs on ``yev``. The differences: yev
|
| 10 |
+
questions are frozen; the number of options is also checked against what yev's heads answer; yev
|
| 11 |
+
reads images in the state only, not in instructions or criteria; and it has no head for ``Box``
|
| 12 |
+
questions, so asking one is an error.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from base64 import b64decode
|
| 16 |
+
from collections.abc import Mapping, Sequence
|
| 17 |
+
import io
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
import re
|
| 22 |
+
from typing import Any, Literal, NotRequired
|
| 23 |
+
from uuid import uuid4
|
| 24 |
+
|
| 25 |
+
from pydantic import BaseModel, ConfigDict, Field, model_serializer
|
| 26 |
+
from pydantic.functional_serializers import SerializerFunctionWrapHandler
|
| 27 |
+
from pydantic_core import core_schema
|
| 28 |
+
from typing_extensions import TypedDict # closed=True, which typing's TypedDict takes from Python 3.15
|
| 29 |
+
|
| 30 |
+
from .errors import YevError
|
| 31 |
+
|
| 32 |
+
type JSONValue = str | int | float | bool | Sequence[JSONValue | None] | Mapping[str, JSONValue | None]
|
| 33 |
+
"""A JSON-like value. May be nested and contain ``None``."""
|
| 34 |
+
|
| 35 |
+
type JSONContent = str | Mapping[str, JSONValue | None] | Sequence[JSONValue | None]
|
| 36 |
+
"""Either a plain string or a mapping/sequence of ``JSONValue`` entries."""
|
| 37 |
+
|
| 38 |
+
CHOICE_LIMITS = (1, 255) # options yev answers; a single option is certain and needs no model
|
| 39 |
+
SCORE_LIMITS = (1, 10) # levels yev's heads were trained on, from zero
|
| 40 |
+
REFERENCE = re.compile(r"<image:[0-9a-f]{32}>") # str(image), in yev and in the TypeSafe SDK alike
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class Image:
|
| 44 |
+
"""An image asset: the TypeSafe SDK's ``Image``.
|
| 45 |
+
|
| 46 |
+
Made from image file bytes or any other buffer (``bytearray``, ``memoryview``, ``mmap``), a path,
|
| 47 |
+
base64 text (``Image.from_base64``) or a Pillow image (``Image.from_pil``); yev also takes a path
|
| 48 |
+
given as a string, a Pillow image directly and a TypeSafe SDK ``Image``. Put it in a state on its
|
| 49 |
+
own, in a list with text (``["Is this paid?", Image(Path("receipt.png"))]``) or as a value in a
|
| 50 |
+
JSON object; ``str(image)`` is its reference, for placing it inside text (see
|
| 51 |
+
``YevClient.attach_assets``). Only an export with a vision backbone reads it.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
__slots__ = ("_reference", "data")
|
| 55 |
+
type = "image"
|
| 56 |
+
"""Asset type discriminator, always ``"image"``."""
|
| 57 |
+
|
| 58 |
+
def __init__(self, data):
|
| 59 |
+
if isinstance(data, Image):
|
| 60 |
+
data = data.data
|
| 61 |
+
elif isinstance(data, (str, os.PathLike)):
|
| 62 |
+
data = Path(data).read_bytes()
|
| 63 |
+
elif callable(getattr(data, "save", None)): # a Pillow image
|
| 64 |
+
buffer = io.BytesIO()
|
| 65 |
+
data.save(buffer, format="PNG")
|
| 66 |
+
data = buffer.getvalue()
|
| 67 |
+
elif getattr(data, "type", None) == "image" and hasattr(data, "data"): # a TypeSafe SDK Image
|
| 68 |
+
data = Image(data.data).data
|
| 69 |
+
else:
|
| 70 |
+
try:
|
| 71 |
+
data = memoryview(data).tobytes()
|
| 72 |
+
except TypeError as error:
|
| 73 |
+
raise TypeError(f"An Image is made from a path, bytes or a Pillow image, not {type(data).__name__}") from error
|
| 74 |
+
if not data:
|
| 75 |
+
raise ValueError("An Image needs non-empty data")
|
| 76 |
+
self.data = data
|
| 77 |
+
"""The image file bytes."""
|
| 78 |
+
self._reference = f"<image:{uuid4().hex}>"
|
| 79 |
+
|
| 80 |
+
@classmethod
|
| 81 |
+
def from_base64(cls, data):
|
| 82 |
+
"""An ``Image`` from base64 text or an ASCII buffer; whitespace, such as wrapped lines, is ignored.
|
| 83 |
+
|
| 84 |
+
Raises:
|
| 85 |
+
YevError: The data isn't valid base64 text.
|
| 86 |
+
"""
|
| 87 |
+
text = data if isinstance(data, str) else memoryview(data).tobytes().decode("latin-1")
|
| 88 |
+
text = "".join(text.split()) if text.isascii() else ""
|
| 89 |
+
if not text:
|
| 90 |
+
raise YevError("Image data must contain non-empty ASCII base64 text.")
|
| 91 |
+
try:
|
| 92 |
+
return cls(b64decode(text, validate=True))
|
| 93 |
+
except ValueError as error:
|
| 94 |
+
raise YevError("Image data must contain valid base64 text.") from error
|
| 95 |
+
|
| 96 |
+
@classmethod
|
| 97 |
+
def from_pil(cls, image, format="jpeg"): # Pillow's own name for it
|
| 98 |
+
"""An ``Image`` from a Pillow image, saved with Pillow's defaults except JPEG quality 95.
|
| 99 |
+
|
| 100 |
+
Raises:
|
| 101 |
+
YevError: Pillow can't encode the image in this format.
|
| 102 |
+
"""
|
| 103 |
+
buffer = io.BytesIO()
|
| 104 |
+
try:
|
| 105 |
+
image.save(buffer, format=format, quality=95)
|
| 106 |
+
except (KeyError, OSError, ValueError) as error: # Pillow raises KeyError for a format it doesn't know
|
| 107 |
+
raise YevError(f"Pillow image could not be encoded as {format}: {error}") from error
|
| 108 |
+
return cls(buffer.getvalue())
|
| 109 |
+
|
| 110 |
+
def __str__(self):
|
| 111 |
+
"""The unique reference to this image, for using it inside text, such as an f-string."""
|
| 112 |
+
return self._reference
|
| 113 |
+
|
| 114 |
+
def __repr__(self):
|
| 115 |
+
return f"Image({len(self.data)} bytes)"
|
| 116 |
+
|
| 117 |
+
@classmethod
|
| 118 |
+
def __get_pydantic_core_schema__(cls, source, handler):
|
| 119 |
+
return core_schema.is_instance_schema(cls) # question objects hold an Image as it is
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
type Asset = Image
|
| 123 |
+
"""An asset referenced from request content; only images are supported."""
|
| 124 |
+
|
| 125 |
+
type ContentValue = str | int | float | bool | Sequence[ContentValue | None] | Mapping[str, ContentValue | None] | Image
|
| 126 |
+
"""A JSON-like value that may also hold images. May be nested and contain ``None``."""
|
| 127 |
+
|
| 128 |
+
type Content = str | Mapping[str, ContentValue | None] | Sequence[ContentValue | None] | Image
|
| 129 |
+
"""Text, an image, or a mapping/sequence of ``ContentValue`` entries."""
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def is_image(value) -> bool:
|
| 133 |
+
"""A yev ``Image`` or a TypeSafe SDK one."""
|
| 134 |
+
return isinstance(value, Image) or (not isinstance(value, Mapping) and getattr(value, "type", None) == "image"
|
| 135 |
+
and hasattr(value, "data"))
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class NoulCriteria(TypedDict, total=False, closed=True):
|
| 139 |
+
"""Optional descriptions of the yes and no outcomes."""
|
| 140 |
+
|
| 141 |
+
true: Content | None
|
| 142 |
+
"""Description of the yes outcome as text, an asset, a JSON object, or an array; ``None`` leaves it undescribed."""
|
| 143 |
+
false: Content | None
|
| 144 |
+
"""Description of the no outcome as text, an asset, a JSON object, or an array; ``None`` leaves it undescribed."""
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class NoulModel(TypedDict, closed=True):
|
| 148 |
+
"""A yes/no question dictionary with ``type="noul"``."""
|
| 149 |
+
|
| 150 |
+
type: Literal["noul"]
|
| 151 |
+
instructions: NotRequired[Content | None]
|
| 152 |
+
"""The question to ask, as text, an asset, a JSON object, or an array; optional when ``criteria`` describes an outcome."""
|
| 153 |
+
criteria: NotRequired[NoulCriteria | None]
|
| 154 |
+
"""Optional descriptions of the yes and no outcomes; required when ``instructions`` is ``None``."""
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
class ChoiceModel(TypedDict, closed=True):
|
| 158 |
+
"""A choice question dictionary with ``type="choice"``."""
|
| 159 |
+
|
| 160 |
+
type: Literal["choice"]
|
| 161 |
+
instructions: NotRequired[Content | None]
|
| 162 |
+
"""The question to ask, expressed as text, an asset, a JSON object, or an array; optional."""
|
| 163 |
+
criteria: Mapping[str, Content | None]
|
| 164 |
+
"""A nonempty mapping of labels to text, asset, object, or array descriptions, or ``None`` for undescribed labels."""
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class ScoreModel(TypedDict, closed=True):
|
| 168 |
+
"""A score question dictionary with ``type="score"``."""
|
| 169 |
+
|
| 170 |
+
type: Literal["score"]
|
| 171 |
+
instructions: NotRequired[Content | None]
|
| 172 |
+
"""The question to ask, expressed as text, an asset, a JSON object, or an array; optional."""
|
| 173 |
+
criteria: Sequence[Content]
|
| 174 |
+
"""A nonempty, ordered list of text, asset, object, or array descriptions, one per score from zero."""
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
class BoxModel(TypedDict, closed=True):
|
| 178 |
+
"""A box question dictionary with ``type="box"``."""
|
| 179 |
+
|
| 180 |
+
type: Literal["box"]
|
| 181 |
+
instructions: NotRequired[Content | None]
|
| 182 |
+
"""What to locate in the request's image, expressed as text, an asset, a JSON object, or an array; optional."""
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class _Question(BaseModel):
|
| 186 |
+
"""Reject unknown fields, freeze, and omit optional fields left at their default from the wire form."""
|
| 187 |
+
|
| 188 |
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
| 189 |
+
|
| 190 |
+
@model_serializer(mode="wrap")
|
| 191 |
+
def _omit_none(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
|
| 192 |
+
# An unset optional field is left off, while ``None`` values nested inside criteria or instructions are kept.
|
| 193 |
+
return {key: value for key, value in handler(self).items() if value is not None}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
class Noul(_Question):
|
| 197 |
+
"""A yes/no question with optional descriptions for either outcome."""
|
| 198 |
+
|
| 199 |
+
type: Literal["noul"] = "noul"
|
| 200 |
+
instructions: Content | None = None
|
| 201 |
+
"""The question to ask, as text, an asset, a JSON object, or an array; optional when ``criteria`` describes an outcome."""
|
| 202 |
+
criteria: NoulCriteria | None = None
|
| 203 |
+
"""Optional descriptions of the yes and no outcomes; required when ``instructions`` is ``None``."""
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
class Choice(_Question):
|
| 207 |
+
"""A question that selects between named alternatives."""
|
| 208 |
+
|
| 209 |
+
type: Literal["choice"] = "choice"
|
| 210 |
+
criteria: Mapping[str, Content | None]
|
| 211 |
+
"""A nonempty mapping of labels to text, asset, object, or array descriptions, or ``None`` for undescribed labels."""
|
| 212 |
+
instructions: Content | None = None
|
| 213 |
+
"""The question to ask, expressed as text, an asset, a JSON object, or an array; optional."""
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
class Score(_Question):
|
| 217 |
+
"""A question that assigns a score using an ordered rubric."""
|
| 218 |
+
|
| 219 |
+
type: Literal["score"] = "score"
|
| 220 |
+
criteria: Sequence[Content] = Field(min_length=1)
|
| 221 |
+
"""A nonempty, ordered list of text, asset, object, or array descriptions, one per score from zero."""
|
| 222 |
+
instructions: Content | None = None
|
| 223 |
+
"""The question to ask, expressed as text, an asset, a JSON object, or an array; optional."""
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class Box(_Question):
|
| 227 |
+
"""A question that locates what its instructions describe in the request's image. yev has no head for it: asking
|
| 228 |
+
one raises ``YevError``. It is here so TypeSafe SDK code that builds one runs until it asks."""
|
| 229 |
+
|
| 230 |
+
type: Literal["box"] = "box"
|
| 231 |
+
instructions: Content | None = None
|
| 232 |
+
"""What to locate in the request's image, expressed as text, an asset, a JSON object, or an array; optional."""
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
type QuestionModel = NoulModel | ChoiceModel | ScoreModel | BoxModel
|
| 236 |
+
"""A question dictionary identified by its ``type`` key."""
|
| 237 |
+
type Question = Noul | Choice | Score | Box | QuestionModel
|
| 238 |
+
"""A question object or question dictionary."""
|
| 239 |
+
type Questions = Mapping[str, Question]
|
| 240 |
+
"""Question inputs keyed by the names used to identify their answers."""
|
| 241 |
+
QUESTION_TYPES = {"noul": Noul, "choice": Choice, "score": Score, "box": Box}
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
class ContentReader:
|
| 245 |
+
"""Reads content as the model's text, replacing each image with the image placeholder; see ``content_parts``."""
|
| 246 |
+
|
| 247 |
+
def __init__(self, known, used):
|
| 248 |
+
self.known, self.used, self.images = known, used, []
|
| 249 |
+
ordered = sorted(known, key=len, reverse=True) # at one position, the longest reference wins
|
| 250 |
+
self.pattern = re.compile("|".join(map(re.escape, ordered))) if known else None
|
| 251 |
+
|
| 252 |
+
def image(self, image, references=()):
|
| 253 |
+
if self.used is not None:
|
| 254 |
+
self.used.update(references)
|
| 255 |
+
self.images.append(Image(image).data)
|
| 256 |
+
from .training.images import IMAGE_PLACEHOLDER
|
| 257 |
+
|
| 258 |
+
return IMAGE_PLACEHOLDER
|
| 259 |
+
|
| 260 |
+
def reference(self, match):
|
| 261 |
+
return self.image(self.known[match[0]], [match[0]])
|
| 262 |
+
|
| 263 |
+
def text(self, value):
|
| 264 |
+
if self.pattern is None:
|
| 265 |
+
return value
|
| 266 |
+
return self.image(self.known[value], [value]) if value in self.known else self.pattern.sub(self.reference, value)
|
| 267 |
+
|
| 268 |
+
def swap(self, value):
|
| 269 |
+
if is_image(value):
|
| 270 |
+
return self.image(value, [reference for reference, image in self.known.items() if image is value])
|
| 271 |
+
if isinstance(value, str):
|
| 272 |
+
return self.text(value)
|
| 273 |
+
if isinstance(value, Mapping):
|
| 274 |
+
return {key: self.swap(item) for key, item in value.items()}
|
| 275 |
+
if isinstance(value, (list, tuple)):
|
| 276 |
+
return [self.swap(item) for item in value]
|
| 277 |
+
return value
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def content_parts(content, assets=None, used=None) -> tuple[str, list[bytes]]:
|
| 281 |
+
"""The model's text for a state, with each image replaced by the image placeholder, and the images' bytes in the
|
| 282 |
+
order their placeholders appear. Text is kept as is, an image alone is just its placeholder, a list of text and
|
| 283 |
+
images becomes lines, and any other object or array becomes compact JSON with the placeholders as string values.
|
| 284 |
+
|
| 285 |
+
``assets`` maps references (``str(image)``, or the aliases a call names) to images. As the TypeSafe API reads them,
|
| 286 |
+
a string that is a reference becomes that image, longer strings have references replaced left to right, and
|
| 287 |
+
object keys stay text. Images placed in the content are known by their own references too. The references that
|
| 288 |
+
matched are added to ``used``, and so are the names of an image placed as itself.
|
| 289 |
+
"""
|
| 290 |
+
reader = ContentReader({**dict(assets or {}), **{str(image): image for image in placed(content)}}, used)
|
| 291 |
+
if is_image(content):
|
| 292 |
+
return reader.swap(content), reader.images
|
| 293 |
+
if (isinstance(content, (list, tuple)) and any(is_image(item) for item in content)
|
| 294 |
+
and all(isinstance(item, str) or is_image(item) for item in content)):
|
| 295 |
+
return "\n".join(reader.swap(item) for item in content), reader.images
|
| 296 |
+
return content_text(reader.swap(content)), reader.images
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def placed(content):
|
| 300 |
+
"""The images placed in content as image objects, in order."""
|
| 301 |
+
if is_image(content):
|
| 302 |
+
yield content
|
| 303 |
+
elif isinstance(content, Mapping):
|
| 304 |
+
for value in content.values():
|
| 305 |
+
yield from placed(value)
|
| 306 |
+
elif isinstance(content, (list, tuple)):
|
| 307 |
+
for value in content:
|
| 308 |
+
yield from placed(value)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def holds_image(content, references=()) -> bool:
|
| 312 |
+
"""Whether content holds an image: an image object, a TypeSafe-style reference, or one of ``references``."""
|
| 313 |
+
if is_image(content):
|
| 314 |
+
return True
|
| 315 |
+
if isinstance(content, str):
|
| 316 |
+
return bool(REFERENCE.search(content)) or any(reference in content for reference in references)
|
| 317 |
+
if isinstance(content, Mapping):
|
| 318 |
+
return any(holds_image(value, references) for value in content.values())
|
| 319 |
+
if isinstance(content, (list, tuple)):
|
| 320 |
+
return any(holds_image(value, references) for value in content)
|
| 321 |
+
return False
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def content_text(content) -> str:
|
| 325 |
+
"""Render text as is and JSON objects or arrays as compact JSON."""
|
| 326 |
+
if content is None:
|
| 327 |
+
return ""
|
| 328 |
+
if isinstance(content, str):
|
| 329 |
+
return content
|
| 330 |
+
return json.dumps(content, ensure_ascii=False, separators=(", ", ": "), sort_keys=False)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def normalize_question(question) -> Noul | Choice | Score | Box:
|
| 334 |
+
"""Coerce a yev question, a TypeSafe SDK question or a ``{"type": ...}`` dict into a yev question."""
|
| 335 |
+
if isinstance(question, (Noul, Choice, Score, Box)):
|
| 336 |
+
return question
|
| 337 |
+
if isinstance(question, Mapping):
|
| 338 |
+
payload = dict(question)
|
| 339 |
+
else:
|
| 340 |
+
dump = getattr(question, "model_dump", None)
|
| 341 |
+
if dump is None:
|
| 342 |
+
raise TypeError(f"Unsupported question object: {type(question).__name__}")
|
| 343 |
+
payload = dump()
|
| 344 |
+
kind = payload.get("type")
|
| 345 |
+
if kind not in QUESTION_TYPES:
|
| 346 |
+
raise ValueError(f"Unknown question type: {kind!r}")
|
| 347 |
+
return QUESTION_TYPES[kind].model_validate(payload)
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def checked_dictionary(name, question):
|
| 351 |
+
"""The TypeSafe SDK's checks on a question dictionary, before it is read."""
|
| 352 |
+
kind = question.get("type")
|
| 353 |
+
if not isinstance(kind, str) or not kind:
|
| 354 |
+
raise YevError(f'Question "{name}" must be a question object or a dictionary with a nonempty string "type".')
|
| 355 |
+
if kind in ("choice", "score") and "criteria" not in question:
|
| 356 |
+
raise YevError(f'Question "{name}" requires "criteria".')
|
| 357 |
+
if kind in ("choice", "score") and not question["criteria"]:
|
| 358 |
+
raise YevError(f'{kind.title()} question "{name}" has no criteria; at least one {kind} is required.')
|
| 359 |
+
if kind == "noul" and question.get("criteria") is not None and not isinstance(question["criteria"], Mapping):
|
| 360 |
+
raise YevError(f'Noul question "{name}" criteria must be an object.')
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def checked_question(name, question) -> Noul | Choice | Score:
|
| 364 |
+
"""One question as a yev question, checked as the TypeSafe SDK checks it, then against what yev answers;
|
| 365 |
+
``YevError`` names it."""
|
| 366 |
+
if isinstance(question, Mapping):
|
| 367 |
+
checked_dictionary(name, question)
|
| 368 |
+
try:
|
| 369 |
+
question = normalize_question(question)
|
| 370 |
+
except (TypeError, ValueError) as error:
|
| 371 |
+
raise YevError(f'Question "{name}" is not valid: {error}') from error
|
| 372 |
+
if isinstance(question, Box):
|
| 373 |
+
raise YevError(f'Box question "{name}" cannot be answered: yev has Noul, Choice and Score heads, and no box head.')
|
| 374 |
+
if isinstance(question, Noul):
|
| 375 |
+
if question.instructions is None and not any(value is not None for value in (question.criteria or {}).values()):
|
| 376 |
+
raise YevError(f'Noul question "{name}" has neither instructions nor criteria; at least one is required.')
|
| 377 |
+
return question
|
| 378 |
+
count = len(question.criteria)
|
| 379 |
+
low, high = CHOICE_LIMITS if question.type == "choice" else SCORE_LIMITS
|
| 380 |
+
if count < low:
|
| 381 |
+
raise YevError(f'{question.type.title()} question "{name}" has no criteria; at least one {question.type} is required.')
|
| 382 |
+
if count > high:
|
| 383 |
+
raise YevError(f'{question.type.title()} question "{name}" has {count} criteria; yev answers at most {high}.')
|
| 384 |
+
return question
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def normalize_questions(questions: Questions) -> dict[str, Noul | Choice | Score]:
|
| 388 |
+
"""Every question as a yev question, checked by ``checked_question``."""
|
| 389 |
+
if not questions:
|
| 390 |
+
raise YevError("At least one question is required.")
|
| 391 |
+
return {name: checked_question(name, question) for name, question in questions.items()}
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def question_content(question):
|
| 395 |
+
"""A question's instructions and criteria values: everything an image could hide in."""
|
| 396 |
+
criteria = getattr(question, "criteria", None) or {}
|
| 397 |
+
return [question.instructions, *(criteria.values() if isinstance(criteria, Mapping) else criteria)]
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def question_labels_and_descriptions(question: Noul | Choice | Score) -> tuple[list[str], list[str]]:
|
| 401 |
+
"""Labels the answer will be keyed by, and the criterion text shown to the model, in order."""
|
| 402 |
+
if isinstance(question, Noul):
|
| 403 |
+
criteria = question.criteria or {}
|
| 404 |
+
descriptions = [
|
| 405 |
+
content_text(criteria.get("false")) or "false",
|
| 406 |
+
content_text(criteria.get("true")) or "true",
|
| 407 |
+
]
|
| 408 |
+
return ["false", "true"], descriptions
|
| 409 |
+
if isinstance(question, Choice):
|
| 410 |
+
labels = list(question.criteria)
|
| 411 |
+
descriptions = [
|
| 412 |
+
f"{label}: {content_text(description)}" if description not in (None, "") else label
|
| 413 |
+
for label, description in question.criteria.items()
|
| 414 |
+
]
|
| 415 |
+
return labels, descriptions
|
| 416 |
+
descriptions = [content_text(level) for level in question.criteria]
|
| 417 |
+
return [str(index) for index in range(len(descriptions))], descriptions
|
src/yev/training/__init__.py
ADDED
|
File without changes
|
src/yev/training/comparison.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Student-versus-teacher distribution comparison on held-out Jev-labelled questions.
|
| 2 |
+
|
| 3 |
+
Pure metrics plus a batched scorer. Nothing here calls the Jev API or downloads data.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from collections import defaultdict
|
| 7 |
+
import math
|
| 8 |
+
import random
|
| 9 |
+
|
| 10 |
+
from .model import PRIMITIVES, collator_for
|
| 11 |
+
from .run import device_batch, evaluation_mode
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def stratified_sample(examples, limit, seed=42):
|
| 15 |
+
"""Deterministically pick up to ``limit`` examples with primitives balanced."""
|
| 16 |
+
if type(limit) is not int or limit < 1:
|
| 17 |
+
raise ValueError("limit must be a positive integer")
|
| 18 |
+
pools = {kind: [example for example in examples if example.kind == kind] for kind in PRIMITIVES}
|
| 19 |
+
for kind in PRIMITIVES:
|
| 20 |
+
random.Random(f"{seed}:compare:{kind}").shuffle(pools[kind])
|
| 21 |
+
selected = []
|
| 22 |
+
cursor = 0
|
| 23 |
+
while len(selected) < limit and any(cursor < len(pool) for pool in pools.values()):
|
| 24 |
+
for kind in PRIMITIVES:
|
| 25 |
+
if cursor < len(pools[kind]) and len(selected) < limit:
|
| 26 |
+
selected.append(pools[kind][cursor])
|
| 27 |
+
cursor += 1
|
| 28 |
+
return selected
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def comparison_metrics(kind, student, teacher):
|
| 32 |
+
"""Per-question distances between two normalized distributions."""
|
| 33 |
+
if len(student) != len(teacher) or not student:
|
| 34 |
+
raise ValueError("Distributions must have the same nonzero length")
|
| 35 |
+
if not all(math.isfinite(value) and value >= 0 for value in student + teacher):
|
| 36 |
+
raise ValueError("Distributions must be finite and nonnegative")
|
| 37 |
+
student_mode = max(range(len(student)), key=student.__getitem__)
|
| 38 |
+
teacher_mode = max(range(len(teacher)), key=teacher.__getitem__)
|
| 39 |
+
metrics = {
|
| 40 |
+
"tvd": 0.5 * sum(abs(p - q) for p, q in zip(student, teacher)),
|
| 41 |
+
"agree": int(student_mode == teacher_mode),
|
| 42 |
+
"log_loss": -sum(q * math.log(max(p, 1e-12)) for p, q in zip(student, teacher)),
|
| 43 |
+
"brier": sum((p - q) ** 2 for p, q in zip(student, teacher)),
|
| 44 |
+
}
|
| 45 |
+
if kind == "score":
|
| 46 |
+
metrics["mean_abs_error"] = abs(sum(index * p for index, p in enumerate(student)) - sum(index * q for index, q in enumerate(teacher)))
|
| 47 |
+
if kind == "noul":
|
| 48 |
+
metrics["probability_abs_error"] = abs(student[1] - teacher[1])
|
| 49 |
+
return metrics
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def aggregate(rows):
|
| 53 |
+
"""Mean of every metric over rows of ``{"metrics": {...}}``."""
|
| 54 |
+
if not rows:
|
| 55 |
+
return {"count": 0}
|
| 56 |
+
totals = defaultdict(float)
|
| 57 |
+
counts = defaultdict(int)
|
| 58 |
+
for row in rows:
|
| 59 |
+
for name, value in row["metrics"].items():
|
| 60 |
+
totals[name] += value
|
| 61 |
+
counts[name] += 1
|
| 62 |
+
return {"count": len(rows), **{name: totals[name] / counts[name] for name in sorted(totals)}}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def domain_of(example):
|
| 66 |
+
return example.source.split(":", 1)[1] if ":" in example.source else example.source
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def student_distributions(model, tokenizer, examples, max_length, micro_batch=8):
|
| 70 |
+
collator = collator_for(model, tokenizer, max_length)
|
| 71 |
+
results = []
|
| 72 |
+
with evaluation_mode(model):
|
| 73 |
+
for start in range(0, len(examples), micro_batch):
|
| 74 |
+
items = examples[start:start + micro_batch]
|
| 75 |
+
batch = device_batch(model, collator, items)
|
| 76 |
+
probabilities = model(**batch)["logits"].float().softmax(-1).cpu()
|
| 77 |
+
for index, item in enumerate(items):
|
| 78 |
+
results.append(probabilities[index, :len(item.target)].tolist())
|
| 79 |
+
return results
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def offline_report(model, tokenizer, examples, max_length, micro_batch=8):
|
| 83 |
+
predictions = student_distributions(model, tokenizer, examples, max_length, micro_batch)
|
| 84 |
+
rows, baseline_rows = [], []
|
| 85 |
+
for example, student in zip(examples, predictions):
|
| 86 |
+
uniform = [1 / len(example.target)] * len(example.target)
|
| 87 |
+
rows.append({"uid": example.uid, "kind": example.kind, "domain": domain_of(example), "student": student,
|
| 88 |
+
"teacher": example.target, "metrics": comparison_metrics(example.kind, student, example.target)})
|
| 89 |
+
baseline_rows.append({"kind": example.kind, "domain": domain_of(example), "metrics": comparison_metrics(example.kind, uniform, example.target)})
|
| 90 |
+
by = lambda rows, key: {value: aggregate([row for row in rows if row[key] == value]) for value in sorted({row[key] for row in rows})} # noqa: E731
|
| 91 |
+
return {
|
| 92 |
+
"student": {"overall": aggregate(rows), "by_primitive": by(rows, "kind"), "by_domain": by(rows, "domain")},
|
| 93 |
+
"uniform_baseline": {"overall": aggregate(baseline_rows), "by_primitive": by(baseline_rows, "kind")},
|
| 94 |
+
"rows": rows,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def format_table(report):
|
| 99 |
+
lines = [f"{'slice':28} {'n':>6} {'TVD':>7} {'agree':>7} {'logloss':>8} {'brier':>7}"]
|
| 100 |
+
student, baseline = report["student"], report["uniform_baseline"]
|
| 101 |
+
def line(name, stats): # noqa: E306
|
| 102 |
+
return f"{name:28} {stats['count']:>6} {stats.get('tvd', float('nan')):>7.3f} {stats.get('agree', float('nan')):>7.3f} {stats.get('log_loss', float('nan')):>8.3f} {stats.get('brier', float('nan')):>7.3f}"
|
| 103 |
+
lines.append(line("student / overall", student["overall"]))
|
| 104 |
+
lines.append(line("uniform / overall", baseline["overall"]))
|
| 105 |
+
for kind in PRIMITIVES:
|
| 106 |
+
if kind in student["by_primitive"]:
|
| 107 |
+
lines.append(line(f"student / {kind}", student["by_primitive"][kind]))
|
| 108 |
+
lines.append(line(f"uniform / {kind}", baseline["by_primitive"][kind]))
|
| 109 |
+
for domain, stats in student["by_domain"].items():
|
| 110 |
+
lines.append(line(f"student / {domain}", stats))
|
| 111 |
+
return "\n".join(lines)
|
src/yev/training/data.py
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections import Counter
|
| 2 |
+
import csv
|
| 3 |
+
from dataclasses import asdict, dataclass, field
|
| 4 |
+
import hashlib
|
| 5 |
+
import io
|
| 6 |
+
import json
|
| 7 |
+
import math
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import random
|
| 10 |
+
import re
|
| 11 |
+
from urllib.request import Request, urlopen
|
| 12 |
+
|
| 13 |
+
from .images import IMAGE_PLACEHOLDER
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
SPLITS = ("train", "dev", "calibration", "test")
|
| 17 |
+
BANKING_REPOSITORY = "https://github.com/PolyAI-LDN/task-specific-datasets"
|
| 18 |
+
BANKING_REVISION_URL = "https://api.github.com/repos/PolyAI-LDN/task-specific-datasets/commits?path=banking_data&per_page=1"
|
| 19 |
+
SEVERITY_CRITERIA = ["Routine", "Low", "Moderate", "High", "Critical"]
|
| 20 |
+
PRIORITY_CRITERIA = ["P4 Low", "P3 Normal", "P2 High", "P1 Urgent"]
|
| 21 |
+
PRIORITY_INSTRUCTIONS = (
|
| 22 |
+
"Apply the highest matching ticket priority rule. P1 Urgent: a confirmed outage, or at least 100 customers affected. "
|
| 23 |
+
"P2 High: at least 20 customers affected, or the ticket has been open for at least 48 hours. "
|
| 24 |
+
"P3 Normal: at least 5 customers affected, or open for at least 8 hours. Otherwise P4 Low. "
|
| 25 |
+
"The criteria are ordered from lowest to highest priority."
|
| 26 |
+
)
|
| 27 |
+
BINOMIAL_INSTRUCTIONS = (
|
| 28 |
+
"How many of the drawn beads will be red? Preserve the actual mathematical outcome probabilities. "
|
| 29 |
+
"The criteria are ordered by the number of red beads drawn."
|
| 30 |
+
)
|
| 31 |
+
SEVERITY_INSTRUCTIONS = (
|
| 32 |
+
"Apply the highest matching incident severity rule. Critical: data loss, downtime >= 120 minutes, "
|
| 33 |
+
"or affected users >= 75%. High: downtime >= 60 minutes or affected users >= 40%. "
|
| 34 |
+
"Moderate: downtime >= 15 minutes or affected users >= 10%. "
|
| 35 |
+
"Low: downtime >= 5 minutes or affected users >= 1%. Otherwise Routine. "
|
| 36 |
+
"The criteria are ordered from least to most severe."
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class DecisionExample:
|
| 42 |
+
uid: str
|
| 43 |
+
group: str
|
| 44 |
+
source: str
|
| 45 |
+
split: str
|
| 46 |
+
kind: str
|
| 47 |
+
state: str
|
| 48 |
+
instructions: str
|
| 49 |
+
criteria: list[str]
|
| 50 |
+
target: list[float]
|
| 51 |
+
images: list[str] = field(default_factory=list) # digests of the images the state's placeholders stand for, in order
|
| 52 |
+
|
| 53 |
+
def __post_init__(self):
|
| 54 |
+
limits = {"choice": (2, 255), "score": (2, 10), "noul": (2, 2)}
|
| 55 |
+
if self.kind not in limits or self.split not in {"train", "dev", "calibration", "test"}:
|
| 56 |
+
raise ValueError("Unknown primitive or split")
|
| 57 |
+
minimum, maximum = limits[self.kind]
|
| 58 |
+
if not minimum <= len(self.criteria) <= maximum or len(self.target) != len(self.criteria):
|
| 59 |
+
raise ValueError("Criteria and targets must match the primitive's size limits")
|
| 60 |
+
if any(not math.isfinite(value) or value < 0 for value in self.target):
|
| 61 |
+
raise ValueError("Targets must contain finite nonnegative probabilities")
|
| 62 |
+
if not math.isclose(sum(self.target), 1.0, abs_tol=1e-6):
|
| 63 |
+
raise ValueError("Target probabilities must sum to one")
|
| 64 |
+
self.images = list(self.images)
|
| 65 |
+
if self.state.count(IMAGE_PLACEHOLDER) != len(self.images):
|
| 66 |
+
raise ValueError(f"The state must hold one {IMAGE_PLACEHOLDER} for each image")
|
| 67 |
+
|
| 68 |
+
def to_dict(self):
|
| 69 |
+
"""The record as stored; a text-only example has no ``images`` key, so text corpora and
|
| 70 |
+
their fingerprints are unchanged."""
|
| 71 |
+
record = asdict(self)
|
| 72 |
+
if not self.images:
|
| 73 |
+
record.pop("images")
|
| 74 |
+
return record
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def is_certain(target):
|
| 78 |
+
"""Whether one answer holds all of the target's probability."""
|
| 79 |
+
return max(target) > 0.999
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def top_answer(target):
|
| 83 |
+
"""The target as its single most likely answer."""
|
| 84 |
+
top = target.index(max(target))
|
| 85 |
+
return [float(index == top) for index in range(len(target))]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def document_group(source, state):
|
| 89 |
+
normalized = " ".join(state.lower().split())
|
| 90 |
+
digest = hashlib.sha256(normalized.encode()).hexdigest()
|
| 91 |
+
return f"{source}:{digest}"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def split_for_group(group, original_split):
|
| 95 |
+
if original_split == "test":
|
| 96 |
+
return "test"
|
| 97 |
+
bucket = int(hashlib.sha256(group.encode()).hexdigest()[:8], 16) % 100
|
| 98 |
+
if bucket < 80:
|
| 99 |
+
return "train"
|
| 100 |
+
return "dev" if bucket < 90 else "calibration"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def probability_examples(seed=42, count=1000):
|
| 104 |
+
generator = random.Random(seed)
|
| 105 |
+
examples = []
|
| 106 |
+
seen = set()
|
| 107 |
+
if not 1 <= count <= 10000:
|
| 108 |
+
raise ValueError("Synthetic count must be between 1 and 10000")
|
| 109 |
+
remaining = synthetic_split_quotas(count)
|
| 110 |
+
while len(seen) < count:
|
| 111 |
+
total = generator.randint(2, 1000)
|
| 112 |
+
red = generator.randint(0, total)
|
| 113 |
+
state = f"A bag contains {red} red beads and {total - red} blue beads. One bead is drawn uniformly at random."
|
| 114 |
+
group = document_group("synthetic_probability", state)
|
| 115 |
+
split = synthetic_split(group)
|
| 116 |
+
if group in seen or remaining[split] == 0:
|
| 117 |
+
continue
|
| 118 |
+
seen.add(group)
|
| 119 |
+
remaining[split] -= 1
|
| 120 |
+
probability = red / total
|
| 121 |
+
for kind in ("choice", "score", "noul"):
|
| 122 |
+
examples.append(DecisionExample(
|
| 123 |
+
uid=f"{group}:{kind}", group=group, source="synthetic_probability", split=split,
|
| 124 |
+
kind=kind, state=state,
|
| 125 |
+
instructions="Will the drawn bead be red? Preserve the actual mathematical outcome probabilities.",
|
| 126 |
+
criteria=["false", "true"] if kind == "noul" else ["The drawn bead is blue (no).", "The drawn bead is red (yes)."],
|
| 127 |
+
target=[1 - probability, probability],
|
| 128 |
+
))
|
| 129 |
+
return examples
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def synthetic_split(group):
|
| 133 |
+
bucket = int(hashlib.sha256(group.encode()).hexdigest()[:8], 16) % 100
|
| 134 |
+
if bucket < 70:
|
| 135 |
+
return "train"
|
| 136 |
+
if bucket < 80:
|
| 137 |
+
return "dev"
|
| 138 |
+
return "calibration" if bucket < 90 else "test"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def synthetic_split_quotas(count):
|
| 142 |
+
if count < 4:
|
| 143 |
+
return {split: int(index < count) for index, split in enumerate(SPLITS)}
|
| 144 |
+
heldout = max(1, count // 10)
|
| 145 |
+
return dict(zip(SPLITS, (count - 3 * heldout, heldout, heldout, heldout)))
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def download_bytes(url, max_bytes=8 * 1024 * 1024):
|
| 149 |
+
request = Request(url, headers={"User-Agent": "jev-investigation-data-preparation"})
|
| 150 |
+
with urlopen(request, timeout=30) as response:
|
| 151 |
+
content = response.read(max_bytes + 1)
|
| 152 |
+
if len(content) > max_bytes:
|
| 153 |
+
raise ValueError(f"Source exceeds the {max_bytes}-byte download limit: {url}")
|
| 154 |
+
return content
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def parse_banking_csv(content, categories):
|
| 158 |
+
reader = csv.DictReader(io.StringIO(content.decode("utf-8-sig"), newline=""))
|
| 159 |
+
if reader.fieldnames != ["text", "category"]:
|
| 160 |
+
raise ValueError("BANKING77 CSV must have the original text,category header")
|
| 161 |
+
records = list(reader)
|
| 162 |
+
for row in records:
|
| 163 |
+
if set(row) != {"text", "category"} or not row["text"] or not row["text"].strip():
|
| 164 |
+
raise ValueError("BANKING77 CSV contains an empty or malformed document")
|
| 165 |
+
if row["category"] not in categories:
|
| 166 |
+
raise ValueError(f"Unknown BANKING77 category: {row['category']!r}")
|
| 167 |
+
return records
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def load_banking77():
|
| 171 |
+
revision_content = download_bytes(BANKING_REVISION_URL, max_bytes=256 * 1024)
|
| 172 |
+
revision = json.loads(revision_content)[0]["sha"]
|
| 173 |
+
if not isinstance(revision, str) or re.fullmatch(r"[0-9a-f]{40}", revision) is None:
|
| 174 |
+
raise ValueError("GitHub did not return a valid BANKING77 commit SHA")
|
| 175 |
+
base_url = f"https://raw.githubusercontent.com/PolyAI-LDN/task-specific-datasets/{revision}/banking_data"
|
| 176 |
+
contents = {}
|
| 177 |
+
files = {}
|
| 178 |
+
for filename in ("categories.json", "train.csv", "test.csv"):
|
| 179 |
+
url = f"{base_url}/{filename}"
|
| 180 |
+
content = download_bytes(url)
|
| 181 |
+
contents[filename] = content
|
| 182 |
+
files[filename] = {"url": url, "sha256": hashlib.sha256(content).hexdigest(), "bytes": len(content)}
|
| 183 |
+
categories = json.loads(contents["categories.json"])
|
| 184 |
+
if (
|
| 185 |
+
not isinstance(categories, list) or len(categories) != 77
|
| 186 |
+
or any(not isinstance(category, str) or not category for category in categories)
|
| 187 |
+
or len(set(categories)) != 77
|
| 188 |
+
):
|
| 189 |
+
raise ValueError("BANKING77 categories.json must contain 77 unique category names")
|
| 190 |
+
records = {split: parse_banking_csv(contents[f"{split}.csv"], categories) for split in ("train", "test")}
|
| 191 |
+
provenance = {
|
| 192 |
+
"url": BANKING_REPOSITORY,
|
| 193 |
+
"revision": revision,
|
| 194 |
+
"revision_url": BANKING_REVISION_URL,
|
| 195 |
+
"revision_response_sha256": hashlib.sha256(revision_content).hexdigest(),
|
| 196 |
+
"files": files,
|
| 197 |
+
"license": "CC-BY-4.0",
|
| 198 |
+
"license_url": "https://creativecommons.org/licenses/by/4.0/",
|
| 199 |
+
"license_evidence_url": "https://huggingface.co/datasets/PolyAI/banking77#licensing-information",
|
| 200 |
+
"attribution": "BANKING77 by PolyAI; Casanueva et al., Efficient Intent Detection with Dual Sentence Encoders (2020).",
|
| 201 |
+
"modifications": "Normalized-document deduplication; seeded subsampling; subset-choice and binary intent questions.",
|
| 202 |
+
"category_count": len(categories),
|
| 203 |
+
"raw_records": {split: len(rows) for split, rows in records.items()},
|
| 204 |
+
"target_type": "Observed single-label annotations, not calibrated soft probabilities.",
|
| 205 |
+
}
|
| 206 |
+
return records, categories, provenance
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def banking_group_policy(records, categories):
|
| 210 |
+
if len(categories) < 2 or len(set(categories)) != len(categories):
|
| 211 |
+
raise ValueError("At least two distinct intent categories are required")
|
| 212 |
+
grouped_labels = {}
|
| 213 |
+
occurrences = Counter()
|
| 214 |
+
for original_split in ("train", "test"):
|
| 215 |
+
for row in records[original_split]:
|
| 216 |
+
if not row["text"].strip() or row["category"] not in categories:
|
| 217 |
+
raise ValueError("BANKING77 records require nonempty text and a known category")
|
| 218 |
+
group = document_group("banking77", row["text"])
|
| 219 |
+
grouped_labels.setdefault(group, {}).setdefault(original_split, set()).add(row["category"])
|
| 220 |
+
occurrences[group] += 1
|
| 221 |
+
owners = {group: "test" if "test" in labels else "train" for group, labels in grouped_labels.items()}
|
| 222 |
+
ambiguous = {group for group, labels in grouped_labels.items() if len(labels[owners[group]]) > 1}
|
| 223 |
+
audit = {
|
| 224 |
+
"raw_records": {split: len(records[split]) for split in ("train", "test")},
|
| 225 |
+
"unique_source_groups": len(grouped_labels),
|
| 226 |
+
"duplicate_groups": sum(count > 1 for count in occurrences.values()),
|
| 227 |
+
"conflicting_groups": sum(len(set().union(*labels.values())) > 1 for labels in grouped_labels.values()),
|
| 228 |
+
"ambiguous_owner_groups_dropped": len(ambiguous),
|
| 229 |
+
"test_priority_groups": sum(len(labels) == 2 for labels in grouped_labels.values()),
|
| 230 |
+
"sampled_raw_records": {},
|
| 231 |
+
"retained_documents": {},
|
| 232 |
+
"excluded_sampled_records": {},
|
| 233 |
+
}
|
| 234 |
+
return owners, ambiguous, audit
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def banking_questions(state, group, split, label, categories, seed):
|
| 238 |
+
generator = random.Random(f"{seed}:{group}")
|
| 239 |
+
alternatives = [category for category in categories if category != label]
|
| 240 |
+
candidate_count = generator.randint(2, min(8, len(categories)))
|
| 241 |
+
candidates = generator.sample(alternatives, candidate_count - 1) + [label]
|
| 242 |
+
generator.shuffle(candidates)
|
| 243 |
+
examples = [DecisionExample(
|
| 244 |
+
uid=f"{group}:choice", group=group, source="banking77", split=split, kind="choice",
|
| 245 |
+
state=state, instructions="Select the customer's intent from the offered candidates.",
|
| 246 |
+
criteria=candidates, target=[float(candidate == label) for candidate in candidates],
|
| 247 |
+
)]
|
| 248 |
+
negative = generator.choice(alternatives)
|
| 249 |
+
for predicate, truth in ((label, True), (negative, False)):
|
| 250 |
+
examples.append(DecisionExample(
|
| 251 |
+
uid=f"{group}:noul:{str(truth).lower()}", group=group, source="banking77", split=split,
|
| 252 |
+
kind="noul", state=state,
|
| 253 |
+
instructions=f"Does the customer's request express the intent '{predicate}'?",
|
| 254 |
+
criteria=["false", "true"], target=[float(not truth), float(truth)],
|
| 255 |
+
))
|
| 256 |
+
return examples
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def banking_examples(records, categories, seed=42, bank_limit=3000):
|
| 260 |
+
if type(bank_limit) is not int or bank_limit < 0:
|
| 261 |
+
raise ValueError("bank_limit must be a nonnegative integer")
|
| 262 |
+
owners, ambiguous, audit = banking_group_policy(records, categories)
|
| 263 |
+
generator = random.Random(seed)
|
| 264 |
+
examples = []
|
| 265 |
+
seen = set()
|
| 266 |
+
for original_split in ("train", "test"):
|
| 267 |
+
shuffled = list(records[original_split])
|
| 268 |
+
generator.shuffle(shuffled)
|
| 269 |
+
sampled = shuffled[:bank_limit]
|
| 270 |
+
audit["sampled_raw_records"][original_split] = len(sampled)
|
| 271 |
+
retained = 0
|
| 272 |
+
excluded = Counter()
|
| 273 |
+
for row in sampled:
|
| 274 |
+
state = row["text"].strip()
|
| 275 |
+
group = document_group("banking77", state)
|
| 276 |
+
if owners[group] != original_split:
|
| 277 |
+
excluded["test_priority"] += 1
|
| 278 |
+
continue
|
| 279 |
+
if group in ambiguous:
|
| 280 |
+
excluded["ambiguous_label"] += 1
|
| 281 |
+
continue
|
| 282 |
+
if group in seen:
|
| 283 |
+
excluded["duplicate"] += 1
|
| 284 |
+
continue
|
| 285 |
+
seen.add(group)
|
| 286 |
+
retained += 1
|
| 287 |
+
split = split_for_group(group, original_split)
|
| 288 |
+
examples.extend(banking_questions(state, group, split, row["category"], categories, seed))
|
| 289 |
+
audit["retained_documents"][original_split] = retained
|
| 290 |
+
audit["excluded_sampled_records"][original_split] = dict(excluded)
|
| 291 |
+
return examples, audit
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def severity_label(downtime_minutes, affected_percent, data_loss):
|
| 295 |
+
if data_loss or downtime_minutes >= 120 or affected_percent >= 75:
|
| 296 |
+
return 4
|
| 297 |
+
if downtime_minutes >= 60 or affected_percent >= 40:
|
| 298 |
+
return 3
|
| 299 |
+
if downtime_minutes >= 15 or affected_percent >= 10:
|
| 300 |
+
return 2
|
| 301 |
+
if downtime_minutes >= 5 or affected_percent >= 1:
|
| 302 |
+
return 1
|
| 303 |
+
return 0
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def severity_examples(seed=42, count=200):
|
| 307 |
+
if type(count) is not int or not 1 <= count <= 200:
|
| 308 |
+
raise ValueError("Severity count must be between 1 and 200")
|
| 309 |
+
generator = random.Random(seed)
|
| 310 |
+
remaining = synthetic_split_quotas(count)
|
| 311 |
+
templates = {
|
| 312 |
+
"train": "Service incident: downtime_minutes={downtime}; affected_percent={affected}; data_loss={loss}.",
|
| 313 |
+
"dev": "Monitoring summary: {affected}% of users affected; downtime {downtime} minutes; data loss {loss}.",
|
| 314 |
+
"calibration": "Support audit records data loss {loss}, {downtime} minutes of downtime and {affected}% affected users.",
|
| 315 |
+
"test": "An operations review measured downtime of {downtime} minutes. The affected share was {affected}% of users. Data loss: {loss}.",
|
| 316 |
+
}
|
| 317 |
+
bands = ((0, 4, 0, 0), (5, 14, 1, 9), (15, 59, 10, 39), (60, 119, 40, 74), (120, 240, 75, 100))
|
| 318 |
+
examples = []
|
| 319 |
+
seen = set()
|
| 320 |
+
while len(examples) < count:
|
| 321 |
+
minimum_time, maximum_time, minimum_impact, maximum_impact = generator.choice(bands)
|
| 322 |
+
downtime = generator.randint(minimum_time, maximum_time)
|
| 323 |
+
affected = generator.randint(minimum_impact, maximum_impact)
|
| 324 |
+
loss = generator.random() < 0.1
|
| 325 |
+
scenario = json.dumps([downtime, affected, loss])
|
| 326 |
+
group = document_group("synthetic_severity", scenario)
|
| 327 |
+
split = synthetic_split(group)
|
| 328 |
+
if group in seen or remaining[split] == 0:
|
| 329 |
+
continue
|
| 330 |
+
seen.add(group)
|
| 331 |
+
remaining[split] -= 1
|
| 332 |
+
label = severity_label(downtime, affected, loss)
|
| 333 |
+
examples.append(DecisionExample(
|
| 334 |
+
uid=f"{group}:score", group=group, source="synthetic_severity", split=split, kind="score",
|
| 335 |
+
state=templates[split].format(downtime=downtime, affected=affected, loss=str(loss).lower()),
|
| 336 |
+
instructions=SEVERITY_INSTRUCTIONS, criteria=list(SEVERITY_CRITERIA),
|
| 337 |
+
target=[float(index == label) for index in range(len(SEVERITY_CRITERIA))],
|
| 338 |
+
))
|
| 339 |
+
return examples
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def binomial_examples(seed=42, count=500):
|
| 343 |
+
"""Ordered Score questions with exact binomial targets over 3 to 6 levels.
|
| 344 |
+
|
| 345 |
+
A bag with known red/blue counts is sampled with replacement ``draws`` times; level ``i``
|
| 346 |
+
is "exactly i red beads". The target is Binomial(draws, red / total), so calibration sees
|
| 347 |
+
genuinely spread ordinal distributions rather than one-hot rules. Templates are split-exclusive.
|
| 348 |
+
"""
|
| 349 |
+
if type(count) is not int or not 1 <= count <= 10000:
|
| 350 |
+
raise ValueError("Binomial count must be between 1 and 10000")
|
| 351 |
+
generator = random.Random(seed)
|
| 352 |
+
remaining = synthetic_split_quotas(count)
|
| 353 |
+
templates = {
|
| 354 |
+
"train": "A bag contains {red} red beads and {blue} blue beads. {draws} beads are drawn one at a time, each returned to the bag before the next draw.",
|
| 355 |
+
"dev": "There are {blue} blue and {red} red beads in a bag. A bead is drawn uniformly at random and put back; this is repeated {draws} times in total.",
|
| 356 |
+
"calibration": "Bag contents: {red} red, {blue} blue. Procedure: draw one bead at random, note its colour, replace it; perform {draws} draws.",
|
| 357 |
+
"test": "{draws} independent draws with replacement are made from a bag holding {red} red beads and {blue} blue beads.",
|
| 358 |
+
}
|
| 359 |
+
examples = []
|
| 360 |
+
seen = set()
|
| 361 |
+
while len(examples) < count:
|
| 362 |
+
total = generator.randint(2, 60)
|
| 363 |
+
red = generator.randint(0, total)
|
| 364 |
+
draws = generator.randint(2, 5)
|
| 365 |
+
scenario = json.dumps([red, total - red, draws])
|
| 366 |
+
group = document_group("synthetic_binomial", scenario)
|
| 367 |
+
split = synthetic_split(group)
|
| 368 |
+
if group in seen or remaining[split] == 0:
|
| 369 |
+
continue
|
| 370 |
+
seen.add(group)
|
| 371 |
+
remaining[split] -= 1
|
| 372 |
+
probability = red / total
|
| 373 |
+
target = [math.comb(draws, hits) * probability ** hits * (1 - probability) ** (draws - hits) for hits in range(draws + 1)]
|
| 374 |
+
examples.append(DecisionExample(
|
| 375 |
+
uid=f"{group}:score", group=group, source="synthetic_binomial", split=split, kind="score",
|
| 376 |
+
state=templates[split].format(red=red, blue=total - red, draws=draws),
|
| 377 |
+
instructions=BINOMIAL_INSTRUCTIONS,
|
| 378 |
+
criteria=[f"Exactly {hits} of the {draws} drawn beads are red." for hits in range(draws + 1)],
|
| 379 |
+
target=target,
|
| 380 |
+
))
|
| 381 |
+
return examples
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def priority_label(customers_affected, hours_open, outage):
|
| 385 |
+
if outage or customers_affected >= 100:
|
| 386 |
+
return 3
|
| 387 |
+
if customers_affected >= 20 or hours_open >= 48:
|
| 388 |
+
return 2
|
| 389 |
+
if customers_affected >= 5 or hours_open >= 8:
|
| 390 |
+
return 1
|
| 391 |
+
return 0
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def priority_examples(seed=42, count=400):
|
| 395 |
+
"""Four-level one-hot Score questions from executable ticket-priority rules with held-out wording."""
|
| 396 |
+
if type(count) is not int or not 1 <= count <= 400:
|
| 397 |
+
raise ValueError("Priority count must be between 1 and 400")
|
| 398 |
+
generator = random.Random(seed)
|
| 399 |
+
remaining = synthetic_split_quotas(count)
|
| 400 |
+
templates = {
|
| 401 |
+
"train": "Support ticket: customers_affected={customers}; hours_open={hours}; confirmed_outage={outage}.",
|
| 402 |
+
"dev": "Ticket status: open for {hours} hours, {customers} customers affected, confirmed outage {outage}.",
|
| 403 |
+
"calibration": "The queue report lists a confirmed outage flag of {outage}, {customers} affected customers and {hours} hours since the ticket was opened.",
|
| 404 |
+
"test": "A ticket has been open {hours} hours. It affects {customers} customers. Outage confirmed: {outage}.",
|
| 405 |
+
}
|
| 406 |
+
customer_bands = ((0, 4), (5, 19), (20, 99), (100, 500))
|
| 407 |
+
hour_bands = ((0, 7), (8, 47), (48, 200))
|
| 408 |
+
examples = []
|
| 409 |
+
seen = set()
|
| 410 |
+
while len(examples) < count:
|
| 411 |
+
customers = generator.randint(*generator.choice(customer_bands))
|
| 412 |
+
hours = generator.randint(*generator.choice(hour_bands))
|
| 413 |
+
outage = generator.random() < 0.1
|
| 414 |
+
scenario = json.dumps([customers, hours, outage])
|
| 415 |
+
group = document_group("synthetic_priority", scenario)
|
| 416 |
+
split = synthetic_split(group)
|
| 417 |
+
if group in seen or remaining[split] == 0:
|
| 418 |
+
continue
|
| 419 |
+
seen.add(group)
|
| 420 |
+
remaining[split] -= 1
|
| 421 |
+
label = priority_label(customers, hours, outage)
|
| 422 |
+
examples.append(DecisionExample(
|
| 423 |
+
uid=f"{group}:score", group=group, source="synthetic_priority", split=split, kind="score",
|
| 424 |
+
state=templates[split].format(customers=customers, hours=hours, outage=str(outage).lower()),
|
| 425 |
+
instructions=PRIORITY_INSTRUCTIONS, criteria=list(PRIORITY_CRITERIA),
|
| 426 |
+
target=[float(index == label) for index in range(len(PRIORITY_CRITERIA))],
|
| 427 |
+
))
|
| 428 |
+
return examples
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def jsonl_content(examples):
|
| 432 |
+
return "".join(json.dumps(example.to_dict(), sort_keys=True, ensure_ascii=True) + "\n" for example in examples).encode("utf-8")
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def partition_examples(examples, seed, require_all_primitives):
|
| 436 |
+
splits = {split: [] for split in SPLITS}
|
| 437 |
+
group_splits = {}
|
| 438 |
+
uids = set()
|
| 439 |
+
for example in examples:
|
| 440 |
+
if group_splits.setdefault(example.group, example.split) != example.split or example.uid in uids:
|
| 441 |
+
raise ValueError("Duplicate example UID or source-document leakage across splits")
|
| 442 |
+
uids.add(example.uid)
|
| 443 |
+
splits[example.split].append(example)
|
| 444 |
+
for split, examples in splits.items():
|
| 445 |
+
random.Random(f"{seed}:{split}").shuffle(examples)
|
| 446 |
+
if require_all_primitives and {example.kind for example in examples} != {"choice", "score", "noul"}:
|
| 447 |
+
raise ValueError(f"Missing primitive in {split}")
|
| 448 |
+
return splits
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def prepare_data(output_dir, seed=42, bank_limit=3000, synthetic_count=500, include_sst5=False) -> dict[str, list[DecisionExample]]:
|
| 452 |
+
if include_sst5:
|
| 453 |
+
raise ValueError("SST5 is deferred: its dataset card has no verified license metadata; include_sst5=True is unsupported")
|
| 454 |
+
if type(seed) is not int:
|
| 455 |
+
raise ValueError("seed must be an integer")
|
| 456 |
+
if type(bank_limit) is not int or bank_limit < 0:
|
| 457 |
+
raise ValueError("bank_limit must be a nonnegative integer")
|
| 458 |
+
if type(synthetic_count) is not int or not 1 <= synthetic_count <= 10000:
|
| 459 |
+
raise ValueError("synthetic_count must be between 1 and 10000")
|
| 460 |
+
records, categories, provenance = load_banking77()
|
| 461 |
+
bank, audit = banking_examples(records, categories, seed=seed, bank_limit=bank_limit)
|
| 462 |
+
probabilities = probability_examples(seed=seed, count=synthetic_count)
|
| 463 |
+
severity = severity_examples(seed=seed, count=min(200, synthetic_count))
|
| 464 |
+
binomial = binomial_examples(seed=seed, count=synthetic_count)
|
| 465 |
+
priority = priority_examples(seed=seed, count=min(400, synthetic_count))
|
| 466 |
+
splits = partition_examples(bank + probabilities + severity + binomial + priority, seed, require_all_primitives=synthetic_count >= 4)
|
| 467 |
+
sources = {"banking77": {**provenance, "audit": audit}}
|
| 468 |
+
target_types = {
|
| 469 |
+
"synthetic_probability": "Exact mathematical probabilities",
|
| 470 |
+
"synthetic_severity": "Executable severity_label rule, one-hot targets",
|
| 471 |
+
"synthetic_binomial": "Exact binomial probabilities over 3-6 ordered levels",
|
| 472 |
+
"synthetic_priority": "Executable priority_label rule, one-hot targets over 4 levels",
|
| 473 |
+
}
|
| 474 |
+
for name, examples, generator_revision in (
|
| 475 |
+
("synthetic_probability", probabilities, "uniform-bags-v1"),
|
| 476 |
+
("synthetic_severity", severity, "ordered-incident-rules-v1"),
|
| 477 |
+
("synthetic_binomial", binomial, "binomial-draws-v1"),
|
| 478 |
+
("synthetic_priority", priority, "ordered-ticket-priority-rules-v1"),
|
| 479 |
+
):
|
| 480 |
+
sources[name] = {
|
| 481 |
+
"urls": [], "origin": "Locally generated; no external dataset or teacher model",
|
| 482 |
+
"license": "Not specified for locally authored synthetic data",
|
| 483 |
+
"generator_revision": generator_revision,
|
| 484 |
+
"sha256": hashlib.sha256(jsonl_content(examples)).hexdigest(),
|
| 485 |
+
"documents": len({example.group for example in examples}), "examples": len(examples),
|
| 486 |
+
"target_type": target_types[name],
|
| 487 |
+
}
|
| 488 |
+
manifest = {
|
| 489 |
+
"format_version": 1,
|
| 490 |
+
"config": {"seed": seed, "bank_limit": bank_limit, "synthetic_count": synthetic_count, "include_sst5": include_sst5},
|
| 491 |
+
"sources": sources,
|
| 492 |
+
"counts": {
|
| 493 |
+
"examples": {split: len(examples) for split, examples in splits.items()},
|
| 494 |
+
"groups": {split: len({example.group for example in examples}) for split, examples in splits.items()},
|
| 495 |
+
"by_source": {split: dict(Counter(example.source for example in examples)) for split, examples in splits.items()},
|
| 496 |
+
"by_primitive": {split: dict(Counter(example.kind for example in examples)) for split, examples in splits.items()},
|
| 497 |
+
},
|
| 498 |
+
"policies": {
|
| 499 |
+
"banking_task": "Subset-choice classification with 2-8 candidates, NOT a full-77 benchmark; one positive and one negative Noul per document.",
|
| 500 |
+
"groups": "Source plus SHA256 of lowercase, whitespace-collapsed document; no punctuation removal or semantic deduplication.",
|
| 501 |
+
"banking_splits": "Original test groups reserved before sampling. Original train groups hash to train/dev/calibration at 80/10/10.",
|
| 502 |
+
"duplicates": "Test labels win cross-split conflicts; conflicting labels within the owning split drop the whole group; no refill after raw-record caps.",
|
| 503 |
+
"synthetic_splits": "Canonical groups hash to 70/10/10/10; rejection sampling meets quotas without moving or copying groups between splits.",
|
| 504 |
+
"severity_holdout": "Four split-exclusive rendering templates, shared rules; canonical numeric scenarios grouped independently of template.",
|
| 505 |
+
"gold_score": "Score calibration/test draw on binary bags, exact binomial draws (3-6 levels), and two executable rule tasks (4 and 5 levels); no human ratings.",
|
| 506 |
+
"small_counts": "Every primitive in every split guaranteed for synthetic_count >= 4; fewer groups can leave splits empty.",
|
| 507 |
+
"sst5": "Deferred, license unverified; enabling it raises ValueError.",
|
| 508 |
+
},
|
| 509 |
+
"outputs": {},
|
| 510 |
+
}
|
| 511 |
+
destination = Path(output_dir)
|
| 512 |
+
destination.mkdir(parents=True, exist_ok=True)
|
| 513 |
+
for split, examples in splits.items():
|
| 514 |
+
filename = f"{split}.jsonl"
|
| 515 |
+
content = jsonl_content(examples)
|
| 516 |
+
(destination / filename).write_bytes(content)
|
| 517 |
+
manifest["outputs"][filename] = {"sha256": hashlib.sha256(content).hexdigest(), "bytes": len(content)}
|
| 518 |
+
(destination / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
| 519 |
+
return splits
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
def validate_split_integrity(splits):
|
| 523 |
+
group_splits = {}
|
| 524 |
+
uids = set()
|
| 525 |
+
for split, examples in splits.items():
|
| 526 |
+
if split not in SPLITS:
|
| 527 |
+
raise ValueError(f"Unknown split: {split}")
|
| 528 |
+
for example in examples:
|
| 529 |
+
if example.split != split:
|
| 530 |
+
raise ValueError(f"Example {example.uid} belongs to {example.split}, not {split}")
|
| 531 |
+
if not isinstance(example.uid, str) or not example.uid.strip():
|
| 532 |
+
raise ValueError("Example UID must be a nonempty string")
|
| 533 |
+
if not isinstance(example.group, str) or not example.group.strip():
|
| 534 |
+
raise ValueError("Example group must be a nonempty string")
|
| 535 |
+
if example.uid in uids:
|
| 536 |
+
raise ValueError(f"Duplicate example UID: {example.uid}")
|
| 537 |
+
if group_splits.setdefault(example.group, split) != split:
|
| 538 |
+
raise ValueError(f"Semantic group leakage across splits: {example.group}")
|
| 539 |
+
uids.add(example.uid)
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
# Where a label came from: Jev's distribution, or people (dataset labels and annotator vote shares).
|
| 543 |
+
LABEL_SOURCES = ("jev_teacher:", "human:", "logit_teacher:")
|
| 544 |
+
# Manifest ``model`` values name the labellers a dataset's targets come from, joined by "+": Jev,
|
| 545 |
+
# people, and generative models read through their answer-token logits ("logit:<model>").
|
| 546 |
+
LABELLERS = ("jev-1.13.0", "human")
|
| 547 |
+
LOGIT_LABELLER = "logit:"
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def labeller_parts(model):
|
| 551 |
+
"""The labellers in a manifest ``model`` value, or None when any of them is unrecognised."""
|
| 552 |
+
if not isinstance(model, str) or not model:
|
| 553 |
+
return None
|
| 554 |
+
parts = model.split("+")
|
| 555 |
+
if any(part not in LABELLERS and not (part.startswith(LOGIT_LABELLER) and len(part) > len(LOGIT_LABELLER))
|
| 556 |
+
for part in parts):
|
| 557 |
+
return None
|
| 558 |
+
return parts
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def join_labellers(parts):
|
| 562 |
+
"""One manifest ``model`` value for several labellers: Jev, then people, then logit teachers."""
|
| 563 |
+
unique = set(parts)
|
| 564 |
+
return "+".join([name for name in LABELLERS if name in unique] + sorted(unique - set(LABELLERS)))
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
def label_source(source):
|
| 568 |
+
"""The labeller prefix of a source string, or None when it has no recognised prefix."""
|
| 569 |
+
return next((prefix for prefix in LABEL_SOURCES if isinstance(source, str) and source.startswith(prefix)), None)
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
def validate_teacher_splits(splits):
|
| 573 |
+
if set(splits) != {"train", "dev"}:
|
| 574 |
+
raise ValueError("Teacher data must contain only train and dev splits")
|
| 575 |
+
validate_split_integrity(splits)
|
| 576 |
+
for examples in splits.values():
|
| 577 |
+
for example in examples:
|
| 578 |
+
prefix = label_source(example.source)
|
| 579 |
+
if prefix is None or not example.source.removeprefix(prefix).strip():
|
| 580 |
+
raise ValueError(f"Invalid teacher source for {example.uid}: {example.source!r}")
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
def load_teacher_data(directory) -> dict[str, list[DecisionExample]]:
|
| 584 |
+
directory = Path(directory)
|
| 585 |
+
manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8"))
|
| 586 |
+
if not isinstance(manifest, dict) or manifest.get("complete") is not True:
|
| 587 |
+
raise ValueError("Teacher dataset must be complete before import")
|
| 588 |
+
if type(manifest.get("format_version")) is not int or manifest["format_version"] != 1:
|
| 589 |
+
raise ValueError("Unsupported teacher manifest format_version")
|
| 590 |
+
if labeller_parts(manifest.get("model")) is None:
|
| 591 |
+
raise ValueError(f"Teacher manifest model must name {' or '.join(LABELLERS)} or {LOGIT_LABELLER}<model>, joined by '+'")
|
| 592 |
+
by_split = manifest.get("by_split")
|
| 593 |
+
outputs = manifest.get("outputs")
|
| 594 |
+
if not isinstance(by_split, dict) or set(by_split) != {"train", "dev"} or not isinstance(outputs, dict):
|
| 595 |
+
raise ValueError("Teacher manifest requires train/dev counts and outputs")
|
| 596 |
+
splits = {}
|
| 597 |
+
for split in ("train", "dev"):
|
| 598 |
+
filename = f"{split}.jsonl"
|
| 599 |
+
integrity = outputs.get(filename)
|
| 600 |
+
if not isinstance(integrity, dict):
|
| 601 |
+
raise ValueError(f"Missing teacher output integrity: {filename}")
|
| 602 |
+
content = (directory / filename).read_bytes()
|
| 603 |
+
if hashlib.sha256(content).hexdigest() != integrity.get("sha256"):
|
| 604 |
+
raise ValueError(f"Teacher output SHA256 mismatch: {filename}")
|
| 605 |
+
examples = [DecisionExample(**json.loads(line)) for line in content.decode("utf-8").splitlines()]
|
| 606 |
+
for count in (by_split[split], integrity.get("examples")):
|
| 607 |
+
if type(count) is not int or count != len(examples):
|
| 608 |
+
raise ValueError(f"Teacher example count mismatch: {filename}")
|
| 609 |
+
splits[split] = examples
|
| 610 |
+
accepted = manifest.get("accepted_examples")
|
| 611 |
+
if type(accepted) is not int or accepted != sum(map(len, splits.values())):
|
| 612 |
+
raise ValueError("Teacher manifest accepted_examples mismatch")
|
| 613 |
+
validate_teacher_splits(splits)
|
| 614 |
+
return splits
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
def write_teacher_data(splits, destination, manifest):
|
| 618 |
+
"""Write ``splits`` as a teacher dataset ``load_teacher_data`` reads: ``manifest`` (the source's,
|
| 619 |
+
with any notes of what was filtered) gets the counts and hashes of what is written."""
|
| 620 |
+
destination = Path(destination)
|
| 621 |
+
destination.mkdir(parents=True, exist_ok=True)
|
| 622 |
+
outputs = {}
|
| 623 |
+
for split in ("train", "dev"):
|
| 624 |
+
content = "".join(json.dumps(example.to_dict(), ensure_ascii=True, allow_nan=False) + "\n" for example in splits[split])
|
| 625 |
+
(destination / f"{split}.jsonl").write_text(content, encoding="utf-8")
|
| 626 |
+
outputs[f"{split}.jsonl"] = {"sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(), "examples": len(splits[split])}
|
| 627 |
+
manifest = {**manifest, "complete": True, "outputs": outputs,
|
| 628 |
+
"accepted_examples": sum(len(splits[split]) for split in ("train", "dev")),
|
| 629 |
+
"by_split": {split: len(splits[split]) for split in ("train", "dev")},
|
| 630 |
+
"by_primitive": dict(Counter(example.kind for split in ("train", "dev") for example in splits[split]))}
|
| 631 |
+
(destination / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=True, allow_nan=False) + "\n",
|
| 632 |
+
encoding="utf-8")
|
| 633 |
+
load_teacher_data(destination) # a written directory must be a loadable one
|
| 634 |
+
return manifest
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
def merge_teacher_data(base_splits, teacher_splits, seed=42) -> dict[str, list[DecisionExample]]:
|
| 638 |
+
if set(base_splits) != set(SPLITS):
|
| 639 |
+
raise ValueError("Base data must contain train, dev, calibration and test splits")
|
| 640 |
+
if type(seed) is not int:
|
| 641 |
+
raise ValueError("seed must be an integer")
|
| 642 |
+
validate_teacher_splits(teacher_splits)
|
| 643 |
+
merged = {split: list(base_splits[split]) for split in SPLITS}
|
| 644 |
+
for split in ("train", "dev"):
|
| 645 |
+
merged[split].extend(teacher_splits[split])
|
| 646 |
+
validate_split_integrity(merged)
|
| 647 |
+
for split in ("train", "dev"):
|
| 648 |
+
random.Random(f"{seed}:{split}").shuffle(merged[split])
|
| 649 |
+
return merged
|
src/yev/training/images.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Images in System One states (v2 plan, Phase 1).
|
| 2 |
+
|
| 3 |
+
A state carries its images as ``IMAGE_PLACEHOLDER`` markers in its text, one for each entry of the
|
| 4 |
+
example's ``images``: content hashes (the SHA-256 of the encoded file) that an ``ImageStore``
|
| 5 |
+
resolves, so a dataset keeps its images beside its JSONL rather than inside it, in an ``images/``
|
| 6 |
+
folder of files named by digest or of uncompressed zip shards (``images-00000.zip``...) holding
|
| 7 |
+
such files, which keeps a Hub repository's file count small.
|
| 8 |
+
|
| 9 |
+
A Qwen3.5 backbone reads each image as ``<|vision_start|>``, one ``<|image_pad|>`` per merged
|
| 10 |
+
patch, then ``<|vision_end|>``. The image processor scales every image to between
|
| 11 |
+
``MIN_IMAGE_TOKENS`` and ``max_image_tokens`` of those, so images have their own budget and never
|
| 12 |
+
push the question or its criteria out of the sequence; ``max_length`` still bounds the text.
|
| 13 |
+
Pillow is imported only when an image is actually read.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
import hashlib
|
| 19 |
+
import io
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
import zipfile
|
| 22 |
+
|
| 23 |
+
IMAGE_PLACEHOLDER = "<|image|>"
|
| 24 |
+
DEFAULT_MAX_IMAGE_TOKENS = 256
|
| 25 |
+
MIN_IMAGE_TOKENS = 16
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def image_digest(data):
|
| 29 |
+
return hashlib.sha256(data).hexdigest()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def save_image(directory, data):
|
| 33 |
+
"""Write encoded image bytes to ``directory`` under their digest; returns the digest."""
|
| 34 |
+
digest = image_digest(data)
|
| 35 |
+
path = Path(directory) / digest
|
| 36 |
+
if not path.exists():
|
| 37 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 38 |
+
temporary = path.with_suffix(".tmp")
|
| 39 |
+
temporary.write_bytes(data)
|
| 40 |
+
temporary.replace(path)
|
| 41 |
+
return digest
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class ImageShards:
|
| 45 |
+
"""Writes encoded images into ``images-NNNNN.zip`` shards of at most ``shard_bytes`` each,
|
| 46 |
+
stored uncompressed (images are compressed already) under their digest."""
|
| 47 |
+
|
| 48 |
+
def __init__(self, directory, shard_bytes=1 << 30):
|
| 49 |
+
self.directory, self.shard_bytes = Path(directory), shard_bytes
|
| 50 |
+
self.directory.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
self.written, self.archive, self.size = set(), None, 0
|
| 52 |
+
|
| 53 |
+
def add(self, data):
|
| 54 |
+
digest = image_digest(data)
|
| 55 |
+
if digest in self.written:
|
| 56 |
+
return digest
|
| 57 |
+
if self.archive is None or self.size + len(data) > self.shard_bytes:
|
| 58 |
+
self.close()
|
| 59 |
+
index = len(list(self.directory.glob("images-*.zip")))
|
| 60 |
+
self.archive, self.size = zipfile.ZipFile(self.directory / f"images-{index:05d}.zip", "w", zipfile.ZIP_STORED), 0
|
| 61 |
+
self.archive.writestr(digest, data)
|
| 62 |
+
self.written.add(digest)
|
| 63 |
+
self.size += len(data)
|
| 64 |
+
return digest
|
| 65 |
+
|
| 66 |
+
def close(self):
|
| 67 |
+
if self.archive is not None:
|
| 68 |
+
self.archive.close()
|
| 69 |
+
self.archive = None
|
| 70 |
+
|
| 71 |
+
def __enter__(self):
|
| 72 |
+
return self
|
| 73 |
+
|
| 74 |
+
def __exit__(self, *exception):
|
| 75 |
+
self.close()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class ImageStore:
|
| 79 |
+
"""Encoded images by digest: added in memory, or read from registered directories holding files
|
| 80 |
+
named by their digest and zip shards of such files (a dataset's ``images/`` folder)."""
|
| 81 |
+
|
| 82 |
+
def __init__(self, *directories):
|
| 83 |
+
self.directories = []
|
| 84 |
+
self.memory = {}
|
| 85 |
+
self.shards = {} # digest -> zip path
|
| 86 |
+
self.open = {} # zip path -> open ZipFile
|
| 87 |
+
for directory in directories:
|
| 88 |
+
self.add_directory(directory)
|
| 89 |
+
|
| 90 |
+
def add_directory(self, directory):
|
| 91 |
+
directory = Path(directory)
|
| 92 |
+
if directory not in self.directories:
|
| 93 |
+
self.directories.append(directory)
|
| 94 |
+
for archive in sorted(directory.glob("images-*.zip")) if directory.is_dir() else []:
|
| 95 |
+
with zipfile.ZipFile(archive) as opened:
|
| 96 |
+
self.shards.update(dict.fromkeys(opened.namelist(), archive))
|
| 97 |
+
|
| 98 |
+
def put(self, data):
|
| 99 |
+
digest = image_digest(data)
|
| 100 |
+
self.memory[digest] = data
|
| 101 |
+
return digest
|
| 102 |
+
|
| 103 |
+
def read(self, digest):
|
| 104 |
+
if digest in self.memory:
|
| 105 |
+
return self.memory[digest]
|
| 106 |
+
archive = self.shards.get(digest)
|
| 107 |
+
if archive is not None:
|
| 108 |
+
if archive not in self.open:
|
| 109 |
+
self.open[archive] = zipfile.ZipFile(archive)
|
| 110 |
+
return self.open[archive].read(digest)
|
| 111 |
+
for directory in self.directories:
|
| 112 |
+
path = directory / digest
|
| 113 |
+
if path.is_file():
|
| 114 |
+
return path.read_bytes()
|
| 115 |
+
raise KeyError(f"No image {digest} in memory or in {[str(directory) for directory in self.directories]}")
|
| 116 |
+
|
| 117 |
+
def load(self, digest):
|
| 118 |
+
from PIL import Image
|
| 119 |
+
|
| 120 |
+
with Image.open(io.BytesIO(self.read(digest))) as image:
|
| 121 |
+
return image.convert("RGB")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def image_processor(source, max_image_tokens=DEFAULT_MAX_IMAGE_TOKENS, revision=None):
|
| 125 |
+
"""The backbone's image processor (PIL backend on every platform, so outputs never depend on
|
| 126 |
+
whether torchvision is installed), sized to ``MIN_IMAGE_TOKENS``..``max_image_tokens`` per image."""
|
| 127 |
+
from transformers.models.qwen2_vl.image_processing_pil_qwen2_vl import Qwen2VLImageProcessorPil
|
| 128 |
+
|
| 129 |
+
processor = Qwen2VLImageProcessorPil.from_pretrained(source, revision=revision)
|
| 130 |
+
pixels_per_token = (processor.patch_size * processor.merge_size) ** 2
|
| 131 |
+
return Qwen2VLImageProcessorPil.from_pretrained(
|
| 132 |
+
source, revision=revision,
|
| 133 |
+
size={"shortest_edge": MIN_IMAGE_TOKENS * pixels_per_token, "longest_edge": max_image_tokens * pixels_per_token})
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@dataclass
|
| 137 |
+
class VisionInputs:
|
| 138 |
+
"""What the collator needs to turn an example's images into backbone inputs."""
|
| 139 |
+
processor: object
|
| 140 |
+
start_id: int
|
| 141 |
+
pad_id: int
|
| 142 |
+
end_id: int
|
| 143 |
+
max_image_tokens: int = DEFAULT_MAX_IMAGE_TOKENS
|
| 144 |
+
store: ImageStore = field(default_factory=ImageStore)
|
| 145 |
+
|
| 146 |
+
@classmethod
|
| 147 |
+
def for_backbone(cls, backbone_config, processor, max_image_tokens=DEFAULT_MAX_IMAGE_TOKENS, store=None):
|
| 148 |
+
return cls(processor, backbone_config.vision_start_token_id, backbone_config.image_token_id,
|
| 149 |
+
backbone_config.vision_end_token_id, max_image_tokens, store or ImageStore())
|
| 150 |
+
|
| 151 |
+
def prepare(self, examples):
|
| 152 |
+
"""Per example, the token block of each of its images; and the batch's pixel values and grids."""
|
| 153 |
+
digests = [digest for example in examples for digest in example.images]
|
| 154 |
+
if not digests:
|
| 155 |
+
return [[] for _ in examples], None
|
| 156 |
+
encoded = self.processor(images=[self.store.load(digest) for digest in digests], return_tensors="pt")
|
| 157 |
+
counts = (encoded["image_grid_thw"].prod(-1) // self.processor.merge_size ** 2).tolist()
|
| 158 |
+
blocks, cursor = [], 0
|
| 159 |
+
for example in examples:
|
| 160 |
+
blocks.append([[self.start_id] + [self.pad_id] * count + [self.end_id]
|
| 161 |
+
for count in counts[cursor:cursor + len(example.images)]])
|
| 162 |
+
cursor += len(example.images)
|
| 163 |
+
return blocks, (encoded["pixel_values"], encoded["image_grid_thw"])
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class ImagePrefetcher:
|
| 167 |
+
"""Prepares the images of upcoming micro-batches in background threads while the model trains on
|
| 168 |
+
the current one, and stands in for the ``VisionInputs`` it wraps.
|
| 169 |
+
|
| 170 |
+
Only image work runs in the threads: reading from the store, decoding, resizing and patching,
|
| 171 |
+
which Pillow and NumPy do with the GIL released. Tokenizing stays on the caller's thread. A
|
| 172 |
+
micro-batch nobody submitted is prepared on the spot, so the result never depends on what was
|
| 173 |
+
prefetched.
|
| 174 |
+
"""
|
| 175 |
+
|
| 176 |
+
def __init__(self, vision, workers=2):
|
| 177 |
+
self.vision = vision
|
| 178 |
+
self.pool = ThreadPoolExecutor(workers, thread_name_prefix="yev-images")
|
| 179 |
+
self.pending = {}
|
| 180 |
+
|
| 181 |
+
def __getattr__(self, name):
|
| 182 |
+
return getattr(self.vision, name)
|
| 183 |
+
|
| 184 |
+
@staticmethod
|
| 185 |
+
def key(examples):
|
| 186 |
+
return tuple(example.uid for example in examples) if any(example.images for example in examples) else None
|
| 187 |
+
|
| 188 |
+
def submit(self, examples):
|
| 189 |
+
key = self.key(examples)
|
| 190 |
+
if key is not None and key not in self.pending:
|
| 191 |
+
self.pending[key] = self.pool.submit(self.vision.prepare, list(examples))
|
| 192 |
+
|
| 193 |
+
def submit_update(self, train, indices, micro_batch):
|
| 194 |
+
"""The update's image micro-batches, grouped as ``run.micro_batches`` groups them."""
|
| 195 |
+
pictures = [index for index in indices if train[index].images]
|
| 196 |
+
for start in range(0, len(pictures), micro_batch):
|
| 197 |
+
self.submit([train[index] for index in pictures[start:start + micro_batch]])
|
| 198 |
+
|
| 199 |
+
def prepare(self, examples):
|
| 200 |
+
future = self.pending.pop(self.key(examples), None)
|
| 201 |
+
return future.result() if future is not None else self.vision.prepare(examples)
|
| 202 |
+
|
| 203 |
+
def close(self):
|
| 204 |
+
self.pool.shutdown(wait=False, cancel_futures=True)
|
| 205 |
+
self.pending.clear()
|
| 206 |
+
|
src/yev/training/model.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import replace
|
| 2 |
+
import math
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
from torch import nn
|
| 6 |
+
|
| 7 |
+
from .data import DecisionExample
|
| 8 |
+
from .images import IMAGE_PLACEHOLDER
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
PRIMITIVES = ("choice", "score", "noul")
|
| 12 |
+
# "mlp": v1's per-primitive MLPs over single token positions. "bilinear": MoJev's readout, one
|
| 13 |
+
# rank-r dot product between pooled context-plus-question and each pooled candidate span.
|
| 14 |
+
HEADS = ("mlp", "bilinear")
|
| 15 |
+
# The bilinear head's optional last-token readout (``last_token``), one projection per role.
|
| 16 |
+
LAST_TOKEN_HEADS = ("option_last", "question_last")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def supports_native_bfloat16():
|
| 20 |
+
return torch.cuda.is_available() and torch.cuda.get_device_capability()[0] >= 8 and torch.cuda.is_bf16_supported()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def candidate_order(example, canonical=False):
|
| 24 |
+
"""The order criteria are laid out in: sorted by text for a canonical Choice, otherwise as given.
|
| 25 |
+
|
| 26 |
+
Candidates share one causal sequence, so a candidate's score moves with the rivals placed
|
| 27 |
+
before it. Sorting makes the sequence a function of the candidate set, so a Choice answer
|
| 28 |
+
cannot depend on the order the caller listed its criteria in (MoJev's ``sort_candidates``).
|
| 29 |
+
Score keeps its scale order and Noul its (false, true) order: there the order is the question.
|
| 30 |
+
"""
|
| 31 |
+
count = len(example.criteria)
|
| 32 |
+
if canonical and example.kind == "choice":
|
| 33 |
+
return sorted(range(count), key=example.criteria.__getitem__)
|
| 34 |
+
return list(range(count))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def fits_context(example, tokenizer, max_length):
|
| 38 |
+
"""Whether a question's instructions and criteria fit ``max_length``. A longer state is cut to
|
| 39 |
+
fit, but answer options never are, so a question that fails this cannot be asked at that length."""
|
| 40 |
+
try:
|
| 41 |
+
encode_example(replace(example, state=".", images=[]), tokenizer, max_length)
|
| 42 |
+
except ValueError:
|
| 43 |
+
return False
|
| 44 |
+
return True
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def encode_example(example, tokenizer, max_length, canonical=False, image_blocks=None):
|
| 48 |
+
"""Token layout of one question. ``image_blocks`` holds the token block of each of the
|
| 49 |
+
example's images, which replaces its placeholder; images are outside ``max_length``, which
|
| 50 |
+
bounds the text, and when the text is cut the images all stay."""
|
| 51 |
+
order = candidate_order(example, canonical)
|
| 52 |
+
prefix = tokenizer.encode(f"Task: {example.kind}\nState:\n", add_special_tokens=False)
|
| 53 |
+
if example.images:
|
| 54 |
+
if image_blocks is None or len(image_blocks) != len(example.images):
|
| 55 |
+
raise ValueError("A state with images needs a backbone with a vision encoder")
|
| 56 |
+
state_parts = [tokenizer.encode(part, add_special_tokens=False) for part in example.state.split(IMAGE_PLACEHOLDER)]
|
| 57 |
+
else:
|
| 58 |
+
state_parts, image_blocks = [tokenizer.encode(example.state, add_special_tokens=False)], []
|
| 59 |
+
question = tokenizer.encode(f"\nQuestion: {example.instructions}\nCriteria:", add_special_tokens=False)
|
| 60 |
+
criteria = [tokenizer.encode(f"\n{index}: {example.criteria[original]}\n", add_special_tokens=False) for index, original in enumerate(order)]
|
| 61 |
+
suffix = tokenizer.encode("\nDecision probabilities:", add_special_tokens=False)
|
| 62 |
+
state_budget = max_length - len(prefix) - len(question) - sum(map(len, criteria)) - len(suffix)
|
| 63 |
+
if state_budget < 1:
|
| 64 |
+
raise ValueError("Instructions and criteria exceed max_length; increase it instead of truncating answer options")
|
| 65 |
+
truncated = max(0, sum(map(len, state_parts)) - state_budget)
|
| 66 |
+
input_ids, room = list(prefix), state_budget
|
| 67 |
+
for index, part in enumerate(state_parts):
|
| 68 |
+
input_ids.extend(part[:room])
|
| 69 |
+
room = max(0, room - len(part))
|
| 70 |
+
if index < len(image_blocks):
|
| 71 |
+
input_ids.extend(image_blocks[index])
|
| 72 |
+
context_end = len(input_ids)
|
| 73 |
+
input_ids.extend(question)
|
| 74 |
+
question_span = (context_end, len(input_ids))
|
| 75 |
+
positions, spans = [], []
|
| 76 |
+
for criterion in criteria:
|
| 77 |
+
start = len(input_ids)
|
| 78 |
+
input_ids.extend(criterion)
|
| 79 |
+
positions.append(len(input_ids) - 1)
|
| 80 |
+
spans.append((start, len(input_ids)))
|
| 81 |
+
input_ids.extend(suffix)
|
| 82 |
+
return {
|
| 83 |
+
"input_ids": input_ids,
|
| 84 |
+
"criterion_positions": positions,
|
| 85 |
+
"criterion_spans": spans,
|
| 86 |
+
"criterion_order": order,
|
| 87 |
+
"context_end": context_end,
|
| 88 |
+
"question_span": question_span,
|
| 89 |
+
"decision_position": len(input_ids) - 1,
|
| 90 |
+
"kind": PRIMITIVES.index(example.kind),
|
| 91 |
+
"target": example.target,
|
| 92 |
+
"truncated_state_tokens": truncated,
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class DecisionCollator:
|
| 97 |
+
"""Pads encoded examples into a batch. Criteria arrive in layout order; ``criterion_order`` maps
|
| 98 |
+
each layout slot back to the caller's index, and targets stay in the caller's order."""
|
| 99 |
+
|
| 100 |
+
def __init__(self, tokenizer, max_length=512, canonical=False, vision=None):
|
| 101 |
+
self.tokenizer = tokenizer
|
| 102 |
+
self.max_length = max_length
|
| 103 |
+
self.canonical = canonical
|
| 104 |
+
self.vision = vision
|
| 105 |
+
|
| 106 |
+
def __call__(self, examples):
|
| 107 |
+
blocks, pixels = ([None] * len(examples), None)
|
| 108 |
+
if self.vision is not None and any(example.images for example in examples):
|
| 109 |
+
blocks, pixels = self.vision.prepare(examples)
|
| 110 |
+
encoded = [encode_example(example, self.tokenizer, self.max_length, self.canonical, image_blocks)
|
| 111 |
+
for example, image_blocks in zip(examples, blocks)]
|
| 112 |
+
length = max(len(item["input_ids"]) for item in encoded)
|
| 113 |
+
count = max(len(item["target"]) for item in encoded)
|
| 114 |
+
batch_size = len(encoded)
|
| 115 |
+
batch = {
|
| 116 |
+
"input_ids": torch.full((batch_size, length), self.tokenizer.pad_token_id, dtype=torch.long),
|
| 117 |
+
"attention_mask": torch.zeros(batch_size, length, dtype=torch.long),
|
| 118 |
+
"criterion_positions": torch.zeros(batch_size, count, dtype=torch.long),
|
| 119 |
+
"criterion_mask": torch.zeros(batch_size, count, dtype=torch.bool),
|
| 120 |
+
"criterion_spans": torch.zeros(batch_size, count, 2, dtype=torch.long),
|
| 121 |
+
"criterion_order": torch.arange(count).repeat(batch_size, 1),
|
| 122 |
+
"context_ends": torch.tensor([item["context_end"] for item in encoded]),
|
| 123 |
+
"question_spans": torch.tensor([item["question_span"] for item in encoded]),
|
| 124 |
+
"decision_positions": torch.tensor([item["decision_position"] for item in encoded]),
|
| 125 |
+
"kinds": torch.tensor([item["kind"] for item in encoded]),
|
| 126 |
+
"targets": torch.zeros(batch_size, count, dtype=torch.float32),
|
| 127 |
+
}
|
| 128 |
+
for index, item in enumerate(encoded):
|
| 129 |
+
token_count = len(item["input_ids"])
|
| 130 |
+
option_count = len(item["target"])
|
| 131 |
+
batch["input_ids"][index, :token_count] = torch.tensor(item["input_ids"])
|
| 132 |
+
batch["attention_mask"][index, :token_count] = 1
|
| 133 |
+
batch["criterion_positions"][index, :option_count] = torch.tensor(item["criterion_positions"])
|
| 134 |
+
batch["criterion_spans"][index, :option_count] = torch.tensor(item["criterion_spans"])
|
| 135 |
+
batch["criterion_order"][index, :option_count] = torch.tensor(item["criterion_order"])
|
| 136 |
+
batch["criterion_mask"][index, :option_count] = True
|
| 137 |
+
batch["targets"][index, :option_count] = torch.tensor(item["target"])
|
| 138 |
+
if pixels is not None: # a text-only batch carries no image keys and runs exactly as before
|
| 139 |
+
batch["pixel_values"], batch["image_grid_thw"] = pixels
|
| 140 |
+
batch["mm_token_type_ids"] = (batch["input_ids"] == self.vision.pad_id).int()
|
| 141 |
+
return batch
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def collator_for(model, tokenizer, max_length, vision=None):
|
| 145 |
+
"""The collator matching ``model``: its candidate layout, and its vision inputs when it reads images."""
|
| 146 |
+
return DecisionCollator(tokenizer, max_length, model.canonical_order, vision or getattr(model, "vision", None))
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def distribution_loss(logits, targets, kinds, criterion_mask, ordinal_weight=0.1, brier_weight=0.0):
|
| 150 |
+
"""Plackett-Luce top-1 (cross-entropy against the target distribution), plus the multi-class
|
| 151 |
+
Brier score at ``brier_weight`` (RLCD's calibration term; MoJev trains at 1.0), plus the
|
| 152 |
+
ordinal CDF term on Score. Absent criteria have probability and target 0, so add nothing."""
|
| 153 |
+
cross_entropy = -(targets * logits.log_softmax(-1)).sum(-1)
|
| 154 |
+
probabilities = logits.softmax(-1)
|
| 155 |
+
brier = (probabilities - targets).square().sum(-1)
|
| 156 |
+
counts = criterion_mask.sum(-1)
|
| 157 |
+
cdf_mask = torch.arange(logits.shape[-1], device=logits.device)[None, :] < (counts - 1)[:, None]
|
| 158 |
+
cdf_error = ((probabilities.cumsum(-1) - targets.cumsum(-1)).square() * cdf_mask).sum(-1)
|
| 159 |
+
ordinal = cdf_error / (counts - 1).clamp_min(1)
|
| 160 |
+
return (cross_entropy + brier_weight * brier + ordinal_weight * ordinal * (kinds == PRIMITIVES.index("score"))).mean()
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def span_mask(starts, ends, length):
|
| 164 |
+
positions = torch.arange(length, device=starts.device)
|
| 165 |
+
return ((positions >= starts[..., None]) & (positions < ends[..., None])).float()
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def mean_pool(hidden, mask):
|
| 169 |
+
"""Mean hidden state over each span of ``mask`` (B, S, L); an empty span pools to zero."""
|
| 170 |
+
return torch.matmul(mask, hidden) / mask.sum(-1, keepdim=True).clamp_min(1)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class DecisionModel(nn.Module):
|
| 174 |
+
def __init__(self, backbone, head_width=128, ordinal_weight=0.1, head="mlp", brier_weight=0.0, last_token=False):
|
| 175 |
+
super().__init__()
|
| 176 |
+
if head not in HEADS:
|
| 177 |
+
raise ValueError(f"head must be one of {HEADS}")
|
| 178 |
+
if last_token and head != "bilinear":
|
| 179 |
+
raise ValueError("The last-token readout extends the bilinear head")
|
| 180 |
+
self.backbone = backbone
|
| 181 |
+
self.head = head
|
| 182 |
+
self.head_width = head_width
|
| 183 |
+
self.ordinal_weight = ordinal_weight
|
| 184 |
+
self.brier_weight = brier_weight
|
| 185 |
+
self.last_token = last_token
|
| 186 |
+
# The bilinear head reads candidate text, so its layout is canonical; v1's MLP heads keep
|
| 187 |
+
# the order they were trained with.
|
| 188 |
+
self.canonical_order = head == "bilinear"
|
| 189 |
+
dimension = backbone.config.get_text_config().hidden_size # multimodal configs nest it
|
| 190 |
+
if head == "bilinear":
|
| 191 |
+
# No per-primitive parameters: the primitive is in the text ("Task: noul"), and a Noul
|
| 192 |
+
# is a two-candidate preference scored exactly like a Choice.
|
| 193 |
+
self.heads = nn.ModuleDict({
|
| 194 |
+
"norm": nn.LayerNorm(dimension),
|
| 195 |
+
"context": nn.Linear(dimension, head_width, bias=False),
|
| 196 |
+
"option": nn.Linear(dimension, head_width, bias=False),
|
| 197 |
+
})
|
| 198 |
+
if last_token:
|
| 199 |
+
# A span's mean blurs texts that differ by one word ("The food is a taco, meaning
|
| 200 |
+
# ..." against "The food is not a taco, meaning it does not ..."); the span's last
|
| 201 |
+
# token has read all of it and tells them apart. Zero-initialised, so adding it to a
|
| 202 |
+
# trained head leaves the model's answers unchanged until it is trained.
|
| 203 |
+
for name in LAST_TOKEN_HEADS:
|
| 204 |
+
self.heads[name] = nn.Linear(dimension, head_width, bias=False)
|
| 205 |
+
nn.init.zeros_(self.heads[name].weight)
|
| 206 |
+
else:
|
| 207 |
+
self.heads = nn.ModuleDict({
|
| 208 |
+
"choice": nn.Sequential(nn.Linear(3 * dimension, head_width), nn.SiLU(), nn.Linear(head_width, 1)),
|
| 209 |
+
"score": nn.Sequential(nn.Linear(3 * dimension, head_width), nn.SiLU(), nn.Linear(head_width, 1)),
|
| 210 |
+
"noul": nn.Sequential(nn.Linear(dimension, head_width), nn.SiLU(), nn.Linear(head_width, 1)),
|
| 211 |
+
})
|
| 212 |
+
self.register_buffer("temperatures", torch.ones(len(PRIMITIVES)))
|
| 213 |
+
|
| 214 |
+
def bilinear_logits(self, hidden, context_ends, question_spans, criterion_spans):
|
| 215 |
+
"""MoJev's readout: LayerNorm, mean-pool the context, question and candidate spans, then
|
| 216 |
+
score each candidate as (proj(context) + proj(question)) . proj(candidate) / sqrt(rank).
|
| 217 |
+
With ``last_token``, the question's and each candidate's last token add their own projections."""
|
| 218 |
+
hidden = self.heads["norm"](hidden.float())
|
| 219 |
+
length = hidden.shape[1]
|
| 220 |
+
context = mean_pool(hidden, span_mask(torch.zeros_like(context_ends), context_ends, length)[:, None]).squeeze(1)
|
| 221 |
+
question = mean_pool(hidden, span_mask(question_spans[:, :1], question_spans[:, 1:], length)).squeeze(1)
|
| 222 |
+
options = mean_pool(hidden, span_mask(criterion_spans[..., 0], criterion_spans[..., 1], length))
|
| 223 |
+
query = self.heads["context"](context) + self.heads["context"](question)
|
| 224 |
+
keys = self.heads["option"](options)
|
| 225 |
+
if self.last_token:
|
| 226 |
+
rows = torch.arange(hidden.shape[0], device=hidden.device)
|
| 227 |
+
query = query + self.heads["question_last"](hidden[rows, question_spans[:, 1] - 1])
|
| 228 |
+
ends = (criterion_spans[..., 1] - 1).clamp_min(0) # an absent candidate's (0, 0) span reads token 0, then is masked
|
| 229 |
+
keys = keys + self.heads["option_last"](hidden[rows[:, None], ends])
|
| 230 |
+
return (query[:, None, :] * keys).sum(-1) / self.head_width ** 0.5
|
| 231 |
+
|
| 232 |
+
def forward(self, input_ids, attention_mask, criterion_positions, criterion_mask, decision_positions, kinds,
|
| 233 |
+
context_ends, question_spans, criterion_spans, criterion_order, targets=None,
|
| 234 |
+
pixel_values=None, image_grid_thw=None, mm_token_type_ids=None):
|
| 235 |
+
dtype = torch.bfloat16 if supports_native_bfloat16() else torch.float16
|
| 236 |
+
images = {} if pixel_values is None else {"pixel_values": pixel_values, "image_grid_thw": image_grid_thw,
|
| 237 |
+
"mm_token_type_ids": mm_token_type_ids}
|
| 238 |
+
with torch.autocast("cuda", dtype=dtype, enabled=input_ids.is_cuda):
|
| 239 |
+
hidden = self.backbone(input_ids=input_ids, attention_mask=attention_mask, use_cache=False, return_dict=True,
|
| 240 |
+
**images).last_hidden_state
|
| 241 |
+
if self.head == "bilinear":
|
| 242 |
+
logits = self.bilinear_logits(hidden, context_ends, question_spans, criterion_spans)
|
| 243 |
+
else:
|
| 244 |
+
logits = self.mlp_logits(hidden, criterion_positions, decision_positions, kinds)
|
| 245 |
+
logits = torch.zeros_like(logits).scatter(1, criterion_order, logits) # layout slots -> caller's order
|
| 246 |
+
logits = (logits / self.temperatures[kinds, None]).masked_fill(~criterion_mask, torch.finfo(torch.float32).min)
|
| 247 |
+
result = {"logits": logits}
|
| 248 |
+
if targets is not None:
|
| 249 |
+
result["loss"] = distribution_loss(logits, targets, kinds, criterion_mask, self.ordinal_weight, self.brier_weight)
|
| 250 |
+
return result
|
| 251 |
+
|
| 252 |
+
def mlp_logits(self, hidden, criterion_positions, decision_positions, kinds):
|
| 253 |
+
rows = torch.arange(hidden.shape[0], device=hidden.device)
|
| 254 |
+
question = hidden[rows, decision_positions].float()
|
| 255 |
+
options = hidden[rows[:, None], criterion_positions].float()
|
| 256 |
+
shared = question[:, None, :].expand_as(options)
|
| 257 |
+
features = torch.cat([shared, options, shared * options], dim=-1)
|
| 258 |
+
choice_logits = self.heads["choice"](features).squeeze(-1)
|
| 259 |
+
score_logits = self.heads["score"](features).squeeze(-1)
|
| 260 |
+
logits = torch.where((kinds == 1)[:, None], score_logits, choice_logits)
|
| 261 |
+
binary_logits = torch.zeros_like(logits)
|
| 262 |
+
binary_logits[:, 1] = self.heads["noul"](question).squeeze(-1)
|
| 263 |
+
return torch.where((kinds == 2)[:, None], binary_logits, logits)
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def typed_answer(kind, probabilities, labels):
|
| 267 |
+
if len(probabilities) != len(labels) or not probabilities:
|
| 268 |
+
raise ValueError("One label is required for each probability")
|
| 269 |
+
if not all(math.isfinite(value) and value >= 0 for value in probabilities) or not math.isclose(sum(probabilities), 1, abs_tol=1e-5):
|
| 270 |
+
raise ValueError("Expected a normalized finite probability vector")
|
| 271 |
+
count = len(probabilities)
|
| 272 |
+
if kind == "noul":
|
| 273 |
+
if count != 2:
|
| 274 |
+
raise ValueError("Noul requires false and true outcomes")
|
| 275 |
+
return {"type": "noul", "noul": probabilities[1]}
|
| 276 |
+
mode = max(range(count), key=probabilities.__getitem__)
|
| 277 |
+
if kind == "choice":
|
| 278 |
+
confidence = 1.0 if count == 1 else (probabilities[mode] - 1 / count) / (1 - 1 / count)
|
| 279 |
+
return {"type": kind, "choice": labels[mode], "probabilities": dict(zip(labels, probabilities)), "confidence": confidence}
|
| 280 |
+
if kind != "score" or count < 2:
|
| 281 |
+
raise ValueError("Expected Choice, Score or Noul")
|
| 282 |
+
distance = sum(value * abs(index - mode) for index, value in enumerate(probabilities))
|
| 283 |
+
uniform = sum(abs(index - (count - 1) / 2) for index in range(count)) / count
|
| 284 |
+
return {
|
| 285 |
+
"type": kind,
|
| 286 |
+
"score": sum(index * value for index, value in enumerate(probabilities)),
|
| 287 |
+
"probabilities": {str(index): value for index, value in enumerate(probabilities)},
|
| 288 |
+
"legend": {str(index): label for index, label in enumerate(labels)},
|
| 289 |
+
"confidence": max(0.0, 1 - distance / uniform),
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@torch.no_grad()
|
| 294 |
+
def predict(model, tokenizer, kind, state, instructions, criteria, max_length=512):
|
| 295 |
+
if isinstance(criteria, dict):
|
| 296 |
+
labels = list(criteria)
|
| 297 |
+
descriptions = [f"{label}: {description}" if description else label for label, description in criteria.items()]
|
| 298 |
+
else:
|
| 299 |
+
descriptions = list(criteria)
|
| 300 |
+
labels = descriptions
|
| 301 |
+
if kind == "choice" and len(labels) == 1:
|
| 302 |
+
return typed_answer(kind, [1.0], labels)
|
| 303 |
+
example = DecisionExample("inference", "inference", "user", "test", kind, state, instructions, descriptions, [1 / len(labels)] * len(labels))
|
| 304 |
+
batch = collator_for(model, tokenizer, max_length)([example])
|
| 305 |
+
device = next(model.heads.parameters()).device
|
| 306 |
+
batch = {name: value.to(device) for name, value in batch.items()}
|
| 307 |
+
model.eval()
|
| 308 |
+
probabilities = model(**batch)["logits"].softmax(-1)[0].tolist()
|
| 309 |
+
return typed_answer(kind, probabilities, labels)
|
src/yev/training/run.py
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import contextmanager
|
| 2 |
+
from dataclasses import asdict, dataclass
|
| 3 |
+
import hashlib
|
| 4 |
+
import importlib.util
|
| 5 |
+
from importlib.metadata import PackageNotFoundError, version
|
| 6 |
+
import json
|
| 7 |
+
import math
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
import random
|
| 11 |
+
import tempfile
|
| 12 |
+
from time import perf_counter
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import torch
|
| 16 |
+
|
| 17 |
+
from .images import DEFAULT_MAX_IMAGE_TOKENS, IMAGE_PLACEHOLDER, MIN_IMAGE_TOKENS, ImagePrefetcher, VisionInputs, image_processor
|
| 18 |
+
from .model import HEADS, LAST_TOKEN_HEADS, DecisionModel, PRIMITIVES, collator_for, encode_example, supports_native_bfloat16
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
STAGES = ("head_warmup", "decision_adaptation", "distribution_adaptation")
|
| 22 |
+
LORA_TARGETS = ("q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj")
|
| 23 |
+
# Gated DeltaNet (linear-attention) projections in hybrid backbones such as Qwen3.5. The vision
|
| 24 |
+
# tower uses other names (qkv, proj, linear_fc*), so these adapt the language model only.
|
| 25 |
+
LINEAR_ATTENTION_TARGETS = ("in_proj_qkv", "in_proj_z", "in_proj_a", "in_proj_b", "out_proj")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def lora_targets(backbone):
|
| 29 |
+
"""Attention and MLP projections, plus linear-attention projections when the backbone has them."""
|
| 30 |
+
names = {name.rsplit(".", 1)[-1] for name, _ in backbone.named_modules()}
|
| 31 |
+
return list(LORA_TARGETS) + [name for name in LINEAR_ATTENTION_TARGETS if name in names]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class TrainingConfig:
|
| 36 |
+
model_id: str = "Qwen/Qwen3-4B-Base"
|
| 37 |
+
model_revision: str | None = None
|
| 38 |
+
output_dir: str = "artifacts/training"
|
| 39 |
+
max_length: int = 512
|
| 40 |
+
micro_batch: int = 1
|
| 41 |
+
gradient_accumulation: int = 16
|
| 42 |
+
seed: int = 42
|
| 43 |
+
head_width: int = 128
|
| 44 |
+
head: str = "mlp"
|
| 45 |
+
head_last_token: bool = False # the bilinear head's last-token readout (model.LAST_TOKEN_HEADS)
|
| 46 |
+
brier_weight: float = 0.0
|
| 47 |
+
lora_rank: int = 16
|
| 48 |
+
eval_limit: int = 128
|
| 49 |
+
eval_every: int = 50
|
| 50 |
+
save_every: int = 50
|
| 51 |
+
quantize4bit: bool = True
|
| 52 |
+
gradient_checkpointing: bool = True
|
| 53 |
+
stage_steps: tuple[int, int, int] = (25, 200, 100)
|
| 54 |
+
stream_start: int = 0 # distribution-stage updates of the data stream to skip before the first one
|
| 55 |
+
human_eval_limit: int = 0 # human-labelled dev examples per primitive scored to select best_human.pt; 0 is off
|
| 56 |
+
max_image_tokens: int = DEFAULT_MAX_IMAGE_TOKENS # tokens per image, outside max_length (vision backbones only)
|
| 57 |
+
snapshot_every: int = 0 # also keep the adapters and heads every this many updates under snapshots/; 0 is off
|
| 58 |
+
dataset_fingerprint: str | None = None
|
| 59 |
+
head_learning_rate: float = 5e-4
|
| 60 |
+
learning_rate: float = 1e-4
|
| 61 |
+
weight_decay: float = 0.01
|
| 62 |
+
max_grad_norm: float = 1.0
|
| 63 |
+
|
| 64 |
+
def __post_init__(self):
|
| 65 |
+
self.output_dir = str(self.output_dir)
|
| 66 |
+
self.stage_steps = tuple(self.stage_steps)
|
| 67 |
+
for name in ("max_length", "micro_batch", "gradient_accumulation", "head_width",
|
| 68 |
+
"lora_rank", "eval_limit", "eval_every", "save_every"):
|
| 69 |
+
if type(getattr(self, name)) is not int or getattr(self, name) < 1:
|
| 70 |
+
raise ValueError(f"{name} must be a positive integer")
|
| 71 |
+
if len(self.stage_steps) != 3 or any(type(steps) is not int or steps < 0 for steps in self.stage_steps):
|
| 72 |
+
raise ValueError("stage_steps must contain three nonnegative optimizer-step limits")
|
| 73 |
+
for name in ("head_learning_rate", "learning_rate", "max_grad_norm"):
|
| 74 |
+
if not math.isfinite(getattr(self, name)) or getattr(self, name) <= 0:
|
| 75 |
+
raise ValueError(f"{name} must be finite and positive")
|
| 76 |
+
if not math.isfinite(self.weight_decay) or self.weight_decay < 0:
|
| 77 |
+
raise ValueError("weight_decay must be finite and nonnegative")
|
| 78 |
+
if self.head not in HEADS:
|
| 79 |
+
raise ValueError(f"head must be one of {HEADS}")
|
| 80 |
+
if self.head_last_token and self.head != "bilinear":
|
| 81 |
+
raise ValueError("head_last_token extends the bilinear head")
|
| 82 |
+
if not math.isfinite(self.brier_weight) or self.brier_weight < 0:
|
| 83 |
+
raise ValueError("brier_weight must be finite and nonnegative")
|
| 84 |
+
if type(self.seed) is not int or not 0 <= self.seed < 2**32:
|
| 85 |
+
raise ValueError("seed must be an integer in [0, 2**32)")
|
| 86 |
+
if type(self.stream_start) is not int or self.stream_start < 0:
|
| 87 |
+
raise ValueError("stream_start must be a nonnegative integer")
|
| 88 |
+
if type(self.human_eval_limit) is not int or self.human_eval_limit < 0:
|
| 89 |
+
raise ValueError("human_eval_limit must be a nonnegative integer")
|
| 90 |
+
if type(self.snapshot_every) is not int or self.snapshot_every < 0:
|
| 91 |
+
raise ValueError("snapshot_every must be a nonnegative integer")
|
| 92 |
+
if type(self.max_image_tokens) is not int or self.max_image_tokens < MIN_IMAGE_TOKENS:
|
| 93 |
+
raise ValueError(f"max_image_tokens must be an integer of at least {MIN_IMAGE_TOKENS}")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def seed_rng(seed):
|
| 97 |
+
random.seed(seed)
|
| 98 |
+
np.random.seed(seed)
|
| 99 |
+
torch.manual_seed(seed)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def select_device():
|
| 103 |
+
"""CUDA when present, then Apple MPS, otherwise CPU."""
|
| 104 |
+
if torch.cuda.is_available():
|
| 105 |
+
return torch.device("cuda", torch.cuda.current_device())
|
| 106 |
+
if torch.backends.mps.is_available():
|
| 107 |
+
return torch.device("mps")
|
| 108 |
+
return torch.device("cpu")
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def compute_dtype(device_type):
|
| 112 |
+
if device_type == "mps":
|
| 113 |
+
return torch.bfloat16
|
| 114 |
+
if device_type != "cuda":
|
| 115 |
+
return torch.float32
|
| 116 |
+
return torch.bfloat16 if supports_native_bfloat16() else torch.float16
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def prepare_backbone(backbone, config, trainable):
|
| 120 |
+
checkpointing = trainable and config.gradient_checkpointing
|
| 121 |
+
if config.quantize4bit and trainable: # for training only: it casts every bf16 weight left unquantized to fp32
|
| 122 |
+
from peft import prepare_model_for_kbit_training
|
| 123 |
+
|
| 124 |
+
return prepare_model_for_kbit_training(
|
| 125 |
+
backbone, use_gradient_checkpointing=checkpointing,
|
| 126 |
+
gradient_checkpointing_kwargs={"use_reentrant": False},
|
| 127 |
+
)
|
| 128 |
+
if checkpointing:
|
| 129 |
+
backbone.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
|
| 130 |
+
return backbone
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def load_model(config, adapter_path=None, trainable=True, processor_source=None):
|
| 134 |
+
"""Load a revision-pinned feature backbone; quantized loads require CUDA. A backbone with a
|
| 135 |
+
vision encoder also gets ``model.vision``, its image processor read from ``processor_source``
|
| 136 |
+
(an export) or the base model."""
|
| 137 |
+
if config.quantize4bit and trainable and not torch.cuda.is_available(): # serving in 4-bit also runs on CPU and Apple GPUs
|
| 138 |
+
raise RuntimeError("4-bit training requires CUDA; select a Colab GPU before loading weights")
|
| 139 |
+
from peft import LoraConfig, PeftModel, TaskType, get_peft_model
|
| 140 |
+
from transformers import AutoConfig, AutoModel, AutoTokenizer, BitsAndBytesConfig
|
| 141 |
+
|
| 142 |
+
seed_rng(config.seed)
|
| 143 |
+
device = select_device()
|
| 144 |
+
cuda = device.type == "cuda"
|
| 145 |
+
dtype = compute_dtype(device.type)
|
| 146 |
+
if cuda:
|
| 147 |
+
torch.backends.cuda.matmul.allow_tf32 = True
|
| 148 |
+
torch.backends.cudnn.allow_tf32 = True
|
| 149 |
+
base_config = AutoConfig.from_pretrained(config.model_id, revision=config.model_revision)
|
| 150 |
+
config.model_revision = getattr(base_config, "_commit_hash", None) or config.model_revision
|
| 151 |
+
if config.model_revision is None and not Path(config.model_id).is_dir():
|
| 152 |
+
raise ValueError("Could not resolve the base model revision")
|
| 153 |
+
base_config.use_cache = False
|
| 154 |
+
tokenizer = AutoTokenizer.from_pretrained(config.model_id, revision=config.model_revision)
|
| 155 |
+
if tokenizer.pad_token_id is None:
|
| 156 |
+
if tokenizer.eos_token_id is None:
|
| 157 |
+
raise ValueError("Tokenizer needs a padding or EOS token")
|
| 158 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 159 |
+
options = {"config": base_config, "revision": config.model_revision, "dtype": dtype}
|
| 160 |
+
if cuda:
|
| 161 |
+
options["device_map"] = {"": device}
|
| 162 |
+
if config.quantize4bit:
|
| 163 |
+
options["quantization_config"] = BitsAndBytesConfig(
|
| 164 |
+
load_in_4bit=True, bnb_4bit_quant_type="nf4",
|
| 165 |
+
bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=dtype,
|
| 166 |
+
llm_int8_skip_modules=["visual"], # the vision encoder has no adapters to learn around its rounding
|
| 167 |
+
)
|
| 168 |
+
backbone = AutoModel.from_pretrained(config.model_id, **options)
|
| 169 |
+
if not cuda and device.type != "cpu":
|
| 170 |
+
backbone = backbone.to(device) # device_map loading onto Apple MPS segfaults under transformers 5
|
| 171 |
+
backbone = prepare_backbone(backbone, config, trainable)
|
| 172 |
+
if adapter_path is not None:
|
| 173 |
+
backbone = PeftModel.from_pretrained(backbone, str(adapter_path), is_trainable=trainable)
|
| 174 |
+
else:
|
| 175 |
+
backbone = get_peft_model(backbone, LoraConfig(
|
| 176 |
+
task_type=TaskType.FEATURE_EXTRACTION, r=config.lora_rank,
|
| 177 |
+
lora_alpha=2 * config.lora_rank, lora_dropout=0.05,
|
| 178 |
+
target_modules=lora_targets(backbone), inference_mode=not trainable,
|
| 179 |
+
))
|
| 180 |
+
model = DecisionModel(backbone, head_width=config.head_width, head=config.head, brier_weight=config.brier_weight,
|
| 181 |
+
last_token=config.head_last_token)
|
| 182 |
+
device = backbone.get_input_embeddings().weight.device
|
| 183 |
+
model.heads.to(device=device, dtype=torch.float32)
|
| 184 |
+
model.temperatures = model.temperatures.to(device)
|
| 185 |
+
model.training_config = config
|
| 186 |
+
if (getattr(base_config, "vision_config", None) is not None and getattr(base_config, "image_token_id", None) is not None
|
| 187 |
+
and importlib.util.find_spec("PIL") is not None): # without Pillow the model still answers text
|
| 188 |
+
processor = image_processor(processor_source or config.model_id, config.max_image_tokens,
|
| 189 |
+
None if processor_source else config.model_revision)
|
| 190 |
+
model.vision = VisionInputs.for_backbone(base_config, processor, config.max_image_tokens)
|
| 191 |
+
if not trainable:
|
| 192 |
+
model.requires_grad_(False)
|
| 193 |
+
model.train(trainable)
|
| 194 |
+
return model, tokenizer
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def forward_batch(model, batch):
|
| 198 |
+
return model(**batch)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def device_batch(model, collator, examples):
|
| 202 |
+
device = next(model.heads.parameters()).device
|
| 203 |
+
return {name: value.to(device) for name, value in collator(examples).items()}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
@contextmanager
|
| 207 |
+
def evaluation_mode(model):
|
| 208 |
+
modes = [(module, module.training) for module in model.modules()]
|
| 209 |
+
model.eval()
|
| 210 |
+
try:
|
| 211 |
+
with torch.no_grad():
|
| 212 |
+
yield
|
| 213 |
+
finally:
|
| 214 |
+
for module, mode in modes:
|
| 215 |
+
module.training = mode
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def one_hot(target):
|
| 219 |
+
return sum(value == 1 for value in target) == 1 and all(value in (0, 1) for value in target)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def summarize_metrics(rows):
|
| 223 |
+
hard = [row[2] for row in rows if row[2] is not None]
|
| 224 |
+
return {
|
| 225 |
+
"count": len(rows),
|
| 226 |
+
"soft_log_loss": sum(row[0] for row in rows) / len(rows) if rows else None,
|
| 227 |
+
"brier": sum(row[1] for row in rows) / len(rows) if rows else None,
|
| 228 |
+
"hard_count": len(hard),
|
| 229 |
+
"hard_accuracy": sum(hard) / len(hard) if hard else None,
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def select_per_primitive(examples, limit):
|
| 234 |
+
"""The first ``limit`` examples of each primitive, in their original order."""
|
| 235 |
+
if type(limit) is not int or limit < 1:
|
| 236 |
+
raise ValueError("Evaluation limit must be a positive integer")
|
| 237 |
+
return [item for kind in PRIMITIVES for item in [item for item in examples if item.kind == kind][:limit]]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def human_dev_sample(dev, limit):
|
| 241 |
+
"""``limit`` human-labelled dev examples per primitive, spread over their datasets by a stable hash of the uid."""
|
| 242 |
+
human = sorted((item for item in dev if item.source.startswith("human:")),
|
| 243 |
+
key=lambda item: hashlib.sha256(item.uid.encode("utf-8")).hexdigest())
|
| 244 |
+
return select_per_primitive(human, limit) if human else []
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def scored(items, batch, logits):
|
| 248 |
+
"""(kind, (log loss, Brier, correct or None)) for each question of a scored micro-batch."""
|
| 249 |
+
losses = -(batch["targets"] * logits.log_softmax(-1)).sum(-1)
|
| 250 |
+
briers = (logits.softmax(-1) - batch["targets"]).square().sum(-1)
|
| 251 |
+
predictions = logits.argmax(-1).tolist()
|
| 252 |
+
for index, item in enumerate(items):
|
| 253 |
+
loss, brier = float(losses[index]), float(briers[index])
|
| 254 |
+
if not math.isfinite(loss) or not math.isfinite(brier):
|
| 255 |
+
raise FloatingPointError("Nonfinite evaluation scores")
|
| 256 |
+
yield item.kind, (loss, brier, int(predictions[index] == item.target.index(1)) if one_hot(item.target) else None)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def eval_model(model, tokenizer, examples, config, limit=None):
|
| 260 |
+
"""Mean proper scores and one-hot-only accuracy, overall and per primitive.
|
| 261 |
+
|
| 262 |
+
``limit`` caps the sample **per primitive**, so a split dominated by one primitive still
|
| 263 |
+
yields a meaningful Score or Noul estimate.
|
| 264 |
+
"""
|
| 265 |
+
selected = select_per_primitive(examples, config.eval_limit if limit is None else limit)
|
| 266 |
+
collator = collator_for(model, tokenizer, config.max_length)
|
| 267 |
+
rows = {kind: [] for kind in PRIMITIVES}
|
| 268 |
+
groups = micro_batches(selected, range(len(selected)), config.micro_batch, config.micro_batch * config.max_length,
|
| 269 |
+
token_lengths(collator))
|
| 270 |
+
with evaluation_mode(model):
|
| 271 |
+
for group in groups:
|
| 272 |
+
items = [selected[index] for index in group]
|
| 273 |
+
batch = device_batch(model, collator, items)
|
| 274 |
+
for kind, row in scored(items, batch, forward_batch(model, batch)["logits"].float()):
|
| 275 |
+
rows[kind].append(row)
|
| 276 |
+
result = summarize_metrics([row for values in rows.values() for row in values])
|
| 277 |
+
result["per_primitive"] = {kind: summarize_metrics(values) for kind, values in rows.items()}
|
| 278 |
+
return result
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def best_temperature(rows, candidates):
|
| 282 |
+
"""The candidate with the lowest multi-class Brier score over (logits, target) rows."""
|
| 283 |
+
losses = [sum(float(((logits / candidate).softmax(-1) - target).square().sum()) for logits, target in rows)
|
| 284 |
+
for candidate in candidates]
|
| 285 |
+
if not all(math.isfinite(loss) for loss in losses):
|
| 286 |
+
raise FloatingPointError("Nonfinite calibration logits")
|
| 287 |
+
return float(candidates[min(range(len(losses)), key=losses.__getitem__)])
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def calibrate_model(model, tokenizer, examples, config, split="calibration", tied=()):
|
| 291 |
+
"""Fit temperatures on at most eval_limit examples per primitive from one named split.
|
| 292 |
+
|
| 293 |
+
Each primitive's temperature minimises the multi-class Brier score of the tempered
|
| 294 |
+
distributions (RLCD's calibration step). The default fits on gold calibration data. Passing ``split="dev"`` with teacher-labelled
|
| 295 |
+
development examples fits temperatures for teacher imitation instead; the caller is
|
| 296 |
+
responsible for keeping those examples out of any later scoring sample. The primitives in ``tied`` share
|
| 297 |
+
one temperature fitted on their examples together, so a Noul and its two-option Choice stay equivalent.
|
| 298 |
+
"""
|
| 299 |
+
if split not in ("calibration", "dev"):
|
| 300 |
+
raise ValueError("Temperatures may be fitted on the calibration split or on held-out dev examples only")
|
| 301 |
+
if not examples or any(item.split != split for item in examples):
|
| 302 |
+
raise ValueError(f"Temperature fitting requires {split} examples ONLY")
|
| 303 |
+
collator = collator_for(model, tokenizer, config.max_length)
|
| 304 |
+
collected = {kind: [] for kind in PRIMITIVES}
|
| 305 |
+
previous = model.temperatures.detach().clone()
|
| 306 |
+
try:
|
| 307 |
+
with evaluation_mode(model):
|
| 308 |
+
model.temperatures.fill_(1)
|
| 309 |
+
selected = select_per_primitive(examples, config.eval_limit)
|
| 310 |
+
for start in range(0, len(selected), config.micro_batch):
|
| 311 |
+
items = selected[start:start + config.micro_batch]
|
| 312 |
+
batch = device_batch(model, collator, items)
|
| 313 |
+
logits = forward_batch(model, batch)["logits"].cpu().double()
|
| 314 |
+
for index, item in enumerate(items):
|
| 315 |
+
collected[item.kind].append((logits[index, :len(item.target)], torch.tensor(item.target, dtype=torch.float64)))
|
| 316 |
+
candidates = [1.0] + [2 ** (index / 8) for index in range(-24, 25) if index != 0] # 0.125 to 8.0
|
| 317 |
+
temperatures = {}
|
| 318 |
+
groups = ([tuple(tied)] if tied else []) + [(kind,) for kind in PRIMITIVES if kind not in tied]
|
| 319 |
+
for group in groups:
|
| 320 |
+
temperatures.update(dict.fromkeys(group, best_temperature([row for kind in group for row in collected[kind]], candidates)))
|
| 321 |
+
model.temperatures.copy_(model.temperatures.new_tensor([temperatures[kind] for kind in PRIMITIVES]))
|
| 322 |
+
except BaseException:
|
| 323 |
+
model.temperatures.copy_(previous)
|
| 324 |
+
raise
|
| 325 |
+
return temperatures
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def fingerprint(value):
|
| 329 |
+
content = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
| 330 |
+
return hashlib.sha256(content.encode()).hexdigest()
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def data_fingerprint(train, dev):
|
| 334 |
+
digest = hashlib.sha256()
|
| 335 |
+
for split, examples in (("train", train), ("dev", dev)):
|
| 336 |
+
digest.update(split.encode())
|
| 337 |
+
for item in examples:
|
| 338 |
+
digest.update(fingerprint(item.to_dict()).encode())
|
| 339 |
+
return digest.hexdigest()
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def config_fingerprint(config, model):
|
| 343 |
+
settings = asdict(config)
|
| 344 |
+
settings.pop("output_dir")
|
| 345 |
+
for name in ("stream_start", "human_eval_limit", "snapshot_every"):
|
| 346 |
+
if not settings[name]: # runs from before these settings existed keep their fingerprint
|
| 347 |
+
settings.pop(name)
|
| 348 |
+
if settings["max_image_tokens"] == DEFAULT_MAX_IMAGE_TOKENS:
|
| 349 |
+
settings.pop("max_image_tokens")
|
| 350 |
+
settings["ordinal_weight"] = model.ordinal_weight
|
| 351 |
+
return fingerprint(settings)
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def stage_pool_size(train, stage):
|
| 355 |
+
"""Presentations in one epoch of a stage's stream, mirroring ``stage_indices``."""
|
| 356 |
+
hard = sum(1 for item in train if one_hot(item.target))
|
| 357 |
+
soft = len(train) - hard
|
| 358 |
+
if stage < 2 or not hard or not soft:
|
| 359 |
+
return hard or soft
|
| 360 |
+
return 2 * max(hard, soft)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def plan_stage_steps(train, config, head_steps=25, decision_epochs=1, distribution_epochs=1):
|
| 364 |
+
"""Derive stage limits that correspond to whole passes over the label pools.
|
| 365 |
+
|
| 366 |
+
Stage 1 warms the frozen-backbone heads, stage 2 adapts on hard labels only, and
|
| 367 |
+
stage 3 replays the balanced hard/soft stream that ``stage_indices`` builds, so one
|
| 368 |
+
distribution epoch costs ``2 * max(hard, soft)`` presentations. Counters describe
|
| 369 |
+
presentations rather than unique examples, and the caller must still measure real
|
| 370 |
+
throughput: this is arithmetic, not a runtime prediction.
|
| 371 |
+
"""
|
| 372 |
+
for name, value in (("head_steps", head_steps), ("decision_epochs", decision_epochs),
|
| 373 |
+
("distribution_epochs", distribution_epochs)):
|
| 374 |
+
if type(value) is not int or value < 0:
|
| 375 |
+
raise ValueError(f"{name} must be a nonnegative integer")
|
| 376 |
+
if not train:
|
| 377 |
+
raise ValueError("A nonempty training split is required to plan a schedule")
|
| 378 |
+
batch = config.micro_batch * config.gradient_accumulation
|
| 379 |
+
hard = sum(1 for item in train if one_hot(item.target))
|
| 380 |
+
soft = len(train) - hard
|
| 381 |
+
decision_updates = -(-stage_pool_size(train, 1) // batch)
|
| 382 |
+
distribution_updates = -(-stage_pool_size(train, 2) // batch)
|
| 383 |
+
return {
|
| 384 |
+
"stage_steps": (head_steps, decision_updates * decision_epochs, distribution_updates * distribution_epochs),
|
| 385 |
+
"effective_batch": batch,
|
| 386 |
+
"hard_examples": hard,
|
| 387 |
+
"soft_examples": soft,
|
| 388 |
+
"head_warmup_presentations": head_steps * batch,
|
| 389 |
+
"decision_epochs": decision_epochs,
|
| 390 |
+
"decision_updates_per_epoch": decision_updates,
|
| 391 |
+
"distribution_epochs": distribution_epochs,
|
| 392 |
+
"distribution_updates_per_epoch": distribution_updates,
|
| 393 |
+
"total_updates": head_steps + decision_updates * decision_epochs + distribution_updates * distribution_epochs,
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def stage_indices(train, stage, seed, epoch):
|
| 398 |
+
generator = random.Random(f"{seed}:{stage}:{epoch}")
|
| 399 |
+
hard = [index for index, item in enumerate(train) if one_hot(item.target)]
|
| 400 |
+
soft = [index for index, item in enumerate(train) if not one_hot(item.target)]
|
| 401 |
+
generator.shuffle(hard)
|
| 402 |
+
generator.shuffle(soft)
|
| 403 |
+
if stage < 2:
|
| 404 |
+
return hard or soft
|
| 405 |
+
if not hard or not soft:
|
| 406 |
+
return hard or soft
|
| 407 |
+
return [pool[index % len(pool)] for index in range(max(len(hard), len(soft))) for pool in (soft, hard)]
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def adapter_parameters(model):
|
| 411 |
+
return {name: parameter for name, parameter in model.named_parameters() if "lora_" in name}
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def checkpoint_tensors(model):
|
| 415 |
+
return {**adapter_parameters(model),
|
| 416 |
+
**{f"heads.{name}": value for name, value in model.heads.state_dict().items()},
|
| 417 |
+
"temperatures": model.temperatures}
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def tensor_state(model):
|
| 421 |
+
return {name: value.detach().cpu().clone() for name, value in checkpoint_tensors(model).items()}
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def with_new_heads(model, tensors):
|
| 425 |
+
"""Checkpoint ``tensors`` completed with the model's zero-initialised last-token readout where
|
| 426 |
+
the checkpoint predates it, so a continuation can add the readout and start where it left off."""
|
| 427 |
+
added = {name: value for name, value in tensor_state(model).items()
|
| 428 |
+
if name not in tensors and name.startswith("heads.") and name.split(".")[1] in LAST_TOKEN_HEADS}
|
| 429 |
+
return {**tensors, **added}
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def restore_tensors(model, tensors):
|
| 433 |
+
expected = checkpoint_tensors(model)
|
| 434 |
+
if tensors.keys() != expected.keys() or any(tensors[name].shape != value.shape for name, value in expected.items()):
|
| 435 |
+
raise ValueError("Checkpoint adapter/head layout does not match the model")
|
| 436 |
+
with torch.no_grad():
|
| 437 |
+
for name, value in expected.items():
|
| 438 |
+
value.copy_(tensors[name])
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def capture_rng():
|
| 442 |
+
numpy_state = np.random.get_state()
|
| 443 |
+
return {
|
| 444 |
+
"python": random.getstate(), "torch": torch.get_rng_state(),
|
| 445 |
+
"numpy": (numpy_state[0], numpy_state[1].tolist(), *numpy_state[2:]),
|
| 446 |
+
"cuda": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else [],
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def restore_rng(states):
|
| 451 |
+
random.setstate(states["python"])
|
| 452 |
+
torch.set_rng_state(states["torch"])
|
| 453 |
+
numpy_state = states["numpy"]
|
| 454 |
+
np.random.set_state((numpy_state[0], np.array(numpy_state[1], dtype=np.uint32), *numpy_state[2:]))
|
| 455 |
+
if states["cuda"]:
|
| 456 |
+
if not torch.cuda.is_available() or len(states["cuda"]) != torch.cuda.device_count():
|
| 457 |
+
raise ValueError("Resume requires the same CUDA device count")
|
| 458 |
+
torch.cuda.set_rng_state_all(states["cuda"])
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def atomic_save(payload, path):
|
| 462 |
+
path = Path(path)
|
| 463 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 464 |
+
temporary = None
|
| 465 |
+
try:
|
| 466 |
+
with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", delete=False) as stream:
|
| 467 |
+
temporary = Path(stream.name)
|
| 468 |
+
torch.save(payload, stream)
|
| 469 |
+
stream.flush()
|
| 470 |
+
os.fsync(stream.fileno())
|
| 471 |
+
os.replace(temporary, path)
|
| 472 |
+
finally:
|
| 473 |
+
if temporary is not None:
|
| 474 |
+
temporary.unlink(missing_ok=True)
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def stage_optimizer(model, config, steps, stage=0):
|
| 478 |
+
head_rate = config.head_learning_rate * (1.0, 0.6, 0.2)[stage]
|
| 479 |
+
adapter_rate = config.learning_rate * (1.0, 1.0, 0.3)[stage]
|
| 480 |
+
groups = [
|
| 481 |
+
{"params": [parameter for name, parameter in model.heads.named_parameters() if not name.endswith("bias")], "lr": head_rate},
|
| 482 |
+
{"params": [parameter for name, parameter in model.heads.named_parameters() if name.endswith("bias")], "lr": head_rate, "weight_decay": 0.0},
|
| 483 |
+
]
|
| 484 |
+
adapters = list(adapter_parameters(model).values())
|
| 485 |
+
if adapters:
|
| 486 |
+
groups.append({"params": adapters, "lr": adapter_rate})
|
| 487 |
+
optimizer = torch.optim.AdamW(groups, lr=config.learning_rate, weight_decay=config.weight_decay)
|
| 488 |
+
warmup = max(1, math.ceil(0.03 * steps))
|
| 489 |
+
|
| 490 |
+
def schedule(step):
|
| 491 |
+
if step < warmup:
|
| 492 |
+
return (step + 1) / warmup
|
| 493 |
+
progress = min(1.0, (step - warmup) / max(1, steps - warmup))
|
| 494 |
+
return 0.5 * (1 + math.cos(math.pi * progress))
|
| 495 |
+
|
| 496 |
+
scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, schedule)
|
| 497 |
+
return optimizer, scheduler
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
def validate_training(model, splits, config):
|
| 501 |
+
train, dev = splits["train"], splits["dev"]
|
| 502 |
+
if not train or not dev:
|
| 503 |
+
raise ValueError("Nonempty train and dev splits are required")
|
| 504 |
+
if any(item.split != "train" for item in train) or any(item.split != "dev" for item in dev):
|
| 505 |
+
raise ValueError("Training and dev selection must use their own splits")
|
| 506 |
+
if {item.group for item in train} & {item.group for item in dev}:
|
| 507 |
+
raise ValueError("Train and dev document groups must be disjoint")
|
| 508 |
+
actual_fingerprint = data_fingerprint(train, dev)
|
| 509 |
+
if config.dataset_fingerprint not in (None, actual_fingerprint):
|
| 510 |
+
raise ValueError("Dataset fingerprint mismatch")
|
| 511 |
+
config.dataset_fingerprint = actual_fingerprint
|
| 512 |
+
if model.head_width != config.head_width:
|
| 513 |
+
raise ValueError("Config head_width does not match the model")
|
| 514 |
+
if model.head != config.head or model.brier_weight != config.brier_weight:
|
| 515 |
+
raise ValueError("Config head and brier_weight must match the model")
|
| 516 |
+
if model.last_token != config.head_last_token:
|
| 517 |
+
raise ValueError("Config head_last_token must match the model")
|
| 518 |
+
if any(config.stage_steps[1:]) and not adapter_parameters(model):
|
| 519 |
+
raise ValueError("Adaptation stages require a LoRA backbone")
|
| 520 |
+
return train, dev
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
def token_lengths(collator):
|
| 524 |
+
"""Each text question's encoded length, counted once."""
|
| 525 |
+
lengths = {}
|
| 526 |
+
|
| 527 |
+
def length(example):
|
| 528 |
+
if example.uid not in lengths:
|
| 529 |
+
lengths[example.uid] = len(encode_example(example, collator.tokenizer, collator.max_length, collator.canonical)["input_ids"])
|
| 530 |
+
return lengths[example.uid]
|
| 531 |
+
return length
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def micro_batches(train, indices, micro_batch, budget, length):
|
| 535 |
+
"""An update's micro-batches. Image questions go in fixed groups of ``micro_batch``, the groups the
|
| 536 |
+
image prefetcher prepares; text questions are sorted by length and packed while a group's padded
|
| 537 |
+
size (its longest question times its count) stays within ``budget`` tokens, so short questions no
|
| 538 |
+
longer run one or two to a forward pass. The update's gradient is the same however it is split."""
|
| 539 |
+
pictures = [index for index in indices if train[index].images]
|
| 540 |
+
groups = [pictures[start:start + micro_batch] for start in range(0, len(pictures), micro_batch)]
|
| 541 |
+
group = []
|
| 542 |
+
for index in sorted((index for index in indices if not train[index].images), key=lambda index: length(train[index])):
|
| 543 |
+
if group and length(train[index]) * (len(group) + 1) > budget:
|
| 544 |
+
groups.append(group)
|
| 545 |
+
group = []
|
| 546 |
+
group.append(index)
|
| 547 |
+
return groups + ([group] if group else [])
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def optimizer_update(model, collator, train, indices, config, optimizer, scaler, length):
|
| 551 |
+
optimizer.zero_grad(set_to_none=True)
|
| 552 |
+
update_loss = 0.0
|
| 553 |
+
for micro_indices in micro_batches(train, indices, config.micro_batch, config.micro_batch * config.max_length, length):
|
| 554 |
+
batch = device_batch(model, collator, [train[index] for index in micro_indices])
|
| 555 |
+
loss = forward_batch(model, batch)["loss"]
|
| 556 |
+
if not torch.isfinite(loss):
|
| 557 |
+
raise FloatingPointError("Nonfinite training loss; last.pt remains at its saved optimizer boundary")
|
| 558 |
+
weighted_loss = loss * (len(micro_indices) / len(indices))
|
| 559 |
+
scaler.scale(weighted_loss).backward()
|
| 560 |
+
update_loss += float(weighted_loss.detach())
|
| 561 |
+
scaler.unscale_(optimizer)
|
| 562 |
+
torch.nn.utils.clip_grad_norm_(
|
| 563 |
+
[parameter for parameter in model.parameters() if parameter.requires_grad],
|
| 564 |
+
config.max_grad_norm, error_if_nonfinite=not scaler.is_enabled(),
|
| 565 |
+
)
|
| 566 |
+
previous_scale = scaler.get_scale()
|
| 567 |
+
scaler.step(optimizer)
|
| 568 |
+
scaler.update()
|
| 569 |
+
optimizer.zero_grad(set_to_none=True)
|
| 570 |
+
return None if scaler.get_scale() < previous_scale else update_loss
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def run_training(model, tokenizer, splits, config, resume=True):
|
| 574 |
+
"""Bounded optimizer steps; last.pt resumes only through its last saved update.
|
| 575 |
+
|
| 576 |
+
Warmup and decision adaptation use hard labels when available. Distribution
|
| 577 |
+
adaptation balances soft targets and hard replay, repeating the smaller pool.
|
| 578 |
+
Each stage has a fresh AdamW optimizer, 3% warmup and cosine decay. Changed schedules
|
| 579 |
+
are refused on resume. ``stream_start`` begins distribution adaptation that many updates
|
| 580 |
+
into its data stream, so continuing a run on the same data and seed skips what it trained on.
|
| 581 |
+
With ``human_eval_limit`` each evaluation also scores a fixed sample of human-labelled dev
|
| 582 |
+
examples and keeps best_human.pt, the update with their lowest log loss. best.pt is selected by dev soft log loss, while the
|
| 583 |
+
returned model remains at the last update, not automatically at the best.
|
| 584 |
+
"""
|
| 585 |
+
train, dev = validate_training(model, splits, config)
|
| 586 |
+
actual_fingerprint = config.dataset_fingerprint
|
| 587 |
+
adapters = adapter_parameters(model)
|
| 588 |
+
device = next(model.heads.parameters()).device
|
| 589 |
+
model.heads.to(dtype=torch.float32)
|
| 590 |
+
model.training_config = config
|
| 591 |
+
signature = config_fingerprint(config, model)
|
| 592 |
+
directory = Path(config.output_dir)
|
| 593 |
+
directory.mkdir(parents=True, exist_ok=True)
|
| 594 |
+
last_path = directory / "last.pt"
|
| 595 |
+
saved = torch.load(last_path, map_location="cpu", weights_only=True) if resume and last_path.exists() else None
|
| 596 |
+
if saved is not None:
|
| 597 |
+
if saved["format_version"] != 1 or saved["config_fingerprint"] != signature:
|
| 598 |
+
raise ValueError("Checkpoint config/schedule fingerprint mismatch; restart explicitly with resume=False")
|
| 599 |
+
if saved["data_fingerprint"] != actual_fingerprint:
|
| 600 |
+
raise ValueError("Checkpoint dataset fingerprint mismatch")
|
| 601 |
+
if saved["device_type"] != device.type:
|
| 602 |
+
raise ValueError("Exact continuation requires the same device type")
|
| 603 |
+
restore_tensors(model, saved["tensors"])
|
| 604 |
+
history, best, best_human = saved["history"], saved["best"], saved.get("best_human")
|
| 605 |
+
history.setdefault("best_human_dev_log_loss", None)
|
| 606 |
+
history.setdefault("best_human_step", None)
|
| 607 |
+
if best is not None:
|
| 608 |
+
atomic_save(best, directory / "best.pt")
|
| 609 |
+
if best_human is not None:
|
| 610 |
+
atomic_save(best_human, directory / "best_human.pt")
|
| 611 |
+
else:
|
| 612 |
+
seed_rng(config.seed)
|
| 613 |
+
model.temperatures.fill_(1)
|
| 614 |
+
history = {"completed_steps": 0, "best_dev_log_loss": None, "best_step": None,
|
| 615 |
+
"best_human_dev_log_loss": None, "best_human_step": None, "updates": []}
|
| 616 |
+
best = best_human = None
|
| 617 |
+
human_dev = human_dev_sample(dev, config.human_eval_limit) if config.human_eval_limit else []
|
| 618 |
+
# Image questions are prepared one update ahead in background threads, so decoding and resizing
|
| 619 |
+
# overlap the GPU's work on the current update; text-only training is unchanged.
|
| 620 |
+
prefetcher = (ImagePrefetcher(model.vision) if getattr(model, "vision", None) is not None
|
| 621 |
+
and any(example.images for example in train) else None)
|
| 622 |
+
collator = collator_for(model, tokenizer, config.max_length, prefetcher)
|
| 623 |
+
length = token_lengths(collator)
|
| 624 |
+
update_size = config.micro_batch * config.gradient_accumulation
|
| 625 |
+
global_step = history["completed_steps"]
|
| 626 |
+
start_stage = saved["stage"] if saved is not None else 0
|
| 627 |
+
for stage in range(start_stage, len(STAGES)):
|
| 628 |
+
steps = config.stage_steps[stage]
|
| 629 |
+
if steps == 0:
|
| 630 |
+
continue
|
| 631 |
+
for name, parameter in model.named_parameters():
|
| 632 |
+
parameter.requires_grad_(name.startswith("heads.") or (stage > 0 and name in adapters))
|
| 633 |
+
model.train()
|
| 634 |
+
if stage == 0:
|
| 635 |
+
model.backbone.eval()
|
| 636 |
+
print(f"{STAGES[stage]}: {steps} optimizer steps", flush=True)
|
| 637 |
+
optimizer, scheduler = stage_optimizer(model, config, steps, stage)
|
| 638 |
+
scaler = torch.amp.GradScaler("cuda", enabled=device.type == "cuda" and not supports_native_bfloat16())
|
| 639 |
+
stage_step, epoch, offset = 0, 0, 0
|
| 640 |
+
resumed = saved is not None and stage == saved["stage"]
|
| 641 |
+
if resumed:
|
| 642 |
+
optimizer.load_state_dict(saved["optimizer"])
|
| 643 |
+
scheduler.load_state_dict(saved["scheduler"])
|
| 644 |
+
scaler.load_state_dict(saved["scaler"])
|
| 645 |
+
stage_step, epoch, offset = saved["stage_step"], saved["epoch"], saved["offset"]
|
| 646 |
+
restore_rng(saved["rng"])
|
| 647 |
+
saved = None
|
| 648 |
+
order = stage_indices(train, stage, config.seed, epoch)
|
| 649 |
+
if stage == 2 and not resumed:
|
| 650 |
+
for _ in range(config.stream_start): # advance as that many updates did, short epoch-end batches included
|
| 651 |
+
offset += min(config.micro_batch * config.gradient_accumulation, len(order) - offset)
|
| 652 |
+
if offset == len(order):
|
| 653 |
+
epoch, offset = epoch + 1, 0
|
| 654 |
+
order = stage_indices(train, stage, config.seed, epoch)
|
| 655 |
+
overflows = 0
|
| 656 |
+
stage_clock, stage_start_step = perf_counter(), stage_step
|
| 657 |
+
while stage_step < steps:
|
| 658 |
+
indices = order[offset:offset + update_size]
|
| 659 |
+
if prefetcher is not None:
|
| 660 |
+
following = offset + len(indices)
|
| 661 |
+
upcoming = (order[following:following + update_size] if following < len(order)
|
| 662 |
+
else stage_indices(train, stage, config.seed, epoch + 1)[:update_size])
|
| 663 |
+
prefetcher.submit_update(train, indices, config.micro_batch)
|
| 664 |
+
prefetcher.submit_update(train, upcoming, config.micro_batch)
|
| 665 |
+
update_loss = optimizer_update(model, collator, train, indices, config, optimizer, scaler, length)
|
| 666 |
+
if update_loss is None:
|
| 667 |
+
overflows += 1
|
| 668 |
+
if overflows > 8:
|
| 669 |
+
raise FloatingPointError("Repeated fp16 gradient overflow; resume from last.pt")
|
| 670 |
+
continue
|
| 671 |
+
overflows = 0
|
| 672 |
+
scheduler.step()
|
| 673 |
+
stage_step += 1
|
| 674 |
+
global_step += 1
|
| 675 |
+
offset += len(indices)
|
| 676 |
+
if offset == len(order):
|
| 677 |
+
epoch += 1
|
| 678 |
+
offset = 0
|
| 679 |
+
order = stage_indices(train, stage, config.seed, epoch)
|
| 680 |
+
record = {"stage": STAGES[stage], "stage_step": stage_step, "step": global_step,
|
| 681 |
+
"loss": update_loss, "examples": len(indices)}
|
| 682 |
+
history["completed_steps"] = global_step
|
| 683 |
+
history["updates"].append(record)
|
| 684 |
+
if stage_step % config.eval_every == 0 or stage_step == steps:
|
| 685 |
+
metrics = eval_model(model, tokenizer, dev, config)
|
| 686 |
+
record["dev"] = metrics
|
| 687 |
+
progress = {"stage": STAGES[stage], "step": global_step, "train_loss": update_loss, "dev_log_loss": metrics["soft_log_loss"]}
|
| 688 |
+
human = eval_model(model, tokenizer, human_dev, config, limit=config.human_eval_limit) if human_dev else None
|
| 689 |
+
if human is not None:
|
| 690 |
+
record["human_dev"] = human
|
| 691 |
+
progress.update(human_dev_log_loss=human["soft_log_loss"], human_dev_accuracy=human["hard_accuracy"])
|
| 692 |
+
progress["seconds_per_update"] = round((perf_counter() - stage_clock) / max(1, stage_step - stage_start_step), 3)
|
| 693 |
+
if device.type == "cuda":
|
| 694 |
+
progress["max_memory_gib"] = round(torch.cuda.max_memory_allocated(device) / 2**30, 2)
|
| 695 |
+
print(json.dumps(progress), flush=True)
|
| 696 |
+
if history["best_dev_log_loss"] is None or metrics["soft_log_loss"] < history["best_dev_log_loss"]:
|
| 697 |
+
history["best_dev_log_loss"] = metrics["soft_log_loss"]
|
| 698 |
+
history["best_step"] = global_step
|
| 699 |
+
best = {"format_version": 1, "tensors": tensor_state(model), "config": asdict(config),
|
| 700 |
+
"config_fingerprint": signature, "data_fingerprint": actual_fingerprint,
|
| 701 |
+
"step": global_step, "dev": metrics}
|
| 702 |
+
atomic_save(best, directory / "best.pt")
|
| 703 |
+
if human is not None and (history["best_human_dev_log_loss"] is None
|
| 704 |
+
or human["soft_log_loss"] < history["best_human_dev_log_loss"]):
|
| 705 |
+
history["best_human_dev_log_loss"] = human["soft_log_loss"]
|
| 706 |
+
history["best_human_step"] = global_step
|
| 707 |
+
best_human = {"format_version": 1, "tensors": tensor_state(model), "config": asdict(config),
|
| 708 |
+
"config_fingerprint": signature, "data_fingerprint": actual_fingerprint,
|
| 709 |
+
"step": global_step, "dev": metrics, "human_dev": human}
|
| 710 |
+
atomic_save(best_human, directory / "best_human.pt")
|
| 711 |
+
if config.snapshot_every and global_step % config.snapshot_every == 0:
|
| 712 |
+
# Same layout as best.pt, so any snapshot can be exported, and snapshots can be blended
|
| 713 |
+
atomic_save({"format_version": 1, "tensors": tensor_state(model), "config": asdict(config),
|
| 714 |
+
"config_fingerprint": signature, "data_fingerprint": actual_fingerprint,
|
| 715 |
+
"step": global_step}, directory / "snapshots" / f"update-{global_step:06d}.pt")
|
| 716 |
+
if stage_step % config.save_every == 0 or stage_step == steps:
|
| 717 |
+
checkpoint = {
|
| 718 |
+
"format_version": 1, "tensors": tensor_state(model), "config": asdict(config),
|
| 719 |
+
"config_fingerprint": signature, "data_fingerprint": actual_fingerprint,
|
| 720 |
+
"optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(),
|
| 721 |
+
"scaler": scaler.state_dict(), "stage": stage, "stage_step": stage_step,
|
| 722 |
+
"global_step": global_step, "epoch": epoch, "offset": offset,
|
| 723 |
+
"rng": capture_rng(), "device_type": device.type, "history": history, "best": best,
|
| 724 |
+
"best_human": best_human,
|
| 725 |
+
}
|
| 726 |
+
atomic_save(checkpoint, last_path)
|
| 727 |
+
if prefetcher is not None:
|
| 728 |
+
prefetcher.close()
|
| 729 |
+
return history
|
| 730 |
+
|
| 731 |
+
|
| 732 |
+
def export_model(model, tokenizer, config, destination):
|
| 733 |
+
"""Export only PEFT weights, float32 heads, temperatures, tokenizer and provenance."""
|
| 734 |
+
from peft import PeftModel
|
| 735 |
+
|
| 736 |
+
if not isinstance(model.backbone, PeftModel):
|
| 737 |
+
raise ValueError("Export requires a PEFT backbone, not full frozen model weights")
|
| 738 |
+
if config.head_width != model.head_width:
|
| 739 |
+
raise ValueError("Config head_width does not match the exported heads")
|
| 740 |
+
destination = Path(destination)
|
| 741 |
+
destination.mkdir(parents=True, exist_ok=True)
|
| 742 |
+
model.backbone.save_pretrained(destination / "adapter", safe_serialization=True, save_embedding_layers=False)
|
| 743 |
+
tokenizer.save_pretrained(destination)
|
| 744 |
+
atomic_save({"heads": {name: value.detach().float().cpu() for name, value in model.heads.state_dict().items()},
|
| 745 |
+
"temperatures": model.temperatures.detach().float().cpu(),
|
| 746 |
+
"ordinal_weight": model.ordinal_weight}, destination / "heads.pt")
|
| 747 |
+
dependencies = {}
|
| 748 |
+
for package in ("torch", "transformers", "peft", "accelerate", "bitsandbytes", "numpy"):
|
| 749 |
+
try:
|
| 750 |
+
dependencies[package] = version(package)
|
| 751 |
+
except PackageNotFoundError:
|
| 752 |
+
dependencies[package] = None
|
| 753 |
+
vision = getattr(model, "vision", None)
|
| 754 |
+
if vision is not None: # export format 2: the image processor and its token budget travel with the weights
|
| 755 |
+
vision.processor.save_pretrained(destination)
|
| 756 |
+
metadata = {"format_version": 2 if vision is not None else 1, "base_model": config.model_id, "base_revision": config.model_revision,
|
| 757 |
+
"head": model.head, "head_width": model.head_width, "head_last_token": model.last_token,
|
| 758 |
+
"brier_weight": model.brier_weight,
|
| 759 |
+
"max_length": config.max_length,
|
| 760 |
+
"dataset_fingerprint": config.dataset_fingerprint, "dependencies": dependencies,
|
| 761 |
+
"temperatures": dict(zip(PRIMITIVES, model.temperatures.tolist()))}
|
| 762 |
+
if vision is not None:
|
| 763 |
+
metadata["images"] = {"max_image_tokens": vision.max_image_tokens, "placeholder": IMAGE_PLACEHOLDER}
|
| 764 |
+
(destination / "config.json").write_text(json.dumps(asdict(config), indent=2, allow_nan=False) + "\n")
|
| 765 |
+
(destination / "metadata.json").write_text(json.dumps(metadata, indent=2, allow_nan=False) + "\n")
|
| 766 |
+
|
| 767 |
+
|
| 768 |
+
def reload_export(destination, quantize4bit=True):
|
| 769 |
+
"""Reload inference weights; saved config is available as model.training_config."""
|
| 770 |
+
from transformers import AutoTokenizer
|
| 771 |
+
|
| 772 |
+
destination = Path(destination)
|
| 773 |
+
config = TrainingConfig(**json.loads((destination / "config.json").read_text()))
|
| 774 |
+
config.quantize4bit = quantize4bit
|
| 775 |
+
processor = destination if (destination / "preprocessor_config.json").exists() else None
|
| 776 |
+
model, tokenizer = load_model(config, adapter_path=destination / "adapter", trainable=False, processor_source=processor)
|
| 777 |
+
tokenizer = AutoTokenizer.from_pretrained(destination)
|
| 778 |
+
payload = torch.load(destination / "heads.pt", map_location="cpu", weights_only=True)
|
| 779 |
+
model.heads.load_state_dict(payload["heads"])
|
| 780 |
+
model.temperatures.copy_(payload["temperatures"].to(model.temperatures.device))
|
| 781 |
+
model.ordinal_weight = payload["ordinal_weight"]
|
| 782 |
+
return model, tokenizer
|