popgen3 3.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
popgen/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .project import popgen_run
2
+
3
+ def run(config_path):
4
+ """Run PopGen from Python."""
5
+ popgen_run(config_path)
6
+
7
+
popgen/cli.py ADDED
@@ -0,0 +1,23 @@
1
+ import os
2
+ import argparse
3
+ import logging
4
+ from popgen.project import popgen_run
5
+
6
+ def main():
7
+ """Command-line interface for running PopGen."""
8
+ parser = argparse.ArgumentParser(description="Run PopGen with a specified configuration file.")
9
+ parser.add_argument(
10
+ "config_path",
11
+ type=str,
12
+ help="Path to the configuration YAML file (e.g., configuration_arizona.yaml)"
13
+ )
14
+ args = parser.parse_args()
15
+
16
+ if not os.path.exists(args.config_path):
17
+ logging.error(f"Configuration file '{args.config_path}' not found.")
18
+ exit(1)
19
+
20
+ popgen_run(args.config_path)
21
+
22
+ if __name__ == "__main__":
23
+ main()
popgen/config.py ADDED
@@ -0,0 +1,121 @@
1
+ import yaml
2
+ import logging
3
+
4
+ #logging.basicConfig(level=logging.INFO)
5
+
6
+
7
+ class ConfigError(Exception):
8
+ pass
9
+
10
+
11
+ def wrap_config_value(value):
12
+ """Wrap YAML elements as Config objects to allow attribute access.
13
+
14
+ Example:
15
+ If YAML defines:
16
+ attribute1:
17
+ attribute2: 'Value'
18
+
19
+ Then, x.attribute1.attribute2 can be used to access "Value".
20
+ """
21
+ if isinstance(value, str):
22
+ return value
23
+ try:
24
+ return value + 0
25
+ except TypeError:
26
+ pass
27
+
28
+ return Config(value)
29
+
30
+
31
+ class Config:
32
+ """Config class for handling YAML configuration as attribute-accessible objects."""
33
+
34
+ DEFAULT_PARAMETERS = {
35
+ "ipf": {
36
+ "tolerance": 0.0001,
37
+ "iterations": 250,
38
+ "zero_marginal_correction": 0.00001,
39
+ "rounding_procedure": "bucket",
40
+ "archive_performance_frequency": 1
41
+ },
42
+ "reweighting": {
43
+ "procedure": "ipu",
44
+ "tolerance": 0.0001,
45
+ "inner_iterations": 1,
46
+ "outer_iterations": 50,
47
+ "archive_performance_frequency": 1
48
+ },
49
+ "draws": {
50
+ "pvalue_tolerance": 0.9999,
51
+ "iterations": 25,
52
+ "seed": 0
53
+ }
54
+ }
55
+
56
+ def __init__(self, data):
57
+ self._data = data or {}
58
+
59
+ def __setitem__(self, key, value):
60
+ self._data[key] = value
61
+
62
+ def __setattr__(self, key, value):
63
+ if key == "_data":
64
+ super().__setattr__(key, value)
65
+ else:
66
+ self._data[key] = value
67
+
68
+ def __getattr__(self, key):
69
+ value = self.return_value(key)
70
+ return wrap_config_value(value) if value is not None else None
71
+
72
+ def __getitem__(self, key):
73
+ value = self.return_value(key)
74
+ return wrap_config_value(value)
75
+
76
+ def __getstate__(self):
77
+ """Ensure `yaml.dump()` works correctly with Config objects."""
78
+ return self.__dict__
79
+
80
+ def write_to_file(self, filepath):
81
+ with open(filepath, 'w') as file:
82
+ yaml.dump(self._data, file, default_flow_style=False)
83
+
84
+ def return_value(self, key):
85
+ """Retrieve value from config data safely."""
86
+ try:
87
+ return self._data[key]
88
+ except KeyError:
89
+ logging.warning(f"Key '{key}' not found in configuration.")
90
+ return None # Return None instead of raising an error
91
+
92
+ def __len__(self):
93
+ return len(self._data)
94
+
95
+ def __repr__(self):
96
+ return repr(self._data)
97
+
98
+ def return_list(self):
99
+ """Return a list of top-level keys in the configuration."""
100
+ data_list = []
101
+ for i in self._data:
102
+ data_list.append(i)
103
+ return data_list
104
+
105
+ def return_dict(self):
106
+ """Convert nested Config objects to pure dictionaries."""
107
+
108
+ def convert(value):
109
+ if isinstance(value, Config):
110
+ return value.return_dict()
111
+ elif isinstance(value, list):
112
+ return [convert(v) for v in value]
113
+ elif isinstance(value, dict):
114
+ return {k: convert(v) for k, v in value.items()}
115
+ return value
116
+
117
+ return convert(self._data)
118
+
119
+ def write_to_open(self, filepath):
120
+ with open(filepath, 'w') as outfile:
121
+ yaml.dump(self._data, outfile, default_flow_style=False)
popgen/data.py ADDED
@@ -0,0 +1,132 @@
1
+ import os
2
+ import pandas as pd
3
+ import numpy as np
4
+ import logging
5
+ from .config import ConfigError
6
+
7
+
8
+ class DB:
9
+ """Handles all PopGen input data files and maintains necessary mappings and datasets."""
10
+
11
+ def __init__(self, config):
12
+ self.config = config
13
+ self.sample = {}
14
+ self.geo_marginals = {}
15
+ self.region_marginals = {}
16
+ self.geo = {}
17
+ self.geo_ids = None
18
+ self.region_ids = None
19
+ self.sample_geo_ids = None
20
+ self._inputs_config = self.config.project.inputs
21
+ self.location = os.path.abspath(self.config.project.location)
22
+
23
+ def load_data(self):
24
+ """Loads all required data while handling missing data gracefully."""
25
+ logging.info("Loading database data...")
26
+
27
+ # Load geo correspondence mapping
28
+ geo_corr_mapping_config = getattr(self._inputs_config.location, "geo_corr_mapping", None)
29
+ self.geo = self.get_data(geo_corr_mapping_config) if geo_corr_mapping_config else {}
30
+
31
+ # Load sample data
32
+ sample_config = getattr(self._inputs_config.location, "sample", None)
33
+ self.sample = self.get_data(sample_config) if sample_config else {}
34
+
35
+ # Load marginals
36
+ marginals_config = getattr(self._inputs_config.location, "marginals", None)
37
+ self.geo_marginals = self.get_data(getattr(marginals_config, "geo", None),
38
+ header=[0, 1]) if marginals_config and hasattr(marginals_config,
39
+ "geo") else {}
40
+ self.region_marginals = self.get_data(getattr(marginals_config, "region", None),
41
+ header=[0, 1]) if marginals_config and hasattr(marginals_config,
42
+ "region") else {}
43
+
44
+ try:
45
+ self._enumerate_geo_ids()
46
+ except Exception as e:
47
+ logging.warning(f"_enumerate_geo_ids failed due to {e}. Defaulting to empty lists.")
48
+ self.geo_ids = []
49
+ self.region_ids = []
50
+
51
+ def get_data(self, config, header=0):
52
+ """Loads data while handling missing files gracefully."""
53
+ config_dict = config.return_dict() if config else {}
54
+ data_dict = {}
55
+
56
+ for item, filename in config_dict.items():
57
+ if filename is None:
58
+ logging.warning(f"{item} has no filename specified. Skipping.")
59
+ continue
60
+
61
+ full_location = os.path.join(self.location, filename)
62
+ if os.path.exists(full_location):
63
+ try:
64
+ data_dict[item] = pd.read_csv(full_location, index_col=0, header=header)
65
+ if data_dict[item].index.name:
66
+ data_dict[item].loc[:, data_dict[item].index.name] = data_dict[item].index.values
67
+ except Exception as e:
68
+ logging.warning(f"Failed to load {filename} due to {e}. Skipping {item}.")
69
+ else:
70
+ logging.warning(f"{filename} not found. Skipping {item}.")
71
+
72
+ return data_dict
73
+
74
+ def _enumerate_geo_ids(self):
75
+ """Ensures proper initialization of geo and region IDs."""
76
+ self.geo_ids_all = []
77
+ self.region_ids_all = []
78
+
79
+ try:
80
+ if "geo_to_sample" in self.geo:
81
+ self.geo_ids_all = self.geo["geo_to_sample"].index.tolist()
82
+
83
+ if "region_to_geo" in self.geo:
84
+ self.region_ids_all = np.unique(self.geo["region_to_geo"].index.values).tolist()
85
+ elif "region_to_sample" in self.geo:
86
+ self.region_ids_all = np.unique(self.geo["region_to_sample"].index.values).tolist()
87
+ except Exception as e:
88
+ logging.warning(f"Failed to enumerate geo IDs due to {e}. Defaulting to empty lists.")
89
+ self.geo_ids_all = []
90
+ self.region_ids_all = []
91
+
92
+ def get_geo_ids_for_region(self, region_id):
93
+ """Retrieves geo IDs corresponding to a given region ID."""
94
+ geo_name = self._inputs_config.column_names.geo
95
+ geo_list = self.geo["region_to_geo"].loc[region_id, geo_name]
96
+ return [int(geo_list)] if isinstance(geo_list, (int, np.integer)) else list(geo_list)
97
+
98
+ def enumerate_geo_ids_for_scenario(self, scenario_config):
99
+ """Ensures geo enumeration is handled even when missing data is encountered."""
100
+ try:
101
+ self.region_ids = getattr(scenario_config.geos_to_synthesize.region, "ids", self.region_ids_all)
102
+ self.geo_ids = []
103
+
104
+ if "region_to_geo" in self.geo:
105
+ for region_id in self.region_ids:
106
+ geo_list = self.get_geo_ids_for_region(region_id)
107
+ if geo_list:
108
+ self.geo_ids += geo_list
109
+ else:
110
+ print("⚠️ No region_to_geo mapping. Using only region data.")
111
+ self.geo_ids = self.region_ids if self.region_ids else []
112
+ except ConfigError as e:
113
+ print(f"⚠️ KeyError: {e}. Defaulting to all geo and region IDs.")
114
+ self.geo_ids = self.geo_ids_all if self.geo_ids_all else []
115
+ self.region_ids = self.region_ids_all if self.region_ids_all else []
116
+
117
+ def return_variables_cats(self, entity, variable_names):
118
+ """Returns unique categories for each variable in an entity dataset."""
119
+ return {var: self.return_variable_cats(entity, var) for var in variable_names}
120
+
121
+ def return_variable_cats(self, entity, variable_name):
122
+ """Returns unique values for a specific variable in an entity dataset."""
123
+ return np.unique(self.sample[entity][variable_name].values).tolist()
124
+
125
+ def check_data(self):
126
+ """Runs data consistency checks."""
127
+ self.check_sample_marginals_consistency()
128
+ self.check_marginals()
129
+
130
+ def check(self):
131
+ """Placeholder for additional data consistency checks."""
132
+ pass
popgen/draw.py ADDED
@@ -0,0 +1,101 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ import logging
4
+ from scipy import stats
5
+
6
+
7
+ class Draw_Population:
8
+ """Handles the process of drawing synthetic populations based on weighted sampling."""
9
+
10
+ def __init__(self, scenario_config, geo_ids, geo_row_idx, geo_frequencies,
11
+ geo_constraints, geo_stacked, region_sample_weights):
12
+ self.scenario_config = scenario_config
13
+ self.geo_ids = geo_ids
14
+ self.geo_row_idx = geo_row_idx
15
+ self.geo_frequencies = geo_frequencies
16
+ self.geo_constraints = geo_constraints
17
+ self.geo_stacked = geo_stacked
18
+ self.region_sample_weights = region_sample_weights
19
+
20
+ self.iterations = self.scenario_config.parameters.draws.iterations
21
+ self.seed = self.scenario_config.parameters.draws.seed
22
+ self.pvalue_tolerance = self.scenario_config.parameters.draws.pvalue_tolerance
23
+
24
+ self.geo_id_rows_syn_dict = {}
25
+ self.performance_columns = ["p_value", "iterations", "chi_sq_stat"]
26
+ self.draws_performance = pd.DataFrame(index=self.geo_ids, columns=self.performance_columns)
27
+
28
+ def draw_population(self):
29
+ """Executes the drawing of households for each geographical ID."""
30
+ np.random.seed(self.seed)
31
+ logging.info("Starting synthetic population drawing.")
32
+
33
+ for geo_id in self.geo_ids:
34
+ logging.info(f"Processing geo ID: {geo_id}")
35
+ geo_sample_weights = self.region_sample_weights.loc[:, geo_id]
36
+ geo_cumulative_weights = self._return_cumulative_probability(geo_sample_weights)
37
+ geo_id_frequencies = self.geo_frequencies.loc[geo_id, :]
38
+ geo_id_constraints = self.geo_constraints.loc[geo_id, :]
39
+
40
+ if geo_id_frequencies.sum() == 0:
41
+ logging.warning(f"Skipping geo_id {geo_id}: Zero frequency sum.")
42
+ continue
43
+
44
+ p_value_max, geo_id_rows_syn_max, iter_max, stat_max = -1, None, None, None
45
+
46
+ for iter in range(self.iterations):
47
+ seed = self.seed + iter
48
+ geo_id_rows_syn = self._pick_households(geo_id_frequencies, geo_cumulative_weights)
49
+ stat, p_value = self._measure_match(geo_id_rows_syn, geo_id_constraints)
50
+
51
+ if p_value > self.pvalue_tolerance:
52
+ logging.info(f"Converged at iteration {iter} for geo ID {geo_id}.")
53
+ p_value_max, geo_id_rows_syn_max, iter_max, stat_max = p_value, geo_id_rows_syn, iter, stat
54
+ break
55
+ elif p_value > p_value_max:
56
+ p_value_max, geo_id_rows_syn_max, iter_max, stat_max = p_value, geo_id_rows_syn, iter, stat
57
+
58
+ self.draws_performance.loc[geo_id, self.performance_columns] = (p_value_max, iter_max, stat_max)
59
+ self.geo_id_rows_syn_dict[geo_id] = geo_id_rows_syn_max
60
+
61
+ def _return_cumulative_probability(self, geo_sample_weights):
62
+ """Computes cumulative probabilities for weighted sampling."""
63
+ geo_cumulative_weights = {}
64
+
65
+ for column in self.geo_frequencies.columns:
66
+ rows = self.geo_row_idx[column]
67
+ weights = geo_sample_weights.take(rows)
68
+ geo_cumulative_weights[column] = (weights / weights.sum()).cumsum()
69
+
70
+ return geo_cumulative_weights
71
+
72
+ def _pick_households(self, geo_id_frequencies, geo_cumulative_weights):
73
+ """Selects households based on cumulative probabilities."""
74
+ last = 0
75
+ rand_numbers = np.random.random(int(geo_id_frequencies.sum()))
76
+ list_rows_syn_subpop = []
77
+
78
+ for column in self.geo_frequencies.columns:
79
+ rows = self.geo_row_idx[column]
80
+ column_frequency = int(geo_id_frequencies[column])
81
+ column_bins = np.searchsorted(geo_cumulative_weights[column], rand_numbers[last:last + column_frequency],
82
+ side="right")
83
+ last += column_frequency
84
+ list_rows_syn_subpop.append(rows.take(column_bins))
85
+
86
+ return np.sort(np.concatenate(list_rows_syn_subpop))
87
+
88
+ def _measure_match(self, geo_id_rows_syn, geo_id_constraints):
89
+ """Evaluates the match between synthetic and expected constraints using chi-square test."""
90
+ geo_id_constraints.name = "constraint"
91
+ geo_id_synthetic = self.geo_stacked.take(geo_id_rows_syn).sum()
92
+ geo_id_synthetic = pd.DataFrame(geo_id_synthetic, columns=["synthetic_count"])
93
+ geo_id_synthetic = geo_id_synthetic.join(geo_id_constraints, how="inner")
94
+
95
+ observed, expected = geo_id_synthetic["synthetic_count"], geo_id_synthetic["constraint"]
96
+ observed_sum, expected_sum = observed.sum(), expected.sum()
97
+
98
+ if abs(observed_sum - expected_sum) / expected_sum > 1e-08:
99
+ expected *= observed_sum / expected_sum
100
+
101
+ return stats.chisquare(observed, expected)