from dataclasses import dataclass
import numpy as np
import pandas as pd
from ._schema import get_table
from ._validation import (
EVIDENCE_TO_LIKELIHOOD_METHODS,
MARKER_ROLE_INFERENCE_MODES,
MARKER_ROLE_MODES,
validate_choice,
validate_positive,
validate_probability_range,
)
from .easydecon import (
_build_marker_compat_diagnostics,
_validate_finite_nonnegative,
_validate_nonempty_string,
_validate_optional_positive_integer,
assign_clusters_from_df,
common_markers_gene_expression_and_filter,
get_clusters_by_similarity_on_tissue,
)
from .markers import (
PreparedMarkers,
prepare_markers,
resolve_phase_marker_tables,
select_prepared_markers,
)
def _evidence_to_likelihood(
evidence_df,
method="softmax",
softmax_tau=1.0,
candidate_mask=None,
) -> pd.DataFrame:
"""Transform Phase 2 evidence into likelihoods.
This preserves the historical easydecon_workflow behavior for both
``row_normalize`` and ``softmax``.
"""
evidence_df = evidence_df.copy()
if candidate_mask is not None:
candidate_mask = _align_candidate_mask(
candidate_mask,
index=evidence_df.index,
columns=evidence_df.columns,
)
if method == "row_normalize":
if candidate_mask is not None:
numeric = evidence_df.apply(pd.to_numeric, errors="coerce")
numeric = numeric.replace([np.inf, -np.inf], np.nan)
values = numeric.to_numpy(dtype=float)
mask = candidate_mask.to_numpy(dtype=bool)
likelihoods_np = np.zeros_like(values, dtype=float)
for row_idx in range(values.shape[0]):
row_mask = mask[row_idx]
if not bool(row_mask.any()):
continue
candidate_values = values[row_idx, row_mask]
finite = np.isfinite(candidate_values)
if not bool(finite.any()):
continue
clean_values = np.where(finite, candidate_values, 0.0)
row_min = np.nanmin(clean_values[finite])
if row_min < 0:
clean_values = clean_values - row_min
clean_values = np.clip(clean_values, 0.0, None)
total = clean_values.sum()
if total > 0:
likelihoods_np[row_idx, row_mask] = clean_values / total
return pd.DataFrame(
likelihoods_np,
index=evidence_df.index,
columns=evidence_df.columns,
)
min_per_row = evidence_df.min(axis=1)
needs_shift = min_per_row < 0
if needs_shift.any():
evidence_df = evidence_df.sub(min_per_row, axis=0)
evidence_df = evidence_df.clip(lower=0)
evidence_row_sum = evidence_df.sum(axis=1).replace(0, np.nan)
return evidence_df.div(evidence_row_sum, axis=0).fillna(0)
if method == "softmax":
x = evidence_df.to_numpy(dtype=float)
x = np.where(np.isfinite(x), x, -np.inf)
if candidate_mask is not None:
mask = candidate_mask.to_numpy(dtype=bool)
x = np.where(mask, x, -np.inf)
row_max = np.max(x, axis=1, keepdims=True)
valid_rows = np.isfinite(row_max[:, 0])
logits = np.zeros_like(x, dtype=float)
valid_logits = (x[valid_rows] - row_max[valid_rows]) / softmax_tau
logits[valid_rows] = np.exp(valid_logits)
if candidate_mask is not None:
logits = np.where(mask, logits, 0.0)
row_sum = np.sum(logits, axis=1, keepdims=True)
row_sum[row_sum == 0] = np.nan
likelihoods_np = logits / row_sum
likelihoods_np = np.nan_to_num(likelihoods_np, nan=0.0)
return pd.DataFrame(
likelihoods_np,
index=evidence_df.index,
columns=evidence_df.columns,
)
raise ValueError("evidence_to_likelihood must be 'row_normalize' or 'softmax'.")
def _align_candidate_mask(candidate_mask, index, columns) -> pd.DataFrame:
"""Return a boolean candidate mask aligned to an output matrix shape."""
if not isinstance(candidate_mask, pd.DataFrame):
raise TypeError("candidate_mask must be a pandas DataFrame.")
return (
candidate_mask.reindex(index=index, columns=columns, fill_value=False)
.fillna(False)
.astype(bool)
)
def _build_phase2_candidate_mask(
priors_df,
phase2_groups,
spatial_index,
threshold=0.0,
) -> pd.DataFrame:
"""Build a group-aware Phase 2 candidate mask from Phase 1 priors."""
if not isinstance(priors_df, pd.DataFrame):
raise TypeError("priors_df must be a pandas DataFrame.")
_validate_finite_nonnegative(threshold, "phase2_candidate_threshold")
groups = [str(group) for group in phase2_groups]
aligned = priors_df.reindex(index=spatial_index, columns=groups)
numeric = aligned.apply(pd.to_numeric, errors="coerce")
numeric = numeric.replace([np.inf, -np.inf], np.nan).fillna(0.0)
numeric = numeric.clip(lower=0.0)
return (numeric > threshold).astype(bool)
def _summarize_candidate_mask(candidate_mask, *, enabled, threshold) -> dict:
"""Compact diagnostics for Phase 2 candidate pruning."""
exact = float(threshold) == 0.0
summary = {
"candidate_pruning_enabled": bool(enabled),
"candidate_threshold": float(threshold),
"exact_candidate_pruning": bool(enabled and exact),
}
if candidate_mask is None:
return summary
mask = candidate_mask.astype(bool)
total_pairs = int(mask.shape[0] * mask.shape[1])
candidate_counts = mask.sum(axis=1).astype(int)
active_counts = candidate_counts[candidate_counts > 0]
n_candidate_pairs = int(candidate_counts.sum())
summary.update(
{
"n_total_location_group_pairs": total_pairs,
"n_candidate_pairs": n_candidate_pairs,
"candidate_fraction": (
float(n_candidate_pairs / total_pairs) if total_pairs else 0.0
),
"n_rows_with_candidates": int((candidate_counts > 0).sum()),
"n_rows_without_candidates": int((candidate_counts == 0).sum()),
"min_candidates_per_active_row": (
int(active_counts.min()) if len(active_counts) else 0
),
"median_candidates_per_active_row": (
float(active_counts.median()) if len(active_counts) else 0.0
),
"max_candidates_per_active_row": (
int(active_counts.max()) if len(active_counts) else 0
),
}
)
return summary
[docs]
@dataclass
class EasyDeconResult:
markers_df: pd.DataFrame
phase1_result: pd.DataFrame
phase2_result: pd.DataFrame
assigned_labels: pd.DataFrame
priors_df: pd.DataFrame
likelihoods_df: pd.DataFrame
posterior_df: pd.DataFrame | None
assignment_df: pd.DataFrame
diagnostics: dict
prepared_markers: PreparedMarkers | None = None
[docs]
def easydecon_workflow(
sdata,
markers_df=None,
prepared_markers=None,
marker_genes=None, # This can be a list of genes, You can only give markers_df
filename=None,
adata=None,
mask_col = "easydecon_mask", # If markers_genes given, this column will be used to mask informative spots
# --- shared / data schema ---
celltype: str = "group", # column in markers_df holding cluster IDs
gene_id_column: str = "names", # column in markers_df holding gene names
exclude_group_names: list[str] | None = None,
bin_size: int = 8, # used by both phases and assignment
# === Marker loading / generation ===
marker_method: str = "auto",
groupby: str | None = None,
sample_col: str | None = None,
marker_key: str = "rank_genes_groups",
top_n_genes: int = 60,
sort_by_column: str = "scores",
ascending: bool = False,
log2fc_min: float = 0.25,
pval_cutoff: float = 0.05,
drop_ribosomal: bool = True,
drop_mitochondrial: bool = True,
table_key=None,
preferred_table_keys=None,
marker_source=None,
scanpy_method: str = "wilcoxon",
layer=None,
use_raw=None,
reference: str = "rest",
copy_adata: bool = True,
rank_genes_groups_kwargs=None,
min_cells_per_group: int = 20,
min_replicates_per_condition: int = 2,
deseq_alpha: float = 0.05,
deseq_n_cpus=None,
deseq_quiet: bool = True,
deseq_kwargs=None,
deseq_stats_kwargs=None,
reference_min_cells: int = 25,
reference_min_mean: float = 2e-4,
reference_min_log2fc: float = 1.0,
reference_min_detection: float = 0.10,
reference_min_detection_delta: float = 0.05,
reference_pseudocount: float = 1e-9,
reference_contrast: str = "max_other",
marker_roles: str = "shared",
reference_presence_min_log2fc: float = 0.5,
reference_presence_min_detection_delta: float = 0.0,
reference_negative_min_log2fc: float = 1.0,
reference_negative_min_detection: float = 0.10,
reference_negative_min_detection_delta: float = 0.05,
marker_role_inference: str = "none",
verbose: bool = True,
return_result_object: bool = False,
return_diagnostics: bool = False,
# === Phase 1 (priors): common_markers_gene_expression_and_filter ===
aggregation_method: str = "sum", # {"sum","mean","median"} supported by your helper funcs
filtering_algorithm: str = "permutation", # {"permutation","quantile"}
num_permutations: int = 5000, # number of permutations
parametric: bool = True, # parametric or empirical quantile
alpha: float = 0.01, # permutation cutoff level
subsample_size: int = 25000, # subsample size for permutation
subsample_signal_quantile: float = 0, #permutation param, between 0 and 1, if 0.1, 10% of the bins with the lowest and highest expression will be discarded
permutation_gene_pool_fraction: float = 0.3, # top fraction of genes to be used for the null distribution
n_subs: int = 5, # permutation: number of subsamples
quantile: float = 0.7, # used only if filtering_algorithm="quantile"
phase1_output_stat: str = "expression", # NEW: {"expression","minus_log10_p"}
# === Phase 2 (evidence): get_clusters_by_similarity_on_tissue ===
method: str = "wjaccard", # {"wjaccard","cosine","spearman","euclidean","jaccard","overlap", ...}
similarity_by_column: str = "logfoldchanges", #
lambda_param: float = 0.25, # lambda parameter wjaccard
weight_column: str = "logfoldchanges", # column in markers_df for weights etc.
min_markers: int = 3,
fallback_auc: float = 0.0,
expression_threshold: float = 0.1,
top_n_markers: int | None = None,
recovery_power: float = 1.0,
drop_shared_markers: bool = False,
center_auc: bool = True,
ucell_max_rank: int | None = None,
ucell_negative_weight: float = 1.0,
ucell_marker_role_column: str = "marker_role",
# === Evidence→likelihood mapping (lightweight, non-DL) ===
evidence_to_likelihood: str = "softmax", # {"row_normalize","softmax"}
softmax_tau: float = 1.0, # softmax temperature
epsilon: float = 1e-12, # numerical guard
# === Bayesian combination weights ===
prior_weight: float = 1.0, # weight for phase 1 priors
likelihood_weight: float = 1.0, # weight for phase 2 likelihoods
# === Optional presence gating by priors ===
apply_prior_presence_mask: bool = False, # if True, priors gate likelihoods
prior_presence_threshold: float = 0.0, # threshold on priors for presence mask
phase2_candidate_pruning: bool = False,
phase2_candidate_threshold: float = 0.0,
# === Final assignment: assign_clusters_from_df ===
results_column: str = "easydecon",
assign_method: str = "max", # {"max","hybrid","zmax"} per your implementation
allow_multiple: bool = False,
diagnostic=None,
fold_change_threshold: float = 2.0,
minimum_evidence: float = 0.0,
tie_tolerance: float = 1e-12,
):
validate_choice(
evidence_to_likelihood,
EVIDENCE_TO_LIKELIHOOD_METHODS,
"evidence_to_likelihood",
)
validate_positive(softmax_tau, "softmax_tau")
validate_positive(epsilon, "epsilon")
_validate_finite_nonnegative(fallback_auc, "fallback_auc")
_validate_finite_nonnegative(expression_threshold, "expression_threshold")
_validate_finite_nonnegative(recovery_power, "recovery_power")
_validate_finite_nonnegative(minimum_evidence, "minimum_evidence")
_validate_finite_nonnegative(tie_tolerance, "tie_tolerance")
validate_choice(marker_roles, MARKER_ROLE_MODES, "marker_roles")
validate_choice(
marker_role_inference,
MARKER_ROLE_INFERENCE_MODES,
"marker_role_inference",
)
_validate_finite_nonnegative(reference_min_log2fc, "reference_min_log2fc")
_validate_finite_nonnegative(
reference_presence_min_log2fc, "reference_presence_min_log2fc"
)
_validate_finite_nonnegative(
reference_negative_min_log2fc, "reference_negative_min_log2fc"
)
for value, name in (
(reference_min_detection, "reference_min_detection"),
(reference_min_detection_delta, "reference_min_detection_delta"),
(
reference_presence_min_detection_delta,
"reference_presence_min_detection_delta",
),
(reference_negative_min_detection, "reference_negative_min_detection"),
(
reference_negative_min_detection_delta,
"reference_negative_min_detection_delta",
),
):
validate_probability_range(value, name)
_validate_optional_positive_integer(top_n_markers, "top_n_markers")
_validate_optional_positive_integer(ucell_max_rank, "ucell_max_rank")
_validate_finite_nonnegative(ucell_negative_weight, "ucell_negative_weight")
_validate_nonempty_string(ucell_marker_role_column, "ucell_marker_role_column")
if isinstance(min_markers, bool) or not isinstance(min_markers, int) or min_markers < 1:
raise ValueError("min_markers must be an integer greater than or equal to 1.")
if not isinstance(drop_shared_markers, bool):
raise ValueError("drop_shared_markers must be a bool.")
if not isinstance(center_auc, bool):
raise ValueError("center_auc must be a bool.")
if prior_weight < 0 or likelihood_weight < 0:
raise ValueError("prior_weight and likelihood_weight must be non-negative.")
if not isinstance(phase2_candidate_pruning, bool):
raise ValueError("phase2_candidate_pruning must be a bool.")
_validate_finite_nonnegative(
phase2_candidate_threshold, "phase2_candidate_threshold"
)
if phase2_candidate_pruning and prior_weight <= 0:
raise ValueError(
"phase2_candidate_pruning requires prior_weight > 0 because "
"candidate groups are derived from Phase 1 priors."
)
marker_genes_is_list = isinstance(marker_genes, list)
if phase2_candidate_pruning and marker_genes_is_list:
raise ValueError(
"phase2_candidate_pruning is not available for list-style "
"marker_genes workflows because Phase 1 does not produce "
"cell-type-specific priors."
)
table = get_table(
sdata,
bin_size=bin_size,
table_key=table_key,
preferred_table_keys=preferred_table_keys,
)
original_celltype = celltype
original_gene_id_column = gene_id_column
resolved_prepared = prepare_markers(
adata=adata,
prepared_markers=prepared_markers,
markers_df=markers_df,
filename=filename,
source=marker_source,
marker_method=marker_method,
groupby=groupby,
marker_key=marker_key,
scanpy_method=scanpy_method,
layer=layer,
use_raw=use_raw,
reference=reference,
copy_adata=copy_adata,
rank_genes_groups_kwargs=rank_genes_groups_kwargs,
sample_col=sample_col,
min_cells_per_group=min_cells_per_group,
min_replicates_per_condition=min_replicates_per_condition,
deseq_alpha=deseq_alpha,
deseq_n_cpus=deseq_n_cpus,
deseq_quiet=deseq_quiet,
deseq_kwargs=deseq_kwargs,
deseq_stats_kwargs=deseq_stats_kwargs,
reference_min_cells=reference_min_cells,
reference_min_mean=reference_min_mean,
reference_min_log2fc=reference_min_log2fc,
reference_min_detection=reference_min_detection,
reference_min_detection_delta=reference_min_detection_delta,
reference_pseudocount=reference_pseudocount,
reference_contrast=reference_contrast,
marker_roles=marker_roles,
reference_presence_min_log2fc=reference_presence_min_log2fc,
reference_presence_min_detection_delta=reference_presence_min_detection_delta,
reference_negative_min_log2fc=reference_negative_min_log2fc,
reference_negative_min_detection=reference_negative_min_detection,
reference_negative_min_detection_delta=reference_negative_min_detection_delta,
marker_role_inference=marker_role_inference,
marker_role_inference_log2fc_min=log2fc_min,
celltype=celltype,
gene_id_column=gene_id_column,
verbose=verbose,
)
markers_df, selection_diagnostics = select_prepared_markers(
resolved_prepared,
gene_universe=table.var_names,
exclude_celltype=None,
top_n_genes=None,
sort_by_column=sort_by_column,
ascending=ascending,
log2fc_min=log2fc_min,
pval_cutoff=pval_cutoff,
drop_ribosomal=drop_ribosomal,
drop_mitochondrial=drop_mitochondrial,
source=marker_source,
return_diagnostics=True,
)
marker_diagnostics = _build_marker_compat_diagnostics(
resolved_prepared,
markers_df,
table,
marker_method=marker_method,
groupby=groupby,
key=marker_key,
scanpy_method=scanpy_method,
marker_roles=marker_roles,
marker_role_inference=marker_role_inference,
prepared_markers_used=prepared_markers is not None,
selection_diagnostics=selection_diagnostics,
top_n_applied_by="workflow_phase_resolver",
)
if not isinstance(markers_df, pd.DataFrame):
raise ValueError("Resolved markers_df must be a pandas DataFrame.")
missing_marker_columns = {"group", "names"}.difference(markers_df.columns)
if missing_marker_columns:
raise ValueError(
"Resolved markers_df must contain canonical columns 'group' and "
f"'names'. Missing: {sorted(missing_marker_columns)}."
)
if markers_df["group"].nunique() == 0:
raise ValueError("Resolved markers_df contains no marker groups.")
if markers_df["names"].nunique() == 0:
raise ValueError("Resolved markers_df contains no marker genes.")
celltype = "group"
gene_id_column = "names"
phase1_markers_df, phase2_markers_df, marker_role_diagnostics = (
resolve_phase_marker_tables(
markers_df,
marker_roles=marker_roles,
method=method,
marker_role_column=ucell_marker_role_column,
top_n_genes=top_n_genes,
require_phase1=marker_genes is None,
)
)
combined_markers_df = pd.concat(
[phase1_markers_df, phase2_markers_df], ignore_index=False
)
if "marker_role" in combined_markers_df.columns:
combined_markers_df = combined_markers_df.drop_duplicates(
subset=["group", "names", "marker_role"], keep="first"
)
combined_markers_df.set_index("group", drop=False, inplace=True)
else:
combined_markers_df = combined_markers_df.drop_duplicates(
subset=["group", "names"], keep="first"
)
combined_markers_df.set_index("group", drop=False, inplace=True)
markers_df = combined_markers_df
phase1_marker_source = (
"marker_genes_override" if marker_genes is not None else "resolved_marker_roles"
)
phase1_markers = phase1_markers_df if marker_genes is None else marker_genes
if isinstance(phase1_markers, pd.DataFrame):
phase1_markers = phase1_markers.copy()
rename_columns = {}
if "group" not in phase1_markers and original_celltype in phase1_markers:
rename_columns[original_celltype] = "group"
if "names" not in phase1_markers and original_gene_id_column in phase1_markers:
rename_columns[original_gene_id_column] = "names"
phase1_markers.rename(columns=rename_columns, inplace=True)
# -----------------------
# Phase 1: Priors
# -----------------------
phase1_result = common_markers_gene_expression_and_filter(
sdata=table,
marker_genes=phase1_markers,
celltype="group",
gene_id_column="names",
exclude_group_names=exclude_group_names,
bin_size=bin_size,
aggregation_method=aggregation_method,
add_to_obs=True if marker_genes is not None else False,
filtering_algorithm=filtering_algorithm,
num_permutations=num_permutations,
alpha=alpha,
subsample_size=subsample_size,
subsample_signal_quantile=subsample_signal_quantile,
permutation_gene_pool_fraction=permutation_gene_pool_fraction,
n_subs=n_subs,
quantile=quantile,
parametric=parametric,
output_stat=phase1_output_stat,
verbose=verbose,
)
if not isinstance(phase1_result, pd.DataFrame):
raise TypeError("Phase 1 result must be a pandas DataFrame (spots x clusters).")
priors_df = phase1_result.copy()
priors_df = priors_df.clip(lower=0)
priors_row_sum = priors_df.sum(axis=1).replace(0, np.nan)
priors_df = priors_df.div(priors_row_sum, axis=0).fillna(0)
prior_row_sums = priors_df.sum(axis=1)
informative_spots = prior_row_sums[prior_row_sums > 0].index
# initialize all spots to 0 (skip)
table.obs[mask_col] = 0
# mark informative spots as 1 (process in Phase 2)
table.obs.loc[
table.obs.index.intersection(informative_spots),
mask_col
] = 1
# -----------------------
# Phase 2: Evidence
# -----------------------
phase2_groups = phase2_markers_df["group"].drop_duplicates().astype(str).tolist()
phase2_candidate_mask = None
candidate_pruning_summary = _summarize_candidate_mask(
None,
enabled=False,
threshold=phase2_candidate_threshold,
)
if phase2_candidate_pruning:
phase2_candidate_mask = _build_phase2_candidate_mask(
priors_df,
phase2_groups=phase2_groups,
spatial_index=table.obs.index,
threshold=phase2_candidate_threshold,
)
candidate_pruning_summary = _summarize_candidate_mask(
phase2_candidate_mask,
enabled=True,
threshold=phase2_candidate_threshold,
)
phase2_performance = {}
phase2_result = get_clusters_by_similarity_on_tissue(
sdata=table,
markers_df=phase2_markers_df,
bin_size=bin_size,
gene_id_column="names",
celltype="group",
method=method,
add_to_obs=False,
#common_group_name="MarkerGroup" if isinstance(marker_genes,list) else None,
common_group_name=mask_col,
similarity_by_column=similarity_by_column,
weight_column=weight_column,
lambda_param=lambda_param,
min_markers=min_markers,
fallback_auc=fallback_auc,
expression_threshold=expression_threshold,
top_n_markers=top_n_markers,
recovery_power=recovery_power,
drop_shared_markers=drop_shared_markers,
center_auc=center_auc,
ucell_max_rank=ucell_max_rank,
ucell_negative_weight=ucell_negative_weight,
ucell_marker_role_column=ucell_marker_role_column,
verbose=verbose,
_diagnostics_out=phase2_performance,
_candidate_mask=phase2_candidate_mask,
)
if not isinstance(phase2_result, pd.DataFrame):
raise TypeError("Phase 2 result must be a pandas DataFrame (spots x clusters).")
likelihoods_df = _evidence_to_likelihood(
phase2_result,
method=evidence_to_likelihood,
softmax_tau=softmax_tau,
candidate_mask=phase2_candidate_mask if phase2_candidate_pruning else None,
)
# -----------------------
# Posterior combination
# -----------------------
if not marker_genes_is_list:
common_clusters = priors_df.columns.intersection(likelihoods_df.columns)
if len(common_clusters) == 0:
raise ValueError("No overlapping cluster columns between Phase 1 and Phase 2 outputs.")
priors_aligned = priors_df[common_clusters]
likelihoods_aligned = likelihoods_df[common_clusters]
common_spots = priors_aligned.index.intersection(likelihoods_aligned.index)
if len(common_spots) == 0:
raise ValueError("No overlapping spot/bin indices between Phase 1 and Phase 2 outputs.")
priors_aligned = priors_aligned.loc[common_spots]
likelihoods_aligned = likelihoods_aligned.loc[common_spots]
if phase2_candidate_pruning:
posterior_candidate_mask = _align_candidate_mask(
phase2_candidate_mask,
index=common_spots,
columns=common_clusters,
).astype(float)
priors_aligned = priors_aligned * posterior_candidate_mask
likelihoods_aligned = likelihoods_aligned * posterior_candidate_mask
# Optional: use priors as a presence/absence gate on BOTH priors and likelihoods
if apply_prior_presence_mask:
presence_mask = (priors_aligned > prior_presence_threshold).astype(float)
priors_aligned = priors_aligned * presence_mask
likelihoods_aligned = likelihoods_aligned * presence_mask
# Guard against exact zeros before exponentiation,
# but keep true zeros from masking as zeros:
priors_safe = priors_aligned.replace(0, np.nan).clip(lower=epsilon).fillna(0)
likelihoods_safe = likelihoods_aligned.replace(0, np.nan).clip(lower=epsilon).fillna(0)
posterior_unnorm = (priors_safe ** prior_weight) * (likelihoods_safe ** likelihood_weight)
row_sum = posterior_unnorm.sum(axis=1)
zero_rows = (row_sum <= epsilon)
if zero_rows.any():
# keep them as zero (no assignment from the posterior)
posterior_unnorm.loc[zero_rows] = 0.0
posterior_row_sum = posterior_unnorm.sum(axis=1).replace(0, np.nan)
posterior_df = posterior_unnorm.div(posterior_row_sum, axis=0).fillna(0)
else:
posterior_df = None
# -----------------------
# Final assignment
# -----------------------
assignment_df = posterior_df if posterior_df is not None and not marker_genes_is_list else phase2_result
assigned_labels = assign_clusters_from_df(
table,
df=assignment_df,
bin_size=bin_size,
results_column=results_column,
method=assign_method,
allow_multiple=allow_multiple,
diagnostic=diagnostic,
fold_change_threshold=fold_change_threshold,
minimum_evidence=minimum_evidence,
tie_tolerance=tie_tolerance,
verbose=verbose,
)
diagnostics = {
"markers": marker_diagnostics,
"n_phase1_spots": int(phase1_result.shape[0]),
"n_phase1_celltypes": int(phase1_result.shape[1]),
"n_phase2_spots": int(phase2_result.shape[0]),
"n_phase2_celltypes": int(phase2_result.shape[1]),
"posterior_available": posterior_df is not None,
"assignment_matrix": (
"posterior_df" if posterior_df is not None else "phase2_result"
),
"results_column": results_column,
"mask_col": mask_col,
"marker_roles": {
**marker_role_diagnostics,
"phase1_marker_source": phase1_marker_source,
},
"phase2": {
"method": method,
"min_markers": min_markers,
"fallback_auc": fallback_auc,
"expression_threshold": expression_threshold,
"top_n_markers": top_n_markers,
"recovery_power": recovery_power,
"drop_shared_markers": drop_shared_markers,
"center_auc": center_auc,
"ucell_max_rank": ucell_max_rank,
"ucell_negative_weight": ucell_negative_weight,
"ucell_marker_role_column": ucell_marker_role_column,
"performance": {
**phase2_performance,
**candidate_pruning_summary,
},
},
"assignment": {
"method": assign_method,
"minimum_evidence": minimum_evidence,
"tie_tolerance": tie_tolerance,
"allow_multiple": allow_multiple,
"fold_change_threshold": fold_change_threshold,
},
}
diagnostics["markers"]["top_n_applied_by"] = "workflow_phase_resolver"
if method == "ucell":
informative_rows = (phase2_result.max(axis=1) > 0)
diagnostics["phase2"]["n_informative_rows"] = int(informative_rows.sum())
diagnostics["phase2"]["n_uninformative_rows"] = int((~informative_rows).sum())
if verbose:
print("Finished easydecon workflow.")
if posterior_df is None:
print(
"Posterior df is None because marker_genes was provided as a "
"list-style mask workflow."
)
if return_result_object:
return EasyDeconResult(
markers_df=markers_df,
phase1_result=phase1_result,
phase2_result=phase2_result,
assigned_labels=assigned_labels,
priors_df=priors_df,
likelihoods_df=likelihoods_df,
posterior_df=posterior_df,
assignment_df=assignment_df,
diagnostics=diagnostics,
prepared_markers=resolved_prepared,
)
result_tuple = (
phase1_result,
phase2_result,
assigned_labels,
priors_df,
assignment_df,
)
if return_diagnostics:
return (*result_tuple, diagnostics)
return result_tuple