Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add round_sig_figs expression for rounding to significant figures #11959

Merged
merged 18 commits into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions crates/polars-ops/src/series/ops/round.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use num_traits::pow::Pow;
use polars_core::prelude::*;
use polars_core::with_match_physical_numeric_polars_type;

use crate::series::ops::SeriesSealed;

Expand Down Expand Up @@ -37,6 +38,24 @@ pub trait RoundSeries: SeriesSealed {
polars_bail!(opq = round, s.dtype());
}

fn round_sig_figs(&self, digits: i32) -> PolarsResult<Series> {
let s = self.as_series();
polars_ensure!(digits >= 1, InvalidOperation: "digits must be an integer >= 1");
polars_ensure!(s.dtype().is_numeric(), InvalidOperation: "round_sig_figs can only be used on numeric types" );
with_match_physical_numeric_polars_type!(s.dtype(), |$T| {
let ca: &ChunkedArray<$T> = s.as_ref().as_ref().as_ref();
let s = ca.apply_values(|value| {
let value = value as f64;
if value == 0.0 {
return value as <$T as PolarsNumericType>::Native;
}
let magnitude = 10.0_f64.powi(digits - 1 - value.abs().log10().floor() as i32);
((value * magnitude).round() / magnitude) as <$T as PolarsNumericType>::Native
}).into_series();
return Ok(s);
});
owrior marked this conversation as resolved.
Show resolved Hide resolved
}

