sampledisco 0.4.2__tar.gz → 0.5.0__tar.gz

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.
Files changed (97) hide show
  1. {sampledisco-0.4.2/src/sampledisco.egg-info → sampledisco-0.5.0}/PKG-INFO +2 -1
  2. {sampledisco-0.4.2 → sampledisco-0.5.0}/README.md +1 -0
  3. {sampledisco-0.4.2 → sampledisco-0.5.0}/pyproject.toml +1 -1
  4. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/__init__.py +6 -1
  5. sampledisco-0.5.0/src/sampledisco/sample_prediction/__init__.py +4 -0
  6. sampledisco-0.5.0/src/sampledisco/sample_prediction/prediction.py +186 -0
  7. {sampledisco-0.4.2 → sampledisco-0.5.0/src/sampledisco.egg-info}/PKG-INFO +2 -1
  8. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco.egg-info/SOURCES.txt +4 -1
  9. sampledisco-0.5.0/tests/test_sample_prediction.py +154 -0
  10. {sampledisco-0.4.2 → sampledisco-0.5.0}/LICENSE +0 -0
  11. {sampledisco-0.4.2 → sampledisco-0.5.0}/requirements.txt +0 -0
  12. {sampledisco-0.4.2 → sampledisco-0.5.0}/setup.cfg +0 -0
  13. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/cli.py +0 -0
  14. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/config/config_demo.yaml +0 -0
  15. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/gene_activity/ATAC_ArchR.py +0 -0
  16. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/gene_activity/ATAC_RNA_harmony.py +0 -0
  17. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/gene_activity/ATAC_gene_activity.py +0 -0
  18. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/gene_activity/ATAC_peak_annotation.py +0 -0
  19. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/gene_activity/RNA_name_convertor.py +0 -0
  20. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/gene_activity/__init__.py +0 -0
  21. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/gene_activity/pseudo_correlation.py +0 -0
  22. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/gene_activity/test_pseudo_correlation.py +0 -0
  23. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/gene_activity/validation.py +0 -0
  24. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/parameter_selection/__init__.py +0 -0
  25. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/parameter_selection/autotune.py +0 -0
  26. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/ATAC_cell_type.py +0 -0
  27. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/ATAC_cell_type_gpu.py +0 -0
  28. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/__init__.py +0 -0
  29. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/atac_preprocess_cpu.py +0 -0
  30. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/atac_preprocess_gpu.py +0 -0
  31. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/cell_type_cpu.py +0 -0
  32. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/cell_type_gpu.py +0 -0
  33. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/multi_omics_batch_correction.py +0 -0
  34. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/multi_omics_cell_type_cpu.py +0 -0
  35. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/multi_omics_cell_type_gpu.py +0 -0
  36. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/multi_omics_glue.py +0 -0
  37. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/multi_omics_merge.py +0 -0
  38. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/rna_preprocess_cpu.py +0 -0
  39. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/preparation/rna_preprocess_gpu.py +0 -0
  40. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_association/__init__.py +0 -0
  41. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_association/association.py +0 -0
  42. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_clustering/RAISIN.py +0 -0
  43. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_clustering/RAISIN_TEST.py +0 -0
  44. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_clustering/__init__.py +0 -0
  45. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_clustering/cluster.py +0 -0
  46. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_clustering/cluster_severity_reconcile.py +0 -0
  47. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_clustering/proportion_test.py +0 -0
  48. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_distance/ChiSquare.py +0 -0
  49. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_distance/__init__.py +0 -0
  50. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_distance/distance_test.py +0 -0
  51. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_distance/jensenshannon.py +0 -0
  52. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_distance/sample_distance.py +0 -0
  53. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_embedding/__init__.py +0 -0
  54. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_embedding/blocks.py +0 -0
  55. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_embedding/sample_embedding.py +0 -0
  56. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_embedding/sample_embedding_gpu.py +0 -0
  57. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_trajectory/CCA.py +0 -0
  58. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_trajectory/CCA_test.py +0 -0
  59. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_trajectory/TSCAN.py +0 -0
  60. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_trajectory/__init__.py +0 -0
  61. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_trajectory/trajectory_DGE_visualization.py +0 -0
  62. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/sample_trajectory/trajectory_diff_gene.py +0 -0
  63. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/Grouping.py +0 -0
  64. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/__init__.py +0 -0
  65. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/batch_regress.py +0 -0
  66. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/embedding_keys.py +0 -0
  67. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/harmony_compat.py +0 -0
  68. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/imbalance_cell_type_handler.py +0 -0
  69. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/inspector.py +0 -0
  70. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/limma.py +0 -0
  71. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/merge_sample_meta.py +0 -0
  72. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/random_seed.py +0 -0
  73. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/safe_save.py +0 -0
  74. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/slim_adata.py +0 -0
  75. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/subsample.py +0 -0
  76. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/subsample_generator.py +0 -0
  77. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/tf_idf.py +0 -0
  78. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/utils/unify_optimal.py +0 -0
  79. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/visualization/ATAC_visualization.py +0 -0
  80. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/visualization/DEG_visualization.py +0 -0
  81. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/visualization/__init__.py +0 -0
  82. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/visualization/multi_omics_visualization.py +0 -0
  83. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/visualization/visualization_embedding.py +0 -0
  84. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/visualization/visualization_helper.py +0 -0
  85. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/visualization/visualization_other.py +0 -0
  86. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/wrapper/__init__.py +0 -0
  87. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/wrapper/atac_wrapper.py +0 -0
  88. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/wrapper/multiomics_wrapper.py +0 -0
  89. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/wrapper/rna_wrapper.py +0 -0
  90. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco/wrapper/wrapper.py +0 -0
  91. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco.egg-info/dependency_links.txt +0 -0
  92. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco.egg-info/entry_points.txt +0 -0
  93. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco.egg-info/requires.txt +0 -0
  94. {sampledisco-0.4.2 → sampledisco-0.5.0}/src/sampledisco.egg-info/top_level.txt +0 -0
  95. {sampledisco-0.4.2 → sampledisco-0.5.0}/tests/test_config_validation.py +0 -0
  96. {sampledisco-0.4.2 → sampledisco-0.5.0}/tests/test_inplace_uns_and_equal_weight.py +0 -0
  97. {sampledisco-0.4.2 → sampledisco-0.5.0}/tests/test_sample_embedding_equivalence.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sampledisco
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Cross-omics, cross-condition sample embedding for single-cell data (RNA + ATAC)
5
5
  Author: Hongkai Ji
