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,474 @@
1
+ """Measuring what person attribution is worth.
2
+
3
+ Most ambient monitoring implicitly attributes every event to the monitored
4
+ resident. This module makes that assumption testable by running the same
5
+ simulated household twice on identical trajectories -- once attributing all
6
+ ambient activity to the resident, once discounting it by the probability that
7
+ the resident actually generated it -- and reporting the difference.
8
+
9
+ The comparison is paired by construction. Both arms see the same household,
10
+ the same visitors and the same sensor record, so any difference is
11
+ attributable to attribution and to nothing else.
12
+
13
+ A negative result is meaningful here. If occupancy-aware attribution changes
14
+ nothing, either contamination is negligible in this simulator or the occupancy
15
+ model is too weak to exploit it, and both are worth knowing.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from collections.abc import Iterable, Sequence
22
+ from dataclasses import dataclass, field, replace
23
+ from datetime import timedelta
24
+ from typing import Any
25
+
26
+ import numpy as np
27
+
28
+ from ..online.pipeline import BehaviouralSensingPipeline, PipelineConfig
29
+ from ..simulation.faults import DegradationConfig, degrade, dropout, not_worn
30
+ from ..simulation.household import HouseholdConfig, simulate
31
+ from .metrics import BinaryMetrics, StateMetrics, binary_metrics, state_metrics
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Scenario:
38
+ """A named occupancy situation to evaluate attribution against."""
39
+
40
+ name: str
41
+ household: HouseholdConfig
42
+ degradation: DegradationConfig | None = None
43
+ description: str = ""
44
+
45
+ def build(self) -> Any:
46
+ """Simulate the scenario and return the result plus its record."""
47
+ result = simulate(self.household)
48
+ observations: Sequence[Any] = result.observations
49
+ if self.degradation is not None:
50
+ observations, _ = degrade(observations, self.degradation)
51
+ return result, observations
52
+
53
+
54
+ def standard_scenarios(days: int = 10, seed: int = 4242) -> list[Scenario]:
55
+ """Build the occupancy situations attribution has to cope with.
56
+
57
+ Each isolates one way in which ambient activity can fail to belong to the
58
+ monitored resident, or one way the evidence for deciding that can be lost.
59
+ """
60
+ base = HouseholdConfig(days=days, seed=seed)
61
+ start = simulate(HouseholdConfig(days=1, seed=seed)).start
62
+
63
+ return [
64
+ Scenario(
65
+ "resident_alone",
66
+ replace(base, visitor_probability=0.0, carer_weekday_visits=False),
67
+ description="No other person ever enters. Attribution should be a no-op.",
68
+ ),
69
+ Scenario(
70
+ "resident_goes_out",
71
+ replace(
72
+ base,
73
+ visitor_probability=0.0,
74
+ carer_weekday_visits=False,
75
+ outing_probability=0.95,
76
+ ),
77
+ description="The resident is frequently away; ambient events then "
78
+ "belong to nobody.",
79
+ ),
80
+ Scenario(
81
+ "short_visitor",
82
+ replace(base, visitor_probability=0.5, carer_weekday_visits=False),
83
+ description="Occasional short social visits.",
84
+ ),
85
+ Scenario(
86
+ "prolonged_visitor",
87
+ replace(base, visitor_probability=1.0, carer_weekday_visits=False),
88
+ description="A visitor present on every day of the record.",
89
+ ),
90
+ Scenario(
91
+ "carer_visits",
92
+ replace(base, visitor_probability=0.0, carer_weekday_visits=True),
93
+ description="A regular weekday carer round, the case most likely "
94
+ "to be mistaken for the resident rising early.",
95
+ ),
96
+ Scenario(
97
+ "visitor_and_carer",
98
+ replace(base, visitor_probability=0.6, carer_weekday_visits=True),
99
+ description="Both, so ambient sensors across several rooms are "
100
+ "contaminated.",
101
+ ),
102
+ Scenario(
103
+ "resident_without_wearable",
104
+ base,
105
+ DegradationConfig(
106
+ faults=not_worn(
107
+ ["wearable_motion", "resident_beacon"], start, timedelta(days=days)
108
+ ),
109
+ seed=seed + 1,
110
+ ),
111
+ description="The strongest attribution evidence is unavailable for "
112
+ "the whole record.",
113
+ ),
114
+ Scenario(
115
+ "no_radar",
116
+ base,
117
+ DegradationConfig(
118
+ faults=(dropout("living_radar", start, timedelta(days=days)),),
119
+ seed=seed + 2,
120
+ ),
121
+ description="No track count, so multi-person evidence is limited to "
122
+ "concurrent room activity.",
123
+ ),
124
+ Scenario(
125
+ "sparse_coverage",
126
+ base,
127
+ DegradationConfig(missing_rate=0.3, seed=seed + 3),
128
+ description="Incomplete sensor coverage on top of contamination.",
129
+ ),
130
+ ]
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class ArmResult:
135
+ """Outcome of one attribution setting on one scenario."""
136
+
137
+ attributed: bool
138
+ states: StateMetrics
139
+ mean_attribution: float
140
+
141
+ def to_dict(self) -> dict[str, object]:
142
+ """Return a serialisable form of the arm."""
143
+ return {
144
+ "attributed": self.attributed,
145
+ "mean_attribution": self.mean_attribution,
146
+ "states": self.states.to_dict(),
147
+ }
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class ScenarioComparison:
152
+ """Naive against occupancy-aware attribution on one scenario."""
153
+
154
+ scenario: str
155
+ description: str
156
+ naive: ArmResult
157
+ occupancy_aware: ArmResult
158
+ visitor_detection: BinaryMetrics
159
+ contaminated_fraction: float
160
+
161
+ @property
162
+ def balanced_accuracy_gain(self) -> float:
163
+ """Balanced accuracy gained by discounting unattributable evidence."""
164
+ return (
165
+ self.occupancy_aware.states.balanced_accuracy
166
+ - self.naive.states.balanced_accuracy
167
+ )
168
+
169
+ @property
170
+ def calibration_gain(self) -> float:
171
+ """Reduction in calibration error. Positive means better calibrated."""
172
+ return (
173
+ self.naive.states.calibration_error
174
+ - self.occupancy_aware.states.calibration_error
175
+ )
176
+
177
+ def to_dict(self) -> dict[str, object]:
178
+ """Return a serialisable form of the comparison."""
179
+ return {
180
+ "scenario": self.scenario,
181
+ "description": self.description,
182
+ "contaminated_fraction": self.contaminated_fraction,
183
+ "balanced_accuracy_gain": self.balanced_accuracy_gain,
184
+ "calibration_gain": self.calibration_gain,
185
+ "visitor_detection": self.visitor_detection.to_dict(),
186
+ "naive": self.naive.to_dict(),
187
+ "occupancy_aware": self.occupancy_aware.to_dict(),
188
+ }
189
+
190
+
191
+ def _run_arm(
192
+ result: Any,
193
+ observations: Sequence[Any],
194
+ *,
195
+ attribute: bool,
196
+ step: timedelta,
197
+ ) -> tuple[ArmResult, list[Any]]:
198
+ """Run one attribution setting over a prepared record."""
199
+ pipeline = BehaviouralSensingPipeline(
200
+ result.registry,
201
+ config=PipelineConfig(
202
+ tz=result.config.tz, step=step, attribute_activity=attribute
203
+ ),
204
+ )
205
+ steps = pipeline.run(observations)
206
+ steps.extend(pipeline.close(result.end))
207
+ if not steps:
208
+ raise ValueError("scenario produced no pipeline steps")
209
+
210
+ truth = result.truth.states_at([s.at for s in steps])
211
+ shares = [
212
+ contribution.attribution
213
+ for s in steps
214
+ for contribution in s.state.evidence
215
+ if contribution.attribution < 1.0 or not attribute
216
+ ]
217
+ return (
218
+ ArmResult(
219
+ attributed=attribute,
220
+ states=state_metrics(truth, [s.state for s in steps]),
221
+ mean_attribution=float(np.mean(shares)) if shares else 1.0,
222
+ ),
223
+ steps,
224
+ )
225
+
226
+
227
+ def compare_scenario(
228
+ scenario: Scenario, *, step: timedelta = timedelta(minutes=10)
229
+ ) -> ScenarioComparison:
230
+ """Run both attribution arms over one scenario and report the difference."""
231
+ result, observations = scenario.build()
232
+
233
+ naive, _ = _run_arm(result, observations, attribute=False, step=step)
234
+ aware, aware_steps = _run_arm(result, observations, attribute=True, step=step)
235
+
236
+ moments = [s.at for s in aware_steps]
237
+ truth_visitor = [result.truth.visitor_at(moment) for moment in moments]
238
+ detection = binary_metrics(
239
+ truth_visitor, [s.context.visitor_present for s in aware_steps]
240
+ )
241
+
242
+ logger.info(
243
+ "%-26s balanced accuracy naive %.3f -> aware %.3f",
244
+ scenario.name,
245
+ naive.states.balanced_accuracy,
246
+ aware.states.balanced_accuracy,
247
+ )
248
+ return ScenarioComparison(
249
+ scenario=scenario.name,
250
+ description=scenario.description,
251
+ naive=naive,
252
+ occupancy_aware=aware,
253
+ visitor_detection=detection,
254
+ contaminated_fraction=float(np.mean(truth_visitor)),
255
+ )
256
+
257
+
258
+ @dataclass
259
+ class AttributionStudy:
260
+ """Results of comparing attribution across several scenarios."""
261
+
262
+ comparisons: list[ScenarioComparison] = field(default_factory=list)
263
+
264
+ def by_name(self, name: str) -> ScenarioComparison:
265
+ """Return the comparison for one scenario."""
266
+ for comparison in self.comparisons:
267
+ if comparison.scenario == name:
268
+ return comparison
269
+ raise KeyError(f"no scenario named '{name}'")
270
+
271
+ @property
272
+ def contaminated(self) -> list[ScenarioComparison]:
273
+ """Scenarios in which another person was actually present."""
274
+ return [c for c in self.comparisons if c.contaminated_fraction > 0.01]
275
+
276
+ def to_dict(self) -> dict[str, object]:
277
+ """Return a serialisable form of the study."""
278
+ return {
279
+ "scenarios": [c.to_dict() for c in self.comparisons],
280
+ "mean_gain_when_contaminated": (
281
+ float(np.mean([c.balanced_accuracy_gain for c in self.contaminated]))
282
+ if self.contaminated
283
+ else 0.0
284
+ ),
285
+ }
286
+
287
+
288
+ def run_attribution_study(
289
+ scenarios: Iterable[Scenario] | None = None,
290
+ *,
291
+ step: timedelta = timedelta(minutes=10),
292
+ ) -> AttributionStudy:
293
+ """Compare naive and occupancy-aware attribution across scenarios."""
294
+ selected = list(scenarios) if scenarios is not None else standard_scenarios()
295
+ if not selected:
296
+ raise ValueError("at least one scenario is required")
297
+ return AttributionStudy(
298
+ comparisons=[compare_scenario(s, step=step) for s in selected]
299
+ )
300
+
301
+
302
+ #: Minimum distance between study seeds.
303
+ #:
304
+ #: ``standard_scenarios`` offsets degradation seeds by up to three, so seeds
305
+ #: closer than this would share simulated faults between replications.
306
+ SEED_SPACING = 4
307
+
308
+
309
+ def _bootstrap_mean(
310
+ values: Sequence[float],
311
+ *,
312
+ confidence: float = 0.95,
313
+ resamples: int = 2000,
314
+ seed: int = 0,
315
+ ) -> dict[str, float]:
316
+ """Summarise a single-arm quantity across replications.
317
+
318
+ Reports the Monte Carlo standard error alongside the interval, because a
319
+ narrow interval from few replications looks identical to a narrow interval
320
+ from many.
321
+ """
322
+ finite = np.asarray([v for v in values if np.isfinite(v)], dtype=float)
323
+ if finite.size == 0:
324
+ return {"n": 0.0, "mean": float("nan"), "mcse": float("nan")}
325
+ if finite.size == 1:
326
+ return {"n": 1.0, "mean": float(finite[0]), "mcse": float("nan")}
327
+
328
+ rng = np.random.default_rng(seed)
329
+ draws = rng.choice(finite, size=(resamples, finite.size), replace=True)
330
+ means = draws.mean(axis=1)
331
+ tail = (1.0 - confidence) / 2.0
332
+ spread = float(finite.std(ddof=1))
333
+ return {
334
+ "n": float(finite.size),
335
+ "mean": float(finite.mean()),
336
+ "sd": spread,
337
+ "mcse": spread / float(np.sqrt(finite.size)),
338
+ "ci_low": float(np.quantile(means, tail)),
339
+ "ci_high": float(np.quantile(means, 1.0 - tail)),
340
+ }
341
+
342
+
343
+ @dataclass(frozen=True)
344
+ class ScenarioAggregate:
345
+ """One scenario's attribution effect estimated across many seeds.
346
+
347
+ The single-seed comparison shows that the mechanism behaves as designed.
348
+ This estimates how much it is worth, which needs replication: see
349
+ ``docs/SIMULATION_PROTOCOLS.md`` for choosing the replication count.
350
+ """
351
+
352
+ scenario: str
353
+ description: str
354
+ seeds: tuple[int, ...]
355
+ balanced_accuracy_gain: Any
356
+ calibration_gain: Any
357
+ visitor_precision: dict[str, float]
358
+ visitor_recall: dict[str, float]
359
+ visitor_f1: dict[str, float]
360
+ contaminated_fraction: dict[str, float]
361
+
362
+ def to_dict(self) -> dict[str, object]:
363
+ """Return a serialisable form of the aggregate."""
364
+ return {
365
+ "scenario": self.scenario,
366
+ "description": self.description,
367
+ "seeds": list(self.seeds),
368
+ "balanced_accuracy_gain": self.balanced_accuracy_gain.to_dict(),
369
+ "calibration_gain": self.calibration_gain.to_dict(),
370
+ "visitor_precision": self.visitor_precision,
371
+ "visitor_recall": self.visitor_recall,
372
+ "visitor_f1": self.visitor_f1,
373
+ "contaminated_fraction": self.contaminated_fraction,
374
+ }
375
+
376
+
377
+ @dataclass
378
+ class ReplicatedAttributionStudy:
379
+ """Attribution measured across scenarios and independent seeds."""
380
+
381
+ seeds: tuple[int, ...] = ()
382
+ aggregates: list[ScenarioAggregate] = field(default_factory=list)
383
+
384
+ def by_name(self, name: str) -> ScenarioAggregate:
385
+ """Return the aggregate for one scenario."""
386
+ for aggregate in self.aggregates:
387
+ if aggregate.scenario == name:
388
+ return aggregate
389
+ raise KeyError(f"no scenario named '{name}'")
390
+
391
+ def to_dict(self) -> dict[str, object]:
392
+ """Return a serialisable form of the study."""
393
+ return {
394
+ "seeds": list(self.seeds),
395
+ "replications": len(self.seeds),
396
+ "scenarios": [a.to_dict() for a in self.aggregates],
397
+ }
398
+
399
+
400
+ def run_replicated_attribution_study(
401
+ seeds: Sequence[int],
402
+ *,
403
+ days: int = 10,
404
+ step: timedelta = timedelta(minutes=10),
405
+ ) -> ReplicatedAttributionStudy:
406
+ """Estimate attribution's effect across independent simulated households.
407
+
408
+ Every seed produces a fresh household for each scenario, and both arms of a
409
+ seed share that household, so the comparison stays paired while the
410
+ replication count grows.
411
+ """
412
+ from .metrics import paired_difference
413
+
414
+ ordered = [int(s) for s in seeds]
415
+ if len(ordered) < 2:
416
+ raise ValueError(
417
+ "a replicated study needs at least two seeds; use "
418
+ "run_attribution_study for a single-seed demonstration"
419
+ )
420
+ if len(set(ordered)) != len(ordered):
421
+ raise ValueError("seeds must be distinct, or replications are not independent")
422
+ # standard_scenarios derives degradation seeds as seed + 1 .. seed + 3, so
423
+ # neighbouring study seeds would hand two supposedly independent
424
+ # replications the same record loss. Adjacent seeds are the natural thing
425
+ # for a caller to type, which is exactly why this has to be refused rather
426
+ # than documented.
427
+ spacing = min(
428
+ (b - a for a, b in zip(sorted(ordered), sorted(ordered)[1:])),
429
+ default=SEED_SPACING,
430
+ )
431
+ if spacing < SEED_SPACING:
432
+ raise ValueError(
433
+ f"seeds must differ by at least {SEED_SPACING}; scenarios derive "
434
+ "degradation seeds from neighbouring values, so closer seeds share "
435
+ "sensor faults between replications"
436
+ )
437
+
438
+ by_scenario: dict[str, list[ScenarioComparison]] = {}
439
+ for seed in ordered:
440
+ for scenario in standard_scenarios(days=days, seed=seed):
441
+ comparison = compare_scenario(scenario, step=step)
442
+ by_scenario.setdefault(scenario.name, []).append(comparison)
443
+
444
+ aggregates: list[ScenarioAggregate] = []
445
+ for name, comparisons in by_scenario.items():
446
+ aware = [c.occupancy_aware.states.balanced_accuracy for c in comparisons]
447
+ naive = [c.naive.states.balanced_accuracy for c in comparisons]
448
+ # Calibration error is better when lower, so the gain is control minus
449
+ # treatment; passing it the other way round would silently invert the
450
+ # sign of every reported calibration effect.
451
+ naive_error = [c.naive.states.calibration_error for c in comparisons]
452
+ aware_error = [c.occupancy_aware.states.calibration_error for c in comparisons]
453
+ aggregates.append(
454
+ ScenarioAggregate(
455
+ scenario=name,
456
+ description=comparisons[0].description,
457
+ seeds=tuple(ordered),
458
+ balanced_accuracy_gain=paired_difference(aware, naive),
459
+ calibration_gain=paired_difference(naive_error, aware_error),
460
+ visitor_precision=_bootstrap_mean(
461
+ [c.visitor_detection.precision for c in comparisons]
462
+ ),
463
+ visitor_recall=_bootstrap_mean(
464
+ [c.visitor_detection.recall for c in comparisons]
465
+ ),
466
+ visitor_f1=_bootstrap_mean(
467
+ [c.visitor_detection.f1 for c in comparisons]
468
+ ),
469
+ contaminated_fraction=_bootstrap_mean(
470
+ [c.contaminated_fraction for c in comparisons]
471
+ ),
472
+ )
473
+ )
474
+ return ReplicatedAttributionStudy(seeds=tuple(ordered), aggregates=aggregates)