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

BUG: Handle overlapping line and bar on the same plot #61173

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

Conversation

MartinBraquet
Copy link
Contributor

@MartinBraquet MartinBraquet commented Mar 24, 2025

This code

import pandas as pd
import matplotlib.pyplot as plt

index = pd.period_range('2023', periods=3, freq='Y')
df = pd.DataFrame({
    'col1': [10, 20, 30],
    'col2': [40, 25, 10]
}, index=index)

fig, ax = plt.subplots()
df['col1'].plot(kind='bar', ax=ax)
ax2 = ax.twinx()
df['col2'].plot(kind='line', ax=ax2, color = 'r')
plt.show()

from the linked issue gives this plot:
image

And swapping the lines (line before bar)

df['col2'].plot(kind='line', ax=ax2, color = 'r')
df['col1'].plot(kind='bar', ax=ax)

gives
image

I believe the difference of results stems from the fact that the figure is always cropped to match the x-range of the last plot. We can certainly adjust the PR to make the first figure behave like the second one if desired.

Please provide feedback on the overall approach. Once confirmed, I'll expand and cover with tests.

@MartinBraquet MartinBraquet changed the title BUG: Handle line and box on the same plot BUG: Handle line and bar on the same plot Mar 24, 2025
Copy link
Member

@rhshadrach rhshadrach left a comment

Choose a reason for hiding this comment

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

Thanks for the PR! Can you add a test in pandas/tests/plotting/test_series.py. Make the plots and check that the x-axis is as expected.

@@ -1868,6 +1870,14 @@ def __init__(

MPLPlot.__init__(self, data, **kwargs)

if isinstance(data.index, ABCPeriodIndex):
self.ax.freq = data.index.freq
Copy link
Member

Choose a reason for hiding this comment

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

Why is the modification of freq necessary? I worry that we don't own ax, and that we shouldn't be mutating it. But I'm not too familiar with the plotting code; do we do this anywhere else? I don't see any instances at a glance.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The only reason is to pass the freq to PeriodConverter, which currently only accepts ax as input arg, not freq. It can obviously be made cleaner. I meant to modify PeriodConverter or find a cleaner way than hacking around ax.

I'd just like to get a thorough review of the overall approach, i.e. shifting the tick positions, as it would apply to all box plots with PeriodIndex, and hence is a potential major change. Not sure how well it is covered by tests currently; I don't want to insert regressions or breaking changes. It would be good to have it first checked by a maintainer well-versed with matplotlib, as I'm not so familiar with all the idiosyncrasies of the ax object.

Introduce `convert_from_freq` method and streamline the conversion process by passing `freq` directly instead of using `axis.freq`. This improves modularity and ensures clearer separation of concerns for frequency handling in Period conversion.
Simplified `tick_pos` calculation by reusing helper methods and added a decorator to register pandas Matplotlib converters in the `_plot` method. This improves clarity and ensures proper integration with the pandas Matplotlib ecosystem.
This test ensures that bar and line plots with identical x values are correctly superposed on the same axes. It verifies that the x-tick positions remain consistent across plot types.
@@ -971,3 +971,17 @@ def test_secondary_y_subplot_axis_labels(self):
s1.plot(ax=ax2)
assert len(ax.xaxis.get_minor_ticks()) == 0
assert len(ax.get_xticklabels()) > 0

def test_bar_line_plot(self):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fails on main

@MartinBraquet MartinBraquet changed the title BUG: Handle line and bar on the same plot BUG: Handle overlapping line and bar on the same plot Apr 7, 2025
Resolved a bug that prevented a line and bar from aligning on the same plot in `Series.plot`. This addresses issue pandas-dev#61161 and improves plot consistency when combining these chart types.
Move `x_compat` logic and time series helper methods from `LinePlot` to `MPLPlot` for better reusability and maintainability. This simplifies the `LinePlot` class and centralizes common functionality.
return PeriodConverter.convert_from_freq(values, axis.freq)

@staticmethod
def convert_from_freq(values, freq):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

convert only uses the freq attribute of axis, so one should allow the user to pass freq without axis object.

Comment on lines 294 to 308
from pandas.plotting import plot_params

self.x_compat = plot_params["x_compat"]
if "x_compat" in self.kwds:
self.x_compat = bool(self.kwds.pop("x_compat"))

@final
def _is_ts_plot(self) -> bool:
# this is slightly deceptive
return not self.x_compat and self.use_index and self._use_dynamic_x()

@final
def _use_dynamic_x(self) -> bool:
return use_dynamic_x(self._get_ax(0), self.data)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Those routines are not exclusive to LinePlot, as they seem related to any type of time series. _is_ts_plot is used in this PR to check if a boxplot is a time series.

@MartinBraquet
Copy link
Contributor Author

@rhshadrach I covered the change with a test. I believe the PR is now ready for review and then merge.

@MartinBraquet MartinBraquet requested a review from rhshadrach April 8, 2025 04:29
s.plot(kind="bar", ax=ax)
bar_xticks = ax.get_xticks().tolist()
s.plot(kind="line", ax=ax, color="r")
line_xticks = ax.get_xticks()[: len(s)].tolist()
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 seeing ax.get_xticks() here have the same length as s; why have [: len(s)] here?

Copy link
Contributor Author

@MartinBraquet MartinBraquet Apr 14, 2025

Choose a reason for hiding this comment

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

After plotting the line, the ticks change on my machine:

ax.get_xticks()
Out[1]: array([53, 54, 55, 56])
ax.get_xticklabels()
Out[2]: 
[Text(53, 0, '2023'),
 Text(54, 0, '2024'),
 Text(55, 0, '2025'),
 Text(56, 0, '2026')]

A fourth tick is added by matplotlib for '2026'.

Even for integer indices, matplotlib does not do a 1-to-1 mapping between each x-tick and data point, as attested below:

ax = plt.subplot()
plt.plot(s.values, [1, 2,3])
ax.get_xticks()
array([0.75, 1.  , 1.25, 1.5 , 1.75, 2.  , 2.25, 2.5 , 2.75, 3.  , 3.25])

Not sure if you can reproduce those results on your machine, but I think that the exact number of ticks is irrelevant. I would suggest that this test checks that both x-tick labels (after bar plot and after line plot) include at least the 3 three ticks in the index (2023, 2024, 2025) and that the associated x-tick positions are the same for both plots. This would ensure that the two plots are superposed.
Additionally, the test can check that x-lim includes those three x-tick positions, which would ensure that the current plot view render the two plots.

I added a commit to reflect what I said above. Happy to discuss or update the code further if there is a need.

# Conflicts:
#	doc/source/whatsnew/v3.0.0.rst
#	pandas/plotting/_matplotlib/core.py
Ensure bar and line plots share consistent x-axis tick labels and verify that x-axis limits are adjusted to make all plotted elements visible in `test_bar_line_plot` test. These changes improve the robustness of visual alignment and boundary checks.
Add an assertion to verify the length of `bar_xticks` aligns with the length of the index. This improves the test's robustness by ensuring the data and ticks remain consistent.
Comment on lines +993 to +994
assert len(bar_xticks) == len(index)
assert bar_xticks == line_xticks
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is checking that

  • the three index years are in the two plots
  • the x-tick positions for those years are the same in the two plots

Comment on lines +996 to +997
assert x_limits[0] <= bar_xticks[0].get_position()[0]
assert x_limits[1] >= bar_xticks[-1].get_position()[0]
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Check that both left-most and right-most data points are shown in the current view

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

Successfully merging this pull request may close these issues.

BUG: fails to plot 2 plots with secondary y axis when index type is PeriodIndex and plot kinds are different
2 participants