2025, Nov 27 23:00
Clean ConfusionMatrixDisplay grids in matplotlib: shared axis labels, no duplicate labels, fig.supxlabel/fig.supylabel
Learn how to plot multiple scikit-learn ConfusionMatrixDisplay subplots with shared axis labels in matplotlib using fig.supxlabel/fig.supylabel drop duplicates
When you place multiple scikit-learn ConfusionMatrixDisplay plots in a matplotlib subplot grid, each panel renders its own axis labels. That makes shared labels via fig.supxlabel and fig.supylabel look duplicated rather than truly shared. On top of that, rcParams for tick label sizes may not affect what ConfusionMatrixDisplay draws the way you expect. The goal is simple: four confusion matrices, one common “Predicted label” at the bottom, one common “True label” on the left, and readable label sizes.
Example that illustrates the issue
The snippet below builds a 2×2 grid of confusion matrices with titles. It attempts to control tick label sizes via rc and to rely on per-axes defaults, resulting in repeated axis labels around all four panels.
import matplotlib as mpl
import matplotlib.pyplot as plt
from sklearn.metrics import ConfusionMatrixDisplay, confusion_matrix
# Assume truth_vec and bin_preds are already defined and aligned
# truth_vec: 1D array-like of ground-truth labels
# bin_preds: dict with keys 'NSflow', 'capacity', 'cost', 'efficiency' and 1D array-like predictions
mpl.rc('xtick', labelsize=6)
mpl.rc('ytick', labelsize=6)
chart, panels = plt.subplots(2, 2, figsize=(8, 8), sharex=True, sharey=True)
chart.suptitle('Confusion Matrix')
panels[0, 0].set_title('NS Flow', fontsize=8)
panels[0, 1].set_title('Capacity', fontsize=8)
panels[1, 0].set_title('Cost', fontsize=8)
panels[1, 1].set_title('Efficiency', fontsize=8)
ConfusionMatrixDisplay(
confusion_matrix=confusion_matrix(truth_vec, bin_preds['NSflow']),
display_labels=[False, True]
).plot(ax=panels[0, 0])
ConfusionMatrixDisplay(
confusion_matrix=confusion_matrix(truth_vec, bin_preds['capacity']),
display_labels=[False, True]
).plot(ax=panels[0, 1])
ConfusionMatrixDisplay(
confusion_matrix=confusion_matrix(truth_vec, bin_preds['cost']),
display_labels=[False, True]
).plot(ax=panels[1, 0])
ConfusionMatrixDisplay(
confusion_matrix=confusion_matrix(truth_vec, bin_preds['efficiency']),
display_labels=[False, True]
).plot(ax=panels[1, 1])
plt.show()What is actually happening
ConfusionMatrixDisplay.plot writes axis labels directly onto each axes it draws on. fig.supxlabel and fig.supylabel do not override or remove those per-axes labels; they simply add super labels to the figure. As a result, you end up with five labels: one shared and four local duplicates. Likewise, relying purely on rcParams for tick label sizes won’t change the labels already set by ConfusionMatrixDisplay the way you want; you need to adjust each axes explicitly.
The fix: blank local labels, then add shared labels
The workaround is straightforward. First, render each confusion matrix but clear its local x and y labels. Second, add shared labels using fig.supxlabel and fig.supylabel. If you also want to make tick labels smaller, call tick_params on each axes. Here is a complete version that does exactly that and avoids extra colorbars per panel.
import matplotlib.pyplot as plt
from sklearn.metrics import ConfusionMatrixDisplay, confusion_matrix
# Assume truth_vec and bin_preds are already defined and aligned
# truth_vec: 1D array-like of ground-truth labels
# bin_preds: dict with keys 'NSflow', 'capacity', 'cost', 'efficiency' and 1D array-like predictions
chart, panels = plt.subplots(2, 2, figsize=(8, 8), sharex=True, sharey=True)
chart.suptitle('Confusion Matrix', fontsize=14)
panel_headers = ['NS Flow', 'Capacity', 'Cost', 'Efficiency']
pred_fields = ['NSflow', 'capacity', 'cost', 'efficiency']
for idx, cell_ax in enumerate(panels.flat):
cm_artist = ConfusionMatrixDisplay(
confusion_matrix=confusion_matrix(truth_vec, bin_preds[pred_fields[idx]]),
display_labels=[False, True]
)
cm_artist.plot(ax=cell_ax, colorbar=False)
cell_ax.set_title(panel_headers[idx], fontsize=10)
cell_ax.set_xlabel('')
cell_ax.set_ylabel('')
cell_ax.tick_params(axis='both', labelsize=8)
chart.supxlabel('Predicted label', fontsize=12)
chart.supylabel('True label', fontsize=12)
plt.tight_layout(rect=[0, 0, 1, 0.95])
plt.show()Why this nuance matters
Small details in visualization APIs add up. When each subplot has its own axis labels, shared semantics get lost in the clutter and comparisons are harder. Clearing redundant labels and explicitly adding figure-level labels focuses attention on what changes between panels while keeping the common context obvious. It also makes styling consistent and easier to manage when grids grow beyond a few panels.
Takeaways
If you want shared axis labels for multiple ConfusionMatrixDisplay plots, let each panel draw the confusion matrix, then remove local x and y labels and add global ones with fig.supxlabel and fig.supylabel. For label sizes, prefer per-axes adjustments like tick_params after plotting. This produces clean, comparable confusion matrices without duplicated labels and with typography under control.