fix: belief accumulates across frames (lazy-OR), not once
A track recognised on 318 of 385 frames was owned on none, so the truth file named nobody while the matcher was accepting almost continuously. The correlation discount was an annihilator rather than an attenuator. Weight was 1 - P(same view), so once a track had one stored view every later frame of that same face scored ~0.01 and the belief stopped moving. One observation just over the accept threshold is logit(0.78) ~ 1.27, under the ownership bar — hence recognised always, owned never. Two changes, in the order they were found. Correlated evidence is now attenuated by effective sample size, n_eff = n / (1 + (n-1)·rho), each frame contributing the marginal gain. That has the right shape at both ends: uncorrelated evidence accumulates linearly, and a held pose converges on 1/rho rather than growing without bound. A constant floor was tried first and rejected — it grows linearly forever, so a long shot could out-argue genuinely varied evidence purely by lasting longer. Combination is now weighted lazy-OR: P = 1 - (1-P_old)·(1-p)^w, stored as log(1-P) so the update is additive and precision stays where it matters as P approaches 1. Each frame is new evidence that this track is that actor, and the belief is the probability that at least one sighting was right. It converges faster than summing log-odds at the same effective count — 2.98 vs 2.53 after two observations at p=0.78 — which is what a real clip needs. Note that summing log-odds was already a correct sequential Bayesian update: the matcher fits with prior 0.5, so logit(p) IS the per-frame log-likelihood ratio and the running sum carries the prior forward. It was not wrong, it was slow. What blocked ownership was the discount, not the combination rule. Also fixes a real correctness bug: the observation count lived on the discounter, which is shared by every track, so tracks pooled into one effective sample and each was discounted by how many others happened to be on screen. It is now a per-track parameter. The registry's frame scope holds its lock for its lifetime and the mutex is not recursive, so calling observe() inside a scope self-deadlocks. The pipeline never does — separate nodes — but the test did, and hung rather than failing. Documented at the call site. Verified end to end: the same clip that produced zero actors now identifies Bing Crosby and Dorothy Lamour with belief 0.97. Suite: 96 cases, 6142 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-025 | SR-002
This commit is contained in:
@@ -41,7 +41,26 @@ public:
|
||||
struct Config {
|
||||
int max_views{8}; ///< distinct views remembered per track
|
||||
float admit_below{0.6f}; ///< P(same view) under this ⇒ a new view
|
||||
float floor{0.0f}; ///< minimum weight for a redundant observation
|
||||
|
||||
/// Ceiling on the correlation between two observations of one track.
|
||||
///
|
||||
/// This is what bounds the accumulation. `n_eff = n / (1 + (n-1)·rho)`
|
||||
/// tends to `1/rho` as `n` grows, so `rho_max` sets how much a single
|
||||
/// repeated view can ever be worth: 0.5 caps it at two observations,
|
||||
/// no matter how long the shot runs.
|
||||
///
|
||||
/// 0.5 caps a repeated view at two independent observations' worth,
|
||||
/// which is what lets a track the matcher accepts on frame after frame
|
||||
/// actually become owned. Higher values starve ownership; the sweep
|
||||
/// (VR-007) decides where it belongs.
|
||||
///
|
||||
/// It is capped below 1 deliberately. P(same view) near 1 says the two
|
||||
/// crops look alike; it does not say the second carries no information.
|
||||
/// A fresh frame is a fresh detection, a fresh alignment and a fresh
|
||||
/// noise realisation, so a little independent evidence survives even a
|
||||
/// perfectly held pose. Setting this to 1 recovers the original bug —
|
||||
/// belief frozen after the first frame.
|
||||
float rho_max{0.5f};
|
||||
};
|
||||
|
||||
// Two constructors rather than a defaulted argument: `Config{}` as a default
|
||||
@@ -53,12 +72,36 @@ public:
|
||||
EvidenceDiscounter(Calibrate cal, Config cfg)
|
||||
: cal_(std::move(cal)), cfg_(cfg) {}
|
||||
|
||||
/// Weight in [0,1] for one observation, updating `views` when the
|
||||
/// observation is novel enough to count as a distinct look at the subject.
|
||||
/// The marginal evidence one observation adds, in units of independent
|
||||
/// observations.
|
||||
///
|
||||
/// The first observation on a track always counts in full: there is nothing
|
||||
/// for it to be redundant with.
|
||||
float weight(std::vector<Embedding>& views, const Embedding& e) const {
|
||||
/// Each frame is a Bayesian update, so confidence must keep growing — but
|
||||
/// correlated observations must grow it less, and must not grow it without
|
||||
/// bound. The standard treatment is **effective sample size**:
|
||||
///
|
||||
/// n_eff(n) = n / (1 + (n-1)·rho)
|
||||
///
|
||||
/// and this returns `n_eff(n) - n_eff(n-1)`, the gain from *this* frame.
|
||||
/// The shape is right at both ends: with rho = 0 every frame counts fully
|
||||
/// and the belief accumulates linearly, while as rho rises the series
|
||||
/// converges on `1/rho` and a held pose stops adding no matter how long it
|
||||
/// is held.
|
||||
///
|
||||
/// The two failure modes it sits between are both real and both were hit:
|
||||
/// a weight of 0 for repeats froze the belief after one frame, so a track
|
||||
/// recognised on 318 frames was owned on none; a constant floor grew it
|
||||
/// linearly forever, so a long shot could out-argue genuinely varied
|
||||
/// evidence purely by lasting longer.
|
||||
///
|
||||
/// `rho` is estimated from P(same view) against the closest stored view,
|
||||
/// capped by `rho_max`. The first observation has nothing to be redundant
|
||||
/// with and counts in full.
|
||||
/// `n_seen` is the count of observations already folded into THIS track.
|
||||
/// It is a parameter rather than discounter state because one discounter
|
||||
/// serves every track: holding the count internally would pool unrelated
|
||||
/// tracks into one effective sample, so a busy film would silently discount
|
||||
/// each track by how many others happened to be on screen.
|
||||
float weight(std::vector<Embedding>& views, int n_seen, const Embedding& e) const {
|
||||
if (views.empty()) {
|
||||
views.push_back(e);
|
||||
return 1.0f;
|
||||
@@ -68,9 +111,12 @@ public:
|
||||
for (const auto& v : views)
|
||||
p_same = std::max(p_same, cal_(cosine_similarity(v, e)));
|
||||
|
||||
// Weight is the probability this is *not* a repeat of something already
|
||||
// counted. A near-duplicate contributes ~0; an unseen pose ~1.
|
||||
const float w = std::max(cfg_.floor, 1.0f - p_same);
|
||||
const float rho = std::min(cfg_.rho_max, std::max(0.0f, p_same));
|
||||
|
||||
const float n_prev = static_cast<float>(std::max(1, n_seen));
|
||||
const float n_now = n_prev + 1.0f;
|
||||
auto n_eff = [rho](float n) { return n / (1.0f + (n - 1.0f) * rho); };
|
||||
const float w = std::max(0.0f, n_eff(n_now) - n_eff(n_prev));
|
||||
|
||||
if (p_same < cfg_.admit_below &&
|
||||
static_cast<int>(views.size()) < cfg_.max_views) {
|
||||
|
||||
Reference in New Issue
Block a user