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,375 @@
|
|
|
1
|
+
"""The output of multimodal fusion: a state belief with its justification.
|
|
2
|
+
|
|
3
|
+
A :class:`StateEstimate` is deliberately more than an argmax. It carries the
|
|
4
|
+
full posterior, how confident that posterior is, how much of the sensing
|
|
5
|
+
apparatus was actually contributing, which sensors supported the conclusion
|
|
6
|
+
and which contradicted it, and which supplied nothing at all.
|
|
7
|
+
|
|
8
|
+
It can also decline. When confidence is low or too little evidence was
|
|
9
|
+
available, :attr:`StateEstimate.state` is
|
|
10
|
+
:attr:`~sensor_modeling.states.BehaviouralState.UNKNOWN` -- an abstention,
|
|
11
|
+
not a prediction of an eighth behaviour.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
from collections.abc import Mapping, Sequence
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from ..observations.types import Modality
|
|
24
|
+
from ..states.ontology import BehaviouralState, StateOntology
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class EvidenceContribution:
|
|
29
|
+
"""What one sensor contributed to the conclusion.
|
|
30
|
+
|
|
31
|
+
Attributes
|
|
32
|
+
----------
|
|
33
|
+
sensor_id
|
|
34
|
+
Sensor the contribution came from.
|
|
35
|
+
modality
|
|
36
|
+
Modality of that sensor.
|
|
37
|
+
support
|
|
38
|
+
Log-likelihood margin the sensor gives the winning state over the
|
|
39
|
+
best alternative. Positive means the sensor favours the conclusion,
|
|
40
|
+
negative means it favours something else, zero means it is
|
|
41
|
+
indifferent between them.
|
|
42
|
+
reliability
|
|
43
|
+
Health-derived evidence weight applied to the sensor.
|
|
44
|
+
attribution
|
|
45
|
+
Probability that what the sensor saw was generated by the monitored
|
|
46
|
+
resident rather than someone else.
|
|
47
|
+
observations
|
|
48
|
+
Number of records this sensor supplied over the interval.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
sensor_id: str
|
|
52
|
+
modality: Modality
|
|
53
|
+
support: float
|
|
54
|
+
reliability: float
|
|
55
|
+
attribution: float
|
|
56
|
+
observations: int
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def supports(self) -> bool:
|
|
60
|
+
"""Whether the sensor favours the reported state."""
|
|
61
|
+
return self.support > 0.0
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def contradicts(self) -> bool:
|
|
65
|
+
"""Whether the sensor favours some other state."""
|
|
66
|
+
return self.support < 0.0
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def reported(self) -> bool:
|
|
70
|
+
"""Whether the sensor produced any records over the interval."""
|
|
71
|
+
return self.observations > 0
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def informative(self) -> bool:
|
|
75
|
+
"""Whether the sensor's likelihood was applied at all.
|
|
76
|
+
|
|
77
|
+
A trusted event sensor that stayed quiet is still informative: under
|
|
78
|
+
a Poisson observation model its silence actively penalises the
|
|
79
|
+
states that would have triggered it. Only a sensor discounted to
|
|
80
|
+
zero reliability contributes nothing.
|
|
81
|
+
"""
|
|
82
|
+
return self.reliability > 0.0
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> dict[str, object]:
|
|
85
|
+
"""Return a serialisable form of the contribution."""
|
|
86
|
+
return {
|
|
87
|
+
"sensor_id": self.sensor_id,
|
|
88
|
+
"modality": self.modality.value,
|
|
89
|
+
"support": self.support,
|
|
90
|
+
"reliability": self.reliability,
|
|
91
|
+
"attribution": self.attribution,
|
|
92
|
+
"observations": self.observations,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Explanation:
|
|
98
|
+
"""Why an estimate reached its conclusion, as addressable parts.
|
|
99
|
+
|
|
100
|
+
Four fields carry information that a bare probability cannot, and each
|
|
101
|
+
answers a question a reader would otherwise have to guess at:
|
|
102
|
+
|
|
103
|
+
``supporting`` / ``contradicting``
|
|
104
|
+
Which sensors agreed and which pointed elsewhere. Disagreement is
|
|
105
|
+
surfaced rather than averaged away.
|
|
106
|
+
``silent``
|
|
107
|
+
Trusted sensors that reported nothing. Their silence *did* inform the
|
|
108
|
+
posterior -- under a Poisson model it penalises the states that would
|
|
109
|
+
have triggered them.
|
|
110
|
+
``missing``
|
|
111
|
+
Sensors discounted to zero reliability. These contributed nothing at
|
|
112
|
+
all, and the distinction from ``silent`` is the difference between a
|
|
113
|
+
quiet room and a broken sensor.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
at: datetime
|
|
117
|
+
state: BehaviouralState
|
|
118
|
+
probability: float
|
|
119
|
+
abstained: bool
|
|
120
|
+
reason: str | None
|
|
121
|
+
supporting: tuple[str, ...]
|
|
122
|
+
contradicting: tuple[str, ...]
|
|
123
|
+
silent: tuple[str, ...]
|
|
124
|
+
missing: tuple[str, ...]
|
|
125
|
+
completeness: float
|
|
126
|
+
|
|
127
|
+
def render(self) -> str:
|
|
128
|
+
"""Render the explanation as an indented, human-readable block."""
|
|
129
|
+
lines = [
|
|
130
|
+
f"State: {self.state.value}",
|
|
131
|
+
f"Probability: {self.probability:.2f}",
|
|
132
|
+
f"Sensor coverage: {self.completeness:.2f}",
|
|
133
|
+
]
|
|
134
|
+
if self.abstained and self.reason:
|
|
135
|
+
lines.append(f"Declined because: {self.reason}")
|
|
136
|
+
lines.append(f"Supporting evidence: {', '.join(self.supporting) or 'none'}")
|
|
137
|
+
lines.append(
|
|
138
|
+
f"Contradictory: {', '.join(self.contradicting) or 'none'}"
|
|
139
|
+
)
|
|
140
|
+
lines.append(f"Quiet but working: {', '.join(self.silent) or 'none'}")
|
|
141
|
+
lines.append(f"No evidence from: {', '.join(self.missing) or 'none'}")
|
|
142
|
+
return "\n".join(lines)
|
|
143
|
+
|
|
144
|
+
def to_dict(self) -> dict[str, object]:
|
|
145
|
+
"""Return a serialisable form of the explanation."""
|
|
146
|
+
return {
|
|
147
|
+
"at": self.at.isoformat(),
|
|
148
|
+
"state": self.state.value,
|
|
149
|
+
"probability": self.probability,
|
|
150
|
+
"abstained": self.abstained,
|
|
151
|
+
"reason": self.reason,
|
|
152
|
+
"supporting": list(self.supporting),
|
|
153
|
+
"contradicting": list(self.contradicting),
|
|
154
|
+
"silent": list(self.silent),
|
|
155
|
+
"missing": list(self.missing),
|
|
156
|
+
"completeness": self.completeness,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class StateEstimate:
|
|
162
|
+
"""A posterior over latent behavioural states, with its justification."""
|
|
163
|
+
|
|
164
|
+
at: datetime
|
|
165
|
+
ontology: StateOntology
|
|
166
|
+
belief: np.ndarray
|
|
167
|
+
evidence: tuple[EvidenceContribution, ...]
|
|
168
|
+
completeness: float
|
|
169
|
+
min_confidence: float
|
|
170
|
+
min_completeness: float
|
|
171
|
+
|
|
172
|
+
def __post_init__(self) -> None:
|
|
173
|
+
"""Validate the belief vector."""
|
|
174
|
+
belief = np.asarray(self.belief, dtype=float)
|
|
175
|
+
if belief.shape != (self.ontology.size,):
|
|
176
|
+
raise ValueError("belief must have one entry per ontology state")
|
|
177
|
+
if not np.all(np.isfinite(belief)) or belief.min() < 0.0:
|
|
178
|
+
raise ValueError("belief must be finite and non-negative")
|
|
179
|
+
total = belief.sum()
|
|
180
|
+
if total <= 0.0:
|
|
181
|
+
raise ValueError("belief must contain at least some probability mass")
|
|
182
|
+
object.__setattr__(self, "belief", belief / total)
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
@property
|
|
186
|
+
def probabilities(self) -> dict[BehaviouralState, float]:
|
|
187
|
+
"""Posterior probability of each latent state."""
|
|
188
|
+
return dict(zip(self.ontology.states, (float(p) for p in self.belief)))
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def most_likely(self) -> BehaviouralState:
|
|
192
|
+
"""The highest-posterior state, regardless of whether it is credible."""
|
|
193
|
+
return self.ontology.states[int(np.argmax(self.belief))]
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def confidence(self) -> float:
|
|
197
|
+
"""Posterior mass on the most likely state."""
|
|
198
|
+
return float(self.belief.max())
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def margin(self) -> float:
|
|
202
|
+
"""Gap between the two highest posterior probabilities."""
|
|
203
|
+
if self.belief.size < 2:
|
|
204
|
+
return self.confidence
|
|
205
|
+
ordered = np.sort(self.belief)
|
|
206
|
+
return float(ordered[-1] - ordered[-2])
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def entropy(self) -> float:
|
|
210
|
+
"""Shannon entropy of the posterior in nats."""
|
|
211
|
+
mass = self.belief[self.belief > 0.0]
|
|
212
|
+
return float(-(mass * np.log(mass)).sum())
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def normalised_entropy(self) -> float:
|
|
216
|
+
"""Entropy scaled to ``[0, 1]``; one means completely undecided."""
|
|
217
|
+
if self.ontology.size < 2:
|
|
218
|
+
return 0.0
|
|
219
|
+
return self.entropy / math.log(self.ontology.size)
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def abstained(self) -> bool:
|
|
223
|
+
"""Whether the estimate declines to name a state.
|
|
224
|
+
|
|
225
|
+
Two independent reasons force an abstention: the posterior is too
|
|
226
|
+
flat to justify a claim, or too little of the sensing apparatus was
|
|
227
|
+
contributing for any posterior to be trustworthy.
|
|
228
|
+
"""
|
|
229
|
+
return (
|
|
230
|
+
self.confidence < self.min_confidence
|
|
231
|
+
or self.completeness < self.min_completeness
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def state(self) -> BehaviouralState:
|
|
236
|
+
"""The reported state, or ``UNKNOWN`` when the estimate abstains."""
|
|
237
|
+
return BehaviouralState.UNKNOWN if self.abstained else self.most_likely
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
@property
|
|
241
|
+
def supporting(self) -> tuple[EvidenceContribution, ...]:
|
|
242
|
+
"""Sensors favouring the most likely state, strongest first."""
|
|
243
|
+
return tuple(
|
|
244
|
+
sorted(
|
|
245
|
+
(c for c in self.evidence if c.supports),
|
|
246
|
+
key=lambda c: c.support,
|
|
247
|
+
reverse=True,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def contradicting(self) -> tuple[EvidenceContribution, ...]:
|
|
253
|
+
"""Sensors favouring some other state, strongest first."""
|
|
254
|
+
return tuple(
|
|
255
|
+
sorted((c for c in self.evidence if c.contradicts), key=lambda c: c.support)
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def missing(self) -> tuple[str, ...]:
|
|
260
|
+
"""Sensors discounted to zero reliability, contributing nothing."""
|
|
261
|
+
return tuple(c.sensor_id for c in self.evidence if not c.informative)
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def silent(self) -> tuple[str, ...]:
|
|
265
|
+
"""Trusted sensors that reported nothing over the interval.
|
|
266
|
+
|
|
267
|
+
Their silence still informed the posterior; this is a record of what
|
|
268
|
+
was quiet, not of what was broken.
|
|
269
|
+
"""
|
|
270
|
+
return tuple(
|
|
271
|
+
c.sensor_id for c in self.evidence if c.informative and not c.reported
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def explanation(self, limit: int = 3) -> Explanation:
|
|
275
|
+
"""Return the justification for this estimate as structured data.
|
|
276
|
+
|
|
277
|
+
Separate from :meth:`explain` because the two have different
|
|
278
|
+
consumers: a report renders the sentence, while an interface or an
|
|
279
|
+
export needs the parts addressable. Both are built from the same
|
|
280
|
+
evidence, so they cannot drift apart.
|
|
281
|
+
"""
|
|
282
|
+
return Explanation(
|
|
283
|
+
at=self.at,
|
|
284
|
+
state=self.state,
|
|
285
|
+
probability=self.confidence,
|
|
286
|
+
abstained=self.abstained,
|
|
287
|
+
reason=(
|
|
288
|
+
None
|
|
289
|
+
if not self.abstained
|
|
290
|
+
else (
|
|
291
|
+
"insufficient sensor coverage"
|
|
292
|
+
if self.completeness < self.min_completeness
|
|
293
|
+
else "no state was clearly indicated"
|
|
294
|
+
)
|
|
295
|
+
),
|
|
296
|
+
supporting=tuple(c.sensor_id for c in self.supporting[:limit]),
|
|
297
|
+
contradicting=tuple(c.sensor_id for c in self.contradicting[:limit]),
|
|
298
|
+
silent=self.silent,
|
|
299
|
+
missing=self.missing,
|
|
300
|
+
completeness=self.completeness,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def explain(self, limit: int = 3) -> str:
|
|
304
|
+
"""Return a short human-readable justification for the estimate.
|
|
305
|
+
|
|
306
|
+
The wording is intentionally cautious. It reports what the sensors
|
|
307
|
+
indicated and how confident the model is, and makes no clinical claim.
|
|
308
|
+
"""
|
|
309
|
+
if self.abstained:
|
|
310
|
+
reason = (
|
|
311
|
+
"insufficient sensor coverage"
|
|
312
|
+
if self.completeness < self.min_completeness
|
|
313
|
+
else "no state was clearly indicated"
|
|
314
|
+
)
|
|
315
|
+
return (
|
|
316
|
+
f"State unknown at {self.at.isoformat()}: {reason} "
|
|
317
|
+
f"(confidence {self.confidence:.2f}, "
|
|
318
|
+
f"coverage {self.completeness:.2f})."
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
parts = [
|
|
322
|
+
f"{self.state.value} at {self.at.isoformat()} "
|
|
323
|
+
f"with probability {self.confidence:.2f}"
|
|
324
|
+
]
|
|
325
|
+
supporting = self.supporting[:limit]
|
|
326
|
+
if supporting:
|
|
327
|
+
named = ", ".join(f"{c.sensor_id} (+{c.support:.2f})" for c in supporting)
|
|
328
|
+
parts.append(f"supported by {named}")
|
|
329
|
+
contradicting = self.contradicting[:limit]
|
|
330
|
+
if contradicting:
|
|
331
|
+
named = ", ".join(f"{c.sensor_id} ({c.support:.2f})" for c in contradicting)
|
|
332
|
+
parts.append(f"contradicted by {named}")
|
|
333
|
+
if self.missing:
|
|
334
|
+
parts.append(f"no evidence from {', '.join(self.missing)}")
|
|
335
|
+
if self.silent:
|
|
336
|
+
parts.append(f"nothing reported by {', '.join(self.silent)}")
|
|
337
|
+
return "; ".join(parts) + "."
|
|
338
|
+
|
|
339
|
+
def to_dict(self) -> dict[str, object]:
|
|
340
|
+
"""Return a serialisable form of the estimate."""
|
|
341
|
+
return {
|
|
342
|
+
"at": self.at.isoformat(),
|
|
343
|
+
"state": self.state.value,
|
|
344
|
+
"most_likely": self.most_likely.value,
|
|
345
|
+
"abstained": self.abstained,
|
|
346
|
+
"confidence": self.confidence,
|
|
347
|
+
"margin": self.margin,
|
|
348
|
+
"normalised_entropy": self.normalised_entropy,
|
|
349
|
+
"completeness": self.completeness,
|
|
350
|
+
"probabilities": {
|
|
351
|
+
state.value: probability
|
|
352
|
+
for state, probability in self.probabilities.items()
|
|
353
|
+
},
|
|
354
|
+
"evidence": [c.to_dict() for c in self.evidence],
|
|
355
|
+
"missing": list(self.missing),
|
|
356
|
+
"silent": list(self.silent),
|
|
357
|
+
"explanation_parts": self.explanation().to_dict(),
|
|
358
|
+
"explanation": self.explain(),
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def belief_from_mapping(
|
|
363
|
+
ontology: StateOntology, probabilities: Mapping[BehaviouralState, float]
|
|
364
|
+
) -> np.ndarray:
|
|
365
|
+
"""Build a belief vector from a mapping, filling absent states with zero."""
|
|
366
|
+
return np.array(
|
|
367
|
+
[float(probabilities.get(state, 0.0)) for state in ontology.states], dtype=float
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def belief_matrix(estimates: Sequence[StateEstimate]) -> np.ndarray:
|
|
372
|
+
"""Stack the beliefs of several estimates into a ``(n, n_states)`` array."""
|
|
373
|
+
if not estimates:
|
|
374
|
+
return np.zeros((0, 0))
|
|
375
|
+
return np.vstack([estimate.belief for estimate in estimates])
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Recursive multimodal fusion into a latent behavioural state.
|
|
2
|
+
|
|
3
|
+
:class:`MultimodalBayesFilter` maintains ``P(Z_t | O_1:t)`` -- the posterior
|
|
4
|
+
over latent states given every observation so far, from every modality.
|
|
5
|
+
|
|
6
|
+
It is a forward filter over a continuous-time Markov chain, which is what lets
|
|
7
|
+
it accept the traffic an ambient deployment actually produces: observations
|
|
8
|
+
arriving asynchronously, at different rates per modality, with modalities
|
|
9
|
+
appearing and disappearing as sensors fail and recover. Between updates the
|
|
10
|
+
belief is propagated by the chain's transition operator for exactly the
|
|
11
|
+
elapsed interval; at each update every sensor that had something to say
|
|
12
|
+
contributes a tempered log-likelihood.
|
|
13
|
+
|
|
14
|
+
Three properties follow from the construction rather than from special cases:
|
|
15
|
+
|
|
16
|
+
*A failed sensor is silent, not negative.* Reliability enters as a tempering
|
|
17
|
+
exponent, so a sensor with reliability zero contributes a flat likelihood and
|
|
18
|
+
leaves the belief entirely to the other modalities.
|
|
19
|
+
|
|
20
|
+
*Absence of evidence widens the posterior.* With nothing to condition on, the
|
|
21
|
+
prediction step relaxes the belief toward the chain's stationary distribution,
|
|
22
|
+
confidence falls, and the estimate eventually abstains instead of coasting on
|
|
23
|
+
a stale conclusion.
|
|
24
|
+
|
|
25
|
+
*Contradiction is preserved, not resolved.* Sensors pointing at different
|
|
26
|
+
states pull the posterior apart, which shows up as a lower confidence and as
|
|
27
|
+
explicitly recorded contradicting evidence, rather than being averaged away.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from datetime import datetime
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
from scipy.special import logsumexp
|
|
39
|
+
|
|
40
|
+
from ..observations.observation import Observation, require_aware
|
|
41
|
+
from ..observations.registry import SensorRegistry
|
|
42
|
+
from ..observations.types import Modality
|
|
43
|
+
from ..states.ontology import StateOntology
|
|
44
|
+
from .emissions import EmissionModel
|
|
45
|
+
from .estimate import EvidenceContribution, StateEstimate
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NonMonotonicUpdateError(ValueError):
|
|
51
|
+
"""Raised when a filter update would move backwards in time.
|
|
52
|
+
|
|
53
|
+
The filter is causal by construction. Reordering late-arriving records is
|
|
54
|
+
the job of the surrounding pipeline, which owns a buffer and can decide
|
|
55
|
+
how much lateness to tolerate; silently folding a stale record into the
|
|
56
|
+
current belief would corrupt it without any trace.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class FusionConfig:
|
|
62
|
+
"""Thresholds governing when the filter declines to name a state.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
min_confidence
|
|
67
|
+
Posterior mass the leading state must hold to be reported.
|
|
68
|
+
min_completeness
|
|
69
|
+
Mean sensor reliability required before any posterior is trusted.
|
|
70
|
+
evidence_floor
|
|
71
|
+
Reliability below which a sensor is recorded as supplying nothing.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
min_confidence: float = 0.35
|
|
75
|
+
min_completeness: float = 0.25
|
|
76
|
+
evidence_floor: float = 0.05
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
"""Validate the abstention thresholds."""
|
|
80
|
+
for name in ("min_confidence", "min_completeness", "evidence_floor"):
|
|
81
|
+
value = float(getattr(self, name))
|
|
82
|
+
if not 0.0 <= value <= 1.0:
|
|
83
|
+
raise ValueError(f"{name} must lie in [0, 1]")
|
|
84
|
+
setattr(self, name, value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class MultimodalBayesFilter:
|
|
88
|
+
"""Forward filtering of latent behavioural state over asynchronous evidence.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
ontology
|
|
93
|
+
Latent states and their continuous-time dynamics.
|
|
94
|
+
emissions
|
|
95
|
+
One observation model per sensor that should inform the state.
|
|
96
|
+
registry
|
|
97
|
+
Sensor declarations, used to label evidence with its modality.
|
|
98
|
+
config
|
|
99
|
+
Abstention thresholds.
|
|
100
|
+
prior
|
|
101
|
+
Initial belief. Defaults to the ontology's stationary distribution,
|
|
102
|
+
which is the most defensible starting point before any evidence.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
ontology: StateOntology,
|
|
108
|
+
emissions: Iterable[EmissionModel],
|
|
109
|
+
registry: SensorRegistry | None = None,
|
|
110
|
+
config: FusionConfig | None = None,
|
|
111
|
+
prior: np.ndarray | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self.ontology = ontology
|
|
114
|
+
self.registry = registry
|
|
115
|
+
self.config = config or FusionConfig()
|
|
116
|
+
self.emissions: dict[str, EmissionModel] = {}
|
|
117
|
+
for emission in emissions:
|
|
118
|
+
if emission.sensor_id in self.emissions:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"duplicate emission model for sensor '{emission.sensor_id}'"
|
|
121
|
+
)
|
|
122
|
+
self.emissions[emission.sensor_id] = emission
|
|
123
|
+
if not self.emissions:
|
|
124
|
+
raise ValueError("at least one emission model is required")
|
|
125
|
+
|
|
126
|
+
self._prior: np.ndarray = self._validated_prior(prior)
|
|
127
|
+
self._belief: np.ndarray = self._prior.copy()
|
|
128
|
+
self._at: datetime | None = None
|
|
129
|
+
|
|
130
|
+
def _validated_prior(self, prior: np.ndarray | None) -> np.ndarray:
|
|
131
|
+
"""Return a normalised prior belief vector."""
|
|
132
|
+
if prior is None:
|
|
133
|
+
return self.ontology.stationary()
|
|
134
|
+
vector = np.asarray(prior, dtype=float)
|
|
135
|
+
if vector.shape != (self.ontology.size,):
|
|
136
|
+
raise ValueError("prior must have one entry per ontology state")
|
|
137
|
+
if not np.all(np.isfinite(vector)) or vector.min() < 0.0 or vector.sum() <= 0.0:
|
|
138
|
+
raise ValueError("prior must be finite, non-negative, and non-zero")
|
|
139
|
+
normalised: np.ndarray = vector / vector.sum()
|
|
140
|
+
return normalised
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
@property
|
|
144
|
+
def at(self) -> datetime | None:
|
|
145
|
+
"""Time of the most recent update, if the filter has been used."""
|
|
146
|
+
return self._at
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def belief(self) -> np.ndarray:
|
|
150
|
+
"""A copy of the current posterior over latent states."""
|
|
151
|
+
current: np.ndarray = self._belief.copy()
|
|
152
|
+
return current
|
|
153
|
+
|
|
154
|
+
def reset(self, prior: np.ndarray | None = None) -> None:
|
|
155
|
+
"""Return the filter to its initial belief and clear its clock."""
|
|
156
|
+
self._prior = self._validated_prior(prior)
|
|
157
|
+
self._belief = self._prior.copy()
|
|
158
|
+
self._at = None
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _weight_for(
|
|
163
|
+
weights: Mapping[str, float] | float | None, sensor_id: str, default: float
|
|
164
|
+
) -> float:
|
|
165
|
+
"""Resolve a per-sensor weight from a mapping, scalar, or default."""
|
|
166
|
+
if weights is None:
|
|
167
|
+
return default
|
|
168
|
+
if isinstance(weights, Mapping):
|
|
169
|
+
return float(weights.get(sensor_id, default))
|
|
170
|
+
return float(weights)
|
|
171
|
+
|
|
172
|
+
def _modality_of(self, sensor_id: str) -> Modality:
|
|
173
|
+
"""Return the modality of *sensor_id*, or ``OTHER`` when unknown."""
|
|
174
|
+
if self.registry is None:
|
|
175
|
+
return Modality.OTHER
|
|
176
|
+
spec = self.registry.get(sensor_id)
|
|
177
|
+
return spec.modality if spec is not None else Modality.OTHER
|
|
178
|
+
|
|
179
|
+
def update(
|
|
180
|
+
self,
|
|
181
|
+
now: datetime,
|
|
182
|
+
observations: Sequence[Observation] = (),
|
|
183
|
+
*,
|
|
184
|
+
reliabilities: Mapping[str, float] | float | None = None,
|
|
185
|
+
attribution: Mapping[str, float] | float | None = None,
|
|
186
|
+
) -> StateEstimate:
|
|
187
|
+
"""Advance the filter to *now* and fold in the interval's evidence.
|
|
188
|
+
|
|
189
|
+
Parameters
|
|
190
|
+
----------
|
|
191
|
+
now
|
|
192
|
+
Timezone-aware instant to advance to. Must not precede the
|
|
193
|
+
previous update.
|
|
194
|
+
observations
|
|
195
|
+
Records covering the interval since the previous update. Records
|
|
196
|
+
from sensors without an emission model are ignored.
|
|
197
|
+
reliabilities
|
|
198
|
+
Per-sensor evidence weights from the health monitor. A missing
|
|
199
|
+
entry defaults to full reliability.
|
|
200
|
+
attribution
|
|
201
|
+
Per-sensor probability that the monitored resident generated the
|
|
202
|
+
observation. A missing entry defaults to full attribution.
|
|
203
|
+
|
|
204
|
+
Returns
|
|
205
|
+
-------
|
|
206
|
+
StateEstimate
|
|
207
|
+
The posterior with its supporting and contradicting evidence.
|
|
208
|
+
"""
|
|
209
|
+
moment = require_aware(now, "now")
|
|
210
|
+
if self._at is not None and moment < self._at:
|
|
211
|
+
raise NonMonotonicUpdateError(
|
|
212
|
+
f"update at {moment.isoformat()} precedes the filter clock at "
|
|
213
|
+
f"{self._at.isoformat()}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
elapsed = moment - self._at if self._at is not None else moment - moment
|
|
217
|
+
predicted = self._belief @ self.ontology.transition(elapsed)
|
|
218
|
+
|
|
219
|
+
grouped: dict[str, list[Observation]] = {}
|
|
220
|
+
for observation in observations:
|
|
221
|
+
if observation.sensor_id in self.emissions:
|
|
222
|
+
grouped.setdefault(observation.sensor_id, []).append(observation)
|
|
223
|
+
else:
|
|
224
|
+
logger.debug(
|
|
225
|
+
"No emission model for sensor '%s'; observation ignored",
|
|
226
|
+
observation.sensor_id,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
log_belief = np.log(np.maximum(predicted, 1e-300))
|
|
230
|
+
likelihoods: dict[str, np.ndarray] = {}
|
|
231
|
+
weights: dict[str, tuple[float, float]] = {}
|
|
232
|
+
reliability_total = 0.0
|
|
233
|
+
|
|
234
|
+
for sensor_id, emission in self.emissions.items():
|
|
235
|
+
reliability = self._weight_for(reliabilities, sensor_id, 1.0)
|
|
236
|
+
share = self._weight_for(attribution, sensor_id, 1.0)
|
|
237
|
+
reliability_total += min(max(reliability, 0.0), 1.0)
|
|
238
|
+
weights[sensor_id] = (reliability, share)
|
|
239
|
+
likelihoods[sensor_id] = emission.log_likelihood(
|
|
240
|
+
self.ontology,
|
|
241
|
+
grouped.get(sensor_id, []),
|
|
242
|
+
elapsed,
|
|
243
|
+
reliability=reliability,
|
|
244
|
+
attribution=share,
|
|
245
|
+
)
|
|
246
|
+
log_belief = log_belief + likelihoods[sensor_id]
|
|
247
|
+
|
|
248
|
+
self._belief = np.exp(log_belief - logsumexp(log_belief))
|
|
249
|
+
self._at = moment
|
|
250
|
+
|
|
251
|
+
# Support is measured against the state the posterior actually
|
|
252
|
+
# settled on, so a sensor pointing somewhere else is recorded as
|
|
253
|
+
# contradicting the conclusion rather than as backing its own guess.
|
|
254
|
+
winner = int(np.argmax(self._belief))
|
|
255
|
+
contributions = tuple(
|
|
256
|
+
EvidenceContribution(
|
|
257
|
+
sensor_id=sensor_id,
|
|
258
|
+
modality=self._modality_of(sensor_id),
|
|
259
|
+
support=_support_for(likelihoods[sensor_id], winner),
|
|
260
|
+
reliability=(
|
|
261
|
+
weights[sensor_id][0]
|
|
262
|
+
if weights[sensor_id][0] >= self.config.evidence_floor
|
|
263
|
+
else 0.0
|
|
264
|
+
),
|
|
265
|
+
attribution=weights[sensor_id][1],
|
|
266
|
+
observations=len(grouped.get(sensor_id, [])),
|
|
267
|
+
)
|
|
268
|
+
for sensor_id in self.emissions
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return StateEstimate(
|
|
272
|
+
at=moment,
|
|
273
|
+
ontology=self.ontology,
|
|
274
|
+
belief=self._belief.copy(),
|
|
275
|
+
evidence=contributions,
|
|
276
|
+
completeness=reliability_total / len(self.emissions),
|
|
277
|
+
min_confidence=self.config.min_confidence,
|
|
278
|
+
min_completeness=self.config.min_completeness,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# ------------------------------------------------------------------
|
|
282
|
+
def snapshot(self) -> dict[str, object]:
|
|
283
|
+
"""Return restartable filter state."""
|
|
284
|
+
return {
|
|
285
|
+
"belief": self._belief.tolist(),
|
|
286
|
+
"at": self._at.isoformat() if self._at else None,
|
|
287
|
+
"states": self.ontology.labels(),
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
def restore(self, state: Mapping[str, object]) -> None:
|
|
291
|
+
"""Restore filter state produced by :meth:`snapshot`.
|
|
292
|
+
|
|
293
|
+
The stored state labels are checked against the current ontology, so
|
|
294
|
+
a belief saved under a different state set is rejected rather than
|
|
295
|
+
silently reinterpreted position by position.
|
|
296
|
+
"""
|
|
297
|
+
labels = state.get("states")
|
|
298
|
+
if labels is not None and list(labels) != self.ontology.labels(): # type: ignore[call-overload]
|
|
299
|
+
raise ValueError("snapshot was taken under a different state ontology")
|
|
300
|
+
belief = np.asarray(state["belief"], dtype=float)
|
|
301
|
+
if belief.shape != (self.ontology.size,):
|
|
302
|
+
raise ValueError("snapshot belief does not match the ontology size")
|
|
303
|
+
total = belief.sum()
|
|
304
|
+
if not np.all(np.isfinite(belief)) or belief.min() < 0.0 or total <= 0.0:
|
|
305
|
+
raise ValueError(
|
|
306
|
+
"snapshot belief must be finite, non-negative, and non-zero"
|
|
307
|
+
)
|
|
308
|
+
self._belief = belief / total
|
|
309
|
+
moment = state.get("at")
|
|
310
|
+
self._at = datetime.fromisoformat(str(moment)) if moment else None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _support_for(likelihood: np.ndarray, winner: int) -> float:
|
|
314
|
+
"""Return how much a sensor favours *winner* over the best alternative.
|
|
315
|
+
|
|
316
|
+
Positive means the sensor backs the reported state, negative means it
|
|
317
|
+
points elsewhere, and zero means it is indifferent -- which is what an
|
|
318
|
+
uninformative or fully discounted sensor produces.
|
|
319
|
+
"""
|
|
320
|
+
if likelihood.size < 2:
|
|
321
|
+
return 0.0
|
|
322
|
+
others = np.delete(likelihood, winner)
|
|
323
|
+
return float(likelihood[winner] - others.max())
|