Generated from the full canonical file for this source snapshot. Line numbers match the library source.
Source SHA256: 6b862676c158da63b04a11930682ee945eaf6c2ec2394a3791929230bd8ec32e
1"""Safeguarded limited-memory optimisation in a fixed, scaled parameter chart.23The evaluator owns the CUDA composition. It returns only a scalar objective and4the small parameter gradient, never a detector image. Parameterisations (pose5anchor, units, constrained nuisance variables) remain fixed throughout a solve.6Starting a new chart requires a new solve and therefore empty curvature history.7"""89from __future__ import annotations1011import math12from collections.abc import Callable13from dataclasses import dataclass14from typing import Literal, Protocol1516from dpt.contracts import ContractError, NumericalError, finite_scalar, integer1718Vector = tuple[float, ...]192021@dataclass(frozen=True, slots=True)22class Evaluation:23"""Objective and derivative with respect to the supplied dimensionless chart."""2425loss: float26gradient: Vector272829class Evaluator(Protocol):30def __call__(self, parameters: Vector, /) -> Evaluation:31"""Evaluate current parameters; invalid trials raise a numerical/domain error."""32...333435@dataclass(frozen=True, slots=True)36class RecoveryPolicy:37max_iterations: int = 20038max_evaluations: int = 200039memory: int = 1040line_search_evaluations: int = 3241gradient_tolerance: float = 1e-642relative_loss_tolerance: float = 1e-1243step_tolerance: float = 1e-1044armijo: float = 1e-445curvature: float = 0.946maximum_step: float = 16.047minimum_step: float = 1e-144849def __post_init__(self) -> None:50for name in ("max_iterations", "max_evaluations", "memory", "line_search_evaluations"):51integer(getattr(self, name), name, minimum=1)52for name in ("gradient_tolerance", "relative_loss_tolerance", "step_tolerance"):53finite_scalar(getattr(self, name), name, minimum=0.0)54for name in ("armijo", "curvature", "minimum_step", "maximum_step"):55finite_scalar(getattr(self, name), name, minimum=0.0)56if not 0.0 < self.armijo < self.curvature < 1.0:57raise ContractError("strong-Wolfe constants must satisfy 0 < c1 < c2 < 1")58if not 0.0 < self.minimum_step < self.maximum_step:59raise ContractError("line-search step interval must be positive and nonempty")606162StopReason = Literal[63"gradient_tolerance",64"step_tolerance",65"loss_stagnation",66"line_search_failed",67"evaluation_budget",68"iteration_budget",69"cancelled",70]717273@dataclass(frozen=True, slots=True)74class IterationRecord:75iteration: int76loss: float77gradient_infinity_norm: float78step: float79evaluations: int80curvature_pairs: int818283@dataclass(frozen=True, slots=True)84class RecoveryResult:85parameters: Vector86evaluation: Evaluation87reason: StopReason88evaluations: int89history: tuple[IterationRecord, ...]9091@property92def stationary(self) -> bool:93"""Only the gradient criterion establishes numerical stationarity here.9495Small loss/step changes indicate stagnation; they do not prove the pose96was recovered or that the current point is an identifiable minimum.97"""98return self.reason == "gradient_tolerance"99100101def _dot(a: Vector, b: Vector) -> float:102try:103return math.fsum(x * y for x, y in zip(a, b, strict=True))104except (OverflowError, ValueError):105# Curvature and line-search callers reject an unrepresentable product;106# it cannot certify a direction or replace the accepted iterate.107return math.nan108109110def _norm(a: Vector) -> float:111return max(abs(x) for x in a)112113114def _checked(evaluation: Evaluation, dimension: int) -> Evaluation:115if len(evaluation.gradient) != dimension:116raise ContractError("evaluator gradient dimension differs from its parameter chart")117if not math.isfinite(evaluation.loss) or not all(map(math.isfinite, evaluation.gradient)):118raise NumericalError("non-finite objective or parameter gradient")119return evaluation120121122# region book:registration-lbfgs-direction123def _direction(gradient: Vector, pairs: list[tuple[Vector, Vector, float]]) -> Vector:124"""Two-loop recursion; curvature vectors all belong to the same scaled chart."""125if not pairs:126return _steepest_direction(gradient)127q = gradient128coefficients: list[float] = []129for displacement, change, reciprocal in reversed(pairs):130coefficient = reciprocal * _dot(displacement, q)131coefficients.append(coefficient)132q = tuple(a - coefficient * b for a, b in zip(q, change, strict=True))133scale = 1.0134if pairs:135displacement, change, _ = pairs[-1]136denominator = _dot(change, change)137if not math.isfinite(denominator) or denominator <= 0.0:138return _steepest_direction(gradient)139scale = _dot(displacement, change) / denominator140if not math.isfinite(scale) or scale <= 0.0:141return _steepest_direction(gradient)142result = tuple(scale * value for value in q)143for (displacement, change, reciprocal), coefficient in zip(144pairs, reversed(coefficients), strict=True145):146beta = reciprocal * _dot(change, result)147result = tuple(148a + (coefficient - beta) * b for a, b in zip(result, displacement, strict=True)149)150return tuple(-value for value in result)151152153# endregion book:registration-lbfgs-direction154155156def _steepest_direction(gradient: Vector) -> Vector:157"""Bound the initial chart displacement without squaring a large gradient."""158scale = max(1.0, _norm(gradient))159return tuple(-value / scale for value in gradient)160161162class _BudgetExhaustedError(Exception):163pass164165166def _line_search(167evaluate: Callable[[Vector], Evaluation],168x: Vector,169base: Evaluation,170direction: Vector,171policy: RecoveryPolicy,172*,173initial_step: float = 1.0,174) -> tuple[float, Evaluation] | None:175"""Seek strong Wolfe, accepting strict Armijo decrease at the step bound.176177Bisection sacrifices polynomial interpolation speed to keep invalid-domain178endpoints and nonfinite trials unambiguous. No failed or unchecked trial is179returned as an accepted iterate. The last accepted state lives in the caller.180"""181slope = _dot(base.gradient, direction)182if not math.isfinite(slope) or slope >= 0:183return None184attempts = 0185186def trial(alpha: float) -> Evaluation | None:187nonlocal attempts188attempts += 1189point = tuple(value + alpha * step for value, step in zip(x, direction, strict=True))190if not all(map(math.isfinite, point)):191return None192try:193return evaluate(point)194except (NumericalError, OverflowError):195# Domain invalidity may be signalled as NumericalError by a196# parameterised evaluator. ContractError remains a programming error.197return None198199def armijo(alpha: float, candidate: Evaluation) -> bool:200return candidate.loss <= base.loss + policy.armijo * alpha * slope201202def zoom(low: float, high: float, low_value: Evaluation) -> tuple[float, Evaluation] | None:203while attempts < policy.line_search_evaluations:204alpha = 0.5 * (low + high)205if abs(high - low) < policy.minimum_step or alpha == low or alpha == high:206return None207candidate = trial(alpha)208if (209candidate is None210or not armijo(alpha, candidate)211or candidate.loss >= low_value.loss212):213high = alpha214continue215derivative = _dot(candidate.gradient, direction)216if abs(derivative) <= -policy.curvature * slope:217return alpha, candidate218if derivative * (high - low) >= 0:219high = low220low, low_value = alpha, candidate221return None222223previous_alpha, previous_value = 0.0, base224alpha = min(initial_step, policy.maximum_step)225while attempts < policy.line_search_evaluations:226candidate = trial(alpha)227if (228candidate is None229or not armijo(alpha, candidate)230or (previous_alpha > 0 and candidate.loss >= previous_value.loss)231):232return zoom(previous_alpha, alpha, previous_value)233derivative = _dot(candidate.gradient, direction)234if abs(derivative) <= -policy.curvature * slope:235return alpha, candidate236if alpha == policy.maximum_step and candidate.loss < base.loss:237return alpha, candidate238if derivative >= 0:239return zoom(alpha, previous_alpha, candidate)240if alpha == policy.maximum_step:241return None242previous_alpha, previous_value = alpha, candidate243alpha = min(2.0 * alpha, policy.maximum_step)244return None245246247# region book:registration-safeguarded-loop248def recover_parameters(249evaluator: Evaluator,250initial: Vector,251*,252policy: RecoveryPolicy | None = None,253observe: Callable[[IterationRecord], None] | None = None,254cancelled: Callable[[], bool] | None = None,255) -> RecoveryResult:256"""Minimise a deterministic objective in an immutable, dimensionless chart.257258The evaluator must use the same observation, forward model and parameter259coordinates for every call. It may reuse device scratch, but must complete260numerical-status checks before returning. Rejected trials never replace the261accepted parameters in the result. Observer exceptions are not swallowed.262"""263selected = policy or RecoveryPolicy()264x: Vector = tuple(finite_scalar(value, "initial parameter") for value in initial)265if not x:266raise ContractError("recovery requires at least one active parameter")267calls = 0268269def evaluate(point: Vector) -> Evaluation:270nonlocal calls271if calls >= selected.max_evaluations:272raise _BudgetExhaustedError273calls += 1274return _checked(evaluator(point), len(x))275276current = evaluate(x)277history: list[IterationRecord] = []278pairs: list[tuple[Vector, Vector, float]] = []279reason: StopReason = "iteration_budget"280history.append(IterationRecord(0, current.loss, _norm(current.gradient), 0.0, calls, 0))281for iteration in range(1, selected.max_iterations + 1):282if cancelled is not None and cancelled():283reason = "cancelled"284break285if _norm(current.gradient) <= selected.gradient_tolerance:286reason = "gradient_tolerance"287break288direction = _direction(current.gradient, pairs)289slope = _dot(direction, current.gradient)290if not all(map(math.isfinite, direction)) or not math.isfinite(slope) or slope >= 0:291pairs.clear()292direction = _steepest_direction(current.gradient)293try:294accepted = _line_search(evaluate, x, current, direction, selected)295if accepted is None:296# A bad inverse-Hessian estimate need not terminate a sound297# forward model: try steepest descent once with empty memory.298pairs.clear()299direction = _steepest_direction(current.gradient)300accepted = _line_search(301evaluate,302x,303current,304direction,305selected,306initial_step=max(307selected.minimum_step, 2.0 ** -min(16, selected.line_search_evaluations)308),309)310except _BudgetExhaustedError:311reason = "evaluation_budget"312break313if accepted is None:314reason = "line_search_failed"315break316alpha, new_value = accepted317step: Vector = tuple(alpha * value for value in direction)318new_x: Vector = tuple(a + b for a, b in zip(x, step, strict=True))319change = tuple(a - b for a, b in zip(new_value.gradient, current.gradient, strict=True))320curvature = _dot(step, change)321threshold = 1e-12 * math.hypot(*step) * math.hypot(*change)322if (323math.isfinite(curvature)324and curvature > max(0.0, threshold)325and math.isfinite(1.0 / curvature)326):327pairs.append((step, change, 1.0 / curvature))328if len(pairs) > selected.memory:329del pairs[0]330old_loss = current.loss331x, current = new_x, new_value332record = IterationRecord(333iteration, current.loss, _norm(current.gradient), alpha, calls, len(pairs)334)335history.append(record)336if observe is not None:337observe(record)338if _norm(current.gradient) <= selected.gradient_tolerance:339reason = "gradient_tolerance"340break341if _norm(step) <= selected.step_tolerance * max(1.0, _norm(x)):342reason = "step_tolerance"343break344if abs(old_loss - current.loss) <= selected.relative_loss_tolerance * max(3451.0, abs(old_loss), abs(current.loss)346):347reason = "loss_stagnation"348break349return RecoveryResult(x, current, reason, calls, tuple(history))350351352# endregion book:registration-safeguarded-loop353