Skip to content
SDB
Responsible AI

Chapter 05 · advanced · 35 min

Reliability & Safety

Robustness testing, adversarial attacks, uncertainty quantification, fail-safe design, and red teaming

Subhendu Datta BhowmikAI Tutorials

What Does Reliable AI Mean?

A reliable AI system performs as intended across the full range of expected conditions — and fails safely when it encounters conditions outside its design envelope.

Dimensions of Reliability

DimensionDefinitionExample Failure
AccuracyCorrect performance on in-distribution data94% accuracy drops to 70% on new data
RobustnessPerformance under perturbations and adversarial inputsFace recognition fooled by adversarial patch
CalibrationConfidence scores match actual accuracyModel says 95% confident but is right only 60% of the time
StabilityConsistent predictions across similar inputsSmall text change flips sentiment prediction
Reliability under shiftPerformance under distribution changeMedical model fails on patients from different demographics
Graceful degradationAcceptable behavior when uncertainHands off to human when confidence < threshold

ISO 42001 — A.9 (AI System Robustness)

Control A.9.3 requires that AI systems:

  • Are tested under conditions including edge cases and adversarial scenarios
  • Have mechanisms to detect and handle inputs outside the expected distribution
  • Provide appropriate outputs (including abstaining) when uncertainty is high
  • Are monitored continuously for performance degradation in production

Safety vs Security

SafetySecurity
Threat sourceEnvironmental, accidentalAdversarial, intentional
ExamplesDistribution shift, edge casesAdversarial attacks, model poisoning
DefenseRobustness testing, uncertainty quantificationAdversarial training, input sanitization
StandardsISO 42001, IEC 61508ISO 27001, NIST CSF

Adversarial Robustness

Attack Types

White-box attacks (attacker knows model architecture and weights):

  • FGSM (Fast Gradient Sign Method): xadv=x+ϵsign(xL(f(x),y))x_{adv} = x + \epsilon \cdot \text{sign}(\nabla_x \mathcal{L}(f(x), y))
  • PGD (Projected Gradient Descent): iterated FGSM with projection back onto the ϵ\epsilon-ball; strongest attack
  • C&W Attack: finds minimum perturbation that causes misclassification (constrained optimization)

Black-box attacks (attacker only has API access):

  • Transfer attacks: craft adversarial example against a surrogate model; often transfers
  • Query-based attacks: iteratively query the model and use output scores to estimate gradient (ZOO, SQUARE)
  • Boundary attacks: start from a misclassified example and move toward the original

Defenses

DefenseHow It WorksEffectiveness
Adversarial trainingInclude adversarial examples in training setStrongest known; computationally expensive
Input preprocessingFeature squeezing, image smoothing, JPEG compressionPartially effective; can be bypassed
Certified defensesProvable robustness bounds (randomized smoothing)Formal guarantees but accuracy cost
Ensemble methodsAggregate predictions from multiple modelsHarder to attack all simultaneously
Anomaly detectionReject inputs flagged as adversarialUseful but introduces new attack surface

Adversarial training objective (Madry et al.):

minθE(x,y)D[maxδSL(fθ(x+δ),y)]\min_\theta \mathbb{E}_{(x,y) \sim D} \left[ \max_{\delta \in \mathcal{S}} \mathcal{L}(f_\theta(x + \delta), y) \right]

The inner maximization finds the worst-case perturbation; the outer minimization trains the model to be robust against it.

Distribution Shift

Models trained on data from distribution PtrainP_{train} may encounter test data from PtestPtrainP_{test} \neq P_{train}:

  • Covariate shift: Ptrain(X)Ptest(X)P_{train}(X) \neq P_{test}(X) but P(YX)P(Y|X) unchanged — e.g., different demographics
  • Concept drift: P(YX)P(Y|X) changes — e.g., fraud patterns evolve after model deployment
  • Dataset shift: both change simultaneously

Detecting Shift

MethodApproachBest For
KS-testStatistical test on feature distributionsUnivariate continuous features
Population Stability Index (PSI)Binned distribution divergenceCredit/risk models, regulatory
MMD (Maximum Mean Discrepancy)Kernel-based distribution distanceHigh-dimensional embeddings
Embedding distanceCosine distance between rolling mean embeddingsText/image models
Drift detector (Evidently)Automated feature-level monitoringProduction deployment

PSI=i(Pprod,iPtrain,i)lnPprod,iPtrain,i\text{PSI} = \sum_i \left( P_{prod,i} - P_{train,i} \right) \cdot \ln \frac{P_{prod,i}}{P_{train,i}}

