From a5d0a4816b482c475b72849955c837a25e7a796c Mon Sep 17 00:00:00 2001 From: hanane9 <58258128+hanane9@users.noreply.github.com> Date: Mon, 4 Mar 2024 10:53:40 +0100 Subject: [PATCH 01/24] convert polygons to label image --- txsim/data_utils/convert.py | 65 ++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/txsim/data_utils/convert.py b/txsim/data_utils/convert.py index b1e2b90..fc8d4b7 100644 --- a/txsim/data_utils/convert.py +++ b/txsim/data_utils/convert.py @@ -4,7 +4,8 @@ import geopandas as gpd from shapely.geometry import Polygon - +from rasterio import features +from rasterio import Affine def create_polygon(x_str, y_str): ''' @@ -68,14 +69,62 @@ def assign_spots_to_cells(cell_df,df,n_z_planes): -def convert_polygons_to_label_image(df, tech="Xenium") -> np.array: - """ - - #TODO: decide on arguments, instead of tech maybe sth else? And add some extra fct/wrapper for tech. - - """ +def convert_polygons_to_label_image(df, X_column, Y_column,complete_img_size_x,complete_img_size_y): + ''' + Create label image from a dataframe with polygons + Note that polygon coordinates should not be negative, if so please shift the coordinates before applying the function + Arguments + --------- + df: pandas.Dataframe + Dataframe containing polygon coordinates + X_column: str + Name of the column with x coordinates of the polygons (coordinates are a string of comma separated values) + Y_column: str + Name of the column with y coordinates of the polygons (coordinates are a string of comma separated values) + complete_img_size_x: int + image size in x + complete_img_size_y: int + image size in y + + Returns: + ---------- + cell_id_image: np.array + Array containing the label image where each polygon has a different integer label + + ''' + # Decode polygon coordinates and filter out NaN values + df = df.dropna(subset=[X_column]) + df = df.dropna(subset=[Y_column]) + df.loc[:, 'polygon_coordinates'] = df.apply(lambda row: create_polygon(row[X_column], row[Y_column]), axis=1) + + + if df.empty: + print("No valid polygons found.") + return - # TODO: Implement this function. (have it laying around somewhere.) + + cell_id_image = np.zeros((complete_img_size_y, complete_img_size_x), dtype=np.uint32) + + cell_id = 1 + for polygon in df["polygon_coordinates"]: + try: + minx, miny, maxx, maxy = polygon.bounds + if (int(maxx - minx)==0) or (int(maxy-miny)==0): # Skip polygons with zero width or height + print(f"Skipping invalid Polygon at cell_id = {cell_id}") + continue + minx, miny, maxx, maxy = int(minx), int(miny), int(maxx), int(maxy) + image = features.rasterize([(polygon, cell_id)], + out_shape=(maxy-miny, maxx-minx), + transform=Affine.translation(minx, miny), + fill=0, + dtype=np.uint32) + except AttributeError: + print(f"Invalid Polygon at cell_id = {cell_id}") + continue + cell_id_image[miny:maxy, minx:maxx] = np.where( + cell_id_image[miny:maxy,minx:maxx] == 0, image, cell_id_image[miny:maxy, minx:maxx]) + cell_id = cell_id + 1 + return cell_id_image def convert_coordinates_to_pixel_space( From 8f0afd25e27f39a0d0b126d78ace590d6dfc1689 Mon Sep 17 00:00:00 2001 From: hanane9 <58258128+hanane9@users.noreply.github.com> Date: Fri, 19 Apr 2024 16:08:02 +0000 Subject: [PATCH 02/24] Added tangram function --- txsim/preprocessing/_ctannotation.py | 88 ++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/txsim/preprocessing/_ctannotation.py b/txsim/preprocessing/_ctannotation.py index ffb5c9a..132a6c4 100644 --- a/txsim/preprocessing/_ctannotation.py +++ b/txsim/preprocessing/_ctannotation.py @@ -171,3 +171,91 @@ def annotate_celltypes( adata.obs['celltype'] = adata.obs['ct_'+str(ct_method)] return adata +def run_tangram( + + adata_st: AnnData, + adata_sc: AnnData, + sc_ct_labels: str = 'celltype', + device: str = 'cpu', + mode: str = 'cells', + num_epochs: int = 1000, + +) -> AnnData: + """Run the Tangram algorithm. + + Parameters + ---------- + adata_st : AnnData + AnnData object of the spatial transcriptomics data + adata_sc : str + Anndata object of the sc transcriptomics data + sc_ct_labels : str + Labels of the cell_type layer in the adata_sc + device : str or torch.device + Optional. Default is 'cpu'. + mode : str + Optional. Tangram mapping mode. 'cells', 'clusters', 'constrained'. Default is 'cells' + num_epochs : int + Optional. Number of epochs. Default is 1000 + + Returns + ------- + AnnData + Anndata object with cell type annotation in ``adata_st.obs['celltype']`` and ``adata_st.obs['score']``, whereby the latter is the noramlized scores, i.e. probability of each spatial cell to belong to a specific cell type assignment. + """ + #import scanpy as sc + import tangram as tg + + #TODO: check the layers in adata_sc + # use log1p noramlized values + #adata_sc.X = adata_sc.layers['lognorm'] + + adata_st_orig = adata_st.copy() + + # use all the genes from adata_st as markers for tangram + markers = adata_st.var_names.tolist() + + # Removes genes that all entries are zero. Finds the intersection between adata_sc, adata_st and given marker gene list, save the intersected markers in two adatas + # Calculates density priors and save it with adata_st + tg.pp_adatas( + adata_sc=adata_sc, + adata_sp=adata_st, + genes=markers, + ) + + # Map single cell data (`adata_sc`) on spatial data (`adata_st`). + # density_prior (str, ndarray or None): Spatial density of spots, when is a string, value can be 'rna_count_based' or 'uniform', when is a ndarray, shape = (number_spots,). + # use 'uniform' if the spatial voxels are at single cell resolution (e.g. MERFISH). 'rna_count_based', assumes that cell density is proportional to the number of RNA molecules. + + adata_map = tg.map_cells_to_space( + adata_sc=adata_sc, + adata_sp=adata_st, + device=device, + mode=mode, + num_epochs=num_epochs, + density_prior='uniform') + + + # Spatial prediction dataframe is saved in `obsm` `tangram_ct_pred` of the spatial AnnData + tg.project_cell_annotations( + adata_map = adata_map, + adata_sp = adata_st, annotation=sc_ct_labels) + + # use original without extra layers generated from tangram + adata_st_orig.obsm['ct_tangram_scores'] = adata_st.obsm['tangram_ct_pred'] + df = adata_st.obsm['tangram_ct_pred'].copy() + adata_st = adata_st_orig.copy() + + + adata_st.obs['celltype'] = df.idxmax(axis=1) + + + # Normalize by row before setting the score + normalized_df = df.div(df.sum(axis=1), axis=0) + max_values = normalized_df.max(axis=1) + adata_st.obs['score'] = max_values + + + + return adata_st + From b62f9af7ee9a4e59d100cd39088e515e9645255a Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Tue, 23 Apr 2024 00:31:35 +0200 Subject: [PATCH 03/24] Delete cell type annotation wrapper as it's not used in the pipeline anymore and the code was updated --- txsim/preprocessing/_ctannotation.py | 51 ---------------------------- 1 file changed, 51 deletions(-) diff --git a/txsim/preprocessing/_ctannotation.py b/txsim/preprocessing/_ctannotation.py index 132a6c4..65a9445 100644 --- a/txsim/preprocessing/_ctannotation.py +++ b/txsim/preprocessing/_ctannotation.py @@ -49,12 +49,10 @@ def run_majority_voting( def run_ssam( - adata_st: AnnData, spots: pd.DataFrame, adata_sc: pd.DataFrame, um_p_px: float = 0.325, - ) -> AnnData: """Add cell type annotation by ssam. @@ -126,51 +124,7 @@ def run_ssam( return adata_st -def annotate_celltypes( - adata: AnnData, - adata_sc: AnnData, - ct_method: str = 'majority', - ct_threshold: float = 0.7, - prior_celltypes : pd.DataFrame = None, - hyperparams: dict = {} -) -> AnnData: - #all_ct_methods = False - #TODO potentially fix how threshold is measured - #Add celltype according to ct_method and check if all methods should be implemented - if hyperparams.get('threshold') is not None: ct_threshold = hyperparams.get('threshold') - ran_ct_method = False - if (ct_method is None): ct_method = 'majority' - if (ct_method == 'majority'): - adata = run_majority_voting(adata, adata.uns['spots']) - ran_ct_method = True - elif (ct_method == 'ssam'): - adata = run_ssam(adata, adata.uns['spots'], adata_sc = adata_sc) - ran_ct_method = True - elif (ct_method == 'pciSeqCT'): - #TODO check if this actually works - ct_method = 'pciSeq' - adata.obs['ct_pciSeq'] = pd.Categorical(prior_celltypes['type'][adata.obs['cell_id']]) - adata.obs['ct_pciSeq_cert'] = prior_celltypes['prob'][adata.obs['cell_id']] - ran_ct_method = True - else: - raise Exception(f'{ct_method} is not a valid cell type method') - # ToDo (second prio) - # elif ct_method == 'manual_markers': - # adata = run_manual_markers(adata, spots) - # elif ct_method == 'scrna_markers': - # adata = run_scrna_markers(adata, spots, rna_adata) - if not ran_ct_method: print('No valid cell type annotation method') - - # Take over primary ct annotation method to adata.obs['celltype'] and apply certainty threshold - # Add methods, if they provide certainty measure - if ct_method in ['majority', 'ssam']: - ct_list = adata.obs['ct_'+str(ct_method)].copy() - ct_list[adata.obs['ct_'+str(ct_method)+'_cert'] < ct_threshold] = "Unknown" #TODO different hyperparams probably - adata.obs['celltype'] = ct_list - else: - adata.obs['celltype'] = adata.obs['ct_'+str(ct_method)] - return adata def run_tangram( adata_st: AnnData, @@ -235,7 +189,6 @@ def run_tangram( num_epochs=num_epochs, density_prior='uniform') - # Spatial prediction dataframe is saved in `obsm` `tangram_ct_pred` of the spatial AnnData tg.project_cell_annotations( adata_map = adata_map, @@ -246,16 +199,12 @@ def run_tangram( df = adata_st.obsm['tangram_ct_pred'].copy() adata_st = adata_st_orig.copy() - adata_st.obs['celltype'] = df.idxmax(axis=1) - # Normalize by row before setting the score normalized_df = df.div(df.sum(axis=1), axis=0) max_values = normalized_df.max(axis=1) adata_st.obs['score'] = max_values - - return adata_st From 96f99d9aca18304a67c42715857f4018b288a327 Mon Sep 17 00:00:00 2001 From: LouisK92 Date: Thu, 25 Apr 2024 16:09:00 +0200 Subject: [PATCH 04/24] Add missing dependency rasterio --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fb5ff0b..60a7efc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ requires = [ "shapely", "scikit-image", "planktonspace", - "geopandas" + "geopandas", + "rasterio" ] build-backend = "setuptools.build_meta" From 920dfb634abf38e37dc1309c95db5725fbfb0b99 Mon Sep 17 00:00:00 2001 From: LouisK92 Date: Fri, 26 Apr 2024 16:09:44 +0200 Subject: [PATCH 05/24] Sparse to dense for ssam annotation --- txsim/preprocessing/_ctannotation.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/txsim/preprocessing/_ctannotation.py b/txsim/preprocessing/_ctannotation.py index 65a9445..d096edc 100644 --- a/txsim/preprocessing/_ctannotation.py +++ b/txsim/preprocessing/_ctannotation.py @@ -3,6 +3,7 @@ from anndata import AnnData import pandas as pd import scanpy as sc +from scipy.sparse import issparse def run_majority_voting( @@ -83,6 +84,8 @@ def run_ssam( spots.x*um_p_px, spots.y*um_p_px ) adata_sc=adata_sc[:,adata_st.var_names] + if issparse(adata_sc.X): + adata_sc.X = adata_sc.X.toarray() exp=pd.DataFrame(adata_sc.X,columns=adata_sc.var_names) exp['celltype']=list(adata_sc.obs['celltype']) signatures=exp.groupby('celltype').mean().transpose() From d442dfa422342a2fb9fd6b3d12b03e407e29d83b Mon Sep 17 00:00:00 2001 From: LouisK92 Date: Sat, 27 Apr 2024 16:35:32 +0200 Subject: [PATCH 06/24] view to copy in ssam ct annotation to support sparse matrices --- txsim/preprocessing/_ctannotation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/txsim/preprocessing/_ctannotation.py b/txsim/preprocessing/_ctannotation.py index d096edc..db32c40 100644 --- a/txsim/preprocessing/_ctannotation.py +++ b/txsim/preprocessing/_ctannotation.py @@ -85,6 +85,7 @@ def run_ssam( spots.y*um_p_px ) adata_sc=adata_sc[:,adata_st.var_names] if issparse(adata_sc.X): + adata_sc = adata_sc.copy() adata_sc.X = adata_sc.X.toarray() exp=pd.DataFrame(adata_sc.X,columns=adata_sc.var_names) exp['celltype']=list(adata_sc.obs['celltype']) From 727c5721cde7a2d1fb2da218dc6f83e3012aae9a Mon Sep 17 00:00:00 2001 From: hanane9 <58258128+hanane9@users.noreply.github.com> Date: Wed, 1 May 2024 12:41:41 +0000 Subject: [PATCH 07/24] update tangram --- txsim/preprocessing/_ctannotation.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/txsim/preprocessing/_ctannotation.py b/txsim/preprocessing/_ctannotation.py index db32c40..94a8a16 100644 --- a/txsim/preprocessing/_ctannotation.py +++ b/txsim/preprocessing/_ctannotation.py @@ -199,16 +199,20 @@ def run_tangram( adata_sp = adata_st, annotation=sc_ct_labels) # use original without extra layers generated from tangram - adata_st_orig.obsm['ct_tangram_scores'] = adata_st.obsm['tangram_ct_pred'] + df = adata_st.obsm['tangram_ct_pred'].copy() adata_st = adata_st_orig.copy() + adata_st.obs['celltype'] = df.idxmax(axis=1) + # Normalize by row before setting the score normalized_df = df.div(df.sum(axis=1), axis=0) max_values = normalized_df.max(axis=1) adata_st.obs['score'] = max_values + adata_st.obsm['ct_tangram_scores'] = normalized_df - return adata_st + + return adata_st From 1f29996d1d747ebb093447c044e9f664eb000d32 Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Thu, 2 May 2024 16:57:49 +0200 Subject: [PATCH 08/24] Adjust simulation, small fixes, and clean up --- txsim/simulation/simulation_utils.py | 249 ++++++++++++++++++++------- 1 file changed, 185 insertions(+), 64 deletions(-) diff --git a/txsim/simulation/simulation_utils.py b/txsim/simulation/simulation_utils.py index efe3260..d9424d6 100644 --- a/txsim/simulation/simulation_utils.py +++ b/txsim/simulation/simulation_utils.py @@ -9,18 +9,39 @@ import scipy.sparse from tqdm import tqdm import itertools +from typing import Union, List class Simulation: + """Simulate spatial transcriptomics data based on scRNA-seq data (scanpy's PBMC3k data). + + + Usage + ----- + # Create a simulation object + sim = Simulation(data_dir="../data") + + # Simulate spatial data (Set a gene that defines how cells are sampled along the spatial y coordinates) + sim.simulate_spatial_data("IL32") + + # Simulate exact cell and spot positions + sim.simulate_exact_positions() + + adata_sp = sim.adata_sp + adata_sc = sim.adata_sc + + + """ + def __init__(self, n_celltypes=3, genes=["IL32", "CYBA", "UBB", "AKR1C3"], data_dir: str = "../data"): """Create a simulation object for spatial transcriptomics data. ---------- n_celltypes: int Number of cell types to simulate spatial data for. The simulation will use the n_celltypes most frequent cell types in the PBMC3k dataset. genes: str | list - List of genes to keep in the simulation. Specify "all" to keep all genes in the PBMC3k dataset. + List of genes to keep in the simulation. Specify "all" to keep all genes of the PBMC3k dataset. data_dir: str - Path to data directory. + Path to data directory. The scRNA-seq data will be stored in or accessed from this directory. Returns ------- @@ -32,52 +53,79 @@ def __init__(self, n_celltypes=3, genes=["IL32", "CYBA", "UBB", "AKR1C3"], data_ sc.settings.datasetdir = data_dir self.n_celltypes = n_celltypes - self.adata_sc, self.celltypes = self._prepare_sc_data() + self.adata_sc, self.celltypes = self._prepare_sc_data(genes) if genes == "all": self.genes = self.adata_sc.var_names else: self.genes = genes - def simulate_spatial_data(self, gene_to_simulate, n_groups=10): + def simulate_spatial_data( + self, gene_for_y_grouping, n_groups=10, n_per_bin_and_ct=5, n_cols_cell_numb_increase=4, seed=0 + ) -> None: """ Simulate spatial data based on the scRNA-seq data. The expression levels of this gene will increase across the y-axis of the simulated spatial data. + + Main features of the simulation result: + - The expression levels of the gene to simulate increase over the y-axis of the spatial data. + - Along x there are different domains of cell types (ct1, ct2, ct3, (ct1,ct2), ..., (ct1,ct2,ct3)) + - Within each cell type domain, the number of cells increases along the x-axis (n_cols_cell_numb_increase) + ---------- - gene_to_simulate: str - Gene to simulate spatial data for. Must be in the list of genes provided when creating the simulation object. + gene_for_y_grouping: str + Gene to simulate spatial data for. Must be in the list of genes provided when creating the simulation + object. n_groups: int - Number of groups to split the expression levels into per cell type. + Number of groups to split the expression levels into. The split is based on the expression histogram of each + cell type. + n_per_bin_and_ct: int + General factor for number of cells per grid field per cell type. Note that this is not the exact number of + cells per grid field since the number of cells per grid field increases over the x-axis (see ). + n_cols_cell_numb_increase: int + Number of grid field columns over which we increase the number of cells of a cell type. + seed: int + Seed. Returns ------- - adata_sp: AnnData - Annotated ``AnnData`` object with simulated spatial data. The resulting object will also be stored directly in the simulation object. + None. The resulting object will be stored directly in the simulation object: + self.adata_sp: AnnData + Annotated ``AnnData`` object with simulated spatial data. """ - if gene_to_simulate not in self.genes: + + if gene_for_y_grouping not in self.genes: raise ValueError( - f"Gene {gene_to_simulate} not in the list of genes {self.genes} provided when creating the simulation object.") + f"Gene {gene_for_y_grouping} not in the list of genes {self.genes} provided when creating the " + + "simulation object." + ) ################################################ # Define expression level groups per cell type # ################################################ - # df of cells with four genes - df = pd.DataFrame(index=self.adata_sc.obs_names, columns=self.genes, - data=self.adata_sc[:, self.genes].X.toarray()) + # Init dataframe of gene expressions for group by operations + df = pd.DataFrame( + index=self.adata_sc.obs_names, columns=self.genes, data=self.adata_sc[:, self.genes].X.toarray() + ) df["celltype"] = self.adata_sc.obs["louvain"].values # Split into cell type dfs dfs = [df.loc[df["celltype"] == c].copy() for c in self.celltypes] - # Add group column + # Add expression group column for df_ in dfs: n_obs = len(df_) n_per_group = n_obs // n_groups - for gene in self.genes: - df_.loc[df_.sort_values(gene).index, gene + "_group"] = np.repeat(np.arange(n_groups + 1), n_per_group)[ - :n_obs] - df_.loc[df_[gene + "_group"] > n_groups - 1, gene + "_group"] = n_groups - 1 + df_.loc[df_.sort_values(gene_for_y_grouping).index, gene_for_y_grouping + "_group"] = np.repeat( + np.arange(n_groups + 1), n_per_group + )[:n_obs] + df_.loc[df_[gene_for_y_grouping + "_group"] > n_groups - 1, gene_for_y_grouping + "_group"] = n_groups - 1 + #for gene in self.genes: #TODO: why all genes? only gene_to_simulate is needed, no? + # df_.loc[df_.sort_values(gene).index, gene + "_group"] = np.repeat( + # np.arange(n_groups + 1), n_per_group + # )[:n_obs] + # df_.loc[df_[gene + "_group"] > n_groups - 1, gene + "_group"] = n_groups - 1 # Concatenate back with initial order df = pd.concat(dfs).loc[self.adata_sc.obs_names] @@ -88,40 +136,57 @@ def simulate_spatial_data(self, gene_to_simulate, n_groups=10): # Define spatial distribution of cell types # ############################################# - N = 5 # general factor for number of cells per grid field per cell type - F_CT = 4 # number of grid field columns over which we increase the number of cells of a cell type - - def stair(f_ct=F_CT, n=1, N=N): + def stair(f_ct=n_cols_cell_numb_increase, n=1, N=n_per_bin_and_ct): return np.concatenate( [np.arange(1, f_ct + 1) * N for _ in range(n)]) # NOTE: maybe quadratic increase more useful - def zeros(f_ct=F_CT, n=1): + def zeros(f_ct=n_cols_cell_numb_increase, n=1): return np.repeat(0, n * f_ct) - x_n_cells_ct0 = np.concatenate( - [stair(), zeros(), zeros(), stair(), stair(), zeros(), stair()]) # NOTE: simplify with itertools? + # NOTE: simplify with itertools? + x_n_cells_ct0 = np.concatenate([stair(), zeros(), zeros(), stair(), stair(), zeros(), stair()]) x_n_cells_ct1 = np.concatenate([zeros(), stair(), zeros(), stair(), zeros(), stair(), stair()]) x_n_cells_ct2 = np.concatenate([zeros(), zeros(), stair(), zeros(), stair(), stair(), stair()]) x_n_cells = np.array([x_n_cells_ct0, x_n_cells_ct1, x_n_cells_ct2]).T - obs1, pos1 = self._sample1(df, y_groups, x_n_cells, gene_to_simulate, self.celltypes, seed=0) + obs1, pos1 = self._sample_cells_from_scRNAseq_v1( + df, y_groups, x_n_cells, gene_for_y_grouping, self.celltypes, seed=seed + ) - adata_sp = self.adata_sc[obs1, self.genes].copy() - adata_sp.obs["x"] = pos1[:, 0] - adata_sp.obs["y"] = pos1[:, 1] + #adata_sp = self.adata_sc[obs1, self.genes].copy() + adata_sp = ad.AnnData( + X=self.adata_sc[obs1, self.genes].X, + obs=pd.DataFrame( + index=[f"spatial_{i}" for i in range(len(obs1))], + data={ + "x": pos1[:, 0], "y": pos1[:, 1], + "sc_obs_names": self.adata_sc[obs1].obs_names, + "louvain": self.adata_sc[obs1].obs["louvain"].values, + } + ), + var=self.adata_sc[:,self.genes].var + ) + #adata_sp.obs["x"] = pos1[:, 0] + #adata_sp.obs["y"] = pos1[:, 1] adata_sp = self._simulate_spots(adata_sp) # filter out cells with no spots - adata_sp.obs["n_spots"] = [len(adata_sp[adata_sp.obs_names == cell_id].uns["spots"]) for cell_id in adata_sp.obs_names] - adata_sp = adata_sp[adata_sp.obs["n_spots"] > 0] + adata_sp.obs["n_spots"] = [ + len(adata_sp[adata_sp.obs_names == cell_id].uns["spots"]) for cell_id in adata_sp.obs_names + ] + assert (adata_sp.obs["n_spots"] > 0).all(), "Some cells have no spots." self.adata_sp = adata_sp - return adata_sp - - def simulate_exact_positions(self, radius_range=(0.01, 0.05), cell_sampling_type='uniform', spot_sampling_type='uniform', - adata_sp=None, cell_spread=0.1): + def simulate_exact_positions( + self, + radius_range=(0.05, 0.10), + cell_sampling_type='uniform', + spot_sampling_type='normal', + adata_sp=None, + cell_spread=0.1 + ) -> None: """Simulate exact cell and spot positions in each grid field. ---------- radius_range: tuple @@ -131,14 +196,16 @@ def simulate_exact_positions(self, radius_range=(0.01, 0.05), cell_sampling_type spot_sampling_type: str Type of spot position sampling. Either 'uniform' or 'normal'. adata_sp: AnnData - Annotated ``AnnData`` object with spatial data. Leave blank to use the spatial data stored in the simulation object. + Annotated ``AnnData`` object with spatial data. Leave blank to use the spatial data stored in the simulation + object. cell_spread: float Spread of cell positions when using normal sampling. Returns ------- + None. The resulting object will be stored directly in the simulation object: adata_sp: AnnData - Annotated ``AnnData`` object with simulated spatial data. The resulting object will also be stored directly in the simulation object. + Annotated ``AnnData`` object with simulated spatial data. """ assert cell_sampling_type in ['uniform', 'normal'], "cell_sampling_type must be 'uniform' or 'normal'" assert spot_sampling_type in ['uniform', 'normal'], "spot_sampling_type must be 'uniform' or 'normal'" @@ -185,13 +252,15 @@ def sample_cell_pos(sampling_type, radius): # If there is an overlap, sample new positions for one of the overlapping cells x_cells[j], y_cells[j] = sample_cell_pos(cell_sampling_type, cell_radii[j]) - new_cell_pos_list.append(pd.DataFrame({"cell_id": cells_in_grid, "x": x_cells, "y": y_cells, "area": cell_areas})) + new_cell_pos_list.append( + pd.DataFrame({"cell_id": cells_in_grid, "x": x_cells, "y": y_cells, "area": cell_areas}) + ) # Simulate spot positions spots_df = adata_sp.uns["spots"][adata_sp.uns["spots"]["cell_id"].isin(cells_in_grid)] # sort in cells_in_grid order - spots_df["cell_id"] = pd.Categorical(spots_df["cell_id"], categories=cells_in_grid, ordered=True) + spots_df.loc[:,"cell_id"] = pd.Categorical(spots_df["cell_id"], categories=cells_in_grid, ordered=True) spots_df = spots_df.sort_values("cell_id") if spot_sampling_type == 'uniform': @@ -204,11 +273,17 @@ def sample_cell_pos(sampling_type, radius): elif spot_sampling_type == 'normal': # spread for this spot is 0.25 * mean cell radius spot_spread = 0.25 * np.mean(cell_radii) - spot_x = np.random.normal(np.repeat(x_cells, spots_df.groupby('cell_id').size()), spot_spread, len(spots_df)) - spot_y = np.random.normal(np.repeat(y_cells, spots_df.groupby('cell_id').size()), spot_spread, len(spots_df)) + spot_x = np.random.normal( + np.repeat(x_cells, spots_df.groupby('cell_id').size()), spot_spread, len(spots_df) + ) + spot_y = np.random.normal( + np.repeat(y_cells, spots_df.groupby('cell_id').size()), spot_spread, len(spots_df) + ) - new_spot_pos_list.append( - pd.DataFrame({"Gene": spots_df["Gene"], "x": spot_x, "y": spot_y, "cell_id": spots_df["cell_id"], "celltype": spots_df["celltype"]})) + new_spot_pos_list.append(pd.DataFrame({ + "Gene": spots_df["Gene"], "x": spot_x, "y": spot_y, "cell_id": spots_df["cell_id"], + "celltype": spots_df["celltype"] + })) # Update adata_sp @@ -224,7 +299,6 @@ def sample_cell_pos(sampling_type, radius): adata_sp.uns["spots"] = pd.concat(new_spot_pos_list) self.adata_sp = adata_sp - return adata_sp def plot_hist(self, adata=None, hue_order=None): """Plot a histogram of the gene expression for each cell type. @@ -505,7 +579,10 @@ def plot_relative_expression_across_celltypes(self, adata_sp=None, adata_sc=None cell_type = adata_sc.obs.loc[adata_sc.obs[cell_type_key].isin(adata_sp.obs[cell_type_key]),cell_type_key].unique() print(cell_type) if len(cell_type) <= 1: - raise ValueError("Only one cell type in the spatial data. At least two cell types are required to compare relative expression across cell types.") + raise ValueError( + "Only one cell type in the spatial data. At least two cell types are required to compare " + + "relative expression across cell types." + ) if genes == "all": genes = self.genes @@ -520,20 +597,36 @@ def plot_relative_expression_across_celltypes(self, adata_sp=None, adata_sc=None adata_sc_ct = adata_sc[adata_sc.obs[cell_type_key].isin([ct1, ct2])] plot_df = pd.DataFrame({ - "Cell Type": np.concatenate([adata_sp_ct.obs[cell_type_key].values, adata_sc_ct.obs[cell_type_key].values, - adata_sp_ct.obs[cell_type_key].values, adata_sc_ct.obs[cell_type_key].values]), - "Modality": np.concatenate([np.repeat("Spatial", adata_sp_ct.n_obs), np.repeat("scRNA-seq", adata_sc_ct.n_obs), - np.repeat("Spatial", adata_sp_ct.n_obs), np.repeat("scRNA-seq", adata_sc_ct.n_obs)]), - f"{gene} expression": np.concatenate([adata_sp_ct[:, gene].X.toarray().flatten(), adata_sc_ct[:, gene].X.toarray().flatten(), - adata_sp_ct[:, gene].X.toarray().flatten(), adata_sc_ct[:, gene].X.toarray().flatten()]) + "Cell Type": np.concatenate([ + adata_sp_ct.obs[cell_type_key].values, adata_sc_ct.obs[cell_type_key].values, + adata_sp_ct.obs[cell_type_key].values, adata_sc_ct.obs[cell_type_key].values + ]), + "Modality": np.concatenate([ + np.repeat("Spatial", adata_sp_ct.n_obs), np.repeat("scRNA-seq", adata_sc_ct.n_obs), + np.repeat("Spatial", adata_sp_ct.n_obs), np.repeat("scRNA-seq", adata_sc_ct.n_obs) + ]), + f"{gene} expression": np.concatenate([ + adata_sp_ct[:, gene].X.toarray().flatten(), adata_sc_ct[:, gene].X.toarray().flatten(), + adata_sp_ct[:, gene].X.toarray().flatten(), adata_sc_ct[:, gene].X.toarray().flatten() + ]) }) - ax = axs[j, i] if len(genes) > 1 and len(cell_type_pairs) > 1 else axs[i] if len(genes) == 1 else axs[j] if len(cell_type_pairs) == 1 else axs - sns.violinplot(plot_df, x="Modality", y=f"{gene} expression", hue="Cell Type", split=True, inner="box", ax=ax) + if len(genes) > 1 and len(cell_type_pairs) > 1: + ax = axs[j, i] + elif len(genes) == 1: + ax = axs[i] + elif len(cell_type_pairs) == 1: + ax = axs[j] + else: + ax = axs + + sns.violinplot( + plot_df, x="Modality", y=f"{gene} expression", hue="Cell Type", split=True, inner="box", ax=ax + ) plt.show() - def _prepare_sc_data(self): + def _prepare_sc_data(self, genes: Union[str, List[str]]): """Prepare scRNA-seq data for simulation. """ # Take lognorm counts from raw adata, since the processed has scaled counts (we don't want scaled). @@ -545,13 +638,25 @@ def _prepare_sc_data(self): adata = sc.datasets.pbmc3k_processed().copy() adata.X = adata_raw[adata.obs_names, adata.var_names].X + # filter cells with no counts + if not (genes == "all"): + obs_filter = sc.pp.filter_cells(adata[:,genes], min_counts=1, inplace=False)[0] + adata = adata[obs_filter].copy() + else: + sc.pp.filter_cells(adata, min_counts=1) + # Reduce to n cell types celltypes = adata.obs["louvain"].value_counts().iloc[:self.n_celltypes].index.tolist() adata = adata[adata.obs["louvain"].isin(celltypes)].copy() + # Make sure to have at least 10 cells per cell type + assert (adata.obs["louvain"].value_counts() > 10).all(), "Not enough cells per cell type. Probably the " + \ + "genes subsetting lead to too many cells with 0 counts." + del adata.uns["rank_genes_groups"] - # I have absolutely no clue why I keep getting more genes than there are in adata.var when running DE tests (only creating a new AnnData worked for me) + # I have absolutely no clue why I keep getting more genes than there are in adata.var when running DE tests + # (only creating a new AnnData worked for me) adata = ad.AnnData(X=adata.X, var=adata.var, obs=adata.obs[["louvain"]]) return adata, celltypes @@ -579,18 +684,29 @@ def _get_gene_expression_dataframe(self, adata, genes, ct_key="louvain"): return df_expr - def _sample1(self, df, y_groups, x_n_cells, gene, celltypes, seed=0): + def _sample_cells_from_scRNAseq_v1(self, df, y_groups, x_n_cells, gene, celltypes, seed=0): """ Sample from cells in different expression level groups (rows) over different numbers of cells per cell type (column) ---------- df: pd.DataFrame - Dataframe of cells with gene expression levels and cell type annotations. Use _get_gene_expression_dataframe to create this dataframe. + Dataframe of cells with gene expression levels and cell type annotations. Use _get_gene_expression_dataframe + to create this dataframe. y_groups: list shape = (number of grid field rows) - Array of groups (each group should occur exactly once, otherwise the cell state frequencies are not correct anymore) + Array of groups (each group should occur exactly once, otherwise the cell state frequencies are not correct + anymore) x_n_cells: np.array shape = (number of grid field columns, number of cell types) Matrix of number of cells per cell type that we change along each row of the grid field + gene: str + Gene that defines expression level groups along y coordinate bins. + Returns + ------- + obs: list + List of cell indices (duplicates possible). + positions: np.array + Array of x and y positions of the cells. + """ np.random.seed(seed) @@ -598,8 +714,9 @@ def _sample1(self, df, y_groups, x_n_cells, gene, celltypes, seed=0): positions = [] for y, group in enumerate(y_groups): - obs_pools = [df.loc[(df["celltype"] == ct) & (df[gene + "_group"] == group)].index.tolist() for ct in - celltypes] + obs_pools = [ + df.loc[(df["celltype"] == ct) & (df[gene + "_group"] == group)].index.tolist() for ct in celltypes + ] for x in range(x_n_cells.shape[0]): for ct_idx in range(x_n_cells.shape[1]): @@ -610,10 +727,14 @@ def _sample1(self, df, y_groups, x_n_cells, gene, celltypes, seed=0): return obs, np.array(positions) def _simulate_spots(self, adata_sp): - """Simulate spots in spatial data. Note that this does not simulate the exact positions of cells and spots, it merely creates a spots dataframe in adata_sp.uns. + """Simulate spots in spatial data. + + Note that this does not simulate the exact positions of cells and spots, it merely creates a spots dataframe in + adata_sp.uns. ---------- adata_sp: AnnData - Annotated ``AnnData`` object with spatial data. The resulting object will also be stored directly in the simulation object. + Annotated ``AnnData`` object with spatial data. The resulting object will also be stored directly in the + simulation object. Returns ------- @@ -621,7 +742,7 @@ def _simulate_spots(self, adata_sp): Annotated ``AnnData`` object with simulated spot positions in adata_sp.uns. """ raw_counts = sc.datasets.pbmc3k() - adata_sp.layers["raw"] = raw_counts[adata_sp.obs_names, adata_sp.var_names].X + adata_sp.layers["raw"] = raw_counts[adata_sp.obs["sc_obs_names"], adata_sp.var_names].X # make obs names unique adata_sp.obs["sc_cell_id"] = adata_sp.obs_names From 6fb0269fb83ee6fc616d41e7780fecc85a591451 Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Mon, 13 May 2024 10:40:18 +0200 Subject: [PATCH 09/24] Clean up of some quality metrics --- txsim/quality_metrics/__init__.py | 2 +- txsim/quality_metrics/_quality_metrics.py | 91 +++++++++++++++-------- 2 files changed, 60 insertions(+), 33 deletions(-) diff --git a/txsim/quality_metrics/__init__.py b/txsim/quality_metrics/__init__.py index 020eef8..062d4cb 100644 --- a/txsim/quality_metrics/__init__.py +++ b/txsim/quality_metrics/__init__.py @@ -1,2 +1,2 @@ from ._combined import all_quality_metrics -from ._quality_metrics import cell_density,proportion_of_assigned_reads,median_reads_cells,mean_reads_cells,number_of_genes,number_of_cells,percentile_5th_reads_cells,mean_genes_cells,percentile_95th_genes_cells,percentile_5th_genes_cells,median_genes_cells,percentile_95th_reads_cells \ No newline at end of file +from ._quality_metrics import * #cell_density,proportion_of_assigned_reads,median_reads_cells,mean_reads_cells,number_of_genes,number_of_cells,percentile_5th_reads_cells,mean_genes_cells,percentile_95th_genes_cells,percentile_5th_genes_cells,median_genes_cells,percentile_95th_reads_cells \ No newline at end of file diff --git a/txsim/quality_metrics/_quality_metrics.py b/txsim/quality_metrics/_quality_metrics.py index 4bb5253..6f7d769 100644 --- a/txsim/quality_metrics/_quality_metrics.py +++ b/txsim/quality_metrics/_quality_metrics.py @@ -3,24 +3,49 @@ import pandas as pd from anndata import AnnData from scipy.spatial import ConvexHull, convex_hull_plot_2d - - -def cell_density(adata_sp: AnnData,pipeline_output=True): - """Calculates the area of the region imaged using convex hull and divide total number of cells/area. XY position should be in um2" +from scipy.sparse import issparse +from typing import Optional + + +def cell_density( + adata_sp: AnnData, + scaling_factor: float = 1.0, + img_shape: Optional[tuple] = None, + pipeline_output: bool = True +) -> float: + """Calculates the area of the region imaged using convex hull and divide total number of cells/area. + Parameters ---------- adata_sp : AnnData annotated ``AnnData`` object with counts from spatial data + scaling_factor: float + XY position should be in µm. If not, a multiplicative scaling factor can be provided. + img_shape: tuple + Provide an image shape for area calculation instead of convex hull pipeline_output : float, optional - Boolean for whether to use the + Generic argument for txsim metrics. Boolean for whether to return only the summary statistic or additional + metric specific outputs. (Here: no additional outputs) + Returns ------- density : float Cell density (cells/um) """ - hull = ConvexHull(np.array(adata_sp.uns['spots'].loc[:,['x','y']])) - density=(adata_sp.shape[0]/hull.area)#*10e6 - return density + if scaling_factor == 1.0: + pos = adata_sp.uns['spots'].loc[:,['x','y']].values + else: + pos = adata_sp.uns['spots'].loc[:,['x','y']].values.copy() * scaling_factor + + if img_shape is not None: + area = img_shape[0] * img_shape[1] + else: + hull = ConvexHull(pos) #TODO: test for large dataset, maybe better to use cell positions for scalability + area = hull.area + + density= adata_sp.n_obs/area #*10e6 #TODO: Check if there can be numerical issues due to large area!!! + + return density def proportion_of_assigned_reads(adata_sp: AnnData,pipeline_output=True): """Proportion of assigned reads @@ -29,47 +54,49 @@ def proportion_of_assigned_reads(adata_sp: AnnData,pipeline_output=True): adata_sp : AnnData annotated ``AnnData`` object with counts from spatial data pipeline_output : float, optional - Boolean for whether to use the + Generic argument for txsim metrics. Boolean for whether to return only the summary statistic or additional + metric specific outputs. (Here: no additional outputs) + Returns ------- proportion_assigned : float Proportion of reads assigned to cells / all reads decoded - """ - proportion_assigned=np.sum(adata_sp.layers['raw'])/adata_sp.uns['spots'].shape[0] + """ + if issparse(adata_sp.layers['raw']): + proportion_assigned=adata_sp.layers['raw'].sum()/adata_sp.uns['spots'].shape[0] + else: + proportion_assigned=np.sum(adata_sp.layers['raw'])/adata_sp.uns['spots'].shape[0] return proportion_assigned -def median_reads_cells(adata_sp: AnnData,pipeline_output=True): - """Median number of reads/cells +def reads_per_cell(adata_sp: AnnData, statistic: str = "mean", pipeline_output=True): + """ Get mean/median number of reads per cell + Parameters ---------- adata_sp : AnnData - annotated ``AnnData`` object with counts from spatial data + annotated ``AnnData`` object with counts from spatial data. Integer counts are expected in + adata_sp.layers['raw']. pipeline_output : float, optional - Boolean for whether to use the + Generic argument for txsim metrics. Boolean for whether to return only the summary statistic or additional + metric specific outputs. (Here: no additional outputs) + Returns ------- median_cells : float Median_number_of_reads_x_cell """ - median_cells=np.median(np.sum(adata_sp.layers['raw'],axis=1)) - return median_cells + if issparse(adata_sp.layers['raw']) and statistic == "mean": + return np.mean(adata_sp.layers['raw'].sum(axis=1)) + elif issparse(adata_sp.layers['raw']) and statistic == "median": + return np.median(np.asarray(adata_sp.layers['raw'].sum(axis=1)).flatten()) + elif statistic == "mean": + return np.mean(np.sum(adata_sp.layers['raw'],axis=1)) + elif statistic == "median": + return np.median(np.sum(adata_sp.layers['raw'],axis=1)) + else: + raise ValueError("Please choose either 'mean' or 'median' for statistic") -def mean_reads_cells(adata_sp: AnnData,pipeline_output=True): - """Mean number of reads/cells - Parameters - ---------- - adata_sp : AnnData - annotated ``AnnData`` object with counts from spatial data - pipeline_output : float, optional - Boolean for whether to use the - Returns - ------- - mean_cells : float - Mean_number_of_reads_x_cell - """ - mean_cells=np.mean(np.sum(adata_sp.layers['raw'],axis=1)) - return mean_cells def number_of_genes(adata_sp: AnnData,pipeline_output=True): """ Size of the gene panel present in the spatial dataset From 4f39a7d006bbc8fd93000d9642e33800f95fca42 Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Mon, 13 May 2024 14:25:06 +0200 Subject: [PATCH 10/24] Add function to convert xenium polygons to label image --- txsim/data_utils/convert.py | 96 +++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 3 deletions(-) diff --git a/txsim/data_utils/convert.py b/txsim/data_utils/convert.py index fc8d4b7..23ad36c 100644 --- a/txsim/data_utils/convert.py +++ b/txsim/data_utils/convert.py @@ -3,7 +3,9 @@ import warnings import geopandas as gpd +import shapely from shapely.geometry import Polygon +import rasterio from rasterio import features from rasterio import Affine @@ -34,8 +36,6 @@ def assign_spots_to_cells(cell_df,df,n_z_planes): results_dfs: list of pandas dataframes for each z_plane where RNA spots are assigned to their corresponding cell_id ''' - - # List to store the results of spatial joins for each z-plane result_dfs = [] @@ -65,7 +65,7 @@ def assign_spots_to_cells(cell_df,df,n_z_planes): # Append the result DataFrame to the list result_dfs.append(result_z) - return results_dfs + return result_dfs @@ -126,6 +126,96 @@ def convert_polygons_to_label_image(df, X_column, Y_column,complete_img_size_x,c cell_id = cell_id + 1 return cell_id_image +# (df, tech="Xenium") -> np.array: + +def convert_polygons_to_label_image_xenium( + df: pd.DataFrame, + img_shape: tuple, + x_col:str = "vertex_x", + y_col:str = "vertex_y", + label_col:str = "label_id", + verbose: bool = False, +) -> np.array: + ''' Create label image from a dataframe with polygons + + Xenium files to load as `df`: cell_boundaries.parquet or nucleus_boundaries.parquet (pd.read_parquet(path_to_file)). + Note that polygon coordinates need to be transformed into pixel coordinates of the according image + (morphology.ome.tif). + + Arguments + --------- + df: pd.Dataframe + Dataframe containing polygon coordinates. Columns: "cell_id", "vertex_x", "vertex_y", "label_id" + img_shape: tuple + Shape of the image the polygons are drawn on. + x_col: str + Column name of the polygon vertices' x-coordinates in the dataframe. + y_col: str + Column name of the polygon vertices' y-coordinates in the dataframe. + label_col: str + Column name of the polygon/cell label in the dataframe. + verbose: bool + If True, print warnings for invalid polygons. + + Returns: + ---------- + np.array + Label image with the same shape as the input image. + ''' + + # Initialize label image + labels = df[label_col].unique() + max_label = np.max(labels) + dtype = np.uint32 if max_label < np.iinfo(np.uint32).max else np.uint64 + + assert max_label < np.iinfo(dtype).max, f"Label values exceed {dtype} range ({max_label})." + + label_image = np.zeros(img_shape, dtype=dtype) + + # Assert that min and max x and y are within the image shape + x_min, x_max, y_min, y_max = df[x_col].min(), df[x_col].max(), df[y_col].min(), df[y_col].max() + assert x_min >= 0 and x_max < img_shape[1], f"Polygon X coords ({x_min}, {x_max}) exceed image shape {img_shape}." + assert y_min >= 0 and y_max < img_shape[0], f"Polygon Y coords ({y_min}, {y_max}) exceed image shape {img_shape}." + + # Iterate over each label id and map the corresponding polygon to the label image + label_grouped_dfs = df.groupby(label_col)[[x_col, y_col]] + + for label_id, df_ in label_grouped_dfs: + + # Skip polygons with less than 3 vertices + if len(df_) < 3: + if verbose: + print(f"Skipping invalid Polygon at cell_id = {label_id}") + continue + + # Get polygon and crop dimensions + polygon = shapely.geometry.Polygon(df_[[x_col, y_col]].values) + + minx, miny, maxx, maxy = polygon.bounds + minx, miny, maxx, maxy = int(minx), int(miny), int(maxx), int(maxy) + + # Skip polygons with zero width or height + if (int(maxx - minx)==0) or (int(maxy-miny)==0): + if verbose: + print(f"Skipping invalid Polygon at cell_id = {label_id}") + continue + + # Rasterize polygon on little crop of the image + cell_image_crop = rasterio.features.rasterize( + [(polygon, label_id)], + out_shape=(maxy-miny, maxx-minx), + transform = rasterio.Affine.translation(minx, miny), + fill=0, + dtype=dtype + ) + + # Update label image + label_image[miny:maxy, minx:maxx] = np.where( + label_image[miny:maxy, minx:maxx] == 0, cell_image_crop, label_image[miny:maxy, minx:maxx] + ) + + return label_image + def convert_coordinates_to_pixel_space( df: pd.DataFrame, From ed6beba743928d9c2d6ff3523416f711ca2f4bb3 Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Mon, 13 May 2024 15:24:44 +0200 Subject: [PATCH 11/24] Import fix --- txsim/data_utils/convert.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/txsim/data_utils/convert.py b/txsim/data_utils/convert.py index 23ad36c..dc9f7ff 100644 --- a/txsim/data_utils/convert.py +++ b/txsim/data_utils/convert.py @@ -5,7 +5,6 @@ import geopandas as gpd import shapely from shapely.geometry import Polygon -import rasterio from rasterio import features from rasterio import Affine @@ -201,10 +200,10 @@ def convert_polygons_to_label_image_xenium( continue # Rasterize polygon on little crop of the image - cell_image_crop = rasterio.features.rasterize( + cell_image_crop = features.rasterize( [(polygon, label_id)], out_shape=(maxy-miny, maxx-minx), - transform = rasterio.Affine.translation(minx, miny), + transform = Affine.translation(minx, miny), fill=0, dtype=dtype ) From 68b69cb13827df95d6420ddc5c021ae85c08e5c7 Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Sun, 19 May 2024 10:13:56 +0200 Subject: [PATCH 12/24] Per cell type computation for cell density --- txsim/quality_metrics/_quality_metrics.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/txsim/quality_metrics/_quality_metrics.py b/txsim/quality_metrics/_quality_metrics.py index 6f7d769..4ca88d6 100644 --- a/txsim/quality_metrics/_quality_metrics.py +++ b/txsim/quality_metrics/_quality_metrics.py @@ -11,6 +11,7 @@ def cell_density( adata_sp: AnnData, scaling_factor: float = 1.0, img_shape: Optional[tuple] = None, + ct_key: str = "celltype", pipeline_output: bool = True ) -> float: """Calculates the area of the region imaged using convex hull and divide total number of cells/area. @@ -23,6 +24,8 @@ def cell_density( XY position should be in µm. If not, a multiplicative scaling factor can be provided. img_shape: tuple Provide an image shape for area calculation instead of convex hull + ct_key: str + Key in adata.obs that contains cell type information. Only needed if pipeline_output is False. pipeline_output : float, optional Generic argument for txsim metrics. Boolean for whether to return only the summary statistic or additional metric specific outputs. (Here: no additional outputs) @@ -30,7 +33,7 @@ def cell_density( Returns ------- density : float - Cell density (cells/um) + Cell density (cells per area unit) """ if scaling_factor == 1.0: pos = adata_sp.uns['spots'].loc[:,['x','y']].values @@ -45,7 +48,12 @@ def cell_density( density= adata_sp.n_obs/area #*10e6 #TODO: Check if there can be numerical issues due to large area!!! - return density + if pipeline_output: + return density + + density_per_celltype = adata_sp.obs[ct_key].value_counts()/area + + return density, density_per_celltype def proportion_of_assigned_reads(adata_sp: AnnData,pipeline_output=True): """Proportion of assigned reads From e18b0b76e72bd99995a7cd02ab1d3e81534b23c2 Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Thu, 6 Jun 2024 00:32:36 +0200 Subject: [PATCH 13/24] Update qmetrics, coexpression sim metric, implement tests --- pyproject.toml | 7 + tests/README.md | 14 + tests/_data/adata_sp_simulated.h5ad | Bin 0 -> 392152 bytes tests/_data/generate_data.py | 17 + tests/conftest.py | 39 +++ tests/test_quality_metrics.py | 126 +++++++ tests/test_similarity_metrics.py | 122 +++++++ txsim/metrics/_coexpression_similarity.py | 383 ++++++++++++---------- txsim/metrics/_negative_marker_purity.py | 35 +- txsim/quality_metrics/_quality_metrics.py | 238 ++++++++++++-- 10 files changed, 757 insertions(+), 224 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/_data/adata_sp_simulated.h5ad create mode 100644 tests/_data/generate_data.py create mode 100644 tests/conftest.py create mode 100644 tests/test_quality_metrics.py create mode 100644 tests/test_similarity_metrics.py diff --git a/pyproject.toml b/pyproject.toml index 60a7efc..9ef2c32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,3 +14,10 @@ requires = [ "rasterio" ] build-backend = "setuptools.build_meta" + + +[tool.pytest.ini_options] +filterwarnings = [ + "ignore::DeprecationWarning:pkg_resources", + "ignore::DeprecationWarning:xarray_schema" +] \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..7445b60 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,14 @@ + + +### About simulated test data + +The test data is located in the `_data` directory. Some data files were generated by simulations. To run tests quicker +the simulation reuslts are stored in the repository and not rerun. If there are changes in the simulation code, the test +data should be updated. The data can be regenerated with the `generate_data.py` script: + +```bash +cd _data +python generate_data.py +``` + +Note: this should only be done if you are sure that the simulation code is correct and the data should be updated. \ No newline at end of file diff --git a/tests/_data/adata_sp_simulated.h5ad b/tests/_data/adata_sp_simulated.h5ad new file mode 100644 index 0000000000000000000000000000000000000000..244d10dcd0a7b3085672360d8d6133f125854081 GIT binary patch literal 392152 zcmeEv1$-4p_x2_PcPD5dXu<_@&~I?CdkLvwLTE$JVS`J!P`2$qaHyk{BLF z8gocjb(y;S5&UC_-)n;0$Vf@wcx>3E)s?s?` zG-)GNoHt~5PU6A$Tu_vQacHc_&tu4VM+LrLkcUveBv-5+_hr0h0{0gKj2EYk69$D2*U^n z?cKIV`>@_!`g0*f^*E%H`N-zesdtyawwOj@>6_sL3_f0BmNLKgy@T4*9%Q?aen3$7 z?lc4^<-3n7S7<<6qS|%|@p&>oPLXG4iT?*l2x}F_E+XlDqfsGlR43|1# z(M*?8_%d7^KAs)8lgHB*J46tYsYSTlUua1CurBSpx3w25V=}URI`U;rKCLdjoodA} z(rJ0S$)wlvpO{PrEnnPZGHUq}CX-3amo%BoTE3LYWYO}aO-7uK;_53-KReeit2RB? zFPo14gzJ}G%X9s5XnC$*PA$*%%cbSHeqLIh>z7;0bN%vYd9I(gmgoBA)$&}wd^)~3 z*Dt@8=lT`U@?5`yTAu4yNXv8me6&2*&sWQH{R(S&uAfcIbNz~Fd9Ghk9bba$$Nw6R z?YjimkNVXFS)B|8>T5{rF#JJlBu^b;fi3_+Mu{*N^{o#&iAnUuQhmkN%d9GYVEzgy!q~*DCpyj!8Ov`iSDrsM3DbNy;*d9Gh=EzkA)RLgVy>S%ecUtJwvn(HT~k&gYh zG}rGlZF;U>eJ#)RYoO)1ehsxe*RPS5=lc0;d9Gh$Ezk99qUE`MO?7-3u3s}P&-H7r z<+*+>v^>|ZrIzRVwbJrjzt&ow>(@rhbN$+Cd9GhOEzk99uj9*d{W@rQu3vzb=lY3h zvSa%#%k>M=rsw)~)bd=vPFkMp*ICPR{kmv*u3uLz&-Lr3;Xf(H_3N(XxqdygJl8K+ z%X9sDYI&|-h?eL2_0sZOzusD&>ldo!xqe|qv-8$)$Gm){tsQ&HI{Z@Io8^qhsb@H@weD&fWYQ@1GV} z-XCzoI{;=(9v!!{m@#Df)goNp_fLR41*P@MC9e6sjLb=l%u=_6SPJ6Z6Yy-4i2M?` zyGM&_Kg!ZW6a&48_|5+VTl~IOg1Gw^ow*#^lBE&MeR*M{i7B%wCMU3(c>gWIbl=(y zn}BRL(qAiYxF`pDKhGZnWV^F8ls`srA(sL!1zZZa6mTivQs6&cfrT07!}Vjk;Ox-| z*g3PWX;oSlfuxhh0r}4}&4vBrOPO}hJreSizX8{b80b9e8RTp|7tZh63ssjzL9**_ zpm#u9_}!L?`HuJnmiexRnTa38`M;053Q7Afff+`B)AlVI2xKcdo`Y%iM#Hunc1U|~ z0^};4i-n%816v0sX7AGHhx%)ao7SSy+e-VkHUhHIUWZJx7fuV;EB1&h%a}E3*tm|z zD*f^>BWrXyHT(ISRnXCY1UxB}2Trt1X4;lZJLAfAsh`kI4+P$bmR%hfG$lRzK zJUEydHs1Ue2D@ebgKf$(8ftAS0JqK^hQXzGnI?}%>d|3k3U*`7ggCygUplsD)(wbF zGXqlpvJ|ova)Wt6-fVI9@{n)vCfrXsIGFDa*m{kO^9v8%4>>Yifn}cAph=x5)8GH@ z7;n^GzJXFz#=+?8HVDk{y=hyP?SPCWra-IV9pFyKi%_b}efX)L2b+*INhttLVq4S&r&~{K`*cEmh`kaq}oaM&A!~M6Q+0|vxX6IDXzI{9q$Zk$c z2szg-Fs<#bVyt9rPIfh4B#<3^+6G?bm;|TxB!uU0k3yM>JE6+u5wNZ52AFoUG|a#8 z2%6?k27RyZf|{j+;M7S^wr+Picz*u`%-ygGE|ypmXWbjjgt5yj!#77}nKtKe5?1xW zXefO)0%DH%!sxs6AiQ>M@VdSo%KC)E`U}zUb+g=mDEpXN+o5FbUQjJV26&n689cp~ ziOska0bw_Hz<1|sm}R1JZ4F!lN4~gSDWvl|*xx1}gg?m5j)shdHyiH5iV{yNrM{u}x4o#d3*J@{Db@p$E{h=qJ zdqh&Uz~>Yk-&G#Q6?qH$66S^3kI%sTCC^~U@bTD&PC~89FCe`9V>osq05U%N9gdCZ z2U+Wv1JCd&u*`0-S2@bUi`BQxeFr>R4h_EC4NZTZ4@*Bc*wu!~Oua^paA!k%CS`uL zVj#=5rck5UA=u`Vg3UCV!R0d3q3D1ef6&pt^RIBIum{V#x`)|rWc%44l$wQmwu9Eo zGBA%?6(N0u8`~K+4SqN}t5WojZ{W(k`H(s34Y=Ge9NwI)!#sXAV7*rp2rPLK=G+K_ z$7?geu=D$1N0G5GV!IpjdD#)}r}AJ_zKeCTvJPEK!mo0ha>*y$aCO zlct>H%WXPZxlKMfE0*zfKE+kzQ(Rqq3Mcm=Ud{7;Smh{xHB4eFXWf>(Gv8U5uKa%` zt~C#}ER>HfFEy^zRK|D4OHSsiVqIBeUecHQlD-VrouyI3WS&-E-L_(x zzb+qHw)gQeKN&~$zb-wA)GN5Hd%Y;U2dTC8FJ+qn}Ehm~6 zS{}5#X!+3!qS4Y|dM=TkEu>}Mv{IgytJ9M7GHB({Xl_M6Dpp3TidF-So~5Gap=b#> zJ-1XJts$B}S`)NpXwA{+nVHsTZPD7J1)$M$AoQ$9C$z3;^jrl!J3-GsgrL!L2z}6K zy+5tVAA&|}xQC;SKpTlR3T-r61ll;XFVH5TO+lN6HXUsy+AOr$Xmio%H^KR63(*## zEk;{{wiNA4v=wNp(7s0d25k-6cWCR;Hll4t+k&4H4x=4K zJC1e&?IhYMw9{xbcl#CX9NGo6i)fe7uAp5*yMcBK?GD;qv571g$w*OSD#KZP7ZQ1)+69 z>w?w|tp{39v=Fr3Xklo5(fXqeKpTiQ7;PBZ2((dXqtPPJ#-n|KHW6(y+BCEoXtUAg zp)Ej*LR*Bk7;OpKGPLDrE79op$YAUlwT%8)zcaCogcyCXwN1g6^&|f4W6UuYVJ&Aj z${RC`NNlgEjqOGejJ3=7-k4}a8w_JjGbS5fVS8_h?KT42=OW~{qW?Lzk?-)?5dL(> z7;KSQv2})Gx~|4p?60$pO-3PX-}8(O*z&)?*0lqxwi@=#`q=wMV(UwYJ#{6v$|&rS zS+GZM#@^$FEp(@m&+x@w+so*IeRBx*A$M%gxsBDvPdGMg#6BNxxEZ<3*q!jHtmWA9 zzr^0Y!YGUH#3_v9KqjLYvI7iHe1}3s?Du=|H}tvK?u#2auqSxqn-EGFC5#G27aSiJ z<8SV@jR|;p)x>Cn<4u53#rO=zo~cGtqkz!}M}u|7w?=yG`IE3`HZ)2bosD)z100oF z;i!-wM}X?aCq^Ll;Kn!tRKgLgp0Nzah8jj@%qgwW-WZP~+*Eu=&LAAs>KZ9={P+R; z=0KyT5oY+|2$a@nVRXRJuZ}qiQ3r52mjeI&3dr|Ksqd|cf8Ofvy-z9`NtcA{mjJzw z@jfXJ%vblGt@|`-Ku6~javyr18*9YlG42^_&68mKd*Vc}%lj`?K)z3EtvEiKE0SwR zlRtF-X8V=v!e|~TpLaY>6DoAk=3m+$&FH9or^K^d79*f%_dY#>Z9RJj2GJB&|2{Wb zgolpi@Z_X#hKl}}62q;S=K5r^zb3~`YyYJ@5?grU5?OFU1rtYoQ|3o$&0UNAla~VL zHRP!O();Sto;vG(Q<>@cbC>*pD8DQ`U!en6k*BN%#95cy>YP$@UOI7dz;haS#7`#cjQBgFnZaNJ?)MYxus{@%a~{ z3!L}3c7l8Uj&JZ*My29g%$@r+MZEeG#D>Nb!?TfQq9%d}X2LHv{{8X(ZT8>n@3MY7 zVj<=G4?Bwj$JIAojG~j_@$D)J&To?#X2%Z-G_tr;9{l$Uu6PoodgVHes$rOa!^Y&9 z5jba}_x_>2Ez1{(+g2Q);PwipW^0Y#M!{_r+)lww6-;g2nqPCoG}g%SwNP+N1-DXg zYX#R<@TUr{qu{y6)a9+11{^D;aRrx9a7hKzeH&|jr4?L8!DSUp_j|1A%PY8of-5Sxl7d0OOu>~ETt&ft3a+YP z>P*)1R#$Kh1=HAPjbBS(IT#O8@L&ZGQ7~QAQ+_i2Fa?Jzc({T`2rT;#jS1HB;ZX7i zO!IUrPv>wePN-ly7h8Ec&s%X41ykR)^0aNma^4}!OXo%_PbXe0rt`lQr&e$p1*cVT zIt8a!a0UfuRB$E*XI3!H9IWN@RB%=WXH#%?1?Nz3P6g*uu$O{!D>#pWy%n5S!TA)N zA2HRLtgiwJE~wx_3ieU3uYwCJ*rwnj0?YnE^-txInCjn(ss62)>fef~{;inm--@aJ zt(fZHimCpsnCjn(ss62)>fef~{;inm--@aJt(fZHimCps*h|4w|5l#r--@aJt(fZH zimCpsSk}MPTh_nCvi>EO^)Ioke~EpS{AK-1Ue>?FYW>ss&{{q^A6hY;53QKahgMAI zLo259p%v5l(2D7NXvK6sv|>6RS}~mut(eY-R!rwZE2i_I71R09is^i4#dJQjVy58A z3a+AHIv-lo)A`Vf>3nF#bUw6VIv-jwoe!;;&WBc9Tfv_ynC4^F_%t80Vw#Ux@n;II zuiyp>Zm8f!3ielUV+A)+a8m_0Q*d(yw@`3P1-DXgYX!Gaa9ahpQ*e6)cTjMEf&&#C zq~MMU?xf((3htudt_tp^;O+|Uq2OQz_f&9*f_o{rw}L|z9H!tt3ht}mehTid-~kFA zh*tx80lkWj5MS~SOckY?<3fhe0Y4AdqfP0QS4QA*(xaY{z;D#0PRzzh9oPDRdv7y8j0i=4`f z*I1fs9h~w)KbnI%<%ND;oOaA#=tpxjat`|!`q7+?oC7cPqd6Qo2VUq$b2@SkywH#4 zc;p;-p&!lp$T{#rKbixQGtbND%kjxB-eXB~LUN8g7m((Ng9@7y9|@c%ffo9WV52qT_{rO$9IKadP|@^2Pn0rSfuIXs$^wMNw{(k?l&hdai+QGGfFo1LzG z$ys?nJT^LdI{rbN{v*#T$JBSQ15fpe`r>iW@eOgt)BZ_AEdNjk9;NrE|M&;f4|C+r zBdCE#Q3KP5JMef|lsg?D^1~f@QWy(Ke{8Zl#{C{FKHr5nile~` zfw-1tMv3G`iFe|{KYslJ|3NjNW5CDw-JHe{ z`MdeY*J-V&x{s$5jeQ?OCmK6H^xeekqT?pPxGwj$rE8xoDO?T_(=eZ=N!GQ=l%DN)_0}_JK_*tT^1&2qCS_!; zyRQqIej+35I&@^~cl|Rk@3c{An-9stYDZqUob0EpEZ?p188Wx>WVK$;-hFF+HnweX zy6`c}va=LvBbyCxk&FFy@YAK;HsoYgH-}!>mM|Clsp}2@&ZTm&@D=kjbbXMWC9E^H zND-EkZBPHCPH*oVY~z7hWyh7t%@Y3xsq&7<%^EE3x41;z+$?LTEn$YfUaUdN34I4; z@n#K1y;@OxiZ`o&e^QGXKjmRbm)`4szI-0$x#RkTUah@Z`muX_FRb=vzfaxYCu?1A zwrc0k$tE1h!(uKLDV=^;UUu=z0-3KY%*PJiykGWt_IzyG%?Uoc2IOT8wnvATxSpRa zIgtD8?Oyp=r;HnqRX$pP{d6^T_Wmmiupuc<-fK9l0J}bAvT=V-0XD;y{)--g1=xk^ z&rbe0xd7XLE#ltmHicO5p^g)7PcFoMjNNlN>7hdG@S&;G1BVr4$JvHM%_bLQUG_`~ zI&q{R%U*ZRj6~0U*taVqn&f-!!`v=?+hPy+ut9Ur4s2ZCmo1{r>r$FQCphGediZJp-Tn>4(&-^9(lc zyOAigPC~Y4|z9_`>P4sy)uaf~rq#aN`y^X8vea<6)B z7FaRq(*x;pvjpXKZTzi*7h6y(Hu+~ida?c|(xm-nhc`14XPq6H!JAbrlRZ~i$^K}OO1EV@aVGix8@XFG1@{G`m;0xbW@g@=}YUx@h+jvQ5C zU_tiVy^&$HpA}?vioA3mnY9orzap^Hx&;MU+J{~0dHz(8ZSH9l$}r8B)!owfaPJmA zY_)BBi!mX-?57k%+Mc@N!_qWK_WJA>KJ5JY`dJG0@MZa$draLo)rYn1e(JveQy=!q zy_8$?-M*~fQ2WUtdWuxEET)$#?BEZLBU&eCIYxexC!kAOcJ9QoS4MPt<}u3i&b1X8 zSc>)acI~W~ku@;x?%TH~6MN}hv-!f$GqbEeUHANXc@{SQyA84F!?UvTQ)?gCJuVv? z_$FoK+AN-I%i`VlH&@BQ+)JMOChI3T+2uaRuXW#;lU2Ij%I*7roGf|3#?W);bF#kA z8kJ3wA_prl>cEp}W3sdSQ?E7Xb15gw+34G}i(+t*ltG8czUV5H#?rKLXC5;^03-oNjfhskca)a-v7mjl-_LIz<$98 z6MD06qgbViGxD$ogFIVA9K&&`QuUCpD&%7uzP#0C{Na4;r0=^`Gv4H7<=V_M0{!!{ zu}4ZI_A8a2RXow)P`|eM*^2Sq%1zvqpY3j2%Gmm!-&(CsJGrD<}EWm<2 zuKltyxBz=RIq#mB@%dRqscNl<2Nq)84rLvY=Tsq9Fx9SCBeA_KX**@f{LF>ei&Z0* z_bH6wDPvC7salXtdpPdPDZPD|5j<_)}{6n*{(vmiv8Oz`Q!$)Pu=&@?+Na}{WMi8PTs#0ZmdhQ;rBx~ zpu@dn?(@d&hjlTf$L~rJ3s;scySc8?71(;N&Gwvq&cnFCGx_Wz_Q27Fzu(B^^9y9l zx$<4;r-xu{>wv8tzq$-7vcBj)_xde3Q1hX!a;B>=*|+tS=RMcLk|g!IR9kl*@~6u9 zy4k8Tuw`HG#gFPD}Fs zW5wc?8G2rXdWn`dySs7^#IVerpFKGNd3xNqw|f71IPR5v@zbfN;FF9omxm0!0ke|T zx;$yy1t@WQRajL2+faMv`N(5OPD1UiryKry|99w`sAv5*#r8svegg)+E$};RuGaj) zz|@-{&+^U16Aiip3wpl@n>Y13(XRz(EZPK_e!Je{?$z7iUA@TOGMz6& zzN8gbo>_SSE|%TAv`lS$xBRa9ZQm}t0l(H^&(w^?9yvuChiNAJ1e%ih3*njP~Te)AB@^?LAb$I~aUVrA2-+x%Wb!C5_O zz03L(Cd?Zacq!=%m{wzWjwLhSg1z3LPhLNK4cq;`UbEiz48HyPlYv7EJcYXZyFFX; z_BAwawQ$3RT#w+0AnGk7>R7VR(}AzSZ(ZLh#qK?W+dG;KJpSuL zXcp1X`$fC~$M#b!T-c0Lok53ThUs-DVP{7&c_*gj|4gP|E;!RSHV64vv41U?T|MeYlF z4Q(2n8tZlU1+@RvYx{3=pTgOaSqlvadI+!6-0NMBj(A*!ukP;~@Cu5Y zd6&tp&09D-Jw=llX&%DurKvWJ81fRXo(^B;|I;IA782XJmopy0mfzMi z583nvywYxqPICPPG~ex(JX_O8@biOT+`9O^gpNZiWLvrP5mfrjbK<>G&!Noe5>2hTq+qZH}`d!tZQp(zoyTb!S=U=S}y^XoF>_R^&+KJqcOOn{Ugu z{MwyOuH!kXM`?G~W7w{}!%HS)u*=WqdtWzp?z7qh?iKW4?g^V!{B2Z1mclRHip0qh zv#b*??%Di%BDSuAN8651-C42Br;j#$=Ee#aZ4-HXkUKkjDM_a<#wTE#OSO3WQ%yJ4 zFGt};_igU%$>R1W-y~1S8XuYbuGy+Y?B(Z;dt}&_fGx|{Bl!4a5BAN?HD$vd8_YZ7 zr*CR~o{&{PWGfZAIT1@yWA881*Bb2U>K4b&_jhNDYoARKGslD36E0=pg*@2O_7OoR z&KWFXa&VRhT@$iSJEP7fYLbYxnNlEOjZ6txNamoYTQ($MyBah!3D{4$E3}&VAQ2m5uRQqk1c_L!S!ZKbC-Pt; zS3L_F^)eAlJ@S;_?hto2u#U~!xA^X+8c+GL66R_LGR=r5j$&C%|o*`sO>x3*>vR|KN3rNhmPV`z^wV^vZ zcQ4<#RnUzvqJ}1n?4-4(YeTa{-zm2%R57o+h?D+%zK?|Ni}Oc^p%4 z2OmS9shICSx$pSr^eOh>W9u^wcMx|!sy^S_-@Wh1Q{%f*x&#LX^~ZOhhyeL}U1&ht zo*hEl2Dk6Q@Ax7P2&%hmgjP^Yf^OgGifB0Q25wFXSr_-#Dq|y{JDZhDR{Jk$0#^L!E~$Ns?RtDk5}*n1%IL7 zi3*;i;7A42`P{mHIxku=ozJY8pBLiW?{tNyamN~;#tSQ^{$|D0Ppo*3g6k`|fr1+< zxRHYW72H_CO%&Wz!OaxhT){0A+)}}<6ijQqtj9}hyR5jag4-#WPI%V%v^L9%0~AcB zP%9s#;EoEW^%U0lofS;$2&_E){%^(n_saP8(OuztD45nnS<}-RC@T(8a4!Y-R&c0- z!xT)H64w0tD!8A5ak}w`_&DwO10JYgT0>;bf3SjSjgXb6H9=N9Ou^v_9xgFeJi0hN z1&-5G;5a=64#70${sfNGQ{Xr~1&-5G;5a=6j?+`%I6Vc9(^KF$Jq3=_Q{Xr~1&-5G z;5a=6j?+`%I6Vc9(^KGZ<@n?Dq!Sf7H#BQ~xGR{JJXv`U1=HFjE1yWgi4{!qeQW%r z3QneA`YqQQpRUENIHiJ9DLA!)DXleqS_P+5aC!x2P;f>CXHsxx1!qyPr-HL8IGcjA zD>#ROb1FEOg1r=+Tfuo0?5*Iu3eKnC{0c6h;DQP+q+lNf`zpAwf^7;eqTr$mE~a3+ zfq{!BrJpO~KU_ zTtmS%6SnO*9tX+&_)E+3B1?&caj1c!an-9rdAe2)Q_(DALC~1e=}C zDdhgZ;~|<&7}L`^hMdaN%4uppbj~4X<;@B*SH|1ybPghC;mtMG*o3hRHane@$XR&4 zG9RI%+?34kM@XwCbM9Pv(ZW=dol| zgBXeQqjN5~c;2iVTGnswpU%PL;(6+Q@$Hw+$>bz&uFA*EX?UUM8t5ENE}kc4sPE(M z@%ZSRP0qrb&t>4j;X&H$bPgwH;mx*z&DZ4VoKDWdQxT|!uo2nhV@iMEO$BHe!u;u+ zPcA;a+4)Hw+_2ec4nWSr^RA67)i2En$XR$iFwB~2823+e1ak2_wI3`THgHN$a|Uu2 z9uJI0PkMgCCLfct@}wOV(rkY;ryyrZZ?dSKsXxsz$i?xLG4>8BBJ~fNbC9#}{FxOj zCFxId5OQ%m9u{UmMKJp>%}K~vc-jM&jkkZAqmZ-kczBdKwE&x)<}Bo_yxD)L6=3;j z4nxkuoBGoDN9SLf(~z_BNKpNo{g;`n!kdY)jF_J0Jmmh6o_HGGkf%8ixp*FnPD4Ag z)IVuXM9#uv%~BPU0yaC%k;ujK)c3LMlmQ+e&6&tqd9(kTeavR3ITX2go?14wJu0Nl zPID@9aXhJmM~_v6m1MKi9E+TV$D}m+^ZKVb7dZ=Wb}dqu#y^^ak+brYn9iQmerZld zE}ln%_D^w;r#TwAc%Iq~=|dvf>@;U1XW?0mZV}8#(egGEwQsY<7Pq zJRKkHpYm_)$YZ<2{HX(@{!N_l*8bbnkvG#)@o4;L=D?fMQ13MBuelSR>W|tl9bXGa z9tk{ps$wi(OD8;4F4Yble=A3xIzAT})3G<0?@T9BR{!RVbDm?W%UOjYx zHhViKJWUQzBy1oydwWOTY}s;t)WMO*gCl)tl(E?Z9C$i3%-^hHn?2A8PlGSYXZHUf zN1h*?Swl8^M@OE(I)ECQ#nK*fFlnaMlltyqT&y6B))&Q20+FzJM{!}h3fjGWC zjyws7+F={P`t9q4r|OseyPpGZ_AV+umcYq#qm&d&U@C;=AK;LlUtjS;IC*Z=oSKk+ zc>H*LgB;VFhfng-{s%kZsfSPwq|XpX9t()AhjhmB4Ryj(&!a(t+RreRr=bb^ABjx$ z6Yj`U#x#4x44gbSYL*_0jxCIiZ-kN_%Z(L?qX+E+^B?KRoBadhnC<6tCp?WkG{#c@ z9OaD13Z~XY{AdRri$&^*@nMW3PZi9Y2bM3w8BYTw353T#)(LM8KxPlN*~dBI&2tm= zGSp|hGoJTP^ZY$Q<$1v|3mPMA_AeZH5}#_ z&CX9FFzP$mfhXyyeyEMu>{A?hOil_>1GCwuI`YWT_{7`KG-o`ryaH_Y=?*+Kt@!>u z!;z;$!{&ttiRGK=z?%m~3Yh#XmB(Q7`~5m zv;FY%?;sDEssHv3{no(6W@H;qs>`w|BpIa3?-kYDPACuK1T9)``n%n48Xr}jt05S zEIVn8`G4z#r=~-Foa*O0N1nPTsb|*j_fB|f|45ttXPw|v2w#1_$9fT-Q26qF8XE*o zE`0f1a6o%LquQuRw@FDS-v?ta#_t4d*79Xd<_8@w?xbwd@^+Kis^vd1nQdC0-$~i7 zv^>{ukCx~9?bY&JzZfmg z_1mZAxqkb#JlF4lmgo8%)bd=vLt38ecUZ@Z7p@)A@?5{8TAu57Ov`iqj%#_Y-w7?x z_4`@NbNx@4A-f`rXj- zT)&%Ip6ho@%X9s1>-bW|Oy-W3=lb2%@?5`rTAu57U(0j-9%y;4-$O0W^?Rh{xqgqe zJlF4umgo9C)$!tmyw9{e*YCNO=lZ?S@?5`{TAu6oO3QQoUTb---y1E@^?R%3xqk1o zJl9YDpfBf5aq}PXLf)j9UOopw*N#?9*P2#5LBVvbYURf&n68zr{CEY^y#y;y_YSO> z?iD0MljR$wV7eDz<>}hris@S4iu)+IuY&t2xW9r2D0rZP2Pt^4f`=%0sDg(nI9$QQ z72HC>Efw5K!L1eCM!{_r+)lym72H9=0SXROaFBvKD!7w^J1e-0g1aiXn}X@ez*_%3 z6dbJJo(c|8a4!Y-R&c0-!xWrX!TA)NU%>?wTu{M<6zrp5Uj-LduuZ{56kJrnbjGsk zNh7Qk(}-%tG=f?&jhI$UBcv76h-k$$0$MSRcveg!oE4W-aCrq+P;f;BS5h!2m?^lj zf~zRlPr+3cTus5%6@@#L3a*_}oyzjTG#! z;KmAWqTr?qZl>Vo0?X%THYs?sf`3r(76orr@HPc+SMZMtj#lsv1@BbwE(Pya@J|Zf zqu{*?j#2PF1@Bkz0RQ1DF!-%{{x1>aHdT?OA$ z@O=e8Q1C+qKT_~x1wT>nQw2X$@N)&fQ1D9yzf$mP1;0`7TLr&UFwM2C;~~O7V0Q&4 zP_T!B6Dl~7f)gt^iGq_Vn9h~f^3k>xr%-T81*cMQY6YiJa9RbYQ*e3(XHalP1!q!l zW(8+au&08vDma^hvnx1QO3sllV}?_kCFjVO zHN&Y5lXKw32U29X^yBm^%k`r+PR^0%`ca!F=g4#Ys1J~Hjy%_o`Vcusp6f?_iku_Q^`kyUPUSxl-$$W7N6wMw`cWSw=g4#Ys85n}~n(nXEIO>qlb-Imh%|KN>^GIr3aT8dJzQ@?5{XoHnnUXjAr&l03PQPsj7*_$_Ge zWIv2c&*e7?=y)!_QBcQo`Hez4p386eXn1iu)$rBvTz;dlj_2|lHXYC9H;U+ZpBtNHL@^zCp`R$G124aCCW`6Ei-L(_I`X1mqL_}nD3~aw zBQFXjis{IUf|b+gS6URTyp9(ItDxgW!7A!_QLsuHUVh&UbiB}y>3E@EWgRc{tD@tD zettS$=vP(83;n9;c%ff)9WV5&q2q;qH8s5azF93DFZ8ReUg1F9UU+9tE=OM ze)V*`(C;%HFZ8Rgv*AG z4;?S`3)b;Mzn(f?=og~ng?_y>y!^gdZyhi63)S&Lzc3vy^y{PH<%3&&b-d88pN<#$ z_1E!2zX3X4=r>Tu3;hOZc=>&^!8%^(H$=w^{f6pzq2DkaFWO(Yju-k3*YQHX5rUW3 z$#VWJYeq#l%9-olU7^mTde8zYU zFXS^OXn0XP;|mQhif2sJ@Z#`|Ng7_x`y&OPLip76ueIVD8Z}xFB0K0Uv>TIVokaw zB3!oT!1iJ7JNCx68;bCynsm!VxIF$~^EHT}#+OQb@ft;Axd=}n{Ki%ImBI>vJ%sPq zyoz~=@s$!@t4?vV3#=63I(=3NUe#x{5-#iaYb9LP?>8b`r^^~my0s!)9#3GG0P~dg ztwVgf`CY*8H1WUJ#9yb0zg`o6gC_n)P5ezFT+UwuLVLIE(H=i?*q;-dHR*o%K)NlO zbX!HZs@FD6{OuxKwmbRq%^yX065-4DY(@+0Cj0@y-=Tzy!!ve@a9N%jLBVEM*d@Xf z317Y+bhp5=y~%dGwYS46m~+f_~YHBI<+P52EFF8hVl=cXq9ElvE} zn(#ZC@VlDudz$e3n(zloxYX^TCj5~SE|2%ICj5ye{HZ4VnI`%k!u7<$VC1FYh1de0g8v z@A~roht8Mxv;M9x?+fXCdH?b6`trWspY>CU`ubRYe9PfmuJ*%qaJ3(?Z<^(3j7rXK6Jiv@mx)p4$gIu^I(_H z^>OL&*K}x9@3X3Ic(=P4Pi7<;CpI*mmk5vb=NqzLs^3*jBjVlfEjBb>77?B$OcUWh zBslInhN8UkdzYKU4;Gz8xPQZH)#xi3?-VBwvzYk7Le9rzxXb_FP@rbD%Ju0;n~B5q z5`H`R!$nn9%W>69yUl48v9|7FN4}PabMCm8M zUpmzNP!#?5exnNUEMyKG#UDp1BLf~sP%tXkCAgDKeC$>CJ#1Nix?d#UQ$TCcq+b1n zUd{M!@NdDmtv|ED$6xoj-*eW=AMiAZ<+$J_fAG}A`(F$G7~iL*wT1G#;a;MZGtm$K zX?vpUS=pWz;r7QU|KdN<2s+HGJ98Kt1?x^P@vWS89?b3dRrUFq#=+w&_VG21IWVGW zuNR^7MnieG<;m-=S^%9BJpZ!y(`m4L!NtBye1=1(!})G@i4BML-z{IW>g@!mmBRf* zai8hn`{TP|-_;)nL6;vr_p3e;y0)s4DSz&8=#}TQy!{GCLh~2rI#u(tjK z&#YUfLY>-^uMSB$2MT|>KXk^81rT{}+R3+x=D@kn&Zo@w?FeXeu1~P9X29XU&3A!z%y&_3Wt-&@o^00I#`_A!Aa4R@?4N2bq)PH*+Jns_pn>7lbxWH_c$M$Of?>tSz{tCOUPd>b3;9D{ z_YKKA6UtmGaG+@7iBM-ztGitfPl0{Oeu-R?cPf-fS~&7<)J#Ya7&El?uqp8Qsf)E| zC5?h%a7un>;Vt=V0!N!^Ec%in$?+?la2dJ5{= zKj({QyN1nxX>0bDd~k3Icn8ma^mUu@aH-KR(_5vCg866CKk%(T7Zx4;rf0_KbD&!D z*f+au3!wEk$6meuaX#GISn;>8{^Q{1nTd>@=O#dC#ltn92_ICmk`*|&E1lktdRzZmo%6G zm4A8K_-^DRSXMDdpDg)i!xvxIsbBu5DRBI5%0V@Dje@cL>n~pKJrjyn9yw_Ip^;$p zfAc8wrm4{5)+DzYizh?O!dXSidrXGUqNg-C6)^^Uet7sv`x0Nk4~43gS?e|#ZoYY% z;Oxt}kUjatt<$$HfUB8y#ClGg0{32jncQ>l2v~hIW4U!+Qz7`=?7;q{epVj6VV`2EIA4;KI&@-ElpmvE?>e_64qXQx2l ztM|KIYc>Xo=04*-;n(5t^oK{u8n2uTPlmK?)$Ua!1a7TYB~_t0u>5@IVb!}$hUjH0 z2mf$x7A#B`R`Txequ}_Yo%d>wnFK?l?jC42b}AJ8DofHFjpjgPf`&22*N%r=yAxk1 zGjJN@JW$QUUU)Rr&i3%wcM+4}rw#+V_Q*3DcH}$1{L|u-VZovvsS0(N1Yg%0Q7mLs zBvh#Ku5Z_i5paPq@11VbVAr!5nI0{RfMVGKei(9g64d%~@1nr&5l}wG?yFvlK8L9b zpLP#FGaSw}2-wmo??QO~Wh_j`ewT6kzSQT|&w)%+60Z2-^>hfXv?0yQXER|*Xuzk9 z-%WuuwqFVkE;tE7M$ezK@mK^HbvtKEc_9*7gfu;3{4^C_rbxUzyZ3as`|E{WEEnu@_mBwuyn@cbN%nlhucLWQtsL_7P<^fVRO4U2P%fH$~nCM3^-G>{EM^==EA}& z)uybO7Y=8RUW*;NZaM^A8Z@c#_DJ~c>4{9!U(AC9?~>=*muUhVYT(^5VBlo96+Xf* zEP5Wa^*pn>OocgM^B!^LB#wVCY^QABw+n|XZj%?(STq_|ZR=6MuR}O=KUcu0d}%(k zZIfW~dSfb-%AWPpI-iV$kibVXS7W~jd6~J>_C*U}#G;jxf@33LRE3xI+SXYBA#UY^ zIzO8Vg?a>3I`m{3jIxFO9MNM%wJ&W`p!D+kxmsbp+j{wXL_Qb?jW+ayjIr}!_T!fug15~E zyAjHIJ(~qVS#E64wPgw%Trr|motQcBCr)v$DDP1q?z&YoA9ws+HH$eD(#@CS=jD9D zltj$$6S(#F;l+@58aVo=^C-H{FXvGUar6vTGt`_<1*Y%VKDs5`w!hWkk6H9^EG4B{fYQ|zBJRn|Gvk+H_op3=;Q24 zJRVseA!5O9{QVCvWIycWeGhp*Ba2)ptVt@zn;5x3SQGDmE%;-cFIJ(5a$X?UhpYGL z9NTOBeL7yJ%&dy|`2fFh5`AX*3%D;CE8 z)AC#A1N4mJ$0$EN8%e+U(3mIHm;Jf3SO_1D963H8Ftoo{@K~qO=Ge=Tl*jQ&T@8teMsb@2d6G{zz4>VI4Z z*I(xf==xuzd=N<_`&j+2zIZ??MC|Y1?0@v^{m1Bk^z6Q_|8>s8m5LU+x%!_F!sR(t zK-d4$h;}3seXRa>R6JOCUpz4QZ}z`+Son|8|L9#9y8d@PpR51LHskU?tbnfnMT&ML zbNE>Ouf8|WIYjL5-|T<%ZkUhJ|LEN^y8ahA%~}7`uU9Krn8%m%eyO+1|KCtR?SFCi z6AH;5$93r4zMr|#y=SN3p1pfu`{RxY|F3%T{#4xl#0shq8_&73$nZnIXIwAB0GR8rR5^0M%!W2jNh9$0K8N24$q9);VngvD`13rzbmC>)LUVcibLFv8LpZj+hE zLq1w8T8PksI0KD*iW4ocS#}Xd`y(7BxcUN9+;B8zF@wS|t_&ldunbpy$x|HKCSID{ zXB39XJk59%7fpu8N?zz<)`Qq5V+KZ&Zv4^9@qb^k;v;qqMyxD;?H;8MV)z<;j-boO^gBVTUQ(aLS|$yu?C zuk$Ic8lU3o;!`-e5AkZA@53rb`Kw_PTRH2t!gS^TD{-xPsAZviba|<9rKU2z zGhT8sUlr@hBJ+~I+?VuaxGrDoe&jx!#g+NV_>`|TzTA(qc$9{mjQ>8L;yK%gHI0lz zzIA^R%kmRvO=k_4`}j9}+ON#VS_XCBR_rW|8Yc6!`s%h7%lviu$g;hUm-)#!sxS8; zeTuK9k(}x~izoA!aX%D?;!|95Y8qWxD6Y&``Z5oRt#O=f%XsheWxDspAx`o#{`<;8 zab-N|%j1^5HC)|(U*0mVERQUk^kx2P9x^_qce#I0fe+n3k>8Dz>oBbEpZk;dPu4c1 z$}ffKgVs{hJ1gitT&d7#oo_m{3}_kAGNI9$-mGZZ(6XcDMDs$+gO(R9KUzUFdLf7n zttgrutvFgqv{GoazP=nIr&&^|@0i$<^Ks*lzXjb2gS1g#lbbF`Ld zt6L+KL(qny4M!V+HWF1gg=mY=7NadeTZ;B2+6pv!4-36(gx&)} z@9g*vZ9N)2zqJ`{3)(icAJKN8?MB;!7K64Q?I7A=w4-Rp(dZk^C(%x!okpX%+plQn z&@P}|M7xA`1??Kz4YXTmchK&l(Km7FJGW2Jo}s-!dxd6x^VJR81R8xmmA;9Z6pg-V zngWf!RZ8CxhFdy3Xcb`Q9DJ=W+yXoTd{7x-2rl<^NA9pnGmKiD*Ys zjDP#NQTe>%(W$)sMT;H9_rL$R=N-Lh-i3~)J|5z5(4*P({~f13?---hvszNR4kA~d zCXDZKa{P!K_x^bA$>KkX`m5{p>i7DiE^>ZsX(<0u$L}hvO97VxE(Kf)xD;?H@L>fO zW|$AxkL`l9M}0T zrqvq_+iut)?Y#+*t8^|FdbSR19hjKCOP?R=uPts`i$-rN?c3T2$VPh|GR(K4B7TQ2R4E7SHI?kpnqG8}04 zq7qr1kz*ipqi*ovU~1TS^II6~mh}&|Da&Z6wW$EyI(HZbm)>QXJRYe>hm|SVjWrYE z__}`S*q&K8AU4emNd3!F$X3V=<^_4P#o5b4zQLPtKjq+HzB~9`mXUFO;i3B>M}{k~ z%rhG_sS{=T``;Y{?=e~9jw>+Y7Non`6uSS~z_eGFe1L3w`3UGgVGNAEPzZW2scPCB zzuC}ibw{{1D23_A*70CdRuqM6^Sv0wvj-(+fsm3N^w|mX_wFxZOgJ9kg>!RXf?b8-064`N|m_} zKlSrq6LQUlH4BbIhvtdckeKUmy0|}do^t@&4r&a$!j40q^D&UK+!%Pc{}wd6x(wRv zoNC&)k0%1z&1nfC=h_9PwcS;Wm5j~FuI7sbvV%|Cz^fdS;MAUk@civjC{uAKRJl9? zwpHB#({7fA`8OUx)BMSx@AX|!vvd%gI_b&Q?M?^J@1KCV8&<)^5^LhDdxM!Uc6nv^ z=EyA5<{VDKsy-MErO!q{%n@H0eRm#&*RBm-*SAAipKw@zAsW7JmirH7A5&{Pl&swg zs%6LkFS9*^r`IyE8J8j;?B)*m?tBfiOjNF|fotH%7q=^gbbbf>+vJ1r2f5kNkg@P) z!+ls$;%TL{H?{&<_ER40NQP)g78Yh2=~SuK7p4`pe+Pk{pPN>xTS`_oQ8n<`o{F6v zF%Z1(tb&xu(y)eWroag}3E8U5fmZ|5n-<;XE2!Eag84*^g)L7D1Nqd}$o9S+2;a8L z%SykR2c^Hw&U|YpXCsTwfa5W%!6$GJjCqpCw8D8dg2#qXsJU?}^xkm|S~o8Ns~aAL zrO$W5bg$)*YUoH9zve9XpU(>+7thC8`a7xEwak%_w%K}U+`^O9fxXbc76nJLPlebU zX;|xVrJ;ZBVyxiojNrBLI?TAg0Y-o3#;!(JfMwrgW9>JlWFvQMfn6u2!>o;$U?G10 z>|w*lP@_m$D3teSD7IiH)Pz{b^Yjp8$8TAYrOR<0Qm5Syw|`g+8Wpr_AWC^EDq1ROsEbNWqy&r8`M^w=KQv0^kFnm8A( z)y~H1?B5RiLr+5Yh@@Qfh+huugpnrz~zSF@aAM4=JB%u z>%E#lV9ARx=SCPjUYiMqo!{cd3X1N z0!uf;)8;l9ym7zjhx(j=iF3EYhtFuP7=J~953Sde_XGaHdcAh^dmcLF{!`>%DdzvT zxPSHjNhJM1X>n37*ZmWVD6UPH0xkty3b+(-DezyW0G<75EfM*0n+{QKlTXfyWqh4a zan<+~R~Mhc$$f}d^L!sxIm%xRli12xwjVm>k@tyIK zlliJxR~DI<^yR*!FT-{DTK6OO;ViDqPsXQwt?}i4oW-Lw$rD`=FCH-M9E>cb4{9 zJ2JLE%B4N2!L9TmTW3A)BN=Z0w>oKGYPA|?y|dQ&h5N9vIBTU{*-9J2?lu40pQY}P zwzA(J)i^WBrT&k`;;fZA*~+|S>+bQ-f7G_r%6MdKvX%C+4XJnT|KG2F-wJ5=Pn^2_fKX!f9BcxayI35TjBp*_fOdS9ShWQP43qW?MXWS>^4z}{=5C}3jB8k{<{MI zU4j3uz<*cZ|063P?{~o8$MZiwZ~gb*@37TctN+CN9h~nEl<}WV;(8tPDw%8hcgOF1 z{Uh(!*!~|Hzw^C~?Dy1ty5^e4ZmY)m<@|RsXSKYJm8~na-gD=C^PTk-QN(|@|G!!R ze7}bLeE#N1uJb|`qh0G(?b}%(wul6Ob6dsUzg0bF>(={8>pI_AY`xF4Ub_~~t;N5# z7Pq$f*S2}xE)CmSyL4#j+E2z+$653r_;S6^A)H4#yY0pH@0XQ+@%PQj`jnaAs*t+Q zHcz(KSKj9^Iodzg?Y8-r^NqpQS2DZbWd8qHhree!o76eqIBb2n{%^hC!e;q>oOap& z@r`yp`ZUWm;^6<|Bl~ZM|4&!}ydD{QP(|$LDH&sJ>lY0+>}-9`LBp2Lk1Fu6{`l*w zi@mMiuzB;^O&fSPx9z+9`W1J-5_vqxi8mhnEdrhj{<`ln3YaD6;h#7``6$TQ%>{qGjqvUPu*wb$N9UU%&Q-feLN>p#hNT7P!7F6{iA2yYK#{omPl1Z#W|J>>kN z` zm;PiN+W!A){;%%G#x3)e@ymQnQbFF70dkOP!1>y1j$*`s@6P>e~GJ z>>}qMBiro!JR|3qmuutU{CooEmz)Q3oV5ANJY-%nPgiTs>#^(W&-Nqx$$VsftRFcp ze|G%Rjer5|Zm)?K{wbKaa^v0R&9pa16k<6pfF$a(H#w}Z!7#;46g#)+NBLJnsw3x=6kBCtFr0{jgm2mpU2Wzq=mWL&kZ@c;q@F$4S;xJ5DkWxvofiGGFOW z)>XzM$4MTS;<4^uW$9Pim94ZVxr|fBE%VXpwOrbhc?wH^(vOTs+LLh!OD>N~Sua^{ z?YPO~O4^a}NdGu4>0e$)$vEV3DbM@jrCr%i<|pk+KhiJTPv$4%mHuQuX-C?XexzR+ zhqNzS@lq%INjuW6^dtSsIR0)s(ysI){o=T!9~qz2%l@*TtG zTyh>t|IW_?b$a?0H4{k@H{XDf5^0koA#y$h>5{vaWJ`<-C*Y zoLu+hJdpjQPWF>r+Qa^3T=uj>ey&K`k^QB8X-C?Zc}Tljd$J#ES8Gq^FY6}jAmhjW zg=KuwzU(h7xztO0vK23NxE|7;)JvW0C;iCy<@zMoFF9Z2{E_oP&L5el92aSit)q+w z$06gB|G7ecsSr6I%tNls4(y#Ep z+Mdi;#wGKU`O5rpzS5ucFY6`kiI+Orif8pQFE$R`Pui97;ka47j6?bnmh%CRo3t##SNhf3mGMgZ!m=K69AsS5p7bO8iI;lWU)q!Xg(a7I$)z2c zr_6)RQ`(dLq#bEj`jK(U7VBj`QZMaFJMy^1zi(;JkMg{V*E_k+Nxy8KvJPzAGCmx) zJg>`nBKM(XUQ#dnGcW5Y>m}omaSKZ>_0qnyEB#2lc&U?I_LuggpFdkK?aO|$e)9Oh z{-j;mVvggH_QkXPWc)0b{iROY#c>NuF7>j%tecEm`jf4AsgwPr9cdTqk=g!#wodwy z`QUysPVur8Fa7^j|99v6SNoH7mGMgd*p9Glah|fDv?uF_^|GIM=~vkKdjXtZ?Dacd zm*hGp*C)At$aM~{!}9o&<0|8namzepTrys{-bwqyTKm$Tw2$q&X6yQ03C=&u_;5bh zpNwCw6Vkr)_hi*i_7v#7~KQdl9uVg%IzOp_tJ{h;RpVZ6#(mr}zCuvu!llG)uSdO3cEA2=>!jg-Z z{V+$ydg)ijAzNura_LXnlXiq97mxc3vwG==<+8uj$vEYB%6TE@iJTu&FZ;9hW&c0> zxW#s5-m(tTkBn36U(R#sU#@R54|$x(^pVXt5_Rvec+B-TD04dC0t^U!1S7e_cFZHrcl8cvir5{%JXRkXlZfRHg zk$$Cptv}gc>ScdvPjc4IpWP4F8{5ZrLq9O;$=S>uZ&0LBab6_JjmmNt%vLTx3Wt~`m(!TU3?P&eUIJG)iM;Wh-U-py!qU6 z@3nFs%Xuy9BY$6&^Hlm{>m>KbWPCDS8NbX&<|p%&`O9&Te_sN{mXph@hD#E zWItIKnRhn3Fsr@IVsA6s+f4R0qrJ^wZ`0e`wDvZ;y-j0pQ`_5A_BN%xO<`}7+uL;Z z*7>_DonN_K8;@M}wv@dsX>Uu|+v4`Nn7u7(Z;RO5!uGb1y)9^O3)tKI_BNlr&1-Ls zz0G59liAyx_BN@#l@GE_Y;zKOE64B89(TEJ%K0M4@vk0#d7R1hSIz^ut|qd_m(boO zu($E;ZLqx!vbS>m4zStzdr6&N&fiVy{Q6gaJ|UiK_mF?bx6yvD$3OoZxU7TBPu54y zUpcSkJeT(5JZINKS#NAt`jhsh9oCQRkNe3u*?!Wl>@W4$j*L@lN9K*|AT0a;yX#~g zvR*QNnUBm*p6})LfINT8^RJvwa(>A9Cg-1=r*eJ<+Y^!36A5fiXm90wPi%7%dn@O! zydIIqi=6Lv89g7_JmqmF$4lld&%<&a%JYC+ALM!=k5{=K$m>#BPi$AlA?qy1SB{4q zcbSiD#Y?@6Px_O7q+RJ><|FOP{?eYbFXNK^gk?Xhmv&@-$;C@MG9GD9>ZMNhlYS%@ zFLhcj{YgJkFZ*M=vM%VQUiO#vqtoMK7^9>GIg9`b5j`l+6??dzbb?&qGyZz7m z(OeIa|9L-}?{!xfs~gK&IJbZI{b4?ccdh(r$dK-G6nRq6a(d_lg>i zOkN(De%c58f7S`#w|BUJtK{GRzCD>wg8$GuCHz0SPP04zr`IXbe`uW&|BZF}>yNw6 z%U$dA?|vaeCF7Q3r|32^o;D^AEfFA=t z0e%Wx2DmJ6IpFfZ6@V)OR|2jKTm`r)a5doSz%?v(U+JQa8v@O0oAz%zko0nY}WW3l`BI2ZDH z!1I9@057!Iy}d<{F9u!$^-F=5LH%;bR{*bs`c=TIf!6@91zrcd9(V)rM&L~ryRSzd z>%rvCeS!M{_qW*k4*C1h0N{bZgMbGE4*?zuJPdd^@Ce|Mz@vai1CIe73p@_kd1J%+ z4w?T1;EBMKfF}b_0iFsx4R|{64B(l-vw&w??0&rGKt30E9`JnN1s1!vw-EA0kS_*a z0=yJ>8Srx86~HSkw!TA-&nn>6z-ugaAOBj&*8#5w-T=H2coXnu;4Q#gfwuu~2i{?^ z`}lW4z6*Fa@E+j3!25vr10MiB2z&_mFz^xJqrk_2j{~0oJ_&rvV)ykq4SWXpEbuvt zt?!WY={)cSsJ{q&3HUPb6^pI!koK5urX3uqV`e zLGBG488`}XRN!d9(Sc(C`&eu(VaV}{2^&kr~*a6Djt-~ix2;2?|L z#~%zFA2^{GwkS7C94)rM@PYIj~I5lt@;ItOIk0%{)dZ^C;c}Cz& zz?otHEWlZTvjJxZ&Hw7}_r(*tJ! z&Ip_dI5Th-i>>dF;P%JO|`CAR;L5;NfU5#m z1FjBS1Gpw|E#TU~b%5&v*8{E(+yJ;Ca3kQxz)gUg0yneR`VKi>zd_y{xCL-a;8wt` zf!hGL1#So2-eT)JWPBYU?+Dxp*!hEm?&o_Ki>>dF_PRpe4Y)gS58$4_y)3rAL)z~R z+z0CWLf#MZ{=fr(2LcZQ9t=FhV(U9(d_#eULH%&ZM*xom9tAuacnt7Zi`|dUIN4=fbRm|1HKRZ z0Qe#BBa5x?ko9^D`~>)^#qR6#4EQV1Lzfa3zk1NH|Fu-Lu-K;R(YVAwxC zn%#-N%y@I2mwq*gpkuO5jw$sV#QzFAd~rfztt}2hIST5jYcYW{chX z&jOqk>azi72hIVU6F3)eZs0t?4qyYE*JAha=L60UTmZPB#nyMo|Y$X1aL{%zZ7t3;4;8vEw;Wx#$OKd^1v0Kz9MiX;L5On709asR|BpNTm!f!a4n17 zk7sS*I>2>d|9X(uhr9uBL*Pchje(n3>^{DxkT(PV4Y)b%-vaWMz^#B=1GfQg3)~L4 zJ#Yu$juyMmuM==*;4Z*jEw;Wx&c|-R-J!k*D1-(5Bvf6Bk(7S-Pi9k@E72(z~6ws1OKqteSLodJKx#vo?E|?_8-{!j&AonjK%Kb z4GVcV;PAi^fFoM$-d-eNPpJ0-_6Cj&`$vI1DzNicj=Rq_+ zocl}e{Ife-|NQ?CY`uN$AK3Ys67KcR&xmkm=X-YD+4&hB?(F=m5$71Bf9Gc?xU=)K zN8H)@86@ueJ3ouWot>Xa;?B;`OmS!DXQ#Nc^RrOg+4)&3?(F=`7I$`jHjFzvKO@GS zou3us&d$$_acAde$GEfeGi2P^`B^gV^L2ivjC=0&(?0pNlbyU(u>aADvgz(p-~KR=5B7Y8l@ToSmH#qQ%N z4S5;hvcTnl%L7*ct_WNSxH51R;Hnn8kGC4+)q!gO*95KwT-##z_Uk}i7xH?*^?@6} z{tbZ}0XGJ20^Ah18Srnw&4F83>^{Giz^#B=1Gll*{rqbS+zz-sa0lRyz@30Q19t)L z3fv92J8%!+p1{2~zJjr7B_9sI=1$Zj(G~nsLGk|9T&$8IP|Jjhw0iFvy z4|qQC0^o(fi+~pcF9BW(yv$Og|ANT<9LEuBchk=h+?7qH7AwLFu9O_R1 zp9DSyd>Z(S#qQ_FS>SWP=YcO+>^}aBz?Xn81788Y3VaRtci`*5H!OCa&rRT4z_)?# z0N(|^2Yes+fyM6Y@eueC@MGX7z)yjn0YA6ceZDV%Ujn}ZehvHv_$}}|;P=2EfIk9% z0{#sA1^6rQH{kCUyRY{T$bSOM{UG`KwA>F8mis}%&ig^`>nrzzB$xX^!g4=ISndZ2 zJMRa%x97Yc2TAU{ALPD1az99N=lvk}{hjxN+}U|Q z$erbWknHcgALO1p?+3Z}C-;M-UhW48%l#l>xgR7f_k)Dxevq)-4-%I9LBeuBNLcO% z3FG~s1Tde3FrP%giGh>A{z-w80VfAe0h|&z6>w_cG{9+r(*dUk&H$VdI1_MY;4Hvd zfwKW;2hL%!`}va-@?5~Vf%5=6fQ`lO?dOF&ALRLg3jh~{{R=@}81f>(MS+U}7Y8l@ zToSkxaB1K&z-58US?s<(<$)^zSA_j5L0%d1D!^5Ns{vOBu3@qJ_-X>zg8JIPb%5&v z*8{E(+yJ;Ca3kQxz)gUg0yhKx4Y)aQ3*eT(t$0X!3Umc{P#oeew( z>gPf}5AylI3xF2_F9Kc+yu@Pn@hydX8Srx86~HTjR{^gEUIV-qcpdP1;0?eVfj0qf z2Hpa^6?hx)cHkYrJAror?*`rjycc*M@P3QkkJkaZ_@b4D8_jeum2GrjKz6E?6_>RTy z?cas`9`JqO2fz=39|1oGeqynE|4$)*2K*fO1@KFY-P?Nw{2KTT@LS+_!0&-S0DlDj z1pFEJ3-DLqZ@}Mye*pgk_6Yrt>k$e#G;kQ;u)yJf!vjYEjtCqH*b~?b*c&)9a1`LE zz|nxC1IGaN0geeA3ph4#9AIByKj65)@qqn-1Aqg8gMfp9;{zuEP6(U`I5BV%i`}p9 zNr96ACx`u00H*{_1)Lf<4RBiEbinC>GXQ4<&IFtpI16xA;B3IzfpY-o1kMGV8#oWJ z1K3#Xethx*=L60U`#XPqiTnNqAuj}680w1v7X>Z`TpYNB#qQVhlE9^)zBF(d;IhEw zfXf3{0ImpJ3Ai$F72vAC)qtx5*8r{wTno51a2<=?*RL+*^?>UGH-P;cLf#0tF>n*$ zrohdBe*P`?g%J@5wLjli3LHv?}0-U_@8csuY8;GMv`fOiA$0p1I| z4|qTD0pNqchky?Q9|1lJd<^)w#qP)N1n^1VQ?UPO$j<)cwUzrLKW z|D0dqo&7n#&chFp|EWGa@ezoRNPHyXJ&E@s-rM#vzl!k3xJ@+v7S! zBR)FuF^KmeJ|^+8h>uNt9O8Y6_ai4;BHdvUVtbs|P~wM?c7_u_g7}fdk0O3F@neV|YkQp6I8r~J z_zA>MBz_X{lWmXVnL_HP5BP?iQh!p*-YxU5WkhQ zvyIemCw>Rn!7c30~IW z6?mzC4PNTs5dW6=cf`LZ{sVX!&qv}v5&xO^FW_ZfU%^Yi--!QC{14mXdGeFg+qe7w z={`@WP_7;DIEE%Z4Dn%!4@Z1>;v*0rk@!f&dlK(Oyf^WYiH|~jRN|xA9*24ojSzVCB7c<^@(pld_&?J5#N~jCd4--z8Ueq5#OBn7R0wC zz7=>mPg;YQ z-^cdy`bUmSUsB(X)b}TT0PzEfA4L3M+v7S6A@xIvA4dFe;ztlalK4^J<@_HFUe@KQgM_*vkko!Pd> zb)G}~T++@wQa_*6FCcy)@r#IGO#Bk!m)aiZyNuK?Cw>L-D~Vr4{A%LY5WklAb;PeH zegp9viQh#0X5zOHzm@oH#BV2l2k|?J-$ndx;`b20m-v0e?v&k%o>_;bXcC;kHQ7j2KnmMq# z>;6Cb(8Px!J}mL!h!0PE1mYtSABlKR;=PFXCO$IpQHYO9d^F;t6CZZ1@5}%Iv^u%W%J|pp&h|f%X7UHuKpN;tJ#OEMBC-J$6&rN(D;vK{r z;`7=b&$oQU=Lav(?*&Nv1xbA&QeT+(BBY(7;N|gBjQHZjmjEyGDrtLsek?_NY0^#^ z;>!|Wj`;G#S0KJ3@s)_LOnepMs}f(0`0B*hAigH?wTQ1xd>!KJ+8&Q@JyKtv_y)u` zB<(jM^^J*dLVQ!=n-TvT@y&^EL3~T%TM^&d_PEY%NPS!4+mUwKlll(CcLXojjZVaO zCcX>tU5W2Te0SU9`t%^aCuye_@x6)fLwsN2`w`!t_yNQZBz_R_gNYwP{7~YD5kDNf zT>nSd9*@gN;zvO{a$b!lehl$ri62M&c;Y7zKau!J#7`!E3h`5kpGN$2;%5*)llWQ0 z&nA8j@pFlvNBn%^7ZAVD_IO?`f_ga*7lW7cY6*C$UkYC8ml405_!Y#jBz_g~tHH}S z*VrD{VJ-3NNIUC^-$49E;x`e$nfNWlZzX;k@!N^tLHthQcM-pv_&vn$C4L|A`-wk5 z{6XRm5r3HYBg7vi{uuGci9ca`JU>qoe~S3ir2R9b{w(q5h(Ax-zd-y&;xB=h>&9j9 z^7y@Cdt9HZr2ZQ5zY~9*_#4FEv^|dJ7V)=j%U?B>oZckHO1v zc|!bC;-3-!ocI^Sza;(@@vn)0L;PFf-x2?w_z%Q?B>ofepNaoM{8!??5&xa|AH@Fz zFOT0}9~5NW>c_`LC|jTpO?(*Q!xA5k`0%!u$Az4?5r~gSd?eyMiT5JjoA}7YM*%PE z9F_QJ#78GS26$N?AMny|OyXk^ADj3%#QPHOM|@n{+Zzx0Jf(QV`x75Pd?4{bwwLot z)+dZ1@5}%Iv^u%W%J|pp& zh|f%X7UHwo-rj_qx7kR2c2b{%)aNAixu9Oo+uY#ge9J?;gS2mm&r5th;`7^H9$zw^ z0>l?2z7T1@F!4o*FG_qd+v7Nk6JLV(lEjxHzBKV=Y;SKu&a1Mdz8vx8iLXF>MdB+F zUzzwS#8)N08u8VMuR(lG;%gCKoA^4!*CoCl@%4#sKzu{u8xh}__$I_RCB7N)zuDg2 zgq(-XNqr0ATN2-j_}0X?A-*l~?TBwrdxZ`<3Okn!{(zAtH~AF1yT_452RfcSyL4+1aGFN296Li|wM+nbR24kLaz zv?JpmLFz{mKZ^L##E&6;x~hrd2JzntL^Pg$a-!gemiMr z2Y8v+PSVaUQooz{J;d)NejoAsi9bO6LE;Y)f0+0qwzoGS>wJ{>W5ge~JziH&5Py>R zQ^cPp{tS3Iug(&Gj`;J$Um*UX?d?s-x?LjvGVxc4ziN9~Pnp*>;(sUpI`KD%ze)Tp z;%^gwhxoh1-y{A$@ehc9NcAKTvEgdE2w#6KneneFj-JtzJJ@h?gHuSorC;@=Se zmiTwXzbF0!@gIr*MEqyszYzbG_;192C;o@+?M=vW|4HiQgAK4glzqVQxQ8Y_jP0?V zuuw10Q{lkNbs#)xCj#*iiH}6QC-GjydlMg-_$aowHzB_tAu93FNITJqk3rh;AwDMY zv51dtdpwSDi1&qd(qi3-z*|>4;BHdKWLK83-{ zyo!)^iV|Op_~OvM^jm`XlHg@LrAYgwi7!KZS>nst-rj^fzmzBS6^O4$+Nnf*W#X%l z_N$WmYQ$G3z6S9%iLXU`ZQJ8{TZj0%#MdM3*C+K2pkB_ahQv1lFXvTb@X}5b;+xtY z=i7|b|3-Xs@G_njr2UpqFXL=Qd~4#{5Z@NOjHey(?TPO|d`IFt*&f%kGx1%B?@D|( z+uNIv$8~q&dysZ|LcOeiFH+x|_&&t+r{4nB& z6F-9Zk;IQ8el+o8h#yP*IO4|>KY{p(#7`oAGVxQ0pGy2R;-?cogZP=m&mw*{@pEjC zkN3Hxejf4jiC;kcLfd1%i=bW}*NaL065^KK>SAHHxa*?_$|b5C4L+6+ri6m-vM5p2Y1>Y*Jl^0-%b1;;`b82kNEw> zA0Yl9@rQ^%O#Bhzj}m{3_~XQ%ApRurr-(modpureh(AmGIpWXT-rj^f?k*62k+gG( z_{+p!A^xiE?M=vdt`Yw`@z-sS>vn_G-z5GP@wbV;L;PLa<9O~7f1mgV#6Kkd5%G_S ze`0%k6SDqKiGK!Ou0PLhkMn&&>R%H7iul*WzajoDc)1?FvptUUJ@Fri|495N;y>Ho z-h{047gGO~)PE!O-%0%s;(rqF5$=Egoc~b7hbBG@@nMM%XL~#@;faqx+KEVfB+`y2 z@m{1IZ&Dwb_$b6jB|aMQ(TR^iybtj)ZEqj`kn=DW@v(`IL%c8XezwQs8<+Ta#QPH; zKztzaLBt2!9@i&6@d=1eNPHsV6BD0=_@uVS^-M8@LNfD@*^Pwp5#fUEsUXFVS;!6@=%Jw+U(!`g6c4Yizi7!WddGIp+ z3dC0=z7lvDPi5k(5MPz}YQ$G3z6S9%ZIA0*i`3U9^>v_L)~zn7uSa}+;u{d(koZQ# zHzvM`?eY1mDXDKp>VG4?Iq@xsZ%KSB;#(8nhWNI`x3fK-5A8{P2jV*t---Cn#CNeh zj;AZB??!xg;(HL^llWf5_a?p%@qLN!M|^+c2M|9Hyj*_Qp_miTeRk0*Wt@e_%k1YVBIWbkrarVu}s_-VvXCw>O;Gl`!?{A}Xq z5I@)U__&@&>gN-`fcS;PFS0%MyO`83A$}=oXBqL!Njob@{Yv6jk#<%SzlQj=#IGZM zJ@Ffe-$?u>;x`k&#rAl-wi3UM`0d2+AbzLq<@JC(PInQ%oA^D%?SB&N7nxnc$wE{;=hpg zzmocI#D6FL2Y4ChPw+BMkMRHd=lq8vKD6!S`XKFxAwDee;lRsy!V@0>y!0E<_IN%- zBHojDFKA!J<4x)#L%obA3h_~ik4AiS+vB`qka{2DV}h4?#Uky;CO!_dBlGno-jB2s z7wTm^@xV*_{-m7%;sc=_X+MbgVB34U9tIxm+P7`mvT^FVZQIuE;#BeN+$@3_= zL&=Sj=T-82N}gZI3$Q$!^XlQ@QBcVXDS2TfFQVi{mAsge7gzEUN?uaQODTD2B`>4o zWoe%N*YPQ*Eiys?ruQSzor-i+p%evR)pC2y|eEtI?^%`^Y%zm<}=R`NDV z-d4%mDS3M(@1W!zmAsRkd;VGt507^C>%&a0hp$IxCGVo-U6s6>l6P0~9!lPm<*tW^ zM=vGst>k@_yswh?Q}X^wK0wI_D)}HKAFSjWlFwW$yX@(N+n;V z?^5#J zcK%PVk6fp(#~vl$tK|EXe7}+(Q1XLHen`m=EBO&6Kgx2~!^h*8k{?&{6H0zk$xpG| z_3-dGt>kBv{H&6nQ}XjlenH7ED)}WPzpUg}l>DlaUt_uJ;qURgl3!Qy8%lnY11mHe5KKUeY>O8%1NuG@DWuax|? zlD|>%w@Us_$=@sa2POZgPlWi$!jWkEhVq5%k6((7^mdpm3)GdPgL?rEVu8! zPge3NcK%QIpE9^k;HgSJP06P#`3xnWspPYid^XKpC(s-vpR45alzcwTT_?~2C10rI zi!u)7*7|+M(n-m3)_y?^g0XO1_un8C~~3_9^*(B|o6#2WjrQe{e|24=ec*mS=Ze zpN=Z|F(p5)`Uf^3zIwM#;}A`8g#&PjlD#e?iGFD)}WPzpUg}l>DlaUsLkm zmHfJr-ynI0bgswWO(nmjDobe^c`BO8!I1 ze=500MAyIn{LDd3Ys{pyUyiJd%=oD!G@EdnIh>{mo@?uI}T**r)c}XQNrR1fRyo{2URq}Ey zx9_i)SMmxksggHS^52xaxstcAbNu^R`i!m@m@SpOm6Eqs@-|A|mgcS( zpzV~ry^?oO@{UU0Ny$4ac^4(`s^s02yt|V3Q1YHi-b=}QD|sI!@2lkfl)S%^4^Z-f zNs7$qO8EBPLp zyKdm^Rq}mGzF)}?DEUE}yKb-@Qu4z}eniQSD)})bKd$5_l>8*kT{o~!Dfww7KcnPl zmHeENpI7n=N`6tvFDdzDCBLHNS849LL3d5be^>JBN`6DhZz}mMCBLoYcWCaqL3dZl z?Csy(#G{gN}fu|Q!9BIB~MFp*Xz@CN}gWHGtk`i`Y)rBXHxRaN}ff@vnqKuCC{$p zIg~u7lIK$L+%$K+e$1of4kb5Ao>$59DS3XDr+2-6EuiEDmAnwmU9VpYD|rzmFRJ9l zl)Si-mr(MOG;ZXFR$bkXzqG_UQx*_DS2fjucG8tmAo3w zU9X?3D|rniuc_p)Sd0{AYIi=NB3%c|#>{q~wj2yor)GRq|#m zw}1ZgHzjYbrm2c9eKsdt|Qow;k7>EShsJ^esn) zirs409eLX^_vD5AVWZ!6ME|xdu-&?wj)Up|L`7abS3HNJRs=^4kl z0ueU%jdjj3Isb>i&P~rbs*FD|CFRjGj;2RWC8^{0#ZfFq#rv=7e{l@k5FFP`_~K|d zt>b;a_+K2czpa=$GV@o*ybSO5jOzN;QDfeZZYySfaimXOJJ;+3UmdAGY)rU(!W+k< z2_?>afB4!lC;#FudG@__bRHbbajVrk$EkI5cU&F(-m$D#gO+1@y>o2Y;aFZj+6PDC zZ%xm>@AcXdA?2f-fumkKKKkFAa4+Q>$JcKSYXseT?YO%t_m~w|UO6I7FLP@9oY#)k zo3fuAmhZD;@1pgM*Npk(nA_{c(6ouaIL0rG(6;!ePmWueQb#o&W8<6m$HQd=xDnl={Vn(-yGd;RBLf^?srF! z-Hlr((ZKc9kCna z_s?yuM}|ECt*1|X=a_#lv45=t?;YI^&AhfC@_R?av1RJqi}v1;yl2(%t4F+d?B92% zeey5w9YIY8WM2C8og@2=ua9acc<;zKW!ZzSN8dSmSFhj6|MWXY?wH@Be7^k7(Wk^x z?-&uSesbl#&}`;A$FK&!?bvIzvvhH|g6k{1cbs3GE>Xw(-yO*oq&oHd(l^KYnc0pd z%=+Ci|Hp#dRjqBR8cl)CB$;00+rRcO^%J%U zc)ug8`8K9N^M@zHnp_FjXFsz&ta%hE;9BG3VNFBN)8C>^4r@Gn&FWWpTv!u(VTb*t zMu#MqzobT;a3*TFgzf7L z4rjK{8h1NF&T!`D*w`OZKM!Zplvy)+y|sT|NB_YcZiF*G>fBiK{b4vW#N$q`?R~U$ux$x~s${kI!Jn+$~- zE+2Ivv>Cdp@}vB(Lz}36!7VmF4sEK1Zr5y0p)e-%lBdTe=L};?MlRp`RG%>BLeAg; zD_@2-v$hUvJ7!F1^C>~vW{JjnnS{qGPtUZ*%jC@;P`^*GmnpNlXT_Pdy-d#mE&61s z;bjVZtyLg@bZ^t|$=!Zy;(MDE5s!q9THMRT+>vGb!G>PuTEfn0$`tT2yUXX! ztpD_VX7gZgGwbKi2U+WSnIjc<=1o4u%Y>i4uR)T#p2qV?lQ%UBTm6MUu)OIaFO&4$ z$m#1hc$xjF-dDJ>Frt~+D*NW7>m!=>QPb?d^F5+@lJ;x+%8eqK0COc`d!I<=_Q8Au zD!+?p-tB5tspH#-=H$qG9?ud)GS@Rb_;Gk;M6)&Sz!qyOMlv@#^?W>TKqRxb;+g^n zi+h?=k=8%k-_z4X54ZJsxY3@bckb3dZszbZdG3~JbM>pI8M3yof2Nk6=2@fSyRxBy$j=m12drneBP!z@A>-X5B8|h*S4^o5uqm_ItHCvN>u} zjJ_2vvPpZdXR<}3Bb%K?gM(%*_cj@8oSd}tg0~s&`{wlOIo>Ab$T3w8Y>RAGgjv`5 z!;r}4bI)bRV^oN2UZ$zhsKn67X56iA>6cITHeZGwo!)JkxA8x4=tugc-X^_SJf_VW zZxcFYtGlyJWV0jl*xKKkMm9YPrTLVsueW(x%yD>n%E;#M@s}m?p7%CwH>W(Ayi{Zp zb@+;h*RDi1H%pfA_)#XZ`Tbt6Yul~A}ebE>i9auciWRAAfG zWlBUc1HUZTJs?dalkmgwUSGmSG7rM$@4Kf-L=$T9@vrk*dYQdnZ&s*q%+r(#sFb_Z zOfPe@*~nU#J9?R+D_dXsXdU0jpJ&cFwkVoe)iZm&K9i%FcArXapR_HS3Ev^&=@P@D z8P6~Y`V49u%_R2l+F5B_G}G3z#o5_ueN521$#V*2^D%>Zc8NMPzmLiHbzGUX?_!ws zJu4@Tn%u|uu8dhLR*;VgKRMm%meqVrldUDo4a(_bE_9EzC4D3x^K;;D56b58F})^E z-cV<4G~?|#_D6+c(M{w{jXm-;jc#(szw+u{*68MhcR~-3-=Z7uA(QT|DIVP{>~?f^ zxoXi(%Fu7hr&{J?`i`v|v))Ckzk{(l_So!WnrAOOsQ)Kx>of1pn;AZ4#pwzOOPu#H zU1B5~p5dU6Nm~5E>+WlPOrv}=;$E8UV>0YJ-ulcstA5+L#kC*#m|}OnmhOGq$E-Dz zm*qI=VvU+&-mAJc8$@EnKc`Iww-rY>u+)5pa4lIlR-DX~qvpcj6zj>R^WioE`^ zU`Q+zwczG8-y6g-5o70Cc581ebGZBUOVyUgGK1!P+PisNO!M<#@X=xIW15axJ0 zx<{>eB>MH(=D_2o)0$0)ZC0%+`RLxd*k*Q-PGeg7#4`EH9Go}xc1)9RzxD8c7t_4_ zvBmn)gjnWK*r^l8o{wqDHQV;^(dd|_-HpN12egW5>X-3uI%Qf+^YQb^gEuzEG*j}8 zi0GR(mRZ&};pj-kW0|&dPxWutCzjdVFW=^OHDZ}LQ=g1`TP2ox*ro2;nzdt@X*Iv$0V5tA zSQN+nHg-dsq^shXUHj5@{8l}VsgwUlhxS$Cn9tF&K3h~Pj(OB6FmB<2am?gLy`t=D z6vvDxmTuFmc5#f!SKwhUtNw1L#vgJwh+~R1ZneAEk~k*SwT)ZHm5XDxUHtI4#>_Zo zzA1R@NS8Rqvs%MkSC3lzMZbFSY*Sye`D^aU6C3-QdNrdzeC6a0H9M^A=jYmfX6*ROqkks#GdF98Z*aD(pSj!hX3Kq5{LGZ*!>%+h=x3%^{E5qT z{#={mnW|Bb)yT0hp4k+5W&Y3w@yv;7UqX$(5zjcv7kYhfL_G8AO~%UYEx$Xs*&*+j zekOQ#+bV0L#x=FVul+qkw76#eihReyg^O$Yb?saB^g=)L=-$-{2lB)zit){mirSVZ0Xf_A{nTK(7j|{Y>t2iwb0(=x0(D9Nn_pb3c>seE_IeHq%%%)L8oeA4f}rqG+T=Tn~ZH9NKy>GtNUuUXjR z{m1Hed`+fPVd`&r<#N{wEkJmI$x%DJ3L_MTwhb#chso2)_$IAGvvs1+Slv} zbt`%D{l4bG;21UE?C~}0Hr4I1d9<&|el6XkHb;ETy(Q;5+_~>-p5@8VzTp{P6ZiL7 z8QTr@HOu!uI??=quh};LWTls@UmG*H>+UzUW$37U> zT=)LaIcJBsCiTx%o7Y-(iKo>5ys%$f(<9HMKC@QGH65;(n7_7VJd^6+t%0#7#xt4g zwI3X{Z9KEHPNC@6w#G9Hn(Q3)rA|B(HgedsE&InaGd;%#9j#`auPMtOZPX;5sdKB? zko*JUnbEfkor=9bo_P^><(^31ekN?+WD&n7@H2kx%f~wx#n1HkJ}YtFAU{(fO^)C< z)_GOLuW;L4*71F~apI4ODf~>KV;+6WX7)2(3!E=-{kE^kHsSV?#D0F};^3yK>Xz^` zy&Ja8eZX3$^m(Ht_SxfamZj)g;_5?xbGBmRO3e9Bh6S3DdPXs*o8aC^y-K$HHr@^Mo<4K#bod#xV6JkT`U@N)0v zgh3`xq|A+;<_R*_O7+^k)IZ2fIuf?$Z#e=?A@ebL^l$#A%f{Lb>;LpOOY_9ZRn;%R ztl5;m;qG1m=F+D#YaS*EF!dbCYMw3>U=}T!l4ig@f7AY1(JR&W`kOxSa}*zzC%|mz zG%2j-27fbQ9xG`)$VVs{6XC>qqq8-1lzlw=y=-S#GbTt(jG^Ec{brO?2AWEKlV5a*8EC%e>#}Q4@jx?cam8)%@&=mX z!)HxR=pShE1og9It)~pxp)@BPZiMrPL>KPDV8sy$oes8h>Q#oOw zj`gqlo9`{3m+E>Z!1{a3h`ew22bk>#r#GE^G{D5@ad>*^O%}IZR&ck)4U#`A_$5-H zDdk-$^ylpX=46_V+ZUV*Fws|LJ3i)7fC*b?O#9%)0p@3k#--102{7NXwdg%=cYtYk zFtm5dBLODnhKiZ??+Y*iyCNmYWgVB5UI#A*CFT^24q}f)i)8UALrhS5`bLM`u*5${(Hf7uTo3@ENjVOQA z-;|8`VBX&4{-*evo(=Lf4>V;uZW&y(ZlKBUF)rgAYrC<@;^gc41{(iFnYwmp7-Whp zxqSNR)gZIBQq8Q+Hs* ziQx_gnOd>0C*EH^$oQJ3Ek2F+Hy&j#Oj_;{V6yLiS!Qmi0Mm7Dg~yH0`kS}&!{@)T z*54#uTJ-0LmHyV#!OI`DF8UkaQ%4#O{NZmt1+J@jxmAE^lIp{Yh1U5#a{Idd9qI%a zpXfz=Z}tc<3yyUgd(S$r`>h-Jxp$QSbFqA(Ye}00m%*ICT~srUR|w5UA-O~n_5vPJ7= zwU;#M^J5zVO^21`>n&RzXol{5|8VT1K(l+3@8vZI1I?t9wRRLb5oofW$m883UV!=h zB+BT}*7ab{%H_kSH4QNBcOQ3zK4)F;Hb>fZDrJBfcjor-Sk`rAV1u(UYj_5j8q0RP z3yK|Jk{wyNJz=E)b0#QCoFq8|OvFY}el$JfZ=NLB{_SR6e>43{zNJZ5_#2-Q6hi_tn1RuC+nY%pWttvuKJ$oSTlb!IAU$@$~OW{w|51be7_ZF z0y}n?(I#S$nf>tD)iCd@`7fHXI<__LbzhsDxi#0iUYs0xEz8M3(_nOr>NkCY%%?%- z^}J6OXKmVg#LPgGU`Lh#6RgLTZ;nDO7X7f!-}W(LyjvJ((q>M7yZ%+{xSqayti-)Q zlXG?b9w%P~ngMw_kJ}eJ$fUY)#K$LmkXb&le8q!S``u%g+&nx=kcm9KS?Grmf=sC_ zxel%hW%ZZWd)P^9J%3a%>HMw!>-z5=yEtZ$=`=Cpi5!LEn;q9WdEF`%-}q+F;2kk% zd{gU3qdxm)#5dh4Uk-n1bbJ$g^!m!ptH(F#D_*PrwPSqqt#F;#`#Z%qp-SH!<2^UN z>F-&n;llp$O`8TQr!6TH-;68bNLsB~d=oy{w_DGG@y+lRVf{;Gjc+oQOGTnVp?|cJO$-!;x=PB;dlV`6)p~ z2`E)@eB!YM^BFnm#KkQEy_koaY$KRSz>64!yWR$lF{$t@bAAxYH+Dm1XAH+fVw4B`83~^`;hzxEj5Qi|lfIFp&!`IE* zpNchN{%bV!pZ*YsOZgJ4HGc`v@EhFf87KkqUWZ*(uVOxeN6#uaO8}qziv1-U2?*>J zo1F8LfI*KJ`X^l^V5-klrPN9s68{_Z8FCee4oR`#W4_{$sT5eb(@q?G48jv5+{B^j z&zbfM#^O*O-XVWj7SH=0PreK(3HWo2YBjiv0+suQgpn8pc4%vqKU1T?-}JDg?>rRP z5TdENZ(b7guCq>kN~XZs1bL4x8ww-|@dRy*pg>kU#kRWl5lkENxnG~NqD^OX|BC41>T-AK2z&S zfs{L^{T8e!kS&|#-xf)M-`{Q~Pko_)_uWo4Z#@2>W{P;M4h8R5e!dPrO99eP&o8y- zDByKIAa+2O0urQJiLI9?aQ))KQYPkOwo=tk_J<^BTr|j*E|$dW#oZf84HU5TxY417 z>AP4Uvb6O(1%4==8nxX)f&SUA-O+QBu);Yw`(;`ZLduSMIoDF)WwcdOZz%<~hrY6U zo)!%VFQ&Obe&G6mZ_~EhGB_1svr1OxIzU*w2t1kCuc`Q{P&rU@W+@QdP+MQ-*SS~Vp$qHmI z3ecH>Uc5IcF!w{v^cIG>XWO?c|3`tqf@s_A78LNbiIPt^PXR%G#knPK3d~2V(LIeR zaJ=l*hAVhJF29zzIOReCQ^l6}XATs|G4+v0`1><=hrBsw3S_F+aeHFEeLSBwzj{D{ zQ(;^$&2YP$tKQ{St`r!1wcnl7i~_Fz#u~FQoF5ElB0PW5Mm}M-`V@#)Ro1S;cxH3m zHGMB*J!qZ#gz)^|8$Hr$ZA$?|HibO|%+JMRdP^HFP{4QGt)bGM0%xoPM@-$YK7Qq1 zBV&DBJ}Z4znu`KIt#f9_Y9yiS@ROW`F-Zv6d*gzn5Y|)24Ybz;3KYCvJf?%?wpB?f z>H3r;^z}t5Oi?JXlwX;lx-5zF-JVCYx+KA9w|Jn;HVV9|KGfKZ_2Ty_z4^_f};`Ba$E>94h)+QWAV`)!z-PD`DpS@;4YG+Kw}VZ-&PU@?+45Ed>62Oq+Py2*n;&SE=#d>O%jTBCY!}z z{k5!9bnYCK1h)_4aU7kPKg-~0ApvYB2IkG%uzpF13Eir)mV`uI9w}<5B!u6o(0J@3 z34TAl7WbA?z-~QXZhtn`qvQGSrJ5=5c(N@VwNk)b#_qmS3Z9on=jg||6yPXs$h}%c zfzQN?#p&G?m>herP|=0;bfdMSt&J2sczK&u;3x$Fc}fOpkEOu(vw0fVbtxDd)jvM$ zAq9`TcKFDKO9AcUV@=8pDcGavnz{d>G;Di)Us3-^1`&|OiRDh(ebZY;itl7_?M^ywcFK$kS%XEe)Sb)3vUINP%|xtLq=Ur9gug?HuKd`I|p5pMd%7 z%(`zg?j;3MT@7=*K~iA;{jBCARcUz4>fWBWSsF5L=LD=#q`_S%iLx0(tM5Cb+V@L? z$kLWZgG?z+9Rak=n=*gZ?zOSn=Ae3jgtc2H@6by{z}1xp_tkr5owUKIrM$w{dDo{Tg&sV2$q!u0GGdxx%+N<(a|mne4wZl`KiR#hww zd|}&U3d^LSpHnC!rAr!`s-Y6~VmZ+^4K0dGL%w5>NHx1OxZD957eOp9=gGJauDUdH;w*RLu1-A}@Sii0m90;`_8`w$V{d`2H`~($T zJO2Gh8Ky#5N$8vQIx4i;xw6*~XmEW)ZjD?o6^#65gA+0QR(e)Fu89gZAI&bwwBzUW zsg^C1RH)a{jpd)h{cHxdu{%&fNJ@9dVO=U{7i&dGX;EP#?U#n|c`DSlA9Ydppn`Bn z@RREUG|*^%z&2Y+gHYlybQ(PS zM&ew`q(NfpBU9~38VEi$6s?=50g9Xb{$7Rlc0MRbGlvEb^A0D;htc5j z$}Q`AaWvqx?2VWVp}}JQ?23Fi4FYLr+BRRHLB>GfFO|zQP^|arzvoYbSDQ9-Y_p?* z(mBY4AX|TRi zcbn)r8YsoE9eDkc2KI`knQ~S%IP}dw=BNz~3N}8AYlxu1enXSaJ$Go(=X_?9Su734 zxC2?;i8OdWVy|a_`)%ghhxf!Z(59|mdx+anuQ@o^yU@Tt_ua2_{QY47zx3Wv8oXKS z&zZ#XapBS(e(;?J$(#$l`#)hh7}7n|{Ae(9T6}6R?*I3)=obFxG=N9CBujl7Fgp~d zZeV^i2~UptKcs=5<4RcYH7w^p0V7B8xX(4DWeslAfN{IfI?9;_>m&=hMIO=M&NY!E zEx5g}mwLu3h6iKo4K{gTdEJhe&xoYK#1k1Und?-@J>dA((w&O)iCfvCF|7POCSG7c zg`+FCUL`wGq5XOCnk|Ps#`BA|kJH~v|02R&~ZK>$$p#tZch$DlI z2HV)KC|vwUg}vRK(e^|d496U-^Bbjt_$VPen1cph0`4cuR;a+}ODRrRqrz#Ck%G~F zDy;hw;=Wi)1<8g@8H@8&u(W%R+?uICA`fe27E{5X%+B>GZufCy%~t*=72a(B7jj~i z3dB#=9sFOZP)EPkK35fO;(AmGw99pOeQlpmTM|CdGu zm7`xfgRjz{@zU+J6YsODtN7?-Q#6Y;l}2_M$d(* zAQGLkuu~M{*~-~`Oq&YdWoOwqbesKS{~>olAwEc@0BuiB$MkurK>Nw)=?VA+PtpEBmYNwdYj0vuz+a&Xx*#hrh=k45Gs7#q~jHkEtN9vEj@{7b+yx zSI18TP+^Z){)zafR0zFNvA-|`STxw_?{be*7~8Gd zP=fw(8f<9y;nLqvgWqp-p579rf#=O1X&-jeKz&kH?yfiuf{6}7b%$thAYS_3I&Alw zUT>CZ_oBgc=J(G}u)S5){oVEc1`WD}Sv7q4JK@?B!<~o*q^RTnark1t$M$OUiU|$m zHq2>>VE^M`Wg+v^hz1*#7diI2)8N5hUf~;9Un26y;{RZKAQc5?(b!Q@%vS$u|Etb{%vGUY-GStqLcLqhF{Lb@P7NsfUe_` zJ_=tL@VT6tF6Byx7e}VMt6k_YePd;X{}mm45_K=-c+i3UdZhl~BRYI=Qua}dq{C{- zfq=3XbQr%Wl4pS1y|?>%-^P&+%QYm&!&P*6+?1laoI{72E-^8McMMpwZ3wuY#en@h z{srm<3=q*jtYiCz0bUZazNH@+u$Ck<9$H6swJkGD4&z&0?I1j(!enKA|yu>z2oa~}Q1Yy_u)a?w|s>R~6o20{u zCJyDn^;mAQ+);#fI=HNO@sAK0fQ-(X>|Uco)O^P?(+v!eeOJHd(kLC?C`ZeyEYcx1 zWxGX_7z6(9vTw`9_=P;D3y$;P`H$h;ATLk(JV`~oDSKJ>y$olG9aiozvwa-p7)8lH}ZIVrXBZ| zerX2WD7Ku7Tc*RNyI;k}co{gav-@$i2m_|`rW%ud=y2L2&uTQB4s)K}>)%AtA(!Sf zw(ct(=bg6}Oq9~Wz4_^x@mxC0=U%McolXb+kA^opZ!bo-FkKLuYBUc#k?BeaK0hSC< zV?EhciS_eE{*|YPo3MUG+|VadTxU16qS*XUzH;kkTi6#|G=$ ztG^}x=acTIZlpb<19w{b^)s0M z=GORPw+D0_w<@Y9rO=`JzgJiDg6N=o-&FgdHyz|E4>Zlb!TgqJl~0t=A!(@S!FjBo z>#r}QnB)GBmY!yR!1`Rdw`jZhb2@}IR9Bu4q(fePbaNS4z_}?+W*PW;nsVR6xx0| zY(V??y&aKiA z^TBpc_XoCzr!qFYBe>u5*A8xxr?H+s+)^~JN{9XLF35S~_XQiC-2J7C?PamAP7S|r zmr_w-Q^E2yjJ?&jj}8v$^V*tnbdZhDk@UuN3E7;heueRDeQ;E51-IkbJ|iKbM~9vp z>mEGaLkH<|KcH}l3HwSvIR!K@VKih-MO%OcK|S0~mVHd<+2eyZLri#TDf*6$hXsOl zl6=auOhBWl&o3Nd!lN(xqSi`GNb!B-DWJfF`_Hy2#2;fqjpe=7RpB)NScn9c&J zQ~T3z%Cq2H+;QJ`FlEK~hrPYss4l z9GQ2A*&UcrlwB9;ZOVkYVZq=0xcxi+_gq@qOo-F-9@taDf(&uuwCooaY++2>GK*MX ze$7Bg{T~Y^lC7?7$YcQ}QK+N7g9WlxB2TIKd8eL=VR$JEvVM8c*SE01(9UmHUp5QY zxBNPvhVdQf+bQ@YhXn(2DhpA6S+Hpbb925d3yw52Y*nSP;Epf7$VY_*`h|nqfxB4n zEp=v>oB|87hfC@w!dQ?y!&e@W&I11&oxj8i795Ozc*qmueQR#^H>jQk?VPv!;!{}= z7n0U#SH^FW7mJaue-yTzH%{cz%Z$^j<0=4Dg4)Z73or5WGmB50UnH5(uIc1#vw;bkGR$LDVoWGYyCLDm#e}B&zbCllnegP} z+?`wjCU`ghr%J+b=kn*SMci(fOWB;Cj`abt`)%f9g8GfW8CUl*;W{aas{-RoQ!1}> z4#oO#qc!Rfrf+!h%gHCtSn#Lr;HLuo%vCgKE&hlF!f$$GNQenb=lm<~IWZx^dQeRU zc z7e6i#ZeC!5ZsqwAAzdaUSGwoyGGIc7oSd)zx@g0 zlWfq{6gFl;xa8TMpLo8)&ypt$beOR5b=;RZOy}}*&5wYe-$t}#u`4q{+heJ)5#xQC zoSf5z?K5>HSC@e8_lxG@%1%soK-h`z`q=*R@89sAzruv{6FeHi*uL7IpBSRtU;^#m zgHJMeylxgl$K93*m**U*rni`|*Dxwe7TYI_YTiv7Gd#T0Jlw)#nm@TFx** ztoECWHs+@(ChnFsmV2k|U~wYmBgtIy@NSH!!=GSYhUfYDh0d*9LDmOwc)~cPslao-bN?*?UZvnZD^?8lF!v+x4J~`KnsWTelO>S8?d`4Q!{GpnNeE zJ+@-PQGH$uUQE}68nXK$e%>u!tI5LtY?0f;{06q;i*e%L*WY7-98s2c z2t)fzieFluv%q0>$1e@6pH&sFEaw;Tyfs5#WX;zB=~rKBF`nlGw+WjT zn85wwl5|Qn)H{M3KPbzj?Vwa{x^?R#Z`*+Pp>8+Mfx??-{CulqA|V+ zQPxbdFJ33UC-2?s#R82J!VYQr*za`smfgI~0@DK`vFdg#U`%Y|d3TKk+jVw!{)hco z>QaY7vN;Qc5)Ov#vSGoKpo1EI@0pP0vRi8o>yg?i3%(nezN;F2e1(Bb@ETh)8vlgt zIyls{%MkI3Hv3-Vu>yvtam3`%;%qDKQsAC|KS)DRHdF73_OGvJIEMnVSWxld-GccUCdgGZdnDlb;IO71O#8ooM&rM(ce221#ns;J7Yp=0 zPgV7eVmj{rT`k>&*MS0!zdGlb(3{s(*@L0O`Gmv{tpBWsXXpQ6IW_<1Cot}h^(g9# zJsX~P=X*+Z<}R2nJ;fLj{x1C@PAPkk31qQ0IN8U9vws9e>&uvs;8bFxf#^sOBwSK_h!r6xF#kr$`rGs#+gtu5EU*@V8M%B zCR+qC9py0thuSN!KhxtY_QQ0Z?tPKQ{u|5xRf2GGITI?6DLu@<`c+p^&Att{kLF3; z`e=;_=F1-!KH>4ML^(-hHDYM)arYdahsT{;%(Qx#5IUjirHT1{x@9GE^n^#PB2WY6|1Co$e2F{`NP|MNka?Rq=Mgg=AJ0!JH| z5W~Oq_;D^4ERNb!)A2ZT-Jf(*ESJ4bibL`EJMlD8Tu_(=?ygyEtQ98Mdy&1%aQ}$t zaUC^V9M8n9S3Q211$lP9U#zrQP#id(7=c)DY{Xq~Rhb2!=!|$KUN?U&a5-C;v7n&x ztqR{I7JScpZdQAS1(vky`xkKBvb-AlD)u4^ic)hQe3fItsnl;5^KpAA{&xd5xIb(E z*V#6_o=0?_`_NL1*XcLA%qci7IkvHRQY4uLtBtqinj2Zb9zPlVwuS}yR_`B~VSBgm zrUXUcb$$9l*BklAI3DdH39l#VmL?alesCoEM$0f^&mt|-Y#&|^ ziRMP^LM*u7*hfvodM@@(3eJk-^{?AiXS*5`_@^Db!xZtlmb^#6ObD;jk`MV?u|L~& zbVSk+RImV51(snRhgESHI^ zIUyKcSW`%G#{A-6&7+Jxa2z-xrY2C;kA60!?EL1@hmx9eHg*h{(U@Hn$?fv)qH5KPi#nC+COL5$a^fU&)t_@gsxut}&hJ zY!b-dI6|DgOosHHg->cmo#?~Ao;N*q-Kh2Jn+N8}XdKomGfF7UfGezWj-@@-hUW~eV~W|7jq_~MM1IW!?PMe(wlLtLEw z%X?(!5YcE%GG=BL(cjzkki_PZag`qT@a{QOXgPn^RB{znT;ecEmRms~lBreYf-C3% z{lbMbj!ATS_oHhuhbEDQ*+hWT=}A=o z5!nA)ZWPD!lR6jJtJIC}CwNT!Y5qV2neXR6j>O}851*c1)D&ZbP1)vzgMZmzF;++b zGTES=%SzWy-gF7YA_6 zCWWfv`ylg5x#uU0rtLEmh@ZLLHR&xegqE(n0f6HUaVt>ZWYh)>MnNZ z_uj;|BZVF0Y$ad0;(H-8yEC8uh#|teCl_M>f4}2>)knF$SRx#Jwe~1Bf(Unb@+bGY z65(k%_uWHL~+>bnpK3Vl%uWT4a&r3I( zIG-3rIl^g*AMXt#+3s!HFZD-|RY{2Z)oVj&J)6*x-=f3lYBG&PHyTD?L}VTj|BWE0 zn5Hz1=cCAnCSRy!KZGPT>o)Vm4x{z+Y&+L34x_|Z-8>bw!-y5B+PsZ(5D~s|Uv3<0 zM+AQzSGd=K4syOaZ5-W(mZdV!jXv!_QNP+Qx<$7l&z-*vP5Ijqr%dYh(Rb}g@fq(U zw~kp9t<5evJvfVc>FjKY$7fON&WTG4?`KhBjKh_qUuV$5oXYAfc?Gc+{N6?%Tt*tl z=X`l5m(Yoyh9%>jOXxL&H!1Ae657?w;pl|lcgFRYG>$K#iYoS~-&o%1oQkqXX9?h2 zo=}%sNdWR6=VzwDM5x?K^)nK|bTKW#0Y9S-ckxm0$);43>! z-_LrwPL3VUraJqtMd15Gz1wb;US)?T8Hd*Ik;nQK>3WjznG7zoFG-&cvcp(PZyuG4 z9Yn=DOh31hA!pyuC;C?Gpp^3Zd51MSe6osYCKi_m-0|TmwW1|vLd8iAjUsu`wLx?-J$R}DIb}g9kcEd>p)wt zEf?t2wr3Jsw5Kav-s4g1kP*P=M#g+8=wV~~60@&LN8D=JLC zJAhc~@+#He`jLbKcbz4z9|aC=SWMd4kItFxlo#36hlaU#UPHeJkgeYp&QF^C=&vb{ zwCnacRm1n!)#{}&x^JOSeQXvXUamhY#EqEdOZ80yISW_=n)qY1CIsN?w70PY_NbF{^)&+Tv|Y1T0O-& z+vkx|ba#lT;XGR6>=8AtTty~VgU?onuA-YRXNEHz)=;TmztMx?6+}HYb=JLQ1?^SW zYuu-?io#Caq>bKLMU(NK_wj`vbj`{z+z96iE)!XMw9}W6*B9GkR{1OF;9KRvkDRNh z&N`Fxiqi^87|b}pnpr_Lf8;u5 $E=Rqx;$`$w+|8}de;L^%cZIm#A;7);oQ_F$ z1W1XAmKMH80AZdT(ZM+cXc+lEcH$TT^i^(IY}i16e-&9BTVD~t;%#Vvb{8AO#3`m8 z^I?PLXCJ(7ePsjNWLbqb%WP1)?kjWo5dltU{<^xSkqwS5=%tiu5a7Requ#$Z5#aRd zgqODi0S@17@Hiw&fb%1}K;$Ut=HYdm#@4Bt#FJe#kR;i%jeS@Ib& ze7sf3lC&eksNmyA80o@m-$3QM0^7qgrbTDmZt< z+a>8Zj3fHY89ME;ME%#9u2Y4Dz>Fj;U0om_*3v_2WV9pPQ ztHnrA7O&yP?n8vqow{~X zT-qr_f_^)bj3fA7VrN6X!OCe8P&e`iZNTf1qWjbKAXyR^&Mp4>e2E0Uy~PZ9KK#A< zZd;is3BvY_?CW}f*O^-)Lea_tX#XU&|G(irbShxXJVSX1X`L^fy4E^^wrXr~(ikd4 zZR@IU)*LTDEBU)-JGKv?QLCETWBmPicjs=Pyki;hd2HM}@Oc>>Z>0(*@~oon?JIY} zrl!%(z<^V6-xg6>`-W@dCznvqqo1y{>_sHGt6rz2ZV9awmoh|!mQdI8EAPcNE+dPU z6D_W*tEgS>OnBMUDl-31&G^yaDr%NFc_d764OMtNPPvWaNXL{g@sC9WSofVg&NzbO zlq+AmQaOk~$;@3p5=VgYIIiO|SPqXi8iHOQ5qbl;^{PK({~t4XdA}NtFHhzsXq_d( zxIm2kI=miR+x#2OnkRydt5qG>4-ycM_4Mq|BZ0fGSF(USwga;i#g=OXh!wKgyLf;A z%iB2^w{BwlG4ffxfa8`s=XooWqzIr;OnPhlfdJ>6-G9Bw#`LAWiqOLMVSlN@R9h_p z{?^Q#k`^F2YEjmX)2iLy zj6SBBnx8BDhssQCm;2S)5xsubf&)(ndbfS*uBhiS+8y-B*vEJU)%B$*+22@3TNJfo zHl-{hlDcJmb^0>?)ok^`R(u~@@llFl#<68|RsJJ!Vt5YGzN;J+aGph#Mq5Aogw3Pt z&m#WrdO45IZ1_#^R$f55a*u+dkIf@!khNQ1IfwM3-X+9KETEhL!}qm4Q-~lp6-XAJ zM0Z6$_ne!bK&yih-gh@lA@QjPN>c(;XgiHF7QRj(gPlZEpGtgRU$HB8Ab1I_#s_-# z7%ia+>7?stB9;;3UV?nI&k|afV3aW6y@XhI6IHisFQZV)rA>Y~URenZiJkbkjJ8%8 z9SFqslhE`&Pjqq-wM7~4;FnrN*F}Z7jZQ70)NRd;_t+MZ`X_x2CA?4YIxAjbdSwX( z>#k)g7IiGIP+xB^0FHd3Qe?p#c;cv$ocUHvP9S^a!|n} zogMh&2kyl-k|Altj{Mh#9YW6=Cm+CZmwlKj!R;ysD%sAk<%klcF z3R`xVy~mSmK0$`XUzh$}HDL!AZMB1rjU3==;IVZFjT35|&-uUJ&jD*UMq zrdrQI4q%T|J$V}YW!sh8*L>qR!13*`7hAYF;kQ{uAl-)pes_I3BcHmU}UQj|R(U;c)ZusI8GV!@^IpJ;t z3T-t<8N7|?WRD~7t9=bGzj(5|zB0;aCRh|PWAnMC;jx)NM4cI-(HDhvBB>aS8C zDluIZp0w^ko}{0A0=eC&YV_onm9aj=%iL+CB-@8*?awzpRPI3@p^jAM%PwT=5vK3n z)Qx5bdr}{`^rOR@0?)j@(t{#mO!po3??o+(k=}Jrd(bt;{;qQtJxJ6VjQM``pc?tF zW#{?3kwv>Qb(=~*a=XAQVBy}4wucT}w#a>oj-<&Nr-!7Y_rk?r_HIi?Q{m;JrOJs& zLSl5(S^qOC>=;she$NlRn z*ov+m@Y0g%TtRA1;xCJ2SJC~2n?2iUtH^l%UiDjn736Tkt>eksGCJ&((D18q8L>J) z%c?moqfdQ>c5T>SxtyLdP3l`gk--AXxp^zdTakPB$a9-6?c`WoedYa0=H8i52s>G>zyj=c4|0O(F-i5R>%wDKs!^QZErc zg?v1ul4@Syx*c63DPJ_EQICuXZPTwARP;8T&)RVoiLRM!t+tv$P(eB(bfm$qP9LswqP*mEs9Fy=# z^q+_B){>bCB+?~%Bjy#Z+p^fQDEx8?8C#{0#1*E{s7b64JB9|%Iy4DioM+;(lx%5m1K0BiX0pYWau6WBf18R= z9UGh%y5@Xa4aetuES~B=V}nBZ3e6wgIPcOQbZ>Ao0ebwe>c$5W;FD77Wa0%JXNa6& zg^Cg2hb_CJSt5@Ah0~-yM6<&Yv4M5+zz&6quU|IRu!BA6&m(qUoF`Fwno)xHsl=`y zyk6MfMb~Dys>^afuGh?eSMPJcP&=@<;kp@81Ihd=PdFfuZz#d+7tW{fERHnR|Lr1POKz@ulv^^E*_wAl!zb-(H@FtIA}!Ty*>7GmQVQP?$yQ00~C2 z{yM&CC&6mHXIwwdQ(EXdv`Ev)`2VGX$E-FIL?P*~PIRm)$TH<|iV!m&4IRDAAtw3@)y4*Hyzg9!81|f} zzX}y0pC*s2D*iImU;X~~hr_){UV2SpC9n@QjwoK5yVr*_ZwXD0EA$}k!yc!vv-;2g z_wV(I%Kb=9rJCMv*o$7<9`h)2nMGyZCm5%`%pjh#_9LHgJ)i?&Z$=taW>C7L<~{M` z8Pt~hzO_(%7R_5&9G$nAMb@%WCdSwE&X{k9joLzaeD!M{IW^RB76aD z)yb0FWIK--ej|*-cz@^BZFftBwt&v5mvF|q&Y`nh-Q#hbI1hV(+eRDje|?^(e6+xj zYL;ey{4yKljr-Kla6IoerF2;02^$cam1A`9e*4(r?}j@22rzqd9$&m5fP_ZvSHdD0 zBtDNYw&FZen5|6qol$n!slPk&Kn5B1TKH?LzQTE^dObT20y`KaCTD2ou)`twC;UO% zIpEKvAi2(o1MbJ}G6*@u0he<=q>*Jf;30SU@!?f=tiKvgcjY+1u(j~=@HP_sZ8Z`4 zh4+;y;ZcqM;%d;<(19R5LL(CLC_E;YQj3fX+#}L!{vbZ*2f-8cTC^c;B~sy815$Y# zQ2MZ-4i$vYr_8R^p}#ls=0caNQGZxxK-#Ao^v%tgt|e2Ca+JRL5t;R<-F0?SMIsO9 zVNPo9(JVlOXf7Fp^TViCoN*)0b{J6>J)b=;>p;=O8+(gyb)sz(ZeNviI?#g%m$)1B ze6;@BZ06+eJfx2L?gxKPLjRo?k0)^?qRxb@l zAj0?a-ueX+h}W2CdFzp&{C6X2|Ayy3Tk)WEAqh75&t2Mhn*`^OeBodt321S%Wl1MfN{OC)ew9-Y_ABtiVz<;MIR z638SGkN!v}L9T^J;gwht%(L0>ovf#^%@g_EC8h38lnalUHk#uMNJ$zKBy&bychT=jTMfXW|z`}iK?yjOR5e zTz*#dPjFo*L;m$up9%@kID4y7RK*+UsnW#ex9|DDr2DPW>Z+(kb!ENnKoL>h11k(K-?&ol==IK_rlP-5({go)b70 z?^eF4a(2;R5vS{vG6ioTF)5Yjio_ z!b+p)H60G9?o~@1@P4ic5vS4^vBWOaope)+ zF4T#F+wbsoUhG0&qGZsAm@XvHKQjF;trMx~tT2pSTTwN0^ApvHc2qaqA$l(GA7V|= z!|GqPqBg=;UlHRjWSn_L;6XvWbr)nS5#vMdfcuPmMYMR z{!Kd+KJYI@Z{m}#^$h$%TNoO?}jd#s7?d z{a25e+Rk~XxC&JYxTF~Es74yyp)Uf0YS00jvDxh!jmV$5?XHD=J<@nNuzT^pS@bdZ zKdEaCGw8vtfeM$$GpJeJpz{U)9Fl(ZBhl~ZEP8#WU(WOOJhIT);C41~9>vgj^c{{a zpyzu6TQUU~(DjuIB1_Q=s5EDFB-45xjYo9*yN|9QtK2=&zvNa?Uy7dK*XdQn&lR?! zvwszFpQx?jvfgcAf;zuR!QbAXy)VK>KL4j5ZOd=sl2(6YeLptErTPh;|? z!#y0JGN1PG)NvvRpTEpCyO9K~M}GJ24#4)wRoXmaM1*GH37VZWzlW{F7Id-Z`o+H8?dX=^ z6G1V2j#0F5e8a){Hl)q;H(Pmx&qEgWC_JBQN0SPhdgYJ(Lm3IbUWoi@MTbo+#?I%r zqtJoo5?q#+!*hDy}AwPKh=Mm{90>K0C87{ZDK!)zo%<7 z?lpkCO*V2&*7c)2t9tWfe7?aJyS9N%djN@dw};-V7(~r(jf(KN57k%X;ghs}RNM5e`Fwz{C*A6!tLH6O#0zXEMqGo>s>26zmP9*f$RfT&P?Y^pSyZO`* z@^M{m*xb|pEzI`n2^O>w4s9l9GLA2^U%kDTUG z-Io3SprrRkLMaIiX!KAxTlZ)UlB3+O%Kq{PMMrIRvvICRY;>{cuG5XEG111x@0EU_cwmB4)}x8en)it(yl?z*!QiQRQ-br`#~<_LIaxeJ-U6mvjI`^S5ehS zC)(`!z1=XS1HI9DSiRM;3TY``4C-b6MAhG}jxB<0K}&B3757=KqNF?QpORx%5&I@>!_?=iNJzS0HFet*y5Wbi4&I+c zMvkJHRy~twe8QHwOJoX-EEls@=Oz(fsMg_p@+4YmOpohnoc z7SW7`ZRYmG1r(XaB8xOHqJrnTVjliDzg_C3&+WH}?eOQj(8vYkbziyiEoBky?}|U_ z^l=e++_N^jyL}N&9{8#L+j;?2uKTg;D$eI!YkP40>PGy(MM67=-G57n^5E2-bp9nY z{(O5Q_skTsU+3i#6gG*v{USEaPfnq1)qwSG-BYOYrm37|$s`J|k87#H^+(+z59I!B z!u3Wy*BLy<`ARyOlo{+Zi9T)jI9H{L>qsg@AKWLijQ+0Qf4=+&KEIMXJZ`wUgk0!8 z6@f#`*dAnszll#Fg-AJz=j*1C%*`z-U5BO+=j97S?KqBV8)dD3+c1e*+bRVuqo&b{ z`#;X}LaXTIHD95GLn}zVVf)@loG0;pUes`8-wKlY&K1E%Ttp^yksFM=@V;r~c6ssy z3B2>_+t$A#f$dc_|AQ+e$aB9+>c@Grjb)ebhc)21H*MrD1T_JU*vV4hgyo z*9nk)Jv+d8jtwl256QW_V25GVYge^4u^TdQFA}BL-1phCtMcna3SCh(HZA4#lqGlyuf)$KRff^C3o22SOp0E=gJN# z1v)#Da9*ME`KMVSC+jhO8qySXUNZ|MnL5SF3nncnj|v zUYHhtieLwNQT#o&Jba!u`?0$!h8+|$`|@Lxh_HXV`Lwz{KL5}cINoPXgg*1b*%#Z0 zaPkTQIa9pvez|ojw1Nm8r!GiYT_?h+D}`Xag#->3YA-bK{(OM*Lrx*i*9us=**wMP zD3>pYHXAGu;7RTVQl20YdWIJRK5G&|-kPn)5|48urnveVj)$Z(Vv|n#li)>B-|=v1 z?C(GHWs%pBpv6%^i?Xc~Iq*oTi0O8qH1{nsOH$qFn!})zkzqS}5ReaETKIpR7rZ?I zLwV@b;R${}ug}Oy{$9ly^E||PTzI(iBhFXyv&xl=N6-X+S>Di<5mf2Sdnw^J{?9>T z`f$X?QM5QNI>eM3L05wl%WQ5Ap@EZ6rq(luks)!Y+Ejf6t%md_T8E9I*Ov|!|4SZ5 z`)#C-4de6d`!^of+vxS7g|87!b)5ZZSM2$26Bs~>T@UU{kp3aUIa}_?JuS#3SYtlO zvk8s5kKWR{*^WdMbmV=Fn~{o+l>y(|PPANP_on(>4~qPi_VKdP6ylLRy7ChHkuaY< z=ig~fqh#M&0?QM>?|4)7L2wGSZ!P}ZM8WlN&CcxmG&G6iNg?~FFBZ{_qHRfneG5ok zS6)Ty;v)L17yM9*cM18j$u?4qrjdNou*KTnDOAfgeCXweNt7L>9MfPmjp7S$CST*7 zMuS(o&ZRxU^@I&hw^w|gL{bCINl9bV==AxD3o%^N=$4A|fm8BRNU26TUK-cYP4{5b z?y8?c{6kiy&v6~v?`9jdZM&wCecf1y@~LU`%y^s9i+@wd&1th)74}1-`dlN#uqpJ! z-@GbAcp9ZGX3Q7cPa|phe2S&zG!i>}`sdGmQ|N6nw~Bz^G~#eyeRR%b8d)zc9Pnk% zquvP9IAi;H^p-vH^}c5d=&hCRv{CgON-O(u$9^l`51A-dY`C+4OdfpAyNBx}`zA~a zwysQ|df9-(=Nu-HVR?{H)}L`?9X@hX<2c@D%@Vi#{fq1H_6~X>*A;YR)8PouSrWJ& zv2M$aC&8CXh3bn=WZ<+WHezp-BRG!c zl~0e}NQRSQZtjA|$>3(D!O?`zvo9Qb8?BR#^Z#n6+dq9FgL|RhrUYEa_Oq*U|Ck_t z{yV(-=6~(oWpos2ySCv#2<~o!5AN>OxVyW%I}GmbHnXn!Wlx zCr@90zMsij`<;Ed8$At*YNTCv(0-N%JpDXzjQI;+2?fzr>Nf9{(BN_ zp0j?ek5I5=AU-e_n%@N9vBXpTF(SSUGPt`*~Q(=m{!Y$L)XT=XCuW z+2^lXed{|dYuHe(lHbOJs#nL?l`QA1t2Ia3=b^&J&DrIKpX>4M)x9f*4()n5tlpv0 zRs38(uO+G8>zRGNVfx{?Eq8}>m5h`vSM@C+T~q4(ywjnp{k(9|_k=HY+s_9ty~x#n zUPxD>8}H&Cp5yBpJ8|jNKS$cnFK=c~eZQfv>tOck{m0q+i?}EE*6h*Q&z0cIwe}NQ z`MIKBJ~N?kQa{(jP5w(Gx3k|j(_vb#4h8Jj`!n`>=ySx+exGfqis{4px%v$|yKDWo zP_DLPLZ5m+($7_U)|8CnllZynuRK=eaS=aPpZW*uRbay;#$0Lh@Z=M%FJD%%h>A(FSHkzJZhg0%zYqtxJ>qW&&#Vv&OH~(73ac- zBX#ZXZ`Qy}af+_C-_PSpwD?d)KYKlZhd;_p_H#WgIcDmoy7v2cq8;zO)ZX9bpS5wz z;|D9P>kE6gc@b}eH7d-;Hf_7@wZfiG`C;GP?N)>6uZEr~e&0HDWntxhjc;31Vl+L_ zs^&c_;>I!w?|j;6P3RjxcJqn5tj<5@w~2Y^koB(8?W>hGud*u6>EHVMwQTwLtStxMw?6EDczy7aM^@WYUz6pW zdEaU{FM8?K#U5F!Umuy6Ce&kV;IIy_D@V5XKPPXrs$J@lHD>sXK|``Vu)-CIoA2Gu zUDnDO$M)rN?XuD?=nyJJ)xFle*#*1r=)c1{9^+u0ep9ztTVAG{+o}8}>sf>Zqi&X3 zV|AW!|5LmrJFUmnGZfi+V3Tz{|IUgHH}AGy+|Spd_Skh+mh-dkXZyO!soN3! zwX6EtQzdfUvl4!6`1#G62iCURm4;Pl{m2@gEa!k35g%D!7B9QibM<{Idfd~sPu_cI zZT&rJ!mKeLT4Bbw&OWdFNh^@@33nqyX@99MU@er~_-V_%X%^YDqAGOa)M|gQ*%n2*b$7;jsq&#J1+w*ovg(zpO zUDq4`9eUbv>uvY2<0?KmY5C;(k|)oZ7gqFMuVW0)_RLz8zvP3z&Ofu37EDzj(EFKk&cTYU97SCI7Y45H_R;}A1hJHNw!1_|W<+?SAA6n;6-D&h^`O$Jr9+B5vKm%wGPbGxzLu_WbHDGq=b@D{ zTCv6tqdu~-PuLM*)Nc>10=G{8bHm=AT_;3dSohUK>*}?i4fo7`WEH+Jx?yMgJm=dk z&!#WA@zDx9y?>W7VLw^n=5$+CbovMD?(8+=JK4`qYwlmWeX_lt`nF;@n=hCcU_E*` zsZ{)V{?@4h|E#|=Ex_8_dvK}f*#oTM(-(ys`#!*W_UT2AJwF4i!>L9m8QsI*n*8MI z?dG%nt+aoCy4tdlzt!_i(=9!-_*)Z8w|Up`rM-T8#xbof+K(3@nw>11Yn1&s)F#BV z?w$Ot_h-g!NHX4j+$mKwbE*abmeqUVpgi{DPK4hQe4Ev2XKD?s9$=MxR({~atpWDyr{52}wAZ0;>EHWs-)I3=%Ghg;RofC^ z1#CIzUpk7v6=vn;=|%0=t$I#K{$^0|0PA$CvFB5E4Y1-}U3x1+8-MFg`a6}LZwRnH zRZJA=;=2HAV3S(ii%#*kB6f*6Z$-opR=I==j(1uZVBP5b*Sd+}{jI&5TGr`M{e!hL z@3BO0?gUsJ&mF1IWlw-L$1mT$H%I-g%_n=0No`*@AZM;O)%Rt!|9!`6geY=2z#7`A zeZqJ5{H)rDMC+)A-CquXPMeWZEn?7yH#sT*0o9SLW&JVBdL62@d<0kXBhOfBxIHHezoWDEF@+|hxYxe9M*Dn+bu&(wR@!-}re=BOw{@+V| zwqL)iGN)?t^a0k#@tc>n`uN`JJS<6=H1_L+&+}jLed=SsuP@|>={GwDSlt)RdLAN5 zfHl8rj6-4V*A=Ifn@~DMp8zX!oSac6CH-Ka}mb3x(_q9Ca;A?&X)~y&>=Pk6??_Y7PP|Pna{H-0A3lwW$uk#;a z#+hfOF8W)c=FUs&Tgu;h@icqCTG75+|759@@sR!cYOkh02Fz;z-MZ1|@Tzh}zFYl1 zSDmzdz&Gps&hc#?Px@wsyLWKnO8d`;tr{>QV?q0UCKpRJ-B|6dH8E7NSz*Gywf^iE zr$LjoZ>$_2^Sy2Q?5*`>=-xfWE4;I|`Mv6RYT-L;#h>vTx2gBedhB~X)r3XwEZ3ll zt>@UsUwKzfOq*u$TdUWtSjmoWd22NY7khIGpSRYQj4ua$v7gU%Xi+k>_2jG7cwe}| zy{CP(Qn#OeU{9iN*6~uS*PaddVh#J!Vc6uPU#)nd6U^~V`OSKLxWe%UwZ2(XhRuo8 zaKIO<>btE8XKc1Ve{QqfSK55B?33f$I`#Q#<$iE{d^G!cWccOBhi19(#cKFl*`*H~ zf3d2)U)y7decYIPUX`>V?DvyQh_W(Y$oBU0#e}M-SzphOVhWTvutJ&~ch*;mOJNXB`4R`9RwIOumo@ru# zv*P=l3}{~LqZMjsm7{OQe6%h^&Y5cG%rLI9k;axDJ1vYW?U5F(Cl3qbiZ@{4ornj* zxK^ZWTXsgxFfO0RQ(sJ4Wj}Ab8)9oy`_G^LyY%}I>%!RY3y+;4dSd%HsBXm#s~_3# zm#$DXchS+lu1CY0udiLt*EOx?>j)WY`MO4}sr@*jeZD!-u{~Aig!Xj}a}{VZHG{7! zUe&r|Yi9R#t(?6lMD7H>t``%Z7A-o@*IvIPTHW4j?APO7URcqhx3BAInL|JQ?epK$ zl5~8t+kT!iH))YS!$h)|%DR~_T7;DL>uOc^#d>z$*VVZB`;qs0__{_c%(EvTg|F-9 zf^u)}bhOW_j@jk3gdx#Vz9N+Iyi)$1xFnU9;nrDBQobudBaqbS=ERCu2+d-`nF--YYGu`MScNZ5sVrYWsLJ(YA5x%h~69S3j##tf~EewaDLV z-kM^czhBnF_pJRs%5~c>b?rOJe*XA+WccMT?bnwwl?;`&s-J61%+R|dMDTN!Uz;-B zQ~yw|?lVf9m|;Ip?DVDjVJnrNt3udO-)Gzo<;r`Y&)8L`d|k0Zj(*sxi?8cw)gO1R z+P{ZUFXe~o8;UtgZufA^KN_WR%B6pwkc z=AjU-ahI~hy5I`o%3g1G>+JUX=OP}eu;J1I`~9TttwcBM^ZYqh$GzTUTL@RL)`?3b zoNm7#`*6jSNA34fkLnm})RA|2%wXpQp%wc;v3t-RuIU%oJqvFiZ*CZVXKk%B zey+}+qwM<`#n06y)01oAw%hOj>C(DX*9U&CB57xr*_OgS&Rj4id6RB_u8%z*E=$ta z&vkReh6Ro6?Gw(&$0v2nQ1<`&_{8<~@$qyVI2IfmjtJR16gVmz z4gML@$0vfv-|u60Pltd*!oF}Q*bfd3hk?Vw;o$Ia1o$ETJ&)kW@Dunc{0x3>+wGyKK2=k4<={0;sN|A5!R>)`e926!X93Em8E zfw#ij;O+1ZcqhCI-VN`8_rm*Zd*0vn!w2Al@FDmxd;~rUAA^s>C*YItDfl#e20ja) zgU`bk;EV7j_%eJ2z6xK1ufsRsoA538Hhc%Z3*Uq9!w=w6aA~*HFs zTeuzE9_|2lgge2VZF@dGbb-6V-QezU54b1X3;qM{4flcj!u@P}?tlBk1K@%1Ab2o5 z1Re?xgNMT-;F0hscr-i)9t)3y$HNogiSQ)bo{|V24XWI7MKg@z> z!*k%d@H}`vyZ~MZ{{=6C7sE^7rSLNNZ+JPp0$vIK1FwQt+xGlj?YaM*4=;ch!hgYw;KlF~cqzOL{u^EnuYgy=|G=x@)$kg4ExZn1 z4{v}s!kggD@D_M0ybazC?|^s0yWrjM9(XUj58e+SfDgilY*b!)M^L@HzNAd;z`)UxF{gS8RLUAFsmK;Op=W_$GV{z75}j@51-s`|tz!A^Zq_ z3_pRN!q4F6@C*2*ZO_}=EBH1127U{_gWtpcZ~*)P{s@19Kf_<(ukbhcJNyIw3H#u9 zBLo~0_OMzCR5%(O9gYFVgk!<6;W%(yI364y zP5>u_e}fajiQy!0QaBl$+_vZaD+T;JoDxn2r-swOY2kEmdN>1|5zYi>hO@v~;cRer zI0u{)&IRX&^T2uGd~kla09+8ZU>95nE({lei^9d=;&2JLq}xZFFJyl-v7cNzx36Dr z3O9qB!!6*Ja4Wbq+y-t7w}acm9pH{|C%7}*1?~!WgS*2$;GS?V_z$=@+z0Lp_k;V} zb{-#o{rm>N1K~mNV0Z{T6dnc-hex>m>-9wY;qQMQznoj0=i{0E^49;@c@@&L`@x~% zFmPDd{&xQ7>p8b|&#&+N|DKKrM}i~6QQ)X>G&njO1C9yDf@8yR;J9!+I6j;JP6+=7 zCxR2hN#LY#GB`P$0{$IN38#Wn!)f5Oa5^|WoB_@VXM!`sS>UX;Js;1r!P(&)a85WE zoEy#q=Y{jZ`QZX^LD+&_a3Q!bTm&u(7lVt#CE${9DY!IT1}+PigUiDe;EHf1xH4P? zt_oL!tHU+mns6<+He3g;3)h3|!wukua3i=e+yriF+jD>13~mm$fLp??;MQ;(xGmfc zZVz{WJHnmd&TtpFE8Gq44)=h2!oA=>;NEZ_+n)QUzHmRdKRf^)2oHh>!$aVq@Gy8d zJOUmGkAg?TW8ksyICwlf0iFm?f+xdM;HmI5csl$iJOiEy&w^*ebKtq~Ja|650A2|H z1uudZ+xFZaEPX!u#O;@B#QBdz6@W1 zufo^h>+lWuCVUIN4c~$9!uR0&@B{cE{0M#wKY^dZ&*10q3-~4c3Vsd0f#1UK;PV!oR_Z;KXneI4PVAP7bGl ze}_}Tso>Ob8aOSS4o(kefHT6G;LLCqI4hhD&JO2*bHcgc+;AQ^FPsm~4;O$7+V%etwd+xvL!S&$=a6`Be+!$^GH-($Q&EXbsOSl!>8g2u(h1so>Ob8aOSS4o(kefHT6G;LLCqI4hhD&JO2*bHcgc z+;AQ^FPsm~4;O$7!WQg;3&DlqB5+Z-7+f4K0hfeJ!KL9ca9Ow|ZU8rg8^Mj?CU8@@8QdIh0k?!(!L8voa9g+?+#c=# zcZ55^o#8HUSGXJ8-L`Z5@@xOs1MUg;g8zVf!+qesa6h;|JOCaD4}u57L*SwCFnBmT z0v-vEf=9z+;IZ&Hcsx7-o(NBZC&N?Vsqi#-I{c??=lJK>?PCT!6P^XnhUdU@;d$_U zcmcc+{tI3NFNT-EOW|ek-|%vH1-uge2VMoQhS$Jr;dStOcmuo<-UM%kx4>KBZSZz@ z2fP#B1@E@)xj)(i?}hil`{4udLHH1S7(N0Yg^$6<;S=yl_!N8^J_DbH&%x*63-Cqw z5_}oH0$+u%!Pns%@J;v@d>g(4--YkN_u&WdL--N=7=8jjg`dIC;TP~r_!aybegnUS z-@)%;e>edC0Dpu(!JpwT@K^X7{2l%Q|Ac+Q{O|rC1RN6fg+tl)eEjl*L&IUzhTFhx;dXF)xC7h~?gV#+yTD!HZg6+F2iz0x z1^;2&^LV{C+z0Lp_k;Vx1K@%1Ab2o51Re?xgNMT-;F0hscr-i)9t)3y$HNogiSQ(N zGCT#I3QvQl!++ZLJbs!1&xB{ev*9`LTzDQlA6@`2g#UsU!HeM~@KSgg{5QNDUIDL! z|AAM*tKl{9T6i729^L?Ngg3#P;Vtl1cpJPO-U07~cfq^iJ@8(5AG{wv03U=8!H3}^ z@KN{}d>lRjpM+1rr{Od3S@;}$9=-rygfGFD;VbY}_!@j2z5(BaZ^5_WJMdlj9(*5u z06&Bu!H?l5@Kg91{2YD(zl2}Gui-cFTlgLP9`=U=;1BRe_!ImY{sMo6zro+(AMj7u zC+z<|{)B)-!oF}Q*bfd3hk?Vw;o$Ia1UMob362a$fuq9F;OMqJkH2ETG2vKnY&Z@a z7mf$VhZDdF;osmyaAG(KoD@z5Cx=tOzr!ivRB&oI4V)HE2d9TKz!~98aAr6QoE6Rn zXNPmZIpJJzZa5E|7tROghYP?3VGDM_h2X+)5x6K^3@#3rfJ?%q;L>mzxGY=_E)Q3L zE5eoF%5W99DqIb&4%dKd!nNSqa2>cVTo0}fH-H<$jo`*`6Syhd3~mm$fLp??;MQ;( zxUFr^=TGh6_HYNdBisq@40nOM!rkERa1XdA+zb8#?hW^W`@;R;{_p^JAUp^j3=e^a z!o%R<@CbM$JPIBSkAcU+l;pOlOcqRM~yb4|ouYuRX>)`e926!X93Em8Efw#ij;O+1Z zcqhCI-VN`8_rm+&{qO{X;Op=W_$GV{z75}j@51-s`|tz!A^Zq_3_pRN!q4F6@C*1Q{0e>zzk%Pv@8I{a zKO6vmfIq^Y;Lq?E_$&Mk{to|uf5OgUub#(GA>fd(FB}T?gG0k%;IMEwI6NEyjtEDB zBg0YPsBknmIvfLz3CDtC!*Sraa6C9ZoB&P;{{|<56T?a1q;N7gIh+Fi9Zm_Sf>Xn3 z;Iwc$I6a&J&Io6MGs9WntZ+6sJDdZ~3Fm@y!+GGma6ULcTmUWzTd)f*1Q&*jz(wI= zaB;W%ev4dT@QX0o)L7 z1UI%_qDTej*VKG`1~~uY{H-gyZsR9wcDG-Yj@5UJ0GUM|JVH2f4Ti427bEn^CO8KS@bBP zM-@Gq=+Q-wVeHxdEipxpC3ut=QZHze*6@{e^S7zrSDm;s5*k?w(8Z+@j|ZJ+G;||6%z=&o6ob z(F>Zod&60xyF@Q!>h29+So9*I7Ztsj=*2}ZA$mzuci(VIiC$XtGNP9iy`1RfMXw-w zMbRsXURm@iqE{8Yn&{O6}_J5^+j(WdPC70nYw$&*jV%? zqBj-2nY;hz_&IanBUf|LTZrCL^j4y`7QKz=ZAEVx?~c2e4lB4We%peUs>$Mc*R&R?)YKzFqVkqVE)a zm$3)#0C$VNNA$g-?-PB$u?Oye4v2nG^h2T_7X66mM@2to>bU}+pBxwcgy<(lKPCEU zV-Gz3J|p^B(a(u~Ui1s1Ulje4=$A#mBKlR)uNiyRf6H~zZ-{`MSmgsOVMA6{#x`mqQ4dWo#^jH_ZK}t^bewc z6#bLvpGE)T?&014{q_1ow!q!L&sWjEiT>Tx19#s(KScj2`mY!G|MUI3zrM4^w)0`? z&YIiay0Zqix9+UT9b^yOefl_SbbIU0n%&;Ivxc{~?yTwUtvhRcd+W}c-$C}k-J_4Q z2DrB#QFLdGaBrXQtQqdDJ8Ou0>&}|u-nz5KxbyL!=U15ne?ZQf&}|x-nz4fxwr1DY3{8%Yn*%Q&YI^z_rM!~vj)1i?yQOKtvhR^2i>#(J9pMh z_tu>?)V=j2qC0D>d;5H6&2?|xS%W?3p5x!SvnIQ@?yS-7tvhSBd+W{`?%ukyrhAY* zSK#(_)_C{Uoi*RRb!QEDZ#})}&KmLFKHph0-dlIpkoVS|HRXfufj=K-jd^d~S##c7 zch;cy)}1x!y>(}edT-rXv))^G*02w{2c92t*0lH5oi*;gb!W|cZ{1k~-&=Rq#P`;n zHS&Y*f#+A8HS@i7XAOPl<3Eqzfj4g-m*~zK``$j^S##f8ch=zd)}1x^gY1DfKObj} zesA4bv)@~H*6{b%oi+Wvb!Ux#Z{1n*-&=RC0T5&ly!rVAzS%N}pO3pc*9Zvm^8&`V0g6!_&Yv-B>-nw&*1n1+w$6tXr5a*f+ z-nw%Q1#i8fu?OD3oNFuu{rQ32x#ohm?p%W*=pJ|jbgs$ZtvlCf@YbDcHU!-RZ-CA< z9K3bsnhxH&bBzaY-MQw2x9(g6BFLU4@CMx0*aL4M&NU)}{`|o1Tr`a#hTiGJAKd3=_|{rGo8^rNC56aBd8CqzGK>h8z4Q=*?1{fy{mML#F{ zc|*_bKL2z<^oycj68*BNyU#CO5&f#W|M&4R@cH*O(XWesL-d=X-!k^V=jXRYza#ox z(eH_VU-So}KNS6u=#NEzBKlL&pNall^cSMP6#bRE|7ZW^zJI+I{f()+?;mePe`o6M z^Ox^M_cwL-`ON^)KNxy$_uv0f^iQIHHg)&+_r=uR=U2aq{!R4nqW=*6r|3Q*1J7^p z_LJLv{x*c@Aw~BUJ(RoucmEo=0sTY|EqWNy!-^ix*aJ6Ec+n$>9#Qm2qDK}zis(^I zJ!jwvtZ1S~H}=32Br!yfDS9l?V~ZX~^thtO6Ft7@2}DmQ`fs8q5n#iJn^YG@_>!J)Nn$_pj+i&mek6L(l3y|C~wm%%W!zJ*(*1 zM9(gI4$*Upo=f!HrtUt!n@9A#qURGmzvu--FDSYtx=ZvzhMvcL`zkDY5z&i^UQG1j zqL&c8q^Y}q{-xagKgTE819woRMK2?ISz`~}L6j4{yyz7~uPAyY(JPByMf9qoR};Ou z=ru&IDS9n;4;Oft{D140xSwA5)E2#t=ygS}CwhI+8;IUe^hTmL7QKn+O+{}edUMfR zh~854R-(5Sy^ZK?4c-0p(x;v1?M3e(dPmVaiQZZCF7D3bJNL`)K3zrcCVF?#dx+lC z)ZNEty+r>*^xmTP5xuYI{Y3BY?*DoG$`SYgK0x$=q7M>%u;@dKJ@5f|sOZB)A1?X` z(MO6tO7zj9j}d*W=;K5mFZu-0CyG8v^vUkd`@j3`Pd-ybpDOw^(Wjfb`}pop(PxN0 zQ}kKx&f^F7(_5d}qR$b1uITebpD+3X(HDyTm*|T`Uu@{^-LuaU(U*$8O!U7+UoQFz z(N~K8kLasJUoH9?cjx`{*ZbGj8oK+}BYf70zTVW`uP7SXqgzD@M) zrtW?Mu|xEoqVFT^ZNt2dVP4lTuVB;`7^@6e=I*JfRO+KJv?^bVqT6upz^oki~=dRNiA ziQZlG9-{XYy_e{Jh~8WDKBD&(y`Sj)MIRvgK+y-eJNL(f4f7#}`B1}rm|;HLFdt!< zk2K6j8Rnx6^D&0`Si^jrdv3aaj~9J{=o3YsB>H60r-(jP^l73`7yVDsXSh3We=`m9 zS%&#+!+efmKG!gx=boE>-t$FYAo@bl{}O$X=!->PBKlI%mx=zj=*vZ4;qJUWtTfF3 zG0ayP=Bo|!HHP_G!+f1#zTPn3V3==o&rP?tO`>lWeT(Q@Mc*d+cF}i;zEkvFqVE=c zkLY_v-zWNh(GQ4zQ1nBh9~S+H=to6ACi-#FPl$d}^i!gr7X6IqXGK3J`gzeWxI6b} z7Y*}EhWTZ~{EA_I)iA$im|r){Zy4q`4f9)u`EA4ej$wY+Fu!M*-#5%380HTR^GAmH zW5fK3VgA%Ge`c6JH_Trc<}VHNSBCj(!~Bh5{?;&mXPCb?%>51X0K@!)VgAuD|74hd zHq5`c=cdQoucChw{k!NtME@zePpH5X4W{ougyy=l&2FAYji<+M91OEZ{ zhI_z0;a+ejxHH@Z?h1E6#D1-FLVz-{4naC^7|+z~Dfmx0T|<>2yg z1-K$y39bxRfvdvR;OcM%#Tm`fvldA>0UV3^##e!*Sraa6C9ZoB&P; z{{|<56T?a1q;N7gIh+Fi9Zm_Sf>Xn3;Iwc$I6a&J&Io6MGs9WntZ+6sJDdZ~3Fm@y z!+GGma6ULcTmUWzTd)f*1Q&*jz(wI=aB;W_`3_<4ex>X!u#O;@B#QBdz6@W1ufo^h>+lWuCVUIN4c~$9!uR0&@B{cE{0M#wKY^dZ z&*10q3-~4c3Vsd0f#1UK;PL&IUX=Ny`+ zopWcNcFvi3+BsL|Y3Cf7r=4?So_5ZOdD=M_=4s~~n5Ug{U!Hc(d3oA7*X3#F9G9n^ zb6cKv&S`ntIhW;W=Ny)&opV>7cFtLO+BsL{Y3Cf3r=4?Co_5YjdD=M_}l&76@ zPM&toF?rfKr{rnp9FnJ) zo_5aBc-lE9<7wv{jHjJ*E}nMIv3S}!r{Zbn9Ezu%b0(g4&XIW9IVa+2=NyQqopTao_5Yrcsd>&A5H)#gnxq*!HMA{a8fuKoE%O8{|={wQ^Bd> zG;mrt9h}~_bAIR7@ni-#Bb*7&3}=C}!r9>La1J;poD0qk=YjLW`QZF;0k|M+!7jH` zJ2zze!?`_rZqGt+VYmof6fOoAhfBaE;ZksExC~qtE(e!~E5H@uN^oVk3S1Sg23Ln` zz%}7oaBa8_To`PH<q*xDVVH?g#gW2fzd2LGWOB2s{)X1`mfv*!DahG7=sIkA}y< zW8rb|cz6Ol5uRk*^YMBzJO!Q#PlKn!f5J21neZ%lHarKO3(te+!wcYr@L%vEc(HBI zpXU;IDZC8+8(t2tfLFr*z=iDJ74`gn3d2R2yg z1-K$y39bxRfvdvR;OcM%#SHd;a|D!wukua3i=e+yrh4H-nqQE#Q`L zE4Ve>25t+tgWJO$;Er%7xHH@Z?h1EX!u#O;@B#QBdz6@W1ufo^h>+lWuCVUIN4c~$9!uM=@?w{_%58#LJBlt1=1bzxXgP+4M z;Fs_#_%-|neha^Y-^2cJ0Q>>|2!Db zL&IU8L03ix+8C7cRQ4X1(A!s+1ja0WOdoXNK5{UZ^jFZd6*H{1vA3-^Qj!vo-f@F3fsKaaui5O^p& z3?2@TfJefk;L-3Hc&u&D?L7`24^Mz6!js_1@Dz9|JPn=>{|V24XTr1K*|t4@K6Bu? z@H}`vyZ~MZ{{=6y?YX@d!%N_$@G|&scsaZRUJ3sLuYy;@Yv8r;I(R+2!M5l2-w1Dl zH^W=tt?)K@JG=wl3Gae;!+YSp@IKp~Ki~cE0r((%2tEuSfsew+;N$QK_#}J^J`JC- z?fLUN3!j6}!x!L-@Fn;%d8L03ix+8C7cRQ4X1(A!s+1ja0WOdoC(ehXMwZA+2HJO4mc;A3(gJaf%C%o;QVj_ zxFBr7F1Qd}7%l=Ag^R((;Sz93xD;F(E(4c^%h`7J55Mj&<>3l&MYs}N8Lk3Xg{#5U z;TmvFxE5R+t^?PF>%sNm25>{T5!@JV0yl-5!Oh_oa7(xq+!}6U+c_Wb>*w1RZU?uA zJHQ>`PH<q*xDVVH?g#gW2fzbud)|Kr!Gqx;@KAUdJRBYY zkAz3Tqv0{|Slgbrk8$vLcmg~To&-;Zr@&L;Y4CLTPurf`dj>oco(0c_=fHDqdv1?; z@O*dyyb%5iUIZ_Om%vNmW$@qda@(HUX9c_x{s&$KuZGvaYvFb9dU%6v&+WSr-UM%k zx4>KBZSZz@2fP#B1@DIUzN8w}eargv$5g(4--YkN_u&WdL--N=7=8jjg`dIC;TP~r z_!aybegnUS-@)%;e>edC0Dpu(!Jln=?oYnJU*T`?clZbV6ZWy+Pw4sgg@8lCzHlhm z4-O57fy2V#;P7w+I3gSgjtobEqr%bP=x_|!`85O2?H3D<4aaf&*Y9_Pu#aV(&;Mon z<=+3X{p0!{+n@eFwtxNYe;gWi_Enx=KdfzM_UrF+t^wg`=b9FtcFt9J+PS8Jrz63U zVdwAm{PR)aXmE5m1{@QP1;>Wt*!KMQ$A#m;@!!T++nT&J}Jv?Od_N)6Ny@ zJndZZ&C|{mygcn(lhV_9;e2p@xBy%bwqO@r2rdj4fs4Y$;NoxzxFlQ(E)AD~%fjX0 z@^A&XB3ucs3|E1x!qwpFa1FSoZO`+swcy%t9k?!B53Ub4fE(KO+#fZ98^cZDrf@U3 zIotwn3AchcA99{vh zg#Uq8!K>jl@LG5sydK^FZ-h6&o8c|+R(Kn{-L~iba|gT=-UaW5_rQDMeeizx0DKTW z1RsWvz(?U@@NxJAeA2e(?fVpb8a@M`h0np~;S2CZ_!4{>z5-u`uff;h8}Lo|7JM7N z1K)-3!S~?@@I&|!{1|=$KZT#c&*2yFOZXN18h&HjbN}=feh0sY{ow%k1N;&G1b>FV zz+d5S@OStJ{1f&G@xS|%5O7G?7Y+sc!J*+Wa9B7T93GAUM}#B6k>MzCR5%(O9gYFV zgk!<6;W%(yI364yP5>u_e}fajiQy!0QaG7y&;4m~I0gJWoDxn2r-swOY2kEmdN>1| z5zYi>hO@v~;cRerI0u{)&IRX&^T2uGd~kla09+8ZU>95nE({lei^9d=;&2JLBwPwE zZQFDITm~)+mxIg072t|+CAczN1+EHLgR8?e;F@qPxHen|t_#%$G;hHxXeG28@h z3O9qB!!6*Ja4Wbq+y-t7w}acm9pH{|C%7}*1?~!WgS*2$;GS?V_z$=@+z0Lp_k;Vx z1K@%1Ab2o51Re?xgNMT-;F0hscr-i)9t)3y$HNogiSQ(NGCT#I3QvQl!+*jv;F<6& zcs4u-o(s=|=fex&h45eSB6u;p1YQa+ga3w?!z*liKL1?_{{ydrSHo-IweUK4J-h+l z2ycQn!&~63@HTimyaV0|?}B&3d*HqBK6pQT06qvGf)B$-;G^&{+n&dt$Kez3N%$0e z8a@M`h0np~;S07sk54Ybm*C6r75FNA4ZaTFfN#RL;M?#W_%3`8z7IcuAHt8|$M6&Q zDf|q64!?k3!mr@h@EiCo{0@E(`@;e72lylW3H}U!fxp7v;P3Dc_$TZW@_%o?A>fd( zFB}T?gG0k%;IMEwI6NEyjtEDBBg0W_dp>?dg`>gI;TUjCI2IfmjswSq}hcmz#;Y@I5I18K=&IV_PbHF*_ zTySnU51bdy2j_EW$8|E<#^O%NtEWq9+kOsp!c>PcC{2(SH{`rRb?dPc3>H(bI~aPW1Hd&ihpc!#ty5p2;xJY?x;; z%(EKi*$ngShItOdJf~rv%P`MvnCCIf^BU&)4D%+`pkZzq<}Sm$kYQffFfU@5 z7d6a_8Ro?e^Ad)6NyEI9VP4uWFJqXOHO$Ky=H(6Z3Wj+_!@QDVUfD3OVwhJo%&QsZ z)eZ9+hIviHyq0_Z>v6wG(d2bTU=uJd# zDta@~n~UB;^p>Kx61}zPZA5P?dOOkEi{3%>j_%IK#ZHEKXT!XUVcyj+?`D{HH_Usu z=cfB%Ptkjc{)gzjMeieeU(x%C-e2?qq7M{(kh`1i=YvHbBKlC#hlxI1^bw+u6n&KF zqeUMh`dHD&i9TNR38GIFeUj*tMV})2RMDr2K3()bMV}%1OwnhFK3nuTqR$n5p6K)4 zo%`_xhWSFn{4c|Nkzu~rFkfPrFLlpN`|)L>|1J7*(N~DRQuKdBUnTl#(btH+R`hkE zuNQrT=o>}fB>HC2w}`$~^lhSV7k!7jn;r*uioQ$q-JIMBgv^0nrbNen|Ag zq8}0csOZN;KQ8(S(NBtg%H6piK5dwvG0e{z=I0Fa^M?5a!~CLQe#tPuY?xm$%&!{e z*9`OPhWQP{{H9@k%P_xfnBOtX?;7U!4D7 z3&Z@SVgAZ6e{Gn*G0fk(=ceO?ccQ-+-Cy(o(Lad(QS?uue-{0V=wC(uCi-{Le~A85 zboa}T|GuBb`J+6q79vF87ce~zhZNmc^iZPvi5^S7@VV=P-&uExuGR!j@=2;B$tcH0u!#ulTp2IND zX_)6S%yS#&c?|QshIu~2JilRHz%Vapm|KRq%P=owm=`w8ix}ob4fA4#d2z$MgkfIN zFfV18mp07HxaX$hKlhuMjK>e<#OIe6y@Kc!MXw}!WznmMURCsJqE{EahUhg#uO)hI z(d&p_SM++K*B8Bk=nX}0Bzj}fn~2_2^k$+r7rlk(Ek$o7dTY_!h~8H8cA~czy@Ti- zMeihfXVJTe-c|H&qIVa)hv+>;?`a#hT ziGEo0BcdM_{g~*--JR#}P8jAV4f9im`Dw%ajA4G(Fh6IQpEt}e80HtYqTd$%j_7wqzbE>A(I1HZQ1nNlKNkIo=ubs|Ci-*HUx@xv z^jD(47X6LrZ$*D6`g_s+MGp}DgXkYc|0Mcn(Z7iPRrGJ7e;56S=s!hwe)rw^FzsKR z--Y+qo!^c3)}7y#_tu@?o%hzA-=+7~o!_nZ)}7zA_tu@?z4z9g-^KUVo!`y()}7zg z_tu@?-S^g=-{tq#o!{;E)}7z=_tu@?{dYc0&ljAv0K9c)Z2)iGSu4O>ch(N@)}6Hk zyme=70dL(|YrtD~)*kTIowW$Ob!Tk?Z{1m|z*~3LF7Vc!wG6yX! zZ_)dR-dFT~qW2elfan87A0+xUn2TacQ@^SmWlqi=*vZ4A^J+u{}Fwa=&MCvBl=p=*NMJf^bMkK6n&HEn?>Iu z`c~1miN0O*9is0PeV6FFMc*U(UeWi7zF+hMq8}9fkm!d+KO*{3(T|CKT=Wy7pA`L+ z=%+uBw112wdSua~h#pn+Xrf0KJ%;EpMUN$VY|-P09#`~uqQ@6Kf#?ZE|4sBnq9+zT ziRej1PbPYD(Nl>2yXYxJPbGS4(bI^YR`hhDrx!hg=ov-NBzk6dH|_tkh@MsSY@%lu zJ%{KyMb9OAZqf6Io>%mIqURUAfanE9w?ub|UP$!9q8AapsOZH+FD`ls(MyV6O7zmA zml3_J=;cH&FM0*hD~eu8^va@F5xuJD)kLo@dJWNQie5|f+M?GHy{_o>M6WM;1JN70 zyXp9|k?4&@Zz6hA(VL0hT=W*Aw-mjV=&jwI@0V+1n71{|+ZpEV4f76$c}K&%lVRT3 zFz;fRcQwqr8Rp#$^B(TG>2}vs^j@O>A$o7o`-t9G^nRlE7kz-}14SPs`e1kG?RJP^ zKGZNDW|$8*%tsjJBMtLWhWTj2e2if})-WIEo||rm<3*n!`b5zui9T8MDWXpmeVXXg zMgLRu8KTb=eU|96MW5sDyq(WA%;y>A^9}O_hWSFn{4c|Nkzu~rJvZGxmx#Vp^kt&| zE&6iNSBSn+^nXNOCHiX7*NDDW^mU@I7kz{18{M6^-%W=3X2X1oVZPNc-)5L^H_Ue! z<~t4ZU55E?!+einzSl6{XPEDI&rSEI1EL=k{gCK~ML#0?QPGcyeq8huqMsD~l<22L zKO_2Ccjx`#oMC?6Fu!1!Uo^}w8RnM_^DBn=Rm1$6VSe2(zhRi)bk9w`MSmgsOVMA6{#x`mqQ4dWo#^jH_ZK}t z^bewc6#bLvpGE&7`d87viT+*mAEN&h-6xcIy;$^+qWg*-N_0QbLyI0p^su6b6Ft1> z5k!wDdL+>!iylSvsG>&`J-X;IM2{(YEYV|&9!K=JqQ?_GzUT==Pbm6tq9+nPvFJ%e zPbzvc(UXgwLiFE7PbqpT(Nl|_M)b6zrxQKB=o#Fd$Hy5B^Gt?$X2U#-VV>16&t{ls zH_USw<~a@XT!wjW!#s~+p4TwXXPDuA4D;58 zc^kvLtzq8IFmG>|cQDL58s?o0^Uj8O7sI@(VcyL!?{1j)FwA=z=DiH_KMeEUhIt>u zysu&2&oJ+Am=7?_2O8#s4D-Q;`4Gc=s9`?LFduH1k1)(fy62|zQ=>#5E&3SI$BI5q z^zouk5PhQPlSH5F?xyo^Q$(LC`ZUp}i~gtRGen;$`Yh3Bi#|v6xuVY#eZJ@mL|-WS zU!pG(eX;0E+}(8lTq^o9(f<~Gx#%lIUn%-OqOTHtwdiX^Un}}L(btQlMA$m*ETZ!IU^fscm6}_G4?M3e(dPmVaiQZZCE~0l8y_@LWMeiYcPtkjc z{)gzjMeieeU(x%yd&Iz}@jhL;c5cQ(0>N7#cnbt?f#59= zyaj@{K=2j_-U7i}Ab1M|Z-L+~5WEF~w?Oa~2;Kt0TOfD~1aE=hEfBl~g112M76{$~ z!CN4B3j}Y0;4Kim1%kIg@D>Q(0>N7#cnbt?f#59=yaj@{K=2j_-U7i}Ab1M|Z-L+~ z5WEF~w?Oa~2;Kt0TOfD~1aE=hEfBl~g112M76{$~!CN4B3j}Y0;4Kim1%kIg@D>Q( c0>N7#cnbt?f#59=yaj@{K=2m$|FH%BAM2_u-2eap literal 0 HcmV?d00001 diff --git a/tests/_data/generate_data.py b/tests/_data/generate_data.py new file mode 100644 index 0000000..a9e0ac7 --- /dev/null +++ b/tests/_data/generate_data.py @@ -0,0 +1,17 @@ +import scanpy as sc +import txsim as tx + +if __name__ == "__main__": + + # Simulate spatial adata + sim = tx.simulation.Simulation() + sim.simulate_spatial_data("IL32", n_groups=3, n_per_bin_and_ct=2, n_cols_cell_numb_increase=2, seed=0) + sim.simulate_exact_positions(spot_sampling_type='uniform') + sim.adata_sp.obs['celltype'] = sim.adata_sp.obs['louvain'] + del sim.adata_sp.obs['louvain'] + # Filter genes with 0 counts + sc.pp.filter_genes(sim.adata_sp, min_cells=1) + sim.adata_sp.layers['lognorm'] = sim.adata_sp.X + sim.adata_sp.write("adata_sp_simulated.h5ad") + #TODO: simulate image as well + #TODO: adata_sp.uns['spots'].index looks weird -> clean up \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3fc3d05 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +import pytest +import anndata as ad +import numpy as np + +@pytest.fixture +def adata_sp(): + adata = ad.read_h5ad("tests/_data/adata_sp_simulated.h5ad") + return adata + +@pytest.fixture +def adata_sp_not_sparse(): + adata = ad.read_h5ad("tests/_data/adata_sp_simulated.h5ad") + adata = adata.copy() + adata.X = adata.X.toarray() + for key in adata.layers.keys(): + adata.layers[key] = adata.layers[key].toarray() + return adata + +@pytest.fixture +def adata_sc_high_sim(): + """adata with high (but not perfect) similarity to adata_sp_simulated""" + adata = ad.read_h5ad("tests/_data/adata_sp_simulated.h5ad") + np.random.seed(0) + obs = np.random.choice(adata.obs_names, size=int(0.9*adata.n_obs), replace=True) + adata = adata[obs] + adata.obs.index = [f"sc_{i}" for i in range(adata.n_obs)] + adata = adata.copy() # Important after subsetting + for key in ["x","y","n_spots","grid_x","grid_y","area"]: + del adata.obs[key] + del adata.uns["spots"] + return adata + +@pytest.fixture +def adata_sc_high_sim_not_sparse(adata_sc_high_sim): + adata = adata_sc_high_sim.copy() + adata.X = adata.X.toarray() + for key in adata.layers.keys(): + adata.layers[key] = adata.layers[key].toarray() + return adata \ No newline at end of file diff --git a/tests/test_quality_metrics.py b/tests/test_quality_metrics.py new file mode 100644 index 0000000..8ae0bb0 --- /dev/null +++ b/tests/test_quality_metrics.py @@ -0,0 +1,126 @@ +import pytest +import pandas as pd +import txsim as tx + +#TODO: Add tests that check if sparse and none sparse adata give the same results + +@pytest.mark.parametrize("adata_spatial", ["adata_sp", "adata_sp_not_sparse"]) +def test_cell_density(adata_spatial, request): + adata_spatial = request.getfixturevalue(adata_spatial) + density, density_per_celltype = tx.quality_metrics.cell_density(adata_spatial, pipeline_output=False) + assert isinstance(density, float) + assert isinstance(density_per_celltype, pd.Series) + assert density >= 0 + assert density_per_celltype.sum() == density + assert (density_per_celltype >= 0).all() + + +@pytest.mark.parametrize("adata_spatial", ["adata_sp", "adata_sp_not_sparse"]) +def test_proportion_of_assigned_reads(adata_spatial, request): + adata_spatial = request.getfixturevalue(adata_spatial) + reads_assigned, reads_assigned_per_gene, reads_assigned_per_ct = tx.quality_metrics.proportion_of_assigned_reads( + adata_spatial, pipeline_output=False + ) + + assert isinstance(reads_assigned, float) + assert isinstance(reads_assigned_per_gene, pd.Series) + assert isinstance(reads_assigned_per_ct, pd.Series) + # >= 0 for all + assert reads_assigned >= 0 + assert (reads_assigned_per_gene >= 0).all() + assert (reads_assigned_per_ct >= 0).all() + # <= 1 for all + assert reads_assigned <= 1 + assert (reads_assigned_per_gene <= 1).all() + assert (reads_assigned_per_ct <= 1).all() + # all genes and cell types in indices + assert reads_assigned_per_gene.index.isin(adata_spatial.var_names).all() + assert reads_assigned_per_ct.index.isin(adata_spatial.obs["celltype"].unique()).all() + # sum of cell type proportions equals total proportion + assert reads_assigned_per_ct.sum() == pytest.approx(reads_assigned) + + +@pytest.mark.parametrize("adata_spatial, statistic", [ + ("adata_sp", "mean"), + ("adata_sp", "median"), + ("adata_sp_not_sparse", "mean"), + ("adata_sp_not_sparse", "median") +]) +def test_reads_per_cell(adata_spatial, statistic, request): + adata_spatial = request.getfixturevalue(adata_spatial) + reads_per_cell, reads_per_cell_per_gene, reads_per_cell_per_ct = tx.quality_metrics.reads_per_cell( + adata_spatial, statistic=statistic, pipeline_output=False + ) + + assert isinstance(reads_per_cell, float) + assert isinstance(reads_per_cell_per_gene, pd.Series) + assert isinstance(reads_per_cell_per_ct, pd.Series) + # >= 0 for all + assert reads_per_cell >= 0 + assert (reads_per_cell_per_gene >= 0).all() + assert (reads_per_cell_per_ct >= 0).all() + # all genes and cell types in indices + assert reads_per_cell_per_gene.index.isin(adata_spatial.var_names).all() + assert reads_per_cell_per_ct.index.isin(adata_spatial.obs["celltype"].unique()).all() + # per gene <= total + assert (reads_per_cell_per_gene <= reads_per_cell).all() + + +@pytest.mark.parametrize("adata_spatial, statistic", [ + ("adata_sp", "mean"), + ("adata_sp", "median"), + ("adata_sp_not_sparse", "mean"), + ("adata_sp_not_sparse", "median") +]) +def test_genes_per_cell(adata_spatial, statistic, request): + adata_spatial = request.getfixturevalue(adata_spatial) + genes_per_cell, genes_per_cell_per_ct = tx.quality_metrics.genes_per_cell( + adata_spatial, statistic=statistic, pipeline_output=False + ) + + assert isinstance(genes_per_cell, float) + assert isinstance(genes_per_cell_per_ct, pd.Series) + # >= 0 for all + assert genes_per_cell >= 0 + assert (genes_per_cell_per_ct >= 0).all() + # <= adata.n_vars for all + assert genes_per_cell <= adata_spatial.n_vars + assert (genes_per_cell_per_ct <= adata_spatial.n_vars).all() + # all cell types in indices + assert genes_per_cell_per_ct.index.isin(adata_spatial.obs["celltype"].unique()).all() + # min per cell type <= total & max per cell type >= total + assert (genes_per_cell_per_ct.min() <= genes_per_cell) + assert (genes_per_cell_per_ct.max() >= genes_per_cell) + + +@pytest.mark.parametrize("adata_spatial", ["adata_sp", "adata_sp_not_sparse"]) +def test_number_of_genes(adata_spatial, request): + adata_spatial = request.getfixturevalue(adata_spatial) + n_genes, n_genes_per_ct = tx.quality_metrics.number_of_genes(adata_spatial, pipeline_output=False) + + assert isinstance(n_genes, int) + assert isinstance(n_genes_per_ct, pd.Series) + # >= 0 for all + assert n_genes >= 0 + assert (n_genes_per_ct >= 0).all() + # all cell types in indices + assert n_genes_per_ct.index.isin(adata_spatial.obs["celltype"].unique()).all() + # per cell type <= total + assert (n_genes_per_ct <= n_genes).all() + + +@pytest.mark.parametrize("adata_spatial", ["adata_sp", "adata_sp_not_sparse"]) +def test_number_of_cells(adata_spatial, request): + adata_spatial = request.getfixturevalue(adata_spatial) + n_cells, n_cells_per_ct = tx.quality_metrics.number_of_cells(adata_spatial, pipeline_output=False) + + assert isinstance(n_cells, int) + assert isinstance(n_cells_per_ct, pd.Series) + # >= 0 for all + assert n_cells >= 0 + assert (n_cells_per_ct >= 0).all() + # all cell types in indices + assert n_cells_per_ct.index.isin(adata_spatial.obs["celltype"].unique()).all() + # sum of cell type counts equals total count + assert n_cells_per_ct.sum() == n_cells + \ No newline at end of file diff --git a/tests/test_similarity_metrics.py b/tests/test_similarity_metrics.py new file mode 100644 index 0000000..5cbee81 --- /dev/null +++ b/tests/test_similarity_metrics.py @@ -0,0 +1,122 @@ +import pytest +import numpy as np +import pandas as pd +import txsim as tx +from scipy.sparse import issparse, csr_matrix + + +#TODO: Test for negative_marker_purity_reads_FP_based_optimum, or remove it from the package + +def set_gene_to_zero_for_celltype(adata, celltype, gene, layer="raw"): + gene_idx = adata.var_names.tolist().index(gene) + if issparse(adata.layers[layer]): + adata.layers[layer] = adata.layers[layer].toarray() + adata.layers[layer][adata.obs["celltype"]==celltype,gene_idx] *= 0 + adata.layers[layer] = csr_matrix(adata.layers[layer]) + else: + adata.layers[layer][adata.obs["celltype"]==celltype,gene_idx] *= 0 + return adata + + +@pytest.mark.parametrize("adata_spatial, adata_sc, neg_marker", [ + ("adata_sp", "adata_sc_high_sim", True), + ("adata_sp", "adata_sc_high_sim", False), + ("adata_sp_not_sparse", "adata_sc_high_sim_not_sparse", True), + ("adata_sp_not_sparse", "adata_sc_high_sim_not_sparse", False), +]) +def test_negative_marker_purity_cells(adata_spatial, adata_sc, neg_marker, request): + adata_spatial = request.getfixturevalue(adata_spatial) + adata_sc = request.getfixturevalue(adata_sc) + + if neg_marker: + adata_sc = set_gene_to_zero_for_celltype(adata_sc, "B cells", "UBB", layer="raw") + + purity, purity_per_gene, purity_per_celltype = tx.metrics.negative_marker_purity_cells( + adata_spatial, adata_sc, pipeline_output = False + ) + + assert isinstance(purity, (float,np.float32)) + assert isinstance(purity_per_gene, pd.Series) + assert isinstance(purity_per_celltype, pd.Series) + assert purity_per_gene.dtype in [float, np.float32] + assert purity_per_celltype.dtype in [float, np.float32] + # >= 0, <= 1 for all that are not np.nan + assert np.isnan(purity) or ((purity >= 0) and (purity <= 1)) + assert (purity_per_gene.loc[~purity_per_gene.isnull()] >= 0).all() + assert (purity_per_gene.loc[~purity_per_gene.isnull()] <= 1).all() + assert (purity_per_celltype.loc[~purity_per_celltype.isnull()] >= 0).all() + assert (purity_per_celltype.loc[~purity_per_celltype.isnull()] <= 1).all() + if neg_marker: + assert not np.isnan(purity) + assert not purity_per_gene.isnull().all() + assert not purity_per_celltype.isnull().all() + + +@pytest.mark.parametrize("adata_spatial, adata_sc, neg_marker", [ + ("adata_sp", "adata_sc_high_sim", True), + ("adata_sp", "adata_sc_high_sim", False), + ("adata_sp_not_sparse", "adata_sc_high_sim_not_sparse", True), + ("adata_sp_not_sparse", "adata_sc_high_sim_not_sparse", False), +]) +def test_negative_marker_purity_reads(adata_spatial, adata_sc, neg_marker, request): + adata_spatial = request.getfixturevalue(adata_spatial) + adata_sc = request.getfixturevalue(adata_sc) + + if neg_marker: + adata_sc = set_gene_to_zero_for_celltype(adata_sc, "B cells", "UBB", layer="raw") + + purity, purity_per_gene, purity_per_celltype = tx.metrics.negative_marker_purity_reads( + adata_spatial, adata_sc, pipeline_output = False + ) + + assert isinstance(purity, (float,np.float32)) + assert isinstance(purity_per_gene, pd.Series) + assert isinstance(purity_per_celltype, pd.Series) + assert purity_per_gene.dtype in [float, np.float32] + assert purity_per_celltype.dtype in [float, np.float32] + # >= 0, <= 1 for all that are not np.nan + assert np.isnan(purity) or ((purity >= 0) and (purity <= 1)) + assert (purity_per_gene.loc[~purity_per_gene.isnull()] >= 0).all() + assert (purity_per_gene.loc[~purity_per_gene.isnull()] <= 1).all() + assert (purity_per_celltype.loc[~purity_per_celltype.isnull()] >= 0).all() + assert (purity_per_celltype.loc[~purity_per_celltype.isnull()] <= 1).all() + if neg_marker: + assert not np.isnan(purity) + assert not purity_per_gene.isnull().all() + assert not purity_per_celltype.isnull().all() + + +@pytest.mark.parametrize("adata_spatial, adata_sc, correlation_measure, by_ct, thresh", [ + ("adata_sp", "adata_sc_high_sim", "pearson", True, 0), + ("adata_sp", "adata_sc_high_sim", "pearson", False, 0), + ("adata_sp", "adata_sc_high_sim", "spearman", True, 0), + ("adata_sp", "adata_sc_high_sim", "spearman", False, 0), + ("adata_sp_not_sparse", "adata_sc_high_sim_not_sparse", "pearson", True, 0), + ("adata_sp_not_sparse", "adata_sc_high_sim_not_sparse", "spearman", True, 0), + ("adata_sp", "adata_sc_high_sim", "pearson", True, 0.3), + ("adata_sp", "adata_sc_high_sim", "spearman", True, 0.2), +]) +def test_coexpression_similarity(adata_spatial, adata_sc, correlation_measure, by_ct, thresh, request): + adata_spatial = request.getfixturevalue(adata_spatial) + adata_sc = request.getfixturevalue(adata_sc) + + coexp_sim, coexp_sim_per_gene, coexp_sim_per_celltype = tx.metrics.coexpression_similarity( + adata_spatial, + adata_sc, + thresh=thresh, + by_celltype = by_ct, + correlation_measure = correlation_measure, + pipeline_output=False, + ) + + assert isinstance(coexp_sim, (float,np.float32)) + assert isinstance(coexp_sim_per_gene, pd.Series) + assert isinstance(coexp_sim_per_celltype, pd.Series) + assert coexp_sim_per_gene.dtype in [float, np.float32] + assert coexp_sim_per_celltype.dtype in [float, np.float32] + # >= 0, <= 1 for all that are not np.nan + assert np.isnan(coexp_sim) or ((coexp_sim >= 0) and (coexp_sim <= 1)) + assert (coexp_sim_per_gene.loc[~coexp_sim_per_gene.isnull()] >= 0).all() + assert (coexp_sim_per_gene.loc[~coexp_sim_per_gene.isnull()] <= 1).all() + assert (coexp_sim_per_celltype.loc[~coexp_sim_per_celltype.isnull()] >= 0).all() + assert (coexp_sim_per_celltype.loc[~coexp_sim_per_celltype.isnull()] <= 1).all() diff --git a/txsim/metrics/_coexpression_similarity.py b/txsim/metrics/_coexpression_similarity.py index 0d20d53..952f5dc 100644 --- a/txsim/metrics/_coexpression_similarity.py +++ b/txsim/metrics/_coexpression_similarity.py @@ -1,3 +1,4 @@ +import warnings from anndata import AnnData import numpy as np import pandas as pd @@ -6,19 +7,18 @@ from scipy import stats -# TODO Change how normalization happens and consider using log1p def coexpression_similarity( - spatial_data: AnnData, - seq_data: AnnData, + adata_sp: AnnData, + adata_sc: AnnData, min_cells: int = 20, thresh: float = 0, layer: str = 'lognorm', - key: str = 'celltype', + ct_key: str = 'celltype', by_celltype: bool = False, correlation_measure: str = "pearson", pipeline_output: bool = True, -): - """Calculate the mean difference of normalised mutual information matrix values +) -> float | tuple[float, pd.Series, pd.Series]: + """Calculate the mean difference between correlation matrices of spatial and scRNAseq data. Parameters ---------- @@ -28,7 +28,7 @@ def coexpression_similarity( annotated ``AnnData`` object with counts scRNAseq data min_cells : int, optional Minimum number of cells in which a gene should be detected to be considered - expressed. By default 20 + expressed. By default 20. If `by_celltype` is True, the filter is applied for each cell type individually. thresh : float, optional threshold for significant pairs from scRNAseq data. Pairs with correlations below the threshold (by magnitude) will be ignored when calculating mean, by @@ -37,7 +37,7 @@ def coexpression_similarity( name of layer used to calculate coexpression similarity. Should be the same in both AnnData objects default lognorm - key : str + ct_key : str name of the column containing the cell type information by_celltype: bool run analysis by cell type? If False, computation will be performed using the @@ -54,124 +54,219 @@ def coexpression_similarity( Returns ------- float - mean of upper triangular difference matrix - matrices: list - list containing coexpression similarity matrix for each modality and gene names + coexpression summary metric (mean of absolute correlation difference between spatial and scRNAseq data). If + `by_celltype` is True, the summary metric is the mean of the mean coexpression similarity per cell type. + if pipeline_output is False also returns: + pd.Series + coexpression similarity per gene (mean over cell types if by_celltype is True) + pd.Series + coexpression similarity per cell type. (empty if by_celltype is False) """ - # Make a copy of the anndata to prevent overwriting the original pnes - _seq_data = seq_data.copy() - _spatial_data = spatial_data.copy() + SUPPORTED_CORR = ["mutual", "pearson", "spearman"] + assert correlation_measure in SUPPORTED_CORR, f"Invalid correlation measure {correlation_measure}" + if correlation_measure == "mutual": + raise NotImplementedError("Mutual information is not yet supported") - # Create matrix only with intersected genes between sp and sc data - common = _seq_data.var_names.intersection(_spatial_data.var_names) - seq = _seq_data[:, common] - spt = _spatial_data[:, common] - # the number of common genes in the sp and sc data - print(len(common), "genes are shared in both modalities") + # Reduce to shared genes + genes_unfiltered = adata_sc.var_names.intersection(adata_sp.var_names) + adata_sc = adata_sc[:, genes_unfiltered].copy() + adata_sp = adata_sp[:, genes_unfiltered].copy() - # if we compare the coexpression similarity based on whole gene expression matrix - if not by_celltype: - """ - Extract genes based on number of cells or counts and - keep genes that have at least `min_counts` cells to express. - {modality}_expressed saves the remaining genes after filtering - """ - spatial_expressed = spt.var_names[sc.pp.filter_genes(spt, min_cells=min_cells, inplace=False)[0]] - seq_expressed = seq.var_names[sc.pp.filter_genes(seq, min_cells=min_cells, inplace=False)[0]] + # Filter genes based on number of cells that express them + genes_sp = adata_sp.var_names[sc.pp.filter_genes(adata_sp, min_cells=min_cells, inplace=False)[0]] + genes_sc = adata_sc.var_names[sc.pp.filter_genes(adata_sc, min_cells=min_cells, inplace=False)[0]] - print(round(len(spatial_expressed) / len(common) * 100), "% of genes are expressed in the spatial modality", - sep='') - print(round(len(seq_expressed) / len(common) * 100), "% of genes are expressed in the single-cell modality", - sep='') + # Get common genes + genes = genes_sp.intersection(genes_sc) + + if (not len(genes)): + print("No expressed genes are shared in both modalities") + if pipeline_output: + return np.nan + else: + return [np.nan, pd.Series(index=genes_unfiltered, data=np.nan), pd.Series(dtype=float)] - # intersected expressed genes between sp and sc data - common_exp = spatial_expressed.intersection(seq_expressed) + if not by_celltype: + # Get matrices for commonly expressed genes + mat_sp = adata_sp[:, genes].layers[layer] + mat_sc = adata_sc[:, genes].layers[layer] - if (not len(common_exp)): - print("No expressed genes are shared in both modalities") - output = None if pipeline_output else [None, None, None] + # Calculate coexpression similarity matrix + coexp_sim_mat = coexpression_similarity_matrix(mat_sp, mat_sc, thresh, correlation_measure) + + # Calculate summary metric + coexp_sim = np.nanmean(coexp_sim_mat[np.triu_indices(len(coexp_sim_mat), k=1)]) + + if pipeline_output: + return coexp_sim else: - print(len(common_exp), "out of", len(common), 'genes will be used') - - # subset the commonly expressed genes - spt_exp = spt[:, common_exp] - seq_exp = seq[:, common_exp] + return [coexp_sim, pd.Series(index=genes, data=np.nanmean(coexp_sim_mat, axis=1)), pd.Series(dtype=float)] + else: + # Get shared cell types + shared_cts = list(set(adata_sc.obs[ct_key].unique()).intersection(set(adata_sp.obs[ct_key].unique()))) + + # Init coexpression similarity matrices + coexp_sim_matrices = np.zeros((len(shared_cts), len(genes), len(genes)), dtype=float) + coexp_sim_matrices[:,:,:] = np.nan + + for ct_idx, ct in enumerate(shared_cts): + # Adatas for the given cell type + adata_sc_ct = adata_sc[adata_sc.obs[ct_key] == ct, :] + adata_sp_ct = adata_sp[adata_sp.obs[ct_key] == ct, :] + + # Filter genes based on number of cells that express them + genes_sp_ct = adata_sp_ct.var_names[sc.pp.filter_genes(adata_sp_ct, min_cells=min_cells, inplace=False)[0]] + genes_sc_ct = adata_sc_ct.var_names[sc.pp.filter_genes(adata_sc_ct, min_cells=min_cells, inplace=False)[0]] + + # Get common genes + genes_ct = genes_sp_ct.intersection(genes_sc_ct) + genes_mask = np.isin(genes, genes_ct) + + # Skip cell type if no expressed genes are shared + if len(genes_ct) == 0: + print(f"No expressed genes are shared in both modalities for cell type {ct}") + continue + + # Get matrices for commonly expressed genes + mat_sp = adata_sp_ct[:, genes_ct].layers[layer] + mat_sc = adata_sc_ct[:, genes_ct].layers[layer] + + # Calculate coexpression similarity matrix + coexp_sim_mat = coexpression_similarity_matrix(mat_sp, mat_sc, thresh, correlation_measure) + coexp_sim_matrices[ct_idx][np.ix_(genes_mask, genes_mask)] = coexp_sim_mat + + # Calculate per cell type score + coexp_sim_per_ct = pd.Series(index=shared_cts, data=np.nan) + for ct_idx, ct in enumerate(shared_cts): + coexp_sim_mat = coexp_sim_matrices[ct_idx, :, :] + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=RuntimeWarning) + coexp_sim_per_ct.loc[ct] = np.nanmean(coexp_sim_mat[np.triu_indices(len(coexp_sim_mat), k=1)]) + + # Calculate per gene score (RuntimeWarning expected for gene pairs with NaN values in all cell types) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=RuntimeWarning) + coexp_sim_mat_ct_avg = np.nanmean(coexp_sim_matrices, axis=0) + coexp_sim_per_gene = pd.Series(index=genes, data=np.nanmean(coexp_sim_mat_ct_avg, axis=0)) + + # Calculate summary metric + coexp_sim = coexp_sim_per_ct.mean() + + if pipeline_output: + return coexp_sim + else: + return [coexp_sim, coexp_sim_per_gene, coexp_sim_per_ct] - spt_mat = spt_exp.layers[layer] - seq_mat = seq_exp.layers[layer] - print("Calculating co-expression similarity") +def coexpression_similarity_matrix( + mat_sp: np.ndarray | scipy.sparse.csr_matrix, + mat_sc: np.ndarray | scipy.sparse.csr_matrix, + thresh: float, + correlation_measure: str +) -> np.ndarray: + """ Calculate the coexpression similarity matrix between spatial and scRNAseq data. + + The treshold to filter out pairs of low correlations is only applied to the scRNAseq data. The reasoning is that + scRNAseq should have less correlation artifacts and is understood as the "ground truth". + + Parameters + ---------- + mat_sp : np.ndarray | scipy.sparse.csr_matrix + spatial data matrix. Note that the matrix should be in the same order as the scRNAseq data matrix. + mat_sc : np.ndarray | scipy.sparse.csr_matrix + scRNAseq data matrix. Note that the matrix should be in the same order as the spatial data matrix. + thresh : float + threshold for significant pairs from scRNAseq data. Pairs with correlations below the threshold (by magnitude) + will be set to NaN. + correlation_measure : str + Metric for the correlation measure refering to coexpression. Supported are "pearson" and "spearman". + + Returns + ------- + np.ndarray + Coexpression similarity matrix (1 - absolute difference of the correlation matrices/2 of spatial and scRNAseq + data). Diagonal is set to NaN. + """ + + # Calculate correlation matrices + if correlation_measure == 'pearson': + coexp_sp = get_pearson_correlation_matrix(mat_sp) + coexp_sc = get_pearson_correlation_matrix(mat_sc) + elif correlation_measure == 'spearman': + coexp_sp = get_spearman_correlation_matrix(mat_sp) + coexp_sc = get_spearman_correlation_matrix(mat_sc) + + # Gene pair filter based on threshold applied to correlations of scRNAseq data + mask = np.abs(coexp_sc) < thresh + + # Calculate difference between modalities + coexp_diff_matrix = np.abs(coexp_sc - coexp_sp) + coexp_diff_matrix[mask] = np.nan + + # Transform to similarity matrix + coexp_sim_matrix = 1 - coexp_diff_matrix / 2 + + # Set diagonal to NaN + np.fill_diagonal(coexp_sim_matrix, np.nan) + + return coexp_sim_matrix + - # for mutual information, we use transposed matrix - if correlation_measure == 'pearson': - output = compute_pearson_correlation(spt_mat, seq_mat, common_exp, thresh, pipeline_output) - elif correlation_measure == 'spearman': - output = compute_spearman_correlation(spt_mat, seq_mat, common_exp, thresh, pipeline_output) - else: - output = compute_mutual_information(spt_mat.T, seq_mat.T, common_exp, thresh, pipeline_output) +def get_pearson_correlation_matrix(mat: np.ndarray | scipy.sparse.csr_matrix) -> np.ndarray: + """ Calculate the Pearson correlation matrix of the input matrix. + + Parameters + ---------- + mat : np.ndarray | scipy.sparse.csr_matrix + input matrix (e.g. adata.X) + + Returns + ------- + np.ndarray + Pearson correlation matrix of genes + """ + mat_ = mat.toarray().astype(float) if scipy.sparse.issparse(mat) else mat.astype(float) + if mat_.shape[1] > 1: + return np.corrcoef(mat_, rowvar=False) else: - output = {} - # Determine cell type populations across both modalities - ct_spt = list(np.unique(np.array(spt.obs[key]))) - ct_seq = list(np.unique(np.array(seq.obs[key]))) + return np.ones((1,1), dtype=np.float32) - common_types = [x for x in ct_spt if x in ct_seq] - - # spt_ct_counts = spt.obs[key].value_counts() - # seq_ct_counts = seq.obs[key].value_counts() - # print(type(spt_ct_counts[spt_ct_counts > min_cells])) - - for c in common_types: - print("[%s]" % c) - - # Extract expression data layer by the specific cell type - spt_ct = spt[spt.obs[key] == c, :] - seq_ct = seq[seq.obs[key] == c, :] - - """ - Extract genes based on number of cells or counts and - keep genes that have at least `min_counts` cells to express. - {modality}_expressed saves the remaining genes after filtering +def get_spearman_correlation_matrix(mat: np.ndarray | scipy.sparse.csr_matrix) -> np.ndarray: + """ Calculate the Spearman correlation + + Parameters + ---------- + mat : np.ndarray | scipy.sparse.csr_matrix + input matrix (e.g. adata.X) + + Returns + ------- + np.ndarray + Spearman correlation matrix of genes + """ + mat_ = mat.toarray().astype(float) if scipy.sparse.issparse(mat) else mat.astype(float) + + if mat.shape[1] > 2: # spearman automatically returns a matrix for 3 and more genes + return stats.spearmanr(mat_).correlation.astype(np.float32) + elif mat.shape[1] == 2: # still get matrix output for 2 or 1 genes + spearmanr = np.ones((mat.shape[1], mat.shape[1]), dtype=np.float32) + spearmanr[0,1] = stats.spearmanr(mat_[:,0], mat_[:,1]).statistic.astype(np.float32) + spearmanr[1,0] = spearmanr[0,1] + return spearmanr + else: + return np.ones((1,1), dtype=np.float32) - """ - spatial_expressed = spt_ct.var_names[sc.pp.filter_genes(spt_ct, min_cells=min_cells, inplace=False)[0]] - seq_expressed = seq_ct.var_names[sc.pp.filter_genes(seq_ct, min_cells=min_cells, inplace=False)[0]] - - print(round(len(spatial_expressed) / len(common) * 100), "% of genes are expressed in the spatial modality", - sep='') - print(round(len(seq_expressed) / len(common) * 100), "% of genes are expressed in the single-cell modality", - sep='') - - common_exp = spatial_expressed.intersection(seq_expressed) - - if (not len(common_exp)): - print("No expressed genes are shared in both modalities. Returning None value\n") - output[c] = None - else: - print(len(common_exp), "out of", len(common), 'genes will be used') - - spt_exp = spt_ct[:, common_exp] - seq_exp = seq_ct[:, common_exp] - - spt_mat = spt_exp.layers[layer] - seq_mat = seq_exp.layers[layer] - - print("Calculating co-expression similarity") - if correlation_measure == 'pearson': - output[c] = compute_pearson_correlation(spt_mat, seq_mat, common_exp, thresh, pipeline_output) - elif correlation_measure == 'spearman': - output[c] = compute_spearman_correlation(spt_mat, seq_mat, common_exp, thresh, pipeline_output) - else: - output[c] = compute_mutual_information(spt_mat.T, seq_mat.T, common_exp, thresh, pipeline_output) - - return output + +############################################################################################################ def compute_mutual_information(spt_mat, seq_mat, common, thresh, pipeline_output): """ Computing normalised mutual information between all pairs of random variables(?) in the expression data. + NOTE: untested. + MI is often used as a generalized correlation measure. It can be used for measuring co-expression, especially non-linear associations. MI is well defined for discrete or categorical variables. @@ -205,79 +300,3 @@ def compute_mutual_information(spt_mat, seq_mat, common, thresh, pipeline_output else [sim_spt, sim_seq, common] return output - - -def compute_pearson_correlation(spt_mat, seq_mat, common, thresh, pipeline_output): - # Calculate pearson correlation of the gene pairs in the matrix - - print(" - Spatial data...") - if scipy.sparse.issparse(spt_mat): - sim_spt = np.corrcoef(spt_mat.toarray(), rowvar=False) - else: - sim_spt = np.corrcoef(spt_mat.astype(float), rowvar=False) - - print(" - Single-cell data...\n") - if scipy.sparse.issparse(seq_mat): - sim_seq = np.corrcoef(seq_mat.toarray(), rowvar=False) - else: - sim_seq = np.corrcoef(seq_mat.astype(float), rowvar=False) - - output = compute_correlation_difference(sim_spt, sim_seq, common, thresh) if pipeline_output \ - else [sim_spt, sim_seq, common] - - return output - - -def compute_spearman_correlation(spt_mat, seq_mat, common, thresh, pipeline_output): - # Calculate spearman correlation of the gene pairs in the matrix - - print(" - Spatial data...") - if scipy.sparse.issparse(spt_mat): - sim_spt = stats.spearmanr(spt_mat.toarray().astype(float)).statistic - else: - sim_spt = stats.spearmanr(spt_mat.astype(float)).statistic - - print(" - Single-cell data...\n") - if scipy.sparse.issparse(seq_mat): - sim_seq = stats.spearmanr(seq_mat.toarray().astype(float)).statistic - else: - sim_seq = stats.spearmanr(seq_mat.astype(float)).statistic - - output = compute_correlation_difference(sim_spt, sim_seq, common, thresh) if pipeline_output \ - else [sim_spt, sim_seq, common] - - return output - -def compute_correlation_difference(sim_spt, sim_seq, common, thresh): - # Evaluate NaN values for each gene in every modality - - ## Spatial - nan_res = np.sum(np.isnan(sim_spt), axis=0) - # If one gene has NA values among all its pars - if any(nan_res == len(common)): - genes_nan = common[nan_res == len(common)] - genes_nan = genes_nan.tolist() - print("The following genes in the spatial modality resulted in NaN values:") - for i in genes_nan: print(i) - - ## Single cell - nan_res = np.sum(np.isnan(sim_seq), axis=0) - if any(nan_res == len(common)): - genes_nan = common[nan_res == len(common)] - genes_nan = genes_nan.tolist() - print("The following genes in the single-cell modality resulted in NaN values:") - for i in genes_nan: print(i) - """ - Significant pairs from scRNAseq data can be filtered by the threshold. Pairs with correlations - below the threshold (by magnitude, abstract) will be ignored when calculating mean. - """ - - # If threshold, mask values with NaNs - sim_seq[np.abs(sim_seq) < np.abs(thresh)] = np.nan - sim_seq[np.tril_indices(len(common))] = np.nan - - # Calculate difference between modalities - diff = sim_seq - sim_spt - mean = np.nanmean(np.absolute(diff)) / 2 - output = mean - return output diff --git a/txsim/metrics/_negative_marker_purity.py b/txsim/metrics/_negative_marker_purity.py index 3f25af5..d89e0c5 100644 --- a/txsim/metrics/_negative_marker_purity.py +++ b/txsim/metrics/_negative_marker_purity.py @@ -8,7 +8,12 @@ # TODO: Write test # TODO: Investigate the importance of setting max_ratio_cells, minimum_exp, and min_number_cells -def negative_marker_purity_cells(adata_sp: AnnData, adata_sc: AnnData, key: str='celltype', pipeline_output: bool=True): +def negative_marker_purity_cells( + adata_sp: AnnData, + adata_sc: AnnData, + key: str='celltype', + pipeline_output: bool=True +) -> float | tuple[float, pd.Series, pd.Series]: """ Negative marker purity aims to measure read leakeage between cells in spatial datasets. For this, we calculate the increase in positive cells assigned in spatial datasets to pairs of genes-celltyes with @@ -35,7 +40,7 @@ def negative_marker_purity_cells(adata_sp: AnnData, adata_sc: AnnData, key: str= max_ratio_cells=0.005 # maximum ratio of cells expressing a marker to call it a negative marker gene-ct pair # Subset adata_sc to genes of spatial data - adata_sc = adata_sc[:,adata_sp.var_names] #TODO: probably here we create a view! --> Then the sparse adata.X doesn't convert to dense --> CHECK and then also adjust in the other metrics!!! + adata_sc = adata_sc[:,adata_sp.var_names].copy() # TMP fix for sparse matrices, ideally we don't convert, and instead have calculations for sparse/non-sparse for a in [adata_sc, adata_sp]: @@ -54,11 +59,13 @@ def negative_marker_purity_cells(adata_sp: AnnData, adata_sc: AnnData, key: str= # Return nan if too few cell types were found if len(celltypes) < 2: print("Not enough cell types (>1) eligible to calculate negative marker purity") - negative_marker_purity = 'nan' + negative_marker_purity = np.nan if pipeline_output==True: return negative_marker_purity else: - return negative_marker_purity, None, None + purity_per_gene = pd.Series(index=adata_sp.var_names, data=np.nan) + purity_per_celltype = pd.Series(index=celltypes, data=np.nan) + return negative_marker_purity, purity_per_gene, purity_per_celltype # Filter cells to eligible cell types adata_sc = adata_sc[adata_sc.obs[key].isin(celltypes)] @@ -81,11 +88,13 @@ def negative_marker_purity_cells(adata_sp: AnnData, adata_sc: AnnData, key: str= # Return nan if no negative markers were found if np.sum(neg_marker_mask) < 1: print("No negative markers were found in the sc data reference.") - negative_marker_purity = 'nan' + negative_marker_purity = np.nan if pipeline_output==True: return negative_marker_purity else: - return negative_marker_purity, None, None + purity_per_gene = pd.Series(index=adata_sp.var_names, data=np.nan) + purity_per_celltype = pd.Series(index=celltypes, data=np.nan) + return negative_marker_purity, purity_per_gene, purity_per_celltype # Get pos cell ratios in negative marker-cell type pairs lowvals_sc = np.full_like(neg_marker_mask, np.nan, dtype=np.float32) @@ -140,7 +149,7 @@ def negative_marker_purity_reads(adata_sp: AnnData, adata_sc: AnnData, key: str= minimum_exp=0.005 #maximum relative expression allowed in a gene in a cluster to consider the gene-celltype pair the analysis # Subset adata_sc to genes of spatial data - adata_sc = adata_sc[:,adata_sp.var_names] + adata_sc = adata_sc[:,adata_sp.var_names].copy() # TMP fix for sparse matrices, ideally we don't convert, and instead have calculations for sparse/non-sparse for a in [adata_sc, adata_sp]: @@ -159,11 +168,13 @@ def negative_marker_purity_reads(adata_sp: AnnData, adata_sc: AnnData, key: str= # Return nan if too few cell types were found if len(celltypes) < 2: print("Not enough cell types (>1) eligible to calculate negative marker purity") - negative_marker_purity = 'nan' + negative_marker_purity = np.nan if pipeline_output==True: return negative_marker_purity else: - return negative_marker_purity, None, None + purity_per_gene = pd.Series(index=adata_sp.var_names, data=np.nan) + purity_per_celltype = pd.Series(index=celltypes, data=np.nan) + return negative_marker_purity, purity_per_gene, purity_per_celltype # Filter cells to eligible cell types adata_sc = adata_sc[adata_sc.obs[key].isin(celltypes)] @@ -205,11 +216,13 @@ def negative_marker_purity_reads(adata_sp: AnnData, adata_sc: AnnData, key: str= # Return nan if no negative markers were found if np.sum(neg_marker_mask) < 1: print("No negative markers were found in the sc data reference.") - negative_marker_purity = 'nan' + negative_marker_purity = np.nan if pipeline_output==True: return negative_marker_purity else: - return negative_marker_purity, None, None + purity_per_gene = pd.Series(index=adata_sp.var_names, data=np.nan) + purity_per_celltype = pd.Series(index=celltypes, data=np.nan) + return negative_marker_purity, purity_per_gene, purity_per_celltype # Get marker expressions in negative marker-cell type pairs lowvals_sc = np.full_like(neg_marker_mask, np.nan, dtype=np.float32) diff --git a/txsim/quality_metrics/_quality_metrics.py b/txsim/quality_metrics/_quality_metrics.py index 4ca88d6..ec5eb68 100644 --- a/txsim/quality_metrics/_quality_metrics.py +++ b/txsim/quality_metrics/_quality_metrics.py @@ -13,7 +13,7 @@ def cell_density( img_shape: Optional[tuple] = None, ct_key: str = "celltype", pipeline_output: bool = True -) -> float: +) -> float | tuple[float, pd.Series]: """Calculates the area of the region imaged using convex hull and divide total number of cells/area. Parameters @@ -26,14 +26,17 @@ def cell_density( Provide an image shape for area calculation instead of convex hull ct_key: str Key in adata.obs that contains cell type information. Only needed if pipeline_output is False. - pipeline_output : float, optional + pipeline_output : bool Generic argument for txsim metrics. Boolean for whether to return only the summary statistic or additional - metric specific outputs. (Here: no additional outputs) + metric specific outputs. Here it is used to return the density per cell type. Returns ------- density : float Cell density (cells per area unit) + if pipeline_output is False, also returns: + density_per_celltype : pd.Series + Cell density per cell type (cells per area unit) """ if scaling_factor == 1.0: pos = adata_sp.uns['spots'].loc[:,['x','y']].values @@ -55,29 +58,70 @@ def cell_density( return density, density_per_celltype -def proportion_of_assigned_reads(adata_sp: AnnData,pipeline_output=True): +def proportion_of_assigned_reads( + adata_sp: AnnData, + ct_key: str = "celltype", + gene_key: str = "Gene", + pipeline_output=True +) -> float | tuple[float, pd.Series, pd.Series]: """Proportion of assigned reads + Parameters ---------- adata_sp : AnnData annotated ``AnnData`` object with counts from spatial data - pipeline_output : float, optional + ct_key: str + Key in adata.obs that contains cell type information. Only needed if pipeline_output is False. + gene_key: str + Key in adata.uns['spots'] that contains gene symbols. Only needed if pipeline_output is False. + pipeline_output : bool Generic argument for txsim metrics. Boolean for whether to return only the summary statistic or additional - metric specific outputs. (Here: no additional outputs) + metric specific outputs. Here it is used to return the proportion of assigned reads per gene and per cell type. Returns ------- proportion_assigned : float - Proportion of reads assigned to cells / all reads decoded + Proportion of reads assigned to cells relative to all reads decoded. + if pipeline_output is False, also returns: + n_spots_per_gene : pd.Series + Proportion of reads assigned to cells per gene (i.e. relative to all reads decoded per gene) + n_spots_per_celltype : pd.Series + Proportion of reads assigned to each cell type relative to all reads decoded. + """ if issparse(adata_sp.layers['raw']): - proportion_assigned=adata_sp.layers['raw'].sum()/adata_sp.uns['spots'].shape[0] + proportion_assigned = adata_sp.layers['raw'].sum()/adata_sp.uns['spots'].shape[0] else: - proportion_assigned=np.sum(adata_sp.layers['raw'])/adata_sp.uns['spots'].shape[0] - return proportion_assigned + proportion_assigned = np.sum(adata_sp.layers['raw'])/adata_sp.uns['spots'].shape[0] + + if pipeline_output: + return proportion_assigned + + # Proportion of assigned reads per gene + n_spots_per_gene = pd.DataFrame(adata_sp.uns["spots"][gene_key].value_counts()).rename(columns={"count": "total"}) + genes_diff = set(adata_sp.var_names) - set(n_spots_per_gene.index) + assert len(genes_diff) == 0, f"Genes {genes_diff} in adata_sp.var_names are not present in adata_sp.uns['spots']." + n_spots_per_gene["assigned"] = 0 + n_spots_per_gene.loc[adata_sp.var_names, "assigned"] = np.array(adata_sp.layers['raw'].sum(axis=0)).flatten() + proportion_assigned_per_gene = n_spots_per_gene["assigned"] / n_spots_per_gene["total"] + + # Proportion of reads assigned to each cell type + obs_df = pd.DataFrame(data = { + "celltype": adata_sp.obs[ct_key], + "assigned": np.array(adata_sp.layers['raw'].sum(axis=1)).flatten() + }) + n_spots_per_celltype = obs_df.groupby("celltype", observed=True).sum() + proportion_assigned_to_ct = n_spots_per_celltype["assigned"] / adata_sp.uns['spots'].shape[0] + + return proportion_assigned, proportion_assigned_per_gene, proportion_assigned_to_ct -def reads_per_cell(adata_sp: AnnData, statistic: str = "mean", pipeline_output=True): +def reads_per_cell( + adata_sp: AnnData, + statistic: str = "mean", + ct_key: str = "celltype", + pipeline_output=True +) -> float | tuple[float, pd.Series, pd.Series]: """ Get mean/median number of reads per cell Parameters @@ -85,58 +129,190 @@ def reads_per_cell(adata_sp: AnnData, statistic: str = "mean", pipeline_output=T adata_sp : AnnData annotated ``AnnData`` object with counts from spatial data. Integer counts are expected in adata_sp.layers['raw']. - pipeline_output : float, optional + statistic: str + Whether to calculate mean or median reads per cell. Options: "mean" or "median" + ct_key: str + Key in adata.obs that contains cell type information. Only needed if pipeline_output is False. + pipeline_output : bool Generic argument for txsim metrics. Boolean for whether to return only the summary statistic or additional - metric specific outputs. (Here: no additional outputs) + metric specific outputs. Here it is used to return the mean/median number of reads per cell per gene and per + cell type. Returns ------- - median_cells : float - Median_number_of_reads_x_cell + mean_reads : float + Mean or medium number of reads per cell + if pipeline_output is False, also returns: + mean_reads_per_gene : pd.Series + Mean or median number of reads per cell per gene + mean_reads_per_celltype : pd.Series + Mean or median number of reads per cell per cell type """ if issparse(adata_sp.layers['raw']) and statistic == "mean": - return np.mean(adata_sp.layers['raw'].sum(axis=1)) + mean_reads = float(np.mean(adata_sp.layers['raw'].sum(axis=1))) elif issparse(adata_sp.layers['raw']) and statistic == "median": - return np.median(np.asarray(adata_sp.layers['raw'].sum(axis=1)).flatten()) + mean_reads = float(np.median(np.asarray(adata_sp.layers['raw'].sum(axis=1)).flatten())) elif statistic == "mean": - return np.mean(np.sum(adata_sp.layers['raw'],axis=1)) + mean_reads = float(np.mean(np.sum(adata_sp.layers['raw'],axis=1))) elif statistic == "median": - return np.median(np.sum(adata_sp.layers['raw'],axis=1)) + mean_reads = float(np.median(np.sum(adata_sp.layers['raw'],axis=1))) else: raise ValueError("Please choose either 'mean' or 'median' for statistic") + + if pipeline_output: + return mean_reads + + # Mean/median number of reads per cell per gene + if statistic == "mean": + mean_reads_per_gene = pd.Series( + index=adata_sp.var_names, data=np.array(adata_sp.layers['raw'].mean(axis=0)).flatten() + ) + elif issparse(adata_sp.layers['raw']) and statistic == "median": + mean_reads_per_gene = pd.Series( + index=adata_sp.var_names, data=np.median(adata_sp.layers['raw'].toarray(),axis=0) + ) + else: + mean_reads_per_gene = pd.Series( + index=adata_sp.var_names, data=np.median(adata_sp.layers['raw'],axis=0) + ) + + # Mean/median number of reads per cell per cell type + obs_df = pd.DataFrame(data = { + "celltype": adata_sp.obs[ct_key], + "counts": np.array(adata_sp.layers['raw'].sum(axis=1)).flatten() + }) + if statistic == "mean": + mean_reads_per_celltype = obs_df.groupby("celltype", observed=True).mean()["counts"] + else: + mean_reads_per_celltype = obs_df.groupby("celltype", observed=True).median()["counts"] + + return mean_reads, mean_reads_per_gene, mean_reads_per_celltype + + +def genes_per_cell( + adata_sp: AnnData, + statistic: str = "mean", + ct_key: str = "celltype", + pipeline_output=True +) -> float | tuple[float, pd.Series]: + """ Get mean/median number of genes per cell + + Parameters + ---------- + adata_sp : AnnData + annotated ``AnnData`` object with counts from spatial data. Integer counts are expected in + adata_sp.layers['raw']. + statistic: str + Whether to calculate mean or median genes per cell. Options: "mean" or "median" + ct_key: str + Key in adata.obs that contains cell type information. Only needed if pipeline_output is False. + pipeline_output : bool + Generic argument for txsim metrics. Boolean for whether to return only the summary statistic or additional + metric specific outputs. Here it is used to return the mean/median number of genes per cell per cell type. + + Returns + ------- + mean_genes : float + Mean or medium number of genes per cell + if pipeline_output is False, also returns: + mean_genes_per_celltype : pd.Series + Mean or median number of genes per cell per cell type + """ + if issparse(adata_sp.layers['raw']): + n_genes_per_cell = np.array((adata_sp.layers['raw'] > 0).sum(axis=1)).flatten() + else: + n_genes_per_cell = (adata_sp.layers['raw'] > 0).sum(axis=1) + + if statistic == "mean": + mean_genes = float(np.mean(n_genes_per_cell)) + elif statistic == "median": + mean_genes = float(np.median(n_genes_per_cell)) + + if pipeline_output: + return mean_genes + + # Mean/median number of genes per cell per cell type + obs_df = pd.DataFrame(data = { + "celltype": adata_sp.obs[ct_key], + "counts": n_genes_per_cell + }) + if statistic == "mean": + mean_genes_per_celltype = obs_df.groupby("celltype", observed=True).mean()["counts"] + else: + mean_genes_per_celltype = obs_df.groupby("celltype", observed=True).median()["counts"] + + return mean_genes, mean_genes_per_celltype + - -def number_of_genes(adata_sp: AnnData,pipeline_output=True): +def number_of_genes( + adata_sp: AnnData, + ct_key: str = "celltype", + pipeline_output=True +) -> int | tuple[int, pd.Series]: """ Size of the gene panel present in the spatial dataset + Parameters ---------- adata_sp : AnnData annotated ``AnnData`` object with counts from spatial data - pipeline_output : float, optional - Boolean for whether to use the + ct_key: str + Key in adata.obs that contains cell type information. Only needed if pipeline_output is False. + pipeline_output : bool + Generic argument for txsim metrics. Boolean for whether to return only the summary statistic or additional + metric specific outputs. Here it is used to return the number of genes per cell type (genes with at least one + count in the given cell type). + Returns ------- number_of genes : float Number of genes present in the spatial dataset - """ - number_of_genes=adata_sp.shape[1] - return number_of_genes + if pipeline_output is False, also returns: + number_of_genes_per_celltype : pd.Series + Number of genes per cell type (genes with at least one count in the given cell type) + """ + number_of_genes=adata_sp.n_vars + if pipeline_output: + return number_of_genes + + # Number of genes per cell type + gene_in_ct = pd.DataFrame(index=adata_sp.obs[ct_key].unique(), columns=adata_sp.var_names) + for ct in adata_sp.obs[ct_key].unique(): + gene_in_ct.loc[ct] = adata_sp[adata_sp.obs[ct_key]==ct].layers['raw'].sum(axis=0) > 0 + + number_of_genes_per_celltype = gene_in_ct.sum(axis=1) + + return number_of_genes, number_of_genes_per_celltype + -def number_of_cells(adata_sp: AnnData,pipeline_output=True): +def number_of_cells(adata_sp: AnnData,ct_key: str = "celltype", pipeline_output = True) -> int | tuple[int, pd.Series]: """ Number of cells present in the spatial dataset + Parameters ---------- adata_sp : AnnData annotated ``AnnData`` object with counts from spatial data - pipeline_output : float, optional - Boolean for whether to use the + ct_key: str + Key in adata.obs that contains cell type information. Only needed if pipeline_output is False. + pipeline_output : bool + Generic argument for txsim metrics. Boolean for whether to return only the summary statistic or additional + metric specific outputs. Here it is used to return the number of cells per cell type. + Returns ------- number_of cells : float Number of cells present in the spatial dataset + if pipeline_output is False, also returns: + number_of_cells_per_celltype : pd.Series + Number of cells per cell type """ - number_of_cells=adata_sp.shape[0] - return number_of_cells + number_of_cells=adata_sp.n_obs + if pipeline_output: + return number_of_cells + + # Number of cells per cell type + number_of_cells_per_celltype = adata_sp.obs[ct_key].value_counts() + + return number_of_cells, number_of_cells_per_celltype def percentile_5th_reads_cells(adata_sp: AnnData,pipeline_output=True): """5th percentile of number of reads/cells in the spatial experiment From 3cf274a648a449ddedd54e55bd7d87b498eee1ca Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Mon, 10 Jun 2024 11:09:47 +0200 Subject: [PATCH 14/24] Tests and little adjustment for knn mixing --- tests/test_similarity_metrics.py | 23 +++++++++++++++++++++++ txsim/metrics/_coembedding.py | 14 +++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/tests/test_similarity_metrics.py b/tests/test_similarity_metrics.py index 5cbee81..972e244 100644 --- a/tests/test_similarity_metrics.py +++ b/tests/test_similarity_metrics.py @@ -120,3 +120,26 @@ def test_coexpression_similarity(adata_spatial, adata_sc, correlation_measure, b assert (coexp_sim_per_gene.loc[~coexp_sim_per_gene.isnull()] <= 1).all() assert (coexp_sim_per_celltype.loc[~coexp_sim_per_celltype.isnull()] >= 0).all() assert (coexp_sim_per_celltype.loc[~coexp_sim_per_celltype.isnull()] <= 1).all() + + +@pytest.mark.parametrize("adata_spatial, adata_sc, k, ct_filter_factor", [ + ("adata_sp", "adata_sc_high_sim", 5, 1), + ("adata_sp_not_sparse", "adata_sc_high_sim_not_sparse", 5, 1), + ("adata_sp", "adata_sc_high_sim", 45, 5), +]) +def test_knn_mixing_score(adata_spatial, adata_sc, k, ct_filter_factor, request): + adata_spatial = request.getfixturevalue(adata_spatial) + adata_sc = request.getfixturevalue(adata_sc) + + knn_mixing_score, knn_mixing_score_per_celltype = tx.metrics.knn_mixing( + adata_spatial, adata_sc, k=k, ct_filter_factor=ct_filter_factor, pipeline_output=False + ) + + assert isinstance(knn_mixing_score, (float,np.float32)) + assert isinstance(knn_mixing_score_per_celltype, pd.Series) + assert knn_mixing_score_per_celltype.dtype in [float, np.float32] + # >= 0, <= 1 for all that are not np.nan + assert np.isnan(knn_mixing_score) or ((knn_mixing_score >= 0) and (knn_mixing_score <= 1)) + assert (knn_mixing_score_per_celltype.loc[~knn_mixing_score_per_celltype.isnull()] >= 0).all() + assert (knn_mixing_score_per_celltype.loc[~knn_mixing_score_per_celltype.isnull()] <= 1).all() + \ No newline at end of file diff --git a/txsim/metrics/_coembedding.py b/txsim/metrics/_coembedding.py index 88036ff..39f7373 100644 --- a/txsim/metrics/_coembedding.py +++ b/txsim/metrics/_coembedding.py @@ -1,7 +1,9 @@ import numpy as np +import pandas as pd import networkx as nx import anndata as ad import scanpy as sc +import scipy from typing import Union, Tuple def knn_mixing( @@ -11,7 +13,7 @@ def knn_mixing( obs_key: str = "celltype", k: int = 45, ct_filter_factor: float = 5, -) -> Union[float, Tuple[str, dict]]: +) -> Union[float, Tuple[str, pd.Series]]: """Compute score for knn mixing of modalities Procedure: Concatenate sc and st data. Compute PCA on dataset union. Compute assortativity of each knn graph on @@ -59,8 +61,6 @@ def knn_mixing( st_cts = set(adata_sp.obs[obs_key].cat.categories) all_cts = list(sc_cts.union(st_cts)) shared_cts = list(sc_cts.intersection(st_cts)) - #st_only_cts = list(st_cts - sc_cts) - #sc_only_cts = list(sc_cts - st_cts) # Get adata per shared cell type scores = {ct:np.nan for ct in all_cts} @@ -73,12 +73,16 @@ def knn_mixing( nx.set_node_attributes(G, {i:a.obs["modality"].values[i] for i in range(G.number_of_nodes())}, "modality") scores[ct] = np.clip(-nx.attribute_assortativity_coefficient(G, "modality") + 1, 0, 1) - mean_score = np.mean([v for _,v in scores.items() if v is not np.nan]) + score_per_ct = pd.Series(scores, dtype=float) + if score_per_ct.isnull().all(): + mean_score = np.nan + else: + mean_score = np.nanmean(list(scores.values())) if pipeline_output: return mean_score else: - return mean_score, scores + return mean_score, score_per_ct #TODO: fix NumbaDeprecationWarning From a09b1ce97736ff50b4046cfc5f70a3977782fee7 Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Mon, 10 Jun 2024 12:02:54 +0200 Subject: [PATCH 15/24] Add tests for relative expr metrics --- tests/test_similarity_metrics.py | 50 +++++++++++++++++++ .../_relative_pairwise_celltype_expression.py | 14 ++++-- .../_relative_pairwise_gene_expression.py | 14 ++++-- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/tests/test_similarity_metrics.py b/tests/test_similarity_metrics.py index 972e244..3a38a39 100644 --- a/tests/test_similarity_metrics.py +++ b/tests/test_similarity_metrics.py @@ -142,4 +142,54 @@ def test_knn_mixing_score(adata_spatial, adata_sc, k, ct_filter_factor, request) assert np.isnan(knn_mixing_score) or ((knn_mixing_score >= 0) and (knn_mixing_score <= 1)) assert (knn_mixing_score_per_celltype.loc[~knn_mixing_score_per_celltype.isnull()] >= 0).all() assert (knn_mixing_score_per_celltype.loc[~knn_mixing_score_per_celltype.isnull()] <= 1).all() + + +@pytest.mark.parametrize("adata_spatial, adata_sc", [ + ("adata_sp", "adata_sc_high_sim"), + ("adata_sp_not_sparse", "adata_sc_high_sim_not_sparse"), +]) +def test_relative_pairwise_celltype_expression(adata_spatial, adata_sc, request): + adata_spatial = request.getfixturevalue(adata_spatial) + adata_sc = request.getfixturevalue(adata_sc) + + rel_ct_expr, rel_ct_expr_per_gene, rel_ct_expr_per_celltype = tx.metrics.relative_pairwise_celltype_expression( + adata_spatial, adata_sc, pipeline_output=False + ) + + assert isinstance(rel_ct_expr, (float,np.float32)) + assert isinstance(rel_ct_expr_per_gene, pd.Series) + assert isinstance(rel_ct_expr_per_celltype, pd.Series) + assert rel_ct_expr_per_gene.dtype in [float, np.float32] + assert rel_ct_expr_per_celltype.dtype in [float, np.float32] + # >= 0, <= 1 for all that are not np.nan #NOTE: theoretically could be negative, but not with the given dataset + assert np.isnan(rel_ct_expr) or ((rel_ct_expr >= 0) and (rel_ct_expr <= 1)) + assert (rel_ct_expr_per_gene.loc[~rel_ct_expr_per_gene.isnull()] >= 0).all() + assert (rel_ct_expr_per_gene.loc[~rel_ct_expr_per_gene.isnull()] <= 1).all() + assert (rel_ct_expr_per_celltype.loc[~rel_ct_expr_per_celltype.isnull()] >= 0).all() + assert (rel_ct_expr_per_celltype.loc[~rel_ct_expr_per_celltype.isnull()] <= 1).all() + + +@pytest.mark.parametrize("adata_spatial, adata_sc", [ + ("adata_sp", "adata_sc_high_sim"), + ("adata_sp_not_sparse", "adata_sc_high_sim_not_sparse"), +]) +def test_relative_pairwise_gene_expression(adata_spatial, adata_sc, request): + adata_spatial = request.getfixturevalue(adata_spatial) + adata_sc = request.getfixturevalue(adata_sc) + + rel_gene_expr, rel_gene_expr_per_gene, rel_gene_expr_per_celltype = tx.metrics.relative_pairwise_gene_expression( + adata_spatial, adata_sc, pipeline_output=False + ) + + assert isinstance(rel_gene_expr, (float,np.float32)) + assert isinstance(rel_gene_expr_per_gene, pd.Series) + assert isinstance(rel_gene_expr_per_celltype, pd.Series) + assert rel_gene_expr_per_gene.dtype in [float, np.float32] + assert rel_gene_expr_per_celltype.dtype in [float, np.float32] + # >= 0, <= 1 for all that are not np.nan + assert np.isnan(rel_gene_expr) or ((rel_gene_expr >= 0) and (rel_gene_expr <= 1)) + assert (rel_gene_expr_per_gene.loc[~rel_gene_expr_per_gene.isnull()] >= 0).all() + assert (rel_gene_expr_per_gene.loc[~rel_gene_expr_per_gene.isnull()] <= 1).all() + assert (rel_gene_expr_per_celltype.loc[~rel_gene_expr_per_celltype.isnull()] >= 0).all() + assert (rel_gene_expr_per_celltype.loc[~rel_gene_expr_per_celltype.isnull()] <= 1).all() \ No newline at end of file diff --git a/txsim/metrics/_relative_pairwise_celltype_expression.py b/txsim/metrics/_relative_pairwise_celltype_expression.py index e09dab2..b0e8817 100644 --- a/txsim/metrics/_relative_pairwise_celltype_expression.py +++ b/txsim/metrics/_relative_pairwise_celltype_expression.py @@ -4,7 +4,13 @@ from anndata import AnnData from scipy.sparse import issparse -def relative_pairwise_celltype_expression(adata_sp: AnnData, adata_sc: AnnData, key:str='celltype', layer:str='lognorm', pipeline_output: bool=True): +def relative_pairwise_celltype_expression( + adata_sp: AnnData, + adata_sc: AnnData, + key:str='celltype', + layer:str='lognorm', + pipeline_output: bool=True +) -> float | tuple[float, pd.Series, pd.Series]: """Calculate the efficiency deviation present between the genes in the panel. ---------- adata_sp : AnnData @@ -111,14 +117,16 @@ def relative_pairwise_celltype_expression(adata_sp: AnnData, adata_sc: AnnData, per_gene_score = np.sum(np.absolute(norm_pairwise_distances_sp - norm_pairwise_distances_sc), axis=(0,1)) per_gene_metric = 1 - (per_gene_score/(2 * np.sum(np.absolute(norm_pairwise_distances_sc), axis=(0,1)))) - per_gene_metric = pd.DataFrame(per_gene_metric, index=mean_celltype_sc.columns, columns=['score']) #add back the gene labels + #per_gene_metric = pd.DataFrame(per_gene_metric, index=mean_celltype_sc.columns, columns=['score']) #add back the gene labels + per_gene_metric = pd.Series(per_gene_metric, index=mean_celltype_sc.columns) #per_gene_metric = pd.DataFrame(per_gene_metric, index=mean_celltype_sc.T.columns, columns=['score']) #add back the gene labels per_celltype_score = np.sum(np.absolute(norm_pairwise_distances_sp - norm_pairwise_distances_sc), axis=(1,2)) per_celltype_metric = 1 - (per_celltype_score/(2 * np.sum(np.absolute(norm_pairwise_distances_sc), axis=(1,2)))) - per_celltype_metric = pd.DataFrame(per_celltype_metric, index=mean_celltype_sc.index, columns=['score']) #add back the celltype labels + #per_celltype_metric = pd.DataFrame(per_celltype_metric, index=mean_celltype_sc.index, columns=['score']) #add back the celltype labels + per_celltype_metric = pd.Series(per_celltype_metric, index=mean_celltype_sc.index) if pipeline_output: return overall_metric diff --git a/txsim/metrics/_relative_pairwise_gene_expression.py b/txsim/metrics/_relative_pairwise_gene_expression.py index 371e718..01a810e 100644 --- a/txsim/metrics/_relative_pairwise_gene_expression.py +++ b/txsim/metrics/_relative_pairwise_gene_expression.py @@ -4,7 +4,13 @@ from anndata import AnnData from scipy.sparse import issparse -def relative_pairwise_gene_expression(adata_sp: AnnData, adata_sc: AnnData, key:str='celltype', layer:str='lognorm', pipeline_output: bool=True): +def relative_pairwise_gene_expression( + adata_sp: AnnData, + adata_sc: AnnData, + key:str='celltype', + layer:str='lognorm', + pipeline_output: bool=True +) -> float | tuple[float, pd.Series, pd.Series]: """Calculate the similarity of pairwise gene expression differences for all pairs of genes in the panel, between the two modalities ---------- adata_sp : AnnData @@ -119,11 +125,13 @@ def relative_pairwise_gene_expression(adata_sp: AnnData, adata_sc: AnnData, key: # We can further compute the metric on a per-gene and per-celltype basis per_gene_score = np.sum(np.absolute(norm_pairwise_distances_sp - norm_pairwise_distances_sc), axis=(1,2)) per_gene_metric = 1 - (per_gene_score/(2 * np.sum(np.absolute(norm_pairwise_distances_sc), axis=(1,2)))) - per_gene_metric = pd.DataFrame(per_gene_metric, index=mean_celltype_sc.columns, columns=['score']) #add back the gene labels + #per_gene_metric = pd.DataFrame(per_gene_metric, index=mean_celltype_sc.columns, columns=['score']) #add back the gene labels + per_gene_metric = pd.Series(per_gene_metric, index=mean_celltype_sc.columns) per_celltype_score = np.sum(np.absolute(norm_pairwise_distances_sp - norm_pairwise_distances_sc), axis=(0,1)) per_celltype_metric = 1 - (per_celltype_score/(2 * np.sum(np.absolute(norm_pairwise_distances_sc), axis=(0,1)))) - per_celltype_metric = pd.DataFrame(per_celltype_metric, index=mean_celltype_sc.index, columns=['score']) #add back the celltype labels + #per_celltype_metric = pd.DataFrame(per_celltype_metric, index=mean_celltype_sc.index, columns=['score']) #add back the celltype labels + per_celltype_metric = pd.Series(per_celltype_metric, index=mean_celltype_sc.index) if pipeline_output: return overall_metric From 2ba35025ceb90c4134af483ac22d11309fb1122a Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Mon, 10 Jun 2024 13:02:53 +0200 Subject: [PATCH 16/24] Add metrics to combined calc --- txsim/metrics/_combined.py | 8 +++++--- txsim/quality_metrics/_combined.py | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/txsim/metrics/_combined.py b/txsim/metrics/_combined.py index a5cd3eb..b78a595 100644 --- a/txsim/metrics/_combined.py +++ b/txsim/metrics/_combined.py @@ -35,10 +35,12 @@ def all_metrics( ##metrics['relative_sim_across_gene_overall_metric'] = relative_pairwise_gene_expression(adata_sp, adata_sc, 'celltype', 'lognorm') ###metrics['mean_sim_across_clust'] = mean_similarity_gene_expression_across_clusters(adata_sp,adata_sc) ###metrics['prc95_sim_across_clust'] = percentile95_similarity_gene_expression_across_clusters(adata_sp,adata_sc) + metrics['rel_pairwise_ct_expr_sim'] = relative_pairwise_celltype_expression(adata_sp.copy(), adata_sc.copy()) + metrics['rel_pairwise_gene_expr_sim'] = relative_pairwise_gene_expression(adata_sp.copy(), adata_sc.copy()) # ### Coexpression similarity - #metrics['coexpression_similarity'] = coexpression_similarity(adata_sp, adata_sc) - #metrics['coexpression_similarity_celltype'] = coexpression_similarity(adata_sp, adata_sc, by_celltype = True) + metrics['coexpr_similarity'] = coexpression_similarity(adata_sp.copy(), adata_sc.copy()) + metrics['coexpr_similarity_celltype'] = coexpression_similarity(adata_sp.copy(), adata_sc.copy(), by_celltype = True) ##metrics['gene_set_coexpression'] = gene_set_coexpression(adata_sp, adata_sc) # Negative marker purity @@ -46,7 +48,7 @@ def all_metrics( metrics['neg_marker_purity_reads'] = negative_marker_purity_reads(adata_sp.copy(),adata_sc.copy()) #### KNN mixing - #metrics['knn_mixing'] = knn_mixing(adata_sp.copy(),adata_sc.copy()) + metrics['knn_mixing'] = knn_mixing(adata_sp.copy(),adata_sc.copy()) # Cell statistics #metrics['ratio_median_readsxcell'] = ratio_median_readsXcells(adata_sp,adata_sc) diff --git a/txsim/quality_metrics/_combined.py b/txsim/quality_metrics/_combined.py index 24b03b7..7eba74b 100644 --- a/txsim/quality_metrics/_combined.py +++ b/txsim/quality_metrics/_combined.py @@ -17,16 +17,18 @@ def all_quality_metrics( metrics['cellular_density']=cell_density(adata_sp) metrics['prop_reads_assigned']=proportion_of_assigned_reads(adata_sp) - metrics['median_readsxcell']=median_reads_cells(adata_sp) - metrics['mean_readsxcell']=mean_reads_cells(adata_sp) + metrics['mean_reads_per_cell']=reads_per_cell(adata_sp, statistic='mean') + metrics['median_reads_per_cell']=reads_per_cell(adata_sp, statistic='median') + metrics['mean_genes_per_cell']=genes_per_cell(adata_sp, statistic='mean') + metrics['median_genes_per_cell']=genes_per_cell(adata_sp, statistic='median') metrics['number_of_genes']=number_of_genes(adata_sp) metrics['number_of_cells']=number_of_cells(adata_sp) - metrics['pct5_readsxcell']=percentile_5th_reads_cells(adata_sp) - metrics['mean_genesxcell']=mean_genes_cells(adata_sp) - metrics['pct95_genesxcell']=percentile_95th_genes_cells(adata_sp) - metrics['pct5_genesxcell']=percentile_5th_genes_cells(adata_sp) - metrics['median_genexcell']=median_genes_cells(adata_sp) - metrics['pct95_readsxcell']=percentile_95th_reads_cells(adata_sp) + #metrics['pct5_readsxcell']=percentile_5th_reads_cells(adata_sp) + #metrics['mean_genesxcell']=mean_genes_cells(adata_sp) + #metrics['pct95_genesxcell']=percentile_95th_genes_cells(adata_sp) + #metrics['pct5_genesxcell']=percentile_5th_genes_cells(adata_sp) + #metrics['median_genexcell']=median_genes_cells(adata_sp) + #metrics['pct95_readsxcell']=percentile_95th_reads_cells(adata_sp) From e0e79c2af609c089974fa7bc1d00f0aac082cb66 Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Mon, 9 Sep 2024 15:54:17 +0200 Subject: [PATCH 17/24] cell id col as input argument for generate_adata --- txsim/preprocessing/_countgeneration.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/txsim/preprocessing/_countgeneration.py b/txsim/preprocessing/_countgeneration.py index 04913bc..ea30dbd 100644 --- a/txsim/preprocessing/_countgeneration.py +++ b/txsim/preprocessing/_countgeneration.py @@ -8,13 +8,15 @@ from scipy.sparse import csr_matrix -def generate_adata(input_spots: DataFrame) -> AnnData: +def generate_adata(input_spots: DataFrame, cell_id_col: str = "cell") -> AnnData: """Generate an AnnData object with counts from molecule data Parameters ---------- input_spots : DataFrame DataFrame containing genes and cell assignments + cell_id_col : str, optional + Column name of the cell id column in the input_spots DataFrame. Returns ------- @@ -24,23 +26,23 @@ def generate_adata(input_spots: DataFrame) -> AnnData: #Read assignments, calculate percentage of non-assigned spots (pct_noise) and save raw version of spots spots = input_spots.copy() - pct_noise = sum(spots['cell'] <= 0)/len(spots['cell']) + pct_noise = sum(spots[cell_id_col] <= 0)/len(spots[cell_id_col]) spots_raw = spots.copy() # save raw spots to add to adata.uns and set 0 to None - spots_raw.loc[spots_raw['cell']==0,'cell'] = None - spots = spots[spots['cell'] > 0] #What is happening here + spots_raw.loc[spots_raw[cell_id_col]==0,cell_id_col] = None + spots = spots[spots[cell_id_col] > 0] #What is happening here #Generate blank, labelled count matrix - X = np.zeros([ len(pd.unique(spots['cell'])), len(pd.unique(spots['Gene'])) ]) + X = np.zeros([ len(pd.unique(spots[cell_id_col])), len(pd.unique(spots['Gene'])) ]) adata = ad.AnnData(X, dtype = X.dtype) - adata.obs['cell_id'] = pd.unique(spots['cell']) + adata.obs['cell_id'] = pd.unique(spots[cell_id_col]) adata.obs_names = [f"{i:d}" for i in adata.obs['cell_id']] adata.var_names = pd.unique(spots['Gene']) adata.obs['centroid_x'] = 0 adata.obs['centroid_y'] = 0 #Sort spots table by cell id and get table intervals for each cell - spots = spots.sort_values("cell",ascending=True) - cells_sorted = spots["cell"].values + spots = spots.sort_values(cell_id_col,ascending=True) + cells_sorted = spots[cell_id_col].values start_indices = np.flatnonzero(np.concatenate(([True], cells_sorted[1:] != cells_sorted[:-1]))) cell_to_start_idx = pd.Series(start_indices, index=cells_sorted[start_indices]) cell_to_end_idx = pd.Series(cell_to_start_idx.iloc[1:].tolist()+[len(spots)], index=cell_to_start_idx.index) From 798056c38bd93572293819154f3cb2e00690c8cb Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Mon, 9 Sep 2024 16:44:01 +0200 Subject: [PATCH 18/24] Move gene key to arguments in count generation --- txsim/preprocessing/_countgeneration.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/txsim/preprocessing/_countgeneration.py b/txsim/preprocessing/_countgeneration.py index ea30dbd..1056d60 100644 --- a/txsim/preprocessing/_countgeneration.py +++ b/txsim/preprocessing/_countgeneration.py @@ -8,7 +8,7 @@ from scipy.sparse import csr_matrix -def generate_adata(input_spots: DataFrame, cell_id_col: str = "cell") -> AnnData: +def generate_adata(input_spots: DataFrame, cell_id_col: str = "cell", gene_col: str = "Gene") -> AnnData: """Generate an AnnData object with counts from molecule data Parameters @@ -32,11 +32,11 @@ def generate_adata(input_spots: DataFrame, cell_id_col: str = "cell") -> AnnData spots = spots[spots[cell_id_col] > 0] #What is happening here #Generate blank, labelled count matrix - X = np.zeros([ len(pd.unique(spots[cell_id_col])), len(pd.unique(spots['Gene'])) ]) + X = np.zeros([ len(pd.unique(spots[cell_id_col])), len(pd.unique(spots[gene_col])) ]) adata = ad.AnnData(X, dtype = X.dtype) adata.obs['cell_id'] = pd.unique(spots[cell_id_col]) adata.obs_names = [f"{i:d}" for i in adata.obs['cell_id']] - adata.var_names = pd.unique(spots['Gene']) + adata.var_names = pd.unique(spots[gene_col]) adata.obs['centroid_x'] = 0 adata.obs['centroid_y'] = 0 @@ -52,7 +52,7 @@ def generate_adata(input_spots: DataFrame, cell_id_col: str = "cell") -> AnnData start_idx = cell_to_start_idx.loc[cell_id] end_idx = cell_to_end_idx.loc[cell_id] spots_of_cell = spots.iloc[start_idx:end_idx] - cts = spots_of_cell['Gene'].value_counts() + cts = spots_of_cell[gene_col].value_counts() adata[str(cell_id), :] = cts.reindex(adata.var_names, fill_value = 0) adata.obs.loc[str(cell_id),'centroid_x'] = spots_of_cell['x'].mean() adata.obs.loc[str(cell_id),'centroid_y'] = spots_of_cell['y'].mean() From 502414d6486e46e9f3316740fe49423e54988f2e Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Tue, 10 Sep 2024 08:04:10 +0200 Subject: [PATCH 19/24] Set column names as arguments for area calc and ssam ct annotation --- txsim/preprocessing/_countgeneration.py | 9 ++++++--- txsim/preprocessing/_ctannotation.py | 24 +++++++++++++++--------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/txsim/preprocessing/_countgeneration.py b/txsim/preprocessing/_countgeneration.py index 1056d60..84688a0 100644 --- a/txsim/preprocessing/_countgeneration.py +++ b/txsim/preprocessing/_countgeneration.py @@ -76,7 +76,8 @@ def generate_adata(input_spots: DataFrame, cell_id_col: str = "cell", gene_col: def calculate_alpha_area( adata: AnnData, - alpha: float = 0 + alpha: float = 0, + cell_id_col: str = "cell" ) -> ndarray: """Calculate and store the alpha shape area of the cell given a set of points (genes). Uses the Alpha Shape Toolboox: https://alphashape.readthedocs.io/en/latest/readme.html @@ -88,6 +89,8 @@ def calculate_alpha_area( alpha : float, optional The alpha parameter a, used to calculate the alpha shape, by default 0. If -1, optimal alpha parameter will be calculated. + cell_id_col : str, optional + Column name of the cell id column in adata.uns['spots'] Returns ------- @@ -101,8 +104,8 @@ def calculate_alpha_area( import json #Read assignments, sort spots table by cell id and get table intervals for each cell - spots = adata.uns['spots'].sort_values("cell",ascending=True) - cells_sorted = spots["cell"].values + spots = adata.uns['spots'].sort_values(cell_id_col,ascending=True) + cells_sorted = spots[cell_id_col].values start_indices = np.flatnonzero(np.concatenate(([True], cells_sorted[1:] != cells_sorted[:-1]))) cell_to_start_idx = pd.Series(start_indices, index=cells_sorted[start_indices]) cell_to_end_idx = pd.Series(cell_to_start_idx.iloc[1:].tolist()+[len(spots)], index=cell_to_start_idx.index) diff --git a/txsim/preprocessing/_ctannotation.py b/txsim/preprocessing/_ctannotation.py index 94a8a16..30c355e 100644 --- a/txsim/preprocessing/_ctannotation.py +++ b/txsim/preprocessing/_ctannotation.py @@ -54,6 +54,9 @@ def run_ssam( spots: pd.DataFrame, adata_sc: pd.DataFrame, um_p_px: float = 0.325, + cell_id_col: str = 'cell', + gene_col: str = 'Gene', + sc_ct_key: str = 'celltype', ) -> AnnData: """Add cell type annotation by ssam. @@ -67,6 +70,12 @@ def run_ssam( Path to the sc transcriptomics AnnData um_p_px : float Conversion factor micrometer per pixel. Adjust to data set + cell_id_col : str + Name of the cell id column in the spots DataFrame + gene_col : str + Name of the gene column in the spots DataFrame + sc_ct_key : str + Name of the cell type column in the sc AnnData Returns ------- @@ -77,10 +86,7 @@ def run_ssam( import plankton.plankton as pl from plankton.utils import ssam - x = spots.x.values - y = spots.y.values - g = spots.Gene.values - sdata = pl.SpatialData( spots.Gene, + sdata = pl.SpatialData( spots[gene_col], spots.x*um_p_px, spots.y*um_p_px ) adata_sc=adata_sc[:,adata_st.var_names] @@ -88,7 +94,7 @@ def run_ssam( adata_sc = adata_sc.copy() adata_sc.X = adata_sc.X.toarray() exp=pd.DataFrame(adata_sc.X,columns=adata_sc.var_names) - exp['celltype']=list(adata_sc.obs['celltype']) + exp['celltype']=list(adata_sc.obs[sc_ct_key]) signatures=exp.groupby('celltype').mean().transpose() # 'cheat-create' an anndata set: adata = AnnData(signatures.T) @@ -96,7 +102,7 @@ def run_ssam( adata.obs['celltype'] = adata.obs.index # pl.ScanpyDataFrame(sdata,adata) sdata = pl.SpatialData( - spots.Gene, + spots[gene_col], spots.x*um_p_px, spots.y*um_p_px, # pixel_maps={'DAPI':bg_map}, @@ -120,11 +126,11 @@ def run_ssam( spots['celltype'] = sdata['celltype'] for cell_id in adata_st.obs['cell_id']: - cts = spots[spots['cell'] == cell_id ]['Gene'].value_counts() - mode = spots[spots['cell'] == cell_id ]['celltype'].mode() + cts = spots[spots[cell_id_col] == cell_id ][gene_col].value_counts() + mode = spots[spots[cell_id_col] == cell_id ]['celltype'].mode() adata_st.obs.loc[adata_st.obs['cell_id'] == cell_id, 'ct_ssam'] = mode.values[0] adata_st.obs.loc[adata_st.obs['cell_id'] == cell_id, 'ct_ssam_cert'] = \ - (spots[spots['cell'] == cell_id ]['celltype'].value_counts()[mode].values[0] / sum(cts)) + (spots[spots[cell_id_col] == cell_id ]['celltype'].value_counts()[mode].values[0] / sum(cts)) return adata_st From ee3e8bd76a81bef330a5c76a9665cb11e4e1bd6d Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:14:05 +0200 Subject: [PATCH 20/24] Option to set not assigned value for ssam ct annotation and layers handling in normalize by area --- txsim/preprocessing/_ctannotation.py | 9 +++++++++ txsim/preprocessing/normalization_sc.py | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/txsim/preprocessing/_ctannotation.py b/txsim/preprocessing/_ctannotation.py index 30c355e..080a949 100644 --- a/txsim/preprocessing/_ctannotation.py +++ b/txsim/preprocessing/_ctannotation.py @@ -57,6 +57,7 @@ def run_ssam( cell_id_col: str = 'cell', gene_col: str = 'Gene', sc_ct_key: str = 'celltype', + no_ct_assigned_value: str | None = 'None_sp', ) -> AnnData: """Add cell type annotation by ssam. @@ -76,6 +77,8 @@ def run_ssam( Name of the gene column in the spots DataFrame sc_ct_key : str Name of the cell type column in the sc AnnData + no_ct_assigned_value : str + Value to assign to cells that are not assigned to any cell type Returns ------- @@ -86,6 +89,8 @@ def run_ssam( import plankton.plankton as pl from plankton.utils import ssam + assert "other" not in adata_sc.obs[sc_ct_key].values, "cell type'other' not allowed in sc data" + sdata = pl.SpatialData( spots[gene_col], spots.x*um_p_px, spots.y*um_p_px ) @@ -125,6 +130,8 @@ def run_ssam( # Assign based on majority vote spots['celltype'] = sdata['celltype'] + adata_st.obs['ct_ssam'] = no_ct_assigned_value + for cell_id in adata_st.obs['cell_id']: cts = spots[spots[cell_id_col] == cell_id ][gene_col].value_counts() mode = spots[spots[cell_id_col] == cell_id ]['celltype'].mode() @@ -132,6 +139,8 @@ def run_ssam( adata_st.obs.loc[adata_st.obs['cell_id'] == cell_id, 'ct_ssam_cert'] = \ (spots[spots[cell_id_col] == cell_id ]['celltype'].value_counts()[mode].values[0] / sum(cts)) + adata_st.obs.loc[adata_st.obs['ct_ssam'] == 'other', 'ct_ssam'] = no_ct_assigned_value + return adata_st diff --git a/txsim/preprocessing/normalization_sc.py b/txsim/preprocessing/normalization_sc.py index 13675a7..a40d971 100644 --- a/txsim/preprocessing/normalization_sc.py +++ b/txsim/preprocessing/normalization_sc.py @@ -54,7 +54,10 @@ def normalize_sc( or updates `adata` with normalized version of the original ` adata.X` and `adata.layers`, depending on `inplace` """ - adata.layers['raw'] = adata.X + if layer: + adata.layers['raw'] = adata.layers[layer] + else: + adata.layers['raw'] = adata.X adata.layers['norm'] = sc.pp.normalize_total(adata=adata, target_sum=target_sum, exclude_highly_expressed=exclude_highly_expressed, max_fraction=max_fraction, key_added=key_added, layer=layer, copy=copy, inplace=False)['X'] adata.layers['lognorm'] = adata.layers['norm'].copy() From 4bd2db2998be379cbce5fcb5f4ea76df485d9f04 Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Fri, 20 Sep 2024 19:42:51 +0200 Subject: [PATCH 21/24] Change setup config and make sure to install dependencies --- pyproject.toml | 41 +++++++++++++++++++++++++-- setup.cfg | 7 ----- setup.py | 3 -- txsim/__init__.py | 3 ++ txsim/preprocessing/_normalization.py | 34 +++++++++++++--------- 5 files changed, 61 insertions(+), 27 deletions(-) delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml index 9ef2c32..f43f28d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,18 @@ [build-system] -requires = [ +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "txsim" +version = "0.1.2" +description = "Python package to measure the similarity between matched single cell and targeted spatial transcriptomics data" +authors = [ + { name = "Louis Kuemmerle", email = "your.email@example.com" }, + { name = "Habib Rehman", email = "habib.email@example.com" } +] +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ "setuptools", "wheel", "omnipath", @@ -11,10 +24,32 @@ requires = [ "scikit-image", "planktonspace", "geopandas", - "rasterio" + "rasterio", + "anndata", + "scanpy", + "numpy", + "pandas", + "scipy", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "flake8", + "black", + "mypy", + "pre-commit", ] -build-backend = "setuptools.build_meta" +[tool.hatch.build.targets.wheel] +packages = ["txsim"] + +[tool.hatch.version] +path = "txsim/__init__.py" + +[tool.black] +line-length = 120 [tool.pytest.ini_options] filterwarnings = [ diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 80b9834..0000000 --- a/setup.cfg +++ /dev/null @@ -1,7 +0,0 @@ -[metadata] -name = txsim -version = 0.1.0 -description = Comparing scRNAseq and spatial transcriptomics data -long_description = file: README.md -long_description_content_type = text/markdown -author = Louis Kuemmerle, Habib Rehman diff --git a/setup.py b/setup.py deleted file mode 100644 index e31fce4..0000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() \ No newline at end of file diff --git a/txsim/__init__.py b/txsim/__init__.py index e53f079..977cd81 100644 --- a/txsim/__init__.py +++ b/txsim/__init__.py @@ -5,3 +5,6 @@ from . import plotting from . import local from . import simulation + +__version__ = "0.1.2" + diff --git a/txsim/preprocessing/_normalization.py b/txsim/preprocessing/_normalization.py index ac9140d..55b195e 100644 --- a/txsim/preprocessing/_normalization.py +++ b/txsim/preprocessing/_normalization.py @@ -166,7 +166,7 @@ def normalize_by_area( def gene_efficiency_correction( - adata_sp: AnnData,adata_sc: AnnData, layer_key:str='lognorm') -> AnnData: + adata_sp: AnnData,adata_sc: AnnData, layer_key:str='lognorm', ct_key:str='celltype') -> AnnData: """ Calculate the efficiency of every gene in the panel and normalize for that in the spatial object Based on https://github.com/scverse/scanpy version 1.9.1 @@ -200,6 +200,8 @@ def gene_efficiency_correction( `adata.X` and `adata.layers`, by default True copy : bool, optional Whether to modify copied input object. Not compatible with ``inplace=False``, by default False + ct_key : str, optional + Name of the field in `adata.obs` where the cell types are stored, by default 'celltype' Returns ------- @@ -209,11 +211,15 @@ def gene_efficiency_correction( ` adata.X` and `adata.layers`, depending on `inplace` """ - transform=layer_key - key='celltype' - adata_sc=adata_sc[:,adata_sp.var_names] - unique_celltypes=adata_sc.obs.loc[adata_sc.obs[key].isin(adata_sp.obs[key]),key].unique() + adata_sc=adata_sc[:,adata_sp.var_names].copy() + + if issparse(adata_sc.layers[transform]): + adata_sc.layers[transform]=adata_sc.layers[transform].toarray().copy() + if issparse(adata_sp.layers[transform]): + adata_sp.layers[transform]=adata_sp.layers[transform].toarray().copy() + + unique_celltypes=adata_sc.obs.loc[adata_sc.obs[ct_key].isin(adata_sp.obs[ct_key]),ct_key].unique() genes=adata_sc.var.index[adata_sc.var.index.isin(adata_sp.var.index)] exp_sc=pd.DataFrame(adata_sc.layers[transform],columns=adata_sc.var.index) gene_means_sc=pd.DataFrame(np.mean(exp_sc,axis=0)) @@ -221,12 +227,12 @@ def gene_efficiency_correction( exp_sp=pd.DataFrame(adata_sp.layers[transform],columns=adata_sp.var.index) gene_means_sp=pd.DataFrame(np.mean(exp_sp,axis=0)) gene_means_sp=gene_means_sp.loc[gene_means_sp.index.sort_values(),:] - exp_sc['celltype']=list(adata_sc.obs['celltype']) - exp_sp['celltype']=list(adata_sp.obs['celltype']) - exp_sc=exp_sc.loc[exp_sc['celltype'].isin(unique_celltypes),:] - exp_sp=exp_sp.loc[exp_sp['celltype'].isin(unique_celltypes),:] - mean_celltype_sp=exp_sp.groupby('celltype').mean() - mean_celltype_sc=exp_sc.groupby('celltype').mean() + exp_sc[ct_key]=list(adata_sc.obs[ct_key]) + exp_sp[ct_key]=list(adata_sp.obs[ct_key]) + exp_sc=exp_sc.loc[exp_sc[ct_key].isin(unique_celltypes),:] + exp_sp=exp_sp.loc[exp_sp[ct_key].isin(unique_celltypes),:] + mean_celltype_sp=exp_sp.groupby(ct_key).mean().astype(np.float64) + mean_celltype_sc=exp_sc.groupby(ct_key).mean().astype(np.float64) mean_celltype_sc=mean_celltype_sc.loc[:,mean_celltype_sc.columns.sort_values()] mean_celltype_sp=mean_celltype_sp.loc[:,mean_celltype_sp.columns.sort_values()] #If no read is prestent in a gene, we add 0.1 so that we can compute statistics @@ -237,10 +243,10 @@ def gene_efficiency_correction( gene_ratios=pd.DataFrame(np.mean(mean_celltype_sp,axis=0)/np.mean(mean_celltype_sc,axis=0)) gr=pd.DataFrame(gene_ratios) gr.columns=['efficiency_st_vs_sc'] - efficiency_mean=np.mean(gene_ratios) - efficiency_std=np.std(gene_ratios) + efficiency_mean=np.mean(gene_ratios,axis=0) + efficiency_std=np.std(gene_ratios,axis=0) meanexp=pd.DataFrame(adata_sp.layers[transform],columns=adata_sp.var.index) for gene in meanexp.columns: - meanexp.loc[:,gene]=meanexp.loc[:,gene]/gene_ratios.loc[gene,'efficiency_st_vs_sc'] + meanexp.loc[:,gene]=meanexp.loc[:,gene]/gr.loc[gene,'efficiency_st_vs_sc'] adata_sp.layers[transform]=meanexp return adata_sp \ No newline at end of file From 3f01ecf8ca25d1252b1588e7fc650695f759590a Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Fri, 20 Sep 2024 20:08:31 +0200 Subject: [PATCH 22/24] unify celltype key naming --- txsim/metrics/_celltype_proportions.py | 8 +++---- txsim/metrics/_coembedding.py | 26 ++++++++++++----------- txsim/metrics/_coexpression_similarity.py | 10 ++++----- txsim/metrics/_combined.py | 19 +++++++++-------- 4 files changed, 33 insertions(+), 30 deletions(-) diff --git a/txsim/metrics/_celltype_proportions.py b/txsim/metrics/_celltype_proportions.py index 63dda7d..b8d33b0 100644 --- a/txsim/metrics/_celltype_proportions.py +++ b/txsim/metrics/_celltype_proportions.py @@ -7,7 +7,7 @@ def mean_proportion_deviation( adata_sp: AnnData, adata_sc: AnnData, ct_set: str = "union", - obs_key: str = "celltype", + key: str = "celltype", pipeline_output: bool = True ) -> Union[float, Tuple[float, pd.DataFrame]]: """Calculate the mean difference in proportions between cell types from both datasets. @@ -21,7 +21,7 @@ def mean_proportion_deviation( ct_set : str, default "union" Text string to determine which (sub)set of cell types to compare. Supported: ["union", "intersection", "sp_specific", "sc_specific"] - obs_key : str, default "celltype" + key : str, default "celltype" Key in adata_sp.obs for the cell type. pipeline_output : bool, default True Boolean that when set to ``False`` will return the DataFrame with cell type proportions for further analysis. @@ -41,8 +41,8 @@ def mean_proportion_deviation( """ # determine proportion of each cell type in each modality - ct_props_sp = adata_sp.obs[obs_key].value_counts(normalize=True).rename("proportion_sp") - ct_props_sc = adata_sc.obs[obs_key].value_counts(normalize=True).rename("proportion_sc") + ct_props_sp = adata_sp.obs[key].value_counts(normalize=True).rename("proportion_sp") + ct_props_sc = adata_sc.obs[key].value_counts(normalize=True).rename("proportion_sc") # merge cell type proportions from modalities together based on ct_how parameter merge_how = {"union": "outer", "intersection": "inner", "sp_specific": "left", "sc_specific": "right"} diff --git a/txsim/metrics/_coembedding.py b/txsim/metrics/_coembedding.py index 39f7373..c58f2fe 100644 --- a/txsim/metrics/_coembedding.py +++ b/txsim/metrics/_coembedding.py @@ -10,7 +10,7 @@ def knn_mixing( adata_sp: ad.AnnData, adata_sc: ad.AnnData, pipeline_output: bool = True, - obs_key: str = "celltype", + key: str = "celltype", k: int = 45, ct_filter_factor: float = 5, ) -> Union[float, Tuple[str, pd.Series]]: @@ -29,6 +29,8 @@ def knn_mixing( Single cell data. pipeline_output: Whether to return only a summary score or additionally also cell type level scores. + key: + adata.obs key for cell type annotations. k: Number of neighbors for knn graphs. ct_filter_factor: @@ -57,17 +59,17 @@ def knn_mixing( sc.tl.pca(adata) # get cell type groups - sc_cts = set(adata_sc.obs[obs_key].cat.categories) - st_cts = set(adata_sp.obs[obs_key].cat.categories) + sc_cts = set(adata_sc.obs[key].cat.categories) + st_cts = set(adata_sp.obs[key].cat.categories) all_cts = list(sc_cts.union(st_cts)) shared_cts = list(sc_cts.intersection(st_cts)) # Get adata per shared cell type scores = {ct:np.nan for ct in all_cts} for ct in shared_cts: - enough_cells = (adata.obs.loc[adata.obs[obs_key]==ct,"modality"].value_counts() > (ct_filter_factor * k)).all() + enough_cells = (adata.obs.loc[adata.obs[key]==ct,"modality"].value_counts() > (ct_filter_factor * k)).all() if enough_cells: - a = adata[adata.obs[obs_key]==ct] + a = adata[adata.obs[key]==ct] sc.pp.neighbors(a,n_neighbors=k) G = nx.Graph(incoming_graph_data=a.obsp["connectivities"]) nx.set_node_attributes(G, {i:a.obs["modality"].values[i] for i in range(G.number_of_nodes())}, "modality") @@ -89,7 +91,7 @@ def knn_mixing( def knn_mixing_per_cell_score( adata_sp: ad.AnnData, adata_sc: ad.AnnData, - obs_key: str = "celltype", + key: str = "celltype", key_added: str = "knn_mixing_score", k: int = 45, ct_filter_factor: float = 2 @@ -105,7 +107,7 @@ def knn_mixing_per_cell_score( Spatial data. adata_sc: Single cell data. - obs_key: + key: adata.obs key for cell type annotations. key_added: adata.obs key where knn mixing scores are saved. @@ -137,15 +139,15 @@ def knn_mixing_per_cell_score( sc.tl.pca(adata) # get cell type groups - sc_cts = set(adata_sc.obs[obs_key].cat.categories) - st_cts = set(adata_sp.obs[obs_key].cat.categories) + sc_cts = set(adata_sc.obs[key].cat.categories) + st_cts = set(adata_sp.obs[key].cat.categories) shared_cts = list(sc_cts.intersection(st_cts)) # Get ratio per shared cell type for ct in shared_cts: - enough_cells = (adata.obs.loc[adata.obs[obs_key]==ct,"modality"].value_counts() > (ct_filter_factor * k)).all() + enough_cells = (adata.obs.loc[adata.obs[key]==ct,"modality"].value_counts() > (ct_filter_factor * k)).all() if enough_cells: - a = adata[adata.obs[obs_key]==ct] + a = adata[adata.obs[key]==ct] exp_val = (a.obs.loc[a.obs["modality"]=="sc"].shape[0])/a.obs.shape[0] sc.pp.neighbors(a,n_neighbors=k) G = nx.Graph(incoming_graph_data=a.obsp["connectivities"]) @@ -160,6 +162,6 @@ def knn_mixing_per_cell_score( i += 1 a.obs[key_added] = f(ct_df) - adata_sp.obs.loc[adata_sp.obs[obs_key] == ct, key_added] = a.obs.loc[a.obs["modality"]=="spatial", key_added] + adata_sp.obs.loc[adata_sp.obs[key] == ct, key_added] = a.obs.loc[a.obs["modality"]=="spatial", key_added] adata_sp.obs_names = sp_obs_names \ No newline at end of file diff --git a/txsim/metrics/_coexpression_similarity.py b/txsim/metrics/_coexpression_similarity.py index 952f5dc..110b201 100644 --- a/txsim/metrics/_coexpression_similarity.py +++ b/txsim/metrics/_coexpression_similarity.py @@ -13,7 +13,7 @@ def coexpression_similarity( min_cells: int = 20, thresh: float = 0, layer: str = 'lognorm', - ct_key: str = 'celltype', + key: str = 'celltype', by_celltype: bool = False, correlation_measure: str = "pearson", pipeline_output: bool = True, @@ -37,7 +37,7 @@ def coexpression_similarity( name of layer used to calculate coexpression similarity. Should be the same in both AnnData objects default lognorm - ct_key : str + key : str name of the column containing the cell type information by_celltype: bool run analysis by cell type? If False, computation will be performed using the @@ -104,7 +104,7 @@ def coexpression_similarity( return [coexp_sim, pd.Series(index=genes, data=np.nanmean(coexp_sim_mat, axis=1)), pd.Series(dtype=float)] else: # Get shared cell types - shared_cts = list(set(adata_sc.obs[ct_key].unique()).intersection(set(adata_sp.obs[ct_key].unique()))) + shared_cts = list(set(adata_sc.obs[key].unique()).intersection(set(adata_sp.obs[key].unique()))) # Init coexpression similarity matrices coexp_sim_matrices = np.zeros((len(shared_cts), len(genes), len(genes)), dtype=float) @@ -112,8 +112,8 @@ def coexpression_similarity( for ct_idx, ct in enumerate(shared_cts): # Adatas for the given cell type - adata_sc_ct = adata_sc[adata_sc.obs[ct_key] == ct, :] - adata_sp_ct = adata_sp[adata_sp.obs[ct_key] == ct, :] + adata_sc_ct = adata_sc[adata_sc.obs[key] == ct, :] + adata_sp_ct = adata_sp[adata_sp.obs[key] == ct, :] # Filter genes based on number of cells that express them genes_sp_ct = adata_sp_ct.var_names[sc.pp.filter_genes(adata_sp_ct, min_cells=min_cells, inplace=False)[0]] diff --git a/txsim/metrics/_combined.py b/txsim/metrics/_combined.py index b78a595..6098cfb 100644 --- a/txsim/metrics/_combined.py +++ b/txsim/metrics/_combined.py @@ -15,10 +15,11 @@ def all_metrics( adata_sp: AnnData, - adata_sc: AnnData + adata_sc: AnnData, + key: str = 'celltype' ) -> DataFrame: - #Generate metrics + # Generate metrics metrics = {} ###Celltype proportion #metrics['mean_ct_prop_dev'] = mean_proportion_deviation(adata_sp,adata_sc) @@ -35,20 +36,20 @@ def all_metrics( ##metrics['relative_sim_across_gene_overall_metric'] = relative_pairwise_gene_expression(adata_sp, adata_sc, 'celltype', 'lognorm') ###metrics['mean_sim_across_clust'] = mean_similarity_gene_expression_across_clusters(adata_sp,adata_sc) ###metrics['prc95_sim_across_clust'] = percentile95_similarity_gene_expression_across_clusters(adata_sp,adata_sc) - metrics['rel_pairwise_ct_expr_sim'] = relative_pairwise_celltype_expression(adata_sp.copy(), adata_sc.copy()) - metrics['rel_pairwise_gene_expr_sim'] = relative_pairwise_gene_expression(adata_sp.copy(), adata_sc.copy()) + metrics['rel_pairwise_ct_expr_sim'] = relative_pairwise_celltype_expression(adata_sp.copy(), adata_sc.copy(), key=key) + metrics['rel_pairwise_gene_expr_sim'] = relative_pairwise_gene_expression(adata_sp.copy(), adata_sc.copy(), key=key) # ### Coexpression similarity - metrics['coexpr_similarity'] = coexpression_similarity(adata_sp.copy(), adata_sc.copy()) - metrics['coexpr_similarity_celltype'] = coexpression_similarity(adata_sp.copy(), adata_sc.copy(), by_celltype = True) + metrics['coexpr_similarity'] = coexpression_similarity(adata_sp.copy(), adata_sc.copy(), key=key) + metrics['coexpr_similarity_celltype'] = coexpression_similarity(adata_sp.copy(), adata_sc.copy(), by_celltype = True, key=key) ##metrics['gene_set_coexpression'] = gene_set_coexpression(adata_sp, adata_sc) # Negative marker purity - metrics['neg_marker_purity_cells'] = negative_marker_purity_cells(adata_sp.copy(),adata_sc.copy()) - metrics['neg_marker_purity_reads'] = negative_marker_purity_reads(adata_sp.copy(),adata_sc.copy()) + metrics['neg_marker_purity_cells'] = negative_marker_purity_cells(adata_sp.copy(),adata_sc.copy(), key=key) + metrics['neg_marker_purity_reads'] = negative_marker_purity_reads(adata_sp.copy(),adata_sc.copy(), key=key) #### KNN mixing - metrics['knn_mixing'] = knn_mixing(adata_sp.copy(),adata_sc.copy()) + metrics['knn_mixing'] = knn_mixing(adata_sp.copy(),adata_sc.copy(), key=key) # Cell statistics #metrics['ratio_median_readsxcell'] = ratio_median_readsXcells(adata_sp,adata_sc) From ae85a4bd9808b59aab1a5e46c7d57d0c4b876b54 Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Sun, 22 Sep 2024 15:51:06 +0200 Subject: [PATCH 23/24] Fix ssam failing when the spots of a cell only have NaNs assigned as cts --- txsim/preprocessing/_ctannotation.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/txsim/preprocessing/_ctannotation.py b/txsim/preprocessing/_ctannotation.py index 080a949..b443e1d 100644 --- a/txsim/preprocessing/_ctannotation.py +++ b/txsim/preprocessing/_ctannotation.py @@ -133,8 +133,15 @@ def run_ssam( adata_st.obs['ct_ssam'] = no_ct_assigned_value for cell_id in adata_st.obs['cell_id']: - cts = spots[spots[cell_id_col] == cell_id ][gene_col].value_counts() - mode = spots[spots[cell_id_col] == cell_id ]['celltype'].mode() + spots_of_cell = spots[spots[cell_id_col] == cell_id ] + if spots_of_cell["celltype"].isna().all(): + # mode fails if all values are NaN. TODO: Potentially we want to convert NaNs to no_ct_assigned_value + # before the loop: currently cells with mainly NaNs are still assigned to the cell type with the highest + # number of spots + adata_st.obs.loc[adata_st.obs['cell_id'] == cell_id, 'ct_ssam'] = no_ct_assigned_value + continue + cts = spots_of_cell[gene_col].value_counts() + mode = spots_of_cell['celltype'].mode() adata_st.obs.loc[adata_st.obs['cell_id'] == cell_id, 'ct_ssam'] = mode.values[0] adata_st.obs.loc[adata_st.obs['cell_id'] == cell_id, 'ct_ssam_cert'] = \ (spots[spots[cell_id_col] == cell_id ]['celltype'].value_counts()[mode].values[0] / sum(cts)) From f1fd322bb883b1b9bbbd8fd338b866d8fa16ef0a Mon Sep 17 00:00:00 2001 From: LouisK92 <37270609+LouisK92@users.noreply.github.com> Date: Sun, 22 Sep 2024 22:29:32 +0200 Subject: [PATCH 24/24] Introduce layer argument to metrics --- txsim/metrics/_coembedding.py | 10 ++++++-- txsim/metrics/_combined.py | 18 ++++++++------ txsim/metrics/_negative_marker_purity.py | 31 ++++++++++++++++-------- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/txsim/metrics/_coembedding.py b/txsim/metrics/_coembedding.py index c58f2fe..dde97d0 100644 --- a/txsim/metrics/_coembedding.py +++ b/txsim/metrics/_coembedding.py @@ -11,6 +11,7 @@ def knn_mixing( adata_sc: ad.AnnData, pipeline_output: bool = True, key: str = "celltype", + layer: str = "lognorm", k: int = 45, ct_filter_factor: float = 5, ) -> Union[float, Tuple[str, pd.Series]]: @@ -31,6 +32,8 @@ def knn_mixing( Whether to return only a summary score or additionally also cell type level scores. key: adata.obs key for cell type annotations. + layer: + adata.layers key where the data is saved. k: Number of neighbors for knn graphs. ct_filter_factor: @@ -52,7 +55,7 @@ def knn_mixing( adata = ad.concat([adata_sp, adata_sc], join='inner') # Set counts to log norm data - adata.X = adata.layers["lognorm"] + adata.X = adata.layers[layer] # Calculate PCA (Note: we could also think about pca per cell type...) assert (adata.obsm is None) or ('X_pca' not in adata.obsm), "PCA already exists." @@ -93,6 +96,7 @@ def knn_mixing_per_cell_score( adata_sc: ad.AnnData, key: str = "celltype", key_added: str = "knn_mixing_score", + layer: str = "lognorm", k: int = 45, ct_filter_factor: float = 2 ) -> None: @@ -111,6 +115,8 @@ def knn_mixing_per_cell_score( adata.obs key for cell type annotations. key_added: adata.obs key where knn mixing scores are saved. + layer: + adata.layers key where the data is saved. k: Number of neighbors for knn graphs. ct_filter_factor: @@ -132,7 +138,7 @@ def knn_mixing_per_cell_score( adata_sp.obs[key_added] = np.zeros(adata_sp.n_obs) # Set counts to log norm data - adata.X = adata.layers["lognorm"] + adata.X = adata.layers[layer] # Calculate PCA (Note: we could also think about pca per cell type...) assert (adata.obsm is None) or ('X_pca' not in adata.obsm), "PCA already exists." diff --git a/txsim/metrics/_combined.py b/txsim/metrics/_combined.py index 6098cfb..9370f8a 100644 --- a/txsim/metrics/_combined.py +++ b/txsim/metrics/_combined.py @@ -16,7 +16,9 @@ def all_metrics( adata_sp: AnnData, adata_sc: AnnData, - key: str = 'celltype' + key: str = 'celltype', + raw_layer: str = 'raw', + lognorm_layer: str = 'lognorm' ) -> DataFrame: # Generate metrics @@ -36,20 +38,20 @@ def all_metrics( ##metrics['relative_sim_across_gene_overall_metric'] = relative_pairwise_gene_expression(adata_sp, adata_sc, 'celltype', 'lognorm') ###metrics['mean_sim_across_clust'] = mean_similarity_gene_expression_across_clusters(adata_sp,adata_sc) ###metrics['prc95_sim_across_clust'] = percentile95_similarity_gene_expression_across_clusters(adata_sp,adata_sc) - metrics['rel_pairwise_ct_expr_sim'] = relative_pairwise_celltype_expression(adata_sp.copy(), adata_sc.copy(), key=key) - metrics['rel_pairwise_gene_expr_sim'] = relative_pairwise_gene_expression(adata_sp.copy(), adata_sc.copy(), key=key) + metrics['rel_pairwise_ct_expr_sim'] = relative_pairwise_celltype_expression(adata_sp.copy(), adata_sc.copy(), key=key, layer=lognorm_layer) + metrics['rel_pairwise_gene_expr_sim'] = relative_pairwise_gene_expression(adata_sp.copy(), adata_sc.copy(), key=key, layer=lognorm_layer) # ### Coexpression similarity - metrics['coexpr_similarity'] = coexpression_similarity(adata_sp.copy(), adata_sc.copy(), key=key) - metrics['coexpr_similarity_celltype'] = coexpression_similarity(adata_sp.copy(), adata_sc.copy(), by_celltype = True, key=key) + metrics['coexpr_similarity'] = coexpression_similarity(adata_sp.copy(), adata_sc.copy(), key=key, layer=lognorm_layer) + metrics['coexpr_similarity_celltype'] = coexpression_similarity(adata_sp.copy(), adata_sc.copy(), by_celltype = True, key=key, layer=lognorm_layer) ##metrics['gene_set_coexpression'] = gene_set_coexpression(adata_sp, adata_sc) # Negative marker purity - metrics['neg_marker_purity_cells'] = negative_marker_purity_cells(adata_sp.copy(),adata_sc.copy(), key=key) - metrics['neg_marker_purity_reads'] = negative_marker_purity_reads(adata_sp.copy(),adata_sc.copy(), key=key) + metrics['neg_marker_purity_cells'] = negative_marker_purity_cells(adata_sp.copy(),adata_sc.copy(), key=key, layer=raw_layer) + metrics['neg_marker_purity_reads'] = negative_marker_purity_reads(adata_sp.copy(),adata_sc.copy(), key=key, layer=raw_layer) #### KNN mixing - metrics['knn_mixing'] = knn_mixing(adata_sp.copy(),adata_sc.copy(), key=key) + metrics['knn_mixing'] = knn_mixing(adata_sp.copy(),adata_sc.copy(), key=key, layer=lognorm_layer) # Cell statistics #metrics['ratio_median_readsxcell'] = ratio_median_readsXcells(adata_sp,adata_sc) diff --git a/txsim/metrics/_negative_marker_purity.py b/txsim/metrics/_negative_marker_purity.py index d89e0c5..a5349e0 100644 --- a/txsim/metrics/_negative_marker_purity.py +++ b/txsim/metrics/_negative_marker_purity.py @@ -11,7 +11,8 @@ def negative_marker_purity_cells( adata_sp: AnnData, adata_sc: AnnData, - key: str='celltype', + key: str='celltype', + layer: str='raw', pipeline_output: bool=True ) -> float | tuple[float, pd.Series, pd.Series]: """ Negative marker purity aims to measure read leakeage between cells in spatial datasets. @@ -27,6 +28,8 @@ def negative_marker_purity_cells( Annotated ``AnnData`` object with counts scRNAseq data key : str Celltype key in adata_sp.obs and adata_sc.obs + layer : str + Layer of ``AnnData`` to use to compute the metric pipeline_output : float, optional Boolean for whether to use the function in the pipeline or not Returns @@ -44,8 +47,8 @@ def negative_marker_purity_cells( # TMP fix for sparse matrices, ideally we don't convert, and instead have calculations for sparse/non-sparse for a in [adata_sc, adata_sp]: - if issparse(a.layers["raw"]): - a.layers["raw"] = a.layers["raw"].toarray() + if issparse(a.layers[layer]): + a.layers[layer] = a.layers[layer].toarray() # Get cell types that we find in both modalities shared_celltypes = adata_sc.obs.loc[adata_sc.obs[key].isin(adata_sp.obs[key]),key].unique() @@ -75,8 +78,8 @@ def negative_marker_purity_cells( # Get ratio of positive cells per cell type - pos_exp_sc = pd.DataFrame(adata_sc.layers['raw'] > 0,columns=adata_sp.var_names) - pos_exp_sp = pd.DataFrame(adata_sp.layers['raw'] > 0,columns=adata_sp.var_names) + pos_exp_sc = pd.DataFrame(adata_sc.layers[layer] > 0,columns=adata_sp.var_names) + pos_exp_sp = pd.DataFrame(adata_sp.layers[layer] > 0,columns=adata_sp.var_names) pos_exp_sc['celltype'] = list(adata_sc.obs[key]) pos_exp_sp['celltype'] = list(adata_sp.obs[key]) ratio_celltype_sc = pos_exp_sc.groupby('celltype').mean() @@ -123,7 +126,13 @@ def negative_marker_purity_cells( return negative_marker_purity, purity_per_gene, purity_per_celltype -def negative_marker_purity_reads(adata_sp: AnnData, adata_sc: AnnData, key: str='celltype', pipeline_output: bool=True): +def negative_marker_purity_reads( + adata_sp: AnnData, + adata_sc: AnnData, + key: str='celltype', + layer: str='raw', + pipeline_output: bool=True +): """ Negative marker purity aims to measure read leakeage between cells in spatial datasets. For this, we calculate the increase in reads assigned in spatial datasets to pairs of genes-celltyes with no/very low expression in scRNAseq @@ -136,6 +145,8 @@ def negative_marker_purity_reads(adata_sp: AnnData, adata_sc: AnnData, key: str= Annotated ``AnnData`` object with counts scRNAseq data key : str Celltype key in adata_sp.obs and adata_sc.obs + layer : str + Layer of ``AnnData`` to use to compute the metric pipeline_output : float, optional Boolean for whether to use the function in the pipeline or not Returns @@ -153,8 +164,8 @@ def negative_marker_purity_reads(adata_sp: AnnData, adata_sc: AnnData, key: str= # TMP fix for sparse matrices, ideally we don't convert, and instead have calculations for sparse/non-sparse for a in [adata_sc, adata_sp]: - if issparse(a.layers["raw"]): - a.layers["raw"] = a.layers["raw"].toarray() + if issparse(a.layers[layer]): + a.layers[layer] = a.layers[layer].toarray() # Get cell types that we find in both modalities shared_celltypes = adata_sc.obs.loc[adata_sc.obs[key].isin(adata_sp.obs[key]),key].unique() @@ -181,8 +192,8 @@ def negative_marker_purity_reads(adata_sp: AnnData, adata_sc: AnnData, key: str= adata_sp = adata_sp[adata_sp.obs[key].isin(celltypes)] # Get mean expression per cell type - exp_sc = pd.DataFrame(adata_sc.layers['raw'],columns=adata_sp.var_names) - exp_sp = pd.DataFrame(adata_sp.layers['raw'],columns=adata_sp.var_names) + exp_sc = pd.DataFrame(adata_sc.layers[layer],columns=adata_sp.var_names) + exp_sp = pd.DataFrame(adata_sp.layers[layer],columns=adata_sp.var_names) exp_sc['celltype'] = list(adata_sc.obs[key]) exp_sp['celltype'] = list(adata_sp.obs[key]) mean_celltype_sc = exp_sc.groupby('celltype').mean()