Skip to content

Row anomaly detection: a second detector, and the end of segment_by - #1489

Open
vb-dbrks wants to merge 102 commits into
mainfrom
feat/anomaly-group-conditioning
Open

Row anomaly detection: a second detector, and the end of segment_by#1489
vb-dbrks wants to merge 102 commits into
mainfrom
feat/anomaly-group-conditioning

Conversation

@vb-dbrks

@vb-dbrks vb-dbrks commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Row anomaly detection: a second detector, and the end of segment_by

Closes #1484 and #1490.

TL;DR

Row anomaly detection gains three orthogonal choices, and loses one path:

what it answers default
profile which detector .. "tabular" (Isolation Forest) or "timeseries" (Mahalanobis) "tabular", unchanged
baseline_by normal compared to what group .. each row against its own group's baseline off
baseline_over_time normal compared to when .. each metric against its own history at that time off
  • All three are opt-in. With none set, the default path is unchanged, and the isolation-forest inertness suite pins that.
  • segment_by and AnomalyParams.max_segment_models are removed. baseline_by replaces them but is not a rename: segment_by trained one model per group, baseline_by conditions one pooled model. Scores differ.
  • Every existing model must be retrained. The config hash now includes the comparison bases, so a stale model raises at scoring time rather than scoring against a different feature list.
  • Registry segmentationgrouping, and a pre-release registry table is migrated in place on the next retrain.
  • expected_anomaly_rate is removed. It set the estimator's contamination, which never changed what DQX flags, so its name promised the opposite of what it did. Nothing replaces it: threshold on the check is the alert budget. Default behaviour is unchanged.
  • Severity between the persisted quantile knots was silently stricter than requested; fixed, with 90/95/99 byte-identical.
  • Contributions are keyed by the columns you passed, under either profile, and are averaged across ensemble members. Previously one column's evidence was split across the features derived from it, and an ensemble's explanation came from whichever member trained first.

Beta, so formats were allowed to change without a migration path. Migration steps are in the user guide.

profile="timeseries"

anomaly_engine.train(df, ..., profile="timeseries")   # "tabular" is the default

Isolation Forest splits on one feature at a time, which makes it strong on tabular data and close to blind when the anomaly is a broken relationship between two readings that are both in range .. there is no single feature to split on.

Measured on the full Server Machine Dataset (28 entities, 38 metrics, 708,420 rows, 327 labelled incidents), trained on an earlier period and scored on a later one .. which is what a user does when they train once and check new data.

incidents surfaced @1% budget avg precision
"tabular" 57.5% 0.277
"timeseries" 65.3% 0.423

Average precision is the larger relative gain, at +53% against +7.8 points of coverage. An incident counts as surfaced if the detector flags at least one of its rows, so coverage answers "would I have been paged".

Which protocol, and why the headline changed

Earlier versions of this description led with 65.4% against 96.8%, measured by fitting a 30% sample of the very rows being scored. That protocol is legitimate for one question .. can a model re-find anomalies it has already seen, which is what scanning a table you already have amounts to .. but under it 272 of the 327 incidents had rows in training, 271 shared rows with the held-out set, and 7,027 of the 29,444 anomalous rows were fitted. So it is not evidence for scoring data that arrives later, which is what the quickstart tells you to do. Same code, same data, 31 points of difference from the split alone.

The table above therefore reports the chronological split, and this is the second correction to the same section. Round 2 flipped the headline from average precision to coverage because average precision was a wash (+0.006, 14W/14L) .. under the leakier protocol. Chronologically it is the stronger signal again, which is where it started. The per-entity statistics that justified the coverage framing (+31.4 points, sd 29.5, 22 wins to 1) belong to the retired protocol and are not carried over, because there are no per-entity chronological figures to replace them with.

Two protocols differing also does not isolate drift as the cause, and an earlier draft said it did.

Both protocols come from one archived run. The comparison is between estimators on raw metric matrices .. DQX's own feature engineering is not part of it. A benchmark through the real pipeline needs a workspace and 28 tables, and is tracked separately.

Two earlier sets of figures were withdrawn rather than adjusted: 79%/33%, from a harness truncating each entity to 4,000 rows, and 57.3%/67.6%, which mixed three runs one of which saved no output. Both were found by review.

No "auto": the choice cannot be verified without labels, and silently swapping estimators would move every score people have set thresholds against.

baseline_by

anomaly_engine.train(df, ..., baseline_by=["region", "product"])

The same value can be fine in one group and wrong in another. One group's volume dropping 80% behind a flat daily total scored 45.1 .. the 45th percentile, which no threshold recovers. baseline_by adds each metric's deviation from its own group's median, on one pooled model, so cost does not grow with group count. The same collapse now scores above 95, and PR-AUC on a contextual anomaly goes from 0.0376 to 0.5703 while an already-globally-extreme anomaly stays at 1.0000.

Rows whose group was absent from training return a null score with is_new_baseline = true, rather than 0.0 .. which was the most normal-looking value in the table, and the wrong thing to say when the truth is "could not judge this".

baseline_over_time

anomaly_engine.train(df, ..., columns=["revenue"], baseline_over_time="event_ts")

A metric that has grown for a year sits outside the range it trained on, so ordinary rows start being flagged; and a value that is ordinary against the whole year can be badly wrong for where the trend had reached. On a fixture where every metric is rolled back together, correlations intact and every value inside the training range, "tabular" surfaces 0.0% of incidents and "timeseries" 0.8%. With the temporal baseline, 100%.

DQX fits each metric's expected level as a function of time and persists the coefficients, so it extends to timestamps the training window never contained. That is why a time-bucket baseline_by cannot do this job.

  • Composes with baseline_by. With both set, the fit runs on the group-relative value. On groups trending at different rates, one pooled fit on the relative value reaches 101% of per-group fits; fitting raw values collapses from 80% to 29% as slopes diverge.
  • Independent of profile: worth +39% mean event coverage on the tree detector and +40% on the correlation-aware one, harming neither. A third axis, not a third detector.
  • Not a forecaster. It models the level expected at a time, never reads the previous row .. which is what keeps scoring valid on a streaming DataFrame, and why lag features were rejected rather than deferred.
  • Opt-in, and DQX warns rather than guessing. On stationary data the transform measures worse than leaving it off, so a warning fires below 10% of variance explained. The statistic separates regimes sharply: 0.016 on real telemetry against 0.999 on trending data.

Four internal rules are measured rather than chosen, each pinned by a test carrying its number: a seasonal term needs six complete cycles (a daily period over 2.8 days cost 15 points of coverage; apparent seasonal strength rises as cycle count falls, so variance explained is the wrong gate and is inverted); the fit is Huber, not least squares (with 5% of rows at 6x normal, ridge recovered slope 0.0694 against a true 0.0500, Huber 0.0502); changepoints are chosen on a held-out tail (in-sample R² was identical from 0 to 25 changepoints while false flags one window out ranged 1.2% to 100%); and the response is standardised before fitting, because the design columns were already scaled while the metric was in its own units and Huber solves for coefficients and a scale parameter jointly. That last one is the largest model-quality fix here: across 20 seeds, a metric measured in billions had its slope 99.89% wrong (sd 0.00%) against 0.13%, winning 20 of 20, and a contaminated sample went from 1.32% to 0.14%, also 20 of 20. Small magnitudes, rates near 1e-6, negatives, integer counts and zero-crossing metrics are a wash, and heavy-tailed noise is a 10/20 coin flip. It surfaced from a ConvergenceWarning in a demo's cell output, which is the sort of thing an exit code hides.

Severity between the quantile knots

threshold=98 did not flag 2% of rows, and threshold=99.9 flagged nothing at all. Severity was interpolated linearly in score space, and a score quantile function is convex in the tail, so a straight line from p95 to p99 sits above the true quantile the whole way.

threshold promised flagged, four score distributions
98 2.0% 1.41% to 1.56%
99.5 0.5% 0.00% to 0.03%
99.9 0.1% 0.00%

Now interpolated in tail probability, which is what a percentile is: severity = 100 - 5 * 5**-u, u = (score - q95) / (q99 - q95). Same distributions give 1.85% to 2.11% at 98 and 0.43% to 0.59% at 99.5.

Chosen over adding knots for two reasons: it persists nothing new, so models already in a registry are corrected by being scored (new keys would have broken every pre-existing model, since extract_quantile_points raises on a missing key); and it is exact at p95 and p99, so 90/95/99 are fixed points and the default configuration is byte-identical. It also breaks a tie .. the old expression clamped everything past the training maximum to exactly 100, which was 20% of a scored batch on average.

Above roughly p99 the tail assumes exponential decay, which held on four synthetic distributions and real telemetry but overshoots on a heavy tail (0.21% against 0.10% nominal at 99.9), so the guide says to read 99.5+ as "much stricter than 99" rather than as a budget.

What this does not fix: scoring a later period with a model fitted on an earlier one flagged 9.8% to 21.7% of rows at threshold=95, against 4.9% to 5.0% on same-period held-out data. Calibration is sound; the input moved. That is a retraining signal, and no training-relative scheme can fix it without a global pass the streaming contract forbids.

Why Mahalanobis, and what was rejected

Chosen by measurement. The metric is event coverage at a fixed alert budget, not PR-AUC: SMD's anomalous rows sit in incidents of median length 6 and max 1041, so point-wise PR-AUC mostly measures whether the one huge incident was found. No point-adjusted F1 either .. Kim et al. (AAAI 2022) showed random scores reach state of the art under it.

It also gives exact, never-negative attribution: leave-one-out contribution has a closed form for this detector, so contributions come free with no SHAP dependency. The naive signed decomposition was rejected because its terms can go negative, and a feature that reduced the distance is not a driver of the anomaly .. it would now be dropped rather than misreported, which silently removes evidence from an explanation whose remaining shares no longer describe the distance they came from. Non-negativity has to come from the formula, not from a clip. Being deterministic, it trains one model rather than an ensemble (ensemble_size is ignored and confidence_std unavailable, both logged).

Rejected alternatives, with the numbers so nobody re-argues them
  • PCA, in any position .. read this one sceptically, because truncated PCA measured better than what ships. At full rank it is a provable no-op (residual 8.6e-13 at ridge zero). Truncated it is neither: PCA 95% → Mahalanobis reaches 74.5% coverage against the shipping 65.3%, 15W/5L. Rejected anyway on three grounds independent of that: k is unchoosable (same fixture swings ROC-AUC 0.48 to 1.00 on k alone, and retained variance is anti-correlated with what the data wants); it collapses on the very anomaly class the second detector exists for (recall 81.6% → 0.8% on a correlation break); and it destroys per-feature attribution.
  • Robust (MAD) covariance. Implemented, then reverted: it excluded 4.6% of legitimate tail rows and took coverage 91.9% → 86.4%, ROC-AUC 0.784 → 0.763. The distortion statistic meant to gate it is inverted. The contamination exposure is real and documented at the top of the detector.
  • Lag / rolling-window features. Raw beat windowed on coverage; an earlier spike measured +9% PR-AUC for 190 extra features with ROC-AUC going backwards.
  • ECOD / COPOD as a third profile. Best ADBench average precision measured (0.605) and 26 points of coverage behind the default on SMD, 1W/9L. Both sum per-dimension tail probability, so neither can see a correlation break.
  • Robust scaling. Provably inert: Isolation Forest's partition is identical under any monotone affine map, and Mahalanobis is affine-invariant.
  • Automatic profile detection. Lag-1 autocorrelation does not separate temporal from tabular data .. it is confounded by any row ordering that correlates with values, which sorted storage produces routinely.
  • Per-group models (the removed segment_by). Worst of three configs on SMD (PR-AUC 0.1416 against 0.1536 conditioned), and failing in a way the average hides: one entity produced a 56% false alarm rate because each per-group model calibrates on its own rows. Cost is linear in groups .. 90 groups took ~88 minutes against 0.17s for one conditioned model.

Explaining a flagged row

Detection and explanation were reviewed separately, and the explanation is where the defects were. Each
of these could name the wrong column, confidently, in business language, on a row the detector had
flagged correctly.

One column, one entry. Feature engineering can derive three features from one numeric column .. the
metric, its deviation from its group's baseline, its deviation from its expected level at that time ..
and contributions were reported per feature, which answers a question nobody asked. The correlation-aware
detector's per-view drops are each almost nothing, because dropping one view leaves another copy of the
same information: adding one affine duplicate of a metric moved the reported cause from 99.8% that
metric to 99.9% an unrelated one
while the score did not move. Blocking by source restores 99.7%/0.3%.
For a tree model the failure is different in shape .. the evidence is split rather than misdirected,
49.3% and 50.7% across two views of a column whose true share is 100% .. and still changes the answer,
because with three views a column whose true share was 74.5% left each view near 25%, handing the top
spot to a derived view of itself with an unrelated single-view column tied alongside. Both detectors now
group by source column, by an exact joint marginalisation for the one and a plain sum for the other,
which is exact because SHAP is additive.

Provenance is read from the recorded transform, not from spelling. A caller's own column named
amount_rel_baseline was decomposed into amount and labelled "amount vs its group baseline" on a model
with no grouping at all .. crediting one column's contribution to another, and making redaction of
amount sweep up an unrelated column. Suffixes now resolve only when the transform that generates them
actually ran, and an exact source-name match wins over decomposition.

