AnomalyResult & ClusteringResult¶
What a fitted detector puts on result_. Most fields are self-explanatory; the
ones below exist to stop a number being read as something it is not.
In short
- Clustering quality indices (
*_features) are measured in feature space;metric_coveragesays how many rows they saw. - Check
verdictbefore any score: it says whether the run found clusters at all. - The anomaly headline is
separation, notanomaly_ratio. - Read distinctive fields with
distinctive_fields(profiles, cluster_id). The table is descriptive and carries no p-value. - Every negative cluster label is noise;
cluster_profileskeeps those rows, so filter tocluster_id >= 0for one row per segment.
Reading the numbers¶
Clustering quality is measured in feature space. silhouette_features,
davies_bouldin_features and calinski_harabasz_features are computed on the
un-reduced fused matrix — the space your data is actually in. The _features
suffix is deliberate: the auto-k sweep scores in the (possibly UMAP-reduced)
clustering geometry, and geometry_space names that space. Three caveats apply
to the reported index, and the result carries the evidence for each:
| Question | Field |
|---|---|
| How much of the data did the metric see? | metric_coverage — noise rows are excluded |
| Was it a subsample? | Compare silhouette_sample_size (rows the metric consumed — the scored rows after the noise mask, and after the draw where the cap bit) against n_samples - n_noise: equal ⇒ every eligible row, smaller ⇒ a draw. None ⇒ nothing was scored |
| How much of the score is search? | auto_k_selected_score vs auto_k_median_score |
Did the run find anything at all? verdict is clustered,
single_cluster, all_noise or degenerate, with verdict_reason as a
human sentence. A quality score answers "how separated", not "is there
structure" — those are different questions. verdict keeps a failed run
distinct from a merely undefined metric.
The anomaly headline is separation, not anomaly_ratio.
anomaly_ratio is the flagged fraction, which in single mode equals the
contamination setting by construction — it ranks runs by a knob the user set.
separation is
(median(score | flagged) − median(score | unflagged)) / IQR(score | unflagged):
dimensionless, shift- and scale-invariant, ≈0 when nothing was found, and large
on real outliers at any contamination. It is None for the degenerate cases
rather than a fabricated number.
Do not confuse it with quality["engine_separation_ratio"] (auto mode). That is
ADEngine's saturating ratio of mean consensus scores, one third of a heuristic
verdict. quality carries no separation key at all, so the two cannot collide —
including inside quality["explanation"], which is ml4's own one-line
restatement of the terms rather than PyOD's format string over the raw ones. A term this run could not measure prints
as n/a there, matching the None on the key itself.
consensus_scores is always in (0, 1]. When only one planned detector
succeeded, PyOD returns that detector's raw decision_scores_ instead of a
rank-normalised mean, so ml4 rank-normalises it before storing;
quality["consensus_basis"] names which path produced it, and every
per_detector_results[i]["consensus_spearman"] is None on that path rather
than a self-correlation of 1.0.
contamination means different things per mode — the requested rate in
single, the achieved rate in auto — so read it as "what happened".
score_degeneracy is set when the scores are not a usable ranking (all
equal, or numerical noise about zero). When present, the top-K table and the
findings are not measurements and must not be rendered as such.
caveats is a list of plain-language notes about the run — always present,
often empty.
Noise rows and the per-cluster surfaces¶
Density algorithms label rejected rows negative: -1 for noise, and sklearn's
HDBSCAN additionally emits -2 for rows containing infinities and -3 for rows
with missing data. Everything that counts or scores clusters treats every
negative label as noise.
The per-cluster surfaces are deliberately not uniform about this:
| Surface | Negative labels |
|---|---|
cluster_keywords, cluster_keywords_by_column, passive_field_stats |
excluded |
n_clusters, n_noise, the validity indices |
excluded (and metric_coverage reports how much was left) |
cluster_profiles |
included |
cluster_profiles keeps them on purpose — "what do the rejected rows look like?"
is a fair question, and the profile is the only surface that can answer it. The
contract is therefore on the consumer: anything rendering one row per
discovered segment must filter to cluster_id >= 0 first. parleml_ml4.unsupervised.labels
exposes non_noise_mask / cluster_ids so that rule lives in one place rather
than being re-derived per caller.
Distinctive fields: the effect size and the decision¶
cluster_profiles carries two different numbers per (cluster, axis) and they
answer different questions. Conflating them gives a three-row cluster a
confident, model-written business name derived from noise.
| Column | Question it answers |
|---|---|
z_vs_global |
How far apart are they? The cluster mean minus the global mean over the feature's population sd. Deliberately carries no sample-size term, so it stays a comparable effect size. |
separation_score |
How does this source rank against the others? A ranking key only — axis-count-calibrated by Šidák so a wide source cannot win on width, constant across a source's axes. Not a probability; do not render it as one. |
source_representative |
May this field be reported? True on exactly one axis per (cluster, source) — the source's strongest — and only when the cluster clears the support floor. One axis per source is what stops a 384-dim text column filling a "top 8" card by itself. |
There is no p-value here, and there cannot be
The profile carries no p_value, source_p_value, source_q_value or
distinctive column, and ClusteringConfig has no FDR knob. Any such
p-value would test the null "this cluster is a random subset of the rows"
— which is false by construction: the labels were produced by an algorithm
maximising exactly this separation, on exactly this matrix. That is
post-selection inference, and its certificate is void. Measured on
structure-free data, such a test fires on hundreds of fields in every fitted
KMeans cluster, against 0 under a genuinely random labelling. A
false-discovery rate that reports the opposite of the truth is worse than no
rate at all. This table is descriptive: these are the fields that most
distinguish the cluster, and nothing here says they could not have arisen
by chance.
Read the ranked answer with
parleml_ml4.unsupervised.clustering.reporting.distinctive_fields(profiles, cluster_id)
— do not re-derive it from max(|z_vs_global|). Every surface that shows
"distinctive fields" (the card, the σ badge, the LLM cluster namer's evidence)
must render that one list, or it re-opens both of:
- No support floor. A three-row cluster's per-feature
zhas null sd1/sqrt(3) = 0.58, so roughly two thirds of its features clear a|z| > 0.25bar on noise alone. The floor isparleml_ml4.unsupervised.passive_stats.MIN_CLUSTER_SUPPORT(10 rows) — the same constant the passive-field surface uses, imported rather than re-declared, so two cards on one page cannot disagree about whether a small cluster may claim a distinctive field. - A max over
kaxes is not axis-count-fair. Grouping engineered axes back to their source column takes the strongest of the source's axes, andE[max_{i<=k}|z_i|]grows likesqrt(2 ln k)— ~2.4 null sd's for a 128-dim embedding. Un-calibrated, a wide text column out-ranks a genuinely shifted numeric column purely by being wide. The Šidák step priceskin; a wide source with real signal still wins.
An empty result is a real answer — "no field distinguishes this cluster at this confidence" — and consumers must say so rather than fall back to an uncorrected ranking.
What z_vs_global is a z of¶
A z-score is invariant under an affine per-column transform, so on those
axes the z on the preprocessed matrix really is the z of the raw column. That is
not true of every axis the preprocessor builds, and the z_units column says
which case each one is:
z_units |
Axis | Safe wording |
|---|---|---|
raw_affine |
1:1 numeric/datetime under numeric_scaler robust or standard |
"standard deviations above the dataset average" |
rank_normal |
1:1 numeric under numeric_scaler="quantile" (a monotone rank transform, not affine) |
"relative enrichment" |
share |
a 0/1 indicator — one-hot level, __missing / __rare / __unseen — standardised by the Bernoulli sd and then bounded by indicator_z |
"over-/under-represented" |
frequency |
a high-cardinality __freq axis (a lookup, affine in nothing) |
"relative enrichment" |
latent |
a text latent axis matched by name — *_TSVD_* or __emb_* — there is no raw column for it to be the z of |
"relative enrichment", and the sign is not meaningful (magnitude is True) |
engineered |
any other axis derived from a source column by a transform the classifier cannot name — including the NMF topic axes, which the text preprocessor names <col>_WT_i, so they land here rather than in latent |
"relative enrichment", exactly as latent; the two differ only in whether the transform could be named |
unknown |
no source map was available (the caller supplied its own matrix with preprocessor=None) |
"relative enrichment" |
AnomalyResult¶
AnomalyResult
dataclass
¶
AnomalyResult(
labels: ndarray,
scores: ndarray,
decision_threshold: float,
detector_name: str,
mode: Literal["auto", "single"],
consensus_scores: Optional[ndarray] = None,
quality: Optional[dict[str, Any]] = None,
per_detector_results: Optional[
list[dict[str, Any]]
] = None,
per_detector_labels: Optional[ndarray] = None,
per_detector_names: Optional[list[str]] = None,
scoring_rule: Optional[str] = None,
caveats: list[str] = list(),
score_degeneracy: Optional[dict[str, Any]] = None,
findings: Optional[list[dict[str, Any]]] = None,
keywords_by_column: Optional[
dict[str, dict[int, list[tuple[str, float]]]]
] = None,
n_samples: int = 0,
n_features_in: int = 0,
feature_names_in: list[str] = list(),
runtime_seconds: float = 0.0,
contamination: float = 0.05,
score_summary: dict[int, float] = dict(),
top_k_indices: ndarray = (
lambda: empty(0, dtype=int64)
)(),
top_k_scores: ndarray = (
lambda: empty(0, dtype=float64)
)(),
)
Frozen container produced by :class:AnomalyDetector.
For mode='auto' (ADEngine) the rank-consensus / quality /
per-detector / scoring-rule fields are populated. For mode='single'
they are None.
labels is the in-sample labelling — for LOF / KNN it legitimately
disagrees with AnomalyDetector.predict() on the very rows that were
fitted. That is PyOD / scikit-learn semantics, not a defect: the two
answer different questions.
labels/scoresare PyOD'slabels_/decision_scores_— "which of the rows I was fitted on are anomalous?" For LOF that is the standard LOF (negative_outlier_factor_).AnomalyDetector.predict(X)is PyOD'spredict— "are these rows anomalous novelties relative to what I was fitted on?" For LOF that isscore_samples, whose scikit-learn docstring warns that a training point handed back in "considers the later in its own neighborhood": it becomes its own nearest neighbour, looks locally dense, and so reads as an inlier.
Both sides apply the same decision_threshold; only the score source
differs, and only for the neighbour-based detectors. Measured across the
whole dispatch table on a 220-row frame, lof (max abs diff 0.46) and
knn (0.28) are the only two that diverge materially; every other curated
detector re-derives the same scores bitwise or to floating-point noise
(≤1e-13), so its labels match. Concretely, on that frame — 20 planted
outliers — lof reports 16 here while predict flags 2
on those same rows. On genuinely unseen rows LOF / KNN rightly decline to
flag a repeat of that outlier cluster — it was in the training set, so it
is a known dense region rather than a novelty. Report labels for the
fitted run; use predict to score new data. set_operating_threshold
does not bridge the two (it also routes through decision_function).
Full evidence: docs/internal/ml4/_false_positives.md.
anomaly_ratio
property
¶
Share of rows flagged in-sample.
In mode='single' this is a restatement of the request, not a
measurement. PyOD thresholds at the 1 - contamination quantile, so
the flagged share equals config.contamination up to ties — which is
why it is reported beside :attr:~AnomalyResult.contamination and its
target / achieved reading, and why :attr:separation rather than
this is the run's quality score.
separation
property
¶
Quality of this run's ranking, independent of where it was cut.
:func:separation_statistic over :attr:scores and :attr:labels — see
it for the definition and for why anomaly_ratio cannot serve as a
headline score. None whenever the number would be fabricated
(nothing flagged, everything flagged, a zero unflagged IQR).
Not to be confused with quality['engine_separation_ratio'], which is
ADEngine's saturating ratio of mean rank-consensus scores.
A property, not a stored field, so it cannot drift from the
scores / labels it summarises: there is no second copy to fall
out of step when a consumer re-thresholds.
Returns None outright when :attr:score_degeneracy is set: those
scores are already diagnosed as noise, and a separation computed from a
labelling that was decided by rounding would be a confident number on top
of a refusal. That field is the single signal for "the scores carry no
information"; this one does not invent a second.
ClusteringResult¶
ClusteringResult
dataclass
¶
ClusteringResult(
labels: ndarray,
soft_proba: Optional[ndarray] = None,
algorithm: str = "",
n_clusters: int = 0,
n_noise: int = 0,
cluster_sizes: dict[int, int] = dict(),
verdict: Literal[
"clustered",
"single_cluster",
"all_noise",
"degenerate",
] = "clustered",
verdict_reason: str = "",
silhouette_features: Optional[float] = None,
davies_bouldin_features: Optional[float] = None,
calinski_harabasz_features: Optional[float] = None,
silhouette_sample_size: Optional[int] = None,
metric_coverage: float = 1.0,
geometry_space: str = "features",
dbscan_eps_used: Optional[float] = None,
birch_threshold_used: Optional[float] = None,
n_samples: int = 0,
n_features_in: int = 0,
feature_names_in: list[str] = list(),
runtime_seconds: float = 0.0,
cluster_profiles: Optional[DataFrame] = None,
cluster_keywords: Optional[dict[int, list[str]]] = None,
cluster_keywords_by_column: Optional[
dict[str, dict[int, list[tuple[str, float]]]]
] = None,
cluster_representatives: Optional[
dict[int, list[str]]
] = None,
passive_field_stats: Optional[
dict[int, dict[str, Any]]
] = None,
auto_k_sweep: Optional[dict[int, float]] = None,
auto_k_score_name: Optional[str] = None,
auto_k_n_candidates: Optional[int] = None,
auto_k_selected_score: Optional[float] = None,
auto_k_median_score: Optional[float] = None,
auto_k_skipped_k: Optional[list[int]] = None,
caveats: list[str] = list(),
)
Frozen container produced by :class:ClusteringDetector.
Two spaces, named. A clustering run can involve two different feature matrices: the fused, un-reduced features (real column names, what the user thinks of as their data) and the clustering geometry the estimator actually optimised in, which is the same matrix unless a pre-reduction ran. Every number here that depends on the choice says which one it means:
silhouette_features/davies_bouldin_features/calinski_harabasz_featuresandcluster_profiles— fused features, always, whatever the geometry was.auto_k_sweep/auto_k_selected_score/auto_k_median_score— the geometry, because the sweep must judge candidates in the space the estimator is optimising.geometry_spacenames it.
They used to share the bare name silhouette while being computed in
different spaces, so the platform's headline clustering score was a UMAP
embedding's own separation — a quantity UMAP's loss maximises by
construction — and the UI showed a second, contradictory silhouette one tab
away.