Source code for spac.utils

import re
import anndata as ad
import numpy as np
import matplotlib.cm as cm
import logging
import warnings
import numbers

# Configure logging
logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s - %(levelname)s - %(message)s')


[docs] def regex_search_list( regex_patterns, list_to_search): """ Perfrom regex (regular expression) search in a list and return list of strings matching the regex pattern Parameters ---------- regex_pattern : str or a list of str The regex pattern to look for, single pattern or a list of patterns. list_to_search : list of str A list of string to seach for string that matches regex pattern. Returns ------- list of str A list of strings containing results from search. Example ------- >>> regex_pattern = ["A", "^B.*"] >>> list_to_search = ["ABC", "BC", "AC", "AB"] >>> result = regex_search_list(regex_pattern, list_to_search) >>> print(result) ['BC'] """ if not isinstance(list_to_search, list): raise TypeError("Please provide a list to search.") if not isinstance(regex_patterns, list): if not isinstance(regex_patterns, str): error_text = "Regrex pattern provided " + \ "is not list nor a string" raise TypeError(error_text) else: regex_patterns = [regex_patterns] else: if not all(isinstance(item, str) for item in regex_patterns): error_text = "All items in the pattern " + \ "list must be of type string." raise TypeError(error_text) def regex_search(pattern, str): try: found = re.search(pattern, str) if found is not None: return found.group(0) except re.error as e: raise ValueError( "Error occurred when searching with regex:\n{}\n" "Please review your regex pattern: {}\nIf using * at the start" ", always have a . before the asterisk.".format(e, pattern) ) all_results = [] for pattern in regex_patterns: print(pattern) str_found = [ regex_search( pattern, x) for x in list_to_search if regex_search( pattern, x) in list_to_search ] all_results.extend(str_found) all_results = list(all_results) return all_results
[docs] def check_list_in_list( input, input_name, input_type, target_list, need_exist=True, warning=False ): """ Check if items in a given list exist in a target list. This function is used to validate whether all or none of the items in a given list exist in a target list. It helps to ensure that the input list contains only valid elements that are present in the target list. Parameters ---------- input : str or list of str or None The input list or a single string element. If it is a string, it will be converted to a list containing only that string element. If `None`, no validation will be performed. input_name : str The name of the input list used for displaying helpful error messages. input_type : str The type of items in the input list (e.g., "item", "element", "category"). target_list : list of str The target list containing valid items that the input list elements should be compared against. need_exist : bool, optional (default=True) Determines whether to check if elements exist in the target list (True), or if they should not exist (False). warning: bool, optional (default=False) If true, generate a warning instead of raising an exception Raises ------ ValueError If the `input` is not a string or a list of strings. If `need_exist` is True and any element of the input list does not exist in the target list. If `need_exist` is False and any element of the input list exists in the target list. Warns ----- UserWarning If the specified behavior is not present and `warning` is True. """ if input is not None: if isinstance(input, str): input = [input] elif not isinstance(input, list): raise ValueError( f"The '{input_name}' parameter " "should be a string or a list of strings." ) target_list_str = "\n".join(target_list) if need_exist: for item in input: if item not in target_list: message = ( f"The {input_type} '{item}' " "does not exist in the provided dataset.\n" f"Existing {input_type}s are:\n" f"{target_list_str}" ) if warning is False: raise ValueError(message) else: warnings.warn(message) else: for item in input: if item in target_list: message = ( f"The {input_type} '{item}' " "exist in the provided dataset.\n" f"Existing {input_type}s are:\n" f"{target_list_str}" ) if warning is False: raise ValueError(message) else: warnings.warn(message)
[docs] def check_table( adata, tables=None, should_exist=True, associated_table=False, warning=False): """ Perform common error checks for table (layers) or derived tables (obsm) in anndata related objects. Parameters ---------- adata : anndata.AnnData The AnnData object to be checked. tables : str or list of str, optional The term "table" is equivalent to layer in anndata structure. The layer(s) to check for existence in adata.layers.keys(). should_exist : bool, optional (default=True) Determines whether to check if elements exist in the target list (True), or if they should not exist (False). associtated_table : bool, optional (default=False) Determines whether to check if the passed tables names should exist as layers or in obsm in the andata object. warning : bool, optional (default=False) If True, generate a warning instead of raising an exception. Raises ------ TypeError If adata is not an instance of anndata.AnnData. ValueError If any of the specified layers, annotations, obsm, or features do not exist. Warns ----- UserWarning If any of the specified layers, annotations, obsm, or features do not exist, and `warning` is True. """ # Check if adata is an instance of anndata.AnnData if not isinstance(adata, ad.AnnData): raise TypeError( "Input dataset should be " "an instance of anndata.AnnData, " "please check the input dataset source." ) # Check for tables if associated_table is False: existing_tables = list(adata.layers.keys()) input_name = "tables" input_type = "table" else: existing_tables = list(adata.obsm.keys()) input_name = "associated tables" input_type = "associated table" check_list_in_list( input=tables, input_name=input_name, input_type=input_type, target_list=existing_tables, need_exist=should_exist, warning=warning )
[docs] def check_annotation( adata, annotations=None, parameter_name=None, should_exist=True): """ Perform common error checks for annotations in anndata related objects. Parameters ---------- adata : anndata.AnnData The AnnData object to be checked. annotations : str or list of str, optional The annotation(s) to check for existence in adata.obs. should_exist : bool, optional (default=True) Determines whether to check if elements exist in the target list (True), or if they should not exist (False). Raises ------ TypeError If adata is not an instance of anndata.AnnData. ValueError If any of the specified layers, annotations, or features do not exist. """ # Check if adata is an instance of anndata.AnnData if not isinstance(adata, ad.AnnData): raise TypeError( "Input dataset should be " "an instance of anndata.AnnData, " "please check the input dataset source." ) # Check for tables existing_annotation = adata.obs.columns.to_list() if not parameter_name: parameter_name="annotations" check_list_in_list( input=annotations, input_name=parameter_name, input_type="annotation", target_list=existing_annotation, need_exist=should_exist )
[docs] def check_feature( adata, features=None, should_exist=True): """ Perform common error checks for features in anndata related objects. Parameters ---------- adata : anndata.AnnData The AnnData object to be checked. features : str or list of str, optional The feature(s) to check for existence in adata.var_names. should_exist : bool, optional (default=True) Determines whether to check if elements exist in the target list (True), or if they should not exist (False). Raises ------ TypeError If adata is not an instance of anndata.AnnData. ValueError If any of the specified layers, annotations, or features do not exist. """ # Check if adata is an instance of anndata.AnnData if not isinstance(adata, ad.AnnData): raise TypeError( "Input dataset should be " "an instance of anndata.AnnData, " "please check the input dataset source." ) # Check for tables existing_features = adata.var_names.to_list() check_list_in_list( input=features, input_name="features", input_type="feature", target_list=existing_features, need_exist=should_exist )
[docs] def check_column_name( column_name, field_name, symbol_checklist="!?,.", ): if column_name.find(" ") != -1: raise ValueError(f"The {column_name} for {field_name} contains a space character.") else: if any(symbol in column_name for symbol in symbol_checklist): raise ValueError(f"One of the symbols in {symbol_checklist} is present in {column_name} for {field_name}.")
[docs] def check_distances(distances): """ Check that the distances are valid: must be an array-like of incremental positive values. Parameters ---------- distances : list, tuple, or np.ndarray The list of increasing distances for the neighborhood profile. Returns ------- None Raises a ValueError or TypeError if the distances are invalid. Notes ----- The distances must be a list of positive real numbers and must be monotonically increasing. """ if not isinstance(distances, (list, tuple, np.ndarray)): raise TypeError("distances must be a list, tuple, or numpy array. " + f"Got {type(distances)}") if not all(isinstance(x, numbers.Real) and x >= 0 for x in distances): raise ValueError("distances must be a list of positive numbers. " + f"Got {distances}") if not all(distances[i] < distances[i+1] for i in range(len(distances)-1)): raise ValueError("distances must be monotonically increasing. " + f"Got {distances}")
[docs] def text_to_others( parameter, text="None", to_None=True, to_False=False, to_True=False, to_Int=False, to_Float=False ): def check_same( parameter, text, target ): if parameter == text: parameter = target return parameter else: return parameter if to_None: parameter = check_same( parameter, text, None ) if to_False: parameter = check_same( parameter, text, False ) if to_True: parameter = check_same( parameter, text, True ) if isinstance(parameter, str): if to_Int and to_Float: raise ValueError("Please select one numeric conversion at a time, thank you.") if to_Int: parameter = int(parameter) if to_Float: parameter = float(parameter) return parameter
[docs] def annotation_category_relations( adata, source_annotation, target_annotation, prefix=False ): """ Calculates the count of unique relationships between two annotations in an AnnData object. Relationship is defined as a unique pair of values, one from the 'source_annotation' and one from the 'target_annotation'. Returns a DataFrame with columns 'source_annotation', 'target_annotation', 'count', 'percentage_source', and 'percentage_target'. Where 'count' represents the number of occurrences of each relationship, percentage_source represents the percentage of the count of link over the total count of the source label, and percentage_target represents the percentage of the count of link over the total count of the target. If the `prefix` is set to True, it appends "source_" and "target_" prefixes to labels in the "source" and "target" columns, respectively. Parameters ---------- adata : AnnData The annotated data matrix of shape `n_obs` * `n_vars`. Rows correspond to cells and columns to genes. source_annotation : str The name of the source annotation column in the `adata` object. target_annotation : str The name of the target annotation column in the `adata` object. prefix : bool, optional If True, appends "source_" and "target_" prefixes to the "source" and "target" columns, respectively. Returns ------- relationships : pandas.DataFrame A DataFrame with the source and target categories, their counts and their percentages. """ check_annotation( adata, [ source_annotation, target_annotation ] ) # Iterate through annotation columns and calculate label relationships logging.info((f"Source: {source_annotation}")) logging.info((f"Target: {target_annotation}")) if source_annotation == source_annotation: logging.info("Source and Target are the same") target_annotation_copy = f"{target_annotation}_copy" adata.obs[target_annotation_copy] = adata.obs[target_annotation] target_annotation = target_annotation_copy # Calculate label relationships between source and target columns relationships = adata.obs.groupby( [source_annotation, target_annotation] ).size().reset_index(name='count') adata.obs.drop(columns=[target_annotation_copy], inplace=True) # Calculate the total count for each source total_counts = ( relationships.groupby(source_annotation)['count'].transform('sum') ) # Calculate the percentage of the total count for each source relationships['percentage_source'] = ( (relationships['count'] / total_counts * 100).round(1) ) total_counts_target = ( relationships.groupby(target_annotation)['count'].transform('sum') ) # Calculate the percentage of the total count for each target relationships['percentage_target'] = ( (relationships['count'] / total_counts_target * 100).round(1) ) relationships.rename( columns={ source_annotation: "source", target_annotation: "target" }, inplace=True ) relationships["source"] = relationships["source"].astype(str) relationships["target"] = relationships["target"].astype(str) relationships["count"] = relationships["count"].astype('int64') relationships[ "percentage_source" ] = relationships["percentage_source"].astype(float) relationships[ "percentage_target" ] = relationships["percentage_target"].astype(float) # Reset the index of the label_relations DataFrame relationships.reset_index(drop=True, inplace=True) if prefix: # Add "Source_" prefix to the "Source" column relationships["source"] = relationships[ "source" ].apply(lambda x: "source_" + x) # Add "Target_" prefix to the "Target" column relationships["target"] = relationships[ "target" ].apply(lambda x: "target_" + x) return relationships
[docs] def color_mapping( labels, color_map='viridis', opacity=1.0, rgba_mode=True, return_dict=False ): """ Map a list of labels to colors using a Matplotlib colormap and opacity. This function assigns a unique color to each label in the provided list using a specified colormap from Matplotlib. The generated colors can be returned in either `rgba` or `rgb` format, suitable for visualization in libraries like Plotly. The function supports both continuous and discrete colormaps: - Continuous colormaps interpolate smoothly between colors across a range. - Discrete colormaps have a fixed number of distinct colors, and labels are distributed evenly across these colors. Opacity can be set with a value between 0 (fully transparent) and 1 (fully opaque). The resulting colors are CSS-compatible strings. Parameters ---------- labels : list A list of unique labels to map to colors. The number of labels determines how the colormap is sampled. color_map : str, optional The colormap name (e.g., 'viridis', 'plasma', 'inferno'). It must be a valid Matplotlib colormap. Default is 'viridis'. opacity : float, optional Opacity (alpha channel) for colors, between 0 (transparent) and 1 (opaque). Default is 1.0. rgba_mode : bool, optional If True, returns colors in `rgba` format (e.g., `rgba(255, 0, 0, 0.5)`). If False, returns `rgb` format (e.g., `rgb(255, 0, 0)`). Default is True. return_dict : bool, optional If True, returns a dictionary where keys are labels, and values are the corresponding colors. Default is False. Returns ------- label_colors : list[str] or dict If `return_dict` is False, returns a list of color strings, one for each label. If `return_dict` is True, returns a dictionary with label keys and color values. The format of the colors depends on the `rgba_mode` parameter. Raises ------ ValueError - If `opacity` is not in the range [0, 1]. - If `color_map` is not a valid Matplotlib colormap name. Examples -------- Assign colors to labels with default settings: >>> labels = ['A', 'B', 'C'] >>> color_mapping(labels) ['rgba(68, 1, 84, 1.0)', 'rgba(58, 82, 139, 1.0)', 'rgba(33, 145, 140, 1.0)'] Use a different colormap with reduced opacity: >>> color_mapping(labels, color_map='plasma', opacity=0.5) ['rgba(13, 8, 135, 0.5)', 'rgba(126, 3, 167, 0.5)', 'rgba(240, 249, 33, 0.5)'] Generate colors in `rgb` format: >>> color_mapping(labels, rgba_mode=False) ['rgb(68, 1, 84)', 'rgb(58, 82, 139)', 'rgb(33, 145, 140)'] Return a dictionary of labels and colors: >>> color_mapping(labels, return_dict=True) {'A': 'rgba(68, 1, 84, 1.0)', 'B': 'rgba(58, 82, 139, 1.0)', 'C': 'rgba(33, 145, 140, 1.0)'} Notes ----- - Continuous colormaps interpolate colors evenly based on the number of labels. - Discrete colormaps divide labels evenly across available colors. - For more information on Matplotlib colormaps: https://matplotlib.org/stable/users/explain/colors/colormaps.html """ if not 0 <= opacity <= 1: raise ValueError("Opacity must be between 0 and 1") try: cmap = cm.get_cmap(color_map) except ValueError: raise ValueError(f"Invalid color map name: {color_map}") if cmap.N > 50: # This is a continuous colormap if len(labels) == 1: label_colors = [ cmap(i / (len(labels))) for i in range(len(labels)) ] else: label_colors = [ cmap(i / (len(labels) - 1)) for i in range(len(labels)) ] else: # This is a discrete colormap # Calculate the number of categories per color categories_per_color = np.ceil(len(labels) / cmap.N) # Interpolate between the colors label_colors = [ cmap(i / (categories_per_color * cmap.N - 1)) for i in range(len(labels)) ] if rgba_mode: label_colors = [ f'rgba({int(color[0]*255)},' f'{int(color[1]*255)},' f'{int(color[2]*255)},{opacity})' for color in label_colors ] else: label_colors = [ f'rgb({int(color[0]*255)},' f'{int(color[1]*255)},' f'{int(color[2]*255)})' for color in label_colors ] if return_dict: returning = {} for i, color in enumerate(label_colors): returning[labels[i]] = color else: returning = label_colors return returning
[docs] def check_label( adata, annotation, labels=None, should_exist=True, warning=False ): """ Check if specified labels exist in a given annotation column in adata.obs. This function verifies whether all or none of the specified labels exist in the provided annotation column of an AnnData object. It ensures that the input labels align with the expected categories present in adata.obs[annotation]. Parameters ---------- adata : anndata.AnnData The AnnData object containing the annotation column. annotation : str The name of the annotation column in adata.obs to check against. labels : str or list of str, optional The label or list of labels to check for existence in the specified annotation column. If None, no validation will be performed. should_exist : bool, optional (default=True) Determines whether to check if elements exist in the target column (True), or if they should not exist (False). warning : bool, optional (default=False) If True, generate a warning instead of raising an exception if the specified condition is not met. Raises ------ TypeError If adata is not an instance of anndata.AnnData. ValueError If the specified annotation does not exist in adata.obs. If should_exist is True and any label does not exist in the annotation column. If should_exist is False and any label already exists in the annotation column. Warns ----- UserWarning If the specified condition is not met and warning is True. Example ------- >>> check_label(adata, "cell_type", "B_cell") >>> check_label( ... adata, "cluster", ["Cluster1", "Cluster2"], should_exist=True ... ) """ # Check if annotation exists in adata.obs check_annotation( adata=adata, annotations=annotation, parameter_name="annotation", should_exist=True ) # Convert single label to list if labels is not None: if isinstance(labels, str): labels = [labels] elif not isinstance(labels, list): raise ValueError( "The 'labels' parameter " "should be a string or a list of strings." ) # Get existing labels in the annotation column existing_labels = adata.obs[annotation].unique().tolist() # Validate the labels check_list_in_list( input=labels, input_name="labels", input_type=f"label(s) in the annotation '{annotation}'", target_list=existing_labels, need_exist=should_exist, warning=warning )
[docs] def spell_out_special_characters(text): """ Convert special characters in a string to comply with NIDAP naming rules. This function processes a string by replacing or removing disallowed characters to ensure compatibility with NIDAP. Spaces, special symbols, and certain substrings are replaced or transformed into readable and regulation-compliant equivalents. Parameters ---------- text : str The input string to be processed and converted. Returns ------- str A sanitized string with special characters replaced or removed, adhering to NIDAP naming conventions. Processing Steps ---------------- 1. Spaces are replaced with underscores (`_`). 2. Substrings related to units (e.g., 'µm²') are replaced with text equivalents: - 'µm²' -> 'um2' - 'µm' -> 'um' 3. Hyphens (`-`) between letters are replaced with underscores (`_`). 4. Certain special symbols are mapped to readable equivalents: - `+` -> `_pos_` - `-` -> `_neg_` - `@` -> `at` - `#` -> `hash` - `&` -> `and` - And more (see Notes section for a full mapping). 5. Remaining disallowed characters are removed (non-alphanumeric and non-underscore characters). 6. Consecutive underscores are consolidated into a single underscore. 7. Leading and trailing underscores are stripped. Notes ----- The following special character mappings are used: - `µ` -> `u` - `²` -> `2` - `/` -> `slash` - `=` -> `equals` - `!` -> `exclamation` - `|` -> `pipe` - For a complete list, refer to the `special_char_map` in the code. Example ------- >>> spell_out_special_characters("Data µm²+Analysis #1-2") 'Data_um2_pos_Analysis_hash1_neg_2' >>> spell_out_special_characters("Invalid!Char@Format") 'Invalid_exclamation_Char_at_Format' """ # Replace spaces with underscores text = text.replace(' ', '_') # Replace specific substrings for units text = text.replace('µm²', 'um2') text = text.replace('µm', 'um') # Replace hyphens between letters with '_' text = re.sub(r'(?<=[A-Za-z])-+(?=[A-Za-z])', '_', text) # Replace '+' with '_pos_' and '-' with '_neg_' text = text.replace('+', '_pos_') text = text.replace('-', '_neg_') # Mapping for specific characters special_char_map = { 'µ': 'u', # Micro symbol replaced with 'u' '²': '2', # Superscript two replaced with '2' '@': 'at', '#': 'hash', '$': 'dollar', '%': 'percent', '&': 'and', '*': 'asterisk', '/': 'slash', '\\': 'backslash', '=': 'equals', '^': 'caret', '!': 'exclamation', '?': 'question', '~': 'tilde', # '(': 'open_parenthesis', # ')': 'close_parenthesis', # '{': 'open_brace', # '}': 'close_brace', # '[': 'open_bracket', # ']': 'close_bracket', '|': 'pipe', } # Replace special characters using special_char_map for char, replacement in special_char_map.items(): text = text.replace(char, replacement) # Remove any remaining disallowed characters (non-alphanumeric and non-underscore) text = re.sub(r'[^a-zA-Z0-9_]', '', text) # Remove multiple underscores and strip leading/trailing underscores text = re.sub(r'_+', '_', text) text = text.strip('_') return text