cellestial 0.48.4__tar.gz → 0.49.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 (62) hide show
  1. {cellestial-0.48.4 → cellestial-0.49.0}/PKG-INFO +1 -1
  2. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/datasets/datasets.py +1 -1
  3. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/frames/build.py +23 -14
  4. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/frames/operations.py +29 -14
  5. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/layers/bracket.py +5 -6
  6. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/layers/outline.py +1 -1
  7. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/heatmap/stacked_violin.py +1 -2
  8. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/util/dendrogram.py +1 -4
  9. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/util/utilities.py +15 -29
  10. {cellestial-0.48.4 → cellestial-0.49.0}/pyproject.toml +1 -1
  11. {cellestial-0.48.4 → cellestial-0.49.0}/LICENSE +0 -0
  12. {cellestial-0.48.4 → cellestial-0.49.0}/README.md +0 -0
  13. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/__init__.py +0 -0
  14. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/_version.py +0 -0
  15. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/datasets/__init__.py +0 -0
  16. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/frames/__init__.py +0 -0
  17. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/layers/__init__.py +0 -0
  18. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/layers/_deferred.py +0 -0
  19. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/layers/arrow.py +0 -0
  20. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/layers/ondata_legend.py +0 -0
  21. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/layers/stream.py +0 -0
  22. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/layers/utilities.py +0 -0
  23. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/__init__.py +0 -0
  24. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/base/__init__.py +0 -0
  25. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/base/base.py +0 -0
  26. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/basic/__init__.py +0 -0
  27. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/basic/bar.py +0 -0
  28. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/basic/scatter.py +0 -0
  29. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/common/__init__.py +0 -0
  30. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/common/xyplot.py +0 -0
  31. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/common/xyplots.py +0 -0
  32. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/core/__init__.py +0 -0
  33. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/core/dimensional.py +0 -0
  34. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/core/distribution.py +0 -0
  35. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/core/distributions.py +0 -0
  36. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/core/subdimensional.py +0 -0
  37. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/core/subdimensionals.py +0 -0
  38. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/differential/__init__.py +0 -0
  39. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/differential/markers.py +0 -0
  40. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/differential/volcano.py +0 -0
  41. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/heatmap/__init__.py +0 -0
  42. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/heatmap/_key_groups.py +0 -0
  43. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/heatmap/_rank_genes_groups.py +0 -0
  44. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/heatmap/dotplot.py +0 -0
  45. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/heatmap/heatmap.py +0 -0
  46. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/quick/__init__.py +0 -0
  47. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/quick/elbow.py +0 -0
  48. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/quick/highest.py +0 -0
  49. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/single/quick/ridge.py +0 -0
  50. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/spatial/__init__.py +0 -0
  51. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/spatial/_components.py +0 -0
  52. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/spatial/spatial.py +0 -0
  53. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/spatial/spatials.py +0 -0
  54. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/themes/__init__.py +0 -0
  55. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/themes/_distributions.py +0 -0
  56. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/themes/_heatmaps.py +0 -0
  57. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/themes/_scatters.py +0 -0
  58. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/util/__init__.py +0 -0
  59. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/util/colors.py +0 -0
  60. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/util/errors.py +0 -0
  61. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/util/operations.py +0 -0
  62. {cellestial-0.48.4 → cellestial-0.49.0}/cellestial/util/save.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cellestial
