Skip to content

MultiNorm class #29876

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open

Conversation

trygvrad
Copy link
Contributor

@trygvrad trygvrad commented Apr 6, 2025

PR summary

This PR continues the work of #28658 and #28454, aiming to close #14168. (Feature request: Bivariate colormapping)

This is part one of the former PR, #29221. Please see #29221 for the previous discussion

Features included in this PR:

  • A MultiNorm class. This is a subclass of colors.Normalize and holds n_variate norms.
  • Testing of the MultiNorm class

Features not included in this PR:

  • changes to colorizer.py needed to expose the MultiNorm class
  • Exposes the functionality provided by MultiNorm together with BivarColormap and MultivarColormap to the plotting functions axes.imshow(...), axes.pcolor, and `axes.pcolormesh(...)
  • Testing of the new plotting methods
  • Examples in the docs

Comment on lines 4103 to 4105
in the case where an invalid string is used. This cannot use
`_api.check_getitem()`, because the norm keyword accepts arguments
other than strings.
Copy link
Member

Choose a reason for hiding this comment

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

I'm confused because this function is only called for isinstance(norm, str).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So this function exists because the norm keyword accepts Normalize objects in addition to strings.
This is fundamentally the same error you get if you give an invalid norm to a Colorizer object.

In main, the @norm.setter on colorizer.Colorizer reads:

    @norm.setter
    def norm(self, norm):
        _api.check_isinstance((colors.Normalize, str, None), norm=norm)
        if norm is None:
            norm = colors.Normalize()
        elif isinstance(norm, str):
            try:
                scale_cls = scale._scale_mapping[norm]
            except KeyError:
                raise ValueError(
                    "Invalid norm str name; the following values are "
                    f"supported: {', '.join(scale._scale_mapping)}"
                ) from None
            norm = _auto_norm_from_scale(scale_cls)()
    ...

The _get_scale_cls_from_str() exists in this PR because this functionality is now needed by both colorizer.Colorizer.norm() and colors.MultiNorm.
Note this PR does not include changes to colorizer.Colorizer.norm() so that it makes use of _get_scale_cls_from_str(). These changes follow in the next PR: #29877 .

@trygvrad trygvrad force-pushed the multivariate-plot-prapare branch from 55b85e3 to f42d65b Compare April 17, 2025 15:18
Copy link
Contributor

@anntzer anntzer left a comment

Choose a reason for hiding this comment

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

Just some minor points, plus re-pinging @timhoffm in case he has an opinion re: n_input / input_dims naming?

@trygvrad
Copy link
Contributor Author

trygvrad commented May 4, 2025

Thank you for the feedback @anntzer !
Hopefully we can hear if @timhoffm has any thoughts on n_input / input_dims naming within the coming week.

@timhoffm
Copy link
Member

timhoffm commented May 5, 2025

See #29876 (comment)

@trygvrad
Copy link
Contributor Author

trygvrad commented May 7, 2025

Thank you @timhoffm
The PR should now be as we agreed (#29876 (comment)) :)

@trygvrad
Copy link
Contributor Author

trygvrad commented Jun 1, 2025

@QuLogic Thank you again and apologies for my tardiness (I was sick)
@timhoffm Do you think you could approve this PR now?

vmin, vmax : float, None, or list of float or None
Limits of the constituent norms.
If a list, each value is assigned to each of the constituent
norms. Single values are repeated to form a list of appropriate size.
Copy link
Member

Choose a reason for hiding this comment

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

Is broadcasting reasonable here? I would assume that most MultiNorms have different scales and thus need per-element entries anyway. It could also be an oversight to pass a single value instead of multiple values.

I'm therefore tempted to not allow scalars here but require exactly n_variables values. A more narrow and explicit interface may be the better start. We can always later expand the API to broadcast scalars if we see that's a typical case and reasonable in terms of usability.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@timhoffm Perhaps this is also a topic for the weekly meeting :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm perfectly fine with removing this here, and perhaps that is a good starting point.

My entry into this topic was a use case (dark-field X-ray microscopy, DFXRM) where we typically want vmax0 = vmax1 = -vmin0 =-vmin1, i.e. equal normalizations, and centered on zero, and given that entry point it felt natural to me to include broadcasting.

Copy link
Member

Choose a reason for hiding this comment

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

We still need a decision here. I slightly favor the more restrictive version of always requiring a tuple, but could be convinced otherwise.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just for clarification, any iterable, or strictly tuple?
I'm thinking any iterable. In any case, I can remove the broadcasting.

Copy link
Member

@timhoffm timhoffm Jul 27, 2025

Choose a reason for hiding this comment

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

Yes, any iterable, but internally coerced to tuple - as with norms in __init__.

@trygvrad trygvrad force-pushed the multivariate-plot-prapare branch from 6b86d63 to 32247f5 Compare June 4, 2025 21:01
@trygvrad
Copy link
Contributor Author

trygvrad commented Jun 6, 2025

This is on hold until we sort out #30149 (Norm Protocol)
see #29876 (comment)

@trygvrad
Copy link
Contributor Author

trygvrad commented Jul 3, 2025

@timhoffm Thank you for taking the time to give detailed comments!

@trygvrad
Copy link
Contributor Author

trygvrad commented Jul 3, 2025

@timhoffm You made a comment here #29876 (comment):

Then (if we still want to expose MultiNorm), let's just call this n_variables and keep it private.

The variable has since changed name to n_components, but I feel like we haven't really explored the merits of keeping it private/public.

The current status is the public property:

    @property
    def n_components(self):
        """Number of norms held by this `MultiNorm`."""
        return len(self._norms)

I think we actually have 3 options:

  1. The current status: A public property.
  2. Remove n_components completely, and replace it with len(self._norms) wherever it occurs. (This would require a type-check for MultiNorm before len(self._norms) is accessed, if the object could also be a Normalize).
  3. Make n_components private.

This impacts how we communicate with the user via docstrings, i.e. in MultiNorm.__call__()
image

Here we could have:

  1. - If tuple, must be of length n_components
  2. - If tuple, must be of length equal to the number of norms held by this MultiNorm

i.e. we can choose to make n_components part of the vocabulary we use, or we can choose not to. My hunch is that this is a useful term to have for both internal discussions, and external communications.
It is also useful to keep in mind that MultiNorm should mirror the situation with colormaps, where currently Colormap, BivarColormap and MultivarColormap has n_components with value 1, 2, and len(self), respectively.

Once the top level plotting functions are made, there will be a check if the data pipeline is consistent, i.e.
number of fields in the data == cmap.n_components == norm.n_components
If we believe that there are users (i.e. developers of packages that rely on matplotlib) that want to use this functionality in some interesting way, I think it would be useful for them to have access to n_components

On the other hand, while I am quite certain that mulitvariate plotting functionality will be used by a number of users, it is unclear to me if any/how many will build advanced functionality that requires access to n_components which in reality is most important for error handling etc, and the typical user does not write custom error messages.


@timhoffm Let me know if I should remove n_components or make it private. Also if I should do the same for the colormap classes.

@github-actions github-actions bot added the Documentation: API files in lib/ and doc/api label Jul 3, 2025
@trygvrad trygvrad force-pushed the multivariate-plot-prapare branch 2 times, most recently from ab61439 to 910b343 Compare July 3, 2025 12:54
@timhoffm
Copy link
Member

timhoffm commented Jul 4, 2025

I think making it public is the right way to go. While it slightly complicates the mental model for all the current 1d norms, it is by design that it’s now only the special case and hence it is slightly more complicated.

@trygvrad trygvrad force-pushed the multivariate-plot-prapare branch from 910b343 to dbeca30 Compare July 10, 2025 19:18
"""
Parameters
----------
norms : list of (str, `Normalize` or None)
Copy link
Member

@timhoffm timhoffm Jul 10, 2025

Choose a reason for hiding this comment

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

why do we accept None here? I don't see an advantage of [None, None] over ["linear", "linear"]. Quite the opposite, the first one is less readable.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe in case folks want to update the list later?

Copy link
Member

@timhoffm timhoffm Jul 11, 2025

Choose a reason for hiding this comment

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

That doesn't make sense to me. (1) None is resolved to Normalize immediately. (2) Not sure why one wanted to lated update but that's possible either way.

I rather suspect that it's because it along the lines of: In 1D some other places - e.g. ScalarMappable.set_norm - accept None. But likely the othe places only accept it intentionally or unintentionally because norm=None is somewhere a default kwarg and that is passed through.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I rather suspect that it's because it along the lines of: In 1D some other places - e.g. ScalarMappable.set_norm - accept None. But likely the othe places only accept it intentionally or unintentionally because norm=None is somewhere a default kwarg and that is passed through.

Exactly this.
My thinking was that a common use case is not to supply a norm:

ax.imshow((A, B), cmap='BiOrangeBlue')

In this case the norm keyword (default: None) is singular, but to be compatible with the data/cmap it needs to have a length of 2, so in this case the norm keyword is repeated and becomes [None, None], and this then gets passed to the MultiNorm constructor.

In any case, it is better if, as you say we only accept strings.
I will make the change later.


see colorizer.py → _ensure_norm(norm, n_variates=n_variates) here for the proposed implementation. (This can also be simplified if we no longer accept None)

Copy link
Member

Choose a reason for hiding this comment

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

That doesn't make sense to me. (1) None is resolved to Normalize immediately. (2) Not sure why one wanted to lated update but that's possible either way.

What happens with ['linear', None]?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It would be parsed to ['linear', 'linear'], but will now (with the update) produce an error.
If we find we need this functionality, we can always bring it back in a later PR.

Copy link
Member

@story645 story645 Jul 16, 2025

Choose a reason for hiding this comment

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

So how do you denote ['linear', mpl.NoNorm()]? is that ['linear', 'none']?

@trygvrad trygvrad force-pushed the multivariate-plot-prapare branch from 23777b3 to 3a4f6b9 Compare July 12, 2025 09:52
@trygvrad
Copy link
Contributor Author

Thank you @timhoffm , the changes should now be in :)

@trygvrad trygvrad force-pushed the multivariate-plot-prapare branch 3 times, most recently from dd8f0c6 to 67b8264 Compare July 13, 2025 11:56
trygvrad and others added 4 commits July 20, 2025 16:05
This commit merges a number of commits now contained in  https://github.com/trygvrad/matplotlib/tree/multivariate-plot-prapare-backup , keeping only the MultiNorm class
Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com>
Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com>
Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com>
@trygvrad trygvrad force-pushed the multivariate-plot-prapare branch from 2707405 to da1ac73 Compare July 24, 2025 20:40
Copy link
Member

@timhoffm timhoffm left a comment

Choose a reason for hiding this comment

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

Thanks @trygvrad! I think we're almost there. You can see the progress in moving from architecture and design questions to docs. :)

Comment on lines +3448 to +3450

result = tuple(n(v, clip=c) for n, v, c in zip(self.norms, values, clip))

Copy link
Member

Choose a reason for hiding this comment

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

Minor nit. As with inverse we don't need separation of the commands by empty lines.

Suggested change
result = tuple(n(v, clip=c) for n, v, c in zip(self.norms, values, clip))
result = tuple(n(v, clip=c) for n, v, c in zip(self.norms, values, clip))

- If iterable, must be of length `n_components`
- If structured array, must have `n_components` fields.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change

Parameters
----------
A
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
A
A : array-like

values : array-like
The input data, as an iterable or a structured numpy array.
- If iterable, must be of length `n_components`
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
- If iterable, must be of length `n_components`
- If iterable, must be of length `n_components`. Each element can be a
scalar or array-like and is normalized through the correspong norm.

Generally, we should describe the inputs and outputs in more detail. Holds also for inverse, autoscale and autoscale_None. I'm a bit torn whether we should do this in every functions or once in the class docstring.

The input data, as an iterable or a structured numpy array.
- If iterable, must be of length `n_components`
- If structured array, must have `n_components` fields.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
- If structured array, must have `n_components` fields.
- If structured array, must have `n_components` fields. Each field
is normalized through the the corresponding norm.

vmin, vmax : float, None, or list of float or None
Limits of the constituent norms.
If a list, each value is assigned to each of the constituent
norms. Single values are repeated to form a list of appropriate size.
Copy link
Member

Choose a reason for hiding this comment

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

We still need a decision here. I slightly favor the more restrictive version of always requiring a tuple, but could be convinced otherwise.

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

Successfully merging this pull request may close these issues.

Feature request: Bivariate colormapping
7 participants
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy