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,297 @@
|
|
|
1
|
+
"""Measuring whether behavioural change detection is usable.
|
|
2
|
+
|
|
3
|
+
Four numbers decide whether a monitoring system is worth deploying, and they
|
|
4
|
+
trade against each other rather than improving together:
|
|
5
|
+
|
|
6
|
+
.. code-block:: text
|
|
7
|
+
|
|
8
|
+
detection delay how long a real change goes unreported
|
|
9
|
+
false alert burden how often a carer is contacted for nothing
|
|
10
|
+
missed changes how often a real change is never reported
|
|
11
|
+
calibration whether the confidence attached means anything
|
|
12
|
+
|
|
13
|
+
A system tuned to detect everything floods its recipients; one tuned for
|
|
14
|
+
silence misses what matters. This module measures the trade rather than
|
|
15
|
+
asserting a favourable point on it, and includes arms where nothing at all has
|
|
16
|
+
changed, because a detector's behaviour on a stable record is as informative
|
|
17
|
+
as its behaviour on a changed one.
|
|
18
|
+
|
|
19
|
+
Arms are paired by seed, so the stable and changed runs of a given seed differ
|
|
20
|
+
only in the injected change.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import Iterable, Sequence
|
|
27
|
+
from dataclasses import dataclass, field, replace
|
|
28
|
+
from datetime import date, timedelta
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
from ..alerts.alert import AlertKind
|
|
34
|
+
from ..online.pipeline import BehaviouralSensingPipeline, PipelineConfig, collect_alerts
|
|
35
|
+
from ..simulation.faults import DegradationConfig, degrade
|
|
36
|
+
from ..simulation.household import BehaviourShift, HouseholdConfig, simulate
|
|
37
|
+
from .metrics import DetectionMetrics, detection_metrics
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
#: Feature the injected changes act on. Sleep is used because the simulator
|
|
42
|
+
#: can shift it cleanly and because it is the quantity most ambient-monitoring
|
|
43
|
+
#: studies report on.
|
|
44
|
+
TRACKED_FEATURE = "sleeping_hours"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ChangeArm:
|
|
49
|
+
"""One configuration of injected change and sensor degradation."""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
shift: BehaviourShift | None
|
|
53
|
+
degradation: DegradationConfig | None = None
|
|
54
|
+
description: str = ""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def has_change(self) -> bool:
|
|
58
|
+
"""Whether a real behavioural change was injected."""
|
|
59
|
+
return self.shift is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ArmOutcome:
|
|
64
|
+
"""What one arm produced on one seed."""
|
|
65
|
+
|
|
66
|
+
arm: str
|
|
67
|
+
seed: int
|
|
68
|
+
metrics: DetectionMetrics
|
|
69
|
+
behavioural_alerts: int
|
|
70
|
+
person_days: float
|
|
71
|
+
mean_alert_confidence: float
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def alerts_per_person_day(self) -> float:
|
|
75
|
+
"""Total behavioural alert burden, matched or not."""
|
|
76
|
+
return self.behavioural_alerts / self.person_days if self.person_days else 0.0
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict[str, object]:
|
|
79
|
+
"""Return a serialisable form of the outcome."""
|
|
80
|
+
return {
|
|
81
|
+
"arm": self.arm,
|
|
82
|
+
"seed": self.seed,
|
|
83
|
+
"behavioural_alerts": self.behavioural_alerts,
|
|
84
|
+
"alerts_per_person_day": self.alerts_per_person_day,
|
|
85
|
+
"mean_alert_confidence": self.mean_alert_confidence,
|
|
86
|
+
"detection": self.metrics.to_dict(),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class DetectionStudy:
|
|
92
|
+
"""Detection outcomes across arms and seeds."""
|
|
93
|
+
|
|
94
|
+
outcomes: list[ArmOutcome] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
def arms(self) -> list[str]:
|
|
97
|
+
"""Arm names in first-seen order."""
|
|
98
|
+
seen: dict[str, None] = {}
|
|
99
|
+
for outcome in self.outcomes:
|
|
100
|
+
seen.setdefault(outcome.arm, None)
|
|
101
|
+
return list(seen)
|
|
102
|
+
|
|
103
|
+
def for_arm(self, arm: str) -> list[ArmOutcome]:
|
|
104
|
+
"""Every outcome for one arm, ordered by seed."""
|
|
105
|
+
return sorted((o for o in self.outcomes if o.arm == arm), key=lambda o: o.seed)
|
|
106
|
+
|
|
107
|
+
def summary(self, arm: str) -> dict[str, float]:
|
|
108
|
+
"""Aggregate one arm across its seeds."""
|
|
109
|
+
outcomes = self.for_arm(arm)
|
|
110
|
+
if not outcomes:
|
|
111
|
+
raise KeyError(f"no outcomes for arm '{arm}'")
|
|
112
|
+
# Pool the individual delays rather than averaging each seed's median.
|
|
113
|
+
# A mean of medians is not a median, it weights a seed that detected one
|
|
114
|
+
# change as heavily as a seed that detected twenty, and it is not the
|
|
115
|
+
# quantity the provenance record defines "median_delay_days" to be.
|
|
116
|
+
delays = [delay for o in outcomes for delay in o.metrics.delays_days]
|
|
117
|
+
seed_medians = [
|
|
118
|
+
o.metrics.median_delay_days
|
|
119
|
+
for o in outcomes
|
|
120
|
+
if not np.isnan(o.metrics.median_delay_days)
|
|
121
|
+
]
|
|
122
|
+
return {
|
|
123
|
+
"seeds": float(len(outcomes)),
|
|
124
|
+
"recall": float(np.mean([o.metrics.recall for o in outcomes])),
|
|
125
|
+
"median_delay_days": float(np.median(delays)) if delays else float("nan"),
|
|
126
|
+
"mean_seed_median_delay_days": (
|
|
127
|
+
float(np.mean(seed_medians)) if seed_medians else float("nan")
|
|
128
|
+
),
|
|
129
|
+
"detected_changes": float(len(delays)),
|
|
130
|
+
"alerts_per_person_day": float(
|
|
131
|
+
np.mean([o.alerts_per_person_day for o in outcomes])
|
|
132
|
+
),
|
|
133
|
+
"false_positives_per_person_day": float(
|
|
134
|
+
np.mean([o.metrics.false_positives_per_person_day for o in outcomes])
|
|
135
|
+
),
|
|
136
|
+
"mean_alert_confidence": float(
|
|
137
|
+
np.mean([o.mean_alert_confidence for o in outcomes])
|
|
138
|
+
),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> dict[str, object]:
|
|
142
|
+
"""Return a serialisable form of the study."""
|
|
143
|
+
return {
|
|
144
|
+
"arms": {arm: self.summary(arm) for arm in self.arms()},
|
|
145
|
+
"outcomes": [o.to_dict() for o in self.outcomes],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def standard_arms(
|
|
150
|
+
days: int = 70, change_day: int = 40, magnitude: float = 1.6
|
|
151
|
+
) -> list[ChangeArm]:
|
|
152
|
+
"""Build the arms needed to characterise the detection trade-off.
|
|
153
|
+
|
|
154
|
+
Every arm that injects a change is matched by one that does not, so the
|
|
155
|
+
alert burden attributable to the change is separable from the burden the
|
|
156
|
+
detector produces anyway.
|
|
157
|
+
"""
|
|
158
|
+
step = BehaviourShift(
|
|
159
|
+
start_day=change_day,
|
|
160
|
+
sleep_delta_hours=magnitude,
|
|
161
|
+
night_bathroom_extra=1.2,
|
|
162
|
+
)
|
|
163
|
+
gradual = BehaviourShift(
|
|
164
|
+
start_day=change_day // 2,
|
|
165
|
+
sleep_delta_hours=magnitude,
|
|
166
|
+
night_bathroom_extra=1.2,
|
|
167
|
+
ramp_days=max(days - change_day // 2 - 5, 5),
|
|
168
|
+
)
|
|
169
|
+
missing = DegradationConfig(missing_rate=0.3, seed=1)
|
|
170
|
+
|
|
171
|
+
return [
|
|
172
|
+
ChangeArm(
|
|
173
|
+
"stable",
|
|
174
|
+
None,
|
|
175
|
+
description="Nothing changed. Every alert here is a false alarm.",
|
|
176
|
+
),
|
|
177
|
+
ChangeArm(
|
|
178
|
+
"abrupt_change",
|
|
179
|
+
step,
|
|
180
|
+
description="A step change in sleep on a known day.",
|
|
181
|
+
),
|
|
182
|
+
ChangeArm(
|
|
183
|
+
"gradual_change",
|
|
184
|
+
gradual,
|
|
185
|
+
description="The same magnitude reached slowly, which no single "
|
|
186
|
+
"day's deviation can reveal.",
|
|
187
|
+
),
|
|
188
|
+
ChangeArm(
|
|
189
|
+
"stable_degraded",
|
|
190
|
+
None,
|
|
191
|
+
missing,
|
|
192
|
+
description="Nothing changed, and a third of records are lost. "
|
|
193
|
+
"Missingness must not manufacture findings.",
|
|
194
|
+
),
|
|
195
|
+
ChangeArm(
|
|
196
|
+
"abrupt_change_degraded",
|
|
197
|
+
step,
|
|
198
|
+
missing,
|
|
199
|
+
description="A real change seen through a degraded record.",
|
|
200
|
+
),
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _evaluate(
|
|
205
|
+
arm: ChangeArm,
|
|
206
|
+
seed: int,
|
|
207
|
+
*,
|
|
208
|
+
days: int,
|
|
209
|
+
change_day: int,
|
|
210
|
+
step: timedelta,
|
|
211
|
+
max_delay_days: float,
|
|
212
|
+
) -> ArmOutcome:
|
|
213
|
+
"""Run one arm on one seed and score the alerts it delivered."""
|
|
214
|
+
household = HouseholdConfig(days=days, seed=seed, shift=arm.shift)
|
|
215
|
+
result = simulate(household)
|
|
216
|
+
|
|
217
|
+
observations: Sequence[Any] = result.observations
|
|
218
|
+
if arm.degradation is not None:
|
|
219
|
+
observations, _ = degrade(observations, replace(arm.degradation, seed=seed))
|
|
220
|
+
|
|
221
|
+
pipeline = BehaviouralSensingPipeline(
|
|
222
|
+
result.registry, config=PipelineConfig(tz=result.config.tz, step=step)
|
|
223
|
+
)
|
|
224
|
+
steps = pipeline.run(observations)
|
|
225
|
+
steps.extend(pipeline.close(result.end))
|
|
226
|
+
|
|
227
|
+
alerts = [
|
|
228
|
+
alert
|
|
229
|
+
for alert in collect_alerts(steps)
|
|
230
|
+
if alert.kind is AlertKind.BEHAVIOURAL_CHANGE
|
|
231
|
+
and TRACKED_FEATURE in str(alert.subject)
|
|
232
|
+
]
|
|
233
|
+
detected: list[date] = [alert.at.date() for alert in alerts]
|
|
234
|
+
|
|
235
|
+
true_changes: list[date] = []
|
|
236
|
+
if arm.shift is not None:
|
|
237
|
+
true_changes.append(household.start + timedelta(days=arm.shift.start_day))
|
|
238
|
+
|
|
239
|
+
metrics = detection_metrics(
|
|
240
|
+
detected,
|
|
241
|
+
true_changes,
|
|
242
|
+
person_days=float(days),
|
|
243
|
+
max_delay_days=max_delay_days,
|
|
244
|
+
)
|
|
245
|
+
return ArmOutcome(
|
|
246
|
+
arm=arm.name,
|
|
247
|
+
seed=seed,
|
|
248
|
+
metrics=metrics,
|
|
249
|
+
behavioural_alerts=len(alerts),
|
|
250
|
+
person_days=float(days),
|
|
251
|
+
mean_alert_confidence=(
|
|
252
|
+
float(np.mean([a.confidence for a in alerts])) if alerts else float("nan")
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def run_detection_study(
|
|
258
|
+
arms: Iterable[ChangeArm] | None = None,
|
|
259
|
+
*,
|
|
260
|
+
seeds: Iterable[int] = (11, 22, 33),
|
|
261
|
+
days: int = 70,
|
|
262
|
+
change_day: int = 40,
|
|
263
|
+
step: timedelta = timedelta(minutes=15),
|
|
264
|
+
max_delay_days: float = 21.0,
|
|
265
|
+
) -> DetectionStudy:
|
|
266
|
+
"""Characterise detection delay against alert burden across arms.
|
|
267
|
+
|
|
268
|
+
A gradual arm's change is dated from where it *begins*, not from where it
|
|
269
|
+
becomes visible, so its reported delay includes the time the trend spent
|
|
270
|
+
too small to detect. That is the honest accounting: the resident was
|
|
271
|
+
already declining.
|
|
272
|
+
"""
|
|
273
|
+
selected = list(arms) if arms is not None else standard_arms(days, change_day)
|
|
274
|
+
if not selected:
|
|
275
|
+
raise ValueError("at least one arm is required")
|
|
276
|
+
|
|
277
|
+
study = DetectionStudy()
|
|
278
|
+
for seed in seeds:
|
|
279
|
+
for arm in selected:
|
|
280
|
+
outcome = _evaluate(
|
|
281
|
+
arm,
|
|
282
|
+
seed,
|
|
283
|
+
days=days,
|
|
284
|
+
change_day=change_day,
|
|
285
|
+
step=step,
|
|
286
|
+
max_delay_days=max_delay_days,
|
|
287
|
+
)
|
|
288
|
+
study.outcomes.append(outcome)
|
|
289
|
+
logger.info(
|
|
290
|
+
"seed %s | %-24s | alerts %d | recall %.2f | delay %.1f",
|
|
291
|
+
seed,
|
|
292
|
+
arm.name,
|
|
293
|
+
outcome.behavioural_alerts,
|
|
294
|
+
outcome.metrics.recall,
|
|
295
|
+
outcome.metrics.median_delay_days,
|
|
296
|
+
)
|
|
297
|
+
return study
|