Spaces:
Sleeping
Sleeping
| Model Monitoring and Observability Guide | |
| ========================================= | |
| ## Overview | |
| Model monitoring ensures that ML models in production continue to perform as expected over time. Models degrade due to data drift (input distribution changes), concept drift (relationship between inputs and outputs changes), and infrastructure issues. A robust monitoring system detects these problems early and triggers alerts or automated retraining. | |
| ## Types of Model Drift | |
| ### Data Drift (Covariate Shift) | |
| The input feature distribution changes between training and serving time. | |
| ```python | |
| # Detecting data drift with Evidently AI | |
| from evidently.report import Report | |
| from evidently.metric_preset import DataDriftPreset, DataQualityPreset | |
| report = Report(metrics=[DataDriftPreset(), DataQualityPreset()]) | |
| report.run(reference_data=training_data, current_data=production_data) | |
| report.save_html("data_drift_report.html") | |
| # Check if drift was detected | |
| result = report.as_dict() | |
| for metric in result['metrics']: | |
| if metric['metric'] == 'DataDriftTable': | |
| drifted_features = [ | |
| col for col, stats in metric['result']['drift_by_columns'].items() | |
| if stats['drift_detected'] | |
| ] | |
| print(f"Drifted features: {drifted_features}") | |
| ``` | |
| ### Concept Drift | |
| The relationship P(y|X) changes — the model's predictions become incorrect even when inputs look normal. | |
| ```python | |
| from evidently.metric_preset import TargetDriftPreset | |
| # Monitor target distribution drift | |
| target_report = Report(metrics=[TargetDriftPreset()]) | |
| target_report.run( | |
| reference_data=training_data[['features', 'target']], | |
| current_data=production_data[['features', 'target']] | |
| ) | |
| ``` | |
| ### Prediction Drift | |
| The distribution of model outputs shifts, indicating the model is seeing different types of inputs. | |
| ```python | |
| from evidently.metrics import ColumnDriftMetric | |
| prediction_drift_report = Report(metrics=[ | |
| ColumnDriftMetric(column_name="prediction"), | |
| ColumnDriftMetric(column_name="prediction_confidence") | |
| ]) | |
| ``` | |
| ## Alibi Detect for Drift Detection | |
| ```python | |
| import alibi_detect | |
| from alibi_detect.cd import KSDrift, MMDDrift, ChiSquareDrift | |
| from alibi_detect.cd import TabularDrift | |
| import numpy as np | |
| # Kolmogorov-Smirnov test for continuous features | |
| ks_detector = KSDrift( | |
| x_ref=X_train, | |
| p_val=0.05, | |
| preprocess_fn=None | |
| ) | |
| result = ks_detector.predict(X_production, drift_type='batch', return_p_val=True) | |
| print(f"Drift detected: {result['data']['is_drift']}") | |
| print(f"P-values per feature: {result['data']['p_val']}") | |
| # Maximum Mean Discrepancy for complex distributions | |
| mmd_detector = MMDDrift( | |
| x_ref=X_train, | |
| backend='pytorch', | |
| p_val=0.05, | |
| n_permutations=100 | |
| ) | |
| # Combined tabular drift (handles mixed types) | |
| tabular_detector = TabularDrift( | |
| x_ref=X_train, | |
| p_val=0.05, | |
| categories_per_feature={0: None, 1: None, 2: 3, 3: 5} # feature_idx: n_categories (None = continuous) | |
| ) | |
| # Save detector | |
| from alibi_detect.utils.saving import save_detector, load_detector | |
| save_detector(ks_detector, './ks_detector') | |
| loaded_detector = load_detector('./ks_detector') | |
| ``` | |
| ## Prometheus and Grafana for ML Metrics | |
| ### Custom Metrics with prometheus-client | |
| ```python | |
| from prometheus_client import Counter, Histogram, Gauge, start_http_server | |
| import time | |
| # Define metrics | |
| inference_requests = Counter('ml_inference_requests_total', 'Total inference requests', ['model_version', 'status']) | |
| inference_latency = Histogram('ml_inference_duration_seconds', 'Inference request latency', | |
| buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0]) | |
| model_confidence = Histogram('ml_prediction_confidence', 'Model prediction confidence scores', | |
| buckets=[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]) | |
| data_drift_score = Gauge('ml_data_drift_score', 'Current data drift score per feature', ['feature_name']) | |
| active_model_version = Gauge('ml_active_model_version', 'Currently active model version') | |
| # Instrumented prediction function | |
| def predict_with_monitoring(request_data): | |
| start_time = time.time() | |
| try: | |
| result = model.predict(request_data) | |
| confidence = float(result['confidence']) | |
| # Record metrics | |
| inference_requests.labels(model_version="1.2", status="success").inc() | |
| inference_latency.observe(time.time() - start_time) | |
| model_confidence.observe(confidence) | |
| # Alert on low confidence | |
| if confidence < 0.6: | |
| low_confidence_predictions.inc() | |
| return result | |
| except Exception as e: | |
| inference_requests.labels(model_version="1.2", status="error").inc() | |
| raise | |
| # Start metrics server | |
| start_http_server(8000) | |
| ``` | |
| ### Grafana Dashboard for ML Models | |
| ```json | |
| { | |
| "dashboard": { | |
| "title": "ML Model Monitoring", | |
| "panels": [ | |
| { | |
| "title": "Inference Requests per Second", | |
| "type": "graph", | |
| "targets": [{"expr": "rate(ml_inference_requests_total[5m])"}] | |
| }, | |
| { | |
| "title": "P99 Inference Latency", | |
| "type": "graph", | |
| "targets": [{"expr": "histogram_quantile(0.99, rate(ml_inference_duration_seconds_bucket[5m]))"}] | |
| }, | |
| { | |
| "title": "Model Accuracy (last hour)", | |
| "type": "stat", | |
| "targets": [{"expr": "ml_model_accuracy"}] | |
| }, | |
| { | |
| "title": "Data Drift Score by Feature", | |
| "type": "bar", | |
| "targets": [{"expr": "ml_data_drift_score"}] | |
| }, | |
| { | |
| "title": "Prediction Confidence Distribution", | |
| "type": "histogram", | |
| "targets": [{"expr": "rate(ml_prediction_confidence_bucket[5m])"}] | |
| } | |
| ] | |
| } | |
| } | |
| ``` | |
| ### AlertManager Rules | |
| ```yaml | |
| # alerting-rules.yml | |
| groups: | |
| - name: ml-model-alerts | |
| rules: | |
| - alert: HighInferenceLatency | |
| expr: histogram_quantile(0.99, rate(ml_inference_duration_seconds_bucket[5m])) > 1.0 | |
| for: 5m | |
| labels: | |
| severity: warning | |
| annotations: | |
| summary: "P99 inference latency exceeds 1 second" | |
| description: "Model {{ $labels.model_version }} P99 latency is {{ $value }}s" | |
| - alert: ModelAccuracyDegradation | |
| expr: ml_model_accuracy < 0.80 | |
| for: 10m | |
| labels: | |
| severity: critical | |
| annotations: | |
| summary: "Model accuracy dropped below 80%" | |
| - alert: DataDriftDetected | |
| expr: ml_data_drift_score > 0.3 | |
| for: 15m | |
| labels: | |
| severity: warning | |
| annotations: | |
| summary: "Significant data drift detected in feature {{ $labels.feature_name }}" | |
| - alert: HighErrorRate | |
| expr: rate(ml_inference_requests_total{status="error"}[5m]) / rate(ml_inference_requests_total[5m]) > 0.05 | |
| for: 5m | |
| labels: | |
| severity: critical | |
| annotations: | |
| summary: "Model error rate exceeds 5%" | |
| ``` | |
| ## Great Expectations for Data Quality | |
| ```python | |
| import great_expectations as gx | |
| from great_expectations.core.batch import RuntimeBatchRequest | |
| context = gx.get_context() | |
| # Create expectation suite | |
| suite = context.add_or_update_expectation_suite("ml_training_data") | |
| # Connect to data | |
| datasource = context.sources.add_pandas("pandas_datasource") | |
| data_asset = datasource.add_dataframe_asset("training_data") | |
| batch_request = data_asset.build_batch_request(dataframe=df) | |
| validator = context.get_validator(batch_request=batch_request, expectation_suite_name="ml_training_data") | |
| # Define expectations | |
| validator.expect_column_to_exist("feature_1") | |
| validator.expect_column_values_to_not_be_null("feature_1") | |
| validator.expect_column_values_to_be_between("age", min_value=0, max_value=120) | |
| validator.expect_column_values_to_be_in_set("category", value_set=["A", "B", "C"]) | |
| validator.expect_column_mean_to_be_between("income", min_value=20000, max_value=200000) | |
| validator.expect_column_stdev_to_be_between("income", min_value=5000, max_value=50000) | |
| validator.expect_table_row_count_to_be_between(min_value=1000, max_value=10000000) | |
| validator.save_expectation_suite() | |
| # Run checkpoint | |
| checkpoint = context.add_or_update_checkpoint( | |
| name="ml_data_checkpoint", | |
| validations=[{ | |
| "batch_request": batch_request, | |
| "expectation_suite_name": "ml_training_data" | |
| }] | |
| ) | |
| results = checkpoint.run() | |
| assert results.success, "Data quality check failed!" | |
| ``` | |
| ## Automated Retraining Triggers | |
| ```python | |
| import boto3 | |
| from datetime import datetime, timedelta | |
| class AutoRetrainingSystem: | |
| def __init__(self, drift_threshold=0.2, accuracy_threshold=0.85): | |
| self.drift_threshold = drift_threshold | |
| self.accuracy_threshold = accuracy_threshold | |
| self.cloudwatch = boto3.client('cloudwatch') | |
| self.sagemaker = boto3.client('sagemaker') | |
| def check_drift_and_trigger_retraining(self, model_name): | |
| # Get current drift score from CloudWatch | |
| response = self.cloudwatch.get_metric_statistics( | |
| Namespace='MLMonitoring', | |
| MetricName='DataDriftScore', | |
| Dimensions=[{'Name': 'ModelName', 'Value': model_name}], | |
| StartTime=datetime.now() - timedelta(hours=24), | |
| EndTime=datetime.now(), | |
| Period=3600, | |
| Statistics=['Average'] | |
| ) | |
| drift_score = max([dp['Average'] for dp in response['Datapoints']], default=0) | |
| if drift_score > self.drift_threshold: | |
| print(f"Drift score {drift_score:.3f} exceeds threshold {self.drift_threshold}. Triggering retraining.") | |
| self._trigger_retraining_pipeline(model_name, reason=f"drift_score={drift_score:.3f}") | |
| def _trigger_retraining_pipeline(self, model_name, reason): | |
| # Start SageMaker Pipeline execution | |
| self.sagemaker.start_pipeline_execution( | |
| PipelineName=f"{model_name}-retraining-pipeline", | |
| PipelineExecutionDisplayName=f"auto-retrain-{datetime.now().strftime('%Y%m%d-%H%M')}", | |
| PipelineParameters=[ | |
| {'Name': 'TriggerReason', 'Value': reason}, | |
| {'Name': 'ModelName', 'Value': model_name} | |
| ] | |
| ) | |
| # Schedule this to run hourly | |
| scheduler = AutoRetrainingSystem(drift_threshold=0.25, accuracy_threshold=0.82) | |
| scheduler.check_drift_and_trigger_retraining("customer-churn-model") | |
| ``` | |
| ## Canary and Shadow Deployments for Safe Rollouts | |
| ```python | |
| # Canary deployment — serve new model to 10% of traffic | |
| class CanaryRouter: | |
| def __init__(self, champion_model, challenger_model, challenger_weight=0.1): | |
| self.champion = champion_model | |
| self.challenger = challenger_model | |
| self.challenger_weight = challenger_weight | |
| def route_request(self, request): | |
| import random | |
| if random.random() < self.challenger_weight: | |
| result = self.challenger.predict(request) | |
| self._log_prediction(result, model_type="challenger") | |
| else: | |
| result = self.champion.predict(request) | |
| self._log_prediction(result, model_type="champion") | |
| return result | |
| def promote_challenger(self): | |
| self.champion = self.challenger | |
| self.challenger_weight = 0.0 | |
| print("Challenger promoted to champion!") | |
| ``` | |
| ## MLflow Model Registry with Monitoring | |
| ```python | |
| import mlflow | |
| from mlflow.tracking import MlflowClient | |
| client = MlflowClient() | |
| # Set model tags with monitoring thresholds | |
| client.set_model_version_tag("MyModel", "1", "accuracy_threshold", "0.85") | |
| client.set_model_version_tag("MyModel", "1", "drift_threshold", "0.2") | |
| client.set_model_version_tag("MyModel", "1", "latency_sla_ms", "100") | |
| # Automated model health check | |
| def check_model_health(model_name, version): | |
| model_version = client.get_model_version(model_name, version) | |
| tags = model_version.tags | |
| accuracy_threshold = float(tags.get("accuracy_threshold", 0.8)) | |
| current_accuracy = get_current_model_accuracy(model_name) | |
| if current_accuracy < accuracy_threshold: | |
| client.transition_model_version_stage( | |
| name=model_name, | |
| version=version, | |
| stage="Archived", | |
| archive_existing_versions=False | |
| ) | |
| print(f"Model {model_name} v{version} archived due to accuracy degradation: {current_accuracy:.3f}") | |
| trigger_retraining(model_name) | |
| ``` | |
| ## Best Practices | |
| 1. Monitor both data quality and model performance — they're both early warning systems | |
| 2. Set up alerts before deploying to production, not after an incident | |
| 3. Store all prediction inputs and outputs for retrospective analysis | |
| 4. Use statistical tests (KS, PSI, MMD) for rigorous drift detection, not just visual inspection | |
| 5. Implement shadow mode testing before promoting new models | |
| 6. Track feature importance changes alongside accuracy metrics | |
| 7. Set SLOs for inference latency and establish error budget policies | |
| 8. Automate retraining triggers but require human approval for production promotions | |
| 9. Store monitoring baselines in version control alongside model artifacts | |
| 10. Create runbooks for each alert type so on-call engineers know how to respond | |