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,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)
|