Targeted label attacks in data poisoning

Where random label flipping degrades a model’s overall accuracy, a targeted label attack aims for something more precise. The adversary selects samples from a specific class and flips their labels to cause predictable misclassifications of that class, rather than general performance degradation. This article implements a targeted label attack against the sentiment analysis classifier from the previous articles, evaluates the class-specific damage using confusion matrices and per-class metrics, and validates that the attack transfers to unseen data.

How targeted label attacks differ from random flipping

In random label flipping, the adversary selects samples across both classes and flips them indiscriminately. The model’s decision boundary shifts in unpredictable ways, and the primary effect is a general loss of reliability. The adversary does not control which class suffers more.

A targeted label attack is more deliberate. The adversary picks a specific class (the target) and flips a percentage of only that class’s labels to the opposing class. In the sentiment analysis scenario, an adversary targeting Class 1 (positive reviews) would flip a fraction of those positive labels to Class 0 (negative). The Class 0 samples stay untouched.

The effect on the loss function is asymmetric. When the model processes a poisoned sample whose features clearly indicate Class 1 but whose label says Class 0, the model predicts a high probability for Class 1 (because the features warrant it), but the corrupted label penalises that prediction heavily. The resulting error signal pushes the weight vector and bias to shift the decision boundary specifically into the Class 1 region, causing the model to reclassify genuine Class 1 instances as Class 0. The boundary moves in a controlled direction that the adversary can anticipate.

Implementing the targeted attack

The targeted_flip_labels function takes the original labels, a poisoning percentage, the class to target, and the class to assign to the flipped samples. The poisoning percentage applies only to samples of the target class, not the entire dataset.

def targeted_flip_labels(y, poison_percentage, target_class, new_class, seed=1337):
    if not 0 <= poison_percentage <= 1:
        raise ValueError("poison_percentage must be between 0 and 1.")
    if target_class == new_class:
        raise ValueError("target_class and new_class cannot be the same.")
    unique_labels = np.unique(y)
    if target_class not in unique_labels:
        raise ValueError(f"target_class ({target_class}) does not exist in y.")
    if new_class not in unique_labels:
        raise ValueError(f"new_class ({new_class}) does not exist in y.")

    target_indices = np.where(y == target_class)[0]
    n_target_samples = len(target_indices)

    if n_target_samples == 0:
        print(f"Warning: No samples found for target_class {target_class}.")
        return y.copy(), np.array([], dtype=int)

    n_to_flip = int(n_target_samples * poison_percentage)

    if n_to_flip == 0:
        print(f"Warning: Poison percentage too low to flip any labels.")
        return y.copy(), np.array([], dtype=int)

    rng_instance = np.random.default_rng(seed)
    indices_within_target_set_to_flip = rng_instance.choice(
        n_target_samples, size=n_to_flip, replace=False
    )
    flipped_indices = target_indices[indices_within_target_set_to_flip]

    y_poisoned = y.copy()
    y_poisoned[flipped_indices] = new_class

    print(f"Targeting Class {target_class} -> Class {new_class}.")
    print(f"Flipped {len(flipped_indices)} of {n_target_samples} "
          f"Class {target_class} samples.")

    return y_poisoned, flipped_indices

The key difference from the random flip_labels function is the scoping. np.where(y == target_class) isolates only the samples belonging to the target class, and the random selection draws from that subset. The poisoning percentage is relative to the target class size, not the total dataset size. Flipping 40% of Class 1 means 40% of Class 1 samples specifically, which is roughly 20% of the total training set if the classes are balanced.

Executing the targeted attack

We target Class 1 (positive sentiment) and flip 40% of its labels to Class 0 (negative). This simulates an adversary who wants the model to systematically misclassify positive reviews as negative.

poison_percentage_targeted = 0.40
target_class_to_flip = 1
new_label_for_flipped = 0

y_train_targeted_poisoned, targeted_flipped_indices = targeted_flip_labels(
    y_train, poison_percentage_targeted,
    target_class_to_flip, new_label_for_flipped, seed=SEED,
)

plot_poisoned_data(
    X_train, y_train, y_train_targeted_poisoned, targeted_flipped_indices,
    title=f"Training Data: {poison_percentage_targeted * 100:.0f}% of "
          f"Class {target_class_to_flip} Flipped to {new_label_for_flipped}",
    target_class_info=target_class_to_flip,
)

The flipped points (red X markers) all sit within the original Class 1 cluster but now carry Class 0 labels. From the model’s perspective, there is a pocket of samples deep in Class 1 territory that claim to be Class 0, which creates a strong pull on the decision boundary toward the Class 1 region.

