import matplotlib.gridspec as gridspec
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from sklearn.metrics import mean_squared_error, r2_score
from spectoprep.pipeline.optimizer import PipelineOptimizer
[docs]
class SpectroPrepPlotter:
"""
A class for creating high-quality plots for spectroscopy data.
This class provides various plotting functions specifically designed
for spectroscopy data and pipeline optimization results.
"""
[docs]
@staticmethod
def set_style(style="whitegrid", context="paper", font_scale=1.2):
"""
Set the visual style for the plots.
Parameters
----------
style : str, default='whitegrid'
The seaborn style.
context : str, default='paper'
The seaborn context.
font_scale : float, default=1.2
The font scale.
"""
sns.set_style(style)
sns.set_context(context, font_scale=font_scale)
plt.rcParams["figure.figsize"] = (10, 6)
plt.rcParams["figure.dpi"] = 100
[docs]
@staticmethod
def plot_spectra(
wavenumbers: np.ndarray,
spectra: np.ndarray,
labels: list[str] | None = None,
title: str = "Spectral Data",
xlabel: str = "Wavenumber (cm$^{-1}$)",
ylabel: str = "Absorbance",
alpha: float = 0.7,
figsize: tuple[int, int] = (12, 6),
color_map: str = "viridis",
legend_loc: str = "best",
grid: bool = True,
invert_xaxis: bool = False,
save_path: str | None = None,
):
"""
Plot spectral data.
Parameters
----------
wavenumbers : array-like
The x-axis values (wavenumbers / wavelengths).
spectra : array-like
The spectra data of shape (n_samples, n_features).
labels : list of str, optional
Labels for each spectrum. If None, spectra are numbered.
title : str, default='Spectral Data'
Plot title.
xlabel : str, default='Wavenumber (cm$^{-1}$)'
X-axis label.
ylabel : str, default='Absorbance'
Y-axis label.
alpha : float, default=0.7
Transparency of the lines.
figsize : tuple, default=(12, 6)
Figure size.
color_map : str, default='viridis'
Colormap for the spectra.
legend_loc : str, default='best'
Location of the legend.
grid : bool, default=True
Whether to show grid.
invert_xaxis : bool, default=False
If True, reverse the x-axis (common for IR/NIR displays).
save_path : str, optional
If provided, save the figure to this path.
Returns
-------
fig : matplotlib.figure.Figure
The figure object.
ax : matplotlib.axes.Axes
The axes object.
"""
fig, ax = plt.subplots(figsize=figsize)
if spectra.ndim == 1:
spectra = spectra.reshape(1, -1)
n_spectra = spectra.shape[0]
# Get colors from colormap
cmap = plt.get_cmap(color_map)
colors = [cmap(i / max(1, n_spectra - 1)) for i in range(n_spectra)]
# Plot each spectrum
show_legend = labels is not None or n_spectra <= 12
for i, spectrum in enumerate(spectra):
label = None
if show_legend:
label = f"Spectrum {i + 1}" if labels is None else labels[i]
ax.plot(wavenumbers, spectrum, label=label, color=colors[i], alpha=alpha)
# Set labels and title
ax.set_xlabel(xlabel, fontsize=12)
ax.set_ylabel(ylabel, fontsize=12)
ax.set_title(title, fontsize=14, fontweight="bold")
# Show grid if requested
if grid:
ax.grid(True, linestyle="--", alpha=0.7)
# Add legend if there are multiple labelled spectra
if show_legend and n_spectra > 1:
ax.legend(loc=legend_loc, frameon=True, framealpha=0.8) # type: ignore[call-overload]
if invert_xaxis:
ax.invert_xaxis()
# Adjust layout
plt.tight_layout()
# Save figure if path is provided
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches="tight")
return fig, ax
[docs]
@staticmethod
def plot_preprocessing_comparison(
wavenumbers: np.ndarray,
original_spectra: np.ndarray,
processed_spectra: dict[str, np.ndarray],
sample_indices: list[int] | None = None,
figsize: tuple[int, int] = (15, 10),
title: str = "Preprocessing Comparison",
xlabel: str = "Wavenumber (cm$^{-1}$)",
color_map: str = "tab10",
alpha: float = 0.35,
invert_xaxis: bool = False,
save_path: str | None = None,
):
"""
Plot comparison of original and processed spectra.
Parameters
----------
wavenumbers : array-like
The x-axis values (wavenumbers / wavelengths).
original_spectra : array-like
The original spectra data of shape (n_samples, n_features).
processed_spectra : dict
Dictionary mapping preprocessing method names to processed spectra.
sample_indices : list of int, optional
Indices of samples to plot. If None, **all** samples are plotted.
figsize : tuple, default=(15, 10)
Figure size.
title : str, default='Preprocessing Comparison'
Main title for the figure.
xlabel : str, default='Wavenumber (cm$^{-1}$)'
X-axis label on the bottom panel.
color_map : str, default='tab10'
Colormap for differentiating samples.
alpha : float, default=0.35
Line transparency (lower helps when plotting many spectra).
invert_xaxis : bool, default=False
If True, reverse the x-axis (common for IR/NIR displays).
save_path : str, optional
If provided, save the figure to this path.
Returns
-------
fig : matplotlib.figure.Figure
The figure object.
"""
# Get the number of preprocessing methods
n_methods = len(processed_spectra) + 1 # +1 for original spectra
# Determine the samples to plot
if sample_indices is None:
sample_indices = list(range(original_spectra.shape[0]))
n_samples = len(sample_indices)
show_legend = n_samples <= 10
# Create a figure with subplots
fig = plt.figure(figsize=figsize)
gs = gridspec.GridSpec(n_methods, 1, height_ratios=[1] * n_methods)
# Get colors for samples
cmap = plt.get_cmap(color_map)
colors = [cmap(i % 10) for i in range(n_samples)]
# Plot original spectra
ax_orig = fig.add_subplot(gs[0])
for i, idx in enumerate(sample_indices):
label = f"Sample {idx + 1}" if show_legend else None
ax_orig.plot(
wavenumbers, original_spectra[idx], color=colors[i], alpha=alpha, label=label
)
ax_orig.set_title("Original Spectra", fontsize=12)
ax_orig.set_xlabel("")
ax_orig.set_ylabel("Absorbance")
if show_legend:
ax_orig.legend(loc="best", frameon=True)
ax_orig.grid(True, linestyle="--", alpha=0.3)
if invert_xaxis:
ax_orig.invert_xaxis()
# Plot processed spectra
for i, (method_name, processed) in enumerate(processed_spectra.items(), 1):
ax = fig.add_subplot(gs[i], sharex=ax_orig)
for j, idx in enumerate(sample_indices):
label = f"Sample {idx + 1}" if show_legend else None
ax.plot(wavenumbers, processed[idx], color=colors[j], alpha=alpha, label=label)
ax.set_title(f"{method_name}", fontsize=12)
if i == n_methods - 1:
ax.set_xlabel(xlabel, fontsize=12)
else:
ax.set_xlabel("")
ax.set_ylabel("Absorbance")
ax.grid(True, linestyle="--", alpha=0.3)
plt.suptitle(title, fontsize=16, fontweight="bold", y=0.98)
plt.tight_layout(rect=(0, 0, 1, 0.97))
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches="tight")
return fig
[docs]
@staticmethod
def plot_optimization_results(
optimizer: PipelineOptimizer,
top_n: int = 5,
figsize: tuple[int, int] = (12, 8),
title: str = "Pipeline Optimization Results",
save_path: str | None = None,
):
"""
Plot optimization results from PipelineOptimizer.
Parameters
----------
optimizer : PipelineOptimizer
The fitted pipeline optimizer.
top_n : int, default=5
Number of top pipelines to display.
figsize : tuple, default=(12, 8)
Figure size.
title : str, default='Pipeline Optimization Results'
Plot title.
save_path : str, optional
If provided, save the figure to this path.
Returns
-------
fig : matplotlib.figure.Figure
The figure object.
"""
if optimizer.optimizer is None:
raise ValueError("No optimization results found. Run bayesian_optimize() first.")
# Get all results
results = optimizer.get_all_tested_pipelines()
# Sort by RMSE (ascending)
sorted_results = sorted(results, key=lambda x: x["rmse"])
# Select top N results
top_results = sorted_results[:top_n]
# Extract data for plotting. R² is not tracked per trial by the
# optimizer (only the RMSE-based objective is), so it arrives as NaN;
# substitute 0.0 for the bar heights to keep the plot well-defined.
pipelines = [" → ".join(res["pipeline_config"]) for res in top_results]
rmses = [res["rmse"] for res in top_results]
r2s = [
0.0 if (res["r2"] is None or np.isnan(res["r2"])) else res["r2"] for res in top_results
]
# Create figure with two subplots
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=figsize, sharex=True)
# Plot RMSE values
ax1.barh(pipelines, rmses, color="skyblue", alpha=0.8)
ax1.set_title("RMSE (lower is better)", fontsize=12)
ax1.set_ylabel("Pipeline Configuration")
ax1.grid(True, linestyle="--", alpha=0.3, axis="x")
# Add RMSE values as text
for i, rmse in enumerate(rmses):
ax1.text(rmse + max(rmses) * 0.01, i, f"{rmse:.4f}", va="center", fontsize=10)
# Plot R² values
ax2.barh(pipelines, r2s, color="lightgreen", alpha=0.8)
ax2.set_title("R² (higher is better)", fontsize=12)
ax2.set_ylabel("Pipeline Configuration")
ax2.set_xlabel("Score")
ax2.grid(True, linestyle="--", alpha=0.3, axis="x")
# Add R² values as text
for i, r2 in enumerate(r2s):
ax2.text(r2 + max(r2s) * 0.01, i, f"{r2:.4f}", va="center", fontsize=10)
plt.suptitle(title, fontsize=16, fontweight="bold")
plt.tight_layout(rect=(0, 0, 1, 0.96))
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches="tight")
return fig
[docs]
@staticmethod
def plot_prediction_scatter(
y_true: np.ndarray,
y_pred: np.ndarray,
title: str = "Prediction Performance",
xlabel: str = "Measured",
ylabel: str = "Predicted",
figsize: tuple[int, int] = (10, 8),
alpha: float = 0.7,
color: str = "blue",
add_metrics: bool = True,
save_path: str | None = None,
):
"""
Create a scatter plot of predicted vs true values.
Parameters
----------
y_true : array-like
True target values.
y_pred : array-like
Predicted target values.
title : str, default='Prediction Performance'
Plot title.
xlabel : str, default='Measured'
X-axis label.
ylabel : str, default='Predicted'
Y-axis label.
figsize : tuple, default=(10, 8)
Figure size.
alpha : float, default=0.7
Transparency of the points.
color : str, default='blue'
Color of the scatter points.
add_metrics : bool, default=True
Whether to add RMSE and R² metrics to the plot.
save_path : str, optional
If provided, save the figure to this path.
Returns
-------
fig : matplotlib.figure.Figure
The figure object.
ax : matplotlib.axes.Axes
The axes object.
"""
# Calculate metrics
rmse = np.sqrt(mean_squared_error(y_true, y_pred))
r2 = r2_score(y_true, y_pred)
# Create figure
fig, ax = plt.subplots(figsize=figsize)
# Plot scatter points
ax.scatter(y_true, y_pred, alpha=alpha, color=color, edgecolor="k", linewidth=0.5)
# Calculate and plot identity line
min_val = min(np.min(y_true), np.min(y_pred))
max_val = max(np.max(y_true), np.max(y_pred))
padding = (max_val - min_val) * 0.05
line_x = np.array([min_val - padding, max_val + padding])
ax.plot(line_x, line_x, "k--", alpha=0.7, label="Identity Line")
# Set axis limits
ax.set_xlim(min_val - padding, max_val + padding)
ax.set_ylim(min_val - padding, max_val + padding)
# Add metrics text if requested
if add_metrics:
metrics_text = f"RMSE = {rmse:.4f}\nR² = {r2:.4f}"
ax.text(
0.05,
0.95,
metrics_text,
transform=ax.transAxes,
bbox={"facecolor": "white", "alpha": 0.8, "boxstyle": "round,pad=0.5"},
verticalalignment="top",
fontsize=12,
)
# Set labels and title
ax.set_xlabel(xlabel, fontsize=12)
ax.set_ylabel(ylabel, fontsize=12)
ax.set_title(title, fontsize=14, fontweight="bold")
# Add grid
ax.grid(True, linestyle="--", alpha=0.3)
# Adjust layout
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches="tight")
return fig, ax
[docs]
@staticmethod
def plot_optimization_progress(
optimizer: PipelineOptimizer,
figsize: tuple[int, int] = (12, 6),
title: str = "Optimization Progress",
save_path: str | None = None,
):
"""
Plot optimization progress over iterations.
Parameters
----------
optimizer : PipelineOptimizer
The fitted pipeline optimizer.
figsize : tuple, default=(12, 6)
Figure size.
title : str, default='Optimization Progress'
Plot title.
save_path : str, optional
If provided, save the figure to this path.
Returns
-------
fig : matplotlib.figure.Figure
The figure object.
ax : matplotlib.axes.Axes
The axes object.
"""
if optimizer.optimizer is None:
raise ValueError("No optimization results found. Run bayesian_optimize() first.")
# Get all results
results = optimizer.get_all_tested_pipelines()
# Extract iteration and RMSE data
iterations = [res["trial"] for res in results if res["trial"] is not None]
rmses = [res["rmse"] for res in results if res["trial"] is not None]
# Create DataFrame for easier plotting
df = pd.DataFrame({"Iteration": iterations, "RMSE": rmses})
df = df.sort_values("Iteration")
# Create figure
fig, ax = plt.subplots(figsize=figsize)
# Plot RMSE values
ax.plot(df["Iteration"], df["RMSE"], marker="o", linestyle="-", color="blue", alpha=0.7)
# Calculate and plot running minimum RMSE
running_min = df["RMSE"].cummin()
ax.plot(
df["Iteration"],
running_min,
marker="",
linestyle="--",
color="red",
alpha=0.8,
label="Best RMSE",
)
# Add labels and title
ax.set_xlabel("Iteration", fontsize=12)
ax.set_ylabel("RMSE", fontsize=12)
ax.set_title(title, fontsize=14, fontweight="bold")
# Add legend
ax.legend(loc="upper right")
# Add grid
ax.grid(True, linestyle="--", alpha=0.3)
# Adjust layout
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches="tight")
return fig, ax
[docs]
@staticmethod
def plot_feature_importance(
wavenumbers: np.ndarray,
coefficients: np.ndarray,
title: str = "Feature Importance",
xlabel: str = "Wavenumber (cm$^{-1}$)",
ylabel: str = "Coefficient Value",
figsize: tuple[int, int] = (12, 6),
color: str = "purple",
highlight_threshold: float | None = None,
highlight_color: str = "red",
save_path: str | None = None,
):
"""
Plot feature importance from model coefficients.
Parameters
----------
wavenumbers : array-like
The x-axis values (wavenumbers).
coefficients : array-like
Model coefficients corresponding to each wavenumber.
title : str, default='Feature Importance'
Plot title.
xlabel : str, default='Wavenumber (cm$^{-1}$)'
X-axis label.
ylabel : str, default='Coefficient Value'
Y-axis label.
figsize : tuple, default=(12, 6)
Figure size.
color : str, default='purple'
Color of the line.
highlight_threshold : float, optional
If provided, highlights coefficients with absolute values above this threshold.
highlight_color : str, default='red'
Color for highlighted coefficients.
save_path : str, optional
If provided, save the figure to this path.
Returns
-------
fig : matplotlib.figure.Figure
The figure object.
ax : matplotlib.axes.Axes
The axes object.
"""
fig, ax = plt.subplots(figsize=figsize)
# Plot coefficients
ax.plot(wavenumbers, coefficients, color=color, alpha=0.7)
# Highlight important features if threshold is provided
if highlight_threshold is not None:
important_mask = np.abs(coefficients) > highlight_threshold
if np.any(important_mask):
ax.scatter(
wavenumbers[important_mask],
coefficients[important_mask],
color=highlight_color,
s=50,
zorder=3,
label=f"|Coef| > {highlight_threshold}",
)
ax.legend(loc="best")
# Add zero line
ax.axhline(y=0, color="gray", linestyle="--", alpha=0.5)
# Set labels and title
ax.set_xlabel(xlabel, fontsize=12)
ax.set_ylabel(ylabel, fontsize=12)
ax.set_title(title, fontsize=14, fontweight="bold")
# Add grid
ax.grid(True, linestyle="--", alpha=0.3)
# Adjust layout
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches="tight")
return fig, ax