Synthetic Data for ML Model Testing and Evaluation
Testing machine learning models is fundamentally different from testing traditional software. A function either returns the right value or it doesn't. A classification model is right 94% of the time — and the question is whether that 94% is measuring what you actually care about, whether the other 6% clusters around a business-critical edge case, and whether the model behaves predictably when inputs shift.
Synthetic data is a first-class tool for ML model testing. It lets you generate precisely controlled inputs to test specific behaviors, create unlimited evaluation datasets without privacy constraints, simulate distribution shift before it happens in production, and stress-test models against edge cases that might not appear in historical data for months.
This guide covers how to generate synthetic data specifically for ML testing and evaluation — different from synthetic data for QA databases.
Why ML Models Need Dedicated Test Data
Real ML evaluation datasets have four problems:
Label leakage. If you evaluate on data from the same distribution as training, you'll get optimistic accuracy numbers. Real-world data is often imbalanced, temporally correlated, or geographically clustered in ways that contaminate train/test splits.
Coverage gaps. Historical data doesn't cover events that haven't happened yet. Your churn prediction model has never seen a global pandemic. Your fraud model has never seen a new payment method. Synthetic data lets you test against hypothetical scenarios before they become real.
Privacy constraints. Evaluation sets with real user data create compliance obligations. If your eval set contains real email addresses, you can't share it with a consultant, push it to a public repository, or use it in a conference demo.
Insufficient rare class examples. You might have 100,000 normal transactions and 200 fraud cases. That's not enough fraudulent examples to robustly evaluate a fraud model's recall. Synthetic data lets you oversample rare classes for evaluation without biasing training.
Approach 1: Controlled Synthetic Test Sets
The most powerful use of synthetic data in ML testing is generating inputs with known ground truth. Instead of labeling data after the fact, you generate labeled data from first principles.
Example: Testing a Credit Risk Model
import numpy as np
import pandas as pd
from faker import Faker
fake = Faker()
np.random.seed(42)
def generate_loan_applicant(risk_tier='medium'):
"""Generate a synthetic loan applicant with known risk characteristics."""
if risk_tier == 'low':
# Low risk profile
credit_score = np.random.normal(750, 20)
debt_to_income = np.random.uniform(0.1, 0.3)
employment_years = np.random.uniform(5, 20)
missed_payments = np.random.choice([0, 1], p=[0.95, 0.05])
elif risk_tier == 'high':
# High risk profile
credit_score = np.random.normal(580, 30)
debt_to_income = np.random.uniform(0.5, 0.8)
employment_years = np.random.uniform(0, 2)
missed_payments = np.random.choice([0, 1, 2, 3], p=[0.3, 0.3, 0.25, 0.15])
else:
# Medium risk
credit_score = np.random.normal(660, 40)
debt_to_income = np.random.uniform(0.3, 0.5)
employment_years = np.random.uniform(2, 7)
missed_payments = np.random.choice([0, 1, 2], p=[0.6, 0.3, 0.1])
return {
"credit_score": np.clip(credit_score, 300, 850),
"debt_to_income": np.clip(debt_to_income, 0, 1),
"employment_years": max(0, employment_years),
"missed_payments_12mo": missed_payments,
"loan_amount": np.random.uniform(5000, 100000),
"annual_income": np.random.uniform(30000, 200000),
"risk_tier": risk_tier # Ground truth label
}
# Generate balanced evaluation set across risk tiers
eval_data = pd.DataFrame([
generate_loan_applicant('low') for _ in range(1000)
] + [
generate_loan_applicant('medium') for _ in range(1000)
] + [
generate_loan_applicant('high') for _ in range(1000)
])
# Evaluate model on synthetic data
from sklearn.metrics import classification_report
predictions = model.predict(eval_data.drop('risk_tier', axis=1))
print(classification_report(eval_data['risk_tier'], predictions))The key advantage: you know exactly which applicants should be classified as high/low/medium risk. If your model classifies 30% of low-risk applicants as high-risk, that's a real problem — and you can measure it precisely because you control ground truth.
Approach 2: Distribution Shift Testing
One of the hardest ML problems is detecting when your model's input distribution has drifted from training. Synthetic data lets you simulate this before it happens.
def generate_shifted_distribution(shift_type, n=1000):
"""Generate data with simulated distribution shift."""
base_data = generate_base_distribution(n)
if shift_type == 'feature_drift':
# Simulate a feature drifting over time
# Example: credit scores trending higher due to economic conditions
base_data['credit_score'] = base_data['credit_score'] + np.random.normal(50, 10, n)
elif shift_type == 'covariate_shift':
# New demographic entering the market
# Example: first-time borrowers with short credit history
new_segment = pd.DataFrame([
generate_loan_applicant('medium') for _ in range(n // 5)
])
new_segment['employment_years'] = np.random.uniform(0, 1, n // 5) # New grads
new_segment['credit_score'] = np.random.normal(640, 50, n // 5)
base_data = pd.concat([base_data.iloc[:n*4//5], new_segment])
elif shift_type == 'label_shift':
# Economic downturn: more applicants are high-risk than at training time
# Re-weight samples toward high-risk profiles
high_risk = [generate_loan_applicant('high') for _ in range(int(n * 0.6))]
medium_risk = [generate_loan_applicant('medium') for _ in range(int(n * 0.3))]
low_risk = [generate_loan_applicant('low') for _ in range(int(n * 0.1))]
base_data = pd.DataFrame(high_risk + medium_risk + low_risk)
return base_data
# Test model performance under distribution shift
from evidently.metric_preset import DataDriftPreset
from evidently.report import Report
for shift_type in ['feature_drift', 'covariate_shift', 'label_shift']:
shifted_data = generate_shifted_distribution(shift_type)
report = Report(metrics=[DataDriftPreset()])
report.run(reference_data=train_data, current_data=shifted_data)
drift_results = report.as_dict()
print(f"{shift_type}: drift detected = {drift_results['metrics'][0]['result']['dataset_drift']}")This lets you validate your drift detection system before production drift occurs.
Approach 3: Adversarial Synthetic Data
For models in security-sensitive applications (fraud detection, content moderation, identity verification), you need to test against adversarial inputs — examples specifically designed to fool the model.
from art.attacks.evasion import FastGradientMethod
from art.estimators.classification import SklearnClassifier
# Wrap your scikit-learn model for adversarial testing
sklearn_model = SklearnClassifier(model=fraud_detector)
# Generate adversarial examples using FGSM
attack = FastGradientMethod(estimator=sklearn_model, eps=0.1)
adversarial_examples = attack.generate(x=legitimate_transactions)
# Test: how many adversarial examples fool the model?
original_predictions = fraud_detector.predict(legitimate_transactions)
adversarial_predictions = fraud_detector.predict(adversarial_examples)
robustness_score = (original_predictions == adversarial_predictions).mean()
print(f"Model robustness under attack: {robustness_score:.2%}")For NLP models, synthetic adversarial examples might include:
- Typos and misspellings that preserve meaning but change token representations
- Paraphrases that say the same thing in structurally different ways
- Insertions of irrelevant context that shouldn't change classification
- Edge case Unicode characters (zero-width spaces, homoglyphs)
def generate_adversarial_text_variants(text, n=10):
"""Generate adversarial text variants for NLP model testing."""
variants = []
# Typo variants
for _ in range(n // 3):
variant = introduce_typo(text)
variants.append(("typo", variant))
# Paraphrase variants (using a paraphrase model)
paraphrases = paraphrase_model.generate(text, num_return_sequences=n // 3)
for p in paraphrases:
variants.append(("paraphrase", p))
# Unicode adversarial variants
for _ in range(n // 3):
variant = insert_zero_width_spaces(text)
variants.append(("unicode", variant))
return variants
# Test toxicity classifier on adversarial variants
toxic_text = "This content should be flagged as harmful"
variants = generate_adversarial_text_variants(toxic_text)
for variant_type, variant in variants:
prediction = toxicity_classifier.predict([variant])[0]
if prediction != 'toxic':
print(f"BYPASS: {variant_type} variant evaded classifier")
print(f" Input: {variant[:100]}...")Approach 4: Synthetic Data for Model Fairness Testing
Fairness evaluation requires testing model behavior across demographic subgroups. Real evaluation data often underrepresents minority groups. Synthetic data lets you generate balanced subgroup samples.
def generate_balanced_fairness_eval_set(n_per_group=500):
"""Generate balanced evaluation set for fairness testing."""
demographic_groups = [
{'gender': 'M', 'age_group': '18-30', 'ethnicity': 'white'},
{'gender': 'M', 'age_group': '18-30', 'ethnicity': 'black'},
{'gender': 'M', 'age_group': '18-30', 'ethnicity': 'hispanic'},
{'gender': 'F', 'age_group': '18-30', 'ethnicity': 'white'},
{'gender': 'F', 'age_group': '31-50', 'ethnicity': 'asian'},
# ... more groups
]
records = []
for group in demographic_groups:
for _ in range(n_per_group):
record = generate_loan_applicant('medium')
record.update(group)
records.append(record)
return pd.DataFrame(records)
eval_set = generate_balanced_fairness_eval_set()
# Check demographic parity
from fairlearn.metrics import demographic_parity_difference
predictions = model.predict(eval_set.drop(['gender', 'ethnicity', 'age_group', 'risk_tier'], axis=1))
dpd = demographic_parity_difference(
eval_set['risk_tier'],
predictions,
sensitive_features=eval_set['gender']
)
print(f"Demographic parity difference (gender): {dpd:.4f}")
# Threshold: |dpd| < 0.1 is generally acceptableImportant caveat: demographic variables (race, gender) should never be features in your model. They can be used in evaluation to detect bias in outcomes, but not as inputs to prediction.
Approach 5: Time-Series Synthetic Data
For forecasting and anomaly detection models, you need time-series evaluation data that includes known anomalies:
import numpy as np
import pandas as pd
def generate_synthetic_time_series(
n_days=365,
trend=0.01,
seasonality_period=7,
anomaly_rate=0.02,
noise_std=0.1
):
"""Generate synthetic time series with known anomalies."""
t = np.arange(n_days)
# Trend component
trend_component = trend * t
# Seasonality (weekly pattern)
seasonality = np.sin(2 * np.pi * t / seasonality_period) * 0.5
# Noise
noise = np.random.normal(0, noise_std, n_days)
# Base series
series = trend_component + seasonality + noise + 100 # offset
# Inject known anomalies
anomaly_indices = np.random.choice(n_days, int(n_days * anomaly_rate), replace=False)
anomaly_labels = np.zeros(n_days, dtype=bool)
for idx in anomaly_indices:
anomaly_magnitude = np.random.choice([-1, 1]) * np.random.uniform(3, 8) * noise_std
series[idx] += anomaly_magnitude
anomaly_labels[idx] = True
return pd.DataFrame({
'timestamp': pd.date_range('2025-01-01', periods=n_days),
'value': series,
'is_anomaly': anomaly_labels
})
# Generate and evaluate anomaly detector
test_series = generate_synthetic_time_series()
predictions = anomaly_detector.predict(test_series['value'])
precision, recall, f1, _ = precision_recall_fscore_support(
test_series['is_anomaly'],
predictions,
average='binary'
)
print(f"Anomaly detection — Precision: {precision:.3f}, Recall: {recall:.3f}, F1: {f1:.3f}")Integrating Synthetic ML Tests into CI/CD
ML model evaluation should run in CI just like unit tests. Synthetic eval sets make this practical:
# .github/workflows/model_eval.yml
jobs:
evaluate-model:
steps:
- name: Generate synthetic eval sets
run: python scripts/generate_eval_data.py --seed=42 --n=10000
- name: Run model evaluation
run: python scripts/evaluate_model.py --eval-data=eval_sets/
- name: Check performance thresholds
run: |
python scripts/check_thresholds.py \
--min-accuracy=0.92 \
--max-demographic-parity=0.05 \
--min-robustness=0.85
- name: Upload evaluation report
uses: actions/upload-artifact@v3
with:
name: model-evaluation-report
path: reports/eval_report.htmlThe --seed=42 ensures deterministic eval sets across CI runs. A model that passes on Monday will pass the same eval set on Friday unless the model changed.
Testing ML-Powered Features in End-to-End Tests
For applications where ML models power user-facing features, synthetic data feeds into end-to-end tests:
*** Test Cases ***
Credit Decision With High-Risk Synthetic Applicant
[Documentation] Verify that high-risk applicant receives appropriate response
As TestUser
Go To ${APP_URL}/apply/loan
Fill In credit_score=560
Fill In debt_to_income=0.65
Fill In employment_years=0.5
Submit Application
Verify Application declined message shown
Verify Explanation provided to applicant
Credit Decision With Low-Risk Synthetic Applicant
[Documentation] Verify that low-risk applicant receives approval
Fill In credit_score=780
Fill In debt_to_income=0.25
Fill In employment_years=12
Submit Application
Verify Application approvedWith HelpMeTest, these tests run continuously against your application, catching regressions when model updates change outcomes for specific input profiles. The synthetic data ensures you're testing specific, controlled scenarios — not whatever happens to be in your staging database.