Skip to main content

Drift Detection

Drift for traditional ML models in the model registry: the drift overview, prediction records, baselines, analyses, their results and rows, and each model's drift settings. An analysis compares a model's active version's production predictions with that version's active baseline.

Baselines are built, and actuals recorded, through the Model Registry API. All endpoints require authentication via the X-API-Key header and the scope shown. List endpoints are paged (see Pagination): limit (default 50, max 200), cursor, sort (a field, - for descending) and q. They return the items in data with total, limit, cursor, hasMore and requestId in meta.


Prediction Record Object​

Every prediction a registry model makes is recorded once, by whichever caller made it.

{
"predictionId": "pred_7f3a",
"entityId": "customer_456",
"modelId": "model_xyz",
"modelVersion": 2,
"source": "production",
"abTestId": "ab_abc123",
"variantId": "challenger",
"routingReason": "weighted_random",
"features": { "age": 42, "plan": "pro" },
"prediction": "churn",
"probabilities": [0.12, 0.88],
"confidence": 0.88,
"actual": "churn",
"success": true,
"latencyMs": 23,
"timestamp": "2026-09-27T10:30:00Z"
}
FieldDescription
sourceproduction or test (the model page's Test tab; never counted in results)
abTestId, variantId, routingReasonSet when an A/B test routed the prediction
features, outputPresent only while the model's Record inputs setting is on
confidenceThe highest class probability (classifiers)
actualThe prediction's actual, when one has been recorded

Drift Result Object​

{
"_id": "6ab9577009d39c66afbbcba6",
"modelId": "model_xyz",
"baselineId": "6ab8e1c0...",
"jobId": "6ab95740...",
"windowStart": "2026-09-20T00:00:00Z",
"windowEnd": "2026-09-27T00:00:00Z",
"sampleSize": 1250,
"overallStatus": "warning",
"driftScore": 0.14,
"featuresAnalyzed": 12,
"featuresWithDrift": 3,
"predictionDrift": {
"metric": "accuracy",
"higherIsBetter": true,
"current": 0.86,
"baseline": 0.91,
"change": -0.05,
"drop": 0.055,
"status": "warning",
"labelledCount": 420
},
"algorithmsRun": ["psi", "ks", "chi_square", "domain_classifier"],
"algorithmsSkipped": [{ "name": "cbpe", "reason": "Estimating accuracy needs the baseline's calibration ..." }],
"calculatedAt": "2026-09-27T00:05:12Z",
"calculationDurationMs": 5400,
"notification": {
"state": "sent",
"sentAt": "2026-09-27T00:05:14Z",
"inApp": { "sent": ["user_123"] },
"email": { "sent": ["ops@example.com"] },
"slack": { "error": "Not sent: no Slack webhook is set" }
}
}
FieldDescription
overallStatusok, warning, alert, or error when nothing could be scored. It comes from driftScore against the PSI thresholds (without a score, the worst status any algorithm gave), raised to predictionDrift.status when that is worse.
driftScoreThe mean PSI across features: a plain number, not a percentage. null when PSI scored no feature.
featuresAnalyzed, featuresWithDriftFeatures some algorithm scored, and those with drift. The rows themselves are GET /mlops/models/:modelId/drift-results/:id/features.
predictionDriftThe model's performance on the window's predictions that have actuals, against the baseline's, scored by the model's task: metric is accuracy for a classification or multiclass model, mae (mean absolute error; higherIsBetter false) for a regression model, and review_score (the mean of the reviewers' verdicts recorded as actuals: true 1, false 0, or a 0 to 1 score) for a multilabel, timeseries or other model. current, baseline and change are on that metric; drop is how much worse than the baseline, relative (0.1 is 10% worse, negative is better); status is ok, warning or alert from the model's performanceDropWarning / performanceDropAlert. When it could not be measured, error says why (no actuals yet, a baseline without performance, or a task changed since the baseline was built) and there are no figures or status, except that against a baseline without performance current (with labelledCount) is still measured and only the comparison is missing.
notificationOnly on a result that found drift (warning or alert): how it was announced. state is pending, sending, sent or failed; inApp, email and slack each have sent (user ids, addresses, the webhook host) or error (why not). See the notifications setting below.

Result Row Object​

A result has one row per feature, carrying every per-feature algorithm's metric, and one row per algorithm about the whole dataset.

{
"_id": "6ab95771...",
"resultId": "6ab9577009d39c66afbbcba6",
"modelId": "model_xyz",
"order": 1,
"featureName": "income",
"category": "feature",
"driftType": "data",
"metrics": {
"psi": { "value": 0.41, "threshold": 0.2, "status": "alert", "category": "univariate" },
"ks": { "value": 0.32, "pValue": 0.001, "threshold": 0.2, "status": "alert", "category": "univariate" }
},
"hasDrift": true,
"status": "alert",
"severity": 3,
"score": 0.41
}
FieldDescription
categoryfeature for a feature's row; multivariate or performance for a dataset row (named like [multivariate] domain_classifier)
metricsBy algorithm: value (null when it could not run), threshold, status, pValue, and the algorithm's category
status, severityThe row's worst metric status, and its severity: alert 3, warning 2, ok 1, error 0
scorePSI when it ran, else the first scored metric; null when none scored
errorWhy nothing on the row could be scored

GET /api/v1/mlops/drift​

The drift overview: the registry models you can see, with their latest result's summary and their latest analysis run.

Scope: mlops:read

ParameterTypeRequiredDescription
statusstringNook, warning, alert, error, or no-data for models never analyzed
qstringNoName, description or framework
sortstringNo-latestDrift.calculatedAt (default), name, latestDrift.driftScore, latestDrift.sampleSize, updatedAt

Response 200 OK

{
"data": [
{
"_id": "model_xyz",
"name": "churn-classifier",
"framework": "sklearn",
"latestDrift": { "resultId": "6ab95770...", "overallStatus": "warning", "driftScore": 0.14, "sampleSize": 1250, "calculatedAt": "2026-09-27T00:05:12Z" },
"latestAnalysis": {
"jobId": "6ab95740...",
"status": "failed",
"triggeredBy": "scheduler",
"error": "Version 2 has no production predictions from 2026-09-20 00:00 to 2026-09-27 00:00 UTC to compare with its baseline (Test tab predictions are not counted)",
"startedAt": "2026-09-27T00:00:03Z",
"updatedAt": "2026-09-27T00:00:41Z",
"finishedAt": "2026-09-27T00:00:41Z"
},
"driftReadiness": { "state": "analyzed" }
}
],
"meta": {
"total": 1,
"limit": 50,
"nextCursor": null,
"requestId": "req_abc123"
}
}

latestAnalysis.status is pending, deploying, running, completed or failed; triggeredBy is manual, scheduler or baseline. The model record (GET /mlops/models/:id) carries the same two fields.

driftReadiness.state is analyzed when the model has a result, else what it still needs: no-task (the model has no classification or regression task; set problemType with PATCH /api/v1/mlops/models/:id), inputs-off (Record inputs is off, so its predictions have no inputs to compare), no-baseline (its active version, given as version, has no baseline) or not-analyzed (it has a baseline and no result yet; latestAnalysis says why a run failed).


POST /api/v1/mlops/models/:id/predictions​

Record a prediction a registry model made outside the platform (a copy served elsewhere), so drift includes it. Predictions the platform serves are recorded automatically.

Scope: mlops:write

FieldTypeRequiredDescription
:id (path)stringYesRegistry model ID
featuresobjectYesInput values by feature name
predictionanyYesThe model's output
probabilitiesarrayClassification: YesPer-class probabilities; the highest is the confidence
entityIdstringNoYour id for what was predicted; actuals can join on it
modelVersionnumberNoThe version that made the prediction
latencyMsnumberNoHow long it took

Response 201 Created

The prediction record as stored, source production.


GET /api/v1/mlops/models/:id/predictions​

A model's prediction records, each with its actual when one was recorded.

Scope: mlops:read

ParameterTypeRequiredDescription
:id (path)stringYesRegistry model ID
sourcestringNoproduction or test
startDate, endDatestringNoISO 8601 bounds on the prediction time
qstringNoPrediction id, entity id or error, or the prediction exactly
sortstringNo-timestamp (default), latencyMs, modelVersion, success, confidence

GET /api/v1/mlops/models/:id/baselines​

A model's baselines, newest first, each with its inputs' names (not their distributions).

Scope: mlops:read

ParameterTypeRequiredDescription
:id (path)stringYesRegistry model ID
sortstringNo-createdAt (default), modelVersion, sampleSize, baselinePerformance.value

Each item: modelVersion, sampleSize (rows), evaluationKind (classification, regression or reviewed: how the model was scored when the baseline was built), labelledRows, baselinePerformance ({ metric, higherIsBetter, value }, the version's performance on its reference rows; see Performance), columns (which of actual, prediction, confidence the rows had), skippedFeatures (with why), source (the file), isActive, supersededAt, createdAt, createdBy.


GET /api/v1/mlops/models/:id/baselines/active​

The active baseline of the model's active version, with its features' distributions and calibration. null when the version has no baseline.

Scope: mlops:read

ParameterTypeRequiredDescription
:id (path)stringYesRegistry model ID

POST /api/v1/mlops/models/:id/drift-analyses​

Start a drift analysis: the active version's production predictions in the window against its active baseline, with the algorithms enabled in the model's drift settings, plus prediction drift from actuals.

Scope: mlops:write

FieldTypeRequiredDescription
:id (path)stringYesRegistry model ID
windowDaysnumberNoDays of predictions to analyze (default 7)
windowStart, windowEndstringNoAn explicit window (ISO 8601)

Response 200 OK

{
"data": { "jobId": "6ab95740...", "status": "deploying" },
"meta": { "requestId": "req_abc123" }
}

A version without a baseline is refused (400, saying to build one). A window with fewer predictions (with recorded inputs) than the model's minWindowSampleSize is not scored: the run fails saying so. Follow the run with the model's latestAnalysis or the status endpoint below.


GET /api/v1/mlops/models/:modelId/drift-analyses/:id​

One analysis run by the jobId it returned: status (pending, deploying, running, completed, failed), its window, and error when it failed.

Scope: mlops:read

ParameterTypeRequiredDescription
:modelId (path)stringYesRegistry model ID

GET /api/v1/mlops/models/:id/drift-results/latest​

A model's most recent drift result. null when it has never been analyzed.

Scope: mlops:read

ParameterTypeRequiredDescription
:id (path)stringYesRegistry model ID

GET /api/v1/mlops/models/:id/drift-results​

A model's drift results, newest first.

Scope: mlops:read

ParameterTypeRequiredDescription
:id (path)stringYesRegistry model ID
statusstringNook, warning, alert or error
sortstringNo-calculatedAt (default), driftScore, sampleSize

GET /api/v1/mlops/models/:id/drift-analyses​

A model's analysis runs, newest first: its history, including runs that could not score anything. Each run has status (pending, running, completed or failed), triggeredBy (manual or scheduler), created_at and duration_ms. A completed run has its result_id and a summary (overallStatus, driftScore, featuresAnalyzed, featuresWithDrift, sampleSize); a failed run has its error, for example no production predictions in its window.

Scope: mlops:read

ParameterTypeRequiredDescription
:id (path)stringYesRegistry model ID
qstringNoStatus, trigger or error text
sortstringNo-created_at (default), duration_ms

GET /api/v1/mlops/models/:modelId/drift-results/:id/features​

A result's rows, most drifted first. Get the resultId from a result's _id or a model's latestDrift.resultId.

Scope: mlops:read

ParameterTypeRequiredDescription
:modelId (path)stringYesRegistry model ID
rowsstringNofeatures (feature rows only) or dataset (dataset rows only)
qstringNoFeature name
sortstringNo-severity (default), score, featureName, order (the result's own order)

GET /api/v1/mlops/models/:id/drift-settings​

A model's drift settings. null when they were never saved.

Scope: mlops:read

ParameterTypeRequiredDescription
:id (path)stringYesRegistry model ID
{
"data": {
"modelId": "model_xyz",
"enabled": true,
"algorithms": {
"enabled": ["psi", "ks", "chi_square", "jensen_shannon", "wasserstein", "domain_classifier", "pca_reconstruction", "adwin", "cbpe"],
"overrides": { "psi": { "warningThreshold": 0.1, "alertThreshold": 0.2 } }
},
"performanceDropWarning": 0.05,
"performanceDropAlert": 0.1,
"minBaselineSampleSize": 30,
"minWindowSampleSize": 30,
"notifications": { "email": true, "recipients": ["ops@example.com"], "slack": "https://hooks.slack.com/services/..." },
"schedule": {
"enabled": true,
"frequency": "daily",
"timeWindowDays": 7,
"lastRunAt": "2026-09-27T00:00:03Z",
"nextRunAt": "2026-09-28T00:00:03Z",
"lastJobId": "6ab95740..."
}
},
"meta": { "requestId": "req_abc123" }
}

PATCH /api/v1/mlops/models/:id/drift-settings​

Save a model's drift settings. The first save stores defaults for the fields not sent; later saves change only the fields sent.

Scope: mlops:write

FieldTypeRequiredDescription
:id (path)stringYesRegistry model ID
enabledbooleanNoDrift settings on; required for the schedule to run
algorithmsobjectNo{ enabled: [names], overrides: { [name]: { warningThreshold, alertThreshold, params } } }. Built-in names: psi, ks, chi_square, jensen_shannon, wasserstein, domain_classifier, pca_reconstruction, adwin, cbpe; or a custom algorithm's name
performanceDropWarning, performanceDropAlertnumberNoHow much worse than the baseline the model's performance may get before prediction drift is a warning (default 0.05) or an alert (default 0.10), relative: 0.05 is 5% worse (lower accuracy or review score, higher mean absolute error). The warning must be smaller than the alert. A warning or alert makes the analysis's overall status at least that high, so it is announced.
minBaselineSampleSizenumberNoFewest reference rows a baseline needs (default 30)
minWindowSampleSizenumberNoFewest predictions with recorded inputs an analysis window needs to be scored: a whole number, at least 1 (default 30)
notificationsobjectNo{ email: boolean, recipients: [addresses], slack?: Slack incoming webhook URL }. When an analysis finds drift (warning or alert), the model's owner is notified in Strongly; with email, the recipients are emailed (the owner when there are none); with slack, that webhook gets a message. An address that is not one, or a webhook pointing at localhost, an internal host or a private address, is refused with 400. An analysis that could not run sends no notification.
scheduleobjectNo{ enabled, frequency: "hourly" | "daily" | "weekly", timeWindowDays }

A scheduled analysis runs while both enabled and schedule.enabled are true, the first run at the scheduler's next check. Building a baseline with analyzeDaily turns on a daily schedule the same way.

Response 200 OK

The model's drift settings, as GET /api/v1/mlops/models/:id/drift-settings shows them.