targeted_poisoned_model = LogisticRegression(random_state=SEED)
targeted_poisoned_model.fit(X_train, y_train_targeted_poisoned)

Evaluating the targeted attack

The evaluation focuses on class-specific metrics rather than aggregate accuracy, because the whole point of a targeted attack is to degrade performance on a specific class while potentially leaving the other class untouched.

y_pred_targeted = targeted_poisoned_model.predict(X_test)
targeted_accuracy = accuracy_score(y_test, y_pred_targeted)
print(f"Accuracy on clean test set: {targeted_accuracy:.4f}")
print(f"Baseline accuracy was: {baseline_accuracy:.4f}")

print("\nClassification Report on Clean Test Set:")
print(classification_report(y_test, y_pred_targeted,
                            target_names=["Class 0", "Class 1"]))

cm_targeted = confusion_matrix(y_test, y_pred_targeted)
plt.figure(figsize=(6, 5))
sns.heatmap(cm_targeted, annot=True, fmt="d", cmap="binary",
            xticklabels=["Predicted 0", "Predicted 1"],
            yticklabels=["Actual 0", "Actual 1"], cbar=False)
plt.xlabel("Predicted Label", color=white)
plt.ylabel("True Label", color=white)
plt.title("Confusion Matrix (Targeted Poisoned Model)",
          fontsize=14, color=htb_green)
plt.xticks(color=hacker_grey)
plt.yticks(color=hacker_grey)
plt.show()

The output tells the story.

Accuracy on clean test set: 0.8100
Baseline accuracy was: 0.9933

Classification Report on Clean Test Set:
              precision    recall  f1-score   support

     Class 0       0.73      1.00      0.84       153
     Class 1       1.00      0.61      0.76       147

    accuracy                           0.81       300
   macro avg       0.86      0.81      0.80       300
weighted avg       0.86      0.81      0.80       300

Overall accuracy dropped from 99.33% to 81.00%, but the damage distribution is asymmetric. Class 0 recall is 1.00, meaning every genuine negative review is still classified correctly. Class 1 recall fell to 0.61, meaning 39% of genuinely positive reviews are now misclassified as negative. The confusion matrix shows 57 false negatives (true Class 1 predicted as Class 0) against zero false positives.

This is exactly what the adversary wanted. The model still looks partially functional, it still gets 81% of predictions right, but it systematically under-reports positive sentiment. In the business scenario, the company would see a stream of negative classifications for a product that customers actually like.

Boundary comparison

Plotting the baseline and poisoned boundaries on the same axes shows how far the boundary has shifted.

plt.figure(figsize=(12, 8))

Z_baseline = baseline_model.predict(mesh_points).reshape(xx.shape)
plt.contour(xx, yy, Z_baseline, levels=[0.5], colors=[htb_green],
            linestyles=["solid"], linewidths=[2.5])

Z_targeted = targeted_poisoned_model.predict(mesh_points).reshape(xx.shape)
plt.contour(xx, yy, Z_targeted, levels=[0.5], colors=[malware_red],
            linestyles=["dashed"], linewidths=[2.5])

plt.title("Comparison: Baseline vs. Targeted Poisoned Decision Boundary",
          fontsize=16, color=htb_green)
plt.xlabel("Feature 1", fontsize=12)
plt.ylabel("Feature 2", fontsize=12)
handles = [
    plt.Line2D([0], [0], color=htb_green, lw=2.5, linestyle="solid",
               label=f"Baseline Boundary (Acc: {baseline_accuracy:.3f})"),
    plt.Line2D([0], [0], color=malware_red, lw=2.5, linestyle="dashed",
               label=f"Targeted Poisoned Boundary (Acc: {targeted_accuracy:.3f})"),
]
plt.legend(handles=handles, title="Boundaries")
plt.grid(True, color=hacker_grey, linestyle="--", linewidth=0.5, alpha=0.3)
plt.xlim(xx.min(), xx.max())
plt.ylim(yy.min(), yy.max())
plt.show()

The poisoned boundary has moved deep into the Class 1 region. Everything between the two lines is territory that the baseline model correctly assigned to Class 1 but the poisoned model now assigns to Class 0.

Validating against unseen data

The true test of whether the attack generalised or simply overfitted to the training set is how the poisoned model performs on completely new data. We generate 500 fresh samples from the same distribution (with a slightly higher cluster_std of 1.50 for more spread) using a different random seed.

n_unseen_samples = 500
unseen_seed = SEED + 1337

X_unseen, y_unseen = make_blobs(
    n_samples=n_unseen_samples, centers=centers,
    n_features=2, cluster_std=1.50, random_state=unseen_seed,
)

y_pred_unseen_poisoned = targeted_poisoned_model.predict(X_unseen)

true_target_class_indices = np.where(y_unseen == target_class_to_flip)[0]
misclassified_target_mask = ((y_unseen == target_class_to_flip) &
                             (y_pred_unseen_poisoned != target_class_to_flip))
misclassified_target_indices = np.where(misclassified_target_mask)[0]
n_true_target = len(true_target_class_indices)
n_misclassified_target = len(misclassified_target_indices)

plt.figure(figsize=(12, 8))
plt.scatter(X_unseen[:, 0], X_unseen[:, 1], c=y_pred_unseen_poisoned,
            cmap=plt.cm.colors.ListedColormap([azure, nugget_yellow]),
            edgecolors=node_black, s=50, alpha=0.7, label="Predicted Label")

if n_misclassified_target > 0:
    plt.scatter(X_unseen[misclassified_target_indices, 0],
                X_unseen[misclassified_target_indices, 1],
                facecolors="none", edgecolors=malware_red, linewidths=1.5,
                marker="X", s=120,
                label=f"Misclassified (True Class {target_class_to_flip})")

Z_targeted_boundary = targeted_poisoned_model.predict(mesh_points).reshape(xx.shape)
plt.contour(xx, yy, Z_targeted_boundary, levels=[0.5], colors=[malware_red],
            linestyles=["dashed"], linewidths=[2.5])

plt.title(f"Poisoned Model on Unseen Data\n"
          f"({n_misclassified_target} of {n_true_target} "
          f"Class {target_class_to_flip} samples misclassified)",
          fontsize=16, color=htb_green)
plt.xlabel("Feature 1", fontsize=12)
plt.ylabel("Feature 2", fontsize=12)
handles = [
    plt.Line2D([0], [0], marker="o", color="w", label="Predicted as Class 0",
               markersize=10, markerfacecolor=azure, linestyle="None"),
    plt.Line2D([0], [0], marker="o", color="w", label="Predicted as Class 1",
               markersize=10, markerfacecolor=nugget_yellow, linestyle="None"),
    *([ plt.Line2D([0], [0], marker="X", color="w",
                   label=f"Misclassified (True Class {target_class_to_flip})",
                   markersize=12, markeredgecolor=malware_red,
                   markerfacecolor="none", linestyle="None")
      ] if n_misclassified_target > 0 else []),
    plt.Line2D([0], [0], color=malware_red, lw=2.5, linestyle="dashed",
               label="Decision Boundary (Targeted Model)"),
]
plt.legend(handles=handles, title="Predictions, Errors & Boundary")
plt.grid(True, color=hacker_grey, linestyle="--", linewidth=0.5, alpha=0.3)
plt.xlim(xx.min(), xx.max())
plt.ylim(yy.min(), yy.max())
plt.show()

The misclassified samples (red X markers) sit within the true Class 1 cluster but fall on the Class 0 side of the shifted boundary. The attack transfers cleanly to data the model has never seen, confirming that the boundary shift is a learned structural change in the model’s parameters, not an artefact of overfitting to the poisoned training samples.

Practical implications

The targeted attack is more dangerous than random label flipping for two reasons. First, it produces predictable, directional misclassifications rather than random noise. An adversary can anticipate which class will suffer and plan accordingly. Second, overall accuracy (81%) can remain high enough that automated monitoring may not flag the model as compromised, while the class-specific recall for the targeted class (61%) tells a completely different story. Defenders who rely solely on aggregate accuracy as a health metric will miss targeted attacks entirely. Per-class recall, confusion matrices, and regular comparison against held-out validation sets are the metrics that surface this kind of damage.

Leave a Reply

Your email address will not be published. Required fields are marked *

RELATED

Executing a label flipping attack on a classifier

Step-by-step label flipping attack walkthrough on a logistic regression classifier, evaluating accuracy degradation and decision boundary shift at 10% to…

Label flipping attacks in data poisoning

How label flipping data poisoning works, where it fits in the AI attack surface, and a practical Python walkthrough building…

AI data attacks across the pipeline

Walk through AI data attacks stage by stage, from data poisoning at collection through storage tampering, processing manipulation, and online…

The AI data pipeline and its attack surface

Map the AI data pipeline stage by stage and learn where each creates attack surface, from data collection through to…