Model monitoring
H2O MLOps model monitoring involves observing the performance and behavior of deployed models to ensure they continue to operate effectively and to identify issues such as model drift.
This guide explains how to configure model monitoring during deployment, analyze aggregated data, and identify model drift. Follow the steps below to set up and use model monitoring in H2O MLOps.
Model monitoring with the UI
Step 1: Enable model monitoring
To enable model monitoring for your deployment:
- In the left navigation panel, click Real-time deployments.
- Click Create deployment.

- On the Create new deployment page, click Advanced settings.
- Toggle Enable monitoring to Yes.

Step 2: Configure and deploy
During model deployment, configure monitoring to collect and analyze data.
- If Kafka is available in your environment, provide a pre-created Kafka topic where raw data will be sent.
For more information, see Raw data export to Kafka.
- Select the columns you want to monitor.

- Provide baseline data for comparison:
- Numerical features:

- Categorical features:

- Numerical features:
- Click Deploy.
Step 3: Start scoring
Once the deployment is Healthy, you can begin scoring.
- In the left navigation panel, click Real-time deployments.
- Select the deployment you created.

- Go to the Quick scoring tab.
- Click Score.

Step 4: View aggregated data
To view the scoring aggregates for each monitored column:
- After scoring completes, go to the Monitoring tab.
- Wait 3–5 minutes to see data under Scoring aggregates.

Step 5: Analyze data in the monitoring UI
To view and analyze model drift:
-
Click View in monitoring UI.

-
The Superset UI opens.
-
From the SQL drop-down, select SQL Lab.

