-
-
Notifications
You must be signed in to change notification settings - Fork 7.9k
[Bug]: Having issues getting Figure.legend
to work with constrained layout and SubFigure
#29418
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
Comments
potentially related to #20736 but i'm not seeing how it's fixed. |
Can you make a minimal self contained example? I'm also not clear why you are using subfigure for this layout instead of just two rows of subplots. |
@jklymak attached file contains the necessary python file and the pickled python dictionary and pandas dataframe required to generate the plot. The reason I used SubFigure is because I want to accommodate for jagged subplots, i.e. the first couple rows all having 3 columns and the last row have only one or two column that would be evenly spaced and centered. Here, the legend should be two rows but the second row is cropped off. |
I hope the exact data does not matter? I (and I expect a fair number of other mpl devs) are not comfortable loading pickle files from issue (see the warning in https://docs.python.org/3/library/pickle.html TL;DR; using pickle to talk between two processes you control is fine, using loading a pickle written by a process you do not control is not). Can you please reduce this to a minimal example (plotting random numbers or trig functions should show the effect) that we can copy-paste-run with no changes? I note that you are using |
Yes, to add to @tacaswell advice, you can probably save us quite a bit of time by minimizing as much as possible - eg removing everything except exactly what causes the problem. Please see http://www.sscce.org for why this is worthwhile. |
code using random generated data that does not involve loading pickle and can be run immediately after copy and paste is at the end of the comment. Based on suggestions from @tacaswell, here are some possible combination of figure layout and savefig bbox. It seems constrained layout inf figure plays significant role in keeping subfigures not overlap with each other, while tight_layout during savefig seems crucial to keep fig.supxlabel and legend box not overlap with each other.
# %%
import math
import os
from typing import Sequence
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.axes import Axes
from matplotlib.figure import Figure, SubFigure
from matplotlib.ticker import FormatStrFormatter
# %%
alpha: float = 0.0
N_COLS: int = 3
SIMPLE_STRAT = "ts"
# %%
out_p = os.path.join("outputs")
os.makedirs(out_p, exist_ok=True)
# %%
STRATS = ["mtspm", "ts", "ei", "revi", "random", "modiste", "spanner"]
ESTIMATORS = ["xgb", "structure", "knn", "uknn", ""]
# %%
STRAT_EST_TO_PLOT_KWARGS = {
f"{SIMPLE_STRAT}_xgb": {
"label": "XGB-Bootstrap",
"color": "blue",
"marker": "x",
},
f"{SIMPLE_STRAT}_structure": {
"label": "Mimic-Bootstrap",
"color": "red",
"marker": "v",
},
"spanner": {
"label": "SpannerIGW",
"color": "gold",
"marker": "h",
},
"modiste_knn": {
"label": "Modiste-KNN",
"color": "darkgreen",
"marker": "+",
},
"modiste_uknn": {
"label": "Modiste-UKNN",
"color": "lime",
"marker": "o",
},
}
# %%
results = {
f"subplot {idx}": {
"ts_xgb": [
pd.DataFrame(
{
"train/n_obs": np.arange(0, 9000, 1000),
"val/reward": np.random.randn(9),
}
)
],
"ts_structure": [
pd.DataFrame(
{
"train/n_obs": np.arange(0, 9000, 1000),
"val/reward": np.random.randn(9),
}
)
],
"spanner": [
pd.DataFrame(
{
"train/n_obs": np.arange(0, 9000, 1000),
"val/reward": np.random.randn(9),
}
)
],
"modiste_knn": [
pd.DataFrame(
{
"train/n_obs": np.arange(0, 9000, 1000),
"val/reward": np.random.randn(9),
}
)
],
"modiste_uknn": [
pd.DataFrame(
{
"train/n_obs": np.arange(0, 9000, 1000),
"val/reward": np.random.randn(9),
}
)
],
}
for idx in range(5)
}
# %%
def make_plot(key: str, supylabel: str, fn_post: str):
fig: Figure = plt.figure(layout="constrained")
n_rows: int = math.ceil(len(results) / N_COLS)
fig.set_figheight(1.7 * n_rows)
xl = fig.supxlabel("observations")
yl = fig.supylabel(supylabel)
subfigs: Sequence[SubFigure] = fig.subfigures(nrows=n_rows, ncols=1) # type:ignore
label_set = list()
line_set = list()
results_l = [(k, v) for k, v in results.items()]
end_idx: int = 0
for _fig in subfigs:
start_idx: int = end_idx
end_idx: int = min(end_idx + N_COLS, len(results_l))
_results = results_l[start_idx:end_idx]
axs: Sequence[Axes] = _fig.subplots(
nrows=1, ncols=len(_results), squeeze=False
)[0]
for ax, (name, strat_est_to_metrics_d) in zip(axs, _results):
ax.set_title(name)
ax.set_box_aspect(1.0)
ax.set_xlim(0.0, 8500)
ax.set_xticks([500, 4000, 8000])
ax.yaxis.set_major_formatter(FormatStrFormatter("%.2f"))
for strat_est, metrics in strat_est_to_metrics_d.items():
_metrics_d: pd.DataFrame = metrics[0][[key, "train/n_obs"]].dropna()
ax.plot(
_metrics_d["train/n_obs"],
_metrics_d[key],
**STRAT_EST_TO_PLOT_KWARGS[strat_est],
)
_lines, _labels = ax.get_legend_handles_labels()
for _line, _label in zip(_lines, _labels):
if _label not in label_set:
line_set.append(_line)
label_set.append(_label)
lg = fig.legend(
line_set,
label_set,
loc="lower center",
borderaxespad=-1.3,
# bbox_to_anchor=(),
frameon=True,
ncol=3,
)
plt.savefig(
os.path.join(
out_p, f"alpha{alpha}_{SIMPLE_STRAT}_{fn_post}_constrained_tight.png"
),
dpi=720,
bbox_extra_artists=[xl, yl, lg],
bbox_inches="tight",
)
plt.savefig(
os.path.join(out_p, f"alpha{alpha}_{SIMPLE_STRAT}_{fn_post}_constrained.png"),
bbox_extra_artists=[xl, yl, lg],
)
plt.show()
plt.close()
make_plot("val/reward", "held-out reward", "reward")
# %%
def make_plot_not_constrained(key: str, supylabel: str, fn_post: str):
fig: Figure = plt.figure()
n_rows: int = math.ceil(len(results) / N_COLS)
fig.set_figheight(1.7 * n_rows)
xl = fig.supxlabel("observations")
yl = fig.supylabel(supylabel)
subfigs: Sequence[SubFigure] = fig.subfigures(nrows=n_rows, ncols=1) # type:ignore
label_set = list()
line_set = list()
results_l = [(k, v) for k, v in results.items()]
end_idx: int = 0
for _fig in subfigs:
start_idx: int = end_idx
end_idx: int = min(end_idx + N_COLS, len(results_l))
_results = results_l[start_idx:end_idx]
axs: Sequence[Axes] = _fig.subplots(
nrows=1, ncols=len(_results), squeeze=False
)[0]
for ax, (name, strat_est_to_metrics_d) in zip(axs, _results):
ax.set_title(name)
ax.set_box_aspect(1.0)
ax.set_xlim(0.0, 8500)
ax.set_xticks([500, 4000, 8000])
ax.yaxis.set_major_formatter(FormatStrFormatter("%.2f"))
for strat_est, metrics in strat_est_to_metrics_d.items():
_metrics_d: pd.DataFrame = metrics[0][[key, "train/n_obs"]].dropna()
ax.plot(
_metrics_d["train/n_obs"],
_metrics_d[key],
**STRAT_EST_TO_PLOT_KWARGS[strat_est],
)
_lines, _labels = ax.get_legend_handles_labels()
for _line, _label in zip(_lines, _labels):
if _label not in label_set:
line_set.append(_line)
label_set.append(_label)
lg = fig.legend(
line_set,
label_set,
loc="lower center",
borderaxespad=-1.3,
# bbox_to_anchor=(),
frameon=True,
ncol=3,
)
plt.savefig(
os.path.join(out_p, f"alpha{alpha}_{SIMPLE_STRAT}_{fn_post}_tight.png"),
dpi=720,
bbox_extra_artists=[xl, yl, lg],
bbox_inches="tight",
)
plt.savefig(
os.path.join(out_p, f"alpha{alpha}_{SIMPLE_STRAT}_{fn_post}.png"),
bbox_extra_artists=[xl, yl, lg],
)
plt.show()
plt.close()
make_plot_not_constrained("val/reward", "held-out reward", "reward")
# %% |
fig, ax = plt.subplots(layout='constrained')
# fig.get_layout_engine().set(rect=[0, 0.1, 1, 0.9])
ax.plot(np.random.rand(10), label='boo')
fig.legend(loc='lower center')
plt.show() If you want to save some space at the bottom for a legend, you can uncomment the second line and manually adjust the 0.1 and 0.9. Automagically taking |
@jklymak I seem to be able to fix your last example simply by replacing import numpy as np
import matplotlib.pyplot as plt
rng = np.random.default_rng(0)
fig, ax = plt.subplots(layout='constrained')
ax.plot(rng.random(10), label='boo')
fig.legend(loc='outside lower center')
fig.savefig(".temp.png") Version information: |
@busFred TLDR: do use Here is an example including "jagged subplots" (which you said was your use case), which works fine for me: import numpy as np
import matplotlib.pyplot as plt
rng = np.random.default_rng(0)
fig = plt.figure(layout='constrained')
subfigs = fig.subfigures(nrows=2, ncols=1)
ax1 = subfigs[0].subplots(nrows=1, ncols=3)
ax2 = subfigs[1].subplots(nrows=1, ncols=2)
ax1[0].plot(rng.random(10), label='boo')
ax2[1].plot(rng.random(10), label='boo 2', c="r")
fig.legend(loc='outside lower center', ncols=2)
# fig.legend(loc='outside lower center', ncols=2, borderaxespad=-1.3)
fig.savefig(".temp.png") If I include the |
This is documented in the Figure legends section of the Legend guide:
See also
|
Ooops, yes I forgot about that! Thanks @jakelevi1996 ! |
This one has been quiet for a while, but it looks like the solution was to use |
Bug summary
I created a plot with
fig: Figure = plt.figure(layout="constrained")
and add several rows ofSubFigure
with potentially unequal amount of columns usingfig.subfigures
. Since all the boxes in this parent figure have lines sharing same line style, I added a global legend box withfig.legend
. When I save the figure withfig.savefig
, legend box just get cropped off regardless of how i set things.Code for reproduction
Actual outcome
Expected outcome
Both rows in the legend box should be saved but the second row is always cropped off regardless.
Additional information
result of
print(fig.get_default_bbox_extra_artists())
seems to contain legend box. Just for whatever reason bbox_inches='tight' isn't considering it properly.Operating system
Linux Mint 22
Matplotlib Version
3.10.0
Matplotlib Backend
print(matplotlib.get_backend())
Python version
3.10.14
Jupyter version
No response
Installation
pip
The text was updated successfully, but these errors were encountered: