Skip to main content

polars_core/chunked_array/ops/aggregate/
quantile.rs

1use num_traits::AsPrimitive;
2use polars_compute::rolling::QuantileMethod;
3#[cfg(feature = "dtype-f16")]
4use polars_utils::float16::pf16;
5
6use super::*;
7
8pub trait QuantileAggSeries {
9    /// Get the median of the [`ChunkedArray`] as a new [`Series`] of length 1.
10    fn median_reduce(&self) -> Scalar;
11
12    /// Get the quantile of the [`ChunkedArray`] as a new [`Series`] of length 1.
13    fn quantile_reduce(&self, quantile: f64, method: QuantileMethod) -> PolarsResult<Scalar>;
14
15    /// Get the quantiles of the [`ChunkedArray`] as a new [`Series`] of same length as quantiles
16    fn quantiles_reduce(&self, _quantiles: &[f64], _method: QuantileMethod)
17    -> PolarsResult<Scalar>;
18}
19
20/// Returns the 0-based indices into `n_nonnull` sorted non-null values that `quantile` needs, and
21/// the fraction of the way from the lower to the upper one: `(lower_idx, upper_idx, fraction)`.
22///
23/// `upper_idx == lower_idx` unless `method` combines two distinct values. Only `Linear` uses
24/// `fraction`; `Midpoint` averages the two values.
25pub fn quantile_indices(
26    n_nonnull: usize,
27    quantile: f64,
28    method: QuantileMethod,
29) -> (usize, usize, f64) {
30    assert!(n_nonnull > 0);
31    let float_idx = (n_nonnull as f64 - 1.0) * quantile;
32    let floor_idx = float_idx as usize;
33    let ceil_idx = (float_idx.ceil() as usize).min(n_nonnull - 1);
34    match method {
35        QuantileMethod::Nearest => {
36            let idx = float_idx.round() as usize;
37            (idx, idx, 0.0)
38        },
39        QuantileMethod::Lower => (floor_idx, floor_idx, 0.0),
40        QuantileMethod::Higher => (ceil_idx, ceil_idx, 0.0),
41        QuantileMethod::Midpoint | QuantileMethod::Linear => {
42            (floor_idx, ceil_idx, float_idx - floor_idx as f64)
43        },
44        QuantileMethod::Equiprobable => {
45            let idx = ((n_nonnull as f64 * quantile).ceil() - 1.0).max(0.0) as usize;
46            (idx, idx, 0.0)
47        },
48    }
49}
50
51/// Extracts the quantiles of the `quantile` expression input, a single quantile or a list of them.
52pub fn extract_quantiles(quantile: &Series) -> PolarsResult<Vec<f64>> {
53    polars_ensure!(!quantile.is_empty(), ComputeError:
54        "the 'quantile' expression input should produce a single quantile or a list of quantiles, \
55        got an empty input"
56    );
57    polars_ensure!(quantile.len() == 1, ComputeError:
58        "polars does not support varying quantiles yet, \
59        make sure the 'quantile' expression input produces a single quantile or a list of quantiles"
60    );
61    let quantiles = match quantile.dtype() {
62        DataType::List(_) => {
63            let inner = quantile.list()?.get_as_series(0).ok_or_else(
64                || polars_err!(ComputeError: "quantile expression contains null values"),
65            )?;
66            polars_ensure!(!inner.has_nulls(), ComputeError: "quantile expression contains null values");
67            inner
68                .cast(&DataType::Float64)?
69                .f64()?
70                .into_no_null_iter()
71                .collect()
72        },
73        _ => vec![quantile.get(0)?.try_extract()?],
74    };
75    for &q in &quantiles {
76        polars_ensure!(
77            (0.0..=1.0).contains(&q),
78            ComputeError: "`quantile` should be between 0.0 and 1.0, got {}", q,
79        );
80    }
81    Ok(quantiles)
82}
83
84/// [`quantile_indices`] as indices into `length` sorted values with the `null_count` nulls first.
85fn quantile_idx(
86    quantile: f64,
87    length: usize,
88    null_count: usize,
89    method: QuantileMethod,
90) -> (usize, f64, usize) {
91    let (lower, upper, fraction) = quantile_indices(length - null_count, quantile, method);
92    (lower + null_count, fraction, upper + null_count)
93}
94
95/// helper
96fn linear_interpol<T: Float>(lower: T, upper: T, fraction: f64) -> T {
97    if lower == upper {
98        lower
99    } else {
100        let proportion: T = T::from(fraction).unwrap();
101        proportion * (upper - lower) + lower
102    }
103}
104fn midpoint_interpol<T: Float>(lower: T, upper: T) -> T {
105    if lower == upper {
106        lower
107    } else {
108        (lower + upper) / (T::one() + T::one())
109    }
110}
111
112// Quickselect algorithm is used when
113//    1. The data is not already sorted
114//    2. We can make a contiguous slice of the data
115//    3. We only need a single quantile
116fn quantile_slice<T: ToPrimitive + TotalOrd + Copy>(
117    vals: &mut [T],
118    quantile: f64,
119    method: QuantileMethod,
120) -> PolarsResult<Option<f64>> {
121    polars_ensure!((0.0..=1.0).contains(&quantile),
122        ComputeError: "quantile should be between 0.0 and 1.0, got {}", quantile,
123    );
124    if vals.is_empty() {
125        return Ok(None);
126    }
127    if vals.len() == 1 {
128        return Ok(vals[0].to_f64());
129    }
130    let (idx, fraction, top_idx) = quantile_idx(quantile, vals.len(), 0, method);
131
132    let (_lhs, lower, rhs) = vals.select_nth_unstable_by(idx, TotalOrd::tot_cmp);
133    if idx == top_idx {
134        Ok(lower.to_f64())
135    } else {
136        match method {
137            QuantileMethod::Midpoint => {
138                let upper = rhs.iter().copied().min_by(TotalOrd::tot_cmp).unwrap();
139                Ok(Some(midpoint_interpol(
140                    lower.to_f64().unwrap(),
141                    upper.to_f64().unwrap(),
142                )))
143            },
144            QuantileMethod::Linear => {
145                let upper = rhs.iter().copied().min_by(TotalOrd::tot_cmp).unwrap();
146                Ok(
147                    linear_interpol(lower.to_f64().unwrap(), upper.to_f64().unwrap(), fraction)
148                        .to_f64(),
149                )
150            },
151            _ => Ok(lower.to_f64()),
152        }
153    }
154}
155
156// This function is called if multiple quantiles are requested
157// but we are able to make a contiguous slice of the data.
158// Right now, we do the same thing as the generic function: sort once and
159// get all quantiles from the sorted data. But we could consider multi-quickselect
160// algorithms in the future.
161fn quantiles_slice<T: ToPrimitive + TotalOrd + Copy>(
162    vals: &mut [T],
163    quantiles: &[f64],
164    method: QuantileMethod,
165) -> PolarsResult<Vec<Option<f64>>> {
166    // Validate all quantiles
167    for &q in quantiles {
168        polars_ensure!(
169            (0.0..=1.0).contains(&q),
170            ComputeError: "quantile should be between 0.0 and 1.0, got {}", q
171        );
172    }
173
174    if vals.is_empty() {
175        return Ok(vec![None; quantiles.len()]);
176    }
177    if vals.len() == 1 {
178        let v = vals[0].to_f64();
179        return Ok(vec![v; quantiles.len()]);
180    }
181
182    // For multiple quantiles, just sort once
183    vals.sort_by(TotalOrd::tot_cmp);
184    let n = vals.len();
185
186    let mut out = Vec::with_capacity(quantiles.len());
187
188    for &q in quantiles {
189        let (idx, fraction, top_idx) = quantile_idx(q, n, 0, method);
190
191        // No nulls here, so unwrap is safe
192        let lower = vals[idx].to_f64().unwrap();
193
194        let opt = match method {
195            QuantileMethod::Midpoint => {
196                if top_idx == idx {
197                    Some(lower)
198                } else {
199                    let upper = vals[idx + 1].to_f64().unwrap();
200                    midpoint_interpol(lower, upper).to_f64()
201                }
202            },
203            QuantileMethod::Linear => {
204                if top_idx == idx {
205                    Some(lower)
206                } else {
207                    let upper = vals[idx + 1].to_f64().unwrap();
208                    linear_interpol(lower, upper, fraction).to_f64()
209                }
210            },
211            _ => Some(lower),
212        };
213
214        out.push(opt);
215    }
216
217    Ok(out)
218}
219
220// This function is called if data is already sorted or we cannot make a contiguous slice
221fn generic_quantiles<T>(
222    ca: ChunkedArray<T>,
223    quantiles: &[f64],
224    method: QuantileMethod,
225) -> PolarsResult<Vec<Option<f64>>>
226where
227    T: PolarsNumericType,
228{
229    // Validate all quantiles
230    for &q in quantiles {
231        polars_ensure!(
232            (0.0..=1.0).contains(&q),
233            ComputeError: "`quantile` should be between 0.0 and 1.0, got {}", q,
234        );
235    }
236
237    let null_count = ca.null_count();
238    let length = ca.len();
239
240    if null_count == length {
241        return Ok(vec![None; quantiles.len()]);
242    }
243
244    let sorted = ca.sort(false);
245    let mut out = Vec::with_capacity(quantiles.len());
246
247    for &q in quantiles {
248        let (idx, fraction, top_idx) = quantile_idx(q, length, null_count, method);
249
250        let lower = sorted.get(idx).map(|v| v.to_f64().unwrap());
251
252        let opt = match method {
253            QuantileMethod::Midpoint => {
254                if top_idx == idx {
255                    lower
256                } else {
257                    let upper = sorted.get(idx + 1).map(|v| v.to_f64().unwrap());
258                    midpoint_interpol(lower.unwrap(), upper.unwrap()).to_f64()
259                }
260            },
261            QuantileMethod::Linear => {
262                if top_idx == idx {
263                    lower
264                } else {
265                    let upper = sorted.get(idx + 1).map(|v| v.to_f64().unwrap());
266                    linear_interpol(lower.unwrap(), upper.unwrap(), fraction).to_f64()
267                }
268            },
269            _ => lower,
270        };
271
272        out.push(opt);
273    }
274
275    Ok(out)
276}
277
278impl<T> ChunkQuantile<f64> for ChunkedArray<T>
279where
280    T: PolarsIntegerType,
281    T::Native: TotalOrd,
282{
283    fn quantile(&self, quantile: f64, method: QuantileMethod) -> PolarsResult<Option<f64>> {
284        // in case of sorted data, the sort is free, so don't take quickselect route
285        if let (Ok(slice), false) = (self.cont_slice(), self.is_sorted_ascending_flag()) {
286            let mut owned = slice.to_vec();
287            quantile_slice(&mut owned, quantile, method)
288        } else {
289            let re_val = generic_quantiles(self.clone(), &[quantile], method)?;
290            Ok(re_val.into_iter().next().unwrap())
291        }
292    }
293
294    fn quantiles(
295        &self,
296        quantiles: &[f64],
297        method: QuantileMethod,
298    ) -> PolarsResult<Vec<Option<f64>>> {
299        // in case of sorted data, the sort is free, so don't take quickselect route
300        if let (Ok(slice), false) = (self.cont_slice(), self.is_sorted_ascending_flag()) {
301            let mut owned = slice.to_vec();
302            quantiles_slice(&mut owned, quantiles, method)
303        } else {
304            generic_quantiles(self.clone(), quantiles, method)
305        }
306    }
307
308    fn median(&self) -> Option<f64> {
309        self.quantile(0.5, QuantileMethod::Linear).unwrap() // unwrap fine since quantile in range
310    }
311}
312
313// Version of quantile/median that don't need a memcpy
314impl<T> ChunkedArray<T>
315where
316    T: PolarsIntegerType,
317    T::Native: TotalOrd,
318{
319    pub(crate) fn quantile_faster(
320        mut self,
321        quantile: f64,
322        method: QuantileMethod,
323    ) -> PolarsResult<Option<f64>> {
324        // in case of sorted data, the sort is free, so don't take quickselect route
325        let is_sorted = self.is_sorted_ascending_flag();
326        if let (Some(slice), false) = (self.cont_slice_mut(), is_sorted) {
327            quantile_slice(slice, quantile, method)
328        } else {
329            self.quantile(quantile, method)
330        }
331    }
332
333    pub(crate) fn median_faster(self) -> Option<f64> {
334        self.quantile_faster(0.5, QuantileMethod::Linear).unwrap()
335    }
336}
337
338macro_rules! impl_chunk_quantile_for_float_chunked {
339    ($T:ty, $CA:ty) => {
340        impl ChunkQuantile<$T> for $CA {
341            fn quantile(&self, quantile: f64, method: QuantileMethod) -> PolarsResult<Option<$T>> {
342                // in case of sorted data, the sort is free, so don't take quickselect route
343                let out = if let (Ok(slice), false) =
344                    (self.cont_slice(), self.is_sorted_ascending_flag())
345                {
346                    let mut owned = slice.to_vec();
347                    quantile_slice(&mut owned, quantile, method)
348                } else {
349                    let re_val = generic_quantiles(self.clone(), &[quantile], method)?;
350                    Ok(re_val.into_iter().next().unwrap())
351                };
352                out.map(|v| v.map(|v| v.as_()))
353            }
354
355            fn quantiles(
356                &self,
357                quantiles: &[f64],
358                method: QuantileMethod,
359            ) -> PolarsResult<Vec<Option<$T>>> {
360                // in case of sorted data, the sort is free, so don't take quickselect route
361                let out = if let (Ok(slice), false) =
362                    (self.cont_slice(), self.is_sorted_ascending_flag())
363                {
364                    let mut owned = slice.to_vec();
365                    quantiles_slice(&mut owned, quantiles, method)
366                } else {
367                    generic_quantiles(self.clone(), quantiles, method)
368                };
369
370                out.map(|vec_t| {
371                    vec_t
372                        .into_iter()
373                        .map(|opt| opt.map(|v| AsPrimitive::<$T>::as_(v)))
374                        .collect::<Vec<Option<$T>>>()
375                })
376            }
377
378            fn median(&self) -> Option<$T> {
379                self.quantile(0.5, QuantileMethod::Linear).unwrap() // unwrap fine since quantile in range
380            }
381        }
382    };
383}
384
385#[cfg(feature = "dtype-f16")]
386impl_chunk_quantile_for_float_chunked!(pf16, Float16Chunked);
387impl_chunk_quantile_for_float_chunked!(f32, Float32Chunked);
388impl_chunk_quantile_for_float_chunked!(f64, Float64Chunked);
389
390macro_rules! impl_float_chunked {
391    ($T:ty, $CA:ty) => {
392        impl $CA {
393            pub(crate) fn quantile_faster(
394                mut self,
395                quantile: f64,
396                method: QuantileMethod,
397            ) -> PolarsResult<Option<$T>> {
398                // in case of sorted data, the sort is free, so don't take quickselect route
399                let is_sorted = self.is_sorted_ascending_flag();
400                if let (Some(slice), false) = (self.cont_slice_mut(), is_sorted) {
401                    Ok(quantile_slice(slice, quantile, method)?.map(AsPrimitive::as_))
402                } else {
403                    Ok(self.quantile(quantile, method)?.map(AsPrimitive::as_))
404                }
405            }
406
407            pub(crate) fn median_faster(self) -> Option<$T> {
408                self.quantile_faster(0.5.into(), QuantileMethod::Linear)
409                    .unwrap()
410            }
411        }
412    };
413}
414
415#[cfg(feature = "dtype-f16")]
416impl_float_chunked!(pf16, Float16Chunked);
417impl_float_chunked!(f32, Float32Chunked);
418impl_float_chunked!(f64, Float64Chunked);
419
420impl ChunkQuantile<String> for StringChunked {}
421impl ChunkQuantile<Series> for ListChunked {}
422#[cfg(feature = "dtype-array")]
423impl ChunkQuantile<Series> for ArrayChunked {}
424#[cfg(feature = "object")]
425impl<T: PolarsObject> ChunkQuantile<Series> for ObjectChunked<T> {}
426impl ChunkQuantile<bool> for BooleanChunked {}