The SHAP orientation was wrong in kind, and the review's reading of it was backwards. TreeSHAP on an
isolation forest explains the ensemble's average path length, not a score, so a negative value
shortens the path and drives the anomaly. Review reported this as abs() turning normalising evidence
into drivers, which implies the positive side is the anomaly-driving one; it is the opposite. Measured on
200 rows each carrying exactly one deliberately anomalous feature, the largest value named the true
culprit 199/200 under the orientation that ships, 199/200 using magnitude alone, and 0/200 using
the positive side .. so implementing the finding as described would have destroyed attribution accuracy.
Magnitude alone was very nearly right, since on rows anomalous enough to be shown the evidence is 97.8%
anomaly-driving at a severity of 95 and 99.5% at 99. It was still wrong in kind, adding evidence of
normality to evidence of anomaly, and correcting it left the top driver unchanged on every row measured
while reordering the ranks below it on roughly a quarter of rows. Not a schema change: the map stays
non-negative and sums to 100.

A row with no anomaly-driving evidence said every feature contributed equally. When the attribution
totalled zero the map was filled with 1 / n .. an explanation with no input behind it, and the more
misleading of the two failures because the numbers look unremarkable. Such rows now carry the all-null
map that a row with a null feature already produced, which every consumer already renders as unknown.
Zero-valued entries are also dropped from messages, prompts and the pattern key, rather than being
listed as quantity (0%).

An ensemble scored with every member and explained with one. The reported score is the mean across
members and so is confidence_std, but contributions came from models[0] .. arbitrary, since members
differ only by random seed. They disagree more than that framing suggests: on 200 rows with the default
three members, the least-agreeing pair named a different top driver on 130 of them and the closest
pair still differed on 92. Contributions are now the mean across members, which decomposes the mean path
length exactly, and the entry point takes the models behind the score rather than a model, so the mistake
cannot be remade by omission. It costs about 7% more at three members, because attribution runs only
on rows above the threshold. Averaging makes the explanation unbiased between members, not stable .. with
three members it stays noisy, and the fix for that is more members.

The prompt asserted a direction the inputs do not contain. Contributions are unsigned magnitudes, so
a row displaced one way and a row displaced equally the other produce the identical score and the
identical map .. (8, 0.5) and (-8, -0.5) both score 65.387. The few-shot exemplars nonetheless said
"sits far above the norm" and "Inflated amount fields overstate revenue", and a smaller serving model
copies the shape of its examples. Both are rewritten so every clause is checkable against that
exemplar's own inputs; the instructions name the absence of direction explicitly; and "be direct, avoid
hedging" is reconciled rather than left to interpretation. confidence is relabelled as agreement
between seeds rather than confidence in the finding, and the header says the model is explaining what it
measured rather than diagnosing a root cause.

Novelty is not guaranteed on the default profile. Retaining every one-hot category puts an unseen
value's distinction in the encoding, but acting on it needs a model that reads features jointly. The
correlation-aware detector scores an unseen category at 2,000,000 against 0.9 for a known one; Isolation
Forest scores it 0.458 against 0.442 and 0.467, i.e. ordinary. A comment claiming both detectors could
tell them apart is corrected, and the guide says so. The same probe strengthens the case against pruning
correlated features: dropping the anticorrelated dummy collapses that separation to 1.1 against 0.9 while
leaving ordinary rows untouched, so the redundancy is the off-support constraint and no in-sample metric
would notice its removal.

Limitations documented rather than fixed

  • baseline_by conditions on level, not on relationship. One pooled model learns one set of
    relationships. On a counterexample where group A has y rising with x and group B has it falling,
    both centred identically, the pooled model scores a row that is impossible for B at 1.3 where a
    model fitted on B alone scores it 260 .. and since the medians match, group-relative features are
    identical, so the transform changes nothing. Groups that behave differently rather than sitting at
    different levels want one model each. baseline_over_time has the same limit: it removes one shared
    trend.
  • The temporal changepoint criterion is blind to constant forecast bias, and that is deliberate,
    because a constant offset in a _rel_time feature cancels before it can reach a score .. offsets of 5
    and 500 move the correlation-aware score by 1.7e-14 and 1.9e-12 relative and leave Isolation Forest
    bit-identical. Non-constant error inflates residual spread, which the criterion does penalise, so the
    blind spot is exactly the case that cannot matter. Written down so nobody "fixes" it.

Breaking changes

  • segment_by removed, along with AnomalyParams.max_segment_models.
  • Registry segmentationgrouping. A pre-release table is migrated in place on the next retrain and the old column is gone afterwards, rather than both being carried forever by a mergeSchema append. A permission failure is translated into an error naming the grant needed.
  • Pre-release models cannot be scored at all, because the config hash formula changed .. retraining is the only path, and the error says so.
  • _dq_info[].anomaly.segment removed; is_new_baseline, new_baseline_key, is_stale_baseline and stale_baseline_horizon added. The struct is wider, so appending to an existing _dq_info table needs mergeSchema.
  • Feature engineering now refuses a schema where a generated feature would overwrite a real column (amount alongside amount_rel_baseline), instead of silently replacing it and fitting on two copies of the derived value.
  • _dq_info[].anomaly.contributions is keyed by source column under both profiles, where "tabular" previously keyed by engineered feature. A query reading signup_hour_sin now reads signup. The map stays map<string,double>, non-negative and summing to 100, so only the key vocabulary changes .. and a column that argued the row was normal now shows 0 rather than a small positive share. A flagged row with no anomaly-driving evidence carries an all-null map instead of an invented even split.
  • expected_anomaly_rate removed from train(). It supplied the estimator's contamination, which places only scikit-learn's own predict/offset_ boundary; DQX ranks score_samples against training quantiles, so the parameter never changed which rows were flagged. Documenting around a name that promises a detection rate would have preserved the trap, so it is gone. contamination now defaults to 0.02 directly, which is exactly what the parameter filled in, so a call that never passed it behaves identically. To change how much is flagged, set threshold on the check; to set contamination because you load the registered model and call predict yourself, pass it through params.algorithm_config.
  • The mlflow floor moves to >=3.13,<4, to pick up a Databricks unified-auth fix that exists in no 2.x release. One consequence needed handling: MLflow 3 validates saved sklearn models against skops' trusted types and refuses MahalanobisDetector, DQX's own class, so profile="timeseries" failed at registration while every unit test passed. serialization_format="cloudpickle" is now named explicitly, which is what MLflow 2 did by default, so this restores existing behaviour rather than introducing new.

Docs, demos, tests

  • Guide gains "Choosing a profile", framed as what kind of data do I have .. the only version of the question answerable without knowing ML. Threshold is documented as an alert budget, not a confidence score. Limitations become one FAQ entry, fixing two claims that were wrong (DQX does model seasonality; drift_threshold does not warn about trend).
  • Quality is published in the existing benchmarks report next to core timings, fed by two new perf tests whose fixtures are generated in-repo from a fixed seed .. nothing downloaded, no third-party data redistributed.
  • dqx_row_anomaly_detection_demo.py is replaced by two problem-led notebooks (card transactions that pass every rule but are jointly implausible; machine telemetry where every metric stays in band while the relationship breaks), both covered by a parametrised e2e test. Both print a threshold sweep with precision beside the ceiling that alert count allows, rather than a single detection rate, and both train on the whole table because DataFrame.sample draws per partition and is not reproducible across partition counts.
  • New: isolation-forest inertness (16 tests, sabotage-checked), the Mahalanobis contract and its leave-one-out identity, feature-name collisions, temporal permutation invariance, one-hot determinism, registry migration from a verbatim pre-release schema, and an MLflow round-trip for a DQX-defined estimator class.
  • New for attribution, each pinning a property rather than a wording: ground-truth orientation on rows with one known culprit (including the rejected alternative, asserted as wrong so nobody reaches for it again); the invented-uniform and all-null paths; the formatter's row cursor, whose off-by-one would misattribute every row after the first unexplained one; ensemble order-invariance, asserted at a tolerance because float addition is not associative; misaligned members raising rather than averaging; and source blocks recovering the undisturbed model's answer on both detectors.

Unit 2754 passing, mypy clean, pylint 10.00/10, make fmt and make build clean, make docs-build clean.

