Executing a label flipping attack on a classifier
This article walks through executing a label flipping attack against the logistic regression classifier trained on the synthetic sentiment analysis dataset established in the previous article. It covers training the baseline model on clean data, implementing the attack function, evaluating its effects at poisoning rates from 10% to 50%, and analysing how the decision boundary shifts even when overall accuracy remains unchanged.
Training the baseline model
Before poisoning anything, we need a clean performance baseline. Logistic regression is a binary classifier that learns a weight vector w and bias b, then passes the linear combination z = w^T x + b through a sigmoid function to produce a probability between 0 and 1. The decision boundary sits where that probability equals 0.5, which is the line defined by w^T x + b = 0.
baseline_model = LogisticRegression(random_state=SEED)
baseline_model.fit(X_train, y_train)
y_pred_baseline = baseline_model.predict(X_test)
baseline_accuracy = accuracy_score(y_test, y_pred_baseline)
print(f"Baseline Model Accuracy: {baseline_accuracy:.4f}")
The baseline achieves 99.33% accuracy on the test set. This is the performance ceiling against which we measure every poisoned model.
To visualise how the model separates the two classes, we plot the decision boundary across the feature space. The function below generates a mesh grid, predicts the class at every point, and overlays the training data on top.
def plot_decision_boundary(model, X, y, title="Decision Boundary"):
h = 0.02
x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h))
Z = model.predict(np.c_[xx.ravel(), yy.ravel()])
Z = Z.reshape(xx.shape)
plt.figure(figsize=(12, 6))
plt.contourf(
xx, yy, Z, cmap=plt.cm.colors.ListedColormap([azure, nugget_yellow]), alpha=0.3
)
scatter = plt.scatter(
X[:, 0], X[:, 1], c=y,
cmap=plt.cm.colors.ListedColormap([azure, nugget_yellow]),
edgecolors=node_black, s=50, alpha=0.8,
)
plt.title(title, 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="Negative Sentiment (Class 0)",
markersize=10, markerfacecolor=azure),
plt.Line2D([0], [0], marker="o", color="w", label="Positive Sentiment (Class 1)",
markersize=10, markerfacecolor=nugget_yellow),
]
plt.legend(handles=handles, title="Classes")
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()
plot_decision_boundary(
baseline_model, X_train, y_train,
title=f"Baseline Model Decision Boundary\nAccuracy: {baseline_accuracy:.4f}",
)
The baseline boundary sits neatly between the two clusters. With well-separated synthetic data like this, logistic regression has no trouble drawing a clean line.
Why flipped labels corrupt the loss function
During training, logistic regression minimises binary cross-entropy (log-loss) across the training set. Each sample contributes a term to the total loss based on how well the model’s predicted probability matches that sample’s label.
When a sample truly belongs to Class 0 and is correctly labelled, the model learns to predict a low probability for Class 1. The loss contribution from that sample is small because the prediction aligns with the label. If an attacker flips that sample’s label to Class 1, the model now faces a conflict. The features still point toward Class 0, but the label says Class 1. The loss contribution from that single sample becomes large because the model’s natural prediction (low probability for Class 1) directly contradicts the corrupted label.
This large error signal forces the optimiser to adjust the weight vector and bias to accommodate the poisoned sample. Multiply this across enough flipped samples and the learned decision boundary shifts away from its optimal position. The model is trying to satisfy two contradictory demands simultaneously, fitting the correctly labelled data and fitting the poisoned data, and the result is a compromised boundary that satisfies neither perfectly.
Implementing the label flipping attack
The flip_labels function takes the original training labels and a poisoning percentage, randomly selects that fraction of samples, and inverts their labels. For binary labels, 1 - label handles the inversion.
def flip_labels(y, poison_percentage):
if not 0 <= poison_percentage <= 1:
raise ValueError("poison_percentage must be between 0 and 1.")
n_samples = len(y)
n_to_flip = int(n_samples * poison_percentage)
if n_to_flip == 0:
print("Warning: Poison percentage is 0 or too low to flip any labels.")
return y.copy(), np.array([], dtype=int)
rng_instance = np.random.default_rng(SEED)
flipped_indices = rng_instance.choice(n_samples, size=n_to_flip, replace=False)
y_poisoned = y.copy()
original_labels_at_flipped = y_poisoned[flipped_indices]
y_poisoned[flipped_indices] = np.where(original_labels_at_flipped == 0, 1, 0)
print(f"Flipping {n_to_flip} labels ({poison_percentage * 100:.1f}%).")
return y_poisoned, flipped_indices
The function works on a copy of the label array so the original y_train stays intact for comparison. It returns both the poisoned labels and the indices of the flipped samples, which is useful for visualisation later. The random number generator is seeded for reproducibility, meaning the same indices get selected on every run.
We also need a visualisation function to highlight which samples had their labels flipped. The plot_poisoned_data function plots all training points, colouring them by their new (possibly corrupted) label, and marks the flipped samples with a red-bordered X marker so they stand out from the untouched data.
def plot_poisoned_data(X, y_original, y_poisoned, flipped_indices,
title="Poisoned Data Visualization", target_class_info=None):
plt.figure(figsize=(12, 7))
mask_not_flipped = np.ones(len(y_poisoned), dtype=bool)
mask_not_flipped[flipped_indices] = False
plt.scatter(X[mask_not_flipped, 0], X[mask_not_flipped, 1],
c=y_poisoned[mask_not_flipped],
cmap=plt.cm.colors.ListedColormap([azure, nugget_yellow]),
edgecolors=node_black, s=50, alpha=0.6, label="Unchanged Label")
flipped_legend_label = (f"Flipped (Orig Class {target_class_info})"
if target_class_info is not None else "Flipped Label")
if len(flipped_indices) > 0:
plt.scatter(X[flipped_indices, 0], X[flipped_indices, 1],
c=y_poisoned[flipped_indices],
cmap=plt.cm.colors.ListedColormap([azure, nugget_yellow]),
edgecolors=malware_red, linewidths=1.5, marker="X",
s=100, alpha=0.9, label=flipped_legend_label)
plt.title(title, 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="Class 0 Point (Azure)",
markersize=10, markerfacecolor=azure, linestyle="None"),
plt.Line2D([0], [0], marker="o", color="w", label="Class 1 Point (Yellow)",
markersize=10, markerfacecolor=nugget_yellow, linestyle="None"),
plt.Line2D([0], [0], marker="X", color="w", label=flipped_legend_label,
markersize=12, markeredgecolor=malware_red,
markerfacecolor=hacker_grey, linestyle="None"),
]
plt.legend(handles=handles, title="Data Points")
plt.grid(True, color=hacker_grey, linestyle="--", linewidth=0.5, alpha=0.3)
plt.show()
Evaluating the label flipping attack
With the attack function in place, we can poison the training labels at increasing rates and measure the effect on the model trained on that corrupted data. The evaluation loop flips labels at 10%, 20%, 30%, 40%, and 50%, trains a fresh logistic regression model at each rate, and evaluates accuracy on the clean test set.
results = {
"percentage": [], "accuracy": [], "model": [],
"y_train_poisoned": [], "flipped_indices": [],
}
decision_boundaries_data = []
# Store baseline
results["percentage"].append(0.0)
results["accuracy"].append(baseline_accuracy)
results["model"].append(baseline_model)
results["y_train_poisoned"].append(y_train.copy())
results["flipped_indices"].append(np.array([], dtype=int))
# Pre-compute meshgrid for boundary plots
h = 0.02
x_min, x_max = X_train[:, 0].min() - 1, X_train[:, 0].max() + 1
y_min, y_max = X_train[:, 1].min() - 1, X_train[:, 1].max() + 1
xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h))
mesh_points = np.c_[xx.ravel(), yy.ravel()]
poison_percentages = [0.10, 0.20, 0.30, 0.40, 0.50]
for pp in poison_percentages:
print(f"\n--- Training with {pp * 100:.0f}% Poisoned Data ---")
y_train_poisoned, flipped_idx = flip_labels(y_train, pp)
poisoned_model = LogisticRegression(random_state=SEED)
poisoned_model.fit(X_train, y_train_poisoned)
y_pred_poisoned = poisoned_model.predict(X_test)
accuracy = accuracy_score(y_test, y_pred_poisoned)
print(f"Accuracy on clean test set: {accuracy:.4f}")
results["percentage"].append(pp)
results["accuracy"].append(accuracy)
results["model"].append(poisoned_model)
results["y_train_poisoned"].append(y_train_poisoned)
results["flipped_indices"].append(flipped_idx)
plot_poisoned_data(X_train, y_train, y_train_poisoned, flipped_idx,
title=f"Training Data with {pp * 100:.0f}% Flipped Labels")
plot_decision_boundary(poisoned_model, X_train, y_train_poisoned,
title=f"Decision Boundary ({pp * 100:.0f}% Poisoned)\nAccuracy: {accuracy:.4f}")
Z = poisoned_model.predict(mesh_points).reshape(xx.shape)
decision_boundaries_data.append({"percentage": pp, "Z": Z})
Each iteration generates two plots, one showing which samples were flipped and one showing the resulting decision boundary. The accuracy scores and boundary predictions are stored for the aggregate analysis below.
Plotting accuracy against poisoning percentage reveals the degradation curve.
plt.figure(figsize=(8, 5))
plot_data_sorted = sorted(zip(results["percentage"], results["accuracy"]))
plot_percentages = [p * 100 for p, a in plot_data_sorted]
plot_accuracies = [a for p, a in plot_data_sorted]
plt.plot(plot_percentages, plot_accuracies, marker="o", linestyle="-",
color=htb_green, markersize=8)
plt.title("Model Accuracy vs. Label Flipping Percentage", fontsize=16, color=htb_green)
plt.xlabel("Percentage of Training Labels Flipped (%)", fontsize=12)
plt.ylabel("Accuracy on Clean Test Set", fontsize=12)
plt.xticks(plot_percentages)
plt.ylim(0, 1.05)
plt.grid(True, color=hacker_grey, linestyle="--", linewidth=0.5, alpha=0.3)
plt.show()
With this synthetic dataset, accuracy holds at 99.33% all the way through 40% poisoning and only collapses at 50%. This is a consequence of the data being so well-separated. The two clusters sit far enough apart that even with a substantial number of labels flipped, the model still finds a boundary that correctly classifies the test set. In a real-world dataset where classes overlap or the feature space is noisier, the accuracy degradation would begin much earlier.
The boundary shift under the surface
The accuracy curve tells a misleading story if read in isolation. While the test accuracy stays flat, the decision boundary is moving at every poisoning rate. Overlaying all boundaries on a single plot makes this visible.
plt.figure(figsize=(12, 8))
plt.scatter(X_train[:, 0], X_train[:, 1], c=y_train,
cmap=plt.cm.colors.ListedColormap([azure, nugget_yellow]),
edgecolors=node_black, s=50, alpha=0.5, label="Clean Data Points")
contour_colors = {0.0: htb_green, 0.10: aquamarine, 0.20: nugget_yellow,
0.30: vivid_purple, 0.40: azure, 0.50: malware_red}
contour_linestyles = {0.0: "solid", 0.10: "dashed", 0.20: "dashed",
0.30: "dashed", 0.40: "dashed", 0.50: "dashed"}
# Baseline boundary
Z_baseline = baseline_model.predict(mesh_points).reshape(xx.shape)
plt.contour(xx, yy, Z_baseline, levels=[0.5], colors=[contour_colors[0.0]],
linestyles=[contour_linestyles[0.0]], linewidths=[2.5])
# Poisoned boundaries
decision_boundaries_data.sort(key=lambda item: item["percentage"])
plotted_percentages = [0.0]
for data in decision_boundaries_data:
pp = data["percentage"]
plt.contour(xx, yy, data["Z"], levels=[0.5], colors=[contour_colors[pp]],
linestyles=[contour_linestyles[pp]], linewidths=[2.5])
plotted_percentages.append(pp)
plt.title("Shift in Decision Boundary with Increasing Label Flipping",
fontsize=16, color=htb_green)
plt.xlabel("Feature 1", fontsize=12)
plt.ylabel("Feature 2", fontsize=12)
legend_handles = [plt.Line2D([0], [0], color=contour_colors[pp], lw=2.5,
linestyle=contour_linestyles[pp],
label=f"Boundary ({pp * 100:.0f}% Poisoned)")
for pp in sorted(plotted_percentages)]
data_handles = [
plt.Line2D([0], [0], marker="o", color="w", label="Class 0", markersize=10,
markerfacecolor=azure, linestyle="None"),
plt.Line2D([0], [0], marker="o", color="w", label="Class 1", markersize=10,
markerfacecolor=nugget_yellow, linestyle="None"),
]
plt.legend(handles=legend_handles + data_handles, title="Boundaries & Data")
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 overlay shows each boundary progressively rotating away from the baseline position as the model accommodates more poisoned samples. At 50%, the boundary has shifted far enough to start misclassifying test samples from the minority region. On noisier, real-world data where the classes already overlap, this kind of boundary migration would cause accuracy loss at much lower poisoning rates, potentially as low as 5-10% depending on the dataset.
This is the practical takeaway from random label flipping. Accuracy is a trailing indicator. The model’s internal representation is already degraded before the accuracy metric reflects it, and a defender who monitors only accuracy may not notice the drift until the boundary has moved far enough to produce visible misclassifications.
The next article covers the targeted variant of this attack, where labels are flipped only within a specific class to cause predictable misclassifications rather than general degradation.