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/__init__.py +94 -0
- structboost/_annotation.py +266 -0
- structboost/_boosting.py +552 -0
- structboost/_decoder.py +109 -0
- structboost/_encoder.py +88 -0
- structboost/_explorer.py +842 -0
- structboost/_io.py +302 -0
- structboost/_model.py +3483 -0
- structboost/_persistence.py +326 -0
- structboost/_plotting.py +301 -0
- structboost/_simulation.py +867 -0
- structboost/_stability.py +412 -0
- structboost/_types.py +393 -0
- structboost/_utils.py +509 -0
- structboost/py.typed +0 -0
- structboost-0.1.0.dist-info/METADATA +219 -0
- structboost-0.1.0.dist-info/RECORD +19 -0
- structboost-0.1.0.dist-info/WHEEL +4 -0
- structboost-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|