Source code for pypesto.visualize.profiles

from collections.abc import Sequence
from warnings import warn

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import is_color_like
from matplotlib.ticker import MaxNLocator

from ..C import COLOR
from ..problem import Problem
from ..result import Result
from .clust_color import assign_colors
from .misc import process_result_list
from .reference_points import ReferencePoint, create_references


def _parameter_label(problem: Problem, idx: int) -> str:
    """Return a scale-aware axis label for parameter ``idx``."""
    name = problem.x_names[idx]
    scale = problem.x_scales[idx] if problem.x_scales is not None else "lin"
    if scale == "log10":
        return f"log10({name})"
    if scale == "log":
        return f"log({name})"
    return name


[docs] def profiles( results: Result | Sequence[Result], ax=None, profile_indices: Sequence[int] = None, size: tuple[float, float] = (18.5, 6.5), reference: ReferencePoint | Sequence[ReferencePoint] = None, colors: COLOR | list[COLOR] | np.ndarray | None = None, legends: Sequence[str] = None, x_labels: Sequence[str] = None, profile_list_ids: int | Sequence[int] = 0, ratio_min: float = 0.0, show_bounds: bool = False, plot_objective_values: bool = False, quality_colors: bool = False, ) -> plt.Axes: """ Plot classical 1D profile plot. Using the posterior, e.g. Gaussian like profile. Parameters ---------- results: List of or single `pypesto.Result` after profiling. ax: List of axes objects to use. profile_indices: List of integer values specifying which profiles should be plotted. size: Figure size (width, height) in inches. Is only applied when no ax object is specified. reference: List of reference points for optimization results, containing at least a function value fval. colors: List of colors, or single color. If multiple colors are passed, their number needs to correspond to either the number of results or the number of profile_list_ids. Cannot be provided if quality_colors is set to True. legends: Labels for line plots, one label per result object. x_labels: Labels for parameter value axes (e.g. parameter names). profile_list_ids: Index or list of indices of the profile lists to visualize. ratio_min: Minimum ratio below which to cut off. show_bounds: Whether to show, and extend the plot to, the lower and upper bounds. plot_objective_values: Whether to plot the objective function values instead of the likelihood ratio values. quality_colors: If set to True, the profiles are colored according to types of steps the profiler took. This gives additional information about the profile quality. Red indicates a step for which min_step_size was reduced, blue indicates a step for which max_step_size was increased, and green indicates a step for which the profiler had to resample the parameter vector due to optimization failure of the previous two. Black indicates a step for which none of the above was necessary. This option is only available if there is only one result and one profile_list_id (one profile per plot). Returns ------- ax: The plot axes. """ if colors is not None and quality_colors: raise ValueError( "Cannot visualize the profiles with `quality_colors` of profiler_result.color_path " " and `colors` provided at the same time. Please provide only one of them." ) # parse input results, profile_list_ids, colors, legends = process_result_list_profiles( results, profile_list_ids, legends, colors ) # get the parameter ids to be plotted profile_indices = process_profile_indices( results, profile_indices, profile_list_ids ) # loop over results for i_result, result in enumerate(results): for i_profile_list, profile_list_id in enumerate(profile_list_ids): fvals, color_paths = handle_inputs( result, profile_indices=profile_indices, profile_list=profile_list_id, ratio_min=ratio_min, plot_objective_values=plot_objective_values, ) # add x_labels for parameters if x_labels is None: x_labels = [ name for name, fval in zip( result.problem.x_names, fvals, strict=True ) if fval is not None ] # plot multiple results or profile runs into one figure? if len(results) == 1 and len(profile_list_ids) > 1: # multiple profile runs per axes object color_ind = i_profile_list else: # multiple results per axes object color_ind = i_result # If quality_colors is set to True, we use the colors provided # by profiler_result.color_path. This will be done only if there is # only one result and one profile_list_id (basically one profile per plot). if ( len(results) == 1 and len(profile_list_ids) == 1 and quality_colors ): color = color_paths else: color = colors[color_ind] # call lowlevel routine ax = profiles_lowlevel( fvals=fvals, ax=ax, size=size, color=color, legend_text=legends[color_ind], x_labels=x_labels, show_bounds=show_bounds, lb_full=result.problem.lb_full, ub_full=result.problem.ub_full, plot_objective_values=plot_objective_values, ) # parse and apply plotting options ref = create_references(references=reference) # plot reference points ax = handle_reference_points(ref, ax, profile_indices) plt.tight_layout() return ax
[docs] def profiles_lowlevel( fvals: float | Sequence[float], ax: Sequence[plt.Axes] | None = None, size: tuple[float, float] = (18.5, 6.5), color: COLOR | list[np.ndarray] | None = None, legend_text: str = None, x_labels=None, show_bounds: bool = False, lb_full: Sequence[float] = None, ub_full: Sequence[float] = None, plot_objective_values: bool = False, ) -> list[plt.Axes]: """ Lowlevel routine for profile plotting. Working with a list of arrays only, opening different axes objects in case. Parameters ---------- fvals: Values to plot. ax: List of axes object to use. size: Figure size (width, height) in inches. Is only applied when no ax object is specified. color: Color for profiles in plot. In case of quality_colors=True, this is a list of np.ndarray[RGBA] for each profile -- one color per profile point for each profile. legend_text: Label for line plots. show_bounds: Whether to show, and extend the plot to, the lower and upper bounds. lb_full: Lower bound. ub_full: Upper bound. plot_objective_values: Whether to plot the objective function values instead of the likelihood ratio values. Returns ------- The plot axes. """ # axes if ax is None: ax = [] fig = plt.figure() fig.set_size_inches(*size) create_new_ax = True else: plt.axes(ax[0]) fig = plt.gcf() create_new_ax = False # count number of necessary axes if isinstance(fvals, Sequence): n_fvals = len(fvals) else: n_fvals = 1 fvals = [fvals] # number of non-trivial profiles n_profiles = sum(fval is not None for fval in fvals) # if axes already exists, we have to match profiles to axes if not create_new_ax: if n_fvals != len(ax) and n_profiles != len(ax): raise ValueError( "Number of axes does not match number of profiles. Stopping." ) elif n_fvals == len(ax) and n_profiles != len(ax): # we may have some empty profiles, which we have to skip n_plots = n_fvals else: # n_profiles == len(ax):, we have exactly as many profiles as axes n_plots = n_profiles else: n_plots = n_profiles if lb_full is None: lb_full = [None] * len(fvals) if ub_full is None: ub_full = [None] * len(fvals) # compute number of columns and rows columns = np.ceil(np.sqrt(n_plots)) if n_plots > columns * (columns - 1): rows = columns else: rows = columns - 1 counter = 0 for i_plot, (fval, lb, ub) in enumerate( zip(fvals, lb_full, ub_full, strict=True) ): # if we have empty profiles and more axes than profiles: skip if n_plots != n_fvals and fval is None: continue # If we use colors from profiler_result.color_path, # we need to take the color path of each profile if isinstance(color, list) and isinstance(color[i_plot], np.ndarray): color_i = color[i_plot] else: color_i = color # handle legend if i_plot == 0: tmp_legend = legend_text else: tmp_legend = None # create or choose an axes object if create_new_ax: ax.append(fig.add_subplot(int(rows), int(columns), counter + 1)) else: plt.axes(ax[counter]) # plot if data if fval is not None: # run lowlevel routine for one profile ax[counter] = profile_lowlevel( fval, ax[counter], size=size, color=color_i, legend_text=tmp_legend, show_bounds=show_bounds, lb=lb, ub=ub, ) # labels if x_labels is None: ax[counter].set_xlabel(f"Parameter {i_plot}") else: ax[counter].set_xlabel(x_labels[counter]) if counter % columns == 0: if plot_objective_values: ax[counter].set_ylabel("Objective function value") else: ax[counter].set_ylabel("Log-posterior ratio") # increase counter and cleanup legend counter += 1 return ax
[docs] def profile_lowlevel( fvals: Sequence[float], ax: plt.Axes | None = None, size: tuple[float, float] = (18.5, 6.5), color: COLOR | np.ndarray | None = None, legend_text: str = None, show_bounds: bool = False, lb: float = None, ub: float = None, ) -> plt.Axes: """ Lowlevel routine for plotting one profile, working with a numpy array only. Parameters ---------- fvals: Values to plot. ax: Axes object to use. size: Figure size (width, height) in inches. Is only applied when no ax object is specified. color: Color for profiles in plot. A single color or an array of RGBA for each profile point legend_text: Label for line plots. show_bounds: Whether to show, and extend the plot to, the lower and upper bounds. lb: Lower bound. ub: Upper bound. Returns ------- The plot axes. """ # parse input fvals = np.asarray(fvals) # get colors if color is None or is_color_like(color): color = assign_colors([1.0], color) single_color = True else: single_color = False # axes if ax is None: ax = plt.subplots()[1] ax.set_xlabel("Parameter value") ax.set_ylabel("Log-posterior ratio") fig = plt.gcf() fig.set_size_inches(*size) # plot if fvals.size != 0: ax.xaxis.set_major_locator(MaxNLocator(integer=True)) xs = fvals[0, :] ratios = fvals[1, :] # If we use colors from profiler_result.color_path, # we need to make a mapping from profile points to their colors if not single_color: # Create a mapping from (x, ratio) to color point_to_color = dict( zip(zip(xs, ratios, strict=True), color, strict=True) ) else: point_to_color = None # Plot each profile point individually to allow for different colors for i in range(1, len(xs)): point_color = ( color[0] if single_color else tuple(point_to_color[(xs[i], ratios[i])]) ) ax.plot( [xs[i - 1], xs[i]], [ratios[i - 1], ratios[i]], color=color[0] if single_color else (0, 0, 0, 1), linestyle="-", ) if not single_color and point_color != (0, 0, 0, 1): ax.plot(xs[i], ratios[i], color=point_color, marker="o") else: ax.plot(xs[i], ratios[i], color=point_color, marker=".") # Plot legend text ax.plot([], [], color=color[0], label=legend_text) if legend_text is not None: ax.legend() if show_bounds: ax.set_xlim([lb, ub]) return ax
def handle_reference_points(ref, ax, profile_indices): """ Handle reference points. Parameters ---------- ref: list, optional List of reference points for optimization results, containing et least a function value fval ax: matplotlib.Axes, optional Axes object to use. profile_indices: list of integer values List of integer values specifying which profiles should be plotted. """ if len(ref) > 0: # loop over axes objects for i_par, i_ax in enumerate(ax): for i_ref in ref: current_x = i_ref["x"][profile_indices[i_par]] i_ax.plot( [current_x, current_x], [0.0, 1.0], color=i_ref.color, label=i_ref.legend, ) # create legend for reference points if i_ref.legend is not None: i_ax.legend() return ax def handle_inputs( result: Result, profile_indices: Sequence[int], profile_list: int, ratio_min: float, plot_objective_values: bool, ) -> tuple[list, list]: """ Retrieve the values of the profiles to be plotted. Parameters ---------- result: Profile result obtained by 'profile.py'. profile_indices: Sequence of integer values specifying which profiles should be plotted. profile_list: Index of the profile list to be used for profiling. ratio_min: Exclude values where profile likelihood ratio is smaller than ratio_min. plot_objective_values: Whether to plot the objective function values instead of the likelihood Returns ------- List of parameter values and ratios that need to be plotted. """ # extract ratio values from result fvals = [] colors = [] for i_par in range(0, len(result.profile_result.list[profile_list])): if ( i_par in profile_indices and result.profile_result.list[profile_list][i_par] is not None ): xs = result.profile_result.list[profile_list][i_par].x_path[ i_par, : ] ratios = result.profile_result.list[profile_list][ i_par ].ratio_path[:] colors_for_par = result.profile_result.list[profile_list][ i_par ].color_path # constrain indices = np.where(ratios > ratio_min) xs = xs[indices] ratios = ratios[indices] colors_for_par = colors_for_par[indices] if plot_objective_values: obj_vals = result.profile_result.list[profile_list][ i_par ].fval_path obj_vals = obj_vals[indices] fvals_for_par = np.array([xs, obj_vals]) else: fvals_for_par = np.array([xs, ratios]) else: fvals_for_par = None colors_for_par = None fvals.append(fvals_for_par) colors.append(colors_for_par) return fvals, colors def process_result_list_profiles( results: Result | list[Result], profile_list_ids: int | Sequence[int] | None, legends: str | list[str], colors: COLOR | list[COLOR] | np.ndarray | None = None, # todo: check ) -> tuple[list[Result], list[int] | Sequence[int], list, list[str]]: """ Assign colors and legends to a list of results. Takes also care of the special cases for profile plotting. Parameters ---------- results: List of or single `pypesto.Result` after profiling. profile_list_ids: Index or list of indices of the profile lists to be used for profiling. colors: list of colors for plotting. legends: Legends for plotting Returns ------- profile_indices: list of integer values corrected list of integer values specifying which profiles should be plotted. """ # ensure list of ids if isinstance(profile_list_ids, int): profile_list_ids = [profile_list_ids] # check if we have a single result if isinstance(results, list): if len(results) != 1: # if we have no single result, then use the standard api results, colors, legends = process_result_list( results, colors, legends ) return results, profile_list_ids, colors, legends else: # a single results was provided, so make a list out of it results = [results] # If we have a single result, we may still have multiple profile_list_ids # which should be plotted separately: use profile_list_ids as results dummy _, colors, legends = process_result_list(profile_list_ids, colors, legends) return results, profile_list_ids, colors, legends def process_profile_indices( results: Sequence[Result], profile_indices: Sequence[int], profile_list_ids: int | Sequence[int], ): """ Clean up profile_indices to be plotted. Retrieve the indices of the parameter for which profiles should be plotted later from a list of pypesto.ProfileResult objects. """ # get all parameter indices, for which profiles were computed plottable_indices = set() for result in results: for profile_list_id in profile_list_ids: # get parameter indices, for which profiles were computed if profile_list_id < len(result.profile_result.list): tmp_indices = [ par_id for par_id, prof in enumerate( result.profile_result.list[profile_list_id] ) if prof is not None ] # profile_indices should contain all parameter indices, # for which in at least one of the results a profile exists plottable_indices.update(tmp_indices) plottable_indices = sorted(plottable_indices) # get the profiles, which should be plotted and sanitize, if not plottable if profile_indices is None: profile_indices_ret = list(plottable_indices) else: profile_indices_ret = list(profile_indices) for ind in profile_indices: if ind not in plottable_indices: profile_indices_ret.remove(ind) warn( f"Requested to plot profile for parameter index {ind}, " "but profile has not been computed.", stacklevel=2, ) return profile_indices_ret
[docs] def profile_lowlevel_2d( result: Result, profile_index: int, second_par_index: int, ax: plt.Axes, profile_list_id: int = 0, ratio_min: float = 0.0, cmap: str = "viridis", plot_objective_values: bool = False, x_labels: Sequence[str] = None, vmin: float = None, vmax: float = None, ) -> plt.Axes: """ Lowlevel routine for plotting a two-parameter profile visualization. Visualizes the profile of one parameter (x-axis) while showing the values of a second parameter (y-axis), with colors indicating the objective ratio or function value. Axis limits are always set to the parameter bounds. Axis labels include the parameter scale (e.g. ``log10(k1)``) unless overridden via ``x_labels``. Parameters ---------- result: A single `pypesto.Result` after profiling. profile_index: Integer index specifying which profile to plot (x-axis parameter). second_par_index: Integer index specifying which parameter to show on y-axis. ax: Axes object to use for plotting. profile_list_id: Index of the profile list to visualize. ratio_min: Minimum ratio below which to cut off. cmap: Colormap to use for the objective ratio/value colors. plot_objective_values: Whether to plot the objective function values instead of the likelihood ratio values. x_labels: Labels for the parameters (indexed by full parameter index). If None, labels are auto-generated from parameter names and scales. vmin: Minimum value for the color scale. If None, auto-scaled to the data. vmax: Maximum value for the color scale. If None, auto-scaled to the data. Returns ------- The plot axes. """ if result.profile_result is None: raise ValueError("Result does not contain profile results.") profile_list = result.profile_result.list[profile_list_id] if profile_list[profile_index] is None: raise ValueError( f"Profile for parameter {profile_index} has not been computed." ) profiler_result = profile_list[profile_index] x_path = profiler_result.x_path ratio_path = profiler_result.ratio_path fval_path = profiler_result.fval_path x_values = x_path[profile_index, :] y_values = x_path[second_par_index, :] color_values = fval_path if plot_objective_values else ratio_path # Filter based on ratio_min indices = np.where(ratio_path > ratio_min) x_values = x_values[indices] y_values = y_values[indices] color_values = color_values[indices] ax.scatter( x_values, y_values, c=color_values, cmap=cmap, s=30, edgecolors="black", linewidths=0.3, vmin=vmin, vmax=vmax, ) ax.plot(x_values, y_values, "k-", alpha=0.2, linewidth=0.8, zorder=0) def _label(idx): if x_labels is not None: return x_labels[idx] return _parameter_label(result.problem, idx) ax.set_xlabel(_label(profile_index)) ax.set_ylabel(_label(second_par_index)) # Always extend axes to parameter bounds ax.set_xlim( [ result.problem.lb_full[profile_index], result.problem.ub_full[profile_index], ] ) ax.set_ylim( [ result.problem.lb_full[second_par_index], result.problem.ub_full[second_par_index], ] ) return ax
[docs] def visualize_2d_profile( result: Result, profile_indices: Sequence[int] = None, size: tuple[float, float] = None, profile_list_id: int = 0, ratio_min: float = 0.0, cmap: str = "viridis", plot_objective_values: bool = False, x_labels: Sequence[str] = None, profile_color: COLOR | np.ndarray | None = None, reference: ReferencePoint | Sequence[ReferencePoint] = None, ) -> tuple[plt.Figure, np.ndarray]: """ Create an n×n grid of profile plots. Diagonal plots show 1D profiles (likelihood ratio vs. parameter value). Off-diagonal plots show the path of one parameter while another is profiled, with color indicating the likelihood ratio or objective value. Parameters ---------- result: A single `pypesto.Result` after profiling. profile_indices: List of integer indices specifying which parameters to include. If None, all parameters with computed profiles are included. size: Figure size (width, height) in inches. If None, automatically sized based on number of parameters (3 inches per parameter). profile_list_id: Index of the profile list to visualize. ratio_min: Minimum ratio below which to cut off. cmap: Colormap to use for the 2D off-diagonal scatter plots. plot_objective_values: Whether to plot the objective function values instead of the likelihood ratio values. x_labels: Labels for the parameters (indexed by full parameter index). If None, labels are auto-generated from parameter names and scales. profile_color: Color for the diagonal 1D profile lines. Passed directly to :func:`profile_lowlevel`. If None, the default color is used. reference: List of reference points for optimization results, shown on diagonal 1D plots. Returns ------- fig: The figure object. axes: Array of axes objects (n×n grid). """ if result.profile_result is None: raise ValueError("Result does not contain profile results.") profile_list = result.profile_result.list[profile_list_id] if profile_indices is None: profile_indices = [ i for i, prof in enumerate(profile_list) if prof is not None ] n_params = len(profile_indices) if n_params == 0: raise ValueError("No profiles available to plot.") if size is None: # +1 inch of extra width reserves space for the colorbar so that # each subplot cell remains approximately square. size = (n_params * 3 + 1, n_params * 3) fig, axes = plt.subplots( n_params, n_params, figsize=size, constrained_layout=True ) if n_params == 1: axes = np.array([[axes]]) ref = create_references(references=reference) def _label(idx): if x_labels is not None: return x_labels[idx] return _parameter_label(result.problem, idx) # Compute global color range across all 2D off-diagonal subplots so the # shared colorbar is accurate for every panel. all_color_values = [] for row_idx in profile_indices: for col_idx in profile_indices: if row_idx == col_idx or profile_list[col_idx] is None: continue profiler = profile_list[col_idx] mask = profiler.ratio_path > ratio_min vals = ( profiler.fval_path[mask] if plot_objective_values else profiler.ratio_path[mask] ) if vals.size > 0: all_color_values.append(vals) if all_color_values: all_vals = np.concatenate(all_color_values) color_vmin, color_vmax = float(all_vals.min()), float(all_vals.max()) else: color_vmin, color_vmax = None, None # Track the last successful 2D axes for the shared colorbar last_2d_ax = None for i, row_param_idx in enumerate(profile_indices): for j, col_param_idx in enumerate(profile_indices): ax = axes[i, j] if i == j: # Diagonal: 1D profile fvals, _ = handle_inputs( result, profile_indices=[row_param_idx], profile_list=profile_list_id, ratio_min=ratio_min, plot_objective_values=plot_objective_values, ) if fvals[row_param_idx] is not None: profile_lowlevel( fvals[row_param_idx], ax, show_bounds=True, color=profile_color, lb=result.problem.lb_full[row_param_idx], ub=result.problem.ub_full[row_param_idx], ) # Fix integer tick locator from profile_lowlevel for float params ax.xaxis.set_major_locator(plt.AutoLocator()) ax.set_xlabel(_label(row_param_idx)) ax.set_ylabel( "Objective value" if plot_objective_values else "Log-posterior ratio" ) if len(ref) > 0: for i_ref in ref: current_x = i_ref["x"][row_param_idx] ax.plot( [current_x, current_x], [0.0, 1.0], color=i_ref.color, label=i_ref.legend if i == 0 and j == 0 else None, ) if i == 0 and j == 0 and i_ref.legend is not None: ax.legend() else: # Off-diagonal: 2D profile # subplot (i, j): x-axis = col_param_idx, y-axis = row_param_idx try: profile_lowlevel_2d( result=result, profile_index=col_param_idx, second_par_index=row_param_idx, ax=ax, profile_list_id=profile_list_id, ratio_min=ratio_min, cmap=cmap, plot_objective_values=plot_objective_values, x_labels=x_labels, vmin=color_vmin, vmax=color_vmax, ) last_2d_ax = ax except (ValueError, IndexError): ax.text( 0.5, 0.5, "No profile", ha="center", va="center", transform=ax.transAxes, ) ax.set_xticks([]) ax.set_yticks([]) # Pairplot-style axis label cleanup: # - bottom row only gets x-axis labels (parameter names) # - leftmost column gets y-axis label = parameter name for that row # - off-diagonal subplots not in leftmost column have y-labels hidden # - diagonal subplots not in leftmost column keep their ratio y-label for i in range(n_params): for j in range(n_params): if i < n_params - 1: axes[i, j].set_xlabel("") axes[i, j].tick_params(labelbottom=False) if j > 0 and i != j: axes[i, j].set_ylabel("") axes[i, j].tick_params(labelleft=False) # Override leftmost-column y-label with row parameter name axes[i, 0].set_ylabel(_label(profile_indices[i])) # Add a shared colorbar for all 2D off-diagonal plots if last_2d_ax is not None: scatter = last_2d_ax.collections[-1] cbar = fig.colorbar(scatter, ax=axes) cbar.set_label( "Objective value" if plot_objective_values else "Log-posterior ratio", rotation=270, labelpad=20, ) return fig, axes