AW Dev Rethought

βœ‚οΈ Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away - Antoine de Saint-ExupΓ©ry

πŸ” AI/ML Security Lab – ⚠️ Adversarial Examples 🐍


Description:

Machine learning models can achieve high accuracy on normal test data, but that does not necessarily mean they are robust against manipulated inputs.

Adversarial examples are inputs that have been deliberately modified to influence a model's prediction. Even relatively small changes to input features can sometimes push an input across a model's learned decision boundary and result in a different classification.

In this first AI/ML Security Lab, we'll use Python and Scikit-learn to build a simple classifier, establish its normal behaviour, simulate controlled input manipulation, observe the impact, and introduce a basic defensive validation layer.


🎯 Understanding Adversarial Examples

A machine learning model learns patterns and decision boundaries from its training data.

During normal inference, new input is evaluated against those learned patterns to produce a prediction. But an attacker may deliberately manipulate input values in an attempt to make the model behave differently.

The basic idea is:

Original Input β†’ Correct Prediction

Manipulated Input β†’ Different Prediction

The model itself hasn't changed. Only the input presented to it has.

This makes adversarial examples particularly interesting from a security perspective because a model that performs well under normal conditions may behave very differently when its inputs are intentionally manipulated.


🟒 Building the Baseline Model

We'll use the Iris dataset, which contains measurements for three species of Iris flowers.

After loading the dataset, we split it into training and testing sets and standardise the feature values.

iris = load_iris()

X = pd.DataFrame(iris.data, columns=iris.feature_names)
y = pd.Series(iris.target)

X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size=0.25,
    random_state=42,
    stratify=y
)

scaler = StandardScaler()

X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

We then train a Logistic Regression classifier and calculate its baseline accuracy.

model = LogisticRegression(max_iter=1000)
model.fit(X_train_scaled, y_train)

baseline_predictions = model.predict(X_test_scaled)
baseline_accuracy = accuracy_score(y_test, baseline_predictions)

print(f"Baseline Accuracy: {baseline_accuracy:.2%}")

This gives us a reference point for how the model behaves before any adversarial manipulation is introduced.


πŸ” Selecting a Target Input

Instead of modifying the entire test dataset, we'll select a sample that the model originally classified correctly.

correct_indices = np.where(
    baseline_predictions == y_test.to_numpy()
)[0]

target_index = correct_indices[0]

original_sample = X_test.iloc[target_index].copy()

We can then record the original prediction and prediction confidence.

original_scaled = scaler.transform(
    original_sample.to_frame().T
)

original_prediction = model.predict(original_scaled)[0]
original_confidence = model.predict_proba(original_scaled).max()

At this point, we have our baseline input and know exactly how the model normally classifies it.


πŸ”΄ Simulating an Adversarial Input

Now we deliberately modify selected features of the sample.

We'll gradually increase the petal length and petal width values and check whether the classifier's prediction changes.

feature_ranges = X.max() - X.min()
perturbation_levels = np.linspace(0.01, 0.50, 50)

for level in perturbation_levels:
    candidate = original_sample.copy()

    candidate["petal length (cm)"] += (
        feature_ranges["petal length (cm)"] * level
    )

    candidate["petal width (cm)"] += (
        feature_ranges["petal width (cm)"] * level
    )

    candidate_scaled = scaler.transform(
        candidate.to_frame().T
    )

    candidate_prediction = model.predict(candidate_scaled)[0]

    if candidate_prediction != original_prediction:
        adversarial_sample = candidate
        adversarial_prediction = candidate_prediction
        used_perturbation = level
        break

This is a controlled educational demonstration rather than a sophisticated adversarial attack algorithm.

The important idea is that we're deliberately searching for an input modification capable of changing the model's decision.


πŸ” Observing the Impact

Once a prediction change is found, we can compare the original and manipulated inputs.

comparison = pd.DataFrame({
    "Original": original_sample,
    "Adversarial": adversarial_sample,
    "Difference": adversarial_sample - original_sample
})

print(comparison)

We can also compare the predictions:

