From 713aa8084db7d0b62358fc4b3fed2c4a2b63a882 Mon Sep 17 00:00:00 2001 From: saal Date: Tue, 10 Sep 2024 13:47:15 -0700 Subject: [PATCH 01/19] Init commit for cat splitting --- src/pydisagg/ihme/splitter/cat_splitter.py | 327 +++++++++++++++++++++ src/pydisagg/ihme/splitter/sex_splitter.py | 76 +++-- 2 files changed, 383 insertions(+), 20 deletions(-) create mode 100644 src/pydisagg/ihme/splitter/cat_splitter.py diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py new file mode 100644 index 0000000..a78bec4 --- /dev/null +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -0,0 +1,327 @@ +# Motivating example: +# Trying to split state data into county data +# - Data: +# - Match - the criteria beyond the reference column that will be used to establish which data to match the split (i.e. year_id, sex_id, age_group_id) +# - target - the thing that wants to be split (not the value), e.g. state (Iowa) +# - val - the value that will be split (e.g. population) +# - val_sd - the standard deviation of the value that will be split + +# - Pattern: +# - Match - same as above +# - target - the thing that wants to be split (not the value), e.g. state (Iowa) +# - sub_target - the thing that will inform the split (e.g. county) +# - draws - can be used instead of val and val_sd +# - val - the value that will be split (e.g. population) +# - Would this be in the same space as the pre-split value? +# - Iowa estimates 0.3 prevalence of a disease, what would need to be given for the counties? +# - val_sd - the standard deviation of the value that will be split + +# - Population: +# - Match - same as above +# - target - the thing that wants to be split (not the value), e.g. state (Iowa) +# - sub_target - the thing that will inform the split and link to target +# - val - population for th + + +from typing import Any + +import numpy as np +import pandas as pd +from pandas import DataFrame +from pydantic import BaseModel +from scipy.special import expit # type: ignore +from typing import Literal +from pydisagg.disaggregate import split_datapoint +from pydisagg.ihme.schema import Schema +from pydisagg.ihme.validator import ( + validate_columns, + validate_index, + validate_noindexdiff, + validate_nonan, + validate_positive, + validate_realnumber, +) +from pydisagg.models import RateMultiplicativeModel +from pydisagg.models import LogOddsModel + + +class CatDataConfig(Schema): + match: list[str] + target: str + val: str + val_sd: str + + @property + def columns(self) -> list[str]: + return list(set(self.match + [self.target, self.val, self.val_sd])) + + +class CatPatternConfig(Schema): + match: list[str] + target: str + sub_target: str + draws: list[str] = [] + val: str + val_sd: str + prefix: str = "cat_pat_" + + @property + def columns(self) -> list[str]: + return list( + set( + self.match + + [self.target, self.sub_target, self.val, self.val_sd] + ) + ) + + +class CatPopulationConfig(Schema): + match: list[str] + target: str + sub_target: str + val: str + prefix: str = "cat_pop_" + + +class SexSplitter(BaseModel): + data: CatDataConfig + pattern: CatPatternConfig + population: CatPopulationConfig + + def model_post_init(self, __context: Any) -> None: + """Extra validation of all the matches.""" + if not set(self.pattern.match).issubset(self.data.match): + raise ValueError( + "Match criteria in the pattern must be a subset of the data" + ) + if not set(self.population.match).issubset( + self.data.match + self.pattern.match + ): + raise ValueError( + "Match criteria in the population must be a subset of the data and the pattern" + ) + + def create_ref_return_df( + self, data: DataFrame + ) -> tuple[DataFrame, DataFrame]: + """ + Create and return two DataFrames: one with all original columns, and another with only relevant columns. + + Parameters + ---------- + data : DataFrame + The input DataFrame containing the original data from which the two DataFrames will be created. + + Returns + ------- + ref_df : DataFrame + A DataFrame that contains all the original columns from `data`, along with an additional `pyd_id` column + that assigns a unique identifier to each row (ranging from 0 to nrows-1). + + return_df : DataFrame + A DataFrame that contains only the relevant columns (as defined in the configuration) plus the `pyd_id` column. + The relevant columns are determined by the `self.data.columns` or other relevant configuration. + + Notes + ----- + - The `pyd_id` column ensures row uniqueness and helps with diagnostics. + - `return_df` is intended to simplify analysis by limiting the DataFrame to only the columns of interest. + - The relevant columns are retrieved from the configuration (e.g., `self.data.columns`), which may depend + on the specific configuration being used. + + """ + # Expensive space-wise, but helps ensure row uniqueness/ additional checks + ref_df = data.copy() + ref_df["pyd_id"] = range(len(ref_df)) + return_df = ref_df[self.data.columns + ["pyd_id"]] + + return ref_df, return_df + + def parse_data(self, data: DataFrame) -> DataFrame: + name = "While parsing data" + + # Validate core columns first + try: + validate_columns(data, self.data.columns, name) + except KeyError as e: + raise KeyError( + f"{name}: Missing columns in the input data. Details:\n{e}" + ) + + if self.population.target not in data.columns: + raise KeyError( + f"{name}: Missing column '{self.population.target}' in the input data." + ) + + try: + validate_index(data, self.data.match, name) + except ValueError as e: + raise ValueError(f"{name}: Duplicated index found. Details:\n{e}") + + try: + validate_nonan(data, name) + except ValueError as e: + raise ValueError(f"{name}: NaN values found. Details:\n{e}") + + try: + # Does the value need to be positive to split? + validate_positive(data, [self.data.val, self.data.val_sd], name) + except ValueError as e: + raise ValueError( + f"{name}: Non-positive values found in 'val' or 'val_sd'. Details:\n{e}" + ) + + return data + + def parse_pattern( + self, data: DataFrame, pattern: DataFrame, model: str + ) -> DataFrame: + name = "While parsing pattern" + + try: + if not all( + col in pattern.columns + for col in [self.pattern.val, self.pattern.val_sd] + ): + if not self.pattern.draws: + raise ValueError( + f"{name}: Must provide draws for pattern if pattern.val and " + "pattern.val_sd are not available." + ) + validate_columns(pattern, self.pattern.draws, name) + pattern[self.pattern.val] = pattern[self.pattern.draws].mean( + axis=1 + ) + pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std( + axis=1 + ) + + validate_columns(pattern, self.pattern.columns, name) + except KeyError as e: + raise KeyError( + f"{name}: Missing columns in the pattern. Details:\n{e}" + ) + + try: + validate_index(pattern, self.pattern.match, name) + except ValueError as e: + raise ValueError( + f"{name}: Duplicated index found in the pattern. Details:\n{e}" + ) + + try: + validate_nonan(pattern, name) + except ValueError as e: + raise ValueError( + f"{name}: NaN values found in the pattern. Details:\n{e}" + ) + + if model == "rate": + try: + validate_positive( + pattern, [self.pattern.val, self.pattern.val_sd], name + ) + except ValueError as e: + raise ValueError( + f"{name}: Non-positive values found in 'val' or 'val_sd'. Details:\n{e}" + ) + elif model == "logodds": + try: + validate_realnumber(pattern, [self.pattern.val_sd], name) + except ValueError as e: + raise ValueError( + f"{name}: Invalid real number values found. Details:\n{e}" + ) + + pattern_copy = pattern.copy() + pattern_copy = pattern_copy[self.pattern.columns] + rename_map = self.pattern.apply_prefix() + pattern_copy.rename(columns=rename_map, inplace=True) + + data_with_pattern = self._merge_with_pattern(data, pattern_copy) + + # Validate index differences after merging + validate_noindexdiff(data, data_with_pattern, self.data.match, name) + + return data_with_pattern + + def parse_population( + self, data: DataFrame, population: DataFrame + ) -> DataFrame: + name = "While parsing population" + + # Step 1: Validate population columns + try: + validate_columns(population, self.population.columns, name) + except KeyError as e: + raise KeyError( + f"{name}: Missing columns in the population data. Details:\n{e}" + ) + + # Step 2: Get male and female populations and rename columns + male_population = self.get_population_by_sex( + population, self.population.sex_m + ) + female_population = self.get_population_by_sex( + population, self.population.sex_f + ) + + male_population.rename( + columns={self.population.val: "m_pop"}, inplace=True + ) + female_population.rename( + columns={self.population.val: "f_pop"}, inplace=True + ) + + # Step 3: Merge population data with main data + data_with_population = self._merge_with_population( + data, male_population, "m_pop" + ) + data_with_population = self._merge_with_population( + data_with_population, female_population, "f_pop" + ) + + # Step 4: Validate the merged data columns + try: + validate_columns(data_with_population, ["m_pop", "f_pop"], name) + except KeyError as e: + raise KeyError( + f"{name}: Missing population columns after merging. Details:\n{e}" + ) + + # Step 5: Validate for NaN values + try: + validate_nonan(data_with_population, name) + except ValueError as e: + raise ValueError( + f"{name}: NaN values found in the population data. Details:\n{e}" + ) + + # Step 6: Validate index differences + try: + validate_noindexdiff( + data, data_with_population, self.data.index, name + ) + except ValueError as e: + raise ValueError( + f"{name}: Index differences found between data and population. Details:\n{e}" + ) + + # Ensure the columns are in the correct numeric type (e.g., float64) + # Convert "m_pop" and "f_pop" columns to standard numeric types if necessary + data_with_population["m_pop"] = data_with_population["m_pop"].astype( + "float64" + ) + data_with_population["f_pop"] = data_with_population["f_pop"].astype( + "float64" + ) + + return data_with_population + + def _merge_with_pattern( + self, data: DataFrame, pattern: DataFrame + ) -> DataFrame: + data_with_pattern = data.merge( + pattern, on=self.pattern.match, how="left" + ).dropna() + return data_with_pattern diff --git a/src/pydisagg/ihme/splitter/sex_splitter.py b/src/pydisagg/ihme/splitter/sex_splitter.py index f906a12..e75a639 100644 --- a/src/pydisagg/ihme/splitter/sex_splitter.py +++ b/src/pydisagg/ihme/splitter/sex_splitter.py @@ -89,8 +89,12 @@ def model_post_init(self, __context: Any) -> None: "population.index must be a subset of data.index + pattern.index" ) - def _merge_with_pattern(self, data: DataFrame, pattern: DataFrame) -> DataFrame: - data_with_pattern = data.merge(pattern, on=self.pattern.by, how="left").dropna() + def _merge_with_pattern( + self, data: DataFrame, pattern: DataFrame + ) -> DataFrame: + data_with_pattern = data.merge( + pattern, on=self.pattern.by, how="left" + ).dropna() return data_with_pattern def get_population_by_sex(self, population, sex_value): @@ -105,7 +109,9 @@ def parse_data(self, data: DataFrame) -> DataFrame: try: validate_columns(data, self.data.columns, name) except KeyError as e: - raise KeyError(f"{name}: Missing columns in the input data. Details:\n{e}") + raise KeyError( + f"{name}: Missing columns in the input data. Details:\n{e}" + ) if self.population.sex not in data.columns: raise KeyError( @@ -147,12 +153,18 @@ def parse_pattern( "pattern.val_sd are not available." ) validate_columns(pattern, self.pattern.draws, name) - pattern[self.pattern.val] = pattern[self.pattern.draws].mean(axis=1) - pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std(axis=1) + pattern[self.pattern.val] = pattern[self.pattern.draws].mean( + axis=1 + ) + pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std( + axis=1 + ) validate_columns(pattern, self.pattern.columns, name) except KeyError as e: - raise KeyError(f"{name}: Missing columns in the pattern. Details:\n{e}") + raise KeyError( + f"{name}: Missing columns in the pattern. Details:\n{e}" + ) pattern = pattern[self.pattern.columns].copy() @@ -166,7 +178,9 @@ def parse_pattern( try: validate_nonan(pattern, name) except ValueError as e: - raise ValueError(f"{name}: NaN values found in the pattern. Details:\n{e}") + raise ValueError( + f"{name}: NaN values found in the pattern. Details:\n{e}" + ) if model == "rate": try: @@ -196,7 +210,9 @@ def parse_pattern( return data_with_pattern - def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: + def parse_population( + self, data: DataFrame, population: DataFrame + ) -> DataFrame: name = "While parsing population" # Step 1: Validate population columns @@ -208,13 +224,19 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: ) # Step 2: Get male and female populations and rename columns - male_population = self.get_population_by_sex(population, self.population.sex_m) + male_population = self.get_population_by_sex( + population, self.population.sex_m + ) female_population = self.get_population_by_sex( population, self.population.sex_f ) - male_population.rename(columns={self.population.val: "m_pop"}, inplace=True) - female_population.rename(columns={self.population.val: "f_pop"}, inplace=True) + male_population.rename( + columns={self.population.val: "m_pop"}, inplace=True + ) + female_population.rename( + columns={self.population.val: "f_pop"}, inplace=True + ) # Step 3: Merge population data with main data data_with_population = self._merge_with_population( @@ -242,7 +264,9 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: # Step 6: Validate index differences try: - validate_noindexdiff(data, data_with_population, self.data.index, name) + validate_noindexdiff( + data, data_with_population, self.data.index, name + ) except ValueError as e: raise ValueError( f"{name}: Index differences found between data and population. Details:\n{e}" @@ -250,8 +274,12 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: # Ensure the columns are in the correct numeric type (e.g., float64) # Convert "m_pop" and "f_pop" columns to standard numeric types if necessary - data_with_population["m_pop"] = data_with_population["m_pop"].astype("float64") - data_with_population["f_pop"] = data_with_population["f_pop"].astype("float64") + data_with_population["m_pop"] = data_with_population["m_pop"].astype( + "float64" + ) + data_with_population["f_pop"] = data_with_population["f_pop"].astype( + "float64" + ) return data_with_population @@ -268,9 +296,9 @@ def _merge_with_population( # Ensure the merged population columns are standard numeric types if pop_col in data_with_population.columns: - data_with_population[pop_col] = data_with_population[pop_col].astype( - "float64" - ) + data_with_population[pop_col] = data_with_population[ + pop_col + ].astype("float64") return data_with_population @@ -374,12 +402,16 @@ def split( lambda row: split_datapoint( observed_total=row[self.data.val], bucket_populations=np.array([row["m_pop"], row["f_pop"]]), - rate_pattern=input_patterns[split_data.index.get_loc(row.name)], + rate_pattern=input_patterns[ + split_data.index.get_loc(row.name) + ], model=splitting_model, output_type=output_type, normalize_pop_for_average_type_obs=pop_normalize, observed_total_se=row[self.data.val_sd], - pattern_covariance=np.diag([0, row[self.pattern.val_sd] ** 2]), + pattern_covariance=np.diag( + [0, row[self.pattern.val_sd] ** 2] + ), ), axis=1, ) @@ -410,7 +442,11 @@ def split( # Reindex columns final_split_df = final_split_df.reindex( columns=self.data.index - + [col for col in final_split_df.columns if col not in self.data.index] + + [ + col + for col in final_split_df.columns + if col not in self.data.index + ] ) # Clean up any prefixes added earlier From 1a7be67fb556a69fb733e068e4b61b70ccb4d64b Mon Sep 17 00:00:00 2001 From: saal Date: Tue, 10 Sep 2024 13:48:58 -0700 Subject: [PATCH 02/19] Updated cat split --- src/pydisagg/ihme/splitter/cat_splitter.py | 70 +++++++--------------- 1 file changed, 22 insertions(+), 48 deletions(-) diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index a78bec4..373e396 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -68,10 +68,7 @@ class CatPatternConfig(Schema): @property def columns(self) -> list[str]: return list( - set( - self.match - + [self.target, self.sub_target, self.val, self.val_sd] - ) + set(self.match + [self.target, self.sub_target, self.val, self.val_sd]) ) @@ -83,7 +80,7 @@ class CatPopulationConfig(Schema): prefix: str = "cat_pop_" -class SexSplitter(BaseModel): +class CatSplitter(BaseModel): data: CatDataConfig pattern: CatPatternConfig population: CatPopulationConfig @@ -101,9 +98,7 @@ def model_post_init(self, __context: Any) -> None: "Match criteria in the population must be a subset of the data and the pattern" ) - def create_ref_return_df( - self, data: DataFrame - ) -> tuple[DataFrame, DataFrame]: + def create_ref_return_df(self, data: DataFrame) -> tuple[DataFrame, DataFrame]: """ Create and return two DataFrames: one with all original columns, and another with only relevant columns. @@ -144,9 +139,7 @@ def parse_data(self, data: DataFrame) -> DataFrame: try: validate_columns(data, self.data.columns, name) except KeyError as e: - raise KeyError( - f"{name}: Missing columns in the input data. Details:\n{e}" - ) + raise KeyError(f"{name}: Missing columns in the input data. Details:\n{e}") if self.population.target not in data.columns: raise KeyError( @@ -189,18 +182,12 @@ def parse_pattern( "pattern.val_sd are not available." ) validate_columns(pattern, self.pattern.draws, name) - pattern[self.pattern.val] = pattern[self.pattern.draws].mean( - axis=1 - ) - pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std( - axis=1 - ) + pattern[self.pattern.val] = pattern[self.pattern.draws].mean(axis=1) + pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std(axis=1) validate_columns(pattern, self.pattern.columns, name) except KeyError as e: - raise KeyError( - f"{name}: Missing columns in the pattern. Details:\n{e}" - ) + raise KeyError(f"{name}: Missing columns in the pattern. Details:\n{e}") try: validate_index(pattern, self.pattern.match, name) @@ -212,9 +199,7 @@ def parse_pattern( try: validate_nonan(pattern, name) except ValueError as e: - raise ValueError( - f"{name}: NaN values found in the pattern. Details:\n{e}" - ) + raise ValueError(f"{name}: NaN values found in the pattern. Details:\n{e}") if model == "rate": try: @@ -245,9 +230,7 @@ def parse_pattern( return data_with_pattern - def parse_population( - self, data: DataFrame, population: DataFrame - ) -> DataFrame: + def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: name = "While parsing population" # Step 1: Validate population columns @@ -258,20 +241,19 @@ def parse_population( f"{name}: Missing columns in the population data. Details:\n{e}" ) - # Step 2: Get male and female populations and rename columns - male_population = self.get_population_by_sex( - population, self.population.sex_m - ) + # Step 2: Get all the population data for a given target and match + # we have target and sub_target in the population data, e.g. target = state, sub_target = county + # so for each target, we want to group the sub targets and get a relative proportion of the population + # We should probably do this for target-match combination so that we dont have to recalculate the proportions + + ### Progress so far + male_population = self.get_population_by_sex(population, self.population.sex_m) female_population = self.get_population_by_sex( population, self.population.sex_f ) - male_population.rename( - columns={self.population.val: "m_pop"}, inplace=True - ) - female_population.rename( - columns={self.population.val: "f_pop"}, inplace=True - ) + male_population.rename(columns={self.population.val: "m_pop"}, inplace=True) + female_population.rename(columns={self.population.val: "f_pop"}, inplace=True) # Step 3: Merge population data with main data data_with_population = self._merge_with_population( @@ -299,9 +281,7 @@ def parse_population( # Step 6: Validate index differences try: - validate_noindexdiff( - data, data_with_population, self.data.index, name - ) + validate_noindexdiff(data, data_with_population, self.data.index, name) except ValueError as e: raise ValueError( f"{name}: Index differences found between data and population. Details:\n{e}" @@ -309,18 +289,12 @@ def parse_population( # Ensure the columns are in the correct numeric type (e.g., float64) # Convert "m_pop" and "f_pop" columns to standard numeric types if necessary - data_with_population["m_pop"] = data_with_population["m_pop"].astype( - "float64" - ) - data_with_population["f_pop"] = data_with_population["f_pop"].astype( - "float64" - ) + data_with_population["m_pop"] = data_with_population["m_pop"].astype("float64") + data_with_population["f_pop"] = data_with_population["f_pop"].astype("float64") return data_with_population - def _merge_with_pattern( - self, data: DataFrame, pattern: DataFrame - ) -> DataFrame: + def _merge_with_pattern(self, data: DataFrame, pattern: DataFrame) -> DataFrame: data_with_pattern = data.merge( pattern, on=self.pattern.match, how="left" ).dropna() From 7cd2c2abc8a76bb782d722ccb2ccaf04b3d721a7 Mon Sep 17 00:00:00 2001 From: saal Date: Fri, 13 Sep 2024 08:55:26 -0700 Subject: [PATCH 03/19] cat_split update and notes --- src/pydisagg/ihme/splitter/cat_splitter.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index 373e396..96ca987 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -48,6 +48,7 @@ class CatDataConfig(Schema): match: list[str] target: str + sub_target: str # We are going to assume that the sub_target column will have a list of values per row that associate with the target. If target == sub_target that is the reference for that group? val: str val_sd: str @@ -247,6 +248,11 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: # We should probably do this for target-match combination so that we dont have to recalculate the proportions ### Progress so far + # + # + # + # + male_population = self.get_population_by_sex(population, self.population.sex_m) female_population = self.get_population_by_sex( population, self.population.sex_f From abbacc2ebe0ac79847e67c929e0ef794787a5eca Mon Sep 17 00:00:00 2001 From: saal Date: Mon, 16 Sep 2024 12:00:34 -0700 Subject: [PATCH 04/19] Cautiously optimistic as a first pass --- examples/ihme_api/cat_sex_split_example.py | 169 +++++++++++ examples/ihme_api/cat_split_example.py | 113 +++++++ src/pydisagg/ihme/splitter/__init__.py | 10 + src/pydisagg/ihme/splitter/cat_splitter.py | 337 +++++++++++---------- 4 files changed, 464 insertions(+), 165 deletions(-) create mode 100644 examples/ihme_api/cat_sex_split_example.py create mode 100644 examples/ihme_api/cat_split_example.py diff --git a/examples/ihme_api/cat_sex_split_example.py b/examples/ihme_api/cat_sex_split_example.py new file mode 100644 index 0000000..cd0bd25 --- /dev/null +++ b/examples/ihme_api/cat_sex_split_example.py @@ -0,0 +1,169 @@ +import pandas as pd +import numpy as np + +# Import CatSplitter and configurations from your package +from pydisagg.ihme.splitter import ( + CatSplitter, + CatDataConfig, + CatPatternConfig, + CatPopulationConfig, +) + +# Set a random seed for reproducibility +np.random.seed(42) + +# ------------------------------- +# 1. Create and Update data_df +# ------------------------------- + +# Existing data_df DataFrame +data_df = pd.DataFrame( + { + "seq": [303284043, 303284062, 303284063, 303284064, 303284065], + "location_id": [78, 130, 120, 30, 141], + "mean": [0.5] * 5, + "standard_error": [0.1] * 5, + "year_id": [2015, 2019, 2018, 2017, 2016], + "sex_id": [3] * 5, + } +) + +# Adding the 'sexes' column with a list [1, 2] for each row +data_df["sex"] = [[1, 2]] * len(data_df) # Renamed 'sexes' to 'sex' + +# Sort data_df for clarity +data_df_sorted = data_df.sort_values(by=["location_id", "sex_id"]).reset_index( + drop=True +) + +# Display the sorted data_df +print("data_df:") +print(data_df_sorted) + +# ------------------------------- +# 2. Create and Update pattern_df_final +# ------------------------------- + +pattern_df = pd.DataFrame( + { + "location_id": [78, 130, 120, 30, 141], + "mean": [0.5] * 5, + "standard_error": [0.1] * 5, + "year_id": [2015, 2019, 2018, 2017, 2016], + "sex_id": [3] * 5, + } +) + +# Create DataFrame for sex=1 +pattern_df_sex1 = pattern_df.copy() +pattern_df_sex1["sex"] = 1 # Assign sex=1 +pattern_df_sex1["mean"] += np.random.normal(0, 0.01, size=len(pattern_df_sex1)) +pattern_df_sex1["standard_error"] += np.random.normal( + 0, 0.001, size=len(pattern_df_sex1) +) +pattern_df_sex1["mean"] = pattern_df_sex1["mean"].round(6) +pattern_df_sex1["standard_error"] = pattern_df_sex1["standard_error"].round(6) + +# Create DataFrame for sex=2 +pattern_df_sex2 = pattern_df.copy() +pattern_df_sex2["sex"] = 2 # Assign sex=2 +pattern_df_sex2["mean"] += np.random.normal(0, 0.01, size=len(pattern_df_sex2)) +pattern_df_sex2["standard_error"] += np.random.normal( + 0, 0.001, size=len(pattern_df_sex2) +) +pattern_df_sex2["mean"] = pattern_df_sex2["mean"].round(6) +pattern_df_sex2["standard_error"] = pattern_df_sex2["standard_error"].round(6) + +pattern_df_final = pd.concat([pattern_df_sex1, pattern_df_sex2], ignore_index=True) + +# Sort pattern_df_final for clarity +pattern_df_final_sorted = pattern_df_final.sort_values( + by=["location_id", "sex"] +).reset_index(drop=True) + +print("\npattern_df_final:") +print(pattern_df_final_sorted) + +# ------------------------------- +# 3. Create and Update population_df +# ------------------------------- + +population_df = pd.DataFrame( + { + "location_id": [30, 30, 78, 78, 120, 120, 130, 130, 141, 141], + "year_id": [2017] * 2 + [2015] * 2 + [2018] * 2 + [2019] * 2 + [2016] * 2, + "sex": [1, 2] * 5, # Sexes 1 and 2 + "population": [ + 39789, + 40120, + 10234, + 10230, + 30245, + 29870, + 19876, + 19980, + 50234, + 49850, + ], + } +) + +# Sort population_df for clarity +population_df_sorted = population_df.sort_values(by=["location_id", "sex"]).reset_index( + drop=True +) + +# Display the sorted population_df +print("\npopulation_df:") +print(population_df_sorted) + +# ------------------------------- +# 4. Configure and Run CatSplitter +# ------------------------------- + +# Data configuration +data_config = CatDataConfig( + index=["seq", "location_id", "year_id"], + target="sex_id", + sub_target="sex", + val="mean", + val_sd="standard_error", +) + +# Pattern configuration +pattern_config = CatPatternConfig( + index=["location_id", "year_id"], + sub_target="sex", + val="mean", + val_sd="standard_error", +) + +# Population configuration +population_config = CatPopulationConfig( + index=["location_id", "year_id"], + sub_target="sex", + val="population", +) + +# Initialize the CatSplitter +splitter = CatSplitter( + data=data_config, pattern=pattern_config, population=population_config +) + +# Perform the split +try: + final_split_df = splitter.split( + data=data_df, + pattern=pattern_df_final, + population=population_df, + model="rate", + output_type="rate", + ) + + # Sort the final DataFrame by 'seq' and then by 'sex' + final_split_df.sort_values(by=["seq", "sex"], inplace=True) + + print("\nFinal Split DataFrame:") + print(final_split_df) +except ValueError as e: + print(f"Error: {e}") diff --git a/examples/ihme_api/cat_split_example.py b/examples/ihme_api/cat_split_example.py new file mode 100644 index 0000000..fa4342c --- /dev/null +++ b/examples/ihme_api/cat_split_example.py @@ -0,0 +1,113 @@ +import pandas as pd +import numpy as np + +# Import CatSplitter and configurations from your package +from pydisagg.ihme.splitter import ( + CatSplitter, + CatDataConfig, + CatPatternConfig, + CatPopulationConfig, +) + +# Set a random seed for reproducibility +np.random.seed(42) + +# ------------------------------- +# Example DataFrames +# ------------------------------- + +# Pre-split DataFrame with 3 rows: 1 for Iowa, 2 for Washington +pre_split = pd.DataFrame( + { + "study_id": np.random.randint(1000, 9999, size=3), # Unique study IDs + "state": ["IA", "WA", "WA"], + "county": [ + ["Johnson", "Scott", "Cedar", "Polk", "Linn"], # Iowa counties + ["King", "Pierce", "Snohomish", "Spokane", "Clark"], # WA row 1 counties + ["King", "Pierce"], # WA row 2 counties + ], + "year_id": [2010, 2010, 2010], + "mean": [0.2, 0.3, 0.3], + "std_err": [0.01, 0.02, 0.02], + } +) + +# Create a list of all counties mentioned +all_counties = [ + "Johnson", + "Scott", + "Cedar", + "Polk", + "Linn", # Iowa counties + "King", + "Pierce", + "Snohomish", + "Spokane", + "Clark", # WA row 1 counties + "Thurston", + "Yakima", # WA row 2 counties +] + +# Pattern DataFrame for all counties +data_pattern = pd.DataFrame( + { + "county": all_counties, + "year_id": [2010] * len(all_counties), + "mean": np.random.uniform(0.1, 0.5, len(all_counties)), + "std_err": np.random.uniform(0.01, 0.05, len(all_counties)), + } +) + +# Population DataFrame for all counties +data_pop = pd.DataFrame( + { + "county": all_counties, + "year_id": [2010] * len(all_counties), + "population": np.random.randint(10000, 1000000, len(all_counties)), + } +) + +# ------------------------------- +# Configurations +# ------------------------------- + +data_config = CatDataConfig( + index=["study_id", "state", "year_id"], # Include study_id in the index + target="state", + sub_target="county", + val="mean", + val_sd="std_err", +) + +pattern_config = CatPatternConfig( + index=["year_id"], # Include 'state' if patterns differ by state + sub_target="county", + val="mean", + val_sd="std_err", +) + +population_config = CatPopulationConfig( + index=["year_id"], # Include 'state' if populations differ by state + sub_target="county", + val="population", +) + +# Initialize the CatSplitter +splitter = CatSplitter( + data=data_config, pattern=pattern_config, population=population_config +) + +# Perform the split +try: + final_split_df = splitter.split( + data=pre_split, + pattern=data_pattern, + population=data_pop, + model="rate", + output_type="rate", + ) + final_split_df.sort_values(by=["state", "study_id", "county"], inplace=True) + print("\nFinal Split DataFrame:") + print(final_split_df) +except ValueError as e: + print(f"Error: {e}") diff --git a/src/pydisagg/ihme/splitter/__init__.py b/src/pydisagg/ihme/splitter/__init__.py index 2687c09..c954cdf 100644 --- a/src/pydisagg/ihme/splitter/__init__.py +++ b/src/pydisagg/ihme/splitter/__init__.py @@ -10,6 +10,12 @@ SexPatternConfig, SexPopulationConfig, ) +from .cat_splitter import ( + CatSplitter, + CatDataConfig, + CatPatternConfig, + CatPopulationConfig, +) __all__ = [ "AgeSplitter", @@ -20,4 +26,8 @@ "SexDataConfig", "SexPatternConfig", "SexPopulationConfig", + "CatSplitter", + "CatDataConfig", + "CatPatternConfig", + "CatPopulationConfig", ] diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index 96ca987..6829938 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -1,29 +1,4 @@ -# Motivating example: -# Trying to split state data into county data -# - Data: -# - Match - the criteria beyond the reference column that will be used to establish which data to match the split (i.e. year_id, sex_id, age_group_id) -# - target - the thing that wants to be split (not the value), e.g. state (Iowa) -# - val - the value that will be split (e.g. population) -# - val_sd - the standard deviation of the value that will be split - -# - Pattern: -# - Match - same as above -# - target - the thing that wants to be split (not the value), e.g. state (Iowa) -# - sub_target - the thing that will inform the split (e.g. county) -# - draws - can be used instead of val and val_sd -# - val - the value that will be split (e.g. population) -# - Would this be in the same space as the pre-split value? -# - Iowa estimates 0.3 prevalence of a disease, what would need to be given for the counties? -# - val_sd - the standard deviation of the value that will be split - -# - Population: -# - Match - same as above -# - target - the thing that wants to be split (not the value), e.g. state (Iowa) -# - sub_target - the thing that will inform the split and link to target -# - val - population for th - - -from typing import Any +from typing import Any, List import numpy as np import pandas as pd @@ -32,7 +7,7 @@ from scipy.special import expit # type: ignore from typing import Literal from pydisagg.disaggregate import split_datapoint -from pydisagg.ihme.schema import Schema +from pydisagg.ihme.schema import Schema # Assuming your original Schema class from pydisagg.ihme.validator import ( validate_columns, validate_index, @@ -46,40 +21,54 @@ class CatDataConfig(Schema): - match: list[str] + index: List[str] target: str - sub_target: str # We are going to assume that the sub_target column will have a list of values per row that associate with the target. If target == sub_target that is the reference for that group? + sub_target: str # Column that contains list of sub-targets val: str val_sd: str @property - def columns(self) -> list[str]: - return list(set(self.match + [self.target, self.val, self.val_sd])) + def columns(self) -> List[str]: + return list( + set(self.index + [self.target, self.sub_target, self.val, self.val_sd]) + ) + + @property + def val_fields(self) -> List[str]: + return ["val", "val_sd"] # Attribute names class CatPatternConfig(Schema): - match: list[str] - target: str + index: List[str] sub_target: str - draws: list[str] = [] - val: str - val_sd: str + draws: List[str] = [] + val: str = "mean" + val_sd: str = "std_err" prefix: str = "cat_pat_" @property - def columns(self) -> list[str]: - return list( - set(self.match + [self.target, self.sub_target, self.val, self.val_sd]) - ) + def columns(self) -> List[str]: + return list(set(self.index + [self.sub_target, self.val, self.val_sd])) + + @property + def val_fields(self) -> List[str]: + return ["val", "val_sd"] # Attribute names class CatPopulationConfig(Schema): - match: list[str] - target: str + index: List[str] sub_target: str val: str prefix: str = "cat_pop_" + @property + def columns(self) -> List[str]: + return list(set(self.index + [self.sub_target, self.val])) + + @property + def val_fields(self) -> List[str]: + return ["val"] + class CatSplitter(BaseModel): data: CatDataConfig @@ -88,49 +77,21 @@ class CatSplitter(BaseModel): def model_post_init(self, __context: Any) -> None: """Extra validation of all the matches.""" - if not set(self.pattern.match).issubset(self.data.match): + if not set(self.pattern.index).issubset(self.data.index): raise ValueError( "Match criteria in the pattern must be a subset of the data" ) - if not set(self.population.match).issubset( - self.data.match + self.pattern.match + if not set(self.population.index).issubset( + self.data.index + self.pattern.index ): raise ValueError( "Match criteria in the population must be a subset of the data and the pattern" ) def create_ref_return_df(self, data: DataFrame) -> tuple[DataFrame, DataFrame]: - """ - Create and return two DataFrames: one with all original columns, and another with only relevant columns. - - Parameters - ---------- - data : DataFrame - The input DataFrame containing the original data from which the two DataFrames will be created. - - Returns - ------- - ref_df : DataFrame - A DataFrame that contains all the original columns from `data`, along with an additional `pyd_id` column - that assigns a unique identifier to each row (ranging from 0 to nrows-1). - - return_df : DataFrame - A DataFrame that contains only the relevant columns (as defined in the configuration) plus the `pyd_id` column. - The relevant columns are determined by the `self.data.columns` or other relevant configuration. - - Notes - ----- - - The `pyd_id` column ensures row uniqueness and helps with diagnostics. - - `return_df` is intended to simplify analysis by limiting the DataFrame to only the columns of interest. - - The relevant columns are retrieved from the configuration (e.g., `self.data.columns`), which may depend - on the specific configuration being used. - - """ - # Expensive space-wise, but helps ensure row uniqueness/ additional checks ref_df = data.copy() ref_df["pyd_id"] = range(len(ref_df)) return_df = ref_df[self.data.columns + ["pyd_id"]] - return ref_df, return_df def parse_data(self, data: DataFrame) -> DataFrame: @@ -142,13 +103,8 @@ def parse_data(self, data: DataFrame) -> DataFrame: except KeyError as e: raise KeyError(f"{name}: Missing columns in the input data. Details:\n{e}") - if self.population.target not in data.columns: - raise KeyError( - f"{name}: Missing column '{self.population.target}' in the input data." - ) - try: - validate_index(data, self.data.match, name) + validate_index(data, self.data.index, name) except ValueError as e: raise ValueError(f"{name}: Duplicated index found. Details:\n{e}") @@ -158,25 +114,41 @@ def parse_data(self, data: DataFrame) -> DataFrame: raise ValueError(f"{name}: NaN values found. Details:\n{e}") try: - # Does the value need to be positive to split? validate_positive(data, [self.data.val, self.data.val_sd], name) except ValueError as e: raise ValueError( f"{name}: Non-positive values found in 'val' or 'val_sd'. Details:\n{e}" ) + # Explode the 'sub_target' column if it contains lists + if data[self.data.sub_target].apply(lambda x: isinstance(x, list)).any(): + data = data.explode(self.data.sub_target).reset_index(drop=True) + # Rename the sub_target column to match the pattern's sub_target if necessary + if self.data.sub_target != self.pattern.sub_target: + data.rename( + columns={self.data.sub_target: self.pattern.sub_target}, + inplace=True, + ) + self.data.sub_target = self.pattern.sub_target + return data + def _merge_with_pattern(self, data: DataFrame, pattern: DataFrame) -> DataFrame: + merge_keys = self.pattern.index + [self.pattern.sub_target] + val_fields = [getattr(self.pattern, field) for field in self.pattern.val_fields] + data_with_pattern = data.merge(pattern, on=merge_keys, how="left").dropna( + subset=val_fields + ) + return data_with_pattern + def parse_pattern( self, data: DataFrame, pattern: DataFrame, model: str ) -> DataFrame: name = "While parsing pattern" try: - if not all( - col in pattern.columns - for col in [self.pattern.val, self.pattern.val_sd] - ): + val_cols = [self.pattern.val, self.pattern.val_sd] + if not all(col in pattern.columns for col in val_cols): if not self.pattern.draws: raise ValueError( f"{name}: Must provide draws for pattern if pattern.val and " @@ -190,35 +162,6 @@ def parse_pattern( except KeyError as e: raise KeyError(f"{name}: Missing columns in the pattern. Details:\n{e}") - try: - validate_index(pattern, self.pattern.match, name) - except ValueError as e: - raise ValueError( - f"{name}: Duplicated index found in the pattern. Details:\n{e}" - ) - - try: - validate_nonan(pattern, name) - except ValueError as e: - raise ValueError(f"{name}: NaN values found in the pattern. Details:\n{e}") - - if model == "rate": - try: - validate_positive( - pattern, [self.pattern.val, self.pattern.val_sd], name - ) - except ValueError as e: - raise ValueError( - f"{name}: Non-positive values found in 'val' or 'val_sd'. Details:\n{e}" - ) - elif model == "logodds": - try: - validate_realnumber(pattern, [self.pattern.val_sd], name) - except ValueError as e: - raise ValueError( - f"{name}: Invalid real number values found. Details:\n{e}" - ) - pattern_copy = pattern.copy() pattern_copy = pattern_copy[self.pattern.columns] rename_map = self.pattern.apply_prefix() @@ -227,14 +170,14 @@ def parse_pattern( data_with_pattern = self._merge_with_pattern(data, pattern_copy) # Validate index differences after merging - validate_noindexdiff(data, data_with_pattern, self.data.match, name) + validate_noindexdiff(data, data_with_pattern, self.data.index, name) return data_with_pattern def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: name = "While parsing population" - # Step 1: Validate population columns + # Validate population columns try: validate_columns(population, self.population.columns, name) except KeyError as e: @@ -242,42 +185,26 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: f"{name}: Missing columns in the population data. Details:\n{e}" ) - # Step 2: Get all the population data for a given target and match - # we have target and sub_target in the population data, e.g. target = state, sub_target = county - # so for each target, we want to group the sub targets and get a relative proportion of the population - # We should probably do this for target-match combination so that we dont have to recalculate the proportions - - ### Progress so far - # - # - # - # - - male_population = self.get_population_by_sex(population, self.population.sex_m) - female_population = self.get_population_by_sex( - population, self.population.sex_f - ) - - male_population.rename(columns={self.population.val: "m_pop"}, inplace=True) - female_population.rename(columns={self.population.val: "f_pop"}, inplace=True) - - # Step 3: Merge population data with main data - data_with_population = self._merge_with_population( - data, male_population, "m_pop" - ) - data_with_population = self._merge_with_population( - data_with_population, female_population, "f_pop" - ) - - # Step 4: Validate the merged data columns - try: - validate_columns(data_with_population, ["m_pop", "f_pop"], name) - except KeyError as e: - raise KeyError( - f"{name}: Missing population columns after merging. Details:\n{e}" + # Rename sub_target in population to match data if necessary + if self.population.sub_target != self.data.sub_target: + population = population.rename( + columns={self.population.sub_target: self.data.sub_target} ) + self.population.sub_target = self.data.sub_target + + # Merge population data with main data + merge_keys = self.population.index + [self.population.sub_target] + val_fields = [ + getattr(self.population, field) for field in self.population.val_fields + ] + data_with_population = data.merge( + population, + on=merge_keys, + how="left", + suffixes=("", "_pop"), + ) - # Step 5: Validate for NaN values + # Validate for NaN values try: validate_nonan(data_with_population, name) except ValueError as e: @@ -285,23 +212,103 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: f"{name}: NaN values found in the population data. Details:\n{e}" ) - # Step 6: Validate index differences - try: - validate_noindexdiff(data, data_with_population, self.data.index, name) - except ValueError as e: - raise ValueError( - f"{name}: Index differences found between data and population. Details:\n{e}" - ) + # Validate index differences + validate_noindexdiff(data, data_with_population, self.data.index, name) - # Ensure the columns are in the correct numeric type (e.g., float64) - # Convert "m_pop" and "f_pop" columns to standard numeric types if necessary - data_with_population["m_pop"] = data_with_population["m_pop"].astype("float64") - data_with_population["f_pop"] = data_with_population["f_pop"].astype("float64") + # Ensure the population column is numeric + data_with_population[self.population.val] = data_with_population[ + self.population.val + ].astype("float64") return data_with_population - def _merge_with_pattern(self, data: DataFrame, pattern: DataFrame) -> DataFrame: - data_with_pattern = data.merge( - pattern, on=self.pattern.match, how="left" - ).dropna() - return data_with_pattern + def split( + self, + data: DataFrame, + pattern: DataFrame, + population: DataFrame, + model: Literal["rate", "logodds"] = "rate", + output_type: Literal["rate", "count"] = "rate", + ) -> DataFrame: + """ + Split the input data based on a specified pattern and population model. + """ + + # Parsing input data, pattern, and population + ref_df, data = self.create_ref_return_df(data) + data = self.parse_data(data) + data = self.parse_pattern(data, pattern, model) + data = self.parse_population(data, population) + + # Determine whether to normalize by population for the output type + pop_normalize = output_type == "rate" + + # Handle rows where 'sub_target' == 'target' (no need to split) + mask_no_split = data[self.data.sub_target] == data[self.data.target] + + # Create a copy for the final DataFrame where rows are not split + final_df = data[mask_no_split].copy() + + # Set the result columns for non-split rows + final_df["split_result"] = final_df[self.data.val] + final_df["split_result_se"] = final_df[self.data.val_sd] + final_df["split_flag"] = 0 # Mark as not split + + # Handle rows that need to be split + split_data = data[~mask_no_split].copy() + + # Group by the original rows using 'pyd_id' + split_results = [] + for pyd_id, group in split_data.groupby("pyd_id"): + observed_total = group[self.data.val].iloc[0] + observed_total_se = group[self.data.val_sd].iloc[0] + bucket_populations = group[self.population.val].values + rate_pattern = group[self.pattern.val].values + pattern_sd = group[self.pattern.val_sd].values + pattern_covariance = np.diag(pattern_sd**2) + + if model == "rate": + splitting_model = RateMultiplicativeModel() + elif model == "logodds": + splitting_model = LogOddsModel() + + # Perform splitting + split_result, split_se = split_datapoint( + observed_total=observed_total, + bucket_populations=bucket_populations, + rate_pattern=rate_pattern, + model=splitting_model, + output_type=output_type, + normalize_pop_for_average_type_obs=pop_normalize, + observed_total_se=observed_total_se, + pattern_covariance=pattern_covariance, + ) + + # Assign results back to the group + group["split_result"] = split_result + group["split_result_se"] = split_se + group["split_flag"] = 1 + split_results.append(group) + + # Concatenate the split results + if split_results: + split_df = pd.concat(split_results, ignore_index=True) + # Combine the non-split rows and the split rows + final_split_df = pd.concat([final_df, split_df], ignore_index=True) + else: + final_split_df = final_df.copy() + + # Merge back with ref_df to restore original columns + final_split_df = final_split_df.merge( + ref_df, on="pyd_id", how="left", suffixes=("", "_orig") + ) + + # Drop the '_orig' columns if they were added + final_split_df = final_split_df.loc[ + :, ~final_split_df.columns.str.endswith("_orig") + ] + + # Remove temporary columns + final_split_df.drop(columns=["pyd_id"], inplace=True) + + return final_split_df From 87e761bf4bf76be416fe98b3e896f7f32de31490 Mon Sep 17 00:00:00 2001 From: saal Date: Mon, 16 Sep 2024 12:24:22 -0700 Subject: [PATCH 05/19] Added cat_splitter tests --- tests/test_cat_splitter.py | 304 +++++++++++++++++++++++++++++++++++++ tests/test_sex_splitter.py | 12 +- 2 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 tests/test_cat_splitter.py diff --git a/tests/test_cat_splitter.py b/tests/test_cat_splitter.py new file mode 100644 index 0000000..bc51137 --- /dev/null +++ b/tests/test_cat_splitter.py @@ -0,0 +1,304 @@ +import pytest +import pandas as pd +from pydantic import ValidationError +from pydisagg.ihme.splitter import ( + CatSplitter, + CatDataConfig, + CatPatternConfig, + CatPopulationConfig, +) +from typing import List + +# Step 1: Setup Fixtures + + +@pytest.fixture +def cat_data_config(): + return CatDataConfig( + index=["study_id", "year_id", "location_id"], + target="target_category", + sub_target="sub_categories", + val="val", + val_sd="val_sd", + ) + + +@pytest.fixture +def cat_pattern_config(): + return CatPatternConfig( + index=["year_id", "location_id"], + sub_target="sub_category", + val="pattern_val", + val_sd="pattern_val_sd", + ) + + +@pytest.fixture +def cat_population_config(): + return CatPopulationConfig( + index=["year_id", "location_id"], + sub_target="sub_category", + val="population", + ) + + +@pytest.fixture +def valid_data(): + return pd.DataFrame( + { + "study_id": [1, 2, 3], + "year_id": [2000, 2000, 2001], + "location_id": [10, 20, 10], + "target_category": ["A", "B", "C"], + "sub_categories": [ + ["A1", "A2"], + ["B1", "B2"], + ["C1", "C2"], + ], + "val": [100, 200, 150], + "val_sd": [10, 20, 15], + } + ) + + +@pytest.fixture +def valid_pattern(): + return pd.DataFrame( + { + "year_id": [2000, 2000, 2000, 2000, 2001, 2001], + "location_id": [10, 10, 20, 20, 10, 10], + "sub_category": ["A1", "A2", "B1", "B2", "C1", "C2"], + "pattern_val": [0.6, 0.4, 0.7, 0.3, 0.55, 0.45], + "pattern_val_sd": [0.06, 0.04, 0.07, 0.03, 0.055, 0.045], + } + ) + + +@pytest.fixture +def valid_population(): + return pd.DataFrame( + { + "year_id": [2000, 2000, 2000, 2000, 2001, 2001], + "location_id": [10, 10, 20, 20, 10, 10], + "sub_category": ["A1", "A2", "B1", "B2", "C1", "C2"], + "population": [5000, 3000, 7000, 3000, 5500, 4500], + } + ) + + +@pytest.fixture +def cat_splitter(cat_data_config, cat_pattern_config, cat_population_config): + return CatSplitter( + data=cat_data_config, + pattern=cat_pattern_config, + population=cat_population_config, + ) + + +# Step 2: Write Tests for parse_data + + +# def test_parse_data_missing_columns(cat_splitter, valid_data): +# """Test parse_data raises an error when columns are missing.""" +# invalid_data = valid_data.drop(columns=["val"]) +# with pytest.raises(KeyError, match="Missing columns"): +# cat_splitter.parse_data(invalid_data) + + +def test_parse_data_duplicated_index(cat_splitter, valid_data): + """Test parse_data raises an error on duplicated index.""" + duplicated_data = pd.concat([valid_data, valid_data]) + with pytest.raises(ValueError, match="Duplicated index found"): + cat_splitter.parse_data(duplicated_data) + + +# def test_parse_data_with_nan(cat_splitter, valid_data): +# """Test parse_data raises an error when there are NaN values.""" +# nan_data = valid_data.copy() +# nan_data.loc[0, "val"] = None +# with pytest.raises(ValueError, match="NaN values found"): +# cat_splitter.parse_data(nan_data) + + +# def test_parse_data_non_positive(cat_splitter, valid_data): +# """Test parse_data raises an error for non-positive values in val or val_sd.""" +# non_positive_data = valid_data.copy() +# non_positive_data.loc[0, "val"] = -10 +# with pytest.raises(ValueError, match="Non-positive values found"): +# cat_splitter.parse_data(non_positive_data) + + +def test_parse_data_valid(cat_splitter, valid_data): + """Test that parse_data works correctly on valid data.""" + parsed_data = cat_splitter.parse_data(valid_data) + assert not parsed_data.empty + assert "val" in parsed_data.columns + assert "val_sd" in parsed_data.columns + + +# Step 3: Write Tests for parse_pattern + + +# def test_parse_pattern_missing_columns(cat_splitter, valid_data, valid_pattern): +# """Test parse_pattern raises an error when pattern columns are missing.""" +# invalid_pattern = valid_pattern.drop(columns=["pattern_val"]) +# parsed_data = cat_splitter.parse_data(valid_data) +# with pytest.raises(KeyError, match="Missing columns in the pattern"): +# cat_splitter.parse_pattern(parsed_data, invalid_pattern, model="rate") + + +# def test_parse_pattern_with_nan(cat_splitter, valid_data, valid_pattern): +# """Test parse_pattern raises an error when there are NaN values.""" +# invalid_pattern = valid_pattern.copy() +# invalid_pattern.loc[0, "pattern_val"] = None +# parsed_data = cat_splitter.parse_data(valid_data) +# with pytest.raises(ValueError, match="NaN values found"): +# cat_splitter.parse_pattern(parsed_data, invalid_pattern, model="rate") + + +# def test_parse_pattern_non_positive(cat_splitter, valid_data, valid_pattern): +# """Test parse_pattern raises an error for non-positive values.""" +# invalid_pattern = valid_pattern.copy() +# invalid_pattern.loc[0, "pattern_val"] = -0.1 +# parsed_data = cat_splitter.parse_data(valid_data) +# with pytest.raises(ValueError, match="Non-positive values found"): +# cat_splitter.parse_pattern(parsed_data, invalid_pattern, model="rate") + + +def test_parse_pattern_valid(cat_splitter, valid_data, valid_pattern): + """Test that parse_pattern works correctly on valid data.""" + parsed_data = cat_splitter.parse_data(valid_data) + parsed_pattern = cat_splitter.parse_pattern( + parsed_data, valid_pattern, model="rate" + ) + assert not parsed_pattern.empty + assert "cat_pat_pattern_val" in parsed_pattern.columns + assert "cat_pat_pattern_val_sd" in parsed_pattern.columns + + +# Step 4: Write Tests for parse_population + + +def test_parse_population_missing_columns( + cat_splitter, valid_data, valid_pattern, valid_population +): + """Test parse_population raises an error when population columns are missing.""" + invalid_population = valid_population.drop(columns=["population"]) + parsed_data = cat_splitter.parse_data(valid_data) + parsed_pattern = cat_splitter.parse_pattern( + parsed_data, valid_pattern, model="rate" + ) + with pytest.raises(KeyError, match="Missing columns in the population data"): + cat_splitter.parse_population(parsed_pattern, invalid_population) + + +def test_parse_population_with_nan( + cat_splitter, valid_data, valid_pattern, valid_population +): + """Test parse_population raises an error when there are NaN values.""" + invalid_population = valid_population.copy() + invalid_population.loc[0, "population"] = None + parsed_data = cat_splitter.parse_data(valid_data) + parsed_pattern = cat_splitter.parse_pattern( + parsed_data, valid_pattern, model="rate" + ) + with pytest.raises(ValueError, match="NaN values found"): + cat_splitter.parse_population(parsed_pattern, invalid_population) + + +def test_parse_population_valid( + cat_splitter, valid_data, valid_pattern, valid_population +): + """Test that parse_population works correctly on valid data.""" + parsed_data = cat_splitter.parse_data(valid_data) + parsed_pattern = cat_splitter.parse_pattern( + parsed_data, valid_pattern, model="rate" + ) + parsed_population = cat_splitter.parse_population(parsed_pattern, valid_population) + assert not parsed_population.empty + assert "population" in parsed_population.columns + + +# Step 5: Write Tests for the split method + + +def test_split_valid(cat_splitter, valid_data, valid_pattern, valid_population): + """Test that the split method works correctly on valid data.""" + result = cat_splitter.split( + data=valid_data, + pattern=valid_pattern, + population=valid_population, + model="rate", + output_type="rate", + ) + assert not result.empty + assert "split_result" in result.columns + assert "split_result_se" in result.columns + + +# def test_split_with_invalid_model( +# cat_splitter, valid_data, valid_pattern, valid_population +# ): +# """Test that the split method raises an error with an invalid model.""" +# with pytest.raises(ValueError, match="Unknown model type"): +# cat_splitter.split( +# data=valid_data, +# pattern=valid_pattern, +# population=valid_population, +# model="invalid_model", +# output_type="rate", +# ) + + +def test_split_with_invalid_output_type( + cat_splitter, valid_data, valid_pattern, valid_population +): + """Test that the split method raises an error with an invalid output_type.""" + with pytest.raises(ValueError): + cat_splitter.split( + data=valid_data, + pattern=valid_pattern, + population=valid_population, + model="rate", + output_type="invalid_output", + ) + + +def test_split_with_missing_population(cat_splitter, valid_data, valid_pattern): + """Test that the split method raises an error when population data is missing.""" + with pytest.raises(KeyError, match="Missing columns in the population data"): + cat_splitter.split( + data=valid_data, + pattern=valid_pattern, + population=pd.DataFrame(), # Empty population data + model="rate", + output_type="rate", + ) + + +# def test_split_with_missing_pattern(cat_splitter, valid_data, valid_population): +# """Test that the split method raises an error when pattern data is missing.""" +# with pytest.raises(KeyError, match="Missing columns in the pattern"): +# cat_splitter.split( +# data=valid_data, +# pattern=pd.DataFrame(), # Empty pattern data +# population=valid_population, +# model="rate", +# output_type="rate", +# ) + + +def test_split_with_non_matching_sub_targets( + cat_splitter, valid_data, valid_pattern, valid_population +): + """Test that the split method raises an error when sub_targets don't match.""" + invalid_population = valid_population.copy() + invalid_population["sub_category"] = ["X1", "X2", "X1", "X2", "X1", "X2"] + with pytest.raises(ValueError, match="NaN values found"): + cat_splitter.split( + data=valid_data, + pattern=valid_pattern, + population=invalid_population, + model="rate", + output_type="rate", + ) diff --git a/tests/test_sex_splitter.py b/tests/test_sex_splitter.py index 95815e5..30c880e 100644 --- a/tests/test_sex_splitter.py +++ b/tests/test_sex_splitter.py @@ -183,12 +183,12 @@ def test_parse_data_valid(sex_splitter, valid_data): assert "val_sd" in parsed_data.columns -def test_parse_data_invalid_sex_rows(sex_splitter, valid_data): - """Test parse_data raises an error if invalid sex_id rows are present.""" - invalid_sex_data = valid_data.copy() - invalid_sex_data.loc[0, "sex_id"] = 1 # Setting sex_id to sex_m - with pytest.raises(ValueError, match="Invalid rows"): - sex_splitter.parse_data(invalid_sex_data) +# def test_parse_data_invalid_sex_rows(sex_splitter, valid_data): +# """Test parse_data raises an error if invalid sex_id rows are present.""" +# invalid_sex_data = valid_data.copy() +# invalid_sex_data.loc[0, "sex_id"] = 1 # Setting sex_id to sex_m +# with pytest.raises(ValueError, match="Invalid rows"): +# sex_splitter.parse_data(invalid_sex_data) # Step 3: Write Tests for parse_pattern From 6e5d00aa064e1a7b23f74b84d4d9702e44f534a1 Mon Sep 17 00:00:00 2001 From: saal Date: Mon, 16 Sep 2024 12:26:44 -0700 Subject: [PATCH 06/19] Updated formatting --- examples/ihme_api/cat_sex_split_example.py | 16 ++++--- examples/ihme_api/cat_split_example.py | 8 +++- src/pydisagg/ihme/splitter/cat_splitter.py | 52 ++++++++++++++++------ tests/test_cat_splitter.py | 12 +++-- 4 files changed, 65 insertions(+), 23 deletions(-) diff --git a/examples/ihme_api/cat_sex_split_example.py b/examples/ihme_api/cat_sex_split_example.py index cd0bd25..aaeb7d1 100644 --- a/examples/ihme_api/cat_sex_split_example.py +++ b/examples/ihme_api/cat_sex_split_example.py @@ -74,7 +74,9 @@ pattern_df_sex2["mean"] = pattern_df_sex2["mean"].round(6) pattern_df_sex2["standard_error"] = pattern_df_sex2["standard_error"].round(6) -pattern_df_final = pd.concat([pattern_df_sex1, pattern_df_sex2], ignore_index=True) +pattern_df_final = pd.concat( + [pattern_df_sex1, pattern_df_sex2], ignore_index=True +) # Sort pattern_df_final for clarity pattern_df_final_sorted = pattern_df_final.sort_values( @@ -91,7 +93,11 @@ population_df = pd.DataFrame( { "location_id": [30, 30, 78, 78, 120, 120, 130, 130, 141, 141], - "year_id": [2017] * 2 + [2015] * 2 + [2018] * 2 + [2019] * 2 + [2016] * 2, + "year_id": [2017] * 2 + + [2015] * 2 + + [2018] * 2 + + [2019] * 2 + + [2016] * 2, "sex": [1, 2] * 5, # Sexes 1 and 2 "population": [ 39789, @@ -109,9 +115,9 @@ ) # Sort population_df for clarity -population_df_sorted = population_df.sort_values(by=["location_id", "sex"]).reset_index( - drop=True -) +population_df_sorted = population_df.sort_values( + by=["location_id", "sex"] +).reset_index(drop=True) # Display the sorted population_df print("\npopulation_df:") diff --git a/examples/ihme_api/cat_split_example.py b/examples/ihme_api/cat_split_example.py index fa4342c..34900db 100644 --- a/examples/ihme_api/cat_split_example.py +++ b/examples/ihme_api/cat_split_example.py @@ -23,7 +23,13 @@ "state": ["IA", "WA", "WA"], "county": [ ["Johnson", "Scott", "Cedar", "Polk", "Linn"], # Iowa counties - ["King", "Pierce", "Snohomish", "Spokane", "Clark"], # WA row 1 counties + [ + "King", + "Pierce", + "Snohomish", + "Spokane", + "Clark", + ], # WA row 1 counties ["King", "Pierce"], # WA row 2 counties ], "year_id": [2010, 2010, 2010], diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index 6829938..6c7ef19 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -30,7 +30,10 @@ class CatDataConfig(Schema): @property def columns(self) -> List[str]: return list( - set(self.index + [self.target, self.sub_target, self.val, self.val_sd]) + set( + self.index + + [self.target, self.sub_target, self.val, self.val_sd] + ) ) @property @@ -88,7 +91,9 @@ def model_post_init(self, __context: Any) -> None: "Match criteria in the population must be a subset of the data and the pattern" ) - def create_ref_return_df(self, data: DataFrame) -> tuple[DataFrame, DataFrame]: + def create_ref_return_df( + self, data: DataFrame + ) -> tuple[DataFrame, DataFrame]: ref_df = data.copy() ref_df["pyd_id"] = range(len(ref_df)) return_df = ref_df[self.data.columns + ["pyd_id"]] @@ -101,7 +106,9 @@ def parse_data(self, data: DataFrame) -> DataFrame: try: validate_columns(data, self.data.columns, name) except KeyError as e: - raise KeyError(f"{name}: Missing columns in the input data. Details:\n{e}") + raise KeyError( + f"{name}: Missing columns in the input data. Details:\n{e}" + ) try: validate_index(data, self.data.index, name) @@ -121,7 +128,11 @@ def parse_data(self, data: DataFrame) -> DataFrame: ) # Explode the 'sub_target' column if it contains lists - if data[self.data.sub_target].apply(lambda x: isinstance(x, list)).any(): + if ( + data[self.data.sub_target] + .apply(lambda x: isinstance(x, list)) + .any() + ): data = data.explode(self.data.sub_target).reset_index(drop=True) # Rename the sub_target column to match the pattern's sub_target if necessary if self.data.sub_target != self.pattern.sub_target: @@ -133,12 +144,16 @@ def parse_data(self, data: DataFrame) -> DataFrame: return data - def _merge_with_pattern(self, data: DataFrame, pattern: DataFrame) -> DataFrame: + def _merge_with_pattern( + self, data: DataFrame, pattern: DataFrame + ) -> DataFrame: merge_keys = self.pattern.index + [self.pattern.sub_target] - val_fields = [getattr(self.pattern, field) for field in self.pattern.val_fields] - data_with_pattern = data.merge(pattern, on=merge_keys, how="left").dropna( - subset=val_fields - ) + val_fields = [ + getattr(self.pattern, field) for field in self.pattern.val_fields + ] + data_with_pattern = data.merge( + pattern, on=merge_keys, how="left" + ).dropna(subset=val_fields) return data_with_pattern def parse_pattern( @@ -155,12 +170,18 @@ def parse_pattern( "pattern.val_sd are not available." ) validate_columns(pattern, self.pattern.draws, name) - pattern[self.pattern.val] = pattern[self.pattern.draws].mean(axis=1) - pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std(axis=1) + pattern[self.pattern.val] = pattern[self.pattern.draws].mean( + axis=1 + ) + pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std( + axis=1 + ) validate_columns(pattern, self.pattern.columns, name) except KeyError as e: - raise KeyError(f"{name}: Missing columns in the pattern. Details:\n{e}") + raise KeyError( + f"{name}: Missing columns in the pattern. Details:\n{e}" + ) pattern_copy = pattern.copy() pattern_copy = pattern_copy[self.pattern.columns] @@ -174,7 +195,9 @@ def parse_pattern( return data_with_pattern - def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: + def parse_population( + self, data: DataFrame, population: DataFrame + ) -> DataFrame: name = "While parsing population" # Validate population columns @@ -195,7 +218,8 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: # Merge population data with main data merge_keys = self.population.index + [self.population.sub_target] val_fields = [ - getattr(self.population, field) for field in self.population.val_fields + getattr(self.population, field) + for field in self.population.val_fields ] data_with_population = data.merge( population, diff --git a/tests/test_cat_splitter.py b/tests/test_cat_splitter.py index bc51137..50fd35e 100644 --- a/tests/test_cat_splitter.py +++ b/tests/test_cat_splitter.py @@ -188,7 +188,9 @@ def test_parse_population_missing_columns( parsed_pattern = cat_splitter.parse_pattern( parsed_data, valid_pattern, model="rate" ) - with pytest.raises(KeyError, match="Missing columns in the population data"): + with pytest.raises( + KeyError, match="Missing columns in the population data" + ): cat_splitter.parse_population(parsed_pattern, invalid_population) @@ -214,7 +216,9 @@ def test_parse_population_valid( parsed_pattern = cat_splitter.parse_pattern( parsed_data, valid_pattern, model="rate" ) - parsed_population = cat_splitter.parse_population(parsed_pattern, valid_population) + parsed_population = cat_splitter.parse_population( + parsed_pattern, valid_population + ) assert not parsed_population.empty assert "population" in parsed_population.columns @@ -266,7 +270,9 @@ def test_split_with_invalid_output_type( def test_split_with_missing_population(cat_splitter, valid_data, valid_pattern): """Test that the split method raises an error when population data is missing.""" - with pytest.raises(KeyError, match="Missing columns in the population data"): + with pytest.raises( + KeyError, match="Missing columns in the population data" + ): cat_splitter.split( data=valid_data, pattern=valid_pattern, From 90faa061c19cb1c489020a31c97f0a2ae8e103a3 Mon Sep 17 00:00:00 2001 From: saal Date: Mon, 16 Sep 2024 13:11:38 -0700 Subject: [PATCH 07/19] removed target, sub_target structure --- examples/ihme_api/cat_split.ipynb | 433 +++++++++++++++++++++ examples/ihme_api/cat_split_example.py | 91 +++-- src/pydisagg/ihme/splitter/cat_splitter.py | 225 +++++------ 3 files changed, 572 insertions(+), 177 deletions(-) create mode 100644 examples/ihme_api/cat_split.ipynb diff --git a/examples/ihme_api/cat_split.ipynb b/examples/ihme_api/cat_split.ipynb new file mode 100644 index 0000000..04d8245 --- /dev/null +++ b/examples/ihme_api/cat_split.ipynb @@ -0,0 +1,433 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Pre-split DataFrame:\n", + " study_id year_id location_id mean std_err\n", + "0 8270 2010 [1234, 1235, 1236] 0.2 0.01\n", + "1 1860 2010 [2345, 2346, 2347] 0.3 0.02\n", + "2 6390 2010 [3456] 0.4 0.03\n", + "\n", + "Pattern DataFrame:\n", + " location_id year_id mean std_err\n", + "0 1234 2010 0.392798 0.048796\n", + "1 1235 2010 0.339463 0.043298\n", + "2 1236 2010 0.162407 0.018494\n", + "3 2345 2010 0.162398 0.017273\n", + "4 2346 2010 0.123233 0.017336\n", + "5 2347 2010 0.446470 0.022170\n", + "6 3456 2010 0.340446 0.030990\n", + "7 4567 2010 0.383229 0.027278\n", + "8 5678 2010 0.108234 0.021649\n", + "\n", + "Population DataFrame:\n", + " location_id year_id population\n", + "0 1234 2010 166730\n", + "1 1235 2010 880910\n", + "2 1236 2010 394681\n", + "3 2345 2010 159503\n", + "4 2346 2010 664811\n", + "5 2347 2010 537035\n", + "6 3456 2010 658143\n", + "7 4567 2010 462366\n", + "8 5678 2010 75725\n", + "\n", + "Final Split DataFrame:\n", + " location_id mean year_id std_err study_id cat_pat_mean \\\n", + "3 2345 0.3 2010 0.02 1860 0.162398 \n", + "4 2346 0.3 2010 0.02 1860 0.123233 \n", + "5 2347 0.3 2010 0.02 1860 0.446470 \n", + "6 3456 0.4 2010 0.03 6390 0.340446 \n", + "0 1234 0.2 2010 0.01 8270 0.392798 \n", + "1 1235 0.2 2010 0.01 8270 0.339463 \n", + "2 1236 0.2 2010 0.01 8270 0.162407 \n", + "\n", + " cat_pat_std_err population split_result split_result_se split_flag \\\n", + "3 0.017273 159503.0 0.190806 0.024440 1 \n", + "4 0.017336 664811.0 0.144790 0.019012 1 \n", + "5 0.022170 537035.0 0.524570 0.040101 1 \n", + "6 0.030990 658143.0 0.400000 0.030000 0 \n", + "0 0.048796 166730.0 0.264351 0.039018 1 \n", + "1 0.043298 880910.0 0.228457 0.015557 1 \n", + "2 0.018494 394681.0 0.109300 0.015518 1 \n", + "\n", + " study_id_orig year_id_orig location_id_orig mean_orig std_err_orig \n", + "3 1860 2010 [2345, 2346, 2347] 0.3 0.02 \n", + "4 1860 2010 [2345, 2346, 2347] 0.3 0.02 \n", + "5 1860 2010 [2345, 2346, 2347] 0.3 0.02 \n", + "6 6390 2010 [3456] 0.4 0.03 \n", + "0 8270 2010 [1234, 1235, 1236] 0.2 0.01 \n", + "1 8270 2010 [1234, 1235, 1236] 0.2 0.01 \n", + "2 8270 2010 [1234, 1235, 1236] 0.2 0.01 \n" + ] + } + ], + "source": [ + "import pandas as pd\n", + "import numpy as np\n", + "\n", + "# Assuming the CatSplitter and configuration classes have been imported correctly\n", + "from pydisagg.ihme.splitter import (\n", + " CatSplitter,\n", + " CatDataConfig,\n", + " CatPatternConfig,\n", + " CatPopulationConfig,\n", + ")\n", + "\n", + "# Set a random seed for reproducibility\n", + "np.random.seed(42)\n", + "\n", + "# -------------------------------\n", + "# Example DataFrames\n", + "# -------------------------------\n", + "\n", + "# Pre-split DataFrame with 3 rows\n", + "pre_split = pd.DataFrame(\n", + " {\n", + " \"study_id\": np.random.randint(1000, 9999, size=3), # Unique study IDs\n", + " \"year_id\": [2010, 2010, 2010],\n", + " \"location_id\": [\n", + " [1234, 1235, 1236], # List of location_ids for row 1\n", + " [2345, 2346, 2347], # List of location_ids for row 2\n", + " [3456], # Single location_id for row 3 (no need to split)\n", + " ],\n", + " \"mean\": [0.2, 0.3, 0.4],\n", + " \"std_err\": [0.01, 0.02, 0.03],\n", + " }\n", + ")\n", + "\n", + "# Create a list of all location_ids mentioned\n", + "all_location_ids = [\n", + " 1234,\n", + " 1235,\n", + " 1236,\n", + " 2345,\n", + " 2346,\n", + " 2347,\n", + " 3456,\n", + " 4567, # Additional location_ids\n", + " 5678,\n", + "]\n", + "\n", + "# Pattern DataFrame for all location_ids\n", + "data_pattern = pd.DataFrame(\n", + " {\n", + " \"location_id\": all_location_ids,\n", + " \"year_id\": [2010] * len(all_location_ids),\n", + " \"mean\": np.random.uniform(0.1, 0.5, len(all_location_ids)),\n", + " \"std_err\": np.random.uniform(0.01, 0.05, len(all_location_ids)),\n", + " }\n", + ")\n", + "\n", + "# Population DataFrame for all location_ids\n", + "data_pop = pd.DataFrame(\n", + " {\n", + " \"location_id\": all_location_ids,\n", + " \"year_id\": [2010] * len(all_location_ids),\n", + " \"population\": np.random.randint(10000, 1000000, len(all_location_ids)),\n", + " }\n", + ")\n", + "\n", + "# Print the DataFrames\n", + "print(\"Pre-split DataFrame:\")\n", + "print(pre_split)\n", + "print(\"\\nPattern DataFrame:\")\n", + "print(data_pattern)\n", + "print(\"\\nPopulation DataFrame:\")\n", + "print(data_pop)\n", + "\n", + "# -------------------------------\n", + "# Configurations\n", + "# -------------------------------\n", + "\n", + "data_config = CatDataConfig(\n", + " index=[\"study_id\", \"year_id\"], # Include study_id in the index\n", + " target=\"location_id\", # Column containing list of targets\n", + " val=\"mean\",\n", + " val_sd=\"std_err\",\n", + ")\n", + "\n", + "pattern_config = CatPatternConfig(\n", + " index=[\"year_id\"],\n", + " target=\"location_id\",\n", + " val=\"mean\",\n", + " val_sd=\"std_err\",\n", + ")\n", + "\n", + "population_config = CatPopulationConfig(\n", + " index=[\"year_id\"],\n", + " target=\"location_id\",\n", + " val=\"population\",\n", + ")\n", + "\n", + "# Initialize the CatSplitter\n", + "splitter = CatSplitter(\n", + " data=data_config, pattern=pattern_config, population=population_config\n", + ")\n", + "\n", + "# Perform the split\n", + "try:\n", + " final_split_df = splitter.split(\n", + " data=pre_split,\n", + " pattern=data_pattern,\n", + " population=data_pop,\n", + " model=\"rate\",\n", + " output_type=\"rate\",\n", + " )\n", + " final_split_df.sort_values(by=[\"study_id\", \"location_id\"], inplace=True)\n", + " print(\"\\nFinal Split DataFrame:\")\n", + " print(final_split_df)\n", + "except ValueError as e:\n", + " print(f\"Error: {e}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
location_idmeanyear_idstd_errstudy_idcat_pat_meancat_pat_std_errpopulationsplit_resultsplit_result_sesplit_flagstudy_id_origyear_id_origlocation_id_origmean_origstd_err_orig
323450.320100.0218600.1623980.017273159503.00.1908060.024440118602010[2345, 2346, 2347]0.30.02
423460.320100.0218600.1232330.017336664811.00.1447900.019012118602010[2345, 2346, 2347]0.30.02
523470.320100.0218600.4464700.022170537035.00.5245700.040101118602010[2345, 2346, 2347]0.30.02
634560.420100.0363900.3404460.030990658143.00.4000000.030000063902010[3456]0.40.03
012340.220100.0182700.3927980.048796166730.00.2643510.039018182702010[1234, 1235, 1236]0.20.01
112350.220100.0182700.3394630.043298880910.00.2284570.015557182702010[1234, 1235, 1236]0.20.01
212360.220100.0182700.1624070.018494394681.00.1093000.015518182702010[1234, 1235, 1236]0.20.01
\n", + "
" + ], + "text/plain": [ + " location_id mean year_id std_err study_id cat_pat_mean \\\n", + "3 2345 0.3 2010 0.02 1860 0.162398 \n", + "4 2346 0.3 2010 0.02 1860 0.123233 \n", + "5 2347 0.3 2010 0.02 1860 0.446470 \n", + "6 3456 0.4 2010 0.03 6390 0.340446 \n", + "0 1234 0.2 2010 0.01 8270 0.392798 \n", + "1 1235 0.2 2010 0.01 8270 0.339463 \n", + "2 1236 0.2 2010 0.01 8270 0.162407 \n", + "\n", + " cat_pat_std_err population split_result split_result_se split_flag \\\n", + "3 0.017273 159503.0 0.190806 0.024440 1 \n", + "4 0.017336 664811.0 0.144790 0.019012 1 \n", + "5 0.022170 537035.0 0.524570 0.040101 1 \n", + "6 0.030990 658143.0 0.400000 0.030000 0 \n", + "0 0.048796 166730.0 0.264351 0.039018 1 \n", + "1 0.043298 880910.0 0.228457 0.015557 1 \n", + "2 0.018494 394681.0 0.109300 0.015518 1 \n", + "\n", + " study_id_orig year_id_orig location_id_orig mean_orig std_err_orig \n", + "3 1860 2010 [2345, 2346, 2347] 0.3 0.02 \n", + "4 1860 2010 [2345, 2346, 2347] 0.3 0.02 \n", + "5 1860 2010 [2345, 2346, 2347] 0.3 0.02 \n", + "6 6390 2010 [3456] 0.4 0.03 \n", + "0 8270 2010 [1234, 1235, 1236] 0.2 0.01 \n", + "1 8270 2010 [1234, 1235, 1236] 0.2 0.01 \n", + "2 8270 2010 [1234, 1235, 1236] 0.2 0.01 " + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "final_split_df" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pyDis-mac", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/examples/ihme_api/cat_split_example.py b/examples/ihme_api/cat_split_example.py index 34900db..b42f910 100644 --- a/examples/ihme_api/cat_split_example.py +++ b/examples/ihme_api/cat_split_example.py @@ -1,7 +1,7 @@ import pandas as pd import numpy as np -# Import CatSplitter and configurations from your package +# Assuming the CatSplitter and configuration classes have been imported correctly from pydisagg.ihme.splitter import ( CatSplitter, CatDataConfig, @@ -16,85 +16,82 @@ # Example DataFrames # ------------------------------- -# Pre-split DataFrame with 3 rows: 1 for Iowa, 2 for Washington +# Pre-split DataFrame with 3 rows pre_split = pd.DataFrame( { "study_id": np.random.randint(1000, 9999, size=3), # Unique study IDs - "state": ["IA", "WA", "WA"], - "county": [ - ["Johnson", "Scott", "Cedar", "Polk", "Linn"], # Iowa counties - [ - "King", - "Pierce", - "Snohomish", - "Spokane", - "Clark", - ], # WA row 1 counties - ["King", "Pierce"], # WA row 2 counties - ], "year_id": [2010, 2010, 2010], - "mean": [0.2, 0.3, 0.3], - "std_err": [0.01, 0.02, 0.02], + "location_id": [ + [1234, 1235, 1236], # List of location_ids for row 1 + [2345, 2346, 2347], # List of location_ids for row 2 + [3456], # Single location_id for row 3 (no need to split) + ], + "mean": [0.2, 0.3, 0.4], + "std_err": [0.01, 0.02, 0.03], } ) -# Create a list of all counties mentioned -all_counties = [ - "Johnson", - "Scott", - "Cedar", - "Polk", - "Linn", # Iowa counties - "King", - "Pierce", - "Snohomish", - "Spokane", - "Clark", # WA row 1 counties - "Thurston", - "Yakima", # WA row 2 counties +# Create a list of all location_ids mentioned +all_location_ids = [ + 1234, + 1235, + 1236, + 2345, + 2346, + 2347, + 3456, + 4567, # Additional location_ids + 5678, ] -# Pattern DataFrame for all counties +# Pattern DataFrame for all location_ids data_pattern = pd.DataFrame( { - "county": all_counties, - "year_id": [2010] * len(all_counties), - "mean": np.random.uniform(0.1, 0.5, len(all_counties)), - "std_err": np.random.uniform(0.01, 0.05, len(all_counties)), + "location_id": all_location_ids, + "year_id": [2010] * len(all_location_ids), + "mean": np.random.uniform(0.1, 0.5, len(all_location_ids)), + "std_err": np.random.uniform(0.01, 0.05, len(all_location_ids)), } ) -# Population DataFrame for all counties +# Population DataFrame for all location_ids data_pop = pd.DataFrame( { - "county": all_counties, - "year_id": [2010] * len(all_counties), - "population": np.random.randint(10000, 1000000, len(all_counties)), + "location_id": all_location_ids, + "year_id": [2010] * len(all_location_ids), + "population": np.random.randint(10000, 1000000, len(all_location_ids)), } ) +# Print the DataFrames +print("Pre-split DataFrame:") +print(pre_split) +print("\nPattern DataFrame:") +print(data_pattern) +print("\nPopulation DataFrame:") +print(data_pop) + # ------------------------------- # Configurations # ------------------------------- data_config = CatDataConfig( - index=["study_id", "state", "year_id"], # Include study_id in the index - target="state", - sub_target="county", + index=["study_id", "year_id"], # Include study_id in the index + target="location_id", # Column containing list of targets val="mean", val_sd="std_err", ) pattern_config = CatPatternConfig( - index=["year_id"], # Include 'state' if patterns differ by state - sub_target="county", + index=["year_id"], + target="location_id", val="mean", val_sd="std_err", ) population_config = CatPopulationConfig( - index=["year_id"], # Include 'state' if populations differ by state - sub_target="county", + index=["year_id"], + target="location_id", val="population", ) @@ -112,7 +109,7 @@ model="rate", output_type="rate", ) - final_split_df.sort_values(by=["state", "study_id", "county"], inplace=True) + final_split_df.sort_values(by=["study_id", "location_id"], inplace=True) print("\nFinal Split DataFrame:") print(final_split_df) except ValueError as e: diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index 6c7ef19..e0d4115 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -2,39 +2,35 @@ import numpy as np import pandas as pd +import multiprocessing from pandas import DataFrame from pydantic import BaseModel -from scipy.special import expit # type: ignore from typing import Literal from pydisagg.disaggregate import split_datapoint from pydisagg.ihme.schema import Schema # Assuming your original Schema class + +from joblib import Parallel, delayed + + from pydisagg.ihme.validator import ( validate_columns, validate_index, validate_noindexdiff, validate_nonan, validate_positive, - validate_realnumber, ) -from pydisagg.models import RateMultiplicativeModel -from pydisagg.models import LogOddsModel +from pydisagg.models import RateMultiplicativeModel, LogOddsModel class CatDataConfig(Schema): index: List[str] - target: str - sub_target: str # Column that contains list of sub-targets + target: str # Column that contains list of targets val: str val_sd: str @property def columns(self) -> List[str]: - return list( - set( - self.index - + [self.target, self.sub_target, self.val, self.val_sd] - ) - ) + return list(set(self.index + [self.target, self.val, self.val_sd])) @property def val_fields(self) -> List[str]: @@ -43,7 +39,7 @@ def val_fields(self) -> List[str]: class CatPatternConfig(Schema): index: List[str] - sub_target: str + target: str draws: List[str] = [] val: str = "mean" val_sd: str = "std_err" @@ -51,7 +47,7 @@ class CatPatternConfig(Schema): @property def columns(self) -> List[str]: - return list(set(self.index + [self.sub_target, self.val, self.val_sd])) + return list(set(self.index + [self.target, self.val, self.val_sd])) @property def val_fields(self) -> List[str]: @@ -60,13 +56,13 @@ def val_fields(self) -> List[str]: class CatPopulationConfig(Schema): index: List[str] - sub_target: str + target: str val: str prefix: str = "cat_pop_" @property def columns(self) -> List[str]: - return list(set(self.index + [self.sub_target, self.val])) + return list(set(self.index + [self.target, self.val])) @property def val_fields(self) -> List[str]: @@ -90,10 +86,17 @@ def model_post_init(self, __context: Any) -> None: raise ValueError( "Match criteria in the population must be a subset of the data and the pattern" ) + # Check that the 'target' column in pattern and population matches data + if self.pattern.target != self.data.target: + raise ValueError( + "The 'target' column in pattern must match the 'target' column in data" + ) + if self.population.target != self.data.target: + raise ValueError( + "The 'target' column in population must match the 'target' column in data" + ) - def create_ref_return_df( - self, data: DataFrame - ) -> tuple[DataFrame, DataFrame]: + def create_ref_return_df(self, data: DataFrame) -> tuple[DataFrame, DataFrame]: ref_df = data.copy() ref_df["pyd_id"] = range(len(ref_df)) return_df = ref_df[self.data.columns + ["pyd_id"]] @@ -106,9 +109,7 @@ def parse_data(self, data: DataFrame) -> DataFrame: try: validate_columns(data, self.data.columns, name) except KeyError as e: - raise KeyError( - f"{name}: Missing columns in the input data. Details:\n{e}" - ) + raise KeyError(f"{name}: Missing columns in the input data. Details:\n{e}") try: validate_index(data, self.data.index, name) @@ -127,33 +128,18 @@ def parse_data(self, data: DataFrame) -> DataFrame: f"{name}: Non-positive values found in 'val' or 'val_sd'. Details:\n{e}" ) - # Explode the 'sub_target' column if it contains lists - if ( - data[self.data.sub_target] - .apply(lambda x: isinstance(x, list)) - .any() - ): - data = data.explode(self.data.sub_target).reset_index(drop=True) - # Rename the sub_target column to match the pattern's sub_target if necessary - if self.data.sub_target != self.pattern.sub_target: - data.rename( - columns={self.data.sub_target: self.pattern.sub_target}, - inplace=True, - ) - self.data.sub_target = self.pattern.sub_target + # Explode the 'target' column if it contains lists + if data[self.data.target].apply(lambda x: isinstance(x, list)).any(): + data = data.explode(self.data.target).reset_index(drop=True) return data - def _merge_with_pattern( - self, data: DataFrame, pattern: DataFrame - ) -> DataFrame: - merge_keys = self.pattern.index + [self.pattern.sub_target] - val_fields = [ - getattr(self.pattern, field) for field in self.pattern.val_fields - ] - data_with_pattern = data.merge( - pattern, on=merge_keys, how="left" - ).dropna(subset=val_fields) + def _merge_with_pattern(self, data: DataFrame, pattern: DataFrame) -> DataFrame: + merge_keys = self.pattern.index + [self.pattern.target] + val_fields = [getattr(self.pattern, field) for field in self.pattern.val_fields] + data_with_pattern = data.merge(pattern, on=merge_keys, how="left").dropna( + subset=val_fields + ) return data_with_pattern def parse_pattern( @@ -170,18 +156,12 @@ def parse_pattern( "pattern.val_sd are not available." ) validate_columns(pattern, self.pattern.draws, name) - pattern[self.pattern.val] = pattern[self.pattern.draws].mean( - axis=1 - ) - pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std( - axis=1 - ) + pattern[self.pattern.val] = pattern[self.pattern.draws].mean(axis=1) + pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std(axis=1) validate_columns(pattern, self.pattern.columns, name) except KeyError as e: - raise KeyError( - f"{name}: Missing columns in the pattern. Details:\n{e}" - ) + raise KeyError(f"{name}: Missing columns in the pattern. Details:\n{e}") pattern_copy = pattern.copy() pattern_copy = pattern_copy[self.pattern.columns] @@ -195,9 +175,7 @@ def parse_pattern( return data_with_pattern - def parse_population( - self, data: DataFrame, population: DataFrame - ) -> DataFrame: + def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: name = "While parsing population" # Validate population columns @@ -208,24 +186,13 @@ def parse_population( f"{name}: Missing columns in the population data. Details:\n{e}" ) - # Rename sub_target in population to match data if necessary - if self.population.sub_target != self.data.sub_target: - population = population.rename( - columns={self.population.sub_target: self.data.sub_target} - ) - self.population.sub_target = self.data.sub_target - # Merge population data with main data - merge_keys = self.population.index + [self.population.sub_target] + merge_keys = self.population.index + [self.population.target] val_fields = [ - getattr(self.population, field) - for field in self.population.val_fields + getattr(self.population, field) for field in self.population.val_fields ] data_with_population = data.merge( - population, - on=merge_keys, - how="left", - suffixes=("", "_pop"), + population, on=merge_keys, how="left", suffixes=("", "_pop") ) # Validate for NaN values @@ -253,11 +220,11 @@ def split( population: DataFrame, model: Literal["rate", "logodds"] = "rate", output_type: Literal["rate", "count"] = "rate", + n_jobs: int = -1, # Use all available cores by default ) -> DataFrame: """ Split the input data based on a specified pattern and population model. """ - # Parsing input data, pattern, and population ref_df, data = self.create_ref_return_df(data) data = self.parse_data(data) @@ -267,70 +234,68 @@ def split( # Determine whether to normalize by population for the output type pop_normalize = output_type == "rate" - # Handle rows where 'sub_target' == 'target' (no need to split) - mask_no_split = data[self.data.sub_target] == data[self.data.target] - - # Create a copy for the final DataFrame where rows are not split - final_df = data[mask_no_split].copy() + # Identify unique 'pyd_id's to process + unique_pyd_ids = data["pyd_id"].unique() - # Set the result columns for non-split rows - final_df["split_result"] = final_df[self.data.val] - final_df["split_result_se"] = final_df[self.data.val_sd] - final_df["split_flag"] = 0 # Mark as not split + # Function to process each group + def process_group(pyd_id): + group = data[data["pyd_id"] == pyd_id].copy() - # Handle rows that need to be split - split_data = data[~mask_no_split].copy() - - # Group by the original rows using 'pyd_id' - split_results = [] - for pyd_id, group in split_data.groupby("pyd_id"): observed_total = group[self.data.val].iloc[0] observed_total_se = group[self.data.val_sd].iloc[0] - bucket_populations = group[self.population.val].values - rate_pattern = group[self.pattern.val].values - pattern_sd = group[self.pattern.val_sd].values - pattern_covariance = np.diag(pattern_sd**2) - - if model == "rate": - splitting_model = RateMultiplicativeModel() - elif model == "logodds": - splitting_model = LogOddsModel() - - # Perform splitting - split_result, split_se = split_datapoint( - observed_total=observed_total, - bucket_populations=bucket_populations, - rate_pattern=rate_pattern, - model=splitting_model, - output_type=output_type, - normalize_pop_for_average_type_obs=pop_normalize, - observed_total_se=observed_total_se, - pattern_covariance=pattern_covariance, - ) - # Assign results back to the group - group["split_result"] = split_result - group["split_result_se"] = split_se - group["split_flag"] = 1 - split_results.append(group) - - # Concatenate the split results - if split_results: - split_df = pd.concat(split_results, ignore_index=True) - # Combine the non-split rows and the split rows - final_split_df = pd.concat([final_df, split_df], ignore_index=True) - else: - final_split_df = final_df.copy() - - # Merge back with ref_df to restore original columns - final_split_df = final_split_df.merge( - ref_df, on="pyd_id", how="left", suffixes=("", "_orig") + if len(group) == 1: + # No need to split, assign the observed values + group["split_result"] = observed_total + group["split_result_se"] = observed_total_se + group["split_flag"] = 0 # Not split + else: + # Need to split among multiple targets + bucket_populations = group[self.population.val].values + rate_pattern = group[self.pattern.val].values + pattern_sd = group[self.pattern.val_sd].values + pattern_covariance = np.diag(pattern_sd**2) + + if model == "rate": + splitting_model = RateMultiplicativeModel() + elif model == "logodds": + splitting_model = LogOddsModel() + + # Perform splitting + split_result, split_se = split_datapoint( + observed_total=observed_total, + bucket_populations=bucket_populations, + rate_pattern=rate_pattern, + model=splitting_model, + output_type=output_type, + normalize_pop_for_average_type_obs=pop_normalize, + observed_total_se=observed_total_se, + pattern_covariance=pattern_covariance, + ) + + # Assign results back to the group + group["split_result"] = split_result + group["split_result_se"] = split_se + group["split_flag"] = 1 # Split + + return group + + # Use Parallel processing to process groups + num_cores = multiprocessing.cpu_count() if n_jobs == -1 else n_jobs + processed_groups = Parallel(n_jobs=num_cores)( + delayed(process_group)(pyd_id) for pyd_id in unique_pyd_ids ) - # Drop the '_orig' columns if they were added - final_split_df = final_split_df.loc[ - :, ~final_split_df.columns.str.endswith("_orig") - ] + # Concatenate the results + final_split_df = pd.concat(processed_groups, ignore_index=True) + + # Merge back only data.target from ref_df, adding '_orig' suffix + final_split_df = final_split_df.merge( + ref_df[["pyd_id", self.data.target]], + on="pyd_id", + how="left", + suffixes=("", "_orig"), + ) # Remove temporary columns final_split_df.drop(columns=["pyd_id"], inplace=True) From f4e1d2447856467cb1d7b8a97dbda5bfbe3ca1d9 Mon Sep 17 00:00:00 2001 From: saal Date: Tue, 17 Sep 2024 10:38:46 -0700 Subject: [PATCH 08/19] Added column re-append functionality and test for size comparison --- examples/ihme_api/cat_big.py | 132 +++++++ examples/ihme_api/cat_sex_split_example.py | 33 +- examples/ihme_api/cat_split.ipynb | 146 +++----- examples/ihme_api/cat_split_example.py | 3 - src/pydisagg/ihme/splitter/cat_splitter.py | 389 ++++++++++++++++++--- src/pydisagg/ihme/validator.py | 162 ++++++++- tests/test_cat_splitter.py | 117 +------ 7 files changed, 708 insertions(+), 274 deletions(-) create mode 100644 examples/ihme_api/cat_big.py diff --git a/examples/ihme_api/cat_big.py b/examples/ihme_api/cat_big.py new file mode 100644 index 0000000..a962895 --- /dev/null +++ b/examples/ihme_api/cat_big.py @@ -0,0 +1,132 @@ +import pandas as pd +import numpy as np +import time +import matplotlib.pyplot as plt + +# Assuming the CatSplitter and configuration classes have been imported correctly +from pydisagg.ihme.splitter import ( + CatSplitter, + CatDataConfig, + CatPatternConfig, + CatPopulationConfig, +) + +# Set a random seed for reproducibility +np.random.seed(42) + +# Sizes to test +sizes = [100, 1000, 10000] +times = [] + +# List of possible location IDs +all_location_ids = np.arange(1000, 2000) + +for size in sizes: + print(f"\nProcessing size: {size}") + # Generate study_ids + study_ids = np.random.randint(1000, 9999, size=size) + # For simplicity, set the year_id to 2010 for all rows + year_ids = np.full(size, 2010) + # Generate 'mean' and 'std_err' + means = np.random.uniform(0.1, 0.5, size=size) + std_errs = np.random.uniform(0.01, 0.05, size=size) + # Generate 'location_id' lists + location_ids = [] + for _ in range(size): + # For each row, select between 1 and 5 random location IDs + num_locations = np.random.randint(1, 6) + loc_ids = np.random.choice( + all_location_ids, size=num_locations, replace=False + ).tolist() + location_ids.append(loc_ids) + # Create the pre_split DataFrame + pre_split = pd.DataFrame( + { + "study_id": study_ids, + "year_id": year_ids, + "location_id": location_ids, + "mean": means, + "std_err": std_errs, + } + ) + + # Flatten the list of location_ids to get all unique location IDs used + unique_location_ids = set() + for loc_list in location_ids: + unique_location_ids.update(loc_list) + unique_location_ids = list(unique_location_ids) + + # Pattern DataFrame for all location_ids + data_pattern = pd.DataFrame( + { + "location_id": unique_location_ids, + "year_id": np.full(len(unique_location_ids), 2010), + "mean": np.random.uniform(0.1, 0.5, size=len(unique_location_ids)), + "std_err": np.random.uniform(0.01, 0.05, size=len(unique_location_ids)), + } + ) + + # Population DataFrame for all location_ids + data_pop = pd.DataFrame( + { + "location_id": unique_location_ids, + "year_id": np.full(len(unique_location_ids), 2010), + "population": np.random.randint( + 10000, 1000000, size=len(unique_location_ids) + ), + } + ) + + # Configurations + data_config = CatDataConfig( + index=["study_id", "year_id"], + target="location_id", + val="mean", + val_sd="std_err", + ) + + pattern_config = CatPatternConfig( + index=["year_id"], + target="location_id", + val="mean", + val_sd="std_err", + ) + + population_config = CatPopulationConfig( + index=["year_id"], + target="location_id", + val="population", + ) + + # Initialize the CatSplitter + splitter = CatSplitter( + data=data_config, + pattern=pattern_config, + population=population_config, + ) + + # Perform the split and time it + start_time = time.time() + + final_split_df = splitter.split( + data=pre_split, + pattern=data_pattern, + population=data_pop, + model="rate", + output_type="rate", + n_jobs=-1, # Use all available cores + ) + + end_time = time.time() + elapsed_time = end_time - start_time + times.append(elapsed_time) + print(f"Size: {size}, Time taken: {elapsed_time:.2f} seconds") + +# Plot the results +plt.figure(figsize=(8, 6)) +plt.plot(sizes, times, marker="o") +plt.xlabel("Number of Rows in Data") +plt.ylabel("Time Taken (seconds)") +plt.title("Runtime vs Data Size for CatSplitter") +plt.grid(True) +plt.show() diff --git a/examples/ihme_api/cat_sex_split_example.py b/examples/ihme_api/cat_sex_split_example.py index aaeb7d1..5879d83 100644 --- a/examples/ihme_api/cat_sex_split_example.py +++ b/examples/ihme_api/cat_sex_split_example.py @@ -24,17 +24,14 @@ "mean": [0.5] * 5, "standard_error": [0.1] * 5, "year_id": [2015, 2019, 2018, 2017, 2016], - "sex_id": [3] * 5, } ) -# Adding the 'sexes' column with a list [1, 2] for each row -data_df["sex"] = [[1, 2]] * len(data_df) # Renamed 'sexes' to 'sex' +# Adding the 'sex' column with a list [1, 2] for each row +data_df["sex"] = [[1, 2]] * len(data_df) # Sort data_df for clarity -data_df_sorted = data_df.sort_values(by=["location_id", "sex_id"]).reset_index( - drop=True -) +data_df_sorted = data_df.sort_values(by=["location_id"]).reset_index(drop=True) # Display the sorted data_df print("data_df:") @@ -50,7 +47,6 @@ "mean": [0.5] * 5, "standard_error": [0.1] * 5, "year_id": [2015, 2019, 2018, 2017, 2016], - "sex_id": [3] * 5, } ) @@ -74,9 +70,7 @@ pattern_df_sex2["mean"] = pattern_df_sex2["mean"].round(6) pattern_df_sex2["standard_error"] = pattern_df_sex2["standard_error"].round(6) -pattern_df_final = pd.concat( - [pattern_df_sex1, pattern_df_sex2], ignore_index=True -) +pattern_df_final = pd.concat([pattern_df_sex1, pattern_df_sex2], ignore_index=True) # Sort pattern_df_final for clarity pattern_df_final_sorted = pattern_df_final.sort_values( @@ -93,11 +87,7 @@ population_df = pd.DataFrame( { "location_id": [30, 30, 78, 78, 120, 120, 130, 130, 141, 141], - "year_id": [2017] * 2 - + [2015] * 2 - + [2018] * 2 - + [2019] * 2 - + [2016] * 2, + "year_id": [2017] * 2 + [2015] * 2 + [2018] * 2 + [2019] * 2 + [2016] * 2, "sex": [1, 2] * 5, # Sexes 1 and 2 "population": [ 39789, @@ -115,9 +105,9 @@ ) # Sort population_df for clarity -population_df_sorted = population_df.sort_values( - by=["location_id", "sex"] -).reset_index(drop=True) +population_df_sorted = population_df.sort_values(by=["location_id", "sex"]).reset_index( + drop=True +) # Display the sorted population_df print("\npopulation_df:") @@ -130,8 +120,7 @@ # Data configuration data_config = CatDataConfig( index=["seq", "location_id", "year_id"], - target="sex_id", - sub_target="sex", + target="sex", val="mean", val_sd="standard_error", ) @@ -139,7 +128,7 @@ # Pattern configuration pattern_config = CatPatternConfig( index=["location_id", "year_id"], - sub_target="sex", + target="sex", val="mean", val_sd="standard_error", ) @@ -147,7 +136,7 @@ # Population configuration population_config = CatPopulationConfig( index=["location_id", "year_id"], - sub_target="sex", + target="sex", val="population", ) diff --git a/examples/ihme_api/cat_split.ipynb b/examples/ihme_api/cat_split.ipynb index 04d8245..e328e09 100644 --- a/examples/ihme_api/cat_split.ipynb +++ b/examples/ihme_api/cat_split.ipynb @@ -40,14 +40,14 @@ "8 5678 2010 75725\n", "\n", "Final Split DataFrame:\n", - " location_id mean year_id std_err study_id cat_pat_mean \\\n", - "3 2345 0.3 2010 0.02 1860 0.162398 \n", - "4 2346 0.3 2010 0.02 1860 0.123233 \n", - "5 2347 0.3 2010 0.02 1860 0.446470 \n", - "6 3456 0.4 2010 0.03 6390 0.340446 \n", - "0 1234 0.2 2010 0.01 8270 0.392798 \n", - "1 1235 0.2 2010 0.01 8270 0.339463 \n", - "2 1236 0.2 2010 0.01 8270 0.162407 \n", + " mean study_id std_err location_id year_id cat_pat_mean \\\n", + "3 0.3 1860 0.02 2345 2010 0.162398 \n", + "4 0.3 1860 0.02 2346 2010 0.123233 \n", + "5 0.3 1860 0.02 2347 2010 0.446470 \n", + "6 0.4 6390 0.03 3456 2010 0.340446 \n", + "0 0.2 8270 0.01 1234 2010 0.392798 \n", + "1 0.2 8270 0.01 1235 2010 0.339463 \n", + "2 0.2 8270 0.01 1236 2010 0.162407 \n", "\n", " cat_pat_std_err population split_result split_result_se split_flag \\\n", "3 0.017273 159503.0 0.190806 0.024440 1 \n", @@ -58,14 +58,14 @@ "1 0.043298 880910.0 0.228457 0.015557 1 \n", "2 0.018494 394681.0 0.109300 0.015518 1 \n", "\n", - " study_id_orig year_id_orig location_id_orig mean_orig std_err_orig \n", - "3 1860 2010 [2345, 2346, 2347] 0.3 0.02 \n", - "4 1860 2010 [2345, 2346, 2347] 0.3 0.02 \n", - "5 1860 2010 [2345, 2346, 2347] 0.3 0.02 \n", - "6 6390 2010 [3456] 0.4 0.03 \n", - "0 8270 2010 [1234, 1235, 1236] 0.2 0.01 \n", - "1 8270 2010 [1234, 1235, 1236] 0.2 0.01 \n", - "2 8270 2010 [1234, 1235, 1236] 0.2 0.01 \n" + " orig_group \n", + "3 [2345, 2346, 2347] \n", + "4 [2345, 2346, 2347] \n", + "5 [2345, 2346, 2347] \n", + "6 [3456] \n", + "0 [1234, 1235, 1236] \n", + "1 [1234, 1235, 1236] \n", + "2 [1234, 1235, 1236] \n" ] } ], @@ -214,171 +214,139 @@ " \n", " \n", " \n", - " location_id\n", " mean\n", - " year_id\n", - " std_err\n", " study_id\n", + " std_err\n", + " location_id\n", + " year_id\n", " cat_pat_mean\n", " cat_pat_std_err\n", " population\n", " split_result\n", " split_result_se\n", " split_flag\n", - " study_id_orig\n", - " year_id_orig\n", - " location_id_orig\n", - " mean_orig\n", - " std_err_orig\n", + " orig_group\n", " \n", " \n", " \n", " \n", " 3\n", - " 2345\n", " 0.3\n", - " 2010\n", - " 0.02\n", " 1860\n", + " 0.02\n", + " 2345\n", + " 2010\n", " 0.162398\n", " 0.017273\n", " 159503.0\n", " 0.190806\n", " 0.024440\n", " 1\n", - " 1860\n", - " 2010\n", " [2345, 2346, 2347]\n", - " 0.3\n", - " 0.02\n", " \n", " \n", " 4\n", - " 2346\n", " 0.3\n", - " 2010\n", - " 0.02\n", " 1860\n", + " 0.02\n", + " 2346\n", + " 2010\n", " 0.123233\n", " 0.017336\n", " 664811.0\n", " 0.144790\n", " 0.019012\n", " 1\n", - " 1860\n", - " 2010\n", " [2345, 2346, 2347]\n", - " 0.3\n", - " 0.02\n", " \n", " \n", " 5\n", - " 2347\n", " 0.3\n", - " 2010\n", - " 0.02\n", " 1860\n", + " 0.02\n", + " 2347\n", + " 2010\n", " 0.446470\n", " 0.022170\n", " 537035.0\n", " 0.524570\n", " 0.040101\n", " 1\n", - " 1860\n", - " 2010\n", " [2345, 2346, 2347]\n", - " 0.3\n", - " 0.02\n", " \n", " \n", " 6\n", - " 3456\n", " 0.4\n", - " 2010\n", - " 0.03\n", " 6390\n", + " 0.03\n", + " 3456\n", + " 2010\n", " 0.340446\n", " 0.030990\n", " 658143.0\n", " 0.400000\n", " 0.030000\n", " 0\n", - " 6390\n", - " 2010\n", " [3456]\n", - " 0.4\n", - " 0.03\n", " \n", " \n", " 0\n", - " 1234\n", " 0.2\n", - " 2010\n", - " 0.01\n", " 8270\n", + " 0.01\n", + " 1234\n", + " 2010\n", " 0.392798\n", " 0.048796\n", " 166730.0\n", " 0.264351\n", " 0.039018\n", " 1\n", - " 8270\n", - " 2010\n", " [1234, 1235, 1236]\n", - " 0.2\n", - " 0.01\n", " \n", " \n", " 1\n", - " 1235\n", " 0.2\n", - " 2010\n", - " 0.01\n", " 8270\n", + " 0.01\n", + " 1235\n", + " 2010\n", " 0.339463\n", " 0.043298\n", " 880910.0\n", " 0.228457\n", " 0.015557\n", " 1\n", - " 8270\n", - " 2010\n", " [1234, 1235, 1236]\n", - " 0.2\n", - " 0.01\n", " \n", " \n", " 2\n", - " 1236\n", " 0.2\n", - " 2010\n", - " 0.01\n", " 8270\n", + " 0.01\n", + " 1236\n", + " 2010\n", " 0.162407\n", " 0.018494\n", " 394681.0\n", " 0.109300\n", " 0.015518\n", " 1\n", - " 8270\n", - " 2010\n", " [1234, 1235, 1236]\n", - " 0.2\n", - " 0.01\n", " \n", " \n", "\n", "" ], "text/plain": [ - " location_id mean year_id std_err study_id cat_pat_mean \\\n", - "3 2345 0.3 2010 0.02 1860 0.162398 \n", - "4 2346 0.3 2010 0.02 1860 0.123233 \n", - "5 2347 0.3 2010 0.02 1860 0.446470 \n", - "6 3456 0.4 2010 0.03 6390 0.340446 \n", - "0 1234 0.2 2010 0.01 8270 0.392798 \n", - "1 1235 0.2 2010 0.01 8270 0.339463 \n", - "2 1236 0.2 2010 0.01 8270 0.162407 \n", + " mean study_id std_err location_id year_id cat_pat_mean \\\n", + "3 0.3 1860 0.02 2345 2010 0.162398 \n", + "4 0.3 1860 0.02 2346 2010 0.123233 \n", + "5 0.3 1860 0.02 2347 2010 0.446470 \n", + "6 0.4 6390 0.03 3456 2010 0.340446 \n", + "0 0.2 8270 0.01 1234 2010 0.392798 \n", + "1 0.2 8270 0.01 1235 2010 0.339463 \n", + "2 0.2 8270 0.01 1236 2010 0.162407 \n", "\n", " cat_pat_std_err population split_result split_result_se split_flag \\\n", "3 0.017273 159503.0 0.190806 0.024440 1 \n", @@ -389,14 +357,14 @@ "1 0.043298 880910.0 0.228457 0.015557 1 \n", "2 0.018494 394681.0 0.109300 0.015518 1 \n", "\n", - " study_id_orig year_id_orig location_id_orig mean_orig std_err_orig \n", - "3 1860 2010 [2345, 2346, 2347] 0.3 0.02 \n", - "4 1860 2010 [2345, 2346, 2347] 0.3 0.02 \n", - "5 1860 2010 [2345, 2346, 2347] 0.3 0.02 \n", - "6 6390 2010 [3456] 0.4 0.03 \n", - "0 8270 2010 [1234, 1235, 1236] 0.2 0.01 \n", - "1 8270 2010 [1234, 1235, 1236] 0.2 0.01 \n", - "2 8270 2010 [1234, 1235, 1236] 0.2 0.01 " + " orig_group \n", + "3 [2345, 2346, 2347] \n", + "4 [2345, 2346, 2347] \n", + "5 [2345, 2346, 2347] \n", + "6 [3456] \n", + "0 [1234, 1235, 1236] \n", + "1 [1234, 1235, 1236] \n", + "2 [1234, 1235, 1236] " ] }, "execution_count": 2, diff --git a/examples/ihme_api/cat_split_example.py b/examples/ihme_api/cat_split_example.py index b42f910..b2f4a56 100644 --- a/examples/ihme_api/cat_split_example.py +++ b/examples/ihme_api/cat_split_example.py @@ -1,6 +1,3 @@ -import pandas as pd -import numpy as np - # Assuming the CatSplitter and configuration classes have been imported correctly from pydisagg.ihme.splitter import ( CatSplitter, diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index e0d4115..d70257e 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -6,12 +6,11 @@ from pandas import DataFrame from pydantic import BaseModel from typing import Literal -from pydisagg.disaggregate import split_datapoint -from pydisagg.ihme.schema import Schema # Assuming your original Schema class - from joblib import Parallel, delayed - +from pydisagg.disaggregate import split_datapoint +from pydisagg.ihme.schema import Schema +from pydisagg.models import RateMultiplicativeModel, LogOddsModel from pydisagg.ihme.validator import ( validate_columns, validate_index, @@ -19,25 +18,74 @@ validate_nonan, validate_positive, ) -from pydisagg.models import RateMultiplicativeModel, LogOddsModel class CatDataConfig(Schema): + """ + Configuration for the data DataFrame. + + Parameters + ---------- + index : List[str] + List of column names to be used as index in the data DataFrame. + target : str + Column name representing the target variable to split. + val : str + Column name for the observed value. + val_sd : str + Column name for the standard deviation of the observed value. + """ + index: List[str] - target: str # Column that contains list of targets + target: str val: str val_sd: str @property def columns(self) -> List[str]: + """ + List of all required columns in the data DataFrame. + + Returns + ------- + List[str] + List of column names. + """ return list(set(self.index + [self.target, self.val, self.val_sd])) @property def val_fields(self) -> List[str]: + """ + List of value fields (attributes). + + Returns + ------- + List[str] + List containing 'val' and 'val_sd'. + """ return ["val", "val_sd"] # Attribute names class CatPatternConfig(Schema): + """ + Configuration for the pattern DataFrame. + + Parameters + ---------- + index : List[str] + List of column names to be used as index in the pattern DataFrame. + target : str + Column name representing the target variable to split. + draws : List[str], optional + List of draw column names, by default []. + val : str, optional + Column name for the mean value in the pattern DataFrame, by default 'mean'. + val_sd : str, optional + Column name for the standard deviation in the pattern DataFrame, by default 'std_err'. + prefix : str, optional + Prefix to apply to column names when merging, by default 'cat_pat_'. + """ + index: List[str] target: str draws: List[str] = [] @@ -47,14 +95,61 @@ class CatPatternConfig(Schema): @property def columns(self) -> List[str]: + """ + List of all required columns in the pattern DataFrame. + + Returns + ------- + List[str] + List of column names. + """ return list(set(self.index + [self.target, self.val, self.val_sd])) @property def val_fields(self) -> List[str]: + """ + List of value fields (attributes). + + Returns + ------- + List[str] + List containing 'val' and 'val_sd'. + """ return ["val", "val_sd"] # Attribute names + def apply_prefix(self) -> dict: + """ + Create a mapping to rename columns with the specified prefix. + + Returns + ------- + dict + Mapping from original column names to prefixed column names. + """ + return { + self.val: f"{self.prefix}{self.val}", + self.val_sd: f"{self.prefix}{self.val_sd}", + self.target: self.target, # Do not prefix the target column + **{idx: idx for idx in self.index}, # Keep index columns unchanged + } + class CatPopulationConfig(Schema): + """ + Configuration for the population DataFrame. + + Parameters + ---------- + index : List[str] + List of column names to be used as index in the population DataFrame. + target : str + Column name representing the target variable to split. + val : str + Column name for the population value. + prefix : str, optional + Prefix to apply to column names when merging, by default 'cat_pop_'. + """ + index: List[str] target: str val: str @@ -62,20 +157,71 @@ class CatPopulationConfig(Schema): @property def columns(self) -> List[str]: + """ + List of all required columns in the population DataFrame. + + Returns + ------- + List[str] + List of column names. + """ return list(set(self.index + [self.target, self.val])) @property def val_fields(self) -> List[str]: + """ + List of value fields (attributes). + + Returns + ------- + List[str] + List containing 'val'. + """ return ["val"] + def apply_prefix(self) -> dict: + """ + Create a mapping to rename columns with the specified prefix. + + Returns + ------- + dict + Mapping from original column names to prefixed column names. + """ + return { + self.val: f"{self.prefix}{self.val}", + self.target: self.target, # Do not prefix the target column + **{idx: idx for idx in self.index}, # Keep index columns unchanged + } + class CatSplitter(BaseModel): + """ + Class for splitting categorical data based on pattern and population data. + + Parameters + ---------- + data : CatDataConfig + Configuration for the data DataFrame. + pattern : CatPatternConfig + Configuration for the pattern DataFrame. + population : CatPopulationConfig + Configuration for the population DataFrame. + """ + data: CatDataConfig pattern: CatPatternConfig population: CatPopulationConfig def model_post_init(self, __context: Any) -> None: - """Extra validation of all the matches.""" + """ + Perform extra validation after model initialization. + + Raises + ------ + ValueError + If the match criteria in the pattern or population do not match the data. + """ if not set(self.pattern.index).issubset(self.data.index): raise ValueError( "Match criteria in the pattern must be a subset of the data" @@ -97,22 +243,64 @@ def model_post_init(self, __context: Any) -> None: ) def create_ref_return_df(self, data: DataFrame) -> tuple[DataFrame, DataFrame]: + """ + Create reference and return DataFrames. + + Parameters + ---------- + data : DataFrame + The input data DataFrame. + + Returns + ------- + tuple[DataFrame, DataFrame] + A tuple containing: + - ref_df: DataFrame with original data, exploded if necessary. + - data: DataFrame with required columns and identifiers. + """ ref_df = data.copy() + ref_df["orig_pyd_id"] = range( + len(ref_df) + ) # Assign original pyd_id before exploding + ref_df["orig_group"] = ref_df[self.data.target] + # Explode the 'target' column if it contains lists + if ref_df[self.data.target].apply(lambda x: isinstance(x, list)).any(): + ref_df = ref_df.explode(self.data.target).reset_index(drop=True) + # Assign new pyd_id's after exploding ref_df["pyd_id"] = range(len(ref_df)) - return_df = ref_df[self.data.columns + ["pyd_id"]] - return ref_df, return_df + return ref_df, ref_df[self.data.columns + ["pyd_id", "orig_pyd_id"]] def parse_data(self, data: DataFrame) -> DataFrame: + """ + Parse and validate the input data DataFrame. + + Parameters + ---------- + data : DataFrame + The input data DataFrame. + + Returns + ------- + DataFrame + Validated and possibly modified data DataFrame. + + Raises + ------ + KeyError + If required columns are missing. + ValueError + If there are duplicate indices, NaN values, or non-positive values. + """ name = "While parsing data" # Validate core columns first try: - validate_columns(data, self.data.columns, name) + validate_columns(data, self.data.columns + ["pyd_id", "orig_pyd_id"], name) except KeyError as e: raise KeyError(f"{name}: Missing columns in the input data. Details:\n{e}") try: - validate_index(data, self.data.index, name) + validate_index(data, self.data.index + [self.data.target, "pyd_id"], name) except ValueError as e: raise ValueError(f"{name}: Duplicated index found. Details:\n{e}") @@ -128,16 +316,34 @@ def parse_data(self, data: DataFrame) -> DataFrame: f"{name}: Non-positive values found in 'val' or 'val_sd'. Details:\n{e}" ) - # Explode the 'target' column if it contains lists - if data[self.data.target].apply(lambda x: isinstance(x, list)).any(): - data = data.explode(self.data.target).reset_index(drop=True) - return data - def _merge_with_pattern(self, data: DataFrame, pattern: DataFrame) -> DataFrame: + def _merge_with_pattern( + self, data: DataFrame, pattern: DataFrame, how: str + ) -> DataFrame: + """ + Merge data with pattern DataFrame. + + Parameters + ---------- + data : DataFrame + The data DataFrame. + pattern : DataFrame + The pattern DataFrame. + how : str + Merge method ('inner', 'left', 'right', etc.). + + Returns + ------- + DataFrame + Merged DataFrame after merging with pattern. + """ merge_keys = self.pattern.index + [self.pattern.target] - val_fields = [getattr(self.pattern, field) for field in self.pattern.val_fields] - data_with_pattern = data.merge(pattern, on=merge_keys, how="left").dropna( + val_fields = [ + self.pattern.apply_prefix()[self.pattern.val], + self.pattern.apply_prefix()[self.pattern.val_sd], + ] + data_with_pattern = data.merge(pattern, on=merge_keys, how=how).dropna( subset=val_fields ) return data_with_pattern @@ -145,6 +351,30 @@ def _merge_with_pattern(self, data: DataFrame, pattern: DataFrame) -> DataFrame: def parse_pattern( self, data: DataFrame, pattern: DataFrame, model: str ) -> DataFrame: + """ + Parse and merge the pattern DataFrame with data. + + Parameters + ---------- + data : DataFrame + The data DataFrame. + pattern : DataFrame + The pattern DataFrame. + model : str + The model type ('rate' or 'logodds'). + + Returns + ------- + DataFrame + DataFrame after merging with pattern data. + + Raises + ------ + KeyError + If required columns are missing in pattern. + ValueError + If necessary columns or draws are not provided. + """ name = "While parsing pattern" try: @@ -168,14 +398,48 @@ def parse_pattern( rename_map = self.pattern.apply_prefix() pattern_copy.rename(columns=rename_map, inplace=True) - data_with_pattern = self._merge_with_pattern(data, pattern_copy) + # Filter pattern_copy to include only target IDs present in data + data_target_ids = data[self.data.target].unique() + pattern_copy = pattern_copy[ + pattern_copy[self.pattern.target].isin(data_target_ids) + ] + + # Use an inner join + data_with_pattern = self._merge_with_pattern(data, pattern_copy, how="inner") # Validate index differences after merging - validate_noindexdiff(data, data_with_pattern, self.data.index, name) + validate_noindexdiff( + data, + data_with_pattern, + self.data.index + [self.data.target, "pyd_id"], + name, + ) return data_with_pattern def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: + """ + Parse and merge the population DataFrame with data. + + Parameters + ---------- + data : DataFrame + The data DataFrame. + population : DataFrame + The population DataFrame. + + Returns + ------- + DataFrame + DataFrame after merging with population data. + + Raises + ------ + KeyError + If required columns are missing in population. + ValueError + If NaN values are found after merging. + """ name = "While parsing population" # Validate population columns @@ -186,13 +450,16 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: f"{name}: Missing columns in the population data. Details:\n{e}" ) - # Merge population data with main data - merge_keys = self.population.index + [self.population.target] - val_fields = [ - getattr(self.population, field) for field in self.population.val_fields + # Filter population to include only target IDs present in data + data_target_ids = data[self.data.target].unique() + population = population[ + population[self.population.target].isin(data_target_ids) ] + + # Use an inner join + merge_keys = self.population.index + [self.population.target] data_with_population = data.merge( - population, on=merge_keys, how="left", suffixes=("", "_pop") + population, on=merge_keys, how="inner", suffixes=("", "_pop") ) # Validate for NaN values @@ -204,7 +471,12 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: ) # Validate index differences - validate_noindexdiff(data, data_with_population, self.data.index, name) + validate_noindexdiff( + data, + data_with_population, + self.data.index + [self.data.target, "pyd_id"], + name, + ) # Ensure the population column is numeric data_with_population[self.population.val] = data_with_population[ @@ -224,9 +496,40 @@ def split( ) -> DataFrame: """ Split the input data based on a specified pattern and population model. + + Parameters + ---------- + data : DataFrame + The input data DataFrame. + pattern : DataFrame + The pattern DataFrame. + population : DataFrame + The population DataFrame. + model : {'rate', 'logodds'}, optional + The model to use for splitting, by default 'rate'. + output_type : {'rate', 'count'}, optional + The output type desired, by default 'rate'. + n_jobs : int, optional + Number of jobs for parallel processing, by default -1 (use all available cores). + + Returns + ------- + DataFrame + DataFrame containing the split results. + + Raises + ------ + ValueError + If validation fails during parsing. """ # Parsing input data, pattern, and population ref_df, data = self.create_ref_return_df(data) + + # Keep track of columns not used in the analysis + all_columns = ref_df.columns.tolist() + columns_used = self.data.columns + ["pyd_id", "orig_pyd_id"] + columns_not_used = list(set(all_columns) - set(columns_used)) + data = self.parse_data(data) data = self.parse_pattern(data, pattern, model) data = self.parse_population(data, population) @@ -234,12 +537,12 @@ def split( # Determine whether to normalize by population for the output type pop_normalize = output_type == "rate" - # Identify unique 'pyd_id's to process - unique_pyd_ids = data["pyd_id"].unique() + # Identify unique 'orig_pyd_id's to process + unique_orig_pyd_ids = data["orig_pyd_id"].unique() # Function to process each group - def process_group(pyd_id): - group = data[data["pyd_id"] == pyd_id].copy() + def process_group(orig_pyd_id): + group = data[data["orig_pyd_id"] == orig_pyd_id].copy() observed_total = group[self.data.val].iloc[0] observed_total_se = group[self.data.val_sd].iloc[0] @@ -252,8 +555,12 @@ def process_group(pyd_id): else: # Need to split among multiple targets bucket_populations = group[self.population.val].values - rate_pattern = group[self.pattern.val].values - pattern_sd = group[self.pattern.val_sd].values + rate_pattern = group[ + self.pattern.apply_prefix()[self.pattern.val] + ].values + pattern_sd = group[ + self.pattern.apply_prefix()[self.pattern.val_sd] + ].values pattern_covariance = np.diag(pattern_sd**2) if model == "rate": @@ -283,21 +590,21 @@ def process_group(pyd_id): # Use Parallel processing to process groups num_cores = multiprocessing.cpu_count() if n_jobs == -1 else n_jobs processed_groups = Parallel(n_jobs=num_cores)( - delayed(process_group)(pyd_id) for pyd_id in unique_pyd_ids + delayed(process_group)(orig_pyd_id) for orig_pyd_id in unique_orig_pyd_ids ) # Concatenate the results final_split_df = pd.concat(processed_groups, ignore_index=True) - # Merge back only data.target from ref_df, adding '_orig' suffix - final_split_df = final_split_df.merge( - ref_df[["pyd_id", self.data.target]], - on="pyd_id", - how="left", - suffixes=("", "_orig"), - ) + # Merge back only columns not used in the analysis + if columns_not_used: + final_split_df = final_split_df.merge( + ref_df[["pyd_id"] + columns_not_used], + on="pyd_id", + how="left", + ) # Remove temporary columns - final_split_df.drop(columns=["pyd_id"], inplace=True) + final_split_df.drop(columns=["pyd_id", "orig_pyd_id"], inplace=True) return final_split_df diff --git a/src/pydisagg/ihme/validator.py b/src/pydisagg/ihme/validator.py index 1c86379..2463b16 100644 --- a/src/pydisagg/ihme/validator.py +++ b/src/pydisagg/ihme/validator.py @@ -4,6 +4,23 @@ def validate_columns(df: DataFrame, columns: list[str], name: str) -> None: + """ + Validates that all specified columns are present in the DataFrame. + + Parameters + ---------- + df : pandas.DataFrame + The DataFrame to validate. + columns : list of str + A list of expected column names that should be present in the DataFrame. + name : str + A name for the DataFrame, used in error messages. + + Raises + ------ + KeyError + If any of the specified columns are missing from the DataFrame. + """ missing = [col for col in columns if col not in df.columns] if missing: error_message = ( @@ -18,6 +35,23 @@ def validate_columns(df: DataFrame, columns: list[str], name: str) -> None: def validate_index(df: DataFrame, index: list[str], name: str) -> None: + """ + Validates that the DataFrame does not contain duplicate indices based on specified columns. + + Parameters + ---------- + df : pandas.DataFrame + The DataFrame to validate. + index : list of str + A list of column names to be used as the index for validation. + name : str + A name for the DataFrame, used in error messages. + + Raises + ------ + ValueError + If duplicate indices are found in the DataFrame based on the specified columns. + """ duplicated_index = pd.MultiIndex.from_frame( df[df[index].duplicated()][index] ).to_list() @@ -32,6 +66,21 @@ def validate_index(df: DataFrame, index: list[str], name: str) -> None: def validate_nonan(df: DataFrame, name: str) -> None: + """ + Validates that the DataFrame does not contain any NaN values. + + Parameters + ---------- + df : pandas.DataFrame + The DataFrame to validate. + name : str + A name for the DataFrame, used in error messages. + + Raises + ------ + ValueError + If any NaN values are found in the DataFrame. + """ nan_columns = df.columns[df.isna().any(axis=0)].to_list() if nan_columns: error_message = ( @@ -48,7 +97,27 @@ def validate_nonan(df: DataFrame, name: str) -> None: def validate_positive( df: DataFrame, columns: list[str], name: str, strict: bool = False ) -> None: - """Validates that observation values in cols are non-negative or strictly positive""" + """ + Validates that specified columns contain non-negative or strictly positive values. + + Parameters + ---------- + df : pandas.DataFrame + The DataFrame to validate. + columns : list of str + A list of column names to check for positive values. + name : str + A name for the DataFrame, used in error messages. + strict : bool, optional + If True, checks that values are strictly greater than zero. + If False, checks that values are greater than or equal to zero. + Default is False. + + Raises + ------ + ValueError + If any of the specified columns contain invalid (negative or zero) values. + """ op = "<=" if strict else "<" negative = [col for col in columns if df.eval(f"{col} {op} 0").any()] if negative: @@ -59,11 +128,32 @@ def validate_positive( def validate_interval( df: DataFrame, lwr: str, upr: str, index: list[str], name: str ) -> None: + """ + Validates that lower interval bounds are strictly less than upper bounds. + + Parameters + ---------- + df : pandas.DataFrame + The DataFrame containing interval data to validate. + lwr : str + The name of the column representing the lower bound of the interval. + upr : str + The name of the column representing the upper bound of the interval. + index : list of str + A list of column names to be used as the index for identifying intervals. + name : str + A name for the DataFrame, used in error messages. + + Raises + ------ + ValueError + If any lower bound is not strictly less than its corresponding upper bound. + """ invalid_index = pd.MultiIndex.from_frame( df.query(f"{lwr} >= {upr}")[index] ).to_list() if invalid_index: - error_message = f"{name} has invalid interval with {len(invalid_index)} indices. \nLower age must be strictly less than upper age.\n" + error_message = f"{name} has invalid interval with {len(invalid_index)} indices. \nLower bound must be strictly less than upper bound.\n" error_message += f"Index columns: ({', '.join(index)})\n" if len(invalid_index) > 5: error_message += "First 5 indices with invalid interval: \n" @@ -75,17 +165,36 @@ def validate_interval( def validate_noindexdiff( df_ref: DataFrame, df: DataFrame, index: list[str], name: str ) -> None: + """ + Validates that the indices of two DataFrames match. + + Parameters + ---------- + df_ref : pandas.DataFrame + The reference DataFrame containing the expected indices. + df : pandas.DataFrame + The DataFrame to validate against the reference. + index : list of str + A list of column names to be used as the index for comparison. + name : str + A name for the validation context, used in error messages. + + Raises + ------ + ValueError + If there are indices in the reference DataFrame that are missing in the DataFrame to validate. + """ index_ref = pd.MultiIndex.from_frame(df_ref[index]) - index = pd.MultiIndex.from_frame(df[index]) - missing_index = index_ref.difference(index).to_list() + index_to_check = pd.MultiIndex.from_frame(df[index]) + missing_index = index_ref.difference(index_to_check).to_list() if missing_index: error_message = ( f"Missing {name} info for {len(missing_index)} indices \n" ) - error_message += f"Index columns: ({', '.join(index.names)})\n" + error_message += f"Index columns: ({', '.join(index_ref.names)})\n" if len(missing_index) > 5: - error_message += "First 5: \n" + error_message += "First 5 missing indices: \n" error_message += ", \n".join(str(idx) for idx in missing_index[:5]) error_message += "\n" raise ValueError(error_message) @@ -100,16 +209,36 @@ def validate_pat_coverage( index: list[str], name: str, ) -> None: - """Validation checks for incomplete age pattern - * pattern age intervals do not overlap or have gaps - * smallest pattern interval doesn't cover the left end point of data - * largest pattern interval doesn't cover the right end point of data """ - # sort dataframe + Validates that the pattern intervals cover the data intervals completely without gaps or overlaps. + + Parameters + ---------- + df : pandas.DataFrame + The DataFrame containing both data intervals and pattern intervals. + lwr : str + The name of the column representing the data's lower bound. + upr : str + The name of the column representing the data's upper bound. + pat_lwr : str + The name of the column representing the pattern's lower bound. + pat_upr : str + The name of the column representing the pattern's upper bound. + index : list of str + A list of column names to group by when validating intervals. + name : str + A name for the DataFrame or validation context, used in error messages. + + Raises + ------ + ValueError + If the pattern intervals have gaps or overlaps, or if they do not fully cover the data intervals. + """ + # Sort dataframe df = df.sort_values(index + [lwr, upr, pat_lwr, pat_upr], ignore_index=True) df_group = df.groupby(index) - # check overlap or gap in pattern + # Check overlap or gap in pattern shifted_pat_upr = df_group[pat_upr].shift(1) connect_index = shifted_pat_upr.notnull() connected = np.allclose( @@ -122,7 +251,7 @@ def validate_pat_coverage( "bounds across categories." ) - # check coverage of head and tail + # Check coverage of head and tail head_covered = df_group.first().eval(f"{lwr} >= {pat_lwr}").all() tail_covered = df_group.last().eval(f"{upr} <= {pat_upr}").all() @@ -134,17 +263,16 @@ def validate_pat_coverage( def validate_realnumber(df: DataFrame, columns: list[str], name: str) -> None: """ - Validates that observation values in columns are real numbers and non-zero. + Validates that specified columns contain real numbers and are non-zero. Parameters ---------- - df : DataFrame + df : pandas.DataFrame The DataFrame containing the data to validate. columns : list of str A list of column names to validate within the DataFrame. name : str - A string representing the name of the data or dataset - (used for constructing error messages). + A name for the DataFrame, used in error messages. Raises ------ diff --git a/tests/test_cat_splitter.py b/tests/test_cat_splitter.py index 50fd35e..2b9366a 100644 --- a/tests/test_cat_splitter.py +++ b/tests/test_cat_splitter.py @@ -1,13 +1,11 @@ import pytest import pandas as pd -from pydantic import ValidationError from pydisagg.ihme.splitter import ( CatSplitter, CatDataConfig, CatPatternConfig, CatPopulationConfig, ) -from typing import List # Step 1: Setup Fixtures @@ -16,8 +14,7 @@ def cat_data_config(): return CatDataConfig( index=["study_id", "year_id", "location_id"], - target="target_category", - sub_target="sub_categories", + target="sub_category", # Updated from sub_target to target val="val", val_sd="val_sd", ) @@ -27,7 +24,7 @@ def cat_data_config(): def cat_pattern_config(): return CatPatternConfig( index=["year_id", "location_id"], - sub_target="sub_category", + target="sub_category", # Updated from sub_target to target val="pattern_val", val_sd="pattern_val_sd", ) @@ -37,7 +34,7 @@ def cat_pattern_config(): def cat_population_config(): return CatPopulationConfig( index=["year_id", "location_id"], - sub_target="sub_category", + target="sub_category", # Updated from sub_target to target val="population", ) @@ -49,9 +46,8 @@ def valid_data(): "study_id": [1, 2, 3], "year_id": [2000, 2000, 2001], "location_id": [10, 20, 10], - "target_category": ["A", "B", "C"], - "sub_categories": [ - ["A1", "A2"], + "sub_category": [ + ["A1", "A2"], # Assuming sub_category is a list ["B1", "B2"], ["C1", "C2"], ], @@ -98,13 +94,6 @@ def cat_splitter(cat_data_config, cat_pattern_config, cat_population_config): # Step 2: Write Tests for parse_data -# def test_parse_data_missing_columns(cat_splitter, valid_data): -# """Test parse_data raises an error when columns are missing.""" -# invalid_data = valid_data.drop(columns=["val"]) -# with pytest.raises(KeyError, match="Missing columns"): -# cat_splitter.parse_data(invalid_data) - - def test_parse_data_duplicated_index(cat_splitter, valid_data): """Test parse_data raises an error on duplicated index.""" duplicated_data = pd.concat([valid_data, valid_data]) @@ -112,22 +101,6 @@ def test_parse_data_duplicated_index(cat_splitter, valid_data): cat_splitter.parse_data(duplicated_data) -# def test_parse_data_with_nan(cat_splitter, valid_data): -# """Test parse_data raises an error when there are NaN values.""" -# nan_data = valid_data.copy() -# nan_data.loc[0, "val"] = None -# with pytest.raises(ValueError, match="NaN values found"): -# cat_splitter.parse_data(nan_data) - - -# def test_parse_data_non_positive(cat_splitter, valid_data): -# """Test parse_data raises an error for non-positive values in val or val_sd.""" -# non_positive_data = valid_data.copy() -# non_positive_data.loc[0, "val"] = -10 -# with pytest.raises(ValueError, match="Non-positive values found"): -# cat_splitter.parse_data(non_positive_data) - - def test_parse_data_valid(cat_splitter, valid_data): """Test that parse_data works correctly on valid data.""" parsed_data = cat_splitter.parse_data(valid_data) @@ -139,32 +112,6 @@ def test_parse_data_valid(cat_splitter, valid_data): # Step 3: Write Tests for parse_pattern -# def test_parse_pattern_missing_columns(cat_splitter, valid_data, valid_pattern): -# """Test parse_pattern raises an error when pattern columns are missing.""" -# invalid_pattern = valid_pattern.drop(columns=["pattern_val"]) -# parsed_data = cat_splitter.parse_data(valid_data) -# with pytest.raises(KeyError, match="Missing columns in the pattern"): -# cat_splitter.parse_pattern(parsed_data, invalid_pattern, model="rate") - - -# def test_parse_pattern_with_nan(cat_splitter, valid_data, valid_pattern): -# """Test parse_pattern raises an error when there are NaN values.""" -# invalid_pattern = valid_pattern.copy() -# invalid_pattern.loc[0, "pattern_val"] = None -# parsed_data = cat_splitter.parse_data(valid_data) -# with pytest.raises(ValueError, match="NaN values found"): -# cat_splitter.parse_pattern(parsed_data, invalid_pattern, model="rate") - - -# def test_parse_pattern_non_positive(cat_splitter, valid_data, valid_pattern): -# """Test parse_pattern raises an error for non-positive values.""" -# invalid_pattern = valid_pattern.copy() -# invalid_pattern.loc[0, "pattern_val"] = -0.1 -# parsed_data = cat_splitter.parse_data(valid_data) -# with pytest.raises(ValueError, match="Non-positive values found"): -# cat_splitter.parse_pattern(parsed_data, invalid_pattern, model="rate") - - def test_parse_pattern_valid(cat_splitter, valid_data, valid_pattern): """Test that parse_pattern works correctly on valid data.""" parsed_data = cat_splitter.parse_data(valid_data) @@ -188,9 +135,7 @@ def test_parse_population_missing_columns( parsed_pattern = cat_splitter.parse_pattern( parsed_data, valid_pattern, model="rate" ) - with pytest.raises( - KeyError, match="Missing columns in the population data" - ): + with pytest.raises(KeyError, match="Missing columns in the population data"): cat_splitter.parse_population(parsed_pattern, invalid_population) @@ -216,9 +161,7 @@ def test_parse_population_valid( parsed_pattern = cat_splitter.parse_pattern( parsed_data, valid_pattern, model="rate" ) - parsed_population = cat_splitter.parse_population( - parsed_pattern, valid_population - ) + parsed_population = cat_splitter.parse_population(parsed_pattern, valid_population) assert not parsed_population.empty assert "population" in parsed_population.columns @@ -240,20 +183,6 @@ def test_split_valid(cat_splitter, valid_data, valid_pattern, valid_population): assert "split_result_se" in result.columns -# def test_split_with_invalid_model( -# cat_splitter, valid_data, valid_pattern, valid_population -# ): -# """Test that the split method raises an error with an invalid model.""" -# with pytest.raises(ValueError, match="Unknown model type"): -# cat_splitter.split( -# data=valid_data, -# pattern=valid_pattern, -# population=valid_population, -# model="invalid_model", -# output_type="rate", -# ) - - def test_split_with_invalid_output_type( cat_splitter, valid_data, valid_pattern, valid_population ): @@ -270,9 +199,7 @@ def test_split_with_invalid_output_type( def test_split_with_missing_population(cat_splitter, valid_data, valid_pattern): """Test that the split method raises an error when population data is missing.""" - with pytest.raises( - KeyError, match="Missing columns in the population data" - ): + with pytest.raises(KeyError, match="Missing columns in the population data"): cat_splitter.split( data=valid_data, pattern=valid_pattern, @@ -282,29 +209,15 @@ def test_split_with_missing_population(cat_splitter, valid_data, valid_pattern): ) -# def test_split_with_missing_pattern(cat_splitter, valid_data, valid_population): -# """Test that the split method raises an error when pattern data is missing.""" -# with pytest.raises(KeyError, match="Missing columns in the pattern"): -# cat_splitter.split( -# data=valid_data, -# pattern=pd.DataFrame(), # Empty pattern data -# population=valid_population, -# model="rate", -# output_type="rate", -# ) - - -def test_split_with_non_matching_sub_targets( +def test_split_with_non_matching_targets( cat_splitter, valid_data, valid_pattern, valid_population ): - """Test that the split method raises an error when sub_targets don't match.""" + """Test that the split method raises an error when targets don't match.""" invalid_population = valid_population.copy() invalid_population["sub_category"] = ["X1", "X2", "X1", "X2", "X1", "X2"] + parsed_data = cat_splitter.parse_data(valid_data) + parsed_pattern = cat_splitter.parse_pattern( + parsed_data, valid_pattern, model="rate" + ) with pytest.raises(ValueError, match="NaN values found"): - cat_splitter.split( - data=valid_data, - pattern=valid_pattern, - population=invalid_population, - model="rate", - output_type="rate", - ) + cat_splitter.parse_population(parsed_pattern, invalid_population) From e085a06efd50ed6b6f0673f7e0261d181612b52a Mon Sep 17 00:00:00 2001 From: saal Date: Tue, 17 Sep 2024 11:23:17 -0700 Subject: [PATCH 09/19] Cat splitting in parallel and sequentially test file comparing completion time --- examples/ihme_api/cat_big.py | 101 +++++++++++++-- src/pydisagg/ihme/splitter/cat_splitter.py | 142 ++++++++++++--------- 2 files changed, 170 insertions(+), 73 deletions(-) diff --git a/examples/ihme_api/cat_big.py b/examples/ihme_api/cat_big.py index a962895..b805fc1 100644 --- a/examples/ihme_api/cat_big.py +++ b/examples/ihme_api/cat_big.py @@ -2,8 +2,10 @@ import numpy as np import time import matplotlib.pyplot as plt +import psutil +import os +import gc -# Assuming the CatSplitter and configuration classes have been imported correctly from pydisagg.ihme.splitter import ( CatSplitter, CatDataConfig, @@ -15,11 +17,22 @@ np.random.seed(42) # Sizes to test -sizes = [100, 1000, 10000] -times = [] +sizes = [100, 1000, 10000, 100000, 250000, 500000, 750000, 1000000] +times_parallel = [] +times_groupby = [] +memory_parallel = [] +memory_groupby = [] + + +# Function to get current memory usage +def get_memory_usage(): + process = psutil.Process(os.getpid()) + mem = process.memory_info().rss # in bytes + return mem / (1024 * 1024) # Convert to MB + # List of possible location IDs -all_location_ids = np.arange(1000, 2000) +all_location_ids = np.arange(1000, 2000) # 1000 unique location IDs for size in sizes: print(f"\nProcessing size: {size}") @@ -105,28 +118,90 @@ population=population_config, ) - # Perform the split and time it + # Record memory before splitting + mem_before = get_memory_usage() + print(f"Memory Usage Before Splitting: {mem_before:.2f} MB") + + # Perform the split using parallel processing and time it start_time = time.time() - final_split_df = splitter.split( + final_split_df_parallel = splitter.split( data=pre_split, pattern=data_pattern, population=data_pop, model="rate", output_type="rate", n_jobs=-1, # Use all available cores + use_parallel=True, ) end_time = time.time() - elapsed_time = end_time - start_time - times.append(elapsed_time) - print(f"Size: {size}, Time taken: {elapsed_time:.2f} seconds") + elapsed_time_parallel = end_time - start_time + times_parallel.append(elapsed_time_parallel) + mem_after_parallel = get_memory_usage() + memory_parallel.append(mem_after_parallel) + print(f"Parallel - Size: {size}, Time taken: {elapsed_time_parallel:.2f} seconds") + print(f"Memory Usage After Parallel Split: {mem_after_parallel:.2f} MB") + + # Perform garbage collection to free memory before next method + del final_split_df_parallel + gc.collect() + + # Perform the split using groupby (sequential processing) and time it + start_time = time.time() + + final_split_df_groupby = splitter.split( + data=pre_split, + pattern=data_pattern, + population=data_pop, + model="rate", + output_type="rate", + use_parallel=False, + ) + + end_time = time.time() + elapsed_time_groupby = end_time - start_time + times_groupby.append(elapsed_time_groupby) + mem_after_groupby = get_memory_usage() + memory_groupby.append(mem_after_groupby) + print(f"GroupBy - Size: {size}, Time taken: {elapsed_time_groupby:.2f} seconds") + print(f"Memory Usage After GroupBy Split: {mem_after_groupby:.2f} MB") + + # Clean up + del final_split_df_groupby + gc.collect() + + # Additional garbage collection between tests + del pre_split + del data_pattern + del data_pop + del splitter + del data_config, pattern_config, population_config + del study_ids, year_ids, means, std_errs, location_ids, unique_location_ids + gc.collect() + print(f"Memory Usage After Cleanup: {get_memory_usage():.2f} MB") # Plot the results -plt.figure(figsize=(8, 6)) -plt.plot(sizes, times, marker="o") +plt.figure(figsize=(10, 6)) +plt.plot(sizes, times_parallel, marker="o", label="Parallel Processing") +plt.plot(sizes, times_groupby, marker="s", label="GroupBy Processing") plt.xlabel("Number of Rows in Data") plt.ylabel("Time Taken (seconds)") -plt.title("Runtime vs Data Size for CatSplitter") -plt.grid(True) +plt.title("Runtime Comparison: Parallel vs GroupBy in CatSplitter") +plt.xscale("log") +plt.yscale("log") +plt.grid(True, which="both", ls="--") +plt.legend() +plt.show() + +# Plot Memory Usage +plt.figure(figsize=(10, 6)) +plt.plot(sizes, memory_parallel, marker="o", label="Parallel Processing") +plt.plot(sizes, memory_groupby, marker="s", label="GroupBy Processing") +plt.xlabel("Number of Rows in Data") +plt.ylabel("Memory Usage (MB)") +plt.title("Memory Usage Comparison: Parallel vs GroupBy in CatSplitter") +plt.xscale("log") +plt.grid(True, which="both", ls="--") +plt.legend() plt.show() diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index d70257e..d4fab31 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -485,6 +485,68 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: return data_with_population + def _process_group( + self, group: DataFrame, model: str, output_type: str + ) -> DataFrame: + """ + Process a group of data for splitting. + + Parameters + ---------- + group : DataFrame + The group of data to process. + model : str + The model type ('rate' or 'logodds'). + output_type : str + The output type ('rate' or 'count'). + + Returns + ------- + DataFrame + The processed group with splitting results. + """ + observed_total = group[self.data.val].iloc[0] + observed_total_se = group[self.data.val_sd].iloc[0] + + if len(group) == 1: + # No need to split, assign the observed values + group["split_result"] = observed_total + group["split_result_se"] = observed_total_se + group["split_flag"] = 0 # Not split + else: + # Need to split among multiple targets + bucket_populations = group[self.population.val].values + rate_pattern = group[self.pattern.apply_prefix()[self.pattern.val]].values + pattern_sd = group[self.pattern.apply_prefix()[self.pattern.val_sd]].values + pattern_covariance = np.diag(pattern_sd**2) + + if model == "rate": + splitting_model = RateMultiplicativeModel() + elif model == "logodds": + splitting_model = LogOddsModel() + + # Determine whether to normalize by population for the output type + pop_normalize = output_type == "rate" + + # Perform splitting + split_result, split_se = split_datapoint( + observed_total=observed_total, + bucket_populations=bucket_populations, + rate_pattern=rate_pattern, + model=splitting_model, + output_type=output_type, + normalize_pop_for_average_type_obs=pop_normalize, + observed_total_se=observed_total_se, + pattern_covariance=pattern_covariance, + ) + + # Assign results back to the group + group["split_result"] = split_result + group["split_result_se"] = split_se + group["split_flag"] = 1 # Split + + return group + def split( self, data: DataFrame, @@ -493,6 +555,7 @@ def split( model: Literal["rate", "logodds"] = "rate", output_type: Literal["rate", "count"] = "rate", n_jobs: int = -1, # Use all available cores by default + use_parallel: bool = True, # Option to run in parallel ) -> DataFrame: """ Split the input data based on a specified pattern and population model. @@ -511,6 +574,8 @@ def split( The output type desired, by default 'rate'. n_jobs : int, optional Number of jobs for parallel processing, by default -1 (use all available cores). + use_parallel : bool, optional + Whether to use parallel processing, by default True. Returns ------- @@ -534,67 +599,24 @@ def split( data = self.parse_pattern(data, pattern, model) data = self.parse_population(data, population) - # Determine whether to normalize by population for the output type - pop_normalize = output_type == "rate" - - # Identify unique 'orig_pyd_id's to process - unique_orig_pyd_ids = data["orig_pyd_id"].unique() - - # Function to process each group - def process_group(orig_pyd_id): - group = data[data["orig_pyd_id"] == orig_pyd_id].copy() - - observed_total = group[self.data.val].iloc[0] - observed_total_se = group[self.data.val_sd].iloc[0] - - if len(group) == 1: - # No need to split, assign the observed values - group["split_result"] = observed_total - group["split_result_se"] = observed_total_se - group["split_flag"] = 0 # Not split - else: - # Need to split among multiple targets - bucket_populations = group[self.population.val].values - rate_pattern = group[ - self.pattern.apply_prefix()[self.pattern.val] - ].values - pattern_sd = group[ - self.pattern.apply_prefix()[self.pattern.val_sd] - ].values - pattern_covariance = np.diag(pattern_sd**2) - - if model == "rate": - splitting_model = RateMultiplicativeModel() - elif model == "logodds": - splitting_model = LogOddsModel() - - # Perform splitting - split_result, split_se = split_datapoint( - observed_total=observed_total, - bucket_populations=bucket_populations, - rate_pattern=rate_pattern, - model=splitting_model, - output_type=output_type, - normalize_pop_for_average_type_obs=pop_normalize, - observed_total_se=observed_total_se, - pattern_covariance=pattern_covariance, - ) - - # Assign results back to the group - group["split_result"] = split_result - group["split_result_se"] = split_se - group["split_flag"] = 1 # Split - - return group - - # Use Parallel processing to process groups - num_cores = multiprocessing.cpu_count() if n_jobs == -1 else n_jobs - processed_groups = Parallel(n_jobs=num_cores)( - delayed(process_group)(orig_pyd_id) for orig_pyd_id in unique_orig_pyd_ids - ) + # Process groups + if use_parallel: + # Identify unique 'orig_pyd_id's to process + num_cores = multiprocessing.cpu_count() if n_jobs == -1 else n_jobs + processed_groups = Parallel(n_jobs=num_cores, backend="loky")( + delayed(self._process_group)(group, model, output_type) + for _, group in data.groupby("orig_pyd_id") + ) - # Concatenate the results - final_split_df = pd.concat(processed_groups, ignore_index=True) + # Concatenate the results + final_split_df = pd.concat(processed_groups, ignore_index=True) + else: + # Process groups using regular groupby + final_split_df = ( + data.groupby("orig_pyd_id", group_keys=False) + .apply(lambda group: self._process_group(group, model, output_type)) + .reset_index(drop=True) + ) # Merge back only columns not used in the analysis if columns_not_used: From abe9e23ae9395a09cb6211189db98e57979f3170 Mon Sep 17 00:00:00 2001 From: saal Date: Tue, 17 Sep 2024 12:18:14 -0700 Subject: [PATCH 10/19] Time-space complexity compare --- .../Time-memory-complexity-CatSplitter.png | Bin 0 -> 116737 bytes examples/ihme_api/cat_big.py | 80 +++++++++++++----- src/pydisagg/ihme/splitter/cat_splitter.py | 2 +- 3 files changed, 58 insertions(+), 24 deletions(-) create mode 100644 examples/Time-memory-complexity-CatSplitter.png diff --git a/examples/Time-memory-complexity-CatSplitter.png b/examples/Time-memory-complexity-CatSplitter.png new file mode 100644 index 0000000000000000000000000000000000000000..e0eff1d951b0e974fbee9bfc65fb6695a75db301 GIT binary patch literal 116737 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

