thinkless 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thinkless/__init__.py +63 -0
- thinkless/__main__.py +3 -0
- thinkless/_hub.py +84 -0
- thinkless/_json.py +33 -0
- thinkless/_version.py +1 -0
- thinkless/bench/__init__.py +22 -0
- thinkless/bench/intents.py +406 -0
- thinkless/bench/intents_report.py +87 -0
- thinkless/bench/metrics.py +115 -0
- thinkless/bench/report.py +123 -0
- thinkless/bench/support.py +342 -0
- thinkless/cli/__init__.py +1 -0
- thinkless/cli/main.py +595 -0
- thinkless/confidence.py +62 -0
- thinkless/data/pricing.toml +56 -0
- thinkless/data/viewer.html +411 -0
- thinkless/decision.py +185 -0
- thinkless/demo/__init__.py +1 -0
- thinkless/demo/support/__init__.py +19 -0
- thinkless/demo/support/agent.py +379 -0
- thinkless/demo/support/data/calibration.jsonl +48 -0
- thinkless/demo/support/data/scenarios.jsonl +53 -0
- thinkless/demo/support/data/world.json +78 -0
- thinkless/demo/support/questions.py +118 -0
- thinkless/demo/support/stack.py +93 -0
- thinkless/demo/support/world.py +165 -0
- thinkless/engine.py +605 -0
- thinkless/errors.py +13 -0
- thinkless/llm/__init__.py +47 -0
- thinkless/llm/anthropic.py +126 -0
- thinkless/llm/base.py +82 -0
- thinkless/llm/factory.py +91 -0
- thinkless/llm/local.py +168 -0
- thinkless/llm/openai_compat.py +165 -0
- thinkless/llm/openrouter.py +78 -0
- thinkless/llm/scripted.py +75 -0
- thinkless/logs.py +68 -0
- thinkless/pricing.py +100 -0
- thinkless/providers/__init__.py +43 -0
- thinkless/providers/base.py +108 -0
- thinkless/providers/gliner.py +194 -0
- thinkless/providers/hf.py +161 -0
- thinkless/providers/laya.py +121 -0
- thinkless/providers/llm.py +305 -0
- thinkless/providers/rules.py +181 -0
- thinkless/providers/systemone.py +147 -0
- thinkless/providers/wire.py +108 -0
- thinkless/py.typed +0 -0
- thinkless/questions.py +246 -0
- thinkless/settings.py +93 -0
- thinkless/tracing/__init__.py +23 -0
- thinkless/tracing/console.py +169 -0
- thinkless/tracing/otel.py +139 -0
- thinkless/tracing/sinks.py +142 -0
- thinkless/tracing/span.py +98 -0
- thinkless/tracing/summary.py +143 -0
- thinkless/tracing/tracer.py +159 -0
- thinkless/tracing/viewer.py +87 -0
- thinkless-0.2.0.dist-info/METADATA +357 -0
- thinkless-0.2.0.dist-info/RECORD +63 -0
- thinkless-0.2.0.dist-info/WHEEL +4 -0
- thinkless-0.2.0.dist-info/entry_points.txt +2 -0
- thinkless-0.2.0.dist-info/licenses/LICENSE +202 -0
thinkless/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""ThinkLess: the decision plane for AI agents.
|
|
2
|
+
|
|
3
|
+
Rules and small calibrated models make the routine decisions of an agent, a
|
|
4
|
+
generative model is reserved for the steps that need thought, and every step
|
|
5
|
+
is traced with its latency, confidence, tokens and cost.
|
|
6
|
+
|
|
7
|
+
Quick start::
|
|
8
|
+
|
|
9
|
+
from thinkless import Choice, Engine
|
|
10
|
+
from thinkless.providers import GLiNER, LLMDecider, Rules
|
|
11
|
+
from thinkless.llm import TransformersLLM
|
|
12
|
+
|
|
13
|
+
llm = TransformersLLM("Qwen/Qwen3-1.7B")
|
|
14
|
+
engine = Engine([Rules(), GLiNER(), LLMDecider(llm)], llm=llm, threshold=0.8)
|
|
15
|
+
|
|
16
|
+
intent = engine.decide(
|
|
17
|
+
"I was charged twice for order #4471, please refund one of them.",
|
|
18
|
+
Choice("What does the customer want?", options={
|
|
19
|
+
"refund": "wants money back",
|
|
20
|
+
"order_status": "asks where an order is",
|
|
21
|
+
"other": "anything else",
|
|
22
|
+
}),
|
|
23
|
+
)
|
|
24
|
+
print(intent.value, intent.confidence, intent.provider)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from ._version import __version__
|
|
28
|
+
from .decision import Answer, Attempt, Decision, Plane, Status, Usage
|
|
29
|
+
from .engine import Engine, Run
|
|
30
|
+
from .errors import ConfigurationError, ThinkLessError
|
|
31
|
+
from .logs import configure_logging
|
|
32
|
+
from .questions import Choice, Extract, Kind, Question, Score, YesNo
|
|
33
|
+
from .settings import load_env
|
|
34
|
+
from .tracing import ConsoleSink, JSONLSink, MemorySink, Tracer, TraceSummary, summarize, tool
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Answer",
|
|
38
|
+
"Attempt",
|
|
39
|
+
"Choice",
|
|
40
|
+
"ConfigurationError",
|
|
41
|
+
"ConsoleSink",
|
|
42
|
+
"Decision",
|
|
43
|
+
"Engine",
|
|
44
|
+
"Extract",
|
|
45
|
+
"JSONLSink",
|
|
46
|
+
"Kind",
|
|
47
|
+
"MemorySink",
|
|
48
|
+
"Plane",
|
|
49
|
+
"Question",
|
|
50
|
+
"Run",
|
|
51
|
+
"Score",
|
|
52
|
+
"Status",
|
|
53
|
+
"ThinkLessError",
|
|
54
|
+
"TraceSummary",
|
|
55
|
+
"Tracer",
|
|
56
|
+
"Usage",
|
|
57
|
+
"YesNo",
|
|
58
|
+
"__version__",
|
|
59
|
+
"configure_logging",
|
|
60
|
+
"load_env",
|
|
61
|
+
"summarize",
|
|
62
|
+
"tool",
|
|
63
|
+
]
|
thinkless/__main__.py
ADDED
thinkless/_hub.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Model downloads from the Hugging Face Hub, made reliable on Windows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .logs import get_logger
|
|
10
|
+
|
|
11
|
+
__all__ = ["DEFAULT_CHECKPOINTS", "cached_checkpoints", "ensure_downloaded"]
|
|
12
|
+
|
|
13
|
+
logger = get_logger("hub")
|
|
14
|
+
|
|
15
|
+
# What `thinkless demo` and the default providers download on first use.
|
|
16
|
+
DEFAULT_CHECKPOINTS = {
|
|
17
|
+
"fastino/gliner2.5-base-v1": "GLiNER provider",
|
|
18
|
+
"convaiinnovations/laya": "Laya provider",
|
|
19
|
+
"Qwen/Qwen3-1.7B": "local LLM (--llm local)",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Weight formats ThinkLess never loads. Skipping them keeps the serial download
|
|
24
|
+
# as small as the one the model library would have made.
|
|
25
|
+
IGNORED = [
|
|
26
|
+
"*.onnx",
|
|
27
|
+
"onnx/*",
|
|
28
|
+
"*.msgpack",
|
|
29
|
+
"*.h5",
|
|
30
|
+
"*.ot",
|
|
31
|
+
"*.tflite",
|
|
32
|
+
"*.gguf",
|
|
33
|
+
"flax_model*",
|
|
34
|
+
"tf_model*",
|
|
35
|
+
"rust_model*",
|
|
36
|
+
"coreml/*",
|
|
37
|
+
"openvino/*",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_local_path(repo_id: str) -> bool:
|
|
42
|
+
return Path(repo_id).exists()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ensure_downloaded(repo_id: str) -> None:
|
|
46
|
+
"""Download ``repo_id`` into the Hugging Face cache before a library loads it.
|
|
47
|
+
|
|
48
|
+
On Windows the Hub client downloads files in parallel and can hit a race
|
|
49
|
+
when it creates cache symlinks (``WinError 1314``). Downloading serially
|
|
50
|
+
first avoids it; the model library then finds everything in the cache.
|
|
51
|
+
Elsewhere this is a no-op and the library downloads as usual. Local paths
|
|
52
|
+
and offline mode (``HF_HUB_OFFLINE=1``) are left alone.
|
|
53
|
+
"""
|
|
54
|
+
if platform.system() != "Windows" or _is_local_path(repo_id):
|
|
55
|
+
return
|
|
56
|
+
if os.environ.get("HF_HUB_OFFLINE", "").strip() in ("1", "true", "True"):
|
|
57
|
+
return
|
|
58
|
+
try:
|
|
59
|
+
from huggingface_hub import snapshot_download
|
|
60
|
+
except ImportError: # pragma: no cover - installed with every local extra
|
|
61
|
+
return
|
|
62
|
+
try:
|
|
63
|
+
snapshot_download(repo_id, max_workers=1, ignore_patterns=IGNORED)
|
|
64
|
+
except Exception as exc: # the library's own loader reports the real error
|
|
65
|
+
logger.warning(
|
|
66
|
+
"pre-download of %s failed (%s); falling back to the library loader", repo_id, exc
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cached_checkpoints() -> dict[str, float | None]:
|
|
71
|
+
"""Size in GB of each default checkpoint in the local cache, or ``None`` if absent."""
|
|
72
|
+
sizes: dict[str, float | None] = dict.fromkeys(DEFAULT_CHECKPOINTS)
|
|
73
|
+
try:
|
|
74
|
+
from huggingface_hub import scan_cache_dir
|
|
75
|
+
except ImportError:
|
|
76
|
+
return sizes
|
|
77
|
+
try:
|
|
78
|
+
info = scan_cache_dir()
|
|
79
|
+
except Exception:
|
|
80
|
+
return sizes
|
|
81
|
+
for repo in info.repos:
|
|
82
|
+
if repo.repo_id in sizes and repo.repo_type == "model":
|
|
83
|
+
sizes[repo.repo_id] = round(repo.size_on_disk / 1e9, 2)
|
|
84
|
+
return sizes
|
thinkless/_json.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""JSON-safe conversion used by traces and reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
__all__ = ["jsonable"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def jsonable(value: Any, *, depth: int = 0) -> Any:
|
|
16
|
+
"""Best-effort conversion of arbitrary values into JSON-safe data."""
|
|
17
|
+
if depth > 8:
|
|
18
|
+
return repr(value)
|
|
19
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
20
|
+
return value
|
|
21
|
+
if isinstance(value, enum.Enum):
|
|
22
|
+
return value.value
|
|
23
|
+
if isinstance(value, BaseModel):
|
|
24
|
+
return jsonable(value.model_dump(mode="json"), depth=depth + 1)
|
|
25
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
26
|
+
return jsonable(dataclasses.asdict(value), depth=depth + 1)
|
|
27
|
+
if isinstance(value, dict):
|
|
28
|
+
return {str(k): jsonable(v, depth=depth + 1) for k, v in value.items()}
|
|
29
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
30
|
+
return [jsonable(v, depth=depth + 1) for v in value]
|
|
31
|
+
if isinstance(value, Path):
|
|
32
|
+
return str(value)
|
|
33
|
+
return repr(value)
|
thinkless/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Benchmarks and calibration."""
|
|
2
|
+
|
|
3
|
+
from .metrics import (
|
|
4
|
+
ThresholdPoint,
|
|
5
|
+
expected_calibration_error,
|
|
6
|
+
percentile,
|
|
7
|
+
recommend_threshold,
|
|
8
|
+
threshold_sweep,
|
|
9
|
+
)
|
|
10
|
+
from .support import ModeReport, SupportBenchmark, TicketResult, run_support_benchmark
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ModeReport",
|
|
14
|
+
"SupportBenchmark",
|
|
15
|
+
"ThresholdPoint",
|
|
16
|
+
"TicketResult",
|
|
17
|
+
"expected_calibration_error",
|
|
18
|
+
"percentile",
|
|
19
|
+
"recommend_threshold",
|
|
20
|
+
"run_support_benchmark",
|
|
21
|
+
"threshold_sweep",
|
|
22
|
+
]
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Intent classification benchmark on public datasets.
|
|
2
|
+
|
|
3
|
+
Measures each provider's accuracy, calibration and latency on one ``Choice``
|
|
4
|
+
question, then simulates the cascade: at every threshold, how often the small
|
|
5
|
+
models answer on their own, how accurate the combined system is, and how many
|
|
6
|
+
calls reach the LLM.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import random
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable, Sequence
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
from pydantic import BaseModel, Field
|
|
22
|
+
|
|
23
|
+
from .._version import __version__
|
|
24
|
+
from ..engine import Engine
|
|
25
|
+
from ..providers.base import DecisionProvider
|
|
26
|
+
from ..questions import Choice, YesNo
|
|
27
|
+
from ..tracing import Tracer
|
|
28
|
+
from .metrics import (
|
|
29
|
+
ThresholdPoint,
|
|
30
|
+
expected_calibration_error,
|
|
31
|
+
percentile,
|
|
32
|
+
recommend_threshold,
|
|
33
|
+
threshold_sweep,
|
|
34
|
+
)
|
|
35
|
+
from .support import environment_info
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"DATASETS",
|
|
39
|
+
"CascadePoint",
|
|
40
|
+
"ChoiceEvaluation",
|
|
41
|
+
"IntentsBenchmark",
|
|
42
|
+
"ProviderEval",
|
|
43
|
+
"build_provider",
|
|
44
|
+
"evaluate_choice",
|
|
45
|
+
"evaluate_question",
|
|
46
|
+
"load_dataset_rows",
|
|
47
|
+
"run_intents_benchmark",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
BANKING77_TEST = "https://raw.githubusercontent.com/PolyAI-LDN/task-specific-datasets/master/banking_data/test.csv"
|
|
51
|
+
|
|
52
|
+
DATASETS: dict[str, dict[str, Any]] = {
|
|
53
|
+
"banking77": {
|
|
54
|
+
"title": "Banking77 (PolyAI), 77 banking intents",
|
|
55
|
+
"instructions": "What is the customer's banking request about?",
|
|
56
|
+
"source": "https://github.com/PolyAI-LDN/task-specific-datasets",
|
|
57
|
+
},
|
|
58
|
+
"clinc150": {
|
|
59
|
+
"title": "CLINC150 plus (clinc/clinc_oos), 150 intents and out of scope",
|
|
60
|
+
"instructions": "What does the user want?",
|
|
61
|
+
"source": "https://huggingface.co/datasets/clinc/clinc_oos",
|
|
62
|
+
"repo": "clinc/clinc_oos",
|
|
63
|
+
"config": "plus",
|
|
64
|
+
"label": "intent",
|
|
65
|
+
},
|
|
66
|
+
"emotion": {
|
|
67
|
+
"title": "Emotion (dair-ai/emotion), 6 emotions",
|
|
68
|
+
"instructions": "Which emotion does the text express?",
|
|
69
|
+
"source": "https://huggingface.co/datasets/dair-ai/emotion",
|
|
70
|
+
"repo": "dair-ai/emotion",
|
|
71
|
+
"config": "split",
|
|
72
|
+
"label": "label",
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _humanize(label: str) -> str:
|
|
78
|
+
return "out of scope" if label == "oos" else label.replace("_", " ")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cache_dir() -> Path:
|
|
82
|
+
path = Path.home() / ".cache" / "thinkless" / "datasets"
|
|
83
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
return path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_dataset_rows(name: str) -> list[dict[str, str]]:
|
|
88
|
+
"""Test split of a supported dataset as ``{"text", "label"}`` rows with readable labels."""
|
|
89
|
+
if name not in DATASETS:
|
|
90
|
+
raise ValueError(f"unknown dataset {name!r}; choose one of {', '.join(DATASETS)}")
|
|
91
|
+
if name == "banking77":
|
|
92
|
+
cached = _cache_dir() / "banking77-test.csv"
|
|
93
|
+
if not cached.exists():
|
|
94
|
+
response = httpx.get(BANKING77_TEST, timeout=60.0, follow_redirects=True)
|
|
95
|
+
response.raise_for_status()
|
|
96
|
+
cached.write_text(response.text, encoding="utf-8")
|
|
97
|
+
reader = csv.DictReader(io.StringIO(cached.read_text(encoding="utf-8")))
|
|
98
|
+
return [{"text": row["text"], "label": _humanize(row["category"])} for row in reader]
|
|
99
|
+
try:
|
|
100
|
+
from datasets import load_dataset
|
|
101
|
+
except ImportError as exc: # pragma: no cover - depends on the extra
|
|
102
|
+
raise ImportError(
|
|
103
|
+
f'The {name} dataset needs the bench extra: pip install "thinkless[bench]"'
|
|
104
|
+
) from exc
|
|
105
|
+
spec = DATASETS[name]
|
|
106
|
+
dataset = load_dataset(spec["repo"], spec["config"], split="test")
|
|
107
|
+
names = dataset.features[spec["label"]].names
|
|
108
|
+
return [{"text": row["text"], "label": _humanize(names[row[spec["label"]]])} for row in dataset]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def build_provider(
|
|
112
|
+
name: str, *, llm_spec: str = "local", device: str = "auto", reasoning: str | None = None
|
|
113
|
+
) -> DecisionProvider:
|
|
114
|
+
"""A provider by short name: ``gliner``, ``laya``, ``llm`` or ``jev``."""
|
|
115
|
+
if name == "gliner":
|
|
116
|
+
from ..providers.gliner import GLiNER
|
|
117
|
+
|
|
118
|
+
return GLiNER(device=device)
|
|
119
|
+
if name == "laya":
|
|
120
|
+
from ..providers.laya import Laya
|
|
121
|
+
|
|
122
|
+
return Laya(device=device)
|
|
123
|
+
if name == "llm":
|
|
124
|
+
from ..llm import from_spec
|
|
125
|
+
from ..providers import LLMDecider
|
|
126
|
+
|
|
127
|
+
return LLMDecider(from_spec(llm_spec, device=device, reasoning=reasoning))
|
|
128
|
+
if name == "jev":
|
|
129
|
+
from ..providers import SystemOne
|
|
130
|
+
|
|
131
|
+
return SystemOne.jev()
|
|
132
|
+
raise ValueError(f"unknown provider {name!r}; choose gliner, laya, llm or jev")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ChoiceEvaluation(BaseModel):
|
|
136
|
+
"""One provider's answers to one question over a labeled set."""
|
|
137
|
+
|
|
138
|
+
provider: str
|
|
139
|
+
predictions: list[Any]
|
|
140
|
+
confidences: list[float | None]
|
|
141
|
+
correct: list[bool]
|
|
142
|
+
latencies_ms: list[float]
|
|
143
|
+
input_tokens: list[int]
|
|
144
|
+
costs_usd: list[float] = Field(default_factory=list)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def accuracy(self) -> float:
|
|
148
|
+
return sum(self.correct) / len(self.correct) if self.correct else 0.0
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def latency_p50_ms(self) -> float:
|
|
152
|
+
return percentile(self.latencies_ms, 50)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def evaluate_question(
|
|
156
|
+
provider: DecisionProvider,
|
|
157
|
+
question: Choice | YesNo,
|
|
158
|
+
rows: Sequence[dict[str, Any]],
|
|
159
|
+
*,
|
|
160
|
+
text_field: str = "text",
|
|
161
|
+
label_field: str = "label",
|
|
162
|
+
progress: Callable[[int, int], None] | None = None,
|
|
163
|
+
) -> ChoiceEvaluation:
|
|
164
|
+
"""Ask ``question`` about every row and score the answer against the row's label.
|
|
165
|
+
|
|
166
|
+
For a ``Choice`` the label is an option; for a ``YesNo`` it is a boolean.
|
|
167
|
+
"""
|
|
168
|
+
provider.warmup()
|
|
169
|
+
engine = Engine([provider], threshold=0.0, tracer=Tracer())
|
|
170
|
+
predictions: list[Any] = []
|
|
171
|
+
confidences: list[float | None] = []
|
|
172
|
+
correct: list[bool] = []
|
|
173
|
+
latencies: list[float] = []
|
|
174
|
+
tokens: list[int] = []
|
|
175
|
+
costs: list[float] = []
|
|
176
|
+
for index, row in enumerate(rows):
|
|
177
|
+
truth = row[label_field]
|
|
178
|
+
if isinstance(question, YesNo):
|
|
179
|
+
truth = (
|
|
180
|
+
truth
|
|
181
|
+
if isinstance(truth, bool)
|
|
182
|
+
else str(truth).strip().lower() in ("true", "yes", "1")
|
|
183
|
+
)
|
|
184
|
+
started = time.perf_counter()
|
|
185
|
+
decision = engine.decide(row[text_field], question)
|
|
186
|
+
latencies.append((time.perf_counter() - started) * 1000.0)
|
|
187
|
+
predictions.append(decision.value)
|
|
188
|
+
confidences.append(decision.confidence)
|
|
189
|
+
correct.append(decision.value == truth)
|
|
190
|
+
tokens.append(decision.usage.input_tokens)
|
|
191
|
+
costs.append(decision.cost_usd)
|
|
192
|
+
if progress is not None:
|
|
193
|
+
progress(index + 1, len(rows))
|
|
194
|
+
return ChoiceEvaluation(
|
|
195
|
+
provider=provider.name,
|
|
196
|
+
predictions=predictions,
|
|
197
|
+
confidences=confidences,
|
|
198
|
+
correct=correct,
|
|
199
|
+
latencies_ms=latencies,
|
|
200
|
+
input_tokens=tokens,
|
|
201
|
+
costs_usd=costs,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def evaluate_choice(
|
|
206
|
+
provider: DecisionProvider,
|
|
207
|
+
question: Choice,
|
|
208
|
+
rows: Sequence[dict[str, Any]],
|
|
209
|
+
*,
|
|
210
|
+
progress: Callable[[int, int], None] | None = None,
|
|
211
|
+
) -> ChoiceEvaluation:
|
|
212
|
+
"""Shorthand for :func:`evaluate_question` on ``{"text", "label"}`` rows."""
|
|
213
|
+
return evaluate_question(provider, question, rows, progress=progress)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class ProviderEval(BaseModel):
|
|
217
|
+
provider: str
|
|
218
|
+
accuracy: float
|
|
219
|
+
ece: float | None
|
|
220
|
+
latency_p50_ms: float
|
|
221
|
+
latency_p95_ms: float
|
|
222
|
+
mean_input_tokens: float
|
|
223
|
+
cost_per_1k: float = 0.0
|
|
224
|
+
abstained: int
|
|
225
|
+
sweep: list[ThresholdPoint] = Field(default_factory=list)
|
|
226
|
+
recommended: ThresholdPoint | None = None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class CascadePoint(BaseModel):
|
|
230
|
+
"""The simulated cascade (small models in order, then the LLM) at one threshold."""
|
|
231
|
+
|
|
232
|
+
threshold: float
|
|
233
|
+
accuracy: float
|
|
234
|
+
llm_share: float
|
|
235
|
+
mean_latency_ms: float
|
|
236
|
+
cost_per_1k: float = 0.0
|
|
237
|
+
answered_by: dict[str, float]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class IntentsBenchmark(BaseModel):
|
|
241
|
+
thinkless_version: str = __version__
|
|
242
|
+
created_at: str
|
|
243
|
+
dataset: str
|
|
244
|
+
dataset_title: str
|
|
245
|
+
source: str
|
|
246
|
+
examples: int
|
|
247
|
+
labels: int
|
|
248
|
+
seed: int
|
|
249
|
+
llm: str
|
|
250
|
+
target_accuracy: float
|
|
251
|
+
environment: dict[str, Any]
|
|
252
|
+
providers: dict[str, ProviderEval]
|
|
253
|
+
cascade: list[CascadePoint]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _cost_at(evaluation: ChoiceEvaluation, index: int) -> float:
|
|
257
|
+
return evaluation.costs_usd[index] if index < len(evaluation.costs_usd) else 0.0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _cascade(
|
|
261
|
+
evaluations: dict[str, ChoiceEvaluation], order: Sequence[str], fallback: str | None
|
|
262
|
+
) -> list[CascadePoint]:
|
|
263
|
+
small = [p for p in order if p != fallback]
|
|
264
|
+
n = len(next(iter(evaluations.values())).correct)
|
|
265
|
+
points = []
|
|
266
|
+
for threshold in [round(x * 0.05, 2) for x in range(0, 20)] + [0.97, 0.99, 1.01]:
|
|
267
|
+
hits = 0
|
|
268
|
+
llm_calls = 0
|
|
269
|
+
latency = 0.0
|
|
270
|
+
cost = 0.0
|
|
271
|
+
answered: dict[str, int] = dict.fromkeys([*small, fallback or "unresolved"], 0)
|
|
272
|
+
for i in range(n):
|
|
273
|
+
chosen = None
|
|
274
|
+
for name in small:
|
|
275
|
+
latency += evaluations[name].latencies_ms[i]
|
|
276
|
+
cost += _cost_at(evaluations[name], i)
|
|
277
|
+
confidence = evaluations[name].confidences[i]
|
|
278
|
+
if confidence is not None and confidence >= threshold:
|
|
279
|
+
chosen = name
|
|
280
|
+
break
|
|
281
|
+
if chosen is None and fallback is not None:
|
|
282
|
+
chosen = fallback
|
|
283
|
+
llm_calls += 1
|
|
284
|
+
latency += evaluations[fallback].latencies_ms[i]
|
|
285
|
+
cost += _cost_at(evaluations[fallback], i)
|
|
286
|
+
if chosen is None:
|
|
287
|
+
answered["unresolved"] += 1
|
|
288
|
+
continue
|
|
289
|
+
answered[chosen] += 1
|
|
290
|
+
hits += int(evaluations[chosen].correct[i])
|
|
291
|
+
points.append(
|
|
292
|
+
CascadePoint(
|
|
293
|
+
threshold=threshold,
|
|
294
|
+
accuracy=round(hits / n, 4),
|
|
295
|
+
llm_share=round(llm_calls / n, 4),
|
|
296
|
+
mean_latency_ms=round(latency / n, 2),
|
|
297
|
+
cost_per_1k=round(cost / n * 1000, 5),
|
|
298
|
+
answered_by={k: round(v / n, 4) for k, v in answered.items()},
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
return points
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _resolved_llm(spec: str) -> str:
|
|
305
|
+
from ..llm.factory import DEFAULT_LOCAL_MODEL
|
|
306
|
+
|
|
307
|
+
backend, _, model = spec.partition(":")
|
|
308
|
+
if backend == "local" and not model:
|
|
309
|
+
return f"local:{DEFAULT_LOCAL_MODEL}"
|
|
310
|
+
return spec
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def run_intents_benchmark(
|
|
314
|
+
dataset: str,
|
|
315
|
+
*,
|
|
316
|
+
providers: Sequence[str] = ("gliner", "laya", "llm"),
|
|
317
|
+
llm_spec: str = "local",
|
|
318
|
+
device: str = "auto",
|
|
319
|
+
reasoning: str | None = None,
|
|
320
|
+
limit: int = 500,
|
|
321
|
+
seed: int = 13,
|
|
322
|
+
target_accuracy: float = 0.95,
|
|
323
|
+
output_dir: str | Path | None = None,
|
|
324
|
+
progress: Callable[[str], None] | None = None,
|
|
325
|
+
) -> IntentsBenchmark:
|
|
326
|
+
"""Evaluate providers on a dataset sample and simulate the cascade.
|
|
327
|
+
|
|
328
|
+
The sample is drawn with a fixed seed so runs are comparable. When ``llm``
|
|
329
|
+
is among the providers it acts as the cascade's fallback.
|
|
330
|
+
"""
|
|
331
|
+
say = progress or (lambda message: None)
|
|
332
|
+
rows = load_dataset_rows(dataset)
|
|
333
|
+
rng = random.Random(seed)
|
|
334
|
+
sample = rng.sample(rows, min(limit, len(rows)))
|
|
335
|
+
labels = sorted({row["label"] for row in rows})
|
|
336
|
+
question = Choice(DATASETS[dataset]["instructions"], options=labels, name="label")
|
|
337
|
+
say(f"{dataset}: {len(sample)} of {len(rows)} test examples, {len(labels)} labels")
|
|
338
|
+
|
|
339
|
+
evaluations: dict[str, ChoiceEvaluation] = {}
|
|
340
|
+
reports: dict[str, ProviderEval] = {}
|
|
341
|
+
for name in providers:
|
|
342
|
+
provider = build_provider(name, llm_spec=llm_spec, device=device, reasoning=reasoning)
|
|
343
|
+
started = time.perf_counter()
|
|
344
|
+
|
|
345
|
+
def tick(done: int, total: int, name: str = name, started: float = started) -> None:
|
|
346
|
+
if done % 50 == 0 or done == total:
|
|
347
|
+
say(f"{name}: {done}/{total} {time.perf_counter() - started:.0f}s")
|
|
348
|
+
|
|
349
|
+
evaluation = evaluate_choice(provider, question, sample, progress=tick)
|
|
350
|
+
evaluations[name] = evaluation
|
|
351
|
+
calibrated = all(c is not None for c in evaluation.confidences)
|
|
352
|
+
confidences = [c if c is not None else 1.0 for c in evaluation.confidences]
|
|
353
|
+
sweep = threshold_sweep(confidences, evaluation.correct) if calibrated else []
|
|
354
|
+
reports[name] = ProviderEval(
|
|
355
|
+
provider=name,
|
|
356
|
+
accuracy=round(evaluation.accuracy, 4),
|
|
357
|
+
ece=round(expected_calibration_error(confidences, evaluation.correct), 4)
|
|
358
|
+
if calibrated
|
|
359
|
+
else None,
|
|
360
|
+
latency_p50_ms=round(evaluation.latency_p50_ms, 2),
|
|
361
|
+
latency_p95_ms=round(percentile(evaluation.latencies_ms, 95), 2),
|
|
362
|
+
mean_input_tokens=round(sum(evaluation.input_tokens) / len(sample), 1),
|
|
363
|
+
cost_per_1k=round(sum(evaluation.costs_usd) / len(sample) * 1000, 5),
|
|
364
|
+
abstained=sum(p is None for p in evaluation.predictions),
|
|
365
|
+
sweep=sweep,
|
|
366
|
+
recommended=recommend_threshold(sweep, target_accuracy) if sweep else None,
|
|
367
|
+
)
|
|
368
|
+
provider.close()
|
|
369
|
+
|
|
370
|
+
fallback = "llm" if "llm" in providers else None
|
|
371
|
+
result = IntentsBenchmark(
|
|
372
|
+
created_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
373
|
+
dataset=dataset,
|
|
374
|
+
dataset_title=DATASETS[dataset]["title"],
|
|
375
|
+
source=DATASETS[dataset]["source"],
|
|
376
|
+
examples=len(sample),
|
|
377
|
+
labels=len(labels),
|
|
378
|
+
seed=seed,
|
|
379
|
+
llm=(
|
|
380
|
+
_resolved_llm(llm_spec)
|
|
381
|
+
+ ("" if reasoning in (None, "default") else f" (reasoning {reasoning})")
|
|
382
|
+
)
|
|
383
|
+
if "llm" in providers
|
|
384
|
+
else "not used",
|
|
385
|
+
target_accuracy=target_accuracy,
|
|
386
|
+
environment=environment_info(),
|
|
387
|
+
providers=reports,
|
|
388
|
+
cascade=_cascade(evaluations, list(providers), fallback),
|
|
389
|
+
)
|
|
390
|
+
if output_dir is not None:
|
|
391
|
+
out = Path(output_dir)
|
|
392
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
393
|
+
(out / "results.json").write_text(
|
|
394
|
+
json.dumps(result.model_dump(mode="json"), indent=2), encoding="utf-8"
|
|
395
|
+
)
|
|
396
|
+
with (out / "predictions.jsonl").open("w", encoding="utf-8") as handle:
|
|
397
|
+
for i, row in enumerate(sample):
|
|
398
|
+
record: dict[str, Any] = {"text": row["text"], "label": row["label"]}
|
|
399
|
+
for name, evaluation in evaluations.items():
|
|
400
|
+
record[name] = {
|
|
401
|
+
"prediction": evaluation.predictions[i],
|
|
402
|
+
"confidence": evaluation.confidences[i],
|
|
403
|
+
"latency_ms": round(evaluation.latencies_ms[i], 2),
|
|
404
|
+
}
|
|
405
|
+
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
406
|
+
return result
|