Source code for bhf_smarthealth.utils.df_utils

"""This module contains utility functions for common Pandas DataFrame operations"""

import functools
import logging
from collections.abc import Callable
from typing import Any

import geopandas as gpd
import numpy as np
import pandas as pd

logger = logging.getLogger(__name__)


[docs] def apply_func_list_to_df( df: gpd.GeoDataFrame | pd.DataFrame, func_list: list[Callable], ) -> gpd.GeoDataFrame | pd.DataFrame: """Apply a list of data cleaning functions to a DataFrame in turn. Logs any changes to data shape. Parameters ---------- df: gpd.GeoDataFrame | pd.DataFrame, Dataframe containing data to be cleaned func_list : list[Callable] list of functions to apply to dataframe Returns ------- gpd.GeoDataFrame | pd.DataFrame Cleaned Dataframe Notes ----- - Logs any changes to the shape of the DataFrame after processing """ original_size = df.shape for func in func_list: df = func(df) logger.debug(f"Ran {func.__name__}") filtered_size = df.shape if original_size != filtered_size: logger.debug( f"Dataframe filtered. Original shape: {original_size}. New shape: {filtered_size}." ) return df
[docs] def remove_columns( df: gpd.GeoDataFrame | pd.DataFrame, col_names: list[str], ) -> gpd.GeoDataFrame | pd.DataFrame: """Remove specified columns from a dataframe Parameters ---------- df: gpd.GeoDataFrame | pd.DataFrame, DataFrame col_names : list[str] Names of columns to be removed Returns ------- df: gpd.GeoDataFrame | pd.DataFrame DataFrame without specified columns """ logger.debug(f"Dropped {len(col_names)} columns: {col_names}.") return df.drop(columns=col_names)
[docs] def rename_columns( df: gpd.GeoDataFrame | pd.DataFrame, col_names: list[str] ) -> gpd.GeoDataFrame | pd.DataFrame: """Rename dataframe columns from list Parameters ---------- df: gpd.GeoDataFrame | pd.DataFrame, DataFrame col_names : list[str] list of column names in order Returns ------- df: gpd.GeoDataFrame | pd.DataFrame DataFrame with renamed columns """ logger.debug(f"Renaming columns to {col_names}") return df.set_axis(labels=col_names, axis="columns")
[docs] def apply_agg_over_interval( df: gpd.GeoDataFrame | pd.DataFrame, agg_dict: dict[str, list[str]], time_period: str = "5min", ID_col: str | None = None, **kwargs: Any, ) -> gpd.GeoDataFrame | pd.DataFrame: """Apply dictionary of aggregation functions to a DataFrame resampled over a specified time interval. Parameters ---------- df : gpd.GeoDataFrame | pd.DataFrame DataFrame with a datetime index and fields to be aggregated agg_dict : dict[str, list[str]] Dictionary containing column names and aggregation functions to be applied time_period : str, optional Time period to resample data over, by default "5min" ID_col : str, optional ID column name to group by Returns ------- gpd.GeoDataFrame | pd.DataFrame Resampled dataframe with aggregated fields Raises ------ AttributeError Raised if DataFrame has no datetime index or the specified ID_col does not exists in the DataFrame Notes ----- - Columns are renamed to join the original column name with the aggregate function - Empty time windows are removed from the result """ defaults = { "label": "left", "closed": "left", } kwargs = {**defaults, **kwargs} # Resample datetime index over time period validate_datetime_index(df) grouper = [pd.Grouper(level="timestamp", freq=time_period, **kwargs)] if ID_col is not None: if ID_col in df.columns: grouper_params = {"key": ID_col} elif ID_col in df.index.names: grouper_params = {"level": ID_col} else: raise AttributeError( f"Specified ID_col {ID_col} does not exist in the DataFrame" ) grouper.insert(0, pd.Grouper(**grouper_params)) # Apply aggregation functions # TypeChecker ignore as types confuse checker. .agg() correctly implemented at runtime grouped_pseudo_df = df.groupby(grouper, observed=False).agg(agg_dict) # type: ignore # Flatten multi-index column names (if needed) as columns are a multiindex (column name, aggregate function) flat_col_list = [ "_".join(col).strip() if isinstance(col, tuple) else col for col in grouped_pseudo_df.columns.values ] grouped_pseudo_df.columns = flat_col_list logger.debug(f"Flattened columns: {grouped_pseudo_df.columns}") # Drop rows where all of the aggregated cols are NaN grouped_pseudo_df = grouped_pseudo_df.replace([0, 0.0, None], np.nan).dropna( how="all" ) return grouped_pseudo_df
[docs] def validate_dataframe_columns( df: pd.DataFrame | gpd.GeoDataFrame, required_cols: list[str] ) -> bool: """Validate that dataframe contains necessary columns Parameters ---------- df : pd.DataFrame | gpd.GeoDataFrame DataFrame to validate required_cols : list[str] list of columns required Raises ------ KeyError Raised if DataFrame does not contain the required fields """ missing = list(set(required_cols) - set(df.columns)) if missing: raise KeyError( f"Dataframe missing required columns. Expected {required_cols}, missing {missing}" ) return True
[docs] def validate_datetime_index( df: pd.DataFrame | gpd.GeoDataFrame, ) -> bool: """Validates DataFrame has DatetimeIndex Parameters ---------- df : pd.DataFrame | gpd.GeoDataFrame DataFrame to validate Raises ------ AttributeError Raised if DataFrame does not have a DatetimeIndex """ has_multiindex_datetime = isinstance(df.index, pd.MultiIndex) and any( isinstance(level, pd.DatetimeIndex) for level in df.index.levels ) has_datetime = isinstance(df.index, pd.DatetimeIndex) if not has_multiindex_datetime and not has_datetime: raise AttributeError("Dataframe missing DatetimeIndex") return True
[docs] def merge_dataframes(dataframe_list: list[pd.DataFrame], **kwargs) -> pd.DataFrame: """Merge a list of multiple dataframes into a single dataframe. Effectively just a a wrapper around pandas.concat. Parameters ---------- dataframe_list : list[pd.DataFrame] list of dataframes, one per participant Returns ------- pd.DataFrame A single, concatenated pandas DataFrame containing all the rows from the input list. DataFrame will be empty if no non-empty DataFrames exist in dataframe_list. Notes ----- * Empty dataframes are filtered before merging * If the filtered list of dataframes is empty, a single, empty DataFrame is returned """ valid_dataframe_list = [df for df in dataframe_list if not df.empty] if not valid_dataframe_list: logger.warning( f"No non-empty dataframes were found out of {len(dataframe_list)} dataframes. Returning empty dataframe." ) return pd.DataFrame() else: if len(valid_dataframe_list) != len(dataframe_list): logger.warning( f"{len(dataframe_list) - len(valid_dataframe_list)} empty dataframes were found and will not be merged" ) merged_dfs = pd.concat(valid_dataframe_list, **kwargs) logger.debug( f"Merged {len(valid_dataframe_list)} DataFrames into one with columns: {merged_dfs.columns} and index: {merged_dfs.index}" ) return merged_dfs
[docs] def join_dataframes( main_df: pd.DataFrame, other_dfs: pd.DataFrame | list[pd.DataFrame], **kwargs ): """Join one or more other data frames to a main dataframe on their common indices Effectively just a wrapper around Dataframe.join, and will use the same defaults. Parameters ---------- main_df : pd.DataFrame primary dataframe to join other dataframes to other_dfs : pd.DataFrame | list[pd.DataFrame] One or more other dataframes to be joined to the main dataframe Returns ------- pd.DataFrame A single, joined pandas DataFrame containing all columns from the input dataframes """ logger.debug(f"Joining {len(list(other_dfs)) + 1} DataFrames into one") return main_df.join(other_dfs, **kwargs)
[docs] def split_timestamp_to_date_and_time( dataframe: pd.DataFrame, column_name: str = "timestamp" ) -> pd.DataFrame: """Splits timestamp column or index into separate time and date columns Will look for a column first then in the index Parameters ---------- dataframe : pd.DataFrame Incoming dataframe column_name : Optional[str], optional column or index to split, by default "timestamp" Returns ------- pd.DataFrame Modified dataframe with date and time columns, returns None if no matching column or index name found Raises ------ ValueError Raised if the specified column_name cannot be found within the DataFrame """ if column_name in dataframe.columns: timestamp = dataframe[column_name].dt elif column_name in (dataframe.index.names or []): timestamp = dataframe.index.get_level_values(column_name) else: raise ValueError( f"Could not find a column or index level named '{column_name}' " f"in the DataFrame." ) dataframe["date"] = timestamp.date dataframe["time"] = timestamp.time return dataframe
[docs] def get_agg_column_name( data_type: str, agg_type: str | functools.partial, overrides: dict | None = None ) -> str: """Returns the name of an aggregated column combined with the data type. Includes option to override specific column names using a dictionary of overrides. Parameters ---------- data_type : str The data type being aggregated, for example "heart" or "steps" agg_type : str | functools.partial The type of aggregation e.g. min, max, size, mean, or a functools.partial generated by bhf_smarthealth.utils.aggregators.config_filtered_aggs factory overrides : Optional[dict] A dictionary containing columns to be overridden (keys) and what to overwrite the names with (values) Returns ------- _type_ _description_ """ # getattr is needed to extract the __name__ from any Partials. Defaults to the str(agg_type) if __name__ is not available, i.e. the agg_type is a standard string name = getattr(agg_type, "__name__", str(agg_type)) if overrides: # default to the above name if not in the overrides dict name = overrides.get(name, name) # e.g. heart_n_samples or steps_min return f"{data_type}_{name}"