Notebook
Embedding filters 4 — Ensemble Kalman & information filters
Two ways to carry a full covariance on a high-dimensional embedding
without the O(D^2)-O(D^3) cost of a dense Kalman filter:
- Information filter — the exact Kalman dual. It stores the inverse covariance, which turns the measurement update into a plain addition. Its posterior is identical to the Kalman filter's (the library test asserts this to 1e-4).
- Ensemble Kalman filter (EnKF) — represents the covariance
implicitly by an ensemble of sample states. Cost scales with the
ensemble size, not
D-squared; this is the method built for very high-dimensional filtering. unitrack uses a deterministic ETKF transform, so the step is reproducible.
import torch
import matplotlib.pyplot as plt
import unitrack
from unitrack.assignment import Associate, Jonker
from unitrack.costs import Cosine
from unitrack.data import Detections, FrameContext, TensorSpec
from unitrack.lifecycle import IncludeAll, NoLifecycle
from unitrack.pipeline import Pipe
torch.manual_seed(0)
D = 16 # embedding dimensionality (256+ in practice; 16 plots fast)
T = 44 # frames
def make_clip(noise=0.15, seed=0, switch=None):
"""
A unit embedding that rotates slowly in the (e0, e1) plane plus
per-frame noise in all D dims. Rotating in a known plane means
projecting onto dims (0, 1) shows the true path as a circle arc.
`switch` optionally rotates the plane mid-clip (an appearance change).
"""
g = torch.Generator().manual_seed(seed)
t = torch.arange(T).float()
theta = 0.10 * t
truth = torch.zeros(T, D)
truth[:, 0] = torch.cos(theta)
truth[:, 1] = torch.sin(theta)
if switch is not None:
# after `switch`, swap appearance into the (e2, e3) plane.
truth[switch:, :] = 0.0
truth[switch:, 2] = torch.cos(theta[switch:])
truth[switch:, 3] = torch.sin(theta[switch:])
obs = truth + noise * torch.randn(T, D, generator=g)
obs = torch.nn.functional.normalize(obs, dim=-1)
dets = [Detections(index=torch.tensor([0]), emb=obs[k:k + 1].clone(),
batch_size=[1]) for k in range(T)]
return t, truth, obs, dets
def run(tracker, dets, fields=("emb",)):
"""Run a single-object clip through a real Tracker; collect snapshot fields."""
ms = unitrack.MultiStream(tracker)
rec = {f: [] for f in fields}
for k, d in enumerate(dets):
ctx = FrameContext.make(frame_idx=k, delta=1.0, fps=1.0, stream_key=0)
res = ms.step(stream_key=0, detections=d, ctx=ctx)
for f in fields:
rec[f].append(getattr(res.snapshot, f)[0].clone())
return {f: torch.stack(v) for f, v in rec.items()}
def cos_to_truth(est, truth):
e = torch.nn.functional.normalize(est, dim=-1)
u = torch.nn.functional.normalize(truth, dim=-1)
return (e * u).sum(-1)
t, truth, obs, dets = make_clip()
print(f"clip: {T} frames, D={D}; raw-detection mean cosine-to-truth "
f"= {cos_to_truth(obs, truth).mean():.3f}")
Output
clip: 44 frames, D=16; raw-detection mean cosine-to-truth = 0.855
from unitrack.states import enkf_state_entries, information_state_entries
def tracker_for(states):
return unitrack.Tracker(
root=Pipe(cost=Cosine("emb"), assoc=Associate(Jonker(threshold=0.6))),
states=states, lifecycle=NoLifecycle(), visibility=IncludeAll(),
)
info = run(tracker_for(information_state_entries("emb", dim=D, q=0.02, r=0.2)),
dets, fields=("emb", "emb_infomat"))
enkf = run(tracker_for(enkf_state_entries("emb", dim=D, ensemble_size=24,
q=0.02, r=0.2, init_std=0.4)),
dets, fields=("emb", "emb_ensemble"))
# Both track the drift; compare to raw.
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(t, cos_to_truth(obs, truth), color="0.6", ls=":", label="raw detections")
ax.plot(t, cos_to_truth(info["emb"], truth), color="tab:blue",
label=f"information filter ({cos_to_truth(info['emb'], truth).mean():.3f})")
ax.plot(t, cos_to_truth(enkf["emb"], truth), color="tab:orange",
label=f"EnKF ({cos_to_truth(enkf['emb'], truth).mean():.3f})")
ax.set_title("Information filter vs EnKF — cosine to truth")
ax.set_xlabel("frame"); ax.set_ylabel("cosine"); ax.legend(fontsize=8)
ax.grid(alpha=0.3)
plt.tight_layout(); plt.show()

Two views of the same shrinking uncertainty
Left: the EnKF ensemble, projected to 2-D — the cloud of members
contracts as measurements arrive (that contraction is the
covariance update; no D-by-D matrix is ever formed). Right: the
information filter's covariance trace (tr(Y^{-1})) falling in
lockstep — the explicit dual of the same belief.
fig, (axe, axt) = plt.subplots(1, 2, figsize=(12, 4))
cmap = plt.get_cmap("viridis")
for k in range(0, T, 6):
members = enkf["emb_ensemble"][k] # (E, D)
axe.scatter(members[:, 0], members[:, 1], s=14, color=cmap(k / T),
alpha=0.6)
axe.plot(enkf["emb"][:, 0], enkf["emb"][:, 1], "-", color="0.3", lw=1,
label="ensemble mean")
axe.set_title("EnKF ensemble (proj. to dims 0,1) contracts over time\n"
"(purple = early, yellow = late)")
axe.set_xlabel("dim 0"); axe.set_ylabel("dim 1"); axe.legend(fontsize=8)
axe.grid(alpha=0.3)
cov_trace = torch.stack([
torch.linalg.inv(info["emb_infomat"][k]).diagonal().sum() for k in range(T)])
ens_trace = torch.stack([enkf["emb_ensemble"][k].var(dim=0).sum() for k in range(T)])
axt.plot(t, cov_trace, color="tab:blue", label="information filter tr(P)")
axt.plot(t, ens_trace, color="tab:orange", label="EnKF ensemble spread")
axt.set_title("Total uncertainty falls under measurements")
axt.set_xlabel("frame"); axt.set_ylabel("trace / spread"); axt.legend(fontsize=8)
axt.grid(alpha=0.3)
plt.tight_layout(); plt.show()

Takeaway. Use the information filter when you want the exact
Gaussian posterior and D is moderate (its update is a cheap
addition, ideal for fusing many cues). Use the EnKF when D is
large enough that a dense covariance is impractical — the ensemble
buys you covariance information at a cost set by the ensemble size.