easydecon 0.1.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
easydecon/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """Public API for easydecon."""
2
+
3
+ from ._version import __version__
4
+ from ._schema import (
5
+ MarkerSchema,
6
+ get_table,
7
+ resolve_marker_columns,
8
+ standardize_marker_dataframe,
9
+ )
10
+ from ._validation import MARKER_ROLE_MODES, UCELL_MARKER_ROLES
11
+ from .config import set_batch_size, set_n_jobs
12
+ from .diagnostics import summarize_easydecon_result, summarize_marker_table
13
+ from .easydecon import (
14
+ assign_clusters_from_df,
15
+ common_markers_gene_expression_and_filter,
16
+ compute_pseudobulk_deseq_markers,
17
+ get_clusters_by_similarity_on_tissue,
18
+ read_markers_dataframe,
19
+ add_df_to_spatialdata,
20
+ )
21
+ from .extra import EasyDeconResult, easydecon_workflow
22
+ from .markers import (
23
+ PreparedMarkers,
24
+ compute_reference_profile_markers,
25
+ prepare_markers,
26
+ select_prepared_markers,
27
+ )
28
+ from .niche import (
29
+ detect_niches_from_easydecon_result,
30
+ detect_spatial_niches_from_posteriors,
31
+ plot_niche_compositions,
32
+ summarize_niche_compositions,
33
+ )
34
+ from .refinement import RefinedGroupResult, refine_group
35
+
36
+ run_easydecon = easydecon_workflow
37
+
38
+ __all__ = [
39
+ "__version__",
40
+ "read_markers_dataframe",
41
+ "common_markers_gene_expression_and_filter",
42
+ "get_clusters_by_similarity_on_tissue",
43
+ "assign_clusters_from_df",
44
+ "add_df_to_spatialdata",
45
+ "compute_pseudobulk_deseq_markers",
46
+ "PreparedMarkers",
47
+ "compute_reference_profile_markers",
48
+ "prepare_markers",
49
+ "select_prepared_markers",
50
+ "set_n_jobs",
51
+ "set_batch_size",
52
+ "easydecon_workflow",
53
+ "run_easydecon",
54
+ "EasyDeconResult",
55
+ "detect_spatial_niches_from_posteriors",
56
+ "detect_niches_from_easydecon_result",
57
+ "summarize_niche_compositions",
58
+ "plot_niche_compositions",
59
+ "MarkerSchema",
60
+ "resolve_marker_columns",
61
+ "standardize_marker_dataframe",
62
+ "get_table",
63
+ "summarize_easydecon_result",
64
+ "summarize_marker_table",
65
+ "RefinedGroupResult",
66
+ "refine_group",
67
+ "UCELL_MARKER_ROLES",
68
+ "MARKER_ROLE_MODES",
69
+ ]
easydecon/_schema.py ADDED
@@ -0,0 +1,365 @@
1
+ """Shared schema helpers for marker tables and AnnData-like objects."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import pandas as pd
6
+
7
+ from ._validation import UCELL_MARKER_ROLES, format_allowed_values
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class MarkerSchema:
12
+ """Column names used by an easydecon marker DataFrame."""
13
+
14
+ group_col: str = "group"
15
+ gene_col: str = "names"
16
+ lfc_col: str = "logfoldchanges"
17
+ padj_col: str = "pvals_adj"
18
+ score_col: str = "scores"
19
+
20
+
21
+ GROUP_ALIASES = [
22
+ "group", "groups", "celltype", "cell_type", "cell type",
23
+ "cluster", "clusters", "annotation", "label", "labels",
24
+ ]
25
+
26
+ GENE_ALIASES = [
27
+ "names", "name", "gene", "genes", "gene_id", "gene_ids",
28
+ "gene_symbol", "symbol", "feature", "features",
29
+ ]
30
+
31
+ LFC_ALIASES = [
32
+ "logfoldchanges", "logfoldchange", "log2FoldChange",
33
+ "log2foldchange", "log2fc", "avg_log2FC", "avg_log2fc",
34
+ "avg_logFC", "lfc",
35
+ ]
36
+
37
+ PADJ_ALIASES = [
38
+ "pvals_adj", "pval_adj", "p_val_adj", "padj",
39
+ "FDR", "fdr", "qval", "q_value", "qvalue",
40
+ ]
41
+
42
+ SCORE_ALIASES = [
43
+ "scores", "score", "stat", "wald_stat", "statistics",
44
+ "baseMean", "basemean",
45
+ ]
46
+
47
+
48
+ _CANONICAL_ALIASES = {
49
+ "group": GROUP_ALIASES,
50
+ "names": GENE_ALIASES,
51
+ "logfoldchanges": LFC_ALIASES,
52
+ "pvals_adj": PADJ_ALIASES,
53
+ "scores": SCORE_ALIASES,
54
+ }
55
+
56
+
57
+ def _schema_columns(schema):
58
+ schema = schema or MarkerSchema()
59
+ return {
60
+ "group": schema.group_col,
61
+ "names": schema.gene_col,
62
+ "logfoldchanges": schema.lfc_col,
63
+ "pvals_adj": schema.padj_col,
64
+ "scores": schema.score_col,
65
+ }
66
+
67
+
68
+ def resolve_marker_columns(df, schema=None):
69
+ """Map easydecon's canonical marker columns to columns present in ``df``.
70
+
71
+ Matching is case-insensitive, while the returned values retain the exact
72
+ column labels from the input DataFrame.
73
+ """
74
+ if not isinstance(df, pd.DataFrame):
75
+ raise TypeError("df must be a pandas DataFrame.")
76
+
77
+ columns = list(df.columns)
78
+ schema_columns = _schema_columns(schema)
79
+ resolved = {}
80
+
81
+ for canonical, aliases in _CANONICAL_ALIASES.items():
82
+ # A canonical spelling has priority over every alias or schema hint.
83
+ if canonical in columns:
84
+ resolved[canonical] = canonical
85
+ continue
86
+
87
+ candidates = [schema_columns[canonical], canonical, *aliases]
88
+ for candidate in candidates:
89
+ candidate_folded = str(candidate).casefold()
90
+ match = next(
91
+ (
92
+ column
93
+ for column in columns
94
+ if str(column).casefold() == candidate_folded
95
+ ),
96
+ None,
97
+ )
98
+ if match is not None:
99
+ resolved[canonical] = match
100
+ break
101
+
102
+ return resolved
103
+
104
+
105
+ def _resolve_sort_column(sort_by_column, df, resolved, schema):
106
+ if sort_by_column is None:
107
+ for canonical in ("scores", "logfoldchanges", "pvals_adj"):
108
+ if canonical in df.columns:
109
+ return canonical
110
+ return None
111
+
112
+ if sort_by_column in df.columns:
113
+ return sort_by_column
114
+
115
+ requested = str(sort_by_column).casefold()
116
+ for column in df.columns:
117
+ if str(column).casefold() == requested:
118
+ return column
119
+
120
+ for canonical, original in resolved.items():
121
+ if str(original).casefold() == requested and canonical in df.columns:
122
+ return canonical
123
+
124
+ schema_columns = _schema_columns(schema)
125
+ for canonical, aliases in _CANONICAL_ALIASES.items():
126
+ candidates = [canonical, schema_columns[canonical], *aliases]
127
+ if any(str(candidate).casefold() == requested for candidate in candidates):
128
+ if canonical in df.columns:
129
+ return canonical
130
+
131
+ available = ", ".join(map(str, df.columns))
132
+ raise ValueError(
133
+ f"Could not resolve sort column {sort_by_column!r}. "
134
+ f"Available columns: {available}."
135
+ )
136
+
137
+
138
+ def _as_exclusion_set(values):
139
+ if isinstance(values, str):
140
+ return {values}
141
+ return set(values)
142
+
143
+
144
+ def resolve_marker_role_column(df, marker_role_column="marker_role"):
145
+ """Return the actual marker-role column name, matching case-insensitively."""
146
+ if not isinstance(df, pd.DataFrame):
147
+ raise TypeError("df must be a pandas DataFrame.")
148
+ if marker_role_column in df.columns:
149
+ return marker_role_column
150
+ requested = str(marker_role_column).casefold()
151
+ return next(
152
+ (column for column in df.columns if str(column).casefold() == requested),
153
+ None,
154
+ )
155
+
156
+
157
+ def normalize_marker_roles(
158
+ df,
159
+ marker_role_column="marker_role",
160
+ *,
161
+ fill_missing_column=False,
162
+ ):
163
+ """Resolve, normalize, and validate marker-role values.
164
+
165
+ Missing or blank role values become ``"positive"``. Unknown values raise a
166
+ user-facing ``ValueError`` that lists the supported roles.
167
+ """
168
+ actual_column = resolve_marker_role_column(df, marker_role_column)
169
+ if actual_column is None:
170
+ if fill_missing_column:
171
+ return pd.Series("positive", index=df.index, dtype="object"), None
172
+ return None, None
173
+
174
+ roles = df[actual_column]
175
+ roles = roles.where(~roles.isna(), "positive").astype(str).str.strip().str.casefold()
176
+ roles = roles.replace("", "positive")
177
+ unknown = sorted(set(roles) - set(UCELL_MARKER_ROLES))
178
+ if unknown:
179
+ raise ValueError(
180
+ "Unknown marker_role values: "
181
+ f"{unknown}. Allowed values: {format_allowed_values(UCELL_MARKER_ROLES)}."
182
+ )
183
+ return roles, actual_column
184
+
185
+
186
+ def standardize_marker_dataframe(
187
+ df,
188
+ schema=None,
189
+ gene_universe=None,
190
+ exclude_celltype=None,
191
+ top_n_genes=60,
192
+ sort_by_column=None,
193
+ ascending=False,
194
+ log2fc_min=0.25,
195
+ pval_cutoff=0.05,
196
+ drop_ribosomal=False,
197
+ drop_mitochondrial=False,
198
+ source=None,
199
+ require_group=True,
200
+ require_gene=True,
201
+ copy=True,
202
+ ):
203
+ """Return a consistently named and filtered marker DataFrame."""
204
+ if not isinstance(df, pd.DataFrame):
205
+ raise TypeError("df must be a pandas DataFrame.")
206
+
207
+ result = df.copy() if copy else df
208
+ if result.index.name is not None and result.index.name in result.columns:
209
+ result = result.reset_index(drop=True)
210
+ resolved = resolve_marker_columns(result, schema=schema)
211
+ available = ", ".join(map(str, result.columns))
212
+
213
+ if require_group and "group" not in resolved:
214
+ raise ValueError(
215
+ "Could not resolve a marker group column. "
216
+ f"Available columns: {available}."
217
+ )
218
+ if require_gene and "names" not in resolved:
219
+ raise ValueError(
220
+ "Could not resolve a marker gene column. "
221
+ f"Available columns: {available}."
222
+ )
223
+
224
+ rename_columns = {
225
+ original: canonical
226
+ for canonical, original in resolved.items()
227
+ if original != canonical
228
+ }
229
+ result.rename(columns=rename_columns, inplace=True)
230
+ roles, role_column = normalize_marker_roles(result)
231
+ has_marker_role = roles is not None
232
+ if has_marker_role:
233
+ if role_column != "marker_role":
234
+ result.rename(columns={role_column: "marker_role"}, inplace=True)
235
+ result["marker_role"] = roles
236
+
237
+ required_present = [column for column in ("group", "names") if column in result]
238
+ if required_present:
239
+ result.dropna(subset=required_present, inplace=True)
240
+ for column in required_present:
241
+ result[column] = result[column].astype(str)
242
+
243
+ if "logfoldchanges" in result.columns:
244
+ lfc_values = pd.to_numeric(result["logfoldchanges"], errors="coerce")
245
+ if has_marker_role:
246
+ negative_mask = result["marker_role"] == "negative"
247
+ keep_lfc = pd.Series(False, index=result.index)
248
+ keep_lfc.loc[negative_mask] = lfc_values.loc[negative_mask].abs() >= log2fc_min
249
+ keep_lfc.loc[~negative_mask] = lfc_values.loc[~negative_mask] >= log2fc_min
250
+ result = result.loc[keep_lfc]
251
+ else:
252
+ result = result.loc[lfc_values >= log2fc_min]
253
+ if "pvals_adj" in result.columns:
254
+ padj_values = pd.to_numeric(result["pvals_adj"], errors="coerce")
255
+ result = result.loc[padj_values <= pval_cutoff]
256
+
257
+ if "names" in result.columns:
258
+ gene_names = result["names"].str.upper()
259
+ if drop_ribosomal:
260
+ result = result.loc[~gene_names.str.startswith(("RPS", "RPL"))]
261
+ gene_names = result["names"].str.upper()
262
+ if drop_mitochondrial:
263
+ result = result.loc[~gene_names.str.startswith("MT-")]
264
+
265
+ if gene_universe is not None:
266
+ allowed_genes = {str(gene) for gene in gene_universe}
267
+ result = result.loc[result["names"].isin(allowed_genes)]
268
+
269
+ if exclude_celltype is not None and "group" in result.columns:
270
+ excluded_groups = {str(group) for group in _as_exclusion_set(exclude_celltype)}
271
+ result = result.loc[~result["group"].isin(excluded_groups)]
272
+
273
+ sort_column = _resolve_sort_column(sort_by_column, result, resolved, schema)
274
+ if sort_column is not None:
275
+ sort_ascending = (
276
+ True
277
+ if sort_by_column is None and sort_column == "pvals_adj"
278
+ else ascending
279
+ )
280
+ if has_marker_role and sort_column in {"logfoldchanges", "scores"}:
281
+ result["_easydecon_sort_value"] = pd.to_numeric(
282
+ result[sort_column], errors="coerce"
283
+ )
284
+ negative_mask = result["marker_role"] == "negative"
285
+ result.loc[negative_mask, "_easydecon_sort_value"] = result.loc[
286
+ negative_mask, "_easydecon_sort_value"
287
+ ].abs()
288
+ result = result.sort_values(
289
+ "_easydecon_sort_value", ascending=sort_ascending, kind="stable"
290
+ ).drop(columns="_easydecon_sort_value")
291
+ else:
292
+ result = result.sort_values(sort_column, ascending=sort_ascending, kind="stable")
293
+
294
+ if {"group", "names"}.issubset(result.columns):
295
+ deduplicate_by = ["group", "names", "marker_role"] if has_marker_role else ["group", "names"]
296
+ group_by = ["group", "marker_role"] if has_marker_role else ["group"]
297
+ result = result.drop_duplicates(subset=deduplicate_by, keep="first")
298
+ if top_n_genes is not None:
299
+ groupers = [result[column] for column in group_by]
300
+ result = result.groupby(
301
+ groupers, sort=False, group_keys=False
302
+ ).head(top_n_genes)
303
+ result["marker_rank"] = (
304
+ result.groupby([result[column] for column in group_by], sort=False).cumcount() + 1
305
+ )
306
+ result.set_index("group", drop=False, inplace=True)
307
+ else:
308
+ result["marker_rank"] = range(1, len(result) + 1)
309
+
310
+ if source is not None:
311
+ result["marker_source"] = source
312
+
313
+ return result
314
+
315
+
316
+ def get_table(sdata, bin_size=8, table_key=None, preferred_table_keys=None):
317
+ """Resolve and validate an AnnData-like table from a container or table."""
318
+ if table_key is not None:
319
+ try:
320
+ table = sdata.tables[table_key]
321
+ except (AttributeError, KeyError, TypeError) as error:
322
+ raise KeyError(f"Table {table_key!r} was not found in sdata.tables.") from error
323
+ else:
324
+ tables = getattr(sdata, "tables", None)
325
+ table = None
326
+ table_found = False
327
+ if tables is not None:
328
+ if isinstance(preferred_table_keys, str):
329
+ keys = [preferred_table_keys]
330
+ else:
331
+ keys = list(preferred_table_keys or [])
332
+ keys.extend(["cell_segmentations", f"square_{bin_size:03}um", "table"])
333
+ for key in dict.fromkeys(keys):
334
+ try:
335
+ table = tables[key]
336
+ table_found = True
337
+ break
338
+ except (KeyError, TypeError):
339
+ continue
340
+ if not table_found:
341
+ table = sdata
342
+
343
+ missing = [name for name in ("obs", "var_names", "X") if not hasattr(table, name)]
344
+ if missing:
345
+ missing_text = ", ".join(missing)
346
+ raise TypeError(
347
+ "Resolved table is not AnnData-like; expected attributes obs, "
348
+ f"var_names, and X. Missing: {missing_text}."
349
+ )
350
+ return table
351
+
352
+
353
+ __all__ = [
354
+ "MarkerSchema",
355
+ "GROUP_ALIASES",
356
+ "GENE_ALIASES",
357
+ "LFC_ALIASES",
358
+ "PADJ_ALIASES",
359
+ "SCORE_ALIASES",
360
+ "resolve_marker_columns",
361
+ "resolve_marker_role_column",
362
+ "normalize_marker_roles",
363
+ "standardize_marker_dataframe",
364
+ "get_table",
365
+ ]
@@ -0,0 +1,127 @@
1
+ """Shared allowed values and lightweight validation helpers."""
2
+
3
+ from numbers import Real
4
+
5
+
6
+ MARKER_METHODS = frozenset(
7
+ {
8
+ "auto",
9
+ "existing",
10
+ "scanpy",
11
+ "pydeseq2",
12
+ "deseq2",
13
+ "pseudobulk_deseq2",
14
+ "reference",
15
+ "rctd_like",
16
+ }
17
+ )
18
+ PYDESEQ2_MARKER_METHODS = frozenset(
19
+ {"pydeseq2", "deseq2", "pseudobulk_deseq2"}
20
+ )
21
+ REFERENCE_MARKER_METHODS = frozenset({"reference", "rctd_like"})
22
+ REFERENCE_CONTRASTS = frozenset({"mean_other", "max_other"})
23
+ FILTERING_ALGORITHMS = frozenset({"permutation", "quantile", "nb"})
24
+ PHASE1_OUTPUT_STATS = frozenset({"expression", "minus_log10_p"})
25
+ AGGREGATION_METHODS = frozenset({"sum", "mean", "median", "coverage"})
26
+ SIMILARITY_METHODS = frozenset(
27
+ {
28
+ "correlation",
29
+ "cosine",
30
+ "jaccard",
31
+ "overlap",
32
+ "wjaccard",
33
+ "diagnostic",
34
+ "sum",
35
+ "mean",
36
+ "median",
37
+ "euclidean",
38
+ "auc",
39
+ "ucell",
40
+ }
41
+ )
42
+ EVIDENCE_TO_LIKELIHOOD_METHODS = frozenset({"row_normalize", "softmax"})
43
+ ASSIGN_METHODS = frozenset({"max", "zmax", "hybrid"})
44
+ UCELL_MARKER_ROLES = frozenset({"positive", "negative", "presence", "identity"})
45
+ MARKER_ROLE_MODES = frozenset({"shared", "phase_specific"})
46
+ MARKER_ROLE_INFERENCE_MODES = frozenset({"none", "signed", "scanpy_signed"})
47
+
48
+
49
+ def format_allowed_values(values) -> str:
50
+ """Format allowed values deterministically for user-facing messages."""
51
+ return ", ".join(repr(value) for value in sorted(values))
52
+
53
+
54
+ def validate_choice(value, allowed, name: str):
55
+ """Return *value* when it belongs to *allowed*, otherwise raise."""
56
+ if value not in allowed:
57
+ raise ValueError(
58
+ f"{name} must be one of: {format_allowed_values(allowed)}. "
59
+ f"Got {value!r}."
60
+ )
61
+ return value
62
+
63
+
64
+ def _format_interval(minimum, maximum, inclusive_min, inclusive_max):
65
+ left = "[" if inclusive_min else "("
66
+ right = "]" if inclusive_max else ")"
67
+ return f"{left}{minimum:g}, {maximum:g}{right}"
68
+
69
+
70
+ def validate_probability_range(
71
+ value,
72
+ name: str,
73
+ *,
74
+ inclusive_min=True,
75
+ inclusive_max=True,
76
+ _minimum=0.0,
77
+ _maximum=1.0,
78
+ ):
79
+ """Validate a numeric probability-like value against bounded endpoints."""
80
+ message = (
81
+ f"{name} must be between 0 and 1."
82
+ if _minimum == 0 and _maximum == 1 and inclusive_min and inclusive_max
83
+ else f"{name} must be in "
84
+ f"{_format_interval(_minimum, _maximum, inclusive_min, inclusive_max)}."
85
+ )
86
+ if isinstance(value, bool) or not isinstance(value, Real):
87
+ raise ValueError(message)
88
+ minimum_ok = value >= _minimum if inclusive_min else value > _minimum
89
+ maximum_ok = value <= _maximum if inclusive_max else value < _maximum
90
+ if not minimum_ok or not maximum_ok:
91
+ raise ValueError(message)
92
+ return value
93
+
94
+
95
+ def validate_positive(value, name: str, *, allow_zero=False):
96
+ """Validate that a numeric value is positive (or non-negative)."""
97
+ message = (
98
+ f"{name} must be greater than or equal to 0."
99
+ if allow_zero
100
+ else f"{name} must be greater than 0."
101
+ )
102
+ if isinstance(value, bool) or not isinstance(value, Real):
103
+ raise ValueError(message)
104
+ if (allow_zero and value < 0) or (not allow_zero and value <= 0):
105
+ raise ValueError(message)
106
+ return value
107
+
108
+
109
+ __all__ = [
110
+ "MARKER_METHODS",
111
+ "PYDESEQ2_MARKER_METHODS",
112
+ "REFERENCE_MARKER_METHODS",
113
+ "REFERENCE_CONTRASTS",
114
+ "FILTERING_ALGORITHMS",
115
+ "PHASE1_OUTPUT_STATS",
116
+ "AGGREGATION_METHODS",
117
+ "SIMILARITY_METHODS",
118
+ "EVIDENCE_TO_LIKELIHOOD_METHODS",
119
+ "ASSIGN_METHODS",
120
+ "UCELL_MARKER_ROLES",
121
+ "MARKER_ROLE_MODES",
122
+ "MARKER_ROLE_INFERENCE_MODES",
123
+ "format_allowed_values",
124
+ "validate_choice",
125
+ "validate_probability_range",
126
+ "validate_positive",
127
+ ]
easydecon/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Package version information."""
2
+
3
+ __version__ = "0.1.7"
easydecon/config.py ADDED
@@ -0,0 +1,24 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class Config:
6
+ n_jobs: int = 5
7
+ batch_size: int | str = 1000
8
+
9
+ config = Config()
10
+
11
+ def set_n_jobs(n):
12
+ n = int(n)
13
+ if n == 0:
14
+ raise ValueError("n_jobs cannot be 0. Use 1 for serial or -1 for all CPUs.")
15
+ config.n_jobs = n
16
+
17
+ def set_batch_size(n):
18
+ if n == "auto":
19
+ config.batch_size = n
20
+ return
21
+ n = int(n)
22
+ if n < 1:
23
+ raise ValueError("batch_size must be a positive integer or 'auto'.")
24
+ config.batch_size = n