-
Schema and table selection:
- Schema name is the Workspace ID from the H2O AI Cloud Workspaces UI. Copy the Workspace ID and select that schema in Superset. See Workspaces in H2O AI Cloud.
- Table name is the Deployment ID. From the MLOps Deployments page, copy the deployment ID (it appears in the default scorer URL). In Superset, select the table named
aggregates_<deployment_id>. - Alternatively, in SQL Lab, use the dropdowns labeled Schema and See table schema to choose the workspace (schema) and
aggregates_<deployment_id>(table).
-
Enter the drift query:
Example:WITH time_series AS (-- Get all unique timestamps for the seriesSELECT DISTINCT "timestamp"FROM <TABLE_NAME>WHERE NOT is_baselineAND column_name = <FEATURE_NAME>ORDER BY "timestamp"),baseline AS (-- Get the baseline (expected) dataSELECT unnest(bin_counts) as countFROM <TABLE_NAME>WHERE is_baselineAND column_name = <FEATURE_NAME>),baseline_sum AS (-- Calculate sum of baseline countsSELECT sum(count) as totalFROM baseline),baseline_props AS (-- Calculate baseline proportionsSELECT count / total as prop,row_number() OVER () as rnFROM baseline,baseline_sum),actual_data AS (-- Get actual data for each timestampSELECT "timestamp",unnest(bin_counts) as count,row_number() OVER (PARTITION BY "timestamp") as rnFROM <TABLE_NAME>WHERE NOT is_baselineAND column_name = <FEATURE_NAME>),actual_sums AS (-- Calculate sums for each timestampSELECT "timestamp",sum(count) as totalFROM actual_dataGROUP BY "timestamp"),actual_props AS (-- Calculate proportions for actual dataSELECT a."timestamp",a.count / s.total as prop,a.rnFROM actual_data aJOIN actual_sums s ON a."timestamp" = s."timestamp"WHERE s.total >= 0 -- Implementing the Python None return for sum < 200),drift_calc AS (-- Calculate absolute differences and sum themSELECT a."timestamp",sum(abs(a.prop - b.prop)) / 2 as drift_scoreFROM actual_props aJOIN baseline_props b ON a.rn = b.rnGROUP BY a."timestamp")-- Final result with timestamps and drift scoresSELECT "timestamp",drift_scoreFROM drift_calcORDER BY "timestamp";Additional Examples:
General numerical columns drift query with time window aggregation:
-- Replace '<TABLE_NAME>' with your table of interest-- Drift calculation for all numerical columns:-- Corrected to avoid false drift for identical distributions-- Numerical drift with time window aggregation for small samples-- This query aggregates bin_counts across time windows before calculating drift-- **CHANGE THIS LINE** to adjust aggregation window: 'hour', 'day', 'week', 'month'-- 'minute' - Aggregates all records within the same minute-- 'hour' - Aggregates all records within the same hour, please use it if small number of data. It's default.-- 'day' - Aggregates all records within the same day-- 'week' - Aggregates all records within the same week-- 'month' - Aggregates all records within the same monthWITH base_raw AS (SELECTcolumn_name,"timestamp",DATE_TRUNC('minute', "timestamp") AS time_bucket,is_baseline,bin_counts,bin_edgesFROM <TABLE_NAME>WHERE logical_type = 1AND bin_counts IS NOT NULLAND bin_edges IS NOT NULL),-- Expand baseline bins with correct ordering by bin_edgesbaseline_expanded AS (SELECTcolumn_name,row_number() OVER (PARTITION BY column_name ORDER BY bin_edges_array) AS bin_position,bc_value::numeric AS count -- CAST the value, not the recordFROM base_raw,LATERAL unnest(bin_counts) WITH ORDINALITY AS bc(bc_value, bin_index)CROSS JOIN LATERAL (SELECT bin_edges[bin_index] AS bin_edges_array) AS eWHERE is_baseline = TRUE),-- Sum baseline counts per columnbaseline_sum AS (SELECT column_name, SUM(count) AS totalFROM baseline_expandedGROUP BY column_name),baseline_norm AS (SELECTb.column_name,b.bin_position,b.count::numeric / NULLIF(s.total, 0) AS propFROM baseline_expanded bJOIN baseline_sum s USING (column_name)),-- Distinct timestamps for actual datatime_buckets AS (SELECT DISTINCT column_name, time_bucketFROM base_rawWHERE is_baseline = FALSE),-- Expand actual bins with correct ordering by bin_edgesactual_expanded AS (SELECTa.column_name,a.time_bucket,row_number() OVER (PARTITION BY a.column_name, a.time_bucket ORDER BY bin_edges_array) AS bin_position,bc_value::numeric AS count -- CAST the value, not the recordFROM base_raw a,LATERAL unnest(a.bin_counts) WITH ORDINALITY AS bc(bc_value, bin_index)CROSS JOIN LATERAL (SELECT bin_edges[bin_index] AS bin_edges_array) AS eWHERE a.is_baseline = FALSE),-- Sum actual counts per column & time bucketactual_sum AS (SELECT column_name, time_bucket, SUM(count) AS totalFROM actual_expandedGROUP BY column_name, time_bucket),-- Combine baseline bins with all actual time bucketsbaseline_with_times AS (SELECTt.column_name,t.time_bucket,b.bin_position,b.prop AS baseline_propFROM time_buckets tCROSS JOIN baseline_norm bWHERE t.column_name = b.column_name),-- Compute actual proportions, default 0 if missingactual_norm AS (SELECTbwt.column_name,bwt.time_bucket,bwt.bin_position,COALESCE(ae.count::numeric / NULLIF(s.total, 0), 0) AS actual_prop,s.total AS sample_size,bwt.baseline_propFROM baseline_with_times bwtLEFT JOIN actual_expanded aeON bwt.column_name = ae.column_nameAND bwt.time_bucket = ae.time_bucketAND bwt.bin_position = ae.bin_positionLEFT JOIN actual_sum sON bwt.column_name = s.column_nameAND bwt.time_bucket = s.time_bucket),-- Drift calculation (TVD)drift_calc AS (SELECTcolumn_name,time_bucket,0.5 * SUM(ABS(actual_prop - baseline_prop)) AS drift_score,MAX(sample_size) AS sample_size,COUNT(*) AS num_binsFROM actual_normGROUP BY column_name, time_bucket)SELECTcolumn_name,time_bucket AS timestamp,ROUND(drift_score::numeric, 4) AS drift_score,CASEWHEN sample_size IS NULL OR sample_size < 3 THEN 'Insufficient Data'WHEN drift_score < 0.2 THEN 'Stable'WHEN drift_score < 0.3 THEN 'Concerning'WHEN drift_score < 0.35 THEN 'Drifting'ELSE 'Critical'END AS drift_status,sample_size,num_binsFROM drift_calcORDER BY column_name, timestamp;Categorical columns drift query with PSI calculation:
-- Time-series drift with configurable time buckets-- Adjust the DATE_TRUNC function to change window size:-- 'minute' 'hour', 'day', 'week', 'month', 'quarter', 'year'-- Replace '<TABLE_NAME>' with your table of interestWITH parsed_data AS (SELECTid,timestamp,-- Create time buckets (change 'day' to desired window size)DATE_TRUNC('hour', timestamp) AS time_bucket,column_name,missing_counts,logical_type,is_baseline,is_response,value_countsFROM <TABLE_NAME>WHEREis_response = falseAND logical_type = 2),flattened_data AS (SELECTp.column_name,p.is_baseline,p.time_bucket,kv.key AS value_name,kv.value::numeric AS value_countFROM parsed_data pCROSS JOIN LATERAL jsonb_each_text(CASEWHEN jsonb_typeof(p.value_counts::jsonb) = 'object'THEN p.value_counts::jsonbELSE '{}'::jsonbEND) AS kv),-- Aggregate counts within each time bucketaggregated_data AS (SELECTcolumn_name,is_baseline,time_bucket,value_name,SUM(value_count) AS value_countFROM flattened_dataGROUP BY column_name, is_baseline, time_bucket, value_name),-- Baseline data (single reference point)baseline_data AS (SELECTcolumn_name,value_name,value_count,SUM(value_count) OVER (PARTITION BY column_name) AS total_countFROM aggregated_dataWHERE is_baseline = true),baseline_probs AS (SELECTcolumn_name,value_name,value_count / total_count AS baseline_probFROM baseline_data),-- Actual data by time bucketactual_data AS (SELECTcolumn_name,time_bucket,value_name,value_count,SUM(value_count) OVER (PARTITION BY column_name, time_bucket) AS total_countFROM aggregated_dataWHERE is_baseline = false),actual_probs AS (SELECTcolumn_name,time_bucket,value_name,value_count / total_count AS actual_probFROM actual_data),-- Get all unique values per columnall_values_per_column AS (SELECT DISTINCT column_name, value_nameFROM aggregated_data),-- Create complete gridtime_windows AS (SELECT DISTINCT column_name, time_bucketFROM actual_probs),complete_grid AS (SELECTtw.column_name,tw.time_bucket,av.value_nameFROM time_windows twCROSS JOIN all_values_per_column avWHERE tw.column_name = av.column_name),-- Align probabilitiesaligned_time_series AS (SELECTg.column_name,g.time_bucket,g.value_name,COALESCE(bp.baseline_prob, 0) AS baseline_prob,COALESCE(ap.actual_prob, 0) AS actual_prob,CASE WHEN COALESCE(bp.baseline_prob, 0) = 0 THEN 1e-10 ELSE bp.baseline_prob END AS baseline_prob_safe,CASE WHEN COALESCE(ap.actual_prob, 0) = 0 THEN 1e-10 ELSE ap.actual_prob END AS actual_prob_safeFROM complete_grid gLEFT JOIN baseline_probs bpON g.column_name = bp.column_nameAND g.value_name = bp.value_nameLEFT JOIN actual_probs apON g.column_name = ap.column_nameAND g.time_bucket = ap.time_bucketAND g.value_name = ap.value_name),-- Calculate drift componentsvalue_drift_over_time AS (SELECTcolumn_name,time_bucket,value_name,baseline_prob,actual_prob,(baseline_prob_safe - actual_prob_safe) * ln(baseline_prob_safe / actual_prob_safe) AS psi_component,abs(baseline_prob - actual_prob) AS drift_componentFROM aligned_time_series),-- Aggregate to column-level scorescolumn_drift_time_series AS (SELECTcolumn_name,time_bucket,SUM(psi_component) AS psi_score,SUM(drift_component) / 2 AS drift_score,COUNT(DISTINCT value_name) AS num_unique_values,SUM(CASE WHEN baseline_prob = 0 AND actual_prob > 0 THEN 1 ELSE 0 END) AS num_new_values,SUM(CASE WHEN baseline_prob > 0 AND actual_prob = 0 THEN 1 ELSE 0 END) AS num_missing_valuesFROM value_drift_over_timeGROUP BY column_name, time_bucket)-- Final outputSELECTtime_bucket AS timestamp,column_name,ROUND(psi_score::numeric, 6) AS psi_score,ROUND(drift_score::numeric, 6) AS drift_score,num_unique_values,num_new_values,num_missing_values,CASEWHEN psi_score < 0.10 THEN 'No Drift'WHEN psi_score < 0.25 THEN 'Moderate Drift'ELSE 'Significant Drift'END AS drift_classificationFROM column_drift_time_seriesORDER BY time_bucket, column_name;Z-Score Drift for numerical columns:
-- ================================================-- Z-SCORE DRIFT CALCULATION (Dynamic Window)-- ================================================-- Parameters you can set before running:-- window_interval -> e.g. '1 hour', '1 day'-- window_granularity -> one of ('minute', 'hour', 'day', 'month')---- Replace '<TABLE_NAME>' with your table of interest-- ================================================WITH params AS (SELECT('window_interval')::interval AS window_interval,'window_granularity'::text AS window_granularity),baseline AS (SELECTcolumn_name,mean AS baseline_mean,standard_deviation AS baseline_stdFROM <TABLE_NAME>WHERE is_baseline = TRUEAND logical_type = 1),current AS (SELECTcolumn_name,date_trunc((SELECT window_granularity FROM params),timestamp) AS window_bucket,AVG(mean) AS current_meanFROM <TABLE_NAME> , paramsWHERE is_baseline = FALSEAND logical_type = 1AND timestamp >= NOW() - (SELECT window_interval FROM params)GROUP BY column_name, window_bucket)SELECTc.column_name,c.window_bucket,ROUND(CASEWHEN b.baseline_std = 0 OR b.baseline_std IS NULL THEN NULLELSE ABS(c.current_mean - b.baseline_mean) / b.baseline_stdEND,6) AS z_score_driftFROM current cJOIN baseline b USING (column_name)ORDER BY c.column_name, c.window_bucket;Hellinger Drift (Gaussian Approximation) for numerical columns:
noteThis method works best at the minute level. For larger time windows (hour, day, week, month), standard_deviation cannot be calculated from aggregates. Use Z-Score Drift or the numerical drift query with time window aggregation for larger time windows.
-- Replace '<TABLE_NAME>' with your table of interestWITH baseline AS (SELECTcolumn_name,mean AS baseline_mean,standard_deviation AS baseline_stdFROM <TABLE_NAME>WHERE is_baseline = TRUEAND logical_type = 1),current AS (SELECTcolumn_name,timestamp,mean AS current_mean,standard_deviation AS current_stdFROM <TABLE_NAME>WHERE is_baseline = FALSEAND logical_type = 1)SELECTc.column_name,c.timestamp,-- Compute Hellinger distance assuming Gaussian distributionsSQRT(1 - SQRT((2 * b.baseline_std * c.current_std) /NULLIF(b.baseline_std^2 + c.current_std^2, 0)) * EXP(-((b.baseline_mean - c.current_mean)^2) /(4 * NULLIF(b.baseline_std^2 + c.current_std^2, 0)))) AS hellinger_driftFROM current cJOIN baseline b USING (column_name)WHERE b.baseline_std IS NOT NULLAND c.current_std IS NOT NULLAND b.baseline_std > 0AND c.current_std > 0ORDER BY c.column_name, c.timestamp;Drift Interpretation Tables:
Z-Score Drift Interpretation:
Z-Score Drift Interpretation Example situation 0.0 – 0.5 No drift / Stable Minor changes within expected noise 0.5 – 1.5 Slight drift Natural variation (e.g., daily fluctuations) 1.5 – 3.0 Moderate drift Statistically meaningful change > 3.0 Strong drift Major feature shift (possible data or model issue) Hellinger Drift Interpretation:
Hellinger Drift Interpretation Overlap between distributions 0.00 – 0.05 No drift / identical >95% overlap 0.05 – 0.15 Small drift ~85–95% overlap 0.15 – 0.3 Moderate drift ~70–85% overlap > 0.3 Strong drift <70% overlap > 0.5 Severe drift Distributions diverged significantly 
-
To save the result as a dataset, go to the Save drop-down and select Save dataset.

