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/eval/regret.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from arcus.adapters.arc_adapter import ArcModel
|
|
8
|
+
from arcus.routing.bandit import Bandit, EpsilonGreedyBandit, RandomBandit, ThompsonSamplingBandit, UCB1Bandit
|
|
9
|
+
|
|
10
|
+
ARMS = [m.value for m in ArcModel]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ArmDistribution:
|
|
15
|
+
mean: float
|
|
16
|
+
std: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# illustrative synthetic reward distributions, not measured. regret
|
|
20
|
+
# benchmarking needs a known ground-truth mean per arm to compute regret
|
|
21
|
+
# against, and that's exactly the thing live traffic can never give us:
|
|
22
|
+
# a real request only ever explores one arm per round, so we'd have no
|
|
23
|
+
# way to know what the *other* three would have scored that round. this
|
|
24
|
+
# is the standard way to study a bandit algorithm's exploration behavior
|
|
25
|
+
# in isolation from actual model quality, not a stand-in for missing
|
|
26
|
+
# data.
|
|
27
|
+
DEFAULT_ARM_DISTRIBUTIONS: dict[str, ArmDistribution] = {
|
|
28
|
+
"gpt-oss-120b": ArmDistribution(mean=0.72, std=0.10),
|
|
29
|
+
"GLM-5.3": ArmDistribution(mean=0.68, std=0.12),
|
|
30
|
+
"Kimi-K3": ArmDistribution(mean=0.75, std=0.08),
|
|
31
|
+
"DeepSeek-V4-Flash": ArmDistribution(mean=0.70, std=0.15),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _sample_reward(dist: ArmDistribution, rng: random.Random) -> float:
|
|
36
|
+
# clipped to [0, 1], real compute_reward() output is bounded there too
|
|
37
|
+
return min(1.0, max(0.0, rng.gauss(dist.mean, dist.std)))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def simulate_regret(
|
|
41
|
+
bandit_factory: Callable[[], Bandit],
|
|
42
|
+
arm_distributions: dict[str, ArmDistribution],
|
|
43
|
+
n_rounds: int,
|
|
44
|
+
seed: int,
|
|
45
|
+
) -> list[float]:
|
|
46
|
+
"""Runs one bandit for n_rounds against a known synthetic reward
|
|
47
|
+
environment and returns its cumulative regret curve. Regret per
|
|
48
|
+
round is the gap between the best arm's true mean and the pulled
|
|
49
|
+
arm's true mean, not the noisy realized reward, so the curve tracks
|
|
50
|
+
the algorithm's actual exploration cost instead of being dominated
|
|
51
|
+
by sampling noise.
|
|
52
|
+
"""
|
|
53
|
+
# Thompson sampling draws from numpy's global RNG (see bandit.py),
|
|
54
|
+
# the other algorithms only touch the stdlib random module, seeding
|
|
55
|
+
# both keeps every algorithm's run reproducible regardless of which
|
|
56
|
+
# one is being simulated.
|
|
57
|
+
rng = random.Random(seed)
|
|
58
|
+
np.random.seed(seed)
|
|
59
|
+
|
|
60
|
+
bandit = bandit_factory()
|
|
61
|
+
best_mean = max(dist.mean for dist in arm_distributions.values())
|
|
62
|
+
|
|
63
|
+
cumulative_regret = 0.0
|
|
64
|
+
curve = []
|
|
65
|
+
for _ in range(n_rounds):
|
|
66
|
+
arm = bandit.select_arm()
|
|
67
|
+
reward = _sample_reward(arm_distributions[arm], rng)
|
|
68
|
+
bandit.update(arm, reward)
|
|
69
|
+
|
|
70
|
+
cumulative_regret += best_mean - arm_distributions[arm].mean
|
|
71
|
+
curve.append(cumulative_regret)
|
|
72
|
+
|
|
73
|
+
return curve
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_regret_benchmark(
|
|
77
|
+
arm_distributions: dict[str, ArmDistribution] = DEFAULT_ARM_DISTRIBUTIONS,
|
|
78
|
+
n_rounds: int = 2000,
|
|
79
|
+
seed: int = 42,
|
|
80
|
+
) -> dict[str, list[float]]:
|
|
81
|
+
"""Cumulative regret curves for all three real algorithms plus the
|
|
82
|
+
random baseline, demonstrating this is a real algorithm family and
|
|
83
|
+
not just "a bandit works."
|
|
84
|
+
"""
|
|
85
|
+
arms = list(arm_distributions.keys())
|
|
86
|
+
factories: dict[str, Callable[[], Bandit]] = {
|
|
87
|
+
"epsilon_greedy": lambda: EpsilonGreedyBandit(arms),
|
|
88
|
+
"ucb1": lambda: UCB1Bandit(arms),
|
|
89
|
+
"thompson": lambda: ThompsonSamplingBandit(arms),
|
|
90
|
+
"random": lambda: RandomBandit(arms),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
name: simulate_regret(factory, arm_distributions, n_rounds, seed)
|
|
95
|
+
for name, factory in factories.items()
|
|
96
|
+
}
|
|
File without changes
|
arcus/quality/gate.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from openai import APIError
|
|
6
|
+
from openai.types.chat import ChatCompletion
|
|
7
|
+
from pydantic import BaseModel, ValidationError
|
|
8
|
+
|
|
9
|
+
from arcus.adapters.arc_adapter import ArcAdapter
|
|
10
|
+
from arcus.routing.bandit import ContextualBandit
|
|
11
|
+
from arcus.routing.reward import compute_reward
|
|
12
|
+
|
|
13
|
+
_REFUSAL_MARKERS = re.compile(
|
|
14
|
+
r"i cannot assist|i can't assist"
|
|
15
|
+
r"|i cannot help|i can't help"
|
|
16
|
+
r"|as an ai language model"
|
|
17
|
+
r"|i'm sorry,? but i can'?t"
|
|
18
|
+
r"|i am not able to|i'm not able to"
|
|
19
|
+
r"|i won'?t be able to"
|
|
20
|
+
r"|i must decline"
|
|
21
|
+
r"|against my guidelines",
|
|
22
|
+
re.IGNORECASE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_REPETITION_THRESHOLD = 0.5
|
|
26
|
+
# below this many words there aren't enough trigrams for the duplication
|
|
27
|
+
# ratio to mean anything, a two-sentence answer isn't "looping" just
|
|
28
|
+
# because it reuses a word.
|
|
29
|
+
_MIN_WORDS_FOR_REPETITION_CHECK = 9
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class QualityIssue:
|
|
34
|
+
kind: str
|
|
35
|
+
detail: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class QualityCheckResult:
|
|
40
|
+
passed: bool
|
|
41
|
+
issues: list[QualityIssue]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check_truncation(finish_reason: str | None) -> QualityIssue | None:
|
|
45
|
+
if finish_reason == "length":
|
|
46
|
+
return QualityIssue("truncated", "response was cut off before finishing (finish_reason=length)")
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check_empty(content: str | None) -> QualityIssue | None:
|
|
51
|
+
# deliberately not a fuzzy "too short" length check, a correct answer
|
|
52
|
+
# can legitimately be one word. this only catches actually-empty output.
|
|
53
|
+
if not content or not content.strip():
|
|
54
|
+
return QualityIssue("empty", "response was empty or whitespace only")
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_repetition(content: str | None, threshold: float = _REPETITION_THRESHOLD) -> QualityIssue | None:
|
|
59
|
+
if not content:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
words = content.split()
|
|
63
|
+
if len(words) < _MIN_WORDS_FOR_REPETITION_CHECK:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
trigrams = [tuple(words[i : i + 3]) for i in range(len(words) - 2)]
|
|
67
|
+
duplication_ratio = 1 - len(set(trigrams)) / len(trigrams)
|
|
68
|
+
|
|
69
|
+
if duplication_ratio > threshold:
|
|
70
|
+
return QualityIssue("repetitive", f"trigram duplication ratio {duplication_ratio:.2f} exceeds {threshold}")
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_refusal(content: str | None) -> QualityIssue | None:
|
|
75
|
+
if not content:
|
|
76
|
+
return None
|
|
77
|
+
if _REFUSAL_MARKERS.search(content):
|
|
78
|
+
return QualityIssue("refusal", "response matched a known refusal phrase")
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check_schema(content: str | None, schema: type[BaseModel] | None) -> QualityIssue | None:
|
|
83
|
+
if schema is None:
|
|
84
|
+
return None
|
|
85
|
+
if not content:
|
|
86
|
+
return QualityIssue("schema_invalid", "no content to validate against schema")
|
|
87
|
+
try:
|
|
88
|
+
schema.model_validate_json(content)
|
|
89
|
+
except ValidationError as e:
|
|
90
|
+
return QualityIssue("schema_invalid", str(e))
|
|
91
|
+
except ValueError as e:
|
|
92
|
+
# model_validate_json raises a plain ValueError (from the underlying
|
|
93
|
+
# json parse) when content isn't valid JSON at all, not a
|
|
94
|
+
# ValidationError, both count as a schema failure here
|
|
95
|
+
return QualityIssue("schema_invalid", str(e))
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def check_response(
|
|
100
|
+
content: str | None,
|
|
101
|
+
finish_reason: str | None,
|
|
102
|
+
schema: type[BaseModel] | None = None,
|
|
103
|
+
) -> QualityCheckResult:
|
|
104
|
+
checks = [
|
|
105
|
+
check_truncation(finish_reason),
|
|
106
|
+
check_empty(content),
|
|
107
|
+
check_repetition(content),
|
|
108
|
+
check_refusal(content),
|
|
109
|
+
check_schema(content, schema),
|
|
110
|
+
]
|
|
111
|
+
issues = [issue for issue in checks if issue is not None]
|
|
112
|
+
return QualityCheckResult(passed=len(issues) == 0, issues=issues)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class AttemptDetail:
|
|
117
|
+
model: str
|
|
118
|
+
passed: bool
|
|
119
|
+
reward: float
|
|
120
|
+
latency_ms: float
|
|
121
|
+
propensity: float
|
|
122
|
+
issues: list[QualityIssue]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class QualityGateOutcome:
|
|
127
|
+
# None only when every arm errored out at the API level rather than
|
|
128
|
+
# returning a bad answer, there was nothing to hand back at all
|
|
129
|
+
response: ChatCompletion | None
|
|
130
|
+
model_used: str
|
|
131
|
+
passed: bool
|
|
132
|
+
attempts: list[AttemptDetail]
|
|
133
|
+
issues: list[QualityIssue]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def call_with_quality_gate(
|
|
137
|
+
adapter: ArcAdapter,
|
|
138
|
+
bandit: ContextualBandit,
|
|
139
|
+
context_key: str,
|
|
140
|
+
messages: list[dict],
|
|
141
|
+
schema: type[BaseModel] | None = None,
|
|
142
|
+
max_attempts: int | None = None,
|
|
143
|
+
) -> QualityGateOutcome:
|
|
144
|
+
"""Calls ARC, runs the response through the quality gate, and retries
|
|
145
|
+
with a different arm on failure, feeding a real (not hardcoded)
|
|
146
|
+
negative reward back to the bandit each time so it actually learns
|
|
147
|
+
from the failure instead of just being told "not this one." Every
|
|
148
|
+
attempt gets logged, not just the winning one, so a caller can later
|
|
149
|
+
tell how often each model got caught by the gate, not only which
|
|
150
|
+
model ended up serving the response.
|
|
151
|
+
"""
|
|
152
|
+
max_attempts = min(max_attempts or len(bandit.arms), len(bandit.arms))
|
|
153
|
+
|
|
154
|
+
already_tried: set[str] = set()
|
|
155
|
+
attempts: list[AttemptDetail] = []
|
|
156
|
+
completion = None
|
|
157
|
+
arm = None
|
|
158
|
+
last_issues: list[QualityIssue] = []
|
|
159
|
+
|
|
160
|
+
for _ in range(max_attempts):
|
|
161
|
+
arm = bandit.select_arm(context_key, exclude=already_tried)
|
|
162
|
+
# propensity has to be read off the bandit's state right here,
|
|
163
|
+
# before update() below changes the counts it's computed from
|
|
164
|
+
propensity = bandit.propensity(context_key, arm, exclude=already_tried)
|
|
165
|
+
|
|
166
|
+
start = time.monotonic()
|
|
167
|
+
try:
|
|
168
|
+
completion = adapter.chat(arm, messages)
|
|
169
|
+
except APIError as e:
|
|
170
|
+
# a network blip, rate limit, or ARC-side outage on this one
|
|
171
|
+
# model isn't a reason to give up on the whole request, treat
|
|
172
|
+
# it exactly like a failed quality check: log it, penalize
|
|
173
|
+
# this arm for this context, and let the loop try the next
|
|
174
|
+
# one instead of crashing the CLI with a raw traceback.
|
|
175
|
+
latency_ms = (time.monotonic() - start) * 1000
|
|
176
|
+
issue = QualityIssue("api_error", str(e))
|
|
177
|
+
reward = compute_reward(latency_ms=latency_ms, model=arm, quality_score=0.0)
|
|
178
|
+
bandit.update(context_key, arm, reward)
|
|
179
|
+
|
|
180
|
+
attempts.append(
|
|
181
|
+
AttemptDetail(
|
|
182
|
+
model=arm, passed=False, reward=reward, latency_ms=latency_ms,
|
|
183
|
+
propensity=propensity, issues=[issue],
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
last_issues = [issue]
|
|
187
|
+
completion = None
|
|
188
|
+
already_tried.add(arm)
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
latency_ms = (time.monotonic() - start) * 1000
|
|
192
|
+
|
|
193
|
+
choice = completion.choices[0]
|
|
194
|
+
result = check_response(choice.message.content, choice.finish_reason, schema)
|
|
195
|
+
last_issues = result.issues
|
|
196
|
+
|
|
197
|
+
quality_score = 1.0 if result.passed else 0.0
|
|
198
|
+
reward = compute_reward(latency_ms=latency_ms, model=arm, quality_score=quality_score)
|
|
199
|
+
bandit.update(context_key, arm, reward)
|
|
200
|
+
|
|
201
|
+
attempts.append(
|
|
202
|
+
AttemptDetail(
|
|
203
|
+
model=arm,
|
|
204
|
+
passed=result.passed,
|
|
205
|
+
reward=reward,
|
|
206
|
+
latency_ms=latency_ms,
|
|
207
|
+
propensity=propensity,
|
|
208
|
+
issues=result.issues,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if result.passed:
|
|
213
|
+
return QualityGateOutcome(
|
|
214
|
+
response=completion, model_used=arm, passed=True, attempts=attempts, issues=[]
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
already_tried.add(arm)
|
|
218
|
+
|
|
219
|
+
# every arm got tried and none passed (or errored out), hand back the
|
|
220
|
+
# last attempt and let the caller decide what to do. response may be
|
|
221
|
+
# None here if the very last arm failed with an API error rather than
|
|
222
|
+
# a bad answer, callers need to handle that.
|
|
223
|
+
return QualityGateOutcome(
|
|
224
|
+
response=completion, model_used=arm, passed=False, attempts=attempts, issues=last_issues
|
|
225
|
+
)
|
|
File without changes
|
arcus/routing/bandit.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import random
|
|
3
|
+
from typing import Callable, Protocol
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Bandit(Protocol):
|
|
9
|
+
def select_arm(self, exclude: set[str] | None = None) -> str: ...
|
|
10
|
+
def update(self, arm: str, reward: float) -> None: ...
|
|
11
|
+
def propensity(self, arm: str, exclude: set[str] | None = None) -> float: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _CountBasedBandit:
|
|
15
|
+
"""Shared bookkeeping for epsilon-greedy and UCB1. Both track a running
|
|
16
|
+
per-arm pull count and reward sum, and both need every arm tried once
|
|
17
|
+
before their selection formula is even defined (UCB1's confidence bound
|
|
18
|
+
divides by pull count, epsilon-greedy has nothing to compare averages
|
|
19
|
+
against on arm zero).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, arms: list[str]) -> None:
|
|
23
|
+
self.arms = list(arms)
|
|
24
|
+
self._pulls = {arm: 0 for arm in self.arms}
|
|
25
|
+
self._reward_sums = {arm: 0.0 for arm in self.arms}
|
|
26
|
+
|
|
27
|
+
def _candidates(self, exclude: set[str] | None) -> list[str]:
|
|
28
|
+
if not exclude:
|
|
29
|
+
return self.arms
|
|
30
|
+
return [arm for arm in self.arms if arm not in exclude]
|
|
31
|
+
|
|
32
|
+
def _untried_arm(self, exclude: set[str] | None) -> str | None:
|
|
33
|
+
for arm in self._candidates(exclude):
|
|
34
|
+
if self._pulls[arm] == 0:
|
|
35
|
+
return arm
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
def _mean_reward(self, arm: str) -> float:
|
|
39
|
+
return self._reward_sums[arm] / self._pulls[arm]
|
|
40
|
+
|
|
41
|
+
def update(self, arm: str, reward: float) -> None:
|
|
42
|
+
self._pulls[arm] += 1
|
|
43
|
+
self._reward_sums[arm] += reward
|
|
44
|
+
|
|
45
|
+
def _cold_start_propensity(self, arm: str, exclude: set[str] | None) -> float | None:
|
|
46
|
+
# returns None once every candidate arm has at least one pull, so
|
|
47
|
+
# the caller knows to fall through to its own formula. while an
|
|
48
|
+
# arm is still untried, select_arm() always returns the first
|
|
49
|
+
# untried one it finds, so the propensity for that specific arm
|
|
50
|
+
# is 1.0 and everything else is 0, not a probability distribution
|
|
51
|
+
# spread across the untried arms.
|
|
52
|
+
untried = self._untried_arm(exclude)
|
|
53
|
+
if untried is None:
|
|
54
|
+
return None
|
|
55
|
+
return 1.0 if arm == untried else 0.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EpsilonGreedyBandit(_CountBasedBandit):
|
|
59
|
+
def __init__(self, arms: list[str], epsilon: float = 0.1) -> None:
|
|
60
|
+
super().__init__(arms)
|
|
61
|
+
self.epsilon = epsilon
|
|
62
|
+
|
|
63
|
+
def select_arm(self, exclude: set[str] | None = None) -> str:
|
|
64
|
+
untried = self._untried_arm(exclude)
|
|
65
|
+
if untried is not None:
|
|
66
|
+
return untried
|
|
67
|
+
|
|
68
|
+
candidates = self._candidates(exclude)
|
|
69
|
+
|
|
70
|
+
if random.random() < self.epsilon:
|
|
71
|
+
return random.choice(candidates)
|
|
72
|
+
|
|
73
|
+
best_mean = max(self._mean_reward(arm) for arm in candidates)
|
|
74
|
+
best_arms = [arm for arm in candidates if self._mean_reward(arm) == best_mean]
|
|
75
|
+
return random.choice(best_arms)
|
|
76
|
+
|
|
77
|
+
def propensity(self, arm: str, exclude: set[str] | None = None) -> float:
|
|
78
|
+
cold_start = self._cold_start_propensity(arm, exclude)
|
|
79
|
+
if cold_start is not None:
|
|
80
|
+
return cold_start
|
|
81
|
+
|
|
82
|
+
candidates = self._candidates(exclude)
|
|
83
|
+
if arm not in candidates:
|
|
84
|
+
return 0.0
|
|
85
|
+
|
|
86
|
+
best_mean = max(self._mean_reward(a) for a in candidates)
|
|
87
|
+
best_arms = [a for a in candidates if self._mean_reward(a) == best_mean]
|
|
88
|
+
|
|
89
|
+
# epsilon/n chance of landing here through the random branch, plus
|
|
90
|
+
# a share of the (1 - epsilon) greedy branch if this arm is one of
|
|
91
|
+
# the (possibly tied) best ones.
|
|
92
|
+
base = self.epsilon / len(candidates)
|
|
93
|
+
if arm in best_arms:
|
|
94
|
+
return base + (1 - self.epsilon) / len(best_arms)
|
|
95
|
+
return base
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class UCB1Bandit(_CountBasedBandit):
|
|
99
|
+
def select_arm(self, exclude: set[str] | None = None) -> str:
|
|
100
|
+
untried = self._untried_arm(exclude)
|
|
101
|
+
if untried is not None:
|
|
102
|
+
return untried
|
|
103
|
+
|
|
104
|
+
candidates = self._candidates(exclude)
|
|
105
|
+
|
|
106
|
+
# textbook UCB1, no tunable exploration constant on purpose, that's
|
|
107
|
+
# the whole point of this algorithm over epsilon-greedy: provable
|
|
108
|
+
# logarithmic regret without anything to hand-tune.
|
|
109
|
+
total_pulls = sum(self._pulls.values())
|
|
110
|
+
|
|
111
|
+
def ucb_score(arm: str) -> float:
|
|
112
|
+
return self._mean_reward(arm) + math.sqrt(2 * math.log(total_pulls) / self._pulls[arm])
|
|
113
|
+
|
|
114
|
+
best_score = max(ucb_score(arm) for arm in candidates)
|
|
115
|
+
best_arms = [arm for arm in candidates if ucb_score(arm) == best_score]
|
|
116
|
+
return random.choice(best_arms)
|
|
117
|
+
|
|
118
|
+
def propensity(self, arm: str, exclude: set[str] | None = None) -> float:
|
|
119
|
+
cold_start = self._cold_start_propensity(arm, exclude)
|
|
120
|
+
if cold_start is not None:
|
|
121
|
+
return cold_start
|
|
122
|
+
|
|
123
|
+
candidates = self._candidates(exclude)
|
|
124
|
+
if arm not in candidates:
|
|
125
|
+
return 0.0
|
|
126
|
+
|
|
127
|
+
total_pulls = sum(self._pulls.values())
|
|
128
|
+
|
|
129
|
+
def ucb_score(a: str) -> float:
|
|
130
|
+
return self._mean_reward(a) + math.sqrt(2 * math.log(total_pulls) / self._pulls[a])
|
|
131
|
+
|
|
132
|
+
best_score = max(ucb_score(a) for a in candidates)
|
|
133
|
+
best_arms = [a for a in candidates if ucb_score(a) == best_score]
|
|
134
|
+
# UCB1 is deterministic except for ties, so the propensity is just
|
|
135
|
+
# 1 over however many arms are tied for the top score.
|
|
136
|
+
return 1 / len(best_arms) if arm in best_arms else 0.0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_THOMPSON_PROPENSITY_SAMPLES = 2000
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ThompsonSamplingBandit:
|
|
143
|
+
def __init__(self, arms: list[str]) -> None:
|
|
144
|
+
self.arms = list(arms)
|
|
145
|
+
# Beta(1, 1) is a uniform prior. no cold-start branch needed here
|
|
146
|
+
# like the other two, a fresh arm just has wide uncertainty and
|
|
147
|
+
# naturally gets sampled a lot until its posterior narrows, that's
|
|
148
|
+
# Thompson sampling's actual advantage over the count-based ones.
|
|
149
|
+
self._alpha = {arm: 1.0 for arm in self.arms}
|
|
150
|
+
self._beta = {arm: 1.0 for arm in self.arms}
|
|
151
|
+
|
|
152
|
+
def select_arm(self, exclude: set[str] | None = None) -> str:
|
|
153
|
+
candidates = [arm for arm in self.arms if not exclude or arm not in exclude]
|
|
154
|
+
samples = {arm: np.random.beta(self._alpha[arm], self._beta[arm]) for arm in candidates}
|
|
155
|
+
return max(samples, key=samples.get)
|
|
156
|
+
|
|
157
|
+
def update(self, arm: str, reward: float) -> None:
|
|
158
|
+
# standard Beta-Bernoulli Thompson sampling assumes 0/1 rewards.
|
|
159
|
+
# ours are continuous in [0, 1], so the reward itself gets treated
|
|
160
|
+
# as a fractional pseudo-observation rather than a hard success or
|
|
161
|
+
# failure. this is the usual way to stretch Beta-Bernoulli to
|
|
162
|
+
# continuous rewards in that range.
|
|
163
|
+
self._alpha[arm] += reward
|
|
164
|
+
self._beta[arm] += 1 - reward
|
|
165
|
+
|
|
166
|
+
def propensity(self, arm: str, exclude: set[str] | None = None) -> float:
|
|
167
|
+
candidates = [a for a in self.arms if not exclude or a not in exclude]
|
|
168
|
+
if arm not in candidates:
|
|
169
|
+
return 0.0
|
|
170
|
+
|
|
171
|
+
# there's no closed form for the probability that one independent
|
|
172
|
+
# Beta draw beats a handful of others, so this estimates it the
|
|
173
|
+
# same way select_arm() actually decides: draw a batch of samples
|
|
174
|
+
# per arm and see how often this arm comes out on top. this is a
|
|
175
|
+
# Monte Carlo estimate, not an exact value, that's expected and
|
|
176
|
+
# documented, not a bug.
|
|
177
|
+
draws = np.stack(
|
|
178
|
+
[np.random.beta(self._alpha[a], self._beta[a], size=_THOMPSON_PROPENSITY_SAMPLES) for a in candidates]
|
|
179
|
+
)
|
|
180
|
+
winners = draws.argmax(axis=0)
|
|
181
|
+
return float((winners == candidates.index(arm)).mean())
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class RandomBandit(_CountBasedBandit):
|
|
185
|
+
"""Uniform random arm selection, ignores everything it's learned. This
|
|
186
|
+
is the 'B' side of the A/B mode flag: hand this to a
|
|
187
|
+
ContextualBandit instead of one of the real algorithms and you get a
|
|
188
|
+
random-routing baseline to compare the real bandits against. update()
|
|
189
|
+
still records pulls and reward sums like the other count-based
|
|
190
|
+
bandits do, not because selection uses them, but so stats aggregation
|
|
191
|
+
can report what random selection would have earned per arm.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
def select_arm(self, exclude: set[str] | None = None) -> str:
|
|
195
|
+
return random.choice(self._candidates(exclude))
|
|
196
|
+
|
|
197
|
+
def propensity(self, arm: str, exclude: set[str] | None = None) -> float:
|
|
198
|
+
candidates = self._candidates(exclude)
|
|
199
|
+
return 1 / len(candidates) if arm in candidates else 0.0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class ContextualBandit:
|
|
203
|
+
"""One independent bandit instance per context bucket, the 'disjoint'
|
|
204
|
+
contextual bandit approach, rather than one bandit shared across
|
|
205
|
+
every kind of request. Instances are created lazily the first time a
|
|
206
|
+
given context key shows up.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(self, algorithm_factory: Callable[[], Bandit], arms: list[str]) -> None:
|
|
210
|
+
self._algorithm_factory = algorithm_factory
|
|
211
|
+
self.arms = list(arms)
|
|
212
|
+
self._bandits: dict[str, Bandit] = {}
|
|
213
|
+
|
|
214
|
+
def _get_bandit(self, context_key: str) -> Bandit:
|
|
215
|
+
if context_key not in self._bandits:
|
|
216
|
+
self._bandits[context_key] = self._algorithm_factory()
|
|
217
|
+
return self._bandits[context_key]
|
|
218
|
+
|
|
219
|
+
def select_arm(self, context_key: str, exclude: set[str] | None = None) -> str:
|
|
220
|
+
return self._get_bandit(context_key).select_arm(exclude=exclude)
|
|
221
|
+
|
|
222
|
+
def update(self, context_key: str, arm: str, reward: float) -> None:
|
|
223
|
+
self._get_bandit(context_key).update(arm, reward)
|
|
224
|
+
|
|
225
|
+
def propensity(self, context_key: str, arm: str, exclude: set[str] | None = None) -> float:
|
|
226
|
+
return self._get_bandit(context_key).propensity(arm, exclude=exclude)
|