wavefactor 2.10.0__cp310-cp310-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
wavefactor/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ WaveFactor: Wavelet-based Bayesian Sparse Factor Model For Spatial Transcriptomics
3
+ """
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def _load_version() -> str:
9
+ """Loads WaveFactor package version from repository root or package metadata."""
10
+ # 1. Dev mode: read language-neutral VERSION asset directly from repository root
11
+ repo_root = Path(__file__).resolve().parent.parent
12
+ repo_version_file = repo_root / "VERSION"
13
+ if repo_version_file.is_file() and (repo_root / "CMakeLists.txt").is_file():
14
+ return repo_version_file.read_text(encoding="utf-8").strip()
15
+
16
+ # 2. Installed mode: read from package metadata when distributed as a wheel
17
+ try:
18
+ from importlib.metadata import version
19
+ return version("wavefactor")
20
+ except Exception:
21
+ raise ImportError(
22
+ "WaveFactor version could not be determined. "
23
+ "Please ensure the package is installed or the VERSION file is present."
24
+ ) from None
25
+
26
+
27
+ __version__ = _load_version()
28
+
29
+ from .priors import Priors
30
+ from .data import WaveFactorData, prepare_spatial_data
31
+ from .results import WaveFactorResult
32
+ from .model import WaveFactor
33
+
34
+ __all__ = [
35
+ "__version__",
36
+ "WaveFactor",
37
+ "WaveFactorResult",
38
+ "WaveFactorData",
39
+ "Priors",
40
+ "prepare_spatial_data",
41
+ ]
wavefactor/data.py ADDED
@@ -0,0 +1,249 @@
1
+ """
2
+ Data ingestion, spatial rasterization, and 2D wavelet decomposition for WaveFactor.
3
+ """
4
+
5
+ from typing import Union, Tuple, Optional, Dict, Any, List
6
+ import numpy as np
7
+ import pandas as pd
8
+ import pywt
9
+
10
+
11
+ def is_power_of_4(n: int) -> bool:
12
+ """Return True if n is an exact power of 4."""
13
+ try:
14
+ n = int(n)
15
+ except (ValueError, TypeError):
16
+ return False
17
+ return n > 0 and (n & (n - 1)) == 0 and (n.bit_length() - 1) % 2 == 0
18
+
19
+
20
+ def get_L_shape(n_spots: int, n_resolutions: int, n_factors: int) -> List:
21
+ """Generates empty nested list structure matching L shape."""
22
+ return [
23
+ [[np.array([0.0] * (int(n_spots / (4 ** (n_resolutions - 1)))))]]
24
+ + [
25
+ [
26
+ np.array([0.0] * (int(n_spots / (4 ** (n_resolutions - i - 1)))))
27
+ for _ in range(3)
28
+ ]
29
+ for i in range(n_resolutions - 1)
30
+ ]
31
+ for _ in range(n_factors)
32
+ ]
33
+
34
+
35
+ def get_Y_shape(n_spots: int, n_resolutions: int, n_features: int) -> List:
36
+ """Generates empty nested list structure matching Y shape."""
37
+ return [
38
+ [[np.array([0.0] * (int(n_spots / (4 ** (n_resolutions - 1)))))]]
39
+ + [
40
+ [
41
+ np.array([0.0] * (int(n_spots / (4 ** (n_resolutions - i - 1)))))
42
+ for _ in range(3)
43
+ ]
44
+ for i in range(n_resolutions - 1)
45
+ ]
46
+ for _ in range(n_features)
47
+ ]
48
+
49
+
50
+ class WaveFactorData:
51
+ """
52
+ Container holding processed spatial transcriptomics matrices,
53
+ 2D discrete wavelet decompositions (true_Y), dimensions, and spatial mappings.
54
+
55
+ Attributes
56
+ ----------
57
+ true_Y : List
58
+ Nested DWT wavelet decomposition matrices for all features.
59
+ dimensions : Dict[str, Any]
60
+ Dictionary with keys 'n_spots', 'n_resolutions', 'n_factors', 'n_features'.
61
+ grid_side_length : int
62
+ Side length L of the square L x L spatial grid (e.g. 64 for a 64x64 grid).
63
+ grid_shape : Tuple[int, int]
64
+ 2D grid shape (grid_side_length, grid_side_length).
65
+ spot_grid_coords : np.ndarray
66
+ Array of shape (N_spots, 2) holding Cartesian [grid_x, grid_y] coordinates:
67
+ - Column 0: grid_x (horizontal coordinate, column index in 0..L-1)
68
+ - Column 1: grid_y (vertical coordinate, row index in 0..L-1)
69
+ grid_x : np.ndarray
70
+ 1D integer grid x-coordinates (columns in 0..L-1).
71
+ grid_y : np.ndarray
72
+ 1D integer grid y-coordinates (rows in 0..L-1).
73
+ coords : np.ndarray, optional
74
+ Original raw spatial coordinates (N_spots, 2).
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ true_Y: List,
80
+ dimensions: Dict[str, Any],
81
+ grid_side_length: int,
82
+ spot_grid_coords: Optional[np.ndarray] = None,
83
+ coords: Optional[np.ndarray] = None,
84
+ ):
85
+ self.true_Y = true_Y
86
+ self.dimensions = dimensions
87
+ self.grid_side_length = int(grid_side_length)
88
+ self.spot_grid_coords = spot_grid_coords
89
+ self.coords = coords
90
+
91
+ @property
92
+ def grid_shape(self) -> Tuple[int, int]:
93
+ """2D spatial grid shape (grid_side_length, grid_side_length)."""
94
+ return (self.grid_side_length, self.grid_side_length)
95
+
96
+ @property
97
+ def grid_x(self) -> Optional[np.ndarray]:
98
+ """1D integer grid x-coordinates (columns in 0..L-1)."""
99
+ return self.spot_grid_coords[:, 0] if self.spot_grid_coords is not None else None
100
+
101
+ @property
102
+ def grid_y(self) -> Optional[np.ndarray]:
103
+ """1D integer grid y-coordinates (rows in 0..L-1)."""
104
+ return self.spot_grid_coords[:, 1] if self.spot_grid_coords is not None else None
105
+
106
+
107
+ def prepare_spatial_data(
108
+ X: np.ndarray,
109
+ coords: np.ndarray,
110
+ n_factors: int = 10,
111
+ n_length_scales: int = 4,
112
+ ) -> WaveFactorData:
113
+ """
114
+ Preprocesses spatial transcriptomics input data, validates 2D spatial grid mappings,
115
+ and executes 2D DWT wavelet decomposition.
116
+
117
+ Parameters
118
+ ----------
119
+ X : np.ndarray
120
+ Gene expression matrix of shape (N_spots, N_features).
121
+ coords : np.ndarray
122
+ Spatial (x, y) integer grid coordinates for each spot, shape (N_spots, 2).
123
+ Must be a unique 1-to-1 mapping onto the [0, L-1] x [0, L-1] lattice,
124
+ where N_spots = L * L is an exact power of 4.
125
+ n_factors : int, default=10
126
+ Number of latent factors (K).
127
+ n_length_scales : int, default=4
128
+ Number of wavelet detail levels (D). Total resolutions R = D + 1.
129
+ Must satisfy 2^D <= L (i.e. D <= log2(L)).
130
+
131
+ Returns
132
+ -------
133
+ WaveFactorData
134
+ Structured data container with wavelet decomposition and dimension constants.
135
+ """
136
+ # 1. Parse and validate 2D expression matrix
137
+ expr_matrix = np.asarray(X, dtype=np.float64)
138
+ if expr_matrix.ndim != 2:
139
+ raise ValueError(
140
+ f"X must be a 2D numpy array of shape (N_spots, N_features), got ndim={expr_matrix.ndim} with shape {expr_matrix.shape}."
141
+ )
142
+
143
+ N_spots, N_features = expr_matrix.shape
144
+
145
+ # 2. Strict power-of-4 spot count verification
146
+ if not is_power_of_4(N_spots):
147
+ raise ValueError(
148
+ f"Number of spots (N={N_spots}) must be an exact power of 4 (e.g. 64, 256, 1024, 4096) "
149
+ "to form a complete 2D dyadic wavelet lattice."
150
+ )
151
+
152
+ side_len = int(round(np.sqrt(N_spots)))
153
+ max_D = int(round(np.log2(side_len)))
154
+
155
+ # 3. Wavelet resolution bound check (2^D <= L)
156
+ if n_length_scales > max_D:
157
+ raise ValueError(
158
+ f"n_length_scales (D={n_length_scales}) cannot exceed log2(grid_side_length)={max_D} "
159
+ f"for a grid of side length L={side_len} (N={N_spots} spots)."
160
+ )
161
+ if n_length_scales <= 0:
162
+ raise ValueError(f"n_length_scales must be a positive integer, got {n_length_scales}.")
163
+
164
+ # 4. Parse and strictly validate spatial coordinates
165
+ spot_coords = np.asarray(coords)
166
+ if spot_coords.ndim != 2 or spot_coords.shape != (N_spots, 2):
167
+ raise ValueError(
168
+ f"coords must be a 2D array of shape (N_spots, 2) = ({N_spots}, 2), got shape {spot_coords.shape}."
169
+ )
170
+
171
+ # Validate integer coordinates
172
+ if not (np.issubdtype(spot_coords.dtype, np.integer) or np.all(np.equal(np.mod(spot_coords, 1), 0))):
173
+ raise ValueError(
174
+ f"coords must contain integer grid indices on [0, {side_len - 1}] x [0, {side_len - 1}]."
175
+ )
176
+
177
+ grid_x = spot_coords[:, 0].astype(int)
178
+ grid_y = spot_coords[:, 1].astype(int)
179
+
180
+ # Bounds check
181
+ if grid_x.min() < 0 or grid_x.max() >= side_len or grid_y.min() < 0 or grid_y.max() >= side_len:
182
+ raise ValueError(
183
+ f"coords indices out of bounds: all x and y must be within [0, {side_len - 1}] "
184
+ f"for an inferred {side_len}x{side_len} grid."
185
+ )
186
+
187
+ # Bijection check: every lattice cell must be covered exactly once
188
+ flat_indices = grid_y * side_len + grid_x
189
+ if len(np.unique(flat_indices)) != N_spots:
190
+ raise ValueError(
191
+ f"Input coords must define a unique 1-to-1 lattice mapping onto the {side_len}x{side_len} grid. "
192
+ "Found duplicate or missing grid cell coordinates."
193
+ )
194
+
195
+ spot_grid_coords = np.column_stack([grid_x, grid_y])
196
+
197
+ # 5. Direct 1-to-1 lattice feature grid assignment
198
+ feature_grid = np.zeros((side_len, side_len, N_features), dtype=np.float64)
199
+ feature_grid[grid_y, grid_x, :] = expr_matrix
200
+
201
+ # Compute 2D DWT using Haar basis
202
+ true_Y = _compute_2d_dwt(feature_grid, n_length_scales, wavelet="haar")
203
+ total_grid_spots = side_len * side_len
204
+ dimensions = _build_dimensions_dict(
205
+ n_spots=total_grid_spots,
206
+ n_length_scales=n_length_scales,
207
+ n_factors=n_factors,
208
+ n_features=N_features,
209
+ )
210
+
211
+ return WaveFactorData(
212
+ true_Y=true_Y,
213
+ dimensions=dimensions,
214
+ grid_side_length=side_len,
215
+ spot_grid_coords=spot_grid_coords,
216
+ coords=spot_coords,
217
+ )
218
+
219
+
220
+ def _compute_2d_dwt(feature_grid: np.ndarray, n_length_scales: int, wavelet: str = "haar") -> List:
221
+ """Performs 2D DWT across all features and formats into true_Y nested structure."""
222
+ H, W, G = feature_grid.shape
223
+ flattened_wavelet_matrices = []
224
+
225
+ for g in range(G):
226
+ coeffs = pywt.wavedec2(feature_grid[:, :, g], wavelet, level=n_length_scales)
227
+ flattened_coeffs = []
228
+ flattened_coeffs.append((coeffs[0].flatten(),))
229
+ for res in coeffs[1:]:
230
+ flattened_coeffs.append(tuple(matrix.flatten() for matrix in res))
231
+ flattened_wavelet_matrices.append(flattened_coeffs)
232
+
233
+ return flattened_wavelet_matrices
234
+
235
+
236
+ def _build_dimensions_dict(n_spots: int, n_length_scales: int, n_factors: int, n_features: int) -> Dict[str, Any]:
237
+ """Constructs dimensions dictionary matching C++ CAVI interface."""
238
+ n_resolutions = n_length_scales + 1
239
+ return {
240
+ "n_factors": n_factors,
241
+ "n_resolutions": n_resolutions,
242
+ "n_features": n_features,
243
+ "n_spots": n_spots,
244
+ "L_shape": get_L_shape(n_spots, n_resolutions, n_factors),
245
+ "Y_shape": get_Y_shape(n_spots, n_resolutions, n_features),
246
+ "ab_t_shape": (n_resolutions, n_factors),
247
+ "ab_tau_shape": (n_resolutions, n_features),
248
+ "F_shape": (n_factors, n_features),
249
+ }