-
To convert it into a chart and add it to a dashboard:
- Go to Charts and customize the chart.

- Click Save, and add it to a dashboard.

- Go to Dashboards and select the one you want to view.
- Go to Charts and customize the chart.
You can explore more advanced dashboards for deeper insights.

Configure model monitoring with the Python client
To learn how to configure monitoring for your deployment using the H2O MLOps Python client, see Monitoring setup.
Raw data export to Kafka
Monitoring supports exporting raw scoring data to Kafka. This feature allows users to process scoring data in ways required by their internal regulations. Request and response data from scoring operations can be sent to a specified Kafka topic for downstream processing, auditing, or debugging. This feature is enabled by the MLOPs administrator.
During model deployment with monitoring enabled, scoring data and response data are sent to a default topic configured by the MLOPs administrator. Users can optionally specify a custom Kafka topic where the data are sent for this particular deployment. This allows separating data streams per deployment for improved observability. This configuration has no effect in case the Kafka intgration is disabled by the admonistrator.

The custom Kafka topic must exist before deploying the model. Monitoring will not attempt to create the topic automatically.
Once configured, the monitoring captures raw request and response data from scoring operations and forwards it to the configured Kafka topic (global or deployment-specific).
Common use cases for exporting raw data to Kafka include:
- Debugging and inspecting raw scoring payloads
- Auditing input/output for compliance
- Real-time analytics via stream processing systems
- Submit and view feedback for this page
- Send feedback about H2O MLOps to cloud-feedback@h2o.ai