margin 0.9.18__tar.gz → 0.9.20__tar.gz
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.
Potentially problematic release.
This version of margin might be problematic. Click here for more details.
- {margin-0.9.18/margin.egg-info → margin-0.9.20}/PKG-INFO +1 -1
- {margin-0.9.18 → margin-0.9.20}/margin/controller.py +67 -0
- margin-0.9.20/margin/fingerprint.py +583 -0
- {margin-0.9.18 → margin-0.9.20}/margin/provenance.py +39 -5
- {margin-0.9.18 → margin-0.9.20}/margin/streaming.py +128 -0
- {margin-0.9.18 → margin-0.9.20/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.18 → margin-0.9.20}/margin.egg-info/SOURCES.txt +2 -0
- {margin-0.9.18 → margin-0.9.20}/pyproject.toml +1 -1
- margin-0.9.20/tests/test_fingerprint_ext.py +444 -0
- margin-0.9.20/tests/test_pipeline_features.py +471 -0
- margin-0.9.18/margin/fingerprint.py +0 -206
- {margin-0.9.18 → margin-0.9.20}/LICENSE +0 -0
- {margin-0.9.18 → margin-0.9.20}/NOTICE +0 -0
- {margin-0.9.18 → margin-0.9.20}/README.md +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/__init__.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/__main__.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/algebra.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/anomaly.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/bridge.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/calibrate.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/causal.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/composite.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/confidence.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/config.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/contract.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/correlate.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/diff.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/drift.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/events.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/forecast.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/health.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/intent.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/ledger.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/loop.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/observation.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/persist.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/policy/__init__.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/policy/compose.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/policy/core.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/policy/temporal.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/policy/trace.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/policy/tuning.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/policy/validate.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/predicates.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/transitions.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/uncertain.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin/validity.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.18 → margin-0.9.20}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.18 → margin-0.9.20}/setup.cfg +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_algebra.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_anomaly.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_bridge.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_calibrate.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_causal.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_composite.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_confidence.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_config.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_contract.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_controller.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_correlate.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_diff.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_drift.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_events.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_forecast.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_health.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_healthcare.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_intent.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_ledger.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_loop.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_neuro.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_observation.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_persist.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_policy.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_policy_compose.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_policy_trace.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_policy_validate.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_predicates.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_printer3d.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_provenance.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_ros2.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_staleness.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_streaming.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_transformer.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_transitions.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_uncertain.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_validity.py +0 -0
- {margin-0.9.18 → margin-0.9.20}/tests/test_windowing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: margin
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.20
|
|
4
4
|
Summary: Typed health classification, uncertainty algebra, and correction auditing for any system that measures things and needs to explain what happened.
|
|
5
5
|
Author: Cope Labs LLC
|
|
6
6
|
License: MIT
|
|
@@ -124,6 +124,7 @@ class Controller:
|
|
|
124
124
|
self.alpha_min = alpha_min
|
|
125
125
|
self.alpha_max = alpha_max
|
|
126
126
|
self._from_fingerprint: bool = False
|
|
127
|
+
self.feature_weights: Optional[list[float]] = None
|
|
127
128
|
|
|
128
129
|
# ------------------------------------------------------------------
|
|
129
130
|
# Factory
|
|
@@ -260,6 +261,72 @@ class Controller:
|
|
|
260
261
|
metric = self._metric(observations)
|
|
261
262
|
return self._apply(alpha, metric, a_min, a_max)
|
|
262
263
|
|
|
264
|
+
def step_from_features(
|
|
265
|
+
self,
|
|
266
|
+
alpha: float,
|
|
267
|
+
feature_vector: list[float],
|
|
268
|
+
weights: Optional[list[float]] = None,
|
|
269
|
+
alpha_min: Optional[float] = None,
|
|
270
|
+
alpha_max: Optional[float] = None,
|
|
271
|
+
) -> tuple[float, str]:
|
|
272
|
+
"""
|
|
273
|
+
Compute the next alpha from a raw feature vector.
|
|
274
|
+
|
|
275
|
+
Converts ``feature_vector`` to a scalar metric via weighted dot product,
|
|
276
|
+
then runs the normal control law. This enables a soft, continuous
|
|
277
|
+
priority score (e.g. ``[t025_score, community_similarity]``) instead
|
|
278
|
+
of a hard binary threshold gate.
|
|
279
|
+
|
|
280
|
+
``metric = dot(weights, feature_vector)``
|
|
281
|
+
|
|
282
|
+
If ``weights`` is None, falls back to ``self.feature_weights`` (set via
|
|
283
|
+
:meth:`with_feature_weights`), then to equal weights (mean of features).
|
|
284
|
+
|
|
285
|
+
Parameters
|
|
286
|
+
----------
|
|
287
|
+
alpha: current alpha value
|
|
288
|
+
feature_vector: list of floats [f0, f1, ..., fN]
|
|
289
|
+
weights: per-call weight override; None = use stored weights
|
|
290
|
+
alpha_min: per-call lower clamp override
|
|
291
|
+
alpha_max: per-call upper clamp override
|
|
292
|
+
|
|
293
|
+
Example::
|
|
294
|
+
|
|
295
|
+
ctrl = Controller(strategy="proportional_setpoint", kp=0.3, target=0.5)
|
|
296
|
+
ctrl = ctrl.with_feature_weights([0.6, 0.4])
|
|
297
|
+
alpha, reason = ctrl.step_from_features(
|
|
298
|
+
alpha, [t025_score, community_sim]
|
|
299
|
+
)
|
|
300
|
+
"""
|
|
301
|
+
a_min = alpha_min if alpha_min is not None else self.alpha_min
|
|
302
|
+
a_max = alpha_max if alpha_max is not None else self.alpha_max
|
|
303
|
+
|
|
304
|
+
_weights = weights or self.feature_weights
|
|
305
|
+
if _weights is not None:
|
|
306
|
+
n = min(len(_weights), len(feature_vector))
|
|
307
|
+
metric = sum(_weights[i] * feature_vector[i] for i in range(n))
|
|
308
|
+
else:
|
|
309
|
+
metric = sum(feature_vector) / len(feature_vector) if feature_vector else 0.0
|
|
310
|
+
|
|
311
|
+
reason_prefix = f"features({len(feature_vector)}): metric={metric:+.3f} "
|
|
312
|
+
alpha_next, inner_reason = self._apply(alpha, metric, a_min, a_max)
|
|
313
|
+
return alpha_next, reason_prefix + inner_reason
|
|
314
|
+
|
|
315
|
+
def with_feature_weights(self, weights: list[float]) -> "Controller":
|
|
316
|
+
"""
|
|
317
|
+
Return a copy of this controller with ``feature_weights`` set.
|
|
318
|
+
|
|
319
|
+
Does not mutate the original::
|
|
320
|
+
|
|
321
|
+
ctrl = Controller(strategy="proportional_setpoint", kp=0.3, target=0.5,
|
|
322
|
+
alpha_min=0.0, alpha_max=1.0)
|
|
323
|
+
ctrl = ctrl.with_feature_weights([0.6, 0.4])
|
|
324
|
+
"""
|
|
325
|
+
import copy
|
|
326
|
+
clone = copy.copy(self)
|
|
327
|
+
clone.feature_weights = list(weights)
|
|
328
|
+
return clone
|
|
329
|
+
|
|
263
330
|
def step_normalized(
|
|
264
331
|
self,
|
|
265
332
|
alpha: float,
|
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fingerprint: session statistics with noise-resistant target queries.
|
|
3
|
+
|
|
4
|
+
Monitor.fingerprint() returns a Fingerprint instead of a plain dict.
|
|
5
|
+
Subclasses dict — fully JSON-serializable, isinstance(fp, dict) is True,
|
|
6
|
+
and fp["component"]["mean"] still works unchanged.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _sorted_vals(values: list[float]) -> list[float]:
|
|
16
|
+
return sorted(values)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _percentile(values: list[float], p: float) -> float:
|
|
20
|
+
"""p-th percentile (0–100) via linear interpolation."""
|
|
21
|
+
if not values:
|
|
22
|
+
return 0.0
|
|
23
|
+
s = _sorted_vals(values)
|
|
24
|
+
n = len(s)
|
|
25
|
+
if n == 1:
|
|
26
|
+
return s[0]
|
|
27
|
+
idx = (p / 100.0) * (n - 1)
|
|
28
|
+
lo = int(idx)
|
|
29
|
+
hi = min(lo + 1, n - 1)
|
|
30
|
+
frac = idx - lo
|
|
31
|
+
return s[lo] * (1.0 - frac) + s[hi] * frac
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _trimmed_mean(values: list[float], fraction: float = 0.1) -> float:
|
|
35
|
+
"""Mean after removing the top and bottom `fraction` of values."""
|
|
36
|
+
if not values:
|
|
37
|
+
return 0.0
|
|
38
|
+
s = _sorted_vals(values)
|
|
39
|
+
n = len(s)
|
|
40
|
+
k = max(1, int(n * fraction))
|
|
41
|
+
trimmed = s[k:-k] if 2 * k < n else s
|
|
42
|
+
return sum(trimmed) / len(trimmed)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Fingerprint(dict):
|
|
46
|
+
"""
|
|
47
|
+
Session statistics from Monitor.fingerprint().
|
|
48
|
+
|
|
49
|
+
Subclasses ``dict`` — fully JSON-serializable without a custom encoder::
|
|
50
|
+
|
|
51
|
+
fp["cpu"]["mean"] # works
|
|
52
|
+
json.dumps(fp) # works — serializes as {name: {mean, std, n, trend}}
|
|
53
|
+
isinstance(fp, dict) # True
|
|
54
|
+
|
|
55
|
+
Noise-resistant queries::
|
|
56
|
+
|
|
57
|
+
fp.robust_target("cpu") # median (default)
|
|
58
|
+
fp.robust_target("cpu", "trimmed") # 10% trimmed mean
|
|
59
|
+
fp.percentile("cpu", 25) # 25th percentile
|
|
60
|
+
|
|
61
|
+
Cross-session normalization::
|
|
62
|
+
|
|
63
|
+
fp.sigma("cpu", value) # z-score: (value − mean) / std
|
|
64
|
+
fp.robust_sigma("cpu", value) # (value − median) / IQR
|
|
65
|
+
|
|
66
|
+
Neural conditioning::
|
|
67
|
+
|
|
68
|
+
fp.to_tensor(["rr", "improvement"], ["mean", "std"]) # flat list
|
|
69
|
+
fp.to_tensor(..., format="numpy") # np.ndarray
|
|
70
|
+
fp.to_tensor(..., format="torch") # torch.Tensor
|
|
71
|
+
|
|
72
|
+
Session comparison::
|
|
73
|
+
|
|
74
|
+
fp.distance(other_fp) # L2 in flattened (mean,std) space
|
|
75
|
+
fp.kl_divergence(other_fp) # symmetric KL of Gaussian components
|
|
76
|
+
|
|
77
|
+
Online update::
|
|
78
|
+
|
|
79
|
+
fp.update("recovery_ratio", 0.12) # Welford incremental update
|
|
80
|
+
|
|
81
|
+
Multi-session aggregation::
|
|
82
|
+
|
|
83
|
+
fp.merge(other_fp, weight=0.5) # weighted average of two fingerprints
|
|
84
|
+
|
|
85
|
+
The raw ``_values`` dict stores per-component float lists from the drift
|
|
86
|
+
window, enabling true median / percentile without re-scanning trackers.
|
|
87
|
+
Raw values are ephemeral (not included in JSON / to_dict output).
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
stats: dict[str, dict],
|
|
93
|
+
values: Optional[dict[str, list[float]]] = None,
|
|
94
|
+
):
|
|
95
|
+
super().__init__(stats)
|
|
96
|
+
self._values: dict[str, list[float]] = values or {}
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Inspection
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def metrics(self) -> list[str]:
|
|
104
|
+
"""Sorted list of components with at least one observation (n > 0)."""
|
|
105
|
+
return [k for k in sorted(self.keys()) if self.get(k, {}).get("n", 0) > 0]
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Noise-resistant queries
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def robust_target(self, component: str, method: str = "median") -> float:
|
|
112
|
+
"""
|
|
113
|
+
Noise-resistant estimate of an achievable target for ``component``.
|
|
114
|
+
|
|
115
|
+
``method="median"`` — median of observed values (default; robust to outliers)
|
|
116
|
+
``method="trimmed"`` — 10% trimmed mean
|
|
117
|
+
``method="mean"`` — plain mean (same as ``fp[component]["mean"]``)
|
|
118
|
+
|
|
119
|
+
When no raw values are stored, checks for pre-computed ``"median"`` in
|
|
120
|
+
the stats dict (present when fingerprint came from ``Monitor.fingerprint()``),
|
|
121
|
+
then falls back to ``"mean"``.
|
|
122
|
+
"""
|
|
123
|
+
vals = self._values.get(component)
|
|
124
|
+
if not vals:
|
|
125
|
+
stats = self.get(component, {})
|
|
126
|
+
if method == "median":
|
|
127
|
+
return stats.get("median", stats.get("mean", 0.0))
|
|
128
|
+
return stats.get("mean", 0.0)
|
|
129
|
+
if method == "median":
|
|
130
|
+
return _percentile(vals, 50)
|
|
131
|
+
if method == "trimmed":
|
|
132
|
+
return _trimmed_mean(vals, fraction=0.1)
|
|
133
|
+
# "mean" or unknown
|
|
134
|
+
return sum(vals) / len(vals)
|
|
135
|
+
|
|
136
|
+
def percentile(self, component: str, p: float) -> float:
|
|
137
|
+
"""
|
|
138
|
+
Return the p-th percentile (0–100) of observed values for ``component``.
|
|
139
|
+
|
|
140
|
+
When no raw values are stored, checks for pre-computed ``"q25"`` / ``"q75"`` /
|
|
141
|
+
``"median"`` in the stats dict (present when fingerprint came from
|
|
142
|
+
``Monitor.fingerprint()``), then falls back to ``"mean"``.
|
|
143
|
+
"""
|
|
144
|
+
vals = self._values.get(component)
|
|
145
|
+
if not vals:
|
|
146
|
+
stats = self.get(component, {})
|
|
147
|
+
if p == 25 and "q25" in stats:
|
|
148
|
+
return stats["q25"]
|
|
149
|
+
if p == 75 and "q75" in stats:
|
|
150
|
+
return stats["q75"]
|
|
151
|
+
if p == 50 and "median" in stats:
|
|
152
|
+
return stats["median"]
|
|
153
|
+
return stats.get("mean", 0.0)
|
|
154
|
+
return _percentile(vals, p)
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
# Cross-session normalization
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def sigma(self, component: str, value: float) -> float:
|
|
161
|
+
"""
|
|
162
|
+
Z-score of ``value`` against the fingerprint's empirical distribution.
|
|
163
|
+
|
|
164
|
+
Returns ``(value − mean) / std`` (standard z-score).
|
|
165
|
+
|
|
166
|
+
Using ``std`` as denominator is correct for cross-session normalization.
|
|
167
|
+
``|mean|`` would only be appropriate when the coefficient of variation
|
|
168
|
+
(std/|mean|) is small; for high-CV components (e.g. ``std=0.498``,
|
|
169
|
+
``mean=0.070``, CV=7.1) it amplifies by 7×, turning a normal ±0.07
|
|
170
|
+
swing into ±1.0 and saturating the controller.
|
|
171
|
+
|
|
172
|
+
Falls back to ``(value − mean) / |mean|`` when ``std`` is zero (constant
|
|
173
|
+
window or not yet stored), and returns ``value`` unchanged when both
|
|
174
|
+
``mean`` and ``std`` are zero.
|
|
175
|
+
"""
|
|
176
|
+
stats = self.get(component, {})
|
|
177
|
+
mean = stats.get("mean", 0.0)
|
|
178
|
+
std = stats.get("std", 0.0)
|
|
179
|
+
if std != 0.0:
|
|
180
|
+
return (value - mean) / std
|
|
181
|
+
if mean == 0.0:
|
|
182
|
+
return value
|
|
183
|
+
return (value - mean) / abs(mean)
|
|
184
|
+
|
|
185
|
+
def robust_sigma(self, component: str, value: float) -> float:
|
|
186
|
+
"""
|
|
187
|
+
Normalize ``value`` against the fingerprint's empirical median, scaled by IQR.
|
|
188
|
+
|
|
189
|
+
Returns ``(value − median) / IQR``.
|
|
190
|
+
|
|
191
|
+
More stable than :meth:`sigma` when the distribution has high variance
|
|
192
|
+
or outliers (e.g. ``recovery_ratio`` std=0.498 vs mean=0.070 — the
|
|
193
|
+
mean-based sigma would amplify noise).
|
|
194
|
+
|
|
195
|
+
Falls back to :meth:`sigma` when IQR is zero (constant window or no
|
|
196
|
+
raw values stored).
|
|
197
|
+
"""
|
|
198
|
+
median = self.robust_target(component, "median")
|
|
199
|
+
q25 = self.percentile(component, 25)
|
|
200
|
+
q75 = self.percentile(component, 75)
|
|
201
|
+
iqr = q75 - q25
|
|
202
|
+
if iqr == 0.0:
|
|
203
|
+
return self.sigma(component, value)
|
|
204
|
+
return (value - median) / iqr
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
# Neural conditioning
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def to_tensor(
|
|
211
|
+
self,
|
|
212
|
+
metrics: Optional[list[str]] = None,
|
|
213
|
+
stats: tuple[str, ...] = ("mean", "std"),
|
|
214
|
+
format: str = "list",
|
|
215
|
+
):
|
|
216
|
+
"""
|
|
217
|
+
Export fingerprint as a flat vector for neural conditioning.
|
|
218
|
+
|
|
219
|
+
Returns the values in row-major order:
|
|
220
|
+
``[metric0_stat0, metric0_stat1, metric1_stat0, ...]``
|
|
221
|
+
|
|
222
|
+
Parameters
|
|
223
|
+
----------
|
|
224
|
+
metrics: components to include, in order; ``None`` = ``sorted(self.keys())``
|
|
225
|
+
stats: which stat fields to include per component (default: mean and std)
|
|
226
|
+
format: ``"list"`` (default, no dependencies), ``"numpy"``, or ``"torch"``
|
|
227
|
+
|
|
228
|
+
Example::
|
|
229
|
+
|
|
230
|
+
# Flat 4-element list: [rr_mean, rr_std, imp_mean, imp_std]
|
|
231
|
+
vec = fp.to_tensor(["recovery_ratio", "improvement"], ["mean", "std"])
|
|
232
|
+
|
|
233
|
+
# As torch tensor for D_fp conditioning:
|
|
234
|
+
t = fp.to_tensor(["recovery_ratio", "improvement"], format="torch")
|
|
235
|
+
"""
|
|
236
|
+
_metrics = metrics if metrics is not None else sorted(self.keys())
|
|
237
|
+
values = []
|
|
238
|
+
for m in _metrics:
|
|
239
|
+
s = self.get(m, {})
|
|
240
|
+
for stat in stats:
|
|
241
|
+
values.append(float(s.get(stat, 0.0)))
|
|
242
|
+
|
|
243
|
+
if format == "list":
|
|
244
|
+
return values
|
|
245
|
+
if format == "numpy":
|
|
246
|
+
import numpy as np
|
|
247
|
+
return np.array(values, dtype=np.float32)
|
|
248
|
+
if format == "torch":
|
|
249
|
+
import torch
|
|
250
|
+
return torch.tensor(values, dtype=torch.float32)
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"Unknown format {format!r}. Supported: 'list', 'numpy', 'torch'"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# ------------------------------------------------------------------
|
|
256
|
+
# Session comparison
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def distance(
|
|
260
|
+
self,
|
|
261
|
+
other: "Fingerprint",
|
|
262
|
+
metrics: Optional[list[str]] = None,
|
|
263
|
+
stats: tuple[str, ...] = ("mean", "std"),
|
|
264
|
+
) -> float:
|
|
265
|
+
"""
|
|
266
|
+
L2 distance between two fingerprints in the flattened (mean, std) space.
|
|
267
|
+
|
|
268
|
+
Uses the same vector layout as :meth:`to_tensor` — the distance is
|
|
269
|
+
directly interpretable as distance in the tensor space used for neural
|
|
270
|
+
conditioning.
|
|
271
|
+
|
|
272
|
+
Only components present in both fingerprints are compared (``metrics``
|
|
273
|
+
can restrict further).
|
|
274
|
+
|
|
275
|
+
Example::
|
|
276
|
+
|
|
277
|
+
# Find the Session 1 sentence fingerprint closest to current state
|
|
278
|
+
best = min(session1_fps, key=lambda fp1: live_fp.distance(fp1))
|
|
279
|
+
"""
|
|
280
|
+
_metrics = metrics if metrics is not None else sorted(
|
|
281
|
+
set(self.keys()) & set(other.keys())
|
|
282
|
+
)
|
|
283
|
+
a = self.to_tensor(metrics=_metrics, stats=stats)
|
|
284
|
+
b = other.to_tensor(metrics=_metrics, stats=stats)
|
|
285
|
+
return sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5
|
|
286
|
+
|
|
287
|
+
def kl_divergence(
|
|
288
|
+
self,
|
|
289
|
+
other: "Fingerprint",
|
|
290
|
+
metrics: Optional[list[str]] = None,
|
|
291
|
+
symmetric: bool = True,
|
|
292
|
+
) -> float:
|
|
293
|
+
"""
|
|
294
|
+
KL divergence treating each component as an independent Gaussian N(mean, std²).
|
|
295
|
+
|
|
296
|
+
When ``symmetric=True`` (default), returns the symmetric KL:
|
|
297
|
+
``0.5 * (KL(self || other) + KL(other || self))``.
|
|
298
|
+
|
|
299
|
+
Components with std=0 are skipped (degenerate Gaussians).
|
|
300
|
+
|
|
301
|
+
Example::
|
|
302
|
+
|
|
303
|
+
drift = fp_session1.kl_divergence(fp_session2)
|
|
304
|
+
if drift > 2.0:
|
|
305
|
+
print("sessions differ significantly")
|
|
306
|
+
"""
|
|
307
|
+
_metrics = metrics if metrics is not None else sorted(
|
|
308
|
+
set(self.keys()) & set(other.keys())
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def _kl_gaussians(p_fp: "Fingerprint", q_fp: "Fingerprint") -> float:
|
|
312
|
+
total = 0.0
|
|
313
|
+
for m in _metrics:
|
|
314
|
+
mu_p = p_fp.get(m, {}).get("mean", 0.0)
|
|
315
|
+
sig_p = max(p_fp.get(m, {}).get("std", 0.0), 1e-8)
|
|
316
|
+
mu_q = q_fp.get(m, {}).get("mean", 0.0)
|
|
317
|
+
sig_q = max(q_fp.get(m, {}).get("std", 0.0), 1e-8)
|
|
318
|
+
# KL(N(μ_p,σ_p²) || N(μ_q,σ_q²))
|
|
319
|
+
total += (
|
|
320
|
+
math.log(sig_q / sig_p)
|
|
321
|
+
+ (sig_p ** 2 + (mu_p - mu_q) ** 2) / (2.0 * sig_q ** 2)
|
|
322
|
+
- 0.5
|
|
323
|
+
)
|
|
324
|
+
return total
|
|
325
|
+
|
|
326
|
+
if symmetric:
|
|
327
|
+
return 0.5 * (_kl_gaussians(self, other) + _kl_gaussians(other, self))
|
|
328
|
+
return _kl_gaussians(self, other)
|
|
329
|
+
|
|
330
|
+
def similarity(
|
|
331
|
+
self,
|
|
332
|
+
other: "Fingerprint",
|
|
333
|
+
metrics: Optional[list[str]] = None,
|
|
334
|
+
stats: tuple[str, ...] = ("mean", "std"),
|
|
335
|
+
) -> float:
|
|
336
|
+
"""
|
|
337
|
+
Cosine similarity between two fingerprints in the flattened (mean, std) space.
|
|
338
|
+
|
|
339
|
+
Returns a value in [−1, 1]; 1.0 = identical direction, 0.0 = orthogonal.
|
|
340
|
+
"""
|
|
341
|
+
_metrics = metrics if metrics is not None else sorted(
|
|
342
|
+
set(self.keys()) & set(other.keys())
|
|
343
|
+
)
|
|
344
|
+
a = self.to_tensor(metrics=_metrics, stats=stats)
|
|
345
|
+
b = other.to_tensor(metrics=_metrics, stats=stats)
|
|
346
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
347
|
+
mag_a = sum(x ** 2 for x in a) ** 0.5
|
|
348
|
+
mag_b = sum(x ** 2 for x in b) ** 0.5
|
|
349
|
+
if mag_a == 0.0 or mag_b == 0.0:
|
|
350
|
+
return 0.0
|
|
351
|
+
return dot / (mag_a * mag_b)
|
|
352
|
+
|
|
353
|
+
# ------------------------------------------------------------------
|
|
354
|
+
# Online update
|
|
355
|
+
# ------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
def update(self, component: str, value: float) -> "Fingerprint":
|
|
358
|
+
"""
|
|
359
|
+
Incrementally update statistics for ``component`` using Welford's algorithm.
|
|
360
|
+
|
|
361
|
+
O(1), numerically stable. Updates ``mean``, ``std``, and ``n`` in-place.
|
|
362
|
+
Also appends to the raw values list if one exists (for percentile accuracy).
|
|
363
|
+
|
|
364
|
+
Returns ``self`` for chaining.
|
|
365
|
+
|
|
366
|
+
Example::
|
|
367
|
+
|
|
368
|
+
for obs in live_observations:
|
|
369
|
+
fp.update("recovery_ratio", obs.value)
|
|
370
|
+
"""
|
|
371
|
+
stats = dict(self.get(component) or {
|
|
372
|
+
"mean": 0.0, "std": 0.0, "n": 0, "trend": "UNKNOWN"
|
|
373
|
+
})
|
|
374
|
+
n_old = stats.get("n", 0)
|
|
375
|
+
n_new = n_old + 1
|
|
376
|
+
mean_old = stats.get("mean", 0.0)
|
|
377
|
+
mean_new = mean_old + (value - mean_old) / n_new
|
|
378
|
+
# Reconstruct M2 from stored std²*(n-1), update, recompute std
|
|
379
|
+
m2_old = stats.get("std", 0.0) ** 2 * max(n_old - 1, 0)
|
|
380
|
+
m2_new = m2_old + (value - mean_old) * (value - mean_new)
|
|
381
|
+
std_new = (m2_new / (n_new - 1)) ** 0.5 if n_new >= 2 else 0.0
|
|
382
|
+
stats["mean"] = mean_new
|
|
383
|
+
stats["std"] = std_new
|
|
384
|
+
stats["n"] = n_new
|
|
385
|
+
self[component] = stats
|
|
386
|
+
|
|
387
|
+
if component in self._values:
|
|
388
|
+
self._values[component].append(value)
|
|
389
|
+
|
|
390
|
+
return self
|
|
391
|
+
|
|
392
|
+
# ------------------------------------------------------------------
|
|
393
|
+
# Multi-session aggregation
|
|
394
|
+
# ------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
def merge(self, other: "Fingerprint", weight: float = 0.5) -> "Fingerprint":
|
|
397
|
+
"""
|
|
398
|
+
Weighted combination of two fingerprints.
|
|
399
|
+
|
|
400
|
+
``weight=0.5``: equal mix; ``weight=0.0``: returns copy of self;
|
|
401
|
+
``weight=1.0``: returns copy of other.
|
|
402
|
+
|
|
403
|
+
Mean and std are linearly interpolated; n is summed. Components
|
|
404
|
+
present in only one fingerprint are included unchanged.
|
|
405
|
+
|
|
406
|
+
Example::
|
|
407
|
+
|
|
408
|
+
# Running average of Session 1 and Session 2
|
|
409
|
+
combined = fp_session1.merge(fp_session2, weight=0.5)
|
|
410
|
+
"""
|
|
411
|
+
w = max(0.0, min(1.0, weight))
|
|
412
|
+
all_keys = set(self.keys()) | set(other.keys())
|
|
413
|
+
merged: dict[str, dict] = {}
|
|
414
|
+
for k in sorted(all_keys):
|
|
415
|
+
s = self.get(k) or {}
|
|
416
|
+
o = other.get(k) or {}
|
|
417
|
+
if not s:
|
|
418
|
+
merged[k] = dict(o)
|
|
419
|
+
elif not o:
|
|
420
|
+
merged[k] = dict(s)
|
|
421
|
+
else:
|
|
422
|
+
merged[k] = {
|
|
423
|
+
"mean": s.get("mean", 0.0) * (1 - w) + o.get("mean", 0.0) * w,
|
|
424
|
+
"std": s.get("std", 0.0) * (1 - w) + o.get("std", 0.0) * w,
|
|
425
|
+
"n": s.get("n", 0) + o.get("n", 0),
|
|
426
|
+
"trend": o.get("trend", s.get("trend", "UNKNOWN")),
|
|
427
|
+
}
|
|
428
|
+
# Carry over percentiles if both have them (interpolate)
|
|
429
|
+
for key in ("median", "q25", "q75"):
|
|
430
|
+
if key in s and key in o:
|
|
431
|
+
merged[k][key] = s[key] * (1 - w) + o[key] * w
|
|
432
|
+
return Fingerprint(stats=merged)
|
|
433
|
+
|
|
434
|
+
# ------------------------------------------------------------------
|
|
435
|
+
# Batch construction
|
|
436
|
+
# ------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
@classmethod
|
|
439
|
+
def from_batch(
|
|
440
|
+
cls,
|
|
441
|
+
items,
|
|
442
|
+
feature_fn,
|
|
443
|
+
) -> "Fingerprint":
|
|
444
|
+
"""
|
|
445
|
+
Build a Fingerprint from a batch of items using Welford's algorithm.
|
|
446
|
+
|
|
447
|
+
``feature_fn`` extracts a ``{component: float}`` dict from each item.
|
|
448
|
+
All components seen across the batch are aggregated.
|
|
449
|
+
|
|
450
|
+
Example::
|
|
451
|
+
|
|
452
|
+
# Fingerprint a batch of communities by genus composition
|
|
453
|
+
fp = Fingerprint.from_batch(
|
|
454
|
+
communities,
|
|
455
|
+
feature_fn=lambda c: {"genus_diversity": c.shannon_h,
|
|
456
|
+
"mean_quality": c.mean_t025},
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
Parameters
|
|
460
|
+
----------
|
|
461
|
+
items: iterable of anything
|
|
462
|
+
feature_fn: callable(item) → dict[str, float]
|
|
463
|
+
"""
|
|
464
|
+
result = cls(stats={})
|
|
465
|
+
for item in items:
|
|
466
|
+
features = feature_fn(item)
|
|
467
|
+
for component, value in features.items():
|
|
468
|
+
result.update(component, float(value))
|
|
469
|
+
return result
|
|
470
|
+
|
|
471
|
+
# ------------------------------------------------------------------
|
|
472
|
+
# Ordering / retrieval
|
|
473
|
+
# ------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
@classmethod
|
|
476
|
+
def optimal_ordering(
|
|
477
|
+
cls,
|
|
478
|
+
items: list,
|
|
479
|
+
key_fn,
|
|
480
|
+
metrics: Optional[list[str]] = None,
|
|
481
|
+
stats: tuple[str, ...] = ("mean", "std"),
|
|
482
|
+
) -> list:
|
|
483
|
+
"""
|
|
484
|
+
Reorder ``items`` to maximise cache reuse via greedy nearest-neighbor.
|
|
485
|
+
|
|
486
|
+
``key_fn`` extracts a :class:`Fingerprint` from each item. The
|
|
487
|
+
algorithm starts from the item whose fingerprint is closest to the
|
|
488
|
+
batch centroid, then always picks the unvisited item closest to the
|
|
489
|
+
current one (greedy TSP approximation).
|
|
490
|
+
|
|
491
|
+
This is a classmethod so it can be called without an instance::
|
|
492
|
+
|
|
493
|
+
ordered = Fingerprint.optimal_ordering(batches, key_fn=lambda b: b.fp)
|
|
494
|
+
|
|
495
|
+
Parameters
|
|
496
|
+
----------
|
|
497
|
+
items: list of objects to reorder
|
|
498
|
+
key_fn: callable(item) → Fingerprint
|
|
499
|
+
metrics: restrict distance calculation to these components;
|
|
500
|
+
``None`` = all shared keys between each pair
|
|
501
|
+
stats: which stat fields to use for distance (default: mean and std)
|
|
502
|
+
|
|
503
|
+
Returns
|
|
504
|
+
-------
|
|
505
|
+
Reordered copy of ``items``.
|
|
506
|
+
|
|
507
|
+
Example::
|
|
508
|
+
|
|
509
|
+
ordered_batches = Fingerprint.optimal_ordering(
|
|
510
|
+
batches,
|
|
511
|
+
key_fn=lambda b: b.fingerprint,
|
|
512
|
+
metrics=["genus_diversity", "mean_quality"],
|
|
513
|
+
)
|
|
514
|
+
"""
|
|
515
|
+
if not items:
|
|
516
|
+
return []
|
|
517
|
+
if len(items) == 1:
|
|
518
|
+
return list(items)
|
|
519
|
+
|
|
520
|
+
fps = [key_fn(item) for item in items]
|
|
521
|
+
|
|
522
|
+
# Compute centroid fingerprint from all stat means
|
|
523
|
+
all_keys: set[str] = set()
|
|
524
|
+
for fp in fps:
|
|
525
|
+
all_keys.update(fp.keys())
|
|
526
|
+
_metrics = sorted(metrics) if metrics is not None else sorted(all_keys)
|
|
527
|
+
|
|
528
|
+
centroid_vals: dict[str, dict] = {}
|
|
529
|
+
for m in _metrics:
|
|
530
|
+
vals_m = [fp.get(m, {}).get("mean", 0.0) for fp in fps]
|
|
531
|
+
stds_m = [fp.get(m, {}).get("std", 0.0) for fp in fps]
|
|
532
|
+
centroid_vals[m] = {
|
|
533
|
+
"mean": sum(vals_m) / len(vals_m),
|
|
534
|
+
"std": sum(stds_m) / len(stds_m),
|
|
535
|
+
"n": 1,
|
|
536
|
+
}
|
|
537
|
+
centroid = cls(stats=centroid_vals)
|
|
538
|
+
|
|
539
|
+
# Start from the item closest to the centroid
|
|
540
|
+
start = min(range(len(fps)), key=lambda i: centroid.distance(fps[i], metrics=_metrics, stats=stats))
|
|
541
|
+
|
|
542
|
+
visited = [False] * len(items)
|
|
543
|
+
order = [start]
|
|
544
|
+
visited[start] = True
|
|
545
|
+
|
|
546
|
+
while len(order) < len(items):
|
|
547
|
+
current_fp = fps[order[-1]]
|
|
548
|
+
best_idx, best_dist = -1, float("inf")
|
|
549
|
+
for j, fp_j in enumerate(fps):
|
|
550
|
+
if visited[j]:
|
|
551
|
+
continue
|
|
552
|
+
d = current_fp.distance(fp_j, metrics=_metrics, stats=stats)
|
|
553
|
+
if d < best_dist:
|
|
554
|
+
best_dist = d
|
|
555
|
+
best_idx = j
|
|
556
|
+
order.append(best_idx)
|
|
557
|
+
visited[best_idx] = True
|
|
558
|
+
|
|
559
|
+
return [items[i] for i in order]
|
|
560
|
+
|
|
561
|
+
# ------------------------------------------------------------------
|
|
562
|
+
# Serialization
|
|
563
|
+
# ------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
def n(self, component: str) -> int:
|
|
566
|
+
"""Number of observations for ``component`` in this fingerprint."""
|
|
567
|
+
return self.get(component, {}).get("n", 0)
|
|
568
|
+
|
|
569
|
+
def components(self) -> list[str]:
|
|
570
|
+
"""Sorted list of all component names (including n=0)."""
|
|
571
|
+
return sorted(self.keys())
|
|
572
|
+
|
|
573
|
+
def to_dict(self) -> dict:
|
|
574
|
+
"""Plain dict (stats only; raw values are ephemeral)."""
|
|
575
|
+
return dict(self)
|
|
576
|
+
|
|
577
|
+
@classmethod
|
|
578
|
+
def from_dict(cls, d: dict) -> "Fingerprint":
|
|
579
|
+
"""Deserialize from a plain dict (no raw values; median falls back to mean)."""
|
|
580
|
+
return cls(stats=d)
|
|
581
|
+
|
|
582
|
+
def __repr__(self) -> str:
|
|
583
|
+
return f"Fingerprint({len(self)} components, raw_values={bool(self._values)})"
|