agentsclimarketplace

Matplotlib optimization visualization

Skill hajibabaie/combinatorial-optimization-skills/skills/matplotlib-optimization-visualization

76 Claude Code skills for combinatorial optimization and operations research: MILP with Gurobi, metaheuristics, encodings/operators, classic problems, and research tooling

Install
npx -y skills add hajibabaie/combinatorial-optimization-skills --skill matplotlib-optimization-visualization

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

When the user wants to turn optimization experiment data into figures: convergence curves with bands over seeds, Gantt charts, route plots, Pareto front plots, and performance profiles, at publication quality with vector output and single-column sizing. Also use when the user mentions "convergence plot," "Gantt chart," "plot routes," "Pareto plot," "publication figure," "performance profile," or "matplotlib." For the tidy result tables that feed these plots, see pandas-experiment-management; for the statistics behind comparison figures, see algorithm-benchmarking-statistics.

SKILL.md

38.2 KB, as published. Nobody here has run it

Matplotlib Visualization for Optimization

You are an expert in scientific visualization for operations research. This skill covers the standard figure types of computational optimization papers — convergence curves, Gantt charts, route plots, Pareto fronts, and performance profiles — plus the publication-quality mechanics (vector output, font matching, column-width sizing) that journals and conferences require. Use the pattern catalog below: each pattern gives the motivation, a complete implementation, and the pitfall that most often ruins the figure.

Initial Assessment