Still open

  • A supported calibration helper. Tuning on one half of a batch and applying to the other cut median error against the requested budget from 3.1x to 1.7x, but only 20 to 30% of machines landed within double their budget: a large improvement, not a guarantee, so it wants its own PR. Both demos print the sweep by hand meanwhile.
  • Training is not reproducible run to run at the default sample_fraction, because the sample is drawn per partition. A deterministic sample is a small change to sample_df but moves every existing user's model.
  • Detrending as a feature transform (measured flat 2.8% false flags against raw's 19.6% to 99.2%) and a trend-aware drift statistic. Both are new capabilities rather than fixes.
  • Version props read 0.16.0 in some places and 0.17.0 in others; which is right depends on the release this lands in, so a maintainer needs to settle it.
  • Signed attributions and an explained-target field in the public map, so a reader can tell a feature that drove the anomaly from one that argued against it. Deliberately not done here: it would reintroduce exactly the directional reading the prompt work just removed, and it wants its own acceptance tests.
  • A vocabulary check outside the learned ranking, so an unrecognised category is caught under the default profile. is_new_baseline is the precedent to follow.
  • The anomaly gate (make anomaly, against a live workspace) and both demo notebooks need a re-run, because the contributions map's keys changed for the default profile. Unit tests cannot reach the registry or MLflow paths, so that gate is the signal that matters and this stays a draft until it is green.

This pull request and its description were written by Isaac.

vb-dbrks and others added 30 commits August 24, 2026 21:53
`_save_training_record` hand-rolled the `features.feature_metadata` JSON with a
literal five-key dict, so `SparkFeatureMetadata.to_json()` was dead on the
training path. Any field added to the dataclass would have been dropped silently
at persistence, and the model would then score with a different feature set than
it trained on — a silent train/score skew rather than a loud failure.

Three changes, no behaviour change:

- `_save_training_record` now calls `artifacts.feature_metadata.to_json()`, and
  `to_json` iterates the dataclass fields instead of naming them, so it stays
  complete by construction. Field order is declaration order, so the emitted
  JSON is byte-identical for existing models.
- `from_json` ignores unknown keys instead of raising. It did `cls(**data)`, so a
  record written by a newer DQX version made an older reader fail outright; it
  now falls back to defaults for fields it does not recognise.
- New `apply_feature_engineering_from_metadata` collapses the five call sites
  that each hand-passed `frequency_maps` + `onehot_categories` +
  `categorical_cardinality_threshold` back out of a `SparkFeatureMetadata`
  (`core.py` x3, `training_service.py`, `feature_prep.py`). Future metadata
  fields thread through one function rather than five.

Groundwork for #1484.
Four documentation defects found while reading the anomaly module for #1484.
Each was verified against the code; no behaviour changes here.

- `drift_threshold`'s docstring claimed "default 3.0, None to disable". The
  signature default is `None`, and `None` disables drift detection outright
  (`drift.py:227` gates on `is not None`), so the stated default was the exact
  opposite of the real one. The reference docs already had this right.
- Drift was described as watching the "score distribution". It compares the
  *input feature* distributions against the per-column baseline statistics
  recorded at training and reports which columns drifted
  (`compute_drift_score`); it never looks at anomaly scores. Corrected in the
  check table, the parameter reference, and the guide. Also documented the
  1,000-row floor below which drift is skipped as too noisy to judge.
- `ensemble_size` was documented as if it always applied. Segmented training
  passes `allow_ensemble=False`, so it trains exactly one model per segment
  regardless, which also means `confidence_std` is unavailable for segmented
  models. Said so on the parameter, in the reference table, and in the guide.
- The guide advised "100+ rows per segment" without mentioning that segments
  below **10** rows are skipped entirely, leaving their rows unscored with no
  error. Documented the hard threshold alongside the guidance.

Groundwork for #1484.
The numbers governing per-segment training were literals spread across the training
service. Collecting them in `anomaly/group_config.py` gives each one a name and a
reason, and makes the ceiling below reviewable as policy rather than as a magic number.

MAX_SEGMENT_MODELS is the cost fuse: a 90-segment run was measured at 70 minutes
without completing. MIN_ROWS_TO_TRAIN_SEGMENT and MIN_ROWS_PER_SEGMENT separate "too
small to fit at all" from "small enough to warn about", which were previously the same
literal used for two different questions.
Five checks, each guarding a failure that is silent rather than loud if it gets through:
the columns must exist; they must not also be feature columns; their types must render
identically in Python and Spark; the list must not be empty when declared; and it must not
duplicate an entry.

The type restriction is the one with teeth. Floating-point and decimal columns are rejected
because Python's `str()` and Spark's `cast("string")` disagree on them, which would produce
one key at training and a different key at scoring — and that mismatch does not raise, it
just misses every baseline lookup and silently falls back to the global baseline.
…ment

Training persists a baseline under a key built in Python; scoring looks it up with a key
built in Spark. A disagreement between the two does not raise — every lookup simply
misses, falls back to the global baseline, and produces a model that appears to work
while conditioning on nothing. That makes this the most expensive invariant in the
feature to get wrong, so both halves and the test that pins them land together.

`_as_spark_string` exists because `str()` is not a drop-in for Spark's `cast("string")`.
Booleans are the case that bites: Spark renders `true`/`false`, Python renders
`True`/`False`. The first real Spark run of this code asserted
`'true\x1f42' == 'True\x1f42'` and failed, which is precisely the class of bug this
contract exists to catch — and a stale unit test had been pinning the wrong behaviour.

Floating-point and decimal baseline columns are rejected rather than shimmed: their two
renderings diverge in ways no small helper can reconcile.

The separator is ASCII unit separator, a control character, so ordinary categorical data
cannot contain it. It is a separator and not an escaping scheme, and the docstring says
so: data that genuinely contains \x1f can still collide. Deliberately not solved by
length-prefixing, which would buy protection against pathological data by widening the
one invariant most expensive to get wrong. A test pins the documented limitation so
nobody mistakes it for collision-proofing.
Closes #1484.

A value can be perfectly ordinary for the table and badly wrong for its own group. Row
anomaly detection had no scalable way to express that: the only grouping mechanism was
`segment_by`, which trains one model per group, so conditioning cost one model per group
and a discovered 90-way grouping became an hours-long run.

`baseline_by` adds, for each numeric metric, its deviation from that group's baseline:

    rel = signed_log(value) - signed_log(group_median(value))

on a **single** model, whatever the group count. Baselines are computed once at training,
persisted in the feature metadata, and broadcast-joined at scoring. The log-ratio form is
stable when a baseline is near zero and symmetric for halving versus doubling. The raw
metric is kept alongside, so a globally absurd value stays detectable even where it is
ordinary for its group.

Severity is calibrated per baseline group as well, since a score that is extreme for one
group is unremarkable for another, with the global calibration as the fallback for groups
that have none.

A row whose group was never seen at training is reported rather than scored. The
alternatives are both silent and both wrong: a high-cardinality frequency map returns 0.0
for an unseen key, which reads as "perfectly normal", and a one-hot encoding leaves every
indicator at zero, which reads as extreme. `is_new_baseline` and `new_baseline_key` name
the case instead. This widens the `_dq_info` struct, so appending to an existing results
table needs `mergeSchema`.

Three properties worth calling out, because they are what makes this safe to enable:

* **Baseline columns are never features.** They are the basis of comparison, not a metric
  being compared, so they are excluded before the sklearn pipeline and the inferred MLflow
  signature see anything. `prepare_training_features` projects to the feature list
  explicitly; the key column rides through as a passthrough for the calibration stage,
  which runs downstream of where the raw group columns are dropped.
* **`segment_by` keeps its exact behaviour.** `_resolve_grouping` clears `baseline_by` on
  that path. Leaving both set would compute relative features *inside* each segment, where
  the key is constant because the frame is already filtered, while `compute_config_hash` is
  built from `segment_by` alone and would not change — an identical hash with a different
  feature list, which is a silent train/score hazard.
* **Models trained before this change score identically.** Every new metadata field
  defaults to empty, so the relative transform returns immediately and
  `engineered_feature_names` is byte-identical to what it was.

There is deliberately no strategy selector and no heterogeneity gate. `baseline_by`
declared means relative features, always. The evidence for that choice, including the
measurement that removing the gate costs nothing, is in `benchmarks/anomaly_conditioning/`.

Also folded in here, because `config.py` and `training_service.py` carry it in the same
hunks: **the segment model ceiling now errors rather than warns.**

Above the ceiling the previous behaviour was to log a warning and continue, which meant a
90-segment run started training 90 models and was measured at 70 minutes without completing.
A warning that precedes an hour of unusable work is not a warning.

It now raises, and the message names the two ways forward — segment more coarsely, or raise
`max_segment_models` explicitly — because an error that only says no is a worse experience
than the warning it replaces.

`max_group_models` is renamed `max_segment_models`. Its only consumer is the legacy
`segment_by` path, the only path that trains N models, so it is a cost fuse on that path
rather than a general tuning knob. The name now says which mechanism it bounds.

`AnomalyParams` cannot import from the anomaly package — `anomaly/__init__.py` raises without
the anomaly extra — so the default is a literal, kept in step with `MAX_SEGMENT_MODELS` by a
unit test rather than by hope.
Predates this feature — `git log -- tests/perf/test_anomaly_benchmark.py` last touches it
in #1129/#990 — and is separable from it. It lands here rather than as its own PR because
the anomaly benchmark is what this branch changes the behaviour of.

`generate_md_report.py` has always contained a fully written "Anomaly Benchmarks" section
with ROC-AUC, precision, recall, F1 and precision@N columns. It has never rendered. The
cause was one missing line: neither benchmark carried
`@pytest.mark.benchmark(group="anomaly_synthetic")`, so pytest-benchmark recorded
`group: null` and the report, which filters on exact equality with `anomaly_synthetic`,
found nothing. `baseline.json` held 213 benchmarks and zero anomaly entries.

Also fixed, all found while making the section actually appear:

* The two benchmarks would have listed **twice** once the marker worked — once in the main
  results table, which is built from every benchmark, and once in their own section.
* Five rounds of a Databricks-backed train-and-register cycle ran per nightly, twice over
  since the job invokes pytest again for the comparison step. Converted to
  `benchmark.pedantic(rounds=1)`, which takes its round count explicitly; verified against
  `--benchmark-min-rounds=5` and confirmed to record one round.
* A module-global `_TRAINED_MODEL` with `needs_training` fallbacks made the score benchmark
  order-dependent and able to train up to three times. Each test now trains its own model
  as unmeasured setup.
* The score benchmark read `_dq_info`, which does not exist on the frame
  `has_no_row_anomalies` returns — that column is assembled a layer up by `DQEngine`. So the
  test failed and recorded none of the quality numbers this section exists to publish. It
  now reads the struct column the check actually returns.

Quality is published as *indicative and first-observed only*, and labelled as such. The
nightly's baseline merge keeps the existing entry on conflict, so once a benchmark exists
its `extra_info` is frozen at first observation and never refreshed — publishing quality
that way publishes a fossil. Quality regressions are gated by assertions in
`tests/integration_anomaly/` instead.

The anomaly module is marked `pytest.mark.anomaly` and excluded from the timing gate.
`--benchmark-compare-fail=mean:25%` is global and cannot be scoped per test, and these
benchmarks are dominated by MLflow and Unity Catalog control-plane latency, so they would
trip it on variance alone. They still produce a baseline; only the comparison skips them.
Reimplements the transform in numpy and fits sklearn's IsolationForest on raw columns and
on raw-plus-relative columns, so the claim behind #1484 is checked in the unit suite in
under five seconds with no Spark session, no workspace and no MLflow.

Measured: PR-AUC 0.0028 to 0.6962 on a contextual collapse, against a 0.0026 base rate --
so the pooled model is at chance, not merely worse. The reverse case is asserted not to
regress: an anomaly already extreme against every group stays at 1.0000 either way.

This guards the *mechanism*, not the pipeline, and the docstring says so. The pipeline is
covered by tests/integration_anomaly/, which needs a real session.
No Spark path in this feature had ever been executed before these tests existed, which was
by far its largest outstanding risk: the baseline-key agreement, the relative transform,
the broadcast joins, per-baseline severity and unseen-baseline marking were verified only
by inspection and by unit tests that never start a session.

Running them found real defects that inspection had not:

* Training any grouped model raised `UNRESOLVED_COLUMN`. Feature engineering drops the raw
  group columns so they cannot reach the sklearn pipeline or the inferred MLflow signature,
  but training-time severity calibration runs downstream of that, on the scored engineered
  frame, and rebuilt the key from columns that were no longer there. Fixed by computing the
  key once and reading it thereafter, via `with_baseline_key`.
* The Python and Spark halves of the baseline key disagreed on booleans.
* A discovered grouping trained one conditioned model where the test still expected one
  model per segment — the intended behaviour change, asserted the old way.

`test_anomaly_quality.py` measures detection quality rather than shape: it trains a
conditioned and an unconditioned model on identical data and asserts conditioning wins by a
margin, beats a random and a max-abs-z baseline, and does not concentrate false positives in
one group. Metric discipline lives in `quality_metrics.py`, which computes no point-adjusted
F1 and says why.

Two measurement flaws in those tests, both fixed here and both worth recording because they
would have made the assertions meaningless rather than wrong:

* Average precision over a single planted positive is a coin flip, so the fixture now plants
  an incident across several groups and days.
* A per-group false-positive rate over four rows can only be 0, 0.25, 0.5, 0.75 or 1, so the
  maximum across ninety groups reached 1.0 by chance and measured the fixture rather than the
  model. The fixture now emits enough control days for the rate to exist, the metric ignores
  groups too small to express one, and the test asserts the statistic was actually measured
  instead of silently NaN.

The severity assertion also matched on country alone, where one country spans fifteen groups
and only one collapsed, so `first()` returned an arbitrary sibling — passing at 95.5 on one
run and failing at 82.3 on the next with identical data. It now matches the whole key, built
with the production function so the assertion depends on Python/Spark agreement too.
`benchmarks/anomaly_conditioning/` compares three ways of relating a model to a group —
pooled, baseline-relative, and one model per group — across 1,395 configurations: a
synthetic two-factor sweep plus the Server Machine Dataset and NSL-KDD, 15 seeds each,
paired by seed and tested with Wilcoxon signed-rank.

Run manually rather than nightly: it downloads third-party data and yields a correlation
rather than a pass/fail. Datasets are fetched at run time and cached, never vendored, which
is what keeps the licensing position simple. SMAP and MSL are excluded — "(c) Original
Authors", no permissive licence. ADBench is unusable here despite being the obvious choice:
it ships pre-processed numeric matrices, so the categorical identity this experiment
measures is already gone.

The results, in `docs/dqx/docs/reference/anomaly_detection_quality.mdx`:

* Contextual anomalies: baseline-relative beats pooled by a median **+0.0734** PR-AUC.
* Globally extreme anomalies: **+0.0000**, and the test does not reject (p = 0.20). Not
  "costs little" — costs nothing. At low heterogeneity every group median approaches the
  global median, so the relative feature degenerates into a monotone transform of the raw
  metric: a near-duplicate of an informative column rather than noise.
* Real datasets: **-0.0010** at p = 0.003. Negligible in size, real in sign, and reported
  rather than buried — SMD and NSL-KDD anomalies are largely globally extreme or
  sequence-dependent, so the extra feature dilutes slightly without adding signal.
* Baseline-relative beats one-model-per-group in **every** mechanism, on real data included,
  while training one model instead of twelve.

That asymmetry — about +0.07 where conditioning applies, about -0.001 where it does not — is
the actual argument for auto-discovery enabling it rather than requiring opt-in.

**On the heterogeneity gate.** The decision rules were fixed before the first run: no gate if
the worst delta below eta-squared 0.10 stayed above -0.01, gate needed if any such cell
reached -0.02. The pre-registered rule fired. It is a minimum over single cells, which makes
it maximally sensitive to estimator variance, and it fired on one seed of
`nslkdd/protocol_type` whose other seeds were +0.1003 and +0.0989.

Rather than rewrite the criterion to get the preferred answer, the rule is left in place and
reported, with the per-grouping median published beside it and the disagreement explained in
the code. Raising the seed count fivefold moved no grouping's median below zero. No grouping
is systematically harmed at low heterogeneity, so the gate had nothing to gate on.

Eta-squared is not useless: it correlates with the size of the benefit (Spearman rho +0.597,
bootstrap CI +0.523 to +0.668, still +0.276 after controlling for anomaly mechanism). But it
predicts how much you gain, never whether you lose, and a gate needs to identify harm. It
cost a full Spark aggregation per training run to answer a question that never changed the
decision.

`scipy` joins the existing mypy `ignore_missing_imports` list, alongside pandas, sklearn,
shap and mlflow — the harness needs Wilcoxon and Spearman, and hand-rolling statistical
tests invites subtler errors than the missing stubs do.
The guide gains a "Baseline conditioning" section built around the one mechanism there is:
what `baseline_by` compares against, that it costs one model however many groups you have,
that baseline columns are the basis of comparison rather than metrics to be compared, and
what happens to a group that was never trained on. The measured numbers are quoted with a
link to `anomaly_detection_quality.mdx` for the full sweep and, more importantly, for what
they do not mean — DQX scores rows independently, so its figures on time-series benchmarks
are a different task, not a worse implementation.

`segment_by` is documented as legacy with the reason rather than a bare recommendation. On the
Server Machine Dataset per-entity models were the worst of three configurations, and one
entity produced 15,963 false positives across 28,392 normal rows. Each per-group model
calibrates its own contamination on its own rows, so a group containing nothing unusual still
has its most-unusual few percent scored as extreme.

Four breaking changes, of which the second is the one existing users will notice:

* Above the segment ceiling, training now raises instead of warning.
* **An auto-discovered grouping now trains one conditioned model instead of N per-group
  models**, so those runs produce different scores.
* A row whose group was never seen at training returns a null score and is reported via
  `is_new_baseline` rather than being scored 0.0.
* The `_dq_info` struct is wider, so appending to an existing results table needs
  `mergeSchema`.
1,395 cells: the synthetic two-factor sweep plus the Server Machine Dataset and NSL-KDD,
15 seeds each, stamped with the commit they were produced from so the numbers are
reproducible rather than merely asserted.

Committed as its own change because the results are an observation, not code. Re-running
the harness at the same seeds reproduces this file, which is how the determinism claim in
the README is checked; the figures quoted in
docs/dqx/docs/reference/anomaly_detection_quality.mdx are read directly from here.
Ten ADBench datasets (BSD-2-Clause, redistributing the ODDS / UCI / Kaggle collections),
chosen to span the axes that actually change a detector's behaviour: 1.8k to 285k rows,
9 to 100 features, and base rates from 0.17% (the Kaggle credit-card set) to 40%.

These answer a different question from the rest of the harness. SMD and NSL-KDD exist to
test whether conditioning on a group helps; these have no grouping at all, so they
characterise the ungrouped path -- which this branch does not change -- and serve as its
regression baseline.

Reported per dataset against its own random floor and its own max-abs-z baseline, never
pooled into a headline: PR-AUC moves with the base rate, so 0.19 at a 0.17% base rate
(a 99x lift) and 0.48 at 40% are not comparable numbers.

An earlier draft of this harness rejected ADBench outright because its preprocessing
discards categorical column identity. That is disqualifying for the grouping question but
not for plain tabular data, so the exclusion was too broad.
…ted one

Auto-discovery routed a discovered grouping to `baseline_by` but kept the selection policy
that only ever made sense for `segment_by`: take a single column, the lowest-cardinality
candidate, requiring 100 rows per group. Both rules exist because a per-segment model has
to train a forest on each group. Under baseline conditioning there is one model however
many groups there are, and a group only has to yield a median.

Measured on a dataset grouped by country x event_type x product -- 90 groups available:

  discovered baseline_by   [product]                     ->  [product, event_type, country]
  groups                   3                             ->  90
  training.columns         country, event_count, event_type -> event_count
  engineered features      13 (11 one-hot dummies)       ->  2
  PR-AUC, contextual       0.1302                        ->  0.5703
  PR-AUC, global           0.2702                        ->  1.0000

The feature-list change falls out of the grouping fix rather than being a separate
decision: the profiler already drops the chosen grouping from the feature columns, so
recognising country and event_type as dimensions stops them being one-hot encoded as
metrics. Eleven dummies alongside a single real metric were diluting the isolation signal,
which is why the global scenario was scoring 0.2702 where explicit columns scored 0.7200.

`select_baseline_columns` is public so its policy can be unit-tested directly rather than
through a Spark session; `MIN_ROWS_PER_BASELINE_GROUP`, `MAX_BASELINE_GROUPS` and
`MAX_BASELINE_COLUMN_CARDINALITY` sit beside the segmented thresholds with the reason they
differ. The legacy segmented policy is untouched.

Also closes a latent hole on the explicit-columns path: a discovered baseline column that
the caller had named as a feature is now excluded, rather than producing the
feature-and-baseline overlap that `validate_baseline_columns` rejects.
The page carried only `AvailableSinceVersion`, and the lifecycle reference is explicit that
"a feature with no status badge is generally available" — so for three releases the docs
promised semantic versioning and backward-compatibility guarantees for a feature that was
Experimental and is now Beta. DQX Studio is tagged experimental, actions and the MCP server
beta; this page was simply missed.

The distinction is not cosmetic. It is what makes the breaking changes in the following
commits legitimate: Beta states that "the API may still change in backward-incompatible
ways between releases", where GA would not.
The hash was f(columns, segment_by). Retraining under the same model_name with a different
baseline_by therefore produced an identical hash, so the single thing this hash exists to
catch -- same name, different configuration -- was blind to the grouping, even though the
grouping changes the feature list and the persisted baselines.

baseline_by now joins the inputs, which changes the hash of every configuration including
ungrouped ones, since the key is present either way. A model registered before this fails
the configuration check in score_global_model and must be retrained.

That break is the point rather than a side effect. Silently scoring a model whose
persisted hash no longer describes its configuration is worse than refusing to, and the
error now names retraining as the remedy and says which version moved the goalposts.
Row anomaly detection was Experimental through 0.16.0 -- "API, behavior, and on-disk or
table formats may change ... without notice or a migration path" -- so no compatibility
was owed here.

At scoring, baseline_by is read back from the persisted feature metadata rather than taken
from the caller: it is a property of the trained model, not a scoring argument. That makes
the recomputed hash match for anything this version trained, and mismatch for anything
older, which is exactly the intended boundary.
RobustScaler sat in front of the IsolationForest and could not have changed a single
prediction. It is an affine per-feature transform, and Isolation Forest splits on
per-feature thresholds drawn uniformly between each feature's min and max, so the induced
partitions are identical with or without it.

Measured rather than argued, across five ADBench datasets at five seeds each -- PR-AUC with
the scaler and without it agreed to four decimal places every time:

  covertype  0.0572 / 0.0572      shuttle  0.9789 / 0.9789
  mnist      0.2740 / 0.2740      fraud    0.1926 / 0.1926
  cardio     0.5766 / 0.5766

What it did cost: a fit and a transform on every training run, a transform on every scoring
pass, and bulk inside every pickled artifact. On every path, grouped or not.

The single-step Pipeline stays. named_steps["model"] is how the SHAP explainer reaches the
tree model, keeping it avoids churning eight -> Pipeline annotations across core.py and
ensemble_training.py, and it leaves a slot for a transform that genuinely does something.

One real hazard fixed alongside: the second SHAP call site indexed
named_steps["scaler"] directly, so it would have raised KeyError on any model trained by
this version. It now looks the step up, which also keeps working for older models that do
carry a scaler.
baseline_by lived only inside the features.feature_metadata JSON blob. That is where scoring
reads it, and functionally it was enough -- but it made the first question worth asking when
detection looks wrong unanswerable in SQL.

That cost real time this cycle. Diagnosing why zero-config conditioning was not engaging
needed a script to pull a model record, parse the JSON, and print baseline_by and the size of
baseline_medians, just to tell "conditioning never engaged" apart from "conditioning engaged
on a 3-group basis where 90 were available". Both look identical from the outside: one model,
is_global_model true.

It now sits in the segmentation struct beside training.columns, so:

  SELECT identity.model_name, training.columns, segmentation.baseline_by
  FROM <registry> WHERE identity.status = 'active'

answers it. The metadata blob stays the source of truth for scoring; this is a projection for
humans, and the duplication is noted where the field is declared.

The registry write gains mergeSchema. Without it a table created by an earlier DQX would
reject the wider struct, which matters more than usual right now: the configuration-hash
change tells users to retrain, retraining writes to their existing registry table, and a
remedy that fails is not a remedy.
The harness fitted IsolationForest(n_estimators=100, contamination="auto") while DQX ships
IsolationForestConfig(num_trees=200) with contamination taken from expected_anomaly_rate
(0.02 by default). So every absolute figure it published described a lighter model than the
product.

contamination is the harmless half: it only shifts the predict/offset_ threshold and cannot
reorder score_samples, so PR-AUC was unaffected by that difference. Tree count is not
neutral -- a held-out measurement at 200 trees put fraud at 0.2607 where the 100-tree
fit-and-score run had reported 0.1926.

Paired comparisons were never affected, since the configuration was held constant on both
sides of each comparison. It is the absolute per-dataset column that was understated.

Found while mapping the end-to-end heuristic flow, which is the sort of thing that exercise
is for.
Isolation Forest loses to a four-line z-score on covertype and mnist under every
hyperparameter tried, so the gap is inductive bias rather than tuning. The obvious remedy is
to put a cheap complementary scorer in the ensemble. This commits the measurement that says
not to, so the idea is not re-proposed from scratch and re-derived by whoever notices the
same gap next.

Held-out splits (70/30), 5 seeds, the shipped forest configuration, each member calibrated to
percentiles against its own training distribution:

  mean-of-percentiles   wins 5/10   median -0.0028   worst -0.1409 (thyroid)
  max-of-percentiles    wins 3/10   median -0.0253   worst -0.0690

It helps exactly where predicted (covertype +0.0295, mnist +0.0724) and hurts where the forest
is genuinely stronger (thyroid, fraud, shuttle, spambase, satellite). A coin flip on average
with a 0.14 worst case is not a default.

Two things the script records that are easy to miss when re-litigating this:

* Naive blending is not even implementable as-is. DQX averages ensemble member scores raw and
  the scales are incomparable -- the forest sits around 0.4-0.7, max-abs-z is unbounded -- so a
  real version has to persist each member's training-score distribution. Ranking inside the
  scoring UDF would be wrong, since the UDF sees a pandas batch and ranks would depend on
  partitioning.
* Choosing the better detector per dataset requires labels, and DQX is unsupervised. So the
  honest options are to expose the choice or document the limitation; guessing is not one.
Two dev pages, both written because this cycle found its defects by accident and that is not
a repeatable way to work.

**anomaly_heuristic_map** — every decision made between train(df) and a flagged row, as a
mermaid flowchart, with the governing default at each stage and the three questions that
isolate most failures: what was chosen (stages 1-2, which decide silently and held both
defects found while drawing this), did conditioning actually engage and on what, and is the
comparison even valid given that a 30% sample drawn with .sample is partition-dependent under
Spark Connect. It also records the measured weak points rather than only the design.

Drawing it immediately surfaced that the harness modelled a 100-tree forest where DQX ships
200, which is the kind of thing the exercise is for.

**anomaly_compatibility** — every affordance that exists only to keep old behaviour alive,
marked so `git grep "COMPAT(anomaly-v1)"` finds them, with the registry-schema item called
out because it touches persisted data rather than code. It deliberately separates that debt
from permanent runtime fallbacks -- unseen-group and missing-quantile handling look identical
in a diff and must not be deleted alongside.
The sweep in this page measured mechanisms against each other; it never measured this build
against the one users are running. That is the question that actually matters, and the answer
now leads the page: v0.16.0 and this build, through the real Spark/MLflow/Unity Catalog path,
over the same Delta tables.

  contextual, zero config        0.1885 -> 0.5703
  contextual, explicit columns   0.0376 -> 0.5703
  global,     zero config        0.3743 -> 1.0000
  global,     explicit columns   1.0000 -> 1.0000

Better on three, identical on the fourth, worse on none. The 0.0376 -> 0.5703 row is the
cleanest statement of what conditioning buys, since v0.16.0 has no conditioning mechanism at
all; the two exact 1.0000s are the regression check, on the path this work does not touch.

The page also records how the measurement went wrong the first time, because it will catch
anyone who repeats it: training samples 30% of rows by default via .sample, which under Spark
Connect depends on partition ordering, so an uncontrolled run had the two builds training on
different rows and showed the new build losing three of four cells. With sample_fraction=1.0
every cell reproduces to four decimals. A single-point comparison of this pipeline is not
evidence.
Regenerating the sweep with the corrected 200-tree harness reversed a published claim, so it is
corrected here rather than left standing.

  baseline-relative minus pooled, real datasets
    was (100 trees):  -0.0010  p = 3.05e-03   "marginally worse"
    now (200 trees):  +0.0025  p = 1.75e-02   marginally better

The page keeps the old number and says why it moved: the harness fitted a lighter forest than DQX
ships, which understated it on exactly the datasets where the forest does the work. The contextual
(+0.0742) and global (+0.0000) rows barely moved, because a paired comparison holds configuration
constant on both sides — it was the real-data row, where the two mechanisms are closest, that a
fidelity gap could flip. The guide and changelog carried the same figure and are updated too.

Also adds the plain-tabular section the sweep has been producing but the page never showed: ten
ADBench benchmarks spanning 1.8k to 30k rows, 6 to 100 features, and base rates from 0.17% to 40%.
DQX beats the random floor on all ten and max-abs-z on eight, with fraud at 115x the floor.

The two it loses get their own subsection rather than a footnote. covertype (0.0534 vs 0.1122) and
mnist (0.2766 vs 0.3367) are Isolation Forest's inductive bias: it splits one random feature at a
time, so a single extreme value among few dimensions is diluted and in 100 dimensions the split
rarely lands on the informative axis. Tuning does not recover it and the obvious knob trades shuttle
and cardio away for fraud. Publishing the losses with the diagnosis is more use to a reader than a
table of wins, and anyone with ADBench reproduces covertype in three lines regardless.

Supersedes the two partial results files from earlier runs.
The page had accumulated material that existed to convince us the work was an improvement, not
to help anyone use the feature: a build-versus-build comparison against v0.16.0, the story of
getting that measurement wrong the first time, a note about which harness revision reported
which number, and the design rationale for a heterogeneity gate users never saw.

That belongs in the changelog, the dev docs and the PR, and it is already in those places. 265
lines down to 151.

What a reader actually needs is now the shape of the page: whether anomaly detection suits
their data, whether to give it a grouping, and what it is bad at. The conditioning tables are
reframed from "mechanism A versus mechanism B" to "your anomalies are like this, so do that",
and the underperformance section ends with what to do instead -- a range check or an outlier
rule serves single-column extremes better, and both can run together.

Kept, because they are the reader's protection rather than our defence: rows are scored
independently so time-series comparisons are a category error, PR-AUC moves with the base rate
so the columns are not comparable, no point-adjusted F1, and every figure measures separability
rather than generalisation.
The detection-quality numbers sat in a page nobody regenerated, so they would go stale the first
time anyone changed the model. They now refresh with the timing benchmarks and ride the same PR.

Not routed through pytest-benchmark, for a specific reason. The nightly merges baseline.json with

    if ($old[.] != null) then $old[.] else $new[.] end

so an entry that already exists keeps its previous value entirely -- which is why no existing
benchmark mean has changed across the last four baseline PRs. Quality published through
extra_info would be frozen at first observation and republished as fresh for ever. emit_docs.py
writes the numbers into the page directly instead.

Generated tables sit between HTML comment markers, so regeneration replaces the numbers and
leaves the prose. That is what makes this page different from benchmarks.mdx, which
generate_md_report.py rewrites wholesale and where hand-written guidance therefore cannot
survive -- the reason the two are separate pages rather than one.

Scoped for CI at 5 seeds rather than 15, and marked continue-on-error: the real datasets are
~250MB fetched from GitHub on a cold cache, and a third-party download failing should not fail
the timing benchmarks that already ran. The trade to be aware of is that the nightly now depends
on an external host it did not before.

emit_docs warns rather than stays quiet when handed a results file with no tabular data, since a
stale table that looks current is worse than an obvious gap.
SHAP contributions are keyed by *engineered* feature name, and redaction filters those keys by
exact match against the caller's redact_columns. Baseline conditioning adds a derived feature per
metric, so redacting `amount` did not stop `amount_rel_baseline` -- a signed log-ratio of the
same column -- from being embedded in a prompt and sent to an external serving endpoint.

Naming a column sensitive has to mean everything computed from it is sensitive, so the redaction
set now expands to cover the derived feature. Expansion is by exact suffix rather than prefix
match, because prefix matching would also swallow `amount_paid` -- a different column the caller
never named -- and quietly narrow what an explanation can discuss.

The suffix is now a named constant in transformers.py, where the feature is created, rather than
an inline f-string, so the two cannot drift apart.

Known gap, documented at the function and not closed here: one-hot and frequency-encoded features
are still not covered, because their names cannot be reconstructed from the source column alone --
`country` becomes `country_C3`, `country_DE`, one per observed value. Closing that needs the
feature metadata at prompt-construction time, which the explainer does not currently receive. That
hole predates this branch; this commit closes the one this branch opened.

redaction_set is public so its policy can be unit-tested directly rather than through a private.
…line_by grouping

Row anomaly detection was Experimental through 0.16.0 and owed no compatibility.
This removes the per-group model path (segment_by / max_segment_models) entirely,
leaving a single pooled baseline_by model that measured better on detection,
reliability and cost across all four benchmark regimes.

Source removals:
- config.AnomalyParams.segment_by / max_segment_models
- training_service: segmented dispatch, segment resolution and validation
- scoring_run: score_segmented, load_segment_models, score_single_segment, and the
  now-orphaned _split_max_groups_budget / _warn_if_max_groups_below_segments helpers
- scoring_orchestrator segmented fallback; scoring_strategies.score_segmented
- model_discovery segment-record selection; drift.check_segment_drift
- SegmentationConfig -> GroupingConfig; persisted `segmentation` struct -> `grouping`
- _dq_info.anomaly.segment field (permanently null once segmentation is gone)
- group_config segmented thresholds (MAX_SEGMENT_MODELS, MIN_ROWS_PER_SEGMENT, ...)
- segment_utils: canonicalize_segment_values / build_segment_name / build_segment_filter

Models trained on a prior build must be retrained: the config hash changed and the
registry struct was renamed. mergeSchema is retained on the registry write so the
retrain writes cleanly to an existing registry table.

Tests: delete test_anomaly_segments, test_anomaly_segment_naming,
test_anomaly_scoring_run; update autodiscovery, registry, drift and apply-checks tests
to the baseline_by policy (baseline columns are grouping, not features).

Docs: CHANGELOG BREAKING entries; anomaly_compatibility.mdx converted to a migration
guide; anomaly_heuristic_map.mdx segmented branch removed; user guide reworked around
group-aware detection with a breaking-change/migration section; Experimental -> Beta.

The LLM explainer's segmentation vocabulary is intentionally left for the next commit,
which rewrites it with the baseline_by replacement in hand rather than blanking it.

Co-authored-by: Isaac <no-reply@databricks.com>
Feature engineering expands each source column into engineered features by a fixed set of naming
conventions (one-hot, frequency, null indicator, boolean, datetime cyclicals, numeric identity,
baseline-relative). Nothing inverted those conventions, so two things were impossible: redacting
every feature derived from a redacted column, and showing a reader a raw key like
`event_count_rel_baseline` as a human phrase.

New anomaly/feature_naming.py, pure functions over SparkFeatureMetadata:
- source_column(name, metadata) -> str | None
- human_label(name, metadata) -> str  (falls back to the name; never raises, never hides a driver)
- engineered_from(source, metadata) -> frozenset[str]  (defined via source_column, so forward and
  reverse can never disagree)

Resolution order is deliberate: one-hot first (matched against onehot_categories, since a value may
contain an underscore or collide with a fixed suffix), then fixed suffixes accepted only when the
remainder is a real source column, then numeric identity. Unit-tested across every convention,
including a category value that collides with a suffix and a numeric column named like one.

Unused until the next two commits wire it into redaction and prompt rendering.

Co-authored-by: Isaac <no-reply@databricks.com>
…column

Redaction filters LLM-prompt contribution keys by exact match, and those keys are engineered
feature names. A prior fix covered the baseline-relative feature (`amount` -> `amount_rel_baseline`)
by reconstructing that one name from the column, but one-hot and frequency features could not be
reconstructed from the column alone (`country` -> `country_US`, `country_DE`, `country_freq`,
`country_is_null`), so they still reached an external serving endpoint after the user redacted
`country`.

Thread the model's feature metadata to the point of redaction and enumerate the derived features
exactly:
- ExplanationContext gains an optional `feature_metadata` field; `from_scoring_config` takes it and
  `scoring_run` passes the `parsed_metadata` already in scope at the call site.
- `redaction_set(redact_columns, metadata)` now unions `engineered_from(column, metadata)` per
  redacted column, covering one-hot, frequency, null-indicator, baseline-relative and identity.
  This replaces the suffix-only expansion. With no metadata (a caller who built the context
  directly), it falls back to the baseline-relative feature only -- best effort, unchanged.

Redaction still matches engineered keys, upstream of any human rendering, so the hole stays shut
when 2c adds human labels.

Tests: metadata-driven redaction over one-hot + frequency + null-indicator + baseline-relative; the
existing metadata-less tests are unchanged and still pass.

Co-authored-by: Isaac <no-reply@databricks.com>
…ines

Contributions were shown to users and to the LLM as raw engineered names — a reader saw
`event_count_rel_baseline (74%)` in `_dq_info` and in the prompt — and the prompt still spoke the
removed language of segments.

- feature_contributions in the prompt now renders human labels via the reverse map, after the
  redaction filter: `amount vs its group baseline (82%), quantity (11%)`. A driver whose label
  cannot be resolved falls back to its raw name, so nothing is ever hidden. The label lookup is a
  SQL map literal with escaped keys/values (column names are user-derived).
- The ai_explanation struct gains a `top_drivers` string (the same human-labelled sentence) for
  display. `top_features` stays engineered names for grouping/tooling, and the top-level
  `contributions` map keeps engineered keys so redaction and downstream tooling still match.
- Prompt vocabulary moves from segments to baselines: the dead `segment` field (always empty since
  the segment path was removed) becomes `baseline_grouping`, the columns forming each row's baseline
  group — the material fact for a contextual anomaly, and a per-run constant with no PII. Few-shot
  examples and instructions reframed accordingly. The dead `_format_segment` helper and its tests
  are removed; the committed prompt snapshot is regenerated.

Billable ai_query surface: the fixed prompt header grows ~322 chars (~+80 est. tokens) per call
from the richer baseline vocabulary and label guidance, plus a few chars per group for longer
human labels.

Tests: unit coverage for the baseline-grouping string and the human-label map (identity omitted,
derived features labelled, empty without metadata); the AI-explanation integration test asserts
top_drivers is populated and carries the top features with weights.

Co-authored-by: Isaac <no-reply@databricks.com>
vb-dbrks and others added 4 commits September 4, 2026 13:43
…sions

Every claim in the review was reproduced against the code before being fixed. All eight held, and so did the
suppression count. Two of my own were wrong and are corrected below.

BLOCKERS

A pre-release registry could not be retrained, which is the remedy both the migration guide and the
stale-hash error prescribe. get_active_model read values["grouping"] unconditionally while a pre-upgrade
table carries "segmentation", so it raised KeyError, and training reads the registry, so it failed before
save_model could write anything. Now migrated properly rather than papered over: the table is rewritten into
the current schema and "segmentation" is gone, instead of sitting beside "grouping" forever with each null on
half the rows. The mergeSchema that produced that outcome is removed, so a future schema change fails loudly
and demands its own migration. Permission failures translate into an error naming the grant needed, and state
plainly that models in an unmigrated table cannot be scored either until retrained -- my first draft claimed
scoring was unaffected, which is false, because the hash formula changed.

Grouped single-model training failed at MLflow signature inference. Feature engineering preserves the group
key on purpose, so the engineered frame is wider than the feature list, and the whole frame reached
model.predict carrying a string column the estimator was never fitted on. It affected profile="timeseries"
always and the tabular profile at ensemble_size=1; the default three-model ensemble registers by URI and
never reaches that code, which is why nothing caught it.

One null timestamp ended temporal training with a TypeError: the null bucket's min(seconds) reached float()
unguarded while the metric values beside it were guarded. Nulls are filtered from the fit; the all-null case
still raises deliberately.

The headline SMD evidence measured a 4,000-row prefix of each entity, after which the ten entities with no
positives inside that prefix were dropped as unusable. Recomputed from the raw labels: the full series holds
29,444 anomalous rows in 327 incidents across all 28 entities, against 3,732 in 39 across 18. machine-1-1
alone has 2,694 anomalous rows and none in its first 4,000. The truncation is removed and the full series is
now the default. The published "79% against 33%" at a 1% budget becomes 67.6% against 57.3%, and the
justification moves to average precision, 0.425 against 0.278, because the per-entity coverage gain is +7.9%
with a standard deviation of 38.2% and a 16/7 record -- inside its own spread. The guide now states the
entity count, the full-series scope and that variance.

ISSUES

Spark and numpy disagreed on a degenerate severity tail: with p95 equal to p99, Spark pinned everything above
the anchor to 95 while numpy interpolated to 100, so thresholds above 95 became unreachable and SHAP gating
decoupled from the authoritative severity. Spark now matches numpy, and the linear chain is extracted so the
head and the fallback share one implementation.

An explicit baseline_by bypassed MAX_BASELINE_GROUPS, which auto-discovery has always respected. The failure
is a driver out-of-memory rather than a slow run, because per-group medians and quantiles are each collected
and persisted into model metadata. Now refused with the group count and the remedy.

The rows-per-group floor was checked before sampling, so a guarantee of 30 rows became about 7 at the fit. The
gate uses the effective figure and honours the caller's own sample_fraction. Two existing tests encoded the
old optimism, one asserting 120 rows per group was "comfortably above the floor" when it is 28.8 by the time
anything is fitted; both were rescaled to test the same boundary.

A null group value collided with a literal "MISSING", silently merging two populations into one baseline. The
sentinel is now a control character. Done now because it changes the persisted key format and every model must
already be retrained this release. Narrower than the review stated: the imputation route is unreachable,
because baseline_by may not overlap the feature list.

SUPPRESSIONS

Four, removed rather than justified. Three type: ignore gave way to the plain if/else that narrows, matching
the block above them. One noqa was inert -- the rule it names is not enabled -- which is why nothing flagged
it, and why citing "pylint 10.00/10" was checking the wrong signal.

Verify with: git diff origin/main...HEAD -- src/ | grep -E "noqa|type: ignore|pylint: disable"

Left for the maintainers: this change is dated 0.16.0 in three places and 0.17.0 in three others against a
__version__.py of 0.16.0. Which is right depends on the release, and version props are historical facts rather
than something to infer, so nothing was changed except the runtime error, which no longer names a release.

Co-authored-by: Isaac <no-reply@databricks.com>
The one failure in the anomaly gate's first real run, 233 of 234 passing on both runners. The fixture gave
`category` 5 distinct values over 200 rows, so 40 rows per group, which the fit sees as 9.6 once sampling and
the train split have taken their share -- below the 30 the constant promises. So it is correctly no longer a
grouping candidate, and the fixture was sized against a threshold nothing enforced.

1,000 rows gives `category` 200 rows per group and ~48 at the fit, so it is selected as before, while adding
`user_code` as a second dimension would give 4 rows per group and is still correctly rejected. Same boundary,
measured against the threshold that applies.

Third fixture in this PR to have encoded the pre-sampling optimism.

Co-authored-by: Isaac <no-reply@databricks.com>
`is_permission_error` was private and the tests that pin it in both directions imported it anyway,
which pylint's C2701 caught. AGENTS.md rules out silencing protected-access to reach past a boundary
and prescribes exactly this remedy: if a member needs outside access, make it public.

Both directions are worth pinning, and the second more than the first. Mistaking a genuine failure
for a missing grant sends someone to their platform team over a defect in DQX, with nothing in the
message to suggest otherwise.

Co-authored-by: Isaac <no-reply@databricks.com>
The integration fixture that writes a dummy Databricks config profile exists because of
mlflow/mlflow#20599, "Fix Databricks unified auth support when MLFLOW_ENABLE_DB_SDK=true", merged
2026-04-30. That fix is in no 2.x release -- the last is 2.22.4 -- so pinning past it crosses the
2->3 boundary, which is why the floor moves this far.

Capped below 4.0 rather than pinned, so the resolver takes the latest 3.x. It resolves to 3.15.2
rather than today's 3.16.0 because `make lock-dependencies` passes `--exclude-newer "7 days"`; the
project's own guard rail refuses a same-day release, which is the behaviour I would have asked for.

One quiet consequence: `log_sklearn_model_compatible` prefers `name=` when the signature offers it,
and 3.15.2 accepts both `name=` and `artifact_path=`, so it now takes the `name=` branch where 2.x
took the other. Same call, different keyword, and the `artifact_path=` fallback is dead on any
supported version. Tracked separately rather than removed here.

Unit tests pass, but they exercise no MLflow tracking or registry call at all -- every real one
lives in tests/integration_anomaly. The anomaly gate is the first real signal on this bump.

Co-authored-by: Isaac <no-reply@databricks.com>
vb-dbrks and others added 23 commits September 4, 2026 16:03
Feature engineering builds derived columns by appending a suffix and writing the result with
`withColumn`, which replaces any column already carrying that name. A table with both `amount` and
`amount_rel_baseline` therefore lost the second one: the derived value overwrote it, the name was
appended to the positional feature list a second time, so the frame carried two identical columns,
and the model was fitted on two copies of the derived value with the user's real metric gone.
Nothing downstream can detect that, and per-feature attribution then names the wrong source column.

Every suffixed transform has the same hole -- `_rel_time`, `_is_null`, `_bool`, `_freq`, the seven
calendar names, and one-hot `{col}_{value}`.

Refused rather than worked around. Renaming the derived feature would leave the persisted feature
list disagreeing with the suffix that redaction and attribution both key on, and dropping either
column silently discards data the caller asked to be checked.

Two checks, because one is knowable from a schema and one is not:

- the predictable names, in `validate_generated_feature_names`, from the schema alone with no Spark
  action. Conservative on the two things a schema cannot answer: a null indicator's name is claimed
  even when this frame has no nulls, because a collision there is a bug waiting for the first null,
  and a categorical column claims its frequency name whatever its cardinality
- the one-hot names, where they are built, because `{col}_{value}` depends on the values

Also catches two columns generating one name, which is why the collector returns a list rather than
a name-keyed dict -- a dict kept one entry and the clash could never be seen.

Suffixes become module constants used by both the transforms and the validator: one drifting apart
from the other would reopen the hole with every test still passing. The calendar block now pairs its
seven expressions with that constant tuple, so the emitted order is the checked order by
construction, and `_classify_column`'s isinstance chain moves into `feature_category_for_type`,
which is what lets validation answer "which transforms will run" from a schema.

The integration test is the one that matters: the unit suite pins the predicate, and only that pins
that `train` consults it.

Co-authored-by: Isaac <no-reply@databricks.com>
… from every metric

Two feature-engineering defects, both of which made a fitted baseline depend on something it should
not have.

Nulls were imputed before the baselines were fitted. `_process_numeric_columns` did
`coalesce(col.cast(double), 0.0)` in place on the raw metric, and both relative blocks run after it,
so a missing observation contributed a fabricated zero to its own group's median and to its time
bucket's median, then received a fabricated deviation from the baseline it had just moved --
`0 - expected(t)` -- on top of its null indicator. Measured on Spark: a group of three 100s and four
nulls had a baseline of 0.0 where the median of the values that exist is 100.0.

The cast stays where it was and only the imputation moves to the end, after both bases are fitted.
`percentile_approx` skips nulls by construction, so the medians and the bucket aggregates now see
what is actually there, and the derived features are imputed to a neutral zero -- which for a
deviation genuinely means "no deviation", with the `_is_null` indicator carrying the missingness as
it was always supposed to. Appends no feature, so the positional contract is untouched.

The temporal basis was chosen by whichever metric came first. `select_basis` scored changepoint
counts on `representative`, the first metric with more than two usable buckets, and took *its* masked
time axis for the period search too, so reordering columns changed every fitted residual for every
metric. Reproduced: 0 changepoints with a straight metric first, 3 with a bent one.

It now reads every metric. Candidates are compared on the mean across metrics of the ratio between a
candidate's holdout residual scale and the same metric's scale under the simplest basis, and the
period search reads the full bucket axis. The ratio is what stops a large-magnitude column outvoting
the rest, since a residual scale carries the metric's own units.

Standardising each metric first was not in the plan and is not cosmetic: my own test asserting the
ratio was unitless failed. The Huber fit's `alpha` penalty is absolute, so the same shape expressed
in millions is effectively fitted with less regularisation, and rescaling one metric by 1e6 moved the
selection from three changepoints to six. Each metric is now centred and scaled by its own MAD before
being scored -- for the basis *choice* only, `fit_temporal` still fits raw values, so no coefficient
moves.

Verified against fieldeng rather than only in CI: the null-baseline, temporal-null and imputation
tests pass on real Spark, and so do the group-relative, transformers, nulls and temporal suites
(44 + 21 + 16 passed).

Co-authored-by: Isaac <no-reply@databricks.com>
…in the width warning

One-hot encoding dropped one category of a binary pair. That is the textbook way to avoid the
dummy-variable trap, but here it cost a blind spot rather than collinearity: at scoring, the omitted
reference category and any value never seen in training both encode as all-zeros, so a brand new
value in a binary column produced no signal at all. Every category is now retained, so a known value
sets exactly one indicator and anything unseen sets none. The collinearity is absorbed by the
detector's ridge, and it is only exact when the column has no nulls.

Categories are also sorted now. `distinct().collect()` has no ordering, so the same table trained
twice produced the same features in different positions -- and for a binary column, a different
surviving category each time. Harmless inside one model, since the list is persisted with it, but it
made a model irreproducible from its own inputs under a positional contract.

The feature-width warning counted one feature per numeric column and was never told about the
comparison bases, each of which adds one more. Twenty numeric columns with both bases build sixty
features; the estimate said twenty and stayed silent under the recommended maximum of fifty, on
exactly the configuration it exists to flag. The bases are now threaded through validate_columns into
the estimate, and the breakdown names them on their own lines, because dropping a basis is a
different decision from dropping a metric.

Two smaller corrections found while counting. One-hot contributed `cardinality + 1` "for MISSING",
but no MISSING indicator is ever built -- the distinct values are collected from the pre-imputation
frame with nulls filtered out -- so the estimate was one too high per one-hot column; a null is
carried by the `_is_null` indicator instead. And the datetime count is now `len(CALENDAR_FEATURE_
SUFFIXES)` rather than a literal 7, so it cannot drift from the transform.

My own test for the width warning failed first time for the right reason: it filtered on
"recommended max", which the max_input_columns warning also contains, and twenty columns trips that
one whatever the bases are. The filter now matches the width warning's own opening words.

Verified on fieldeng: the one-hot, determinism and width tests pass on real Spark, along with the
rest of the transformers and column-naming suites.

Co-authored-by: Isaac <no-reply@databricks.com>
…ctually do

Three claims in the documentation that the code does not support. No behaviour changes here.

`expected_anomaly_rate` was described as controlling how many rows the model flags. It does not.
It supplies `contamination`, which for both detectors places only `offset_` and therefore only
`predict` / `decision_function`; every DQX scoring path reads `-score_samples` and ranks it against
the training score quantiles, so what gets flagged is decided by the check's `threshold`. It also
does not mitigate anomalies present in the training sample, which the engine docstring implied by
calling it a defence against contaminated training data. Two unit tests now pin both halves: scores
identical across contamination 0.01 and 0.20, `offset_` and `predict` different. The parameter stays,
because it is real for anyone who loads the registered model and calls `predict` -- but the docs no
longer sell it as a detection knob.

`_rel_baseline` was documented as a log ratio that is "stable when a baseline is near zero and
symmetric for halving versus doubling", and my own review brief called it unitless and therefore
invariant. It is a signed-log1p *difference*, and only the first half is true. Doubling from 1 gives
log3 - log2 = 0.405, the same ratio at 100x scale gives 0.688, and halving gives -0.288 rather than
-0.405. So deviations are comparable within a group but not across groups of very different
magnitude. What the form does buy is stated instead: finite and monotone where a true log ratio is
undefined. A median/MAD standardisation would be genuinely dimensionless and is noted as the
alternative, with the cost that argues for measuring it first -- a per-group MAD persisted beside
every median.

The severity tail above roughly p99 assumes an exponentially decaying score distribution. That held
on four synthetic distributions and on real telemetry, but a heavier tail overshoots: on lognormal
scores `threshold=99.9` flagged 0.21% of rows against 0.10% requested. The guide now says to read
99.5 and above as "much stricter than 99" rather than as a budget.

Co-authored-by: Isaac <no-reply@databricks.com>
The headline SMD evidence combined three runs. The coverage table came from a script that fitted a
single Isolation Forest on SMD's clean train split and wrote no JSON; the spread and win-loss record
came from the ensemble panel, whose coverage is 57.5%/65.3% rather than 57.3%/67.6%; and the average
precision pair matched no archived file at all -- 0.425 against an archived 0.4231, which does not
round. The guide then said both detectors were "trained on data that still contained the anomalies",
which was true of an even earlier protocol and false of the one actually run: the loader fits SMD's
unlabelled train split.

Replaced by one run measuring both shipping profiles under two protocols, archived as
model_quality/results/smd-protocol.json:

- dqx: sample 30% of the rows to be scored, fit 80% of that sample, score everything. What DQX does to
  a live table, contaminated training included.
- clean: fit SMD's own train split, score its test split. The conventional protocol, and the one every
  archived number so far was really produced under.

Under DQX's own protocol the second detector surfaces 96.8% of incidents inside a 1% alert budget
against 65.4%, +31.4 points per entity with sd 29.5 and 22 wins to 1 loss. That inverts the published
framing, which told readers to look at average precision because coverage was inside its spread:
under this protocol average precision is the wash (+0.006, 14W/14L) and coverage is the consistent
win. The clean protocol is worse for *both* detectors, because SMD's training period drifts from its
test period and a model fitted on a sample of the rows it will score does not inherit that drift.

That is not leakage, which was the obvious objection, so it is measured rather than argued: ranking
metrics restricted to the rows the model never saw give ROC-AUC 0.826 against 0.845 and average
precision 0.380 against 0.382, the same to three decimals. Event coverage is deliberately not reported
there, because dropping a quarter of the rows fragments incidents into a different set of events.

What this still is not is DQX's pipeline -- no group-relative, time-relative or calendar features,
because those need Spark and a workspace. The guide now says so rather than implying otherwise.

Benchmark figures come out of the docstrings entirely and are cited from the guide instead. Copying one
number into five files is how they came to disagree, and two of those five were generated API pages
that no one could have edited anyway.

Also corrects the PCA rejection, which claimed truncation "lands on the correlation-aware detector's
own number". The archive says otherwise: PCA at 95% variance before that detector reaches 74.5%
coverage against its 65.3%, 15 wins to 5. The rejection stands on grounds that survive the number --
k is unchoosable, it collapses on the very anomaly class the detector exists for (recall 81.6% to
0.8%), and it destroys per-column attribution -- and now says that instead.

Co-authored-by: Isaac <no-reply@databricks.com>
…ng it

Follow-up to scaling this fixture from 200 rows to 1,000 so `category` clears the effective
rows-per-group floor. The row count moved; an assertion expecting "200 distinct values" in the
high-cardinality warning did not, and `transaction_ref` is unique per row, so it now reports 1,000.
Third time a hardcoded fixture number in this file has had to be chased after a rescale, so it reads
off `row_count` now.

Also pins the collinearity that Stage 4 introduced by retaining every one-hot category. Retained
categories sum to 1 on every trained row, so the correlation-aware detector sees a zero-variance
direction. Measured both halves rather than assuming: rows satisfying the sum score identically to
the one-dummy encoding, so the redundant column is free, while an unseen category lies off that
surface and scores far above a known one. That large score is deliberate and is not new behaviour --
columns with three or more categories were always retained in full, so an unseen value has always
scored this way. Truncating the binary case was the inconsistency.

Co-authored-by: Isaac <no-reply@databricks.com>
`make build` failed with `Unexpected '', expected '-c', '-e', '-r' or the start of a requirement at
.build-constraints.txt:7`. Line 7 is a `# via` comment wrapped in escape sequences.

`make lock-dependencies` pipes `uv pip compile` into the file. uv suppresses colour when its output is
not a terminal, but this shell exports FORCE_COLOR=3, which overrides that, so regenerating the file
while bumping the mlflow floor wrote twelve escape sequences into it and uv's own requirements parser
then rejected the result. That breaks the release wheel, not just a local build.

The file is now byte-identical to main: the build-system requires are hatchling and
hatch-fancy-pypi-readme, which an mlflow floor cannot affect, so it should never have changed in that
commit at all. Stripped rather than re-resolved, so the pins cannot drift against a different
--exclude-newer window; all seven pinned requirements are unchanged. `make build` exits 0 and produces
both the wheel and the sdist.

Found by building the wheel to re-run the demo notebooks, which is not a step CI performs on a pull
request -- so nothing would have reported this before the release.

Co-authored-by: Isaac <no-reply@databricks.com>
Only uv.lock conflicted; everything else auto-merged, and the merged pyproject.toml correctly carries
both sides -- main's `requires-python >=3.10,<3.13`, its litellm ceiling and its ruff bump, alongside
this branch's mlflow floor.

Resolved by taking main's lock as the base and re-running `make lock-dependencies`, because a lockfile
is generated rather than authored and hand-merging 4,500 hunks of it would produce a file no tool
would ever emit. Basing on main keeps the churn to what the mlflow floor actually forces. Resolves to
mlflow 3.15.2 and ruff 0.12.12, so both sides' intent survives.

Two things checked rather than assumed, both because they bit today:

- **No private-proxy URLs.** The only hosts in uv.lock are pypi.org and files.pythonhosted.org. The
  target's perl step rewrites them, which is exactly why the lock is regenerated through make rather
  than by calling uv directly -- an ad-hoc `uv run` earlier this session did leak
  pypi-proxy.cloud.databricks.com into the working tree, and that copy was discarded, never committed.
- **No ANSI escape codes in .build-constraints.txt**, run with NO_COLOR because this shell exports
  FORCE_COLOR=3, which is how colour codes got committed into it earlier and broke `make build`. The
  file is byte-identical to main.

ruff moves from ~=0.3.4 to ~=0.12.0 here, which is a nine-minor jump in the linter, so `make fmt` is
re-run against this branch's new code separately from this integration commit.

Co-authored-by: Isaac <no-reply@databricks.com>
…needs for our own detector

Raising the mlflow floor to 3.x broke `profile="timeseries"` outright. MLflow 3 validates a saved
sklearn model against skops' set of trusted types and refuses anything it does not recognise:

    MlflowException: The saved sklearn model references untrusted types.
    Untrusted types found in the file: ['...timeseries_detector.MahalanobisDetector']

`IsolationForest` is recognised, so the default profile was unaffected and every unit test still
passed. The fleet demo failed on a cluster while the tabular demo succeeded, which is exactly the
split the diagnosis predicts, and it reproduces locally against mlflow 3.15.2.

Naming cloudpickle restores what MLflow 2 did by default, so this is the format every DQX model has
always been written with rather than a new one, and it works identically on both major versions.
`skops_trusted_types` would be narrower but exists only on MLflow 3, so the shim would need a second
version branch. The trust model is unchanged and already documented: DQX loads models only from the
Unity Catalog registry the caller owns.

The regression test round-trips rather than only saving, because scoring loads the model back and a
format that writes but cannot read would move the failure from training to the first scored batch.
Scores and feature contributions must both come back identical.

Two existing tests stubbed `log_model` with fakes that did not accept the new argument; they now
assert it is passed, on both branches. Fixing them surfaced a second problem worth recording: on
MLflow 3 `mlflow.sklearn` is a LazyLoader until something touches it, and patching an attribute on the
loader materialises the real module underneath, so the patch landed on the loader while the code under
test called the real `log_model` and tried to reach a tracking server. Importing `mlflow.sklearn`
explicitly makes the patch target and the call target the same object.

Co-authored-by: Isaac <no-reply@databricks.com>
… stop deciding its trend

Found by reading the demo's cell output rather than its exit code: the run logged
`ConvergenceWarning: lbfgs failed to converge ... max_iter=400` from the Huber fit, with sklearn's own
advice to raise the cap or scale the data.

The design columns were already scaled (seconds / span, harmonics in [-1, 1]) but the response was not,
and HuberRegressor solves for the coefficients and a scale parameter jointly, so a metric in its own
large units is badly conditioned. The warning turned out to be the mild symptom. Measured against a
known slope across 20 seeds:

  a metric in billions (bytes)     99.89% wrong (sd 0.00%) against 0.13%, new wins 20/20
  a contaminated sample, 5% at 6x  1.32% against 0.14%, new wins 20/20
  millions, and negatives          8.37% and 0.89% against 0.26%
  heavy-tailed (lognormal) noise   0.31% against 0.28%, new wins 10/20 -- a coin flip

That error went straight into the residual `baseline_over_time` exists to produce: a metric measured in
billions had essentially no trend removed at all.

So this generalises rather than fitting the demo. Rates near 1e-6, small magnitudes, integer counts,
zero-crossing metrics and a large offset with tiny variation are all a wash to within a tenth of a
point. A single seed appeared to regress on heavy-tailed noise, which is why that case was repeated
across 20 -- it is a coin flip, and reporting the n=1 result would have been wrong.

Exactly invertible for the unpenalised problem, because the model is linear, so persisted coefficients
stay in the metric's own units and scoring is untouched. The one real change is that HUBER_ALPHA's
effective regularisation was magnitude-dependent and is now scale-free, which is the behaviour worth
having but is not a strict no-op. Median and MAD rather than mean and standard deviation, for the same
reason the loss is Huber at all, with a documented fallback when more than half the values are
identical.

make fmt exit 0, unit 2713 passing.

Co-authored-by: Isaac <no-reply@databricks.com>
The docstring said "the ridged pseudo-inverse gives the zero-variance direction no weight". There is no
pseudo-inverse in the detector and it never forms an inverse at all: a ridge floor is added as a
fraction of the average variance, the result is factored by Cholesky, and diag(inverse) comes from the
squared column norms of L-inverse. The ridge is what makes a singular covariance factorable in the
first place, so it is doing the work the comment credited elsewhere.

Also corrects an implication: both dummies of a retained pair vary, so neither is dropped by the
constant-feature mask, which keys on per-feature spread rather than on directions in feature space.

The measurement the test makes is unchanged and still passes -- scores identical to the one-dummy
encoding, an unseen category far above a known one. Only the explanation was wrong, from confusing this
detector with the benchmark harness's Mahalanobis, which does use np.linalg.pinv.

Found by an agent fact-checking a teaching deck against the source, which is a better review than
re-reading my own comment would have been.

Co-authored-by: Isaac <no-reply@databricks.com>
…w reported

The docstring said "a linear metric first chose one changepoint where a bent metric chose six". That is
the reviewer's number, quoted from their report. Our own reproduction on the straight-and-bent pair the
unit test uses gives **0 changepoints against 3**, which is what the commit that fixed this said, so the
docstring and the commit message contradicted each other and the docstring carried a figure nobody here
had measured.

Re-confirmed just now: straight-first 0, bent-first 3, and both 3 once every metric is read. The
docstring now names the test that reproduces it, so the number has somewhere to be checked against.

Both figures demonstrate the same defect, so nothing about the fix changes. The point is that publishing
a measurement we did not take is the exact habit this review round was about, and it survived into a
docstring while the correct number sat in the commit message beside it.

The second figure in that docstring (rescaling one metric by 1e6 moving the count from three to six) is
ours, from the scale-invariance test that failed before the response was standardised, and stands.

Found by an agent cross-checking a code walkthrough against the source.

Co-authored-by: Isaac <no-reply@databricks.com>
Prompted by the question "does our sampling harm the temporal fit, or sample insensitively to the
trend?" -- worth measuring rather than reasoning about, and the measurement says the comment overstated
one thing while the design is otherwise sound.

Sampling does not harm the trend, and the reason is worth writing down because it is not obvious:
`DataFrame.sample` is a uniform Bernoulli draw and the train split is `randomSplit`, neither ordered nor
chronological, so training rows spread across the whole history and the slope estimator stays unbiased.
Measured slope error, full table against the 24% DQX actually fits: 0.00% vs 0.00% at 200k rows, 0.01%
vs 0.01% at 20k, 0.13% vs 0.27% at 2k. The fitted window pulls in by under 0.1% of span, so staleness
boundaries are unaffected.

What sampling does erode is bucket occupancy, and the comment leaned on exactly that: "each bucket
contributes its median, which is robust before Huber even sees it". Rows per bucket, full against
sampled, are 50 -> 12 at 200k rows, 5 -> 1.6 at 20k, and 1 -> 1 at 2k, where a median of one row is the
row. So below roughly 100k rows that sentence describes nothing.

It does not matter, because Huber is carrying the robustness: with 5% of rows at 6x normal the recovered
slope error is 0.02% sampled against 0.01% full. But the comment credited the wrong mechanism, which is
how someone later "optimises away" the thing that was actually load-bearing.

Also records a bucketing consequence unrelated to sampling: width is span/4000, so at 200k hourly rows
the width is 50h and a daily period is correctly rejected as unresolvable. Sub-bucket-width seasonality
is unavailable on very long spans, by construction.

Measurement reuses the shipped `candidate_periods`, `fit_temporal` and TEMPORAL_FIT_BUCKETS over 8 seeds
per size, reproducing `_fit_temporal_from_buckets`' bucketing exactly.

Co-authored-by: Isaac <no-reply@databricks.com>
It read as the knob for how much gets flagged, and it was not one. It set the estimator's
`contamination`, which places only scikit-learn's own `predict`/`offset_` boundary; every DQX scoring
path reads `-score_samples` and ranks it against training-score quantiles, so what gets flagged is
decided by the check's `threshold`. A parameter whose name promises the opposite of what it does is
worse than no parameter, and this release already carries breaking changes, so it goes now rather than
being documented around.

Behaviour is unchanged. `IsolationForestConfig.contamination` now defaults to 0.02 directly, which is
exactly what the removed parameter filled in when unset, so a caller who passed nothing gets what they
got before. Anyone who was setting it can set `params.algorithm_config.contamination` instead -- still
honest, because that name promises only what it delivers, and it still matters to anyone loading the
registered model and calling `predict` themselves.

Gone with it: `apply_expected_anomaly_rate_if_default_contamination`, the parameter on
`AnomalyEngine.train` and `build_context`, the field on `AnomalyTrainingContext`, and its range check in
`validate_training_params`. Not in `AnomalyConfig` or the workflow, so no run-config or YAML break.

The two docstring examples that used it to "adjust expected anomaly rate for specific use cases" now
show `threshold` on the check, which is the thing those examples were reaching for.

Tests: the three unit tests for the removed mapping helper become two for the config default and the
explicit escape hatch; the two range-check tests become one asserting contamination is still validated,
which matters more now that it is the only route. The integration test that asserted the parameter
reached the persisted hyperparameters is kept and rewritten against the default, because it is the only
coverage that the algorithm config flows into `training.hyperparameters` at all.

Verified: fmt exit 0, unit 2709 passing, and the nine training-validation integration tests pass against
a live workspace.

Co-authored-by: Isaac <no-reply@databricks.com>
The removal itself landed without touching the guide, and the guide's upgrade section opened with "If
you never used `segment_by`, nothing here affects you" -- which that removal made false. Anyone passing
`expected_anomaly_rate` now gets a TypeError regardless of whether they ever touched `segment_by`.

Rewritten so the section states what actually needs action: every model must be retrained whether or not
any of this was used, and calls passing either removed parameter will raise. Adds a migration bullet
saying nothing replaces it and why -- `threshold` on the check is how you change what gets flagged, and
`params.algorithm_config.contamination` is the escape hatch for anyone who loads the registered model
and calls `predict` themselves.

Also an FAQ entry, because the TypeError names the parameter but not the fix, and the obvious guess
(reach for contamination) is usually the wrong one.

Checked repo-wide rather than assumed: no demo, notebook, Studio app, MCP server or reference page ever
passed it, and the only remaining mentions are comments explaining the removal. The generated API pages
regenerate clean from the docstrings.

Co-authored-by: Isaac <no-reply@databricks.com>
…was made

An earlier commit today rewrote the `TEMPORAL_FIT_BUCKETS` comment to say plainly that bucketing bounds
driver memory and that Huber, not the per-bucket median, is what carries robustness. It missed the
identical claim in `_fit_temporal_from_buckets`' own docstring, 200 lines further down the same file:
"the per-bucket median is itself robust, so gross outliers are attenuated before the Huber fit ever
sees them".

So the file contradicted itself, with the measurements on one side and the withdrawn claim on the other.
That is worse than the original overstatement: a reader who finds the docstring first has no way to know
it is the stale one.

Now points at the constant, which carries the numbers, and says outright that bucketing is for memory
rather than robustness.

Found by an agent building a code walkthrough, which read both sites and refused to paper over the
disagreement. Grepping for the claim I was withdrawing, rather than editing the site I happened to be
looking at, would have caught it at the time.

Co-authored-by: Isaac <no-reply@databricks.com>
Third-review finding, and a gap my own earlier fix opened. Stage 1 of that work made a schema legal that
had not been before: a caller may pass their own `amount_rel_baseline` column alongside `amount`, because
the collision validator only refuses a name the transform would actually generate, and with no
`baseline_by` nothing generates it. The reverse mapper never learned that. It decomposed the name by
suffix, credited `amount`, and labelled the feature "amount vs its group baseline" in a model with no
group baseline at all -- attributing one user column's contribution to a different column, and making
redaction of `amount` sweep up an unrelated one.

Two changes, both about asking the metadata rather than the string:

- An exact source-column match now wins over suffix decomposition. A name in `column_infos` is a column
  that was analysed, so identity cannot be wrong, and it cannot mask a genuine derived feature because
  `validate_generated_feature_names` refuses that collision.
- Suffix resolution is gated on the basis having run: `_rel_baseline` only decomposes when `baseline_by`
  is recorded, `_rel_time` only when `baseline_over_time` is. Both were already persisted.

The module docstring said the resolution order "must not be reordered", justified by a numeric column
named `revenue_freq` where `revenue` does not exist. That case resolves correctly under either order, so
it never argued for suffix-first. Corrected, with the reason that does hold.

Adds `source_blocks`, mapping each source to its engineered descendants. Redaction gets it for free
because `engineered_from` is already defined in terms of `source_column`, and the next commit needs it:
explaining one engineered feature at a time is unsound when several carry the same source.

The gate immediately caught three test fixtures describing models that cannot exist -- a
baseline-relative feature listed with no `baseline_by` recorded -- in the redaction, explainer and naming
suites. Fixed rather than worked around; a fixture that cannot occur in production is not protecting
anything.

Verified with the review's own probe: `amount_rel_baseline` now resolves to itself and labels as itself.

Co-authored-by: Isaac <no-reply@databricks.com>
…eered feature

Feature engineering gives one source column several engineered views: the metric itself, its deviation
from its group's baseline, its deviation from its expected level at that time. The leave-one-out
attribution then measures almost nothing for any single view, because dropping one leaves another copy
of the same information behind -- and normalising those small numbers hands nearly all of the apparent
blame to an unrelated metric.

Measured on a correlated pair: adding one affine duplicate of x moved the reported cause from
99.8% x to 99.9% y while the score itself did not move (65.44813 vs 65.44816). So the detector found
the right row and then named the wrong column, in business language, with no signal that anything was
off. That compounds with the LLM prompt's rule to call a metric abnormal when contributions are
"concentrated in a single metric": the guard fires on the innocent one.

MahalanobisDetector.block_contributions marginalises a whole group of features out at once,
(Pd)_G' inv(P_GG) (Pd)_G. Sigma^-1 is never formed -- L^-1 is recovered once per call from the stored
Cholesky factor and each P_GG is built from its columns, so nothing new is persisted and models
trained before this keep loading. A single-feature block reduces exactly to the existing
a_i = z_i^2/(Sigma^-1)_ii, asserted at rtol=1e-12 rather than argued, so a model with no derived
features explains identically to before. Block drops are not additive and the docstring says so:
overlapping information belongs to no single block.

Blocks come from the persisted metadata via source_block_indices, computed once on the driver and
closed over by the UDF. The emitted map is therefore keyed by the source column the caller passed,
which fixes the defect by construction -- one entry per source, so duplicate views cannot dilute each
other -- and is more useful, since a reader gets their own column names rather than amount_rel_time.
Verified end to end: the probe that produced 100% y now returns 99.7% x, matching the undisturbed
two-feature model.

Scope is the correlation-aware detector only. TreeSHAP's correct block aggregation is a signed sum,
and the tree path still discards sign, so blocking there would add magnitudes that should have
cancelled. That asymmetry is documented rather than hidden, and the signed-attribution fix it depends
on is tracked separately.

Two consequences of the key change, both checked rather than assumed. Redaction stays exact: it
already unions the source column with every feature derived from it, and the source column is now
itself the key. Label lookup falls through to the raw key, which for a source column is the user's own
column name -- the intended outcome, not a gap. Both docstrings asserting that contribution keys are
always engineered names are corrected, as are the guide's schema table and its "How it works" step.

Co-authored-by: Isaac <no-reply@databricks.com>
…t contain

The prompt receives contribution magnitudes, severity percentiles, group size, an unsigned drift score
and metadata. Nothing in that carries a sign. Attribution is a squared quantity, so a row displaced one
way and a row displaced equally the other way produce the identical score and the identical contribution
map -- (8, 0.5) and (-8, -0.5) both score 65.387 with the same map.

The few-shot exemplars asserted direction anyway: "sits far above the norm" and "Inflated amount fields
overstate revenue". A few-shot example is an instruction, and a smaller serving model copies its shape,
so those two lines made confident, business-language, half-of-the-time-backwards claims the house style.
Both are rewritten so every clause is checkable against that exemplar's own inputs, and the instructions
now name the absence of direction explicitly and list the words it rules out.

"Be direct, avoid hedging" was the rule that made this feel sanctioned, so it is reconciled rather than
left to interpretation: being direct means stating plainly what the inputs contain, and does not license
asserting a direction, a cause or a value they do not.

The correlation-aware reading was overclaiming in the other direction. It instructed the model to
"describe the pattern as a broken relationship", but a high contribution there is equally consistent with
one metric moving a long way on its own. The input cannot separate those, so the text now says so and
refuses both readings rather than mandating one. The guard it already carried -- do not call a metric
abnormal unless the contributions concentrate on it -- is only trustworthy now that attribution is keyed
by source column, since concentration used to be able to land on an innocent metric.

Neither exemplar showed attribution_basis, the field that decides how contributions may be described, so
the model had no demonstration of the field it is told to follow. Both now state it, with different
values, so one reading cannot become the assumed default.

Two smaller honesty fixes in the same pass. "confidence" is agreement between ensemble members that
differ only by random seed on identical training data; it measures score stability, not whether the flag
is right or whether the data has drifted, and the prompt now says that and forbids presenting it as
confidence in the finding. And the header says "why the model flagged this group" rather than describing
a root-cause pattern, because the inputs cannot establish a cause.

Snapshot regenerated. Five new tests pin the properties rather than the wording: no directional phrase in
either exemplar response, both readings demonstrated, the two reconciliation clauses present, the
correlation-aware text refusing to assert, and the confidence caveat intact. The existing
attribution-semantics test kept its docstring -- it records a real observed failure, an LLM reporting
"Abnormal coolant flow" for rows whose every reading sat inside its healthy range -- with its assertions
repointed at those protections so a rewording that keeps the guarantees passes.

Co-authored-by: Isaac <no-reply@databricks.com>
The comment justifying full one-hot retention said an unseen value "scored as a large deviation" and that
"the detectors can tell apart" a known value from an unrecognised one. The plural was wrong, and it was a
comment written in this branch.

Measured on the same binary column, with every category retained:

  IsolationForest   unseen 0.458   known 0.442 / 0.467   -> ordinary, between the two known encodings
  Mahalanobis       unseen 2,000,000                     known 0.9

Isolation Forest splits one feature at a time, so no tree can express "these indicators sum to zero", and
an all-zeros row sits inside every individual indicator's observed range. Retaining the categories puts
the distinction in the encoding, which is what the encoding can do; acting on it needs a model that reads
features jointly. So an unrecognised category is not reliably caught under profile="tabular", and the
comment and the test docstring that implied otherwise now both say so. The guide gains the user-facing
half of this in the following commit, which is where its other prose changes land.

The retention argument itself comes out stronger, not weaker. Pruning the anticorrelated dummy -- exactly
what a correlation-threshold feature filter would do -- collapses the correlation-aware separation from
2,000,000-against-0.9 to 1.1-against-0.9, while leaving every score on ordinary rows untouched. The
redundancy is not waste being tolerated: it *is* the off-support constraint, and no in-sample metric would
notice its removal. That is now the sharpest argument on record against pruning correlated features here.

Two tests make both halves executable rather than asserted in prose.

Also lands the mirrored-row test, which belongs to the prompt work in the previous commit but lives in this
file: a row and its mirror produce identical scores, identical per-feature contributions and identical
block contributions. That is the fact making the prompt's no-direction rule necessary, pinned at the source
of the numbers rather than only as an assertion about prompt text.

Co-authored-by: Isaac <no-reply@databricks.com>
…d three real limits

The guide led with 65.4% against 96.8% incident coverage, measured by fitting a 30% sample of the very
rows being scored. Under that split 272 of the 327 labelled incidents had rows in training. So the number
answers "can the model re-find anomalies it was shown", which is fair for scanning a table you already
have, but it is not evidence for the guide's own quickstart -- train once, then check new or incoming data.

The guide now reports the chronological split only: train on an earlier period, score a later one. 57.5%
against 65.3% coverage at a 1% budget, average precision 0.277 against 0.423. Deliberately *less* prose,
not more: a reader of this page is deciding whether to use "timeseries" on their data and cannot adjudicate
a protocol taxonomy, so the retrospective table, the held-out-rows digression and the whole "why a clean
training split looks worse" admonition are gone, and the sentence that actually helps stays -- choose on the
shape of anomaly you expect, not on an expected accuracy gap. The full picture, both protocols and the
overlap counts, belongs in the PR and the benchmark archive.

One correction that follows from the switch: round 2 flipped the headline from average precision to coverage
because AP was a wash -- under the leakier protocol. Chronologically AP is the stronger signal again, +0.146
or +53% against +7.8 points of coverage. The per-machine statistics that justified the coverage framing
(+31 points, 22 wins to 1) belong to the retired protocol and are not carried over, because there are no
per-machine chronological numbers to replace them with. The drift attribution is softened too: two protocols
disagreeing does not isolate drift as the sole cause.

Three limitations are documented rather than fixed, because each is a real property of the design and not a
defect:

baseline_by conditions on level, not on relationship. One model is fitted and it learns one set of
relationships shared across groups -- the right trade when groups differ in scale and agree in behaviour.
Measured on a counterexample where group A has y rising with x and group B has y falling, both centred
identically: the pooled model scores a row that is impossible for B at 1.3, where a model fitted on B alone
scores it 260. Since the medians match, group-relative features are identical and the transform changes
nothing. Users whose groups genuinely behave differently are told to train per group. The same limit applies
to baseline_over_time, which removes one shared trend.

An unrecognised category is caught by "timeseries" and generally not by "tabular", which is the user-facing
half of the previous commit. No accuracy number in that section reveals it, so it is stated plainly and
points at is_in_list and foreign_key.

The temporal selection criterion is blind to constant forecast bias, and that is fine. It subtracts the
residual median, so it measures spread. But a constant offset in a _rel_time column cancels before it can
reach a score: the correlation-aware detector centres on the training mean, and IsolationForest draws split
thresholds from each feature's observed range so shifting a column shifts its candidates with it. Measured
against no offset, offsets of 5 and 500 move the correlation-aware score by 1.7e-14 and 1.9e-12 relative --
floating-point residue from the centring, far below the quantile spacing that decides a severity percentile
-- and leave IsolationForest bit-identical. Non-constant error inflates residual spread, which the criterion
does penalise, so the blind spot is exactly the case that cannot matter. Pinned as a test, asserted at a
tolerance rather than at bit equality, because bit equality is false: an earlier draft of this claim said
"bit-identical on both detectors" and the test written to prove it failed.

Co-authored-by: Isaac <no-reply@databricks.com>
The schema table linked to #how-it-works, but the heading is "How it works under the hood", so the
anchor is #how-it-works-under-the-hood. Docusaurus fails the build on broken anchors rather than
warning, so this took the whole docs job down. Introduced two commits ago, in the same change that
added the sentence.

It also took the five codecov statuses with it: .github/codecov.yml sets require_ci_to_pass with
wait_for_ci, so those report failure when any CI job fails, whatever the coverage actually is.

Verified with `make docs-build` locally, which is what CI runs and what I should have run before
pushing the change that broke it. A cross-check of every in-page anchor in that file against every
heading found no others.

Co-authored-by: Isaac <no-reply@databricks.com>
…lising feature, or one member

Three defects in feature attribution, all of which could name the wrong column, confidently, in business
language, on a row the detector had flagged correctly. One fix is the prerequisite for the other two, so
they land together.

TreeSHAP's sign was being discarded rather than read. It explains the ensemble's average *path length*,
not a score -- its base value is a path length around 12 -- and a row is anomalous when it is isolated in
few splits, so a NEGATIVE value drives the anomaly and a positive one argues the row is ordinary. Using
the magnitude added the second to the first and reported the sum as one number.

The review that raised this had the sign backwards, and implementing it as described would have been far
worse than the status quo. Measured on 200 rows each carrying exactly one deliberately anomalous feature,
the largest value named the true culprit 199/200 under the orientation that ships, 199/200 using
magnitude alone, and 0/200 using the positive side -- which reliably names the most ordinary feature
instead. So magnitude was very nearly right; on rows anomalous enough to be shown, the evidence is 97.8%
anomaly-driving at a severity of 95 and 99.5% at 99. Correcting it left the top driver unchanged on every
row measured and reordered the ranks below it on roughly a quarter of rows. The map stays non-negative
and sums to 100, so this is not a schema change.

Orientation happens where the values are produced; the clip happens once, at the end, in
format_shap_contributions. That ordering is load-bearing rather than tidy. Averaging across ensemble
members and summing a source column's views both rely on SHAP's exact additivity, and a value arguing the
row is normal has to be able to cancel one arguing it is not: two views at +3 and -1 net to 2 but clip
first and they sum to 3; two members at +4 and -2 average to 1 but clip first and it is 2. An earlier
draft of this change clipped at the point of production, which would have made both operations
decompositions of nothing while every test still passed.

A row with no anomaly-driving evidence was given 1/n for every key -- "every feature contributed
equally", which is an explanation with no input behind it and the more misleading of the two failures
because the numbers look unremarkable. Those rows now carry the all-null map that a row with a null
feature already produced, and which every consumer already renders as unknown. Zero-valued entries are
dropped from messages, prompts and the pattern key too, rather than being listed as "quantity (0%)":
exact zeros went from rare to common when the normalising side stopped earning a share.

An ensemble scored with every member and explained with one. The reported score is the mean across
members and so is confidence_std, but contributions came from models[0], which is not a representative
member -- they differ only by random seed, so it is whichever trained first. They disagree more than that
framing suggests: on 200 rows with the default three members, the least-agreeing pair named a different
top driver on 130 of them and the closest pair still differed on 92. Contributions are now the mean,
which decomposes the mean path length exactly. The entry point takes the models behind the score rather
than a model, so the mistake cannot be remade by omission, and misaligned members raise instead of being
averaged into a confident, plausible, wrong answer. Cost is about 7% more at three members, because
attribution runs only on rows above the threshold; there is deliberately no knob for it, since the honest
lever is enable_contributions=False and it already exists.

Both detectors now report one entry per source column. The correlation-aware one already did, from the
previous commit's joint marginalisation; a tree model gets the plain sum within each block, which is
exact because SHAP is additive and which the corrected sign is what makes possible. The failure it fixes
is different in shape from the correlation-aware one -- the evidence is split rather than misdirected,
49.3% and 50.7% across two views of a column whose true share is 100% -- and it still changes the answer:
with three views, a column whose true share was 74.5% left each view near 25%, handing the top spot to a
derived view of itself while an unrelated single-view column sat tied alongside. The ensemble path never
received blocks at all, and the default profile *is* the ensemble, so that plumbing is added.

One knock-on worth stating: the shared integration contract asserted contribution keys were a subset of
the *engineered* feature names. That was true only by accident on an all-numeric fixture, where a column's
only feature is named after it, and false the moment a categorical appeared. It now asserts the actual
contract, the caller's own columns.

Both demos taught that a contribution reads "X vs its expected level at that time". It no longer does,
and that is the trade this makes: naming the column right costs the signal of which comparison objected,
which is now carried by baseline_over_time being set rather than by the key. Both prints say so.

Also brought into line: compute_contributions_for_matrix carried both original defects in the same
module, which is how one module came to hold two answers to what a negative SHAP value means; the
correlation-aware detector's docstring claimed every consumer takes abs(); and the guide documented
enable_contributions as defaulting to False when it is True.

Co-authored-by: Isaac <no-reply@databricks.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

under-review This PR is currently being reviewed by one of DQX maintainers.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Anomaly detection: no detector for metrics that move together Anomaly detection: grouped data has no scalable path to conditioning

2 participants