from __future__ import annotations from typing import ( TYPE_CHECKING, Literal, ) import numpy as np from pandas.core.dtypes.common import ( is_integer, is_list_like, ) from pandas.core.dtypes.generic import ( ABCDataFrame, ABCIndex, ) from pandas.core.dtypes.missing import ( isna, remove_na_arraylike, ) from pandas.io.formats.printing import pprint_thing from pandas.plotting._matplotlib.core import ( LinePlot, MPLPlot, ) from pandas.plotting._matplotlib.groupby import ( create_iter_data_given_by, reformat_hist_y_given_by, ) from pandas.plotting._matplotlib.misc import unpack_single_str_list from pandas.plotting._matplotlib.tools import ( create_subplots, flatten_axes, maybe_adjust_figure, set_ticks_props, ) if TYPE_CHECKING: from matplotlib.axes import Axes from pandas._typing import PlottingOrientation from pandas import DataFrame class HistPlot(LinePlot): @property def _kind(self) -> Literal["hist", "kde"]: return "hist" def __init__( self, data, bins: int | np.ndarray | list[np.ndarray] = 10, bottom: int | np.ndarray = 0, **kwargs, ) -> None: self.bins = bins # use mpl default self.bottom = bottom self.xlabel = kwargs.get("xlabel") self.ylabel = kwargs.get("ylabel") # Do not call LinePlot.__init__ which may fill nan MPLPlot.__init__(self, data, **kwargs) # pylint: disable=non-parent-init-called def _args_adjust(self) -> None: # calculate bin number separately in different subplots # where subplots are created based on by argument if is_integer(self.bins): if self.by is not None: by_modified = unpack_single_str_list(self.by) grouped = self.data.groupby(by_modified)[self.columns] self.bins = [self._calculate_bins(group) for key, group in grouped] else: self.bins = self._calculate_bins(self.data) if is_list_like(self.bottom): self.bottom = np.array(self.bottom) def _calculate_bins(self, data: DataFrame) -> np.ndarray: """Calculate bins given data""" nd_values = data.infer_objects(copy=False)._get_numeric_data() values = np.ravel(nd_values) values = values[~isna(values)] hist, bins = np.histogram( values, bins=self.bins, range=self.kwds.get("range", None) ) return bins # error: Signature of "_plot" incompatible with supertype "LinePlot" @classmethod def _plot( # type: ignore[override] cls, ax, y, style=None, bottom: int | np.ndarray = 0, column_num: int = 0, stacking_id=None, *, bins, **kwds, ): if column_num == 0: cls._initialize_stacker(ax, stacking_id, len(bins) - 1) base = np.zeros(len(bins) - 1) bottom = bottom + cls._get_stacked_values(ax, stacking_id, base, kwds["label"]) # ignore style n, bins, patches = ax.hist(y, bins=bins, bottom=bottom, **kwds) cls._update_stacker(ax, stacking_id, n) return patches def _make_plot(self) -> None: colors = self._get_colors() stacking_id = self._get_stacking_id() # Re-create iterated data if `by` is assigned by users data = ( create_iter_data_given_by(self.data, self._kind) if self.by is not None else self.data ) for i, (label, y) in enumerate(self._iter_data(data=data)): ax = self._get_ax(i) kwds = self.kwds.copy() label = pprint_thing(label) label = self._mark_right_label(label, index=i) kwds["label"] = label style, kwds = self._apply_style_colors(colors, kwds, i, label) if style is not None: kwds["style"] = style kwds = self._make_plot_keywords(kwds, y) # the bins is multi-dimension array now and each plot need only 1-d and # when by is applied, label should be columns that are grouped if self.by is not None: kwds["bins"] = kwds["bins"][i] kwds["label"] = self.columns kwds.pop("color") # We allow weights to be a multi-dimensional array, e.g. a (10, 2) array, # and each sub-array (10,) will be called in each iteration. If users only # provide 1D array, we assume the same weights is used for all iterations weights = kwds.get("weights", None) if weights is not None: if np.ndim(weights) != 1 and np.shape(weights)[-1] != 1: try: weights = weights[:, i] except IndexError as err: raise ValueError( "weights must have the same shape as data, " "or be a single column" ) from err weights = weights[~isna(y)] kwds["weights"] = weights y = reformat_hist_y_given_by(y, self.by) artists = self._plot(ax, y, column_num=i, stacking_id=stacking_id, **kwds) # when by is applied, show title for subplots to know which group it is if self.by is not None: ax.set_title(pprint_thing(label)) self._append_legend_handles_labels(artists[0], label) def _make_plot_keywords(self, kwds, y): """merge BoxPlot/KdePlot properties to passed kwds""" # y is required for KdePlot kwds["bottom"] = self.bottom kwds["bins"] = self.bins return kwds def _post_plot_logic(self, ax: Axes, data) -> None: if self.orientation == "horizontal": ax.set_xlabel("Frequency" if self.xlabel is None else self.xlabel) ax.set_ylabel(self.ylabel) else: ax.set_xlabel(self.xlabel) ax.set_ylabel("Frequency" if self.ylabel is None else self.ylabel) @property def orientation(self) -> PlottingOrientation: if self.kwds.get("orientation", None) == "horizontal": return "horizontal" else: return "vertical" class KdePlot(HistPlot): @property def _kind(self) -> Literal["kde"]: return "kde" @property def orientation(self) -> Literal["vertical"]: return "vertical" def __init__(self, data, bw_method=None, ind=None, **kwargs) -> None: # Do not call LinePlot.__init__ which may fill nan MPLPlot.__init__(self, data, **kwargs) # pylint: disable=non-parent-init-called self.bw_method = bw_method self.ind = ind def _args_adjust(self) -> None: pass def _get_ind(self, y): if self.ind is None: # np.nanmax() and np.nanmin() ignores the missing values sample_range = np.nanmax(y) - np.nanmin(y) ind = np.linspace( np.nanmin(y) - 0.5 * sample_range, np.nanmax(y) + 0.5 * sample_range, 1000, ) elif is_integer(self.ind): sample_range = np.nanmax(y) - np.nanmin(y) ind = np.linspace( np.nanmin(y) - 0.5 * sample_range, np.nanmax(y) + 0.5 * sample_range, self.ind, ) else: ind = self.ind return ind @classmethod def _plot( cls, ax, y, style=None, bw_method=None, ind=None, column_num=None, stacking_id=None, **kwds, ): from scipy.stats import gaussian_kde y = remove_na_arraylike(y) gkde = gaussian_kde(y, bw_method=bw_method) y = gkde.evaluate(ind) lines = MPLPlot._plot(ax, ind, y, style=style, **kwds) return lines def _make_plot_keywords(self, kwds, y): kwds["bw_method"] = self.bw_method kwds["ind"] = self._get_ind(y) return kwds def _post_plot_logic(self, ax, data) -> None: ax.set_ylabel("Density") def _grouped_plot( plotf, data, column=None, by=None, numeric_only: bool = True, figsize: tuple[float, float] | None = None, sharex: bool = True, sharey: bool = True, layout=None, rot: float = 0, ax=None, **kwargs, ): # error: Non-overlapping equality check (left operand type: "Optional[Tuple[float, # float]]", right operand type: "Literal['default']") if figsize == "default": # type: ignore[comparison-overlap] # allowed to specify mpl default with 'default' raise ValueError( "figsize='default' is no longer supported. " "Specify figure size by tuple instead" ) grouped = data.groupby(by) if column is not None: grouped = grouped[column] naxes = len(grouped) fig, axes = create_subplots( naxes=naxes, figsize=figsize, sharex=sharex, sharey=sharey, ax=ax, layout=layout ) _axes = flatten_axes(axes) for i, (key, group) in enumerate(grouped): ax = _axes[i] if numeric_only and isinstance(group, ABCDataFrame): group = group._get_numeric_data() plotf(group, ax, **kwargs) ax.set_title(pprint_thing(key)) return fig, axes def _grouped_hist( data, column=None, by=None, ax=None, bins: int = 50, figsize: tuple[float, float] | None = None, layout=None, sharex: bool = False, sharey: bool = False, rot: float = 90, grid: bool = True, xlabelsize: int | None = None, xrot=None, ylabelsize: int | None = None, yrot=None, legend: bool = False, **kwargs, ): """ Grouped histogram Parameters ---------- data : Series/DataFrame column : object, optional by : object, optional ax : axes, optional bins : int, default 50 figsize : tuple, optional layout : optional sharex : bool, default False sharey : bool, default False rot : float, default 90 grid : bool, default True legend: : bool, default False kwargs : dict, keyword arguments passed to matplotlib.Axes.hist Returns ------- collection of Matplotlib Axes """ if legend: assert "label" not in kwargs if data.ndim == 1: kwargs["label"] = data.name elif column is None: kwargs["label"] = data.columns else: kwargs["label"] = column def plot_group(group, ax) -> None: ax.hist(group.dropna().values, bins=bins, **kwargs) if legend: ax.legend() if xrot is None: xrot = rot fig, axes = _grouped_plot( plot_group, data, column=column, by=by, sharex=sharex, sharey=sharey, ax=ax, figsize=figsize, layout=layout, rot=rot, ) set_ticks_props( axes, xlabelsize=xlabelsize, xrot=xrot, ylabelsize=ylabelsize, yrot=yrot ) maybe_adjust_figure( fig, bottom=0.15, top=0.9, left=0.1, right=0.9, hspace=0.5, wspace=0.3 ) return axes def hist_series( self, by=None, ax=None, grid: bool = True, xlabelsize: int | None = None, xrot=None, ylabelsize: int | None = None, yrot=None, figsize: tuple[float, float] | None = None, bins: int = 10, legend: bool = False, **kwds, ): import matplotlib.pyplot as plt if legend and "label" in kwds: raise ValueError("Cannot use both legend and label") if by is None: if kwds.get("layout", None) is not None: raise ValueError("The 'layout' keyword is not supported when 'by' is None") # hack until the plotting interface is a bit more unified fig = kwds.pop( "figure", plt.gcf() if plt.get_fignums() else plt.figure(figsize=figsize) ) if figsize is not None and tuple(figsize) != tuple(fig.get_size_inches()): fig.set_size_inches(*figsize, forward=True) if ax is None: ax = fig.gca() elif ax.get_figure() != fig: raise AssertionError("passed axis not bound to passed figure") values = self.dropna().values if legend: kwds["label"] = self.name ax.hist(values, bins=bins, **kwds) if legend: ax.legend() ax.grid(grid) axes = np.array([ax]) set_ticks_props( axes, xlabelsize=xlabelsize, xrot=xrot, ylabelsize=ylabelsize, yrot=yrot ) else: if "figure" in kwds: raise ValueError( "Cannot pass 'figure' when using the " "'by' argument, since a new 'Figure' instance will be created" ) axes = _grouped_hist( self, by=by, ax=ax, grid=grid, figsize=figsize, bins=bins, xlabelsize=xlabelsize, xrot=xrot, ylabelsize=ylabelsize, yrot=yrot, legend=legend, **kwds, ) if hasattr(axes, "ndim"): if axes.ndim == 1 and len(axes) == 1: return axes[0] return axes def hist_frame( data, column=None, by=None, grid: bool = True, xlabelsize: int | None = None, xrot=None, ylabelsize: int | None = None, yrot=None, ax=None, sharex: bool = False, sharey: bool = False, figsize: tuple[float, float] | None = None, layout=None, bins: int = 10, legend: bool = False, **kwds, ): if legend and "label" in kwds: raise ValueError("Cannot use both legend and label") if by is not None: axes = _grouped_hist( data, column=column, by=by, ax=ax, grid=grid, figsize=figsize, sharex=sharex, sharey=sharey, layout=layout, bins=bins, xlabelsize=xlabelsize, xrot=xrot, ylabelsize=ylabelsize, yrot=yrot, legend=legend, **kwds, ) return axes if column is not None: if not isinstance(column, (list, np.ndarray, ABCIndex)): column = [column] data = data[column] # GH32590 data = data.select_dtypes( include=(np.number, "datetime64", "datetimetz"), exclude="timedelta" ) naxes = len(data.columns) if naxes == 0: raise ValueError( "hist method requires numerical or datetime columns, nothing to plot." ) fig, axes = create_subplots( naxes=naxes, ax=ax, squeeze=False, sharex=sharex, sharey=sharey, figsize=figsize, layout=layout, ) _axes = flatten_axes(axes) can_set_label = "label" not in kwds for i, col in enumerate(data.columns): ax = _axes[i] if legend and can_set_label: kwds["label"] = col ax.hist(data[col].dropna().values, bins=bins, **kwds) ax.set_title(col) ax.grid(grid) if legend: ax.legend() set_ticks_props( axes, xlabelsize=xlabelsize, xrot=xrot, ylabelsize=ylabelsize, yrot=yrot ) maybe_adjust_figure(fig, wspace=0.3, hspace=0.3) return axes