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