"""Phase 4 — per-candidate metrics and basic rejection filters.
Given a raw :class:`~elbow_helper.types.KneeCandidate` (location + the
difference curve it came from), attach its prominence, local-noise estimate,
prominence-to-noise ratio and boundary distance, then decide whether it clears
the cheap structural filters before any expensive stability analysis.
Author
------
Warith Harchaoui, <warithmetics@deraison.ai>
"""
from __future__ import annotations
import numpy as np
from .config import RobustKneeConfig
from .numerics import peak_prominence, robust_sigma_from_diffs
from .types import KneeCandidate, Reason
[docs]
def evaluate_candidate(
candidate: KneeCandidate,
y_difference: np.ndarray,
threshold_index: int,
y_scaled: np.ndarray,
) -> KneeCandidate:
"""Fill in a candidate's prominence, noise, ratio and boundary distance.
Parameters
----------
candidate : KneeCandidate
The candidate to annotate (mutated in place and returned).
y_difference : numpy.ndarray
The difference curve the candidate was found on.
threshold_index : int
Index of the generating peak on ``y_difference``.
y_scaled : numpy.ndarray
The (unsmoothed) scaled signal, for the robust noise estimate.
Returns
-------
KneeCandidate
The same object, with metric fields populated.
"""
candidate.prominence = peak_prominence(y_difference, threshold_index)
candidate.local_noise = robust_sigma_from_diffs(y_scaled)
denom = candidate.local_noise if candidate.local_noise > 1e-9 else 1e-9
candidate.noise_prominence_ratio = candidate.prominence / denom
candidate.boundary_distance = min(
candidate.knee_x_norm, 1.0 - candidate.knee_x_norm
)
return candidate
[docs]
def passes_basic_filters(
candidate: KneeCandidate, n: int, config: RobustKneeConfig
) -> bool:
"""Return ``True`` iff a candidate survives the cheap structural filters.
Sets ``candidate.rejected`` to a reason code when it fails. Rejections:
within the boundary margin, too few points on one side, within half a
smoothing window of an end, weak prominence, or weak prominence-to-noise.
The boundary margin is the wider of ``boundary_margin`` (a fraction of the
range) and ``boundary_min_samples`` (a count), because the two things it
guards against scale differently -- see the field comments on
:class:`~elbow_helper.config.RobustKneeConfig`. Taken as a fraction alone
it silently got stricter the longer the curve was, and on a long one it
did worse than miss an early knee: it rejected the knee at its true
position while keeping the drifted copy the coarse smoothing scales put
further in. A 200-point curve whose plateau begins at index 11 had
candidates at 11, 13, 14, 15 and 18 all thrown out as boundary artefacts,
and the cluster that survived to be reported sat at 21.
Parameters
----------
candidate : KneeCandidate
The annotated candidate.
n : int
Number of samples in the curve.
config : RobustKneeConfig
Filter thresholds.
Returns
-------
bool
Whether the candidate passes.
"""
margin = max(
config.boundary_margin,
config.boundary_min_samples / max(n - 1, 1),
)
if candidate.boundary_distance < margin:
candidate.rejected = Reason.BOUNDARY_KNEE
return False
left = candidate.knee_index
right = n - 1 - candidate.knee_index
if left < config.min_side_points or right < config.min_side_points:
candidate.rejected = Reason.BOUNDARY_KNEE
return False
half_window = candidate.window // 2
if candidate.knee_index < half_window or candidate.knee_index > n - 1 - half_window:
candidate.rejected = Reason.BOUNDARY_KNEE
return False
if candidate.prominence < config.min_prominence:
candidate.rejected = Reason.ALL_CANDIDATES_WEAK
return False
if candidate.noise_prominence_ratio < config.min_noise_prominence_ratio:
candidate.rejected = Reason.ALL_CANDIDATES_WEAK
return False
return True