Skip to content

Fix: harmonise visualize style -- axes, returns, attributes#1709

Open
Doresic wants to merge 13 commits into
developfrom
fix-harmonise-visualize-style
Open

Fix: harmonise visualize style -- axes, returns, attributes#1709
Doresic wants to merge 13 commits into
developfrom
fix-harmonise-visualize-style

Conversation

@Doresic
Copy link
Copy Markdown
Contributor

@Doresic Doresic commented May 4, 2026

This PR lays the foundation for a coherent pyPESTO visualization API. It primarily normalizes the contracts that callers depend on, so that downstream style work can be applied uniformly, with a few small review-driven follow-up changes to helper placement, layout handling, and observable-mapping axes handling.

What changed

Shared helpers in pypesto/visualize/misc.py
Shared helpers used by all plotters:

  • get_ax(ax, size) — returns the provided axes or creates a new figure/axes pair
  • get_axes_array(axes, nrows, ncols, size) — returns the provided 2-D array or creates a new subplot grid; validates shape if an existing array is passed
  • plot_diagonal_marginal(ax, values, diag_kind) — shared KDE/histogram for scatter-matrix diagonals
  • process_deprecated_kwarg(...) — generic rename shim with DeprecationWarning
  • make_grid_shape(n_panels) — shared near-square subplot-grid helper

Type hints (all visualize files)

  • tupletuple[float, float] for size parameters
  • ax: Axes = Noneax: Axes | None = None throughout
  • Added -> matplotlib.axes.Axes and -> np.ndarray return annotations where missing

Single-panel ax contract
All single-panel public functions now accept ax: matplotlib.axes.Axes | None and return matplotlib.axes.Axes.

Multi-panel axes grid contract
All multi-panel public functions now accept axes: np.ndarray | None and return a 2-D np.ndarray of Axes. optimization_scatter rewritten from seaborn PairGrid to pure matplotlib: per-panel KDE diagonals, fval-encoded viridis_r colorbar, correct lb_full/ub_full[parameter_indices[i]] bound indexing.

sampling_parameter_cis: alphaconfidence_levels

  • Old: alpha: Sequence[int] (integer percentages, e.g. [95, 68])
  • New: confidence_levels: Sequence[float] (fractions, e.g. [0.95, 0.68])
  • alpha kept as a deprecated kwarg; auto-converts with DeprecationWarning

profiles: confidence_level convenience parameter
New optional confidence_level: float | None = None alongside ratio_min; converts via chi2_quantile_to_ratio.

Layout handling
Where figures are created internally, they now use constrained layout instead of forcing tight_layout() afterward.

Tests

  • helper tests moved from test/visualize/test_style.py to test/visualize/test_misc.py
  • close_fig moved to test/conftest.py so it can be reused across visualization tests

What this PR does NOT touch

  • Default figure sizes, font sizes, or any rcParams
  • Spine removal, colorbar style unification (planned for follow-up)
  • model_fit.py, ordinal_categories.py internals

Doresic added 7 commits April 29, 2026 17:25
- Add pypesto/visualize/_style.py with shared helpers get_ax and process_deprecated_kwarg. The latter is the canonical pattern for the visualization signature harmonization that follows.
- Drop the module-level cmap global in sampling.py; inline at the single use site.
- Rename par_indices → parameter_indices in sampling_parameter_traces, sampling_1d_marginals, and the internal get_data_to_plot. Public functions accept par_indices as a deprecated alias (DeprecationWarning).
- ax: canonicalize to `matplotlib.axes.Axes | None = None` everywhere
  (was `plt.Axes | None`, `matplotlib.axes.Axes` without `| None`, or
  untyped `ax=None` in profiles.py)
- size: fix `tuple[float]` → `tuple[float, float]` in
  optimizer_convergence.py and waterfall_lowlevel; bare `tuple` →
  `tuple[float, float]` in optimizer_history_lowlevel
- title/suptitle: `str = None` → `str | None = None` in sampling functions
- Add `import matplotlib.axes` to waterfall.py, optimizer_convergence.py,
  optimizer_history.py, profiles.py
- switch remaining single-panel plotters and lowlevel helpers to the
  shared `get_ax` helper
- remove ad hoc pyplot-based axes creation in favor of the shared path
- ensure plots still fully configure passed-in axes, including labels
  and other axis-level metadata
- make `projection_scatter_umap_original` explicitly accept and return
  an axes object
- preserve backward compatibility for
  `optimization_run_properties_one_plot` by keeping positional argument
  order intact