6
6
  Author-email: Harry Jiang <hjiang55@jh.edu>
@@ -109,6 +109,7 @@ code/
109
109
  │ ├── sample_clustering/ # Hierarchical (HRA / HRC / NN / UPGMA / consensus), K-means, proportion test, RAISIN
110
110
  │ ├── sample_trajectory/ # CCA (supervised) and TSCAN (unsupervised) + GAM-based trajectory DGE
111
111
  │ ├── sample_association/ # Per-PC variance explained vs sample-level covariates (permutation FDR)
112
+ │ ├── sample_prediction/ # Cross-validated phenotype prediction from the PCs; kNN label transfer
112
113
  │ ├── visualization/ # Embedding plots, dendrograms, DGE volcanos, modality-aware multi-omics scatters
113
114
  │ ├── utils/ # Shared helpers: seed, safe h5ad I/O, limma, TF-IDF, batch regress, Grouping
114
115
  │ └── gene_activity/ # ATAC peak → gene activity inference + RNA-ATAC validation
@@ -61,6 +61,7 @@ code/
61
61
  │ ├── sample_clustering/ # Hierarchical (HRA / HRC / NN / UPGMA / consensus), K-means, proportion test, RAISIN
62
62
  │ ├── sample_trajectory/ # CCA (supervised) and TSCAN (unsupervised) + GAM-based trajectory DGE
63
63
  │ ├── sample_association/ # Per-PC variance explained vs sample-level covariates (permutation FDR)
64
+ │ ├── sample_prediction/ # Cross-validated phenotype prediction from the PCs; kNN label transfer
64
65
  │ ├── visualization/ # Embedding plots, dendrograms, DGE volcanos, modality-aware multi-omics scatters
65
66
  │ ├── utils/ # Shared helpers: seed, safe h5ad I/O, limma, TF-IDF, batch regress, Grouping
66
67
  │ └── gene_activity/ # ATAC peak → gene activity inference + RNA-ATAC validation
@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
9
9
 
10
10
  [project]
11
11
  name = "sampledisco"
