End-to-End MLOps: What Actually Works in Production
A practitioner's guide to understandingwhat actually works in the real world ML system.
End-to-End MLOps: What Actually Works in Production
I’ve deployed 23 ML models to production over the last four years. Six of them are still running. Twelve were retired because they weren’t worth maintaining. Five failed catastrophically and had to be rolled back.
The difference between the ones that survived and the ones that died wasn’t model architecture or accuracy metrics. It was operations. Monitoring, retraining, deployment pipelines, data quality checks, incident response. The boring infrastructure work that nobody talks about in research papers.
Our most successful model is a fraud detection classifier that’s been running for 31 months. It has 91% precision at 87% recall. Not state-of-art. But it’s reliable, maintainable, and caught $4.2M in fraud last year. The operational cost is $6,800 per month including compute, storage, on-call time, and retraining.
Here’s what actually keeps models alive in production.
The Data Pipeline Is The Model
Most ML failures are data failures. I track incidents in a spreadsheet. Over 18 months:
- 47 incidents total
- 31 were data quality issues (66%)
- 9 were model performance degradation (19%)
- 4 were infrastructure failures (9%)
- 3 were code bugs (6%)
Data quality issues look like: missing features, schema changes, encoding errors, timestamp misalignment, upstream pipeline failures, silent corruption, distribution shift.
Built a fraud model that trained on transaction data with timestamps in UTC. Production data came in with local timezones. Model saw 3 AM transactions as 11 AM transactions. Completely broke the temporal features. Took three days to find because the data wasn’t technically wrong, just interpreted wrong.
Now we validate everything before it touches the model. Built a data validation layer that runs on every batch:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class DataValidator:
def __init__(self, schema: Schema, checks: List[Check]):
self.schema = schema
self.checks = checks
self.baseline_stats = self.load_baseline()
def validate(self, batch: DataFrame) -> ValidationReport:
report = ValidationReport()
# Schema validation
schema_errors = self.validate_schema(batch)
report.add_errors(schema_errors)
# Statistical validation
for check in self.checks:
violations = check.run(batch, self.baseline_stats)
report.add_violations(violations)
# Business logic validation
business_errors = self.validate_business_rules(batch)
report.add_errors(business_errors)
return report
The schema validation catches type errors, missing columns, unexpected nulls. Standard stuff.
Statistical validation is where it gets interesting. For every numeric feature, maintain baseline statistics from training data: mean, std, min, max, percentiles. In production, compare batch statistics to baseline. Flag when they diverge.
For our fraud model, transaction_amount has baseline mean $147, std $312. If a batch comes in with mean $890, that’s a 2.4 sigma deviation. Either fraud patterns changed dramatically or there’s a data quality issue. Usually it’s data quality.
Thresholds are feature-dependent. Set them by measuring historical variance. transaction_amount varies a lot, threshold at 3 sigma. merchant_category is stable, threshold at 1.5 sigma.
Business logic validation catches domain-specific impossibilities. Transaction timestamp in the future. Transaction amount negative. User age 257 years old. Credit card expiration date in 1987. These slip through schema validation because the types are correct.
Validation runs pre-training and pre-inference. Pre-training catches issues before they corrupt the model. Pre-inference catches issues before they cause bad predictions.
We reject 0.3% of training data and 0.8% of inference data due to validation failures. Every rejected sample is logged. Review logs weekly. Usually reveals upstream data pipeline bugs.
Feature Stores Are Not Optional At Scale
Started with features computed on-demand. Query the database, run transformations, feed to model. Simple. Doesn’t scale.
Problem one: latency. Our fraud model needs 47 features. Thirteen of them require aggregations over 30-day windows. Computing those aggregations at inference time takes 340ms. P99 is 890ms when the database is under load. Unacceptable for real-time fraud detection.
Problem two: training-serving skew. Training computes features one way. Serving computes them slightly differently. Model trains on feature X computed as mean(transactions, 30d). Serving accidentally computes it as mean(transactions, 31d) due to an off-by-one error. Model performance degrades silently.
Problem three: recomputation waste. Ten models all need user_transaction_count_30d. Each model computes it independently. Ten times the database load, ten times the compute cost.
Built a feature store. It’s not fancy. Postgres tables with feature values materialized and indexed by entity ID and timestamp. Features are computed once by batch jobs, stored, and served to all models.
Architecture:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class FeatureStore:
def __init__(self, db: Database):
self.db = db
self.cache = LRUCache(maxsize=10000)
def get_features(self, entity_id: str, feature_names: List[str],
timestamp: datetime) -> Dict[str, float]:
cache_key = (entity_id, tuple(feature_names), timestamp)
if cache_key in self.cache:
return self.cache[cache_key]
# Point-in-time correct feature retrieval
query = """
SELECT feature_name, feature_value
FROM features
WHERE entity_id = %s
AND feature_name = ANY(%s)
AND computed_at <= %s
ORDER BY computed_at DESC
LIMIT %s
"""
results = self.db.execute(query,
(entity_id, feature_names, timestamp,
len(feature_names)))
features = {row['feature_name']: row['feature_value']
for row in results}
self.cache[cache_key] = features
return features
The critical part is point-in-time correctness. When training on historical data, retrieve features as they existed at that timestamp. Don’t use future information. This eliminates training-serving skew.
Feature computation runs as Airflow DAGs. Each feature has a DAG that:
- Reads raw data from source tables
- Applies transformations
- Validates output
- Writes to feature store
- Updates metadata
DAGs run on different schedules. Simple features like user_account_age update hourly. Expensive aggregations like user_fraud_score_30d update daily. Real-time features like user_active_session update every 5 minutes via streaming jobs.
Serving latency dropped from 340ms to 12ms. Training-serving skew dropped from 8% feature mismatch to 0.2%. Database load dropped 70% because features are precomputed.
Cost went up initially. Feature store storage is 180GB, costs $45/month. Feature computation jobs cost $320/month in compute. But we’re saving $2,400/month in reduced database load and eliminated redundant computation. Net savings of $2,035/month.
More importantly, we can iterate faster. New model needs a feature? Add it to the feature store. All models can use it immediately. No reimplementation.
Model Versioning Is Not Git Versioning
Git tracks code. You also need to track: model weights, training data version, feature definitions, hyperparameters, evaluation metrics, training logs, serving configuration.
Built a model registry that’s just a Postgres table with good tooling:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
CREATE TABLE model_registry (
model_id UUID PRIMARY KEY,
model_name VARCHAR(255),
version INTEGER,
-- Training artifacts
training_data_version VARCHAR(255),
training_code_commit VARCHAR(40),
hyperparameters JSONB,
-- Model artifacts
model_weights_s3_path VARCHAR(512),
feature_definitions JSONB,
-- Evaluation metrics
metrics JSONB,
validation_report JSONB,
-- Metadata
trained_by VARCHAR(255),
trained_at TIMESTAMP,
deployed_at TIMESTAMP,
retired_at TIMESTAMP,
-- Status
status VARCHAR(50),
notes TEXT
);
Every training run creates a model_id. The model object gets serialized and uploaded to S3. Metadata goes in the registry. This gives us full lineage.
Production deployment references model_id, not a file path or model name. If we need to rollback, we change which model_id is serving traffic. The model artifacts are immutable and versioned.
Implemented A/B testing at the model_id level:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ModelRouter:
def __init__(self, registry: ModelRegistry):
self.registry = registry
self.models = {} # model_id -> loaded model
def predict(self, user_id: str, features: Dict) -> Prediction:
# Route based on experiment assignment
model_id = self.get_model_for_user(user_id)
if model_id not in self.models:
self.models[model_id] = self.load_model(model_id)
model = self.models[model_id]
prediction = model.predict(features)
# Log for analysis
self.log_prediction(user_id, model_id, features, prediction)
return prediction
We run 10% of traffic on candidate models before full deployment. Measure precision, recall, latency, cost over two weeks. If metrics are better and no incidents occur, promote to 50%. Then 100%.
Caught multiple issues this way. A new fraud model had 94% precision in offline eval. In A/B test on 10% traffic, precision was 89%. The 10% sample skewed toward a specific merchant category where the model performed worse. Would have degraded production metrics if we’d deployed to 100% immediately.
Model registry also tracks retirement. When we stop serving a model, we mark retired_at but keep the artifacts. Three months later we archive to cold storage. One year later we delete. This ensures we can always reconstruct what was running in production at any timestamp.
Monitoring Is Not Metrics Dashboards
Everyone builds dashboards with accuracy, precision, recall. Those lag indicators. By the time they degrade, you’ve been serving bad predictions for days or weeks.
Built leading indicators instead:
Input distribution monitoring: Track feature distributions in real-time. Compare to training distribution using KL divergence. When KL divergence exceeds threshold, alert.
For fraud model, user_age has KL divergence of 0.03 in normal operation. Last month it spiked to 0.47. Investigated. Found a data pipeline bug where ages were being corrupted during ETL. Fixed before model performance degraded.
Prediction distribution monitoring: Track prediction distributions. Most models have stable prediction distributions if the world isn’t changing. Sudden shifts indicate issues.
Our fraud model typically predicts fraud for 2.3% of transactions. One day it jumped to 7.1%. Model wasn’t wrong. A merchant got compromised and was processing fraudulent transactions at high volume. Model caught it. But the alert let us investigate immediately instead of waiting for user complaints.
Confidence calibration monitoring: Track model confidence versus actual outcomes. A well-calibrated model that predicts 80% fraud probability should be right 80% of the time.
We bin predictions by confidence decile and track accuracy per bin. When calibration drifts, it’s often the first sign of model degradation. Caught concept drift three weeks earlier than accuracy metrics would have shown.
Latency monitoring by percentile: P50, P95, P99 latency tracked separately. Median latency is useless. The outliers are what users experience as broken.
Our fraud model P50 is 18ms, P95 is 45ms, P99 is 340ms. The P99 matters because those are the high-value transactions that hit complex code paths. When P99 degrades, we investigate before users complain about slow checkouts.
Feature freshness monitoring: Track when features were last updated. Stale features cause predictions to degrade.
One feature, user_transaction_velocity, stopped updating due to a streaming job failure. Took four hours to notice because predictions didn’t break immediately. But fraud detection accuracy was degrading silently. Now we alert if any feature is stale for more than 15 minutes.
Error rate by feature: Track which features cause prediction errors. If user_income_bucket starts causing 30% of inference errors, there’s likely a data quality issue with that feature.
All these metrics feed into a single alerting system:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class ModelMonitor:
def __init__(self, model_id: str, thresholds: Dict):
self.model_id = model_id
self.thresholds = thresholds
self.baseline = self.load_baseline()
def check(self, batch: PredictionBatch) -> List[Alert]:
alerts = []
# Input distribution
kl_div = self.calculate_kl_divergence(
batch.features, self.baseline.features
)
if kl_div > self.thresholds['kl_divergence']:
alerts.append(Alert('INPUT_DRIFT', severity='high',
metric=kl_div))
# Prediction distribution
pred_dist = self.calculate_prediction_distribution(batch)
if self.is_anomalous(pred_dist):
alerts.append(Alert('PREDICTION_DRIFT', severity='medium'))
# Calibration
calibration_error = self.calculate_ece(batch)
if calibration_error > self.thresholds['calibration']:
alerts.append(Alert('CALIBRATION_DRIFT', severity='high'))
# Latency
if batch.p99_latency > self.thresholds['p99_latency']:
alerts.append(Alert('LATENCY_DEGRADATION', severity='high'))
return alerts
Alerts go to Slack and PagerDuty depending on severity. We get 3-4 alerts per week. About 60% are actionable. The rest are false positives we tune out over time.
Response SLA is 2 hours for high-severity alerts, 24 hours for medium. Most issues are caught and fixed before they impact production metrics.
Retraining Is Not Occasional
Models decay. The world changes. Retraining is continuous operational overhead, not a one-time activity.
Our fraud model retrains weekly. Each retrain:
- Pulls last 90 days of data (2.1M transactions)
- Applies data validation
- Computes features via feature store
- Trains model with fixed hyperparameters
- Evaluates on hold-out set
- Runs automated tests
- Deploys to 10% traffic if tests pass
The entire pipeline takes 4.2 hours. Runs overnight Saturday. If successful, new model serves 10% traffic Sunday. Promoted to 100% Thursday if metrics look good.
Retraining frequency was determined experimentally. Tried daily, weekly, monthly. Daily was too noisy. Month-to-month variance in fraud patterns meant accuracy degraded 3-4% between retrains. Weekly hit the sweet spot. Accuracy stays within 1% of optimal.
Automated tests before deployment:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class ModelTests:
def run_tests(self, candidate_model, baseline_model, test_data):
results = TestResults()
# Accuracy must not regress
candidate_acc = self.evaluate_accuracy(candidate_model, test_data)
baseline_acc = self.evaluate_accuracy(baseline_model, test_data)
results.add('accuracy_regression',
candidate_acc >= baseline_acc * 0.98)
# Calibration must be acceptable
ece = self.calculate_ece(candidate_model, test_data)
results.add('calibration', ece < 0.10)
# Inference latency must be acceptable
latency = self.measure_latency(candidate_model)
results.add('latency', latency.p99 < 50) # ms
# Fairness metrics
fairness = self.evaluate_fairness(candidate_model, test_data)
results.add('fairness', all(fairness.values() > 0.80))
# No feature importance drift
importance_shift = self.compare_feature_importance(
candidate_model, baseline_model
)
results.add('feature_stability', importance_shift < 0.30)
return results
If any test fails, deployment is blocked. Requires manual review. This catches issues that offline metrics miss.
Last month a retrain passed all accuracy tests but failed fairness tests. Model was more aggressive on transactions from certain zip codes. Would have created bias in production. Investigated and found a data labeling issue where fraud in those zip codes was over-reported. Fixed the labels and retrained.
Retraining cost is $84 per week in compute. Storage for training data is $120/month. Total operational cost for continuous retraining is about $500/month. Worth it to keep the model from decaying.
Shadow Mode Is Your Best Friend
Never deploy directly to production. Shadow mode first.
Shadow deployment means the new model runs in production but doesn’t affect user experience. It receives real traffic, makes real predictions, but those predictions are logged and discarded. We compare shadow predictions to baseline model predictions and ground truth labels.
Run shadow mode for minimum two weeks before promotion. During that time:
- Compare prediction distributions
- Measure accuracy on cases where ground truth is available
- Check latency under production load
- Monitor resource usage
- Look for edge cases that break the model
Caught multiple issues in shadow mode. A recommendation model looked great in offline eval. In shadow mode, it had 10x higher latency on 5% of queries. Those queries hit a code path that did inefficient database lookups. Fixed before it impacted users.
Another model had good average accuracy but completely broke on a specific product category. The category was underrepresented in training data. Offline eval didn’t catch it because test set was equally underrepresented. Shadow mode showed the issue immediately because production traffic has different distribution.
Shadow mode does cost money. Running duplicate inference doubles compute cost during the shadow period. For a model serving 180K requests/day, that’s an extra $120-180 during the two-week shadow period. Cheap insurance against production incidents.
Implementation is straightforward:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ShadowRouter:
def __init__(self, baseline_model, shadow_model):
self.baseline = baseline_model
self.shadow = shadow_model
def predict(self, features):
# Baseline prediction (returned to user)
baseline_pred = self.baseline.predict(features)
# Shadow prediction (logged only)
try:
shadow_pred = self.shadow.predict(features)
self.log_shadow_prediction(features, baseline_pred, shadow_pred)
except Exception as e:
# Shadow failures don't affect users
self.log_shadow_error(e)
return baseline_pred
Shadow failures are logged but don’t impact users. If the shadow model crashes, users still get predictions from the baseline. This lets us test risky changes safely.
Incident Response Playbooks
ML incidents are different from normal software incidents. You can’t just read stack traces. The model made predictions. Some were wrong. Why?
Built incident response playbooks for common failure modes:
Playbook: Accuracy degradation
- Check data validation logs. Are we rejecting more data than usual?
- Check feature freshness. Are features stale?
- Check input distribution monitoring. Has the data distribution shifted?
- Pull sample of recent predictions. Manually inspect for patterns.
- Check for upstream pipeline changes in last 48 hours.
- If data quality issue: fix pipeline, backfill features, retrain.
- If concept drift: emergency retrain with recent data.
- If no root cause found in 2 hours: rollback to previous model version.
Playbook: Latency spike
- Check P50/P95/P99. Which percentile is affected?
- Check feature store latency. Is feature retrieval slow?
- Check model serving infrastructure. CPU/memory usage?
- Profile slow predictions. Which features or code paths?
- Check database load. Are we causing contention?
- If infrastructure: scale up or optimize queries.
- If model: deploy latency-optimized version or rollback.
Playbook: Prediction distribution shift
- Check if this is expected. Did fraud patterns genuinely change?
- Check recent data. Is this coming from one merchant? One region?
- Check feature values for shifted predictions. Which features changed?
- Check for data corruption. Are features within expected ranges?
- If genuine shift: communicate to stakeholders, monitor closely.
- If data issue: identify corrupt features, fix pipeline, reprocess.
These playbooks cut mean time to resolution from 3.2 hours to 45 minutes. Everyone knows what to check. No wasted time guessing.
The Full Pipeline
Current production architecture for fraud detection model:
Data Ingestion
- Kafka consumers read transaction streams
- Data validation on every message
- Write to data lake (S3)
- Trigger feature computation DAGs
- Cost: $480/month
Feature Engineering
- Airflow DAGs compute features
- Write to feature store (Postgres)
- Cache frequently accessed features (Redis)
- Cost: $320/month compute, $45/month storage
Model Training
- Weekly automated retraining
- Training data: 90 days, 2.1M samples
- Training time: 4.2 hours
- Automated testing before deployment
- Cost: $336/month ($84/week)
Model Serving
- FastAPI service on Kubernetes
- Load balanced across 6 pods
- Feature retrieval: 12ms p99
- Model inference: 18ms p50, 45ms p95
- Serving 180K requests/day
- Cost: $840/month
Monitoring
- Input/prediction distribution monitoring
- Calibration tracking
- Latency monitoring by percentile
- Feature freshness checks
- Datadog for metrics, Sentry for errors
- Cost: $280/month
Total operational cost: $2,301/month
Model caught $4.2M in fraud last year. Cost to operate: $27,612 annually. ROI is 152:1.
More importantly, zero production incidents in last six months. The model just works. It’s not exciting. It’s reliable.
What Actually Matters
Training the model is 5% of the work. The other 95% is:
- Data validation so you know what’s going into the model
- Feature stores so training and serving don’t diverge
- Monitoring that catches issues before they impact users
- Retraining pipelines that keep models from decaying
- Testing that catches regressions before deployment
- Incident response that fixes issues quickly
- Documentation so the next person can maintain it
The models that survive in production aren’t the ones with the best accuracy. They’re the ones with the best operations.
Build boring infrastructure. Test everything. Monitor everything. Have rollback plans. Respond to incidents systematically.
That’s MLOps. Not the blog posts about neural architecture search or AutoML or whatever is trendy. The unglamorous work that keeps models alive in production.
The fraud model I mentioned is gradient boosted trees. Nothing fancy. It works because the operations around it work.
Better ops beats better models every time.