Coverage for python/lsst/analysis/tools/actions/plot/histPlot.py: 16%
239 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-14 02:49 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-14 02:49 -0700
1# This file is part of analysis_tools.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21from __future__ import annotations
23__all__ = ("HistPanel", "HistPlot", "HistStatsPanel")
25import logging
26from collections import defaultdict
27from typing import Mapping
29import numpy as np
30from lsst.pex.config import (
31 ChoiceField,
32 Config,
33 ConfigDictField,
34 ConfigField,
35 DictField,
36 Field,
37 FieldValidationError,
38 ListField,
39)
40from lsst.utils.plotting import make_figure
41from matplotlib import cm
42from matplotlib.figure import Figure
43from matplotlib.gridspec import GridSpec
44from matplotlib.patches import Rectangle
46from ...interfaces import KeyedData, KeyedDataSchema, PlotAction, Vector
47from ...math import nanMax, nanMedian, nanMin, sigmaMad
48from .plotUtils import addPlotInfo
50log = logging.getLogger(__name__)
53class HistStatsPanel(Config):
54 """A Config class that holds parameters to configure a the stats panel
55 shown for histPlot.
57 The fields in this class correspond to the parameters that can be used to
58 customize the HistPlot stats panel.
60 - The ListField parameter a dict to specify names of 3 stat columns accepts
61 latex formating
63 - The other parameters (stat1, stat2, stat3) are lists of strings that
64 specify vector keys correspoinding to scalar values computed in the
65 prep/process/produce steps of an analysis tools plot/metric configurable
66 action. There should be one key for each group in the HistPanel.
68 A separate config class is used instead of constructing
69 `~lsst.pex.config.DictField`'s in HistPanel for each parameter for clarity
70 and consistency.
72 Notes
73 -----
74 This is intended to be used as a configuration of the HistPlot/HistPanel
75 class.
77 If no HistStatsPanel is specified then the default behavor persists where
78 the stats panel shows N / median / sigma_mad for each group in the panel.
79 """
81 statsLabels = ListField[str](
82 doc="list specifying the labels for stats",
83 length=3,
84 default=("N$_{{data}}$", "Med", "${{\\sigma}}_{{MAD}}$"),
85 )
86 stat1 = ListField[str](
87 doc="A list specifying the vector keys of the first scalar statistic to be shown in this panel."
88 "there should be one entry for each hist in the panel",
89 default=None,
90 optional=True,
91 )
92 stat2 = ListField[str](
93 doc="A list specifying the vector keys of the second scalar statistic to be shown in this panel."
94 "there should be one entry for each hist in the panel",
95 default=None,
96 optional=True,
97 )
98 stat3 = ListField[str](
99 doc="A list specifying the vector keys of the third scalar statistic to be shown in this panel."
100 "there should be one entry for each hist in the panel",
101 default=None,
102 optional=True,
103 )
105 def validate(self):
106 super().validate()
107 if not all([self.stat1, self.stat2, self.stat3]) and any([self.stat1, self.stat2, self.stat3]):
108 raise ValueError(f"{self._name}: If one stat is configured, all 3 stats must be configured")
111class HistPanel(Config):
112 """A Config class that holds parameters to configure a single panel of a
113 histogram plot. This class is intended to be used within the ``HistPlot``
114 class.
115 """
117 label = Field[str](
118 doc="Panel x-axis label.",
119 default="label",
120 )
121 hists = DictField[str, str](
122 doc="A dict specifying the histograms to be plotted in this panel. Keys are used to identify "
123 "histogram IDs. Values are used to add to the legend label displayed in the upper corner of the "
124 "panel.",
125 optional=False,
126 )
127 yscale = Field[str](
128 doc="Y axis scaling.",
129 default="linear",
130 )
131 bins = Field[int](
132 doc="Number of x axis bins within plot x-range.",
133 default=50,
134 )
135 rangeType = ChoiceField[str](
136 doc="Set the type of range to use for the x-axis. Range bounds will be set according to "
137 "the values of lowerRange and upperRange.",
138 allowed={
139 "percentile": "Upper and lower percentile ranges of the data.",
140 "sigmaMad": "Range is (sigmaMad - lowerRange*sigmaMad, sigmaMad + upperRange*sigmaMad).",
141 "fixed": "Range is fixed to (lowerRange, upperRange).",
142 },
143 default="percentile",
144 )
145 lowerRange = Field[float](
146 doc="Lower range specifier for the histogram bins. See rangeType for interpretation "
147 "based on the type of range requested. If more than one histogram is plotted in a given "
148 "panel and rangeType is not set to fixed, the limit is the minimum value across all input "
149 "data.",
150 default=0.0,
151 )
152 upperRange = Field[float](
153 doc="Upper range specifier for the histogram bins. See rangeType for interpretation "
154 "based on the type of range requested. If more than one histogram is plotted in a given "
155 "panel and rangeType is not set to fixed, the limit is the maximum value across all input "
156 "data.",
157 default=100.0,
158 )
159 referenceValue = Field[float](
160 doc="Value at which to add a black solid vertical line. Ignored if set to `None`.",
161 default=None,
162 optional=True,
163 )
164 refRelativeToMedian = Field[bool](
165 doc="Is the referenceValue meant to be an offset from the median?",
166 default=False,
167 optional=True,
168 )
169 histDensity = Field[bool](
170 doc="Whether to plot the histogram as a normalized probability distribution. Must also "
171 "provide a value for referenceValue",
172 default=False,
173 )
174 statsPanel = ConfigField[HistStatsPanel](
175 doc="configuration for stats to be shown on plot, if None then "
176 "default stats: N, median, sigma mad are shown",
177 default=None,
178 )
180 def validate(self):
181 super().validate()
182 if self.rangeType == "percentile" and self.lowerRange < 0.0 or self.upperRange > 100.0:
183 msg = (
184 "For rangeType %s, ranges must obey: lowerRange >= 0 and upperRange <= 100." % self.rangeType
185 )
186 raise FieldValidationError(self.__class__.rangeType, self, msg)
187 if self.rangeType == "sigmaMad" and self.lowerRange < 0.0:
188 msg = (
189 "For rangeType %s, lower range must obey: lowerRange >= 0 (the lower range is "
190 "set as median - lowerRange*sigmaMad." % self.rangeType
191 )
192 raise FieldValidationError(self.__class__.rangeType, self, msg)
193 if self.rangeType == "fixed" and (self.upperRange - self.lowerRange) == 0.0:
194 msg = (
195 "For rangeType %s, lower and upper ranges must differ (i.e. must obey: "
196 "upperRange - lowerRange != 0)." % self.rangeType
197 )
198 raise FieldValidationError(self.__class__.rangeType, self, msg)
199 if self.histDensity and self.referenceValue is None:
200 msg = "Must provide referenceValue if histDensity is True."
201 raise FieldValidationError(self.__class__.referenceValue, self, msg)
204class HistPlot(PlotAction):
205 """Make an N-panel plot with a configurable number of histograms displayed
206 in each panel. Reference lines showing values of interest may also be added
207 to each histogram. Panels are configured using the ``HistPanel`` class.
208 """
210 panels = ConfigDictField(
211 doc="A configurable dict describing the panels to be plotted, and the histograms for each panel.",
212 keytype=str,
213 itemtype=HistPanel,
214 default={},
215 )
216 cmap = Field[str](
217 doc="Color map used for histogram lines. All types available via `plt.cm` may be used. "
218 "A number of custom color maps are also defined: `newtab10`, `bright`, `vibrant`.",
219 default="newtab10",
220 )
222 def getInputSchema(self) -> KeyedDataSchema:
223 for panel in self.panels: # type: ignore
224 for histData in self.panels[panel].hists.items(): # type: ignore
225 yield histData, Vector
227 def __call__(self, data: KeyedData, **kwargs) -> Mapping[str, Figure] | Figure:
228 return self.makePlot(data, **kwargs)
229 # table is a dict that needs: x, y, run, skymap, filter, tract,
231 def makePlot(
232 self, data: KeyedData, plotInfo: Mapping[str, str] = None, **kwargs # type: ignore
233 ) -> Figure:
234 """Make an N-panel plot with a user-configurable number of histograms
235 displayed in each panel.
237 Parameters
238 ----------
239 data : `pandas.core.frame.DataFrame`
240 The catalog to plot the points from.
241 plotInfo : `dict`
242 A dictionary of information about the data being plotted with keys:
243 `"run"`
244 Output run for the plots (`str`).
245 `"tractTableType"`
246 Table from which results are taken (`str`).
247 `"plotName"`
248 Output plot name (`str`)
249 `"SN"`
250 The global signal-to-noise data threshold (`float`)
251 `"skymap"`
252 The type of skymap used for the data (`str`).
253 `"tract"`
254 The tract that the data comes from (`int`).
255 `"bands"`
256 The bands used for this data (`str` or `list`).
257 `"visit"`
258 The visit that the data comes from (`int`)
260 Returns
261 -------
262 fig : `matplotlib.figure.Figure`
263 The resulting figure.
265 Examples
266 --------
267 An example histogram plot may be seen below:
269 .. image:: /_static/analysis_tools/histPlotExample.png
271 For further details on how to generate a plot, please refer to the
272 :ref:`getting started guide<analysis-tools-getting-started>`.
273 """
275 # set up figure
276 fig = make_figure(dpi=300)
277 hist_fig, side_fig = fig.subfigures(1, 2, wspace=0, width_ratios=[2.9, 1.1])
278 axs, ncols, nrows = self._makeAxes(hist_fig)
280 # loop over each panel; plot histograms
281 colors = self._assignColors()
282 nth_panel = len(self.panels)
283 nth_col = ncols
284 nth_row = nrows - 1
285 label_font_size = max(6, 10 - nrows)
286 for panel, ax in zip(self.panels, axs):
287 nth_panel -= 1
288 nth_col = ncols - 1 if nth_col == 0 else nth_col - 1
289 if nth_panel == 0 and nrows * ncols - len(self.panels) > 0:
290 nth_col -= 1
291 # Set font size for legend based on number of panels being plotted.
292 legend_font_size = max(4, int(7 - len(self.panels[panel].hists) / 2 - nrows // 2)) # type: ignore
293 nums, meds, mads, stats_dict = self._makePanel(
294 data,
295 panel,
296 ax,
297 colors[panel],
298 label_font_size=label_font_size,
299 legend_font_size=legend_font_size,
300 ncols=ncols,
301 )
303 all_handles, all_nums, all_meds, all_mads = [], [], [], []
304 handles, labels = ax.get_legend_handles_labels() # code for plotting
305 all_handles += handles
306 all_nums += nums
307 all_meds += meds
308 all_mads += mads
309 title_str = self.panels[panel].label # type: ignore
310 # add side panel; add statistics
311 self._addStatisticsPanel(
312 side_fig,
313 all_handles,
314 all_nums,
315 all_meds,
316 all_mads,
317 stats_dict,
318 legend_font_size=legend_font_size,
319 yAnchor0=ax.get_position().y0,
320 nth_row=nth_row,
321 nth_col=nth_col,
322 title_str=title_str,
323 )
324 nth_row = nth_row - 1 if nth_col == 0 else nth_row
326 # add general plot info
327 if plotInfo is not None:
328 hist_fig = addPlotInfo(hist_fig, plotInfo)
330 # finish up
331 fig.canvas.draw()
332 return fig
334 def _makeAxes(self, fig):
335 """Determine axes layout for main histogram figure."""
336 num_panels = len(self.panels)
337 if num_panels <= 1:
338 ncols = 1
339 else:
340 ncols = 2
341 nrows = int(np.ceil(num_panels / ncols))
343 gs = GridSpec(nrows, ncols, left=0.12, right=0.88, bottom=0.1, top=0.88, wspace=0.41, hspace=0.45)
345 axs = []
346 counter = 0
347 for row in range(nrows):
348 for col in range(ncols):
349 counter += 1
350 if counter < num_panels:
351 axs.append(fig.add_subplot(gs[row : row + 1, col : col + 1]))
352 else:
353 axs.append(fig.add_subplot(gs[row : row + 1, col : np.min([col + 2, ncols + 1])]))
354 break
356 return axs, ncols, nrows
358 def _assignColors(self):
359 """Assign colors to histograms using a given color map."""
360 custom_cmaps = dict(
361 # https://www.tableau.com/about/blog/2016/7/colors-upgrade-tableau-10-56782
362 newtab10=[
363 "#4e79a7",
364 "#f28e2b",
365 "#e15759",
366 "#76b7b2",
367 "#59a14f",
368 "#edc948",
369 "#b07aa1",
370 "#ff9da7",
371 "#9c755f",
372 "#bab0ac",
373 ],
374 # https://personal.sron.nl/~pault/#fig:scheme_bright
375 bright=[
376 "#4477AA",
377 "#EE6677",
378 "#228833",
379 "#CCBB44",
380 "#66CCEE",
381 "#AA3377",
382 "#BBBBBB",
383 ],
384 # https://personal.sron.nl/~pault/#fig:scheme_vibrant
385 vibrant=[
386 "#EE7733",
387 "#0077BB",
388 "#33BBEE",
389 "#EE3377",
390 "#CC3311",
391 "#009988",
392 "#BBBBBB",
393 ],
394 )
395 if self.cmap in custom_cmaps.keys():
396 all_colors = custom_cmaps[self.cmap]
397 else:
398 try:
399 all_colors = getattr(cm, self.cmap).copy().colors
400 except AttributeError:
401 raise ValueError(f"Unrecognized color map: {self.cmap}")
403 counter = 0
404 colors = defaultdict(list)
405 for panel in self.panels:
406 for hist in self.panels[panel].hists:
407 colors[panel].append(all_colors[counter % len(all_colors)])
408 counter += 1
409 return colors
411 def _makePanel(self, data, panel, ax, colors, label_font_size=9, legend_font_size=7, ncols=1):
412 """Plot a single panel containing histograms."""
413 nums, meds, mads = [], [], []
414 for i, hist in enumerate(self.panels[panel].hists):
415 hist_data = data[hist][np.isfinite(data[hist])]
416 num, med, mad = self._calcStats(hist_data)
417 nums.append(num)
418 meds.append(med)
419 mads.append(mad)
420 panel_range = self._getPanelRange(data, panel, mads=mads, meds=meds)
421 if all(np.isfinite(panel_range)):
422 nHist = 0
423 for i, hist in enumerate(self.panels[panel].hists):
424 hist_data = data[hist][np.isfinite(data[hist])]
425 if len(hist_data) > 0:
426 ax.hist(
427 hist_data,
428 range=panel_range,
429 bins=self.panels[panel].bins,
430 histtype="step",
431 density=self.panels[panel].histDensity,
432 lw=2,
433 color=colors[i],
434 label=self.panels[panel].hists[hist],
435 )
436 ax.axvline(meds[i], ls=(0, (5, 3)), lw=1, c=colors[i])
437 nHist += 1
439 if nHist > 0:
440 ax.legend(fontsize=legend_font_size, loc="upper left", frameon=False)
441 ax.set_xlim(panel_range)
442 # The following accommodates spacing for ranges with large numbers
443 # but small-ish dynamic range (example use case: RA 300-301).
444 if ncols > 1 and max(np.abs(panel_range)) >= 100 and (panel_range[1] - panel_range[0]) < 5:
445 ax.xaxis.set_major_formatter("{x:.2f}")
446 ax.tick_params(axis="x", labelrotation=25, pad=-1)
447 ax.set_xlabel(self.panels[panel].label, fontsize=label_font_size)
448 y_label = "Normalized (PDF)" if self.panels[panel].histDensity else "Frequency"
449 ax.set_ylabel(y_label, fontsize=label_font_size)
450 ax.set_yscale(self.panels[panel].yscale)
451 ax.tick_params(labelsize=max(5, label_font_size - 2))
452 # add a buffer to the top of the plot to allow headspace for labels
453 ylims = list(ax.get_ylim())
454 if ax.get_yscale() == "log":
455 ylims[1] = 10 ** (np.log10(ylims[1]) * 1.1)
456 else:
457 ylims[1] *= 1.1
458 ax.set_ylim(ylims[0], ylims[1])
460 # Draw a vertical line at a reference value, if given.
461 # If histDensity is True, also plot a reference PDF with
462 # mean = referenceValue and sigma = 1 for reference.
463 if self.panels[panel].referenceValue is not None:
464 ax = self._addReferenceLines(ax, panel, panel_range, meds, legend_font_size=legend_font_size)
466 # Check if we should use the default stats panel or if a custom one
467 # has been created.
468 statList = [
469 self.panels[panel].statsPanel.stat1,
470 self.panels[panel].statsPanel.stat2,
471 self.panels[panel].statsPanel.stat3,
472 ]
473 if not any(statList):
474 stats_dict = {
475 "statLabels": ["N$_{{data}}$", "Med", "${{\\sigma}}_{{MAD}}$"],
476 "stat1": nums,
477 "stat2": meds,
478 "stat3": mads,
479 }
480 elif all(statList):
481 stat1 = [data[stat] for stat in self.panels[panel].statsPanel.stat1]
482 stat2 = [data[stat] for stat in self.panels[panel].statsPanel.stat2]
483 stat3 = [data[stat] for stat in self.panels[panel].statsPanel.stat3]
484 stats_dict = {
485 "statLabels": self.panels[panel].statsPanel.statsLabels,
486 "stat1": stat1,
487 "stat2": stat2,
488 "stat3": stat3,
489 }
490 else:
491 raise RuntimeError("Invalid configuration of HistStatPanel")
492 else:
493 stats_dict = {key: [] for key in ("stat1", "stat2", "stat3")}
494 stats_dict["statLabels"] = [""] * 3
495 return nums, meds, mads, stats_dict
497 def _getPanelRange(self, data, panel, mads=None, meds=None):
498 """Determine panel x-axis range based config settings."""
499 panel_range = [np.nan, np.nan]
500 rangeType = self.panels[panel].rangeType
501 lowerRange = self.panels[panel].lowerRange
502 upperRange = self.panels[panel].upperRange
503 if rangeType == "percentile":
504 panel_range = self._getPercentilePanelRange(data, panel)
505 elif rangeType == "sigmaMad":
506 # Set the panel range to extend lowerRange[upperRange] times the
507 # maximum sigmaMad for the datasets in the panel to the left[right]
508 # from the minimum[maximum] median value of all datasets in the
509 # panel.
510 maxMad = nanMax(mads)
511 maxMed = nanMax(meds)
512 minMed = nanMin(meds)
513 panel_range = [minMed - lowerRange * maxMad, maxMed + upperRange * maxMad]
514 if panel_range[1] - panel_range[0] == 0:
515 log.info(
516 "NOTE: panel_range for {} based on med/sigMad was 0. Computing using "
517 "percentile range instead.".format(panel)
518 )
519 panel_range = self._getPercentilePanelRange(data, panel)
520 elif rangeType == "fixed":
521 panel_range = [lowerRange, upperRange]
522 else:
523 raise RuntimeError(f"Invalid rangeType: {rangeType}")
524 return panel_range
526 def _getPercentilePanelRange(self, data, panel):
527 """Determine panel x-axis range based on data percentile limits."""
528 panel_range = [np.nan, np.nan]
529 for hist in self.panels[panel].hists:
530 data_hist = data[hist]
531 # TODO: Consider raising instead
532 if len(data_hist) > 0:
533 hist_range = np.nanpercentile(
534 data[hist], [self.panels[panel].lowerRange, self.panels[panel].upperRange]
535 )
536 panel_range[0] = nanMin([panel_range[0], hist_range[0]])
537 panel_range[1] = nanMax([panel_range[1], hist_range[1]])
538 return panel_range
540 def _calcStats(self, data):
541 """Calculate the number of data points, median, and median absolute
542 deviation of input data."""
543 num = len(data)
544 med = nanMedian(data)
545 mad = sigmaMad(data)
546 return num, med, mad
548 def _addReferenceLines(self, ax, panel, panel_range, meds, legend_font_size=7):
549 """Draw the vertical reference line and density curve (if requested)
550 on the panel.
551 """
552 ax2 = ax.twinx()
553 ax2.axis("off")
554 ax2.set_xlim(ax.get_xlim())
555 ax2.set_ylim(ax.get_ylim())
557 if self.panels[panel].histDensity:
558 reference_label = None
559 else:
560 if self.panels[panel].refRelativeToMedian:
561 reference_value = self.panels[panel].referenceValue + meds[0]
562 reference_label = "${{\\mu_{{ref}}}}$: {:10.3F}".format(reference_value)
563 else:
564 reference_value = self.panels[panel].referenceValue
565 reference_label = "${{\\mu_{{ref}}}}$: {:10.3F}".format(reference_value)
566 ax2.axvline(reference_value, ls="-", lw=1, c="black", zorder=0, label=reference_label)
567 if (
568 self.panels[panel].histDensity
569 and (panel_range[1] - panel_range[0] != 0)
570 and all(np.isfinite(panel_range))
571 ):
572 ref_x = np.arange(panel_range[0], panel_range[1], (panel_range[1] - panel_range[0]) / 100.0)
573 ref_mean = self.panels[panel].referenceValue
574 ref_std = 1.0
575 ref_y = (
576 1.0
577 / (ref_std * np.sqrt(2.0 * np.pi))
578 * np.exp(-((ref_x - ref_mean) ** 2) / (2.0 * ref_std**2))
579 )
580 ax2.fill_between(ref_x, ref_y, alpha=0.1, color="black", label="P$_{{norm}}(0,1)$", zorder=-1)
581 # Make sure the y-axis extends beyond the data plotted and that
582 # the y-ranges of both axes are in sync.
583 y_max = max(max(ref_y), ax2.get_ylim()[1])
584 if ax2.get_ylim()[1] < 1.05 * y_max:
585 ax.set_ylim(ax.get_ylim()[0], 1.05 * y_max)
586 ax2.set_ylim(ax.get_ylim())
587 ax2.legend(fontsize=legend_font_size, handlelength=1.5, loc="upper right", frameon=False)
589 return ax
591 def _addStatisticsPanel(
592 self,
593 fig,
594 handles,
595 nums,
596 meds,
597 mads,
598 stats_dict,
599 legend_font_size=8,
600 yAnchor0=0.0,
601 nth_row=0,
602 nth_col=0,
603 title_str=None,
604 ):
605 """Add an adjoining panel containing histogram summary statistics."""
606 ax = fig.add_subplot(1, 1, 1)
607 ax.axis("off")
608 fig.subplots_adjust(left=0.05, right=0.95, bottom=0.0, top=1.0)
609 # empty handle, used to populate the bespoke legend layout
610 empty = Rectangle((0, 0), 1, 1, fc="w", fill=False, edgecolor="none", linewidth=0)
612 # set up new legend handles and labels
613 legend_handles = [empty] + handles + ([empty] * 3 * len(handles)) + ([empty] * 3)
615 legend_labels = (
616 ([""] * (len(handles) + 1))
617 + [stats_dict["statLabels"][0]]
618 + [f"{x:.3g}" if abs(x) > 0.01 else f"{x:.2e}" for x in stats_dict["stat1"]]
619 + [stats_dict["statLabels"][1]]
620 + [f"{x:.3g}" if abs(x) > 0.01 else f"{x:.2e}" for x in stats_dict["stat2"]]
621 + [stats_dict["statLabels"][2]]
622 + [f"{x:.3g}" if abs(x) > 0.01 else f"{x:.2e}" for x in stats_dict["stat3"]]
623 )
624 # Replace "e+0" with "e" and "e-0" with "e-" to save space.
625 legend_labels = [label.replace("e+0", "e") for label in legend_labels]
626 legend_labels = [label.replace("e-0", "e-") for label in legend_labels]
628 # Set the y anchor for the legend such that it roughly lines up with
629 # the panels.
630 yAnchor = max(0, yAnchor0 - 0.01) + nth_col * (0.008 + len(nums) * 0.005) * legend_font_size
632 nth_legend = ax.legend(
633 legend_handles,
634 legend_labels,
635 loc="lower left",
636 bbox_to_anchor=(-0.25, yAnchor),
637 ncol=4,
638 handletextpad=-0.25,
639 fontsize=legend_font_size,
640 borderpad=0,
641 frameon=False,
642 columnspacing=-0.25,
643 title=title_str,
644 title_fontproperties={"weight": "bold", "size": legend_font_size},
645 )
646 if nth_row + nth_col > 0:
647 ax.add_artist(nth_legend)