Source code for knowledgespaces.estimation.discrepancy

"""State inclusion rules for discrepancy-based estimation, as in pks.

Independent implementations of the distance rules, with exact feasibility
under fixed-zero error parameters and log-scaled hyperbolic weights.
"""

from dataclasses import dataclass
from operator import index
from typing import Literal

import numpy as np


[docs] @dataclass(frozen=True) class MDOptions: """Discrepancy assignment options shared by fitting and refitting. ``minimum`` includes feasible states with d(R,K) <= d_min + radius. ``hypblc1`` weights all feasible states by (1+d-d_min)**(-exponent); ``hypblc2`` uses (1+d)**(-exponent). Hyperbolic rules are available for non-iterative MD only, with radius=0. The exponent must be positive. Radius is an excess distance from the response, not a distance between a state and the nearest state. Defaults recover ordinary MD/MDML. These are the pks ``incradius`` and ``blimMD`` inclusion conventions. """ rule: Literal["minimum", "hypblc1", "hypblc2"] = "minimum" radius: int = 0 exponent: float = 1.0 def __post_init__(self) -> None: if self.rule not in ("minimum", "hypblc1", "hypblc2"): raise ValueError("rule must be 'minimum', 'hypblc1', or 'hypblc2'.") try: radius = index(self.radius) except TypeError as error: raise ValueError("radius must be a nonnegative integer.") from error if isinstance(self.radius, (bool, np.bool_)) or radius < 0: raise ValueError("radius must be a nonnegative integer.") object.__setattr__(self, "radius", radius) if not np.isfinite(self.exponent) or self.exponent <= 0: raise ValueError("exponent must be finite and positive.") if self.rule != "minimum" and radius: raise ValueError("Hyperbolic rules require radius=0.") if self.rule == "minimum" and self.exponent != 1: raise ValueError("exponent applies only to hyperbolic rules.")
def _validate_options(options: MDOptions | None, method: str) -> MDOptions: if options is not None and not isinstance(options, MDOptions): raise TypeError("discrepancy must be an MDOptions instance.") if options is not None and method == "ML": raise ValueError("Discrepancy options apply only to MD/MDML.") spec = options or MDOptions() if method != "MD" and spec.rule != "minimum": raise ValueError("Hyperbolic inclusion is supported only by MD.") return spec def _discrepancy_weights( responses: np.ndarray, membership: np.ndarray, counts: np.ndarray, options: MDOptions, feasible: np.ndarray | None = None, ) -> np.ndarray: distance = responses @ (1 - membership.T) + (1 - responses) @ membership.T if feasible is not None: distance[~feasible] = np.inf minimum = distance.min(axis=1, keepdims=True) possible = np.isfinite(minimum[:, 0]) if np.any((counts > 0) & ~possible): raise ValueError("Observed responses are impossible under the fixed parameters.") # Arbitrary harmless assignment for impossible zero-frequency rows. distance[~possible] = 0 minimum[~possible] = 0 if options.rule == "minimum": # Hamming distances never exceed the item count; avoid conversion # overflow for an arbitrarily large integer radius. radius = min(options.radius, responses.shape[1]) return (np.isfinite(distance) & (distance <= minimum + radius)).astype(float) offset = distance - minimum if options.rule == "hypblc1" else distance log_base = np.log1p(offset) # A rowwise constant cancels on normalization; subtract before multiplying # to avoid all weights underflowing for very large exponents. log_base -= log_base.min(axis=1, keepdims=True) with np.errstate(over="ignore"): return np.exp(-options.exponent * log_base)