print(f"Original Prediction: {iris.target_names[original_prediction]}")
print(f"Adversarial Prediction: {iris.target_names[adversarial_prediction]}")

This illustrates an important ML security principle:

Correct behaviour on normal test data doesn't guarantee robust behaviour under intentionally manipulated inputs.

Traditional model evaluation primarily measures predictive performance. Security testing asks an additional question: How does the model behave when someone deliberately tries to make it fail?


πŸ›‘οΈ Adding a Defensive Layer

One basic defence is to validate incoming feature values before allowing them to reach the model.

We can determine the minimum and maximum values observed in the training data:

training_min = X_train.min()
training_max = X_train.max()

Then create a validation function:

def validate_input(sample):
    violations = []

    for feature in X.columns:
        if sample[feature] < training_min[feature] or sample[feature] > training_max[feature]:
            violations.append(feature)

    return violations

An input containing unexpected values can then be flagged for additional handling rather than automatically being trusted.


πŸ§ͺ Is Input Validation Enough?

No.

Range validation is intentionally a simple defensive example for this lab.

An adversarial input may remain completely within expected feature ranges while still causing an incorrect prediction. Real-world adversarial ML defenses can involve techniques such as adversarial training, robustness testing, anomaly detection, preprocessing, monitoring, and model-specific defensive strategies.

This distinction is important: adding one validation rule does not make an ML model "adversarially secure."

Instead, the validation layer demonstrates a broader security principle:

Inputs reaching an ML model should be treated as untrusted data.


πŸ“Œ Security Takeaways

  • Adversarial examples deliberately manipulate model inputs to influence predictions.
  • A model can perform well on ordinary test data while still being sensitive to manipulated inputs.
  • Comparing baseline and adversarial behaviour helps reveal model weaknesses.
  • Input validation can detect certain suspicious inputs but cannot prevent every adversarial example.
  • ML security requires evaluating robustness and security, not only model accuracy.

πŸš€ Final Note

Machine learning security begins by recognising that models operate in environments where inputs may not always be trustworthy.

By testing a model under controlled adversarial conditions, we can better understand its weaknesses and design defences around those risks.


Code Snippet:

import numpy as np
import pandas as pd

from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler


def load_dataset():
    """Load the Iris dataset."""
    iris = load_iris()

    X = pd.DataFrame(iris.data, columns=iris.feature_names)
    y = pd.Series(iris.target)

    print(f"βœ… Dataset loaded | samples={len(X)} | features={X.shape[1]}")
    return iris, X, y


def prepare_data(X, y):
    """Split and standardize the dataset."""
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.25, random_state=42, stratify=y
    )

    scaler = StandardScaler()
    X_train_scaled = scaler.fit_transform(X_train)
    X_test_scaled = scaler.transform(X_test)

    print(f"βœ… Data prepared | training={len(X_train)} | testing={len(X_test)}")
    return X_train, X_test, y_train, y_test, X_train_scaled, X_test_scaled, scaler


def train_baseline_model(X_train_scaled, y_train):
    """Train the baseline Logistic Regression model."""
    model = LogisticRegression(max_iter=1000)
    model.fit(X_train_scaled, y_train)

    print("βœ… Baseline model trained")
    return model


def evaluate_baseline(model, X_test_scaled, y_test):
    """Evaluate normal model performance."""
    predictions = model.predict(X_test_scaled)
    accuracy = accuracy_score(y_test, predictions)

    print(f"πŸ“Š Baseline Accuracy: {accuracy:.2%}")
    return predictions


def select_target_sample(X_test, y_test, predictions, scaler, model, iris):
    """Select a correctly classified sample for the adversarial test."""
    correct_indices = np.where(predictions == y_test.to_numpy())[0]

    if len(correct_indices) == 0:
        raise ValueError("No correctly classified samples are available.")

    target_index = correct_indices[0]
    original_sample = X_test.iloc[target_index].copy()
    original_scaled = scaler.transform(original_sample.to_frame().T)

    original_prediction = model.predict(original_scaled)[0]
    original_confidence = model.predict_proba(original_scaled).max()
    actual_class = y_test.iloc[target_index]

    print("\n🎯 Target Sample")
    print(original_sample)
    print(f"\nActual Class: {iris.target_names[actual_class]}")
    print(f"Predicted Class: {iris.target_names[original_prediction]}")
    print(f"Confidence: {original_confidence:.2%}")

    return original_sample, original_prediction


