Buckets:
| """Per-episode log-likelihood: how well one particle's rollout matches the observed track. | |
| This module produces exactly one number per particle per episode-object | |
| (:class:`~fpgm.physics.types.EpisodeLogLik`), and the arithmetic contract on that | |
| number is set by :mod:`fpgm.physics.types`, not by anything here: particles are | |
| pooled by *summing* ``logL_e(theta_i)`` across episodes, then softmax-ing. That | |
| means every choice below is judged by one question -- "does this preserve | |
| `constant-per-episode => softmax no-op`?" -- not by whether it looks like a | |
| normal likelihood function. | |
| **Why the returned vector is never normalised, and never has its max | |
| subtracted.** A per-episode ``logL - logsumexp(logL)`` (turning it into a proper | |
| normalised log-probability over particles) is the textbook thing to do with a | |
| log-likelihood, and it is exactly wrong here. ``logsumexp`` is *not* an additive | |
| constant -- it depends on the full shape of ``logL`` across particles, so two | |
| episodes with different informativeness would get shifted by different amounts | |
| before summing, and the sum would no longer equal ``sum_e logL_e(theta_i)`` | |
| evaluated on the raw densities. An uninformative episode (flat ``logL``) would | |
| still normalise to something informative-looking (a near-uniform but not exactly | |
| flat vector, once floating point is involved) and stop being a true no-op. So | |
| :func:`log_likelihood` returns a raw sum of per-frame log-densities and nothing | |
| more; the only place softmax happens is once, in | |
| :mod:`fpgm.physics.inference`, over the fully-summed weights. | |
| **Why Student-t, not Gaussian.** ``ObservedTrack.sigma_trans_m`` / | |
| ``sigma_rot_rad`` are measured noise floors from frames the object provably did | |
| not move (:mod:`fpgm.datagen.static_span`) -- but they describe the *typical* | |
| frame, not the worst one. A PnP pose estimate occasionally fails badly on a | |
| single frame (motion blur, a fleeting occlusion) without that meaning anything | |
| about theta. Under a Gaussian, one such frame contributes a residual-squared | |
| term that grows without bound and can dominate the whole episode's likelihood, | |
| i.e. one bad frame vetoes an otherwise-correct particle. A Student-t's tails | |
| decay polynomially instead of like ``exp(-x^2)``, so a single outlier frame's | |
| log-density floors out instead of diverging -- it still counts against a | |
| particle, just not catastrophically. ``nu=4`` is a fixed, moderate choice (not | |
| fit per episode): heavy enough tails to matter, not so heavy that genuinely | |
| informative frames stop contributing. This is a design default, not a measured | |
| value. | |
| **What "residual" means here.** :func:`se3_residuals` reduces each frame to two | |
| *scalar* magnitudes -- a Euclidean translation distance (metres, >= 0) and a | |
| geodesic rotation angle (radians, >= 0) -- rather than keeping the full 3-vector | |
| translation error or a per-axis rotation error. The Student-t density below is | |
| then evaluated on that scalar residual directly, symmetric around zero, with the | |
| residual's actual sign discarded (it has none: a distance is not signed). This is | |
| an approximation: the true sampling distribution of a Euclidean-norm residual | |
| under isotropic per-axis noise is a (generalised) chi-like distribution, not a | |
| Student-t. It is used anyway because (a) ``ObjectPoseDebug.pose_noise_mm`` is | |
| itself only a scalar spread, not a full 3x3 covariance, so a per-axis model would | |
| be inventing structure the upstream measurement does not support, and (b) the | |
| qualitative behaviour that actually matters for the importance weighting -- | |
| small residual -> high density, residual >> sigma -> density decays slowly, not | |
| catastrophically -- holds regardless. Treated as a calibrated, heavy-tailed | |
| penalty on "how many noise-floors away was this frame", not as a rigorously | |
| derived sampling density. | |
| **Quaternion convention and why these helpers are local numpy, not | |
| ``scipy.spatial.transform``.** ``predicted_poses`` is MuJoCo's own wire format | |
| straight out of :class:`~fpgm.physics.types.SimResult`: ``(w, x, y, z)``. | |
| ``scipy.spatial.transform.Rotation`` uses ``(x, y, z, w)``, so every call site | |
| touching it would need a manual axis reorder -- precisely the kind of "array | |
| looks right but is quietly transposed" bug | |
| :mod:`fpgm.physics.types`'s own docstring calls out as the reason this stage's | |
| wire formats are shape-checked in the first place. :mod:`fpgm.geometry.transforms` | |
| already sets the precedent of implementing SE3 conversions as plain numpy for | |
| this reason; :func:`_matrix_to_quat_wxyz` and :func:`_quat_geodesic_angle` | |
| follow it, fully vectorised over arbitrary leading dimensions so ``(T, 3, 3)`` | |
| and ``(N, T, 4)`` arrays are converted/compared in one call each, no Python loop | |
| over frames or particles. ``scipy.special.gammaln`` *is* used in | |
| :func:`_student_t_logpdf`, deliberately: it is a numerical special function with | |
| no convention to get backwards, unlike a rotation representation. | |
| **Why ``observed.n_valid == 0`` returns exact zeros, not an error or ``-inf``.** | |
| Zero valid frames means literally no observation was made of this object in this | |
| episode -- e.g. it was occluded the whole clip, or every PnP estimate failed | |
| S6's visibility gate. That is the purest case of "this episode carries no | |
| information": the correct contribution to ``sum_e logL_e(theta_i)`` is a | |
| constant (here, exactly zero) added to every particle, which is a no-op under | |
| softmax by the same arithmetic the whole stage is built on. Raising would turn a | |
| routine, expected situation (S6 does not promise every object is visible in | |
| every episode) into a pipeline failure; returning ``-inf`` would incorrectly | |
| veto every particle for an episode that said nothing at all. | |
| """ | |
| from __future__ import annotations | |
| from contextlib import nullcontext | |
| from typing import TYPE_CHECKING | |
| import numpy as np | |
| from scipy.special import gammaln | |
| from fpgm.physics.types import ObservedTrack, PhysicsError | |
| if TYPE_CHECKING: | |
| from fpgm.utils.timing import StepTimer | |
| __all__ = ["se3_residuals", "log_likelihood"] | |
| def _step(timer: StepTimer | None, label: str, *, n: int | None = None): | |
| """``timer.step(...)`` if a timer was given, else a no-op context manager. | |
| Keeps every function below able to declare ``timer: StepTimer | None = None`` | |
| without an ``if timer is not None: ... else: ...`` fork at every call site. | |
| """ | |
| if timer is None: | |
| return nullcontext() | |
| return timer.step(label, n=n) | |
| # --------------------------------------------------------------------------- # | |
| # Quaternion helpers (local numpy -- see module docstring for why) | |
| # --------------------------------------------------------------------------- # | |
| def _matrix_to_quat_wxyz(rot: np.ndarray) -> np.ndarray: | |
| """Vectorised ``(..., 3, 3)`` rotation matrix -> ``(..., 4)`` unit quaternion, ``(w, x, y, z)``. | |
| Shepperd's method: pick the numerically stable branch (largest of ``trace`` | |
| and the three diagonal entries) per-element via boolean masks, rather than | |
| the classic scalar if/elif chain, so this runs as four masked vector ops | |
| over the whole batch instead of a Python loop over ``T`` or ``N*T`` matrices. | |
| """ | |
| rot = np.asarray(rot, dtype=np.float64) | |
| lead_shape = rot.shape[:-2] | |
| m = rot.reshape(-1, 3, 3) | |
| n = m.shape[0] | |
| q = np.zeros((n, 4), dtype=np.float64) | |
| trace = m[:, 0, 0] + m[:, 1, 1] + m[:, 2, 2] | |
| d00, d11, d22 = m[:, 0, 0], m[:, 1, 1], m[:, 2, 2] | |
| case_trace = trace > 0 | |
| case_x = ~case_trace & (d00 >= d11) & (d00 >= d22) | |
| case_y = ~case_trace & (d11 > d00) & (d11 >= d22) | |
| case_z = ~case_trace & (d22 > d00) & (d22 > d11) | |
| if np.any(case_trace): | |
| c = case_trace | |
| s = np.sqrt(trace[c] + 1.0) * 2.0 # s = 4w | |
| q[c, 0] = 0.25 * s | |
| q[c, 1] = (m[c, 2, 1] - m[c, 1, 2]) / s | |
| q[c, 2] = (m[c, 0, 2] - m[c, 2, 0]) / s | |
| q[c, 3] = (m[c, 1, 0] - m[c, 0, 1]) / s | |
| if np.any(case_x): | |
| c = case_x | |
| s = np.sqrt(1.0 + d00[c] - d11[c] - d22[c]) * 2.0 # s = 4x | |
| q[c, 0] = (m[c, 2, 1] - m[c, 1, 2]) / s | |
| q[c, 1] = 0.25 * s | |
| q[c, 2] = (m[c, 0, 1] + m[c, 1, 0]) / s | |
| q[c, 3] = (m[c, 0, 2] + m[c, 2, 0]) / s | |
| if np.any(case_y): | |
| c = case_y | |
| s = np.sqrt(1.0 + d11[c] - d00[c] - d22[c]) * 2.0 # s = 4y | |
| q[c, 0] = (m[c, 0, 2] - m[c, 2, 0]) / s | |
| q[c, 1] = (m[c, 0, 1] + m[c, 1, 0]) / s | |
| q[c, 2] = 0.25 * s | |
| q[c, 3] = (m[c, 1, 2] + m[c, 2, 1]) / s | |
| if np.any(case_z): | |
| c = case_z | |
| s = np.sqrt(1.0 + d22[c] - d00[c] - d11[c]) * 2.0 # s = 4z | |
| q[c, 0] = (m[c, 1, 0] - m[c, 0, 1]) / s | |
| q[c, 1] = (m[c, 0, 2] + m[c, 2, 0]) / s | |
| q[c, 2] = (m[c, 1, 2] + m[c, 2, 1]) / s | |
| q[c, 3] = 0.25 * s | |
| return q.reshape(*lead_shape, 4) | |
| def _quat_multiply(q1: np.ndarray, q2: np.ndarray) -> np.ndarray: | |
| """Hamilton product of two batches of ``(..., 4)`` ``(w, x, y, z)`` quaternions.""" | |
| w1, x1, y1, z1 = q1[..., 0], q1[..., 1], q1[..., 2], q1[..., 3] | |
| w2, x2, y2, z2 = q2[..., 0], q2[..., 1], q2[..., 2], q2[..., 3] | |
| w = w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2 | |
| x = w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2 | |
| y = w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2 | |
| z = w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2 | |
| return np.stack([w, x, y, z], axis=-1) | |
| def _quat_geodesic_angle(q1: np.ndarray, q2: np.ndarray) -> np.ndarray: | |
| """Angle in ``[0, pi]`` radians between two batches of unit quaternions, ``(..., 4)``. | |
| Computed as ``2 * atan2(||relative.xyz||, |relative.w|)``, where | |
| ``relative = conjugate(q1) * q2`` is the quaternion that rotates frame 1 | |
| onto frame 2. The absolute value on ``relative.w`` accounts for ``q`` and | |
| ``-q`` representing the same rotation. | |
| This is deliberately *not* the more obvious ``2 * arccos(|dot(q1, q2)|)``: | |
| that formula is ill-conditioned exactly where it matters most for this | |
| module -- near-zero rotation error, which is the common case for a | |
| particle whose theta is roughly right. ``d/dx arccos(x)`` diverges as ``x | |
| -> 1``, so the ~1e-16 float noise ordinary quaternion arithmetic | |
| accumulates gets amplified to angle errors around 1e-7 rad, which is not | |
| negligible next to ``sigma_rot_rad`` (a few hundredths of a radian) -- | |
| this was measured directly while writing this module's tests, not assumed. | |
| ``atan2(small, ~1)`` has no such singularity: its response to the same | |
| float noise stays at the noise's own scale. Near the opposite extreme | |
| (angle ~pi), ``arccos`` is equally ill-conditioned and ``atan2`` is | |
| equally fine, by the same argument applied to the ``w`` component instead. | |
| """ | |
| q1 = np.asarray(q1, dtype=np.float64) | |
| q2 = np.asarray(q2, dtype=np.float64) | |
| q1 = q1 / (np.linalg.norm(q1, axis=-1, keepdims=True) + 1e-15) | |
| q2 = q2 / (np.linalg.norm(q2, axis=-1, keepdims=True) + 1e-15) | |
| q1_conj = q1 * np.array([1.0, -1.0, -1.0, -1.0]) | |
| relative = _quat_multiply(q1_conj, q2) | |
| xyz_norm = np.linalg.norm(relative[..., 1:4], axis=-1) | |
| w = np.abs(relative[..., 0]) | |
| return 2.0 * np.arctan2(xyz_norm, w) | |
| # --------------------------------------------------------------------------- # | |
| # Residuals | |
| # --------------------------------------------------------------------------- # | |
| def se3_residuals( | |
| observed: ObservedTrack, | |
| predicted_poses: np.ndarray, | |
| *, | |
| timer: StepTimer | None = None, | |
| ) -> tuple[np.ndarray, np.ndarray]: | |
| """Per-frame, per-particle pose error between ``observed`` and ``predicted_poses``. | |
| Args: | |
| observed: The measured track. Its ``valid`` mask decides which frames | |
| participate; see the "invalid frames" note below. | |
| predicted_poses: ``(N, T, 7)`` ``[x, y, z, qw, qx, qy, qz]``, MuJoCo's | |
| quaternion order (matches :class:`~fpgm.physics.types.SimResult`). | |
| timer: Optional :class:`~fpgm.utils.timing.StepTimer`; a clean no-op | |
| when ``None``. | |
| Returns: | |
| ``(trans_residuals, rot_residuals)``, each ``(N, T)``: | |
| translation in metres (Euclidean distance between positions), rotation | |
| in radians (geodesic angle between orientations). Both are ``NaN`` at | |
| every frame where ``observed.valid`` is ``False`` -- never zero. Zero | |
| would silently claim "predicted and observed agreed here"; ``NaN`` is | |
| the honest "there is no observation to compare against", and it is what | |
| lets :func:`log_likelihood` exclude those frames via ``nansum`` instead | |
| of accidentally rewarding a rollout for matching a pose nobody measured. | |
| """ | |
| predicted_poses = np.asarray(predicted_poses, dtype=np.float64) | |
| if predicted_poses.ndim != 3 or predicted_poses.shape[2] != 7: | |
| raise PhysicsError(f"predicted_poses must be (N, T, 7), got {predicted_poses.shape}") | |
| n_particles, n_frames, _ = predicted_poses.shape | |
| if n_frames != observed.T_world_obj.shape[0]: | |
| raise PhysicsError( | |
| f"{observed.label}: predicted_poses has {n_frames} frames, observed " | |
| f"track has {observed.T_world_obj.shape[0]}" | |
| ) | |
| with _step(timer, "se3_residuals", n=n_particles * n_frames): | |
| observed_pos = observed.T_world_obj[:, :3, 3] # (T, 3) | |
| observed_quat = _matrix_to_quat_wxyz(observed.T_world_obj[:, :3, :3]) # (T, 4) | |
| predicted_pos = predicted_poses[:, :, :3] # (N, T, 3) | |
| predicted_quat = predicted_poses[:, :, 3:7] # (N, T, 4) | |
| trans_res = np.linalg.norm(predicted_pos - observed_pos[None, :, :], axis=-1) | |
| rot_res = _quat_geodesic_angle(predicted_quat, observed_quat[None, :, :]) | |
| invalid = ~observed.valid | |
| trans_res[:, invalid] = np.nan | |
| rot_res[:, invalid] = np.nan | |
| return trans_res, rot_res | |
| # --------------------------------------------------------------------------- # | |
| # Log-likelihood | |
| # --------------------------------------------------------------------------- # | |
| def _student_t_logpdf(x: np.ndarray, sigma: float, nu: float) -> np.ndarray: | |
| """Log-density of a location-0, scale-``sigma`` Student-t at ``x`` (``NaN`` in, ``NaN`` out).""" | |
| z = x / sigma | |
| return ( | |
| gammaln((nu + 1.0) / 2.0) | |
| - gammaln(nu / 2.0) | |
| - 0.5 * np.log(nu * np.pi) | |
| - np.log(sigma) | |
| - 0.5 * (nu + 1.0) * np.log1p(z * z / nu) | |
| ) | |
| def log_likelihood( | |
| observed: ObservedTrack, | |
| predicted_poses: np.ndarray, | |
| ok: np.ndarray, | |
| *, | |
| nu: float = 4.0, | |
| timer: StepTimer | None = None, | |
| ) -> np.ndarray: | |
| """Raw per-particle log-likelihood of ``observed`` under each particle's rollout. | |
| Args: | |
| observed: The measured track. | |
| predicted_poses: ``(N, T, 7)``, see :func:`se3_residuals`. | |
| ok: ``(N,)`` bool, one per particle -- ``SimResult.ok``. ``False`` means | |
| the rollout diverged; that particle gets exactly ``-inf`` regardless | |
| of any residual computed from its (numerically meaningless) poses. | |
| ``-inf`` is a real statement ("this theta is inconsistent with | |
| physics itself, before even comparing to the observation"), not a | |
| dropped sample -- it still participates in the softmax denominator | |
| downstream in :mod:`fpgm.physics.inference`. | |
| nu: Student-t degrees of freedom; see the module docstring for why | |
| Student-t and why this default. | |
| timer: Optional :class:`~fpgm.utils.timing.StepTimer`. | |
| Returns: | |
| ``(N,)`` float64. Exactly ``np.zeros(N)`` if ``observed.n_valid == 0`` | |
| (see module docstring). Otherwise the sum, over valid frames only, of | |
| the translation and rotation Student-t log-densities -- **not** | |
| normalised, **not** max-subtracted. That is not an oversight: see the | |
| module docstring's first section for why either operation would break | |
| the "uninformative episode is a softmax no-op" invariant this whole | |
| stage depends on. | |
| """ | |
| predicted_poses = np.asarray(predicted_poses, dtype=np.float64) | |
| ok = np.asarray(ok, dtype=bool).reshape(-1) | |
| n_particles = predicted_poses.shape[0] | |
| if ok.shape[0] != n_particles: | |
| raise PhysicsError( | |
| f"{observed.label}: ok has {ok.shape[0]} entries for {n_particles} particles" | |
| ) | |
| if observed.n_valid == 0: | |
| return np.zeros(n_particles, dtype=np.float64) | |
| trans_res, rot_res = se3_residuals(observed, predicted_poses, timer=timer) | |
| with _step(timer, "score_frames", n=n_particles): | |
| with np.errstate(over="ignore", invalid="ignore"): | |
| trans_ll = _student_t_logpdf(trans_res, observed.sigma_trans_m, nu) | |
| rot_ll = _student_t_logpdf(rot_res, observed.sigma_rot_rad, nu) | |
| loglik = np.nansum(trans_ll, axis=1) + np.nansum(rot_ll, axis=1) | |
| loglik = np.where(ok, loglik, -np.inf) | |
| return loglik | |
Xet Storage Details
- Size:
- 16.8 kB
- Xet hash:
- ef4fae3c0fb2940e0cc47306b3e8c8396aadf5eab36103171afa5599f349eb29
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.