diff --git a/chemap/__init__.py b/chemap/__init__.py index f05e3c8..5395e77 100644 --- a/chemap/__init__.py +++ b/chemap/__init__.py @@ -3,8 +3,8 @@ __all__ = [ + "DatasetLoader", "FingerprintConfig", "compute_fingerprints", - "DatasetLoader", "mol_from_smiles", ] diff --git a/chemap/approx_nn.py b/chemap/approx_nn.py index 74a7db6..1275cb5 100644 --- a/chemap/approx_nn.py +++ b/chemap/approx_nn.py @@ -16,7 +16,7 @@ """ import time -from typing import Any, List, Tuple +from typing import Any import numba import numpy as np from fingerprint_computation import compute_fingerprints_from_smiles @@ -30,8 +30,8 @@ def compound_nearest_neighbors( - smiles: List[str], k_pca: int = 500, k_morgan: int = 100 -) -> Tuple[Any, Any]: + smiles: list[str], k_pca: int = 500, k_morgan: int = 100 +) -> tuple[Any, Any]: """ Compute approximate nearest neighbors for a list of SMILES strings. @@ -78,8 +78,20 @@ def compound_nearest_neighbors( def compute_approx_nearest_neighbors( fingerprints_coarse, fingerprints_fine, k_pca: int = 500, k_morgan: int = 100 -) -> Tuple[Any, Any]: +) -> tuple[Any, Any]: + """Compute approximate nearest neighbors using PCA and Ruzicka similarity. + Parameters + ---------- + fingerprints_coarse: + Dense fingerprints (e.g., 1024-bit) used for PCA-based dimensionality reduction. + fingerprints_fine: + Sparse fingerprints (e.g., 4096-bit) used for refined Ruzicka-based neighbor search. + k_pca: + Number of neighbors to consider in the PCA-based approximate nearest neighbor search. + k_morgan: + Number of neighbors to consider in the refined Ruzicka-based neighbor search. + """ t_start = time.time() print(">" * 20, "Compute PCA vectors") pca = PCA(n_components=100) diff --git a/chemap/benchmarking/fingerprint_duplicates.py b/chemap/benchmarking/fingerprint_duplicates.py index 557d13a..73981e6 100644 --- a/chemap/benchmarking/fingerprint_duplicates.py +++ b/chemap/benchmarking/fingerprint_duplicates.py @@ -1,7 +1,8 @@ import json +from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Any import numpy as np @@ -14,14 +15,14 @@ class DuplicatesNPZ: """CSR-like encoding of duplicate groups.""" indices: np.ndarray # shape (nnz,), int indptr: np.ndarray # shape (n_groups+1,), int - n_items: Optional[int] = None # optional size of the original universe + n_items: int | None = None # optional size of the original universe def encode_duplicates( duplicates: Sequence[Sequence[int]], *, dtype: np.dtype = np.int32, - n_items: Optional[int] = None, + n_items: int | None = None, ) -> DuplicatesNPZ: """Encode list-of-lists duplicate groups into CSR-like arrays.""" # Build indptr @@ -51,7 +52,7 @@ def encode_duplicates( ) -def decode_duplicates(encoded: DuplicatesNPZ) -> List[List[int]]: +def decode_duplicates(encoded: DuplicatesNPZ) -> list[list[int]]: """Decode CSR-like arrays back into list-of-lists.""" indices = np.asarray(encoded.indices) indptr = np.asarray(encoded.indptr) @@ -65,7 +66,7 @@ def decode_duplicates(encoded: DuplicatesNPZ) -> List[List[int]]: if np.any(indptr[1:] < indptr[:-1]): raise ValueError("indptr must be non-decreasing") - out: List[List[int]] = [] + out: list[list[int]] = [] for i in range(indptr.size - 1): start = int(indptr[i]) end = int(indptr[i + 1]) @@ -78,12 +79,12 @@ def decode_duplicates(encoded: DuplicatesNPZ) -> List[List[int]]: # --------------------------------------------------------------------------- def save_duplicates_npz( - filepath: Union[str, Path], + filepath: str | Path, duplicates: Sequence[Sequence[int]], *, - n_items: Optional[int] = None, + n_items: int | None = None, dtype: np.dtype = np.int32, - metadata: Optional[Mapping[str, Any]] = None, + metadata: Mapping[str, Any] | None = None, metadata_suffix: str = ".json", ) -> Path: """Save duplicates to a compressed NPZ file (+ optional JSON metadata sidecar).""" @@ -107,11 +108,11 @@ def save_duplicates_npz( def load_duplicates_npz( - filepath: Union[str, Path], + filepath: str | Path, *, load_metadata: bool = False, metadata_suffix: str = ".json", -) -> Tuple[List[List[int]], Optional[Dict[str, Any]]]: +) -> tuple[list[list[int]], dict[str, Any] | None]: """Load duplicates from NPZ (and optional JSON metadata if present).""" path = Path(filepath) with np.load(path, allow_pickle=False) as z: @@ -119,14 +120,14 @@ def load_duplicates_npz( indptr = z["indptr"] n_items_arr = z.get("n_items", None) - n_items: Optional[int] = None + n_items: int | None = None if n_items_arr is not None: v = int(np.asarray(n_items_arr).reshape(-1)[0]) n_items = None if v < 0 else v duplicates = decode_duplicates(DuplicatesNPZ(indices=indices, indptr=indptr, n_items=n_items)) - meta: Optional[Dict[str, Any]] = None + meta: dict[str, Any] | None = None if load_metadata: meta_path = path.with_suffix(path.suffix + metadata_suffix) if meta_path.exists(): @@ -145,17 +146,17 @@ class PrecomputedDuplicates: """One precomputed experiment entry loaded from disk.""" name: str path_npz: Path - duplicates: List[List[int]] - metadata: Optional[Dict[str, Any]] = None + duplicates: list[list[int]] + metadata: dict[str, Any] | None = None def load_precomputed_duplicates_folder( - folder: Union[str, Path], + folder: str | Path, *, pattern: str = "*_duplicates.npz", - name_from_filename: Optional[Any] = None, + name_from_filename: Any | None = None, load_metadata: bool = False, -) -> List[PrecomputedDuplicates]: +) -> list[PrecomputedDuplicates]: """Load all precomputed duplicate results from a folder. Parameters @@ -178,11 +179,11 @@ def load_precomputed_duplicates_folder( raise FileNotFoundError(f"Folder does not exist: {folder_path}") files = sorted(folder_path.glob(pattern)) - out: List[PrecomputedDuplicates] = [] + out: list[PrecomputedDuplicates] = [] def default_name(p: Path) -> str: stem = p.stem # for "x_duplicates.npz" => "x_duplicates" - return stem[:-11] if stem.endswith("_duplicates") else stem + return stem.removesuffix("_duplicates") name_fn = name_from_filename or default_name diff --git a/chemap/benchmarking/utils.py b/chemap/benchmarking/utils.py index 27100e3..ae75a46 100644 --- a/chemap/benchmarking/utils.py +++ b/chemap/benchmarking/utils.py @@ -1,4 +1,3 @@ -from typing import List import numpy as np @@ -11,7 +10,7 @@ def compute_duplicate_max_mass_differences( """ Compute all maximum mass differences between duplicates. """ - max_diffs: List[float] = [] + max_diffs: list[float] = [] for group in duplicates: idx = np.asarray(group, dtype=int) group_masses = masses[idx] @@ -22,6 +21,9 @@ def compute_duplicate_max_mass_differences( def compute_compound_max_mass_differences(masses): + """ + Compute the maximum mass difference for each compound in a group of masses. + """ all_max_diffs = [] min_mass = masses.min() max_mass = masses.max() diff --git a/chemap/data_loader.py b/chemap/data_loader.py index 1453bc5..05e6a8a 100644 --- a/chemap/data_loader.py +++ b/chemap/data_loader.py @@ -6,6 +6,7 @@ class DatasetLoader: + """Class to load datasets from local files or web sources.""" def __init__(self, cache_dir="./data_cache"): self.cache_dir = cache_dir @@ -51,10 +52,10 @@ def load_collection(self, source: str, **kwargs) -> list: ------------- ValueError if DOI not present. """ - doi_pattern = r'(10\.\d{4,9}/[-._;()/:a-zA-Z0-9]+)' + doi_pattern = r"(10\.\d{4,9}/[-._;()/:a-zA-Z0-9]+)" if not source.startswith("doi") or not bool(re.search(doi_pattern, source)): - ValueError(f"Could not detect DOI in source {source}.") + raise ValueError(f"Could not detect DOI in source {source}.") return self._from_registry(source, **kwargs) diff --git a/chemap/fingerprint_computation.py b/chemap/fingerprint_computation.py index 8e8d9e6..7812da2 100644 --- a/chemap/fingerprint_computation.py +++ b/chemap/fingerprint_computation.py @@ -1,5 +1,6 @@ +from collections.abc import Sequence from dataclasses import dataclass -from typing import Any, Dict, List, Literal, Optional, Protocol, Sequence, Tuple, Union +from typing import Any, Literal, Optional, Protocol import numpy as np import scipy.sparse as sp from joblib import Parallel, delayed @@ -14,9 +15,9 @@ # ----------------------------- InvalidPolicy = Literal["drop", "keep", "raise"] -Scaling = Optional[Literal["log"]] +Scaling = Literal["log"] | None -FingerprintResult = Union[np.ndarray, sp.csr_matrix, UnfoldedBinary, UnfoldedCount] +FingerprintResult = np.ndarray | sp.csr_matrix | UnfoldedBinary | UnfoldedCount @dataclass(frozen=True) @@ -65,8 +66,8 @@ class FingerprintConfig: folded: bool = True return_csr: bool = False # only applies when folded=True scaling: Scaling = None - folded_weights: Optional[np.ndarray] = None - unfolded_weights: Optional[Dict[int, float]] = None + folded_weights: np.ndarray | None = None + unfolded_weights: dict[int, float] | None = None invalid_policy: InvalidPolicy = "keep" @@ -79,11 +80,12 @@ def fit(self, X: Any, y: Any = None) -> "SklearnTransformer": def transform(self, X: Sequence[str]) -> Any: ... - def get_params(self, deep: bool = False) -> Dict[str, Any]: + def get_params(self, deep: bool = False) -> dict[str, Any]: ... class RobustMolTransformer(BaseEstimator, TransformerMixin): + """Sklearn-style transformer that robustly converts SMILES to RDKit Mol objects.""" def __init__(self, n_jobs=-1): self.n_jobs = n_jobs @@ -103,7 +105,7 @@ def transform(self, X): def compute_fingerprints( smiles: Sequence[str], fpgen: Any, - config: FingerprintConfig = FingerprintConfig(), + config: FingerprintConfig | None = None, *, show_progress: bool = False, n_jobs: int = -1, @@ -126,6 +128,9 @@ def compute_fingerprints( - config.count False: List[np.ndarray[int64]] (sorted feature IDs) - config.count True : List[Tuple[np.ndarray[int64], np.ndarray[float32]]] (sorted feature IDs + values) """ + if config is None: + config = FingerprintConfig() + _validate_config(config) if _looks_like_rdkit_fpgen(fpgen): @@ -185,7 +190,7 @@ def _apply_folded_weights_csr(X: sp.csr_matrix, weights: np.ndarray) -> sp.csr_m return X.multiply(w).astype(np.float32) -def _apply_unfolded_weights(keys: np.ndarray, vals: np.ndarray, weights: Dict[int, float]) -> np.ndarray: +def _apply_unfolded_weights(keys: np.ndarray, vals: np.ndarray, weights: dict[int, float]) -> np.ndarray: w = np.array([float(weights.get(int(k), 1.0)) for k in keys], dtype=np.float32) return (vals * w).astype(np.float32, copy=False) @@ -216,7 +221,7 @@ def _empty_unfolded_binary() -> np.ndarray: return np.array([], dtype=np.int64) -def _empty_unfolded_count() -> Tuple[np.ndarray, np.ndarray]: +def _empty_unfolded_count() -> tuple[np.ndarray, np.ndarray]: return np.array([], dtype=np.int64), np.array([], dtype=np.float32) @@ -260,7 +265,7 @@ def mol_from_smiles(smiles: str) -> Optional["Chem.Mol"]: return mol -def _compute_mols_parallel(smiles: Sequence[str], n_jobs: int, show_progress: bool) -> List[Optional["Chem.Mol"]]: +def _compute_mols_parallel(smiles: Sequence[str], n_jobs: int, show_progress: bool) -> list[Optional["Chem.Mol"]]: """ Compute RDKit molecules from SMILES in parallel. """ @@ -329,7 +334,7 @@ def _rdkit_unfolded( if cfg.count: out: UnfoldedCount = [] for s, mol in tqdm( - zip(smiles, mols), + zip(smiles, mols, strict=True), disable=not show_progress, desc="Compute fingerprints", total=len(mols) @@ -351,7 +356,7 @@ def _rdkit_unfolded( out: UnfoldedBinary = [] for s, mol in tqdm( - zip(smiles, mols), + zip(smiles, mols, strict=True), disable=not show_progress, desc="Compute fingerprints", total=len(mols) @@ -381,12 +386,12 @@ def _rdkit_folded_dense( Dense folded output (N, D) float32 for RDKit generators. """ mols = _compute_mols_parallel(smiles, n_jobs, show_progress) - rows: List[np.ndarray] = [] - n_features: Optional[int] = None - pending_invalid: List[int] = [] # indices in `rows` that need backfill after we learn D + rows: list[np.ndarray] = [] + n_features: int | None = None + pending_invalid: list[int] = [] # indices in `rows` that need backfill after we learn D for s, mol in tqdm( - zip(smiles, mols), + zip(smiles, mols, strict=True), disable=not show_progress, desc="Compute fingerprints", total=len(mols) @@ -444,18 +449,18 @@ def _rdkit_folded_csr( - raise: raises ValueError """ mols = _compute_mols_parallel(smiles, n_jobs, show_progress) - n_features: Optional[int] = None + n_features: int | None = None - idx_chunks: List[np.ndarray] = [] - val_chunks: List[np.ndarray] = [] - row_lengths: List[int] = [] + idx_chunks: list[np.ndarray] = [] + val_chunks: list[np.ndarray] = [] + row_lengths: list[int] = [] - w: Optional[np.ndarray] = None + w: np.ndarray | None = None if cfg.folded_weights is not None: w = np.asarray(cfg.folded_weights, dtype=np.float32).ravel() for s, mol in tqdm( - zip(smiles, mols), + zip(smiles, mols, strict=True), disable=not show_progress, desc="Compute fingerprints", total=len(mols) @@ -523,7 +528,7 @@ def _looks_like_sklearn_transformer(fpgen: Any) -> bool: return hasattr(fpgen, "transform") and hasattr(fpgen, "get_params") -def _clone_transformer_with_params(fpgen: SklearnTransformer, updates: Dict[str, Any]) -> SklearnTransformer: +def _clone_transformer_with_params(fpgen: SklearnTransformer, updates: dict[str, Any]) -> SklearnTransformer: params = fpgen.get_params(deep=False) params.update(updates) return fpgen.__class__(**params) # type: ignore[arg-type] @@ -534,7 +539,7 @@ def _resolve_skfp_variant( *, want_folded: bool, want_count: bool, -) -> Optional[str]: +) -> str | None: """ Return the appropriate value for the transformer's `variant` parameter, or None if no variant update is needed / supported. @@ -586,7 +591,7 @@ def _skfp_configure_output( - folded via folded=True """ params = fpgen.get_params(deep=False) - updates: Dict[str, Any] = {} + updates: dict[str, Any] = {} if "verbose" in params: updates["verbose"] = 1 if show_progress else 0 diff --git a/chemap/fingerprint_conversions.py b/chemap/fingerprint_conversions.py index 4a56f40..8a4f6b1 100644 --- a/chemap/fingerprint_conversions.py +++ b/chemap/fingerprint_conversions.py @@ -1,5 +1,6 @@ +from collections.abc import Callable, Sequence from dataclasses import dataclass -from typing import Callable, Dict, Literal, Optional, Sequence, Tuple, Union +from typing import Literal import numpy as np import scipy.sparse as sp @@ -8,11 +9,11 @@ # Types # --------------------------- -CountFingerprint = Tuple[np.ndarray, np.ndarray] # (bits, counts) +CountFingerprint = tuple[np.ndarray, np.ndarray] # (bits, counts) BinaryFingerprint = np.ndarray # (bits,) -FingerprintInput = Union[CountFingerprint, BinaryFingerprint] +FingerprintInput = CountFingerprint | BinaryFingerprint -TFTransform = Optional[Callable[[np.ndarray], np.ndarray]] # for count fingerprints only +TFTransform = Callable[[np.ndarray], np.ndarray] | None # for count fingerprints only @dataclass(frozen=True, slots=True) @@ -20,7 +21,7 @@ class Vocabulary: """Column vocabulary for unfolded fingerprints.""" col_bits: np.ndarray # shape (n_cols,), int64; original bit-id per column df: np.ndarray # shape (n_cols,), int32; document frequency per column (occurrence across rows) - bit_to_col: Optional[Dict[int, int]] = None # optional (can be huge) + bit_to_col: dict[int, int] | None = None # optional (can be huge) @dataclass(frozen=True, slots=True) @@ -28,7 +29,7 @@ class MatrixWithVocab: """Convenience return type.""" X: sp.csr_matrix vocab: Vocabulary - idf: Optional[np.ndarray] = None # shape (n_cols,), float32/float64 + idf: np.ndarray | None = None # shape (n_cols,), float32/float64 # --------------------------- @@ -37,9 +38,9 @@ class MatrixWithVocab: def _resolve_occurrence_thresholds( n_rows: int, - min_occurrence: Optional[int], - max_occurrence: Optional[Union[int, float]], -) -> tuple[Optional[int], Optional[int]]: + min_occurrence: int | None, + max_occurrence: int | float | None, +) -> tuple[int | None, int | None]: if min_occurrence is not None: if not isinstance(min_occurrence, (int, np.integer)): raise TypeError("min_occurrence must be an int or None.") @@ -86,7 +87,7 @@ def _validate_row( row: FingerprintInput, row_idx: int, kind: Literal["count", "binary"], -) -> tuple[np.ndarray, Optional[np.ndarray]]: +) -> tuple[np.ndarray, np.ndarray | None]: """ Return (bits_i64, counts_or_none). Always 1D. """ @@ -113,7 +114,7 @@ def _compute_df_and_order( fingerprints: Sequence[FingerprintInput], *, sort_bits: bool, -) -> tuple[Dict[int, int], Optional[Dict[int, int]], int, Literal["count", "binary"]]: +) -> tuple[dict[int, int], dict[int, int] | None, int, Literal["count", "binary"]]: """ Compute document frequency df(bit) = number of rows where bit appears at least once. @@ -134,8 +135,8 @@ def _compute_df_and_order( return {}, None, 0, "binary" kind = _infer_kind(fingerprints[0]) - df: Dict[int, int] = {} - order: Optional[Dict[int, int]] = {} if not sort_bits else None + df: dict[int, int] = {} + order: dict[int, int] | None = {} if not sort_bits else None nnz_ub = 0 for i, row in enumerate(fingerprints): @@ -168,14 +169,14 @@ def _compute_df_and_order( def _build_vocab( - df_dict: Dict[int, int], - order: Optional[Dict[int, int]], + df_dict: dict[int, int], + order: dict[int, int] | None, *, n_rows: int, sort_bits: bool, return_bit_to_col: bool, - min_occurrence: Optional[int], - max_occurrence: Optional[Union[int, float]], + min_occurrence: int | None, + max_occurrence: int | float | None, ) -> Vocabulary: """ Build filtered vocabulary (col_bits + df array + optional bit_to_col). @@ -211,7 +212,7 @@ def _build_vocab( col_bits = all_bits[keep] df_kept = df_all[keep] - bit_to_col: Optional[Dict[int, int]] = None + bit_to_col: dict[int, int] | None = None if return_bit_to_col: bit_to_col = {int(b): int(j) for j, b in enumerate(col_bits)} @@ -225,13 +226,13 @@ def _build_vocab( def fingerprints_to_csr( fingerprints: Sequence[FingerprintInput], *, - dtype: Union[np.dtype, type] = np.float32, + dtype: np.dtype | type = np.float32, sort_bits: bool = True, sort_indices_within_rows: bool = True, consolidate_duplicates_within_rows: bool = True, return_bit_to_col: bool = False, - min_occurrence: Optional[int] = None, - max_occurrence: Optional[Union[int, float]] = None, + min_occurrence: int | None = None, + max_occurrence: int | float | None = None, tf_transform: TFTransform = None, ) -> MatrixWithVocab: """ @@ -365,7 +366,6 @@ def fingerprints_to_csr( if tf_transform is not None: vals = tf_transform(vals) else: - uniq_bits = uniq_bits vals = np.ones(uniq_bits.shape[0], dtype=dtype) else: uniq_bits = bits_i64 @@ -512,13 +512,13 @@ def idf_normalized(df: np.ndarray, N: int) -> np.ndarray: def fingerprints_to_tfidf( fingerprints: Sequence[FingerprintInput], *, - dtype: Union[np.dtype, type] = np.float32, + dtype: np.dtype | type = np.float32, sort_bits: bool = True, sort_indices_within_rows: bool = True, consolidate_duplicates_within_rows: bool = True, return_bit_to_col: bool = False, - min_occurrence: Optional[int] = None, - max_occurrence: Optional[Union[int, float]] = None, + min_occurrence: int | None = None, + max_occurrence: int | float | None = None, tf_transform: TFTransform = None, ) -> MatrixWithVocab: """ @@ -640,13 +640,13 @@ def fingerprints_to_csr_folded( fingerprints: Sequence[FingerprintInput], *, n_folded_features: int, - dtype: Union[np.dtype, type] = np.float32, + dtype: np.dtype | type = np.float32, sort_bits: bool = True, sort_indices_within_rows: bool = True, consolidate_duplicates_within_rows: bool = True, return_bit_to_col: bool = False, - min_occurrence: Optional[int] = None, - max_occurrence: Optional[Union[int, float]] = None, + min_occurrence: int | None = None, + max_occurrence: int | float | None = None, tf_transform: TFTransform = None, ) -> MatrixWithVocab: """ @@ -710,13 +710,13 @@ def fingerprints_to_tfidf_folded( fingerprints: Sequence[FingerprintInput], *, n_folded_features: int, - dtype: Union[np.dtype, type] = np.float32, + dtype: np.dtype | type = np.float32, sort_bits: bool = True, sort_indices_within_rows: bool = True, consolidate_duplicates_within_rows: bool = True, return_bit_to_col: bool = False, - min_occurrence: Optional[int] = None, - max_occurrence: Optional[Union[int, float]] = None, + min_occurrence: int | None = None, + max_occurrence: int | float | None = None, tf_transform: TFTransform = None, ) -> MatrixWithVocab: """ @@ -857,7 +857,7 @@ def _fingerprints_to_csr_with_vocab( fingerprints: Sequence[FingerprintInput], vocab: Vocabulary, *, - dtype: Union[np.dtype, type] = np.float32, + dtype: np.dtype | type = np.float32, sort_indices_within_rows: bool = True, consolidate_duplicates_within_rows: bool = True, tf_transform: TFTransform = None, @@ -957,13 +957,13 @@ def fingerprints_to_csr_frequency_folded( fingerprints: Sequence[FingerprintInput], *, n_frequency_features: int, - dtype: Union[np.dtype, type] = np.float32, + dtype: np.dtype | type = np.float32, sort_bits: bool = True, sort_indices_within_rows: bool = True, consolidate_duplicates_within_rows: bool = True, return_bit_to_col: bool = False, - min_occurrence: Optional[int] = None, - max_occurrence: Optional[Union[int, float]] = None, + min_occurrence: int | None = None, + max_occurrence: int | float | None = None, tf_transform: TFTransform = None, exclude_constant_bits: bool = True, ) -> MatrixWithVocab: diff --git a/chemap/fingerprint_statistics.py b/chemap/fingerprint_statistics.py index d50292a..46f29e2 100644 --- a/chemap/fingerprint_statistics.py +++ b/chemap/fingerprint_statistics.py @@ -44,12 +44,10 @@ def _unfolded_fingerprint_bit_statistics( count_arr = np.empty(n, dtype=np.int32) first_arr = np.empty(n, dtype=np.int32) - idx = 0 - for key in counts: - unique_keys[idx] = key - count_arr[idx] = counts[key] - first_arr[idx] = first_instance[key] - idx += 1 + for i, key in enumerate(counts): + unique_keys[i] = key + count_arr[i] = counts[key] + first_arr[i] = first_instance[key] order = np.argsort(unique_keys) return unique_keys[order], count_arr[order], first_arr[order] diff --git a/chemap/fingerprints/element_count_fp.py b/chemap/fingerprints/element_count_fp.py index bdd8779..37abf89 100644 --- a/chemap/fingerprints/element_count_fp.py +++ b/chemap/fingerprints/element_count_fp.py @@ -1,4 +1,5 @@ -from typing import Any, Dict, List, Optional, Sequence +from collections.abc import Sequence +from typing import Any import numpy as np import scipy.sparse as sp from joblib import Parallel, delayed @@ -30,7 +31,7 @@ class ElementCountFingerprint(BaseEstimator, TransformerMixin): def __init__( self, *, - elements: Optional[Sequence[str]] = None, + elements: Sequence[str] | None = None, include_hs: str = "implicit", # "implicit" | "explicit" | "none" unknown_policy: str = "other", # "ignore" | "other" | "error" sparse: bool = False, @@ -88,7 +89,7 @@ def fit(self, X: Sequence[Any], y: Any = None) -> "ElementCountFingerprint": elems.append("Other") self.elements_ = elems - self._elem2idx_: Dict[str, int] = {e: i for i, e in enumerate(self.elements_)} + self._elem2idx_: dict[str, int] = {e: i for i, e in enumerate(self.elements_)} self.n_features_in_ = len(self.elements_) return self @@ -150,7 +151,7 @@ def fp_row(mol) -> np.ndarray: return out - rows: List[np.ndarray] = Parallel(n_jobs=self.n_jobs, verbose=self.verbose)( + rows: list[np.ndarray] = Parallel(n_jobs=self.n_jobs, verbose=self.verbose)( delayed(fp_row)(mol) for mol in X ) diff --git a/chemap/fingerprints/map4.py b/chemap/fingerprints/map4.py index c38ce47..6886b31 100644 --- a/chemap/fingerprints/map4.py +++ b/chemap/fingerprints/map4.py @@ -22,7 +22,6 @@ from collections import defaultdict from dataclasses import dataclass from hashlib import sha1 -from typing import Dict, List, Optional, Set import numpy as np from rdkit.Chem import Mol, MolToSmiles, PathToSubmol from rdkit.Chem.rdmolops import FindAtomEnvironmentOfRadiusN, GetDistanceMatrix @@ -36,8 +35,8 @@ @dataclass(frozen=True) class _SparseCountFingerprint: """RDKit SparseIntVect-like shim for chemap.""" - nz: Dict[int, int] - def GetNonzeroElements(self) -> Dict[int, int]: + nz: dict[int, int] + def GetNonzeroElements(self) -> dict[int, int]: return self.nz @@ -77,8 +76,8 @@ def __init__( radius: int = 2, *, include_duplicated_shingles: bool = False, - max_dist: Optional[int] = None, - dist_binning: Optional[np.ndarray] = None, + max_dist: int | None = None, + dist_binning: np.ndarray | None = None, ): if radius <= 0: raise ValueError("radius must be > 0.") @@ -87,14 +86,14 @@ def __init__( self.max_dist = max_dist self.dist_binning = dist_binning - def shingles_unique(self, mol: Mol) -> Set[bytes]: + def shingles_unique(self, mol: Mol) -> set[bytes]: return set(self._all_pairs(mol, self._get_atom_envs(mol))) - def shingles_with_counts_true(self, mol: Mol) -> Dict[bytes, int]: + def shingles_with_counts_true(self, mol: Mol) -> dict[bytes, int]: """ True multiplicities (counts) WITHOUT suffix trick, regardless of include_duplicated_shingles. """ - counts: Dict[bytes, int] = defaultdict(int) + counts: dict[bytes, int] = defaultdict(int) for sh in self._all_pairs(mol, self._get_atom_envs(mol), force_no_suffix=True): counts[sh] += 1 return dict(counts) @@ -104,8 +103,8 @@ def _convert_dist(self, dist: float) -> int: return int(dist) return int(np.digitize(dist, self.dist_binning, right=True)) - def _get_atom_envs(self, mol: Mol) -> Dict[int, List[Optional[str]]]: - atoms_env: Dict[int, List[Optional[str]]] = {} + def _get_atom_envs(self, mol: Mol) -> dict[int, list[str | None]]: + atoms_env: dict[int, list[str | None]] = {} for atom in mol.GetAtoms(): atom_identifier = atom.GetIdx() for r in range(1, self.radius + 1): @@ -115,11 +114,11 @@ def _get_atom_envs(self, mol: Mol) -> Dict[int, List[Optional[str]]]: return atoms_env @staticmethod - def _find_env(mol: Mol, atom_identifier: int, radius: int) -> Optional[str]: - atom_identifiers_within_radius: List[int] = FindAtomEnvironmentOfRadiusN( + def _find_env(mol: Mol, atom_identifier: int, radius: int) -> str | None: + atom_identifiers_within_radius: list[int] = FindAtomEnvironmentOfRadiusN( mol=mol, radius=radius, rootedAtAtom=atom_identifier ) - atom_map: Dict[int, int] = {} + atom_map: dict[int, int] = {} sub_molecule: Mol = PathToSubmol(mol, atom_identifiers_within_radius, atomMap=atom_map) if atom_identifier not in atom_map: @@ -135,18 +134,18 @@ def _find_env(mol: Mol, atom_identifier: int, radius: int) -> Optional[str]: def _all_pairs( self, mol: Mol, - atoms_env: Dict[int, List[Optional[str]]], + atoms_env: dict[int, list[str | None]], *, force_no_suffix: bool = False, - ) -> List[bytes]: + ) -> list[bytes]: """ Return shingles as bytes. If include_duplicated_shingles is enabled and not forced off, suffix trick is applied to make duplicates unique (MAP4C-style behavior). """ - out: List[bytes] = [] + out: list[bytes] = [] dm = GetDistanceMatrix(mol) n = mol.GetNumAtoms() - shingle_dict: Dict[str, int] = defaultdict(int) + shingle_dict: dict[str, int] = defaultdict(int) for i, j in itertools.combinations(range(n), 2): dist_val = float(dm[i][j]) @@ -220,8 +219,8 @@ def __init__( minhash_for_unfolded: bool = False, unfolded_bits: int = 32, # 32 or 64, only if minhash_for_unfolded=False # optional distance handling - max_dist: Optional[int] = None, - dist_binning: Optional[np.ndarray] = None, + max_dist: int | None = None, + dist_binning: np.ndarray | None = None, ): self.dimensions = int(dimensions) self.radius = int(radius) @@ -273,7 +272,7 @@ def GetSparseCountFingerprint(self, mol: Mol) -> _SparseCountFingerprint: if not counts: return _SparseCountFingerprint({}) - nz: Dict[int, int] = defaultdict(int) + nz: dict[int, int] = defaultdict(int) if self.minhash_for_unfolded: # MHFP token-hash domain: sha1 first 4 bytes (little endian) diff --git a/chemap/fingerprints/mhfp.py b/chemap/fingerprints/mhfp.py index 41cb17b..0152736 100644 --- a/chemap/fingerprints/mhfp.py +++ b/chemap/fingerprints/mhfp.py @@ -1,10 +1,10 @@ import struct +from collections.abc import Iterable, Sequence from hashlib import sha1 -from typing import Iterable, Sequence, Union import numpy as np -BytesLike = Union[bytes, bytearray, memoryview] +BytesLike = bytes | bytearray | memoryview class MHFPEncoderLite: diff --git a/chemap/mbp.py b/chemap/mbp.py index 0d10827..ab903cd 100644 --- a/chemap/mbp.py +++ b/chemap/mbp.py @@ -1,7 +1,7 @@ import itertools from collections import defaultdict +from collections.abc import Iterable from multiprocessing.dummy import Pool as ThreadPool -from typing import Dict, Iterable, List, Optional, Set, Tuple import numpy as np from mhfp.encoder import MHFPEncoder from rdkit.Chem import AllChem, Mol @@ -93,14 +93,14 @@ def calculate_sparse(self, mol: Mol, count: bool = False) -> np.ndarray: order = np.argsort(bits_hashed) return bits_hashed[order], counts[order] else: - atom_env_pairs: Set[str] = self._calculate(mol, count) + atom_env_pairs: set[str] = self._calculate(mol, count) return np.sort(self.encoder.hash(atom_env_pairs)) def calculate_many( self, mols: Iterable[Mol], count: bool = False, - number_of_threads: Optional[int] = None, + number_of_threads: int | None = None, verbose: bool = False, ) -> np.ndarray: """ @@ -137,7 +137,7 @@ def calculate_many( def calculate_many_sparse( self, mols: Iterable[Mol], - number_of_threads: Optional[int] = None, + number_of_threads: int | None = None, count: bool = False, verbose: bool = False, ) -> np.ndarray: @@ -180,16 +180,16 @@ def calculate_many_sparse( pool.join() return results - def _calculate(self, mol: Mol, count: bool = False) -> Set[str]: + def _calculate(self, mol: Mol, count: bool = False) -> set[str]: """ For a given molecule, return the set (or dict if count=True) of shingles. Shingles are built by pairing the Morgan fingerprint bits (for each radius 0...radius) from each atom with every other atom, together with the distance between them. """ - atoms_bits: Dict[int, List[Optional[str]]] = self._get_atom_bits(mol) + atoms_bits: dict[int, list[str | None]] = self._get_atom_bits(mol) return self._all_pairs(mol, atoms_bits, count=count) - def _fold(self, pairs: Set[str]) -> np.ndarray: + def _fold(self, pairs: set[str]) -> np.ndarray: """ Folds the fingerprint using the MinHash encoder. @@ -206,7 +206,7 @@ def _fold(self, pairs: Set[str]) -> np.ndarray: fp_hash = self.encoder.hash(pairs) return self.encoder.fold(fp_hash, self.dimensions) - def fold_count(self, shingle_counts: Dict[str, int]) -> np.ndarray: + def fold_count(self, shingle_counts: dict[str, int]) -> np.ndarray: """ Folds the fingerprint using the MinHash encoder and counts. @@ -238,7 +238,7 @@ def _convert_dist(self, dist): dist = np.digitize(dist, self.dist_binning, right=True) return dist - def _get_atom_bits(self, mol: Mol) -> Dict[int, List[Optional[str]]]: + def _get_atom_bits(self, mol: Mol) -> dict[int, list[str | None]]: """ Compute the Morgan fingerprint bits for each atom in the molecule. @@ -258,10 +258,10 @@ def _get_atom_bits(self, mol: Mol) -> Dict[int, List[Optional[str]]]: of Morgan bit strings. If a bit is not found for a given radius, the slot remains None. """ - atoms_bits: Dict[int, List[Optional[str]]] = { + atoms_bits: dict[int, list[str | None]] = { atom.GetIdx(): [None] * (self.radius + 1) for atom in mol.GetAtoms() } - bitInfo: Dict[int, List[Tuple[int, int]]] = {} + bitInfo: dict[int, list[tuple[int, int]]] = {} ao = AllChem.AdditionalOutput() ao.CollectBitInfoMap() @@ -275,9 +275,9 @@ def _get_atom_bits(self, mol: Mol) -> Dict[int, List[Optional[str]]]: return atoms_bits def _all_pairs( - self, mol: Mol, atoms_bits: Dict[int, List[Optional[str]]], + self, mol: Mol, atoms_bits: dict[int, list[str | None]], count: bool = False - ) -> Set[str]: + ) -> set[str]: """ Build the set (or dict if count=True) of shingle strings from pairs of atoms. @@ -306,7 +306,7 @@ def _all_pairs( if count: atom_pairs = {} else: - atom_pairs: Set[str] = set() + atom_pairs: set[str] = set() distance_matrix = GetDistanceMatrix(mol) num_atoms = mol.GetNumAtoms() shingle_dict = defaultdict(int) diff --git a/chemap/metrics.py b/chemap/metrics.py index 35b17b9..6c06113 100644 --- a/chemap/metrics.py +++ b/chemap/metrics.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, Tuple, Union +from typing import Literal import numba import numpy as np import scipy.sparse as sp @@ -10,8 +10,8 @@ # Unfolded inputs UnfoldedBinary = np.ndarray -UnfoldedCount = Tuple[np.ndarray, np.ndarray] -UnfoldedFingerprint = Union[UnfoldedBinary, UnfoldedCount] +UnfoldedCount = tuple[np.ndarray, np.ndarray] +UnfoldedFingerprint = UnfoldedBinary | UnfoldedCount # Dense / sparse fixed-size DenseVector = np.ndarray @@ -107,6 +107,7 @@ def tanimoto_similarity_sparse_binary(bits1: np.ndarray, bits2: np.ndarray) -> f @numba.njit(cache=True, fastmath=True) def tanimoto_distance_sparse_binary(bits1: np.ndarray, bits2: np.ndarray) -> float: + """Distance = 1 - similarity.""" return 1.0 - tanimoto_similarity_sparse_binary(bits1, bits2) @@ -185,6 +186,7 @@ def tanimoto_distance_sparse(ind1, data1, ind2, data2) -> float: @numba.njit(cache=True, fastmath=True) def tanimoto_similarity_sparse(ind1, data1, ind2, data2) -> float: + """Similarity = 1 - distance.""" return 1.0 - tanimoto_distance_sparse(ind1, data1, ind2, data2) @@ -276,7 +278,7 @@ def tanimoto_similarity_matrix_sparse( # High-level Python convenience wrappers # --------------------------- -def _as_1xD_csr(x: Union[np.ndarray, sp.csr_matrix]) -> sp.csr_matrix: +def _as_1xD_csr(x: np.ndarray | sp.csr_matrix) -> sp.csr_matrix: """Convert input to a 1xD csr_matrix.""" if sp.isspmatrix_csr(x): if x.shape[0] == 1: @@ -291,10 +293,10 @@ def _as_1xD_csr(x: Union[np.ndarray, sp.csr_matrix]) -> sp.csr_matrix: def tanimoto_similarity( - a: Union[DenseVector, sp.csr_matrix, UnfoldedFingerprint], - b: Union[DenseVector, sp.csr_matrix, UnfoldedFingerprint], + a: DenseVector | sp.csr_matrix | UnfoldedFingerprint, + b: DenseVector | sp.csr_matrix | UnfoldedFingerprint, *, - kind: Optional[Literal["dense", "sparse", "unfolded-binary", "unfolded-count"]] = None, + kind: Literal["dense", "sparse", "unfolded-binary", "unfolded-count"] | None = None, ) -> float: """ Function to compute Tanimoto similarity between two fingerprints/vectors. @@ -363,8 +365,8 @@ def tanimoto_similarity( def tanimoto_similarity_matrix( - references: Union[DenseMatrix, sp.csr_matrix], - queries: Union[DenseMatrix, sp.csr_matrix], + references: DenseMatrix | sp.csr_matrix, + queries: DenseMatrix | sp.csr_matrix, *, kind: Literal["dense", "sparse"] = "dense", ) -> np.ndarray: diff --git a/chemap/plotting/benchmark_duplicates.py b/chemap/plotting/benchmark_duplicates.py index ebe755f..5f11c3e 100644 --- a/chemap/plotting/benchmark_duplicates.py +++ b/chemap/plotting/benchmark_duplicates.py @@ -1,5 +1,6 @@ +from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Any, List, Mapping, Optional, Sequence, Tuple +from typing import Any import matplotlib.pyplot as plt import numpy as np from chemap.benchmarking import compute_duplicate_max_mass_differences @@ -18,7 +19,7 @@ class DuplicateBinResult: """Binned duplicate statistics for one experiment/dataset.""" name: str bin_edges: Bins - bin_labels: List[str] + bin_labels: list[str] bin_counts: np.ndarray # shape (n_bins,) total: int @@ -28,8 +29,8 @@ def default_bins_da() -> Bins: return [(0, 1), (1, 10), (10, 50), (50, 100), (100, 200), (200, 400), (400, np.inf)] -def _format_bin_labels(bins: Bins, unit: str = "Da") -> List[str]: - labels: List[str] = [] +def _format_bin_labels(bins: Bins, unit: str = "Da") -> list[str]: + labels: list[str] = [] for low, high in bins: if np.isinf(high): labels.append(f"{low:g}-inf {unit}") @@ -42,7 +43,7 @@ def compute_duplicate_bin_counts( duplicates: Sequence[Sequence[int]], masses: Sequence[float], *, - bins: Optional[Bins] = None, + bins: Bins | None = None, unit: str = "Da", name: str = "experiment", ) -> DuplicateBinResult: @@ -105,7 +106,7 @@ def compute_duplicate_bin_counts( def plot_duplicate_bins( results: Sequence[DuplicateBinResult], *, - figsize: Tuple[float, float] = (10, 6), + figsize: tuple[float, float] = (10, 6), sort_by_total: bool = True, cmap = green_yellow_red, bar_height: float = 0.5, @@ -114,8 +115,8 @@ def plot_duplicate_bins( xlabel: str = "Compounds with Fingerprint Duplicates", title: str = "Duplicate Statistics by Experiment", legend_title: str = "Maximum mass difference\n(for identical fingerprints)", - ax: Optional[plt.Axes] = None, -) -> Tuple[plt.Figure, plt.Axes]: + ax: plt.Axes | None = None, +) -> tuple[plt.Figure, plt.Axes]: """Plot stacked horizontal bars of duplicate counts across bins. Parameters @@ -202,15 +203,15 @@ def plot_duplicates_by_experiment( experiments: Mapping[str, Mapping[str, Any]], masses_arr: np.ndarray, *, - bins: Optional[Bins] = None, + bins: Bins | None = None, unit: str = "Da", # plot options cmap = green_yellow_red, title: str = "Duplicate fingerprints plot", - figsize: Tuple[float, float] = (10, 6), - ax: Optional[plt.Axes] = None, + figsize: tuple[float, float] = (10, 6), + ax: plt.Axes | None = None, sort_by_total: bool = True, -) -> Tuple[plt.Figure, plt.Axes, List[DuplicateBinResult]]: +) -> tuple[plt.Figure, plt.Axes, list[DuplicateBinResult]]: """Compute binned duplicate stats per experiment and plot them. Parameters @@ -223,7 +224,7 @@ def plot_duplicates_by_experiment( figsize, sort_by_total, cmap: Passed to `plot_duplicate_bins`. """ - results: List[DuplicateBinResult] = [] + results: list[DuplicateBinResult] = [] for name, duplicates in experiments.items(): res = compute_duplicate_bin_counts( duplicates, diff --git a/chemap/plotting/chem_space_umap.py b/chemap/plotting/chem_space_umap.py index 8fe28b5..14954ff 100644 --- a/chemap/plotting/chem_space_umap.py +++ b/chemap/plotting/chem_space_umap.py @@ -1,5 +1,5 @@ from dataclasses import replace -from typing import Any, Optional +from typing import Any import numpy as np import pandas as pd from chemap import FingerprintConfig, compute_fingerprints @@ -54,15 +54,15 @@ def create_chem_space_umap( x_col: str = "x", y_col: str = "y", # fingerprinting - fpgen: Optional[Any] = None, - fingerprint_config: Optional[FingerprintConfig] = None, + fpgen: Any | None = None, + fingerprint_config: FingerprintConfig | None = None, show_progress: bool = True, scaling: str = None, # UMAP (CPU / umap-learn) n_neighbors: int = 100, min_dist: float = 0.25, n_jobs: int = -1, - umap_random_state: Optional[int] = None, + umap_random_state: int | None = None, distance_function: str = "tanimoto", ) -> pd.DataFrame: """Compute fingerprints (CPU) and create 2D UMAP coordinates (CPU). @@ -170,8 +170,8 @@ def create_chem_space_umap_gpu( x_col: str = "x", y_col: str = "y", # fingerprinting - fpgen: Optional[Any] = None, - fingerprint_config: Optional[FingerprintConfig] = None, + fpgen: Any | None = None, + fingerprint_config: FingerprintConfig | None = None, show_progress: bool = True, scaling: str = None, # UMAP (GPU / cuML) diff --git a/chemap/plotting/cleveland.py b/chemap/plotting/cleveland.py index 6214f2f..e944e65 100644 --- a/chemap/plotting/cleveland.py +++ b/chemap/plotting/cleveland.py @@ -1,5 +1,5 @@ +from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Dict, Mapping, Optional, Sequence, Tuple import matplotlib.pyplot as plt import numpy as np from matplotlib.axes import Axes @@ -10,7 +10,7 @@ @dataclass(frozen=True) class ClevelandStyle: """Styling defaults for a Cleveland-ish dot plot.""" - figsize: Tuple[float, float] = (9.0, 6.0) + figsize: tuple[float, float] = (9.0, 6.0) dpi: int = 600 markersize: float = 7.0 markeredgecolor: str = "white" @@ -28,23 +28,23 @@ def cleveland_dotplot( # Data in "tidy" arrays row: Sequence[str], x: Sequence[float], - color_group: Optional[Sequence[str]] = None, - marker_group: Optional[Sequence[str]] = None, - connect_group: Optional[Sequence[str]] = None, - marker_zorder: Optional[Mapping[str, float]] = None, + color_group: Sequence[str] | None = None, + marker_group: Sequence[str] | None = None, + connect_group: Sequence[str] | None = None, + marker_zorder: Mapping[str, float] | None = None, # Ordering / labels - row_order: Optional[Sequence[str]] = None, + row_order: Sequence[str] | None = None, row_label_fn=None, # Mappings - color_map: Optional[Dict[str, str]] = None, - marker_map: Optional[Dict[str, str]] = None, + color_map: dict[str, str] | None = None, + marker_map: dict[str, str] | None = None, # Figure/axes title: str = "", xlabel: str = "", - ax: Optional[Axes] = None, + ax: Axes | None = None, # Behavior connect: bool = True, @@ -63,8 +63,8 @@ def cleveland_dotplot( color_legend_position: str = "lower left", marker_legend_position: str = "lower right", - style: ClevelandStyle = ClevelandStyle(), -) -> Tuple[Figure, Axes]: + style: ClevelandStyle | None = None, +) -> tuple[Figure, Axes]: """ Generic Cleveland-ish dot plot. @@ -147,6 +147,8 @@ def cleveland_dotplot( # --- axes setup --- if ax is None: fig_h = max(2.5, len(row_order) * 0.28) + if style is None: + style = ClevelandStyle() fig, ax = plt.subplots(figsize=(style.figsize[0], fig_h), dpi=style.dpi) else: fig = ax.figure @@ -155,7 +157,7 @@ def cleveland_dotplot( if row_range: from collections import defaultdict xs_by_row = defaultdict(list) - for r, xv in zip(row, x): + for r, xv in zip(row, x, strict=True): xs_by_row[r].append(float(xv)) for r in row_order: @@ -175,14 +177,14 @@ def cleveland_dotplot( # --- optional connectors --- if connect: # For each (row, connect_group), connect min->max x - key_arr = list(zip(row, connect_group)) + key_arr = list(zip(row, connect_group, strict=True)) # group indices by key from collections import defaultdict idx_by_key = defaultdict(list) for i, k in enumerate(key_arr): idx_by_key[k].append(i) - for (r, cg), idxs in idx_by_key.items(): + for (r, _cg), idxs in idx_by_key.items(): if len(idxs) < 2: continue xs = x[idxs] diff --git a/chemap/plotting/colormap_handling.py b/chemap/plotting/colormap_handling.py index 37726f0..517db09 100644 --- a/chemap/plotting/colormap_handling.py +++ b/chemap/plotting/colormap_handling.py @@ -1,6 +1,7 @@ import re +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass -from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Any import matplotlib as mpl import matplotlib.colors as mcolors import numpy as np @@ -33,10 +34,10 @@ class LabelMapConfig: sep: str = "->" # If provided, keep only top_k_classes as explicit labels in huge superclasses - top_k_classes: Optional[int] = None + top_k_classes: int | None = None -def _normalize_label_value(x: Any) -> Optional[str]: +def _normalize_label_value(x: Any) -> str | None: """Normalize a label cell value. Returns None for missing / unknown-like values, else a stripped string. @@ -54,8 +55,8 @@ def _normalize_label_value(x: Any) -> Optional[str]: def build_hier_label_map( labels: pd.DataFrame, *, - config: LabelMapConfig = LabelMapConfig(), -) -> Tuple[Dict[str, str], Dict[str, Dict[str, int | str]]]: + config: LabelMapConfig | None = None, +) -> tuple[dict[str, str], dict[str, dict[str, int | str]]]: """Build a mapping from fine-grained class labels to display labels. Parameters @@ -86,6 +87,9 @@ def build_hier_label_map( if not isinstance(labels, pd.DataFrame): raise TypeError("labels must be a pandas DataFrame") + if config is None: + config = LabelMapConfig() + missing_cols = [c for c in (config.superclass_col, config.class_col) if c not in labels.columns] if missing_cols: raise KeyError(f"labels is missing required columns: {missing_cols}") @@ -118,8 +122,8 @@ def build_hier_label_map( .astype(int) ) - class_to_label: Dict[str, str] = {} - superclass_info: Dict[str, Dict[str, int | str]] = {} + class_to_label: dict[str, str] = {} + superclass_info: dict[str, dict[str, int | str]] = {} for superclass, sc_count in superclass_counts.items(): # Series indexed by class for this superclass @@ -156,7 +160,7 @@ def build_hier_label_map( superclass_info[str(superclass)] = { "count": int(sc_count), "branch": branch, - "n_classes": int(len(cls_counts)), + "n_classes": len(cls_counts), } return class_to_label, superclass_info @@ -181,11 +185,11 @@ class PaletteConfig: child_lighten_min: float = 0.15 child_lighten_max: float = 0.65 - neutral_rare: Tuple[float, float, float] = (0.6, 0.6, 0.6) - neutral_other: Tuple[float, float, float] = (0.35, 0.35, 0.35) + neutral_rare: tuple[float, float, float] = (0.6, 0.6, 0.6) + neutral_other: tuple[float, float, float] = (0.35, 0.35, 0.35) -_SUBLABEL_RE_CACHE: Dict[str, re.Pattern[str]] = {} +_SUBLABEL_RE_CACHE: dict[str, re.Pattern[str]] = {} def _get_sub_re(sep: str) -> re.Pattern[str]: @@ -196,21 +200,21 @@ def _get_sub_re(sep: str) -> re.Pattern[str]: return pat -def _lighten_rgb(rgb: Tuple[float, float, float], amount: float) -> Tuple[float, float, float]: +def _lighten_rgb(rgb: tuple[float, float, float], amount: float) -> tuple[float, float, float]: """Blend `rgb` towards white by `amount` in [0, 1].""" amount = float(np.clip(amount, 0.0, 1.0)) r, g, b = rgb return (r + (1.0 - r) * amount, g + (1.0 - g) * amount, b + (1.0 - b) * amount) -def _get_cmap(cmap: Union[str, mpl.colors.Colormap]) -> mpl.colors.Colormap: +def _get_cmap(cmap: str | mpl.colors.Colormap) -> mpl.colors.Colormap: """Matplotlib 3.7+ safe colormap retrieval.""" if isinstance(cmap, str): return mpl.colormaps.get_cmap(cmap) return cmap -def _distinct_base_colors(n: int, cmap_name: str) -> list[Tuple[float, float, float]]: +def _distinct_base_colors(n: int, cmap_name: str) -> list[tuple[float, float, float]]: """Get n distinct colors from a matplotlib colormap, as RGB tuples. Uses the non-deprecated Matplotlib colormap registry API. @@ -225,8 +229,8 @@ def _distinct_base_colors(n: int, cmap_name: str) -> list[Tuple[float, float, fl def make_hier_palette( display_labels: Iterable[Any], *, - config: PaletteConfig = PaletteConfig(), -) -> Dict[str, Tuple[float, float, float]]: + config: PaletteConfig | None = None, +) -> dict[str, tuple[float, float, float]]: """Create a hierarchical color palette for plot-ready display labels. Parameters @@ -252,6 +256,9 @@ def make_hier_palette( - "...->other" gets `config.neutral_other`. - `config.rare_label` gets `config.neutral_rare`. """ + if config is None: + config = PaletteConfig() + # Normalize, drop NA, preserve uniqueness with stable ordering s = pd.Series(list(display_labels)) s = s[~s.isna()].map(lambda x: str(x)) @@ -264,7 +271,7 @@ def make_hier_palette( sub_re = _get_sub_re(config.sep) - super_to_children: Dict[str, list[str]] = {} + super_to_children: dict[str, list[str]] = {} pure_super: set[str] = set() for lab in unique_labels: @@ -279,9 +286,9 @@ def make_hier_palette( base_supers = sorted(pure_super | set(super_to_children.keys())) base_colors = _distinct_base_colors(len(base_supers), config.base_cmap) - super_to_base: Dict[str, Tuple[float, float, float]] = dict(zip(base_supers, base_colors, strict=True)) + super_to_base: dict[str, tuple[float, float, float]] = dict(zip(base_supers, base_colors, strict=True)) - label_to_color: Dict[str, Tuple[float, float, float]] = {} + label_to_color: dict[str, tuple[float, float, float]] = {} # Pure superclass colors for sup in pure_super: @@ -364,11 +371,11 @@ class PresentPairsConfig: subclass_col: str = "Subclass" # Optional explicit global ordering for classes. - class_order: Optional[Sequence[str]] = None + class_order: Sequence[str] | None = None # Optional ordering for subclasses within a class: # { "Lipids": ["Fatty acids", "Steroids", ...], "Alkaloids": [...], ... } - subclass_order_within_class: Optional[Mapping[str, Sequence[str]]] = None + subclass_order_within_class: Mapping[str, Sequence[str]] | None = None # If True, ensure class/subclass values are normalized by stripping whitespace. strip: bool = True @@ -390,7 +397,7 @@ def _normalize_for_sorting(x: Any, *, strip: bool = True) -> str: def sorted_present_pairs( data_plot: pd.DataFrame, *, - config: PresentPairsConfig = PresentPairsConfig(), + config: PresentPairsConfig | None = None, ) -> pd.DataFrame: """Return a sorted DataFrame of unique (Class, Subclass) pairs present in `data_plot`. @@ -424,6 +431,9 @@ def sorted_present_pairs( if not isinstance(data_plot, pd.DataFrame): raise TypeError("data_plot must be a pandas DataFrame") + if config is None: + config = PresentPairsConfig() + missing_cols = [c for c in (config.class_col, config.subclass_col) if c not in data_plot.columns] if missing_cols: raise KeyError(f"data_plot is missing required columns: {missing_cols}") @@ -462,11 +472,11 @@ def sorted_present_pairs( order_map = config.subclass_order_within_class # Precompute index maps for O(1) lookup - index_maps: Dict[str, Dict[str, int]] = {} + index_maps: dict[str, dict[str, int]] = {} for cls, order in order_map.items(): index_maps[str(cls)] = {str(lbl): i for i, lbl in enumerate(order)} - def _sub_key(row: pd.Series) -> Tuple[int, Any]: + def _sub_key(row: pd.Series) -> tuple[int, Any]: cls = row[config.class_col] sub = row[config.subclass_col] cls_s = "" if pd.isna(cls) else str(cls) @@ -499,7 +509,7 @@ def _sub_key(row: pd.Series) -> Tuple[int, Any]: def palette_from_cmap( labels: Sequence[Any], cmap: str = "viridis", -) -> Dict[str, Tuple[float, float, float] | Tuple[float, float, float, float]]: +) -> dict[str, tuple[float, float, float] | tuple[float, float, float, float]]: """Evenly distribute labels along a colormap. Parameters @@ -521,7 +531,7 @@ def palette_from_cmap( return {} positions = np.linspace(0.0, 1.0, n) if n > 1 else np.array([0.5]) - return {lbl: cmap(pos) for lbl, pos in zip(labels, positions)} + return {lbl: cmap(pos) for lbl, pos in zip(labels, positions, strict=True)} def build_selected_label_column( @@ -529,8 +539,8 @@ def build_selected_label_column( *, class_col: str, subclass_col: str, - selected_classes: Optional[Sequence[Any]] = None, - selected_subclasses: Optional[Sequence[Any]] = None, + selected_classes: Sequence[Any] | None = None, + selected_subclasses: Sequence[Any] | None = None, other_label: str = "other", ) -> pd.Series: """Return a Series of labels where only selected classes/subclasses keep their name, else 'other'.""" @@ -570,12 +580,12 @@ def build_selected_label_column( def build_selected_palette( labels_in_plot: Sequence[str], *, - palette_or_cmap: Union[Palette, str, Any] = "viridis", + palette_or_cmap: Palette | str | Any = "viridis", other_label: str = "other", - other_color: Union[Color, ColorA] = (0.7, 0.7, 0.7, 1.0), + other_color: Color | ColorA = (0.7, 0.7, 0.7, 1.0), cmap_single_position: float = 0.5, cmap_rgb_only: bool = False, -) -> Dict[str, Union[Color, ColorA]]: +) -> dict[str, Color | ColorA]: """Build a palette for the reduced label set (selected + other).""" # Keep stable unique labels seen: set[str] = set() @@ -621,7 +631,7 @@ def build_selected_palette( def n_colors_from_cmap( n: int, cmap, -) -> List[Tuple[float, float, float, float]]: +) -> list[tuple[float, float, float, float]]: """Get n colors from green -> yellow -> dark red (RGBA). Parameters diff --git a/chemap/plotting/scatter_plots.py b/chemap/plotting/scatter_plots.py index 22bdb3b..b16922b 100644 --- a/chemap/plotting/scatter_plots.py +++ b/chemap/plotting/scatter_plots.py @@ -1,5 +1,6 @@ +from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Any import matplotlib as mpl import matplotlib.colors as mcolors import matplotlib.pyplot as plt @@ -29,7 +30,8 @@ @dataclass(frozen=True) class ScatterStyle: - figsize: Tuple[float, float] = (20, 20) + """Styling defaults for scatter plots.""" + figsize: tuple[float, float] = (20, 20) title: str = "UMAP of embeddings" s: float = 5.0 @@ -39,7 +41,7 @@ class ScatterStyle: display_legend: bool = True legend_outside: bool = False - legend_title: Optional[str] = None + legend_title: str | None = None legend_loc: str = "lower left" legend_frameon: bool = False legend_ncol: int = 1 @@ -56,7 +58,7 @@ def _validate_required_columns(df: pd.DataFrame, cols: Sequence[str]) -> None: raise KeyError(f"data_plot is missing required columns: {missing}") -def _to_rgba(color: Union[Color, ColorA]) -> ColorA: +def _to_rgba(color: Color | ColorA) -> ColorA: return mcolors.to_rgba(color) @@ -64,11 +66,11 @@ def _build_legend_handles( ordered_labels: Sequence[str], palette: Palette, *, - fallback: Union[Color, ColorA] = (0.5, 0.5, 0.5, 1.0), + fallback: Color | ColorA = (0.5, 0.5, 0.5, 1.0), markersize: float = 8.0, alpha: float = 0.8, -) -> List[Line2D]: - handles: List[Line2D] = [] +) -> list[Line2D]: + handles: list[Line2D] = [] for lbl in ordered_labels: col = palette.get(lbl, fallback) handles.append( @@ -94,10 +96,10 @@ def scatter_plot_base( y_col: str = "y", label_col: str, palette: Palette, - legend_labels: Optional[Sequence[str]] = None, - style: ScatterStyle = ScatterStyle(), - ax: Optional[Axes] = None, -) -> Tuple[Figure, Axes]: + legend_labels: Sequence[str] | None = None, + style: ScatterStyle | None = None, + ax: Axes | None = None, +) -> tuple[Figure, Axes]: """A base scatter plot function that takes pre-mapped labels and a palette. This is not intended for direct use, but as a building block for the more user-friendly wrapper functions below. """ @@ -112,6 +114,9 @@ def scatter_plot_base( colors = data_plot[label_col].map(lambda v: palette.get(str(v), (0.5, 0.5, 0.5, 1.0))) + if style is None: + style = ScatterStyle() + if ax is None: fig, ax = plt.subplots(figsize=style.figsize) else: @@ -186,19 +191,19 @@ def scatter_plot_all_classes( y_col: str = "y", class_col: str = "Class", subclass_col: str = "Subclass", - palette_or_cmap: Union[Palette, str, mpl.colors.Colormap] = "viridis", + palette_or_cmap: Palette | str | mpl.colors.Colormap = "viridis", # ordering options (same semantics as before) - class_order: Optional[Sequence[str]] = None, - subclass_order_within_class: Optional[Mapping[str, Sequence[str]]] = None, + class_order: Sequence[str] | None = None, + subclass_order_within_class: Mapping[str, Sequence[str]] | None = None, # plotting style (surface the key knobs; advanced users can pass ScatterStyle via style=) - figsize: Tuple[float, float] = (20, 20), + figsize: tuple[float, float] = (20, 20), title: str = "UMAP of embeddings", s: float = 5.0, alpha: float = 0.25, linewidths: float = 0.0, display_legend: bool = True, legend_outside: bool = False, - legend_title: Optional[str] = None, + legend_title: str | None = None, legend_loc: str = "lower left", legend_frameon: bool = False, legend_ncol: int = 1, @@ -206,8 +211,8 @@ def scatter_plot_all_classes( legend_alpha: float = 0.8, hide_ticks: bool = True, hide_axis_labels: bool = True, - ax: Optional[Axes] = None, -) -> Tuple[Figure, Axes, Dict[str, Union[Color, ColorA]]]: + ax: Axes | None = None, +) -> tuple[Figure, Axes, dict[str, Color | ColorA]]: """Balanced/small label-space scatter. Parameters @@ -254,7 +259,7 @@ def scatter_plot_all_classes( present_subclasses = present[subclass_col].dropna().map(str).tolist() if isinstance(palette_or_cmap, Mapping): - palette: Dict[str, Union[Color, ColorA]] = {str(k): v for k, v in palette_or_cmap.items()} + palette: dict[str, Color | ColorA] = {str(k): v for k, v in palette_or_cmap.items()} else: palette = palette_from_cmap( present_subclasses, @@ -312,7 +317,7 @@ def scatter_plot_hierarchical_labels( max_superclass_size: int = 10_000, rare_label: str = "Rare Superclass or Unknown", sep: str = "->", - top_k_classes: Optional[int] = None, + top_k_classes: int | None = None, # palette params other_suffix: str = "other", base_cmap: str = "tab20", @@ -321,7 +326,7 @@ def scatter_plot_hierarchical_labels( child_lighten_min: float = 0.15, child_lighten_max: float = 0.65, # plotting style - figsize: Tuple[float, float] = (20, 20), + figsize: tuple[float, float] = (20, 20), title: str = "UMAP of embeddings", s: float = 2.0, alpha: float = 0.2, @@ -336,8 +341,8 @@ def scatter_plot_hierarchical_labels( legend_alpha: float = 0.8, hide_ticks: bool = True, hide_axis_labels: bool = True, - ax: Optional[Axes] = None, -) -> Tuple[Figure, Axes, Dict[str, str], Dict[str, Union[Color, ColorA]]]: + ax: Axes | None = None, +) -> tuple[Figure, Axes, dict[str, str], dict[str, Color | ColorA]]: """Hierarchical-label scatter (builds display labels and palette internally). Parameters @@ -384,7 +389,7 @@ def scatter_plot_hierarchical_labels( df = data_plot if inplace else data_plot.copy() - class_to_label, info = build_hier_label_map( + class_to_label, _info = build_hier_label_map( df, config=LabelMapConfig( superclass_col=superclass_col, @@ -463,17 +468,17 @@ def scatter_plot_selected_only( y_col: str = "y", class_col: str = "Class", subclass_col: str = "Subclass", - selected_classes: Optional[Sequence[Any]] = None, - selected_subclasses: Optional[Sequence[Any]] = None, + selected_classes: Sequence[Any] | None = None, + selected_subclasses: Sequence[Any] | None = None, selected_size_relative: float = 2.0, other_label: str = "other", - other_color: Union[Color, ColorA] = (0.8, 0.8, 0.8, 0.1), - palette_or_cmap: Union[Palette, str, Any] = "viridis", + other_color: Color | ColorA = (0.8, 0.8, 0.8, 0.1), + palette_or_cmap: Palette | str | Any = "viridis", cmap_single_position: float = 0.5, cmap_rgb_only: bool = False, - style: ScatterStyle = ScatterStyle(), - ax: Optional[Axes] = None, -) -> Tuple[Figure, Axes, Dict[str, Union[Color, ColorA]]]: + style: ScatterStyle | None = None, + ax: Axes | None = None, +) -> tuple[Figure, Axes, dict[str, Color | ColorA]]: """Scatter plot where only a selected subset is colored; all other points are gray 'other'. Additionally, selected points get a larger marker size: `style.s * selected_size_relative`. @@ -507,6 +512,9 @@ def scatter_plot_selected_only( if col not in data_plot.columns: raise KeyError(f"data_plot is missing required column: {col}") + if style is None: + style = ScatterStyle() + df = data_plot.copy() df["_selected_label"] = build_selected_label_column( @@ -563,7 +571,7 @@ def scatter_plot_selected_only( from matplotlib.lines import Line2D - handles: List[Line2D] = [] + handles: list[Line2D] = [] for lbl in legend_labels: handles.append( Line2D( diff --git a/chemap/types.py b/chemap/types.py index abbda61..eb4a29a 100644 --- a/chemap/types.py +++ b/chemap/types.py @@ -1,10 +1,10 @@ -from typing import Mapping, Sequence, Tuple, Union +from collections.abc import Mapping, Sequence import numpy as np -Bins = Sequence[Tuple[float, float]] -Color = Tuple[float, float, float] # RGB -ColorA = Tuple[float, float, float, float] # RGBA -Palette = Mapping[str, Union[Color, ColorA]] +Bins = Sequence[tuple[float, float]] +Color = tuple[float, float, float] # RGB +ColorA = tuple[float, float, float, float] # RGBA +Palette = Mapping[str, Color | ColorA] UnfoldedBinary = list[np.ndarray] # list of int64 feature IDs per molecule UnfoldedCount = list[tuple[np.ndarray, np.ndarray]] # (int64 feature IDs, float32 values) diff --git a/chemap/visualizations.py b/chemap/visualizations.py index 14bfe3c..ce4c731 100644 --- a/chemap/visualizations.py +++ b/chemap/visualizations.py @@ -39,7 +39,7 @@ def heatmap_comparison(similarities1, similarities2, label1, label2, bins=50, # Plot the heatmap using imshow with a logarithmic color scale im = ax.imshow( - hist.T, origin='lower', aspect='equal', + hist.T, origin="lower", aspect="equal", extent=[x_edges[0], x_edges[-1], y_edges[0], y_edges[-1]], cmap=colormap, norm=LogNorm(vmin=1, vmax=np.max(hist)) ) @@ -132,7 +132,7 @@ def percentile_to_uniform(p, edges): within bin i in [0..1]. """ # Find the bin index where p belongs - i = np.searchsorted(edges, p, side='right') - 1 + i = np.searchsorted(edges, p, side="right") - 1 # Clamp i to [0, len(edges)-2] i = max(0, min(i, len(edges) - 2)) @@ -191,8 +191,8 @@ def heatmap_comparison_scaled_bins(similarities1, similarities2, # ------------------------------------------------------------------------- im = ax.imshow( hist.T, - origin='lower', - aspect='equal', + origin="lower", + aspect="equal", extent=[0, 1, 0, 1], cmap=colormap, norm=LogNorm(vmin=1, vmax=hist.max() if hist.max() > 0 else 1) @@ -288,7 +288,7 @@ def coord_to_bin_idx(u): ax.set_yticklabels([f"{p}%" for p in minor_percentiles], minor=True) # Optionally turn on grid lines - ax.grid(which='major', color='lightgray', linestyle='-', linewidth=0.8, alpha=0.9) + ax.grid(which="major", color="lightgray", linestyle="-", linewidth=0.8, alpha=0.9) #ax.grid(which='minor', color='gray', linestyle='-', linewidth=0.5, alpha=0.3) # Labels diff --git a/pyproject.toml b/pyproject.toml index 1af929d..c94a571 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "chemap" -version = "0.3.7" +version = "0.3.8" description = "Library for computing molecular fingerprint based similarities as well as dimensionality reduction based chemical space visualizations. " authors = [ { name="Florian Huber", email="florian.huber@hs-duesseldorf.de" }, @@ -15,7 +15,6 @@ classifiers = [ ] dependencies = [ - "map4>=1.1.3", "numba>=0.61.2", "numpy>=2.1.0", "pandas>=2.2.1", @@ -82,27 +81,39 @@ docstring-code-format = true line-ending = "lf" [tool.ruff.lint] -# TODO: add some rules in future, eg. W291/292 -extend-select = ["D", "E", "I"] +extend-select = ["D", "E", "I", "F", "Q", "UP", "B", "C4"] +# TODO: some of these rules should be enforced in the future ignore = [ - "D100", - "D101", - "D102", - "D103", - "D104", - "D105", - "D107", - "D200", - "D201", - "D202", - "D203", - "D204", - "D205", - "D209", - "D210", - "D212", - "D213", - "D4", + "BLE001", + "D100", + "D102", + "D104", + "D105", + "D107", + "D200", + "D201", + "D202", + "D203", + "D204", + "D205", + "D209", + "D210", + "D212", + "D213", + "D4", + "DTZ005", + "ISC004", + "PLC0132", + "PIE790", + "PERF402", + "PYI041", + "RET501", + "RUF012", + "RUF013", + "RUF015", + "S110", + "SIM102", + "SIM118", ] [tool.ruff.lint.isort] diff --git a/tests/test_benchmarking_duplicates.py b/tests/test_benchmarking_duplicates.py index a3a95b5..f308951 100644 --- a/tests/test_benchmarking_duplicates.py +++ b/tests/test_benchmarking_duplicates.py @@ -202,7 +202,7 @@ def test_plot_duplicates_by_experiment_happy_path(): "B": duplicates_b, } - fig, ax, results = plot_duplicates_by_experiment( + fig, _ax, results = plot_duplicates_by_experiment( experiments, masses, bins=[(0, 1), (1, 10), (10, np.inf)], diff --git a/tests/test_chemap_base_fingerprint.py b/tests/test_chemap_base_fingerprint.py index 31500fa..bfb2e3e 100644 --- a/tests/test_chemap_base_fingerprint.py +++ b/tests/test_chemap_base_fingerprint.py @@ -135,7 +135,7 @@ def test_folded_false_binary_uses_ensure_smiles_accepts_mols(): assert _is_unfolded_binary(out) assert len(out) == len(SMILES) # feature id == len(smiles) - for s, arr in zip(SMILES, out): + for s, arr in zip(SMILES, out, strict=False): np.testing.assert_array_equal(arr, np.array([len(s)], dtype=np.int64)) @@ -145,7 +145,7 @@ def test_folded_false_count_uses_ensure_smiles_and_returns_float32_vals(): assert _is_unfolded_count(out) assert len(out) == len(SMILES) - for s, (keys, vals) in zip(SMILES, out): + for s, (keys, vals) in zip(SMILES, out, strict=False): np.testing.assert_array_equal(keys, np.array([1, 2], dtype=np.int64)) np.testing.assert_array_equal(vals, np.array([float(len(s)), float(len(s) + 1)], dtype=np.float32)) @@ -169,7 +169,7 @@ def test_parallel_map_deterministic(n_jobs): out2 = fp.transform(SMILES) assert len(out1) == len(out2) - for (k1, v1), (k2, v2) in zip(out1, out2): + for (k1, v1), (k2, v2) in zip(out1, out2, strict=False): np.testing.assert_array_equal(k1, k2) np.testing.assert_array_equal(v1, v2) diff --git a/tests/test_cleveland_plot.py b/tests/test_cleveland_plot.py index 6107fec..09044f4 100644 --- a/tests/test_cleveland_plot.py +++ b/tests/test_cleveland_plot.py @@ -79,7 +79,7 @@ def test_group_length_mismatch_raises(): def test_default_row_order_is_stable_by_appearance(): - fig, ax = cleveland_dotplot( + _, ax = cleveland_dotplot( row=["B", "A", "B", "C"], x=[0.2, 0.1, 0.3, 0.4], show_legends=False, @@ -95,7 +95,7 @@ def test_default_row_order_is_stable_by_appearance(): def test_row_range_indicator_drawn_per_row_with_2plus_points(): # Row A has 3 points -> should get a range line. # Row B has 1 point -> no range line. - fig, ax = cleveland_dotplot( + _, ax = cleveland_dotplot( row=["A", "A", "A", "B"], x=[10, 20, 5, 7], row_range=True, @@ -122,7 +122,7 @@ def test_connectors_drawn_within_row_and_connect_group_and_use_color_map(): # One point in (row=A, group=g2) => no connector for that. color_map = {"binary": "crimson", "count": "teal"} - fig, ax = cleveland_dotplot( + _, ax = cleveland_dotplot( row=["A", "A", "A"], x=[1.0, 3.0, 2.0], color_group=["binary", "binary", "count"], @@ -153,7 +153,7 @@ def test_marker_zorder_applied_per_marker_group(): marker_map = {"dense": "o", "sparse": "^"} marker_zorder = {"dense": 3.0, "sparse": 5.0} - fig, ax = cleveland_dotplot( + _, ax = cleveland_dotplot( row=["A", "A"], x=[1.0, 1.0], marker_group=["dense", "sparse"], @@ -179,7 +179,7 @@ def test_marker_zorder_applied_per_marker_group(): def test_zero_line_added_only_when_min_x_leq_zero(): # Case 1: includes negative -> should add zero vline - fig, ax = cleveland_dotplot( + _, ax = cleveland_dotplot( row=["A", "B"], x=[-0.1, 0.2], connect=False, @@ -190,7 +190,7 @@ def test_zero_line_added_only_when_min_x_leq_zero(): assert _has_zero_vline(ax) is True # Case 2: all positive -> should not add zero vline - fig, ax = cleveland_dotplot( + _, ax = cleveland_dotplot( row=["A", "B"], x=[0.1, 0.2], connect=False, @@ -201,7 +201,7 @@ def test_zero_line_added_only_when_min_x_leq_zero(): assert _has_zero_vline(ax) is False # Case 3: negative but disabled -> should not add - fig, ax = cleveland_dotplot( + _, ax = cleveland_dotplot( row=["A", "B"], x=[-0.1, 0.2], connect=False, @@ -216,7 +216,7 @@ def test_legends_created_when_enabled_and_groups_present(): marker_map = {"dense": "o", "sparse": "^"} color_map = {"binary": "crimson", "count": "teal"} - fig, ax = cleveland_dotplot( + _, ax = cleveland_dotplot( row=["A", "A", "B", "B"], x=[1.0, 2.0, 3.0, 4.0], marker_group=["dense", "sparse", "dense", "sparse"], @@ -238,7 +238,7 @@ def test_legends_created_when_enabled_and_groups_present(): def test_no_legends_when_disabled(): - fig, ax = cleveland_dotplot( + _, ax = cleveland_dotplot( row=["A", "B"], x=[1.0, 2.0], show_legends=False, diff --git a/tests/test_colormap_handling.py b/tests/test_colormap_handling.py index f77bc9b..8db57dc 100644 --- a/tests/test_colormap_handling.py +++ b/tests/test_colormap_handling.py @@ -1,4 +1,3 @@ -from typing import Tuple import numpy as np import pandas as pd from chemap.plotting import ( @@ -12,13 +11,13 @@ ) -def _is_rgb(t: Tuple[float, float, float]) -> bool: +def _is_rgb(t: tuple[float, float, float]) -> bool: if not (isinstance(t, tuple) and len(t) == 3): return False return all(isinstance(x, (float, int)) and 0.0 <= float(x) <= 1.0 for x in t) -def _is_rgba(t: Tuple[float, float, float, float]) -> bool: +def _is_rgba(t: tuple[float, float, float, float]) -> bool: if not (isinstance(t, tuple) and len(t) == 4): return False return all(isinstance(x, (float, int)) and 0.0 <= float(x) <= 1.0 for x in t) diff --git a/tests/test_fingerprint_computation.py b/tests/test_fingerprint_computation.py index d89ce91..14aa9d3 100644 --- a/tests/test_fingerprint_computation.py +++ b/tests/test_fingerprint_computation.py @@ -1,4 +1,4 @@ -from typing import Sequence +from collections.abc import Sequence import numpy as np import pytest import scipy.sparse as sp @@ -36,7 +36,7 @@ class _FakeBitVector: """Mimics RDKit ExplicitBitVect returned by GetFingerprint().""" def __init__(self, nbits: int, on_bits: Sequence[int]): self._nbits = int(nbits) - self._on_bits = sorted(set(int(b) for b in on_bits)) + self._on_bits = sorted({int(b) for b in on_bits}) def GetNumBits(self): return self._nbits diff --git a/tests/test_fingerprint_generators.py b/tests/test_fingerprint_generators.py index 9a9a084..310fe5d 100644 --- a/tests/test_fingerprint_generators.py +++ b/tests/test_fingerprint_generators.py @@ -1,5 +1,5 @@ from dataclasses import replace -from typing import Any, Dict, List, Optional, Tuple +from typing import Any import numpy as np import pytest import scipy.sparse as sp @@ -18,7 +18,7 @@ # simple smiles for testing -SMILES: List[str] = [ +SMILES: list[str] = [ "CCO", # ethanol "c1ccccc1", # benzene "CC(=O)O", # acetic acid @@ -31,7 +31,7 @@ # Generator inventory (RDKit) # ---------------------------- -def _rdkit_generators() -> List[Tuple[str, Any]]: +def _rdkit_generators() -> list[tuple[str, Any]]: return [ ("rdkit_morgan_2048_r2", rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)), ("rdkit_rdkitfp_2048", rdFingerprintGenerator.GetRDKitFPGenerator(fpSize=2048)), @@ -44,7 +44,7 @@ def _rdkit_generators() -> List[Tuple[str, Any]]: # Generator inventory (scikit-fingerprints) # --------------------------------------- -def _skfp_generators() -> Dict[str, Any]: +def _skfp_generators() -> dict[str, Any]: return { "MAPFingerprint": MAPFingerprint, "AvalonFingerprint": AvalonFingerprint, @@ -57,16 +57,16 @@ def _skfp_generators() -> Dict[str, Any]: } -def _supports_count_param(params: Dict[str, Any]) -> Optional[str]: +def _supports_count_param(params: dict[str, Any]) -> str | None: for key in ("count", "counts", "use_counts", "useCounts", "use_count"): if key in params: return key return None -def _build_skfp_transformers() -> List[Tuple[str, Any, bool]]: +def _build_skfp_transformers() -> list[tuple[str, Any, bool]]: mod = _skfp_generators() - out: List[Tuple[str, Any, bool]] = [] + out: list[tuple[str, Any, bool]] = [] for cls_name, cls in mod.items(): try: @@ -74,7 +74,7 @@ def _build_skfp_transformers() -> List[Tuple[str, Any, bool]]: params = base.get_params(deep=False) supports_count = _supports_count_param(params) is not None out.append((f"skfp_{cls_name}", base, supports_count)) - except Exception: + except Exception: # noqa: S112 continue return out @@ -146,7 +146,7 @@ def _case1_dense_cases(): - dense fingerprint (binary and where feasible count) Each run = separate pytest param case. """ - cases: List[pytest.ParamSpecArg] = [] + cases: list[pytest.ParamSpecArg] = [] # RDKit: binary + count for name, gen in _rdkit_generators(): @@ -175,7 +175,7 @@ def _case2_csr_cases(): - return_csr=True (binary and where feasible count) Each run = separate pytest param case. """ - cases: List[pytest.ParamSpecArg] = [] + cases: list[pytest.ParamSpecArg] = [] # RDKit: binary + count for name, gen in _rdkit_generators(): @@ -209,7 +209,7 @@ def _case3_fit_backend_cases(): - if supports variant: works (unfolded) - else: raises NotImplementedError """ - cases: List[pytest.ParamSpecArg] = [] + cases: list[pytest.ParamSpecArg] = [] for name, fp, supports_count in _build_skfp_transformers(): # A) diff --git a/tests/test_lingo.py b/tests/test_lingo.py index a159533..bd41e10 100644 --- a/tests/test_lingo.py +++ b/tests/test_lingo.py @@ -124,7 +124,7 @@ def test_folded_equals_manual_folding_from_unfolded_counts(): # keys are int64; interpret as unsigned for modulo stability keys_u = keys.astype(np.uint64, copy=False) buckets = (keys_u % np.uint64(fp_size)).astype(np.int64) - for b, v in zip(buckets, vals): + for b, v in zip(buckets, vals, strict=False): X_manual[i, b] += np.uint32(v) # shape and dtype sanity @@ -139,7 +139,7 @@ def test_deterministic_unfolded_output(): out2 = fp.transform(SMILES) assert len(out1) == len(out2) - for (k1, v1), (k2, v2) in zip(out1, out2): + for (k1, v1), (k2, v2) in zip(out1, out2, strict=False): np.testing.assert_array_equal(k1, k2) np.testing.assert_array_equal(v1, v2)