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.
Files changed (114) hide show
  1. sensor_modeling/__init__.py +45 -0
  2. sensor_modeling/alerts/__init__.py +26 -0
  3. sensor_modeling/alerts/alert.py +532 -0
  4. sensor_modeling/analysis/__init__.py +43 -0
  5. sensor_modeling/analysis/_frame.py +19 -0
  6. sensor_modeling/analysis/behavioral_analysis.py +57 -0
  7. sensor_modeling/analysis/behavioral_metrics.py +66 -0
  8. sensor_modeling/analysis/comparison.py +164 -0
  9. sensor_modeling/analysis/dependency_network.py +408 -0
  10. sensor_modeling/analysis/granger_causality.py +314 -0
  11. sensor_modeling/analysis/pipeline.py +168 -0
  12. sensor_modeling/analysis/reporting.py +109 -0
  13. sensor_modeling/baseline/__init__.py +30 -0
  14. sensor_modeling/baseline/adaptive.py +520 -0
  15. sensor_modeling/baseline/features.py +224 -0
  16. sensor_modeling/change_point/__init__.py +13 -0
  17. sensor_modeling/change_point/_validation.py +31 -0
  18. sensor_modeling/change_point/adaptive_normalization.py +55 -0
  19. sensor_modeling/change_point/embedding_cpd.py +60 -0
  20. sensor_modeling/change_point/energy_efficient.py +57 -0
  21. sensor_modeling/change_point/genetic_optimization.py +65 -0
  22. sensor_modeling/cli.py +416 -0
  23. sensor_modeling/context/__init__.py +33 -0
  24. sensor_modeling/context/occupancy.py +529 -0
  25. sensor_modeling/data/__init__.py +5 -0
  26. sensor_modeling/data/loaders.py +146 -0
  27. sensor_modeling/data/preprocessing.py +83 -0
  28. sensor_modeling/data/synthetic.py +121 -0
  29. sensor_modeling/data/validation.py +81 -0
  30. sensor_modeling/evaluation/__init__.py +92 -0
  31. sensor_modeling/evaluation/ablation.py +303 -0
  32. sensor_modeling/evaluation/attribution.py +474 -0
  33. sensor_modeling/evaluation/detection.py +297 -0
  34. sensor_modeling/evaluation/metrics.py +541 -0
  35. sensor_modeling/evaluation/provenance.py +309 -0
  36. sensor_modeling/examples/__init__.py +1 -0
  37. sensor_modeling/examples/demos/__init__.py +1 -0
  38. sensor_modeling/examples/demos/ambient_pipeline_demo.py +418 -0
  39. sensor_modeling/examples/demos/bernoulli_ar_demo.py +356 -0
  40. sensor_modeling/examples/demos/cpd_ar_demo.py +25 -0
  41. sensor_modeling/examples/demos/cpd_benchmark.py +42 -0
  42. sensor_modeling/examples/demos/hmm_granger_demo.py +30 -0
  43. sensor_modeling/examples/demos/nhpp_pelt_demo.py +80 -0
  44. sensor_modeling/examples/tutorials/__init__.py +1 -0
  45. sensor_modeling/fusion/__init__.py +46 -0
  46. sensor_modeling/fusion/defaults.py +296 -0
  47. sensor_modeling/fusion/emissions.py +339 -0
  48. sensor_modeling/fusion/estimate.py +375 -0
  49. sensor_modeling/fusion/filter.py +323 -0
  50. sensor_modeling/health/__init__.py +31 -0
  51. sensor_modeling/health/monitor.py +590 -0
  52. sensor_modeling/health/status.py +74 -0
  53. sensor_modeling/hmm/__init__.py +15 -0
  54. sensor_modeling/hmm/adaptive_hmm.py +22 -0
  55. sensor_modeling/hmm/base.py +134 -0
  56. sensor_modeling/hmm/circadian_hmm.py +22 -0
  57. sensor_modeling/hmm/heterogeneous_hmm.py +22 -0
  58. sensor_modeling/hmm/hierarchical_hmm.py +35 -0
  59. sensor_modeling/hmm/scaled_dirichlet_hmm.py +23 -0
  60. sensor_modeling/interop/__init__.py +57 -0
  61. sensor_modeling/interop/fhir.py +418 -0
  62. sensor_modeling/interop/privacy.py +308 -0
  63. sensor_modeling/models/__init__.py +12 -0
  64. sensor_modeling/models/bernoulli_ar/__init__.py +6 -0
  65. sensor_modeling/models/bernoulli_ar/base_model.py +569 -0
  66. sensor_modeling/models/bernoulli_ar/multivariate_model.py +411 -0
  67. sensor_modeling/models/change_point_detection/__init__.py +10 -0
  68. sensor_modeling/models/change_point_detection/deep.py +65 -0
  69. sensor_modeling/models/change_point_detection/pelt.py +159 -0
  70. sensor_modeling/models/nhpp_pelt/__init__.py +5 -0
  71. sensor_modeling/models/nhpp_pelt/bspline.py +96 -0
  72. sensor_modeling/models/nhpp_pelt/cli.py +243 -0
  73. sensor_modeling/models/nhpp_pelt/diagnostics.py +234 -0
  74. sensor_modeling/models/nhpp_pelt/io.py +58 -0
  75. sensor_modeling/models/nhpp_pelt/model.py +408 -0
  76. sensor_modeling/models/nhpp_pelt/optimizer.py +142 -0
  77. sensor_modeling/models/nhpp_pelt/plotting.py +218 -0
  78. sensor_modeling/models/nhpp_pelt/quad.py +72 -0
  79. sensor_modeling/models/nhpp_pelt/regularization.py +121 -0
  80. sensor_modeling/models/nhpp_pelt/utils.py +174 -0
  81. sensor_modeling/observations/__init__.py +59 -0
  82. sensor_modeling/observations/adapters.py +195 -0
  83. sensor_modeling/observations/ingest.py +269 -0
  84. sensor_modeling/observations/observation.py +270 -0
  85. sensor_modeling/observations/registry.py +262 -0
  86. sensor_modeling/observations/stream.py +342 -0
  87. sensor_modeling/observations/types.py +107 -0
  88. sensor_modeling/observations/units.py +117 -0
  89. sensor_modeling/online/__init__.py +36 -0
  90. sensor_modeling/online/benchmarks.py +242 -0
  91. sensor_modeling/online/pipeline.py +485 -0
  92. sensor_modeling/simulation/__init__.py +54 -0
  93. sensor_modeling/simulation/faults.py +191 -0
  94. sensor_modeling/simulation/household.py +862 -0
  95. sensor_modeling/states/__init__.py +23 -0
  96. sensor_modeling/states/markov.py +105 -0
  97. sensor_modeling/states/ontology.py +238 -0
  98. sensor_modeling/utils/__init__.py +41 -0
  99. sensor_modeling/utils/data_io.py +199 -0
  100. sensor_modeling/utils/logging_config.py +10 -0
  101. sensor_modeling/utils/missing.py +188 -0
  102. sensor_modeling/utils/plotting.py +98 -0
  103. sensor_modeling/utils/validation.py +117 -0
  104. sensor_modeling/visualization/__init__.py +3 -0
  105. sensor_modeling/visualization/clinical.py +67 -0
  106. sensor_modeling/visualization/interactive.py +208 -0
  107. sensor_modeling/visualization/research.py +60 -0
  108. sensor_modeling/visualization/web_app.py +137 -0
  109. sensor_modeling-0.2.0.dist-info/METADATA +683 -0
  110. sensor_modeling-0.2.0.dist-info/RECORD +114 -0
  111. sensor_modeling-0.2.0.dist-info/WHEEL +5 -0
  112. sensor_modeling-0.2.0.dist-info/entry_points.txt +18 -0
  113. sensor_modeling-0.2.0.dist-info/licenses/LICENSE +21 -0
  114. 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
+ }