From 80d15231848504ea763a7e878aade18c991cd45f Mon Sep 17 00:00:00 2001 From: chrisholder Date: Tue, 26 Nov 2024 19:48:41 +0000 Subject: [PATCH 1/5] [MNT] Change the "metric" parameter to "measure in the distance module (#2403) * refactor metric to measure * refactor metric to measure in clustering module --- .../distance_based/_proximity_tree.py | 6 +- .../distance_based/_time_series_neighbors.py | 2 +- aeon/clustering/_clara.py | 6 +- aeon/clustering/_clarans.py | 6 +- aeon/clustering/_elastic_som.py | 8 +- aeon/clustering/_k_means.py | 14 +- aeon/clustering/_k_medoids.py | 12 +- aeon/clustering/averaging/_ba_petitjean.py | 2 +- aeon/clustering/averaging/_ba_subgradient.py | 2 +- aeon/clustering/averaging/_ba_utils.py | 4 +- .../averaging/_barycenter_averaging.py | 2 +- aeon/distances/_distance.py | 188 +++++++++--------- .../elastic/tests/test_alignment_path.py | 2 +- .../elastic/tests/test_cost_matrix.py | 6 +- aeon/distances/tests/test_distances.py | 34 ++-- .../tests/test_numba_distance_parameters.py | 2 +- aeon/distances/tests/test_pairwise.py | 18 +- .../distance_based/_time_series_neighbors.py | 2 +- .../channel_selection/_elbow_class.py | 2 +- .../series/tests/test_warping.py | 2 +- examples/distances/distances.ipynb | 4 +- .../Lets_do_the_time_warp_again.ipynb | 20 +- 22 files changed, 175 insertions(+), 169 deletions(-) diff --git a/aeon/classification/distance_based/_proximity_tree.py b/aeon/classification/distance_based/_proximity_tree.py index e3db90864d..9af2edfe84 100644 --- a/aeon/classification/distance_based/_proximity_tree.py +++ b/aeon/classification/distance_based/_proximity_tree.py @@ -234,7 +234,7 @@ def _get_best_splitter(self, X, y): dist = distance( X[j], splitter[0][labels[k]], - metric=measure, + measure=measure, **splitter[1][measure], ) if dist < min_dist: @@ -320,7 +320,7 @@ def _build_tree(self, X, y, depth, node_id, parent_target_value=None): dist = distance( X[i], splitter[0][labels[j]], - metric=measure, + measure=measure, **splitter[1][measure], ) if dist < min_dist: @@ -404,7 +404,7 @@ def _classify(self, treenode, x): dist = distance( x, treenode.splitter[0][branches[i]], - metric=measure, + measure=measure, **treenode.splitter[1][measure], ) if dist < min_dist: diff --git a/aeon/classification/distance_based/_time_series_neighbors.py b/aeon/classification/distance_based/_time_series_neighbors.py index a24459d182..efb7473e65 100644 --- a/aeon/classification/distance_based/_time_series_neighbors.py +++ b/aeon/classification/distance_based/_time_series_neighbors.py @@ -111,7 +111,7 @@ def _fit(self, X, y): y : array-like, shape = (n_cases) The class labels. """ - self.metric_ = get_distance_function(metric=self.distance) + self.metric_ = get_distance_function(measure=self.distance) self.X_ = X self.classes_, self.y_ = np.unique(y, return_inverse=True) return self diff --git a/aeon/clustering/_clara.py b/aeon/clustering/_clara.py index 66da2e9920..edc334f6c9 100644 --- a/aeon/clustering/_clara.py +++ b/aeon/clustering/_clara.py @@ -42,7 +42,7 @@ class TimeSeriesCLARA(BaseClusterer): If a np.ndarray provided it must be of shape (n_clusters,) and contain the indexes of the time series to use as centroids. distance : str or Callable, default='msm' - Distance metric to compute similarity between time series. A list of valid + Distance measure to compute similarity between time series. A list of valid strings for metrics can be found in the documentation for :func:`aeon.distances.get_distance_function`. If a callable is passed it must be a function that takes two 2d numpy arrays as input and returns a float. @@ -73,7 +73,7 @@ class TimeSeriesCLARA(BaseClusterer): If `None`, the random number generator is the `RandomState` instance used by `np.random`. distance_params : dict, default=None - Dictionary containing kwargs for the distance metric being used. + Dictionary containing kwargs for the distance measure being used. Attributes ---------- @@ -189,7 +189,7 @@ def _fit(self, X: np.ndarray, y=None): curr_centers = pam.cluster_centers_ if isinstance(pam.distance, str): pairwise_matrix = pairwise_distance( - X, curr_centers, metric=self.distance, **pam._distance_params + X, curr_centers, measure=self.distance, **pam._distance_params ) else: pairwise_matrix = pairwise_distance( diff --git a/aeon/clustering/_clarans.py b/aeon/clustering/_clarans.py index 71ca1a9284..c13d177dfa 100644 --- a/aeon/clustering/_clarans.py +++ b/aeon/clustering/_clarans.py @@ -43,8 +43,8 @@ class TimeSeriesCLARANS(TimeSeriesKMedoids): If a np.ndarray provided it must be of shape (n_clusters,) and contain the indexes of the time series to use as centroids. distance : str or Callable, default='msm' - Distance metric to compute similarity between time series. A list of valid - strings for metrics can be found in the documentation for + Distance measure to compute similarity between time series. A list of valid + strings for measures can be found in the documentation for :func:`aeon.distances.get_distance_function`. If a callable is passed it must be a function that takes two 2d numpy arrays as input and returns a float. max_neighbours : int, default=None, @@ -62,7 +62,7 @@ class TimeSeriesCLARANS(TimeSeriesKMedoids): random_state : int or np.random.RandomState instance or None, default=None Determines random number generation for centroid initialization. distance_params : dict, default=None - Dictionary containing kwargs for the distance metric being used. + Dictionary containing kwargs for the distance measure being used. Attributes ---------- diff --git a/aeon/clustering/_elastic_som.py b/aeon/clustering/_elastic_som.py index 36a8769b13..e4edec42ab 100644 --- a/aeon/clustering/_elastic_som.py +++ b/aeon/clustering/_elastic_som.py @@ -44,8 +44,8 @@ class ElasticSOM(BaseClusterer): n_clusters : int, default=8 The number of clusters to form as well as the number of centroids to generate. distance : str or Callable, default='dtw' - Distance metric to compute similarity between time series. A list of valid - strings for metrics can be found in the documentation for + Distance measure to compute similarity between time series. A list of valid + strings for measures can be found in the documentation for :func:`aeon.distances.get_distance_function`. If a callable is passed it must be a function that takes two 2d numpy arrays as input and returns a float. init : str or np.ndarray, default='random' @@ -224,7 +224,7 @@ def _find_bmu(self, x, weights): pairwise_matrix = pairwise_distance( x, weights, - metric=self.distance, + measure=self.distance, **self._distance_params, ) return pairwise_matrix.argmin(axis=1) @@ -366,7 +366,7 @@ def _kmeans_plus_plus_center_initializer(self, X: np.ndarray): for _ in range(1, self.n_clusters): pw_dist = pairwise_distance( - X, X[indexes], metric=self.distance, **self._distance_params + X, X[indexes], measure=self.distance, **self._distance_params ) min_distances = pw_dist.min(axis=1) probabilities = min_distances / min_distances.sum() diff --git a/aeon/clustering/_k_means.py b/aeon/clustering/_k_means.py index e68d09e1b8..7c1e9a07d2 100644 --- a/aeon/clustering/_k_means.py +++ b/aeon/clustering/_k_means.py @@ -54,8 +54,8 @@ class TimeSeriesKMeans(BaseClusterer): n_timepoints) and contains the time series to use as centroids. distance : str or Callable, default='msm' - Distance metric to compute similarity between time series. A list of valid - strings for metrics can be found in the documentation for + Distance measure to compute similarity between time series. A list of valid + strings for measures can be found in the documentation for :func:`aeon.distances.get_distance_function`. If a callable is passed it must be a function that takes two 2d numpy arrays as input and returns a float. n_init : int, default=10 @@ -236,7 +236,7 @@ def _fit_one_init(self, X: np.ndarray) -> tuple: prev_labels = None for i in range(self.max_iter): curr_pw = pairwise_distance( - X, cluster_centres, metric=self.distance, **self._distance_params + X, cluster_centres, measure=self.distance, **self._distance_params ) curr_labels = curr_pw.argmin(axis=1) curr_inertia = curr_pw.min(axis=1).sum() @@ -273,13 +273,13 @@ def _fit_one_init(self, X: np.ndarray) -> tuple: def _predict(self, X: np.ndarray, y=None) -> np.ndarray: if isinstance(self.distance, str): pairwise_matrix = pairwise_distance( - X, self.cluster_centers_, metric=self.distance, **self._distance_params + X, self.cluster_centers_, measure=self.distance, **self._distance_params ) else: pairwise_matrix = pairwise_distance( X, self.cluster_centers_, - metric=self.distance, + measure=self.distance, **self._distance_params, ) return pairwise_matrix.argmin(axis=1) @@ -346,7 +346,7 @@ def _kmeans_plus_plus_center_initializer(self, X: np.ndarray): for _ in range(1, self.n_clusters): pw_dist = pairwise_distance( - X, X[indexes], metric=self.distance, **self._distance_params + X, X[indexes], measure=self.distance, **self._distance_params ) min_distances = pw_dist.min(axis=1) probabilities = min_distances / min_distances.sum() @@ -381,7 +381,7 @@ def _handle_empty_cluster( index_furthest_from_centre = curr_pw.min(axis=1).argmax() cluster_centres[current_empty_cluster_index] = X[index_furthest_from_centre] curr_pw = pairwise_distance( - X, cluster_centres, metric=self.distance, **self._distance_params + X, cluster_centres, measure=self.distance, **self._distance_params ) curr_labels = curr_pw.argmin(axis=1) curr_inertia = curr_pw.min(axis=1).sum() diff --git a/aeon/clustering/_k_medoids.py b/aeon/clustering/_k_medoids.py index a54220cec2..6a00f2a46e 100644 --- a/aeon/clustering/_k_medoids.py +++ b/aeon/clustering/_k_medoids.py @@ -56,8 +56,8 @@ class TimeSeriesKMedoids(BaseClusterer): If a np.ndarray provided it must be of shape (n_clusters,) and contain the indexes of the time series to use as centroids. distance : str or Callable, default='msm' - Distance metric to compute similarity between time series. A list of valid - strings for metrics can be found in the documentation for + Distance measure to compute similarity between time series. A list of valid + strings for measures can be found in the documentation for :func:`aeon.distances.get_distance_function`. If a callable is passed it must be a function that takes two 2d numpy arrays as input and returns a float. method : str, default='pam' @@ -88,7 +88,7 @@ class TimeSeriesKMedoids(BaseClusterer): If `None`, the random number generator is the `RandomState` instance used by `np.random`. distance_params: dict, default=None - Dictionary containing kwargs for the distance metric being used. + Dictionary containing kwargs for the distance measure being used. Attributes ---------- @@ -211,7 +211,7 @@ def _fit(self, X: np.ndarray, y=None): def _predict(self, X: np.ndarray, y=None) -> np.ndarray: if isinstance(self.distance, str): pairwise_matrix = pairwise_distance( - X, self.cluster_centers_, metric=self.distance, **self._distance_params + X, self.cluster_centers_, measure=self.distance, **self._distance_params ) else: pairwise_matrix = pairwise_distance( @@ -456,7 +456,7 @@ def _check_params(self, X: np.ndarray) -> None: f"n_clusters ({self.n_clusters}) cannot be larger than " f"n_cases ({X.shape[0]})" ) - self._distance_callable = get_distance_function(metric=self.distance) + self._distance_callable = get_distance_function(measure=self.distance) self._distance_cache = np.full((X.shape[0], X.shape[0]), np.inf) if self.method == "alternate": @@ -486,7 +486,7 @@ def _kmedoids_plus_plus_center_initializer(self, X: np.ndarray): for _ in range(1, self.n_clusters): pw_dist = pairwise_distance( - X, X[indexes], metric=self.distance, **self._distance_params + X, X[indexes], measure=self.distance, **self._distance_params ) min_distances = pw_dist.min(axis=1) probabilities = min_distances / min_distances.sum() diff --git a/aeon/clustering/averaging/_ba_petitjean.py b/aeon/clustering/averaging/_ba_petitjean.py index 3d456bfa69..6b3765f77e 100644 --- a/aeon/clustering/averaging/_ba_petitjean.py +++ b/aeon/clustering/averaging/_ba_petitjean.py @@ -55,7 +55,7 @@ def petitjean_barycenter_average( random_state: int or None, default=None Random state to use for the barycenter averaging. **kwargs - Keyword arguments to pass to the distance metric. + Keyword arguments to pass to the distance measure. Returns ------- diff --git a/aeon/clustering/averaging/_ba_subgradient.py b/aeon/clustering/averaging/_ba_subgradient.py index 12410ba6d9..369ad6bded 100644 --- a/aeon/clustering/averaging/_ba_subgradient.py +++ b/aeon/clustering/averaging/_ba_subgradient.py @@ -70,7 +70,7 @@ def subgradient_barycenter_average( random_state: int or None, default=None Random state to use for the barycenter averaging. **kwargs - Keyword arguments to pass to the distance metric. + Keyword arguments to pass to the distance measure. Returns ------- diff --git a/aeon/clustering/averaging/_ba_utils.py b/aeon/clustering/averaging/_ba_utils.py index 4b449fb0a8..496f1f6e74 100644 --- a/aeon/clustering/averaging/_ba_utils.py +++ b/aeon/clustering/averaging/_ba_utils.py @@ -31,7 +31,7 @@ def _medoids( return X if precomputed_pairwise_distance is None: - precomputed_pairwise_distance = pairwise_distance(X, metric=distance, **kwargs) + precomputed_pairwise_distance = pairwise_distance(X, measure=distance, **kwargs) x_size = X.shape[0] distance_matrix = np.zeros((x_size, x_size)) @@ -155,6 +155,6 @@ def _get_alignment_path( elif distance == "adtw": return adtw_alignment_path(ts, center, window=window, warp_penalty=warp_penalty) else: - # When numba version > 0.57 add more informative error with what metric + # When numba version > 0.57 add more informative error with what measure # was passed. raise ValueError("Distance parameter invalid") diff --git a/aeon/clustering/averaging/_barycenter_averaging.py b/aeon/clustering/averaging/_barycenter_averaging.py index 78f2583c59..48397f8f84 100644 --- a/aeon/clustering/averaging/_barycenter_averaging.py +++ b/aeon/clustering/averaging/_barycenter_averaging.py @@ -84,7 +84,7 @@ def elastic_barycenter_average( random_state: int or None, default=None Random state to use for the barycenter averaging. **kwargs - Keyword arguments to pass to the distance metric. + Keyword arguments to pass to the distance measure. Returns ------- diff --git a/aeon/distances/_distance.py b/aeon/distances/_distance.py index 6b3c9d91a3..0004884184 100644 --- a/aeon/distances/_distance.py +++ b/aeon/distances/_distance.py @@ -118,7 +118,7 @@ class DistanceKwargs(TypedDict, total=False): def distance( x: np.ndarray, y: np.ndarray, - metric: Union[str, DistanceFunction], + measure: Union[str, DistanceFunction], **kwargs: Unpack[DistanceKwargs], ) -> float: """Compute the distance between two time series. @@ -131,13 +131,13 @@ def distance( y : np.ndarray Second time series, either univariate, shape ``(n_timepoints,)``, or multivariate, shape ``(n_channels, n_timepoints)``. - metric : str or Callable - The distance metric to use. - A list of valid distance metrics can be found in the documentation for + measure : str or Callable + The distance measure to use. + A list of valid distance measures can be found in the documentation for :func:`aeon.distances.get_distance_function` or by calling the function :func:`aeon.distances.get_distance_function_names`. kwargs : Any - Arguments for metric. Refer to each metrics documentation for a list of + Arguments for measure. Refer to each measure documentation for a list of possible arguments. Returns @@ -149,7 +149,7 @@ def distance( ------ ValueError If x and y are not 1D, or 2D arrays. - If metric is not a valid string or callable. + If measure is not a valid string or callable. Examples -------- @@ -157,21 +157,21 @@ def distance( >>> from aeon.distances import distance >>> x = np.array([[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]) >>> y = np.array([[11, 12, 13, 14, 15, 16, 17, 18, 19, 20]]) - >>> distance(x, y, metric="dtw") + >>> distance(x, y, measure="dtw") 768.0 """ - if metric in DISTANCES_DICT: - return DISTANCES_DICT[metric]["distance"](x, y, **kwargs) - elif isinstance(metric, Callable): - return metric(x, y, **kwargs) + if measure in DISTANCES_DICT: + return DISTANCES_DICT[measure]["distance"](x, y, **kwargs) + elif isinstance(measure, Callable): + return measure(x, y, **kwargs) else: - raise ValueError("Metric must be one of the supported strings or a callable") + raise ValueError("Measure must be one of the supported strings or a callable") def pairwise_distance( x: np.ndarray, y: Optional[np.ndarray] = None, - metric: Union[str, DistanceFunction, None] = None, + measure: Union[str, DistanceFunction, None] = None, symmetric: bool = True, **kwargs: Unpack[DistanceKwargs], ) -> np.ndarray: @@ -185,20 +185,20 @@ def pairwise_distance( y : np.ndarray or None, default=None A single series or a collection of time series of shape ``(m_timepoints,)`` or ``(m_cases, m_timepoints)`` or ``(m_cases, m_channels, m_timepoints)`` - metric : str or Callable - The distance metric to use. - A list of valid distance metrics can be found in the documentation for + measure : str or Callable + The distance measure to use. + A list of valid distance measure can be found in the documentation for :func:`aeon.distances.get_distance_function` or by calling the function :func:`aeon.distances.get_distance_function_names`. symmetric : bool, default=True - If True and a function is provided as the "metric" paramter, then it will + If True and a function is provided as the "measure" paramter, then it will compute a symmetric distance matrix where d(x, y) = d(y, x). Only the lower triangle is calculated, and the upper triangle is ignored. If False and a - function is provided as the "metric" parameter, then it will compute an + function is provided as the "measure" parameter, then it will compute an asymmetric distance matrix, and the entire matrix (including both upper and lower triangles) is returned. kwargs : Any - Extra arguments for metric. Refer to each metric documentation for a list of + Extra arguments for measure. Refer to each measure documentation for a list of possible arguments. Returns @@ -211,7 +211,7 @@ def pairwise_distance( ValueError If X is not 2D or 3D array when only passing X. If X and y are not 1D, 2D or 3D arrays when passing both X and y. - If metric is not a valid string or callable. + If measure is not a valid string or callable. Examples -------- @@ -219,7 +219,7 @@ def pairwise_distance( >>> from aeon.distances import pairwise_distance >>> # Distance between each time series in a collection of time series >>> X = np.array([[[1, 2, 3]],[[4, 5, 6]], [[7, 8, 9]]]) - >>> pairwise_distance(X, metric='dtw') + >>> pairwise_distance(X, measure='dtw') array([[ 0., 26., 108.], [ 26., 0., 26.], [108., 26., 0.]]) @@ -227,26 +227,26 @@ def pairwise_distance( >>> # Distance between two collections of time series >>> X = np.array([[[1, 2, 3]],[[4, 5, 6]], [[7, 8, 9]]]) >>> y = np.array([[[11, 12, 13]],[[14, 15, 16]], [[17, 18, 19]]]) - >>> pairwise_distance(X, y, metric='dtw') + >>> pairwise_distance(X, y, measure='dtw') array([[300., 507., 768.], [147., 300., 507.], [ 48., 147., 300.]]) >>> X = np.array([[[1, 2, 3]],[[4, 5, 6]], [[7, 8, 9]]]) >>> y_univariate = np.array([11, 12, 13]) - >>> pairwise_distance(X, y_univariate, metric='dtw') + >>> pairwise_distance(X, y_univariate, measure='dtw') array([[300.], [147.], [ 48.]]) """ - if metric in PAIRWISE_DISTANCE: - return DISTANCES_DICT[metric]["pairwise_distance"](x, y, **kwargs) - elif isinstance(metric, Callable): + if measure in PAIRWISE_DISTANCE: + return DISTANCES_DICT[measure]["pairwise_distance"](x, y, **kwargs) + elif isinstance(measure, Callable): if y is None and not symmetric: - return _custom_func_pairwise(x, x, metric, **kwargs) - return _custom_func_pairwise(x, y, metric, **kwargs) + return _custom_func_pairwise(x, x, measure, **kwargs) + return _custom_func_pairwise(x, y, measure, **kwargs) else: - raise ValueError("Metric must be one of the supported strings or a callable") + raise ValueError("Measure must be one of the supported strings or a callable") def _custom_func_pairwise( @@ -302,7 +302,7 @@ def _custom_from_multiple_to_multiple_distance( def alignment_path( x: np.ndarray, y: np.ndarray, - metric: Union[str, DistanceFunction, None] = None, + measure: Union[str, DistanceFunction, None] = None, **kwargs: Unpack[DistanceKwargs], ) -> tuple[list[tuple[int, int]], float]: """Compute the alignment path and distance between two time series. @@ -313,13 +313,13 @@ def alignment_path( First time series. y : np.ndarray, of shape (m_channels, m_timepoints) or (m_timepoints,) Second time series. - metric : str or Callable - The distance metric to use. - A list of valid distance metrics can be found in the documentation for + measure : str or Callable + The distance measure to use. + A list of valid distance measure can be found in the documentation for :func:`aeon.distances.get_distance_function` or by calling the function :func:`aeon.distances.get_distance_function_names`. kwargs : any - Arguments for metric. Refer to each metrics documentation for a list of + Arguments for measure. Refer to each measure documentation for a list of possible arguments. Returns @@ -335,7 +335,7 @@ def alignment_path( ------ ValueError If x and y are not 1D, or 2D arrays. - If metric is not one of the supported strings or a callable. + If measure is not one of the supported strings or a callable. Examples -------- @@ -343,21 +343,21 @@ def alignment_path( >>> from aeon.distances import alignment_path >>> x = np.array([[1, 2, 3, 6]]) >>> y = np.array([[1, 2, 3, 4]]) - >>> alignment_path(x, y, metric='dtw') + >>> alignment_path(x, y, measure='dtw') ([(0, 0), (1, 1), (2, 2), (3, 3)], 4.0) """ - if metric in ALIGNMENT_PATH: - return DISTANCES_DICT[metric]["alignment_path"](x, y, **kwargs) - elif isinstance(metric, Callable): - return metric(x, y, **kwargs) + if measure in ALIGNMENT_PATH: + return DISTANCES_DICT[measure]["alignment_path"](x, y, **kwargs) + elif isinstance(measure, Callable): + return measure(x, y, **kwargs) else: - raise ValueError("Metric must be one of the supported strings") + raise ValueError("Measure must be one of the supported strings") def cost_matrix( x: np.ndarray, y: np.ndarray, - metric: Union[str, DistanceFunction, None] = None, + measure: Union[str, DistanceFunction, None] = None, **kwargs: Unpack[DistanceKwargs], ) -> np.ndarray: """Compute the alignment path and distance between two time series. @@ -368,13 +368,13 @@ def cost_matrix( First time series. y : np.ndarray, of shape (m_channels, m_timepoints) or (m_timepoints,) Second time series. - metric : str or Callable - The distance metric to use. - A list of valid distance metrics can be found in the documentation for + measure : str or Callable + The distance measure to use. + A list of valid distance measures can be found in the documentation for :func:`aeon.distances.get_distance_function` or by calling the function :func:`aeon.distances.get_distance_function_names`. kwargs : Any - Arguments for metric. Refer to each metrics documentation for a list of + Arguments for measure. Refer to each measures documentation for a list of possible arguments. Returns @@ -386,7 +386,7 @@ def cost_matrix( ------ ValueError If x and y are not 1D, or 2D arrays. - If metric is not one of the supported strings or a callable. + If measure is not one of the supported strings or a callable. Examples -------- @@ -394,7 +394,7 @@ def cost_matrix( >>> from aeon.distances import cost_matrix >>> x = np.array([[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]) >>> y = np.array([[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]) - >>> cost_matrix(x, y, metric="dtw") + >>> cost_matrix(x, y, measure="dtw") array([[ 0., 1., 5., 14., 30., 55., 91., 140., 204., 285.], [ 1., 0., 1., 5., 14., 30., 55., 91., 140., 204.], [ 5., 1., 0., 1., 5., 14., 30., 55., 91., 140.], @@ -406,12 +406,12 @@ def cost_matrix( [204., 140., 91., 55., 30., 14., 5., 1., 0., 1.], [285., 204., 140., 91., 55., 30., 14., 5., 1., 0.]]) """ - if metric in COST_MATRIX: - return DISTANCES_DICT[metric]["cost_matrix"](x, y, **kwargs) - elif isinstance(metric, Callable): - return metric(x, y, **kwargs) + if measure in COST_MATRIX: + return DISTANCES_DICT[measure]["cost_matrix"](x, y, **kwargs) + elif isinstance(measure, Callable): + return measure(x, y, **kwargs) else: - raise ValueError("Metric must be one of the supported strings") + raise ValueError("Measure must be one of the supported strings") def get_distance_function_names() -> list[str]: @@ -440,11 +440,11 @@ def get_distance_function_names() -> list[str]: return sorted(DISTANCES_DICT.keys()) -def get_distance_function(metric: Union[str, DistanceFunction]) -> DistanceFunction: - """Get the distance function for a given metric string or callable. +def get_distance_function(measure: Union[str, DistanceFunction]) -> DistanceFunction: + """Get the distance function for a given measure string or callable. =============== ======================================== - metric Distance Function + measure Distance Function =============== ======================================== 'dtw' distances.dtw_distance 'shape_dtw' distances.shape_dtw_distance @@ -468,8 +468,8 @@ def get_distance_function(metric: Union[str, DistanceFunction]) -> DistanceFunct Parameters ---------- - metric : str or Callable - The distance metric to use. + measure : str or Callable + The distance measure to use. If string given then it will be resolved to a alignment path function. If a callable is given, the value must be a function that accepts two numpy arrays and **kwargs returns a float. @@ -477,12 +477,12 @@ def get_distance_function(metric: Union[str, DistanceFunction]) -> DistanceFunct Returns ------- Callable[[np.ndarray, np.ndarray, Any], float] - The distance function for the given metric. + The distance function for the given measure. Raises ------ ValueError - If metric is not one of the supported strings or a callable. + If measure is not one of the supported strings or a callable. Examples -------- @@ -494,16 +494,16 @@ def get_distance_function(metric: Union[str, DistanceFunction]) -> DistanceFunct >>> dtw_dist_func(x, y, window=0.2) 874.0 """ - return _resolve_key_from_distance(metric, "distance") + return _resolve_key_from_distance(measure, "distance") def get_pairwise_distance_function( - metric: Union[str, PairwiseFunction] + measure: Union[str, PairwiseFunction] ) -> PairwiseFunction: - """Get the pairwise distance function for a given metric string or callable. + """Get the pairwise distance function for a given measure string or callable. =============== ======================================== - metric Distance Function + measure Distance Function =============== ======================================== 'dtw' distances.dtw_pairwise_distance 'shape_dtw' distances.shape_dtw_pairwise_distance @@ -527,8 +527,8 @@ def get_pairwise_distance_function( Parameters ---------- - metric : str or Callable - The metric string to resolve to a alignment path function. + measure : str or Callable + The measure string to resolve to a alignment path function. If string given then it will be resolved to a alignment path function. If a callable is given, the value must be a function that accepts two numpy arrays and **kwargs returns a np.ndarray that is the pairwise distance @@ -537,12 +537,12 @@ def get_pairwise_distance_function( Returns ------- Callable[[np.ndarray, np.ndarray, Any], np.ndarray] - The pairwise distance function for the given metric. + The pairwise distance function for the given measure. Raises ------ ValueError - If metric is not one of the supported strings or a callable. + If measure is not one of the supported strings or a callable. Examples -------- @@ -556,14 +556,14 @@ def get_pairwise_distance_function( [147., 300., 507.], [ 48., 147., 300.]]) """ - return _resolve_key_from_distance(metric, "pairwise_distance") + return _resolve_key_from_distance(measure, "pairwise_distance") -def get_alignment_path_function(metric: str) -> AlignmentPathFunction: - """Get the alignment path function for a given metric string or callable. +def get_alignment_path_function(measure: str) -> AlignmentPathFunction: + """Get the alignment path function for a given measure string or callable. =============== ======================================== - metric Distance Function + measure Distance Function =============== ======================================== 'dtw' distances.dtw_alignment_path 'shape_dtw' distances.shape_dtw_alignment_path @@ -581,19 +581,19 @@ def get_alignment_path_function(metric: str) -> AlignmentPathFunction: Parameters ---------- - metric : str or Callable - The metric string to resolve to an alignment path function. + measure : str or Callable + The measure string to resolve to an alignment path function. Returns ------- Callable[[np.ndarray, np.ndarray, Any], Tuple[List[Tuple[int, int]], float]] - The alignment path function for the given metric. + The alignment path function for the given measure. Raises ------ ValueError - If metric is not one of the supported strings or a callable. - If the metric doesn't have an alignment path function. + If measure is not one of the supported strings or a callable. + If the measure doesn't have an alignment path function. Examples -------- @@ -605,14 +605,14 @@ def get_alignment_path_function(metric: str) -> AlignmentPathFunction: >>> dtw_alignment_path_func(x, y, window=0.2) ([(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)], 500.0) """ - return _resolve_key_from_distance(metric, "alignment_path") + return _resolve_key_from_distance(measure, "alignment_path") -def get_cost_matrix_function(metric: str) -> CostMatrixFunction: - """Get the cost matrix function for a given metric string or callable. +def get_cost_matrix_function(measure: str) -> CostMatrixFunction: + """Get the cost matrix function for a given measure string or callable. =============== ======================================== - metric Distance Function + measure Distance Function =============== ======================================== 'dtw' distances.dtw_cost_matrix 'shape_dtw' distances.shape_dtw_cost_matrix @@ -630,19 +630,19 @@ def get_cost_matrix_function(metric: str) -> CostMatrixFunction: Parameters ---------- - metric : str or Callable - The metric string to resolve to a cost matrix function. + measure : str or Callable + The measure string to resolve to a cost matrix function. Returns ------- Callable[[np.ndarray, np.ndarray, Any], np.ndarray] - The cost matrix function for the given metric. + The cost matrix function for the given measure. Raises ------ ValueError - If metric is not one of the supported strings or a callable. - If the metric doesn't have a cost matrix function. + If measure is not one of the supported strings or a callable. + If the measure doesn't have a cost matrix function. Examples -------- @@ -658,20 +658,20 @@ def get_cost_matrix_function(metric: str) -> CostMatrixFunction: [ inf, inf, 343., 400., 521.], [ inf, inf, inf, 424., 500.]]) """ - return _resolve_key_from_distance(metric, "cost_matrix") + return _resolve_key_from_distance(measure, "cost_matrix") -def _resolve_key_from_distance(metric: Union[str, Callable], key: str) -> Any: - if isinstance(metric, Callable): - return metric - if metric == "mpdist": +def _resolve_key_from_distance(measure: Union[str, Callable], key: str) -> Any: + if isinstance(measure, Callable): + return measure + if measure == "mpdist": return mp_distance - dist = DISTANCES_DICT.get(metric) + dist = DISTANCES_DICT.get(measure) if dist is None: - raise ValueError(f"Unknown metric {metric}") + raise ValueError(f"Unknown measure {measure}") dist_callable = dist.get(key) if dist_callable is None: - raise ValueError(f"Metric {metric} does not have a {key} function") + raise ValueError(f"Measure {measure} does not have a {key} function") return dist_callable diff --git a/aeon/distances/elastic/tests/test_alignment_path.py b/aeon/distances/elastic/tests/test_alignment_path.py index 6c2fa5ebfc..5fd1132783 100644 --- a/aeon/distances/elastic/tests/test_alignment_path.py +++ b/aeon/distances/elastic/tests/test_alignment_path.py @@ -32,7 +32,7 @@ def _validate_alignment_path_result( assert isinstance(alignment_path_result, tuple) assert isinstance(alignment_path_result[0], list) assert isinstance(alignment_path_result[1], float) - assert compute_alignment_path(x, y, metric=name) == alignment_path_result + assert compute_alignment_path(x, y, measure=name) == alignment_path_result # Test a callable being passed assert callable_alignment_path == alignment_path_result diff --git a/aeon/distances/elastic/tests/test_cost_matrix.py b/aeon/distances/elastic/tests/test_cost_matrix.py index 1971923ebf..46f4c0d47a 100644 --- a/aeon/distances/elastic/tests/test_cost_matrix.py +++ b/aeon/distances/elastic/tests/test_cost_matrix.py @@ -30,8 +30,8 @@ def _validate_cost_matrix_result( ---------- x (np.ndarray): The first input array. y (np.ndarray): The second input array. - name: The name of the distance metric. - distance: The distance metric function. + name: The name of the distance measure. + distance: The distance measure function. cost_matrix: The cost matrix function. """ original_x = x.copy() @@ -40,7 +40,7 @@ def _validate_cost_matrix_result( cost_matrix_callable_result = DISTANCES_DICT[name]["cost_matrix"](x, y) assert isinstance(cost_matrix_result, np.ndarray) - assert_almost_equal(cost_matrix_result, compute_cost_matrix(x, y, metric=name)) + assert_almost_equal(cost_matrix_result, compute_cost_matrix(x, y, measure=name)) assert_almost_equal(cost_matrix_callable_result, cost_matrix_result) if name == "ddtw" or name == "wddtw": assert cost_matrix_result.shape == (x.shape[-1] - 2, y.shape[-1] - 2) diff --git a/aeon/distances/tests/test_distances.py b/aeon/distances/tests/test_distances.py index a361d47ae0..efcbd1ee2e 100644 --- a/aeon/distances/tests/test_distances.py +++ b/aeon/distances/tests/test_distances.py @@ -37,7 +37,7 @@ def _validate_distance_result( ---------- x (np.ndarray): First array. y (np.ndarray): Second array. - name (str): Name of the distance metric. + name (str): Name of the distance measure. distance (callable): Distance function. expected_result (float): Expected distance result. check_xy_permuted: (bool): recursively call with swapped series @@ -50,8 +50,8 @@ def _validate_distance_result( dist_result = distance(x, y) assert isinstance(dist_result, float) assert_almost_equal(dist_result, expected_result) - assert_almost_equal(dist_result, compute_distance(x, y, metric=name)) - assert_almost_equal(dist_result, compute_distance(x, y, metric=distance)) + assert_almost_equal(dist_result, compute_distance(x, y, measure=name)) + assert_almost_equal(dist_result, compute_distance(x, y, measure=distance)) dist_result_to_self = distance(x, x) assert isinstance(dist_result_to_self, float) @@ -160,10 +160,10 @@ def test_get_distance_function_names(): def test_resolve_key_from_distance(): """Test _resolve_key_from_distance.""" - with pytest.raises(ValueError, match="Unknown metric"): - _resolve_key_from_distance(metric="FOO", key="cost_matrix") + with pytest.raises(ValueError, match="Unknown measure"): + _resolve_key_from_distance(measure="FOO", key="cost_matrix") with pytest.raises(ValueError): - _resolve_key_from_distance(metric="dtw", key="FOO") + _resolve_key_from_distance(measure="dtw", key="FOO") def foo(x, y): return 0 @@ -176,17 +176,23 @@ def test_incorrect_inputs(): x = np.array([[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]) y = np.array([[11, 12, 13, 14, 15, 16, 17, 18, 19, 20]]) with pytest.raises( - ValueError, match="Metric must be one of the supported strings or a " "callable" + ValueError, + match="Measure must be one of the supported strings or a " "callable", ): - compute_distance(x, y, metric="FOO") + compute_distance(x, y, measure="FOO") with pytest.raises( - ValueError, match="Metric must be one of the supported strings or a " "callable" + ValueError, + match="Measure must be one of the supported strings or a " "callable", ): - pairwise_distance(x, y, metric="FOO") - with pytest.raises(ValueError, match="Metric must be one of the supported strings"): - alignment_path(x, y, metric="FOO") - with pytest.raises(ValueError, match="Metric must be one of the supported strings"): - cost_matrix(x, y, metric="FOO") + pairwise_distance(x, y, measure="FOO") + with pytest.raises( + ValueError, match="Measure must be one of the supported strings" + ): + alignment_path(x, y, measure="FOO") + with pytest.raises( + ValueError, match="Measure must be one of the supported strings" + ): + cost_matrix(x, y, measure="FOO") x = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) with pytest.raises(ValueError, match="dist_func must be a callable"): diff --git a/aeon/distances/tests/test_numba_distance_parameters.py b/aeon/distances/tests/test_numba_distance_parameters.py index 4353b2ae8f..5b0bc385d0 100644 --- a/aeon/distances/tests/test_numba_distance_parameters.py +++ b/aeon/distances/tests/test_numba_distance_parameters.py @@ -93,7 +93,7 @@ def _test_distance_params( del param_dict["g"] results = [ distance_func(x, y, **param_dict.copy()), - distance(x, y, metric=distance_str, **param_dict.copy()), + distance(x, y, measure=distance_str, **param_dict.copy()), ] if distance_str in _expected_distance_results_params: diff --git a/aeon/distances/tests/test_pairwise.py b/aeon/distances/tests/test_pairwise.py index 6aaab3690b..152913aee0 100644 --- a/aeon/distances/tests/test_pairwise.py +++ b/aeon/distances/tests/test_pairwise.py @@ -52,7 +52,7 @@ def _validate_pairwise_result( Parameters ---------- x: Input np.ndarray. - name: Name of the distance metric. + name: Name of the distance measure. distance: Distance function. pairwise_distance: Pairwise distance function. """ @@ -64,11 +64,11 @@ def _validate_pairwise_result( assert isinstance(pairwise_result, np.ndarray) assert pairwise_result.shape == expected_size assert_almost_equal( - pairwise_result, compute_pairwise_distance(x, metric=name, symmetric=symmetric) + pairwise_result, compute_pairwise_distance(x, measure=name, symmetric=symmetric) ) assert_almost_equal( pairwise_result, - compute_pairwise_distance(x, metric=distance, symmetric=symmetric), + compute_pairwise_distance(x, measure=distance, symmetric=symmetric), ) if isinstance(x, np.ndarray): @@ -100,7 +100,7 @@ def _validate_multiple_to_multiple_result( ---------- x: Input array. y: Input array. - name: Name of the distance metric. + name: Name of the distance measure. distance: Distance function. multiple_to_multiple_distance: Mul-to-Mul distance function. check_xy_permuted: recursively call with swapped series @@ -123,11 +123,11 @@ def _validate_multiple_to_multiple_result( assert multiple_to_multiple_result.shape == expected_size assert_almost_equal( - multiple_to_multiple_result, compute_pairwise_distance(x, y, metric=name) + multiple_to_multiple_result, compute_pairwise_distance(x, y, measure=name) ) assert_almost_equal( multiple_to_multiple_result, - compute_pairwise_distance(x, y, metric=distance), + compute_pairwise_distance(x, y, measure=distance), ) if isinstance(x, np.ndarray) and isinstance(y, np.ndarray): @@ -168,7 +168,7 @@ def _validate_single_to_multiple_result( ---------- x: Input array. y: Input array. - name: Name of the distance metric. + name: Name of the distance measure. distance: Distance function. single_to_multiple_distance: Single to multiple distance function. run_inverse: Boolean that reruns the test with x and y swapped in position @@ -198,11 +198,11 @@ def _validate_single_to_multiple_result( assert single_to_multiple_result.shape[1] == expected_size assert_almost_equal( single_to_multiple_result, - compute_pairwise_distance(x, y, metric=name, symmetric=symmetric), + compute_pairwise_distance(x, y, measure=name, symmetric=symmetric), ) assert_almost_equal( single_to_multiple_result, - compute_pairwise_distance(x, y, metric=distance, symmetric=symmetric), + compute_pairwise_distance(x, y, measure=distance, symmetric=symmetric), ) if len(x_shape) < len(y_shape): diff --git a/aeon/regression/distance_based/_time_series_neighbors.py b/aeon/regression/distance_based/_time_series_neighbors.py index 29056de961..d56120ea92 100644 --- a/aeon/regression/distance_based/_time_series_neighbors.py +++ b/aeon/regression/distance_based/_time_series_neighbors.py @@ -111,7 +111,7 @@ def _fit(self, X, y): y : array-like, shape = (n_cases) The output value. """ - self.metric_ = get_distance_function(metric=self.distance) + self.metric_ = get_distance_function(measure=self.distance) self.X_ = X self.y_ = y return self diff --git a/aeon/transformations/collection/channel_selection/_elbow_class.py b/aeon/transformations/collection/channel_selection/_elbow_class.py index d32b7fbb1e..be0f8102de 100644 --- a/aeon/transformations/collection/channel_selection/_elbow_class.py +++ b/aeon/transformations/collection/channel_selection/_elbow_class.py @@ -90,7 +90,7 @@ class values {len(class_vals)} must be of same length." lambda row: aeon_distance( row[: row.shape[0] // 2], row[row.shape[0] // 2 :], - metric="dtw", + measure="dtw", ), axis=1, arr=np.concatenate((cls1_ch, cls2_ch), axis=1), diff --git a/aeon/transformations/series/tests/test_warping.py b/aeon/transformations/series/tests/test_warping.py index ca14ab65a3..f707d0e198 100644 --- a/aeon/transformations/series/tests/test_warping.py +++ b/aeon/transformations/series/tests/test_warping.py @@ -16,7 +16,7 @@ def test_warping_path_transformer(distance): x = make_example_2d_numpy_series(n_timepoints=20, n_channels=2) y = make_example_2d_numpy_series(n_timepoints=20, n_channels=2) - alignment_path_function = get_alignment_path_function(metric=distance) + alignment_path_function = get_alignment_path_function(measure=distance) warping_path = alignment_path_function(x, y)[0] diff --git a/examples/distances/distances.ipynb b/examples/distances/distances.ipynb index fbd557ec47..6e441e5533 100644 --- a/examples/distances/distances.ipynb +++ b/examples/distances/distances.ipynb @@ -192,7 +192,7 @@ "\n", "d1 = euclidean_distance(first, second)\n", "d2 = euclidean_distance(first, third)\n", - "d3 = distance(second, third, metric=\"euclidean\")\n", + "d3 = distance(second, third, measure=\"euclidean\")\n", "print(d1, \",\", d2, \",\", d3)" ] }, @@ -568,7 +568,7 @@ "y = np.array([[2, 3, 4, 5, 6, 7]])\n", "p, d = dtw_alignment_path(x, y)\n", "print(\"path =\", p, \" distance = \", d)\n", - "p, d = alignment_path(x, y, metric=\"dtw\")\n", + "p, d = alignment_path(x, y, measure=\"dtw\")\n", "print(\"path =\", p, \" distance = \", d)" ] }, diff --git a/examples/pydata/Amsterdam-2023/Lets_do_the_time_warp_again.ipynb b/examples/pydata/Amsterdam-2023/Lets_do_the_time_warp_again.ipynb index 0d2007db3b..27f123e611 100644 --- a/examples/pydata/Amsterdam-2023/Lets_do_the_time_warp_again.ipynb +++ b/examples/pydata/Amsterdam-2023/Lets_do_the_time_warp_again.ipynb @@ -60,7 +60,7 @@ "from aeon.distances import distance\n", "\n", "# Any value in the table above is a valid metric string\n", - "distance(x, y, metric=\"dtw\")" + "distance(x, y, measure=\"dtw\")" ] }, { @@ -109,7 +109,7 @@ "msm_distance(x, y, window=0.2)\n", "\n", "# Calling a distance using utility function\n", - "distance(x, y, metric=\"msm\", window=0.2)" + "distance(x, y, measure=\"msm\", window=0.2)" ] }, { @@ -160,7 +160,7 @@ "# Using utility function\n", "from aeon.distances import pairwise_distance\n", "\n", - "pairwise_distance(X, metric=\"twe\")" + "pairwise_distance(X, measure=\"twe\")" ] }, { @@ -205,7 +205,7 @@ "erp_pairwise_distance(X, y)\n", "\n", "# Using utility function\n", - "pairwise_distance(X, y, metric=\"erp\")" + "pairwise_distance(X, y, measure=\"erp\")" ] }, { @@ -260,7 +260,7 @@ "wdtw_pairwise_distance(X, y, window=0.2)\n", "\n", "# Using utility function\n", - "pairwise_distance(X, y, metric=\"wdtw\", window=0.2)" + "pairwise_distance(X, y, measure=\"wdtw\", window=0.2)" ] }, { @@ -314,7 +314,7 @@ "# Using utility function\n", "from aeon.distances import alignment_path\n", "\n", - "alignment_path(x, y, metric=\"msm\", window=0.2)" + "alignment_path(x, y, measure=\"msm\", window=0.2)" ] }, { @@ -391,7 +391,7 @@ "X_test, y_test = load_data(split=\"TEST\", return_type=\"numpy2D\")\n", "x = X_train[0]\n", "y = X_train[22]\n", - "msm_alignment_path = alignment_path(x, y, metric=\"msm\")\n", + "msm_alignment_path = alignment_path(x, y, measure=\"msm\")\n", "curr_plot = plot_alignment(x, y, msm_alignment_path[0])\n", "curr_plot.show()" ] @@ -467,7 +467,7 @@ ")\n", "\n", "# Precompute pairwise twe distances\n", - "train_pw_distance = pairwise_distance(X_train, metric=\"twe\")\n", + "train_pw_distance = pairwise_distance(X_train, measure=\"twe\")\n", "\n", "# Fit model using precomputed\n", "model_precomputed.fit(train_pw_distance)\n", @@ -517,8 +517,8 @@ "model_distance = SVC(kernel=msm_pairwise_distance)\n", "\n", "# Precompute pairwise twe distances\n", - "train_pw_distance = pairwise_distance(X_train, metric=\"msm\")\n", - "test_pw_distance = pairwise_distance(X_test, X_train, metric=\"msm\")\n", + "train_pw_distance = pairwise_distance(X_train, measure=\"msm\")\n", + "test_pw_distance = pairwise_distance(X_test, X_train, measure=\"msm\")\n", "\n", "# Fit model using precomputed\n", "model_precomputed.fit(train_pw_distance, y_train)\n", From f4d2daa82cbc7924e909df4b477065e8408645fe Mon Sep 17 00:00:00 2001 From: Matthew Middlehurst Date: Wed, 27 Nov 2024 00:00:01 +0200 Subject: [PATCH 2/5] [DOC] Utils and forecasting api docs (#2402) * utils and forecasting * fix and forecasting panel * fixes * correct import * does this fix * for some reason dev dependencies are needed to build docs * isolate pytest * isolate pytest * add to periodic --- .github/workflows/periodic_tests.yml | 25 ++ .github/workflows/pr_core_dep_import.yml | 43 +++ .github/workflows/pr_pytest.yml | 2 +- .readthedocs.yml | 2 +- README.md | 1 + aeon/base/tests/test_base_collection.py | 2 +- aeon/classification/tests/test_base.py | 2 +- aeon/pipeline/__init__.py | 5 +- .../regression/compose/tests/test_ensemble.py | 8 +- aeon/regression/tests/test_base.py | 2 +- aeon/segmentation/tests/test_base.py | 4 +- .../estimator_checking/_estimator_checking.py | 1 + .../_yield_anomaly_detection_checks.py | 5 +- .../_yield_classification_checks.py | 2 +- .../_yield_estimator_checks.py | 3 +- .../_yield_regression_checks.py | 2 +- .../_yield_segmentation_checks.py | 3 +- .../_yield_soft_dependency_checks.py | 6 +- .../_yield_transformation_checks.py | 2 +- aeon/testing/mock_estimators/__init__.py | 40 ++- .../_mock_anomaly_detectors.py | 2 +- .../mock_estimators/_mock_classifiers.py | 14 +- .../mock_estimators/_mock_clusterers.py | 8 + .../_mock_collection_transformers.py | 7 +- .../mock_estimators/_mock_forecasters.py | 9 +- .../mock_estimators/_mock_regressors.py | 10 +- .../mock_estimators/_mock_segmenters.py | 10 +- .../_mock_series_transformers.py | 10 +- .../_mock_similarity_search.py | 10 +- aeon/testing/tests/__init__.py | 14 +- aeon/testing/tests/test_core_imports.py | 26 ++ aeon/testing/tests/test_softdeps.py | 13 +- aeon/testing/tests/test_testing_data.py | 2 +- aeon/testing/utils/deep_equals.py | 2 +- ...ut_supression.py => output_suppression.py} | 0 .../utils/tests/test_output_supression.py | 2 +- aeon/transformations/collection/_hog1d.py | 2 +- aeon/transformations/collection/_slope.py | 2 +- .../collection/compose/_identity.py | 2 +- aeon/utils/__init__.py | 15 +- aeon/utils/conversion/_convert_collection.py | 2 +- .../tests/test_convert_collection.py | 2 +- aeon/utils/{_data_types.py => data_types.py} | 0 aeon/utils/networks/weight_norm.py | 2 +- aeon/utils/sklearn.py | 12 +- aeon/utils/{_split.py => split.py} | 0 aeon/utils/tags/_tags.py | 2 +- aeon/utils/tests/test_show_versions.py | 2 +- aeon/utils/tests/test_split.py | 2 +- aeon/utils/tests/test_weighted_metrics.py | 25 -- aeon/utils/tests/test_weightnorm.py | 6 +- aeon/utils/validation/__init__.py | 1 - .../utils/validation/tests/test_collection.py | 2 +- aeon/utils/weighted_metrics.py | 44 --- docs/api_reference.md | 1 + docs/api_reference/forecasting.md | 14 + docs/api_reference/utils.rst | 284 +++++++++++++++++- docs/developer_guide/deprecation.md | 1 - docs/getting_started.md | 13 +- docs/index.md | 20 ++ examples/forecasting/forecasting.ipynb | 2 +- examples/forecasting/img/forecasting.png | Bin 0 -> 139258 bytes 62 files changed, 581 insertions(+), 176 deletions(-) create mode 100644 .github/workflows/pr_core_dep_import.yml create mode 100644 aeon/testing/tests/test_core_imports.py rename aeon/testing/utils/{output_supression.py => output_suppression.py} (100%) rename aeon/utils/{_data_types.py => data_types.py} (100%) rename aeon/utils/{_split.py => split.py} (100%) delete mode 100644 aeon/utils/tests/test_weighted_metrics.py delete mode 100644 aeon/utils/weighted_metrics.py create mode 100644 docs/api_reference/forecasting.md create mode 100644 examples/forecasting/img/forecasting.png diff --git a/.github/workflows/periodic_tests.yml b/.github/workflows/periodic_tests.yml index 8c5eeca564..ac81989b27 100644 --- a/.github/workflows/periodic_tests.yml +++ b/.github/workflows/periodic_tests.yml @@ -83,6 +83,31 @@ jobs: # Save cache with the current date (ENV set in numba_cache action) key: numba-run-notebook-examples-${{ runner.os }}-3.10-${{ env.CURRENT_DATE }} + test-core-imports: + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install aeon and dependencies + uses: nick-fields/retry@v3 + with: + timeout_minutes: 30 + max_attempts: 3 + command: python -m pip install . + + - name: Show dependencies + run: python -m pip list + + - name: Run import test + run: python aeon/testing/tests/test_core_imports.py + test-no-soft-deps: runs-on: ubuntu-22.04 diff --git a/.github/workflows/pr_core_dep_import.yml b/.github/workflows/pr_core_dep_import.yml new file mode 100644 index 0000000000..19fb56e294 --- /dev/null +++ b/.github/workflows/pr_core_dep_import.yml @@ -0,0 +1,43 @@ +name: PR module imports + +on: + push: + branches: + - main + pull_request: + branches: + - main + paths: + - "aeon/**" + - ".github/workflows/**" + - "pyproject.toml" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + test-core-imports: + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install aeon and dependencies + uses: nick-fields/retry@v3 + with: + timeout_minutes: 30 + max_attempts: 3 + command: python -m pip install . + + - name: Show dependencies + run: python -m pip list + + - name: Run import test + run: python aeon/testing/tests/test_core_imports.py diff --git a/.github/workflows/pr_pytest.yml b/.github/workflows/pr_pytest.yml index 0a5ed4c673..abbb0f596f 100644 --- a/.github/workflows/pr_pytest.yml +++ b/.github/workflows/pr_pytest.yml @@ -48,7 +48,7 @@ jobs: run: python -m pip list - name: Run tests - run: python -m pytest -n logical -k 'not TestAll' + run: python -m pytest -n logical pytest: runs-on: ${{ matrix.os }} diff --git a/.readthedocs.yml b/.readthedocs.yml index c031c0d25b..1b016c167c 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -11,7 +11,7 @@ python: - docs build: - os: ubuntu-20.04 + os: ubuntu-22.04 tools: python: "3.10" diff --git a/README.md b/README.md index f51a79291a..0763b2f5de 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The following modules are still considered experimental, and the [deprecation po does not apply: - `anomaly_detection` +- `forecasting` - `segmentation` - `similarity_search` - `visualisation` diff --git a/aeon/base/tests/test_base_collection.py b/aeon/base/tests/test_base_collection.py index 97e2232c66..fff1f75f38 100644 --- a/aeon/base/tests/test_base_collection.py +++ b/aeon/base/tests/test_base_collection.py @@ -14,7 +14,7 @@ UNEQUAL_LENGTH_MULTIVARIATE_CLASSIFICATION, UNEQUAL_LENGTH_UNIVARIATE_CLASSIFICATION, ) -from aeon.utils import COLLECTIONS_DATA_TYPES +from aeon.utils.data_types import COLLECTIONS_DATA_TYPES from aeon.utils.validation import get_type diff --git a/aeon/classification/tests/test_base.py b/aeon/classification/tests/test_base.py index e59baa1bf4..49782cab85 100644 --- a/aeon/classification/tests/test_base.py +++ b/aeon/classification/tests/test_base.py @@ -15,7 +15,7 @@ EQUAL_LENGTH_UNIVARIATE_CLASSIFICATION, UNEQUAL_LENGTH_UNIVARIATE_CLASSIFICATION, ) -from aeon.utils import COLLECTIONS_DATA_TYPES +from aeon.utils.data_types import COLLECTIONS_DATA_TYPES __maintainer__ = [] diff --git a/aeon/pipeline/__init__.py b/aeon/pipeline/__init__.py index 732dae0187..a0b15f190f 100644 --- a/aeon/pipeline/__init__.py +++ b/aeon/pipeline/__init__.py @@ -1,6 +1,9 @@ """Pipeline maker utility.""" -__all__ = ["make_pipeline", "sklearn_to_aeon"] +__all__ = [ + "make_pipeline", + "sklearn_to_aeon", +] from aeon.pipeline._make_pipeline import make_pipeline from aeon.pipeline._sklearn_to_aeon import sklearn_to_aeon diff --git a/aeon/regression/compose/tests/test_ensemble.py b/aeon/regression/compose/tests/test_ensemble.py index 447afda327..4d5b288de7 100644 --- a/aeon/regression/compose/tests/test_ensemble.py +++ b/aeon/regression/compose/tests/test_ensemble.py @@ -14,7 +14,7 @@ make_example_3d_numpy, make_example_3d_numpy_list, ) -from aeon.testing.mock_estimators import MockHandlesAllInput, MockRegressor +from aeon.testing.mock_estimators import MockRegressor, MockRegressorFullTags mixed_ensemble = [ DummyRegressor(), @@ -114,7 +114,7 @@ def test_unequal_tag_inference(): n_cases=10, min_n_timepoints=8, max_n_timepoints=12, regression_target=True ) - r1 = MockHandlesAllInput() + r1 = MockRegressorFullTags() r2 = MockRegressor() assert r1.get_tag("capability:unequal_length") @@ -144,7 +144,7 @@ def test_missing_tag_inference(): X, y = make_example_3d_numpy(n_cases=10, n_timepoints=12, regression_target=True) X[5, 0, 4] = np.nan - r1 = MockHandlesAllInput() + r1 = MockRegressorFullTags() r2 = MockRegressor() assert r1.get_tag("capability:missing_values") @@ -175,7 +175,7 @@ def test_multivariate_tag_inference(): n_cases=10, n_channels=2, n_timepoints=12, regression_target=True ) - r1 = MockHandlesAllInput() + r1 = MockRegressorFullTags() r2 = MockRegressor() assert r1.get_tag("capability:multivariate") diff --git a/aeon/regression/tests/test_base.py b/aeon/regression/tests/test_base.py index 30302de8c7..d04469db8b 100644 --- a/aeon/regression/tests/test_base.py +++ b/aeon/regression/tests/test_base.py @@ -12,7 +12,7 @@ EQUAL_LENGTH_UNIVARIATE_REGRESSION, UNEQUAL_LENGTH_UNIVARIATE_REGRESSION, ) -from aeon.utils import COLLECTIONS_DATA_TYPES +from aeon.utils.data_types import COLLECTIONS_DATA_TYPES class _TestRegressor(BaseRegressor): diff --git a/aeon/segmentation/tests/test_base.py b/aeon/segmentation/tests/test_base.py index 013e4ecdad..870fbdb7e5 100644 --- a/aeon/segmentation/tests/test_base.py +++ b/aeon/segmentation/tests/test_base.py @@ -5,7 +5,7 @@ import pytest from aeon.segmentation.base import BaseSegmenter -from aeon.testing.mock_estimators import MockSegmenter, SupervisedMockSegmenter +from aeon.testing.mock_estimators import MockSegmenter, MockSegmenterRequiresY def test_fit_predict_correct(): @@ -25,7 +25,7 @@ def test_fit_predict_correct(): assert res.is_fitted res = seg.fit_predict(x_correct) assert isinstance(res, np.ndarray) - seg = SupervisedMockSegmenter() + seg = MockSegmenterRequiresY() res = seg.fit(x_correct, y=x_correct) assert res.is_fitted with pytest.raises( diff --git a/aeon/testing/estimator_checking/_estimator_checking.py b/aeon/testing/estimator_checking/_estimator_checking.py index c07815ea89..97eed5a0e9 100644 --- a/aeon/testing/estimator_checking/_estimator_checking.py +++ b/aeon/testing/estimator_checking/_estimator_checking.py @@ -193,6 +193,7 @@ class is passed. {'check_get_params(estimator=MockClassifier())': 'PASSED'} """ # check if estimator has soft dependencies installed + _check_soft_dependencies("pytest") _check_estimator_deps(estimator) checks = [] diff --git a/aeon/testing/estimator_checking/_yield_anomaly_detection_checks.py b/aeon/testing/estimator_checking/_yield_anomaly_detection_checks.py index 0d94fbc34f..5f2f05aaa9 100644 --- a/aeon/testing/estimator_checking/_yield_anomaly_detection_checks.py +++ b/aeon/testing/estimator_checking/_yield_anomaly_detection_checks.py @@ -3,7 +3,6 @@ from functools import partial import numpy as np -import pytest from aeon.base._base import _clone_estimator from aeon.base._base_series import VALID_SERIES_INNER_TYPES @@ -64,6 +63,8 @@ def check_anomaly_detector_overrides_and_tags(estimator_class): def check_anomaly_detector_univariate(estimator): """Test the anomaly detector on univariate data.""" + import pytest + estimator = _clone_estimator(estimator) if estimator.get_tag(tag_name="capability:univariate"): @@ -78,6 +79,8 @@ def check_anomaly_detector_univariate(estimator): def check_anomaly_detector_multivariate(estimator): """Test the anomaly detector on multivariate data.""" + import pytest + estimator = _clone_estimator(estimator) if estimator.get_tag(tag_name="capability:multivariate"): diff --git a/aeon/testing/estimator_checking/_yield_classification_checks.py b/aeon/testing/estimator_checking/_yield_classification_checks.py index 00b82705de..09f15877be 100644 --- a/aeon/testing/estimator_checking/_yield_classification_checks.py +++ b/aeon/testing/estimator_checking/_yield_classification_checks.py @@ -24,7 +24,7 @@ _assert_predict_probabilities, _get_tag, ) -from aeon.utils import COLLECTIONS_DATA_TYPES +from aeon.utils.data_types import COLLECTIONS_DATA_TYPES from aeon.utils.validation import get_n_cases diff --git a/aeon/testing/estimator_checking/_yield_estimator_checks.py b/aeon/testing/estimator_checking/_yield_estimator_checks.py index b5fb60c1d3..70f714d4d9 100644 --- a/aeon/testing/estimator_checking/_yield_estimator_checks.py +++ b/aeon/testing/estimator_checking/_yield_estimator_checks.py @@ -9,7 +9,6 @@ import joblib import numpy as np -import pytest from numpy.testing import assert_array_almost_equal from sklearn.exceptions import NotFittedError @@ -596,6 +595,8 @@ def check_fit_updates_state_and_cloning(estimator, datatype): def check_raises_not_fitted_error(estimator, datatype): """Check exception raised for non-fit method calls to unfitted estimators.""" + import pytest + estimator = _clone_estimator(estimator) for method in NON_STATE_CHANGING_METHODS: diff --git a/aeon/testing/estimator_checking/_yield_regression_checks.py b/aeon/testing/estimator_checking/_yield_regression_checks.py index bf6d2cb568..52933e81f7 100644 --- a/aeon/testing/estimator_checking/_yield_regression_checks.py +++ b/aeon/testing/estimator_checking/_yield_regression_checks.py @@ -20,7 +20,7 @@ ) from aeon.testing.testing_data import FULL_TEST_DATA_DICT from aeon.testing.utils.estimator_checks import _assert_predict_labels, _get_tag -from aeon.utils import COLLECTIONS_DATA_TYPES +from aeon.utils.data_types import COLLECTIONS_DATA_TYPES def _yield_regression_checks(estimator_class, estimator_instances, datatypes): diff --git a/aeon/testing/estimator_checking/_yield_segmentation_checks.py b/aeon/testing/estimator_checking/_yield_segmentation_checks.py index 6c2c964f5a..054ca56bd4 100644 --- a/aeon/testing/estimator_checking/_yield_segmentation_checks.py +++ b/aeon/testing/estimator_checking/_yield_segmentation_checks.py @@ -3,7 +3,6 @@ from functools import partial import numpy as np -import pytest from aeon.base._base import _clone_estimator from aeon.base._base_series import VALID_SERIES_INNER_TYPES @@ -42,6 +41,8 @@ def check_segmenter_base_functionality(estimator_class): def check_segmenter_instance(estimator): """Test segmenters.""" + import pytest + estimator = _clone_estimator(estimator) def _assert_output(output, dense, length): diff --git a/aeon/testing/estimator_checking/_yield_soft_dependency_checks.py b/aeon/testing/estimator_checking/_yield_soft_dependency_checks.py index 83c6f96b83..ed5c9605b1 100644 --- a/aeon/testing/estimator_checking/_yield_soft_dependency_checks.py +++ b/aeon/testing/estimator_checking/_yield_soft_dependency_checks.py @@ -6,8 +6,6 @@ from functools import partial -import pytest - from aeon.utils.validation._dependencies import ( _check_python_version, _check_soft_dependencies, @@ -23,6 +21,8 @@ def _yield_soft_dependency_checks(estimator_class, estimator_instances, datatype def check_python_version_softdep(estimator_class): """Test that estimators raise error if python version is wrong.""" + import pytest + # if dependencies are incompatible skip softdeps = estimator_class.get_class_tag("python_dependencies", None) if softdeps is not None and not _check_soft_dependencies(softdeps, severity="none"): @@ -46,6 +46,8 @@ def check_python_version_softdep(estimator_class): def check_python_dependency_softdep(estimator_class): """Test that estimators raise error if required soft dependencies are missing.""" + import pytest + # if python version is incompatible skip if not _check_python_version(estimator_class, severity="none"): return diff --git a/aeon/testing/estimator_checking/_yield_transformation_checks.py b/aeon/testing/estimator_checking/_yield_transformation_checks.py index 507c8c1e08..4a8c51f795 100644 --- a/aeon/testing/estimator_checking/_yield_transformation_checks.py +++ b/aeon/testing/estimator_checking/_yield_transformation_checks.py @@ -20,7 +20,7 @@ from aeon.testing.utils.estimator_checks import _run_estimator_method from aeon.transformations.collection.channel_selection.base import BaseChannelSelector from aeon.transformations.series import BaseSeriesTransformer -from aeon.utils import COLLECTIONS_DATA_TYPES +from aeon.utils.data_types import COLLECTIONS_DATA_TYPES def _yield_transformation_checks(estimator_class, estimator_instances, datatypes): diff --git a/aeon/testing/mock_estimators/__init__.py b/aeon/testing/mock_estimators/__init__.py index 32d947cb7d..219fc3e987 100644 --- a/aeon/testing/mock_estimators/__init__.py +++ b/aeon/testing/mock_estimators/__init__.py @@ -1,26 +1,46 @@ """Mock estimators for testing and debugging.""" __all__ = [ - "make_mock_estimator", + # anomaly detection + "MockAnomalyDetector", + "MockAnomalyDetectorRequiresFit", + "MockAnomalyDetectorRequiresY", + # classification "MockClassifier", "MockClassifierPredictProba", "MockClassifierFullTags", "MockClassifierParams", + "MockClassifierComposite", + # clustering "MockCluster", "MockDeepClusterer", - "MockSegmenter", - "SupervisedMockSegmenter", - "MockHandlesAllInput", + # collection transformation + "MockCollectionTransformer", + # forecasting + "MockForecaster", + # regression "MockRegressor", + "MockRegressorFullTags", + # segmentation + "MockSegmenter", + "MockSegmenterRequiresY", + # series transformation + "MockSeriesTransformer", + "MockUnivariateSeriesTransformer", "MockMultivariateSeriesTransformer", "MockSeriesTransformerNoFit", - "MockUnivariateSeriesTransformer", - "MockCollectionTransformer", - "MockSeriesTransformer", + # similarity search + "MockSimilaritySearch", ] +from aeon.testing.mock_estimators._mock_anomaly_detectors import ( + MockAnomalyDetector, + MockAnomalyDetectorRequiresFit, + MockAnomalyDetectorRequiresY, +) from aeon.testing.mock_estimators._mock_classifiers import ( MockClassifier, + MockClassifierComposite, MockClassifierFullTags, MockClassifierParams, MockClassifierPredictProba, @@ -29,13 +49,14 @@ from aeon.testing.mock_estimators._mock_collection_transformers import ( MockCollectionTransformer, ) +from aeon.testing.mock_estimators._mock_forecasters import MockForecaster from aeon.testing.mock_estimators._mock_regressors import ( - MockHandlesAllInput, MockRegressor, + MockRegressorFullTags, ) from aeon.testing.mock_estimators._mock_segmenters import ( MockSegmenter, - SupervisedMockSegmenter, + MockSegmenterRequiresY, ) from aeon.testing.mock_estimators._mock_series_transformers import ( MockMultivariateSeriesTransformer, @@ -43,3 +64,4 @@ MockSeriesTransformerNoFit, MockUnivariateSeriesTransformer, ) +from aeon.testing.mock_estimators._mock_similarity_search import MockSimilaritySearch diff --git a/aeon/testing/mock_estimators/_mock_anomaly_detectors.py b/aeon/testing/mock_estimators/_mock_anomaly_detectors.py index 78ddfe76f1..4ec14d35fa 100644 --- a/aeon/testing/mock_estimators/_mock_anomaly_detectors.py +++ b/aeon/testing/mock_estimators/_mock_anomaly_detectors.py @@ -1,4 +1,4 @@ -"""Mock anomaly detectors for testing.""" +"""Mock anomaly detectorsuseful for testing and debugging.""" __maintainer__ = ["MatthewMiddlehurst"] __all__ = [ diff --git a/aeon/testing/mock_estimators/_mock_classifiers.py b/aeon/testing/mock_estimators/_mock_classifiers.py index bcfcb8162e..1bf9357d60 100644 --- a/aeon/testing/mock_estimators/_mock_classifiers.py +++ b/aeon/testing/mock_estimators/_mock_classifiers.py @@ -1,7 +1,13 @@ -"""Mock classifiers useful for testing and debugging. - -Used in tests for the classifier base class. -""" +"""Mock classifiers useful for testing and debugging.""" + +__maintainer__ = ["MatthewMiddlehurst"] +__all__ = [ + "MockClassifier", + "MockClassifierPredictProba", + "MockClassifierFullTags", + "MockClassifierParams", + "MockClassifierComposite", +] import numpy as np diff --git a/aeon/testing/mock_estimators/_mock_clusterers.py b/aeon/testing/mock_estimators/_mock_clusterers.py index b920b83c30..53b1014290 100644 --- a/aeon/testing/mock_estimators/_mock_clusterers.py +++ b/aeon/testing/mock_estimators/_mock_clusterers.py @@ -1,3 +1,11 @@ +"""Mock clusterers useful for testing and debugging.""" + +__maintainer__ = [] +__all__ = [ + "MockCluster", + "MockDeepClusterer", +] + import numpy as np from aeon.clustering.base import BaseClusterer diff --git a/aeon/testing/mock_estimators/_mock_collection_transformers.py b/aeon/testing/mock_estimators/_mock_collection_transformers.py index bc59069283..7feb8d46a9 100644 --- a/aeon/testing/mock_estimators/_mock_collection_transformers.py +++ b/aeon/testing/mock_estimators/_mock_collection_transformers.py @@ -1,4 +1,9 @@ -"""Mock collection transformers.""" +"""Mock collection transformers useful for testing and debugging.""" + +__maintainer__ = [] +__all__ = [ + "MockCollectionTransformer", +] from aeon.transformations.collection import BaseCollectionTransformer diff --git a/aeon/testing/mock_estimators/_mock_forecasters.py b/aeon/testing/mock_estimators/_mock_forecasters.py index f5bb86d249..8eb2ba2635 100644 --- a/aeon/testing/mock_estimators/_mock_forecasters.py +++ b/aeon/testing/mock_estimators/_mock_forecasters.py @@ -1,7 +1,10 @@ -"""Mock forecasters useful for testing and debugging. +"""Mock forecasters useful for testing and debugging.""" + +__maintainer__ = ["TonyBagnall"] +__all__ = [ + "MockForecaster", +] -Used in tests for the forecasting base class. -""" from aeon.forecasting.base import BaseForecaster diff --git a/aeon/testing/mock_estimators/_mock_regressors.py b/aeon/testing/mock_estimators/_mock_regressors.py index 355534abac..5258019aab 100644 --- a/aeon/testing/mock_estimators/_mock_regressors.py +++ b/aeon/testing/mock_estimators/_mock_regressors.py @@ -1,3 +1,11 @@ +"""Mock regressors useful for testing and debugging.""" + +__maintainer__ = ["MatthewMiddlehurst"] +__all__ = [ + "MockRegressor", + "MockRegressorFullTags", +] + from sklearn.utils import check_random_state from aeon.regression.base import BaseRegressor @@ -20,7 +28,7 @@ def _predict(self, X): return rng.random(size=(len(X))) -class MockHandlesAllInput(BaseRegressor): +class MockRegressorFullTags(BaseRegressor): """Dummy regressor for testing base class fit/predict/predict_proba.""" _tags = { diff --git a/aeon/testing/mock_estimators/_mock_segmenters.py b/aeon/testing/mock_estimators/_mock_segmenters.py index 82ff6a81f6..45b7a524aa 100644 --- a/aeon/testing/mock_estimators/_mock_segmenters.py +++ b/aeon/testing/mock_estimators/_mock_segmenters.py @@ -1,4 +1,10 @@ -"""Mock segmenters for testing.""" +"""Mock segmenters useful for testing and debugging.""" + +__maintainer__ = [] +__all__ = [ + "MockSegmenter", + "MockSegmenterRequiresY", +] import numpy as np @@ -42,7 +48,7 @@ def _get_test_params(cls, parameter_set="default"): return {} -class SupervisedMockSegmenter(MockSegmenter): +class MockSegmenterRequiresY(MockSegmenter): """Mock segmenter for testing.""" _tags = { diff --git a/aeon/testing/mock_estimators/_mock_series_transformers.py b/aeon/testing/mock_estimators/_mock_series_transformers.py index 937f4a6325..66d62ef687 100644 --- a/aeon/testing/mock_estimators/_mock_series_transformers.py +++ b/aeon/testing/mock_estimators/_mock_series_transformers.py @@ -1,4 +1,12 @@ -"""Mock series transformers.""" +"""Mock series transformers useful for testing and debugging.""" + +__maintainer__ = [] +__all__ = [ + "MockSeriesTransformer", + "MockUnivariateSeriesTransformer", + "MockMultivariateSeriesTransformer", + "MockSeriesTransformerNoFit", +] import numpy as np diff --git a/aeon/testing/mock_estimators/_mock_similarity_search.py b/aeon/testing/mock_estimators/_mock_similarity_search.py index 8542b81a1b..55c9c435c7 100644 --- a/aeon/testing/mock_estimators/_mock_similarity_search.py +++ b/aeon/testing/mock_estimators/_mock_similarity_search.py @@ -1,12 +1,14 @@ -"""Mock similarity search useful for testing and debugging. +"""Mock similarity searchers useful for testing and debugging.""" -Used in tests for the query search base class. -""" +__maintainer__ = ["baraline"] +__all__ = [ + "MockSimilaritySearch", +] from aeon.similarity_search.base import BaseSimilaritySearch -class MocksimilaritySearch(BaseSimilaritySearch): +class MockSimilaritySearch(BaseSimilaritySearch): """Mock similarity search for testing base class predict.""" def _fit(self, X, y=None): diff --git a/aeon/testing/tests/__init__.py b/aeon/testing/tests/__init__.py index 69d1ff2d83..aaeacff24d 100644 --- a/aeon/testing/tests/__init__.py +++ b/aeon/testing/tests/__init__.py @@ -1 +1,13 @@ -"""Tests for the aeon package and testing module utiltiies.""" +"""Tests for the aeon package and testing module utilties.""" + +import pkgutil + +import aeon + +# collect all modules +ALL_AEON_MODULES = pkgutil.walk_packages(aeon.__path__, aeon.__name__ + ".") +ALL_AEON_MODULES = [x[1] for x in ALL_AEON_MODULES] + +ALL_AEON_MODULES_NO_TESTS = [ + x for x in ALL_AEON_MODULES if not any(part == "tests" for part in x.split(".")) +] diff --git a/aeon/testing/tests/test_core_imports.py b/aeon/testing/tests/test_core_imports.py new file mode 100644 index 0000000000..f13740c08e --- /dev/null +++ b/aeon/testing/tests/test_core_imports.py @@ -0,0 +1,26 @@ +"""Tests that non-core dependencies are handled correctly in modules.""" + +import re +from importlib import import_module + +from aeon.testing.tests import ALL_AEON_MODULES_NO_TESTS + +if __name__ == "__main__": + """Test imports in aeon modules with core dependencies only. + + Imports all modules and catch exceptions due to missing dependencies. + """ + for module in ALL_AEON_MODULES_NO_TESTS: + try: + import_module(module) + except ModuleNotFoundError as e: # pragma: no cover + dependency = "unknown" + match = re.search(r"\'(.+?)\'", str(e)) + if match: + dependency = match.group(1) + + raise ModuleNotFoundError( + f"The module: {module} should not require any non-core dependencies, " + f"but tried importing: '{dependency}'. Make sure non-core dependencies " + f"are properly isolated outside of tests/ directories." + ) from e diff --git a/aeon/testing/tests/test_softdeps.py b/aeon/testing/tests/test_softdeps.py index 830d0e80f0..2271d21497 100644 --- a/aeon/testing/tests/test_softdeps.py +++ b/aeon/testing/tests/test_softdeps.py @@ -1,23 +1,14 @@ """Tests that soft dependencies are handled correctly in modules.""" -__maintainer__ = [] - -import pkgutil import re from importlib import import_module import pytest -import aeon from aeon.testing.testing_config import PR_TESTING +from aeon.testing.tests import ALL_AEON_MODULES, ALL_AEON_MODULES_NO_TESTS -# collect all modules -modules = pkgutil.walk_packages(aeon.__path__, aeon.__name__ + ".") -modules = [x[1] for x in modules] - -if PR_TESTING: # pragma: no cover - # exclude test modules - modules = [x for x in modules if not any(part == "tests" for part in x.split("."))] +modules = ALL_AEON_MODULES_NO_TESTS if PR_TESTING else ALL_AEON_MODULES def test_module_crawl(): diff --git a/aeon/testing/tests/test_testing_data.py b/aeon/testing/tests/test_testing_data.py index 505cb474a8..f9afe264dd 100644 --- a/aeon/testing/tests/test_testing_data.py +++ b/aeon/testing/tests/test_testing_data.py @@ -20,7 +20,7 @@ UNEQUAL_LENGTH_UNIVARIATE_REGRESSION, UNEQUAL_LENGTH_UNIVARIATE_SIMILARITY_SEARCH, ) -from aeon.utils import COLLECTIONS_DATA_TYPES +from aeon.utils.data_types import COLLECTIONS_DATA_TYPES from aeon.utils.validation import ( has_missing, is_collection, diff --git a/aeon/testing/utils/deep_equals.py b/aeon/testing/utils/deep_equals.py index 86c6c5cd96..81f6d91534 100644 --- a/aeon/testing/utils/deep_equals.py +++ b/aeon/testing/utils/deep_equals.py @@ -1,6 +1,6 @@ """Testing utility to compare equality in value for nested objects.""" -__maintainer__ = [] +__maintainer__ = ["MatthewMiddlehurst"] __all__ = ["deep_equals"] from inspect import isclass diff --git a/aeon/testing/utils/output_supression.py b/aeon/testing/utils/output_suppression.py similarity index 100% rename from aeon/testing/utils/output_supression.py rename to aeon/testing/utils/output_suppression.py diff --git a/aeon/testing/utils/tests/test_output_supression.py b/aeon/testing/utils/tests/test_output_supression.py index 8e8b0a4862..56f7b18ec5 100644 --- a/aeon/testing/utils/tests/test_output_supression.py +++ b/aeon/testing/utils/tests/test_output_supression.py @@ -2,7 +2,7 @@ import sys -from aeon.testing.utils.output_supression import suppress_output +from aeon.testing.utils.output_suppression import suppress_output @suppress_output() diff --git a/aeon/transformations/collection/_hog1d.py b/aeon/transformations/collection/_hog1d.py index 3deddf5931..a3cc18d8aa 100644 --- a/aeon/transformations/collection/_hog1d.py +++ b/aeon/transformations/collection/_hog1d.py @@ -6,7 +6,7 @@ import numpy as np from aeon.transformations.collection.base import BaseCollectionTransformer -from aeon.utils import split_series +from aeon.utils.split import split_series class HOG1DTransformer(BaseCollectionTransformer): diff --git a/aeon/transformations/collection/_slope.py b/aeon/transformations/collection/_slope.py index 7a11cbcdb6..cf9d860478 100644 --- a/aeon/transformations/collection/_slope.py +++ b/aeon/transformations/collection/_slope.py @@ -8,7 +8,7 @@ import numpy as np from aeon.transformations.collection.base import BaseCollectionTransformer -from aeon.utils import split_series +from aeon.utils.split import split_series class SlopeTransformer(BaseCollectionTransformer): diff --git a/aeon/transformations/collection/compose/_identity.py b/aeon/transformations/collection/compose/_identity.py index 20c6674b6b..a359255242 100644 --- a/aeon/transformations/collection/compose/_identity.py +++ b/aeon/transformations/collection/compose/_identity.py @@ -1,7 +1,7 @@ """Identity transformer.""" from aeon.transformations.collection import BaseCollectionTransformer -from aeon.utils import COLLECTIONS_DATA_TYPES +from aeon.utils.data_types import COLLECTIONS_DATA_TYPES class CollectionId(BaseCollectionTransformer): diff --git a/aeon/utils/__init__.py b/aeon/utils/__init__.py index 8f16b7102d..e198bb676e 100644 --- a/aeon/utils/__init__.py +++ b/aeon/utils/__init__.py @@ -1,20 +1,7 @@ """Utility functionality.""" __all__ = [ - "split_series", - "ALL_TIME_SERIES_TYPES", - "COLLECTIONS_DATA_TYPES", - "SERIES_DATA_TYPES", - "HIERARCHICAL_DATA_TYPES", - # github debug util - "show_versions", + "show_versions", # github debug util ] -from aeon.utils._data_types import ( - ALL_TIME_SERIES_TYPES, - COLLECTIONS_DATA_TYPES, - HIERARCHICAL_DATA_TYPES, - SERIES_DATA_TYPES, -) -from aeon.utils._split import split_series from aeon.utils.show_versions import show_versions diff --git a/aeon/utils/conversion/_convert_collection.py b/aeon/utils/conversion/_convert_collection.py index e41ed0c8a2..0e3e28f1af 100644 --- a/aeon/utils/conversion/_convert_collection.py +++ b/aeon/utils/conversion/_convert_collection.py @@ -22,7 +22,7 @@ import pandas as pd from numba.typed import List as NumbaList -from aeon.utils._data_types import COLLECTIONS_DATA_TYPES +from aeon.utils.data_types import COLLECTIONS_DATA_TYPES from aeon.utils.validation.collection import _equal_length, get_type diff --git a/aeon/utils/conversion/tests/test_convert_collection.py b/aeon/utils/conversion/tests/test_convert_collection.py index e9940aa673..3776dc7f4f 100644 --- a/aeon/utils/conversion/tests/test_convert_collection.py +++ b/aeon/utils/conversion/tests/test_convert_collection.py @@ -8,7 +8,6 @@ EQUAL_LENGTH_UNIVARIATE_CLASSIFICATION, UNEQUAL_LENGTH_UNIVARIATE_CLASSIFICATION, ) -from aeon.utils import COLLECTIONS_DATA_TYPES from aeon.utils.conversion._convert_collection import ( _from_numpy2d_to_df_list, _from_numpy2d_to_np_list, @@ -24,6 +23,7 @@ resolve_equal_length_inner_type, resolve_unequal_length_inner_type, ) +from aeon.utils.data_types import COLLECTIONS_DATA_TYPES from aeon.utils.validation.collection import ( _equal_length, get_n_cases, diff --git a/aeon/utils/_data_types.py b/aeon/utils/data_types.py similarity index 100% rename from aeon/utils/_data_types.py rename to aeon/utils/data_types.py diff --git a/aeon/utils/networks/weight_norm.py b/aeon/utils/networks/weight_norm.py index 1a613f9b64..459cfd7104 100644 --- a/aeon/utils/networks/weight_norm.py +++ b/aeon/utils/networks/weight_norm.py @@ -5,7 +5,7 @@ if _check_soft_dependencies(["tensorflow"], severity="none"): import tensorflow as tf - class WeightNormalization(tf.keras.layers.Wrapper): + class _WeightNormalization(tf.keras.layers.Wrapper): """Apply weight normalization to a Keras layer.""" def __init__(self, layer, **kwargs): diff --git a/aeon/utils/sklearn.py b/aeon/utils/sklearn.py index 1f7d45a6fc..8ee26cf311 100644 --- a/aeon/utils/sklearn.py +++ b/aeon/utils/sklearn.py @@ -1,5 +1,15 @@ """Sklearn related typing and inheritance checking utility.""" +__maintainer__ = [] +__all__ = [ + "is_sklearn_estimator", + "sklearn_estimator_identifier", + "is_sklearn_transformer", + "is_sklearn_classifier", + "is_sklearn_regressor", + "is_sklearn_clusterer", +] + from inspect import isclass from sklearn.base import ( @@ -12,8 +22,6 @@ from sklearn.model_selection import GridSearchCV, RandomizedSearchCV from sklearn.pipeline import Pipeline -__maintainer__ = [] - from aeon.base import BaseAeonEstimator diff --git a/aeon/utils/_split.py b/aeon/utils/split.py similarity index 100% rename from aeon/utils/_split.py rename to aeon/utils/split.py diff --git a/aeon/utils/tags/_tags.py b/aeon/utils/tags/_tags.py index 554584115e..e1bacdd5ad 100644 --- a/aeon/utils/tags/_tags.py +++ b/aeon/utils/tags/_tags.py @@ -17,7 +17,7 @@ class : identifier for the base class of objects this tag applies to __maintainer__ = ["MatthewMiddlehurst"] __all__ = ["ESTIMATOR_TAGS"] -from aeon.utils import COLLECTIONS_DATA_TYPES, SERIES_DATA_TYPES +from aeon.utils.data_types import COLLECTIONS_DATA_TYPES, SERIES_DATA_TYPES ESTIMATOR_TAGS = { # all estimators diff --git a/aeon/utils/tests/test_show_versions.py b/aeon/utils/tests/test_show_versions.py index 866692a7d6..b47810ed98 100644 --- a/aeon/utils/tests/test_show_versions.py +++ b/aeon/utils/tests/test_show_versions.py @@ -1,6 +1,6 @@ """Test the show versions function.""" -from aeon.testing.utils.output_supression import suppress_output +from aeon.testing.utils.output_suppression import suppress_output from aeon.utils.show_versions import show_versions diff --git a/aeon/utils/tests/test_split.py b/aeon/utils/tests/test_split.py index 3c4f8df751..4e655f3aa2 100644 --- a/aeon/utils/tests/test_split.py +++ b/aeon/utils/tests/test_split.py @@ -3,7 +3,7 @@ import numpy as np import pytest -from aeon.utils import split_series +from aeon.utils.split import split_series X = np.arange(10) testdata = [ diff --git a/aeon/utils/tests/test_weighted_metrics.py b/aeon/utils/tests/test_weighted_metrics.py deleted file mode 100644 index c8a0b09135..0000000000 --- a/aeon/utils/tests/test_weighted_metrics.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Test weighted metric.""" - -import numpy as np -import pytest - -from aeon.utils.weighted_metrics import weighted_geometric_mean - - -def test_weighted_geometric_mean(): - """Test weighted_geometric_mean.""" - y = np.array([1.0, 2.0, 3.0]) - w = np.array([0.1, 0.8, 0.1]) - w2 = np.array([[0.1, 0.8, 0.1]]).T - res = weighted_geometric_mean(y, w) - assert round(res, 5) == 1.94328 - res2 = weighted_geometric_mean(y, w, axis=0) - assert res == res2 - y2 = np.array([[1.0, 2.0, 3.0]]).T - with pytest.raises(ValueError, match="do not match"): - weighted_geometric_mean(y2, w, axis=1) - weighted_geometric_mean(y2, w2, axis=1) - with pytest.raises( - ValueError, match="Input data and weights have inconsistent shapes" - ): - weighted_geometric_mean(y, w2) diff --git a/aeon/utils/tests/test_weightnorm.py b/aeon/utils/tests/test_weightnorm.py index 43b20293d5..0642530b2c 100644 --- a/aeon/utils/tests/test_weightnorm.py +++ b/aeon/utils/tests/test_weightnorm.py @@ -16,11 +16,11 @@ def test_weight_norm(): import numpy as np import tensorflow as tf - from aeon.utils.networks.weight_norm import WeightNormalization + from aeon.utils.networks.weight_norm import _WeightNormalization X = np.random.random((10, 10, 5)) _input = tf.keras.layers.Input((10, 5)) - l1 = WeightNormalization( + l1 = _WeightNormalization( tf.keras.layers.Conv1D(filters=5, kernel_size=1, dilation_rate=4) )(_input) model = tf.keras.models.Model(inputs=_input, outputs=l1) @@ -42,7 +42,7 @@ def test_weight_norm(): model_path = "test_weight_norm_model.h5" model.save(model_path) loaded_model = tf.keras.models.load_model( - model_path, custom_objects={"WeightNormalization": WeightNormalization} + model_path, custom_objects={"_WeightNormalization": _WeightNormalization} ) assert loaded_model is not None loaded_output = loaded_model.predict(X) diff --git a/aeon/utils/validation/__init__.py b/aeon/utils/validation/__init__.py index 14a16853ad..7e86a79a13 100644 --- a/aeon/utils/validation/__init__.py +++ b/aeon/utils/validation/__init__.py @@ -12,7 +12,6 @@ "check_window_length", "get_n_cases", "get_type", - "equal_length", "is_equal_length", "has_missing", "is_univariate", diff --git a/aeon/utils/validation/tests/test_collection.py b/aeon/utils/validation/tests/test_collection.py index b1c27e4a64..4c53572b32 100644 --- a/aeon/utils/validation/tests/test_collection.py +++ b/aeon/utils/validation/tests/test_collection.py @@ -13,7 +13,7 @@ make_example_3d_numpy_list, ) from aeon.testing.testing_data import EQUAL_LENGTH_UNIVARIATE_CLASSIFICATION -from aeon.utils import COLLECTIONS_DATA_TYPES +from aeon.utils.data_types import COLLECTIONS_DATA_TYPES from aeon.utils.validation.collection import ( _is_numpy_list_multivariate, _is_pd_wide, diff --git a/aeon/utils/weighted_metrics.py b/aeon/utils/weighted_metrics.py deleted file mode 100644 index 84ec005d2f..0000000000 --- a/aeon/utils/weighted_metrics.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Statistical functionality used throughout aeon.""" - -import numpy as np -from sklearn.utils.validation import check_consistent_length - -__maintainer__ = [] -__all__ = [ - "weighted_geometric_mean", -] - - -def weighted_geometric_mean(y, weights, axis=None): - """Calculate weighted version of geometric mean. - - Parameters - ---------- - y : np.ndarray - Values to take the weighted geometric mean of. - weights: np.ndarray - Weights for each value in `array`. Must be same shape as `array` or - of shape `(array.shape[0],)` if axis=0 or `(array.shape[1], ) if axis=1. - axis : int - The axis of `y` to apply the weights to. - - Returns - ------- - geometric_mean : float - Weighted geometric mean - """ - if weights.ndim == 1: - if axis == 0: - check_consistent_length(y, weights) - elif axis == 1: - if y.shape[1] != len(weights): - raise ValueError( - f"Input features ({y.shape[1]}) do not match " - f"number of `weights` ({len(weights)})." - ) - weight_sums = np.sum(weights) - else: - if y.shape != weights.shape: - raise ValueError("Input data and weights have inconsistent shapes.") - weight_sums = np.sum(weights, axis=axis) - return np.exp(np.sum(weights * np.log(y), axis=axis) / weight_sums) diff --git a/docs/api_reference.md b/docs/api_reference.md index 34a580ba74..d0c6335ca7 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -19,6 +19,7 @@ api_reference/clustering api_reference/data_format api_reference/datasets api_reference/distances +api_reference/forecasting api_reference/networks api_reference/regression api_reference/segmentation diff --git a/docs/api_reference/forecasting.md b/docs/api_reference/forecasting.md new file mode 100644 index 0000000000..131fb8be86 --- /dev/null +++ b/docs/api_reference/forecasting.md @@ -0,0 +1,14 @@ +# Forecasting + +```{eval-rst} +.. currentmodule:: aeon.datasets + +.. autosummary:: + :toctree: auto_generated/ + :template: class.rst + + DummyForecaster + BaseForecaster + RegressionForecaster + ETSForecaster +``` diff --git a/docs/api_reference/utils.rst b/docs/api_reference/utils.rst index adaacdf0f2..40dea9f67c 100644 --- a/docs/api_reference/utils.rst +++ b/docs/api_reference/utils.rst @@ -5,18 +5,13 @@ Utility functions ``aeon`` has a number of modules dedicated to utilities: -* :mod:`aeon.pipeline`, which contains generics for pipeline construction. +* :mod:`aeon.pipeline`, which contains functions for pipeline construction. +* :mod:`aeon.testing`, which contains functions for estimator testing and data generation. * :mod:`aeon.utils`, which contains generic utility functions. -Pipeline construction ---------------------- - -:mod:`aeon.pipeline` - -.. automodule:: aeon.pipeline - :no-members: - :no-inherited-members: +Pipeline +-------- .. currentmodule:: aeon.pipeline @@ -26,3 +21,274 @@ Pipeline construction make_pipeline sklearn_to_aeon + +Testing +------- + +Data Generation +^^^^^^^^^^^^^^^ + +.. currentmodule:: aeon.testing.data_generation + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + make_example_3d_numpy + make_example_2d_numpy_collection + make_example_3d_numpy_list + make_example_2d_numpy_list + make_example_dataframe_list + make_example_2d_dataframe_collection + make_example_multi_index_dataframe + make_example_1d_numpy + make_example_2d_numpy_series + make_example_pandas_series + make_example_dataframe_series + +Estimator Checking +^^^^^^^^^^^^^^^^^^ + +.. currentmodule:: aeon.testing.estimator_checking + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + check_estimator + parametrize_with_checks + +Mock Estimators +^^^^^^^^^^^^^^^ + +.. currentmodule:: aeon.testing.mock_estimators + +.. autosummary:: + :toctree: auto_generated/ + :template: class.rst + + MockAnomalyDetector + MockAnomalyDetectorRequiresFit + MockAnomalyDetectorRequiresY + MockClassifier + MockClassifierPredictProba + MockClassifierFullTags + MockClassifierParams + MockClassifierComposite + MockCluster + MockDeepClusterer + MockCollectionTransformer + MockForecaster + MockRegressor + MockRegressorFullTags + MockSegmenter + MockSegmenterRequiresY + MockSeriesTransformer + MockUnivariateSeriesTransformer + MockMultivariateSeriesTransformer + MockSeriesTransformerNoFit + MockSimilaritySearch + +Utilities +^^^^^^^^^ + +.. currentmodule:: aeon.testing.utils.deep_equals + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + deep_equals + +.. currentmodule:: aeon.testing.utils.output_suppression + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + suppress_output + +Utils +----- + +Estimator Discovery & Tags +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. currentmodule:: aeon.utils.base + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + get_identifier + +.. currentmodule:: aeon.utils.discovery + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + all_estimators + +.. currentmodule:: aeon.utils.tags + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + check_valid_tags + all_tags_for_estimator + + +Data Conversion & Validation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. currentmodule:: aeon.utils.conversion + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + resolve_equal_length_inner_type + resolve_unequal_length_inner_type + convert_collection + convert_series + +.. currentmodule:: aeon.utils.validation + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + is_int + is_float + is_timedelta + is_date_offset + is_timedelta_or_date_offset + check_n_jobs + check_window_length + get_n_cases + get_type + is_equal_length + has_missing + is_univariate + is_univariate_series + is_single_series + is_collection + is_tabular + is_hierarchical + +Numba +^^^^^ + +.. currentmodule:: aeon.utils.numba.general + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + unique_count + first_order_differences + first_order_differences_2d + first_order_differences_3d + z_normalise_series_with_mean + z_normalise_series + z_normalise_series_2d + z_normalise_series_3d + set_numba_random_seed + choice_log + get_subsequence + get_subsequence_with_mean_std + sliding_mean_std_one_series + combinations_1d + slope_derivative + slope_derivative_2d + slope_derivative_3d + generate_combinations + +.. currentmodule:: aeon.utils.numba.stats + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + mean + row_mean + count_mean_crossing + row_count_mean_crossing + count_above_mean + row_count_above_mean + quantile + row_quantile + median + row_median + quantile25 + row_quantile25 + quantile75 + row_quantile75 + std + std2 + row_std + numba_min + row_numba_min + numba_max + row_numba_max + slope + row_slope + iqr + row_iqr + ppv + row_ppv + fisher_score + prime_up_to + is_prime + +.. currentmodule:: aeon.utils.numba.wavelets + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + haar_transform + multilevel_haar_transform + +Other +^^^^^ + +.. currentmodule:: aeon.utils + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + show_versions + +.. currentmodule:: aeon.utils.sklearn + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + is_sklearn_estimator + sklearn_estimator_identifier + is_sklearn_transformer + is_sklearn_classifier + is_sklearn_regressor + is_sklearn_clusterer + +.. currentmodule:: aeon.utils.split + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + split_series + +.. currentmodule:: aeon.utils.windowing + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + sliding_windows + reverse_windowing diff --git a/docs/developer_guide/deprecation.md b/docs/developer_guide/deprecation.md index a4b294b86a..4b10d81cb2 100644 --- a/docs/developer_guide/deprecation.md +++ b/docs/developer_guide/deprecation.md @@ -20,7 +20,6 @@ Note that the deprecation policy does not necessarily apply to modules we class experimental. Currently experimental modules are: - `anomaly_detection` -- `benchmarking` - `forecasting` - `segmentation` - `similarity_search` diff --git a/docs/getting_started.md b/docs/getting_started.md index 3a4ef8dbc0..36f18583cb 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -26,6 +26,9 @@ classical techniques for the following learning tasks: ([more details](examples/similarity_search/similarity_search.ipynb)). - [**Anomaly detection**](api_reference/anomaly_detection), where the goal is to find values or areas of a single time series that are not representative of the whole series. +- [**Forecasting**](api_reference/forecasting), where the goal is to predict future values + of a single time series + ([more details](examples/forecasting/forecasting.ipynb)). - [**Segmentation**](api_reference/segmentation), where the goal is to split a single time series into regions where the series are sofind areas of a time series that are not representative of the whole series @@ -37,8 +40,8 @@ classical techniques for the following learning tasks: transformed into a different representation or domain. ([more details](examples/transformations/transformations.ipynb)). - [**Distances**](api_reference/distances), which measure the dissimilarity between two time series or collections of series and include functions to align series ([more details](examples/distances/distances.ipynb)). -- [**Networks**](api_reference/networks), provides core models for deep learning for all time series tasks ([more - details](examples/networks/deep_learning.ipynb)). +- [**Networks**](api_reference/networks), provides core models for deep learning for all time series tasks +- ([more details](examples/networks/deep_learning.ipynb)). There are dedicated notebooks going into more detail for each of these modules. This guide is meant to give you the briefest of introductions to the main concepts and @@ -99,9 +102,9 @@ structures, see our [datasets](examples/datasets/datasets.ipynb) notebooks. ## Single Series Modules Different `aeon` modules work with individual series or collections of series. -Estimators in the `anomaly detection` and `segmentation` modules use single -series input (they inherit from `BaseSeriesEstimator`). The functions in `distances` -take two series as arguments. +Estimators in the `anomaly detection`, `forecasting` and `segmentation` modules use +single series input (they inherit from `BaseSeriesEstimator`). The functions in +`distances` take two series as arguments. ### Anomaly Detection diff --git a/docs/index.md b/docs/index.md index a1b8f0cba7..83fc6bb049 100644 --- a/docs/index.md +++ b/docs/index.md @@ -110,6 +110,25 @@ Anomaly Detection ::: +:::{grid-item-card} +:img-top: examples/forecasting/img/forecasting.png +:class-img-top: aeon-card-image +:text-align: center + +Get started with forecasting + ++++ + +```{button-ref} /examples/forecasting/forecasting.ipynb +:color: primary +:click-parent: +:expand: + +Forecasting +``` + +::: + :::{grid-item-card} :img-top: examples/segmentation/img/segmentation.png :class-img-top: aeon-card-image @@ -253,6 +272,7 @@ is relaxed, so it is suggested that you integrate these modules with care. The c experimental modules are: - `anomaly_detection` +- `forecasting` - `segmentation` - `similarity_search` - `visualisation` diff --git a/examples/forecasting/forecasting.ipynb b/examples/forecasting/forecasting.ipynb index e17b6667dc..0e0b4ac72f 100644 --- a/examples/forecasting/forecasting.ipynb +++ b/examples/forecasting/forecasting.ipynb @@ -109,7 +109,7 @@ { "cell_type": "code", "source": [ - "from aeon.utils import SERIES_DATA_TYPES\n", + "from aeon.utils.data_types import SERIES_DATA_TYPES\n", "\n", "print(\" Possible data structures for input to forecaster \", SERIES_DATA_TYPES)\n", "print(\"\\n Tags for BaseForecaster: \", BaseForecaster.get_class_tags())" diff --git a/examples/forecasting/img/forecasting.png b/examples/forecasting/img/forecasting.png new file mode 100644 index 0000000000000000000000000000000000000000..c9316dbe5af419b09b82a28218e4363aa705871d GIT binary patch literal 139258 zcmeFZc{r5o{{TErr#hw5h8EE#J4uawNlDqWFIiGZ7_w%cQ)!_nJ7Y~lS;xLFg(SwB zeakisA!fu(miK;UjPu3szxTiQ?YXXVo#Q;ueczvbzvqRTiu~T)$9BVDu)Q~~UsZ>} z4%~ymcK!Hg2lzjxeWm%}Uvv)Y^0Kh>1{OT{<8O=0%9mlV%mDhO+uOn4yX>y(Ily2S zDxiPpIPdEBz+kKB8&@xDJ~aB#TlcEx{Ne3Qnx8Cv+#lV4Hl%>g%sIQy=%wENZSJ4p zym*%H{KP@`)bA^Qfvx^Le<<*W0)Hs*hXQ{n@P`6_DDZ~@e<<*W0)Hs*hXQ{n@P`6_ zDDZ~@e<<*W0)Hs*hXQ{n@P`6_DDZ~@e<<*W0)Hs*hXVgEDZqM?Z}QhoeSi5a`>Yfn z&Ux1@FR`UPYxjW5f}~5H@#f;IvD&gsiWyo}0`-tLJRKR~y6yN2P=N1>X(pZ_0k z21D&btrY4W?Ag5L`{Fd$y&Jg!B3nVQUI?ZfG`x8YtY$u%a?tnTyAk^ z%UKn(@67rVM|p(Lwn>@(A8+e@b*2?8MXzl^j746*-A2F2ULH{K^VYXDsdl3VfLYf! z1BzT$tSeHk(=K}hsFJz)E$p{2k3Q+9LY?5T&96+#Te1+Xfgx-Vq!OFoR(N4|YqJKn z*r#rO1M7^!^n=8y55EprZCThqka~Ue16X)}>%tId;d==|uyAtg!j4p#QFgG9 zaq~i0zbeFyn7KzlK5uQo@%wVUuZ9s2>RZqV#fIm*C&Yg=mw-~TXO+|1tHsLx=nBQa zHmA21Y897RPJ~@Pki3)m8cW#ar)GcWXxas5xgFWh_yEQjRugA^-zokf8-JyG2*)(q z028ZtM%iWbg+ZT8UEF)Mp;qC}ysS%ohOVbmV3!?b4qP_rC7T&F`Xo+h>S$?SGrx*k z_)S`p;yYhOlJBqoM=JI?;rKdyapDR-T#B`2+uR9T#0XZGKQ^QY_ch~E{1UUgGhlS9 zD1~Nd24{WyA&wCa*rG@n33vXoy9v61Syex}ynpJlGOVAv_8%s$3fsA&zd(mS-~0~d zM?HL$^91#9!7aRE%Fuk-@_}+VH0-kB!H7@~_ic%%9?rbsHTc8ifr1ZbbUk$Vm(A~B zQ#}r|?8mDw1pA*0*t_{{Q z>F>=8?FJLk=g?U}W#?jFGh89TsqS+%TZA|@TV3=8ONo>Itsv>sn^&5gP*%LIFnV^bv6k)7=9d_M1@zgu z7ZjCSdB6EB(kTlvDBu%&g*HxbFH$4t4z&wM05$DC^D6+CPQV-rI5W z`if+p(AQs%W?QU&yhGZmo!VV7ega*bA+NvaWzAIdFCEyzi2fi#UAeyXi<`X1-w+z8 z036=3{dOt<@Y6!(1OJwi61F%Sa=Pw?(?S-FE01gDE&a@7Md;yZIwnnnu$W2HNe|t-s~N2ARuSNKtW= z^WJ9a!$h`i=i9Q{A}($VnZNHjoU`S_sDB@AF{Vf%wf|26Y0_97W__4mr$MN7c;w0! z@~7_j{|Nzg(+zA(=Z&q~hb3=!O59Zw9$+*j&#o zxGZwXg=3Wr{&5o_jm6iNuQX#jmpZOg8nFKSL>?I%d$3Xd3iu$(H>F?cxVUPbf|xcw zp&S4Xc!Lc$Ix=+cNTLr{rmK#YaK7`Y*0*7`5|(q`&=|m_EYZLD0+w2XN6Ct%# z5kBJ2%97U_;L};c{SBY78#s^R8>=FGR$VOqY(dgsJokP%*oYqa3S?j=$4T>xLOU}_)7)?!EhqA%dgk+- z*PSIiRp#db6j7_2?0FvqA=2qUy4Z?byo&U}37oLs=&7vED%|JV0n{a7=Gm)7Z>Dg}$!BV2P^;GegADl3zIf;at!%vjHvMXxT+mBD zvy&}dPl^h&ipYzP=VCYdFB1|(Y0U0V&BMl;)x@B96Mja=iGG)tWsRpKQlPBS;m%KOG>2Au_ z{5`#_-UiRHDZXYWvdUgye`&LLx~@cSgd#^CVwPd{d}OmBe0cFgJfdHncWgiDs+ZSr zXQnFF_{4_qktE2?$mp2Ke+hblpySLdfyvU*}kaM3F4EbOB=0VxBx^I-ky_* zK&lNjxCi!&j#c4u9}kXyBw1}F;CVL4S?PxEN3o?(-~v>6y7w0%pNJGW?zdg~v2UlkTCaDe^9uryaya8-jHUxIXX=|HT)9TeY8OBz3 zKc8c#`Oa23P7yL#`h>jqZlriaEF@apj^k#wD@0((QYUbpNud_cc{yiN@=!IS8D}@_ zUqPZU=y;5AfV9vz?B}y=oQKV?pE{Mepklv#w&TgvgG+pQyE0w7Px_ZhTgNW?V{a3_ z+ud|=Rm9~)bV~5qc|+p_BfEL*f(UW$5c6JB za^S?5+2066K>2-O)w~z4le?pbzv1~r$n?|-EBR^W<(x{h`ByF}rfx>hu{8?S zM<#>!w+<+;xK@tD>L^JCSs*?)pE1%MSR|1WCPJ>vhPvB+#uamnIdqM9^gH$temDR- z+Uw!FfzYsgRY+c$laoSqc=NW;2{&n696^{Z!bBZP;lc*2B-DS_d!8!^~JY$J$C6j>wfuAHsBV(rgJFQCmE zL|9UL-)beR)VQx__}ORx^3nZbQaiW5x6_=S=X;`_vuIE`!OqIcG0cN|<7Tl?yoGeg zEtFhIPDTuU7-dQUzLW2i{|*EBR^btiF$wElx5*PJ;blVK@b~b9DLceJJ@|pK){WaN zT0r_Df?{sxke05(waCU-f4yt(<-On3?U%{Jr4)jt(9UN=d!?e!HL>ORXa6 zNgpxMD~haa8}_1vkNE;sIME=FR88~dnRnh%ON62uUiT&=n_wu`4!=8y(Ej#<_#K#bfVa(Ky1Qr&698hSBMT#EYsDDvDF1}%d66m@F7bkY`|_Z z`dsX)5XV4TP<6R#(gm*@tp8POAfR+9XceR}-)i%))DoU0X8Eq1`-Ue+tG~ADpWRNn zh)1dg#Il&WDdKJ0LzHm8qZKo}#w6!9W>XbnkfW=E3z|sDm}+bYFY1lHieVM zwI(F&cu*ora_>_oi#wkR>gvCFLUPv0_0|TOnDjw$!S|nUn)9`s2sjNtE^_$();HWS z1tPJ>B!SC_svYhb^9pdFE)!HlwO=!pT#( zW>T>3si5-7e3i1;4(8Qq#fH1-DTMG1&{v2-VL8U=)6Yd9vHiN-G2>UxWv^b*N(y^& zn;hw(kczo4;zk~sS?)m3d{RHunj$=Ld?u=9V-Btz0Zb-x{$?9CzSZ}fTcDb-G9zl+ z?>FKN9=AIuwAu*ydXHmj zhfN7pYQk*1p3g$x)GquI_=Iu2zNv_(%MR(7cHQUCpHEy-()w`q?Xas^ZuBo)D>7T` zux+8sb8H54nPS}yCAOngL_XJzHDwbmNQ87xv^f*f;t>e$PG3lVgO=6X@VibWGKBb^ zEDElDxr#Yj)7aO2tdBilAN{50HRYJ(nsN=Y7r#Ughu6u?$G?J(_j*5RAkAJy^sAnG z)?l_2SSjC9Iya8dyj^^}9JL?CfqS!oyhtg?wKsX*c3;A9C+3GLuoVG6miufHx8iC& z{<7AEP@^!=C1-Kox{PO2Rf^~XoreGc!#4EWXZ-Bnl!e|isy5lQotL~nXSi&F7ccp; z$CO~asoE^VMqy|o1emO~krwyEaZOXV{?BRo{el5|qw|1+Go3449&RnGp>Hthl(?|z zSjTAS*y&vlmZr4&N@l*PKSP);N$2OuFVvm6VrzZt!Cb4Xl6>932LEEoI9o64n0&uqe)EWlt@CHi34AH<>#U1A_ z{nZx>@*qZ^gU=eyR%se~^|?<+%0KO=ck1T$>|hS=bSK-DK27dbhkl{yIS0^MkmPwi}LAn>pQ-!mvZh6Xo zHnBXzl#t-6sjH{Oa)NVZ2UBpZ)A!K2iJ;innhkykRzxwrbDEi?QFXm5@^h2VGENL| z1nlOqJ~<iJz!V*su-E5K2f)GJ#e~YDG)w^IGUSn-)!P*STaIDesee zIIq<^Vu@)bbMd&IjI5Ft>qcD~{|jQ0pGGzDk@~61xge<`y@1%jnkTu;ri54V2r0Fd z(wx}ZZs|Aj8}SWQhBWv0KI4U$#=U~2iJDayVihjLK>nI>l>GKA?BF8ZDfnvfVzJ)U zr}$m28?!W%2*_IGSnaQ2*dy4d&TFD;Zc4t+D8H)1dWbW8No7StE2*4+E;JTmZ6Wg=+db;CU|z_|6o#upTNn(e@@M zKe<(2u^iogsMUDi5Y{+?ii_AtUNt9FI~yJby_8NH4YwN8qoj9*2yxp6n?{b<)3lQ? zR%4@)pZr9}P5m7Ldj_z~wJ_gg_V-SgeR!)Qp899BkHao$G>|$j?g^{ zNic4Vq~kw*bOJYze&k~YLKz)+x(LbARmtSqDgW|P;&B7D^!hs#{Djo1vX(gQXxxp< zhp1UE4|4TXU;GIlbssd)nLCd~$SV@3&+m+9l`bQ?8>sCY6@*qJdol_9RXMNajw;%| zEul#kQUwZK?FK(N6RKq208ej9ATytkB$>HhHStdtSt)CoK^y?qdz&kzb9=If>rMod zJLes}T25OU942@Oj$7eC01zLp-j3+%Rgp&3Y|{s6*DCk&3ey{m=hAKJdR;~F88IbA zy~A0lPKTkJ*pvrDN$t#as)=*zuAf>Cq7FeW(N!YOENGywRrU7?{*&>D$TSghi3BC< zNcHKHWkU1X=W!y|VVI^!R{0eHs}*G};SA~Ai?<^{`c-|hY%&|L<&kEkBZi2wmJ}=% z3!ogzW0fC_CdNGaLc--$^C=jt!2%NzfVk4ocoJ@`SB;yh{`7<$xQ<>4R?Oe=DhB8Q z^T{s6zOe()=O$*HW^d%KS+0AnF^Xj=qz8FGN|EcTCHpQ#h3EKfAomq?l`wfBYdz62 zlQZ?iZE1u$e?V+UaQT@s;T3}OXvhD&K|uti7QklAEw8IB+gEa%aF2in>ucd~MCtHI zKFK|4MVtAJ-N<6)+!1J96Hjq9FM47B)gmS|un^S83i8(#U0Q;Y*_ytX9FPG>9BVrD z_~8;gM|q>>QeR%UnlKjB2aKpPDrTEBE-UDlD)S#Gsk-;S1jV#fXr%YbV} z?(0s53Q->KiN9?wty44lqNnuj6N-9?W4Dy}e}!8qOe(2IDt6a4Sj4Qd{zqKx=a8ck zf~te22R>amh&rC1E=u|iAiE8WsV&cne~*gw8g=7@w9~@P@`)qgPW$(qi*>Vz7Mq!N z^^hPfDU1n>#rAmV%*E1>MV58Bb|wC_|3We+U$`N47A7+zS1WH7R(G;&{ljJ3J@^N(WwRQ3d)+XiVIZHo_E}ula zsk7a6A1?qOChB@I z&O+8YMuTjaAB}ZeYNGO+w^!v~c~u2*XFpwMKlwQ>J38l;m88Q3#L?fF`X3%DB%wMs zb{bBGZ-KA)e(2MiVTKe_a<=;5$4`e^rJuSH7Xi66fQHal4?IM@PPL^w&cZYLO58jaB#vSJg31ZIoE)yq3J;h+O-Q0@O&p`R*#RGye?s3xUkJd9@Bhl9 z-NF%)&xz=&Mcl5tNV-FdJLC9(CL*1fpW2m?wQwwTmR+3bT_vJ*LS1Jz-gUZOr2xUc9g&F#s6KLLgzn0M2nH#rh z#-hR+QuN99$KcNt*muyYS|fV9rCiGais#OiFNZN_?3%(oo125A!OqB8fiG0eJU-Y* zPIsY&$!A^3#~|Wk5VA_;x}5yFvMo+w9m9?Fcj&CB)u8vN<6k?T{yO*+Z#74ebUPK? zyvoNybMo}Z99~&L`no)SG+%MK^nW{Ur%*}VMp!@~go*c5*ytPNrAyDTIx6Yg*`!9h zn-HdiXRfXgR3a(SZ-YPmrn9-5^!F7m`V;VFuXF=TzfUwRLH0m7X6oZtcWR_ARpGR% zKRw!RaiPXxPPZbmr&lF_&yYjk{}Cs&6{17sK=q^DoQJcUEGD(P->x%F*9rnhYoR2i zpOgVi>N3~%?TRm4apQ;A2k=(3JNd z5T+@mD3RmA*is~hg3!4yd|QJy+1m!sS8 zHhg;n77=ly$LvPk%XL=7bwIL~at8{$1&l z!F%3DtbiZLOF|h7+ZArs|HW*cB*DcT8SHh(vypf#(*coAYkJEVLph6f?ckZ=-%rB* z!xa8uitJocXWjL^-!XbGqT~IB*d3AkCl*h1l2rYurmqX@;M1AMb@ziL0UV ze-)SnO@0khDQ(D!P!pahCr-HZpP@)h#qRqFIk(5c6%NG(Y3E_sV|ApIshh!&)sRLI zyKr_(NN)HHU&vlw?o)~0$xrb&0jh-&w+6i89s4Z#GP@cSA-jt))#tLf`5IQ5Tt7@W*K>(06C7!$b)=mGNRu_|6|2pfM6f7Q#jPzb(jN)q~y_)ss=~DB_xfYAxo`$ zpmpd`i^B>%rEfj>Z;XEJCnhtR9g#7ByG^N-;2eSG9M{ik>ns%WrAx_o$~zRe{6`#Z;1<9D1v zW&nvZ^i)hifiAInFhzcW-ib^weN*xFVCy8Md>`;P=KOI`xQ@BCZ9uyOo*OcCh=J<) zn$8gT)znO2QTNGZ&4`aT@I}q}KVlT6B&nOikgZ%m3CJg{lf2nX2}#v|{VT@~&*<$e znD>VOq_4!Cs-DzPjJV}85FQ3up*RklQ78rsUwGU@3Xvb)t^F3s47 zdgvkxJHarPp#wP>KmTA%;;``JGWf8}=_K*bkr(&gPnoJl>)vP(|&Nv}{&r$Ii z!NO~$t2tKM?uCL>*6zgSMw!{Cf*rM$2DJ1eK81R)lDExw^VofIkS%aRADe^Ws zLSmncz>~p;-m`A)>L5@IqDY6yi{Nb7l)g01#*P^ot>QpcNkF} zppqu9R3rd_>V=eSbqB)Q(_Y|=97{R>uf z@*9>%fgR@e758hg%k?nQXt!W#p`)kOnV?)R4gn}YsW=!M(-mtgF}zb7n;e|06r@V4 z7mXi;Om!CMb?SY90W*R9S7Nt}?Osql^y?`2HAke168*)1Il9J$f1NNI&KC;%L((66 zKF^{B2IEJdEiQoEIUa-ezTK7Dnmxw}D+NZ&J1pHP?JOu!x#o~s)^<7{U>|BZHwbJ0 zjOe<7kL-M=G*H(a;SG)d1h3H;DF^YNSCJ7l#wffjz+KtgO5lX?ocV@5?%Pqqbm2Lc zhmZw*n`4cr{tLda%?jamqL<4W8q1s&>w5fysbNHeW?PvqLm}0kQ;8N~@ZSR5C_%|f z0e@lClgT6AoQ4bC3h!HksI1Rp2_{YTUrH2R%chAjp%IbFA^be9I6(rE>f~sQo401h(E(0^4h>7q%wJVdv z1}K&bid{u5xtXt?gnON$BDp=XCea%;L&tO{|K~eLzG*jSi^e1x^P*lrmQ(rJ(0JVy zr5P@p5o1vPxCv`-87wOF9_;Qk%2S=jQc$y_Q)nD*kW?d&DhtB&c(xY zv-(jCj) z2`@yIF-um|qRsoA{TQe5KGWJJ2SQ{~F1gDB&#cVMrn`d1G>*eaiw zPHJ_>QJlUA1A1(B?9eDLZ27#Wg;f&949o?hCtbtVnTX7N2S^{Dua;!Yh3GWC-l9l2 z0*f*gN@Nbrh~_)%Zxk8^vHLsAl=VXai2&)2hX{ryV{tP+=7(&$*PO>1*3-ZgMS$OV z)p(Ug0mL`stENTV0dy5dmC{><88hPf&hC-_O;y3D>Ra&988E%bwhzgw(N1IB2?`ux zpo5T-bJ_cHAwO6>^-pG(PQlZ>pz!`kDj2+pjlGJELyecDZR~tS>8+o^M{? zaov|-E+{a3wzwh10>hYtW}1N_YK}QrnzhQs)0uTU(!majXU}ysx#wfSiU9H*BQgG9mbZlE|&W0ZsU@Q zz`P=ZUC3iZpc!B`m%0GdxL1a3Wq@X(o^wKiiCw1 zG+_zwtZjDi)(aTOJOEv!dD1>Lx2CnNL2Y27Wyg}--TkfkBZ9C38DLd&9n)-p!!Zh{ zpsMTsRS%|{1K43nr-1KoY5R|GLJACdRKg8`3@sSmFK`+RGGu~+BJQre=W?G$M!t>N z{s8Io6#IZ_S7_{Qw{Pj5YxeT# z3vO<#+mK9}f;OQU<%C9a7Ef=AteNnI#N@|X{Q^M?FWU~7esc#vagGa8a`4EAD=?%{ z0eb|sZqdrW{H{O8I)J)f&PY>T{}Nuyy5F#9U!!=?BsmrVW?2F$fr2g%bJ1WQ&H=_` znZNkXQFY?JvqeJ^bx)uM8g6P-$}b;pgGg> zlvsU|(puhF2dQ$Z4}IUa7=~>y#dQ%w=4!f~T-!vw4LcK46z?T?bhQx_tmrR7eVsX{ zLZ7OfHR8K67rp3vY7Z;ApPy<5P4Ox$VEC`CJ3=8Y$tN-FBs&oD0i!uL^pqS5+D_y; z(3}nh%=`$yL+)ysFAS+V#5`sO38Hql4?@5386C1=-PTlm<(7O%-DLMaPHtWg&Un^= zBGsH)$!I?is!%W?q=*;mlU+G000ljmtVC3qVq+&TIN}>e4P0W1H;hlPMjc2_wD^+SI0rIJ{40pbE-hFhs0#WT3H|53Hrb5 z)?q<`A)F!yO?N=AFuB0hi*DEKVi~A+pHQoIeCJpi;|}Joo*A_-p(q}rLZPtf)j0g= zHjJ-xovOk@8Nazpzl7A91WYCq)m%eID(PU7jOmD#JB+RJzNj&S##s)~3mAWHM_gp~ z315l#=!>yEH(4S=RUCDN#&&?@qivXd>*l8EIC^Jo_F1-lu<>}T&Z`+#(_El|bkAnikODcVYv{*`0b8ePRJqb_$ zat79P`!N`-YH=!gr8K>Fm5%~LlNEDOyadp;a5b8Pk>LntJxH;eaq?g$htKA5O9zk1 z7kCC?N;PE<%ni(h%zFBE5ScFUG1O7FYVkoF140OEkX@H#dM&pj5){MB`VYtw(aN(n z9N+Mgl%`w_EKWXIY*`84-G2?9alh;$6)SP{7#^r;9-Rq%-S6Eq#MAwxkXCFu3yj5r zIYB8Wp{3Ioo^zB*0S+}{ zWY`IFyv5jR8wRh;j|NSMSb11nQnHw2yyGj|Gtcr-wqL0zUBl}E>U_3_g-fsVxn3E6 zd4>uM7#{UQEB{QhC>DJUb{I=`6O=Osvsk0-kXE`4%EG;Sp6>-0jldvk_UiY{mgNGg zF46!~Py}IdO^A67@PpIT-I9H=0b%e8;FWGg%EL^yKe-6m^gp16dZ* z>n4re6|P*8P0^5{klDHSX+ljfybNF?oB;#|BbzK%`p&U0*4?y7l%nBH$Hxr5y^Y!q z3sqfbkA;@ob8!lL{69<#uf`{%7-IAArStEkyTK%IF{Ih2P&xrKM%B10qm!#1hfK+t zR7f(StZw>&D0DouEF^a%bIlvxGr)hGq9QZ0J)?vqlpNl#-~F@yzA2%W) z(?GRABk~_uDrSExN^#-eLJs&dLLl^csbk_>rsE&z;-uG^o?6&)rJKt1tGRtF{0Mtn z(Z{#lWLfEuj5>Q;6BHI6Y1@x1dQt<4Qm39*eQ=d+GT!Tose1*zGn;B4kYJQLcnJ3^ z^pL5WGuYtLfeFVPN7ZomJXF{imFDTD&v`|%nI_36a(R1_5Rj@ATlBCKsi zp}_b6O!v&Qr&yifX?mNx=amLtQ=AnHHXaPAqd1=oSE)fq?z#yLzl^8*L7{Ny_|G?v z8Qh63Ves7ii2bb@&{kKV$h40Q7ZYw27Dk;S8s}Y?T%PyDCMOCR6*b%N`F_W{LHDEI zsB~z=Via0@989Kl>~A$bJadT9;7ll>M(Hphb~Fp6djP#NjY0_BKyJ7IH{!0X;3+s; z4``M_$KL@1&gw-v_2NP9jXhL$0EV>WXNBTRTpiaap}?R+jRpP&9YZd(@(;*No^bse zR!}+8{}ZP%!-IB~YRpG-;J-3}$`bKnV&z?x-a0t;SB;ScLDx9e|nCjKmN- zha~UxzrY$JO3jHGbCg6FoDJubSyz{)2k2+-sldlU6BBF`xVsMsZm2GV7Z+%+#NM?x zCS!+6?=Q$kk#9>pqoamqJ+*h3{oNnH32N*?%4;pS>mNG ztiB7QVXv)k`&EA~%5`nx8`*Z#?YbC6VYk0GCI35jZ9de2Sk(cz_x&XpB)c&y)p9Le zY9(%sa~1mQt^xBmdS?O!`FmaFl3w>7Ja(%)`VG4P4*s5++$oXaC>;T81)PSr^Sbu% zUdP`ar-+=sBQ!{m@2kv>P=oj`9VQRb+N_%c5A*l8{#IBBakUWgXM3|I!MX;X+*Xu< zl6COCI3;0Jd*zp0R5<-j8K1_Hcm_0DLu8AqA^g*ed%W_dLKFakj zmHJ2ok0ddQV)PV~r-+Y+5{XQQOpB;f-DkjsbTF7}g=ZC-c_KpoRdSZPD8Km`kU&_go;E#|~f!U}i)(hO)Ioe#oz4vFtx2q$qQN`FXWx{7}sXN+M$Yz?~rJ51NwGT1w5QUx2|#+AiY!-QY%; zz*qfqLfkZ+5{oCQIZ&j@At&^GK`mkg-nDK)v{>#G91h z0i@y%?`Yxcca~t*yHvIC2OTo~Ze+jtIV+<#VLOq9UA+jeG{*tUQ>u+L2x6QS)f8s_ zapqd5g%6Kp356CFQBOK0(lGdvHl=otJD^2^n}D3W!a|HIaN3ReMnXl36k83I0_H!Y zj=qJi$I)zm_f0nwQHp4az6bROJ{vJxAz&I({aMOwS?OT7bYJXXi{!ojU6pTE2M$sl z2JXG88Q(f%N!*g}sDNxgR&`uj{F|~yw_X2ze->{?uz~)A#i9id1Qg^g#Jiq@vvv6< zG*TlE80@^fCnv?l3et>S#jOxl6$)9t|K;Q6q5L4*74-to0a_wW$sn#PdMFU-AZxwD z^#)Zy^s_YDvmg&G?qgVsh*x4=yJ~6zNAC=zs7(PZp4BE89+4l#18q|Diu_qzN3b;C zPCC*ZQau`FLX{x`GyM;NBSKhZsSLUXMZGzNC8NEV2S3_Xu|Qm9A)4E%M!EyuMXi}^ zlgp5pWY8qdWHgvng1W(u!Q^4%!=`TDmJY545vdFLk2^ig0CAX-eenI~(~3Mc+<;MuGhgbqReahzfpb?}NOI+|ipcCF z##0lVb!)<=4vBX-2_#E5cuo-h|4S>&1Qf_?|(Y{ENoiOLDkyp3~dtz zC5XM;&RpU`)6k80%D@mv3A0+RR8m5WsK?1F0$d1ITfxWSUMHb#Kr8MN+P$MBn-SCn z*4*0o3s3DTauG1{Cd`(XBWcYuks^790;_yp{vAv2|2WE)&BzTM3Yyy zd-{)B=CdeQFxPWF;NOvU6ibpnEY}gWII+J~`mpe;{E%=j0SJ&wh0;bhO9HUsf`&9v zi>qW+jFsd))TZhoAk6KW@o-GWEILkpz243 z@49!*NhhA99&VwwS3Zmp{vtmTJ4NgSe$mm0u?B_y-Bpt9>bMk66HgxVq<<*(eSxJo zF#19SWK3vWmg1CLLQ!n|Wi4SlDc zae!fM&h_Qd2OegZTJPhmK;N<2AJG91qa^XMH+;3x-aOgJ&heMP*=B2W=471YNzi)u_&MVjRkG!@`OB6kXudxSx^4wYihDrXr0NGS(9_~+ zTXv&{77PBokvJo#;-}_2qvU*6-0T9)3B`FrJC(_@?EQMYqNk2BMLSAoVrGp9h`m`f zgB6GFqtM8Dc#0|h@IdU3>olVx;|I#36-%14i^B@X%MBVBM#0)qO~KLxyBQR?oWen{ zrvhxJ4d^jz#6kV59-6BU4`8l#=`BufH4cM&<%4dMI&gg2E#^w9eEr+TqV}VbXvF*; z^}q5}4^7C_%R{4AHj4%4#*Iu3EWPxQoZ zy2l*eN4?4vvj_rIfyA>lx?2C-_38E@m zxerLLG-N7s87CY)XQf2J&6{hkqErBM!}0FnK&^+wBTu}bxzu*3_blQK`Y>@h@x{4S zI5W_r(zOC^kh7m>4BSk$F$0p*ZlskoAkZf?7KNe-9YzxfUF|S6j;y#3jarN$97Md7Zf|o**#%vj8l^+N zxF61{Rb!)Z07>}|%pfKy%;J@CufN+@OCI z^;RsBRjb6iLHq&Hlcx3K@9@7-Aheo|S*zuiLjCLp5X=2LlsFZ7(DYd8_b_m`4~UH# z=t?XoE;PG>;a;2dvrE9)Y{1PGP?^yL-G%)AORI9&Sf?A)Xa1>qwh~BA#nC-b`C>YO zZ{)`M(XY~>rjW7LTD3#Q77`qxo=%ZpAGE9yyw=|ke4tgR0*Ef+w;#0%5}0TUV2Wzj zluky#sqnp7z z;3YxS`$#fl(CJy+@Bkj0CZO(#3@pC+z2d;DMkRoo&AfdEV-dBUWMvxSy1RhLNd@PQ zJfNBYlR`*U@jGT0nV*LD8+XU`X@lXqKHxvTOK1vuTHyP|K-kkZ5VYpZkRgzF?<3I& zhj|DQ&hb>DmE!U29);&vG!R&m9YMeG^GW^224D8BAFL;C#z}C?&+(zv3{1G7qd8J0 zaQGza&xlJO&23=?Cg4V)PElh6$SG#NtVMbi3aBz>F-Y2lx*=&_)}k_v(ku3mEjZ_? zY$@c7I$AZm4dNNdKnXQy_bHDcFKL0bp$@A%`cGOwszuY)dg!|8;uCJTw_5+pJ{92F zLP2GpU+sNOfIy$^SoC?8DOzSy(T#W0tDE+q-9QUx0BZiT5lT&Sb`_^R>D&Vv(jY|% z@7FAvX%NQeuj^O4^H(+8JnMaWS}ao03o%Q)7qpsK_4<+ET&0;MwsPmzG2m#CqPu3R zym|WE!RZn!KWV1sydZ?QF$HnMwYrlzxc7l{a)Fv*$>E>T*bbXf=q4PbucGvVrD(_1 zXovK=HF*Qd$0v)o*Q+CzcH!5t5D^A^({(wO$g>-jZlL%a+Xz}ppn)~2|_JG28o{sv}Z^3qRuKF|_olQK(ia({t8^DLl$&I|}#oSYVmr}3TMYI_VRDkw~i3pIfI|J2YF> z4lWSmt^80a24EVYb_^Q%1SXDNvl@35zXF{Fl&swuYi)9h%WK`8RPUb2>Oaxc|YFOybO>%2?f+<7%P^ zvQyg|TywSZHybejMcL>9R1tEq4_J||c3&U5D6TU&Q zT5Z^3l`fG!eOV=Mr$t7*5hRsY&Miz_N$M{iiwW;{A&m<6((*PKuO4KGAFm;_4MNCF zE80lESE5f{R?^D@&~-cTmHFjL6k9~-e|Lt$cfDd7V)@AZFNErO&vCHYt)$6;Y* zkQwneLiD&)XkqN!E$IOYh%ac7OVj067o#LI&gzBR;;*AmuSZ>dU}7DqmE$xz9uu$P zt<`(6Pgb_CHwRR~#(*1ay7?@i*DBNX5<74 zG(t!GW7NAj`?O_Qq=mr-#0u#0v*H5L)xsg16-6V6TVksmANbK9jdpwU{N)~H>69f~ z>NdBaX{FS_N}awrGbp#pxlt7TZU7o0kvurz=tSz5DhHPLb=X=N!0kTS%NnTcxoj>g z(%q!8<7w}x;Ym1jUpE!pkuBHjKG|pFzxoU)UDFxpcM(S2e|sDP+CWP^VQz^0J?KSN zo1Ixgs0G&z>%usNha(WLcmeb}Xf=^IV2*OG2Jxb^B%q#)xM00i~K9mUQ zL@(2YXn}wtZA6BQ{0*v{*joiSi|W0IlLRR7pmG&KB3GrT?uhzT2@0uz3%gIE6T8Joc(!l<5y^?`O zVV!j|$e3sFRNbR7Z<`))-44q?bVx!}bo4=}k1PY%8)`pY8*c|4jGe%h$u!!q+OX;s z4K#`ih`G(x%~84+U?}ZaC*KvWGKZdjQ%!Hwl05nqsM8-v0%T95dcY5wsCEAhI1P#> zz#hpdP0xDUO-Ppe+zO>}r(G$@PyZL2L>{*pJDN~j%DFc8*8p{F$ROKWWrEA(2ZErf zl$1Q%2l-W~(Ol3{>fP`95p=+5)ecx{Jia&&sBuJndq+#4p0o}-)j3p%f>|^fV;>PW z6-$DH0T^X;SzZ0nw=3$br5?u$vFxXe+}{>iJF5J&bgF;rHp$5)Wgk22C^=hYC3!&1 zO0DP6--+2p2N-UZsD=Aa48ES<@qzWHzMR}u6R~UySccY5rqzl2^U|k1Q?UMC$=nwR zuTtxiXBG$~&mBbr4`U0Gp9}UsR08}wW1fAal$$)T8pRcB7!&@L8f?L8dJ#gwcFNNS z@0Qr|Bv0Abk_69u4r9@<8xBHeIaOJO&eaw4-`FCBXs-_TgAr`OxTA zEskDe2<*<4x`^VsxZhlV$)(Nk$J zOujen$npas@9Rs|XpIkR*-(X3^0iZ-PtN|O;7$EO_=Q^MhEyNw-2SfwNag`Pp7M(y z9TboDdKHE#Y=g<`l3qUGc4PVyk-STZdo9euB=wnd4n~4)9u9=wowCdE7OHW(M-es? zfnz}K6&j+-iJ8qc4K`+WgLCHGdtodOR6W~y`Yp4MtauWYUJE@oCANK7tBzOvs~xYW z*xlxn(k>wX`=Ulf4BRQ8VU@AV&k@mOSs%3VY^uUWSO1+Y|_+xd1V;&{)enk*Uc}rGEEJE?9kjbr)ydeh}Mp?c$m$ ze!h5*&Lm=QLx9vNa$I5V;e?s%K%DPb6FwQT9g^a?+fbO+{8)X>nIg4+z_0A;SmnR` z`8Rx2Y($PN;2c#CkwBVxb1w^$)5h=M;Ogyw7+^9`LyD~gvr_U}9FRT$y;l2xr5WJN z`Pw3J_Xk?ir+IqFV_Yqc0~CyrXDE2J#T6_WHb1LR8~qf`76fF^aR)QMr~YF7Yf;tm z<@8+5gH-yM+z!pXsTk6C?SiFF#?3T6|2(~`Yj`2=C_xlz#`zRWog*V5nQ6`kJcsG* z^&LFXoPDMJ))6VukTox&TL6hM`8y=W=i0g0ewE#Cp{B_-!4P5zmjyla7U>+0b6pcq zG^2y1Klt_O=gmRFmCs;X>8O6UHpz7oUCBO()qf;y?6?lebqEqHjx>0UIBRbps?keo z2BK5pw4?ebW3$lJ_QMG^>$Fk49dSID_saU(YtY)-F8VO(i%a(k(NSH;JPm*n?LQX9 z<|RjDc_RcU+qbUN>L1$wv1&gkA4K&UBG)L)1?rMhm zlv~V~h^)_b>q3SWMzjY`uHGC;u31e`q;N=t3;}So*R5xCf$eamx1W?Yg1!3ZE(ra9 zW6I{47dm^7t=w6>^+u@LZe8qkj~2F{$yldff-*#dkHCmoFTSy-KYF-QTwM=_{QTA8 zdD7KJCQ%_y{$=%bH0+b-t7?k${MO{s&e5{zuZr6}y~jMYmWM~+4hSn^9I&%-nb@%b zPYt5=8}WRjBVHcdyQm_C$?~_!W}?yHUJLMam5!4(mB)8hkF&iE2-VLfXVfjhEw_+H zMuAjm3t)33P?WRzdX`1(mZq0*8Vnx-SAeNOG8rzrWSSVx-F!|XMAe8}=1pu+;@`+^ z(LDTpR%$O-E-ala47t@06ds1NPdp;U>(&1y>|^`5apPgzhYJ)fyLV$g^A>T%UAS#v z#_PO@SAP1xAgqz$u}`xw%DcCwB=XnOnUA$eSSr9onsDtuIT8Zi7~BwC$_2u9(ht~# zS3e8S*p~58v`zo@upz17-@2>d*Os8G**J}5ajo=RY~4YtREn|w{`|}Q!OFUAu*hSF zD;sx7$sqVF8jQ)Efa)jxva8VoFJr{B5|6AgQ+y2Pe)0ppa6*;p*FyuKPa>?%2ObNnI*iPjK4!S@FK@S)N^djku@eYH8ns<nReBoCsbg6H9`^EM!;jC+AitCME& z^ys|?wg^kgg2B2%8jlZXdarzmzAU)3OmPceI6K_u@)98G*+r#&EiYCMnh0+hq{ay; z=Q`@>1@^#1zC;ib8ed9DAb2c*BM=SQ0sAsdY)|QTR@<{G=C-dk(zGCkA}$du=zt~X z;|1@o023y1;jQ;hvE8#Xb`&ATyoI#x<_?2O={?e72o6lRre}P&Yd0uYL#1*=|U&ixs;e@s|TC>S?@uXE^Ty=QZ!(uYbXE zCU(XUB7a|NJX7e9SU^b7fV{)Wga-+s7utSLr{Ha@&C3L_msf!3c=4-KUfQipRsO|AS>v66&VrVWP31UOVBe}h|LepOO zEzE;IZXQ}LZhWIenu)@{raE;q9lme7R~c3`K0gwG{kqV3tnYx=z20lS%?!Y$!svmC z9%a}Yt9W7TM|(;UCBnd5{k-?qzQWsUI|f?W5WZerQ#RebAjW7)%r>a3xyE)R|IKB= zl1ZX!@A5CmR(%R?Je4os$N_B4HB}`j+efmu5=-4LrQgQ|7RM`g1F1ls8SK1t?*lC& zzs#wU<)t9#XZaa<9)qMl0|&=-#@F&;OWL zDJB1t@wLEJF@)=Q!9yKz*OKK@bNO!;KbpGLrRk?KuCS4kUdkE6zU|Gs*2o~mi{Pjp zkNCg~tBB`}y}@s^@G?-;!naxY|Do%>qngUTuwiB#b!HT?4N6f)9dS?;fzV5I6c8aG zP3fT20HFn>ml;PaG!>)-2ucaPND(6?C?k=U7y?8>6M+N>RRRG*;5|3N-}|laTi^X- zTx*uzd&)k0KhLxGKH(d5^E3B#Qbb?SG)>93=o#isr#eVHG*=aGYMogRDjs_>J+jn| z1d{?!ib?qqG115$E>6w#O&a7TKDKGk)|%?CXmA5j$M;W?SNx{lLgq6i5OzxVR^+G; zi(q17!1h)qPL32Ln0*gjr*Qx=;d#wVo8wvljL3m^4Sf8vkuL|in|&5*d)f5Kkkf7? z`!_5TegLcdDTl*c4$cN3%D0kln$Y0B`(j6Z;K(m|-zytU)R8&_?N9jKvwMVmC)=4x zKE?2gang&Ujeqc6%swkBXLh&%3`tU!hGFFH-*)GYzd)}ry0@n#iSpn-lruO}t_y>- zp)=r^sRqCo4+Rl;z653v-t@n{`W&*CZq@q0HEs)dkBlzah;J`f4mWC45bHthQS`kDYjSwc z*AW@M9Xmd92cx=>VUt^6&sp>}0P$@r3!F09=Yj~e@l3-lTPu{ncgLzfAx3J$Qu#|{ z%SunYvk`Whzi+aSY~^4ZQd>VH)V;gkk$pHqb?_$E+BVb?Y+GdjSpR|?x3o+d&rE&6 zXg;y+!ovl3DpN_g#dD zGzbGNyz|wN(IsG^Db{w~S?w5IGHeCwlxV;o*-npj>`)kgnJk3$bD|2=uNSE}^rISb z)&vl`bU!#)X+#DpDDnpTm9LE&Sn5M&AS~wJ#U0f;8Hxo?GC+XrL<^_FR$zefL~F%m zB>%LfU3;ApzLP(wgkSh?ag`6fy!y%)0u=?2*EshwJaX;cBwiY_%Z>X2yZoJ^0p@u{ zFwekFl+My`{C9jg3%#TF#ZfKw^=ogQAI_!mUy&OgvxZsdm-dVyr0}Ejo=@y!T!qFf zBfQ{Bdcn|Y;eL=1-9;f7pFf)ve`_P`ApdQ?FBbRQ|B~NQQ^e3W`_XrACU?qUzRGIv zmh9%WH}?;>EJLuv#TM^BabCGDChCCph;t`?sm0jnG%%y+6r~6vHb(04wA1_PQ!Va< zm5w>RM@SF@qi;@Fx7d<Y|%!!3g*S(Y%)FBrD7)Xzcd_JiI3hXN}ZSr{_BJuxTd5^n8-qg1U$&{SKyj2mH}JQpy<6jKVbmDzvmX(aII6p zq_&JZ0iJyHR|8T+%dmz(l(K6P5d%i{yGoZpKaYN%1AGzn=;0Qd_agt;T(e?@K%Vlt zM$qV-NFp}K9~XXi{ej{KiQyA`>SevZ@mz)5fq4>9N`6=EfYAVv(PAi=Dp))uW0=XQ;5yyC$_R=VniUPc5~QOKK%AVXOhN9GJoUKl~9knSL&B+=U3ej5G=fVI>Uoh zm=O5$_m?$ye*#VfryNV!^G{;*002!X05pBMrizYar;nnI0lygaG=*r~@71{ZKI+2F z&h^Ns!I?jlH1X1xosy-CUL)#Zbspt~t-XWc(zj0eJ@hIIZjN z*er2r=8v*MDP+FA{tR|V4hJk=uiepebzpJHxM}ZWgG51Wi_77RFW0OMhtq?&V~lgc z5=k@4ut$u3#dHi-^79|K%UO$?DqhM?D1K0f=5jHq=!n(qf!H*mgmN@IV#-cAb~IZ@ zN~rE>!EdQ>b;!Y*D#vy>h-z&CvL0^2-!NxWC80;dNi=F7Xb2r<8B^2pz3J-6YW!8S zv9xeK35KxBu^fY5&wmVEFgtH@KYTqMo$FbBl;^m*=%caL#9uCe6yd_yzpEIXYX(vR zNGt}6sKCa!DOpgH4d-Wb@ws`UiW-xjbH>u^#dx1<@#>=;$GH_9lTqse#m&@>Dj8;<^I84W`fhjb?kNuB_n zH7F-B5`@OrqixV+K8Cgfz^4%^+MWfiWDMW$r(L1Co<=n&CQe^ftD&QnkN?8Vye7UP z)n&ZTgQ4K#s+xl2l)N?SyUAZE@So<##iCnR_uRw9>s%Gm1A9JvFk)uFC_%9y0Drj>ZL;eYA^?G${=@&sNK zW~KU*Zw zx&QoS61WSw$Jz_1Gc8B*!9gLmgXq;V#>_2U8{#ASw98b^6f#&8;rV(kp=ox^TYW3C zAJzE`H%JTix~Qks`=+MN0#TQBPB_s?v_9VBF4a|_m4J__cKgeQ7n?$K%<+Df%<#fK z5dT$rv}aJ~nXh$4Adr}l1sq=cq?n8wX;^!`Q}9Vsws!+|aK1fP+@yQq=GEotN0@Y& zk})-D@wFmM3~7q~mR2lINmEuJ;|Ix+tjnc~-)9yr0}@Ej!q5F70lIb4y{{HFH9$kM~(8Q(>b0LZwZY<>?99 zlc>`l>{w8cc=g&U4trQzZo*K0{T+W3-2V)fvo|Jj#t|b!x5n;PJKTFBeYY0$xhHFSYdgi6ZlX;Mj&4pSKSqOs31YwYxP&X_z&P z4)M9y7oqr!5a)2?<6Ur6ajW<(v$=u5^i{4qR~lf6dd(zv5>LUW(GF2D6`3CBkb|Et zD%Tgs>X61MhU~Zck*eE6^6fF-b0=r#4r=Rr@#x;b z6}5^64j&^YhM?VmgfKT{6GR9CAoEgDxY7F5I@)Y*@9PW8d}{DT2r-gdFpBofRnwJ7 z53*~nVIA@>zmmq4OdTjF!u92yFe2?bzdMV$9+FjXn|!%#D0tUCv$blp{9E!Fs@+Ji zGlWq2kE9ayLNxuH+7aC!DlH+YeDc)rP1Cq0Y2XHa=VSHJYBSQ|+zvis^NpU&3f58^ zdCCm+8FBTOoQK6<@~^m@wk*kXzu293w-0WnhBKP;rzRT}$b`qhhFj!LNENLs-IsfE{1@2utkR~?Oc*XOPg9@CzFhaDn- ziVtvzbN)@I5*ZFDaTyWG%s*zQZFAOg$GvV=d8X7Jz^~-$&t?)OyWr^_hUK*<QB?o(VGc3MX36_c-}XFG~$+`E}^IS6afPEUt8d(_?wBR8FN=hB4&=$GwYGCdPmeY#Jk2m# zfvfyq)6PL28J>CWfrXtGI(Mfs5HA&8GjJX7k}v=}g;px9;$kS(=A^;U+pCNGrQH8; z_yH|xi5E-_TB%lUO_Ls5D~XXu!LA|S%%jS*9(v5C)>w%m%KgfBN#p094uzdSNO*+K z+avaHEQaX!j0q7z5n}^=p**-S=l1Ijr;t*(|Jb85;djoq$fH}j>KcrF9c+`nxiK81 z7Ah;E>TBAbR&oiu>$kXQL^0gJ>OZpb_PWa6UyS|cINB@#=9NE^4!7(Eu*`@}@t#W# z#T5)g~2qU$;<`e#??@I5H$s>yi*FGZZ=1x!?pjE-=qXL>x7@m!6!;6OT`g|WQA@Q9b7M0!ekVf=siOM6PkV_Swwty|XRmKXwwxlY9qX1p zSJXB?g*4?ET{GLpvCWjHtes&QvH3zDneETd`Mkm_H5`sC%uoaTp6ph4Aqdb+A#v4P zfL61;o-2J=APWxDU+fDF*v$FXjHU`>Ft7l_<(gW?3*@29v&f`U(f)tWU^p+v)#nlO z6O!@nXE|DH|4Pt8B?!m+EFIs1<3qhro19dqKlfidcg8cv6Q2GJqp2e7 zpdTL#-O|D>l3Bd7) zw5&WEA1^;{MaW(*G08EUyp;1?xS@C70b*BRWz&Zat0F0E;-rnW4> zpZa$m!Hu4COBp$aNV@*rD}goD2_xOt5UO$Vja)rIA>z+ZsPG3{6EZNV_z$zBRP68- zvM2ca7hih(d$4g&w&5J-D`J>dv0lI0qTMP@tGzCc9m<(2!4dHp%A^o;=G)GG$?*v9 z$U*LN?k2`}F2WR>oo#1Bm^h$P4xej67JCkI&7` zq>hGgAgf7>FB;JqCRio-k=C}~4AYhePRLP%&|m#o}iZt$ypm4iUSYl5x+2|yU&{t&S3biZM{ zK0Rr5&JEa8IG+prrgepQ-T+cl<9%6jW*LL6bMmehnafq8VAMU~N_ zZo}Nt?6-ov)hSywHB+7Kyhf!=__ejAjr)Me9ScC@E(L#5=H{>4CF5@Y6=D>1Lfs(= zUqp)v5N|Tgaq3s(!WP6-cK$lo$fj*ujBgF_3Qh`?uOdPzE$LrXhHryZu|C_xd*yJ8 ziuQLcvN_HuyXjjB*aO@``Z*`k(A}vBJ{VGcy|2XN@vif;$472wkLdLJn7XMTlK@sO z@6e7vzCxW{&OS^!lBZcfj%aRa+MMmiq0ccJ8tqRR`i!}COQSU=-Q2!4iK%={`+EFg zcOUt3txwo#VJmA+MDUhY4%&julM0=-qe`C}d%^Wo>5X`~%TAmRr|@Woyl8 z(*KLhch3*p5f=I;t{Jp?VD!P8+3{59a4G~_FTUmOB->gkeiq+co|>Jl;|on+J|ovj zn!@DiKULh3{`Lc!ug6RY`dMdD$30yJ(@qtJ+2OqY^P4x0n9(R(UibN#`Bs~JJ;UYJ zTuac-<#Geg_!wLprBHpZd}^-ND|9x)**16|73R`^u6MB}BHJi;uwct>gvdfqcXTg2 zr?8v!A>kEzqV@5jUb57azX)wofnu>ldGH41?0cL26gq+Fdr2bMhqS_65c2C7K1ff< zYVms3U3gbbTG&Ab(RZ>2Px?}sF(O+WnWS#fz0e244}(8i0X`_uTj~Ku;qXiusl)m2 zywri-vI0#JhbPME{g;_l>s%bOUZ24`Mr9xO!c2ee?g`6Ohv#UQ$$nAwUqfUSb5)}pF?j_xfBQYY6umpeC z8*!0vh!{sZoeABtGq#y;ksws~y!_$v!27Yn)50hn^9n4i<~NhZ(C$z8t4n>=50@no za}!93zXv}LtoFEL|5Pc98rYrJWnJCJ+OjeaQvklg$DqYcv?~lzjQfLR zP+;-oyCOT2wIb(d11sVa6{(F)uZOp~3tOGrL*dym6>-w=0*h|n?cu*DpX%V;d*=Cc zhfqkSb|bG3RFnj*%>HJ;|bmHX&HCdITQ)5lk@xNuFJ7WM zpN6SbpvvV{FDz&D6ag;oLD=qNni_-Hy^t;$ zfXK*Xo*nbMd@pHzgKD2-w|my@^-sk82^rVKl~ccM@VsCg=eK8k)*i#cU1}E50#z9B z^Xuh0hwMM?kWIZTA9{y9ijq(RA{rdR64n>rTnr6hOQtA&nB2+75|R?}+BfBhd81_y zJz{*V_x@w2E!R66GT)mm1%s{;tnkK7YN+tCa38hsYvk%Z&3SI*ZJ zj4Bq9OfI^O!FUlCz3I!t?*1b#UH{ED-6I)O$41CIj^ACk%fYkoLLbKft9-JMJ0J>#6H@KR{X)^MkaBjF?4+jn=HiB&V6b~A_4A>w zs5_r}%)9p8%+oh`Ghdtcvh3k>?{{CJp6WgfOys5#>g=-^l`62Yz0k1fiPtk~$_EP^ zVpnb}Opuon!N4CeyfSxgL|O);QxuX|PxpC!dN#^gj%U?{`#}rKk|3lBj6sd`{bxxe zkwAcz)B;l2PRj1Ovb^A~-H_51(HfXZdo?^ILhi z$^5&Fa8(ELn|JRdyMAsZI^c>UYBwd9W<9!tUv^0gAxIM@g;Af0YBI%kWEi)qZy@+4 zOiyblgDY}9L-uM2X+B|PjGnnZ2MhP;ReQ`F)QElCN}U^6-Sk@J=1mPE=GI^nopZdR z#E1faY}R_i@?b@s-+WD5`@@7>EbQ?1nfu%kOtDz#j-Z?GIRR;dIUomlINUu$3j{eX zlycbv$!nHuuN1}qrS^QsE#iM@{Y85t8~5(gzU6gl9-ZcQH}OpST-x-Tf7Dvrd>cj^ z{(p?cf^*Zn9_wLCcXsd;(dMlH*AbP#NHYZV6OK%}AKctxL@=n4T26*LIAklQTf;^o ziy~8~xH6B>_zKsUkyB>0pS#(|2aMH-N3NnBGBzvnM$hH8t#$crZqMk;N9v26d?juE zv33=Nm;%rc@%RUlpPX{ts!L^JDg>JI&i3WG$nwtq7x^ZKqD5(K=J{{TE=h?VP&_NsHxDRdS zl9a7}?cMS=r%idZhatn^7F%J@Fnu7Pkb@Prm(no`I)lPMF8+({huYk`#Y28z&7}g% zu5_G0k1$)w$TeUdTKsS#BGdcoFT zxoCm@cdx#STc2~(fbFfygUgDr%a-O@(m8aXa_)HU1jjd_k^!$gMU~zI#W|B2xg;45 z>j-tB2!_YiTlLhIU0u$td1Xw^O!8BJJ(Tej9UVM0wX(jYDD3d7S$08$DKAp8AQP<; z>#^&*HhJ#KJ<;f1l|N&m4o8@0fC{xyDcm!VGR^@7FpHEWYoLc3xbgYjm;H9U8`{Cf z+2=zr5|SQcPdvK*GDj=uz1+Ml#VnVm)wctm@H%q3K3A<=zhY}XOApm(L3ci(?a1?+ zmz*;=KT#VJPIo?w%IpX^-=eLArv)Vebg@G!a2*BY69VA-jELils}kMP1}Er5Wiz`Z zo#6-D`(7CUGP0H^AP@VLL>UfPlSHJT)T!@7EPLkGFuGD{owksE-mHCXi(e?B%uisY zTw1=pp=t)Z@K9m%V-x1o6{i$ClPoAe0LfVYip_!Ek~6|t1~4B$OiFUzf>qW9NGMeo zZHQ~|i8?3jkOnwYn!Cd^e)Q^AZnw1ABL*}{i;HMO`D@D6Zj6=d5(ttylYvGDK zUdhD*+cm}kKQ-=Fa9w=(EsjfNkg4T3R(khBzm^B93=*ghcWI?hA4k~8-iH=bpMD>2 zLA=2u(GljJ3S)i~jVevXO5cA2;>$%)Gf;^E$yUxkBJMVApsb?r$mbt~E;a(=P0eA} zWvZF&l@es1|IJbQ32@H;H(I!OUipH1$=^i!BOC2gm;!7@pA_c@;8-VD*4=V>h0NAN zJY$0l!_$}&AOl_L@9kU8(n*SFdnE3!= zF_NyF-kvj;g~Qm7>CBi>YYmaBn!`DnOexkz%yb)@z(y@XKt?H3^ zj8!==coP{F)%8?o@cSZH{L&kK9AGx>_7>~nYCU)xdyUxd)QsKWH@K1h;s?qmGy*%*(eghLsL4S!&xyR|0e zeawz`uyS~BE`V%BvU0|C)mvI(6{D9iFp8!5Qf#r2d6a_MiVKR@bBZ4dXn?XkWo^XM zk}ZU97O%O!IYJ!RU3=u&&dr%^bMTED4;3rFyr=mNNH!VYmjVqj@9vLA=|hWc>h@Tr zfeUgABe_hxEl9T#v?C_kZ6KcvvZkl32^Z6qX$mlpf1bPq94<`yO42{l@fvr5C$$cV zgCFQpN%!wWQy2OIX)4&Gvsa$D0bo#77zPpzo)3z7Mj16{izDn51_wagHNKBF&I_yB zDuWQ@H^qUKS}*^^I&4ZtOQj`KVC%Vn?^{&jml)+trF^1--Rdb`l%Q4V+hRo znN%yzZTj*bOOl@WS#+*i1&9}4VGR~4gAI%CZu5Tc{an*;E*dxw>vfJQ2JFOoyeYYM z^8zMJL5008a;@c-Zh$0onA12x0PL~}KX8W%VmJB)H7(#mJ}6ckgHp=x6J3_v0w&s@ z*Ik1a*WZoouvz#e{en-s8-;Jk(WW(bbFi?03zP<`D?R21M27Q>>u}6Z6D#n8T^i}Y z|8tSSCGG0u*lC(sLfBopvj-&{d>T4qZYGE`n87f$m5J8n&CqSukPcXbP8OlDlb)GR z_imW)Rex;_l7arVCUk4^-7kJA+^m?GR-(l*GzvSk^ojBPcT64kjivSLp8pfpd`p$A zJfDuWtmC>waj3W(`E*wm%7}`7EsEZY51^?uDPRa#2_rWAEb5sZUPQ!}g=&x~mdMLi z&ah5m9E|m9|MWk@fY2F;zn#3}+BS!U0A$&f{kN9}C?$g2#?4MpD)1Y8Y@&num^LKGPui=l zNW&8dxKVwa$VBe47%+l(41U~@6nIi4t6_qgD-N-_Y9MvbZ55i$Tidt5AA0Oso2}_L z{AaD4{G-JAI0=ob+>1{$X`yh$+^9zsbs_yZhw{jM&YJO-^x!3>w$pcW8q?D#jgN#I zRWr_yk$Wz4kUUA_h)9oq7G_q(XE`VgwLR`;NnEH^qp@i1(H&pjfIbZNNUn&}z{mcw zj*)B#4TA=ng_KD(IiP0dpW!16KvdiGcu7)UnATgAKTxFyWSG2?(5ydIR+?rBd5Eev zqYRtj0!^$&jeVfzp^b~eLPM3F-uu=MCe9@ zv&K3ouKPR)TpTSMvrS31x=`Y3iI zt8G9Lbm5K6WNPzLfV?_5KUxl+*&~kg9e1ZAv40|IO*31bjE;;EK0gt1f46#tSonz)m+XBWp?*qY7@`VT$tidpLZooiR^9u5xm-QB#ok0BNB za+KCabUA|5e6xHC>q8}IP6$dKEU0p{;4S*be?r ztVoIXO8nv_91Wct5FPQqO}kpP0HfK;AJABCnb?dB;f(TjmuaoD2MVTIhnWWw38SsU ztpzDg+=(<=Umg6zb$40^WyxAT-1wZ?qp#?J7egE~scVY#}LwNiy?<5s=Q)&1I#HZp?a^`hYY-pAL`( zIg;k^J(LE7;_zm4_TmtFz}YJTl&P%u$9w2N^SH`EYa#y>m>ZD-i#@htkg-QWC?Nxo z;8W{AD~CO%WNR&3RRkA6MFgkv!g1jdt9#Sr`58vRRICE3@4M9WvEW+jbN6wB4{9_( zs{YeSK+L3t&(Iram>vRVZ8x0SEk-8eR&B*XD+@DYnN_H9tY69-m91XRfJK13;O!2*}$COQCr`t=*X zl|d5f`teD`{;=e_y4`4$zN)$kFD>I_OCIC?_$_hh5Kr!U{nHOs_Ee8DV?fQ3o&-#0 zQd?dZqZ|0FCHJ{DAa^ak1YnCVcLjJODh6`qneI~4>2BG$Mu!T01H_g0)`!{}Q|2#5 zx}GvAYqMbX0zH%Dh@praW;1A- zl-bhc2d&?e+!4AUeeyKO$!84RKGs+gxiOo+^_7EP8yJ>*PL?jWFfc$!u22gv8|-2I zjYF}Yj5)3PCp^#vnv7=OEg%^=QM!J@*w@3tZ|6k3xq#k?Q(Chsa%(`?bXkd)ayod` z8nbHGGF)m)IZ}Fh0dOG3ZeP)73rejjH9;Jbp**NSjrR@2NI{{=_bPN$6+%TjGv)YY z*g|R4oQ(6O|LoSDW)Iw@sVL{7pPp}w&#s>uKc9`g?%Eq*XC&Tq10jCF+2`A*%6IRb z{Rfu-ex>H(E85yNg8*W2q7W2w)>u~T`NCGu5r%upK~kMD-CnP5a@YCljX@lu`tfuR zb%m#*&2edE{=s7o<(()kl;*7EL=gm`sq)`exu5T)>Qh0hS@c?_7JU877bn_X(>QDj zH0FN+x!8C#y5!imH%idK<7lM4Z+L+27Rd@rC2k6XA{B>NQb!GtTcY}BQ5(UW@k7?U zD6dzYv>I#Hp;==sQ#y7FSiv5#s7~IGPWIgQoHTi&O51 z$WTThl9nUYXy0^}=2MmX4jgCjZe`6+r(rfol8NUyFXMSH>=1MEwC&U;keO2cbCqw{ zAGLtd@)-=@9Gd5k`#+GTP2ENQ*Gln??^+TcmkTlWmk~iY(&_^$x5&)OdZG-OC=x*9 z9_fhM>>XG8l{&Ecz<)+6dBFRwvDXF%X2E(NQN#pIRr)PQiuJ4i_EVT~ND>*z9bY}# zl*vqDE$s@i;5%??CJT=lmP4+-hc(u9FBBmn<}Z9<;@d(AwzSBU>>)4>-N5O#Medzj zfe8{@34>?UIFIh?&_HrWApS;=mA35}kVcMuINR(euNqZ!Bw~`TOR_&25eT_E`AWRf z#@9*wMNs(FnawYB6iPEmO8gQr*;ofvgIH^XbUX+QlgRd^w%KSmhJEdE3j$oZaD9GA zZ_qy*=JgU&-Uen-KDF6ZFYS(%-mNybRy*8^2ZuA6yV?lOrX_G7pxSQVSde)V*ApA2 zit(wConAfC`y#(hOZ2qftUd^}w6*NzHynec2nh#TGFwmfT9PN0wJ9)r#}7(&UVb=={$%juCX+#`LNS-R+#YBf&wPs%QnV|TWxOyEs;WT6P9~TbbzV%dx6!x z!v5P+Le0!J)R(`^V2mTbL1T1eJ-c57d2sZ@zB?eORGq|xPey%~3OA<4y_jlJM)~`G zN|R(VaEByB5`YF~#Z&@ISV%$y$R@SnQ%dw3&qBsv$5CcA_ORceu`FqfB&U?vP%`yO zPc1HN{$gSf<|;Sj%Y4VT(1MpWe`rD(9YM&D41$zi>=`ZYbIzcwZq#oPUoh|%DZZz3 z^=?E3J8i_J$$^{zkuOLInR$G>z1kp^T^)=l_Y@Cx$Jwcvmx+i=-Kw=azgt->?PJc| z8W5mdA)eN_nFL7stHPzabAV`ME@M3Wj{}}*7R&h6YF zz<((l%bj8KN{xyB`Aq~$8lXYw0iIpsBsZ{M3BS2C?6f`T6H84uhT;Jpp=y}zCF@Gn zL0i9C$9!zS;X{&`cINx+a`+hr2{f}3F7kb}i6A`yEFwh+6S?0F!Ce##$egAGBB48cIz13Eu&OQ0C}G}6sDDQ$YjS(p zpE=jd1OElKDxBTcKvYL}Z|#yakHWH4Wia*E%hzsMC51JI2rTwXv9z35##LYpQy_s1 zKe=953zeoi@SU|`mdsqJFS|4iv_b$AhS*2*;heyufc`wOE3dtRsNz25UY7cgxq5W7 z6>v>?V6!e+2EYtdCkHLs@ChZ0yr<$uve&6R%(A_QKei-qbWiX2=K;;Rd#&Sdw?KL@ zeOdJ!qGHjx*r`OJX_ri|=9qcZaj!!z97!F3ltL1GXqSood9ACeL5UGT_0xm`C*ziz z-Q)Pr=f1-WSlI1>KkDU#e{hFV$;#`6N{`6X!GpZ>T)f2T8rM&R4)lizaduu|n4#;{Ln@M1)DU(6EtuenK=Ho%|qYqcO zmxuaenMjz`yb7yH2aUjif+lqKSX4E=sR_XxLGXe;N8+-=2zfG0NIU=x?~~_@vdg2_ zkE&>z9i?rx)*rTSZ+W4^7h3z5QsElwX|?f#*0$?C5Daqiu71OI0R!$k+#+QRm2wNY zHxu8<#!7R($`*|f5PfI!U!tv8s5qcCwLRJ-g5Y;bGr_w1ldL!|LH*xACrV_GB5R(6 zgYN-HueSHa#EY@vtfgi@^soqmyd_JtOuySWE*sd|g%CeZv`=4NmG1h+z1uKv26IjC zEG|2N@{jko-6oj{Pmm6(bH9P$f&ubQ#a_5;SJoYNDO4QS3i>LyB(ybLJNucoO+kFa zxU^$;#P69gmst$dpBK{?cW*-dPnh-Mdmd1z@Mt*L6@dtq$OS4S46lu+`6?lMDklS- z5c5>4`FvfbT1$jFNI>&&Lj-AJX3XnN#X)BW!Lg((zMHQ^!n9KUT*4CDYiJ(BwRt74 zV_@nrq1`tGM{iXGTGBpH)Mt~MXgTl^&urX`fb@r>5}=zu9($}BwDz759JuSf>Iu{L zrWW;j(UC{@RjW_tZZ;}-Gfc{K=*;2x*BOS5x0_|5q6mQKaiwTaD7x9EN-}-AO_kk| zmByfEqDTjAmwIJ`r@1K>yMjDP9p1e=i`urf@2vHcwwNi$*!j*a>$-ZS6Ngt)C?=`+#FQEvysPO9-M>u;IK{yXUIb5O4p+!GKYty*5)Js7#ZKOCe2C) zr4q^9a2Y6uU{#kNeaD@!3|=|UTNLkIs&97xEL5Fuj_D+P9E*posbLtaBI=Cw#p9}x zt!RfwjM!%}#^*8T+BJ#$k>Aj;bX$%kaG%|k=@}n;lExY=IGj?hS+CDqk={4%32oy| zph**YRDXm7xJ=OW`*Lze-=LV6@Vs`!IuJ1d!}pEv_YvE4+zECj4_!i*MlOQKzdEAckNMvivpfBnkOxMNVL|wv`wv0fgJLX0a8p_u=qZ1 zFQ#~A;sshftHbIw@+8P7STVKTvxBIN#=!uoV!$B%Kass_;nW%G<@9B`x&Iu|d4*me zy)3#M2HI-D6a5=d9Qcy4T&q+$qYDVz&!CgIQezdjY0TD4SL9zz!1ozKqB-@$QHYw? zNClO$>~)|2ZGOo;jHbEOe{WwQ#Z`ea{Q4|H!e0rLFf|}2mG;rg*SWBoS2&uAaJDt< zRx(f?VmCq_gTPKjYY#K0u?GE~_2zfaj;)DjCWRavN<<`R(YI^Up&gF~lq7_*y970E z=r{Easz{mv_nSnzfU&87$h|$X(eohB6D3}Tq9f{PQCGz46~vVexg%$9QW?J>gFSOF zCJbb8B^a1^8Rs0RO$qz8F5{y;5&W!=e23a&Mv+?|dSv|VO{@QEQB`tkE4fqh^Qre% zoT^%7xtX3r9!WhMIdL;%@$G^b8fe~r5dmuD>8tm88^kiDHZ7sXJMLS5psFX6i+mnq z&!JtQ{Nen_+l_L5OQ+B!mbY!xU_|{H<)+*L)|M$K`CRs7Nod@nsV=Wa_1VowT^4_< zAT4oX=DC&lA23SZimI^qKb^(Mq%=_LntpW&kf7tmlET*e-j;KRjPVjRutreT1AGQx z#x}q80#zxi0|r-Ij;qW1$_z=#=Cu-TDY$Z)yi5nIE&01Gw9+$qA9XKSX6Jqlo4r1d zv~PB0O^?xRvf&N#AR`yOLzpB&fQP>B#N1guFF-)eFW>MBZRWaoVYscg{yDYQz|#Ab z>}>k_=wQPUh66{PyU!}2+yf9RNDRf93xfrGQc7p2!_?+2TpM4b$nbb+?lac$ScdmA z7d4Tve06xzqt=a>r$`t$xP?>~Lzvpcc7~ls2#$jg4dm2bfT9qn3!-;JY&ac+ z`(|?>`J9Vv?M$scazW6j2HO7qN^0GKQn{IiGii^EKa446SURIqu0v_x$(Nozad#3~ zzMozr`h<=LpnyuHOEi%N_D>Cbsy3GmlJ8&*(dpAnC6{#4JMUJa{D=7@WR%a$b7}Z? z!41elIb1z{SPmytUK_GW0uoIoAT8i39b>@C9nap|6BJ?1jswTDjIty^AVRqPWz=FI z&vn(XIvB{6Pc=xQw|}XXrztwUuT@E1FAVnjXQEMFTdhXnFS@yE;`U5w^lUyd9^^<6 zE2n8%qFs#+3^gKEb+ReF^JF>K-l}I!use=AfOUUL08LXR6k-t`lr4f=L4d(4_RH!L zB(ywT5q`AY{C0RDAz0-{+`irEIQ)C>RxUv=_lbUQS~`OqFcVK&Ita|9iXiX`Q}V}y z%P_V}?)BD;#wJSUvh4I*WxT7}O1^$pn=Ey4o^g@X;@rJmz_TCh%kPk&98$vfA)s9xXLcN@30wO9T?%wU}Xd*0n)`W*E>PxZUhAQ<&!3!xw?Kb?)^8lK| z0%Tx#x$aLTuKliZyY1_Ch}XdSQ~ktYq5TF_aS*)pOSU(N;pSmNw0|_M*M>KapRay2 zzR@T^x6KFgflzCItK2pOT*A?CZv9WCAQEr`5Y-V<)CijE9snGfkcd^Q&Of@@J4G!R zQ4rib^hJmwUIwZK?tiQ$c@2--7WCykYN;xV^{gTzCoOg>&86YwTQ8$$X_om&Y8-Oq zOm~fu_;B&|2}-E?81{Bi$>(6|8kwR|}WT3vsy==%CCC`Kv|3hPa4GBy>V z^U(2Om})zC>yoq7&^X{W`Iri{Cy4g<+!hBSeZ5U-(KNA)(^J0;DeB^J0 z+=}O??iaUp6PAv3*^qO}J(F0^og6SdcuYjOi4k?ijQ&RnGRh&pYuiA408ScLe8XGj zBx+v;jRuo`kf-jPomr#oen8F=19}qSxd2vFCh90vrRmfTm$q%X81F%xey_ePTbQn6 zhQQe8y>z0CI3&p=`hY$nL`EQJEG?PV1GSlj+1Z76!O^nCY#Ltp#%WamDPC?lomR75 z7(7!pwWsag*PXvzDnPHi2Oaq$CTO=xrHvLSm{s-5#=wtgMM3)2^wA3G}R$rwf| zPd_(JXJ#AE>OuE2h3qAbtbLl?(E}wf;ZQkpv=>PA33>YOyu_B2h+`(&{c z_`HRA-FH<(Xr%yH;Bnxosup(iY=qo~&Lg-Rr8e~+w@;;Oe?ymV2|T#aAx!y%wR7%$ zPQ=ro;bOX@npn`r;K%Zdv4Us(G{DYb#KJy}nQr|GgtBdWLj3)kuf@ z61vo51S{D0i?^R5j?wSd-tO=Yl^CGqs}Fnd=>JH(Ed3VZT{XWuzGzb$p;GJK~z zh1!|sK6O#-YCPyN+11i5IZ zp>u+XQ^C1+yvwimiqlp`oo32Km_aV6j4m4^bwoRfVfeivT*&ti3szOD@Q*7DvFMf=@H|Mc`HQ9tEVYkts8mh{}n2{~gHuU0C3?14p)DK(pE%Q~Nn2bs1bCyO1( zIgyDAm!R;q`(OwG*V=Mxbln98Um!3bb`oGsU2?_iYsM^q|tN z!oFJ;QQAh!*%HeBpW}6Z44Dl)-?|l&jZ=S6MrXEh?-V<$mirDrf(;WyNq$N6-ep8f zbq0fO?nHG}^bBYan}+p*pL?q{1_grO37R{ktoCQB!Ph~+rrtCitc5DLn*Z|h7AB~M zD|dnR!F%6_&|PEl4yJF0?(7Qnnecdgpi2uy_xMB^AFQb*`O#U=lrs!+$52zYdKm0I z5ECDSY){npUub(lhG7Ffs)t)che1bekA-&Zrqyge3`#&}XK(yNYd{NptFQA_RtclM ztKj%E93w5`^?{Gx-3)Xr11pVE-m6xMlcbiv>#P-_3<3-QW&8`eSnwiBW95qofoS~Y zXK?w6V<-shPB|im10TT@PC%9<+w6Y<%$W?Cd31>pFNzOueF>#Sd(g3z7yr+@h{ih>3`&y!1ljzrE|{(B_$N;WxGk5Z0VkniHTZdm)#mV z1$oIUCfXUIG_4X4wB9T+_yq_;juR7>iM1bFe=Ws&l-UQ2T-DETKGf^}tR|!cvpF-d zx`tsgH4*ZHy6pSz{>B%kIo9U&#|ch?6-;{sJaU(yawivDjCQswOA(w56g0C3uD+w) z1v=ys;F@B{{CiQLo0Py0qq)cTUKUePd$ZPar5jdRF|93e#yx@3+6Y^+ZrM3RHJcrg z`)TOvb6cl>_L<5Ys^ArDdZgkg(LhI`28_CY4?9RvYV;_Vg zUxG9);fPipqqBcNZ+@F)OUhWZMEe1cc)K{}N6gO3A6>0H-#o-gCgnstH090YBg9J* z2=oh>3O|L7QWfN1RmseV9|%m0eKvIa3iRNPG)7}iBh{l%A^A58MqC3(@wb)-Py>?h zB)Hx$%8osV0Z0+b;tN*Dz?}}loA*J}bll%SMC<5IJAE3V_I*LSFXBKc9R|nsJIXmN z_=es!_Cb%7yeXjs(B@9rDv!EK2RCxHF!P>)%ZUz?4igslff9D9940L;#7S@tgk+0c zeEyL~9ZEmj+mw)4Lce>`!YlxL1MP=yo%5vsX^) zjp{!TyM4#KyyHQ~*`m7vxBvryhm>~y4S~UtT^Q)X(lSlP|cKcT5$3k=sCH(Wg%VJ3ELFN3EM_iLW(8gxmP`pxwX9!yY|UFUwJ2z;xx*@7-z-I+B1!w>iqjKaVe58~ z?fy&2JLOr?TIts18x?6_Sc{i>cY7x-^)m2MDn=}Y|-+b@*L)YF8 z@$B!{7}nrCB&#ymdS;LMOpT<4Gx%k~lkelnc9rnu&Vfs~;%Wz{ES|sR|6_TMm_o}_ z`3+c2YEI%+P)wn99&m~1=kluX^?w)EltGJ#-1nt7DDx09^*L_J%zF`vPIiKYe`B=zmq!dZ(KqS+dxEsiZd`&zZ7$mSP1!@irZgG4mUAI4 zL#a-i+5O=A>xrhoMnK{4#*Ll~>P%{l{wWKm=SarzIi$Mat!WPIzxaOQCLKsgyD7w% zfGxi5;Ty|DGP%aVG^(`=dxyPa8=E%2>M6Y32E|Hn!BTPL8;3AwLX5%~9_QZae`7~R zi?`r%J5ZHys4=#VG`IJU-o>6n@dJto@fJz{CBc17LJ4UL`FuZ9ns)}iq%_|E+6u=4rS89e zHhKgqk*lm~cN>_m-?UOef4}n$OjWi>x69NYS9(jS;Fm4$lSmE_C}YYS8kUq@h69;2 zbcBE?!f2OX+7YxuD7?Rr?1Lm_UUN@&64PUdPgpGoJzc-rkr`mVko`{N|J19Da>SgX-5vH+QdzRBGXX zYo_zS;V7&&r1swYXK-jC=w6ce1r)wh%SX&-SHSu2l3bw}>YfHy9TN&O3Df5SZB?)R5Qyr|Ic2+=VSxH@7P zw-*J{e1arxTRpgm4JbX#CFimpeaA(yhz?tGJrd7i9jG0`VH>it z+Hl>)0Miwb6gl4z&F}k?kaeFuoM)47z_8%n%kk!47DEe4LDXnM49$GLPQv-p>C#Lk z+=$``)}vuLjZlj>MACb_`01bfMF3Ic{ew_Zw(tj2bo&2C0I{WKTu^g_Ly@UrP|s8wulTK&XP#G%Nkh$|}{+kCr>^N7GiUTcV_Zs0ml!E5b4)IGg3 z$R!=T(&uW2Af<%yxKzeg;K7PeG!HP20gBPWA=S?}&twr+-4*Q=ndULbW3e3x{N=t1 zx(jyOa%f@2w?YwpROaxrHN({HyVSeXxL!C34vynO&J~h~Bi8b`@r3XXf1o{aiZd$~ zwBQT2{$h~Pj(LjsROyb?w|pS{h4p2h3dKXOxgV+>xbo$mfn`kM!j5w*^9o!f$L);l zp?2grM9NXkb9^U)r-!bK?dOk0mRJm=A!1>~7B2c8;&$BncM}74={U{Rw;`?5L*u}o zwhASvVkr^&ftgrjb#4)u{LWbElWz<_^DD3N`%?(Os+)xjjnm5B|@>OiR6%1(XXv&+5RLrNMDmFr zew?Grjs1&{Rlatn-re|ufIT3F%KxkvN^FDq)@f$%HweeUVOs)5&=A(x_?yhm)Ba9) zdarLtDQfY4IBfj^FJLH^A&dw5Kr7ym!6Y!&8~7{_Mzb=KdXV=?4M1|Z%0NB4k9|AV z;dx^~DhDQetv_#FZ&Ba?a)t?=1Rv^l+Ft&WEl?JJ`g5ADs)aNBgSDl8>2sFqUq=Qf zKBW`pxsIlLXd@ajcmWrQJ`-R`NXj~%$j9c#&wJv&_-`BxtD=ozh`m=u3+Iqp!MHNBaMw=~{+u6CB*Zr*YGDb!Qb&;9$2UjXU$2PN*!g$Bx zNju3sQ;1^O|0Wvx0ts_6y^!8g2t!mY*CibGDFl8l&|t+nc@_wRBZ!#XRbp2)(iDT* zT3ItgGz5(8;!lSZ8DiZ5k!_=M`dN$5Lc?l+{Uvx{KreX6v=IG@7hT=J=wFeww2bx- zSXX04@~ymVtUs*C> z1fW%gGq^JeVP+T4gqQ!}bYnm6u(Bdmdy{wf16SX+nNdKVM$4{!dnZY9dxK0771F4G1`^L z*xg5?vJ-b%W~YS}uWUUojT)_49oEyf`-$U0k3ED=YD5BLzL;!Ix}ueuIR>ZNoTyJP z%}q(=Kz%N&v56!XBE_v5yyanOrn>rATE^CRd?Jgyy`XA1P99;%&%n_tfmMyu~_wj#Q3{fxa&G2b7#|v1*jWQv(3z1%6nE{g=xw%eLOWTI{>qa50-FX7PT=>8gWL_5 z0ul&G{Cbu!H$J{;;Gq~|-iC}kJ=41A*HvLKZ z>zayH7fU_wLy!v}DOBjNW$_67UPOP5C5Cg~e+))$p=P;+@USNz33}Ah9{mRqLkRqv zr!>_0-R$A(33S{>c*k7UU`|XOTEvse?@}>?F$jHq6H1T0w@yLk$1T3#T_utJrbBvhk8HypZ zyf&nxYic6cchlFG)UufDyj_-Tq9#+&rV=I95@iCZmCrs$y7TNd)krx|{ITy&2}G#> zfpoYRfQVUo3-9T;A-LFmJ)#J>)BX?Vh0dqo$6gJynC)%4Ag3SNm} z6GGfsV5GObU zQQ;Y{NRB=6=bi}gYQ1t!IAVzlHzLU&z-h(-v5XV)Et%ho0G%JvzjE+d05}{gtUC|< z(E3){jXQ{XA0z^KlEnGY!b7S9ohhZw+1-;GFgn*e6hy%CA5p&LK=>95YLWNA*dd{t zI}$&$CL1z`AJ2(%s%fd2%i`ngx}^tRA-Sw=^q_N}J|md>lB`>AQ~oQ*ycUfNn zqYTqG*i}~!Z~l}$0-oq{-;*n6ulqOVWtkynFk3D17^3cbb^R2IJV<$9P71F_};Ua>`s2yj%lJ3%OR|qLraX)ZF(_Fm!dAz zAJpHVQ;H@p%`tjg%Z0ARwFvbqTyJua)p?k}DoGWE0h+(17A3E?cHN)2-!~)^HF19* zq9aSkMQ^e!#T8G=&b?nlUIJDC#!zGy?@fHW!>nq)s*Qt=#7%i2Xbg~Agoj|`>z)FJ#kLlZoKiHpA~4(B}T!W zBa=HumNDzj8RfLR()pdPx#@zx<~ttv+)0C z*~nUz-*@DtPP{4(B_4PKx_q_EI1SCMVTD#SGKMMNsr!`(I}JIw(fY_V`{CIdfb?+-j?Zevb$1U z2r7N~+W}MCf%tWT+l(yS^#k(j2FE)CAo$1Kp$qTA)6GxR@ zsL&bEz-B>}Hv&jSPg^~92j+eUXxOdzGACK2kNV$pI(X1S=^z1Pn!Hr-bGkiks z^I#XBj*-DkPWc!xCC{EPm^qWxCX-L_W;IWV0GG9GD%e;;hS|SidC^&pf4ygc=DzBE zO))Vl?Va!)f~^jRy03Z@NDl>vy^)F)=PH#pub{fK!V>>aH&}qEuE4;ZKoP#8HEJ_`n~%y)rUBjq!5K)g^M!>xDe!2lMiqCwDH*T0TGFl zphy+fkg@xdy&~7HDQi()TaU!i5LySJcJ`+VHBYlS@E$yxO`S&TmldQT!|sRhI;Nhu zBQgH1*$dt?Zb3I;dh~P9FShzoiWuqNhPtFdx$rqsE>RD)%MTs!`Y01okt2A(?yuar zn`0ff3>oXQpUx}y{TbW ziJy~D66_m(%)VinW2+)*`fbBoIpUGdB>Ec-Y{&?-EaIL1Hn97x)jY|*(QCo5S6v7y z;CN+bDLy0`)7Ry1=_F8dkaW(mDsH4~-EMgQ_rp`~bG(9>tiD@WBS^#;-7r11j@XE@ z**0QOm!gMz)VR_1bjfoTGYbMpU;d2bY&D3JCaBK^F>Fw5N>c7-ra9^~LnnC@n#G@H z(Ib#QQ?QOco2#1SWo3y)`X*s;Aoq?&{{y5UU|mI~0Q*X_rA+4{h45Y_3~i;wjcGoO ziO8$LuUkR|&S|0RD#H>HI#U47;t?3v$DNqv)Gab(*eZ1^5tZR}>)yvw5l4hUJ+``wN|!lSz5}4$7)?JsLq=d} zO{LF=`f3or3>g4pOIXOl>N4;VL#5`Qva*ta|aqv17kl}cjS!4~dS zn+ZuOn8%cel5G61&tYQLnEqJ0?wEcTbP(mpIGY-1#nG)~E+yG5)W_nnT5$sWk#&^` zCf={Y#8Zpp?3S`Gh%l9{8es^W$+R-47kxXSs_?#)q1v6s`eusnrGUVi3=y_a1rDhA8x%Xgcw zIWx6mWkT4FU&e`x(MjD6AMOhg=QJ`{i9!v-70Dn3Y@139a*y)`J0hWc2Lpk(x1T~g zYTopwl1s$r09F9-bgW)=wRo|tqM_K(N=u)(tu(oE0HzrstmHeg zkzAqoCG&blrKu+Cr!X#soq;Ljz1(l>bYjW8a@DDBRvi|#QR=xUuaEv*F*z+_juSpt zw+)DC>q2Ql$X1sJs|QVDxLtVdLDIxUAxX^~-0UZ8<1H+zu#gc)kM6gxMHMLN`D$!m z*v?Y&C2D0MG#t_R>E<0o5N+kE?#=YRJ0v3I0C_Kja}cb5o!iFKX)a}yCA+|8OQN|2 z-6>1Bi*Qw+gS*OY5!PqSc`YX#0?Lw1%{?(!W&g=`zPfz_Zu1^b z<|BHyef)=9v{=}N7tyETxrp79Avbe_qnt@`$X9IM41$LLay3fkc#;>tWi308iu0bj ziuKj0t+co-v0&q8|Hz>$mG z-W+9`eMSYejBd@7Laf<$VhkvG)KQdWOMy4m47fbes$fOIf4&B0qpS4(ULTtXO?^hd z@G#C(D&{y4T%}c~eNk$`N2$(gCKJzLpwmkFwNt@VnbX|vYf99t4nBVvR+3jBY=*4_ zp&JV4b}7Tq2pt_Y6Z({<-h#Y%FD8FY>6p+vn>ZH(OESM+7Ls+2PMw}&pMJT?6BY5T zB1vD+j6}qXzBzRs*9Jk`Yij4v-Hmt_MEX5B5It)%;pA4|cqBiwN&vT+zlfD}(t!dt zDm?Rah;*eS@5c37kF-mxWu6VNuG_(^Tne|#7hMyoZ^tO29Jaf-3l{$R)LqD&FAB#j zdd#&7Gn251J`2xvxsYoR?e1V-gLCPL21(dUpx6@?VWhM1;M;+L=am<5UCgZ-hgG29 zM0;O6c}W#o6y_wza>^1QAHr{rc0XO(ICz?TyCA8-UNgXAgm1v|XDA__mVt3hfUCe0FC zovJ2SCCI84NT5E_H;m#%G7!m<4`J~t($5uReL~Xd3se3|oY*}=D2*(fE;8fz-~B)p zcn9}c<9U+dz~&U9=<;ensOGZLnF>_I!mO0-0}%*rsOod)3p72*E?~4+y0Ia3-O8x5z5Lv)rP-y_>pZq_N{i$+=C(0M|(Q39ND)xHq5)aH%?=upnD}3&~e+x zFE&ZsZ`Fp}+zn37x#w2<9B~5%-|8MQ3qkJ2$4_D;niOCL+IkbpM|Ht{H*7Ev=%xBk zox}c;DI`SOai%uLQz>^D<|j$<>mS53SX|Z`8Y}OD5$8ZzTgk7T2n3>jg`KC8C_*ZK zFsuDnswtVF$$$tWCYAbCkS&Ys+xFYcb|n~s;i~HNmercR1p~3hUw9htwuU7J;rCC3o`s~U-yBA$GeU`9xlnk&rfqglw-m@q?_w3GZD3jG$9*Fv@oM*3s{6CWPT57y>$1C{B(N z`6lZQmCvrP`jRr71&VLQg$J}R)zUMZO1X$;BVp(JRWfu;j{%nCA~bEJz+AiSxJ^NU zg|tV!In{T9JPdP8=WzU^r>bn~%zLo;+`-2ZGZdN*;yH<#~tAm)>VSgv8rPC4o zjnqbu$Amf0>;B9g=SwG9yZcAH#Q~{nivdWS|4MG_gzx4YFR=!f@?!i5dbWR2f4BCn z?urObcxvZc9>$(6WwiGpFfiVT#vRF;kZ@C} z&MAy6x!Ytk}4JQeeJm0Jtir5?|L@Dz^)Eb*XuxH_F) zPm<}c8su7lO5h9>KJ$Ao+LzH|35g`)VsQslw6&4%!*SfSTDi+YQT7%7+ZIgGt$T1m zpK;jlv@CB9LuGf%>N7ovV16RllI6HBKF{rL2a2!7?yYbj6<=ijiMcE0GMA-N2NN?o zMrS_IoJy0Vo&Hn&Vefw9rU+=j1G<>&z}qA;Yc4)pBbVEYl#m<_cX4IrqXNey2;EB4 z82`0v-VJH0s^@!Csp&hVU_tMVnuBW8qKZo!=&zvx0=2l(Y705A&4MD&HVI9CtKg7J zh<_;BO5-FW5%9%QfPImlHR6&`6(@FyEF)zAMv9z9-K^tkkY`1gFCbyg{JDELTpidj zv@!MqfS}Zp8uwERvMQJK#>S5+;DJ8rHDvLs{vapX}=`9Yj85~5&}Mowr0iC9ill0-tZ#b=9QJam^RroTq$>vICin`ljxPWy?Q@sk+&HA!D|6F*q>xx{%A{F+ zA26ttW|K|(ygiH@qA5bG=p&Pp7N{IqqtOzj|M%7^kd;h1wwK(>MO9_fK~~-@L6w{S zS$|I)`6+-2z7$7NwJKGPO=5axkfqgFrv&)>H$};y)s1rb<1Rhq4*lVehmUX*}}1q zT>8hGHVFYYr^aES*5#1ZNr~Wo!MhsY%21v`(qeC%VVXwXHM!aGMDTftZID?a1WV^V zhAdg?^rnhSM8`xdt1*GsF`v}$EwnC9n;m_(=>l!x0>fc*C3jtJ zV`Ps29{piQSaW_sGbe9eiLd8Xr{nSK_6s7*bzlSUDS?Z%JS-!j&@L3Sd8_9$5*DzQ zqy2EQM=p&28Zy12B8NcAn;$4{h1Eg3# zFL;-B+%5&dPj0(G`+L~bsJMAREdVfPD6(|c2fdMsV>40)omNnFa2E}oV=p~*K-WDe zq{{y8Y4Ac5nV)Td?NLyj?3I-l=yDIX)- zpo0m6kXMmxQ9Qm>cHjW zqWorjbDZ(a3?@QTK(Y(JT1DK*LW2;JBf8lK^~SNU{%{chh}jf^bE98Bc!Y!jMMvW!7XbmU zi8#F~37AlyJ+lcl*+Xe=_PT}4mGWP3A=^f$lXJr_-}X3-kPN=!mI*r#v!hyto<|}Q zNWiH?G4?WFS6?SRAr;Md0zvbsVP?SbP5KF-$~=G_66rF@4YUtp@a*Op*(8sP*E*4X zJCI6=M6S%UEc~li$V_Dcc64J0$c^<9nLMj_>+QC>VlWYra`_%XHmi+JPDpRT!8QmF z%=8FJra(BZ!cba1e-Q2+&)nfb+k#4h2j3Ah}!J|5VykxT%cnZ zmLi)w(9~9c3M#T>iJukzb090J3<;UQ?zOHSG#ylG^nf5g^ORHjJVshH8=TC({`*)h z?uHeN>|;O)NBZ~2-3h*tynajimU5W)A|cHwJr?;Uq-CvUH^9+Uj860n#@6zT@5jY5 z9=#P+(F64@ri%$NqLqwd$7J=HRpzVOkVs~-JgEFBC|T>uIoo;}L0ChNxR0Rxq8ga@ zJx)WompOy?Cn+~i7pAi3Dj@rjSA?R9)sMjhXz<27P8=Ql-_8!w?YUL)f9B2=w2A6n zYdT~^ST8D%V=S7QEi$jKz6w&Zx?ac(g$i;V+%C@oYH~#pFJyXT;fko!^g9^~YHYgZ z`_s~H2cLyj-o@-a<`h>A9|HictIQ?OypP;j0JvT9&nqUU;yfaps4zJTFa zOcJ^!dDlG$;#V{O{Z{;4jmgW4XxRfNg_-lUb2eTXd2~(|eWo33==Mt}dRl^Fe}!)hTI6~HxbS|Va~1D&O+q)c{Ic7)Rk!&n?^?ZL z6rKcz-XfV)&||whR<>F?=EpCYA2+wQCH`-!-b|{}>aqT+@*_O_xB2KNnW};7{}LE7 z9@vK#KD~A7gT)QWJ$y$GP98A&)#&K4qdiCd`1QBN^OHYs-Z-WF+sVsc5@sxvA2_Q0 z{Pt}9Yt-kLp)Lj|Z+!aW=zHGSnR>;)9-sb2{!J^RV*54U#61CJVX0b|o9inDTZ~jH zQ~Sew`M9ZZ-1MyJoX%{LIaeV=wE3?+Rv}*=!TA)W4?j=Sl$A<)SC&4Rb$19(@uMC% z7DcBUGE}_SWeVY4&#bt*CY)r%{@kragTMzVLa!;8^$`L3^tc{H?`SJMtYJrNkiv1t zteZN!B_D}Nl{9nuQuGW8!Np@W1?TvP7mTTX;dcbj$t+UdrT)`&6)qH6j`~s znzihggWGTRSe5&_@9sm^K1D#$eIrlRS9tD{{i06!l$?6M7H4(5B4y_?BqUb*9~w7w~+_ND6k&vP3UmZlTD89M?Ddhs#r z`1<}r8{+7W2cFWP61YY~uRCp1)-yv2%_t))6RPei#p_S@R7NBs81JByxcCCj*~*W*%%EqN%Kw$1}S)Xwp8T6LOX{Lx~x%CD{f53p{bja8!kI=RAar1)b>AJ#}m z4KBRbO1e$jD=OH#Ga0~BE}$%U)G#H!uMPy$-6RXo%{AVHEg3tYU*W7cF(KwM_CYtl za&9iXRnTj=AuA-|~bDd3#{j0CDZvz$L1Nod5IW*5|UZ7`Y4Nebf+LcB5E zgC&J8?gaAteR{nHq8+rH^1fRkn5lgCe--mwq#n_(6@>nlQ!VE}4$@Onc^2o7^U5pL zV#Jx)I5%{)t@_jcp}9Vm3^!tQg=Pr!9 z-@r{Z`NID9*kD9?3Tajb^zn;yt0%3n;=#+bsb&kq13GxgTgR=qI*>4P7v>WO-5=he z>m`d{a@Qg#22HS}qx$W#%(~9PK1n>UY~odxc&M|*WAm}jP^kyAm+YxqTo854%a&ZH z6R!-zhGP~y#$RC6BlvE6Tr9>HHQ6mXH~jJ(-^6Ooj{o!)_4hC_bhJRQa!$3ZgCMEq zRqrl2LI(yx&yZr5kZiODTyS*mZGxR6D-ZUN}Hk<2e zH06W^x25Y@zKIfADmu%1bH^)4%p!BvLszNhoP}y7)_g8htk8ul713Jhx;VOPrPbxX z(|Jo`Cu4rhmm;1?ZM?Ivlbsdh{Y_?sn_Z9_5T#`{SH4GjtzV)`PF{msxOV<^fQ)yW zgfF4s<{}qjZn=u9>ei%k7?%f(Ru$M{Bf%CMenf0Wm;}ER3+MEqKS}Dp3bsF!k{!9P zB0zGP$IS6)XM`+He}R$5oX^dgeKT9*&E#6k*>YsjvwIXN(OR9k!{=>4q(ui0NimXx-yn0t65^cw)_r#5#ZZ{UEuc^6a%4Q@}a6d z6%Ay>pRf*|Yw?%y#8E-p>n8nHeoNJh_1^JFd~N?>Jec0}W%F^mR3-Be*)!lSIjBP& z^~grD5?!{r=%!Q~N4*n^ZmBp$l5Rh%EFid~;%m~XKDlI(-(f17 z8s1tt<|8AluuQypx1ejNt=v+4^^&YZ56PQU&9F+uEXr4x$>00bVY8t*qUbvye%5*P zBA8QUK4^5a4g-bFGGHD4^`$Gtwy?Bmeh8vy_pJS;u=j&?^2alhY}l1HSKnm3Oj8zL}yO?JiQhM9Og} z&;zLv%=HXL6jLnqN#S-NZ^}$yU*zK*-|p4qW200S@_J)(vkJ3ZM2}Vi{Y;9w;gzh@I7^Qlyn2vHuHaWaN}mzVwRXJMYnoX zNfD__OF4R0RS1^f`hdx8xWEb;r0y;@FjT!es~_8-?34};-FG)HZst|sFPm}Io2^cd zOaJX!;%bU3mgb&Y92Z*{_}$E8sY{a`CYMh3+I8{3@wBv6ufOWTy++Ew{yEX%T{K^hhYvuaT z$M-^i>K?6bCH0h5`_$Hx^GHKt=Yw9#8q;>!^}YTONQ}vnylsEErWz>&<9Of6`EX1s zIH1-qXNkPtZXdlaaEG`y`^c&#z4vKI?K4jFM(VMGuLr!kGgPYmG z4EQOdq-u2MUI6i_Oko6D4h93ab^}MJo|F^-dZQ{ios{W5n!7|Npe-kfk zUXr!zAzJUCHBz5qJWwq}3%)BQHQNnY1PzxJ2+aCN^U}f?@59=?D8yUtqU9UCR zxhaCA-nRgt|DN@BlQe$L^O;0od+uiCp1CPYvD{*X&CCaH#od-hx-lj?Lk6_oqw4MrClPtjeFo@5qQB1;BWX!Cw5f(4eN;75czJKs z_R__lR!D#L2f551wpoa8qKxSuMgTji;CADT^=-6tdDw@94iCrQW0k~ zu{`bf_BtviTV%M_QTle|5;S=p@aB}}NOUApQi@=iVvD4<&PL;$Ef$z^{(sC_IrT|t zh=R<-0jY@vWpZcj`=|3QGAco?T{;Tn^%N$YsC@JA^GTT;yg+ZfkCBp40@!5^2BK+N zCjC^rxCBMVL`Ib#+z3a5ZQZTg*J!7_?9IvHEH}b|zfLGs%}V?H;jF_;*%n^^{8p3g zB}@q~HqJyZk(9d4sFA>`)Gp0MwE`rXpv?D~7Lq{ylUBT=CJGz%x_s}qpqM5~sbi_Z z4$30N^w1=<6-)nx`U;M6W??Yeh9H%~#pbZ6X`GnVS6}$hN824%%z@d=Zqa9hx}-9DOnMT63_aAl1mMBv}&Y< zlUB;_?>_O_;58)?75OFr5^e0b8KO?cRhKSsUMWGqd#r6&x|AF<%@5rF14Ff*am`bduyh1CaNIj1i}4#Btj?f0 z%l2eefAINY3*i+CRWYYLB4Ql)UySpc1+!CoYd40;#YOG!Tg?MUO>D?6ZFFK4p3R>i^F=K z&t#B3DU?UyNTy%{EJ2pw*GwM4cLe%M7TVmBb)50@5d)C2G}t(%M54?qkn^mfTj9%J zTfHGl#`L{O$;=q`Y2;Z5iCvSHPRe~klIQFIO=N*Uc@w+-CkBbfQ4YnXjBZ2;@1^fG zMj1QnJ(G%TDpzI;&Y+!7iDrj-{&~gXzPg|+>+j?gk-nceH3EfILf01BpilE)KfHL}En zDY_;yoEn<2F!F`Oxd|Pbu$O(YX`ng_OmO(TD(I5a6^N5v7|fg7%s)f)mQR0CLYy*Fu$x>ok1xu2-%kG;js>`NEo-(U|SlKv7 zA%>7zErk}~BQBKnQu$MI{qM_iSG_00D(3%1yS}uBSoC(ks0l9i4Gqn5?#OW;Ftw)a z&+WImZfVqsbzYr9WaVYtWn*02sFiK!jXi)RRd-Q8*5s{fC4DWQS0=YrUMKiI?5wOrul!XQ4z`iBIeC999Ys-K zLBQHSY7OcH5zE-qP+g_-!^Kz|JiLSQqyOAYyXJ4n5M?h00o?}Mk)0;th@a&6gXo&# zeW|BtO5H0X>BGU)uy&tN;lsT?2^bqt9#)(nJ09t$RC@;Jl4gH_2YvAFU;+)RILB## z;=?Xo5(ik_JYK!Yyw+cTSrS*RQ1Dso<%&oQ7On z(K#~Xyxt14ycor%h?jNmNfEU6NbKROu%JLgpC0tLF zy?0~!QZ%S#!43>_FmfAGjAuq&pwkb(9mjU)?|HZ=0$Xz8Kyq$kQG{c1qOZ3Y zIUV69hW)h3ukFeR#oSBbY?ax!zXuTH~ZN5O6GQCa;qWg;;*@c!C-;BeEAm;0&Z=Cn!M)wOWG_d+5V z960fcyRT}NWgp&Qg!g5>PZ0|1YTe6e75Mr*_@#@_E|;%`uTO25$8T323ZVj6gfHwU~+x(q6I`0O43Bb7>?>>HKq+!1dfgS%$FvYB&h zAKeLKqqp$2<;fYA`mU8s`rc)^Ydtf@;VpG=f}Q`oT^^baEq0A!BL6XN|0Aw7x?JskQIt$8@_OF~nF0m(wSJanz2l4OI zO>_e6Xm5*@#O3%tIWL?NmeAAX&oAR|55t00H@o)R?Y2hkL zKY|V%NtqQ8RY)!k8jIN_bhV-?l3iR0SY0M{@}DLU+d}n`Mui*aU|9Ez_tAj{h#-{XFPH#;;h5~2ggZlgn+1qv%SiqY%1Ns zpd24#HWJAKVfqbBC2+u_L5pIRv7;gW|V zzjWR;#vPkNUdJ(*Msw{ev4}E}9i3U(!z6+w701u=VLgK0|K;YgT<z7 zbWBgW9&InGL1>}9sjjGZm}kxyG15yNCU!|J zKn?EN`L8AwNFF_hbpIhF>dHY-l^v95yKQi!?#$NKn2;fK6r787rK3i3GCLl*zLdbe z%Vex96#F#saGEyl&Z{zQ@EXJZ2_DvI zS}3>{TwBlqD`2|)%|5+3%FaXk*C1Fn)Gx^FiA!H=-)OvY7H;zh_#(kOL+8{7KMPVZs z-DrVaF5E`MnBBy#;NnmvQxwUp%)dZ+qqZl!i5mPH?S9g=o?TQvLzLm#yyD!bAMb;> z_^ox<#P|0&9*8^LikQ%1AMd+VXoq+(^9mBjL)HN3XT9z$#2YgvSwJAYW&0U5YfZ?c z)Mzn=1xz?PA1lYm&*}QYmAM5{2tgmQRLbIUFg^20BWUksr3rmVbMAH{$1JqEI3$K_ zSR;@Bd_&#IWBk{UcRnF)N)@}p`lnSPi`3Pg8{)F;b%&B-VG&GXzxBAdV}nq+UK=LY zgbf)vo{nMlaLk1-CJk`L6T8Gws)}Bf)Bc}6oz2{}7&#}w3vuPDz685`F=0L`kbt!Q zFn}NOUfHAE&6zLF?>bFx=XM~^zju9quDwFJ6+Avfn+=X1Afb0JU#wDxuhCj3%_^ex zrw_lcE)KI+-@G*!>EU`SLOcVL1cIXPX(ED*qv9We+;_w39hMF55R6u-9k=SXby@LQ z{0Wi%Lz)XFCHRowZ!ef%xKzm;L7qKW>#^%iuJeGTr}z#jsbSM+XD)eRxk#!}neXS{ zAM&JN$N#)5&+!L%B5EeQ(FQ`oTGn~JI0#x!;xTe*jBmK{Le8#yYo5KIsP*&AGJj&M zVx*SK#7VXZvmT-O5ANiX#ee!B;vZUb1s-{kJfP9sWEiE5m!YX1_kElRuJ_G-piiu_ zH0i&UC3b|;S=G{tLoEcJ{1;d%pGTMbcTAlZV`Yqkb=lkC;0XBfuqJqzGOX>h(U~}> zPQQF=OAq{!bpU+l$MrUE0V81f^qmExE`F8^FTN%E-%u=?vED=Q$_x!EK@17x8K-Tk9Ma7?>x)aYY1$NmiK#yfyP+xg&b&#(UCYO zf?tV)rrl+Pv+NKu=Bz@Tk^1P zmg526_0(4jPfr}==zMD8Mvx?QOp3x29?T#Ru7JvDHm-zVOe2=_7ZkD?5cwjP=f?Et zFR21H2M;7~tFf&y$4iGF$Nn-{Yss6IU3Ip*%2uGKp}5MS4v7vf21`5sH6(LlfT(S8 zq7&;isC0_8DpNaSrn<9UoZKqzn`!?)4>K&N&-1gcH84+hHkaH7wch5XoA9hR_aXgl z-}}mWbq(O|%*vy;K|*1xjff09uyB*FTNduPLoeU^>!}>zHqNCENoC9%6`#$)JZJvnAHMeX5*@U+}3fF}1k1=q8DG zPUZe_Y?|MWP5TuX-*F^wHB@993Mp6KCoBR9JfNS8fd^Bqa0zv1UnnBU6*#<)`^2k0 z4F{fGm*Xk{JoXI;LbG>(r)zKa{5i@4~*M8Q!s z#`%V12Y}n!Nl}om2Q!rp6FYzPasFi1)uJSEt|;<(6NPA)xTiELcl|1IHRF(6v5F1E zJ`qZs4P*x)ps>FfIAqr%3ai}PP*HUSE~TWwz`VFxB7El-xYDrqBR?aX*MrTU+-FSB z&(Zp8)38wm5uTNvyT(lWfW|m+JL5)qHh|^eP=0#p${1qD2dCgP^WijK(XuzlONHr1 zR}ODBay9BjE)Bpw-?)OkvTuFqY?)ef^o8Qo^6bNOh}ixRKj}A9Ze@IPwbd{%)g0m| zDm#vX`$PVl_sWs#vp@`j#moAPs-dmyDzf~996=mXTP9?YC^|7m;B2gJtpP7zBiiaW z4`KM5&b^Q~kQEWj5B8n?lXZ8Mth*z%1g3b=Qw4on0<+oW4fy3ZVu{~2@F9Z{HhCOM zhKB&u$F*+KSD)VOP)-0RBs?fBWoYT z2E59{+?kcMUK2UF`xi_8TpVt_Ue{g-l!SW9>+hDNs3Vhb!&eh@Lbqq@`qDo>Mmuzh z{i%{tCvo9jX_dq9xEG?_Wp7r56Az)$v!|`sj8?r6OZVbGAdPlj%s8ozt}{QL7Ww^Y z0+5O%@>2KYu$~3Eo~2@Bd-GuJ@DtX9b+a$_Js5fVX!h$m%dqDZRn-LqIK$KIjI9oT z*E9|-ag@6A--WH#Xm-Chf`qL14eJ-jJ0CusB>?fl$Wk|J ztH^_HRhL~#tXfG_HKxb19^-v51=Y-y8s83@d{KX+1#0L?R}%BaSy&hTilg?%w`aag z2Y4>qf0nhlci7+H-v(a-lq|e#+$iC_B<_1KmAT--GHr%Vb&aOlw6GGqDcvdwq#OQw z@Z;=0zTG<3tTUqTX_2e{cs>YrI=SJ9{0fNWB~h{d7+uSaj+7E-%M*wJzz+;*b=4aL zrloK#78XRa!5WBV{5P?jerX}Zm*jl(v?9*O$ow!r;&X~FgMR8x6@{41zOz~oIY?S# z->K}0fx_#wNH2TOO^n8OowrWZ#J*aH1+R1eBiNDW&c{zHCNELHhOP=nB)7DJW^*9A ziPtmfS52;&t~{W?s;Ft#KQyivFBWgdlK%gn1fX#LGi=9(lwa>w_6BC-8a(L19MBE- zI^5`Ayl%eSfhloTL9KNpE3#IXbTJzvY#5l4qELn;8d-TeQ1H{=75s>!xOm9#{TJ1I z#1I^@y){{}^bDTKp#dGg_>Q~fYa4w(HzGOX7*?*Q^L8FaFl+6aiD5p&_`50gdDh1@ zi=e+Z{{{DtW`!RmxokdCDD=gZfOz8hs++{X~FQ9o=6fs3_I_0;g z78^HT=Bhp)7oO_)(|d!5UEzQ~(#rU*v@cH{`^7NoitTo9!BO$(807M+BoKW)AtKX= zsToXEHf98}^noIBaPCT5y+KQS|FG0|%l?0##7fg2dPeKo2v(I+K`!ExAEH|N;H_rw zS`i>)pn7P3;Z8eCkWt5TDQ1S-nRz*w_5a?;wQqNu@`vID%Q)xXPmjcbk9@bG%*^rI zhQ&QNf*63N$$K3;wsRv4Z6 ztqlk59^3L0JRZpC)5k|}dMDxZ1iW8~yX0=M^5p*?VP76kb^iTtrq7INDw@osqSQ<= zvXecOrX*Y0cT)B(gzRS2lx&sdTC$W}?zLt~b`7QMbzMX#vTx~HaxM3F-tYIliuyi& z_m9tf!fQMGd7krnU2}Zcg3{8RC44B@@9wpeZuv|z6JD0|_~6sMp?$-{wMIxV=KvG6 z1_Tiov)e2y5vEFFOy!uqy?Us~vpznJdVpa>q@FU22oL0atPl3wJe?HU*XLQ}%jblzX9C?;`7)^$FG75Gi{-eWm3VW9)L=_HT9nwp9(G2!^Y&MjHHwvK`f`)?|5BG9 zSa=j#yc_0;je=b{hcwu0D4X;*Bwn+FZz2WL7WmMuxuhRIn767xkkfAFBw`&4FaWA8 zf6`6nrSHK?>Jv+Ds3v)&?Fyr$&g?`237!){J4bTo=H`U>AZkh%{4l7t(u^>)`rI?0 z{9*jUx!qdikhP;;`+U@#UqQuo-_B02D8Hg7dw3s*GZHP{g7l@;419Cj`@xDv-s_nW zA$Q=%oN486KS<)|&q3Or?A^ggk!HNtu*yDC`TZSY7Y=(>Tp`BeHAflj5SSY(z3}2)z3PSj4?^>d2Iu+ zUgp({3`?N?_m`(`f-u3=;n_5quRFTGZXg@sjK@DfNrf$dArl(BOSE(|Bhb8^vre&y zDn{_r>1}hq!7#_Xj4($E!00O{h`S+PdN$pn1D2{+YT;%v(!|xNyr=M3Rk_WT!4B|= ze~dPGOKZ_iAY4cZa@99W=ryxc)d_HBY|umh^Rds(5VbGdF@QK`nIy&BCHd<@crg+I zN$G%G`-4{f{r*tJo01g&E7){T@_Lisz;p&=AvzQVy$GKLTlO$fJMxYJRLFg;?n;JI z3g^zbL|n0l2?KkMfjCCE{cCS#q}x=dg_{+%K7>SftuGlJ3yKyI(c;$yjVs(30_4GP zkzf>}`_l;cL!`Si@d~2Nec^6HVEZetAYoTv&uL`pd?M{ncWEKd`ZRJ0D&{*w7aWDu zCksB}{jJu{{>6;vW_SqP+@w1*)EtgscW~$bxcFC8lt1?5M=q0%Yy#orl2}!l09&C= zbv1?ly);xO1@>%Z4N!8;jTQmDq*wOApQKc<%2Jql&;Sdw)oR`e=Vsmj+x}W&ecE#& z7-l_|j0ophLCF?%lg5Um!J#_D12D0HxI0HKWkcP<46EnB51E92gHy-7IK?BUw$$Dq z^Y94#%zO_-d}VzhLJm`BS>VdG(BNQ`JvPT-obK?BrR;LTSTVy)Bf;A^j-5g{#Z%Fg z_gI-90XbrS1g|lr2b~v@>>owfu)ntlp-d~QwUl$$AY+V+4x_-IxB5WOMr34|x_7)j z)L58=1wb@f%*{b-($gmP8Muxyrh=*jkL~%#)Nw+E9<)-OWY`u+Hgfm=R&~ej z@~e*8e3&B87SN*!CrdK_L7{%j=Eu!J*x=D>o7vxh+?VWV(PD$HSg8yC9zNdLV2q53 zFO|7KQcrc>Z~NUM)`)#swufZ%Ui?V%Cu z53MG3Dl|VXCx3Ag`+@r=W8a%j@oQ(YBJEX77hOHv=S;MG^2!Mcej~qS7Nb9fU%)Iz zU}_&~7evbAO?Z1b8W3}X2Zxd;JK1=$GK|*c?dF1Rq5zN(H9Mgg2y$so4B>;^$PNuP zCLTn9ouS?Qe2PSTy)6-xAM^ge^>>97sIhX3B3}Pc&sH8@h!vGd%<=9zRvt z!@YYxZ()gFd#*{e=)=0+aL7${_uJ& z=vGfOKy8`>5W+aJD`Bwl$snUJ2op1TTl;Ch5g{Xl(7TDN`z#cKK(Lcz_f4jv8k#(@ zO&CZUCumGIff^fp-b1gg5U*1leGeKI1~6xJAw-iN5e2w-pERM@md&mdNmO6==AAY1 zOqlVXpwvyYhz`G9oU=uKj(xl>{j_~g5??C|xp~dJ`Qj|8Sz|c7VCP`-lR@${DghI> zfT_xM5iw!|euG?Cw*WH9NFoG24*BntTSQIkrk^4rgb6sy7L^WPpD|ZBj1Hv(+e_ge zOO?dcV_zJ^z>HVEwB$7iwyEw>=@<}UCe_mF+lk;xLZE2-QoRf>{Sb8;7uSW?h zh7jZp-DCvAOiUFJtf~>;Dfhh!znOd!@tCN3NzK)t_cI;SG?`k9J)}-T9ZSisB}TlV zDdMxC83_X_bZFN^R$7*am*^t|MlFSwJp#19w=9K_p(z>ZHegb}hvI~NKuEc_PrtAd zse6B;iIG1%aE6rBUuumMZ`%%MddHTk;$(%&Z6*^?;ZAS8^w>XgMt<@{!h&N-!Q-|! zDEtMpBe^{!(to1!C_P=VK$b*hLp>2X4xusTenTQ8L7d1TJAZh#RA`+(?`!~gJ;UjO zl5IhtEp&#yv%W_Bn_-IBonSTz0O(wMp>jFSZDg5D+lfu_EZ2n1su zy3!7U>LxJ_arwlmvz7flo~W&vum~C)YHaHj1cT{?b;KeR3^d6!;q{j#&pyxqqswgZ zc^Y-E)pyexCQ%JZM1NCM!EiokH!YyfxHo-+jnJAMhNv_8$>Rf71{3f+eh}Uqd5E+v zna6{Y$wEVPNF?q(0=Bg5BkDS%mC2F6XZ)v5CMGA~ zgY*};vE9)7Gp%BFt074N&Gct$5v{ifz{y$b>Gg>t5`uoJa|$Xok9xbI5|wpD>kGQ> z0+&l@aFGTPqdv&k zmNeN1@~sIFHi6Yiv93?6QMmJF9HKsPE(J(c%e|1Fqn|*NG$8JubmCt7OqK53tg#T7 z25E*HxNiE;8?B@qV+~vIa6U7+i2T@-fh9rkbQtu_j82x^FUVrbd|fZVY)sJ@K|(_j zX*s4LpxvaAKJN<}Yya7Hctp>KJuOa)PnzWgw`TptMlxX|ci8)ZJ@F9=o=7(=*rvdJ z9McpQ=iY@ubpsPh!58y{d1${8K+mIhQ#1xqSXDN##!FEuO`9~?-yxxnHR_+eoyuF5 z0wkxGT&>F^d{sVMbdyxS6EoZBg0g&$&LJNzk0Dn4o*V~ppP~5(i!=KXw14b(<7V&; zGs7$$l@e$)>~>wypu#{pOCi2%lU0-E$}Qx~*r)72YQjyUbU5N+20{awN>q%=ZP6e@39SJ8BxPTw;TfYF+e{y28M7MAVe-zarsddfP#~6q#@@;wBot5 zu!CTy@2Dfk$KWR_xfN)DXI>vQEZ!I0xy7pGOQhryCj-w32cr|OJrG&>!0&>~(&ufJ zW81pYkj&VXcrR^b^5N2xawtX{Fl0a~oSwfOQ-UN*mpG%$1-d$1CkmSiw1tYvdmWbX zEt>4TqQh)KA&gC(5fBt-Rh!sM!Q09EnisC3$Ru)($G?2u7xB{=a)pKXUA>b!1aAm7 z#NL4X5#keWwICtViw39HS_)!_EC>$*5UD`&squw7RDBGOP#wmgn!|C(&1IYGG{eq% zT!2hrd4BGx;{Z}>Vj@9C^c*A=w%LQITIA=A)`uG&w95;RZd2u^E&7fsJ9TCa-=%)hDs%XE7AUU5(05Zx(uGQ1(N0(BSmMZv|Gy7j`jJMvZ~ez(^)PsO;Rmi)?pH(`brcupi!o-v&%s+-s=W_{0aC5W;1RXT-{!D#v%X`pXd zVK%nI2nnmRQ2Y4w?LX9agp~_mOjlO_?}5CTR5!kJ~PQG*7v>2>3j6UzI}GQ;iE<2(2phVvUo9lx5BQKif!U2NMHw zP1|4*wCc^JC$Dr&S2`l5G>`+_b-;u9Na=#J;t`CJ6!sv>n}#oCQABstgnd`PVF9)vZ0)%9(2`hwagB+QQ~j4+ujQBcT)~ZQSu?$z2j; z5q7dD2u4f!hx-dXsAsDVlhMxZ6)P%kOuR;{G@`qcz_@67xdB;rL<}&OgeP9trxQva z#K!~nJt3LspTs?4{G(Zsz%YiO?jIU=KJ3(kRDzd=`Z)l1 zG%KE|>QH*x;jrv~6bU45fo*%R?ceKFf4Ki5;6ogeMs-!aev8>+Z?49aoEWsvFQM)8 z2rb0^V3ZHTzy+8<1n4%vsc*(>`bhq;?z4btlL6X_^MJ4G@F;qGx!WsZ6t3 zD2#E5jTF32q@FEi>Y*TWP6|XL@*ASyz%F4hHv#lpz5{**YYvlE(xXj z{*a*IY=3K4)_CbHDwzu?1L2Q_*%;Ef8?t+LK#v6oKOUA%oUvcL?&2?DihL87@FdvM z3(+-HAuU`BA_H#MiR5n7ia&=)i#>h*K9UV!H^`CGF%0qBgD~eqAXB(@YLpht>0`9? ztpD7MCZxoHw$O(7rge+pgAuDM=p57<1FaHN$NSiDcQO|Atsl%I5QB zk|opUA9xkar!!OxZ%QYvc?k4RRe?`0))tHEiJuuavF|$z{gTMiNMQb!W_YxC5kGsa zpd1XW8>+JoA^F4ri6WjRB;QYGxawFtMnEC}T@u$HrS@wPxc@AZ8|YZQJ$G*AkB7)B zKPYTM(*t;0-9(}x&LylRd9A6m8@4^IKB?wrRo>#~PmlKl2{DX%X$FEFqotkY2TF@J zKDILgSi+4!wzR>Cv9U6|VEkZs2!e-CL|uC~mYY70mj?Q3unJbr>!QSfU*UD>XHN;5 z&weTZ^<2yQ@06zjT!PBPuaY5Y2;u#pQ*9XseMI}+Um^&hQ?h5Tjv1PInsN=(y%Kbt;syu2}LTwQ$5K#Jac0 zP7`xAO!xu%0$3M`Y4TDb&DXWdjTaltvt`u&RMQyom`kDa;YvC3n~R3fo%Cr)A9-$} zRc}wB@c54^O|J-lLE>gY{~5kAVjUVDYM^w0YwafspkmDWThk}fJz z;ZRY>**S;`?9QFO^u=UBM1S=$MFYU1J9-3A_r?40%5jPsLC$^(in*z1atHfTjbj0KM0Y#5(GPe4<(Wn=&g^Kv3xY znb7dSIHctLy}xN-U9@~po$Kx()zT4-6`oN4g_COgJu+xj2>9(YuNTj-W$ zbHAorE=u~c|IABPpS10h@MIC}SU_8F;je{%W1MmGcuhYyavOD<8eU{l;n!lctC7hM z61#$-30xIz2%61awhcZkCzxS!s|Vnz`)GwjWmHWOWlhko(K&ET(Z|+h0cmMe1Kp?B zjyOOv#DmULW}9%&9`S$^LwT8_X__a2>unfnVj#M(v%p?P;B^p^23BQp(YSuNw1y<- zvyc@w-q|EnF`xOP&dR&Cw1-(pszb<&ri@W-PLgSM9q*qYHvK!;CTucf}G=2`N%)^^QhXxUyl?qj`tsR@TnWt z0(xeqUmy9pyH-iyBOgoXDKjX5vOW+Dv21b$gNQ`6CXVG16(3{$9EYNG+=S`5+Ogcp z*OCF|cc6b}|2=5+Q$|`ft;&1#YLN!ez{-~(VGqxWYp9w03MH``40FrNgbu{o_q`IJ zLaP&qFf)XoiOE+Pngws!T@=j*pja>*X-mZzc%M^(mQ?yD)GI1Dk-#T~9j?}yxJD$1 z=}?#%z6EY-KG^#kE^SX7(&Oo8f|dIami++t7^2?)@UW2=m*am7J&@@x?ItZPpEr$* zZ_&<8RK{Rt=njFDRu&^n#uc|OCK+T|?x?O47esN?R*o%XcQX^+IVDY>p1ZGYlF*)h zbb_}xz^$1PPH~gsXT1CNt#JvD+%JeK-4!FJ(y>h6#qO;gNpWw+t)TGQ!6=7AmG-^~ z?-(k2;B^nJ$&6}Q*cNj_$oV)55e>mA+~%(}?FBI5TCKmNJ={lWV7MA4CW$?TaOLdz zMMF~fP0zYDe%LM0{q0F0_|Lm;MAbhCR7xF|*Z&xy5LuxouU_ zCnkZ5VHhAP%k$9YR0p+dM;K}`3cNYiXVd!Mkv;smE1Dm_*PnWP7OZM@l!39+zc1u2 zK_{1=@)`xq{{@J=3XjRsO7cS_*hz6dVsm~Egk@%0?A7^rB33FF7XMM@dcgh?*55WI93_J{BUOdvtL*GPfCh^smAT3QX(X@Tw`_Bh0X zlIRQ`+S;EF5f3J>E=|yPuo&lzu+4O(*!4ztaSW@OFmiX8Llxl2Sh`xkRUXuoo+G}l zKNvjvHVV?4?9mX@MNO83VsuQb_kiCAHqE7hazd59>8B)FM!*O&o%S(s>7(2BWgASm zQnd@Bm3NSygmdsqMyQ&7tWUUho}z_r$LhZnB7%brgPOK5+$%`fEu_8o0U{$1%oa0< zV}%lDuOu{);03P?f=kXpaR6a4r0M0s{0l(1lg*AgPmX`Kd}3g^Xt&|bB}b^6vOYyU zbmbXwqqu`2dnYoU4w6ZcxH3@$KZ!#vmDoV*J#qr1YBhukSER42;yRSqaK~&Cjhs;F5d$l*1%0_> zMusMZUcQ;t!RT#X2Tyw_#Fw{4L!oQg7d5GIZXe!cI_p9ALfU+zv{-L|0^0Y7P9^*Y zP{@>tjF`*p`GwA)JR%^z556@=H3%kehc|LRBhlS2o6)D~=CNLpXOaT%` zVkYDhxEn54$>!AjCsFnqhEt4|J{-i%S_(IeL!#XFrWAt&NrgTY*-6svC{;WtJyjthmj$wS4YG zXuN@1Ey+H^ZX!hurbCj>OmTpF1m zj47QI|L{iBD`Qm)=jMqQdgD670TPHvowZq?+@#$ z`PlQx@@Ume~^qPR%HBs>62io4NJ9uat4y z855t*$ahf7VmlKF0&q=x6Qs2s+P=h!a;ZPCv^Ie;AJ{4lP4*%LV)1>O*5QxVKbXQV zxRmVXUg&_n4ldJ--evL^VhWXtKAGlmt){acaPeqn0Rmjl2pnPoRq1x?xQ8{ZiPn`+ z{vcWw35pOAm--t4kzE54r7ht`BR$E3wCp%^yY!&B_XPwc7%zPU(VJ_zHViS3r(5NG zj;#r%Tt18lBoY&Sm^YE@PQ_O*=Ek_i%pWlZ}!3UD9;Cb(k3oH z%N=waI4XkS8C&R#F{rS)GP$>b=%mG7h`ddL0n90>AO9Kpj^O73t>5Iu&w3teuXodV z?=UMAk6wQ2XuqhdwE4;AjYJO!*urERh2VM%aLX57wCj-4= zdXxgCT&<-^uqHh;3VHzvu~_H_?!XE0?SnQ@Gmha_sa!|BoAw=gHzS;!@?RYqc*wOo zeP0X3bAXd`7}wFK{f6fZ=n+!^KiR?ZPVMDeO^WV=j?m&mN)WPiJkg9+c&m3tBRv3} z_QM+FqhAzwH)rRvOWOA7zo|6gt+ZVpmjhVL6M(9+?uL-12#TE2>H_kl)pVb~pZ=SJ z{~{8|$;Vg+nw3M}U(0GV$jDjx7p0JDN|r7S+BFiTX)fk|D-EB`Hgc?qyO^vU-w9v- zw<0M7*U5L_9_GVy7}rsi6ux(YEY7MrS>|T#@_1gB!9Tu=6WVF2;nPb*uEF=(d%gRO> z@#%O$b7&?Nmsa^xEV%ExQE6GsTXQ%ay~Tt4%MVBW9g@1s!nYv+3L%8(mS~x_(hCaR zHo{qE$z2JM*IGk|c7|?t0w=yj@YdJrCav&d3s&pD z^fPpFeQZmTvxdxWigDoA-%_xYg5x@7@ug>@#UL2FfaWmjx1VUPd}ynb^V~@!*D)N_ z^KWu0ajW3tp#y{vZRof1ZAwKg$)%qmxKtxl__S*`MKiLp{4?Ls%;P+8jmZ7u?>g;m z&S-30%`2hrv@fo=YPgBfVz)uR#%9x@(;!KdjN|jxg_qpD(}^6bi$t<)8k>wx77{HO!e#E|rRw zBXg2FnI^?(v&ARmJTa3PxT0b~crpb=aPd8k_G+{y!q9}oq^p-dxfwA)4!f#oG zHyN&-<-dEKKMYY{r36ZzvbO%bMM4|A>OV6{0+@*(j|2*39N$J9e(a>%YREh-|j;0Q1-iOw?piDZ&M9l z6?XGVPPn&)=3tkc3||X}ML)nU?mOS&9mkb-e0@uEFG9}66$m>dp%)VZ_wC*~?unnL zB2+-HK0EJapyCbRR zA41^G%bVZAcl4uNm_Mch_mk%Fq}je2-S zLSbp5%74KTRp8bWaHWGp@S{mQV8ruzhrX#p@bJu3CcG?oy8n0WUhO#wDnP9l2K-iy z!e1{qpJ>j>sYLr!;UIy{-=syYd5N@``{u)*^;`Pf(oxes+lf3PU^kr%W%MevvJS(K zS$}&6XUVkNrT;_nKY6sU8ORGw@aM*?nO?iCzN4WGg3&3F-x8=_kvEIH7J1$3x3C0U zK#7H~?$@NWwzV)JU0qcS(7#oAZ22;B*$??ZQf2l(1bw)9oSc;=s4%En(zfh{noj(4 zNNf_R2FF&Bdx!K?QT@+vxT(5OzUxhFuW(5p@keu4W3agFcD~)dC>T8jubO!M|AuE$ z1JVTLEY-;6M^R1I%7F47PXPBV>Fa{!^ZtzC;e6j(eJk8gvev>Sx(>JIm1tlmZ$*1G zk~hN_nUUEUh=)|pw+JVO>b;;Wtj>>_u$$331yRedk4^D+D4=%` z#3igjc-33M@VDGi;Cl`|^3-951?BwXTcbk(FZ7pkC-C|Zz0FXfTb=fL;!`EESnv@ItJu`n9|3{<-KXF%f(C-Nh$neZXumM zYq#6B8KPs%+o2AnyS2G$UXY-o0&sbzMlc#KOPG;a_9*mS>&RIPL&*8ov{Ux zaqs?OLGGctcKZJ5b)453%bb}KA)@H7ZP}G?4IzdF-S1(8%-aFF59UXTHtdmL@UCTg zqS-c6;?@|}KFK+^e*rV{{+wJ}e|SxU6TTQJ$d{u=8Tc`O2Ly#tZ#w?(aI$}_9bvM`hC z`^6f*X?@)}Yb^zz=U7J-Cd1fJ^PND3FqpSY6L$;jS4)^H@(Uizyz=?$FOn55g4q}P z+}n=)uJQYAhNpRPx?H%@z$n=*iW-rg8?SMS@}$rCc^#O7hj(Z2MOmVvHm`OMYFK2<*1zcD@5 zRF|7;EfyE?Ben;P$ej0FC1@hI&lsOT%i+MCcJZ;adyf_~8m`7RA4VIUWPa%%S+MuJ z(ac6Mi$>LuF@Rr|xiIgXSR-q;)6p@h3Mp!w<_`E>ir4}Cxoq~A?$n@}nls9bzefHW z6Sv8*^UvDZ_AY#?AltGY9?7Y9j}|XJEb{-WW3xy_)q2T`lTNwjT)30uXU`E{s_v|r zj{bVY*oIVQFBhD{X5sD1%YY@3`K$9?k8SDuv-Yy7W5GGxW|3(D^axh>4O&>U=MI&s z-xy`H9}degs7Ej!#wRb7v-Llk&5z9frH@`_P$tw_1=0A^3LiYGM(+Kq4mOsueq*~~ zW3*o^x=gw+wpl7RPGh99B1)cK$eQCR)u4{$^e{W?u3yFhmifiru~N$~=v-a!;=8ni zu!HJG@fu(6BBht^{Ud|uPZ--`YIydY0Ov0j{%Y=S=dN!SS<&J}FTEd%pWCEhJg(Jv zuqMb;&=Q3m=wtM`S!~WXNF07TGk_94P=KvJLvK>&aj~5Rlv`etLCvGQ2qLT&+bc8I@ z+P!z0@DwigY&gPy-wDVXu~FH#Iilg$^2nGL#Uj`Z2v|p|&|L=_M+U2++ z%Bc>1l#P#cZj5G=_nqnpUMyL^yO3tsotBX4^5i|W3*!?`weG0S39}h1wJ=L+;1|1u&cD-fjY9p&+dtl&3m}?ur<=~Y61G=_Ko{X)=U2{(jW)6 zU9*4JbKg^Evydr5xf*M)raE94kV>gtezW1&1F%4<F|NC1cMR3F zQ#nj{YG=tq6DK5v)=Tq27Ms`7ikuY%kB(;y)M_G_yZM2uv3=)8T`nhWuS?$`J3BxG zww@REKjwJhY{%v@iUww`w%pr2v_z(I7Ci5oIt!4X5B9FoqODNl0uX#vhythwJ)bT zs9Xil$Ecqk)0rPAt!JVjcd{Ofe9T`0Prc?^-q2EuniN2jQ_X}>?b&I1V{xTu-)XSU ziRVzU0Pj9b98XU_y&GW&rh5Fj+ev(O2?3?5Hl*ukko#bPX{}pd1=;)Eio1Brzmjf8 z%)@m$G&F9DB2o$8({vejt8PD@YNRW^vO zIqAs=0aN!^n_b#6=l?2IS51~0e4~=1Q;#_a;p<u1j)QzV&nHr92n>pcHY_G}q?aPVEi5<8*D216CT^{9o|D;tB`ff;lU5majI=a$tzHLxOTSPWXAiIc!y_icihX&KA@w+0L)r z6&rK;=yoA?6M2rL^#I;y7#DRPo7AljK$9g%{UZxhm}#rb7a)FvH_IL~azSkWAY;=M2ZL z(IXwn^lWLbEP&dJZyWsBUYg|+%8iYj4bOGvA z{OXJ<0iLV(x}c-ty$BY7=%qj`61`fly2FARy|$v}x)pn7{|?C0T-tRhOWHNjRoN@|T7=Ja72F>0IafQHcY19?UGL z5+rxYWwqPKsXc39c-+qKi{?nAbz-k1gjdLPem*LAU??I6^&_0A-1zg^!=fQs+mt*R zo+tKU7j@M3OA{0F?y8&oN}^hVyjgcB$<|`E3OjY3&0Oe8F=5`=>XxbMFfJd}Fs4R* z$CcS5UmEDAt+lfgU8nAkD{Ct%K|n4m|jz`cGS%!tLrw2c(x)V}m!tJzsu z#sY<-kt@DEX}Enw|ubNZkC#ua+#vH*raM z*Qgaknv#X-!ifWT1;X3~tH{}9#){$Ob`6clFFbFYnVM>qVV?uZu^sF$#-Y9TU6)hZ zBjZlC{|hl1ok5o~9{GF6N4-PYRej&>Si|zqB2}r6Ug8Jt>SFd~(*e90VfLccoz@}A z|9hD4&mw(Qk6z%%XZt_#V2PpCgW%CDLuac~UKVs)cueW51EzE1DSQd`GHN!I*O=mo z{rc~yN6~6y=*Qf$`S%=AuP{-%obuI9ZQDt5hdJzJG;S=KVrLXSM=YU#jMfrCrrKZM&8$Q)S~onF-c1jSrsPL*x2D$DIgE_ce!pTr??T?ahip+U@}Z-?eeSul<`| zU(ey_n&pZP6HDZ-oteh0TI1jNrk2;8HE{+Odl)jvnYxi$-ML_S$!cN{e=N7~`7D@L zU<6Z#psMegX>-;qd&&g{vBQsVG)A6;S6*My+QYGPWx=S)*r)?;<46)SO zC?ok5iV3V_iTca&vFd-Kc|`09Iyqk0Ia}{UP=zDt!c1BlMLOS4F(Dwc^f-QCw*RRD z<}!rSsgC{**~MZZS0&6|-KDOM$Ix}j`0uYsmu^eCZpU#EJkV&13KHWp4H=V$+$85c ztzypmdokqXx33nKqlzfpFBQR$*9H}1&`PY^`B*2t$(hE(gxfM43}dK?OQ}v)ZE2Xi(g~WQl9yLAKtd;Q02r_oNoo(qYEOo zg83iJg$~)|Ip66n?${hsNNO_X9`ui`c$DjoFE##t03X4KldvHUkX>h!Ykk!n*;H|; zJ|UrH^*?sqejPRz>P4$95crx3qO}hlGeE7Fr_8XERL39pzLZT9 z%e)#pt+Vdz9~2-)c8@9-<4Pf zv*&`WqKnR#+S5%9eVt8)R?Zz$L6Vt{m%&cZfhndK08PZnNv-@lNIvTk56)Imp9A6E zE%AUPGTD~9|okvLA+*$VwYefqt=-Gxc*&@9-Rsn2ZoLpDIEz?wVQMX zKK;3lJ09H3g^kU(oA_W5!O>|{6ud1Ksp`9Cj*oJyHPXzoHI5`az%i5wTTwO=Xp!C* zo^0CJ_R2)DFhL}#_Ch$>6ce#y*6MHkMkJbq6+kc+ z$g8uogF{tgPnU0qB5&ov=TQutEH&86Y;MzepgjiF1n$iMHjpb{hN6%NW>@lTSL7Dw zD~|iCvypu4Rz@0BY$$xWuAe^WG^Am3ncGXHr1?}xdH*$;YTf~C%fvj(-ou59VMV=g zTFS6`D0}aHOfaDVfASuey@j{wjnNhIzFCojg`+)?qbfDfd1gM(+4`ztJE^Zj{wI$( zbSB_WuWd|Ioxod=-vU>1m@Z1N6*#8AD{mQxWjxz0KMCwwMCblnOBNm%c z-Cd#k&eFDby0fh4)A`2UwcO~i>$a${$XAhjIq-AaBX?>T2wKoBXOf5Qu`}H%yO1@q6Wp+O^^hEc~6Y zqgAuC0zr;A%6r|3FvXjdH;opLjt=g`ESI?i&^e4O(MOnnnM>J9S-~PasWUeg-N}`& zLSH%8dGcXA(N|Pz7x*{URN@81C;eWhZ}inn6HPk&OlsYyW}8~`t1NdfG~S)ScE;s2us@oUtc=b5M5j|^Rdy=i$_HEm7-Cz}uzf|}5B0+h z$zM>Kk7uIf!xA4CRYpT0d>>}ltACRj&5H2TAPuQV%*RBgd4QH0;#VGJQ- z;*8u*Q8~PVe{C6?O!qu_Y6uuP*S*}ioia|Wcyma_hBO_cfIRgGAFG=01QI$52NbgW zM#e=C3nMybo>T9N@5LO*I}l-0tMVftF)F{J1iAdY457WkbN9u2i|7PrNC?y zzB9Jy)?^CDXIcBQz@n+CI_Jz;dul4G<_eMMb)2kh4s< zF(SurVq6CCD42CNw#}@$+yT>B1uv?gSj9eMK;VVe+AU6d!3^P>rlp9q(i(8~iUG0H zCV1r9KYXlOz7wd+v8DJ(f_%tS9QU?GgDIv&TMKWGY%Xz#CVJ^X*rgx-6YdeLomGk~ zFKc&;RtBn7$W+-3|`;;bzBPqoofWwiYrNjuY5CxA49jYEX7X zlH#dZ+gRl@qEMyooN4d!O~5ANCMal*-m9k_#7kb6 zSaGV_+gG?YqXNt*R69!@tE+Z$Q^zPLM&5er;j83$1IQ9F0SVlDVCzQ>@t|_P+9Sj{ zwX}nHN!xv5-H0z#J zkGJ&?tzIkazj|DzVE*qssA>$MDHhYCziuq9l1<553%aJNjOc6pO0Dso-cC^lgrv8V zExO_TUH=5G?vSM%Jv__KQycRizDM@AoXr+bclvpxGh=yI9@IaDvgR|bqFRRnWCo)a z{O##e-|`meDYhl4BuE7KUf|NMjM+}ojVxZ_`Ni>Y-jPZ2YNWn@^1m)Z8k)$Q3zz=a>WAgfg#%6i^6f~*K_ZF?6r?31YOhH zfD8?OoRnFqk<9cvHX(ChpRYD16-HAJ3ZB(ni>*96ZqmdrBA1PcTC57gBOD0V*e0@K zRMf$%M6F16{&#V9Re0s&nX>6UmQ-^>+XhIb#P`4ud91Ehkd5*K=GPq(wo_6wS2A~7 zxr07pMyc_dY5AvaAIH_A?IdqDO8Y*CienJB7UR`&HqG@{ zj>BHHm-0F-sgs{*7%ZHZtq9@xT%J`-ENf2ELmHJX+mAjKh{=EIIUmcl*Fq*Uc(f{W zMOFHahNMW+7C6fq}(>fKCsT5`~MKl;$2<#kLL=sCXfw z87M&MpKo2bCPC9B;U(L=ahf|QJc4p>eB}CaRrgMu-Uud+sY?%5^zE^r;>PNp35qqg zVDYlKVAeL0NoGaXZtmJy9{R=`C%XJTSgAE?K!s7V$TZDY;9e6~;ghfQ6xF|`^xm8+(aJ6C9hVCk4lw#Jcx;o>5+K5l%mo=>E! zE#%W`Lm7P>4}4+>n<~Fb_)E8w?sYLg7i6>WKaZ)t8&7RvS$hYBMXQZn)R7qauJLH? zOf4Z9^erjX`}W7V+PjL4ndeukdm7rl>-%e?pr?w9$laqJ$gjB?TiF%R111US24HGT zGyxA7AFH4{?%Z_vKzPj9e)RhW%*SV$2WUN9c=jVAetGQyQFmg6esYxYcWk*=7Dd>E zKf6)hZH#7IBnoZm@ly%8Hi$KAuGlI*mLVU5W5okyV6QT5Nq^f_ofA}p${lF(R)~Qu z#{w^GgjNUhy+LBT>Rv$BJE}|HY!Np=#B-0W;!G2`j4D4)5Df^JIDao6=tjis)K0}4Ud}}w;+eIxedjf(W*CL@YUVtL z&1@`W4%N=Su+unK857$};&?wi%e*)b8xG&7jEkk)DJz*={IbnmmoC>~60!5Y9H~-A zG=R!6u+*I9($k$ct$2ev@w}kI$#>4S?W{)&ZAqCYRB5{72kL1JeUdfHS>&E2XDvKy z>{pnG8ASij>aRCAv5~MMWZ<<|&Xfz8a=9SUm+dg2<1azOH?>AqRuR_t*h>BJ-Sr(i zEWC}X_9|4cld|h)zZ28l$Upn|Cr^@d{AwqTM#^B+=D*90_b-|j?><_DnW-36p{X)c z(oo%1iDbFOg{rE1LVt4Xu;`V2F)cpM{-#`&EAQGyM8#K)jgmKiyr2PK?1xxvea-zM z+p%n#c!R24ZzeYLPGhObKl=I#7lRMrDH8MFTb{7+CS6QQD);Cbd1grsbky9q-NSkY z9Q#Ty%+**y*|raB;|aW0-n7w(=4+yXR_qC+zzmq5RN-Pp(Y|C~fddepiX@81Why1V3eQC(@F7^b=A4i`IqFr8r zj@$noD7!9YxJ@LCD0g(x(1L1VLyC(G2ymY)e<|KD``woHK!5iO2tyZzJhT;Vc*OpZ zpoMt^{WB})D?PI%B|w5`J^>n)ekEvfW4`6;lTX$s8hNh83KHig-`h*Ss&cUKcXSr^ z*6-f88iSl5a~hK2(o;vN1X7kY%C+pn+@mG<+Sh*$kqemNO_;56Nb&BVxFyI3gD9MloVT1 zKpXWE5(^7%bu>(9Bv*j;%dYnh#cZrLn?>YG=HC?}M7Bn>91}`e#a%;O5cAk&TT75? zUk=m@AO~X78hIL$+X29JLTrm|m*dE&z*)t{r4y<`EJ4d!psss2FgBRE5YQy^0p>P9 zlWef=oxr_mpd-0+jaErd{!SJwMkm(3duiwgNq|MV)fxkICLg|_LC~Gd?e*mL2?7X= ztnoTyUQA-K0iVrL6hSbt?ihvsH!Rx=hmw|kQRQoXwyw(~55$BXGbm%B_a-33O4!?u zG*s*`L3m*a#Gm5ETQ69w-WK&^qG_z3et^X$cvP;4HPeK9E2)7VpgfG1piMsOeLWG` ztBta=I^4Z~>O`?^|A>KFLZ8cJR<%lz1NdUKkD=tb2u?68Da3Upn{H+1!dVOd)03O* zJ6?-3D*WbkW7EwFzgkd>4Otx@DGLc;1d)9f?8DM~4{8XJ<+;6*ku>ESqt1#6{>6Ra z&W3o_w?A)?lhMGK?Gz(BE+bjBMr*p542ah(x7u+zKEu!xgwW=;*^ic{cXrLpAdL#t ziyyZIFAALiv>wS-EU8T4%Ihhi#bUELXr@J1q0}QW);}M`Hkh~-6Os)k)0(8t$L`a1 zLUXK`Q-A_)5vh1!OL@xSWASlLb!dbyaspuzQZ+Ebvzn9Ed5F@zZpTzOD{|(4c{Fg> z2u)&{`cnFasxoFtRtd3Hogo9se`2KAG8*M3RHDm9%9U`G&$cC5M~nr%kQ|~w_tCz% zTvOlSVjS=PMm9<|NXWm;Q^t$iyS=C`zR8%rcc$1=p^AGMQ>({CuP) zrn>Nx$U?jXS7&Fi?1-hCW^#d*S94)-ftC|9Sv7r8y?R*ILf*EQJt4I+AzujDLy~}LyJqCv0 zX>>&KUY@;J2_W#oc8e~p>5?WXh-)!}-B>#_`AO#9UYhp}uk998#UhPEW*VxrR<3Yb z2GUBS^^>sYdg-&2OG%SnYITR`>)c9g_jzcIQpYSGXOPKoKz|n?eZ;vQt3(9&!?<_Z z!m(xKnHp3ZkrtG>2NzFRP|N%il6oOZwfy5L+EBz!s5)oP-GiCENw;Z#c8z(LD#s}XUOTtW;|L6V zC*bJw$w(1et5&MJZC`Q4pzfxcd(Jb0OLw{oC==%RKSVFz>Wf3`E(;V_Y$?p)W75h3 zxzkT)uOg9u{jcBiKUkOGIPGQytGe8_jq8f6#=4F#rpG%Vv(e3Fm0Sw#tIS{cKyGkE zg#DNfWJ)Awk|7)AiFdN{s+k2fw-w(5X>S|C5TMFV)Adw|VlJ#bn&bazQW#jvd)*AM8lX=($*PQ zqeq(zsV>W(#+cuRv&|cSqSqN(Z*Qmc8Y(z#adpLUasilzEZIfVgR39oT^^b6`JCBq$h;H#EsqNGPOZ ztdyf;w*4a#}EKsQb7z1$(vx zI?3vYDV91!AHb`CLZ!9B@lsn}g0t|4yCAPL_)6tib=ZG~Q39R21VB!E`OmlgAeZ3? z9ai;&rLPl@iPQnMljA`#{m`F`lpq{1uMGUd`mnY76~V|~+IQ3H36wj4?YMIPlMJBr z0*VIcYirjAT%Ziurb>-{0SZ6X3S|OHDlA(|{mVL#j5?MQb1xR5GrOzaOf)W)q&_~a z{{AQ>7tp}l|3BGI8`v0;gxhPD17^Gu%bT{Vn$6P0Ae%U4p70CdHgD>}BWCG*0ae z$@<7{2yC75>)%6Rfz@3Cs$O5}ta+Z4t3l-;nv(<5wG{}flKQYTtb@1fitFH-4Oz3- zu!CG3p+Y&pnwCu}B_K;9kT#aY`etQ9J>K_o;iT3Nsglt57|IH16wxnE7@ zxMa?ibQL^iE-iG6HW4kkjdvGi&w z5dFPUCZ7}wIF-+Ghp6n^c8{0S*ULLEY9wB3Uv1+`1oZPra@-F095h@tpv}G z(f%mY&XIg=auTTuf|gCbPRV0aESdfIx!?lO|I50gY4;=(x;Y+DF*F0Py}shccy#cHg!={tgU#03`Ka!NCuG$07<`hbEqGCIW<0FT`W7&n_TUU8885?pdI9 z_7E9g1qDb}<_D8mqYOf9Ki$cJ_c9S0Ydk$$8ybc)qtdr7UNIUv`xWpoaE4Z1lo6Qf zF#KB-SdLI2GlFDW`ZBrrxP}nzXERGT=^3AN$ONT-f0AF3s0I<@;ZwS z3`Vf~41Bw<6G}zlUHZI*0N9dKhm&zJx)2{~d1lsp=8BI^ z(n-1)29&3w7vMKq=(^3N(Sy)3+f!|LD&&+^re8xh^TP7JKw!sN2D@7( zQ=3N;%fZjje|wsx4(&eb_xP9gOPy7LoKf|9J^-h84<%Qn`)%K@5xeD{kA)V6WA#rl zEpOaBP0IqD{S&3)dvkiXopvDkgCjpQNxwM+xJ+pdw=y}RMy0$rTV-EV1?kk=taX?< z=KTXNR8B#Ufsb70aj?d|rTV_0d9?%WhS8$XU#kxQ?~@b!tsCt)C&M$$W}ZCxMyihX<3CzGS!4o72giUK|9o$=J#?S9=SaR z)w^igRMipICa6ohE%+w%|WSCAoIy!MLcE5y3Qcw8E+LLV&_cOgA=U^swIwn3Hsxrc#8wRfq_<(|vVHp_-iEs~Oo1*6#gx z*OgzW=GsDld-PH4NfC5*@GFb#kDrl^&RaFqb5hnp*|xl6j$-V6&fdh4Rgn~)y703x zG_B?O=tkdy3)Gz0B{h%%5}uX$`J|~a=yoqZt{kXDDFzA#Ce%zFq4QN97gTVn=bmIWU=K3P- zuh~JT&}00R=p5b}7S4jCrt09&O#zhTu@CYSz+{Ho*L9z6ryUh|u;7-QEkyBtQDfm% z;x7c8HP5h+P^5^^nHXpem}_1qapA6N(;ge2580qh8l>rlN2``X%x@G-={?M|J_n59 zai%sa)p$hZ8}*|xk+BGo`o{QJ?zj2*jzyxhrGlsPT(;<>n-R=DAp7C-(3Bz>c;d}a z+)ul_3a0m38!=y_+LYdRW1vhgUsA2j(}J% z2p|%t1}zeNdf;1hLjD>^u8SXDxu?ux96b3_i7HVz**dyGaibV}zJofT$cWw03vWSU zW#R6z6o#UQ=X7J1ctA~-y-@(7`o1hsh>fe5258USM7W=x+ED+8rHUc|5&6dZO+lM% zMXU5xzt`5~85Av?JQ1aOUiaA&$L9I&egjp5Q(GF6qM*6^mFGb^!; zV<%IDBt=-fkf<4D2u!@G$pBZ7H8)R`rZ=>-HZuqahmP780-%v}_vYec@}R@{g=GL8 zOir#Z_ZnIe6pBd#no~92;0WWZJy}_tRXTArs{&T2#komb8hX+%z_m?=eGv4+h8H#e zp&M7PuPZGsfD4YBEdF7nH)wIsP=;XXG67ofY10u>u$xkQ@JT zwh9kiTy-J}euZuRwNP*Uy&S^GZ5b*JSQ@iG07z4;y+=ShQC}{}4!QZw{8yQ5=-} zE91x>74VY=b|z{Z*wV2OU~DYxA0-vYat^*xq1hxb?abL9PvW(c<61iYC**2>ALDD} zC({NbL>TNJtSm!iz_1)kky06Yyd-0dG% zd7Ak3BX$TB-yd>B7oRwm!v2J|U0#|aZJjIA%)$O(5TzN+o z-f#sVc~up|tSUs~!IM6gqHcVU!>OMF*-hw^mj2^_MNv_ew^@*$7kzHDcL7Lr4kXH( zBTlLg1>jvK|2&RdJvXbbog*D7Gr$ozc^K-}GSk`O8$bT*KgdYWuNUIgGm(ycf!<}> z7!p4XV5V#aC1`*z<$XUL36kp#UT^d?06>Mc-C+^UQ^2N()lFgBTpLW9?Z-pmI>FS| zrxBl6eA-wV1@ty}NXydL{mb03FoQz?Skot$r+4>)3cKoJ1Ss|8Iz|Eb<_(Bbp^vTR zkB6bS*Z=cX(y<_W+?v_{v7+00bkqEvGeR zllsl%~FRv;m{%jfHV?_c68c1WMwwtI8XmDA^}f;1F^%cMtKyY-4s_>u1lq z>#WC8LZ>(~is2y5cYc!s>75`ijAA9!T#PrA@!+d;ACb#YiTR}+ccBTH+Z?V?s<7l^ z=DEucc~e*Jd-u-a4mcxZ{K0~NCuks)q8kv4HSE7r#07eBq5fro5(oyAuy=aT9NYuH z9$CMJlas6pZ&-?J8U`q56y!gFQ;R)ORP?=(Df;}ZX|?%vK^lUn`VXj*!rC5h6Q2V~ zWT>bA`o6JcFsjiFs2B<5;OfiGpH-aqwed%Yo-n=0f8PeP{livRF@hkS!ukupFH*u zA0_I;>$LI$r6Od|pD?GL!Vc3qw<8(OT{<+*-YIE~)CHN|Ln(Wr`T^@1?Xgi)a31`n z4(&X->y=1&CLUB^OyItY_#5%=^Ic{kyZmo63`o>W*ScMjDZUpAm#I&WTyNNm)0avu1%c4bag9Kova2!{WwD5I zYZ;}??c>0bM|%$RIa`W`3)uiPRqiv-iT(z02AJHn9c9>o>8Y_nHu7Kn;}vF5>tX37 zz`WxfIo+r)eKs}zEKe)y5;#p{^M(i2Bx6eMxi0O=vo;M6c|-my~* zE@g;3!|}dY0;ngCQFNJs9SHaRWZr;O704YWTeV|5hH#^Y01^mu&+w^s4gi(~j|ATH80~l-A~WY1F_dWoAhUZs6k@=J~mevd<7B~05z4p4Cjr%(zNjl2$X0nCI+ic>FiSb-c-j}6ut{YNSn>=TtA6W zlwACKi}`$tf+wwL>+QJ*qMKl^EodX}XL7Xtx^kB$;x`#!&M$XV z2@_Y^Q?O}B+4vyP$ymlNwDQ<76OdOgth1n9^+Hp4;;2NVcj0p4sk+2^y(&cEFh)#x~)! zkAO%q7X(pBd1*XgVpfcNq zO}G=;+s%;-E)2lLq%*>L9Czlh2T<>GZ<-|H@VG^kF7T4HJj)qDLqTlPfj;l$lN$y; z>Jg@($Rts37$`}Zo6;T`X>ewM*=Fu6v4LHIljo16sB22SX$H=nmlwbzkGTR0MAs_e z^qgN&9o9bq+{rE0_JAU{k%`NM%(q z=oL9YXbc~)C^Z8l2xy0Lh5(Tnxy0+?OB__pQE9<5x3#bNd#{O(!C+3AJ90tKan&YF zwyi^a>bfF?rGvop!X(Y0$sADe0GF@`M7eR69st+hBmvYM(D1XKvwL#%Q#irx867(^ ztS)FUUXu_pthhVUuXk$PhcY-46r~G7`&f?3Sl?v!c05S3sw~=fx*h4g$x9#y)USP! zQ5-J`-ebQx^v<$C%Z-yhnZNG~d|P_83HR(R?h97~5||y^($2vkJ24N02Mvp&=h)#D zs#p5q^BOO~)0+li28%wu^P>At0)Tm%-J|$|d$G6O5;lTuV)~&a#}yS^E`ek!lJuxs zTtr{XOYt%P<5)Q8!}4tBc2^PNUinjW76wyJ-I>Uj6{-AV$Hw&rrKJ@2B3DO8;lAYq zlAyfC=bAtrjwdosN7wdxh5m;OPX+P5Btn*6k89_I-J=k<;Yj%8Kl zL}Zth+6hJ)41(ZptPAjP259c}3g`Uo0BBzT*>xgmU+Hy#9)|)ND(Oaz@L-OL0=V~i zXZ6_hSl^!kB&;j&Y0Pd;pqWLvt4p!uYzaUp&p6Op4}tVVk^+S5*QypM=gS&L^=FcR zm6`>WH7GnWTipfweP>H7^BX8AA>YUMGZB*H(ttd>CejMMltD84Q;>UT9e2BhWgl(e z1Fn<51`~3%Ins@%LOTvr^{aw+mT9#B07ep`RH1IS9z zqGT9bb+_an#sF>7O(rR|>_@H+f@~K*0;n0? ztR1CB%gFcK<_O_{2)NgVSwo5BzF>>BYC;qS4KV7MpIOIdRCYO#pi;SJ-K@<>G8 zYENP4#33goBS|qztC8~aqpn!DCt!PiGCMoRXM>@efmECDX?92To_tt}t4I6xLH7pu zf|H$RRvU1d->e(Hi#ToAfv&2+0UU2!Ab40hw;sms*#T&q2)a^{~Qvl=Z27nCL z`?cO)JDhwwcUHzZX3=67&=eh@xeYk`pFuhuaks7;tE59JP03r7H$ z29NMC-zcaj%wYkTzHIogmU)a%vv3l?rgA&3kGlO19g*_R-crx5r?IG_VDeuLDIYF1 z;o{#-jGhR>JV5}3$aVpO&GLW*J}(AGX&onNCc2nu22YMeJ{tmPy{E)|b_OU=Gl;&q zv#aYbE}~E#Oh#vLu(6BTLrASL39E*!(|Pc9Jm0k8EHE57mi4Iw_-eo`8Uij zPTFza7~sSLhu_A@`%@`_7T0@2080KB#Y|&i&+0y_SNJmsX7HOCTX%-OZMbE(>W3K6 zT|4T~RJu_xry)p7vRCi#V%jhN#)?oE-Z%4hBAEQ;Bt~CfUFx*&8L)mGs9Yg-NI?QB zC+lnZFh<3hi<0p6U*BTG$;Uv%NNi9Csz5P4C*k|WLQI^kP!1Ayu3Jrp6k$U&^}LH@ z9ld@>I0U*1hpGp%+j@K@K~=ejllKHcL1E&0|1ShzZsLQ}1?o*}ykx>J?YT=BPHf&f z;4p`P3=ndDyB7=GWe1H76Xxf8_SiQ~>WWadU6@|j49L(fLvWNabw-$9ChbBRRz*;+ zMgz$B^F_|nx{vDzAe^hEBD$*B!g*wo0CcHHCcSl6X8}+=k9FT`c-ol5{mBYYL2xu1 zo9}E$Vun@kpN|j|T%9wI#Y=f`uSWuzx(L$e%N4&DU`C~+rR&oHj6r0-#=l|Dl{<^R z-+2`M=T?l^b4GE$HFT*+ah#(^1c*}FlxyZeI|DTK0jn0-r5cJPq8Q{TK;usag0+Ed z?5mR6zS$M2@CutJN#J)YW!>8MP~SC}Z^IfIx1&(oA}G(;=zFd4q==7j;VZ-hxKt48 z9Q51m*ieeE)C9M6L|Swsp2}krVAS;h^_WmlzM>1n7_#0oN23;beQuR1#lom_;Z= z;wDm|UbSl|AP1YD9?$)jw9$>qB^LhDl@dv~-v6Z37Tg(RBX%gEqAYPvhw2Ubp5H-G zYhQtsHMPXgiDLCKAfdZfi<9gPOU5jlqV!qo)c08!%#Vzv(zpza?KcZ9*`&=eeuGYMzC+K7S_bG&1&{g$r6B@&}qo8dVL?(St8NoIOvMCXe*#OBO!oLy;v73A4W@v zMFhQ(W+Bv@_+jaz(ohGu73IByH)+x78&%hx&H?uWdM?03 z1xM6N0{FEmD(3v5WUM|HNO7QmW4`&(^4>nW$4dZs*Y#m3Wh$`slsd?Z0}J&SD1wrh zNnj0hXaEJFl(l6>GB`IJr_B1RlPE|!*gZ6F)eX{keK=tj`DO`GEE zOWu?u+rFi#smq_Cabx&=0mO0gHfPtrU}ybLV8o53tGw=yVC_xdri)EZMk8X6n$DZ7 zei=Qybczzo^JaR=2HsO9hq~K!;0v-6oVuYqgwl6`U@Yne zi;u3DygRPm=@TmiP56Z%$A>v#C_i=h()bCH4c`~4I91du3)0sKi$3UT&!p`dKCI`{ zHqkSobU3(M03?z#;;*}1<8{gd*~y#}@%D{NK>hbYz90|dNYZS{NUD0Gh}u5nYO567 z>k}tr=o0smmV!pWW*HC~sO^$A%m{R|Zc2G?cJ{-ZURuA48&{Er%N~GWi4f*QX9Z9G zNxLvMWiRYGO$yB2Jm*Jyhdltts3e}+-#18}bKJvtLn6k5| zf~z-yyvMvsms z(N%{aE@Z21P2i{Pu%!v)^nt(x#_r*es+wB@ooj8NPdl=dTxm|JW{H{n7mQ71qlslm z81nc5ZnoxVLNCBD_HAH=2=Vn!y#X=s-2Im!1O!lCn`8mB$-A{>gkgpSWr=nN0?FYQ zRrc6$n7VoL67td5u)$bt4y6m3DepV zg(@Kmw%9NrZXq)O6pT!wNE(kkzbtZlA$-_}(Lw z_akm*?(1rY`DN{YrnG$c1GhF(vvutT4EEsXt@AfBX4REr!8EKs)YkAlLHtu^&~SyK z$10dw7dn?&8^RSHj)udG;&|2Y-oBFBl&hLIg<3*aD~Bn|9S*&N8$Mb#Sud%c?(*Rc zjZ>%Mi=BB`d91g0V)W$jrk4b3z7t8bH@`&uvmz_kt9lR8#psgDrHOl4Sq5;;o8cko z3oxTy9Fk2wBrOdD$~GTmVBor8=*}{Dgj#NDvFM{|lQMrOaKml}Wt}xqaiYJ?qv69s z0#7i@X7mg3LiC}nsY0bRoedtbcUq;hd_AL~ahrUTGMqJ2@$JZ^S#An(P*rTw~k{)|@z`Bfqdy9|h# zX_>YeR7*`~s=0T@+PxM2-F_0r6*KVT*y{*^b+>Ac#q_y1!#=YE6b#>X*EM$%Q2KGi8CXP&7)j=7AsY3%I4qBHA0L>tX-I#E2s@= zM2W(gx99RBe%kU|v}Ex778}Zvos{ydi6!`#_;py`)2M^Q+waF}y)&qU6E%1VYRE35 zFGD4-2~MV$h#hZUqEy}SGWk+zC!9N}cxCHbq|r-0b}RM|SRTX>8*A13$oE!mZqEdJ zo3y1eg*iDw6A)lq9EfV1b2*b{;CE{RnXz2HoiK+hyw@N+qXFk4pi zx>%84-?ESJa!tMWj>~m}o47GEml}p4#g2yez8p<~b>3%$WbOOJZ*9CYHX|KdnA}#W zu-0c=Gv<}$*H7JE%tyItcF3_%uf2)By^=83*f2%#(6Ikm!PmPC<-A_R(0ag3hU7C6 zNgSySUB!4_lzu-=5%JLQ5#i6^zkz4AcIq5zO|1=`Ls+yAT9`xU?EQ3mcd)bJ+uM`n zP07F>iqFc=N)uG|o!4TRn952+aCvrXX>9bS2z07{G(^BooQKz_6YwpE&x~i>g$bfR5Y4r+O(&Bp9`%rsQwjc*rbD< zGYp6)S?@GwnbA10_uaiccW`E`W|Yi$Op@0%@84mEB5bEmu{5Sy06EEw!Ci83*X&5W zM|zKZA;-Pam+f*(BY{1UMRDxLAeu923VsZ3_HA2%HCsG+$Ojw^U%*g86fvNlMZm`F z%v5OpOIeYm46L}MHp0im1~HFrC`15f9eFLEShY#2H9`dsnehOGe||82_IBSPijKT(jH9UD(qTMGhjYu=P2 zyrjBoY6b=Pq9#9d_419SHc>6T(_lI;&f==<+oR`90Z;IZUm}LgQwcc^9v9pxYu8s+ z7yS)6UQ-$y%J#>!50@^6_uj$91tk@Zplee%qZ2^mYD=*#1J*ZO$!D-+s;yBeeRKSh z%k(>*kghYgI&R;q8*v)GOFpNmnLEcU(Np+(HGL&-*Mld)?Q+;8b~zM0{;9`R&8`i; zim*z#3RFUDOlJ(uUsClKHo@wb^FbRybgM z1@awYs)q(of3?Jjp-*H$@ARBLQAjb6J7t1)UK45PembX zw4JaVNt-YPXfd-!Tdq%`A-<+<5>;x+=NDtz=~SP~{R1IwJtg~5c*O&7c@I9-9Vtq)n9aVX^@K0P5JC?Axc zFYA+)HpEedvNLE}_x1)U^Q})|{I-^;dT_9xI-;eG5aQ1;CA_RUPTpnv(C(7Y4fmO< z!+l4n9;OD)263v*@@83S+!9h!B>e?rMxB$8k|IP}@RVCAQ}&QpY%?~Fty3(>H|^)I z5(BRB72^H6f3_me{*1{KUFyo}AwGCx(yd;bdUAUK)vKmk`_Hl;(JfbR3Y|i}5aNYD z%P!K?z}%}_Y$W-lp)%r0j52r3Z~>-~op_{d>00cGMkL}l9#5kvuDgrNL1F4$;T%F` zzOe_1hWF5&QWlY&f2@>A{WhYkt=&CrzY;x)EfY@*S)Ft6IPYFXQC?YF5q{mZ{^J@UoEd2rAw{N#?@I5 z?S#h%I|uimKItNb_%rQ;>L}C0)9T}wie&hwnSt^NS(sd%^pII=!mm$;h|lbOd4q5bE7N+S8VTy8R^$b zoUZrOn-*dq1uRx&0Iblf`gzp+fQ&=yRgGC;%S*`9@-g;%b6*(li>U+l9B~h~-tNVa z_Z3P*!}xzMbt^l+u=RsL%v%->#%Mg!waSRZGL(&y|UuR#i#j+&Ye$FB^p+%yY74tZ1k z7e~x}m+prft{T>*PAlTZatsq=Gf<`#6YIusZOWv(2KuPHYwl0s-8MC*#I85>E#)<) zQ5I&D7xNgtz{VTz$1L7s&<9p8uavQJ6Zqec5r^BJA1tbxmvXScmnNxq@{O921bU;V z)N4bgG#1oB*x+~T(SY3GbN9`Q)ms_2990;<@yK4W@TQPeA;2!VNF%fb{jv=P8 z*D&#SS#%Faek;czG1PSu0tc+wMBojK?|fi04zcpdv7dzt=mf`(;23JpuY86JtwYtX zPBivz9B3g%R!-w|UDur@E2k~I>TrszMZqw%^m`02r6*E$x?=hu0J&z&libM~>%`#?oIDFvmal>{^%*Q+{F zk+_ccZp^8xEztnnb@_@T-r4^jlF4+@zWNXNEd+vp>cnDx)fYdOH06knsSJHUYkBys zN-?1+94U|=+2hbfsx7$!4WvhRpsc{)5jljY=1ukHkU0eK?|ZY-?38@d^dpjsG~9*a zf#Het(BR@8>NgP8=t9eCKBM?AWP{p;pTrrue1G|))|+Xx#U)B#MOLVT2l*Y3cg#tv z-=-=R&CDp1Z|cu*@XPv2ym47ps(fd;Rj(y+J+isT2`?P4i~LJjzk$ZBxw{e?k3R zq*UU3wqk7wbccuVmPfPwU#))Lkd|=V4PrZRz`YDQcvxa$@Eh;Elp^w9R>?b?Yu;20 zkLuiLPASI>h%2n^!w5FwgYEgiNO{jkhb9?6ZvC!=a_Th@Wz3>T#=S2CnB+irA7YUUSK@GQ}n4uG=iMC#q?FOhqnC; zGqOI^T7CT-`lMC%&6_2GjrQX}Wgko+-ODwENGGG=jFPV!0E)G``V>h`>UZwV-{>g( zbIXsY_Ke~}Q;d5%nr;AHcG18Y4y=dN;>$9epD`>V`31W{jGTu?5}P0atRHJ0Iml=K zEYahRHyNl#zZX~O9W1QMv?j0=-_od$eg;BNLcOP{W>-=RSf{n0-$^SgGc)3BRvP+< zfko*25ifW9pBh*0P?kvQ80rP>Xe_H?1n_o@PM)Nq4k=D`};%R6- z?{_kr^xkF+n8;`IvNCZ|gEfG*4c-~3zS@_B(0KJh%9oWk2%2qkfv%+FIdi0fn+nu5 zeV-g8+SKu8vY21OWmRO6W)NxTB6MzR%h6AC?(W`66vDaDe@W6zX(La2qe zTz#nU^;&H3UIr~Ng^>td+4UcZ~C}U24V4Km83%2`Rlq3 zAyX;yBrHdlv!fZ3@Ihk#st)b6Dg_i`OF_-|gpRoOh z1Pu`eU6^T#f_-0`Pt$PS46xU!kQ8P8MOWgDDAHd?XIZ%Tr|NK#ojeagMq}}%r!LSO z0eByI{>VBHhmwrXKpde~?FT8BqG9~3OmDP4TH@~F zYeWgTV~{1x7Yg31e^RE)VA9nC18xrwmZKcK^VdGuW~@e5q#4E?OmH*1`}ta2p#vFRoBC(PE(Hl^d?!9S(=+$E;4omu(z| zE0ZLmyyeoNYgQn0RWd&(>Z$f%G4`&ihtzV3!%l>)XoNiq+ES$ho zYU5ZEx$BJ^?p=C~h_~-0_PnW&GdzOr^D(iXP}b}~C)uYbMXTQOjX2opR2vEc*z`HW z+Tmll%^;ENp^a8ppGg4TLZYe*VVSKb@HG*7?;GztYIC2*jY zn;mAB=PT``F=NAPw?Pu@;!1D!@U-sxyHZ4|jJJ>e%IcIuW#zD=`U?5ri^lhF3WoDJ za@zNH_a5%!Zt=MLwnBL6;XHv*GuHiH1b2_%drA)_vP7OI#tknP40~^|gHMN)TS@qE<6E%{1}nT^jdzaxhx5*ba1<@LVM~5_Bq=~WzZQX#2J8aZ96!oJ@W%U z*>N0YH1x~^=$Y*tz(3|vj)b1c0?+&`y5$|yo;fN|v468o= zAA5w}e`RqBT-6WK&M+7=oi7hTRMs|z`N6lN{Ow8oK8t~0cqEXUgkk6Y`i~uhCxksU zO`!$Jfd%pWbG2FEM;^)Efrns6w}XQ1QDK)cztbobyvxs}c`KIr_T?dkSK<&6K!Zuw zsecap;43t2@gbPjZ91!MfVOhj6lSDPXHuO*5ao|Bz_QJ^l6l`IeUMm0n^cAorc?jV z71@Aa9`J^HF~JH${z-+dMQBBghhdhx|GC^kW9ZFLCRmo$KhMmn9eR`ZFib1*pL=Ns zKYI!oTNIX;^3O9%ZKiG13}&QCXQQ69jh+y&nB>r`M;CL906@^86B=t({bA^K5{I*Vt+JLT)ht2)F#O z$XuQ)Y7Cn1+7FHcHMG3lWf2EQ7n)+&Vmx7qalqX%X zMil<*aMKSi%ciJLgN*+b>)(SRI{fFy&jsTby~kX56YBNDcMd?eRYiiS|Mscg#bhZ0 zHuSfTR<$R0tI0oyY3=spr2F?zo=52YJ@5BvKnXh2MihF`{d<1lUAliiS7=55??v>- z57BC5(D^5QV458|bT=UUn+^R5u4M=85@0O&8+~A~OcQX=+M$1*O;&~m{Rs|Sra!@z z!}KROVI@I#f(M12hv<@;m&u7PiJpo+pt}Q`wyXQ;o_)Kf3B7^N@dMLM=Pb`r;NAEztJ;gzL+p@gl!k>R1Jl*%M@+PXZ)ipifUwsUHP;Xe(R>?gK6sbUov9cfN=7)wXSV<2K-cPQz3Hi~ z&FbXRb+wwy7RM8uRFUZArcoKq@^H5ekGVN7`~y-xdfEP{B(NA!ERA6v-1sh zHPapQR1Q^#J%!m`c$+6bJ^$6ZE2?BB#8mm|+I%tkN}`R#>idH{VWf;WU3{5{2lszp z_Vt36(#3nyWyz&O*Pe8gYQExMMuviSLvzU`qaNG(LMNO)$yPrtqr+Y)KkfG+ERU83 z4ql&IJ57{}z-@2WN`Z}@Un+r*h_B+VixGEw-*WpDMxv>zaP6(T{nOInF#br~cJj%p za)+12V$?qT%G>NiY!Ik-VlLpfA3BUz6~oOYTuoHmnmL)ML(iA*s5l|y-8GOv8epr7 z6?d9N#(>$g;Kd{MBMMe?$@;mF6X?&QR_a zDmhh}UdQPKS1r$`yJt4fyq#eJHElzl)t zgLBY6>J^>lY6%_|-=%Xqqpcv=Th zjCwHkR>|dtm2|0mCI|gLyqkI3F4$DAFw>d2t{~JB& z6vz#qUFnyeN`@3KkdOOS9z;Iq<>kbY@6xG+Jxxy;>7Zg}%Z0<_ti}WTNhdL4_w6dD zYu)*qy`9&vCow0s*|h2gJ@G7DZ+Sa&UwSUNbaE=Y-bo-8qF#YiJIC2b*>6lLn@5*P z$y_dgPX+7oGRSMFuF zDBn04R&F0`VLv_*YWV28jO;MIa6%in4J}dJt#8IF5~p)DX@ZJ&?=qL|_LccQ@lrKi z?f=fDX4dQLN~AfX%t^DgB8v|Nk_4I&IsAv1RHBz*x6u6Bgb$iT5Yk&19$i>%UqeX| z&~;CS@D5Z`tgOM7#uD$LKRU6n&BB93?>StdHo`;aAF~1z(kl3CbP!$oaTb zA4mF7AmIdzFVIlW*yLizj9$qbCA(qSBFd=Ob*7A>DcMoRQdJxs(__mac1?meNZm6&L`5ui?3_+W&ra z$~90Qj}m_@>ng%E<+`6rkvKfwR<1Txysa&j=_;xtwmcOR9!{hIFE`Gp&rLBT?~@)m zyb18ND)(sf7K-a*dCYYkORX#TH40<)5~|C-^^^5}E13=-Ijt$qP^%Za1cFw%IIVkB z@woJnZ&T4V{71aya^%Foe#SF&%j*f^=~ctez$9ut43$xO^ui^+KJJrNg!=x(lP(>+106+X0Al21L=6y=~C5Dx2Px7BQ2^67fw8wod z_6=hEF-1oN?!=-mZmAS&FSbj8gLQA}s@JlsFL8CRo=y%4 zzcuDc|MO_$;WW{SKZQbPjVg`L?lH6gx%{(BYhvGOK8&~NYCuQ*H}<*^;}qK^QZT8m$$rQxXLDX*4zN?yXRZ6$jXPVCCb*b zFrdDZKJzM04qqzZk$5u(U;aC{i)AH&`|fZOkk%eelWX;kjD<`l(n@&O{Vuxv3wVXJ zZ^gFcD7xIPQyNyavfGIixFJw$Re)DYwAp|=FNA(AiYIp!S)2q*W;?)95NN zFS+HhfFjS$ej!IhC`e zRz{8d4+Z9T5K_XXtwaaRL>^87Zuoy;WADmir$g`7nkk;dv`+*5^03=_{I5zrK-mr^ zOPOm^Wi?qOe4OZnukRLH>EP)qDSoVnSL#U51M{4o5^zoih66sf6`EqYu9a|)#MjYB z?u*_xKDv+jh>||(%l;~LevxN)ZUWXycJsZ_9kV6P0@-v;bg zY@`@{oLU&yArq|&O8=Sd7d%N4|MxMpV|cjgEX19fkumTuoECT)I2dE-T#P^F%>XE@ zA4ct?R531SZ96R7TJ)Gj{F$WT`g`dMuS!{6tET*%^t}3CFYpDqPO2IrzVw`rGC9}i zpgU2ul$;~9O?nhvT`}M1LSY{{^w7KoTTaA=Q+mQ~*Wx9ez?pJ7^!uOr=odX){AkEW zMc;7SfUVQDxWg_}P32#Ie`s!3MdIyePZ`RuHOlXr*Sz$j@Y|W19#2VTV4PW*~jvB@O)JMb?}=$HCL{8 z>p#2v@cJRwiP>)zUj7OpHYwr>$EiQmq?1E@dZMH0L=GEd#_(!gKFCq>y`Te zsc>4NI;GBG8%}UUjV|3BkFk9Jd z;t=L~-S$y3>e4;R_2q$z6JgzlDrgasazT<=C+Z*XL+%KD=Q z)On@1!5|Oq=$`JB%ez~t9~L+op?nLUz3HhbW_2a)hF=~Bv_yg3NP^PuK9OhJm<>(3 zMKUX5U;Z@l?!W66eJuM!BTL>K5D?Ew=5wJ0=uF6WW2^W)C;_tWpBhcV?c1)hSWXV& ze*1n(;&e|*vxMKi_n_k4v-&w!-0Z(xft)Z;0r8u!Ekqa$JO%EE)Tnxr zirgp{qawD;j zwCxLBrL2i&vG-EqEN>fNcjJlwVQ_D{gHO2uW$+OU&b5|}zWOcAbdtG>{*jbVhGl;3 zi86=Mh~Fc~LQ(n}ryr!np>`CS>l;Ty=s%j_>KFLPz@R=>|06sLK&|n`)Aa{FV+>;-~NQdg}X3+X6~9 z0W+ucEr)k9oBlCP-ERt}*Vk0v0aV^!8JJ*^Nj4_opYJJP~-h zbhWGZhb&72U8fq@8U5uyrCnc_f-GRt4}+8GE@jy5V-*sjA=TH*mK3@c0X>jgzh$_ z6Do1{plyccz!kppq4)s(ZHhaMbVG7ctZDx}wR@6zhq0T7+zlW$l)0T4`#s}xgub`v zAZvx29tX-7J8oAxy;|y3v$5Z`TcHFqTl#-idHjy~x?e}`Op9IbKhGH!xNEVuo?d-v zOb`gJ}(;C4QNj<@?u z?d`gmJ($c}nGWpA$Fd%)2)Z2WTCFqYdaCKriZ;de9*S^o@Ohad3foh@9Oy}w5IJIE8Bu6{1-oD!M< Dgx7|m literal 0 HcmV?d00001 From 8a613f6b3772d789349a00a946583039784cb6c3 Mon Sep 17 00:00:00 2001 From: Matthew Middlehurst Date: Wed, 27 Nov 2024 10:49:51 +0200 Subject: [PATCH 3/5] regression and segmentation api (#2399) Co-authored-by: Tony Bagnall --- aeon/regression/compose/__init__.py | 5 ++- aeon/regression/sklearn/__init__.py | 2 + aeon/regression/sklearn/_wrapper.py | 4 +- docs/api_reference/regression.rst | 66 +++++++++++++++-------------- docs/api_reference/segmentation.rst | 14 +++++- 5 files changed, 54 insertions(+), 37 deletions(-) diff --git a/aeon/regression/compose/__init__.py b/aeon/regression/compose/__init__.py index dcf2c29555..6601ecea8f 100644 --- a/aeon/regression/compose/__init__.py +++ b/aeon/regression/compose/__init__.py @@ -1,6 +1,9 @@ """Implement composite time series regression estimators.""" -__all__ = ["RegressorEnsemble", "RegressorPipeline"] +__all__ = [ + "RegressorEnsemble", + "RegressorPipeline", +] from aeon.regression.compose._ensemble import RegressorEnsemble from aeon.regression.compose._pipeline import RegressorPipeline diff --git a/aeon/regression/sklearn/__init__.py b/aeon/regression/sklearn/__init__.py index 07d058dab3..621030787a 100644 --- a/aeon/regression/sklearn/__init__.py +++ b/aeon/regression/sklearn/__init__.py @@ -2,6 +2,8 @@ __all__ = [ "RotationForestRegressor", + "SklearnRegressorWrapper", ] from aeon.regression.sklearn._rotation_forest_regressor import RotationForestRegressor +from aeon.regression.sklearn._wrapper import SklearnRegressorWrapper diff --git a/aeon/regression/sklearn/_wrapper.py b/aeon/regression/sklearn/_wrapper.py index caf00f15b7..4064beea8a 100644 --- a/aeon/regression/sklearn/_wrapper.py +++ b/aeon/regression/sklearn/_wrapper.py @@ -26,7 +26,7 @@ class SklearnRegressorWrapper(BaseRegressor): """ _tags = { - "X_inner_type": ["np-list", "numpy3D"], + "X_inner_type": "numpy2D", } def __init__(self, regressor, random_state=None): @@ -35,7 +35,7 @@ def __init__(self, regressor, random_state=None): super().__init__() - def _fit(self, X, y=None): + def _fit(self, X, y): self.regressor_ = _clone_estimator(self.regressor, self.random_state) self.regressor_.fit(X, y) return self diff --git a/docs/api_reference/regression.rst b/docs/api_reference/regression.rst index 882e986e73..c5efa287f5 100644 --- a/docs/api_reference/regression.rst +++ b/docs/api_reference/regression.rst @@ -9,26 +9,6 @@ All regressors in ``aeon``can be listed using the ``aeon.registry.all_estimators using ``estimator_types="regressor"``, optionally filtered by tags. Valid tags can be listed using ``aeon.registry.all_tags``. -Base ----- - -.. currentmodule:: aeon.regression.base - -.. autosummary:: - :toctree: auto_generated/ - :template: class.rst - - BaseRegressor - -.. currentmodule:: aeon.regression.deep_learning.base - -.. autosummary:: - :toctree: auto_generated/ - :template: class.rst - - BaseDeepRegressor - - Convolution-based ----------------- @@ -53,7 +33,6 @@ Deep learning :toctree: auto_generated/ :template: class.rst - BaseDeepRegressor TimeCNNRegressor EncoderRegressor FCNRegressor @@ -76,17 +55,6 @@ Distance-based KNeighborsTimeSeriesRegressor -Dummy ------ - -.. currentmodule:: aeon.regression - -.. autosummary:: - :toctree: auto_generated/ - :template: class.rst - - DummyRegressor - Feature-based -------------- @@ -128,6 +96,7 @@ Interval-based RandomIntervalRegressor RandomIntervalSpectralEnsembleRegressor TimeSeriesForestRegressor + QUANTRegressor Shapelet-based -------------- @@ -151,3 +120,36 @@ sklearn :template: class.rst RotationForestRegressor + SklearnRegressorWrapper + +Compose +------- + +.. currentmodule:: aeon.regression.compose + +.. autosummary:: + :toctree: auto_generated/ + :template: class.rst + + RegressorEnsemble + RegressorPipeline + +Base +---- + +.. currentmodule:: aeon.regression.base + +.. autosummary:: + :toctree: auto_generated/ + :template: class.rst + + BaseRegressor + DummyRegressor + +.. currentmodule:: aeon.regression.deep_learning.base + +.. autosummary:: + :toctree: auto_generated/ + :template: class.rst + + BaseDeepRegressor diff --git a/docs/api_reference/segmentation.rst b/docs/api_reference/segmentation.rst index 9f9706ba68..2e892be0d8 100644 --- a/docs/api_reference/segmentation.rst +++ b/docs/api_reference/segmentation.rst @@ -13,13 +13,23 @@ contains algorithms and tools for time series segmentation. :toctree: auto_generated/ :template: class.rst - BaseSegmenter BinSegmenter ClaSPSegmenter FLUSSSegmenter InformationGainSegmenter GreedyGaussianSegmenter - DummySegmenter EAggloSegmenter HMMSegmenter HidalgoSegmenter + RandomSegmenter + +Base +---- + +.. currentmodule:: aeon.segmentation.base + +.. autosummary:: + :toctree: auto_generated/ + :template: class.rst + + BaseSegmenter From 5a06ee0672832545db02a59108acf1d2388a6b1d Mon Sep 17 00:00:00 2001 From: Matthew Middlehurst Date: Wed, 27 Nov 2024 10:51:49 +0200 Subject: [PATCH 4/5] [DOC] Update API webpages to include new classes and functions (#2397) * api part 1 * imports * only takes 2d --- .../{estimators => _estimators}/__init__.py | 0 .../compose/__init__.py | 0 .../compose/collection_channel_ensemble.py | 0 .../compose/collection_ensemble.py | 0 .../compose/collection_pipeline.py | 0 .../hybrid/__init__.py | 2 +- .../hybrid/base_rist.py | 0 .../hybrid/tests/__init__.py | 0 .../hybrid/tests/test_base_rist.py | 0 .../interval_based/__init__.py | 2 +- .../interval_based/base_interval_forest.py | 0 .../interval_based/tests/__init__.py | 0 .../tests/test_base_interval_forest.py | 0 .../compose/_channel_ensemble.py | 2 +- aeon/classification/compose/_ensemble.py | 2 +- aeon/classification/compose/_pipeline.py | 2 +- aeon/classification/hybrid/_rist.py | 2 +- aeon/classification/interval_based/_cif.py | 2 +- aeon/classification/interval_based/_drcif.py | 2 +- .../interval_based/_interval_forest.py | 2 +- aeon/classification/interval_based/_rise.py | 2 +- aeon/classification/interval_based/_stsf.py | 2 +- aeon/classification/interval_based/_tsf.py | 2 +- aeon/classification/sklearn/__init__.py | 2 + aeon/classification/sklearn/_wrapper.py | 4 +- aeon/clustering/averaging/__init__.py | 1 + aeon/clustering/compose/_pipeline.py | 2 +- aeon/regression/compose/_ensemble.py | 2 +- aeon/regression/compose/_pipeline.py | 2 +- aeon/regression/hybrid/_rist.py | 2 +- aeon/regression/interval_based/_cif.py | 2 +- aeon/regression/interval_based/_drcif.py | 2 +- .../interval_based/_interval_forest.py | 2 +- aeon/regression/interval_based/_rise.py | 2 +- aeon/regression/interval_based/_tsf.py | 2 +- .../collection/compose/_pipeline.py | 2 +- docs/api_reference/anomaly_detection.rst | 18 ++++- docs/api_reference/base.rst | 1 + docs/api_reference/classification.rst | 11 +-- docs/api_reference/clustering.rst | 76 +++++++++++++++---- 40 files changed, 108 insertions(+), 49 deletions(-) rename aeon/base/{estimators => _estimators}/__init__.py (100%) rename aeon/base/{estimators => _estimators}/compose/__init__.py (100%) rename aeon/base/{estimators => _estimators}/compose/collection_channel_ensemble.py (100%) rename aeon/base/{estimators => _estimators}/compose/collection_ensemble.py (100%) rename aeon/base/{estimators => _estimators}/compose/collection_pipeline.py (100%) rename aeon/base/{estimators => _estimators}/hybrid/__init__.py (56%) rename aeon/base/{estimators => _estimators}/hybrid/base_rist.py (100%) rename aeon/base/{estimators => _estimators}/hybrid/tests/__init__.py (100%) rename aeon/base/{estimators => _estimators}/hybrid/tests/test_base_rist.py (100%) rename aeon/base/{estimators => _estimators}/interval_based/__init__.py (52%) rename aeon/base/{estimators => _estimators}/interval_based/base_interval_forest.py (100%) rename aeon/base/{estimators => _estimators}/interval_based/tests/__init__.py (100%) rename aeon/base/{estimators => _estimators}/interval_based/tests/test_base_interval_forest.py (100%) diff --git a/aeon/base/estimators/__init__.py b/aeon/base/_estimators/__init__.py similarity index 100% rename from aeon/base/estimators/__init__.py rename to aeon/base/_estimators/__init__.py diff --git a/aeon/base/estimators/compose/__init__.py b/aeon/base/_estimators/compose/__init__.py similarity index 100% rename from aeon/base/estimators/compose/__init__.py rename to aeon/base/_estimators/compose/__init__.py diff --git a/aeon/base/estimators/compose/collection_channel_ensemble.py b/aeon/base/_estimators/compose/collection_channel_ensemble.py similarity index 100% rename from aeon/base/estimators/compose/collection_channel_ensemble.py rename to aeon/base/_estimators/compose/collection_channel_ensemble.py diff --git a/aeon/base/estimators/compose/collection_ensemble.py b/aeon/base/_estimators/compose/collection_ensemble.py similarity index 100% rename from aeon/base/estimators/compose/collection_ensemble.py rename to aeon/base/_estimators/compose/collection_ensemble.py diff --git a/aeon/base/estimators/compose/collection_pipeline.py b/aeon/base/_estimators/compose/collection_pipeline.py similarity index 100% rename from aeon/base/estimators/compose/collection_pipeline.py rename to aeon/base/_estimators/compose/collection_pipeline.py diff --git a/aeon/base/estimators/hybrid/__init__.py b/aeon/base/_estimators/hybrid/__init__.py similarity index 56% rename from aeon/base/estimators/hybrid/__init__.py rename to aeon/base/_estimators/hybrid/__init__.py index 642a5cc0bc..61eabb879a 100644 --- a/aeon/base/estimators/hybrid/__init__.py +++ b/aeon/base/_estimators/hybrid/__init__.py @@ -2,4 +2,4 @@ __all__ = ["BaseRIST"] -from aeon.base.estimators.hybrid.base_rist import BaseRIST +from aeon.base._estimators.hybrid.base_rist import BaseRIST diff --git a/aeon/base/estimators/hybrid/base_rist.py b/aeon/base/_estimators/hybrid/base_rist.py similarity index 100% rename from aeon/base/estimators/hybrid/base_rist.py rename to aeon/base/_estimators/hybrid/base_rist.py diff --git a/aeon/base/estimators/hybrid/tests/__init__.py b/aeon/base/_estimators/hybrid/tests/__init__.py similarity index 100% rename from aeon/base/estimators/hybrid/tests/__init__.py rename to aeon/base/_estimators/hybrid/tests/__init__.py diff --git a/aeon/base/estimators/hybrid/tests/test_base_rist.py b/aeon/base/_estimators/hybrid/tests/test_base_rist.py similarity index 100% rename from aeon/base/estimators/hybrid/tests/test_base_rist.py rename to aeon/base/_estimators/hybrid/tests/test_base_rist.py diff --git a/aeon/base/estimators/interval_based/__init__.py b/aeon/base/_estimators/interval_based/__init__.py similarity index 52% rename from aeon/base/estimators/interval_based/__init__.py rename to aeon/base/_estimators/interval_based/__init__.py index 4a65216eed..7520551bb1 100644 --- a/aeon/base/estimators/interval_based/__init__.py +++ b/aeon/base/_estimators/interval_based/__init__.py @@ -2,4 +2,4 @@ __all__ = ["BaseIntervalForest"] -from aeon.base.estimators.interval_based.base_interval_forest import BaseIntervalForest +from aeon.base._estimators.interval_based.base_interval_forest import BaseIntervalForest diff --git a/aeon/base/estimators/interval_based/base_interval_forest.py b/aeon/base/_estimators/interval_based/base_interval_forest.py similarity index 100% rename from aeon/base/estimators/interval_based/base_interval_forest.py rename to aeon/base/_estimators/interval_based/base_interval_forest.py diff --git a/aeon/base/estimators/interval_based/tests/__init__.py b/aeon/base/_estimators/interval_based/tests/__init__.py similarity index 100% rename from aeon/base/estimators/interval_based/tests/__init__.py rename to aeon/base/_estimators/interval_based/tests/__init__.py diff --git a/aeon/base/estimators/interval_based/tests/test_base_interval_forest.py b/aeon/base/_estimators/interval_based/tests/test_base_interval_forest.py similarity index 100% rename from aeon/base/estimators/interval_based/tests/test_base_interval_forest.py rename to aeon/base/_estimators/interval_based/tests/test_base_interval_forest.py diff --git a/aeon/classification/compose/_channel_ensemble.py b/aeon/classification/compose/_channel_ensemble.py index a1ddc71e81..3605debe32 100644 --- a/aeon/classification/compose/_channel_ensemble.py +++ b/aeon/classification/compose/_channel_ensemble.py @@ -10,7 +10,7 @@ import numpy as np from sklearn.utils import check_random_state -from aeon.base.estimators.compose.collection_channel_ensemble import ( +from aeon.base._estimators.compose.collection_channel_ensemble import ( BaseCollectionChannelEnsemble, ) from aeon.classification.base import BaseClassifier diff --git a/aeon/classification/compose/_ensemble.py b/aeon/classification/compose/_ensemble.py index d409adaab7..af9d31f9aa 100644 --- a/aeon/classification/compose/_ensemble.py +++ b/aeon/classification/compose/_ensemble.py @@ -7,7 +7,7 @@ import numpy as np from sklearn.utils import check_random_state -from aeon.base.estimators.compose.collection_ensemble import BaseCollectionEnsemble +from aeon.base._estimators.compose.collection_ensemble import BaseCollectionEnsemble from aeon.classification.base import BaseClassifier from aeon.classification.sklearn._wrapper import SklearnClassifierWrapper from aeon.utils.sklearn import is_sklearn_classifier diff --git a/aeon/classification/compose/_pipeline.py b/aeon/classification/compose/_pipeline.py index e917b6a088..b7473965bb 100644 --- a/aeon/classification/compose/_pipeline.py +++ b/aeon/classification/compose/_pipeline.py @@ -4,7 +4,7 @@ __all__ = ["ClassifierPipeline"] -from aeon.base.estimators.compose.collection_pipeline import BaseCollectionPipeline +from aeon.base._estimators.compose.collection_pipeline import BaseCollectionPipeline from aeon.classification.base import BaseClassifier diff --git a/aeon/classification/hybrid/_rist.py b/aeon/classification/hybrid/_rist.py index f098a6b9c6..87c01e940a 100644 --- a/aeon/classification/hybrid/_rist.py +++ b/aeon/classification/hybrid/_rist.py @@ -6,7 +6,7 @@ from sklearn.ensemble import ExtraTreesClassifier from sklearn.preprocessing import FunctionTransformer -from aeon.base.estimators.hybrid import BaseRIST +from aeon.base._estimators.hybrid import BaseRIST from aeon.classification import BaseClassifier from aeon.utils.numba.general import first_order_differences_3d diff --git a/aeon/classification/interval_based/_cif.py b/aeon/classification/interval_based/_cif.py index c46a23dc9a..b974698f63 100644 --- a/aeon/classification/interval_based/_cif.py +++ b/aeon/classification/interval_based/_cif.py @@ -8,7 +8,7 @@ import numpy as np -from aeon.base.estimators.interval_based import BaseIntervalForest +from aeon.base._estimators.interval_based import BaseIntervalForest from aeon.classification import BaseClassifier from aeon.classification.sklearn import ContinuousIntervalTree from aeon.transformations.collection.feature_based import Catch22 diff --git a/aeon/classification/interval_based/_drcif.py b/aeon/classification/interval_based/_drcif.py index 64780842ef..bf378595d5 100644 --- a/aeon/classification/interval_based/_drcif.py +++ b/aeon/classification/interval_based/_drcif.py @@ -10,7 +10,7 @@ import numpy as np from sklearn.preprocessing import FunctionTransformer -from aeon.base.estimators.interval_based import BaseIntervalForest +from aeon.base._estimators.interval_based import BaseIntervalForest from aeon.classification.base import BaseClassifier from aeon.classification.sklearn._continuous_interval_tree import ContinuousIntervalTree from aeon.transformations.collection import PeriodogramTransformer diff --git a/aeon/classification/interval_based/_interval_forest.py b/aeon/classification/interval_based/_interval_forest.py index 9cf6f33d43..ae41ec821a 100644 --- a/aeon/classification/interval_based/_interval_forest.py +++ b/aeon/classification/interval_based/_interval_forest.py @@ -5,7 +5,7 @@ import numpy as np -from aeon.base.estimators.interval_based.base_interval_forest import BaseIntervalForest +from aeon.base._estimators.interval_based.base_interval_forest import BaseIntervalForest from aeon.classification.base import BaseClassifier diff --git a/aeon/classification/interval_based/_rise.py b/aeon/classification/interval_based/_rise.py index 0542f39428..50f15fe522 100644 --- a/aeon/classification/interval_based/_rise.py +++ b/aeon/classification/interval_based/_rise.py @@ -5,7 +5,7 @@ import numpy as np -from aeon.base.estimators.interval_based.base_interval_forest import BaseIntervalForest +from aeon.base._estimators.interval_based.base_interval_forest import BaseIntervalForest from aeon.classification import BaseClassifier from aeon.classification.sklearn import ContinuousIntervalTree from aeon.transformations.collection import ( diff --git a/aeon/classification/interval_based/_stsf.py b/aeon/classification/interval_based/_stsf.py index 4642be7e11..050be994d3 100644 --- a/aeon/classification/interval_based/_stsf.py +++ b/aeon/classification/interval_based/_stsf.py @@ -11,7 +11,7 @@ import numpy as np from sklearn.preprocessing import FunctionTransformer -from aeon.base.estimators.interval_based.base_interval_forest import BaseIntervalForest +from aeon.base._estimators.interval_based.base_interval_forest import BaseIntervalForest from aeon.classification.base import BaseClassifier from aeon.transformations.collection import PeriodogramTransformer from aeon.utils.numba.general import first_order_differences_3d diff --git a/aeon/classification/interval_based/_tsf.py b/aeon/classification/interval_based/_tsf.py index ae827c6950..238e3c4dce 100644 --- a/aeon/classification/interval_based/_tsf.py +++ b/aeon/classification/interval_based/_tsf.py @@ -8,7 +8,7 @@ import numpy as np -from aeon.base.estimators.interval_based.base_interval_forest import BaseIntervalForest +from aeon.base._estimators.interval_based.base_interval_forest import BaseIntervalForest from aeon.classification import BaseClassifier from aeon.classification.sklearn import ContinuousIntervalTree diff --git a/aeon/classification/sklearn/__init__.py b/aeon/classification/sklearn/__init__.py index 2dd7eb6f0c..b008132d72 100644 --- a/aeon/classification/sklearn/__init__.py +++ b/aeon/classification/sklearn/__init__.py @@ -3,9 +3,11 @@ __all__ = [ "RotationForestClassifier", "ContinuousIntervalTree", + "SklearnClassifierWrapper", ] from aeon.classification.sklearn._continuous_interval_tree import ContinuousIntervalTree from aeon.classification.sklearn._rotation_forest_classifier import ( RotationForestClassifier, ) +from aeon.classification.sklearn._wrapper import SklearnClassifierWrapper diff --git a/aeon/classification/sklearn/_wrapper.py b/aeon/classification/sklearn/_wrapper.py index 889f181108..69b2b2b51c 100644 --- a/aeon/classification/sklearn/_wrapper.py +++ b/aeon/classification/sklearn/_wrapper.py @@ -26,7 +26,7 @@ class SklearnClassifierWrapper(BaseClassifier): """ _tags = { - "X_inner_type": ["np-list", "numpy3D"], + "X_inner_type": "numpy2D", } def __init__(self, classifier, random_state=None): @@ -35,7 +35,7 @@ def __init__(self, classifier, random_state=None): super().__init__() - def _fit(self, X, y=None): + def _fit(self, X, y): self.classifier_ = _clone_estimator(self.classifier, self.random_state) self.classifier_.fit(X, y) return self diff --git a/aeon/clustering/averaging/__init__.py b/aeon/clustering/averaging/__init__.py index c4a4c014b1..d5d12fb99d 100644 --- a/aeon/clustering/averaging/__init__.py +++ b/aeon/clustering/averaging/__init__.py @@ -8,6 +8,7 @@ "VALID_BA_METRICS", "shift_invariant_average", ] + from aeon.clustering.averaging._averaging import mean_average from aeon.clustering.averaging._ba_petitjean import petitjean_barycenter_average from aeon.clustering.averaging._ba_subgradient import subgradient_barycenter_average diff --git a/aeon/clustering/compose/_pipeline.py b/aeon/clustering/compose/_pipeline.py index eb6c255806..fef3f87e0b 100644 --- a/aeon/clustering/compose/_pipeline.py +++ b/aeon/clustering/compose/_pipeline.py @@ -4,7 +4,7 @@ __all__ = ["ClustererPipeline"] -from aeon.base.estimators.compose.collection_pipeline import BaseCollectionPipeline +from aeon.base._estimators.compose.collection_pipeline import BaseCollectionPipeline from aeon.clustering import BaseClusterer diff --git a/aeon/regression/compose/_ensemble.py b/aeon/regression/compose/_ensemble.py index 14d3f837bb..2b0333ef15 100644 --- a/aeon/regression/compose/_ensemble.py +++ b/aeon/regression/compose/_ensemble.py @@ -6,7 +6,7 @@ import numpy as np -from aeon.base.estimators.compose.collection_ensemble import BaseCollectionEnsemble +from aeon.base._estimators.compose.collection_ensemble import BaseCollectionEnsemble from aeon.regression import BaseRegressor from aeon.regression.sklearn._wrapper import SklearnRegressorWrapper from aeon.utils.sklearn import is_sklearn_regressor diff --git a/aeon/regression/compose/_pipeline.py b/aeon/regression/compose/_pipeline.py index 3d161bf5df..6f78f21be3 100644 --- a/aeon/regression/compose/_pipeline.py +++ b/aeon/regression/compose/_pipeline.py @@ -3,7 +3,7 @@ __maintainer__ = ["MatthewMiddlehurst"] __all__ = ["RegressorPipeline"] -from aeon.base.estimators.compose.collection_pipeline import BaseCollectionPipeline +from aeon.base._estimators.compose.collection_pipeline import BaseCollectionPipeline from aeon.regression.base import BaseRegressor diff --git a/aeon/regression/hybrid/_rist.py b/aeon/regression/hybrid/_rist.py index 15e0f763fb..ca0d78e056 100644 --- a/aeon/regression/hybrid/_rist.py +++ b/aeon/regression/hybrid/_rist.py @@ -1,7 +1,7 @@ from sklearn.ensemble import ExtraTreesRegressor from sklearn.preprocessing import FunctionTransformer -from aeon.base.estimators.hybrid import BaseRIST +from aeon.base._estimators.hybrid import BaseRIST from aeon.regression import BaseRegressor from aeon.utils.numba.general import first_order_differences_3d diff --git a/aeon/regression/interval_based/_cif.py b/aeon/regression/interval_based/_cif.py index 6892e83051..1007648ab6 100644 --- a/aeon/regression/interval_based/_cif.py +++ b/aeon/regression/interval_based/_cif.py @@ -5,7 +5,7 @@ import numpy as np -from aeon.base.estimators.interval_based import BaseIntervalForest +from aeon.base._estimators.interval_based import BaseIntervalForest from aeon.regression import BaseRegressor from aeon.transformations.collection.feature_based import Catch22 from aeon.utils.numba.stats import row_mean, row_slope, row_std diff --git a/aeon/regression/interval_based/_drcif.py b/aeon/regression/interval_based/_drcif.py index 6247d682f3..c1f3b68afb 100644 --- a/aeon/regression/interval_based/_drcif.py +++ b/aeon/regression/interval_based/_drcif.py @@ -7,7 +7,7 @@ import numpy as np from sklearn.preprocessing import FunctionTransformer -from aeon.base.estimators.interval_based import BaseIntervalForest +from aeon.base._estimators.interval_based import BaseIntervalForest from aeon.regression import BaseRegressor from aeon.transformations.collection import PeriodogramTransformer from aeon.transformations.collection.feature_based import Catch22 diff --git a/aeon/regression/interval_based/_interval_forest.py b/aeon/regression/interval_based/_interval_forest.py index c155a23271..c8e555a6af 100644 --- a/aeon/regression/interval_based/_interval_forest.py +++ b/aeon/regression/interval_based/_interval_forest.py @@ -5,7 +5,7 @@ import numpy as np -from aeon.base.estimators.interval_based.base_interval_forest import BaseIntervalForest +from aeon.base._estimators.interval_based.base_interval_forest import BaseIntervalForest from aeon.regression.base import BaseRegressor diff --git a/aeon/regression/interval_based/_rise.py b/aeon/regression/interval_based/_rise.py index b9999a4305..f9608b3465 100644 --- a/aeon/regression/interval_based/_rise.py +++ b/aeon/regression/interval_based/_rise.py @@ -5,7 +5,7 @@ import numpy as np -from aeon.base.estimators.interval_based.base_interval_forest import BaseIntervalForest +from aeon.base._estimators.interval_based.base_interval_forest import BaseIntervalForest from aeon.regression import BaseRegressor from aeon.transformations.collection import ( AutocorrelationFunctionTransformer, diff --git a/aeon/regression/interval_based/_tsf.py b/aeon/regression/interval_based/_tsf.py index b75982a7f2..5ff656decf 100644 --- a/aeon/regression/interval_based/_tsf.py +++ b/aeon/regression/interval_based/_tsf.py @@ -8,7 +8,7 @@ import numpy as np -from aeon.base.estimators.interval_based.base_interval_forest import BaseIntervalForest +from aeon.base._estimators.interval_based.base_interval_forest import BaseIntervalForest from aeon.regression import BaseRegressor diff --git a/aeon/transformations/collection/compose/_pipeline.py b/aeon/transformations/collection/compose/_pipeline.py index d4c57b4957..796450706d 100644 --- a/aeon/transformations/collection/compose/_pipeline.py +++ b/aeon/transformations/collection/compose/_pipeline.py @@ -4,7 +4,7 @@ __all__ = ["CollectionTransformerPipeline"] -from aeon.base.estimators.compose.collection_pipeline import BaseCollectionPipeline +from aeon.base._estimators.compose.collection_pipeline import BaseCollectionPipeline from aeon.transformations.collection import BaseCollectionTransformer from aeon.transformations.collection.compose import CollectionId diff --git a/docs/api_reference/anomaly_detection.rst b/docs/api_reference/anomaly_detection.rst index b6be5ffc71..92084f4c72 100644 --- a/docs/api_reference/anomaly_detection.rst +++ b/docs/api_reference/anomaly_detection.rst @@ -69,16 +69,26 @@ Detectors :toctree: auto_generated/ :template: class.rst - CBLOF COPOD DWT_MLEAD IsolationForest - LOF KMeansAD LeftSTAMPi + LOF MERLIN + OneClassSVM PyODAdapter - STRAY STOMP - OneClassSVM + STRAY + +Base +---- + +.. currentmodule:: aeon.anomaly_detection.base + +.. autosummary:: + :toctree: auto_generated/ + :template: class.rst + + BaseAnomalyDetector diff --git a/docs/api_reference/base.rst b/docs/api_reference/base.rst index 3d06b37103..9c315fec4c 100644 --- a/docs/api_reference/base.rst +++ b/docs/api_reference/base.rst @@ -17,3 +17,4 @@ Base classes BaseAeonEstimator BaseCollectionEstimator BaseSeriesEstimator + ComposableEstimatorMixin diff --git a/docs/api_reference/classification.rst b/docs/api_reference/classification.rst index 8d3f43d863..4f29f61692 100644 --- a/docs/api_reference/classification.rst +++ b/docs/api_reference/classification.rst @@ -34,7 +34,6 @@ Deep learning :toctree: auto_generated/ :template: class.rst - BaseDeepClassifier TimeCNNClassifier EncoderClassifier FCNClassifier @@ -59,6 +58,7 @@ Dictionary-based ContractableBOSS IndividualBOSS IndividualTDE + MrSEQLClassifier MrSQMClassifier MUSE REDCOMETS @@ -140,6 +140,7 @@ Shapelet-based LearningShapeletClassifier RDSTClassifier SASTClassifier + RSASTClassifier ShapeletTransformClassifier sklearn @@ -153,6 +154,7 @@ sklearn ContinuousIntervalTree RotationForestClassifier + SklearnClassifierWrapper Early classification -------------------- @@ -179,13 +181,6 @@ Ordinal classification IndividualOrdinalTDE OrdinalTDE - -.. autosummary:: - :toctree: auto_generated/ - :template: function.rst - - histogram_intersection - Composition ----------- diff --git a/docs/api_reference/clustering.rst b/docs/api_reference/clustering.rst index c519406495..b2f8442ee3 100644 --- a/docs/api_reference/clustering.rst +++ b/docs/api_reference/clustering.rst @@ -9,6 +9,24 @@ All clusterers in `aeon` can be listed using the `aeon.registry.all_estimators` utility, using `estimator_types="clusterer"`, optionally filtered by tags. Valid tags can be listed using `aeon.registry.all_tags`. +Clustering Algorithms +--------------------- + +.. currentmodule:: aeon.clustering + +.. autosummary:: + :toctree: auto_generated/ + :template: class.rst + + TimeSeriesKMeans + TimeSeriesKMedoids + TimeSeriesKShape + TimeSeriesKernelKMeans + TimeSeriesCLARA + TimeSeriesCLARANS + ElasticSOM + KSpectralCentroid + Deep learning ------------- @@ -18,28 +36,51 @@ Deep learning :toctree: auto_generated/ :template: class.rst - BaseDeepClusterer AEFCNClusterer AEResNetClusterer + AEDCNNClusterer + AEDRNNClusterer + AEAttentionBiGRUClusterer + AEBiGRUClusterer -Clustering Algorithms ---------------------- +Feature-based +------------- -.. currentmodule:: aeon.clustering +.. currentmodule:: aeon.clustering.feature_based .. autosummary:: :toctree: auto_generated/ :template: class.rst - TimeSeriesKMeans - TimeSeriesKMedoids - TimeSeriesKShapes - TimeSeriesKShape - TimeSeriesKernelKMeans - TimeSeriesCLARA - TimeSeriesCLARANS - ElasticSOM - KSpectralCentroid + Catch22Clusterer + SummaryClusterer + TSFreshClusterer + +Compose +------- + +.. currentmodule:: aeon.clustering.compose + +.. autosummary:: + :toctree: auto_generated/ + :template: class.rst + + ClustererPipeline + +Averaging +--------- + +.. currentmodule:: aeon.clustering.averaging + +.. autosummary:: + :toctree: auto_generated/ + :template: function.rst + + elastic_barycenter_average + mean_average + petitjean_barycenter_average + subgradient_barycenter_average + shift_invariant_average Base ---- @@ -51,3 +92,12 @@ Base :template: class.rst BaseClusterer + DummyClusterer + +.. currentmodule:: aeon.clustering.deep_learning + +.. autosummary:: + :toctree: auto_generated/ + :template: class.rst + + BaseDeepClusterer From 2ae9b842f039422a470d44ad8e23bb1711774a41 Mon Sep 17 00:00:00 2001 From: Matthew Middlehurst Date: Wed, 27 Nov 2024 10:54:27 +0200 Subject: [PATCH 5/5] [DOC] Imports and api for visualisation and transformers (#2404) * transformation and vis api * notebook --- aeon/transformations/collection/__init__.py | 6 --- aeon/visualisation/__init__.py | 9 ++++ .../{deep_learning => }/_network_plot.py | 0 .../estimator/deep_learning/__init__.py | 5 -- .../estimator/deep_learning/tests/__init__.py | 1 - aeon/visualisation/results/__init__.py | 4 -- docs/api_reference/transformations.rst | 51 +++++++++---------- docs/api_reference/visualisation.rst | 21 +++++--- examples/visualisation/plotting_results.ipynb | 8 +-- 9 files changed, 51 insertions(+), 54 deletions(-) rename aeon/visualisation/estimator/{deep_learning => }/_network_plot.py (100%) delete mode 100644 aeon/visualisation/estimator/deep_learning/__init__.py delete mode 100644 aeon/visualisation/estimator/deep_learning/tests/__init__.py diff --git a/aeon/transformations/collection/__init__.py b/aeon/transformations/collection/__init__.py index 2f5cd4c4c5..11ccc604b0 100644 --- a/aeon/transformations/collection/__init__.py +++ b/aeon/transformations/collection/__init__.py @@ -8,8 +8,6 @@ "ARCoefficientTransformer", "Centerer", "DownsampleTransformer", - "ElbowClassSum", - "ElbowClassPairwise", "DWTTransformer", "HOG1DTransformer", "MatrixProfile", @@ -39,7 +37,3 @@ from aeon.transformations.collection._slope import SlopeTransformer from aeon.transformations.collection._truncate import Truncator from aeon.transformations.collection.base import BaseCollectionTransformer -from aeon.transformations.collection.channel_selection import ( - ElbowClassPairwise, - ElbowClassSum, -) diff --git a/aeon/visualisation/__init__.py b/aeon/visualisation/__init__.py index 19d7d36e94..5276381d11 100644 --- a/aeon/visualisation/__init__.py +++ b/aeon/visualisation/__init__.py @@ -18,17 +18,25 @@ "plot_scatter_predictions", "plot_pairwise_scatter", "plot_score_vs_time_scatter", + "create_multi_comparison_matrix", # Estimator plotting "plot_series_with_profiles", "plot_cluster_algorithm", "plot_temporal_importance_curves", + "plot_network", "ShapeletVisualizer", "ShapeletTransformerVisualizer", "ShapeletClassifierVisualizer", + # Distance plotting + "plot_pairwise_distance_matrix", ] +from aeon.visualisation.distances._pairwise_distance_matrix import ( + plot_pairwise_distance_matrix, +) from aeon.visualisation.estimator._clasp import plot_series_with_profiles from aeon.visualisation.estimator._clustering import plot_cluster_algorithm +from aeon.visualisation.estimator._network_plot import plot_network from aeon.visualisation.estimator._shapelets import ( ShapeletClassifierVisualizer, ShapeletTransformerVisualizer, @@ -43,6 +51,7 @@ ) from aeon.visualisation.results._boxplot import plot_boxplot from aeon.visualisation.results._critical_difference import plot_critical_difference +from aeon.visualisation.results._mcm import create_multi_comparison_matrix from aeon.visualisation.results._scatter import ( plot_pairwise_scatter, plot_scatter_predictions, diff --git a/aeon/visualisation/estimator/deep_learning/_network_plot.py b/aeon/visualisation/estimator/_network_plot.py similarity index 100% rename from aeon/visualisation/estimator/deep_learning/_network_plot.py rename to aeon/visualisation/estimator/_network_plot.py diff --git a/aeon/visualisation/estimator/deep_learning/__init__.py b/aeon/visualisation/estimator/deep_learning/__init__.py deleted file mode 100644 index 6b5cb525c2..0000000000 --- a/aeon/visualisation/estimator/deep_learning/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Deep learning visualization module.""" - -__all__ = ["plot_network"] - -from aeon.visualisation.estimator.deep_learning._network_plot import plot_network diff --git a/aeon/visualisation/estimator/deep_learning/tests/__init__.py b/aeon/visualisation/estimator/deep_learning/tests/__init__.py deleted file mode 100644 index 1fa771e563..0000000000 --- a/aeon/visualisation/estimator/deep_learning/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Plotting for deep learners tests.""" diff --git a/aeon/visualisation/results/__init__.py b/aeon/visualisation/results/__init__.py index 1429d8416d..264582fc5d 100644 --- a/aeon/visualisation/results/__init__.py +++ b/aeon/visualisation/results/__init__.py @@ -1,5 +1 @@ """Plotting tools for estimator results.""" - -__all__ = ["create_multi_comparison_matrix"] - -from aeon.visualisation.results._mcm import create_multi_comparison_matrix diff --git a/docs/api_reference/transformations.rst b/docs/api_reference/transformations.rst index 02ea16d5c8..fa3184af7b 100644 --- a/docs/api_reference/transformations.rst +++ b/docs/api_reference/transformations.rst @@ -15,14 +15,6 @@ All transformers in `aeon` can be listed using the `aeon.registry Collection transformers ----------------------- -.. currentmodule:: aeon.transformations.collection.base - -.. autosummary:: - :toctree: auto_generated/ - :template: class.rst - - BaseCollectionTransformer - .. currentmodule:: aeon.transformations.collection .. autosummary:: @@ -31,6 +23,7 @@ Collection transformers AutocorrelationFunctionTransformer ARCoefficientTransformer + Centerer DownsampleTransformer DWTTransformer HOG1DTransformer @@ -39,12 +32,11 @@ Collection transformers Normalizer Padder PeriodogramTransformer - Tabularizer Resizer - SimpleImputer SlopeTransformer - Standardizer + SimpleImputer Truncator + Tabularizer Channel selection @@ -72,6 +64,7 @@ Compose :template: class.rst CollectionTransformerPipeline + CollectionId Convolution based @@ -85,7 +78,6 @@ Convolution based Rocket MiniRocket - MiniRocketMultivariateVariable MultiRocket HydraTransformer @@ -148,8 +140,6 @@ Shapelet based SAST RSAST - - Signature based ~~~~~~~~~~~~~~~ @@ -165,15 +155,6 @@ Signature based Series transforms ----------------- -.. currentmodule:: aeon.transformations.series.base - -.. autosummary:: - :toctree: auto_generated/ - :template: class.rst - - BaseSeriesTransformer - - .. currentmodule:: aeon.transformations.series .. autosummary:: @@ -181,7 +162,6 @@ Series transforms :template: class.rst AutoCorrelationSeriesTransformer - ClearSkyTransformer ClaSPTransformer DFTSeriesTransformer Dobin @@ -193,9 +173,28 @@ Series transforms StatsModelsPACF BKFilter BoxCoxTransformer - YeoJohnsonTransformer - Dobin ScaledLogitSeriesTransformer SIVSeriesTransformer PCASeriesTransformer WarpingSeriesTransformer + + +Base +---- + +.. currentmodule:: aeon.transformations.collection.base + +.. autosummary:: + :toctree: auto_generated/ + :template: class.rst + + BaseCollectionTransformer + + +.. currentmodule:: aeon.transformations.series.base + +.. autosummary:: + :toctree: auto_generated/ + :template: class.rst + + BaseSeriesTransformer diff --git a/docs/api_reference/visualisation.rst b/docs/api_reference/visualisation.rst index 147b85b7e6..58a95fc102 100644 --- a/docs/api_reference/visualisation.rst +++ b/docs/api_reference/visualisation.rst @@ -3,9 +3,7 @@ Visualisation ============= -.. automodule:: aeon.visualisation - :no-members: - :no-inherited-members: +.. currentmodule:: aeon.visualisation .. autosummary:: :toctree: auto_generated/ @@ -22,13 +20,20 @@ Visualisation plot_series plot_lags plot_correlations - plot_interval - plot_windows + plot_series_collection + plot_collection_by_class + plot_spectrogram + plot_series_windows + plot_series_with_change_points plot_critical_difference + plot_significance plot_boxplot plot_scatter_predictions - plot_scatter - plot_time_series_with_change_points - plot_time_series_with_profiles + plot_pairwise_scatter + plot_score_vs_time_scatter + create_multi_comparison_matrix + plot_series_with_profiles plot_cluster_algorithm + plot_temporal_importance_curves plot_network + plot_pairwise_distance_matrix diff --git a/examples/visualisation/plotting_results.ipynb b/examples/visualisation/plotting_results.ipynb index 9d816235b5..535334c1d8 100644 --- a/examples/visualisation/plotting_results.ipynb +++ b/examples/visualisation/plotting_results.ipynb @@ -431,7 +431,7 @@ } ], "source": [ - "from aeon.visualisation.results import create_multi_comparison_matrix\n", + "from aeon.visualisation import create_multi_comparison_matrix\n", "\n", "create_multi_comparison_matrix(df, fig_size=\"8,4\")" ] @@ -464,7 +464,7 @@ } ], "source": [ - "from aeon.visualisation.results import create_multi_comparison_matrix\n", + "from aeon.visualisation import create_multi_comparison_matrix\n", "\n", "create_multi_comparison_matrix(\n", " df, fig_size=\"8,4\", pvalue_test_params={\"alternative\": \"two-sided\"}\n", @@ -499,7 +499,7 @@ } ], "source": [ - "from aeon.visualisation.results import create_multi_comparison_matrix\n", + "from aeon.visualisation import create_multi_comparison_matrix\n", "\n", "create_multi_comparison_matrix(\n", " df,\n", @@ -537,7 +537,7 @@ } ], "source": [ - "from aeon.visualisation.results import create_multi_comparison_matrix\n", + "from aeon.visualisation import create_multi_comparison_matrix\n", "\n", "create_multi_comparison_matrix(\n", " df,\n",