Skip to main content

polars_ops/series/ops/
various.rs

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