agent-learning 0.4.1__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.
- agent_learning/__init__.py +160 -0
- agent_learning/_version.py +3 -0
- agent_learning/capture.py +271 -0
- agent_learning/classifiers/__init__.py +37 -0
- agent_learning/classifiers/base.py +185 -0
- agent_learning/classifiers/router.py +236 -0
- agent_learning/classifiers/scorers/__init__.py +34 -0
- agent_learning/classifiers/scorers/_base.py +162 -0
- agent_learning/classifiers/scorers/adherence.py +26 -0
- agent_learning/classifiers/scorers/completion.py +26 -0
- agent_learning/classifiers/scorers/intent.py +25 -0
- agent_learning/cli.py +386 -0
- agent_learning/config.py +524 -0
- agent_learning/learners/__init__.py +6 -0
- agent_learning/learners/base.py +38 -0
- agent_learning/learners/reinforce.py +153 -0
- agent_learning/metrics/__init__.py +22 -0
- agent_learning/metrics/base.py +233 -0
- agent_learning/metrics/intent_resolution.py +50 -0
- agent_learning/metrics/registry.py +42 -0
- agent_learning/metrics/task_adherence.py +42 -0
- agent_learning/metrics/task_completion.py +54 -0
- agent_learning/policy/__init__.py +7 -0
- agent_learning/policy/base.py +59 -0
- agent_learning/policy/contextual_softmax.py +243 -0
- agent_learning/policy/softmax_bandit.py +157 -0
- agent_learning/py.typed +1 -0
- agent_learning/rewards/__init__.py +6 -0
- agent_learning/rewards/shaping.py +121 -0
- agent_learning/rewards/writer.py +130 -0
- agent_learning/scorers/__init__.py +187 -0
- agent_learning/scorers/base.py +49 -0
- agent_learning/scorers/llm/__init__.py +19 -0
- agent_learning/scorers/llm/_base.py +126 -0
- agent_learning/scorers/llm/adherence.py +21 -0
- agent_learning/scorers/llm/completion.py +21 -0
- agent_learning/scorers/llm/intent.py +21 -0
- agent_learning/scorers/nlp/__init__.py +16 -0
- agent_learning/scorers/nlp/_base.py +99 -0
- agent_learning/scorers/nlp/adherence.py +17 -0
- agent_learning/scorers/nlp/completion.py +17 -0
- agent_learning/scorers/nlp/intent.py +17 -0
- agent_learning/scorers/nlp_text/__init__.py +26 -0
- agent_learning/scorers/nlp_text/_base.py +234 -0
- agent_learning/scorers/nlp_text/adherence.py +94 -0
- agent_learning/scorers/nlp_text/completion.py +91 -0
- agent_learning/scorers/nlp_text/intent.py +59 -0
- agent_learning/scorers/slm/__init__.py +25 -0
- agent_learning/scorers/slm/_base.py +292 -0
- agent_learning/scorers/slm/adherence.py +98 -0
- agent_learning/scorers/slm/completion.py +111 -0
- agent_learning/scorers/slm/intent.py +80 -0
- agent_learning/scorers/stdlib/__init__.py +38 -0
- agent_learning/scorers/stdlib/_text.py +87 -0
- agent_learning/scorers/stdlib/adherence.py +157 -0
- agent_learning/scorers/stdlib/completion.py +117 -0
- agent_learning/scorers/stdlib/intent.py +182 -0
- agent_learning/storage/__init__.py +14 -0
- agent_learning/storage/base.py +156 -0
- agent_learning/storage/cosmos.py +506 -0
- agent_learning/storage/local.py +353 -0
- agent_learning/storage/memory.py +209 -0
- agent_learning/training/__init__.py +5 -0
- agent_learning/training/runner.py +172 -0
- agent_learning/types.py +507 -0
- agent_learning-0.4.1.dist-info/METADATA +82 -0
- agent_learning-0.4.1.dist-info/RECORD +71 -0
- agent_learning-0.4.1.dist-info/WHEEL +5 -0
- agent_learning-0.4.1.dist-info/entry_points.txt +2 -0
- agent_learning-0.4.1.dist-info/licenses/LICENSE +21 -0
- agent_learning-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Contextual softmax bandit policy.
|
|
2
|
+
|
|
3
|
+
The policy stores a weight matrix ``W`` of shape ``(K, d)`` where ``K``
|
|
4
|
+
is the number of actions and ``d`` is the feature dimension. Action
|
|
5
|
+
logits at inference time are computed as ``logits = W @ phi`` for a
|
|
6
|
+
caller-supplied feature vector ``phi`` of length ``d``. A numerically
|
|
7
|
+
stable softmax then turns the logits into a probability distribution
|
|
8
|
+
the policy samples from.
|
|
9
|
+
|
|
10
|
+
The weight matrix lives in :attr:`PolicySnapshot.metadata`:
|
|
11
|
+
|
|
12
|
+
- ``metadata["feature_dim"]`` (int) - the dimension ``d``.
|
|
13
|
+
- ``metadata["context_weights"]`` (dict[action_id, list[float]]) -
|
|
14
|
+
one length-``d`` row per action.
|
|
15
|
+
|
|
16
|
+
Using ``metadata`` keeps the persisted snapshot schema unchanged
|
|
17
|
+
(the existing ``logits`` map stays untouched and unused). Storage
|
|
18
|
+
and Cosmos round-trip continue to work with no migration.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
import random
|
|
25
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from ..types import Action, PolicySnapshot
|
|
30
|
+
from .base import Decision, Policy
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_METADATA_WEIGHTS_KEY = "context_weights"
|
|
34
|
+
_METADATA_DIM_KEY = "feature_dim"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ContextualSoftmaxPolicy(Policy):
|
|
38
|
+
"""Linear contextual softmax over a fixed action set.
|
|
39
|
+
|
|
40
|
+
Construction:
|
|
41
|
+
policy = ContextualSoftmaxPolicy.from_actions(
|
|
42
|
+
actions, feature_dim=25, agent_id="dq"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
Continuation from a snapshot:
|
|
46
|
+
policy = ContextualSoftmaxPolicy.from_snapshot(snapshot)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
snapshot: PolicySnapshot,
|
|
52
|
+
*,
|
|
53
|
+
rng: Optional[random.Random] = None,
|
|
54
|
+
max_weight_abs: float = 10.0,
|
|
55
|
+
) -> None:
|
|
56
|
+
if not snapshot.actions:
|
|
57
|
+
raise ValueError("ContextualSoftmaxPolicy requires at least one action.")
|
|
58
|
+
feature_dim = int(snapshot.metadata.get(_METADATA_DIM_KEY, 0))
|
|
59
|
+
if feature_dim <= 0:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"ContextualSoftmaxPolicy requires snapshot.metadata['feature_dim'] > 0."
|
|
62
|
+
)
|
|
63
|
+
weights = snapshot.metadata.get(_METADATA_WEIGHTS_KEY)
|
|
64
|
+
if not isinstance(weights, dict):
|
|
65
|
+
weights = {}
|
|
66
|
+
# Materialise a row of zeros for any action missing a weight row.
|
|
67
|
+
for action in snapshot.actions:
|
|
68
|
+
row = weights.get(action.id)
|
|
69
|
+
if row is None or len(row) != feature_dim:
|
|
70
|
+
weights[action.id] = [0.0] * feature_dim
|
|
71
|
+
else:
|
|
72
|
+
weights[action.id] = [float(x) for x in row]
|
|
73
|
+
snapshot.metadata[_METADATA_WEIGHTS_KEY] = weights
|
|
74
|
+
snapshot.metadata[_METADATA_DIM_KEY] = feature_dim
|
|
75
|
+
|
|
76
|
+
self._snapshot = snapshot
|
|
77
|
+
self._rng = rng or random.Random()
|
|
78
|
+
self._max_weight_abs = float(max_weight_abs)
|
|
79
|
+
self._feature_dim = feature_dim
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# Constructors
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_actions(
|
|
87
|
+
cls,
|
|
88
|
+
actions: Sequence[Action],
|
|
89
|
+
*,
|
|
90
|
+
feature_dim: int,
|
|
91
|
+
agent_id: str = "default",
|
|
92
|
+
task_id: str = "default",
|
|
93
|
+
rng: Optional[random.Random] = None,
|
|
94
|
+
max_weight_abs: float = 10.0,
|
|
95
|
+
initial_weights: Optional[Dict[str, Sequence[float]]] = None,
|
|
96
|
+
) -> "ContextualSoftmaxPolicy":
|
|
97
|
+
if feature_dim <= 0:
|
|
98
|
+
raise ValueError("feature_dim must be a positive integer.")
|
|
99
|
+
weights: Dict[str, List[float]] = {
|
|
100
|
+
a.id: [0.0] * feature_dim for a in actions
|
|
101
|
+
}
|
|
102
|
+
if initial_weights:
|
|
103
|
+
for action_id, row in initial_weights.items():
|
|
104
|
+
if action_id in weights:
|
|
105
|
+
row_list = [float(x) for x in row]
|
|
106
|
+
if len(row_list) != feature_dim:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"initial_weights['{action_id}'] has length "
|
|
109
|
+
f"{len(row_list)}, expected {feature_dim}."
|
|
110
|
+
)
|
|
111
|
+
weights[action_id] = row_list
|
|
112
|
+
snapshot = PolicySnapshot(
|
|
113
|
+
agent_id=agent_id,
|
|
114
|
+
task_id=task_id,
|
|
115
|
+
version=0,
|
|
116
|
+
actions=list(actions),
|
|
117
|
+
logits={a.id: 0.0 for a in actions}, # kept for back-compat, unused
|
|
118
|
+
metadata={
|
|
119
|
+
_METADATA_DIM_KEY: int(feature_dim),
|
|
120
|
+
_METADATA_WEIGHTS_KEY: weights,
|
|
121
|
+
"policy_kind": "contextual_softmax",
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
return cls(snapshot, rng=rng, max_weight_abs=max_weight_abs)
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_snapshot(
|
|
128
|
+
cls,
|
|
129
|
+
snapshot: PolicySnapshot,
|
|
130
|
+
*,
|
|
131
|
+
rng: Optional[random.Random] = None,
|
|
132
|
+
max_weight_abs: float = 10.0,
|
|
133
|
+
) -> "ContextualSoftmaxPolicy":
|
|
134
|
+
return cls(snapshot, rng=rng, max_weight_abs=max_weight_abs)
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
# Policy interface
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def actions(self) -> Sequence[Action]:
|
|
141
|
+
return tuple(self._snapshot.actions)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def feature_dim(self) -> int:
|
|
145
|
+
return self._feature_dim
|
|
146
|
+
|
|
147
|
+
def _weights_matrix(self) -> np.ndarray:
|
|
148
|
+
rows = [
|
|
149
|
+
self._snapshot.metadata[_METADATA_WEIGHTS_KEY][a.id]
|
|
150
|
+
for a in self._snapshot.actions
|
|
151
|
+
]
|
|
152
|
+
return np.asarray(rows, dtype=np.float64)
|
|
153
|
+
|
|
154
|
+
def probabilities(self, state: Optional[Any] = None) -> List[float]:
|
|
155
|
+
"""Softmax over ``W @ phi`` in deterministic action order.
|
|
156
|
+
|
|
157
|
+
If ``state`` is None the policy returns the uniform distribution
|
|
158
|
+
over actions (equivalent to ``phi == 0``).
|
|
159
|
+
"""
|
|
160
|
+
n_actions = len(self._snapshot.actions)
|
|
161
|
+
if state is None:
|
|
162
|
+
uniform = 1.0 / n_actions
|
|
163
|
+
return [uniform] * n_actions
|
|
164
|
+
phi = np.asarray(state, dtype=np.float64).reshape(-1)
|
|
165
|
+
if phi.shape[0] != self._feature_dim:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"State vector has length {phi.shape[0]}, expected "
|
|
168
|
+
f"{self._feature_dim}."
|
|
169
|
+
)
|
|
170
|
+
logits = self._weights_matrix() @ phi
|
|
171
|
+
logits -= logits.max() # numerical stability
|
|
172
|
+
exp = np.exp(logits)
|
|
173
|
+
denom = float(exp.sum())
|
|
174
|
+
if denom <= 0.0 or not np.isfinite(denom): # pragma: no cover
|
|
175
|
+
uniform = 1.0 / n_actions
|
|
176
|
+
return [uniform] * n_actions
|
|
177
|
+
return (exp / denom).tolist()
|
|
178
|
+
|
|
179
|
+
def choose(self, state: Optional[Any] = None) -> Decision:
|
|
180
|
+
probs = self.probabilities(state)
|
|
181
|
+
idx = self._weighted_sample(probs)
|
|
182
|
+
chosen = self._snapshot.actions[idx]
|
|
183
|
+
prob = max(probs[idx], 1e-12)
|
|
184
|
+
return Decision(action=chosen, logprob=math.log(prob), probabilities=probs)
|
|
185
|
+
|
|
186
|
+
def snapshot(self) -> PolicySnapshot:
|
|
187
|
+
return PolicySnapshot.from_dict(self._snapshot.to_dict())
|
|
188
|
+
|
|
189
|
+
def apply_update(
|
|
190
|
+
self,
|
|
191
|
+
deltas: Dict[str, Any],
|
|
192
|
+
*,
|
|
193
|
+
baseline: float,
|
|
194
|
+
episodes_seen: int,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Apply additive updates to the context weights, clip, and bump version.
|
|
197
|
+
|
|
198
|
+
``deltas`` accepts two shapes for backward compatibility with
|
|
199
|
+
the marginal :class:`SoftmaxPolicy` API:
|
|
200
|
+
|
|
201
|
+
- ``{action_id: float}`` - apply ``delta`` to every dimension of
|
|
202
|
+
that action's weight row (rare; useful only as a smoke test).
|
|
203
|
+
- ``{action_id: Sequence[float]}`` of length ``feature_dim`` -
|
|
204
|
+
element-wise add to the weight row.
|
|
205
|
+
"""
|
|
206
|
+
weights: Dict[str, List[float]] = self._snapshot.metadata[_METADATA_WEIGHTS_KEY]
|
|
207
|
+
for action_id, delta in deltas.items():
|
|
208
|
+
if action_id not in weights:
|
|
209
|
+
continue
|
|
210
|
+
current = np.asarray(weights[action_id], dtype=np.float64)
|
|
211
|
+
if isinstance(delta, (int, float)):
|
|
212
|
+
current = current + float(delta)
|
|
213
|
+
else:
|
|
214
|
+
delta_arr = np.asarray(delta, dtype=np.float64).reshape(-1)
|
|
215
|
+
if delta_arr.shape[0] != self._feature_dim:
|
|
216
|
+
raise ValueError(
|
|
217
|
+
f"Delta for '{action_id}' has length "
|
|
218
|
+
f"{delta_arr.shape[0]}, expected {self._feature_dim}."
|
|
219
|
+
)
|
|
220
|
+
current = current + delta_arr
|
|
221
|
+
current = np.clip(current, -self._max_weight_abs, self._max_weight_abs)
|
|
222
|
+
weights[action_id] = current.tolist()
|
|
223
|
+
self._snapshot.metadata[_METADATA_WEIGHTS_KEY] = weights
|
|
224
|
+
self._snapshot.baseline = float(baseline)
|
|
225
|
+
self._snapshot.episodes_seen = int(episodes_seen)
|
|
226
|
+
self._snapshot.updates_applied += 1
|
|
227
|
+
self._snapshot.advance_version()
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
# Internals
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def _weighted_sample(self, probs: Sequence[float]) -> int:
|
|
234
|
+
roll = self._rng.random()
|
|
235
|
+
cumulative = 0.0
|
|
236
|
+
for idx, p in enumerate(probs):
|
|
237
|
+
cumulative += p
|
|
238
|
+
if roll <= cumulative:
|
|
239
|
+
return idx
|
|
240
|
+
return len(probs) - 1 # pragma: no cover - rounding tail
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
__all__ = ["ContextualSoftmaxPolicy"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Softmax bandit policy over a fixed set of discrete actions.
|
|
2
|
+
|
|
3
|
+
The policy stores one logit per action. Action probabilities are
|
|
4
|
+
computed via a numerically stable softmax. The action space is
|
|
5
|
+
fixed at construction time; the learner mutates only the logits and
|
|
6
|
+
the EMA baseline.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import random
|
|
13
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from ..types import Action, PolicySnapshot
|
|
18
|
+
from .base import Decision, Policy
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SoftmaxPolicy(Policy):
|
|
22
|
+
"""Discrete softmax policy with one logit per action.
|
|
23
|
+
|
|
24
|
+
Construction:
|
|
25
|
+
policy = SoftmaxPolicy.from_actions(actions, agent_id="dq")
|
|
26
|
+
|
|
27
|
+
Continuation from a snapshot (e.g. after process restart):
|
|
28
|
+
policy = SoftmaxPolicy.from_snapshot(snapshot)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
snapshot: PolicySnapshot,
|
|
34
|
+
*,
|
|
35
|
+
rng: Optional[random.Random] = None,
|
|
36
|
+
max_logit_abs: float = 10.0,
|
|
37
|
+
) -> None:
|
|
38
|
+
if not snapshot.actions:
|
|
39
|
+
raise ValueError("SoftmaxPolicy requires at least one action.")
|
|
40
|
+
self._snapshot = snapshot
|
|
41
|
+
self._rng = rng or random.Random()
|
|
42
|
+
self._max_logit_abs = max_logit_abs
|
|
43
|
+
# Make sure every action has a logit
|
|
44
|
+
for action in snapshot.actions:
|
|
45
|
+
snapshot.logits.setdefault(action.id, 0.0)
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
# Constructors
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_actions(
|
|
53
|
+
cls,
|
|
54
|
+
actions: Sequence[Action],
|
|
55
|
+
*,
|
|
56
|
+
agent_id: str = "default",
|
|
57
|
+
task_id: str = "default",
|
|
58
|
+
rng: Optional[random.Random] = None,
|
|
59
|
+
max_logit_abs: float = 10.0,
|
|
60
|
+
initial_logits: Optional[Dict[str, float]] = None,
|
|
61
|
+
) -> "SoftmaxPolicy":
|
|
62
|
+
logits = {a.id: 0.0 for a in actions}
|
|
63
|
+
if initial_logits:
|
|
64
|
+
for action_id, value in initial_logits.items():
|
|
65
|
+
if action_id in logits:
|
|
66
|
+
logits[action_id] = float(value)
|
|
67
|
+
snapshot = PolicySnapshot(
|
|
68
|
+
agent_id=agent_id,
|
|
69
|
+
task_id=task_id,
|
|
70
|
+
version=0,
|
|
71
|
+
actions=list(actions),
|
|
72
|
+
logits=logits,
|
|
73
|
+
)
|
|
74
|
+
return cls(snapshot, rng=rng, max_logit_abs=max_logit_abs)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_snapshot(
|
|
78
|
+
cls,
|
|
79
|
+
snapshot: PolicySnapshot,
|
|
80
|
+
*,
|
|
81
|
+
rng: Optional[random.Random] = None,
|
|
82
|
+
max_logit_abs: float = 10.0,
|
|
83
|
+
) -> "SoftmaxPolicy":
|
|
84
|
+
return cls(snapshot, rng=rng, max_logit_abs=max_logit_abs)
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# Policy interface
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def actions(self) -> Sequence[Action]:
|
|
91
|
+
return tuple(self._snapshot.actions)
|
|
92
|
+
|
|
93
|
+
def probabilities(self) -> List[float]:
|
|
94
|
+
"""Softmax over the action logits in deterministic order."""
|
|
95
|
+
logits = np.array(
|
|
96
|
+
[self._snapshot.logits[a.id] for a in self._snapshot.actions],
|
|
97
|
+
dtype=np.float64,
|
|
98
|
+
)
|
|
99
|
+
logits -= logits.max() # numerical stability
|
|
100
|
+
exp = np.exp(logits)
|
|
101
|
+
denom = exp.sum()
|
|
102
|
+
if denom <= 0.0 or not np.isfinite(denom): # pragma: no cover
|
|
103
|
+
uniform = 1.0 / len(self._snapshot.actions)
|
|
104
|
+
return [uniform] * len(self._snapshot.actions)
|
|
105
|
+
probs = exp / denom
|
|
106
|
+
return probs.tolist()
|
|
107
|
+
|
|
108
|
+
def choose(self, state: Optional[Any] = None) -> Decision:
|
|
109
|
+
# ``state`` is accepted for interface compatibility with
|
|
110
|
+
# contextual policies and is intentionally ignored: this is a
|
|
111
|
+
# marginal softmax that conditions only on its stored logits.
|
|
112
|
+
del state
|
|
113
|
+
probs = self.probabilities()
|
|
114
|
+
idx = self._weighted_sample(probs)
|
|
115
|
+
chosen = self._snapshot.actions[idx]
|
|
116
|
+
# log probability is clamped to avoid -inf for tiny probabilities
|
|
117
|
+
prob = max(probs[idx], 1e-12)
|
|
118
|
+
return Decision(action=chosen, logprob=math.log(prob), probabilities=probs)
|
|
119
|
+
|
|
120
|
+
def snapshot(self) -> PolicySnapshot:
|
|
121
|
+
return PolicySnapshot.from_dict(self._snapshot.to_dict())
|
|
122
|
+
|
|
123
|
+
def apply_update(
|
|
124
|
+
self,
|
|
125
|
+
deltas: Dict[str, float],
|
|
126
|
+
*,
|
|
127
|
+
baseline: float,
|
|
128
|
+
episodes_seen: int,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Apply per-action logit deltas, clip, and bump version."""
|
|
131
|
+
for action_id, delta in deltas.items():
|
|
132
|
+
if action_id not in self._snapshot.logits:
|
|
133
|
+
continue
|
|
134
|
+
new_value = self._snapshot.logits[action_id] + float(delta)
|
|
135
|
+
new_value = max(-self._max_logit_abs, min(self._max_logit_abs, new_value))
|
|
136
|
+
self._snapshot.logits[action_id] = new_value
|
|
137
|
+
self._snapshot.baseline = float(baseline)
|
|
138
|
+
self._snapshot.episodes_seen = int(episodes_seen)
|
|
139
|
+
self._snapshot.updates_applied += 1
|
|
140
|
+
self._snapshot.advance_version()
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
# Internals
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _weighted_sample(self, probs: Sequence[float]) -> int:
|
|
147
|
+
"""Sample an index from ``probs`` using the policy's RNG."""
|
|
148
|
+
roll = self._rng.random()
|
|
149
|
+
cumulative = 0.0
|
|
150
|
+
for idx, p in enumerate(probs):
|
|
151
|
+
cumulative += p
|
|
152
|
+
if roll <= cumulative:
|
|
153
|
+
return idx
|
|
154
|
+
return len(probs) - 1 # pragma: no cover - rounding tail
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = ["SoftmaxPolicy"]
|
agent_learning/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Marker file used by PEP 561 type checkers."""
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Reward shaping: combine multiple metric results into one scalar reward.
|
|
2
|
+
|
|
3
|
+
The shaper turns each metric's normalised score (in ``[0, 1]``) into
|
|
4
|
+
a signed contribution (in ``[-1, 1]``) and sums them with the
|
|
5
|
+
configured weights. Additional behavioural penalties (latency, cost)
|
|
6
|
+
can be applied after the metric sum. The final reward is clamped to
|
|
7
|
+
``[-1, 1]`` so it is always safe to feed into a policy-gradient
|
|
8
|
+
update.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Iterable, List, Optional
|
|
15
|
+
|
|
16
|
+
from ..config import ShapingConfig
|
|
17
|
+
from ..types import Episode, MetricName, MetricResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ShapedReward:
|
|
22
|
+
"""Result of shaping a metric collection into a scalar reward.
|
|
23
|
+
|
|
24
|
+
``metric_contributions`` exposes the signed contribution of every
|
|
25
|
+
metric individually so callers can persist per-metric rewards
|
|
26
|
+
alongside the aggregate.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
value: float
|
|
30
|
+
metric_contributions: List[tuple]
|
|
31
|
+
penalties: List[tuple]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RewardShaper:
|
|
35
|
+
"""Combine multiple :class:`MetricResult` into a scalar in ``[-1, 1]``."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, config: Optional[ShapingConfig] = None) -> None:
|
|
38
|
+
self._config = config or ShapingConfig()
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def config(self) -> ShapingConfig:
|
|
42
|
+
return self._config
|
|
43
|
+
|
|
44
|
+
def shape(
|
|
45
|
+
self,
|
|
46
|
+
results: Iterable[MetricResult],
|
|
47
|
+
*,
|
|
48
|
+
latency_ms: Optional[int] = None,
|
|
49
|
+
routing_correct: Optional[bool] = None,
|
|
50
|
+
hallucinated_class: bool = False,
|
|
51
|
+
) -> ShapedReward:
|
|
52
|
+
"""Combine scores + behavioural signals into a single scalar.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
results: Per-score metric outputs.
|
|
56
|
+
latency_ms: Episode latency in milliseconds; if above the
|
|
57
|
+
configured threshold the latency penalty is applied.
|
|
58
|
+
routing_correct: ``True`` if the calling system routed the
|
|
59
|
+
request to a class id in the allowed set
|
|
60
|
+
(→ add ``route_correct_reward``); ``False`` if it
|
|
61
|
+
picked a class outside the allowed set (→ add
|
|
62
|
+
``route_wrong_penalty``, which is negative). ``None``
|
|
63
|
+
disables the term entirely (no signal either way).
|
|
64
|
+
hallucinated_class: ``True`` if the rendered output
|
|
65
|
+
references an entity/class id not in the allowed set
|
|
66
|
+
(→ add ``hallucinated_class_penalty``).
|
|
67
|
+
"""
|
|
68
|
+
weights = {
|
|
69
|
+
MetricName.INTENT_RESOLUTION: self._config.intent_resolution_weight,
|
|
70
|
+
MetricName.TASK_ADHERENCE: self._config.task_adherence_weight,
|
|
71
|
+
MetricName.TASK_COMPLETION: self._config.task_completion_weight,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
contributions: List[tuple] = []
|
|
75
|
+
total = 0.0
|
|
76
|
+
for r in results:
|
|
77
|
+
if r.normalized is None or r.status != "completed":
|
|
78
|
+
continue
|
|
79
|
+
weight = weights.get(r.metric, 0.0)
|
|
80
|
+
# Map [0, 1] -> [-1, 1]
|
|
81
|
+
signed = 2.0 * r.normalized - 1.0
|
|
82
|
+
contribution = weight * signed
|
|
83
|
+
total += contribution
|
|
84
|
+
contributions.append((r.metric, weight, signed, contribution))
|
|
85
|
+
|
|
86
|
+
penalties: List[tuple] = []
|
|
87
|
+
if (
|
|
88
|
+
latency_ms is not None
|
|
89
|
+
and latency_ms > self._config.latency_penalty_threshold_ms
|
|
90
|
+
):
|
|
91
|
+
total += self._config.latency_penalty_value
|
|
92
|
+
penalties.append(("latency", latency_ms, self._config.latency_penalty_value))
|
|
93
|
+
|
|
94
|
+
if routing_correct is True:
|
|
95
|
+
total += self._config.route_correct_reward
|
|
96
|
+
penalties.append(("route_correct", True, self._config.route_correct_reward))
|
|
97
|
+
elif routing_correct is False:
|
|
98
|
+
total += self._config.route_wrong_penalty
|
|
99
|
+
penalties.append(("route_wrong", False, self._config.route_wrong_penalty))
|
|
100
|
+
|
|
101
|
+
if hallucinated_class:
|
|
102
|
+
total += self._config.hallucinated_class_penalty
|
|
103
|
+
penalties.append(
|
|
104
|
+
("hallucinated_class", True, self._config.hallucinated_class_penalty)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
clamped = max(-1.0, min(1.0, total))
|
|
108
|
+
return ShapedReward(value=clamped, metric_contributions=contributions, penalties=penalties)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def shape_episode_reward(
|
|
112
|
+
episode: Episode,
|
|
113
|
+
results: Iterable[MetricResult],
|
|
114
|
+
config: Optional[ShapingConfig] = None,
|
|
115
|
+
) -> ShapedReward:
|
|
116
|
+
"""Convenience wrapper that pulls latency from the episode automatically."""
|
|
117
|
+
shaper = RewardShaper(config)
|
|
118
|
+
return shaper.shape(results, latency_ms=episode.request_latency_ms)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = ["RewardShaper", "ShapedReward", "shape_episode_reward"]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Persist metric results and aggregate rewards for an episode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Iterable, List, Optional
|
|
7
|
+
|
|
8
|
+
from ..storage.base import LearningStore
|
|
9
|
+
from ..storage.cosmos import get_default_store
|
|
10
|
+
from ..types import Episode, MetricName, MetricResult, Reward, RewardSource
|
|
11
|
+
from .shaping import ShapedReward
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RewardWriter:
|
|
17
|
+
"""Translate metric results into stored :class:`Reward` records.
|
|
18
|
+
|
|
19
|
+
The writer stores one ``Reward`` per metric (``source=METRIC``)
|
|
20
|
+
plus a single aggregate reward (``source=AGGREGATE``) that the
|
|
21
|
+
learner consumes. Storing per-metric values keeps the learning history
|
|
22
|
+
complete and lets downstream analytics decompose the aggregate.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, store: Optional[LearningStore] = None) -> None:
|
|
26
|
+
self._store = store
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def store(self) -> LearningStore:
|
|
30
|
+
if self._store is None:
|
|
31
|
+
self._store = get_default_store()
|
|
32
|
+
return self._store
|
|
33
|
+
|
|
34
|
+
def write(
|
|
35
|
+
self,
|
|
36
|
+
episode: Episode,
|
|
37
|
+
results: Iterable[MetricResult],
|
|
38
|
+
shaped: ShapedReward,
|
|
39
|
+
*,
|
|
40
|
+
rubric: Optional[str] = None,
|
|
41
|
+
) -> List[Reward]:
|
|
42
|
+
"""Persist metric and aggregate rewards. Returns the stored rows."""
|
|
43
|
+
stored: List[Reward] = []
|
|
44
|
+
results_list = list(results)
|
|
45
|
+
|
|
46
|
+
# 1) Persist the raw metric results themselves (richer than rewards)
|
|
47
|
+
try:
|
|
48
|
+
self.store.store_metric_results(episode.id, episode.agent_id, results_list)
|
|
49
|
+
except Exception as exc: # pragma: no cover - persistence error path
|
|
50
|
+
logger.warning("Failed to persist metric results for %s: %s", episode.id, exc)
|
|
51
|
+
|
|
52
|
+
# 2) Per-metric reward records (signed contributions)
|
|
53
|
+
contributions = {m: signed for m, _w, signed, _c in shaped.metric_contributions}
|
|
54
|
+
for result in results_list:
|
|
55
|
+
if result.status != "completed" or result.normalized is None:
|
|
56
|
+
continue
|
|
57
|
+
signed_value = contributions.get(
|
|
58
|
+
result.metric,
|
|
59
|
+
2.0 * result.normalized - 1.0,
|
|
60
|
+
)
|
|
61
|
+
reward = Reward(
|
|
62
|
+
episode_id=episode.id,
|
|
63
|
+
agent_id=episode.agent_id,
|
|
64
|
+
source=RewardSource.METRIC,
|
|
65
|
+
value=max(-1.0, min(1.0, signed_value)),
|
|
66
|
+
raw_value=result.score,
|
|
67
|
+
metric=result.metric,
|
|
68
|
+
rubric=rubric or result.metric.value,
|
|
69
|
+
evaluator=result.evaluator,
|
|
70
|
+
metadata={"reason": result.reason} if result.reason else {},
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
self.store.store_reward(reward)
|
|
74
|
+
stored.append(reward)
|
|
75
|
+
except Exception as exc: # pragma: no cover
|
|
76
|
+
logger.warning(
|
|
77
|
+
"Failed to persist %s reward for %s: %s",
|
|
78
|
+
result.metric.value,
|
|
79
|
+
episode.id,
|
|
80
|
+
exc,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# 3) Latency / cost penalty rewards (kept separate for analytics)
|
|
84
|
+
for kind, raw_value, value in shaped.penalties:
|
|
85
|
+
reward = Reward(
|
|
86
|
+
episode_id=episode.id,
|
|
87
|
+
agent_id=episode.agent_id,
|
|
88
|
+
source=RewardSource.LATENCY_PENALTY
|
|
89
|
+
if kind == "latency"
|
|
90
|
+
else RewardSource.COST_PENALTY,
|
|
91
|
+
value=max(-1.0, min(1.0, value)),
|
|
92
|
+
raw_value=raw_value,
|
|
93
|
+
rubric=kind,
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
self.store.store_reward(reward)
|
|
97
|
+
stored.append(reward)
|
|
98
|
+
except Exception as exc: # pragma: no cover
|
|
99
|
+
logger.warning("Failed to persist %s penalty for %s: %s", kind, episode.id, exc)
|
|
100
|
+
|
|
101
|
+
# 4) Aggregate reward consumed by the learner
|
|
102
|
+
aggregate = Reward(
|
|
103
|
+
episode_id=episode.id,
|
|
104
|
+
agent_id=episode.agent_id,
|
|
105
|
+
source=RewardSource.AGGREGATE,
|
|
106
|
+
value=shaped.value,
|
|
107
|
+
rubric=rubric or "aggregate",
|
|
108
|
+
metadata={
|
|
109
|
+
"metric_contributions": [
|
|
110
|
+
{
|
|
111
|
+
"metric": m.value if isinstance(m, MetricName) else str(m),
|
|
112
|
+
"weight": w,
|
|
113
|
+
"signed": signed,
|
|
114
|
+
"contribution": c,
|
|
115
|
+
}
|
|
116
|
+
for m, w, signed, c in shaped.metric_contributions
|
|
117
|
+
],
|
|
118
|
+
"penalties": [{"kind": k, "raw": r, "value": v} for k, r, v in shaped.penalties],
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
self.store.store_reward(aggregate)
|
|
123
|
+
stored.append(aggregate)
|
|
124
|
+
except Exception as exc: # pragma: no cover
|
|
125
|
+
logger.warning("Failed to persist aggregate reward for %s: %s", episode.id, exc)
|
|
126
|
+
|
|
127
|
+
return stored
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
__all__ = ["RewardWriter"]
|