Skip to content

AnomalyDetector

Unsupervised anomaly detection over PyOD — an auto mode that profiles the data and runs a consensus of the best-fit detectors, or a single named detector from a curated roster. See the unsupervised guide.

In short

  • AnomalyConfig.mode picks the path: "auto" (default) or "single".
  • auto runs up to 3 detectors from PyOD's routing table and serves their z-score mean; single fits the one detector named by detector_name (default "iforest").
  • contamination (default 0.05) sets the share of rows labelled anomalous.
  • to_pipeline() returns the deployable UnsupervisedPipeline.

AnomalyDetector

AnomalyDetector(
    config: Optional[AnomalyConfig] = None,
    unsupervised_config: Optional[
        UnsupervisedConfig
    ] = None,
    preprocessor: Optional[
        UnsupervisedPreprocessor
    ] = DEFAULT_PREPROCESSOR,
    *,
    outputs_dir: Optional[str] = None,
    setup_file_logger: bool = True
)

Bases: BaseEstimator

Unsupervised anomaly detector.

See the class-module docstring for the design.

Parameters

config : AnomalyConfig, optional Task configuration. Defaults to AnomalyConfig(). unsupervised_config : UnsupervisedConfig, optional Preprocessor + embedding configuration. Defaults to UnsupervisedConfig(). preprocessor : UnsupervisedPreprocessor, optional Override the default preprocessor (pass None to skip preprocessing entirely if your input is already a numeric matrix). outputs_dir : str, optional, keyword-only When provided, opt into the outputs_dir discipline: fit() writes <outputs_dir>/{events.ndjson, manifest.json, CANCEL, ml4.log} and persists the deployable :class:UnsupervisedPipeline to <outputs_dir>/deploy/ under :data:~parleml_ml4.run_layout.UNSUPERVISED_ARTEFACT_NAME. When None (default), the fit runs in memory with no telemetry and no artefact. setup_file_logger : bool, keyword-only, default True When True and outputs_dir is provided and the root logger has no handlers, install a rotating file handler at <outputs_dir>/ml4.log. Mirrors :class:AutoSupervised's gating.

DEPLOY_ARTIFACT_NAME class-attribute instance-attribute

DEPLOY_ARTIFACT_NAME = UNSUPERVISED_ARTEFACT_NAME

Filename inside <outputs_dir>/deploy/ that the saved :class:UnsupervisedPipeline lands in.

Deliberately the same name the platform's enriched re-save uses — see :data:parleml_ml4.run_layout.UNSUPERVISED_ARTEFACT_NAME for why one deploy/ directory must not end up holding two same-versioned artefacts that disagree.

outputs_dir property

outputs_dir: Optional[str]

The outputs_dir passed to the constructor, or None.

operating_threshold property

operating_threshold: Optional[float]

The active operating-threshold override, or None.

None means :meth:predict / :meth:explain_rows use the detector's own fit-time threshold_.

fit

fit(X, y=None)

Fit the detector.

Wraps the logical stages (preprocess / fit_single or fit_auto / build_result, plus persist when outputs_dir was provided) with stage_* events. When outputs_dir is None the runtime is identical to the legacy synchronous path: no telemetry, no on-disk artefact.

fit_predict

fit_predict(X, y=None)

sklearn convention — equivalent to fit(X).predict(X).

set_operating_threshold

set_operating_threshold(value: Optional[float]) -> None

Override the operating cutoff used by :meth:predict / :meth:explain_rows.

When set, labels are derived as decision_function(X) >= value (higher == more anomalous — see :meth:_anomaly_mask), bypassing the fit-time threshold_. Pass None to revert to the fitted threshold. Persisted with the detector via joblib.

predict

predict(X) -> ndarray

Anomaly labels (0/1) for X — the novelty labelling.

Answers "are these rows anomalous relative to what I was fitted on?", which is the right question for serving new data and the one the deployable :class:~parleml_ml4.unsupervised.pipeline.UnsupervisedPipeline asks. Passing the training frame back in is a different question from the one result_.labels answered, and for LOF / KNN the two legitimately disagree on those rows — see :class:~parleml_ml4.unsupervised.result.AnomalyResult. Not a bug, and not something :meth:set_operating_threshold reconciles.

decision_function

decision_function(X) -> ndarray

Raw anomaly scores for X - higher is more anomalous.

normalized_score

normalized_score(X) -> ndarray

Min-max normalised anomaly score in [0, 1] - not a probability.

PyOD's predict_proba(X, method='linear'), whose entire body is::