Before producing any figure, establish:

  • Venue and column geometry. Single-column (~3.3–3.5 in) or double-column (~7 in) figure? IEEE, INFORMS, Springer, and Elsevier column widths differ; the figure must be designed at its final printed width.
  • Target format. PDF or EPS vector for the paper; PNG only for previews, slides, or raster-heavy panels. Some journals still require EPS or TIFF — confirm before styling.
  • Data shape. Is the experiment data already in tidy form (one row per run, columns for instance, algorithm, seed, time, objective)? If not, fix the table first — plotting code should never reshape ad hoc.
  • What the figure must argue. Anytime behavior (convergence plot), final quality distribution (box/strip plot), robustness across instances (performance profile), structural correctness (route/Gantt plot), or trade-offs (Pareto plot). One claim per figure.
  • Minimization or maximization. Determines np.minimum.accumulate vs np.maximum.accumulate, axis direction, and which corner of a Pareto plot is "good."
  • Number of seeds and instances. Fewer than ~5 seeds: plot individual runs, not a band. Many instances: aggregate with performance profiles, not 50 separate convergence plots.
  • Time axis semantics. Wall-clock seconds, CPU seconds, or evaluation count? Mixed hardware makes wall-clock comparisons unfair; evaluation counts hide per-iteration cost differences. State the choice on the axis label.
  • Scale needs. Objectives spanning orders of magnitude, or late-stage differences of <1%, need log axes or gap-to-best transforms decided up front.
  • Color constraints. Will reviewers print in grayscale? Is colorblind safety required (it should be)? Fix a palette before the first figure so the whole paper is consistent.
  • Reproducibility requirement. Every paper figure should be regenerated by one script from one results file. Plan scripts/make_figures.py + results/*.csv + figures/ from the start.

Figure Selection and Sizing Rules

Which figure answers which question

QuestionFigureData neededPattern
Which algorithm improves faster, and is it consistent?Convergence curve with quantile bandImprovement events (time, incumbent) per seed3, 4
Is the schedule feasible and tight?Gantt chartOperations (job, machine, start, end)6
Do the routes look sane (no crossings, balanced)?Route plotCoordinates + route sequences5
What is the trade-off between two objectives?Pareto front plotBi-objective points, dominated + non-dominated7
Which solver is most robust across an instance set?Performance profileCost matrix instances × solvers8
What is the distribution of final quality over seeds?Box plot + jittered pointsTidy table of final objectives9

Decision guidance:

  • Use a convergence plot when the time budget matters and algorithms may rank differently at different budgets. Use final-quality box plots when all methods run to the same stopping rule and only the end result matters.
  • Use a performance profile (Dolan & Moré 2002, "Benchmarking optimization software with performance profiles") when you have ≥ ~20 instances and want one figure instead of a table with 20 rows. Below ~10 instances, a table is more honest.
  • Use route/Gantt plots for debugging and for one illustrative figure in the paper — never as evidence of average quality. A pretty picture of one instance proves nothing statistically.
  • Use Pareto plots for bi-objective results. For ≥3 objectives, switch to parallel-coordinate plots or pairwise scatter matrices; a 3D scatter is almost never readable in print.

Sizing rules that prevent unreadable figures

  1. Design at final printed size. Create the figure at the exact column width in inches and include it in LaTeX without scaling (\includegraphics{fig.pdf}, no width= rescale). Then an 8 pt label stays 8 pt in print.
  2. Font sizes near caption size. Caption text is typically 8–9 pt; figure text should be 7–9 pt at final size. Anything below 6 pt is rejected-reviewer territory.
  3. Aspect ratio ~0.6–0.7 (height/width) for curve plots; route plots need set_aspect("equal") and therefore squarer canvases; Gantt height scales with machine count (~0.3 in per lane plus margins).
  4. One legend strategy per paper. Inside the axes if there is empty space; outside (right or below) otherwise; a single shared legend for small-multiple grids.
  5. Step, not line, for incumbents. The best-so-far objective is a piecewise-constant, right-continuous function of time. Drawing straight lines between improvement events claims progress that never happened. Always ax.step(..., where="post").
  6. Fixed identity per algorithm. Same color, marker, and linestyle for the same algorithm in every figure of the paper. Use the Okabe-Ito palette (Okabe & Ito 2008, colorblind-safe) plus distinct linestyles so grayscale printing still separates curves.

Foundation Patterns: Style, Sizing, Saving

Pattern 1 — One style function for the whole paper

Set rcParams once, in one shared module, before any figure is created. Per-figure styling drifts; a single function keeps every figure of the paper identical and lets you restyle the whole paper for a different venue by editing one place.

import matplotlib as mpl

JOURNAL_WIDTHS_IN: dict[str, float] = {
    "single_column": 3.5,   # typical two-column journal: 3.3-3.5 in
    "double_column": 7.0,
    "beamer_slide": 5.0,
}


def set_publication_style(base_pt: float = 8.0) -> None:
    """Set global rcParams for camera-ready figures. Call once, before creating figures."""
    mpl.rcParams.update({
        "font.family": "serif",
        "font.size": base_pt,
        "axes.labelsize": base_pt,
        "axes.titlesize": base_pt,
        "xtick.labelsize": base_pt - 1,
        "ytick.labelsize": base_pt - 1,
        "legend.fontsize": base_pt - 1,
        "mathtext.fontset": "cm",      # Computer Modern: matches LaTeX math
        "axes.spines.top": False,
        "axes.spines.right": False,
        "axes.grid": True,
        "grid.linewidth": 0.4,
        "grid.alpha": 0.4,
        "lines.linewidth": 1.2,
        "lines.markersize": 3.5,
        "legend.frameon": False,
        "pdf.fonttype": 42,            # embed TrueType outlines, never Type 3
        "ps.fonttype": 42,
        "savefig.dpi": 300,            # raster fallback resolution
        "figure.constrained_layout.use": True,
    })


def fig_size(width_in: float, aspect: float = 0.62) -> tuple[float, float]:
    """Figure size at FINAL printed width; aspect = height/width (0.62 ~ golden ratio)."""
    return (width_in, round(width_in * aspect, 2))


set_publication_style()
print(fig_size(JOURNAL_WIDTHS_IN["single_column"]))
# Expected: (3.5, 2.17) -- include at natural size in LaTeX so 8 pt text prints at 8 pt

Pitfall: Setting figsize=(10, 6) and later scaling with width=\columnwidth in LaTeX shrinks the figure by ~65%, turning 10 pt fonts into ~3.5 pt fonts. This is the single most common defect in submitted optimization papers. Fix the width in inches here and never rescale downstream.

Pattern 2 — Save vector master plus raster preview

The paper needs a vector PDF (crisp at any zoom, searchable text); day-to-day inspection needs a PNG that opens fast and embeds in notebooks and chat. Save both from one call, and close the figure so long experiment scripts do not leak memory through hundreds of open canvases.

from pathlib import Path

import matplotlib
matplotlib.use("Agg")          # headless backend: works on servers and in CI
import matplotlib.pyplot as plt
from matplotlib.figure import Figure


def save_figure(fig: Figure, stem: str | Path,
                formats: tuple[str, ...] = ("pdf", "png")) -> list[Path]:
    """Save one figure in several formats; pdf is the paper artifact, png the preview."""
    stem = Path(stem)
    stem.parent.mkdir(parents=True, exist_ok=True)
    written: list[Path] = []
    for ext in formats:
        target = stem.with_suffix(f".{ext}")
        fig.savefig(target)    # constrained_layout already handles spacing
        written.append(target)
    plt.close(fig)
    return written


fig, ax = plt.subplots(figsize=(3.5, 2.17))
ax.plot([0, 1, 2], [3, 1, 2])
ax.set_xlabel("iteration")
ax.set_ylabel("objective")
paths = save_figure(fig, "figures/demo_curve")
print([p.name for p in paths])
# Expected: ['demo_curve.pdf', 'demo_curve.png'] -- vector master plus raster preview

Pitfall: bbox_inches="tight" recomputes the canvas size at save time, so the saved figure is not the width you designed, and a grid of "3.5 in" figures ends up with three slightly different widths. With constrained_layout enabled (Pattern 1) you do not need tight; if you must crop, accept that printed width changed and re-check font sizes.

Convergence Patterns

Pattern 3 — Best-so-far incumbent as a step curve

The incumbent objective is piecewise constant between improvements. Reduce the raw evaluation log to improvement events, then draw a right-continuous staircase and extend the final incumbent to the end of the run, otherwise the curve visually "stops" at the last improvement and hides the long tail without progress.

import numpy as np
import matplotlib.pyplot as plt


def best_so_far(times: np.ndarray, objectives: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Reduce a raw evaluation log to incumbent improvement events (minimization)."""
    order = np.argsort(times, kind="stable")
    t = times[order]
    f = np.minimum.accumulate(objectives[order])
    keep = np.ones(t.size, dtype=bool)
    keep[1:] = f[1:] < f[:-1]          # keep only strict improvements
    return t[keep], f[keep]


def plot_incumbent(ax: plt.Axes, times: np.ndarray, objectives: np.ndarray,
                   run_end: float, label: str) -> None:
    """Right-continuous step curve of the incumbent objective over time."""
    t, f = best_so_far(times, objectives)
    t_ext = np.append(t, run_end)      # hold the last incumbent to the end of the run
    f_ext = np.append(f, f[-1])
    ax.step(t_ext, f_ext, where="post", label=label)


rng = np.random.default_rng(7)
t = np.sort(rng.uniform(0.0, 60.0, size=400))
f = 1000.0 * np.exp(-t / 25.0) + rng.normal(0.0, 15.0, size=400) + 200.0
fig, ax = plt.subplots(figsize=(3.5, 2.17))
plot_incumbent(ax, t, f, run_end=60.0, label="ILS")
ax.set_xlabel("wall-clock time (s)")
ax.set_ylabel("best objective found")
ax.legend()
fig.savefig("incumbent_demo.pdf")
# Expected: a monotone non-increasing staircase from ~1150 down to ~210, flat after the
# last improvement and extended to t = 60

Pitfall: Logging every evaluation instead of every improvement makes log files of metaheuristics explode (millions of rows) and plotting slow. Log improvement events only — (time, new_incumbent) — plus one final row at the time limit. The plot needs nothing else.

Pattern 4 — Quantile band over seeds on a common time grid

Single-seed curves overstate differences; averages over seeds require all trajectories sampled at the same time points. Resample each seed's staircase onto a shared grid with previous-value interpolation (never linear — see Pattern 3), then plot the median with a 25–75% band. A log-spaced grid gives the early phase, where most improvement happens, enough resolution.

import numpy as np
import matplotlib.pyplot as plt


def step_resample(event_t: np.ndarray, event_f: np.ndarray,
                  grid: np.ndarray) -> np.ndarray:
    """Sample a step trajectory on a grid with previous-value (staircase) interpolation."""
    idx = np.searchsorted(event_t, grid, side="right") - 1
    out = np.full(grid.size, np.nan)   # NaN before the first incumbent exists
    seen = idx >= 0
    out[seen] = event_f[idx[seen]]
    return out


def plot_convergence_band(ax: plt.Axes, runs: list[tuple[np.ndarray, np.ndarray]],
                          grid: np.ndarray, label: str, color: str) -> None:
    """Median incumbent over seeds with a 25-75% quantile band on a shared time grid."""
    curves = np.vstack([step_resample(t, f, grid) for t, f in runs])
    q25, q50, q75 = np.nanpercentile(curves, [25.0, 50.0, 75.0], axis=0)
    ax.plot(grid, q50, color=color, label=label)
    ax.fill_between(grid, q25, q75, color=color, alpha=0.25, linewidth=0)


def synthetic_runs(rate: float, noise: float, n_seeds: int,
                   seed: int) -> list[tuple[np.ndarray, np.ndarray]]:
    """Generate improvement logs (event times, incumbent values) for one algorithm."""
    rng = np.random.default_rng(seed)
    runs: list[tuple[np.ndarray, np.ndarray]] = []
    for _ in range(n_seeds):
        n_events = int(rng.integers(20, 40))
        t = np.sort(rng.uniform(0.05, 60.0, size=n_events))
        f = 500.0 * np.exp(-rate * t) + 100.0 + rng.normal(0.0, noise, size=n_events)
        runs.append((t, np.minimum.accumulate(f)))
    return runs


grid = np.geomspace(0.1, 60.0, num=200)    # log-spaced: early progress gets resolution
fig, ax = plt.subplots(figsize=(3.5, 2.17))
plot_convergence_band(ax, synthetic_runs(0.10, 8.0, 10, seed=1), grid, "ALNS", "C0")
plot_convergence_band(ax, synthetic_runs(0.06, 8.0, 10, seed=2), grid, "GA", "C1")
ax.set_xscale("log")
ax.set_xlabel("wall-clock time (s)")
ax.set_ylabel("best objective (median, IQR over 10 seeds)")
ax.legend()
fig.savefig("convergence_band.pdf")
# Expected: ALNS band drops faster and sits below the GA band after ~5 s; bands overlap
# early, which honestly shows the early phase is not statistically separated

Pitfall: Computing the mean instead of the median lets one bad seed drag the whole curve, and computing quantiles over runs of different lengths without the NaN handling above silently mixes "no incumbent yet" with real values. Use np.nanpercentile, and never extrapolate a seed's trajectory beyond its own run end — if seeds have different budgets, truncate the grid to the shortest budget.

Solution-Structure Patterns

Pattern 5 — Route plots for TSP/VRP solutions

A route plot is the fastest sanity check for routing output: crossing edges suggest missed 2-opt moves, a giant route next to tiny ones suggests broken capacity handling. One color per vehicle, depot as a distinct marker, equal aspect so geometry is not distorted, and direction shown by an arrow on the first leg.

import numpy as np
import matplotlib.pyplot as plt


def plot_routes(ax: plt.Axes, coords: np.ndarray,
                routes: list[list[int]], depot: int = 0) -> None:
    """Draw vehicle routes over customer coordinates; one color per route, depot square."""
    cmap = plt.get_cmap("tab10")
    for k, route in enumerate(routes):
        seq = np.array([depot, *route, depot])
        xy = coords[seq]
        color = cmap(k % 10)
        ax.plot(xy[:, 0], xy[:, 1], "-", color=color, linewidth=1.0,
                label=f"route {k + 1} ({len(route)} stops)", zorder=1)
        mid = 0.5 * (xy[0] + xy[1])    # arrow on the first leg shows direction
        ax.annotate("", xy=tuple(mid), xytext=tuple(xy[0]),
                    arrowprops={"arrowstyle": "-|>", "color": color, "lw": 1.0})
    customers = np.setdiff1d(np.arange(len(coords)), [depot])
    ax.scatter(coords[customers, 0], coords[customers, 1], s=12, color="0.25", zorder=2)
    ax.scatter(coords[depot, 0], coords[depot, 1], marker="s", s=45,
               color="black", zorder=3, label="depot")
    ax.set_aspect("equal")
    ax.set_xticks([])
    ax.set_yticks([])
    ax.legend(loc="upper left", bbox_to_anchor=(1.01, 1.0))


rng = np.random.default_rng(42)
coords = rng.uniform(0.0, 100.0, size=(13, 2))
coords[0] = (50.0, 50.0)
routes = [[1, 4, 7, 10], [2, 5, 8, 11], [3, 6, 9, 12]]
fig, ax = plt.subplots(figsize=(3.5, 3.0))
plot_routes(ax, coords, routes)
fig.savefig("routes_demo.pdf")
# Expected: three colored loops through the black depot square at (50, 50), customer
# dots in gray, route legend placed outside the axes on the right

Pitfall: Forgetting set_aspect("equal") stretches the plane, so routes that cross look fine and vice versa — the one thing the figure exists to show becomes unreliable. Also resist plotting node indices for instances beyond ~30 nodes; the labels turn the plot into noise. Annotate only nodes you discuss in the text.

Pattern 6 — Gantt charts for machine schedules

A Gantt chart shows feasibility (no overlapping bars in a lane), idle time (gaps), and the makespan (dashed line) at a glance. One horizontal lane per machine, one color per job so precedence chains are traceable across machines, and bar labels only where they fit.

import matplotlib.pyplot as plt
from matplotlib.patches import Patch

Operation = tuple[int, int, float, float]      # (job, machine, start, end)


def plot_gantt(ax: plt.Axes, ops: list[Operation], n_machines: int) -> None:
    """Machine-row Gantt: one lane per machine, one color per job, makespan line."""
    cmap = plt.get_cmap("tab20")
    jobs = sorted({job for job, _, _, _ in ops})
    color_of = {job: cmap(i % 20) for i, job in enumerate(jobs)}
    for job, machine, start, end in ops:
        ax.barh(machine, end - start, left=start, height=0.6,
                color=color_of[job], edgecolor="black", linewidth=0.4)
        if end - start >= 4.0:                 # label only bars wide enough to read
            ax.text(0.5 * (start + end), machine, f"J{job}",
                    ha="center", va="center", fontsize=6)
    makespan = max(end for _, _, _, end in ops)
    ax.axvline(makespan, color="0.3", linestyle="--", linewidth=0.8)
    ax.set_yticks(range(n_machines), [f"M{m}" for m in range(n_machines)])
    ax.invert_yaxis()                          # machine 0 on top: reading order
    ax.set_xlabel("time")
    handles = [Patch(facecolor=color_of[j], edgecolor="black", label=f"job {j}")
               for j in jobs]
    ax.legend(handles=handles, ncol=min(len(jobs), 4), loc="upper center",
              bbox_to_anchor=(0.5, -0.28))


ops: list[Operation] = [
    (0, 0, 0.0, 5.0), (0, 1, 5.0, 9.0), (0, 2, 9.0, 16.0),
    (1, 1, 0.0, 5.0), (1, 0, 5.0, 11.0), (1, 2, 16.0, 21.0),
    (2, 2, 0.0, 8.0), (2, 0, 11.0, 18.0), (2, 1, 18.0, 24.0),
]
fig, ax = plt.subplots(figsize=(3.5, 2.4))
plot_gantt(ax, ops, n_machines=3)
fig.savefig("gantt_demo.pdf")
# Expected: 3-lane chart with no overlap inside any lane, dashed makespan line at
# t = 24, three-entry job legend centered below the axes

Pitfall: Coloring by machine instead of by job makes every bar in a lane the same color, which hides exactly the information a job-shop Gantt must show: how each job flows across machines. Color by job; the lane already encodes the machine. For more than ~20 jobs, drop the legend and per-bar labels, and color by a job attribute instead (due-date tightness, tardiness, family).

Comparison Patterns

Pattern 7 — Pareto front with dominated background

For bi-objective minimization, show all evaluated points in light gray and overlay the non-dominated set as a staircase. The gray cloud gives scale ("how much of the search was wasted?"); the staircase, drawn with where="post", encodes the attainment boundary exactly — straight lines between front points claim trade-off solutions that do not exist.

import numpy as np
import matplotlib.pyplot as plt


def pareto_mask(points: np.ndarray) -> np.ndarray:
    """Boolean mask of non-dominated points for bi-objective minimization."""
    order = np.lexsort((points[:, 1], points[:, 0]))   # by f1, tie-break by f2
    mask = np.zeros(points.shape[0], dtype=bool)
    best_f2 = np.inf
    for i in order:                                    # sweep keeps duplicates out
        if points[i, 1] < best_f2:
            mask[i] = True
            best_f2 = points[i, 1]
    return mask


def plot_pareto(ax: plt.Axes, points: np.ndarray, label: str, color: str) -> None:
    """Scatter all points; overlay the non-dominated front as a staircase."""
    nd = pareto_mask(points)
    ax.scatter(points[~nd, 0], points[~nd, 1], s=10, color="0.78", zorder=1,
               label="dominated")
    front = points[nd][np.argsort(points[nd, 0])]
    ax.step(front[:, 0], front[:, 1], where="post", color=color,
            linewidth=1.0, zorder=2)
    ax.scatter(front[:, 0], front[:, 1], s=18, color=color, zorder=3, label=label)


rng = np.random.default_rng(3)
raw = rng.uniform(0.0, 1.0, size=(60, 2))
pts = np.column_stack([raw[:, 0], 0.2 / (0.2 + raw[:, 0]) + 0.5 * raw[:, 1]])
fig, ax = plt.subplots(figsize=(3.5, 2.6))
plot_pareto(ax, pts, label="non-dominated", color="C0")
ax.set_xlabel("$f_1$: makespan")
ax.set_ylabel("$f_2$: total tardiness")
ax.legend()
fig.savefig("pareto_demo.pdf")
# Expected: gray dominated cloud, colored staircase along the lower-left boundary;
# every colored point has no point both below and to its left

Pitfall: Comparing fronts from two algorithms by eye invites bias — fronts cross, and "looks closer to the corner" is not a metric. Pair the figure with hypervolume or IGD numbers from the multi-objective skill, and when overlaying two fronts, use the fixed per-algorithm styles (Pattern 10) plus distinct markers, because the staircases will partially coincide.

Pattern 8 — Performance profiles (Dolan & Moré 2002)

To compare solvers on a whole instance set, compute for solver $s$ on instance $p$ the ratio to the best solver on that instance, then plot the cumulative distribution of ratios:

$$ r_{p,s} = \frac{c_{p,s}}{\min_{s'} c_{p,s'}}, \qquad \rho_s(\tau) = \frac{1}{|P|},\bigl|{, p \in P : r_{p,s} \le \tau ,}\bigr| $$

$\rho_s(1)$ is the fraction of instances where $s$ is (tied) best; the curve's height at large $\tau$ is its robustness. Failed runs get cost $\infty$ and cap the curve below 1.

import numpy as np
import matplotlib.pyplot as plt


def performance_profile(ax: plt.Axes, costs: np.ndarray, names: list[str],
                        tau_max: float | None = None) -> None:
    """Dolan-More profile. costs[i, s]: cost of solver s on instance i (np.inf = fail)."""
    n_instances = costs.shape[0]
    ratios = costs / np.min(costs, axis=1, keepdims=True)
    if tau_max is None:
        tau_max = float(ratios[np.isfinite(ratios)].max()) * 1.05
    for s, name in enumerate(names):
        finite = np.sort(ratios[np.isfinite(ratios[:, s]), s])
        x = np.concatenate([[1.0], finite, [tau_max]])
        y = np.concatenate([[0.0], (np.arange(finite.size) + 1) / n_instances,
                            [finite.size / n_instances]])
        ax.step(x, y, where="post", label=name)
    ax.set_xlim(1.0, tau_max)
    ax.set_ylim(0.0, 1.02)
    ax.set_xlabel(r"performance ratio $\tau$")
    ax.set_ylabel(r"$\rho_s(\tau)$: fraction of instances")
    ax.legend(loc="lower right")


rng = np.random.default_rng(11)
base = rng.lognormal(mean=2.0, sigma=0.8, size=(40, 1))
costs = base * np.column_stack([np.ones(40),
                                rng.uniform(1.0, 2.5, size=40),
                                rng.uniform(0.8, 4.0, size=40)])
costs[rng.random(40) < 0.10, 2] = np.inf       # third solver fails on ~10% of instances
fig, ax = plt.subplots(figsize=(3.5, 2.4))
performance_profile(ax, costs, ["TS", "SA", "GA"])
fig.savefig("performance_profile.pdf")
# Expected: TS curve highest at tau = 1 (most wins); GA plateaus below 1.0 because its
# failed instances never get covered at any tau

Pitfall: Profiles compare each solver against the best of the plotted set, so adding or removing one solver changes every curve — rankings between two solvers can flip depending on who else is in the plot (Gould & Scott 2016, "A note on performance profiles for benchmarking software"). Report pairwise statistical tests alongside, and never extract "A is 2.3x faster than B" from ratio axes built against the pooled best.

Pattern 9 — Final-quality distributions per algorithm

When all algorithms run with the same budget, the deliverable comparison is the distribution of final objectives over seeds. A box plot summarizes; jittered raw points keep small sample sizes honest (with 10–20 seeds, readers should see every run).

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt


def plot_final_objectives(ax: plt.Axes, results: pd.DataFrame) -> None:
    """Box plot + jittered points of final objective per algorithm from a tidy table."""
    algorithms = sorted(results["algorithm"].unique())
    data = [results.loc[results["algorithm"] == a, "objective"].to_numpy()
            for a in algorithms]
    ax.boxplot(data, positions=range(len(algorithms)), widths=0.5,
               showfliers=False, manage_ticks=False,
               medianprops={"color": "black"})
    rng = np.random.default_rng(0)             # fixed seed: jitter is reproducible
    for i, values in enumerate(data):
        x = i + rng.uniform(-0.15, 0.15, size=values.size)
        ax.scatter(x, values, s=8, alpha=0.6, color=f"C{i}", zorder=3)
    ax.set_xticks(range(len(algorithms)), algorithms)
    ax.set_ylabel("final objective")


rng = np.random.default_rng(5)
rows = []
for name, mu, sigma in [("ALNS", 980.0, 12.0), ("GA", 1010.0, 25.0), ("ILS", 985.0, 8.0)]:
    for seed in range(20):
        rows.append({"algorithm": name, "seed": seed,
                     "objective": float(rng.normal(mu, sigma))})
results = pd.DataFrame(rows)
fig, ax = plt.subplots(figsize=(3.5, 2.4))
plot_final_objectives(ax, results)
fig.savefig("final_objectives.pdf")
# Expected: ILS shows the tightest box, GA the widest box and highest median; all
# 20 points per algorithm are individually visible as jittered dots

Pitfall: showfliers=True (the default) double-plots outliers — once as a box-plot flier, once as a jittered dot — which visually doubles the evidence of bad runs. Disable fliers when overlaying raw points. And do not let this figure replace the statistics: pair it with Wilcoxon/Friedman tests from the benchmarking skill before claiming a winner.

Advanced Techniques

Fixed per-algorithm identity across the paper

Define one mapping from algorithm name to color, marker, and linestyle, and import it everywhere. The Okabe-Ito palette is colorblind-safe and its members stay distinguishable in grayscale when paired with distinct linestyles.

from typing import TypedDict


class AlgoStyle(TypedDict):
    color: str
    marker: str
    linestyle: str


PAPER_STYLES: dict[str, AlgoStyle] = {
    "ALNS": {"color": "#0072B2", "marker": "o", "linestyle": "-"},
    "GA":   {"color": "#D55E00", "marker": "s", "linestyle": "--"},
    "ILS":  {"color": "#009E73", "marker": "^", "linestyle": "-."},
    "MIP":  {"color": "#000000", "marker": "D", "linestyle": ":"},
}


def style_of(algorithm: str) -> AlgoStyle:
    """Look up the fixed style for an algorithm; fail loudly on unknown names."""
    return PAPER_STYLES[algorithm]


print(style_of("GA")["color"])
# Expected: '#D55E00' -- Okabe-Ito vermilion, colorblind-safe, distinct in grayscale

Gap-to-best transforms and logarithmic axes

Late-run differences of 0.5% vanish on a linear objective axis. Plot the relative gap $g(t) = (f(t) - f^*)/f^*$ against the best known value $f^*$ on a log y-axis instead: equal vertical distances become equal relative improvements, and the tail separates. Two cautions: a run that reaches $f^*$ exactly produces $g = 0$, which a log axis cannot show — clip at a floor like $10^{-6}$ and say so in the caption, or use ax.set_yscale("symlog", linthresh=1e-6). And when $f^*$ comes from the runs being plotted rather than an external best-known value, the gap definition is circular across papers; state its source.

Rasterized layers inside vector figures

A scatter of 200,000 evaluated solutions makes a 50 MB PDF that crashes viewers. Pass rasterized=True to the heavy artist (ax.scatter(..., rasterized=True)) and set fig.savefig(path, dpi=300): that one layer is embedded as a 300 dpi image while axes, text, and the front lines stay vector. This keeps file size in the hundreds of kilobytes with no visible loss at print resolution. Apply it to dominated-point clouds (Pattern 7), dense convergence spaghetti, and heatmaps.

Small multiples with shared axes

When showing per-instance behavior for 6–12 instances, use one plt.subplots(nrows, ncols, sharex=True, sharey="row", figsize=(7.0, height)) grid instead of separate figures: shared axes make panels comparable, and one figure-level legend (fig.legend(handles, labels, loc="lower center", ncol=4)) replaces twelve copies. Put the instance name inside each panel with ax.set_title(name, fontsize=7). Avoid sharey=True across instances whose objective scales differ by orders of magnitude — share per row, or plot gaps (previous subsection) so one scale fits all.

Matching LaTeX fonts without usetex

text.usetex=True gives perfect font matching but requires a LaTeX toolchain on every machine that renders figures, breaks CI, and slows rendering. For most venues mathtext.fontset: cm with a serif family (Pattern 1) is indistinguishable in print. Reserve usetex for cases that genuinely need LaTeX-only macros in labels. Either way keep pdf.fonttype: 42; Type 3 bitmap fonts are explicitly rejected by IEEE PDF compliance checks.

Practical Challenges

Convergence curves from five algorithms overlap into spaghetti. Cut the figure's job down: plot the two or three methods the text argues about and move the rest to an appendix figure. Use the fixed style registry so the highlighted methods keep their identity, thin and gray the context curves (color="0.8", zorder=1), and consider a gap-to-best log axis, which usually separates curves that a linear axis stacks.

Seeds have different run lengths, and the band jumps at the end. The band must only be computed where all seeds have data. Truncate the common grid at the minimum run end across seeds, or — if budgets differ by design — plot evaluation count instead of time. Never forward-fill a short run past its termination; that fabricates data.

The log y-axis breaks because some runs reach gap zero. Clip gaps at a small floor (e.g., np.maximum(gap, 1e-6)) and state the floor in the caption, or switch to symlog with linthresh at the measurement precision. Alternatively plot f(t) - f_star + 1 style shifted objectives only if the caption defines the shift; unexplained shifted log axes draw reviewer fire.

Fonts look fine on screen but are unreadable in the compiled paper. The figure was designed too large and scaled down in LaTeX. Rebuild at final width (Pattern 1), include without width= scaling, and verify by printing the PDF page at 100%: figure text should be visually close to caption text. A quick check: open the paper PDF at 100% zoom and compare an axis label to the caption.

Reviewers print in grayscale and the curves become identical. Colorblind-safe palettes are necessary, not sufficient. Pair every color with a linestyle and marker (style registry), and check the figure with a grayscale conversion before submission. For bands, use the same hue as the median line at low alpha rather than a second hue.

The PDF figure is 40 MB and the journal upload fails. Heavy scatter or pcolormesh layers must be rasterized (rasterized=True + dpi=300 at save time). Also pre-reduce the data: a convergence plot needs improvement events, not every evaluation; a dominated cloud can be subsampled to a few thousand points without changing the visual message.

The Gantt chart for a 50-job instance is an unreadable mosaic. Drop per-job colors and the legend; color bars by an attribute the paper discusses (tardy vs on-time, job family, bottleneck involvement) with a 3–5 entry legend. Show the full instance once for scale, then zoom panels on the time windows the text analyzes.

Two Pareto fronts from different algorithms partially coincide and hide each other. Use open markers for one front (facecolors="none") and filled for the other, offset marker sizes, and draw the staircases with different linestyles. If they still overlap, plot the difference directly: hypervolume over time, or the empirical attainment surfaces, instead of raw fronts.

Best-so-far curves drawn with straight lines between improvements. This is wrong, not just ugly: it shows objective values that no run ever held. Replot with ax.step(where="post"). The same applies to performance profiles and attainment curves — all are step functions, and reviewers in OR venues know it.

The journal demands EPS and the math symbols disappear. EPS does not support transparency: alpha in bands and scatters silently degrades or rasterizes the whole figure. Replace alpha with pre-blended lighter colors (e.g., band color "#cce0f0" instead of blue at 0.25 alpha) for the EPS build, keep ps.fonttype: 42, and check the result in a PostScript viewer, not just a PDF conversion.

Tools & Libraries

LibraryWhen to useNote
matplotlibAll paper figures in this skillThe only hard dependency; full control over print output
numpyTrajectory resampling, Pareto masks, profilesAll data prep here is array work; np.random.default_rng(seed) for jitter
pandasReading tidy result tables into plot functionsKeep plotting functions accepting arrays/tidy frames, not raw logs
seabornQuick exploratory stats plots during researchConvenient for drafts; restyle to the paper rcParams before submission
scienceplotsReady-made journal style sheets (IEEE, Nature)Good starting point; still verify column width and font sizes yourself
colorcet / cmcrameriPerceptually uniform colormaps for heatmapsAvoid jet; uniform maps survive grayscale and colorblind viewing
plotlyInteractive inspection of large solution setsExploration only; export final figures via matplotlib for print
PGF backend (.pgf)Figures compiled by LaTeX itselfPerfect font matching; couples figure builds to the LaTeX toolchain

Output Format

A complete visualization deliverable contains:

  1. One script per figure (scripts/fig_convergence.py, scripts/fig_profile.py) reading from results/*.csv and writing to figures/; no figure is produced inside an experiment run.
  2. Vector master + preview per figure (figures/convergence.pdf, figures/convergence.png) via the save helper.
  3. A caption draft stating: instance set, number of seeds, time budget, what the band/box shows (median? IQR?), and the source of best-known values for gap axes.

Pre-submission figure checklist:

  • Figure created at final printed width; included in LaTeX without rescaling
  • All text 7–9 pt at print size; compared against caption text at 100% zoom
  • Vector PDF/EPS output; pdf.fonttype = 42; no Type 3 fonts (check with a font report tool)
  • Incumbents, profiles, and fronts drawn as step functions (where="post")
  • Bands/boxes defined in the caption (median + IQR over N seeds), N stated
  • Same color/marker/linestyle per algorithm in every figure of the paper
  • Survives grayscale conversion and a colorblind simulation check
  • Heavy point layers rasterized; file size below the venue limit
  • Axis labels carry units and semantics ("wall-clock time (s)", not "time")
  • Every figure is referenced in the text and supports exactly one claim

Reusable rcParams template (drop into a shared plot_style.py or a .mplstyle file):

font.family:        serif
font.size:          8
axes.labelsize:     8
xtick.labelsize:    7
ytick.labelsize:    7
legend.fontsize:    7
legend.frameon:     False
mathtext.fontset:   cm
axes.spines.top:    False
axes.spines.right:  False
axes.grid:          True
grid.linewidth:     0.4
grid.alpha:         0.4
lines.linewidth:    1.2
lines.markersize:   3.5
pdf.fonttype:       42
ps.fonttype:        42
savefig.dpi:        300
figure.constrained_layout.use: True

Questions to Ask

  • Which venue and column layout is this for — what is the final printed width in inches?
  • Is the result data already tidy (one row per run with instance, algorithm, seed, time, objective)?
  • How many seeds and instances per configuration — enough for bands and profiles, or should individual runs be shown?
  • Is the x-axis wall-clock time, CPU time, or evaluation count, and is that comparable across the methods shown?
  • Minimization or maximization, and is there a best-known value available for gap plots?
  • Must the figure survive grayscale printing or meet specific accessibility requirements?
  • Which single claim should this figure support in the text?
  • Are there venue format constraints (EPS only, TIFF, font embedding, maximum file size)?

Related Skills

  • pandas-experiment-management — when the result tables feeding these plots need structure: one row per run, metadata columns, atomic writes, and aggregation across instances and seeds.
  • multi-objective-optimization — when Pareto plots need the underlying machinery: non-dominated sorting, hypervolume and IGD indicators, and exact epsilon-constraint fronts to plot against.
  • algorithm-benchmarking-statistics — when figures must be backed by sound comparisons: instance/seed protocols, Wilcoxon and Friedman tests, and the methodology behind performance profiles.
  • metaheuristic-design-principles — when convergence plots should inform design decisions: diagnosing premature convergence, intensification/diversification balance, and stopping criteria from anytime curves.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.