From ce3cf1167cf0b073c451c01f7ae3ae23c4a5b27e Mon Sep 17 00:00:00 2001 From: Emmanuel Jordy Menvouta <56538317+emmanueljordy@users.noreply.github.com> Date: Mon, 17 Feb 2025 14:13:43 +0100 Subject: [PATCH 1/4] add data processing class --- .pre-commit-config.yaml | 60 +++++ requirements.txt | 3 +- synthpop/method/GC.py | 423 ++++++++++++++++++++++++++++++ synthpop/method/helpers.py | 169 ++++++++++++ synthpop/method/test_method.ipynb | 0 synthpop/processor/data_processor | 137 ++++++++++ 6 files changed, 791 insertions(+), 1 deletion(-) create mode 100644 .pre-commit-config.yaml create mode 100644 synthpop/method/GC.py create mode 100644 synthpop/method/test_method.ipynb create mode 100644 synthpop/processor/data_processor diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0249621 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,60 @@ +repos: + # 1. Code Formatter: Black (Ensures uniform formatting) + - repo: https://github.com/psf/black + rev: stable + hooks: + - id: black + + # 2. Code Formatter: isort (Sorts imports) + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + + # 3. Linter: Flake8 (Finds style & syntax issues) + - repo: https://github.com/pycqa/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + + # 4. Security: Bandit (Finds security vulnerabilities) + - repo: https://github.com/PyCQA/bandit + rev: stable + hooks: + - id: bandit + args: ["-r", "."] + + # 5. Security: detect-secrets (Prevents committing secrets) + - repo: https://github.com/Yelp/detect-secrets + rev: v1.3.0 + hooks: + - id: detect-secrets-hook + + # 6. Type Checker: mypy (Checks for type errors) + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.0 + hooks: + - id: mypy + + # 7. Tests: Pytest (Runs test cases before commit) + - repo: local + hooks: + - id: pytest + name: Run Pytest + entry: pytest + language: system + types: [python] + + # 8. Dependency Check: pip-audit (Checks for vulnerable dependencies) + - repo: https://github.com/pypa/pip-audit + rev: v2.4.0 + hooks: + - id: pip-audit + + # 9. File Cleanup: Remove trailing whitespace + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + + diff --git a/requirements.txt b/requirements.txt index 3c8fd34..8fc59a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ numpy>=1.20.0 pandas>=1.3.0 scikit-learn>=1.0.0 -pytest>=7.0.0 \ No newline at end of file +pytest>=7.0.0 +copulas>=0.1.0 diff --git a/synthpop/method/GC.py b/synthpop/method/GC.py new file mode 100644 index 0000000..ddaedf2 --- /dev/null +++ b/synthpop/method/GC.py @@ -0,0 +1,423 @@ +import inspect +import logging +import warnings +from copy import deepcopy +import numpy as np +import pandas as pd +import scipy +import copulas.univariate +from copulas import multivariate +from sklearn.preprocessing import OneHotEncoder +from synthpop.method.helpers import ( + validate_numerical_distributions, + warn_missing_numerical_distributions, + flatten_dict, + unflatten_dict, +) + +LOGGER = logging.getLogger(__name__) + + +class BaseSingleTableSynthesizer: + """Base class for single table synthesizers + Args: + metadata (dict): + dictionary containing for each column their name and the type + enforce_min_max_values (bool): + Specify whether or not to clip the data returned by ``reverse_transform`` of + the numerical transformer, ``FloatFormatter``, to the min and max values seen + during ``fit``. Defaults to ``True``. + enforce_rounding (bool): + Define rounding scheme for ``numerical`` columns. If ``True``, the data returned + by ``reverse_transform`` will be rounded as in the original data. Defaults to ``True``. + locales (list or str): + The default locale(s) to use for AnonymizedFaker transformers. + Defaults to ``['en_US']``. + """ + + def __init__( + self, + metadata, + enforce_min_max_values=True, + enforce_rounding=True, + locales=["en_US"], + ): + + self.metadata = metadata + self.enforce_min_max_values = enforce_min_max_values + self.enforce_rounding = enforce_rounding + self.locales = locales + + +class GaussianCopulaMethod(BaseSingleTableSynthesizer): + _DISTRIBUTIONS = { + "norm": copulas.univariate.GaussianUnivariate, + "beta": copulas.univariate.BetaUnivariate, + "truncnorm": copulas.univariate.TruncatedGaussian, + "gamma": copulas.univariate.GammaUnivariate, + "uniform": copulas.univariate.UniformUnivariate, + "gaussian_kde": copulas.univariate.GaussianKDE, + } + + @classmethod + def get_distribution_class(cls, distribution): + """Return the corresponding distribution class from ``copulas.univariate``. + + Args: + distribution (str): + A string representing a copulas univariate distribution. + + Returns: + copulas.univariate: + A copulas univariate class that corresponds to the distribution. + """ + if not isinstance(distribution, str) or distribution not in cls._DISTRIBUTIONS: + error_message = f"Invalid distribution specification '{distribution}'." + raise ValueError(error_message) + + return cls._DISTRIBUTIONS[distribution] + + def __init__( + self, + metadata, + enforce_min_max_values=True, + enforce_rounding=True, + locales=["en_US"], + numerical_distributions=None, + default_distribution=None, + ): + super().__init__(metadata, enforce_min_max_values, enforce_rounding, locales) + validate_numerical_distributions(numerical_distributions, self.metadata.columns) + + self.default_distribution = default_distribution or "beta" + self._default_distribution = self.get_distribution_class( + self.default_distribution + ) + + self._set_numerical_distributions(numerical_distributions) + self._num_rows = None + + def _set_numerical_distributions(self, numerical_distributions): + self.numerical_distributions = numerical_distributions or {} + self._numerical_distributions = { + field: self.get_distribution_class(distribution) + for field, distribution in self.numerical_distributions.items() + } + + def _fit(self, processed_data): + """Fit the model to the table. + + Args: + processed_data (pandas.DataFrame): + Data to be learned. + """ + warn_missing_numerical_distributions( + self.numerical_distributions, processed_data.columns + ) + self._num_rows = self._learn_num_rows(processed_data) + numerical_distributions = self._get_numerical_distributions(processed_data) + self._model = self._initialize_model(numerical_distributions) + self._fit_model(processed_data) + + def _learn_num_rows(self, processed_data): + return len(processed_data) + + def _get_numerical_distributions(self, processed_data): + numerical_distributions = deepcopy(self._numerical_distributions) + for column in processed_data.columns: + if column not in numerical_distributions: + numerical_distributions[column] = self._numerical_distributions.get( + column, self._default_distribution + ) + + return numerical_distributions + + def _initialize_model(self, numerical_distributions): + return multivariate.GaussianMultivariate(distribution=numerical_distributions) + + def _fit_model(self, processed_data): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", module="scipy") + self._model.fit(processed_data) + + def _warn_quality_and_performance(self, column_name_to_transformer): + """Raise warning if the quality/performance may be impacted. + + Args: + column_name_to_transformer (dict): + Dict mapping column names to transformers to be used for that column. + """ + for column, transformer in column_name_to_transformer.items(): + if isinstance(transformer, OneHotEncoder): + warnings.warn( + f"Using a OneHotEncoder transformer for column '{column}' " + "may slow down the preprocessing and modeling times." + ) + + def _sample(self, num_rows, conditions=None): + """Sample the indicated number of rows from the model. + + Args: + num_rows (int): + Amount of rows to sample. + conditions (dict): + If specified, this dictionary maps column names to the column + value. Then, this method generates ``num_rows`` samples, all of + which are conditioned on the given variables. + + Returns: + pandas.DataFrame: + Sampled data. + """ + return self._model.sample(num_rows, conditions=conditions) + + def _get_valid_columns_from_metadata(self, columns): + + valid_columns = [] + for column in columns: + for valid_column in self.metadata.columns: + if column.startswith(valid_column): + valid_columns.append(column) + break + + return valid_columns + + def get_learned_distributions(self): + """Get the marginal distributions used by the ``GaussianCopula``. + + Return a dictionary mapping the column names with the distribution name and the learned + parameters for those. + + Returns: + dict: + Dictionary containing the distributions used or detected for each column and the + learned parameters for those. + """ + if not self._fitted: + raise ValueError( + "Distributions have not been learned yet. Please fit your model first using 'fit'." + ) + + if not hasattr(self._model, "to_dict") or not self._model.to_dict(): + return {} + + parameters = self._model.to_dict() + columns = parameters["columns"] + univariates = deepcopy(parameters["univariates"]) + learned_distributions = {} + valid_columns = self._get_valid_columns_from_metadata(columns) + for column, learned_params in zip(columns, univariates): + if column in valid_columns: + distribution = self.numerical_distributions.get( + column, self.default_distribution + ) + learned_params.pop("type") + learned_distributions[column] = { + "distribution": distribution, + "learned_parameters": learned_params, + } + + return learned_distributions + + def _get_parameters(self): + """Get copula model parameters. + + Compute model ``correlation`` and ``distribution.std`` + before it returns the flatten dict. + + Returns: + dict: + Copula parameters. + + """ + for univariate in self._model.univariates: + univariate_type = type(univariate) + if univariate_type is copulas.univariate.Univariate: + univariate = univariate._instance + + params = self._model.to_dict() + + correlation = [] + for index, row in enumerate(params["correlation"][1:]): + correlation.append(row[: index + 1]) + + params["correlation"] = correlation + params["univariates"] = dict(zip(params.pop("columns"), params["univariates"])) + params["num_rows"] = self._num_rows + + return flatten_dict(params) + + @staticmethod + def _get_nearest_correlation_matrix(matrix): + """Find the nearest correlation matrix. + + If the given matrix is not Positive Semi-definite, which means + that any of its eigenvalues is negative, find the nearest PSD matrix + by setting the negative eigenvalues to 0 and rebuilding the matrix + from the same eigenvectors and the modified eigenvalues. + + After this, the matrix will be PSD but may not have 1s in the diagonal, + so the diagonal is replaced by 1s and then the PSD condition of the + matrix is validated again, repeating the process until the built matrix + contains 1s in all the diagonal and is PSD. + + After 10 iterations, the last step is skipped and the current PSD matrix + is returned even if it does not have all 1s in the diagonal. + + Insipired by: https://stackoverflow.com/a/63131250 + """ + eigenvalues, eigenvectors = scipy.linalg.eigh(matrix) + negative = eigenvalues < 0 + identity = np.identity(len(matrix)) + + iterations = 0 + while np.any(negative): + eigenvalues[negative] = 0 + matrix = eigenvectors.dot(np.diag(eigenvalues)).dot(eigenvectors.T) + if iterations >= 10: + break + + matrix = matrix - matrix * identity + identity + + max_value = np.abs(np.abs(matrix).max()) + if max_value > 1: + matrix /= max_value + + eigenvalues, eigenvectors = scipy.linalg.eigh(matrix) + negative = eigenvalues < 0 + iterations += 1 + + return matrix + + def _set_parameters(self, parameters, default_params=None): + """Set copula model parameters. + + Args: + params [dict]: + Copula flatten parameters. + default_params [list]: + Flattened list of parameters to fall back to if `params` are invalid. + + """ + if default_params is not None: + default_params = unflatten_dict(default_params) + else: + default_params = {} + + parameters = unflatten_dict(parameters) + if "num_rows" in parameters: + num_rows = parameters.pop("num_rows") + self._num_rows = 0 if pd.isna(num_rows) else max(0, int(round(num_rows))) + + if parameters: + parameters = self._rebuild_gaussian_copula(parameters, default_params) + self._model = multivariate.GaussianMultivariate.from_dict(parameters) + + def _rebuild_gaussian_copula(self, model_parameters, default_params=None): + """Rebuild the model params to recreate a Gaussian Multivariate instance. + + Args: + model_parameters (dict): + Sampled and reestructured model parameters. + default_parameters (dict): + Fall back parameters if sampled params are invalid. + + Returns: + dict: + Model parameters ready to recreate the model. + """ + if default_params is None: + default_params = {} + + columns = [] + univariates = [] + for column, univariate in model_parameters["univariates"].items(): + columns.append(column) + if column in self._numerical_distributions: + univariate_type = self._numerical_distributions[column] + else: + univariate_type = self.get_distribution_class(self.default_distribution) + + univariate["type"] = univariate_type + model = univariate_type.MODEL_CLASS + if hasattr(model, "_argcheck"): + to_check = { + parameter: univariate[parameter] + for parameter in inspect.signature( + model._argcheck + ).parameters.keys() + if parameter in univariate + } + if not model._argcheck(**to_check): + if column in default_params.get("univariates", []): + LOGGER.info( + f"Invalid parameters sampled for column '{column}', " + "using default parameters." + ) + univariate = default_params["univariates"][column] + univariate["type"] = univariate_type + else: + LOGGER.debug(f"Column '{column}' has invalid parameters.") + else: + LOGGER.debug( + f"Univariate for col '{column}' does not have _argcheck method." + ) + + if "scale" in univariate: + univariate["scale"] = max(0, univariate["scale"]) + + univariates.append(univariate) + + model_parameters["univariates"] = univariates + model_parameters["columns"] = columns + + correlation = model_parameters.get("correlation") + if correlation: + model_parameters["correlation"] = self._rebuild_correlation_matrix( + correlation + ) + else: + model_parameters["correlation"] = [[1.0]] + + return model_parameters + + @classmethod + def _rebuild_correlation_matrix(cls, triangular_correlation): + """Rebuild a valid correlation matrix from its lower half triangle. + + The input of this function is a list of lists of floats of size 1, 2, 3...n-1: + + [[c_{2,1}], [c_{3,1}, c_{3,2}], ..., [c_{n,1},...,c_{n,n-1}]] + + Corresponding to the values from the lower half of the original correlation matrix, + **excluding** the diagonal. + + The output is the complete correlation matrix reconstructed using the given values + and scaled to the :math:`[-1, 1]` range if necessary. + + Args: + triangle_correlation (list[list[float]]): + A list that contains lists of floats of size 1, 2, 3... up to ``n-1``, + where ``n`` is the size of the target correlation matrix. + + Returns: + numpy.ndarray: + rebuilt correlation matrix. + """ + zero = [0.0] + size = len(triangular_correlation) + 1 + left = np.zeros((size, size)) + right = np.zeros((size, size)) + for idx, values in enumerate(triangular_correlation): + values = values + zero * (size - idx - 1) + left[idx + 1, :] = values + right[:, idx + 1] = values + + correlation = left + right + max_value = np.abs(correlation).max() + if max_value > 1: + correlation /= max_value + + correlation += np.identity(size) + + return cls._get_nearest_correlation_matrix(correlation).tolist() diff --git a/synthpop/method/helpers.py b/synthpop/method/helpers.py index e4c1c93..39aaa2b 100644 --- a/synthpop/method/helpers.py +++ b/synthpop/method/helpers.py @@ -48,3 +48,172 @@ def smooth(dtype, y_synth, y_real_min, y_real_max): y_synth[indices] = y_synth[indices].astype(int) return y_synth + + +def validate_numerical_distributions(numerical_distributions, metadata_columns): + """Validate ``numerical_distributions``. + + Raise an error if it's not None or dict, or if its columns are not present in the metadata. + + Args: + numerical_distributions (dict): + Dictionary that maps field names from the table that is being modeled with + the distribution that needs to be used. + metadata_columns (list): + Columns present in the metadata. + """ + if numerical_distributions: + if not isinstance(numerical_distributions, dict): + raise TypeError('numerical_distributions can only be None or a dict instance.') + + invalid_columns = numerical_distributions.keys() - set(metadata_columns) + if invalid_columns: + raise SynthesizerInputError( + 'Invalid column names found in the numerical_distributions dictionary ' + f'{invalid_columns}. The column names you provide must be present ' + 'in the metadata.' + ) + +def warn_missing_numerical_distributions(numerical_distributions, processed_data_columns): + """Raise an `UserWarning` when numerical distribution columns don't exist anymore.""" + unseen_columns = numerical_distributions.keys() - set(processed_data_columns) + for column in unseen_columns: + warnings.warn( + f"Cannot use distribution '{numerical_distributions[column]}' for column " + f"'{column}' because the column is not statistically modeled.", + UserWarning, + ) + +def flatten_array(nested, prefix=''): + """Flatten an array as a dict. + + Args: + nested (list, numpy.array): + Iterable to flatten. + prefix (str): + Name to append to the array indices. Defaults to ``''``. + + Returns: + dict: + Flattened array. + """ + result = {} + for index in range(len(nested)): + prefix_key = '__'.join([prefix, str(index)]) if len(prefix) else str(index) + + value = nested[index] + if isinstance(value, (list, np.ndarray)): + result.update(flatten_array(value, prefix=prefix_key)) + + elif isinstance(value, dict): + result.update(flatten_dict(value, prefix=prefix_key)) + + else: + result[prefix_key] = value + + return result + + +def flatten_dict(nested, prefix=''): + """Flatten a dictionary. + + This method returns a flatten version of a dictionary, concatenating key names with + double underscores. + + Args: + nested (dict): + Original dictionary to flatten. + prefix (str): + Prefix to append to key name. Defaults to ``''``. + + Returns: + dict: + Flattened dictionary. + """ + result = {} + + for key, value in nested.items(): + prefix_key = '__'.join([prefix, str(key)]) if len(prefix) else key + + if key in IGNORED_DICT_KEYS and not isinstance(value, (dict, list)): + continue + + elif isinstance(value, dict): + result.update(flatten_dict(value, prefix_key)) + + elif isinstance(value, (np.ndarray, list)): + result.update(flatten_array(value, prefix_key)) + + else: + result[prefix_key] = value + + return result + +def unflatten_dict(flat): + """Transform a flattened dict into its original form. + + Args: + flat (dict): + Flattened dict. + + Returns: + dict: + Nested dict (if corresponds) + """ + unflattened = {} + + for key, value in sorted(flat.items(), key=_key_order): + if '__' in key: + key, subkey = key.split('__', 1) + subkey, name = subkey.rsplit('__', 1) + + if name.isdigit(): + column_index = int(name) + row_index = int(subkey) + + array = unflattened.setdefault(key, []) + + if len(array) == row_index: + row = [] + array.append(row) + elif len(array) == row_index + 1: + row = array[row_index] + else: + # This should never happen + raise ValueError('There was an error unflattening the extension.') + + if len(row) == column_index: + row.append(value) + else: + # This should never happen + raise ValueError('There was an error unflattening the extension.') + + else: + subdict = unflattened.setdefault(key, {}) + if subkey.isdigit() and key != 'univariates': + subkey = int(subkey) + + inner = subdict.setdefault(subkey, {}) + inner[name] = value + + else: + unflattened[key] = value + + return unflattened + + + +def extract_metadata(df: pd.DataFrame) -> dict: + """ + Extract metadata from a pandas DataFrame. + + Args: + df (pd.DataFrame): The input DataFrame. + + Returns: + dict: A dictionary where keys are column names and values are column types. + """ + return {col: str(df[col].dtype) for col in df.columns} + + + diff --git a/synthpop/method/test_method.ipynb b/synthpop/method/test_method.ipynb new file mode 100644 index 0000000..e69de29 diff --git a/synthpop/processor/data_processor b/synthpop/processor/data_processor new file mode 100644 index 0000000..73f3c3e --- /dev/null +++ b/synthpop/processor/data_processor @@ -0,0 +1,137 @@ +import pandas as pd +import numpy as np +import warnings +import logging +from sklearn.preprocessing import LabelEncoder, OneHotEncoder, StandardScaler, MinMaxScaler + +# Set up logging +LOGGER = logging.getLogger(__name__) + +class InvalidDataError(Exception): + """Custom exception for invalid data errors.""" + pass + +class DataProcessor: + """Preprocess and post-process data before and after synthetic data generation. + + Handles: + - Type conversions (categorical ↔ numerical). + - Missing value handling. + - Feature transformations for Gaussian Copula. + - Reverse transformations to restore original data types. + """ + + def __init__(self, metadata, enforce_rounding=True, enforce_min_max_values=True, model_kwargs=None, table_name=None, locales=['en_US']): + self.metadata = metadata + self.enforce_rounding = enforce_rounding + self.enforce_min_max_values = enforce_min_max_values + self.model_kwargs = model_kwargs or {} + self.table_name = table_name + self.locales = locales + self._fitted = False + self._prepared_for_fitting = False + self.encoders = {} # Stores encoders for categorical columns + self.scalers = {} # Stores scalers for numerical columns + self.original_columns = None # To restore column order + + def preprocess(self, data: pd.DataFrame) -> pd.DataFrame: + """Transform the raw data into numerical space.""" + if self._fitted: + warnings.warn( + "This model has already been fitted. To use new preprocessed data, " + "please refit the model using 'fit'." + ) + + self.validate(data) + self.original_columns = data.columns # Store original column order + processed_data = self._preprocess(data) + + return processed_data + + def _preprocess(self, data: pd.DataFrame) -> pd.DataFrame: + """Handles encoding, scaling, and missing values.""" + data = data.copy() + + for col, dtype in self.metadata.items(): + if dtype == "categorical": + # Use Label Encoding for small categories, OneHot for larger + encoder = LabelEncoder() if len(data[col].unique()) < 10 else OneHotEncoder(sparse=False, drop="first") + transformed_data = self._encode_categorical(data[col], encoder) + self.encoders[col] = encoder + data.drop(columns=[col], inplace=True) + data = pd.concat([data, transformed_data], axis=1) + + elif dtype == "numerical": + scaler = StandardScaler() + data[col] = self._handle_missing_values(data[col]) + data[col] = scaler.fit_transform(data[[col]]) + self.scalers[col] = scaler + + elif dtype == "boolean": + data[col] = data[col].astype(int) # Convert True/False to 1/0 + + elif dtype == "datetime": + data[col] = pd.to_datetime(data[col]).astype(int) // 10**9 # Convert to Unix timestamp + + return data + + def postprocess(self, synthetic_data: pd.DataFrame) -> pd.DataFrame: + """Transform numerical synthetic data back to its original format.""" + synthetic_data = synthetic_data.copy() + + for col, dtype in self.metadata.items(): + if dtype == "categorical" and col in self.encoders: + encoder = self.encoders[col] + synthetic_data[col] = self._decode_categorical(synthetic_data[col], encoder) + + elif dtype == "numerical" and col in self.scalers: + scaler = self.scalers[col] + synthetic_data[col] = scaler.inverse_transform(synthetic_data[[col]]) + + elif dtype == "boolean": + synthetic_data[col] = synthetic_data[col].round().astype(bool) + + elif dtype == "datetime": + synthetic_data[col] = pd.to_datetime(synthetic_data[col], unit='s') + + return synthetic_data[self.original_columns] # Restore original column order + + def validate(self, data: pd.DataFrame): + """Validate input data.""" + if not isinstance(data, pd.DataFrame): + raise ValueError("Input data must be a pandas DataFrame.") + + missing_columns = set(self.metadata.keys()) - set(data.columns) + if missing_columns: + raise InvalidDataError(f"Missing columns: {missing_columns}") + + primary_keys = [col for col, dtype in self.metadata.items() if dtype == "primary_key"] + for key in primary_keys: + if data[key].duplicated().any(): + raise InvalidDataError(f"Primary key '{key}' is not unique.") + + def _encode_categorical(self, series: pd.Series, encoder): + """Encode categorical columns.""" + if isinstance(encoder, LabelEncoder): + return pd.DataFrame(encoder.fit_transform(series), columns=[series.name]) + elif isinstance(encoder, OneHotEncoder): + encoded_array = encoder.fit_transform(series.values.reshape(-1, 1)) + encoded_df = pd.DataFrame(encoded_array, columns=encoder.get_feature_names_out([series.name])) + return encoded_df + + def _decode_categorical(self, series: pd.Series, encoder): + """Decode categorical columns.""" + if isinstance(encoder, LabelEncoder): + return encoder.inverse_transform(series.astype(int)) + elif isinstance(encoder, OneHotEncoder): + category_index = np.argmax(series.values, axis=1) + return encoder.categories_[0][category_index] + + def _handle_missing_values(self, series: pd.Series): + """Handle missing values based on column type.""" + if series.dtype in ["float64", "int64"]: + return series.fillna(series.median()) + elif series.dtype == "object": + return series.fillna(series.mode()[0]) + else: + return series.fillna(0) From 2faa8694d971fea59010dc040529539ccc32c467 Mon Sep 17 00:00:00 2001 From: Emmanuel Jordy Menvouta <56538317+emmanueljordy@users.noreply.github.com> Date: Fri, 21 Feb 2025 16:19:13 +0100 Subject: [PATCH 2/4] add missing data handler --- synthpop/processor/missing_data_handler.py | 95 ++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 synthpop/processor/missing_data_handler.py diff --git a/synthpop/processor/missing_data_handler.py b/synthpop/processor/missing_data_handler.py new file mode 100644 index 0000000..a1f5daa --- /dev/null +++ b/synthpop/processor/missing_data_handler.py @@ -0,0 +1,95 @@ +import numpy as np +import pandas as pd +import scipy.stats as stats +from sklearn.impute import SimpleImputer +from sklearn.linear_model import LogisticRegression +from sklearn.preprocessing import LabelEncoder +from fancyimpute import IterativeImputer # For MICE and EM +import warnings + +class MissingDataHandler: + """Detects missingness type (MCAR, MAR, MNAR) and applies automatic imputation.""" + + def __init__(self): + self.imputers = {} + + def detect_missingness(self, df: pd.DataFrame) -> dict: + """Detects missingness type for each column. + + Args: + df (pd.DataFrame): The input DataFrame. + + Returns: + dict: Dictionary mapping column names to detected missingness type. + """ + missingness = {} + + for col in df.columns: + missing_values = df[col].isna().sum() + if missing_values == 0: + continue # No missing values → Skip detection + + # 1️⃣ Little's MCAR Test + _, p_value = stats.chisquare(df[col].dropna().value_counts()) + if p_value > 0.05: + missingness[col] = "MCAR" + continue + + # 2️⃣ Logistic Regression (MAR Detection) + missing_mask = df[col].isna().astype(int) + observed_data = df.drop(columns=[col]).fillna(df.mean()) + + model = LogisticRegression() + model.fit(observed_data, missing_mask) + if model.score(observed_data, missing_mask) > 0.6: # Predictable missingness → MAR + missingness[col] = "MAR" + continue + + # 3️⃣ Distributional Check (MNAR Detection) + observed_values = df[col].dropna() + missing_rows = df[col].isna() + if missing_rows.sum() > 0: + missing_values = df.loc[missing_rows, df.columns != col].mean(axis=1) + _, p_value = stats.ks_2samp(observed_values, missing_values) + if p_value < 0.05: + missingness[col] = "MNAR" + continue + + missingness[col] = "MAR" # Default to MAR if uncertain + + return missingness + + def apply_imputation(self, df: pd.DataFrame, missingness: dict) -> pd.DataFrame: + """Automatically applies imputation based on missingness type. + + Args: + df (pd.DataFrame): Input data with missing values. + missingness (dict): Mapping of column names to missingness type. + + Returns: + pd.DataFrame: Data with imputed values. + """ + df = df.copy() + + for col, mtype in missingness.items(): + if df[col].dtype == "object": + # Categorical Data + if mtype == "MCAR": + df[col].fillna(df[col].mode()[0], inplace=True) # Mode Imputation + elif mtype == "MAR": + encoder = LabelEncoder() + df[col] = encoder.fit_transform(df[col].astype(str)) + df[col] = IterativeImputer().fit_transform(df[[col]]) # Classification-based + elif mtype == "MNAR": + df[col].fillna("Missing", inplace=True) # Add "Missing" Category + + else: + # Numerical Data + if mtype == "MCAR": + df[col] = SimpleImputer(strategy="mean").fit_transform(df[[col]]) + elif mtype == "MAR": + df[col] = IterativeImputer().fit_transform(df[[col]]) # Regression-based + elif mtype == "MNAR": + df[col] = IterativeImputer().fit_transform(df[[col]]) # EM Algorithm + + return df From 2f3c46565b337dd28978d20a87c5f8c6e1765e98 Mon Sep 17 00:00:00 2001 From: Emmanuel Jordy Menvouta <56538317+emmanueljordy@users.noreply.github.com> Date: Mon, 3 Mar 2025 15:41:35 +0100 Subject: [PATCH 3/4] Update repo by adding data handling logic and SDG methods Add: - pre/postprocessing - Gaussian Copula method -missing_data handler -example notebooks --- .venv/Scripts/Activate.ps1 | 502 ++++++++++++++++++ .venv/Scripts/activate | 70 +++ .venv/Scripts/activate.bat | 34 ++ .venv/Scripts/deactivate.bat | 22 + .venv/Scripts/f2py.exe | Bin 0 -> 108458 bytes .venv/Scripts/numpy-config.exe | Bin 0 -> 108458 bytes .venv/Scripts/pip.exe | Bin 0 -> 108465 bytes .venv/Scripts/pip3.12.exe | Bin 0 -> 108465 bytes .venv/Scripts/pip3.exe | Bin 0 -> 108465 bytes .venv/Scripts/py.test.exe | Bin 0 -> 108463 bytes .venv/Scripts/pytest.exe | Bin 0 -> 108463 bytes .venv/Scripts/python.exe | Bin 0 -> 270104 bytes .venv/Scripts/pythonw.exe | Bin 0 -> 258840 bytes .../01_missing_data_handler_example.ipynb | 127 +++++ .../02_data_processor_example.ipynb | 120 +++++ example_notebooks/03_gaussian_copula.ipynb | 123 +++++ example_notebooks/04_cart_method.ipynb | 122 +++++ example_notebooks/05_metrics.ipynb | 170 ++++++ pyproject.toml | 2 + setup.py | 1 + synthpop/__init__.py | 28 +- synthpop/constants.py | 2 + synthpop/method/GC.py | 465 +++++++--------- synthpop/method/__init__.py | 93 +--- synthpop/method/cart.py | 179 +++++-- synthpop/method/gaussian_copula.py | 106 ---- synthpop/method/helpers.py | 25 +- synthpop/method/test_method.ipynb | 0 synthpop/metrics/__init__.py | 29 + synthpop/metrics/diagnostic_report.py | 107 ++++ synthpop/metrics/efficacy_metrics.py | 101 ++++ synthpop/metrics/privacy_metrics.py | 86 +++ synthpop/metrics/single_columns_metrics.py | 253 +++++++++ synthpop/processor/__init__.py | 6 +- .../{data_processor => data_processor.py} | 12 +- synthpop/processor/missing_data_handler.py | 284 ++++++++-- synthpop/synthpop.py | 203 ------- synthpop/validator/__init__.py | 6 +- synthpop/validator/validator.py | 321 +---------- 39 files changed, 2510 insertions(+), 1089 deletions(-) create mode 100644 .venv/Scripts/Activate.ps1 create mode 100644 .venv/Scripts/activate create mode 100644 .venv/Scripts/activate.bat create mode 100644 .venv/Scripts/deactivate.bat create mode 100644 .venv/Scripts/f2py.exe create mode 100644 .venv/Scripts/numpy-config.exe create mode 100644 .venv/Scripts/pip.exe create mode 100644 .venv/Scripts/pip3.12.exe create mode 100644 .venv/Scripts/pip3.exe create mode 100644 .venv/Scripts/py.test.exe create mode 100644 .venv/Scripts/pytest.exe create mode 100644 .venv/Scripts/python.exe create mode 100644 .venv/Scripts/pythonw.exe create mode 100644 example_notebooks/01_missing_data_handler_example.ipynb create mode 100644 example_notebooks/02_data_processor_example.ipynb create mode 100644 example_notebooks/03_gaussian_copula.ipynb create mode 100644 example_notebooks/04_cart_method.ipynb create mode 100644 example_notebooks/05_metrics.ipynb create mode 100644 synthpop/constants.py delete mode 100644 synthpop/method/gaussian_copula.py delete mode 100644 synthpop/method/test_method.ipynb create mode 100644 synthpop/metrics/__init__.py create mode 100644 synthpop/metrics/diagnostic_report.py create mode 100644 synthpop/metrics/efficacy_metrics.py create mode 100644 synthpop/metrics/privacy_metrics.py create mode 100644 synthpop/metrics/single_columns_metrics.py rename synthpop/processor/{data_processor => data_processor.py} (93%) delete mode 100644 synthpop/synthpop.py diff --git a/.venv/Scripts/Activate.ps1 b/.venv/Scripts/Activate.ps1 new file mode 100644 index 0000000..b63e7b7 --- /dev/null +++ b/.venv/Scripts/Activate.ps1 @@ -0,0 +1,502 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove VIRTUAL_ENV_PROMPT altogether. + if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { + Remove-Item -Path env:VIRTUAL_ENV_PROMPT + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } + $env:VIRTUAL_ENV_PROMPT = $Prompt +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" + +# SIG # Begin signature block +# MIIvIwYJKoZIhvcNAQcCoIIvFDCCLxACAQExDzANBglghkgBZQMEAgEFADB5Bgor +# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG +# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBnL745ElCYk8vk +# dBtMuQhLeWJ3ZGfzKW4DHCYzAn+QB6CCE8MwggWQMIIDeKADAgECAhAFmxtXno4h +# MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK +# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV +# BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z +# ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ +# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 +# IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB +# AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z +# G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ +# anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s +# Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL +# 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb +# BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3 +# JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c +# AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx +# YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0 +# viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL +# T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud +# EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf +# Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk +# aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS +# PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK +# 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB +# cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp +# 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg +# dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri +# RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7 +# 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5 +# nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3 +# i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H +# EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G +# CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ +# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 +# IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla +# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE +# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz +# ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C +# 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce +# 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da +# E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T +# SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA +# FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh +# D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM +# 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z +# 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05 +# huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY +# mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP +# /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T +# AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD +# VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG +# A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY +# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj +# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV +# HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU +# cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN +# BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry +# sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL +# IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf +# Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh +# OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh +# dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV +# 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j +# wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH +# Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC +# XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l +# /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW +# eE4wggd3MIIFX6ADAgECAhAHHxQbizANJfMU6yMM0NHdMA0GCSqGSIb3DQEBCwUA +# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE +# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz +# ODQgMjAyMSBDQTEwHhcNMjIwMTE3MDAwMDAwWhcNMjUwMTE1MjM1OTU5WjB8MQsw +# CQYDVQQGEwJVUzEPMA0GA1UECBMGT3JlZ29uMRIwEAYDVQQHEwlCZWF2ZXJ0b24x +# IzAhBgNVBAoTGlB5dGhvbiBTb2Z0d2FyZSBGb3VuZGF0aW9uMSMwIQYDVQQDExpQ +# eXRob24gU29mdHdhcmUgRm91bmRhdGlvbjCCAiIwDQYJKoZIhvcNAQEBBQADggIP +# ADCCAgoCggIBAKgc0BTT+iKbtK6f2mr9pNMUTcAJxKdsuOiSYgDFfwhjQy89koM7 +# uP+QV/gwx8MzEt3c9tLJvDccVWQ8H7mVsk/K+X+IufBLCgUi0GGAZUegEAeRlSXx +# xhYScr818ma8EvGIZdiSOhqjYc4KnfgfIS4RLtZSrDFG2tN16yS8skFa3IHyvWdb +# D9PvZ4iYNAS4pjYDRjT/9uzPZ4Pan+53xZIcDgjiTwOh8VGuppxcia6a7xCyKoOA +# GjvCyQsj5223v1/Ig7Dp9mGI+nh1E3IwmyTIIuVHyK6Lqu352diDY+iCMpk9Zanm +# SjmB+GMVs+H/gOiofjjtf6oz0ki3rb7sQ8fTnonIL9dyGTJ0ZFYKeb6BLA66d2GA +# LwxZhLe5WH4Np9HcyXHACkppsE6ynYjTOd7+jN1PRJahN1oERzTzEiV6nCO1M3U1 +# HbPTGyq52IMFSBM2/07WTJSbOeXjvYR7aUxK9/ZkJiacl2iZI7IWe7JKhHohqKuc +# eQNyOzxTakLcRkzynvIrk33R9YVqtB4L6wtFxhUjvDnQg16xot2KVPdfyPAWd81w +# tZADmrUtsZ9qG79x1hBdyOl4vUtVPECuyhCxaw+faVjumapPUnwo8ygflJJ74J+B +# Yxf6UuD7m8yzsfXWkdv52DjL74TxzuFTLHPyARWCSCAbzn3ZIly+qIqDAgMBAAGj +# ggIGMIICAjAfBgNVHSMEGDAWgBRoN+Drtjv4XxGG+/5hewiIZfROQjAdBgNVHQ4E +# FgQUt/1Teh2XDuUj2WW3siYWJgkZHA8wDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQM +# MAoGCCsGAQUFBwMDMIG1BgNVHR8Ega0wgaowU6BRoE+GTWh0dHA6Ly9jcmwzLmRp +# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNI +# QTM4NDIwMjFDQTEuY3JsMFOgUaBPhk1odHRwOi8vY3JsNC5kaWdpY2VydC5jb20v +# RGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0Ex +# LmNybDA+BgNVHSAENzA1MDMGBmeBDAEEATApMCcGCCsGAQUFBwIBFhtodHRwOi8v +# d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwgZQGCCsGAQUFBwEBBIGHMIGEMCQGCCsGAQUF +# BzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wXAYIKwYBBQUHMAKGUGh0dHA6 +# Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWdu +# aW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3J0MAwGA1UdEwEB/wQCMAAwDQYJKoZI +# hvcNAQELBQADggIBABxv4AeV/5ltkELHSC63fXAFYS5tadcWTiNc2rskrNLrfH1N +# s0vgSZFoQxYBFKI159E8oQQ1SKbTEubZ/B9kmHPhprHya08+VVzxC88pOEvz68nA +# 82oEM09584aILqYmj8Pj7h/kmZNzuEL7WiwFa/U1hX+XiWfLIJQsAHBla0i7QRF2 +# de8/VSF0XXFa2kBQ6aiTsiLyKPNbaNtbcucaUdn6vVUS5izWOXM95BSkFSKdE45O +# q3FForNJXjBvSCpwcP36WklaHL+aHu1upIhCTUkzTHMh8b86WmjRUqbrnvdyR2yd +# I5l1OqcMBjkpPpIV6wcc+KY/RH2xvVuuoHjlUjwq2bHiNoX+W1scCpnA8YTs2d50 +# jDHUgwUo+ciwpffH0Riq132NFmrH3r67VaN3TuBxjI8SIZM58WEDkbeoriDk3hxU +# 8ZWV7b8AW6oyVBGfM06UgkfMb58h+tJPrFx8VI/WLq1dTqMfZOm5cuclMnUHs2uq +# rRNtnV8UfidPBL4ZHkTcClQbCoz0UbLhkiDvIS00Dn+BBcxw/TKqVL4Oaz3bkMSs +# M46LciTeucHY9ExRVt3zy7i149sd+F4QozPqn7FrSVHXmem3r7bjyHTxOgqxRCVa +# 18Vtx7P/8bYSBeS+WHCKcliFCecspusCDSlnRUjZwyPdP0VHxaZg2unjHY3rMYIa +# tjCCGrICAQEwfTBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu +# Yy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJT +# QTQwOTYgU0hBMzg0IDIwMjEgQ0ExAhAHHxQbizANJfMU6yMM0NHdMA0GCWCGSAFl +# AwQCAQUAoIHIMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcC +# AQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCBnAZ6P7YvTwq0fbF62 +# o7E75R0LxsW5OtyYiFESQckLhjBcBgorBgEEAYI3AgEMMU4wTKBGgEQAQgB1AGkA +# bAB0ADoAIABSAGUAbABlAGEAcwBlAF8AdgAzAC4AMQAyAC4ANABfADIAMAAyADQA +# MAA2ADAANgAuADAAMaECgAAwDQYJKoZIhvcNAQEBBQAEggIAV29hYhi09QNyGtav +# HZIo33y/iqXsIa4o88S5gzBa7Nnkwra0QLitSjvRfVbcFvq54Id+VIn00di4Nde0 +# maAUKPGXtTQL48esG/F/TLDOWd/jb9qCYHyNZYpJjKdXqI8IbyG6Pl05IMSas7wX +# DHsK19ZEGuGrmKCAxh6JbFXADgeUbftg3i9UxpMnfSugZjjdKIdyVWlzUnpYkKuI +# fpafwvNHfIYzfxOeV9CWsdqe34D6fRrEs8ZDEZSQl+Mw9aGaT39vuryFE1iKOzj0 +# uqrX/wN/wwu8oLWNC7JWE8SDG3eD0QLy+x7zEnlPkWsRV9nGOgrP9Khge0LgL+jP +# Km8iDs7fSGEOB/7PPxAl8yshEULOZAhBhcsGeGs+kQrVzlqZ9WlrU1Z1cylpLWzX +# Kkvs2DXD+zrplhpiVv6Gnn3YMBr4BKf0mXESTX9/BzIwvxlkhpv/BT0OWwrDlgPM +# hNj8jA5r2/WSqCg15DYjJ0RlnCerC/ORhSbs7v/HjpmH3DhaICJF7tdyFSIFXgNV +# W0GyQJMulQDEPd2+o+PNyAPElvGC3SYTjVnRLPcJTGhAt+VuHfnMG4HNkmyeU+nk +# OAMShxEax6NLeRsjKqqABUgZb2g4FSmXzHy7HgQOPmCQMv8xH4m8u992YMLyxh5U +# gGRUOUiAhrHXNZ6wG6T52NGQppehghc/MIIXOwYKKwYBBAGCNwMDATGCFyswghcn +# BgkqhkiG9w0BBwKgghcYMIIXFAIBAzEPMA0GCWCGSAFlAwQCAQUAMHcGCyqGSIb3 +# DQEJEAEEoGgEZjBkAgEBBglghkgBhv1sBwEwMTANBglghkgBZQMEAgEFAAQg+eJt +# Pwl5Hz89rrpf2qbsjNAUNlBq9SGjVuw+Erci2HcCEDPjoeI//+uRP30fqUoeHIAY +# DzIwMjQwNjA2MTk1MDE0WqCCEwkwggbCMIIEqqADAgECAhAFRK/zlJ0IOaa/2z9f +# 5WEWMA0GCSqGSIb3DQEBCwUAMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdp +# Q2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2 +# IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwHhcNMjMwNzE0MDAwMDAwWhcNMzQxMDEz +# MjM1OTU5WjBIMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4x +# IDAeBgNVBAMTF0RpZ2lDZXJ0IFRpbWVzdGFtcCAyMDIzMIICIjANBgkqhkiG9w0B +# AQEFAAOCAg8AMIICCgKCAgEAo1NFhx2DjlusPlSzI+DPn9fl0uddoQ4J3C9Io5d6 +# OyqcZ9xiFVjBqZMRp82qsmrdECmKHmJjadNYnDVxvzqX65RQjxwg6seaOy+WZuNp +# 52n+W8PWKyAcwZeUtKVQgfLPywemMGjKg0La/H8JJJSkghraarrYO8pd3hkYhftF +# 6g1hbJ3+cV7EBpo88MUueQ8bZlLjyNY+X9pD04T10Mf2SC1eRXWWdf7dEKEbg8G4 +# 5lKVtUfXeCk5a+B4WZfjRCtK1ZXO7wgX6oJkTf8j48qG7rSkIWRw69XloNpjsy7p +# Be6q9iT1HbybHLK3X9/w7nZ9MZllR1WdSiQvrCuXvp/k/XtzPjLuUjT71Lvr1KAs +# NJvj3m5kGQc3AZEPHLVRzapMZoOIaGK7vEEbeBlt5NkP4FhB+9ixLOFRr7StFQYU +# 6mIIE9NpHnxkTZ0P387RXoyqq1AVybPKvNfEO2hEo6U7Qv1zfe7dCv95NBB+plwK +# WEwAPoVpdceDZNZ1zY8SdlalJPrXxGshuugfNJgvOuprAbD3+yqG7HtSOKmYCaFx +# smxxrz64b5bV4RAT/mFHCoz+8LbH1cfebCTwv0KCyqBxPZySkwS0aXAnDU+3tTbR +# yV8IpHCj7ArxES5k4MsiK8rxKBMhSVF+BmbTO77665E42FEHypS34lCh8zrTioPL +# QHsCAwEAAaOCAYswggGHMA4GA1UdDwEB/wQEAwIHgDAMBgNVHRMBAf8EAjAAMBYG +# A1UdJQEB/wQMMAoGCCsGAQUFBwMIMCAGA1UdIAQZMBcwCAYGZ4EMAQQCMAsGCWCG +# SAGG/WwHATAfBgNVHSMEGDAWgBS6FtltTYUvcyl2mi91jGogj57IbzAdBgNVHQ4E +# FgQUpbbvE+fvzdBkodVWqWUxo97V40kwWgYDVR0fBFMwUTBPoE2gS4ZJaHR0cDov +# L2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5NlNIQTI1 +# NlRpbWVTdGFtcGluZ0NBLmNybDCBkAYIKwYBBQUHAQEEgYMwgYAwJAYIKwYBBQUH +# MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBYBggrBgEFBQcwAoZMaHR0cDov +# L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5NlNI +# QTI1NlRpbWVTdGFtcGluZ0NBLmNydDANBgkqhkiG9w0BAQsFAAOCAgEAgRrW3qCp +# tZgXvHCNT4o8aJzYJf/LLOTN6l0ikuyMIgKpuM+AqNnn48XtJoKKcS8Y3U623mzX +# 4WCcK+3tPUiOuGu6fF29wmE3aEl3o+uQqhLXJ4Xzjh6S2sJAOJ9dyKAuJXglnSoF +# eoQpmLZXeY/bJlYrsPOnvTcM2Jh2T1a5UsK2nTipgedtQVyMadG5K8TGe8+c+nji +# kxp2oml101DkRBK+IA2eqUTQ+OVJdwhaIcW0z5iVGlS6ubzBaRm6zxbygzc0brBB +# Jt3eWpdPM43UjXd9dUWhpVgmagNF3tlQtVCMr1a9TMXhRsUo063nQwBw3syYnhmJ +# A+rUkTfvTVLzyWAhxFZH7doRS4wyw4jmWOK22z75X7BC1o/jF5HRqsBV44a/rCcs +# QdCaM0qoNtS5cpZ+l3k4SF/Kwtw9Mt911jZnWon49qfH5U81PAC9vpwqbHkB3NpE +# 5jreODsHXjlY9HxzMVWggBHLFAx+rrz+pOt5Zapo1iLKO+uagjVXKBbLafIymrLS +# 2Dq4sUaGa7oX/cR3bBVsrquvczroSUa31X/MtjjA2Owc9bahuEMs305MfR5ocMB3 +# CtQC4Fxguyj/OOVSWtasFyIjTvTs0xf7UGv/B3cfcZdEQcm4RtNsMnxYL2dHZeUb +# c7aZ+WssBkbvQR7w8F/g29mtkIBEr4AQQYowggauMIIElqADAgECAhAHNje3JFR8 +# 2Ees/ShmKl5bMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK +# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV +# BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0yMjAzMjMwMDAwMDBaFw0z +# NzAzMjIyMzU5NTlaMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwg +# SW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2IFNIQTI1 +# NiBUaW1lU3RhbXBpbmcgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC +# AQDGhjUGSbPBPXJJUVXHJQPE8pE3qZdRodbSg9GeTKJtoLDMg/la9hGhRBVCX6SI +# 82j6ffOciQt/nR+eDzMfUBMLJnOWbfhXqAJ9/UO0hNoR8XOxs+4rgISKIhjf69o9 +# xBd/qxkrPkLcZ47qUT3w1lbU5ygt69OxtXXnHwZljZQp09nsad/ZkIdGAHvbREGJ +# 3HxqV3rwN3mfXazL6IRktFLydkf3YYMZ3V+0VAshaG43IbtArF+y3kp9zvU5Emfv +# DqVjbOSmxR3NNg1c1eYbqMFkdECnwHLFuk4fsbVYTXn+149zk6wsOeKlSNbwsDET +# qVcplicu9Yemj052FVUmcJgmf6AaRyBD40NjgHt1biclkJg6OBGz9vae5jtb7IHe +# IhTZgirHkr+g3uM+onP65x9abJTyUpURK1h0QCirc0PO30qhHGs4xSnzyqqWc0Jo +# n7ZGs506o9UD4L/wojzKQtwYSH8UNM/STKvvmz3+DrhkKvp1KCRB7UK/BZxmSVJQ +# 9FHzNklNiyDSLFc1eSuo80VgvCONWPfcYd6T/jnA+bIwpUzX6ZhKWD7TA4j+s4/T +# Xkt2ElGTyYwMO1uKIqjBJgj5FBASA31fI7tk42PgpuE+9sJ0sj8eCXbsq11GdeJg +# o1gJASgADoRU7s7pXcheMBK9Rp6103a50g5rmQzSM7TNsQIDAQABo4IBXTCCAVkw +# EgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUuhbZbU2FL3MpdpovdYxqII+e +# yG8wHwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQD +# AgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEF +# BQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRw +# Oi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy +# dDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln +# aUNlcnRUcnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglg +# hkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIBAH1ZjsCTtm+YqUQiAX5m1tghQuGw +# GC4QTRPPMFPOvxj7x1Bd4ksp+3CKDaopafxpwc8dB+k+YMjYC+VcW9dth/qEICU0 +# MWfNthKWb8RQTGIdDAiCqBa9qVbPFXONASIlzpVpP0d3+3J0FNf/q0+KLHqrhc1D +# X+1gtqpPkWaeLJ7giqzl/Yy8ZCaHbJK9nXzQcAp876i8dU+6WvepELJd6f8oVInw +# 1YpxdmXazPByoyP6wCeCRK6ZJxurJB4mwbfeKuv2nrF5mYGjVoarCkXJ38SNoOeY +# +/umnXKvxMfBwWpx2cYTgAnEtp/Nh4cku0+jSbl3ZpHxcpzpSwJSpzd+k1OsOx0I +# SQ+UzTl63f8lY5knLD0/a6fxZsNBzU+2QJshIUDQtxMkzdwdeDrknq3lNHGS1yZr +# 5Dhzq6YBT70/O3itTK37xJV77QpfMzmHQXh6OOmc4d0j/R0o08f56PGYX/sr2H7y +# Rp11LB4nLCbbbxV7HhmLNriT1ObyF5lZynDwN7+YAN8gFk8n+2BnFqFmut1VwDop +# hrCYoCvtlUG3OtUVmDG0YgkPCr2B2RP+v6TR81fZvAT6gt4y3wSJ8ADNXcL50CN/ +# AAvkdgIm2fBldkKmKYcJRyvmfxqkhQ/8mJb2VVQrH4D6wPIOK+XW+6kvRBVK5xMO +# Hds3OBqhK/bt1nz8MIIFjTCCBHWgAwIBAgIQDpsYjvnQLefv21DiCEAYWjANBgkq +# hkiG9w0BAQwFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5j +# MRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBB +# c3N1cmVkIElEIFJvb3QgQ0EwHhcNMjIwODAxMDAwMDAwWhcNMzExMTA5MjM1OTU5 +# WjBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL +# ExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJv +# b3QgRzQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1K +# PDAiMGkz7MKnJS7JIT3yithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2r +# snnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C +# 8weE5nQ7bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBf +# sXpm7nfISKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGY +# QJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8 +# rhsDdV14Ztk6MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaY +# dj1ZXUJ2h4mXaXpI8OCiEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+ +# wJS00mFt6zPZxd9LBADMfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw +# ++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+N +# P8m800ERElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7F +# wI+isX4KJpn15GkvmB0t9dmpsh3lGwIDAQABo4IBOjCCATYwDwYDVR0TAQH/BAUw +# AwEB/zAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wHwYDVR0jBBgwFoAU +# Reuir/SSy4IxLVGLp6chnfNtyA8wDgYDVR0PAQH/BAQDAgGGMHkGCCsGAQUFBwEB +# BG0wazAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEMGCCsG +# AQUFBzAChjdodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRBc3N1 +# cmVkSURSb290Q0EuY3J0MEUGA1UdHwQ+MDwwOqA4oDaGNGh0dHA6Ly9jcmwzLmRp +# Z2ljZXJ0LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcmwwEQYDVR0gBAow +# CDAGBgRVHSAAMA0GCSqGSIb3DQEBDAUAA4IBAQBwoL9DXFXnOF+go3QbPbYW1/e/ +# Vwe9mqyhhyzshV6pGrsi+IcaaVQi7aSId229GhT0E0p6Ly23OO/0/4C5+KH38nLe +# JLxSA8hO0Cre+i1Wz/n096wwepqLsl7Uz9FDRJtDIeuWcqFItJnLnU+nBgMTdydE +# 1Od/6Fmo8L8vC6bp8jQ87PcDx4eo0kxAGTVGamlUsLihVo7spNU96LHc/RzY9Hda +# XFSMb++hUD38dglohJ9vytsgjTVgHAIDyyCwrFigDkBjxZgiwbJZ9VVrzyerbHbO +# byMt9H5xaiNrIv8SuFQtJ37YOtnwtoeW/VvRXKwYw02fc7cBqZ9Xql4o4rmUMYID +# djCCA3ICAQEwdzBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu +# Yy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5NiBTSEEyNTYg +# VGltZVN0YW1waW5nIENBAhAFRK/zlJ0IOaa/2z9f5WEWMA0GCWCGSAFlAwQCAQUA +# oIHRMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcN +# MjQwNjA2MTk1MDE0WjArBgsqhkiG9w0BCRACDDEcMBowGDAWBBRm8CsywsLJD4Jd +# zqqKycZPGZzPQDAvBgkqhkiG9w0BCQQxIgQgUvswt0fWRoofHUAuTE0/8V9tLmHP +# zr/l2RTobZjBdqYwNwYLKoZIhvcNAQkQAi8xKDAmMCQwIgQg0vbkbe10IszR1EBX +# aEE2b4KK2lWarjMWr00amtQMeCgwDQYJKoZIhvcNAQEBBQAEggIAc7/uG/S8kf0i +# 2kaDQkE8NSfiXCYfN7z/2sgi6RNrkipvs/KTWfEKuMbhu9qWjjusZFgywn/IrZqw +# td4Js1kmaN+HJ02t/HXYUCr+KTJye4mDaBGvaXXHllCqsK7bhsJxJYE0uYiL03MP +# g64jyu9WdJD3N26MW/DkO6HTVhYzRzjafbAKbrr8KCvaFan1KZERzYwbA8XVjm88 +# HOodLCA9h+91Iqdc+uSz3Sg9/+Ns4zCp4BonvnsPYTlWTitiB5cpfPe/v4lBvCNu +# x0ha6whvKMdRLZJgXsiDXo2NwwB55kkWEBwD3a1RnBJQmyJxFEGpSXOrhmdcEWPg +# fjoHVIfowKBrIgINdWJbvIu+pLzQRMkVhuJzB32xpiZBIvbzkPETYQMOmKIu40I9 +# 5EAL0xNakPxYiT3nTkncn6woLOhiOXFm7crE+gO4IzDNauYuT9Vfe36K1CqtuYSy +# JesLIey9Z81OQqOo6n2/lW110MKMEV2PkPU7YW/bYO2uKsZ3OAjUWr63nMT+M2wk +# VdUAcqm0QdZsELY75Q3ekRxHje/B9ePP4Q4RMQGOZvmgqdtEeFhsmRwufR4fzfqx +# WMttmOHelTd8Sc0sfA9B+1dxtiC9GFn3de5/o+T2s/jQn6eNp2hvlCqGV0iFzSQp +# InPTBa9Na/+5UeXZ3NBWRvarfZ62TVM= +# SIG # End signature block diff --git a/.venv/Scripts/activate b/.venv/Scripts/activate new file mode 100644 index 0000000..cffeeaa --- /dev/null +++ b/.venv/Scripts/activate @@ -0,0 +1,70 @@ +# This file must be used with "source bin/activate" *from bash* +# You cannot run it directly + +deactivate () { + # reset old environment variables + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # Call hash to forget past commands. Without forgetting + # past commands the $PATH changes we made may not be respected + hash -r 2> /dev/null + + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +# on Windows, a path can contain colons and backslashes and has to be converted: +if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then + # transform D:\path\to\venv to /d/path/to/venv on MSYS + # and to /cygdrive/d/path/to/venv on Cygwin + export VIRTUAL_ENV=$(cygpath "c:\Users\valen\OneDrive\Documents\Jordy Projects\AlgorithmAudit\python_synthpop\.venv") +else + # use the path as-is + export VIRTUAL_ENV="c:\Users\valen\OneDrive\Documents\Jordy Projects\AlgorithmAudit\python_synthpop\.venv" +fi + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/Scripts:$PATH" +export PATH + +# unset PYTHONHOME if set +# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) +# could use `if (set -u; : $PYTHONHOME) ;` in bash +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1:-}" + PS1="(.venv) ${PS1:-}" + export PS1 + VIRTUAL_ENV_PROMPT="(.venv) " + export VIRTUAL_ENV_PROMPT +fi + +# Call hash to forget past commands. Without forgetting +# past commands the $PATH changes we made may not be respected +hash -r 2> /dev/null diff --git a/.venv/Scripts/activate.bat b/.venv/Scripts/activate.bat new file mode 100644 index 0000000..2d6b787 --- /dev/null +++ b/.venv/Scripts/activate.bat @@ -0,0 +1,34 @@ +@echo off + +rem This file is UTF-8 encoded, so we need to update the current code page while executing it +for /f "tokens=2 delims=:." %%a in ('"%SystemRoot%\System32\chcp.com"') do ( + set _OLD_CODEPAGE=%%a +) +if defined _OLD_CODEPAGE ( + "%SystemRoot%\System32\chcp.com" 65001 > nul +) + +set VIRTUAL_ENV=c:\Users\valen\OneDrive\Documents\Jordy Projects\AlgorithmAudit\python_synthpop\.venv + +if not defined PROMPT set PROMPT=$P$G + +if defined _OLD_VIRTUAL_PROMPT set PROMPT=%_OLD_VIRTUAL_PROMPT% +if defined _OLD_VIRTUAL_PYTHONHOME set PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME% + +set _OLD_VIRTUAL_PROMPT=%PROMPT% +set PROMPT=(.venv) %PROMPT% + +if defined PYTHONHOME set _OLD_VIRTUAL_PYTHONHOME=%PYTHONHOME% +set PYTHONHOME= + +if defined _OLD_VIRTUAL_PATH set PATH=%_OLD_VIRTUAL_PATH% +if not defined _OLD_VIRTUAL_PATH set _OLD_VIRTUAL_PATH=%PATH% + +set PATH=%VIRTUAL_ENV%\Scripts;%PATH% +set VIRTUAL_ENV_PROMPT=(.venv) + +:END +if defined _OLD_CODEPAGE ( + "%SystemRoot%\System32\chcp.com" %_OLD_CODEPAGE% > nul + set _OLD_CODEPAGE= +) diff --git a/.venv/Scripts/deactivate.bat b/.venv/Scripts/deactivate.bat new file mode 100644 index 0000000..62a39a7 --- /dev/null +++ b/.venv/Scripts/deactivate.bat @@ -0,0 +1,22 @@ +@echo off + +if defined _OLD_VIRTUAL_PROMPT ( + set "PROMPT=%_OLD_VIRTUAL_PROMPT%" +) +set _OLD_VIRTUAL_PROMPT= + +if defined _OLD_VIRTUAL_PYTHONHOME ( + set "PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%" + set _OLD_VIRTUAL_PYTHONHOME= +) + +if defined _OLD_VIRTUAL_PATH ( + set "PATH=%_OLD_VIRTUAL_PATH%" +) + +set _OLD_VIRTUAL_PATH= + +set VIRTUAL_ENV= +set VIRTUAL_ENV_PROMPT= + +:END diff --git a/.venv/Scripts/f2py.exe b/.venv/Scripts/f2py.exe new file mode 100644 index 0000000000000000000000000000000000000000..dd6da593ec7c6e66273412e406907ddd7c57fd6a GIT binary patch literal 108458 zcmeFadw5jU)%ZWjWXKQ_P7p@IO-Bic#!G0tBo5RJ%;*`JC{}2xf}+8Qib}(bU_}i* zNt@v~ed)#4zP;$%+PC)dzP-K@u*HN(5-vi(8(ykWyqs}B0W}HN^ZTrQW|Da6`@GNh z?;nrOIeVXdS$plZ*IsMwwRUQ*Tjz4ST&_I+w{4fJg{S
9ahiv48usxvYph53A*~8(9C(zhxUuAG_s-p91ME#!0Q$JSe%fv0pf`Iy`k-vUY&tiPqL?X zvbdHFYS-%QRTNw0a;_E}o fZE#A@+KUZ! $4dp*1|c4o(ssj&>wkjNm~aX$iNMcV14@ZI|{H zteO#9yn&@U{r+j|$KTficN6^epS51~xY&fSu_`(9-m4Oc$sEe1%lMrkgUjW+tc!5e zgK{8^X`#jX1dbAKLcU~WI1ZN@hgR(%0-TSU^Zzg(+AFW7aED6TPGE$v?$2xWANhN3 zW^=8_`jB8w;_b6g-wYRiU%+k67$s$3wB$Xs=d4%s)FPu#V6f=L>+hd{RBmFN6nK~Q zA^ONfNwq$8>`Yr+CA|pKr0h>E5yX|AZ((`Y_fSPl*yW&O<`6hpr$o84=fePl5_C zaAEblI|_9p=={%tjKW&}Qy)B05hJb3$n&TS>r9<>y=?g_8$~(U+kv0F5JIzmL=C|Y zZ)J4f@p-JT{x2itfeVp|Ey%yJbBS+bz>^`fePLGA;jI0~kn)bwvfi#>U*yiT&fXvT z4rhDNs-1*Z?WeU??I8oHfTyh&-;zr7G(5#-l0>GH$oZj|R=mf_>Gl0sTV>q8Vl3wn zdnv2JW@#f$u?hH`amgUb2{IfW&n>$;Q@%~zNn~pY1t+^N;^&?Q*%BichZ7V)-sAVM z` bpKsGH=pT&i!vuH0x=%)GL8)31qNbEr*FT7eaVPc5%> zpSU6JKHQejp@j%9+xp|%wukSC2Lw+t^xt&FptzLtz_Eqqf~G!ooqABDH)4e{92UxX zMrX>|
0LWzQKOtB?ny+XZb^ =4+M+5=f4>c;9Ej z7tu5vdBuH+=f+s
r}mV#cafb!(7!3=m#mFD z_fnX*eH*epc{IzneS5Rx3ZQ|aZ|1dqqFdH!WBEMP_8uSFwjBftUrA^ogl_n> 2W*^$!WUD&UoL(n6bH?yJyA+6E+Oy7Cl-d z*t+q5LmxrcebPxks(H>oiW7E!(|QSy3YqK)OrF`)cT>_IS*7|zi958qAz7j8nwEO^ z` gOEPNKGP&=L73boh(8E8x%E b4b zzCsCqKgN_WpON=OB|MFS^ekbfl(0Vzx?I)bW1CPw`Y4B_T@^LCdx;WhZE~8UMWaMK z%03I?P-P1wuh|pXqop@jPoOUXq#rLL1;pD$P4W*WphWe+QQnqt>cn*J%P0?e1f 6Rp^+8hqunvz;&Sx6HQKa3hu^Pxm{_Jlp?Umh)V2_!_b2+z(u zcHOpiR_segNsE@x6z*V}0y7Ty&>(SrGz8JD2 8qn_-zOuCpD~#2Ct1kRYrW2tIXVZ7^q;c=qU}w6z5VCR3nEV6wu JZbuMb_Fh^uaF_0 jc?m?bbGyY)f%N3*m#X-rb81yl(n$b5OyH4h^jj z?;S>*F8#NTsyxwu`zS6w^xr;oqkHS{Nd33A(yL}}@yzu+)X;Z7uD%@>8n5(9>nI8; zWWMo*T3Et*8j8u8h>G9nHgK8^|8CpAX~WxX*gzIUq %yV^w8t3upxNUace9#R_-3US>Dy7DPR zH-)(8{clr sI!>Z{|SY-y7{zE zl2~;tT?%o}JK8P^aRFh4xZp84q4Rh&3#GaLe^7{f&ql_}6Dq_-9x>@zw!oTrkqU9s zhtdxIM+$LoB3j;6PL+6iQ;54 @oX!^J)DhX;)xaF))?PH z#uF>V{p6=%Li-~X;(l_LPRdb;YgD_+(m1RU_xThA%r=hJ8gZwykYvIM#QW-x#-WCr zrP-G&$h~>GS!8~hg4|gsU@Z$w;;*A1cN5oL-cM+6tUJ4cI~AQf kN}=GnIX}UEB2_!we3-nJ4x(IQ1C9W+|zKfKvd)o z7Kn=6egaXE+eaX(9OYh;s5dH BKPasgRLU>A}1PD exrbo}5QDqzeS^fby<-qp+v|cr^tiSI#wx0<1w^RUtBPDx8gX9O_ES7s zPhJ*YIbNG>tH}N4;mG?&EYL;JRWuG~upaoiA1cE%;+@V$9agp qUSN2^Q-L6iU zbJBmXKT0Ncwkei{jHg-6x4{Sz-MCj}&dMaM+RARaakH`NZGR*eT+%3S#Qtc2eh0 L$EcL`h|cCwTyo7meir45qW_ypeM~7y_JZ z!o4-OO5no44Mw7whm8*g&6N^i6-SLi^G4f7iHoo3`o5hAKhi0$yDG)Hg>ww&z#wln z-Dp=k3PBe!lIOQtcTY99OMLa;9Hcz!g{{VA#ti*NEh@III$w@_28a+m&$Pf=7e4g2 zzD+Ychgi++4r?lC-P)rnq~tnE_!fw4nd>A+^}7o%mwhrZr4v)|RLez(rprgOeS6d= zO?WMLNMwkL2;H`bZ@5+L_4@3MX8Xm I5|qfxsj}$AfKM?%H|l})Yttw(<>zSf^}rqQ^MA}coYYVK(Q7>GhiUuc z${xCjvd`w&MIU}pfKRhb;XMsMXINmy2 i-}^sUw=|1pn$$98FRi2rB9+R;a;6~fxl?~TJ;rMl$xRda5T${3Oy zd3HcHr@kNhl%wU)@8x_Z#hQLecs%;xTy`Fx5_w)|6e>%MdX`6KVIhaWG3nCOEP4Zc zd-0UnYP0|^pHUX&4^3ZECd?_G@4IEMKXdwgzJgU;s0@9;twqtX(*89#du}e1&FB~W zxU)H|w`<`#p%2|cPDbPn;=b1QYjjo68JYvb{1g7l*k-L~rzh%nWP=ro;f$?0Xia_J z-#8hPuJSide|3d)9@zT7Aa5Lph|XG?eXhijZ9Vz`F*e5TE`nKf_5H%GU%lG8>pso5 zueQ!u;?O`358-y-b@osD&mp!Lj`!Y@q{lS*-PTEUI?{PM<>mmKq%`PIU@{W)YAs0C z$Jc33XWO2BVmwWd&(H_br*8Cz`s7b|&mTILd*BOsAgwyT7?G^zK+Y3F`h3yTwO=aW zy#Hbv=Bh?;sNA5NJ!4v#r{NBKfF^> lzq zb$pN|ZU^7_g)Bk$*;kFFs=e0BnN0oS?Gody?T2{karT%c2aoy=41CE?U`<+E@hn+O zlbdqBhBeV6f+J~4DPrg4v@DAOSKpi)vqz59DP*iZW$o<_9b-s=3?DLb$R**>0pE6R zH?fFs=9V4@q$r^4b<9J@lzrO!?$l0sSMxj<5-Zb>m|=n?NT2|_D0xvAH7I0QtdNQO zJ(_tKvOPELAeGLPRQL_P-^s+ nJ=g@#ux^GYXpUE{ZwY%4mtMy` zdD-kT#=b{X9jwOZtT&0DvoK!6%*}kuA9^XrlfM`1d(0Ud7u{|%Ik|RN`|DOdG1q6r z1{16?I=LhQ`+2%b^zuJvamYnhSH{cONPldZdayI)YQEYRt-cIG5jmdDW*H}iH2NvA zXgf!$iFMgbydF8^ABJ4ZTij0d*P{@5ob|{8DVHQ npw}3AsEltK@!{1nR%n)CuKi>d2T@PY-k9ymfU~yL<&J9ht@~pg zsbzbf*zY^=DK|Z`I8|Q)#5N!|KM<`AqzObvgjXQiA^fxJ@?7pZ4#J-1X1&T-$G6IG zwWs&6zh2u%wWs3C<-V>x*>NWm*ksh9a3>h2b<*&_(vjDOHIGxx3MDOMLMqg4%m2u< zG{pMJd}m0u7SG_YTUf2_@uAq!aCI78P`uu`56<9JF*em 1t$8(4-nZr^QMU)K7yX6e z$OG3;c^em`w#}qp_VU1WdywMw^1$`3MHICA1J`3eavIco(vn!eGQfG;himmbayZOd zF+21mmL+5T*2{mE FA5+U{qO65&=u9G-(S%t(!U9u$k=_u#4Agc&UD^ zGa+fi!|qAl}W!VVpe1hJ4O)*{|ruTPSJsCDM^mN|0!6*EN}Rld XkX27H zll;60td$0~ShuqcVcI}V-QM<8lXBOjVC{hjqV&=bm-9K2MXRc$TmK#(B`Ad84-00! zBIKOUPopJ*M<^S2;j|FIWpNa_G4`${Qu5t?qnCl{`BrVg&HY3nNT5$=N+?!)N!!&q z&I0Wm_pbgc>~fOi&LgRM{h@bR*%w$JOb}s2b~jwpjC9GeUhL@tStLxM^@#0~9vNmk z!=bWPtm!2> Ct{ZaWhL_dg=sbxtI`?UY(s{cWdi36hm`YjV#_nu1YR2SRS^ z!Fzhk4da8dp7>^OPI}yycYu#0iI%6cHuUPGL#>Q(>QOw_6w1nva1Rr@{_#58*rS S#BR!2%5`H^JUW8LYM5t6CBi-t*er=)B!pCRzmQ8EXmAzy>l%Hj7up{f%TBR9RMK}mW|MUBQmIAG3NCQ{u z0~@L-=DVK_(` hN3LD;F!`p258yoJnVXF-f+t5AL#Gh)z(``7@hIuwzYQrmR zc)bmOXu~vFnD85H!#*~A?<`~gk?l`SGvA3e9BadwHoVY=SJ-fa4R5#MRvSKL!#8dC zfenw@aKLnv&M7v$(1wLJth8Z+4R5yLW*gpX!-s6R(}pkF@NFA**zi*u#-C}@_1f@s z8=hms`8NEz4XbUq!G@b`xY>sH+VBY*9 d$J8PZ0NV)*KN4UhBw&odp7*J z4Ii-K9vi-9!)bOs>dNKMGj=^bWWz&Fy*eIF05^{lrEW?MDl)L}pn=caZD7w}?$3;U z-6_4hNBVaqeXvZvWhs-7X+ 5lf9K$B+5tt0KOO70fdIn~UFN*aWqGWIRR0(`9SQqm;?N zf}WCJu0`s6O4%h}PJRrm b5 z_^R#UZ!!5O(IxNhvJl^;5x(=Gab-l<1-N(rmV7wrDq5MOr<93bz9l{>hr}cKmhh~6 z{AaIRd3J5ML6z`3-J8$PE68eo_##~X 9U$&QBAml&o8Rf zpQNiuOA)`st%y_N!&DM}wIVKwN6jr=rU;`J6a|7cB{=Y#TT^ah(4{O`Qycz*UZo|K zr4bejgXSy0s#5z}5VT=YK;n_`5=P-q;YZ;vNhnuTbWCiYICtOpgv6wNp5*=m1`bLY zJS27KNyCPZIC-RZ)aWr|$DJ}h?bOpIoIY{V z5Z6Eh{c5UB05M{E90pR#sM3f1{>0 z5WMQ@RjaT0=9;zFUZ>_%)#R)y4;0i?6_-lwuB0s$Q};Erf>Je!mQ 1^kQj $ap5>jf{=b z56da_3cf0J|1H;JTV!0~UQU|jxL5G^8rz@ro_O86O#I@n1ovX?E k%|D6Jgeb?QlKSvM87ZZSbtSekQhK$|E6Kmfdw^aorI%W)CB_Qvr%Ely zPU4d~bxJ1VQx}~kYC5eXZ5dN#%<-x;W`ttCYSgKGEhoN8zNO5PC$W*1AoP?H9Z#uB zokwXwW)6_@Nehb%nXU6Aqp9R;lCE88PfmSL3D qbeZN0_i)ooDPv6H7R z`c6@2h2wMb^VRC}YSQXG#op`G&|wOrhLiuVo}Tn9>9hZx^rnZ?tEP>bHgFYj)extw zIx3*r@jc1un_U!h@;@yc-&fE7<>Xw}N~=gWKpz$gIbYHuom%Wl&8hD*)QoU?z14RW zwJP;xMndV|ReH3LQL~gWQbw&(9fQ-39B9gOMvwL+xsn)Vd@y5 MC@_T%IE1|lKf kF|&gSBdxJJjbsld zzrtj*-;$G6{j ?eC%Xx7YqY$^PD&X#8`vLjSVtZ@HWyzm5ds&J_Ut+hTu@w7*;9jl0+WuC~8N z+23_; ()`k9?#x3GPbjc&-~JeK}L)U`k?&MDuWdjps?}#aHhxMYIGmf zCn`B6CnqOXe$&&5OFVir3YNsV)miE3iwoeNd%e1exeLn*`6;!kdKEu6K6rV-?FP8{ zC!hcMK>_b^|I!!-&A;Q_j<@ksGhg z_+~wSSQ@T(7$RMZxp=D*v4D z-v6|L >tB@XtNnArAK#+?S(|^<10 RkcF}imB>egLf-?09MZ*6GY7`n0Prf+Zh&duMw z<<{?g|F$3e@JF}*_$NQze8-(X`}r^Kx_iqne|68jzy8f{xBl0C_doF9Ll1A;{>Y<` zJ^sY+ns@Bnwfo6Edt3HB_4G5(KKK0o0|#Gt@uinvIrQplufOs8H{WXg!`pv+=TCqB zi`DjS`+M(y@YjwH|MvHfK0bWp=qI0k_BpC+{>KcO6Ek4G5`*U7UH*S}`u}74|04$3 ziQP4W?B8AfSk8mxfZq9y;9F$LoF6iZ-M*Xnj$BLJ)Z?4mzunw7_4wuvcsKW(dwhSl z$G1FL8JV6uYZ>`1(kHT}ZpO$-{CTAguW@mCWl7c53j#%fa`>UxFRCrAnYZkU(&9jF z^bUmD*u3VdRH *`q0Mc+_&!}WE8Vq;m+tzW+$!l$R#71V7|Zk0AZqhN6z z>opd21qB-j>P@TLP)8`mvaYPG% X6^@^t?zN?XK!meeS#+g*)&@!_eR(BCFW1F#!gsk>1p~c#u=CgD4_bbS zzeUuG!zXcg%f-};a3_RUA-hr8K?uJ?ILL Q+pNIj<;)4aPup!stnXrRd~ya zDoZL#YrH+n*;RilN&{41dB9s-RZ{A$TJEiOc=Zy~B+^}laek9&Kegm&GVMTeF&Q`6 z)jPkORn>Gb(=trW6Yt8E 6X0`$U sb$wOqb8}>qxrm+(r5?Db-CO(vLS-D}-6JaPCBN zVjSsTr#yblcyEzi3TZ`=p-JI*|D(o3+KP&*t0iIy-J>}eq8%5mdyV!;rI&PyYE}fL z!fU;0rB^Xhl`r>}uB;BMKJ_1`w~VG{4`M}Rw77`Y;524wu-=uWE351y!O?b49IZ!G z>4#o*ydC_r1=$O3T{GeF-?yBX^Mk`lj~;vLYw0eEI_K=AGC$QWy_iP0dMW2+GEvno ztu0?!T~T_uGY&5;DX$GI4V*b`Qgw+Lhz*%e_*dfYKhUiPmL#fy(-PFc`JVkr%?Z_S z%rWu;cY2k25|bqY{rsNtD)lDD`R;#Gj5=w`;OdmZLFp1k;@dY$slQ{sW`}VNjaNeh zNopu*3|*L@hEC(VCZ&1k#H8sXcYD;ZKtDC4B#HDBm1k;vO`q17{ZYcqSi>9$aK*={ zc*5XP?MiT|1WM)_6t4zN^Qb{nk~{jfChm`Kc2~z0_9^HuY3(MB0I;MlX}Q(V`6>II zytSOJ)E_VbCvUv(5kq|ahsUbnvs0T*NtAN@Z |uz2brSq&?pKBo0k!)_k5e?W6`fh#p$rBZLH)LSZbkUC%6 zSN9*(M-3`*QwMQU2fDpTxpHSJwFDC`SDz@=XMWU|){ErtGH%9vgn7r#PZaF4AsFYo zHyRe7%Xu-zNvnVVKB_-?>_0_XaD1Udt9!DPdLHxFFGz@AU)`Sis`&YR!uj6j<4k?F zQbRvC(1o6)L|1?1@+K;8Nq^;Cn5?|e#alDH MYWcpDQj(#kqc@`;E{~o8&% x%-G@%@t4 zZify%esd{8`b!yWoIFS!)kLKa9qA@b_Tn{N{Ym @RUni3*Pi z*Oe%BD`usgrpcG-A5I&c%QB(>v%&UL3NH6Iw?yW13T rdLxd&{Xi z1Z14Bavf_KCLDG^j2bX4Ne#F;p}?j4qutMj$D2B&Zim-&)t^JF*RMb`(3L2N?VgA9 zp%WA6D;KF@3k&Ek^VBfc`O4HhnOVblL8e^86V&iPD(zzk?PIVS?i!#>uf$D{iS%#k zb13y`_wVNZCuldnLJs9*1ZA9dWBNP&yu=<)=cjZ;_V?v1xqgNDi=FR@;JYwG>^|U1 zajO)@mK4U86xveCl>W{AkGI?J(BWq=>i>Y5;)K`vC+!l(*@fY8w%OGq|1KF{Ih1e> zaWlsERYMj6sk oRm1Nj|E>M^dzzD~6AKg4<7vbFWlUo18OFRcY|4-h zLpxLF(oeRs6M7rtJ|-~{mmaGaqsUL{G`C8fV)sQU7jaO=Rx`VGjSWBk9%BQhD-Oa@ zC#lp)Ds&-^>Y?cgYUH%L)JWIus{3q1qSW>N7}6djeX}2ZGl{;Ls0Q7fT&-!bFrG1h zaey(v_+j26e}l;1p!v2R>d?curTyss>el_Wuh5P$$*F_ITTyR_DWDDny2i$ Lh+95aM;2Ttu*(=%LpIGl%Y{gmgvglZ>USHCFLZ%Vv)(e0)u>`AZ3pI2%J zM%s$N{zKwvgRC_e2Zqca*x|GWhenGIDD_9oqc)99AB$K=F#kGzOyb;gkn!mSrCxPt zdNO1E%?Yi2_s2EIR>u@Z7eu8CO}l8(HNOu%GeM1;_KoOquI16awJGl~^7|$2_6My> zJ&keN?TO~TEB~O>Z!yl?XWDWJZTV}xw&fPatuIS=`}<10k8#p Vm~)T#81>lyP;k5VVO8qHdferUe&1l`l!_)F}g66srs z^UeCuH8N3+4D?qcOOol+{nW^=G2dS6bQ?cfSp%IYudR~Tp;Hso=s>A!bV-S8^t58v zXxG z7)@6QM zrV8#-&5pb~Ulw+oqq_XqUN!iSe7vE{f8^s09sak;$B%SHii0+};JeN-{GmK{)Qi=G zm<6T6AS@^flr2`*@)gOgg?nc>xN3` {{{b*X*tc{w}+L*u_QVfw@&R z3t%) y6x>0Nv!l^KXP`BFU4aekD>Pi!;#1xt_TfT*hog?g9rEU?5EC__%Kb0~_J{PX8 zE>)T0I;X0#wyL6ZPN1g3#8RU!)%L-f8ki>83 zj#*S$rkg}b&Z=TWzX=Zkh*YWjrJN^pj*8B$%`ROQT(P3Grl6*@7GkJVV&(@bE-t5% ziYgXW!nb0-Gg9pGs;aIGR?mf1E(wrnVG5;+%bcQW O89(N@`42punm8KtTHlJ;YI8{#E8#scxLDh2n=VTL +@7t?@rvs7y&4dY@6qz+O86{UfmROHZWK}9L@ z{F9^e=HwSu(~4 eHm z>RPTqEG#FTT1inb^=*565sSsj7oAsCRFYS|tcEKOl=?N@2IiLO_3<~_LlMN!&ee&RkD tBlgoV z^39a1zd26P-%M*d%zWE^femGLk@zpcNZKrZb-0y4FNUc}4acy+)cKcki2pi_ M`QpfRX$lAEPCLe`0^%0hIjx93$!7jS+tjW28*aVZ{9vjJT&l6rqn8q07Ja zmwdvXN!NSA-@i6r|F>d4vGASA!HI>x{%_^*U!Tqin}9t_pRfsd|MhwMH>B{tyh#+~ znDv({Dn<_ =`)vOY;s5zN-?{T7^`|?nJ2~j=@e9X)?HxMAMNB9cz4rCjyz27Tu6S)q z58sT(FC2Qa^%JGexYmS3RaWPm2w#5t-buC%vurrih8Z@TX2WzFrrFSI!&Do(ZFsbg zq4Rq-Y_;JVHauj*7j3xThR@ir#fH0W*lfecY`D#a57=<44Y%0vHXGh(!v-5V@vpJJ z12(L%VWAC|*wAmo3>&7~@N^q`ZRob)(O6UNzD)S82s(Gz_LdD>ZFtCr`)$}_!)6<9 zwc%zPZnEJj8y4EIz=jz%Ot)d04ZSu@wPCUi-8NJ 67^?HGPnht$A)*?=`K|O{LVnuoY>z2TssI^0Ps5CKFk~7 z&j6E9R9ctjQiFiYFk8mDR0%L`2)ujz2%N`-=uO}Sz@=>5mx2pCG*YPtzy-dIkvNr? z^BzpW7?<(_zrZX6SED%3!bn;HVC-n(#NG|e!PJqi==^LH96vV#Cyp_AI&kh-(!#$V z*ou*~1b%OvDeq<=dcbs8fp=rX&lX_9cw?UkoMq!J!23@{R~d0W0PMtkB>6c_snalu z{G1LfJ{=x`&;*z;k>Y_T0#C&hh#%nBXaq~ZmjZWUq%6CE?_wkm9|6xzM=lThEZ{dW zLgzKWUt`42R^Z4plzNPp8@<4DFcN WNV zux2J@!A}4 ;->+am1XP&M*H9i5q}Ku zo3qhD1il7%6GrmC3HTbDjxy{;R_WCo@+mlQyB`@O@W+4y&nHgsrNA{92`lh+8yEOC zM)IaEpqerJ@t+R#V-A5A058J40bU3!!nA^y0H^06j|-jwtipT*UJZ=TC;!x4B9Lo1 zDj+X#0x!l$9+m+AhLL*z2v`SmOz0`F`cmq0Jn;ZeTS`9#KOOiOW+Ax1GcKp!flmVt zDB_F}96fnzCPw0~Sf