PSI < 0.1: no significant drift; 0.1–0.2: moderate drift (investigate); > 0.2: major drift (retrain).

Uncertainty Quantification

A model that says "I don't know" when it doesn't know is safer than one that confidently hallucinate answers.

Monte Carlo Dropout

During inference, keep dropout active and run TT forward passes — variance in predictions estimates uncertainty:

y^=1Tt=1Tfω^t(x)Uncertainty1Tt=1Ty^t2y^2\hat{y} = \frac{1}{T} \sum_{t=1}^T f^{\hat{\omega}_t}(x) \qquad \text{Uncertainty} \approx \frac{1}{T} \sum_{t=1}^T \hat{y}_t^2 - \hat{y}^2

Conformal Prediction

Conformal prediction provides statistically valid uncertainty sets: a set of predictions that contains the true label with probability 1α\geq 1 - \alpha (e.g., 90%), regardless of the true distribution:

  1. Compute non-conformity scores on calibration set: si=1f(xi)[yi]s_i = 1 - f(x_i)[y_i] (how "surprising" is the true label?)
  2. Find the (1α)(1-\alpha) quantile of scores: q^\hat{q}
  3. At test time, prediction set: C(x)={y:f(x)[y]1q^}C(x) = \{y : f(x)[y] \geq 1 - \hat{q}\}

This gives a rigorous coverage guarantee — no distributional assumptions needed.

Robustness Testing, Uncertainty Quantification, and Fail-Safe Designpython
# pip install torch torchvision adversarial-robustness-toolbox evidently

# ─── 1. FGSM Adversarial Attack ──────────────────────────────
import torch
import torch.nn as nn
import numpy as np
from torchvision import models, transforms
from PIL import Image

def fgsm_attack(model, loss_fn, image, label, epsilon=0.03):
    """Fast Gradient Sign Method adversarial attack."""
    image.requires_grad = True
    output = model(image)
    loss = loss_fn(output, label)
    model.zero_grad()
    loss.backward()
    perturbation = epsilon * image.grad.data.sign()
    adversarial = torch.clamp(image + perturbation, 0, 1)
    return adversarial

def pgd_attack(model, loss_fn, image, label, epsilon=0.03, alpha=0.01, num_steps=40):
    """Projected Gradient Descent — strongest first-order attack."""
    adv = image.clone().detach().requires_grad_(True)
    for _ in range(num_steps):
        output = model(adv)
        loss = loss_fn(output, label)
        loss.backward()
        with torch.no_grad():
            adv_update = adv + alpha * adv.grad.sign()
            # Project back onto epsilon-ball
            adv_update = image + torch.clamp(adv_update - image, -epsilon, epsilon)
            adv_update = torch.clamp(adv_update, 0, 1)
        adv = adv_update.detach().requires_grad_(True)
    return adv.detach()

# Simple CNN for demonstration
class SmallCNN(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(64 * 7 * 7, 256), nn.ReLU(), nn.Dropout(0.5),
            nn.Linear(256, num_classes),
        )
    def forward(self, x):
        return self.classifier(self.features(x))

model = SmallCNN()
loss_fn = nn.CrossEntropyLoss()

# Demo: measure accuracy under attack on a batch
batch_size = 32
x = torch.rand(batch_size, 1, 28, 28)  # synthetic MNIST-like
y = torch.randint(0, 10, (batch_size,))

with torch.no_grad():
    clean_preds = model(x).argmax(dim=1)
    clean_acc = (clean_preds == y).float().mean()
    print(f"Clean accuracy: {clean_acc:.3f}")

adv_x = fgsm_attack(model, loss_fn, x.clone().requires_grad_(True), y, epsilon=0.1)
with torch.no_grad():
    adv_preds = model(adv_x).argmax(dim=1)
    adv_acc = (adv_preds == y).float().mean()
    print(f"Accuracy under FGSM (ε=0.1): {adv_acc:.3f}")
    print(f"  Attack success rate: {1 - adv_acc:.3f}")

# ─── 2. Monte Carlo Dropout Uncertainty ──────────────────────
def mc_dropout_predict(model, x, n_passes=50):
    """Enable dropout during inference for uncertainty estimation."""
    model.train()  # Keep dropout active
    preds = torch.stack([
        torch.softmax(model(x), dim=1) for _ in range(n_passes)
    ])  # (n_passes, batch, classes)
    mean_pred = preds.mean(dim=0)          # Mean prediction
    uncertainty = preds.var(dim=0).sum(dim=1)  # Epistemic uncertainty
    return mean_pred, uncertainty

