Skip to main content

polars_ops/series/ops/
horizontal.rs

1use std::borrow::Cow;
2
3use polars_core::chunked_array::cast::CastOptions;
4use polars_core::prelude::*;
5use polars_core::runtime::RAYON;
6use polars_core::series::arithmetic::coerce_lhs_rhs;
7use polars_core::utils::dtypes_to_supertype;
8use polars_core::with_match_physical_numeric_polars_type;
9use polars_utils::broadcast::broadcast_len;
10use polars_utils::min_max::MinMax;
11use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator};
12
13pub trait MinMaxHorizontal {
14    /// Aggregate the column horizontally to their min values.
15    fn min_horizontal(&self) -> PolarsResult<Option<Column>>;
16    /// Aggregate the column horizontally to their max values.
17    fn max_horizontal(&self) -> PolarsResult<Option<Column>>;
18}
19
20impl MinMaxHorizontal for DataFrame {
21    fn min_horizontal(&self) -> PolarsResult<Option<Column>> {
22        min_horizontal(self.columns())
23    }
24    fn max_horizontal(&self) -> PolarsResult<Option<Column>> {
25        max_horizontal(self.columns())
26    }
27}
28
29#[derive(Copy, Clone, Debug, PartialEq)]
30pub enum NullStrategy {
31    Ignore,
32    Propagate,
33}
34
35pub trait SumMeanHorizontal {
36    /// Sum all values horizontally across columns.
37    fn sum_horizontal(&self, null_strategy: NullStrategy) -> PolarsResult<Option<Column>>;
38
39    /// Compute the mean of all numeric values horizontally across columns.
40    fn mean_horizontal(&self, null_strategy: NullStrategy) -> PolarsResult<Option<Column>>;
41}
42
43impl SumMeanHorizontal for DataFrame {
44    fn sum_horizontal(&self, null_strategy: NullStrategy) -> PolarsResult<Option<Column>> {
45        sum_horizontal(self.columns(), null_strategy)
46    }
47    fn mean_horizontal(&self, null_strategy: NullStrategy) -> PolarsResult<Option<Column>> {
48        mean_horizontal(self.columns(), null_strategy)
49    }
50}
51
52fn min_binary<T>(left: &ChunkedArray<T>, right: &ChunkedArray<T>) -> ChunkedArray<T>
53where
54    T: PolarsNumericType,
55    T::Native: MinMax,
56{
57    if !left.has_nulls() && !right.has_nulls() {
58        arity::broadcast_binary_elementwise_values(left, right, MinMax::min_ignore_nan)
59    } else {
60        arity::broadcast_binary_elementwise(left, right, |opt_l, opt_r| match (opt_l, opt_r) {
61            (Some(l), Some(r)) => Some(l.min_ignore_nan(r)),
62            (Some(x), None) | (None, Some(x)) => Some(x),
63            (None, None) => None,
64        })
65    }
66}
67
68fn max_binary<T>(left: &ChunkedArray<T>, right: &ChunkedArray<T>) -> ChunkedArray<T>
69where
70    T: PolarsNumericType,
71    T::Native: MinMax,
72{
73    if !left.has_nulls() && !right.has_nulls() {
74        arity::broadcast_binary_elementwise_values(left, right, MinMax::max_ignore_nan)
75    } else {
76        arity::broadcast_binary_elementwise(left, right, |opt_l, opt_r| match (opt_l, opt_r) {
77            (Some(l), Some(r)) => Some(l.max_ignore_nan(r)),
78            (Some(x), None) | (None, Some(x)) => Some(x),
79            (None, None) => None,
80        })
81    }
82}
83
84fn min_max_binary_columns(left: &Column, right: &Column, min: bool) -> PolarsResult<Column> {
85    if left.dtype().to_physical().is_primitive_numeric()
86        && right.dtype().to_physical().is_primitive_numeric()
87    {
88        let height = broadcast_len([left, right])?;
89        let left_s = left.as_materialized_series_maintain_scalar();
90        let right_s = right.as_materialized_series_maintain_scalar();
91        let (lhs, rhs) = coerce_lhs_rhs(&left_s, &right_s)?;
92        let logical = lhs.dtype();
93
94        let lhs = lhs.to_physical_repr();
95        let rhs = rhs.to_physical_repr();
96
97        with_match_physical_numeric_polars_type!(lhs.dtype(), |$T| {
98            let a: &ChunkedArray<$T> = lhs.as_ref().as_ref().as_ref();
99            let b: &ChunkedArray<$T> = rhs.as_ref().as_ref().as_ref();
100
101            unsafe {
102                if min {
103                    min_binary(a, b).into_series().from_physical_unchecked(logical)
104                } else {
105                    max_binary(a, b).into_series().from_physical_unchecked(logical)
106                }
107            }
108        })
109        .and_then(|s| s.broadcast_owned_to(height))
110        .map(Column::from)
111    } else {
112        let mut mask = if min {
113            left.lt(right)?
114        } else {
115            left.gt(right)?
116        };
117        if left.has_nulls() {
118            mask = mask & left.is_not_null();
119        }
120        if right.has_nulls() {
121            mask = mask | right.is_null();
122        }
123        left.zip_with(&mask, right)
124    }
125}
126
127pub fn max_horizontal(columns: &[Column]) -> PolarsResult<Option<Column>> {
128    broadcast_len(columns.iter()).context("max_horizontal")?;
129
130    let max_fn = |acc: &Column, s: &Column| min_max_binary_columns(acc, s, false);
131
132    match columns.len() {
133        0 => Ok(None),
134        1 => Ok(Some(columns[0].clone())),
135        2 => max_fn(&columns[0], &columns[1]).map(Some),
136        _ => {
137            // the try_reduce_with is a bit slower in parallelism,
138            // but I don't think it matters here as we parallelize over columns, not over elements
139            RAYON.install(|| {
140                columns
141                    .par_iter()
142                    .map(|s| Ok(Cow::Borrowed(s)))
143                    .try_reduce_with(|l, r| max_fn(&l, &r).map(Cow::Owned))
144                    // we can unwrap the option, because we are certain there is a column
145                    // we started this operation on 3 columns
146                    .unwrap()
147                    .map(|cow| Some(cow.into_owned()))
148            })
149        },
150    }
151}
152
153pub fn min_horizontal(columns: &[Column]) -> PolarsResult<Option<Column>> {
154    broadcast_len(columns.iter()).context("min_horizontal")?;
155
156    let min_fn = |acc: &Column, s: &Column| min_max_binary_columns(acc, s, true);
157
158    match columns.len() {
159        0 => Ok(None),
160        1 => Ok(Some(columns[0].clone())),
161        2 => min_fn(&columns[0], &columns[1]).map(Some),
162        _ => {
163            // the try_reduce_with is a bit slower in parallelism,
164            // but I don't think it matters here as we parallelize over columns, not over elements
165            RAYON.install(|| {
166                columns
167                    .par_iter()
168                    .map(|s| Ok(Cow::Borrowed(s)))
169                    .try_reduce_with(|l, r| min_fn(&l, &r).map(Cow::Owned))
170                    // we can unwrap the option, because we are certain there is a column
171                    // we started this operation on 3 columns
172                    .unwrap()
173                    .map(|cow| Some(cow.into_owned()))
174            })
175        },
176    }
177}
178
179pub fn sum_horizontal(
180    columns: &[Column],
181    null_strategy: NullStrategy,
182) -> PolarsResult<Option<Column>> {
183    broadcast_len(columns.iter()).context("sum_horizontal")?;
184    let ignore_nulls = null_strategy == NullStrategy::Ignore;
185
186    let apply_null_strategy = |s: Series| -> PolarsResult<Series> {
187        if ignore_nulls && s.null_count() > 0 {
188            s.fill_null(FillNullStrategy::Zero)
189        } else {
190            Ok(s)
191        }
192    };
193
194    let sum_fn = |acc: Series, s: Series| -> PolarsResult<Series> {
195        let acc: Series = apply_null_strategy(acc)?;
196        let s = apply_null_strategy(s)?;
197        // This will do owned arithmetic and can be mutable
198        std::ops::Add::add(acc, s)
199    };
200
201    // @scalar-opt
202    let non_null_cols = columns
203        .iter()
204        .filter(|x| x.dtype() != &DataType::Null)
205        .collect::<Vec<_>>();
206    let out_dtype = sum_output_dtype(&dtypes_to_supertype(
207        non_null_cols.iter().map(|c| c.dtype()),
208    )?);
209
210    // If we have any null columns and null strategy is not `Ignore`, we can return immediately.
211    if !ignore_nulls && non_null_cols.len() < columns.len() {
212        return Ok(Some(Column::full_null(
213            columns[0].name().clone(),
214            columns[0].len(),
215            &out_dtype,
216        )));
217    }
218
219    let non_null_cols = non_null_cols
220        .into_iter()
221        .map(|c| c.as_materialized_series().cast(&out_dtype))
222        .collect::<PolarsResult<Vec<_>>>()?;
223
224    match non_null_cols.len() {
225        0 => {
226            if columns.is_empty() {
227                Ok(None)
228            } else {
229                // all columns are null dtype, so result is null dtype
230                Ok(Some(columns[0].clone()))
231            }
232        },
233        1 => {
234            let [s] = <[Series; 1]>::try_from(non_null_cols).unwrap();
235            Ok(Some(apply_null_strategy(s)?.into()))
236        },
237        2 => {
238            let [a, b] = <[Series; 2]>::try_from(non_null_cols).unwrap();
239            sum_fn(a, b).map(Column::from).map(Some)
240        },
241        _ => {
242            // the try_reduce_with is a bit slower in parallelism,
243            // but I don't think it matters here as we parallelize over columns, not over elements
244            let out = RAYON.install(|| {
245                non_null_cols
246                    .into_par_iter()
247                    .map(Ok)
248                    .try_reduce_with(sum_fn)
249                    // We can unwrap because we started with at least 3 columns, so we always get a Some
250                    .unwrap()
251            });
252            out.map(Column::from).map(Some)
253        },
254    }
255}
256
257pub fn mean_horizontal(
258    columns: &[Column],
259    null_strategy: NullStrategy,
260) -> PolarsResult<Option<Column>> {
261    broadcast_len(columns.iter()).context("mean_horizontal")?;
262
263    let (numeric_columns, non_numeric_columns): (Vec<_>, Vec<_>) = columns.iter().partition(|s| {
264        let dtype = s.dtype();
265        dtype.is_primitive_numeric() || dtype.is_decimal() || dtype.is_bool() || dtype.is_null()
266    });
267
268    if !non_numeric_columns.is_empty() {
269        let col = non_numeric_columns.first().cloned();
270        polars_bail!(
271            InvalidOperation: "'horizontal_mean' expects numeric expressions, found {:?} (dtype={})",
272            col.unwrap().name(),
273            col.unwrap().dtype(),
274        );
275    }
276    let columns = numeric_columns.into_iter().cloned().collect::<Vec<_>>();
277    let num_rows = columns.len();
278    match num_rows {
279        0 => Ok(None),
280        1 => Ok(Some(match columns[0].dtype() {
281            dt if !matches!(dt, DataType::Float16 | DataType::Float32) && !dt.is_decimal() => {
282                columns[0].cast(&DataType::Float64)?
283            },
284            _ => columns[0].clone(),
285        })),
286        _ => {
287            let sum = || sum_horizontal(columns.as_slice(), null_strategy);
288            let null_count = || {
289                columns
290                    .par_iter()
291                    .map(|c| {
292                        c.is_null()
293                            .into_column()
294                            .cast_with_options(&DataType::UInt32, CastOptions::NonStrict)
295                    })
296                    .reduce_with(|l, r| {
297                        let l = l?;
298                        let r = r?;
299                        let result = std::ops::Add::add(&l, &r)?;
300                        PolarsResult::Ok(result)
301                    })
302                    // we can unwrap the option, because we are certain there is a column
303                    // we started this operation on 2 columns
304                    .unwrap()
305            };
306
307            let (sum, null_count) = RAYON.install(|| rayon::join(sum, null_count));
308            let sum = sum?;
309            let null_count = null_count?;
310
311            // value lengths: len - null_count
312            let value_length: UInt32Chunked = (Column::new_scalar(
313                PlSmallStr::EMPTY,
314                Scalar::from(num_rows as u32),
315                null_count.len(),
316            ) - null_count)?
317                .u32()
318                .unwrap()
319                .clone();
320
321            // make sure that we do not divide by zero
322            // by replacing with None
323            let dt = sum
324                .as_ref()
325                .map(Column::dtype)
326                .filter(|dt| matches!(dt, DataType::Float16 | DataType::Float32))
327                .unwrap_or(&DataType::Float64);
328            let value_length = value_length
329                .set(&value_length.equal(0), None)?
330                .into_column()
331                .cast(dt)?;
332
333            sum.map(|sum| std::ops::Div::div(&sum, &value_length))
334                .transpose()
335        },
336    }
337}
338
339pub fn coalesce_columns(s: &[Column]) -> PolarsResult<Column> {
340    // Each iteration recomputes the null mask and allocates a new `out`; a single
341    // pass over all inputs would be faster for many inputs with many nulls.
342    polars_ensure!(!s.is_empty(), NoData: "cannot coalesce empty list");
343    let len = broadcast_len(s)?;
344    let mut out = s[0].clone();
345    for next in &s[1..] {
346        if !out.has_nulls() {
347            break;
348        }
349        if out.null_count() == out.len() {
350            out = next.clone().with_name(out.name().clone());
351            continue;
352        }
353        let mask = out.is_not_null();
354        out = out
355            .as_materialized_series()
356            .zip_with_same_type(&mask, next.as_materialized_series())?
357            .into();
358    }
359    // The early exits skip the broadcast `zip_with` would have done.
360    if out.len() != len {
361        out = out.new_from_index(0, len);
362    }
363    Ok(out)
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    #[cfg_attr(miri, ignore)]
372    fn test_horizontal_agg() {
373        let a = Column::new("a".into(), [1, 2, 6]);
374        let b = Column::new("b".into(), [Some(1), None, None]);
375        let c = Column::new("c".into(), [Some(4), None, Some(3)]);
376
377        let df = DataFrame::new_infer_height(vec![a, b, c]).unwrap();
378        assert_eq!(
379            Vec::from(
380                df.mean_horizontal(NullStrategy::Ignore)
381                    .unwrap()
382                    .unwrap()
383                    .f64()
384                    .unwrap()
385            ),
386            &[Some(2.0), Some(2.0), Some(4.5)]
387        );
388        assert_eq!(
389            Vec::from(
390                df.sum_horizontal(NullStrategy::Ignore)
391                    .unwrap()
392                    .unwrap()
393                    .i32()
394                    .unwrap()
395            ),
396            &[Some(6), Some(2), Some(9)]
397        );
398        assert_eq!(
399            Vec::from(df.min_horizontal().unwrap().unwrap().i32().unwrap()),
400            &[Some(1), Some(2), Some(3)]
401        );
402        assert_eq!(
403            Vec::from(df.max_horizontal().unwrap().unwrap().i32().unwrap()),
404            &[Some(4), Some(2), Some(6)]
405        );
406    }
407}