From f200512abbd666a90ad6b15772ebd28d79d4cdfd Mon Sep 17 00:00:00 2001 From: AnthonyCampbell208 <78286293+AnthonyCampbell208@users.noreply.github.com> Date: Fri, 30 Jun 2023 16:33:41 -0400 Subject: [PATCH 01/19] Adding model selection functionality Signed-off-by: AnthonyCampbell208 <78286293+AnthonyCampbell208@users.noreply.github.com> Co-authored-by: ShrutiRM97 <98553136+ShrutiRM97@users.noreply.github.com> Co-authored-by: CooperGibbs --- econml/dml/dml.py | 68 ++- econml/new_tests/test_model_selection.py | 273 +++++++++ .../new_tests/test_model_selection_utils.py | 235 ++++++++ econml/sklearn_extensions/model_selection.py | 252 +++++++- .../model_selection_utils.py | 563 ++++++++++++++++++ econml/tests/test_dml.py | 12 +- econml/utilities.py | 24 +- 7 files changed, 1399 insertions(+), 28 deletions(-) create mode 100644 econml/new_tests/test_model_selection.py create mode 100644 econml/new_tests/test_model_selection_utils.py create mode 100644 econml/sklearn_extensions/model_selection_utils.py diff --git a/econml/dml/dml.py b/econml/dml/dml.py index ce579d519..7ff5ad354 100644 --- a/econml/dml/dml.py +++ b/econml/dml/dml.py @@ -34,6 +34,8 @@ 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 class _FirstStageWrapper: @@ -356,6 +358,14 @@ class takes as input the parameter `model_t`, which is an arbitrary scikit-learn The estimator for fitting the response residuals to the treatment residuals. Must implement `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. + If 'auto', it will be chosen based on the model type. + + scaling: bool, default True + Whether to scale the features during the estimation process. + Scaling can help improve the performance of some models. + featurizer: :term:`transformer`, optional Must support fit_transform and transform. Used to create composite features in the final CATE regression. It is ignored if X is None. The final CATE will be trained on the outcome of featurizer.fit_transform(X). @@ -380,6 +390,9 @@ class takes as input the parameter `model_t`, which is an arbitrary scikit-learn The categories to use when encoding discrete treatments (or 'auto' to use the unique sorted values). The first category will be treated as the control treatment. + verbose: int, default 2 + The verbosity level of the output messages. Higher values indicate more verbosity. + cv: int, cross-validation generator or an iterable, default 2 Determines the cross-validation splitting strategy. Possible inputs for cv are: @@ -469,13 +482,19 @@ class takes as input the parameter `model_t`, which is an arbitrary scikit-learn def __init__(self, *, model_y, model_t, model_final, + param_list_y=None, + param_list_t=None, + scaling=False, featurizer=None, treatment_featurizer=None, fit_cate_intercept=True, linear_first_stages=False, discrete_treatment=False, categories='auto', + verbose=2, # New cv=2, + grid_folds=2, # New + n_jobs=None, # New mc_iters=None, mc_agg='mean', random_state=None, @@ -487,6 +506,13 @@ def __init__(self, *, # since we clone it and fit separate copies self.fit_cate_intercept = fit_cate_intercept self.linear_first_stages = linear_first_stages + self.scaling = scaling + self.param_list_y = param_list_y + self.param_list_t = param_list_t + self.verbose = verbose + self.cv = cv + self.grid_folds = grid_folds + self.n_jobs = n_jobs self.featurizer = clone(featurizer, safe=False) self.model_y = clone(model_y, safe=False) self.model_t = clone(model_t, safe=False) @@ -508,23 +534,37 @@ def _gen_allowed_missing_vars(self): def _gen_featurizer(self): return clone(self.featurizer, safe=False) - def _gen_model_y(self): + def _gen_model_y(self): # New if self.model_y == 'auto': - model_y = WeightedLassoCVWrapper(random_state=self.random_state) + model_y = SearchEstimatorList(estimator_list=self.model_y, param_grid_list=self.param_list_y, + scaling=self.scaling, verbose=self.verbose, cv=self.cv, n_jobs=self.n_jobs, random_state=self.random_state) else: - model_y = clone(self.model_y, safe=False) + model_y = clone(SearchEstimatorList(estimator_list=self.model_y, param_grid_list=self.param_list_y, + scaling=self.scaling, verbose=self.verbose, cv=self.cv, n_jobs=self.n_jobs, random_state=self.random_state), safe=False) + # 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): + def _gen_model_t(self): # New + # import pdb + # pdb.set_trace() if self.model_t == 'auto': if self.discrete_treatment: - model_t = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - random_state=self.random_state) + model_t = SearchEstimatorList(estimator_list=self.model_t, param_grid_list=self.param_list_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 = WeightedLassoCVWrapper(random_state=self.random_state) + model_t = 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) + # model_t = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), + # model_t = WeightedLassoCVWrapper(random_state=self.random_state) else: - model_t = clone(self.model_t, safe=False) + 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) + # model_t = clone(self.model_t, safe=False) + return _FirstStageWrapper(model_t, False, self._gen_featurizer(), self.linear_first_stages, self.discrete_treatment) @@ -716,13 +756,19 @@ class LinearDML(StatsModelsCateEstimatorMixin, DML): def __init__(self, *, model_y='auto', model_t='auto', + param_list_y=None, + param_list_t=None, featurizer=None, treatment_featurizer=None, fit_cate_intercept=True, linear_first_stages=True, discrete_treatment=False, categories='auto', + scaling=True, + verbose=2, cv=2, + grid_folds=2, + n_jobs=None, mc_iters=None, mc_agg='mean', random_state=None, @@ -733,6 +779,8 @@ def __init__(self, *, super().__init__(model_y=model_y, model_t=model_t, + param_list_y=param_list_y, + param_list_t=param_list_t, model_final=None, featurizer=featurizer, treatment_featurizer=treatment_featurizer, @@ -740,7 +788,11 @@ def __init__(self, *, linear_first_stages=linear_first_stages, discrete_treatment=discrete_treatment, categories=categories, + scaling=scaling, + verbose=verbose, cv=cv, + n_jobs=n_jobs, + grid_folds=grid_folds, mc_iters=mc_iters, mc_agg=mc_agg, random_state=random_state, diff --git a/econml/new_tests/test_model_selection.py b/econml/new_tests/test_model_selection.py new file mode 100644 index 000000000..b007ddd21 --- /dev/null +++ b/econml/new_tests/test_model_selection.py @@ -0,0 +1,273 @@ +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_custom_scoring_function(self): + with self.assertRaises(ValueError): + search = SearchEstimatorList(estimator_list='linear', is_discrete=self.is_discrete, + scaling=False, scoring='invalid_scorer') + + 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 new file mode 100644 index 000000000..8e7e7c917 --- /dev/null +++ b/econml/new_tests/test_model_selection_utils.py @@ -0,0 +1,235 @@ +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/sklearn_extensions/model_selection.py b/econml/sklearn_extensions/model_selection.py index 79b714bbc..e9c82ddc2 100644 --- a/econml/sklearn_extensions/model_selection.py +++ b/econml/sklearn_extensions/model_selection.py @@ -1,25 +1,30 @@ # Copyright (c) PyWhy contributors. All rights reserved. # Licensed under the MIT License. - """Collection of scikit-learn extensions for model selection techniques.""" import numbers +import pdb import warnings -import sklearn -from sklearn.base import BaseEstimator -from sklearn.utils.multiclass import type_of_target + import numpy as np import scipy.sparse as sp +import sklearn from joblib import Parallel, delayed -from sklearn.base import clone, is_classifier -from sklearn.model_selection import KFold, StratifiedKFold, check_cv, GridSearchCV +from sklearn.base import BaseEstimator, clone, is_classifier +from sklearn.exceptions import FitFailedWarning +from sklearn.model_selection import (BaseCrossValidator, GridSearchCV, 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.utils import indexable, check_random_state +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 * + def _split_weighted_sample(self, X, y, sample_weight, is_stratified=False): random_state = self.random_state if self.shuffle else None @@ -256,6 +261,216 @@ def get_n_splits(self, X, y, groups=None): return self.n_splits +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 + tuning, model fitting, and prediction for multiple estimators. + + + Parameters + ---------- + estimator_list : list, string, or sklearn model object, default ['linear', 'forest'] + 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. + + scaling : bool, default True + Indicates whether to scale the input data using StandardScaler. + + is_discrete : bool, default False + Specifies if the models in `estimator_list` are discrete. + + scoring : str or None, default None + The scoring metric to be used for selecting the best estimator. + + n_jobs : int or None, default None + The number of CPU cores to use for parallel processing during grid search. + + refit : bool, default True + Determines whether to refit the best estimator with the entire dataset after grid search. + + grid_folds : int, default 3 + Number of folds for the cross-validation during grid search. Must be at least 2. + + verbose : int, default 2 + Verbosity level of the class's methods and inner workings. + + pre_dispatch : str, default '2*n_jobs' + Controls the number of jobs that get dispatched during parallel execution of the grid search. + + 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. + + 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. + + 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 + """ + + 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 + # 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): + self.param_grid_list = len(self.complete_estimator_list) * [{}] + else: + self.param_grid_list = param_grid_list + self.categorical_indices = categorical_indices + self.scoring = scoring + if scoring == None: + if is_discrete: + self.scoring = 'f1_macro' + else: + self.scoring = 'neg_mean_squared_error' + warnings.warn(f"No scoring value was given. Using default score method {self.scoring}.") + self.scaling = scaling + self.n_jobs = n_jobs + self.refit = refit + self.cv = cv + self.verbose = verbose + self.random_state = random_state + self.pre_dispatch = pre_dispatch + self.error_score = error_score + self.return_train_score = return_train_score + self.is_discrete = is_discrete + + def fit(self, X, y, *, sample_weight=None, groups=None): + # print(groups) + # if groups != None: + # pdb.set_trace() + + self._search_list = [] + # pdb.set_trace() + # Change estimators if multi_task + if is_likely_multi_task(y): + for index, estimator in enumerate(self.complete_estimator_list): + 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: + self.param_grid_list[index] = make_param_multi_task( + estimator=estimator, param_grid=self.param_grid_list[index]) + + if self.scaling: + if not is_data_scaled(X): + self.scaler = StandardScaler() + scaled_X = self.scaler.fit_transform(X) + + 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 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 + if is_polynomial_pipeline(estimator): + estimator = estimator.set_params(linear__random_state=self.random_state) + else: + estimator.set_params(random_state=self.random_state) + if is_polynomial_pipeline(estimator=estimator): + # Only linear part of pipeline can handle sampleweight + estimator.fit(X, y, linear__sample_weight=sample_weight) + elif not supports_sample_weight(estimator=estimator): + estimator.fit(X, y) + else: + estimator.fit(X, y, sample_weight=sample_weight) + self.best_ind_ = None + self.best_estimator_ = estimator + self.best_score_ = None + self.best_params_ = {} + return self + for estimator, param_grid in zip(self.complete_estimator_list, self.param_grid_list): + try: + if self.random_state != 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 + if is_polynomial_pipeline(estimator): + estimator = estimator.set_params(linear__random_state=self.random_state) + else: + estimator.set_params(random_state=self.random_state) + print(estimator) # Note Delete this + print(param_grid) # Note Delete this + # 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, + return_train_score=self.return_train_score) + if self.scaling: + # Add sample weights to the linear layer, not the polynomial featurizer + if is_polynomial_pipeline(estimator=estimator): + temp_search.fit(scaled_X, y, groups=groups, linear__sample_weight=sample_weight) + # MLP does not have sample weight so we cannot fit the search + elif is_mlp(estimator=estimator): + temp_search.fit(scaled_X, y, groups=groups) + else: + temp_search.fit(scaled_X, y, groups=groups, sample_weight=sample_weight) + self._search_list.append(temp_search) + else: + if is_polynomial_pipeline(estimator=estimator): + temp_search.fit(X, y, groups=groups, linear__sample_weight=sample_weight) + elif not supports_sample_weight(estimator=estimator): + temp_search.fit(X, y, groups=groups) + else: + temp_search.fit(X, y, groups=groups, sample_weight=sample_weight) + self._search_list.append(temp_search) + except (ValueError, TypeError, FitFailedWarning) as e: + # This warning catches errors during the fit operation. + 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_." + warnings.warn(warning_msg, category=FitFailedWarning) + + self.best_ind_ = np.argmax([search.best_score_ for search in self._search_list]) + 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_}') + return self + + def scaler_transform(self, X): + if self.scaling: + return self.scaler.transform(X) + + def best_model(self): + return self.best_estimator_ + + def predict(self, X): + if self.scaling: + return self.best_estimator_.predict(self.scaler.transform(X)) + return self.best_estimator_.predict(X) + + def predict_proba(self, X): + return self.best_estimator_.predict_proba(X) + + def refit(self, X, y): + # Refits the best estimator using the entire dataset. + if self.best_estimator_ is None: + raise ValueError("No best estimator found. Please call the 'fit' method before calling 'refit'.") + + self.best_estimator_.fit(X, y) + return self + + class GridSearchCVList(BaseEstimator): """ An extension of GridSearchCV that allows for passing a list of estimators each with their own parameter grid and returns the best among all estimators in the list and hyperparameter in their @@ -279,14 +494,20 @@ class GridSearchCVList(BaseEstimator): of parameter settings. """ - def __init__(self, estimator_list, param_grid_list, scoring=None, + def __init__(self, estimator_list=['linear', 'forest'], param_grid_list='auto', scoring=None, n_jobs=None, refit=True, cv=None, verbose=0, pre_dispatch='2*n_jobs', - error_score=np.nan, return_train_score=False): - self.estimator_list = estimator_list - self.param_grid_list = param_grid_list + 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 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 @@ -296,7 +517,7 @@ def __init__(self, estimator_list, param_grid_list, scoring=None, def fit(self, X, y=None, **fit_params): self._gcv_list = [GridSearchCV(estimator, param_grid, scoring=self.scoring, - n_jobs=self.n_jobs, refit=self.refit, cv=self.cv, verbose=self.verbose, + n_jobs=self.n_jobs, 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)] @@ -306,6 +527,9 @@ 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) @@ -313,7 +537,7 @@ def predict_proba(self, X): return self.best_estimator_.predict_proba(X) -def _cross_val_predict(estimator, X, y=None, *, groups=None, cv=None, +def _cross_val_predict(estimator, X, y=None, *, groups=None, cv=3, 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 new file mode 100644 index 000000000..7aced8728 --- /dev/null +++ b/econml/sklearn_extensions/model_selection_utils.py @@ -0,0 +1,563 @@ + +import pdb +import warnings +from sklearn.exceptions import NotFittedError +import numpy as np +import sklearn +import sklearn.ensemble +import sklearn.linear_model +import sklearn.neural_network +import sklearn.preprocessing +from sklearn.base import BaseEstimator, is_regressor, is_classifier +from sklearn.ensemble import (GradientBoostingClassifier, + GradientBoostingRegressor, + RandomForestClassifier, RandomForestRegressor) +from sklearn.linear_model import (ElasticNetCV, + LogisticRegression, + LogisticRegressionCV, MultiTaskElasticNetCV) +from sklearn.model_selection import (BaseCrossValidator, GridSearchCV, + RandomizedSearchCV, + check_cv) +from sklearn.neural_network import MLPClassifier, MLPRegressor +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import (PolynomialFeatures, + StandardScaler) +from sklearn.svm import SVC, LinearSVC +import inspect +from sklearn.exceptions import NotFittedError +from sklearn.multioutput import MultiOutputRegressor, MultiOutputClassifier +from sklearn.model_selection import KFold +# from sklearn_extensions.model_selection import WeightedStratifiedKFold + + +def select_continuous_estimator(estimator_type, random_state): + """ + Returns a continuous estimator object for the specified estimator type. + + Parameters + ---------- + estimator_type (str): The type of estimator to use, one of: 'linear', 'forest', 'gbf', 'nnet', 'poly'. + TODO Add Random State for parameter + Returns + ---------- + object: An instance of the selected estimator class. + + Raises: + ValueError: If the estimator type is unsupported. + """ + if estimator_type == 'linear': + return (ElasticNetCV(random_state=random_state)) + elif estimator_type == 'forest': + return RandomForestRegressor(random_state=random_state) + elif estimator_type == 'gbf': + return GradientBoostingRegressor(random_state=random_state) + elif estimator_type == 'nnet': + return (MLPRegressor(random_state=random_state)) + elif estimator_type == 'poly': + poly = PolynomialFeatures() + linear = ElasticNetCV(random_state=random_state) # Play around with precompute and tolerance + return (Pipeline([('poly', poly), ('linear', linear)])) + else: + raise ValueError(f"Unsupported estimator type: {estimator_type}") + + +def select_discrete_estimator(estimator_type, random_state): + """ + Returns a discrete estimator object for the specified estimator type. + + Parameters + ---------- + estimator_type (str): The type of estimator to use, one of: 'linear', 'forest', 'gbf', 'nnet', 'poly'. + TODO Add Random State for parameter + Returns + ---------- + object: An instance of the selected estimator class. + + Raises: + ValueError: If the estimator type is unsupported. + """ + + if estimator_type == 'linear': + return (LogisticRegressionCV(cv=KFold(random_state=random_state), + multi_class='auto', random_state=random_state)) + elif estimator_type == 'forest': + return RandomForestClassifier(random_state=random_state) + elif estimator_type == 'gbf': + return GradientBoostingClassifier(random_state=random_state) + elif estimator_type == 'nnet': + return (MLPClassifier(random_state=random_state)) + elif estimator_type == 'poly': + poly = PolynomialFeatures() + linear = (LogisticRegressionCV(cv=KFold(random_state=random_state), + multi_class='auto', random_state=random_state)) + return (Pipeline([('poly', poly), ('linear', linear)])) + else: + raise ValueError(f"Unsupported estimator type: {estimator_type}") + + +def select_estimator(estimator_type, is_discrete, random_state): + """ + Returns an estimator object for the specified estimator and target types. + + Parameters + ---------- + 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 + ---------- + object: An instance of the selected estimator class. + + Raises: + ValueError: If the estimator or target types are unsupported. + """ + if not isinstance(is_discrete, bool): + raise ValueError(f"Unsupported target type: {type(is_discrete)}. is_discrete should be of type bool.") + elif is_discrete: + return select_discrete_estimator(estimator_type=estimator_type, random_state=random_state) + else: + return select_continuous_estimator(estimator_type=estimator_type, random_state=random_state) + + +def is_likely_estimator(estimator): + required_methods = ['fit', 'predict'] + return all(hasattr(estimator, method) for method in required_methods) or isinstance(estimator, BaseEstimator) + + +def check_list_type(lst): + """ + Checks if a list only contains strings, sklearn model objects, and sklearn model selection objects. + + Parameters + ---------- + lst (list): A list to check. + + Returns + ---------- + 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. + + Examples: + >>> check_list_type(['linear', RandomForestRegressor(), KFold()]) + True + >>> check_list_type([1, 'linear']) + TypeError: The list must contain only strings, sklearn model objects, and sklearn model selection objects. + """ + 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}") + return True + + +def get_complete_estimator_list(estimator_list, is_discrete, random_state): + ''' + Returns a list of sklearn objects from an input list of str's, and sklearn objects. + + Parameters + ---------- + 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 + ---------- + object: A list of sklearn objects + + Raises: + 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'] + elif 'auto' == estimator_list: + estimator_list = ['linear'] + elif estimator_list in ['linear', 'forest', 'gbf', 'nnet', 'poly']: + 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']") + elif isinstance(estimator_list, list): + if 'auto' in estimator_list: + for estimator in ['linear']: + if estimator not in estimator_list: + estimator_list.append(estimator) + if 'all' in estimator_list: + for estimator in ['linear', 'forest', 'gbf', 'nnet', 'poly']: + if estimator not in estimator_list: + estimator_list.append(estimator) + + elif is_likely_estimator(estimator_list): + estimator_list = [estimator_list] + else: + raise ValueError(f"Incorrect type: {type(estimator_list)}") + check_list_type(estimator_list) + temp_est_list = [] + + if not isinstance(estimator_list, list): + raise ValueError(f"estimator_list should be of type list not: {type(estimator_list)}") + + # Set to remove duplicates + for estimator in set(estimator_list): + # if sklearn object: add to list, else turn str into corresponding sklearn object and add to list + if isinstance(estimator, BaseCrossValidator) or is_likely_estimator(estimator): + temp_est_list.append(estimator) + else: + temp_est_list.append(select_estimator(estimator_type=estimator, + is_discrete=is_discrete, random_state=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))) + return temp_est_list + + +def select_classification_hyperparameters(estimator): + """ + Returns a hyperparameter grid for the specified classification model type. + + Parameters + ---------- + model_type (str): The type of model to be used. Valid values are 'linear', 'forest', 'nnet', and 'poly'. + + Returns + ---------- + A dictionary representing the hyperparameter grid to search over. + """ + + if isinstance(estimator, LogisticRegressionCV): + return { + 'Cs': [0.01, 0.1, 1], + 'cv': [3], + 'penalty': ['l1', 'l2', 'elasticnet'], + 'solver': ['lbfgs', 'liblinear', 'saga'] + } + elif isinstance(estimator, RandomForestClassifier): + return { + 'n_estimators': [100, 500], + 'max_depth': [None, 5, 10, 20], + 'min_samples_split': [2, 5], + 'min_samples_leaf': [1, 2] + } + elif isinstance(estimator, GradientBoostingClassifier): + return { + 'n_estimators': [100, 500], + 'learning_rate': [0.01, 0.05, 0.1], + 'max_depth': [3, 5, 7], + + } + elif isinstance(estimator, MLPClassifier): + return { + 'hidden_layer_sizes': [(10,), (50,), (100,)], + 'activation': ['relu'], + 'solver': ['adam'], + 'alpha': [0.0001, 0.001, 0.01], + 'learning_rate': ['constant', 'adaptive'] + } + elif is_polynomial_pipeline(estimator=estimator): + return { + 'poly__degree': [2, 3, 4], + 'linear__Cs': [1, 10, 20], + 'linear__max_iter': [100, 200], + 'linear__penalty': ['l2'], + 'linear__solver': ['saga', 'liblinear', '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) + return {} + # raise ValueError("Invalid model type. Valid values are 'linear', 'forest', 'nnet', and 'poly'.") + + +def select_regression_hyperparameters(estimator): + """ + Returns a dictionary of hyperparameters to be searched over for a regression model. + + Parameters + ---------- + model_type (str): The type of model to be used. Valid values are 'linear', 'forest', 'nnet', and 'poly'. + + Returns + ---------- + A dictionary of hyperparameters to be searched over using a grid search. + """ + if isinstance(estimator, ElasticNetCV): + return { + 'l1_ratio': [0.1, 0.5, 0.9], + 'cv': [3], + 'max_iter': [1000], + } + elif isinstance(estimator, RandomForestRegressor): + return { + 'n_estimators': [100], + 'max_depth': [None, 10, 50], + 'min_samples_split': [2, 5, 10], + } + elif isinstance(estimator, MLPRegressor): + return { + 'hidden_layer_sizes': [(10,), (50,), (100,)], + 'alpha': [0.0001, 0.001, 0.01], + 'learning_rate': ['constant', 'adaptive'] + } + elif isinstance(estimator, GradientBoostingRegressor): + return { + 'n_estimators': [100, 500], + 'learning_rate': [0.01, 0.1, 0.05], + 'max_depth': [3, 5], + } + elif is_polynomial_pipeline(estimator=estimator): + return { + 'linear__l1_ratio': [0.1, 0.5, 0.9], + 'linear__max_iter': [1000], + '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) + return {} + + +def flatten_list(lst): + """ + Flatten a list that may contain nested lists. + + Parameters + ---------- + lst (list): The list to flatten. + + Returns + ---------- + list: The flattened list. + """ + flattened = [] + for item in lst: + if isinstance(item, list): + flattened.extend(flatten_list(item)) + else: + flattened.append(item) + return flattened + + +def auto_hyperparameters(estimator_list, is_discrete=True): + """ + Selects hyperparameters for a list of estimators. + + Parameters + ---------- + - estimator_list: list of scikit-learn estimators + - is_discrete: boolean indicating whether the problem is classification or regression + + Returns + ---------- + - param_list: list of parameter grids for the estimators + """ + param_list = [] + for estimator in estimator_list: + if is_discrete: + param_list.append(select_classification_hyperparameters(estimator=estimator)) + else: + param_list.append(select_regression_hyperparameters(estimator=estimator)) + return param_list + + +def set_search_hyperparameters(search_object, hyperparameters): + if isinstance(search_object, (RandomizedSearchCV, GridSearchCV)): + search_object.set_params(**hyperparameters) + else: + raise ValueError("Invalid search object") + + +def is_mlp(estimator): + return isinstance(estimator, (MLPClassifier, MLPRegressor)) + + +def has_random_state(model): + if is_polynomial_pipeline(model): + signature = inspect.signature(type(model['linear'])) + else: + signature = inspect.signature(type(model)) + return ("random_state" in signature.parameters) + + +def supports_sample_weight(estimator): + fit_signature = inspect.signature(estimator.fit) + return 'sample_weight' in fit_signature.parameters + + +def just_one_model_no_params(estimator_list, param_list): + return (len(estimator_list) == 1) and (len(param_list) == 1) and (len(param_list[0]) == 0) + + +def param_grid_is_empty(param_grid): + return len(param_grid) == 0 + + +def is_linear_model(estimator): + """ + Check whether an estimator is a polynomial regression, logistic regression, linear SVM, or any other type of + linear model. + + Parameters + ---------- + estimator (scikit-learn estimator): The estimator to check. + + Returns + ---------- + is_linear (bool): True if the estimator is a linear model, False otherwise. + """ + + if isinstance(estimator, Pipeline): + has_poly_feature_step = any(isinstance(step[1], PolynomialFeatures) for step in estimator.steps) + if has_poly_feature_step: + return True + + if hasattr(estimator, 'fit_intercept') and hasattr(estimator, 'coef_'): + return True + + if isinstance(estimator, (LogisticRegression, LinearSVC, SVC)): + return True + + return False + + +def is_data_scaled(X): + """ + Check if the input data is already centered and scaled using StandardScaler. + + Parameters + ---------- + X array-like of shape (n_samples, n_features): The input data. + + Returns + ---------- + is_scaled (bool): Whether the input data is already centered and scaled using StandardScaler or not. + + """ + mean = np.mean(X, axis=0) + std = np.std(X, axis=0) + + is_scaled = np.allclose(mean, 0.0) and np.allclose(std, 1.0) + + return is_scaled + + +def is_regressor_or_classifier(model, is_discrete): + if is_discrete: + if is_polynomial_pipeline(model): + return is_classifier(model[1]) + else: + return is_classifier(model) + else: + if is_polynomial_pipeline(model): + return is_regressor(model[1]) + else: + return is_regressor(model) + + +def scale_pipeline(model): + """ + Returns a pipeline that scales the input data using StandardScaler and applies the given model. + + Parameters + ---------- + model : estimator object + A model object that implements the scikit-learn estimator interface. + + Returns + ---------- + pipe : Pipeline object + A pipeline that scales the input data using StandardScaler and applies the given model. + """ + pipe = Pipeline([('scaler', StandardScaler()), ('model', model)]) + return pipe + + +def is_polynomial_pipeline(estimator): + if not isinstance(estimator, Pipeline): + return False + steps = estimator.steps + if len(steps) != 2: + return False + poly_step = steps[0] + if not isinstance(poly_step[1], PolynomialFeatures): + return False + return True + + +def is_likely_multi_task(y): + if len(y.shape) == 2: + if y.shape[1] > 1: + return True + return False + + +def can_handle_multitask(model, is_discrete=False): + X = np.random.rand(10, 3) + if is_discrete: + y = np.random.randint(0, 2, (10, 2)) + else: + y = np.random.rand(10, 2) + + try: + model.fit(X, y) + except Exception as e: + return False + + try: + model.predict(X) + except Exception as e: + # warnings.warn(f"The model {model.__class__.__name__} is not properly fitted. Error: {e}") + return False + return True + + +def pipeline_convert_to_multitask(pipeline): + steps = list(pipeline.steps) + + if isinstance(steps[-1][1], (LogisticRegressionCV)): + steps[-1] = ('linear', MultiOutputClassifier(steps[-1][1])) + if isinstance(steps[-1][1], (ElasticNetCV)): + steps[-1] = ('linear', MultiTaskElasticNetCV()) + new_pipeline = Pipeline(steps) + + return new_pipeline + + +def make_model_multi_task(model, is_discrete): + try: + if is_discrete: + if is_polynomial_pipeline(model): + return pipeline_convert_to_multitask(model) + return MultiOutputClassifier(model) + else: + if isinstance(model, ElasticNetCV): + return MultiTaskElasticNetCV() + elif is_polynomial_pipeline(model): + return pipeline_convert_to_multitask(model) + else: + return MultiOutputRegressor(model) + except TypeError as e: + raise ValueError(f"An error occurred due to type mismatch: {e}") from e + except AttributeError as e: + raise ValueError(f"An error occurred due to attribute error: {e}") from e + except Exception as e: + raise ValueError("An unknown error occurred when making model multitask.") from e + + +def make_param_multi_task(estimator, param_grid): + if isinstance(estimator, ElasticNetCV): + return param_grid + else: + param_grid_multi = {f'estimator__{k}': v for k, v in param_grid.items()} + return param_grid_multi diff --git a/econml/tests/test_dml.py b/econml/tests/test_dml.py index 195b4615d..afb445ccd 100644 --- a/econml/tests/test_dml.py +++ b/econml/tests/test_dml.py @@ -22,7 +22,9 @@ 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 @@ -623,9 +625,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, WeightedLasso) + assert isinstance(mdl, SearchEstimatorList) for mdl in est.models_t[0]: - assert isinstance(mdl, LogisticRegression) + assert isinstance(mdl, SearchEstimatorList) 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(), @@ -639,9 +641,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, WeightedLasso) + assert isinstance(mdl, SearchEstimatorList) for mdl in est.models_t[0]: - assert isinstance(mdl, LogisticRegression) + assert isinstance(mdl, SearchEstimatorList) np.testing.assert_array_equal(est.cate_feature_names(['A']), ['A']) def test_forest_dml_perf(self): @@ -1129,7 +1131,7 @@ 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) diff --git a/econml/utilities.py b/econml/utilities.py index 84c577a93..aa6145d96 100644 --- a/econml/utilities.py +++ b/econml/utilities.py @@ -30,6 +30,7 @@ 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 @@ -950,8 +951,28 @@ def fit_with_groups(model, X, y, groups=None, **kwargs): 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): + for estimator in model.complete_estimator_list: + if hasattr(estimator, 'cv'): + old_cv = estimator.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: + estimator.cv = splits + return estimator.fit(X, y, **kwargs) + finally: + 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... if hasattr(model, 'cv'): @@ -967,6 +988,7 @@ def fit_with_groups(model, X, y, groups=None, **kwargs): # 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: From 30c290a6e52c7cc1e758ac9cb219452754150c9e Mon Sep 17 00:00:00 2001 From: AnthonyCampbell208 <78286293+AnthonyCampbell208@users.noreply.github.com> Date: Fri, 21 Jul 2023 15:15:48 -0400 Subject: [PATCH 02/19] Fixed fitting with groups, fixed one param grid case, other bugs Signed-off-by: AnthonyCampbell208 <78286293+AnthonyCampbell208@users.noreply.github.com> --- econml/dml/dml.py | 18 +- econml/new_tests/test_model_selection.py | 8 +- econml/sklearn_extensions/model_selection.py | 11 +- .../model_selection_utils.py | 242 +++++++++++++++++- econml/utilities.py | 13 +- 5 files changed, 253 insertions(+), 39 deletions(-) diff --git a/econml/dml/dml.py b/econml/dml/dml.py index 7ff5ad354..515295444 100644 --- a/econml/dml/dml.py +++ b/econml/dml/dml.py @@ -541,7 +541,10 @@ def _gen_model_y(self): # New else: model_y = clone(SearchEstimatorList(estimator_list=self.model_y, param_grid_list=self.param_list_y, scaling=self.scaling, verbose=self.verbose, cv=self.cv, n_jobs=self.n_jobs, random_state=self.random_state), safe=False) - # model_y = clone(self.model_y, 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) @@ -557,14 +560,19 @@ def _gen_model_t(self): # New model_t = 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) - # model_t = LogisticRegressionCV(cv=WeightedStratifiedKFold(random_state=self.random_state), - # model_t = WeightedLassoCVWrapper(random_state=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) - # model_t = clone(self.model_t, 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) diff --git a/econml/new_tests/test_model_selection.py b/econml/new_tests/test_model_selection.py index b007ddd21..1eb82db0b 100644 --- a/econml/new_tests/test_model_selection.py +++ b/econml/new_tests/test_model_selection.py @@ -256,17 +256,11 @@ def test_custom_random_state(self): self.assertGreaterEqual(acc, self.expected_accuracy) self.assertGreaterEqual(f1, self.expected_f1_score) - def test_invalid_custom_scoring_function(self): - with self.assertRaises(ValueError): - search = SearchEstimatorList(estimator_list='linear', is_discrete=self.is_discrete, - scaling=False, scoring='invalid_scorer') - + 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__': diff --git a/econml/sklearn_extensions/model_selection.py b/econml/sklearn_extensions/model_selection.py index e9c82ddc2..0e667cee2 100644 --- a/econml/sklearn_extensions/model_selection.py +++ b/econml/sklearn_extensions/model_selection.py @@ -332,7 +332,10 @@ def __init__(self, estimator_list=['linear', 'forest'], param_grid_list=None, sc elif (param_grid_list == None): self.param_grid_list = len(self.complete_estimator_list) * [{}] else: - self.param_grid_list = param_grid_list + if isinstance(param_grid_list, dict): + self.param_grid_list = [param_grid_list] + else: + self.param_grid_list = param_grid_list self.categorical_indices = categorical_indices self.scoring = scoring if scoring == None: @@ -356,9 +359,9 @@ def fit(self, X, y, *, sample_weight=None, groups=None): # print(groups) # if groups != None: # pdb.set_trace() - - self._search_list = [] # pdb.set_trace() + self._search_list = [] + # Change estimators if multi_task if is_likely_multi_task(y): for index, estimator in enumerate(self.complete_estimator_list): @@ -375,7 +378,7 @@ def fit(self, X, y, *, sample_weight=None, groups=None): scaled_X = self.scaler.fit_transform(X) 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 + # 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 has_random_state(model=estimator): diff --git a/econml/sklearn_extensions/model_selection_utils.py b/econml/sklearn_extensions/model_selection_utils.py index 7aced8728..0ab0f87c1 100644 --- a/econml/sklearn_extensions/model_selection_utils.py +++ b/econml/sklearn_extensions/model_selection_utils.py @@ -120,6 +120,22 @@ def select_estimator(estimator_type, is_discrete, random_state): def is_likely_estimator(estimator): + """ + Check if an object is likely to be an estimator. + + This function checks if an object has 'fit' and 'predict' methods, or if it is an instance of BaseEstimator. + + Parameters + ---------- + estimator : object + The object to check. + + Returns + ------- + bool + True if the object is likely to be an estimator, False otherwise. + """ + required_methods = ['fit', 'predict'] return all(hasattr(estimator, method) for method in required_methods) or isinstance(estimator, BaseEstimator) @@ -383,6 +399,22 @@ def is_mlp(estimator): def has_random_state(model): + """ + Check if a model has a 'random_state' parameter. + + This function inspects the model's signature to check if it has a 'random_state' parameter. + + Parameters + ---------- + model : object + The model to check. + + Returns + ------- + bool + True if the model has a 'random_state' parameter, False otherwise. + """ + if is_polynomial_pipeline(model): signature = inspect.signature(type(model['linear'])) else: @@ -391,30 +423,84 @@ def has_random_state(model): def supports_sample_weight(estimator): + """ + Check if a model supports 'sample_weight'. + + This function inspects the signature of the model's 'fit' method to check if it supports 'sample_weight'. + + Parameters + ---------- + model : object + The model to check. + + Returns + ------- + bool + True if the model supports 'sample_weight', False otherwise. + """ + fit_signature = inspect.signature(estimator.fit) return 'sample_weight' in fit_signature.parameters def just_one_model_no_params(estimator_list, param_list): + """ + Check if there is only one model and the parameter list is empty. + + This function checks if the length of the model and parameter list is 1 and 0 respectively. + + Parameters + ---------- + estimator_list : list + List of models. + + param_list : list + List of parameters. + + Returns + ------- + bool + True if there is only one model and the parameter list is empty, False otherwise. + """ + return (len(estimator_list) == 1) and (len(param_list) == 1) and (len(param_list[0]) == 0) def param_grid_is_empty(param_grid): + """ + Check if a parameter grid is empty. + + This function checks if the length of the parameter grid is 0. + + Parameters + ---------- + param_grid : dict + Parameter grid to check. + + Returns + ------- + bool + True if the parameter grid is empty, False otherwise. + """ + return len(param_grid) == 0 def is_linear_model(estimator): """ - Check whether an estimator is a polynomial regression, logistic regression, linear SVM, or any other type of - linear model. + 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. Parameters ---------- - estimator (scikit-learn estimator): The estimator to check. + model : object + The model to check. Returns - ---------- - is_linear (bool): True if the estimator is a linear model, False otherwise. + ------- + bool + True if the model is a linear model, False otherwise. """ if isinstance(estimator, Pipeline): @@ -433,15 +519,19 @@ def is_linear_model(estimator): def is_data_scaled(X): """ - Check if the input data is already centered and scaled using StandardScaler. + 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. Parameters ---------- - X array-like of shape (n_samples, n_features): The input data. + X : array-like of shape (n_samples, n_features) + Input data. Returns - ---------- - is_scaled (bool): Whether the input data is already centered and scaled using StandardScaler or not. + ------- + bool + True if the input data is scaled, False otherwise. """ mean = np.mean(X, axis=0) @@ -453,6 +543,25 @@ def is_data_scaled(X): def is_regressor_or_classifier(model, is_discrete): + """ + Check if a model is a regressor or classifier. + + This function checks if a model is a regressor or classifier depending on the 'is_discrete' parameter. + + Parameters + ---------- + model : object + The model to check. + + is_discrete : bool + If True, checks if the model is a classifier. If False, checks if the model is a regressor. + + Returns + ------- + bool + True if the model matches the type specified by 'is_discrete', False otherwise. + """ + if is_discrete: if is_polynomial_pipeline(model): return is_classifier(model[1]) @@ -484,6 +593,22 @@ def scale_pipeline(model): def is_polynomial_pipeline(estimator): + """ + Check if a model is a polynomial pipeline. + + This function checks if a model is a pipeline that includes a PolynomialFeatures step. + + Parameters + ---------- + model : object + The model to check. + + Returns + ------- + bool + True if the model is a polynomial pipeline, False otherwise. + """ + if not isinstance(estimator, Pipeline): return False steps = estimator.steps @@ -496,6 +621,22 @@ def is_polynomial_pipeline(estimator): def is_likely_multi_task(y): + """ + Check if a target array is likely multi-task. + + This function checks if a target array is likely to be multi-task by checking its shape. + + Parameters + ---------- + y : array-like + The target array to check. + + Returns + ------- + bool + True if the target array is likely multi-task, False otherwise. + """ + if len(y.shape) == 2: if y.shape[1] > 1: return True @@ -503,6 +644,22 @@ def is_likely_multi_task(y): def can_handle_multitask(model, is_discrete=False): + """ + Check if a model can handle multi-task output. + + This function checks if a model can handle multi-task output by trying to fit and predict on random data. + + Parameters + ---------- + model : object + The model to check. + + Returns + ------- + bool + True if the model can handle multi-task output, False otherwise. + """ + X = np.random.rand(10, 3) if is_discrete: y = np.random.randint(0, 2, (10, 2)) @@ -523,8 +680,31 @@ def can_handle_multitask(model, is_discrete=False): def pipeline_convert_to_multitask(pipeline): - steps = list(pipeline.steps) + """ + Convert a pipeline to handle multi-task output if possible. + + This function iterates over the steps in the input pipeline. If a step is a + polynomial transformer, it adds the step to the new pipeline as is. If the + step is an estimator, it attempts to convert it to handle multi-task output + and adds the converted estimator to the new pipeline. + + Parameters + ---------- + pipeline : sklearn.Pipeline + The pipeline to convert. + + Returns + ------- + sklearn.Pipeline + The converted pipeline. + + Raises + ------ + ValueError + If an unknown error occurs when making model multi-task. + """ + steps = list(pipeline.steps) if isinstance(steps[-1][1], (LogisticRegressionCV)): steps[-1] = ('linear', MultiOutputClassifier(steps[-1][1])) if isinstance(steps[-1][1], (ElasticNetCV)): @@ -535,6 +715,25 @@ def pipeline_convert_to_multitask(pipeline): def make_model_multi_task(model, is_discrete): + """ + Convert a model to handle multi-task output if possible. + + This function converts a model to handle multi-task output if possible. + + Parameters + ---------- + model : object + The model to convert. + + is_discrete : bool + If True, the model is treated as a classifier. If False, the model is treated as a regressor. + + Returns + ------- + object + The converted model if possible, raises an error otherwise. + """ + try: if is_discrete: if is_polynomial_pipeline(model): @@ -547,15 +746,30 @@ def make_model_multi_task(model, is_discrete): return pipeline_convert_to_multitask(model) else: return MultiOutputRegressor(model) - except TypeError as e: - raise ValueError(f"An error occurred due to type mismatch: {e}") from e - except AttributeError as e: - raise ValueError(f"An error occurred due to attribute error: {e}") from e except Exception as e: raise ValueError("An unknown error occurred when making model multitask.") from e 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. + + Parameters + ---------- + estimator : object + The estimator the parameter grid is for. + + param_grid : dict + The parameter grid to convert. + + Returns + ------- + dict + The converted parameter grid. + """ + if isinstance(estimator, ElasticNetCV): return param_grid else: diff --git a/econml/utilities.py b/econml/utilities.py index aa6145d96..008bfc244 100644 --- a/econml/utilities.py +++ b/econml/utilities.py @@ -955,27 +955,22 @@ def fit_with_groups(model, X, y, groups=None, **kwargs): # 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 - # 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: estimator.cv = splits - return estimator.fit(X, y, **kwargs) - finally: + 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... - if hasattr(model, 'cv'): + elif hasattr(model, 'cv'): old_cv = model.cv # logic copied from check_cv cv = 5 if old_cv is None else old_cv From 55c585809d3103570e817cd367d7733f4baefc0d Mon Sep 17 00:00:00 2001 From: AnthonyCampbell208 <78286293+AnthonyCampbell208@users.noreply.github.com> Date: Thu, 10 Aug 2023 17:06:29 -0400 Subject: [PATCH 03/19] Final commit, added encoding for categorical data (untested) and added notebook to showcase some of the functionality Signed-off-by: AnthonyCampbell208 <78286293+AnthonyCampbell208@users.noreply.github.com> --- econml/dml/dml.py | 14 +- econml/sklearn_extensions/model_selection.py | 23 +- .../model_selection_utils.py | 47 +- .../SearchEstimatorList functionality.ipynb | 1031 +++++++++++++++++ 4 files changed, 1090 insertions(+), 25 deletions(-) create mode 100644 notebooks/SearchEstimatorList functionality.ipynb diff --git a/econml/dml/dml.py b/econml/dml/dml.py index 515295444..d7c59013b 100644 --- a/econml/dml/dml.py +++ b/econml/dml/dml.py @@ -484,6 +484,8 @@ def __init__(self, *, model_y, model_t, model_final, param_list_y=None, param_list_t=None, + scoring_y=None, + scoring_t=None, scaling=False, featurizer=None, treatment_featurizer=None, @@ -509,6 +511,8 @@ def __init__(self, *, self.scaling = scaling self.param_list_y = param_list_y self.param_list_t = param_list_t + self.scoring_y = scoring_y + self.scoring_t = scoring_t self.verbose = verbose self.cv = cv self.grid_folds = grid_folds @@ -536,10 +540,10 @@ def _gen_featurizer(self): def _gen_model_y(self): # New if self.model_y == 'auto': - model_y = SearchEstimatorList(estimator_list=self.model_y, param_grid_list=self.param_list_y, + 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, + 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) @@ -549,15 +553,13 @@ def _gen_model_y(self): # New self.linear_first_stages, self.discrete_treatment) def _gen_model_t(self): # New - # import pdb - # pdb.set_trace() if self.model_t == 'auto': if self.discrete_treatment: - model_t = SearchEstimatorList(estimator_list=self.model_t, param_grid_list=self.param_list_t, + 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=self.model_t, param_grid_list=self.param_list_t, + 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) diff --git a/econml/sklearn_extensions/model_selection.py b/econml/sklearn_extensions/model_selection.py index 0e667cee2..d8c55538d 100644 --- a/econml/sklearn_extensions/model_selection.py +++ b/econml/sklearn_extensions/model_selection.py @@ -354,6 +354,7 @@ def __init__(self, estimator_list=['linear', 'forest'], param_grid_list=None, sc self.error_score = error_score self.return_train_score = return_train_score self.is_discrete = is_discrete + self.supported_models = ['linear', 'forest', 'gbf', 'nnet', 'poly'] def fit(self, X, y, *, sample_weight=None, groups=None): # print(groups) @@ -400,6 +401,11 @@ def fit(self, X, y, *, sample_weight=None, groups=None): self.best_params_ = {} return self for estimator, param_grid in zip(self.complete_estimator_list, self.param_grid_list): + if self.verbose: + if is_polynomial_pipeline(estimator): + print(f"Processing estimator: {type(estimator.named_steps['linear']).__name__}") + else: + print(f"Processing estimator: {type(estimator).__name__}") try: if self.random_state != None: if has_random_state(model=estimator): @@ -408,8 +414,6 @@ def fit(self, X, y, *, sample_weight=None, groups=None): estimator = estimator.set_params(linear__random_state=self.random_state) else: estimator.set_params(random_state=self.random_state) - print(estimator) # Note Delete this - print(param_grid) # Note Delete this # 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, @@ -441,8 +445,11 @@ def fit(self, X, y, *, sample_weight=None, groups=None): # 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) - - self.best_ind_ = np.argmax([search.best_score_ for search in self._search_list]) + try: + self.best_ind_ = np.argmax([search.best_score_ for search in self._search_list]) + except Exception as e: + warning_msg = f"Failed for estimator {estimator} and param_grid {param_grid} with this error {e}." + raise Exception(warning_msg) from e 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_ @@ -465,14 +472,6 @@ def predict(self, X): def predict_proba(self, X): return self.best_estimator_.predict_proba(X) - def refit(self, X, y): - # Refits the best estimator using the entire dataset. - if self.best_estimator_ is None: - raise ValueError("No best estimator found. Please call the 'fit' method before calling 'refit'.") - - self.best_estimator_.fit(X, y) - return self - class GridSearchCVList(BaseEstimator): """ An extension of GridSearchCV that allows for passing a list of estimators each with their own diff --git a/econml/sklearn_extensions/model_selection_utils.py b/econml/sklearn_extensions/model_selection_utils.py index 0ab0f87c1..477731600 100644 --- a/econml/sklearn_extensions/model_selection_utils.py +++ b/econml/sklearn_extensions/model_selection_utils.py @@ -27,7 +27,7 @@ from sklearn.exceptions import NotFittedError from sklearn.multioutput import MultiOutputRegressor, MultiOutputClassifier from sklearn.model_selection import KFold -# from sklearn_extensions.model_selection import WeightedStratifiedKFold +import pandas as pd def select_continuous_estimator(estimator_type, random_state): @@ -57,6 +57,9 @@ def select_continuous_estimator(estimator_type, random_state): poly = PolynomialFeatures() linear = ElasticNetCV(random_state=random_state) # Play around with precompute and tolerance return (Pipeline([('poly', poly), ('linear', linear)])) + elif estimator_type == 'weighted_lasso': + from econml.sklearn_extensions.linear_model import WeightedLassoCVWrapper + return WeightedLassoCVWrapper(random_state=random_state) else: raise ValueError(f"Unsupported estimator type: {estimator_type}") @@ -278,18 +281,15 @@ def select_classification_hyperparameters(estimator): elif isinstance(estimator, MLPClassifier): return { 'hidden_layer_sizes': [(10,), (50,), (100,)], - 'activation': ['relu'], - 'solver': ['adam'], - 'alpha': [0.0001, 0.001, 0.01], + 'alpha': [0.0001, 0.01], 'learning_rate': ['constant', 'adaptive'] } elif is_polynomial_pipeline(estimator=estimator): return { 'poly__degree': [2, 3, 4], - 'linear__Cs': [1, 10, 20], 'linear__max_iter': [100, 200], 'linear__penalty': ['l2'], - 'linear__solver': ['saga', 'liblinear', 'lbfgs'] + '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) @@ -324,7 +324,7 @@ def select_regression_hyperparameters(estimator): elif isinstance(estimator, MLPRegressor): return { 'hidden_layer_sizes': [(10,), (50,), (100,)], - 'alpha': [0.0001, 0.001, 0.01], + 'alpha': [0.0001, 0.01], 'learning_rate': ['constant', 'adaptive'] } elif isinstance(estimator, GradientBoostingRegressor): @@ -775,3 +775,36 @@ def make_param_multi_task(estimator, param_grid): else: param_grid_multi = {f'estimator__{k}': v for k, v in param_grid.items()} return param_grid_multi + + +def preprocess_and_encode(data, cat_indices=None): + """ + Detects categorical columns, one-hot encodes them, and returns the preprocessed data. + + Parameters: + - data: pandas DataFrame or numpy array + - cat_indices: list of column indices (or names for DataFrame) to be considered categorical + + Returns: + - Preprocessed data in the format of the original input (DataFrame or numpy array) + """ + was_numpy = False + if isinstance(data, np.ndarray): + was_numpy = True + data = pd.DataFrame(data) + + # If cat_indices is None, detect categorical columns using object type as a heuristic + if cat_indices is None: + cat_columns = data.select_dtypes(['object']).columns.tolist() + else: + if all(isinstance(i, int) for i in cat_indices): # if cat_indices are integer indices + cat_columns = data.columns[cat_indices].tolist() + else: # assume cat_indices are column names + cat_columns = cat_indices + + data_encoded = pd.get_dummies(data, columns=cat_columns) + + if was_numpy: + return data_encoded.values + else: + return data_encoded diff --git a/notebooks/SearchEstimatorList functionality.ipynb b/notebooks/SearchEstimatorList functionality.ipynb new file mode 100644 index 000000000..4464199de --- /dev/null +++ b/notebooks/SearchEstimatorList functionality.ipynb @@ -0,0 +1,1031 @@ +{ + "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//PMlwRJCiBghY7KEECLGTJkyhRNPPJFhw4ZRXFzM888/j8Ph4O677452aCKGlJSU8Mknn/Dmm29SXl7OzTffHO2QhBBC1JIkSwghYsyZZ57Jm2++yTPPPIOiKIwfP57nn3+eadOmRTs0EUM2bdrEZZddRkZGBn/5y18i1SWFEEJEn4zJEkIIIYQQQohOJGOyhBBCCCGEEKITSZIlhBBCCCGEEJ1IxmS1QVVVDh48SHx8fGQSUCGEEEIIIcTRR9M0ampq6NOnDzpd6+1VkmS14eDBg+Tm5kY7DCGEEEIIIUSM2LdvHzk5Oa3+X5KsNsTHxwPhFzIhISHK0QghhBBCCCGixeFwkJubG8kRWiNJVhvquggmJCRIkiWEEEIIIYRocxiRFL4QQgghhBBCiE4kSZYQQgghhBBCdKIel2Q9+eST9O/fH4vFwuTJk1m1alWr6/7jH/9AUZRGF4vF0o3RCiGEEEIIIY42PSrJeu2117j11luZP38+33//PWPGjGHmzJmUlJS0ep+EhAQOHToUuezdu7cbIxZCCCGEEEIcbXpUkvXYY49x7bXXctVVVzF8+HCeeuopbDYbL7zwQqv3URSFrKysyCUzM7MbIxZCCCGEEEIcbXpMkuX3+/nuu++YMWNGZJlOp2PGjBmsWLGi1fs5nU7y8vLIzc1l9uzZbNy48bDb8fl8OByORhchhBBCCCGEaK8ek2SVlZURCoWatURlZmZSVFTU4n2GDBnCCy+8wLvvvsvLL7+Mqqocd9xx7N+/v9XtLFiwgMTExMhFJiIWQgghhBBCdESPSbKOxJQpU5gzZw5jx45l+vTpvPXWW6Snp/P000+3ep958+ZRXV0duezbt68bIxZCCCGEEEL0dD1mMuK0tDT0ej3FxcWNlhcXF5OVldWuxzAajYwbN44dO3a0uo7ZbMZsNv+oWIUQQgghhBBHrx7TkmUymZgwYQJLly6NLFNVlaVLlzJlypR2PUYoFGL9+vVkZ2d3VZhCCCGEEEKIo1yPackCuPXWW7niiiuYOHEikyZN4vHHH8flcnHVVVcBMGfOHPr27cuCBQsAuP/++zn22GMZOHAgVVVV/OEPf2Dv3r38/Oc/j+bTEEIIIYQQQvRiPSrJuvjiiyktLeWee+6hqKiIsWPHsnjx4kgxjMLCQnS6+sa5yspKrr32WoqKikhOTmbChAl8/fXXDB8+PFpPQQghhBBCCNHLKZqmadEOIpY5HA4SExOprq4mISEh2uEIIYQQQgghoqS9uUGPGZMlhBBCCCGEED2BJFlCCCGEEEII0YkkyRJCCCGEEEKITiRJlhBCCCGEEEJ0oh5VXVAIIYQQ7aOqGiFNQ9U06kpcaRpoaLV/oa72laIoKLX3U2qvKCgoCugUBV3dX53SbDtCCCGakyRLCCGEiGGaphEIaQRCKkFVI6RqBFU1/DekRZapWv1fVe2aWBSFSOKl19Veaq8b9LV/dTr0OgWjXsGo12HUS6cZIcTRR5IsIYQQIopUVcMXVPEHVXyhEMHahCp8CSdSsULTwheV9selKNQmXbpI4mUy1F70OswGHYoiLWRCiN5FkiwhhBCii2laOJHyBVR8wVA4qQqFE6tYSqK6gqYRbnELhfC0so7RoGCqTb4sRj3m2r/SCiaE6KkkyRJCCCE6USCk4gmE8AZCkaTKG1Aj46JEc4GgRiAYwuULAYHIcp2ORkmXtfYiY8OEELFOkiwhhBDiCAVCKm5/OKHy+EN4AqFe3zLVnVQV3L4Q7ibJl9moCydcJkm8hBCxSZIsIYQQoh00TcPtD9Vegrj9klBFiy8Q7npZ5Q4nXooCZoMOm9mA3aTHZjJgMkhXQyFE9EiSJYQQQrQgGFJx+cMtVC5/EI8/JF3+YpSmgTeg4g34qahdZtAr2E0GbGY9cWYDFqM+qjEKIY4ukmQJIYQQhKv8Of1BXL7wxePv/Dro3kCIak8Apy8YvnjDf121tz3+ULggRkglUFscI9Cg2iDUz1+lANTOb1XXkmM2hMcvmWvHMdVdjzMbSLAYiLcYibcYiLcYSLAYe3Vlv2BIo9oToNoTbu3S6xTizAbiLAbsZj1mgyRdQoiuI0mWEEKIo1Jd9z9ngwTnx7RUuf1Bih1eDlV7Ka3xUen2U+7yU1F7qXT5cflDnfcEOoFJryPJZiQtzkxanInU2r/h22Yy4s0kWo29IhELqY2TLqMhnHTFm43EWQzoZUyXEKITSZIlhBDiqBEMqdR4g+GLL9DhSXv9QZX9lW4KK9wcqPJQVB1Oqooc3sjBe1uMeoV4sxF7bTc2e23rSpzZEBlLZNKHS5oba+eSqptjCkADVA3QNGr/oGrh1q6mZeJ9wXClQ6c3SI03gKP2b403SFDV8IdUSmp8lNT4Wo3XZtLTJ9FKnyQL2UnWyPWcZBtx5p57GBEIalQGA1S6AihK+HnWtfRJ10IhxI/Vc78dhRBCiHbw+EM4ahMLTztbklRN42CVhz3lbvaWuyiscLO33M2hak84wWlFvMVAdqKF9HgLqXYTKU0vNhM2kz7qLUOapuEJhHB4g1S6/JQ5fZQ7w3/LXH7Knb7IMrc/xI5SJztKnc0eJz3eTP9UG/1T7eSn2emfaqdPkrXHtQppGrh84RLyRdVgMujCXSqtRuwx8H4JIXoeRdNkGO/hOBwOEhMTqa6uJiEhIdrhCCGEaIOmabj8IRyeAA5vgEDw8D9zmqZRXONje3ENO0qcbC9xsqPEiSfQckIWZzaQl2ojJ8lKVqKV7EQLWYkWshIs2Htwy05L/EGVQ9UeDlZ7OVTl4WBV+PqBKg8VLn+L9zHqFfJS7QzOjGdIZhyDM+Ppk2RF10MTFb1OIcEaTrjizQZJuIQ4yrU3N5Akqw2SZAkhROzTNI0aXzCcWHmChA7T3BQIqWwrrmHjQQcbDzrYXlJDjTfYbD2TQUf/VBt5KXb6pdrIS7GRl2on2dY7xij9WE5fkL3lLvaUudhd7mZPmYu9FS68geZ9MO1mPYMz4hmcFc/QrHiGZydgM/W8hFSngwSLMZJwydxcQhx9JMnqJJJkCSFEbNI0DacvSJU73GLV2vgqtz/IlqIaNh10sOFgNduKawg0md/KoFPon2pnUGYcgzLiGJgRT78UW4/r9hZtqqZRVO1lZ6mTrUU1bCuuYWepC3+o8ZujU2BAehyj+iYyqm8iw/v0vKSrLuFKshmJtxijHY4QoptIktVJJMkSQojY4vIFqfIEqHYHWmyxCqkaO0ud/FBYyfeFVWwpcjQbR5VkNTKiTwLD+yQyNCue/DQ7Rr1MXtsVgiGVPeVuthbXsK2ohk2HHBQ5vI3WqUu6RuckMq5fMsOzE3rU+2HQKyTZjCRZTVhNUjRDiN5MkqxOIkmWEEJEnzcQotLtp9rT8hircqePHwqr+H5fJWv2VTXr/pcRb2ZEnwRG9ElkRJ8E+iZZO6XLn04HBp0OvU5Br1Mw6BR0OgW9oqBTAAV0tXNZ6ZS6+a3qqgSGn0fDX2GNcJKoaRohTUPVwvN3qZpGSNUIqhrBkHbY7pA9QWmNjw0Hq1l/oJoNB6o5VN046bIYdYzJSWJCXjIT+iWTkWCJUqQdZzbqSLIaSbabelSiKIRoH0myOokkWUIIER0hVaPK7afS7W82MbCmaewpd7Nydznf7CpnZ6mr0f9tJj1jcpIY1y+JcbnJZCV2/CDdaFAw6utLqBv0DW8rGKJ4AK1p9QlXUFUJhupLuAcikxn3nJ/3uqRrzb4qvi+spMrduBx+brKVCXnJHFuQytCshB7RjVNRwkVSku0mEixSMEOI3kKSrE4iSZYQQnSfugIWVa7wOKuGv1AhVWPTIQff7Cpn5e5yih31czspwKDMOMb1S2Z8v2QGZ8S1KwnS6cBs0GM26GoveszGcCLV04saaFp4Hix/7XxZvqCKNxDCGwh1eH6w7qRqGrtKXXxXWMl3eyvZ2qS7Z5LVyOSCVI4rSGVUTmKPaC3S6xSS7UaSbSaZg0uIHk6SrE4iSZYQQnQ9f1Cl0u2nwuUn2KAoRUjV2HCwmi+3lbJiV3mjboAmvY5x/ZI4Nj+VY/JTSLQevviAXqdgNemxGsMXiymcVB2N6lq9vIEQHn8ITyCEr4WqgLHA6Q3yw75KVu+pYNWeCly++tL6dpOeY/JTOK4glfF5yT3i/bSZ9aTaTSRapUqlED2RJFmdRJIsIYToOg5vgAqnv1HypGkaW4tr+HJbKct2lFHZoOtYvNnAMfkpHJufwrh+ya22CigKWE167CYDVpMem0nfI1o8oimkhico9vjrEy9/MLYSr2BIZf2BalbsCncTbbhvWI16pgxI5cTB6YzOSYr5LoUGvUKK3USyzYTJIPumED2FJFmdRJIsIYToXIGQSqXLT4Xb32jc0N5yF59vLeXL7aWU1NR3BYwzG5g6IJUTBqUzsm9iiwfPOh3YTQZs5nBiZTPppZWgEwRCKi5fEJc/hMsXjKnWLlXT2FpUw9c7y/l6Z1mjfSbZZuSEQemcODidgRlxMb0vKArEWwykxpmJ62WTWQvRG0mS1UkkyRJCiM7h9gcpq/E3Gmvl9AX5anspSzYVs73EGVnXatQzuSCFaYPSGZub1KwVqq6lKt5sIM5i6HFzLPVUgZCK2xfC6Q/GVNKlahqbDzn4orb1s2HLaN8kKycPzeCUoRmkxpmjGGXbzEYdaXFmmfBaiBgmSVYnkSRLCCGOnKZpVHsClDn9ePzhsTSqprFufzWfbC5mxc7yyES1ep3CxLxkThySwcS85l0BTQYdcRYDcebwJda7gx0NfMEQTm+QGm8Qpy9ILBxRBEIqPxRW8cW2Er7ZXRHp8qhTYGJeCqcOz2RiXnJUq0O2Ra9TSIszkWI3xXScQhyNJMnqJJJkCSFExwVDKhUuP+UNCllUuPx8tLGITzYXN+ra1S/FxqnDMzlxcDpJNlOjx7GadCRYjCRYjVKVLcZpmobTF062HJ5gTIzncvuDrNhZzpLNxWw86IgsT7YZOWVoJqcOz6RPkjWKER6eokCy3USqXaoSChErJMnqJJJkCSFE+/mCIUprfFS5w10CNU1jw0EHH6w/xIpd5ZFJdO0mPdMGpzNjWCaDGoyZUZTwHFcJViMJFqMUBOjBvIEQ1Z4ADk8Abwx0K9xf6WbJpmI+3VJClae+YMbonETOGt2HSf1TYrp1NMFqID3eLF1jhYgySbI6iSRZQgjRtrrxVtW1B69uf5DPt5bywfpD7K1wR9Yblp3AGSOzOG5AaqNy2zazniSrkUSrUbpH9ULeQAiHN5xwNZ1YursFQiqr91SwZFMx3xdWRubgSo83c+bIbE4bnklCG9MBRJPdrCcjwSJFMoSIEkmyOokkWUII0boab4DSGl9k7qJD1R4WrT3I0s0leALhZWaDjhOHZHDmyCwK0uMi9zUbdSTZjCRZpYT10cQXDFHtDlDpDkS9S2FJjZcP1xfx0aaiSLEMk17H9MHpnDU6u9H+GmusJj0ZCWYSLLGbEArRG0mS1UkkyRJCiOaq3QFKaryRbmBbDjl4e80BvtlVHmkZ6Jtk5cxR2Zw8NCNy1l2vU0i2hxMrq0nGmBzt3P4gle4A1e5ApCtpNPiCIb7aVsZ76w+yq9QVWT6iTwI/GZfDxP7J6GK02p/VpCM9zkKiTZItIbqDJFmdRJIsIYSoV+X2U1LjwxdQCakaK3eX884PB9hcVBNZZ0JeMrPH9GFsblJkrFWcxUCKzUSC1SClqUUzmqbh8Aapcocnpo7WkYmmaWwuquH9dQf5emf9GMLcFBs/GdeX6YPTY3ZSa4tRR0aChcQY7uooRG8gSVYnkSRLCHG00zSNKneAUmc4ufIHVT7ZXMw7aw5wqNoLgEGncNKQDGaP7UNeqj28TK+QbDORbDc2Gn8lxOG0Nll1dyt3+li09iAfbiiKdH1NtZs4Z0wfTh+ZFbMFKKymcLIl3QiF6BqSZHUSSbKEEEeruuSqpMaHP6jiDYRYvKGIt37YT6U7XOAizmzgzFHZnDUqm2R7uPy63awnNc5MgkVarcSP4/AGqHD6ozoHl8sX5KONRby75iAVbj8QroB55shszh3XN2ZbjqwmPVmJUiBDiM4mSVYnkSRLCHE0qnL7KXaEkyu3P8j/1h3inTUHcNQWB0iLM/OTcX05dXgmFqMeRYFEq5H0eLPM5yM6nT+oUun2U+70R23sViCk8sXWUt76YT/7Kj1AuKjLmaOyOW9cX5KbzPEWK+zmcLIVqy1vQvQ0kmR1EkmyhBBHE4c3QHF1uKCF0xtk0doDLFp3MFI9MCvBwgUTcjh5aAZGvQ6DXiHVbiLFbpLS66LLaZpGpTtAudMXtbm3VE1j9Z4KXl29jx0lTgBMBh1njMji/PE5kRbdWJNoNZKZaJauu0L8SJJkdRJJsoQQRwOXL0iRw4vbF8LtD/LumoO8/cOByFiUnGQrF03MZdqgdPQ6BYtRR1qcmSSbUboEiqio8QYod/ojpde7m6ZpfLe3kv+sLmRbcW2ypddx+shwspUSg8mWokCy3URmvFlOighxhCTJ6iSSZAkhejOPP0SRw4vTG8QbCPHB+kO8+f3+yIFr/1QbFx/TjykFqeh1ClaTnvR4c8yOQxFHH28gRLnLT6XLH5VxW5qm8X1hFf9ZVcjW4nCVTZNex1mjs7lgQg7xMViAQlEgI95MWpwZnU5OkgjREZJkdRJJsoQQvVEgpFJU7aXKHSAQUlmyqZjXVu+LDOzvm2Tl8mPzOG5AKjpFwW7Wk5Egg+hF7AqEVMqcPsqd0Uu21uwLJ1t1UxrYTXrOG5/D7DF9YnKsokGvkJlgiclWNyFilSRZnUSSLCFEb6KqGqVOH6U1PoIhjS+2lfDvVYUUO3xA+Oz2Tyf146QhGeh1CvEWAxkJZhk0L3qMYEil3OWnzOlDjcKwrbpuhC+t2MOecjcASTYjl0zM5bQRWTE5z5bVpCM70YpdTqII0SZJsjqJJFlCiN6grmBAscNLMBQ+4/7C8t3sLnMBkGwzcnGDg8B4i4HMBAtWU+ydfReiPUKqRrnLR1lNdCoSqprGl9tKeWVlIUWO8HxymQlmLp+cx7TB6ehicCxjotVIVqIFkyH2EkEhYoUkWZ1EkiwhRE9X4w1QVFsxcG+5ixe/3sN3eyuBcHnnC8bnctbobCxGvZR7Fr2OqmqUu/yU1viikmzVdcd9dXVhZH65gRlx/Pz4fEb0Sez2eNqiKJAebyZdxmsJ0SJJsjqJJFlCiJ7KFwxRVO3F4QlS6fLzyqpClmwqQtVAr1OYNSqbiyfmkmA1YjPryZQxV6IXC6ka5U4fpVHqRugNhFi09iBvfrc/UrXzuAGpXHVcPlmJlu4PqA0GvUJ2ooWkGJ3/S4hoaW9u0OPag5988kn69++PxWJh8uTJrFq16rDrv/HGGwwdOhSLxcKoUaP44IMPuilSIYSIDlXVKHZ42V7spLTGx2urC/nFy9/x0cZwgjWlIJW/XTqea08oIDPRTF6ajQHpcZJgiV5Nr1PISLAwJDOe9Hgz3d1bz2LUc9HEXJ752QROH5GFToGvd5bzq1e+48Xlu3H5olOKvjXBkMa+Cg+7Sp14a5NCIUT79aiWrNdee405c+bw1FNPMXnyZB5//HHeeOMNtm7dSkZGRrP1v/76a6ZNm8aCBQs466yz+Pe//83ChQv5/vvvGTlyZLu2KS1ZQoiepNod4JDDgz+g8s2ucp5btpuSmnBRi0EZcVxT20XJaFDIjLfE7MSpQnS1QEiltMZHRZRKv+8pc/H88t2s2VcFhMdDXTa5H6cNz0IfY930FAXS4sxkxEsXQiF6ZXfByZMnc8wxx/DXv/4VAFVVyc3N5cYbb+SOO+5otv7FF1+My+Xi/fffjyw79thjGTt2LE899VS7tilJlhCiJ/AGQhys8uDyhdhX6eaZL3dFDt7S4kxceVw+JwxKw6BXSI83k2aXgyUhoHG32u5WV4nw+eW72V/pAaAgzc4vpw9gWHbsHXMYDQrZiVaZJ08c1dqbG/SYviF+v5/vvvuOefPmRZbpdDpmzJjBihUrWrzPihUruPXWWxstmzlzJu+8806r2/H5fPh8vshth8Px4wIXQogupKoaJTU+ypw+XL4gr67ex6K1BwmpGgadwk/G53DhhBysJj3JdhOZ8WYMMVhCWohoMRv05KXacfmCHKr24vF3X9c4RVGY2D+FsblJfLSxiJdXFrKrzMVv/7uOU4ZmcOVx/WNqTFQgqFFY7ibOYqBPkgWzQaqPCtGaHpNklZWVEQqFyMzMbLQ8MzOTLVu2tHifoqKiFtcvKipqdTsLFizgvvvu+/EBCyFEF6vxBjhY5cUXCPH5tlJeXL47Ur3smP7J/Pz4AvokWYm3GMhKtMTkZKhCxAq72cDAjDiq3H6KHF4Cwe7r6GPQ65g1ug/HD0rnnyv28PGmYpZuKeGbXeVcfmweZ4zMjqkuhE5vkO3FTjISwlUIlRgsRy9EtPWYJKu7zJs3r1Hrl8PhIDc3N4oRCSFEY4GQSlG1lyp3gH2Vbp78bAcbD4Zb3bMTLVx7QgHH9E/BZNCRnWQhwSJde4RorySbiQSLkTJXeNLu7qxEmGg1cuPJgzh1eCZPfbGTnaUunv5yF0s2FcdcF0JNg+JqHw5PgL5JNplTT4gmekySlZaWhl6vp7i4uNHy4uJisrKyWrxPVlZWh9YHMJvNmM3mHx+wEEJ0gXKnjyKHF69f5Y3v9vHmd/sJqhomg45LJuZy7ri+mAw6MuLNtRXU5AyzEB2l0ylkxFtItpkiJzS609CsBP544Vg+3lTEP1fsjXQhPHV4Jlcfl0+cJXYO3zx+lZ2lTlLjTGTGW2SspxC1ekzHfJPJxIQJE1i6dGlkmaqqLF26lClTprR4nylTpjRaH2DJkiWtri+EELHKGwixs9TJwSovawqruOnVH3h19T6CqsaEvGT+dul4LpyYS1qcmcGZ8WQkWCTBEuJHMup15KbYKEi3YzF27yGTXqdwxshsnrp8AqcOCw99WLKpmF/9+zu+2l5KLNUt0zQoq/GzvcRJjbd7E1IhYlWPqi742muvccUVV/D0008zadIkHn/8cV5//XW2bNlCZmYmc+bMoW/fvixYsAAIl3CfPn06jzzyCLNmzeLVV1/l4YcflhLuQogeQ9M0Sp0+Shw+qtwBXli+m0+3lACQbDNy3bQBTB2QisWkp0+SVea6EqKLaJpGuctPscMblcmMNx6s5q+f7YhUITymfzK/mj6Q9PjY632TZDPSJ8kaU+PIhOgsva66IIRLspeWlnLPPfdQVFTE2LFjWbx4caS4RWFhITpd/Zmm4447jn//+9/cdddd3HnnnQwaNIh33nmn3QmWEEJEkzcQYn+lG7cvXNji2a92UeMNogCnj8xizpT+xFsMMvhciG6gKAppcWYSrcaodCEc0SeRv1wyjte/DXcTXr2nkg0Hvudnx+Zx5qjYKoxR5Q7g9AXpm2yVMaHiqNWjWrKiQVqyhBDdTdM0Smt84dLsNT6e/HwHq/dUApCXYuOGkwYyNDsBu1lP32SrlFEWIgqcviAHqzz4At3frFVY4eavn25nc1ENAEMy47nx5IHkpdq7PZa2SKuW6G165WTE0SBJlhCiO3n84dYrjz/E0i0lPLdsFy5fCINO4ZJJ/Th/XF/MRj3ZiRaS7bEzf44QRyNNC89TV1rjo7uPplRNY/GGIv7x9R48gfB3xE8n9eP88Tkxl9AYDQp9k6zES6uW6AUkyeokkmQJIbpDw9arEke49eq7veHWq4EZcfzmlEHkpdpJshnJTrTIhMJCxJBw115Pt05kXKfc6eNvn+9k1Z4KIPx9ccuMwfRLsXV7LG1JthvJTpRWLdGzSZLVSSTJEkJ0tboDNLcvyMebinlh+W7c/hBGvcKlk/I4b1xfrKZw10ApbCFE7KqbYqG7C2NomsZnW0t45stduPzhVq3LJoe/O2ItoTEaFHKSbfJdJnosSbI6iSRZQoiuVO70cajaS1mNj798up3vC6uA8BiLm08ZRG6KjZQ4E9kJMv+MED2BP6hysMpDjTfY7dsud/r462c7+La2FXxIZjw3zxhEbnLstWqlx5vJTJCCPaLnkSSrk0iSJXqFUADUECgKKDpACV9HAd0RdDvTNNDU+sc5Wn8kNS38umohUIMNrtd2GdLpQdGDzhC+XvcXCIRU9ld6cHqDLNtRxpOf7cDpC2LS67hscj9mjz1M65Wm1W6v4UUNv5eKvsF26/7qGt838rVfe71uX+jq97Juv6nbfqNtNbjeWXFEttdwX9Ud/rFVtf4+WoPmiIavUV28nfm6qWp439HU2hh1bcfa9P4A1L62Td/jtq4Djd6DOk322yPS8H3X1NrtHea1hK7bDyP7f9PX6TAaxtlsWesq3QEOVnuOvFVLC+8TSu2+qGgqGvX7sKbogCb7iaahaSpLN5fw7PI9kRbxOZNzmD06E12D9Ro+F03R1+9zP4YaAmpjbcdjWk06cpJtWIyH2b/qPhtqw66YLbxvkc+M/sh/1xr+bfq56M7fuhb306bPuUks3fU9fiTqnk/d57/u+UR+Dxo+36bfB3XPSQ/62Gj9lCSrk0iSJXqFqn3gLjvMCi19qdX+cDf6Ymxy4Nn0/pEEruEPXIOvmEZfN239iLRxQNPsh5DGP7JND1QbHnBHLqFWnk9rmsZxZF+fNT6V0hofDq/KE9+5Wbo3XAp6ULKO3x1rJy9RT6LNRFqcuX5airrXQA12MOa6uDsSa5P3M7L9Fg6Gm2p0EK/S/OC6g3G3ut0Gy+ria/jeqqE2ttfKPv6jtPD5afiZUBp8Npom561uW6lNlmv35Wafx5YOvrqAogOdMZxw6Q3hv4qu/nlETjg0+Fx1enwKjT7TjT7jusb7WuS7qptfpyYCoXBhDHejsVpN9uPafVirXabUvYYd/cw2Wb/EpfKn1R6+LQpve1ymntsnW0m3tZWAhL9DtUZJfv3nTGvwPajUvtfhv4cZj6boAV3tYzbZvk4hPc5Msq22kI/WIKk63GMeVoP9o+4EQcOD+aYH+0f0+C3sg02f3+GSiWbJBZ28nzb9XW7pu7Tpvkjz7/aWfjsbnaRpep+6RL7B568zWBIhpaBzHutHkiSrk0iSJXqFit3grYp2FEc9VYUSp5cab5C1xUEeXemhxK2hU+CSYSYuH2HGZtKTGW/GapKy7EL0FtWeIGVOL2o3H3Fpmsb/dgZ46gcvvhDEm+A3x1iZlht7Vf5spvCcf0bpFi1aIklW7yNJlujx3BWw4q/gra5flnMMpA8JX3ccgu0f07wJn/D13EmQPSa8rrMENi2qfxydHvR1Z7eNkDEcMoaF/+etht1fQcgPagBCwfrrqgp9xkK/Y+vXXfNK+Lqq1p/dr+sKlzMRBs8M/9/vhmWPNT+rXndmPX0I5E8LrxsKwPrXw2dR686MRs68q5AyAAaeEl5XU2HlMy2c+at9XZL61ccAsOLJ8OPX3VfTALV+3TGX1K+76jmCAR+ugIZfVVhborKtIoCeEE5jGgOmnseINAOJViPpG15ACbgavIENDjjsaTDhivrba16pfV/rzuzr6+O3JMDI8+vX3fIBeCpaPotqtDaOd/0b4DjY4HnV7gsAehMcd2ODdd+Eyr00V9sN8YTb6hdteAvKtoWfU+R9M9ZfH/ez8H4EsGcZVOxq8vo2MPZSMJjD1/d+DaVbG7xkTc7MjroATLXzBx1cA2Xba7tWNmgdqtvXhs4Cc3x43X0r4dA6Gp+1brB/DJ0FtpTwuofWwoHvW28RG3oWxGeFrxdvDD92s9ba2tuDZkBC3/D18h2wb1V9DE31Px4Sc8LXq/ZC4aqWY9VC0HciJOWG163YDTuX1rc+NTrDDxScBFkjax93H2xe1OBMfYPuWKoa/hzXres4GN4nWjsb329K+DsFwt8na15psC9Se7/a++YeC/knhK+7y2HVsw26LxqIdInVGSBzJPSbHF7XVwPf/6txa4iiD++7eiNkDIX+tY8b9MLWD8PfT2qgtmt1oP522uD6z70ahFXPEPm8QYOWBy18ADj49PrnuvLp2vc2/LqFUHD6VQIqBOzZOHOmR1ZN2fIfFDVA3fev0qDVIGjLomrg7Pp1N7+MLuiJtDZFuuWhEbKkUJ1/ZqPH1fuqqQnAyiKNMq9CCB15SUbG9kvFM/icyLoJexZj8FbW3qr/jlA0FdVoo3LQBZF1k7e/icFdQn3rh672XVPQDBbKh8+pf9zdH2JyHap71Nr16+6nb7RuYuEnJPkOYNLVffc2aQk/7qb6lqntS6B8Z+PPcm0MAIy7LPyeA+z+Ekq3Nd4n6n4PACZfV/+53/MVFG1o0tLd4H0ee1n4gB9g/7dQtL7+N7Du91DRh/efgTPqH3f/6vAlFGzc4lvXRXnClZCQXRvDctixpMn3NfXf8+PnQEp+eNmhtbBjaX233oafC50+/Fmu+9yXbQ9/t9a1Ptd93hUF0MHAk+sTmco9sOvz5l3h6y6DT4Ws0eF1qwrDvzG6um7qOhq1fOVNhcwR4XWr98PGd2hV3nHQd3z4ek0RrHs9fD1rZOPfkyhqb24QG50bhRBd5+snwklJQ6a4+iTLWQTfvdj6/c3x9UmWuxzWvNz6uhOurE+yXGXw1f+1vq6i1CdZflf9F2lLTPb6A52gB3Z80vq6Q85skGT5wwdmrSk4qXGSte7V1tftd1zjJGvTO/VJVlN9xjdKWtRN72Lw11D7s8wMYEbtt68rcQglGReRnmAmzmSAXUvDr11LUgoaJ1lbF0P1vpbXTejTOMna+Fb4gL0l1pTGSdauL6B4Q8vrGu2Nk6x9K8MHDi1RdI1/FA9+H/6Bb824yxvE8Pnh3+dRFzRIslbAlvdaX3fIGfVJ1p5lsOHN1tfNm1p/UHRofX3y35J+x9YnWUXr4fuXWl83d3J9klWyCb7/Z+vrZg6vT7JKttQe2LcioW99klW2Hb55svV1T76r/mCrej/8cJjPcnJ+feLkLAon3q2xJtav6y4P72utrptSn2R5q2HTu62va0muT7L8bti2uPV1R55fn2QFPOGTK60ZelaDJMsHy//c+roDT22QZIUO/z3V/4TGSda61xp1ldJD5DvAlTG+UZKVtOMt9EFPiw/rSR3eKMlK3LMYg6+qxXW9iQMaJVkJhZ9gdBeTBORC/VGfE/Zs6sPGlDMZkRZemLTrPcyOlk6YQMCa3ijJsh9cgbVya4vrhozxjRKn+ANfYStb1+K6qs7YaF3bgeWYilv5PoFwklVn79ew67PW1x19cX2StXcFbPuw9XUnXll/ff934e/31gw/tz7JOvAdrP1P6+tmj63/PinacPj9Z8S59UlW9b7wd2CrMdQnx1TsCp8EaU3aoPrPffmOw39PpQ1snGR994/W180aUZ9kOQ4c/vfTnlGfZLlKD/8dbE+rT7I8lfXfJ97qmEmy2kuSLCF6O2dx+G/6UEgdEL6enFf/f1sqDD278XiRhn3x0wbXr2tNrj9w17Twma1QIHymKxQIH5jVMceHE5O6s3t1Z5Hrzq5lj65f1xQHY35au22l/kx13dm41EH16xqscOz1tS1iDbZfd6YtY3j9ujo9DD4DUGtbeRqcZdPpGz8uCoy+qPZkZYO+5HUtASn9G7+uYy4Nr9PSeLTag+mgqlHi8GEqOIe95S7WFflBC2HRa4zJMpMdb0SXkEVuig1DXReZMZeGk87wi9x4m9bkxreHzgr/8DTqL18bd91BQJ3+x4ffy0iLSV3cSvj1b2jwzHBLY13LWMPXSN+km9Gg0yBrFC1qOvZi0Mza90dr8t7V7j+6Bj9JfcaBwdJ8/ECdhnH0GVPfstLSWD2jtf562uDw2eXI+CG1fn/QGRqvmzUKRl5A41aeBuMTLAmNH3f4uS2Mc6hlS62/njowvG5EgzPVmgb29Pp/JebUHrg3GQNRJ67BunGZ4efW9Oy/poWfY6PH7QsjftJ4vFfD2FMHNnjcrPDns9mYRq32c9Rw3YxwstxsXErdazqywWuSAuOvaHlsiKLUH5QBWJNg0rWNz6SrQSLj2rIafp/Yw/E2/LzX7WNqoPHjGsyQP71xi3ykddUYPkCto9PD6EsavBda489TXctCnVEXNW6xqGu1QMWcmIfZoMMXDCdhjv5nhGOr/Q7RGrQaBmzpjR62asA56ALuyNit+jFcCkFrk3XzZ6ELuMKfHC2EooUodwX44ZCffYEE/rrUzWXDTVw2wowz+zi8yUMj70N9kQ0dIWPj7whH3kzcGePr3mDQNJTalh5N13iS9Jq+J+BLGkjkM9lgf9CaFFRxZU8hEJ+LpigYdDpsZlN4TsCGv011+k0J79MNx9Y23NcaPnbOBDDZWigKVLuO0dZ4XYO58fdDw/e57oQNhE+IDD+3tpdGg31MVcP7kNFSv27WqPD+o69t6YqMGattHW74+ew7IZxQNvxdrnt+Wqj+JAyEf9vHX9FyK50WCn8v1EnqV/s91WSMaN3+3PBx47Ng2DkttJDVflc2PDaIzw7/fka2q9Y/P0UHqQ26+cVlhlsDW6TVn6iF8Pdm3cm3hr/tPYR0F2yDdBcUPd4bV8LGt8Nf2CN/Eu1ojhpuf4hih5dqr8rj33r4vDBcznl8pp7fHWsl1aojPd5ColXOdQlxNFJVKHf5qPK00iLehVx+jSe+80aK7oxO1zNvipW0NotidC8FyIi3kCDfk6IHjsmSvVaI3q6uVcScUNti0eQse3tKvTY9F9NqVaGWHu8wj9/R0shNWylaarU4rKbVk1q53XBZS2OYGpX2blLFUNFR6gxQ5vex1WHkkc+LKHIG0SlwxbgkLhwZj82oJzvJitmga77dZs+vlRaMRk+rpefTwvVm6zfcZGuvbYNlTddvVumvQQx192taVatZmd4GMTaLm1Yes0krTXsfs+H9mz2vFvaFlspdt7mvtbL/HnFJ5dbu14HPQKP3qr2P33S1H/FZbamKWsPHOsIS6S0E2eC+SuO/dS0ALbX61t1XUVq+b5v7RgvvRbPPbNPbCjpFIT1OweIPUVTtI6g2Xl9p9rrV7uMN4tJa+nw32J7S0v6haVjMcPuJMHGXk7+sKGddaYhffuTmtuPTmJRja/Y4jVqrtIbLFVDqWr2atJK3pdHzIvL4WqP7Kxz0KzgNRjITLOgP+7jteR9a0NL3ZaPXtYXvsabjpGpjbTumptulhe01uW/T+7e2T7b63Ggl9lZen3Z/9lr7vWn6vxZutzeehrEY7fQ00pLVBmnJEj3e3q/DYzUKpkNy/2hH06sFQyr7Kj04PAHe+eEA//xmLyFVIyPezO0zhzA0K4GMBDMZ8TIBpxCiXrB23rxoTGB8sMrDwo+2sKs0fELuJ+P68rNj88Ld9GKIyaAjL7WNObWE6AZSXbCTSJIlejy/KzxoPC697XXFEXP5ghRWuCl3+nlsyTa+L6wEYOrANG44aSBJNiO5KbbmEwsLIUStMqePompvuxpgOlMgpPLC8t28vy5cBXBIZjy3zxxCZoKljXt2L0WBPklWUuymtlcWootIktVJJMkSPZ63OjwY154W7Uh6rdIaH8UOL5sPOVi4eAtlTj8mvY7rphVw2vBM4q1GcpOtMXdmWAgRezz+EIUVbvyN+w92i693lvGXT7fj8oWwm/XcdPIgjhsQe78dSTYjfZOs6GROLREFkmR1EkmyRI/3wyvhaj/Dz25enU78KCFVY3+lm2p3gPfXHeKF5bsJqhp9k6zccfpQ8tPtZCZYSI83RztUIUQPUvfd4vB0f/fBYoeXP3y0la3FNQCcO7YvVx7XH32MJTQWo45+qTbMBuk+KLqXJFmdRJIs0eP9YWB4XopfLmu91LboMG8gxN5yN1VuP098uoNlO8JzW00dmMZNJw8kyWYiN8WKzSTdA4UQRyZa3QeDIZV/frOXt384AMCIPgn8buZQkmOsm55OBzlJNhJtxrZXFqKTtDc3kL4rQvR2gdoJLhvOAyJ+lGp3gB0lTrYX13Dr62tZtqMMvU7h2hPy+d3MIWQlWhiYEScJlhDiR0mLM1OQbsdk6N7DNYNex9VT85l3xlCsRj0bDzq4+bUf2HiwulvjaIuqQmGFm4NVHqTNQMQaSbKE6O2C3vDfhpOsiiOiaRqHqj0UVrj5dEsJt72xlgNVHtLiTDxy3ihmj+1LdpKVvFR7zHWtEUL0TDaTgYEZcVGZK+q4AWk8dtEY+qXYqHQHuPPt9byz5kDMJTTlTj+7ylwEQt0/jk2I1kiSJURvFqydhR4kyfqRQqrGnnI3RVVenv5yJ48t2YYvqDI2N4nHLx7HyJxE8tPsMv5KCNHp9DqFvFQ7mYnd//2Sk2zjjxeOYfrgdFQNnl+2m4WLt+D2d/94scNx+0LsKHHGXFzi6CV9WYTozQLu+us9cCK/WFE3/qrE4WXh4i1sOOgA4OJjcvnpMf1IsBrITbFhlOqBQogulBFvwWrUs6/CQ0jtvtYki1HPbacOZmhWPM8v283yneXsKXdz16xh5CTHTlf0YEhjV6lLyryLmCBHBEL0ZnXjsRQ96GVg8JGoG3+16aCDW15fy4aDDqxGPXeeOYzLJ+eRlWghP80uCZYQolvEW4wMyLBjNXXvd46iKJw1ug8LzhtFqt3EgSoPt72xlm/3VHRrHG3RNDhQ6WF/pTvmujWKo4scFQjRm9W1ZBmt4VkcRYcUVXsprHCzdHMJv/vvOsqcPvomWfnjhWOYOjCVvDQbWYkWFHlthRDdyGzQU5AWR1IUquoNzU7gTxePZVh2Am5/iPvf38Qb3+2LuYSm0hVgZ6mM0xLRIyXc2yAl3EWP5nXA6ucgLh3G/Sza0fQYqqqxr9JNhdPPi1/vYdHagwBMzEtm7mlDSI0zyfwsQoiYEK0y74GQytNf7uKjjUUAnDAojZtOHoTFGFvfiwa9Ql6qTaq9ik4j82R1EkmyRI+mhqBoHaQNAVPs9JuPZf6gSmGFi+JqHwsXb2HdgXDJ4osn5nLp5H4k20zkJFvRSfVAIUSMcPmC7C13d+s4rTofbjjE01/uIqRqFKTZ+f2Zw8hIsHR7HIejKNA3yRpz83yJnknmyRJC1FcWlO5s7eL2B9lZ6mRbkZO5b65l3YFqrEY9884YyuXH5pGdaKFfqk0SLCFETLGbw2XeLcbuP6w7Y2Q2D84eSaLVyK4yF7e8vob1B2JrPi1Ng/2VHg5Ve6IdijiKSJIlRG9WVQi7voD930Y7kphX7Q6wq9TFql0VzH1zLYeqvWQmmPnDBaM5flAaeWm2mDs7K4QQdUwGHQXpccRbur9b3Mi+iTx20RgGpNtxeIPc/e6GSDfCWFJW42dPmSsqLX7i6CNJlhC92b5v4JP58PmCaEcS04odXvaWu1i05iD3vb8Rtz/E8OwE/njhWAZnxTMgPY4Ei1RnFELENr1OoX+U5uvLiLfwyE9GM21QGiFV46+f7eD5ZbtjLqGp8YZ7LPiCoWiHIno5SbKE6M18rvBfo4zHaommaeyrcHOw0sPfv9jJM1/tQtXglKEZPHjuSHKSrQxIj4u5gdxCCHE4WYkWcpKt3d5T3GLUM/e0IVw6qR8A76w5wIIPN+Pxx1ZC4wuo7Cxx4fTJxMWi60iSJURv5pckqzUhVWNXmYv9FR7ue38TH24oQgGuOq4/N58yiD5JVvqn2dHL+CshRA+UbDdRkG7HoO/e7zBFUfjppH7cftoQjHqFlbsruOOt8BQYsSSkauwpc1Hh8kc7FNFLSZIlRG8WqEuyrNGNI8b4giF2ljrZURwucLFmXxUWo47fzxrG+RNyyE0Jz38lhBA9mc1kqG2N7/7DvWmD03n4vFEk1RbEuO31tWwvrun2OA6nbuLiompvtEMRvZAkWUL0Zv7ayYilfHuE2x9kV6mLtYVV3P7mWg5UeUiPN/Po+WM4bkAa+Wl2KfMrhOg16gpixEWhIMbQrAT+78Ix5KXYqHD7uePt9Xy9s6zb42hLaY2PwnI3aoyNHxM9myRZQvRmAeku2FC1J1xB8Iutpfz+nQ04vEEGZsTxxwvGMCw7ngEZduxmmbBSCNG76HUK/VNtJNu7v4BPZoKFRy8YzYS8ZPxBlQUfbuGt7/cTa9O0VnsC7CpzEQyp0Q5F9BKSZAnRmwVq5wSRJItyp4+9ZS7e/uEACxdvwR9SmdQ/hQXnjaJfqo2C9DjMBilwIYTonRRFISfZRmZC91cetJkM3D1rOGeNzgbgxa/38MxXu2Ku8qDHH2JHqRNvILYKdYieSU7ZCtGbDZoJyXkw5IxoRxJVh6o9FFf7eG7ZLt5fdwiAWaOyufaEAjISzGQnWlBkwmYhxFEgI8GCyaBjf6WH7mxM0usUfjFtAJkJFp5ftpv31x2i3OnnttMGx9QJrkBQY2epk7xUO3HSs0H8CNKSJURvljEUhp4NfcdHO5KoqCvRvr/Cw4IPN0cSrKun9ucX0wrISbHSJ8kqCZYQ4qiSZDPRP82OLgpHgeeO7ctvZw7BoFNYsaucu97ZQLUn0P2BHIaqwp4yF5VSeVD8CJJkCdGbqUG6faKUGKGqGnvL3ewpc3Hn2+tZubsCo17hjtOH8pPxOeSl2UmL6/5uM0IIEQvizOHKg0ZD9/9GnDAonQfPHUmc2cCWohp+++ZaDlV7uj2Ow9E02F/pocQhlQfFkZEkS4jeKhSEonWwbxW4Yq+aU1cKhlR2lbnYXuzkt/9dx/YSJ/EWAw+dO4ppg9MpSLeTaO3+AeBCCBFLLEZ91Eq8j+iTyKPnjyYj3szBai+3v7mObTFW4h2g2OFjf6U75gp1iNgnSZYQvZUahG/+Dh/MDSdaRwl/MJxgbTxQze3/Xcuhai+ZCWb+cP4YxvZLYkCGHZtJ+tkLIQSAUR8u8W4zd/+4qNwUG3+4YAwF6XaqPQHufHs9q/dUdHscbal0BdgrJd5FB0mSJURvpQYh6AtfP0omI/YGQuwqc7J6dwV3vLWeKneA/DQ7j54/hkFZcQyQCoJCCNGMXqeQn2onwdr9J6BS7CYWnDeK8f2S8QVVHvzfJj7dUtLtcbSlxhuUEu+iQyTJEqK3UoMQqO1LfhSUcK+bZPjzLaXMX7QRTyDEyD4JLDhvFAXpdgrS7Oh1R+f4NCGEaItOp9AvJTpzaYVLvA/jxCHpqBr86ZNtvLPmQLfH0RaPP8TOUhe+oJR4F22TJEuI3koNQbA2yTL17iTL6QsnWO+tPcjCxVsIqhpTClK575yR9Eu1kZtikwqCQgjRhrq5tDKiMJeWQa/jlhmDmT2mDwDPL9vNP1fsibmxUP6gyq5SFx6/JFri8CTJEqK3atRdsPcmWQ5vgN2lTl7+Zi9//2InGnD6iCx+d/pQclPDJdqFEEK0X2aChT5Jlm7frk5RuOb4fOZMyQPgje/288RnO2Ju0uJgSGNXmROnLxjtUEQMkyRLiN5KDda3ZPXSMVlVbj97ylz87fOdvLp6HwA/PSaXX580gLxUGxnx3X+QIIQQvUFqnJncFGu3zwKiKAoXTsjlhpMGolNgyaZiHlm8GX8wtsZC1c2lFWtzfInYIUmWEL1VwANabXeGXtiSVeHys7vUxWMfb+PDDUUowK+mD+CyY/PIS7OTbDdFO0QhhOjRkmym2u7W3b/tmSOyuOP0oRj1Ct/sqmD+og24YqzlSNNgX4WbCpm0WLSgxyRZFRUVXHbZZSQkJJCUlMQ111yD0+k87H1OPPFEFEVpdPnlL3/ZTRELEWVqEE64DY6/FUz2aEfTqUprfOwudfHI4i18vq0UvU7httOGcPaYPhSk20mwyBxYQgjRGRKtRvJSo5NoTRmQxn1nj8Bq1LPhoIO73tkQcy1HmgYHKj2U1MikxaKxHpNkXXbZZWzcuJElS5bw/vvv8+WXX3Lddde1eb9rr72WQ4cORS6PPvpoN0QrRAxQdDDsbBh7Geh7T9JR4vCyu9TF/e9vZOXuCox6hTvPGMaM4RkUpMscWEII0dniLUby0+zoonDUOConiQU/GUWCxcCOUid3vr0+JluOiqt9HKr2RDsMEUN6RJK1efNmFi9ezHPPPcfkyZM5/vjjeeKJJ3j11Vc5ePDgYe9rs9nIysqKXBISEropaiGiTK3tVtGLquodqvaws8TFPYs2sHZ/NVajnnvPHsEJg9MYkB6HxShzYAkhRFewmw0UpMVFZSqMAelxPPKT0aTYTRRWuLnjrXWUOGKv5aisxs/+SnfMVUQU0dEjkqwVK1aQlJTExIkTI8tmzJiBTqdj5cqVh73vK6+8QlpaGiNHjmTevHm43e7Dru/z+XA4HI0uQvRInirY/y0Ub4p2JJ3iYJWH7cVO7nxnPVuKaogzG3hg9kgmF6RQkGbHqO8RX2dCCNFjWU16CtLtGPTdn2jlpthY+JPRZMSbOVTt5XdvredAZey1HFW6AhRWSKIlekiSVVRUREZGRqNlBoOBlJQUioqKWr3fpZdeyssvv8xnn33GvHnz+Ne//sXll19+2G0tWLCAxMTEyCU3N7dTnoMQ3UrToHIXfDAXFt8R7Wh+tANVHrYcqmHeW+vZXeYiyWZkwXmjGJeXRH5aHAZJsIQQoltYjHoGpMdhMnT/925WooWF54+mb5KVMqePO95ex54yV7fH0RaHJ8iecjdqjJWeF90rqkcmd9xxR7PCFE0vW7ZsOeLHv+6665g5cyajRo3isssu45///Cdvv/02O3fubPU+8+bNo7q6OnLZt2/fEW9fiKhpOBGxsWeXMd9f6WbTQQd3vLWOA1Ue0uPNLPzJaEblJJKfao9K1xUhhDiamQw68tPsUUm00uLMPPKTUeSn2alyB7jz7fVsL67p9jja4vQG2VPuirk5vkT3ieoI8dtuu40rr7zysOsUFBSQlZVFSUlJo+XBYJCKigqysrLavb3JkycDsGPHDgYMGNDiOmazGbO5+2c6F6JTNZyI2NBzk6x9FeEE6/fvrKfM6adPooUHzx3FwIy42vlbJMESQohoMBl0FKTb2VXq6vY5rJJsJh4+dxT3vreRrcU1/P6dDcw/ezgj+iR2axxtcflC7C5zkZ8mJwSPRlFNstLT00lPT29zvSlTplBVVcV3333HhAkTAPj0009RVTWSOLXHmjVrAMjOzj6ieIXoMXrBRMT7KtysP1DNXW9voMLtJyfZykPnjmJAhp2c5N4375cQQvQ0Rn040dpd5sIX6N5EK85i4P7ZI3jwf5tZf6Ca+Ys2Mv+s4YzKSerWONri8YfYVeokP80uXduPMj3i3R42bBinn3461157LatWrWL58uXccMMNXHLJJfTp0weAAwcOMHToUFatWgXAzp07eeCBB/juu+/Ys2cPixYtYs6cOUybNo3Ro0dH8+kI0fXUIAR6ZpKlaRr7Ktys3VcVLtXr9pOXYuPh80YxOCtOEiwhhIghRn2466DZ2P2HlDaTgflnD2dcbhK+oMq9729i7f6qbo+jLd6Ayq4yF4FQ9yaiIrp6RJIF4SqBQ4cO5ZRTTuHMM8/k+OOP55lnnon8PxAIsHXr1kj1QJPJxCeffMJpp53G0KFDue222zj//PN57733ovUUhOg+DcdkGXpOkhVOsDz8UFjJnW+vp8odID/NzkPnjWJIVjzZiT3nuQghxNHCqNdRkGbHEoVEy2zQc9es4Yzvl4w/qHL/e5v4obCy2+Noiy+gsqvUhS8YinYoopsomtSYPCyHw0FiYiLV1dUyx5boOWqK4as/wqqnYfhsuOif0Y6oTZqmsb/Sw7d7Krnn3Q3U+IIMTI/j/tkjGJgZR0Z8zx1bJoQQR4NgSGV3mQtvN3cdBAiEVB7+YDPf7q3EqFe468zhjM9L7vY42mI0KOGWP4PM69hTtTc36DEtWUKIDlCD0GccTP4lDD492tG0y/5KD6t2V3DXu+up8QUZkhnPA+eOZFBmvCRYQgjRAxhquw5aTd1/eGnU67jzzGFMzk8hENJ48INNfLu3otvjaEsgqLGr1IU3IC1avZ0kWUL0RmoQMobCmEug4MRoR9OmfRVuVu2u4O53N+DyhRiWFc/9s0cwKDOO9Hip9imEED2FQa+jf2p0ug4a9Tp+d/pQphSkEghpPPS/zazaHXuJVjCk1bb4SaLVm0mSJURvpAYb3IjtsrH7K93hLoKLNuD2hxjRJ4H7zgm3YKXFSYIlhBA9jSGKxTCMeh2/nTmEqQNSCaoaCz7czDe7yrs9jrYEQ9Ki1dtJkiVEb6SGoHIvlGwCT+wNAK5zoCo8BivSgpWdwL1nh1uwUuymaIcnhBDiCEUz0TLodcw9bQgnDEojqGosXLwlJlu0QqokWr2ZJFlC9EZqEL77B7xzPWz9INrRtOhglYfv9lRw9zsbcPqCDM2K575zhjMoM45kSbCEEKLHqyvvbjJEJ9G67dT6RGvBh5v5bm/snXSsS7Q8fkm0ehtJsoTojdQgBD3h68bYm1fqUHW4Bev372yIFLm4f/YIhmQmkGSTBEsIIXqLukTLaOj+rut6ncKtMwZzXG3XwYc+2MSafVXdHkdbQqrGrjKnJFq9jCRZQvQ2mgZaCIK+8O0Ym4y42OHluz2V3PXOemq8QQZmxHHf7BEMzown0WaMdnhCCCE6mcmgoyAtLiqJVl3Xwbqqgw/8bxPrY3DCYlVFEq1eRpIsIXqbuqIXdZMRx1BLVmmNL9KC5fAGGZBu58HZIxmaFS8tWEII0YuZDOEWLYO++xOtuqqDE/NqJyz+3yY2Hqzu9jjaoqqwu0y6DvYWkmQJ0dtEkqzYaskqd/r4bk8lv39nPdWeAAVpdh48dyRDsyXBEkKIo4HZoCc/zY5eF51Ea94ZwxiXm4Q3oHLfe5vYUuTo9jjaElKlvHtvIUmWEL1NXZIViJ2WrCq3nx8Kq7jr3fVUuQP0T7XxwOyRDMuWMVhCCHE0sRjDiZYuCkegJoOO388axuicRDyBEPMXbWRbcU33B9IGqTrYO0iSJURv07S7oCm6LVnVngBr91Vx1zsbKHP66Ztk5YHZIxnRVxIsIYQ4GllNevqn2lGiMI2j2aDn7lnDGdEnAbc/xD2LNrC7zNX9gbRBEq2eT5IsIXobtfYLeeylMP4KiMuKWig13gAbDlRz1zsbKHJ4yUww89C5IxnZN1ESLCGEOIrZzQbyUm1RSbQsRj3zzxrBsKx4XL4Q97y7gQOVnu4PpA3SdbBnkyRLiN6mriVr1AUw8Sqwp0UlDJcvyMYDDu5+ZwP7Kj2k2k08eO4oRucmyTxYQgghiLcYyU2JTqJlNem55+wRFKTbqfIEuOvdDZQ4vN0fSBuCoXCi5QtKotXTSJIlRG9Tl2RFdP+vl8cfYtOhauYv2siuMhdJViMPnjuScf2SSJEESwghRK1Eq5G+SdHp1h5nNnD/OSPJSbZS5vRx17sbqHD5oxLL4dQlWv6gGu1QRAdIkiVEb6MGw5fSLVC5p9s37wuG2FLk4P73NrO1uCb8IzZ7JBP7p5AWZ+72eIQQQsS2ZLuJ7CRLVLadaDXy4OyRZCaYOVTt5Z53N+DwBKISy+EEguFEKxCSRKunkCRLiN5GDYGnCt7+Jbx5Nd3ZkhUIqWwvdvLg/zaz/kA1VqOe+84ZwbEFKaTHS4IlhBCiZWlxZjITovM7kRpn5sHZo0ixm9hb4Wb+extx+5v2Cok+f1Bld5mLoCRaPYIkWUL0NmqovrKgwUJ31ckNqRo7S5wsXLyF7/ZWYjLomH/2cKYOTCMjITpnKIUQQvQcGQkWUuOi06U8K9HCA7NHkmAxsKPEyf3vb4rJghO+QDjRCqlatEMRbZAkS4jeRg02SLLMdEdLlqpq7C5z8pdPd/DV9jIMOoU7zxjGtMHpZCVKgiWEEKJ9+iRZSbQao7Ltfik27jtnJDaTno0HHSz4cEtMds/zSqLVI0iSJURvowYh6AtfN1jp6rJNmqZRWOHm2a9288H6QyjALTMGM2N4Bn2iNJhZCCFEz5WbYiXOYojKtgdmxDH/7BGYDTq+L6zk8U+2o2qxl8x4/CH2lLtQJdGKWZJkCdGbaBpooW5tydpf6eGVlXt5bfU+AH45fQDnjO1DTrKtS7crhBCid1IUhbwUG1ZTdA5Th2cnMO+MYeh1Cl9uL+XZL3ehxWCi5faF2FvhjsnYhCRZQvQudeXbIy1Zli5tyTpU7eHtHw7w7Fe7Abh8cj8uPiaXnGRpwRJCCHHkdDqF/ql2TIboHKpOyEvmlhmDUYD31x/itW/3RSWOtji9QQol0YpJkmQJ0ZvUJVmB2pnru7Alq7TGxwfrinj8k20AnDOmD1dO7U+/FBtKNGaWFEII0asY9Dry0+wY9NH5TZk+OJ1rTygA4JWVhXy44VBU4miLwxNkf6Un2mGIJiTJEqI3qUuykvNg3M9g0IwuacmqcvtZurmYhYu3oGpw0pB0bjh5APlpceh0kmAJIYToHCZDONHqpkK5zZw9pg8XH5MLwN8/38myHWXRCaQNVe4AB6sk0Yol0RlVKIToGnVJVurA8AXo7JYspy/IV9vLeOD9TfhDKpP6p3D7zCEUpMWhlwRLCCFEJ7MY9eSl2tlT5iIaveIum9QPhyfAhxuK+OPHW4kzGxibm9T9gbSh3OnHoFNk2pQYIS1ZQvQmagtzeiid9zH3BkKs3l3O/EUbcflDjOiTwO/PGsrAjHgMevk6EUII0TXizIaojfdVFIVfTBvA1AGpBFWNhz7YxLbimqjE0pZih49ypy/aYQgkyRKid6lryXKXQ1UheB2d1l3QH1RZu6+Ku9/dSIXLT16Kjflnj2BIZkLUBiYLIYQ4eiTZTGQmmqOybb1O4bbThjAmJxFvQOW+9zZyIEbHQR2s8lLl9kc7jKOeHBkJ0ZvUJVnrXofX58CaVzolyQqpGtuKHdy7aCP7Kz2kxZm4f/YIRvVNxGLU/+jHF0IIIdojI95CSpwpKts26nXceeYwBmbE4fAGmf/eBipjNJnZX+nB4Q1EO4yjmiRZQvQmLZVw/5E0TWNXqZMH3t/M5qIa7GY9950zggn9U7CaJMESQgjRvfokWoiP0mTFNpOB+WcNJyvBQrHDx/3vbcLjb6GrfpRpGhSWu3H5gtEO5aglSZYQvUndmKy6yYiNPz7JKix3838fb2Pl7gqMeoW7Zw3n+IHpxJmlbo4QQojupygK/VJsUTvRl2Qzcd85I0iwGNhR6mThR1sIhtSoxHI4mgZ7yl14A7GXBB4NOpxkuVyurohDCNEZOrkl61C1h6e/3MVHG4tQgLmnDeHU4Zkk2ow/Lk4hhBDiRwhPVmyL2pjgPklW7jlrBCaDju/2VvLk5ztickJgVYXdZS78wdhLAnu7Du+ZmZmZXH311Sxbtqwr4hFC/BiRJKt2MK7xyCsxlTt9vPJNIf9eVQjAL6YP4NxxfUmNi86gYyGEEKIhg15HXqotatOHDMmK53czh6JT4JPNJZHfy1gTDGnsKXfFZGtbb9bhJOvll1+moqKCk08+mcGDB/PII49w8ODBrohNCNFRzVqyjizJcngDLFp7kL99vgOACyfkcPmx/ciUuTeEEELEkPAcWrbOKqTbYZPyU/jV9PC8lK+u3sdHG4uiE0gbfAGVPeVuVDX2Wtt6qw4nWeeeey7vvPMOBw4c4Je//CX//ve/ycvL46yzzuKtt94iGJQBdkJEhaqCVnuWqm5M1hF0F/QGQny+pYSFi7eganDy0Ax+fdIA+iZFZ34SIYQQ4nDsUZxDC+D0kVlcfEwuAH/7fAer91RELZbD8fhDFFa4Y7JbY290xB1Z09PTufXWW1m3bh2PPfYYn3zyCRdccAF9+vThnnvuwe12d2acQoi2qA1OcAycASPPh8TcDj1EIKSyek8F9y7ahDegMjY3id+dPoR+KXaUaJ0mFEIIIdqQZDORkRC97uyXTerHjGEZqBosXLwlZicrrvEG2R+j83v1NkdcHqy4uJiXXnqJf/zjH+zdu5cLLriAa665hv3797Nw4UK++eYbPv74486MVQhxOA2TrJHnh/92oCVLVTU2Hqzmrrc3UOH20y/FxvyzhzMwIx5dlPq7CyGEEO2VmWDBH1Spcnf//FCKovDrEwdS4QrwfWElD/xvE3+8YAwZMdjNvsodwKj3kpUYe7H1Jh1Ost566y1efPFFPvroI4YPH87111/P5ZdfTlJSUmSd4447jmHDhnVmnEKItqgtddVtX3JUNxfWvYs2sbfCTbLNyAOzRzCiT2LUBhQLIYQQHdU3yYovqEZl7iqDXsfvTh/C7/67jj3lbu57fxOPnj8aewxOeVJa48OgV0iTYlZdpsPdBa+66ir69OnD8uXLWbNmDTfccEOjBAugT58+/P73v++sGIUQ7aE1+EFxHABXWf0YrTYcrPKw8KOtrNlXhcWoY/7ZI5iUnxq10rhCCCHEkagr7W40ROcEoc1kYP7ZI0ixmyiscPPI4ticQwvgUJWX6ii0+h0tFK2Do9/cbjc2m62r4ok5DoeDxMREqqurSUhIiHY4QrTOVQbV+8LXnz0lnHRd8T/IP/6wdytz+vjzJ9v51zd70Slw16zhXDAxhwSLzIUlhBCiZ/IGQuwsdaJGKb/ZUeJk3tvr8AZUTh2eyY0nDYzJsc2KAvlp9phsbYtV7c0NOnyaOj4+npKSkmbLy8vL0eujM/O2EIL67oKhQH2rlunwJ0Qc3gCvrirkX9/sBeC6aeG5sCTBEkII0ZNZjHr6pUSvtPvAjDhuPy08h9aSTcX89/sD0QmkDZoGe8vdeAPd372yt+twktVaw5fP58NkMv3ogIQQRygyR5a3ftlh5snyBkJ8uP4Qj3+yHYBzx/blyuP6k2KXz7EQQoieL95iJDuKxR0m5adw7QkFALy0Yg9fbS+NWiyHE1I19pa7Y7ZbY0/V7rbBv/zlL0C4espzzz1HXFxc5H+hUIgvv/ySoUOHdn6EQnQlTQsnJ/oe2nITCkLIF5582O8KL6ubiFjRtfq8giGVFTvLefB/mwmqGscNSOWWUwdJpSEhhBC9SmqcGW9QpcLpj8r2zxrdh0PVXhatPcifPtlGepyZodmxN/zEHwxPVlyQZpeKwp2k3UnWn/70JyDckvXUU0816hpoMpno378/Tz31VOdHKBoLeMIHzzoD6H5k90w1FD4wD3rDj6nowo+pM4CiD19X9OEOu93V3q6qoDYchFm73cj2FdC3c7dVQ+GucyF/OJEK+Zvfrmv9UXThVh+jBYy2cOlzo7Xjr7Gqgq8avNXh7bf0mur0oDeFt9Gex9e08HsU8IQvIR8E/eH4tRaa9xtORNzC+6ZpGhsPOrj73Q3UeIMMzIjjrlnD6Jdy9Iy1FEIIcfTok2jBFwjh8kWnS9zVU/MpdnhZubsiXNr9wrExeVKzbrLivFRbTI4f62nanWTt3r0bgJNOOom33nqL5OTkLgtKHEbFrvDBNQBK/QF8XdKlN4HOGG7B0Jvq/ypK+ADd74aAK/w3eCST0TVJepSG29c1SCYM4W0bLLXJRCs9U1UV/M5wslf3l3bUYolsty6BqU0SGyZTLSUgrdHU8OsScAHl9cv1pnCyFUm8bGBo0p1ODYWTKm8V+GraXdEPCL9XRisYzPWvVd17FXBDwBt+nzrymG0kWXvKXcxftJH9lR7S4kw8MHsEQ7IS5AtVCCFEr6QoCv1SbOwsdeEPdn+XOL1OYe5pQ7jjrXXsLHVx//828YcYLe1e4w1ysNpL36TWhxuI9unwu/vZZ591RRziiNR2dWtxfqQmFF3HDtQPt00It65A+DHVdpT/1BlrE4naZEINgs8ZTiTak1Q1CyMEoRB09UmpUG2Lkbe6fpnOUNvqZQ0nNL4ajug5QPi18wXA1ynRhtV1FzRYaDpPVonDy6OLw6XazQYd888awYS8FJkLSwghRK9m0OvIS7VFreKgxajn7lnDufWNteyrcPPoR1u556zhMfn7W+H0Y9QrZMTHXmtbT9KuJOvWW2/lgQcewG63c+uttx523ccee6xTAhOdrFMSrB9BDYA/EG6t6unUIPhrwpdYZEmEEeeBKa5RS5bDG+C5r3bz4YYiFGDuaUM4ZXiGzIUlhBDiqGAx6slNsbG3zB2V7afGmbl71nB+99Y6vi+s5IXluyOFMWJNcbUPs15Poq2HjlmPAe1Ksn744QcCgUDkemu6srvRQw89xP/+9z/WrFmDyWSiqqqqzftomsb8+fN59tlnqaqqYurUqfz9739n0KBBXRanEFGXmANTb669Ef5MegMh3v7+AM8t2wXAlcf158KJOdhMsddVQQghhOgqCRYjmYlmiqs7swtJ+w3MiOPWGYN5ZPEWFq09SG6yjdNHZkUllrbsq3RjNNjlWOEItetVa9hFMFrdBf1+PxdeeCFTpkzh+eefb9d9Hn30Uf7yl7/w0ksvkZ+fz913383MmTPZtGkTFos0gYqjgKIQDKl8vq2ERz7cgqrBjGEZ/HJ6AUk2KdUuhBDi6JMRb8EXUKlyt2O4QxeYOjCNyyf34+WVhTz15U76JFkYnZMUlVgOR9NgT5mbARl2zAaZC7ejOtxPqLq6moqKimbLKyoqcDgcnRJUS+677z5uueUWRo0a1a71NU3j8ccf56677mL27NmMHj2af/7znxw8eJB33nmny+IUIur8bnCXQ9CLBqzbX8U972zEEwgxok8Cd545jMxEGdAqhBDi6NU3yYrVFL3u8hdNzGXaoHRCqsaCD7dwsOpIipF1vbo5tELqEY49P4p1eO+65JJLePXVV5stf/3117nkkks6JajOsHv3boqKipgxY0ZkWWJiIpMnT2bFihWt3s/n8+FwOBpdhOhRti2Gl8+Hzx9hX5Wfu97ZSEmNj+xECw+cO5L8NHu0IxRCCCGiSqdTyEu1Y9BHp/CEoijcdMpAhmTG4/QFuf/9TTh97ShkFgW+gEphhRtNk0SrIzqcZK1cuZKTTjqp2fITTzyRlStXdkpQnaGoqAiAzMzMRsszMzMj/2vJggULSExMjFxyc3O7NE4hOl1tdUE/Rh7+9BCbDjmwmfTcP3sko/smSql2IYQQAjDqdfRLsXXbVKBNmQ16fn/mMNLizByo8vDo4i0x22Lk9AY5EKOtbbGqw0mWz+cjGGyeaQcCATyejr34d9xxB4qiHPayZcuWjob4o8ybN4/q6urIZd++fd26fSF+tNp5sjZV6li8zYEC/Pb0IZwwKA2DXioJCiGEEHXsZgPZUZwYONlu4u5ZwzAbdPywr4rnawtUxaJKV4DSmugUDOmJOlwuZNKkSTzzzDM88cQTjZY/9dRTTJgwoUOPddttt3HllVcedp2CgiMrbZmVFa7UUlxcTHZ2dmR5cXExY8eObfV+ZrMZs9l8RNsUIhaEAl70wKrS8Mf7iuP6c/74HCxGGbQqhBBCNJUaZ8YTCFHpik4hjIL0OG47bQgPf7CZ99YdoiAtjhnDM9u+YxQUVXsxGXQkWqW0e1s6nGQ9+OCDzJgxg7Vr13LKKacAsHTpUlavXs3HH3/cocdKT08nPT29oyG0S35+PllZWSxdujSSVDkcDlauXMmvfvWrLtmmENGmqlBWVUMm4MHMSQPiuf7EAcRb5MtQCCGEaE3fJCvegIrHH4rK9qcUpHLppH78e1UhT36+g5wUK0OzEqISS1v2VbgxpcdhNcnJ28PpcN+hqVOnsmLFCnJzc3n99dd57733GDhwIOvWreOEE07oihgBKCwsZM2aNRQWFhIKhVizZg1r1qzB6ayf3Hbo0KG8/fbbQHhA4W9+8xsefPBBFi1axPr165kzZw59+vTh3HPP7bI4hYim3ZUevj/gAsBuNXP3qblkJMh0BUIIIcThKIpCvxQbel30xi1ffEwuUwpSCaoaCz7YQrkzNrvmaRrsKXcRCKnRDiWmHdHsYmPHjuWVV17p7FgO65577uGll16K3B43bhwQnrfrxBNPBGDr1q1UV1dH1vntb3+Ly+Xiuuuuo6qqiuOPP57FixfLHFmiVypz+bjn8xp+GvCCHmYOSiBHKgkKIYQQ7WIy6MhLtbG7zEU0CunpFIVbZgzm4Jtr2Vvh5uEPN7PgvNGYDLE3njoYCpd2L0izo4tiYhrLFK0d9RgdDgcJCQmR64dTt15v4XA4SExMpLq6OjaeW/FGCPmjHYWIMS5/kIe/quKVjX6uMCzh5333kj1hFoYR54I9LdrhCSGEED1GmdPHoSpv1LZfVO3l1tfXUOMLcvLQDH5zyqCYrQycZDOSm2KLdhjdqr25QbtaspKTkzl06BAZGRkkJSW1+EZrmoaiKIRC0enLKsTRyh9SeX29k1c2hpPvzAlnkz4+CYNRB8Tml7IQQggRq9LizHj8Iarc0SmEkZVo4XenD+WeRRv4dEsJBWl2Zo/tG5VY2lLlDmA2eGVoQgvalWR9+umnpKSkAOHueUKI2KCqsGKvi4Ur3QCcP8TEz8YkYDHWdi2I0TNfQgghRCzrm2TFFwzh8Udn3NGY3CSuOT6fZ7/azQvLd5OXamdsblJUYmlLscOH2aiXioNNtCvJmj59euR6fn4+ubm5zVqzNE2TOaWE6GY7yt3M+9yJNwjjMvX89rh44hUPBAxgkLNKQgghxJHQ6RRyU2zsKHGiRqm+w9mj+7Cr1MXSLSUsXLyFxy4aQ3aiNTrBtEEqDjbX4ZF0+fn5lJaWNlteUVFBfn5+pwQlhGhbucvPXZ87OejUyLQpPHxiPJnxZnjrF/DiGVCySVqyhBBCiCNkNuijOt5IURSuP3EggzPjcPqCPPzBZryB2ByWo2mwt0IqDjbU4SSrbuxVU06nU6r2CdFN3P4Qf/rGwapDQUx6ePBEO0PSa38IgrWDdQ1mZEyWEEIIceQSLEbS481R277JoOPOM4aRbDOyp9zNE5/uoB0166IiEAxXHIzV+Lpbu0u433rrrUA4q7777rux2eoz+1AoxMqVKyOT/gohuk4gpPHmxhperi10cesxFqb1j0NXd8okWDuvhsEiLVlCCCHEj5SZYMbtD+LyRacVKTXOzO9OH8rv39nAl9tLGZQZx7kxWgjD4w+xv9Jz1FUcbEm7k6wffvgBCLdkrV+/HpPJFPmfyWRizJgxzJ07t/MjFEJEaBqsLHTxyIpwoYtzB5u4fGwC5oZzaEhLlhBCCNFp6iYq3l7iJBiKTivNiD6JXDM1n2e+2sWLy3czIM3OqJykqMTSlip3AKvJR1pc9FoAY0G7k6y6qoJXXXUVf/7zn2NjzighjjK7yj3M+9yJOwij0vXcMTWeOFODj3EoAFrtmTZpyRJCCCE6hUEfnqh4V2l0JioGOGt0NttKavh8aykLP9rK4xePjdlEpqjai8WoJ87c7lSj1+nwmKwXX3yxUYLlcDh455132LJlS6cGJoRorNId4J4vathXo5JmVVhwYjxZTfuJBxtMnmiwIC1ZQgghROewmQxkJUav/oCiKPz6xIHkp9mp9gRY8OHmmC00oWlQWO7GF4zNQh3docNJ1kUXXcRf//pXADweDxMnTuSiiy5i1KhR/Pe//+30AIUQ4A2o/Hmlg+UHghh18OB0O8MyW+jvXDceS9GBziAtWUIIIUQnSoszk2SL3nxQFqOeO88YRpzZwLZiJ09/uStqsbQlpGoUlrtR1aOzEEaHk6wvv/ySE044AYC3334bTdOoqqriL3/5Cw8++GCnByjE0S6kwvtba3hpfTiBunGihZMK4tC39OnVGWDQaTDg5NoES5IsIYQQojP1TbJiNnb4ELrTZCVauP20ISjARxuL+GhjUdRiaYs3oLK/0hPtMKKiw3tIdXU1KSkpACxevJjzzz8fm83GrFmz2L59e6cHKMTRbu1BF/cvc6MBZxQYuWpck0IXDVmT4KQ74eS7wrelJUsIIYToVDpduBBGNH9ix+clc/mxeQA89cVOthXXRC+YNlR7ApQ4vG2v2Mt0OMnKzc1lxYoVuFwuFi9ezGmnnQZAZWWlzJMlRCcrcvi48/MaHH6NQck67j4hgfiODCJVonemTQghhOitLEY9fZKsUY3hggk5HFuQQlDVWPDhFhyeQFTjOZxih4/qGI6vK3T4COw3v/kNl112GTk5OfTp04cTTzwRCHcjHDVqVGfHJ8RRy+UP8tAyB1sqVOKM8PCJ8fRJbKOKkBoMj8uKlD6SliwhhBCiK6TYTVEdn6VTFG6ZMZg+iRbKnD7+uGQbagxPBLy/0o03cPQUwuhwknX99dfzzTff8MILL7Bs2TJ0tTOgFhQUyJgsITpJIKTx8poa3tsRPutz51Qb4/q2Y2K/favghZnw7q/Dt6W7oBBCCNFloj0+y2YyMO+MYZgMOr4vrOS11fuiFktbVBUKK9yEjpJCGEe0V0yYMIHzzjuPuLi4yLJZs2YxderUTgtMiKOVpsHyPS4eWx0eKHr5CBPnDYvHoGtHwhSoHVxqqGvxkiRLCCGE6CqxMD6rf5qdX584AID/rCrkh8LK6AXTBl9AZX+lO9phdIsjmiFs//79LFq0iMLCQvx+f6P/PfbYY50SmBBHq90VHn7/pRNfCMZn6rllcgJWo759d64r4V6XZElLlhBCCNGlLEY9fZOsUa2id/LQTDYddPDRpmL+7+OtPH7xONKbzqUZIxyeICUOLxkJvbuWQ4eTrKVLl3LOOedQUFDAli1bGDlyJHv27EHTNMaPH98VMQpx1HB4whMOH6hRSbcpPHxSPKlxpvY/QN1kxIa6Ly5JsoQQQoiulmw34fQFqXJHr7jDddMGsL3Uya5SF49+tIWHzxuFscX5XqKv2OHDatITb4nemLau1uFXft68ecydO5f169djsVj473//y759+5g+fToXXnhhV8QoxFHBF1R5crWDZfuDGHTwwDQ7g9PaMQ6rIWnJEkIIIaIi2uOzTAYd804fht2sZ0tRDf/4ek/UYmmPwgo3vmDvLYTR4T1h8+bNzJkzBwCDwYDH4yEuLo7777+fhQsXdnqAQhwNVBU+2eHk2bXhJOn68eEJh3Ud/YRGWrJqy8pKkiWEEEJ0i1gYn5WVaOGWGYMBWLT2IF9tL41eMG1QVSgsd6P20kIYHU6y7HZ7ZBxWdnY2O3fujPyvrKys8yIT4iiyrczNvV+5UDU4qZ+Bn4+Lb33C4cOJJFlmpKugEEII0b1iYf6syfmpnD8+B4AnPt0R04UmvAGVA1XRG8vWlTp8FHfssceybNkyAM4880xuu+02HnroIa6++mqOPfbYTg9QiN6u0h3g7i+clHo0cuJ1zJ+WQIL1CPsoJ/eH/sdDSoG0YgkhhBBREO35swB+dmweo/om4gmEeOTDLTE9P1WVO0CZ0xftMDpdh5Osxx57jMmTJwNw3333ccopp/Daa6/Rv39/nn/++U4PUIjezBdU+esqB6sPBTHpw+Ow8pJ/RLWdIWfAaQ/C4JlIS5YQQggRHX2SrJiOpEdKJ9HrFG4/bQjJNiN7K9w889WuqMXSHkXVXly+YLTD6FSKpsXw1NAxwOFwkJiYSHV1NQkJCdEOB4o3Qsjf9noi5oVU+N82B79Z4kTV4LZJVn55TBJGfSclRzoDZI3qnMcSQgghRIe4/UF2lbqI5pH22v1V3P3OBjTg1lMHc9KQjOgF0waDXmFgRlzMVkSs097cILafhRC92NZSN/fVjsM6Jc/IlePifnyCpTbsDiAtWUIIIUS02EwGMhKiO1fVmJwkfjqpHwB/+3wH+2J4fFYwpFFY4aa3tP+0K8lKTk4mJSWlXRchRNsqXH7u/qKGco9GboKOe6bFE2/uhP7bH8yFZ0+BXZ/LmCwhhBAiyjLiLcRZOjwtbae6aGIuo3MS8QZUHl28JabLprt9IYoc3miH0Sna9a4//vjjXRyGEEcPb0DliVU1fFcUwqyHB6fF0S+pk2Y9D3pBC4HOiLRkCSGEENGXk2xle7GTUJRKlet1CnNPHcJNr/3AnnI3z365ixtOHhSVWNqjrMaPzWQg8UiLgMWIdiVZV1xxRVfHIcRRIaTCh9treGlDuIrOTRMtHJdn77xGp0CDEu7SkiWEEEJEnVGvIyfFyt6y6HXVS7abmHvqEO5+dwMfbSpmZN9ETozh8Vn7K91YjHGYDfpoh3LE2j0m6/XXX4/MjwWwf/9+VFWN3Ha73Tz66KOdG50QvczWUjcPLHOjanBqfyNzxiZ0XqELgGBtCVSjFWnJEkIIIWJDgsVIWrwpqjGMyU3i4mNyAfjb5ztjev6s3jBRcbuTrJ/+9KdUVVVFbg8fPpw9e/ZEbtfU1DBv3rzOjE2IXqXSHWD+l04qvBp5CTruPiGeeHMn99MOSkuWEEIIEYuyEixYTdGtOXfJMf0i82ctjPHxWT19ouJ2v9NNK330lsofQnQHX1DlqW/r58O6f1ocuZ01DquhupYsgwVpyRJCCCFih6Io5CTbonoOVK9TmHvaEJKsxvD4rK92Ry+YdqhyB6hw9cypi6SEuxBdTFXhs11OnlsbToCuH9/J47DqaBoEa8/4SEuWEEIIEXMsRj19kqxRjSHFbuLWUwejAB9tLGLZjrKoxtOWg1UePP7YbXFrjSRZQnSxXRXh+bBCGpyQa+DqsfGdOw6rjqZC7rHQdzwYbUhLlhBCCBF7UuwmEqzRLes+rl8yF0zIAeCvn26nOIbLpmsalNb4oh1Gh3XoHf7oo49ITEwEQFVVli5dyoYNGwAajdcSQoQ5PAEeXObkkEsj06Yw//g4ErqqJKlOD6c/XH9bWrKEEEKImNQ3yYrb7yQYit7wm0sn9WPd/mq2Ftfwfx9vZcF5ozDoY7P9RaPnDVNStHYOrtLp2n7RFUUhFOp5zXmH43A4SExMpLq6moSEhGiHA8UbIdQz+6YebXxBlWdWV/HH1V50Cvz1tDhOH5RAOz5KncOcAKkDumljQgghhOiIGm+APVEs6w5Q7PBy06s/4PaHuGhiLj87Ni+q8bQmwWogL9Ue7TCA9ucG7T7cU1W1zUtvS7CEOFKaBiv3ufjr9+Hm96tHmzllQFz3JVggLVlCCCFEDIu3GEmNi25Z98wECzecNBCAN77dx9r9VVGNpzeJzTZBIXq4/VVe5n/pwheC8Zl6fjUxHrOhiz9u5TvguVPh9Tnh24p8vIUQQohYlp1owWKM7u/1CYPSOW14Jhrw2MfbqPYEohpPbyFHYUJ0Mqc/yKMrathdrZJkVrh/ejyp9m44UxX0gRqAULB2gbRkCSGEELFMURRyU6Jb1h3g2hMKyE22UuH28/gn22Sqpk4gSZYQnSigary10cl7O8Jnge48zsrQjG4q1dpwImKQ7oJCCCFED2Ax6slK7IK5MzsYw+0zh2LUK3y7t5L31h2Majy9gSRZQnSiDYfc/GFleK6qi4aaOGtIPAZdNyU7jSYiBmnJEkIIIXqGtDgzcZbolnXPT7NzzdR8AF5cvoedpc6oxtPTSZIlRCcpc/q4f5mTGr/GoGQdtx4bj82k774AArUTERtrkyxpyRJCCCF6jJxkK/ruOjHbijNHZTM5P4WgqvGHj7b2yEmAY0WHk6x9+/axf//+yO1Vq1bxm9/8hmeeeaZTAxOiJ/EEQjz9nZMfikNY9HDv8XFkxpu7N4hIS1bddiXJEkIIIXoKo15H36RuGmLQCkVRuOnkQaTFmThQ5eH5ZbuiGk9P1uEk69JLL+Wzzz4DoKioiFNPPZVVq1bx+9//nvvvv7/TAxQi1oVU+GK3ixfWhZOc68dbOKZfFAaxRsZkSUuWEEII0RMl2owk2YxRjSHBauSWGYNRgI82FbNiZ1lU4+mpOpxkbdiwgUmTJgHw+uuvM3LkSL7++mteeeUV/vGPf3R2fELEvMJKDw8udxPS4PgcA3PGxGOKxozpthTIHgvJ+bULJMkSQgghepo+SVYM+uj+ho/OSeIn43MAeOLTHZQ7fVGNpyfq8JFgIBDAbA53R/rkk08455xzABg6dCiHDh3q3OiEiHHVniCPrnCyv0Ylzapw1/Fx0TsDVXAinP04TLgifFtasoQQQogeR69TyEmObrdBgMsm92NAup0aX5DHl25HlbLuHdLhJGvEiBE89dRTfPXVVyxZsoTTTz8dgIMHD5KamtrpAdZ56KGHOO6447DZbCQlJbXrPldeeSWKojS61MUrxI/lC6r8d1MNH+4KoAB3HmdjYFr0vxTrSZIlhBBC9ETxFiMpcd0wx+ZhGPU65p42BJNBx5p9VSxaI2XdO6LDSdbChQt5+umnOfHEE/npT3/KmDFjAFi0aFGkG2FX8Pv9XHjhhfzqV7/q0P1OP/10Dh06FLn85z//6aIIxdFE08Ll2v+0urZc+zATMwfZu69ce3tIS5YQQgjRY2UnWDAbo1sIPCfZxrXHFwDw0oo97JKy7u3W4YL8J554ImVlZTgcDpKTkyPLr7vuOmw2W6cG19B9990H0OFxX2azmaysrC6ISBzNSmp83L/cRY0fBiXruPmYOOym6M5vwVePwe4vYOLVMHw20pIlhBBC9Fy62m6Du0pdRLOn3swRmXy7t4KVuyv4v4+38thFY7EYu3GKmh7qiNJjTdP47rvvePrpp6mpqQHAZDJ1aZJ1pD7//HMyMjIYMmQIv/rVrygvLz/s+j6fD4fD0egiRENuf4jnfnCytiSExQD3nmAnKyG6M7UD4HOAtxrU2jktpCVLCCGE6NFsJgPp3T0lTBOKonDjyYNIthnZV+nhH1/viWo8PUWHk6y9e/cyatQoZs+eza9//WtKS0uBcDfCuXPndnqAP8bpp5/OP//5T5YuXcrChQv54osvOOOMMwiFWp9YbcGCBSQmJkYuubm53RixiHVBVWN5oYsX19eWax9nYWKOHV0sTOvdtIS7tGQJIYQQPV5GvBmrKboHGolWI7+ZMRiA/60/xKrdFVGNpyfo8Dt28803M3HiRCorK7Fa6wf5n3feeSxdurRDj3XHHXc0K0zR9LJly5aOhhhxySWXcM455zBq1CjOPfdc3n//fVavXs3nn3/e6n3mzZtHdXV15LJv374j3r7offZXeXlouZugClP6Grh8dBxmQyxkWDSfjFhasoQQQogeT1EUcpKjMP9mE+P7JXPOmD4A/OXT7VS6/dENKMZ1eBDJV199xddff43J1LjiSf/+/Tlw4ECHHuu2227jyiuvPOw6BQUFHQ3xsI+VlpbGjh07OOWUU1pcx2w2R0rUC9FQtSfIE6td7KlWSTIr3DnFToo9upV/GmnWkiWEEEKI3sBi1JORYKa4OrrzVV0xpT/r9lexp9zNXz/dwV2zhqFEO/uLUR1OslRVbbG73f79+4mPj+/QY6Wnp5Oent7REI7Y/v37KS8vJzs7u9u2KXoHf0jl4x1O/rs1fNZm7mQLQzNjbAxioDbJMtYmWfKlJ4QQQvQaGfEWHJ4gHn/rw166msmg49ZTh3Dr62tYtaeCJZuLOW24FJhrSYf7OZ122mk8/vjjkduKouB0Opk/fz5nnnlmZ8bWSGFhIWvWrKGwsJBQKMSaNWtYs2YNTmd9KcmhQ4fy9ttvA+B0Orn99tv55ptv2LNnD0uXLmX27NkMHDiQmTNndlmcovfRNNhR6uHRleFy7bMGGDlnSDzGKM/G3kyku6CMyRJCCCF6o5xka9TPoean2fnZsXkAPPfVboqqvdENKEZ1OMn64x//yPLlyxk+fDher5dLL7000lVw4cKFXREjAPfccw/jxo1j/vz5OJ1Oxo0bx7hx4/j2228j62zdupXq6moA9Ho969at45xzzmHw4MFcc801TJgwga+++kq6A4oOKXf5+eNKN6VujT5xOm6dHEeCNcrl2luSWgBpg8EcF74d7W9hIYQQQnQqi1FPZgxUNJ49ti8j+iTgCYR47JNthNQo1piPUYqmdbzyfjAY5NVXX2XdunU4nU7Gjx/PZZdd1qgQRm/hcDhITEykurqahISEaIcDxRshJAMNu4vHH+LldQ4e+tqDToG/zLAzc0gCxliadLg1KQPAEgP7rBBCCCE61c5SJ25f9LoNAhQ7vNz4nx/wBELMmZLHhRO6riJ3gtVAXqq9yx6/I9qbGxzR6XiDwcDll19+xMEJ0ROEVFhf7ObPq8PdBC8dbmJ6vr1nJFgASoxUPRRCCCFEp8pJtrK92BnVSYozEyxcN62APy/dzr9XFjKhXzIF6XHRCyjGtCvJWrRoUbsf8JxzzjniYISIJcU1Hh752o0zAENSdPxygp14Swx2E2yNdBcUQggheiWzQU9WooVDVdEdD3XK0AxW7i7nm10V/HHJNv500VhMsTK1TZS164jx3HPPbdeDKYpy2Il+hegparxBXlnv4fviEGY93HmcjeyEGO4O63fCaz8LF724+F+gMyCFL4QQQojeKy3OjMMTwBXFboOKonDDSYPYcuh7Civc/OubvVxzfH7U4okl7Uo1VVVt10USLNEbBFSN7w+4eW5t+OzQdWMtTMyxoY/lEzMBL3gqwVkMij68TFqyhBBCiF6tbwxUG0y0Grnx5EEAvLvmAOv3V0U3oBgRy4eNQkTFwSovj3zjwReC8Zl6Lh9lw26K8W6CDScijnzbSpIlhBBC9GZmg57sxOhXG5yUn8LM4ZlowJ+WbsflC0Y7pKg7oiNHl8vFF198QWFhIX5/40p3N910U6cEJkQ0VLoDvLTOzebyEDYj3DHFTnpc9L+82tQwyaoT7VNbQgghhOhyqXFmqqPcbRDgmuMLWLu/miKHl+eW7eLmUwZHNZ5o63CS9cMPP3DmmWfidrtxuVykpKRQVlaGzWYjIyNDkizRY/mCKqv3u/nnhvCkvr8eb2FUlhVdT2jvbTYRMUhLlhBCCHF06BsD1QatJj23nDqYO/67jk82lzClII1J+SnRCyjKOnz4eMstt3D22WdTWVmJ1Wrlm2++Ye/evUyYMIH/+7//64oYhehymgYHKj088o2HoArH9TVw4TAbVpM+2qG1T6Qlq8FE29KSJYQQQhwV6qoNRtvw7ARmj+0LwF8/247DE4hyRNHT4SRrzZo13Hbbbeh0OvR6PT6fj9zcXB599FHuvPPOrohRiC5X4fbz3FoPu6pUEkwKcyfbSOsJ3QTrBFroLigtWUIIIcRRIy3OHBMnhy8/th85yVYq3QGe/nJXtMOJmg4nWUajEV1t/6mMjAwKCwsBSExMZN++fZ0bnRDdwOMP8XWhm1c3h8cX3nyMhWEZPaSbYB2DGVIHQVK/+mXSkiWEEEIcVXJioNqg2aDnlhmD0Snw5fZSlu8oi25AUdLhMVnjxo1j9erVDBo0iOnTp3PPPfdQVlbGv/71L0aOHNkVMQrRZVQVCiu9PPqNF1WDk/MMnDO4B3UTrJMzMXxpRJIsIYQQ4mhiMerJSDBTXO2LahyDM+O5YEIur3+7j799voMRfRJIspmiGlN36/C5+ocffpjs7GwAHnroIZKTk/nVr35FaWkpzzzzTKcHKERXKnP5ePoHD/tqVFIsCr85xkaq3dz2HXuCaJ/KEkIIIUS3S48zYzVFvzvOJcfk0j/VhsMb5MnPd6BFsypHFHS4JWvixPqz5RkZGSxevLhTAxKiu7j8Qb7c6+GtbeFugrdOsjAwrYd1EzwcSbKEEEKIo46iKOQk29hREt1qg0a9jltPHcytr6/lm10VfL6tlJOGZEQvoG72ow8nv/jiCz788EMqKys7Ix4hukVIhb0VXv5vpQeAMwqMnDbAhq2ndROss+41ePVS+P5ftQskwRJCCCGOVhajnvT46PfMyU+L45JjcgF4+sudlDuj242xO7U7yVq4cCF333135LamaZx++umcdNJJzJo1i2HDhrFx48YuCVK0wV0OfnfnP64ahPKd9XMwdRU1CLu/hJVPN15euRdC/pbv8yOV/n979x3fVL3/cfyVpBndpaV0QBllryIbRDYKbhRFuShUAReooKigIOBCRX84cHDxCiggbuQ6QMYFBVEQLEtAQBCZZXbSdCS/P0IDlba00DZN+34+HnnYnJyc8zknKZ5PP9/z+aZmMP230xxOcxLuZ2BEGz+qevMwwfQTkHwQMlNdz1XFEhERqdSqBVqxmT0/POeW1jHUrxZAmj2HN/9XeYYNFvnMf/zxx3kaW3z22Wf88MMP/Pjjjxw7dow2bdowadKkUglSCuB0wo7v4JPBsLYU7odz5MB/H4ZPB8NfP5X89k+fhN/mwEcDYMnTsPEjOPXX2X1/9zjMuw1+nelKJEtISkY2q/Zl8NVO19wNj7T1pU6orXjDBHOyYON8sKdcRACHIe1o8d9XmPMmI1aSJSIiUpkZDAaql4NugyajgVG9GmA2GVj/10m+//2IZwMqI0W+rNyzZw9xcXHu599++y233HILnTp1IjQ0lHHjxrFmzZpSCVLykXLIlYSsfMlVvTi63VX1cTph1VTY+X3xtpdtd73nfy/gHsDrY4WoFq6kYPGTsPgpSL3EX4zsDDj4m2s/c/vDuvdcCYctGC67AyyBZ4/PmeNKxDbMdq277Fk4cmnV0iyHk30nM3j1zDDBa2LN9Iz1Lf4wwd8+hD9XgCXgnGMrQsUvaT98dLtraN/G+a5ksiT8czJiT/+LKiIiIh7nZ/EhLMDzXf1iQv24o30tAN5fvYdjlWDYYJEbX2RnZ2O1nh1OtWbNGkaOHOl+Hh0dzbFjlbMPfplyOmDLF67KVXYGmCzQ5m5ofgsYfWD3/+D3r1yPjGTX8sKc/Mu17s7vzw41a3StK7kC6DEONnwAmz6Bv1bDgfXQajDE3eraX2EyTsGxXeAXCqGxrmWJ2+HrUWfXCW8ITW+G2G5nEwSA4BowYD7s+RG2fA5HtsDuZa5HZBx0uA+qNSnOmQOnk6PJdmZszODQmWGCD7TxLX43weO74be5EBgJhxIguqWrovXxHVCrk+uc5x7v8d1wYjfUv+rscflXg7RE+OVd2LMSuo6BKrUK3+fJv1xJWeJWqNsTWg92Lc9Mh/89D8f+cD1XJUtERETOERFoI+l0FlnZnh2md+Nl1flp93F2HElh2v92MeG6Jhgq8B+Fi5xk1a1blx9++IHY2Fj27dvHH3/8QZcuXdyv79+/n7CwsFIJUs44+RcseAAOb3I9j4yDLo9BSMzZdWK7wpF+rsRkzTTISHIlYed+iZ0O2PezK1k78OvZ5QERrgQruMbZZWZfaH+vK0lY/Roc2ghrp8POxdDnRVeikXUa/v4Fkg5A8plH0gFIP5N0N78VOg53/Vy1nivJiG4JTftCtcYFH6/RB+p2dz2O/eE6pl3LXMfvcBT8vsxU2LEI/vgOrnsNrIGQbSfr+4ns9+3Agj/aAjCqrS+1Q22YijNM0JENP7zsqrKFxkLUZa7le1e5zvWOb12P6q1cCdDR7WCyQq3Lz1a9bp/jOo6fpkHiNvhiKLS+G+L6g/EfFbXE3yFhnmv7uezJZ3/OPu1KfnP5nfkdrMD/aImIiEjRGY0Gqof4svdYKdy/Xwwmo4GHe9bn4Y9/Y/1fJ1m+PZGejSM8GlNpKnKSNXz4cEaMGMGPP/7Izz//TMeOHWnS5GwlYfny5bRs2bJUgpQzzL5wcq/rv+3uhSY3gOEfGYLBCB1HgC0Efv2Pa1hbxinoNPLsBfzB31zD/3LXr3U5NL4Bqrc+/yI/V2gdV8Ky83v4+R3XMv+qrv9m22HpxPzfF1Q975A6SwAM/KS4Rw5VG0C3sdB2qKu6FXnOxNe/zXElMtGXuRKcPxa5Ej9wJVtxt5Kz7RvM+9fQmp+5xvggztqd6VHHRoClmLMYbP4Mju4Aiz9cMfJsMtOgjys53fwZ7P0RDmxwLTf6QM0OkJl29jyYLNDwatf5/vFVV4K6drrrfddNdVX09q9zVcsOJZzdd+3O+SfBnUe7hopaAqB2pzMvKMkSERERl0CbmRA/M6fSszwaR0yoHwPa1eSDNX8xY9WfXBYTQliAFzceK0SRrzCHDRuGyWTiv//9L126dGHChAl5Xj948CB33313iQco5wioBlc95/pvYGTB6xkM0OpO131Oq6bCtv+67qu6+mXXa9EtIaIpRDSHpjdCYFTR9m8wQIPeULOj616p3OGCtmCIbuWqogRXdyVWQdGuIXDnJlglwT8cmt189nlGkivJyr0nKVeV2q5hiPWvBOBQ9d7s999Ch7TlvGGZxv6oAKoGXFm8fSfth1/fd/3cYfjZqhG4zk1kc9cj5bArGTX7Qr1e4Fsl/+0FVHNVA/9Y5Ko6htY9O2Ry86euBMtgclURW9ye/5BCsx80vu785apkiYiIyDmigm2kZGST4/DssMGbW9bgp93H2ZWYytsrdjPu2sYVctigwVlZ+ihepOTkZIKDg0lKSiIoKMjT4bgaPxSnrfmfK2D5c66L8YGfnr2IdzorxoV4Tib88T1smu8aoljrclcSFt3KfXyn0rNY/mcajy1P5WXzu/QzrcJp9MFw5TOu9YvC6XDdS3Zoo2so4DWvluz5S010Vccs/q7nhza52trH9XclY8Xl4wvVGpVcfCIiIuL1TqVn8veJ054Og7+OpzHy4wSyHU4evbIB3S4wSXGQrw+1wvzLKLrCFTU3KOZYKfE6sd3AN9TVMMKeDD7hruUVIcEC19C7xte5htE5ssFkzvNyZo6D/acyeGVtBjkYWVp9BFebTfj9vRKWTHBVBmu2v/B+MpJc93r52FzD80r6/P0zkYqKcz0uVkX5fEVERKTEhPhZOJmeRWpGtkfjqBXmz+1tY5jzyz7+/cOftKgRQhV/z3dBLEmen6FMSl9UHFwxyjXUrqIyGM5LsACOJNuZudnOwVQH4b4G7mvtj7nXeKjTFRxZsGLy2fu3CuNbBW6a7rpnKii6FA6gpCnJEhERkfNVD/H83FkA/VrVIDbcnxR7Nu+s3F3hJilWkiUV1sn0LDYcsvPFDtfwyofb2qhVxYbZ7AM9x0P93tD7ede9U+CqVDkL6Vpo9Cm8G2J58s+GKCIiIiKAxcdIRJDtwiuWMh+TkZE962MyGljz53FW7apYU0HpSkwqpMwcB4eSMvi/tRk4gV61zXStZSPE70y1y+gD3ce6GoDk+nEqzL0VfngF9q52NdPY/T/4dSbkeLYbT7GVhz9RiYiISLkUHmjF11JAR+kyVKdqAP1bu7omv7tyN0mnvex6qxCXfE9WcnIyy5cvp2HDhjRu7CV/5ZcK70iynbm/2/kr2UGI1cD9La1EBBXSItTphMObIf04bP/a9TBZXBWh7AxXB8VzuxqKiIiIeLEaVXzZlZiKp0fp3domhjV/Hmfv8XSm/7Cbx3tXjMZdxa5k9e/fn2nTpgFw+vRp2rRpQ//+/YmLi+Pzzz8v8QBFiutkeha/H81k/u+uYYIjWtuoE2bDUtiswwaDa5Lga6ZA05tcEzPnZLoSrCp1oPH1ZRR9CVElS0RERAphM5uoWg7mqDKbjDzcswFGA/y48xi/7Dnu6ZBKRLGTrB9++IHOnTsD8OWXX+J0Ojl16hRvvPEGzz33XIkHKFIc9mwHickZvPLLaXKc0KmGD1fWsVDFtwgda0wWqNEWOj0MA+bDLe+7GoZc/VK+TTXKNyVZIiIiUrhqgVbMPp6/ZqhXLYCbWrqGDb69Yjdpds92PywJxU6ykpKSCA0NBWDRokX069cPPz8/rr32Wnbu3FniAYoUldMJicl2PtuRyc6TDgLM8GBrG9WCbMUv7BgMEBoLTW68uHmqPE2VLBEREbkAo9FAdIivp8MAYEC7GKKDbZxIy2TWT3s9Hc4lK3aSFRMTw5o1a0hLS2PRokVcddVVAJw8eRKbzfOdSqTyOnk6k10nM5m9xQ7AvS1t1A214mv2/I2dZU9JloiIiFxYkM1MsK/nR+xYfUyM6FEfgEVbD7P5QJKHI7o0xU6yRo4cycCBA6lRowbR0dF069YNcA0jbN68eUnHJ1Ik9mwHx1Ls/N/aDDJzoFWEiWvrWgj19/xYY49QJUtERESKKCrEhrEc9BxvXj2YPk0jAXhz+U7s2TkejujiFft0PvDAA6xZs4b333+fVatWYTzzicTGxuqeLPGYxGQ7X+/OYvPRHGw+MKqtL+FBNgrrdVGxKckSERGRojGbjESWg7mzAOIvr02ov4VDSRl8tHafp8O5aBd1CdqmTRtuuukmAgIC3MuuvfZaOnXqVGKBiRTVyfQs9iVl8V5CBgB3x1mpF2Yh0HrJMxR4L1WyREREpBjCAsrH3Fn+Vh+Gd6sLwJe/HWBXYqqHI7o4xb4KfeSRR/JdbjAYsNls1KtXjxtvvNHdHEOkNGXmODiWksHrv2aQng1NqproW99SLlqSepaSLBERESme8jJ3Vrs6YXSuX5Ufdx7jjeU7eW9Qa88GdBGKnWT99ttvbNiwgZycHBo2bAjAH3/8gclkolGjRrz99ts8+uijrFq1iiZNmpR4wCLnOpJsZ8Xf2aw9lI3ZCI+0tREeaMNsquRJhipZIiIiUkw2s4mwAAvHUjI9HQr3dI4lYd8p9hxL46N1fzPuWu/KK4o9XPDGG2+kV69eHDx4kPXr17N+/Xr279/PlVdeyYABAzhw4ABdunRh1KhRpRGviNvJ9CwSU7N5a71rmOCAJlYahlmo4uf5DjmepyRLREREii8i0FYu5s4K8bMwtHMsAB/8tNfrhg0WO8maMmUKzz77LEFBQe5lwcHBTJw4kZdffhk/Pz+efvpp1q9fX6KBipwrK8fJ8VQ7MxIyOGV3UjPIyO2NLVQLquzDBM9QJUtEREQuQnmaO6t7w3Ba1axCZo6TsV9swuHw8DjGYrioyYgTExPPW3706FGSk5MBCAkJITPT82VGqbiOJGfw25FsFu3JAmBUWxvVAq1YfSptO8F/UJIlIiIiFyfIZibI1/MNxAwGA8O71SXUz0yfZlF4T4p1Efdk3Xjjjdx99928+uqrtG3bFoB169YxevRo+vbtC8DatWtp0KBBiQYqkivpdDanTmczdZ1rmOD19cxcFmkm1M/i4cjKEVWyRERE5BJEBfuSkpHi8SYY1YJszL+nAw0igy68cjlS7CRr+vTpjBo1ittvv53s7GzXRnx8GDx4MFOnTgWgUaNGvPfeeyUbqQiuYYLHUjOY+7udg6kOwnwNDImzER5oLReT6JUfSrJERETk4ll8jFQLsnIkye7pULCaPd9avriKnWQFBAQwY8YMpk6dyp9//gm4JiI+d86syy67rMQCFDnX0RQ7u07m8Mk213DUEa1tRASaCbB4vqRdrqiSJSIiIpcoPMBKUnoWGVkOT4fidS76yjQgIIC4uLiSjEWkUMmns0nOyGLq2tPkOKFTDR861zBrTqx8KckSERGRS2MwGIgK8WXP0TRPh+J1ip1kpaWl8eKLL7Js2TISExNxOPJmtrnVLZGSlO1wcizVzn93ZbH9hAM/M4xoZSMswKo5sfKjSpaIiIiUgACrDyF+Zk6lZ3k6FK9S7CRr6NChrFy5kjvvvJOoqCgMupiTMnA0xc6h1Bze3+RqdjE0zkb1IB9CfDUnVr70eykiIiIlJDLYRnJGFg6NGiyyYidZ3333Hd988w2dOnUqjXjytXfvXp599lmWL1/O4cOHiY6O5o477uCpp57CYim4o1xGRgaPPvoo8+fPx26307t3b95++20iIiLKLHa5dCn2bFIysnhzfQans6FJVRPX1jMTHmBVLlEgnRgREREpGWaTkcggGwdPZXg6FK9R7H5sVapUITQ0tDRiKdD27dtxOBxMnz6drVu3MnXqVN59912efPLJQt83atQo/vvf//Lpp5+ycuVKDh48yM0331xGUUtJyHHAsRQ7q/Zn8/PBbHyMrjmxgn3N+Fq8r9NMmVH2KSIiIiUoLMCKr0WtnIvK4HQWr/v9nDlz+Oqrr5g9ezZ+fn6lFdcFTZkyhXfeeafAe8CSkpIIDw9n3rx53HLLLYArWWvcuDFr1qyhQ4cORdpPcnIywcHBJCUlERRUDvrzH9kKOZVnoufEFDsHkzMZ+m0qx047+VcTC0Nb+FIzzA8foxKJAoXWBVs5+L6KiIhIhZGemc3uxLJvghHk60OtMP8y329+ipobFHu44Kuvvsru3buJiIigdu3amM1574nZsGFD8aO9CElJSYVW1NavX09WVha9evVyL2vUqBE1a9YsNMmy2+3Y7WfnA0hOTi65oKVY0jNzSDqdxazNdo6ddhIdYOBfTayEBViVYF2IKlkiIiJSwvwsPoQGWDiRWnn+4H+xip1k9e3btxTCKJ5du3bx5ptv8sorrxS4zuHDh7FYLISEhORZHhERweHDhwt83+TJk5k0aVJJhSoXyeFwVbH+OJHDwp2uX+QHW/sS7OtDsK/mxLowJVkiIiJS8iKDbCSlZ5HjKNZguEqn2FerEyZMKLGdjxkzhpdeeqnQdbZt20ajRo3czw8cOECfPn249dZbGTZsWInFkmvs2LE88sgj7ufJycnExMSU+H6kcCfSM8nIyuG1dadxOKF7TR/aRvlQLVBzYhWJKlkiIiJSCkxGA5HBNg6cPO3pUMq1iy4JrF+/nm3btgHQtGlTWrZsWextPProo8THxxe6TmxsrPvngwcP0r17dy6//HL+/e9/F/q+yMhIMjMzOXXqVJ5q1pEjR4iMjCzwfVarFatVF/KelJHl4FR6Jgt3ZbLzpAN/M9zX0tXswuqjGy6LRkmWiIiIlI5Qfwsn0jI5nZnj6VDKrWInWYmJidx+++2sWLHCnbycOnWK7t27M3/+fMLDw4u8rfDw8CKvf+DAAbp3707r1q2ZOXMmRmPhF9utW7fGbDazbNky+vXrB8COHTvYt28fHTt2LHKMUracTjiaksHRdAezNrnujRvSwkY1fxOh/kp+i0yVLBERESlF1UN82ZWY6ukwyq1ilwUefPBBUlJS2Lp1KydOnODEiRNs2bKF5ORkHnroodKIkQMHDtCtWzdq1qzJK6+8wtGjRzl8+HCee6sOHDhAo0aNWLt2LQDBwcEMGTKERx55hP/973+sX7+eu+66i44dOxa5s6CUvZOnM8nIdvD2hgzSs6FxmIlr65oJ87diUhGrGJRkiYiISOnxtZio4m++8IqVVLErWYsWLWLp0qU0btzYvaxJkya89dZbXHXVVSUaXK4lS5awa9cudu3aRY0aNfK8ltuBPisrix07dpCenu5+berUqRiNRvr165dnMmIpnzJzHJxIzeTnA1n8uD8bowFGtrXhZ/EhSM0uikeVLBERESllkUE2kk9nqwlGPopdG3A4HOe1bQcwm804HI4SCeqf4uPjcTqd+T5y1a5dG6fTSbdu3dzLbDYbb731FidOnCAtLY0vvvii0PuxxLOOpmSSnu3kzfWu2cRvaWihbohJzS4uipIsERERKV0+JiMRQbpOy0+xk6wePXrw8MMPc/DgQfeyAwcOMGrUKHr27FmiwUnlkXw6m/TMbOZssZOY7iTCz8AdzaxqdnGxVMkSERGRMhAWYMXXomu1fyr2GZk2bRrJycnUrl2bunXrUrduXerUqUNycjJvvvlmacQoFVy2w8mxVDt7TuXw2Q7XnFgjWtsItBjV7OKiKckSERGRshEd4uvpEMqdYt/oEhMTw4YNG1i6dCnbt28HoHHjxvTq1avEg5PK4ViqnWyHgzd+zcDhhE7VfehQXc0uLokqWSIiIlJG/Cw+hPiZOZWe5elQyo2L6iZgMBi48sorufLKK0s6Hqlk0jKzScnIZsneLLYcy8Fmggda2bCZTWp2cUmUZImIiEjZiQq2kZyRRSm1aPA6Ra4TrFmzhq+//jrPsg8++IA6depQrVo17rnnHux2e4kHKBWXw+FqdpGS6WRGguu7c0czKxH+RjW7uFSqZImIiEgZcjXBsHk6jHKjyEnWM888w9atW93PN2/ezJAhQ+jVqxdjxozhv//9L5MnTy6VIKViOp5uJyvHwcxNGZyyO6kZZOTmBhY1uygJSrJERESkjIX5W7CZdQ0HxUiyEhIS8nQPnD9/Pu3bt2fGjBk88sgjvPHGG3zyySelEqRUPBlZDpLSs9hxPIevd7nG7z7U2obNR80uLp0SLBERESl7BoOBKDXBAIqRZJ08eZKIiAj385UrV3L11Ve7n7dt25a///67ZKOTCsnphKMpGWQ7nLz+62mcQM9aZlpE+BAWoGYXl0xVLBEREfGQAKsPwb7nz6lb2RT5cjYiIoI9e/YAkJmZyYYNG+jQoYP79ZSUlHwnKRb5p1Ons8jIdvDt7ix2nnTgZ4Z7LrNi8zESrGYXJUBJloiIiHhOZLCt0v/Nt8hJ1jXXXMOYMWP48ccfGTt2LH5+fnTu3Nn9+qZNm6hbt26pBCkVR1aOkxNpdk5mOPjPpgwA7mpuI9TXSFU1uygZlf1fNREREfEoi4+R8Ep+XVfkssGzzz7LzTffTNeuXQkICGD27NlYLBb36++//z5XXXVVqQQpFcexVDsOJ8xIsJOWBfWqGLm+nplAmw++ZpOnw6sglGSJiIiIZ4UHWDmZnklWttPToXhEkZOsqlWr8sMPP5CUlERAQAAmU94L4k8//ZSAgIASD1AqjtTMbFLt2WxKdM2LZQAeamPDx2ggLKBy/7WjRKmSJSIiIh5mNBqIDLLx94nTng7FI4rdYiA4OPi8BAsgNDQ0T2VL5FyuObHsZDucvLneNUzwmrpmGof5EBpgwWxUYlBiDOocIiIiIp4X4mfBz1o5RyrpakzKxPF0O9k5Thb8kcneJAdBFgN3x1kxm4xU8VVyXrKUsIqIiEj5EB1cOVu6K8mSUmfPds2Jdfy0gw+32AEY0sJKkNVI1UCLRreVNJ1QERERKSd8LSaq+Fe+DuRKsqTUJSbbceJqdpGeDQ1DjfSJNeNv8SHAopbtJU9JloiIiJQfkUE2jJUs66hkhytl7VR6FhnZOWxOzGbZX65mFw+29sVkMBAWoGGCpUKVLBERESlHfExGIoJsng6jTCnJklKT5XByPM1OjsPJtA2uZhdXx5ppGGYi2M+M1Udfv9KhJEtERETKlzB/C1Zz5bn2qzxHKmXuWIprTqz/7sriz1MOAi1wdwsrPkYDoX5q2V5qVMkSERGRcsZgMBAVXHmqWUqypFSknZkT62SGg1mbXVWsu5rbCLYaCfW3YtI3T0RERKRSCbSZCbRVjvvxdakrJc41J1YmAO9vtJOWBfWqGLmmrhmbj5Fg38rxy+UxqmSJiIhIORUZbKsUlypKsqTEnTydSVaOg9+PZbNoTxYAD7a2YTIaqBqgYYKlrxL8yyUiIiJeyWY2Eepf8ZufKcmSEpWZ4+BkWqar2cV61zDBq+qYaVLVhwCrD76Wyjnrd5mqDH8eEhEREa9VLdBa4Vu6V/DDk7J2NCUTJ/Ddn1nsPOnA3wxDW1gxgFq2lxklWSIiIlJ+VYaW7kqypMSkZGSTnplNst3B+5vsAAxubqWKzUiInwWLul2UDVWyREREpJyr6C3dK+6RSZnKccCxVFdiNWuznZRMJ3WCjdxQz4KP0UAVP1Wxyo6SLBERESnfDAYDkRW4pbuSLCkRJ9LtZDuc/Hkqh292u5pdDG/lanahlu1lTJUsERER8QJBNjMBFbSluy595ZLZsx0kpWfhdDp5a30GDid0ifGhRYSPWrZ7hJIsERER8Q5RFbSlu5IsuWSJKXacwA9/Z7PpaA5WE9xzmav8q5btHlAR/6USERGRCslmNlGlArZ0V5IllyTpdDYZWTlkZDuZnuBq2X5bYysR/ka1bPcYJVkiIiLiPSIqYEv3CnY4UpayHU6On2l28ck2O0fTnVTzM3BrIwsGVMXyGFWyRERExIv4mIxUC6xYTTCUZMlFO55qJ8fp5Eiag4+3ZwJwb0sbNh8DIX4WzCZd7HuGzruIiIh4l6oBFiw+FSc1qThHImXqdFYOyRnZAExPyCAzB1pUM9G5hg8mg1q2e5RBv9YiIiLiXQwGA5EVaIJiXY3JRTmW4hommHAkmx//zsZocLVsNxgMhPpb1LLdkzRcUERERLxQsJ+5wtzPr0thKbak09lkZDvIcTh5a4Or2cV19czUCTFh8TES7Gv2cISVnZIsERER8U7RIRWjmqUkS4rl3GYXX+/OYm+Sg0CLgcHNXL8QYQEWFVI8TR+AiIiIeCk/i0+F+IO9kiwpltxmF8l2B7M3u6pYdzW3EmQ14Gs2EWDRxMMiIiIicvEigq1e/zdjJVlSZOc2u5i9xU5KJsSGGLmmruuvDWrZXk54+79KIiIiUqlZfUyEBXh3EzUlWVJkx1Jcbdr3JuXw9a4sAO5vacNkNBBo88Fm1tepfFCSJSIiIt6tWqDrGtNb6apYisTV7CIHp9PJu79l4HBCpxo+XBbhgwEIUxWr/FAlS0RERLycyWggPNB7ry+VZMkFndvsYu2hbNYfzsFshHtauJpdVPG3YPbivzRUPPosRERExPt58wTF3hm1lKncZhfZDifv/uZKtm5qYCE60IiP0UAVX+8eM1vhqJIlIiIiFYA3T1CsVnBSqIwsh7vZxcKdmexPcRBiNfCvpq7ybai/FaNS9XJGSZaIiIhUDMF+ZjJzHJ4Oo9h0eSyFOpriqlwl2R18uMX1811xVvzNBqw+RoJ9laeXO6pkiYiISAVS1Qs7DSrJkgIln2l2AfDBFjupWa6W7b3ruFq2h3rhF75yUJIlIiIiFYfBC/+A7BVJ1t69exkyZAh16tTB19eXunXrMmHCBDIzMwt9X7du3TAYDHke9913XxlF7d1yHHDsTLOLc1u2P3CmZbsmHi7HvPAfIhEREZGKxCuukrdv347D4WD69OnUq1ePLVu2MGzYMNLS0njllVcKfe+wYcN45pln3M/9/PxKO9wK4US6q9mF0+nknQ2ulu1X1PChRYTrK6OJh8szJVkiIiIinuQVSVafPn3o06eP+3lsbCw7duzgnXfeuWCS5efnR2RkZGmHWKHYsx0kpbsqV78czGbDEVfL9mFnWrZr4uFyTpUsEREREY/y2ivlpKQkQkNDL7je3LlzqVq1Ks2aNWPs2LGkp6cXur7dbic5OTnPo7I5mmLHCWTlOJmekLdluwEI81cVq3xTkiUiIiLiSV5RyfqnXbt28eabb16wivWvf/2LWrVqER0dzaZNm3jiiSfYsWMHX3zxRYHvmTx5MpMmTSrpkL1GSkY2p7NczS7+u+v8lu3BfmbMJl3El2uqZImIiIh4lMHpdDo9tfMxY8bw0ksvFbrOtm3baNSokfv5gQMH6Nq1K926deO9994r1v6WL19Oz5492bVrF3Xr1s13Hbvdjt1udz9PTk4mJiaGpKQkgoKCirW/UnFkK+QU3vDjYjkc8NfJNLJznCTbncR/k0JKJoxqa+OauhaMBqgdFoDJa+uflUREczB55d9PRERERMq15ORkgoODL5gbePRK7NFHHyU+Pr7QdWJjY90/Hzx4kO7du3P55Zfz73//u9j7a9++PUChSZbVasVqrZzD4U6eziQ7x5Vzz91qJyUT6gSf07Ld36oEyxuokiUiIiLiUR5NssLDwwkPDy/SugcOHKB79+60bt2amTNnYjQW/2o/ISEBgKioqGK/t6LLynFyMs1VIdufksPCXa6f77nM1bLdx2QgxNfsyRClqAzKhEVEREQ8ySuuxg4cOEC3bt2oWbMmr7zyCkePHuXw4cMcPnw4zzqNGjVi7dq1AOzevZtnn32W9evXs3fvXhYuXMigQYPo0qULcXFxnjqUcutYqqvZBcB7G+1kO6BtlA9tolx5eJifVQUSb6EPSkRERMSjvOLGjSVLlrBr1y527dpFjRo18ryWe0tZVlYWO3bscHcPtFgsLF26lNdee420tDRiYmLo168f48aNK/P4y7u0zGxS7dkAbErMZvX+bIwGuPcy17BJm4+RIF+v+KqIiIiIiHicRxtfeIOi3txWZkq48YXTCftOppOZ7cDhdDLi+zR2nnRwXT0zD7fxBSA6xIa/RUmWdzBA9GWeDkJERESkQipqbuAVwwWl9Jw6nUVmtgOAZXuz2HnSgZ8PDGrmqmL5WXyUYHkT3Y8lIiIi4nG6IqvEsh1OTqS52tVnZDuZucn184AmVqrYXF+NMH+Lx+KTi6D7sUREREQ8TklWJXYiLRPHmcGin+/I5OhpJxF+Bm5u6EqsAm0+2Mz6ingXJVkiIiIinqYr6ErKnu0g+XQWACdOO5i/zVXFuruFDYvJgAEIVRXL+6iSJSIiIuJxSrIqqaMpZ1u2z9psJyMbGoWZ6F7Tdf9VkK8Zi2Ye9kJKskREREQ8TVfRlVCKPZvTWTkA7DmVw+I9rorWfS2tGAyuKlYVVbG8kypZIiIiIh6nJKuScTjgeOrZFvDTEzJwOKFLjA9Nq7qqWFX8LZiNulj3TvrcRERERDxNSVYlc+p0Jlk5rpbtvx7KZv3hHHyMMLSFDQCTwUCIr6pYXkuVLBERERGPU5JViWQ5nJxIc1WxchxOZmzMAOCGehaiAlxfhSr+FnQrljdTkiUiIiLiabqcrkSOp55tdrHsryz+POXA3wwDm7oqVz4mAyG+Zs8FKJdOlSwRERERj1OSVUmczsohJSMbAHu2k1nnTDwcZD0z8bCfVdfoXk8foIiIiIinKcmqJI6lnG128eUfromHq/kZuKmBq4pl9TES5OvjqfCkpChLFhEREfE4JVmVQEpGNhnZrpbtpzIcfHRm4uG74qxYTK6L8tAANbsQERERESkJSrIqOIcDjqXZ3c/nbs0kPQvqVTHSo5br/iub2USARVWsCkGVLBERERGP05V1BXfqdCbZOa52FwdSHPx3l2vY4LAWNoxnLsirauLhCkRJloiIt8vJySErK8vTYYhUSmazGZPJdMnbUZJVgZ3bsh3gP5syyHFC2ygfWkW6Pnp/iw++lkv/Ikk5oUqWiIjXcjqdHD58mFOnTnk6FJFKLSQkhMjISAyXcF2lJKsCO7dl++/Hsvnx72yMBhjWwupeJ1RVrApGSZaIiLfKTbCqVauGn5/fJV3giUjxOZ1O0tPTSUxMBCAqKuqit6Ukq4LKyHK4W7Y7nU6mJ7juy7qqjpk6Ia7KVYDVB5tZt+VVKPofsoiIV8rJyXEnWGFhYZ4OR6TS8vX1BSAxMZFq1apd9NBBXWFXUEdTzza7WH0gm9+P5WA1weBmriqWAVWxKiYlWSIi3ij3Hiw/Pz8PRyIiub+Hl3JvpJKsCiglI5uMLFfL9hyHk/9sdCVctzS0UNXP9ZEH2Hyw+ujjr3BUyRIR8WoaIijieSXxe6ir7ArG6czbsn3Rn1nsT3EQYjVwa+OzVawwf2sBWxCvZtCvtIiIiIin6Yqsgjl5Tsv209lOPtjiSrgGNrXib3Zl5cG+Zswm/aWsYtLnKiIi3mfWrFmEhIR4NIZu3boxcuTIUt1HfHw8ffv2LdK6e/fuxWAwkJCQUKoxlTaDwcCCBQuAkjkmbzkvSrIqkCyHk5PntGz/YkcmJzKcRPobuLaua+JhA1BF92JVXBpmIiIiZezo0aPcf//91KxZE6vVSmRkJL1792b16tWeDs1t1qxZGAyG8x42m61U9ldQIvD6668za9asIm0jJiaGQ4cO0axZMwBWrFiBwWAokRb/556D4OBgOnXqxPLlyy95uxfyz2O6kPyS0uJuw1PUXbACOZFqx3GmZ3uS3cEn21xVrLvibO7KVYifBR+jLsQrLn22IiJStvr160dmZiazZ88mNjaWI0eOsGzZMo4fP+7p0PIICgpix44deZaV9T1wwcHBRV7XZDIRGRlZarHMnDmTPn36cOzYMZ566imuu+46tmzZQmxs7HnrZmVlYTabL3mfJXFMpX1eSooqWRWEPdtB8pmW7QDztmaSng31qhjpVtOVSxsNUMVPVawKTZUsEREpQ6dOneLHH3/kpZdeonv37tSqVYt27doxduxYbrjhBvd6//d//0fz5s3x9/cnJiaGBx54gNTU1EK3/dVXX9GqVStsNhuxsbFMmjSJ7Oyz09NMnDjRXT2Ljo7moYceKnR7BoOByMjIPI+IiIgC1//www9p06YNgYGBREZG8q9//cs9fxLAyZMnGThwIOHh4fj6+lK/fn1mzpwJQJ06dQBo2bIlBoOBbt26AedXZhwOBy+//DL16tXDarVSs2ZNnn/+eSBvNWzv3r10794dgCpVqmAwGIiPj+eDDz4gLCwMu/3s/fgAffv25c477yz0fOROuNusWTPeeecdTp8+zZIlS9zn6p133uGGG27A39/fHVNhnwnAzp076dKlCzabjSZNmri3lyu/Ct/WrVu57rrrCAoKIjAwkM6dO7N7924mTpzI7Nmz+eqrr9xVtxUrVuS7jZUrV9KuXTusVitRUVGMGTMmT1zdunXjoYce4vHHHyc0NJTIyEgmTpxY6Pm5VKpkVRDHUs8OEzyU6mDhLtfzoS1sGM9ceFfxt2BSWl3BKckSEakonE4np890Cy5rvmZTkao8AQEBBAQEsGDBAjp06IDVmn9jLaPRyBtvvEGdOnX4888/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 +} From fe1c5e1493b7b421c7fcc31b48bd7eadba101eab Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Mon, 6 Nov 2023 09:43:52 -0500 Subject: [PATCH 04/19] Model selection WIP Signed-off-by: Keith Battocchi --- econml/_ortho_learner.py | 40 +- econml/dml/_rlearner.py | 33 +- econml/dml/causal_forest.py | 18 +- econml/dml/dml.py | 150 +-- econml/dr/_drlearner.py | 49 +- econml/iv/dml/_dml.py | 210 ++-- econml/iv/dr/_dr.py | 235 ++-- econml/new_tests/test_model_selection.py | 267 ----- .../new_tests/test_model_selection_utils.py | 235 ---- econml/panel/dml/_dml.py | 46 +- econml/sklearn_extensions/linear_model.py | 108 +- econml/sklearn_extensions/model_selection.py | 382 +++++- .../model_selection_utils.py | 39 +- econml/tests/test_dml.py | 26 +- econml/tests/test_dmliv.py | 8 +- econml/tests/test_driv.py | 27 +- econml/tests/test_drlearner.py | 13 +- econml/tests/test_missing_values.py | 2 +- econml/tests/test_ortho_learner.py | 14 +- econml/tests/test_refit.py | 12 +- econml/tests/utilities.py | 17 +- econml/utilities.py | 74 -- .../SearchEstimatorList functionality.ipynb | 1031 ----------------- 23 files changed, 780 insertions(+), 2256 deletions(-) delete mode 100644 econml/new_tests/test_model_selection.py delete mode 100644 econml/new_tests/test_model_selection_utils.py delete mode 100644 notebooks/SearchEstimatorList functionality.ipynb 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..1cddcc247 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 @@ -909,7 +883,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): @@ -923,7 +897,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_t_xwz(self): @@ -937,7 +911,7 @@ def models_t_xwz(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_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): @@ -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..97190639b 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..b123fb5a2 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,297 @@ 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 + # TODO: want to get out-of-sample score here if selecting, which + # would require cross-validation, but want to respect grouping, stratifying, etc. + _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 +570,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 +600,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 +637,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 +656,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 +664,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 +676,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 +703,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 +739,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 +752,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 +795,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 +812,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 +822,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 +829,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..142f23563 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 @@ -179,7 +179,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 @@ -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+WhEo043K5eO211zjttNMkoRJCiAghc6qEECLMJk6cyDHHHMPQoUMp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69bhwIEDiIyMrNZnQ0REVUsQn/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/+rnHH1QDAorR2l9ERMqNjIwM9uzZQ506dc5rhy4iZauw38ei5gYaLlgR5LZzB7AElkyCBWeqNEWsiJXEJMYXKyAi74TBRrNrIuGySLDgbDWrIAUNFTz39YL4+BaeYIGrAQYGVbJEREREygklWRVF7r1Zl9LsIj9+oReeZ6ss5sYqjMl89t4kk8WVYJkL77RT4nyrFHxv1oXuVSssySrKPVu5c5vpniwRERGRckFJVkXhW8VVdTKXwhCDwIjCE62ymhurMAERrqpPWP3SOQcXUmA1q4Cuguey+LvuH8tPUbs1BkSokiUiIiJSTijJqiiMRlcyVFoKS7Q80fDin0w+EN7QVdXxFN8q4POPBO9CQwXBlRzl15zDYHTNxVUUZt/iTW4sIiIiIqVGSZYUXX6JltFctnNjFcbTlRyDwVVROldR29rn10HQElC8Y7rQvVsiIiIiUiaUZEnx/DPRKqkmGxVFnmpWEYYK5sqvClXU94qIiIhIuaIkS4rv3ESrPAwVLE/OrWYVZahgLh/r+Y0zNPxPRERExCspyZKLExgBVep4pslEeecX6qpmFXWoYK5zkyqT1ZV4iYiIiIjXUZIlF8+TbdvLu8Co4g/3OzfJUhVLRERExGspyRIpDb4hxW9rbw3EPddVeWkmIiIiUkHs3bsXg8FAQkJCqe7HYDCwYMGCIq07ceJELrvsslKNRzzDx9MBiMgZRpNrzqzMNLCokiUiImcc/K1s9xfdssirGi7QBXfChAlMnDjxEgMqmm7durFy5crzlt977728++67Jb6/iRMnsmDBgvOStkOHDlGlStFuGRg9ejQPPvig+3l8fDynTp0qcpJWkFmzZnHXXXcBrs8oIiKCLl26MGXKFGrWrHlJ25aiUZIlUp7kVrOMKjKLiEj5d+jQIffPH3/8MU8//TQ7duxwLwsIODvfo9PpJCcnBx+f0rv8HDZsGM8880yeZX5+fqW2v/xERkYWed2AgIA856gkBQUFsWPHDpxOJ3v27OGBBx7g1ltv5ZdffimV/UleupITKU+sgRoqKCIiXiMyMtL9CA4OxmAwuJ9v376dwMBAvvvuO1q3bo3VamXVqlXEx8fTt2/fPNsZOXIk3bp1cz93OBxMnjyZOnXq4OvrS4sWLfjss88uGI+fn1+emCIjIwkKyv//qzk5OQwZMsS9j4YNG/L666/nWWfFihW0a9cOf39/QkJC6NSpE3/99RezZs1i0qRJbNy4EYPBgMFgYNasWcD5wwX379/PgAEDCA0Nxd/fnzZt2rgTnXOHC06cOJHZs2fz1Vdfube5YsUKevTowYgRI/LEdfToUSwWC8uWLSvwXOR+FlFRUVx++eUMGTKEtWvXkpyc7F7niSeeoEGDBvj5+REbG8v48ePJyspyv54b34cffkjt2rUJDg7m9ttvJyUlxb1OSkoKAwcOxN/fn6ioKKZOnUq3bt0YOXKkex273c7o0aOpXr06/v7+tG/fnhUrVhQYe0WgSpZIeWL2A6N+LUVEpOIYM2YMr7zyCrGxsUUeRjd58mTmzJnDu+++S/369fnhhx+44447CA8Pp2vXriUSl8PhoEaNGnz66aeEhYXx008/cc899xAVFUX//v3Jzs6mb9++DBs2jI8++ojMzEzWrl2LwWDgtttuY8uWLSxatIilS5cCEBx8fsOr1NRUunbtSvXq1Vm4cCGRkZFs2LABh8Nx3rqjR49m27ZtJCcnM3PmTABCQ0MZOnQoI0aM4NVXX8VqdXUenjNnDtWrV6dHjx5FOtbExES+/PJLTCYTJtPZe8YDAwOZNWsW0dHRbN68mWHDhhEYGMjjjz/uXmf37t0sWLCAr7/+mpMnT9K/f39efPFFnn/+eQAeeeQRVq9ezcKFC4mIiODpp59mw4YNee41GzFiBL///jvz588nOjqaL7/8kj59+rB582bq169fpGPwNrqaEylPDAa1bhcRkQrlmWee4corryzy+na7nRdeeIGlS5fSsWNHAGJjY1m1ahXTp08vNMl6++23ee+99/Ismz59OgMHDjxvXbPZzKRJk9zP69Spw5o1a/jkk0/o378/ycnJJCUlcd1111G3bl0AGjdu7F4/ICAAHx+fQocHzps3j6NHj7Ju3TpCQ11zi9arVy/fdQMCAvD19cVut+fZ5s0338yIESP46quv6N+/P+C65yo+Pr7Qe+KSkpIICAjA6XSSnp4OwEMPPYS/v797nXHjxrl/rl27NqNHj2b+/Pl5kiyHw8GsWbMIDHTdL37nnXeybNkynn/+eVJSUpg9ezbz5s2jZ8+eAMycOZPo6Gj3+/ft28fMmTPZt2+fe/no0aNZtGgRM2fO5IUXXijwGLyZkiwRERERKTVt2rQp1vq7du0iPT39vMQsMzOTli0Lb8oxcOBAnnrqqTzLIiIiClz/rbfe4v3332ffvn2cPn2azMxMdwUmNDSU+Ph4evfuzZVXXkmvXr3o378/UVFRRT6WhIQEWrZs6U6wLobNZuPOO+/k/fffp3///mzYsIEtW7awcOHCQt8XGBjIhg0byMrK4rvvvmPu3Lnu6lOujz/+mDfeeIPdu3eTmppKdnb2ecMra9eu7U6wAKKiokhMTATgzz//JCsri3bt2rlfDw4OpmHDhu7nmzdvJicnhwYNGuTZrt1uJywsrHgnw4soyRIRERGRUnNu5QTAaDTidDrzLDv3PqDU1FQAvvnmG6pXr55nvdzhcgUJDg4usFL0T/Pnz2f06NG8+uqrdOzYkcDAQKZMmZKnMcTMmTN56KGHWLRoER9//DHjxo1jyZIldOjQoUj78PX1LdJ6FzJ06FAuu+wy9u/fz8yZM+nRowe1atUq9D1Go9F9Lho3bszu3bu5//77+fDDDwFYs2YNAwcOZNKkSfTu3Zvg4GDmz5/Pq6++mmc7ZrM5z3ODwZDvcMeCpKamYjKZWL9+fZ6hikCpNf0oD5RkiYiIiEiZCQ8PZ8uWLXmWJSQkuC/mmzRpgtVqZd++fSV2/1V+Vq9ezeWXX84DDzzgXrZ79+7z1mvZsiUtW7Zk7NixdOzYkXnz5tGhQwcsFgs5OTmF7iMuLo733nuPEydOFKmaVdA2mzdvTps2bZgxYwbz5s1j2rRpRTjCvMaMGUPdunUZNWoUrVq14qeffqJWrVp5Kn9//fVXsbYZGxuL2Wxm3bp17tbwSUlJ/PHHH3Tp0gVwnb+cnBwSExPp3LlzseP2VuouKCIiIiJlpkePHvz666988MEH7Ny5kwkTJuRJugIDAxk9ejSjRo1i9uzZ7N69mw0bNvDmm28ye/bsQrednp7O4cOH8zxOnjyZ77r169fn119/ZfHixfzxxx+MHz+edevWuV/fs2cPY8eOZc2aNfz11198//337Ny5031fVu3atdmzZw8JCQkcO3YMu91+3j4GDBhAZGQkffv2ZfXq1fz55598/vnnrFmzJt+YateuzaZNm9ixYwfHjh3LU+EbOnQoL774Ik6nk5tuuqnQ85CfmJgYbrrpJp5++mn38e/bt4/58+eze/du3njjDb788stibTMwMJDBgwfz2GOP8b///Y+tW7cyZMgQjEaj+36xBg0aMHDgQAYNGsQXX3zBnj17WLt2LZMnT+abb74p9nF4C1WyRERERMqzYkwO7A169+7N+PHjefzxx8nIyODuu+9m0KBBbN682b3Os88+S3h4OJMnT+bPP/8kJCSEVq1a8eSTTxa67RkzZjBjxozz9rdo0aLz1r333nv57bffuO222zAYDAwYMIAHHniA7777DnC1g9++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 -} From 6d41ada7b0c4df89aa5dd2be566f1b36246430f3 Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Wed, 8 Nov 2023 17:19:37 -0500 Subject: [PATCH 05/19] Fix some model selection logic Signed-off-by: Keith Battocchi --- econml/sklearn_extensions/linear_model.py | 3 +- econml/sklearn_extensions/model_selection.py | 150 +++++++++++-------- 2 files changed, 91 insertions(+), 62 deletions(-) diff --git a/econml/sklearn_extensions/linear_model.py b/econml/sklearn_extensions/linear_model.py index 8045d23bf..7c29cbd70 100644 --- a/econml/sklearn_extensions/linear_model.py +++ b/econml/sklearn_extensions/linear_model.py @@ -1276,7 +1276,8 @@ class WeightedLassoCVWrapper(_PairedEstimatorWrapper): _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_']) + _post_fit_attrs = set(['alpha_', 'alphas_', 'coef_', 'dual_gap_', + 'intercept_', 'n_iter_', 'n_features_in_', 'mse_path_']) class WeightedLassoWrapper(_PairedEstimatorWrapper): diff --git a/econml/sklearn_extensions/model_selection.py b/econml/sklearn_extensions/model_selection.py index b123fb5a2..94d16b091 100644 --- a/econml/sklearn_extensions/model_selection.py +++ b/econml/sklearn_extensions/model_selection.py @@ -378,10 +378,10 @@ def __init__(self, model): def train(self, is_selecting, *args, groups=None, **kwargs): # whether selecting or not, need to train the model on the data - # TODO: want to get out-of-sample score here if selecting, which - # would require cross-validation, but want to respect grouping, stratifying, etc. _fit_with_groups(self.model, *args, groups=groups, **kwargs) if is_selecting and hasattr(self.model, 'score'): + # TODO: we need to alter this to use out-of-sample score here, which + # will require cross-validation, but should respect grouping, stratifying, etc. self._score = self.model.score(*args, **kwargs) return self @@ -394,6 +394,69 @@ def best_score(self): return self._score +def _copy_to(m1, m2, attrs, insert_underscore=False): + for attr in attrs: + setattr(m2, attr, getattr(m1, attr + "_" if insert_underscore else attr)) + + +def _convert_linear_model(model, new_cls, extra_attrs=[]): + new_model = new_cls() + # copy common parameters + _copy_to(model, new_model, ["fit_intercept", "max_iter", + "tol", + "random_state"]) + # copy common fitted variables + _copy_to(model, new_model, ["coef_", "intercept_", "n_features_in_", "n_iter_"]) + # copy attributes unique to this class + _copy_to(model, new_model, extra_attrs) + return new_model + + +def _to_logisticRegression(model: LogisticRegressionCV): + lr = _convert_linear_model(model, LogisticRegression) + _copy_to(model, lr, ["penalty", "dual", "intercept_scaling", + "class_weight", + "solver", "multi_class", + "verbose", "n_jobs"]) + _copy_to(model, lr, ["classes_"]) + + _copy_to(model, lr, ["C", "l1_ratio"], True) # these are arrays in LogisticRegressionCV, need to convert them next + + # make sure all classes agree on best c/l1 combo + assert np.isclose(lr.C, lr.C.flatten()[0]).all() + assert np.equal(lr.l1_ratio, None).all() or np.isclose(lr.l1_ratio, lr.l1_ratio.flatten()[0]).all() + lr.C = lr.C[0] + lr.l1_ratio = lr.l1_ratio[0] + avg_scores = np.average([v for k, v in model.scores_.items()], axis=1) # average over folds + best_scores = np.max(avg_scores, axis=tuple(range(1, avg_scores.ndim))) # average score of best c/l1 combo + assert np.isclose(best_scores, best_scores.flatten()[0]).all() # make sure all folds agree on best c/l1 combo + return lr, best_scores[0] + + +def _convert_linear_regression(model, new_cls, extra_attrs=["positive"]): + new_model = _convert_linear_model(model, new_cls, ["normalize", "copy_X", + "n_iter_"]) + _copy_to(model, new_model, ["alpha"], True) + return new_model + + +def _to_elasticNet(model: ElasticNetCV, is_lasso=False, cls=None, extra_attrs=[]): + cls = cls or (Lasso if is_lasso else ElasticNet) + new_model = _convert_linear_regression(model, cls, extra_attrs + ['selection', 'warm_start', + 'dual_gap_']) + if not is_lasso: + # l1 ratio doesn't apply to Lasso, only ElasticNet + _copy_to(model, new_model, ["l1_ratio"], True) + max_score = np.max(np.mean(model.mse_path_, axis=-1)) # last dimension in mse_path is folds, so average over that + return new_model, max_score + + +def _to_ridge(model, cls=Ridge, extra_attrs=["positive"]): + ridge = _convert_linear_regression(model, cls, extra_attrs + ["_normalize", "solver"]) + best_score = model.best_score_ + return ridge, best_score + + class SklearnCVSelector(SingleModelSelector): """ Wraps one of sklearn's CV classes in the ModelSelector interface @@ -412,48 +475,32 @@ def can_wrap(model): @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_"]) + return {LogisticRegressionCV: _to_logisticRegression, + ElasticNetCV: _to_elasticNet, + LassoCV: lambda model: _to_elasticNet(model, True, None, ["positive"]), + RidgeCV: _to_ridge, + RidgeClassifierCV: lambda model: _to_ridge(model, RidgeClassifier, ["positive", "class_weight", + "_label_binarizer"]), + MultiTaskElasticNetCV: lambda model: _to_elasticNet(model, False, MultiTaskElasticNet, extra_attrs=[]), + MultiTaskLassoCV: lambda model: _to_elasticNet(model, True, MultiTaskLasso, extra_attrs=[]), + WeightedLassoCVWrapper: lambda model: _to_elasticNet(model, True, WeightedLassoWrapper, + extra_attrs=[]), } 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) + + if isinstance(self.searcher, GridSearchCV) or isinstance(self.searcher, RandomizedSearchCV): + self._best_model = self.searcher.best_estimator_ + self._best_score = self.searcher.best_score_ + + for known_type in self._model_mapping().keys(): + if isinstance(self.searcher, known_type): + converter = self._model_mapping()[known_type] + self._best_model, self._best_score = converter(self.searcher) + return self + else: # don't need to use _fit_with_groups here since none of these models support it self.best_model.fit(*args, **kwargs) @@ -467,26 +514,6 @@ def best_model(self): 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): """ @@ -534,8 +561,9 @@ def get_selector(input, is_discrete, *, random_state=None, cv=None, wrapper=Grid 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)), + 'forest': (GridSearchCV(RandomForestClassifier(random_state=random_state) if is_discrete + else RandomForestRegressor(random_state=random_state), + param_grid={}, cv=cv)), } if isinstance(input, ModelSelector): # we've already got a model selector, don't need to do anything return input From 0435b26ee7f03fd203c7df8acbb9f402936f1186 Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Thu, 9 Nov 2023 10:48:09 -0500 Subject: [PATCH 06/19] Remove deprecated "normalize" param Signed-off-by: Keith Battocchi --- econml/sklearn_extensions/model_selection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/econml/sklearn_extensions/model_selection.py b/econml/sklearn_extensions/model_selection.py index 94d16b091..aafed51cb 100644 --- a/econml/sklearn_extensions/model_selection.py +++ b/econml/sklearn_extensions/model_selection.py @@ -434,7 +434,7 @@ def _to_logisticRegression(model: LogisticRegressionCV): def _convert_linear_regression(model, new_cls, extra_attrs=["positive"]): - new_model = _convert_linear_model(model, new_cls, ["normalize", "copy_X", + new_model = _convert_linear_model(model, new_cls, ["copy_X", "n_iter_"]) _copy_to(model, new_model, ["alpha"], True) return new_model From db7441345669dbed0c5ed8acc0e1ca610ea32147 Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Thu, 9 Nov 2023 13:06:16 -0500 Subject: [PATCH 07/19] Adjust tests for lack of linear_first_stages Signed-off-by: Keith Battocchi --- econml/tests/test_dml.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/econml/tests/test_dml.py b/econml/tests/test_dml.py index 2f321c5f2..975323a05 100644 --- a/econml/tests/test_dml.py +++ b/econml/tests/test_dml.py @@ -1051,7 +1051,10 @@ def test_linear_sparse(self): Y = T * (x @ a) + xw @ g + err_Y # Test sparse estimator # --> test coef_, intercept_ - sparse_dml = SparseLinearDML(fit_cate_intercept=False) + # with this DGP, since T depends linearly on X, Y depends on X quadratically + # so we should use a quadratic featurizer + sparse_dml = SparseLinearDML(fit_cate_intercept=False, model_y=Pipeline([('poly', PolynomialFeatures(2)), + ('lr', LassoCV())])) sparse_dml.fit(Y, T, X=x, W=w) np.testing.assert_allclose(a, sparse_dml.coef_, atol=2e-1) with pytest.raises(AttributeError): @@ -1125,7 +1128,9 @@ def _test_sparse(n_p, d_w, n_r): y[fold * n:(fold + 1) * n] = y_f t[fold * n:(fold + 1) * n] = t_f - dml = SparseLinearDML(model_y=LinearRegression(fit_intercept=False), + # we have quadratic terms in y, so we need to pipeline with a quadratic featurizer + dml = SparseLinearDML(model_y=Pipeline([('poly', PolynomialFeatures(2)), + ('lr', LinearRegression(fit_intercept=False))]), model_t=LinearRegression(fit_intercept=False), fit_cate_intercept=False) dml.fit(y, t, X=x, W=w) From 7e61c00cf1d14e136ca919a097e46de11c47f566 Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Thu, 9 Nov 2023 16:24:14 -0500 Subject: [PATCH 08/19] Remove vestigal functionality Signed-off-by: Keith Battocchi --- econml/dml/dml.py | 46 +- econml/sklearn_extensions/model_selection.py | 220 ----- .../model_selection_utils.py | 817 ------------------ 3 files changed, 5 insertions(+), 1078 deletions(-) delete mode 100644 econml/sklearn_extensions/model_selection_utils.py diff --git a/econml/dml/dml.py b/econml/dml/dml.py index caa12e0c2..e32b6685d 100644 --- a/econml/dml/dml.py +++ b/econml/dml/dml.py @@ -367,14 +367,6 @@ class takes as input the parameter `model_t`, which is an arbitrary scikit-learn The estimator for fitting the response residuals to the treatment residuals. Must implement `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. - If 'auto', it will be chosen based on the model type. - - scaling: bool, default True - Whether to scale the features during the estimation process. - Scaling can help improve the performance of some models. - featurizer: :term:`transformer`, optional Must support fit_transform and transform. Used to create composite features in the final CATE regression. It is ignored if X is None. The final CATE will be trained on the outcome of featurizer.fit_transform(X). @@ -491,21 +483,13 @@ class takes as input the parameter `model_t`, which is an arbitrary scikit-learn def __init__(self, *, model_y, model_t, model_final, - param_list_y=None, - param_list_t=None, - scoring_y=None, - scoring_t=None, - scaling=False, featurizer=None, treatment_featurizer=None, fit_cate_intercept=True, - linear_first_stages=False, + linear_first_stages="deprecated", discrete_treatment=False, categories='auto', - verbose=2, # New cv=2, - grid_folds=2, # New - n_jobs=None, # New mc_iters=None, mc_agg='mean', random_state=None, @@ -513,19 +497,11 @@ def __init__(self, *, use_ray=False, ray_remote_func_options=None ): - # TODO: consider whether we need more care around stateful featurizers, - # since we clone it and fit separate copies self.fit_cate_intercept = fit_cate_intercept + if linear_first_stages != "deprecated": + warn("The linear_first_stages parameter is deprecated and will be removed in a future version of EconML", + DeprecationWarning) self.linear_first_stages = linear_first_stages - self.scaling = scaling - self.param_list_y = param_list_y - self.param_list_t = param_list_t - self.scoring_y = scoring_y - self.scoring_t = scoring_t - self.verbose = verbose - self.cv = cv - self.grid_folds = grid_folds - self.n_jobs = n_jobs self.featurizer = clone(featurizer, safe=False) self.model_y = clone(model_y, safe=False) self.model_t = clone(model_t, safe=False) @@ -741,19 +717,13 @@ class LinearDML(StatsModelsCateEstimatorMixin, DML): def __init__(self, *, model_y='auto', model_t='auto', - param_list_y=None, - param_list_t=None, featurizer=None, treatment_featurizer=None, fit_cate_intercept=True, - linear_first_stages=True, + linear_first_stages="deprecated", discrete_treatment=False, categories='auto', - scaling=True, - verbose=2, cv=2, - grid_folds=2, - n_jobs=None, mc_iters=None, mc_agg='mean', random_state=None, @@ -764,8 +734,6 @@ def __init__(self, *, super().__init__(model_y=model_y, model_t=model_t, - param_list_y=param_list_y, - param_list_t=param_list_t, model_final=None, featurizer=featurizer, treatment_featurizer=treatment_featurizer, @@ -773,11 +741,7 @@ def __init__(self, *, linear_first_stages=linear_first_stages, discrete_treatment=discrete_treatment, categories=categories, - scaling=scaling, - verbose=verbose, cv=cv, - n_jobs=n_jobs, - grid_folds=grid_folds, mc_iters=mc_iters, mc_agg=mc_agg, random_state=random_state, diff --git a/econml/sklearn_extensions/model_selection.py b/econml/sklearn_extensions/model_selection.py index aafed51cb..4b1456d51 100644 --- a/econml/sklearn_extensions/model_selection.py +++ b/econml/sklearn_extensions/model_selection.py @@ -29,10 +29,6 @@ from sklearn.utils.validation import _num_samples 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): @@ -584,222 +580,6 @@ def get_selector(input, is_discrete, *, random_state=None, cv=None, wrapper=Grid 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 - tuning, model fitting, and prediction for multiple estimators. - - - Parameters - ---------- - estimator_list : list, string, or sklearn model object, default ['linear', 'forest'] - 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. - - scaling : bool, default True - Indicates whether to scale the input data using StandardScaler. - - is_discrete : bool, default False - Specifies if the models in `estimator_list` are discrete. - - scoring : str or None, default None - The scoring metric to be used for selecting the best estimator. - - n_jobs : int or None, default None - The number of CPU cores to use for parallel processing during grid search. - - refit : bool, default True - Determines whether to refit the best estimator with the entire dataset after grid search. - - grid_folds : int, default 3 - Number of folds for the cross-validation during grid search. Must be at least 2. - - verbose : int, default 2 - Verbosity level of the class's methods and inner workings. - - pre_dispatch : str, default '2*n_jobs' - Controls the number of jobs that get dispatched during parallel execution of the grid search. - - 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. - - 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. - - 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 - """ - - 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): - 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 - # 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 is None): - self.param_grid_list = len(self.complete_estimator_list) * [{}] - else: - if isinstance(param_grid_list, dict): - self.param_grid_list = [param_grid_list] - else: - self.param_grid_list = param_grid_list - self.categorical_indices = categorical_indices - self.scoring = scoring - if scoring is None: - if is_discrete: - self.scoring = 'f1_macro' - else: - self.scoring = 'neg_mean_squared_error' - warnings.warn(f"No scoring value was given. Using default score method {self.scoring}.") - self.scaling = scaling - self.n_jobs = n_jobs - self.refit = refit - self.cv = cv - self.verbose = verbose - self.random_state = random_state - self.pre_dispatch = pre_dispatch - self.error_score = error_score - self.return_train_score = return_train_score - self.is_discrete = is_discrete - self.supported_models = ['linear', 'forest', 'gbf', 'nnet', 'poly'] - - def fit(self, X, y, *, sample_weight=None, groups=None): - self._search_list = [] - - # Change estimators if multi_task - if is_likely_multi_task(y): - for index, estimator in enumerate(self.complete_estimator_list): - 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 is not None: - self.param_grid_list[index] = make_param_multi_task( - estimator=estimator, param_grid=self.param_grid_list[index]) - - if self.scaling: - if not is_data_scaled(X): - self.scaler = StandardScaler() - scaled_X = self.scaler.fit_transform(X) - - 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 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 - if is_polynomial_pipeline(estimator): - estimator = estimator.set_params(linear__random_state=self.random_state) - else: - estimator.set_params(random_state=self.random_state) - if is_polynomial_pipeline(estimator=estimator): - # Only linear part of pipeline can handle sampleweight - estimator.fit(X, y, linear__sample_weight=sample_weight) - elif not supports_sample_weight(estimator=estimator): - estimator.fit(X, y) - else: - estimator.fit(X, y, sample_weight=sample_weight) - self.best_ind_ = None - self.best_estimator_ = estimator - self.best_score_ = None - self.best_params_ = {} - return self - for estimator, param_grid in zip(self.complete_estimator_list, self.param_grid_list): - if self.verbose: - if is_polynomial_pipeline(estimator): - print(f"Processing estimator: {type(estimator.named_steps['linear']).__name__}") - else: - print(f"Processing estimator: {type(estimator).__name__}") - try: - 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 - if is_polynomial_pipeline(estimator): - estimator = estimator.set_params(linear__random_state=self.random_state) - else: - estimator.set_params(random_state=self.random_state) - - 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, - return_train_score=self.return_train_score) - if self.scaling: - # Add sample weights to the linear layer, not the polynomial featurizer - if is_polynomial_pipeline(estimator=estimator): - temp_search.fit(scaled_X, y, groups=groups, linear__sample_weight=sample_weight) - # MLP does not have sample weight so we cannot fit the search - elif is_mlp(estimator=estimator): - temp_search.fit(scaled_X, y, groups=groups) - else: - temp_search.fit(scaled_X, y, groups=groups, sample_weight=sample_weight) - self._search_list.append(temp_search) - else: - if is_polynomial_pipeline(estimator=estimator): - temp_search.fit(X, y, groups=groups, linear__sample_weight=sample_weight) - elif not supports_sample_weight(estimator=estimator): - temp_search.fit(X, y, groups=groups) - else: - temp_search.fit(X, y, groups=groups, sample_weight=sample_weight) - self._search_list.append(temp_search) - except (ValueError, TypeError, FitFailedWarning) as e: - # This warning catches errors during the fit operation. - 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_.") - warnings.warn(warning_msg, category=FitFailedWarning) - try: - self.best_ind_ = np.argmax([search.best_score_ for search in self._search_list]) - except Exception as e: - warning_msg = f"Failed for estimator {estimator} and param_grid {param_grid} with this error {e}." - raise Exception(warning_msg) from e - 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_} ' - f'and best params {self.best_params_}') - return self - - def scaler_transform(self, X): - if self.scaling: - return self.scaler.transform(X) - - def best_model(self): - return self.best_estimator_ - - def predict(self, X): - if self.scaling: - return self.best_estimator_.predict(self.scaler.transform(X)) - return self.best_estimator_.predict(X) - - def predict_proba(self, X): - return self.best_estimator_.predict_proba(X) - - class GridSearchCVList(BaseEstimator): """ An extension of GridSearchCV that allows for passing a list of estimators each with their own parameter grid and returns the best among all estimators in the list and hyperparameter in their diff --git a/econml/sklearn_extensions/model_selection_utils.py b/econml/sklearn_extensions/model_selection_utils.py deleted file mode 100644 index ab3f567d8..000000000 --- a/econml/sklearn_extensions/model_selection_utils.py +++ /dev/null @@ -1,817 +0,0 @@ - -import warnings -from sklearn.exceptions import NotFittedError -import numpy as np -import sklearn -import sklearn.ensemble -import sklearn.linear_model -import sklearn.neural_network -import sklearn.preprocessing -from sklearn.base import BaseEstimator, is_regressor, is_classifier -from sklearn.ensemble import (GradientBoostingClassifier, - GradientBoostingRegressor, - RandomForestClassifier, RandomForestRegressor) -from sklearn.linear_model import (ElasticNetCV, - LogisticRegression, - LogisticRegressionCV, MultiTaskElasticNetCV) -from sklearn.model_selection import (BaseCrossValidator, GridSearchCV, - RandomizedSearchCV, - check_cv) -from sklearn.neural_network import MLPClassifier, MLPRegressor -from sklearn.pipeline import Pipeline -from sklearn.preprocessing import (PolynomialFeatures, - StandardScaler) -from sklearn.svm import SVC, LinearSVC -import inspect -from sklearn.exceptions import NotFittedError -from sklearn.multioutput import MultiOutputRegressor, MultiOutputClassifier -from sklearn.model_selection import KFold -import pandas as pd - - -def select_continuous_estimator(estimator_type, random_state): - """ - Returns a continuous estimator object for the specified estimator type. - - Parameters - ---------- - estimator_type (str): The type of estimator to use, one of: 'linear', 'forest', 'gbf', 'nnet', 'poly'. - TODO Add Random State for parameter - Returns - ---------- - object: An instance of the selected estimator class. - - Raises: - ValueError: If the estimator type is unsupported. - """ - if estimator_type == 'linear': - return (ElasticNetCV(random_state=random_state)) - elif estimator_type == 'forest': - return RandomForestRegressor(random_state=random_state) - elif estimator_type == 'gbf': - return GradientBoostingRegressor(random_state=random_state) - elif estimator_type == 'nnet': - return (MLPRegressor(random_state=random_state)) - elif estimator_type == 'poly': - poly = PolynomialFeatures() - linear = ElasticNetCV(random_state=random_state) # Play around with precompute and tolerance - return (Pipeline([('poly', poly), ('linear', linear)])) - elif estimator_type == 'weighted_lasso': - from econml.sklearn_extensions.linear_model import WeightedLassoCVWrapper - return WeightedLassoCVWrapper(random_state=random_state) - else: - raise ValueError(f"Unsupported estimator type: {estimator_type}") - - -def select_discrete_estimator(estimator_type, random_state): - """ - Returns a discrete estimator object for the specified estimator type. - - Parameters - ---------- - estimator_type (str): The type of estimator to use, one of: 'linear', 'forest', 'gbf', 'nnet', 'poly'. - TODO Add Random State for parameter - Returns - ---------- - object: An instance of the selected estimator class. - - Raises: - ValueError: If the estimator type is unsupported. - """ - - if estimator_type == 'linear': - return (LogisticRegressionCV(cv=KFold(random_state=random_state), - multi_class='auto', random_state=random_state)) - elif estimator_type == 'forest': - return RandomForestClassifier(random_state=random_state) - elif estimator_type == 'gbf': - return GradientBoostingClassifier(random_state=random_state) - elif estimator_type == 'nnet': - return (MLPClassifier(random_state=random_state)) - elif estimator_type == 'poly': - poly = PolynomialFeatures() - linear = (LogisticRegressionCV(cv=KFold(random_state=random_state), - multi_class='auto', random_state=random_state)) - return (Pipeline([('poly', poly), ('linear', linear)])) - else: - raise ValueError(f"Unsupported estimator type: {estimator_type}") - - -def select_estimator(estimator_type, is_discrete, random_state): - """ - Returns an estimator object for the specified estimator and target types. - - Parameters - ---------- - 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 - ---------- - object: An instance of the selected estimator class. - - Raises: - ValueError: If the estimator or target types are unsupported. - """ - if not isinstance(is_discrete, bool): - raise ValueError(f"Unsupported target type: {type(is_discrete)}. is_discrete should be of type bool.") - elif is_discrete: - return select_discrete_estimator(estimator_type=estimator_type, random_state=random_state) - else: - return select_continuous_estimator(estimator_type=estimator_type, random_state=random_state) - - -def is_likely_estimator(estimator): - """ - Check if an object is likely to be an estimator. - - This function checks if an object has 'fit' and 'predict' methods, or if it is an instance of BaseEstimator. - - Parameters - ---------- - estimator : object - The object to check. - - Returns - ------- - bool - True if the object is likely to be an estimator, False otherwise. - """ - - required_methods = ['fit', 'predict'] - return all(hasattr(estimator, method) for method in required_methods) or isinstance(estimator, BaseEstimator) - - -def check_list_type(lst): - """ - Checks if a list only contains strings, sklearn model objects, and sklearn model selection objects. - - Parameters - ---------- - lst (list): A list to check. - - Returns - ---------- - 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. - - Examples: - >>> check_list_type(['linear', RandomForestRegressor(), KFold()]) - True - >>> check_list_type([1, 'linear']) - TypeError: The list must contain only strings, sklearn model objects, and sklearn model selection objects. - """ - if len(lst) == 0: - raise ValueError("Estimator list is empty. Please add some models or use some of the defaults provided.") - - for element in lst: - if (not isinstance(element, (str, BaseCrossValidator))): - if not is_likely_estimator(element): - raise TypeError( - "The list must contain only strings, sklearn model objects, and sklearn model selection objects. " - f"Invalid element: {element}") - return True - - -def get_complete_estimator_list(estimator_list, is_discrete, random_state): - ''' - Returns a list of sklearn objects from an input list of str's, and sklearn objects. - - Parameters - ---------- - 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 - ---------- - object: A list of sklearn objects - - Raises: - ValueError: If the estimator is not supported. - - ''' - if isinstance(estimator_list, str): - if 'all' == estimator_list: - estimator_list = ['linear', 'forest', 'gbf', 'nnet', 'poly'] - elif 'auto' == estimator_list: - estimator_list = ['linear'] - elif estimator_list in ['linear', 'forest', 'gbf', 'nnet', 'poly']: - 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']") - elif isinstance(estimator_list, list): - if 'auto' in estimator_list: - for estimator in ['linear']: - if estimator not in estimator_list: - estimator_list.append(estimator) - if 'all' in estimator_list: - for estimator in ['linear', 'forest', 'gbf', 'nnet', 'poly']: - if estimator not in estimator_list: - estimator_list.append(estimator) - - elif is_likely_estimator(estimator_list): - estimator_list = [estimator_list] - else: - raise ValueError(f"Incorrect type: {type(estimator_list)}") - check_list_type(estimator_list) - temp_est_list = [] - - if not isinstance(estimator_list, list): - raise ValueError(f"estimator_list should be of type list not: {type(estimator_list)}") - - # Set to remove duplicates - for estimator in set(estimator_list): - # if sklearn object: add to list, else turn str into corresponding sklearn object and add to list - if isinstance(estimator, BaseCrossValidator) or is_likely_estimator(estimator): - temp_est_list.append(estimator) - else: - temp_est_list.append(select_estimator(estimator_type=estimator, - is_discrete=is_discrete, random_state=random_state)) - temp_est_list = flatten_list(temp_est_list) - - # Check that all types of models are matched towards the problem. - for estimator in temp_est_list: - if (isinstance(estimator, BaseEstimator)): - if not is_regressor_or_classifier(estimator, is_discrete=is_discrete): - raise TypeError(f"Invalid estimator type: {type(estimator)} - must be a regressor or classifier") - return temp_est_list - - -def select_classification_hyperparameters(estimator): - """ - Returns a hyperparameter grid for the specified classification model type. - - Parameters - ---------- - model_type (str): The type of model to be used. Valid values are 'linear', 'forest', 'nnet', and 'poly'. - - Returns - ---------- - A dictionary representing the hyperparameter grid to search over. - """ - - if isinstance(estimator, LogisticRegressionCV): - return { - 'Cs': [0.01, 0.1, 1], - 'cv': [3], - 'penalty': ['l1', 'l2', 'elasticnet'], - 'solver': ['lbfgs', 'liblinear', 'saga'] - } - elif isinstance(estimator, RandomForestClassifier): - return { - 'n_estimators': [100, 500], - 'max_depth': [None, 5, 10, 20], - 'min_samples_split': [2, 5], - 'min_samples_leaf': [1, 2] - } - elif isinstance(estimator, GradientBoostingClassifier): - return { - 'n_estimators': [100, 500], - 'learning_rate': [0.01, 0.05, 0.1], - 'max_depth': [3, 5, 7], - - } - elif isinstance(estimator, MLPClassifier): - return { - 'hidden_layer_sizes': [(10,), (50,), (100,)], - 'alpha': [0.0001, 0.01], - 'learning_rate': ['constant', 'adaptive'] - } - elif is_polynomial_pipeline(estimator=estimator): - return { - 'poly__degree': [2, 3, 4], - 'linear__max_iter': [100, 200], - 'linear__penalty': ['l2'], - '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) - return {} - # raise ValueError("Invalid model type. Valid values are 'linear', 'forest', 'nnet', and 'poly'.") - - -def select_regression_hyperparameters(estimator): - """ - Returns a dictionary of hyperparameters to be searched over for a regression model. - - Parameters - ---------- - model_type (str): The type of model to be used. Valid values are 'linear', 'forest', 'nnet', and 'poly'. - - Returns - ---------- - A dictionary of hyperparameters to be searched over using a grid search. - """ - if isinstance(estimator, ElasticNetCV): - return { - 'l1_ratio': [0.1, 0.5, 0.9], - 'cv': [3], - 'max_iter': [1000], - } - elif isinstance(estimator, RandomForestRegressor): - return { - 'n_estimators': [100], - 'max_depth': [None, 10, 50], - 'min_samples_split': [2, 5, 10], - } - elif isinstance(estimator, MLPRegressor): - return { - 'hidden_layer_sizes': [(10,), (50,), (100,)], - 'alpha': [0.0001, 0.01], - 'learning_rate': ['constant', 'adaptive'] - } - elif isinstance(estimator, GradientBoostingRegressor): - return { - 'n_estimators': [100, 500], - 'learning_rate': [0.01, 0.1, 0.05], - 'max_depth': [3, 5], - } - elif is_polynomial_pipeline(estimator=estimator): - return { - 'linear__l1_ratio': [0.1, 0.5, 0.9], - 'linear__max_iter': [1000], - '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) - return {} - - -def flatten_list(lst): - """ - Flatten a list that may contain nested lists. - - Parameters - ---------- - lst (list): The list to flatten. - - Returns - ---------- - list: The flattened list. - """ - flattened = [] - for item in lst: - if isinstance(item, list): - flattened.extend(flatten_list(item)) - else: - flattened.append(item) - return flattened - - -def auto_hyperparameters(estimator_list, is_discrete=True): - """ - Selects hyperparameters for a list of estimators. - - Parameters - ---------- - - estimator_list: list of scikit-learn estimators - - is_discrete: boolean indicating whether the problem is classification or regression - - Returns - ---------- - - param_list: list of parameter grids for the estimators - """ - param_list = [] - for estimator in estimator_list: - if is_discrete: - param_list.append(select_classification_hyperparameters(estimator=estimator)) - else: - param_list.append(select_regression_hyperparameters(estimator=estimator)) - return param_list - - -def set_search_hyperparameters(search_object, hyperparameters): - if isinstance(search_object, (RandomizedSearchCV, GridSearchCV)): - search_object.set_params(**hyperparameters) - else: - raise ValueError("Invalid search object") - - -def is_mlp(estimator): - return isinstance(estimator, (MLPClassifier, MLPRegressor)) - - -def has_random_state(model): - """ - Check if a model has a 'random_state' parameter. - - This function inspects the model's signature to check if it has a 'random_state' parameter. - - Parameters - ---------- - model : object - The model to check. - - Returns - ------- - bool - True if the model has a 'random_state' parameter, False otherwise. - """ - - if is_polynomial_pipeline(model): - signature = inspect.signature(type(model['linear'])) - else: - signature = inspect.signature(type(model)) - return ("random_state" in signature.parameters) - - -def supports_sample_weight(estimator): - """ - Check if a model supports 'sample_weight'. - - This function inspects the signature of the model's 'fit' method to check if it supports 'sample_weight'. - - Parameters - ---------- - model : object - The model to check. - - Returns - ------- - bool - True if the model supports 'sample_weight', False otherwise. - """ - - fit_signature = inspect.signature(estimator.fit) - return 'sample_weight' in fit_signature.parameters - - -def just_one_model_no_params(estimator_list, param_list): - """ - Check if there is only one model and the parameter list is empty. - - This function checks if the length of the model and parameter list is 1 and 0 respectively. - - Parameters - ---------- - estimator_list : list - List of models. - - param_list : list - List of parameters. - - Returns - ------- - bool - True if there is only one model and the parameter list is empty, False otherwise. - """ - - return (len(estimator_list) == 1) and (len(param_list) == 1) and (len(param_list[0]) == 0) - - -def param_grid_is_empty(param_grid): - """ - Check if a parameter grid is empty. - - This function checks if the length of the parameter grid is 0. - - Parameters - ---------- - param_grid : dict - Parameter grid to check. - - Returns - ------- - bool - True if the parameter grid is empty, False otherwise. - """ - - return len(param_grid) == 0 - - -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. - - Parameters - ---------- - model : object - The model to check. - - Returns - ------- - bool - True if the model is a linear model, False otherwise. - """ - - if isinstance(estimator, Pipeline): - has_poly_feature_step = any(isinstance(step[1], PolynomialFeatures) for step in estimator.steps) - if has_poly_feature_step: - return True - - if hasattr(estimator, 'fit_intercept') and hasattr(estimator, 'coef_'): - return True - - if isinstance(estimator, (LogisticRegression, LinearSVC, SVC)): - return True - - return False - - -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. - - Parameters - ---------- - X : array-like of shape (n_samples, n_features) - Input data. - - Returns - ------- - bool - True if the input data is scaled, False otherwise. - - """ - mean = np.mean(X, axis=0) - std = np.std(X, axis=0) - - is_scaled = np.allclose(mean, 0.0) and np.allclose(std, 1.0) - - return is_scaled - - -def is_regressor_or_classifier(model, is_discrete): - """ - Check if a model is a regressor or classifier. - - This function checks if a model is a regressor or classifier depending on the 'is_discrete' parameter. - - Parameters - ---------- - model : object - The model to check. - - is_discrete : bool - If True, checks if the model is a classifier. If False, checks if the model is a regressor. - - Returns - ------- - bool - True if the model matches the type specified by 'is_discrete', False otherwise. - """ - - if is_discrete: - if is_polynomial_pipeline(model): - return is_classifier(model[1]) - else: - return is_classifier(model) - else: - if is_polynomial_pipeline(model): - return is_regressor(model[1]) - else: - return is_regressor(model) - - -def scale_pipeline(model): - """ - Returns a pipeline that scales the input data using StandardScaler and applies the given model. - - Parameters - ---------- - model : estimator object - A model object that implements the scikit-learn estimator interface. - - Returns - ---------- - pipe : Pipeline object - A pipeline that scales the input data using StandardScaler and applies the given model. - """ - pipe = Pipeline([('scaler', StandardScaler()), ('model', model)]) - return pipe - - -def is_polynomial_pipeline(estimator): - """ - Check if a model is a polynomial pipeline. - - This function checks if a model is a pipeline that includes a PolynomialFeatures step. - - Parameters - ---------- - model : object - The model to check. - - Returns - ------- - bool - True if the model is a polynomial pipeline, False otherwise. - """ - - if not isinstance(estimator, Pipeline): - return False - steps = estimator.steps - if len(steps) != 2: - return False - poly_step = steps[0] - if not isinstance(poly_step[1], PolynomialFeatures): - return False - return True - - -def is_likely_multi_task(y): - """ - Check if a target array is likely multi-task. - - This function checks if a target array is likely to be multi-task by checking its shape. - - Parameters - ---------- - y : array-like - The target array to check. - - Returns - ------- - bool - True if the target array is likely multi-task, False otherwise. - """ - - if len(y.shape) == 2: - if y.shape[1] > 1: - return True - return False - - -def can_handle_multitask(model, is_discrete=False): - """ - Check if a model can handle multi-task output. - - This function checks if a model can handle multi-task output by trying to fit and predict on random data. - - Parameters - ---------- - model : object - The model to check. - - Returns - ------- - bool - True if the model can handle multi-task output, False otherwise. - """ - - X = np.random.rand(10, 3) - if is_discrete: - y = np.random.randint(0, 2, (10, 2)) - else: - y = np.random.rand(10, 2) - - try: - model.fit(X, y) - except Exception as e: - return False - - try: - model.predict(X) - except Exception as e: - # warnings.warn(f"The model {model.__class__.__name__} is not properly fitted. Error: {e}") - return False - return True - - -def pipeline_convert_to_multitask(pipeline): - """ - Convert a pipeline to handle multi-task output if possible. - - This function iterates over the steps in the input pipeline. If a step is a - polynomial transformer, it adds the step to the new pipeline as is. If the - step is an estimator, it attempts to convert it to handle multi-task output - and adds the converted estimator to the new pipeline. - - Parameters - ---------- - pipeline : sklearn.Pipeline - The pipeline to convert. - - Returns - ------- - sklearn.Pipeline - The converted pipeline. - - Raises - ------ - ValueError - If an unknown error occurs when making model multi-task. - """ - - steps = list(pipeline.steps) - if isinstance(steps[-1][1], (LogisticRegressionCV)): - steps[-1] = ('linear', MultiOutputClassifier(steps[-1][1])) - if isinstance(steps[-1][1], (ElasticNetCV)): - steps[-1] = ('linear', MultiTaskElasticNetCV()) - new_pipeline = Pipeline(steps) - - return new_pipeline - - -def make_model_multi_task(model, is_discrete): - """ - Convert a model to handle multi-task output if possible. - - This function converts a model to handle multi-task output if possible. - - Parameters - ---------- - model : object - The model to convert. - - is_discrete : bool - If True, the model is treated as a classifier. If False, the model is treated as a regressor. - - Returns - ------- - object - The converted model if possible, raises an error otherwise. - """ - - try: - if is_discrete: - if is_polynomial_pipeline(model): - return pipeline_convert_to_multitask(model) - return MultiOutputClassifier(model) - else: - if isinstance(model, ElasticNetCV): - return MultiTaskElasticNetCV() - elif is_polynomial_pipeline(model): - return pipeline_convert_to_multitask(model) - else: - return MultiOutputRegressor(model) - except Exception as e: - raise ValueError("An unknown error occurred when making model multitask.") from e - - -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. - - Parameters - ---------- - estimator : object - The estimator the parameter grid is for. - - param_grid : dict - The parameter grid to convert. - - Returns - ------- - dict - The converted parameter grid. - """ - - if isinstance(estimator, ElasticNetCV): - return param_grid - else: - param_grid_multi = {f'estimator__{k}': v for k, v in param_grid.items()} - return param_grid_multi - - -def preprocess_and_encode(data, cat_indices=None): - """ - Detects categorical columns, one-hot encodes them, and returns the preprocessed data. - - Parameters: - - data: pandas DataFrame or numpy array - - cat_indices: list of column indices (or names for DataFrame) to be considered categorical - - Returns: - - Preprocessed data in the format of the original input (DataFrame or numpy array) - """ - was_numpy = False - if isinstance(data, np.ndarray): - was_numpy = True - data = pd.DataFrame(data) - - # If cat_indices is None, detect categorical columns using object type as a heuristic - if cat_indices is None: - cat_columns = data.select_dtypes(['object']).columns.tolist() - else: - if all(isinstance(i, int) for i in cat_indices): # if cat_indices are integer indices - cat_columns = data.columns[cat_indices].tolist() - else: # assume cat_indices are column names - cat_columns = cat_indices - - data_encoded = pd.get_dummies(data, columns=cat_columns) - - if was_numpy: - return data_encoded.values - else: - return data_encoded From fe63f23a57bc05d096038fe4a99bcaa3de597882 Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Thu, 9 Nov 2023 17:54:16 -0500 Subject: [PATCH 09/19] Fix linting Signed-off-by: Keith Battocchi --- econml/tests/test_dml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/econml/tests/test_dml.py b/econml/tests/test_dml.py index 975323a05..be20b2639 100644 --- a/econml/tests/test_dml.py +++ b/econml/tests/test_dml.py @@ -1129,7 +1129,7 @@ def _test_sparse(n_p, d_w, n_r): t[fold * n:(fold + 1) * n] = t_f # we have quadratic terms in y, so we need to pipeline with a quadratic featurizer - dml = SparseLinearDML(model_y=Pipeline([('poly', PolynomialFeatures(2)), + dml = SparseLinearDML(model_y=Pipeline([('poly', PolynomialFeatures(2)), ('lr', LinearRegression(fit_intercept=False))]), model_t=LinearRegression(fit_intercept=False), fit_cate_intercept=False) From 6f6a5148477967b747e2e658f57286a744940a9c Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Fri, 10 Nov 2023 09:47:06 -0500 Subject: [PATCH 10/19] Speed up tests by doing less model selection Signed-off-by: Keith Battocchi --- econml/tests/test_dmliv.py | 16 +++++++++++++++- econml/tests/test_driv.py | 29 +++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/econml/tests/test_dmliv.py b/econml/tests/test_dmliv.py index 16f8f55a9..8246db428 100644 --- a/econml/tests/test_dmliv.py +++ b/econml/tests/test_dmliv.py @@ -8,7 +8,7 @@ import pytest from scipy import special from sklearn.ensemble import RandomForestRegressor -from sklearn.linear_model import LinearRegression, LogisticRegression +from sklearn.linear_model import LassoCV, LinearRegression, LogisticRegression, LogisticRegressionCV from sklearn.preprocessing import PolynomialFeatures from econml.iv.dml import OrthoIV, DMLIV, NonParamDMLIV @@ -62,26 +62,40 @@ def eff_shape(n, d_x, d_y): None, PolynomialFeatures(degree=2, include_bias=False), ]: + # since we're running so many combinations, just use LassoCV/LogisticRegressionCV for the models + # instead of also selecting over random forest models est_list = [ OrthoIV( + model_y_xw=LassoCV(), + model_t_xw=LogisticRegressionCV() if binary_T else LassoCV(), + model_z_xw=LogisticRegressionCV() if binary_Z else LassoCV(), projection=False, featurizer=featurizer, discrete_treatment=binary_T, discrete_instrument=binary_Z, ), OrthoIV( + model_y_xw=LassoCV(), + model_t_xw=LogisticRegressionCV() if binary_T else LassoCV(), + model_t_xwz=LogisticRegressionCV() if binary_T else LassoCV(), projection=True, featurizer=featurizer, discrete_treatment=binary_T, discrete_instrument=binary_Z, ), DMLIV( + model_y_xw=LassoCV(), + model_t_xw=LogisticRegressionCV() if binary_T else LassoCV(), + model_t_xwz=LogisticRegressionCV() if binary_T else LassoCV(), model_final=LinearRegression(fit_intercept=False), featurizer=featurizer, discrete_treatment=binary_T, discrete_instrument=binary_Z, ), NonParamDMLIV( + model_y_xw=LassoCV(), + model_t_xw=LogisticRegressionCV() if binary_T else LassoCV(), + model_t_xwz=LogisticRegressionCV() if binary_T else LassoCV(), model_final=RandomForestRegressor(), featurizer=featurizer, discrete_treatment=binary_T, diff --git a/econml/tests/test_driv.py b/econml/tests/test_driv.py index 38bb8421a..17185b1f5 100644 --- a/econml/tests/test_driv.py +++ b/econml/tests/test_driv.py @@ -74,7 +74,13 @@ def eff_shape(n, d_x): Z = np.random.normal(size=(n,)) est_list = [ + # we're running a lot of tests, so use fixed models instead of model selection DRIV( + model_y_xw=LinearRegression(), + model_t_xw=LinearRegression(), + model_tz_xw=LinearRegression(), + model_t_xwz=LinearRegression() if projection else "auto", + model_z_xw=LinearRegression() if not projection else "auto", flexible_model_effect=StatsModelsLinearRegression(fit_intercept=False), model_final=StatsModelsLinearRegression( fit_intercept=False @@ -88,6 +94,11 @@ def eff_shape(n, d_x): use_ray=use_ray, ), LinearDRIV( + model_y_xw=LinearRegression(), + model_t_xw=LinearRegression(), + model_tz_xw=LinearRegression(), + model_t_xwz=LinearRegression() if projection else "auto", + model_z_xw=LinearRegression() if not projection else "auto", flexible_model_effect=StatsModelsLinearRegression(fit_intercept=False), fit_cate_intercept=True, projection=projection, @@ -98,6 +109,11 @@ def eff_shape(n, d_x): use_ray=use_ray, ), SparseLinearDRIV( + model_y_xw=LinearRegression(), + model_t_xw=LinearRegression(), + model_tz_xw=LinearRegression(), + model_t_xwz=LinearRegression() if projection else "auto", + model_z_xw=LinearRegression() if not projection else "auto", flexible_model_effect=StatsModelsLinearRegression(fit_intercept=False), fit_cate_intercept=True, projection=projection, @@ -108,6 +124,11 @@ def eff_shape(n, d_x): use_ray=use_ray, ), ForestDRIV( + model_y_xw=LinearRegression(), + model_t_xw=LinearRegression(), + model_tz_xw=LinearRegression(), + model_t_xwz=LinearRegression() if projection else "auto", + model_z_xw=LinearRegression() if not projection else "auto", flexible_model_effect=StatsModelsLinearRegression(fit_intercept=False), projection=projection, fit_cov_directly=fit_cov_directly, @@ -125,6 +146,8 @@ def eff_shape(n, d_x): if binary_T and binary_Z and not fit_cov_directly: est_list += [ IntentToTreatDRIV( + model_y_xw=LinearRegression(), + model_t_xwz=LinearRegression(), flexible_model_effect=StatsModelsLinearRegression( fit_intercept=False ), @@ -133,6 +156,8 @@ def eff_shape(n, d_x): use_ray=use_ray, ), LinearIntentToTreatDRIV( + model_y_xw=LinearRegression(), + model_t_xwz=LinearRegression(), flexible_model_effect=StatsModelsLinearRegression( fit_intercept=False ), @@ -283,8 +308,8 @@ def test_fit_cov_directly(self): # fitting the covariance directly should be at least as good as computing the covariance from separate models # 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()) + est = LinearDRIV(model_y_xw=LinearRegression(), model_t_xw=LinearRegression(), model_z_xw=LinearRegression(), + model_tz_xw=LinearRegression()) n = 500 p = 10 From 2451faa9c79da8fddd42be8da0eba001be6ddcba Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Fri, 10 Nov 2023 09:51:48 -0500 Subject: [PATCH 11/19] Ensure use of models that can fit arrays and vectors in DMLIV tests Signed-off-by: Keith Battocchi --- econml/tests/test_dmliv.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/econml/tests/test_dmliv.py b/econml/tests/test_dmliv.py index 8246db428..e2dc6c21e 100644 --- a/econml/tests/test_dmliv.py +++ b/econml/tests/test_dmliv.py @@ -8,12 +8,12 @@ import pytest from scipy import special from sklearn.ensemble import RandomForestRegressor -from sklearn.linear_model import LassoCV, LinearRegression, LogisticRegression, LogisticRegressionCV +from sklearn.linear_model import LinearRegression, LogisticRegression, LogisticRegressionCV from sklearn.preprocessing import PolynomialFeatures from econml.iv.dml import OrthoIV, DMLIV, NonParamDMLIV from econml.iv.dr._dr import _DummyCATE -from econml.sklearn_extensions.linear_model import StatsModelsLinearRegression +from econml.sklearn_extensions.linear_model import StatsModelsLinearRegression, WeightedLassoCVWrapper from econml.utilities import shape from econml.tests.utilities import GroupingModel @@ -62,40 +62,40 @@ def eff_shape(n, d_x, d_y): None, PolynomialFeatures(degree=2, include_bias=False), ]: - # since we're running so many combinations, just use LassoCV/LogisticRegressionCV for the models - # instead of also selecting over random forest models + # since we're running so many combinations, just use LassoCV/LogisticRegressionCV + # for the models instead of also selecting over random forest models est_list = [ OrthoIV( - model_y_xw=LassoCV(), - model_t_xw=LogisticRegressionCV() if binary_T else LassoCV(), - model_z_xw=LogisticRegressionCV() if binary_Z else LassoCV(), + model_y_xw=WeightedLassoCVWrapper(), + model_t_xw=LogisticRegressionCV() if binary_T else WeightedLassoCVWrapper(), + model_z_xw=LogisticRegressionCV() if binary_Z else WeightedLassoCVWrapper(), projection=False, featurizer=featurizer, discrete_treatment=binary_T, discrete_instrument=binary_Z, ), OrthoIV( - model_y_xw=LassoCV(), - model_t_xw=LogisticRegressionCV() if binary_T else LassoCV(), - model_t_xwz=LogisticRegressionCV() if binary_T else LassoCV(), + model_y_xw=WeightedLassoCVWrapper(), + model_t_xw=LogisticRegressionCV() if binary_T else WeightedLassoCVWrapper(), + model_t_xwz=LogisticRegressionCV() if binary_T else WeightedLassoCVWrapper(), projection=True, featurizer=featurizer, discrete_treatment=binary_T, discrete_instrument=binary_Z, ), DMLIV( - model_y_xw=LassoCV(), - model_t_xw=LogisticRegressionCV() if binary_T else LassoCV(), - model_t_xwz=LogisticRegressionCV() if binary_T else LassoCV(), + model_y_xw=WeightedLassoCVWrapper(), + model_t_xw=LogisticRegressionCV() if binary_T else WeightedLassoCVWrapper(), + model_t_xwz=LogisticRegressionCV() if binary_T else WeightedLassoCVWrapper(), model_final=LinearRegression(fit_intercept=False), featurizer=featurizer, discrete_treatment=binary_T, discrete_instrument=binary_Z, ), NonParamDMLIV( - model_y_xw=LassoCV(), - model_t_xw=LogisticRegressionCV() if binary_T else LassoCV(), - model_t_xwz=LogisticRegressionCV() if binary_T else LassoCV(), + model_y_xw=WeightedLassoCVWrapper(), + model_t_xw=LogisticRegressionCV() if binary_T else WeightedLassoCVWrapper(), + model_t_xwz=LogisticRegressionCV() if binary_T else WeightedLassoCVWrapper(), model_final=RandomForestRegressor(), featurizer=featurizer, discrete_treatment=binary_T, From 6d4a2033e71b8ecb0d5c041bcf89ef56e3d89d2e Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Fri, 10 Nov 2023 11:03:51 -0500 Subject: [PATCH 12/19] Fix tests Signed-off-by: Keith Battocchi --- econml/_ortho_learner.py | 2 +- econml/dml/_rlearner.py | 5 +++-- econml/iv/dr/_dr.py | 12 +++++++----- econml/tests/test_driv.py | 40 +++++++++++++++++++++------------------ 4 files changed, 33 insertions(+), 26 deletions(-) diff --git a/econml/_ortho_learner.py b/econml/_ortho_learner.py index 270fd5d84..d41ec66c9 100644 --- a/econml/_ortho_learner.py +++ b/econml/_ortho_learner.py @@ -179,7 +179,7 @@ def _crossfit(model: ModelSelector, folds, use_ray, ray_remote_fun_option, *args class Wrapper: def __init__(self, model): self._model = model - def fit(self, is_selecting, X, y, W=None): + def train(self, is_selecting, X, y, W=None): self._model.fit(X, y) return self def predict(self, X, y, W=None): diff --git a/econml/dml/_rlearner.py b/econml/dml/_rlearner.py index b1bc9e2ad..159763a6c 100644 --- a/econml/dml/_rlearner.py +++ b/econml/dml/_rlearner.py @@ -203,6 +203,7 @@ class _RLearner(_OrthoLearner): import numpy as np from sklearn.linear_model import LinearRegression from econml.dml._rlearner import _RLearner + from econml.sklearn_extensions.model_selection import get_selector from sklearn.base import clone class ModelFirst: def __init__(self, model): @@ -221,9 +222,9 @@ def predict(self, X): return self.model.predict(X) class RLearner(_RLearner): def _gen_model_y(self): - return ModelFirst(LinearRegression()) + return get_selector(ModelFirst(LinearRegression())) def _gen_model_t(self): - return ModelFirst(LinearRegression()) + return get_selector(ModelFirst(LinearRegression())) def _gen_rlearner_model_final(self): return ModelFinal() np.random.seed(123) diff --git a/econml/iv/dr/_dr.py b/econml/iv/dr/_dr.py index b4e2c8a40..8e48f905e 100644 --- a/econml/iv/dr/_dr.py +++ b/econml/iv/dr/_dr.py @@ -668,7 +668,7 @@ def _gen_ortho_learner_model_nuisance(self): 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 + # this is a regression model since the instrument E[T|X,W,Z] is always continuous model_tz_xw = _make_first_stage_selector(self.model_tz_xw, is_discrete=False, random_state=self.random_state) @@ -679,12 +679,14 @@ def _gen_ortho_learner_model_nuisance(self): random_state=self.random_state) else: - 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), + 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) - model_z = _make_first_stage_selector(self.model_z_xw, is_discrete=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(), diff --git a/econml/tests/test_driv.py b/econml/tests/test_driv.py index 17185b1f5..2b2eba26d 100644 --- a/econml/tests/test_driv.py +++ b/econml/tests/test_driv.py @@ -77,10 +77,11 @@ def eff_shape(n, d_x): # we're running a lot of tests, so use fixed models instead of model selection DRIV( model_y_xw=LinearRegression(), - model_t_xw=LinearRegression(), - model_tz_xw=LinearRegression(), - model_t_xwz=LinearRegression() if projection else "auto", - model_z_xw=LinearRegression() if not projection else "auto", + model_t_xw=LogisticRegression() if binary_T else LinearRegression(), + model_tz_xw=LogisticRegression() if binary_T and binary_Z and not ( + projection or fit_cov_directly) else LinearRegression(), + model_t_xwz="auto" if not projection else LogisticRegression() if binary_T else LinearRegression(), + model_z_xw="auto" if projection else LogisticRegression() if binary_Z else LinearRegression(), flexible_model_effect=StatsModelsLinearRegression(fit_intercept=False), model_final=StatsModelsLinearRegression( fit_intercept=False @@ -95,10 +96,11 @@ def eff_shape(n, d_x): ), LinearDRIV( model_y_xw=LinearRegression(), - model_t_xw=LinearRegression(), - model_tz_xw=LinearRegression(), - model_t_xwz=LinearRegression() if projection else "auto", - model_z_xw=LinearRegression() if not projection else "auto", + model_t_xw=LogisticRegression() if binary_T else LinearRegression(), + model_tz_xw=LogisticRegression() if binary_T and binary_Z and not ( + projection or fit_cov_directly) else LinearRegression(), + model_t_xwz="auto" if not projection else LogisticRegression() if binary_T else LinearRegression(), + model_z_xw="auto" if projection else LogisticRegression() if binary_Z else LinearRegression(), flexible_model_effect=StatsModelsLinearRegression(fit_intercept=False), fit_cate_intercept=True, projection=projection, @@ -110,10 +112,11 @@ def eff_shape(n, d_x): ), SparseLinearDRIV( model_y_xw=LinearRegression(), - model_t_xw=LinearRegression(), - model_tz_xw=LinearRegression(), - model_t_xwz=LinearRegression() if projection else "auto", - model_z_xw=LinearRegression() if not projection else "auto", + model_t_xw=LogisticRegression() if binary_T else LinearRegression(), + model_tz_xw=LogisticRegression() if binary_T and binary_Z and not ( + projection or fit_cov_directly) else LinearRegression(), + model_t_xwz="auto" if not projection else LogisticRegression() if binary_T else LinearRegression(), + model_z_xw="auto" if projection else LogisticRegression() if binary_Z else LinearRegression(), flexible_model_effect=StatsModelsLinearRegression(fit_intercept=False), fit_cate_intercept=True, projection=projection, @@ -125,10 +128,11 @@ def eff_shape(n, d_x): ), ForestDRIV( model_y_xw=LinearRegression(), - model_t_xw=LinearRegression(), - model_tz_xw=LinearRegression(), - model_t_xwz=LinearRegression() if projection else "auto", - model_z_xw=LinearRegression() if not projection else "auto", + model_t_xw=LogisticRegression() if binary_T else LinearRegression(), + model_tz_xw=LogisticRegression() if binary_T and binary_Z and not ( + projection or fit_cov_directly) else LinearRegression(), + model_t_xwz="auto" if not projection else LogisticRegression() if binary_T else LinearRegression(), + model_z_xw="auto" if projection else LogisticRegression() if binary_Z else LinearRegression(), flexible_model_effect=StatsModelsLinearRegression(fit_intercept=False), projection=projection, fit_cov_directly=fit_cov_directly, @@ -147,7 +151,7 @@ def eff_shape(n, d_x): est_list += [ IntentToTreatDRIV( model_y_xw=LinearRegression(), - model_t_xwz=LinearRegression(), + model_t_xwz=LogisticRegression(), flexible_model_effect=StatsModelsLinearRegression( fit_intercept=False ), @@ -157,7 +161,7 @@ def eff_shape(n, d_x): ), LinearIntentToTreatDRIV( model_y_xw=LinearRegression(), - model_t_xwz=LinearRegression(), + model_t_xwz=LogisticRegression(), flexible_model_effect=StatsModelsLinearRegression( fit_intercept=False ), From 818ff9c8bd02459dfa41d56ecd0c9e9ccfa3ed0b Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Fri, 10 Nov 2023 11:29:02 -0500 Subject: [PATCH 13/19] Speed up tests Signed-off-by: Keith Battocchi --- econml/tests/test_dml.py | 2 +- econml/tests/test_treatment_featurization.py | 71 +++++++++++++------- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/econml/tests/test_dml.py b/econml/tests/test_dml.py index be20b2639..5867f4d40 100644 --- a/econml/tests/test_dml.py +++ b/econml/tests/test_dml.py @@ -159,7 +159,7 @@ def make_random(n, is_discrete, d): True, ['auto']), (LinearDML(model_y=Lasso(), - model_t='auto', + model_t=model_t, featurizer=featurizer, fit_cate_intercept=fit_cate_intercept, discrete_treatment=is_discrete, diff --git a/econml/tests/test_treatment_featurization.py b/econml/tests/test_treatment_featurization.py index 834da1ffe..a5b825253 100644 --- a/econml/tests/test_treatment_featurization.py +++ b/econml/tests/test_treatment_featurization.py @@ -4,7 +4,7 @@ import unittest import numpy as np from sklearn.preprocessing import PolynomialFeatures -from sklearn.linear_model import LinearRegression, LogisticRegression +from sklearn.linear_model import LassoCV, LinearRegression, LogisticRegression from sklearn.ensemble import RandomForestRegressor from joblib import Parallel, delayed @@ -14,7 +14,7 @@ from econml.iv.dr import DRIV, LinearDRIV, SparseLinearDRIV, ForestDRIV from econml.orf import DMLOrthoForest from sklearn.preprocessing import OneHotEncoder, FunctionTransformer -from econml.sklearn_extensions.linear_model import StatsModelsLinearRegression +from econml.sklearn_extensions.linear_model import StatsModelsLinearRegression, WeightedLassoCVWrapper from econml.utilities import jacify_featurizer from econml.iv.sieve import DPolynomialFeatures @@ -200,6 +200,25 @@ def sum_squeeze_func_transform(x): class TestTreatmentFeaturization(unittest.TestCase): def test_featurization(self): + # use LassoCV rather than also selecting over RandomForests to save time + dml_models = { + "model_t": WeightedLassoCVWrapper(), + "model_y": WeightedLassoCVWrapper() + } + + dmliv_models = { + "model_y_xw": WeightedLassoCVWrapper(), + "model_t_xw": WeightedLassoCVWrapper(), + "model_t_xwz": WeightedLassoCVWrapper(), + } + + driv_models = { + "model_y_xw": WeightedLassoCVWrapper(), + "model_t_xw": WeightedLassoCVWrapper(), + "model_z_xw": WeightedLassoCVWrapper(), + "model_tz_xw": WeightedLassoCVWrapper(), + } + identity_config = { 'DGP_params': { 'n': 2000, @@ -223,10 +242,10 @@ def test_featurization(self): 'squeeze_Ts': [False, True], 'squeeze_Ys': [False, True], 'est_dicts': [ - {'class': LinearDML, 'init_args': {}}, - {'class': CausalForestDML, 'init_args': {}}, - {'class': SparseLinearDML, 'init_args': {}}, - {'class': KernelDML, 'init_args': {}}, + {'class': LinearDML, 'init_args': dml_models}, + {'class': CausalForestDML, 'init_args': dml_models}, + {'class': SparseLinearDML, 'init_args': dml_models}, + {'class': KernelDML, 'init_args': dml_models}, ] } @@ -253,10 +272,10 @@ def test_featurization(self): 'squeeze_Ts': [False, True], 'squeeze_Ys': [False, True], 'est_dicts': [ - {'class': LinearDML, 'init_args': {}}, - {'class': CausalForestDML, 'init_args': {}}, - {'class': SparseLinearDML, 'init_args': {}}, - {'class': KernelDML, 'init_args': {}}, + {'class': LinearDML, 'init_args': dml_models}, + {'class': CausalForestDML, 'init_args': dml_models}, + {'class': SparseLinearDML, 'init_args': dml_models}, + {'class': KernelDML, 'init_args': dml_models}, ] } @@ -268,9 +287,11 @@ def test_featurization(self): poly_IV_config['DGP_params']['d_z'] = 1 poly_IV_config['DGP_params']['nuisance_TZ'] = lambda Z: Z poly_IV_config['est_dicts'] = [ - {'class': OrthoIV, 'init_args': { - 'model_t_xwz': RandomForestRegressor(random_state=1), 'projection': True}}, - {'class': DMLIV, 'init_args': {'model_t_xwz': RandomForestRegressor(random_state=1)}}, + {'class': OrthoIV, 'init_args': {**dmliv_models, + 'model_t_xwz': RandomForestRegressor(random_state=1), + 'projection': True}}, + {'class': DMLIV, 'init_args': {**dmliv_models, + 'model_t_xwz': RandomForestRegressor(random_state=1)}}, ] poly_1d_config = deepcopy(poly_config) @@ -287,11 +308,13 @@ def test_featurization(self): poly_1d_IV_config['treatment_featurizer'] = polynomial_1d_treatment_featurizer poly_1d_IV_config['actual_cme'] = poly_1d_actual_cme poly_1d_IV_config['est_dicts'] = [ - {'class': NonParamDMLIV, 'init_args': {'model_final': StatsModelsLinearRegression()}}, - {'class': DRIV, 'init_args': {'fit_cate_intercept': True}}, - {'class': LinearDRIV, 'init_args': {}}, - {'class': SparseLinearDRIV, 'init_args': {}}, - {'class': ForestDRIV, 'init_args': {}}, + {'class': NonParamDMLIV, 'init_args': {**dmliv_models, + 'model_final': StatsModelsLinearRegression()}}, + {'class': DRIV, 'init_args': {**driv_models, + 'fit_cate_intercept': True}}, + {'class': LinearDRIV, 'init_args': driv_models}, + {'class': SparseLinearDRIV, 'init_args': driv_models}, + {'class': ForestDRIV, 'init_args': driv_models}, ] sum_IV_config = { @@ -319,11 +342,13 @@ def test_featurization(self): 'squeeze_Ts': [False], 'squeeze_Ys': [False, True], 'est_dicts': [ - {'class': NonParamDMLIV, 'init_args': {'model_final': StatsModelsLinearRegression()}}, - {'class': DRIV, 'init_args': {'fit_cate_intercept': True}}, - {'class': LinearDRIV, 'init_args': {}}, - {'class': SparseLinearDRIV, 'init_args': {}}, - {'class': ForestDRIV, 'init_args': {}}, + {'class': NonParamDMLIV, 'init_args': {**dmliv_models, + 'model_final': StatsModelsLinearRegression()}}, + {'class': DRIV, 'init_args': {**driv_models, + 'fit_cate_intercept': True}}, + {'class': LinearDRIV, 'init_args': driv_models}, + {'class': SparseLinearDRIV, 'init_args': driv_models}, + {'class': ForestDRIV, 'init_args': driv_models}, ] } From ba7de62c9df204ef557e7fc6283d7db993226fdc Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Fri, 10 Nov 2023 17:05:29 -0500 Subject: [PATCH 14/19] Make tests more reliable Signed-off-by: Keith Battocchi --- econml/dml/_rlearner.py | 4 ++-- econml/tests/test_dml.py | 4 ++-- econml/tests/test_driv.py | 2 +- econml/tests/test_drlearner.py | 1 + 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/econml/dml/_rlearner.py b/econml/dml/_rlearner.py index 159763a6c..bcde54fc9 100644 --- a/econml/dml/_rlearner.py +++ b/econml/dml/_rlearner.py @@ -222,9 +222,9 @@ def predict(self, X): return self.model.predict(X) class RLearner(_RLearner): def _gen_model_y(self): - return get_selector(ModelFirst(LinearRegression())) + return get_selector(ModelFirst(LinearRegression()), is_discrete=False) def _gen_model_t(self): - return get_selector(ModelFirst(LinearRegression())) + return get_selector(ModelFirst(LinearRegression()), is_discrete=False) def _gen_rlearner_model_final(self): return ModelFinal() np.random.seed(123) diff --git a/econml/tests/test_dml.py b/econml/tests/test_dml.py index 5867f4d40..167c29632 100644 --- a/econml/tests/test_dml.py +++ b/econml/tests/test_dml.py @@ -1012,9 +1012,9 @@ def prediction_stderr(self, X): assert dml.marginal_effect_interval(1) == (1, 1) def test_sparse(self): + # Ensure reproducibility + np.random.seed(1234) for _ in range(5): - # Ensure reproducibility - np.random.seed(1234) n_p = np.random.randint(2, 5) # 2 to 4 products d_w = np.random.randint(0, 5) # random number of covariates min_n = np.ceil(2 + d_w * (1 + (d_w + 1) / n_p)) # minimum number of rows per product diff --git a/econml/tests/test_driv.py b/econml/tests/test_driv.py index 2b2eba26d..111ecd608 100644 --- a/econml/tests/test_driv.py +++ b/econml/tests/test_driv.py @@ -313,7 +313,7 @@ def test_fit_cov_directly(self): # 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=LinearRegression(), model_t_xw=LinearRegression(), model_z_xw=LinearRegression(), - model_tz_xw=LinearRegression()) + model_tz_xw=LassoCV()) n = 500 p = 10 diff --git a/econml/tests/test_drlearner.py b/econml/tests/test_drlearner.py index 3d3e982a9..a02aea617 100644 --- a/econml/tests/test_drlearner.py +++ b/econml/tests/test_drlearner.py @@ -765,6 +765,7 @@ def test_DRLearner(self): outcome_model = Pipeline( [('poly', PolynomialFeatures()), ('model', LinearRegression())]) DR_learner = DRLearner(model_regression=outcome_model, + model_propensity=LogisticRegressionCV(), model_final=LinearRegression()) self._test_te(DR_learner, tol=0.5, te_type="heterogeneous") # Test heterogenous treatment effect for W =/= None From a551c19d728c860e9ba9ee5e299cdd0c3f8ea326 Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Fri, 10 Nov 2023 19:00:32 -0500 Subject: [PATCH 15/19] Try to fix tests Signed-off-by: Keith Battocchi --- econml/tests/test_dml.py | 13 ++++++------- econml/tests/test_driv.py | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/econml/tests/test_dml.py b/econml/tests/test_dml.py index 167c29632..656596fca 100644 --- a/econml/tests/test_dml.py +++ b/econml/tests/test_dml.py @@ -1014,13 +1014,12 @@ def prediction_stderr(self, X): def test_sparse(self): # Ensure reproducibility np.random.seed(1234) - for _ in range(5): - n_p = np.random.randint(2, 5) # 2 to 4 products - d_w = np.random.randint(0, 5) # random number of covariates - min_n = np.ceil(2 + d_w * (1 + (d_w + 1) / n_p)) # minimum number of rows per product - n_r = np.random.randint(min_n, min_n + 3) - with self.subTest(n_p=n_p, d_w=d_w, n_r=n_r): - TestDML._test_sparse(n_p, d_w, n_r) + n_p = np.random.randint(2, 5) # 2 to 4 products + d_w = np.random.randint(0, 5) # random number of covariates + min_n = np.ceil(2 + d_w * (1 + (d_w + 1) / n_p)) # minimum number of rows per product + n_r = np.random.randint(min_n, min_n + 3) + with self.subTest(n_p=n_p, d_w=d_w, n_r=n_r): + TestDML._test_sparse(n_p, d_w, n_r) def test_linear_sparse(self): """SparseDML test with a sparse DGP""" diff --git a/econml/tests/test_driv.py b/econml/tests/test_driv.py index 111ecd608..6863006f1 100644 --- a/econml/tests/test_driv.py +++ b/econml/tests/test_driv.py @@ -236,7 +236,7 @@ def test_cate_api_without_ray(self): self._test_cate_api(use_ray=False) def _test_accuracy(self, use_ray=False): - np.random.seed(42) + np.random.seed(123) # dgp (binary T, binary Z) From 96cb47e33cf50c792c825b2727b350d3d12c6f9d Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Fri, 10 Nov 2023 21:51:29 -0500 Subject: [PATCH 16/19] Fix tests Signed-off-by: Keith Battocchi --- econml/dml/_rlearner.py | 18 +++++++++++------- econml/tests/test_dml.py | 2 +- econml/tests/test_driv.py | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/econml/dml/_rlearner.py b/econml/dml/_rlearner.py index bcde54fc9..c1db38dab 100644 --- a/econml/dml/_rlearner.py +++ b/econml/dml/_rlearner.py @@ -203,16 +203,20 @@ class _RLearner(_OrthoLearner): import numpy as np from sklearn.linear_model import LinearRegression from econml.dml._rlearner import _RLearner - from econml.sklearn_extensions.model_selection import get_selector + from econml.sklearn_extensions.model_selection import SingleModelSelector from sklearn.base import clone - class ModelFirst: + class ModelSelector(SingleModelSelector): def __init__(self, model): self._model = clone(model, safe=False) - def fit(self, X, W, Y, sample_weight=None): + def train(self, is_selecting, X, W, Y, sample_weight=None): self._model.fit(np.hstack([X, W]), Y) return self - def predict(self, X, W): - return self._model.predict(np.hstack([X, W])) + @property + def best_model(self): + return self._model + @property + def best_score(self): + return 0 class ModelFinal: def fit(self, X, T, T_res, Y_res, sample_weight=None, freq_weight=None, sample_var=None): self.model = LinearRegression(fit_intercept=False).fit(X * T_res.reshape(-1, 1), @@ -222,9 +226,9 @@ def predict(self, X): return self.model.predict(X) class RLearner(_RLearner): def _gen_model_y(self): - return get_selector(ModelFirst(LinearRegression()), is_discrete=False) + return ModelSelector(LinearRegression()) def _gen_model_t(self): - return get_selector(ModelFirst(LinearRegression()), is_discrete=False) + return ModelSelector(LinearRegression()) def _gen_rlearner_model_final(self): return ModelFinal() np.random.seed(123) diff --git a/econml/tests/test_dml.py b/econml/tests/test_dml.py index 656596fca..57f6e9051 100644 --- a/econml/tests/test_dml.py +++ b/econml/tests/test_dml.py @@ -1013,7 +1013,7 @@ def prediction_stderr(self, X): def test_sparse(self): # Ensure reproducibility - np.random.seed(1234) + np.random.seed(123) n_p = np.random.randint(2, 5) # 2 to 4 products d_w = np.random.randint(0, 5) # random number of covariates min_n = np.ceil(2 + d_w * (1 + (d_w + 1) / n_p)) # minimum number of rows per product diff --git a/econml/tests/test_driv.py b/econml/tests/test_driv.py index 6863006f1..9ff237c47 100644 --- a/econml/tests/test_driv.py +++ b/econml/tests/test_driv.py @@ -236,7 +236,7 @@ def test_cate_api_without_ray(self): self._test_cate_api(use_ray=False) def _test_accuracy(self, use_ray=False): - np.random.seed(123) + np.random.seed(0) # dgp (binary T, binary Z) From f454f24b0b1ed1f876d4a00621bf85f7298172ce Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Sat, 11 Nov 2023 05:55:40 -0500 Subject: [PATCH 17/19] Fix docstrings Signed-off-by: Keith Battocchi --- econml/dml/_rlearner.py | 16 ++++++--- econml/dml/causal_forest.py | 6 ++-- econml/dml/dml.py | 52 ++++++++++++++-------------- econml/dr/_drlearner.py | 69 ++++++++++++++++++------------------- econml/iv/dml/_dml.py | 18 +++++----- econml/iv/dr/_dr.py | 52 ++++++++++++++-------------- econml/panel/dml/_dml.py | 46 ++++++++++++------------- 7 files changed, 133 insertions(+), 126 deletions(-) diff --git a/econml/dml/_rlearner.py b/econml/dml/_rlearner.py index c1db38dab..99eb347a7 100644 --- a/econml/dml/_rlearner.py +++ b/econml/dml/_rlearner.py @@ -205,12 +205,20 @@ class _RLearner(_OrthoLearner): from econml.dml._rlearner import _RLearner from econml.sklearn_extensions.model_selection import SingleModelSelector from sklearn.base import clone - class ModelSelector(SingleModelSelector): + class ModelFirst: def __init__(self, model): self._model = clone(model, safe=False) - def train(self, is_selecting, X, W, Y, sample_weight=None): + def fit(self, X, W, Y, sample_weight=None): self._model.fit(np.hstack([X, W]), Y) return self + def predict(self, X, W): + return self._model.predict(np.hstack([X, W])) + class ModelSelector(SingleModelSelector): + def __init__(self, model): + self._model = ModelFirst(model) + def train(self, is_selecting, X, W, Y, sample_weight=None): + self._model.fit(np.hstack(X, W, Y) + return self @property def best_model(self): return self._model @@ -250,9 +258,9 @@ def _gen_rlearner_model_final(self): array([0.999631...]) >>> est.score_ 9.82623204...e-05 - >>> [mdl._model for mdls in est.models_y for mdl in mdls] + >>> [mdl.best_model._model for mdls in est.models_y for mdl in mdls] [LinearRegression(), LinearRegression()] - >>> [mdl._model for mdls in est.models_t for mdl in mdls] + >>> [mdl.best_model._model for mdls in est.models_t for mdl in mdls] [LinearRegression(), LinearRegression()] Attributes diff --git a/econml/dml/causal_forest.py b/econml/dml/causal_forest.py index 757b498ef..8b4555974 100644 --- a/econml/dml/causal_forest.py +++ b/econml/dml/causal_forest.py @@ -548,10 +548,10 @@ class CausalForestDML(_BaseDML): est.fit(y, T, X=X, W=None) >>> est.effect(X[:3]) - array([0.76625..., 1.52176..., 0.73679...]) + array([0.88518..., 1.25061..., 0.81112...]) >>> est.effect_interval(X[:3]) - (array([0.39668..., 1.08245... , 0.16566...]), - array([1.13581..., 1.96107..., 1.30791...])) + (array([0.40163..., 0.75023..., 0.46629...]), + array([1.36873..., 1.75099.., 1.15596...])) Attributes ---------- diff --git a/econml/dml/dml.py b/econml/dml/dml.py index e32b6685d..4852cb83f 100644 --- a/econml/dml/dml.py +++ b/econml/dml/dml.py @@ -467,18 +467,19 @@ class takes as input the parameter `model_t`, which is an arbitrary scikit-learn est.fit(y, T, X=X, W=None) >>> est.effect(X[:3]) - array([0.63382..., 1.78225..., 0.71859...]) + array([0.65142..., 1.82917..., 0.79287...]) >>> est.effect_interval(X[:3]) - (array([0.27937..., 1.27619..., 0.42091...]),...([0.98827... , 2.28831..., 1.01628...])) + (array([0.28936..., 1.31239..., 0.47626...]), + array([1.01348..., 2.34594..., 1.10949...])) >>> est.coef_ - array([ 0.42857..., 0.04488..., -0.03317..., 0.02258..., -0.14875...]) + array([ 0.32570..., -0.05311..., -0.03973..., 0.01598..., -0.11045...]) >>> est.coef__interval() - (array([ 0.25179..., -0.10558..., -0.16723... , -0.11916..., -0.28759...]), - array([ 0.60535..., 0.19536..., 0.10088..., 0.16434..., -0.00990...])) + (array([ 0.13791..., -0.20081..., -0.17941..., -0.12073..., -0.25769...]), + array([0.51348..., 0.09458..., 0.09993..., 0.15269..., 0.03679...])) >>> est.intercept_ - 1.01166... + 1.02940... >>> est.intercept__interval() - (0.87125..., 1.15207...) + (0.88754..., 1.17125...) """ def __init__(self, *, @@ -699,20 +700,19 @@ class LinearDML(StatsModelsCateEstimatorMixin, DML): est.fit(y, T, X=X, W=None) >>> est.effect(X[:3]) - array([0.59252... , 1.74657..., 0.77384...]) + array([0.60257..., 1.74564..., 0.72062...]) >>> est.effect_interval(X[:3]) - (array([0.25503..., 1.24556..., 0.48440...]), - array([0.93002... , 2.24757..., 1.06328... ])) + (array([0.25760..., 1.24005..., 0.41770...]), + array([0.94754..., 2.25123..., 1.02354...])) >>> est.coef_ - array([ 0.39746..., -0.00313..., 0.01346..., 0.01402..., -0.09071...]) + array([ 0.41635..., 0.00287..., -0.01831..., -0.01197..., -0.11620...]) >>> est.coef__interval() - (array([ 0.23709..., -0.13618... , -0.11712..., -0.11954..., -0.22782...]), - array([0.55783..., 0.12991..., 0.14405..., 0.14758..., 0.04640...])) + (array([ 0.24496..., -0.13418..., -0.14852..., -0.13947..., -0.25089...]), + array([0.58775..., 0.13993..., 0.11189..., 0.11551..., 0.01848...])) >>> est.intercept_ - 0.99197... + 0.97162... >>> est.intercept__interval() - (0.85855..., 1.12539...) - + (0.83640..., 1.10684...) """ def __init__(self, *, @@ -955,19 +955,19 @@ class SparseLinearDML(DebiasedLassoCateEstimatorMixin, DML): est.fit(y, T, X=X, W=None) >>> est.effect(X[:3]) - array([0.59401..., 1.74717..., 0.77105...]) + array([0.59812..., 1.75138..., 0.71770...]) >>> est.effect_interval(X[:3]) - (array([0.26608..., 1.26369..., 0.48690...]), - array([0.92195..., 2.23066..., 1.05520...])) + (array([0.25046..., 1.24249..., 0.42606...]), + array([0.94577..., 2.26028..., 1.00935... ])) >>> est.coef_ - array([ 0.39857..., -0.00101... , 0.01112..., 0.01457..., -0.09117...]) + array([ 0.41820..., 0.00506..., -0.01831..., -0.00778..., -0.11965...]) >>> est.coef__interval() - (array([ 0.24285..., -0.13728..., -0.12351..., -0.11585..., -0.22974...]), - array([0.55430..., 0.13526..., 0.14576..., 0.14501... , 0.04738...])) + (array([ 0.25058..., -0.13713..., -0.15469..., -0.13932..., -0.26252...]), + array([0.58583..., 0.14726..., 0.11806..., 0.12376..., 0.02320...])) >>> est.intercept_ - 0.99378... + 0.97131... >>> est.intercept__interval() - (0.86045..., 1.12711...) + (0.83363..., 1.10899...) """ def __init__(self, *, @@ -1204,7 +1204,7 @@ class KernelDML(DML): est.fit(y, T, X=X, W=None) >>> est.effect(X[:3]) - array([0.59341..., 1.54740..., 0.69454... ]) + array([0.64124..., 1.46561..., 0.68568... ]) """ def __init__(self, model_y='auto', model_t='auto', @@ -1412,7 +1412,7 @@ class NonParamDML(_BaseDML): est.fit(y, T, X=X, W=None) >>> est.effect(X[:3]) - array([0.35318..., 1.28760..., 0.83506...]) + array([0.32389..., 0.85703..., 0.97468...]) """ def __init__(self, *, diff --git a/econml/dr/_drlearner.py b/econml/dr/_drlearner.py index 1f74890e0..4a026ff4f 100644 --- a/econml/dr/_drlearner.py +++ b/econml/dr/_drlearner.py @@ -340,30 +340,29 @@ class takes as input the parameter ``model_regressor``, which is an arbitrary sc est.fit(y, T, X=X, W=None) >>> est.const_marginal_effect(X[:2]) - array([[0.511640..., 1.144004...], - [0.378140..., 0.613143...]]) + array([[0.520977..., 1.244073...], + [0.365645..., 0.749762...]]) >>> est.effect(X[:2], T0=0, T1=1) - array([0.511640..., 0.378140...]) + array([0.520977..., 0.365645...]) >>> est.score_ - 5.11238581... + 3.15958089... >>> est.score(y, T, X=X) - 5.78673506... + 2.60965712... >>> est.model_cate(T=1).coef_ - array([0.434910..., 0.010226..., 0.047913...]) + array([0.369069..., 0.016610..., 0.019072...]) >>> est.model_cate(T=2).coef_ - array([ 0.863723..., 0.086946..., -0.022288...]) + array([ 0.768336..., 0.082106..., -0.030475...]) >>> est.cate_feature_names() ['X0', 'X1', 'X2'] >>> [mdl.coef_ for mdls in est.models_regression for mdl in mdls] - [array([ 1.472..., 0.001..., -0.011..., 0.698..., 2.049...]), - array([ 1.455..., -0.002..., 0.005..., 0.677..., 1.998...])] + [array([ 1.463..., 0.006..., -0.006..., 0.726..., 2.029...]), + array([ 1.466..., -0.002..., 0..., 0.646..., 2.014...])] >>> [mdl.coef_ for mdls in est.models_propensity for mdl in mdls] - [array([[-0.747..., 0.153..., -0.018...], - [ 0.083..., -0.110..., -0.076...], - [ 0.663..., -0.043... , 0.094...]]), - array([[-1.048..., 0.000..., 0.032...], - [ 0.019..., 0.124..., -0.081...], - [ 1.029..., -0.124..., 0.049...]])] + [array([[-0.67903093, 0.04261741, -0.05969718], + [ 0.034..., -0.013..., -0.013...], + [ 0.644..., -0.028..., 0.073...]]), array([[-0.831..., 0.100..., 0.090...], + [ 0.084..., 0.013..., -0.154...], + [ 0.747..., -0.113..., 0.063...]])] Beyond default models: @@ -385,19 +384,19 @@ class takes as input the parameter ``model_regressor``, which is an arbitrary sc est.fit(y, T, X=X, W=None) >>> est.score_ - 1.7... + 3.7... >>> est.const_marginal_effect(X[:3]) - array([[0.68..., 1.10...], - [0.56..., 0.79...], - [0.34..., 0.10...]]) + array([[0.64..., 1.23...], + [0.49..., 0.92...], + [0.20..., 0.26...]]) >>> est.model_cate(T=2).coef_ - array([0.74..., 0. , 0. ]) + array([0.72..., 0. , 0. ]) >>> est.model_cate(T=2).intercept_ - 1.9... + 2.0... >>> est.model_cate(T=1).coef_ - array([0.24..., 0.00..., 0. ]) + array([0.31..., 0.01..., 0.00...]) >>> est.model_cate(T=1).intercept_ - 0.94... + 0.97... Attributes ---------- @@ -865,17 +864,17 @@ class LinearDRLearner(StatsModelsCateEstimatorDiscreteMixin, DRLearner): est.fit(y, T, X=X, W=None) >>> est.effect(X[:3]) - array([ 0.409743..., 0.312604..., -0.127394...]) + array([0.457602..., 0.335707..., 0.011288...]) >>> est.effect_interval(X[:3]) - (array([ 0.065306..., -0.182074..., -0.765901...]), array([0.754180..., 0.807284..., 0.511113...])) + (array([ 0.164623..., -0.098980..., -0.493464...]), array([0.750582..., 0.77039... , 0.516041...])) >>> est.coef_(T=1) - array([ 0.450779..., -0.003214... , 0.063884... ]) + array([ 0.338061..., 0.025654..., 0.044389...]) >>> est.coef__interval(T=1) - (array([ 0.155111..., -0.246272..., -0.136827...]), array([0.746447..., 0.239844..., 0.264595...])) + (array([ 0.135677..., -0.155845..., -0.143376...]), array([0.540446..., 0.207155..., 0.232155...])) >>> est.intercept_(T=1) - 0.88425066... + 0.78646497... >>> est.intercept__interval(T=1) - (0.64868548..., 1.11981585...) + (0.60344468..., 0.96948526...) Attributes ---------- @@ -1158,17 +1157,17 @@ class SparseLinearDRLearner(DebiasedLassoCateEstimatorDiscreteMixin, DRLearner): est.fit(y, T, X=X, W=None) >>> est.effect(X[:3]) - array([ 0.41..., 0.31..., -0.12...]) + array([0.45..., 0.33..., 0.01...]) >>> est.effect_interval(X[:3]) - (array([-0.02..., -0.29... , -0.84...]), array([0.84..., 0.92..., 0.59...])) + (array([ 0.11..., -0.13..., -0.54...]), array([0.79..., 0.80..., 0.57...])) >>> est.coef_(T=1) - array([ 0.45..., -0.00..., 0.06...]) + array([0.33..., 0.02..., 0.04...]) >>> est.coef__interval(T=1) - (array([ 0.20..., -0.23..., -0.17...]), array([0.69..., 0.23..., 0.30...])) + (array([ 0.14..., -0.15..., -0.14...]), array([0.53..., 0.20..., 0.23...])) >>> est.intercept_(T=1) - 0.88... + 0.78... >>> est.intercept__interval(T=1) - (0.64..., 1.11...) + (0.60..., 0.96...) Attributes ---------- diff --git a/econml/iv/dml/_dml.py b/econml/iv/dml/_dml.py index 1cddcc247..649293cde 100644 --- a/econml/iv/dml/_dml.py +++ b/econml/iv/dml/_dml.py @@ -331,19 +331,19 @@ def true_heterogeneity_function(X): est.fit(Y=y, T=T, Z=Z, X=X) >>> est.effect(X[:3]) - array([-4.57086..., 6.06523..., -3.02513...]) + array([-4.49594..., 5.79852..., -2.88049...]) >>> est.effect_interval(X[:3]) - (array([-7.45472..., 1.85334..., -5.47322...]), - array([-1.68700... , 10.27712..., -0.57704...])) + (array([-7.40954..., 1.47475..., -5.32889...]), + array([-1.58235..., 10.12229..., -0.43209...])) >>> est.coef_ - array([ 5.11260... , 0.71353..., 0.38242..., -0.23891..., -0.07036...]) + array([ 5.27614..., 0.92092..., 0.57579..., -0.22810..., -0.16952...]) >>> est.coef__interval() - (array([ 3.76773..., -0.42532..., -0.78145..., -1.36996..., -1.22505...]), - array([6.45747..., 1.85239..., 1.54631..., 0.89213..., 1.08432...])) + (array([ 3.93362..., -0.22159..., -0.59863..., -1.39139..., -1.34549...]), + array([6.61866..., 2.06345..., 1.75022..., 0.93518..., 1.00644...])) >>> est.intercept_ - -0.24090... + -0.29110... >>> est.intercept__interval() - (-1.39053..., 0.90872...) + (-1.45607..., 0.87386...) """ def __init__(self, *, @@ -1492,7 +1492,7 @@ def true_heterogeneity_function(X): est.fit(Y=y, T=T, Z=Z, X=X) >>> est.effect(X[:3]) - array([-5.52240..., 7.86930..., -3.57966...]) + array([-6.18157..., 8.70189..., -4.06004...]) """ diff --git a/econml/iv/dr/_dr.py b/econml/iv/dr/_dr.py index 8e48f905e..c2393cabf 100644 --- a/econml/iv/dr/_dr.py +++ b/econml/iv/dr/_dr.py @@ -883,7 +883,7 @@ def true_heterogeneity_function(X): est.fit(Y=y, T=T, Z=Z, X=X) >>> est.effect(X[:3]) - array([-1.71678..., -0.27824..., -3.18333...]) + array([-4.07330..., 6.01693..., -2.71813...]) """ def __init__(self, *, @@ -1364,19 +1364,19 @@ def true_heterogeneity_function(X): est.fit(Y=y, T=T, Z=Z, X=X) >>> est.effect(X[:3]) - array([-0.54223..., 0.77763..., -2.01011...]) + array([-4.29809..., 5.94280..., -3.00977...]) >>> est.effect_interval(X[:3]) - (array([-4.73213..., -5.57270..., -5.84891...]), - array([3.64765..., 7.12797..., 1.82868...])) + (array([-7.09165..., 1.79692..., -5.46033...]), + array([-1.50452..., 10.08868..., -0.55922...])) >>> est.coef_ - array([ 3.12341..., 1.78962..., -0.45351..., -0.41677..., 0.93306...]) + array([ 4.84900..., 0.82084..., 0.24269..., -0.04771..., -0.29325...]) >>> est.coef__interval() - (array([ 1.36498..., 0.00496..., -2.28573..., -2.02274..., -0.94000...]), - array([4.88184..., 3.57428..., 1.37869..., 1.18919..., 2.80614...])) + (array([ 3.67882..., -0.35547..., -0.97063..., -1.15410..., -1.50482...]), + array([6.01917..., 1.99716..., 1.45603..., 1.05867..., 0.91831...])) >>> est.intercept_ - 1.10417... + -0.16276... >>> est.intercept__interval() - (-0.65690..., 2.86525...) + (-1.32713..., 1.00160...) """ def __init__(self, *, @@ -1715,19 +1715,19 @@ def true_heterogeneity_function(X): est.fit(Y=y, T=T, Z=Z, X=X) >>> est.effect(X[:3]) - array([-0.68659..., 1.03696..., -2.10343...]) + array([-4.26791..., 5.98882..., -3.02154...]) >>> est.effect_interval(X[:3]) - (array([-4.92102..., -4.99359..., -5.79899...]), - array([3.54783..., 7.06753..., 1.59212...])) + (array([-7.06828..., 2.00060..., -5.46554...]), + array([-1.46754..., 9.97704..., -0.57754...])) >>> est.coef_ - array([ 3.18552..., 1.83651..., -0.47721..., -0.28640... , 0.87765...]) + array([ 4.84189..., 0.81844... , 0.20681..., -0.04660..., -0.28790...]) >>> est.coef__interval() - (array([ 1.43299..., 0.06316..., -2.28671..., -2.01185..., -0.93582...]), - array([4.93805..., 3.60987..., 1.33227... , 1.43904..., 2.69114...])) + (array([ 3.68288..., -0.35434..., -0.98986..., -1.18770..., -1.48722...]), + array([6.00090..., 1.99122..., 1.40349..., 1.09449..., 0.91141...])) >>> est.intercept_ - 1.15151... + -0.12298... >>> est.intercept__interval() - (-0.60109..., 2.90411...) + (-1.28204..., 1.03607...) """ def __init__(self, *, @@ -2627,7 +2627,7 @@ def true_heterogeneity_function(X): est.fit(Y=y, T=T, Z=Z, X=X) >>> est.effect(X[:3]) - array([-4.29282..., 6.08590..., -2.11608...]) + array([-3.71724..., 6.39915..., -2.14545...]) """ def __init__(self, *, @@ -2921,19 +2921,19 @@ def true_heterogeneity_function(X): est.fit(Y=y, T=T, Z=Z, X=X) >>> est.effect(X[:3]) - array([-4.81123..., 5.65430..., -2.63204...]) + array([-4.05294..., 6.44603..., -2.49535...]) >>> est.effect_interval(X[:3]) - (array([-8.42669..., 0.36538... , -5.82840...]), - array([-1.19578... , 10.94323..., 0.56430...])) + (array([-8.42902..., 0.05595..., -6.34202...]), + array([ 0.32313..., 12.83612..., 1.35131...])) >>> est.coef_ - array([ 5.01936..., 0.71988..., 0.82603..., -0.08192... , -0.02520...]) + array([ 4.99132..., 0.35043..., 0.41963..., -0.63553..., -0.33972...]) >>> est.coef__interval() - (array([ 3.52057... , -0.72550..., -0.72653..., -1.50040... , -1.52896...]), - array([6.51816..., 2.16527..., 2.37861..., 1.33656..., 1.47854...])) + (array([ 3.11828..., -1.44768..., -1.46377..., -2.36080..., -2.18746...]), + array([6.86435..., 2.14856..., 2.30303..., 1.08973..., 1.50802...])) >>> est.intercept_ - -0.45176... + -0.25633... >>> est.intercept__interval() - (-1.93313..., 1.02959...) + (-2.07961..., 1.56695...) """ def __init__(self, *, diff --git a/econml/panel/dml/_dml.py b/econml/panel/dml/_dml.py index 97190639b..a9de0a4a1 100644 --- a/econml/panel/dml/_dml.py +++ b/econml/panel/dml/_dml.py @@ -434,33 +434,33 @@ class DynamicDML(LinearModelFinalCateEstimatorMixin, _OrthoLearner): est.fit(y, T, X=X, W=None, groups=groups, inference="auto") >>> est.const_marginal_effect(X[:2]) - array([[-0.336..., -0.048..., -0.061..., 0.042..., -0.204..., - 0.00667271], - [-0.101..., 0.433..., 0.054..., -0.217..., -0.101..., - -0.159...]]) + array([[-0.345..., -0.056..., -0.044..., 0.046..., -0.202..., + 0.023...], + [-0.120..., 0.434..., 0.052..., -0.201..., -0.115..., + -0.134...]]) >>> est.effect(X[:2], T0=0, T1=1) - array([-0.601..., -0.091...]) + array([-0.579..., -0.085...]) >>> est.effect(X[:2], T0=np.zeros((2, n_periods*T.shape[1])), T1=np.ones((2, n_periods*T.shape[1]))) - array([-0.601..., -0.091...]) + array([-0.579, -0.085...]) >>> est.coef_ - array([[ 0.112...], - [ 0.231...], - [ 0.055...], - [-0.125...], - [ 0.049...], - [-0.079...]]) + array([[ 0.108...], + [ 0.235...], + [ 0.046...], + [-0.119...], + [ 0.042...], + [-0.075...]]) >>> est.coef__interval() - (array([[-0.063...], - [-0.009...], - [-0.114...], - [-0.413...], - [-0.117...], - [-0.262...]]), array([[0.289...], - [0.471...], - [0.225...], - [0.163...], - [0.216...], - [0.103...]])) + (array([[-0.042...], + [-0.001...], + [-0.120...], + [-0.393...], + [-0.120...], + [-0.256...]]), array([[0.258...], + [0.473...], + [0.212...], + [0.154...], + [0.204...], + [0.104...]])) """ def __init__(self, *, From 9b456019c468c7f4e8ed9cba079e69fe7e0a83ca Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Sat, 11 Nov 2023 10:27:17 -0500 Subject: [PATCH 18/19] Fix doctests Signed-off-by: Keith Battocchi --- econml/dml/_rlearner.py | 2 +- econml/dml/causal_forest.py | 2 +- econml/dml/dml.py | 4 +-- econml/dr/_drlearner.py | 2 +- econml/iv/dml/_dml.py | 6 ++-- econml/iv/dr/_dr.py | 6 ++-- econml/panel/dml/_dml.py | 2 +- notebooks/Scaling EconML using Ray.ipynb | 36 +++++++++++------------- 8 files changed, 28 insertions(+), 32 deletions(-) diff --git a/econml/dml/_rlearner.py b/econml/dml/_rlearner.py index 99eb347a7..adec1fc3e 100644 --- a/econml/dml/_rlearner.py +++ b/econml/dml/_rlearner.py @@ -217,7 +217,7 @@ class ModelSelector(SingleModelSelector): def __init__(self, model): self._model = ModelFirst(model) def train(self, is_selecting, X, W, Y, sample_weight=None): - self._model.fit(np.hstack(X, W, Y) + self._model.fit(X, W, Y, sample_weight=sample_weight) return self @property def best_model(self): diff --git a/econml/dml/causal_forest.py b/econml/dml/causal_forest.py index 8b4555974..2672b3c8a 100644 --- a/econml/dml/causal_forest.py +++ b/econml/dml/causal_forest.py @@ -551,7 +551,7 @@ class CausalForestDML(_BaseDML): array([0.88518..., 1.25061..., 0.81112...]) >>> est.effect_interval(X[:3]) (array([0.40163..., 0.75023..., 0.46629...]), - array([1.36873..., 1.75099.., 1.15596...])) + array([1.36873..., 1.75099..., 1.15596...])) Attributes ---------- diff --git a/econml/dml/dml.py b/econml/dml/dml.py index 4852cb83f..be06d9b9e 100644 --- a/econml/dml/dml.py +++ b/econml/dml/dml.py @@ -474,7 +474,7 @@ class takes as input the parameter `model_t`, which is an arbitrary scikit-learn >>> est.coef_ array([ 0.32570..., -0.05311..., -0.03973..., 0.01598..., -0.11045...]) >>> est.coef__interval() - (array([ 0.13791..., -0.20081..., -0.17941..., -0.12073..., -0.25769...]), + (array([ 0.13791..., -0.20081..., -0.17941..., -0.12073..., -0.25769...]), array([0.51348..., 0.09458..., 0.09993..., 0.15269..., 0.03679...])) >>> est.intercept_ 1.02940... @@ -1204,7 +1204,7 @@ class KernelDML(DML): est.fit(y, T, X=X, W=None) >>> est.effect(X[:3]) - array([0.64124..., 1.46561..., 0.68568... ]) + array([0.64124..., 1.46561..., 0.68568...]) """ def __init__(self, model_y='auto', model_t='auto', diff --git a/econml/dr/_drlearner.py b/econml/dr/_drlearner.py index 4a026ff4f..fac6da9f7 100644 --- a/econml/dr/_drlearner.py +++ b/econml/dr/_drlearner.py @@ -355,7 +355,7 @@ class takes as input the parameter ``model_regressor``, which is an arbitrary sc >>> est.cate_feature_names() ['X0', 'X1', 'X2'] >>> [mdl.coef_ for mdls in est.models_regression for mdl in mdls] - [array([ 1.463..., 0.006..., -0.006..., 0.726..., 2.029...]), + [array([ 1.463..., 0.006..., -0.006..., 0.726..., 2.029...]), array([ 1.466..., -0.002..., 0..., 0.646..., 2.014...])] >>> [mdl.coef_ for mdls in est.models_propensity for mdl in mdls] [array([[-0.67903093, 0.04261741, -0.05969718], diff --git a/econml/iv/dml/_dml.py b/econml/iv/dml/_dml.py index 649293cde..b42925ff9 100644 --- a/econml/iv/dml/_dml.py +++ b/econml/iv/dml/_dml.py @@ -1113,11 +1113,11 @@ def true_heterogeneity_function(X): est.fit(Y=y, T=T, Z=Z, X=X) >>> est.effect(X[:3]) - array([-4.47392..., 5.74626..., -3.08471...]) + array([-6.83575..., 9.40666..., -4.27123...]) >>> est.coef_ - array([ 5.00993..., 0.86981..., 0.35110..., -0.11390... , -0.17933...]) + array([ 8.07179..., 1.51080..., 0.87328..., -0.06944..., -0.47404...]) >>> est.intercept_ - -0.27719... + -0.20555... """ diff --git a/econml/iv/dr/_dr.py b/econml/iv/dr/_dr.py index c2393cabf..a72272774 100644 --- a/econml/iv/dr/_dr.py +++ b/econml/iv/dr/_dr.py @@ -2154,10 +2154,10 @@ def true_heterogeneity_function(X): est.fit(Y=y, T=T, Z=Z, X=X) >>> est.effect(X[:3]) - array([-1.74672..., 1.57..., -1.58916...]) + array([-1.60489..., 5.40611..., -3.46904...]) >>> est.effect_interval(X[:3]) - (array([-7.05230..., -6..., -5.11344...]), - array([3.55885..., 9.9..., 1.93512...])) + (array([-5.37171..., 0.73055..., -7.15266...]), + array([ 2.16192..., 10.08168..., 0.21457...])) """ def __init__(self, *, diff --git a/econml/panel/dml/_dml.py b/econml/panel/dml/_dml.py index a9de0a4a1..8fe01b6d8 100644 --- a/econml/panel/dml/_dml.py +++ b/econml/panel/dml/_dml.py @@ -441,7 +441,7 @@ class DynamicDML(LinearModelFinalCateEstimatorMixin, _OrthoLearner): >>> est.effect(X[:2], T0=0, T1=1) array([-0.579..., -0.085...]) >>> est.effect(X[:2], T0=np.zeros((2, n_periods*T.shape[1])), T1=np.ones((2, n_periods*T.shape[1]))) - array([-0.579, -0.085...]) + array([-0.579..., -0.085...]) >>> est.coef_ array([[ 0.108...], [ 0.235...], diff --git a/notebooks/Scaling EconML using Ray.ipynb b/notebooks/Scaling EconML using Ray.ipynb index 49c9a44fa..217bd122a 100644 --- a/notebooks/Scaling EconML using Ray.ipynb +++ b/notebooks/Scaling EconML using Ray.ipynb @@ -35,11 +35,11 @@ "execution_count": 4, "id": "01b70101-d4ad-40fc-baa6-565795ee897a", "metadata": { - "tags": [], "ExecuteTime": { "end_time": "2023-08-16T18:32:09.629351Z", "start_time": "2023-08-16T18:32:09.627091Z" - } + }, + "tags": [] }, "outputs": [], "source": [ @@ -47,9 +47,8 @@ "import os\n", "import numpy as np\n", "import scipy\n", - "from econml.dml import DML\n", + "from econml.dml import LinearDML\n", "from sklearn.ensemble import RandomForestRegressor, RandomForestClassifier\n", - "from econml.sklearn_extensions.linear_model import StatsModelsLinearRegression\n", "import warnings\n", "warnings.filterwarnings(\"ignore\")" ] @@ -125,9 +124,10 @@ "outputs": [], "source": [ "np.random.seed(123)\n", - "X = np.random.normal(size=(10000, 5))\n", + "n = 5000\n", + "X = np.random.normal(size=(n, 5))\n", "T = np.random.binomial(1, scipy.special.expit(X[:, 0]))\n", - "y = (1 + .5*X[:, 0]) * T + X[:, 0] + np.random.normal(size=(10000,))" + "y = (1 + .5*X[:, 0]) * T + X[:, 0] + np.random.normal(size=(n,))" ] }, { @@ -171,11 +171,9 @@ "\n", "ray_opts = {'num_cpus':2,'scheduling_strategy':'SPREAD'}\n", "\n", - "est = DML(\n", + "est = LinearDML(\n", " model_y=RandomForestRegressor(random_state=0),\n", " model_t=RandomForestClassifier(random_state=0),\n", - " model_final=StatsModelsLinearRegression(fit_intercept=False),\n", - " linear_first_stages=False,\n", " discrete_treatment=True,\n", " use_ray=True, #setting use_ray flag to True to use ray.\n", " ray_remote_func_options=ray_opts,\n", @@ -217,15 +215,13 @@ " runtimes = []\n", " for cv in cv_values:\n", " ray_opts = {'num_cpus': 2, 'scheduling_strategy': 'SPREAD'} if use_ray else None\n", - " est = DML(model_y=RandomForestRegressor(random_state=0),\n", - " model_t=RandomForestClassifier(random_state=0),\n", - " model_final=StatsModelsLinearRegression(fit_intercept=False),\n", - " linear_first_stages=False,\n", - " discrete_treatment=True,\n", - " use_ray=use_ray,\n", - " ray_remote_func_options=ray_opts,\n", - " cv=cv,\n", - " mc_iters=1)\n", + " est = LinearDML(model_y=RandomForestRegressor(random_state=0),\n", + " model_t=RandomForestClassifier(random_state=0),\n", + " discrete_treatment=True,\n", + " use_ray=use_ray,\n", + " ray_remote_func_options=ray_opts,\n", + " cv=cv,\n", + " mc_iters=1)\n", " \n", " start_time = time.time()\n", " est.fit(y, T, X=X, W=None)\n", @@ -296,9 +292,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.8" + "version": "3.10.11" } }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file From 4618ffa8fbcffc9c7d5ef914ebcd581ff0ca2705 Mon Sep 17 00:00:00 2001 From: Keith Battocchi Date: Sat, 11 Nov 2023 12:33:16 -0500 Subject: [PATCH 19/19] Fix doctests Signed-off-by: Keith Battocchi --- econml/dml/_rlearner.py | 4 ++-- econml/dr/_drlearner.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/econml/dml/_rlearner.py b/econml/dml/_rlearner.py index adec1fc3e..a020b432d 100644 --- a/econml/dml/_rlearner.py +++ b/econml/dml/_rlearner.py @@ -258,9 +258,9 @@ def _gen_rlearner_model_final(self): array([0.999631...]) >>> est.score_ 9.82623204...e-05 - >>> [mdl.best_model._model for mdls in est.models_y for mdl in mdls] + >>> [mdl._model for mdls in est.models_y for mdl in mdls] [LinearRegression(), LinearRegression()] - >>> [mdl.best_model._model for mdls in est.models_t for mdl in mdls] + >>> [mdl._model for mdls in est.models_t for mdl in mdls] [LinearRegression(), LinearRegression()] Attributes diff --git a/econml/dr/_drlearner.py b/econml/dr/_drlearner.py index fac6da9f7..749eec5d9 100644 --- a/econml/dr/_drlearner.py +++ b/econml/dr/_drlearner.py @@ -868,7 +868,7 @@ class LinearDRLearner(StatsModelsCateEstimatorDiscreteMixin, DRLearner): >>> est.effect_interval(X[:3]) (array([ 0.164623..., -0.098980..., -0.493464...]), array([0.750582..., 0.77039... , 0.516041...])) >>> est.coef_(T=1) - array([ 0.338061..., 0.025654..., 0.044389...]) + array([0.338061..., 0.025654..., 0.044389...]) >>> est.coef__interval(T=1) (array([ 0.135677..., -0.155845..., -0.143376...]), array([0.540446..., 0.207155..., 0.232155...])) >>> est.intercept_(T=1)