with torch.no_grad():
    mean_preds, uncertainties = mc_dropout_predict(model, x[:10])

print("\nMC Dropout Uncertainty Estimation:")
for i in range(5):
    pred_class = mean_preds[i].argmax().item()
    confidence = mean_preds[i].max().item()
    unc = uncertainties[i].item()
    flag = " ⚠ HIGH UNCERTAINTY" if unc > 0.3 else ""
    print(f"  Sample {i}: pred={pred_class}, conf={confidence:.3f}, uncertainty={unc:.4f}{flag}")

# ─── 3. Conformal Prediction ──────────────────────────────────
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
import numpy as np

X_all, y_all = make_classification(n_samples=2000, n_features=20, n_classes=3,
                                    n_informative=15, random_state=42)
X_train, X_temp, y_train, y_temp = train_test_split(X_all, y_all, test_size=0.4)
X_cal, X_test, y_cal, y_test = train_test_split(X_temp, y_temp, test_size=0.5)

clf = RandomForestClassifier(n_estimators=100, random_state=42)
clf.fit(X_train, y_train)

# Conformal calibration: compute non-conformity scores on calibration set
cal_probs = clf.predict_proba(X_cal)
cal_scores = 1 - cal_probs[np.arange(len(y_cal)), y_cal]  # how "wrong" the model is on true class

# At alpha=0.1 → 90% coverage guarantee
alpha = 0.1
q_hat = np.quantile(cal_scores, 1 - alpha)
print(f"\nConformal Prediction (α={alpha}, target coverage={100*(1-alpha):.0f}%):")
print(f"  Calibrated quantile q̂ = {q_hat:.4f}")

# Generate prediction sets for test instances
test_probs = clf.predict_proba(X_test)
prediction_sets = (test_probs >= 1 - q_hat)  # include class if score ≥ threshold

# Evaluate coverage and set size
covered = prediction_sets[np.arange(len(y_test)), y_test].mean()
avg_set_size = prediction_sets.sum(axis=1).mean()
print(f"  Empirical coverage: {covered:.4f} (should be ≥ {1-alpha:.2f})")
print(f"  Average prediction set size: {avg_set_size:.2f} (smaller = more informative)")

# Show examples
print("\n  Example prediction sets:")
for i in range(3):
    pset = [j for j in range(3) if prediction_sets[i, j]]
    correct = y_test[i] in pset
    print(f"    Sample {i}: set={pset}, true={y_test[i]}, covered={correct}")

# ─── 4. Fail-Safe Circuit Breaker Pattern ────────────────────
import time
from dataclasses import dataclass
from typing import Callable, Optional

class CircuitState:
    CLOSED = "CLOSED"       # Normal operation
    OPEN = "OPEN"           # Failing — reject requests, use fallback
    HALF_OPEN = "HALF_OPEN" # Testing recovery

@dataclass
class CircuitBreaker:
    """Circuit breaker for AI model inference — fail fast and safely."""
    name: str
    failure_threshold: int = 5     # failures before opening
    recovery_timeout: float = 60.0 # seconds before trying half-open
    success_threshold: int = 2     # successes in half-open before closing

    failure_count: int = 0
    success_count: int = 0
    state: str = CircuitState.CLOSED
    last_failure_time: float = 0.0

    def call(self, fn: Callable, fallback: Callable, *args, **kwargs):
        """Call fn; use fallback if circuit is open."""
        if self.state == CircuitState.OPEN:
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = CircuitState.HALF_OPEN
                print(f"  [Circuit {self.name}] HALF-OPEN — testing recovery")
            else:
                print(f"  [Circuit {self.name}] OPEN — using fallback")
                return fallback(*args, **kwargs)

        try:
            result = fn(*args, **kwargs)
            self._on_success()
            return result
        except Exception as e:
            self._on_failure(e)
            print(f"  [Circuit {self.name}] Error: {e} — using fallback")
            return fallback(*args, **kwargs)

    def _on_success(self):
        if self.state == CircuitState.HALF_OPEN:
            self.success_count += 1
            if self.success_count >= self.success_threshold:
                self.state = CircuitState.CLOSED
                self.failure_count = 0
                self.success_count = 0
                print(f"  [Circuit {self.name}] CLOSED — recovered")
        elif self.state == CircuitState.CLOSED:
            self.failure_count = 0  # reset on success

    def _on_failure(self, error):
        self.failure_count += 1
        self.last_failure_time = time.time()
        if self.failure_count >= self.failure_threshold:
            self.state = CircuitState.OPEN
            print(f"  [Circuit {self.name}] OPEN after {self.failure_count} failures")

