Bayesian Calculation Deep Dive¶
This document provides a detailed mathematical explanation of the probability calculation used for area occupancy detection: a sigmoid/logit-space model that combines a learned prior with per-sensor evidence. "Bayesian" here refers to the general shape of the problem — start from a prior belief, update it with evidence — not to classic Bayes'-theorem odds multiplication. The engine is sigmoid_probability() in custom_components/area_occupancy/utils.py, reached via presence_probability() and environmental_confidence().
Not naive Bayes
This integration previously shipped a classic log-odds naive-Bayes accumulator (bayesian_probability()). It was superseded by the model below in PR #353 (2026.2.1) and removed entirely in PR #529 (2026.8.1) after being confirmed to have zero production call sites. If you're looking at older material (a cached doc, a stale comment) that describes multiplying likelihoods and renormalizing, it's describing the removed function, not current behavior.
Mathematical Foundation¶
The Prior as a Log-Odds Bias¶
The learned prior (global prior combined with time-of-day prior — see Global Prior Flow and Time Prior Flow) is converted to logit space and used as the starting point, or bias, for the calculation:
A prior of 0.5 (no lean either way) gives a bias of 0. A prior of 0.3 gives a bias of about -0.85 (below the midline, i.e. leaning "not occupied" before any sensor evidence is considered).
Additive Evidence in Logit Space¶
Each sensor then adds a weighted term to that starting value:
Where, for entity \(i\):
- \(w_i\): effective weight (
entity.effective_weight, falling back toentity.weight) — the entity's configured weight, reduced for sensors with low learned information gain - \(e_i\): evidence —
1.0if actively providing evidence, a fractional decay factor (0.0–1.0) if decaying from recent activity, or0.0if inactive and not decaying - \(c_i\): correlation — a learned 0.0–1.0 multiplier from
db/correlation.py's statistical analysis (defaults to1.0if no correlation data exists yet) - \(s_i\): strength factor —
prob_given_true × strength_multiplier, i.e. the sensor's learned reliability (prob_given_true) scaled by a per-InputTypeconstant (3.0for motion,2.0for most other types — seeDEFAULT_TYPESindata/entity_type.py) that gives ground-truth-quality sensors a stronger say
Why Logit Space?¶
Working in logit (log-odds) space instead of raw probability gives the same numerical-stability benefits classic Bayesian log-space combination has, plus a simpler combination rule:
- Purely additive: each sensor is one term added to a running sum — no renormalization step, no multiplying probabilities together
- Order independence: summation is commutative, so entity iteration order can never change the result
- Unbounded contribution range: a very strong or very weak sensor can push
zarbitrarily far from 0 without the intermediate math overflowing, since the sigmoid squashes the final sum back into(0, 1) - No likelihood-ratio bookkeeping: unlike naive Bayes, there's no need to track separate "occupied" and "not occupied" running totals and normalize between them at the end — one running sum, one sigmoid call
Step-by-Step Calculation¶
Step 1: Initialize from the Prior¶
The prior is clamped to [MIN_PROBABILITY, MAX_PROBABILITY] (0.01–0.99) first, so logit() never sees exactly 0 or 1.
Step 2: Entity Loop¶
For each entity with weight > 0:
- Determine evidence (\(e_i\)):
- Actively providing evidence (
entity.evidence is True) →1.0 - Not currently active, but decaying from recent activity (
entity.decay.is_decaying) →entity.decay_factor, which fades from1.0toward0.0over the decay window - Neither →
0.0— an inactive sensor contributes nothing, not negative evidence. This is a deliberate difference from a classic naive-Bayes formulation, where an inactive sensor with a highprob_given_truewould actively push the posterior down. Here it simply drops out of the sum. - Look up correlation (\(c_i\)): the entity's learned correlation strength, or
1.0if none is available yet. - Compute the strength factor (\(s_i\)):
entity.prob_given_true * entity.type.strength_multiplier. - Accumulate:
z += effective_weight * evidence * correlation * strength_factor.
Step 3: Convert Back to Probability¶
sigmoid() branches on the sign of z internally (computing 1/(1+e^{-z}) for z >= 0 and e^{z}/(1+e^{z}) for z < 0) so it never evaluates e^{x} for a large positive x, avoiding overflow. The result is then clamped to [MIN_PROBABILITY, MAX_PROBABILITY].
Decay Interpolation¶
While an entity is decaying (recently active, now inactive, within its half-life window), its evidence value is the decay factor itself — not the learned likelihood interpolated toward neutral, and not the raw prob_given_true:
So a decaying entity's whole contribution — effective_weight × decay_factor × correlation × strength_factor — shrinks toward zero as decay_factor shrinks toward zero. There's no intermediate "neutral 0.5" state to interpolate through; the contribution just fades out. See data/decay.py for how decay_factor itself is computed from elapsed time and half-life.
Weight Application¶
effective_weight (falling back to plain weight when unset) scales each entity's whole contribution linearly:
- Full weight (1.0): the entity's evidence/correlation/strength product enters
zunscaled - Half weight (0.5): half the contribution
- Zero weight: the entity is skipped before evidence is even evaluated
Because contributions are additive in logit space rather than multiplicative in probability space, doubling a weight roughly doubles that entity's logit-space pull — the effect on the final sigmoid()-transformed probability is nonlinear (compressed near 0 and 1, closer to linear near 0.5).
Complete Example Calculation¶
Consider an area with prior 0.3 and three sensors:
| Sensor | State | Weight | prob_given_true | strength_multiplier |
|---|---|---|---|---|
| Motion | Active | 1.0 | 0.95 | 3.0 |
| Media player | Inactive (not decaying) | 0.85 | 0.65 | 2.0 |
| Door | Active | 0.3 | 0.2 | 2.0 |
Assume no learned correlations yet (c_i = 1.0 for all).
Step 1 — bias from the prior:
Step 2 — motion (active):
Step 3 — media player (inactive, not decaying → evidence = 0):
Step 4 — door (active):
Step 5 — sigmoid:
The active, high-reliability motion sensor dominates (contributing +2.85 in logit space, vs. the door's +0.12), pulling the result from a 30% prior up to ~89%. The inactive media player contributes nothing — it neither reinforces nor opposes the motion evidence.
Numerical Stability Techniques¶
Probability Clamping¶
Every probability that flows through logit() — the prior, and the final result — is clamped to [MIN_PROBABILITY, MAX_PROBABILITY] (clamp_probability(), default 0.01–0.99) so ln(p/(1-p)) never divides by zero or takes ln(0).
Sign-Branched Sigmoid¶
sigmoid(z) computes 1/(1+e^{-z}) when z >= 0 and the algebraically equivalent e^{z}/(1+e^{z}) when z < 0, so math.exp() is only ever called with a non-positive argument — no overflow risk regardless of how large z grows from a long list of strong sensors.
Edge Case Handling¶
An empty entities dict, or every entity having weight <= 0, returns the (clamped) prior unchanged rather than dividing by zero or looping over nothing meaningfully.
Performance Considerations¶
- Single pass, O(n): one loop over entities accumulating a running sum — no separate "true" and "false" accumulators to maintain in parallel, and no final normalization/division step (
sigmoid()is already bounded). - Correlation lookups: correlation strengths are precomputed by the hourly analysis pipeline (
db/correlation.py) and passed in as a plain dict, not queried per-entity during the hot path. - Prior caching: time-based priors are cached by
(day_of_week, hour_slot)and reloaded in the executor when they change, so the real-time calculation never issues a database query.
See Also¶
- Complete Calculation Flow - End-to-end process
- Calculation Feature - User-facing documentation
- Prior Calculation Deep Dive - How priors are calculated
- Likelihood Calculation Deep Dive - How likelihoods are learned