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 fn min_horizontal(&self) -> PolarsResult<Option<Column>>;
16 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 fn sum_horizontal(&self, null_strategy: NullStrategy) -> PolarsResult<Option<Column>>;
38
39 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 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 .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 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 .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 std::ops::Add::add(acc, s)
199 };
200
201 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 !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 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 let out = RAYON.install(|| {
245 non_null_cols
246 .into_par_iter()
247 .map(Ok)
248 .try_reduce_with(sum_fn)
249 .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 .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 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 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 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 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}