# AI model with circuit breaker + uncertainty-based escalation
call_count = 0
def ai_model_predict(input_data: dict) -> dict:
    global call_count
    call_count += 1
    if call_count % 3 == 0:  # simulate occasional failures
        raise RuntimeError("Model inference timeout")
    uncertainty = np.random.uniform(0, 1)
    pred = np.random.randint(0, 2)
    return {"prediction": pred, "confidence": 1 - uncertainty, "uncertainty": uncertainty}

def rule_based_fallback(input_data: dict) -> dict:
    """Simple rule-based fallback when AI model fails."""
    return {"prediction": 0, "confidence": 0.5, "source": "FALLBACK_RULES", "escalate": True}

circuit = CircuitBreaker("LoanModel", failure_threshold=3, recovery_timeout=5.0)

UNCERTAINTY_THRESHOLD = 0.7
HUMAN_REVIEW_THRESHOLD = 0.5

print("\nFail-Safe AI System (circuit breaker + uncertainty escalation):")
for i in range(8):
    result = circuit.call(ai_model_predict, rule_based_fallback, {"applicant_id": f"APP-{i}"})
    if result.get("source") == "FALLBACK_RULES" or result.get("escalate"):
        action = "→ HUMAN REVIEW (fallback triggered)"
    elif result["uncertainty"] > UNCERTAINTY_THRESHOLD:
        action = f"→ HUMAN REVIEW (high uncertainty={result['uncertainty']:.2f})"
    elif result["confidence"] < HUMAN_REVIEW_THRESHOLD:
        action = f"→ CONFIDENCE CHECK (conf={result['confidence']:.2f})"
    else:
        action = f"→ AUTO-APPROVE (conf={result['confidence']:.2f})"
    print(f"  Request {i+1}: {action}")

Fail-Safe Design Patterns

Pattern 1: Uncertainty-Based Escalation

Define confidence thresholds for automatic decisions vs human review:

if model.confidence > 0.95:
    auto_approve()          # High confidence → automated
elif model.confidence > 0.70:
    assisted_review()       # Medium → human sees AI recommendation
else:
    human_review_required() # Low confidence → human decides independently

Pattern 2: Circuit Breaker

Automatically stop calling a failing model and switch to fallback logic — prevents cascading failures in production systems. States: Closed → Open (after threshold failures) → Half-Open (recovery test) → Closed.

Pattern 3: Shadow Mode Testing

Run a new model in parallel with the production model, logging its predictions without acting on them. Compare outputs before cutover. Reveals distributional differences and edge case behaviors safely.

Pattern 4: Staged Rollout (Canary)

  • 1%: monitor for errors, latency, confidence distribution
  • 10%: fairness check on representative sample
  • 50%: broader monitoring
  • 100%: full deployment if all metrics pass

Red Teaming

AI Red Teaming involves adversarial stress-testing by a dedicated team trying to break the system:

  • Functional: can we make the model fail on important cases?
  • Safety: can we elicit harmful outputs from an LLM?
  • Fairness: are there demographic groups where the model performs unacceptably?
  • Security: can we extract training data, reverse-engineer the model, or poison it?

ISO 42001 A.6.2.5 recommends adversarial testing as part of the AI system validation process.

Knowledge check

A medical AI system gives a high-confidence prediction (98%) that a patient's scan is benign. The patient is later found to have cancer. What reliability failure does this illustrate?

Summary

  • Adversarial attacks (FGSM, PGD) expose fundamental ML model vulnerabilities; adversarial training is the strongest known defense but computationally expensive
  • Distribution shift is the most common real-world reliability failure; use PSI and embedding distance for continuous monitoring
  • Uncertainty quantification (MC Dropout, conformal prediction) allows models to say "I don't know" — enabling safe escalation to human review
  • Conformal prediction provides formal coverage guarantees without distributional assumptions — critical for high-stakes applications
  • Fail-safe patterns (circuit breakers, staged rollouts, uncertainty thresholds) prevent cascading failures and ensure graceful degradation
  • Red teaming proactively finds failures before users do — ISO 42001 A.6.2.5 recommends adversarial testing for all high-risk systems

Next: Privacy & Security — protecting data throughout the AI lifecycle with differential privacy and federated learning.

Responsible AI