Source code for bhf_smarthealth.utils.aggregators

"""This module contains custom aggregation functions"""

import logging
import pandas as pd
from typing import Any
from functools import partial
from typing import List

logger = logging.getLogger(__name__)


[docs] def mode_with_later_tiebreak(series: "pd.Series") -> Any: """Calculates the modal value of a series. Where there is a tie, the modal value that lies latest in the sorted series is returned. Parameters ---------- series : pd.Series Pandas series of data to calculate a single modal value from Returns ------- Any Modal value that is latest in the ordered series Notes ----- Can be used to return the mode associated with the latest timestamp in timeseries data. """ if series.empty: return None modes = series.mode() if len(modes) == 0: return pd.NA elif len(modes) == 1: return modes.iloc[0] else: # Filter the original series to only include values that are a mode mode_series = series[series.isin(modes)] # Get the last value in the series that is also a mode # The last value in this filtered series will be the most recent mode value. return mode_series.iloc[-1]
[docs] def filtered_aggregation( series: "pd.Series", min_value: float, max_value: float, agg_func: str ) -> object | None: """Perform an aggregation function on a filtered Pandas Series Parameters ---------- series : pd.Series Series of data to be filtered and aggregated min_value : float The allowed minimum value (inclusive) max_value : float The maximum allowed value (inclusive) agg_func : str The aggregation function to be applied to the filtered Series Returns ------- float | None The aggregated value or None if the Series, or filtered Series, is empty Raises ------ ValueError Raised if an unsupported aggregation function is specified """ # If Series/DataFrame contains only NaNs, it is still not considered empty. Must drop these NaN values before empty check if series.dropna().empty: logger.debug("Empty series passed to filtered_aggregation") return None filtered_series = series[series.between(min_value, max_value, inclusive="both")] if filtered_series.dropna().empty: logger.debug( "Series passed to filtered_aggregation is empty after filtering with parameters min_value: {min_value}, max_value: {max_value}" ) return None # return None if the filters remove everything try: # Get the attribute from the Series object attr = getattr(filtered_series, agg_func) # Differentiate between e.g. .mean() and .size (method vs. property) return attr() if callable(attr) else attr except AttributeError as e: e.add_note(f"Unknown aggregation function: {agg_func}") raise
[docs] def config_filtered_aggs( min_value: float, max_value: float, agg_funcs: List[str] ) -> List[partial]: """Configures the filtered_aggregation function(s) as a Partial so that aggregation functions passed to a Pandas groupby object can be passed additional arguments. The function name is manually set to provide an informative column name in the resulting DataFrame. See https://pandas.pydata.org/pandas-docs/stable/user_guide/groupby.html#groupby-aggregate-named for more information Parameters ---------- min_value : float The minimum allowed value (inclusive) max_value : float The maximum allowed value (inclusive) agg_funcs : str The aggregation function(s) to be applied to the filtered Series Returns ------- List[functools.partial] A list of partial objects, see https://docs.python.org/3/library/functools.html#partial-objects Notes ----- When passing config_filtered_aggs to pandas.agg(), if you want to combine custom filtered aggregations with standard pandas aggregation strings you can use list expansion, i.e. {"column_to_be_aggregated": ["list", "of", "standard", "aggregations"] + config_filtered_aggs(min_value, max_value, ["list", "of", "filtered", "aggregations]) } """ aggs = [] for agg_func in agg_funcs: # Create a partial function. The "series" will be passed when called from Pandas .agg() partial_func = partial( filtered_aggregation, min_value=min_value, max_value=max_value, agg_func=agg_func, ) # Set the function name partial_func.__name__ = f"filtered_{agg_func}" # type: ignore aggs.append(partial_func) return aggs