def create_adversarial_sample(original_sample, original_prediction, X, scaler, model):
    """Create controlled input variations until the prediction changes."""
    feature_ranges = X.max() - X.min()
    perturbation_levels = np.linspace(0.01, 0.50, 50)

    for level in perturbation_levels:
        candidate = original_sample.copy()
        candidate["petal length (cm)"] += feature_ranges["petal length (cm)"] * level
        candidate["petal width (cm)"] += feature_ranges["petal width (cm)"] * level

        candidate_scaled = scaler.transform(candidate.to_frame().T)
        candidate_prediction = model.predict(candidate_scaled)[0]

        if candidate_prediction != original_prediction:
            print(f"\nπŸ”΄ Adversarial input found | perturbation={level:.2%}")
            return candidate, candidate_prediction, level

    print("\n⚠️ No prediction change was found within the tested perturbation range.")
    return None, original_prediction, None


def observe_impact(original_sample, adversarial_sample, original_prediction, adversarial_prediction, used_perturbation, iris):
    """Compare the original and adversarial inputs."""
    if adversarial_sample is None:
        print("No adversarial sample available for comparison.")
        return

    comparison = pd.DataFrame({
        "Original": original_sample,
        "Adversarial": adversarial_sample,
        "Difference": adversarial_sample - original_sample,
    })

    print("\nπŸ” Adversarial Impact")
    print(comparison)
    print(f"\nPerturbation Level: {used_perturbation:.2%}")
    print(f"Original Prediction: {iris.target_names[original_prediction]}")
    print(f"Adversarial Prediction: {iris.target_names[adversarial_prediction]}")


def validate_input(sample, training_min, training_max, features):
    """Check whether input values fall outside the training range."""
    violations = []

    for feature in features:
        if sample[feature] < training_min[feature] or sample[feature] > training_max[feature]:
            violations.append(feature)

    return violations


def verify_defense(adversarial_sample, X_train, features):
    """Validate the adversarial sample using expected feature ranges."""
    if adversarial_sample is None:
        print("\n⚠️ No adversarial sample available for validation.")
        return

    training_min = X_train.min()
    training_max = X_train.max()
    violations = validate_input(adversarial_sample, training_min, training_max, features)

    print("\nπŸ›‘οΈ Defense Verification")

    if violations:
        print("⚠️ Suspicious input detected.")
        print(f"Features outside expected range: {violations}")
    else:
        print("βœ… Input passed range validation.")
        print("Additional adversarial defenses may still be required.")


def run_lab():
    """Run the complete adversarial examples security lab."""
    print("πŸ” Starting AI/ML Security Lab β€” Adversarial Examples\n")

    iris, X, y = load_dataset()

    X_train, X_test, y_train, y_test, X_train_scaled, X_test_scaled, scaler = prepare_data(X, y)

    model = train_baseline_model(X_train_scaled, y_train)
    predictions = evaluate_baseline(model, X_test_scaled, y_test)

    original_sample, original_prediction = select_target_sample(
        X_test, y_test, predictions, scaler, model, iris
    )

    adversarial_sample, adversarial_prediction, used_perturbation = create_adversarial_sample(
        original_sample, original_prediction, X, scaler, model
    )

    observe_impact(
        original_sample,
        adversarial_sample,
        original_prediction,
        adversarial_prediction,
        used_perturbation,
        iris,
    )

    verify_defense(adversarial_sample, X_train, X.columns)

    print("\nβœ… AI/ML Security Lab completed successfully")


if __name__ == "__main__":
    run_lab()

Link copied!

Comments

Add Your Comment

Comment Added!