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,296 @@
|
|
|
1
|
+
"""Sensible default observation models derived from the sensor registry.
|
|
2
|
+
|
|
3
|
+
Hand-writing an emission model per sensor is the right thing to do for a study
|
|
4
|
+
that has calibration data. For everything else -- a first deployment, an
|
|
5
|
+
ablation sweep, the worked example -- the registry already says enough to build
|
|
6
|
+
a defensible model: the modality says what kind of likelihood applies, and the
|
|
7
|
+
room says which states should expect the sensor to fire.
|
|
8
|
+
|
|
9
|
+
Deriving the models from declarations rather than from sensor names is what
|
|
10
|
+
keeps the platform sensor-agnostic. Adding a new device to a deployment means
|
|
11
|
+
adding a :class:`~sensor_modeling.observations.registry.SensorSpec`, not
|
|
12
|
+
editing inference code.
|
|
13
|
+
|
|
14
|
+
The defaults are starting points and are meant to be replaced by fitted values
|
|
15
|
+
where data allows. They are deliberately conservative: rates differ between
|
|
16
|
+
states by roughly an order of magnitude rather than by several, so no single
|
|
17
|
+
sensor can dominate the posterior on its own.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
from ..observations.registry import SensorRegistry, SensorSpec
|
|
26
|
+
from ..observations.types import Modality, ObservationKind
|
|
27
|
+
from ..observations.units import Unit
|
|
28
|
+
from ..states.ontology import BehaviouralState, StateOntology
|
|
29
|
+
from .emissions import (
|
|
30
|
+
BernoulliEmission,
|
|
31
|
+
BetaEmission,
|
|
32
|
+
EmissionModel,
|
|
33
|
+
GaussianEmission,
|
|
34
|
+
PoissonEventEmission,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
S = BehaviouralState
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class EmissionDefaults:
|
|
44
|
+
"""Rate and level assumptions used to build default emission models.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
active_rate
|
|
49
|
+
Activations per hour a room sensor produces when the resident is in
|
|
50
|
+
that room and fully active.
|
|
51
|
+
activity_levels
|
|
52
|
+
Fraction of ``active_rate`` each state actually generates. This is the
|
|
53
|
+
distinction between *where* a state puts the resident and *how much
|
|
54
|
+
they move* while there, and it is not optional: a sleeping person is
|
|
55
|
+
in the bedroom but almost perfectly still, so a bedroom sensor's
|
|
56
|
+
silence is expected during sleep rather than evidence against it.
|
|
57
|
+
idle_rate
|
|
58
|
+
Background rate: false activations, pets, draughts. Never zero, so a
|
|
59
|
+
single stray activation cannot rule a state out permanently.
|
|
60
|
+
away_rate
|
|
61
|
+
Rate while the resident is out. Non-zero because visitors exist.
|
|
62
|
+
door_rate
|
|
63
|
+
Rate for an entrance door in the states either side of a crossing.
|
|
64
|
+
wearable_levels
|
|
65
|
+
Expected wearable activity magnitude per state.
|
|
66
|
+
wearable_sigma
|
|
67
|
+
Standard deviation of the wearable signal.
|
|
68
|
+
presence_probability
|
|
69
|
+
Probability a personal beacon reads in-range in each home state.
|
|
70
|
+
bed_probability
|
|
71
|
+
Probability a bed sensor reads occupied in the in-bed states.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
active_rate: float = 40.0
|
|
75
|
+
activity_levels: dict[BehaviouralState, float] | None = None
|
|
76
|
+
idle_rate: float = 0.15
|
|
77
|
+
away_rate: float = 0.05
|
|
78
|
+
door_rate: float = 4.0
|
|
79
|
+
wearable_levels: dict[BehaviouralState, float] | None = None
|
|
80
|
+
wearable_sigma: float = 0.35
|
|
81
|
+
presence_probability: float = 0.95
|
|
82
|
+
bed_probability: float = 0.97
|
|
83
|
+
|
|
84
|
+
def __post_init__(self) -> None:
|
|
85
|
+
"""Validate the assumptions and supply per-state defaults."""
|
|
86
|
+
rates = (self.active_rate, self.idle_rate, self.away_rate, self.door_rate)
|
|
87
|
+
if any(rate < 0 for rate in rates):
|
|
88
|
+
raise ValueError("emission rates must be non-negative")
|
|
89
|
+
if self.wearable_sigma <= 0:
|
|
90
|
+
raise ValueError("wearable_sigma must be positive")
|
|
91
|
+
for name in ("presence_probability", "bed_probability"):
|
|
92
|
+
value = float(getattr(self, name))
|
|
93
|
+
if not 0.0 <= value <= 1.0:
|
|
94
|
+
raise ValueError(f"{name} must lie in [0, 1]")
|
|
95
|
+
if self.activity_levels is None:
|
|
96
|
+
self.activity_levels = {
|
|
97
|
+
S.SLEEPING: 0.02,
|
|
98
|
+
S.BED_AWAKE: 0.35,
|
|
99
|
+
S.HOME_INACTIVE: 0.25,
|
|
100
|
+
S.HOME_ACTIVE: 1.0,
|
|
101
|
+
S.KITCHEN_ACTIVITY: 1.0,
|
|
102
|
+
S.BATHROOM_ACTIVITY: 1.0,
|
|
103
|
+
S.AWAY: 0.0,
|
|
104
|
+
}
|
|
105
|
+
if any(level < 0 for level in self.activity_levels.values()):
|
|
106
|
+
raise ValueError("activity levels must be non-negative")
|
|
107
|
+
if self.wearable_levels is None:
|
|
108
|
+
self.wearable_levels = {
|
|
109
|
+
S.SLEEPING: 0.05,
|
|
110
|
+
S.BED_AWAKE: 0.3,
|
|
111
|
+
S.HOME_INACTIVE: 0.4,
|
|
112
|
+
S.HOME_ACTIVE: 1.4,
|
|
113
|
+
S.KITCHEN_ACTIVITY: 1.1,
|
|
114
|
+
S.BATHROOM_ACTIVITY: 0.9,
|
|
115
|
+
S.AWAY: 1.8,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _event_rates(
|
|
120
|
+
spec: SensorSpec, ontology: StateOntology, defaults: EmissionDefaults
|
|
121
|
+
) -> dict[BehaviouralState, float]:
|
|
122
|
+
"""Return per-state activation rates for an event sensor.
|
|
123
|
+
|
|
124
|
+
A rate is the product of two independent things: whether the state puts
|
|
125
|
+
the resident within range of this sensor, and how much they move while in
|
|
126
|
+
that state. Conflating the two is the classic way to make a bed sensor
|
|
127
|
+
and a bedroom motion sensor argue with each other all night.
|
|
128
|
+
"""
|
|
129
|
+
levels = defaults.activity_levels or {}
|
|
130
|
+
located = {ontology.room_of(state) for state in ontology.states}
|
|
131
|
+
# A state that names no room could put the resident in any of the rooms
|
|
132
|
+
# the ontology does name, plus somewhere unmonitored, so its rate for any
|
|
133
|
+
# one sensor is diluted across those possibilities.
|
|
134
|
+
spread = max(len({room for room in located if room}) + 1, 1)
|
|
135
|
+
|
|
136
|
+
rates: dict[BehaviouralState, float] = {}
|
|
137
|
+
for state in ontology.states:
|
|
138
|
+
activity = float(levels.get(state, 0.5))
|
|
139
|
+
room = ontology.room_of(state)
|
|
140
|
+
if state is S.AWAY:
|
|
141
|
+
rate = defaults.away_rate
|
|
142
|
+
elif spec.room is None:
|
|
143
|
+
rate = defaults.active_rate * activity
|
|
144
|
+
elif room == spec.room:
|
|
145
|
+
rate = defaults.active_rate * activity
|
|
146
|
+
elif room is None:
|
|
147
|
+
rate = defaults.active_rate * activity / spread
|
|
148
|
+
else:
|
|
149
|
+
rate = defaults.idle_rate
|
|
150
|
+
rates[state] = max(rate, defaults.idle_rate)
|
|
151
|
+
return rates
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _door_rates(
|
|
155
|
+
ontology: StateOntology, defaults: EmissionDefaults
|
|
156
|
+
) -> dict[BehaviouralState, float]:
|
|
157
|
+
"""Return per-state rates for an entrance door.
|
|
158
|
+
|
|
159
|
+
A door fires at the boundary between being in and being out, so it is
|
|
160
|
+
most informative about the states either side of a crossing rather than
|
|
161
|
+
about any state in isolation.
|
|
162
|
+
"""
|
|
163
|
+
return {
|
|
164
|
+
state: (
|
|
165
|
+
defaults.door_rate
|
|
166
|
+
if state in (S.AWAY, S.HOME_ACTIVE)
|
|
167
|
+
else defaults.idle_rate
|
|
168
|
+
)
|
|
169
|
+
for state in ontology.states
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def default_emission_for(
|
|
174
|
+
spec: SensorSpec,
|
|
175
|
+
ontology: StateOntology,
|
|
176
|
+
defaults: EmissionDefaults | None = None,
|
|
177
|
+
) -> EmissionModel | None:
|
|
178
|
+
"""Build a default observation model for one declared sensor.
|
|
179
|
+
|
|
180
|
+
Returns
|
|
181
|
+
-------
|
|
182
|
+
EmissionModel | None
|
|
183
|
+
``None`` when the modality carries no information about behavioural
|
|
184
|
+
state. A room-occupancy count, for instance, is evidence about *who
|
|
185
|
+
is present* and belongs to the occupancy layer, not here.
|
|
186
|
+
"""
|
|
187
|
+
settings = defaults or EmissionDefaults()
|
|
188
|
+
levels = settings.wearable_levels or {}
|
|
189
|
+
|
|
190
|
+
if spec.modality is Modality.DOOR:
|
|
191
|
+
return PoissonEventEmission(
|
|
192
|
+
spec.sensor_id, rates=_door_rates(ontology, settings)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if spec.kind is ObservationKind.EVENT:
|
|
196
|
+
return PoissonEventEmission(
|
|
197
|
+
spec.sensor_id, rates=_event_rates(spec, ontology, settings)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if spec.modality is Modality.BED_PRESSURE:
|
|
201
|
+
occupied = {
|
|
202
|
+
state: (
|
|
203
|
+
settings.bed_probability
|
|
204
|
+
if state in (S.SLEEPING, S.BED_AWAKE)
|
|
205
|
+
else 1.0 - settings.bed_probability
|
|
206
|
+
)
|
|
207
|
+
for state in ontology.states
|
|
208
|
+
}
|
|
209
|
+
return BernoulliEmission(spec.sensor_id, probabilities=occupied)
|
|
210
|
+
|
|
211
|
+
if spec.modality in (Modality.WEARABLE_MOTION, Modality.WEARABLE_PHYSIOLOGY):
|
|
212
|
+
return GaussianEmission(
|
|
213
|
+
spec.sensor_id,
|
|
214
|
+
means={state: levels.get(state, 0.5) for state in ontology.states},
|
|
215
|
+
sigmas={state: settings.wearable_sigma for state in ontology.states},
|
|
216
|
+
# A wearable samples far more often than a contact sensor fires.
|
|
217
|
+
# Without this discount its sampling rate, rather than its
|
|
218
|
+
# informativeness, would decide how much it counts.
|
|
219
|
+
weight=0.25,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if spec.modality is Modality.PROXIMITY:
|
|
223
|
+
present = {
|
|
224
|
+
state: (
|
|
225
|
+
1.0 - settings.presence_probability
|
|
226
|
+
if state is S.AWAY
|
|
227
|
+
else settings.presence_probability
|
|
228
|
+
)
|
|
229
|
+
for state in ontology.states
|
|
230
|
+
}
|
|
231
|
+
return BernoulliEmission(spec.sensor_id, probabilities=present)
|
|
232
|
+
|
|
233
|
+
if spec.modality is Modality.RADAR:
|
|
234
|
+
if spec.unit is Unit.PROBABILITY:
|
|
235
|
+
return BetaEmission(
|
|
236
|
+
spec.sensor_id,
|
|
237
|
+
means={
|
|
238
|
+
state: (0.05 if state is S.AWAY else 0.9)
|
|
239
|
+
for state in ontology.states
|
|
240
|
+
},
|
|
241
|
+
weight=0.25,
|
|
242
|
+
)
|
|
243
|
+
# A track count is presence evidence: zero people means nobody home.
|
|
244
|
+
return GaussianEmission(
|
|
245
|
+
spec.sensor_id,
|
|
246
|
+
means={
|
|
247
|
+
state: (0.05 if state is S.AWAY else 1.0) for state in ontology.states
|
|
248
|
+
},
|
|
249
|
+
sigmas={state: 0.7 for state in ontology.states},
|
|
250
|
+
weight=0.25,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
logger.debug(
|
|
254
|
+
"No default emission model for modality %s ('%s')",
|
|
255
|
+
spec.modality.value,
|
|
256
|
+
spec.sensor_id,
|
|
257
|
+
)
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _group_sizes(registry: SensorRegistry) -> dict[str, int]:
|
|
262
|
+
"""Count how many sensors share each declared redundancy group."""
|
|
263
|
+
sizes: dict[str, int] = {}
|
|
264
|
+
for spec in registry:
|
|
265
|
+
if spec.redundancy_group:
|
|
266
|
+
sizes[spec.redundancy_group] = sizes.get(spec.redundancy_group, 0) + 1
|
|
267
|
+
return sizes
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def default_emissions(
|
|
271
|
+
registry: SensorRegistry,
|
|
272
|
+
ontology: StateOntology,
|
|
273
|
+
defaults: EmissionDefaults | None = None,
|
|
274
|
+
) -> list[EmissionModel]:
|
|
275
|
+
"""Build default observation models for every sensor that has one.
|
|
276
|
+
|
|
277
|
+
Sensors sharing a ``redundancy_group`` have their evidence weight divided
|
|
278
|
+
across the group. The filter combines sensors as conditionally
|
|
279
|
+
independent given the state, so without this, several views of the same
|
|
280
|
+
physical event accumulate as several independent observations and drive
|
|
281
|
+
the posterior toward certainty it has not earned.
|
|
282
|
+
"""
|
|
283
|
+
sizes = _group_sizes(registry)
|
|
284
|
+
models: list[EmissionModel] = []
|
|
285
|
+
for spec in registry:
|
|
286
|
+
model = default_emission_for(spec, ontology, defaults)
|
|
287
|
+
if model is None:
|
|
288
|
+
continue
|
|
289
|
+
if spec.redundancy_group:
|
|
290
|
+
model.weight /= sizes[spec.redundancy_group]
|
|
291
|
+
models.append(model)
|
|
292
|
+
if not models:
|
|
293
|
+
raise ValueError(
|
|
294
|
+
"no sensor in the registry carries information about behavioural state"
|
|
295
|
+
)
|
|
296
|
+
return models
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Modality-specific observation models.
|
|
2
|
+
|
|
3
|
+
Each emission model answers one question: given that the resident is in state
|
|
4
|
+
``z``, how surprising is what this sensor reported over the last interval?
|
|
5
|
+
Keeping that question per-sensor is what makes the fusion layer genuinely
|
|
6
|
+
multimodal -- modalities with different units, sampling rates and temporal
|
|
7
|
+
semantics each get an appropriate likelihood, and the filter combines them.
|
|
8
|
+
|
|
9
|
+
Two mechanisms are shared by every model.
|
|
10
|
+
|
|
11
|
+
*Tempering.* The raw log-likelihood is multiplied by the sensor's reliability
|
|
12
|
+
and by the probability that what it saw was generated by the monitored
|
|
13
|
+
resident. A reliability of zero contributes exactly nothing, which is the
|
|
14
|
+
formal statement of "a broken sensor is missing evidence, not evidence of
|
|
15
|
+
inactivity". This is a power-likelihood weighting in the sense of generalised
|
|
16
|
+
Bayesian updating, and it degrades smoothly rather than switching a sensor
|
|
17
|
+
abruptly on and off.
|
|
18
|
+
|
|
19
|
+
*Centring.* Log-likelihoods are shifted so the best-supported state sits at
|
|
20
|
+
zero. State-independent constants cancel under normalisation anyway, and
|
|
21
|
+
centring makes each model's output directly readable as "how much worse is
|
|
22
|
+
this state than the best one, according to this sensor".
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import math
|
|
28
|
+
from abc import ABC, abstractmethod
|
|
29
|
+
from collections.abc import Mapping, Sequence
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import timedelta
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
|
|
35
|
+
from ..observations.observation import Observation
|
|
36
|
+
from ..states.ontology import BehaviouralState, StateOntology
|
|
37
|
+
|
|
38
|
+
#: Smallest event rate any state may have, in events per second. A state that
|
|
39
|
+
#: "never" triggers a sensor still needs a positive rate, or a single spurious
|
|
40
|
+
#: activation would drive its posterior to exactly zero and make it
|
|
41
|
+
#: unrecoverable for the rest of the deployment.
|
|
42
|
+
MIN_RATE = 1e-9
|
|
43
|
+
|
|
44
|
+
#: Floor applied to Bernoulli and Beta parameters for the same reason.
|
|
45
|
+
EPSILON = 1e-6
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class EmissionModel(ABC):
|
|
50
|
+
"""Base class for per-sensor observation models.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
sensor_id
|
|
55
|
+
Sensor this model interprets.
|
|
56
|
+
weight
|
|
57
|
+
Evidence weight for the modality. Fast-sampling sensors accumulate
|
|
58
|
+
more log-likelihood per interval simply by reporting more often; this
|
|
59
|
+
multiplier lets a deployment correct that imbalance deliberately
|
|
60
|
+
rather than letting sampling rate decide how much a modality counts.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
sensor_id: str
|
|
64
|
+
weight: float = 1.0
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
"""Validate shared configuration."""
|
|
68
|
+
if not isinstance(self.sensor_id, str) or not self.sensor_id.strip():
|
|
69
|
+
raise ValueError("sensor_id must be a non-empty string")
|
|
70
|
+
if not math.isfinite(self.weight) or self.weight < 0.0:
|
|
71
|
+
raise ValueError("weight must be finite and non-negative")
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def _raw_log_likelihood(
|
|
75
|
+
self,
|
|
76
|
+
ontology: StateOntology,
|
|
77
|
+
observations: Sequence[Observation],
|
|
78
|
+
elapsed: timedelta,
|
|
79
|
+
) -> np.ndarray:
|
|
80
|
+
"""Return the untempered per-state log-likelihood."""
|
|
81
|
+
|
|
82
|
+
def log_likelihood(
|
|
83
|
+
self,
|
|
84
|
+
ontology: StateOntology,
|
|
85
|
+
observations: Sequence[Observation],
|
|
86
|
+
elapsed: timedelta,
|
|
87
|
+
*,
|
|
88
|
+
reliability: float = 1.0,
|
|
89
|
+
attribution: float = 1.0,
|
|
90
|
+
) -> np.ndarray:
|
|
91
|
+
"""Return the tempered, centred per-state log-likelihood.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
reliability
|
|
96
|
+
Health-derived evidence weight in ``[0, 1]``.
|
|
97
|
+
attribution
|
|
98
|
+
Probability in ``[0, 1]`` that the monitored resident generated
|
|
99
|
+
what this sensor saw. Ambient sensors in a home with a visitor
|
|
100
|
+
should pass a value below one.
|
|
101
|
+
"""
|
|
102
|
+
temper = self.weight * float(reliability) * float(attribution)
|
|
103
|
+
if temper <= 0.0:
|
|
104
|
+
return np.zeros(ontology.size)
|
|
105
|
+
raw = np.asarray(
|
|
106
|
+
self._raw_log_likelihood(ontology, observations, elapsed), dtype=float
|
|
107
|
+
)
|
|
108
|
+
if raw.shape != (ontology.size,):
|
|
109
|
+
raise ValueError("emission models must return one value per state")
|
|
110
|
+
if not np.all(np.isfinite(raw)):
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"emission model for '{self.sensor_id}' produced non-finite values"
|
|
113
|
+
)
|
|
114
|
+
tempered: np.ndarray = temper * (raw - raw.max())
|
|
115
|
+
return tempered
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _per_state(
|
|
119
|
+
ontology: StateOntology,
|
|
120
|
+
values: Mapping[BehaviouralState, float],
|
|
121
|
+
default: float,
|
|
122
|
+
) -> np.ndarray:
|
|
123
|
+
"""Expand a per-state mapping into a vector in ontology order."""
|
|
124
|
+
return np.array(
|
|
125
|
+
[float(values.get(state, default)) for state in ontology.states],
|
|
126
|
+
dtype=float,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class PoissonEventEmission(EmissionModel):
|
|
132
|
+
"""Event sensors modelled as a state-dependent Poisson process.
|
|
133
|
+
|
|
134
|
+
Over an interval of length ``T`` in which ``n`` activations were recorded,
|
|
135
|
+
a state with rate ``lambda`` has log-likelihood ``n log(lambda) -
|
|
136
|
+
lambda * T`` up to a constant. This is the natural model for irregular
|
|
137
|
+
event streams, and it handles the two hard cases correctly without
|
|
138
|
+
special-casing:
|
|
139
|
+
|
|
140
|
+
*Silence is graded evidence.* A long quiet interval penalises high-rate
|
|
141
|
+
states in proportion to how long the silence lasted, rather than being
|
|
142
|
+
treated as an observation of zero activity.
|
|
143
|
+
|
|
144
|
+
*Asynchrony is free.* The likelihood depends on elapsed time, so events
|
|
145
|
+
arriving at irregular moments need no resampling onto a grid.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
rates
|
|
150
|
+
Activations per hour expected in each state.
|
|
151
|
+
default_rate
|
|
152
|
+
Rate per hour for states not named in *rates*.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
rates: Mapping[BehaviouralState, float] = field(default_factory=dict)
|
|
156
|
+
default_rate: float = 0.05
|
|
157
|
+
|
|
158
|
+
def __post_init__(self) -> None:
|
|
159
|
+
"""Validate the declared rates."""
|
|
160
|
+
super().__post_init__()
|
|
161
|
+
for state, rate in self.rates.items():
|
|
162
|
+
if not math.isfinite(rate) or rate < 0.0:
|
|
163
|
+
raise ValueError(f"rate for {state} must be finite and non-negative")
|
|
164
|
+
if not math.isfinite(self.default_rate) or self.default_rate < 0.0:
|
|
165
|
+
raise ValueError("default_rate must be finite and non-negative")
|
|
166
|
+
|
|
167
|
+
def _raw_log_likelihood(
|
|
168
|
+
self,
|
|
169
|
+
ontology: StateOntology,
|
|
170
|
+
observations: Sequence[Observation],
|
|
171
|
+
elapsed: timedelta,
|
|
172
|
+
) -> np.ndarray:
|
|
173
|
+
per_second = (
|
|
174
|
+
np.maximum(self._per_state(ontology, self.rates, self.default_rate), 0.0)
|
|
175
|
+
/ 3600.0
|
|
176
|
+
)
|
|
177
|
+
per_second = np.maximum(per_second, MIN_RATE)
|
|
178
|
+
count = sum(1 for obs in observations if obs.value != 0.0)
|
|
179
|
+
duration = max(elapsed.total_seconds(), 0.0)
|
|
180
|
+
loglik: np.ndarray = count * np.log(per_second) - per_second * duration
|
|
181
|
+
return loglik
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class GaussianEmission(EmissionModel):
|
|
186
|
+
"""Sampled scalar signals modelled as state-dependent Gaussians.
|
|
187
|
+
|
|
188
|
+
Suitable for wearable activity counts, ambient light, or any continuously
|
|
189
|
+
sampled quantity whose typical level differs between states.
|
|
190
|
+
|
|
191
|
+
Parameters
|
|
192
|
+
----------
|
|
193
|
+
means
|
|
194
|
+
Expected value of the signal in each state.
|
|
195
|
+
sigmas
|
|
196
|
+
Standard deviation in each state.
|
|
197
|
+
default_mean, default_sigma
|
|
198
|
+
Values used for states not named explicitly.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
means: Mapping[BehaviouralState, float] = field(default_factory=dict)
|
|
202
|
+
sigmas: Mapping[BehaviouralState, float] = field(default_factory=dict)
|
|
203
|
+
default_mean: float = 0.0
|
|
204
|
+
default_sigma: float = 1.0
|
|
205
|
+
|
|
206
|
+
def __post_init__(self) -> None:
|
|
207
|
+
"""Validate the declared moments."""
|
|
208
|
+
super().__post_init__()
|
|
209
|
+
if self.default_sigma <= 0.0 or not math.isfinite(self.default_sigma):
|
|
210
|
+
raise ValueError("default_sigma must be positive and finite")
|
|
211
|
+
for state, sigma in self.sigmas.items():
|
|
212
|
+
if not math.isfinite(sigma) or sigma <= 0.0:
|
|
213
|
+
raise ValueError(f"sigma for {state} must be positive and finite")
|
|
214
|
+
|
|
215
|
+
def _raw_log_likelihood(
|
|
216
|
+
self,
|
|
217
|
+
ontology: StateOntology,
|
|
218
|
+
observations: Sequence[Observation],
|
|
219
|
+
elapsed: timedelta,
|
|
220
|
+
) -> np.ndarray:
|
|
221
|
+
if not observations:
|
|
222
|
+
return np.zeros(ontology.size)
|
|
223
|
+
means = self._per_state(ontology, self.means, self.default_mean)
|
|
224
|
+
sigmas = self._per_state(ontology, self.sigmas, self.default_sigma)
|
|
225
|
+
values = np.array([obs.value for obs in observations], dtype=float)
|
|
226
|
+
residuals = values[:, None] - means[None, :]
|
|
227
|
+
loglik: np.ndarray = -(
|
|
228
|
+
0.5 * (residuals / sigmas[None, :]) ** 2 + np.log(sigmas)[None, :]
|
|
229
|
+
).sum(axis=0)
|
|
230
|
+
return loglik
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass
|
|
234
|
+
class BernoulliEmission(EmissionModel):
|
|
235
|
+
"""Binary state sensors modelled as state-dependent Bernoulli draws.
|
|
236
|
+
|
|
237
|
+
Suitable for bed or chair occupancy, where the sensor reports a level that
|
|
238
|
+
persists until it changes. Only the most recent reading in the interval is
|
|
239
|
+
used, because repeated reports of an unchanged state are not repeated
|
|
240
|
+
independent measurements of it.
|
|
241
|
+
|
|
242
|
+
Parameters
|
|
243
|
+
----------
|
|
244
|
+
probabilities
|
|
245
|
+
Probability the sensor reads one in each state.
|
|
246
|
+
default_probability
|
|
247
|
+
Probability for states not named explicitly.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
probabilities: Mapping[BehaviouralState, float] = field(default_factory=dict)
|
|
251
|
+
default_probability: float = 0.5
|
|
252
|
+
|
|
253
|
+
def __post_init__(self) -> None:
|
|
254
|
+
"""Validate the declared probabilities."""
|
|
255
|
+
super().__post_init__()
|
|
256
|
+
values = [*self.probabilities.values(), self.default_probability]
|
|
257
|
+
if any(not math.isfinite(p) or not 0.0 <= p <= 1.0 for p in values):
|
|
258
|
+
raise ValueError("probabilities must lie in [0, 1]")
|
|
259
|
+
|
|
260
|
+
def _raw_log_likelihood(
|
|
261
|
+
self,
|
|
262
|
+
ontology: StateOntology,
|
|
263
|
+
observations: Sequence[Observation],
|
|
264
|
+
elapsed: timedelta,
|
|
265
|
+
) -> np.ndarray:
|
|
266
|
+
if not observations:
|
|
267
|
+
return np.zeros(ontology.size)
|
|
268
|
+
probabilities = np.clip(
|
|
269
|
+
self._per_state(ontology, self.probabilities, self.default_probability),
|
|
270
|
+
EPSILON,
|
|
271
|
+
1.0 - EPSILON,
|
|
272
|
+
)
|
|
273
|
+
latest = observations[-1].value
|
|
274
|
+
loglik: np.ndarray = (
|
|
275
|
+
np.log(probabilities) if latest != 0.0 else np.log1p(-probabilities)
|
|
276
|
+
)
|
|
277
|
+
return loglik
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass
|
|
281
|
+
class BetaEmission(EmissionModel):
|
|
282
|
+
"""Probability-valued derived features modelled as Beta draws.
|
|
283
|
+
|
|
284
|
+
Radar and room-occupancy devices commonly report a presence probability
|
|
285
|
+
rather than a raw measurement. Treating that number as a Beta observation
|
|
286
|
+
keeps it on its natural support and lets the deployment state how much to
|
|
287
|
+
trust it through the concentration parameter, instead of hard-thresholding
|
|
288
|
+
it into a binary and discarding the uncertainty the device supplied.
|
|
289
|
+
|
|
290
|
+
Parameters
|
|
291
|
+
----------
|
|
292
|
+
means
|
|
293
|
+
Expected feature value in each state, strictly inside ``(0, 1)``.
|
|
294
|
+
concentration
|
|
295
|
+
Beta concentration. Larger values mean the feature is expected to sit
|
|
296
|
+
tightly around its state mean.
|
|
297
|
+
default_mean
|
|
298
|
+
Mean for states not named explicitly.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
means: Mapping[BehaviouralState, float] = field(default_factory=dict)
|
|
302
|
+
concentration: float = 8.0
|
|
303
|
+
default_mean: float = 0.5
|
|
304
|
+
|
|
305
|
+
def __post_init__(self) -> None:
|
|
306
|
+
"""Validate the declared means and concentration."""
|
|
307
|
+
super().__post_init__()
|
|
308
|
+
if not math.isfinite(self.concentration) or self.concentration <= 0.0:
|
|
309
|
+
raise ValueError("concentration must be positive and finite")
|
|
310
|
+
values = [*self.means.values(), self.default_mean]
|
|
311
|
+
if any(not math.isfinite(m) or not 0.0 <= m <= 1.0 for m in values):
|
|
312
|
+
raise ValueError("means must lie in [0, 1]")
|
|
313
|
+
|
|
314
|
+
def _raw_log_likelihood(
|
|
315
|
+
self,
|
|
316
|
+
ontology: StateOntology,
|
|
317
|
+
observations: Sequence[Observation],
|
|
318
|
+
elapsed: timedelta,
|
|
319
|
+
) -> np.ndarray:
|
|
320
|
+
if not observations:
|
|
321
|
+
return np.zeros(ontology.size)
|
|
322
|
+
means = np.clip(
|
|
323
|
+
self._per_state(ontology, self.means, self.default_mean),
|
|
324
|
+
EPSILON,
|
|
325
|
+
1.0 - EPSILON,
|
|
326
|
+
)
|
|
327
|
+
alpha = self.concentration * means
|
|
328
|
+
beta = self.concentration * (1.0 - means)
|
|
329
|
+
values = np.clip(
|
|
330
|
+
np.array([obs.value for obs in observations], dtype=float),
|
|
331
|
+
EPSILON,
|
|
332
|
+
1.0 - EPSILON,
|
|
333
|
+
)
|
|
334
|
+
log_values = np.log(values)[:, None]
|
|
335
|
+
log_complements = np.log1p(-values)[:, None]
|
|
336
|
+
loglik: np.ndarray = (
|
|
337
|
+
(alpha - 1.0) * log_values + (beta - 1.0) * log_complements
|
|
338
|
+
).sum(axis=0)
|
|
339
|
+
return loglik
|