zmap-tools 0.2.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zmap-tools
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: API for working with ZMAP (Zebrafish Multi-Atlas Project)
5
5
  Author-email: Daniel Wagner <Daniel.Wagner@ucsf.edu>
6
6
  License: BSD 3-Clause License
@@ -50,7 +50,7 @@ License-File: LICENSE
50
50
  Requires-Dist: anndata>=0.10
51
51
  Requires-Dist: scanpy>=1.9
52
52
  Requires-Dist: numpy>=1.24
53
- Requires-Dist: pandas>=2.0
53
+ Requires-Dist: pandas<3.0,>=2.0
54
54
  Requires-Dist: matplotlib>=3.7
55
55
  Requires-Dist: requests>=2.31
56
56
  Requires-Dist: scipy>=1.10
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zmap-tools"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "API for working with ZMAP (Zebrafish Multi-Atlas Project)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -30,7 +30,7 @@ dependencies = [
30
30
  "anndata>=0.10",
31
31
  "scanpy>=1.9",
32
32
  "numpy>=1.24",
33
- "pandas>=2.0",
33
+ "pandas>=2.0,<3.0",
34
34
  "matplotlib>=3.7",
35
35
  "requests>=2.31",
36
36
  "scipy>=1.10",
@@ -18,10 +18,12 @@ _FAISS_INDEX_CACHE_ORDER: list[tuple[str, str, int, int]] = []
18
18
 
19
19
 
20
20
  def _as_float32_c(x: np.ndarray) -> np.ndarray:
21
+ """Cast array to float32 and ensure C-contiguous memory layout (required by FAISS)."""
21
22
  return np.ascontiguousarray(np.asarray(x, dtype=np.float32))
22
23
 
23
24
 
24
25
  def _l2_normalize(x: np.ndarray) -> np.ndarray:
26
+ """Row-wise L2-normalize an array to unit vectors (used for cosine similarity via inner product)."""
25
27
  x = _as_float32_c(x)
26
28
  norms = np.linalg.norm(x, axis=1, keepdims=True)
27
29
  norms = np.where(norms == 0, 1.0, norms)
@@ -29,6 +31,7 @@ def _l2_normalize(x: np.ndarray) -> np.ndarray:
29
31
 
30
32
 
31
33
  def _parse_cuda_device(device: str) -> int:
34
+ """Parse a device string like 'cuda:1' into an integer GPU index. Returns 0 on failure."""
32
35
  d = str(device).strip().lower()
33
36
  if ":" in d:
34
37
  try:
@@ -44,6 +47,17 @@ def _search_sklearn(
44
47
  n_neighbors: int,
45
48
  metric: str,
46
49
  ) -> tuple[np.ndarray, np.ndarray]:
50
+ """
51
+ kNN search using scikit-learn's NearestNeighbors.
52
+
53
+ Used as the fallback when FAISS is unavailable or fails. Slower than FAISS
54
+ on large references but supports any sklearn-compatible metric.
55
+
56
+ Returns
57
+ -------
58
+ neighbor_indices : np.ndarray, shape (n_query, n_neighbors)
59
+ distances : np.ndarray, shape (n_query, n_neighbors)
60
+ """
47
61
  nn = NearestNeighbors(n_neighbors=n_neighbors, metric=metric)
48
62
  nn.fit(ref)
49
63
  distances, neighbor_indices = nn.kneighbors(query, return_distance=True)
@@ -59,6 +73,44 @@ def _search_faiss(
59
73
  nprobe: int | None = None,
60
74
  cache_key: str | None = None,
61
75
  ) -> tuple[np.ndarray, np.ndarray, dict[str, Any]]:
76
+ """
77
+ kNN search using FAISS with optional GPU acceleration and index caching.
78
+
79
+ Builds an IVF-Flat index by default for fast approximate search on large references.
80
+ Falls back to an exact Flat index if IVF training fails. Cosine similarity is
81
+ implemented as inner product after L2-normalization; euclidean uses L2 distance.
82
+
83
+ Parameters
84
+ ----------
85
+ ref : np.ndarray, shape (n_ref, d)
86
+ Reference embedding matrix.
87
+ query : np.ndarray, shape (n_query, d)
88
+ Query embedding matrix.
89
+ n_neighbors : int
90
+ Number of nearest neighbors to return.
91
+ metric : {'euclidean', 'cosine'}
92
+ Distance metric.
93
+ device : str
94
+ Target device — 'cpu', 'auto', 'cuda', or 'cuda:<id>'.
95
+ nprobe : int, optional
96
+ Number of IVF cells to probe during search. Higher values increase recall
97
+ at the cost of speed. Defaults to DEFAULT_FAISS_NPROBE (16).
98
+ cache_key : str, optional
99
+ If provided, the built FAISS index is cached in-memory under this key and
100
+ reused on subsequent calls with matching (key, metric, n_ref, d). Up to
101
+ MAX_FAISS_INDEX_CACHE (16) indices are retained (LRU eviction).
102
+
103
+ Returns
104
+ -------
105
+ indices : np.ndarray, shape (n_query, n_neighbors), dtype int64
106
+ Indices into ref for each neighbor. Entries are -1 (set to NaN in distances)
107
+ when FAISS cannot fill all k neighbors (rare with Flat indices).
108
+ distances : np.ndarray, shape (n_query, n_neighbors), dtype float32
109
+ Euclidean distances or cosine-distance proxies (1 - cosine_similarity).
110
+ meta : dict
111
+ Diagnostic info: backend_used, device_used, index_type, nlist, nprobe,
112
+ cache_key_used, cache_hit.
113
+ """
62
114
  try:
63
115
  import faiss # type: ignore
64
116
  except Exception as e:
@@ -233,21 +285,47 @@ def knn_search(
233
285
  cache_key: str | None = None,
234
286
  ) -> tuple[np.ndarray, np.ndarray, dict[str, Any]]:
235
287
  """
236
- Unified kNN search for ZMAP API.
237
-
238
- backend:
239
- - auto: try faiss first, fallback to sklearn
240
- - faiss: require faiss, fallback to sklearn with warning
241
- - sklearn: force sklearn
242
- device:
243
- - auto | cpu | cuda | cuda:<id>
244
- - used only by faiss backend
245
- nprobe:
246
- - IVF probe count for FAISS (None -> default)
247
- - used only by faiss backend
248
- cache_key:
249
- - optional key to reuse FAISS index build across repeated calls
250
- - used only by faiss backend
288
+ Unified kNN search supporting FAISS and scikit-learn backends.
289
+
290
+ Tries FAISS first (fast, approximate) and falls back to sklearn (exact) if
291
+ FAISS is unavailable or raises. Both backends return consistent output shapes
292
+ and distance semantics.
293
+
294
+ Parameters
295
+ ----------
296
+ ref : np.ndarray, shape (n_ref, d)
297
+ Reference embedding matrix.
298
+ query : np.ndarray, shape (n_query, d)
299
+ Query embedding matrix.
300
+ n_neighbors : int
301
+ Number of nearest neighbors to return per query point.
302
+ metric : {'cosine', 'euclidean'}, default 'cosine'
303
+ Distance metric. Cosine is computed as 1 - cosine_similarity.
304
+ backend : {'auto', 'faiss', 'sklearn'}, default 'auto'
305
+ Search backend. 'auto' tries FAISS first and falls back to sklearn.
306
+ 'faiss' also falls back to sklearn with a warning if FAISS fails.
307
+ 'sklearn' skips FAISS entirely.
308
+ device : {'auto', 'cpu', 'cuda', 'cuda:<id>'}, default 'auto'
309
+ Device for FAISS index. Ignored by the sklearn backend. 'auto' uses
310
+ GPU if FAISS GPU support is available, otherwise CPU.
311
+ nprobe : int, optional
312
+ Number of IVF partitions to probe during FAISS search. Higher values
313
+ improve recall at the cost of speed. Defaults to DEFAULT_FAISS_NPROBE.
314
+ Ignored by the sklearn backend and FAISS Flat indices.
315
+ cache_key : str, optional
316
+ If provided, the FAISS index built for `ref` is cached in-memory under
317
+ this key and reused on subsequent calls with the same (key, metric,
318
+ n_ref, d). Useful when the same reference is queried repeatedly.
319
+
320
+ Returns
321
+ -------
322
+ indices : np.ndarray, shape (n_query, n_neighbors), dtype int64
323
+ Row indices into `ref` for each neighbor, ordered by ascending distance.
324
+ distances : np.ndarray, shape (n_query, n_neighbors), dtype float32
325
+ Corresponding distances. NaN where FAISS returns invalid indices (-1).
326
+ meta : dict
327
+ Diagnostic info including backend_used, device_used, index_type, and
328
+ cache_hit. Useful for verifying which backend and device were actually used.
251
329
  """
252
330
  metric = str(metric).lower()
253
331
  backend_req = str(backend).lower()
@@ -962,7 +962,7 @@ def predict_labels_kNN(
962
962
  )
963
963
 
964
964
  # compact digest to validate cache
965
- _mask_digest = [int(n_ref_keep), int(np.sum(np.flatnonzero(ref_keep_mask.to_numpy()) % 1048573))]
965
+ _mask_digest = [str(int(n_ref_keep)), str(int(np.sum(np.flatnonzero(ref_keep_mask.to_numpy()) % 1048573)))]
966
966
 
967
967
  # filtered reference arrays
968
968
  X_ref_all = adata_ref.obsm[ref_basis]
@@ -991,7 +991,7 @@ def predict_labels_kNN(
991
991
  cache.get('query_basis') == query_basis and
992
992
  cache.get('metric') == metric and
993
993
  cache.get('n_neighbors') == n_neighbors and
994
- cache.get('mask_digest') == _mask_digest and
994
+ list(cache.get('mask_digest', [])) == list(_mask_digest) and
995
995
  cache.get('tissue_mode', 'none') == expected_cache_mode and
996
996
  cache.get('knn_backend_requested', 'auto') == knn_backend and
997
997
  cache.get('knn_device_requested', 'auto') == knn_device and
@@ -1101,7 +1101,7 @@ def predict_labels_kNN(
1101
1101
 
1102
1102
  predicted_labels = sorted_classes[np.argmax(probabilities_sorted, axis=1)]
1103
1103
  predicted_labels = predicted_labels.astype(object)
1104
- predicted_labels[~has_votes] = pd.NA
1104
+ predicted_labels[~has_votes] = np.nan
1105
1105
 
1106
1106
  # ---------- outputs ----------
1107
1107
  if omit_labels:
@@ -1224,7 +1224,7 @@ def predict_labels_kNN(
1224
1224
  adata_query.obs[col_reject] = ~accept
1225
1225
 
1226
1226
  # Mask rejected predictions
1227
- adata_query.obs.loc[~accept, col_main] = pd.NA
1227
+ adata_query.obs.loc[~accept, col_main] = np.nan
1228
1228
 
1229
1229
  n_total = len(accept)
1230
1230
  n_accept = int(accept.sum())
@@ -1285,7 +1285,7 @@ def predict_labels_kNN(
1285
1285
  rare_labels = label_counts[label_counts < min_cells_per_label].index
1286
1286
  if len(rare_labels) > 0:
1287
1287
  adata_query.obs[col_rareflag] = adata_query.obs[col_main].isin(rare_labels)
1288
- adata_query.obs.loc[adata_query.obs[col_rareflag], col_main] = pd.NA
1288
+ adata_query.obs.loc[adata_query.obs[col_rareflag], col_main] = np.nan
1289
1289
  adata_query.uns.setdefault('zmap_labels', {}).setdefault(space, {})
1290
1290
  adata_query.uns['zmap_labels'][space]['Rare Labels'] = list(rare_labels)
1291
1291
  _zlog(f"Filtered {len(rare_labels)} rare labels: {list(rare_labels[:10])}{'...' if len(rare_labels) > 10 else ''}")
@@ -1671,8 +1671,8 @@ def predict_labels_tissue_kNN(
1671
1671
  )
1672
1672
 
1673
1673
  mask_digest = [
1674
- int(n_ref_keep),
1675
- int(np.sum(np.flatnonzero(ref_keep_mask.to_numpy()) % 1048573)),
1674
+ str(int(n_ref_keep)),
1675
+ str(int(np.sum(np.flatnonzero(ref_keep_mask.to_numpy()) % 1048573))),
1676
1676
  ]
1677
1677
 
1678
1678
  p_thresh_use = p_thresh
@@ -2061,6 +2061,8 @@ def summarize_knn_run(adata_query, label_key):
2061
2061
  ("pct_assigned", cov.get("pct_assigned")),
2062
2062
  ], columns=["Key", "Value"])
2063
2063
 
2064
+ df["Value"] = df["Value"].astype(str)
2065
+
2064
2066
  return df
2065
2067
 
2066
2068
 
@@ -2227,6 +2229,17 @@ def aggregate_by_cluster(
2227
2229
 
2228
2230
  df = pd.DataFrame(records)
2229
2231
 
2232
+ # Ensure object columns are h5ad-serializable in uns:
2233
+ # - numeric columns demoted to object by pd.NA → coerce back to float (pd.NA → np.nan)
2234
+ # - string columns with pd.NA → fillna("") and cast to str
2235
+ _numeric_cols = {"top_fraction", "mean_prob", "margin", "second_fraction"}
2236
+ for col in df.columns:
2237
+ if df[col].dtype == object:
2238
+ if col in _numeric_cols:
2239
+ df[col] = pd.to_numeric(df[col], errors="coerce")
2240
+ else:
2241
+ df[col] = df[col].fillna("").astype(str)
2242
+
2230
2243
  if save_csv:
2231
2244
  os.makedirs(output_dir, exist_ok=True)
2232
2245
  out_path = os.path.join(output_dir, f"{label_space}_cluster_summary.csv")
@@ -2305,6 +2318,11 @@ def build_cell_annotations_table(
2305
2318
  df.index.name = "cell_id"
2306
2319
  df = df.reset_index()
2307
2320
 
2321
+ # Ensure object columns are pure str (np.nan in object dtype is not h5ad-serializable in uns)
2322
+ for col in df.columns:
2323
+ if df[col].dtype == object:
2324
+ df[col] = df[col].fillna("").astype(str)
2325
+
2308
2326
  if save_csv:
2309
2327
  os.makedirs(output_dir, exist_ok=True)
2310
2328
  out_path = os.path.join(output_dir, f"{label_space}_cell_annotations.csv")
@@ -2557,6 +2575,7 @@ def plot_embedding_with_ondata_labels(
2557
2575
  # ---- data/keys ----
2558
2576
  color_key: str = "ZMAP_Tissue_predicted",
2559
2577
  basis: str = "X_umap",
2578
+ ref_basis: str | None = None,
2560
2579
  filter_na: bool = True,
2561
2580
 
2562
2581
  # ---- palette handling ----
@@ -2572,10 +2591,14 @@ def plot_embedding_with_ondata_labels(
2572
2591
  # ---- figure style ----
2573
2592
  figsize: tuple[float, float] = (6, 6),
2574
2593
  dpi: int = 200,
2594
+ title_fontsize: float = 10,
2575
2595
 
2576
- # ---- point/legend style ----
2596
+ # ---- point style ----
2577
2597
  ref_size: float = 2,
2598
+ ref_alpha: float = 0.3,
2578
2599
  test_size: float = 2,
2600
+ test_alpha: float = 1.0,
2601
+ show_ref: bool = True,
2579
2602
  cmap: str = "jet",
2580
2603
  frameon: bool = False,
2581
2604
  sort_order: bool = True,
@@ -2583,6 +2606,9 @@ def plot_embedding_with_ondata_labels(
2583
2606
  legend_fontsize: float = 5,
2584
2607
  legend_fontweight: str = "normal",
2585
2608
 
2609
+ # ---- label visibility ----
2610
+ show_labels: bool = True,
2611
+
2586
2612
  # ---- label styling ----
2587
2613
  recolor_labels_from_palette: bool = True,
2588
2614
  text_stroke_width: float = 1.0,
@@ -2613,9 +2639,10 @@ def plot_embedding_with_ondata_labels(
2613
2639
  Plot a query dataset overlaid on the reference embedding, with on-data labels
2614
2640
  and an optional vertical time distribution strip.
2615
2641
 
2616
- Renders two layers: (1) the full reference embedding as a faint grey
2617
- background for spatial context, and (2) the query cells colored by a
2618
- predicted label column. Labels are drawn directly on the embedding using
2642
+ Renders up to two layers: (1) optionally, the full reference embedding
2643
+ as a faint grey background for spatial context (controlled by
2644
+ ``show_ref``), and (2) the query cells colored by a predicted label
2645
+ column. Labels are drawn directly on the embedding using
2619
2646
  ``adjustText`` to minimize overlap. A vertical colorbar histogram of
2620
2647
  predicted developmental time (``ZMAP_time_id``) can optionally be added
2621
2648
  as a strip on the right side of the figure.
@@ -2630,7 +2657,12 @@ def plot_embedding_with_ondata_labels(
2630
2657
  Column in ``adata_test.obs`` containing the categorical labels to color
2631
2658
  and annotate. Typically a ``_predicted`` column from ``predict_labels_kNN``.
2632
2659
  basis : str, default ``"X_umap"``
2633
- ``obsm`` key used for the 2D embedding coordinates in both datasets.
2660
+ ``obsm`` key used for the 2D embedding coordinates in the query dataset.
2661
+ ref_basis : str or None, default ``None``
2662
+ ``obsm`` key for the reference background embedding. When ``None``,
2663
+ falls back to ``basis``. Useful when the query embedding lives in a
2664
+ different key than the reference (e.g. ``basis="X_umap_zmap"`` for
2665
+ the query while the reference uses ``"X_umap"``).
2634
2666
  filter_na : bool, default ``True``
2635
2667
  Drop query cells with ``NaN`` in ``color_key`` before plotting.
2636
2668
  palette : dict or None, default ``None``
@@ -2653,15 +2685,34 @@ def plot_embedding_with_ondata_labels(
2653
2685
  Figure size in inches ``(width, height)``.
2654
2686
  dpi : int, default ``200``
2655
2687
  Figure resolution.
2656
- ref_size, test_size : float, default ``2``
2657
- Scatter point sizes for reference background and query cells respectively.
2688
+ ref_size : float, default ``2``
2689
+ Scatter point size for reference background cells.
2690
+ ref_alpha : float, default ``0.3``
2691
+ Opacity of reference background points. Lower values push the
2692
+ reference further into the background.
2693
+ test_size : float, default ``2``
2694
+ Scatter point size for query (projected) cells.
2695
+ test_alpha : float, default ``1.0``
2696
+ Opacity of query overlay points.
2697
+ show_ref : bool, default ``True``
2698
+ If ``True``, draw the reference embedding as a faint grey background
2699
+ for spatial context. If ``False``, only the query cells are plotted
2700
+ (useful when ``do_ingest=False``). When ``False``, ``adata_ref``
2701
+ may be ``None``.
2658
2702
  cmap : str, default ``"jet"``
2659
2703
  Colormap used for the reference background scatter.
2660
2704
  legend_loc : str, default ``"on data"``
2661
2705
  Where to place the category legend. ``"on data"`` draws labels directly
2662
2706
  at centroid positions; other values follow matplotlib legend conventions.
2707
+ Ignored when ``show_labels=False`` (forced to ``"none"``).
2663
2708
  legend_fontsize, legend_fontweight : float and str, default ``5`` and ``"normal"``
2664
2709
  Font size and weight for on-data legend labels.
2710
+ show_labels : bool, default ``True``
2711
+ If ``True``, draw on-data text labels at category centroids with
2712
+ ``adjustText`` repositioning and optional arrow connectors. If
2713
+ ``False``, suppress all text labels and arrows — only the colored
2714
+ scatter is shown, which is useful for clean figures or when the
2715
+ number of categories is too large for readable labels.
2665
2716
  replace_underscores : bool, default ``True``
2666
2717
  Replace underscores in label strings with line breaks for cleaner
2667
2718
  on-data annotation.
@@ -2669,17 +2720,23 @@ def plot_embedding_with_ondata_labels(
2669
2720
  ``(x_expand, y_expand)`` passed to ``adjustText`` for label placement.
2670
2721
  match_arrow_color_to_text : bool, default ``True``
2671
2722
  Color annotation arrows to match their corresponding text label.
2723
+ ref_kwargs : dict or None, default ``None``
2724
+ Extra keyword arguments forwarded to the reference ``sc.pl.embedding``
2725
+ call. Explicit ``ref_alpha`` takes priority over ``alpha`` in this dict.
2726
+ test_kwargs : dict or None, default ``None``
2727
+ Extra keyword arguments forwarded to the query ``sc.pl.embedding``
2728
+ call. Explicit ``test_alpha`` takes priority over ``alpha`` in this dict.
2672
2729
  show : bool, default ``False``
2673
2730
  Call ``plt.show()`` after rendering.
2674
2731
  save : bool, default ``True``
2675
- Save the figure as PNG and PDF to ``./zmap/predict/``.
2732
+ Save the figure as PNG and PDF to ``output_dir``.
2676
2733
  return_ax : bool, default ``False``
2677
2734
  Return the main ``matplotlib.axes.Axes`` object.
2678
2735
 
2679
2736
  Returns
2680
2737
  -------
2681
- matplotlib.axes.Axes or None
2682
- The main axes when ``return_ax=True``, otherwise ``None``.
2738
+ tuple or None
2739
+ ``(fig, ax_umap, ax_strip)`` when ``return_ax=True``, otherwise ``None``.
2683
2740
  """
2684
2741
  # ---- prepare test AnnData (drop NAs on requested key, cast to categorical) ----
2685
2742
  if filter_na:
@@ -2698,7 +2755,7 @@ def plot_embedding_with_ondata_labels(
2698
2755
  # Only attempt sync if there's no _color_map dict already available
2699
2756
  # (annotate_with_zmap populates _color_map from reference colormaps)
2700
2757
  cmap_dict_key = f"{base_obs}_color_map"
2701
- if cmap_dict_key not in adata_test_plot.uns:
2758
+ if cmap_dict_key not in adata_test_plot.uns and adata_ref is not None:
2702
2759
  try:
2703
2760
  sync_zmap_colors(adata_ref, obs_key=base_obs)
2704
2761
  sync_zmap_colors(adata_test_plot, obs_key=color_key, ref_adata=adata_ref, ref_obs_key=base_obs)
@@ -2745,6 +2802,13 @@ def plot_embedding_with_ondata_labels(
2745
2802
  test_kwargs = {} if test_kwargs is None else dict(test_kwargs)
2746
2803
  time_strip_kwargs = {} if time_strip_kwargs is None else dict(time_strip_kwargs)
2747
2804
 
2805
+ # ---- Inject alpha into kwargs (explicit params take priority) ----
2806
+ ref_kwargs.setdefault("alpha", ref_alpha)
2807
+ test_kwargs.setdefault("alpha", test_alpha)
2808
+
2809
+ # ---- Resolve legend_loc: suppress labels when show_labels=False ----
2810
+ legend_loc_use = legend_loc if show_labels else "none"
2811
+
2748
2812
  # ---- Check if we actually have a time vector ----
2749
2813
  has_time = (
2750
2814
  show_time_strip and
@@ -2756,6 +2820,9 @@ def plot_embedding_with_ondata_labels(
2756
2820
  ax_umap = None
2757
2821
  ax_strip = None
2758
2822
 
2823
+ # ---- Resolve ref_basis: defaults to basis if not provided ----
2824
+ ref_basis_use = ref_basis if ref_basis is not None else basis
2825
+
2759
2826
  # ---- figure + axes layout ----
2760
2827
  with plt.rc_context({'figure.figsize': figsize, 'figure.dpi': dpi}):
2761
2828
  if has_time:
@@ -2773,15 +2840,18 @@ def plot_embedding_with_ondata_labels(
2773
2840
  fig, ax_umap = plt.subplots()
2774
2841
  ax_strip = None
2775
2842
 
2776
- # ---- reference embedding in UMAP axis ----
2777
- ax = sc.pl.embedding(
2778
- adata_ref,
2779
- basis=basis,
2780
- show=False,
2781
- s=ref_size,
2782
- ax=ax_umap,
2783
- **ref_kwargs,
2784
- )
2843
+ # ---- reference embedding in UMAP axis (optional) ----
2844
+ if show_ref:
2845
+ ax = sc.pl.embedding(
2846
+ adata_ref,
2847
+ basis=ref_basis_use,
2848
+ show=False,
2849
+ s=ref_size,
2850
+ ax=ax_umap,
2851
+ **ref_kwargs,
2852
+ )
2853
+ else:
2854
+ ax = ax_umap
2785
2855
 
2786
2856
  # ---- query overlay in UMAP axis ----
2787
2857
  ax = sc.pl.embedding(
@@ -2793,7 +2863,7 @@ def plot_embedding_with_ondata_labels(
2793
2863
  basis=basis,
2794
2864
  sort_order=sort_order,
2795
2865
  size=test_size,
2796
- legend_loc=legend_loc,
2866
+ legend_loc=legend_loc_use,
2797
2867
  legend_fontweight=legend_fontweight,
2798
2868
  ax=ax,
2799
2869
  show=False,
@@ -2801,42 +2871,44 @@ def plot_embedding_with_ondata_labels(
2801
2871
  palette=palette,
2802
2872
  **test_kwargs,
2803
2873
  )
2874
+ ax.title.set_fontsize(title_fontsize)
2804
2875
 
2805
- # --- obtain existing on-data labels ---
2806
- texts = [t for t in ax.texts]
2876
+ # --- on-data label styling (only when show_labels=True) ---
2877
+ if show_labels:
2878
+ texts = [t for t in ax.texts]
2807
2879
 
2808
- # --- recolor existing on-data text labels by palette ---
2809
- if recolor_labels_from_palette:
2810
- for t in texts:
2811
- label = t.get_text()
2812
- if label in palette:
2813
- t.set_color(palette[label])
2814
-
2815
- # --- add white border to text labels ---
2816
- for t in texts:
2817
- t.set_path_effects([pe.withStroke(linewidth=text_stroke_width, foreground='white')])
2880
+ # --- recolor existing on-data text labels by palette ---
2881
+ if recolor_labels_from_palette:
2882
+ for t in texts:
2883
+ label = t.get_text()
2884
+ if label in palette:
2885
+ t.set_color(palette[label])
2818
2886
 
2819
- # --- replace underscores with line breaks ---
2820
- if replace_underscores:
2887
+ # --- add white border to text labels ---
2821
2888
  for t in texts:
2822
- t.set_text(t.get_text().replace(linebreak_from, linebreak_to))
2823
-
2824
- # --- adjust label positions with slim arrows ---
2825
- if len(texts) > 0:
2826
- adjust_text(
2827
- texts,
2828
- ax=ax,
2829
- expand=adjust_expand,
2830
- arrowprops=arrowprops,
2831
- min_arrow_len=min_arrow_len,
2832
- )
2889
+ t.set_path_effects([pe.withStroke(linewidth=text_stroke_width, foreground='white')])
2890
+
2891
+ # --- replace underscores with line breaks ---
2892
+ if replace_underscores:
2893
+ for t in texts:
2894
+ t.set_text(t.get_text().replace(linebreak_from, linebreak_to))
2895
+
2896
+ # --- adjust label positions with slim arrows ---
2897
+ if len(texts) > 0:
2898
+ adjust_text(
2899
+ texts,
2900
+ ax=ax,
2901
+ expand=adjust_expand,
2902
+ arrowprops=arrowprops,
2903
+ min_arrow_len=min_arrow_len,
2904
+ )
2833
2905
 
2834
- # --- match arrow color to label color ---
2835
- if match_arrow_color_to_text and len(texts) > 0:
2836
- arrows = [p for p in ax.patches if isinstance(p, FancyArrowPatch)]
2837
- for t, a in zip(texts, arrows[-len(texts):]):
2838
- a.set_color(t.get_color())
2839
- a.set_alpha(arrow_alpha)
2906
+ # --- match arrow color to label color ---
2907
+ if match_arrow_color_to_text and len(texts) > 0:
2908
+ arrows = [p for p in ax.patches if isinstance(p, FancyArrowPatch)]
2909
+ for t, a in zip(texts, arrows[-len(texts):]):
2910
+ a.set_color(t.get_color())
2911
+ a.set_alpha(arrow_alpha)
2840
2912
 
2841
2913
  # ---- time distribution strip on the right (rotated) ----
2842
2914
  if has_time and ax_strip is not None:
@@ -2920,7 +2992,6 @@ def plot_embedding_with_ondata_labels(
2920
2992
  return None
2921
2993
 
2922
2994
 
2923
-
2924
2995
  # ================================================================
2925
2996
  # 6. Overlap matrix & plot
2926
2997
  # ================================================================
@@ -3078,20 +3149,26 @@ def map_query_labels(
3078
3149
  fig = None
3079
3150
  if show_plot or save_plots:
3080
3151
  plt.rcParams["axes.grid"] = False
3081
- fig, ax = plt.subplots(figsize=(figsize, figsize))
3152
+ n_rows, n_cols = plot_table.shape
3153
+ tick_fontsize = 8
3154
+ cell_size = 0.25 # inches per cell — fits 8pt labels without crowding
3155
+ fig_w = min(28, max(figsize, n_cols * cell_size))
3156
+ fig_h = min(28, max(figsize, n_rows * cell_size))
3157
+ cb_shrink = min(0.8, 3.0 / fig_h) # keep colorbar ~3 inches tall
3158
+ fig, ax = plt.subplots(figsize=(fig_w, fig_h))
3082
3159
 
3083
3160
  im = ax.imshow(plot_table.to_numpy(), cmap=cmap, vmin=vmin, vmax=vmax)
3084
3161
 
3085
3162
  ax.set_aspect("equal")
3086
3163
  ax.set_xticks(np.arange(plot_table.shape[1]))
3087
3164
  ax.set_yticks(np.arange(plot_table.shape[0]))
3088
- ax.set_xticklabels(plot_table.columns, rotation=90)
3089
- ax.set_yticklabels(plot_table.index)
3165
+ ax.set_xticklabels(plot_table.columns, rotation=90, fontsize=tick_fontsize)
3166
+ ax.set_yticklabels(plot_table.index, fontsize=tick_fontsize)
3090
3167
  ax.set_title(title)
3091
3168
  ax.set_xlabel(obs_A)
3092
3169
  ax.set_ylabel(obs_B)
3093
3170
 
3094
- cb = fig.colorbar(im, ax=ax, shrink=0.5)
3171
+ cb = fig.colorbar(im, ax=ax, shrink=cb_shrink)
3095
3172
  cb.ax.set_ylabel(colorbar_label)
3096
3173
 
3097
3174
  if overlay_values:
@@ -3315,7 +3392,7 @@ def annotate_with_zmap(
3315
3392
  - ``.obs[f"{label_space}_predicted"]`` — transferred cell labels.
3316
3393
  - ``.obs[f"{label_space}_prob"]`` — label confidence (0–1).
3317
3394
  - ``.obs["ZMAP_time_id_predicted"]`` — predicted time (hpf).
3318
- - ``.obsm["X_umap"]`` — UMAP coordinates (if ingested).
3395
+ - ``.obsm["X_umap_zmap"]`` — projected UMAP coordinates (if ingested).
3319
3396
  - ``.uns['zmap_labels']['_last_space']`` — most recent label_space.
3320
3397
  - ``.uns['zmap_labels'][label_space]['_run_config']`` — stored run parameters
3321
3398
  for zero-arg on-demand plot accessors.
@@ -3478,9 +3555,29 @@ def annotate_with_zmap(
3478
3555
  if do_ingest:
3479
3556
  if v >= 1:
3480
3557
  _zlog("Ingesting query into reference UMAP...")
3558
+
3559
+ # Stash pre-existing X_umap so symphony doesn't clobber it
3560
+ _had_existing_umap = "X_umap" in adata_query.obsm
3561
+ if _had_existing_umap:
3562
+ _existing_umap = adata_query.obsm["X_umap"].copy()
3563
+
3481
3564
  with warnings.catch_warnings():
3482
3565
  warnings.simplefilter("ignore", FutureWarning)
3483
3566
  sp.tl.ingest(adata_query=adata_query, adata_ref=adata_ref)
3567
+
3568
+ # Move ingested coords to X_umap_zmap (non-destructive)
3569
+ adata_query.obsm["X_umap_zmap"] = adata_query.obsm["X_umap"].copy()
3570
+
3571
+ # Restore original X_umap if one existed, otherwise remove
3572
+ if _had_existing_umap:
3573
+ adata_query.obsm["X_umap"] = _existing_umap
3574
+ if v >= 1:
3575
+ _zlog("Restored pre-existing X_umap; ingested coords in X_umap_zmap.")
3576
+ else:
3577
+ del adata_query.obsm["X_umap"]
3578
+ if v >= 1:
3579
+ _zlog("Ingested coords stored in X_umap_zmap.")
3580
+
3484
3581
  if v >= 1:
3485
3582
  _zlog("Ingestion complete.")
3486
3583
 
@@ -3554,15 +3651,26 @@ def annotate_with_zmap(
3554
3651
  adata_query.uns.setdefault("zmap_labels", {})
3555
3652
  adata_query.uns["zmap_labels"]["_last_space"] = space
3556
3653
  adata_query.uns["zmap_labels"].setdefault(space, {})
3654
+ # Determine embedding basis and whether to show reference background
3655
+ if do_ingest:
3656
+ _plot_basis = "X_umap_zmap"
3657
+ _show_ref = True
3658
+ else:
3659
+ # No ingest → query lives in its own UMAP space
3660
+ _plot_basis = "X_umap" if "X_umap" in adata_query.obsm else None
3661
+ _show_ref = False
3662
+
3557
3663
  adata_query.uns["zmap_labels"][space]["_run_config"] = {
3558
3664
  "label_space": space,
3559
3665
  "ref_label_col": ref_label_col,
3560
3666
  "query_label_col": query_label_col,
3561
3667
  "time_col": time_col_actual,
3562
3668
  "output_dir": space_dir,
3669
+ "basis": _plot_basis,
3670
+ "show_ref": _show_ref,
3563
3671
  }
3564
- # Store reference UMAP for on-demand plot_embedding (avoids needing adata_ref)
3565
- if "X_umap" in adata_ref.obsm:
3672
+ # Store reference UMAP for on-demand plot_embedding (only when ingested)
3673
+ if do_ingest and "X_umap" in adata_ref.obsm:
3566
3674
  adata_query.uns["zmap_labels"][space]["_ref_umap"] = (
3567
3675
  np.asarray(adata_ref.obsm["X_umap"]).copy()
3568
3676
  )
@@ -3652,15 +3760,24 @@ def annotate_with_zmap(
3652
3760
  # ------------------------------------------------------------------
3653
3761
  # 9. UMAP overlay figure (v >= 2)
3654
3762
  # ------------------------------------------------------------------
3655
- if v >= 2:
3763
+ if _plot_basis is None:
3764
+ if v >= 1:
3765
+ _zlog(
3766
+ "Skipping UMAP overlay: no embedding found "
3767
+ "(do_ingest=False and no pre-existing X_umap)."
3768
+ )
3769
+ elif v >= 2:
3656
3770
  try:
3657
3771
  if v >= 1:
3658
3772
  _zlog("Plotting UMAP overlay with predicted labels...")
3659
3773
  plot_embedding_with_ondata_labels(
3660
- adata_ref,
3774
+ adata_ref if _show_ref else None,
3661
3775
  adata_query,
3662
3776
  color_key=f"{space}_predicted",
3663
3777
  time_key=time_col_actual,
3778
+ basis=_plot_basis,
3779
+ ref_basis="X_umap",
3780
+ show_ref=_show_ref,
3664
3781
  show=True,
3665
3782
  save=save_outputs,
3666
3783
  output_dir=space_dir,
@@ -3675,10 +3792,13 @@ def annotate_with_zmap(
3675
3792
  # Still save the figure even if not showing it
3676
3793
  try:
3677
3794
  plot_embedding_with_ondata_labels(
3678
- adata_ref,
3795
+ adata_ref if _show_ref else None,
3679
3796
  adata_query,
3680
3797
  color_key=f"{space}_predicted",
3681
3798
  time_key=time_col_actual,
3799
+ basis=_plot_basis,
3800
+ ref_basis="X_umap",
3801
+ show_ref=_show_ref,
3682
3802
  show=False,
3683
3803
  save=True,
3684
3804
  output_dir=space_dir,
@@ -3946,6 +4066,7 @@ def validate_markers(
3946
4066
  groupby=groupby,
3947
4067
  method=method,
3948
4068
  corr_method=corr_method,
4069
+ use_raw=False,
3949
4070
  )
3950
4071
 
3951
4072
  # ---- Extract and filter DE results per group ----
@@ -4186,8 +4307,14 @@ def plot_embedding(
4186
4307
  label_space: str | None = None,
4187
4308
  *,
4188
4309
  save: bool = False,
4310
+ show_labels: bool = True,
4311
+ ref_size: float = 2,
4312
+ ref_alpha: float = 0.3,
4313
+ test_size: float = 2,
4314
+ test_alpha: float = 1.0,
4315
+ return_ax: bool = False,
4189
4316
  **kwargs,
4190
- ) -> None:
4317
+ ) -> tuple | None:
4191
4318
  """
4192
4319
  Re-plot the UMAP overlay with on-data labels and time strip.
4193
4320
 
@@ -4202,37 +4329,75 @@ def plot_embedding(
4202
4329
  Label namespace. When ``None``, uses the most recent run.
4203
4330
  save : bool, default ``False``
4204
4331
  Save the figure to the run's output directory.
4332
+ show_labels : bool, default ``True``
4333
+ If ``True``, draw on-data text labels at category centroids.
4334
+ If ``False``, suppress all text labels and arrows.
4335
+ ref_size : float, default ``2``
4336
+ Scatter point size for reference background cells.
4337
+ ref_alpha : float, default ``0.3``
4338
+ Opacity of reference background points.
4339
+ test_size : float, default ``2``
4340
+ Scatter point size for query (projected) cells.
4341
+ test_alpha : float, default ``1.0``
4342
+ Opacity of query overlay points.
4343
+ return_ax : bool, default ``False``
4344
+ Return ``(fig, ax_umap, ax_strip)`` instead of ``None``.
4205
4345
  **kwargs
4206
4346
  Extra keyword arguments forwarded to ``plot_embedding_with_ondata_labels``.
4347
+
4348
+ Returns
4349
+ -------
4350
+ tuple or None
4351
+ ``(fig, ax_umap, ax_strip)`` when ``return_ax=True``, otherwise ``None``.
4207
4352
  """
4208
4353
  label_space, config = _resolve_config(adata_query, label_space)
4209
4354
  space_store = adata_query.uns.get("zmap_labels", {}).get(label_space, {})
4210
4355
 
4211
- ref_umap = space_store.get("_ref_umap")
4212
- if ref_umap is None:
4356
+ # Resolve basis and show_ref from stored config
4357
+ plot_basis = config.get("basis", "X_umap_zmap")
4358
+ show_ref = config.get("show_ref", True)
4359
+
4360
+ if plot_basis is None:
4213
4361
  raise KeyError(
4214
- f"No stored reference UMAP found at "
4215
- f"adata.uns['zmap_labels']['{label_space}']['_ref_umap']. "
4216
- "Run annotate_with_zmap first."
4362
+ f"No embedding basis stored for label_space='{label_space}'. "
4363
+ "The original run had do_ingest=False and no pre-existing X_umap."
4217
4364
  )
4218
4365
 
4219
- # Build a minimal AnnData shell with just the reference UMAP
4220
- adata_ref_mini = ad.AnnData(
4221
- obs=pd.DataFrame(index=[f"ref_{i}" for i in range(ref_umap.shape[0])]),
4222
- )
4223
- adata_ref_mini.obsm["X_umap"] = ref_umap
4366
+ # Build reference shell only when needed
4367
+ adata_ref_mini = None
4368
+ if show_ref:
4369
+ ref_umap = space_store.get("_ref_umap")
4370
+ if ref_umap is None:
4371
+ raise KeyError(
4372
+ f"No stored reference UMAP found at "
4373
+ f"adata.uns['zmap_labels']['{label_space}']['_ref_umap']. "
4374
+ "Run annotate_with_zmap first."
4375
+ )
4376
+ adata_ref_mini = ad.AnnData(
4377
+ obs=pd.DataFrame(index=[f"ref_{i}" for i in range(ref_umap.shape[0])]),
4378
+ )
4379
+ adata_ref_mini.obsm["X_umap"] = ref_umap
4224
4380
 
4225
4381
  color_key = f"{label_space}_predicted"
4226
4382
  time_key = config.get("time_col", "ZMAP_time_id_predicted")
4227
4383
  space_dir = config.get("output_dir", os.path.join("zmap_predict", label_space))
4228
4384
 
4229
- plot_embedding_with_ondata_labels(
4385
+ return plot_embedding_with_ondata_labels(
4230
4386
  adata_ref_mini,
4231
4387
  adata_query,
4232
4388
  color_key=color_key,
4233
4389
  time_key=time_key,
4390
+ basis=plot_basis,
4391
+ ref_basis="X_umap",
4392
+ show_ref=show_ref,
4234
4393
  show=True,
4235
4394
  save=save,
4395
+ show_labels=show_labels,
4396
+ ref_size=ref_size,
4397
+ ref_alpha=ref_alpha,
4398
+ test_size=test_size,
4399
+ test_alpha=test_alpha,
4400
+ return_ax=return_ax,
4236
4401
  output_dir=space_dir,
4237
4402
  **kwargs,
4238
4403
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zmap-tools
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: API for working with ZMAP (Zebrafish Multi-Atlas Project)
5
5
  Author-email: Daniel Wagner <Daniel.Wagner@ucsf.edu>
6
6
  License: BSD 3-Clause License
@@ -50,7 +50,7 @@ License-File: LICENSE
50
50
  Requires-Dist: anndata>=0.10
51
51
  Requires-Dist: scanpy>=1.9
52
52
  Requires-Dist: numpy>=1.24
53
- Requires-Dist: pandas>=2.0
53
+ Requires-Dist: pandas<3.0,>=2.0
54
54
  Requires-Dist: matplotlib>=3.7
55
55
  Requires-Dist: requests>=2.31
56
56
  Requires-Dist: scipy>=1.10
@@ -1,7 +1,7 @@
1
1
  anndata>=0.10
2
2
  scanpy>=1.9
3
3
  numpy>=1.24
4
- pandas>=2.0
4
+ pandas<3.0,>=2.0
5
5
  matplotlib>=3.7
6
6
  requests>=2.31
7
7
  scipy>=1.10
File without changes
File without changes
File without changes