sensor-modeling 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sensor_modeling/__init__.py +45 -0
- sensor_modeling/alerts/__init__.py +26 -0
- sensor_modeling/alerts/alert.py +532 -0
- sensor_modeling/analysis/__init__.py +43 -0
- sensor_modeling/analysis/_frame.py +19 -0
- sensor_modeling/analysis/behavioral_analysis.py +57 -0
- sensor_modeling/analysis/behavioral_metrics.py +66 -0
- sensor_modeling/analysis/comparison.py +164 -0
- sensor_modeling/analysis/dependency_network.py +408 -0
- sensor_modeling/analysis/granger_causality.py +314 -0
- sensor_modeling/analysis/pipeline.py +168 -0
- sensor_modeling/analysis/reporting.py +109 -0
- sensor_modeling/baseline/__init__.py +30 -0
- sensor_modeling/baseline/adaptive.py +520 -0
- sensor_modeling/baseline/features.py +224 -0
- sensor_modeling/change_point/__init__.py +13 -0
- sensor_modeling/change_point/_validation.py +31 -0
- sensor_modeling/change_point/adaptive_normalization.py +55 -0
- sensor_modeling/change_point/embedding_cpd.py +60 -0
- sensor_modeling/change_point/energy_efficient.py +57 -0
- sensor_modeling/change_point/genetic_optimization.py +65 -0
- sensor_modeling/cli.py +416 -0
- sensor_modeling/context/__init__.py +33 -0
- sensor_modeling/context/occupancy.py +529 -0
- sensor_modeling/data/__init__.py +5 -0
- sensor_modeling/data/loaders.py +146 -0
- sensor_modeling/data/preprocessing.py +83 -0
- sensor_modeling/data/synthetic.py +121 -0
- sensor_modeling/data/validation.py +81 -0
- sensor_modeling/evaluation/__init__.py +92 -0
- sensor_modeling/evaluation/ablation.py +303 -0
- sensor_modeling/evaluation/attribution.py +474 -0
- sensor_modeling/evaluation/detection.py +297 -0
- sensor_modeling/evaluation/metrics.py +541 -0
- sensor_modeling/evaluation/provenance.py +309 -0
- sensor_modeling/examples/__init__.py +1 -0
- sensor_modeling/examples/demos/__init__.py +1 -0
- sensor_modeling/examples/demos/ambient_pipeline_demo.py +418 -0
- sensor_modeling/examples/demos/bernoulli_ar_demo.py +356 -0
- sensor_modeling/examples/demos/cpd_ar_demo.py +25 -0
- sensor_modeling/examples/demos/cpd_benchmark.py +42 -0
- sensor_modeling/examples/demos/hmm_granger_demo.py +30 -0
- sensor_modeling/examples/demos/nhpp_pelt_demo.py +80 -0
- sensor_modeling/examples/tutorials/__init__.py +1 -0
- sensor_modeling/fusion/__init__.py +46 -0
- sensor_modeling/fusion/defaults.py +296 -0
- sensor_modeling/fusion/emissions.py +339 -0
- sensor_modeling/fusion/estimate.py +375 -0
- sensor_modeling/fusion/filter.py +323 -0
- sensor_modeling/health/__init__.py +31 -0
- sensor_modeling/health/monitor.py +590 -0
- sensor_modeling/health/status.py +74 -0
- sensor_modeling/hmm/__init__.py +15 -0
- sensor_modeling/hmm/adaptive_hmm.py +22 -0
- sensor_modeling/hmm/base.py +134 -0
- sensor_modeling/hmm/circadian_hmm.py +22 -0
- sensor_modeling/hmm/heterogeneous_hmm.py +22 -0
- sensor_modeling/hmm/hierarchical_hmm.py +35 -0
- sensor_modeling/hmm/scaled_dirichlet_hmm.py +23 -0
- sensor_modeling/interop/__init__.py +57 -0
- sensor_modeling/interop/fhir.py +418 -0
- sensor_modeling/interop/privacy.py +308 -0
- sensor_modeling/models/__init__.py +12 -0
- sensor_modeling/models/bernoulli_ar/__init__.py +6 -0
- sensor_modeling/models/bernoulli_ar/base_model.py +569 -0
- sensor_modeling/models/bernoulli_ar/multivariate_model.py +411 -0
- sensor_modeling/models/change_point_detection/__init__.py +10 -0
- sensor_modeling/models/change_point_detection/deep.py +65 -0
- sensor_modeling/models/change_point_detection/pelt.py +159 -0
- sensor_modeling/models/nhpp_pelt/__init__.py +5 -0
- sensor_modeling/models/nhpp_pelt/bspline.py +96 -0
- sensor_modeling/models/nhpp_pelt/cli.py +243 -0
- sensor_modeling/models/nhpp_pelt/diagnostics.py +234 -0
- sensor_modeling/models/nhpp_pelt/io.py +58 -0
- sensor_modeling/models/nhpp_pelt/model.py +408 -0
- sensor_modeling/models/nhpp_pelt/optimizer.py +142 -0
- sensor_modeling/models/nhpp_pelt/plotting.py +218 -0
- sensor_modeling/models/nhpp_pelt/quad.py +72 -0
- sensor_modeling/models/nhpp_pelt/regularization.py +121 -0
- sensor_modeling/models/nhpp_pelt/utils.py +174 -0
- sensor_modeling/observations/__init__.py +59 -0
- sensor_modeling/observations/adapters.py +195 -0
- sensor_modeling/observations/ingest.py +269 -0
- sensor_modeling/observations/observation.py +270 -0
- sensor_modeling/observations/registry.py +262 -0
- sensor_modeling/observations/stream.py +342 -0
- sensor_modeling/observations/types.py +107 -0
- sensor_modeling/observations/units.py +117 -0
- sensor_modeling/online/__init__.py +36 -0
- sensor_modeling/online/benchmarks.py +242 -0
- sensor_modeling/online/pipeline.py +485 -0
- sensor_modeling/simulation/__init__.py +54 -0
- sensor_modeling/simulation/faults.py +191 -0
- sensor_modeling/simulation/household.py +862 -0
- sensor_modeling/states/__init__.py +23 -0
- sensor_modeling/states/markov.py +105 -0
- sensor_modeling/states/ontology.py +238 -0
- sensor_modeling/utils/__init__.py +41 -0
- sensor_modeling/utils/data_io.py +199 -0
- sensor_modeling/utils/logging_config.py +10 -0
- sensor_modeling/utils/missing.py +188 -0
- sensor_modeling/utils/plotting.py +98 -0
- sensor_modeling/utils/validation.py +117 -0
- sensor_modeling/visualization/__init__.py +3 -0
- sensor_modeling/visualization/clinical.py +67 -0
- sensor_modeling/visualization/interactive.py +208 -0
- sensor_modeling/visualization/research.py +60 -0
- sensor_modeling/visualization/web_app.py +137 -0
- sensor_modeling-0.2.0.dist-info/METADATA +683 -0
- sensor_modeling-0.2.0.dist-info/RECORD +114 -0
- sensor_modeling-0.2.0.dist-info/WHEEL +5 -0
- sensor_modeling-0.2.0.dist-info/entry_points.txt +18 -0
- sensor_modeling-0.2.0.dist-info/licenses/LICENSE +21 -0
- sensor_modeling-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""Evaluation metrics appropriate to each inference problem.
|
|
2
|
+
|
|
3
|
+
Accuracy alone is close to useless here. The states are heavily imbalanced --
|
|
4
|
+
a resident is asleep or quietly at home for most of the day -- so a model that
|
|
5
|
+
predicts ``home_inactive`` forever scores well and knows nothing. And because
|
|
6
|
+
every stage of this platform reports a probability, an evaluation that only
|
|
7
|
+
looks at the argmax throws away exactly the part that matters: whether a
|
|
8
|
+
confidence of 0.9 means anything.
|
|
9
|
+
|
|
10
|
+
So the metrics here cover four separate questions:
|
|
11
|
+
|
|
12
|
+
.. code-block:: text
|
|
13
|
+
|
|
14
|
+
state inference balanced accuracy, macro F1, log loss, Brier,
|
|
15
|
+
calibration error, transition timing
|
|
16
|
+
attribution precision, recall, F1, calibration
|
|
17
|
+
change detection detection delay, false positives per person-day
|
|
18
|
+
comparison paired differences with bootstrap intervals and
|
|
19
|
+
effect sizes, not p-values alone
|
|
20
|
+
|
|
21
|
+
The abstention convention is stated once and applied consistently: a reported
|
|
22
|
+
``UNKNOWN`` is never correct, because ``UNKNOWN`` is never a true label. It
|
|
23
|
+
therefore costs recall. Selective accuracy and abstention rate are reported
|
|
24
|
+
alongside so that a model which declines usefully can be distinguished from
|
|
25
|
+
one that is simply wrong.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import math
|
|
31
|
+
from collections.abc import Mapping, Sequence
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from datetime import datetime, timedelta
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
from ..fusion.estimate import StateEstimate
|
|
38
|
+
from ..states.ontology import BehaviouralState, StateOntology
|
|
39
|
+
|
|
40
|
+
EPSILON = 1e-12
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class StateMetrics:
|
|
45
|
+
"""Quality of a run of behavioural state estimates.
|
|
46
|
+
|
|
47
|
+
Attributes
|
|
48
|
+
----------
|
|
49
|
+
n
|
|
50
|
+
Number of scored estimates.
|
|
51
|
+
accuracy
|
|
52
|
+
Fraction correct, counting abstentions as errors.
|
|
53
|
+
selective_accuracy
|
|
54
|
+
Fraction correct among the estimates that did commit to a state.
|
|
55
|
+
abstention_rate
|
|
56
|
+
Fraction of estimates that declined to name a state.
|
|
57
|
+
balanced_accuracy
|
|
58
|
+
Mean per-class recall, which is what stops a model that always
|
|
59
|
+
predicts the majority state from looking good.
|
|
60
|
+
macro_f1
|
|
61
|
+
Unweighted mean F1 across the classes present in the truth.
|
|
62
|
+
log_loss
|
|
63
|
+
Mean negative log probability assigned to the true state.
|
|
64
|
+
brier
|
|
65
|
+
Multiclass Brier score of the full posterior, in ``[0, 2]``.
|
|
66
|
+
calibration_error
|
|
67
|
+
Expected calibration error: how far stated confidence is from
|
|
68
|
+
observed accuracy.
|
|
69
|
+
per_class_recall
|
|
70
|
+
Recall for each state present in the truth.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
n: int
|
|
74
|
+
accuracy: float
|
|
75
|
+
selective_accuracy: float
|
|
76
|
+
abstention_rate: float
|
|
77
|
+
balanced_accuracy: float
|
|
78
|
+
macro_f1: float
|
|
79
|
+
log_loss: float
|
|
80
|
+
brier: float
|
|
81
|
+
calibration_error: float
|
|
82
|
+
per_class_recall: dict[BehaviouralState, float]
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> dict[str, object]:
|
|
85
|
+
"""Return a serialisable form of the metrics."""
|
|
86
|
+
return {
|
|
87
|
+
"n": self.n,
|
|
88
|
+
"accuracy": self.accuracy,
|
|
89
|
+
"selective_accuracy": self.selective_accuracy,
|
|
90
|
+
"abstention_rate": self.abstention_rate,
|
|
91
|
+
"balanced_accuracy": self.balanced_accuracy,
|
|
92
|
+
"macro_f1": self.macro_f1,
|
|
93
|
+
"log_loss": self.log_loss,
|
|
94
|
+
"brier": self.brier,
|
|
95
|
+
"calibration_error": self.calibration_error,
|
|
96
|
+
"per_class_recall": {
|
|
97
|
+
state.value: value for state, value in self.per_class_recall.items()
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _expected_calibration_error(
|
|
103
|
+
confidences: np.ndarray, correct: np.ndarray, bins: int = 10
|
|
104
|
+
) -> float:
|
|
105
|
+
"""Return the bin-weighted gap between confidence and accuracy."""
|
|
106
|
+
if confidences.size == 0:
|
|
107
|
+
return 0.0
|
|
108
|
+
edges = np.linspace(0.0, 1.0, bins + 1)
|
|
109
|
+
total = 0.0
|
|
110
|
+
for low, high in zip(edges[:-1], edges[1:]):
|
|
111
|
+
in_bin = (confidences > low) & (confidences <= high)
|
|
112
|
+
if not in_bin.any():
|
|
113
|
+
continue
|
|
114
|
+
weight = in_bin.mean()
|
|
115
|
+
total += weight * abs(correct[in_bin].mean() - confidences[in_bin].mean())
|
|
116
|
+
return float(total)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def state_metrics(
|
|
120
|
+
truth: Sequence[BehaviouralState | None],
|
|
121
|
+
estimates: Sequence[StateEstimate],
|
|
122
|
+
*,
|
|
123
|
+
calibration_bins: int = 10,
|
|
124
|
+
) -> StateMetrics:
|
|
125
|
+
"""Score a run of state estimates against known labels.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
truth
|
|
130
|
+
True state at each estimate, or ``None`` where no truth exists.
|
|
131
|
+
Unlabelled positions are skipped rather than guessed at.
|
|
132
|
+
estimates
|
|
133
|
+
The corresponding estimates, in the same order.
|
|
134
|
+
calibration_bins
|
|
135
|
+
Number of confidence bins used for the calibration error.
|
|
136
|
+
|
|
137
|
+
Raises
|
|
138
|
+
------
|
|
139
|
+
ValueError
|
|
140
|
+
If the two sequences differ in length, or nothing is labelled.
|
|
141
|
+
"""
|
|
142
|
+
if len(truth) != len(estimates):
|
|
143
|
+
raise ValueError("truth and estimates must be the same length")
|
|
144
|
+
pairs = [
|
|
145
|
+
(actual, estimate)
|
|
146
|
+
for actual, estimate in zip(truth, estimates)
|
|
147
|
+
if actual is not None
|
|
148
|
+
]
|
|
149
|
+
if not pairs:
|
|
150
|
+
raise ValueError("no labelled estimates to score")
|
|
151
|
+
|
|
152
|
+
ontology: StateOntology = pairs[0][1].ontology
|
|
153
|
+
states = ontology.states
|
|
154
|
+
index = {state: position for position, state in enumerate(states)}
|
|
155
|
+
|
|
156
|
+
reported = [estimate.state for _, estimate in pairs]
|
|
157
|
+
actuals = [actual for actual, _ in pairs]
|
|
158
|
+
beliefs = np.vstack([estimate.belief for _, estimate in pairs])
|
|
159
|
+
confidences = np.array([estimate.confidence for _, estimate in pairs])
|
|
160
|
+
argmax_correct = np.array(
|
|
161
|
+
[actual is estimate.most_likely for actual, estimate in pairs],
|
|
162
|
+
dtype=float,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
correct = sum(1 for actual, said in zip(actuals, reported) if actual is said)
|
|
166
|
+
abstained = sum(1 for said in reported if said is BehaviouralState.UNKNOWN)
|
|
167
|
+
decided = len(pairs) - abstained
|
|
168
|
+
|
|
169
|
+
truth_positions = np.array(
|
|
170
|
+
[index[actual] for actual in actuals if actual in index], dtype=int
|
|
171
|
+
)
|
|
172
|
+
scored = np.array([actual in index for actual in actuals], dtype=bool)
|
|
173
|
+
true_probability = np.full(len(pairs), EPSILON)
|
|
174
|
+
true_probability[scored] = beliefs[scored, truth_positions]
|
|
175
|
+
log_loss = float(-np.log(np.maximum(true_probability, EPSILON)).mean())
|
|
176
|
+
|
|
177
|
+
one_hot = np.zeros_like(beliefs)
|
|
178
|
+
one_hot[np.arange(len(pairs))[scored], truth_positions] = 1.0
|
|
179
|
+
brier = float(((beliefs - one_hot) ** 2).sum(axis=1).mean())
|
|
180
|
+
|
|
181
|
+
recalls: dict[BehaviouralState, float] = {}
|
|
182
|
+
f1_scores: list[float] = []
|
|
183
|
+
for state in sorted({a for a in actuals}, key=lambda s: s.value):
|
|
184
|
+
actual_count = sum(1 for a in actuals if a is state)
|
|
185
|
+
hits = sum(
|
|
186
|
+
1 for a, said in zip(actuals, reported) if a is state and said is state
|
|
187
|
+
)
|
|
188
|
+
predicted_count = sum(1 for said in reported if said is state)
|
|
189
|
+
recall = hits / actual_count if actual_count else 0.0
|
|
190
|
+
precision = hits / predicted_count if predicted_count else 0.0
|
|
191
|
+
recalls[state] = recall
|
|
192
|
+
f1_scores.append(
|
|
193
|
+
2 * precision * recall / (precision + recall)
|
|
194
|
+
if precision + recall > 0
|
|
195
|
+
else 0.0
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return StateMetrics(
|
|
199
|
+
n=len(pairs),
|
|
200
|
+
accuracy=correct / len(pairs),
|
|
201
|
+
selective_accuracy=(
|
|
202
|
+
sum(
|
|
203
|
+
1
|
|
204
|
+
for actual, said in zip(actuals, reported)
|
|
205
|
+
if said is not BehaviouralState.UNKNOWN and actual is said
|
|
206
|
+
)
|
|
207
|
+
/ decided
|
|
208
|
+
if decided
|
|
209
|
+
else 0.0
|
|
210
|
+
),
|
|
211
|
+
abstention_rate=abstained / len(pairs),
|
|
212
|
+
balanced_accuracy=(float(np.mean(list(recalls.values()))) if recalls else 0.0),
|
|
213
|
+
macro_f1=float(np.mean(f1_scores)) if f1_scores else 0.0,
|
|
214
|
+
log_loss=log_loss,
|
|
215
|
+
brier=brier,
|
|
216
|
+
calibration_error=_expected_calibration_error(
|
|
217
|
+
confidences, argmax_correct, calibration_bins
|
|
218
|
+
),
|
|
219
|
+
per_class_recall=recalls,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True)
|
|
224
|
+
class TimingMetrics:
|
|
225
|
+
"""How closely inferred state changes line up with real ones."""
|
|
226
|
+
|
|
227
|
+
true_transitions: int
|
|
228
|
+
matched: int
|
|
229
|
+
median_error: float
|
|
230
|
+
mean_absolute_error: float
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def matched_fraction(self) -> float:
|
|
234
|
+
"""Fraction of true transitions that were matched at all."""
|
|
235
|
+
if self.true_transitions == 0:
|
|
236
|
+
return 0.0
|
|
237
|
+
return self.matched / self.true_transitions
|
|
238
|
+
|
|
239
|
+
def to_dict(self) -> dict[str, object]:
|
|
240
|
+
"""Return a serialisable form of the metrics."""
|
|
241
|
+
return {
|
|
242
|
+
"true_transitions": self.true_transitions,
|
|
243
|
+
"matched": self.matched,
|
|
244
|
+
"matched_fraction": self.matched_fraction,
|
|
245
|
+
"median_error_seconds": self.median_error,
|
|
246
|
+
"mean_absolute_error_seconds": self.mean_absolute_error,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def transition_timing(
|
|
251
|
+
truth_times: Sequence[datetime],
|
|
252
|
+
inferred_times: Sequence[datetime],
|
|
253
|
+
*,
|
|
254
|
+
tolerance: timedelta = timedelta(minutes=30),
|
|
255
|
+
) -> TimingMetrics:
|
|
256
|
+
"""Compare when state changes truly happened to when they were inferred.
|
|
257
|
+
|
|
258
|
+
Each true transition is matched to the nearest inferred one within
|
|
259
|
+
*tolerance*, and each inferred transition may be used only once, so a
|
|
260
|
+
model that flickers rapidly cannot match every truth by accident.
|
|
261
|
+
"""
|
|
262
|
+
remaining = sorted(inferred_times)
|
|
263
|
+
errors: list[float] = []
|
|
264
|
+
for moment in sorted(truth_times):
|
|
265
|
+
if not remaining:
|
|
266
|
+
break
|
|
267
|
+
deltas = [abs((candidate - moment).total_seconds()) for candidate in remaining]
|
|
268
|
+
best = int(np.argmin(deltas))
|
|
269
|
+
if deltas[best] <= tolerance.total_seconds():
|
|
270
|
+
errors.append((remaining[best] - moment).total_seconds())
|
|
271
|
+
remaining.pop(best)
|
|
272
|
+
return TimingMetrics(
|
|
273
|
+
true_transitions=len(truth_times),
|
|
274
|
+
matched=len(errors),
|
|
275
|
+
median_error=float(np.median(errors)) if errors else float("nan"),
|
|
276
|
+
mean_absolute_error=float(np.mean(np.abs(errors))) if errors else float("nan"),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True)
|
|
281
|
+
class BinaryMetrics:
|
|
282
|
+
"""Quality of a probabilistic binary judgement, such as attribution."""
|
|
283
|
+
|
|
284
|
+
n: int
|
|
285
|
+
precision: float
|
|
286
|
+
recall: float
|
|
287
|
+
f1: float
|
|
288
|
+
calibration_error: float
|
|
289
|
+
positive_rate: float
|
|
290
|
+
|
|
291
|
+
def to_dict(self) -> dict[str, object]:
|
|
292
|
+
"""Return a serialisable form of the metrics."""
|
|
293
|
+
return {
|
|
294
|
+
"n": self.n,
|
|
295
|
+
"precision": self.precision,
|
|
296
|
+
"recall": self.recall,
|
|
297
|
+
"f1": self.f1,
|
|
298
|
+
"calibration_error": self.calibration_error,
|
|
299
|
+
"positive_rate": self.positive_rate,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def binary_metrics(
|
|
304
|
+
truth: Sequence[bool],
|
|
305
|
+
probabilities: Sequence[float],
|
|
306
|
+
*,
|
|
307
|
+
threshold: float = 0.5,
|
|
308
|
+
calibration_bins: int = 10,
|
|
309
|
+
) -> BinaryMetrics:
|
|
310
|
+
"""Score probabilistic binary judgements such as visitor presence."""
|
|
311
|
+
if len(truth) != len(probabilities):
|
|
312
|
+
raise ValueError("truth and probabilities must be the same length")
|
|
313
|
+
if not truth:
|
|
314
|
+
raise ValueError("no observations to score")
|
|
315
|
+
|
|
316
|
+
actual = np.array(truth, dtype=bool)
|
|
317
|
+
scores = np.asarray(probabilities, dtype=float)
|
|
318
|
+
predicted = scores >= threshold
|
|
319
|
+
|
|
320
|
+
true_positive = int((predicted & actual).sum())
|
|
321
|
+
predicted_positive = int(predicted.sum())
|
|
322
|
+
actual_positive = int(actual.sum())
|
|
323
|
+
precision = true_positive / predicted_positive if predicted_positive else 0.0
|
|
324
|
+
recall = true_positive / actual_positive if actual_positive else 0.0
|
|
325
|
+
|
|
326
|
+
# Calibration is scored on the stated probability of the predicted class,
|
|
327
|
+
# so that a confident "no visitor" is judged as strictly as a confident
|
|
328
|
+
# "visitor".
|
|
329
|
+
confidence = np.where(predicted, scores, 1.0 - scores)
|
|
330
|
+
hits = (predicted == actual).astype(float)
|
|
331
|
+
|
|
332
|
+
return BinaryMetrics(
|
|
333
|
+
n=len(truth),
|
|
334
|
+
precision=precision,
|
|
335
|
+
recall=recall,
|
|
336
|
+
f1=(
|
|
337
|
+
2 * precision * recall / (precision + recall)
|
|
338
|
+
if precision + recall > 0
|
|
339
|
+
else 0.0
|
|
340
|
+
),
|
|
341
|
+
calibration_error=_expected_calibration_error(
|
|
342
|
+
confidence, hits, calibration_bins
|
|
343
|
+
),
|
|
344
|
+
positive_rate=float(actual.mean()),
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@dataclass(frozen=True)
|
|
349
|
+
class DetectionMetrics:
|
|
350
|
+
"""Quality of behavioural change detection over a monitoring period."""
|
|
351
|
+
|
|
352
|
+
true_changes: int
|
|
353
|
+
detected: int
|
|
354
|
+
false_positives: int
|
|
355
|
+
person_days: float
|
|
356
|
+
delays_days: tuple[float, ...]
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def recall(self) -> float:
|
|
360
|
+
"""Fraction of real changes that were detected in time."""
|
|
361
|
+
return self.detected / self.true_changes if self.true_changes else 0.0
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def precision(self) -> float:
|
|
365
|
+
"""Fraction of raised alerts that corresponded to a real change."""
|
|
366
|
+
total = self.detected + self.false_positives
|
|
367
|
+
return self.detected / total if total else 0.0
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def false_positives_per_person_day(self) -> float:
|
|
371
|
+
"""Alert burden, the number that decides whether a system is usable."""
|
|
372
|
+
return self.false_positives / self.person_days if self.person_days else 0.0
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def median_delay_days(self) -> float:
|
|
376
|
+
"""Median days between a change occurring and being reported."""
|
|
377
|
+
return float(np.median(self.delays_days)) if self.delays_days else float("nan")
|
|
378
|
+
|
|
379
|
+
def to_dict(self) -> dict[str, object]:
|
|
380
|
+
"""Return a serialisable form of the metrics."""
|
|
381
|
+
return {
|
|
382
|
+
"true_changes": self.true_changes,
|
|
383
|
+
"detected": self.detected,
|
|
384
|
+
"false_positives": self.false_positives,
|
|
385
|
+
"person_days": self.person_days,
|
|
386
|
+
"recall": self.recall,
|
|
387
|
+
"precision": self.precision,
|
|
388
|
+
"false_positives_per_person_day": self.false_positives_per_person_day,
|
|
389
|
+
"median_delay_days": self.median_delay_days,
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def detection_metrics(
|
|
394
|
+
detected_days: Sequence[object],
|
|
395
|
+
true_change_days: Sequence[object],
|
|
396
|
+
*,
|
|
397
|
+
person_days: float,
|
|
398
|
+
max_delay_days: float = 14.0,
|
|
399
|
+
) -> DetectionMetrics:
|
|
400
|
+
"""Score change detections against known change points.
|
|
401
|
+
|
|
402
|
+
A detection counts only if it falls at or after the true change and
|
|
403
|
+
within *max_delay_days*. Anything else is a false positive, including a
|
|
404
|
+
detection that precedes the change: a system that alarms before anything
|
|
405
|
+
happened has not detected it early, it has alarmed at noise.
|
|
406
|
+
"""
|
|
407
|
+
if person_days <= 0:
|
|
408
|
+
raise ValueError("person_days must be positive")
|
|
409
|
+
|
|
410
|
+
unclaimed = sorted(detected_days) # type: ignore[type-var]
|
|
411
|
+
delays: list[float] = []
|
|
412
|
+
matched: set[int] = set()
|
|
413
|
+
|
|
414
|
+
for change in sorted(true_change_days): # type: ignore[type-var]
|
|
415
|
+
for position, moment in enumerate(unclaimed):
|
|
416
|
+
if position in matched:
|
|
417
|
+
continue
|
|
418
|
+
delay = (moment - change).days # type: ignore[operator]
|
|
419
|
+
if 0 <= delay <= max_delay_days:
|
|
420
|
+
delays.append(float(delay))
|
|
421
|
+
matched.add(position)
|
|
422
|
+
break
|
|
423
|
+
|
|
424
|
+
return DetectionMetrics(
|
|
425
|
+
true_changes=len(true_change_days),
|
|
426
|
+
detected=len(delays),
|
|
427
|
+
false_positives=len(unclaimed) - len(matched),
|
|
428
|
+
person_days=float(person_days),
|
|
429
|
+
delays_days=tuple(delays),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@dataclass(frozen=True)
|
|
434
|
+
class PairedDifference:
|
|
435
|
+
"""A paired comparison between two methods on the same trajectories."""
|
|
436
|
+
|
|
437
|
+
n: int
|
|
438
|
+
mean_difference: float
|
|
439
|
+
ci_low: float
|
|
440
|
+
ci_high: float
|
|
441
|
+
effect_size: float
|
|
442
|
+
wins: int
|
|
443
|
+
losses: int
|
|
444
|
+
mcse: float = 0.0
|
|
445
|
+
"""Monte Carlo standard error of the mean difference.
|
|
446
|
+
|
|
447
|
+
The precision the replication count bought. An interval without one cannot
|
|
448
|
+
be told apart from an interval that is narrow only because the sample was
|
|
449
|
+
small; see docs/SIMULATION_PROTOCOLS.md for choosing n from a target MCSE.
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
@property
|
|
453
|
+
def excludes_zero(self) -> bool:
|
|
454
|
+
"""Whether the interval excludes no difference at all."""
|
|
455
|
+
return self.ci_low > 0.0 or self.ci_high < 0.0
|
|
456
|
+
|
|
457
|
+
def to_dict(self) -> dict[str, object]:
|
|
458
|
+
"""Return a serialisable form of the comparison."""
|
|
459
|
+
return {
|
|
460
|
+
"n": self.n,
|
|
461
|
+
"mean_difference": self.mean_difference,
|
|
462
|
+
"ci_low": self.ci_low,
|
|
463
|
+
"ci_high": self.ci_high,
|
|
464
|
+
"effect_size": self.effect_size,
|
|
465
|
+
"wins": self.wins,
|
|
466
|
+
"losses": self.losses,
|
|
467
|
+
"mcse": self.mcse,
|
|
468
|
+
"excludes_zero": self.excludes_zero,
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def paired_difference(
|
|
473
|
+
treatment: Sequence[float],
|
|
474
|
+
control: Sequence[float],
|
|
475
|
+
*,
|
|
476
|
+
confidence: float = 0.95,
|
|
477
|
+
resamples: int = 2000,
|
|
478
|
+
seed: int = 0,
|
|
479
|
+
) -> PairedDifference:
|
|
480
|
+
"""Compare two methods evaluated on the same simulated trajectories.
|
|
481
|
+
|
|
482
|
+
Pairing matters more than it might seem. Simulated households differ from
|
|
483
|
+
each other far more than two sensor configurations differ on one
|
|
484
|
+
household, so an unpaired comparison buries a real effect under
|
|
485
|
+
between-household variance. Every ablation in this package therefore
|
|
486
|
+
evaluates all configurations on identical trajectories, and this function
|
|
487
|
+
is what preserves that structure in the analysis.
|
|
488
|
+
|
|
489
|
+
Reports a bootstrap interval and a standardised effect size rather than a
|
|
490
|
+
p-value: with simulations, any effect can be made "significant" simply by
|
|
491
|
+
running more seeds, so the size of the difference and its uncertainty are
|
|
492
|
+
the informative quantities.
|
|
493
|
+
"""
|
|
494
|
+
if len(treatment) != len(control):
|
|
495
|
+
raise ValueError("paired comparison requires equal-length sequences")
|
|
496
|
+
if len(treatment) < 2:
|
|
497
|
+
raise ValueError("paired comparison requires at least two pairs")
|
|
498
|
+
if not 0.0 < confidence < 1.0:
|
|
499
|
+
raise ValueError("confidence must lie in (0, 1)")
|
|
500
|
+
if resamples < 100:
|
|
501
|
+
raise ValueError("resamples must be at least 100")
|
|
502
|
+
|
|
503
|
+
differences = np.asarray(treatment, dtype=float) - np.asarray(control, dtype=float)
|
|
504
|
+
if not np.all(np.isfinite(differences)):
|
|
505
|
+
raise ValueError("paired values must be finite")
|
|
506
|
+
|
|
507
|
+
rng = np.random.default_rng(seed)
|
|
508
|
+
draws = rng.choice(differences, size=(resamples, differences.size), replace=True)
|
|
509
|
+
means = draws.mean(axis=1)
|
|
510
|
+
tail = (1.0 - confidence) / 2.0
|
|
511
|
+
spread = float(differences.std(ddof=1))
|
|
512
|
+
|
|
513
|
+
return PairedDifference(
|
|
514
|
+
n=differences.size,
|
|
515
|
+
mean_difference=float(differences.mean()),
|
|
516
|
+
ci_low=float(np.quantile(means, tail)),
|
|
517
|
+
ci_high=float(np.quantile(means, 1.0 - tail)),
|
|
518
|
+
effect_size=(
|
|
519
|
+
float(differences.mean() / spread)
|
|
520
|
+
if spread > 0
|
|
521
|
+
else (0.0 if math.isclose(float(differences.mean()), 0.0) else math.inf)
|
|
522
|
+
),
|
|
523
|
+
wins=int((differences > 0).sum()),
|
|
524
|
+
losses=int((differences < 0).sum()),
|
|
525
|
+
mcse=float(spread / math.sqrt(differences.size)) if spread > 0 else 0.0,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def summarise(values: Mapping[str, Sequence[float]]) -> dict[str, dict[str, float]]:
|
|
530
|
+
"""Return mean, standard deviation, and range for each named series."""
|
|
531
|
+
return {
|
|
532
|
+
name: {
|
|
533
|
+
"mean": float(np.mean(series)),
|
|
534
|
+
"sd": float(np.std(series, ddof=1)) if len(series) > 1 else 0.0,
|
|
535
|
+
"min": float(np.min(series)),
|
|
536
|
+
"max": float(np.max(series)),
|
|
537
|
+
"n": float(len(series)),
|
|
538
|
+
}
|
|
539
|
+
for name, series in values.items()
|
|
540
|
+
if len(series) > 0
|
|
541
|
+
}
|