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.
Files changed (71) hide show
  1. agent_learning/__init__.py +160 -0
  2. agent_learning/_version.py +3 -0
  3. agent_learning/capture.py +271 -0
  4. agent_learning/classifiers/__init__.py +37 -0
  5. agent_learning/classifiers/base.py +185 -0
  6. agent_learning/classifiers/router.py +236 -0
  7. agent_learning/classifiers/scorers/__init__.py +34 -0
  8. agent_learning/classifiers/scorers/_base.py +162 -0
  9. agent_learning/classifiers/scorers/adherence.py +26 -0
  10. agent_learning/classifiers/scorers/completion.py +26 -0
  11. agent_learning/classifiers/scorers/intent.py +25 -0
  12. agent_learning/cli.py +386 -0
  13. agent_learning/config.py +524 -0
  14. agent_learning/learners/__init__.py +6 -0
  15. agent_learning/learners/base.py +38 -0
  16. agent_learning/learners/reinforce.py +153 -0
  17. agent_learning/metrics/__init__.py +22 -0
  18. agent_learning/metrics/base.py +233 -0
  19. agent_learning/metrics/intent_resolution.py +50 -0
  20. agent_learning/metrics/registry.py +42 -0
  21. agent_learning/metrics/task_adherence.py +42 -0
  22. agent_learning/metrics/task_completion.py +54 -0
  23. agent_learning/policy/__init__.py +7 -0
  24. agent_learning/policy/base.py +59 -0
  25. agent_learning/policy/contextual_softmax.py +243 -0
  26. agent_learning/policy/softmax_bandit.py +157 -0
  27. agent_learning/py.typed +1 -0
  28. agent_learning/rewards/__init__.py +6 -0
  29. agent_learning/rewards/shaping.py +121 -0
  30. agent_learning/rewards/writer.py +130 -0
  31. agent_learning/scorers/__init__.py +187 -0
  32. agent_learning/scorers/base.py +49 -0
  33. agent_learning/scorers/llm/__init__.py +19 -0
  34. agent_learning/scorers/llm/_base.py +126 -0
  35. agent_learning/scorers/llm/adherence.py +21 -0
  36. agent_learning/scorers/llm/completion.py +21 -0
  37. agent_learning/scorers/llm/intent.py +21 -0
  38. agent_learning/scorers/nlp/__init__.py +16 -0
  39. agent_learning/scorers/nlp/_base.py +99 -0
  40. agent_learning/scorers/nlp/adherence.py +17 -0
  41. agent_learning/scorers/nlp/completion.py +17 -0
  42. agent_learning/scorers/nlp/intent.py +17 -0
  43. agent_learning/scorers/nlp_text/__init__.py +26 -0
  44. agent_learning/scorers/nlp_text/_base.py +234 -0
  45. agent_learning/scorers/nlp_text/adherence.py +94 -0
  46. agent_learning/scorers/nlp_text/completion.py +91 -0
  47. agent_learning/scorers/nlp_text/intent.py +59 -0
  48. agent_learning/scorers/slm/__init__.py +25 -0
  49. agent_learning/scorers/slm/_base.py +292 -0
  50. agent_learning/scorers/slm/adherence.py +98 -0
  51. agent_learning/scorers/slm/completion.py +111 -0
  52. agent_learning/scorers/slm/intent.py +80 -0
  53. agent_learning/scorers/stdlib/__init__.py +38 -0
  54. agent_learning/scorers/stdlib/_text.py +87 -0
  55. agent_learning/scorers/stdlib/adherence.py +157 -0
  56. agent_learning/scorers/stdlib/completion.py +117 -0
  57. agent_learning/scorers/stdlib/intent.py +182 -0
  58. agent_learning/storage/__init__.py +14 -0
  59. agent_learning/storage/base.py +156 -0
  60. agent_learning/storage/cosmos.py +506 -0
  61. agent_learning/storage/local.py +353 -0
  62. agent_learning/storage/memory.py +209 -0
  63. agent_learning/training/__init__.py +5 -0
  64. agent_learning/training/runner.py +172 -0
  65. agent_learning/types.py +507 -0
  66. agent_learning-0.4.1.dist-info/METADATA +82 -0
  67. agent_learning-0.4.1.dist-info/RECORD +71 -0
  68. agent_learning-0.4.1.dist-info/WHEEL +5 -0
  69. agent_learning-0.4.1.dist-info/entry_points.txt +2 -0
  70. agent_learning-0.4.1.dist-info/licenses/LICENSE +21 -0
  71. agent_learning-0.4.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,160 @@
