Skip to content
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

so.Est() + tick label formatter => 'PseudoAxis' object has no attribute '_view_interval' #3585

Open
stas-sl opened this issue Dec 5, 2023 · 8 comments

Comments

@stas-sl
Copy link

stas-sl commented Dec 5, 2023

Hi, thanks for cool library, I especially enjoy using objects interface.

While using it I encountered a small issue when trying to customise tick labels using matplotlib formatter in combination with so.Est(). If I replace it with so.Agg() the issue disappears. I guess this has something to do with the fact that several variables use the same scale (y/ymin/ymax) and _view_interval is not initialised properly in that case?

import seaborn as sns
import seaborn.objects as so
import matplotlib as mpl

fmri = sns.load_dataset("fmri")
(
    so.Plot(fmri, x="timepoint", y="signal", color="event")
        .add(so.Band(), so.Est())
        .scale(y=so.Continuous().label(mpl.ticker.PercentFormatter()))  
)
File ~/.pyenv/versions/miniforge3/lib/python3.9/site-packages/matplotlib/ticker.py:233, in Formatter.format_ticks(self, values)
    231 """Return the tick labels for all the ticks at once."""
    232 self.set_locs(values)
--> 233 return [self(value, i) for i, value in enumerate(values)]

File ~/.pyenv/versions/miniforge3/lib/python3.9/site-packages/matplotlib/ticker.py:233, in <listcomp>(.0)
    231 """Return the tick labels for all the ticks at once."""
    232 self.set_locs(values)
--> 233 return [self(value, i) for i, value in enumerate(values)]

File ~/.pyenv/versions/miniforge3/lib/python3.9/site-packages/matplotlib/ticker.py:1525, in PercentFormatter.__call__(self, x, pos)
   1523 def __call__(self, x, pos=None):
   1524     """Format the tick as a percentage with the appropriate scaling."""
-> 1525     ax_min, ax_max = self.axis.get_view_interval()
   1526     display_range = abs(ax_max - ax_min)
   1527     return self.fix_minus(self.format_pct(x, display_range))

File ~/.pyenv/versions/miniforge3/lib/python3.9/site-packages/seaborn/_core/scales.py:921, in PseudoAxis.get_view_interval(self)
    920 def get_view_interval(self):
--> 921     return self._view_interval

AttributeError: 'PseudoAxis' object has no attribute '_view_interval'

Versions:
seaborn: 0.13.0
matplotlib: 3.6.2

@mwaskom
Copy link
Owner

mwaskom commented Dec 5, 2023

Thanks for the reproducible example! I don't know exactly what is causing this but I think you're on the right track with your suggestion. Note that it is also possible to get percent formatting by directly using the seaborn API:

fmri = sns.load_dataset("fmri")
(
    so.Plot(fmri, x="timepoint", y="signal", color="event")
    .add(so.Band(), so.Est())
    .scale(y=so.Continuous().label(like="{x:.1%}"))
)

@MaozGelbart
Copy link
Contributor

A similar (but different) error is apparent when trying to use a LogLocator for ticking of bands added with so.Est:

import seaborn as sns
import seaborn.objects as so
import matplotlib as mpl

fmri = sns.load_dataset("fmri")
(
    so.Plot(fmri, x="timepoint", y="signal", color="event")
        .add(so.Band(), so.Est())
        .scale(y=so.Continuous().tick(mpl.ticker.LogLocator()))  
)

Which errors the following AttributeError (mpl 3.8.2, sns 0.13.0):

AttributeError                            Traceback (most recent call last)
File ~\miniconda3\envs\tst\Lib\site-packages\IPython\core\formatters.py:344, in BaseFormatter.__call__(self, obj)
    342     method = get_real_method(obj, self.print_method)
    343     if method is not None:
--> 344         return method()
    345     return None
    346 else:

File ~\miniconda3\envs\tst\Lib\site-packages\seaborn\_core\plot.py:387, in Plot._repr_png_(self)
    385 if Plot.config.display["format"] != "png":
    386     return None
--> 387 return self.plot()._repr_png_()

File ~\miniconda3\envs\tst\Lib\site-packages\seaborn\_core\plot.py:934, in Plot.plot(self, pyplot)
    930 """
    931 Compile the plot spec and return the Plotter object.
    932 """
    933 with theme_context(self._theme_with_defaults()):
--> 934     return self._plot(pyplot)

File ~\miniconda3\envs\tst\Lib\site-packages\seaborn\_core\plot.py:964, in Plot._plot(self, pyplot)
    962 # Process the data for each layer and add matplotlib artists
    963 for layer in layers:
--> 964     plotter._plot_layer(self, layer)
    966 # Add various figure decorations
    967 plotter._make_legend(self)

