Source code for mixle.reason.task_projection
"""Task-sufficient projection ``pi_T`` for receiver-specific beliefs.
A :class:`~mixle.reason.modality.ModalityView` can carry a full structured
belief, while a receiver for task ``T`` may need only the smallest projection
that preserves the distinctions relevant to that task. This module builds that
operator on existing closed-form projection tools from
:mod:`mixle.inference.project`: components that ``task`` cannot distinguish
are moment-matched into one Gaussian, while components the task can
distinguish are kept separate.
This is task-specific projection rather than generic compression. A projection
built for one task should be validated before being reused for another.
"""
from __future__ import annotations
from collections.abc import Callable, Hashable
from dataclasses import dataclass
from typing import Any
import numpy as np
from mixle.inference.project import collapse_mixture
from mixle.stats.latent.mixture import MixtureDistribution
[docs]
@dataclass
class TaskReadout:
"""Task readout used to decide which mixture components can be merged.
``label(mean)`` maps a component mean to a discrete readout value. Components
sharing a readout are indistinguishable for this task and may be merged;
components with different readouts remain separate.
"""
name: str
label: Callable[[np.ndarray], Hashable]
projection_error: Callable[[tuple[Any, ...], np.ndarray, Any], float] | None = None
max_projection_error: float = 0.0
def __post_init__(self) -> None:
if not isinstance(self.name, str) or not self.name.strip():
raise ValueError("TaskReadout.name must be a non-empty string.")
if not callable(self.label):
raise TypeError("TaskReadout.label must be callable.")
if self.projection_error is not None and not callable(self.projection_error):
raise TypeError("TaskReadout.projection_error must be callable when provided.")
self.max_projection_error = float(self.max_projection_error)
if not np.isfinite(self.max_projection_error) or self.max_projection_error < 0.0:
raise ValueError("max_projection_error must be finite and non-negative.")
[docs]
def task_sufficient_projection(mixture: Any, task: TaskReadout) -> MixtureDistribution:
"""``pi_T(mixture)``: collapse ``mixture``'s components into groups sharing ``task.label``.
Components are grouped by ``task.label(component_mean)``. Groups with more
than one component are moment-matched by
:func:`~mixle.inference.project.collapse_mixture`; singleton groups pass
through unchanged. The result never has more components than the input.
"""
w = _validated_weights(mixture)
means = _component_means(mixture)
groups: dict[Hashable, list[int]] = {}
for k in range(len(w)):
groups.setdefault(task.label(means[k]), []).append(k)
merged_dists = [_merge_group(mixture, idx, task) for idx in groups.values()]
merged_w = np.asarray([float(w[idx].sum()) for idx in groups.values()])
return MixtureDistribution(merged_dists, merged_w / merged_w.sum())
[docs]
def read_out(mixture: Any, task: TaskReadout, x: Any) -> Hashable:
"""Return the task label of the component most responsible for ``x``.
The same readout applies to a full or projected belief, so a projection can
be evaluated by the task labels it preserves.
"""
w = _validated_weights(mixture)
means = _component_means(mixture)
log_post = np.array(
[np.log(max(w[k], 1e-300)) + float(mixture.components[k].log_density(x)) for k in range(len(w))]
)
if np.isnan(log_post).any() or np.isposinf(log_post).any():
raise ValueError("component scoring returned an invalid posterior responsibility.")
if not np.isfinite(log_post).any():
raise ValueError("observation is impossible under every mixture component; task readout is undefined.")
return task.label(means[int(np.argmax(log_post))])
def _validated_weights(mixture: Any) -> np.ndarray:
components = tuple(getattr(mixture, "components", ()))
w = np.asarray(getattr(mixture, "w", ()), dtype=float)
if not components or w.shape != (len(components),):
raise ValueError("mixture must contain one weight per non-empty component.")
if not np.isfinite(w).all() or np.any(w < 0.0) or not np.isclose(float(w.sum()), 1.0, atol=1e-10):
raise ValueError("mixture weights must be finite, non-negative, and normalized.")
return w
def _component_means(mixture: Any) -> np.ndarray:
if hasattr(mixture, "mu") and hasattr(mixture, "sig2"): # GaussianMixtureDistribution: (K, d) directly
return np.asarray(mixture.mu, dtype=float)
means = []
for c in mixture.components:
if hasattr(c, "covar"): # MultivariateGaussianDistribution(mu, covar)
means.append(np.asarray(c.mu, dtype=float).ravel())
elif hasattr(c, "sigma2"): # univariate GaussianDistribution(mu, sigma2)
means.append(np.array([float(c.mu)]))
else:
raise ValueError(
f"component {type(c).__name__} is not Gaussian; task_sufficient_projection needs a "
"Gaussian mixture belief (see mixle.inference.project for the same restriction)."
)
return np.asarray(means)
def _merge_group(mixture: Any, idx: list[int], task: TaskReadout) -> Any:
if len(idx) == 1:
return mixture.components[idx[0]]
if task.projection_error is None:
raise ValueError(
f"task {task.name!r} groups distinct components but supplies no projection_error verifier; "
"equal labels at component means do not prove task sufficiency."
)
sub_w = np.asarray(mixture.w, dtype=float)[idx]
sub = MixtureDistribution([mixture.components[i] for i in idx], sub_w / sub_w.sum())
merged = collapse_mixture(sub)
error = float(task.projection_error(tuple(sub.components), np.asarray(sub.w, dtype=float), merged))
if not np.isfinite(error) or error < 0.0:
raise ValueError("projection_error must return a finite non-negative bound.")
if error > task.max_projection_error:
raise ValueError(
f"task projection error {error} exceeds the declared bound {task.max_projection_error} "
f"for task {task.name!r}."
)
return merged