Source code for gain.genomic_resources.statistics.min_max

from __future__ import annotations

import numpy as np
import yaml

from gain.genomic_resources.statistics.base_statistic import (
    NON_NUMERIC_ERRORS,
    PYTHON_NUMBER_TYPES,
    Statistic,
    as_python_number,
    non_numeric_error,
)


[docs] class MinMaxValue(Statistic): """Statistic that calculates Min and Max values in a genomic score.""" def __init__( self, score_id: str, min_value: float = np.nan, max_value: float = np.nan, ): super().__init__("min_max", "Calculates Min and Max values") self.score_id = score_id self.min = min_value self.max = max_value
[docs] def add_value(self, value: float | np.generic | None) -> None: """Fold one value into the running extremum. The same numeric contract as ``NumberHistogram.add_value``, stated the same way and in the same order, so that a nullified score's reason reads alike whichever twin refused (gain#1313, gain#1358). The agreement is pinned value by value in ``test_numeric_reducer_twins``. """ # ``np.isnan`` is the first refusal: it raises on text and # ``Decimal``, and re-wording its complaint costs ~nothing when it # does not fire, where an isinstance guard AHEAD of it costs this # per-record path measurably (gain#1313). # # Skip nan as ``NumberHistogram.add_value`` does: a ``min(nan, x)`` / # ``max(nan, x)`` returns nan and would wipe the running extremum (and # a trailing nan would nullify the histogram via the view_range check). # A nan reaches here only as a literal value token that parsed to nan # but is not a configured NA sentinel; both are non-values for min/max. try: if value is None or np.isnan(value): return except NON_NUMERIC_ERRORS as err: raise non_numeric_error(value, "a min/max") from err # Reached only by values ``np.isnan`` accepted -- which is not the # same as values a min/max can fold: a complex or a 0-d array passes # it, and ``min()`` would order either without complaint and leave # the extremum holding it (gain#1358). A Python number folds as-is # (``np.float64`` IS one, it subclasses ``float``); everything else # goes through the rule the histogram twin shares, which says why a # numpy scalar folds as ``item()`` -- so the extremum a histogram # later reads its ``view_range`` from is not in float32 by accident # -- and why the rest is refused. The isinstance stays inline # because this runs per value of every record and a Python float is # what the scan folds. if not isinstance(value, PYTHON_NUMBER_TYPES): value = as_python_number(value, "a min/max") self.min = min(value, self.min) self.max = max(value, self.max)
[docs] def merge(self, other: Statistic) -> None: if not isinstance(other, MinMaxValue): raise TypeError("unexpected type of statistics to merge with") if self.score_id != other.score_id: raise ValueError( "Attempting to merge min max values of different scores!", ) if np.isnan(self.min): self.min = min(other.min, self.min) else: self.min = min(self.min, other.min) if np.isnan(self.max): self.max = max(other.max, self.max) else: self.max = max(self.max, other.max)
[docs] def serialize(self) -> str: return yaml.dump({ "score_id": self.score_id, "min": self.min, "max": self.max, })
[docs] @staticmethod def deserialize(content: str) -> MinMaxValue: # Unknown keys are ignored rather than rejected, so a file carrying # extra fields still reads. data = yaml.safe_load(content) return MinMaxValue( data["score_id"], data["min"], data["max"], )
[docs] class NullMinMaxValue(MinMaxValue): """A score's min/max, refused rather than measured. The min/max twin of :class:`~gain.genomic_resources.histogram.NullHistogram`, and it exists for the same reason: a reducer that refuses a value has to leave something behind that the rest of the pass can keep feeding harmlessly, so one score costs one score rather than the resource's whole statistics build (gain#1285, gain#1313). ``add_value`` is a no-op, which is what makes the refusal cost ONE exception rather than one per record -- the score is swapped out for this and every later record folds into nothing. Without that latch a caller catching per value would pay a raise per record, which on a genome-scale resource is multiples of the pass it is protecting. It keeps the inherited nan min and max, so a caller that never learned about this type still reads it as a score with no values to bin. ``update_hist_confs`` does know about it, and nullifies it in a branch of its own -- the same outcome as the nan branch beside it, reached without depending on the seed, and worded from ``reason``: "the values could not be folded" and "there were no values" are different facts about a resource, and a curator reading a nullified score needs to know which. It deliberately does NOT override the inherited :meth:`MinMaxValue.serialize` / :meth:`MinMaxValue.deserialize`, which would write the nan seed and read back a plain ``MinMaxValue`` -- losing the refusal. Those two have no callers on any live path (ADR 0001), and a refused min/max never reaches a file; a refusal travels between the region tasks and the merge as an OBJECT. This is the one asymmetry with ``NullHistogram``, which avoids the trap by being a sibling of its histogram rather than a subclass. """ def __init__(self, score_id: str, reason: str): super().__init__(score_id) self.reason = reason
[docs] def add_value( self, value: float | np.generic | None, # ruff: ignore[unused-method-argument] ) -> None: # pylint: disable=unused-argument return
[docs] def merge( self, other: Statistic, # ruff: ignore[unused-method-argument] ) -> None: # pylint: disable=unused-argument # Stays refused: a region that folded values cannot un-refuse a score # another region could not fold. ``merge_min_max`` states the other # direction, where the refusal has to travel INTO a live fold. return
[docs] class MinMaxValueStatisticMixin:
[docs] @staticmethod def get_min_max_file(score_id: str) -> str: return f"min_max_{score_id}.yaml"