Source code for elbow_helper.config

"""Configuration for the robust knee detector.

All positional thresholds are expressed in **normalized x-range units** (the
x-axis is scaled to ``[0, 1]`` during preprocessing), so they are independent
of the absolute scale of the caller's data.

The defaults follow the "first practical prototype" scope: modest replicate
counts so the full pipeline runs in seconds. For validation-grade runs raise
``bootstrap_replicates`` to 500 and ``null_replicates`` to 1000.

Author
------
Warith Harchaoui, <warithmetics@deraison.ai>
"""

from __future__ import annotations

from dataclasses import dataclass, replace
from typing import Optional, Tuple


[docs] @dataclass(frozen=True) class RobustKneeConfig: """Immutable bundle of thresholds and search settings. Use :meth:`with_` to derive a tweaked copy (the dataclass is frozen). """ # --- data adequacy --- min_samples: int = 20 # --- y normalization --- # Width of the median despiker applied to y before min/max scaling. A # median filter is the identity on locally monotone data, so it removes # isolated outliers without touching the head or tail of a genuinely # steep curve; set to 1 to disable it. despike_window: int = 3 # --- positional resolution --- # No precision requirement can be meaningfully finer than the x sampling # grid: a knee located on n samples is only ever known to within # 1 / (n - 1) of the normalized range. The gates that demand positional # precision -- cluster MAD, neighbor shift, bootstrap median shift and CI # width -- are floored at this many sample spacings. ``cluster_tolerance`` # deliberately is *not*: it decides which hits count as the same knee, so # widening it changes which knee gets reported, not how precisely. # See :meth:`RobustKneeConfig.positional`. positional_floor_samples: float = 2.0 # --- scale-space search --- smoothing_fractions: Tuple[float, ...] = ( 0.0, 0.02, 0.03, 0.05, 0.08, 0.12, 0.18, 0.25, ) sensitivity_fractions: Tuple[float, ...] = (0.0, 0.01, 0.02, 0.05) # How many smoothing scales beyond the finest one a cluster appears at are # read for its *location*. Persistence is judged across the whole scale # space, but a smoothed corner's difference-curve peak slides toward the # shallower side, so the coarse scales sit systematically late; see # :func:`~elbow_helper.clustering._fine_scale_location`. ``0`` reads the # finest scale alone, which is the only one carrying no displacement at # all: on curves whose breakpoint is unambiguous it is accurate to 0.18 # samples on average, against 0.50 for the two finest scales pooled, # because pooling two scales splits the difference with a drift rather # than averaging away a jitter. Jitter is still averaged, over the # several sensitivities the cluster has at that one scale. fine_scale_span: int = 0 # --- global shape compatibility --- min_spearman_abs: float = 0.60 max_direction_violation_rate: float = 0.25 # --- candidate basic filters --- # A knee is rejected when it sits within ``boundary_margin`` of either end # of the normalized range, or within ``boundary_min_samples`` samples of # it, whichever band is wider. Two parameters rather than one because the # two reasons an end knee is untrustworthy scale differently: too few # points on one side to read a slope from is a count, and it does not get # better on a longer curve; a feature indistinguishable from the curve # simply starting steeply is a fraction of the range. Expressing the # count as a fraction, as a single ``boundary_margin`` had to, overstates # it badly once the curve is long -- at n=200 a 0.10 margin discards the # first 20 samples, and a rank-10 factor structure lives there. See # :func:`~elbow_helper.metrics.passes_basic_filters`. # # 0.03 is calibrated, not guessed. Swept against 160 no-knee control # curves (straight, flat, flat plus noise, pure noise, line plus noise at # four lengths and eight seeds each) and the detection suites: # # margin plateau factor-rank false positives on controls # 0.10 4/5 2/4 1/160 # 0.06 5/5 3/4 1/160 # 0.04 5/5 4/4 1/160 # 0.03 5/5 4/4 1/160 # 0.01 5/5 4/4 1/160 # # The false-positive count does not move anywhere in that range, so the # margin was not buying conservatism; detection saturates at 0.04, and # 0.03 takes the full gain one step inside the plateau rather than # perched on its edge. boundary_margin: float = 0.03 boundary_min_samples: int = 5 min_side_points: int = 5 min_prominence: float = 0.05 min_noise_prominence_ratio: float = 4.0 # --- persistence clustering --- # cluster_tolerance and max_neighbor_shift are calibrated (§19) slightly # above the plan's 0.05 to absorb the one-to-two-sample locator # discretization jitter seen at modest sample sizes (n ~ 60-100). cluster_tolerance: float = 0.06 min_consecutive_scales: int = 3 min_sensitivity_support: float = 0.70 max_cluster_mad: float = 0.03 max_neighbor_shift: float = 0.07 # --- uniqueness --- secondary_support_frac: float = 0.30 min_dominance_ratio: float = 2.0 # --- slope / model confirmation --- slope_left_window: Tuple[float, float] = (0.15, 0.03) # (far, near) offsets slope_right_window: Tuple[float, float] = (0.03, 0.15) # (near, far) offsets min_slope_contrast: float = 0.30 min_cv_improvement: float = 0.10 min_bic_improvement: float = 10.0 cv_folds: int = 5 # --- bootstrap robustness --- # Width of the smoother that supplies the bootstrap's signal. Small on # purpose: it has to average noise away without rounding off the knee the # replicates are meant to re-find. bootstrap_signal_window: int = 5 bootstrap_replicates: int = 100 min_bootstrap_detection_rate: float = 0.90 max_ci90_width: float = 0.10 min_primary_cluster_rate: float = 0.80 max_secondary_cluster_rate: float = 0.15 max_bootstrap_median_shift: float = 0.03 # --- no-knee null test --- null_replicates: int = 200 max_null_p_value: float = 0.01 # --- reporting --- # Language of the plain-language ``NoClearKnee.detail`` sentence, "en" or # "fr". Only the wording depends on it; every threshold, measurement and # :class:`~elbow_helper.types.Reason` code is identical either way. It # lives on the config rather than on the call because the sentence is # assembled deep in the pipeline, where the numbers are, and the config is # the one thing already threaded to every stage. See # :mod:`elbow_helper.explain`. language: str = "en" # --- reproducibility --- random_seed: Optional[int] = None
[docs] def positional( self, threshold: float, n: int, samples: Optional[float] = None ) -> float: """Widen a positional threshold to at least the x sampling resolution. Thresholds on normalized ``x`` are written as curve fractions, but the quantity they bound is quantized at the sample spacing ``h = 1 / (n - 1)``: on a 49-point curve a single sample is already 0.021 of the range, so a 0.03 tolerance leaves no room for the one-to-two-sample jitter the locator inevitably has. This returns the threshold widened to ``samples * h`` whenever the grid is coarser than the constant, and leaves it untouched on finely sampled curves. Parameters ---------- threshold : float The configured tolerance, in normalized ``x`` units. n : int Number of samples on the curve. samples : float, optional Number of sample spacings to floor at. Defaults to ``positional_floor_samples``. Returns ------- float ``max(threshold, samples / (n - 1))``. """ if samples is None: samples = self.positional_floor_samples return max(float(threshold), float(samples) / max(int(n) - 1, 1))
[docs] def with_(self, **changes) -> RobustKneeConfig: """Return a copy of this config with ``changes`` applied. Parameters ---------- **changes Field overrides, e.g. ``config.with_(bootstrap_replicates=500)``. Returns ------- RobustKneeConfig A new, independent configuration. """ return replace(self, **changes)
[docs] @dataclass(frozen=True) class RobustKneesConfig: """Immutable settings for :func:`elbow_helper.robust_knees` (plural). The multi-knee search ships the combination validated in ``research/multiknee/RESULTS.md``: dynamic-program search, the subtractive-sign modified BIC as the selection criterion, and a Bonferroni-gated sequential permutation test layered on top by default, matching this package's design priority of minimising false-positive knees. Use :meth:`with_` to derive a tweaked copy. """ # --- data adequacy --- min_samples: int = 20 # --- search --- k_max: int = 4 min_seg_fraction: float = 0.08 # --- false-positive control --- fwer_alpha: float = 0.05 fwer_permutations: int = 200 require_fwer_confirmation: bool = True # --- reproducibility --- random_seed: Optional[int] = None
[docs] def with_(self, **changes) -> RobustKneesConfig: """Return a copy of this config with ``changes`` applied.""" return replace(self, **changes)