structboost 0.1.0__py3-none-any.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.
structboost/_utils.py ADDED
@@ -0,0 +1,509 @@
1
+ """Utility functions for structboost."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ import numpy as np
9
+ from numpy.typing import NDArray
10
+
11
+ if TYPE_CHECKING:
12
+ from anndata import AnnData
13
+
14
+
15
+ def compute_covariance_cache(
16
+ sourcemat: NDArray[np.floating],
17
+ *,
18
+ out: NDArray[np.floating] | None = None,
19
+ ) -> NDArray[np.floating]:
20
+ """Compute the predictor covariance matrix used by allboost.
21
+
22
+ This function computes X.T @ X (the Gram matrix), which allboost uses
23
+ internally to update regression coefficients. Pre-computing this matrix
24
+ can provide significant speedups when calling allboost multiple times
25
+ on the same sourcemat (e.g., during cross-validation or hyperparameter
26
+ tuning).
27
+
28
+ Note: The output matrix is O(p²) in memory, which can be substantial for
29
+ high-dimensional data.
30
+
31
+ Parameters
32
+ ----------
33
+ sourcemat : ndarray of shape (n_samples, n_features)
34
+ Predictor matrix. Standardization is recommended but not required.
35
+ out : ndarray of shape (n_features, n_features), optional
36
+ Pre-allocated output array. If provided, the result is written
37
+ in-place. Must have dtype float64.
38
+
39
+ Returns
40
+ -------
41
+ covcache : ndarray of shape (n_features, n_features)
42
+ The covariance/Gram matrix X.T @ X.
43
+
44
+ Examples
45
+ --------
46
+ >>> import numpy as np
47
+ >>> from structboost import allboost, compute_covariance_cache
48
+ >>> rng = np.random.default_rng(42)
49
+ >>> X = rng.standard_normal((100, 50))
50
+ >>> X = (X - X.mean(axis=0)) / X.std(axis=0)
51
+ >>> covcache = compute_covariance_cache(X)
52
+ >>> # Reuse one cache across calls. Targets are (n_samples, n_targets).
53
+ >>> targets = rng.standard_normal((100, 3))
54
+ >>> beta1 = allboost(X, targets, covcache=covcache)
55
+ >>> beta2 = allboost(X, targets[:, :2], covcache=covcache)
56
+ >>> beta1.shape
57
+ (3, 50)
58
+ """
59
+ p = sourcemat.shape[1]
60
+
61
+ if out is not None:
62
+ if out.shape != (p, p):
63
+ raise ValueError(f"out array must have shape ({p}, {p}), got {out.shape}")
64
+ np.dot(sourcemat.T, sourcemat, out=out)
65
+ return out
66
+
67
+ return np.dot(sourcemat.T, sourcemat).astype(np.float64, copy=False)
68
+
69
+
70
+ def disentangle_boosting_targets(
71
+ targets: NDArray[np.floating],
72
+ *,
73
+ standardize: bool = False,
74
+ ) -> NDArray[np.floating]:
75
+ """Orthogonalize gradient vectors across latent dimensions.
76
+
77
+ For each gradient vector (column), computes the residual after removing
78
+ the linear projection onto the subspace spanned by all other columns.
79
+ This encourages the subsequent boosting step to learn encoder weights
80
+ that produce disentangled latent representations.
81
+
82
+ Parameters
83
+ ----------
84
+ targets
85
+ Boosting target matrix of shape (n_samples, latent_dim).
86
+ Each column is the negative gradient for one latent dimension.
87
+ standardize
88
+ If True, standardize each column to zero mean and unit variance before
89
+ orthogonalization. This gives all dimensions equal influence regardless
90
+ of gradient magnitude. Default is False (preserve original scales).
91
+
92
+ Returns
93
+ -------
94
+ Orthogonalized target matrix of shape (n_samples, latent_dim).
95
+ """
96
+ n_dims = targets.shape[1]
97
+ if n_dims == 1:
98
+ return targets.copy()
99
+
100
+ work = targets
101
+ if standardize:
102
+ means = targets.mean(axis=0, keepdims=True)
103
+ stds = targets.std(axis=0, keepdims=True)
104
+ stds = np.where(stds < 1e-12, 1.0, stds) # Avoid division by zero
105
+ work = (targets - means) / stds
106
+
107
+ result = np.empty_like(work)
108
+ for j in range(n_dims):
109
+ other_cols = np.delete(work, j, axis=1)
110
+ y = work[:, j]
111
+ coeffs, *_ = np.linalg.lstsq(other_cols, y, rcond=None)
112
+ fitted = other_cols @ coeffs
113
+ result[:, j] = y - fitted
114
+
115
+ return result.astype(targets.dtype, copy=False)
116
+
117
+
118
+ def _pca_scores(
119
+ X: NDArray[np.floating], n_components: int, *, center: bool = True
120
+ ) -> NDArray[np.float64]:
121
+ """Top-``n_components`` principal component scores of X, via SVD.
122
+
123
+ Columns are **never** rescaled. BAE's input contract is z-transformed data, so
124
+ dividing by column standard deviations here would silently apply a second
125
+ transformation on top of one the caller already performed — and on data that is
126
+ *not* standardized it would mask the condition that
127
+ ``BAE._check_standardized`` exists to warn about.
128
+
129
+ Parameters
130
+ ----------
131
+ X
132
+ Data matrix of shape (n_samples, n_features).
133
+ n_components
134
+ Number of components to keep. Must not exceed ``min(X.shape)``.
135
+ center
136
+ Subtract the column means before decomposing. Required for PCA to be
137
+ meaningful on raw data, but pass ``center=False`` when X is already
138
+ z-transformed so that no further transformation is applied to it.
139
+
140
+ Returns
141
+ -------
142
+ ndarray of shape (n_samples, n_components)
143
+ Component scores, ordered by decreasing explained variance.
144
+
145
+ Raises
146
+ ------
147
+ ValueError
148
+ If ``n_components`` is not in ``[1, min(X.shape)]``.
149
+ """
150
+ n_max = min(X.shape)
151
+ if not 1 <= n_components <= n_max:
152
+ raise ValueError(
153
+ f"n_components must be in [1, {n_max}] for data of shape {X.shape}, got {n_components}"
154
+ )
155
+ work = X - X.mean(axis=0, keepdims=True) if center else X
156
+ u, s, _ = np.linalg.svd(np.asarray(work, dtype=np.float64), full_matrices=False)
157
+ return u[:, :n_components] * s[:n_components]
158
+
159
+
160
+ @dataclass
161
+ class ObsCovariateEncoding:
162
+ """Stores encoding parameters for obs covariates.
163
+
164
+ Attributes
165
+ ----------
166
+ encoded
167
+ Standardized encoded matrix, shape (n_samples, n_dummy_cols).
168
+ column_info
169
+ Per-column metadata: type ("categorical"/"numeric"), categories, dummy col indices.
170
+ mean
171
+ Column means used for standardization.
172
+ std
173
+ Column stds used for standardization.
174
+ obs_columns
175
+ Original obs column names.
176
+ n_columns
177
+ Total number of encoded columns.
178
+ encoded_columns
179
+ Human-readable names of the encoded columns.
180
+ """
181
+
182
+ encoded: NDArray[np.floating]
183
+ column_info: dict[str, dict]
184
+ mean: NDArray[np.floating]
185
+ std: NDArray[np.floating]
186
+ obs_columns: list[str]
187
+ n_columns: int
188
+ encoded_columns: list[str]
189
+
190
+
191
+ def encode_obs_covariates(
192
+ adata: AnnData,
193
+ obs_columns: list[str],
194
+ ) -> ObsCovariateEncoding:
195
+ """Encode obs covariates as a standardized numeric matrix.
196
+
197
+ Categorical, string, object, and boolean columns are dummy-encoded using
198
+ the first observed category as reference. Numeric columns are kept as-is.
199
+ The result is z-standardized.
200
+
201
+ Parameters
202
+ ----------
203
+ adata
204
+ AnnData object with obs DataFrame.
205
+ obs_columns
206
+ Column names in adata.obs to encode.
207
+
208
+ Returns
209
+ -------
210
+ ObsCovariateEncoding with the encoded matrix and metadata.
211
+
212
+ Raises
213
+ ------
214
+ ValueError
215
+ If a column is missing, contains missing/non-finite values, is constant,
216
+ or if the resulting design matrix is rank-deficient.
217
+ """
218
+ import pandas as pd
219
+
220
+ missing = [c for c in obs_columns if c not in adata.obs.columns]
221
+ if missing:
222
+ raise ValueError(f"Columns {missing} not found in adata.obs")
223
+
224
+ if not obs_columns:
225
+ raise ValueError("obs_columns must contain at least one column")
226
+
227
+ parts: list[NDArray[np.floating]] = []
228
+ column_info: dict[str, dict] = {}
229
+ encoded_columns: list[str] = []
230
+
231
+ for col in obs_columns:
232
+ series = adata.obs[col]
233
+ if series.isna().any():
234
+ raise ValueError(f"Column {col!r} contains missing values")
235
+ is_categorical = (
236
+ isinstance(series.dtype, pd.CategoricalDtype)
237
+ or pd.api.types.is_bool_dtype(series.dtype)
238
+ or pd.api.types.is_object_dtype(series.dtype)
239
+ or pd.api.types.is_string_dtype(series.dtype)
240
+ )
241
+ if is_categorical:
242
+ if isinstance(series.dtype, pd.CategoricalDtype):
243
+ cats = list(series.cat.categories)
244
+ else:
245
+ cats = list(pd.unique(series))
246
+ if len(cats) < 2:
247
+ raise ValueError(f"Column {col!r} is constant")
248
+ categorical = pd.Series(
249
+ pd.Categorical(series, categories=cats),
250
+ index=series.index,
251
+ )
252
+ dummies = pd.get_dummies(categorical, drop_first=True).to_numpy(dtype=np.float64)
253
+ names = [f"{col}[{cat}]" for cat in cats[1:]]
254
+ column_info[col] = {
255
+ "type": "categorical",
256
+ "categories": cats,
257
+ "n_dummies": dummies.shape[1],
258
+ "encoded_columns": names,
259
+ }
260
+ parts.append(dummies)
261
+ encoded_columns.extend(names)
262
+ else:
263
+ try:
264
+ vals = series.to_numpy(dtype=np.float64).reshape(-1, 1)
265
+ except (TypeError, ValueError) as exc:
266
+ raise ValueError(f"Column {col!r} cannot be encoded as numeric") from exc
267
+ if not np.isfinite(vals).all():
268
+ raise ValueError(f"Column {col!r} contains non-finite values")
269
+ if vals.std() < 1e-12:
270
+ raise ValueError(f"Column {col!r} is constant")
271
+ column_info[col] = {
272
+ "type": "numeric",
273
+ "n_dummies": 1,
274
+ "encoded_columns": [col],
275
+ }
276
+ parts.append(vals)
277
+ encoded_columns.append(col)
278
+
279
+ raw = np.hstack(parts)
280
+ mean = raw.mean(axis=0)
281
+ std = raw.std(axis=0)
282
+ if np.any(std < 1e-12):
283
+ raise ValueError("Encoded covariate design contains a constant column")
284
+ encoded = (raw - mean) / std
285
+ rank = np.linalg.matrix_rank(encoded)
286
+ if rank < encoded.shape[1]:
287
+ raise ValueError(
288
+ f"Encoded covariate design is rank-deficient (rank {rank} < {encoded.shape[1]} columns)"
289
+ )
290
+
291
+ return ObsCovariateEncoding(
292
+ encoded=encoded,
293
+ column_info=column_info,
294
+ mean=mean,
295
+ std=std,
296
+ obs_columns=list(obs_columns),
297
+ n_columns=encoded.shape[1],
298
+ encoded_columns=encoded_columns,
299
+ )
300
+
301
+
302
+ def transform_obs_covariates(
303
+ adata: AnnData,
304
+ encoding: ObsCovariateEncoding,
305
+ ) -> NDArray[np.floating]:
306
+ """Apply a stored obs covariate encoding to new data.
307
+
308
+ Parameters
309
+ ----------
310
+ adata
311
+ New AnnData object with the same obs columns.
312
+ encoding
313
+ Encoding from a previous ``encode_obs_covariates`` call.
314
+
315
+ Returns
316
+ -------
317
+ Encoded matrix of shape (n_samples, n_columns), standardized with
318
+ the training mean/std.
319
+
320
+ Raises
321
+ ------
322
+ ValueError
323
+ If required columns are missing, contain missing/non-finite values, or
324
+ contain a categorical level not observed during fitting.
325
+ """
326
+ import pandas as pd
327
+
328
+ missing = [c for c in encoding.obs_columns if c not in adata.obs.columns]
329
+ if missing:
330
+ raise ValueError(f"Columns {missing} not found in adata.obs")
331
+
332
+ parts: list[NDArray[np.floating]] = []
333
+ for col in encoding.obs_columns:
334
+ info = encoding.column_info[col]
335
+ series = adata.obs[col]
336
+ if series.isna().any():
337
+ raise ValueError(f"Column {col!r} contains missing values")
338
+ if info["type"] == "categorical":
339
+ cats = info["categories"]
340
+ unknown = [value for value in pd.unique(series) if value not in cats]
341
+ if unknown:
342
+ raise ValueError(
343
+ f"Column {col!r} contains levels not seen during fitting: {unknown}"
344
+ )
345
+ categorical = pd.Series(
346
+ pd.Categorical(series, categories=cats),
347
+ index=series.index,
348
+ )
349
+ dummies = pd.get_dummies(categorical, drop_first=True).to_numpy(dtype=np.float64)
350
+ parts.append(dummies)
351
+ else:
352
+ vals = series.to_numpy(dtype=np.float64).reshape(-1, 1)
353
+ if not np.isfinite(vals).all():
354
+ raise ValueError(f"Column {col!r} contains non-finite values")
355
+ parts.append(vals)
356
+
357
+ raw = np.hstack(parts)
358
+ return (raw - encoding.mean) / encoding.std
359
+
360
+
361
+ def _resolve_flat_mandatory(
362
+ entries: list[str] | list[int] | NDArray[np.intp],
363
+ adata: AnnData,
364
+ ) -> NDArray[np.intp]:
365
+ """Resolve a flat list of gene names or indices to intp array."""
366
+ if isinstance(entries, np.ndarray) and entries.dtype == np.intp:
367
+ return entries
368
+
369
+ indices: list[int] = []
370
+ for entry in entries:
371
+ if isinstance(entry, str):
372
+ if entry not in adata.var_names:
373
+ raise ValueError(f"Gene '{entry}' not found in adata.var_names")
374
+ indices.append(adata.var_names.get_loc(entry))
375
+ else:
376
+ indices.append(int(entry))
377
+ return np.array(indices, dtype=np.intp)
378
+
379
+
380
+ def resolve_mandatory_genes(
381
+ mandatory_genes: list[str]
382
+ | list[int]
383
+ | NDArray[np.intp]
384
+ | list[list[str] | list[int] | NDArray[np.intp]]
385
+ | None,
386
+ adata: AnnData,
387
+ ) -> NDArray[np.intp] | list[NDArray[np.intp]] | None:
388
+ """Resolve mandatory gene specifications to integer indices.
389
+
390
+ Parameters
391
+ ----------
392
+ mandatory_genes
393
+ Gene names (str) or column indices (int), or per-target lists thereof.
394
+ None means no mandatory genes.
395
+ adata
396
+ AnnData with var_names for name resolution.
397
+
398
+ Returns
399
+ -------
400
+ Resolved indices as a 1D intp array (global) or list of 1D intp arrays
401
+ (per-target). None if input is None.
402
+ """
403
+ if mandatory_genes is None:
404
+ return None
405
+
406
+ # Check if it's a list of lists (per-target)
407
+ if (
408
+ isinstance(mandatory_genes, list)
409
+ and len(mandatory_genes) > 0
410
+ and isinstance(mandatory_genes[0], (list, np.ndarray))
411
+ ):
412
+ return [_resolve_flat_mandatory(sub, adata) for sub in mandatory_genes]
413
+
414
+ return _resolve_flat_mandatory(mandatory_genes, adata)
415
+
416
+
417
+ def linear_ceiling(
418
+ adata: AnnData,
419
+ n_components: int,
420
+ *,
421
+ layer: str | None = None,
422
+ ) -> float:
423
+ """Variance explainable by the best ``n_components``-dimensional linear model.
424
+
425
+ A reconstruction MSE means little on its own. On the z-transformed input BAE
426
+ expects, predicting zero everywhere scores exactly 1.0, so an MSE of 0.85 is
427
+ 15% of variance explained, not "close to perfect". Even that number needs a
428
+ reference: most per-gene variance in scRNA-seq is dropout and sampling noise,
429
+ so no ``n_components``-dimensional model can reach 1. This returns that
430
+ reference — the fraction captured by the leading ``n_components`` principal
431
+ components, which upper-bounds any linear encoder of the same width.
432
+
433
+ Compare it against ``adata.uns["bae"]["variance_explained"]``. A fit sitting
434
+ near the ceiling is doing as well as its latent budget allows and should be
435
+ given more dimensions rather than more iterations; one far below it is
436
+ underfitting, and ``boosting_stepno`` or ``max_iterations`` is the lever.
437
+
438
+ Parameters
439
+ ----------
440
+ adata
441
+ AnnData object. Uses the same matrix BAE was fitted on.
442
+ n_components
443
+ Latent width to compare against, i.e. ``config.latent_dim``.
444
+ layer
445
+ Optional ``adata.layers`` key to use instead of ``adata.X``. Pass the
446
+ same layer the model was fitted with (``adata.uns["bae"]["layer"]``, or
447
+ absent when the fit read ``adata.X``); a ceiling computed on a different
448
+ matrix is not comparable with the model's reconstruction.
449
+
450
+ Returns
451
+ -------
452
+ Fraction of total variance in ``[0, 1]`` captured by the top
453
+ ``n_components`` principal components.
454
+
455
+ See Also
456
+ --------
457
+ structboost.BAE.fit : Writes ``uns["bae"]["variance_explained"]`` to compare
458
+ against this ceiling.
459
+
460
+ Examples
461
+ --------
462
+ >>> ceiling = linear_ceiling(adata, adata.uns["bae"]["latent_dim"])
463
+ >>> got = adata.uns["bae"]["variance_explained"]
464
+ >>> print(f"BAE reaches {100 * got / ceiling:.0f}% of the linear ceiling")
465
+ """
466
+ import scipy.sparse as sp
467
+ from scipy.sparse.linalg import LinearOperator, svds
468
+
469
+ matrix = adata.layers[layer] if layer is not None else adata.X
470
+ n_obs, n_vars = matrix.shape
471
+ max_rank = min(n_obs, n_vars)
472
+ if not 1 <= n_components <= max_rank:
473
+ raise ValueError(
474
+ f"n_components must be in [1, {max_rank}] for data of shape "
475
+ f"{(n_obs, n_vars)}, got {n_components}"
476
+ )
477
+
478
+ mean = np.asarray(matrix.mean(axis=0), dtype=np.float64).ravel()
479
+ # Total sum of squares about the per-gene mean, without densifying: for sparse
480
+ # X, ||X - 1 mu'||_F^2 = ||X||_F^2 - n_obs ||mu||^2.
481
+ if sp.issparse(matrix):
482
+ sum_squares = float(matrix.multiply(matrix).sum())
483
+ else:
484
+ sum_squares = float(np.square(np.asarray(matrix, dtype=np.float64)).sum())
485
+ ss_total = sum_squares - n_obs * float(mean @ mean)
486
+ if ss_total <= 0:
487
+ return float("nan")
488
+
489
+ # `svds` cannot return every singular value, so fall back to a dense
490
+ # decomposition when the full spectrum is requested (only feasible, and only
491
+ # asked for, on small data).
492
+ if n_components >= max_rank - 1:
493
+ dense = matrix.toarray() if sp.issparse(matrix) else np.asarray(matrix)
494
+ singular = np.linalg.svd(np.asarray(dense, dtype=np.float64) - mean, compute_uv=False)
495
+ else:
496
+ # Centering a sparse matrix would densify it; apply it as an operator.
497
+ def _matvec(v):
498
+ return matrix @ v - mean @ v
499
+
500
+ def _rmatvec(v):
501
+ return np.asarray(matrix.T @ v).ravel() - mean * v.sum()
502
+
503
+ centered = LinearOperator(
504
+ (n_obs, n_vars), matvec=_matvec, rmatvec=_rmatvec, dtype=np.float64
505
+ )
506
+ singular = svds(centered, k=n_components, return_singular_vectors=False)
507
+
508
+ top = np.sort(singular)[::-1][:n_components]
509
+ return float(min(np.square(top).sum() / ss_total, 1.0))
structboost/py.typed ADDED
File without changes