File ~\miniconda3\envs\tst\Lib\site-packages\seaborn\_core\plot.py:1505, in Plotter._plot_layer(self, p, layer)
   1503 # TODO is this the right place for this?
   1504 for view in self._subplots:
-> 1505     view["ax"].autoscale_view()
   1507 if layer["legend"]:
   1508     self._update_legend_contents(p, mark, data, scales, layer["label"])

File ~\miniconda3\envs\tst\Lib\site-packages\matplotlib\axes\_base.py:2939, in _AxesBase.autoscale_view(self, tight, scalex, scaley)
   2934     # End of definition of internal function 'handle_single_axis'.
   2936 handle_single_axis(
   2937     scalex, self._shared_axes["x"], 'x', self.xaxis, self._xmargin,
   2938     x_stickies, self.set_xbound)
-> 2939 handle_single_axis(
   2940     scaley, self._shared_axes["y"], 'y', self.yaxis, self._ymargin,
   2941     y_stickies, self.set_ybound)

File ~\miniconda3\envs\tst\Lib\site-packages\matplotlib\axes\_base.py:2896, in _AxesBase.autoscale_view.<locals>.handle_single_axis(scale, shared_axes, name, axis, margin, stickies, set_bound)
   2894 # If x0 and x1 are nonfinite, get default limits from the locator.
   2895 locator = axis.get_major_locator()
-> 2896 x0, x1 = locator.nonsingular(x0, x1)
   2897 # Find the minimum minpos for use in the margin calculation.
   2898 minimum_minpos = min(
   2899     getattr(ax.dataLim, f"minpos{name}") for ax in shared)

File ~\miniconda3\envs\tst\Lib\site-packages\matplotlib\ticker.py:2427, in LogLocator.nonsingular(self, vmin, vmax)
   2424     vmin, vmax = 1, 10
   2425 else:
   2426     # Consider shared axises
-> 2427     minpos = min(axis.get_minpos() for axis in self.axis._get_shared_axis())
   2428     if not np.isfinite(minpos):
   2429         minpos = 1e-300  # This should never take effect.

AttributeError: 'PseudoAxis' object has no attribute '_get_shared_axis'

@mwaskom
Copy link
Owner

mwaskom commented Dec 13, 2023

Probably the same underlying problem but @MaozGelbart I am curious what your usecase is for using a LogLocator on a linear-scale axis?

@MaozGelbart
Copy link
Contributor

Probably the same underlying problem but @MaozGelbart I am curious what your usecase is for using a LogLocator on a linear-scale axis?

No such use case, just for ease of reproduction. Can reproduce with this code as well:

import seaborn as sns
import seaborn.objects as so
import matplotlib as mpl

fmri = sns.load_dataset("fmri")
(
    so.Plot(fmri, x="timepoint", y="signal", color="event")
        .add(so.Band(), so.Est())
        .scale(y=so.Continuous(trans="symlog").tick(locator=LogLocator()))
)

@mwaskom
Copy link
Owner

mwaskom commented Dec 13, 2023

OK got it, thanks!

@mwaskom
Copy link
Owner

mwaskom commented Dec 13, 2023

(I guess, follow-up question, did you have a usecase where LogLocator was necessary to configure something that couldn't be configured through the other parameters to Continuous.tick? Or were you just poking at the issue?)

@MaozGelbart
Copy link
Contributor

(I guess, follow-up question, did you have a usecase where LogLocator was necessary to configure something that couldn't be configured through the other parameters to Continuous.tick? Or were you just poking at the issue?)

In the objects interface symlog scale uses a different locator than log scale, so sometimes in this scenario it makes sense to change the locator into a logarithmic one.

@MaozGelbart
Copy link
Contributor

I think the culprit here is the cross-reference between locators and the axis they're associated with.

During scale setup a shallow copy of the scale is performed:

def _setup(
self, data: Series, prop: Property, axis: Axis | None = None,
) -> Scale:
new = copy(self)
if new._tick_params is None:
new = new.tick()
if new._label_params is None:
new = new.label()

This uses the same locator instance to setup the scale of the y variable and the scales of ymin and ymax. During PseudoAxis initialization for ymin and ymax (using the same copied Locator of YAxis), the locator is informed of the Axis that contains it:
def set_major_locator(self, locator):
self.major.locator = locator
locator.set_axis(self)

and because ymin and ymax are calculated after y scale has been created, the locator is stuck pointing to the last PseudoAxis created. During plotting, the locator is questioned about the axis it is contained within, and PseudoAxis implementation lacks some Axis members that are otherwise expected to be.
Changing the copy operation to deepcopy results with new Locator instances during scale setup for ymin and ymax, so the original y scale locator keeps the reference to YAxis. If this solution is in the correct direction, I can draft a PR.

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

No branches or pull requests

3 participants