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.