scaler = MinMaxScaler().fit(train_scores.reshape(-1, 1))
probs[:, 1] = scaler.transform(test_scores...).ravel().clip(0, 1)
probs[:, 0] = 1 - probs[:, 1]
  • an affine rescale of an uncalibrated score by the two most extreme training rows (pyod/models/base.py). It is not calibrated against any observed frequency, it is not comparable across detectors, it moves if one outlier is added to the training set, and any served row beyond the training range is clipped to exactly 0 or 1 — so 0.87 is not an "87 % chance this row is an anomaly", and the name says so. PyOD's alternative method='unify' is not offered: it assumes Gaussian scores, which no detector here guarantees.

:returns: (n_samples, 2) of [1 - s, s] where s is the normalised score - shape preserved so a caller reading column 1 keeps working; the meaning is a rank surrogate, not a class posterior. :raises NotImplementedError: for detectors that expose no such rescale.

explain_rows

explain_rows(
    X, *, top_k_features: int = 3
) -> list[dict[str, Any]]

Per-row reason codes for arbitrary (out-of-sample) rows.

The serving twin of the training findings, through the same :mod:~parleml_ml4.unsupervised.anomaly.findings producer: each entry carries the anomaly label / score plus the top contributing features, z-scored against the training feature statistics so attributions are stable across calls and comparable with the fitted run's.

The same caveats apply and are stated in the narrative: the z-scores are computed on the engineered matrix (RobustScaler units, one-hot axes), each contribution names its source_column, and below :data:~parleml_ml4.unsupervised.anomaly.findings.MATERIAL_Z the narrative says that no feature deviates materially instead of naming the three largest of a set of near-zero numbers.

contamination_diagnostics

contamination_diagnostics(
    threshold_sweep: Optional[Sequence[float]] = None,
) -> dict[float, dict[str, Any]]

Read-only threshold sweep over the served scores.

Synthesised from result_.scores in both modes, so the curve is derived from the very score vector whose labels it is drawn beside, and every threshold it reports is in the units of result_.decision_threshold (and of :meth:set_operating_threshold). ADEngine's own contamination_diagnostics sweeps its rank-normalised consensus instead, a different model in different units, so it is not used.

The sweep must reproduce PyOD's own labelling rule, because the curve is read next to result_.n_anomalies: PyOD sets threshold_ = percentile(decision_scores_, 100 * (1 - c)) and then labels_ = decision_scores_ > threshold_ (pyod/models/base.py). np.quantile(scores, 1 - c) is the identical cut point (PyOD imports NumPy's own percentile), so the only thing that has to match is the comparison — and it is strict. A non-strict >= over-counted by exactly the number of scores sitting on the cut, making the sweep contradict the result it annotates: on this repo's 220-row anomaly fixture, single-mode LOF at c=0.1 reported 25 flagged while result_.labels held 16. Ties are not a synthetic-data curiosity — integer / low-cardinality columns and rank- or histogram-based detectors produce them routinely.

:param threshold_sweep: Optional iterable of contamination fractions in (0, 1). Defaults to :data:~parleml_ml4.unsupervised.result.DEFAULT_CONTAMINATION_GRID — the same 40-point 0.005 .. 0.20 grid mli's plot fallback sweeps, so the two agree by construction rather than by coincidence. :returns: {contamination: {"n_anomalies": int, "threshold": float, "anomaly_ratio": float}} for every contamination probed, with threshold in the units of result_.scores in both modes. n_anomalies equals result_.labels.sum() at the contamination the fitted detector was actually thresholded at — config.contamination, which now reaches the detector in auto too (it is written into ADEngine's plans by :func:apply_contamination_to_plans).

engine_report

engine_report() -> Optional[str]

Return ADEngine.report(..., format='text') when this detector ran in mode='auto'; None otherwise.

Public accessor so callers (the pipeline's MLflow logger, the viewer panel) don't have to reach into self._engine / self._state.

explain

explain() -> dict[str, Any]

Return a dict summarising what was served + why.

Carries score_degeneracy when the fit produced no usable separation and caveats whenever the numbers need qualifying, because this dict is the natural place a caller asks "is this run worth reading?" and the honest answer is sometimes "yes, but".

In mode='auto' it also carries scoring_rule - how the served z-score mean combines every candidate rather than picking one - and the per-detector rows carry the router's own reason / routing_confidence / evidence. engine_report is ADEngine's own text, whose detector section is the candidate ADEngine would pick, not the served z-score mean.

to_pipeline

to_pipeline(
    *,
    compute_embedding: bool = False,
    X_for_embedding: Optional[Any] = None,
    metadata: Optional[dict[str, Any]] = None
) -> Any

Wrap this fitted detector into a self-contained :class:parleml_ml4.unsupervised.UnsupervisedPipeline.

Sugar over UnsupervisedPipeline.from_anomaly_detector(self).

Next: UnsupervisedPipeline · Example: anomaly detection