/// Floor underlying floating point array to the lowest integers smaller or equal to the float value.
fn floor(&self) -> PolarsResult<Series> {
let s = self.as_series();
Expand Down
12 changes: 11 additions & 1 deletion crates/polars-plan/src/dsl/function_expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ pub enum FunctionExpr {
decimals: u32,
},
#[cfg(feature = "round_series")]
RoundSF {
digits: i32,
},
#[cfg(feature = "round_series")]
Floor,
#[cfg(feature = "round_series")]
Ceil,
Expand Down Expand Up @@ -436,7 +440,9 @@ impl Hash for FunctionExpr {
#[cfg(feature = "round_series")]
Round { decimals } => decimals.hash(state),
#[cfg(feature = "round_series")]
Floor => {},
FunctionExpr::RoundSF { digits } => digits.hash(state),
#[cfg(feature = "round_series")]
FunctionExpr::Floor => {},
#[cfg(feature = "round_series")]
Ceil => {},
UpperBound => {},
Expand Down Expand Up @@ -612,6 +618,8 @@ impl Display for FunctionExpr {
#[cfg(feature = "round_series")]
Round { .. } => "round",
#[cfg(feature = "round_series")]
RoundSF { .. } => "round_sig_figs",
#[cfg(feature = "round_series")]
Floor => "floor",
#[cfg(feature = "round_series")]
Ceil => "ceil",
Expand Down Expand Up @@ -873,6 +881,8 @@ impl From<FunctionExpr> for SpecialEq<Arc<dyn SeriesUdf>> {
#[cfg(feature = "round_series")]
Round { decimals } => map!(round::round, decimals),
#[cfg(feature = "round_series")]
RoundSF { digits } => map!(round::round_sig_figs, digits),
#[cfg(feature = "round_series")]
Floor => map!(round::floor),
#[cfg(feature = "round_series")]
Ceil => map!(round::ceil),
Expand Down
4 changes: 4 additions & 0 deletions crates/polars-plan/src/dsl/function_expr/round.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ pub(super) fn round(s: &Series, decimals: u32) -> PolarsResult<Series> {
s.round(decimals)
}

pub(super) fn round_sig_figs(s: &Series, digits: i32) -> PolarsResult<Series> {
s.round_sig_figs(digits)
}

pub(super) fn floor(s: &Series) -> PolarsResult<Series> {
s.floor()
}
Expand Down
2 changes: 1 addition & 1 deletion crates/polars-plan/src/dsl/function_expr/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ impl FunctionExpr {
Entropy { .. } | Log { .. } | Log1p | Exp => mapper.map_to_float_dtype(),
Unique(_) => mapper.with_same_dtype(),
#[cfg(feature = "round_series")]
Round { .. } | Floor | Ceil => mapper.with_same_dtype(),
Round { .. } | RoundSF { .. } | Floor | Ceil => mapper.with_same_dtype(),
UpperBound | LowerBound => mapper.with_same_dtype(),
#[cfg(feature = "fused")]
Fused(_) => mapper.map_to_supertype(),
Expand Down
6 changes: 6 additions & 0 deletions crates/polars-plan/src/dsl/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,12 @@ impl Expr {
self.map_private(FunctionExpr::Round { decimals })
}

/// Round underlying floating point array to given significant figures.
#[cfg(feature = "round_series")]
pub fn round_sig_figs(self, digits: i32) -> Self {
self.map_private(FunctionExpr::RoundSF { digits })
}

/// Floor underlying floating point array to the lowest integers smaller or equal to the float value.
#[cfg(feature = "round_series")]
pub fn floor(self) -> Self {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Manipulation/selection
Expr.rle
Expr.rle_id
Expr.round
Expr.round_sig_figs
Expr.sample
Expr.shift
Expr.shift_and_fill
Expand Down
1 change: 1 addition & 0 deletions py-polars/docs/source/reference/series/modify_select.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Manipulation/selection
Series.rle
Series.rle_id
Series.round
Series.round_sig_figs
Series.sample
Series.set
Series.set_at_idx
Expand Down
28 changes: 28 additions & 0 deletions py-polars/polars/expr/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -1786,6 +1786,34 @@ def round(self, decimals: int = 0) -> Self:
"""
return self._from_pyexpr(self._pyexpr.round(decimals))

def round_sig_figs(self, digits: int) -> Self:
"""
Round underlying floating point data by `decimals` digits.

Parameters
----------
significant_figures
Number of significant figures to round by.

Examples
--------
>>> df = pl.DataFrame({"a": [0.33, 0.52, 1.02, 1.17]})
>>> df.select(pl.col("a").round_sig_figs(2))
shape: (4, 1)
┌──────┐
│ a │
│ --- │
│ f64 │
╞══════╡
│ 0.33 │
│ 0.52 │
│ 1.0 │
│ 1.2 │
└──────┘

"""
return self._from_pyexpr(self._pyexpr.round_sig_figs(digits))

def dot(self, other: Expr | str) -> Self:
"""
Compute the dot/inner product between two Expressions.
Expand Down
23 changes: 23 additions & 0 deletions py-polars/polars/series/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -4696,6 +4696,29 @@ def round(self, decimals: int = 0) -> Series:

"""

def round_sig_figs(self, digits: int) -> Series:
"""
Round underlying floating point data by `significant` figures.

Examples
--------
>>> s = pl.Series("a", [0.12345, 2.56789, 39.01234])
>>> s.round_sig_figs(2)
shape: (3,)
Series: 'a' [f64]
[
0.12
2.6
39.0
]

Parameters
----------
digits
number of significant figures to round by.

"""

def dot(self, other: Series | ArrayLike) -> float | None:
"""
Compute the dot/inner product between two Series.
Expand Down
4 changes: 4 additions & 0 deletions py-polars/src/expr/general.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,10 @@ impl PyExpr {
self.inner.clone().round(decimals).into()
}

fn round_sig_figs(&self, digits: i32) -> Self {
self.clone().inner.round_sig_figs(digits).into()
}

fn floor(&self) -> Self {
self.inner.clone().floor().into()
}
Expand Down
34 changes: 34 additions & 0 deletions py-polars/tests/unit/series/test_series.py
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,40 @@ def test_round() -> None:
assert b.to_list() == [1.0, 2.0]


@pytest.mark.parametrize(
("series", "digits", "expected_result"),
[
pytest.param(pl.Series([1.234, 0.1234]), 2, pl.Series([1.2, 0.12]), id="f64"),
pytest.param(
pl.Series([1.234, 0.1234]).cast(pl.Float32),
2,
pl.Series([1.2, 0.12]).cast(pl.Float32),
id="f32",
),
pytest.param(pl.Series([123400, 1234]), 2, pl.Series([120000, 1200]), id="i64"),
pytest.param(
pl.Series([123400, 1234]).cast(pl.Int32),
2,
pl.Series([120000, 1200]).cast(pl.Int32),
id="i32",
),
pytest.param(
pl.Series([0.0]), 2, pl.Series([0.0]), id="0 should remain the same"
),
],
)
def test_round_sig_figs(
series: pl.Series, digits: int, expected_result: pl.Series
) -> None:
result = series.round_sig_figs(digits=digits)
assert_series_equal(result, expected_result)


def test_round_sig_figs_raises_exc() -> None:
with pytest.raises(polars.exceptions.InvalidOperationError):
pl.Series([1.234, 0.1234]).round_sig_figs(digits=0)


def test_apply_list_out() -> None:
s = pl.Series("count", [3, 2, 2])
out = s.map_elements(lambda val: pl.repeat(val, val, eager=True))
Expand Down
Loading