1"""
2Module responsible for execution of NDFrame.describe() method.
3
4Method NDFrame.describe() delegates actual execution to function describe_ndframe().
5"""
6
7from __future__ import annotations
8
9from abc import (
10 ABC,
11 abstractmethod,
12)
13from typing import (
14 TYPE_CHECKING,
15 cast,
16)
17import warnings
18
19import numpy as np
20
21from pandas.errors import Pandas4Warning
22from pandas.util._exceptions import find_stack_level
23from pandas.util._validators import validate_percentile
24
25from pandas.core.dtypes.common import (
26 is_bool_dtype,
27 is_numeric_dtype,
28)
29from pandas.core.dtypes.dtypes import (
30 ArrowDtype,
31 DatetimeTZDtype,
32 ExtensionDtype,
33)
34
35from pandas.core.arrays.floating import Float64Dtype
36from pandas.core.reshape.concat import concat
37
38from pandas.io.formats.format import format_percentiles
39
40if TYPE_CHECKING:
41 from collections.abc import (
42 Callable,
43 Hashable,
44 Sequence,
45 )
46
47 from pandas._typing import (
48 DtypeObj,
49 NDFrameT,
50 npt,
51 )
52
53 from pandas import (
54 DataFrame,
55 Series,
56 )
57
58
59def describe_ndframe(
60 *,
61 obj: NDFrameT,
62 include: str | Sequence[str] | None,
63 exclude: str | Sequence[str] | None,
64 percentiles: Sequence[float] | np.ndarray | None,
65) -> NDFrameT:
66 """Describe series or dataframe.
67
68 Called from pandas.core.generic.NDFrame.describe()
69
70 Parameters
71 ----------
72 obj: DataFrame or Series
73 Either dataframe or series to be described.
74 include : 'all', list-like of dtypes or None (default), optional
75 A white list of data types to include in the result. Ignored for ``Series``.
76 exclude : list-like of dtypes or None (default), optional,
77 A black list of data types to omit from the result. Ignored for ``Series``.
78 percentiles : list-like of numbers, optional
79 The percentiles to include in the output. All should fall between 0 and 1.
80 The default is ``[.25, .5, .75]``, which returns the 25th, 50th, and
81 75th percentiles.
82
83 Returns
84 -------
85 Dataframe or series description.
86 """
87 percentiles = _refine_percentiles(percentiles)
88
89 describer: NDFrameDescriberAbstract
90 if obj.ndim == 1:
91 if include is not None or exclude is not None:
92 # GH#54193
93 warnings.warn(
94 "The 'include' and 'exclude' arguments are deprecated for "
95 "Series.describe and will be removed in a future version. "
96 "These arguments have no effect on Series and will be removed.",
97 Pandas4Warning,
98 stacklevel=find_stack_level(),
99 )
100 describer = SeriesDescriber(
101 obj=cast("Series", obj),
102 )
103 else:
104 describer = DataFrameDescriber(
105 obj=cast("DataFrame", obj),
106 include=include,
107 exclude=exclude,
108 )
109
110 result = describer.describe(percentiles=percentiles)
111 return cast("NDFrameT", result)
112
113
114class NDFrameDescriberAbstract(ABC):
115 """Abstract class for describing dataframe or series.
116
117 Parameters
118 ----------
119 obj : Series or DataFrame
120 Object to be described.
121 """
122
123 def __init__(self, obj: DataFrame | Series) -> None:
124 self.obj = obj
125
126 @abstractmethod
127 def describe(self, percentiles: Sequence[float] | np.ndarray) -> DataFrame | Series:
128 """Do describe either series or dataframe.
129
130 Parameters
131 ----------
132 percentiles : list-like of numbers
133 The percentiles to include in the output.
134 """
135
136
137class SeriesDescriber(NDFrameDescriberAbstract):
138 """Class responsible for creating series description."""
139
140 obj: Series
141
142 def describe(self, percentiles: Sequence[float] | np.ndarray) -> Series:
143 describe_func = select_describe_func(
144 self.obj,
145 )
146 return describe_func(self.obj, percentiles)
147
148
149class DataFrameDescriber(NDFrameDescriberAbstract):
150 """Class responsible for creating dataobj description.
151
152 Parameters
153 ----------
154 obj : DataFrame
155 DataFrame to be described.
156 include : 'all', list-like of dtypes or None
157 A white list of data types to include in the result.
158 exclude : list-like of dtypes or None
159 A black list of data types to omit from the result.
160 """
161
162 obj: DataFrame
163
164 def __init__(
165 self,
166 obj: DataFrame,
167 *,
168 include: str | Sequence[str] | None,
169 exclude: str | Sequence[str] | None,
170 ) -> None:
171 self.include = include
172 self.exclude = exclude
173
174 if obj.ndim == 2 and obj.columns.size == 0:
175 raise ValueError("Cannot describe a DataFrame without columns")
176
177 super().__init__(obj)
178
179 def describe(self, percentiles: Sequence[float] | np.ndarray) -> DataFrame:
180 data = self._select_data()
181
182 ldesc: list[Series] = []
183 for _, series in data.items():
184 describe_func = select_describe_func(series)
185 ldesc.append(describe_func(series, percentiles))
186
187 col_names = reorder_columns(ldesc)
188 d = concat(ldesc, axis=1, ignore_index=True, sort=False).reindex(col_names)
189 d.columns = data.columns.copy()
190 return d
191
192 def _select_data(self) -> DataFrame:
193 """Select columns to be described."""
194 if (self.include is None) and (self.exclude is None):
195 # when some numerics are found, keep only numerics
196 default_include: list[npt.DTypeLike] = [np.number, "datetime"]
197 data = self.obj.select_dtypes(include=default_include)
198 if len(data.columns) == 0:
199 data = self.obj
200 elif isinstance(self.include, str) and self.include == "all":
201 # isinstance guard so an array-like spec is not compared elementwise
202 if self.exclude is not None:
203 msg = "exclude must be None when include is 'all'"
204 raise ValueError(msg)
205 data = self.obj
206 else:
207 data = self.obj.select_dtypes(
208 include=self.include,
209 exclude=self.exclude,
210 )
211 if len(data.columns) == 0:
212 msg = "No columns match the specified include or exclude data types"
213 raise ValueError(msg)
214 return data
215
216
217def reorder_columns(ldesc: Sequence[Series]) -> list[Hashable]:
218 """Set a convenient order for rows for display."""
219 names: list[Hashable] = []
220 seen_names: set[Hashable] = set()
221 ldesc_indexes = sorted((x.index for x in ldesc), key=len)
222 for idxnames in ldesc_indexes:
223 for name in idxnames:
224 if name not in seen_names:
225 seen_names.add(name)
226 names.append(name)
227 return names
228
229
230def describe_numeric_1d(series: Series, percentiles: Sequence[float]) -> Series:
231 """Describe series containing numerical data.
232
233 Parameters
234 ----------
235 series : Series
236 Series to be described.
237 percentiles : list-like of numbers
238 The percentiles to include in the output.
239 """
240 from pandas import Series
241
242 formatted_percentiles = format_percentiles(percentiles)
243
244 if len(percentiles) == 0:
245 quantiles = []
246 else:
247 quantiles = series.quantile(percentiles).tolist()
248
249 stat_index = ["count", "mean", "std", "min", *formatted_percentiles, "max"]
250 d = [
251 series.count(),
252 series.mean(),
253 series.std(),
254 series.min(),
255 *quantiles,
256 series.max(),
257 ]
258 # GH#48340 - always return float on non-complex numeric data
259 dtype: DtypeObj | None
260 if isinstance(series.dtype, ExtensionDtype):
261 if isinstance(series.dtype, ArrowDtype):
262 if series.dtype.kind == "m":
263 # GH53001: describe timedeltas with object dtype
264 dtype = None
265 else:
266 import pyarrow as pa
267
268 dtype = ArrowDtype(pa.float64())
269 else:
270 dtype = Float64Dtype()
271 elif series.dtype.kind in "iufb":
272 # i.e. numeric but exclude complex dtype
273 dtype = np.dtype("float")
274 else:
275 dtype = None
276 return Series(d, index=stat_index, name=series.name, dtype=dtype)
277
278
279def describe_categorical_1d(
280 data: Series,
281 percentiles_ignored: Sequence[float],
282) -> Series:
283 """Describe series containing categorical data.
284
285 Parameters
286 ----------
287 data : Series
288 Series to be described.
289 percentiles_ignored : list-like of numbers
290 Ignored, but in place to unify interface.
291 """
292 names = ["count", "unique", "top", "freq"]
293 objcounts = data.value_counts()
294 count_unique = len(objcounts[objcounts != 0])
295 if count_unique > 0:
296 top, freq = objcounts.index[0], objcounts.iloc[0]
297 dtype = None
298 else:
299 # If the DataFrame is empty, set 'top' and 'freq' to None
300 # to maintain output shape consistency
301 top, freq = np.nan, np.nan
302 dtype = "object"
303
304 result = [data.count(), count_unique, top, freq]
305
306 from pandas import Series
307
308 return Series(result, index=names, name=data.name, dtype=dtype)
309
310
311def describe_timestamp_1d(data: Series, percentiles: Sequence[float]) -> Series:
312 """Describe series containing datetime64 dtype.
313
314 Parameters
315 ----------
316 data : Series
317 Series to be described.
318 percentiles : list-like of numbers
319 The percentiles to include in the output.
320 """
321 # GH-30164
322 from pandas import Series
323
324 formatted_percentiles = format_percentiles(percentiles)
325
326 stat_index = ["count", "mean", "min", *formatted_percentiles, "max"]
327 d = [
328 data.count(),
329 data.mean(),
330 data.min(),
331 *data.quantile(percentiles).tolist(),
332 data.max(),
333 ]
334 return Series(d, index=stat_index, name=data.name)
335
336
337def select_describe_func(
338 data: Series,
339) -> Callable[..., Series]:
340 """Select proper function for describing series based on data type.
341
342 Parameters
343 ----------
344 data : Series
345 Series to be described.
346 """
347 if is_bool_dtype(data.dtype):
348 return describe_categorical_1d
349 elif is_numeric_dtype(data):
350 return describe_numeric_1d
351 elif data.dtype.kind == "M" or isinstance(data.dtype, DatetimeTZDtype):
352 return describe_timestamp_1d
353 elif data.dtype.kind == "m":
354 return describe_numeric_1d
355 else:
356 return describe_categorical_1d
357
358
359def _refine_percentiles(
360 percentiles: Sequence[float] | np.ndarray | None,
361) -> npt.NDArray[np.float64]:
362 """
363 Ensure that percentiles are unique and sorted.
364
365 Parameters
366 ----------
367 percentiles : list-like of numbers, optional
368 The percentiles to include in the output.
369 """
370 if percentiles is None:
371 return np.array([0.25, 0.5, 0.75])
372
373 percentiles = np.asarray(percentiles)
374
375 # get them all to be in [0, 1]
376 validate_percentile(percentiles)
377
378 # sort and check for duplicates
379 unique_pcts = np.unique(percentiles)
380 assert percentiles is not None
381 if len(unique_pcts) < len(percentiles):
382 raise ValueError("percentiles cannot contain duplicates")
383
384 return unique_pcts