Skip to content

Playbook — unsupervised: anomaly detection and clustering

For experiments of type: "anomaly" (score how unusual each row is, and flag the most unusual) and type: "cluster" (group rows into segments). There is no target to predict: the plan carries an unsupervised block — task (anomaly or clustering), feature_cols, the shared preprocessing in its nested unsupervised block, and the task's own anomaly or clustering settings. describe_platform("plan_schema", name="cluster") (or name="anomaly") lists every setting by its path inside that block; offer and plan only those. The delivery lifecycle is get_playbook("deliver"); this playbook is what each of its steps does here. Text columns add get_playbook("type-text").

Frame

  • What the result is for — anomaly: who acts on a flag, and how many flags they can take (the alert budget); clustering: what the segments are used for, and roughly how many segments people can act on (clustering.auto_k_min / auto_k_max, or a fixed n_clusters).
  • The rows and the columns — the population and the columns the grouping or the unusualness should be about. A column the result must not depend on (an identifier, an evaluation label, a timestamp) is not a feature; a column people want described per cluster but not clustered on is a passive column (unsupervised.passive_columns).
  • Known labels — anomaly: a column of confirmed past incidents is an evaluation label (label_col), never a feature; with labels for most rows, propose a supervised framing as the runner-up. Weigh that runner-up on the labels' number and how they were picked (a one-off audit, only the cases someone flagged) — never on "new rows will have no label": a classifier scores unlabelled rows; it needs labels only to train. Clustering: a known grouping only judges the result afterwards.
  • Preprocessing the schema has — unsupervised.numeric_scaler is robust (default), standard or quantile (the choice for heavy skew); there is no Yeo-Johnson or log option — say "not supported", never offer it. Start clustering on robust. quantile spreads tied values apart, so a column with a point mass (many customers who ordered today, many with tenure 0) splits off small clusters defined by that one value; before calling such clusters fringe groups of the data, compare with a robust run.
  • Cluster names — the fit never names clusters. The workspace's agent may name every cluster of a finished run in one call (the clusters.names annotation: each cluster's statistics and up to unsupervised.description_n_representatives member rows), automatically when the workspace's profile says so, or when a person presses ✦ on the clusters panel. The names come back as ai_name (AI-written). Either way the released names are the ones the person approves (Release and operate). When the run has ai_names, report with those names, or say which you renamed and why: naming the clusters afresh leaves the person reading two sets of names for one result.
  • What an anomaly looks like — anomaly: ask, or read from the request, which kind the person means; the kind picks the detector family before the baseline (describe_platform("detectors") gives each detector's family):
  • an extreme — one reading far outside its usual range: anomaly.mode auto, or a per-column detector (ecod, copod, hbos), which scores each column on its own;
  • a broken relationship — ordinary marginal values that are unusual jointly. Compare the appropriate multivariate families in describe_platform("detectors"): covariance or projection approaches (mcd, pca, kpca) can suit particular relationship structures; distance, local-density and isolation methods have different assumptions. Do not claim all auto candidates are incapable of joint anomalies. Check dimensionality, scaling, categorical encoding and representative examples; no family wins solely from this description.
  • a rare category — a value seldom seen is an extreme to the per-column detectors; a rare combination of categories is a relationship: make it one recipe column (the categories joined) so that it is a rare value;
  • a time pattern — unusual for the entity's own history, a drift: no detector reads the row order (time_col is held out of the features). Build the pattern into the recipe — the change since the entity's previous reading, the gap to its own rolling mean, bounded at the moment — and it becomes an extreme or a relationship.
  • The requirement choice — anomaly: anomaly.mode auto (the engine picks the detector and the contamination) or single (a named detector_name at the person's contamination), as the anomaly kind says; describe_platform("detectors"). Clustering: clustering.algorithm (describe_platform("clustering_algorithms")) — kmeans, minibatch_kmeans, gmm and birch score new rows and can be registered; hdbscan, dbscan and agglomerative describe the rows they saw and cannot.
  • Metric — anomaly: separation, plus precision and recall with a label; clustering: silhouette (with davies_bouldin, calinski_harabasz; clustering.auto_k_score picks k). describe_platform("metric_catalog", context="anomaly") or context="clustering".

Data

  • Recipe — the rows to score or group, bounded at the prediction moment (author_recipe).
  • Roles — feature_cols on the plan names the columns the run reads; list them: an empty list feeds every column, identifiers and labels included. Declare the row key on the dataset (prepare_data(action="columns")): the published assignments and scores carry it. A file with no key gets a stand-in in an order unrelated to every column (a hash of the row, or the file's own order — never ROW_NUMBER() OVER (ORDER BY amount)): label halves split by the key must not split by a feature. Anomaly label_col and time_col are held out of the features. Text columns are encoded by their dataset domain (get_playbook("type-text")).
  • Leakage — a column that is the answer (an evaluation label, a "flagged" status set by the old process) stays out of feature_cols; prepare_data(action="leakage") still scans for identifiers and constant columns.
  • Signal — no probe: an unsupervised run has no target to probe. Say so at the data stop.

Validation

There is no train/test split for the result itself. Anomaly: separation is measured on the scored rows; with label_col, precision, recall and the ROC/PR areas compare the flags with the labels (simulate_decision(kind="unsupervised_threshold")). Some detectors score only the rows they were fitted on (the catalog's scope).

Labels and selection. State how the audit labels were obtained and whether they cover the population or only previously flagged cases. Never treat unreviewed or withheld cases as normal. When the label is an existing rule — an alarm threshold, a rules engine's flag — agreeing with it is a sanity check, not the value: a detector whose flags are all alarm cases (precision 1.0 against the label) tells the person nothing the alarm does not. Then choose the detector and the budget by the flags that fall outside the rule and look like genuine extremes (a broken relationship between correlated columns, several columns unusual together), headline how many there are with a few example rows, and report agreement with the rule beside them. The current label reader requires complete binary labels; it does not support an evaluation mask. Do not replace a withheld half with zero to force it through that contract. If a genuinely separate, fully labelled evaluation dataset and supported scoring/evaluation surface are available, use them; otherwise disclose the lack of independent evaluation. Selecting a detector or threshold on the same labels used to report performance is development evaluation, even if the agent calls the labels "report only" after seeing them. Unsupervised fitting itself does not imply label tuning.

separation describes the run's score distribution relative to its own cutoff. Its dimensionless scale does not make it a validated cross-detector measure of incident detection. Judge usefulness against the anomaly definition, reviewed examples, representative labels where available and the alert budget. A surrogate's fidelity measures agreement with the detector, not agreement with truth.

Clustering: the metrics are internal; ParleML computes no stability index. If seed sensitivity matters, compare compatible existing runs or justify a second run with another clustering.random_state. A crosstab of their published assignments joined on the key column (query_sql, labelled as a descriptive aggregate) shows how memberships align; cluster numbers are arbitrary. Seed agreement does not establish temporal stability or a true grouping.

Baseline

On a transaction table (a label, when one exists, judges the detector afterwards and is not a feature):

{
  "experiment": {
    "workspace_id": "<workspace_id>", "name": "card anomaly baseline", "type": "anomaly",
    "dataset_id": "<dataset_id>",
    "unsupervised": {
      "task": "anomaly",
      "feature_cols": ["amount", "merchant_category", "country", "hour_of_day", "is_online", "days_since_last_txn"],
      "label_col": "is_anomaly",
      "anomaly": {"mode": "auto"}
    }
  },
  "hypothesis": {"lever": "baseline", "expected_effect": "Foreign, nocturnal, rapid transactions separate from the rest."}
}

On a customer table (an existing group label, when one exists, judges the result afterwards and is not a feature; a column derived from others is described per cluster rather than clustered on — decide which from the data):

{
  "experiment": {
    "workspace_id": "<workspace_id>", "name": "persona baseline", "type": "cluster",
    "dataset_id": "<dataset_id>",
    "unsupervised": {
      "task": "clustering",
      "feature_cols": ["avg_order_value", "orders_per_year", "days_since_last_order", "tenure_years"],
      "unsupervised": {"passive_columns": ["annual_spend"]},
      "clustering": {"algorithm": "kmeans", "n_clusters": "auto", "auto_k_min": 3, "auto_k_max": 6}
    }
  },
  "hypothesis": {"lever": "baseline", "expected_effect": "Recency, frequency and order value form three to six separable personas."}
}

Reference evidence. Anomaly separation measures the detector's own score separation; neither a positive value nor exceeding zero establishes useful anomaly detection. With evaluation labels, compare native ranking/review metrics against an appropriate matched-budget reference; the label base rate is the expected precision of uniform random selection. Without labels, inspect cases and operational relevance without inventing a predictive floor. Clustering verdicts such as single_cluster, all_noise or degenerate explain why the requested segmentation was not obtained. A silhouette near zero indicates overlap in the measured geometry, not that every useful segmentation is impossible. Compare geometry, coverage and noise exclusions before comparing internal indices. Internal indices (silhouette, Davies–Bouldin, Calinski–Harabasz) compare partitions of the same feature space only: a run that changes feature_cols, their encoding or k measures a different geometry, so its silhouette is neither a gain nor a cost and a stop rule on it decides nothing. Compare such runs by membership agreement (how many rows keep their segment), purity against any known grouping, and what each segment means to the person.

Iterate

Lever (hypothesis.lever) Change
features feature_cols, unsupervised.passive_columns
column_roles a text column's handling: unsupervised.tfidf_columns, nmf_columns, text_columns
recipe the rows themselves (a population filter, an aggregate per entity)
other numeric_scaler, auto_k_min / auto_k_max, n_clusters, the detector or algorithm, text_block_weight (text dominating or vanishing in the clusters), a random_state for stability — with the reason in note
decision_layer the anomaly threshold: not a refit (Decide)

In mode="auto" the contamination is the engine's own choice: changing anomaly.contamination there changes nothing. A seed run is a stability check, never an improvement.

Headroom first. With a review budget of B flags and P labelled anomalies, recall at the budget cannot pass min(1, B / P) when P is the number of positives in that same evaluated population (80 flags, 160 positives: 50 %). When the baseline is within a few points of that ceiling (say 44 % against a 50 % ceiling) another detector or feature can buy at most those points on that sample. Consider whether the remaining gain, stability or another requirement is worth testing; do not extrapolate a selectively audited sample to the live population.

Insights

Interpretation checklist: choose the relevant available reads for the question and candidate:

  1. get_insight(kind="unsupervised_summary") — detector or algorithm, counts, separation or the clustering metrics, the threshold and its source, verdict, caveats, and per cluster its computed profile (and ai_name when names were written). A caveat that auto-k picked the edge of its range means the best count may lie beyond it: widen auto_k_min / auto_k_max, or choose the count on business grounds and say so. Anomaly caveats say on what basis auto mode shortlisted its detectors (data shape, no quality measure). Correlated numeric pairs can motivate checking joint anomalies, but correlation alone does not establish which detector misses them. Relate the native caveat to the actual detector, representative cases and the person's anomaly definition; do not turn it into proof that a different family must win.
  2. get_insight(kind="unsupervised_features") — the features the run used (no identifier or label among them).
  3. get_insight(kind="unsupervised_surrogate") — anomaly: which features drive the flag.
  4. get_chart_data("anomaly", "score_distribution", experiment_id), get_chart_data("anomaly", "top_k_table", experiment_id) — how scores spread; the most unusual rows.
  5. get_chart_data("clustering", "cluster_sizes", experiment_id), get_chart_data("clustering", "silhouette", experiment_id) — sizes; how well each segment holds together.

What a cluster is comes from its computed profile and your own query_sql aggregates on the published assignments (raw-unit means: "average order $182 vs $96 overall"), never from a generated name. Give every cluster a plain-words name and one sentence built from that evidence, labelled agent-written; quote ai_name beside it only as AI-written. Then the Gems table (get_playbook("iterate")).

Challenge

Check Reads required
Leakage get_insight(kind="unsupervised_features"): no identifier, evaluation label or outcome status among the features
Validation design anomaly: precision and recall at the threshold (simulate_decision(kind="unsupervised_threshold")), the scope, and whether the detector or contamination was chosen on the same labels (Validation); clustering: the metrics and the verdict
Stability a second run with another random_state and how its clusters line up (joined on the key); for anomalies, whether the top flagged rows stay on top
Plausible drivers get_insight(kind="unsupervised_surrogate") (anomaly); the cluster profiles — would a domain expert recognise the segments?
Fits the requirements anomaly: can the detector (get_insight(kind="unsupervised_summary"): its mode, detector_name and the correlated-columns caveat) see the anomaly kind the person described (Frame)?

Decide

  • Anomaly — simulate_decision(kind="unsupervised_threshold"): every threshold with the rows it flags and, with a label, its precision, recall and F1. current_point reports the exact active cutoff using its fitted or committed tie rule; sampled alternatives are not a substitute for the current point. These label metrics describe the fitted rows, not independent holdout. Propose the point that fits the alert budget, then the alternatives. The person commits a threshold on the experiment's unsupervised tab.
  • Clustering — no simulation: the decision is the segmentation itself — the number of clusters, whether each is distinct and large enough to act on, and the name of each cluster (your evidence-built names, the person's corrections) — stated with get_insight(kind="unsupervised_summary") and the cluster sizes.

Release and operate

  • Register with register_model(action="register") and its release_record: an anomaly run, or a clustering run whose algorithm can score new rows. A transductive clustering run (hdbscan, dbscan, agglomerative) is not registered: record its release with register_model(action="record_release", experiment_id, …).
  • Approved cluster names are applied by the release record: apply {"kind": "cluster_labels", "labels": {"0": "High-ticket occasional", …}} — every cluster, distinct names. The published clusters table then carries them as label (name_source = 'approved'). An anomaly threshold the person committed is bound with {"kind": "anomaly_threshold"}. Anything nothing applies (a group kept outside the segmentation) is a limitation in the record, not an operating point.
  • Score new rows with score_batch(action="submit").
  • Results: get_playbook("schema-unsupervised").