Truecaller: MLOps Excellence & Cost Optimization at Billion-Row Scale
Truecaller: MLOps Excellence & Cost Optimization at Billion-Row Scale
| Role: Senior Data Scientist | Duration: Apr 2022 - Present |
Executive Summary
At Truecaller, I lead fraud detection and spam classification efforts for one of the world’s largest caller identification platforms. Working with datasets containing billions of rows, I architected MLOps pipelines that reduced cloud infrastructure costs by 25% while improving fraud detection rates by 8% and SMS spam detection by 10%. The key innovations: automated model retraining triggered by data drift, PySpark optimization, and semi-supervised learning for label-scarce domains.
The Problem: Scale, Cost, and Continuous Evolution
Business Context
Truecaller processes:
- 250+ million active users globally
- Billions of phone calls and SMS messages daily
- Real-time fraud detection for financial transactions
- Spam classification across multiple languages and regions
Technical Challenges
1. Data Scale
- Datasets with 5-10 billion rows per month
- 500+ features per transaction/message
- High-velocity data streams (1M+ events per minute peak)
- Storage costs growing 30% year-over-year
2. Model Staleness
- Fraud patterns evolve rapidly (new scam techniques weekly)
- Spam content changes with current events and trends
- Manual retraining was reactive, not proactive
- Model performance degraded 5-10% within 2-3 months
3. Cloud Cost Explosion
- Spark clusters running 24/7 for batch processing
- Inefficient queries scanning entire datasets
- Redundant transformations across multiple pipelines
- Monthly GCP costs increasing unsustainably
4. Label Scarcity
- SMS spam: only 2% of data labeled by users
- Cold-start problem for new spam categories
- Manual labeling doesn’t scale
- Need to leverage unlabeled data
The Solution: MLOps Automation + Spark Optimization
1. Automated Retraining Pipeline with Data Drift Detection
The Core Idea: Models should retrain automatically when data distribution shifts, not on a fixed schedule.
Architecture:
[Production Data Stream]
↓
[BigQuery - Data Warehouse]
↓
[Cloud Scheduler - Daily Drift Check]
↓
[Drift Detection Service]
├── Feature Distribution Analysis (KL Divergence)
├── Prediction Distribution Shift
└── Performance Metric Degradation
↓
[Drift Detected?] ──No──> [Continue Monitoring]
↓ Yes
[Pub/Sub Topic - Retrain Trigger]
↓
[Cloud Function - Orchestrator]
↓
[MLFlow Pipeline]
├── Data Validation (Great Expectations)
├── Feature Engineering (PySpark)
├── Model Training (XGBoost/LightGBM)
├── Hyperparameter Tuning (Optuna)
├── Model Evaluation (Hold-out + Temporal Split)
└── Model Registry (MLFlow)
↓
[A/B Test Deployment]
↓
[Production Serving (if metrics improve)]
Drift Detection Implementation:
import numpy as np
from scipy.stats import ks_2samp, entropy
class DataDriftDetector:
def __init__(self, reference_data, threshold=0.05):
self.reference_data = reference_data
self.threshold = threshold
def detect_feature_drift(self, current_data, feature_name):
"""
Detect drift using Kolmogorov-Smirnov test for numerical features
and KL divergence for categorical features
"""
ref_values = self.reference_data[feature_name]
curr_values = current_data[feature_name]
if self._is_numerical(feature_name):
# KS test for continuous distributions
statistic, p_value = ks_2samp(ref_values, curr_values)
drift_detected = p_value < self.threshold
else:
# KL divergence for categorical distributions
ref_dist = self._get_distribution(ref_values)
curr_dist = self._get_distribution(curr_values)
kl_div = entropy(curr_dist, ref_dist)
drift_detected = kl_div > 0.1 # Threshold tuned empirically
return drift_detected
def detect_prediction_drift(self, model, current_data):
"""
Monitor if prediction distributions shift significantly
"""
predictions = model.predict_proba(current_data)
ref_predictions = self.reference_data['predictions']
# Compare prediction score distributions
statistic, p_value = ks_2samp(predictions[:, 1], ref_predictions)
return p_value < self.threshold
def detect_performance_drift(self, current_metrics, reference_metrics):
"""
Alert if model performance degrades beyond acceptable threshold
"""
metrics_to_monitor = ['auc', 'precision', 'recall', 'f1']
for metric in metrics_to_monitor:
degradation = (reference_metrics[metric] - current_metrics[metric]) / reference_metrics[metric]
if degradation > 0.05: # 5% degradation threshold
return True
return False
MLFlow Integration:
import mlflow
from mlflow.tracking import MlflowClient
class FraudModelPipeline:
def __init__(self, experiment_name="fraud-detection"):
mlflow.set_experiment(experiment_name)
self.client = MlflowClient()
def train_and_log_model(self, train_data, val_data, params):
with mlflow.start_run():
# Log parameters
mlflow.log_params(params)
# Train model
model = self._train_model(train_data, params)
# Evaluate
metrics = self._evaluate_model(model, val_data)
mlflow.log_metrics(metrics)
# Log model artifact
mlflow.sklearn.log_model(model, "model")
# Log feature importance
self._log_feature_importance(model)
# Register model if performance improves
if metrics['auc'] > self.get_production_auc():
model_uri = f"runs:/{mlflow.active_run().info.run_id}/model"
mlflow.register_model(model_uri, "fraud-detection-model")
def get_production_auc(self):
# Fetch current production model metrics
production_model = self.client.get_latest_versions("fraud-detection-model", stages=["Production"])
if production_model:
run_id = production_model[0].run_id
run = self.client.get_run(run_id)
return run.data.metrics.get('auc', 0.0)
return 0.0
Impact:
- Reduced time-to-deployment for model updates from 2 weeks to 8 hours
- Models now retrain automatically when drift exceeds thresholds
- Improved fraud detection rate by 8% through timely updates
- Eliminated manual monitoring overhead
2. PySpark Pipeline Optimization: 25% Cost Reduction
Problem: Existing Spark jobs were inefficient, leading to:
- Long-running clusters (8+ hours for daily batch jobs)
- Full table scans on multi-TB datasets
- Redundant shuffles and data movement
- High memory pressure causing OOM errors
Optimization Strategies:
a) Partition Pruning & Predicate Pushdown
# BEFORE: Inefficient query
fraud_data = spark.read.parquet("gs://bucket/fraud_events/")
filtered_data = fraud_data.filter(col("event_date") == "2024-01-15")
# AFTER: Partition pruning
fraud_data = spark.read.parquet("gs://bucket/fraud_events/") \
.where("event_date = '2024-01-15'") # Pushed to storage layer
# Result: 90% reduction in data scanned
b) Broadcast Joins for Small Dimension Tables
# BEFORE: Shuffle join (expensive for large fact table)
enriched = transactions.join(user_features, "user_id")
# AFTER: Broadcast join
from pyspark.sql.functions import broadcast
enriched = transactions.join(broadcast(user_features), "user_id")
# Result: Eliminated shuffle for 500M row table
c) Caching Strategic Intermediate Results
# BEFORE: Recomputing transformations multiple times
base_features = compute_base_features(raw_data)
agg_features_1 = base_features.groupBy("user_id").agg(...)
agg_features_2 = base_features.groupBy("session_id").agg(...)
# AFTER: Cache intermediate result
base_features = compute_base_features(raw_data).cache()
agg_features_1 = base_features.groupBy("user_id").agg(...)
agg_features_2 = base_features.groupBy("session_id").agg(...)
# Result: 40% reduction in compute time
d) Optimized Data Formats (Parquet with Compression)
# BEFORE: CSV files with no compression
raw_data.write.csv("gs://bucket/output/")
# AFTER: Parquet with Snappy compression
raw_data.write.parquet("gs://bucket/output/", compression="snappy")
# Result: 5x storage reduction, 3x faster reads
e) Dynamic Partition Overwrite (Avoid Full Table Rewrites)
# BEFORE: Overwrite entire table daily
spark.conf.set("spark.sql.sources.partitionOverwriteMode", "static")
output.write.mode("overwrite").partitionBy("event_date").parquet("...")
# AFTER: Overwrite only affected partitions
spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")
output.write.mode("overwrite").partitionBy("event_date").parquet("...")
# Result: 80% reduction in write time for incremental updates
f) Cluster Autoscaling & Resource Right-Sizing
# Configured Dataproc clusters with:
# - Autoscaling: 5-50 workers based on pending tasks
# - Preemptible VMs: 70% of workers (cost saving)
# - Right-sized machine types: n1-highmem-4 instead of n1-standard-8
# Result: 40% reduction in compute costs
Cost Impact Breakdown:
| Optimization | Cost Reduction |
|---|---|
| Partition pruning | 15% |
| Broadcast joins | 8% |
| Parquet + compression | 20% |
| Autoscaling + preemptibles | 40% |
| Caching + fewer recomputes | 12% |
| Net Cost Reduction | ~25% |
Note: Percentages don’t sum linearly due to overlapping benefits.
3. Semi-Supervised Learning for SMS Spam Detection
Problem: Only 2% of SMS data labeled by users. Cold-start problem for new spam categories (e.g., COVID scams, cryptocurrency fraud).
Solution: Semi-supervised learning with DeBERTaV3 fine-tuning.
Approach:
Step 1: Self-Training with Pseudo-Labeling
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch
class SemiSupervisedSpamClassifier:
def __init__(self, model_name="microsoft/deberta-v3-base"):
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForSequenceClassification.from_pretrained(
model_name, num_labels=2
)
def train_with_pseudo_labels(self, labeled_data, unlabeled_data, iterations=3):
for iteration in range(iterations):
# Step 1: Train on labeled data
self.fine_tune(labeled_data)
# Step 2: Predict on unlabeled data
pseudo_labels = self.predict_with_confidence(unlabeled_data)
# Step 3: Add high-confidence predictions to training set
high_conf_samples = pseudo_labels[pseudo_labels['confidence'] > 0.95]
labeled_data = pd.concat([labeled_data, high_conf_samples])
print(f"Iteration {iteration+1}: Added {len(high_conf_samples)} pseudo-labeled samples")
def predict_with_confidence(self, texts):
self.model.eval()
predictions = []
for text in texts:
inputs = self.tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
with torch.no_grad():
outputs = self.model(**inputs)
probs = torch.softmax(outputs.logits, dim=1)
confidence, pred_label = torch.max(probs, dim=1)
predictions.append({
'text': text,
'label': pred_label.item(),
'confidence': confidence.item()
})
return pd.DataFrame(predictions)
Step 2: Data Augmentation for Rare Classes
import nlpaug.augmenter.word as naw
def augment_minority_class(spam_samples, num_augmented=5):
augmenter = naw.SynonymAug(aug_src='wordnet')
augmented_samples = []
for text in spam_samples:
for _ in range(num_augmented):
augmented_text = augmenter.augment(text)
augmented_samples.append(augmented_text)
return augmented_samples
Step 3: Active Learning for Labeling Budget Optimization
def select_samples_for_labeling(model, unlabeled_data, budget=1000):
"""
Select most informative samples using uncertainty sampling
"""
predictions = model.predict_with_confidence(unlabeled_data)
# Select samples with predictions closest to 0.5 (high uncertainty)
predictions['uncertainty'] = 1 - abs(predictions['confidence'] - 0.5) * 2
most_uncertain = predictions.nlargest(budget, 'uncertainty')
return most_uncertain
Results:
| Metric | Supervised Only | Semi-Supervised |
|---|---|---|
| Precision | 0.82 | 0.89 |
| Recall | 0.76 | 0.85 |
| F1-Score | 0.79 | 0.87 |
| Labeled Data Required | 100% (200K) | 10% (20K) + 180K unlabeled |
Impact:
- 10% improvement in SMS spam detection (F1: 0.79 → 0.87)
- Reduced labeling costs by 80% through pseudo-labeling
- Faster adaptation to new spam types (2 days vs. 2 weeks)
The Impact: Production-Grade MLOps at Scale
Technical Achievements
- Cost Optimization: 25% reduction in cloud infrastructure costs (~$100K+ annual savings)
- Performance Improvement: Fraud detection rate up 8%, spam detection up 10%
- Automation: Model retraining fully automated, reducing manual effort by 90%
- Scalability: Pipelines handle 10B+ rows efficiently with sub-hour SLAs
- Reliability: 99.9% uptime for ML serving endpoints
Business Impact
- Customer Trust: Improved fraud detection protects millions of users daily
- User Experience: Reduced spam exposure improves app engagement
- Operational Efficiency: Data science team focuses on innovation, not maintenance
- Competitive Advantage: Faster adaptation to evolving fraud/spam patterns
Learnings & Best Practices
What Worked:
- Drift detection over scheduled retraining: Data-driven triggers are more efficient
- Spark optimization compounds: Small improvements across many jobs add up
- Semi-supervised learning is practical: Leverage unlabeled data intelligently
- MLFlow standardization: Reproducibility and version control are non-negotiable
What Was Challenging:
- Tuning drift detection thresholds (false positives vs. false negatives)
- Balancing preemptible VM savings vs. job reliability
- Cold-start problem for entirely new fraud patterns (still requires some labeled data)
What I’d Do Differently:
- Implement feature store earlier (reduce redundant feature computation)
- Add more comprehensive data quality checks in pipelines
- Experiment with online learning for even faster adaptation
Technical Deep Dive: Spark Optimization Workflow
For engineers working with large-scale Spark pipelines:
1. Profiling & Identifying Bottlenecks
# Enable Spark UI and analyze execution plans
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
# Analyze query plan
df.explain("extended")
# Look for:
# - Full table scans (TableScan without partition filters)
# - Shuffle operations (Exchange)
# - Large broadcast joins (BroadcastExchange with >1GB data)
2. Partitioning Strategy
# Partition data by frequently filtered columns
transactions.write.partitionBy("event_date", "country") \
.parquet("gs://bucket/transactions/")
# For high-cardinality columns, use bucketing
user_features.write.bucketBy(100, "user_id") \
.sortBy("user_id") \
.saveAsTable("user_features_bucketed")
3. Handling Skew
from pyspark.sql.functions import rand
# Salting technique for skewed joins
skewed_df = large_df.withColumn("salt", (rand() * 10).cast("int"))
small_df_salted = small_df.crossJoin(spark.range(10).withColumnRenamed("id", "salt"))
result = skewed_df.join(small_df_salted, ["join_key", "salt"]).drop("salt")
Conclusion
The Truecaller project demonstrates that MLOps is not just about automation—it’s about intelligent automation. By combining data drift detection, Spark optimization, and semi-supervised learning, we built a system that:
- Adapts automatically to changing data patterns
- Operates efficiently at billion-row scale
- Delivers measurable business impact (cost reduction + performance improvement)
The key insight: Production ML is 20% modeling and 80% infrastructure, data engineering, and monitoring.
Technologies Used: Python, PySpark, MLFlow, Google Cloud Platform (BigQuery, Dataproc, Pub/Sub, Cloud Functions), DeBERTaV3, Transformers, XGBoost, LightGBM, Optuna, Great Expectations