1
+ """Native reinforcement learning SDK for AI agents.
2
+
3
+ Replaces the agent-lightning LLM fine-tuning loop with a fully
4
+ native, in-process learner. The SDK is organised into five layers:
5
+
6
+ - ``agent_learning.types`` - durable record types (``Episode``,
7
+ ``Reward``, ``PolicySnapshot``, ...).
8
+ - ``agent_learning.storage`` - pluggable persistence (Cosmos DB,
9
+ local file system, and in-memory).
10
+ - ``agent_learning.metrics`` - score-based metrics that wrap the
11
+ Azure AI Evaluation evaluators for Intent Resolution, Task
12
+ Adherence, and Task Completion.
13
+ - ``agent_learning.rewards`` - reward shaping + persistence.
14
+ - ``agent_learning.policy`` - discrete softmax bandit policy.
15
+ - ``agent_learning.learners`` - REINFORCE-with-baseline learner.
16
+ - ``agent_learning.training`` - end-to-end :class:`LearningRunner`.
17
+
18
+ Quick start::
19
+
20
+ from agent_learning import (
21
+ Action,
22
+ EpisodeCapture,
23
+ LearningRunner,
24
+ SoftmaxPolicy,
25
+ )
26
+
27
+ actions = [Action(id="prompt_A"), Action(id="prompt_B")]
28
+ policy = SoftmaxPolicy.from_actions(actions, agent_id="dq", task_id="sales-summary")
29
+
30
+ # Capture
31
+ capture = EpisodeCapture()
32
+ decision = policy.choose()
33
+ ctx = capture.start(
34
+ "Tell me my Q3 sales summary",
35
+ task_id="sales-summary",
36
+ intent_summary="Summarize Q3 sales",
37
+ action_type="chat",
38
+ action_name=decision.action.id,
39
+ expected_outcome="An accurate Q3 sales summary",
40
+ policy_id=policy.snapshot().id,
41
+ policy_version=policy.snapshot().version,
42
+ action_id=decision.action.id,
43
+ action_logprob=decision.logprob,
44
+ )
45
+ # ... agent runs, records tool calls, produces output ...
46
+ episode = capture.end(
47
+ ctx,
48
+ assistant_output="...",
49
+ execution_status="completed",
50
+ result_summary="Returned the sales summary",
51
+ )
52
+
53
+ # Train
54
+ runner = LearningRunner(policy=policy)
55
+ run = runner.run_offline_batch("dq", task_id="sales-summary", episode_limit=200)
56
+ """
57
+
58
+ from ._version import __version__
59
+ from .capture import CaptureContext, EpisodeCapture, get_capture
60
+ from .classifiers import (
61
+ AdherenceScorer,
62
+ Classifier,
63
+ ClassifierResult,
64
+ CompletionScorer,
65
+ IntentScorer,
66
+ RouterClassifier,
67
+ )
68
+ from .config import (
69
+ CaptureConfig,
70
+ CosmosConfig,
71
+ ScoreConfig,
72
+ LearnerConfig,
73
+ ShapingConfig,
74
+ )
75
+ from .learners import Learner, LearnerResult, ReinforceLearner
76
+ from .metrics import (
77
+ IntentResolutionMetric,
78
+ MetricEvaluator,
79
+ MetricRequest,
80
+ TaskAdherenceMetric,
81
+ TaskCompletionMetric,
82
+ default_metrics,
83
+ evaluate_all,
84
+ )
85
+ from .policy import ContextualSoftmaxPolicy, Policy, SoftmaxPolicy
86
+ from .rewards import RewardShaper, RewardWriter, shape_episode_reward
87
+ from .storage import (
88
+ CosmosStore,
89
+ InMemoryStore,
90
+ LearningStore,
91
+ LocalFileStore,
92
+ get_default_store,
93
+ )
94
+ from .training import LearningRunner
95
+ from .types import (
96
+ Action,
97
+ AgentSummary,
98
+ AgentTaskSummary,
99
+ Episode,
100
+ MetricName,
101
+ MetricResult,
102
+ PolicySnapshot,
103
+ Reward,
104
+ RewardSource,
105
+ ToolCall,
106
+ TrainingRun,
107
+ TrainingStatus,
108
+ )
109
+
110
+ __all__ = [
111
+ "Action",
112
+ "AdherenceScorer",
113
+ "AgentSummary",
114
+ "AgentTaskSummary",
115
+ "CaptureConfig",
116
+ "CaptureContext",
117
+ "Classifier",
118
+ "ClassifierResult",
119
+ "CompletionScorer",
120
+ "ContextualSoftmaxPolicy",
121
+ "CosmosConfig",
122
+ "CosmosStore",
123
+ "Episode",
124
+ "EpisodeCapture",
125
+ "InMemoryStore",
126
+ "IntentScorer",
127
+ "IntentResolutionMetric",
128
+ "ScoreConfig",
129
+ "Learner",
130
+ "LearnerConfig",
131
+ "LearnerResult",
132
+ "LearningRunner",
133
+ "LearningStore",
134
+ "LocalFileStore",
135
+ "MetricEvaluator",
136
+ "MetricName",
137
+ "MetricRequest",
138
+ "MetricResult",
139
+ "Policy",
140
+ "PolicySnapshot",
141
+ "ReinforceLearner",
142
+ "Reward",
143
+ "RewardShaper",
144
+ "RewardSource",
145
+ "RewardWriter",
146
+ "RouterClassifier",
147
+ "ShapingConfig",
148
+ "SoftmaxPolicy",
149
+ "TaskAdherenceMetric",
150
+ "TaskCompletionMetric",
151
+ "ToolCall",
152
+ "TrainingRun",
153
+ "TrainingStatus",
154
+ "__version__",
155
+ "default_metrics",
156
+ "evaluate_all",
157
+ "get_capture",
158
+ "get_default_store",
159
+ "shape_episode_reward",
160
+ ]
@@ -0,0 +1,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "0.4.1"
@@ -0,0 +1,271 @@
1
+ """Episode capture helpers.
2
+
3
+ The capture hook is the orchestrator-facing entry point. It wraps a
4
+ single agent turn, lets the caller record tool calls during the
5
+ turn, and persists a complete :class:`Episode` at the end.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ import time
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from .config import CaptureConfig
18
+ from .storage.base import LearningStore
19
+ from .storage.cosmos import get_default_store
20
+ from .types import Episode, ToolCall
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # Conservative redaction patterns (mirrors lightning's set)
26
+ _REDACT_PATTERNS = [
27
+ (re.compile(r"(bearer\s+)[a-zA-Z0-9\-_\.]+", re.IGNORECASE), r"\1[REDACTED]"),
28
+ (re.compile(r"(api[_-]?key[\"\s:=]+)[a-zA-Z0-9\-_]+", re.IGNORECASE), r"\1[REDACTED]"),
29
+ (re.compile(r"(password[\"\s:=]+)[^\s\"]+", re.IGNORECASE), r"\1[REDACTED]"),
30
+ (re.compile(r"(secret[\"\s:=]+)[^\s\"]+", re.IGNORECASE), r"\1[REDACTED]"),
31
+ (re.compile(r"(token[\"\s:=]+)[a-zA-Z0-9\-_\.]+", re.IGNORECASE), r"\1[REDACTED]"),
32
+ (re.compile(r"(connection[_-]?string[\"\s:=]+)[^\s\"]+", re.IGNORECASE), r"\1[REDACTED]"),
33
+ (re.compile(r"(AccountKey=)[^;]+", re.IGNORECASE), r"\1[REDACTED]"),
34
+ (re.compile(r"(SharedAccessSignature=)[^;]+", re.IGNORECASE), r"\1[REDACTED]"),
35
+ ]
36
+
37
+
38
+ def redact(text: Optional[str]) -> Optional[str]:
39
+ """Strip well-known secret patterns from a string."""
40
+ if not text:
41
+ return text
42
+ out = text
43
+ for pattern, replacement in _REDACT_PATTERNS:
44
+ out = pattern.sub(replacement, out)
45
+ return out
46
+
47
+
48
+ @dataclass
49
+ class CaptureContext:
50
+ """In-flight state for a single capture."""
51
+
52
+ episode_id: str
53
+ agent_id: str
54
+ start_time: float
55
+ user_input: str
56
+ system_message: Optional[str] = None
57
+ conversation_history: List[Dict[str, str]] = field(default_factory=list)
58
+ tool_calls: List[ToolCall] = field(default_factory=list)
59
+ policy_id: Optional[str] = None
60
+ policy_version: Optional[int] = None
61
+ action_id: Optional[str] = None
62
+ action_logprob: Optional[float] = None
63
+ context_features: Dict[str, Any] = field(default_factory=dict)
64
+ model_deployment: Optional[str] = None
65
+ correlation_id: Optional[str] = None
66
+ session_id: Optional[str] = None
67
+ metadata: Dict[str, Any] = field(default_factory=dict)
68
+ agent_name: Optional[str] = None
69
+ task_id: str = "default"
70
+ task_name: Optional[str] = None
71
+ intent_summary: Optional[str] = None
72
+ action_type: Optional[str] = None
73
+ action_name: Optional[str] = None
74
+ target: Optional[str] = None
75
+ input_summary: Optional[str] = None
76
+ expected_outcome: Optional[str] = None
77
+
78
+
79
+ class EpisodeCapture:
80
+ """Capture one episode at a time and persist it via the store."""
81
+
82
+ def __init__(
83
+ self,
84
+ config: Optional[CaptureConfig] = None,
85
+ store: Optional[LearningStore] = None,
86
+ ) -> None:
87
+ self._config = config or CaptureConfig()
88
+ self._store = store
89
+
90
+ # ------------------------------------------------------------------
91
+ # Plumbing
92
+ # ------------------------------------------------------------------
93
+
94
+ @property
95
+ def store(self) -> LearningStore:
96
+ if self._store is None:
97
+ self._store = get_default_store()
98
+ return self._store
99
+
100
+ @property
101
+ def config(self) -> CaptureConfig:
102
+ return self._config
103
+
104
+ def is_enabled(self) -> bool:
105
+ return self._config.enabled
106
+
107
+ # ------------------------------------------------------------------
108
+ # Lifecycle
109
+ # ------------------------------------------------------------------
110
+
111
+ def start(
112
+ self,
113
+ user_input: str,
114
+ *,
115
+ task_id: Optional[str] = None,
116
+ task_name: Optional[str] = None,
117
+ intent_summary: Optional[str] = None,
118
+ action_type: Optional[str] = None,
119
+ action_name: Optional[str] = None,
120
+ target: Optional[str] = None,
121
+ input_summary: Optional[str] = None,
122
+ expected_outcome: Optional[str] = None,
123
+ system_message: Optional[str] = None,
124
+ conversation_history: Optional[List[Dict[str, str]]] = None,
125
+ model_deployment: Optional[str] = None,
126
+ correlation_id: Optional[str] = None,
127
+ session_id: Optional[str] = None,
128
+ policy_id: Optional[str] = None,
129
+ policy_version: Optional[int] = None,
130
+ action_id: Optional[str] = None,
131
+ action_logprob: Optional[float] = None,
132
+ context_features: Optional[Dict[str, Any]] = None,
133
+ metadata: Optional[Dict[str, Any]] = None,
134
+ ) -> CaptureContext:
135
+ return CaptureContext(
136
+ episode_id=str(uuid.uuid4()),
137
+ agent_id=self._config.agent_id,
138
+ agent_name=self._config.agent_name,
139
+ task_id=task_id or self._config.task_id,
140
+ task_name=task_name or self._config.task_name,
141
+ start_time=time.time(),
142
+ user_input=user_input,
143
+ intent_summary=intent_summary,
144
+ action_type=action_type,
145
+ action_name=action_name,
146
+ target=target,
147
+ input_summary=input_summary,
148
+ expected_outcome=expected_outcome,
149
+ system_message=system_message,
150
+ conversation_history=conversation_history or [],
151
+ policy_id=policy_id,
152
+ policy_version=policy_version,
153
+ action_id=action_id,
154
+ action_logprob=action_logprob,
155
+ context_features=context_features or {},
156
+ model_deployment=model_deployment,
157
+ correlation_id=correlation_id,
158
+ session_id=session_id,
159
+ metadata=metadata or {},
160
+ )
161
+
162
+ def record_tool_call(
163
+ self,
164
+ ctx: CaptureContext,
165
+ name: str,
166
+ arguments: Dict[str, Any],
167
+ result: Optional[str] = None,
168
+ *,
169
+ duration_ms: Optional[int] = None,
170
+ error: Optional[str] = None,
171
+ ) -> None:
172
+ if not self.is_enabled():
173
+ return
174
+
175
+ safe_args = arguments
176
+ if self._config.redact_secrets:
177
+ safe_args = {
178
+ k: redact(v) if isinstance(v, str) else v for k, v in arguments.items()
179
+ }
180
+
181
+ safe_result = result
182
+ if safe_result and self._config.redact_secrets:
183
+ safe_result = redact(safe_result)
184
+ if safe_result and len(safe_result) > self._config.max_output_length:
185
+ safe_result = safe_result[: self._config.max_output_length] + "...[TRUNCATED]"
186
+
187
+ ctx.tool_calls.append(
188
+ ToolCall(
189
+ name=name,
190
+ arguments=safe_args or {},
191
+ result=safe_result,
192
+ duration_ms=duration_ms,
193
+ error=error,
194
+ )
195
+ )
196
+
197
+ def end(
198
+ self,
199
+ ctx: CaptureContext,
200
+ assistant_output: str,
201
+ *,
202
+ execution_status: Optional[str] = None,
203
+ result_summary: Optional[str] = None,
204
+ token_usage: Optional[Dict[str, int]] = None,
205
+ extra_metadata: Optional[Dict[str, Any]] = None,
206
+ ) -> Optional[Episode]:
207
+ if not self.is_enabled():
208
+ return None
209
+
210
+ latency_ms = int((time.time() - ctx.start_time) * 1000)
211
+ output = assistant_output
212
+ if output and self._config.redact_secrets:
213
+ output = redact(output) or output
214
+
215
+ metadata = dict(ctx.metadata)
216
+ if extra_metadata:
217
+ metadata.update(extra_metadata)
218
+
219
+ episode = Episode(
220
+ id=ctx.episode_id,
221
+ agent_id=ctx.agent_id,
222
+ agent_name=ctx.agent_name,
223
+ task_id=ctx.task_id,
224
+ task_name=ctx.task_name,
225
+ user_input=ctx.user_input,
226
+ assistant_output=output or "",
227
+ intent_summary=ctx.intent_summary,
228
+ action_type=ctx.action_type,
229
+ action_name=ctx.action_name,
230
+ target=ctx.target,
231
+ input_summary=ctx.input_summary,
232
+ expected_outcome=ctx.expected_outcome,
233
+ execution_status=execution_status,
234
+ result_summary=result_summary,
235
+ system_message=ctx.system_message,
236
+ conversation_history=ctx.conversation_history,
237
+ tool_calls=ctx.tool_calls,
238
+ policy_id=ctx.policy_id,
239
+ policy_version=ctx.policy_version,
240
+ action_id=ctx.action_id,
241
+ action_logprob=ctx.action_logprob,
242
+ context_features=ctx.context_features,
243
+ model_deployment=ctx.model_deployment,
244
+ correlation_id=ctx.correlation_id,
245
+ session_id=ctx.session_id,
246
+ request_latency_ms=latency_ms,
247
+ token_usage=token_usage,
248
+ metadata=metadata,
249
+ )
250
+
251
+ try:
252
+ self.store.store_episode(episode)
253
+ logger.info("Captured episode %s for agent %s", episode.id, episode.agent_id)
254
+ except Exception as exc: # pragma: no cover
255
+ logger.warning("Failed to persist episode %s: %s", episode.id, exc)
256
+
257
+ return episode
258
+
259
+
260
+ _default_capture: Optional[EpisodeCapture] = None
261
+
262
+
263
+ def get_capture() -> EpisodeCapture:
264
+ """Return a process-wide singleton capture hook."""
265
+ global _default_capture
266
+ if _default_capture is None:
267
+ _default_capture = EpisodeCapture()
268
+ return _default_capture
269
+
270
+
271
+ __all__ = ["CaptureContext", "EpisodeCapture", "get_capture", "redact"]
@@ -0,0 +1,37 @@
1
+ """Pure-Python classifiers for RL reward shaping pipelines.
2
+
3
+ The reward shaper in :mod:`agent_learning.rewards.shaping` combines
4
+ multiple classifier outputs into a scalar reward. This sub-package
5
+ ships the classifier implementations themselves:
6
+
7
+ - :class:`agent_learning.classifiers.router.RouterClassifier` —
8
+ multi-class classifier over a context vector. Used to route an
9
+ incoming request to one of a known set of class ids before the
10
+ policy chooses an action.
11
+ - :class:`agent_learning.classifiers.scorers.intent.IntentScorer`,
12
+ :class:`~agent_learning.classifiers.scorers.adherence.AdherenceScorer`,
13
+ and
14
+ :class:`~agent_learning.classifiers.scorers.completion.CompletionScorer`
15
+ — binary ``{pass, fail}`` classifiers over a ``(context, action)``
16
+ pair. Drop-in replacements for LLM-based scorers with the same
17
+ call surface.
18
+
19
+ All classes here are deterministic, dependency-free (stdlib only),
20
+ and JSON-serialisable via ``to_snapshot`` / ``from_snapshot``. The
21
+ fit loop is a plain mini-batch logistic regression so the
22
+ classifiers are reproducible across environments and easy to ship
23
+ through the same image as the rest of the SDK.
24
+ """
25
+
26
+ from .base import Classifier, ClassifierResult
27
+ from .router import RouterClassifier
28
+ from .scorers import AdherenceScorer, CompletionScorer, IntentScorer
29
+
30
+ __all__ = [
31
+ "AdherenceScorer",
32
+ "Classifier",
33
+ "ClassifierResult",
34
+ "CompletionScorer",
35
+ "IntentScorer",
36
+ "RouterClassifier",
37
+ ]
@@ -0,0 +1,185 @@
1
+ """Base types shared by the router and the three scorers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+ from typing import Dict, List, Protocol
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ClassifierResult:
12
+ """Output of any classifier in this package.
13
+
14
+ ``label`` is the predicted class id (a ``str`` for the router,
15
+ ``"pass"`` / ``"fail"`` for the scorers). ``confidence`` is the
16
+ model's calibrated probability for that label, in ``[0, 1]``.
17
+ ``features`` exposes the per-feature contribution so callers can
18
+ persist a per-decision explainability trace.
19
+ """
20
+
21
+ label: str
22
+ confidence: float
23
+ features: Dict[str, float] = field(default_factory=dict)
24
+
25
+
26
+ class Classifier(Protocol):
27
+ """Common surface across the router and the three scorers.
28
+
29
+ Implementations are deterministic — given the same fit input
30
+ they produce the same weights, and given the same predict
31
+ input they produce the same :class:`ClassifierResult`.
32
+ """
33
+
34
+ def fit(self, training_rows: List[dict]) -> "Classifier":
35
+ ...
36
+
37
+ def predict(self, features: Dict[str, float]) -> ClassifierResult:
38
+ ...
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Pure-Python logistic regression primitives shared by the four classifiers
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ def _sigmoid(x: float) -> float:
47
+ """Numerically stable logistic function."""
48
+ if x >= 0.0:
49
+ z = math.exp(-x)
50
+ return 1.0 / (1.0 + z)
51
+ z = math.exp(x)
52
+ return z / (1.0 + z)
53
+
54
+
55
+ def _softmax(logits: List[float]) -> List[float]:
56
+ """Numerically stable softmax over ``logits``."""
57
+ if not logits:
58
+ return []
59
+ m = max(logits)
60
+ exps = [math.exp(v - m) for v in logits]
61
+ s = sum(exps)
62
+ if s == 0.0:
63
+ n = len(exps)
64
+ return [1.0 / n] * n
65
+ return [v / s for v in exps]
66
+
67
+
68
+ def _dot(weights: List[float], features: List[float]) -> float:
69
+ """Inner product of two equal-length vectors."""
70
+ total = 0.0
71
+ for w, x in zip(weights, features):
72
+ total += w * x
73
+ return total
74
+
75
+
76
+ def fit_binary_logreg(
77
+ rows: List[dict],
78
+ feature_dim: int,
79
+ *,
80
+ epochs: int = 20,
81
+ learning_rate: float = 0.10,
82
+ weight_decay: float = 1e-4,
83
+ batch_size: int = 256,
84
+ seed: int = 42,
85
+ ) -> List[float]:
86
+ """Fit a binary logistic regression with closed-form gradient.
87
+
88
+ Each row in ``rows`` must carry:
89
+
90
+ - ``"features"``: list[float] of length ``feature_dim + 1`` (the
91
+ last entry is the bias term — caller appends 1.0).
92
+ - ``"label"``: ``0`` or ``1``.
93
+
94
+ Returns a weight vector of length ``feature_dim + 1``. Uses
95
+ standard mini-batch gradient descent with an L2 penalty; pure
96
+ Python, no numpy.
97
+ """
98
+ import random as _random
99
+
100
+ rng = _random.Random(seed)
101
+ n = feature_dim + 1 # bias column appended by caller
102
+ weights = [0.0] * n
103
+ indices = list(range(len(rows)))
104
+ if not indices:
105
+ return weights
106
+
107
+ for _epoch in range(epochs):
108
+ rng.shuffle(indices)
109
+ for start in range(0, len(indices), batch_size):
110
+ batch = indices[start:start + batch_size]
111
+ grad = [0.0] * n
112
+ for i in batch:
113
+ row = rows[i]
114
+ x = row["features"]
115
+ y = float(row["label"])
116
+ z = _dot(weights, x)
117
+ p = _sigmoid(z)
118
+ err = p - y
119
+ for j in range(n):
120
+ grad[j] += err * x[j]
121
+ inv_b = 1.0 / max(len(batch), 1)
122
+ for j in range(n):
123
+ grad[j] = grad[j] * inv_b + weight_decay * weights[j]
124
+ weights[j] -= learning_rate * grad[j]
125
+ return weights
126
+
127
+
128
+ def fit_multinomial_logreg(
129
+ rows: List[dict],
130
+ feature_dim: int,
131
+ num_classes: int,
132
+ *,
133
+ epochs: int = 20,
134
+ learning_rate: float = 0.10,
135
+ weight_decay: float = 1e-4,
136
+ batch_size: int = 256,
137
+ seed: int = 42,
138
+ ) -> List[List[float]]:
139
+ """Fit a multinomial logistic regression (softmax classifier).
140
+
141
+ Each row in ``rows`` must carry:
142
+
143
+ - ``"features"``: list[float] of length ``feature_dim + 1``.
144
+ - ``"label"``: integer in ``[0, num_classes)``.
145
+
146
+ Returns a ``num_classes x (feature_dim + 1)`` weight matrix.
147
+ """
148
+ import random as _random
149
+
150
+ rng = _random.Random(seed)
151
+ n = feature_dim + 1
152
+ weights: List[List[float]] = [[0.0] * n for _ in range(num_classes)]
153
+ indices = list(range(len(rows)))
154
+ if not indices:
155
+ return weights
156
+
157
+ for _epoch in range(epochs):
158
+ rng.shuffle(indices)
159
+ for start in range(0, len(indices), batch_size):
160
+ batch = indices[start:start + batch_size]
161
+ grads: List[List[float]] = [[0.0] * n for _ in range(num_classes)]
162
+ for i in batch:
163
+ row = rows[i]
164
+ x = row["features"]
165
+ y = int(row["label"])
166
+ logits = [_dot(weights[k], x) for k in range(num_classes)]
167
+ probs = _softmax(logits)
168
+ for k in range(num_classes):
169
+ err = probs[k] - (1.0 if k == y else 0.0)
170
+ for j in range(n):
171
+ grads[k][j] += err * x[j]
172
+ inv_b = 1.0 / max(len(batch), 1)
173
+ for k in range(num_classes):
174
+ for j in range(n):
175
+ grads[k][j] = grads[k][j] * inv_b + weight_decay * weights[k][j]
176
+ weights[k][j] -= learning_rate * grads[k][j]
177
+ return weights
178
+
179
+
180
+ __all__ = [
181
+ "Classifier",
182
+ "ClassifierResult",
183
+ "fit_binary_logreg",
184
+ "fit_multinomial_logreg",
185
+ ]