Skip to main content

polars_ops/series/ops/
various.rs

1#[cfg(feature = "dtype-struct")]
2use polars_core::chunked_array::ops::row_encode::_get_rows_encoded_ca;
3use polars_core::prelude::arity::unary_elementwise_values;
4use polars_core::prelude::*;
5use polars_core::series::IsSorted;
6use polars_core::with_match_physical_numeric_polars_type;
7#[cfg(feature = "hash")]
8use polars_utils::aliases::PlSeedableRandomStateQuality;
9use polars_utils::total_ord::TotalOrd;
10
11use crate::series::ops::SeriesSealed;
12
13pub trait SeriesMethods: SeriesSealed {
14    /// Create a [`DataFrame`] with the unique `values` of this [`Series`] and a column `"counts"`
15    /// with dtype [`IdxType`]
16    fn value_counts(
17        &self,
18        sort: bool,
19        parallel: bool,
20        name: PlSmallStr,
21        normalize: bool,
22    ) -> PolarsResult<DataFrame> {
23        let s = self.as_series();
24        polars_ensure!(
25            s.name() != &name,
26            Duplicate: "using `value_counts` on a column/series named '{}' would lead to duplicate \
27            column names; change `name` to fix", name,
28        );
29        let groups = s.group_tuples(parallel, sort)?;
30        let values = unsafe { s.agg_first(&groups) }
31            .with_name(s.name().clone())
32            .into();
33        let counts = groups.group_count().with_name(name.clone());
34
35        let counts = if normalize {
36            let len = s.len() as f64;
37            let counts: Float64Chunked =
38                unary_elementwise_values(&counts, |count| count as f64 / len);
39            counts.into_column()
40        } else {
41            counts.into_column()
42        };
43
44        let height = counts.len();
45        let cols = vec![values, counts];
46        let df = unsafe { DataFrame::new_unchecked(height, cols) };
47        if sort {
48            df.sort(
49                [name],
50                SortMultipleOptions::default()
51                    .with_order_descending(true)
52                    .with_multithreaded(parallel),
53            )
54        } else {
55            Ok(df)
56        }
57    }
58
59    #[cfg(feature = "hash")]
60    fn hash(&self, build_hasher: PlSeedableRandomStateQuality) -> UInt64Chunked {
61        let s = self.as_series();
62        let mut h = vec![];
63        s.0.vec_hash(build_hasher, &mut h).unwrap();
64        UInt64Chunked::from_vec(s.name().clone(), h)
65    }
66
67    fn ensure_sorted_arg(&self, operation: &str) -> PolarsResult<()> {
68        polars_ensure!(
69            self.is_sorted(SortOptions::default())?,
70            InvalidOperation: "argument in operation '{}' is not sorted, please sort the 'expr/series/column' first",
71            operation
72        );
73        Ok(())
74    }
75
76    /// Checks if a [`Series`] is sorted with concrete options. Tries to fail fast.
77    ///
78    /// For inference of `descending` / `nulls_last`, see [`Self::is_sorted_any`].
79    fn is_sorted(&self, options: SortOptions) -> PolarsResult<bool> {
80        is_sorted_impl(self.as_series(), options)
81    }
82
83    fn is_sorted_any(
84        &self,
85        descending: Option<bool>,
86        nulls_last: Option<bool>,
87    ) -> PolarsResult<bool> {
88        let s = self.as_series();
89        let (descending, nulls_last) = resolve_sort_options(s, descending, nulls_last)?;
90        // When an option could not be inferred the series is trivially sorted along that axis
91        // (e.g. all non-null values equal, or no nulls), so any value works; default to `false`.
92        let options = SortOptions {
93            descending: descending.unwrap_or(false),
94            nulls_last: nulls_last.unwrap_or(false),
95            ..Default::default()
96        };
97        is_sorted_impl(s, options)
98    }
99}
100
101fn is_sorted_impl(s: &Series, options: SortOptions) -> PolarsResult<bool> {
102    let null_count = s.null_count();
103
104    if (options.descending
105        && (options.nulls_last || null_count == 0)
106        && matches!(s.is_sorted_flag(), IsSorted::Descending))
107        || (!options.descending
108            && (!options.nulls_last || null_count == 0)
109            && matches!(s.is_sorted_flag(), IsSorted::Ascending))
110    {
111        return Ok(true);
112    }
113
114    #[cfg(feature = "dtype-struct")]
115    if matches!(s.dtype(), DataType::Struct(_)) {
116        let encoded = _get_rows_encoded_ca(
117            PlSmallStr::EMPTY,
118            &[s.clone().into()],
119            &[options.descending],
120            &[options.nulls_last],
121            false,
122        )?;
123        let options = SortOptions {
124            descending: false,
125            nulls_last: false,
126            ..options
127        };
128        return is_sorted_impl(&encoded.into_series(), options);
129    }
130
131    let s_len = s.len();
132    if null_count == s_len {
133        // All nulls are equal.
134        return Ok(true);
135    }
136    // Check if nulls are in the right location.
137    if null_count > 0 {
138        if options.nulls_last {
139            if s.slice((s_len - null_count) as i64, null_count)
140                .null_count()
141                != null_count
142            {
143                return Ok(false);
144            }
145        } else if s.slice(0, null_count).null_count() != null_count {
146            return Ok(false);
147        }
148    }
149
150    if s.dtype().is_primitive_numeric() {
151        with_match_physical_numeric_polars_type!(s.dtype(), |$T| {
152            let ca: &ChunkedArray<$T> = s.as_ref().as_ref().as_ref();
153            return Ok(is_sorted_ca_num::<$T>(ca, options))
154        })
155    }
156
157    // Logical non-primitive types (e.g. String, Categorical, List, …): take only the contiguous
158    // non-null values (`non_null`). For ordinary `Categorical` use `iter_str` (below); otherwise
159    // `to_physical_repr`, then
160    // (1) for ordinary [`DataType::Categorical`], compare adjacent **decoded strings** (`iter_str`),
161    // (2) reuse `is_sorted_ca_num` when the physical type is primitive numeric (temporal /
162    //     Decimal, Enum-as-integer, …) after `to_physical_repr`;
163    // (3) uses a dedicated kernel for boolean values,
164    // (4) else scans string / binary values with `TotalOrd`,
165    // (5) else fall back to pairwise `Series::lt_eq` / `gt_eq` (nested types, etc.).
166    let non_null_len = s_len - null_count;
167    if non_null_len <= 1 {
168        return Ok(true);
169    }
170
171    let offset = (!options.nulls_last as i64) * (null_count as i64);
172    let non_null = s.slice(offset, non_null_len);
173    debug_assert_eq!(
174        non_null.null_count(),
175        0,
176        "internal error: `is_sorted` non-null slice contains nulls"
177    );
178
179    #[cfg(feature = "dtype-categorical")]
180    if matches!(non_null.dtype(), DataType::Categorical(_, _)) {
181        return is_sorted_categorical_lexical_adjacent(&non_null, options);
182    }
183
184    let phys = non_null.to_physical_repr();
185    let s_phys = phys.as_ref();
186    if s_phys.dtype().is_primitive_numeric() {
187        with_match_physical_numeric_polars_type!(s_phys.dtype(), |$T| {
188            let ca: &ChunkedArray<$T> = s_phys.as_ref().as_ref().as_ref();
189            return Ok(is_sorted_ca_num::<$T>(ca, options))
190        })
191    }
192
193    match s_phys.dtype() {
194        DataType::Boolean => {
195            let ca = s_phys.bool()?;
196            Ok(is_sorted_ca_bool(ca, options.descending))
197        },
198        DataType::String => {
199            let ca = s_phys.str()?;
200            Ok(is_sorted_adjacent_total_ord(
201                ca.no_null_iter(),
202                options.descending,
203            ))
204        },
205        DataType::Binary => {
206            let ca = s_phys.binary()?;
207            Ok(is_sorted_adjacent_total_ord(
208                ca.no_null_iter(),
209                options.descending,
210            ))
211        },
212        DataType::BinaryOffset => {
213            let ca = s_phys.binary_offset()?;
214            Ok(is_sorted_adjacent_total_ord(
215                ca.no_null_iter(),
216                options.descending,
217            ))
218        },
219        _ => {
220            // `non_null` excludes nulls already; compare `non_null[..-1]` with `non_null[1..]`.
221            let cmp_len = non_null_len - 1;
222            let s1 = non_null.slice(0, cmp_len);
223            let s2 = non_null.slice(1, cmp_len);
224            let cmp_op = if options.descending {
225                Series::gt_eq
226            } else {
227                Series::lt_eq
228            };
229            Ok(cmp_op(&s1, &s2)?.all())
230        },
231    }
232}
233
234/// Returns whether iterator elements are non-decreasing (`descending == false`) or non-increasing
235/// (`descending == true`) under [`TotalOrd`].
236///
237/// Assumes the iterator `it` yields **only** the non-null values in row order (one item per row). An empty
238/// iterator is considered sorted. Stops at the first pair that violates the ordering.
239fn is_sorted_adjacent_total_ord<T: TotalOrd>(
240    it: impl Iterator<Item = T>,
241    descending: bool,
242) -> bool {
243    let mut it = it;
244    // Sliding window: `prev` is always the previous element; seed with the first value.
245    let Some(mut prev) = it.next() else {
246        return true;
247    };
248    if descending {
249        for v in it {
250            if !prev.tot_ge(&v) {
251                return false;
252            }
253            prev = v;
254        }
255    } else {
256        for v in it {
257            if !prev.tot_le(&v) {
258                return false;
259            }
260            prev = v;
261        }
262    }
263    true
264}
265
266/// Ordinary [`DataType::Categorical`]: lexical order via adjacent decoded strings (`iter_str`), same as
267/// `Series::lt_eq` / `gt_eq`, but without a Boolean series. Caller must pass a contiguous **non-null**
268/// slice.
269#[cfg(feature = "dtype-categorical")]
270fn is_sorted_categorical_lexical_adjacent(s: &Series, options: SortOptions) -> PolarsResult<bool> {
271    polars_ensure!(
272        matches!(s.dtype(), DataType::Categorical(_, _)),
273        ComputeError: "internal error: expected Categorical in lexical `is_sorted` path",
274    );
275
276    with_match_categorical_physical_type!(s.dtype().cat_physical().unwrap(), |$C| {
277        let ca = s.cat::<$C>()?;
278
279        // `ca.null_count() == 0` implies each `phys` row decodes via `iter_str` to `Some(..)`
280        Ok(is_sorted_adjacent_total_ord(
281            ca.iter_str().map(|opt| {
282                opt.expect(
283                    "`iter_str` produced None while categorical null_count reported 0 (`is_sorted`)"
284                )
285            }),
286            options.descending,
287        ))
288    })
289}
290
291/// Booleans ordered as [`false`] < [`true`] (same as inequality comparisons on [`BooleanChunked`]).
292///
293/// Monotone order is equivalent to at most one plateau change: ascending is `F…FT…T`, descending is
294/// `T…TF…F`. Implemented with `first_true_idx` / `first_false_idx` plus a global false/true count
295/// check.
296///
297/// Caller must ensure **`ca` has no nulls** on the flattened series (see `non_null` slice above).
298fn is_sorted_ca_bool(ca: &BooleanChunked, descending: bool) -> bool {
299    let len = ca.len();
300    if len <= 1 {
301        return true;
302    }
303    debug_assert_eq!(
304        ca.null_count(),
305        0,
306        "internal error: `is_sorted_ca_bool` expects a non-null boolean slice"
307    );
308    if descending {
309        let Some(idx) = ca.first_false_idx() else {
310            return true;
311        };
312        !ca.slice(idx as i64, ca.len() - idx).any()
313    } else {
314        let Some(idx) = ca.first_true_idx() else {
315            return true;
316        };
317        ca.slice(idx as i64, ca.len() - idx).all()
318    }
319}
320
321/// Infers the `(descending, nulls_last)` sort options for `s`, honoring any provided hints.
322///
323/// Each returned value is `Some` when known — taken from the corresponding hint when given,
324/// otherwise inferred from the data — and `None` when it cannot be inferred from `s` alone:
325/// - `descending` is `None` when there are fewer than two distinct non-null values, so no direction
326///   is implied.
327/// - `nulls_last` is `None` when `s` has no nulls, is entirely null, or the nulls are interleaved
328///   (the last of which is not sorted under any placement and is rejected by the `is_sorted` check).
329///
330/// The two axes are independent, so callers can use whichever was determined even when the other
331/// could not be.
332pub fn resolve_sort_options(
333    s: &Series,
334    descending: Option<bool>,
335    nulls_last: Option<bool>,
336) -> PolarsResult<(Option<bool>, Option<bool>)> {
337    let nulls_last = match nulls_last {
338        Some(n) => Some(n),
339        None => infer_nulls_last(s),
340    };
341
342    let descending = match descending {
343        Some(d) => Some(d),
344        None => infer_descending(s, nulls_last.unwrap_or(false))?,
345    };
346
347    Ok((descending, nulls_last))
348}
349
350/// Infers null placement from `s`: `Some(true)` if all nulls sit at the tail, `Some(false)` if all
351/// sit at the head, and `None` if there are no nulls, `s` is entirely null, or the nulls are
352/// interleaved (the latter is not sorted under any placement; the `is_sorted` check rejects it).
353fn infer_nulls_last(s: &Series) -> Option<bool> {
354    let null_count = s.null_count();
355    let s_len = s.len();
356
357    if null_count == 0 || null_count == s_len {
358        return None;
359    }
360
361    if s.slice((s_len - null_count) as i64, null_count)
362        .null_count()
363        == null_count
364    {
365        Some(true)
366    } else if s.slice(0, null_count).null_count() == null_count {
367        Some(false)
368    } else {
369        None
370    }
371}
372
373fn infer_descending(s: &Series, nulls_last: bool) -> PolarsResult<Option<bool>> {
374    let null_count = s.null_count();
375    let non_null_len = s.len() - null_count;
376    if non_null_len < 2 {
377        return Ok(None);
378    }
379
380    let non_null_start = if nulls_last { 0 } else { null_count };
381    let non_null = s.slice(non_null_start as i64, non_null_len);
382
383    let a = non_null.slice(0, non_null_len - 1);
384    let b = non_null.slice(1, non_null_len - 1);
385
386    let lt = a.lt(&b)?;
387    let gt = a.gt(&b)?;
388
389    let lt_first = lt.iter().position(|v| v == Some(true));
390    let gt_first = gt.iter().position(|v| v == Some(true));
391
392    Ok(match (lt_first, gt_first) {
393        (None, None) => None,
394        (Some(_), None) => Some(false),
395        (None, Some(_)) => Some(true),
396        (Some(l), Some(g)) => Some(g < l),
397    })
398}
399
400fn check_cmp<T: NumericNative, Cmp: Fn(&T, &T) -> bool>(
401    vals: &[T],
402    f: Cmp,
403    previous: &mut T,
404) -> bool {
405    let mut sorted = true;
406    for c in vals.chunks(1024) {
407        for v in c {
408            sorted &= f(previous, v);
409            *previous = *v;
410        }
411        if !sorted {
412            return false;
413        }
414    }
415    sorted
416}
417
418fn is_sorted_ca_num<T: PolarsNumericType>(ca: &ChunkedArray<T>, options: SortOptions) -> bool {
419    if let Ok(vals) = ca.cont_slice() {
420        let Some(mut previous) = vals.first().copied() else {
421            return true;
422        };
423        return if options.descending {
424            check_cmp(vals, |prev, c| prev.tot_ge(c), &mut previous)
425        } else {
426            check_cmp(vals, |prev, c| prev.tot_le(c), &mut previous)
427        };
428    };
429
430    if ca.null_count() == 0 {
431        let Some(mut previous) = ca
432            .downcast_iter()
433            .find_map(|arr| arr.values().first().copied())
434        else {
435            return true;
436        };
437        for arr in ca.downcast_iter() {
438            let vals = arr.values();
439            let sorted = if options.descending {
440                check_cmp(vals, |prev, c| prev.tot_ge(c), &mut previous)
441            } else {
442                check_cmp(vals, |prev, c| prev.tot_le(c), &mut previous)
443            };
444            if !sorted {
445                return false;
446            }
447        }
448        return true;
449    };
450
451    let null_count = ca.null_count();
452    if options.nulls_last {
453        let ca = ca.slice(0, ca.len() - null_count);
454        is_sorted_ca_num(&ca, options)
455    } else {
456        let ca = ca.slice(null_count as i64, ca.len() - null_count);
457        is_sorted_ca_num(&ca, options)
458    }
459}
460
461impl SeriesMethods for Series {}