- keep tests broad and figure-focused, with targeted checks only where
  behavior actually changed
- `get_axes_array(axes, nrows, ncols, size)` — create or normalize a 2-D
  axes grid; raises on shape mismatch
- `plot_diagonal_marginal(ax, values, diag_kind)` — KDE/hist marginal
  extracted from `optimization_scatter` so `sampling_scatter` can share it

Functions updated: `optimization_scatter`, `sampling_scatter`,
`sampling_1d_marginals`, `sampling_prediction_trajectories`,
`visualize_estimated_observable_mapping`,
`plot_linear_observable_mappings_from_pypesto_result`,
`plot_splines_from_pypesto_result`, `plot_splines_from_inner_result`,
`ensemble_scatter_lowlevel`, `projection_scatter_umap_original`,
`optimization_run_properties_one_plot`.

Also removes all `sns.set(style="ticks")` calls from `sampling.py` —
these were the root cause of global matplotlib style mutations that made
plots look inconsistent depending on call order.
`sampling_parameter_cis` accepted `alpha: Sequence[int]` (e.g. `[95]`)
as a credibility level in integer percentages. This was mis-named (not
matplotlib transparency) and used inconsistent units compared to every
other CI parameter in the codebase (all others use 0–1 fractions).
`profiles` accepted only `ratio_min` (a likelihood-ratio threshold) to
filter profile points. Users wanting to cut off at a standard confidence
level had to call `chi2_quantile_to_ratio` manually first.

New additive kwarg `confidence_level: float | None = None` — pass e.g.
`confidence_level=0.95` and it is converted to the equivalent `ratio_min`
via `chi2_quantile_to_ratio`. The two parameters are mutually exclusive;
passing both raises `ValueError`. `ratio_min` is unchanged and keeps its
existing meaning and default.
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 4, 2026

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 83.65123% with 60 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.29%. Comparing base (37e21df) to head (5eb1381).

Files with missing lines Patch % Lines
pypesto/visualize/observable_mapping.py 60.00% 22 Missing ⚠️
pypesto/visualize/parameters.py 84.21% 12 Missing ⚠️
pypesto/visualize/misc.py 87.17% 10 Missing ⚠️
pypesto/visualize/sampling.py 90.00% 8 Missing ⚠️
pypesto/visualize/dimension_reduction.py 85.18% 4 Missing ⚠️
pypesto/visualize/profiles.py 72.72% 3 Missing ⚠️
pypesto/visualize/optimization_stats.py 95.00% 1 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1709      +/-   ##
===========================================
+ Coverage    83.99%   84.29%   +0.30%     
===========================================
  Files          164      164              
  Lines        14492    14636     +144     
===========================================
+ Hits         12172    12337     +165     
+ Misses        2320     2299      -21     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Doresic Doresic marked this pull request as ready for review May 5, 2026 12:38
Comment thread pypesto/visualize/ensemble.py Outdated
size: tuple[float] | None = (12, 6),
):
size: tuple[float, float] | None = (12, 6),
) -> plt.Axes:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this rather be matplotlib.axes.Axes as in the other files?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yepp will change

Comment thread pypesto/visualize/ensemble.py Outdated
size: tuple[float] | None = (16, 10),
):
size: tuple[float, float] | None = (16, 10),
) -> plt.Axes:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yepp will change

list(cmap(v))[:LEN_RGB]
list(matplotlib.cm.viridis(v))[:LEN_RGB]
for v in np.linspace(cmap_min, cmap_max, n_variables)
]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole function is pretty tailored to this one specific colormap.
Since we are touching visualizations anyways, we could think about making this more verstaile. So a user could use different colormaps and cut-offs (0.3, 0.85) and we leave the current one as defaults.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea
I agree
Paul suggested below some similar additions of allowing the user pass settings to the functions.
I would tackle all of these together in the next PR that'll focus on style of all plots + the possibility of used adding style changes to the functions or even whole module. There could be a very cool module-wide-changes thing one can do and define.
A simple thing could be just allowing the user to pass a dictionary of kwargs that will get distributed acorss all plotting functions to change sizes shapes etc. -- up to some limit, adding all would probably be too much.
And then having a specification that's general across all visualisation functions in _style.py of general sizes/font sizes etc that will be used by default and imported from the _style as constants.

ax: plt.Axes | None = None,
**kwargs,
):
) -> plt.Axes:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be matplotlib.axes.Axes?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either is fine.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll put it to matplotlib.axes.Axes just for consistency.

Copy link
Copy Markdown
Collaborator

