From 51fec6ae03bfa5a2181c3faa74d1f23a49ea7ce4 Mon Sep 17 00:00:00 2001 From: Thomas Pinder Date: Thu, 22 Aug 2024 20:12:47 +0200 Subject: [PATCH 1/3] Initial commit --- README.md | 65 ++++++- examples/azcausal.pct.py | 117 ++++++++++++ examples/basic.pct.py | 169 +++++++++++++++++ pyproject.toml | 154 +++++++++++++++ src/causal_validation/__about__.py | 3 + src/causal_validation/__init__.py | 4 + src/causal_validation/base.py | 6 + src/causal_validation/config.py | 33 ++++ src/causal_validation/data.py | 130 +++++++++++++ src/causal_validation/effects.py | 67 +++++++ src/causal_validation/plotters.py | 49 +++++ src/causal_validation/py.typed | 1 + src/causal_validation/simulate.py | 37 ++++ src/causal_validation/testing.py | 33 ++++ src/causal_validation/transforms/__init__.py | 4 + src/causal_validation/transforms/base.py | 94 +++++++++ src/causal_validation/transforms/parameter.py | 63 +++++++ src/causal_validation/transforms/periodic.py | 35 ++++ src/causal_validation/transforms/trends.py | 26 +++ src/causal_validation/types.py | 11 ++ src/causal_validation/weights.py | 52 +++++ static/fig_creation.py | 38 ++++ static/readme_fig.png | Bin 0 -> 98314 bytes static/style.mplstyle | 52 +++++ tests/__init__.py | 0 tests/conftest.py | 6 + tests/test_causal_validation/README.md | 29 +++ tests/test_causal_validation/__init__.py | 0 .../test_amzn_synthetic_causal_data_gen.py | 2 + tests/test_causal_validation/test_base.py | 0 tests/test_causal_validation/test_data.py | 168 +++++++++++++++++ tests/test_causal_validation/test_effect.py | 58 ++++++ .../test_integration.py | 37 ++++ tests/test_causal_validation/test_plotters.py | 55 ++++++ .../test_transforms/test_periodic.py | 178 ++++++++++++++++++ .../test_transforms/test_trends.py | 122 ++++++++++++ tests/test_causal_validation/test_weights.py | 32 ++++ 37 files changed, 1921 insertions(+), 9 deletions(-) create mode 100644 examples/azcausal.pct.py create mode 100644 examples/basic.pct.py create mode 100644 pyproject.toml create mode 100644 src/causal_validation/__about__.py create mode 100644 src/causal_validation/__init__.py create mode 100644 src/causal_validation/base.py create mode 100644 src/causal_validation/config.py create mode 100644 src/causal_validation/data.py create mode 100644 src/causal_validation/effects.py create mode 100644 src/causal_validation/plotters.py create mode 100644 src/causal_validation/py.typed create mode 100644 src/causal_validation/simulate.py create mode 100644 src/causal_validation/testing.py create mode 100644 src/causal_validation/transforms/__init__.py create mode 100644 src/causal_validation/transforms/base.py create mode 100644 src/causal_validation/transforms/parameter.py create mode 100644 src/causal_validation/transforms/periodic.py create mode 100644 src/causal_validation/transforms/trends.py create mode 100644 src/causal_validation/types.py create mode 100644 src/causal_validation/weights.py create mode 100644 static/fig_creation.py create mode 100644 static/readme_fig.png create mode 100644 static/style.mplstyle create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_causal_validation/README.md create mode 100644 tests/test_causal_validation/__init__.py create mode 100644 tests/test_causal_validation/test_amzn_synthetic_causal_data_gen.py create mode 100644 tests/test_causal_validation/test_base.py create mode 100644 tests/test_causal_validation/test_data.py create mode 100644 tests/test_causal_validation/test_effect.py create mode 100644 tests/test_causal_validation/test_integration.py create mode 100644 tests/test_causal_validation/test_plotters.py create mode 100644 tests/test_causal_validation/test_transforms/test_periodic.py create mode 100644 tests/test_causal_validation/test_transforms/test_trends.py create mode 100644 tests/test_causal_validation/test_weights.py diff --git a/README.md b/README.md index 847260c..c6ff01a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,64 @@ -## My Project +# SyntheticCausalDataGen -TODO: Fill this README out! +This package provides functionality to define your own causal data generation process and then simulate data from the process. Within the package, there is functionality to include complex components to your process, such as periodic and temporal trends, and all of these operations are fully composable with one another. -Be sure to: +A short example is given below +```python +from causal_validation import Config, simulate +from causal_validation.effects import StaticEffect +from causal_validation.plotters import plot +from causal_validation.transforms import Trend, Periodic +from causal_validation.transforms.parameter import UnitVaryingParameter +from scipy.stats import norm -* Change the title in this README -* Edit your repository description on GitHub +cfg = Config( + n_control_units=10, + n_pre_intervention_timepoints=60, + n_post_intervention_timepoints=30, +) -## Security +# Simulate the base observation +base_data = simulate(cfg) -See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. +# Apply a linear trend with unit-varying intercept +intercept = UnitVaryingParameter(sampling_dist = norm(0, 1)) +trend_component = Trend(degree=1, coefficient=0.1, intercept=intercept) +trended_data = trend_component(base_data) -## License +# Simulate a 5% lift in the treated unit's post-intervention data +effect = StaticEffect(0.05) +inflated_data = effect(trended_data) -This project is licensed under the Apache-2.0 License. +# Plot your data +plot(inflated_data) +``` + +## Examples + +To supplement the above example, we have two more detailed notebooks which exhaustively present and explain the functionalty in this package, along with how the generated data may be integrated with [AZCausal](https://github.com/amazon-science/azcausal). +1. [Basic notebook](): We here show the full range of available functions for data generation +2. [AZCausal notebook](): We here show how the generated data may be used within an AZCausal model. + +## Installation + +In this section we guide the user through the installation of this package. We distinguish here between _users_ of the package who seek to define their own data generating processes, and _developers_ who wish to extend the existing functionality of the package. + +### Prerequisites + +- Python 3.10 or higher +- [Poetry](https://python-poetry.org/) (optional, but recommended) + +### For Users + +1. It's strongly recommended to use a virtual environment. Create and activate one using your preferred method before proceeding with the installation. +2. Clone the package `git clone git@github.com:amazon-science/causal-validation.git` +3. Enter the package's root directory `cd SyntheticCausalDataGen` +4. Install the package `pip install -e .` + +### For Developers + +1. Follow steps 1-3 from `For Users` +2. Create a hatch environment `hatch env create` +3. Open a hatch shell `hatch shell` +4. Validate your installation by running `hatch run tests:test` diff --git a/examples/azcausal.pct.py b/examples/azcausal.pct.py new file mode 100644 index 0000000..a939358 --- /dev/null +++ b/examples/azcausal.pct.py @@ -0,0 +1,117 @@ +# %% +from azcausal.estimators.panel.sdid import SDID +import scipy.stats as st + +from causal_validation import ( + Config, + simulate, +) +from causal_validation.effects import StaticEffect +from causal_validation.plotters import plot +from causal_validation.transforms import ( + Periodic, + Trend, +) +from causal_validation.transforms.parameter import UnitVaryingParameter + +# %% [markdown] +# ## AZCausal Integration +# +# Amazon's [AZCausal](https://github.com/amazon-science/azcausal) library provides the +# functionality to fit synthetic control and difference-in-difference models to your +# data. Integrating the synthetic data generating process of `causal_validation` with +# AZCausal is trivial, as we show in this notebook. To start, we'll simulate a toy +# dataset. + +# %% +cfg = Config( + n_control_units=10, + n_pre_intervention_timepoints=60, + n_post_intervention_timepoints=30, + seed=123, +) + +linear_trend = Trend(degree=1, coefficient=0.05) +data = linear_trend(simulate(cfg)) +plot(data) + +# %% [markdown] We'll now simulate a 5% lift in the treatment group's observations. This +# will inflate the treated group's observations in the post-intervention window. + +# %% +TRUE_EFFECT = 0.05 +effect = StaticEffect(effect=TRUE_EFFECT) +inflated_data = effect(data) +plot(inflated_data) + +# %% [markdown] +# ### Fitting a model +# +# We now have some very toy data on which we may apply a model. For this demonstration +# we shall use the Synthetic Difference-in-Differences model implemented in AZCausal; +# however, the approach shown here will work for any model implemented in AZCausal. To +# achieve this, we must first coerce the data into a format that is digestible for +# AZCausal. Through the `.to_azcausal()` method implemented here, this is +# straightforward to achieve. Once we have a AZCausal compatible dataset, the modelling +# is very simple by virtue of the clean design of AZCausal. + +# %% +panel = inflated_data.to_azcausal() +model = SDID() +result = model.fit(panel) +print(f"Delta: {TRUE_EFFECT - result.effect.percentage().value / 100}") +print(result.summary(title="Synthetic Data Experiment")) + +# %% [markdown] +# We see that SDID has done an excellent job of estimating the treatment effect. +# However, given the simplicity of the data, this is not surprising. With the +# functionality within this package though we can easily construct more complex datasets +# in effort to fully stress-test any new model and identify its limitations. +# +# To achieve this, we'll simulate 10 control units, 60 pre-intervention time points, and +# 30 post-intervention time points according to the following process: +# $$ +# \begin{align} +# \mu_{n, t} & \sim\mathcal{N}(20, 0.5^2)\\ +# \alpha_{n} & \sim \mathcal{N}(0, 1^2)\\ +# \beta_{n} & \sim \mathcal{N}(0.05, 0.01^2)\\ +# \nu_n & \sim \mathcal{N}(1, 1^2)\\ +# \gamma_n & \sim \operatorname{Student-t}_{10}(1, 1^2)\\ +# \mathbf{Y}_{n, t} & = \mu_{n, t} + \alpha_{n} + \beta_{n}t + \nu_n\sin\left(3\times 2\pi t + \gamma\right) + \delta_{t, n} +# \end{align} +# $$ +# where the true treatment effect $\delta_{t, n}$ is 5% when $n=1$ and $t\geq 60$ and 0 +# otherwise. Meanwhile, $\mathbf{Y}$ is the matrix of observations, long in the number +# of time points and wide in the number of units. + +# %% +cfg = Config( + n_control_units=10, + n_pre_intervention_timepoints=60, + n_post_intervention_timepoints=30, + global_mean=20, + global_scale=1, + seed=123, +) + +intercept = UnitVaryingParameter(sampling_dist=st.norm(loc=0.0, scale=1)) +coefficient = UnitVaryingParameter(sampling_dist=st.norm(loc=0.05, scale=0.01)) +linear_trend = Trend(degree=1, coefficient=coefficient, intercept=intercept) + +amplitude = UnitVaryingParameter(sampling_dist=st.norm(loc=1.0, scale=2)) +shift = UnitVaryingParameter(sampling_dist=st.t(df=10)) +periodic = Periodic(amplitude=amplitude, shift=shift, frequency=3) + +data = effect(periodic(linear_trend(simulate(cfg)))) +plot(data) + +# %% [markdown] As before, we may now go about estimating the treatment. However, this +# time we see that the delta between the estaimted and true effect is much larger than +# before. + +# %% +panel = data.to_azcausal() +model = SDID() +result = model.fit(panel) +print(f"Delta: {100*(TRUE_EFFECT - result.effect.percentage().value / 100): .2f}%") +print(result.summary(title="Synthetic Data Experiment")) diff --git a/examples/basic.pct.py b/examples/basic.pct.py new file mode 100644 index 0000000..4df01aa --- /dev/null +++ b/examples/basic.pct.py @@ -0,0 +1,169 @@ +# %% +from itertools import product + +import matplotlib.pyplot as plt +from scipy.stats import ( + norm, + poisson, +) + +from causal_validation import ( + Config, + simulate, +) +from causal_validation.effects import StaticEffect +from causal_validation.plotters import plot +from causal_validation.transforms import ( + Periodic, + Trend, +) +from causal_validation.transforms.parameter import UnitVaryingParameter + +# %% [markdown] +# ## Simulating a Dataset + +# %% [markdown] Simulating a dataset is as simple as specifying a `Config` object and +# then invoking the `simulate` function. Once simulated, we may visualise the data +# through the `plot` function. + +# %% +cfg = Config( + n_control_units=10, + n_pre_intervention_timepoints=60, + n_post_intervention_timepoints=30, + seed=123, +) + +data = simulate(cfg) +plot(data) + +# %% [markdown] +# ### Controlling baseline behaviour +# +# We observe that we have 10 control units, each of which were sampled from a Gaussian +# distribution with mean 20 and scale 0.2. Had we wished for our underlying observations +# to have more or less noise, or to have a different global mean, then we can simply +# specify that through the config file. + +# %% +means = [10, 50] +scales = [0.1, 0.5] + +fig, axes = plt.subplots(ncols=2, nrows=2, figsize=(10, 6), tight_layout=True) +for (m, s), ax in zip(product(means, scales), axes.ravel()): + cfg = Config( + n_control_units=10, + n_pre_intervention_timepoints=60, + n_post_intervention_timepoints=30, + global_mean=m, + global_scale=s, + ) + data = simulate(cfg) + plot(data, ax=ax, title=f"Mean: {m}, Scale: {s}") + +# %% [markdown] +# ### Reproducibility +# +# In the above four panels, we can see that whilst the mean and scale of the underlying +# data generating process is varying, the functional form of the data is the same. This +# is by design to ensure that data sampling is reproducible. To sample a new dataset, +# you may either change the underlying seed in the config file. + +# %% +cfg = Config( + n_control_units=10, + n_pre_intervention_timepoints=60, + n_post_intervention_timepoints=30, + seed=42, +) + +# %% [markdown] +# Reusing the same config file across simulations + +# %% +fig, axes = plt.subplots(ncols=2, figsize=(10, 3)) +for ax in axes: + data = simulate(cfg) + plot(data, ax=ax) + +# %% [markdown] +# Or manually specifying and passing your own pseudorandom number generator key + +# %% +import numpy as np + +rng = np.random.RandomState(42) + +fig, axes = plt.subplots(ncols=2, figsize=(10, 3)) +for ax in axes: + data = simulate(cfg, key=rng) + plot(data, ax=ax) + +# %% [markdown] +# ### Simulating an effect +# +# In the data we have seen up until now, the treated unit has been drawn from the same +# data generating process as the control units. However, it can be helpful to also +# inflate the treated unit to observe how well our model can recover the the true +# treatment effect. To do this, we simply compose our dataset with an `Effect` object. +# In the below, we shall inflate our data by 2%. + +# %% +effect = StaticEffect(effect=0.02) +inflated_data = effect(data) +fig, (ax0, ax1) = plt.subplots(ncols=2, figsize=(10, 3)) +plot(data, ax=ax0, title="Original data") +plot(inflated_data, ax=ax1, title="Inflated data") + +# %% [markdown] +# ### More complex generation processes +# +# The example presented above shows a very simple stationary data generation process. +# However, we may make our example more complex by including a non-stationary trend to +# the data. + +# %% +trend_term = Trend(degree=1, coefficient=0.1) +data_with_trend = effect(trend_term(data)) +plot(data_with_trend) + +# %% +trend_term = Trend(degree=2, coefficient=0.0025) +data_with_trend = effect(trend_term(data)) +plot(data_with_trend) + +# %% [markdown] +# We may also include periodic components in our data + +# %% +periodicity = Periodic(amplitude=2, frequency=6) +perioidic_data = effect(periodicity(trend_term(data))) +plot(perioidic_data) + +# %% [markdown] +# ### Unit-level parameterisation + +# %% +sampling_dist = norm(0.0, 1.0) +intercept = UnitVaryingParameter(sampling_dist=sampling_dist) +trend_term = Trend(degree=1, intercept=intercept, coefficient=0.1) +data_with_trend = effect(trend_term(data)) +plot(data_with_trend) + +# %% +sampling_dist = poisson(2) +frequency = UnitVaryingParameter(sampling_dist=sampling_dist) + +p = Periodic(frequency=frequency) +plot(p(data)) + +# %% [markdown] +# ## Conclusions +# +# In this notebook we have shown how one can define their model's true underlying data +# generating process, starting from simple white-noise samples through to more complex +# example with periodic and temporal components, perhaps containing unit-level +# variation. In a follow-up notebook, we show how these datasets may be integrated with +# Amazon's own AZCausal library to compare the effect estimated by a model with the true +# effect of the underlying data generating process. A link to this notebook is +# [here](PLACEHOLDER). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..00e5423 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,154 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "causal_validation" +dynamic = ["version"] +description = 'A validation framework for causal models.' +readme = "README.md" +requires-python = ">=3.10,<4.0" +license = "MIT" +keywords = [ + "synthetic data", "causal model", "machine learning" +] +authors = [ + { name = "Thomas Pinder", email = "pinthoma@amazon.nl" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [ + "azcausal", + "beartype", + "jaxtyping", + "matplotlib", + "numpy", + "pandas", +] + +[tool.hatch.build] +include = ["src/causal_validation"] +packages = ["src/causal_validation"] + +[tool.hatch.envs.tests] +dependencies = [ + "mypy", + "black", + "isort", + "pytest", + "pytest-xdist", + "pytest-cov", + "pytest-sugar", + "coverage", + "autoflake", + "ruff", + "hypothesis", + "pre-commit", + "absolufy-imports", + ] + +[tool.hatch.envs.dev] +dependencies = [ + "ipykernel", + "ipython", + "jupytext", + ] + +[tool.hatch.envs.tests.scripts] +test = "pytest --hypothesis-profile causal_validation" +ptest = "pytest -n auto . --hypothesis-profile causal_validation" +format = [ + "isort src tests", + "black src tests", + "ruff format src tests" +] + +[tool.hatch.envs.dev.scripts] +build_nbs = [ + "jupytext --to notebook examples/*.pct.py", + "mv examples/*.ipynb nbs" +] + +[tool.hatch.version] +path = "src/causal_validation/__about__.py" + +[tool.coverage.run] +source_pkgs = ["causal_validation", "tests"] +branch = true +parallel = true +omit = [ + "src/causal_validation/__about__.py", +] + +[tool.coverage.paths] +causal_validation = ["src/causal_validation", "*/causal_validation/src/causal_validation"] +tests = ["tests", "*/causal_validation/tests"] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +[tool.black] +line-length = 88 +target-version = ["py310"] + + +[tool.isort] +profile = "black" +line_length = 88 +known_first_party = [ "causal_validation" ] +combine_as_imports = true +force_sort_within_sections = true +force_grid_wrap = 2 + +[tool.pytest.ini_options] +addopts = [ + "--durations=5", + "--color=yes", + "--cov=causal_validation" +] +testpaths = [ "test" ] +looponfailroots = [ + "src", + "test", +] + +[tool.ruff] +fix = true +cache-dir = "~/.cache/ruff" +line-length = 88 +src = ["src", "test"] +target-version = "py310" + +[tool.ruff.lint] +dummy-variable-rgx = "^_$" +select = [ + "F", + "E", + "W", + "YTT", + "B", + "Q", + "PLE", + "PLR", + "PLW", + "PIE", + "PYI", + "TID", + "ISC", +] +ignore = ["F722"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" diff --git a/src/causal_validation/__about__.py b/src/causal_validation/__about__.py new file mode 100644 index 0000000..8d885d2 --- /dev/null +++ b/src/causal_validation/__about__.py @@ -0,0 +1,3 @@ +__version__ = "0.0.1" + +__all__ = ["__version__"] diff --git a/src/causal_validation/__init__.py b/src/causal_validation/__init__.py new file mode 100644 index 0000000..9ae3c8c --- /dev/null +++ b/src/causal_validation/__init__.py @@ -0,0 +1,4 @@ +from causal_validation.config import Config +from causal_validation.simulate import simulate + +__all__ = ["Config", "simulate"] diff --git a/src/causal_validation/base.py b/src/causal_validation/base.py new file mode 100644 index 0000000..68031dd --- /dev/null +++ b/src/causal_validation/base.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass +class BaseObject: + name: str = "Abstract Object" diff --git a/src/causal_validation/config.py b/src/causal_validation/config.py new file mode 100644 index 0000000..901b0f2 --- /dev/null +++ b/src/causal_validation/config.py @@ -0,0 +1,33 @@ +from dataclasses import ( + dataclass, + field, +) +import datetime as dt +import typing as tp + +import numpy as np + +from causal_validation.weights import UniformWeights + +if tp.TYPE_CHECKING: + from causal_validation.types import WeightTypes + + +@dataclass(kw_only=True, frozen=True) +class WeightConfig: + weight_type: "WeightTypes" = field(default_factory=UniformWeights) + + +@dataclass(kw_only=True) +class Config: + n_control_units: int + n_pre_intervention_timepoints: int + n_post_intervention_timepoints: int + global_mean: float = 20.0 + global_scale: float = 0.2 + start_date: dt.date = dt.date(year=2023, month=1, day=1) + seed: int = 123 + weights_cfg: WeightConfig = field(default_factory=WeightConfig) + + def __post_init__(self): + self.rng = np.random.RandomState(self.seed) diff --git a/src/causal_validation/data.py b/src/causal_validation/data.py new file mode 100644 index 0000000..202cb63 --- /dev/null +++ b/src/causal_validation/data.py @@ -0,0 +1,130 @@ +from copy import deepcopy +from dataclasses import dataclass +import datetime as dt +import typing as tp + +from azcausal.core.panel import CausalPanel +from azcausal.util import to_panels +from jaxtyping import ( + Float, + Integer, +) +import numpy as np +import pandas as pd +from pandas._libs.tslibs.timestamps import Timestamp +from pandas.core.indexes.datetimes import DatetimeIndex + +from causal_validation.types import InterventionTypes + + +@dataclass(frozen=True) +class Dataset: + Xtr: Float[np.ndarray, "N D"] + Xte: Float[np.ndarray, "M D"] + ytr: Float[np.ndarray, "N 1"] + yte: Float[np.ndarray, "M 1"] + _start_date: dt.date + counterfactual: tp.Optional[Float[np.ndarray, "M 1"]] = None + + def to_df(self, index_start: str = "2023-01-01") -> pd.DataFrame: + inputs = np.vstack([self.Xtr, self.Xte]) + outputs = np.vstack([self.ytr, self.yte]) + data = np.hstack([outputs, inputs]) + index = self._get_index(index_start) + colnames = self._get_columns() + indicator = self._get_indicator() + df = pd.DataFrame(data, index=index, columns=colnames) + df = df.assign(treated=indicator) + return df + + @property + def n_post_intervention(self) -> int: + return self.Xte.shape[0] + + @property + def n_pre_intervention(self) -> int: + return self.Xtr.shape[0] + + @property + def n_units(self) -> int: + return self.Xtr.shape[1] + + @property + def n_timepoints(self) -> int: + return self.n_post_intervention + self.n_pre_intervention + + @property + def control_units(self) -> Float[np.ndarray, "N+M 1"]: + return np.vstack([self.Xtr, self.Xte]) + + @property + def treated_units(self) -> Float[np.ndarray, "N+M 1"]: + return np.vstack([self.ytr, self.yte]) + + @property + def pre_intervention_obs( + self, + ) -> tp.Tuple[Float[np.ndarray, "N D"], Float[np.ndarray, "N 1"]]: + return self.Xtr, self.ytr + + @property + def post_intervention_obs( + self, + ) -> tp.Tuple[Float[np.ndarray, "M D"], Float[np.ndarray, "M 1"]]: + return self.Xte, self.yte + + @property + def full_index(self) -> DatetimeIndex: + return self._get_index(self._start_date) + + @property + def treatment_date(self) -> Timestamp: + idxs = self.full_index + return idxs[self.n_pre_intervention] + + def get_index(self, period: InterventionTypes) -> DatetimeIndex: + if period == "pre-intervention": + return self.full_index[: self.n_pre_intervention] + elif period == "post-intervention": + return self.full_index[self.n_pre_intervention :] + else: + return self.full_index + + def _get_columns(self) -> tp.List[str]: + colnames = ["T"] + [f"C{i}" for i in range(self.n_units)] + return colnames + + def _get_index(self, start_date: str) -> pd.Series: + return pd.date_range(start=start_date, freq="D", periods=self.n_timepoints) + + def _get_indicator(self) -> Integer[np.ndarray, "N 1"]: + indicator = np.vstack( + [ + np.zeros(shape=(self.n_pre_intervention, 1)), + np.ones(shape=(self.n_post_intervention, 1)), + ] + ) + return indicator + + def inflate(self, inflation_vals: Float[np.ndarray, "M 1"]) -> "Dataset": + Xtr, ytr = [deepcopy(i) for i in self.pre_intervention_obs] + Xte, yte = [deepcopy(i) for i in self.post_intervention_obs] + inflated_yte = yte * inflation_vals + return Dataset(Xtr, Xte, ytr, inflated_yte, self._start_date, yte) + + def to_azcausal(self): + time_index = np.arange(self.n_timepoints) + data = self.to_df().assign(time=time_index).melt(id_vars=["time", "treated"]) + data.loc[:, "treated"] = np.where( + (data["variable"] == "T") & (data["treated"] == 1.0), 1, 0 + ) + panels = to_panels(data, "time", "variable", ["value", "treated"]) + ctypes = dict( + outcome="value", time="time", unit="variable", intervention="treated" + ) + panel = CausalPanel(panels).setup(**ctypes) + return panel + + @property + def _slots(self) -> tp.Dict[str, int]: + return {"n_units": self.n_units + 1, "n_timepoints": self.n_timepoints} diff --git a/src/causal_validation/effects.py b/src/causal_validation/effects.py new file mode 100644 index 0000000..accf3b5 --- /dev/null +++ b/src/causal_validation/effects.py @@ -0,0 +1,67 @@ +from dataclasses import dataclass +import typing as tp + +from jaxtyping import Float +import numpy as np + +from causal_validation.base import BaseObject +from causal_validation.data import Dataset + +if tp.TYPE_CHECKING: + from causal_validation.config import EffectConfig + + +@dataclass +class AbstractEffect(BaseObject): + name: str = "Abstract Effect" + + def get_effect(self, data: Dataset, **kwargs) -> Float[np.ndarray, "N 1"]: + raise NotImplementedError("Please implement `get_effect` in all subclasses.") + + def __call__(self, data: Dataset, **kwargs) -> Dataset: + inflation_vals = self.get_effect(data) + return data.inflate(inflation_vals) + + +@dataclass +class _StaticEffect: + effect: float + + +@dataclass +class _RandomEffect: + mean_effect: float + stddev_effect: float + + +@dataclass +class StaticEffect(AbstractEffect, _StaticEffect): + effect: float + name = "Static Effect" + + def get_effect(self, data: Dataset, **kwargs) -> Float[np.ndarray, "N 1"]: + n_post_intervention = data.n_post_intervention + return np.repeat(1.0 + self.effect, repeats=n_post_intervention)[:, None] + + +@dataclass +class RandomEffect(AbstractEffect, _RandomEffect): + mean_effect: float + stddev_effect: float + name: str = "Random Effect" + + def get_effect( + self, data: Dataset, key: np.random.RandomState + ) -> Float[np.ndarray, "N 1"]: + n_post_intervention = data.n_post_intervention + effect_sample = key.normal( + loc=1.0 + self.mean_effect, + scale=self.stddev_effect, + size=(n_post_intervention, 1), + ) + return effect_sample + + +# Placeholder for now. +def resolve_effect(cfg: "EffectConfig") -> AbstractEffect: + return StaticEffect(effect=cfg.effect) diff --git a/src/causal_validation/plotters.py b/src/causal_validation/plotters.py new file mode 100644 index 0000000..be5d91b --- /dev/null +++ b/src/causal_validation/plotters.py @@ -0,0 +1,49 @@ +import typing as tp + +import matplotlib as mpl +from matplotlib.axes._axes import Axes +import matplotlib.dates as mdates +import matplotlib.pyplot as plt + +from causal_validation.data import Dataset + + +def clean_legend(ax: Axes) -> Axes: + """Remove duplicate legend entries from a plot. + + Args: + ax (Axes): The matplotlib axes containing the legend to be formatted. + + Returns: + Axes: The cleaned matplotlib axes. + """ + handles, labels = ax.get_legend_handles_labels() + by_label = dict(zip(labels, handles, strict=False)) + ax.legend(by_label.values(), by_label.keys(), loc="best") + return ax + + +def plot( + data: Dataset, + ax: tp.Optional[Axes] = None, + title: tp.Optional[str] = None, +) -> Axes: + cols = mpl.rcParams["axes.prop_cycle"].by_key()["color"] + X = data.control_units + y = data.treated_units + idx = data.full_index + treatment_date = data.treatment_date + + if ax is None: + _, ax = plt.subplots(figsize=(6, 3), tight_layout=True) + ax.plot(idx, X, color=cols[0], label="Control", alpha=0.5) + ax.plot(idx, y, color=cols[1], label="Treated") + ax.axvline(x=treatment_date, color=cols[2], label="Intervention", linestyle="--") + ax.xaxis.set_major_formatter( + mdates.ConciseDateFormatter(ax.xaxis.get_major_locator()) + ) + clean_legend(ax) + ax.spines["right"].set_visible(False) + ax.spines["top"].set_visible(False) + ax.set(xlabel="Time", ylabel="Observed", title=title) + return ax diff --git a/src/causal_validation/py.typed b/src/causal_validation/py.typed new file mode 100644 index 0000000..7ef2116 --- /dev/null +++ b/src/causal_validation/py.typed @@ -0,0 +1 @@ +# Marker file that indicates this package supports typing diff --git a/src/causal_validation/simulate.py b/src/causal_validation/simulate.py new file mode 100644 index 0000000..2f02c10 --- /dev/null +++ b/src/causal_validation/simulate.py @@ -0,0 +1,37 @@ +import typing as tp + +import numpy as np + +from causal_validation.config import Config +from causal_validation.data import Dataset +from causal_validation.weights import ( + AbstractWeights, + UniformWeights, +) + + +def simulate(config: Config, key: tp.Optional[np.random.RandomState] = None) -> Dataset: + if key is None: + key = config.rng + weights = UniformWeights() + + base_data = _simulate_base_obs(config, weights, key) + return base_data + + +def _simulate_base_obs( + config: Config, weights: AbstractWeights, key: np.random.RandomState +) -> Dataset: + n_timepoints = ( + config.n_pre_intervention_timepoints + config.n_post_intervention_timepoints + ) + n_units = config.n_control_units + obs = key.normal( + loc=config.global_mean, scale=config.global_scale, size=(n_timepoints, n_units) + ) + Xtr = obs[: config.n_pre_intervention_timepoints, :] + Xte = obs[config.n_pre_intervention_timepoints :, :] + ytr = weights.weight_obs(Xtr) + yte = weights.weight_obs(Xte) + data = Dataset(Xtr, Xte, ytr, yte, _start_date=config.start_date) + return data diff --git a/src/causal_validation/testing.py b/src/causal_validation/testing.py new file mode 100644 index 0000000..a1bcb22 --- /dev/null +++ b/src/causal_validation/testing.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +import typing as tp + +from causal_validation.config import Config +from causal_validation.data import Dataset +from causal_validation.simulate import simulate + + +@dataclass(frozen=True, kw_only=True) +class TestConstants: + N_CONTROL: int = 10 + N_PRE_TREATMENT: int = 500 + N_POST_TREATMENT: int = 500 + DATA_SLOTS: tp.Tuple[str, str, str, str] = ("Xtr", "Xte", "ytr", "yte") + ZERO_DIVISION_ERROR: float = 1e-6 + GLOBAL_SCALE: float = 1.0 + __test__: bool = False + + +def simulate_data( + global_mean: float, seed: int, constants: tp.Optional[TestConstants] = None +) -> Dataset: + if not constants: + constants = TestConstants() + cfg = Config( + n_control_units=constants.N_CONTROL, + n_pre_intervention_timepoints=constants.N_PRE_TREATMENT, + n_post_intervention_timepoints=constants.N_POST_TREATMENT, + global_mean=global_mean, + global_scale=constants.GLOBAL_SCALE, + seed=seed, + ) + return simulate(config=cfg) diff --git a/src/causal_validation/transforms/__init__.py b/src/causal_validation/transforms/__init__.py new file mode 100644 index 0000000..2707bfc --- /dev/null +++ b/src/causal_validation/transforms/__init__.py @@ -0,0 +1,4 @@ +from causal_validation.transforms.periodic import Periodic +from causal_validation.transforms.trends import Trend + +__all__ = ["Trend", "Periodic"] diff --git a/src/causal_validation/transforms/base.py b/src/causal_validation/transforms/base.py new file mode 100644 index 0000000..ea15109 --- /dev/null +++ b/src/causal_validation/transforms/base.py @@ -0,0 +1,94 @@ +from copy import deepcopy +from dataclasses import dataclass +import typing as tp + +from jaxtyping import Float +import numpy as np + +from causal_validation.data import Dataset +from causal_validation.transforms.parameter import resolve_parameter + +if tp.TYPE_CHECKING: + from causal_validation.transforms.parameter import ( + Parameter, + resolve_parameter, + ) + + +@dataclass(kw_only=True) +class AbstractTransform: + _slots: tp.Optional[tp.Tuple[str]] = None + + def __post_init__(self): + if self._slots: + for slot in self._slots: + coerced_param = resolve_parameter(getattr(self, slot)) + setattr(self, slot, coerced_param) + + def __call__(self, data: Dataset) -> Dataset: + vals = self.get_values(data) + pre_intervention_trend = vals[: data.n_pre_intervention] + post_intervention_trend = vals[data.n_pre_intervention :] + return self.apply_values( + pre_intervention_trend, post_intervention_trend, data=data + ) + + def get_values(self, data: Dataset) -> Float[np.ndarray, "N D"]: + raise NotImplementedError + + def apply_values( + self, + pre_intervention_vals: np.ndarray, + post_intervention_vals: np.ndarray, + data: Dataset, + ) -> Dataset: + raise NotImplementedError + + @staticmethod + def _resolve_parameter( + data: Dataset, parameter: "Parameter" + ) -> Float[np.ndarray, "..."]: + data_params = data._slots + return parameter.get_value(**data_params) + + def _get_parameter_values(self, data: Dataset) -> tp.Dict[str, np.ndarray]: + param_vals = {} + if self._slots: + for slot in self._slots: + param = getattr(self, slot) + param_vals[slot] = self._resolve_parameter(data, param) + return param_vals + + +@dataclass(kw_only=True) +class AdditiveTransform(AbstractTransform): + def apply_values( + self, + pre_intervention_vals: np.ndarray, + post_intervention_vals: np.ndarray, + data: Dataset, + ) -> Dataset: + Xtr, ytr = [deepcopy(i) for i in data.pre_intervention_obs] + Xte, yte = [deepcopy(i) for i in data.post_intervention_obs] + Xtr = Xtr + pre_intervention_vals[:, 1:] + ytr = ytr + pre_intervention_vals[:, :1] + Xte = Xte + post_intervention_vals[:, 1:] + yte = yte + post_intervention_vals[:, :1] + return Dataset(Xtr, Xte, ytr, yte, data._start_date, data.counterfactual) + + +@dataclass(kw_only=True) +class MultiplicativeTransform(AbstractTransform): + def apply_values( + self, + pre_intervention_vals: np.ndarray, + post_intervention_vals: np.ndarray, + data: Dataset, + ) -> Dataset: + Xtr, ytr = [deepcopy(i) for i in data.pre_intervention_obs] + Xte, yte = [deepcopy(i) for i in data.post_intervention_obs] + Xtr = Xtr * pre_intervention_vals + ytr = ytr * pre_intervention_vals + Xte = Xte * post_intervention_vals + yte = yte * post_intervention_vals + return Dataset(Xtr, Xte, ytr, yte, data._start_date, data.counterfactual) diff --git a/src/causal_validation/transforms/parameter.py b/src/causal_validation/transforms/parameter.py new file mode 100644 index 0000000..5ff9362 --- /dev/null +++ b/src/causal_validation/transforms/parameter.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass +import typing as tp + +from jaxtyping import Float +import numpy as np + +from causal_validation.types import RandomVariable + + +@dataclass +class Parameter: + def get_value(self, **kwargs) -> Float[np.ndarray, "..."]: + raise NotImplementedError + + +@dataclass +class FixedParameter(Parameter): + value: float + + def get_value( + self, n_units: int, n_timepoints: int + ) -> Float[np.ndarray, "{n_timepoints} {n_units}"]: + return np.ones(shape=(n_timepoints, n_units)) * self.value + + +@dataclass +class RandomParameter(Parameter): + sampling_dist: RandomVariable + random_state: int = 123 + + +@dataclass +class UnitVaryingParameter(RandomParameter): + def get_value( + self, n_units: int, n_timepoints: int + ) -> Float[np.ndarray, "{n_timepoints} {n_units}"]: + unit_param = self.sampling_dist.rvs( + size=(n_units,), random_state=self.random_state + ) + return np.stack([unit_param] * n_timepoints) + + +@dataclass +class TimeVaryingParameter(RandomParameter): + def get_value( + self, n_units: int, n_timepoints: int + ) -> Float[np.ndarray, "{n_timepoints} {n_units}"]: + time_param = self.sampling_dist.rvs( + size=(n_timepoints, 1), random_state=self.random_state + ) + return np.tile(time_param, reps=n_units) + + +ParameterOrFloat = tp.Union[Parameter, float] + + +def resolve_parameter(value: ParameterOrFloat) -> Parameter: + if isinstance(value, tp.Union[int, float]): + return FixedParameter(value=value) + elif isinstance(value, Parameter): + return value + else: + raise TypeError("`value` argument must be either a `Parameter` or `float`.") diff --git a/src/causal_validation/transforms/periodic.py b/src/causal_validation/transforms/periodic.py new file mode 100644 index 0000000..d9a1376 --- /dev/null +++ b/src/causal_validation/transforms/periodic.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +from typing import Tuple + +from jaxtyping import Float +import numpy as np + +from causal_validation.data import Dataset +from causal_validation.transforms.base import AdditiveTransform +from causal_validation.transforms.parameter import ParameterOrFloat + + +@dataclass(kw_only=True) +class Periodic(AdditiveTransform): + amplitude: ParameterOrFloat = 1.0 + frequency: ParameterOrFloat = 1.0 + shift: ParameterOrFloat = 0.0 + offset: ParameterOrFloat = 0.0 + _slots: Tuple[str, str, str, str] = ( + "amplitude", + "frequency", + "shift", + "offset", + ) + + def get_values(self, data: Dataset) -> Float[np.ndarray, "N D"]: + amplitude = self.amplitude.get_value(**data._slots) + frequency = self.frequency.get_value(**data._slots) + shift = self.shift.get_value(**data._slots) + offset = self.offset.get_value(**data._slots) + x_vals = np.tile( + np.linspace(0, 2 * np.pi, num=data.n_timepoints).reshape(-1, 1), + reps=data.n_units + 1, + ) + sine_curve = amplitude * np.sin((x_vals * np.abs(frequency)) + shift) + offset + return sine_curve diff --git a/src/causal_validation/transforms/trends.py b/src/causal_validation/transforms/trends.py new file mode 100644 index 0000000..56018d5 --- /dev/null +++ b/src/causal_validation/transforms/trends.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import Tuple + +from jaxtyping import Float +import numpy as np + +from causal_validation.data import Dataset +from causal_validation.transforms.base import AdditiveTransform +from causal_validation.transforms.parameter import ParameterOrFloat + + +@dataclass(kw_only=True) +class Trend(AdditiveTransform): + degree: int = 1 + coefficient: ParameterOrFloat = 1.0 + intercept: ParameterOrFloat = 0.0 + _slots: Tuple[str, str] = ("coefficient", "intercept") + + def get_values(self, data: Dataset) -> Float[np.ndarray, "N D"]: + coefficient = self._resolve_parameter(data, self.coefficient) + intercept = self._resolve_parameter(data, self.intercept) + trend = np.tile( + np.arange(data.n_timepoints)[:, None] ** self.degree, data.n_units + 1 + ) + scaled_trend = intercept + coefficient * trend + return scaled_trend diff --git a/src/causal_validation/types.py b/src/causal_validation/types.py new file mode 100644 index 0000000..34f5104 --- /dev/null +++ b/src/causal_validation/types.py @@ -0,0 +1,11 @@ +import typing as tp + +from scipy.stats._distn_infrastructure import ( + rv_continuous, + rv_discrete, +) + +EffectTypes = tp.Literal["fixed", "random"] +WeightTypes = tp.Literal["uniform", "non-uniform"] +InterventionTypes = tp.Literal["pre-intervention", "post-intervention", "both"] +RandomVariable = tp.Union[rv_continuous, rv_discrete] diff --git a/src/causal_validation/weights.py b/src/causal_validation/weights.py new file mode 100644 index 0000000..f108a7d --- /dev/null +++ b/src/causal_validation/weights.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass +import typing as tp + +from jaxtyping import Float +import numpy as np + +from causal_validation.base import BaseObject + +if tp.TYPE_CHECKING: + from causal_validation.config import WeightConfig + + +@dataclass +class AbstractWeights(BaseObject): + name: str = "Abstract Weights" + + def _get_weights(self, obs: Float[np.ndarray, "N D"]) -> Float[np.ndarray, " D"]: + raise NotImplementedError("Please implement `_get_weights` in all subclasses.") + + def get_weights(self, obs: Float[np.ndarray, "N D"]) -> Float[np.ndarray, " D"]: + weights = self._get_weights(obs) + + np.testing.assert_almost_equal( + weights.sum(), 1.0, decimal=1.0, err_msg="Weights must sum to 1." + ) + assert min(weights >= 0), "Weights should be non-negative" + return weights + + def __call__(self, obs: Float[np.ndarray, "N D"]) -> Float[np.ndarray, "N 1"]: + return self.weight_obs(obs) + + def weight_obs(self, obs: Float[np.ndarray, "N D"]) -> Float[np.ndarray, "N 1"]: + weights = self.get_weights(obs) + + weighted_obs = obs @ weights + return weighted_obs + + +@dataclass +class UniformWeights(AbstractWeights): + name: str = "Uniform Weights" + + def _get_weights(self, obs: Float[np.ndarray, "N D"]) -> Float[np.ndarray, " D"]: + n_units = obs.shape[1] + return np.repeat(1.0 / n_units, repeats=n_units).reshape(-1, 1) + + +def resolve_weights(config: "WeightConfig") -> AbstractWeights: + if config.weight_type == "uniform": + return UniformWeights() diff --git a/static/fig_creation.py b/static/fig_creation.py new file mode 100644 index 0000000..83ff916 --- /dev/null +++ b/static/fig_creation.py @@ -0,0 +1,38 @@ +import matplotlib.pyplot as plt +from scipy.stats import norm + +from causal_validation import ( + Config, + simulate, +) +from causal_validation.effects import StaticEffect +from causal_validation.plotters import plot +from causal_validation.transforms import ( + Periodic, + Trend, +) +from causal_validation.transforms.parameter import UnitVaryingParameter + +plt.style.use("style.mplstyle") + +if __name__ == "__main__": + + cfg = Config( + n_control_units=10, + n_pre_intervention_timepoints=60, + n_post_intervention_timepoints=30, + ) + + # Simulate the base observation + base_data = simulate(cfg) + + # Apply a linear trend with unit-varying intercept + intercept = UnitVaryingParameter(sampling_dist=norm(0, 1)) + trend_component = Trend(degree=1, coefficient=0.1, intercept=intercept) + trended_data = trend_component(base_data) + + # Simulate a 5% lift in the treated unit's post-intervention data + effect = StaticEffect(0.05) + inflated_data = effect(trended_data) + plot(inflated_data) + plt.savefig("readme_fig.png", dpi=150) diff --git a/static/readme_fig.png b/static/readme_fig.png new file mode 100644 index 0000000000000000000000000000000000000000..06a78f02ef4a033ae89338653bc57cec414437e0 GIT binary patch literal 98314 zcmbrmWk6Kl_dPr?0!j)nA z|1+QZ{$4-N3q)p^d+#}CpS{;!YaK#fD$5e!QsY7(5CVBQDK!WL>I8vcRNTY{ub3}t zq=RolPSV;=uk6g6;Kq)o5G7+LduuxC2e=PpHk`j*e|DT&$nc}y(j&O%-MIjyjcYo51>?JP<>%D*| zBu4Ecy=rUR*5%IFL^{Nq0a~haYD;R#Ir_UaURT265e98N9-K;0@A*$hMF+BLn)F3) zhWH{-_?FaJOf&TVJ!z%5MeDUI3I+X)9=3(EQ&-Oa*U?YjSrIJvJad$Y9t>=d)3}hg zYx8W&x9bd9pnfKNC zoi^_KSNl;uJz}#Rng8q?p~s5dbhy)g!9u<;#qK_d$RMZT^_ywAO4?2C&YS%{s6#_~ z?^EMMFv%>etgIq!fdobM+vVMa(;kSXi^sgYdSaDLo@WP@=IL(t*CIuB$0a;Z_o`iP zkNmZRpFKv@@sYar@^1V9v1|F}PDCzMQbHR34d30?sV}cz>#+|oeehIccmD?z}CjDIV z1Mm4?FS%d3s>@C)|KWE^)?6`{xX>vDpPw^A9VFT~m=OB%ylEf4JnfL+ya9rC33Y)$ z%+%P;@vyP6kzO2a@Sf<+ooi+k7CvliP|gtdYEO3_)sdX_JnZfH_NF6JQAsH+DI-JT zM8)TP=!<)`1ZqF3&gf!t3>!n=X+XLSzb)x4WqbCRp+|8AoTkFOfA_IjPg2ar#s;a+ z^~D?sUlPPx2WV| zfK349JXU;&X^6Z`gX-sPZY57@Qp^j#zOULuioFW0ko^8s$%+={@hnfjnv^k8QpxM* zJNHgZ!c$y4yx4xBwbaGj1cOFbhx1e+m}6<(YVsAB z*+xB;8U)xe`PW-U*ORc>Mz=Js`t{7m@H3mK>YOIj`PLSC{vCZwJy+Oqa0qDycT0V) ztZF)ASt-J)`3qgnk2Y4&;pmYHpLNTjFmyH6xfBO6lYV~C&1KiP7|Q-)#-e7{-D)l~ zGc#LY*7ePm+gh3y;a(cT)BJjUAWK%_bUwh#u67~N1f;xe*uSOLf-2V(j$I->$c|Cy zXATA&V==+jDrD&{Zl7Z@!S&wXPtJem)R(Pw;%T9|CeNPQ1IqCU8M}qrOe_PDFS+6- zq9?QGv&D5vZT25AXgdTS9e)$@Jp8)QFcf>$Tf!say7!s>9xv}<8IiVVY4f=yc`KAi zv}{b|N><{0fR1IoywTrwZ~ps8NfPvTcXtsz_v@#})izU&^rJExLwPF2T0IzzhkYVC zAn=Rrf6+eG)YM#FO|hFrb{nC}`$Ngt2Xhrasm)A#{abL8qe5#9@SId*8Rfk6V$Ls^ z?%^eUMbQ!VJm#1>d2&O5qk+Pnc);ZbCynnl7~MnVm%`RvImVQ8{P=GojVWme+8*%W zXSH*Oa}l(i))-U1q0vDU1sXpBFHUoZLj#R_1n`%J-?(kFKAf1CF!nt-IJl1};%d2k z{A=xy$NgwMDH`75eYGh9-=&=edX^zq{_z36aQrAv{N;OXq_3~@rtHraxtztL;Zv3AJMj=7Fxjl3fq zIkcVn2Nb;fOhNxX+naVile-*=f&P9h`*Ny~XV}G~%8?qE|Gwu>RYBm*jQbk|56jSsV8*CLI)IODWU5BzSCjjxW_i zhLI;2S)r;Vf#phn8e~j~5Q%){xjp+lZkRdau(u)*VcTmX^J4)Yk}+rGmfbNt=nRsZ z>cKRu-&wEh^|L%GUNgRT;e#NJPs%a>DAXt#ysw<duRU{M(~u)$c=5A- zbxbwD5tF?s0(rkwj2c{Qs9Wd12dLy{>inG{l23dsCbFV+1rj-0X$Uyzaj`cQyS>S4 z$?VhmRKoPO?+xnN!u+pt?!`2{^6g94Ayw)|amSo)J#ZS2Q|NFIkEDQEo-ehUeP-qE z;@xW4%v%W+s=73p?+(v3F~_wu zIN>FfOaziQu(wO@CEYYta=6Tm#)!9H=YdD=){i>bM#Z-cxU6b=(5-5234!_8x-?WDp z6c!irlXDrSo}F)%nj5*T#7meVYIzo(zSs$_^C6BHl*ktEvjUWwOo&ddSFnDuF`Ko%v;uSj6|f3YK(8+?gYkStZ3}-gVN{_+2f+2uKDM59-!3o9oxNi=n zfS7+uXu{Vf!Nq>DG)faL)zSD#XNLIM={ryj<~}Zg`Dp-qj zAKbUw}G)zQ-E!5 z%E$Itn+)9C!z!Z;d7p-dAE9?2kJw)%+TkL$cwl>~aywnF-N1YL>%*Uy%R{R&m?DE$ z4vaVJv7}N6FgrO{iZ zLM5_agw+0h=(EyhZ@g-a6ShJ^IMpD(Tj>PTvF}{sxX70$+m!uZ+0tr@vu*vEi1{Io zgO_@FD|w~b%{6A@y9ehB#vSq@Mq`kU2EwN`PS@>=$#RBXKZTZ!{_u~qEa{OpG=EyO ztNVWUsfE(`_2T(Kh{%hr4k5vy&4tTARgrwFgD#x_;o@E;B>iZjTbtixkFgLNf%InN z*_A`~;G1Vv*#P*cf9uD+^j&sUp6{+v>V+%T-Jdx6G)l z>7yC9kPUO0>|5U>u6M<6laqPgNa`0$+o4nViqpBvR8~4qAKi}U1iT}ZHEM}ec{9d+ zn67a3JHRYa12l6Z?GJW>uSfeoZ_b?9P^+XpdR{&&1EEUaz_~5Z^}e`T{c7^T&+zOh z^R$|n_IdGnJ-K=oIi(bVa-`-1O6}orWVHWqztWmLQFwswU0m!Z{AIRw^J-p#hRhoTr-1oI1~aNhT@xg6YigN zFeDh6SqpwuV+d`+1q|G<2D3Qn=owLtF{sGyn0Kxp0cJLI$W?o)0_{_ua%RIYyoCkT zBe8C28(_c!hjoZ4z;Yp#hGRE#x+fCNU zPMSae0@+B8HeZJHmN~_^%**c zz?^yPi1kz0MvJkSu)Sdl^Bl93iQm;Zcz!F#(A2U%;O0*}u?~Ak2x>=qlI3Tje(_7h z)q98Y-DrQpI>Bqm2~FTPTDIx!!2Gk>;NvCOKRLW|q7*Ltd;vMd z`lu^JNDQlzt35HDkBVHPUA0~k?1SUC-DKDm<{c5n^UV;79Lmm${FnS1!VvnP&HGV+ zx-jU~Mw|d+?)?Z=sQNuj&KUU+1ro;fuITVutX4cwzEy=y{T$1jtaeDKsSCaAo?EUt z#^f6iQIlt6Fd*vMs5AIvg6=p)E?HxFJH6jRk)E1c(MA1t8#+MAgylcQ)Fv?^UDJ6& zRhzEOvvzMI!ehzsSIGnQtvbRuL#-FVq2=yU!A;}7Nia7e`BGX^fhO9Jhb7Qz0GQ8f z-@vW5+=-P4A|PL0a+fv7TsgZ6XaA43MCpNh5D1cmgmc)*eP%NwiO|=8AOy;KndhmQ zn+8m8?YU&?if~z?o*f_9@{)!S)-P@?*yLyMwXR#((y>`PF}%(N@L`^)>f2+ayS zqzvRK*vNSe+FTmW#=Rd;>}F9#MpuO%GBPq!VVr+g23E|%YU}&lEZ?_@TW=1P3qbK= z@)P_hQ);&vt0$To44}lpOk%NGOkOjQ+C2s8Du{2KqA{r~Q>1_f)&0;x=V4uhM4*`{ z_&}z#!D3z%;0BWJWQp(pBo!;KsMxCdXEKKdcsLrk1}Q~4c!k2V1zipmX}%gx#RvR{9_cXh zyHCW@%gY}d0X!LXf+vZl{E|;(SnL(1=ctv!UOL;DGxmNX=>OW(>NDJL9PGNe^l46| zN&oqm*Prj%PGNIHleRxKlDV?o!G9mMnB*n**4E$r07q$L!6K3t(2X781+Vf5C0zGj z55}s9*B#G#Aa)wuk2k}&N*Zjh(Jy5c7IvL35c!OIoNP}Bfq&#d?^e)qG^ju%3J{>k z(9jT(n3DU^fGjM-W4CUafaXbdVq)UO#mSBy>|&>e!W)o4b8DOc9Aibg!>bS124h*L!xT%d-hUM96K96lv`!3CvVpF9gxgE+H?7?jlfE7gz9U z2iZ9^CS`90+)o$+#8Fensx>;z<}$rPtxxM#7FFkdcz}`UpOp3%gW@)gDa<$Ow%;;! z#8&|h4=DF-Vi)M*6Fq1nX$`$3hexvcf#E#q>rjh{rkL42;_L-v`AhCU%aXS=Dg@Yf zlZZav8##1+qkHo1CuFrZi@0nD13g`Fi;E`LEQ$J$j~2tY#O-&g#>kJs6N}Rl^1@bo z7yw<}-w4!0FF++MYQ9$K~IJ0Hd4E zId*eEqdMIF)+=C(uXSC7RTpAIgKO_(vW12SLM~bF;BNsGqR9N~Fb{H#;~~6#CWKFeE8< z@A_q@l-O;YD0T0e3C1Y27aIxhRS>hTd`h@7c|iHVX6KEl=DTNO%Jz)Jn}p;7Ck(bc z3vxek=8gt>@XbbEgOAh)8RzKF~YRkyS?fMEyfBx9@a=!xxI=X375Hnl-B8Wyu z_&I3#3h7+u{ZMu-S1x&}K4b03|zXZ<^`JA?S4dp7)oS!a5A)T;hK!B-V z?zIsiJNx=dqEyZqi&q6Kt4qgN|jJ{!kv^eFrOXa>p6 zbkM_;bSH3h<6{S4NnA|ypJ0mawL&kH#m|-(irt_84TUZR9O<~LvAVf1$N}jynMrYP;d-vz_ zro&RZ24G_@{)oAOCNNP93<%A%R?KiYn#<^ z(Zyc+RwZ4uu2HYX))>*;Cx)CJ?4b8)9GkLhnQ{9a&49DopOLYGyhyv)Z9u$yXs)J4 z0-tf#S}3lWP5$J%^$$Xec%$pWWuhcUok>0(6`aYzF{1+F;|a%n>1G+W$xU@#>5yZPjOn| zylHB3i*e*`KGHy*xGp31|js zCbUReBkzi+)x!~niShAdZ^j=3Zm!L}<}ctvs}Nrm0Xh^$4iem4k$kJ)job^UlO*-=RunBpvor?AmWlz~jZQVJorOTD3%de1ZtY z-wjvd+Uofc2jA2Ez=x`iD)&P&D0VAjbKZZFGbF2ig9Gbf^v$VPphTt0yj*2x*C3dA zDm6}U{Adxv9-X_=u}CLTIUS@9FPVDUj>FJPh7+EIoWgcsc4z-}mt0 z`X9nn-9J^NA1fLH&8<&Ghm)u9Wf?ah-WiBs_ZAj&xY+$jiK`1_sSY69+R2}S+XssY6~J-M z>|!3m4liW!msXTVR)QvZhc99ai(U=3xI2HJq=@|GP{WTNHhxCbXo(hHP#ZB?R4ggh zF0EY*4S>&i!AB9G>evE(!zdi4=H^z1UNlaFda3CI%t+D^OKE9o6kzlj1iVv#NE0(S zIM~+nnzL^CBedoE3ULUM!pur2dr7bZ?_`Tliv&A6dunQG4=7hJfFxFD+j6yI*U*sr z;Q07h6m_~Jwpdf}*JP~zl+586)Q#blf}{lm6XA?+e}Xq#OvKAgny5%cA4{omYe9;V zA76pa4`R>HSg!BFcx*SzmrEd5TE`Um0-#i{)-Bn3l&U@X)7e?Bh8yCp4oALeDayr4 z${dq|ubpbH3_Rk+xBa2p*Oe)I_C^*PgGRUqe}$6=ialmSIR;;qO^oGXicudMr3V7%V>t%>-?w- zkE^k4IZxus0`-ByP;fax&vrt~&~Vphvmmd2WqElRF;&Ag(a?)dnc{~%yff%a&PoR2 z7wiJ$a)l1F6ErdCwG<&xZwTw;K=*kQsW!2PFS3hk&T?{)KGx8Dh0Q7s2J95{+ zXU2JqOaefum6p?ATn9BaQwhSB!}*0;Mqd04U%!6UNR#9LD^FHRsHcrOKW(y@CK5H$m3W~<)1{+x>{{X5$q-j1Q(t}lh=p_J$%ZIo%UuH zpYel?;K*SS72`X@-Lnk%-EkDkkPx8K@L3v`)9R!%61A( zA6WYCFLjJv9%fumUk+uUD*MqIVsl+xopa5!qk=I|B(_=((-8Q3wucm8^dnY%$lXdRq7VwLi9vWF3&lV)rQ zK!-wu{l%I&%7lnaDK-7h&d7e?z--qoD^sCho@Te z&$H>L8DG0Spv-^O;m+{dJ)sy!{3)ZqPWna$rd_4U&;2AG34xZ59k+n%L3sZt6-q8! z{%SEwO3g23cg{GYWj8B?*U$qtxzf)VF`@DR-|jqlb)5(Q4ZrV_VAj$ahf%ZWUG1cV zgeQ(agNe5;y?s*BFaI?l5WC>yRF!@S^^D9fDKW%TOyRQ_Bp%VSoWuc@D+QC=?O-{l z<3gK{0h$zTW! zdKW$(0SN&KirRj^hKO->wP>e72_{Up+uVZNheSx(A3S5ZqGA>6D~HYJKLY*Uj6r;? zl({6k3oTuDl3A|<9xycbb3 zquvc|WvsHL91qsMLP}3_8hl$I!h&W%Gg1<+Fzl&KdJ!@Q!Vb0T(~-kf21;L(Qh=Hg zIcdM5{TM6?y*=w5$L9(GoAM`Yb$oTryB*k<&YG&v!VAJICYtFk;4_m;t-GCKp)RJZ zAGTt}(*@tyNXx9@&VN`9t&0r&&)t)J?XJ!-iS_$bkaRtYG8j_JITn~aOIGG*^7`gr zE@ppu9^}kY#9-veBwNO|=KQSA>ag_0i)BD=Mn2aRfrka{1|&YuYw*Yn_76Y4pjx^M zc?1=g(lveSkqf6p&*mIX&yC&ZgK}>&KUT@J#TNxCoxA?Zf7N`I(c%gB%CrH{5=dd! zoZAVBH)0#T4$w5v1!*yi@h_v!-*Nu_`AC8@^dY!aR&l5(zAUxf8fMmQ9xWVe=}Els zr;P7BV%n$0e7SsUd_|c}TyO_#rwAU_xtw3bOx9*VrZ|RO} zFQ)b?5j*bQ4;v!Jp7VPqwpeApIE6ntr=w4Gr-xx<=B z$`;$s`4B4!9;QH^$u+?czRm++a+}6d1HfXlyts#yrY^|%^PjP7 z=v?sk>(PsvT(XyoRyRphw*rE1WcmHNYFz=MCdlwAG!xsOf?syax>wssWjb(y3K@~U zvI>+DjM`fv(QdCeL_9|4A}F)JN=@Fj*76Z}0YI}x<}Uf$)O14q@S}nYO!<*%GF)uh zc*CU3$X`=(mjPf6kYzL}$$2x5r+(p!TpYR!I>G>{U(+|o2${Q1tbwQ#`&v)> zDgQ^8PYRXIC4?JXH53jhM+cg)yZ0r(w<-f?4OIVGW#l6)9_ddvxA{3dus)i(v8aBB z?(yt-!%kp#6j?R}b;6`LFBW-BI=?=U->86ljC~gwJ>F=s8v0Bp-GGxjI^tycE`YzY zfGp)aLVy2z++hO65uJMQ%_I4rK)jG%0|FHD$uz-J#^A8z<6Yr<06F}_Yfs*_d@YVi z>;M&-uAfujVEo}yG)DLO0m;ncXm7hAKjmE0Dcef3!|#*$*(JSXun{>C-#pTh*@8Fq z#8i@YQc@!>pYi3Se@a?TTN;5q>rdY33>a<8p>iYx_JgJ1P?_jE{dmLO-FdD4)z(V$ z5q-zwExfSFgBpeV#X6}tN5x21f=J3{hGscI2{w}5!QpD!{1Sy1XGez%v0BY8tNb}t zY!}rhvY7toTFf;t);*EB_jEW{BHfbt zg9EABl1nSu4WHI3b())s$ZBg6*GF$Ep*pPzrjD)3OMUc_Lv1%zOjAgY?@+a+60=14OYrh7pX$Yl+3%w3Qt)m2F;87UHn{>yO^d7~qQp(#}q#BoN9y`xPM z@cxGn-^CDzgobkh+LrAA_4szs9~qEap#>>0M2bl^QoqV#qC0w6!`C9$RDj*9f(gyx z{*{H5wqmhpk1o?9@E`LNfaiV9jJrD8r2)BqXDj(`sJw$IqQJlfdJQyHSj^2{Q#o8S zRvclP#|XT6H!_wNKlw6?ai9KN(d!k;t$r@eD)6N4H8fw`(VS7WnTXmQ0Xm)_XbTpn zVsV(+7a4q`s{Rn|R)jejnfl!g2fp_t`6($CUrDADF>PlIZ#b|zGnLvgu$4{xZgNIP zhiXheDdvCfNxlv;;5NV*BPsq=gq?2bmqdf9zxc@svT?C`!o_|A7Z@jKErPvXXci8P z`O29#RS@z#m#>`J|CzhL16fUlke?0_f5GMcUAgUpT8t5~yuHASA0iWpRl=amb6EsHnDSRS#@GJ3n zz)i)u+juRYWfPU#TOpy1YV#V|GJ`uAa9p(_^ZX0rUp(A#7QDa!S^3cCa)};>s9Q_N z0Ik&bNno7PdIE0(H1*u|W&ib&Fxn}p%;#|UFA2X4$RAGOf1ROWg(8J2%j@H<2ODy{ z2vs@ej;*IU#nvZFMmZ4#aiBE#C27`bULOa)vrH%e_@<8ecEIm@KEcUP2*yh@VHuz_ zn#?Z=AvjO+P9uJQFi+K=pD+|2WHi1vNIS(&l0k?C?WLQlWO?xtC;ITD-d!aEpuRcC zeV+zB6juZNxEKc;iL!;|U0-3J!Cj(U!@U6y)?VEu%4Xq7c?kjM;u2=}h{BkyYXXxG z78J{LcnmR9wAl<^;$?Su<|l$GGuETgL_I|uAi3$>mSbiitjV7}7DHIw-P+G zan^qRnwe=LZE0y4p;jfS6m?l&3Qz_|&6=b79JYe|D59$|Bp1mMf!WIG zVB5RAJ}lc_rs@>W60ZF-8->4%!{F?)uUaUa-FfhU?U!hM%&tkQ%4oDOjHU>x4STuWawIyB z*{UI;92&R$EjCJ8mw5Ht>i`(@*2_!?h4Vl2;xj=zeVT7>2n^7l-L}ItgSNnF*UD0W zaanXe=y#&FQNQpthWD0W-z^qqzL&bXn_Gi93JpClR1HU5T z0Vtzk`H0nf^MNvp2^PzGqpO~0?^8u;OC{gyy@^S0wlty{Dc^O7BP9#Ey|YpMg$ls{ z4Jk{NtYoE?OH(KQ=)Iw~F%blM;2-|w=iZhrZ=!{Zq=qrMYFM?TUb$DhDe1a17E>F- zSo7aaZ?xJ34=B1{x@>x(9{Mm}ZlBBsywD^_8HSnnV@Qj5oNyJWyby<6^4`I}lakaP2%K7ouE8qldhoUu@CW5~$xCn1# z!>$C4W{LhM7eM0@b#-+;29_n(NMLAM2If~%SQ84kfLEylc{;0idT8t#zZoej_nlr| zUOHTZH_B%=_B)U%bCkvVz<#&{)E?Fz;56MUF=!wZ zdjNKR|F}}hlN=6%dcGkqBhZrHB~+6%|IZIRh4%lPp#7l00k5<@T0B+8UfTHB4zMnr zv!=-~!@sDF{mgL5-FpvLshMt3okq&O|E?`1brvl%o52upA>G;`S6Xu{cU=XQyB0_k z&4k(~Dw=#4EdthRZV&gNa<5!dw-t_Ph+SQ7Kq&EO+5Ke!8ug}d}+MYM3 z6;qSgyEdz@+B(-zYa3w*YSIS}Yj5 ziUyIpR=OuuzXiQX90^#NRylH{hFI96Wp<-L+NuKZfNKx_wufHky#b{VYg5d{C2OuJ zk!YY@-pEEYvGDS(??!$Sz>qka_Ks4GtMIuQxx87pd-4j!2 z#=Gq25ky@-WQ+-R*sWV5OyM?uekMsIroIP~tqTgc_C{gA=*m9_6elFwZ;tZ0M*7sC z^(ce@(5e;{xSEdcwSw~xg^&aey#$w09rK9wi3;!T%aAGW(@fojG|_y?CqxtkpM*30C5W`xrd{ecGOk1m&7?Nn!898Dj(z zZ!)fd)RRVYz5XZ#Mb99-}9I@z61nsLd}k;!3vo?A>B`zW@Pc z6TLRDs8-h=f@IFx^>o#fzkuB_9atlxZo-j*_|CKjekxPX*9*MxWu;7w`oB759!Ka! z2i-j906t!1)F6;_`FjBn`F)%`(SV@ylq~C{-}!O{572dC6E8BK<-i zw=4-tF%r}J_OFHcgu;S{v){7vax^KuBC)cx3`Yk`tDW~@$_#`E$X(5>Vvx6K0iO-*+sL7tRT5m9>ReO(?GVq6eONe zAD}kVc!}4&1qUU-F_H|iu+Y#;z$z}J*wz=E0^N8wHZ9G7{<@hYEiVIgly~-*;&C*j zGG_Yrt>A*a;2Yrgj7J+0=Q4mvQ;Z&zaf8_v8j;f8Mo~1^D&(iyPMk^WQ zxhT%()K(2!se##$(DB6dg-eO~a*ixq={wD|bBO@|D%HynCW#}7-#xTKPL zwM?CfN%gxFTayiTr*vw8n%M+?bH!fA4}Y1${QgBCM13SkfPoQ~7rI_)o(mOk3$bti zDBzw^_tv{K+g@-U#u(D>PPj+7hcuerPe}%dJvZ4fE4L&1>9Cvgb5Ex}sTUcLcF=x1 zJ)nFS|1GHAT;oa#>hhDS+1VzKs|?Qr`86Q;*=adA zd~2z#J^j;I(J$Vx43^MfcC=V87U=x0`L|Ee)H;F;1{u?b^?pa|OdZubeTn*x@>H!u zDiUdTfr`D6n?gvt(QMnWQ+)+2`$^(n?q{Sbq607>=CXkm=di4-vtRfT4h}>xqgBg> z#wuqmLpReiAZr!Pd0*^2TJB!EE$jYQoq;W8O=9RPS*J~4%zp@cAEvuzEyb+?(eoqn zy&LlNiUy>BVZg@GGnd07lT$T%0@VxK7WwGFTaviyMkXO)R38h}+-=nyqXtwn{Rfn%2f?$n zL6a(q&(yp~E#lOeHITghXw3e|rhT>Jo((?j^0`m=`VD%M(7~_dR6Fscs(%R7B(|ct zI1+231N|kQ;4_J@9AH%S#CB}nyH1f)QgQ~!!DJA>qN1WL2UO+IK7OmwY~jc@^!{#~ zyId8x1w+AiCgYdMbbziQq}XT#5DClNX}iD=v@w}F?d7Ct| zzq9iebbGF)MaO>kGUBy5T@gW_BecR>FV5IfG0er?8(zs2z` zv+4)i&)nrEa$M|(ixg@x8Iop<0S)&{ySf!0PTH6CrHxYX1nSVV*jk~*<;;lTl$CNp z!B7t_cfJA#~a)y~%P<`RY60yVT zm#=q+;!60I1=c(ZK`p=uLLrt6G|xlYc!Hx9_)Z+7x4zwV`}4A7R^cJZC%lw|v}tX- zr$gl;3~Q}oeQ%d5$_PRPea5GkY)Vsu{deh(4+%OP8le|BF+}?Yt&iHc6PPHD`LgYP zMMN$&!WE;AT5e?Y!+)yOgTjts4-7PjCSc`wLBhccL?KEH;0STh#D- zAO>5To6V0NJdiW2b2MEg^4<%OEYXlHF9kyXdql58DmD$Qnq2m8s5P{z&Lm?q+Y)uF zaN=M^>cgiLccg>l04x7qOZ2i=r#lFZB5?Bd_C;U0c3g0Lls-kz>>skMWsCuBbG5fxp|BGLY-LP_T~6pU=<4 zeVw;Pop0S(?q(s0v8n|>gaeT84yWlAE8TzJW(?gEij2ij)CI@iU}`b^JBg|gus=n> zsZgQEXb(7_E5Td~+f6n0)yF5=`dhufs?yHk^7WORi*F&!R5HCAXB&8ZyBVrbMcZ{s zPRcl*?TR1Cywfc>W-MGSrq-HKrrin0a<$Ky-ZK&UTsJPqH7Z^wF}LNP4He<7WuzuH zUnAC#Z#^t#4%WsJ()0`|LY$d!SXpOUj*rAQ*hTVAEi;U?PU6plmk5tk4ya^K*?n}% zoUM^@&O--7@Cz?&jbq}uKikV;?3;`;7$3c4R>nHa$?j7J#E;2bUNtZqETkv;>2$J} z4_&rFnuQpwkkb(>=7J<=oisxn$ARGKio@n)tZqjNhHoT1&T6dn{Jmi(CLvz2{n|`P zSH{^(S7tsQiJHq)rYiW*yj}7OWhfSQB_yOl#?@PQqpg;$s^ot4&QtgEtPdW#bx)-CdZj70qJoApi>`gHUn1_x zeZ5(z06r-%(zS_=aY~SNvb9t}ZY{12pKscUUmFpLoT5b~u%nJfS(-%79P!)k@lL?M zUxXzzk~`4OqRJijRs)=w4jx~Hs99~KE>4N@iJ0zecnwy!)HYu(-_3EZNf;Aqdr2$< zWu)qC&%M(U^PDd;Q9zWH#bTn|v}mygd7cpJ?e+-;8>v$%3Hu|&(V34yIm6-H&CevP zV!aG=>uJeiRY{*tJsM~eG)A8?Ve-w3!Se<S+eURt3z!;hL^o+RIFXj_lKLr}6 zZVD<(ddWrJ+F&bCcJCVCPwwmc9S)IVjJXLwrB$NWc;j2ME}q1=xSyhKUESSy8BT-p z@hW1+>L{}utopCk)@gzH1S0Ko5?n%FD$-Xsece0cy*%_*-0C(^b&?gY9-cePm(*K59~(oR zNpDpC=sN#`EjTk6`$T0crFn2WMps{#^hBb$eAy%_33_i)5ap$DEIB)4DDifnZ}a0N zr~H=HR+>6EfT*~Wc4AzrEPsn$y>Cv9XIP9&yzOQ`ZS*&4ja=R@=9Ay0MT!Kri@(wn zmZ6^RWCx5aDA_p780s2{7uIH){aNWpp|%Z0Y6yc)}nlwVoov&X@_0dG{cMi~AvgoX@T&^Ty0xh!@o;C$r5 zs^1%?AiaO&y&*Cb`*vDSTPLI3TIHAzE@K;%Rw*{!RSN;n_)5S{~N-fPBg# zQqxa~;3M?w3o(Q zXnoznopS%;D&D*0Iw`y7>h)NRo7fWDM7>N=)86O20*W>5=Tcp^AK3B&VI0V${6(Ek6SrihS zCYwCqU4XM0W1fUxe#^FScCjacxQMQ3gU~pYfIYPv1d;Hr< zzv!VN5{KTC+X@)zMw;A@3tNDBqVc)nV%%Hp3^5WO$?FNLR*)JD&O$8oa-r zD_~W!aH)BgZ_g+#V#Rk$YoyD>NtW^R~m|r+~R&1?b`z zhZs(Tv93${f>hu2W~xM_!o5}MoF{BKkNFO=i3~Gbe$nt3Z1~`&c{=_GI<9Fl=8_!r z==j;I>w0*W_e)SOIUx;`a(^S5z#+xdD0I3AV%pGEggWjhO*O#z*nKYBjk>WxKAA4Ve z3;0=;xG8%)z`1r7E=&tv;0XFHC9JJg7+*_F6xwdL>pN zJ~>w<>!9>va(;NSFnoA2IlMBta5>xFAQwZPQ2i^Fbx9I@w2D5_rLP2I*5YfkN?qXp zBkC*znr#2KPe`cL2YAdptPj4beAA(NF$vB!f2&Ot0*WXE!`m9j1YKE z@Be-Oo|n8C*RGxMjpO)y`xRc%?4bE@?|fs_H0ylJ0r&#R9k0G^aDa{j@+ipNmv0uq z4$9tTPT6be44mA*@k`s=VOeStY~!3cvv(7UQv9h;)j+Shmy2%oylVwjY|~8;Z!Bw-T9xcEA&|UX{t{Hh7mdC=Y+^CP z7m8hC=k9g<&R-GH36Z3FXY@a<&!o8g+L$(D4(FDBN?xmBtYkv3McCK%3Pp zZ+m~wL)V71$U07I6a81TJ^NxEoBNZekhUcxUheo1u8AI&98QH2w_DBvEevi*W&-?H zci#2f{_bD&2O{Cy4XY4S0?EyjYPFY+_={EZN=N!&gQl`99IGkkdpO_ehh)d$$1arC zP!BY_edDS!SxK)W5T)ih>9~b$Q{T%i=_v-XI&}ERX{i?lYLr2GCu!j27-5rW9 zee&rTUc31k zq)-hL9pAn&1x~hX9sHSLF()mWk~wd)ba)|abAmdlR@DqB5Z)N`BRJx=aK+Q+_s078 z$wjr-nl%T9w>cC~y>0MILsB$C-Ua5y28C{@_n3yX$cVU!C^9Zt3~;2Hf4h!XWbFGM!<%d_DmG_<(wc2bx03;$3S^UBEvEoBM%;TwB#-e4YJc~P^U+d6)`H-?>|4h^GG&2~^D*Wm=$bHxi zlp>vntC^p)GyBEo!8VUOqr}J{{;UrNYaKz2VM|Y2QDz8h_Wl?(JDEGao6LK1Cn2pc zLF{V=h_Om{pg2CnQwRkYO!PAjH|Ap=y5NmQpbgo%shJ-<3rA=%za%(#ruw9(y>d3` zT~L$uAoudS&o>o;)v0uKTdcFli}fWi=EsaC@G3c{Fm>b-eQBI=vNNYqV%kF(?yS^H z*#GO@ftISv_OghPm&C3g*EMWq^NASE@j9Dd>RCk(-G2Y8gq({EmHppMPZMEl@ zZHuOxKLfg+lJHp%E7_fji85H{@?)z!qcT%Ie}b#NfVq;5Kn&)wX<0p0gu=a$YdI); z;E2FlMoQpAPzTl>Y;_pn2bDUr7pVQZH{_JfnXYNGzvsum(yaN!@tUJUWqp(iJ)F`i zrQ1Rf6_CzzT?!Ei)qgwE@<81 z%Z`xSHdIs$_B%|s@c&Ag!jB$%&CqlC2-nfut^IuPwuA0gA*k%3C)=3C#%CnoRLX`Y zW-g`?Ocxp6Ig+cD=VMQ*xMjDKgL1SYA2j{hI$HfT#J|meLOsT#CiaRFU=V2AO8gg$ zmzl6!^LC>VV!L8hH?Amd;ZL97J|C`LW6@SNc(DrlkJop#{i+f}kY)GHh=(trNyYR3eAb;e!T4LeBMba6s9s)Y z7Q;u&?^7ppMCeZ1!HJJTca_ER5hLM#1!iUObS-J!hCp5Ui04Ags$+%Cuj z$cA^dGem=<)0SHrj^SmWerNUYF`)~FbEaFaCf+MHy#K57vG@Jz+u;i|bMMwJVy>Q~ zMkS_#7Y+kjq-~SNC5Q3;>nVf8&CMY!1{D8%A}GT}BJ;shfzHh%c+?Ax>8-TiSxqvW z(8YUr5UYDRbCFQ_3a0fX3^n@`&k)1AM{u0IQ;gOza6q%U8yNL7Llqqqo41E-_hL23k8fi##W`Wb}bYiAX}O@d;OMXbKc=xFkIvSooS+m7z}< z2zVED#FKyH-Wo5dqeM2Euiz8#5GPn$UKK+LSnK7J82)4zVXuksDsl&S8 z{RA%ke=h~Cx2&D7VY+|l3O?6v6@!D>eF!tXP2WN5n;ujpQJ=Zeit8Hfum$6U4(PXSTB55Hd03X0_1BZM2;^tR! z2JgoedC+%*Dz7lw;0Aot{j^uPJZ&nrHqV|4^cm&LEmymcu~i#&B7OrhyF*Eq`1DJii8oi75t!X0j_a%nzZaa$yoRgV z2)UUmL;Qn1;y6c(a7C9A0=PQ(Axp;+y$# z9EU&fRPV>j?ZMjHc6=T$QhQF|w~cwnFD|cyolYxEcqOEr=H1qT18L^vwwtfT&jf}> z!N`Itv&{k>;sN;^>Yojx@ln`0K!Z7z@sU*KCB%Hy`uq^fD>-r;ci`>)7;?{zBFOGsH-VRQ-H9@n@tId+~A00yO;GmzwU(!OkI_QZeljZ>Qf9W9m^y^KolVwUpF>6 zzKIx;PUczfQIL%Xzc=EXPb!%qWeT)n&_%k9l_jl*dtTFS2CUn}j=Q$w@BfBT-Gn9t zMfk_qWmQ#}hQB7*#geiz_jbf){6By?4MJe>jSxJH)f6gMYQvDX`mu(6*PB-wt~LWY z9b!7oMtSUYPdwR-IRl~vu8hn|lxXTxZ9%eA&{eGhX+oWP1p}afkWsgfFWIO=>x(M1 z7^@5{A7Ibwc{;yQ?}=s6-(y+pj~V$oGLoJmMdn)b`1oiY-Nn0^AS8G50~HQ4 zcKe?m)G(KI>K&#)bZ4{_ZX(;TB1_y1YD#R@ZdTV+IsNg{MC~_R!Mkz{Tj1i*BO{9&C?5*1$ioTRy zPY}ZjZbf&i2s<;(tZd&N5Iy^lfr@~70PTW2(IlM(-ky@$=J|;4=^bbz5j%9rDas@= zD{g=sAZs9Bc&*`u&g~g8w^Yj9*`7hzxqW`s8~XI3Wj*f;@Ahx%1ns5JoB94Z|MPi! zAERIiJn+PS+(9r~HuAJ@pl|nf03M|L+M9g}oZX~}77{qVdkfuwoJmiei*E_NEa_V$eGOnSb{IU!)JjuVQ zkNv{MAuDD*8$QE~vN_JA2`Bt!3h#W0v^6Ck;u{dknZTB$Wwzdd&kX{{l|Ubv zheD_fKoyb#<&uf0Wba%?6sb|!F{4ON8oe_I9pyV!k ze$L2P8tWurL9!4-_c}qa<249(Dru+Im20tUdT=C#41f=;#9BbAXNxI0bGp??X()%( zTklfBG=ziPL!l|S6^9ny6m~EJznnPh9802N)d=eI^4D9pEI?-DIx1{L$aZc6%z(Me zVjV@1Tng^Bydn7wgwokY2(?to{x;5VbtLIb{ds@c?4Pcf^knNu$1_IMBlbxTGBMJ~ zpmTyFO$=rSjrQ74RQ?;6stjXF{*Ad>c+#`r-A|CUPXg z^W?E}Nqt2Hy{&AQ6UB6W*^B>`wP3U(%#5Su?le>QPFR%v{O;`R%NT|wcp(WduF8_*W39&63Wv}-ZtGuE zkr@J+1aAvncBfY40N*evksCJXKe zzrB<}Z-sKFaZSD^SdT!!NMez10T_;|p~XhrDRFN>w)!RH0y!oVNaVOb_AWhTTdkr* z{Cagf_jC|ZQKk_y)yWr<+sn;OIK2N58HLtB{C3mD$KX`?bsF=rt8Q1*A-{>2%47{x zMC$Pt3_$O-e}`R+g-%cL3G4Q%%Sne>Z;VpnlXw0m&2lk3;e}1VC#P~hzFRz2|M_rc zAquUtEcZB4`F7J1g3h=hy=T7Ky2pMbs5a?2fufnhU*SizE<$<>XPxWf$6$s%sA+|% z>dhcVWUt#H{dY647#l#JE&XqaR-5?d}v zad&}=leRGng;q1`tX+6iIBwjM)J5ApA6#x|o1W2zk6f&U{{$Z&FQAI4^qJiVFrK{UupWQdCVsN$o!eYxPKSs7Ib|Z}#(~mZ8c|lF#sApG!>qu# zd1FYk&zgWd=0);3*XW<~T*O`a`Sd5RYB}!eDRHB*@I`Gg13ud&qt?>wrHi-nf~48j z>se>o+we>H=w$WSU(r;tm;ZG>nE`FXb-c8nrDV-F+=9nFrs0Lp$4d1E?d(md*()k= z*3~e%MLI(CVx+*)W%B0N%&x*?j>(n)%OK|~!g=P-lflCw3P?t(XUW|K2Y05JbXxvs zA?Yr(2e>wrdGDvaGFHRoS8%0|lIZBEgzH8?*B1EKX5ur2JvMCumjvj1I$ncxw{uB$W?u>Q@B2)7>2dqL05MCXx6_E%BADGY{U5vQq(caEZ<+hCZEj=FI<5CF z`V(`?*L`UW{Y7{vy!>4CjUhkkqFSp}TB|}fF+;b(TM!i~$K}h(BdIignD(DdBEvGu zEu)#rwHtizE1c822^DPumyNM67-Pd|0&CG?%CAgc&gylT6k}9mdN(8~i5!J^E?f{( z%PXDYw$8A8To(CC8TBO=POjGoIW@C$5YmWd52bTbB#hD-Y*2JajAuMmirS*bN~p-pLnEyZ6?vwGKFymnV& zN$l_nMf(8!w;r$T%xAEW3%B_}{Ep_v%KQyDH2k#e#S16VL>=BfWSee#(?>nO8NQ z;9v!$i=5U__g_!ae|W=PASuyk;`W|XDb zanaL1M;*Q?(`U5TH`O`*nuOPWK0#9V_*P=#1a3*-eVnL_&Bo#rej?iPN^JQGsc!v< z`?hRFcXx%lcyt-ZIRa0dd&D@7uF~YD#)*5lWq$R%$XC>L()sZTQ*8Qh?J~(m=#HHc zLCe%w0;nNz0enFj(n&n-+`rs!6mSkjfSF{(e2ECg&_v3kp#*)7zsTK`@nQfSgNVQH zv?X44)IA)3@j3JJlu>IDtA$6+3pzwYAl3>JqwBs`B}aylTJ%>jQd>v(|q)L}5hBulSU1P#RQ-Tb<{{TGM|VOtmS1 zK2Wwc9HIegB{ol8lX$ug1`y&nKj?cItX>lIYa`{2z&;Q+(+}I=F|0XB_nCjM2fH@+5FE;uyRvi*k~!rf=XSe zDD7~`kgL#F1Xao2SeA!%w=SG;J6iZZ>kbqNE$TY4vkxv9otqW!h3P@>Wjkbu#}+6C zModSFvLVzBVr{7p9v0B~{u&wvtr9@)Z89AD`D!=r|J#!%DK#12G|-+FcgxOzla=_u z>e9nv3a^n8s*%)JY9zqZ%)F>G(f6n#;sx8XKz{5S`V8%!M#&4?7bhNOWgS-AEY2$qef)oKRG(cS58F<(W%O;ulligg?S{TD2W7%r5zsV+!kX* zN}{LW*rs?_B;|J88>MuSxv?WNUvn?q*cIL0h|jVjo<^DRCZtV;!FGjNk6$^BWtm7l zpp8Z}F39@fz7dDNbtC>THbpU5yS38o?>?hN@%~Y}KT(!o9R2@JA0psUW5F$d4{p8- zZD(h?k9{5EizR0|G9uWSpr@paUsAdGTx{{hgq3)HbzrWXqhKFLGODLA^-#~H;WaYf zM>X27PfHp^FHgYo$e9oc+!{-4a_#Pzdqd%BZy@5?vJDxop3#%XBI1%CxRj8<%(Nco zKcCXiwUlvFPG+^R5rtCzd3j1W`RSMAyyWd(Tzybudy&AF-;`1S*>C-L&(&S{PDbsR3OczS}N3Tf-RvtQAE9mk&?^LVs}wlXib>sg2&u*+T4#! zXLGe$^%y6!Wx>2+Dz)tCB@u2eHuJ8iXmejaozrW3QV3RB{ZZfZ;)(!U)#B3XfM9lV zGli7+jkmSce1{PM+GF=NF-Zo&k{9qv!n@c~_;wlDC7kVHg{<{f64Edax{g%h8HLx$ z$M+Cv?wI5-7-j3A0&}ANnRnG_FlgI?;(k(xy1i697;p+Cr5t7bbmI*ab&zD0TJ3tOGPV|rRnFjjV$6*e+SRbTI5tAHY0v+MR% zEmYWmxT5bMa~e?ZTBGXQ!PdS&Jl%RF`Bt2Bch~2+XuqonO#I!$f2j_chV>QC>K!L2 zLjYzy_opU@hSibKh+ssUjJWXj@p(z;-gYMmO<8C#yZ!y2o%Q%^Vb&rQWrRNY@55?2 zPX=irZfr|7RM2&Y=6H|q{Wnb&5b8WPPRZ9->J6N(@!2y5Z_&blYIa*|1!%fx0kOb_`vPSea#|u6DCS{M-H@*s!-8 z18+M_2fsCfu?plt$vJ`StPu1tQa$Mb-mEgTYx{)tRTC%Y!Aq`3&R#q^Y|VFkLxvx? z=hYXr1}SsNEC2pKYqn2%?}Fy_a0uAJLHQ+|^Sq@uF{3boT&j-Y3XavcSOAK&f1(t> zksx=_m4jVrBtqRDL&+}g%{@3$ch!*%@>@K8*1@-JaKWWV18zWtSFdR^G-BxOxvSO?uK$ zRjqJhB;tyQ9q%K#idwHMOzF-?eCRM}4uE3|l!j9hJ|WzKlO%XIqCIy$@j~Nk-zsu&|MHIL1Ud~Y=jTeMMDaink}3`0QVFe z1x)k$%QZGw2KVGdLx0sSSj>+uIen^#7M)>p) z&2$jInUZw(f$n({X^ag3e(I9fI)*wl%5tTIj<5EXQLy2j&c?|0c%17wteQAwNL^-q-&p5zM`{c*3crx zxt=wDC?@_`t}+gj(+|^uezBjB2GaH?7n}Jh6S31(5~F?>A>&veah3z6BKa&K5WqdW z`ttz$4g)D`;pDR;#UZ5&*4(%hd*lFF*Y`xu)Dc1Y|E>=J7&I&V_I1TX=!Ge0BjQ6( zWbqHj!7`2>4*;5yLAW6FOHB+Nd*X)#eb7j|hmb<|*qA$8_6KmgJ-PvLQxKhIfB#53 z^tS?jX)ChGUT=hhy?Iq^mo+vel1Sq1;173uvsu@3 z?83?&L@zS-#*+(?a8$^fxcvHXK8WqqSipw@?SCO5eZgY(@taPl76Q#GA*+x~m65h! zmX~PIOJ++5$BG<(-~`M_uDBP1@z{HxR-@R5s|i?3vUr;jB$E_8xuGyoPB!ffA9A55 zu`JqerMmN4h*UTnOYTLi z!a4{TNk`>;I=Yy|j9}J}3FS`k;q#hNDXlf%boh0nU+!~QVu0RAcs33@P9s*@47?|_ zoi#$PGHJsOe&a*N3#-Yxc^OoYH$4g?pC9{vqxGuw-6gmo_(uJkjh&V#ma%*I%N{-A z@uykzn9@?i<|BdXP_+Jt&q>;xtUm4xmiRZ|bAR=&{8KTjo!NcJaOgLD-F>CIUdAJU zR(qk&e!f~%8zaB*>zsUZ(jf9^$Z)K?LloDweGB(5Tf_@EKpYpPR>&Qyb(FHwbfNsz zB_?#Q2nS%6*_Uv$(t(jb(r~NyaenXFa2!bmo|>hY)5qul0;y^z5!+QD5e!hLF1qL2 zDWDk=zCqpvk}Nnc!u_`OrGB=-mmNg`frw&HrlSEV6s^)7Yl_>J_ULfc7UQ*f*i1m( z=j8SiB)#0Act8NnKLK~{v3f;;jtL)^u==A{u#!8Fn2=*k?_5FA8Vgi{7;Rv{w)T=! zr+8)C^(io{9N&oKC(@*I`o)TNk8&|lH1VO|!;3+P9k=!99c|sIn-k0*KgEK!G?ih} zq6vGX?9mKB!$@1>QZjX0dctT>C^yZ&tBS++Rtp4NkoGKc&nlLDXGBI; z_uJ)U&?C)DSW@H4Xu=m@X`ZRwY6#^(;y2l61KL26_&gePw@O!uv}?5WEQv1uxWm#Vq2Ckg?(6%PVYg2Vln ze)wG>u#NeQEkT-?QTf+e+5PXmvo$i!Yn4J_eaC9wazE1glyZ1J`_Xj0wECcI1CNj0 zJ`oohVgp`wwoUEF29ftJ6ng^D-!`n6Nu_0`w2;EdoYEAbk)~{b9#X&XTqET=d8g>R zMrrnLAdd-R>Yhm<2Vx~Kk>HB-;3aF$!$o5>16r92wcd&tTXwRKwTqiHcrvV3$6I8r zDQgdzA+#s{5P?ES{A7YU6Plz-B$=tmrgf6D9(V^us7R!(bo>HXC-ae*@=N)n}2*6Xn6Rembhe4!pzB=D)AP5 zRC8B-Y4`F>j$LlOxl9-3aJ44%BvgR|dgRSqtRPNw+_Cb2-D&(i*GoXj>+`$&uHF^@ zWl_cv;DKBZ#{1hV|5sC)q*d+AiX$@gW*tQEAa%k<=B*?b|NP!&zN3`k(7bpLw^3;# zuf5X#9i4!@UHm`NATL7xcir+j->)7ITkrze;91UhGZq`_9rK{Od&C?Y!YGUv_DATd zR44ylixP=DsawA=u33F#L=Si02>neH;QaLxu5F?AG!?<32kH$ZVY6$<9Q955P>flc z;MtovTOg4kRqTL9K%R-bn9dh_?W!wUn+Gfj}$izKi47 zxR?*HP){&E$1wn!L}SZb@46WGc>HbNI{5XE|6!kM*+%B~1@aT>bKn~(!1(2yFKxNW@8zxbDfF@zkyQxcKyYxu$187P@y-&yf47twUg%K?gR%)B*q&>GvOH(htMruY z=p&EKwe%JyS~5O4e1k6|M;hJ3Cme8g>uTFJffnH+9M z$8=`YWt*S~xnUSgklrfDxs4e@ssh7P+vZzdYaZx3KaXrZq0aI@ux0+k-dZ$dl%(ux zb24Ty(6%$`8P-M3R+2_Q#;?76b}4H#YE!Zq$n(*{nIO^PS$PVroTIuGmuLAbU9*pB zg1<+hAs+$6fna37qL5LRj{I0nd#XaVbIfo4Yf-P_$iBC-w7;{sO8^}gEOV5L%_0!M zdtKfU$&M%xRXtPt{C06k1sA87=G5D=WVnm&xYY+TD5xJ#px=yC3W;Q~5p!=5>d0}T z=?=?Fl1jp`?$*hrPREA)3)iZp7ztsMU944U;4@^h@X;wN^3CKNhiU|>N#S;J0N9ff z9dC2k3NO62x91NmEU$4%g39%U$Not^eK>6VpH(BZ$o!`+V~Gw7v- zb#dx0d9|zYY%=;V!8Xdh>KZ}?pjcN6S3rpnyb_mFg`q(9gUA#YvM)seArvEa`qOk4 ztR{%QaT+v!5Ekrz#Viw@ma^@zkx>tMgedA3?;z-i-1&$S?Ce3*}T{|e;S^h+-0Y#FFsFK zSh8yY7&m&ix#l9-33#U~kSDvKFe_1~hU}xYGxNw0e;EfwM2DS7;G*8Q&nulxxjQ&H zN<*DVl84{0K*44J6U61yX4FcGIDzigsYH&Q#Qyh2nfqDCKDy8!)CN3^EYABng>;df zzDG)r02v^3Mb(F!d?u=)f)xDqJ-WX!$@) zfY6;_I;b$mT4u=n%*vwgzAW@*UFVp^HeSw-tmc?W=m%z`Dqzvb5I~$CkwK`L?VN7% zVPOEuR#b1-L$Wi=jp?YUlLy=-yZ);e3I5P9RLyVu{6Stair5B<;~8Cy1^2c}``Pof=ksQ0*V0ge^?u)Oy59FY*Qs!X9nc^Y+Y3q{cO&6sv z4--ROpmt7T9~rAA^1 z$q8wojPtU}^T5_l5Hn?5lD7CdO~-ENZt%*)Qz0MGli+s`qvCf15`in(2O?hrZ$a@F%^mrUR(I$CNoFU2Q zSqEb^AePRRp9~B4>TVw_?-zG~aV=R3q8g7`i=Weo!CkUc;QbXBq>h9`y2~od5o7;w zrxa1e9SR9t$Frg5caslJ!9e!=!iT1Dk{+$m zFTy|E;9p5o_1F`;34!dgX=f#XZQ+DvDxRXA5*O|g*urtGS*#ew+%Rx8EFwNC)RSq= zxG3244`xm7UdNy501>IdivwJ|7$F=Pay`mz&U~Rbe^B#bemWQaK_d6X6oPQbrNVSZ zSs{K5kJ>+FE~e11h^;c=34GhcJ>wZyzLJwI1yugc=*2!OsmS!(A2}Pzwg{H{(DO4l z-AAN?lnZ(`ylZ7L#K&z+3OZPkte%Fm9uqN02Fg)aG36xQa;C;3Iq3q+7fN%VZa6B? zeq~s9trh;kddz8|{&o!NnHEW#h9WQlk$& z=7ycKPCDhrEqSPOq~aD32gbs5jb|0}*>-FTxDN6@Q8|z#Y8XerjN>}3Kj5ReL`n?g z)OKP-sDu3MtK+&SinEVY+{ZyAOMs3XUb3kGDnh;z>%{m&Z}9`^YtYa>>2xY4)iKJ5 z7GyV@PK=~f-FvK8wL|>Ci_ABEF~?|E$US(Dv|P-z?-RwlHswmN$F6CIma0p{=lVM; z*spk{A2f~AFs=tX0-Hk2bDD4W;1DO`pI=?G423T3+1vkcbh(lXg*jGSIsgio(7zSj zSU$jUkj*!?RLR=cLBJT!yVR@j?p&_+It%rj=wbYxo18t(Uo0l_`5f~=aEay|8uo4B zri}sHU^zCNz3TmuPL-Y&nQ6q)R21{WRP5lxCZp0%8`f~T75sS)E3GrC{E{U9kanLT z*ky+uT*?Hh-9&?QW5R^KDTTCf@(s^Xn;ZE40>hO;E*0u4DqC0Ux~}|sgtQf;Iexdn z@QY^0SA`KDtXUJHD(#0hfpKKG(YlEhqjAek{Y$sd59LY)SZgjhjK*gow6^hAY5nL; zm4fYX$uyKgc<0Of+!$B*$d5?G5wPd5Ct$r{!SN-4ik8M-peHXb2FD2Nk0kVvs42v) zyOFlRwU~3>uj{#u7uQ8=`w1>A}W94;zFZ+tz_vJ@I9;*xzY_SBP|_mZvvV z42+{kc1BUyI?@kSTR~HcLi3J}?T$`35jh2Mo&@fA>9-b7EYNQ$wws8<&Ng{h4Eu>S z;OF8`@WTG*#XGwRhxixS4PQUu%rBih6n!#&Wl#P_1NXNy%KXc(Q`#D?Seo-ce?*Mx z2-4}M$G@S^tg+2Fgymg9Pb2|IaP)YF2|gaj#R*um+^gF0zpo56Z;dxpZy2lK8;bU& zPo$Fo>h%!g&eLxf=FNPG!kgmS5eOk@iF{|~#pJ}Sj{WG`Zvl&f=k8>#E3;)_xocs4yvCH7ISByc(@$# zlrVrOV1cm_6$%38FmTSvKwh^^KB9=^Pu3;6w=4ck6&m;&fuCL+#Sz zYgXJZx=($7#VPq18nBn|EBD`o&x~@rj4b(B7@lCUQ$4uZ;X+d2a<=UROkc>{)N^4bF zf`gGtZL!hE3=-%$pJJshF%(OLe9Byc*0jrqk`^fzV_r~A=*fS(_>U|8to?`c^gxuJ3i5_B15d=GEY!Rt@=;Ngqn!`1Cr z(g38=+`0*>==3Gn7(?&o0Hmmp$T|$Qx_iS5lw5_$-|-{rgk&ZTuQnhA?XBJBtwZ#l zK>u@RkV)T?O#AHlE0Z`bL*_M>B4%~QwOhyb)L>Q_DMWt9vrMWu_}gajS~0aoj-+o1 zd4-tluC#x{%vcH@fLW6QjWfLSxBhT7HMzQjhifrfd!mZv^CzL0+icd{p75UN%kI-| zt;44Ik7ov_qkr^{-7wU0-wY+CWiGNCYl#rn-(w!Fw*%a=&X?;5bJ%0O3*68F+&rKHrmFMNJI&eZ2`$Dve`>kzxC z{UR1WkXR_@-ELe#Ua9nAS*3Ttgx!@*Olw2Rh_z%*(b3}Mhhf@|4#eXg&0x!KA<;I? zY%Y8iYQgrN>m13)8kM9Gk&HSFk;LDx(wK~`I4@2}dQ_fe1IK;K3)|6f+|h;5{i%(= zaEChY$?8YvHe1MC??OjWt<1Bn$?7s_Y|@RN&ALOAE{}hoYb*4OM=&m3r%;<|>Cj6r zL%nr&Ya4LG9yOqYQ^&$D<9PYn>i_aPj=JHKrxi$(IhP7c={o@}HKkpzoRX=-8M*K} zY-x^dPt=2#dV^@4*|&b$emI{YZA!k{Wjnl|c^|*8lyEbxlW6 z=Lq-i2b_!X#`^TMt0JggxN!Xi5u4-#iIpUJW$$NoHo@wt6ZAj$9#q9F8;nWdVzWto z|CN%kHRW#z1|Zs!{MOq$+8FvsPcp_NAh({gGuK$fElHUt{5W;v93IrFP(@Hsv- zY{2#5LwKxcS=o3RT@XI+OO*}Ybl3K~tpOMrjO!!RsWCnZ?v zd?4X*ma~!Nn|Yr%R#ZdaugxG}>Xf$%yae&LV^8V5qLot^TMF}ehZtGD+P)38lpjxZ zAHIz2?Cz2%^J}TAJAe{&5yYo&-Ea8|b`a1Fh|FJS-a>8F`Pd%cOFmBUfe?obJlr^t z;Ht23hk{+(sGR*F!PiIIycEVh_~^TDXY6z!kP^Kxg+aE72XN z#>e&v#=G7iN0-`5pxKt)iHK;Y*27%iEljiN_We!W95W-Y(EF=coJWrX+c_*MKU7Pv zObA(1gOYoHP}pQ;$(7WSUt#5>wqZj}UU^XAeIKkJn@}l(t%@BqR4ys3*UD_6Gsmu{ ze?>3hhmV_dxqek>L-7;0(Fs&JaLC z^Xy(5E&ynFWxY++Pg@ir@&D0k(P zZJPau>MT2{OfLM(*0HrM+M?f<<~fUw4h0jVEBuXUVe+VWHoo`qz<2n`lk2&IFkA5W zP0{R29c@3lznjxw0W`~1UgZ_3&EExhb@F4Do^%YYtzWHkiT7Qa1et8bOcgu#>xmgP z?VSh@23lQOd=jnAM<`*e_Bfr~=X%&MmiMKz$dcX1Y64!-y9p<&Prf`FuRRHz(%<5) z(hRh8_=>JfJET|Ieyt$hxXRlv;9On613W)m7uAhsLy zOl+9S9Y9Aq$AI_O ztWbzpf!VpywrsEG-B@9HZ>3k?4Olglb8!Y(6*E{~PmQydFvMZ=ftBpl#`JT)3&XWK(_A4TwguZ_CQn@oEE`oGw?_k29Wli+>{r4+g86(WFkp=Xl zdQ03Jh}t=!1<^+$v`GK{gLqoZ+oTA(!tk`Y8i8%VI*e)&`co&lfCpUZ%oWb3ov}@0{M1{H;x4bnOa1 z-!q?Nh`##M9Wqe5S9G*rm5CSsP(>!BfdnkJwnB)Wt`o&grSM5h`}bUZqC%hU$NXMv zBSQ13CHTJwa!=R@X0fkxjIs^5n%YE#ck^7uL9km8_4Dg(fiDmH6>cf)x-qN`+|)ml z0n^H5Z)67wUYu+VJKdROKAWFTHRs=rv@!bgxXYCr4338#T==uJKK?6(aDN5tv7()w zpQuIdv)*yvYi8K3LW1UB;N{RgHJMMZC3fX@yi*$c+u91t5w+B$#Mmr>DwDUroV>B} z#}^+-Y?-0Jt@A}t$Ek`C;#QgVhK0C~etUhjBOM=p%2zNq**k*mthyxxFkWS1LUKP; zhGUM-;{8f><1koY2p`fw{SC6S|dW z%4siJ88-Y5-jHrac7Jf6IMKfe8|VuuxPnyziOu z$iF6FtNqDo%!uJ!$zRV|3hE@WqHglqsdz2kjtgN;MZNyw%q~yvJf9qmIKE<-jbIr+ zY!3UCQ{}Mbv%0p55s)1BJN}8BlB_ul?ExBs5`H!^s)|+?XE$(DHg&P|M-IvC@vfW z-}@_s1ui7qZRz8m>o)n2vlNS$J}$j0f=dhz&n<#3iyZrB#SI4xs}JA zCsXfnRB|W5r$b?6k2&bj^n`zKAbwK_;q{#H&!5(b*xeSZXJ@`uTgSw0l8e=r$gL4A zU#F-A&udUUGaeiIR0H(HNGXvw8TPq933FM}c_|i)sNLTVM)>I2ubi=KX!Br6=h61( z!gre~mnBK$bZ)g4QIaGEO~%tsvf3x<^lOx)9+Z}j91StH;S=HIhVNPug8{>B6* zyKl|OF&WRn1&7i@C8PIlRXM>P#Y-zet)Ig>Lt#sI*l=uVpj(giGV${NA4yjo7Io7_ z=~B8ImhSF`r9n~<>F#cjlvGlXZkF!u5Rj1W4(aaB@8|pd%frJiGk5Nsd(N4AE8wPP zZttkFH#7bpdFVI>x}BpN0<7N(>7P|he4Ycnghdp zpI@G{G-qYM01_l^P24*w$*q&!&SXH?0%|!rIq>W-Ys`kkP<+nVFLN-doC-8;O419a zi%9H(fV$tQ12y9=$+mB4VNh}q*uITok8iC8n|jk%X>2r7!S-FNPhVp*Lw~V(EK)D~ zvz*tP9d)}O(AYtlN9`H*9Cbq%m>dmUE_61!t24rZ0bZX;|6?D&=I4k{gsAc?yWaL8*yM2B`1A)?z6c1u6cTVW17@l;SS zpk#om#>O=*q0FAhMuboBqvU2I?5Jcx$&#FAk5G<(Q%xfp%kTQ*PPq^Y0f)Kdh`8mY z9+|#|!5o+TC4X)3!VSzE&kPq^*3?L)wkK24``>uqu&4BxGk*M|T(f=&T= zc_@VKeSTa^5}86Ko;T#6IWLlorry>FEARb`gE=-k*x`us%-ooD-Geh5-w9$0tjy2O<} z%Hfq7=7qCkDeEq*XpZu)NrM_Hb4sBMtVYMhu`@zuIDO=$$Pko)6%pB}OY+Ar8PB}? zD@pKRV8`u3+7xZJz0_KTH1)n?wW-Q#yuLB#38NXPCbOZ$6cb;q2>N5=TLZOYO~lqg zOJycv=gr2{Zc44=&KWOI!0ch52qNxY4?+i;WJNu13F1Bs)WO`ps*xo8D?*nr84q0m zLJ28Ou5TJ3kgBhN#*WqGUcl6zcM-Q*u8VU&ikAL(?W{Dvkg2KA@W^F`L?~AUGJvO= z#;DUc1(EK<6z267qki3gGC6`Z)ZW~UP_wpVgEEwQ?Q)Pfgw=)r`OQ|NiUZ(#i4R`t}odue!Ugf9P-EXG(0b}ke)K6Ti(67L(@MLPM6w$8>X}xT5 z&~7Lob%l5&J-(YISUc%FN2aaHRaxp{9nCzGuRbCt0{-MCc6(D9Pem*#5_Z> zDtw^BF>z5QR$}07*Z$GJ*V^c8Q9qWPvL07)-DH`zp@6F09Z-)XPBjoQT?hIa0LPbI zo(PIWl(S3ZwEy!L`l&>H zV=n{(r7Pjv8tLL=>JA`cg?j6vlFUQlV?`@KKo;+*09*IGYAf(S!WzIKm5xIRxBb9> ziTkhHW~9@J1B)@>*}~&2R@xWzW(H;%uP>Z3>|GBjEjcd}-_}CI6SIz$GrAs4(R2LC z3YuwK?fPCOnmsLYHB$xSf1(2{}Z4}^nSj7Z)cib8q>&;}`(qAL)*W21ZU zj0PgY{Z4U{_fNo=#ZG^1Ly4?zVJPzpB2FwWi`MnzteG4o1?L~?2C^{zbQK1jwl~;U zfVSI{!uxIV7p zi*GHna-^DH8B_qDH|#tXPvSz@jF`I&f&_jEwAa_KmgAki*cDU&hxg7F)3V2JbU{Yj z>|F5c!c3il5zsz%VdDd^o;!>nUc40PYx!r@jr2bi9PyNL_<6UNz(aAZvfQk`GpmXd z24`RF$*aFaKS|Iz<`J7qiWyhBiW!BGkkHp^Ue!JQJJ_t(y9O*B&nq?58(Q0acS}^L z!U(Mg3624m=N={I9C4g4FKP=J0w^LJ)R37?+2EQ@<(?v|hd2wh_UcWHC0(BV3Q)fd|e&X729qXIr;|hMY0r|^XBBppiPBpX27_^G0~-61Q|~7lR2rpJf9v_ z)^I>RboFP>&!DYgt*Y{kQeER_dD5Uh=Sm?t z4B6V(^h@r8Nd5T8UHU(<9Mg~isxcdDoezYL%o%r!))DD0iAp&?(c!TikJG>xL{pK8 zQUcOmd#SP-J>fHb163ov5V*F3_Jl}W z*KwYvLQU6WD|d;#JSt-V)8`A!rA#3Sc4mLhH~HX9tA+`8-boCv!4RE4bDatZaB|e8 zGvaSB;AVH35p5K#V3DrBhK4i}LTZztkMAJ}y?O!IEDuO3nAoAcjurI3n8k%RX50Gn5D4lR+pY zL$`LFr_lH$$2qsZ5zv}Mwbe?vFsI3y4E01n8}A{%rBQ!0KAL=Cfe7*m$X)j-=STOw z7_2AOhvNbOt8~D9{r4?u~N-Cd#(cw83NSQOogo&<-)DT^LPFK-(-5@4QQ( zSP^{F`nOoJ(F2t$=|A}B)Tmh;I!^o2Y)@ouPS8XawbZKMBQB7-fq6}0v96v%q$S?O z5$~(Dsp4arX1WilHmJ-GIbaCP#T{i|Wd&`z+C-{EWKHrmr5jB5_d7mXVlBIvpsM4$ zl~-J&QvU>6AzeP{Sid@0%l8MprfZQAK>;b`r;9U*jlcb4uibJ3{9H)m2+!UwU#YQ>lUqSRJH* zt`dpF$2wW^#8%*PKtTk?0lCJHkA50wZpx~y0HvJZ-541qSxzO_Pvm~|%kn6l7b@hY zArFGY&_O%WUldsv)tBaIy!AXIS+dP|e{_a>9T_@(`G z`{YfdwAqqFm1n(+gW_z1VK~|C2jN`>K+G#^Hg!O@13Hb{BuezB%B7h*Qir;bV_8Rd zD^#lg5``Ddd|KO7_GRqJd?jJ@zO&MW&yXt?{RkTu@s-b|9vAb#o=gOIaK(jnk{J`h zg4lA48!hl6`avP? z7QA+*1GZg9!?KbVf?z1tQ@3)b=@&&uGW1?Q=vykK+ z3pJ04s8iV%zb|TU#Dlm{Ox+@Tl=5J5%KyDGkrD+T3?$vEJsPbS$zc?+3t~&?)93#> z2%Q^XKgr3cQ*Rx4U^UXH9vEB!-Neajvc#6_k3DBRzQ}zxLRAl_N5^zP{jo04BG~2X z;!f}76OQ3p>R(Q0;<={LQ5%QYJUi&Ca?SX$3M+?Ov_k_qolNfVuMu@L>zbMwAEm$J)E@xgg+{dbfZJQD3`qR_jxewj3&~cX!ojPT#%C6V#g}(->%TKAll6f7Kg&>Bk7CEy@uFVghVhL*M zt@YDNmlXw;W=3Qh*U_Ct-K6!}t$X4fnlg&J%Va@v7n|LRnCe~F*a9Et*G^nLH6>Ld z6O9M`coW~o*L=g9oU<2SvyF@7j}l7TmAHlH;h-FWWsLJ1)PZN~bD?=R{y!#XQRzgoxPt z0&o;Q+DrVg$o|M7Rs8*urZ)FZCxT=h$H#|mpr%DFH^AIFV z5cnRa*JFjDJClC!<}YS38daRg!|%qePP0RE2XV9BfrP=7ZU5l))T;x^fI{Q4}5Z z6UDrWDknHgYTtGQjjWSsr|??)6N3C2w_bbB(r2|(d~%N__sFCt`r7)0Pi3$Xe$%5) zolzookNhML-AdJuT*PmWlrv@xpX98YNqI{srumJwp>4&vc?D@&JD-eFGR8Bvg|VL^ z(TF&QW^N+&6gn4xNuHSO=Cd2{3%X96E9jW*CZwBp>X?(+*xP^3_;$EtL7~2g-8!+i z8m3+vs~9?pv5mPe)URDQkTaBXZuw>0V3j#-AQ1%p&(5kjTl~DP|X@ z5c=2)I<=fMx2YNJ6kT_Gx2Ryt_Z9bMR2XKKU~mx07qzK?dKj?1TKF2!o#_2CmaaPJaz8TqdZU9CKI(Cq2VFrUiD^t%C2# zTITSGb|%{i&PhhPh9_Vt%v@F-?+&$0zvCiN;|(Rn#Q21bsbn>LkLH(RP($yRv1)Ad zsbgP`4H$e-UBbw{9sao}NX$>23j1Qgf7v-8m`K6UB#V_se>KQpibd)}rLpvQmG_Z2 zL;kQ*#ri>tv;2gQv98skpKt>Dva@VnBn5GF_!>JpeIw~z=$B4Ky8#iz2j%;OkN$5% z!>TNK0p#UU^MB}^Tl(TWA2&mY|87eGvFx%TJX{;*la>6lS|^DIVU>a$$7>yk=?IL! z^uIne8%6|2-#s2W;4ghZ27T^SH=@KnVVB6nqtD4y@DX=a*gw+>hU!AbZ2y3hXejSf zk>PmLi5?%%S?2(BNix;78_*AV&j?Vr^#t&hj=QG#S?EeO`|xrX`zLok)aN|Q&V~qh z+ls2A?!xa{+~zEF%()b7ujE&9Ux`>#{2sO)_&NJhVl>AseQn?};rA?_#c=L{Q{AQ1 zXS#gJpL7L<7+s`LmzeKdhaTNkV0F33IZiGq;f_)=$hGuiEuyreErxsBfb%I>!Ht!H zwbW=TV%bp2oR$Hy;lQ!X#_?1sL0yB|@|FC+(XtxWE7ia7`hk7>z3C0aD^ZOnTj0`r^2> z9OhW43#5Dez?gL6Zj^RgA#L=SwxXr1q(_F{!LYR$3x~JLbRfyc4=W>}{YAgoZEdIygdzO4!hcD>2HSV7{TnQ4Mz{P$oi?rKZpu{}>vPDwZHRRQ?vD zK|&cn%Y1C-`;AIt{C!O zi*I3q+D%$X*H-=AqMz&k%EMWgM_a8hOO@(aJOxP=r#}CXY_tO6#MXY`MO1yMFvQ?c zIrA!0&yV>=S=VRLFx#dV1v$TVHL&tTw2u2R;EB5Kud3zmdD1&|H?|qG#l-MR^G~U3 z_Kg~0ry5;d+um{|zO>Uj(<`3I32TLaG0wSLV=Z%Re^?VFMA`!%d{)7t7@~rXqP*`P z_-*#_{d7WH z@*cj^PU!R~#BS495=3Up)nKb3rGh_&v{MI3XyFAbX~P`#ek(p{CRU41bY$jycb|09 zEc@s}=x)aeaxVHIXJlPIV=Ux(*lP_0B2;nY*(f3jpFa{TR<9SEh2peO0+mdCwuhfwxY8bl^!C3HHj=Cl5L-(Tm4tzW|Bkx)zU zA@~S!YXJp*TI=t-{Wkd8nXgMR%fhx`Yh}eEJ`}=lW+v#R+ImZ0FI39}lJBsKlX*ez zDvyw{xE9;VhucZG=au@%Pl)Y~V>dY9Qwt0Nz7v^s{p{-|E$*J7=wK|t@v9$6=A($O znD>I`%2z)eP1E80k-iLEwdXJ4aR1wy54_YGvTwFCaNA90-Ul+v&U{a$aVO2Dn12P~ z87LS19m6O=+Fygu8X{7><)FQ(xV$(zwk*=^9pH&n#SQFlYoOs*|F+_b@!zd-BrnB0 zwF`8PV=Qz8;H3cDguMtn%^%oUt~12+0oqEFEyo>+_ywIbC#W4J$65w~S!+`w26zc) zcjhLZ!YF^VLd7SiZdW?1rO_7l+*Q+PxTn7SEDSg zNIo_nb1jyKRehFb%88NL@i6OfPz;{e!#;M~5^5dOST#N}LqyGAJ-q2VF1_l=-%$YzMe^O1k@m5a&5rlm$+qUAdk{Rh`7kbjX zCFeQtOhADdTfrr9Lk@zkBq`}h@k`2gRgdgfp%cX@R zdgkY5((W9SUy0*UGei1_hkty;0Gf8d-W-;aNanBQ(RJTLcWlVG3)|ezVx#-%7sm*0gw>u=@jadfeL=LPZJM|*89j9}a z|GM%HfHeTp3|9?H3;3{?!!a-4YI=aSku0K26xz5vGjAGsegOW3oQXbubqf`XI(Pf; zR{1-PlJB;%`t$M&?jg?9D9TdQ#|K_+>@N$<6}vV0*&&Xs2T5;ixyWq<>ZGh-u2)}| z_O79Jtr(>F0Xb>;w7U)>G#lmBu@-vvKRet_bNTjgG8*ge3s)h` zkyJbqFFRZ2Cr(UFOta-@>14t|t|XV8#bgFVg-p5lhf}8GIJBt51inr`s1;2|b)?8&W`0GV>rm-PG z#n8HO3p?0mig+a!DorWwG(GIoSB{4%Y$d;4eMr%&R?fxA`nvqGa}(vq=>fDUv2ka_ zW#_%TYkl(JiI;=Wxth3Ok;{?Eo#gi{Og1m0hr2}xX{?zM1A?QFKl*SA4<&?U_RwUa zS-4|JbhIc6`^>|Sh#UA>dpje)V=yRzl=TwM5z?HTRZ-70!F*9qQbh-NFv(Xs=a!YK zPnz@94u8frShPT4Qi`3YM@HxtE#874ojhqe$3w-9zqL0$2M%yeT>TM4Q4D4j0&L%AM6eMJ4^fym?$CJ(Vn9h^jS6axmxSZ zr~uBhQR0EXk<4gydEf7E-{iAD?Ms#<4Y-yCF+lDHmt%;MiZV^F-k>+S^Q6DaK=++B z=TT%27pKL>`Kg zK3}uNr{#3rWU7S7$lV4dTn6mlaHE;Buw2Tk3rl3TzCuAgE(8qrUEgt9Z{+;wd5<(< z-lP#0IS*X!u%2)0^JntyUJ^tTE&}M(lafFhalr^lyZrbIG0TC$99^%p2_rm|KW)W8 zHS-HGPg)?dqmQT~udRX!~9-#UKr--`%sP#w}KRcu+xYb!1V8is4=0Ki>z*?B%k zK8OqXkWLmWsz^spzHP1jvNsjsR?U>yl>yQ5cioo1DAC-g$u{y-1_sh=YWODZL9MBJT`z5%M{=CRyN$lBM!r;MP zvwNs>Rgmx2S*&W>5>g_bj{TfrklcXP)shppf9nK)dNh)gP^&1hT3UseKR)G0Gdy(! zj%5;6+sMbI)~T}|QUld3S!vm2=c7j}AqO6ikn!&m_Ve^O&Qrdc+o5&-x>tUP8_PC- z=)+iBxKX=1B`4qhE@w+z#K4b!>l;n(uz9lwgJ_IgBl`N`oi3O+8=X1s>s~){Mx;)- z`z&)EHVT)!s6Foizl5RxV$lG&@8BWhl>wKb)8Xz^7-83T+={!r&j$8C>F)`{4`ZGe z?}vjl>~73#InGiQ#Mk<%Zm8^lDKE3BG!vekDt#vXH?_YZzy0F7m1y9F=U|~*nXm75 z!NPOOG=xeiI3s*&qeeUuq$aG1u!g|-odnKpVRl=CyeHOilb|QF(Ui1h4)^#ZA={ax zS*30usrqFFQuD3Z6W;CmnA`7QQA*}=&==BY0d3@7iSBKOYsaLvPgxV+{IQ(|Ys=|` z$Da<3j>Y~1Z?picCM`&RSU?cg*E3u6TZ}4P-bNzE!jk$vpPE`K&cr*iAjj%%?+CGB-*5pdRQYP%ukVL-En(_u?yxTtr3Um)3K z>`O>dJYy1F1vK5Og?E}&2VV%Em?%5oB|(_6Cnp2B-nc>5Z zP?0Dga#dY2<4|8}9a$d^f~Rk|RJ6(n?$6IoLsq|1KwcqW8sRNPnrZz2HhC0I)ic!P zBZ)o|qsZik^7_3sAmXx0_g(bh$ipwil>-H{IaQAE-5muy;tqOyG@2`Uy(+8{BWCz{RCt~ zSpTh>PatqlsIF#^Z;(+MCj0<25HU=A4WM-fzQabDli7f1{o*zQNr!aUXbb*vkGLI>4Xm3*W zInl40@?xmX@X;MF`@&tx5d()(XRw)14Ew^d(lJ3oYW6#%xi55>gzuR?kB3c*01+1k zNcw0M6rcm(c}Ld(G&+VJIk|o}TWRO5bkpJ3a?`f3yhu03->Zq2S>5q^@5U_$l|EB% zOyF1-%Z9h6hgxCV(=-my9&ja&vB}iRU)M98wI>8-Xd(#4O~ZC*6CjYQSi6# z**=75n(GRErUFOFNX!p*rtbESZr2K z%G8R00wh@NXHlN`TaNci%;37&#+fQ(?YdY6j8}4C6sqh^vLf`M?U7C3x(v@-YDw-x z4lY1L#9k2-Hv)06|J>=&?XiMvwYgbOre!O!X9g3*==B{S$XsVA!2+X@Of;=8qA0Vk`{nk$*I)$m;|!n838)6rReJBxOJBcBNw57p6mu^ zI?G7N!MQNMnDV_LHNS(Z-;(0s^DnN$RbfcaM@kw8qE&fKY|f5{8l63K3{1ipqz#nB zIYDU5HdHIU2IzFP(UpVTlMW#ET3dYQ=I5e$mS0&~6DGbmVchd=_yTL?L#^4uZn2Mx zZ9fnlpQTRHj|SnakQTWWHl@8*b`wj=t6wAEJ~XuuI%UW4eAt!B9F)Ip=2+msrsK;) zI1Lr2O56X|P6+y9qvj-OsWYd^-RsTv$k{l7|Lwcn%5>JAG%D^ttetDA_Z8+7c@@oO z4KD0zamhg2SGOL#ehF}AVSY|4q1U?t@7<5{=IU&QAUR82w3$1wo!tOw43WT-*Pd00 zs|-tX|P@P@w!Y80NkD^s_x z9DON4>J)9Nj3(Mv@*tS?xX}8KIk%|O_5(NRHjX^XC4M|ubtl>*HFWj^6}6hovNjfgyW0leImXp1pzBn@m(&4V5*(cQCfs8!neI31Z0GoeE(4jT{Wd&N~1 z=`hd8j2D9>SK&c#GKEaD0!dihuU%qYHOAtjTTF^{Uw)Ce611shZYvwoL`HQ7H5O$+Q1WT0$@R|BE3eMDyqZ_ z+1+<<2#a}o{eyX-b2u)|KxDdUp@XQ>CBa*x9LKWM4s0&=Ul?RjHW1-L9I(`VUS|5x zAQLY{n90?W3y~QbLO}jgnBadySyh%0-+MC*3P@dFV5i^ZYTVNLKp^@ya)RjM!L<+* zv3a=GuLJybTcJ~8xDGPpmO9cl!#Y{a>> zY%Xr=yGf;asctFYDrP+YfBgpuQ!8~q6=`_4=yd0cumtDJG4)jB4x?gDlx~(Y&JbHD z{mTsUM@fcj-#@lm@2o^}7ejQ7#ck`H?ADJ`;pX};V#-VDZw`AR|8F=yoz|;Bnr|Ps z#3mQgGBA8zR`smi{D7^IlH;2?KyB!LY22!mw65X9j1wU5o$zLq*tVlOr!q=7X`YAK zOFxO?RfH!pO;=+y#s{faQ3PkZ3#W?)`eH^B2PKM-69;A!%^nga@*elToPI@*Di4Sg zlVm*bYBj|_kL?Z^b+j8?%paL~L6r!pftmhV-Na!%MQWLDnysfM*l1=pyCVBM|NT3o zU|>bWV8zjv$P1nzeo@+6RXwSvEU=WOw6+e^tLE3MqC{8kiK4zAxm!hrT(F+oXx`yb z_H+XRglE>CH#xpn`oH|=G-Djx8rhwjZ?0~9`yc?b*F{y@3)QF-YH`{d-HM)lKs<-iz!mA&6v0ak#d9=Bu-rrHP!20VcrSs))_UE z6c0y@27%afE|W)#zM0fja3=P9V=&a{%iRdU#}8_#9@;LDAVN@HO#w`R2*6pb<(Q|# z^RG{=Ehj$>XU10AH`nDIJ;?iMH_n2x#~khWCDW82*IOdWwL+5=ru2m*P;9ZQ$C;zM zJF7AU?5>wyGO@c}gKZNG0_5XA{vZV>^9lRZSJp>jukjtb6TN%VPug;-@_ zfA$jqjSY|#!VhLbu&;O_$7Fnm#vw>_fs(Q=gP6dd}`VyxP!+WijHR{QwJ1 zwV7D0W-?yt`o^{?fND&0p193U$uys|OtTEawOQb8 zA6}Q73_FAM(3u>UsmUN8YCI~JX_L9tP@JmaeYDuqZEJLp)9dZB+F5`hsN z1c61~2Ij`mewtQJ{nWYTLh#_!WT{w%q-om{iL3?cCc)Ie5ZcB+N!!Xk9rWK#M|maXo#GhY|?Y5eD7j60iZ&W zmZqjE<{a0@uQj(9nF?T+q(b2X!3BWNqno<{NVb8B&!aYz3(QDr7#{|KOH*?9GhCqb zcRYK96d_LsEg^5Ii9c5aLNxm;YZ0{YUxH_(jqG60rKkDMXx{2*H)}E*w?yAR6CM;& zKE;(@!q)*P^6^t~7}J+nH;m_7ZAy5rT<=akvmXK&L%Pwl@6bS!_h{mN_L-rFd#~gY znI;5^n329#{-(WIK+P}OkM~+aA*k%HFx|ZNZ6EpnRI$-r# zV(nw3CH9cOBfCp6y0IhDt?*tz($3dxvZg;NCoL#`D3-8x!zU(wMg@MXC*nPrF=f)) z`5N5Dj~QDq;<1}+#G}+KykQRUzhUU+R_G^;@KPSES(4DyXc%HvCX8ruMAA2tI%~r$Q5(ZuY-qLyvIM62F_^Z&`1*gjPszh^q~MXnW5v z;HxHR0TVqW=ypPkVJ)U47-=hgkY&H?-lbOGb*?X>fiqIB!_FL;I4PcZJD{=fOGN~2F+WHqdGXT@Y|01t;!H6|6 zAE}RDtudT8{s*im*#8Wa3~p0SKSSe)v&#J1IicTYk?O}3J1D8CvVP}8m$o8E3g2fh z$D->XbgEITJ#Sp+kBcE2(5w&m|w@xL3e0T4B zm^dFkrC+oMkTI(MN$b)KeI--yE?;4p1wa@~@}2`i4PO8#|jQv%r;7iz$rTS_CYUzGYmue zpt0Y82Hsv9m!2T65VDu^mOT=~r%P!1g3we|R_Ay2VBu?e^crx5ODeyHQN%_O@v8epBZQ#qD-v7WF(`aX_G+U2kdlcAeFB z#}jZ?k|auEsp5sJzfQQ%C9Jp4EyHE(L`f7N!ei_x#pqO7kgHlU0^E&*F_PG=o~kOz z_$D^{O<_~?Q9SCiWTOVjT zeh02|IfgvE;cP6+VBQRkFYe!gp3{@X-F04gGZ?s)MElC>vilN33uggRPlou$y<(;*@uNnU8C!9hG7za?T>IEcA&?r=jk1Cf z)(;Lo1C`2XW!S((2U06O8()AgaES~Dd3G`3_hD@prk0?Hpg#Y4?hng2Em<@gb6yZO zmryZt2&9*u=Xws1Fv58VpIlhi-y#iEN=zc?ynMo6QB_q0j{*GG zVh**NO^(j^q;bnX1g7Z%&i#Xy_P5U}18zuuh*{qg6SRY*7o&KUS{H91ZJRe?9!AI@ z{kF&RPHyVWnh-UtGmwa?66kV~Dyn5rIk%{hYAasd?D#JqEt}M~B_N50%hB;MY)HGs z#HLfs&_{U2iHf3A{&44zIIBG97NZJvYtvbm+-ky(ZW)DBUX}QDM*s;tk@%KL; zAbBda$F{Q6<9HmkxPBBNjt{*~P2tJ_ndzJ*t^ul>HvD_&1Ci~XYK%!CSTBm$($E)-*?`>5} zHx2bm3S06FSO+^acUdDFEk>llJ~{BA|F_Oq2TH3jz2L#mF!0;&)a=K1BCcoiHdd;r zdhAT$F#uj9Tyj8+OlMu?3jKF`0Hc%9CvP=b+ho4ZhT$^tU)Hb+T}v5XN0bp9^4l1b zC868a^;Qf&tLAjoVz5RzLBP6ntfg; zm$9dKgdD!7{lNf?Zp8mk1BaPRr}XDgcHearoB;2icK!)G@uekWD5tL#w}}4UuW>- zraC(tT5S088Ev@MpLn!+)j>xK0Go@!aRD+9S6!hNwf{EeM|ZLLR{W9^?W%3X^V;XL z>)%LJfCS46IVHN9`NVCVNG$?I^i6$cNe$yM4Vg$gH7(~T-ut&IMBNQaD2Uu5u7yj@ z>8F6=E8cl>_ckSs^xb=g3lDNDl?sVMTrv=sA#VlVT#Efq3QhqwQ(shicNR<36{CLz zdU|W?Rov$vv4VVIZrBlbTu=Lin;p3Eb0vi73qglFX$UUIp?#;l`sV0W{B8mVJm1LF zd)98NX!%eWFVu48v;a|M~v{aaN zn}E2BnQ^2Av5|oStU|aYOFwgq?qIuy29Owm-~=*=)_x2kGe_)>RUz96;(BfQks8Kr zm+-i*qmEi5V0@#4jgs%vu^7_oK4U^XyYVa(03>c(i-(uM+r+4koW$;3#9B~F`jcCY zTddLy&OEyX$vsI=jazdd9`AxdJk_IEjy-|_S7A5Fj;*#cvSJEcXaY#j|O zsYD+pdXF*Ay?`nxafgM+%8m`(5Z)VKSFh5y3P8BwsLb;Q17k8e3fBbA;G7PE5|1jX zse1T@#jlE1$S992ftN9JU%TGwy6lPr&VzI!C`fr@?STtc9SX?Jo_R};)smeU&0GmN zgr0w9Egqa3P50TN9#r{nZaW5xw+4AS!meFmTP?GWYy$=Wcuqq6o-wzM*_M_a#p+tv zan$IP2~Lcs1iJtwG`0xkUd4>%IUsiG;vaED&JK5?j?-sZ2>lq~nMe3iC$Bx!voG#0 zb{39X4ZZOZ!Z*w>pSUi zS%C27xH0j}MXmsWgMUDZrg-u+J0-H+`M^;y#R0DJo>cC>3eY@HzJNmyPSu^Hk(wU% z)b>;<=0J&q1oJ#|Q0Im_e=I5IXzpA8+-O9EI|%+BbvsOeM45zoWViQlg_Xiw=#+F& z6A?_Nt0s78z^$hG4QW2cpmNqSaI(m5UDfq>5B^wshx388bk<$$qE>lTg)P_9P`SJ&kzAsuyoHmQu2)arUKgQ*H!ySYC)Hb#QIMGb&mh?aCfnhA*eX{5WSJ& zx4$&yky(_ji3rHOQ*oWxO6I9as;qPI-N8P^AU)C+BaAwddeR&q>@4&00o4=s4D7fz zxX0{NYGWrqS938&2);xBz=^ETqWiI&HitfyXd*ZAjd<@Djchs*hJRHXvKL}|LhX?q zox&M6CPgSn^AN8 zb6d8>&QP&Sa$XYl-=Up531Kf8DCc`Hg*Wq$YX^FQ!jOO_gUlnj0Fv(YuunY$!mQ?4p5 zUbwwoc3fY*Q&Dxg7dvM1(PQelh*DYQQ7RPfY-&DuEW9Dn^}$6IUCb!-G`!FXgN2YBvjqyTnVa>Qqn)-^+XPm5aTu(ilFgx4(&lkAwD$|6`gu44-Go{FMy!Rh_^-HK^sC$RM6L)^;uhzC<`Yr^$w64p(xxwb| zXL}U0diSxu(XFofu3EUePI6ulTg&|SpHjBfyFDp~wi2V#^T)!N(qvw;#=E8h+L&Vc zCI6d5O7d|MwWNlRJpnXntZQ>C?g--Y@w1O@dvnh|U}lb_!yXXdgN`8Zmweu9lhth%rg8r$U^U(3}ARjLw&{?^>~#>NM(`{ zcvkKv$G6D_3I%#$|IX(PZ(~lr+B`2)>-gEp_)A8?1OsWyKV`=XYVp_YoPk)wHsm4@ zuNNKDKl29MohF{nlif!qcTF^k28&<39_PO5YcMI4C+S*h1@Py#QrpXPH4kpq(Xzif z$?8_9>_UAY|JYewWmfsm82GfTTXp?bMbEGlu%40Ikrry!JA!G>VkqLePwlE-m+kH*g==ypp7C{&4v}rqz4>TnWL~(MNH<|R zYkh(9B-6e^XpG=v=k*eURO7W<$01+6((MpQcZQldISHE?YwD%ku><+yOjch8UWvGS z9sI4z;yG@~4sbb7`$`J*=s;Xba2L~a+307p^SAVV;8IynSX%ZXRaK9#sT%u3*`}T0 zJQ)_dr{7iRy)bxNvNKe0r_Ja0!)`@8YpJQLM`%Vb%f_LyI!v=sd)0LSp$x+QxBtK3 z<0BRIm36)34(KtGRkXQhOC}d|*ZX);YFmURuT+H`vHI_~NLhxctc$s96tLl`UFqHf zt5*c#Qz{AiM;tb2InPnU-PNl??c5c}ix5*ru*)w%1v7RzO@3>L#_C5tf^2;B27x@$Z& zGM&tm-jjM;^bawORaxc%YDHcQttM?s%N_?q&BD2InAx10)QFJJMBLD2N{l;RS1YLy zA#^gBEI{WwaTa}tH5{}ek^Sr(8e2qb$9zQ{s^Mukz5nfzx zC0uq6&wK1}s#umV3v@dXU^Oh`*LgralGb1=Q& zxnf3Ir3X`nxvH{ZpW%a8-hxVc73Wr*4<{adt1|C=tjP1#l=uAxOX_CYEx81f^>@!? zdrFclgx|0wh|VoE$(xY8YKpj*;}(YqiyS`j?$(hDd+MxKDi`SzqLnc)pBcH2wvn3e zyj`;y-mhCizJ24;5XeTEy}UtkWd%TxpD(f=PrA#>nUq@EhWE8FVJ3J{;*8*H27yHD z4&{a+N~+7sw8*EIOkLYJ4M;2|U|K6JQGFW2|7T0HXX`;J3-6yU%ig`7R#|qm3+em| zCL#y$VIfSV%C3(%;$r&KEn$**H27tkHs3&H5vMtQB6&U;FhZI1+xl~yn^_aMk9MKHUZLCgENi~xQlK@D{Xdf8c5&J*!{uOsa%nT-@i zqjR=`_^$YeL_Zo*e2h4OxFhpepboA+SR4DxoC6SJ_>Vr%0NoxusOP?az%nS)wdk?p zsWv|D4H5b}2g;9Liv!2_!qd1GHrn7kD%)@=`ZkA+MEzul=pLng;d(5F?|fgs5Yq-7 zUoFVZ!H$#qRY}s_J~QG|`+Y-Q7#pd+&Wc0YwU$Xmm~WAN zmZ8hdF-Y%~*hw8KS0a&+s7(B6m4TaX-$)V6dn>Dm zzOw86$?7<0aK{?*Kc>DqDC_2nm+mg3rx1 z$-DgS%$@tEYQK&W@i5#JA^i=sg`jNOl^)z8BD`CM^;EXez2D@gx{uO* z4D(0E9a>{b+`zx{xCfCs2wxhPB}SlgiUm8$k545W*Z5$a39&&YEBpE2-;RC#)mQo+n6?vLm~AsebRWIyfY4$jC-FN4xGFSNP(`~i zbCZV=_TI$4$+VLTH+RWM%;>NdWX?hp>d0=%snI?D^PmSEgCa=UWz(^kUz2;G4N#un;JBVy;p)(E#ym_^*;; z=!CB@V5-PtDZoHLl7Ss7Df5>a4NtykxrIVO?fXL-JYbxGIz(K82oe~h%Ur35iX2_# z_m+1w)b8ND`F*O~GV&PuW7jHA(G$Hb+ZOU1Ke2iX&*gYzv{5YF;LOxpHU+KmBXWTM z1YkISbb`rHY#*769MWe0^$lG&L34mtVg7+aMt}vEMiGw(!Y;pEMSd+^BeQ3Vt`3<% z@+=jBd@H$rFTHI~p~Nc&^DuD5k^{d=B6J$jOQ*1NF&V_1r{m-D_c~06)&EcX2R3Ba z5-eyW!Wn;wQLaiC7bLAhftN!X`YU^X{d`!8krqPvqQ^NH=N?OnP-CO(saf(5HWuY_jE4{L9 zT?cI1H7ePF9iZIwQB#!ycR(HMXglh7ufsRiBhkwl`ZNnsOv4b%K9G{R?gsyR9)dHs ze2n5evTAWI-2Gm-!H{0?S?ut0<%79(XP&v z;Xde$x#E`}=m1=FoFfeYzrm}jiin#K|>7txM%ynq4`C~kw_9g5pH;_xPn*{Oq280I@IF7avJ<#_hybR=ExlwO*3I2=6d^Ae4 zSi>UN=A-+<9~Vr1j7$khFteZ>)Qw@ts+>~xM9uo{1iK0b;0++Hk3Q4NY%xNK{Z0Yp z+;}&YnEfoH^z&+pJq@sbMqT96^y-!qbVjN0%qTv%KVWIACmcX^l~*T;quZQJQrC$X zI-w{PJK&kocpE^DL`2Qy%QMb^6xxQMWR9u6_^`WrVtF8^JWeb`ftxm2a0#!zmX(;K zh$+Mvg)8}%77waGOL^aP(u$v?xG0d{)Vi|i39!$804=MuXDsM$uAR$?qBZSM+5Q16OUNM>rR+zg zQ*VWn4wFCWjPpG<6Q%#j2xaQhgP9xTZkEe!msj~ICHDBS8e;BWG6hgK80! z%xq9HWy~07*W6&`aBsf8h>S4&cRSv6x!jhI#;8}2Xw{6KSVU7cv|G8sYP8(4` zq~Z=}P?}A5c7M4AHqW)?Cmf%5qwxQ?`cRNMChg;Hief zeLJuW%CyZ%z+^fzncr~#b~0|X6R=+t$l1sH`}53xI}P}9K$7$!g*N>k*>}_utlD8l zXCAYXpT#p2*sXB$4VaRW(2ti7I4LJC%_pssK!Fw@XVC_4k$y`o=`5NZB0v3~?Cl*J zY#tVFUZWzKTg?V2jusBG=kRQ~>nI9hz|SCdvseMX0sMo?9A!RA{*A=sFhg9wyn~P5 zY13(xmC?Cygl5~gjKpu6WvXYr4+=wd!A^;_BPCNldg|q7!uoGEc>lPsx;~#@v@7{p zy$xnb%&aw4#e9=A23&e~PCb!S^L2u!jseBnt^5h;H(^I!nF824UdU?59|*CmfmM$gDxz%T=I{PUB^(j@mlYU@C)W zUU7NBb_GU#11Yc-Y!nN9=@Sd2`L%$!#Dg^T17juRk_|XaE0cj%mXAQ04f#v+Ry^k* z7sWie#oH(_crqlu-RLGi+D2S)nat2Na_zB5is?C_1jl65SGQ$^ydweXOKUDyiKtjawUcJMfR5w zWwjltwx4sKo=$ZDWOcIkvbyjWB7+-|t>T|Sw5TCEUZ5%9%{<={Ht{UX^VVa;ji(>; z(%@CMiz zQ*6@AW?!$&B|bKv*PgVFso5%}pcp7p5Pf}_uCO45I!yCNJ zaIqjR-IL!yU2FEg;O>BEYT`!Mx}_WI)}Pq=xI>r&xj?ug&4`v{}@L;x5*n5WWrAoVhVspEgpoP%2yz zl@oJ$VIN4ACcF*71u`3(r=GC<$=}T7j0r=&@P#kul(CgCpz4_Q99ozVo!Ki;%d#o1H(-G8BekZimn=CFF@UmfP4Q@W5w4-BB50!pWN-y08yyXZwHvr(0yHT_7{ze2$kl z|L>FL#k;N(ajFbRTpPmUsl*BC-Q0U%spQ4M*(*ZhCOV~m^sBd49+HiJ=X-@~YgZg$%`Ppw|w2u3|=SdQf|HoGD*Q zn*W_%%(HuPA*2c2U))Tko&8e5;K9Z6X-INcJD*<#< zexSN4c=HWlc1mPN*tUsP8*O^V25~9TfqI{!Y+WbDE-v(XWq=MR84Pmfopl7bKnh=$ z4y!_|?!hRDwyUQ=qaPFcjVttqz9TW)wCBT^K_2KgEo2DP%Mm5Ml7(hx{hV0Wr_Av9 zjI(r>&E~d{QQtC2VK9Bx0)b>pOPD&CMuV^rnhO^fYi=qibomC&5_kh{y&7E_E4tY6*T!-dM~=zajIIux z&Z&a=FR5L{^rvM)6aH(Dy64pcn-s(SId;g~hJ-X-eqMH3+4%O_BDHtG-wrB80`n(p z`dI`Gp9G*^(avM4)>184t*iCbia$7l9cOz z`M~U>2(+uRHr7v89CCd*J?kKX1fPuu!=;t$YR=viz-SOShUi*g3g^Y>z81GPsXG&8&+&g3YHwoE*r{%dn@O9gpIH}SbLu9J)=Ao@R3f2Qssl!oXe%|!O zX(P^!1O-U@DdaWz*mbU~>a2koWmNL}*lb`XvdMe>M`UL3u>m&|)Yl)wFRHO;nE}SP zfH+F9)PTZF{$tTi)A|$$W5V&0fN`rymoh>8-pjA@6<2yB5b2{J{(L)jQyG#LQmNm( zWgLE3`H%@^m*MM))5LyNG7=D<&u+_ycJt%FWNpY0#j1$fIl>1b2e%cYt=u*DJ+fwZ zVafjBEso21wqYF)+77|yz?83W2qWHkMvJwYD%Oi%L^;bM)}3%}FrUwjFBw%QJnv4R zIuta|qA!2|v!(QuM;E|(2XRAC3t@J&yUdv zlyLeEgSn|dFlBDGJcKiih0H#ZEe}_c$E!hJsSb{T4<#~ zX}l}t-SO89`Deh&#Z^EFO=qH7FwzIB~Tk(v} z^7Adgcn@L8EkR>In}fr|RB$N*p&6~W-}IVNc7Q*lpS=7bZtYb_b*Y2i1_8DT#^1K( z{=8^-**G>!mXy$KS_K9s$~c9kj0XAiuc9BU&ztLdHrYq$6ok{SQW~KE#O`qz8-K#u z@m<5|K39nNoqlrr;#1K~Fh~*{R+Xt0V<`6_3xPY3pDLbmDCfhx_|I*ibiKuYBaUED z9gaIuCq7Eg*KNX)*4k~eO7|5G@g#=)Mlt}HA7OH_pGA1!dq%V%pp>hFt|Y2GEA<^gj-fcsOQ%Ci^u8H0Kj_c`hr2CWN~B{w%S zH9Ix&B1L)p75n8gbNNl(V0XMF`MQ!&=1SyTwd7&tGOxo3hsmM7Ca_D z?alRecx7%}Z-QfR{>XqcTt+MV3)?X&A3P+( zTQ%#sN)f$!A}2XdEb~_mN8RzJ(evH!*=1gMCjNDG5?0q*V=I z#0l%cpE6P)T#A7E-k|n4O&&Tq7fH}Nw)9N8X~#qz7=0=Vab5nAot3$Qs(!H?OqC5a zj>S+z`K+v&bT{Xf>S8sfyXdxkS>!prI z6u`?jA@x0!mJwkGAA1?+{46L;T|w4PW|2i%S3|*}M%t#WUwSeK!T(j#H}0fS=6$FI zAFU_7Ww!8Cc$CGuU;9aGm0H|_dFZk*c8T0!L(PNf-#8}`--X0xBF53Za^xo{*yfJ! zPLSbgJSGN1!N~KXEkn3Y)me$}D1D_BK-pJ}+Dq1J5Mi&Bht4p5;i&n%Z$_c6n>{69 zE!D*)Y(LnQxYu}4dBhED1gXF;T%hynK2KWf9civ)rcTPiBGvWLM zDCFvWS9TpC5+p+r>T{#^>KsQAoTx9EDo#bDRdlER3=xp`q4#ZwO@zeXB7FEV8cY2Y zyv|r;VWG?l4%so$pX4QWmvK4m)Km# zqnXjzNJtQ1dNbkoVdjJ%?dHC#>!YwET+%m^H9l$&E~nHN84ehN5EBobOzjxZVlI9| zL}X$m7Ta){liHk7m}{-5X-^BThV+Yvc=G3#N~{aFxQDvtHsrb0O#;ouR)Br8rAYD1 zKWkQP90BoD$>Kg~vl5itt;cAQJ4-GYVOuD~wr#hoY;bI~^P{7MpIvLz*90A$BtP*{ zyRc8_C=-&%l9P)%xHL_w?FWKGIA!1f<|?$qZ@*6YD7eptI<`IPP+avD0&uvTgDC*W zfE@_-d;^7-2<^QX5w_=}iJ0}%y2eSbIMvbC%ltQ-AW7p6lV9|9wmW?-K%m z0)X8$mK!a&i7ePjZITBjfCeLP=$T>Vz?PS^@Yokbui)$mF`-@TH2UBQS^`UVyT9zcU=;rakb~6D4E^fQQYtmHsz{RCj&!6wnWO45-Sv+|?|9#0w)ZGa-L8ku zV0`7Z6yQ~O6ieTHGO%fekTu@o$7WB7?n8^SLxwD-zwJIou^V12O!-jt=<6&j1(v0* z)m%7izL=D}xmVhVmA6&*u;9V}67WRqGzx9qVCR?Ujd_erckKotQ^+VSOjElNk-jR6 z$}^UtJakbh#tP_(oc)qL75;+!0jr_v|Ez`xo50fNW;xvhAi#!Rh}=*1AHy(!fiAcj z(Ieq-kPZ1lr2yh#(rN3t`dEupRh!V0@{AOC&w>lUjc>i-CeU_s;K5dgGiMZs)NLz# zDkYQl<35lVH^G7RxJ(2uHX!m!&lwxhY&0oz*n6AycZ5Rj*Y;-ITOaoHF>qejJy)Nl zw)<2vvoJ&e%~%8${M|Hv!-JM3)mJ*vL}6n)OR=|a+7Okf#+BONId?5?*E1JEAvyZnK5YyJ zL@>|jnK|TDyUVY>)P`E(9WSCrLsVtzH*QRL7O&(&72&Yk7Ab3%XWl0L0L3MMI=M}O zqJ82|)<{|0Uy7HxP|E{M!Pn2h;B>w$8UlKoU~U#BK}s1X}@y(am{UQ2{u; z>K=pq1TO>z>+anAX)Y$!Ao2=V?0gCC)!(;trUl^jcc*}AFdzVjoVXn9-qP+tfi+QJ z>eBlrz1PEBdf^0hof_!Cx+%N&jUp-BAxupgoD?3-xBF&l!2dDb2p1olT2#tMS}1~- zbm*3t8Rw+n0&@!wLKYC3?%FUUxmS_7L@YgxQ$ z7&{Q6B8FA)DIV#ngO}S)7%5lb+92cCYEczvwXM7mx!MEM9hYnnB zMH6(pVSLIlDI#_>_)eW14H_nRu+6E7?LeSXhd^WAJ72$0et>Z9_6hC2u^((wRJmM5 z5?NksJOi)E=S;muM>3OofdFyiy$JLR?%8SGuwgd<^XZ_yP+yJ9^z{L~cLUCZ`Rs{K zhS)tL^K>*Z6nDTk1!*B=kS``N#sS*wXuzcfznY*Q%E}EP%l*C_sA;A(l|P%9*TH?0 zE`!h8f`{-R=2`fp6;PYZ8y}`pU<&aq9wA81D@;X)Y?I}X%I@P8^WANPq{Bx!3>rlZ zt+j`fMq#52h%l^f2XLJIm1r?wddVuly*^hsxETJeSInPD>5vu#BL-?Zx4K*@PBBBg z?CSy=lT@-W1@qREpIojTwS0xB;W8O>i;D}G z($GVzG1vFFdAv4O6B$8_XJnh4n=edHn0nMQ_EqV`KX2rw>T-$jJJ(G_jUGYq#&ydS zEU|si$%EbuWPT#j8>%v?6Pfq0Qj?+yvDpst0&F9Ywyuco+PD zcPrK&#V+6H{wnL_)Ggoi@M~m2zVHecCSue)lPOg7jexM3#tGBTQGZCe;qF6#H90qg zx{HznpMq)AB+Bs47x>JQ6ZPa7XpjQd8^-N!vO78LJy;A%|kcI1d*NC%P=F@BX2^t^ zo3+isC`OM9To)3FRk#aE?)`8v@)J=A{=qy5Opx?QWbs8O#Z=4=Q}6rnzO)WpL=*+r zmM~Db7phYTl>cGqB?iGj*F_~Ohfnk-1TeF7>ebzfrrzA>NlYfFRZ^KGR0VDVsBwfS zcd_HNqP#w=>iZ9xqr>|-RBu+qqK6s*sm_-O50uecsy&0FM^xKf?J7Lx{gj*6wST|x zf9<>r1PO~jJb{?ByY6vu%%gk8Kc1bZ;tzBL{do<}{XH5DPdj3mcNjc>)3hbNvOqnUG zTGp+{qyDeIs)H!nk)%~~m#~p=uJ$mw)A}vBkdu4{-pe=#MdedoEP-1RxY2tgF?*|2 z*UXufI&x8R|CZux{QpzK{zV~LJ!=KFk;T3r z(@W(Rn`h!FxVPH0O0zK9^D$l!!X2%zl|6W)_GlQ64_AtHC`QnE_ZvoJCS6l-#f5`* z?PCxbnrrh-hZyR-zH0#?q^wHIU>F3bPMp&;|P>fpV$KjEKmdaRme&F~c% z_hVZ`<}=?5`ID22V5NUd$QCy+4XVHOP2fztyRvU>$!d#d2`1b%jFYdNu~B$;p%xOW z$qwoynR-biHSMeS8W?8iYX$_$45B9uXhySCR1uBe?NCy+~bNd=M6F%;1(@9&Q2Fr7@L5v@ZLQr^?*of}Vob z1HSpURP+_KfZqm|ek?DV_OW9y!&j6;%q*OaEKPIt*pc-?0IJ){SY`d?ppHHNe=Bt) znBsTW&cK1Zff`S5ePJ!uPDEi{-Mu`Ut?U3vWio1F4Baitao^#^ci8tkttqnT1>riN zLy^OXp3+N8T_XO+wkaNL1+ttFyu+!lExGP+JJD~wE~O~}k{fLRB-YXO8Yyz?a`RSqwEf z|EF}cN}&%i+9v&-GWnenlb8!ie=&-3HvOsFOKiV_VJPSGNiWD=-jLZo6-lur(_Uwo zdCa-9mLBOF?2Tu&Rc^MN-wMeF#KOmKccCOE>m>C68d`Gnkd_9$eXz4r3w=k#8P=w!2Klzj zkCR*D!u}}OL_Z-Sj8rCI<9uhMqZatnGdT4qq7A#p`g@x1Lti}Kknw27C*yfRs{c{K zwJOy#)X^_ooE8srvS&QNGGbCzmk>ATR2&$t}qAqYDXN}jkP4lK<+Ko-aUnX@bUlA%%AjkO0JMbD~M zTGA_xB9=H4;Xd$n=4I|$MGun!@E4K5s`86moG>QI?+2oTrf4xSq$jGg-(Ys{WYoEX z*AOS8>q2Tkzg9EI8oakl%HC(a((~o99S-I5t;^*}6BKI13TGvhxYM2gM zKZb@U9+}-AHp6j4_BQGN#B0)~6J5XlK0zc!E;^BaSzeia3V3==r=;8|S#V|k@Lc`Q zw6pI%GaGL#Um+bp<2IYvpVO0TIcMIXqY-Kk7D`yTrRZN1*B- z62m)^LBRogZjqx?P>LfS1tj{6qQ7!4s_~svOe?M5T8AwDs z`IS0-*57t>g073?P9+IV6Er7wG5Yj(7+NUh`&+@->E ztpSqAq6Sh3%}x@o;>+riBNOAi0D~iN6kxRn$r*Uhxn{_$!{At2TIN8^Xh(K0LLniu z+r4W~l^3Y%U6FGEA-&$TtG4+urygu8A&iNr3K|10@zUlK{1RuXaZB>>f)i5xI9Ud` zpDvOlY6IjwJ`NKX&Sld(5li_R?^nnIWE>0_@=3|&m}u4FptHhG`Y^=pEGclEt4 z=fBSk4PCX-)3%Xm*s{B8UE@xo{fj(JQEus|bLw@C8l_4)U%`aJE*JBbTdC$EOanbB zG?&R#9AL^POlQXNp+_7z#3HwkIcWs@0_bpVxx2~hTptOQe1sW7$||;m(FH(sl=j}g z=2;ZYDfZ&qmuOLNUD=s)n#8EnQb}A}1)|xbs@E_S9vNgSbHW*IVHN*=FIVC7VW$`` z<18V z?0)Cy5vGElgcZ-(_^tYecZirGH(|(Ji=)blGGQhCZYfVy+ulbbDxH$j+Juj4R}7VG zz`jpFDckNY`rwy0W)>5Q8I{uaV~zXBmlGcCe6057B{2!Hm#UB|`436mzg=`}kT%Z! z$RHJtvx`fNs0`xDP44vF7Vwox=iLeREs^-B;jZ|&Foyj~SDD`EBs=n+8hG-X2kC5> z+75{&3>m6~crhw1Rt|fwL-0M~1o=!Pm0?xD4pu>P$?58wBMKxcsbiDf6Vh>No5acR z2|h^hthV)xMRP^pT?Gz;&Qy)!vxnxLUQp~BO!A1q*1Gb54n%IK@ zr^Ju?F$#;oRNm$)`PgW;?swO6u>QH4pIo9MZxWsZD3+MY$GbQm+lZUChf2+NL!$!w ziOBax<}y+Pe1DmwcbL(rkdWYIcEF#3NN@u849e4@Vk8TkQLGB6NX9Z`Z= zig#%30{C7juS6g!Xx}$b7#V?!^RxRR|3>eh2w3|D6cUv&iI9_9@+!Xk<*B9s%vB7< z2a$toqnc>%ZTtAu!GIJx>BKWJtRf-eou$rt$c&K|7uvncNd0(f4V;IDvR zSld7gjfV{n?INK2(~EdU*l6~~fu-qwsA{d>wDK{df zI?Eun!F>;q|3v|+fK>@rWU;dcHS>fAS}c;4(r=P6sg0at;Bl*ZAbN^jo4xRDx6=@U zUNBB>01x}gFX#=%`!T`Xs2S~srE@=ogZSa8FIoF>3{L8j4(*bFQKiBo6zZm%N}@a*in(mw^@sy)hZVQz$#<2jlMn?fnX!G zLr@s00EQZG`N-`8z*6+MHi_<)*RuavGN4(r`057G(Yj{eal<>oEek7p{apQI>u|SS z{kvZ*lKL|CTgTRGnV+smeGK!5?>66=1^!JIHW5`U)U5KKMM6dZ;v>050q$??z+JQl za}WB)&>=;E2z&msQ7IeK#3sPKNmi~#vvQ=vwg6{-GNoq!H=z~)H9K&} zr{|d@SP-mF@88dhWEgr+e-mvM4+F#&N#GYc>B?+)>z=r?qbkmTjj#8vv{}m z<})?il6e|@Z;1GF*mI@S$RbC zMHxxWfBFNQDsl)s)j)da^*g)tT`v<{y(hTFkLq#ov-2&hkXpaE|ME`JvqHLiFB(2W z4W*8-De}QGpSuKN;FT`x>B7djN$@ZJ^jr;+A6e9tNr|G4sKc$`lb#CDmhm0^jQ!3a z)}&|{eq-kxy?Kk_&EswN{U$tHM(L8U9i#Gd*Tjvy9JxJ43-MWS5B!sBpqVuP^Ef&%ck2?9L5dpi7E zfX+3c)`H0Hb>I|7MYE=rK`Fz#I}Kt^fomNX;4+q(8u|yU-a&J3B|Iq)7FI{qoIAg( z%05SqMfgR9MD0<; z@px-LGi=CB>*fa&`=t$oS*IUYvR5QW(-Ldl4Y?jAb1^Ad{lbB9MJk}EqMBFCQp z;fHpDrkIIhH%Rh(zL?kJ?sFDXAGD;kXk|9*Xkua`4t1N0KmBy(t#5O);Xs~!H{xPx zu!3XbAkAwVrIx!4GHV9|!dEHSIOhMw$v?XoJceN#o*QMfIq9BkH(!kS%tv9*r+GAE zGb_=5{;Xc{g_dKdqYUm%EoW|vudV@W=lvc(UuUk5KaC;TUG)b|cqobNo4w)?Mf^v+ zC9nxge3~;$2y-C`=oU)uoqfuY5%XIuDqjddoFLfdiHt}}`vb64S6cy9s?LiXx@o)! zh=}|J1ZBhfQT`P56Hh?OktKcfie|7iTXlJzm4^mL=Ae(|B zm>uaiS$3O*2&_3Kzjtw#JEbIGmJd{$ZVw{t^M852ONxVfqSJpyexJc>V?7*al&AAv z0_Nonl*hKci!~=gE~E3*8BYR&#f;~wq>momC#~0@_cWkf5kY1y)f+Zsh?Tl*KB&-! zZzTBL_~+w{0WwbkByqZTgBFor1N90;v#pV*3(N)pWKNDY*Hv)nAkFj-?snS6BQCsM z!k{SEdkoz4N+i_@W8e{^v7R`AJUhtv4rU+mY=8MgxJO~H5~q6;>c^?x(69R$r8w&N zCdg7@7mV>iJb}=(jjZPdCr6Slr#)uyJuo>g)Z?EAl8`UiwM0YV=Xp6WoE+PM5MKIy z1eosJrV-*2cJ?W|h9AU{N=%-(a1-boI(yJ^_rA5?2`NGBMfKE~?xU+v)Zb&{Sakp2 z-Hruzy92^_G4aGXdc@0Z4f-zgw^!5@WyS$U{>w)7W~1xJ_@22-wBeY zV3CsL!S;U!>+e;y@WPq`O-;{A6gLF*Nkki@^aoPj>s5i-$-lkAczQEvrMuiCog89091%3d=!t=weCaL)oIbwmz9ydG8gN+Y#v1%`b z04)Il_y$KIK4}(&_qAzVeXYh5+>jFvp%5yV=VPDsk*VfEcy_eVClXw7Rnt4J$|LW! zOOeTHlX&&SZVVA~y#ay=L$R4{BNUtj?tDT(y4!lifp-%ADBDhs?*f} zu_$&YDDW-4O!_WKyqHXREV`sIo~qXOTGbhX9REiiTOr~T@DMg6-u*S>=3*Ew1v_7k z#9?1PAShl_5WOTnH)@Ra#{izLZXnv>$QYCeZI}2XLR=xN@p}Z!`3)Wq&BekjcB*a` z3qJ4SG6z0*aklU_C6f)x%sAO%u_B4^S2jDjNz(0_0XrEFmz6Wk!7e*0 z7fOR-g#Vaf6xsgzEIa;(37~p#3zOQ#_;4Av7BjV>wJ)SB-v>b~*jkPrBHz#I{A{~> zgHxQj>yQJ+k9H7+2Y1VsqU{|c82Qni>NWt5+pm$Zz^yI)%KeYM0_{#4qBV(;o~yS) z#e8R%;6Eg^edwOhIv|43fplPtkSc20>(-!mId6vk9^Fl4Ra&(nT?RUy6p%T0ST_AA z@Jl2BbPeyyVeei|cO7jxU@Qr1zcF*9%$i%qs0L3=jf?iMP*jl$N7gQFaNZqpwC?P8 zOqo3Rj&*PDBu^dFzkD#-?T`?>b_i?_@x(Y8F2fBCoC*{?E6jZC zVQyUa+|){zB4%&`{C(s#q6ZV^u}E?7u^g}4|AwS@ru?|Lh1|YNgE$RWJPTO)S@{V; zL8s9g_b#0QVC3NrK9W*#K+HP|F?0eX(|tMnu^|tZ()brP6wzSicW6qx$|kM=imV0OI_Uru4bZt0j2S!v_(n&07-ByLxk+)`MisphjwQ?;>1nyeVsL#( z5?1BBEbZ$l0>0KrgtsDEL+jpxgut;cB(s5wM~AOFY-ZC=qH|6d^G+Xu&|U}bLGY|c zu?1=pzKm8)gFd#`w$!<|1Fp0^Au~6pRXHINKR$zO9O-B(*lxK*G+%Dp6`c2&0mvmp zly(=cxQCC(?T|g78zM2POI@mmSH6#7-oJOc3qSlQZo~!g#1#Bi-xKh^aRT(V39z`} zC7~OGG!JC;!zVQF-umi6)%?xHp;)<)jXkxg71Y%OA7e>7GpP(qN?|qXDLM??>)A0b z_(~K#0PWf!T-)bgRp7}qx+2c!=|N!7OeI6EKDg(Kp}**`rE2GDzz_aW_;dwa-=?im^g5}+>_bV&m{^P3|Bc5)(I7H{3!BKD-SxU-<+Ip5if{LZdO6j~N7Jq# zEFw02&EWn?%w!@`Z3=Aa>*7K1qMAQd)~}7U2oxh`THTUpjAIUWql60H&g2I>@2CR1 zx!8e~IvmB}ha8TqYkw)vRPf{?5DzRgvkvH8U=$Bc$o)4Z1;TOTAZxqNyq3>_HGdHI z$sMGc{Kc9JAX7V;mTv%G*96pvuaqdd%I(&Wo=cF&wnD&>K~UGlR}j22Fh|&;i{N_w z8pTL!Q=y{vf^qVzDq339u-jcnmlw;ibBkljrj=vHB*s&%r zK}zfqR|=dU+3owZ`3F-M!ZTSQyx>;4hCTx?%0J?|)v%e|n|xCmDeO0}$3QU0SD-00 ziEvd?yy#6p=kX`;V95iE)^y1-nFA&gOvT>V2&S;=8d2E}L8({yPlCk*zfhFfe<&0z zov~TkNc8bq=jf9JVN1tIwjNv~$)GVL8@ufmQOp|$ARMFQX4}rpR@#nz=g;5WMRXY& zdsVPIAx3g4Q)%{fX23s#TG%En@VasodDn1V%8_gFQ*8(p0z|g>NR$A>iBTR~oLi1c zR*t?1r|rI7k=rOzVaYkASpXgpLO=9#+Z_(k{*@5+sH)r#d0-67oH`$z@`@KfpZ~C6 zloCBSQ;!Bf;*li0e~8<)Sq_Hx#PL~uCP`b@?|L6W4%Pa26Ra7#I|png{2KZq96l(J zrTmL((yetgU72_Ko2hy0FX_KGV2i$~bifK)z!t!oL5sTxBI|?fec;*Un$)RzN@U%dA2F*0o!y}8ABi)2RA*q$OsyymbZosXisu!P$~=1(O% z+j7A3LBBD`r1_GXDBf1;Gw|1_M#t+vlFX~l3y>GX%(C0NPeX5~6jNQB%Vs1$Cyn0u zVdvM1oE8xzN2J<(ugz@K3!8;wVzd`|vw#qJ(y*&;i#E8HKw9auH9Ir-XXd+Z9Ch`k z;}3W(s%o#1ibA3?PkUNy}>1v%}u43wLybilIb&a^>ke;C>;=%VYt*~ zi4+eKv_M;$;(hI}3&c;A)!=pMWyx7Xn~UYOl4R}lzBlzWMu(+Y_&6A??=P@J2zp;P z3m9tKp9ltME?t^MrX1fp%dc3)unCZr<~hDy_T=xW zIiL3?^F%Ln9PH=M?-97t zf3F!gyq1ZS(h zPRU2pgMmcHd|KV`XJ4@u=7F+L6OlXNE1#%j=ExL61HX~aH3Rh@k9vRMzc%i2w4K)u z9zA>=q^I|h{&CAMPf>3U~NE5(pH4hA9O?L{2)KCi2A>Ab)5`vVZN=bJP zNSBg=(j_e=AYDU9NJ_)N&|L#a*SEQ!_xnd24A-^y>UFMj(R_Z$B}RWaeA>xtt+?=* zS9luVS2`<^;htrtd-0ajp9Yx#N>?1e)Jau1(rA2a@qf6p%N83JhSzG!kKmUFWgk!} z=NRK&3#*uw$epB-$;C=i)tbwbAv*#1_4WKCf`4v=gMw5_Zxyvda9qz1Y_zA`Ro1Tv z#p2S|Xq3di4(1&Up?Yl0WH&#`m%G&68-)3vx~Zu$@1za3L8z;neDvE0>WT9|T$Z7; z|9wOqG`e&8V0?K`UQiwK!_x=MrCOBz7CdX$<0}w-VoZm9*F$`_`&P%W-@OQ8t3XWO zM&X5bF8bg61NY_)qykL-VKoEno2VgPNX&un=v7ylY#FB3`fW4b3G#TU?u~w+s=KA7 z-4U;i5>iuxi%^|Bx$+Q2_O$P#!0qMyzt*^gzoapQK8<0T^o=X@e}j2mq2O2Q4PzO1wZ_A1>|~1(*Ua{#X_=y33np?MQ2IKs7R1Ld zik&ZXwknW{5BY&^gjPzeEHi7G?Cea>B2r5?CET>hrgQQv?`+4X&Xvh--2*=C5AdNdU^oy9Q5AJrKS=+&tt}v__os$rriogs%6OqEMJE1%51|` zR_E%5ONAe>(L!G>?&Y%bPeq_jWzs%sihK7$oykeyZErX2I7_ynf0INdw%U76@mVa+ zcc7Y28lU!KWb8QL1!y|3fI&_R?_u)MiJZ`!f{mF|&B$>U>6Tyk#V;Br&-&7%#~a=Y z2jnX13UVlDr& zF@UDi`)uD&mskI|u~-aa zIg7Hm1OV3jo*6~R#rR6`*`S&bwV0?hx|*#&fSBE5-zQq=?&;7$2En)pV9)t+9P3)+ z=F{@+bq5Sda?x${&tp;xnL;hFP}w8`<_POjdiKB*j-)@r2P8jb|1D|gX@Xnj?&xG% z)I3(XdztdP|6KfqjFblin<%jGyAbY7ga;jx3k;m$)#38CG&wLc&_V-2%ONAi2fda7 zdtPnRH7k(XEgW|x$ccF;%Iv6buHk7}9Zr118Vta^?EoC&CF$1T6`DH#j^ua(p2dC^ zoX-!t!sl*gOu8D@QrUaVE>{9Ls&Tx`K&qe{>i82RPQ3qS{9MzC@&wi&UxN>+1E^*c zDWy2enGog8KW38Ma`hqEzxy~r2W4AhyD){e{>i^_?ilCS$2hD6vzl!P+OqdSEdrN}2Gs zd{c7k@klsw7$bH5@BJXBi_gMW!*zT+vBc*~|wD-msb7{-JCEk(wTd^^}fPW1D| zNL3 ze}k<5H<7Tk3AJx~L#eR5@W)wIDn*=rp6nr$Yk!i zCep7(7;)*4;vc#p0)}_=-@Z<)?Z-+E!)@PuBfQS_c3?(Qica1+g8C{OvJ#@l`>9>6 z_puo*UbdkoFLYX98)2R5rN@-t38;3+j?XMsmDU>r0H|9(3woX6llL{pj7RaYi;tJ! z;ufvsZ-c-xzj>fOt1g^krkF3IpP`=ZDRbfF0(F<$bLD;4J*Oj+Jn$+GH$bS$ zoy4?ix$&vA8>!1=|A|AR(cR(opJM!SATH0MDep+h_I94dSI&H2PT-p#e;o#!qqSMj zzar?UUBB=F75+Jk>YFz0F#0*Y!?nWOlhev*cxM5l8f+dUNhSlQnR4yvSMMU4CsKM2 zF*Zb_E`bVjFW9FYCYAX0^98?%ZGO`K5){ASAX%C`eP?LJP_xiu+aq=(_Fr16(AyQM zvf1I_RJUe?fBS&6l{)C{&iiWfPMY46qr6~7`a-5459aoZ56=Z1CSa7Lkx089dqvs0 ztlCN3`hpjyk4A%!ALG3`voZvOdI4jq1br8@TaS;(z|xlS$Y{|_9>(|0fO)q$FsV9L&bb^^}#S-tM6|<{q=@?D)&D3 zp_kdPVc!x=!VVZz{(Z%q4d5g!LC!uX6MKWQ?oK2{nQgsx`*dRJ!^vmANaxT+({cx= zfBBAQ@LZqoZ%?kzj*M{x7y`s%>9YiuzINUz-mS8!_?Ag7vIAh|pNC3?%c*0kWu$ z4Ex;H{d&GcF;NUQ{M&}<@HryCnjGp}5z;sbdPgABH(^e5s@Wm5D7U#Jd0dook6Guc zt)IB0i0gW67C!ie_6XN^-or>3tXi8}Afy&tvy@vcENJ%H5gvxz9(_IL-mI)xKExp- zxf7yR`gk+Tt+3uW@c}%33or-5scs^~(PLW?rC+~SiqT9w@c2FpGZ2XJ%--Q0xNeg+ zJGOv5st$-SnNBok{aOF3`o_xt#Ia3J`;I{`)ejJxLpTPi#Qsi?;5CDhE*dRJtZ!Wu zqAquPoUorIy~=f^i|@)qmA1zJY;{?i--zd&9Qhl>FaWiX5#6hl1lnymVr{4z>LH0I z)lhw|Q1St+w_N;@VmYKP>V#A1QXy3st+FFQ%j&==%)Oq?)4bRpn4YY>Zv7u1fz)L= zD&k;j7pRZ|%shU+Bsy`zX`s>lW9hXmh~DJWgz!WPLK|J4A!)vx91Pe)*Yyy=Um@5%DaU~0IBrQ6GYJPIU=9iuc91ZDV`0YU9((Fft zi5wf)sUA^Kd_)!?#_{$2&}COdF`;egYY#hG{&&OQwtg3no05$0SOn{t8rG5O9~$dB z*T3)WPSg>oZO+|GUp7BVXKK!*Rla+0o(7JMEny#E&%gX3K}`e0LSvvjy`c6B%+iML zCk?D0UTK0Yfx9Aq#}W5S;wWrlm;C|fc>sKHXZX8-<;Y1Jq#?-?KIyHTa;zgoJh5r1 z{y-sOg!#VtA&T4^d3`qnDEXMo67L%zw4^!W-kSDcCmqx3K;0PUy&STlg02rPUWo6+ zo_%7u(8EC+9)S~iW*nccKmyUDa-}T}Iij?UkW7G!-7XY#e{iir@U-XdFt#m~V zZ@pEb&MK!hVk0yYL2zxO?_DVwutFQTJ?w#Af_G&hw5S9vU)S{7gQb+Y^?A^|XAC%uM zVUl?|;ugxSqvOZ_wuVK^&k}7MXR<7E0E3r_C+W#H55-YOYqcM5B)W6MwS(hM$WR zYvM{gy1Xk?P)`C4)JGT!A^WSpweUICmMC% zx`l=hnsyv z*grXOAnE%2jh<403uU)M-enme0K(3nv9zU&kIaCDE8m#lG*db#fmp$5wC5GGZ0Gg^ z0myptDIpXJpXpCC*e1cHdgwFT*0Ugv7{dTdrS=cZgZWe%$;KXbEPmzH#9w+|jZiqg z18CoU&^TyPE!!xIKwgQ2?01%)B;I$TLC60vC}Jcd543~_{cn*pj)RaljXPNn1zWIv zu`#_-=q>p$z3|yM);{PSYRTQK$GJT4NbQLSCLEV}SxYg~OFt&Sm+Lcl zPsaro1po#*jX+x*WoOZ<1%G0}&--#4ZT+k?jL#YDMym-Yl24!yr#y^k^2Ni{S1BkO{jc^RJg_jvQ)ehI+yr}k zHYQC00MkYzyAv18_^v&<8d;>IFc5>aZLfABv0qU;usJ1Ji#yX$=Sq3xUh&!ux!x*I3it8;vf9zuh*~0(@iN2{J3PVf`Z!f z+z?4&abajQcU52GnQX+t74W>!I%+P$h-5;!wx`ql4 zUiE|{;CR;%WUlWe3}Awk(RKzz=Iy@mvTx>Rgp@(zmV8F^qC3}A;LgIizh1Pd$wyu^ zCndBDqC}VV{pA0upx$x-8UA4u^Kaz68;`anR3lSA@X~x;;_iR+Wg6M!YWUGB3ygr} z@{?0+P3HfX=@1~(tNPUyxzr~!M~CXfe`e7VFNMi57yUzV`f14ngqbGtda{cvcz^Ui ziSP>RF@KOh+ama=cHb}b#RgbX(URp20a20*+}Sfn;3uDX_=1~QlyNV2o2god+V;#~ zo;ZyVIx(~0jzl6gl~8|jL}H{Nz)h(&T6bs0vFk=EkA`C4Jy1qXj~S2AiHM>|xrt$9 zXw4`xU3`OK9k?>k2(Z2NR7Gu>{!*rYc8!6$sG?z#=Lu8@Ror0IC&7Mh5M%@Bxzd5x zQqSGlz{KZP<1Z>A>@)-$*Kx$4hyw(p3GX?LJax2{hYtnvA`yJ!9p<7{b&gX($M@Ksue4S8&nvW6r) zSbLD=_%2N`Fe5tRjYvx=4r!?~MmHy20nZ$2q6rlXg@xt(dj*wLQb6d?Em!cTibf5q zIhKXs`eb(Cv?uo%S0xt;vhpYVQY){tS60C$#xHlVn#uk|b2{@b$X|`dX*S*a^B&t< zJ5skd;JZ%CXI@A@{9C8iS>ja?McTeNaP}ZM=R;C08XeDB0H_AwK_Pr2ZE=SZV(n!lFO4kTd~%bL5H7@38&SelKm)U;sz*j2d}bxoLaPmy->|vdqq=S&Wv@ z+7V)y_{|#lTCRdl22~$-dsy&U|CJ=%Id=SWijrw@Wff;i`yHdUaLu(BUTM^)M?%~e zKMPSt(>FF$$+jO=gSx4vDd?8VoV2sMn zd@~z7%GARM*hfkE<7WA(qobWt4VZt_5TxxihbYHOdvrMD7#Gn!PmPb#taW(u3cD#_ z!S}2kTVF~5>{0~27weK~^!=wk8oVK^kf8B3b@LHdu&9-i zGzAi<+zlpqK1!Fer@2j($fp?rT2P_2*{lW+4bTM>)7nlA{5EjqdC!cFem(D(C7Rhc z=a%;;)b@RBvGr$bk~@qa8?=#8+TZB*?&tgOj}9*oD0G*XtL+Y^iA=#MiORtrtONs3 z9=rji?0a)^l6=m+6tmNr(lUb3Gd{SREndTs1+k5Qv zo)bs)(;C6(>tmE}jQywF)tUZfVjUPWB4?8Jm#)!@+a+yLvX9G#nuRb(Q-l(AXJUFy zD47ah2#_vfuhS``UNF2ClwF?xJX=B0VviQkhecZQUo=Y0$FtR;6u%M@x?(T zk*qs$B}8DIm+g;UJR$}?CgIS+yy{Ct~${QgMax z#^;{~EPM{?rRycZ%u{T(i0FE39>kaMkD;z@I%t%`lj5x*X1HcV$52FMyVLo=vB>r- zuGuX&$K;!bjQtw#0h2T&5y>l{n#9)Tj3 zj8bk)697*S??pA!KB5`-Sv0ENGqtJ?2Xcm%g9w*5NSnI(eRj9$vFIPSznp?aEGDMX z?;%K7W)Bzg7(J@nFgb&LzPld0eGsvu%$n#3N=|E3S=OIlSf4#rC@+OpLT*o!Jl-xGCYq#RR^vpJgxj>oP(Nsb+k$jZ zkI}O$m9r!-F1}OgmJ9$&!4j_QoyCMo)pJ2N_q>EIa+N^BPDf-*_+o0%fJ-KTHKh&S z+QtV)R%FxhzjWPn=Aupt4`3QQpQ(sGN}?I4G^;S;-96^HULEF)e?t#JK2~`$zsU2@ zuFZZUEKxI33@dKb2eMTl*H6mU7bYBMvn_6o02z^gwg>4)gPnH@%^G4M1~EqRBYML< z>Wh6V?xSEX;r{+b;z>yx6DT#(`F=FJfJV)69Kht6mO9DLV^jraeG0mM-e5UCcpS_R zcj$dj*prD2g%g#E8Ek_=uxiWkM8wB>4xN`iS$y=y5hN{HfG;o%VU?5JD4h6xM?7$s ztt$N0U7MxCq8@{s-n(xq$E|}Pe*tmur}2S-NwNtHg$N-h;X>8AeX7Tr@F-W`2`KPzNK!*6ge2hQRgLXIx` zK7@lY9FaV8^+B&O#n1_;7aV^`5bkFulqa8p$g}S9O zG z$rWKjThX|Nd4KdIc?;YJzQ?jy0-}+sQy&_fC?;RhZHkZjXdZ@^^eJ0o>Tr-5RUdS|&O;oHwC;H9f-%OfZ|2s3= zkAg@{1-H5}CHJ9USU=;vds$mBkJsxRWgp`PR56uY%ogkL+)Sz^wZ^STKBt1$#8(n3 zKk*jThPw&QtJxX+-7pw8eC=AD5ou{y_@&ADvhgKl%dgwiv&`G-QS+%mN&D*9b17sY zcu$uH2J~UgnV+{8W;I{ckj5W5sBK3-fRg=qLSRP6zdMt4Ae&lw4Dbg*LfE#Qdo(e6 z$$VbiHgO0&raJ8S$(38zds0oH$E;*ba%XHoTlwy6T8tf2t%T!rqo55=g~9fT4z}$p42lkfNFB8N4VSTBlD7Vm zVwHu+~A5K@>*Ss&?22p`L50RPAJ&Z){ZlNom6;aBv7m zRhPo+?8h%EW=iDEq`_b(I%+#SnnjqKLDJ$UrWY#dq14oU{Q|upbN@I(Kzd=yo>uJ< zny-);rJ$eh6{;H-W!6(X^Zz+BCw>rbW}$_ozBg4oQ$$?FUCr@n#PauChz2>&pj}hP zhuX%wplIz#eI9Sz&|LgT^hQ&v%qF^17}F$`yW~-S>YP4vsyr_J6Ww@2mpm_giE%7V zST(lVuE#X87jCDmG&y$G2?Jku-xH9G z+a_icSo!c^Wb3S%LtsEkU5N}esQapT+aV5WNzK7{UgvN_wn#M zR?Sl-81`zQnyK(lEMXbMfYSaB*fSVa6TIIRGq`?W1?F=CZFYRutk94(wD4`Y@~D@Z zyawOQiWN2rx;b-kX9dqcC|M^_qJW53YV7j^XE6YE4dd&*Z~`rn1Lm=KY?5~5&Il+H z@kTdFHZv^}ftCn}jKQdBMifr2qYFPdX~WEB=DyqyUBNA)Jlj{&9S*DW|5VcKrLM?b z&b(eS>h({+QbA$60%X{<=UK2UGN#Im-%s}FGuhu)`u_b=ypBkIK~`$W22hRsm81ZA7Q}q-N`UtgZ7KNYT4r2K znr8^ArI{}D<|R+?TH4U+(Tl3-?(HEL)#^${Xpjp%J-DQrC3g_~i;YcjlulZFM`e4J z^=|?K=E&k(|Dub}e1UOR9je95BXojf6j-z-zWO;7ta*v-yzChh`Y-^Nj_bqV!`-nJUm0U>rU)VG*p%XC$WIKfY97I0;A?s#nMRGjxSm=4SNBD|_sBd2RQwyv zxSSY>{XxY!jIVWMYI2=Rq)7tb8_Uhc%WWYnXR%xfx%P|+Fu2K3Ie!uNx&89 z)(1s**)~vpqEYjMw_r#QxCUtDCR@x-`$JM1&sA3B$dj*53|`~(_Tc1wulYVcB^VJ^ zuE^du6EJ~TQ&S);=F+D3f;F>-QX6|R9erR7swFJ`fE_>okxiv;4?i_tr5o{@%*F`|aHK0a^?nlbq+#^4|OYMz%HY{HJRxCsG5A{$c;R zN7kM)TxY|2HWPZ_IOK)ui-(BB2f33s_&=^@#N%|ay3bc2+QI89TR?UG?v{YD7E?4& z(tj*zxN}zG`xUPSX9*Iqylp?w&;Skn8-z3cO8ZWTwwUR6Z{CXRD!CVW_fczJM5m6$ zb6aB1Cp}DnbE3S5TYKRbgfu`W0=6fMm%CtA&NEG1ilO`a?wD9S>{rde^T%iDV8S*j zQ%B<9?F2IX_68yCD57n=LUtz{MYF{XcizEGb&JK80*}Cak^JFNhx8cd80pr2t^fGS zUX5>BWE$V3ZB|HoSOfNu{12n^>I+MDx+0;G3TFNQ zm!{fZRB8;5nV9}#-UUHUeFV<@i%)=6^W6XLGP(|A*|g`A(KB2J)qt-=w6{uvj1uIG zOAPhXgUrV9UiXCaeH+m%4D_$6ofE=5#J9}Xz-;=%awwp4cBei%70nw7i~&>#nlzE) zWb}hYVF&VwN3)S=u5ox{pOozgwKir0q@Qm^D`}Z?m5vwv`91+9Vx2-0PCXGLMZH$i z<~bA0>k8yNO-jl1+sM`KeV?~}G1&Qf>5kRjbjo*SCstc^uEioOKXL4ME2Y`ueP7@< z>B7a8fqcQM(8=_1R+@>)^)1BW)W;wXBRbvlru@0&w!9u{ z{T-4yF>?gO8o-+S2jk2b0tvh)|J&5TLz4slmNW>=fvbg`%iZ#7$ zfY0&SL|z@H$XIe`DG0>h9GhgpbeOcpLxR=;{p$2fs;d%NwLhJcNO6!>#b{yqY3KM9 z3zz))R3P0@d!bmphhF4pI9h6|(n!_YpR#f9;!?AJ2Wl+3OlaokloWX#WX7*hpApm7 zwC3hrN9TkSOGx?cY)7YXVS@%u^s8rXi3qrw1#qZr@)3a24+d>@-b+_mJW3(N9yxh~ zZudv4$xE(X)iUHjm?YA_oVAloe%ut0lrw6HbZ%QvifO2=|Gk3>|9ob{^Z!pF{a%5h zh->R&#GtAdt%%8I*5RHck5s*2jv$N5t(-i=oo55%IiLp_b_+?EnaYoNdTBl+|M-K9 zU)#}~Kc6i6niEI(-sZDHKx-KkFmO>MOBBsPf-4@fJ=M+drIrqJ+#SXXxcz{KQmy{S z!+=@xXrd#OP&PG=Uj)C{(Qr_z^%D6hxKr#kf}~pg!FlCOcS~MEV9m!tdKrMcpDFq( zQ(s}K@j(HL`*T2Og^}CJ=@fS>o8ltujm2rh_3(3++9%6=!>Sl_k7kE-6~wALiqeVE z7kD>vBi{O2yccH@EeV`w#b@LzE<7VOY9m^Fb2-u_0So~;#QQw|%tN@Ag~R{X?xLYg zf1gXt|DlO+F8%SJ>ni0ET%6hTfSF;;e<2E;i~U^1sI?A zvu5e#&Nf6BJ?~IS{J6!Tz5}Lv1V9#sTFdan<*|(+?~p{&n}ZsR^ETzJ%cvPsP3S0o ziS_W6&~-uPfr%S&VL%cpKvr|hDsU@0+FcI>W!fC#1gK-GQc!WA?bw%}H)mV|j z7;fSp^{PUH|9SxedHW@K30iGE^X%hZlC~?yWK;zg7dIFzGHi?Sz<)@cHMy8g_EuSD zUwArR+`*fUMMO*Z5AL!`ni7UuNjj(>b~0gfy^mLuQJB&C6oFI4CQ<&3TO_9XBJtSO z(&kY{59wcLOIl%V427=jxp$;M7EnJuLG5_RfQ$RWN~1Bs?d;;lIU}vLNo&H;=Y@Og z!PL%2ovD_I;XTmxHcxS+c{>v#o(aEgH%d=) zCxJZP)2eMC%)fG6W^*3&n;I!>XyI31QV&E%L_`FD!?2Ufcw-2~nOSZp+e&^} zBcZ4IaKxKvMoYrj-9Zf_ zp7;O91g2W64u>G!_Rb3OY2Uw1qZ6ygJAb}wJO|E1cm>s%15#@L>C8%d&L0!0;dL;7 zb-1|yO3Vixez@rPT;ddW@vjQ%tKv9+E4JVZHuVgn+v~0LS7yvke);BP&L%URUVZ`r%r3uIGi^e<%@|i!klt3EpLXf5?4U{XLNSOxd>i$K z%w;W;w6lT_eB(Q9HcK`}?dT~haa(Njyv9l+MHjpI4)6Q{B&uL|CfvvQY-2HSZf^}& zwebP;nY8jiW`s16XS?B>ha0HJ%Vtn=$fD4)1r7TR2}AQqeWu#R5iRlPZzFk9s)w~G z!b!%Iq+6bNn_-n~xjS{ftFS}-+r5r6De8^=0T|hXQPz2(^Vt4ptIO}EU2WNp0J6;~l~J=OiLtPKid3rlkKeAne9DtFY;bvVQ<07Z-a8-H_{Y{chXFM+C9p1d17OefAz?t{y7 z+w$O`QYGQ@F1|0fo+_lB%&j@4Y^F9E@tK%VRWkUjJR^&P#dC>@u4lP@vM)tQ5WS6) z2Fsf|W9QfWYxdO*RG=8d$N&e6OA+wZyTkOYi_GYaQm+MY03HmGE@KrXx;i3erB=&a zYRd4Mozd+2%H2_`g#4I5 z4DaRCQp=kjw6ccxRpc7nRT%mN0`5tCe28^@iAU7*!+wki@#%mhN)eKSvTCoZiXIP( zHf_fa^a_i^X0iZgGq~1qpmd?$dpP1p-8T-Ft5NR{t9TN@?`UAVY-22ol4(ko-_U0a zP#!E^y>`4_v$3@!P@;1m&o!1Lf-@1}4vg^$)MD~DaJ7)r_ZuV6V5n}7$+(EN`HJr9MQFkaReHH4MZI^YF6LYj~#IjQ~18-8UJiqeldTZgr3Ry{aWckcmIg zKpP8SWwY*tYM6BQi=-=U{JMU#6S%|vBnq{cBB9bt?D8*4_^`jz8S(AbrKFO*!-eJ7 zD;)<2(r+;}MeVbY`4DuVm5h35!QHO2KdX@|1rC4bQ+Wm`&u-Vg#tc&=fZab|$2q(_ zR3n#TDa;OK%6wBolJbixJR}gWp~k=^3;*Pzh~Mk0?;nQ9Zlq@S0gJ%K_bK9EQ!2q` zN+^XC?(P>37M{hLk;XD?y_}Krro+W8 zdAH_F-KFEnD%8<`^>zak2@jB7#c^%K^*k(mGZY#>vr;kAldaF*H&Y6C?HmV&|L~)VwZGE`1Hf#ol0I-*r6GT0eV=VwBc9yU z^2usd|L3)tr>?(O?O`ct@Xq?FDSJ@@dUYcc`kz0h%VWplSIu9SCv$eZ&#(-WPT7A+ZA-@m5d3%%9QF%T1GpDGnC>ba3=VER7oRH4rD8K4E)NJ0jvG^TBIaH*%l}XV=zYp&kS+9ETgM#@~QKOa)9eHj?pqU5H4P@@KH>#ZFBa##l{XQz2}>3^kSX zK-VpoO}BeiB)$4qoJn>{*FN>V_6iW;H{PB#po#Tg-j|m;>sVpkue?6VrI|HQ()}>o z0`m+o^f4G0Um2fn$<^@ z@^(ee(XIok-=DMw`@a9+90YI?1Q_`5&Z98+t5aZHU74vQ9SP1ip?UP}9{!=$8h zmM^PK=trx=AZ1MQIUG0o#2)3P@Gl|6vBB}jiqaVuCs*4m^|iVr9ji$pj}We`IsLZX zycWe_X{W+?iUhZ7o7-7Wi8hzxO8dPW=fQ$g`GJ9JsTq!3rExCt4VZJAxSfsJ#M_*P znidE1j2h{m^%he-3wEMz7Oq#VTPs=)vmC?fnTK8J=%+q_zBxBdSUc5mO1ExI-QP~) zB4nrA|MX76@Ex>n|D*c~dMm6}Qn%)pAZir-o4SAaN~$V~!s9vw z5C^D5CxV#UL5izu>BoxFWQ+6PC|rNva_i&7si~U%#;OMUB=JG%eBXna*>uP3%YQtZ( zIk-;S&##}(mqTk$7CrZ*wlh=4dOT8B&ZbAdWjrjMa}khnwVfV}5@Q&rN~5N+n-DJ) zLQLuKV#i@AvhEA|%tS(?O-?(044NJIwSf88lQQg@+HlR+-&&+jM|$>W{1+%Q5&qY+ zUnzn|Cc{Y6s67_;gja^|kSDnslKI1-qk7=>C4S~z|Gg`H8t1pn;P78C)G?mURZm<`VF~nhZ^tb5jdsYzD>}}q10_Ra3EZcMU zY-@BrxKK+YHm+;%J3HEQw=8RlDK_Ob+lc3Bv0si5gvQu@D6iNV{G|q>z4#nqPH??; z8WQiiq`Wi_+qmH$A38llD&Q* zr+@RM@_mLhQ_Epedh@DLU8U;baArNFRGmGO)Z?z%ThgI6UyIa*x z#)_0U_ZO(hzs4PDwO-u}*HusFx1FDkl)fzKaWI{S!`YKKSiHsXzSp5kUmj95soUBT z`<})KpXKE~#(+7FqHP>b^T7%?A7M37oxd>9bA&!HHOsQxp-I2RN8+}fDx4KINJAv1(^N=41Yf1-3Ec#MAww*@<&qvqdb z(FH{88621AK`XJ8-gXIv$IFd(Is;N4EX_a$GvSmaO*uBMF(vd4k{2)j=Jk#P_4RWA7_8gQQukk5Y(%e@V-i`{>Q7fz%Y6I%%>NCtO!V z+0eT1ABKX+;zEnjG_$vWbt5B!RnUhuV*|^QtHWR0i<|3|8+b~s9UHOiXVuQ@0pWvvy?JLj3xUsLF^ig1~eJm#L2z$s=gV*O{C?T#AF zv;MxM4`1xiMsEfMd5GD=IyRCU;aqeQhNiG|Q7EuzewS z-!ri|(~z_Fwuf&W^+48distZ?;>h}@y>1|r-=4L({tW)CpR}us)7XnNE7*Gv1jG1^ zGj&t%aU<MclNKP#&;cslF45vC@AE8o3N9yvW!0G{gWFA z55$+lq40MnW)w)9nfE#vQVKlyw&&u|eBBBA<e-{l|ze1v3xVvtYTC)3a~)lz`U?X3$6oLL#ipJpp%rpwi$-43WD(7Nv=3V~H#F zwvBQRd){TSH#L8n%1bw8Up4%(%X}{Y$2R;x0DKn3n)T0V7t={PbS3#MT7$9OFrU4Y z!{O<2rNj1pNX-3_|7|pasB5|J%0k!vYYnTOY5~f{z>h&U0`ED@tgC-%=g+SNeUsse z&Ee*>2ze|-=eJ3Dbo#1>NAv{^xLN+#nMZ!YVm>$OFsQAr)$GVacq7Yx6UM>sSuzop z>V)6z!b}>b2g0uIYXWV+aKbV~F_RE_<`O zao@mg1U7r&a$t`INE6ZLk&GS;bbX3KT0%VBMDMO}=unODk@CL!%aP9W9~PI5_%4_s zv@zXexx1mmJosL3D-(wT|2&j9>*AOOb_%O3E zb~$hf;YL7jjCs95q4)7rV6jI#g(s4D%vXvVe|gOhl(yUre|#@oUh!ggva@uj_A2kE zND!zul-ppiVzZFvDygR-wyf^toI;-hZ@0%?^RYa6e5s;D(|LkrvCg6;u5P8G5S+vM zr#=TSUSX+fA1NftPcJBe<#V6~SXyORZZjPd^yQ6|zUai~C^(G1GXpAKGG6Qu-Z|Xs zm!NQR>$vX0Dn57AfY-^j5I*es@xve266+Wp+>y1@<>+~{%LFoV|oihWhDfEB!l^kSdmX;jIq=bOsa zRGzz|tn!X2D)I zlJ={qHXj&@OCXATndGba2--A?e!5@(9^<~CA8zBxEEYI1zc+urMI|{&z>tdE&MHIA z;fM6N5c&v@&k=SdCwhZ6nEO=Q9ud7ry#&;Ze|@NK5(|Scj;$~wJ}RY+%pu`JMyEAI z7y7U6fkqv-0mmy$DGg_%xk;?K>Cf*1Vplq!j)idc^ejbk?7$5)sHz**Ml{oegra80 zCB8emzU(Yg8%#r7_3pkH`_|&)a{Ab|TMe{AuDBa78n(jopSKcfbUQ1TC(ofv7!F*fZ|La0~aUNz1q@n*7Qce~8+jX`%)~e z`y*5c1U!loIw0lBoWyZD4~e4BlhFLWf%fC&xWWI^Cenfa~n8D%W68~m-6M`qngD(*9Jb6 zaE{{O>S}AQi3+*OPHX`^1~=`~ym{ez=&^Iy<98!-5~o839YWAm>_=1D_~&IguB-Yg zKXt=6qxgK4|AU}q?pbxe&*ChomN~jY?p5C=1+9@zraU>SE4XaD*@#aR?Lc!DpLkSv zF;c8tZS2-6hCYz^iE6lA?aC8KW>>YkRM?q7MyBZQ^Tnlqe?ZZ0`!L6oa@lo&MaQND zmD#ZArG8^gjTtL<(?ue_|Jm+sgFt|h2B&BM<*rmp%v*bQrx)NoV$bke!-rh4+sVGo zX|b;+filFstA;H^WPQ#mhP#!YEUTA5LUt_WR+ zhY6&u*}~jGNSD_C0=T9yXRvINsQyXyHu_FQYJS&KrqKiP5yIC%Opt*IO6e1$4_Ovv zl~TLRnITpMlL^OCVnMV4X%+QH)ntK& zTi3s(N~m}U?hKnZ@>_hpS4YTB{apV*#<~H8QU`AA!iAf>q3yE~-%hHe(9!mu%;y^> zN=Wa(x^HK^XyB9yVe}y4;Fj90(vsaS3=m%)=`904)&u2e|HI7O*PH2L{ty%N^0WER z!B2;5`z?*(ay`v$WZ?J3_H?%?94rZ2bK=5uXe*eDj|Rk| z^qJiEDK0noeWQ)q^8KbrigI&5oP7`@7RR7B(2u1&f`nqbUM=^jpB6GtqY-;qTC^d7 zOnmerN%mR'}EEK^;IW;Jj)w~?;iW2LB4nDTB}{MoX7DK>+-eoYaXd;rrHyP@+L7Fv8heM-alQ z?a)fqU_Rf8CE+IxpQ1vWQ{VUyn|_vkLY^-DWWIO6=7rpHZX;{;7V((AS41RFtw3!c zphJ^9VBujJNIw%XUG05GU3*VEV5NxU;8GOx7{VhI=X2m_&K3?VyEe7`EtPqpHK*33 zJzwcl#m=+nD;ss7mM9(9kD9384RQl=qd)B#T&|t)tnch; z3432XkJCY%EX<3}G)&LuorbSIs}L}3;Cq_lK;mwR7-GITP_n5{(3$>OLAiOT@xd7% z^1vp`WUb-AnaZkYO?;FPV-?7}(a((z+gqIJ7boS1woT7|018(a@RU~E#vH@#o-&#V zC+7ZY323yH)vhO~w-04H9Y1a;enCGbfM4#s_u}gVc@#Oi)`ItW?8Gm2Q-x?uHkY<_ z@LWErV*xa|7ZC`|&wPJ1$JOXa*dzi|Qfo=L%;xHFT{)#opNpVQse{^Vhpl|9@^*g*RUzQ zb%(=+j8ptgtq?+x^|}#*ppBtm1c;!nU@Ah49BT%#ZW!YHGB?Z?JqXQYDREWEa$rjL zrUDw*(1f zTFwiH6{l-)>h7X)kWnhA%zzqnOjWv0$O*X?H=Xe^C_qGUsgt1YR|pKE?-w6+!`$&3 zKi$nSLGVr(1aE#nk^+#N+4k66XX4>$eO3FD%iPQhR3EE_t%^1IGy0M(Z{3pp;N{(t zxY6c+bvbbETm{6~eh~?b=bPqYJcQ?*L=VbfmKH*3nHkq7ZS|iO4)p6~%&kP$jU5wx z{gSu%h&rF^)IojCJJsVmJoEu?Dg!&#jG!YyNH1-NIG1Le;BEJ~Vnq1hwZ4ZtTv4t& z#*Fq!znwryL5Zm0`M zAPji-{ceYI^1uGRNc|AZ7t8@8L>Q?GB*Uvn=$;W3R3Uex{4oKQ;^7HN37V(SKIjjS zzp8hGB+9&fp!U5?Rsn@>L|fXUCeyX4FsX=(?F zOQ+Gz_VRtux(&ujHODtm2aQjyTr#YU$dMcwyWcxE5$(<3J3L6Z*K~#XWY~(w@0V$V z^sn_1<9EC?;9!bzD99+ID%v8>s3zaY;+F5E^6?A<>8*P?s_#C?%CG4vsO_GC@qFv4V`@9x-Ksfk>{pSqx0(A!EBrYg9)Jk z!7kaU{Lb>90_rz5x184~yl#*^*D|^@G7_xF-kC>ao3~Of*CQ1XG_7&S)qdb&s!=|j zftWjK1M>B_M38#(JRs<5e&bdOpWlg7pfKn}7=^SXao2Kb5;AlWks8DPo5@<$(bZ%tZ=jUC^{z zbkA)1Q0XSsBzm`cNA_*#PAmC z?uSLxr$?1yRQCQZDw6#zdfj>|R3)uYOq@Jc+;n?LpHN+t*a04$t@EUWlbcJr**IB! z^q1_3V*+aI@2(q+BQzvY4FJAZ;a_ytSkimJS!nQ|oqjy;y73h8PpfFti67z#SUK)Z93~i9(SexifWE21R;6s0U zNChHB9EjbXS=F>yo7TF=?$WcFc?;KQ_jAyzPd<*~1833zEae;6Q1xP9&rVe8+=}5unrHkgNq9m!}>W7H~_W*-)wrc++fGrqy)iQs0`P7>q zNAhIef^ygG271E}^{dX#7*@J*uEG&-^ zwdOeNfNGS{4eL%oaQn71*#RRLuppHaij<{WUKn)-Eef9&bjGXNg|{lA zFV7dw+0ie}zt>OcKD2WYz?&6o!$tUsE@3H-T~^Q;%TE*{KD%x75TNB=-F>+--zcn$ z-ydrg*ml$~t42{r8WGxKD}vkn#~=^um*H5^SS1dh3jJK%2(I~&<-*OG0kZn+m$zSd z1aBf#+jg*SV$KQwWgK77Wo0CpzVKB%5Yod}C+Onh7FC)-zzSm2Cs5V}6IFkhWdS+N zS}W+_539$F?-7&}&+P!wI`!ryZ@rt(=e40WS3|sZ3hUjQ#n#7{!$4{w-?caOeDI!! zR5%-CIqQuHK|iQ)*%x)2!c>Er(XK2YKP(Xo?X**%qABJwl%=dvo^aKeFzDY!00MD?}MVkd-czPflU| zP~kb&P~tuV`1!Ac zNU%Eqc0J~HroBumSs?QrW=$+ly%TWAH!}tZeJoOuWgNH?;l5GXN|eC_)M^`9m1fB9 zeEC$b%bK?KF++LZ?ho_A1Zw}QSHzD*Q`}t4vAw@jAS{q9-;BIE2?>@{OK+oPIw_PCa?>ZBY|~eNay7IP3NDNNp0xo&|C&emdh; zieg6_T;J2SaYz$Bt}OzZv{V>y4l((XdCGSlw||PH7D+OpQ<(w8Ko;$&6XD? zd8f`mQp2o!r@#plY6B(NpSIK&lIL>D}1Q*p!M8jr7b%f+1DBJ_Jr z5uIfy+%XnQ;Ukpawf~O2OKbt473id+Zf{Y|Lw`K1{8fA)pTGQvd3*2IP}-F48*A=7 z-@M(HtIv*CeM(O5%mF&(JJVR)0whwT=S!^wVm|@4F3JvtnXWVommT10~>a(AH^KCnN zl1xVv82kM!oUEJF_x^TV3yLwM?yyj zHq`nY>u&VHoGhlVFCHTJIXCeK6#|MN8~`;wyX(3tRcJe^Y8DUpz@6nsVtWeL>TzlC zx^+rzk5$4yzAaad1qB>;8Q-rZUt8F4H<0qTZHEcBfWgZY%#lhYUIr%0Qq zI)MylePa*EgAJ@tvfvoS9A>w{^$Ve-r#vtrg=kq#qM{1)FiURRf zcw#Y3*DD>vS;=WHeO#;en>M(RAhmR7^RH^NYWwi00qy^$L_Gw**7-*H58`>L@Y>wO zKd+O+E7jWn4Lp^|^mmfsokjR-yA@8;hW(bapQii!SpjojoHb+^T|tjv3Kc_6Bt zQ2FD{QyGEZ)tQ-;Gb1arZ5u8Q_{lsG$L|F%{Bw;1KaH4+;u!JwxO_wW=_Vfarx!=E9u(5StpLRnmD2`I@{Q*VQwtMN!U12@`525xZO*wY2VT z*x|3hFaG;92m0pR&wjph_gIeGQrhq9YHOAR!@}n3YiS~-N{}s3Q3;(XO9co>B^F?< zcoXm;8a0w^BZmCWDej&>CD4|v`SZ>-f|HGBM1Az{{gs}VKniUVGbs^f72`KKH8K7! zkmw1>q9w>mM~0UH-XEmd=2^o%gct|8EljRM!xXV?TjY>0^VXl}AER{nM8e#hL6t9$ z-TcwJkJu2<7VqU389h36XijGlE~z2S2BL?PqsmVS=9Ci%^}BQ-x;Z7TIzqASv0fm5 zdEx6%wTxZ{7qUB z=65O%BhV&+mBG>4PZwZn7U^KR2-T@cHB0bfs67VP8dWFVML6I+RrkuIY+Bg069OZ4>> z9xVWo9=bb%FSflES5?&9e_J~*wGqS%a}ZFx*uDL~^#UEhQ#c3^Ly(5K$~|EMB}7;d zNP|@Z>M{ovHS_lLE?~jkf%La}BmiPex?@x>Y$coOqGsQAVcV(;SwQOrJwmd0b!V zJ#qTf1AP4}zin;=a^oakeeQRNKb6kMUfeeDn=?o9w!imZUkpU&=l_qF-4}aMFVgY| zSHgdWS6w8;@eIJIoq75B`JVq!6r)~GU#kCm_^faLvOT9hQZz|F%WyOA&+V&Ki+a2Z zao-GQ9#G@@(iFm@2z$(Qi)sXq(xd zPYEvSz-Ymj-(4Sln*OBt<@8@ivwT|W)RU&!XSS-YhMUpp-!CyG<%e6Nr#+dDx`~$0 zjr$@lsT{|@M)u5@h=xS0*|c2JIq4y|0+>LQ?qW2pb>|#tYMts3K&DvmPg6XLIUJ?#zaBP&9w!Ze6)^U#hd=Pk2gAAO;(S-UZ9`p>a0|ZZ z;Y{)!q_;M{4$b`ds#adH&j@MojZ~_QAMWx=#i=6kb6xX)`6Wj*;5LRxnd4iDoGzcK z>s5{(24SjV{}@*OzH!|sASfT&)gosBcPfsVO_c8u!zLK?0=(LGals8J^l zH@UW){1CihmBrEugwS#Fmm6Q>RG-k#f+9_qPb01>gM0(Y$-!B^3m^?HWu$h^2O~MD zxRT_#>hDcWg$bq14L(T`!#KEl2256D{V2m2X`mY4du6OPYRL^Yhi$-qh@HSXphMBH zv1HSm4S%RdmY^|v%3-xy@p@@_5TEIR*L*J zH9_1%!_WlT(@Xhw$woWauGb-Q^e$#}t$ANqI9wEk))vIPb9&Il0S17>kkdV`MM_0(dA}2^{-V=ny&^qR&U7Qrb{C0#3gkGSf=K=IjjsIQy|j??%AMS5$~ zcGXKM=js8T0!Tw^lf;Yc*AQJ9$;;KzDx0rw@m!o8U)`jsq9p!d?1FtFIN4$#8q@1MYy&Q#J)w_TB&R8%l(6prS{Fg81@(%@+rd1XEa}$%d zmVUog;dDV7*-TvejUI5|HOrA>`UlVv2Bl=_4xD`jU63nHMyy{Yau*_Q`&O@9MB2?%4C#PkOEt)m|M^IIm0R(|F`IKAdM zbBa#?h?LpdyEVaK+V>BbB;$XiR5e8#Hct^dv(!>mdu#Q*-T*Du&`T+>6Ty;B7BeW%p9 z#|#UH7&)_>vy<;R!T9L&xsHe8OzCvfU1r}r&`l6yIs2$G!G!$W776g;;xym>X$B*Y zbv;QFe;_t)VsK-^I?$e=On+3d0v2rOIc3qVRm2Wz(f# zmU+cMsYzQWJgm;%v%sz}cKJQVVe@gmkMf^Q#IB}K=3>P}q2#c$xh*Xj)Hp5m5+l}v z*gd;z*wjvD*Y{Z?AGF|Di|X*iq4;W{oxg5V?qOD?IxUKigGy+VRHoAT7$m+B&Af}0 zajlM)%^77#UOzIp8+~qp1J9FUSL3p68o!dHyXKvcSy#rs1rozZ#R~W>99z&1nWrC& zI!C83n8VyI!rZ3CngZDy%J3F{ZRVYd74JHfF~P; zJGh>}w;-owqSr(8LJcaxt{6i3yc5l41T zXeC=8m{^;SpNj!9Zu|J5Lyfo4DdXg`1$|GGg0%wtV6U!?ITmt{wsg(&60l4DU7$wq za6?XSy-H(WPpuu*hH=#JYs+UeQ^Oek^WZvk##|x(_Ks(7QvPgGU#%PD9Gl2OGcOww z4R~~ST3vhFGp3No%A+}Wh4JqT){@YU@AX4BwN1CU@OlV+c7+=Hpu;A#W-c~+TX(-+ zp7ZWFl2;^-yC&hEG$yKWOauR#0e|j}#WQw%%N0>r?;!m#t=PzXWa2fMe1swx$?mDa zy2nCeCt|e;FKC09YYVN=A#1W)yqKRhzJ&qLwwb8lmu~@Ya2}pm-76X;t}Dvcp#@*x zlT*0x=xWNvwOr~CgG90B59m-{Mj0EfA~+u8XM7L-6}VY12>X2=hn-it^GgpX>8fxR z*AK0SFpd~5FI=Dr5{FSR@3r?O6p}4(zIFy#cwHiPRUsbrpx$VkthInc(R|E@LG!oo z0jhx2HCYr|nl(aw;})gB0juPj9h0FXQm8;FFI7A}ggvs&p~b~ldVQzc#-zgEf;XF8 z6jmS@R$A`j{=?IS`&0kogDd~~^nYW9|22W+#p05VF?Q3_~-Ok&bR?N0Lt=Zqx&~G^ZRd<}x!8wPnn>j|nk_jV(m!^<1~%&X_mD!QVv8RYYOl8_nXOL#X@Au70D7 z>=u_gg=X$B#BlszV`S3WXe^(uY^_>+fE${^i8bNIs@q$F^Rc;T=xffu|DkoE^+h{c6E zfvoMMgh(naEnnliZbCbsXIt%i8a7r0vL27K;?PP#tR*pCta++7T020AVZS$gu#~rv zFH$R>A#X&;ro{GR{o}cHHTa0= zcG>^~p|CO%viLa5%kJCeVC}*LkZs3b-K?DgEX~G|C(+s_-8xP_=pRV@`Mt??`iP(k zTsC4FUb;;g3$`;8pE$8*Z-w}2Gnixj4@V#=TBf8sGVc2D__sp||9MPkD1qYeJvH0C zcO?m;pqU}ah_9qfF8gjchW~M7$^Ls4y7~(#+6`^< zZsbZFZsR`ANRs8RjemxVB9o*A*rksWoe=8&LJB^QV(4<}NBM)|#k6^fVn3s`8yjRa z@Ech8r=lkMji%gBh_b9$du-2G%b~662FgD)C&P!GB?|E;E?Gv*sLf1$GiTh5E4VM# z-9F78O%g2Ma+=Jrf`a&zEUNG(-FvOq?<6-->p0l}VS5`QDIbUwY;Gpm6CJ8%Z2wxp z1++KHk~EQ>IR{0(>!ECQ+DXHsBl5AMu}5QnWa5m6PkWrqAY0H-q4R{T$N~uQB?C9BWuzjYJmNPgvi*vF{NnXLUd@XmALnUWtm%~ zYGiylg8oW~sO;YS;?8;4*l^TMuhVPopx~+*z=VkMh=LYP-S`0g99ym|^SArOT zoya{_WFxp$+nqb)x<(O#1Y1kPXj3|J-=;UiXWhKZOMtzZ*df|M5^au?gKjFtGdy$> z{tU7hTQTa;81i7ULi_`SJ~SX*G+nRq}d#wWzMq zj50N`%+o&RVA^T7aCcs48_u9}oeJ@jGihSYxmaaJPL(4Hb{;J>CZ zxd*|)iGVGWDmVj4;#Vees(f#e5Bfb_*%kO5by#R;NIg&0v#|fr}W`+8Ip1pj{~GPewTH z9%iLpEi^kWC0zF#DY^KsyUUN!p*FCwhZyQ{jITa4T3ZrD+sh5TK3ki7l@NIieAz#V zb&0}x>OAtvF=afPMa7RU%>JK^-#sP%!Qd9EU+zc4} zK$8h3IRL-&L>?W3j1^+0Mh#+Pb|@HL8e^Yd`t2K7IMwbMVo};pu#x@c99p!xea@zg7#VVtZ_6LBJ^cyg)I9_VJo)@&{>kVQOkrkHVm&O$qv^v^ZDF^hEsT|U%e zFO&thR($FdACa_=m-8pT;C<6zNYmqO2`$klWdQXby4rt!`tKFC|C&erFGAe^*UQ$n aARl){FReIPYw&=d^QSMKDmA-)?|%S_7p}] +``` + +By default, the package is set up to automatically pass any unknown flags forwards to pytest. Check the tox and pytest documentation for more information. + +Code coverage is automatically reported for causal_validation; +to add other packages, modify `pyproject.toml` in the package root directory. + +To debug failing tests, use the helpful `guard` command which runs the testing on a watch looponfailroots + +``` +$ brazil-build guard +``` + +Or if you want to debug the tests with a debugger open to the failed test, use pytest's pdb option: +``` +$ brazil-build pytest --pdb +``` diff --git a/tests/test_causal_validation/__init__.py b/tests/test_causal_validation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_causal_validation/test_amzn_synthetic_causal_data_gen.py b/tests/test_causal_validation/test_amzn_synthetic_causal_data_gen.py new file mode 100644 index 0000000..a244514 --- /dev/null +++ b/tests/test_causal_validation/test_amzn_synthetic_causal_data_gen.py @@ -0,0 +1,2 @@ +def test_causal_validation_importable(): + assert True diff --git a/tests/test_causal_validation/test_base.py b/tests/test_causal_validation/test_base.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_causal_validation/test_data.py b/tests/test_causal_validation/test_data.py new file mode 100644 index 0000000..fd79430 --- /dev/null +++ b/tests/test_causal_validation/test_data.py @@ -0,0 +1,168 @@ +from azcausal.estimators.panel.did import DID +from hypothesis import ( + given, + settings, + strategies as st, +) +import numpy as np +import pandas as pd +from pandas.core.indexes.datetimes import DatetimeIndex + +from causal_validation.data import Dataset +from causal_validation.testing import ( + TestConstants, + simulate_data, +) +from causal_validation.types import InterventionTypes + +DEFAULT_SEED = 123 +NUM_NON_CONTROL_COLS = 2 +LARGE_N_POST = 5000 +LARGE_N_PRE = 5000 + + +@given( + seed=st.integers(min_value=1, max_value=30), + global_mean=st.floats( + min_value=-5.0, max_value=5.0, allow_infinity=False, allow_nan=False + ), +) +def test_global_mean(seed: int, global_mean: float): + constants = TestConstants( + N_POST_TREATMENT=LARGE_N_POST, N_PRE_TREATMENT=LARGE_N_PRE, GLOBAL_SCALE=0.01 + ) + data = simulate_data(global_mean, seed, constants=constants) + assert isinstance(data, Dataset) + + control_units = data.control_units + treated_units = data.treated_units + + np.testing.assert_almost_equal( + np.mean(control_units, axis=0), global_mean, decimal=0 + ) + np.testing.assert_almost_equal( + np.mean(treated_units, axis=0), global_mean, decimal=0 + ) + + +@given( + n_control=st.integers(min_value=1, max_value=50), + n_pre_treatment=st.integers(min_value=1, max_value=50), + n_post_treatment=st.integers(min_value=1, max_value=50), +) +def test_array_shapes(n_control: int, n_pre_treatment: int, n_post_treatment: int): + constants = TestConstants( + N_POST_TREATMENT=n_post_treatment, + N_PRE_TREATMENT=n_pre_treatment, + N_CONTROL=n_control, + ) + data = simulate_data(0.0, DEFAULT_SEED, constants=constants) + + # Test high-level property values + assert data.n_units == n_control + assert data.n_timepoints == n_pre_treatment + n_post_treatment + assert data.n_pre_intervention == n_pre_treatment + assert data.n_post_intervention == n_post_treatment + + # Test field shapes + assert data.Xtr.shape == (n_pre_treatment, n_control) + assert data.Xte.shape == (n_post_treatment, n_control) + assert data.ytr.shape == (n_pre_treatment, 1) + assert data.yte.shape == (n_post_treatment, 1) + + # Test property shapes + Xtr, ytr = data.pre_intervention_obs + Xte, yte = data.post_intervention_obs + assert Xtr.shape == (n_pre_treatment, n_control) + assert ytr.shape == (n_pre_treatment, 1) + assert Xte.shape == (n_post_treatment, n_control) + assert yte.shape == (n_post_treatment, 1) + + +@given( + n_pre_treatment=st.integers(min_value=1, max_value=50), + n_post_treatment=st.integers(min_value=1, max_value=50), +) +def test_indicator(n_pre_treatment: int, n_post_treatment: int): + constants = TestConstants( + N_POST_TREATMENT=n_post_treatment, + N_PRE_TREATMENT=n_pre_treatment, + ) + data = simulate_data(0.0, DEFAULT_SEED, constants=constants) + assert data._get_indicator().sum() == n_post_treatment + + +@given( + n_control=st.integers(min_value=1, max_value=50), + n_pre_treatment=st.integers(min_value=1, max_value=50), + n_post_treatment=st.integers(min_value=1, max_value=50), +) +def test_to_df(n_control: int, n_pre_treatment: int, n_post_treatment: int): + constants = TestConstants( + N_POST_TREATMENT=n_post_treatment, + N_PRE_TREATMENT=n_pre_treatment, + N_CONTROL=n_control, + ) + data = simulate_data(0.0, DEFAULT_SEED, constants=constants) + + df = data.to_df() + assert isinstance(df, pd.DataFrame) + assert df.shape == ( + n_pre_treatment + n_post_treatment, + n_control + NUM_NON_CONTROL_COLS, + ) + + colnames = data._get_columns() + assert isinstance(colnames, list) + assert colnames[0] == "T" + assert len(colnames) == n_control + 1 + + index = data.full_index + assert isinstance(index, DatetimeIndex) + assert index[0].strftime("%Y-%m-%d") == data._start_date.strftime("%Y-%m-%d") + + +@given( + n_control=st.integers(min_value=2, max_value=50), + n_pre_treatment=st.integers(min_value=10, max_value=50), + n_post_treatment=st.integers(min_value=10, max_value=50), + global_mean=st.floats( + min_value=-5.0, max_value=5.0, allow_infinity=False, allow_nan=False + ), +) +@settings(max_examples=5) +def test_to_azcausal( + n_control: int, n_pre_treatment: int, n_post_treatment: int, global_mean: float +): + constants = TestConstants( + N_POST_TREATMENT=n_post_treatment, + N_PRE_TREATMENT=n_pre_treatment, + N_CONTROL=n_control, + ) + data = simulate_data(global_mean, DEFAULT_SEED, constants=constants) + + panel = data.to_azcausal() + model = DID() + result = model.fit(panel) + assert not np.isnan(result.effect.value) + + +@given( + n_post_treatment=st.integers(min_value=10, max_value=50), + n_pre_treatment=st.integers(min_value=10, max_value=50), + idx=st.sampled_from(["pre-intervention", "post-intervention", "both"]), +) +def test_get_index(n_post_treatment: int, n_pre_treatment: int, idx: InterventionTypes): + constants = TestConstants( + N_POST_TREATMENT=n_post_treatment, + N_PRE_TREATMENT=n_pre_treatment, + ) + data = simulate_data(0.0, DEFAULT_SEED, constants=constants) + idx_vals = data.get_index(idx) + assert isinstance(idx_vals, DatetimeIndex) + if idx == "both": + assert len(idx_vals) == n_pre_treatment + n_post_treatment + elif idx == "post-intervention": + assert len(idx_vals) == n_post_treatment + elif idx == "pre-intervention": + assert len(idx_vals) == n_pre_treatment diff --git a/tests/test_causal_validation/test_effect.py b/tests/test_causal_validation/test_effect.py new file mode 100644 index 0000000..f959315 --- /dev/null +++ b/tests/test_causal_validation/test_effect.py @@ -0,0 +1,58 @@ +from hypothesis import ( + given, + strategies as st, +) + +from causal_validation.effects import StaticEffect +from causal_validation.testing import ( + TestConstants, + simulate_data, +) + +EFFECT_LOWER_BOUND = 1e-3 + + +@st.composite +def effect_strategy(draw): + lower_range = st.floats( + min_value=-0.1, + max_value=-1e-4, + exclude_max=True, + allow_infinity=False, + allow_nan=False, + ) + upper_range = st.floats( + min_value=1e-4, + max_value=0.1, + exclude_min=True, + allow_infinity=False, + allow_nan=False, + ) + combined_strategy = st.one_of(lower_range, upper_range) + return draw(combined_strategy) + + +@given( + global_mean=st.floats( + min_value=20.0, max_value=50.0, allow_nan=False, allow_infinity=False + ), + effect_val=effect_strategy(), + seed=st.integers(min_value=1, max_value=10), +) +def test_array_shapes(global_mean: float, effect_val: float, seed: int): + constants = TestConstants(GLOBAL_SCALE=0.01) + data = simulate_data(global_mean, seed, constants=constants) + effect = StaticEffect(effect=effect_val) + + inflated_data = effect(data) + if effect_val == 0: + assert inflated_data.yte.sum() == data.yte.sum() + elif effect_val < 0: + assert inflated_data.yte.sum() < data.yte.sum() + elif effect_val > 0: + assert inflated_data.yte.sum() > data.yte.sum() + + assert inflated_data.counterfactual.sum() == data.yte.sum() + + _effects = effect.get_effect(data) + assert _effects.shape == (data.n_post_intervention, 1) diff --git a/tests/test_causal_validation/test_integration.py b/tests/test_causal_validation/test_integration.py new file mode 100644 index 0000000..0525bec --- /dev/null +++ b/tests/test_causal_validation/test_integration.py @@ -0,0 +1,37 @@ +import numpy as np +import pytest + +from causal_validation import ( + Config, + simulate, +) +from causal_validation.data import Dataset +from causal_validation.transforms import ( + Periodic, + Trend, +) + + +def _sum_data(data: Dataset) -> float: + return data.Xtr.sum() + data.ytr.sum() + data.Xte.sum() + data.yte.sum() + + +@pytest.mark.parametrize( + "seed,e1,e2", [(123, 19794.92, 63849.93), (42, 19803.64, 63858.64)] +) +def test_end_to_end(seed: int, e1: float, e2: float): + cfg = Config( + n_control_units=10, + n_pre_intervention_timepoints=60, + n_post_intervention_timepoints=30, + seed=seed, + ) + + data = simulate(cfg) + np.testing.assert_approx_equal(_sum_data(data), e1, significant=2) + + t = Trend() + np.testing.assert_approx_equal(_sum_data(t(data)), e2, significant=2) + + p = Periodic() + np.testing.assert_approx_equal(_sum_data(p(t(data))), e2, significant=2) diff --git a/tests/test_causal_validation/test_plotters.py b/tests/test_causal_validation/test_plotters.py new file mode 100644 index 0000000..fcdd286 --- /dev/null +++ b/tests/test_causal_validation/test_plotters.py @@ -0,0 +1,55 @@ +from hypothesis import ( + given, + settings, + strategies as st, +) +from matplotlib.axes._axes import Axes +import matplotlib.pyplot as plt + +from causal_validation.plotters import plot +from causal_validation.testing import ( + TestConstants, + simulate_data, +) + +DEFAULT_SEED = 123 +NUM_AUX_LINES = 2 +LARGE_N_POST = 5000 +LARGE_N_PRE = 5000 +N_LEGEND_ENTRIES = 3 + + +# Define a strategy for generating titles +title_strategy = st.text( + alphabet=st.characters( + whitelist_categories=("Lu", "Ll", "Nd"), whitelist_characters=" " + ), + min_size=1, + max_size=50, +) + + +@given( + n_control=st.integers(min_value=1, max_value=50), + n_pre_treatment=st.integers(min_value=1, max_value=50), + n_post_treatment=st.integers(min_value=1, max_value=50), + ax_bool=st.booleans(), +) +@settings(max_examples=5) +def test_plot( + n_control: int, n_pre_treatment: int, n_post_treatment: int, ax_bool: bool +): + constants = TestConstants( + N_POST_TREATMENT=n_post_treatment, + N_PRE_TREATMENT=n_pre_treatment, + N_CONTROL=n_control, + ) + data = simulate_data(0.0, DEFAULT_SEED, constants=constants) + if ax_bool: + _, ax = plt.subplots() + ax = plot(data) + assert isinstance(ax, Axes) + assert len(ax.lines) == n_control + 2 + assert ax.get_legend() is not None + assert len(ax.get_legend().get_texts()) == N_LEGEND_ENTRIES + plt.close() diff --git a/tests/test_causal_validation/test_transforms/test_periodic.py b/tests/test_causal_validation/test_transforms/test_periodic.py new file mode 100644 index 0000000..07f845f --- /dev/null +++ b/tests/test_causal_validation/test_transforms/test_periodic.py @@ -0,0 +1,178 @@ +from hypothesis import ( + given, + settings, + strategies as st, +) +import numpy as np +from scipy.stats import norm + +from causal_validation.data import Dataset +from causal_validation.testing import ( + TestConstants, + simulate_data, +) +from causal_validation.transforms import Periodic +from causal_validation.transforms.parameter import UnitVaryingParameter + +CONSTANTS = TestConstants() +DEFAULT_SEED = 123 +GLOBAL_MEAN = 20 +GLOBAL_SCALE = 0.5 + + +@given( + frequency=st.integers(min_value=1, max_value=20), + amplitude=st.floats( + min_value=-100, max_value=100, allow_infinity=False, allow_nan=False + ), + shift=st.floats( + min_value=-100, max_value=100, allow_infinity=False, allow_nan=False + ), + offset=st.floats( + min_value=-100, max_value=100, allow_infinity=False, allow_nan=False + ), + global_mean=st.floats( + min_value=-5.0, max_value=5.0, allow_infinity=False, allow_nan=False + ), +) +@settings(max_examples=5) +def test_periodic_initialisation( + frequency: int, + amplitude: float, + shift: float, + offset: float, + global_mean: float, +): + periodic_transform = Periodic( + amplitude=amplitude, frequency=frequency, shift=shift, offset=offset + ) + base_data = simulate_data(global_mean, DEFAULT_SEED) + data = periodic_transform(base_data) + assert isinstance(data, Dataset) + for slot in CONSTANTS.DATA_SLOTS: + _base_data_array = getattr(base_data, slot) + _data_array = getattr(data, slot) + assert _base_data_array.shape == _data_array.shape + assert np.sum(np.isnan(_base_data_array)) == 0 + assert np.sum(np.isnan(_data_array)) == 0 + + +@given( + frequency=st.integers(min_value=1, max_value=20), + seed=st.integers(min_value=1, max_value=30), + global_mean=st.floats( + min_value=-5.0, max_value=5.0, allow_infinity=False, allow_nan=False + ), +) +def test_frequency_param(frequency: int, seed: int, global_mean: float): + periodic_transform = Periodic(amplitude=1, frequency=frequency, shift=0, offset=0) + base_data = simulate_data(global_mean, seed) + data = periodic_transform(base_data) + np.testing.assert_array_almost_equal( + np.mean(data.control_units, axis=0), np.mean(base_data.control_units, axis=0) + ) + np.testing.assert_array_almost_equal( + np.mean(data.treated_units, axis=0), np.mean(base_data.treated_units, axis=0) + ) + + +@st.composite +def amplitude_strategy(draw): + lower_range = st.floats( + min_value=-100, + max_value=-1e-6, + exclude_max=True, + allow_infinity=False, + allow_nan=False, + ) + upper_range = st.floats( + min_value=1e-6, + max_value=100, + exclude_min=True, + allow_infinity=False, + allow_nan=False, + ) + combined_strategy = st.one_of(lower_range, upper_range) + return draw(combined_strategy) + + +@given( + amplitude=amplitude_strategy(), + seed=st.integers(min_value=1, max_value=30), + global_mean=st.floats( + min_value=-5.0, max_value=5.0, allow_infinity=False, allow_nan=False + ), +) +def test_amplitude_param(amplitude: float, seed: int, global_mean: float): + periodic_transform = Periodic(frequency=1, amplitude=amplitude, shift=0, offset=0) + base_data = simulate_data(global_mean, seed) + data = periodic_transform(base_data) + + assert np.isclose( + np.max(data.control_units - base_data.control_units), np.abs(amplitude), rtol=1 + ) + assert np.isclose( + np.max(data.treated_units - base_data.treated_units), np.abs(amplitude), rtol=1 + ) + + +@given( + frequency=st.integers(min_value=1, max_value=20), + seed=st.integers(min_value=1, max_value=30), + global_mean=st.floats( + min_value=-5.0, max_value=5.0, allow_infinity=False, allow_nan=False + ), +) +def test_num_frequencies(frequency: int, seed: int, global_mean: float): + periodic_transform = Periodic(frequency=frequency, amplitude=1, shift=0, offset=0) + base_data = simulate_data(global_mean, seed) + data = periodic_transform(base_data) + control_units = data.control_units + treated_units = data.treated_units + for d in [control_units, treated_units]: + num_samples = d.shape[0] + fft_vals = np.fft.fft(d, axis=0) + peak_frequency = np.argmax(np.abs(fft_vals[1 : num_samples // 2]), axis=0) + 1 + np.testing.assert_equal(peak_frequency, frequency) + + +@given( + offset=st.floats( + min_value=-100, max_value=100, allow_infinity=False, allow_nan=False + ), + seed=st.integers(min_value=1, max_value=30), + global_mean=st.floats( + min_value=-5.0, max_value=5.0, allow_infinity=False, allow_nan=False + ), +) +def test_offset(offset: float, seed: int, global_mean: float): + periodic_transform = Periodic(frequency=1, amplitude=5, shift=0, offset=offset) + base_data = simulate_data(global_mean, seed) + data = periodic_transform(base_data) + original_array = base_data.treated_units.squeeze() + offset_array = data.treated_units.squeeze() + + normal_mean = np.mean(original_array) + offset_mean = np.mean(offset_array) + assert np.isclose(offset_mean - normal_mean, offset, atol=0.1) + + +def test_varying_parameters(): + periodic_transform = Periodic() + param_slots = periodic_transform._slots + constants = TestConstants(N_CONTROL=2) + data_slots = constants.DATA_SLOTS + base_data = simulate_data(GLOBAL_MEAN, DEFAULT_SEED, constants=constants) + base_data_transform = periodic_transform(base_data) + for slot in param_slots: + setattr( + periodic_transform, + slot, + UnitVaryingParameter(sampling_dist=norm(GLOBAL_MEAN, GLOBAL_SCALE)), + ) + data = periodic_transform(base_data) + for dslot in data_slots: + assert not np.any(np.isnan(getattr(data, dslot))) + assert not np.array_equal( + getattr(data, dslot), getattr(base_data_transform, dslot) + ) diff --git a/tests/test_causal_validation/test_transforms/test_trends.py b/tests/test_causal_validation/test_transforms/test_trends.py new file mode 100644 index 0000000..1c9e3da --- /dev/null +++ b/tests/test_causal_validation/test_transforms/test_trends.py @@ -0,0 +1,122 @@ +from hypothesis import ( + given, + settings, + strategies as st, +) +import numpy as np +from scipy.stats import norm + +from causal_validation.testing import ( + TestConstants, + simulate_data, +) +from causal_validation.transforms import Trend +from causal_validation.transforms.parameter import UnitVaryingParameter + +CONSTANTS = TestConstants() +DEFAULT_SEED = 123 +GLOBAL_MEAN = 20 +STATES = [42, 123] + + +@st.composite +def coefficient_strategy(draw): + lower_range = st.floats( + min_value=-1, + max_value=-1e-6, + exclude_max=True, + allow_infinity=False, + allow_nan=False, + ) + upper_range = st.floats( + min_value=1e-6, + max_value=1, + exclude_min=True, + allow_infinity=False, + allow_nan=False, + ) + combined_strategy = st.one_of(lower_range, upper_range) + return draw(combined_strategy) + + +@given(degree=st.integers(min_value=1, max_value=3), coefficient=coefficient_strategy()) +@settings(max_examples=5) +def test_trend_coefficient(degree: int, coefficient: float): + trend_transform = Trend(degree=degree, coefficient=coefficient, intercept=0) + base_data = simulate_data(GLOBAL_MEAN, DEFAULT_SEED) + data = trend_transform(base_data) + + if coefficient > 1: + assert np.all(data.Xtr[-1, :] > base_data.Xtr[-1, :]) + elif coefficient < 0: + assert np.all(data.Xtr[-1, :] < base_data.Xtr[-1, :]) + + +@given(intercept=coefficient_strategy()) +@settings(max_examples=5) +def test_trend_intercept(intercept: float): + trend_transform = Trend(degree=1, coefficient=0, intercept=intercept) + base_data = simulate_data(GLOBAL_MEAN, DEFAULT_SEED) + data = trend_transform(base_data) + + if intercept > 0: + assert np.all(data.Xtr > base_data.Xtr) + elif intercept < 0: + assert np.all(data.Xtr < base_data.Xtr) + + +@given( + loc=st.floats( + min_value=-10.0, max_value=10.0, allow_nan=False, allow_infinity=False + ), + scale=st.floats( + min_value=1e-3, max_value=10, allow_infinity=False, allow_nan=False + ), +) +def test_varying_trend(loc: float, scale: float): + constants = TestConstants( + N_CONTROL=2, + ) + data = simulate_data(GLOBAL_MEAN, DEFAULT_SEED, constants=constants) + sampling_dist = norm(loc, scale) + param = UnitVaryingParameter(sampling_dist=sampling_dist) + trend = Trend(degree=1, coefficient=0.0, intercept=param) + transformed_data = trend(data) + assert not np.array_equal(transformed_data.Xtr[:, 0], transformed_data.Xtr[:, 1]) + + trend = Trend(degree=1, coefficient=param, intercept=0.0) + transformed_data = trend(data) + assert not np.array_equal(transformed_data.Xtr[:, 0], transformed_data.Xtr[:, 1]) + + +@given( + loc=st.floats( + min_value=-10.0, max_value=10.0, allow_nan=False, allow_infinity=False + ), + scale=st.floats( + min_value=1e-3, max_value=10, allow_infinity=False, allow_nan=False + ), +) +@settings(max_examples=5) +def test_randomness(loc: float, scale: float): + constants = TestConstants( + N_CONTROL=2, + ) + data = simulate_data(GLOBAL_MEAN, DEFAULT_SEED, constants=constants) + SLOTS = TestConstants().DATA_SLOTS + + for slot in SLOTS: + transformed_datas = [] + for random_state in STATES: + sampling_dist = norm(loc, scale) + param = UnitVaryingParameter( + sampling_dist=sampling_dist, random_state=random_state + ) + trend = Trend(degree=1, coefficient=0.0, intercept=param) + transformed_datas.append(trend(data)) + assert not np.array_equal( + getattr(transformed_datas[0], slot), getattr(transformed_datas[1], slot) + ) + assert not np.array_equal( + getattr(transformed_datas[0], slot), getattr(data, slot) + ) diff --git a/tests/test_causal_validation/test_weights.py b/tests/test_causal_validation/test_weights.py new file mode 100644 index 0000000..1b5ed6d --- /dev/null +++ b/tests/test_causal_validation/test_weights.py @@ -0,0 +1,32 @@ +from hypothesis import ( + given, + strategies as st, +) +import numpy as np + +from causal_validation.weights import UniformWeights + + +@given( + n_units=st.integers(min_value=1, max_value=100), + n_time=st.integers(min_value=1, max_value=100), +) +def test_uniform_weights(n_units: int, n_time: int): + weights = UniformWeights() + data = np.random.random(size=(n_time, n_units)) + weight_vals = weights.get_weights(data) + np.testing.assert_almost_equal(np.mean(weight_vals), weight_vals, decimal=6) + assert weight_vals.shape == (n_units, 1) + + +@given( + n_units=st.integers(min_value=1, max_value=100), + n_time=st.integers(min_value=1, max_value=100), +) +def test_weight_obs(n_units: int, n_time: int): + obs = np.ones(shape=(n_time, n_units)) + weighted_obs = UniformWeights()(obs) + np.testing.assert_almost_equal(np.mean(weighted_obs), weighted_obs, decimal=6) + np.testing.assert_almost_equal( + obs @ UniformWeights().get_weights(obs), weighted_obs, decimal=6 + ) From 9dbe0096beb3f0908cbf17723dc9c262bcb0fa01 Mon Sep 17 00:00:00 2001 From: Thomas Pinder Date: Thu, 22 Aug 2024 20:12:47 +0200 Subject: [PATCH 2/3] Initial commit --- .github/pull_request_template.md | 11 +++++++++++ .github/workflows/ruff.yml | 12 +++++++++++ .github/workflows/tests.yml | 34 ++++++++++++++++++++++++++++++++ pyproject.toml | 14 ++++++------- 4 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ruff.yml create mode 100644 .github/workflows/tests.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..4996f57 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Checklist + +- [ ] I've formatted the new code by running `hatch run dev:format` before committing. +- [ ] I've added tests for new code. +- [ ] I've added docstrings for the new code. + +## Description + +Please describe your changes here. If this fixes a bug, please link to the issue, if possible. + +Issue Number: N/A \ No newline at end of file diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..2539eeb --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,12 @@ +name: Check linting +on: + pull_request: + push: + branches: + - main +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3.5.2 + - uses: chartboost/ruff-action@v1 \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a15ee52 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: Run Tests +on: + pull_request: + push: + branches: + - main + +jobs: + unit-tests: + name: Run Tests + runs-on: ubuntu-latest + strategy: + matrix: + # Select the Python versions to test against + os: ["ubuntu-latest", "macos-latest"] + python-version: ["3.10", "3.11"] + fail-fast: true + steps: + - name: Check out the code + uses: actions/checkout@v3.5.2 + with: + fetch-depth: 1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + # Install Hatch + - name: Install Hatch + uses: pypa/hatch@install + + # Run the unit tests and build the coverage report + - name: Run Tests + run: hatch run dev:test \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 00e5423..d93258b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,11 @@ dependencies = [ include = ["src/causal_validation"] packages = ["src/causal_validation"] -[tool.hatch.envs.tests] +[tool.hatch.envs.default] +installer = "uv" +python = "3.10" + +[tool.hatch.envs.dev] dependencies = [ "mypy", "black", @@ -52,16 +56,12 @@ dependencies = [ "hypothesis", "pre-commit", "absolufy-imports", - ] - -[tool.hatch.envs.dev] -dependencies = [ "ipykernel", "ipython", "jupytext", ] -[tool.hatch.envs.tests.scripts] +[tool.hatch.envs.dev.scripts] test = "pytest --hypothesis-profile causal_validation" ptest = "pytest -n auto . --hypothesis-profile causal_validation" format = [ @@ -69,8 +69,6 @@ format = [ "black src tests", "ruff format src tests" ] - -[tool.hatch.envs.dev.scripts] build_nbs = [ "jupytext --to notebook examples/*.pct.py", "mv examples/*.ipynb nbs" From f42653bd6464fb59ec3aebd1b36999b7a57b89eb Mon Sep 17 00:00:00 2001 From: Thomas Pinder Date: Thu, 22 Aug 2024 20:40:22 +0200 Subject: [PATCH 3/3] Lint --- examples/azcausal.pct.py | 24 ++++++++++-------------- examples/basic.pct.py | 4 ++-- pyproject.toml | 8 +++++--- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/examples/azcausal.pct.py b/examples/azcausal.pct.py index a939358..1ac2bbb 100644 --- a/examples/azcausal.pct.py +++ b/examples/azcausal.pct.py @@ -35,7 +35,7 @@ data = linear_trend(simulate(cfg)) plot(data) -# %% [markdown] We'll now simulate a 5% lift in the treatment group's observations. This +# %% We'll now simulate a 5% lift in the treatment group's observations. This [markdown] # will inflate the treated group's observations in the post-intervention window. # %% @@ -62,27 +62,23 @@ print(f"Delta: {TRUE_EFFECT - result.effect.percentage().value / 100}") print(result.summary(title="Synthetic Data Experiment")) -# %% [markdown] -# We see that SDID has done an excellent job of estimating the treatment effect. -# However, given the simplicity of the data, this is not surprising. With the +# %% We see that SDID has done an excellent job of estimating the treatment [markdown] +# effect. However, given the simplicity of the data, this is not surprising. With the # functionality within this package though we can easily construct more complex datasets # in effort to fully stress-test any new model and identify its limitations. # # To achieve this, we'll simulate 10 control units, 60 pre-intervention time points, and -# 30 post-intervention time points according to the following process: -# $$ -# \begin{align} +# 30 post-intervention time points according to the following process: $$ \begin{align} # \mu_{n, t} & \sim\mathcal{N}(20, 0.5^2)\\ # \alpha_{n} & \sim \mathcal{N}(0, 1^2)\\ # \beta_{n} & \sim \mathcal{N}(0.05, 0.01^2)\\ # \nu_n & \sim \mathcal{N}(1, 1^2)\\ # \gamma_n & \sim \operatorname{Student-t}_{10}(1, 1^2)\\ -# \mathbf{Y}_{n, t} & = \mu_{n, t} + \alpha_{n} + \beta_{n}t + \nu_n\sin\left(3\times 2\pi t + \gamma\right) + \delta_{t, n} -# \end{align} -# $$ -# where the true treatment effect $\delta_{t, n}$ is 5% when $n=1$ and $t\geq 60$ and 0 -# otherwise. Meanwhile, $\mathbf{Y}$ is the matrix of observations, long in the number -# of time points and wide in the number of units. +# \mathbf{Y}_{n, t} & = \mu_{n, t} + \alpha_{n} + \beta_{n}t + \nu_n\sin\left(3\times +# 2\pi t + \gamma\right) + \delta_{t, n} \end{align} $$ where the true treatment effect +# $\delta_{t, n}$ is 5% when $n=1$ and $t\geq 60$ and 0 otherwise. Meanwhile, +# $\mathbf{Y}$ is the matrix of observations, long in the number of time points and wide +# in the number of units. # %% cfg = Config( @@ -105,7 +101,7 @@ data = effect(periodic(linear_trend(simulate(cfg)))) plot(data) -# %% [markdown] As before, we may now go about estimating the treatment. However, this +# %% As before, we may now go about estimating the treatment. However, this [markdown] # time we see that the delta between the estaimted and true effect is much larger than # before. diff --git a/examples/basic.pct.py b/examples/basic.pct.py index 4df01aa..60c5217 100644 --- a/examples/basic.pct.py +++ b/examples/basic.pct.py @@ -2,6 +2,7 @@ from itertools import product import matplotlib.pyplot as plt +import numpy as np from scipy.stats import ( norm, poisson, @@ -22,7 +23,7 @@ # %% [markdown] # ## Simulating a Dataset -# %% [markdown] Simulating a dataset is as simple as specifying a `Config` object and +# %% Simulating a dataset is as simple as specifying a `Config` object and [markdown] # then invoking the `simulate` function. Once simulated, we may visualise the data # through the `plot` function. @@ -90,7 +91,6 @@ # Or manually specifying and passing your own pseudorandom number generator key # %% -import numpy as np rng = np.random.RandomState(42) diff --git a/pyproject.toml b/pyproject.toml index d93258b..4ea4544 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,11 +64,13 @@ dependencies = [ [tool.hatch.envs.dev.scripts] test = "pytest --hypothesis-profile causal_validation" ptest = "pytest -n auto . --hypothesis-profile causal_validation" -format = [ +black-format = ["black src tests", "jupytext --pipe black examples/*.py"] +imports-format = [ "isort src tests", - "black src tests", - "ruff format src tests" + "isort examples/*.py --treat-comment-as-code '# %%' --float-to-top", ] +lint-format = ['ruff format src tests examples'] +format = ["black-format", "imports-format", "lint-format"] build_nbs = [ "jupytext --to notebook examples/*.pct.py", "mv examples/*.ipynb nbs"