diff --git a/econml/_ortho_learner.py b/econml/_ortho_learner.py index 15d7b7af3..270fd5d84 100644 --- a/econml/_ortho_learner.py +++ b/econml/_ortho_learner.py @@ -45,6 +45,7 @@ class in this module implements the general logic in a very versatile way from .utilities import (_deprecate_positional, check_input_arrays, cross_product, filter_none_kwargs, inverse_onehot, jacify_featurizer, ndim, reshape, shape, transpose) +from .sklearn_extensions.model_selection import ModelSelector try: import ray @@ -100,7 +101,7 @@ def _fit_fold(model, train_idxs, test_idxs, calculate_scores, args, kwargs): kwargs_train = {key: var[train_idxs] for key, var in kwargs.items()} kwargs_test = {key: var[test_idxs] for key, var in kwargs.items()} - model.fit(*args_train, **kwargs_train) + model.train(False, *args_train, **kwargs_train) nuisance_temp = model.predict(*args_test, **kwargs_test) if not isinstance(nuisance_temp, tuple): @@ -115,17 +116,18 @@ def _fit_fold(model, train_idxs, test_idxs, calculate_scores, args, kwargs): return nuisance_temp, model, test_idxs, (score_temp if calculate_scores else None) -def _crossfit(model, folds, use_ray, ray_remote_fun_option, *args, **kwargs): +def _crossfit(model: ModelSelector, folds, use_ray, ray_remote_fun_option, *args, **kwargs): """ General crossfit based calculation of nuisance parameters. Parameters ---------- - model : object - An object that supports fit and predict. Fit must accept all the args - and the keyword arguments kwargs. Similarly predict must all accept - all the args as arguments and kwards as keyword arguments. The fit - function estimates a model of the nuisance function, based on the input + model : ModelSelector + An object that has train and predict methods. + The train method must take an 'is_selecting' argument first, and then + accept positional arguments `args` and keyword arguments `kwargs`; the predict method + just takes those `args` and `kwargs`. The train + method selects or estimates a model of the nuisance function, based on the input data to fit. Predict evaluates the fitted nuisance function on the input data to predict. folds : list of tuple or None @@ -177,7 +179,7 @@ def _crossfit(model, folds, use_ray, ray_remote_fun_option, *args, **kwargs): class Wrapper: def __init__(self, model): self._model = model - def fit(self, X, y, W=None): + def fit(self, is_selecting, X, y, W=None): self._model.fit(X, y) return self def predict(self, X, y, W=None): @@ -202,13 +204,17 @@ def predict(self, X, y, W=None): """ model_list = [] + + kwargs = filter_none_kwargs(**kwargs) + model.train(True, *args, **kwargs) + calculate_scores = hasattr(model, 'score') # remove None arguments - kwargs = filter_none_kwargs(**kwargs) if folds is None: # skip crossfitting model_list.append(clone(model, safe=False)) - model_list[0].fit(*args, **kwargs) + model_list[0].train(True, *args, **kwargs) + model_list[0].train(False, *args, **kwargs) # fit the selected model nuisances = model_list[0].predict(*args, **kwargs) scores = model_list[0].score(*args, **kwargs) if calculate_scores else None @@ -394,7 +400,7 @@ class ModelNuisance: def __init__(self, model_t, model_y): self._model_t = model_t self._model_y = model_y - def fit(self, Y, T, W=None): + def train(self, is_selecting, Y, T, W=None): self._model_t.fit(W, T) self._model_y.fit(W, Y) return self @@ -448,7 +454,7 @@ class ModelNuisance: def __init__(self, model_t, model_y): self._model_t = model_t self._model_y = model_y - def fit(self, Y, T, W=None): + def train(self, is_selecting, Y, T, W=None): self._model_t.fit(W, np.matmul(T, np.arange(1, T.shape[1]+1))) self._model_y.fit(W, Y) return self @@ -532,15 +538,15 @@ def _gen_allowed_missing_vars(self): @abstractmethod def _gen_ortho_learner_model_nuisance(self): - """ Must return a fresh instance of a nuisance model + """Must return a fresh instance of a nuisance model selector Returns ------- - model_nuisance: estimator - The estimator for fitting the nuisance function. Must implement - `fit` and `predict` methods that both have signatures:: + model_nuisance: selector + The selector for fitting the nuisance function. The returned estimator must implement + `train` and `predict` methods that both have signatures:: - model_nuisance.fit(Y, T, X=X, W=W, Z=Z, + model_nuisance.train(is_selecting, Y, T, X=X, W=W, Z=Z, sample_weight=sample_weight) model_nuisance.predict(Y, T, X=X, W=W, Z=Z, sample_weight=sample_weight) diff --git a/econml/dml/_rlearner.py b/econml/dml/_rlearner.py index bd645fda3..b1bc9e2ad 100644 --- a/econml/dml/_rlearner.py +++ b/econml/dml/_rlearner.py @@ -29,40 +29,35 @@ import numpy as np import copy from warnings import warn + +from ..sklearn_extensions.model_selection import ModelSelector from ..utilities import (shape, reshape, ndim, hstack, filter_none_kwargs, _deprecate_positional) from sklearn.linear_model import LinearRegression from sklearn.base import clone from .._ortho_learner import _OrthoLearner -class _ModelNuisance: +class _ModelNuisance(ModelSelector): """ Nuisance model fits the model_y and model_t at fit time and at predict time calculates the residual Y and residual T based on the fitted models and returns the residuals as two nuisance parameters. """ - def __init__(self, model_y, model_t): + def __init__(self, model_y: ModelSelector, model_t: ModelSelector): self._model_y = model_y self._model_t = model_t - def fit(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): + def train(self, is_selecting, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): assert Z is None, "Cannot accept instrument!" - self._model_t.fit(X, W, T, **filter_none_kwargs(sample_weight=sample_weight, groups=groups)) - self._model_y.fit(X, W, Y, **filter_none_kwargs(sample_weight=sample_weight, groups=groups)) + self._model_t.train(is_selecting, X, W, T, **filter_none_kwargs(sample_weight=sample_weight, groups=groups)) + self._model_y.train(is_selecting, X, W, Y, **filter_none_kwargs(sample_weight=sample_weight, groups=groups)) return self def score(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): - if hasattr(self._model_y, 'score'): - # note that groups are not passed to score because they are only used for fitting - Y_score = self._model_y.score(X, W, Y, **filter_none_kwargs(sample_weight=sample_weight)) - else: - Y_score = None - if hasattr(self._model_t, 'score'): - # note that groups are not passed to score because they are only used for fitting - T_score = self._model_t.score(X, W, T, **filter_none_kwargs(sample_weight=sample_weight)) - else: - T_score = None + # note that groups are not passed to score because they are only used for fitting + T_score = self._model_t.score(X, W, T, **filter_none_kwargs(sample_weight=sample_weight)) + Y_score = self._model_y.score(X, W, Y, **filter_none_kwargs(sample_weight=sample_weight)) return Y_score, T_score def predict(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): @@ -302,7 +297,7 @@ def _gen_model_y(self): """ Returns ------- - model_y: estimator of E[Y | X, W] + model_y: selector for the estimator of E[Y | X, W] The estimator for fitting the response to the features and controls. Must implement `fit` and `predict` methods. Unlike sklearn estimators both methods must take an extra second argument (the controls), i.e. :: @@ -317,7 +312,7 @@ def _gen_model_t(self): """ Returns ------- - model_t: estimator of E[T | X, W] + model_t: selector for the estimator of E[T | X, W] The estimator for fitting the treatment to the features and controls. Must implement `fit` and `predict` methods. Unlike sklearn estimators both methods must take an extra second argument (the controls), i.e. :: @@ -432,11 +427,11 @@ def rlearner_model_final_(self): @property def models_y(self): - return [[mdl._model_y for mdl in mdls] for mdls in super().models_nuisance_] + return [[mdl._model_y.best_model for mdl in mdls] for mdls in super().models_nuisance_] @property def models_t(self): - return [[mdl._model_t for mdl in mdls] for mdls in super().models_nuisance_] + return [[mdl._model_t.best_model for mdl in mdls] for mdls in super().models_nuisance_] @property def nuisance_scores_y(self): diff --git a/econml/dml/causal_forest.py b/econml/dml/causal_forest.py index 4f038eb3f..757b498ef 100644 --- a/econml/dml/causal_forest.py +++ b/econml/dml/causal_forest.py @@ -11,7 +11,7 @@ from sklearn.model_selection import train_test_split from itertools import product from .dml import _BaseDML -from .dml import _FirstStageWrapper +from .dml import _make_first_stage_selector from ..sklearn_extensions.linear_model import WeightedLassoCVWrapper from ..sklearn_extensions.model_selection import WeightedStratifiedKFold from ..inference import NormalInferenceResults @@ -668,22 +668,10 @@ def _gen_featurizer(self): return clone(self.featurizer, safe=False) def _gen_model_y(self): - if self.model_y == 'auto': - model_y = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_y = clone(self.model_y, safe=False) - return _FirstStageWrapper(model_y, True, self._gen_featurizer(), False, self.discrete_treatment) + return _make_first_stage_selector(self.model_y, False, self.random_state) def _gen_model_t(self): - if self.model_t == 'auto': - if self.discrete_treatment: - model_t = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_t = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_t = clone(self.model_t, safe=False) - return _FirstStageWrapper(model_t, False, self._gen_featurizer(), False, self.discrete_treatment) + return _make_first_stage_selector(self.model_t, self.discrete_treatment, self.random_state) def _gen_model_final(self): return MultiOutputGRF(CausalForest(n_estimators=self.n_estimators, diff --git a/econml/dml/dml.py b/econml/dml/dml.py index d7c59013b..caa12e0c2 100644 --- a/econml/dml/dml.py +++ b/econml/dml/dml.py @@ -29,76 +29,85 @@ from ..sklearn_extensions.model_selection import WeightedStratifiedKFold from ..utilities import (_deprecate_positional, add_intercept, broadcast_unit_treatments, check_high_dimensional, - cross_product, deprecated, fit_with_groups, + cross_product, deprecated, hstack, inverse_onehot, ndim, reshape, reshape_treatmentwise_effects, shape, transpose, get_feature_names_or_default, filter_none_kwargs) from .._shap import _shap_explain_model_cate -from ..sklearn_extensions.model_selection import SearchEstimatorList -import pdb +from ..sklearn_extensions.model_selection import get_selector, ModelSelector, SingleModelSelector -class _FirstStageWrapper: - def __init__(self, model, is_Y, featurizer, linear_first_stages, discrete_treatment): - self._model = clone(model, safe=False) - self._featurizer = clone(featurizer, safe=False) - self._is_Y = is_Y - self._linear_first_stages = linear_first_stages - self._discrete_treatment = discrete_treatment - - def _combine(self, X, W, n_samples, fitting=True): - if X is None: - # if both X and W are None, just return a column of ones - return (W if W is not None else np.ones((n_samples, 1))) - XW = hstack([X, W]) if W is not None else X - if self._is_Y and self._linear_first_stages: - if self._featurizer is None: - F = X - else: - F = self._featurizer.fit_transform(X) if fitting else self._featurizer.transform(X) - return cross_product(XW, hstack([np.ones((shape(XW)[0], 1)), F])) - else: - return XW +def _combine(X, W, n_samples): + if X is None: + # if both X and W are None, just return a column of ones + return (W if W is not None else np.ones((n_samples, 1))) + return hstack([X, W]) if W is not None else X - def fit(self, X, W, Target, sample_weight=None, groups=None): - if (not self._is_Y) and self._discrete_treatment: - # In this case, the Target is the one-hot-encoding of the treatment variable - # We need to go back to the label representation of the one-hot so as to call - # the classifier. - if np.any(np.all(Target == 0, axis=0)) or (not np.any(np.all(Target == 0, axis=1))): - raise AttributeError("Provided crossfit folds contain training splits that " + - "don't contain all treatments") - Target = inverse_onehot(Target) - if sample_weight is not None: - fit_with_groups(self._model, self._combine(X, W, Target.shape[0]), Target, groups=groups, - sample_weight=sample_weight) - else: - fit_with_groups(self._model, self._combine(X, W, Target.shape[0]), Target, groups=groups) - return self +class _FirstStageWrapper: + def __init__(self, model, discrete_target): + self._model = model # plain sklearn-compatible model, not a ModelSelector + self._discrete_target = discrete_target def predict(self, X, W): n_samples = X.shape[0] if X is not None else (W.shape[0] if W is not None else 1) - if (not self._is_Y) and self._discrete_treatment: - return self._model.predict_proba(self._combine(X, W, n_samples, fitting=False))[:, 1:] + if self._discrete_target: + return self._model.predict_proba(_combine(X, W, n_samples))[:, 1:] else: - return self._model.predict(self._combine(X, W, n_samples, fitting=False)) + return self._model.predict(_combine(X, W, n_samples)) def score(self, X, W, Target, sample_weight=None): if hasattr(self._model, 'score'): - if (not self._is_Y) and self._discrete_treatment: + if self._discrete_target: # In this case, the Target is the one-hot-encoding of the treatment variable # We need to go back to the label representation of the one-hot so as to call # the classifier. Target = inverse_onehot(Target) if sample_weight is not None: - return self._model.score(self._combine(X, W, Target.shape[0]), Target, sample_weight=sample_weight) + return self._model.score(_combine(X, W, Target.shape[0]), Target, sample_weight=sample_weight) else: - return self._model.score(self._combine(X, W, Target.shape[0]), Target) + return self._model.score(_combine(X, W, Target.shape[0]), Target) else: return None +class _FirstStageSelector(SingleModelSelector): + def __init__(self, model: SingleModelSelector, discrete_target): + self._model = clone(model, safe=False) + self._discrete_target = discrete_target + + def train(self, is_selecting, X, W, Target, sample_weight=None, groups=None): + if self._discrete_target: + # In this case, the Target is the one-hot-encoding of the treatment variable + # We need to go back to the label representation of the one-hot so as to call + # the classifier. + if np.any(np.all(Target == 0, axis=0)) or (not np.any(np.all(Target == 0, axis=1))): + raise AttributeError("Provided crossfit folds contain training splits that " + + "don't contain all treatments") + Target = inverse_onehot(Target) + + self._model.train(is_selecting, _combine(X, W, Target.shape[0]), Target, + **filter_none_kwargs(groups=groups, sample_weight=sample_weight)) + return self + + @property + def best_model(self): + return _FirstStageWrapper(self._model.best_model, self._discrete_target) + + @property + def best_score(self): + return self._model.best_score + + +def _make_first_stage_selector(model, is_discrete, random_state): + if model == 'auto': + model = ['forest', 'linear'] + return _FirstStageSelector(get_selector(model, + is_discrete=is_discrete, + random_state=random_state), + discrete_target=is_discrete) + + class _FinalWrapper: def __init__(self, model_final, fit_cate_intercept, featurizer, use_weight_trick): self._model = clone(model_final, safe=False) @@ -359,7 +368,7 @@ class takes as input the parameter `model_t`, which is an arbitrary scikit-learn `fit` and `predict` methods, and must be a linear model for correctness. param_list: list or 'auto', default 'auto' - The list of parameters to be used during cross-validation. + The list of parameters to be used during cross-validation. If 'auto', it will be chosen based on the model type. scaling: bool, default True @@ -538,45 +547,11 @@ def _gen_allowed_missing_vars(self): def _gen_featurizer(self): return clone(self.featurizer, safe=False) - def _gen_model_y(self): # New - if self.model_y == 'auto': - model_y = SearchEstimatorList(estimator_list=WeightedLassoCVWrapper(random_state=self.random_state), param_grid_list=self.param_list_y, scoring=self.scoring_y, - scaling=self.scaling, verbose=self.verbose, cv=self.cv, n_jobs=self.n_jobs, random_state=self.random_state) - else: - model_y = clone(SearchEstimatorList(estimator_list=self.model_y, param_grid_list=self.param_list_y, scoring=self.scoring_y, - scaling=self.scaling, verbose=self.verbose, cv=self.cv, n_jobs=self.n_jobs, random_state=self.random_state), safe=False) - # if self.model_y == 'auto': - # model_y = WeightedLassoCVWrapper(random_state=self.random_state) - # else: - # model_y = clone(self.model_y, safe=False) - return _FirstStageWrapper(model_y, True, self._gen_featurizer(), - self.linear_first_stages, self.discrete_treatment) - - def _gen_model_t(self): # New - if self.model_t == 'auto': - if self.discrete_treatment: - model_t = SearchEstimatorList(estimator_list=self.model_t, param_grid_list=self.param_list_t, scoring=self.scoring_t, - scaling=self.scaling, verbose=self.verbose, cv=WeightedStratifiedKFold(random_state=self.random_state), is_discrete=self.discrete_treatment, - n_jobs=self.n_jobs, random_state=self.random_state) - else: - model_t = SearchEstimatorList(estimator_list=WeightedLassoCVWrapper(random_state=self.random_state), param_grid_list=self.param_list_t, scoring=self.scoring_t, - scaling=self.scaling, verbose=self.verbose, cv=self.cv, is_discrete=self.discrete_treatment, - n_jobs=self.n_jobs, random_state=self.random_state) + def _gen_model_y(self): + return _make_first_stage_selector(self.model_y, False, self.random_state) - else: - model_t = clone(SearchEstimatorList(estimator_list=self.model_t, param_grid_list=self.param_list_t, - scaling=self.scaling, verbose=self.verbose, cv=self.cv, is_discrete=self.discrete_treatment, - n_jobs=self.n_jobs, random_state=self.random_state), safe=False) - # if self.model_t == 'auto': - # if self.discrete_treatment: - # model_t = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - # random_state=self.random_state) - # else: - # model_t = WeightedLassoCVWrapper(random_state=self.random_state) - # else: - # model_t = clone(self.model_t, safe=False) - return _FirstStageWrapper(model_t, False, self._gen_featurizer(), - self.linear_first_stages, self.discrete_treatment) + def _gen_model_t(self): + return _make_first_stage_selector(self.model_t, self.discrete_treatment, self.random_state) def _gen_model_final(self): return clone(self.model_final, safe=False) @@ -1520,12 +1495,11 @@ def _gen_featurizer(self): return clone(self.featurizer, safe=False) def _gen_model_y(self): - return _FirstStageWrapper(clone(self.model_y, safe=False), True, - self._gen_featurizer(), False, self.discrete_treatment) + return _make_first_stage_selector(self.model_y, is_discrete=False, random_state=self.random_state) def _gen_model_t(self): - return _FirstStageWrapper(clone(self.model_t, safe=False), False, - self._gen_featurizer(), False, self.discrete_treatment) + return _make_first_stage_selector(self.model_t, is_discrete=self.discrete_treatment, + random_state=self.random_state) def _gen_model_final(self): return clone(self.model_final, safe=False) diff --git a/econml/dr/_drlearner.py b/econml/dr/_drlearner.py index 9b75ca75d..1f74890e0 100644 --- a/econml/dr/_drlearner.py +++ b/econml/dr/_drlearner.py @@ -43,6 +43,7 @@ LogisticRegressionCV) from sklearn.ensemble import RandomForestRegressor + from .._ortho_learner import _OrthoLearner from .._cate_estimator import (DebiasedLassoCateEstimatorDiscreteMixin, BaseCateEstimator, ForestModelFinalCateEstimatorDiscreteMixin, @@ -51,13 +52,17 @@ from ..grf import RegressionForest from ..sklearn_extensions.linear_model import ( DebiasedLasso, StatsModelsLinearRegression, WeightedLassoCVWrapper) +from ..sklearn_extensions.model_selection import ModelSelector, SingleModelSelector, get_selector from ..utilities import (_deprecate_positional, check_high_dimensional, - filter_none_kwargs, fit_with_groups, inverse_onehot, get_feature_names_or_default) + filter_none_kwargs, inverse_onehot, get_feature_names_or_default) from .._shap import _shap_explain_multitask_model_cate, _shap_explain_model_cate -class _ModelNuisance: - def __init__(self, model_propensity, model_regression, min_propensity): +class _ModelNuisance(ModelSelector): + def __init__(self, + model_propensity: SingleModelSelector, + model_regression: SingleModelSelector, + min_propensity): self._model_propensity = model_propensity self._model_regression = model_regression self._min_propensity = min_propensity @@ -65,7 +70,7 @@ def __init__(self, model_propensity, model_regression, min_propensity): def _combine(self, X, W): return np.hstack([arr for arr in [X, W] if arr is not None]) - def fit(self, Y, T, X=None, W=None, *, sample_weight=None, groups=None): + def train(self, is_selecting, Y, T, X=None, W=None, *, sample_weight=None, groups=None): if Y.ndim != 1 and (Y.ndim != 2 or Y.shape[1] != 1): raise ValueError("The outcome matrix must be of shape ({0}, ) or ({0}, 1), " "instead got {1}.".format(len(X), Y.shape)) @@ -77,22 +82,16 @@ def fit(self, Y, T, X=None, W=None, *, sample_weight=None, groups=None): XW = self._combine(X, W) filtered_kwargs = filter_none_kwargs(sample_weight=sample_weight) - fit_with_groups(self._model_propensity, XW, inverse_onehot(T), groups=groups, **filtered_kwargs) - fit_with_groups(self._model_regression, np.hstack([XW, T]), Y, groups=groups, **filtered_kwargs) + self._model_propensity.train(is_selecting, XW, inverse_onehot(T), groups=groups, **filtered_kwargs) + self._model_regression.train(is_selecting, np.hstack([XW, T]), Y, groups=groups, **filtered_kwargs) return self def score(self, Y, T, X=None, W=None, *, sample_weight=None, groups=None): XW = self._combine(X, W) filtered_kwargs = filter_none_kwargs(sample_weight=sample_weight) - if hasattr(self._model_propensity, 'score'): - propensity_score = self._model_propensity.score(XW, inverse_onehot(T), **filtered_kwargs) - else: - propensity_score = None - if hasattr(self._model_regression, 'score'): - regression_score = self._model_regression.score(np.hstack([XW, T]), Y, **filtered_kwargs) - else: - regression_score = None + propensity_score = self._model_propensity.score(XW, inverse_onehot(T), **filtered_kwargs) + regression_score = self._model_regression.score(np.hstack([XW, T]), Y, **filtered_kwargs) return propensity_score, regression_score @@ -114,6 +113,12 @@ def predict(self, Y, T, X=None, W=None, *, sample_weight=None, groups=None): return Y_pred.reshape(Y.shape + (T.shape[1] + 1,)), propensities_weight.reshape((n,)) +def _make_first_stage_selector(model, is_discrete, random_state): + if model == "auto": + model = ['linear', 'forest'] + return get_selector(model, is_discrete=is_discrete, random_state=random_state) + + class _ModelFinal: # Coding Remark: The reasoning around the multitask_model_final could have been simplified if # we simply wrapped the model_final with a MultiOutputRegressor. However, because we also want @@ -499,16 +504,8 @@ def _get_inference_options(self): return options def _gen_ortho_learner_model_nuisance(self): - if self.model_propensity == 'auto': - model_propensity = LogisticRegressionCV(cv=3, solver='lbfgs', multi_class='auto', - random_state=self.random_state) - else: - model_propensity = clone(self.model_propensity, safe=False) - - if self.model_regression == 'auto': - model_regression = WeightedLassoCVWrapper(cv=3, random_state=self.random_state) - else: - model_regression = clone(self.model_regression, safe=False) + model_propensity = _make_first_stage_selector(self.model_propensity, True, self.random_state) + model_regression = _make_first_stage_selector(self.model_regression, False, self.random_state) return _ModelNuisance(model_propensity, model_regression, self.min_propensity) @@ -648,7 +645,7 @@ def models_propensity(self): monte carlo iterations, each element in the sublist corresponds to a crossfitting fold and is the model instance that was fitted for that training fold. """ - return [[mdl._model_propensity for mdl in mdls] for mdls in super().models_nuisance_] + return [[mdl._model_propensity.best_model for mdl in mdls] for mdls in super().models_nuisance_] @property def models_regression(self): @@ -662,7 +659,7 @@ def models_regression(self): monte carlo iterations, each element in the sublist corresponds to a crossfitting fold and is the model instance that was fitted for that training fold. """ - return [[mdl._model_regression for mdl in mdls] for mdls in super().models_nuisance_] + return [[mdl._model_regression.best_model for mdl in mdls] for mdls in super().models_nuisance_] @property def nuisance_scores_propensity(self): diff --git a/econml/iv/dml/_dml.py b/econml/iv/dml/_dml.py index c8889599f..af01f9572 100644 --- a/econml/iv/dml/_dml.py +++ b/econml/iv/dml/_dml.py @@ -24,17 +24,30 @@ from ..._cate_estimator import LinearModelFinalCateEstimatorMixin, StatsModelsCateEstimatorMixin, LinearCateEstimator from ...inference import StatsModelsInference, GenericSingleTreatmentModelFinalInference from ...sklearn_extensions.linear_model import StatsModels2SLS, StatsModelsLinearRegression, WeightedLassoCVWrapper -from ...sklearn_extensions.model_selection import WeightedStratifiedKFold +from ...sklearn_extensions.model_selection import (ModelSelector, SingleModelSelector, + WeightedStratifiedKFold, get_selector) from ...utilities import (_deprecate_positional, get_feature_names_or_default, filter_none_kwargs, add_intercept, cross_product, broadcast_unit_treatments, reshape_treatmentwise_effects, shape, parse_final_model_params, deprecated, Summary) -from ...dml.dml import _FirstStageWrapper, _FinalWrapper +from ...dml.dml import _make_first_stage_selector, _FinalWrapper from ...dml._rlearner import _ModelFinal from ..._shap import _shap_explain_joint_linear_model_cate, _shap_explain_model_cate -class _OrthoIVModelNuisance: - def __init__(self, model_y_xw, model_t_xw, model_z, projection): +def _combine(W, Z, n_samples): + if Z is not None: + Z = Z.reshape(n_samples, -1) + return Z if W is None else np.hstack([W, Z]) + return None if W is None else W + + +class _OrthoIVNuisanceSelector(ModelSelector): + + def __init__(self, + model_y_xw: SingleModelSelector, + model_t_xw: SingleModelSelector, + model_z: SingleModelSelector, + projection): self._model_y_xw = model_y_xw self._model_t_xw = model_t_xw self._projection = projection @@ -43,21 +56,15 @@ def __init__(self, model_y_xw, model_t_xw, model_z, projection): else: self._model_z_xw = model_z - def _combine(self, W, Z, n_samples): - if Z is not None: - Z = Z.reshape(n_samples, -1) - return Z if W is None else np.hstack([W, Z]) - return None if W is None else W - - def fit(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): - self._model_y_xw.fit(X=X, W=W, Target=Y, sample_weight=sample_weight, groups=groups) - self._model_t_xw.fit(X=X, W=W, Target=T, sample_weight=sample_weight, groups=groups) + def train(self, is_selecting, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): + self._model_y_xw.train(is_selecting, X=X, W=W, Target=Y, sample_weight=sample_weight, groups=groups) + self._model_t_xw.train(is_selecting, X=X, W=W, Target=T, sample_weight=sample_weight, groups=groups) if self._projection: # concat W and Z - WZ = self._combine(W, Z, Y.shape[0]) - self._model_t_xwz.fit(X=X, W=WZ, Target=T, sample_weight=sample_weight, groups=groups) + WZ = _combine(W, Z, Y.shape[0]) + self._model_t_xwz.train(is_selecting, X=X, W=WZ, Target=T, sample_weight=sample_weight, groups=groups) else: - self._model_z_xw.fit(X=X, W=W, Target=Z, sample_weight=sample_weight, groups=groups) + self._model_z_xw.train(is_selecting, X=X, W=W, Target=Z, sample_weight=sample_weight, groups=groups) return self def score(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): @@ -71,7 +78,7 @@ def score(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): T_X_score = None if self._projection: # concat W and Z - WZ = self._combine(W, Z, Y.shape[0]) + WZ = _combine(W, Z, Y.shape[0]) if hasattr(self._model_t_xwz, 'score'): T_XZ_score = self._model_t_xwz.score(X=X, W=WZ, Target=T, sample_weight=sample_weight) else: @@ -91,7 +98,7 @@ def predict(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None) if self._projection: # concat W and Z - WZ = self._combine(W, Z, Y.shape[0]) + WZ = _combine(W, Z, Y.shape[0]) T_proj = self._model_t_xwz.predict(X, WZ) else: Z_pred = self._model_z_xw.predict(X=X, W=W) @@ -387,57 +394,29 @@ def _gen_ortho_learner_model_final(self): return _OrthoIVModelFinal(self._gen_model_final(), self._gen_featurizer(), self.fit_cate_intercept) def _gen_ortho_learner_model_nuisance(self): - if self.model_y_xw == 'auto': - model_y_xw = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_y_xw = clone(self.model_y_xw, safe=False) + model_y = _make_first_stage_selector(self.model_y_xw, + is_discrete=False, + random_state=self.random_state) - if self.model_t_xw == 'auto': - if self.discrete_treatment: - model_t_xw = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_t_xw = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_t_xw = clone(self.model_t_xw, safe=False) + model_t = _make_first_stage_selector(self.model_t_xw, + is_discrete=self.discrete_treatment, + random_state=self.random_state) if self.projection: # train E[T|X,W,Z] - if self.model_t_xwz == 'auto': - if self.discrete_treatment: - model_t_xwz = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_t_xwz = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_t_xwz = clone(self.model_t_xwz, safe=False) - - return _OrthoIVModelNuisance(_FirstStageWrapper(clone(model_y_xw, safe=False), True, - self._gen_featurizer(), False, False), - _FirstStageWrapper(clone(model_t_xw, safe=False), False, - self._gen_featurizer(), False, self.discrete_treatment), - _FirstStageWrapper(clone(model_t_xwz, safe=False), False, - self._gen_featurizer(), False, self.discrete_treatment), - self.projection) + model_z = _make_first_stage_selector(self.model_t_xwz, + is_discrete=self.discrete_treatment, + random_state=self.random_state) else: - # train [Z|X,W] - if self.model_z_xw == "auto": - if self.discrete_instrument: - model_z_xw = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_z_xw = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_z_xw = clone(self.model_z_xw, safe=False) + # train E[Z|X,W] + # note: discrete_instrument rather than discrete_treatment in call to _make_first_stage_selector + model_z = _make_first_stage_selector(self.model_z_xw, + is_discrete=self.discrete_instrument, + random_state=self.random_state) - return _OrthoIVModelNuisance(_FirstStageWrapper(clone(model_y_xw, safe=False), True, - self._gen_featurizer(), False, False), - _FirstStageWrapper(clone(model_t_xw, safe=False), False, - self._gen_featurizer(), False, self.discrete_treatment), - _FirstStageWrapper(clone(model_z_xw, safe=False), False, - self._gen_featurizer(), False, self.discrete_instrument), - self.projection) + return _OrthoIVNuisanceSelector(model_y, model_t, model_z, + self.projection) def fit(self, Y, T, *, Z, X=None, W=None, sample_weight=None, freq_weight=None, sample_var=None, groups=None, cache_values=False, inference="auto"): @@ -604,7 +583,7 @@ def models_y_xw(self): iterations, each element in the sublist corresponds to a crossfitting fold and is the model instance that was fitted for that training fold. """ - return [[mdl._model_y_xw._model for mdl in mdls] for mdls in super().models_nuisance_] + return [[mdl._model_y_xw.best_model._model for mdl in mdls] for mdls in super().models_nuisance_] @property def models_t_xw(self): @@ -618,7 +597,7 @@ def models_t_xw(self): iterations, each element in the sublist corresponds to a crossfitting fold and is the model instance that was fitted for that training fold. """ - return [[mdl._model_t_xw._model for mdl in mdls] for mdls in super().models_nuisance_] + return [[mdl._model_t_xw.best_model._model for mdl in mdls] for mdls in super().models_nuisance_] @property def models_z_xw(self): @@ -634,7 +613,7 @@ def models_z_xw(self): """ if self.projection: raise AttributeError("Projection model is fitted for instrument! Use models_t_xwz.") - return [[mdl._model_z_xw._model for mdl in mdls] for mdls in super().models_nuisance_] + return [[mdl._model_z_xw.best_model._model for mdl in mdls] for mdls in super().models_nuisance_] @property def models_t_xwz(self): @@ -650,7 +629,7 @@ def models_t_xwz(self): """ if not self.projection: raise AttributeError("Direct model is fitted for instrument! Use models_z_xw.") - return [[mdl._model_t_xwz._model for mdl in mdls] for mdls in super().models_nuisance_] + return [[mdl._model_t_xwz.best_model._model for mdl in mdls] for mdls in super().models_nuisance_] @property def nuisance_scores_y_xw(self): @@ -717,29 +696,24 @@ def residuals_(self): return Y_res, T_res, Z_res, self._cached_values.X, self._cached_values.W, self._cached_values.Z -class _BaseDMLIVModelNuisance: +class _BaseDMLIVNuisanceSelector(ModelSelector): """ Nuisance model fits the three models at fit time and at predict time returns :math:`Y-\\E[Y|X]` and :math:`\\E[T|X,Z]-\\E[T|X]` as residuals. """ - def __init__(self, model_y_xw, model_t_xw, model_t_xwz): - self._model_y_xw = clone(model_y_xw, safe=False) - self._model_t_xw = clone(model_t_xw, safe=False) - self._model_t_xwz = clone(model_t_xwz, safe=False) - - def _combine(self, W, Z, n_samples): - if Z is not None: - Z = Z.reshape(n_samples, -1) - return Z if W is None else np.hstack([W, Z]) - return None if W is None else W + def __init__(self, model_y_xw: ModelSelector, model_t_xw: ModelSelector, model_t_xwz: ModelSelector): + self._model_y_xw = model_y_xw + self._model_t_xw = model_t_xw + self._model_t_xwz = model_t_xwz - def fit(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): - self._model_y_xw.fit(X, W, Y, **filter_none_kwargs(sample_weight=sample_weight, groups=groups)) - self._model_t_xw.fit(X, W, T, **filter_none_kwargs(sample_weight=sample_weight, groups=groups)) + def train(self, is_selecting, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): + self._model_y_xw.train(is_selecting, X, W, Y, **filter_none_kwargs(sample_weight=sample_weight, groups=groups)) + self._model_t_xw.train(is_selecting, X, W, T, **filter_none_kwargs(sample_weight=sample_weight, groups=groups)) # concat W and Z - WZ = self._combine(W, Z, Y.shape[0]) - self._model_t_xwz.fit(X, WZ, T, **filter_none_kwargs(sample_weight=sample_weight, groups=groups)) + WZ = _combine(W, Z, Y.shape[0]) + self._model_t_xwz.train(is_selecting, X, WZ, T, + **filter_none_kwargs(sample_weight=sample_weight, groups=groups)) return self def score(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): @@ -754,7 +728,7 @@ def score(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): T_X_score = None if hasattr(self._model_t_xwz, 'score'): # concat W and Z - WZ = self._combine(W, Z, Y.shape[0]) + WZ = _combine(W, Z, Y.shape[0]) T_XZ_score = self._model_t_xwz.score(X, WZ, T, **filter_none_kwargs(sample_weight=sample_weight)) else: T_XZ_score = None @@ -764,7 +738,7 @@ def predict(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None) # note that sample_weight and groups are not passed to predict because they are only used for fitting Y_pred = self._model_y_xw.predict(X, W) # concat W and Z - WZ = self._combine(W, Z, Y.shape[0]) + WZ = _combine(W, Z, Y.shape[0]) TXZ_pred = self._model_t_xwz.predict(X, WZ) TX_pred = self._model_t_xw.predict(X, W) if (X is None) and (W is None): # In this case predict above returns a single row @@ -1183,42 +1157,19 @@ def _gen_featurizer(self): return clone(self.featurizer, safe=False) def _gen_model_y_xw(self): - if self.model_y_xw == 'auto': - model_y_xw = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_y_xw = clone(self.model_y_xw, safe=False) - return _FirstStageWrapper(model_y_xw, True, self._gen_featurizer(), - False, False) + return _make_first_stage_selector(self.model_y_xw, False, self.random_state) def _gen_model_t_xw(self): - if self.model_t_xw == 'auto': - if self.discrete_treatment: - model_t_xw = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_t_xw = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_t_xw = clone(self.model_t_xw, safe=False) - return _FirstStageWrapper(model_t_xw, False, self._gen_featurizer(), - False, self.discrete_treatment) + return _make_first_stage_selector(self.model_t_xw, self.discrete_treatment, self.random_state) def _gen_model_t_xwz(self): - if self.model_t_xwz == 'auto': - if self.discrete_treatment: - model_t_xwz = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_t_xwz = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_t_xwz = clone(self.model_t_xwz, safe=False) - return _FirstStageWrapper(model_t_xwz, False, self._gen_featurizer(), - False, self.discrete_treatment) + return _make_first_stage_selector(self.model_t_xwz, self.discrete_treatment, self.random_state) def _gen_model_final(self): return clone(self.model_final, safe=False) def _gen_ortho_learner_model_nuisance(self): - return _BaseDMLIVModelNuisance(self._gen_model_y_xw(), self._gen_model_t_xw(), self._gen_model_t_xwz()) + return _BaseDMLIVNuisanceSelector(self._gen_model_y_xw(), self._gen_model_t_xw(), self._gen_model_t_xwz()) def _gen_ortho_learner_model_final(self): return _BaseDMLIVModelFinal(_FinalWrapper(self._gen_model_final(), @@ -1579,42 +1530,19 @@ def _gen_featurizer(self): return clone(self.featurizer, safe=False) def _gen_model_y_xw(self): - if self.model_y_xw == 'auto': - model_y_xw = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_y_xw = clone(self.model_y_xw, safe=False) - return _FirstStageWrapper(model_y_xw, True, self._gen_featurizer(), - False, False) + return _make_first_stage_selector(self.model_y_xw, False, self.random_state) def _gen_model_t_xw(self): - if self.model_t_xw == 'auto': - if self.discrete_treatment: - model_t_xw = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_t_xw = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_t_xw = clone(self.model_t_xw, safe=False) - return _FirstStageWrapper(model_t_xw, False, self._gen_featurizer(), - False, self.discrete_treatment) + return _make_first_stage_selector(self.model_t_xw, self.discrete_treatment, self.random_state) def _gen_model_t_xwz(self): - if self.model_t_xwz == 'auto': - if self.discrete_treatment: - model_t_xwz = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_t_xwz = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_t_xwz = clone(self.model_t_xwz, safe=False) - return _FirstStageWrapper(model_t_xwz, False, self._gen_featurizer(), - False, self.discrete_treatment) + return _make_first_stage_selector(self.model_t_xwz, self.discrete_treatment, self.random_state) def _gen_model_final(self): return clone(self.model_final, safe=False) def _gen_ortho_learner_model_nuisance(self): - return _BaseDMLIVModelNuisance(self._gen_model_y_xw(), self._gen_model_t_xw(), self._gen_model_t_xwz()) + return _BaseDMLIVNuisanceSelector(self._gen_model_y_xw(), self._gen_model_t_xw(), self._gen_model_t_xwz()) def _gen_ortho_learner_model_final(self): return _BaseDMLIVModelFinal(_FinalWrapper(self._gen_model_final(), diff --git a/econml/iv/dr/_dr.py b/econml/iv/dr/_dr.py index c06df6278..e4bbb81b9 100644 --- a/econml/iv/dr/_dr.py +++ b/econml/iv/dr/_dr.py @@ -27,16 +27,23 @@ LinearCateEstimator) from ...inference import StatsModelsInference from ...sklearn_extensions.linear_model import StatsModelsLinearRegression, DebiasedLasso, WeightedLassoCVWrapper -from ...sklearn_extensions.model_selection import WeightedStratifiedKFold +from ...sklearn_extensions.model_selection import ModelSelector, SingleModelSelector, WeightedStratifiedKFold from ...utilities import (_deprecate_positional, add_intercept, filter_none_kwargs, inverse_onehot, get_feature_names_or_default, check_high_dimensional, check_input_arrays) from ...grf import RegressionForest -from ...dml.dml import _FirstStageWrapper, _FinalWrapper +from ...dml.dml import _make_first_stage_selector, _FinalWrapper from ...iv.dml import NonParamDMLIV from ..._shap import _shap_explain_model_cate -class _BaseDRIVModelNuisance: +def _combine(W, Z, n_samples): + if Z is not None: # Z will not be None + Z = Z.reshape(n_samples, -1) + return Z if W is None else np.hstack([W, Z]) + return None if W is None else W + + +class _BaseDRIVNuisanceSelector(ModelSelector): def __init__(self, *, prel_model_effect, model_y_xw, model_t_xw, model_tz_xw, model_z, projection, fit_cov_directly, discrete_treatment, discrete_instrument): @@ -53,22 +60,30 @@ def __init__(self, *, prel_model_effect, model_y_xw, model_t_xw, model_tz_xw, mo else: self._model_z_xw = model_z - def _combine(self, W, Z, n_samples): - if Z is not None: # Z will not be None - Z = Z.reshape(n_samples, -1) - return Z if W is None else np.hstack([W, Z]) - return None if W is None else W - - def fit(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): + def train(self, is_selecting, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): # T and Z only allow single continuous or binary, keep the shape of (n,) for continuous and (n,1) for binary T = T.ravel() if not self._discrete_treatment else T Z = Z.ravel() if not self._discrete_instrument else Z - self._model_y_xw.fit(X=X, W=W, Target=Y, sample_weight=sample_weight, groups=groups) - self._model_t_xw.fit(X=X, W=W, Target=T, sample_weight=sample_weight, groups=groups) + self._model_y_xw.train(is_selecting, X=X, W=W, Target=Y, sample_weight=sample_weight, groups=groups) + self._model_t_xw.train(is_selecting, X=X, W=W, Target=T, sample_weight=sample_weight, groups=groups) + if is_selecting and self._fit_cov_directly: + # need to fit, too, since we call predict later inside this train method + self._model_t_xw.train(False, X=X, W=W, Target=T, sample_weight=sample_weight, groups=groups) + + if self._projection: + WZ = _combine(W, Z, Y.shape[0]) + self._model_t_xwz.train(is_selecting, X=X, W=WZ, Target=T, sample_weight=sample_weight, groups=groups) + if is_selecting: + # need to fit, too, since we call predict later inside this train method + self._model_t_xwz.train(False, X=X, W=WZ, Target=T, sample_weight=sample_weight, groups=groups) + else: + self._model_z_xw.train(is_selecting, X=X, W=W, Target=Z, sample_weight=sample_weight, groups=groups) + if is_selecting: + # need to fit, too, since we call predict later inside this train method + self._model_z_xw.train(False, X=X, W=W, Target=Z, sample_weight=sample_weight, groups=groups) + if self._projection: - WZ = self._combine(W, Z, Y.shape[0]) - self._model_t_xwz.fit(X=X, W=WZ, Target=T, sample_weight=sample_weight, groups=groups) T_proj = self._model_t_xwz.predict(X, WZ).reshape(T.shape) if self._fit_cov_directly: # We're projecting, so we're treating E[T|X,Z] as the instrument (ignoring W for simplicity) @@ -82,15 +97,14 @@ def fit(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): else: T_pred = T_pred.reshape(T.shape) target = (T_proj - T_pred)**2 - self._model_tz_xw.fit(X=X, W=W, Target=target, - sample_weight=sample_weight, groups=groups) + self._model_tz_xw.train(is_selecting, X=X, W=W, Target=target, + sample_weight=sample_weight, groups=groups) else: # return shape (n,) target = (T * T_proj).reshape(T.shape[0],) - self._model_tz_xw.fit(X=X, W=W, Target=target, - sample_weight=sample_weight, groups=groups) + self._model_tz_xw.train(is_selecting, X=X, W=W, Target=target, + sample_weight=sample_weight, groups=groups) else: - self._model_z_xw.fit(X=X, W=W, Target=Z, sample_weight=sample_weight, groups=groups) if self._fit_cov_directly: Z_pred = self._model_z_xw.predict(X, W) T_pred = self._model_t_xw.predict(X, W) @@ -111,10 +125,10 @@ def fit(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): target_shape = Z_res.shape if Z_res.ndim > 1 else T_res.shape target = T_res.reshape(target_shape) * Z_res.reshape(target_shape) # TODO: if the T and Z models overfit, then this will be biased towards 0; - # consider using nested cross-fitting here + # consider using nested cross-fitting # a similar comment applies to the projection case - self._model_tz_xw.fit(X=X, W=W, Target=target, - sample_weight=sample_weight, groups=groups) + self._model_tz_xw.train(is_selecting, X=X, W=W, Target=target, + sample_weight=sample_weight, groups=groups) else: if self._discrete_treatment: if self._discrete_instrument: @@ -130,8 +144,8 @@ def fit(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): else: # shape(n,) target = T * Z - self._model_tz_xw.fit(X=X, W=W, Target=target, - sample_weight=sample_weight, groups=groups) + self._model_tz_xw.train(is_selecting, X=X, W=W, Target=target, + sample_weight=sample_weight, groups=groups) # TODO: prel_model_effect could allow sample_var and freq_weight? if self._discrete_instrument: @@ -168,7 +182,7 @@ def score(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): if self._projection: if hasattr(self._model_t_xwz, 'score'): - WZ = self._combine(W, Z, Y.shape[0]) + WZ = _combine(W, Z, Y.shape[0]) t_xwz_score = self._model_t_xwz.score(X=X, W=WZ, Target=T, sample_weight=sample_weight) else: t_xwz_score = None @@ -232,7 +246,7 @@ def predict(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None) if self._projection: # concat W and Z - WZ = self._combine(W, Z, Y.shape[0]) + WZ = _combine(W, Z, Y.shape[0]) T_proj = self._model_t_xwz.predict(X, WZ).reshape(T.shape) Z_res = T_proj - T_pred if self._fit_cov_directly: @@ -650,86 +664,38 @@ def _gen_prel_model_effect(self): return clone(self.prel_model_effect, safe=False) def _gen_ortho_learner_model_nuisance(self): - if self.model_y_xw == 'auto': - model_y_xw = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_y_xw = clone(self.model_y_xw, safe=False) - - if self.model_t_xw == 'auto': - if self.discrete_treatment: - model_t_xw = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_t_xw = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_t_xw = clone(self.model_t_xw, safe=False) + model_y_xw = _make_first_stage_selector(self.model_y_xw, False, self.random_state) + model_t_xw = _make_first_stage_selector(self.model_t_xw, self.discrete_treatment, self.random_state) if self.projection: # this is a regression model since proj_t is probability - if self.model_tz_xw == "auto": - model_tz_xw = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_tz_xw = clone(self.model_tz_xw, safe=False) + model_tz_xw = _make_first_stage_selector(self.model_tz_xw, + is_discrete=False, + random_state=self.random_state) - if self.model_t_xwz == 'auto': - if self.discrete_treatment: - model_t_xwz = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_t_xwz = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_t_xwz = clone(self.model_t_xwz, safe=False) - - return _BaseDRIVModelNuisance(prel_model_effect=self._gen_prel_model_effect(), - model_y_xw=_FirstStageWrapper( - model_y_xw, True, self._gen_featurizer(), False, False), - model_t_xw=_FirstStageWrapper(model_t_xw, False, self._gen_featurizer(), - False, self.discrete_treatment), - # outcome is continuous since proj_t is probability - model_tz_xw=_FirstStageWrapper(model_tz_xw, False, self._gen_featurizer(), - False, False), - model_z=_FirstStageWrapper(model_t_xwz, False, self._gen_featurizer(), - False, self.discrete_treatment), - projection=self.projection, - fit_cov_directly=self.fit_cov_directly, - discrete_treatment=self.discrete_treatment, - discrete_instrument=self.discrete_instrument) + # we're using E[T|X,W,Z] as the instrument + model_z = _make_first_stage_selector(self.model_t_xwz, + is_discrete=self.discrete_treatment, + random_state=self.random_state) else: - if self.model_tz_xw == "auto": - if self.discrete_treatment and self.discrete_instrument and not self.fit_cov_directly: - model_tz_xw = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_tz_xw = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_tz_xw = clone(self.model_tz_xw, safe=False) + model_tz_xw = _make_first_stage_selector(self.model_tz_xw, is_discrete=(self.discrete_treatment and + self.discrete_instrument and + not self.fit_cov_directly), + random_state=self.random_state) - if self.model_z_xw == 'auto': - if self.discrete_instrument: - model_z_xw = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_z_xw = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_z_xw = clone(self.model_z_xw, safe=False) - - return _BaseDRIVModelNuisance(prel_model_effect=self._gen_prel_model_effect(), - model_y_xw=_FirstStageWrapper( - model_y_xw, True, self._gen_featurizer(), False, False), - model_t_xw=_FirstStageWrapper(model_t_xw, False, self._gen_featurizer(), - False, self.discrete_treatment), - model_tz_xw=_FirstStageWrapper(model_tz_xw, False, self._gen_featurizer(), - False, (self.discrete_treatment and - self.discrete_instrument and - not self.fit_cov_directly)), - model_z=_FirstStageWrapper(model_z_xw, False, self._gen_featurizer(), - False, (self.discrete_instrument and - not self.fit_cov_directly)), - projection=self.projection, - fit_cov_directly=self.fit_cov_directly, - discrete_treatment=self.discrete_treatment, - discrete_instrument=self.discrete_instrument) + model_z = _make_first_stage_selector(self.model_z_xw, is_discrete=self.discrete_instrument, + random_state=self.random_state) + + return _BaseDRIVNuisanceSelector(prel_model_effect=self._gen_prel_model_effect(), + model_y_xw=model_y_xw, + model_t_xw=model_t_xw, + model_tz_xw=model_tz_xw, + model_z=model_z, + projection=self.projection, + fit_cov_directly=self.fit_cov_directly, + discrete_treatment=self.discrete_treatment, + discrete_instrument=self.discrete_instrument) class DRIV(_DRIV): @@ -1090,7 +1056,7 @@ def models_y_xw(self): iterations, each element in the sublist corresponds to a crossfitting fold and is the model instance that was fitted for that training fold. """ - return [[mdl._model_y_xw._model for mdl in mdls] for mdls in super().models_nuisance_] + return [[mdl._model_y_xw.best_model._model for mdl in mdls] for mdls in super().models_nuisance_] @property def models_t_xw(self): @@ -1104,7 +1070,7 @@ def models_t_xw(self): iterations, each element in the sublist corresponds to a crossfitting fold and is the model instance that was fitted for that training fold. """ - return [[mdl._model_t_xw._model for mdl in mdls] for mdls in super().models_nuisance_] + return [[mdl._model_t_xw.best_model._model for mdl in mdls] for mdls in super().models_nuisance_] @property def models_z_xw(self): @@ -1120,7 +1086,7 @@ def models_z_xw(self): """ if self.projection: raise AttributeError("Projection model is fitted for instrument! Use models_t_xwz.") - return [[mdl._model_z_xw._model for mdl in mdls] for mdls in super().models_nuisance_] + return [[mdl._model_z_xw.best_model._model for mdl in mdls] for mdls in super().models_nuisance_] @property def models_t_xwz(self): @@ -1136,7 +1102,7 @@ def models_t_xwz(self): """ if not self.projection: raise AttributeError("Direct model is fitted for instrument! Use models_z_xw.") - return [[mdl._model_t_xwz._model for mdl in mdls] for mdls in super().models_nuisance_] + return [[mdl._model_t_xwz.best_model._model for mdl in mdls] for mdls in super().models_nuisance_] @property def models_tz_xw(self): @@ -1150,7 +1116,7 @@ def models_tz_xw(self): iterations, each element in the sublist corresponds to a crossfitting fold and is the model instance that was fitted for that training fold. """ - return [[mdl._model_tz_xw._model for mdl in mdls] for mdls in super().models_nuisance_] + return [[mdl._model_tz_xw.best_model._model for mdl in mdls] for mdls in super().models_nuisance_] @property def models_prel_model_effect(self): @@ -2342,25 +2308,23 @@ def model_final(self, model): raise ValueError("Parameter `model_final` cannot be altered for this estimator!") -class _IntentToTreatDRIVModelNuisance: - def __init__(self, model_y_xw, model_t_xwz, dummy_z, prel_model_effect): - self._model_y_xw = clone(model_y_xw, safe=False) - self._model_t_xwz = clone(model_t_xwz, safe=False) - self._dummy_z = clone(dummy_z, safe=False) - self._prel_model_effect = clone(prel_model_effect, safe=False) - - def _combine(self, W, Z, n_samples): - if Z is not None: # Z will not be None - Z = Z.reshape(n_samples, -1) - return Z if W is None else np.hstack([W, Z]) - return None if W is None else W +class _IntentToTreatDRIVNuisanceSelector(ModelSelector): + def __init__(self, + model_y_xw: SingleModelSelector, + model_t_xwz: SingleModelSelector, + dummy_z: SingleModelSelector, + prel_model_effect): + self._model_y_xw = model_y_xw + self._model_t_xwz = model_t_xwz + self._dummy_z = dummy_z + self._prel_model_effect = prel_model_effect - def fit(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): - self._model_y_xw.fit(X=X, W=W, Target=Y, sample_weight=sample_weight, groups=groups) + def train(self, is_selecting, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): + self._model_y_xw.train(is_selecting, X=X, W=W, Target=Y, sample_weight=sample_weight, groups=groups) # concat W and Z - WZ = self._combine(W, Z, Y.shape[0]) - self._model_t_xwz.fit(X=X, W=WZ, Target=T, sample_weight=sample_weight, groups=groups) - self._dummy_z.fit(X=X, W=W, Target=Z, sample_weight=sample_weight, groups=groups) + WZ = _combine(W, Z, Y.shape[0]) + self._model_t_xwz.train(is_selecting, X=X, W=WZ, Target=T, sample_weight=sample_weight, groups=groups) + self._dummy_z.train(is_selecting, X=X, W=W, Target=Z, sample_weight=sample_weight, groups=groups) # we need to undo the one-hot encoding for calling effect, # since it expects raw values self._prel_model_effect.fit(Y, inverse_onehot(T), Z=inverse_onehot(Z), X=X, W=W, @@ -2374,7 +2338,7 @@ def score(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): Y_X_score = None if hasattr(self._model_t_xwz, 'score'): # concat W and Z - WZ = self._combine(W, Z, Y.shape[0]) + WZ = _combine(W, Z, Y.shape[0]) T_XZ_score = self._model_t_xwz.score(X=X, W=WZ, Target=T, sample_weight=sample_weight) else: T_XZ_score = None @@ -2390,8 +2354,8 @@ def score(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): def predict(self, Y, T, X=None, W=None, Z=None, sample_weight=None, groups=None): Y_pred = self._model_y_xw.predict(X, W) - T_pred_zero = self._model_t_xwz.predict(X, self._combine(W, np.zeros(Z.shape), Y.shape[0])) - T_pred_one = self._model_t_xwz.predict(X, self._combine(W, np.ones(Z.shape), Y.shape[0])) + T_pred_zero = self._model_t_xwz.predict(X, _combine(W, np.zeros(Z.shape), Y.shape[0])) + T_pred_one = self._model_t_xwz.predict(X, _combine(W, np.ones(Z.shape), Y.shape[0])) Z_pred = self._dummy_z.predict(X, W) prel_theta = self._prel_model_effect.effect(X) @@ -2486,16 +2450,8 @@ def _gen_prel_model_effect(self): return clone(self.prel_model_effect, safe=False) def _gen_ortho_learner_model_nuisance(self): - if self.model_y_xw == 'auto': - model_y_xw = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_y_xw = clone(self.model_y_xw, safe=False) - - if self.model_t_xwz == 'auto': - model_t_xwz = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_t_xwz = clone(self.model_t_xwz, safe=False) + model_y_xw = _make_first_stage_selector(self.model_y_xw, is_discrete=False, random_state=self.random_state) + model_t_xwz = _make_first_stage_selector(self.model_t_xwz, is_discrete=True, random_state=self.random_state) if self.z_propensity == "auto": dummy_z = DummyClassifier(strategy="prior") @@ -2504,14 +2460,9 @@ def _gen_ortho_learner_model_nuisance(self): else: raise ValueError("Only 'auto' or float is allowed!") - return _IntentToTreatDRIVModelNuisance(_FirstStageWrapper(model_y_xw, True, self._gen_featurizer(), - False, False), - _FirstStageWrapper(model_t_xwz, False, - self._gen_featurizer(), False, True), - _FirstStageWrapper(dummy_z, False, - self._gen_featurizer(), False, True), - self._gen_prel_model_effect() - ) + dummy_z = _make_first_stage_selector(dummy_z, is_discrete=True, random_state=self.random_state) + + return _IntentToTreatDRIVNuisanceSelector(model_y_xw, model_t_xwz, dummy_z, self._gen_prel_model_effect()) class _DummyCATE: diff --git a/econml/new_tests/test_model_selection.py b/econml/new_tests/test_model_selection.py deleted file mode 100644 index 1eb82db0b..000000000 --- a/econml/new_tests/test_model_selection.py +++ /dev/null @@ -1,267 +0,0 @@ -import unittest - -import numpy as np -from econml.sklearn_extensions.model_selection import * -from econml.sklearn_extensions.model_selection_utils import * -from sklearn.datasets import fetch_california_housing, load_iris -from sklearn.preprocessing import StandardScaler -from sklearn.model_selection import train_test_split -from sklearn.metrics import accuracy_score, f1_score -from sklearn.pipeline import make_pipeline -from sklearn.svm import SVR - - -class TestSearchEstimatorListClassifier(unittest.TestCase): - def setUp(self): - self.expected_accuracy = 0.9 - self.expected_f1_score = 0.9 - self.accuracy_tolerance = 0.05 - self.f1_score_tolerance = 0.05 - self.is_discrete = True - X, y = load_iris(return_X_y=True) - X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.6, random_state=42) - self.X_train = X_train - self.y_train = y_train - self.X_test = X_test - self.y_test = y_test - - def test_initialization(self): - with self.assertRaises(ValueError): - SearchEstimatorList(estimator_list='invalid_estimator') - - def test_auto_param_grid_discrete(self): - - search_estimator_list = SearchEstimatorList(is_discrete=self.is_discrete, scaling=False) - search_estimator_list.fit(self.X_train, self.y_train) - self.assertIsNotNone(search_estimator_list.best_estimator_) - self.assertIsNotNone(search_estimator_list.best_score_) - self.assertIsNotNone(search_estimator_list.best_params_) - - def test_linear_estimator(self): - search = SearchEstimatorList(estimator_list='linear', is_discrete=self.is_discrete, scaling=False) - search.fit(self.X_train, self.y_train) - y_pred = search.predict(self.X_test) - acc = accuracy_score(self.y_test, y_pred) - f1 = f1_score(self.y_test, y_pred, average='macro') - - self.assertEqual(len(search.complete_estimator_list), 1) - self.assertEqual(len(search.param_grid_list), 1) - self.assertIsInstance(search.complete_estimator_list[0], LogisticRegressionCV) - - self.assertGreaterEqual(acc, self.expected_accuracy) - self.assertGreaterEqual(f1, self.expected_f1_score) - - def test_poly_estimator(self): - search = SearchEstimatorList(estimator_list='poly', is_discrete=self.is_discrete, scaling=False) - search.fit(self.X_train, self.y_train) - y_pred = search.predict(self.X_test) - acc = accuracy_score(self.y_test, y_pred) - f1 = f1_score(self.y_test, y_pred, average='macro') - - self.assertEqual(len(search.complete_estimator_list), 1) - self.assertTrue(is_polynomial_pipeline(search.complete_estimator_list[0])) - - self.assertGreaterEqual(acc, self.expected_accuracy) - self.assertGreaterEqual(f1, self.expected_f1_score) - - def test_forest_estimator(self): - search = SearchEstimatorList(estimator_list='forest', is_discrete=self.is_discrete, scaling=False) - search.fit(self.X_train, self.y_train) - y_pred = search.predict(self.X_test) - acc = accuracy_score(self.y_test, y_pred) - f1 = f1_score(self.y_test, y_pred, average='macro') - - self.assertEqual(len(search.complete_estimator_list), 1) - self.assertEqual(len(search.param_grid_list), 1) - self.assertIsInstance(search.complete_estimator_list[0], RandomForestClassifier) - - self.assertGreaterEqual(acc, self.expected_accuracy) - self.assertGreaterEqual(f1, self.expected_f1_score) - - def test_gbf_estimator(self): - search = SearchEstimatorList(estimator_list='gbf', is_discrete=self.is_discrete, scaling=False) - search.fit(self.X_train, self.y_train) - y_pred = search.predict(self.X_test) - acc = accuracy_score(self.y_test, y_pred) - f1 = f1_score(self.y_test, y_pred, average='macro') - - self.assertEqual(len(search.complete_estimator_list), 1) - self.assertEqual(len(search.param_grid_list), 1) - self.assertIsInstance(search.complete_estimator_list[0], GradientBoostingClassifier) - - self.assertGreaterEqual(acc, self.expected_accuracy) - self.assertGreaterEqual(f1, self.expected_f1_score) - - def test_nnet_estimator(self): - search = SearchEstimatorList(estimator_list='nnet', is_discrete=self.is_discrete, scaling=False) - search.fit(self.X_train, self.y_train) - y_pred = search.predict(self.X_test) - acc = accuracy_score(self.y_test, y_pred) - f1 = f1_score(self.y_test, y_pred, average='macro') - - self.assertEqual(len(search.complete_estimator_list), 1) - self.assertEqual(len(search.param_grid_list), 1) - self.assertIsInstance(search.complete_estimator_list[0], MLPClassifier) - - self.assertGreaterEqual(acc, self.expected_accuracy) - self.assertGreaterEqual(f1, self.expected_f1_score) - - def test_linear_and_forest_estimators(self): - search = SearchEstimatorList(estimator_list=['linear', 'forest'], is_discrete=self.is_discrete, scaling=False) - search.fit(self.X_train, self.y_train) - y_pred = search.predict(self.X_test) - acc = accuracy_score(self.y_test, y_pred) - f1 = f1_score(self.y_test, y_pred, average='macro') - - self.assertEqual(len(search.complete_estimator_list), 2) - self.assertEqual(len(search.param_grid_list), 2) - self.assertIsInstance(search.complete_estimator_list[0], LogisticRegressionCV) - self.assertIsInstance(search.complete_estimator_list[1], RandomForestClassifier) - - self.assertGreaterEqual(acc, self.expected_accuracy) - self.assertGreaterEqual(f1, self.expected_f1_score) - - def test_all_estimators(self): - search = SearchEstimatorList(estimator_list=['linear', 'forest', - 'gbf', 'nnet', 'poly'], is_discrete=self.is_discrete, scaling=False) - search.fit(self.X_train, self.y_train) - y_pred = search.predict(self.X_test) - acc = accuracy_score(self.y_test, y_pred) - f1 = f1_score(self.y_test, y_pred, average='macro') - - self.assertEqual(len(search.complete_estimator_list), 5) - self.assertEqual(len(search.param_grid_list), 5) - - self.assertGreaterEqual(acc, self.expected_accuracy) - self.assertGreaterEqual(f1, self.expected_f1_score) - - def test_logistic_regression_estimator(self): - search = SearchEstimatorList(estimator_list=LogisticRegression(), is_discrete=self.is_discrete, scaling=False) - search.fit(self.X_train, self.y_train) - y_pred = search.predict(self.X_test) - acc = accuracy_score(self.y_test, y_pred) - f1 = f1_score(self.y_test, y_pred, average='macro') - self.assertGreaterEqual(acc, self.expected_accuracy) - self.assertGreaterEqual(f1, self.expected_f1_score) - - def test_logistic_regression_cv_estimator(self): - search = SearchEstimatorList(estimator_list=LogisticRegressionCV(), - is_discrete=self.is_discrete, scaling=False) - search.fit(self.X_train, self.y_train) - y_pred = search.predict(self.X_test) - acc = accuracy_score(self.y_test, y_pred) - f1 = f1_score(self.y_test, y_pred, average='macro') - self.assertGreaterEqual(acc, self.expected_accuracy) - self.assertGreaterEqual(f1, self.expected_f1_score) - - def test_empty_estimator_list(self): - with self.assertRaises(ValueError): - search = SearchEstimatorList(estimator_list=[], is_discrete=self.is_discrete, scaling=False) - - def test_invalid_regressor(self): - with self.assertRaises(TypeError): - estimator_list = [SVR(kernel='linear')] - search = SearchEstimatorList(estimator_list=estimator_list, is_discrete=self.is_discrete) - - def test_polynomial_pipeline_regressor(self): - with self.assertRaises(TypeError): - estimator_list = [make_pipeline(PolynomialFeatures(), ElasticNetCV())] - search = SearchEstimatorList(estimator_list=estimator_list, is_discrete=self.is_discrete) - - def test_mlp_regressor(self): - with self.assertRaises(TypeError): - estimator_list = [MLPRegressor()] - search = SearchEstimatorList(estimator_list=estimator_list, is_discrete=self.is_discrete) - - def test_random_forest_regressor(self): - with self.assertRaises(TypeError): - estimator_list = [RandomForestRegressor()] - search = SearchEstimatorList(estimator_list=estimator_list, is_discrete=self.is_discrete) - - def test_gradient_boosting_regressor(self): - with self.assertRaises(TypeError): - estimator_list = [GradientBoostingRegressor()] - search = SearchEstimatorList(estimator_list=estimator_list, is_discrete=self.is_discrete) - - def test_combined_estimators(self): - with self.assertRaises(TypeError): - estimator_list = [LogisticRegression(), SVC(), GradientBoostingRegressor()] - search = SearchEstimatorList(estimator_list=estimator_list, is_discrete=self.is_discrete) - - def test_random_forest_discrete(self): - estimator_list = [RandomForestClassifier()] - param_grid_list = [{'n_estimators': [10, 50, 100], 'max_depth': [3, 5, None]}] - - search = SearchEstimatorList( - estimator_list=estimator_list, param_grid_list=param_grid_list, is_discrete=self.is_discrete, scaling=False) - search.fit(self.X_train, self.y_train) - - self.assertEqual(len(search.complete_estimator_list), 1) - self.assertEqual(len(search.param_grid_list), 1) - - self.assertIsNotNone(search.best_estimator_) - self.assertIsNotNone(search.best_score_) - self.assertIsNotNone(search.best_params_) - - def test_data_scaling(self): - search = SearchEstimatorList(estimator_list='linear', is_discrete=self.is_discrete, scaling=True) - search.fit(self.X_train, self.y_train) - y_pred = search.predict(self.X_test) - acc = accuracy_score(self.y_test, y_pred) - f1 = f1_score(self.y_test, y_pred, average='macro') - - self.assertEqual(len(search.complete_estimator_list), 1) - self.assertEqual(len(search.param_grid_list), 1) - self.assertIsInstance(search.complete_estimator_list[0], LogisticRegressionCV) - - self.assertGreaterEqual(acc, self.expected_accuracy) - self.assertGreaterEqual(f1, self.expected_f1_score) - - def test_custom_scoring_function(self): - def custom_scorer(y_true, y_pred): - return f1_score(y_true, y_pred, average='macro') - - search = SearchEstimatorList(estimator_list='linear', is_discrete=self.is_discrete, - scaling=False, scoring=custom_scorer) - search.fit(self.X_train, self.y_train) - y_pred = search.predict(self.X_test) - acc = accuracy_score(self.y_test, y_pred) - f1 = f1_score(self.y_test, y_pred, average='macro') - - self.assertEqual(len(search.complete_estimator_list), 1) - self.assertEqual(len(search.param_grid_list), 1) - self.assertIsInstance(search.complete_estimator_list[0], LogisticRegressionCV) - - self.assertGreaterEqual(acc, self.expected_accuracy) - self.assertGreaterEqual(f1, self.expected_f1_score) - - # def test_refit_false(self): - # search = SearchEstimatorList(estimator_list='linear', is_discrete=self.is_discrete, scaling=False, refit=False) - # search.fit(self.X_train, self.y_train) - # with self.assertRaises(NotFittedError): - # y_pred = search.predict(self.X_test) - - def test_custom_random_state(self): - search = SearchEstimatorList(estimator_list='linear', is_discrete=self.is_discrete, - scaling=False, random_state=42) - search.fit(self.X_train, self.y_train) - y_pred = search.predict(self.X_test) - acc = accuracy_score(self.y_test, y_pred) - f1 = f1_score(self.y_test, y_pred, average='macro') - - self.assertEqual(len(search.complete_estimator_list), 1) - self.assertEqual(len(search.param_grid_list), 1) - self.assertIsInstance(search.complete_estimator_list[0], LogisticRegressionCV) - - self.assertGreaterEqual(acc, self.expected_accuracy) - self.assertGreaterEqual(f1, self.expected_f1_score) - - - def test_invalid_incorrect_scoring_numbers(self): - with self.assertRaises(ValueError): - search = SearchEstimatorList(estimator_list='linear', is_discrete=self.is_discrete, - scaling=False, scoring=123) - - -if __name__ == '__main__': - unittest.main() diff --git a/econml/new_tests/test_model_selection_utils.py b/econml/new_tests/test_model_selection_utils.py deleted file mode 100644 index 8e7e7c917..000000000 --- a/econml/new_tests/test_model_selection_utils.py +++ /dev/null @@ -1,235 +0,0 @@ -import unittest - -import numpy as np -from econml.sklearn_extensions.model_selection import * -from econml.sklearn_extensions.model_selection_utils import * -from sklearn.datasets import fetch_california_housing, load_iris -from sklearn.preprocessing import StandardScaler, PolynomialFeatures -from sklearn.model_selection import train_test_split -from sklearn.linear_model import ElasticNetCV, LogisticRegressionCV - - -class TestIsDataScaled(unittest.TestCase): - - def test_scaled_data(self): - # Test with data that is already centered and scaled - X = np.array([[0.0, -1.0], [1.0, 0.0], [-1.0, 1.0]]) - scale = StandardScaler() - scaled_X = scale.fit_transform(X) - self.assertTrue(is_data_scaled(scaled_X)) - - def test_unscaled_data(self): - # Test with data that is not centered and scaled - X = np.array([[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]]) - self.assertFalse(is_data_scaled(X)) - - def test_large_scaled_data(self): - # Test with a larger dataset that is already centered and scaled - np.random.seed(42) - X = np.random.randn(1000, 5) - scale = StandardScaler() - scaled_X = scale.fit_transform(X) - self.assertTrue(is_data_scaled(scaled_X)) - - def test_large_unscaled_data(self): - np.random.seed(42) - X = np.random.randn(1000, 5) - self.assertFalse(is_data_scaled(X)) - - def test_is_data_scaled_with_scaled_iris_dataset(self): - X, y = load_iris(return_X_y=True) - scaler = StandardScaler() - X_scaled = scaler.fit_transform(X) - assert is_data_scaled(X_scaled) == True - - def test_is_data_scaled_with_unscaled_iris_dataset(self): - X, y = load_iris(return_X_y=True) - assert is_data_scaled(X) == False - - def test_is_data_scaled_with_scaled_california_housing_dataset(self): - X, y = housing = fetch_california_housing(return_X_y=True) - scaler = StandardScaler() - X_scaled = scaler.fit_transform(X) - assert is_data_scaled(X_scaled) == True - - def test_is_data_scaled_with_unscaled_california_housing_dataset(self): - X, y = fetch_california_housing(return_X_y=True) - assert is_data_scaled(X) == False - - -class TestFlattenList(unittest.TestCase): - - def test_flatten_empty_list(self): - input = [] - expected_output = [] - self.assertEqual(flatten_list(input), expected_output) - - def test_flatten_simple_list(self): - input = [1, 10, 15] - expected_output = [1, 10, 15] - self.assertEqual(flatten_list(input), expected_output) - - def test_flatten_nested_list(self): - input = [1, [10, 15], [20, [25, 30]]] - expected_output = [1, 10, 15, 20, 25, 30] - self.assertEqual(flatten_list(input), expected_output) - - # Check functionality for below - # def test_flatten_none_list(self): - # input = [[1, 10, None], 15, None] - # expected_output = [1, 10, None, 15, None] - # self.assertEqual(flatten_list(input), expected_output) - - def test_flatten_iris_dataset(self): - X = load_iris() - input = X.data.tolist() - expected_output = sum(X.data.tolist(), []) - self.assertEqual(flatten_list(input), expected_output) - - def test_flatten_california_housing_dataset(self): - X = fetch_california_housing() - input = X.data.tolist() - expected_output = sum(X.data.tolist(), []) - self.assertEqual(flatten_list(input), expected_output) - - -class TestIsPolynomialPipeline(unittest.TestCase): - - def test_is_polynomial_pipeline_true(self): - X = np.array([[5, 10], [15, 20], [25, 30], [35, 40], [45, 50]]) - y = np.array([15, 29, 38, 47, 55]) - scaler = StandardScaler() - X_scaled = scaler.fit_transform(X) - model = Pipeline([ - ('poly', PolynomialFeatures(degree=2)), - ('linear', ElasticNetCV()) - ]) - model.fit(X_scaled, y) - assert is_polynomial_pipeline(model) == True - - def test_is_polynomial_pipeline_false(self): - model = ElasticNetCV() - assert is_polynomial_pipeline(model) == False - - def test_is_polynomial_pipeline_false_step_number(self): - X, y = load_iris(return_X_y=True) - model = Pipeline([ - ('poly', PolynomialFeatures(degree=2)), - ('linear', LogisticRegressionCV()), - ('step_false', '') - ]) - assert is_polynomial_pipeline(model) == False - - def test_is_polynomial_pipeline_interchange_steps(self): - X, y = load_iris(return_X_y=True) - model = Pipeline([ - ('poly', LogisticRegressionCV()), - ('linear', PolynomialFeatures(degree=2)), - ]) - assert is_polynomial_pipeline(model) == False - - # Cross-check functionaity - can the 'poly' keyword be changed to something else - def test_is_polynomial_pipeline_false_first_step(self): - X, y = fetch_california_housing(return_X_y=True) - model = Pipeline([ - ('not_poly', PolynomialFeatures(degree=2)), - ('linear', ElasticNetCV()) - ]) - assert is_polynomial_pipeline(model) == True - - -class TestCheckListType(unittest.TestCase): - - def test_check_list_type_true(self): - list = ['linear', LogisticRegressionCV(), KFold()] - assert check_list_type(list) == True - - def test_check_list_type_false_string(self): - list = [18, LogisticRegressionCV(), KFold()] - try: - check_list_type(list) - except TypeError as e: - assert str(e) == "The list must contain only strings, sklearn model objects, and sklearn model selection objects." - - def test_check_list_type_empty(self): - list = [] - try: - check_list_type(list) - except ValueError as e: - assert str(e) == "Estimator list is empty. Please add some models or use some of the defaults provided." - - def test_check_list_type_all_strings(self): - list = ['linear', 'lasso', 'forest'] - assert check_list_type(list) == True - - def test_check_list_type_all_models(self): - list = [LogisticRegressionCV(), ElasticNetCV()] - assert check_list_type(list) == True - - def test_check_list_duplicate_models_strings(self): - list = [LogisticRegressionCV(), LogisticRegressionCV(), 'linear', 'linear'] - assert check_list_type(list) == True - - -class TestSelectContinuousEstimator(unittest.TestCase): - - def test_select_continuous_estimator_valid(self): - assert isinstance(select_continuous_estimator('linear'), ElasticNetCV) - assert isinstance(select_continuous_estimator('forest'), RandomForestRegressor) - assert isinstance(select_continuous_estimator('gbf'), GradientBoostingRegressor) - assert isinstance(select_continuous_estimator('nnet'), MLPRegressor) - assert isinstance(select_continuous_estimator('poly'), Pipeline) - - def test_select_continuous_estimator_invalid(self): - try: - select_continuous_estimator('ridge') - except ValueError as e: - assert str(e) == 'Unsupported estimator type: ridge' - - -class TestSelectDiscreteEstimator(unittest.TestCase): - - def test_select_discrete_estimator_valid(self): - assert isinstance(select_discrete_estimator('linear'), LogisticRegressionCV) - assert isinstance(select_discrete_estimator('forest'), RandomForestClassifier) - assert isinstance(select_discrete_estimator('gbf'), GradientBoostingClassifier) - assert isinstance(select_discrete_estimator('nnet'), MLPClassifier) - assert isinstance(select_discrete_estimator('poly'), Pipeline) - - def test_select_discrete_estimator_invalid(self): - try: - select_discrete_estimator('lasso') - except ValueError as e: - assert str(e) == 'Unsupported estimator type: lasso' - - -class TestSelectEstimator(unittest.TestCase): - - def test_select_estimator_valid(self): - assert isinstance(select_estimator('linear', is_discrete=False), ElasticNetCV) - assert isinstance(select_estimator('forest', is_discrete=False), RandomForestRegressor) - assert isinstance(select_estimator('gbf', is_discrete=False), GradientBoostingRegressor) - assert isinstance(select_estimator('nnet', is_discrete=False), MLPRegressor) - assert isinstance(select_estimator('poly', is_discrete=False), Pipeline) - - assert isinstance(select_estimator('linear', is_discrete=True), LogisticRegression) - assert isinstance(select_estimator('forest', is_discrete=True), RandomForestClassifier) - assert isinstance(select_estimator('gbf', is_discrete=True), GradientBoostingClassifier) - assert isinstance(select_estimator('nnet', is_discrete=True), MLPClassifier) - assert isinstance(select_estimator('poly', is_discrete=True), Pipeline) - - def test_select_estimator_invalid_estimator(self): - try: - select_estimator('lasso', is_discrete=True) - except ValueError as e: - assert str(e) == 'Unsupported estimator type: lasso' - - def test_select_estimator_invalid(self): - try: - select_estimator('linear', is_discrete=None) - except ValueError as e: - assert str(e) == 'Unsupported target type: None' - - -if __name__ == '__main__': - unittest.main() diff --git a/econml/panel/dml/_dml.py b/econml/panel/dml/_dml.py index c3dc96a4e..10ce615c5 100644 --- a/econml/panel/dml/_dml.py +++ b/econml/panel/dml/_dml.py @@ -9,13 +9,13 @@ from scipy.stats import norm from sklearn.linear_model import (ElasticNetCV, LassoCV, LogisticRegressionCV) from ...sklearn_extensions.linear_model import (StatsModelsLinearRegression, WeightedLassoCVWrapper) -from ...sklearn_extensions.model_selection import WeightedStratifiedKFold -from ...dml.dml import _FirstStageWrapper, _FinalWrapper +from ...sklearn_extensions.model_selection import ModelSelector, WeightedStratifiedKFold +from ...dml.dml import _make_first_stage_selector, _FinalWrapper from ..._cate_estimator import TreatmentExpansionMixin, LinearModelFinalCateEstimatorMixin from ..._ortho_learner import _OrthoLearner from ...utilities import (_deprecate_positional, add_intercept, broadcast_unit_treatments, check_high_dimensional, - cross_product, deprecated, fit_with_groups, + cross_product, deprecated, hstack, inverse_onehot, ndim, reshape, reshape_treatmentwise_effects, shape, transpose, get_feature_names_or_default, check_input_arrays, @@ -33,7 +33,7 @@ def _get_groups_period_filter(groups, n_periods): return group_period_filter -class _DynamicModelNuisance: +class _DynamicModelNuisanceSelector(ModelSelector): """ Nuisance model fits the model_y and model_t at fit time and at predict time calculates the residual Y and residual T based on the fitted models and returns @@ -45,21 +45,27 @@ def __init__(self, model_y, model_t, n_periods): self._model_t = model_t self.n_periods = n_periods - def fit(self, Y, T, X=None, W=None, sample_weight=None, groups=None): + def train(self, is_selecting, Y, T, X=None, W=None, sample_weight=None, groups=None): """Fit a series of nuisance models for each period or period pairs.""" assert Y.shape[0] % self.n_periods == 0, \ "Length of training data should be an integer multiple of time periods." period_filters = _get_groups_period_filter(groups, self.n_periods) - self._model_y_trained = {} - self._model_t_trained = {j: {} for j in np.arange(self.n_periods)} + if is_selecting: # create the per-period y and t models + self._model_y_trained = {t: clone(self._model_y, safe=False) + for t in np.arange(self.n_periods)} + self._model_t_trained = {j: {t: clone(self._model_t, safe=False) + for t in np.arange(j + 1)} + for j in np.arange(self.n_periods)} for t in np.arange(self.n_periods): - self._model_y_trained[t] = clone(self._model_y, safe=False).fit( + self._model_y_trained[t].train( + is_selecting, self._index_or_None(X, period_filters[t]), self._index_or_None( W, period_filters[t]), Y[period_filters[self.n_periods - 1]]) for j in np.arange(t, self.n_periods): - self._model_t_trained[j][t] = clone(self._model_t, safe=False).fit( + self._model_t_trained[j][t].train( + is_selecting, self._index_or_None(X, period_filters[t]), self._index_or_None(W, period_filters[t]), T[period_filters[j]]) @@ -534,30 +540,18 @@ def _gen_featurizer(self): return clone(self.featurizer, safe=False) def _gen_model_y(self): - if self.model_y == 'auto': - model_y = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_y = clone(self.model_y, safe=False) - return _FirstStageWrapper(model_y, True, self._gen_featurizer(), - self.linear_first_stages, self.discrete_treatment) + return _make_first_stage_selector(self.model_y, is_discrete=False, random_state=self.random_state) def _gen_model_t(self): - if self.model_t == 'auto': - if self.discrete_treatment: - model_t = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) - else: - model_t = WeightedLassoCVWrapper(random_state=self.random_state) - else: - model_t = clone(self.model_t, safe=False) - return _FirstStageWrapper(model_t, False, self._gen_featurizer(), - self.linear_first_stages, self.discrete_treatment) + return _make_first_stage_selector(self.model_t, + is_discrete=self.discrete_treatment, + random_state=self.random_state) def _gen_model_final(self): return StatsModelsLinearRegression(fit_intercept=False) def _gen_ortho_learner_model_nuisance(self): - return _DynamicModelNuisance( + return _DynamicModelNuisanceSelector( model_t=self._gen_model_t(), model_y=self._gen_model_y(), n_periods=self._n_periods) diff --git a/econml/sklearn_extensions/linear_model.py b/econml/sklearn_extensions/linear_model.py index 0c90c6868..8045d23bf 100644 --- a/econml/sklearn_extensions/linear_model.py +++ b/econml/sklearn_extensions/linear_model.py @@ -20,8 +20,7 @@ import warnings from collections.abc import Iterable from scipy.stats import norm -from econml.sklearn_extensions.model_selection import WeightedKFold, WeightedStratifiedKFold -from econml.utilities import ndim, shape, reshape, _safe_norm_ppf, check_input_arrays +from ..utilities import ndim, shape, reshape, _safe_norm_ppf, check_input_arrays from sklearn import clone from sklearn.linear_model import LinearRegression, LassoCV, MultiTaskLassoCV, Lasso, MultiTaskLasso from sklearn.linear_model._base import _preprocess_data @@ -41,7 +40,24 @@ from typing import List +class _WeightedCVIterableWrapper(_CVIterableWrapper): + def __init__(self, cv): + super().__init__(cv) + + def get_n_splits(self, X=None, y=None, groups=None, sample_weight=None): + if groups is not None and sample_weight is not None: + raise ValueError("Cannot simultaneously use grouping and weighting") + return super().get_n_splits(X, y, groups) + + def split(self, X=None, y=None, groups=None, sample_weight=None): + if groups is not None and sample_weight is not None: + raise ValueError("Cannot simultaneously use grouping and weighting") + return super().split(X, y, groups) + + def _weighted_check_cv(cv=5, y=None, classifier=False, random_state=None): + # local import to avoid circular imports + from .model_selection import WeightedKFold, WeightedStratifiedKFold cv = 5 if cv is None else cv if isinstance(cv, numbers.Integral): if (classifier and (y is not None) and @@ -60,21 +76,6 @@ def _weighted_check_cv(cv=5, y=None, classifier=False, random_state=None): return cv # New style cv objects are passed without any modification -class _WeightedCVIterableWrapper(_CVIterableWrapper): - def __init__(self, cv): - super().__init__(cv) - - def get_n_splits(self, X=None, y=None, groups=None, sample_weight=None): - if groups is not None and sample_weight is not None: - raise ValueError("Cannot simultaneously use grouping and weighting") - return super().get_n_splits(X, y, groups) - - def split(self, X=None, y=None, groups=None, sample_weight=None): - if groups is not None and sample_weight is not None: - raise ValueError("Cannot simultaneously use grouping and weighting") - return super().split(X, y, groups) - - class WeightedModelMixin: """Mixin class for weighted models. @@ -1204,73 +1205,90 @@ def _set_attribute(self, attribute_name, condition=True, default=None): setattr(self, attribute_name, attribute_value) -class WeightedLassoCVWrapper: - """Helper class to wrap either WeightedLassoCV or WeightedMultiTaskLassoCV depending on the shape of the target.""" +class _PairedEstimatorWrapper: + """Helper class to wrap two different estimators, one of which can be used only with single targets and the other + which can be used on multiple targets. Not intended to be used directly by users.""" + + _SingleEst = None + _MultiEst = None + _known_params = [] + _post_fit_attrs = [] def __init__(self, *args, **kwargs): self.args = args self.kwargs = kwargs - # set model to WeightedLassoCV by default so there's always a model to get and set attributes on - self.model = WeightedLassoCV(*args, **kwargs) - - # whitelist known params because full set is not necessarily identical between LassoCV and MultiTaskLassoCV - # (e.g. former has 'positive' and 'precompute' while latter does not) - known_params = set(['eps', 'n_alphas', 'alphas', 'fit_intercept', 'normalize', 'max_iter', 'tol', 'copy_X', - 'cv', 'verbose', 'n_jobs', 'random_state', 'selection']) + # set model to the single-target estimator by default so there's always a model to get and set attributes on + self.model = self._SingleEst(*args, **kwargs) def fit(self, X, y, sample_weight=None): - self.needs_unravel = False + self._needs_unravel = False params = {key: value for (key, value) in self.get_params().items() - if key in self.known_params} + if key in self._known_params} if ndim(y) == 2 and shape(y)[1] > 1: - self.model = WeightedMultiTaskLassoCV(**params) + self.model = self._MultiEst(**params) else: if ndim(y) == 2 and shape(y)[1] == 1: y = np.ravel(y) - self.needs_unravel = True - self.model = WeightedLassoCV(**params) + self._needs_unravel = True + self.model = self._SingleEst(**params) self.model.fit(X, y, sample_weight) - # set intercept_ attribute - self.intercept_ = self.model.intercept_ - # set coef_ attribute - self.coef_ = self.model.coef_ - # set alpha_ attribute - self.alpha_ = self.model.alpha_ - # set alphas_ attribute - self.alphas_ = self.model.alphas_ - # set n_iter_ attribute - self.n_iter_ = self.model.n_iter_ + for param in self._post_fit_attrs: + setattr(self, param, getattr(self.model, param)) return self def predict(self, X): predictions = self.model.predict(X) - return reshape(predictions, (-1, 1)) if self.needs_unravel else predictions + return reshape(predictions, (-1, 1)) if self._needs_unravel else predictions def score(self, X, y, sample_weight=None): return self.model.score(X, y, sample_weight) def __getattr__(self, key): - if key in self.known_params: + if key in self._known_params: return getattr(self.model, key) else: raise AttributeError("No attribute " + key) def __setattr__(self, key, value): - if key in self.known_params: + if key in self._known_params: setattr(self.model, key, value) else: super().__setattr__(key, value) def get_params(self, deep=True): """Get parameters for this estimator.""" - return self.model.get_params(deep=deep) + return {k: v for k, v in self.model.get_params(deep=deep).items() if k in self._known_params} def set_params(self, **params): """Set parameters for this estimator.""" self.model.set_params(**params) +class WeightedLassoCVWrapper(_PairedEstimatorWrapper): + """Helper class to wrap either WeightedLassoCV or WeightedMultiTaskLassoCV depending on the shape of the target.""" + + _SingleEst = WeightedLassoCV + _MultiEst = WeightedMultiTaskLassoCV + + # whitelist known params because full set is not necessarily identical between LassoCV and MultiTaskLassoCV + # (e.g. former has 'positive' and 'precompute' while latter does not) + _known_params = set(['eps', 'n_alphas', 'alphas', 'fit_intercept', 'normalize', 'max_iter', 'tol', 'copy_X', + 'cv', 'verbose', 'n_jobs', 'random_state', 'selection']) + + _post_fit_attrs = set(['alpha_', 'alphas_', 'coef_', 'dual_gap_', 'intercept_', 'n_iter_', 'n_features_in_']) + + +class WeightedLassoWrapper(_PairedEstimatorWrapper): + """Helper class to wrap either WeightedLasso or WeightedMultiTaskLasso depending on the shape of the target.""" + + _SingleEst = WeightedLasso + _MultiEst = WeightedMultiTaskLasso + _known_params = set(['alpha', 'fit_intercept', 'copy_X', 'max_iter', 'tol', + 'random_state', 'selection']) + _post_fit_attrs = set(['coef_', 'dual_gap_', 'intercept_', 'n_iter_', 'n_features_in_']) + + class SelectiveRegularization: """ Estimator of a linear model where regularization is applied to only a subset of the coefficients. diff --git a/econml/sklearn_extensions/model_selection.py b/econml/sklearn_extensions/model_selection.py index d8c55538d..be692f9c3 100644 --- a/econml/sklearn_extensions/model_selection.py +++ b/econml/sklearn_extensions/model_selection.py @@ -3,27 +3,36 @@ """Collection of scikit-learn extensions for model selection techniques.""" import numbers -import pdb import warnings +import abc import numpy as np +from collections.abc import Iterable import scipy.sparse as sp import sklearn from joblib import Parallel, delayed from sklearn.base import BaseEstimator, clone, is_classifier +from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor from sklearn.exceptions import FitFailedWarning -from sklearn.model_selection import (BaseCrossValidator, GridSearchCV, KFold, +from sklearn.linear_model import (ElasticNet, ElasticNetCV, Lasso, LassoCV, MultiTaskElasticNet, MultiTaskElasticNetCV, + MultiTaskLasso, MultiTaskLassoCV, Ridge, RidgeCV, RidgeClassifier, RidgeClassifierCV, + LogisticRegression, LogisticRegressionCV) +from sklearn.model_selection import (BaseCrossValidator, GridSearchCV, GroupKFold, KFold, RandomizedSearchCV, StratifiedKFold, check_cv) # TODO: conisder working around relying on sklearn implementation details from sklearn.model_selection._validation import (_check_is_permutation, _fit_and_predict) -from sklearn.preprocessing import LabelEncoder +from sklearn.preprocessing import LabelEncoder, StandardScaler from sklearn.utils import check_random_state, indexable from sklearn.utils.multiclass import type_of_target from sklearn.utils.validation import _num_samples -from econml.sklearn_extensions.model_selection_utils import * +from .linear_model import WeightedLassoCVWrapper, WeightedLassoWrapper +from .model_selection_utils import (auto_hyperparameters, can_handle_multitask, get_complete_estimator_list, + has_random_state, is_data_scaled, is_likely_multi_task, + is_mlp, is_polynomial_pipeline, just_one_model_no_params, make_model_multi_task, + make_param_multi_task, param_grid_is_empty, supports_sample_weight) def _split_weighted_sample(self, X, y, sample_weight, is_stratified=False): @@ -261,11 +270,295 @@ def get_n_splits(self, X, y, groups=None): return self.n_splits +class ModelSelector(metaclass=abc.ABCMeta): + """ + This class enables a two-stage fitting process, where first a model is selected + by calling `train` with `is_selecting=True`, and then the selected model is fit (presumably + on a different data set) by calling train with `is_selecting=False`. + + + """ + + @abc.abstractmethod + def train(self, is_selecting: bool, *args, **kwargs): + """ + Either selects a model or fits a model, depending on the value of `is_selecting`. + """ + raise NotImplementedError("Abstract method") + + @abc.abstractmethod + def predict(self, *args, **kwargs): + """ + Predicts using the selected model; should not be called until after `train` has been used + both to select a model and to fit it. + """ + raise NotImplementedError("Abstract method") + + @abc.abstractmethod + def score(self, *args, **kwargs): + """ + Gets the score of the selected model on the given data; should not be called until after `train` has been used + both to select a model and to fit it. + """ + raise NotImplementedError("Abstract method") + + +class SingleModelSelector(ModelSelector): + """ + A model selection class that selects a single best model; + this encompasses random search, grid search, ensembling, etc. + """ + + @property + @abc.abstractmethod + def best_model(self): + raise NotImplementedError("Abstract method") + + @property + @abc.abstractmethod + def best_score(self): + raise NotImplementedError("Abstract method") + + def predict(self, *args, **kwargs): + return self.best_model.predict(*args, **kwargs) + + def predict_proba(self, *args, **kwargs): + return self.best_model.predict_proba(*args, **kwargs) + + def score(self, *args, **kwargs): + if hasattr(self.best_model, 'score'): + return self.best_model.score(*args, **kwargs) + else: + return None + + +def _fit_with_groups(model, X, y, *, groups, **kwargs): + """ + Fits a model while correctly handling grouping if necessary. + + This enables us to perform an inner-loop cross-validation of a model + which handles grouping correctly, which is not easy using typical sklearn models. + + For example, GridSearchCV and RandomSearchCV both support passing `groups` to fit, + but other CV-related estimators (e.g. LassoCV) do not, which means that GroupKFold + cannot be used as the cv instance, because the `groups` argument will never be passed through + to GroupKFold's `split` method. + + The hacky workaround here is to explicitly set the `cv` attribute to the set of + rows that GroupKFold would have generated rather than using GroupKFold as the cv instance. + """ + if groups is not None: + if hasattr(model, 'cv'): + old_cv = model.cv + # logic copied from check_cv + cv = 5 if old_cv is None else old_cv + if isinstance(cv, numbers.Integral): + cv = GroupKFold(cv) + # otherwise we will assume the user already set the cv attribute to something + # compatible with splitting with a `groups` argument + + splits = list(cv.split(X, y, groups=groups)) + try: + model.cv = splits + return model.fit(X, y, **kwargs) # drop groups from arg list + finally: + model.cv = old_cv + + # drop groups from arg list, which were already used at the outer level and may not be supported by the model + return model.fit(X, y, **kwargs) + + +class FixedModelSelector(SingleModelSelector): + """ + Model selection class that always selects the given model + """ + + def __init__(self, model): + self.model = clone(model, safe=False) + + def train(self, is_selecting, *args, groups=None, **kwargs): + # whether selecting or not, need to train the model on the data + _fit_with_groups(self.model, *args, groups=groups, **kwargs) + if is_selecting and hasattr(self.model, 'score'): + self._score = self.model.score(*args, **kwargs) + return self + + @property + def best_model(self): + return self.model + + @property + def best_score(self): + return self._score + + +class SklearnCVSelector(SingleModelSelector): + """ + Wraps one of sklearn's CV classes in the ModelSelector interface + """ + + def __init__(self, searcher): + self.searcher = clone(searcher) + + @staticmethod + def convertible_types(): + return {GridSearchCV, RandomizedSearchCV} | SklearnCVSelector._model_mapping().keys() + + @staticmethod + def can_wrap(model): + return any(isinstance(model, model_type) for model_type in SklearnCVSelector.convertible_types()) + + @staticmethod + def _model_mapping(): + return {LogisticRegressionCV: (LogisticRegression, + ["C", "l1_ratio"], + [], + ["classes_", "coef_", "intercept_", "n_features_in_", "n_iter_"]), + ElasticNetCV: (ElasticNet, + ["alpha", "l1_ratio"], + ["precompute"], + ["coef_", "intercept_", "dual_gap_", "n_features_in_", "n_iter_"]), + LassoCV: (Lasso, + ["alpha"], + ["precompute"], + ["coef_", "intercept_", "dual_gap_", "n_features_in_", "n_iter_"]), + RidgeCV: (Ridge, + ["alpha"], + [], + ["coef_", "intercept_", "dual_gap_", "n_features_in_", "n_iter_"]), + RidgeClassifierCV: (RidgeClassifier, + ["alpha"], + [], + ["label_binarizer", "coef_", "intercept_", "n_features_in_", "n_iter_"]), + MultiTaskElasticNetCV: (MultiTaskElasticNet, + ["alpha", "l1_ratio"], + ["precompute"], + ["coef_", "intercept_", "dual_gap_", "n_features_in_", "n_iter_"]), + MultiTaskLassoCV: (MultiTaskLasso, + ["alpha"], + [], + ["coef_", "intercept_", "dual_gap_", "n_features_in_", "n_iter_"]), + WeightedLassoCVWrapper: (WeightedLassoWrapper, + ["alpha"], + [], + ["coef_", "intercept_", "dual_gap_", "n_features_in_", "n_iter_"]) + } + + def train(self, is_selecting: bool, *args, groups=None, **kwargs): + if is_selecting: + + _fit_with_groups(self.searcher, *args, groups=groups, **kwargs) + self._best_model = self._extract_best_model() + # TODO: ideally, want the out-of-sample score here instead; + # but this is not exposed in a consistent way + self._best_score = self.searcher.score(*args, **kwargs) + else: + # don't need to use _fit_with_groups here since none of these models support it + self.best_model.fit(*args, **kwargs) + return self + + @property + def best_model(self): + return self._best_model + + @property + def best_score(self): + return self._best_score + + def _extract_best_model(self): + if isinstance(self.searcher, GridSearchCV) or isinstance(self.searcher, RandomizedSearchCV): + return self.searcher.best_estimator_ + else: + for known_type in self._model_mapping().keys(): + if isinstance(self.searcher, known_type): + model_type, opt_params, strip_params, fit_vars = self._model_mapping()[known_type] + model = model_type() + # set all shared parameters + for param in model.get_params().keys() & self.searcher.get_params().keys() - set(strip_params): + setattr(model, param, getattr(self.searcher, param)) + # update learned hyperparameters with best values + for param in opt_params: + setattr(model, param, getattr(self.searcher, param + "_")) + # set all fitted variables + for var in fit_vars: + setattr(model, var, getattr(self.searcher, var)) + return model + raise ValueError(f"Unsupported type: {type(self.searcher)}") + + +class ListSelector(SingleModelSelector): + """ + Model selection class that selects the best model from a list of model selectors + + Parameters + ---------- + models : list of ModelSelector + The list of model selectors to choose from + unwrap : bool, default True + Whether to return the best model's best model, rather than just the outer best model selector + """ + + def __init__(self, models, unwrap=True): + self.models = [clone(model, safe=False) for model in models] + self.unwrap = unwrap + + def train(self, is_selecting, *args, **kwargs): + if is_selecting: + scores = [] + for model in self.models: + model.train(is_selecting, *args, **kwargs) + scores.append(model.best_score) + self._all_scores = scores + self._best_score = np.max(scores) + self._best_model = self.models[np.argmax(scores)] + + else: + self._best_model.train(is_selecting, *args, **kwargs) + + @property + def best_model(self): + """ + Gets the best model; note that if we were selecting over SingleModelSelectors and `unwrap` is `False`, + we will return the SingleModelSelector instance, not its best model. + """ + return self._best_model.best_model if self.unwrap else self._best_model + + @property + def best_score(self): + return self._best_score + + +def get_selector(input, is_discrete, *, random_state=None, cv=None, wrapper=GridSearchCV): + named_models = { + 'linear': (LogisticRegressionCV(random_state=random_state, cv=cv) if is_discrete + else WeightedLassoCVWrapper(random_state=random_state, cv=cv)), + 'forest': (RandomForestClassifier(random_state=random_state) if is_discrete + else RandomForestRegressor(random_state=random_state)), + } + if isinstance(input, ModelSelector): # we've already got a model selector, don't need to do anything + return input + elif isinstance(input, list): # we've got a list; call get_selector on each element, then wrap in a ListSelector + models = [get_selector(model, is_discrete, + random_state=random_state, cv=cv, wrapper=wrapper) + for model in input] + return ListSelector(models) + elif isinstance(input, str): # we've got a string; look it up + if input in named_models: + return get_selector(named_models[input], is_discrete, + random_state=random_state, cv=cv, wrapper=wrapper) + else: + raise ValueError(f"Unknown model type: {input}, must be one of {named_models.keys()}") + elif SklearnCVSelector.can_wrap(input): + return SklearnCVSelector(input) + else: # assume this is an sklearn-compatible model + return FixedModelSelector(input) + + class SearchEstimatorList(BaseEstimator): """ The SearchEstimatorList is a utility class for hyperparameter tuning. - It provides a convenient way to perform GridSearch cross-validation for - a list of estimators. The class automates the process of hyperparameter + It provides a convenient way to perform GridSearch cross-validation for + a list of estimators. The class automates the process of hyperparameter tuning, model fitting, and prediction for multiple estimators. @@ -275,7 +568,8 @@ class SearchEstimatorList(BaseEstimator): A list of names of estimators to be used for grid search. param_grid_list : list or 'auto', default 'auto' - A list of dictionaries specifying hyperparameters for each estimator in `estimator_list`. If set to 'auto', the class automatically generates hyperparameters for the estimators. + A list of dictionaries specifying hyperparameters for each estimator in `estimator_list`. If set to 'auto', + the class automatically generates hyperparameters for the estimators. scaling : bool, default True Indicates whether to scale the input data using StandardScaler. @@ -304,32 +598,35 @@ class SearchEstimatorList(BaseEstimator): random_state : int, RandomState instance, or None, default None If int, `random_state` is the seed used by the random number generator; If `RandomState` instance, `random_state` is the random number generator; - If None, the random number generator is the `RandomState` instance used by `np.random`. Used when `shuffle` == True. + If None, the random number generator is the `RandomState` instance used by `np.random`. + Used when `shuffle` == True. error_score : float or 'raise', default np.nan - The value assigned to the score if an error occurs during fitting an estimator. If set to 'raise', an error is raised. + The value assigned to the score if an error occurs during fitting an estimator. If set to 'raise', + an error is raised. return_train_score : bool, default False Determines whether to include training scores in the `cv_results_` attribute of the class. categorical_indices : str, int, list, or None default None - List of categorical indices + List of categorical indices """ - def __init__(self, estimator_list=['linear', 'forest'], param_grid_list=None, scaling=False, is_discrete=False, scoring=None, - n_jobs=None, refit=True, cv=2, verbose=2, pre_dispatch='2*n_jobs', random_state=None, + def __init__(self, estimator_list=['linear', 'forest'], param_grid_list=None, scaling=False, + is_discrete=False, scoring=None, n_jobs=None, refit=True, cv=2, verbose=2, + pre_dispatch='2*n_jobs', random_state=None, error_score=np.nan, return_train_score=False, categorical_indices=None): - # pdb.set_trace() self.estimator_list = estimator_list self.complete_estimator_list = get_complete_estimator_list( clone(estimator_list, safe=False), is_discrete=is_discrete, random_state=random_state) - # TODO Add in more functionality by checking if it's an empty list. If it's just 1 dictionary then we're going to need to turn it into a list + # TODO Add in more functionality by checking if it's an empty list. If it's just 1 dictionary + # then we're going to need to turn it into a list # Just do more cases if param_grid_list == 'auto': self.param_grid_list = auto_hyperparameters( estimator_list=self.complete_estimator_list, is_discrete=is_discrete) - elif (param_grid_list == None): + elif (param_grid_list is None): self.param_grid_list = len(self.complete_estimator_list) * [{}] else: if isinstance(param_grid_list, dict): @@ -338,7 +635,7 @@ def __init__(self, estimator_list=['linear', 'forest'], param_grid_list=None, sc self.param_grid_list = param_grid_list self.categorical_indices = categorical_indices self.scoring = scoring - if scoring == None: + if scoring is None: if is_discrete: self.scoring = 'f1_macro' else: @@ -357,10 +654,6 @@ def __init__(self, estimator_list=['linear', 'forest'], param_grid_list=None, sc self.supported_models = ['linear', 'forest', 'gbf', 'nnet', 'poly'] def fit(self, X, y, *, sample_weight=None, groups=None): - # print(groups) - # if groups != None: - # pdb.set_trace() - # pdb.set_trace() self._search_list = [] # Change estimators if multi_task @@ -369,7 +662,7 @@ def fit(self, X, y, *, sample_weight=None, groups=None): if not can_handle_multitask(model=estimator, is_discrete=self.is_discrete): self.complete_estimator_list[index] = make_model_multi_task( model=estimator, is_discrete=self.is_discrete) - if self.param_grid_list != None: + if self.param_grid_list is not None: self.param_grid_list[index] = make_param_multi_task( estimator=estimator, param_grid=self.param_grid_list[index]) @@ -381,9 +674,10 @@ def fit(self, X, y, *, sample_weight=None, groups=None): if just_one_model_no_params(estimator_list=self.complete_estimator_list, param_list=self.param_grid_list): # Just fit the model and return it, no need for grid search or for loop estimator = self.complete_estimator_list[0] - if self.random_state != None: + if self.random_state is not None: if has_random_state(model=estimator): - # For a polynomial pipeline, you have to set the random state of the linear part, the polynomial part doesn't have random state + # For a polynomial pipeline, you have to set the random state of the linear part, + # the polynomial part doesn't have random state if is_polynomial_pipeline(estimator): estimator = estimator.set_params(linear__random_state=self.random_state) else: @@ -407,14 +701,15 @@ def fit(self, X, y, *, sample_weight=None, groups=None): else: print(f"Processing estimator: {type(estimator).__name__}") try: - if self.random_state != None: + if self.random_state is not None: if has_random_state(model=estimator): - # For a polynomial pipeline, you have to set the random state of the linear part, the polynomial part doesn't have random state + # For a polynomial pipeline, you have to set the random state of the linear part, + # the polynomial part doesn't have random state if is_polynomial_pipeline(estimator): estimator = estimator.set_params(linear__random_state=self.random_state) else: estimator.set_params(random_state=self.random_state) - # pdb.set_trace() # Note Delete this + temp_search = GridSearchCV(estimator, param_grid, scoring=self.scoring, n_jobs=self.n_jobs, refit=self.refit, cv=self.cv, verbose=self.verbose, pre_dispatch=self.pre_dispatch, error_score=self.error_score, @@ -442,8 +737,10 @@ def fit(self, X, y, *, sample_weight=None, groups=None): warning_msg = f"Warning: {e} for estimator {estimator} and param_grid {param_grid}" warnings.warn(warning_msg, category=UserWarning) if not hasattr(temp_search, 'cv_results_') and not param_grid_is_empty(param_grid=param_grid): - # This warning catches a problem after fit has run with no exception, however if there is no cv_results_ this indicates a failed fit operation. - warning_msg = f"Warning: estimator {estimator} and param_grid {param_grid} failed has no attribute cv_results_." + # This warning catches a problem after fit has run with no exception, + # however if there is no cv_results_ this indicates a failed fit operation. + warning_msg = (f"Warning: estimator {estimator} and param_grid {param_grid} " + "failed, has no attribute cv_results_.") warnings.warn(warning_msg, category=FitFailedWarning) try: self.best_ind_ = np.argmax([search.best_score_ for search in self._search_list]) @@ -453,8 +750,8 @@ def fit(self, X, y, *, sample_weight=None, groups=None): self.best_estimator_ = self._search_list[self.best_ind_].best_estimator_ self.best_score_ = self._search_list[self.best_ind_].best_score_ self.best_params_ = self._search_list[self.best_ind_].best_params_ - print( - f'Best estimator {self.best_estimator_} and best score {self.best_score_} and best params {self.best_params_}') + print(f'Best estimator {self.best_estimator_} and best score {self.best_score_} ' + f'and best params {self.best_params_}') return self def scaler_transform(self, X): @@ -496,20 +793,14 @@ class GridSearchCVList(BaseEstimator): of parameter settings. """ - def __init__(self, estimator_list=['linear', 'forest'], param_grid_list='auto', scoring=None, + def __init__(self, estimator_list, param_grid_list, scoring=None, n_jobs=None, refit=True, cv=None, verbose=0, pre_dispatch='2*n_jobs', - error_score=np.nan, return_train_score=False, is_discrete=False): - # 'discrete' if is_discrete else 'continuous' - self.estimator_list = get_complete_estimator_list(estimator_list, is_discrete, ) - if param_grid_list == 'auto': - self.param_grid_list = auto_hyperparameters(estimator_list=self.estimator_list, is_discrete=is_discrete) - elif (param_grid_list == None): - self.param_grid_list = len(self.estimator_list) * [{}] - else: - self.param_grid_list = param_grid_list + error_score=np.nan, return_train_score=False): + self.estimator_list = estimator_list + self.param_grid_list = param_grid_list self.scoring = scoring self.n_jobs = n_jobs - # self.refit = refit + self.refit = refit self.cv = cv self.verbose = verbose self.pre_dispatch = pre_dispatch @@ -519,7 +810,7 @@ def __init__(self, estimator_list=['linear', 'forest'], param_grid_list='auto', def fit(self, X, y=None, **fit_params): self._gcv_list = [GridSearchCV(estimator, param_grid, scoring=self.scoring, - n_jobs=self.n_jobs, cv=self.cv, verbose=self.verbose, + n_jobs=self.n_jobs, refit=self.refit, cv=self.cv, verbose=self.verbose, pre_dispatch=self.pre_dispatch, error_score=self.error_score, return_train_score=self.return_train_score) for estimator, param_grid in zip(self.estimator_list, self.param_grid_list)] @@ -529,9 +820,6 @@ def fit(self, X, y=None, **fit_params): self.best_params_ = self._gcv_list[self.best_ind_].best_params_ return self - def best_model(self): - return self.best_estimator_ - def predict(self, X): return self.best_estimator_.predict(X) @@ -539,7 +827,7 @@ def predict_proba(self, X): return self.best_estimator_.predict_proba(X) -def _cross_val_predict(estimator, X, y=None, *, groups=None, cv=3, +def _cross_val_predict(estimator, X, y=None, *, groups=None, cv=None, n_jobs=None, verbose=0, fit_params=None, pre_dispatch='2*n_jobs', method='predict', safe=True): """This is a fork from :meth:`~sklearn.model_selection.cross_val_predict` to allow for diff --git a/econml/sklearn_extensions/model_selection_utils.py b/econml/sklearn_extensions/model_selection_utils.py index 477731600..ab3f567d8 100644 --- a/econml/sklearn_extensions/model_selection_utils.py +++ b/econml/sklearn_extensions/model_selection_utils.py @@ -1,5 +1,4 @@ -import pdb import warnings from sklearn.exceptions import NotFittedError import numpy as np @@ -104,7 +103,8 @@ def select_estimator(estimator_type, is_discrete, random_state): Parameters ---------- - estimator_type (str): The type of estimator to use, one of: 'linear', 'forest', 'gbf', 'nnet', 'poly', 'automl', 'all'. + estimator_type (str): The type of estimator to use, one of: 'linear', 'forest', + 'gbf', 'nnet', 'poly', 'automl', 'all'. is_discrete (bool): The type of target variable, if true then it's discrete. TODO Add Random State for parameter Returns @@ -156,7 +156,8 @@ def check_list_type(lst): bool: True if the list only contains valid objects, False otherwise. Raises: - TypeError: If the list contains objects other than strings, sklearn model objects, or sklearn model selection objects. + TypeError: If the list contains objects other than strings, sklearn model objects, + or sklearn model selection objects. Examples: >>> check_list_type(['linear', RandomForestRegressor(), KFold()]) @@ -167,13 +168,12 @@ def check_list_type(lst): if len(lst) == 0: raise ValueError("Estimator list is empty. Please add some models or use some of the defaults provided.") - # pdb.set_trace() for element in lst: if (not isinstance(element, (str, BaseCrossValidator))): if not is_likely_estimator(element): - # pdb.set_trace() raise TypeError( - f"The list must contain only strings, sklearn model objects, and sklearn model selection objects. Invalid element: {element}") + "The list must contain only strings, sklearn model objects, and sklearn model selection objects. " + f"Invalid element: {element}") return True @@ -183,7 +183,8 @@ def get_complete_estimator_list(estimator_list, is_discrete, random_state): Parameters ---------- - estimator_list : List of estimators; can be sklearn object or str: 'linear', 'forest', 'gbf', 'nnet', 'poly', 'auto', 'all'. + estimator_list : List of estimators; can be sklearn object or str: 'linear', 'forest', 'gbf', + 'nnet', 'poly', 'auto', 'all'. is_discrete (bool): if target type is discrete or continuous. Returns @@ -194,7 +195,6 @@ def get_complete_estimator_list(estimator_list, is_discrete, random_state): ValueError: If the estimator is not supported. ''' - # pdb.set_trace() if isinstance(estimator_list, str): if 'all' == estimator_list: estimator_list = ['linear', 'forest', 'gbf', 'nnet', 'poly'] @@ -204,7 +204,8 @@ def get_complete_estimator_list(estimator_list, is_discrete, random_state): estimator_list = [estimator_list] else: raise ValueError( - "Invalid estimator_list value. Please provide a valid value from the list of available estimators: ['linear', 'forest', 'gbf', 'nnet', 'poly', 'automl']") + "Invalid estimator_list value. Please provide a valid value from the list of available estimators: " + "['linear', 'forest', 'gbf', 'nnet', 'poly', 'automl']") elif isinstance(estimator_list, list): if 'auto' in estimator_list: for estimator in ['linear']: @@ -236,11 +237,10 @@ def get_complete_estimator_list(estimator_list, is_discrete, random_state): temp_est_list = flatten_list(temp_est_list) # Check that all types of models are matched towards the problem. - # pdb.set_trace() for estimator in temp_est_list: if (isinstance(estimator, BaseEstimator)): if not is_regressor_or_classifier(estimator, is_discrete=is_discrete): - raise TypeError("Invalid estimator type: {} - must be a regressor or classifier".format(type(estimator))) + raise TypeError(f"Invalid estimator type: {type(estimator)} - must be a regressor or classifier") return temp_est_list @@ -292,7 +292,9 @@ def select_classification_hyperparameters(estimator): 'linear__solver': ['saga', 'lbfgs'] } else: - warnings.warn("No hyperparameters for this type of model. There are default hyperparameters for LogisticRegressionCV, RandomForestClassifier, MLPClassifier, and the polynomial pipleine", category=UserWarning) + warnings.warn("No hyperparameters for this type of model. There are default hyperparameters for " + "LogisticRegressionCV, RandomForestClassifier, MLPClassifier, and the polynomial pipleine", + category=UserWarning) return {} # raise ValueError("Invalid model type. Valid values are 'linear', 'forest', 'nnet', and 'poly'.") @@ -340,7 +342,9 @@ def select_regression_hyperparameters(estimator): 'poly__degree': [2, 3, 4] } else: - warnings.warn("No hyperparameters for this type of model. There are default hyperparameters for ElasticNetCV, RandomForestRegressor, MLPRegressor, and the polynomial pipeline.", category=UserWarning) + warnings.warn("No hyperparameters for this type of model. There are default hyperparameters for " + "ElasticNetCV, RandomForestRegressor, MLPRegressor, and the polynomial pipeline.", + category=UserWarning) return {} @@ -490,7 +494,8 @@ def is_linear_model(estimator): """ Check if a model is a linear model. - This function checks if a model has 'fit_intercept' and 'coef_' attributes or if it is an instance of LogisticRegression, LinearSVC, or SVC. + This function checks if a model has 'fit_intercept' and 'coef_' attributes or if it is an instance of + LogisticRegression, LinearSVC, or SVC. Parameters ---------- @@ -521,7 +526,8 @@ def is_data_scaled(X): """ Check if input data is scaled. - This function checks if the input data is scaled by comparing its mean and standard deviation to 0 and 1 respectively. + This function checks if the input data is scaled by comparing its mean and standard deviation to + 0 and 1 respectively. Parameters ---------- @@ -754,7 +760,8 @@ def make_param_multi_task(estimator, param_grid): """ Convert the keys in a parameter grid to work with a multi-task model. - This function converts the keys in a parameter grid to work with a multi-task model by prepending 'estimator__' to each key. + This function converts the keys in a parameter grid to work with a multi-task model by prepending + 'estimator__' to each key. Parameters ---------- diff --git a/econml/tests/test_dml.py b/econml/tests/test_dml.py index afb445ccd..2f321c5f2 100644 --- a/econml/tests/test_dml.py +++ b/econml/tests/test_dml.py @@ -22,9 +22,7 @@ from sklearn.ensemble import RandomForestRegressor, RandomForestClassifier from sklearn.multioutput import MultiOutputRegressor from econml.grf import MultiOutputGRF -from econml.sklearn_extensions.model_selection import SearchEstimatorList from econml.tests.utilities import (GroupingModel, NestedModel) -import pdb try: import ray @@ -625,9 +623,9 @@ def test_access_to_internal_models(self): assert isinstance(est.featurizer_, Pipeline) assert isinstance(est.model_cate, WeightedLasso) for mdl in est.models_y[0]: - assert isinstance(mdl, SearchEstimatorList) + assert isinstance(mdl, WeightedLasso) for mdl in est.models_t[0]: - assert isinstance(mdl, SearchEstimatorList) + assert isinstance(mdl, LogisticRegression) np.testing.assert_array_equal(est.cate_feature_names(['A']), ['A', 'A^2']) np.testing.assert_array_equal(est.cate_feature_names(), ['X0', 'X0^2']) est = DML(model_y=WeightedLasso(), @@ -641,9 +639,9 @@ def test_access_to_internal_models(self): assert isinstance(est.featurizer_, FunctionTransformer) assert isinstance(est.model_cate, WeightedLasso) for mdl in est.models_y[0]: - assert isinstance(mdl, SearchEstimatorList) + assert isinstance(mdl, WeightedLasso) for mdl in est.models_t[0]: - assert isinstance(mdl, SearchEstimatorList) + assert isinstance(mdl, LogisticRegression) np.testing.assert_array_equal(est.cate_feature_names(['A']), ['A']) def test_forest_dml_perf(self): @@ -1131,7 +1129,6 @@ def _test_sparse(n_p, d_w, n_r): model_t=LinearRegression(fit_intercept=False), fit_cate_intercept=False) dml.fit(y, t, X=x, W=w) - # pdb.set_trace() np.testing.assert_allclose(a, dml.coef_.reshape(-1), atol=1e-1) eff = reshape(t * np.choose(np.tile(p, 2), a), (-1,)) np.testing.assert_allclose(eff, dml.effect(x, T0=0, T1=t), atol=1e-1) @@ -1239,8 +1236,8 @@ def test_groups(self): # test outer grouping # with 2 folds, we should get exactly 3 groups per split, each with 10 copies of the y or t value - est = LinearDML(model_y=GroupingModel(LinearRegression(), (3, 3), n_copies), - model_t=GroupingModel(LinearRegression(), (3, 3), n_copies)) + est = LinearDML(model_y=GroupingModel(LinearRegression(), 60, (3, 3), n_copies), + model_t=GroupingModel(LinearRegression(), 60, (3, 3), n_copies)) est.fit(y, t, groups=groups) # test nested grouping @@ -1248,17 +1245,10 @@ def test_groups(self): # with 2-fold outer and 2-fold inner grouping, and six total groups, # should get 1 or 2 groups per split - est = LinearDML(model_y=NestedModel(LassoCV(cv=2), (1, 2), n_copies), - model_t=NestedModel(LassoCV(cv=2), (1, 2), n_copies)) + est = LinearDML(model_y=NestedModel(LassoCV(cv=2), 60, (1, 2), n_copies), + model_t=NestedModel(LassoCV(cv=2), 60, (1, 2), n_copies)) est.fit(y, t, groups=groups) - # by default, we use 5 split cross-validation for our T and Y models - # but we don't have enough groups here to split both the outer and inner samples with grouping - # TODO: does this imply we should change some defaults to make this more likely to succeed? - est = LinearDML(model_y=LassoCV(cv=5), model_t=LassoCV(cv=5)) - with pytest.raises(Exception): - est.fit(y, t, groups=groups) - def test_treatment_names(self): Y = np.random.normal(size=(100, 1)) T = np.random.binomial(n=1, p=0.5, size=(100, 1)) diff --git a/econml/tests/test_dmliv.py b/econml/tests/test_dmliv.py index f52c14356..16f8f55a9 100644 --- a/econml/tests/test_dmliv.py +++ b/econml/tests/test_dmliv.py @@ -207,7 +207,7 @@ def test_groups(self): projection=False, discrete_treatment=True, discrete_instrument=True, - model_y_xw=GroupingModel(LinearRegression(), ct_lims, n_copies), + model_y_xw=GroupingModel(LinearRegression(), n, ct_lims, n_copies), model_t_xw=LogisticRegression(), model_z_xw=LogisticRegression(), ), @@ -215,7 +215,7 @@ def test_groups(self): projection=True, discrete_treatment=True, discrete_instrument=True, - model_y_xw=GroupingModel(LinearRegression(), ct_lims, n_copies), + model_y_xw=GroupingModel(LinearRegression(), n, ct_lims, n_copies), model_t_xw=LogisticRegression(), model_t_xwz=LogisticRegression(), ), @@ -223,7 +223,7 @@ def test_groups(self): model_final=LinearRegression(fit_intercept=False), discrete_treatment=True, discrete_instrument=True, - model_y_xw=GroupingModel(LinearRegression(), ct_lims, n_copies), + model_y_xw=GroupingModel(LinearRegression(), n, ct_lims, n_copies), model_t_xw=LogisticRegression(), model_t_xwz=LogisticRegression(), ), @@ -231,7 +231,7 @@ def test_groups(self): model_final=RandomForestRegressor(), discrete_treatment=True, discrete_instrument=True, - model_y_xw=GroupingModel(LinearRegression(), ct_lims, n_copies), + model_y_xw=GroupingModel(LinearRegression(), n, ct_lims, n_copies), model_t_xw=LogisticRegression(), model_t_xwz=LogisticRegression(), ), diff --git a/econml/tests/test_driv.py b/econml/tests/test_driv.py index 39b90c1ed..38bb8421a 100644 --- a/econml/tests/test_driv.py +++ b/econml/tests/test_driv.py @@ -13,7 +13,7 @@ import pickle from scipy import special from sklearn.preprocessing import PolynomialFeatures -from sklearn.linear_model import LinearRegression, LogisticRegression +from sklearn.linear_model import LassoCV, LinearRegression, LogisticRegression import unittest try: @@ -281,7 +281,10 @@ def test_accuracy_without_ray(self): def test_fit_cov_directly(self): # fitting the covariance directly should be at least as good as computing the covariance from separate models - est = LinearDRIV() + + # set the models so that model selection over random forests doesn't take too much time in the repeated trials + est = LinearDRIV(model_y_xw=LassoCV(), model_t_xw=LassoCV(), model_z_xw=LassoCV(), + model_tz_xw=LassoCV()) n = 500 p = 10 @@ -334,8 +337,8 @@ def ceil(a, b): # ceiling analog of // DRIV( discrete_instrument=True, discrete_treatment=True, - model_y_xw=GroupingModel(LinearRegression(), ct_lims_2, n_copies), - model_z_xw=LinearRegression(), + model_y_xw=GroupingModel(LinearRegression(), n, ct_lims_2, n_copies), + model_z_xw=LogisticRegression(), model_t_xw=LogisticRegression(), model_tz_xw=LinearRegression(), model_t_xwz=LogisticRegression(), @@ -344,8 +347,8 @@ def ceil(a, b): # ceiling analog of // LinearDRIV( discrete_instrument=True, discrete_treatment=True, - model_y_xw=GroupingModel(LinearRegression(), ct_lims_2, n_copies), - model_z_xw=LinearRegression(), + model_y_xw=GroupingModel(LinearRegression(), n, ct_lims_2, n_copies), + model_z_xw=LogisticRegression(), model_t_xw=LogisticRegression(), model_tz_xw=LinearRegression(), model_t_xwz=LogisticRegression(), @@ -354,8 +357,8 @@ def ceil(a, b): # ceiling analog of // SparseLinearDRIV( discrete_instrument=True, discrete_treatment=True, - model_y_xw=GroupingModel(LinearRegression(), ct_lims_2, n_copies), - model_z_xw=LinearRegression(), + model_y_xw=GroupingModel(LinearRegression(), n, ct_lims_2, n_copies), + model_z_xw=LogisticRegression(), model_t_xw=LogisticRegression(), model_tz_xw=LinearRegression(), model_t_xwz=LogisticRegression(), @@ -364,20 +367,20 @@ def ceil(a, b): # ceiling analog of // ForestDRIV( discrete_instrument=True, discrete_treatment=True, - model_y_xw=GroupingModel(LinearRegression(), ct_lims_2, n_copies), - model_z_xw=LinearRegression(), + model_y_xw=GroupingModel(LinearRegression(), n, ct_lims_2, n_copies), + model_z_xw=LogisticRegression(), model_t_xw=LogisticRegression(), model_tz_xw=LinearRegression(), model_t_xwz=LogisticRegression(), prel_cate_approach='dmliv' ), IntentToTreatDRIV( - model_y_xw=GroupingModel(LinearRegression(), ct_lims_3, n_copies), + model_y_xw=GroupingModel(LinearRegression(), n, ct_lims_3, n_copies), model_t_xwz=LogisticRegression(), prel_cate_approach='dmliv' ), LinearIntentToTreatDRIV( - model_y_xw=GroupingModel(LinearRegression(), ct_lims_3, n_copies), + model_y_xw=GroupingModel(LinearRegression(), n, ct_lims_3, n_copies), model_t_xwz=LogisticRegression(), prel_cate_approach='dmliv' ) diff --git a/econml/tests/test_drlearner.py b/econml/tests/test_drlearner.py index f6a5e4ae8..3d3e982a9 100644 --- a/econml/tests/test_drlearner.py +++ b/econml/tests/test_drlearner.py @@ -828,26 +828,17 @@ def test_groups(self): # cross-fit generate one est = LinearDRLearner(model_propensity=LogisticRegression(), # with 2-fold grouping, we should get exactly 3 groups per split - model_regression=GroupingModel(LinearRegression(), (3, 3), n_copies), + model_regression=GroupingModel(LinearRegression(), 60, (3, 3), n_copies), cv=StratifiedGroupKFold(2)) est.fit(y, t, W=w, groups=groups) # test nested grouping est = LinearDRLearner(model_propensity=LogisticRegression(), # with 2-fold outer and 2-fold inner grouping, we should get 1-2 groups per split - model_regression=NestedModel(LassoCV(cv=2), (1, 2), n_copies), + model_regression=NestedModel(LassoCV(cv=2), 60, (1, 2), n_copies), cv=StratifiedGroupKFold(2)) est.fit(y, t, W=w, groups=groups) - # by default, we use 5 split cross-validation for our T and Y models - # but we don't have enough groups here to split both the outer and inner samples with grouping - # TODO: does this imply we should change some defaults to make this more likely to succeed? - est = LinearDRLearner(model_propensity=LogisticRegressionCV(cv=5), - model_regression=LassoCV(cv=5), - cv=StratifiedGroupKFold(2)) - with pytest.raises(Exception): - est.fit(y, t, W=w, groups=groups) - def test_score(self): """Test that scores are the same no matter whether the prediction of cate model has the same shape of input or the shape of input.reshape(-1,1).""" diff --git a/econml/tests/test_missing_values.py b/econml/tests/test_missing_values.py index 66d917f76..eb1c4f7e4 100644 --- a/econml/tests/test_missing_values.py +++ b/econml/tests/test_missing_values.py @@ -27,7 +27,7 @@ def __init__(self, model_t, model_y): self._model_t = model_t self._model_y = model_y - def fit(self, Y, T, W=None): + def train(self, is_selecting, Y, T, W=None): self._model_t.fit(W, T) self._model_y.fit(W, Y) return self diff --git a/econml/tests/test_ortho_learner.py b/econml/tests/test_ortho_learner.py index 66c389ae0..b22a9dbcc 100644 --- a/econml/tests/test_ortho_learner.py +++ b/econml/tests/test_ortho_learner.py @@ -29,7 +29,7 @@ class Wrapper: def __init__(self, model): self._model = model - def fit(self, X, y, Q, W=None): + def train(self, is_selecting, X, y, Q, W=None): self._model.fit(X, y) return self @@ -109,7 +109,7 @@ class Wrapper: def __init__(self, model): self._model = model - def fit(self, X, y, W=None): + def train(self, is_selecting, X, y, W=None): self._model.fit(X, y) return self @@ -219,7 +219,7 @@ def __init__(self, model_t, model_y): self._model_t = model_t self._model_y = model_y - def fit(self, Y, T, W=None): + def train(self, is_selecting, Y, T, W=None): self._model_t.fit(W, T) self._model_y.fit(W, Y) return self @@ -331,7 +331,7 @@ def __init__(self, model_t, model_y): self._model_t = model_t self._model_y = model_y - def fit(self, Y, T, W=None): + def train(self, is_selecting, Y, T, W=None): self._model_t.fit(W, T) self._model_y.fit(W, Y) return self @@ -378,7 +378,7 @@ def __init__(self, model_t, model_y): self._model_t = model_t self._model_y = model_y - def fit(self, Y, T, W=None): + def train(self, is_selecting, Y, T, W=None): self._model_t.fit(W, T) self._model_y.fit(W, Y) return self @@ -434,7 +434,7 @@ def __init__(self, model_t, model_y): self._model_t = model_t self._model_y = model_y - def fit(self, Y, T, W=None): + def train(self, is_selecting, Y, T, W=None): self._model_t.fit(W, np.matmul(T, np.arange(1, T.shape[1] + 1))) self._model_y.fit(W, Y) return self diff --git a/econml/tests/test_refit.py b/econml/tests/test_refit.py index 9cf93334c..00cc81dff 100644 --- a/econml/tests/test_refit.py +++ b/econml/tests/test_refit.py @@ -188,9 +188,9 @@ def test_orthoiv(self): est.model_t_xw = ElasticNet() est.model_z_xw = WeightedLasso() est.fit(y, T, Z=Z, W=W, cache_values=True) - assert isinstance(est.models_nuisance_[0][0]._model_y_xw._model, Lasso) - assert isinstance(est.models_nuisance_[0][0]._model_t_xw._model, ElasticNet) - assert isinstance(est.models_nuisance_[0][0]._model_z_xw._model, WeightedLasso) + assert isinstance(est.models_y_xw[0][0], Lasso) + assert isinstance(est.models_t_xw[0][0], ElasticNet) + assert isinstance(est.models_z_xw[0][0], WeightedLasso) est = DMLIV(model_y_xw=LinearRegression(), model_t_xw=LinearRegression(), @@ -202,9 +202,9 @@ def test_orthoiv(self): est.model_t_xw = ElasticNet() est.model_t_xwz = WeightedLasso() est.fit(y, T, Z=Z, X=X, W=W, cache_values=True) - assert isinstance(est.models_nuisance_[0][0]._model_y_xw._model, Lasso) - assert isinstance(est.models_nuisance_[0][0]._model_t_xw._model, ElasticNet) - assert isinstance(est.models_nuisance_[0][0]._model_t_xwz._model, WeightedLasso) + assert isinstance(est.models_y_xw[0][0], Lasso) + assert isinstance(est.models_t_xw[0][0], ElasticNet) + assert isinstance(est.models_t_xwz[0][0], WeightedLasso) est = NonParamDMLIV(model_y_xw=LinearRegression(), model_t_xw=LinearRegression(), diff --git a/econml/tests/utilities.py b/econml/tests/utilities.py index 4c04cc89d..1c11be343 100644 --- a/econml/tests/utilities.py +++ b/econml/tests/utilities.py @@ -16,15 +16,17 @@ class GroupingModel: and the number of copies of each y value should be equal to the group size """ - def __init__(self, model, limits, n_copies): + def __init__(self, model, total, limits, n_copies): self.model = model + self.total = total self.limits = limits self.n_copies = n_copies - def validate(self, y): + def validate(self, y, skip_group_counts=False): (yvals, cts) = np.unique(y, return_counts=True) (llim, ulim) = self.limits - if not (llim <= len(yvals) <= ulim): + # if we aren't fitting on the whole dataset, ensure that the limits are respected + if (not skip_group_counts) and (not (llim <= len(yvals) <= ulim)): raise Exception(f"Grouping failed: received {len(yvals)} groups instead of {llim}-{ulim}") # ensure that the grouping has worked correctly and we get exactly the number of copies @@ -35,7 +37,7 @@ def validate(self, y): f"Grouping failed; received {ct} copies of {yval} instead of {self.n_copies[yval]}") def fit(self, X, y): - self.validate(y) + self.validate(y, len(y) == self.total) self.model.fit(X, y) return self @@ -46,12 +48,9 @@ def predict(self, X): class NestedModel(GroupingModel): """ Class for testing nested grouping. The wrapped model must have a 'cv' attribute; - this class exposes an identical 'cv' attribute, which is how nested CV is implemented in fit_with_groups + this class exposes an identical 'cv' attribute, which is how nested CV is implemented in _fit_with_groups """ - def __init__(self, model, limits, n_copies): - super().__init__(model, limits, n_copies) - # DML nested CV works via a 'cv' attribute @property def cv(self): @@ -64,6 +63,6 @@ def cv(self, value): def fit(self, X, y): for (train, test) in check_cv(self.cv, y).split(X, y): # want to validate the nested grouping, not the outer grouping in the nesting tests - self.validate(y[train]) + self.validate(y[train], len(y) == self.total) self.model.fit(X, y) return self diff --git a/econml/utilities.py b/econml/utilities.py index 008bfc244..f62ffbb4d 100644 --- a/econml/utilities.py +++ b/econml/utilities.py @@ -21,7 +21,6 @@ from sklearn.preprocessing import PolynomialFeatures import warnings from warnings import warn -from sklearn.model_selection import KFold, StratifiedKFold, GroupKFold from collections.abc import Iterable from sklearn.utils.multiclass import type_of_target import numbers @@ -30,7 +29,6 @@ from statsmodels.compat.python import lmap import copy from inspect import signature -from econml.sklearn_extensions.model_selection import SearchEstimatorList MAX_RAND_SEED = np.iinfo(np.int32).max @@ -920,78 +918,6 @@ def filter_inds(coords, data, n): [arrs[indMap[c][0][0]].shape[indMap[c][0][1]] for c in outputs]) -def fit_with_groups(model, X, y, groups=None, **kwargs): - """ - Fit a model while correctly handling grouping if necessary. - - This enables us to perform an inner-loop cross-validation of a model - which handles grouping correctly, which is not easy using typical sklearn models. - - For example, GridSearchCV and RandomSearchCV both support passing 'groups' to fit, - but other CV-related estimators (such as those derived from LinearModelCV, including LassoCV), - do not support passing groups to fit which meanst that GroupKFold cannot be used as the cv instance - when using these types, because the required 'groups' argument will never be passed to the - GroupKFold's split method. See also https://github.com/scikit-learn/scikit-learn/issues/12052 - - The (hacky) workaround that is used here is to explicitly set the 'cv' attribute (if there is one) to - the exact set of rows and not to use GroupKFold even with the sklearn classes that could support it; - this should work with classes derived from BaseSearchCV, LinearModelCV, and CalibratedClassifierCV. - - Parameters - ---------- - model : estimator - The model to fit - X : array_like - The features to fit against - y : array_like - The target to fit against - groups : array_like, optional - The set of groupings that should be kept together when splitting rows for - cross-validation - kwargs : dict - Any other named arguments to pass to the model's fit - """ - # import pdb - # pdb.set_trace() - if groups is not None: - if isinstance(model, SearchEstimatorList): - # SearchEstimatorList must be handled different. Each estimator must be changed for CV else the functionality isn't the same - # It does have a CV but it does not work if you just change the CV of the SearchEstimatorList - for estimator in model.complete_estimator_list: - if hasattr(estimator, 'cv'): - old_cv = estimator.cv - cv = 5 if old_cv is None else old_cv - if isinstance(cv, numbers.Integral): - cv = GroupKFold(cv) - splits = list(cv.split(X, y, groups=groups)) - try: - estimator.cv = splits - except: - estimator.cv = old_cv - # assume that we should perform nested cross-validation if and only if - # the model has a 'cv' attribute; this is a somewhat brittle assumption... - elif hasattr(model, 'cv'): - old_cv = model.cv - # logic copied from check_cv - cv = 5 if old_cv is None else old_cv - if isinstance(cv, numbers.Integral): - cv = GroupKFold(cv) - # otherwise we will assume the user already set the cv attribute to something - # compatible with splitting with a 'groups' argument - - # now we have to compute the folds explicitly because some classifiers (like LassoCV) - # don't use the groups when calling split internally - splits = list(cv.split(X, y, groups=groups)) - try: - print(splits) - model.cv = splits - return model.fit(X, y, **kwargs) - finally: - model.cv = old_cv - - return model.fit(X, y, **kwargs) - - def filter_none_kwargs(**kwargs): """ Filters out any keyword arguments that are None. diff --git a/notebooks/SearchEstimatorList functionality.ipynb b/notebooks/SearchEstimatorList functionality.ipynb deleted file mode 100644 index 4464199de..000000000 --- a/notebooks/SearchEstimatorList functionality.ipynb +++ /dev/null @@ -1,1031 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "# Import necessary packages\n", - "from sklearn.model_selection import train_test_split\n", - "from sklearn.metrics import mean_squared_error, accuracy_score\n", - "from sklearn.datasets import load_iris\n", - "from econml.sklearn_extensions.model_selection import SearchEstimatorList\n", - "import warnings\n", - "import numpy as np\n", - "from econml.dml import LinearDML, CausalForestDML\n", - "from econml.cate_interpreter import SingleTreeCateInterpreter, SingleTreePolicyInterpreter\n", - "import pandas as pd\n", - "from sklearn.preprocessing import PolynomialFeatures\n", - "import matplotlib.pyplot as plt\n", - "from sklearn.exceptions import ConvergenceWarning\n", - "\n", - "# Ignore the ConvergenceWarning\n", - "warnings.filterwarnings(\"ignore\", category=ConvergenceWarning)\n", - "\n", - "%matplotlib inline" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# SearchEstimatorList\n", - "\n", - "The SearchEstimatorList class is a custom Python class designed to streamline the process of training multiple machine learning models and tuning their hyperparameters. This class can be especially useful when you're unsure which model will perform best on your data and you want to compare several of them.\n", - "\n", - "# Key Features\n", - "\n", - " Multiple Model Training: The SearchEstimatorList class takes a list of Scikit-learn estimators (machine learning models) and trains each of them on your data.\n", - "\n", - " Hyperparameter Tuning: For each model, the class conducts a grid search over a provided range of hyperparameters. This allows you to automatically find the hyperparameters that result in the best model performance.\n", - "\n", - " Model Evaluation: The class retains the best performing model based on a specified scoring metric. This makes it easy to determine which model and hyperparameters are the most suitable for your data.\n", - "\n", - " Data Scaling: The SearchEstimatorList class also supports data scaling, which can be important for certain types of models.\n", - "\n", - " Handling of Different Target Types: This class handles both continuous and discrete target variables, making it suitable for both regression and classification tasks.\n", - "\n", - "# Usage\n", - "\n", - "To use the SearchEstimatorList class, you start by initializing an instance of the class with a list of models and their corresponding hyperparameter grids. Then, you call the fit method to train the models and conduct the grid search. After fitting, you can use the predict method to generate predictions for new data. The class also has methods to refit the best model using the entire dataset (refit) and to return the best model (best_model)." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Classifier" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "No scoring value was given. Using default score method f1_macro.\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Fitting 2 folds for each of 3 candidates, totalling 6 fits\n", - "[CV 1/2] END ...................n_estimators=50;, score=0.916 total time= 0.1s\n", - "[CV 2/2] END ...................n_estimators=50;, score=0.950 total time= 0.1s\n", - "[CV 1/2] END ..................n_estimators=100;, score=0.916 total time= 0.1s\n", - "[CV 2/2] END ..................n_estimators=100;, score=0.950 total time= 0.1s\n", - "[CV 1/2] END ..................n_estimators=150;, score=0.916 total time= 0.1s\n", - "[CV 2/2] END ..................n_estimators=150;, score=0.950 total time= 0.1s\n", - "Fitting 2 folds for each of 9 candidates, totalling 18 fits\n", - "[CV 1/2] END learning_rate=0.01, n_estimators=50;, score=0.900 total time= 0.0s\n", - "[CV 2/2] END learning_rate=0.01, n_estimators=50;, score=0.950 total time= 0.0s\n", - "[CV 1/2] END learning_rate=0.01, n_estimators=100;, score=0.900 total time= 0.0s\n", - "[CV 2/2] END learning_rate=0.01, n_estimators=100;, score=0.950 total time= 0.1s\n", - "[CV 1/2] END learning_rate=0.01, n_estimators=150;, score=0.900 total time= 0.1s\n", - "[CV 2/2] END learning_rate=0.01, n_estimators=150;, score=0.950 total time= 0.1s\n", - "[CV 1/2] END learning_rate=0.1, n_estimators=50;, score=0.900 total time= 0.0s\n", - "[CV 2/2] END learning_rate=0.1, n_estimators=50;, score=0.950 total time= 0.0s\n", - "[CV 1/2] END learning_rate=0.1, n_estimators=100;, score=0.900 total time= 0.1s\n", - "[CV 2/2] END learning_rate=0.1, n_estimators=100;, score=0.933 total time= 0.1s\n", - "[CV 1/2] END learning_rate=0.1, n_estimators=150;, score=0.900 total time= 0.1s\n", - "[CV 2/2] END learning_rate=0.1, n_estimators=150;, score=0.933 total time= 0.1s\n", - "[CV 1/2] END ..learning_rate=1, n_estimators=50;, score=0.900 total time= 0.0s\n", - "[CV 2/2] END ..learning_rate=1, n_estimators=50;, score=0.933 total time= 0.0s\n", - "[CV 1/2] END .learning_rate=1, n_estimators=100;, score=0.900 total time= 0.1s\n", - "[CV 2/2] END .learning_rate=1, n_estimators=100;, score=0.933 total time= 0.1s\n", - "[CV 1/2] END .learning_rate=1, n_estimators=150;, score=0.900 total time= 0.1s\n", - "[CV 2/2] END .learning_rate=1, n_estimators=150;, score=0.933 total time= 0.1s\n", - "Best estimator RandomForestClassifier(n_estimators=50) and best score 0.9330819977445048 and best params {'n_estimators': 50}\n", - "Accuracy: 1.0\n" - ] - } - ], - "source": [ - "# Load the Iris dataset for classification\n", - "iris = load_iris()\n", - "\n", - "# Split the dataset into training and test sets\n", - "X_train_cls, X_test_cls, y_train_cls, y_test_cls = train_test_split(\n", - " iris.data, iris.target, test_size=0.2, random_state=42\n", - ")\n", - "\n", - "# Define models and their parameter grids\n", - "estimator_list_cls = ['forest', 'gbf']\n", - "param_grid_list_cls = [{'n_estimators': [50, 100, 150]}, {'n_estimators': [50, 100, 150], 'learning_rate': [0.01, 0.1, 1]}]\n", - "\n", - "# Initialize SearchEstimatorList\n", - "sel_cls = SearchEstimatorList(\n", - " estimator_list=estimator_list_cls, \n", - " param_grid_list=param_grid_list_cls, \n", - " is_discrete=True,\n", - " verbose=3\n", - ")\n", - "\n", - "# Fit the model to the training data\n", - "sel_cls.fit(X_train_cls, y_train_cls)\n", - "\n", - "# Predict outcomes for the test set\n", - "predictions_cls = sel_cls.predict(X_test_cls)\n", - "\n", - "# Evaluate the model\n", - "acc = accuracy_score(y_test_cls, predictions_cls)\n", - "\n", - "# Print the evaluation metric\n", - "print(f\"Accuracy: {acc}\")\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Regressor" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Fitting 2 folds for each of 7 candidates, totalling 14 fits\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/anthonycampbell/Documents/EconML-CS696DS/econml/sklearn_extensions/model_selection.py:346: UserWarning: No scoring value was given. Using default score method neg_mean_squared_error.\n", - " warnings.warn(f\"No scoring value was given. Using default score method {self.scoring}.\")\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[CV 1/2] END .....................l1_ratio=0.1;, score=-0.584 total time= 0.0s\n", - "[CV 2/2] END .....................l1_ratio=0.1;, score=-0.725 total time= 0.0s\n", - "[CV 1/2] END .....................l1_ratio=0.5;, score=-0.549 total time= 0.0s\n", - "[CV 2/2] END .....................l1_ratio=0.5;, score=-0.675 total time= 0.0s\n", - "[CV 1/2] END .....................l1_ratio=0.7;, score=-0.546 total time= 0.0s\n", - "[CV 2/2] END .....................l1_ratio=0.7;, score=-0.668 total time= 0.0s\n", - "[CV 1/2] END .....................l1_ratio=0.9;, score=-0.544 total time= 0.0s\n", - "[CV 2/2] END .....................l1_ratio=0.9;, score=-0.663 total time= 0.0s\n", - "[CV 1/2] END ....................l1_ratio=0.95;, score=-0.544 total time= 0.0s\n", - "[CV 2/2] END ....................l1_ratio=0.95;, score=-0.662 total time= 0.0s\n", - "[CV 1/2] END ....................l1_ratio=0.99;, score=-0.544 total time= 0.0s\n", - "[CV 2/2] END ....................l1_ratio=0.99;, score=-0.661 total time= 0.0s\n", - "[CV 1/2] END .......................l1_ratio=1;, score=-0.544 total time= 0.0s\n", - "[CV 2/2] END .......................l1_ratio=1;, score=-0.661 total time= 0.0s\n", - "Fitting 2 folds for each of 3 candidates, totalling 6 fits\n", - "[CV 1/2] END ............hidden_layer_sizes=50;, score=-0.712 total time= 1.0s\n", - "[CV 2/2] END ............hidden_layer_sizes=50;, score=-0.580 total time= 1.3s\n", - "[CV 1/2] END ...........hidden_layer_sizes=100;, score=-0.695 total time= 0.8s\n", - "[CV 2/2] END ...........hidden_layer_sizes=100;, score=-2.334 total time= 1.0s\n", - "[CV 1/2] END ...........hidden_layer_sizes=200;, score=-0.641 total time= 8.1s\n", - "[CV 2/2] END ...........hidden_layer_sizes=200;, score=-1.162 total time= 5.4s\n", - "Best estimator ElasticNetCV(l1_ratio=1) and best score -0.6025662427788023 and best params {'l1_ratio': 1}\n", - "Mean Squared Error: 0.5555752649052167\n" - ] - } - ], - "source": [ - "# Import necessary packages\n", - "from sklearn.model_selection import train_test_split\n", - "from sklearn.metrics import mean_squared_error, accuracy_score\n", - "from sklearn.datasets import fetch_california_housing\n", - "from econml.sklearn_extensions.model_selection import SearchEstimatorList\n", - "\n", - "# Load the Boston Housing dataset for regression\n", - "california_housing = fetch_california_housing()\n", - "\n", - "# Split the dataset into training and test sets\n", - "X_train_reg, X_test_reg, y_train_reg, y_test_reg = train_test_split(\n", - " california_housing.data, california_housing.target, test_size=0.2, random_state=42\n", - ")\n", - "\n", - "# Define models and their parameter grids\n", - "# This will use ElasticNet because it's a Linear Model and a Neural Network Regressor\n", - "estimator_list_reg = ['linear', 'nnet']\n", - "param_grid_list_reg = [{'l1_ratio': [.1, .5, .7, .9, .95, .99, 1]}, {'hidden_layer_sizes': [50, 100, 200]}]\n", - "\n", - "# Initialize SearchEstimatorList\n", - "sel_reg = SearchEstimatorList(\n", - " estimator_list=estimator_list_reg, \n", - " param_grid_list=param_grid_list_reg,\n", - " is_discrete=False,\n", - " verbose=3\n", - ")\n", - "\n", - "# Fit the model to the training data\n", - "sel_reg.fit(X_train_reg, y_train_reg)\n", - "\n", - "# Predict outcomes for the test set\n", - "predictions_reg = sel_reg.predict(X_test_reg)\n", - "\n", - "# Evaluate the model\n", - "mse = mean_squared_error(y_test_reg, predictions_reg)\n", - "\n", - "# Print the evaluation metric\n", - "print(f\"Mean Squared Error: {mse}\")\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Using all estimators" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/anthonycampbell/Documents/EconML-CS696DS/econml/sklearn_extensions/model_selection.py:346: UserWarning: No scoring value was given. Using default score method f1_macro.\n", - " warnings.warn(f\"No scoring value was given. Using default score method {self.scoring}.\")\n" - ] - } - ], - "source": [ - "search = SearchEstimatorList(estimator_list = ['linear', 'forest', 'gbf', 'nnet', 'poly'], is_discrete=True)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Single Estimators and Model Objects" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Best estimator LogisticRegression(C=0.001, max_iter=50, penalty='none', solver='sag') and best score 0.966624895572264 and best params {'C': 0.001, 'max_iter': 50, 'penalty': 'none', 'solver': 'sag'}\n", - "LogisticRegression(C=0.001, max_iter=50, penalty='none', solver='sag')\n", - "{'C': 0.001, 'max_iter': 50, 'penalty': 'none', 'solver': 'sag'}\n", - "mse of test dataset: 0.0\n", - "[[7.30818687e-04 9.18278306e-01 8.09908750e-02]\n", - " [9.96517769e-01 3.48223146e-03 9.52705844e-13]\n", - " [8.11833119e-11 2.27064968e-04 9.99772935e-01]\n", - " [1.49082115e-03 8.82474441e-01 1.16034738e-01]\n", - " [6.61814371e-04 9.57060549e-01 4.22776371e-02]\n", - " [9.94291457e-01 5.70854348e-03 8.51181731e-12]\n", - " [3.09570872e-02 9.66175329e-01 2.86758338e-03]\n", - " [1.03620286e-04 2.72711857e-01 7.27184523e-01]\n", - " [1.86273814e-04 5.89659675e-01 4.10154051e-01]\n", - " [7.89829063e-03 9.84383361e-01 7.71834853e-03]\n", - " [1.79967697e-04 3.80342060e-01 6.19477972e-01]\n", - " [9.87625715e-01 1.23742845e-02 6.37903013e-11]\n", - " [9.97989545e-01 2.01045508e-03 2.71212460e-13]\n", - " [9.87073806e-01 1.29261936e-02 5.68033322e-11]\n", - " [9.97732149e-01 2.26785067e-03 1.43489213e-12]\n", - " [2.40047637e-03 9.42313621e-01 5.52859030e-02]\n", - " [1.40979957e-07 5.60447914e-03 9.94395380e-01]\n", - " [4.57991768e-03 9.78714479e-01 1.67056034e-02]\n", - " [1.07687184e-03 8.47974601e-01 1.50948527e-01]\n", - " [1.55738075e-07 5.44482660e-03 9.94555018e-01]\n", - " [9.84143440e-01 1.58565593e-02 2.21243624e-10]\n", - " [1.96353775e-04 3.77725182e-01 6.22078464e-01]\n", - " [9.90664487e-01 9.33551321e-03 6.98033897e-11]\n", - " [2.52736850e-07 8.46501225e-03 9.91534735e-01]\n", - " [1.95677109e-05 4.08891407e-01 5.91089025e-01]\n", - " [1.72461836e-05 8.83781623e-02 9.11604592e-01]\n", - " [1.09118029e-07 1.18285926e-02 9.88171298e-01]\n", - " [3.31801168e-07 1.03342423e-02 9.89665426e-01]\n", - " [9.86532115e-01 1.34678849e-02 1.68835118e-10]\n", - " [9.80493031e-01 1.95069688e-02 2.80655184e-10]]\n" - ] - } - ], - "source": [ - "with warnings.catch_warnings():\n", - " warnings.simplefilter(\"ignore\")\n", - "\n", - " from sklearn.linear_model import LogisticRegression\n", - " lr_param_grid = {\n", - " 'penalty': ['l1', 'l2', 'elasticnet', 'none'],\n", - " 'C': [0.001, 0.01, 0.1, 1, 10, 100],\n", - " 'solver': ['newton-cg', 'lbfgs', 'liblinear', 'sag', 'saga'],\n", - " 'max_iter': [50, 100, 200, 500],\n", - " }\n", - "\n", - " search = SearchEstimatorList(estimator_list = LogisticRegression(), param_grid_list= lr_param_grid, verbose=0, is_discrete=True)\n", - " search.fit(X_train_cls, y_train_cls)\n", - " print(search.best_model())\n", - " print(search.best_params_)\n", - " y_pred = search.predict(X_test_cls)\n", - "\n", - " mse = mean_squared_error(y_test_cls, y_pred)\n", - "\n", - "print(\"mse of test dataset:\", mse,)\n", - "print(search.predict_proba(X_test_cls))\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Polynomial Feature\n" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Fitting 2 folds for each of 9 candidates, totalling 18 fits\n", - "[CV 1/2] END linear__l1_ratio=0.1, poly__degree=2;, score=0.322 total time= 0.3s\n", - "[CV 2/2] END linear__l1_ratio=0.1, poly__degree=2;, score=0.287 total time= 0.2s\n", - "[CV 1/2] END linear__l1_ratio=0.1, poly__degree=3;, score=0.000 total time= 0.3s\n", - "[CV 2/2] END linear__l1_ratio=0.1, poly__degree=3;, score=0.014 total time= 0.3s\n", - "[CV 1/2] END linear__l1_ratio=0.1, poly__degree=4;, score=0.000 total time= 1.0s\n", - "[CV 2/2] END linear__l1_ratio=0.1, poly__degree=4;, score=-0.000 total time= 1.1s\n", - "[CV 1/2] END linear__l1_ratio=0.5, poly__degree=2;, score=0.322 total time= 0.3s\n", - "[CV 2/2] END linear__l1_ratio=0.5, poly__degree=2;, score=0.287 total time= 0.2s\n", - "[CV 1/2] END linear__l1_ratio=0.5, poly__degree=3;, score=0.000 total time= 0.3s\n", - "[CV 2/2] END linear__l1_ratio=0.5, poly__degree=3;, score=0.014 total time= 0.4s\n", - "[CV 1/2] END linear__l1_ratio=0.5, poly__degree=4;, score=0.000 total time= 1.5s\n", - "[CV 2/2] END linear__l1_ratio=0.5, poly__degree=4;, score=-0.000 total time= 1.3s\n", - "[CV 1/2] END linear__l1_ratio=0.9, poly__degree=2;, score=0.322 total time= 0.2s\n", - "[CV 2/2] END linear__l1_ratio=0.9, poly__degree=2;, score=0.287 total time= 0.2s\n", - "[CV 1/2] END linear__l1_ratio=0.9, poly__degree=3;, score=0.000 total time= 0.3s\n", - "[CV 2/2] END linear__l1_ratio=0.9, poly__degree=3;, score=0.014 total time= 0.4s\n", - "[CV 1/2] END linear__l1_ratio=0.9, poly__degree=4;, score=0.000 total time= 1.1s\n", - "[CV 2/2] END linear__l1_ratio=0.9, poly__degree=4;, score=-0.000 total time= 1.1s\n", - "Best estimator Pipeline(steps=[('poly', PolynomialFeatures()),\n", - " ('linear', ElasticNetCV(l1_ratio=0.9))]) and best score 0.30443941337924607 and best params {'linear__l1_ratio': 0.9, 'poly__degree': 2}\n", - "Mean Squared Error: 0.8894038237145269\n" - ] - } - ], - "source": [ - "with warnings.catch_warnings():\n", - " warnings.simplefilter(\"ignore\")\n", - " # For polynomial, please ensure that you have \"poly__\" (two \"_\" or underscores after poly) underneath to change degree\n", - " # To change the linear method please add \"linear__\" (two \"_\" or underscores after linear)\n", - " param_grid_list_poly = {'poly__degree': [2, 3, 4], 'linear__l1_ratio': [0.1, 0.5, 0.9]}\n", - " sel_reg = SearchEstimatorList(\n", - " estimator_list='poly', \n", - " param_grid_list=param_grid_list_poly,\n", - " is_discrete=False,\n", - " scoring='explained_variance',\n", - " verbose=3\n", - " )\n", - "\n", - " # Fit the model to the training data\n", - " sel_reg.fit(X_train_reg, y_train_reg)\n", - "\n", - " # Predict outcomes for the test set\n", - " predictions_reg = sel_reg.predict(X_test_reg)\n", - "\n", - " # Evaluate the model\n", - " mse = mean_squared_error(y_test_reg, predictions_reg)\n", - "\n", - " # Print the evaluation metric\n", - " print(f\"Mean Squared Error: {mse}\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "['linear', 'forest', 'gbf', 'nnet', 'poly']\n" - ] - } - ], - "source": [ - "# These are all of the supported models that we have that have built in hyper parameters already included\n", - "print(sel_reg.supported_models)" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n", - "[CV 1/2] END .................................., score=-0.518 total time= 0.1s\n", - "[CV 2/2] END .................................., score=-0.552 total time= 0.0s\n", - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n", - "[CV 1/2] END .................................., score=-0.287 total time= 1.3s\n", - "[CV 2/2] END .................................., score=-0.293 total time= 1.3s\n", - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n", - "[CV 1/2] END .................................., score=-0.286 total time= 3.1s\n", - "[CV 2/2] END .................................., score=-0.274 total time= 3.1s\n", - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n", - "[CV 1/2] END .................................., score=-0.305 total time= 3.2s\n", - "[CV 2/2] END .................................., score=-0.305 total time= 3.0s\n", - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n", - "[CV 1/2] END .................................., score=-0.526 total time= 0.6s\n", - "[CV 2/2] END ................................., score=-12.077 total time= 0.5s\n", - "Best estimator RandomForestRegressor() and best score -0.27976201134927425 and best params {}\n", - "Mean Squared Error: 0.2508316133481009\n" - ] - } - ], - "source": [ - "# To try every type of model simply use the \"all\" option\n", - "with warnings.catch_warnings():\n", - " warnings.simplefilter(\"ignore\")\n", - " sel_reg = SearchEstimatorList(\n", - " estimator_list='all', \n", - " param_grid_list=None,\n", - " is_discrete=False,\n", - " scaling=True,\n", - " verbose=5\n", - " )\n", - "\n", - " # Fit the model to the training data\n", - " sel_reg.fit(X_train_reg, y_train_reg)\n", - "\n", - " # Predict outcomes for the test set\n", - " predictions_reg = sel_reg.predict(X_test_reg)\n", - "\n", - " # Evaluate the model\n", - " mse = mean_squared_error(y_test_reg, predictions_reg)\n", - "\n", - " # Print the evaluation metric\n", - " print(f\"Mean Squared Error: {mse}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Scoring functions\n", - "\n", - "Using a custom scoring function. See https://scikit-learn.org/stable/modules/model_evaluation.html for how to make your own scoring metric\n" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n", - "[CV 1/2] END .................................., score=-0.741 total time= 0.0s\n", - "[CV 2/2] END .................................., score=-0.822 total time= 0.0s\n", - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n", - "[CV 1/2] END .................................., score=-2.404 total time= 0.8s\n", - "[CV 2/2] END .................................., score=-1.671 total time= 0.8s\n", - "Best estimator ElasticNetCV() and best score -0.7813657065847333 and best params {}\n", - "Root Mean Squared Error: 0.7490149943228499\n" - ] - } - ], - "source": [ - "import numpy as np\n", - "from sklearn.metrics import mean_squared_error\n", - "from sklearn.metrics import make_scorer\n", - "\n", - "def root_mean_squared_error(y_true, y_pred):\n", - " mse = mean_squared_error(y_true, y_pred)\n", - " rmse = np.sqrt(mse)\n", - " return rmse\n", - "loss_function = make_scorer(root_mean_squared_error, greater_is_better=False)\n", - "\n", - "sel_reg = SearchEstimatorList(\n", - " estimator_list=estimator_list_reg, \n", - " param_grid_list=None,\n", - " is_discrete=False,\n", - " scoring=loss_function,\n", - " verbose=3\n", - ")\n", - "\n", - "# Fit the model to the training data\n", - "sel_reg.fit(X_train_reg, y_train_reg)\n", - "\n", - "# Predict outcomes for the test set\n", - "predictions_reg = sel_reg.predict(X_test_reg)\n", - "\n", - "# Evaluate the model\n", - "rmse = root_mean_squared_error(y_test_reg, predictions_reg)\n", - "\n", - "# Print the evaluation metric\n", - "print(f\"Root Mean Squared Error: {rmse}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# What this means for EconML?\n", - "\n", - "By integrating the SearchEstimatorList into econml, we can gain a number of benefits in these categories:\n", - "\n", - " Model Selection: econml contains many different models, each with its own assumptions and use cases. By using SearchEstimatorList, you can more easily compare the performance of different models on your data and select the best one.\n", - "\n", - " Hyperparameter Tuning: Many of the models in econml have hyperparameters that need to be tuned for optimal performance. SearchEstimatorList can automate this process by performing a grid search over specified hyperparameters for each model.\n", - "\n", - " Efficiency: Instead of having to manually train each model and tune its hyperparameters, SearchEstimatorList can do this all at once. This can save a significant amount of time and make the model building process more efficient.\n", - "\n", - "See the example below with data taken fromt he Customer Segmentation at an Online Media Company Notebook" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "No scoring value was given. Using default score method neg_mean_squared_error.\n", - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().\n", - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "*** Causal Estimate ***\n", - "\n", - "## Identified estimand\n", - "Estimand type: nonparametric-ate\n", - "\n", - "### Estimand : 1\n", - "Estimand name: backdoor\n", - "Estimand expression:\n", - " d \n", - "────────────(E[log_demand|income,friends_count,days_⟨visited,⟩_hours,age,songs\n", - "d[log_price] \n", - "\n", - " \n", - "_purchased,has_membership,is_US,account_age])\n", - " \n", - "Estimand assumption 1, Unconfoundedness: If U→{log_price} and U→log_demand then P(log_demand|log_price,income,friends_count,days_visited,avg_hours,age,songs_purchased,has_membership,is_US,account_age,U) = P(log_demand|log_price,income,friends_count,days_visited,avg_hours,age,songs_purchased,has_membership,is_US,account_age)\n", - "\n", - "## Realized estimand\n", - "b: log_demand~log_price+income+friends_count+days_visited+avg_hours+age+songs_purchased+has_membership+is_US+account_age | income\n", - "Target units: ate\n", - "\n", - "## Estimate\n", - "Mean value: 2.6518132830256684\n", - "Effect estimates: [ 2.57968831 -0.23224908 4.35502223 ... 0.85234463 -3.53167996\n", - " 6.99294565]\n", - "\n" - ] - } - ], - "source": [ - "# Import the sample pricing data\n", - "file_url = \"https://msalicedatapublic.z5.web.core.windows.net/datasets/Pricing/pricing_sample.csv\"\n", - "train_data = pd.read_csv(file_url)\n", - "\n", - "# Data sample\n", - "train_data.head()\n", - "\n", - "# Define estimator inputs\n", - "train_data[\"log_demand\"] = np.log(train_data[\"demand\"])\n", - "train_data[\"log_price\"] = np.log(train_data[\"price\"])\n", - "\n", - "Y = train_data[\"log_demand\"].values\n", - "T = train_data[\"log_price\"].values\n", - "X = train_data[[\"income\"]].values # features\n", - "confounder_names = [\"account_age\", \"age\", \"avg_hours\", \"days_visited\", \"friends_count\", \"has_membership\", \"is_US\", \"songs_purchased\"]\n", - "W = train_data[confounder_names].values\n", - "\n", - "# Get test data\n", - "X_test = np.linspace(0, 5, 100).reshape(-1, 1)\n", - "X_test_data = pd.DataFrame(X_test, columns=[\"income\"])\n", - "\n", - "# initiate an EconML cate estimator\n", - "est = LinearDML(model_y='gbf', model_t='gbf',\n", - " featurizer=PolynomialFeatures(degree=2, include_bias=False))\n", - "\n", - "# fit through dowhy\n", - "est_dw = est.dowhy.fit(Y, T, X=X, W=W, outcome_names=[\"log_demand\"], treatment_names=[\"log_price\"], feature_names=[\"income\"],\n", - " confounder_names=confounder_names, inference=\"statsmodels\")\n", - "\n", - "lineardml_estimate = est_dw.estimate_\n", - "print(lineardml_estimate)\n" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA1UAAAIjCAYAAADr8zGuAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAADRlUlEQVR4nOzddXhcVfrA8e8dl7gnjTR1b6ECpbSl1ChFl2LLogsrOKXY4m6Lww9ZbHdhF4dFC7RIhVK07pqkbVwmGZf7+2OSSdMkJUmTzEzyfp5nnszcOZl5ZzK5c997znmPoqqqihBCCCGEEEKIDtGEOwAhhBBCCCGEiGaSVAkhhBBCCCHEIZCkSgghhBBCCCEOgSRVQgghhBBCCHEIJKkSQgghhBBCiEMgSZUQQgghhBBCHAJJqoQQQgghhBDiEEhSJYQQQgghhBCHQJIqIYQQQgghhDgEklQJIYRol127dqEoCq+++mq4Q2lR3759ueCCC6LieSP9vRRCCNE2klQJIQSwdu1a5s2bR15eHiaTiT59+jBz5kyeeuqpcId2yAKBAP/617844ogjSEpKIjY2lkGDBnHeeefx/fffhzu8Nvnmm29QFKXVyxtvvNEtcXz33XfccccdVFdXd9lzfPrpp9xxxx1d9vhdRVEULr/88nCHIYQQYaELdwBCCBFu3333HdOmTSM3N5dLLrmEjIwMCgsL+f7773niiSe44oorwh3iIbnyyit55plnOPnkkznnnHPQ6XRs3ryZzz77jH79+nHkkUeGO8Q2u/LKKxk/fnyz7RMnTuyW5//uu++48847ueCCC0hISGhy3+bNm9Fo2neuMi8vD6fTiV6vD2379NNPeeaZZ6IysRJCiN5KkiohRK937733Eh8fz48//tjsQLm0tDQ8QXWSkpIS/u///o9LLrmEF154ocl9jz/+OGVlZWGKrGMmT57MvHnzwh1Gi4xGY7t/R1EUTCZTF0QjhBCiO8nwPyFEr7d9+3aGDx/eLKECSEtLa3Lb5/Nx9913079/f4xGI3379uVvf/sbbre7Sbu+fftywgknsGzZMiZMmIDJZKJfv37861//avYca9asYerUqZjNZrKzs7nnnnt45ZVXUBSFXbt2hdr99NNPzJ49m5SUFMxmM/n5+Vx00UUHfW07d+5EVVUmTZrU7D5FUZq8vsrKShYsWMDIkSOJiYkhLi6OOXPmsHr16oM+R4NNmzYxb948kpKSMJlMjBs3jg8//LBJG6/Xy5133snAgQMxmUwkJydz9NFH8+WXX7bpOTqiPa/rqaeeYvjw4VgsFhITExk3bhz/+c9/ALjjjju47rrrAMjPzw8NPWz4G7U0p6q6upprrrmGvn37YjQayc7O5rzzzqO8vBxoPqfqggsu4JlnngFoMrxRVVX69u3LySef3Cxml8tFfHw8f/7zn1t9D0aMGMG0adOabQ8EAvTp06dJovrGG28wduxYYmNjiYuLY+TIkTzxxBOtPnZrGoZsvvXWW9x7771kZ2djMpmYPn0627Zta9Z+5cqVHH/88SQmJmK1Whk1alSz5/3qq6+YPHkyVquVhIQETj75ZDZu3NikzR133IGiKGzZsoU//OEPxMfHk5qayq233oqqqhQWFnLyyScTFxdHRkYGjzzySLNY3G43t99+OwMGDMBoNJKTk8P111/f7P9cCCEaSE+VEKLXy8vLY8WKFaxbt44RI0YctO3FF1/MP//5T+bNm8e1117LypUruf/++9m4cSPvv/9+k7bbtm1j3rx5/PGPf+T888/n5Zdf5oILLmDs2LEMHz4cgD179jBt2jQUReGmm27CarXy4osvNuv1KC0tZdasWaSmpnLjjTeSkJDArl27eO+9937ztQG8/fbbnH766Vgsllbb7tixgw8++IDTTz+d/Px8SkpKeP7555k6dSobNmwgKyur1d9dv349kyZNok+fPtx4441YrVbeeustTjnlFN59911OPfVUIHjAe//993PxxRczYcIEbDYbP/30E7/88gszZ8486GsBqK2tDSUk+0tOTkZRlEN6Xf/4xz+48sormTdvHldddRUul4s1a9awcuVKfv/73/O73/2OLVu28N///pfHHnuMlJQUAFJTU1t83rq6OiZPnszGjRu56KKLOPzwwykvL+fDDz+kqKgo9Pv7+/Of/8zevXv58ssv+fe//x3arigKf/jDH3jooYeorKwkKSkpdN9HH32EzWbjD3/4Q6vv25lnnskdd9xBcXExGRkZoe3Lli1j7969nHXWWQB8+eWXnH322UyfPp0HH3wQgI0bN7J8+XKuuuqqVh//YB544AE0Gg0LFiygpqaGhx56iHPOOYeVK1eG2nz55ZeccMIJZGZmctVVV5GRkcHGjRv5+OOPQ8+7aNEi5syZQ79+/bjjjjtwOp089dRTTJo0iV9++YW+ffs2e81Dhw7lgQce4JNPPuGee+4hKSmJ559/nmOPPZYHH3yQ119/nQULFjB+/HimTJkCBBPNk046iWXLlvGnP/2JoUOHsnbtWh577DG2bNnCBx980KH3QQjRw6lCCNHLffHFF6pWq1W1Wq06ceJE9frrr1c///xz1ePxNGm3atUqFVAvvvjiJtsXLFigAupXX30V2paXl6cC6pIlS0LbSktLVaPRqF577bWhbVdccYWqKIr666+/hrZVVFSoSUlJKqDu3LlTVVVVff/991VA/fHHH9v9+s477zwVUBMTE9VTTz1V/fvf/65u3LixWTuXy6X6/f4m23bu3KkajUb1rrvuarINUF955ZXQtunTp6sjR45UXS5XaFsgEFCPOuoodeDAgaFto0ePVufOndvu1/D111+rQKuXffv2hdrm5eWp559/frtf18knn6wOHz78oHE8/PDDTf4u+zvweW+77TYVUN97771mbQOBQCiOA9/Lyy67TG3p63nz5s0qoD777LNNtp900klq3759Q4/Zkobffeqpp5psv/TSS9WYmBjV4XCoqqqqV111lRoXF6f6fL5WH6s1gHrZZZeFbjf8zYYOHaq63e7Q9ieeeEIF1LVr16qqqqo+n0/Nz89X8/Ly1KqqqiaPuf9rGjNmjJqWlqZWVFSEtq1evVrVaDTqeeedF9p2++23q4D6pz/9KbTN5/Op2dnZqqIo6gMPPBDaXlVVpZrN5iZ/t3//+9+qRqNRly5d2iSW5557TgXU5cuXt/OdEUL0BjL8TwjR682cOZMVK1Zw0kknsXr1ah566CFmz55Nnz59mgxf+/TTTwGYP39+k9+/9tprAfjkk0+abB82bBiTJ08O3U5NTWXw4MHs2LEjtG3hwoVMnDiRMWPGhLYlJSVxzjnnNHmshqGJH3/8MV6vt12v75VXXuHpp58mPz+f999/nwULFjB06FCmT5/Onj17Qu2MRmOo0ILf76eiooKYmBgGDx7ML7/80urjV1ZW8tVXX3HGGWeEepLKy8upqKhg9uzZbN26NfQ8CQkJrF+/nq1bt7brNTS47bbb+PLLL5td9u+5OVBbX1dCQgJFRUX8+OOPHYrtQO+++y6jR48O9dLtr7VetYMZNGgQRxxxBK+//npoW2VlJZ999hnnnHPOQR9z0KBBjBkzhjfffDO0ze/3884773DiiSdiNpuB4Htgt9s7dTjmhRdeiMFgCN1u+J9o+D/49ddf2blzJ1dffXWzIbgNr2nfvn2sWrWKCy64oMnfetSoUcycOTP0v7m/iy++OHRdq9Uybtw4VFXlj3/8Y2h7QkJCs//Jt99+m6FDhzJkyJDQZ7m8vJxjjz0WgK+//rqjb4UQogeTpEoIIYDx48fz3nvvUVVVxQ8//MBNN91EbW0t8+bNY8OGDQDs3r0bjUbDgAEDmvxuRkYGCQkJ7N69u8n23NzcZs+TmJhIVVVV6Pbu3bubPR7QbNvUqVM57bTTuPPOO0lJSeHkk0/mlVdeadMcD41Gw2WXXcbPP/9MeXk5//vf/5gzZw5fffVVaNgXBIc9PfbYYwwcOBCj0UhKSgqpqamsWbOGmpqaVh9/27ZtqKrKrbfeSmpqapPL7bffDjQW/Ljrrruorq5m0KBBjBw5kuuuu441a9b85mtoMHLkSGbMmNHssv9B+4Ha+rpuuOEGYmJimDBhAgMHDuSyyy5j+fLlbY7tQNu3b//N4aTtdd5557F8+fLQZ+3tt9/G6/Vy7rnn/ubvnnnmmSxfvjyU4H7zzTeUlpZy5plnhtpceumlDBo0iDlz5pCdnc1FF13EwoULDynmA/8PEhMTAUL/B9u3bwc46HvV8HoHDx7c7L6hQ4dSXl6O3W4/6PPGx8djMpmaDbuMj49v8j+5detW1q9f3+yzPGjQICD6i9cIIbqGJFVCCLEfg8HA+PHjue+++3j22Wfxer28/fbbTdq0tZdBq9W2uF1V1XbHpSgK77zzDitWrODyyy9nz549XHTRRYwdO5a6uro2P05ycjInnXQSn376KVOnTmXZsmWhA9b77ruP+fPnM2XKFF577TU+//xzvvzyS4YPH04gEGj1MRvuW7BgQYu9SF9++WUoSZwyZQrbt2/n5ZdfZsSIEbz44oscfvjhvPjii+1+T9qqra9r6NChbN68mTfeeIOjjz6ad999l6OPPjqUGEaCs846C71eH+qteu211xg3blyLycaBzjzzTFRVDX2e33rrLeLj4znuuONCbdLS0li1ahUffvghJ510El9//TVz5szh/PPP73DMnfl/cKjP25ZYAoEAI0eObPWzfOmll3ZZzEKI6CWFKoQQohXjxo0DgkOPIFj0IRAIsHXrVoYOHRpqV1JSQnV1dagoRHvk5eW1WAmtpW0ARx55JEceeST33nsv//nPfzjnnHN44403mgx1aqtx48bx7bffsm/fPvLy8njnnXeYNm0aL730UpN21dXVLRZVaNCvXz8A9Ho9M2bM+M3nTUpK4sILL+TCCy+krq6OKVOmcMcdd3ToNbRFe16X1WrlzDPP5Mwzz8Tj8fC73/2Oe++9l5tuugmTydSuYXv9+/dn3bp17Y73YM+RlJTE3Llzef311znnnHNYvnw5jz/+eJseNz8/nwkTJvDmm29y+eWX895773HKKac0K4piMBg48cQTOfHEEwkEAlx66aU8//zz3HrrrS32qh6q/v37A7Bu3bpWPz8N/1ubN29udt+mTZtISUnBarV2WjyrV69m+vTpHRqmKYTonaSnSgjR63399dctnjVvmKfR0Atw/PHHAzQ7iH300UcBmDt3brufe/bs2axYsYJVq1aFtlVWVjaZNwPBoVIHxtgwD+tgQwCLi4tDwxf35/F4WLx4cZPhjFqtttlzvP32203mXbUkLS2NY445hueffz6UgO5v/7WwKioqmtwXExPDgAEDurRUdVtf14GxGQwGhg0bhqqqoXlsDQfu1dXVv/m8p512GqtXr25WFRIO3kvzW89x7rnnsmHDBq677jq0Wm2TIZy/5cwzz+T777/n5Zdfpry8vMnQP2j+Hmg0GkaNGgUc/HN2KA4//HDy8/N5/PHHm73mhvcpMzOTMWPG8M9//rNJm3Xr1vHFF1+E/jc7wxlnnMGePXv4xz/+0ew+p9PZbJihEEKA9FQJIQRXXHEFDoeDU089lSFDhuDxePjuu+9488036du3LxdeeCEAo0eP5vzzz+eFF16gurqaqVOn8sMPP/DPf/6TU045pcV1gH7L9ddfz2uvvcbMmTO54oorQiXVc3NzqaysDJ0p/+c//8n//d//ceqpp9K/f39qa2v5xz/+QVxc3EEPKIuKipgwYQLHHnss06dPJyMjg9LSUv773/+yevVqrr766lBvzQknnMBdd93FhRdeyFFHHcXatWt5/fXXQz1RB/PMM89w9NFHM3LkSC655BL69etHSUkJK1asoKioKLQm1LBhwzjmmGMYO3YsSUlJ/PTTT7zzzjtcfvnlbXq/li5disvlarZ91KhRoYP/A7X1dc2aNYuMjAwmTZpEeno6Gzdu5Omnn2bu3LnExsYCMHbsWABuvvnm0FC8E088scVekuuuu4533nmH008/PTRUs7Kykg8//JDnnnuO0aNHtxhvw3NceeWVzJ49u1niNHfuXJKTk3n77beZM2dOs7XUDuaMM85gwYIFLFiwgKSkpGY9QxdffDGVlZUce+yxZGdns3v3bp566inGjBnTpHe2M2k0Gp599llOPPFExowZw4UXXkhmZiabNm1i/fr1fP755wA8/PDDzJkzh4kTJ/LHP/4xVFI9Pj6eO+64o9PiOffcc3nrrbf4y1/+wtdff82kSZPw+/1s2rSJt956i88//zzUiy2EECFhqTkohBAR5LPPPlMvuugidciQIWpMTIxqMBjUAQMGqFdccYVaUlLSpK3X61XvvPNONT8/X9Xr9WpOTo560003NSklrqrB8totlQ6fOnWqOnXq1Cbbfv31V3Xy5Mmq0WhUs7Oz1fvvv1998sknVUAtLi5WVVVVf/nlF/Xss89Wc3NzVaPRqKalpaknnHCC+tNPPx30tdlsNvWJJ55QZ8+erWZnZ6t6vV6NjY1VJ06cqP7jH/9oUrLa5XKp1157rZqZmamazWZ10qRJ6ooVK5rF3FIZcFVV1e3bt6vnnXeempGRoer1erVPnz7qCSecoL7zzjuhNvfcc486YcIENSEhQTWbzeqQIUPUe++9t1n5+gP9Vkn122+/vcl7f2BJ9ba8rueff16dMmWKmpycrBqNRrV///7qddddp9bU1DSJ5e6771b79OmjajSaJuXVD3xeVQ2Wx7/88svVPn36qAaDQc3OzlbPP/98tby8vNX30ufzqVdccYWampqqKorSYnn1Sy+9VAXU//znPwd931oyadKkFpcGUFVVfeedd9RZs2apaWlpqsFgUHNzc9U///nPTUrWt4ZWSqq//fbbTdq19vlZtmyZOnPmTDU2Nla1Wq3qqFGjmpWAX7RokTpp0iTVbDarcXFx6oknnqhu2LChSZuGkuplZWVNtp9//vmq1WptFvfUqVObldL3eDzqgw8+qA4fPlw1Go1qYmKiOnbsWPXOO+9s9nkQQghVVVVFVbt4pqgQQoh2u/rqq3n++eepq6trdXK96L2uueYaXnrpJYqLiw+6oLMQQojuIXOqhBAizJxOZ5PbFRUV/Pvf/+boo4+WhEo043K5eO211zjttNMkoRJCiAghc6qEECLMJk6cyDHHHMPQoUMpKSnhpZdewmazceutt4Y7NBFBSktLWbRoEe+88w4VFRVcddVV4Q5JCCFEPUmqhBAizI4//njeeecdXnjhBRRF4fDDD+ell15iypQp4Q5NRJANGzZwzjnnkJaWxpNPPhmq/iiEECL8ZE6VEEIIIYQQQhwCmVMlhBBCCCGEEIdAkiohhBBCCCGEOAQyp+oAgUCAvXv3EhsbG1p0UwghhBBCCNH7qKpKbW0tWVlZaDSt90dJUnWAvXv3kpOTE+4whBBCCCGEEBGisLCQ7OzsVu+XpOoAsbGxQPCNi4uLC3M0QgghhBBCiHCx2Wzk5OSEcoTWSFJ1gIYhf3FxcZJUCSGEEEIIIX5zWpAUqhBCCCGEEEKIQyBJlRBCCCGEEEIcAkmqhBBCCCGEEOIQSFIlhBBCCCGEEIdAkiohhBBCCCGEOASSVAkhhBBCCCHEIZCkSgghhBBCCCEOgSRVQgghhBBCCHEIJKkSQgghhBBCiEMgSZUQQgghhBBCHAJJqoQQQgghhBDiEEhSJYQQQgghhBCHQJIqIYQQQgghhDgEUZVULVmyhBNPPJGsrCwUReGDDz5ocr+qqtx2221kZmZiNpuZMWMGW7duDU+wQgghhBBCiF4hqpIqu93O6NGjeeaZZ1q8/6GHHuLJJ5/kueeeY+XKlVitVmbPno3L5ermSIUQQgghhBC9hS7cAbTHnDlzmDNnTov3qarK448/zi233MLJJ58MwL/+9S/S09P54IMPOOuss7ozVCGEEEIIIUQvEVU9VQezc+dOiouLmTFjRmhbfHw8RxxxBCtWrGj199xuNzabrclFCCGEEEIIIdoqqnqqDqa4uBiA9PT0JtvT09ND97Xk/vvv58477+zS2IQQQjQKBFT8qkpAVVHV4DZVBRW1/mdw9MGBFEVpvA4oCigowZ/11zUKaBQFjUZp9vtCCCFEV+kxSVVH3XTTTcyfPz9022azkZOTE8aIhBAisvkDKl5/AH9AxRdQ638GCAQIJkv7bQ/UJ0+BAE2SqO6gKKDVKGgUBa0mmJRpFQWtJnjRaRqvB29r0GmD2/dP4IQQQojf0mOSqoyMDABKSkrIzMwMbS8pKWHMmDGt/p7RaMRoNHZ1eEIIEfF8/gC+gIrHH8DnV/H5A43XAwG8/mCi1J2J0aFQVfD5VYJ9X+2j1SjotQo6rQadRkGv1aDXKuh1GgxaDXqtBq30hgkhhKjXY5Kq/Px8MjIyWLx4cSiJstlsrFy5kr/+9a/hDU4IISKAP6Di8QWCF38Arz943esP4PYFoiZZ6g7++p42vIFW22g0hBIsgy54Mdb/NGg10tslhBC9SFQlVXV1dWzbti10e+fOnaxatYqkpCRyc3O5+uqrueeeexg4cCD5+fnceuutZGVlccopp4QvaCGE6EaqquL2Beovfjz11z2+QH2vTft4/QHq3D4cbn/w8eoTsYbErOFnINB8TlTD9Yahdnpt4xA7fX0PkFGvxazXYjEEf5oNWvTa6KihFAiAKxDA1ULipSig1waTLKNeg1GnxVT/U3q4hBCi54mqpOqnn35i2rRpodsNc6HOP/98Xn31Va6//nrsdjt/+tOfqK6u5uijj2bhwoWYTKZwhSyEEF0ilDx5A7h8/tBPz0F6nFRVpc7to8rhpdrhafazxunF7vZR5/Zhd/up8/jw+Frvqekqeq2CxaDDatASb9YTZ9YTX39puJ5oMZAcYyDFasRs0HZ7jL9FVQkln7UHLJWo1ymhJMukCyaSRp30bAkhRDRT1JZKLPViNpuN+Ph4ampqiIuLC3c4QghBIKDi9PqDF0+wx8jlbZ48qapKtcPLPpuLUpuLslo3pfWXsloXpbVu3B1Mkiz1B/7BYW5aDFoFgy64Ta8NFoNoUo2Pxmp9AVUNzcvy+VW8geB8rYZhh06PH4cn2AvWEVaDluQYIykxRlJiDKTEGMmMN5GVYCYz3kSsSd+hx+1OikKoJ8vc0Gun10oVQyGECLO25gZR1VMlhBA9XUMC5fAEE6iGXqj9ubx+9lQ72VvtpKiq/mf9bYfH/5vPYTVqSTAbSLToSbQaSKjv+Ym36Ik16rDud4kx6jDru2fIms8fCCWODo+fWrcPm9NLjdOLzVX/s/52pcNLRZ0bh8eP3ePHXumgoNLR4uPGGHVkxJvIijeRmWAmN9FCbpKFPonmiBlqqKrg9ARwegJUO7yh7Sa9BrNBi8WgCyW20qMlhBCRR5IqIYQII1dDEuH14/T4mvRABVSVEpuLXRUOdpXb2VVhZ1e5nX01rlbr2SlAaqyR1FgjabFG0uJMwZ+xwZ8pMUYMushIJA6k02qI1Wra1bPk8PioqPNQVuemos5NeZ2H0loX+2qCl0q7hzq3j22ldWwrrWvyuxoFshLM5CYFk6y8ZCv9UqxkxpsiJnFxeYNztqrswURLUcBs0GI16LAYgz9ljpYQQoSfDP87gAz/E0J0FVUN9kLZ3X7sbh8Ojz9YYa7+vn01LraU1LK1tI6tJbXsrLC3WAQBIM6ko0+ihT4JJvokBH8Gh7uZ25w0KfUL5QbXaQoO19MoSrOFdRvaBuNs/Kmihq43rEflDxBanyoSvl1cXj/FNS722Vzsq+/NK6h0sLvS0WqvntWgpV9qDP1TYxiQFkP/VCtZCWY0EZJoHcik12AxBuegWQy6iE2ahRAiGrU1N5Ck6gCSVAkhOouqqsHhaW5fcIia2xdKNGqcXjbuszUmUaW12N3ND/J1GoXcZAt9k63kJ1vpm2IlL9lCosXQ4nM2lPnef30lnVZBX191L7TYraJ0+XwdVVUJqITmUvnq51L5A8F5VX6/Gqog2JBcdhdVVamweyioCA4bLKh0sLPCzu4KO94WqiSa9VoGpccwJDOOoRlxDM6IJcYYmYM9DDoNVqOWmPohnJEyxFEIIaKRJFUdJEmVEOJQuLx+al2+UBU9VQ0ewJfWulm/18aGvTVs2GejsMrZ7Hf1WoV+KTEMTI9hYFos/VOtZCdamg3vMuga10UyaJv+jNahYIH6RYcb185S68vB+7t1DS2fP0BhlSM4XLDMzvbSOnaW21ssopGbZGFoRixDMuMYkRVPepwxYoYN7s+k1zSZIxetnxEhhAgHSao6SJIqIUR7+PwBal3BBKrO7QutBVVqc7GqqJrVhTWs31tDhd3T7HdzkiwMyYhlUFosA9NjyEuyoNuvV6FhMVmTXtvkZ2+rCHfg2ltub6DVCohdwR9QKah0sKnYxqZ9tWwstrGvxtWsXWqskVF94hmVHc/IPgmkxhq7Prh2apiTFWvSEWvUR2Q5eiGEiCSSVHWQJFVCiN/i9PipdXmxuXw46+fl1Ll8rNlTzarC4OXAg26tRmFAagzDsuIYnhUcQhZnbizIYNRrMOu1mPSNJbWlR+HgVFXF5Q00KTfv8vq7JdGqdnjYVFzLpmIbG/ba2FJa12wIY2a8iVHZCYzJCV4icbigTqsQY9QRZ9ITY5JeLCGEOJAkVR0kSZUQ4kCqqobKe9e5fXh9wUIM20vr+HFXJT/trmJ7WR37H1NrFBicHsuonARG9YlnUHosJn2wV0CrUbAag0UFJIHqXA29Wg2FQBwef7csYOz0+Nm4z8aaPTWsKapu8fMwJCOOsXmJHJ6bSL9Ua8QVvlCU4HpkcWY9cSa9FLwQQggkqeowSaqEEBCc41Pr8mFzBddICgSC5btXFVaHEqn91xMCyEk0h3olRvSJx2II9kwYdBosBi1WY3CtoYbkSnQPrz+Aw+3H7vHh8Phwero+ybK7fazfa2N1UTW/FlQ1m0OXYNFzeG4i4/smcXhuQuizEklMek0owZJhgkKI3kqSqg6SpEqI3ssfUKmtX2S21hUsMlFR5+b7HRV8v7OSdXtq8O3X/WDWazksN4HxeUkclptAckxwDo1ep2A16Ig1SfW1SOTzB7C7/dR5fNS5fN3Sk1Vic/FLQRU/765idVF1k1L5eq3C6OwEjuyXzIT8pFYrO4aTXqcQZ9KTYNFHZAIohBBdRZKqDpKkSojepaFHqtrpCSVSJTYXK7ZX8N32cjYV1zZZaDcz3sT4vkmM75vE8Kw49FoNGg3E1FdWsxp10hMVZTy+QLDQiMtHrTvYK9mVvP4AG/bZ+GlXFSt3VjSZf6cAQzPjOLJfEhP7pZARb+raYDqgIcGKN+uxRuA8MSGE6EySVHWQJFVC9HyqqlLn9lHtaBzat6/GydKt5azYXsG2srom7Qenx3JU/2SOyE+mT6IZCBaWiDXpiDXpsRq0EVlKW7SfqqrYGwqROLu+F0tVVQqrnKzYUcH3OyrYVtr0szcwLYbJA1M4ekBqRFYTlB4sIURPJ0lVB0lSJUTP5fT4qXJ4qHF68flVqhwelm4tZ8mWMjaX1IbaaRQYlhnHUf1TmNg/mZQYI4oCVqOuPpHSYdRJb1Rv0LDumM3lxdHC4sydrazWzQ87K1ixo4K1e2qaFLsYlhnH5IEpTBqQEpFDBA06DYkWPfEWvfx/CCF6DEmqOkiSKiF6Fn8gmDxVOzw4PQEcHh/f76jgm81lrC6qDh20ahQYlZ3ApP4pHNkviQSLAUUhNMwp1qTrdetDiaa8/gA2Z3DOnb0bEqxqh4fvtlewZGsZG/baQsNQNQqM7BPPsUPSOKp/SkQONzUbtCRY9CSY9U3WXhNCiGgjSVUHSVIlRM9Q6/JSZQ8O7/MHVFYXVrNoYynf76jA428c0jUoPYapg9KYPCCFRKsBjSaYSMWZ9cQaJZESLevuBKuizs2ybeUs3VrepFfVrNdyVP9kpg9JY3if+Igs0x5j1JFoNRBn0skwWSFE1JGkqoMkqRIienn9AarsHiodHrw+lWKbi8UbS1i8qZSyWneoXZ8EM8cMTmXKwFSyEsyhRCreEkyk5MBPtIfXH6Da4aXa4WlS1a+rFNtcfLO5lK82lTYpcpEWa2TakDSOHZxGVoK5y+NoL61GIdGqJ9FiiMjeNSGEaIkkVR0kSZUQ0cfu9lFR58Hm8uL0+Fmxo4JFG0tYU1QTamM1ajlmUBrTh6QxIC0GjUYh1qQjwWyQoX2i0zg9fqqdHqodwXl7XUlVVTYW1/LVxhKWbivH4WnsMRuVHc9xwzM4sl9yRJb0Nxs0JFoMJFgMsvC1ECKiSVLVQZJUCREd/AGVaoeHSnuwd6CoysFn64pZvKkkNBxLAUbnJDBzaDpH9kvGoNNgNWpJsBiIN+vlYE50GVVVqXX7qK4fgtrV37Run5/vd1Ty1aYSfi2oDs2/ijfrmT4kjdnDMyKy90pRgjEmxxikeqAQIiJJUtVBklQJEdncPj8VdR6qHB483gArd1by6bp9TXql0mKNzByWzrFD0kiLNaHXKSRaDCRaDBh0kXfWXvRsPn+AKoeXSrunWxYaLrW5+GJjCV9uKKHS7gltj5beq0SLQXqOhRARQ5KqDpKkSojIZHf7KK9zY3P6qKhzs3B9MV+sL6HSETxo1CgwLi+JOSMzODw3Ea1GId6sJ9FqIEYWKBURos7to7J+qGpXf/v6Ayo/7qrk8/XF/Ly7KtR7lWQxMGdkBrOHZ0RkaXaNBhIsBpKtMvdKCBF+klR1kCRVQkQOVVWxOX2U1blxevxsKanlf6v2sGxbeagUeoJZz8xh6Rw3PIO0OJPM1RBRwesPUFU/fNXr6/qv4Ybeqy/WF1Pl8AKg0yhMGZjKiaOzGJAW0+UxdITVqCU5xiiVA4UQYSNJVQdJUiVE+AUCKpUOD+V1blyeACt2VPDhqj1sLG4sJT08K465IzNDc6USLHqSrDIvQ0SXA08cdDWvP8DybeV8vGZfk9LsQzNiOXF0FhP7JUfkulJ6nUKS1UCSxRCR8Qkhei5JqjpIkiohwscfUKmoc1Ne56HG6eWL9cV8vHZfqBy6TqMwZVAqJ43Oon9qDEa9hiRrcA6G9EqJaOfwBKtY1ji7fmggwObiWj5es5dl28rx1Xf9psYaOXl0FrOGZWA2RN7QO0WBBIuelBijDA0UQnQLSao6SJIqIbqfzx+gvM5Dhd1Nmc3N/1bvZeG6Ypze4Jn7OJOOOSMzmTsik6QYA3GmYLUwq8yVEj2Q1x+goi44NNAf6Pqv6Eq7h4Xr9vHpumJqnMGhgTFGHXNGZHDiqCwSrZE37wogxqQjJcZArEkf7lCEED2YJFUdJEmVEN3H4wtQXuem0u6hqNLJe78W8dWm0tBZ87wkCyeNyeKYQWmYDdrg8B+rVPATvUMgoFJhDw6D7eo1ryBYWfPrTWW8/2sRe+sXFdZrFY4dnMYph/UhO9HS5TF0hNmgISXGSLxZL/OuhBCdTpKqDpKkSoiu5/UHKK11U2X3sKW4lnd/KeK77RWh6mTDs+KYd3g2Y/MSMRm0pMQYSTDrpcyy6JVUVaXK4aWs1t0tJdn9AZUfdlbw7i97QvOuFODIfsmcOT6H/qmRWdRCr1NIthpJsspwYCFE55GkqoMkqRKi63j9Acpqgz1T6/bU8MaPhawqrA7dP6FvEqeNzWZYZpwM7RGiBTUOL2V1Lpyerk+uVFVlwz4b7/+6h5U7K0Pbx/dN5KzxuQxKj+3yGDpCo4Fkq5GUGClqIYQ4dJJUdZAkVUJ0Pp8/QFmdm4o6D2uKanjjhwLW7Aku1qtRYOqgVE47PJu+KVbizXpSY2USuhAHY3N5KbV1T8VAgN0Vdt7+uYilW8tCyxkclpPAmeNzGJ4V3y0xtJeiQJLVQEqMUYYMCyE6TJKqDpKkSojO4w+olNe5Kat1s6awmv/+WMja+mRKp1GYMTSdeWOzyYg3kRxjINkqBz9CtEcwueqeniuAPVVO3v65kK83l4aSqxFZcfx+Qi4jsxO6JYb2aqgYmBprxKiTkzVCiPaRpKqDJKkS4tCpanCCfanNzaqCKv7zQwHr9tqAYDI1c1gwmcpKMIeSKZkDIUTH1TiDyZXL2z3JVbHNxTs/F7F4Y0mosMzo7Hj+cGQeQzIi97sz3qwnLU56woUQbSdJVQdJUiXEoal2eCi2uVi/x8a/VuxidVFjz9Ss4RnMOzybrEQTKTFGkiwGKT4hRCeqcXgpre2+5Kq8zs07Pxfx+friUHI1Li+RPxyZF7EFLUCSKyFE20lS1UGSVAnRMbUuLyU2F5v21fLayt18vyM4sb0hmTp9bDaZCSZSY4LVuaT0sRBdp8ruoaTWhdfXPV/xpTYXb/xUyOKNJaFhgZP6J/P7I/LITYrMUuwgyZUQ4rdJUtVBklQJ0T4ur599NS62l9bxn5UFfLMlONdCo8Axg9P4/YRccpIspMUaSbDIOjJCdJeGda5Ka10Euqfjir3VTv77QwHfbilDJViK/ZjBqfzhiDzS4kzdE0QHNMy5kuRKCHEgSao6SJIqIdrG5w9QUutmZ1kdb/xQyML9hv9M7JfMH47MY0BaDOlxsiinEOHkD6iU1rqoqPPQXd/4uyvs/OeHAr7bXgEEFxE+YVQWZ4zNIcak654gOiDBoic9ziQFc4QQIZJUdZAkVUIcnKqqlNd5KKyy88Eve3n75yKc3mBZ5zE5CZx7ZB7D+8SRHmuSnikhIojHF6DE5qLa4e2259xaUsur3+0KLaFgNWo5Y2wOJ4zKitjERVEg0WogLdaIXta5EqLXk6SqgySpEqJ1NpeXPVVOvlhfwr+/3015nRuAAakxXHBUX8blJ8qcKSEinNPjZ2+NE4e7e9a4UlWVnwuqeHX5LnZXOgBIjTVy7pF5TB2UiiZC9xWKAikxRlJjpTqpEL2ZJFUdJEmVEM01zJv6bls5Ly/fyfYyOxA84Dh/Yh7HDk0jPc5EsiRTQkSNGoeXfTZntxWz8AdUvt5cymvf76bC7gGgX6qVS47ux4g+kbmAMIBGE0wCU6xGqVYqRC8kSVUHSVIlRKNAQKW01s2qwipeXraLH3YFK/qZ9VpOH5fNKYdlkZ1gISVGDjaEiEaB+gW6S2vd3Tbfyu3z8+HqvbzzcxEOT7C3bFL/ZC6clE96BBez0GkV0uNMJFkN4Q5FCNGNJKnqIEmqhAiqcXrZVlrLa98X8OHqvfgDKhoF5ozI5PdH5JKfYiUt1ohO5hwIEfW8/gDFNd0736rG6eX1lbv5fH0xATVYzOKUMX04fWwOZkPkVuEz6TWkx5uIM+nDHYoQohtIUtVBklSJ3s7t81NU5eSjVXt5dcWu0EHWuLxELjo6n5F94kmLM2LURe5BjxCiY+rcPvZWO3F30+LBADvL7by4bAdr6hcKT7IYOG9iHtOGpEXsfCsIFt3IjDdHdAIohDh0klR1kCRVorcKBFTK6tws31bO89/uYHNJLQBZ8SYuntyPaUPSyIgzyQGEED2cqgb3BaW27hsSqKoq3++s5OVlOym2uQAYlB7Dn6f0Z1B6bPcE0UFShl2Ink2Sqg6SpEr0RnVuH+uKanhx2Q4WbSwFgvOmzhqfw7xx2eQkWWSoixC9jMcXYF+NE5vT123P6fUH+HD1Xt78sRCn148CzB6ewXkT84iN4H2QokBarFHmlwrRA0lS1UGSVInexOcPUFTl5M0fC/nXil3Y6yeNHzskjYsm5TMkM1Yq+gnRy9lcXvZVu/D4um9IYJXdw8vf7eSbzWUAxJp0XHBUX2YMTY/oIYF6nUJGnIkEixSzEKKnkKSqgySpEr1FtcPDki3lPPXVVraW1gHB9ab+ckw/Jg1IIS3WJGuzCCGAxkqg5XXdNyQQYN2eGp79djsF9etbDU6P5a/H9Kd/akz3BdEBZoOWrAQTFoMu3KEIIQ6RJFUdJEmV6Ok8vgCbS2p5/tvtfLp2HwEVLAYt5x2Zxxnjc+iTaJYiFEKIFjk9fvZUO3B6uq/XyucP8PGaffznhwKcXn+oCum5R+ZhNUZ20pJg0ZMRb0IvVVKFiFqSVHWQJFWiJyurdfHWT0W8uHQHVfVV/aYMTOWvxwQX34zkOQtCiMigqirldR5KbK5u7bWqqHPz8vKdLNlaDkCS1cBfpvRjYv+U7guiAzQaSIs1kRIjQ6mFiEaSVHWQJFWiJ3L7/Py4q5JHPt/Cr4XVAPRJMPPXqf2ZNSKd1BijfNkLIdrF7fOzp8qJ3e3v1uddXVjNM99sY19NsErgEflJ/GVqf1JijN0aR3sZ9RqyEszERHjvmhCiKUmqOkiSKtHTFNe4eHHpDv79/W7cvgB6rcIZ43K4aFI+uckWGZYihDgklXYP+2qcBLpvRCBun5+3firi3V+K8AdUzHot503MY86IzIifCxpv1pOZIEMChYgWklR1kCRVoqdwef0s2VrGwws3hwpRjMiK49pZgxmfnyRnS4UQncbjC7Cn2kmdq/vKrwPsrrDz9Nfb2FQcXFdvcHosl00bQH6KtVvjaC9FgbQ4o4wSECIKSFLVQZJUiZ6gqMrBU19t452fg2dxrQYtF07K59yJeaTFype4EKJrVNS52VfTvXOtAqrKwnXFvPrdLpxeP1qNwrzDszlzfE7E9wYZ9Rr6JJgjvuCGEL2ZJFUdJEmViGZun5+F64p5+PPNFFU5AZjYL5lrZw1iVHYCBl1kH2AIIaJfuOZaVdS5eX7JDlbsqAAgL8nCVdMHMjA9tlvj6IhEq56MOBO6CE8CheiNJKnqIEmqRLTaU+3koYWb+HDVXlQg0aLn0mMGcMa4HOItUtVPCNG9yuvcFHdzrxXAsm3lPPvNNmwuHxoFTjs8m7PG50b8SSWtRiEz3kSiVRYOFiKSSFLVQZJUiWjj9Qf4Yn0x9326iT3Vwd6pGUPTWDB7MAPTYiN+0rYQoudyef0UVTlxerq316rG6eX5JdtZWl9+PSfRzFXTBzE4I/J7raxGLVkJZkx6WS9QiEggSVUHSVIloklprYsHP9vM+78WEVCD67bMnzmI0w7PxmyQL2QhRPipqkqJzU1Zrbvbn/u77eU8+812qp1eNAqcMqYP5xyRF/G9VooCabFGUmUOrBBhJ0lVB0lSJaKBP6CyaGMJd3+8ITR36tghafxtzhD6p8XIl7AQIuLY3T4Kqxx4fd172GFzevnH0h18s6UMgJwkC/NnDGJAWky3xtERJr2GPolmLAYpZCFEuEhS1UGSVIlIV+3wcP9nm3j7p0ICanDu1PyZgzh9XI4MFxFCRDR/QGVvtZNqh7fbn3vlzgqe/nob1Q4vWo3C2eNzmDc2JyqGSCfHGMiIM6GJgliF6GkkqeogSapEpFJVlZU7Krj5g3VsL7MDcMzgVG6ZOywqzrgKIUSDaoeHPdXdu2AwBOda/d832/hue7BC4OD0WK6ZMYg+iebuDaQD9DqFPglmYk1SeEiI7iRJVQdJUiUikdvr56mvtvHCkh14/AFiTTqunTWIsyfkYtRJ75QQIvp4fAEKKh3dXsRCVVW+2VLG899ux+7xY9BpuGhSPsePyIiKodMJFj2Z8VJ+XYjuIklVB0lSJSLNlhIbN767ll8KqgEYm5vIvaeOYEimfD6FENFNVVWKbS7Kaz3d/txltW6eWLyF1UU1AByWk8BV0weSHGPs9ljaS6dVyEowE2+WXishupokVR0kSZWIFP6Ayn9W7uahhZupdfswaDX8eWo/LpvWH5NeJi0LIXoOm8tLUaUTf6B7D0kCqsrHa/bxz+92hUYBXHnsQI7sl9ytcXRUgkVPVoI5KuaFCRGtJKnqIEmqRCQor3Vz0/tr+XJDCQD9U63c97uRTOibFBXDU4QQor28/uBwQIe7e4cDAhRWOXjki82h+apzRmRw0aT8qCj+o9Mq9Ek0EydzrYToEpJUdZAkVSLclm0t4/p317C32oVGgTPH53DTnKHEyTAPIUQPp6oqpbVuSm3dv6aV1x/g39/v5v1f9wDB0uvXzRpMfoq122PpCOm1EqJrSFLVQZJUiXDx+vw8vngrz3+7A19AJSXGyD2nDGf28OiYPC2EEJ2l1uWlMAzDAQF+LajisUVbqHJ40WsVLjgqnxNHZUbFflh6rYTofJJUdZAkVSIcCisdzH9rFT/uqgLgqP7JPDxvFH0SLWGOTAghwsPrD7C7ovurA0Kw9PoTi7eE9snj8hK5avpAEiyGbo+lI5JiDGTKulZCdApJqjpIkirR3T5Zs5db/7eeSrsHvVbhimkDuXRafymXK4To9VRVZV+Ni4q67q8OqKoqn67dx0vLd+L1qyRZDCyYNYiR2QndHktHGHQacpLMWAxS2EiIQyFJVQdJUiW6i9Pj455PNvKflQWoQG6ShYfnjeKIKKk6JYQQ3aXa4aGoykk4jlh2V9h58PPNFFY60Chw9oRcTh+bExVzlxQFUmONpMUao2L4ohCRSJKqDpKkSnSH7WV1XPXfX1m31wbA3JEZ3HPKSBKt0TG0RAghupvL66eg0oHbGwjLcz+/ZDuLNpYCMCYngfkzB5EYJcMBzQYN2YmWqKhmKESkkaSqgySpEl3to9V7uOWD9dQ4vZj1Wv52/BD+cGSenEUUQojf4A+oFFU5sDl9YXn+xRtLePbb7bh9ARItehbMGsyoKBkOqCiQEW8iJQoWNxYikkhS1UGSVImu4vb6ue/TjfxrxW5UID/FylNnH8aIPvHhDk0IIaJKaa2LkpruL7sOUFDp4MGFmyioHw541vhczhgXHcMBAWJNOrITzTJvV4g2kqSqgySpEl2hoMLOVW+u4teCaiA43O/+342StaeEEKKDbC4vhZUOAt0/GhCX188LS3bw5cbgAu1jchJYMGsw8VGyT9dpFbITzcRK6XUhfpMkVR0kSZXobF+sL+bG99ZSafdg1Gm44bghXDiprwz3E0KIQxTOeVYAX20q5f++2YbbFyAlxsCNxw1lcEZsWGLpiJRYAxlxJvk+EuIgJKnqIEmqRGfx+QM88sVmnl+yg4AKOYlmHj9rDGPzksIdmhBC9Bj+gEphpYNaV3jmWe2usHP/Z5vYU+1Ep1G4eHI/jh8RPYu2SxELIQ5OkqoOkqRKdIaKOjdXv7mKpVvLAZg+JI2HTx9FklUmCAshRFcornFRVhueeVYOj4/HF21lxY4KAI4ZnMplxwyImkRFUSArwUySVKAVohlJqjpIkipxqFYVVHHlG6soqHSg1ShcNX0glx3TH61MChZCiC4VzvWsVFXlg1V7ePW7XQRU6Jts4aY5Q8lKMHd/MB0Ub9bTJ9EcNUU3hOgObc0N5ChPiE4SCKj894fd/P7FlRRUOkiyGnj+3LFcOX2gJFRCCNENEiwG+qfGoNN2f1KgKAqnHpbNPaeMJMGiZ1eFg2veWsX39b1X0aDG6WVbaR1Ojz/coQgRdXrkkd4zzzxD3759MZlMHHHEEfzwww/hDkn0cA6Pj1v/t46b3luHw+NneFYc7/31KGYMTQ93aEII0auYDVoGpMVgNoTnEGdkn3geP2MMQzPjcHj83PvpRv6zcjeBKBkY5PEF2F5WR3ldeIZSChGtelxS9eabbzJ//nxuv/12fvnlF0aPHs3s2bMpLS0Nd2iihyqsdHDuSz/w+soCAE4Zk8Vbf55I3xRrmCMTQojeSa/V0C8lhjizLizPnxxj5L5TRnDiqEwA/vtjIfd9uhGHJzzFNNpLVWFftYvdFXZ8/vBUVhQi2vS4OVVHHHEE48eP5+mnnwYgEAiQk5PDFVdcwY033tisvdvtxu1uPBtjs9nIycmROVWiTVZsL2f+W6vZV+PCoNNw/ezBXDQpH42MRxdCiIiwr8ZJea0nbM+/aGMJ//fNNrx+lZxEMzcfP4w+idEzz0qvU8hJtGA1hidBFSLc2jqnqkf9h3g8Hn7++Wduuumm0DaNRsOMGTNYsWJFi79z//33c+edd3ZXiKKH8AdU3vihgHs+2YjT6yct1sijZ47m6AGp4Q4NStaDqwbyjgre9jph1X/2a3DAeZTkgdBvavC63wu//LP1x07MhwHT6x9GhZ9fqb+jIYlUCc0Qj8+BQbMaf/fnVyFwwFnahraxmTD0hP3a/hN8Bww9aShPbE2B4ac2bl/9BnjqWghWAXMCjDitcdPad8BZ1fJrM8bB6DMbb69/H+zlLbfVm+GwPzTe3vgR1Ba33Farh7EXNN7evBBqCltuCzDhksbrWxdB1c7W2469IPj4ADu+hYptrbcd8/tg3AC7lkPphtbbjj4LjPVr7RSshOI1rbcdcRpY6pcJKPoZ9v7Setthp0BM/f/IvjVQ9GPrbQcfD3HBs/yUbICClvfhAAycCQm5wetlW2DX0qb371/aut80SMoPXq/YDju+bv1x86dCysDg9eoC2PpF621zj4L0YcHrtr2w+dPW22ZPgMxRwet1ZbDxw9bbZh0GfQ4PXndUwoYPWm+bPhJyxgevu2yw9u36O1o4d5o6FPpOCl73OGDV660/bsqgpvuIn19tvW1SPgyYUf+0Kvz4YuttE3Jh0OzG2z+9DIFW5vLEZcGQuY23f30NfK7G59mfNRWGnxK6mbnrQ+Lqaqh2euvfivr2KviN8dT0PynUNn77/9C6a1oMIaCPoXrg7xpD2vkJOmdL86UUAjoT1YNOB2DG0HSO8HzPtz+twW7z8cPbHzNjWDp5ScHRDKpGS9WQ34d+O7ZgEfq6vS2/D0DlsPNC12OKvsVg29162yFngya4j7DuXY6xZkerbasGno6qMwFg2bcSU9Xm0H01CgRMOmIbEqsD9xEla1t9XIb/rnEfsedn2Ptr430H/u2a7CNWQ+FBpm8MOaGVfUTD33e/xx40u+k+Yue3LT+mokD/YyGpX/B2xXbY/lXzNsErkD/lgH3Ely0/P0DepMZ9RM2e4D6itX6NnAmQNSZ4va4UNvyv5XYQ3D/0GRu87qiE9e+13jZjVPCxoX4f8VbrbdOGNR5HtLSP2H+/mjIo+F5A5+0jBs6CxLzWHycC9aikqry8HL/fT3p603ks6enpbNq0qcXfuemmm5g/f37odkNPlRCtcXp8PLBwM//6bhcqwfHzT//+MPKSI2C4n70CXp4d/LJJ7Bvc5qiAT+a3/jvDTw3uEAG8Dvjk2tbbDjouuKOF4M7w42tab5s/FTJGNt7+7IbGg6ADZU9o/FIA+PI2cFW33DZ9JOQc2Xh70Z1Q28oBSPLA4BdZwxfX1/dB5faW28b1afxSAFjycDBBbYk5KfjF22DZ47Dnp5bb6i0wcL8DxxXPwK4lLbeF4N+uwQ/PH/xAvv+xjYnSTy8d/Is358jGA5tf/w2r/9t626zDgu8HwJo3gge7rUkbCskDgtfXvQPf/1/rbRPyIGMEoARjXfr31ttaUiD3iOD1zZ/CV3e33vaU56FffXK59Qv44ubW257wONQfOLLj24N/3mffD4b6/+tdyw/e9tjbGt/fwh8O3nbydcEDfwgmlwf7/5x4RfCkA0D51oP/z437I8RnB6/XFB78cUef3ZhcOirg0wWttx3+O0gdErzuqTt420HHQdrw4HVVPXjb/KnBg7wGC29qfR+Rc8A+4vObW99HZIyEnCMaby+6A2vtXlraQ7vi+2HPmBC6nf7zoxhtLZ/I8Fgzqcs6KnQ77dcnMVdubLGtz5hIXZ/Jodujd73MUeqvUP8xZb9DkoDOTG1O4/4kZe2LxOz7ruXXBtjyZgUPaFWV5PWvEFf4Vatta/tMCSVKSRv+TcKu1pN9e/p4/KbEYNstb5C49d1W2zbZR6x+A34+yD4idUjjPmLtu/D9M623Tchr/N7Y8D9Y+kjrba1pjQnCb+0jDLHt3EfU71d3fHvwz/BxDzQml7uWH/x/7tjbwBx8fyn84eCPO/k6iKk/lt235uBtJ14OsVnB6+VbDr7vGffHxuSypvDgbUf/PngiFdq2j0gZFPyf99jbsI/Y7ziitban/0uSqmhjNBoxGmXtINE2+6qdXP/OGpZuC/ZgnDAqk/t/N5JYk/43frObLH0Y3LXBHovYzOAXr8EKQ09s/Xdyj2o82+d1wdCTWmmoBg9UGtqqamMCoKpNz1opCmSOaWwLwbb+FobgKErwrPn+bYee2Nj7FDqTV/8zMb9p20Gzgjv8JqHWxxOXDbEZjdsHzgLbnpZfnjWl6eMOmAFJ/ZvHCsEv0bis/dpOh/g+TZ+/gc7Y9HH7TQVLYssxQNO2+VMak6YDqWrwAFpXv//KmwRqoPG+AxceTcgGU3zwes4RwS++A19Xg8S+wfejoe2B7++BbRve4+xxMOzk1tumDGh837IOC34mWlsgNXVQ4+Nmjj74ZzhtcOP7ljGyadsDzwSnDWtsmz5sv7YtxJExojHetCEHjyFzdGO8KYMO3jZrzH7/c47W26oq9DmssW3A1zTpPlD2uMa2Gu0Bj3vA68s9svG1GazN/277v2+5EyG2/gDPG3/wv3H2hKb7iKEntf43zhzd/P++pX0EBPcR+/8vDz6+aQ/1/s+R1K/5PqK+19kfUHF4/QQCwd/xWjPxWRsftzZnGi77kBZD8JlTmrSty56KJ65vi239htgmbe19JuOzZhBQVXaW2ymxBZPH5Bgj/TOSmz5un6PxmxKaPuB+f48mj5s5EVVrbP4eNLSNyQzd78g8AqWlXst63pgsAobg0CZ7+ng0PmeL7TSKgs6agzmu/jORMwEcrfTqwwH7iLEH+Y4Bkvs3/u36jG38rDXr0VGD+4iGts32EQe8F2lD9ms7quXPcMNzNNlHDG8h3v1iSR/e+NpSB7ceg6JA1n6f97QhwV65hvsO1OfwpvuIhrYtxdBnbPN9RLPHrL+dM6ExXkXTwr5nv9/LPbLxcQ2Wpu9Zk7+HWr+PqH/cgx5H1MfQsO9p2Ee0JCH6Ojh61Jwqj8eDxWLhnXfe4ZRTTgltP//886muruZ//zvIWdx6sk6VaImqqvxaUM2Ct1ezo9yOVqNwxbEDuGzaAPSRUi69cic8MyF4UPL7t5sOvRNCCAGA1x9gd4Udpyd8BRg+W7ePF5bswBdQGZAawy1zh5IcEz0neGWxYNGb9Mp1qgwGA2PHjmXx4sWhbYFAgMWLFzNx4sQwRiaimc8f4H+r9nLhqz+yo9xOvFnP42eO4cpjB0ZOQuV1wqI7gglV1tjgHBMhhBDNNFQGjDWFb7DOnBGZ3HPKCOJMOraV1TH/rdVsLakNWzztpaqwp8pJYaWDQKDHnJsX4pBEyBFh55k/fz7/+Mc/+Oc//8nGjRv561//it1u58ILLwx3aCIKOT1+nluynQVvr6bG6aVfipV/XTSBE0dnRU6FP48jOOl240fB29NvbX24jRBCCDQahbxkC0kx4etpGZ4VzyNnjCE3yUKlw8ON761l6daysMXTEdUOL9vL6nD7ZLFgIXrcnKozzzyTsrIybrvtNoqLixkzZgwLFy5sVrxCiN9SUefmoYWbefOnYKW2o/on8/d5o8mKpFK4Hkdw/tSPL4PqD85n6D8t3FEJIUTEUxSFPglm9FqFkprwLHSbEWfi4XmjePjzzfy0u4qHPt9MUZWTs8bnoETJyTGXN8C20jpykizERcr8YiHCoEfNqeoMMqdKqKrKrnI7t/xvPcvrC1KcMS6bW+cOI9YcQV8YDQmV1w5vXwi1++Cs/8KQ48MdmRBCRJVqh4eiKmerFa67mj+g8srynfxvdbCS6eSBKVw1fSBGnTY8AXVQepyRtDhTuMMQolO1NTeQpOoAEZVU+TzNq241OXOltLB9//sb1gzaf82E1v7cbTwj1tbn/614mvxs7/M2PO5+v3tghbiWtu3/XPvHpyihOH0BlTV767jp00I2l7vQaeCaKVn88cgsTHpdfcyBpvGrgf2e94A4D/a+7P+aWvs7Nbu+3+txVgd7pyA4l6roJxhzTnBtJiGEEO1S5/axu8IerAwYJp+vL+bZb7fjD6gMTIvh1rnDSIyyYhCxJh05SRa0kTJEXohDJElVB0VUUtVQGlt0C5c3wNLddfztGztlDpVYg8K9U63MGRyLPiq+HJRgOWlNdJ3ZFEKISOH0+NlZbscfxuILa4uquf+zTdS6faTGGrn9hGGRsQ5iOxj1GnKTLJj08n0kol+vrP4nREfVuny8s76GK7+oo8yhkhOr4R9zYpk7OC6yE6qClcF1KSC4yKwkVEII0WFmg5b+aVb0uvDt90dmJ/D300eTFW+irNbN9e+uYVVhddji6Qh3/TyrGoc33KEI0W0kqRK9XoXdwz9+tnHbUidOHxyWruWlE+I4IjeGSKmY3qJ9a2DhDfDuxeD3Nq7qLoQQosOMOi39U2Mw6cP3BZCVYObheaMZlhmHw+Pnjo/Ws2hDSdji6QhVhYJKB8U1rnCHIkS3iORDRiG6VCAAe6qdPLjcxpM/uwiocFy+nv+bE8+gVEtkVyVXVfjxH8HrGSNBqwdjTHhjEkKIHkKv1dAvNQaLMXy9/3FmPXefPIIpA1PxB1Se+Gorr32/m2ibtVFW62ZXmIdUCtEdJKmKVqFiCVEs4AefK9jL0s2vxetX2V5h5+ZvanlrkweAC0Yaue/YeDKjoXJR0Q9QvDaYTB12LigaMEhSJYQQnUWrUchPtoZ1kWCDTsO1swZxxrgcAN78qZBHvtyC1x/GahodUOvysb2sDpdX1rMSPVePW6eqRyleB4vvAK8zWD7bV//T6wgmI2MvhLHnB9va9sJ3T4ExLjgMTKMLVoTze8DnDq5dlHdUsG3lTvjm/vr7vY3t/N5gsjb6LBh7QbCtowK+eQB0ZtAZGqvdNVz6ToJBxzW2/eqeYLKkBup/+oM//R4YMKMxXns5vD6v8bUqGtAaghedAfodCxMvDd4X8MHCm0BnBK2x/md9NSS/B1IHw7CTG29/dHVwbpEpvvklLhtH0lC2lDq4dakDR3kB/TQ6Lhht5Xcj9MSo1VBcCnUlwSp6fcYGH9ddC2+dH0xitIbGGBpiyhwFY37f+Hre/2t9wugJxq81gN4cnPeUOgSO/Etj25XPg8ce/Dv53cGfDdcT82HKgsa2n98Mrhqw7QneHnYqxKQFE6qI7loTQojo07BIcFGVk+owzQ/SKArnHplHRpyRZ77Zzrdbyiivc3Pz8UOJjaJ1odzeANvL6shNskRV3EK0lSRVkcxTBwXft37//nNo7OVQsKL1tgm5jUlVwAflWw7yxPsdnLtqoOjH1pvGZTZe93th76+tt7WXN17XHlAiVg0EkxCfC9wEX3sDn/vgMXidjUmVRgelG1pvmj2Rn4fcxN++dVBYG2Cb8QZ0SgA2Erzsr+/kxqTKEBNMfPwHWSBy/6SqckfrbQ8sJrHxw+Bjt+iARKl0Izgr62OyNj6nzKcSQoguoShKfYlwJxV1nrDFMXNYBqmxJu7/bCPr99q44d013HHScNJio2B0Rb1AAHaVO0iPN0ZV3EK0hZRUP0BElVSv3Amr/gMGS7CnyFDf06G3NPZ66IzBtvZyKPweXLXgtgV7h3SGxt6UjJGQNizY1mNvHDrW0DvU0EOEEnw+U0KwrcsWTOy8jvokQQMaTbBnSdFA8gBIHx5s63PBruXBpEHRNv2pNYA1BeKygm3VQLD3R6MP9mY19Kg1/DTGNrb1e2D71017cHzuYM+M1giJfYM9Zg12LYeAN5gQ7nfx1FWx0zCIM3efRLVbJc2sskz7F/SKHyXgDSaFihZiUiEmHTJHw7iLGh+3ahd4XcHnb4izIWZzAuRObGxb9GNj75tGF2zndQQTQFMcZI9vbPvLv4LvR0MvnM4AOlPwd80JkDGqsW3ByuDzoUJyf4jrE9yeOiT4mRBCCNFlimtclNUe5ORaN9hdYeeOj9ZTXuchyWrgjhOHk58SXSXXARIsevokmNFEcoVdIZB1qjosopIqWaeqU/gDUGJz8c1uF3ctc+DyQ/8EDfdPtTIqy9pY4anhXyHahtFp9JAxItxRCCFEr1Ba66KkJryJVVmtmzs+Wk9BpQOLQcstxw9lZHZCWGPqCLNBS16yBX1El9oVvZ2sUyUEwYIUe6odfLDZwa1LggnV2AwtT86KYXQfa9OSuYoSfQkVSNU/IYToRmmxJrISwjt0LTXWyIO/G8XwrGDJ9ds+XM/SrWVhjakjnB4/28vqcHqkgIWIfpJUiR7L6fVTVOXgtXVOHlrpwq/C9Dw9D06LYVCqFaOuh3z8jWHuURVCiF4mOcZIdqI5rOfhYkw67jppBEf1T8YXUHno8838b9We8AXUQV6fyvayOmqcslCwiG495KhSiKZqXT4KKx08/bOTF1YFh2mcNtjAbUdb6JtsQa+Nwh6p1kiRCiGE6HaJVgM5ieFd09Cg03D97CGcMDJYNOrFZTt5eflOAlE2s0NVoaDCQWmtLBQsopckVaLHqbB7KKxy8uD3Lt7dHKzU9KcxRq4ebyU70YquJ02K1ZmCBUeEEEJ0u3iLnrzk8CZWWo3Cn6b04/yJfQF4/9c9PLZoC74oW8sKoKTGTWGlI+oWOBYCJKkSPYiqQrHNRVG1m1uXOli824tWgeuPMHHuSAtZCWZ63FxY6aUSQoiwijXp6ZtiDWtipSgK88Zmc82MgWgU+GZzGfd9thG3L/rmKlU7vOwot0dlUih6t552iCl6KX8A9lQ7KarxcP3Xdn4u9mPSwt1TLJw0yEyf+B6YUEFw/SwhhBBhFWPUhT2xAjh2SDo3Hz8Mg1bDj7uquP3D9dS5feENqgMcbj/by+y4vNGXFIreqyceZopexuMPUFTtoKDGy/zFDjZXBogzKDx0rIWpeSay4s1oeuQnXZGeKiGEiBAxRh35Kdawf99MyE/irpOHYzFoWb/Xxt/eX0uVPXyLFneUxxdgR5kdexQmhaJ36pGHmqL3cHr9FFU62VHl5ZpFdgpsAVLNCo9OtzA+y0RWvCnsX3BdxmANLqwshBAiIlgjJLEanhXP/aeOJMGiZ2e5nRveW0NxTfQVgfAHVHaW26l2RF9SKHqfnnq4KSKFRgdaQ3CBWqVzP261Lh97qpxsqfQxf7GDEodKVoyGx2ZYGZ5mJCPOFPahGF1Khv4JIUTEsRgiI7HqlxrDQ6eNIj3OyL4aF9e/u5qd5fbwBtUBqgqFlU5KbdGXFIreRVGlxEoTbV01uVsEAuCpBa8LvA7wucDnBlr6kynB5EVnDFaD0+iDCY1GG7wo9T81OlADwcfyuuofs/6itnNSqKIFvQUMlmAVOo1uv+fUtdyLoqoQ8IPqb/ypqsHX1ORnQ1tvfXye+hiD46urHF7K69ysL/dxy7cO6rzQL0HDA8dYyE0wkp5gRWnyHugaL4qm/nkC+z3ngdcDjdvUhm3+g7z/B743GtBbg++N3hx8r9224N+xLe+rMSa4/pSqNv0bBfYbBpE8UBb+FUKICOX0+NlZbscfCO9hVkWdm9s/XM/uSgdWo5bbTxjO0MzoXN8wwaKvXx+sJ58xFZGmrbmBJFUHiKikqiWBQOMBNjRNpA6Vzx28BHzBhCfg2+9SP1lUbwomUnpL8Hp383vZV1FDla2WnwtruWdREW6/yrAMK7cdP5g+ybFkJ1m7dofrczdN9HzuYPKnMwV7jwz1709LMfh9weTKbQN3bX2SpATbG2ODF4O15d9t+H2fM/icluTW2wkhhAg7l9fPjrLwJ1Z1Lh93fbyejcW1mPQabpk7jNHZCWGNqaOsRi15yVa0PWl5FBHRJKnqoIhPqnqxQEClsMqBzelj2bZyHvliM76AyuG5idw0ZwgZ8aboO4PlddYPj5S5UUII0RM5PX52lNcRCHOFcJfXz72fbmRVYTV6rcKNxw1lQn5SeIPqIJNeQ16yFYNOZrGIrtfW3EA+jSIq+PwBdlbYsTl9LNpYwsOfb8IXUDl6QAq3zB1KelwUJlQQHBooCZUQQvRYZoOWfikxYZ9jZdJrue2EYRyRn4TXr3LfZxtZurUsvEF1kMsbYHtZnZRcFxFFkioR8dw+PzvK7Tjcfj5du48nFm8loMKsYeksmDWYlBgjOUlRmFAJIYToFcwGbUQUr9BrNdx43BCmDkrFH1D5+xebWbShJLxBdZDPr7K9rC4q1+ESPZMkVSKiOT3B8ehub4APVu3h2W+3A3DiqEwunzaAJKtBEiohhBARL1KqAuq0Gq6ZMYjZw9IJqPDEV1v5eM3e8AbVQYEA7JKS6yJCSFIlIlaty8v2sjp8fpU3fyrkpWU7AZh3eDaXTO5HgkUSKiGEENHDYtDRN9ka9hpDWo3CZdMGcPLoLACeX7KDt38uDG9QHRQquV4rJddFeElSJSJStcPD7goHgYDKv1bs4rXvdwNwzhG5nDcxj3iLXhIqIYQQUadhgeBwf30pisIfj87nrPE5APxrxW5e+3430Vq/rKTGzd5qZ7jDEL2YJFUi4pTVuimsdBIIqLy0bCdv/1wEwEWT+nLW+FzizHpykyySUAkhhIhKVqOOvhGSWJ1zRB4XHtUXgDd/KuSfK6I3saqo87C7wh618YvoJkmViCj7apwU17gIqCrPfrud/60OjvP+y9T+nHpYNlajVhIqIYQQUS/GqCMv2RL2xArgd4dn86fJ/QB495ciXl6+M2oTE5vTFxGLLoveR5IqERFUVaWw0kF5rYeAqvL019v4bF0xCnDVsQOZOzITi1FL32QrGlnwTwghRA8Qa9KTkxQZidWJo7O49Jj+AHywai8vLN0RtYmV3e1nZ3kdXn+YFwcTvYokVSLsAgGV3RUOqh1e/AGVJxdv5csNJWgUmD9zEDOGpWM2SEIlhBCi54k368lONIc7DADmjAhW1lWAj9fs49lvtxOI0sTK6QkEqwf7ZC0r0T0kqRJh5fMH2FFup9blCyVUizeVolFgwazBHDM4DbNBQ36KFa0kVEIIIXqgBIuBPhGSWM0ensFV0weiAJ+tK+bpr7dFbWLl8QUTK6dHEivR9SSpEmHjrU+onB4//oDK44u38NXmYEJ13ewhTB6YilGvoW+yJFRCCCF6tiSrgcwEU7jDAGD60HTmzxyERoEvN5TwxKKtUTtHyedX2VEuiwSLridJlQgLt8/P9rI63N4A/oDKY4u28M3mMrQahetnD+HoASkYdMEeKp1WPqZCCCF6vpQYI+lxxnCHAcAxg9NYMGswGgW+2lzK44u2RG1i1bBIcI3TG+5QRA8mR6ui27m8fnaU2fH6VPwBlUe/3My3W4IJ1Q2zBzNpQAo6rUJ+ihW9JFRCCCF6kbQ4E6mxkZFYTR6YyvWzh6DVKHyzpYwnFkdvYhVcJNhBld0T7lBED6ULdwCid7G7feyqsBMIgD+g8vcvNrNsWzk6jcINxw3hyH7JaDXBhMqgk4RKCCFE75MRb8KvqlTWhT8BmDQgBUWBBxdu4uvNZSgoXDl9YFQOy1dVKKpy4guoEZO4ip5DjlpFt6l1edlZ3phQPfJlY0J105xgQqXRQN8UCya9NtzhCiGEEGHTJ8FMgkUf7jAAOKp/CtfPHhIaCvjUV1ujtngFQHGNixKbK9xhiB5GkirRLWocXnZXOFDVYEL1+KItLN3amFBNyE9GUSAv2YrFIB2oQgghRHaimVhTZHwnThqQEppjtXhTaVRXBQQotbnZW+0MdxiiB5GkSnS5SruHgspgQhVQVZ76aivf1M+huv64xoQqJ8lCjDEyvjyEEEKIcFMUhdwkCxZjZIzemDwwlWtnDg5VBXwmyhOrijoPhZWOqF3kWEQWSapElyqrdbOnKngmKKCqPPP1ttA6VNfNGszEfslAcJhDvDkyhjkIIYQQkUKjUeibbMWkj4xDtimDUrlmRrDc+hcbSvi/b6J3gWCAaoe3/sRv9L4GERki4z9U9EilNhfFNcExy6qq8ty32/liQ0loYd9JA1IAyEwwkWg1hDNUIYQQImJpNQp9I6iA0zGD07h6xiAU4PP1xTz37faoTkpsTl/9nO/ofQ0i/CLjv1P0OPtqnJTY3EAwoXph6Q4+W1eMAlw9YxCTB6YCkBZnJCVGKvAIIYQQB6PXauibYkGnjYyqe9MGp3H1jIEowGfrinlp2c6oTqzsbj87K+xRWzJehJ8kVaLT7al2Ul4bLAOrqiovL9/Jx2v2oQBXTh/ItMFpACRa9aTHRcbq8UIIIUSkM+q05KdY0UTI0duxQ9K5/NgBAPxv9V5eW1kQ5ogOjcPtZ2d5HT5/INyhiCgUIf+WoidQVZXCSkeTdTX+/f1uPli1F4DLpg1gxtB0AOLMOvokmMMSpxBCCBGtTHotfZOtKJHRYcWsYRn8ZUo/AN76qZA3fyoMc0SHxukJsLPcLomVaLd2J1V2u70r4hBRLphQOal2eEPb3vqpkLd/LgLgr1P7M3t4BgAWo5acRAtKpHwjCCGEEFHEatSRk2QJdxghc0dlceFRfQF47fvdfLBqT3gDOkQub4Ad5Xa8kliJdmh3UpWens5FF13EsmXLuiIeEYVUVWV3hYMaZ2NC9eHqPfz7+90A/HFSPsePzATApNfQN9mKJgpXYhdCCCEiRbxZT1ZC5Ayh/93h2ZxzRC4ALy3byadr94U5okPj9gbYUWbH45PESrRNu5Oq1157jcrKSo499lgGDRrEAw88wN69e7siNhEFAgGVXRUOal2+0LbP1xfzj6U7Afj9hFxOOawPAHqdQl6yFa0kVEIIIcQhS44xkhobOcWezhyXw7zDswF49tvtLNpYEuaIDo3HF2BHeR0urz/coYgo0O6k6pRTTuGDDz5gz549/OUvf+E///kPeXl5nHDCCbz33nv4fL7ffhDRIwQTKjt1+yVU32wu5ZmvtwHwu8P6cNb4HAA0GuibHDnlYIUQQoieICPeRIIlMtZ5VBSF8ybmcdLoLACe+morS7aUhTmqQ+P1qewst0tiJX5Th49wU1NTmT9/PmvWrOHRRx9l0aJFzJs3j6ysLG677TYcDkdnxikiTCCgsrPCjt3duJNZsaOCxxZtQQXmjMjggqP6oigKikL9woWRsSK8EEII0ZNkJ5qJNenCHQYQTKwuPjqf44ZnEFDhkS8388POinCHdUh8fpUdZZJYiYPrcFJVUlLCQw89xLBhw7jxxhuZN28eixcv5pFHHuG9997jlFNO6cQwRSTx1ydUjv0Sql92V/HQwk0EVDh2cBp/mdo/VIgiJ8mC1RgZO3shhBCip1EUhdwkC2ZDZJy8VBSFvx7Tn2mDUwmo8MDCTawtqg53WIfEH5DEShycorZzpbb33nuPV155hc8//5xhw4Zx8cUX84c//IGEhIRQm+3btzN06FA8Hk/rDxShbDYb8fHx1NTUEBcXF+5wIo4/EOwGd3oadyrr9tRw+4fr8fgDTOqfzHWzh4TmTWUmmGRxXyGEEKIb+PwBtkdQcQV/QOWBhRv5fkclZr2We04ZwaD02HCHdUi0GoX8FGvEJLCi67U1N2h3T9WFF15IVlYWy5cvZ9WqVVx++eVNEiqArKwsbr755nYHLSKbzx9gZ3ldk4RqW2kdd3+yAY8/wLi8RK6dNTiUUKXEGiShEkIIIbqJTquhb4olYgpCaTUK180awujseJxeP3d8uJ7dFdG9NI8/oLKjvA6HR2oIiKba3VPlcDiwWCJnbYTOJj1VLfP5A+yqsOP0NJ792lPl5Ib31lDj9DI8K447TxqOURc8cxNv1pOb3HM/J0IIIUSkcnh87Ciz074jvK7j9Pi59X/r2FxSS5LFwIOnjSIjPnLKwXeERgP5KVYsBpne0NN1WU9VbGwspaWlzbZXVFSg1UpXaE/UUkJVVuvm1g/XUeP00j/Vyq1zh4USKotRS3aiOVzhCiGEEL2axaAjJzFyTmyaDVpuP3EYfZMtVDo83PK/tVTUucMd1iEJBGBnuR27W3qsRFC7k6rWOrbcbjcGg+GQAxKRpaWEqsbp5bYP11FW66ZPgpk7ThweKkRh1GvIS7LI4r5CCCFEGMVb9BHVGxRr0nPXSSPIjDdRYnNz64frsTm94Q7rkEhiJfbX5j7LJ598EghWdHnxxReJiYkJ3ef3+1myZAlDhgzp/AhF2LSUUDk8Pu74aD1FVU5SYgzcdfJwEizBZFqrUchLtqDTylpUQgghRLilxhrx+ANU1kVG4bBEq4G7Tx7BDe+uobDSwe0frefeU0ZE9RA6VYVdFXb6Jlul0nEv1+Y5Vfn5+QDs3r2b7OzsJkP9DAYDffv25a677uKII47omki7icypCmopofL4Atz50XrW7KkhzqTjgdNGhYYXKAr0S5WxxUIIIUQkUVWV3RUOal2R05tSWOngxvfWYHP5GJOTwG0nDEMf5SdkZY5Vz9XW3KDdhSqmTZvGe++9R2Ji4iEHGYkkqWo5ofIHVO7/bCMrdwbLot536kgGpDX2VuYmW4g3R8aK7kIIIYRoFKivWLf/93q4bSmp5eYP1uLyBpg8MIVrZw6OmKqFHSWJVc/UZYUqvv766x6bUImWEypVVXnqq62s3FmJXqtw69yhTRKqjHiTJFRCCCFEhNJoFPKSreh1kZO0DEqP5W9zhqLTKCzdWs4/lu5odd5+tGiYYyXl1nunNqXS8+fP5+6778ZqtTJ//vyDtn300Uc7JTDR/fwBtVlCBfCvFbtZvKkUjQI3HDeEkdkJofsSrXpSY2UtKiGEECKS6bUa+iZb2V5WRyBCOqwOy01k/sxBPPz5Zj5Zu494s56zJ+SGO6xD0pBYSY9V79Omv/avv/6K1+sNXW+NokTOGRDRPv6AWr+wb9M97Yer9/DOL0UAXDFtIEfkJ4fusxq19EmQ0ulCCCFENDDpteQkWSiocETMGlaTB6ZS4/Ty/JId/OeHAhIseuaMyAx3WIekIbHqlxKD2SDLDfUW7Z5T1dP1xjlVwYTKjtPjb7J9yZYyHv5iMwDnHZnH6eNyQvcZ9Rr6p8ZE/fhnIYQQorcpr3Ozr9oV7jCaeG3lbt78sRCF4KiYSQNSwh3SIdNokMSqB+iyOVU1NTVUVlY2215ZWYnNZmvvw4kwC4SG/DVNqFYVVvPYoi0AnDAqk3ljs0P3NZROl4RKCCGEiD4pMUaSYiJrbdFzJuRy3PAMVODvX2xmdWF1uEM6ZA09Vi6v/7cbi6jX7qTqrLPO4o033mi2/a233uKss87qlKBE92hIqBzupv/s20rruO/TjfgCKkcPSOGSyf1CQzsVBfKSLRh1ctZFCCGEiFZZ8SZiTJEz50dRFP4ytT9H9U/GF1C599ONbCutC3dYh8wfUNlRJolVb9DupGrlypVMmzat2fZjjjmGlStXdkpQouupqsruSgf2AxKqfTVO7vxoPU6vn1HZ8cyfOQjNfnPl+iSYZXE7IYQQIsopikJukgWjPnLWh9JqFK6dOZhRfeJxev3c9fF6im2RNUyxIxqmWbh9klj1ZO3+T3K73fh8zUtFer1enE5npwQlulbDQoB1BywEWOXwcNv/1lPt9NIv1crNxw9tshhfWpyRRGtkDRcQQgghRMdE4nB+g07D344fSt9kC1UOL3d8uB6b0xvusA6Zzx9MrDy+CCm9KDpdu5OqCRMm8MILLzTb/txzzzF27NhOCUp0HVVVKahsvrK60+Pnzo+CZ4TS44zcccLwJqVA48w60uNM3R2uEEIIIbqQUaclL9lCJBVwthp13HHicFJijOypdnL3Jxt6RC+P1xdMrLx+Sax6onZX/1u+fDkzZsxg/PjxTJ8+HYDFixfz448/8sUXXzB58uQuCbS79PTqfwUVDmoOOOPj8we4+5ON/FJQRbxZz0OnjSJrv1LppvpKf5oIOpMlhBBCiM5TZfdQVBVZI44KKh1c/+5q7G4/R/ZL4sbjhkZUr1pHGfUa8lOsTUYDicjVZdX/Jk2axIoVK8jJyeGtt97io48+YsCAAaxZsybqE6qerrCyeUKlqir/9+12fimowqDTcNsJw5okVNr6VdgloRJCCCF6rkSrgZTYyBrin5tk4da5w9BrFb7fUckLS3fQE1YCcnsD7Cq345Meqx5F1qk6QE/tqdpb7aSiztNs+xs/FvD6ygI0Ctx8/FAm7Le4r6JAfopVClMIIYQQvcSucnuzKQLhtnxbOQ8u3IRK83Uzo5nZoCE/Rdb8jHSd2lO1//pTNpvtoBcReYprXC0mVIs2lvD6ygIA/jK1f5OECiBLKv0JIYQQvUpOhFUEBJg0IIWLJ/cD4F/f7+arTSVhjqhzOD0BdlXYCQSkf6MnaNMRc2JiIvv27SMtLY2EhITQmkX7U1UVRVHw+6N/ImFPUmpzUVbrbrb9l4Iqnv56GwDzDs9mzojMJvenxBpIkkp/QgghRK+i1QRLrW8vqyMQQaPTThqdRUWdm/d+3cOTX20j0WLgsNzEcId1yBxuP7srHfRNtrR4fC2iR5uSqq+++oqkpCQAvv766y4NSHSe8jo3JbbmCdWOsjoe+GwT/oDKMYNSOXdiXpP7Y0w6MqTSnxBCCNErmfRacpMs7K5wEEmTRM4/qi8Vdg/fbinjgYWbeOi0UeQlW8Md1iGrc/koqHSQmySJVTRrU1I1derU0PX8/HxycnKa/dFVVaWwsLBzoxMdVmn3sK+6+YJ5pbUu7vxoQ3Bx3z7xXDl9YJPFfY16jfxTCyGEEL1crElPepyJ4prIWXxXoyhcNX0g5XVu1u+1ccdHG/j7vFEkxxjDHdohszl9FFU5yUmyhDsU0UHtHjSbn59PWVlZs+2VlZXk5+d3SlDi0FQ7POxpoSyq3e3jzo82UOnwkJdk4aYDFvfVaIKVdmTCpBBCCCFSY40kWPThDqMJvVbDzccPpU+CmfI6N3d/sgGnp2dMPal2eNlTHVll7UXbtTupapg7daC6ujpMpq4bMnbvvfdy1FFHYbFYSEhIaLFNQUEBc+fOxWKxkJaWxnXXXYfPF1kVbLqazeVtcZ0Jnz/AAws3UVDpIMlq4PYThxNzQBGKnCQLJr22u0IVQgghRITLTjRjMUbWsUGsSc8dJw4n3qxne5mdhz4PTmnoCSrrPOyrkcQqGrW5tNv8+fMBUBSFW2+9FYulsXvS7/ezcuVKxowZ0+kBNvB4PJx++ulMnDiRl156qdn9fr+fuXPnkpGRwXfffce+ffs477zz0Ov13HfffV0WVySxu30UtDD+uWEtqlWF1Zj0wbWoUmObdpWnxxmJM0XW2SghhBBChJeiBAtXbCutw+ePnMQlI97ELXOHcvP76/hpdxUvLN3BX6b06xHTF8prPWgVhTSZ3x5V2pxU/frrr0DwAH3t2rUYDI2V4QwGA6NHj2bBggWdH2G9O++8E4BXX321xfu/+OILNmzYwKJFi0hPT2fMmDHcfffd3HDDDdxxxx1N4t2f2+3G7W4s5hCtZeGdHj+7KuwtTih995c9fLmhBI0C180aQv/UmCb3x5v18o8rhBBCiBbptRryki3sKGv5OCNchmTEMX/mIB5cuIlP1+4jM87EKYf1CXdYnaLE5karUXrEfLHeos1JVUPVvwsvvJAnnngi4hbGXbFiBSNHjiQ9PT20bfbs2fz1r39l/fr1HHbYYS3+3v333x9K2KKVy+tnZ7m9xdKny7aV888VuwC4ZHI/JuQnNbnfpNeQnWjuhiiFEEIIEa0sBh1ZCeYW52yH06QBKVw0KZ+Xlu/k5eU7SY01MmlASrjD6hR7q11oNQoJFlniJhq0e07VK6+80iShstlsfPDBB2zatKlTA2uv4uLiJgkVELpdXFzc6u/ddNNN1NTUhC7RVsHQ4wsuHNfSWOJN+2w8+uVmILi+wwmjsprcr9FAbrIFjRSmEEIIIcRvSLIaSIqJvAP8k8dkMXdkJirw6Jdb2FxcG+6QOk1RlRObyxvuMEQbtDupOuOMM3j66acBcDqdjBs3jjPOOIORI0fy7rvvtuuxbrzxRhRFOeilq5M1o9FIXFxck0u08PmDCZXX1zyhKq5xcc+nG/H6VSb0TeKiSc0rM+YmWTDqImvyqRBCCCEiV1a8KeIKVyiKwiWT+zG+byIef4B7PtlAqS1ySsEfClWFggoHdnfvKrwWjdqdVC1ZsoTJkycD8P7776OqKtXV1Tz55JPcc8897Xqsa6+9lo0bNx700q9fvzY9VkZGBiUlJU22NdzOyMhoV1zRwB9Q2VVhx+1tPuavzuXjzo/XU+P00j/VyoJZg5uVSU+PNxIrhSmEEEII0Q4NhSt02sga5aLVKFw3awj5KVaqnV7u/mQDDk/PSERUFXZV2HF5e0bp+J6q3UlVTU0NSUnBeTkLFy7ktNNOw2KxMHfuXLZu3dqux0pNTWXIkCEHvbRWYOJAEydOZO3atZSWloa2ffnll8TFxTFs2LB2xRXpAgGV3RV2nJ7mCZXPH+D+hRspqnKSEmPg1rnDMBuanlGKN+tJi5XCFEIIIYRov4bCFZFWaM9s0HLr3GEkWvTsqnDw8Oebe0yp9UAAdpbbcfsksYpU7U6qcnJyWLFiBXa7nYULFzJr1iwAqqqqunSdqoKCAlatWkVBQQF+v59Vq1axatUq6urqAJg1axbDhg3j3HPPZfXq1Xz++efccsstXHbZZRiNPadyiqqqFFY5sLub/1Opqspz325nTVENZr2W204Y3qxqjBSmEEIIIcShaihcEWlSY43cMncYBq2Gn3ZX8fLyneEOqdP4/Cq7yh14/S1UJhNh1+6k6uqrr+acc84hOzubrKwsjjnmGCA4LHDkyJGdHV/IbbfdxmGHHcbtt99OXV0dhx12GIcddhg//fQTAFqtlo8//hitVsvEiRP5wx/+wHnnncddd93VZTGFw55qJzZny93ZH67ey+cNpdNnDyY/xdrkfilMIYQQQojOEqmFKwalx3LNzEFA8Njo07X7whxR5/H4Auwqb7lAmQgvRVXbv+LAzz//TEFBATNnziQmJrjm0SeffEJCQgKTJk3q9CC7k81mIz4+npqamogrWlFic1Fqc7d430+7Krn7kw0EVPjj0fmcMqb5Og25yRbizTKPSgghhBCdQ1VVtpfZcXoib1jamz8V8tr3u9EocPuJwzk8NzHcIXUai1FLfrJVTpR3g7bmBh1KqnqySE2qyuvc7KtuuZLN7go7172zBqfXz6xh6Vw+bUCzFcVTY41kxMs8KiGEEEJ0Lo8vwLbSuojrPVFVlccWbeHrzWVYDFoenjea3CRLuMPqNHFmHblJlmbHfKJztTU3aPPiv/srKiriww8/pKCgAI/H0+S+Rx99tCMPKQ6i2uFpNaGqcXq56+MNOL1+RvaJ5y9T+zf754ox6SShEkIIIUSXMOg05CZb2FVuJ5JO1SuKwhXHDqTE5mbDPht3fbyeR04f02NG7dicPvZUO8lO7DmJYjRrd1K1ePFiTjrpJPr168emTZsYMWIEu3btQlVVDj/88K6IsVerdXkpamX1cq8/wL2fbqS01k1mvIkbjxuCXtt0mpxep5AjhSmEEEII0YVijDrS4oyU1LQ8TSFc9FoNfzt+KAveXk2xzcX9n23k7pNHNDteilZVdi96rYv0ODl5Hm7t/kTddNNNLFiwgLVr12IymXj33XcpLCxk6tSpnH766V0RY6/l9vnZXeFo8ayPqqo8/dU2Nu6zYTVoufWEYcQdcOZFUahfS6Jn7DiEEEIIEbnSYk3EmTs0CKpLxZv13HbCMCwGLev32nju2+30pNkvpTY3FXWRlcz2Ru0+2t64cSPnnXceADqdDqfTSUxMDHfddRcPPvhgpwfYm3n9aqvd6O/+soevNpeiUeCG44aQ00LXb1aCGYsh8nZuQgghhOiZshMtGHSRdzI3J8nCdbMGowBfbCjhozU9pyIgwN5qFzUOb7jD6NXa/am3Wq2heVSZmZls3749dF95eXnnRSZatXJnBf9asQuAP03ux2EtVLNJtOpJskZemVMhhBBC9FxajRKRCwMDjOubxAVH9QXgpWU7+LWgKrwBdbLCKgd17paX3RFdr91J1ZFHHsmyZcsAOP7447n22mu59957ueiiizjyyCM7PUDR1O4KO498sQUVmDMig7mjspq1MRs0ZMXLPCohhBBCdD+TXkt2hM7nPvWwPhw7OI2ACg9+vok9rcxbj0aqGjxOdHkjr7x9b9DupOrRRx/liCOOAODOO+9k+vTpvPnmm/Tt25eXXnqp0wMUjWxOL/d8sjFU6e9Pk/s1a6PRBLu4Zd0CIYQQQoRLgiUyFwZWFIXLpg1gSEYsdrefuz/Z0KN6dwIB2FVhx+MLhDuUXkfWqTpAJK1TVef2sbPMDoA/oHL7h+tYXVRDWqyRR89ouSSoLPArhBBCiEgQyQsDV9k9zH97FeV1Hg7PTeC2E4aj7UEnpE16Df1SY3rUawqXtuYGkTeTULTopWU7WF1Ug0mv4da5w1pMnFJiDZJQCSGEECIiKIpCbpIFTQQebSZaDdwydxgGnYZfCqp5ZfnOcIfUqVzeALsr7D2qymGka1NpuMTExDav1lxZWXlIAYnmvthQHKpSM3/GIPqmWJu1MRu0ZMgaBUIIIYSIIAadhpwkC7vLHeEOpZn+qTFcM2MQDy7cxP9W76VvspUZw9LDHVansbv9FFY6yU2WxYG7Q5uSqscff7yLwxCt2bDPxrPfBCss/n5CLhP7pzRro9UEzwS1NfEVQgghhOgucSY9KbEGyms94Q6lmaMHpFAwPof//ljIM99sIzvJzJCM8E7/6Ew1Ti/7apxkSgGzLtempOr888/v6jhEC/ZVO7n/0434AiqT+idz5vicFtvlJJkjck0IIYQQQgiAjDgTDo8fhzvy5ledNSGXnRV2vt9Ryf2fbuKxM8f0qGVpyms96LUaUmKM4Q6lR2vzkfhbb70VWp8KoKioiECgsbKIw+HgoYce6tzoejGnx8+Vb/xKtdNLfoqVq2cMQtNCT1RanJFYk8yjEkIIIUTkaphfFYmFEzSKwjUzBpGTZKHS4eH+zzbi9fes6nn7ZHHgLtfmpOrss8+muro6dHvYsGHs2rUrdLu2tpabbrqpM2Pr1V75bicb99USb9Zzy/FDMem1zdpYjVrSZR6VEEIIIaKAXqshJykyh6FZDDpuOX4oVqOWTcW1PPft9h5X5KGwyoHD03PKx0eaNidVB36wetoHLdL8aXI/zp+Yx43HDSGthcRJpw2e8RFCCCGEiBaxJj1pcZE5DC0rwcx1s4agUeCLDSV8tq443CF1KlWFXeUO3L7IG4LZE8hEnAil02q47rghjOgT3+L9OUkWdFr58wkhhBAiuqTHmbAam4/AiQRj8xI5b2JfAF5YuoP1e2vCG1An8wdUdpU78PWw4Y2RQI7Ko1BanJEYY5tqjAghhBBCRJzcJAs6beTNrwL43WF9mDwwBX9A5YHPNlFW6w53SJ3K4wuwu9Iho846WbuOzD///HPi44M9J4FAgMWLF7Nu3TqAJvOtRNeReVRCCCGEiHY6bXD9qp1l9nCH0oyiKFx57ECKqpzsLLdz36cbeeC0kRh1kdm71hEOWcOq0ylqG9NUTRuWw1YUBb8/usdp2mw24uPjqampIS4uvOsU1Ll9TXY2Wo3CwPQY9DLsTwghhBA9QInNRaktMnuCSmwurnlrFbUuH9MGp3LNjEE9bk3Q1FgjGfFysv5g2pobtPnoPBAI/OYl2hOqSJeTZJaESgghhBA9RlqsEUuEzq9KjzNx43HBwhVfby7jk7X7wh1SpyurdVNpj7xFmaORHKFHidRYWY9KCCGEED1LJK9fBTAqO4ELj8oH4MVlO9mwzxbmiDrf3montS5Zw+pQSVIVBcwGLekRWn5UCCGEEOJQ6LUasiN0/SqAk8dk7Ve4YmOP69lRVSiodODyyoizQyFJVYTTaIIVcnraGF4hhBBCiAZxJj0psYZwh9EiRVG4YtpAcpMsVDm8PLBwE94eVpI8EIBdFfYe97q6kyRVEUwBshMtGHTyZxJCCCFEz5YRZ8JsiMz5VWaDlpuPH4rFoGXjPhsvL98Z7pA6ndensrvCQSAgpdY7Qo7WI5jFoCXeLPOohBBCCNHzKYpCTpKZNhScDousBDPXzhwEwMdr9vH15tIwR9T5nB4/RVXOcIcRldr9sS0sLKSoqCh0+4cffuDqq6/mhRde6NTABDLkTwghhBC9ilGnJTshctdOmpCfzJnjcwB4+utt7CirC3NEna/G6aW4xhXuMKJOu5Oq3//+93z99dcAFBcXM3PmTH744Qduvvlm7rrrrk4PUAghhBBC9B7xFj2J1sgdqXP2+FwOz03E4wtw32cbe2TlvLJaN1U9rCBHV2t3UrVu3TomTJgAwFtvvcWIESP47rvveP3113n11Vc7Oz4hhBBCCNHLZMWbMeojcxygVqOwYNYg0uOMlNjc/P2LLQTUnjcPaU+1kzq3L9xhRI12f1q9Xi9GY7C896JFizjppJMAGDJkCPv29bxF0YQQQgghRPfSaBRyEi1E6kyIWJOem48fikGn4ZeCKt78sTDcIXU6VYWCCim13lbtTqqGDx/Oc889x9KlS/nyyy857rjjANi7dy/JycmdHqAQQgghhOh9zAYtGfGmcIfRqvyUGC6d2h+A//5QwC8FVWGOqPP5A8GKgD4ptf6b2p1UPfjggzz//PMcc8wxnH322YwePRqADz/8MDQsUAghhBBCiEOVEmMk1qQLdxitmj40ndnD0lGBv3+xmbJad7hD6nQeX4CCSgdqDxzi2JkUtQPvkN/vx2azkZiYGNq2a9cuLBYLaWlpnRpgd7PZbMTHx1NTU0NcXFy4wxFCCCGE6NV8/gBbS+vw+SPzoN7jC3D9u6vZXmZncHos9/9uJHptZM4HOxRJMQb6JJjDHUa3a2tu0KG/uKqq/Pzzzzz//PPU1tYCYDAYsFgitwSmEEIIIYSIPjqthuzEyD2YN+g03DhnKFajls0ltT1yYWCAyjoP5XU9ryeus7Q7qdq9ezcjR47k5JNP5rLLLqOsrAwIDgtcsGBBpwcohBBCCCF6t1iTnpRYQ7jDaFVGnIn5MwYDwYWBl2wpC3NEXaO4xtUjS8h3hnYnVVdddRXjxo2jqqoKs7nxrMGpp57K4sWLOzU4IYQQQgghIJi4mA2RO6xuQn4Sp4/NBuCpr7dSUOkIc0SdT1WhoFIqArak3Z/MpUuXcsstt2AwND1b0LdvX/bs2dNpgQkhhBBCCNFAURSyI7jMOsA5R+Qxqk88Lm+ABz7biNPT85KPQAB2VzjwByJzjlu4tDupCgQC+P3NPyBFRUXExsZ2SlBCCCGEEEIcyKTXkhXBxRK0GoUFsweTZDFQWOXk6a+39siqeR5fgN0V9h752jqq3UnVrFmzePzxx0O3FUWhrq6O22+/neOPP74zYxNCCCGEEKKJJKshosusJ1oMXH/cYDQKLNlazmfrisMdUpewu/3srXGFO4yI0e6k6pFHHmH58uUMGzYMl8vF73//+9DQvwcffLArYhRCCCGEECIkO9GMThu54wCHZ8Vz/sS+APxj6Q62l9WFN6AuUlnnoUIqAgIdXKfK5/PxxhtvsGbNGurq6jj88MM555xzmhSuiFayTpUQQgghROSzubzsLo/cYhCqqnLPJxv5YVclmfEmHjtjDFZj5PawdZSiQH6KtUe+Nmh7btChpKonk6RKCCGEECI67Kl2UlnnCXcYrap1ebn6zVWU1rqZ1D+ZG44bghLJlTY6SKtRGJAWg0EXudUZO6qtuUGbUsoPP/ywzU980kkntbmtEEIIIYQQHZUZZ8Lu9uH2BsIdSotiTXqunz2EG99bw/LtFXyydh8njMoKd1idzh9Q2V1hp39qDBpNz0sa26JNPVUaTduyTkVRWqwMGE2kp0oIIYQQIno4PX62l9URyWOv/rdqDy8u24lOo/DQaaMYmN4zK2bHm/XkJlvCHUanamtu0KZsKRAItOkS7QmVEEIIIYSILmaDlrQ4Y7jDOKiTRmcxsV8yvoDKg59vos7tC3dIXaLG6aXU1jsrAva8gY9CCCGEEKJXSYs1YTFqwx1GqxRF4crpA0mPM1Jic/Pk4p65fhVAic1NjdMb7jC6XYfKdNjtdr799lsKCgrweJpODrzyyis7JTAhhBBCCCHaKifRwtbSWgKROb2KGKOOG2YP4fp317BiRwUfrdnLSaP7hDusLlFU5cCoi8Gkj9xEt7O1u/rfr7/+yvHHH4/D4cBut5OUlER5eTkWi4W0tDR27NjRVbF2C5lTJYQQQggRnarsHoqqnOEO46A+XrOX55fsQKdRePC0UQzqofOrDDoN/VOt6LTRPTCuU+dU7e+aa67hxBNPpKqqCrPZzPfff8/u3bsZO3Ysf//73w8paCGEEEIIIToq0WogzhzZ6yXNHZnJpP7B+VUPf74Zew+dX+XxBSiscvbYYY4HandStWrVKq699lo0Gg1arRa3201OTg4PPfQQf/vb37oiRiGEEEIIIdqkT4IZbQSX9VYUhcuPHUharJFim4tnvtnWYxOPOpeP4l5SuKLdSZVerw+VWE9LS6OgoACA+Ph4CgsLOzc6IYQQQggh2kGn1dAn0RzuMA4qxqjj+tlD0GoUlm4t54sNJeEOqcuU13qodkTuAs2dpd1J1WGHHcaPP/4IwNSpU7ntttt4/fXXufrqqxkxYkSnByiEEEIIIUR7xJv1JFj04Q7joAZnxHLekXkAvLBkB7sr7GGOqOsUVTlxeXv20kvtTqruu+8+MjMzAbj33ntJTEzkr3/9K2VlZbzwwgudHqAQQgghhBDtlZVgRq+L3GGAAKcc1ofDcxPx+AM8+PnmHpt4qCrsrnDg80doacZO0O7qfz2dVP8TQgghhOgZ6tw+dpZFdg9QtcPDVW+sotLhYdawdK44dmC4Q+oyMSYdfZMtKEpkJ7v767Lqfwf69ttv+eyzz6iqqjrUhxJCCCGEEKLTxBh1JMcYwh3GQSVYDMyfNQgF+GJDCUu2lIU7pC7TkwtXtDmpevDBB7n11ltDt1VV5bjjjmPatGnMnTuXoUOHsn79+i4JUgghhBBCiI7IiDNh1Ef2WkmjsxM4Y1wOAE9/vY19NZG91tah6KmFK9r8CXvzzTebFKJ45513WLJkCUuXLqW8vJxx48Zx5513dkmQQgghhBBCdIRGo5CTaCHSR5ydPSGX4VlxOL1+Hlq4GW8Pnn/UEwtXtDmp2rlzJ6NGjQrd/vTTT5k3bx6TJk0iKSmJW265hRUrVnRJkEIIIYQQQnSU2aAlLdYY7jAOSqtRuHbmYGKNOraV1fHv73eHO6Qu01C4wh/oOaUd2pxU+Xw+jMbGD+OKFSs46qijQrezsrIoLy/v3OiEEEIIIYToBKmxRsyGyB4GmBpr5MrpwUIV7/+6h18Kem7NAo8vQGGlI9xhdJo2f7L69+/PkiVLACgoKGDLli1MmTIldH9RURHJycmdH6EQQgghhBCHSFEUsqNgGOCR/ZKZMyIDgMcWbemR848a1Lp8lPSQwhVtTqouu+wyLr/8cv74xz8yZ84cJk6cyLBhw0L3f/XVVxx22GFdEqQQQgghhBCHyqSP/GGAAH88Op/cJAvVDi9PLN5KT14BqdTmxubyhjuMQ9bmpOqSSy7hySefpLKykilTpvDuu+82uX/v3r1cdNFFnR6gEEIIIYQQnSUahgEadVqumzUYvVbhp91VfLRmX7hD6lKFlQ7cvuguXCGL/x5AFv8VQgghhOjZXF4/20rriPSj4E/W7OW5JTvQaRQePWM0+Skx4Q6py5j0GvqnxqDRRNb4zG5b/FcIIYQQQohoYtJrSYuL/GGAx4/MZELfJHwBlYc+39zjypDvz+UNsKc6etfnkqRKCCGEEEL0OqkxRswGbbjDOChFUbhy+kCSLAaKqpy8uGxnuEPqUtUOL+V17nCH0SGSVAkhhBBCiF4nWA3QHPHVAOPNeubPHIQCfL6+mO+29+wljIprXFHZIydJlRBCCCGE6JVMei3pcaZwh/GbRuck8LvDswF46qttlNVGZ29OW6gqePyBcIfRboecVNlsNj744AM2btzYGfEIIYQQQgjRbVJjjViMkT0MEOCcI3IZmBZDndvH44u2EIj0Khu9TLuTqjPOOIOnn34aAKfTybhx4zjjjDMYNWpUszLrQgghhBBCRLpoGAao12pYMGswJr2GNXtq+ODXPeEOSeyn3UnVkiVLmDx5MgDvv/8+qqpSXV3Nk08+yT333NPpAQohhBBCCNGVjLroGAaYlWDm4qP7AfDv73ezvawuzBGJBu1OqmpqakhKSgJg4cKFnHbaaVgsFubOncvWrVs7PUAhhBBCCCG6WnBR4MgfBjhrWDpH9guWWX/ki55dZj2atDupysnJYcWKFdjtdhYuXMisWbMAqKqqwmSK/AxfCCGEEEKIlkTDMEBFUbh82kASLXoKq5y8+t2ucIck6EBSdfXVV3POOeeQnZ1NVlYWxxxzDBAcFjhy5MjOjk8IIYQQQohuYdJrSYuN/EWB4816rp4+CIBP1u7jp12VYY5ItDupuvTSS1mxYgUvv/wyy5YtQ6MJPkS/fv1kTpUQQgghhIhqwWGAkb/q0OF5iZw4KhOAJ77aSrXDE+aIercOfWLGjRvHqaeeSkxMTGjb3LlzmTRpUqcFtr9du3bxxz/+kfz8fMxmM/379+f222/H42n64VmzZg2TJ0/GZDKRk5PDQw891CXxCCGEEEKInim4KLAl4ocBApx/VF9ykyxUO7w89dU2VCmzHja69v7C/PnzW9yuKAomk4kBAwZw8sknh4pZdIZNmzYRCAR4/vnnGTBgAOvWreOSSy7Bbrfz97//HQiulzVr1ixmzJjBc889x9q1a7noootISEjgT3/6U6fFIoQQQgghejaTXktqrJFSW2QvsmvUaVkwazDz31rFD7sqWbi+mDkjMsMdVq+kqO1MaadNm8Yvv/yC3+9n8ODBAGzZsgWtVsuQIUPYvHkziqKwbNkyhg0b1iVBAzz88MM8++yz7NixA4Bnn32Wm2++meLiYgwGAwA33ngjH3zwAZs2bWrz49psNuLj46mpqSEuLq5LYhdCCCGEEJFNVVW2ldbh8gbCHcpv+uDXPby0fCcGnYYnzhxDdqIl3CEdkrwUC3EmfbjDANqeG7R7+N/JJ5/MjBkz2Lt3Lz///DM///wzRUVFzJw5k7PPPps9e/YwZcoUrrnmmkN6Ab9l/9LuACtWrGDKlCmhhApg9uzZbN68maqqqlYfx+12Y7PZmlyEEEIIIUTvpigKfaKgGiDASWOyGJOTgMcX4NEvt+DzR34i2NO0O6l6+OGHufvuu5tkavHx8dxxxx089NBDWCwWbrvtNn7++edODXR/27Zt46mnnuLPf/5zaFtxcTHp6elN2jXcLi4ubvWx7r//fuLj40OXnJycrglaCCGEEEJEFYtBR0pM5FcD1CgKV08fiNWoZWtpHW/9VBjukHqdDi3+W1pa2mx7WVlZqJcnISGhWRGJltx4440oinLQy4FD9/bs2cNxxx3H6aefziWXXNLe8Ju56aabqKmpCV0KC+VDKIQQQgghgtJijRh0kV8NMDnGyKVTBwDw5k+FbCmpDXNEvUu7C1WcfPLJXHTRRTzyyCOMHz8egB9//JEFCxZwyimnAPDDDz8waNCg33ysa6+9lgsuuOCgbfr16xe6vnfvXqZNm8ZRRx3FCy+80KRdRkYGJSUlTbY13M7IyGj18Y1GI0Zj5J+BEEIIIYQQ3U+jCQ4D3FlmD3cov2nKoFRW7qxgydZyHv1yC4+fOQaTXhvusHqFdidVzz//PNdccw1nnXUWPp8v+CA6Heeffz6PPfYYAEOGDOHFF1/8zcdKTU0lNTW1Tc+7Z88epk2bxtixY3nllVdC62M1mDhxIjfffDNerxe9Pjix7csvv2Tw4MEkJia25yUKIYQQQggREmPUkWjVU2X3hjuU3/SXqf1Zt9fGnmonr363i79M7R/ukHqFdlf/a1BXVxeqvNevX78ma1Z1tj179nDMMceQl5fHP//5T7Taxoy7oReqpqaGwYMHM2vWLG644QbWrVvHRRddxGOPPdaukupS/U8IIYQQQhzIH1DZUlKLzx/5a0H9WlDFbR+uB+DOE4dzeF50dTBEY/W/DidV3enVV1/lwgsvbPG+/cNfs2YNl112GT/++CMpKSlcccUV3HDDDe16LkmqhBBCCCFES2qcXgoqHOEOo02e/3Y7H6/dR5LFwNO/P4zYCElS2qJXJFV2u50HHniAxYsXU1paSiDQtGRjQ+9VtJKkSgghhBBCtGZ3hR2b0xfuMH6Ty+vn6jdXsafaydEDUrh+9mCUaKgPT3QmVe2eU3XxxRfz7bffcu6555KZmRk1fxwhhBBCCCEOVVaCmTp3LYEIXwrKpNcyf+YgrntnNcu2lXNEfhLHDE4Ld1g9VruTqs8++4xPPvmESZMmdUU8QgghhBBCRCy9VkNmvJk9Vc5wh/KbBqXHctb4XP7zQwHPLdnOiD7xUbHuVjRqd9H9xMREkpKSuiIWIYQQ4v/bu/OwqMr+f+DvM8OwDgwiyAyIsijkhuGOG4gLavVIWZpRirmlkpmh6eOGS2mauWSpj30D09zKXB7NvTAlXBEVF1RCcUNcWURZZs7vDx/n18TiIMuZ0ffruua6POfcc9+fc2bomk/3RkRk8pzsLGFnZR5Llb/VvDZ8XZV4kK/Fwr0XYAbLKZilcidVM2bMwJQpU5CXZx6T9IiIiIiIKpt7DRuYwywYC7kMH3fxhaVchqQr97E9OUPqkJ5L5R7+N2/ePKSmpsLV1RWenp76PaGeSExMrLTgiIiIiIhMkZWFHLUcrHAzK1/qUJ6qdg1bDGhbF8v3pyHmzzQE1HGERmUjdVjPlXInVWFhYVUQBhERERGReXFRWiH7YSEeFpj4qhUAXvV3Q0LqHSRfz8bCvRfwWVgTyGVm0NVmJsxin6rqxCXViYiIiMhYeQVFSM18IHUYRsnIfoRRa47jYaEWg9p5ISzAXeqQSmSOS6qXe07VE8eOHcOqVauwatUqHD9+/FmrISIiIiIyW7aWFqiptJQ6DKOoHawxqL0XAOCHg5dw5S7XSKgs5U6qMjMzERISgpYtW2LUqFEYNWoUmjdvjs6dO+PWrVtVESMRERERkclydbCGwsI8htJ1a+iKZnVqoFAr4qs956HVcdBaZSh3UvXhhx8iJycHp0+fxt27d3H37l0kJycjOzsbo0aNqooYiYiIiIhMllwmmM3CD4IgYFRIPdhZyXExMxc/H7sidUjPhXInVTt27MC3336LBg0a6M81bNgQ33zzDbZv316pwRERERERmQOVjQIONuVeA04SNZVWGNbRBwCw9sgV/HUrV+KIzF+5kyqdTldsGXUAUCgU0OlMf+UTIiIiIqKq4OZoA9kzr1hQvYJ9XRDoXRNFOhHz95xHoZa/4yui3B97SEgIPvroI1y/fl1/7tq1a/j444/RuXPnSg2OiIiIiMhcKOQyuDpYSx2GUQRBwIhgHzhYW+DSnTysOZwudUhmrdxJ1eLFi5GdnQ1PT0/4+PjAx8cHXl5eyM7Oxtdff10VMRIRERERmQVnpRVsLOVSh2EUR1tLjAiuBwDYkHgVF27mSByR+Sr3wE8PDw8kJiZiz549OHfuHACgQYMG6NKlS6UHR0RERERkbmrXsMHFzFyYw26w7eo5o0N9Z+y/cBsL9l7Agr4vQyE3kzGMJoSb//4DN/8lIiIioorKyHqEWzn5UodhlKyHhYhcnYj7DwvxVvPa6B/oKWk8z/XmvwkJCdi6davBuR9++AFeXl6oVasWhg4divx88/jiEBERERFVpVr2VrC0MI8eH5WNAsODH68GyGGAz8boT3r69Ok4ffq0/vjUqVMYNGgQunTpgvHjx+O///0vZs2aVSVBEhERERGZE5lMgMbRPBatAIC2Po+HAepEYMHeC1wNsJyMTqqSkpIMVvdbu3YtWrdujeXLl2PMmDFYtGgR1q9fXyVBEhERERGZGwdr89m7CgCGdfSBykaB9Lt5WHuEmwKXh9FJ1b179+Dq6qo/3rdvH3r06KE/btmyJa5c4cMnIiIiInpCo7KBIEgdhXFUNgoMD3o8DPDnY1c4DLAcjE6qXF1dkZaWBgAoKChAYmIi2rRpo7+ek5NT4qbAREREREQvKksL89m7Cvj/qwHqRGAhhwEazeikqmfPnhg/fjz279+PCRMmwNbWFh06dNBfP3nyJHx8fKokSCIiIiIic+WstIS1wjwWrQD+/zDAy3fzsI7DAI1i9Kc7Y8YMWFhYICgoCMuXL8fy5cthaWmpv/7999+jW7duVRIkEREREZG5EgQB7jVspA7DaH8fBvjTsSu4mJkrcUSmr9z7VGVlZUGpVEIuN9wp+u7du1AqlQaJljniPlVEREREVBWu3svDvQeFUodhtDk7z2H/hduo62SL+dW4KfBzvU/VEyqVqlhCBQBOTk5mn1AREREREVUVjcoGcpmZrFoBw2GAPx+7KnU4Js18BncSEREREZkxuUyARmU+i1aobBQY1tEbALD+6BVcuv1A4ohMF5MqIiIiIqJqUsPOEnZWxUd9mar29ZzR2ssJRToRC3+7AK2uXDOHXhhMqoiIiIiIqpGbo/nsXSUIAoYH+cDOUo6LmbnYnHRN6pBMEpMqIiIiIqJqZK2Qw1lpJXUYRquptMLg9o+HAf54KB3X7j2UOCLTw6SKiIiIiKia1bK3gsLCTLqrAHRuUAsBHo4o0Orw9e8XoCvfAuLPPSZVRERERETVTCYToHEwn72rBEHAyE71YK2Q4fT1bGw/dUPqkEwKkyoiIiIiIgmobBVQWltIHYbRXB2sMSDQEwCwIuEyMrMfSRuQCWFSRUREREQkETdHa7NZtAIAejbRoKHGAQ8LtVj8+0WIHAYIgEkVEREREZFkrCzkcLE3n0UrZIKAD0PqQSEXcPzKfew9myl1SCaBSRURERERkYRclOa1aEXtGrYIb10XAPBd/F+4+6BA4oikx6SKiIiIiEhCMpkAjcp8Fq0AgLCX3VHPRYkH+Vr8549UqcORHJMqIiIiIiKJqWwUsDejRSvkssfDAGUCEJ96Bwl/3ZE6JEkxqSIiIiIiMgEaM1u0wttFiTcCagMAlu5LxYP8Iokjkg6TKiIiIiIiE2BlIUctM1q0AgDebuUBjcoadx8UYEXCJanDkQyTKiIiIiIiE+GstIKlhfn8RLeykOPDTvUAANuTM3D6epbEEUnDfD4xIiIiIqLnnEwmQONoLXUY5dKktiNCG7oCAL7+7SIKinQSR1T9mFQREREREZkQB2vzWrQCACLaeaGGrQLX7j/EuqNXpA6n2jGpIiIiIiIyMea2aIXSygIfBPkAADYkXkXa7QcSR1S9mFQREREREZkYKws5nJXmtWhFWx9nBHrXhFYn4uvfLkCrE6UOqdowqSIiIiIiMkG17K2gsDCj7ioAwzp6w85SjguZudh68rrU4VQbJlVERERERCZIJhOgcbCROoxyqam0wsB2XgCAlQcv42b2I4kjqh5MqoiIiIiITJTKVgE7K7nUYZRL14auaOTmgPwiHZbuS4UoPv/DAJlUERERERGZMDdHG7NatEImCBjZqR4sZAKOXr6HAxdvSx1SlWNSRURERERkwqwVcjjZWUodRrl41LBFnxYeAID/7P8LuY+KJI6oajGpIiIiIiIyca4O1pDLzKi7CsCbzWujdg0b3M8rRGzCJanDqVJMqoiIiIiITJxcJkCtspY6jHJRyGUYGVwPALDzdAZOX8+SOKKqw6SKiIiIiMgMONlZwsbSvBataOyuQteGrgCAb+JSUajVSRxR1WBSRURERERkJtwczau3CgAGtvWEo40CV+7m4ZfEq1KHUyWYVBERERERmQlbSws42iqkDqNc7K0VGNzBGwCw7ugVXLv3UOKIKh+TKiIiIiIiM6JWWZvVEusA0LG+M5rVcUShVsS3cRefu72rmFQREREREZkRhVyGWg5WUodRLoIgYHhwPVhayHDyWhZ+O5cpdUiVikkVEREREZGZcVFawdLCvH7Kqx2s8U6rOgCA/zuQhqyHhRJHVHnM65MgIiIiIiIIgvktsQ4AvZq6wcvZDjn5RYiJT5M6nErDpIqIiIiIyAypbBRQWltIHUa5WMhlGBHsAwHA3nOZOHXt+di7ikkVEREREZGZ0pjhohUvqR3QvbEaALAk7uJzsXcVkyoiIiIiIjNlrZDDyc5S6jDKrX+b/+1dde8hNh6/JnU4FcakioiIiIjIjLk6WEMuM6/uKqW1BQa19wIArDtyBRlZjySOqGKYVBERERERmTG5TICrmS2xDgBBvi5oWluFAq0OS/almvXeVUyqiIiIiIjMnJOdJawV5vXTXhAEDA+qBwuZgMT0e/gz9Y7UIT0z83ryRERERERUjCAI0DjaSB1GubnXsMFbzWsDAP6z/y/kFRRJHNGzYVJFRERERPQcUFpZwMHGvJZYB4A3m3tAo7LG3QcFWHXwstThPBMmVUREREREzwm1GS6xbmkhw/AgHwDAtlM3cOZ6tsQRlR+TKiIiIiKi54SVhRw1lea3xHpAnRroWN8FOhGYsfUMtDrzWrSCSRURERER0XOklr35LbEOAIPbe8HOUo7T17PNbhggkyoiIiIioueIuS6xXsPOEv0DPRFQxxGBPjWlDqdczG8mGxERERERlcnJzhJ3HhQgv1AndSjl0r2xGsOCvOFoa15DGNlTRURERET0nBEEARqVtdRhlJtMECAzw6GLTKqIiIiIiJ5D9tYK2FtzYFp1YFJFRERERPScMscl1s0RkyoiIiIioueUtUIOJzvzmp9kjswmqfrXv/6FOnXqwNraGhqNBu+99x6uX79uUObkyZPo0KEDrK2t4eHhgTlz5kgULRERERGRaahlbwWZ2fzqN09m83g7deqE9evXIyUlBRs2bEBqairefPNN/fXs7Gx069YNdevWxbFjxzB37lxER0fjP//5j4RRExERERFJy0Iug6uD+S1aYU4EURTNa7vi/9myZQvCwsKQn58PhUKBJUuWYOLEicjIyICl5eMuzvHjx2PTpk04d+6c0fVmZ2dDpVIhKysLDg4OVRU+EREREVG1EUUR52/moqDI9JdYr+tsCwdrhdRhADA+NzCbnqq/u3v3Ln788Ue0bdsWCsXjB56QkICOHTvqEyoACA0NRUpKCu7du1dqXfn5+cjOzjZ4ERERERE9TwRBgNoMl1g3F2aVVH366aews7NDzZo1kZ6ejs2bN+uvZWRkwNXV1aD8k+OMjIxS65w1axZUKpX+5eHhUTXBExERERFJSGWjgK2VXOownkuSJlXjx4+HIAhlvv4+dG/s2LE4fvw4du3aBblcjv79+6OioxcnTJiArKws/evKlSsVvS0iIiIiIpNkjhsCmwNJdwP75JNPEBERUWYZb29v/b+dnZ3h7OwMX19fNGjQAB4eHjh48CACAwOhVqtx8+ZNg/c+OVar1aXWb2VlBSsrq2e/CSIiIiIiM2FraQFHWwXu5xVKHcpzRdKkysXFBS4uLs/0Xp3u8SS7/Px8AEBgYCAmTpyIwsJC/Tyr3bt3w8/PDzVq1KicgImIiIgqmVarRWEhf+BS9VFZAvdyCgATXa6uIF+GR9BWS1sKhQJyecWHRJrF6n+HDh3CkSNH0L59e9SoUQOpqamYPHkybt68idOnT8PKygpZWVnw8/NDt27d8OmnnyI5ORnvv/8+5s+fj6FDhxrdFlf/IyIiouogiiIyMjJw//59qUOhF5BWJ0KrM800wEIuQCYI1daeo6Mj1Go1hBLaNDY3kLSnyli2trb45ZdfMHXqVDx48AAajQbdu3fHpEmT9EP3VCoVdu3ahZEjR6J58+ZwdnbGlClTypVQEREREVWXJwlVrVq1YGtrW+IPOqKqIooi8gt1EE2wu8rSQgZ5NexWLIoi8vLykJmZCQDQaDTPXJdZ9FRVJ/ZUERERUVXTarU4f/48atWqhZo1a0odDr2girQ6FGhNb98qq2pKqp64c+cOMjMz4evrW2wo4HO9TxURERGROXsyh8rW1lbiSOhFJpdV7zA7U/Xk77AicxuZVBERERFJhEP+SEqCIEAh53ewMv4OmVQREREREb2g5DIZ5EzuK4xJFRERERFVm9jYWDg6OkoaQ3BwMEaPHl2lbURERCAsLMyospcuXYIgCEhKSqrSmEqjkFdOSmBnpcB/N28GAFy+dAl2VgqcOJH0zPVJ/VzKg0kVERERERnl1q1bGD58OOrUqQMrKyuo1WqEhoYiPj5e6tD0YmNjIQhCsZe1tXWVtFfaD/+FCxciNjbWqDo8PDxw48YNNG7cGAAQFxcHQRAqZbn9vz8DlUqFdu3a4bfffjMoI5MJsJBVbm9VbQ8PpF6+gkaNGhtVfujg99H3zd4G5/75XEyZWSypTkRERETS6927NwoKCrBixQp4e3vj5s2b2Lt3L+7cuSN1aAYcHByQkpJicK6656+pVCqjy8rlcqjV6iqLJSYmBt27d8ft27cxceJEvPrqq0hOToa3t7e+jIVcBq1Oi4LCQigUigq3WRn3VNXPpTKxp4qIiIiInur+/fvYv38/vvjiC3Tq1Al169ZFq1atMGHCBPzrX//Sl/vqq6/QpEkT2NnZwcPDAyNGjEBubm6ZdW/evBnNmjWDtbU1vL29MW3aNBQVFQF4vJdQdHS0vnfMzc0No0aNKrM+QRCgVqsNXq6urqWWX7lyJVq0aAF7e3uo1Wq88847+r2LAODevXsIDw+Hi4sLbGxsUL9+fcTExAAAvLy8AAABAQEQBAHBwcEAig//0+l0mDNnDurVqwcrKyvUqVMHn332GQDD3q5Lly6hU6dOAIAaNWpAEARERETghx9+QM2aNZGfn28Qe1hYGN57770yn8eTzW0bN26MJUuW4OHDh9i9e7f+WS1ZsgRhvXrBuYYKc2bPAgBs3bIFbVu3hJODEo38fPH5zBn6zwQALl64gG6dO8HJQYnmTf2xd88egzZLGv535sxp9A7rBbWzE1xr1kDXkGD8lZqKz2ZMx48rV2Lrf7fAzkoBC7kccXFxJfYC7tu3D61atYKVlRU0Gg3Gjx9vEFdwcDBGjRqFcePGwcnJCWq1GtHR0WU+n8rAnioiIiIiiYmiiIeFWknatlHIjerFUSqVUCqV2LRpE9q0aQMrK6sSy8lkMixatAheXl7466+/MGLECIwbNw7ffvttieX379+P/v37Y9GiRejQoQNSU1MxdOhQAMDUqVOxYcMGzJ8/H2vXrkWjRo2QkZGBEydOPPsNl6CwsBAzZsyAn58fMjMzMWbMGERERODXX38FAEyePBlnzpzB9u3b4ezsjIsXL+Lhw4cAgMOHD6NVq1bYs2cPGjVqBEtLyxLbmDBhApYvX4758+ejffv2uHHjBs6dO1esnIeHBzZs2IDevXsjJSUFDg4OsLGxgaWlJUaNGoUtW7bgrbfeAgBkZmZi27Zt2LVrl9H3amNjAwAoKCjQn4uOjsbs2bMxf/58aEUZ4g8cwJBBAzH3q/lo1649/vrrL3w4YjgA4N+TJkOn06Ff37dQq5Yr4g7EIzsrG+OixpTZ7vVr1xDaOQQdOgbh1527YG/vgISEP1FUVISPPh6DlHPnkJ2djaXLv4OVhQwuzs64fv26QR3Xrl1Dz5499UnmuXPnMGTIEFhbWxskTitWrMCYMWNw6NAhJCQkICIiAu3atUPXrl2Nfk7lxaSKiIiISGIPC7VoOGWnJG2fmR4KW8un/yS0sLBAbGwshgwZgqVLl6JZs2YICgrC22+/DX9/f325vy8A4enpiZkzZ+KDDz4oNamaNm0axo8fjwEDBgAAvL29MWPGDIwbNw5Tp05Feno61Go1unTpAoVCgTp16qBVq1ZlxpqVlQWlUmlwrkOHDti+fXuJ5d9//339v729vbFo0SK0bNkSubm5UCqVSE9PR0BAAFq0aKG/rydcXFwAADVr1ix1qFpOTg4WLlyIxYsX6+/Tx8cH7du3L1ZWLpfDyckJAFCrVi2DRT3eeecdxMTE6JOqVatWoU6dOvresafJy8vDpEmTIJfLERQUZFDvwIEDAQCFWh2GDR2MMWPH4d33+gMAvLy9MTk6GpP+PQH/njQZv+3di/MpKdiy9Vdo3NwAANHTZ+L1f71aatvLli6Bg4MKK1b9qB9eWN/XV3/d2sYa+fn5UKvVpW7+++2338LDwwOLFy+GIAh46aWXcP36dXz66aeYMmUKZP97j7+/P6ZOnfq4jfr1sXjxYuzdu5dJFRERERFJr3fv3njllVewf/9+HDx4ENu3b8ecOXPw3XffISIiAgCwZ88ezJo1C+f+1/NQVFSER48eIS8vr8TNjk+cOIH4+Hj9UDgA0Gq1+ve89dZbWLBgAby9vdG9e3f07NkTr732GiwsSv8Za29vj8TERINzT3poSnLs2DFER0fjxIkTuHfvHnQ6HQAgPT0dDRs2xPDhw9G7d28kJiaiW7duCAsLQ9u2bY1+bmfPnkV+fj46d+5s9HtKMmTIELRs2RLXrl2Du7s7YmNjERER8dSexn79+kEul+Phw4dwcXHB//3f/xkkwk+SRQCwkAlIPnUSBxP+xNz/DQUEDD+TlHNnUbu2hz6hAoDWbdqUGcPJEyfQtn27Cs3XOnv2LAIDAw3ut127dsjNzcXVq1dRp04dADC4NwDQaDQGwzmrApMqIiIiIonZKOQ4Mz1UsrbLw9raGl27dkXXrl0xefJkDB48GFOnTkVERAQuXbqEV199FcOHD8dnn30GJycnHDhwAIMGDUJBQUGJSVVubi6mTZuGN954o8S2PDw8kJKSgj179mD37t0YMWIE5s6di3379pX6A10mk6FevXpG3c+DBw8QGhqK0NBQ/Pjjj3BxcUF6ejpCQ0P1Q+R69OiBy5cv49dff8Xu3bvRuXNnjBw5El9++aVRbZSV0JVHQEAAmjZtih9++AHdunXD6dOnsW3btqe+b/78+ejSpQtUKpW+Z+3v7Ozs9P8WBAG5ubmYOHkqepWwJPyzrqJYWc/AGP/8XgiCoE+UqwqTKiIiIiKJCYJg1BA8U9SwYUNs2rQJwOMeH51Oh3nz5umHYq1fv77M9zdr1gwpKSllJkE2NjZ47bXX8Nprr2HkyJF46aWXcOrUKTRr1qzC8Z87dw537tzB7Nmz4eHhAQA4evRosXIuLi4YMGAABgwYgA4dOmDs2LH48ssv9XOotNrS58TVr18fNjY22Lt3LwYPHvzUmMqqc/DgwViwYAGuXbuGLl266GMui1qtNjrJBB5/JhcvnIdPKe/xe6kBrl69ghs3bkCj0QAADh86VGadjZs0wY8rV6KwlNUFLRWWZT5DAGjQoAE2bNgAURT1vVXx8fGwt7dH7dq1jbm1KmOef71EREREVK3u3LmDt956C++//z78/f1hb2+Po0ePYs6cOejVqxcAoF69eigsLMTXX3+N1157DfHx8Vi6dGmZ9U6ZMgWvvvoq6tSpgzfffBMymQwnTpxAcnIyZs6cidjYWGi1WrRu3Rq2trZYtWoVbGxsULdu3VLrFEURGRkZxc7XqlVLn+w9UadOHVhaWuLrr7/GBx98gOTkZMyYMaNYjM2bN0ejRo2Qn5+PrVu3okGDBvo6bWxssGPHDtSuXRvW1tbFllO3trbGp59+inHjxsHS0hLt2rXDrVu3cPr0aQwaNKhYnHXr1oUgCNi6dSt69uwJGxsb/Ryxd955B1FRUVi+fDl++OGHMp/ts3rymdT28EDYG29AJshw6tRJnDl9GlOnTUdI586oX98XQwe9j89mz0ZOdg6mTZ1cZp3Dho/A0m+/wYB3wxE1bhwcHFQ4fPgQWrRoCV8/P9T19MSePbtxPiUFGlcXONWoUayOESNGYMGCBfjwww8RGRmJlJQUTJ06FWPGjCn2uVY3LqlORERERE+lVCrRunVrzJ8/Hx07dkTjxo0xefJkDBkyBIsXLwYANG3aFF999RW++OILNG7cGD/++CNmzZpVZr2hoaHYunUrdu3ahZYtW6JNmzaYP3++PmlydHTE8uXL0a5dO/j7+2PPnj3473//i5o1a5ZaZ3Z2NjQaTbFXSfNqXFxcEBsbi59++gkNGzbE7Nmziw3rs7S0xIQJE+Dv74+OHTtCLpdj7dq1AB4v4LFo0SIsW7YMbm5u+gTznyZPnoxPPvkEU6ZMQYMGDdC3b99S5/m4u7vrF/BwdXVFZGSk/ppKpULv3r2hVCoNlmyvTE8+k717dqNj20B06tgeixct1M9ZkslkWLP+Jzx69BBB7dpi5AfDMHXajDLrrFmzJrbt3IXc3FyEdumM9oGtEfv9/+l7rSLeH4T69X3RoW0bqF1dS9xQ2t3dHb/++isOHz6Mpk2b4oMPPsCgQYMwadKkyn8I5SSIoihKHYQpyc7OhkqlQlZWFhwcHKQOh4iIiJ5Djx49QlpaGry8vJ55jgq9uDp37oxGjRph0aJFVdqOTifiUVH1L/Vf2up/VaWsv0djcwMO/yMiIiIiMgP37t1DXFwc4uLiSl2ivjLJZAIsZAKKdOyDeRomVUREREREZiAgIAD37t3DF198AT8/v2pp00Iug1anBdOqsjGpIiIiIiIyA5cuXar2NmWCAAuZDIVVvCS5ueNCFUREREREVCoLuYCytxcmJlVERERERFQqQRBgIWfaUBY+HSIiIiIiKpOFTNBvuEvFMakiIiIiIqIyCYIAhYxJVWmYVBERERER0VPJZQJk7K0qEZMqIiIiIiJ6qsdzq5hUlYRJFRERERE9Vy5dugRBEJCUlFSl7QiCgE2bNhlVNjo6Gi+//HKVxlMdLGQy9laVgEkVERERET2VIAhlvqKjo6stluDg4BJj+OCDD6qkvdISohs3bqBHjx5G1REVFYW9e/fqjyMiIhAWFlZJEVYvBVcCLIab/xIRERHRU924cUP/73Xr1mHKlClISUnRn1Mqlfp/i6IIrVYLC4uq+6k5ZMgQTJ8+3eCcra1tlbVXErVabXRZpVJp8IzMmVwmQC4I0Iqi1KGYDKaZRERERKai4EHpr8JH5Sj70Liy5aBWq/UvlUoFQRD0x+fOnYO9vT22b9+O5s2bw8rKCgcOHCixN2b06NEIDg7WH+t0OsyaNQteXl6wsbFB06ZN8fPPPz81HltbW4OY1Go1HBwcSiyr1WoxaNAgfRt+fn5YuHChQZm4uDi0atUKdnZ2cHR0RLt27XD58mXExsZi2rRpOHHihL5HLDY2FkDx4X9Xr15Fv3794OTkBDs7O7Ro0QKHDh0CYNjbFR0djRUrVmDz5s36OuPi4hASEoLIyEiDuG7dugVLS0uDXi5TwN4qQ+ypIiIiIjIVn7uVfq1+NyD8p/9/PLceUJhXctm67YGB2/7/8YImQN6d4uWis54tzlKMHz8eX375Jby9vVGjRg2j3jNr1iysWrUKS5cuRf369fHHH3/g3XffhYuLC4KCgiolLp1Oh9q1a+Onn35CzZo18eeff2Lo0KHQaDTo06cPioqKEBYWhiFDhmDNmjUoKCjA4cOHIQgC+vbti+TkZOzYsQN79uwBAKhUqmJt5ObmIigoCO7u7tiyZQvUajUSExOh0+mKlY2KisLZs2eRnZ2NmJgYAICTkxMGDx6MyMhIzJs3D1ZWVgCAVatWwd3dHSEhIZXyLCqLTCbAQiagSMfeKoBJFRERERFVkunTp6Nr165Gl8/Pz8fnn3+OPXv2IDAwEADg7e2NAwcOYNmyZWUmVd9++y2+++47g3PLli1DeHh4sbIKhQLTpk3TH3t5eSEhIQHr169Hnz59kJ2djaysLLz66qvw8fEBADRo0EBfXqlUwsLCoszhfqtXr8atW7dw5MgRODk5AQDq1atXYlmlUgkbGxvk5+cb1PnGG28gMjISmzdvRp8+fQAAsbGxiIiIMMmNdy3kMmh1WjCtYlJFREREZDr+fb30a4Lc8HjsxTLK/mNo1uhTzx5TObRo0aJc5S9evIi8vLxiiVhBQQECAgLKfG94eDgmTpxocM7V1bXU8t988w2+//57pKen4+HDhygoKNAPx3NyckJERARCQ0PRtWtXdOnSBX369IFGozH6XpKSkhAQEKBPqJ6FtbU13nvvPXz//ffo06cPEhMTkZycjC1btjxznVVJJgiQy2QoKqE37kXDpIqIiIjIVFjaSV+2AuzsDNuRyWQQ/7GYQWFhof7fubm5AIBt27bB3d3doNyT4W+lUalUpfYE/dPatWsRFRWFefPmITAwEPb29pg7d65+vhMAxMTEYNSoUdixYwfWrVuHSZMmYffu3WjTpo1RbdjY2BhV7mkGDx6Ml19+GVevXkVMTAxCQkJQt27dSqm7KijkArQ6vPC9VUyqiIiIiKhKuLi4IDk52eBcUlISFAoFAKBhw4awsrJCenp6pc2fKkl8fDzatm2LESNG6M+lpqYWKxcQEICAgABMmDABgYGBWL16Ndq0aQNLS0totdoy2/D398d3332Hu3fvGtVbVVqdTZo0QYsWLbB8+XKsXr0aixcvNuIOpfN4Q2AZCrUvdm8Vl+0gIiIioioREhKCo0eP4ocffsCFCxcwdepUgyTL3t4eUVFR+Pjjj7FixQqkpqYiMTERX3/9NVasWFFm3Xl5ecjIyDB43bt3r8Sy9evXx9GjR7Fz506cP38ekydPxpEjR/TX09LSMGHCBCQkJODy5cvYtWsXLly4oJ9X5enpibS0NCQlJeH27dvIz88v1ka/fv2gVqsRFhaG+Ph4/PXXX9iwYQMSEhJKjMnT0xMnT55ESkoKbt++bdCDN3jwYMyePRuiKOL1118v8zmYAguZAAGmN+erOjGpIiIiIqIqERoaismTJ2PcuHFo2bIlcnJy0L9/f4MyM2bMwOTJkzFr1iw0aNAA3bt3x7Zt2+Dl5VVm3cuXL4dGozF49evXr8Syw4YNwxtvvIG+ffuidevWuHPnjkGvla2tLc6dO4fevXvD19cXQ4cOxciRIzFs2DAAQO/evdG9e3d06tQJLi4uWLNmTbE2LC0tsWvXLtSqVQs9e/ZEkyZNMHv2bMjl8mJlgcf7bPn5+aFFixZwcXFBfHy8/lq/fv1gYWGBfv36wdrausznYAoe91a92EmVIP5zoOsLLjs7GyqVCllZWaXudUBERERUEY8ePUJaWhq8vLzM4kczVa9Lly7Bx8cHR44cQbNmzaQOxyiiKOJRka7YHLpnYWUhg1xWfX0/Zf09GpsbcE4VEREREZEJKCwsxJ07dzBp0iS0adPGbBIq4HFvlUImoED7YvbXcPgfEREREZEJiI+Ph0ajwZEjR7B06VKpwyk3uUyAzAT306oO7KkiIiIiIjIBwcHBlTJ8TiqCIMDiBe2tYk8VERERERFVCgu57IXsrWJSRURERERElUbxAq4EyKSKiIiIiIgqjVwmg/wF661iUkVERERERJXKQv5ipRkv1t0SEREREVGVk8uEF6q3ikkVERERERFVOsUL1Fv14twpEREREdEzio6OhqurKwRBwKZNmxAREYGwsLAy3xMcHIzRo0dXS3ymSCYTIJdVT2+V1M+a+1QRERERmYhTV7Oqtb0mtVXlKp+Tk4PJkydj48aNyMzMREBAABYuXIiWLVvqy0RERGDFihUG7wsNDcWOHTsAAPn5+Rg8eDA2b94MtVqNb7/9Fl26dNGXnTt3LtLT0/H1118/NZ7s7Gx88cUX2LBhAy5dugRHR0c0btwYI0aMwOuvvw6hkoafnT17FtOmTcPGjRvRpk0b1KhRA506dTLrPaX+ThAEbNy48alJ4hOxsbEYPXo07t+//9SyCpkMOp0Wz8eTKh2TKiIiIiIyyuDBg5GcnIyVK1fCzc0Nq1atQpcuXXDmzBm4u7vry3Xv3h0xMTH6YysrK/2///Of/+DYsWNISEjA9u3b8c477+DmzZsQBAFpaWlYvnw5jh49+tRY7t+/j/bt2yMrKwszZ85Ey5YtYWFhgX379mHcuHEICQmBo6Njpdx3amoqAKBXr176RO3v90Sle9JbVaQrnlZptVoIggCZzPwHz5n/HRARERFRlXv48CE2bNiAOXPmoGPHjqhXrx6io6NRr149LFmyxKCslZUV1Gq1/lWjRg39tbNnz+Jf//oXGjVqhJEjR+LWrVu4ffs2AGD48OH44osv4ODg8NR4/v3vf+PSpUs4dOgQBgwYgIYNG8LX1xdDhgxBUlISlEolAODevXvo378/atSoAVtbW/To0QMXLlzQ1xMbGwtHR0fs3LkTDRo0gFKpRPfu3XHjxg0Aj4f9vfbaawAAmUymT6r+OfzvwYMH6N+/P5RKJTQaDebNm1cs5vz8fERFRcHd3R12dnZo3bo14uLijI7lie+//x6NGjWClZUVNBoNIiMj9dfu37+PwYMHw8XFBQ4ODggJCcGJEyee+jyfuHTpEgRBwC+//IJOnTrB1tYWTZs2RUJCAgAgLi4OAwcORFZWFgRBgCAIiI6OLvP+LOQyCABW/rACbrWcse2//0Xzpv6oYW+H2O//D04OymK9Xh999BFCQkIAAHfu3EG/fv3g7u4OW1tbNGnSBGvWrDH6nqoDkyoiIiIieqqioiJotVpYW1sbnLexscGBAwcMzsXFxaFWrVrw8/PD8OHDcefOHf21pk2b4sCBA3j48CF27twJjUYDZ2dn/Pjjj7C2tsbrr7/+1Fh0Oh3Wrl2L8PBwuLm5FbuuVCphYfF4QFZERASOHj2KLVu2ICEhAaIoomfPnigsLNSXz8vLw5dffomVK1fijz/+QHp6OqKiogAAUVFR+l63GzduFEtwnhg7diz27duHzZs3Y9euXYiLi0NiYqJBmcjISCQkJGDt2rU4efIk3nrrLXTv3t0gySsrFgBYsmQJRo4ciaFDh+LUqVPYsmUL6tWrp7/+1ltvITMzE9u3b8exY8fQrFkzdO7cGXfv3n3qc/27iRMnIioqCklJSfD19UW/fv1QVFSEtm3bYsGCBXBwcNA/jyfxlXZ/qRcvQv6/3qi8vDx8NW8uvlm6FEePn0Dffu9A5eiIzRt/0bet1Wqxbt06hIeHAwAePXqE5s2bY9u2bUhOTsbQoUPx3nvv4fDhw+W6pyolkoGsrCwRgJiVlSV1KERERPScevjwoXjmzBnx4cOHBudPXrlfra/yCgwMFIOCgsRr166JRUVF4sqVK0WZTCb6+vrqy6xZs0bcvHmzePLkSXHjxo1igwYNxJYtW4pFRUWiKIpiQUGBOGLECNHT01Ns0aKFuH//fvHOnTuit7e3mJ6eLk6cOFH08fERu3XrJl69erXEOG7evCkCEL/66qsy4z1//rwIQIyPj9efu337tmhjYyOuX79eFEVRjImJEQGIFy9e1Jf55ptvRFdXV/3xxo0bxX/+bB4wYIDYq1cvURRFMScnR7S0tNTXKYqieOfOHdHGxkb86KOPRFEUxcuXL4tyuVy8du2aQT2dO3cWJ0yYYHQsbm5u4sSJE0u83/3794sODg7io0ePDM77+PiIy5YtK/khiaIIQNy4caMoiqKYlpYmAhC/++47/fXTp0+LAMSzZ8/q41SpVAZ1PO3+tDqduGz5dyIAMeHIUfFBfqH+NSLyQzEouJP++Nft20UrKyvx3r17pcb8yiuviJ988on+OCgoSP+sy6u0v0dRND434JwqIiIiIjLKypUr8f7778Pd3R1yuRzNmjVDv379cOzYMX2Zt99+W//vJk2awN/fHz4+PoiLi0Pnzp2hUCjwzTffGNQ7cOBAjBo1CsePH8emTZtw4sQJzJkzB6NGjcKGDRuKxSEauUDE2bNnYWFhgdatW+vP1axZE35+fjh79qz+nK2tLXx8fPTHGo0GmZmZRrUBPJ5zVVBQYNCOk5MT/Pz89MenTp2CVquFr6+vwXvz8/NRs2ZNo2LJzMzE9evX0blz5xLjOHHiBHJzcw3qAx4P3XwyL8xY/v7+BjE8af+ll14qsfzT7k8mCJAJAiwtLdGkib9Bmbf79UNwh/a4cf06NG5uWLN6NV555RX9nDitVovPP/8c69evx7Vr11BQUID8/HzY2tqW656qEpMqIiIiIjKKj48P9u3bhwcPHiA7OxsajQZ9+/aFt7d3qe/x9vaGs7MzLl68WGIy8Pvvv+P06dP47rvvMHbsWPTs2RN2dnbo06cPFi9eXGKdLi4ucHR0xLlz5yrlvhQKhcGxIAiVvrJfbm4u5HI5jh07BrlcbnDtyfyvp8ViY2Pz1DY0Go3BPK0nyrtox9/jeDKPTKfTldn20+5PLhNgY2NTbFXG5i1awtvbBz+tX4chwz7Apk2bEBsbq78+d+5cLFy4EAsWLECTJk1gZ2eH0aNHo6CgoFz3VJWYVBERERFRudjZ2cHOzg737t3Dzp07MWfOnFLLXr16FXfu3NH3dvzdo0ePMHLkSPz444+Qy+XQarX6BKKwsBBarbbEOmUyGd5++22sXLkSU6dOLTavKjc3F9bW1mjQoAGKiopw6NAhtG3bFsDjRQ9SUlLQsGHDZ739Ynx8fKBQKHDo0CHUqVMHwOMFMs6fP4+goCAAQEBAALRaLTIzM9GhQ4dnasfe3h6enp7Yu3cvOnXqVOx6s2bNkJGRAQsLC3h6ej7z/TyNpaVlsc/GmPsra4n7vv36Yd3aNXCvXRsymQyvvPKK/lp8fDx69eqFd999F8Dj5O78+fOV+hlWFBeqICIiIiKj7Ny5Ezt27EBaWhp2796NTp064aWXXsLAgQMBPE5mxo4di4MHD+LSpUvYu3cvevXqhXr16iE0NLRYfTNmzEDPnj0REBAAAGjXrh1++eUXnDx5EosXL0a7du1KjeWzzz6Dh4cHWrdujR9++AFnzpzBhQsX8P333yMgIAC5ubmoX78+evXqhSFDhuDAgQM4ceIE3n33Xbi7u6NXr16V9lyUSiUGDRqEsWPH4rfffkNycjIiIiIMlgr39fVFeHg4+vfvj19++QVpaWk4fPgwZs2ahW3bthndVnR0NObNm4dFixbhwoULSExM1O/p1aVLFwQGBiIsLAy7du3CpUuX8Oeff2LixIlGLVNvLE9PT+Tm5mLv3r24ffs28vLyynV/JaVWfd/uh6TjxzFn9iz07t3bYMn6+vXrY/fu3fjzzz9x9uxZDBs2DDdv3qy0+6kM7KkiIiIiMhHl3Yy3umVlZWHChAm4evUqnJyc0Lt3b3z22Wf6oWJyuRwnT57EihUrcP/+fbi5uaFbt26YMWNGsX2dkpOTsX79eiQlJenPvfnmm4iLi0OHDh3g5+eH1atXlxqLk5MTDh48iNmzZ2PmzJm4fPkyatSogSZNmmDu3LlQqR4/y5iYGHz00Ud49dVXUVBQgI4dO+LXX38tNsyuoubOnYvc3Fy89tprsLe3xyeffIKsLMPNnGNiYjBz5kx88sknuHbtGpydndGmTRu8+uqrRrczYMAAPHr0CPPnz0dUVBScnZ3x5ptvAnjcE/Trr79i4sSJGDhwIG7dugW1Wo2OHTvC1dW10u61bdu2+OCDD9C3b1/cuXMHU6dORXR0tNH3ZyGXoVBrOJTQp149tGjZEkePHMH8+fMNrk2aNAl//fUXQkNDYWtri6FDhyIsLKzY85WSIFb2gFEzl52dDZVKhaysLKP2SCAiIiIqr0ePHiEtLQ1eXl7Flignet6JoohHhTqIKDkNsbKQ6Zdgrw5l/T0amxtw+B8REREREVUbQRBgIS99fpU5YlJFRERERETVykImQChxdpV5YlJFRERERETV6nnrrWJSRURERERE1c5CJpS5zLo5YVJFRERERETVThAEKGRMqoiIiIiIiJ6ZXCZA9hz0VjGpIiIiIiIiSQiCAIvnoLeKSRUREREREUnmeeitYlJFRERERESSeR56q5hUEREREdFz5dKlSxAEAUlJSVXajiAI2LRpk1Flo6Oj8fLLL1dpPObMQi4z694qC6kDICIiIqL/uX68ettzCzC66NOWvp46dSqio6MrGJBxgoODsW/fvmLnhw0bhqVLl1Z6e9HR0di0aVOxJO3GjRuoUaOGUXVERUXhww8/1B9HRETg/v37RidlpYmNjcXAgQMBPP6MXF1d0bFjR8ydOxd16tSpUN3VzUImoEArSh3GM2FSRURERERPdePGDf2/161bhylTpiAlJUV/TqlU6v8tiiK0Wi0sLKrup+aQIUMwffp0g3O2trZV1l5J1Gq10WWVSqXBM6pMDg4OSElJgSiKSEtLw4gRI/DWW2/h0KFDVdJeVbGQy1CkM8+kisP/iIiIiOip1Gq1/qVSqSAIgv743LlzsLe3x/bt29G8eXNYWVnhwIEDiIiIQFhYmEE9o0ePRnBwsP5Yp9Nh1qxZ8PLygo2NDZo2bYqff/75qfHY2toaxKRWq+Hg4FBiWa1Wi0GDBunb8PPzw8KFCw3KxMXFoVWrVrCzs4OjoyPatWuHy5cvIzY2FtOmTcOJEycgCI83q42NjQVQfPjf1atX0a9fPzg5OcHOzg4tWrTQJzZ/H/4XHR2NFStWYPPmzfo64+LiEBISgsjISIO4bt26BUtLS+zdu7fUZ/Hks9BoNGjbti0GDRqEw4cPIzs7W1/m008/ha+vL2xtbeHt7Y3JkyejsLBQf/1JfCtXroSnpydUKhXefvtt5OTk6Mvk5OQgPDwcdnZ20Gg0mD9/PoKDgzF69Gh9mfz8fERFRcHd3R12dnZo3bo14uLiSo39n8x1bhV7qoiIiIioUowfPx5ffvklvL29jR4WN2vWLKxatQpLly5F/fr18ccff+Ddd9+Fi4sLgoKCKiUunU6H2rVr46effkLNmjXx559/YujQodBoNOjTpw+KiooQFhaGIUOGYM2aNSgoKMDhw4chCAL69u2L5ORk7NixA3v27AEAqFSqYm3k5uYiKCgI7u7u2LJlC9RqNRITE6HT6YqVjYqKwtmzZ5GdnY2YmBgAgJOTEwYPHozIyEjMmzcPVlZWAIBVq1bB3d0dISEhRt1rZmYmNm7cCLlcDrlcrj9vb2+P2NhYuLm54dSpUxgyZAjs7e0xbtw4fZnU1FRs2rQJW7duxb1799CnTx/Mnj0bn332GQBgzJgxiI+Px5YtW+Dq6oopU6YgMTHRYK5YZGQkzpw5g7Vr18LNzQ0bN25E9+7dcerUKdSvX/+p8VvIZdCJ5tdbxaSKiIiIiCrF9OnT0bVrV6PL5+fn4/PPP8eePXsQGBgIAPD29saBAwewbNmyMpOqb7/9Ft99953BuWXLliE8PLxYWYVCgWnTpumPvby8kJCQgPXr16NPnz7Izs5GVlYWXn31Vfj4+AAAGjRooC+vVCphYWFR5nC/1atX49atWzhy5AicnJwAAPXq1SuxrFKphI2NDfLz8w3qfOONNxAZGYnNmzejT58+AB7PmYqIiChzTltWVhaUSiVEUUReXh4AYNSoUbCzs9OXmTRpkv7fnp6eiIqKwtq1aw2SKp1Oh9jYWNjb2wMA3nvvPezduxefffYZcnJysGLFCqxevRqdO3cGAMTExMDNzU3//vT0dMTExCA9PV1/PioqCjt27EBMTAw+//zzUu/h78xxwQomVURERERUKVq0aFGu8hcvXkReXl6xRKygoAABAWUvohEeHo6JEycanHN1dS21/DfffIPvv/8e6enpePjwIQoKCvQ9LE5OToiIiEBoaCi6du2KLl26oE+fPtBoNEbfS1JSEgICAvQJ1bOwtrbGe++9h++//x59+vRBYmIikpOTsWXLljLfZ29vj8TERBQWFmL79u348ccf9b1LT6xbtw6LFi1CamoqcnNzUVRUVGy4pKenpz6hAgCNRoPMzEwAwF9//YXCwkK0atVKf12lUsHPz09/fOrUKWi1Wvj6+hrUm5+fj5o1a5bvYZgZJlVEREREVCn+3jMCADKZDOI/hnL9fR5Pbm4uAGDbtm1wd3c3KPdk+FtpVCpVqT1B/7R27VpERUVh3rx5CAwMhL29PebOnWuwkENMTAxGjRqFHTt2YN26dZg0aRJ2796NNm3aGNWGjY2NUeWeZvDgwXj55Zdx9epVxMTEICQkBHXr1i3zPTKZTP8sGjRogNTUVAwfPhwrV64EACQkJCA8PBzTpk1DaGgoVCoV1q5di3nz5hnUo1AoDI4FQShx+GJpcnNzIZfLcezYMYOhhwCqbJEOU8GkioiIiIiqhIuLC5KTkw3OJSUl6X+8N2zYEFZWVkhPT6+0+VMliY+PR9u2bTFixAj9udTU1GLlAgICEBAQgAkTJiAwMBCrV69GmzZtYGlpCa1WW2Yb/v7++O6773D37l2jeqtKq7NJkyZo0aIFli9fjtWrV2Px4sVG3KGh8ePHw8fHBx9//DGaNWuGP//8E3Xr1jXo2bt8+XK56vT29oZCocCRI0f0S7VnZWXh/Pnz6NixI4DHz0+r1SIzMxMdOnQod9zmzOxW/8vPz8fLL79c4oZuJ0+eRIcOHWBtbQ0PDw/MmTNHmiCJiIiICCEhITh69Ch++OEHXLhwAVOnTjVIsuzt7REVFYWPP/4YK1asQGpqKhITE/H1119jxYoVZdadl5eHjIwMg9e9e/dKLFu/fn0cPXoUO3fuxPnz5zF58mQcOXJEfz0tLQ0TJkxAQkICLl++jF27duHChQv6eVWenp5IS0tDUlISbt++jfz8/GJt9OvXD2q1GmFhYYiPj8dff/2FDRs2ICEhocSYPD09cfLkSaSkpOD27dsGPXiDBw/G7NmzIYoiXn/99TKfQ0k8PDzw+uuvY8qUKfr7T09Px9q1a5GamopFixZh48aN5arT3t4eAwYMwNixY/H777/j9OnTGDRoEGQymX6+l6+vL8LDw9G/f3/88ssvSEtLw+HDhzFr1ixs27at3PdhVkQzM2rUKLFHjx4iAPH48eP681lZWaKrq6sYHh4uJicni2vWrBFtbGzEZcuWlav+rKwsEYCYlZVVyZETERERPfbw4UPxzJkz4sOHD6UO5ZnExMSIKpVKf/z777+LAMR79+4VKztlyhTR1dVVVKlU4scffyxGRkaKQUFB+us6nU5csGCB6OfnJyoUCtHFxUUMDQ0V9+3bV2r7QUFBIoBir9DQUFEURTEtLc3gt+KjR4/EiIgIUaVSiY6OjuLw4cPF8ePHi02bNhVFURQzMjLEsLAwUaPRiJaWlmLdunXFKVOmiFqtVv/+3r17i46OjiIAMSYmRhRFUQQgbty4UR/XpUuXxN69e4sODg6ira2t2KJFC/HQoUOiKIri1KlT9e2JoihmZmaKXbt2FZVKpQhA/P333/XXcnJyRFtbW3HEiBFlfxBi8c/iiYSEBBGAvv2xY8eKNWvWFJVKpdi3b19x/vz5Bu/7Z3yiKIrz588X69atqz/Ozs4W33nnHdHW1lZUq9XiV199JbZq1UocP368vkxBQYE4ZcoU0dPTU1QoFKJGoxFff/118eTJk0+9F6mU9fdobG4giKL5rFm4fft2jBkzBhs2bECjRo1w/Phx/QTDJUuWYOLEicjIyIClpSWAx12fmzZtwrlz54xuIzs7GyqVCllZWaXudUBERERUEY8ePUJaWhq8vLxgbW0tdThkYi5dugQfHx8cOXIEzZo1kzqcUj148ADu7u6YN28eBg0aJHU4z6ysv0djcwOzGf538+ZNDBkyBCtXrixxt+yEhAR07NhRn1ABQGhoKFJSUkrtCgYeDyfMzs42eBERERERVbfCwkJkZGRg0qRJaNOmjcklVMePH8eaNWv0wzSfLF/fq1cviSOTnlkkVaIoIiIiAh988EGpS3VmZGQUW0bzyXFGRkapdc+aNQsqlUr/8vDwqLzAiYiIiIiMFB8fD41GgyNHjmDp0qVSh1OiL7/8Ek2bNkWXLl3w4MED7N+/H87OzlKHJTlJV/8bP348vvjiizLLnD17Frt27UJOTg4mTJhQ6TFMmDABY8aM0R9nZ2czsSIiIiKiahccHFxsCXpTEhAQgGPHjkkdhkmSNKn65JNPEBERUWYZb29v/Pbbb0hISCi2X0GLFi0QHh6OFStWQK1W4+bNmwbXnxyXtfu1lZXVU/dBICIiIiIiKo2kSZWLiwtcXFyeWm7RokWYOXOm/vj69esIDQ3FunXr0Lp1awBAYGAgJk6ciMLCQv3eB7t374afnx9q1KhRNTdAREREVAGm3CtB9KKojL9Ds5hTVadOHTRu3Fj/8vX1BQD4+Pigdu3aAIB33nkHlpaWGDRoEE6fPo1169Zh4cKFBkP7iIiIiEzBk/8BnJeXJ3EkRPTk7/DJ3+WzkLSnqjKpVCrs2rULI0eORPPmzeHs7IwpU6Zg6NChUodGREREZEAul8PR0RGZmZkAAFtbW/0GqkRUPURRRF5eHjIzM+Ho6Ai5XP7MdZnVPlXVgftUERERUXUQRREZGRm4f/++1KEQvdAcHR2hVqtL/B8bxuYGz01PFREREZE5EQQBGo0GtWrVQmFhodThEL2QFApFhXqonmBSRURERCQhuVxeKT/qiEg6ZrFQBRERERERkaliUkVERERERFQBTKqIiIiIiIgqgHOq/uHJYojZ2dkSR0JERERERFJ6khM8bcF0JlX/kJOTAwDw8PCQOBIiIiIiIjIFOTk5UKlUpV7nPlX/oNPpcP36ddjb20u+CV92djY8PDxw5coV7plFRuF3hsqL3xkqD35fqLz4naHyMrXvjCiKyMnJgZubG2Sy0mdOsafqH2QyGWrXri11GAYcHBxM4ktF5oPfGSovfmeoPPh9ofLid4bKy5S+M2X1UD3BhSqIiIiIiIgqgEkVERERERFRBTCpMmFWVlaYOnUqrKyspA6FzAS/M1Re/M5QefD7QuXF7wyVl7l+Z7hQBRERERERUQWwp4qIiIiIiKgCmFQRERERERFVAJMqIiIiIiKiCmBSRUREREREVAFMqkzUN998A09PT1hbW6N169Y4fPiw1CGRCfvjjz/w2muvwc3NDYIgYNOmTVKHRCZs1qxZaNmyJezt7VGrVi2EhYUhJSVF6rDIhC1ZsgT+/v76zTgDAwOxfft2qcMiMzJ79mwIgoDRo0dLHQqZqOjoaAiCYPB66aWXpA7LaEyqTNC6deswZswYTJ06FYmJiWjatClCQ0ORmZkpdWhkoh48eICmTZvim2++kToUMgP79u3DyJEjcfDgQezevRuFhYXo1q0bHjx4IHVoZKJq166N2bNn49ixYzh69ChCQkLQq1cvnD59WurQyAwcOXIEy5Ytg7+/v9ShkIlr1KgRbty4oX8dOHBA6pCMxiXVTVDr1q3RsmVLLF68GACg0+ng4eGBDz/8EOPHj5c4OjJ1giBg48aNCAsLkzoUMhO3bt1CrVq1sG/fPnTs2FHqcMhMODk5Ye7cuRg0aJDUoZAJy83NRbNmzfDtt99i5syZePnll7FgwQKpwyITFB0djU2bNiEpKUnqUJ4Je6pMTEFBAY4dO4YuXbroz8lkMnTp0gUJCQkSRkZEz6usrCwAj38kEz2NVqvF2rVr8eDBAwQGBkodDpm4kSNH4pVXXjH4XUNUmgsXLsDNzQ3e3t4IDw9Henq61CEZzULqAMjQ7du3odVq4erqanDe1dUV586dkygqInpe6XQ6jB49Gu3atUPjxo2lDodM2KlTpxAYGIhHjx5BqVRi48aNaNiwodRhkQlbu3YtEhMTceTIEalDITPQunVrxMbGws/PDzdu3MC0adPQoUMHJCcnw97eXurwnopJFRHRC2zkyJFITk42q3HrJA0/Pz8kJSUhKysLP//8MwYMGIB9+/YxsaISXblyBR999BF2794Na2trqcMhM9CjRw/9v/39/dG6dWvUrVsX69evN4thxkyqTIyzszPkcjlu3rxpcP7mzZtQq9USRUVEz6PIyEhs3boVf/zxB2rXri11OGTiLC0tUa9ePQBA8+bNceTIESxcuBDLli2TODIyRceOHUNmZiaaNWumP6fVavHHH39g8eLFyM/Ph1wulzBCMnWOjo7w9fXFxYsXpQ7FKJxTZWIsLS3RvHlz7N27V39Op9Nh7969HLtORJVCFEVERkZi48aN+O233+Dl5SV1SGSGdDod8vPzpQ6DTFTnzp1x6tQpJCUl6V8tWrRAeHg4kpKSmFDRU+Xm5iI1NRUajUbqUIzCnioTNGbMGAwYMAAtWrRAq1atsGDBAjx48AADBw6UOjQyUbm5uQb/JyctLQ1JSUlwcnJCnTp1JIyMTNHIkSOxevVqbN68Gfb29sjIyAAAqFQq2NjYSBwdmaIJEyagR48eqFOnDnJycrB69WrExcVh586dUodGJsre3r7YPE07OzvUrFmT8zepRFFRUXjttddQt25dXL9+HVOnToVcLke/fv2kDs0oTKpMUN++fXHr1i1MmTIFGRkZePnll7Fjx45ii1cQPXH06FF06tRJfzxmzBgAwIABAxAbGytRVGSqlixZAgAIDg42OB8TE4OIiIjqD4hMXmZmJvr3748bN25ApVLB398fO3fuRNeuXaUOjYieE1evXkW/fv1w584duLi4oH379jh48CBcXFykDs0o3KeKiIiIiIioAjinioiIiIiIqAKYVBEREREREVUAkyoiIiIiIqIKYFJFRERERERUAUyqiIiIiIiIKoBJFRERERERUQUwqSIiIiIiIqoAJlVEREREREQVwKSKiIiIiIioAphUERGRWYqIiEBYWJjUYRARETGpIiIiIiIiqggmVUREZPaCg4MxatQojBs3Dk5OTlCr1YiOjjYoc//+fQwbNgyurq6wtrZG48aNsXXrVv31DRs2oFGjRrCysoKnpyfmzZtn8H5PT0/MnDkT/fv3h1KpRN26dbFlyxbcunULvXr1glKphL+/P44ePWrwvgMHDqBDhw6wsbGBh4cHRo0ahQcPHlTZsyAiourHpIqIiJ4LK1asgJ2dHQ4dOoQ5c+Zg+vTp2L17NwBAp9OhR48eiI+Px6pVq3DmzBnMnj0bcrkcAHDs2DH06dMHb7/9Nk6dOoXo6GhMnjwZsbGxBm3Mnz8f7dq1w/Hjx/HKK6/gvffeQ//+/fHuu+8iMTERPj4+6N+/P0RRBACkpqaie/fu6N27N06ePIl169bhwIEDiIyMrNZnQ0REVUsQn/yXn4iIyIxERETg/v372LRpE4KDg6HVarF//3799VatWiEkJASzZ8/Grl270KNHD5w9exa+vr7F6goPD8etW7ewa9cu/blx48Zh27ZtOH36NIDHPVUdOnTAypUrAQAZGRnQaDSYPHkypk+fDgA4ePAgAgMDcePGDajVagwePBhyuRzLli3T13vgwAEEBQXhwYMHsLa2rpJnQ0RE1Ys9VURE9Fzw9/c3ONZoNMjMzAQAJCUloXbt2iUmVABw9uxZtGvXzuBcu3btcOHCBWi12hLbcHV1BQA0adKk2Lkn7Z44cQKxsbFQKpX6V2hoKHQ6HdLS0p71VomIyMRYSB0AERFRZVAoFAbHgiBAp9MBAGxsbCq9DUEQSj33pN3c3FwMGzYMo0aNKlZXnTp1KiUmIiKSHpMqIiJ67vn7++Pq1as4f/58ib1VDRo0QHx8vMG5+Ph4+Pr66uddPYtmzZrhzJkzqFev3jPXQUREpo/D/4iI6LkXFBSEjh07onfv3ti9ezfS0tKwfft27NixAwDwySefYO/evZgxYwbOnz+PFStWYPHixYiKiqpQu59++in+/PNPREZGIikpCRcuXMDmzZu5UAUR0XOGSRUREb0QNmzYgJYtW6Jfv35o2LAhxo0bp58v1axZM6xfvx5r165F48aNMWXKFEyfPh0REREVatPf3x/79u3D+fPn0aFDBwQEBGDKlClwc3OrhDsiIiJTwdX/iIiIiIiIKoA9VURERERERBXApIqIiIiIiKgCmFQRERERERFVAJMqIiIiIiKiCmBSRUREREREVAFMqoiIiIiIiCqASRUREREREVEFMKkiIiIiIiKqACZVREREREREFcCkioiIiIiIqAKYVBEREREREVXA/wMaS57vBsnB9wAAAABJRU5ErkJggg==", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# Define underlying treatment effect function given DGP\n", - "def gamma_fn(X):\n", - " return -3 - 14 * (X[\"income\"] < 1)\n", - "\n", - "def beta_fn(X):\n", - " return 20 + 0.5 * (X[\"avg_hours\"]) + 5 * (X[\"days_visited\"] > 4)\n", - "\n", - "def demand_fn(data, T):\n", - " Y = gamma_fn(data) * T + beta_fn(data)\n", - " return Y\n", - "\n", - "def true_te(x, n, stats):\n", - " if x < 1:\n", - " subdata = train_data[train_data[\"income\"] < 1].sample(n=n, replace=True)\n", - " else:\n", - " subdata = train_data[train_data[\"income\"] >= 1].sample(n=n, replace=True)\n", - " te_array = subdata[\"price\"] * gamma_fn(subdata) / (subdata[\"demand\"])\n", - " if stats == \"mean\":\n", - " return np.mean(te_array)\n", - " elif stats == \"median\":\n", - " return np.median(te_array)\n", - " elif isinstance(stats, int):\n", - " return np.percentile(te_array, stats)\n", - "\n", - "# Get the estimate and range of true treatment effect\n", - "truth_te_estimate = np.apply_along_axis(true_te, 1, X_test, 1000, \"mean\") # estimate\n", - "truth_te_upper = np.apply_along_axis(true_te, 1, X_test, 1000, 95) # upper level\n", - "truth_te_lower = np.apply_along_axis(true_te, 1, X_test, 1000, 5) # lower level\n", - "\n", - "te_pred = est_dw.effect(X_test).flatten()\n", - "te_pred_interval = est_dw.effect_interval(X_test)\n", - "\n", - "# Compare the estimate and the truth\n", - "plt.figure(figsize=(10, 6))\n", - "plt.plot(X_test.flatten(), te_pred, label=\"Sales Elasticity Prediction\")\n", - "plt.plot(X_test.flatten(), truth_te_estimate, \"--\", label=\"True Elasticity\")\n", - "plt.fill_between(\n", - " X_test.flatten(),\n", - " te_pred_interval[0].flatten(),\n", - " te_pred_interval[1].flatten(),\n", - " alpha=0.2,\n", - " label=\"95% Confidence Interval\",\n", - ")\n", - "plt.fill_between(\n", - " X_test.flatten(),\n", - " truth_te_lower,\n", - " truth_te_upper,\n", - " alpha=0.2,\n", - " label=\"True Elasticity Range\",\n", - ")\n", - "plt.xlabel(\"Income\")\n", - "plt.ylabel(\"Songs Sales Elasticity\")\n", - "plt.title(\"Songs Sales Elasticity vs Income\")\n", - "plt.legend(loc=\"lower right\")" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "No scoring value was given. Using default score method neg_mean_squared_error.\n", - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel().\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Processing estimator: RandomForestRegressor\n", - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n", - "[CV] END .................................................... total time= 1.1s\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel().\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[CV] END .................................................... total time= 0.7s\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel().\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Processing estimator: MLPRegressor\n", - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[CV] END .................................................... total time= 0.3s\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[CV] END .................................................... total time= 0.4s\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Best estimator RandomForestRegressor() and best score -0.007087413279468611 and best params {}\n", - "Processing estimator: RandomForestRegressor\n", - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n", - "[CV] END .................................................... total time= 2.3s\n", - "[CV] END .................................................... total time= 2.3s\n", - "Processing estimator: MLPRegressor\n", - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n", - "[CV] END .................................................... total time= 12.6s\n", - "[CV] END .................................................... total time= 10.5s\n", - "Best estimator RandomForestRegressor() and best score -0.015753967716546576 and best params {}\n", - "Processing estimator: RandomForestRegressor\n", - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel().\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[CV] END .................................................... total time= 0.7s\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel().\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[CV] END .................................................... total time= 0.7s\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples,), for example using ravel().\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Processing estimator: MLPRegressor\n", - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n", - "[CV] END .................................................... total time= 0.2s\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().\n", - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[CV] END .................................................... total time= 0.3s\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Best estimator RandomForestRegressor() and best score -0.006845612318994855 and best params {}\n", - "Processing estimator: RandomForestRegressor\n", - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n", - "[CV] END .................................................... total time= 2.2s\n", - "[CV] END .................................................... total time= 2.1s\n", - "Processing estimator: MLPRegressor\n", - "Fitting 2 folds for each of 1 candidates, totalling 2 fits\n", - "[CV] END .................................................... total time= 12.2s\n", - "[CV] END .................................................... total time= 14.3s\n", - "Best estimator RandomForestRegressor() and best score -0.014455828883075759 and best params {}\n", - "*** Causal Estimate ***\n", - "\n", - "## Identified estimand\n", - "Estimand type: nonparametric-ate\n", - "\n", - "### Estimand : 1\n", - "Estimand name: backdoor\n", - "Estimand expression:\n", - " d \n", - "────────────(E[log_demand|income,friends_count,days_⟨visited,⟩_hours,age,songs\n", - "d[log_price] \n", - "\n", - " \n", - "_purchased,has_membership,is_US,account_age])\n", - " \n", - "Estimand assumption 1, Unconfoundedness: If U→{log_price} and U→log_demand then P(log_demand|log_price,income,friends_count,days_visited,avg_hours,age,songs_purchased,has_membership,is_US,account_age,U) = P(log_demand|log_price,income,friends_count,days_visited,avg_hours,age,songs_purchased,has_membership,is_US,account_age)\n", - "\n", - "## Realized estimand\n", - "b: log_demand~log_price+income+friends_count+days_visited+avg_hours+age+songs_purchased+has_membership+is_US+account_age | income\n", - "Target units: ate\n", - "\n", - "## Estimate\n", - "Mean value: -0.9764341213588181\n", - "Effect estimates: [-1.06939218 -1.44817143 -0.81689907 ... -1.30445479 -1.87209822\n", - " -0.40427838]\n", - "\n" - ] - } - ], - "source": [ - "# initiate an EconML cate estimator\n", - "\n", - "est = LinearDML(model_y=['forest', 'nnet'], model_t=['nnet', 'forest'], scaling=False,\n", - " featurizer=PolynomialFeatures(degree=2, include_bias=False))\n", - "\n", - "# fit through dowhy\n", - "est_dw = est.dowhy.fit(Y, T, X=X, W=W, outcome_names=[\"log_demand\"], treatment_names=[\"log_price\"], feature_names=[\"income\"],\n", - " confounder_names=confounder_names, inference=\"statsmodels\")\n", - "\n", - "lineardml_estimate = est_dw.estimate_\n", - "print(lineardml_estimate)" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 19, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA1kAAAIjCAYAAADxz9EgAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAADy6klEQVR4nOzdd3hb5fXA8e/VHt7biR3Hzt6ThBBIGIEAAQJlFmhYhbaUUSC0hAJhh9BfKS2lZVNaaFllBAqBEGZCSMLI3tNZ3kPWHvf+/pAt79gOtiU75/M8eixdX+keSVfSPfd93/MqmqZpCCGEEEIIIYToFLpoByCEEEIIIYQQvYkkWUIIIYQQQgjRiSTJEkIIIYQQQohOJEmWEEIIIYQQQnQiSbKEEEIIIYQQohNJkiWEEEIIIYQQnUiSLCGEEEIIIYToRJJkCSGEEEIIIUQnkiRLCCGEEEIIITqRJFlCCCF+lD179qAoCv/4xz+iHUqL+vfvz5VXXtkjthvrr6UQQoj2kSRLCCFasH79ei644ALy8vKwWCz07duXU089lSeeeCLaof1oqqryz3/+k8mTJ5OSkkJ8fDyDBw9mzpw5fPPNN9EOr10+//xzFEVp9fLqq692Sxxff/019957L1VVVV22jQ8++IB77723yx6/qyiKwg033BDtMIQQIioM0Q5ACCFizddff81JJ51Ev379uPbaa8nKymLfvn188803/PnPf+bGG2+Mdog/yk033cSTTz7J7NmzueyyyzAYDGzdupUPP/yQgoICjj322GiH2G433XQTxxxzTLPlU6ZM6Zbtf/3119x3331ceeWVJCUlNfrf1q1b0ek6di4zLy8Pj8eD0WiMLPvggw948skne2SiJYQQRytJsoQQoomHHnqIxMREVq9e3ezAuaSkJDpBdZLi4mL+9re/ce211/LMM880+t/jjz9OaWlplCI7MieccAIXXHBBtMNokdls7vB9FEXBYrF0QTRCCCG6k3QXFEKIJnbu3MmIESOaJVgAGRkZjW4Hg0EeeOABBgwYgNlspn///tx55534fL5G6/Xv35+zzjqLZcuWMWnSJCwWCwUFBfzzn/9sto1169Yxffp0rFYrOTk5PPjgg7z44osoisKePXsi63377bfMnDmTtLQ0rFYr+fn5XH311Yd9brt370bTNKZOndrsf4qiNHp+FRUVzJ07l1GjRhEXF0dCQgJnnHEGa9euPew26mzZsoULLriAlJQULBYLEydOZNGiRY3WCQQC3HfffQwaNAiLxUJqairHH388S5Ysadc2jkRHntcTTzzBiBEjsNlsJCcnM3HiRP79738DcO+993L77bcDkJ+fH+mqWPcetTQmq6qqiltuuYX+/ftjNpvJyclhzpw5lJWVAc3HZF155ZU8+eSTAI26Q2qaRv/+/Zk9e3azmL1eL4mJifziF79o9TUYOXIkJ510UrPlqqrSt2/fRonrq6++yoQJE4iPjychIYFRo0bx5z//udXHbk1dF8/XX3+dhx56iJycHCwWC6eccgo7duxotv7KlSs588wzSU5Oxm63M3r06Gbb/fTTTznhhBOw2+0kJSUxe/ZsNm/e3Gide++9F0VR2LZtG5dffjmJiYmkp6dz9913o2ka+/btY/bs2SQkJJCVlcUf//jHZrH4fD7mz5/PwIEDMZvN5Obm8tvf/rbZ51wIIepIS5YQQjSRl5fHihUr2LBhAyNHjjzsuj//+c956aWXuOCCC7jttttYuXIlCxYsYPPmzbz99tuN1t2xYwcXXHAB11xzDVdccQUvvPACV155JRMmTGDEiBEAHDhwgJNOOglFUZg3bx52u53nnnuuWatISUkJp512Gunp6dxxxx0kJSWxZ88e3nrrrTafG8Abb7zBhRdeiM1ma3XdXbt28c4773DhhReSn59PcXExTz/9NNOnT2fTpk306dOn1ftu3LiRqVOn0rdvX+644w7sdjuvv/465557Lv/9738577zzgPAB8IIFC/j5z3/OpEmTcDgcfPvtt3z//feceuqph30uADU1NZEEpaHU1FQURflRz+vZZ5/lpptu4oILLuDmm2/G6/Wybt06Vq5cyaWXXspPfvITtm3bxn/+8x/+9Kc/kZaWBkB6enqL23U6nZxwwgls3ryZq6++mvHjx1NWVsaiRYvYv39/5P4N/eIXv+DgwYMsWbKEf/3rX5HliqJw+eWX8+ijj1JRUUFKSkrkf++99x4Oh4PLL7+81dft4osv5t5776WoqIisrKzI8mXLlnHw4EEuueQSAJYsWcJPf/pTTjnlFBYuXAjA5s2bWb58OTfffHOrj384jzzyCDqdjrlz51JdXc2jjz7KZZddxsqVKyPrLFmyhLPOOovs7GxuvvlmsrKy2Lx5M++//35ku5988glnnHEGBQUF3HvvvXg8Hp544gmmTp3K999/T//+/Zs952HDhvHII4/wv//9jwcffJCUlBSefvppTj75ZBYuXMgrr7zC3LlzOeaYY5g2bRoQTjzPOeccli1bxnXXXcewYcNYv349f/rTn9i2bRvvvPPOEb0OQoheThNCCNHIxx9/rOn1ek2v12tTpkzRfvvb32offfSR5vf7G623Zs0aDdB+/vOfN1o+d+5cDdA+/fTTyLK8vDwN0L788svIspKSEs1sNmu33XZbZNmNN96oKYqi/fDDD5Fl5eXlWkpKigZou3fv1jRN095++20N0FavXt3h5zdnzhwN0JKTk7XzzjtP+7//+z9t8+bNzdbzer1aKBRqtGz37t2a2WzW7r///kbLAO3FF1+MLDvllFO0UaNGaV6vN7JMVVXtuOOO0wYNGhRZNmbMGG3WrFkdfg6fffaZBrR6OXToUGTdvLw87Yorrujw85o9e7Y2YsSIw8bxhz/8odH70lDT7d5zzz0aoL311lvN1lVVNRJH09fy17/+tdbSz/XWrVs1QPv73//eaPk555yj9e/fP/KYLam77xNPPNFo+fXXX6/FxcVpbrdb0zRNu/nmm7WEhAQtGAy2+litAbRf//rXkdt179mwYcM0n88XWf7nP/9ZA7T169drmqZpwWBQy8/P1/Ly8rTKyspGj9nwOY0dO1bLyMjQysvLI8vWrl2r6XQ6bc6cOZFl8+fP1wDtuuuuiywLBoNaTk6OpiiK9sgjj0SWV1ZWalartdH79q9//UvT6XTaV1991SiWp556SgO05cuXd/CVEUIcDaS7oBBCNHHqqaeyYsUKzjnnHNauXcujjz7KzJkz6du3b6Pubh988AEAt956a6P733bbbQD873//a7R8+PDhnHDCCZHb6enpDBkyhF27dkWWLV68mClTpjB27NjIspSUFC677LJGj1XXlfH9998nEAh06Pm9+OKL/PWvfyU/P5+3336buXPnMmzYME455RQOHDgQWc9sNkcKN4RCIcrLy4mLi2PIkCF8//33rT5+RUUFn376KRdddFGkpamsrIzy8nJmzpzJ9u3bI9tJSkpi48aNbN++vUPPoc4999zDkiVLml0atuw01d7nlZSUxP79+1m9evURxdbUf//7X8aMGRNpxWuotVa3wxk8eDCTJ0/mlVdeiSyrqKjgww8/5LLLLjvsYw4ePJixY8fy2muvRZaFQiHefPNNzj77bKxWKxB+DVwuV6d237zqqqswmUyR23WfibrPwQ8//MDu3bv5zW9+06zLbt1zOnToEGvWrOHKK69s9F6PHj2aU089NfLZbOjnP/955Lper2fixIlomsY111wTWZ6UlNTsM/nGG28wbNgwhg4dGtmXy8rKOPnkkwH47LPPjvSlEEL0YpJkCSFEC4455hjeeustKisrWbVqFfPmzaOmpoYLLriATZs2AbB37150Oh0DBw5sdN+srCySkpLYu3dvo+X9+vVrtp3k5GQqKysjt/fu3dvs8YBmy6ZPn87555/PfffdR1paGrNnz+bFF19s1xgRnU7Hr3/9a7777jvKysp49913OeOMM/j0008j3cQg3E3qT3/6E4MGDcJsNpOWlkZ6ejrr1q2jurq61cffsWMHmqZx9913k56e3ugyf/58oL6AyP33309VVRWDBw9m1KhR3H777axbt67N51Bn1KhRzJgxo9ml4UF8U+19Xr/73e+Ii4tj0qRJDBo0iF//+tcsX7683bE1tXPnzja7n3bUnDlzWL58eWRfe+ONNwgEAvzsZz9r874XX3wxy5cvjyS8n3/+OSUlJVx88cWRda6//noGDx7MGWecQU5ODldffTWLFy/+UTE3/RwkJycDRD4HO3fuBDjsa1X3fIcMGdLsf8OGDaOsrAyXy3XY7SYmJmKxWJp100xMTGz0mdy+fTsbN25sti8PHjwY6PnFcIQQXUOSLCGEOAyTycQxxxzDww8/zN///ncCgQBvvPFGo3Xa2wqh1+tbXK5pWofjUhSFN998kxUrVnDDDTdw4MABrr76aiZMmIDT6Wz346SmpnLOOefwwQcfMH36dJYtWxY5gH344Ye59dZbmTZtGi+//DIfffQRS5YsYcSIEaiq2upj1v1v7ty5LbYyLVmyJJI0Tps2jZ07d/LCCy8wcuRInnvuOcaPH89zzz3X4dekvdr7vIYNG8bWrVt59dVXOf744/nvf//L8ccfH0kUY8Ell1yC0WiMtGa9/PLLTJw4scXko6mLL74YTdMi+/Prr79OYmIip59+emSdjIwM1qxZw6JFizjnnHP47LPPOOOMM7jiiiuOOObO/Bz82O22JxZVVRk1alSr+/L111/fZTELIXouKXwhhBDtNHHiRCDcVQnCRSRUVWX79u0MGzYssl5xcTFVVVWRIhMdkZeX12KltZaWARx77LEce+yxPPTQQ/z73//msssu49VXX23UNaq9Jk6cyBdffMGhQ4fIy8vjzTff5KSTTuL5559vtF5VVVWLRRrqFBQUAGA0GpkxY0ab201JSeGqq67iqquuwul0Mm3aNO69994jeg7t0ZHnZbfbufjii7n44ovx+/385Cc/4aGHHmLevHlYLJYOdfMbMGAAGzZs6HC8h9tGSkoKs2bN4pVXXuGyyy5j+fLlPP744+163Pz8fCZNmsRrr73GDTfcwFtvvcW5557brMiKyWTi7LPP5uyzz0ZVVa6//nqefvpp7r777hZbXX+sAQMGALBhw4ZW95+6z9bWrVub/W/Lli2kpaVht9s7LZ61a9dyyimnHFG3TiHE0UlasoQQoonPPvusxbPqdeM86loJzjzzTIBmB7WPPfYYALNmzerwtmfOnMmKFStYs2ZNZFlFRUWjcTcQ7lrVNMa6cVyH6zJYVFQU6e7YkN/vZ+nSpY26P+r1+mbbeOONNxqN22pJRkYGJ554Ik8//XQkIW2o4Vxc5eXljf4XFxfHwIEDu7Q0dnufV9PYTCYTw4cPR9O0yDi4ugP5qqqqNrd7/vnns3bt2mZVJ+HwrThtbeNnP/sZmzZt4vbbb0ev1zfq8tmWiy++mG+++YYXXniBsrKyRl0FoflroNPpGD16NHD4/ezHGD9+PPn5+Tz++OPNnnPd65Sdnc3YsWN56aWXGq2zYcMGPv7448hnszNcdNFFHDhwgGeffbbZ/zweT7NuiUIIAdKSJYQQzdx444243W7OO+88hg4dit/v5+uvv+a1116jf//+XHXVVQCMGTOGK664gmeeeYaqqiqmT5/OqlWreOmllzj33HNbnIeoLb/97W95+eWXOfXUU7nxxhsjJdz79etHRUVF5Ez6Sy+9xN/+9jfOO+88BgwYQE1NDc8++ywJCQmHPcDcv38/kyZN4uSTT+aUU04hKyuLkpIS/vOf/7B27Vp+85vfRFpzzjrrLO6//36uuuoqjjvuONavX88rr7wSaak6nCeffJLjjz+eUaNGce2111JQUEBxcTErVqxg//79kTmphg8fzoknnsiECRNISUnh22+/5c033+SGG25o1+v11Vdf4fV6my0fPXp0JBloqr3P67TTTiMrK4upU6eSmZnJ5s2b+etf/8qsWbOIj48HYMKECQD8/ve/j3TdO/vss1tsRbn99tt58803ufDCCyNdOysqKli0aBFPPfUUY8aMaTHeum3cdNNNzJw5s1kiNWvWLFJTU3njjTc444wzms3ldjgXXXQRc+fOZe7cuaSkpDRrOfr5z39ORUUFJ598Mjk5Oezdu5cnnniCsWPHNmq97Uw6nY6///3vnH322YwdO5arrrqK7OxstmzZwsaNG/noo48A+MMf/sAZZ5zBlClTuOaaayIl3BMTE7n33ns7LZ6f/exnvP766/zyl7/ks88+Y+rUqYRCIbZs2cLrr7/ORx99FGnlFkKIiKjUNBRCiBj24YcfaldffbU2dOhQLS4uTjOZTNrAgQO1G2+8USsuLm60biAQ0O677z4tPz9fMxqNWm5urjZv3rxGpcs1LVzOu6VS5dOnT9emT5/eaNkPP/ygnXDCCZrZbNZycnK0BQsWaH/5y180QCsqKtI0TdO+//577ac//anWr18/zWw2axkZGdpZZ52lffvtt4d9bg6HQ/vzn/+szZw5U8vJydGMRqMWHx+vTZkyRXv22Wcblcj2er3abbfdpmVnZ2tWq1WbOnWqtmLFimYxt1R2XNM0befOndqcOXO0rKwszWg0an379tXOOuss7c0334ys8+CDD2qTJk3SkpKSNKvVqg0dOlR76KGHmpXLb6qtEu7z589v9No3LeHenuf19NNPa9OmTdNSU1M1s9msDRgwQLv99tu16urqRrE88MADWt++fTWdTteonHvT7WpauBz/DTfcoPXt21czmUxaTk6OdsUVV2hlZWWtvpbBYFC78cYbtfT0dE1RlBbLuV9//fUaoP373/8+7OvWkqlTp7Y4FYGmadqbb76pnXbaaVpGRoZmMpm0fv36ab/4xS8alchvDa2UcH/jjTcardfa/rNs2TLt1FNP1eLj4zW73a6NHj26Wcn5Tz75RJs6dapmtVq1hIQE7eyzz9Y2bdrUaJ26Eu6lpaWNll9xxRWa3W5vFvf06dOble73+/3awoULtREjRmhms1lLTk7WJkyYoN13333N9gchhNA0TVM0rYtHmgohhPjRfvOb3/D000/jdDpbHawvjl633HILzz//PEVFRYedYFoIIUT3kDFZQggRYzweT6Pb5eXl/Otf/+L444+XBEs04/V6efnllzn//PMlwRJCiBghY7KEECLG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GTMmT1zdunXjoYce4vHHHyc0NJTIyEgmTpxY6Pm5VKpkVRDHUs8OEzyU6mDhLtfzoS1sGM9ceFfxt2BSWl3BKckSEakonE4np890Cy5rvmZTkao8AQEBBAQEsGDBAjp06IDVmn9jLaPRyBtvvEGdOnX4888/eeCBB3j88cd5++23813/xx9/ZNCgQbzxxhvui+577rkHgAkTJvD5558zdepU5s+fT9OmTTl8+DAbN268+APOR1ZWFs8++ywNGzYkMTGRRx55hPj4eL799lsAxo8fz++//853331H1apV2bVrF6dPnwZg7dq1tGvXjqVLl9K0aVMslvz/yD127FhmzJjB1KlTueKKKzh06BDbt28/b72YmBg+//xz+vXrx44dOwgKCsLX1xeLxcJDDz3EwoULufXWWwHX/E7ffPMN33//fZGPNXduqMzMs9eTEydO5MUXX+S1117Dx8fngp+Jw+Hg5ptvJiIigl9++YWkpKQL3uN24MABunTpQrdu3Vi+fDlBQUGsXr2a7OxsRo8ezbZt20hOTnYnr6GhoRw8ePC8bVxzzTXupHP79u0MGzYMm82WJ5GaPXs2jzzyCL/88gtr1qwhPj6eTp06ceWVVxb5PBWHkqwKIDUzm/TMs9n67M12sh3QKsJE60jXR+xjNBBiUxWrwlMlS0SkwjidlUOTpxd7ZN+/P9MbP8uFLxN9fHyYNWsWw4YN491336VVq1Z07dqV22+/nbi4OPd6515s165dm+eee4777ruvwCRr0qRJjBkzhsGDBwMQGxvLs88+y+OPP86ECRPYt28fkZGR9OrVC7PZTM2aNWnXrl2hsSYlJREQEJBnWefOnfnuu+/yXf/uu+92/xwbG8sbb7xB27ZtSU1NJSAggH379tGyZUvatGnjPq5c4eHhAISFhRU4tC0lJYXXX3+dadOmuY+zbt26XHHFFeetazKZCA0NBaBatWp5moT861//YubMme4ka86cOdSsWdNdPbuQ9PR0xo0bh8lkomvXrnm2e9ddd+U5H4V9JkuXLmX79u0sXryY6OhoAF544QWuvvrqAvf91ltvERwczPz5893DERs0aOB+3dfXF7vdXujwwLfffpuYmBimTZuGwWCgUaNGHDx4kCeeeIKnn34ao9FVYYiLi2PChAkA1K9fn2nTprFs2TIlWZI/pxOOn1PF2nUyh+V/uSZOG9ri7M2cIX4WjKpiVQJKskREpGz169ePa6+9lh9//JGff/6Z7777jpdffpn33nuP+Ph4AJYuXcrkyZPZvn07ycnJZGdnk5GRQXp6er4TMG/cuJHVq1e7h6kB5OTkuN9z66238tprrxEbG0ufPn245ppruP766/HxKfjSNjAwkA0bNuRZllvByc/69euZOHEiGzdu5OTJkzgcDgD27dtHkyZNuP/+++nXrx8bNmzgqquuom/fvlx++eVFPm/btm3DbrfTs2fPIr8nP8OGDaNt27YcOHCA6tWrM2vWLOLj4y9YiRwwYAAmk4nTp08THh7Of/7znzyJcW7ymOtCn8m2bduIiYlxJ1gAHTt2LDSGhIQEOnfufEn3e23bto2OHTvmOd5OnTqRmprK/v37qVmzJkCeYwOIiorKM/yzpCnJ8nJJp7PIzHa4n7+/KQMn0L2mD/VDTQD4mAyE+F76zYriBVTJEhGpMHzNJn5/prfH9l0cNpuNK6+8kiuvvJLx48czdOhQJkyYQHx8PHv37uW6667j/vvv5/nnnyc0NJRVq1YxZMgQMjMz802yUlNTmTRpEjfffHO++4qJiWHHjh0sXbqUJUuW8MADDzBlyhRWrlxZ4AW70WikXr16RTqetLQ0evfuTe/evZk7dy7h4eHs27eP3r17u4fUXX311fz11198++23LFmyhJ49ezJ8+HBeeeWVIu2jsASvOFq2bEmLFi344IMPuOqqq9i6dSvffPPNBd83depUevXqRXBwsLvydi5/f/88zy/0mVyMkjoHRfHP74XBYHAnzqVBSZYXy3HAiXNatv92JJt1h3LwMbo6CuYK9bPq2ltERMTLGAyGIg3ZK4+aNGninhtp/fr1OBwOXn31VffQrU8++aTQ97dq1YodO3YUmhT5+vpy/fXXc/311zN8+HAaNWrE5s2badWq1SXHv337do4fP86LL75ITEwMAL/++ut564WHhzN48GAGDx5M586deeyxx3jllVfc92Dl5BR8T139+vXx9fVl2bJlDB069IIxFbbNoUOH8tprr3HgwAF69erljrkwkZGRRU464cKfSePGjfn77785dOgQUVFRAPz888+FbjMuLo7Zs2cX2L3QYrEUeg5z9/v555/jdDrd1azVq1cTGBhIjRo1inJopcI7f3MFgJPpmeQ4XT3bHU4n723MAOC6uhaiAlz/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mmuuISvr7CiW9PR0XnnlFT788EN++OEH9u3bx+jRowEYPXq0uyp36NCh8xKeXI899hgrV67kq6++4vvvv2fFihVs2LAhzzojRoxgzZo1zJ8/n02bNnHrrbfSp0+fPElfYbEAvPPOOwwfPpx77rmHzZs3s3DhQurVq+d+/dZbbyUxMZHvvvuO9evX06pVK3r27MmJEycueF7P9dRTTzF69GgSEhJo0KABAwYMIDs7m8svv5zXXnuNoKAg9/nIja+ox/fSSy/x3nvvsXXrVgYOHEhISAiff/65e52cnBw+/vhjBg4cCEBGRgatW7fmm2++YcuWLdxzzz3ceeedrF27tljHVFo0XNBbWQNdF9rZpwtfz+jjShCKysfiuog/ffLi4jIYXe8vTWYb+Fa5yBgNrpbyxakEGk2uiZMDqrn2mXYUstIvbt/nnhtrEGQUcZy92d9VAcsVUM11f9o/ty8iIlKGAgMD6dixI88++yyNGzcmIiKCjz76iDVr1uS5yO/Tpw8333wzderUYffu3Tz55JNcffXVrFmzBpPJxN13382mTZto0qQJVatW5ZNPPuHkyZM8/fTTrFixgnHjxjF//nzq1q3L+++/n2cYYq5jx45x8uRJGjVqVGjMO3fuZOHChaxevZrLL78cgLlz5xITE8OCBQu49dZbAcjKyuLdd9+lbt26gCtZeOaZZwBXwpY77DAyMp95QIHU1FT+85//MGfOHHr27AnA7NmzqVGjhnudffv2MXPmTPbt2+dODEePHs2iRYuYOXMmL7zwwgVjAXjuued49NFHefjhh93Lcu+JW7VqFWvXriUxMdE9nPGVV15hwYIFfPbZZ9xzzz2Fnq9zjR49mmuvvRaASZMm0bRpU3bt2kWjRo0IDg7GYDDkOR/FOb63336bFi1auN97++23M2/ePIYMGQLAsmXLOHXqFP369QOgevXqeRLNBx98kMWLF/PJJ5/Qrl27Ih9TaVGS5c38wyFpX+HrBEbnvTgvioCIi0+ybMFn28KXpsAo171TxbkvzZ1gXeTExwaD614zv1Cwp0BqYvGacFgD856b3OF/RX3vuXyrQMohVwONc+MTEREpYx9++CF333031atXx2Qy0apVKwYMGMD69evd69x+++3un5s3b05cXBx169ZlxYoV9OzZE7PZzFtvvZVnu3fddRcPPfQQv/32GwsWLGDjxo28/PLLPPTQQ3kqHLmK2nBi27Zt+Pj40L59e/eysLAwGjZsyLZt29zL/Pz83EkNQFRUFImJRW88tnv3bjIzM/PsJzQ0lIYNG7qfb968mZycHBo0aJDnvXa7nbCws7dwFBZLYmIiBw8edCdy/7Rx40ZSU1PzbA9cQz1z7ysrqri4uDwx5O6/oMS2qMdnsVjybBtg4MCBdOjQgYMHDxIdHc3cuXO59tpr3cltTk4OL7zwAp988gkHDhwgMzMTu92On59fsY6ptCjJ8ma+VVzdAB3Z+b9u9i98EuCCmH1dVZaL6eJX2kMFc/lYz1SzilrmNpxp/HGRCdY/WQNd954l/p430SmMb5W8z32sYLIWraPhPytvBoNrbrPk/ecuLFocIiIiJahu3bqsXLmStLQ0kpOTiYqK4rbbbiM2NrbA98TGxlK1alV27dqVb3Lwv//9j61bt/Lee+/x2GOPcc011+Dv70///v2ZNm1avtsMDw8nJCSE7du3l8hxmc15bxMwGAwl3jkwNTUVk8nE+vXrMZny/pE69/6xC8Xi65vPLQT/2EdUVFSe+6ByFbcJyLlx5N6H5nA4Ct13UY7P19f3vK6Pbdu2pW7dusyfP5/777+fL7/8klmzZrlfnzJlCq+//jqvvfYazZs3x9/fn5EjR5KZWcTrslKmJMubGY2upCbfdu4GCK6Rz/IiCqhW/CTLdBENOS5FYNSZituF/sE7k2CV9DBGg8E159iFqom5MeSX4FkDIP0CSZbBBOZ8/irjF+bqUJibZKuSJSIiHuTv74+/vz8nT55k8eLFvPzyywWuu3//fo4fP+6uhpwrIyOD4cOHM3fuXEwmEzk5Oe6EIisri5yc/LsgG41Gbr/9dj788EMmTJhw3n1Zqamp2Gw2GjduTHZ2Nr/88ot7uODx48fZsWMHTZo0udjDP0/dunUxm8388ssv1KxZE3A13Pjjjz/o2rUrAC1btiQnJ4fExEQ6d+58UfsJDAykdu3aLFu2jO7du5/3eqtWrTh8+DA+Pj7Url37oo/nQiwWy3mfzaUe38CBA5k7dy41atTAaDS6hyoCrF69mhtvvJE77rgDcCV7f/zxR4l+hpdCjS+8XUGNKvyruib2vVjWwPwv7Avzz0pNafOxXLgbosHo6qxYWveJ+YW6qlEX8s+hgucuL8p780ugjEZX90M3JVkiIlL2Fi9ezKJFi9izZw9Lliyhe/fuNGrUiLvuugtwJTePPfYYP//8M3v37mXZsmXceOON1KtXj969e5+3vWeffZZrrrmGli1bAtCpUye++OILNm3axLRp0+jUqVOBsTz//PPExMTQvn17PvjgA37//Xd27tzJ+++/T8uWLUlNTaV+/frceOONDBs2jFWrVrFx40buuOMOqlevzo033lhi5yUgIIAhQ4bw2GOPsXz5crZs2UJ8fHye1uQNGjRg4MCBDBo0iC+++II9e/awdu1aJk+ezDfffFPkfU2cOJFXX32VN954g507d7Jhwwb3nGK9evWiY8eO9O3bl++//569e/fy008/8dRTTxWpLX5R1a5dm9TUVJYtW8axY8dIT0+/5OMbOHAgGzZs4Pnnn+eWW27J0yK/fv36LFmyhJ9++olt27Zx7733cuTIkRI7nkulSpa3y23nfm4DBaO5BDrw4UrgzmuuUIiyGip4roAISD9OvtUsg8mVYBW3i19xGAwQGHnh81RQAmoNwpUcFVKNsxY2t1m4q5LpdKiSJSJSQRV3cuCylpSUxNixY9m/fz+hoaH069eP559/3j20zGQysWnTJmbPns2pU6eIjo7mqquu4tlnnz1vXqktW7bwySefkJCQ4F52yy23sGLFCjp37kzDhg2ZN29egbGEhoby888/8+KLL/Lcc8/x119/UaVKFZo3b86UKVMIDnady5kzZ/Lwww9z3XXXkZmZSZcuXfj222/PG5Z3qaZMmUJqairXX389gYGBPProoyQl5W16NXPmTHfjigMHDlC1alU6dOjAddddV+T9DB48mIyMDKZOncro0aOpWrUqt9xyC+Aa1vftt9/y1FNPcdddd3H06FEiIyPp0qULERERJXasl19+Offddx+33XYbx48fZ8KECUycOPGSjq9evXq0a9eOtWvX8tprr+V5bdy4cfz555/07t0bPz8/7rnnHvr27Xve+fUUg7OiTEtdSpKTkwkODiYpKalI8zN4hD0Fju86+zykVslMBux0Fv2eI7M/hDe48HqlIWn/mcmZz2H0cSVYFv/S37/TCYnbCrm3ygCRzQtuCHL0D8hKK3j7Ec3AVMg/+rnHH1QDAorR2l9ERMqNjIwM9uzZQ506dc5rhy4iZauw38ei5gYaLlgR5LZzB7AElkyCBWeqNEWsiJXEJMYXKyAi74TBRrNrIuGy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JCSEVq1a8eSTTxa67RkzZjBjxozz9rdo0aLz1r333nv57bffuO222zAYDAwYMIAHHniA7777DnC1g9++fTuzZ8/m+PHjREVFMXz4cO69914A+vXrxxdffEH37t05deoUM2fOJD4+Ps8+LBYL33//PY8++ijXXHMN2dnZNGnShLfeeivf+IcNG8aKFSto06YNqamp/O9//3O3th8wYAAjR45kwIAB2Gy2Qs9DQUaNGkXHjh1Zu3YtN9xwA6NGjWLEiBHY7XauvfZaxo8fX+zJo//v//6P++67j+uuu46goCAef/xx/v777zwxzpw5k+eee45HH32UAwcOULVqVTp06MB11113UcfhDQzOfw6KlTySk5MJDg4mKSmpwHkWRERERC5FRkYGe/bsoU6dOhd9AS0V2969e6lbty7r1q2jVatWng6nQGlpaVSvXp1XX32VIUOGeDqci1LY72NRcwNVskREREREyqmsrCyOHz/OuHHj6NChQ7lLsH777Te2b99Ou3btSEpK4plnngHgxhtv9HBknqUkS0RERESknFq9ejXdu3enQYMGfPbZZ54OJ1+vvPIKO3bswGKx0Lp1a3788UeqVq3q6bA8SkmWiIiIiEg51a1bt/Na3pcnLVu2ZP369Z4Oo9xRd0EREREREZESpCRLREREpJwozxULkcqiJH4PlWSJiIiIeFjuRLzp6ekejkREcn8Pc38vL4bX3JN1ww03kJCQQGJiIlWqVKFXr1689NJLREdHF/iebt26sXLlyjzL7r33Xt59993SDldERESkyEwmEyEhISQmJgKuOZpyJ3MVkbLhdDpJT08nMTGRkJAQTCbTRW/La5Ks7t278+STTxIVFcWBAwcYPXo0t9xyCz/99FOh7xs2bJi7lSS4/tESERERKW8iIyMB3ImWiHhGSEiI+/fxYnlNkjVq1Cj3z7Vq1WLMmDH07duXrKysQkt5fn5+xTpJdrsdu93ufp6cnHxxAYuIiIgUg8FgICoqimrVqpGVleXpcEQqJbPZfEkVrFxek2Sd68SJE8ydO5fLL7/8gmMl586dy5w5c4iMjOT6669n/PjxhVazJk+ezKRJk0o6ZBEREZEiMZlMJXKRJyKeY3B6URubJ554gmnTppGenk6HDh34+uuvCQsLK3D9f//739SqVYvo6Gg2bdrEE088Qbt27fjiiy8KfE9+layY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XHEFP//8M+Hh4Z4OrUg0T5aIiIiIiEgJ0j1ZIiIiIiIiJUhJloiIiIiISAlSkiUiIiIiIlKClGSJiIiIiIiUICVZIiIiIiIiJUhJloiIiIiISAlSkiUiIiIiIlKClGSJiIiIiIiUICVZIiIiIiIiJUhJloiIVAjx8fH07dvX02GIiIgoyRIRERERESlJSrJERKTC6datGw899BCPP/44oaGhREZGMnHixDzrnDp1invvvZeIiAhsNhvNmjXj66+/dr/++eef07RpU6xWK7Vr1+bVV1/N8/7atWvz3HPPMWjQIAICAqhVqxYLFy7k6NGj3HjjjQQEBBAXF8evv/6a532rVq2ic+fO+Pr6EhMTw0MPPURaWlqpnQsRESl7SrJERKRCmj17Nv7+/vzyyy+8/PLLPPPMMyxZsgQAh8PB1VdfzerVq5kzZw6///47L774IiaTCYD169fTv39/br/9djZv3szEiRMZP348s2bNyrOPqVOn0qlTJ3777TeuvfZa7rzzTgYNGsQdd9zBhg0bqFu3LoMGDcLpdAKwe/du+vTpQ79+/di0aRMff/wxq1atYsSIEWV6bkREpHQZnLn/8ouIiHix+Ph4Tp06xYIFC+jWrRs5OTn8+OOP7tfbtWtHjx49ePHFF/n++++5+uqr2bZtGw0aNDhvWwMHDuTo0aN8//337mWPP/4433zzDVu3bgVclazOnTvz4YcfAnD48GGioqIYP348zzzzDAA///wzHTt25NChQ0RGRjJ06FBMJhPTp093b3fVqlV07dqVtLQ0bDZbqZwbEREpW6pkiYhIhRQXF5fneVRUFImJiQAkJCRQo0aNfBMsgG3bttGpU6c8yzp16sTOnTvJycnJdx8REREANG/e/LxlufvduHEjs2bNIiAgwP3o3bs3DoeDPXv2XOyhiohIOePj6QBERERKg9lszvPcYDDgcDgA8PX1LfF9GAyGApfl7jc1NZV7772Xhx566Lxt1axZs0RiEhERz1OSJSIilU5cXBz79+/njz/+yLea1bhxY1avXp1n2erVq2nQoIH7vq2L0apVK37//Xfq1at30dsQEZHyT8MFRUSk0unatStdunShX79+LFmyhD179vDdd9+xaNEiAB599FGWLVvGs88+yx9//MHs2bOZNm0ao0ePvqT9PvHEE/z000+MGDGChIQEdu7cyVdffaXGFyIiFYySLBERqZQ+//xz2rZty4ABA2jSpAmPP/64+36rVq1a8cknnzB//nyaNWvG008/zTPPPEN8fPwl7TMuLo6VK1fyxx9/0LlzZ1q2bMnTTz9NdHR0CRyRiIiUF+ouKCIiIiIiUoJUyRIRERERESlBSrJERERERERKkJIsERERERGREqQkS0REREREpAQpyRIRERERESlBSrJERERERERKkJIsERERERGREqQkS0REREREpAQpyRIRERERESlBSrJERERERERKkJIsERERERGREvT//ibzWRn3Z50AAAAASUVORK5CYII=", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "te_pred = est_dw.effect(X_test).flatten()\n", - "te_pred_interval = est_dw.effect_interval(X_test)\n", - "\n", - "# Compare the estimate and the truth\n", - "plt.figure(figsize=(10, 6))\n", - "plt.plot(X_test.flatten(), te_pred, label=\"Sales Elasticity Prediction\")\n", - "plt.plot(X_test.flatten(), truth_te_estimate, \"--\", label=\"True Elasticity\")\n", - "plt.fill_between(\n", - " X_test.flatten(),\n", - " te_pred_interval[0].flatten(),\n", - " te_pred_interval[1].flatten(),\n", - " alpha=0.2,\n", - " label=\"95% Confidence Interval\",\n", - ")\n", - "plt.fill_between(\n", - " X_test.flatten(),\n", - " truth_te_lower,\n", - " truth_te_upper,\n", - " alpha=0.2,\n", - " label=\"True Elasticity Range\",\n", - ")\n", - "plt.xlabel(\"Income\")\n", - "plt.ylabel(\"Songs Sales Elasticity\")\n", - "plt.title(\"Songs Sales Elasticity vs Income\")\n", - "plt.legend(loc=\"lower right\")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "base", - "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.9.15" - }, - "orig_nbformat": 4 - }, - "nbformat": 4, - "nbformat_minor": 2 -}