Coverage for src/finbot/models/logistic.py: 85%
177 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 12:05 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 12:05 +0000
1import json
2import math
3from pathlib import Path
4from typing import cast
6from sklearn.linear_model import LogisticRegression
8from finbot.features import FeatureDataset, SupervisedDataset
9from finbot.models.core import (
10 Model,
11 ModelMetadata,
12 ModelScalar,
13 Prediction,
14 TrainingConfiguration,
15)
17MODEL_ARTIFACT_FORMAT_VERSION = 1
18LOGISTIC_REGRESSION_MODEL_TYPE = "logistic_regression"
21class LogisticRegressionModel(Model):
22 """Deterministic binary classifier for positive forward returns."""
24 def __init__(
25 self,
26 *,
27 random_seed: int = 0,
28 c: float = 1.0,
29 max_iter: int = 1_000,
30 ) -> None:
31 if isinstance(random_seed, bool) or not isinstance(random_seed, int):
32 raise ValueError("Random seed must be an integer.")
33 if isinstance(c, bool) or not isinstance(c, (int, float)) or not math.isfinite(c) or c <= 0:
34 raise ValueError("Logistic-regression C must be a finite positive number.")
35 if isinstance(max_iter, bool) or not isinstance(max_iter, int) or max_iter < 1:
36 raise ValueError("Maximum iterations must be a positive integer.")
38 self.random_seed = random_seed
39 self.c = float(c)
40 self.max_iter = max_iter
41 self._metadata: ModelMetadata | None = None
42 self._coefficients: tuple[float, ...] | None = None
43 self._intercept: float | None = None
45 @property
46 def metadata(self) -> ModelMetadata:
47 if self._metadata is None:
48 raise RuntimeError("Model has not been trained.")
49 return self._metadata
51 def train(self, dataset: SupervisedDataset) -> None:
52 feature_schema = self._validate_schema(dataset.feature_names)
53 if not dataset.rows:
54 raise ValueError("Training dataset must not be empty.")
56 features: list[list[float]] = []
57 labels: list[int] = []
58 for index, row in enumerate(dataset.rows):
59 features.append(
60 list(self._validate_values(row.features, feature_schema, context=f"Training row {index}")),
61 )
62 if isinstance(row.target, bool) or not isinstance(row.target, (int, float)): 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true
63 raise ValueError(f"Training target at row {index} must be a number.")
64 target = float(row.target)
65 if not math.isfinite(target):
66 raise ValueError(f"Training target at row {index} must be finite.")
67 labels.append(int(target > 0.0))
69 if len(set(labels)) < 2:
70 raise ValueError("Training targets must contain both positive and non-positive classes.")
72 estimator = LogisticRegression(
73 C=self.c,
74 max_iter=self.max_iter,
75 random_state=self.random_seed,
76 solver="liblinear",
77 )
78 estimator.fit(features, labels)
80 self._coefficients = tuple(float(value) for value in estimator.coef_[0])
81 self._intercept = float(estimator.intercept_[0])
82 self._metadata = ModelMetadata(
83 model_type=LOGISTIC_REGRESSION_MODEL_TYPE,
84 hyperparameters=(
85 ("C", self.c),
86 ("max_iter", self.max_iter),
87 ("solver", "liblinear"),
88 ),
89 feature_schema=feature_schema,
90 random_seed=self.random_seed,
91 training_configuration=TrainingConfiguration(
92 dataset_id=dataset.dataset_id,
93 target_name=dataset.target_name,
94 sample_count=len(dataset.rows),
95 ),
96 )
98 def predict_proba(self, dataset: FeatureDataset) -> tuple[Prediction, ...]:
99 metadata = self.metadata
100 if tuple(dataset.feature_names) != metadata.feature_schema:
101 raise ValueError(
102 "Inference feature schema must exactly match the trained feature names and ordering.",
103 )
104 assert self._coefficients is not None
105 assert self._intercept is not None
107 predictions: list[Prediction] = []
108 for index, row in enumerate(dataset.rows):
109 values = self._validate_values(
110 row.values,
111 metadata.feature_schema,
112 context=f"Inference row {index}",
113 )
114 score = self._intercept + sum(
115 coefficient * value
116 for coefficient, value in zip(self._coefficients, values)
117 )
118 probability = self._sigmoid(score)
119 predictions.append(
120 Prediction(
121 symbol=row.symbol,
122 timestamp=row.timestamp,
123 probability_positive=probability,
124 model_id=metadata.model_id,
125 model_type=metadata.model_type,
126 metadata=(
127 ("training_dataset_id", metadata.training_configuration.dataset_id),
128 ("target_name", metadata.training_configuration.target_name),
129 ),
130 ),
131 )
132 return tuple(predictions)
134 def save(self, path: Path) -> None:
135 metadata = self.metadata
136 assert self._coefficients is not None
137 assert self._intercept is not None
138 payload = {
139 "format_version": MODEL_ARTIFACT_FORMAT_VERSION,
140 "metadata": metadata.to_dict(),
141 "parameters": {
142 "coefficients": list(self._coefficients),
143 "intercept": self._intercept,
144 },
145 }
146 serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False)
147 path.parent.mkdir(parents=True, exist_ok=True)
148 temporary = path.with_suffix(f"{path.suffix}.tmp")
149 temporary.write_text(serialized, encoding="utf-8")
150 temporary.replace(path)
152 @classmethod
153 def load(cls, path: Path) -> "LogisticRegressionModel":
154 try:
155 payload = json.loads(path.read_text(encoding="utf-8"))
156 except FileNotFoundError:
157 raise
158 except (OSError, json.JSONDecodeError) as exc:
159 raise ValueError("Model artifact is not valid JSON.") from exc
161 metadata, coefficients, intercept = cls._parse_artifact(payload)
162 hyperparameters = dict(metadata.hyperparameters)
163 model = cls(
164 random_seed=metadata.random_seed,
165 c=cast(float, hyperparameters["C"]),
166 max_iter=cast(int, hyperparameters["max_iter"]),
167 )
168 model._metadata = metadata
169 model._coefficients = coefficients
170 model._intercept = intercept
171 return model
173 @staticmethod
174 def _validate_schema(feature_names: tuple[str, ...]) -> tuple[str, ...]:
175 if not feature_names:
176 raise ValueError("Training feature schema must not be empty.")
177 if any(not isinstance(name, str) or not name.strip() for name in feature_names): 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true
178 raise ValueError("Training feature names must be non-empty strings.")
179 if len(set(feature_names)) != len(feature_names): 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 raise ValueError("Training feature names must be unique.")
181 return tuple(feature_names)
183 @staticmethod
184 def _validate_values(
185 raw_values: tuple[float | None, ...],
186 feature_schema: tuple[str, ...],
187 *,
188 context: str,
189 ) -> tuple[float, ...]:
190 if len(raw_values) != len(feature_schema): 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true
191 raise ValueError(f"{context} values must match the feature schema.")
192 values: list[float] = []
193 for value in raw_values:
194 if value is None or isinstance(value, bool) or not isinstance(value, (int, float)):
195 raise ValueError(f"{context} features must be finite numbers.")
196 normalized = float(value)
197 if not math.isfinite(normalized):
198 raise ValueError(f"{context} features must be finite numbers.")
199 values.append(normalized)
200 return tuple(values)
202 @staticmethod
203 def _sigmoid(score: float) -> float:
204 if score >= 0:
205 return 1.0 / (1.0 + math.exp(-score))
206 exponential = math.exp(score)
207 return exponential / (1.0 + exponential)
209 @staticmethod
210 def _parse_artifact(payload: object) -> tuple[ModelMetadata, tuple[float, ...], float]:
211 if not isinstance(payload, dict) or payload.get("format_version") != MODEL_ARTIFACT_FORMAT_VERSION:
212 raise ValueError("Unsupported model artifact format version.")
213 raw_metadata = payload.get("metadata")
214 raw_parameters = payload.get("parameters")
215 if not isinstance(raw_metadata, dict) or not isinstance(raw_parameters, dict): 215 ↛ 216line 215 didn't jump to line 216 because the condition on line 215 was never true
216 raise ValueError("Model artifact is missing metadata or parameters.")
217 if raw_metadata.get("model_type") != LOGISTIC_REGRESSION_MODEL_TYPE: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true
218 raise ValueError("Model artifact has an unexpected model type.")
220 raw_hyperparameters = raw_metadata.get("hyperparameters")
221 raw_schema = raw_metadata.get("feature_schema")
222 raw_seed = raw_metadata.get("random_seed")
223 raw_training = raw_metadata.get("training_configuration")
224 if not isinstance(raw_hyperparameters, dict): 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true
225 raise ValueError("Model hyperparameters must be an object.")
226 if set(raw_hyperparameters) != {"C", "max_iter", "solver"} or raw_hyperparameters.get("solver") != "liblinear": 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true
227 raise ValueError("Model artifact contains unsupported hyperparameters.")
228 if not isinstance(raw_schema, list) or not all(isinstance(name, str) for name in raw_schema): 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true
229 raise ValueError("Model feature schema must be a list of names.")
230 feature_schema = LogisticRegressionModel._validate_schema(tuple(raw_schema))
231 if isinstance(raw_seed, bool) or not isinstance(raw_seed, int): 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true
232 raise ValueError("Model random seed must be an integer.")
233 if not isinstance(raw_training, dict): 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 raise ValueError("Model training configuration must be an object.")
235 dataset_id = raw_training.get("dataset_id")
236 target_name = raw_training.get("target_name")
237 sample_count = raw_training.get("sample_count")
238 if not isinstance(dataset_id, str) or not dataset_id or not isinstance(target_name, str) or not target_name: 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true
239 raise ValueError("Model training configuration is invalid.")
240 if isinstance(sample_count, bool) or not isinstance(sample_count, int) or sample_count < 1: 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true
241 raise ValueError("Model training sample count must be positive.")
243 raw_coefficients = raw_parameters.get("coefficients")
244 raw_intercept = raw_parameters.get("intercept")
245 if not isinstance(raw_coefficients, list) or len(raw_coefficients) != len(feature_schema):
246 raise ValueError("Model coefficients must match the feature schema.")
247 coefficients = LogisticRegressionModel._parse_finite_numbers(raw_coefficients, "coefficients")
248 intercept_values = LogisticRegressionModel._parse_finite_numbers([raw_intercept], "intercept")
250 c = raw_hyperparameters.get("C")
251 max_iter = raw_hyperparameters.get("max_iter")
252 model = LogisticRegressionModel(random_seed=raw_seed, c=cast(float, c), max_iter=cast(int, max_iter))
253 metadata = ModelMetadata(
254 model_type=LOGISTIC_REGRESSION_MODEL_TYPE,
255 hyperparameters=model._hyperparameters(),
256 feature_schema=feature_schema,
257 random_seed=raw_seed,
258 training_configuration=TrainingConfiguration(
259 dataset_id=dataset_id,
260 target_name=target_name,
261 sample_count=sample_count,
262 ),
263 )
264 return metadata, coefficients, intercept_values[0]
266 def _hyperparameters(self) -> tuple[tuple[str, ModelScalar], ...]:
267 return (("C", self.c), ("max_iter", self.max_iter), ("solver", "liblinear"))
269 @staticmethod
270 def _parse_finite_numbers(values: list[object], name: str) -> tuple[float, ...]:
271 if any(
272 isinstance(value, bool)
273 or not isinstance(value, (int, float))
274 or not math.isfinite(value)
275 for value in values
276 ):
277 raise ValueError(f"Model {name} must contain finite numbers.")
278 return tuple(float(value) for value in values)
281def load_model(path: Path) -> Model:
282 """Load a supported model artifact from disk."""
283 try:
284 payload = json.loads(path.read_text(encoding="utf-8"))
285 except FileNotFoundError:
286 raise
287 except (OSError, json.JSONDecodeError) as exc:
288 raise ValueError("Model artifact is not valid JSON.") from exc
289 if not isinstance(payload, dict) or not isinstance(payload.get("metadata"), dict): 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true
290 raise ValueError("Model artifact is missing metadata.")
291 if payload["metadata"].get("model_type") == LOGISTIC_REGRESSION_MODEL_TYPE:
292 return LogisticRegressionModel.load(path)
293 raise ValueError("Model artifact uses an unsupported model type.")