Synthetic Data for ML Model Testing and Evaluation

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 acceptable

Important 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.html

The --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 approved

With 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.

Read more

Start now free