@PaulJonasJost PaulJonasJost left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some great ideas among this. Not entirely sure what the big picture is, you want to move towards with this initial PR as the AI summary hints here. Would perhaps be important to discuss this once in the near future.

Comment thread pypesto/visualize/_style.py Outdated
Comment on lines +4 to +5
Cross-cutting pieces used by multiple plotters live here so each plotter
does not reinvent them.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Cross-cutting pieces used by multiple plotters live here so each plotter
does not reinvent them.

Superfluous

legends: str | list[str] | None = None,
plot_type: str = "line",
) -> matplotlib.axes.Axes:
) -> np.ndarray:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it is an array, yes it says that the array consist of axes, yet i find the previous matplotlib.axes.Axematplotlib.axes.Axes still more informative

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm yeah I see the point. matplotlib.axes.Axes is more informative, but in this case it was wrong to use it.
The code defines axes, a np.ndarray of axes. In both cases.

  • if plot_type!="both" it creates a (1,1) array of Axes objects
  • if plot_type=="both" it creates a (1,2) array of Axes objects
    so with the current code of the visualisation, matplotlib.axes.Axes would always have been wrong.

What we can do is make it return really just a matplotlib.axes.Axes object in the first case, and then say the return type is either this or that depending on the settings. But I would vote for current situation instead, to make each function always have one type of output instead of the output type changing depending on input parameters.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't consider an incorrect type more informative 🤔

n = len(parameter_indices)
x_labels = [result.problem.x_names[i] for i in parameter_indices]