3
- Version: 0.48.4
3
+ Version: 0.49.0
4
4
  Summary:
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -487,7 +487,7 @@ def from_url(
487
487
  print(f"No cache at {cache_file} !")
488
488
  print(f"Downloading from {url} ...")
489
489
 
490
- block_size = 1024
490
+ block_size = 1024 * 1024
491
491
  # Stream to a .part file and atomically rename on success, so an interrupted
492
492
  # download cannot poison the cache.
493
493
  partial_file = cache_file.with_name(cache_file.name + ".part")
@@ -2,9 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING, Literal
4
4
 
5
+ import numpy as np
5
6
  import pandas as pd
6
7
  import polars as pl
7
8
  from anndata import AnnData
9
+ from scipy.sparse import issparse
8
10
  from spatialdata import SpatialData
9
11
 
10
12
  from cellestial.util.errors import UnsupportedDataTypeError, VariableNotFoundError
@@ -29,17 +31,23 @@ def anndata_variable_columns(
29
31
  columns = []
30
32
  if isinstance(keys, str):
31
33
  keys = [keys]
32
- for key in keys:
33
- if key in column_names: # check for repeats
34
- continue
35
- elif key in data.var_names:
36
- values = data.obs_vector(key)
37
- # add the variable column to the list of columns
38
- columns.append(pl.Series(key, values).cast(pl.Float32))
39
- column_names.append(key)
40
- else:
41
- msg = f"Key `{key}` not found in data."
42
- raise VariableNotFoundError(msg)
34
+ # remove keys that are already in column_names to avoid repeats
35
+ keys = [key for key in keys if key not in column_names]
36
+ # check that all keys are in data.var_names
37
+ available_names = set(data.var_names)
38
+ missing = [key for key in keys if key not in available_names]
39
+ if missing:
40
+ msg = f"Keys not found in data.var_names: {missing}"
41
+ raise VariableNotFoundError(msg)
42
+
43
+ # subset X
44
+ X = data[:, keys].X
45
+
46
+ if issparse(X):
47
+ X = X.toarray() # ty:ignore[unresolved-attribute]
48
+ else:
49
+ X = np.asarray(X)
50
+ columns = [pl.Series(key, X[:, i]) for i, key in enumerate(keys)]
43
51
 
44
52
  return columns
45
53
 
@@ -210,7 +218,7 @@ def anndata_variables_frame(
210
218
 
211
219
 
212
220
  def build_frame(
213
- data: AnnData,
221
+ data: AnnData | SpatialData,
214
222
  *,
215
223
  variable_keys: str | Sequence[str] | None = None,
216
224
  axis: Literal[0, 1] | None = None,
@@ -223,8 +231,8 @@ def build_frame(
223
231
 
224
232
  Parameters
225
233
  ----------
226
- data : AnnData
227
- The AnnData object containing the variables.
234
+ data : AnnData | SpatialData
235
+ The AnnData or SpatialData object containing the variables.
228
236
  variable_keys : str | Sequence[str] | None
229
237
  Variable keys to add to the DataFrame. If None, no additional keys are added.
230
238
  axis : {0,1} | None
@@ -282,6 +290,7 @@ def build_frame(
282
290
  if isinstance(data, SpatialData):
283
291
  tables = list(data.tables.keys())
284
292
  if len(tables) == 1:
293
+ # retrieve an anndata
285
294
  data = data.tables[tables[0]]
286
295
  elif len(tables) == 0:
287
296
  msg = "No annotation tables found in the SpatialData object."
@@ -6,7 +6,6 @@ from anndata import AnnData
6
6
  from polars import DataFrame
7
7
  from scipy.sparse import issparse
8
8
 
9
- from cellestial.frames.build import anndata_variable_columns
10
9
  from cellestial.util.errors import UnsupportedDataTypeError
11
10
 
12
11
 
@@ -16,28 +15,44 @@ def _highest_expressed_genes_frame(
16
15
  ) -> DataFrame:
17
16
  """Get the top n highest expressed genes by mean percentage across all cells."""
18
17
  if isinstance(data, AnnData):
18
+ # VALIDATE: inputs
19
19
  if n > data.n_vars:
20
20
  msg = f"Requested n={n} genes, but only {data.n_vars} genes available in data."
21
21
  raise ValueError(msg)
22
22
  X = data.X
23
- # normalize each cell to sum to 100 (percentage)
23
+ # matmul and fancy indexing below require an in-memory ndarray/sparse;
24
+ # fail fast on backed/dask/zappy rather than crashing deep in the math.
25
+ if not (isinstance(X, np.ndarray) or issparse(X)):
26
+ msg = (
27
+ f"Unsupported `X` type for this operation: {type(X).__name__}. "
28
+ "Load AnnData in non-backed mode or materialize `X` manually."
29
+ )
30
+ raise UnsupportedDataTypeError(msg)
31
+
32
+ # BUILD: per-cell normalizer so each cell's expression sums to 100
24
33
  if issparse(X):
25
- row_sums = np.array(X.sum(axis=1)).ravel()
26
- row_sums[row_sums == 0] = 1
27
- X_normalized = X.multiply(100 / row_sums[:, np.newaxis])
28
- mean_percent = np.array(X_normalized.mean(axis=0)).ravel()
34
+ row_sums = np.asarray(X.sum(axis=1)).ravel()
29
35
  else:
30
- row_sums = X.sum(axis=1, keepdims=True)
31
- row_sums[row_sums == 0] = 1
32
- X_normalized = X / row_sums * 100
33
- mean_percent = X_normalized.mean(axis=0)
36
+ row_sums = X.sum(axis=1)
37
+ row_sums[row_sums == 0] = 1 # guard cells with zero total to avoid div-by-zero
38
+ inv_rows = 100.0 / row_sums
34
39
 
40
+ # RANK: gene means via a single matvec instead of materializing the
41
+ # full normalized matrix. Identity: mean(X / row_sums * 100, axis=0)
42
+ # equals (inv_rows @ X) / n_cells, but uses O(n_vars) memory instead of O(n_obs * n_vars).
43
+ mean_percent = np.asarray(inv_rows @ X).ravel() / X.shape[0]
35
44
  top_idx = np.argsort(mean_percent)[::-1][:n]
36
45
  genes = data.var_names[top_idx].tolist()
37
- if issparse(X_normalized):
38
- X_normalized = X_normalized.tocsr()
39
- norm_data = AnnData(X=X_normalized, var=data.var, obs=data.obs)
40
- frame = pl.DataFrame(anndata_variable_columns(norm_data, keys=genes, column_names=[]))
46
+
47
+ # EXTRACT: normalize only the top n columns (small n_obs * n slice),
48
+ # skipping the full n_obs * n_vars materialization the naive path would do.
49
+ X_top = X[:, top_idx]
50
+ if issparse(X_top):
51
+ X_top = X_top.toarray()
52
+ X_top_normalized = X_top * inv_rows[:, np.newaxis]
53
+
54
+ # BUILD: polars frame keyed by gene name
55
+ frame = pl.DataFrame({gene: X_top_normalized[:, i] for i, gene in enumerate(genes)})
41
56
  else:
42
57
  msg = f"Unsupported data type: `{type(data)}`"
43
58
  raise UnsupportedDataTypeError(msg)
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Literal
6
6
  import numpy as np
7
7
  import polars as pl
8
8
  from lets_plot import aes, geom_bracket
9
+ from scipy.stats import mannwhitneyu, ttest_ind
9
10
 
10
11
  from cellestial.layers._deferred import DeferredLayer
11
12
  from cellestial.util import get_mapping, retrieve
@@ -134,8 +135,6 @@ def _compute_bracket_frame(
134
135
  y_padding: float,
135
136
  ) -> DataFrame:
136
137
  """Build a `geom_bracket` DataFrame from pairwise significance tests."""
137
- from scipy.stats import mannwhitneyu, ttest_ind
138
-
139
138
  # determine which pairs to compare
140
139
  groups = frame[x].drop_nulls().unique(maintain_order=True).to_list()
141
140
  if comparisons is None:
@@ -238,10 +237,10 @@ def _compute_bracket_frame(
238
237
  brackets = brackets.with_columns(label=pl.Series(labels))
239
238
 
240
239
  # compute y positions so brackets stack above the data without overlapping
241
- values = frame[y].to_numpy()
242
- data_min = float(values.min())
243
- data_max = float(values.max())
244
- data_range = data_max - data_min
240
+
241
+ data_min = frame[y].min()
242
+ data_max = frame[y].max()
243
+ data_range = data_max - data_min # ty:ignore[unsupported-operator]
245
244
  _y_position = data_max + data_range * y_padding if y_position is None else y_position
246
245
  _y_step = data_range * y_padding if y_step is None else y_step
247
246
  positions = [_y_position + _y_step * index for index in range(len(brackets))]
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Literal
5
5
  import numpy as np
6
6
  import polars as pl
7
7
  from lets_plot import aes, geom_path
8
+ from scipy.stats import gaussian_kde
8
9
 
9
10
  from cellestial.layers._deferred import DeferredLayer
10
11
  from cellestial.util import get_mapping
@@ -28,7 +29,6 @@ def _get_density_boundaries(
28
29
  grid_size: int = 200,
29
30
  ) -> DataFrame:
30
31
  """Creates and Returns a DataFrame to encircle the cluster via `geom_path`."""
31
- from scipy.stats import gaussian_kde
32
32
  from skimage import measure
33
33
 
34
34
  # 1. Normalize input to a Sequence of Sequences: List[List[str]]
@@ -18,6 +18,7 @@ from lets_plot import (
18
18
  theme,
19
19
  )
20
20
  from lets_plot.plot.core import FeatureSpec
21
+ from scipy.stats import gaussian_kde
21
22
 
22
23
  from cellestial.frames import build_frame
23
24
  from cellestial.single.heatmap._key_groups import (
@@ -65,8 +66,6 @@ def _compute_violin_polygons(
65
66
  mapped to local y inside the row band and density mapped to local x around
66
67
  the column position.
67
68
  """
68
- from scipy.stats import gaussian_kde
69
-
70
69
  var_stats = frame.group_by(variable_column).agg(
71
70
  pl.col(value_column).min().alias("_vmin"),
72
71
  pl.col(value_column).max().alias("_vmax"),
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import numpy as np
3
4
  import polars as pl
4
5
  from anndata import AnnData
5
6
 
@@ -11,8 +12,6 @@ def _get_dendrogram(
11
12
  use_key: str | None = None,
12
13
  ) -> tuple[list[str], pl.DataFrame]:
13
14
  """Get or compute the dendrogram for `group_by` and extract paths."""
14
- import numpy as np
15
-
16
15
  if isinstance(data, AnnData):
17
16
  key = use_key if use_key is not None else f"dendrogram_{group_by}"
18
17
  if key not in data.uns:
@@ -57,8 +56,6 @@ def _get_dendrogram_path_frame(
57
56
  dendrogram_ratio: float = 0.15,
58
57
  ) -> pl.DataFrame:
59
58
  """Map normalized dendrogram paths into plot coordinates (right side along y-axis)."""
60
- import numpy as np
61
-
62
59
  y_dendrogram_size = n_x * dendrogram_ratio
63
60
  leaf_xp = np.arange(n_groups, dtype=float)
64
61
  plot_y = np.interp(paths["x"].to_numpy(), leaf_xp, group_centers)
@@ -4,6 +4,7 @@ import inspect
4
4
  import re
5
5
  import warnings
6
6
  from collections.abc import Sequence
7
+ from functools import lru_cache
7
8
  from math import ceil, log10
8
9
  from pathlib import Path
9
10
  from typing import Literal
@@ -286,19 +287,24 @@ def _fill_gradient(
286
287
  midpoint=mid_value,
287
288
  )
288
289
 
289
-
290
- def _share_labels(plot, i: int, keys: Sequence[str], ncol: int | None) -> SupPlotsSpec:
290
+ # cache to avoid recomputing for every plot in a grid...
291
+ @lru_cache(maxsize=32)
292
+ def _grid_places(total: int, ncol: int | None) -> tuple[frozenset[int], frozenset[int]]:
291
293
  if ncol is None:
292
- ncol = len(keys)
293
- total = len(keys)
294
+ ncol = total
294
295
  nrow = ceil(total / ncol)
295
- left_places = [i for i in range(total) if i % ncol == 0]
296
- bottom_places = [i for i in range(total) if i >= ncol * (nrow - 1)]
296
+ left_places = {idx for idx in range(total) if idx % ncol == 0}
297
+ bottom_places = {idx for idx in range(total) if idx >= ncol * (nrow - 1)}
297
298
  # the last grid row may be incomplete; for the columns it does not cover,
298
299
  # the bottom-most plot lives in the penultimate row.
299
300
  last_row_count = total - ncol * (nrow - 1)
300
301
  if nrow >= 2 and last_row_count < ncol:
301
- bottom_places.extend(ncol * (nrow - 2) + col for col in range(last_row_count, ncol))
302
+ bottom_places.update(ncol * (nrow - 2) + col for col in range(last_row_count, ncol))
303
+ return frozenset(left_places), frozenset(bottom_places)
304
+
305
+
306
+ def _share_labels(plot, i: int, keys: Sequence[str], ncol: int | None) -> SupPlotsSpec:
307
+ left_places, bottom_places = _grid_places(len(keys), ncol)
302
308
  if i not in bottom_places: # remove x axis title except for bottom row
303
309
  plot = plot + theme(axis_title_x=element_blank())
304
310
  if i not in left_places: # remove y axis title except for left column
@@ -310,17 +316,7 @@ def _share_labels(plot, i: int, keys: Sequence[str], ncol: int | None) -> SupPlo
310
316
  def _share_axis(
311
317
  plot, i: int, keys: Sequence[str], ncol: int | None, axis_type: Literal["axis", "arrow"]
312
318
  ) -> SupPlotsSpec:
313
- total = len(keys)
314
- if ncol is None:
315
- ncol = len(keys)
316
- nrow = ceil(total / ncol)
317
- left_places = [i for i in range(total) if i % ncol == 0]
318
- bottom_places = [i for i in range(total) if i >= ncol * (nrow - 1)]
319
- # the last grid row may be incomplete; for the columns it does not cover,
320
- # the bottom-most plot lives in the penultimate row.
321
- last_row_count = total - ncol * (nrow - 1)
322
- if nrow >= 2 and last_row_count < ncol:
323
- bottom_places.extend(ncol * (nrow - 2) + col for col in range(last_row_count, ncol))
319
+ left_places, bottom_places = _grid_places(len(keys), ncol)
324
320
 
325
321
  if axis_type == "axis":
326
322
  if i not in bottom_places: # remove x axis title except for bottom row
@@ -347,17 +343,7 @@ def _share_axis(
347
343
 
348
344
 
349
345
  def _share_ticks(plot, i: int, keys: Sequence[str], ncol: int | None) -> SupPlotsSpec:
350
- if ncol is None:
351
- ncol = len(keys)
352
- total = len(keys)
353
- nrow = ceil(total / ncol)
354
- left_places = [i for i in range(total) if i % ncol == 0]
355
- bottom_places = [i for i in range(total) if i >= ncol * (nrow - 1)]
356
- # the last grid row may be incomplete; for the columns it does not cover,
357
- # the bottom-most plot lives in the penultimate row.
358
- last_row_count = total - ncol * (nrow - 1)
359
- if nrow >= 2 and last_row_count < ncol:
360
- bottom_places.extend(ncol * (nrow - 2) + col for col in range(last_row_count, ncol))
346
+ left_places, bottom_places = _grid_places(len(keys), ncol)
361
347
  if i not in bottom_places: # remove x axis title except for bottom row
362
348
  plot = plot + theme(axis_text_x=element_blank())
363
349
  if i not in left_places: # remove y axis title except for left column
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cellestial"
3
- version = "0.48.4"
3
+ version = "0.49.0"
4
4
  description = ""
5
5
  authors = [
6
6
  { name = "Zaf4", email = "zafermolbio@gmail.com" },
File without changes
File without changes