1"""
2Implementation of nlargest and nsmallest.
3"""
4
5from __future__ import annotations
6
7from collections.abc import (
8 Hashable,
9 Sequence,
10)
11from typing import (
12 TYPE_CHECKING,
13 Generic,
14 Literal,
15 cast,
16 final,
17)
18
19import numpy as np
20
21from pandas._libs import algos as libalgos
22
23from pandas.core.dtypes.common import (
24 is_bool_dtype,
25 is_complex_dtype,
26 is_integer_dtype,
27 is_list_like,
28 is_numeric_dtype,
29 needs_i8_conversion,
30)
31from pandas.core.dtypes.dtypes import BaseMaskedDtype
32
33from pandas.core.indexes.api import default_index
34
35if TYPE_CHECKING:
36 from pandas._typing import (
37 DtypeObj,
38 IndexLabel,
39 NDFrameT,
40 )
41
42 from pandas import (
43 DataFrame,
44 Index,
45 Series,
46 )
47else:
48 # Generic[...] requires a non-str, provide it with a plain TypeVar at
49 # runtime to avoid circular imports
50 from pandas._typing import T
51
52 NDFrameT = T
53 DataFrame = T
54 Series = T
55
56
57class SelectN(Generic[NDFrameT]):
58 def __init__(
59 self, obj: NDFrameT, n: int, keep: Literal["first", "last", "all"]
60 ) -> None:
61 self.obj = obj
62 self.n = n
63 self.keep = keep
64
65 if self.keep not in ("first", "last", "all"):
66 raise ValueError('keep must be either "first", "last" or "all"')
67
68 def compute(self, method: str) -> NDFrameT:
69 raise NotImplementedError
70
71 @final
72 def nlargest(self) -> NDFrameT:
73 return self.compute("nlargest")
74
75 @final
76 def nsmallest(self) -> NDFrameT:
77 return self.compute("nsmallest")
78
79 @final
80 @staticmethod
81 def is_valid_dtype_n_method(dtype: DtypeObj) -> bool:
82 """
83 Helper function to determine if dtype is valid for
84 nsmallest/nlargest methods
85 """
86 if is_numeric_dtype(dtype):
87 return not is_complex_dtype(dtype)
88 return needs_i8_conversion(dtype)
89
90
91class SelectNSeries(SelectN[Series]):
92 """
93 Implement n largest/smallest for Series
94
95 Parameters
96 ----------
97 obj : Series
98 n : int
99 keep : {'first', 'last'}, default 'first'
100
101 Returns
102 -------
103 nordered : Series
104 """
105
106 def compute(self, method: str) -> Series:
107 from pandas.core.reshape.concat import concat
108
109 n = self.n
110 dtype = self.obj.dtype
111 if not self.is_valid_dtype_n_method(dtype):
112 raise TypeError(f"Cannot use method '{method}' with dtype {dtype}")
113
114 if n <= 0:
115 return self.obj[[]]
116
117 # Save index and reset to default index to avoid performance impact
118 # from when index contains duplicates
119 original_index: Index = self.obj.index
120 default_index = self.obj.reset_index(drop=True)
121
122 # Slower method used when taking the full length of the series
123 # In this case, it is equivalent to a sort.
124 if n >= len(default_index):
125 ascending = method == "nsmallest"
126 result = default_index.sort_values(ascending=ascending, kind="stable").head(
127 n
128 )
129 result.index = original_index.take(result.index)
130 return result
131
132 # Fast method used in the general case
133 dropped = default_index.dropna()
134 nan_index = default_index.drop(dropped.index)
135
136 new_dtype = dropped.dtype
137
138 # Similar to algorithms._ensure_data
139 arr = dropped._values
140 if needs_i8_conversion(arr.dtype):
141 arr = arr.view("i8")
142 elif isinstance(arr.dtype, BaseMaskedDtype):
143 arr = arr._data
144 else:
145 arr = np.asarray(arr)
146 if arr.dtype.kind == "b":
147 arr = arr.view(np.uint8)
148
149 if method == "nlargest":
150 arr = -arr
151 if is_integer_dtype(new_dtype):
152 # GH 21426: ensure reverse ordering at boundaries
153 arr -= 1
154
155 elif is_bool_dtype(new_dtype):
156 # GH 26154: ensure False is smaller than True
157 arr = 1 - (-arr)
158
159 if self.keep == "last":
160 arr = arr[::-1]
161
162 nbase = n
163 narr = len(arr)
164 n = min(n, narr)
165
166 # arr passed into kth_smallest must be contiguous. We copy
167 # here because kth_smallest will modify its input
168 # avoid OOB access with kth_smallest_c when n <= 0
169 if len(arr) > 0:
170 kth_val = libalgos.kth_smallest(arr.copy(order="C"), n - 1)
171 else:
172 kth_val = np.nan
173 (ns,) = np.nonzero(arr <= kth_val)
174 inds = ns[arr[ns].argsort(kind="stable")]
175
176 if self.keep != "all":
177 inds = inds[:n]
178 findex = nbase
179 elif len(inds) < nbase <= len(nan_index) + len(inds):
180 findex = len(nan_index) + len(inds)
181 else:
182 findex = len(inds)
183
184 if self.keep == "last":
185 # reverse indices
186 inds = narr - 1 - inds
187
188 result = concat([dropped.iloc[inds], nan_index]).iloc[:findex]
189 result.index = original_index.take(result.index)
190 return result
191
192
193class SelectNFrame(SelectN[DataFrame]):
194 """
195 Implement n largest/smallest for DataFrame
196
197 Parameters
198 ----------
199 obj : DataFrame
200 n : int
201 keep : {'first', 'last'}, default 'first'
202 columns : list or str
203
204 Returns
205 -------
206 nordered : DataFrame
207 """
208
209 def __init__(
210 self,
211 obj: DataFrame,
212 n: int,
213 keep: Literal["first", "last", "all"],
214 columns: IndexLabel,
215 ) -> None:
216 super().__init__(obj, n, keep)
217 if not is_list_like(columns) or isinstance(columns, tuple):
218 columns = [columns]
219
220 columns = cast(Sequence[Hashable], columns)
221 columns = list(columns)
222 self.columns = columns
223
224 def compute(self, method: str) -> DataFrame:
225 n = self.n
226 frame = self.obj
227 columns = self.columns
228
229 for column in columns:
230 dtype = frame[column].dtype
231 if not self.is_valid_dtype_n_method(dtype):
232 raise TypeError(
233 f"Column {column!r} has dtype {dtype}, "
234 f"cannot use method {method!r} with this dtype"
235 )
236
237 def get_indexer(current_indexer: Index, other_indexer: Index) -> Index:
238 """
239 Helper function to concat `current_indexer` and `other_indexer`
240 depending on `method`
241 """
242 if method == "nsmallest":
243 return current_indexer.append(other_indexer)
244 else:
245 return other_indexer.append(current_indexer)
246
247 # Below we save and reset the index in case index contains duplicates
248 original_index = frame.index
249 cur_frame = frame = frame.reset_index(drop=True)
250 cur_n = n
251 indexer: Index = default_index(0)
252
253 for i, column in enumerate(columns):
254 # For each column we apply method to cur_frame[column].
255 # If it's the last column or if we have the number of
256 # results desired we are done.
257 # Otherwise there are duplicates of the largest/smallest
258 # value and we need to look at the rest of the columns
259 # to determine which of the rows with the largest/smallest
260 # value in the column to keep.
261 series = cur_frame[column]
262 is_last_column = len(columns) - 1 == i
263 values = getattr(series, method)(
264 cur_n, keep=self.keep if is_last_column else "all"
265 )
266
267 if is_last_column or len(values) <= cur_n:
268 indexer = get_indexer(indexer, values.index)
269 break
270
271 # Now find all values which are equal to
272 # the (nsmallest: largest)/(nlargest: smallest)
273 # from our series.
274 border_value = values == values[values.index[-1]]
275
276 # Some of these values are among the top-n
277 # some aren't.
278 unsafe_values = values[border_value]
279
280 # These values are definitely among the top-n
281 safe_values = values[~border_value]
282 indexer = get_indexer(indexer, safe_values.index)
283
284 # Go on and separate the unsafe_values on the remaining
285 # columns.
286 cur_frame = cur_frame.loc[unsafe_values.index]
287 cur_n = n - len(indexer)
288
289 frame = frame.take(indexer)
290
291 # Restore the index on frame
292 frame.index = original_index.take(indexer)
293
294 # If there is only one column, the frame is already sorted.
295 if len(columns) == 1:
296 return frame
297
298 ascending = method == "nsmallest"
299
300 return frame.sort_values(columns, ascending=ascending, kind="stable")