ax = sns.pairplot(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I see this correctly, we have changed something like 10 lines of code for 120, for an improvement that to me is not immediately visible.

If the goal is to move to purely matplotlib, that might be a reason, but I don't see why we would push for that necessarily.

Summary of this specific change (my opinion, to be discussed): This does not improve the code, rather the opposite.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, true
Initial reason for changing this code was to go away from the different return object (in the idea of harmonisation) -- a Seaborn grid object -- compared to the object we return in most cases -- either Axes or a np.ndarray of them.
Furthermore, a second PR later on would focus on harmonisation of general looks of the figures across the module. In the sense that the examples we have in docs have figures with similar fonts, color schemes, and general looks. So moving only to matplotlib would make that simpler. For instance, it's nice to have all plots of this type, a scatter plot with diagonal distributions, have a colorbar on the side to map objective function value to the scatter plot dots. This is a bit tough or would be very different from other ones if we stuck with seaborn.

So I would vote for this. I do see it's more lines just since matplotlib doesn't have an integrated visualisation of this type of a plot, but this allows us a nicer freedom of playing with it to make "our style" across the module.

Comment thread pypesto/visualize/profiles.py Outdated
@@ -408,10 +424,10 @@ def profile_lowlevel(
ax: plt.Axes | None = None,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No changes here to the Axes? If we make a point of harmonising it, please check all functions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ty, missed some

Comment thread pypesto/visualize/_style.py Outdated
return axes_array


def plot_diagonal_marginal(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not really a style thing right? In general, why isn't this whole file put into misc.py or in a utility.py which we rather use?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, was thinking about that
feels nicer in misc.py

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think all of these functions fit better to misc.py
There will be a purpose to the style file but it's not there yet -- next PR stuff that actually deal with style.
In the sense that it will contain constants of sizes, fonts etc that would be used as style defaults across the vis. module.
Makes more sense yup

Comment on lines +733 to +736
color="C2",
linewidth=1.8,
label="Spline function",
zorder=2,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I again feel like with more hardcoded style parameters, we move away from a flexible visualisation approach.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to above

stepsize=stepsize,
full_trace=full_trace,
par_indices=par_indices,
parameter_indices=parameter_indices,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs a depracation hint as in lines 1359

Comment on lines -1228 to -1229
ax = sns.pairplot(
params_fval.drop(["logPosterior", "iteration"], axis=1),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same argument as in the other scatterplot. Is this change useful?!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so yeah
Reasoning above

Comment on lines +1001 to +1019
if alpha is not None:
if confidence_levels is not None:
raise ValueError(
"Pass either `confidence_levels` or the deprecated `alpha`, not both."
)
import warnings

warnings.warn(
"`alpha` is deprecated; use `confidence_levels` instead. "
"Note: units have changed — pass fractions in (0, 1) "
"(e.g. `confidence_levels=[0.95]`) instead of integer percentages "
"(e.g. `alpha=[95]`). Your values have been divided by 100 automatically.",
DeprecationWarning,
stacklevel=2,
)
confidence_levels = [a / 100 for a in alpha]

if confidence_levels is None:
confidence_levels = [0.95]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be a target that should also be covered by process_deprecated_kwarg right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but this one is a bit different. The kwarg deprecation is only changing the naming of some argument.
This is much more specific deprecation -- the value users provide has to be changed as well. So I made a custom one for here to point this out to not cause very plotting outcomes for people without warning.

Comment thread pypesto/visualize/_style.py Outdated
ax.set_ylabel("Count")


def process_deprecated_kwarg(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel this is an amazing idea. So amazing, this should not be confined to visualization alone and also perhaps not to specific instances, rather have this as some kind of decorator on top of a function. Instead of passing the value we can have some kind of transformation function (either identity or in this case for example y = x/100), this would be great for maintaining this and can be easily removed at the next major release as discussed in our versioning policy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good idea
For now I made it only in this module, so I kept it at this point.
But definitely a good idea for a separate whole-package-wide PR introducing this concept for further use
Apart from a transformation function, then also a custom message should also be possible to add. Cause without it, the warning would be too vague in case of an added transformation. Or maybe just messages depending on the transformation.
Definitely a good addition for whole pyPESTO

@PaulJonasJost PaulJonasJost requested a review from dweindl May 6, 2026 10:39
Comment thread pypesto/visualize/_style.py Outdated
value:
The resolved value, or ``None`` if neither was given.
"""
if deprecated_value is None:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will miss f(deprecated_kwarg=None), right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm that's true
I didn't think about this, in case a user wanted to pass None, this would not catch it.
Currently I don't think I have examples of where I use this deprecation in which a user would ever want to pass None. But there might be

Should we then in this case

  • have a specific _UNSET value that tracks whether something is set or not for variables that are set or not that would then be different from None to catch None's too?
  • just have a dosctring acknowledging this limitation for future usage of this function (mostly for us) to make sure the case where we use it is not one of these cases?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd go for the first option, which is probably even faster...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc incompatible with return type annotation.

ax: plt.Axes | None = None,
**kwargs,
):
) -> plt.Axes:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either is fine.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc incompatible with return type annotation.

Comment thread pypesto/visualize/observable_mapping.py Outdated
pass


def _observable_mapping_grid_shape(n_panels: int) -> tuple[int, int]:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't really related to observable mappings. Rename and move to general plotting utils?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep true, makes sense, we use this at many places. Will add to misc and use it anywhere we want to define a grid with a certain number of plots

legends: str | list[str] | None = None,
plot_type: str = "line",
) -> matplotlib.axes.Axes:
) -> np.ndarray:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't consider an incorrect type more informative 🤔

Comment thread pypesto/visualize/sampling.py Outdated
@@ -1178,18 +1210,19 @@ def sampling_parameter_traces(
fig.tight_layout()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user is allowed to pass fig/ax, we should remove all calls to tight_layout(). The user may have chosen a different layout engine which would be replaced here - that's annoying.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very true, didn't have this in mind
hmmm should this be "no tight layout in case an ax is passed" or in general?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No experience with this specific instance, but overall, I think, tight_layout will disappear.
I'd suggest using plt.subplots(..., layout="constrained") in get_ax et al., and just removing all tight_layout() calls.

Comment thread test/visualize/test_style.py Outdated
custom = get_ax(size=(4.0, 3.0))
assert tuple(custom.get_figure().get_size_inches()) == (4.0, 3.0)

plt.close("all")
Copy link
Copy Markdown
Member

@dweindl dweindl May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's close_fig to be used as decorator in test_visualizy.py. Move that to conftest.py so it can also be used here. Replace all direct plt.close("all") calls to ensure figures are closed also in case of test failure.

- move shared visualize helpers from _style.py into misc.py
- add hide_unused_axes and reuse it across plotting functions that trim
  unused subplot panels
- switch internal figure creation to constrained layout and stop forcing
  tight_layout afterwards
- keep deprecated-kwarg handling with _UNSET-based detection of omitted
  vs explicit None
- update observable mapping axes preparation to use the shared helpers
- move helper tests from test_style.py to test_misc.py
- move close_fig to test/conftest.py and ensure figure cleanup also
  happens on test failure
@Doresic
Copy link
Copy Markdown
Contributor Author

Doresic commented May 6, 2026

Thank you a lot for all the comments!
They should be addressed now
Feel free to take another look @dweindl @PaulJonasJost @vwiela

@dweindl
Copy link
Copy Markdown
Member

dweindl commented May 7, 2026

They should be addressed now

Didn't check everything in all details, but my points have been addressed, thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants