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