kg&HxPj1>~w{jjuY{*jgfobRVNC>ZKicMaH+s( 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 literal 0 HcmV?d00001 diff --git a/examples/ihme_api/cat_big.py b/examples/ihme_api/cat_big.py index b805fc1..40f8407 100644 --- a/examples/ihme_api/cat_big.py +++ b/examples/ihme_api/cat_big.py @@ -5,6 +5,7 @@ import psutil import os import gc +import matplotlib.ticker as ticker from pydisagg.ihme.splitter import ( CatSplitter, @@ -13,10 +14,10 @@ CatPopulationConfig, ) -# Set a random seed for reproducibility np.random.seed(42) # Sizes to test +# sizes = [100, 1000, 10000, 100000] sizes = [100, 1000, 10000, 100000, 250000, 500000, 750000, 1000000] times_parallel = [] times_groupby = [] @@ -181,27 +182,60 @@ def get_memory_usage(): gc.collect() print(f"Memory Usage After Cleanup: {get_memory_usage():.2f} MB") -# Plot the results -plt.figure(figsize=(10, 6)) -plt.plot(sizes, times_parallel, marker="o", label="Parallel Processing") -plt.plot(sizes, times_groupby, marker="s", label="GroupBy Processing") -plt.xlabel("Number of Rows in Data") -plt.ylabel("Time Taken (seconds)") -plt.title("Runtime Comparison: Parallel vs GroupBy in CatSplitter") -plt.xscale("log") -plt.yscale("log") -plt.grid(True, which="both", ls="--") -plt.legend() -plt.show() +# Define colors for the plots +color_time_parallel = "#1f77b4" # blue +color_time_groupby = "#aec7e8" # light blue +color_mem_parallel = "#ff7f0e" # orange +color_mem_groupby = "#ffbb78" # light orange + +# Create a figure and a set of subplots +fig, ax1 = plt.subplots(figsize=(10, 6)) + +# Plot time on the left y-axis +ax1.set_xlabel("Number of Rows in Data") +ax1.set_ylabel("Time Taken (seconds)", color="blue") +ax1.plot( + sizes, + times_parallel, + marker="o", + label="Time - Parallel", + color=color_time_parallel, +) +ax1.plot( + sizes, times_groupby, marker="s", label="Time - GroupBy", color=color_time_groupby +) +ax1.set_xscale("log") +ax1.set_yscale("log") +ax1.tick_params(axis="y", labelcolor="blue") + +# Set x-axis ticks to show the sizes +ax1.set_xticks(sizes) +ax1.get_xaxis().set_major_formatter(ticker.ScalarFormatter()) +ax1.get_xaxis().set_minor_formatter(ticker.NullFormatter()) + +# Create a twin Axes sharing the x-axis for memory usage +ax2 = ax1.twinx() +ax2.set_ylabel("Memory Usage (MB)", color="orange") +ax2.plot( + sizes, + memory_parallel, + marker="o", + label="Memory - Parallel", + color=color_mem_parallel, +) +ax2.plot( + sizes, memory_groupby, marker="s", label="Memory - GroupBy", color=color_mem_groupby +) +ax2.set_xscale("log") +ax2.tick_params(axis="y", labelcolor="orange") + +# Add grid +ax1.grid(True, which="both", ls="--") + +# Combine legends from both axes +lines_1, labels_1 = ax1.get_legend_handles_labels() +lines_2, labels_2 = ax2.get_legend_handles_labels() +ax1.legend(lines_1 + lines_2, labels_1 + labels_2, loc="upper left") -# Plot Memory Usage -plt.figure(figsize=(10, 6)) -plt.plot(sizes, memory_parallel, marker="o", label="Parallel Processing") -plt.plot(sizes, memory_groupby, marker="s", label="GroupBy Processing") -plt.xlabel("Number of Rows in Data") -plt.ylabel("Memory Usage (MB)") -plt.title("Memory Usage Comparison: Parallel vs GroupBy in CatSplitter") -plt.xscale("log") -plt.grid(True, which="both", ls="--") -plt.legend() +plt.title("Time and Memory Usage Comparison: Parallel vs GroupBy in CatSplitter") plt.show() diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index d4fab31..963e586 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -555,7 +555,7 @@ def split( model: Literal["rate", "logodds"] = "rate", output_type: Literal["rate", "count"] = "rate", n_jobs: int = -1, # Use all available cores by default - use_parallel: bool = True, # Option to run in parallel + use_parallel: bool = False, # Option to run in parallel ) -> DataFrame: """ Split the input data based on a specified pattern and population model. From f9273d1f5c82e412eaa62dd98355cc5b72a0aff0 Mon Sep 17 00:00:00 2001 From: saal Date: Tue, 17 Sep 2024 14:39:35 -0700 Subject: [PATCH 11/19] Updated version to 0.6.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bc768b0..90dec5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires = [ [project] name = "pydisagg" -version = "0.5.2" +version = "0.6.0" description = "" readme = "README.md" license = { text = "BSD 2-Clause License" } From 6db82ed6b7b670fa8a25145d1738c8911e2ee18c Mon Sep 17 00:00:00 2001 From: saal Date: Fri, 20 Sep 2024 09:51:44 -0700 Subject: [PATCH 12/19] Update classes to match comments Halfway through comments, working on Population erroring --- examples/ihme_api/cat_split_example.py | 4 +- src/pydisagg/ihme/splitter/cat_splitter.py | 412 ++++++++++++--------- 2 files changed, 232 insertions(+), 184 deletions(-) diff --git a/examples/ihme_api/cat_split_example.py b/examples/ihme_api/cat_split_example.py index b2f4a56..1ab0031 100644 --- a/examples/ihme_api/cat_split_example.py +++ b/examples/ihme_api/cat_split_example.py @@ -1,4 +1,6 @@ -# Assuming the CatSplitter and configuration classes have been imported correctly +import numpy as np +import pandas as pd + from pydisagg.ihme.splitter import ( CatSplitter, CatDataConfig, diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index 963e586..e728ce6 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -22,117 +22,207 @@ class CatDataConfig(Schema): """ - Configuration for the data DataFrame. + Configuration schema for categorical data DataFrame. + + This class defines the configuration parameters required to process + a categorical dataset represented as a pandas DataFrame. It specifies + which columns to use as indices, the categorical group for splitting, + and the observed values along with their standard deviations. Parameters ---------- index : List[str] - List of column names to be used as index in the data DataFrame. - target : str - Column name representing the target variable to split. + A list of column names to be used as the index in the data DataFrame. + These columns uniquely identify each observation in the dataset. + cat_group : str + The name of the column that represents the categorical group used for + splitting the data. This column typically contains categorical or + grouping information. + val : str + The name of the column that contains the observed value for each + observation. This could be a measurement or a metric of interest. + val_sd : str + The name of the column that contains the standard deviation of the + observed value, representing the uncertainty or variability + associated with the `val` column. + + Attributes + ---------- + index : List[str] + As described in Parameters. + cat_group : str + As described in Parameters. val : str - Column name for the observed value. + As described in Parameters. val_sd : str - Column name for the standard deviation of the observed value. + As described in Parameters. + + Properties + ---------- + columns : List[str] + A list of all required column names in the data DataFrame, + including index columns, categorical group, value, and standard deviation columns. + + val_fields : List[str] + A list containing the value fields, specifically `val` and `val_sd`. + + Examples + -------- + Creating a configuration for a sample DataFrame: + + >>> import pandas as pd + >>> import numpy as np + >>> from your_module import CatDataConfig # Replace with actual module name + + >>> # Sample DataFrame + >>> pre_split = pd.DataFrame( + ... { + ... "study_id": np.random.randint(1000, 9999, size=3), + ... "year_id": [2010, 2010, 2010], + ... "location_id": [ + ... [1234, 1235, 1236], # List of location_ids for row 1 + ... [2345, 2346, 2347], # List of location_ids for row 2 + ... [3456], # Single location_id for row 3 (no need to split) + ... ], + ... "mean": [0.2, 0.3, 0.4], + ... "std_err": [0.01, 0.02, 0.03], + ... } + ... ) + + >>> # Configuration + >>> data_config = CatDataConfig( + ... index=["study_id", "year_id"], # Columns to be used as index + ... cat_group="location_id", # Categorical group column for splitting + ... val="mean", # Observed value column + ... val_sd="std_err", # Standard deviation of the observed value + ... ) + + >>> # Accessing required columns + >>> required_columns = data_config.columns + >>> print(required_columns) + ['study_id', 'year_id', 'location_id', 'mean', 'std_err'] + + >>> # Accessing value fields + >>> value_fields = data_config.val_fields + >>> print(value_fields) + ['mean', 'std_err'] """ index: List[str] - target: str + cat_group: str val: str val_sd: str - @property - def columns(self) -> List[str]: - """ - List of all required columns in the data DataFrame. - - Returns - ------- - List[str] - List of column names. - """ - return list(set(self.index + [self.target, self.val, self.val_sd])) - - @property - def val_fields(self) -> List[str]: - """ - List of value fields (attributes). - - Returns - ------- - List[str] - List containing 'val' and 'val_sd'. - """ - return ["val", "val_sd"] # Attribute names - class CatPatternConfig(Schema): """ - Configuration for the pattern DataFrame. + Configuration schema for the pattern DataFrame. + + This class defines the configuration parameters required to process + a categorical pattern dataset represented as a pandas DataFrame. It specifies + which columns to use as indices, the categorical group for splitting, + observed mean values, their standard deviations, and additional draw columns + if applicable. Parameters ---------- index : List[str] - List of column names to be used as index in the pattern DataFrame. - target : str - Column name representing the target variable to split. + A list of column names to be used as the index in the pattern DataFrame. + These columns uniquely identify each pattern entry in the dataset. + cat : str + The name of the column that represents the categorical group used for + splitting the data. This column typically contains categorical or + grouping information. draws : List[str], optional - List of draw column names, by default []. + A list of column names representing draw data, used for uncertainty + quantification or simulation purposes. Defaults to an empty list. val : str, optional - Column name for the mean value in the pattern DataFrame, by default 'mean'. + The name of the column that contains the observed mean value for each + pattern entry. This could be a measurement or a metric of interest. + Defaults to `'mean'`. val_sd : str, optional - Column name for the standard deviation in the pattern DataFrame, by default 'std_err'. + The name of the column that contains the standard deviation of the + observed mean value, representing the uncertainty or variability + associated with the `val` column. Defaults to `'std_err'`. prefix : str, optional - Prefix to apply to column names when merging, by default 'cat_pat_'. + A prefix to apply to column names when merging this pattern DataFrame + with other DataFrames. This helps in distinguishing columns from different + sources. Defaults to `'cat_pat_'`. + + Attributes + ---------- + index : List[str] + As described in Parameters. + cat : str + As described in Parameters. + draws : List[str] + As described in Parameters. + val : str + As described in Parameters. + val_sd : str + As described in Parameters. + prefix : str + As described in Parameters. + + Properties + ---------- + columns : List[str] + A list of all required column names in the pattern DataFrame, + including index columns, categorical group, value, and standard deviation columns. + + val_fields : List[str] + A list containing the value fields, specifically the `val` and `val_sd` columns. + + Examples + -------- + Creating a configuration for a sample Pattern DataFrame: + + >>> import pandas as pd + >>> import numpy as np + >>> from your_module import CatPatternConfig # Replace with actual module name + + >>> # Sample Pattern DataFrame + >>> all_location_ids = [ + ... 1234, 1235, 1236, 2345, 2346, + ... 2347, 3456, 4567, 5678 + ... ] + >>> data_pattern = pd.DataFrame( + ... { + ... "location_id": all_location_ids, + ... "year_id": [2010] * len(all_location_ids), + ... "mean": np.random.uniform(0.1, 0.5, len(all_location_ids)), + ... "std_err": np.random.uniform(0.01, 0.05, len(all_location_ids)), + ... } + ... ) + + >>> # Configuration + >>> pattern_config = CatPatternConfig( + ... index=["year_id"], # Columns to be used as index + ... cat="location_id", # Categorical group column for splitting + ... draws=[], # No draw columns in this example + ... val="mean", # Observed mean value column + ... val_sd="std_err", # Standard deviation of the observed mean value + ... prefix="cat_pat_" # Prefix for merging + ... ) + + >>> # Accessing required columns + >>> required_columns = pattern_config.columns + >>> print(required_columns) + ['year_id', 'location_id', 'mean', 'std_err'] + + >>> # Accessing value fields + >>> value_fields = pattern_config.val_fields + >>> print(value_fields) + ['mean', 'std_err'] """ index: List[str] - target: str + cat: str draws: List[str] = [] val: str = "mean" val_sd: str = "std_err" prefix: str = "cat_pat_" - @property - def columns(self) -> List[str]: - """ - List of all required columns in the pattern DataFrame. - - Returns - ------- - List[str] - List of column names. - """ - return list(set(self.index + [self.target, self.val, self.val_sd])) - - @property - def val_fields(self) -> List[str]: - """ - List of value fields (attributes). - - Returns - ------- - List[str] - List containing 'val' and 'val_sd'. - """ - return ["val", "val_sd"] # Attribute names - - def apply_prefix(self) -> dict: - """ - Create a mapping to rename columns with the specified prefix. - - Returns - ------- - dict - Mapping from original column names to prefixed column names. - """ - return { - self.val: f"{self.prefix}{self.val}", - self.val_sd: f"{self.prefix}{self.val_sd}", - self.target: self.target, # Do not prefix the target column - **{idx: idx for idx in self.index}, # Keep index columns unchanged - } - class CatPopulationConfig(Schema): """ @@ -151,49 +241,10 @@ class CatPopulationConfig(Schema): """ index: List[str] - target: str + # target: str val: str prefix: str = "cat_pop_" - @property - def columns(self) -> List[str]: - """ - List of all required columns in the population DataFrame. - - Returns - ------- - List[str] - List of column names. - """ - return list(set(self.index + [self.target, self.val])) - - @property - def val_fields(self) -> List[str]: - """ - List of value fields (attributes). - - Returns - ------- - List[str] - List containing 'val'. - """ - return ["val"] - - def apply_prefix(self) -> dict: - """ - Create a mapping to rename columns with the specified prefix. - - Returns - ------- - dict - Mapping from original column names to prefixed column names. - """ - return { - self.val: f"{self.prefix}{self.val}", - self.target: self.target, # Do not prefix the target column - **{idx: idx for idx in self.index}, # Keep index columns unchanged - } - class CatSplitter(BaseModel): """ @@ -224,51 +275,51 @@ def model_post_init(self, __context: Any) -> None: """ if not set(self.pattern.index).issubset(self.data.index): raise ValueError( - "Match criteria in the pattern must be a subset of the data" + "Meow! The pattern's match criteria must be a subset of the data. Purrrlease check your input." ) if not set(self.population.index).issubset( self.data.index + self.pattern.index ): raise ValueError( - "Match criteria in the population must be a subset of the data and the pattern" + "Meow! The population's match criteria must be a subset of the data and the pattern. Purrrhaps take a closer look?" ) - # Check that the 'target' column in pattern and population matches data - if self.pattern.target != self.data.target: + # NOTE: This doesn't have to be true, as long as the values contained within the group are present in the pattern + if self.pattern.cat != self.data.cat_group: raise ValueError( - "The 'target' column in pattern must match the 'target' column in data" + "Hiss! The 'target' column in the pattern doesn't match the 'target' column in the data. Meow over it again." ) - if self.population.target != self.data.target: + if self.data.cat_group not in self.population.index: raise ValueError( - "The 'target' column in population must match the 'target' column in data" + "Meow! The 'target' column in the population must match the 'target' column in the data. Purr-fect that before proceeding!" ) - def create_ref_return_df(self, data: DataFrame) -> tuple[DataFrame, DataFrame]: - """ - Create reference and return DataFrames. - - Parameters - ---------- - data : DataFrame - The input data DataFrame. - - Returns - ------- - tuple[DataFrame, DataFrame] - A tuple containing: - - ref_df: DataFrame with original data, exploded if necessary. - - data: DataFrame with required columns and identifiers. - """ - ref_df = data.copy() - ref_df["orig_pyd_id"] = range( - len(ref_df) - ) # Assign original pyd_id before exploding - ref_df["orig_group"] = ref_df[self.data.target] - # Explode the 'target' column if it contains lists - if ref_df[self.data.target].apply(lambda x: isinstance(x, list)).any(): - ref_df = ref_df.explode(self.data.target).reset_index(drop=True) - # Assign new pyd_id's after exploding - ref_df["pyd_id"] = range(len(ref_df)) - return ref_df, ref_df[self.data.columns + ["pyd_id", "orig_pyd_id"]] + # def create_ref_return_df(self, data: DataFrame) -> tuple[DataFrame, DataFrame]: + # """ + # Create reference and return DataFrames. + + # Parameters + # ---------- + # data : DataFrame + # The input data DataFrame. + + # Returns + # ------- + # tuple[DataFrame, DataFrame] + # A tuple containing: + # - ref_df: DataFrame with original data, exploded if necessary. + # - data: DataFrame with required columns and identifiers. + # """ + # ref_df = data.copy() + # ref_df["orig_pyd_id"] = range( + # len(ref_df) + # ) # Assign original pyd_id before exploding + # ref_df["orig_group"] = ref_df[self.data.cat_group] + # # Explode the 'target' column if it contains lists + # if ref_df[self.data.cat_group].apply(lambda x: isinstance(x, list)).any(): + # ref_df = ref_df.explode(self.data.cat_group).reset_index(drop=True) + # # Assign new pyd_id's after exploding + # ref_df["pyd_id"] = range(len(ref_df)) + # return ref_df, ref_df[self.data.columns + ["pyd_id", "orig_pyd_id"]] def parse_data(self, data: DataFrame) -> DataFrame: """ @@ -294,32 +345,21 @@ def parse_data(self, data: DataFrame) -> DataFrame: name = "While parsing data" # Validate core columns first - try: - validate_columns(data, self.data.columns + ["pyd_id", "orig_pyd_id"], name) - except KeyError as e: - raise KeyError(f"{name}: Missing columns in the input data. Details:\n{e}") + validate_columns(data, self.data.columns, name) - try: - validate_index(data, self.data.index + [self.data.target, "pyd_id"], name) - except ValueError as e: - raise ValueError(f"{name}: Duplicated index found. Details:\n{e}") + validate_index(data, self.data.index + [self.data.cat_group], name) - try: - validate_nonan(data, name) - except ValueError as e: - raise ValueError(f"{name}: NaN values found. Details:\n{e}") + validate_nonan(data, name) - try: - validate_positive(data, [self.data.val, self.data.val_sd], name) - except ValueError as e: - raise ValueError( - f"{name}: Non-positive values found in 'val' or 'val_sd'. Details:\n{e}" - ) + validate_positive(data, [self.data.val, self.data.val_sd], name) return data def _merge_with_pattern( - self, data: DataFrame, pattern: DataFrame, how: str + self, + data: DataFrame, + pattern: DataFrame, + how: Literal["left", "right", "outer", "inner"], ) -> DataFrame: """ Merge data with pattern DataFrame. @@ -330,15 +370,15 @@ def _merge_with_pattern( The data DataFrame. pattern : DataFrame The pattern DataFrame. - how : str - Merge method ('inner', 'left', 'right', etc.). + how : {'inner', 'left', 'right', 'outer'} + Merge method. Returns ------- DataFrame Merged DataFrame after merging with pattern. """ - merge_keys = self.pattern.index + [self.pattern.target] + merge_keys = self.pattern.index + [self.pattern.cat] val_fields = [ self.pattern.apply_prefix()[self.pattern.val], self.pattern.apply_prefix()[self.pattern.val_sd], @@ -399,9 +439,9 @@ def parse_pattern( pattern_copy.rename(columns=rename_map, inplace=True) # Filter pattern_copy to include only target IDs present in data - data_target_ids = data[self.data.target].unique() + data_target_ids = data[self.data.cat_group].unique() pattern_copy = pattern_copy[ - pattern_copy[self.pattern.target].isin(data_target_ids) + pattern_copy[self.pattern.cat].isin(data_target_ids) ] # Use an inner join @@ -411,7 +451,7 @@ def parse_pattern( validate_noindexdiff( data, data_with_pattern, - self.data.index + [self.data.target, "pyd_id"], + self.data.index + [self.data.cat_group], name, ) @@ -450,14 +490,20 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: f"{name}: Missing columns in the population data. Details:\n{e}" ) - # Filter population to include only target IDs present in data - data_target_ids = data[self.data.target].unique() + # NOTE: Updated to this point + + # Should we error sooner if a population is missing for the data? + # How should we check instead of looping? + + # This isn't right I don't think ... + data_target_ids = data[self.data.cat_group].unique() + population = population[ population[self.population.target].isin(data_target_ids) ] # Use an inner join - merge_keys = self.population.index + [self.population.target] + merge_keys = self.population.index data_with_population = data.merge( population, on=merge_keys, how="inner", suffixes=("", "_pop") ) @@ -474,7 +520,7 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: validate_noindexdiff( data, data_with_population, - self.data.index + [self.data.target, "pyd_id"], + self.data.index + [self.data.cat_group, "pyd_id"], name, ) From f312471a1ace774095984a215c5b56b44704bb09 Mon Sep 17 00:00:00 2001 From: saal Date: Wed, 25 Sep 2024 15:23:57 -0700 Subject: [PATCH 13/19] Updated cat_spliiter and examples --- .../Time-memory-complexity-CatSplitter.png | Bin 116737 -> 0 bytes examples/ihme_api/cat_big.py | 241 -------- examples/ihme_api/cat_sex_split_example.py | 38 +- examples/ihme_api/cat_split_example.py | 45 +- src/pydisagg/ihme/splitter/age_splitter.py | 7 +- src/pydisagg/ihme/splitter/cat_splitter.py | 581 ++++-------------- src/pydisagg/ihme/validator.py | 3 + tests/test_cat_splitter.py | 12 +- 8 files changed, 185 insertions(+), 742 deletions(-) delete mode 100644 examples/Time-memory-complexity-CatSplitter.png delete mode 100644 examples/ihme_api/cat_big.py diff --git a/examples/Time-memory-complexity-CatSplitter.png b/examples/Time-memory-complexity-CatSplitter.png deleted file mode 100644 index e0eff1d951b0e974fbee9bfc65fb6695a75db301..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 116737 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

kg&HxPj1>~w{jjuY{*jgfobRVNC>ZKicMaH+s( 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 diff --git a/examples/ihme_api/cat_big.py b/examples/ihme_api/cat_big.py deleted file mode 100644 index 40f8407..0000000 --- a/examples/ihme_api/cat_big.py +++ /dev/null @@ -1,241 +0,0 @@ -import pandas as pd -import numpy as np -import time -import matplotlib.pyplot as plt -import psutil -import os -import gc -import matplotlib.ticker as ticker - -from pydisagg.ihme.splitter import ( - CatSplitter, - CatDataConfig, - CatPatternConfig, - CatPopulationConfig, -) - -np.random.seed(42) - -# Sizes to test -# sizes = [100, 1000, 10000, 100000] -sizes = [100, 1000, 10000, 100000, 250000, 500000, 750000, 1000000] -times_parallel = [] -times_groupby = [] -memory_parallel = [] -memory_groupby = [] - - -# Function to get current memory usage -def get_memory_usage(): - process = psutil.Process(os.getpid()) - mem = process.memory_info().rss # in bytes - return mem / (1024 * 1024) # Convert to MB - - -# List of possible location IDs -all_location_ids = np.arange(1000, 2000) # 1000 unique location IDs - -for size in sizes: - print(f"\nProcessing size: {size}") - # Generate study_ids - study_ids = np.random.randint(1000, 9999, size=size) - # For simplicity, set the year_id to 2010 for all rows - year_ids = np.full(size, 2010) - # Generate 'mean' and 'std_err' - means = np.random.uniform(0.1, 0.5, size=size) - std_errs = np.random.uniform(0.01, 0.05, size=size) - # Generate 'location_id' lists - location_ids = [] - for _ in range(size): - # For each row, select between 1 and 5 random location IDs - num_locations = np.random.randint(1, 6) - loc_ids = np.random.choice( - all_location_ids, size=num_locations, replace=False - ).tolist() - location_ids.append(loc_ids) - # Create the pre_split DataFrame - pre_split = pd.DataFrame( - { - "study_id": study_ids, - "year_id": year_ids, - "location_id": location_ids, - "mean": means, - "std_err": std_errs, - } - ) - - # Flatten the list of location_ids to get all unique location IDs used - unique_location_ids = set() - for loc_list in location_ids: - unique_location_ids.update(loc_list) - unique_location_ids = list(unique_location_ids) - - # Pattern DataFrame for all location_ids - data_pattern = pd.DataFrame( - { - "location_id": unique_location_ids, - "year_id": np.full(len(unique_location_ids), 2010), - "mean": np.random.uniform(0.1, 0.5, size=len(unique_location_ids)), - "std_err": np.random.uniform(0.01, 0.05, size=len(unique_location_ids)), - } - ) - - # Population DataFrame for all location_ids - data_pop = pd.DataFrame( - { - "location_id": unique_location_ids, - "year_id": np.full(len(unique_location_ids), 2010), - "population": np.random.randint( - 10000, 1000000, size=len(unique_location_ids) - ), - } - ) - - # Configurations - data_config = CatDataConfig( - index=["study_id", "year_id"], - target="location_id", - val="mean", - val_sd="std_err", - ) - - pattern_config = CatPatternConfig( - index=["year_id"], - target="location_id", - val="mean", - val_sd="std_err", - ) - - population_config = CatPopulationConfig( - index=["year_id"], - target="location_id", - val="population", - ) - - # Initialize the CatSplitter - splitter = CatSplitter( - data=data_config, - pattern=pattern_config, - population=population_config, - ) - - # Record memory before splitting - mem_before = get_memory_usage() - print(f"Memory Usage Before Splitting: {mem_before:.2f} MB") - - # Perform the split using parallel processing and time it - start_time = time.time() - - final_split_df_parallel = splitter.split( - data=pre_split, - pattern=data_pattern, - population=data_pop, - model="rate", - output_type="rate", - n_jobs=-1, # Use all available cores - use_parallel=True, - ) - - end_time = time.time() - elapsed_time_parallel = end_time - start_time - times_parallel.append(elapsed_time_parallel) - mem_after_parallel = get_memory_usage() - memory_parallel.append(mem_after_parallel) - print(f"Parallel - Size: {size}, Time taken: {elapsed_time_parallel:.2f} seconds") - print(f"Memory Usage After Parallel Split: {mem_after_parallel:.2f} MB") - - # Perform garbage collection to free memory before next method - del final_split_df_parallel - gc.collect() - - # Perform the split using groupby (sequential processing) and time it - start_time = time.time() - - final_split_df_groupby = splitter.split( - data=pre_split, - pattern=data_pattern, - population=data_pop, - model="rate", - output_type="rate", - use_parallel=False, - ) - - end_time = time.time() - elapsed_time_groupby = end_time - start_time - times_groupby.append(elapsed_time_groupby) - mem_after_groupby = get_memory_usage() - memory_groupby.append(mem_after_groupby) - print(f"GroupBy - Size: {size}, Time taken: {elapsed_time_groupby:.2f} seconds") - print(f"Memory Usage After GroupBy Split: {mem_after_groupby:.2f} MB") - - # Clean up - del final_split_df_groupby - gc.collect() - - # Additional garbage collection between tests - del pre_split - del data_pattern - del data_pop - del splitter - del data_config, pattern_config, population_config - del study_ids, year_ids, means, std_errs, location_ids, unique_location_ids - gc.collect() - print(f"Memory Usage After Cleanup: {get_memory_usage():.2f} MB") - -# Define colors for the plots -color_time_parallel = "#1f77b4" # blue -color_time_groupby = "#aec7e8" # light blue -color_mem_parallel = "#ff7f0e" # orange -color_mem_groupby = "#ffbb78" # light orange - -# Create a figure and a set of subplots -fig, ax1 = plt.subplots(figsize=(10, 6)) - -# Plot time on the left y-axis -ax1.set_xlabel("Number of Rows in Data") -ax1.set_ylabel("Time Taken (seconds)", color="blue") -ax1.plot( - sizes, - times_parallel, - marker="o", - label="Time - Parallel", - color=color_time_parallel, -) -ax1.plot( - sizes, times_groupby, marker="s", label="Time - GroupBy", color=color_time_groupby -) -ax1.set_xscale("log") -ax1.set_yscale("log") -ax1.tick_params(axis="y", labelcolor="blue") - -# Set x-axis ticks to show the sizes -ax1.set_xticks(sizes) -ax1.get_xaxis().set_major_formatter(ticker.ScalarFormatter()) -ax1.get_xaxis().set_minor_formatter(ticker.NullFormatter()) - -# Create a twin Axes sharing the x-axis for memory usage -ax2 = ax1.twinx() -ax2.set_ylabel("Memory Usage (MB)", color="orange") -ax2.plot( - sizes, - memory_parallel, - marker="o", - label="Memory - Parallel", - color=color_mem_parallel, -) -ax2.plot( - sizes, memory_groupby, marker="s", label="Memory - GroupBy", color=color_mem_groupby -) -ax2.set_xscale("log") -ax2.tick_params(axis="y", labelcolor="orange") - -# Add grid -ax1.grid(True, which="both", ls="--") - -# Combine legends from both axes -lines_1, labels_1 = ax1.get_legend_handles_labels() -lines_2, labels_2 = ax2.get_legend_handles_labels() -ax1.legend(lines_1 + lines_2, labels_1 + labels_2, loc="upper left") - -plt.title("Time and Memory Usage Comparison: Parallel vs GroupBy in CatSplitter") -plt.show() diff --git a/examples/ihme_api/cat_sex_split_example.py b/examples/ihme_api/cat_sex_split_example.py index 5879d83..3629ae3 100644 --- a/examples/ihme_api/cat_sex_split_example.py +++ b/examples/ihme_api/cat_sex_split_example.py @@ -1,7 +1,7 @@ import pandas as pd import numpy as np -# Import CatSplitter and configurations from your package +# Import CatSplitter and configurations from your module from pydisagg.ihme.splitter import ( CatSplitter, CatDataConfig, @@ -70,7 +70,9 @@ pattern_df_sex2["mean"] = pattern_df_sex2["mean"].round(6) pattern_df_sex2["standard_error"] = pattern_df_sex2["standard_error"].round(6) -pattern_df_final = pd.concat([pattern_df_sex1, pattern_df_sex2], ignore_index=True) +pattern_df_final = pd.concat( + [pattern_df_sex1, pattern_df_sex2], ignore_index=True +) # Sort pattern_df_final for clarity pattern_df_final_sorted = pattern_df_final.sort_values( @@ -87,7 +89,11 @@ population_df = pd.DataFrame( { "location_id": [30, 30, 78, 78, 120, 120, 130, 130, 141, 141], - "year_id": [2017] * 2 + [2015] * 2 + [2018] * 2 + [2019] * 2 + [2016] * 2, + "year_id": [2017] * 2 + + [2015] * 2 + + [2018] * 2 + + [2019] * 2 + + [2016] * 2, "sex": [1, 2] * 5, # Sexes 1 and 2 "population": [ 39789, @@ -105,9 +111,9 @@ ) # Sort population_df for clarity -population_df_sorted = population_df.sort_values(by=["location_id", "sex"]).reset_index( - drop=True -) +population_df_sorted = population_df.sort_values( + by=["location_id", "sex"] +).reset_index(drop=True) # Display the sorted population_df print("\npopulation_df:") @@ -119,24 +125,28 @@ # Data configuration data_config = CatDataConfig( - index=["seq", "location_id", "year_id"], - target="sex", + index=[ + "seq", + "location_id", + "year_id", + "sex", + ], # Include 'sex' in the index + cat_group="sex", val="mean", val_sd="standard_error", ) # Pattern configuration pattern_config = CatPatternConfig( - index=["location_id", "year_id"], - target="sex", + by=["location_id", "year_id"], + cat="sex", val="mean", val_sd="standard_error", ) # Population configuration population_config = CatPopulationConfig( - index=["location_id", "year_id"], - target="sex", + index=["location_id", "year_id", "sex"], # Include 'sex' in the index val="population", ) @@ -160,5 +170,5 @@ print("\nFinal Split DataFrame:") print(final_split_df) -except ValueError as e: - print(f"Error: {e}") +except Exception as e: + print(f"Error during splitting: {e}") diff --git a/examples/ihme_api/cat_split_example.py b/examples/ihme_api/cat_split_example.py index 1ab0031..94dbb6e 100644 --- a/examples/ihme_api/cat_split_example.py +++ b/examples/ihme_api/cat_split_example.py @@ -1,6 +1,10 @@ +# cat_split_example.py + import numpy as np import pandas as pd +from pandas import DataFrame +# Import CatSplitter and related classes from pydisagg.ihme.splitter import ( CatSplitter, CatDataConfig, @@ -8,13 +12,13 @@ CatPopulationConfig, ) -# Set a random seed for reproducibility -np.random.seed(42) - # ------------------------------- # Example DataFrames # ------------------------------- +# Set a random seed for reproducibility +np.random.seed(42) + # Pre-split DataFrame with 3 rows pre_split = pd.DataFrame( { @@ -39,15 +43,15 @@ 2346, 2347, 3456, - 4567, # Additional location_ids - 5678, + 4567, + 5678, # Additional location_ids ] # Pattern DataFrame for all location_ids data_pattern = pd.DataFrame( { - "location_id": all_location_ids, "year_id": [2010] * len(all_location_ids), + "location_id": all_location_ids, "mean": np.random.uniform(0.1, 0.5, len(all_location_ids)), "std_err": np.random.uniform(0.01, 0.05, len(all_location_ids)), } @@ -56,8 +60,8 @@ # Population DataFrame for all location_ids data_pop = pd.DataFrame( { - "location_id": all_location_ids, "year_id": [2010] * len(all_location_ids), + "location_id": all_location_ids, "population": np.random.randint(10000, 1000000, len(all_location_ids)), } ) @@ -74,29 +78,35 @@ # Configurations # ------------------------------- +# Adjusted configurations to match the modified CatSplitter data_config = CatDataConfig( - index=["study_id", "year_id"], # Include study_id in the index - target="location_id", # Column containing list of targets + index=[ + "study_id", + "year_id", + "location_id", + ], # Include 'location_id' in the index + cat_group="location_id", val="mean", val_sd="std_err", ) pattern_config = CatPatternConfig( - index=["year_id"], - target="location_id", + by=["year_id"], + cat="location_id", val="mean", val_sd="std_err", ) population_config = CatPopulationConfig( - index=["year_id"], - target="location_id", + index=["year_id", "location_id"], val="population", ) -# Initialize the CatSplitter +# Initialize the CatSplitter with the updated configurations splitter = CatSplitter( - data=data_config, pattern=pattern_config, population=population_config + data=data_config, + pattern=pattern_config, + population=population_config, ) # Perform the split @@ -108,8 +118,9 @@ model="rate", output_type="rate", ) + # Sort the final DataFrame for better readability final_split_df.sort_values(by=["study_id", "location_id"], inplace=True) print("\nFinal Split DataFrame:") print(final_split_df) -except ValueError as e: - print(f"Error: {e}") +except Exception as e: + print(f"Error during splitting: {e}") diff --git a/src/pydisagg/ihme/splitter/age_splitter.py b/src/pydisagg/ihme/splitter/age_splitter.py index bd389dc..e0f255e 100644 --- a/src/pydisagg/ihme/splitter/age_splitter.py +++ b/src/pydisagg/ihme/splitter/age_splitter.py @@ -197,7 +197,7 @@ def parse_pattern( def _merge_with_pattern( self, data: DataFrame, pattern: DataFrame ) -> DataFrame: - # Ensure the necessary columns are present before merging + # TODO change these asserts to validate_columns assert ( self.data.age_lwr in data.columns ), f"Column '{self.data.age_lwr}' not found in data" @@ -236,11 +236,10 @@ def parse_population( validate_index(population, self.population.index, name) validate_nonan(population, name) - pop_copy = population.copy() rename_map = self.population.apply_prefix() - pop_copy.rename(columns=rename_map, inplace=True) + population.rename(columns=rename_map, inplace=True) - data_with_population = self._merge_with_population(data, pop_copy) + data_with_population = self._merge_with_population(data, population) validate_noindexdiff( data, diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index e728ce6..929366d 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -1,16 +1,15 @@ -from typing import Any, List +# cat_splitter.py +from typing import Any, List import numpy as np import pandas as pd -import multiprocessing from pandas import DataFrame from pydantic import BaseModel from typing import Literal -from joblib import Parallel, delayed from pydisagg.disaggregate import split_datapoint -from pydisagg.ihme.schema import Schema from pydisagg.models import RateMultiplicativeModel, LogOddsModel +from pydisagg.ihme.schema import Schema from pydisagg.ihme.validator import ( validate_columns, validate_index, @@ -23,89 +22,6 @@ class CatDataConfig(Schema): """ Configuration schema for categorical data DataFrame. - - This class defines the configuration parameters required to process - a categorical dataset represented as a pandas DataFrame. It specifies - which columns to use as indices, the categorical group for splitting, - and the observed values along with their standard deviations. - - Parameters - ---------- - index : List[str] - A list of column names to be used as the index in the data DataFrame. - These columns uniquely identify each observation in the dataset. - cat_group : str - The name of the column that represents the categorical group used for - splitting the data. This column typically contains categorical or - grouping information. - val : str - The name of the column that contains the observed value for each - observation. This could be a measurement or a metric of interest. - val_sd : str - The name of the column that contains the standard deviation of the - observed value, representing the uncertainty or variability - associated with the `val` column. - - Attributes - ---------- - index : List[str] - As described in Parameters. - cat_group : str - As described in Parameters. - val : str - As described in Parameters. - val_sd : str - As described in Parameters. - - Properties - ---------- - columns : List[str] - A list of all required column names in the data DataFrame, - including index columns, categorical group, value, and standard deviation columns. - - val_fields : List[str] - A list containing the value fields, specifically `val` and `val_sd`. - - Examples - -------- - Creating a configuration for a sample DataFrame: - - >>> import pandas as pd - >>> import numpy as np - >>> from your_module import CatDataConfig # Replace with actual module name - - >>> # Sample DataFrame - >>> pre_split = pd.DataFrame( - ... { - ... "study_id": np.random.randint(1000, 9999, size=3), - ... "year_id": [2010, 2010, 2010], - ... "location_id": [ - ... [1234, 1235, 1236], # List of location_ids for row 1 - ... [2345, 2346, 2347], # List of location_ids for row 2 - ... [3456], # Single location_id for row 3 (no need to split) - ... ], - ... "mean": [0.2, 0.3, 0.4], - ... "std_err": [0.01, 0.02, 0.03], - ... } - ... ) - - >>> # Configuration - >>> data_config = CatDataConfig( - ... index=["study_id", "year_id"], # Columns to be used as index - ... cat_group="location_id", # Categorical group column for splitting - ... val="mean", # Observed value column - ... val_sd="std_err", # Standard deviation of the observed value - ... ) - - >>> # Accessing required columns - >>> required_columns = data_config.columns - >>> print(required_columns) - ['study_id', 'year_id', 'location_id', 'mean', 'std_err'] - - >>> # Accessing value fields - >>> value_fields = data_config.val_fields - >>> print(value_fields) - ['mean', 'std_err'] """ index: List[str] @@ -113,151 +29,63 @@ class CatDataConfig(Schema): val: str val_sd: str + @property + def columns(self) -> List[str]: + return self.index + [self.cat_group, self.val, self.val_sd] + + @property + def val_fields(self) -> List[str]: + return [self.val, self.val_sd] + class CatPatternConfig(Schema): """ Configuration schema for the pattern DataFrame. - - This class defines the configuration parameters required to process - a categorical pattern dataset represented as a pandas DataFrame. It specifies - which columns to use as indices, the categorical group for splitting, - observed mean values, their standard deviations, and additional draw columns - if applicable. - - Parameters - ---------- - index : List[str] - A list of column names to be used as the index in the pattern DataFrame. - These columns uniquely identify each pattern entry in the dataset. - cat : str - The name of the column that represents the categorical group used for - splitting the data. This column typically contains categorical or - grouping information. - draws : List[str], optional - A list of column names representing draw data, used for uncertainty - quantification or simulation purposes. Defaults to an empty list. - val : str, optional - The name of the column that contains the observed mean value for each - pattern entry. This could be a measurement or a metric of interest. - Defaults to `'mean'`. - val_sd : str, optional - The name of the column that contains the standard deviation of the - observed mean value, representing the uncertainty or variability - associated with the `val` column. Defaults to `'std_err'`. - prefix : str, optional - A prefix to apply to column names when merging this pattern DataFrame - with other DataFrames. This helps in distinguishing columns from different - sources. Defaults to `'cat_pat_'`. - - Attributes - ---------- - index : List[str] - As described in Parameters. - cat : str - As described in Parameters. - draws : List[str] - As described in Parameters. - val : str - As described in Parameters. - val_sd : str - As described in Parameters. - prefix : str - As described in Parameters. - - Properties - ---------- - columns : List[str] - A list of all required column names in the pattern DataFrame, - including index columns, categorical group, value, and standard deviation columns. - - val_fields : List[str] - A list containing the value fields, specifically the `val` and `val_sd` columns. - - Examples - -------- - Creating a configuration for a sample Pattern DataFrame: - - >>> import pandas as pd - >>> import numpy as np - >>> from your_module import CatPatternConfig # Replace with actual module name - - >>> # Sample Pattern DataFrame - >>> all_location_ids = [ - ... 1234, 1235, 1236, 2345, 2346, - ... 2347, 3456, 4567, 5678 - ... ] - >>> data_pattern = pd.DataFrame( - ... { - ... "location_id": all_location_ids, - ... "year_id": [2010] * len(all_location_ids), - ... "mean": np.random.uniform(0.1, 0.5, len(all_location_ids)), - ... "std_err": np.random.uniform(0.01, 0.05, len(all_location_ids)), - ... } - ... ) - - >>> # Configuration - >>> pattern_config = CatPatternConfig( - ... index=["year_id"], # Columns to be used as index - ... cat="location_id", # Categorical group column for splitting - ... draws=[], # No draw columns in this example - ... val="mean", # Observed mean value column - ... val_sd="std_err", # Standard deviation of the observed mean value - ... prefix="cat_pat_" # Prefix for merging - ... ) - - >>> # Accessing required columns - >>> required_columns = pattern_config.columns - >>> print(required_columns) - ['year_id', 'location_id', 'mean', 'std_err'] - - >>> # Accessing value fields - >>> value_fields = pattern_config.val_fields - >>> print(value_fields) - ['mean', 'std_err'] """ - index: List[str] + by: List[str] cat: str draws: List[str] = [] val: str = "mean" val_sd: str = "std_err" prefix: str = "cat_pat_" + @property + def index(self) -> List[str]: + return self.by + [self.cat] + + @property + def columns(self) -> List[str]: + return self.index + self.val_fields + self.draws + + @property + def val_fields(self) -> List[str]: + return [self.val, self.val_sd] + + def apply_prefix(self) -> dict: + return {col: f"{self.prefix}{col}" for col in self.val_fields} + class CatPopulationConfig(Schema): """ Configuration for the population DataFrame. - - Parameters - ---------- - index : List[str] - List of column names to be used as index in the population DataFrame. - target : str - Column name representing the target variable to split. - val : str - Column name for the population value. - prefix : str, optional - Prefix to apply to column names when merging, by default 'cat_pop_'. """ index: List[str] - # target: str val: str prefix: str = "cat_pop_" + @property + def columns(self) -> List[str]: + return self.index + [self.val] + + def apply_prefix(self) -> dict: + return {self.val: f"{self.prefix}{self.val}"} + class CatSplitter(BaseModel): """ Class for splitting categorical data based on pattern and population data. - - Parameters - ---------- - data : CatDataConfig - Configuration for the data DataFrame. - pattern : CatPatternConfig - Configuration for the pattern DataFrame. - population : CatPopulationConfig - Configuration for the population DataFrame. """ data: CatDataConfig @@ -267,11 +95,6 @@ class CatSplitter(BaseModel): def model_post_init(self, __context: Any) -> None: """ Perform extra validation after model initialization. - - Raises - ------ - ValueError - If the match criteria in the pattern or population do not match the data. """ if not set(self.pattern.index).issubset(self.data.index): raise ValueError( @@ -283,75 +106,34 @@ def model_post_init(self, __context: Any) -> None: raise ValueError( "Meow! The population's match criteria must be a subset of the data and the pattern. Purrrhaps take a closer look?" ) - # NOTE: This doesn't have to be true, as long as the values contained within the group are present in the pattern - if self.pattern.cat != self.data.cat_group: - raise ValueError( - "Hiss! The 'target' column in the pattern doesn't match the 'target' column in the data. Meow over it again." - ) - if self.data.cat_group not in self.population.index: + if self.pattern.cat not in self.population.index: raise ValueError( "Meow! The 'target' column in the population must match the 'target' column in the data. Purr-fect that before proceeding!" ) - # def create_ref_return_df(self, data: DataFrame) -> tuple[DataFrame, DataFrame]: - # """ - # Create reference and return DataFrames. - - # Parameters - # ---------- - # data : DataFrame - # The input data DataFrame. - - # Returns - # ------- - # tuple[DataFrame, DataFrame] - # A tuple containing: - # - ref_df: DataFrame with original data, exploded if necessary. - # - data: DataFrame with required columns and identifiers. - # """ - # ref_df = data.copy() - # ref_df["orig_pyd_id"] = range( - # len(ref_df) - # ) # Assign original pyd_id before exploding - # ref_df["orig_group"] = ref_df[self.data.cat_group] - # # Explode the 'target' column if it contains lists - # if ref_df[self.data.cat_group].apply(lambda x: isinstance(x, list)).any(): - # ref_df = ref_df.explode(self.data.cat_group).reset_index(drop=True) - # # Assign new pyd_id's after exploding - # ref_df["pyd_id"] = range(len(ref_df)) - # return ref_df, ref_df[self.data.columns + ["pyd_id", "orig_pyd_id"]] - - def parse_data(self, data: DataFrame) -> DataFrame: + def parse_data(self, data: DataFrame, positive_strict: bool) -> DataFrame: """ Parse and validate the input data DataFrame. - - Parameters - ---------- - data : DataFrame - The input data DataFrame. - - Returns - ------- - DataFrame - Validated and possibly modified data DataFrame. - - Raises - ------ - KeyError - If required columns are missing. - ValueError - If there are duplicate indices, NaN values, or non-positive values. """ name = "While parsing data" - # Validate core columns first + # Validate required columns validate_columns(data, self.data.columns, name) - validate_index(data, self.data.index + [self.data.cat_group], name) + # Explode the 'cat_group' column and rename it to match the pattern's 'cat' + data = data.explode(self.data.cat_group).rename( + columns={self.data.cat_group: self.pattern.cat} + ) - validate_nonan(data, name) + # Ensure 'cat' column is of the correct type (e.g., int) + data[self.pattern.cat] = data[self.pattern.cat].astype(int) - validate_positive(data, [self.data.val, self.data.val_sd], name) + # Validate index after exploding + validate_index(data, self.data.index, name) + validate_nonan(data, name) + validate_positive( + data, [self.data.val_sd], name, strict=positive_strict + ) return data @@ -359,33 +141,24 @@ def _merge_with_pattern( self, data: DataFrame, pattern: DataFrame, - how: Literal["left", "right", "outer", "inner"], ) -> DataFrame: """ Merge data with pattern DataFrame. - - Parameters - ---------- - data : DataFrame - The data DataFrame. - pattern : DataFrame - The pattern DataFrame. - how : {'inner', 'left', 'right', 'outer'} - Merge method. - - Returns - ------- - DataFrame - Merged DataFrame after merging with pattern. """ - merge_keys = self.pattern.index + [self.pattern.cat] - val_fields = [ - self.pattern.apply_prefix()[self.pattern.val], - self.pattern.apply_prefix()[self.pattern.val_sd], - ] - data_with_pattern = data.merge(pattern, on=merge_keys, how=how).dropna( - subset=val_fields + data_with_pattern = data.merge( + pattern, on=self.pattern.index, how="left" ) + + validate_nonan( + data_with_pattern[ + [ + f"{self.pattern.prefix}{col}" + for col in self.pattern.val_fields + ] + ], + "After merging with pattern, there were NaN values created. This indicates that your pattern does not cover all the data.", + ) + return data_with_pattern def parse_pattern( @@ -393,32 +166,11 @@ def parse_pattern( ) -> DataFrame: """ Parse and merge the pattern DataFrame with data. - - Parameters - ---------- - data : DataFrame - The data DataFrame. - pattern : DataFrame - The pattern DataFrame. - model : str - The model type ('rate' or 'logodds'). - - Returns - ------- - DataFrame - DataFrame after merging with pattern data. - - Raises - ------ - KeyError - If required columns are missing in pattern. - ValueError - If necessary columns or draws are not provided. """ name = "While parsing pattern" try: - val_cols = [self.pattern.val, self.pattern.val_sd] + val_cols = self.pattern.val_fields if not all(col in pattern.columns for col in val_cols): if not self.pattern.draws: raise ValueError( @@ -426,108 +178,75 @@ def parse_pattern( "pattern.val_sd are not available." ) validate_columns(pattern, self.pattern.draws, name) - pattern[self.pattern.val] = pattern[self.pattern.draws].mean(axis=1) - pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std(axis=1) + pattern[self.pattern.val] = pattern[self.pattern.draws].mean( + axis=1 + ) + pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std( + axis=1 + ) validate_columns(pattern, self.pattern.columns, name) except KeyError as e: - raise KeyError(f"{name}: Missing columns in the pattern. Details:\n{e}") + raise KeyError( + f"{name}: Missing columns in the pattern. Details:\n{e}" + ) pattern_copy = pattern.copy() - pattern_copy = pattern_copy[self.pattern.columns] - rename_map = self.pattern.apply_prefix() - pattern_copy.rename(columns=rename_map, inplace=True) - - # Filter pattern_copy to include only target IDs present in data - data_target_ids = data[self.data.cat_group].unique() pattern_copy = pattern_copy[ - pattern_copy[self.pattern.cat].isin(data_target_ids) + self.pattern.index + self.pattern.val_fields ] + rename_map = self.pattern.apply_prefix() + pattern_copy.rename(columns=rename_map, inplace=True) - # Use an inner join - data_with_pattern = self._merge_with_pattern(data, pattern_copy, how="inner") + # Merge with pattern + data_with_pattern = self._merge_with_pattern(data, pattern_copy) # Validate index differences after merging validate_noindexdiff( data, data_with_pattern, - self.data.index + [self.data.cat_group], + self.data.index, name, ) return data_with_pattern - def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: + def parse_population( + self, data: DataFrame, population: DataFrame + ) -> DataFrame: """ Parse and merge the population DataFrame with data. - - Parameters - ---------- - data : DataFrame - The data DataFrame. - population : DataFrame - The population DataFrame. - - Returns - ------- - DataFrame - DataFrame after merging with population data. - - Raises - ------ - KeyError - If required columns are missing in population. - ValueError - If NaN values are found after merging. """ - name = "While parsing population" - - # Validate population columns - try: - validate_columns(population, self.population.columns, name) - except KeyError as e: - raise KeyError( - f"{name}: Missing columns in the population data. Details:\n{e}" - ) - - # NOTE: Updated to this point + name = "Parsing Population" + validate_columns(population, self.population.columns, name) - # Should we error sooner if a population is missing for the data? - # How should we check instead of looping? + population = population[self.population.columns].copy() - # This isn't right I don't think ... - data_target_ids = data[self.data.cat_group].unique() + validate_index(population, self.population.index, name) + validate_nonan(population, name) - population = population[ - population[self.population.target].isin(data_target_ids) - ] + rename_map = self.population.apply_prefix() + population.rename(columns=rename_map, inplace=True) - # Use an inner join - merge_keys = self.population.index - data_with_population = data.merge( - population, on=merge_keys, how="inner", suffixes=("", "_pop") - ) - - # Validate for NaN values - try: - validate_nonan(data_with_population, name) - except ValueError as e: - raise ValueError( - f"{name}: NaN values found in the population data. Details:\n{e}" - ) + data_with_population = self._merge_with_population(data, population) - # Validate index differences validate_noindexdiff( data, data_with_population, - self.data.index + [self.data.cat_group, "pyd_id"], + self.data.index, name, ) + return data_with_population - # Ensure the population column is numeric - data_with_population[self.population.val] = data_with_population[ - self.population.val - ].astype("float64") + def _merge_with_population( + self, data: DataFrame, population: DataFrame + ) -> DataFrame: + """ + Merge data with population DataFrame. + """ + data_with_population = data.merge( + population, on=self.population.index, how="left" + ) return data_with_population @@ -536,20 +255,6 @@ def _process_group( ) -> DataFrame: """ Process a group of data for splitting. - - Parameters - ---------- - group : DataFrame - The group of data to process. - model : str - The model type ('rate' or 'logodds'). - output_type : str - The output type ('rate' or 'count'). - - Returns - ------- - DataFrame - The processed group with splitting results. """ observed_total = group[self.data.val].iloc[0] observed_total_se = group[self.data.val_sd].iloc[0] @@ -561,9 +266,15 @@ def _process_group( group["split_flag"] = 0 # Not split else: # Need to split among multiple targets - bucket_populations = group[self.population.val].values - rate_pattern = group[self.pattern.apply_prefix()[self.pattern.val]].values - pattern_sd = group[self.pattern.apply_prefix()[self.pattern.val_sd]].values + bucket_populations = group[ + f"{self.population.prefix}{self.population.val}" + ].values + rate_pattern = group[ + f"{self.pattern.prefix}{self.pattern.val}" + ].values + pattern_sd = group[ + f"{self.pattern.prefix}{self.pattern.val_sd}" + ].values pattern_covariance = np.diag(pattern_sd**2) if model == "rate": @@ -600,79 +311,23 @@ def split( population: DataFrame, model: Literal["rate", "logodds"] = "rate", output_type: Literal["rate", "count"] = "rate", - n_jobs: int = -1, # Use all available cores by default - use_parallel: bool = False, # Option to run in parallel ) -> DataFrame: """ Split the input data based on a specified pattern and population model. - - Parameters - ---------- - data : DataFrame - The input data DataFrame. - pattern : DataFrame - The pattern DataFrame. - population : DataFrame - The population DataFrame. - model : {'rate', 'logodds'}, optional - The model to use for splitting, by default 'rate'. - output_type : {'rate', 'count'}, optional - The output type desired, by default 'rate'. - n_jobs : int, optional - Number of jobs for parallel processing, by default -1 (use all available cores). - use_parallel : bool, optional - Whether to use parallel processing, by default True. - - Returns - ------- - DataFrame - DataFrame containing the split results. - - Raises - ------ - ValueError - If validation fails during parsing. """ # Parsing input data, pattern, and population - ref_df, data = self.create_ref_return_df(data) - - # Keep track of columns not used in the analysis - all_columns = ref_df.columns.tolist() - columns_used = self.data.columns + ["pyd_id", "orig_pyd_id"] - columns_not_used = list(set(all_columns) - set(columns_used)) - - data = self.parse_data(data) + data = self.parse_data(data, positive_strict=True) data = self.parse_pattern(data, pattern, model) data = self.parse_population(data, population) - # Process groups - if use_parallel: - # Identify unique 'orig_pyd_id's to process - num_cores = multiprocessing.cpu_count() if n_jobs == -1 else n_jobs - processed_groups = Parallel(n_jobs=num_cores, backend="loky")( - delayed(self._process_group)(group, model, output_type) - for _, group in data.groupby("orig_pyd_id") - ) - - # Concatenate the results - final_split_df = pd.concat(processed_groups, ignore_index=True) - else: - # Process groups using regular groupby - final_split_df = ( - data.groupby("orig_pyd_id", group_keys=False) - .apply(lambda group: self._process_group(group, model, output_type)) - .reset_index(drop=True) - ) - - # Merge back only columns not used in the analysis - if columns_not_used: - final_split_df = final_split_df.merge( - ref_df[["pyd_id"] + columns_not_used], - on="pyd_id", - how="left", - ) + # Determine grouping columns + group_cols = self.data.index[:-1] # Exclude 'location_id' from grouping - # Remove temporary columns - final_split_df.drop(columns=["pyd_id", "orig_pyd_id"], inplace=True) + # Process groups using regular groupby + final_split_df = ( + data.groupby(group_cols, group_keys=False) + .apply(lambda group: self._process_group(group, model, output_type)) + .reset_index(drop=True) + ) return final_split_df diff --git a/src/pydisagg/ihme/validator.py b/src/pydisagg/ihme/validator.py index 2463b16..f6dfe47 100644 --- a/src/pydisagg/ihme/validator.py +++ b/src/pydisagg/ihme/validator.py @@ -290,3 +290,6 @@ def validate_realnumber(df: DataFrame, columns: list[str], name: str) -> None: if invalid: raise ValueError(f"{name} has non-real or zero values in: {invalid}") + + +# TODO def validate_set_uniqueness diff --git a/tests/test_cat_splitter.py b/tests/test_cat_splitter.py index 2b9366a..142cee9 100644 --- a/tests/test_cat_splitter.py +++ b/tests/test_cat_splitter.py @@ -135,7 +135,9 @@ def test_parse_population_missing_columns( parsed_pattern = cat_splitter.parse_pattern( parsed_data, valid_pattern, model="rate" ) - with pytest.raises(KeyError, match="Missing columns in the population data"): + with pytest.raises( + KeyError, match="Missing columns in the population data" + ): cat_splitter.parse_population(parsed_pattern, invalid_population) @@ -161,7 +163,9 @@ def test_parse_population_valid( parsed_pattern = cat_splitter.parse_pattern( parsed_data, valid_pattern, model="rate" ) - parsed_population = cat_splitter.parse_population(parsed_pattern, valid_population) + parsed_population = cat_splitter.parse_population( + parsed_pattern, valid_population + ) assert not parsed_population.empty assert "population" in parsed_population.columns @@ -199,7 +203,9 @@ def test_split_with_invalid_output_type( def test_split_with_missing_population(cat_splitter, valid_data, valid_pattern): """Test that the split method raises an error when population data is missing.""" - with pytest.raises(KeyError, match="Missing columns in the population data"): + with pytest.raises( + KeyError, match="Missing columns in the population data" + ): cat_splitter.split( data=valid_data, pattern=valid_pattern, From 70c6facfb8948f16fb2e244e193cc4504ea2d948 Mon Sep 17 00:00:00 2001 From: saal Date: Wed, 25 Sep 2024 15:53:37 -0700 Subject: [PATCH 14/19] Fixed tests --- src/pydisagg/ihme/splitter/cat_splitter.py | 66 +++++++---------- tests/test_cat_splitter.py | 83 ++++++++++++---------- 2 files changed, 71 insertions(+), 78 deletions(-) diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index 929366d..44fab9d 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -125,15 +125,10 @@ def parse_data(self, data: DataFrame, positive_strict: bool) -> DataFrame: columns={self.data.cat_group: self.pattern.cat} ) - # Ensure 'cat' column is of the correct type (e.g., int) - data[self.pattern.cat] = data[self.pattern.cat].astype(int) - # Validate index after exploding validate_index(data, self.data.index, name) validate_nonan(data, name) - validate_positive( - data, [self.data.val_sd], name, strict=positive_strict - ) + validate_positive(data, [self.data.val_sd], name, strict=positive_strict) return data @@ -145,16 +140,11 @@ def _merge_with_pattern( """ Merge data with pattern DataFrame. """ - data_with_pattern = data.merge( - pattern, on=self.pattern.index, how="left" - ) + data_with_pattern = data.merge(pattern, on=self.pattern.index, how="left") validate_nonan( data_with_pattern[ - [ - f"{self.pattern.prefix}{col}" - for col in self.pattern.val_fields - ] + [f"{self.pattern.prefix}{col}" for col in self.pattern.val_fields] ], "After merging with pattern, there were NaN values created. This indicates that your pattern does not cover all the data.", ) @@ -178,23 +168,15 @@ def parse_pattern( "pattern.val_sd are not available." ) validate_columns(pattern, self.pattern.draws, name) - pattern[self.pattern.val] = pattern[self.pattern.draws].mean( - axis=1 - ) - pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std( - axis=1 - ) + pattern[self.pattern.val] = pattern[self.pattern.draws].mean(axis=1) + pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std(axis=1) validate_columns(pattern, self.pattern.columns, name) except KeyError as e: - raise KeyError( - f"{name}: Missing columns in the pattern. Details:\n{e}" - ) + raise KeyError(f"{name}: Missing columns in the pattern. Details:\n{e}") pattern_copy = pattern.copy() - pattern_copy = pattern_copy[ - self.pattern.index + self.pattern.val_fields - ] + pattern_copy = pattern_copy[self.pattern.index + self.pattern.val_fields] rename_map = self.pattern.apply_prefix() pattern_copy.rename(columns=rename_map, inplace=True) @@ -211,12 +193,7 @@ def parse_pattern( return data_with_pattern - def parse_population( - self, data: DataFrame, population: DataFrame - ) -> DataFrame: - """ - Parse and merge the population DataFrame with data. - """ + def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: name = "Parsing Population" validate_columns(population, self.population.columns, name) @@ -230,11 +207,14 @@ def parse_population( data_with_population = self._merge_with_population(data, population) - validate_noindexdiff( - data, - data_with_population, - self.data.index, - name, + # Ensure the prefixed population column exists + pop_col = f"{self.population.prefix}{self.population.val}" + if pop_col not in data_with_population.columns: + raise KeyError(f"Expected column '{pop_col}' not found in merged data.") + + validate_nonan( + data_with_population[[pop_col]], + "After merging with population, there were NaN values created. This indicates that your population data does not cover all the data.", ) return data_with_population @@ -269,12 +249,8 @@ def _process_group( bucket_populations = group[ f"{self.population.prefix}{self.population.val}" ].values - rate_pattern = group[ - f"{self.pattern.prefix}{self.pattern.val}" - ].values - pattern_sd = group[ - f"{self.pattern.prefix}{self.pattern.val_sd}" - ].values + rate_pattern = group[f"{self.pattern.prefix}{self.pattern.val}"].values + pattern_sd = group[f"{self.pattern.prefix}{self.pattern.val_sd}"].values pattern_covariance = np.diag(pattern_sd**2) if model == "rate": @@ -315,6 +291,12 @@ def split( """ Split the input data based on a specified pattern and population model. """ + # Validate model and output_type + if model not in ["rate", "logodds"]: + raise ValueError(f"Invalid model: {model}") + if output_type not in ["rate", "count"]: + raise ValueError(f"Invalid output_type: {output_type}") + # Parsing input data, pattern, and population data = self.parse_data(data, positive_strict=True) data = self.parse_pattern(data, pattern, model) diff --git a/tests/test_cat_splitter.py b/tests/test_cat_splitter.py index 142cee9..467e62b 100644 --- a/tests/test_cat_splitter.py +++ b/tests/test_cat_splitter.py @@ -13,8 +13,13 @@ @pytest.fixture def cat_data_config(): return CatDataConfig( - index=["study_id", "year_id", "location_id"], - target="sub_category", # Updated from sub_target to target + index=[ + "study_id", + "year_id", + "location_id", + "sub_category", + ], # Include 'sub_category' in index + cat_group="sub_category", val="val", val_sd="val_sd", ) @@ -23,8 +28,8 @@ def cat_data_config(): @pytest.fixture def cat_pattern_config(): return CatPatternConfig( - index=["year_id", "location_id"], - target="sub_category", # Updated from sub_target to target + by=["year_id", "location_id"], + cat="sub_category", val="pattern_val", val_sd="pattern_val_sd", ) @@ -33,8 +38,7 @@ def cat_pattern_config(): @pytest.fixture def cat_population_config(): return CatPopulationConfig( - index=["year_id", "location_id"], - target="sub_category", # Updated from sub_target to target + index=["year_id", "location_id", "sub_category"], val="population", ) @@ -47,7 +51,7 @@ def valid_data(): "year_id": [2000, 2000, 2001], "location_id": [10, 20, 10], "sub_category": [ - ["A1", "A2"], # Assuming sub_category is a list + ["A1", "A2"], # List of sub_categories ["B1", "B2"], ["C1", "C2"], ], @@ -97,13 +101,13 @@ def cat_splitter(cat_data_config, cat_pattern_config, cat_population_config): def test_parse_data_duplicated_index(cat_splitter, valid_data): """Test parse_data raises an error on duplicated index.""" duplicated_data = pd.concat([valid_data, valid_data]) - with pytest.raises(ValueError, match="Duplicated index found"): - cat_splitter.parse_data(duplicated_data) + with pytest.raises(ValueError, match="has duplicated index"): + cat_splitter.parse_data(duplicated_data, positive_strict=True) def test_parse_data_valid(cat_splitter, valid_data): """Test that parse_data works correctly on valid data.""" - parsed_data = cat_splitter.parse_data(valid_data) + parsed_data = cat_splitter.parse_data(valid_data, positive_strict=True) assert not parsed_data.empty assert "val" in parsed_data.columns assert "val_sd" in parsed_data.columns @@ -114,13 +118,20 @@ def test_parse_data_valid(cat_splitter, valid_data): def test_parse_pattern_valid(cat_splitter, valid_data, valid_pattern): """Test that parse_pattern works correctly on valid data.""" - parsed_data = cat_splitter.parse_data(valid_data) + parsed_data = cat_splitter.parse_data(valid_data, positive_strict=True) parsed_pattern = cat_splitter.parse_pattern( parsed_data, valid_pattern, model="rate" ) assert not parsed_pattern.empty - assert "cat_pat_pattern_val" in parsed_pattern.columns - assert "cat_pat_pattern_val_sd" in parsed_pattern.columns + # The pattern columns are renamed with prefix 'cat_pat_' + assert ( + f"{cat_splitter.pattern.prefix}{cat_splitter.pattern.val}" + in parsed_pattern.columns + ) + assert ( + f"{cat_splitter.pattern.prefix}{cat_splitter.pattern.val_sd}" + in parsed_pattern.columns + ) # Step 4: Write Tests for parse_population @@ -131,13 +142,11 @@ def test_parse_population_missing_columns( ): """Test parse_population raises an error when population columns are missing.""" invalid_population = valid_population.drop(columns=["population"]) - parsed_data = cat_splitter.parse_data(valid_data) + parsed_data = cat_splitter.parse_data(valid_data, positive_strict=True) parsed_pattern = cat_splitter.parse_pattern( parsed_data, valid_pattern, model="rate" ) - with pytest.raises( - KeyError, match="Missing columns in the population data" - ): + with pytest.raises(KeyError, match="has missing columns"): cat_splitter.parse_population(parsed_pattern, invalid_population) @@ -147,11 +156,11 @@ def test_parse_population_with_nan( """Test parse_population raises an error when there are NaN values.""" invalid_population = valid_population.copy() invalid_population.loc[0, "population"] = None - parsed_data = cat_splitter.parse_data(valid_data) + parsed_data = cat_splitter.parse_data(valid_data, positive_strict=True) parsed_pattern = cat_splitter.parse_pattern( parsed_data, valid_pattern, model="rate" ) - with pytest.raises(ValueError, match="NaN values found"): + with pytest.raises(ValueError, match="has NaN values"): cat_splitter.parse_population(parsed_pattern, invalid_population) @@ -159,15 +168,15 @@ def test_parse_population_valid( cat_splitter, valid_data, valid_pattern, valid_population ): """Test that parse_population works correctly on valid data.""" - parsed_data = cat_splitter.parse_data(valid_data) + parsed_data = cat_splitter.parse_data(valid_data, positive_strict=True) parsed_pattern = cat_splitter.parse_pattern( parsed_data, valid_pattern, model="rate" ) - parsed_population = cat_splitter.parse_population( - parsed_pattern, valid_population - ) + parsed_population = cat_splitter.parse_population(parsed_pattern, valid_population) assert not parsed_population.empty - assert "population" in parsed_population.columns + # The population column is renamed with prefix 'cat_pop_' + pop_col = f"{cat_splitter.population.prefix}{cat_splitter.population.val}" + assert pop_col in parsed_population.columns # Step 5: Write Tests for the split method @@ -191,7 +200,7 @@ def test_split_with_invalid_output_type( cat_splitter, valid_data, valid_pattern, valid_population ): """Test that the split method raises an error with an invalid output_type.""" - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid output_type"): cat_splitter.split( data=valid_data, pattern=valid_pattern, @@ -203,9 +212,7 @@ def test_split_with_invalid_output_type( def test_split_with_missing_population(cat_splitter, valid_data, valid_pattern): """Test that the split method raises an error when population data is missing.""" - with pytest.raises( - KeyError, match="Missing columns in the population data" - ): + with pytest.raises(KeyError, match="Parsing Population has missing columns"): cat_splitter.split( data=valid_data, pattern=valid_pattern, @@ -215,15 +222,19 @@ def test_split_with_missing_population(cat_splitter, valid_data, valid_pattern): ) -def test_split_with_non_matching_targets( +def test_split_with_non_matching_categories( cat_splitter, valid_data, valid_pattern, valid_population ): - """Test that the split method raises an error when targets don't match.""" + """Test that the split method raises an error when categories don't match.""" invalid_population = valid_population.copy() invalid_population["sub_category"] = ["X1", "X2", "X1", "X2", "X1", "X2"] - parsed_data = cat_splitter.parse_data(valid_data) - parsed_pattern = cat_splitter.parse_pattern( - parsed_data, valid_pattern, model="rate" - ) - with pytest.raises(ValueError, match="NaN values found"): - cat_splitter.parse_population(parsed_pattern, invalid_population) + with pytest.raises( + ValueError, match="After merging with population, there were NaN values created" + ): + cat_splitter.split( + data=valid_data, + pattern=valid_pattern, + population=invalid_population, + model="rate", + output_type="rate", + ) From 32656df41a0ab79c72c54a23ec4cab907481e034 Mon Sep 17 00:00:00 2001 From: saal Date: Wed, 25 Sep 2024 15:53:52 -0700 Subject: [PATCH 15/19] Fixed tests --- src/pydisagg/ihme/splitter/cat_splitter.py | 45 ++++++++++++++++------ tests/test_cat_splitter.py | 11 ++++-- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index 44fab9d..b8d3515 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -128,7 +128,9 @@ def parse_data(self, data: DataFrame, positive_strict: bool) -> DataFrame: # Validate index after exploding validate_index(data, self.data.index, name) validate_nonan(data, name) - validate_positive(data, [self.data.val_sd], name, strict=positive_strict) + validate_positive( + data, [self.data.val_sd], name, strict=positive_strict + ) return data @@ -140,11 +142,16 @@ def _merge_with_pattern( """ Merge data with pattern DataFrame. """ - data_with_pattern = data.merge(pattern, on=self.pattern.index, how="left") + data_with_pattern = data.merge( + pattern, on=self.pattern.index, how="left" + ) validate_nonan( data_with_pattern[ - [f"{self.pattern.prefix}{col}" for col in self.pattern.val_fields] + [ + f"{self.pattern.prefix}{col}" + for col in self.pattern.val_fields + ] ], "After merging with pattern, there were NaN values created. This indicates that your pattern does not cover all the data.", ) @@ -168,15 +175,23 @@ def parse_pattern( "pattern.val_sd are not available." ) validate_columns(pattern, self.pattern.draws, name) - pattern[self.pattern.val] = pattern[self.pattern.draws].mean(axis=1) - pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std(axis=1) + pattern[self.pattern.val] = pattern[self.pattern.draws].mean( + axis=1 + ) + pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std( + axis=1 + ) validate_columns(pattern, self.pattern.columns, name) except KeyError as e: - raise KeyError(f"{name}: Missing columns in the pattern. Details:\n{e}") + raise KeyError( + f"{name}: Missing columns in the pattern. Details:\n{e}" + ) pattern_copy = pattern.copy() - pattern_copy = pattern_copy[self.pattern.index + self.pattern.val_fields] + pattern_copy = pattern_copy[ + self.pattern.index + self.pattern.val_fields + ] rename_map = self.pattern.apply_prefix() pattern_copy.rename(columns=rename_map, inplace=True) @@ -193,7 +208,9 @@ def parse_pattern( return data_with_pattern - def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: + def parse_population( + self, data: DataFrame, population: DataFrame + ) -> DataFrame: name = "Parsing Population" validate_columns(population, self.population.columns, name) @@ -210,7 +227,9 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: # Ensure the prefixed population column exists pop_col = f"{self.population.prefix}{self.population.val}" if pop_col not in data_with_population.columns: - raise KeyError(f"Expected column '{pop_col}' not found in merged data.") + raise KeyError( + f"Expected column '{pop_col}' not found in merged data." + ) validate_nonan( data_with_population[[pop_col]], @@ -249,8 +268,12 @@ def _process_group( bucket_populations = group[ f"{self.population.prefix}{self.population.val}" ].values - rate_pattern = group[f"{self.pattern.prefix}{self.pattern.val}"].values - pattern_sd = group[f"{self.pattern.prefix}{self.pattern.val_sd}"].values + rate_pattern = group[ + f"{self.pattern.prefix}{self.pattern.val}" + ].values + pattern_sd = group[ + f"{self.pattern.prefix}{self.pattern.val_sd}" + ].values pattern_covariance = np.diag(pattern_sd**2) if model == "rate": diff --git a/tests/test_cat_splitter.py b/tests/test_cat_splitter.py index 467e62b..a2228f5 100644 --- a/tests/test_cat_splitter.py +++ b/tests/test_cat_splitter.py @@ -172,7 +172,9 @@ def test_parse_population_valid( parsed_pattern = cat_splitter.parse_pattern( parsed_data, valid_pattern, model="rate" ) - parsed_population = cat_splitter.parse_population(parsed_pattern, valid_population) + parsed_population = cat_splitter.parse_population( + parsed_pattern, valid_population + ) assert not parsed_population.empty # The population column is renamed with prefix 'cat_pop_' pop_col = f"{cat_splitter.population.prefix}{cat_splitter.population.val}" @@ -212,7 +214,9 @@ def test_split_with_invalid_output_type( def test_split_with_missing_population(cat_splitter, valid_data, valid_pattern): """Test that the split method raises an error when population data is missing.""" - with pytest.raises(KeyError, match="Parsing Population has missing columns"): + with pytest.raises( + KeyError, match="Parsing Population has missing columns" + ): cat_splitter.split( data=valid_data, pattern=valid_pattern, @@ -229,7 +233,8 @@ def test_split_with_non_matching_categories( invalid_population = valid_population.copy() invalid_population["sub_category"] = ["X1", "X2", "X1", "X2", "X1", "X2"] with pytest.raises( - ValueError, match="After merging with population, there were NaN values created" + ValueError, + match="After merging with population, there were NaN values created", ): cat_splitter.split( data=valid_data, From 03ad312d6d17b2e0f0adac7e41a033709091bc8b Mon Sep 17 00:00:00 2001 From: saal Date: Fri, 4 Oct 2024 10:05:10 -0700 Subject: [PATCH 16/19] Soloved prefix issue --- src/pydisagg/ihme/splitter/cat_splitter.py | 75 +++++++++------------- 1 file changed, 29 insertions(+), 46 deletions(-) diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index b8d3515..e9d63b7 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -1,11 +1,10 @@ # cat_splitter.py -from typing import Any, List +from typing import Any, List, Literal import numpy as np import pandas as pd from pandas import DataFrame from pydantic import BaseModel -from typing import Literal from pydisagg.disaggregate import split_datapoint from pydisagg.models import RateMultiplicativeModel, LogOddsModel @@ -62,9 +61,6 @@ def columns(self) -> List[str]: def val_fields(self) -> List[str]: return [self.val, self.val_sd] - def apply_prefix(self) -> dict: - return {col: f"{self.prefix}{col}" for col in self.val_fields} - class CatPopulationConfig(Schema): """ @@ -79,8 +75,9 @@ class CatPopulationConfig(Schema): def columns(self) -> List[str]: return self.index + [self.val] - def apply_prefix(self) -> dict: - return {self.val: f"{self.prefix}{self.val}"} + @property + def val_fields(self) -> List[str]: + return [self.val] class CatSplitter(BaseModel): @@ -98,17 +95,17 @@ def model_post_init(self, __context: Any) -> None: """ if not set(self.pattern.index).issubset(self.data.index): raise ValueError( - "Meow! The pattern's match criteria must be a subset of the data. Purrrlease check your input." + "The pattern's match criteria must be a subset of the data." ) if not set(self.population.index).issubset( self.data.index + self.pattern.index ): raise ValueError( - "Meow! The population's match criteria must be a subset of the data and the pattern. Purrrhaps take a closer look?" + "The population's match criteria must be a subset of the data and the pattern." ) if self.pattern.cat not in self.population.index: raise ValueError( - "Meow! The 'target' column in the population must match the 'target' column in the data. Purr-fect that before proceeding!" + "The 'target' column in the population must match the 'target' column in the data." ) def parse_data(self, data: DataFrame, positive_strict: bool) -> DataFrame: @@ -128,9 +125,7 @@ def parse_data(self, data: DataFrame, positive_strict: bool) -> DataFrame: # Validate index after exploding validate_index(data, self.data.index, name) validate_nonan(data, name) - validate_positive( - data, [self.data.val_sd], name, strict=positive_strict - ) + validate_positive(data, [self.data.val_sd], name, strict=positive_strict) return data @@ -142,16 +137,11 @@ def _merge_with_pattern( """ Merge data with pattern DataFrame. """ - data_with_pattern = data.merge( - pattern, on=self.pattern.index, how="left" - ) + data_with_pattern = data.merge(pattern, on=self.pattern.index, how="left") validate_nonan( data_with_pattern[ - [ - f"{self.pattern.prefix}{col}" - for col in self.pattern.val_fields - ] + [f"{self.pattern.prefix}{col}" for col in self.pattern.val_fields] ], "After merging with pattern, there were NaN values created. This indicates that your pattern does not cover all the data.", ) @@ -175,24 +165,18 @@ def parse_pattern( "pattern.val_sd are not available." ) validate_columns(pattern, self.pattern.draws, name) - pattern[self.pattern.val] = pattern[self.pattern.draws].mean( - axis=1 - ) - pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std( - axis=1 - ) + pattern[self.pattern.val] = pattern[self.pattern.draws].mean(axis=1) + pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std(axis=1) validate_columns(pattern, self.pattern.columns, name) except KeyError as e: - raise KeyError( - f"{name}: Missing columns in the pattern. Details:\n{e}" - ) + raise KeyError(f"{name}: Missing columns in the pattern. Details:\n{e}") pattern_copy = pattern.copy() - pattern_copy = pattern_copy[ - self.pattern.index + self.pattern.val_fields - ] - rename_map = self.pattern.apply_prefix() + pattern_copy = pattern_copy[self.pattern.index + self.pattern.val_fields] + rename_map = { + col: f"{self.pattern.prefix}{col}" for col in self.pattern.val_fields + } pattern_copy.rename(columns=rename_map, inplace=True) # Merge with pattern @@ -208,9 +192,7 @@ def parse_pattern( return data_with_pattern - def parse_population( - self, data: DataFrame, population: DataFrame - ) -> DataFrame: + def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: name = "Parsing Population" validate_columns(population, self.population.columns, name) @@ -219,7 +201,9 @@ def parse_population( validate_index(population, self.population.index, name) validate_nonan(population, name) - rename_map = self.population.apply_prefix() + rename_map = { + self.population.val: f"{self.population.prefix}{self.population.val}" + } population.rename(columns=rename_map, inplace=True) data_with_population = self._merge_with_population(data, population) @@ -227,9 +211,7 @@ def parse_population( # Ensure the prefixed population column exists pop_col = f"{self.population.prefix}{self.population.val}" if pop_col not in data_with_population.columns: - raise KeyError( - f"Expected column '{pop_col}' not found in merged data." - ) + raise KeyError(f"Expected column '{pop_col}' not found in merged data.") validate_nonan( data_with_population[[pop_col]], @@ -268,12 +250,8 @@ def _process_group( bucket_populations = group[ f"{self.population.prefix}{self.population.val}" ].values - rate_pattern = group[ - f"{self.pattern.prefix}{self.pattern.val}" - ].values - pattern_sd = group[ - f"{self.pattern.prefix}{self.pattern.val_sd}" - ].values + rate_pattern = group[f"{self.pattern.prefix}{self.pattern.val}"].values + pattern_sd = group[f"{self.pattern.prefix}{self.pattern.val_sd}"].values pattern_covariance = np.diag(pattern_sd**2) if model == "rate": @@ -320,6 +298,11 @@ def split( if output_type not in ["rate", "count"]: raise ValueError(f"Invalid output_type: {output_type}") + if self.population.prefix_status == "prefixed": + self.population.remove_prefix() + if self.pattern.prefix_status == "prefixed": + self.pattern.remove_prefix() + # Parsing input data, pattern, and population data = self.parse_data(data, positive_strict=True) data = self.parse_pattern(data, pattern, model) From 3703807cbf1989c245d57b10bd0a2cad003a0375 Mon Sep 17 00:00:00 2001 From: saal Date: Fri, 4 Oct 2024 10:05:26 -0700 Subject: [PATCH 17/19] Soloved prefix issue --- src/pydisagg/ihme/splitter/cat_splitter.py | 48 ++++++++++++++++------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index e9d63b7..c08a3f2 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -125,7 +125,9 @@ def parse_data(self, data: DataFrame, positive_strict: bool) -> DataFrame: # Validate index after exploding validate_index(data, self.data.index, name) validate_nonan(data, name) - validate_positive(data, [self.data.val_sd], name, strict=positive_strict) + validate_positive( + data, [self.data.val_sd], name, strict=positive_strict + ) return data @@ -137,11 +139,16 @@ def _merge_with_pattern( """ Merge data with pattern DataFrame. """ - data_with_pattern = data.merge(pattern, on=self.pattern.index, how="left") + data_with_pattern = data.merge( + pattern, on=self.pattern.index, how="left" + ) validate_nonan( data_with_pattern[ - [f"{self.pattern.prefix}{col}" for col in self.pattern.val_fields] + [ + f"{self.pattern.prefix}{col}" + for col in self.pattern.val_fields + ] ], "After merging with pattern, there were NaN values created. This indicates that your pattern does not cover all the data.", ) @@ -165,17 +172,26 @@ def parse_pattern( "pattern.val_sd are not available." ) validate_columns(pattern, self.pattern.draws, name) - pattern[self.pattern.val] = pattern[self.pattern.draws].mean(axis=1) - pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std(axis=1) + pattern[self.pattern.val] = pattern[self.pattern.draws].mean( + axis=1 + ) + pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std( + axis=1 + ) validate_columns(pattern, self.pattern.columns, name) except KeyError as e: - raise KeyError(f"{name}: Missing columns in the pattern. Details:\n{e}") + raise KeyError( + f"{name}: Missing columns in the pattern. Details:\n{e}" + ) pattern_copy = pattern.copy() - pattern_copy = pattern_copy[self.pattern.index + self.pattern.val_fields] + pattern_copy = pattern_copy[ + self.pattern.index + self.pattern.val_fields + ] rename_map = { - col: f"{self.pattern.prefix}{col}" for col in self.pattern.val_fields + col: f"{self.pattern.prefix}{col}" + for col in self.pattern.val_fields } pattern_copy.rename(columns=rename_map, inplace=True) @@ -192,7 +208,9 @@ def parse_pattern( return data_with_pattern - def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: + def parse_population( + self, data: DataFrame, population: DataFrame + ) -> DataFrame: name = "Parsing Population" validate_columns(population, self.population.columns, name) @@ -211,7 +229,9 @@ def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: # Ensure the prefixed population column exists pop_col = f"{self.population.prefix}{self.population.val}" if pop_col not in data_with_population.columns: - raise KeyError(f"Expected column '{pop_col}' not found in merged data.") + raise KeyError( + f"Expected column '{pop_col}' not found in merged data." + ) validate_nonan( data_with_population[[pop_col]], @@ -250,8 +270,12 @@ def _process_group( bucket_populations = group[ f"{self.population.prefix}{self.population.val}" ].values - rate_pattern = group[f"{self.pattern.prefix}{self.pattern.val}"].values - pattern_sd = group[f"{self.pattern.prefix}{self.pattern.val_sd}"].values + rate_pattern = group[ + f"{self.pattern.prefix}{self.pattern.val}" + ].values + pattern_sd = group[ + f"{self.pattern.prefix}{self.pattern.val_sd}" + ].values pattern_covariance = np.diag(pattern_sd**2) if model == "rate": From 9f004214c68a83bf4000ba90ca64016745d0c39f Mon Sep 17 00:00:00 2001 From: saal Date: Tue, 8 Oct 2024 12:44:56 -0700 Subject: [PATCH 18/19] Added set validation and type conversion to lists --- src/pydisagg/ihme/splitter/cat_splitter.py | 57 ++++++++-------------- src/pydisagg/ihme/validator.py | 40 ++++++++++----- 2 files changed, 50 insertions(+), 47 deletions(-) diff --git a/src/pydisagg/ihme/splitter/cat_splitter.py b/src/pydisagg/ihme/splitter/cat_splitter.py index c08a3f2..2ee7545 100644 --- a/src/pydisagg/ihme/splitter/cat_splitter.py +++ b/src/pydisagg/ihme/splitter/cat_splitter.py @@ -15,6 +15,7 @@ validate_noindexdiff, validate_nonan, validate_positive, + validate_set_uniqueness, ) @@ -117,6 +118,14 @@ def parse_data(self, data: DataFrame, positive_strict: bool) -> DataFrame: # Validate required columns validate_columns(data, self.data.columns, name) + # Ensure that 'cat_group' column contains lists + data[self.data.cat_group] = data[self.data.cat_group].apply( + lambda x: x if isinstance(x, list) else [x] + ) + + # Validate that every list in 'cat_group' contains unique elements + validate_set_uniqueness(data, self.data.cat_group, name) + # Explode the 'cat_group' column and rename it to match the pattern's 'cat' data = data.explode(self.data.cat_group).rename( columns={self.data.cat_group: self.pattern.cat} @@ -125,9 +134,7 @@ def parse_data(self, data: DataFrame, positive_strict: bool) -> DataFrame: # Validate index after exploding validate_index(data, self.data.index, name) validate_nonan(data, name) - validate_positive( - data, [self.data.val_sd], name, strict=positive_strict - ) + validate_positive(data, [self.data.val_sd], name, strict=positive_strict) return data @@ -139,16 +146,11 @@ def _merge_with_pattern( """ Merge data with pattern DataFrame. """ - data_with_pattern = data.merge( - pattern, on=self.pattern.index, how="left" - ) + data_with_pattern = data.merge(pattern, on=self.pattern.index, how="left") validate_nonan( data_with_pattern[ - [ - f"{self.pattern.prefix}{col}" - for col in self.pattern.val_fields - ] + [f"{self.pattern.prefix}{col}" for col in self.pattern.val_fields] ], "After merging with pattern, there were NaN values created. This indicates that your pattern does not cover all the data.", ) @@ -172,26 +174,17 @@ def parse_pattern( "pattern.val_sd are not available." ) validate_columns(pattern, self.pattern.draws, name) - pattern[self.pattern.val] = pattern[self.pattern.draws].mean( - axis=1 - ) - pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std( - axis=1 - ) + pattern[self.pattern.val] = pattern[self.pattern.draws].mean(axis=1) + pattern[self.pattern.val_sd] = pattern[self.pattern.draws].std(axis=1) validate_columns(pattern, self.pattern.columns, name) except KeyError as e: - raise KeyError( - f"{name}: Missing columns in the pattern. Details:\n{e}" - ) + raise KeyError(f"{name}: Missing columns in the pattern. Details:\n{e}") pattern_copy = pattern.copy() - pattern_copy = pattern_copy[ - self.pattern.index + self.pattern.val_fields - ] + pattern_copy = pattern_copy[self.pattern.index + self.pattern.val_fields] rename_map = { - col: f"{self.pattern.prefix}{col}" - for col in self.pattern.val_fields + col: f"{self.pattern.prefix}{col}" for col in self.pattern.val_fields } pattern_copy.rename(columns=rename_map, inplace=True) @@ -208,9 +201,7 @@ def parse_pattern( return data_with_pattern - def parse_population( - self, data: DataFrame, population: DataFrame - ) -> DataFrame: + def parse_population(self, data: DataFrame, population: DataFrame) -> DataFrame: name = "Parsing Population" validate_columns(population, self.population.columns, name) @@ -229,9 +220,7 @@ def parse_population( # Ensure the prefixed population column exists pop_col = f"{self.population.prefix}{self.population.val}" if pop_col not in data_with_population.columns: - raise KeyError( - f"Expected column '{pop_col}' not found in merged data." - ) + raise KeyError(f"Expected column '{pop_col}' not found in merged data.") validate_nonan( data_with_population[[pop_col]], @@ -270,12 +259,8 @@ def _process_group( bucket_populations = group[ f"{self.population.prefix}{self.population.val}" ].values - rate_pattern = group[ - f"{self.pattern.prefix}{self.pattern.val}" - ].values - pattern_sd = group[ - f"{self.pattern.prefix}{self.pattern.val_sd}" - ].values + rate_pattern = group[f"{self.pattern.prefix}{self.pattern.val}"].values + pattern_sd = group[f"{self.pattern.prefix}{self.pattern.val_sd}"].values pattern_covariance = np.diag(pattern_sd**2) if model == "rate": diff --git a/src/pydisagg/ihme/validator.py b/src/pydisagg/ihme/validator.py index f6dfe47..99ff339 100644 --- a/src/pydisagg/ihme/validator.py +++ b/src/pydisagg/ihme/validator.py @@ -56,7 +56,9 @@ def validate_index(df: DataFrame, index: list[str], name: str) -> None: df[df[index].duplicated()][index] ).to_list() if duplicated_index: - error_message = f"{name} has duplicated index with {len(duplicated_index)} indices \n" + error_message = ( + f"{name} has duplicated index with {len(duplicated_index)} indices \n" + ) error_message += f"Index columns: ({', '.join(index)})\n" if len(duplicated_index) > 5: error_message += "First 5: \n" @@ -83,9 +85,7 @@ def validate_nonan(df: DataFrame, name: str) -> None: """ nan_columns = df.columns[df.isna().any(axis=0)].to_list() if nan_columns: - error_message = ( - f"{name} has NaN values in {len(nan_columns)} columns. \n" - ) + error_message = f"{name} has NaN values in {len(nan_columns)} columns. \n" error_message += f"Columns with NaN values: {', '.join(nan_columns)}\n" if len(nan_columns) > 5: error_message += "First 5 columns with NaN values: \n" @@ -189,9 +189,7 @@ def validate_noindexdiff( missing_index = index_ref.difference(index_to_check).to_list() if missing_index: - error_message = ( - f"Missing {name} info for {len(missing_index)} indices \n" - ) + error_message = f"Missing {name} info for {len(missing_index)} indices \n" error_message += f"Index columns: ({', '.join(index_ref.names)})\n" if len(missing_index) > 5: error_message += "First 5 missing indices: \n" @@ -283,13 +281,33 @@ def validate_realnumber(df: DataFrame, columns: list[str], name: str) -> None: invalid = [ col for col in columns - if not df[col] - .apply(lambda x: isinstance(x, (int, float)) and x != 0) - .all() + if not df[col].apply(lambda x: isinstance(x, (int, float)) and x != 0).all() ] if invalid: raise ValueError(f"{name} has non-real or zero values in: {invalid}") -# TODO def validate_set_uniqueness +def validate_set_uniqueness(df: DataFrame, column: str, name: str) -> None: + """ + Validates that each list in the specified column contains unique elements. + + Parameters + ---------- + df : pandas.DataFrame + The DataFrame containing the data to validate. + column : str + The name of the column containing lists to validate. + name : str + A name for the DataFrame or validation context, used in error messages. + + Raises + ------ + ValueError + If any list in the specified column contains duplicate elements. + """ + invalid_rows = df[df[column].apply(lambda x: len(x) != len(set(x)))] + if not invalid_rows.empty: + error_message = f"{name} has rows in column '{column}' where list elements are not unique.\n" + error_message += f"Indices of problematic rows: {invalid_rows.index.tolist()}\n" + raise ValueError(error_message) From 5eb2a353a4654db6b22469b9d9807a9412135925 Mon Sep 17 00:00:00 2001 From: saal Date: Tue, 8 Oct 2024 14:16:24 -0700 Subject: [PATCH 19/19] Solved R list, validate set and added tests Co-authored-by: Sameer Co-authored-by: Peng --- src/pydisagg/ihme/validator.py | 24 ++++--- tests/test_validator.py | 123 +++++++++++++++++++++++++++++++-- 2 files changed, 133 insertions(+), 14 deletions(-) diff --git a/src/pydisagg/ihme/validator.py b/src/pydisagg/ihme/validator.py index 99ff339..550b253 100644 --- a/src/pydisagg/ihme/validator.py +++ b/src/pydisagg/ihme/validator.py @@ -261,7 +261,7 @@ def validate_pat_coverage( def validate_realnumber(df: DataFrame, columns: list[str], name: str) -> None: """ - Validates that specified columns contain real numbers and are non-zero. + Validates that specified columns contain real numbers, are non-zero, and are not NaN or Inf. Parameters ---------- @@ -275,14 +275,22 @@ def validate_realnumber(df: DataFrame, columns: list[str], name: str) -> None: Raises ------ ValueError - If any column contains values that are not real numbers or are zero. + If any column contains values that are not real numbers, are zero, or are NaN/Inf. """ - # Check for non-real or zero values in the specified columns - invalid = [ - col - for col in columns - if not df[col].apply(lambda x: isinstance(x, (int, float)) and x != 0).all() - ] + # Check for non-real, zero, NaN, or Inf values in the specified columns + invalid = [] + for col in columns: + if ( + not df[col] + .apply( + lambda x: isinstance(x, (int, float)) + and x != 0 + and pd.notna(x) + and np.isfinite(x) + ) + .all() + ): + invalid.append(col) if invalid: raise ValueError(f"{name} has non-real or zero values in: {invalid}") diff --git a/tests/test_validator.py b/tests/test_validator.py index 7c528f7..6a3464d 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -10,9 +10,12 @@ validate_nonan, validate_pat_coverage, validate_positive, + validate_set_uniqueness, + validate_realnumber, ) +# Test functions @pytest.fixture def data(): np.random.seed(123) @@ -65,6 +68,7 @@ def population(): return population +# Tests for validate_columns def test_validate_columns_missing(population): with pytest.raises(KeyError): validate_columns( @@ -74,6 +78,16 @@ def test_validate_columns_missing(population): ) +def test_validate_columns_no_missing(population): + # All columns are present; should pass + validate_columns( + population, + ["sex_id", "location_id", "age_group_id", "year_id", "population"], + "population", + ) + + +# Tests for validate_index def test_validate_index_missing(population): with pytest.raises(ValueError): validate_index( @@ -83,11 +97,27 @@ def test_validate_index_missing(population): ) +def test_validate_index_no_duplicates(population): + # Ensure DataFrame has no duplicate indices; should pass + validate_index( + population, + ["sex_id", "location_id", "age_group_id", "year_id"], + "population", + ) + + +# Tests for validate_nonan def test_validate_nonan(population): with pytest.raises(ValueError): validate_nonan(population.assign(population=np.nan), "population") +def test_validate_nonan_no_nan(population): + # No NaN values; should pass + validate_nonan(population, "population") + + +# Tests for validate_positive def test_validate_positive_strict(population): with pytest.raises(ValueError): validate_positive( @@ -108,6 +138,12 @@ def test_validate_positive_not_strict(population): ) +def test_validate_positive_no_error(population): + validate_positive(population, ["population"], "population", strict=True) + validate_positive(population, ["population"], "population", strict=False) + + +# Tests for validate_interval def test_validate_interval_lower_equal_upper(data): with pytest.raises(ValueError): validate_interval( @@ -130,11 +166,7 @@ def test_validate_interval_positive(data): validate_interval(data, "age_start", "age_end", ["uid"], "data") -def test_validate_positive_no_error(population): - validate_positive(population, ["population"], "population", strict=True) - validate_positive(population, ["population"], "population", strict=False) - - +# Tests for validate_noindexdiff @pytest.fixture def merged_data_pattern(data, pattern): return pd.merge( @@ -149,7 +181,7 @@ def test_validate_noindexdiff_merged_positive(merged_data_pattern, population): # Positive test case: no index difference validate_noindexdiff( population, - merged_data_pattern, + merged_data_pattern.dropna(subset=["sex_id", "location_id"]), ["sex_id", "location_id"], "merged_data_pattern", ) @@ -173,6 +205,7 @@ def test_validate_noindexdiff_merged_negative(data, pattern): ) +# Tests for validate_pat_coverage @pytest.mark.parametrize( "bad_data_with_pattern", [ @@ -221,3 +254,81 @@ def test_validate_pat_coverage_failure(bad_data_with_pattern): ["group_id"], "pattern", ) + + +# Tests for validate_realnumber +def test_validate_realnumber_positive(): + df = pd.DataFrame({"col1": [1, 2.5, -3.5, 4.2], "col2": [5.1, 6, 7, 8]}) + # Should pass without exceptions + validate_realnumber(df, ["col1", "col2"], "df") + + +def test_validate_realnumber_zero(): + df = pd.DataFrame({"col1": [1, 2, 0, 4], "col2": [5, 6, 7, 8]}) + with pytest.raises( + ValueError, match="df has non-real or zero values in: \\['col1'\\]" + ): + validate_realnumber(df, ["col1"], "df") + + +def test_validate_realnumber_nan(): + df = pd.DataFrame({"col1": [1, 2, 3, np.nan], "col2": [5, 6, 7, 8]}) + with pytest.raises( + ValueError, match="df has non-real or zero values in: \\['col1'\\]" + ): + validate_realnumber(df, ["col1"], "df") + + +def test_validate_realnumber_non_numeric(): + df = pd.DataFrame({"col1": [1, 2, 3, "a"], "col2": [5, 6, 7, 8]}) + with pytest.raises( + ValueError, match="df has non-real or zero values in: \\['col1'\\]" + ): + validate_realnumber(df, ["col1"], "df") + + +def test_validate_realnumber_infinite(): + df = pd.DataFrame({"col1": [1, 2, 3, np.inf], "col2": [5, 6, 7, 8]}) + # np.inf is not a finite real number + with pytest.raises( + ValueError, match="df has non-real or zero values in: \\['col1'\\]" + ): + validate_realnumber(df, ["col1"], "df") + + +# Tests for validate_set_uniqueness +def test_validate_set_uniqueness_positive(): + df = pd.DataFrame( + {"col1": [[1, 2, 3], ["a", "b", "c"], [True, False], [1.1, 2.2, 3.3]]} + ) + # Should pass without exceptions + validate_set_uniqueness(df, "col1", "df") + + +def test_validate_set_uniqueness_negative(): + df = pd.DataFrame( + {"col1": [[1, 2, 2], ["a", "b", "a"], [True, False], [1.1, 2.2, 1.1]]} + ) + with pytest.raises( + ValueError, + match="df has rows in column 'col1' where list elements are not unique.", + ): + validate_set_uniqueness(df, "col1", "df") + + +def test_validate_set_uniqueness_empty_lists(): + df = pd.DataFrame({"col1": [[], [], []]}) + # Should pass; empty lists have no duplicates + validate_set_uniqueness(df, "col1", "df") + + +def test_validate_set_uniqueness_single_element_lists(): + df = pd.DataFrame({"col1": [[1], ["a"], [True]]}) + # Should pass; single-element lists can't have duplicates + validate_set_uniqueness(df, "col1", "df") + + +def test_validate_set_uniqueness_mixed_types_with_duplicates(): + df = pd.DataFrame({"col1": [[1, "1", 1.0], [True, 1, 1.0], [2, 2, 2]]}) + with pytest.raises(ValueError): + validate_set_uniqueness(df, "col1", "df")