12
- version = "0.4.2"
12
+ version = "0.5.0"
13
13
  description = "Cross-omics, cross-condition sample embedding for single-cell data (RNA + ATAC)"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.10"
@@ -6,6 +6,8 @@ pull scanpy / torch / scGLUE until you actually call into the pipeline):
6
6
  import sampledisco
7
7
  sampledisco.wrapper(...) # full pipeline (RNA / ATAC / multi-omics)
8
8
  sampledisco.compute_sample_embedding(...) # the core method only
9
+ sampledisco.predict_phenotype(...) # cross-validated phenotype prediction from the PCs
10
+ sampledisco.transfer_labels(...) # kNN label transfer in the sample embedding
9
11
 
10
12
  The CLI entry point is ``sampledisco --config <yaml>`` (see ``sampledisco.cli``).
11
13
  """
@@ -18,7 +20,7 @@ try:
18
20
  except PackageNotFoundError: # running from a source tree that isn't installed
19
21
  __version__ = "0.0.0+unknown"
20
22
 
21
- __all__ = ["wrapper", "compute_sample_embedding"]
23
+ __all__ = ["wrapper", "compute_sample_embedding", "predict_phenotype", "transfer_labels"]
22
24
 
23
25
 
24
26
  def __getattr__(name: str):
@@ -29,4 +31,7 @@ def __getattr__(name: str):
29
31
  if name == "compute_sample_embedding":
30
32
  from sampledisco.sample_embedding import compute_sample_embedding
31
33
  return compute_sample_embedding
34
+ if name in ("predict_phenotype", "transfer_labels"):
35
+ from sampledisco.sample_prediction import prediction
36
+ return getattr(prediction, name)
32
37
  raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,4 @@
1
+ """Supervised downstream use of the sample embedding: phenotype prediction and label transfer."""
2
+ from sampledisco.sample_prediction.prediction import predict_phenotype, transfer_labels
3
+
4
+ __all__ = ["predict_phenotype", "transfer_labels"]
@@ -0,0 +1,186 @@
1
+ """Supervised use of the sample embedding: phenotype prediction and label transfer.
2
+
3
+ Both functions take a one-row-per-sample AnnData (``build_sample_adata``), read the
4
+ first ``n_pcs`` components of ``uns['X_DR_sample']`` (``obsm`` as fallback) and the
5
+ labels from ``.obs``.
6
+ """
7
+ from typing import Dict, Optional, Sequence, Tuple
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ from anndata import AnnData
12
+ from scipy.spatial.distance import cdist
13
+ from sklearn.linear_model import LogisticRegression, Ridge
14
+ from sklearn.metrics import accuracy_score, balanced_accuracy_score, r2_score, roc_auc_score
15
+ from sklearn.model_selection import GridSearchCV, GroupKFold, StratifiedGroupKFold
16
+ from sklearn.pipeline import Pipeline
17
+ from sklearn.preprocessing import StandardScaler
18
+
19
+
20
+ def _embedding(pseudo_adata: AnnData, n_pcs: Optional[int]) -> np.ndarray:
21
+ emb = pseudo_adata.uns.get("X_DR_sample")
22
+ if isinstance(emb, pd.DataFrame):
23
+ X = emb.loc[pseudo_adata.obs_names].to_numpy(dtype=float)
24
+ elif "X_DR_sample" in pseudo_adata.obsm:
25
+ X = np.asarray(pseudo_adata.obsm["X_DR_sample"], dtype=float)
26
+ else:
27
+ raise KeyError("'X_DR_sample' not found in .uns or .obsm; build the AnnData with build_sample_adata.")
28
+ return X[:, :n_pcs]
29
+
30
+
31
+ def predict_phenotype(
32
+ pseudo_adata: AnnData,
33
+ label_col: str,
34
+ *,
35
+ group_col: Optional[str] = None,
36
+ n_pcs: Optional[int] = None,
37
+ task: str = "classification",
38
+ score_by: str = "sample",
39
+ n_splits: int = 5,
40
+ random_state: int = 0,
41
+ ) -> Tuple[pd.DataFrame, Dict]:
42
+ """Out-of-fold prediction of a sample phenotype from the first ``n_pcs`` embedding PCs.
43
+
44
+ Standardized PCs feed an L2 logistic regression (``task="classification"``, binary or
45
+ multiclass) or a ridge regression (``task="regression"``). The penalty is chosen by an
46
+ inner 3-fold grouped CV inside each training split (scored by AUC, balanced accuracy
47
+ or R²) from C in {10, 1, 0.1, 0.01} or alpha in {0.1, 1, 10, 100}; a tie goes to the
48
+ weakest penalty. The outer split is ``n_splits``-fold, stratified by class for
49
+ classification and seeded by ``random_state``. With ``group_col`` (e.g. donor) every
50
+ sample of a group falls in the same fold, outer and inner, so draws of one donor are
51
+ never in training and test at once. Samples with a missing label are left out. This is
52
+ the recipe of the paper's Sound Life trait prediction.
53
+
54
+ ``score_by="group"`` scores groups instead of samples: the out-of-fold probabilities
55
+ (or predictions) and the labels are averaged over ``group_col`` before scoring (the
56
+ majority label for classification), as for the paper's donor-level AUCs.
57
+
58
+ Returns ``(predictions, metrics)``. ``predictions`` is indexed by sample with columns
59
+ ``label``, ``predicted``, ``fold`` and, for classification, ``prob_<class>``.
60
+ ``metrics`` holds ``n_samples``, ``n_groups``, ``n_pcs``, the penalty chosen in each
61
+ fold (``C`` or ``alpha``) and ``auc`` (binary; the second class in sorted order is
62
+ positive), ``accuracy`` and ``balanced_accuracy`` (classification) or ``r2``
63
+ (regression), computed on the out-of-fold predictions at the ``score_by`` level.
64
+ """
65
+ if task not in ("classification", "regression") or score_by not in ("sample", "group"):
66
+ raise ValueError("task must be 'classification' or 'regression', score_by 'sample' or 'group'.")
67
+ obs = pseudo_adata.obs
68
+ keep = obs[label_col].notna().to_numpy()
69
+ X = _embedding(pseudo_adata, n_pcs)[keep]
70
+ labels = obs[label_col].to_numpy()[keep]
71
+ groups = (obs[group_col] if group_col else obs.index.to_series()).astype(str).to_numpy()[keep]
72
+
73
+ if task == "classification":
74
+ classes, y = np.unique(labels, return_inverse=True)
75
+ model, param = LogisticRegression(max_iter=3000), "C"
76
+ grid, scoring = [10, 1, 0.1, 0.01], "roc_auc" if len(classes) == 2 else "balanced_accuracy"
77
+ outer = StratifiedGroupKFold(n_splits, shuffle=True, random_state=random_state).split(X, y, groups)
78
+ P = np.zeros((len(y), len(classes)))
79
+ else:
80
+ y = labels.astype(float)
81
+ model, param, grid, scoring = Ridge(), "alpha", [0.1, 1, 10, 100], "r2"
82
+ codes = np.unique(groups, return_inverse=True)[1]
83
+ shuffled = np.random.default_rng(random_state).permutation(codes.max() + 1)[codes]
84
+ outer = GroupKFold(n_splits).split(X, y, shuffled)
85
+ pred = np.zeros(len(y))
86
+
87
+ fold, chosen = np.zeros(len(y), dtype=int), []
88
+ pipe = Pipeline([("scale", StandardScaler()), ("model", model)])
89
+ for i, (tr, te) in enumerate(outer):
90
+ gs = GridSearchCV(pipe, {f"model__{param}": grid}, cv=GroupKFold(3), scoring=scoring)
91
+ gs.fit(X[tr], y[tr], groups=groups[tr])
92
+ chosen.append(gs.best_params_[f"model__{param}"])
93
+ fold[te] = i
94
+ if task == "classification":
95
+ P[np.ix_(te, gs.classes_)] = gs.predict_proba(X[te])
96
+ else:
97
+ pred[te] = gs.predict(X[te])
98
+
99
+ metrics = {"task": task, "score_by": score_by, "n_samples": len(y), "n_groups": len(np.unique(groups)),
100
+ "n_pcs": X.shape[1], param: chosen}
101
+ out = pd.DataFrame({"label": labels}, index=obs.index[keep])
102
+ if task == "classification":
103
+ out["predicted"] = classes[P.argmax(1)]
104
+ for j, c in enumerate(classes):
105
+ out[f"prob_{c}"] = P[:, j]
106
+ Y = np.eye(len(classes))[y] # one-hot; summed over a group = class counts
107
+ if score_by == "group":
108
+ Y = pd.DataFrame(Y).groupby(groups).sum().to_numpy()
109
+ P = pd.DataFrame(P).groupby(groups).mean().to_numpy()
110
+ y, hard = Y.argmax(1), P.argmax(1)
111
+ if len(classes) == 2:
112
+ metrics["auc"] = float(roc_auc_score(y, P[:, 1]))
113
+ metrics["accuracy"] = float(accuracy_score(y, hard))
114
+ metrics["balanced_accuracy"] = float(balanced_accuracy_score(y, hard))
115
+ else:
116
+ out["predicted"] = pred
117
+ if score_by == "group":
118
+ y, pred = (pd.Series(v).groupby(groups).mean().to_numpy() for v in (y, pred))
119
+ metrics["r2"] = float(r2_score(y, pred))
120
+ out["fold"] = fold
121
+ return out.infer_objects(), metrics
122
+
123
+
124
+ def _knn_vote(D: np.ndarray, y: np.ndarray, k: int, weighted: bool) -> Tuple[np.ndarray, np.ndarray]:
125
+ pred, conf = np.full(len(D), np.nan, dtype=object), np.full(len(D), np.nan)
126
+ for i, d in enumerate(D):
127
+ nn = np.argsort(d, kind="stable")[:k]
128
+ nn = nn[np.isfinite(d[nn])]
129
+ if nn.size == 0:
130
+ continue
131
+ w = 1.0 / np.maximum(d[nn], 1e-12) if weighted else np.ones(nn.size)
132
+ votes = pd.Series(w).groupby(y[nn], sort=False).sum() # labels in order of first (nearest) neighbour
133
+ pred[i], conf[i] = votes.idxmax(), votes.max() / w.sum()
134
+ return pred, conf
135
+
136
+
137
+ def transfer_labels(
138
+ pseudo_adata: AnnData,
139
+ label_col: str,
140
+ *,
141
+ query: Optional[Sequence[bool]] = None,
142
+ n_neighbors: int = 5,
143
+ weighted: bool = False,
144
+ n_pcs: Optional[int] = None,
145
+ group_col: Optional[str] = None,
146
+ ) -> Tuple[pd.DataFrame, Dict]:
147
+ """Label query samples by a k-nearest-neighbour vote in the sample embedding.
148
+
149
+ References are the samples with a label in ``label_col`` that are not in the boolean
150
+ ``query`` mask; every other sample is a query (by default, the unlabelled samples).
151
+ Each query takes the majority label of its ``n_neighbors`` nearest references
152
+ (Euclidean distance on the first ``n_pcs`` PCs); with ``weighted=True`` each vote
153
+ counts 1/distance. Ties go to the label of the nearest tied neighbour. The confidence
154
+ is the (weighted) share of the votes won by the assigned label.
155
+
156
+ To judge reliability, every reference is also labelled from the other references
157
+ (leave-one-out). With ``group_col`` (a donor, or ``sample`` for multi-omics units) the
158
+ references of the same group are left out as well, so a unit is never matched to
159
+ another unit of the same donor or sample.
160
+
161
+ Returns ``(table, metrics)``. ``table`` is indexed by sample with columns ``label``,
162
+ ``query``, ``predicted`` (the transferred label for queries, the leave-one-out label for
163
+ references) and ``confidence``. ``metrics`` holds ``loo_accuracy``,
164
+ ``loo_balanced_accuracy``, ``n_reference``, ``n_query``, ``n_neighbors`` and ``n_pcs``.
165
+ """
166
+ obs = pseudo_adata.obs
167
+ X = _embedding(pseudo_adata, n_pcs)
168
+ labels = obs[label_col]
169
+ ref = labels.notna().to_numpy()
170
+ if query is not None:
171
+ ref &= ~np.asarray(query, dtype=bool)
172
+ y = labels.to_numpy()[ref]
173
+
174
+ D = cdist(X, X[ref])
175
+ g = (obs[group_col] if group_col else obs.index.to_series()).astype(str).to_numpy()[ref]
176
+ D[ref] = np.where(g[:, None] == g[None, :], np.inf, D[ref])
177
+ pred, conf = _knn_vote(D, y, n_neighbors, weighted)
178
+
179
+ table = pd.DataFrame({"label": labels.to_numpy(), "query": ~ref, "predicted": pred, "confidence": conf},
180
+ index=obs.index).infer_objects() # numeric labels back to a numeric dtype
181
+ loo = table[ref & pd.notna(pred)]
182
+ metrics = {"loo_accuracy": float(accuracy_score(loo["label"], loo["predicted"])),
183
+ "loo_balanced_accuracy": float(balanced_accuracy_score(loo["label"], loo["predicted"])),
184
+ "n_reference": int(ref.sum()), "n_query": int((~ref).sum()),
185
+ "n_neighbors": n_neighbors, "n_pcs": X.shape[1]}
186
+ return table, metrics
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sampledisco
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Cross-omics, cross-condition sample embedding for single-cell data (RNA + ATAC)
5
5
  Author: Hongkai Ji
6
6
  Author-email: Harry Jiang <hjiang55@jh.edu>
@@ -109,6 +109,7 @@ code/
109
109
  │ ├── sample_clustering/ # Hierarchical (HRA / HRC / NN / UPGMA / consensus), K-means, proportion test, RAISIN
110
110
  │ ├── sample_trajectory/ # CCA (supervised) and TSCAN (unsupervised) + GAM-based trajectory DGE
111
111
  │ ├── sample_association/ # Per-PC variance explained vs sample-level covariates (permutation FDR)
112
+ │ ├── sample_prediction/ # Cross-validated phenotype prediction from the PCs; kNN label transfer
112
113
  │ ├── visualization/ # Embedding plots, dendrograms, DGE volcanos, modality-aware multi-omics scatters
113
114
  │ ├── utils/ # Shared helpers: seed, safe h5ad I/O, limma, TF-IDF, batch regress, Grouping
114
115
  │ └── gene_activity/ # ATAC peak → gene activity inference + RNA-ATAC validation
@@ -53,6 +53,8 @@ src/sampledisco/sample_embedding/__init__.py
53
53
  src/sampledisco/sample_embedding/blocks.py
54
54
  src/sampledisco/sample_embedding/sample_embedding.py
55
55
  src/sampledisco/sample_embedding/sample_embedding_gpu.py
56
+ src/sampledisco/sample_prediction/__init__.py
57
+ src/sampledisco/sample_prediction/prediction.py
56
58
  src/sampledisco/sample_trajectory/CCA.py
57
59
  src/sampledisco/sample_trajectory/CCA_test.py
58
60
  src/sampledisco/sample_trajectory/TSCAN.py
@@ -89,4 +91,5 @@ src/sampledisco/wrapper/rna_wrapper.py
89
91
  src/sampledisco/wrapper/wrapper.py
90
92
  tests/test_config_validation.py
91
93
  tests/test_inplace_uns_and_equal_weight.py
92
- tests/test_sample_embedding_equivalence.py
94
+ tests/test_sample_embedding_equivalence.py
95
+ tests/test_sample_prediction.py
@@ -0,0 +1,154 @@
1
+ """predict_phenotype and transfer_labels on synthetic sample embeddings with a known answer.
2
+ Run with `pytest tests/` or `python tests/test_sample_prediction.py`."""
3
+ import anndata as ad
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from sklearn.metrics import r2_score, roc_auc_score
8
+
9
+ from sampledisco import predict_phenotype, transfer_labels
10
+
11
+
12
+ def make(X, **obs):
13
+ a = ad.AnnData(X=X.astype(np.float32), obs=pd.DataFrame(obs, index=[f"s{i}" for i in range(len(X))]))
14
+ a.uns["X_DR_sample"] = pd.DataFrame(X, index=a.obs_names, columns=[f"PC{j + 1}" for j in range(X.shape[1])])
15
+ return a
16
+
17
+
18
+ def separated(n=60, pc=0, seed=0):
19
+ """Two classes 12 SD apart on one PC (``pc``), pure noise on the other four."""
20
+ rng = np.random.default_rng(seed)
21
+ y = np.repeat(["healthy", "severe"], n // 2)
22
+ X = rng.normal(size=(n, 5))
23
+ X[:, pc] += np.where(y == "severe", 6.0, -6.0)
24
+ return make(X, status=y)
25
+
26
+
27
+ def test_binary_perfect():
28
+ pred, m = predict_phenotype(separated(), "status")
29
+ assert m["auc"] == 1.0 and m["accuracy"] == 1.0 and m["n_pcs"] == 5
30
+ assert m["C"] == [10] * 5 # every C ties on separable data: weakest penalty
31
+ assert (pred["predicted"] == pred["label"]).all()
32
+ assert np.allclose(pred[["prob_healthy", "prob_severe"]].sum(1), 1)
33
+
34
+
35
+ def test_only_first_n_pcs_used():
36
+ a = separated(pc=2) # signal on PC3 only
37
+ assert predict_phenotype(a, "status", n_pcs=3)[1]["auc"] == 1.0
38
+ assert predict_phenotype(a, "status", n_pcs=2)[1]["auc"] < 0.75
39
+ assert transfer_labels(a, "status", n_pcs=3)[1]["loo_accuracy"] == 1.0
40
+ assert transfer_labels(a, "status", n_pcs=2)[1]["loo_accuracy"] < 0.75
41
+
42
+
43
+ def test_random_labels_near_chance():
44
+ rng = np.random.default_rng(1)
45
+ a = make(rng.normal(size=(200, 10)), status=rng.choice(["a", "b"], 200))
46
+ _, m = predict_phenotype(a, "status")
47
+ assert 0.3 < m["auc"] < 0.7
48
+
49
+
50
+ def test_multiclass_and_regression():
51
+ rng = np.random.default_rng(2)
52
+ y = np.repeat([0, 1, 2], 30)
53
+ X = rng.normal(size=(90, 4))
54
+ X[:, 0] += 8 * y
55
+ a = make(X, grade=y, score=3 * X[:, 0] + rng.normal(size=90))
56
+ pred, m = predict_phenotype(a, "grade")
57
+ assert "auc" not in m and m["accuracy"] > 0.95 and {"prob_0", "prob_1", "prob_2"} <= set(pred.columns)
58
+ pred, m = predict_phenotype(a, "score", task="regression")
59
+ assert m["r2"] > 0.95 and len(m["alpha"]) == 5
60
+
61
+
62
+ def test_groups_never_split_and_seeded():
63
+ """Each donor's draws share a fold. The label is a donor trait unrelated to X, and donors sit
64
+ apart in 30 dimensions: when draws of a donor are split across folds the model memorizes donors."""
65
+ rng = np.random.default_rng(3)
66
+ donors = np.repeat([f"d{i}" for i in range(20)], 4)
67
+ status = np.repeat(rng.permutation(np.repeat(["neg", "pos"], 10)), 4)
68
+ X = np.repeat(rng.normal(size=(20, 30)), 4, axis=0) + rng.normal(size=(80, 30)) * 0.05
69
+ a = make(X, status=status, donor=donors)
70
+ pred, m = predict_phenotype(a, "status", group_col="donor")
71
+ assert (pred.groupby(a.obs["donor"])["fold"].nunique() == 1).all() and m["n_groups"] == 20
72
+ assert m["auc"] < 0.8 # no donor leakage: near chance
73
+ _, leaky = predict_phenotype(a, "status") # draws split across folds: inflated
74
+ assert leaky["auc"] > 0.95
75
+ again, _ = predict_phenotype(a, "status", group_col="donor")
76
+ assert again.equals(pred)
77
+
78
+
79
+ def test_score_by_group():
80
+ """Group scores = scores of the per-group mean of the out-of-fold predictions."""
81
+ rng = np.random.default_rng(5)
82
+ donors = np.repeat([f"d{i}" for i in range(30)], 3)
83
+ trait = np.repeat(rng.choice(["neg", "pos"], 30), 3)
84
+ X = rng.normal(size=(90, 4))
85
+ X[:, 0] += np.where(trait == "pos", 1.0, -1.0)
86
+ a = make(X, trait=trait, donor=donors, bmi=X[:, 0] + rng.normal(size=90))
87
+ pred, m = predict_phenotype(a, "trait", group_col="donor", score_by="group")
88
+ d = pred.groupby(a.obs["donor"]).agg(label=("label", "first"), p=("prob_pos", "mean"))
89
+ assert np.isclose(m["auc"], roc_auc_score(d["label"] == "pos", d["p"]))
90
+ assert np.isclose(m["accuracy"], ((d["p"] > 0.5) == (d["label"] == "pos")).mean())
91
+ assert pred.equals(predict_phenotype(a, "trait", group_col="donor")[0]) # only the scoring changes
92
+ pred, m = predict_phenotype(a, "bmi", group_col="donor", task="regression", score_by="group")
93
+ d = pred.groupby(a.obs["donor"])[["label", "predicted"]].mean()
94
+ assert np.isclose(m["r2"], r2_score(d["label"], d["predicted"]))
95
+
96
+
97
+ def test_missing_labels_skipped_and_obsm_fallback():
98
+ a = separated()
99
+ a.obs.loc[["s0", "s1"], "status"] = np.nan
100
+ a.obsm["X_DR_sample"] = a.uns.pop("X_DR_sample").to_numpy()
101
+ pred, m = predict_phenotype(a, "status")
102
+ assert m["n_samples"] == 58 and "s0" not in pred.index and m["auc"] > 0.99
103
+
104
+
105
+ def test_transfer_recovers_held_out_labels():
106
+ a = separated()
107
+ hidden = a.obs["status"].copy()
108
+ a.obs.loc[a.obs_names[::6], "status"] = np.nan
109
+ table, m = transfer_labels(a, "status")
110
+ q = table[table["query"]]
111
+ assert len(q) == 10 and (q["predicted"] == hidden[q.index]).all() and (q["confidence"] == 1).all()
112
+ assert m["loo_accuracy"] == 1.0 and m["n_reference"] == 50 and m["n_query"] == 10
113
+
114
+
115
+ def test_transfer_mask_vote_tie_and_weights():
116
+ """Query at 0; references at 1 (A), 2 (B), 3 (B), 4 (A). k=4 is a 2:2 tie -> nearest (A).
117
+ k=3: majority B (2/3); distance-weighted A wins (1 vs 1/2 + 1/3)."""
118
+ X = np.array([[0.0], [1.0], [2.0], [3.0], [4.0]])
119
+ a = make(X, cls=["A", "A", "B", "B", "A"])
120
+ query = np.array([True, False, False, False, False]) # labelled but masked
121
+ table, _ = transfer_labels(a, "cls", query=query, n_neighbors=4)
122
+ assert table.loc["s0", "predicted"] == "A" and table.loc["s0", "confidence"] == 0.5
123
+ table, _ = transfer_labels(a, "cls", query=query, n_neighbors=3)
124
+ assert table.loc["s0", "predicted"] == "B" and np.isclose(table.loc["s0", "confidence"], 2 / 3)
125
+ table, _ = transfer_labels(a, "cls", query=query, n_neighbors=3, weighted=True)
126
+ assert table.loc["s0", "predicted"] == "A" and np.isclose(table.loc["s0", "confidence"], 1 / (1 + 1 / 2 + 1 / 3))
127
+
128
+
129
+ def test_transfer_loo_excludes_group():
130
+ """Paired units (one sample in two modalities) sit on top of each other: without group_col each
131
+ unit's nearest reference is its partner, so leave-one-out accuracy is inflated."""
132
+ rng = np.random.default_rng(4)
133
+ centres = rng.normal(size=(40, 3)) * 5
134
+ lab = rng.choice(["x", "y"], 40) # unrelated to position
135
+ X = np.repeat(centres, 2, axis=0) + rng.normal(size=(80, 3)) * 0.01
136
+ a = make(X, sample=np.repeat([f"p{i}" for i in range(40)], 2), cls=np.repeat(lab, 2))
137
+ _, leaky = transfer_labels(a, "cls", n_neighbors=1)
138
+ _, fair = transfer_labels(a, "cls", n_neighbors=1, group_col="sample")
139
+ assert leaky["loo_accuracy"] == 1.0 and fair["loo_accuracy"] < 0.75
140
+
141
+
142
+ def test_numeric_labels_keep_dtype():
143
+ a = separated()
144
+ a.obs["sev"] = np.where(a.obs["status"] == "severe", 4.0, 1.0)
145
+ a.obs.loc["s3", "sev"] = np.nan
146
+ table, m = transfer_labels(a, "sev")
147
+ assert table["predicted"].dtype == float and table.loc["s3", "predicted"] == 1.0 and m["loo_accuracy"] == 1.0
148
+
149
+
150
+ if __name__ == "__main__":
151
+ for name, fn in list(globals().items()):
152
+ if name.startswith("test_"):
153
+ fn()
154
+ print("ok", name)
File without changes
File without changes