#!/usr/bin/env python # # file: qmllab/plotting.py # # revision history: # 20260810 (am): initial version for workshop lab 02 # # Plots for Lab 02. # # Two pictures carry most of the teaching here. The decision boundary # shows where a model changes its answer, and the margin field shows how # strongly it believes everywhere. A concentrated kernel is obvious in # both: the boundary shatters into islands and the field ripples. #------------------------------------------------------------------------------ # import third-party modules # import matplotlib.pyplot as plt import numpy as np #------------------------------------------------------------------------------ # # global variables are listed here # #------------------------------------------------------------------------------ # the palette used across the lab, matching the session 3 slides # TEAL = "#00A19B" PURPLE = "#8A63D2" RED = "#EC2E2E" # a two-class colormap built from that palette # CMAP_PTS = plt.matplotlib.colors.ListedColormap([TEAL, PURPLE]) #------------------------------------------------------------------------------ # # functions are listed here # #------------------------------------------------------------------------------ def plot_dataset(X, y, title="dataset", ax=None): """ function: plot_dataset arguments: X: an (n, 2) array of features y: an (n,) array of labels title: the plot title ax: an existing axis, or None to make one return: the axis that was drawn on description: Scatters a two-dimensional, two-class dataset. """ # make an axis if the caller did not supply one # ax = ax or plt.subplots(figsize=(5, 4))[1] # scatter the points colored by class # ax.scatter(X[:, 0], X[:, 1], c=y, cmap=CMAP_PTS, s=28, edgecolor="white", linewidth=0.5) ax.set_title(title) ax.set_xlabel("feature 1") ax.set_ylabel("feature 2") # exit gracefully # return ax # # end of function def plot_kernel_matrix(K, title="kernel matrix", ax=None, colorbar=True): """ function: plot_kernel_matrix arguments: K: a kernel matrix title: the plot title ax: an existing axis, or None to make one colorbar: whether to draw a colorbar return: the axis that was drawn on description: Draws the similarity table from lecture slide 4 as a heatmap. """ # make an axis if the caller did not supply one # ax = ax or plt.subplots(figsize=(4.6, 4))[1] # draw the matrix on a fixed scale so panels stay comparable # image = ax.imshow(K, cmap="viridis", vmin=0, vmax=1) ax.set_title(title) ax.set_xlabel("sample j") ax.set_ylabel("sample i") # attach a colorbar when asked # if colorbar: plt.colorbar(image, ax=ax, fraction=0.046, label="similarity") # exit gracefully # return ax # # end of function def plot_kernel_comparison(kernels, titles, suptitle=None): """ function: plot_kernel_comparison arguments: kernels: a list of kernel matrices titles: one title per matrix suptitle: an overall figure title return: the figure that was drawn description: Places several kernel matrices side by side on one shared scale, which is how the lab compares exact, sampled and measured kernels. """ # lay out one panel per matrix # fig, axes = plt.subplots(1, len(kernels), figsize=(4.2 * len(kernels), 3.8)) axes = np.atleast_1d(axes) # draw each matrix without its own colorbar # for K, title, ax in zip(kernels, titles, axes): plot_kernel_matrix(K, title, ax=ax, colorbar=False) # share a single colorbar across the row # fig.colorbar(axes[0].images[0], ax=list(axes), fraction=0.025, label="similarity") # add the overall title when given # if suptitle: fig.suptitle(suptitle, y=1.02, fontsize=13, fontweight="bold") # exit gracefully # return fig # # end of function def make_grid(X, resolution=60, pad_frac=0.15): """ function: make_grid arguments: X: the data the grid should cover resolution: points per axis pad_frac: how far past the data to extend, as a fraction return: a tuple (xx, yy, points) of two meshes and the flattened points description: Builds the mesh every decision boundary is evaluated on. """ # extend a little past the data so the boundary has room # pad = pad_frac * max(np.ptp(X[:, 0]), np.ptp(X[:, 1])) xx, yy = np.meshgrid( np.linspace(X[:, 0].min() - pad, X[:, 0].max() + pad, resolution), np.linspace(X[:, 1].min() - pad, X[:, 1].max() + pad, resolution)) # exit gracefully # return xx, yy, np.c_[xx.ravel(), yy.ravel()] # # end of function def decision_field(model, X_ref, resolution=60, kernel_fn=None, feature_map=None, X_train=None, **kernel_kwargs): """ function: decision_field arguments: model: a fitted classifier X_ref: the data the grid should cover resolution: points per axis kernel_fn: a kernel routine for a precomputed-kernel model feature_map: the encoding circuit X_train: the training points the model was fitted on kernel_kwargs: forwarded to kernel_fn return: a tuple (xx, yy, Z, margin); margin is None without one description: Evaluates a model across a grid. Z holds 0/1 labels and margin holds the signed decision function when the model exposes one. A kernel model must be compared against the same training points it was fitted on, in the same order, so X_train is required there. Cost note: on a simulator this is cheap, roughly two seconds for a 60x60 grid against 800 training points. On real hardware the same picture is millions of circuits, which is why segment 4 explains rather than draws. """ # build the mesh # xx, yy, grid = make_grid(X_ref, resolution) # a kernel model needs similarities against its training points # if kernel_fn is not None: if X_train is None: raise ValueError( "kernel models need X_train, the fitted training set") features = kernel_fn(grid, X_train, feature_map=feature_map, **kernel_kwargs) # any other model consumes the raw coordinates # else: features = grid # predict, normalizing +/-1 labels to 0/1 for plotting # Z = np.asarray(model.predict(features)).ravel() if set(np.unique(Z).tolist()) <= {-1.0, 1.0}: Z = (Z + 1) / 2 # collect the signed decision function when there is one # margin = None if hasattr(model, "decision_function"): try: margin = np.asarray( model.decision_function(features)).ravel().reshape(xx.shape) except Exception: margin = None # exit gracefully # return xx, yy, Z.reshape(xx.shape), margin # # end of function def plot_decision_boundary(model, X, y, kernel_fn=None, feature_map=None, X_train=None, title="decision boundary", ax=None, resolution=60, support_idx=None, show_points=True, **kernel_kwargs): """ function: plot_decision_boundary arguments: model: a fitted classifier X: the points to scatter y: their labels kernel_fn: a kernel routine for a precomputed-kernel model feature_map: the encoding circuit X_train: the training points the model was fitted on title: the plot title ax: an existing axis, or None to make one resolution: points per axis support_idx: indices to ring, typically model.support_ show_points: whether to scatter the data over the regions kernel_kwargs: forwarded to kernel_fn return: the axis that was drawn on description: Draws a model's decision regions. Ringing the support vectors makes an over-fitting kernel obvious: a healthy kernel keeps a handful, while a concentrated one keeps most of the training set. """ # make an axis if the caller did not supply one # ax = ax or plt.subplots(figsize=(5, 4))[1] # evaluate the model across the grid # xx, yy, Z, _ = decision_field(model, X, resolution, kernel_fn, feature_map, X_train, **kernel_kwargs) # shade the two regions and outline the boundary between them # ax.contourf(xx, yy, Z, levels=[-0.5, 0.5, 1.5], colors=[TEAL, PURPLE], alpha=0.20) ax.contour(xx, yy, Z, levels=[0.5], colors="k", linewidths=1.6) # ring the support vectors when they were supplied # if support_idx is not None and X_train is not None: sv = X_train[support_idx] ax.scatter(sv[:, 0], sv[:, 1], s=110, facecolors="none", edgecolors="k", linewidths=0.9, label=f"{len(sv)} support vectors") ax.legend(loc="upper right", fontsize=8, framealpha=0.9) # scatter the data on top # if show_points: ax.scatter(X[:, 0], X[:, 1], c=y, cmap=CMAP_PTS, s=26, edgecolor="white", linewidth=0.5, zorder=3) # label the panel and drop the ticks, which carry no information # ax.set_title(title, fontsize=10) ax.set_xticks([]) ax.set_yticks([]) # exit gracefully # return ax # # end of function def plot_margin_field(model, X, y, kernel_fn=None, feature_map=None, X_train=None, title="decision confidence", ax=None, resolution=60, **kernel_kwargs): """ function: plot_margin_field arguments: model: a fitted classifier exposing decision_function X: the points to scatter y: their labels kernel_fn: a kernel routine for a precomputed-kernel model feature_map: the encoding circuit X_train: the training points the model was fitted on title: the plot title ax: an existing axis, or None to make one resolution: points per axis kernel_kwargs: forwarded to kernel_fn return: the axis that was drawn on description: Plots the signed decision function rather than the hard boundary. This is the sharper diagnostic. A healthy kernel sweeps smoothly and confidently from one class to the other. A concentrated kernel ripples: the field never leaves zero by much, because no region of the plane accumulates real evidence. """ # make an axis if the caller did not supply one # ax = ax or plt.subplots(figsize=(5, 4))[1] # evaluate the model, requiring a signed decision function # xx, yy, Z, margin = decision_field(model, X, resolution, kernel_fn, feature_map, X_train, **kernel_kwargs) if margin is None: raise ValueError("this model has no decision_function to plot") # draw the field on a symmetric scale centered at zero # limit = float(np.abs(margin).max()) or 1.0 image = ax.contourf(xx, yy, margin, levels=24, cmap="RdBu_r", vmin=-limit, vmax=limit) ax.contour(xx, yy, margin, levels=[0], colors="k", linewidths=1.6) # scatter the data over the field # ax.scatter(X[:, 0], X[:, 1], c=y, cmap=CMAP_PTS, s=20, edgecolor="white", linewidth=0.4, zorder=3) plt.colorbar(image, ax=ax, fraction=0.046, label="signed distance") # label the panel and drop the ticks # ax.set_title(title, fontsize=10) ax.set_xticks([]) ax.set_yticks([]) # exit gracefully # return ax # # end of function def plot_boundary_comparison(specs, suptitle=None, resolution=60, figsize_each=(4.4, 3.9)): """ function: plot_boundary_comparison arguments: specs: a list of dicts, each forwarded to plot_decision_boundary suptitle: an overall figure title resolution: points per axis figsize_each: the size of a single panel return: the figure that was drawn description: Places several decision boundaries side by side, which is how the lab compares one scaler, one path or one kernel against another. """ # lay out one panel per spec # fig, axes = plt.subplots( 1, len(specs), figsize=(figsize_each[0] * len(specs), figsize_each[1])) axes = np.atleast_1d(axes) # draw each boundary into its own panel # for spec, ax in zip(specs, axes): plot_decision_boundary(ax=ax, resolution=resolution, **spec) # add the overall title when given # if suptitle: fig.suptitle(suptitle, y=1.03, fontsize=13, fontweight="bold") plt.tight_layout() # exit gracefully # return fig # # end of function def plot_scaler_study(results, title="Why the scaler decides everything"): """ function: plot_scaler_study arguments: results: a dict of scaler name -> {"width", "test_acc"} title: the plot title return: the figure that was drawn description: Bars the accuracy of each scaler, ordered by the width of the range it produces and annotated with that width. The ordering is the segment 2 result. """ # order the scalers by the width of the range they produce # names = sorted(results, key=lambda n: results[n]["width"]) widths = [results[n]["width"] for n in names] accuracies = [results[n]["test_acc"] for n in names] # color each bar by how well it did # colors = [TEAL if a >= 0.9 else (PURPLE if a >= 0.8 else RED) for a in accuracies] # draw the bars # fig, ax = plt.subplots(figsize=(8, 4)) bars = ax.bar(names, accuracies, color=colors) # annotate each bar with its accuracy and range width # for bar, width, accuracy in zip(bars, widths, accuracies): ax.text(bar.get_x() + bar.get_width() / 2, accuracy + 0.015, f"{accuracy:.3f}\nwidth {width:.1f}", ha="center", fontsize=8) # mark chance level and finish the axes # ax.axhline(0.5, color="gray", ls="--", lw=0.8) ax.set_ylim(0, 1.15) ax.set_ylabel("test accuracy") ax.set_title(title) ax.tick_params(axis="x", rotation=20) plt.tight_layout() # exit gracefully # return fig # # end of function def plot_convergence(history, title="training loss", ax=None, label=None): """ function: plot_convergence arguments: history: the loss recorded at each optimizer step title: the plot title ax: an existing axis, or None to make one label: a legend entry return: the axis that was drawn on description: Plots a variational circuit's loss curve as it trains. """ # make an axis if the caller did not supply one # ax = ax or plt.subplots(figsize=(6, 3.4))[1] # draw the loss against the optimizer step # ax.plot(history, lw=1.8, color=PURPLE, label=label) ax.set_xlabel("optimizer iteration") ax.set_ylabel("loss") ax.set_title(title) # add a legend only when there is something to name # if label: ax.legend() # exit gracefully # return ax # # end of function # # end of file