arcus-cli 0.1.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.
- arcus/__init__.py +0 -0
- arcus/adapters/__init__.py +0 -0
- arcus/adapters/arc_adapter.py +68 -0
- arcus/cache/__init__.py +0 -0
- arcus/cache/benchmark.py +159 -0
- arcus/cache/semantic_cache.py +168 -0
- arcus/cli.py +335 -0
- arcus/config.py +46 -0
- arcus/embeddings.py +34 -0
- arcus/eval/__init__.py +0 -0
- arcus/eval/offline.py +219 -0
- arcus/eval/regret.py +96 -0
- arcus/quality/__init__.py +0 -0
- arcus/quality/gate.py +225 -0
- arcus/routing/__init__.py +0 -0
- arcus/routing/bandit.py +226 -0
- arcus/routing/context.py +218 -0
- arcus/routing/reward.py +91 -0
- arcus/routing/warm_start.py +35 -0
- arcus/storage/__init__.py +0 -0
- arcus/storage/db.py +117 -0
- arcus/storage/stats.py +51 -0
- arcus_cli-0.1.0.dist-info/METADATA +308 -0
- arcus_cli-0.1.0.dist-info/RECORD +27 -0
- arcus_cli-0.1.0.dist-info/WHEEL +4 -0
- arcus_cli-0.1.0.dist-info/entry_points.txt +2 -0
- arcus_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
arcus/cli.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import threading
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from sqlmodel import Session, select
|
|
9
|
+
|
|
10
|
+
from arcus.adapters.arc_adapter import ArcAdapter, ArcModel
|
|
11
|
+
from arcus.cache.semantic_cache import lookup as cache_lookup
|
|
12
|
+
from arcus.cache.semantic_cache import store as cache_store
|
|
13
|
+
from arcus.config import ArcusConfig, BanditAlgorithm, config_path, load_config, save_config
|
|
14
|
+
from arcus.embeddings import get_embedding_model
|
|
15
|
+
from arcus.quality.gate import call_with_quality_gate
|
|
16
|
+
from arcus.routing.bandit import (
|
|
17
|
+
Bandit,
|
|
18
|
+
ContextualBandit,
|
|
19
|
+
EpsilonGreedyBandit,
|
|
20
|
+
RandomBandit,
|
|
21
|
+
ThompsonSamplingBandit,
|
|
22
|
+
UCB1Bandit,
|
|
23
|
+
)
|
|
24
|
+
from arcus.routing.context import Context, classify
|
|
25
|
+
from arcus.routing.warm_start import replay_history
|
|
26
|
+
from arcus.storage.db import RequestLog, get_engine, log_request
|
|
27
|
+
from arcus.storage.stats import aggregate_by_arm_and_mode
|
|
28
|
+
|
|
29
|
+
ARMS = [m.value for m in ArcModel]
|
|
30
|
+
|
|
31
|
+
_ALGORITHM_FACTORIES: dict[BanditAlgorithm, "type[Bandit]"] = {
|
|
32
|
+
"epsilon_greedy": EpsilonGreedyBandit,
|
|
33
|
+
"ucb1": UCB1Bandit,
|
|
34
|
+
"thompson": ThompsonSamplingBandit,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: list[str] | None = None) -> None:
|
|
39
|
+
"""Entry point for the `arcus` command. Dispatches by hand on argv[0]
|
|
40
|
+
rather than using typer's subcommand machinery. the whole point is for
|
|
41
|
+
`arcus "some question"` to just work with no subcommand at all, and a
|
|
42
|
+
real subcommand parser (click underneath typer) fights that: it wants
|
|
43
|
+
to treat the prompt text itself as an unrecognized command. `stats` is
|
|
44
|
+
the one reserved word, everything else is prompt text.
|
|
45
|
+
"""
|
|
46
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
47
|
+
|
|
48
|
+
if argv and argv[0] == "stats":
|
|
49
|
+
run_stats()
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if argv and argv[0] == "chat":
|
|
53
|
+
random_mode = "--random" in argv
|
|
54
|
+
run_chat(random_mode=random_mode)
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
random_mode = "--random" in argv
|
|
58
|
+
argv = [arg for arg in argv if arg != "--random"]
|
|
59
|
+
|
|
60
|
+
prompt = _build_prompt(argv)
|
|
61
|
+
if not prompt:
|
|
62
|
+
_print_usage()
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
run_ask(prompt, random_mode=random_mode)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_prompt(args: list[str]) -> str:
|
|
69
|
+
# if stdin isn't a tty, something got piped in (a traceback, a test
|
|
70
|
+
# failure, whatever), treat that as the main
|
|
71
|
+
# content and let any positional args add an explicit instruction
|
|
72
|
+
# on top of it, e.g. `python broken.py 2>&1 | arcus "why is this
|
|
73
|
+
# failing"`.
|
|
74
|
+
piped = sys.stdin.read().strip() if not sys.stdin.isatty() else ""
|
|
75
|
+
positional = " ".join(args).strip()
|
|
76
|
+
|
|
77
|
+
if piped and positional:
|
|
78
|
+
return f"{piped}\n\n{positional}"
|
|
79
|
+
return piped or positional
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _print_usage() -> None:
|
|
83
|
+
Console().print('usage: arcus "<question>" or arcus chat or arcus stats')
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ensure_config() -> ArcusConfig:
|
|
87
|
+
try:
|
|
88
|
+
return load_config()
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
return _run_setup_wizard()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _run_setup_wizard() -> ArcusConfig:
|
|
94
|
+
console = Console()
|
|
95
|
+
console.print("[bold]no ARC key found, let's get you set up.[/bold]")
|
|
96
|
+
console.print(
|
|
97
|
+
"grab one from llm.arc.vt.edu under "
|
|
98
|
+
"User profile > Settings > Account > API keys.\n"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
api_key = typer.prompt("ARC API key", hide_input=True)
|
|
102
|
+
|
|
103
|
+
console.print("checking that key works...")
|
|
104
|
+
try:
|
|
105
|
+
ArcAdapter(api_key=api_key).chat(
|
|
106
|
+
ArcModel.GPT_OSS_120B,
|
|
107
|
+
[{"role": "user", "content": "reply with just the word ok"}],
|
|
108
|
+
max_tokens=5,
|
|
109
|
+
)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
console.print(f"[red]that key didn't work:[/red] {e}")
|
|
112
|
+
raise SystemExit(1) from e
|
|
113
|
+
|
|
114
|
+
config = ArcusConfig(arc_api_key=api_key)
|
|
115
|
+
save_config(config)
|
|
116
|
+
console.print(f"[green]saved to {config_path()}[/green]\n")
|
|
117
|
+
return config
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_ask(prompt: str, random_mode: bool = False) -> None:
|
|
121
|
+
console = Console()
|
|
122
|
+
|
|
123
|
+
# the embedding model backs both the context classifier's fallback
|
|
124
|
+
# path and the semantic cache lookup below, and loading it costs a
|
|
125
|
+
# few seconds the first time any process touches it. kick that load
|
|
126
|
+
# off now, in the background, so it's warm (or at least warming) by
|
|
127
|
+
# the time either one actually calls embed(), instead of eating that
|
|
128
|
+
# cost inline and in serial with everything else.
|
|
129
|
+
#
|
|
130
|
+
# this has to start AFTER building the ArcAdapter, not before.
|
|
131
|
+
# constructing the openai client is the first time this process
|
|
132
|
+
# touches httpx internals, and openai does that lazily, on client
|
|
133
|
+
# construction, not on import. starting the embedding thread first
|
|
134
|
+
# let it race that first-time httpx touch against sentence-transformers'
|
|
135
|
+
# own (torch/huggingface_hub) import chain, which also reaches into
|
|
136
|
+
# httpx, and that produced a real, reliably reproducible crash:
|
|
137
|
+
# "partially initialized module 'httpx' ... circular import". building
|
|
138
|
+
# the adapter first means the main thread finishes touching httpx
|
|
139
|
+
# before any second thread gets a chance to.
|
|
140
|
+
config = _ensure_config()
|
|
141
|
+
engine = get_engine()
|
|
142
|
+
adapter = ArcAdapter(api_key=config.arc_api_key)
|
|
143
|
+
|
|
144
|
+
threading.Thread(target=get_embedding_model, daemon=True).start()
|
|
145
|
+
|
|
146
|
+
context = classify(prompt)
|
|
147
|
+
mode = "random" if random_mode else "bandit"
|
|
148
|
+
|
|
149
|
+
cached = cache_lookup(prompt, engine=engine)
|
|
150
|
+
if cached.hit:
|
|
151
|
+
console.print(cached.response)
|
|
152
|
+
log_request(
|
|
153
|
+
prompt=prompt,
|
|
154
|
+
task_type=context.task_type.value,
|
|
155
|
+
length_bucket=context.length_bucket.value,
|
|
156
|
+
model=cached.model,
|
|
157
|
+
cache_hit=True,
|
|
158
|
+
quality_passed=True,
|
|
159
|
+
engine=engine,
|
|
160
|
+
)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
algorithm_factory = RandomBandit if random_mode else _ALGORITHM_FACTORIES[config.bandit_algorithm]
|
|
164
|
+
bandit = ContextualBandit(lambda: algorithm_factory(ARMS), arms=ARMS)
|
|
165
|
+
# rebuild what this bandit already learned from past requests in this
|
|
166
|
+
# same mode, otherwise every invocation starts back at zero since
|
|
167
|
+
# there's no daemon holding it in memory between runs.
|
|
168
|
+
replay_history(bandit, engine, mode=mode)
|
|
169
|
+
|
|
170
|
+
content, model_used, passed = _route_and_answer(
|
|
171
|
+
adapter, bandit, context, prompt, [{"role": "user", "content": prompt}], mode, engine
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
console.print(content if content else "[red]no usable response from any model.[/red]")
|
|
175
|
+
|
|
176
|
+
if passed and content:
|
|
177
|
+
cache_store(prompt, content, model=model_used, engine=engine)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _route_and_answer(
|
|
181
|
+
adapter: ArcAdapter,
|
|
182
|
+
bandit: ContextualBandit,
|
|
183
|
+
context: Context,
|
|
184
|
+
prompt: str,
|
|
185
|
+
messages: list[dict],
|
|
186
|
+
mode: str,
|
|
187
|
+
engine,
|
|
188
|
+
conversation_id: str | None = None,
|
|
189
|
+
turn_index: int | None = None,
|
|
190
|
+
) -> tuple[str | None, str, bool]:
|
|
191
|
+
"""Runs one turn through the quality gate, logs every attempt, and
|
|
192
|
+
returns (content, model_used, passed). content can be non-None even
|
|
193
|
+
when passed is False (the last attempt still produced text, it just
|
|
194
|
+
didn't clear the gate); content is None only when every arm errored
|
|
195
|
+
out with nothing to show at all.
|
|
196
|
+
"""
|
|
197
|
+
outcome = call_with_quality_gate(adapter, bandit, context.key, messages)
|
|
198
|
+
|
|
199
|
+
for attempt in outcome.attempts:
|
|
200
|
+
log_request(
|
|
201
|
+
prompt=prompt,
|
|
202
|
+
task_type=context.task_type.value,
|
|
203
|
+
length_bucket=context.length_bucket.value,
|
|
204
|
+
model=attempt.model,
|
|
205
|
+
propensity=attempt.propensity,
|
|
206
|
+
latency_ms=attempt.latency_ms,
|
|
207
|
+
mode=mode,
|
|
208
|
+
reward=attempt.reward,
|
|
209
|
+
quality_passed=attempt.passed,
|
|
210
|
+
conversation_id=conversation_id,
|
|
211
|
+
turn_index=turn_index,
|
|
212
|
+
engine=engine,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# response is None when every arm errored out at the API level (see
|
|
216
|
+
# call_with_quality_gate), not just returned a bad answer
|
|
217
|
+
content = outcome.response.choices[0].message.content if outcome.response else None
|
|
218
|
+
return content, outcome.model_used, outcome.passed
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_MAX_CHAT_MESSAGES = 20 # ~10 exchanges, a first-guess cap like the
|
|
222
|
+
# reward weights, not tuned against anything yet
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _trim_history(messages: list[dict], max_messages: int = _MAX_CHAT_MESSAGES) -> list[dict]:
|
|
226
|
+
if len(messages) <= max_messages:
|
|
227
|
+
return messages
|
|
228
|
+
# drop whole oldest turns (user+assistant pairs), not a single
|
|
229
|
+
# message, so the transcript never gets left with an orphaned
|
|
230
|
+
# question and no answer in front of it
|
|
231
|
+
excess = len(messages) - max_messages
|
|
232
|
+
excess += excess % 2
|
|
233
|
+
return messages[excess:]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def run_chat(random_mode: bool = False) -> None:
|
|
237
|
+
console = Console()
|
|
238
|
+
|
|
239
|
+
config = _ensure_config()
|
|
240
|
+
engine = get_engine()
|
|
241
|
+
adapter = ArcAdapter(api_key=config.arc_api_key) # before the thread, same reason as run_ask
|
|
242
|
+
|
|
243
|
+
threading.Thread(target=get_embedding_model, daemon=True).start()
|
|
244
|
+
|
|
245
|
+
mode = "random" if random_mode else "bandit"
|
|
246
|
+
algorithm_factory = RandomBandit if random_mode else _ALGORITHM_FACTORIES[config.bandit_algorithm]
|
|
247
|
+
bandit = ContextualBandit(lambda: algorithm_factory(ARMS), arms=ARMS)
|
|
248
|
+
replay_history(bandit, engine, mode=mode)
|
|
249
|
+
|
|
250
|
+
conversation_id = str(uuid4())
|
|
251
|
+
messages: list[dict] = []
|
|
252
|
+
turn_index = 0
|
|
253
|
+
|
|
254
|
+
console.print("[bold]chatting with arcus, type 'exit' or ctrl-d to leave.[/bold]\n")
|
|
255
|
+
|
|
256
|
+
while True:
|
|
257
|
+
try:
|
|
258
|
+
user_input = input("you: ").strip()
|
|
259
|
+
except (EOFError, KeyboardInterrupt):
|
|
260
|
+
console.print()
|
|
261
|
+
break
|
|
262
|
+
|
|
263
|
+
if not user_input:
|
|
264
|
+
continue
|
|
265
|
+
if user_input.lower() in ("exit", "quit"):
|
|
266
|
+
break
|
|
267
|
+
|
|
268
|
+
messages.append({"role": "user", "content": user_input})
|
|
269
|
+
context = classify(user_input)
|
|
270
|
+
|
|
271
|
+
content, model_used, passed = _route_and_answer(
|
|
272
|
+
adapter,
|
|
273
|
+
bandit,
|
|
274
|
+
context,
|
|
275
|
+
user_input,
|
|
276
|
+
messages,
|
|
277
|
+
mode,
|
|
278
|
+
engine,
|
|
279
|
+
conversation_id=conversation_id,
|
|
280
|
+
turn_index=turn_index,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if content:
|
|
284
|
+
messages.append({"role": "assistant", "content": content})
|
|
285
|
+
console.print(f"[green]arcus ({model_used}):[/green] {content}\n")
|
|
286
|
+
else:
|
|
287
|
+
# nothing usable came back, don't leave an unanswered
|
|
288
|
+
# question sitting in history for the next turn to trip over
|
|
289
|
+
messages.pop()
|
|
290
|
+
console.print("[red]no usable response from any model, try rephrasing.[/red]\n")
|
|
291
|
+
|
|
292
|
+
messages = _trim_history(messages)
|
|
293
|
+
turn_index += 1
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def run_stats() -> None:
|
|
297
|
+
console = Console()
|
|
298
|
+
engine = get_engine()
|
|
299
|
+
|
|
300
|
+
with Session(engine) as session:
|
|
301
|
+
rows = session.exec(select(RequestLog)).all()
|
|
302
|
+
|
|
303
|
+
if not rows:
|
|
304
|
+
console.print("no requests logged yet, go ask arcus something.")
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
table = Table(title="arcus stats")
|
|
308
|
+
table.add_column("model")
|
|
309
|
+
table.add_column("mode")
|
|
310
|
+
table.add_column("requests", justify="right")
|
|
311
|
+
table.add_column("avg reward", justify="right")
|
|
312
|
+
table.add_column("avg latency (ms)", justify="right")
|
|
313
|
+
table.add_column("cost score", justify="right")
|
|
314
|
+
|
|
315
|
+
for summary in aggregate_by_arm_and_mode(engine):
|
|
316
|
+
table.add_row(
|
|
317
|
+
summary.model,
|
|
318
|
+
summary.mode,
|
|
319
|
+
str(summary.request_count),
|
|
320
|
+
f"{summary.avg_reward:.3f}" if summary.avg_reward is not None else "-",
|
|
321
|
+
f"{summary.avg_latency_ms:.0f}" if summary.avg_latency_ms is not None else "-",
|
|
322
|
+
f"{summary.cost_score:.2f}" if summary.cost_score is not None else "-",
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
console.print(table)
|
|
326
|
+
|
|
327
|
+
cache_hits = sum(1 for row in rows if row.cache_hit)
|
|
328
|
+
gate_catches = sum(1 for row in rows if not row.quality_passed)
|
|
329
|
+
|
|
330
|
+
console.print(f"\ncache hit rate: {cache_hits / len(rows):.1%} ({cache_hits}/{len(rows)})")
|
|
331
|
+
console.print(f"quality gate catches: {gate_catches} attempt(s) failed and got retried")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == "__main__":
|
|
335
|
+
main()
|
arcus/config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from platformdirs import user_config_dir
|
|
6
|
+
from pydantic_settings import BaseSettings
|
|
7
|
+
|
|
8
|
+
BanditAlgorithm = Literal["epsilon_greedy", "ucb1", "thompson"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ArcusConfig(BaseSettings):
|
|
12
|
+
arc_api_key: str
|
|
13
|
+
# thompson sampling is the default, it's the one that needs the least
|
|
14
|
+
# hand-tuning (no epsilon to pick) and adapts the fastest early on.
|
|
15
|
+
# the algorithm is swappable here rather than hardcoded, so a user
|
|
16
|
+
# can pick a different one without touching code.
|
|
17
|
+
bandit_algorithm: BanditAlgorithm = "thompson"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_path() -> Path:
|
|
21
|
+
# computed inside a function rather than a module constant so tests
|
|
22
|
+
# can point it somewhere disposable the same way storage/db.py's data
|
|
23
|
+
# dir already gets monkeypatched.
|
|
24
|
+
return Path(user_config_dir("arcus")) / "config.toml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_config(path: Path | None = None) -> ArcusConfig:
|
|
28
|
+
path = path or config_path()
|
|
29
|
+
with open(path, "rb") as f:
|
|
30
|
+
data = tomllib.load(f)
|
|
31
|
+
return ArcusConfig(**data)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save_config(config: ArcusConfig, path: Path | None = None) -> None:
|
|
35
|
+
path = path or config_path()
|
|
36
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
# only two fields right now, not worth pulling in a TOML-writing
|
|
39
|
+
# dependency just to serialize a couple of key/value lines.
|
|
40
|
+
path.write_text(
|
|
41
|
+
f'arc_api_key = "{config.arc_api_key}"\n'
|
|
42
|
+
f'bandit_algorithm = "{config.bandit_algorithm}"\n'
|
|
43
|
+
)
|
|
44
|
+
# the API key lives on disk in plain text, chmod 600 so it's at least
|
|
45
|
+
# not readable by other users on the same machine.
|
|
46
|
+
path.chmod(0o600)
|
arcus/embeddings.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
_model_cache = None
|
|
6
|
+
_model_lock = threading.Lock()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_embedding_model():
|
|
10
|
+
# imported lazily so importing this module doesn't drag torch in for
|
|
11
|
+
# callers that never actually need embeddings. cached as a singleton
|
|
12
|
+
# once loaded since the load itself (not the import) is the slow
|
|
13
|
+
# part, worth paying once per process, not once per call site.
|
|
14
|
+
#
|
|
15
|
+
# the lock matters once the CLI started warming this up on a
|
|
16
|
+
# background thread (see cli.py): without it, a foreground call
|
|
17
|
+
# landing while the warm-up thread is mid-load would see an empty
|
|
18
|
+
# cache and kick off a second, redundant load instead of just
|
|
19
|
+
# waiting for the first one to finish.
|
|
20
|
+
global _model_cache
|
|
21
|
+
if _model_cache is not None:
|
|
22
|
+
return _model_cache
|
|
23
|
+
|
|
24
|
+
with _model_lock:
|
|
25
|
+
if _model_cache is None:
|
|
26
|
+
from sentence_transformers import SentenceTransformer
|
|
27
|
+
|
|
28
|
+
_model_cache = SentenceTransformer("all-MiniLM-L6-v2")
|
|
29
|
+
return _model_cache
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def embed(texts: list[str]) -> np.ndarray:
|
|
33
|
+
model = get_embedding_model()
|
|
34
|
+
return model.encode(texts, normalize_embeddings=True)
|
arcus/eval/__init__.py
ADDED
|
File without changes
|
arcus/eval/offline.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from statistics import mean
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from sqlmodel import Session, select
|
|
7
|
+
|
|
8
|
+
from arcus.storage.db import RequestLog
|
|
9
|
+
|
|
10
|
+
Policy = Callable[[str], str]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class LoggedExample:
|
|
15
|
+
context_key: str
|
|
16
|
+
arm: str
|
|
17
|
+
propensity: float
|
|
18
|
+
reward: float
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_logged_examples(engine, mode: str = "bandit") -> list[LoggedExample]:
|
|
22
|
+
"""Pulls rows out of the request log that are actually usable for
|
|
23
|
+
offline evaluation: propensity has to be set (the whole point of
|
|
24
|
+
logging it from day one) and reward has to be set (a row that errored
|
|
25
|
+
out before a reward was computed can't tell an estimator anything).
|
|
26
|
+
"""
|
|
27
|
+
with Session(engine) as session:
|
|
28
|
+
rows = session.exec(
|
|
29
|
+
select(RequestLog)
|
|
30
|
+
.where(RequestLog.mode == mode)
|
|
31
|
+
.where(RequestLog.propensity.is_not(None))
|
|
32
|
+
.where(RequestLog.reward.is_not(None))
|
|
33
|
+
).all()
|
|
34
|
+
|
|
35
|
+
return [
|
|
36
|
+
LoggedExample(
|
|
37
|
+
context_key=f"{row.task_type}:{row.length_bucket}",
|
|
38
|
+
arm=row.model,
|
|
39
|
+
propensity=row.propensity,
|
|
40
|
+
reward=row.reward,
|
|
41
|
+
)
|
|
42
|
+
for row in rows
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def policy_always(arm: str) -> Policy:
|
|
47
|
+
return lambda context_key: arm
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def greedy_policy_from_log(examples: list[LoggedExample]) -> Policy:
|
|
51
|
+
"""Builds a deterministic policy that, for each context bucket, picks
|
|
52
|
+
whichever arm had the best average logged reward. Fit and evaluated
|
|
53
|
+
on the same data (no held-out split), so its reported value will run
|
|
54
|
+
a little optimistic, this is a known limitation of the simple
|
|
55
|
+
version of this technique, not a bug. A proper cross-fitted version
|
|
56
|
+
would split the log before fitting.
|
|
57
|
+
"""
|
|
58
|
+
sums: dict[tuple[str, str], float] = {}
|
|
59
|
+
counts: dict[tuple[str, str], int] = {}
|
|
60
|
+
for ex in examples:
|
|
61
|
+
key = (ex.context_key, ex.arm)
|
|
62
|
+
sums[key] = sums.get(key, 0.0) + ex.reward
|
|
63
|
+
counts[key] = counts.get(key, 0) + 1
|
|
64
|
+
|
|
65
|
+
best_arm_per_context: dict[str, str] = {}
|
|
66
|
+
for (context_key, arm), total in sums.items():
|
|
67
|
+
avg = total / counts[(context_key, arm)]
|
|
68
|
+
current_best = best_arm_per_context.get(context_key)
|
|
69
|
+
if current_best is None or avg > sums[(context_key, current_best)] / counts[(context_key, current_best)]:
|
|
70
|
+
best_arm_per_context[context_key] = arm
|
|
71
|
+
|
|
72
|
+
fallback_arm = max(counts, key=counts.get)[1] if counts else None
|
|
73
|
+
|
|
74
|
+
def policy(context_key: str) -> str:
|
|
75
|
+
return best_arm_per_context.get(context_key, fallback_arm)
|
|
76
|
+
|
|
77
|
+
return policy
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ips_estimate(examples: list[LoggedExample], policy: Policy) -> float:
|
|
81
|
+
"""Inverse propensity scoring. For each logged row, if the target
|
|
82
|
+
policy would have picked the same arm the logging policy actually
|
|
83
|
+
picked, that row's reward counts, reweighted by how unlikely the
|
|
84
|
+
logging policy was to pick it (rarer picks get boosted more, that's
|
|
85
|
+
what corrects for the logging policy's own selection bias). Rows
|
|
86
|
+
where the policies disagree contribute nothing, we simply never
|
|
87
|
+
observed what would have happened.
|
|
88
|
+
"""
|
|
89
|
+
if not examples:
|
|
90
|
+
return 0.0
|
|
91
|
+
|
|
92
|
+
weighted = [
|
|
93
|
+
ex.reward / ex.propensity if policy(ex.context_key) == ex.arm else 0.0
|
|
94
|
+
for ex in examples
|
|
95
|
+
]
|
|
96
|
+
return mean(weighted)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _fit_reward_model(examples: list[LoggedExample]) -> dict[tuple[str, str], float]:
|
|
100
|
+
groups: dict[tuple[str, str], list[float]] = {}
|
|
101
|
+
for ex in examples:
|
|
102
|
+
groups.setdefault((ex.context_key, ex.arm), []).append(ex.reward)
|
|
103
|
+
return {key: mean(rewards) for key, rewards in groups.items()}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def dr_estimate(
|
|
107
|
+
examples: list[LoggedExample],
|
|
108
|
+
policy: Policy,
|
|
109
|
+
reward_model: dict[tuple[str, str], float] | None = None,
|
|
110
|
+
) -> float:
|
|
111
|
+
"""Doubly robust estimator: a direct-method estimate (the reward
|
|
112
|
+
model's guess at what the target policy would earn) plus an IPS
|
|
113
|
+
correction term that only kicks in on rows where the logged arm
|
|
114
|
+
happens to match the target policy. Stays unbiased if either the
|
|
115
|
+
reward model or the propensities are right, not both, that's the
|
|
116
|
+
"doubly" part. Same same-data-fit caveat as greedy_policy_from_log
|
|
117
|
+
applies to the default reward model here.
|
|
118
|
+
"""
|
|
119
|
+
if not examples:
|
|
120
|
+
return 0.0
|
|
121
|
+
|
|
122
|
+
reward_model = reward_model if reward_model is not None else _fit_reward_model(examples)
|
|
123
|
+
overall_mean = mean(ex.reward for ex in examples)
|
|
124
|
+
|
|
125
|
+
total = 0.0
|
|
126
|
+
for ex in examples:
|
|
127
|
+
target_arm = policy(ex.context_key)
|
|
128
|
+
q_target = reward_model.get((ex.context_key, target_arm), overall_mean)
|
|
129
|
+
|
|
130
|
+
if target_arm == ex.arm:
|
|
131
|
+
q_logged = reward_model.get((ex.context_key, ex.arm), overall_mean)
|
|
132
|
+
correction = (ex.reward - q_logged) / ex.propensity
|
|
133
|
+
else:
|
|
134
|
+
correction = 0.0
|
|
135
|
+
|
|
136
|
+
total += q_target + correction
|
|
137
|
+
|
|
138
|
+
return total / len(examples)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def bootstrap_ci(
|
|
142
|
+
examples: list[LoggedExample],
|
|
143
|
+
estimator: Callable[[list[LoggedExample]], float],
|
|
144
|
+
n_resamples: int = 1000,
|
|
145
|
+
confidence: float = 0.95,
|
|
146
|
+
seed: int | None = None,
|
|
147
|
+
) -> tuple[float, float, float]:
|
|
148
|
+
"""Percentile bootstrap: resample the log with replacement a bunch of
|
|
149
|
+
times, run the estimator on each resample, and read the confidence
|
|
150
|
+
interval off the sorted results. Plain stdlib random, no scipy, the
|
|
151
|
+
log sizes here don't need anything fancier.
|
|
152
|
+
"""
|
|
153
|
+
point = estimator(examples)
|
|
154
|
+
if not examples:
|
|
155
|
+
return point, point, point
|
|
156
|
+
|
|
157
|
+
rng = random.Random(seed)
|
|
158
|
+
n = len(examples)
|
|
159
|
+
resampled = [estimator([examples[rng.randrange(n)] for _ in range(n)]) for _ in range(n_resamples)]
|
|
160
|
+
resampled.sort()
|
|
161
|
+
|
|
162
|
+
lower_idx = int((1 - confidence) / 2 * n_resamples)
|
|
163
|
+
upper_idx = min(int((1 + confidence) / 2 * n_resamples), n_resamples - 1)
|
|
164
|
+
|
|
165
|
+
return point, resampled[lower_idx], resampled[upper_idx]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class PolicyEvaluation:
|
|
170
|
+
name: str
|
|
171
|
+
ips_estimate: float
|
|
172
|
+
ips_ci: tuple[float, float]
|
|
173
|
+
dr_estimate: float
|
|
174
|
+
dr_ci: tuple[float, float]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def evaluate_policies(
|
|
178
|
+
engine,
|
|
179
|
+
policies: dict[str, Policy],
|
|
180
|
+
mode: str = "bandit",
|
|
181
|
+
n_resamples: int = 1000,
|
|
182
|
+
seed: int | None = None,
|
|
183
|
+
) -> list[PolicyEvaluation]:
|
|
184
|
+
"""Compares the logged policy against offline-estimated alternatives,
|
|
185
|
+
each with a confidence interval.
|
|
186
|
+
"""
|
|
187
|
+
examples = load_logged_examples(engine, mode=mode)
|
|
188
|
+
|
|
189
|
+
logged_point, logged_low, logged_high = bootstrap_ci(
|
|
190
|
+
examples, lambda ex: mean(e.reward for e in ex) if ex else 0.0, n_resamples, seed=seed
|
|
191
|
+
)
|
|
192
|
+
rows = [
|
|
193
|
+
PolicyEvaluation(
|
|
194
|
+
name="logged (as-run)",
|
|
195
|
+
ips_estimate=logged_point,
|
|
196
|
+
ips_ci=(logged_low, logged_high),
|
|
197
|
+
dr_estimate=logged_point,
|
|
198
|
+
dr_ci=(logged_low, logged_high),
|
|
199
|
+
)
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
for name, policy in policies.items():
|
|
203
|
+
ips_point, ips_low, ips_high = bootstrap_ci(
|
|
204
|
+
examples, lambda ex, p=policy: ips_estimate(ex, p), n_resamples, seed=seed
|
|
205
|
+
)
|
|
206
|
+
dr_point, dr_low, dr_high = bootstrap_ci(
|
|
207
|
+
examples, lambda ex, p=policy: dr_estimate(ex, p), n_resamples, seed=seed
|
|
208
|
+
)
|
|
209
|
+
rows.append(
|
|
210
|
+
PolicyEvaluation(
|
|
211
|
+
name=name,
|
|
212
|
+
ips_estimate=ips_point,
|
|
213
|
+
ips_ci=(ips_low, ips_high),
|
|
214
|
+
dr_estimate=dr_point,
|
|
215
|
+
dr_ci=(dr_low, dr_high),
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return rows
|