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
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Checkpoint serialization for a fitted :class:`~structboost._model.BAE`.
|
|
2
|
+
|
|
3
|
+
The payload written here contains **only** tensors and Python primitives. That
|
|
4
|
+
is a deliberate constraint, not an accident of implementation: it is what lets
|
|
5
|
+
``BAE.load`` call ``torch.load(..., weights_only=True)``, so reading a
|
|
6
|
+
structboost checkpoint can never execute arbitrary code from the file. PyTorch's
|
|
7
|
+
restricted unpickler (``torch/_weights_only_unpickler.py``) admits tensors,
|
|
8
|
+
``torch.device``, ``torch.Size``, ``OrderedDict``, ``set``, ``bytes`` and the
|
|
9
|
+
builtin scalar/container types — but *not* NumPy arrays or NumPy scalars, which
|
|
10
|
+
is why everything crossing this boundary is converted explicitly.
|
|
11
|
+
|
|
12
|
+
Two things a fitted model carries are intentionally not written:
|
|
13
|
+
|
|
14
|
+
``ObsCovariateEncoding.encoded``
|
|
15
|
+
The training-set covariate design matrix, shape ``(n_cells, n_columns)``. It
|
|
16
|
+
is read only inside ``BAE.fit``, which re-encodes from the incoming
|
|
17
|
+
``AnnData`` before using it, so nothing needs it after a load. Dropping it
|
|
18
|
+
keeps cell-level training data out of a shipped model file and keeps the
|
|
19
|
+
file size independent of the training set.
|
|
20
|
+
optimizer state
|
|
21
|
+
A loaded model is deployable, not resumable mid-run. ``fit`` rebuilds the
|
|
22
|
+
decoder and its optimizer unconditionally anyway.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
32
|
+
from ._model import BAE
|
|
33
|
+
from ._utils import ObsCovariateEncoding
|
|
34
|
+
|
|
35
|
+
#: Identifies the file as a structboost checkpoint. A ``.pt`` file is otherwise
|
|
36
|
+
#: indistinguishable from any other torch payload, and a confusing
|
|
37
|
+
#: ``KeyError`` deep inside the restore is a worse diagnostic than a refusal.
|
|
38
|
+
MAGIC = "structboost.bae"
|
|
39
|
+
|
|
40
|
+
#: Bumped whenever the payload layout changes incompatibly. ``load`` refuses a
|
|
41
|
+
#: file written by a newer format than it understands rather than guessing.
|
|
42
|
+
#:
|
|
43
|
+
#: 2 — added ``layer``. The bump is for *forward* compatibility: an older install
|
|
44
|
+
#: would ignore the key and silently read ``adata.X`` instead of the layer the
|
|
45
|
+
#: model was fitted on, which is a wrong answer rather than a missing one.
|
|
46
|
+
#:
|
|
47
|
+
#: 3 — the two covariate encodings became one, alongside the ``batch_key`` /
|
|
48
|
+
#: ``batch_integration_mode`` API. Formats 1 and 2 predate the first public
|
|
49
|
+
#: release, so no migration is written and the loader refuses them by name
|
|
50
|
+
#: rather than failing on a missing key.
|
|
51
|
+
#:
|
|
52
|
+
#: 4 — ``BAEConfig.boosting_mode`` was dropped with ``allboost``'s refine mode.
|
|
53
|
+
#: ``restore_payload`` splats the stored config into ``BAEConfig``, so a format-3
|
|
54
|
+
#: file would otherwise die on an unexpected keyword argument. Every format below
|
|
55
|
+
#: this one predates the first public release.
|
|
56
|
+
CHECKPOINT_FORMAT = 4
|
|
57
|
+
#: Oldest format this install can read.
|
|
58
|
+
MIN_CHECKPOINT_FORMAT = 4
|
|
59
|
+
|
|
60
|
+
#: Category element types that survive the round trip with their comparison
|
|
61
|
+
#: semantics intact. ``bool`` precedes ``int`` because ``bool`` is a subclass of
|
|
62
|
+
#: ``int`` and would otherwise be recorded as one.
|
|
63
|
+
_CATEGORY_KINDS: tuple[tuple[str, type], ...] = (
|
|
64
|
+
("bool", bool),
|
|
65
|
+
("int", int),
|
|
66
|
+
("float", float),
|
|
67
|
+
("str", str),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _to_primitive(obj: Any, *, coerce_unknown: bool = False) -> Any:
|
|
72
|
+
"""Convert `obj` into tensors-free primitives that ``weights_only`` accepts.
|
|
73
|
+
|
|
74
|
+
NumPy scalars become Python scalars and NumPy arrays become nested lists;
|
|
75
|
+
tuples become lists, since pickle would otherwise be the only thing keeping
|
|
76
|
+
them apart and the distinction does not survive anyway.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
obj
|
|
81
|
+
Value to convert.
|
|
82
|
+
coerce_unknown
|
|
83
|
+
If True, anything not otherwise convertible is stringified. This is used
|
|
84
|
+
only for provenance dictionaries, matching the best-effort
|
|
85
|
+
``json.dumps(..., default=str)`` policy in :mod:`structboost._io`. For
|
|
86
|
+
values that take part in computation it stays False, so an unexpected
|
|
87
|
+
type raises instead of silently becoming its ``repr``.
|
|
88
|
+
"""
|
|
89
|
+
if obj is None or isinstance(obj, (str, bool, int, float)):
|
|
90
|
+
return obj
|
|
91
|
+
if isinstance(obj, np.generic):
|
|
92
|
+
return obj.item()
|
|
93
|
+
if isinstance(obj, np.ndarray):
|
|
94
|
+
return obj.tolist()
|
|
95
|
+
if isinstance(obj, dict):
|
|
96
|
+
return {str(k): _to_primitive(v, coerce_unknown=coerce_unknown) for k, v in obj.items()}
|
|
97
|
+
if isinstance(obj, (list, tuple, set)):
|
|
98
|
+
return [_to_primitive(v, coerce_unknown=coerce_unknown) for v in obj]
|
|
99
|
+
if coerce_unknown:
|
|
100
|
+
return str(obj)
|
|
101
|
+
raise TypeError(f"cannot serialize object of type {type(obj).__name__!r} into a checkpoint")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _tensor(array: Any) -> Any:
|
|
105
|
+
"""Convert an array-like to a CPU tensor, preserving dtype."""
|
|
106
|
+
import torch
|
|
107
|
+
|
|
108
|
+
return torch.as_tensor(np.asarray(array)).cpu()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _array(tensor: Any) -> np.ndarray:
|
|
112
|
+
"""Convert a checkpoint tensor back to a NumPy array."""
|
|
113
|
+
return tensor.cpu().numpy()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _encode_categories(column: str, categories: list) -> dict[str, Any]:
|
|
117
|
+
"""Record a categorical column's levels with their type.
|
|
118
|
+
|
|
119
|
+
The levels come from ``list(series.cat.categories)`` or
|
|
120
|
+
``list(pd.unique(series))``, so they can be NumPy scalars. Their *type*
|
|
121
|
+
matters downstream: ``transform_obs_covariates`` both tests
|
|
122
|
+
``value not in categories`` and builds ``pd.Categorical(series,
|
|
123
|
+
categories=...)``, and a level list whose dtype no longer matches the incoming
|
|
124
|
+
column maps every cell to NaN, yielding an all-zero dummy block — a wrong
|
|
125
|
+
reconstruction with no error raised. NumPy scalars are therefore unwrapped to
|
|
126
|
+
true Python scalars, which compare equal to their NumPy counterparts, and
|
|
127
|
+
anything outside the supported set is refused here rather than corrupting a
|
|
128
|
+
reconstruction later.
|
|
129
|
+
"""
|
|
130
|
+
values = [v.item() if isinstance(v, np.generic) else v for v in categories]
|
|
131
|
+
for kind, py_type in _CATEGORY_KINDS:
|
|
132
|
+
if all(isinstance(v, py_type) for v in values):
|
|
133
|
+
return {"kind": kind, "values": values}
|
|
134
|
+
offending = next(
|
|
135
|
+
(type(v).__name__ for v in values if not isinstance(v, (bool, int, float, str))),
|
|
136
|
+
type(values[0]).__name__ if values else "unknown",
|
|
137
|
+
)
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"obs column {column!r} has categories of type {offending!r}, which cannot be "
|
|
140
|
+
"persisted in a BAE checkpoint. Convert the column to string, integer or "
|
|
141
|
+
"boolean before fitting."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _decode_categories(payload: dict[str, Any]) -> list:
|
|
146
|
+
"""Rebuild a category list with the element type it was saved with."""
|
|
147
|
+
kind = payload["kind"]
|
|
148
|
+
caster = {"bool": bool, "int": int, "float": float, "str": str}[kind]
|
|
149
|
+
return [caster(v) for v in payload["values"]]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _encode_encoding(encoding: ObsCovariateEncoding | None) -> dict[str, Any] | None:
|
|
153
|
+
"""Serialize an :class:`~structboost._utils.ObsCovariateEncoding`.
|
|
154
|
+
|
|
155
|
+
``encoded`` is dropped; see the module docstring.
|
|
156
|
+
"""
|
|
157
|
+
if encoding is None:
|
|
158
|
+
return None
|
|
159
|
+
column_info: dict[str, Any] = {}
|
|
160
|
+
for column, info in encoding.column_info.items():
|
|
161
|
+
entry = {
|
|
162
|
+
"type": info["type"],
|
|
163
|
+
"n_dummies": int(info["n_dummies"]),
|
|
164
|
+
"encoded_columns": [str(c) for c in info["encoded_columns"]],
|
|
165
|
+
}
|
|
166
|
+
if info["type"] == "categorical":
|
|
167
|
+
entry["categories"] = _encode_categories(column, list(info["categories"]))
|
|
168
|
+
column_info[column] = entry
|
|
169
|
+
return {
|
|
170
|
+
"column_info": column_info,
|
|
171
|
+
"mean": _tensor(encoding.mean),
|
|
172
|
+
"std": _tensor(encoding.std),
|
|
173
|
+
"obs_columns": [str(c) for c in encoding.obs_columns],
|
|
174
|
+
"n_columns": int(encoding.n_columns),
|
|
175
|
+
"encoded_columns": [str(c) for c in encoding.encoded_columns],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _decode_encoding(payload: dict[str, Any] | None) -> ObsCovariateEncoding | None:
|
|
180
|
+
"""Rebuild an encoding, with a zero-row ``encoded`` placeholder."""
|
|
181
|
+
if payload is None:
|
|
182
|
+
return None
|
|
183
|
+
from ._utils import ObsCovariateEncoding
|
|
184
|
+
|
|
185
|
+
column_info: dict[str, dict] = {}
|
|
186
|
+
for column, info in payload["column_info"].items():
|
|
187
|
+
entry: dict[str, Any] = {
|
|
188
|
+
"type": info["type"],
|
|
189
|
+
"n_dummies": info["n_dummies"],
|
|
190
|
+
"encoded_columns": list(info["encoded_columns"]),
|
|
191
|
+
}
|
|
192
|
+
if info["type"] == "categorical":
|
|
193
|
+
entry["categories"] = _decode_categories(info["categories"])
|
|
194
|
+
column_info[column] = entry
|
|
195
|
+
n_columns = int(payload["n_columns"])
|
|
196
|
+
return ObsCovariateEncoding(
|
|
197
|
+
# Not the training design matrix: it is not persisted, and a zero-row
|
|
198
|
+
# array makes any accidental use fail on shape instead of quietly
|
|
199
|
+
# contributing wrong numbers.
|
|
200
|
+
encoded=np.empty((0, n_columns), dtype=np.float64),
|
|
201
|
+
column_info=column_info,
|
|
202
|
+
mean=_array(payload["mean"]),
|
|
203
|
+
std=_array(payload["std"]),
|
|
204
|
+
obs_columns=list(payload["obs_columns"]),
|
|
205
|
+
n_columns=n_columns,
|
|
206
|
+
encoded_columns=list(payload["encoded_columns"]),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def build_payload(model: BAE) -> dict[str, Any]:
|
|
211
|
+
"""Assemble the checkpoint dictionary for a fitted model."""
|
|
212
|
+
from dataclasses import asdict
|
|
213
|
+
|
|
214
|
+
config = asdict(model.config)
|
|
215
|
+
config["device"] = str(model.config.device)
|
|
216
|
+
config["decoder_hidden_dims"] = [int(d) for d in model.config.decoder_hidden_dims]
|
|
217
|
+
|
|
218
|
+
report = model._training_report
|
|
219
|
+
prior = model._prior_weights
|
|
220
|
+
scaling = model._latent_scaling
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
"magic": MAGIC,
|
|
224
|
+
"format_version": CHECKPOINT_FORMAT,
|
|
225
|
+
"structboost_version": _package_version(),
|
|
226
|
+
"n_genes": int(model.n_genes),
|
|
227
|
+
"config": _to_primitive(config),
|
|
228
|
+
"encoder_state": model.encoder.state_dict(),
|
|
229
|
+
"decoder_state": model.decoder.state_dict(),
|
|
230
|
+
# Read off the decoder itself rather than re-derived from the encoding,
|
|
231
|
+
# so the rebuilt geometry cannot drift from what the weights expect.
|
|
232
|
+
"decoder_n_covariates": int(model.decoder.n_covariates),
|
|
233
|
+
"is_fitted": bool(model._is_fitted),
|
|
234
|
+
"layer": model._layer,
|
|
235
|
+
"var_names": None if model._var_names is None else [str(v) for v in model._var_names],
|
|
236
|
+
"training_history": {
|
|
237
|
+
str(k): [float(x) for x in v] for k, v in model._training_history.items()
|
|
238
|
+
},
|
|
239
|
+
"training_report": (
|
|
240
|
+
None if report is None else {k: _tensor(v) for k, v in report.to_dict().items()}
|
|
241
|
+
),
|
|
242
|
+
"latent_init": _to_primitive(model._latent_init),
|
|
243
|
+
"batch_encoding": _encode_encoding(model._batch_encoding),
|
|
244
|
+
"batch_integration_mode": str(model._batch_integration_mode),
|
|
245
|
+
"batch_weights": (None if model._batch_weights is None else _tensor(model._batch_weights)),
|
|
246
|
+
"balance_obs": model._balance_obs,
|
|
247
|
+
"mandatory_genes": _to_primitive(model._mandatory_genes),
|
|
248
|
+
"prior_weights": None if prior is None else _tensor(prior),
|
|
249
|
+
"prior_info": _to_primitive(model._prior_info, coerce_unknown=True),
|
|
250
|
+
"latent_scaling": (
|
|
251
|
+
None
|
|
252
|
+
if scaling is None
|
|
253
|
+
else {"mean": _tensor(scaling["mean"]), "scale": _tensor(scaling["scale"])}
|
|
254
|
+
),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def restore_payload(cls: type[BAE], payload: dict[str, Any], device: Any) -> BAE:
|
|
259
|
+
"""Rebuild a model from a checkpoint payload on `device`."""
|
|
260
|
+
from ._decoder import BAEDecoder
|
|
261
|
+
from ._types import BAEConfig, TrainingReport
|
|
262
|
+
|
|
263
|
+
config_kwargs = dict(payload["config"])
|
|
264
|
+
# Must be a tuple again: BAEConfig equality and `dataclasses.replace`
|
|
265
|
+
# round-trips both depend on it, and a list would quietly propagate.
|
|
266
|
+
config_kwargs["decoder_hidden_dims"] = tuple(config_kwargs["decoder_hidden_dims"])
|
|
267
|
+
config_kwargs["device"] = device
|
|
268
|
+
config = BAEConfig(**config_kwargs)
|
|
269
|
+
|
|
270
|
+
model = cls(int(payload["n_genes"]), config)
|
|
271
|
+
|
|
272
|
+
# Rebuild the decoder at the geometry the saved weights were trained with;
|
|
273
|
+
# `__init__` builds an unconditioned one. Mirrors BAE.fit.
|
|
274
|
+
decoder_input = 2 * config.latent_dim if config.split_softmax else config.latent_dim
|
|
275
|
+
model.decoder = BAEDecoder(
|
|
276
|
+
model.n_genes,
|
|
277
|
+
config,
|
|
278
|
+
input_dim_override=decoder_input,
|
|
279
|
+
n_covariates=int(payload["decoder_n_covariates"]),
|
|
280
|
+
).to(config.device)
|
|
281
|
+
|
|
282
|
+
model.encoder.load_state_dict(payload["encoder_state"])
|
|
283
|
+
model.decoder.load_state_dict(payload["decoder_state"])
|
|
284
|
+
model.to(config.device)
|
|
285
|
+
|
|
286
|
+
model._is_fitted = bool(payload["is_fitted"])
|
|
287
|
+
# `.get`: format-1 checkpoints predate layer support and were fitted on `.X`.
|
|
288
|
+
model._layer = payload.get("layer")
|
|
289
|
+
var_names = payload["var_names"]
|
|
290
|
+
model._var_names = None if var_names is None else np.asarray(var_names, dtype=object)
|
|
291
|
+
model._training_history = {k: list(v) for k, v in payload["training_history"].items()}
|
|
292
|
+
report = payload["training_report"]
|
|
293
|
+
model._training_report = (
|
|
294
|
+
None
|
|
295
|
+
if report is None
|
|
296
|
+
else TrainingReport.from_dict({k: _array(v) for k, v in report.items()})
|
|
297
|
+
)
|
|
298
|
+
model._latent_init = dict(payload["latent_init"])
|
|
299
|
+
|
|
300
|
+
model._batch_encoding = _decode_encoding(payload["batch_encoding"])
|
|
301
|
+
model._batch_integration_mode = payload["batch_integration_mode"]
|
|
302
|
+
batch_weights = payload["batch_weights"]
|
|
303
|
+
model._batch_weights = None if batch_weights is None else _array(batch_weights)
|
|
304
|
+
model._balance_obs = payload["balance_obs"]
|
|
305
|
+
model._mandatory_genes = payload["mandatory_genes"]
|
|
306
|
+
|
|
307
|
+
prior = payload["prior_weights"]
|
|
308
|
+
model._prior_weights = None if prior is None else _array(prior)
|
|
309
|
+
model._prior_info = dict(payload["prior_info"])
|
|
310
|
+
scaling = payload["latent_scaling"]
|
|
311
|
+
model._latent_scaling = (
|
|
312
|
+
None
|
|
313
|
+
if scaling is None
|
|
314
|
+
else {"mean": _array(scaling["mean"]), "scale": _array(scaling["scale"])}
|
|
315
|
+
)
|
|
316
|
+
return model
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _package_version() -> str:
|
|
320
|
+
"""Best-effort package version for provenance; never fatal."""
|
|
321
|
+
try:
|
|
322
|
+
from . import __version__
|
|
323
|
+
|
|
324
|
+
return str(__version__)
|
|
325
|
+
except Exception: # pragma: no cover - provenance only
|
|
326
|
+
return "unknown"
|
structboost/_plotting.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Plotting helpers for structboost.
|
|
2
|
+
|
|
3
|
+
These utilities are intentionally lightweight and optional: they only depend on
|
|
4
|
+
matplotlib and numpy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def plot_top_boosting_coefficients(
|
|
15
|
+
betamat: np.ndarray,
|
|
16
|
+
gene_names: Sequence[str],
|
|
17
|
+
cluster_labels: Sequence[str],
|
|
18
|
+
*,
|
|
19
|
+
top_m: int = 10,
|
|
20
|
+
neg_color: str = "#1f77b4",
|
|
21
|
+
pos_color: str = "#d62728",
|
|
22
|
+
):
|
|
23
|
+
"""Plot per-target dot/lollipop charts of top boosting coefficients.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
betamat
|
|
28
|
+
Array of shape (n_targets, n_features) with boosting coefficients.
|
|
29
|
+
gene_names
|
|
30
|
+
Feature names of length n_features (e.g., gene symbols).
|
|
31
|
+
cluster_labels
|
|
32
|
+
Target labels of length n_targets (e.g., cluster IDs).
|
|
33
|
+
top_m
|
|
34
|
+
Number of features to display per target (ranked by ``|beta|``).
|
|
35
|
+
neg_color, pos_color
|
|
36
|
+
Colors used for negative and positive coefficients, respectively.
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
figs
|
|
41
|
+
List of matplotlib Figure objects, one per target.
|
|
42
|
+
axes
|
|
43
|
+
List of matplotlib Axes objects, one per target.
|
|
44
|
+
"""
|
|
45
|
+
import matplotlib.pyplot as plt
|
|
46
|
+
|
|
47
|
+
betamat = np.asarray(betamat, dtype=float)
|
|
48
|
+
n_targets, n_features = betamat.shape
|
|
49
|
+
|
|
50
|
+
if len(gene_names) != n_features:
|
|
51
|
+
raise ValueError(f"gene_names must have length {n_features}, got {len(gene_names)}")
|
|
52
|
+
if len(cluster_labels) != n_targets:
|
|
53
|
+
raise ValueError(f"cluster_labels must have length {n_targets}, got {len(cluster_labels)}")
|
|
54
|
+
if top_m < 1:
|
|
55
|
+
raise ValueError("top_m must be >= 1")
|
|
56
|
+
|
|
57
|
+
top_m_eff = min(top_m, n_features)
|
|
58
|
+
figs = []
|
|
59
|
+
axes = []
|
|
60
|
+
|
|
61
|
+
for t, cluster in enumerate(cluster_labels):
|
|
62
|
+
beta_all = betamat[t, :]
|
|
63
|
+
|
|
64
|
+
nonzero = np.flatnonzero(beta_all)
|
|
65
|
+
if nonzero.size > 0:
|
|
66
|
+
idx_rank = nonzero[np.argsort(np.abs(beta_all[nonzero]))[::-1]]
|
|
67
|
+
else:
|
|
68
|
+
idx_rank = np.argsort(np.abs(beta_all))[::-1]
|
|
69
|
+
|
|
70
|
+
feat_idx = idx_rank[:top_m_eff]
|
|
71
|
+
beta_hat = beta_all[feat_idx]
|
|
72
|
+
genes = [gene_names[j] for j in feat_idx]
|
|
73
|
+
|
|
74
|
+
order = np.argsort(np.abs(beta_hat))[::-1]
|
|
75
|
+
beta_hat = beta_hat[order]
|
|
76
|
+
genes = [genes[o] for o in order]
|
|
77
|
+
y_pos = np.arange(len(genes))
|
|
78
|
+
|
|
79
|
+
colors = np.where(beta_hat >= 0, pos_color, neg_color)
|
|
80
|
+
|
|
81
|
+
fig, ax = plt.subplots(figsize=(9.5, 0.55 * len(genes) + 1.6))
|
|
82
|
+
ax.hlines(y_pos, 0, beta_hat, color=colors, linewidth=4, alpha=0.25, zorder=1)
|
|
83
|
+
ax.scatter(beta_hat, y_pos, s=90, c=colors, edgecolors="white", linewidths=0.9, zorder=2)
|
|
84
|
+
ax.axvline(0, color="#9aa0a6", linestyle=(0, (3, 3)), linewidth=1.5, zorder=0)
|
|
85
|
+
|
|
86
|
+
ax.set_yticks(y_pos)
|
|
87
|
+
ax.set_yticklabels(genes, fontsize=10)
|
|
88
|
+
ax.set_xlabel("Boosting coefficient", fontsize=11)
|
|
89
|
+
ax.set_title(
|
|
90
|
+
f"Top {len(genes)} boosting coefficients (Cluster {cluster})", fontsize=12, pad=10
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
max_abs = float(np.max(np.abs(beta_hat))) if beta_hat.size else 1.0
|
|
94
|
+
pad = 0.15 * max_abs + 1e-9
|
|
95
|
+
ax.set_xlim(-(max_abs + pad), (max_abs + pad))
|
|
96
|
+
|
|
97
|
+
ax.grid(axis="y", visible=False)
|
|
98
|
+
for spine in ("top", "right"):
|
|
99
|
+
ax.spines[spine].set_visible(False)
|
|
100
|
+
|
|
101
|
+
for x, y, c in zip(beta_hat, y_pos, colors, strict=True):
|
|
102
|
+
ha = "left" if x >= 0 else "right"
|
|
103
|
+
ax.text(
|
|
104
|
+
x + (0.02 * (max_abs + pad) if x >= 0 else -0.02 * (max_abs + pad)),
|
|
105
|
+
y,
|
|
106
|
+
f"{x:.3f}",
|
|
107
|
+
va="center",
|
|
108
|
+
ha=ha,
|
|
109
|
+
fontsize=9,
|
|
110
|
+
color=c,
|
|
111
|
+
alpha=0.85,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
ax.invert_yaxis()
|
|
115
|
+
fig.tight_layout()
|
|
116
|
+
|
|
117
|
+
figs.append(fig)
|
|
118
|
+
axes.append(ax)
|
|
119
|
+
|
|
120
|
+
return figs, axes
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def plot_boosting_coefficient_paths(
|
|
124
|
+
beta_path: np.ndarray,
|
|
125
|
+
gene_names: Sequence[str],
|
|
126
|
+
cluster_labels: Sequence[str],
|
|
127
|
+
*,
|
|
128
|
+
top_m: int = 10,
|
|
129
|
+
linewidth: float = 1.6,
|
|
130
|
+
alpha: float = 0.9,
|
|
131
|
+
):
|
|
132
|
+
"""Plot coefficient trajectories across boosting steps (per target).
|
|
133
|
+
|
|
134
|
+
This visualizes how coefficients evolve over iterations. For each target, we
|
|
135
|
+
select the top-``|beta|`` features at the **final** step and plot their paths.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
beta_path
|
|
140
|
+
Array of shape (n_targets, n_steps, n_features), typically
|
|
141
|
+
`history.beta_path` from `allboost(..., return_history=True)`.
|
|
142
|
+
gene_names
|
|
143
|
+
Feature names of length n_features.
|
|
144
|
+
cluster_labels
|
|
145
|
+
Target labels of length n_targets.
|
|
146
|
+
top_m
|
|
147
|
+
Number of features to display per target (ranked by ``|beta|`` at final step).
|
|
148
|
+
linewidth
|
|
149
|
+
Line width for each feature trajectory.
|
|
150
|
+
alpha
|
|
151
|
+
Line alpha for each feature trajectory.
|
|
152
|
+
Returns
|
|
153
|
+
-------
|
|
154
|
+
figs, axes
|
|
155
|
+
Lists of matplotlib Figure/Axes objects, one per target.
|
|
156
|
+
"""
|
|
157
|
+
import matplotlib.pyplot as plt
|
|
158
|
+
|
|
159
|
+
beta_path = np.asarray(beta_path, dtype=float)
|
|
160
|
+
n_targets, n_steps, n_features = beta_path.shape
|
|
161
|
+
|
|
162
|
+
if len(gene_names) != n_features:
|
|
163
|
+
raise ValueError(f"gene_names must have length {n_features}, got {len(gene_names)}")
|
|
164
|
+
if len(cluster_labels) != n_targets:
|
|
165
|
+
raise ValueError(f"cluster_labels must have length {n_targets}, got {len(cluster_labels)}")
|
|
166
|
+
if top_m < 1:
|
|
167
|
+
raise ValueError("top_m must be >= 1")
|
|
168
|
+
|
|
169
|
+
steps = np.arange(1, n_steps + 1)
|
|
170
|
+
top_m_eff = min(top_m, n_features)
|
|
171
|
+
|
|
172
|
+
figs = []
|
|
173
|
+
axes = []
|
|
174
|
+
|
|
175
|
+
for t, cluster in enumerate(cluster_labels):
|
|
176
|
+
beta_final = beta_path[t, -1, :]
|
|
177
|
+
top_idx = np.argsort(np.abs(beta_final))[::-1][:top_m_eff]
|
|
178
|
+
|
|
179
|
+
fig, ax = plt.subplots(figsize=(10, 4))
|
|
180
|
+
for j in top_idx:
|
|
181
|
+
ax.plot(
|
|
182
|
+
steps, beta_path[t, :, j], label=gene_names[j], linewidth=linewidth, alpha=alpha
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
ax.axhline(0, color="black", linewidth=0.9, alpha=0.4)
|
|
186
|
+
ax.set_title(f"Coefficient paths (Cluster {cluster})")
|
|
187
|
+
ax.set_xlabel("Boosting step")
|
|
188
|
+
ax.set_ylabel("Coefficient")
|
|
189
|
+
ax.legend(ncols=2, fontsize=8, frameon=False)
|
|
190
|
+
fig.tight_layout()
|
|
191
|
+
|
|
192
|
+
figs.append(fig)
|
|
193
|
+
axes.append(ax)
|
|
194
|
+
|
|
195
|
+
return figs, axes
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def plot_training_diagnostics(
|
|
199
|
+
report,
|
|
200
|
+
*,
|
|
201
|
+
figsize: tuple[float, float] = (13.0, 9.0),
|
|
202
|
+
log_scale: bool = True,
|
|
203
|
+
):
|
|
204
|
+
"""Plot a :class:`~structboost.TrainingReport` as a six-panel diagnostic figure.
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
report
|
|
209
|
+
A :class:`~structboost.TrainingReport`, a fitted ``BAE`` (its
|
|
210
|
+
``training_report`` is used), or an ``AnnData`` carrying
|
|
211
|
+
``uns["bae"]["training_report"]``.
|
|
212
|
+
figsize
|
|
213
|
+
Figure size in inches.
|
|
214
|
+
log_scale
|
|
215
|
+
Use a log y-axis for the convergence and gradient panels, whose values
|
|
216
|
+
span orders of magnitude.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
matplotlib.figure.Figure
|
|
221
|
+
The figure. Panels are: reconstruction loss, per-iteration contribution
|
|
222
|
+
of the encoder and decoder halves, encoder convergence, selected genes,
|
|
223
|
+
gradient norms, and boosting fit quality.
|
|
224
|
+
|
|
225
|
+
Raises
|
|
226
|
+
------
|
|
227
|
+
ValueError
|
|
228
|
+
If no training report is available (``diagnostics=True`` was not set).
|
|
229
|
+
"""
|
|
230
|
+
import matplotlib.pyplot as plt
|
|
231
|
+
|
|
232
|
+
from ._types import TrainingReport
|
|
233
|
+
|
|
234
|
+
if not isinstance(report, TrainingReport):
|
|
235
|
+
candidate = getattr(report, "training_report", None)
|
|
236
|
+
if candidate is None:
|
|
237
|
+
uns = getattr(report, "uns", None)
|
|
238
|
+
stored = uns.get("bae", {}).get("training_report") if uns is not None else None
|
|
239
|
+
if stored is None:
|
|
240
|
+
raise ValueError(
|
|
241
|
+
"no training report available; fit with diagnostics=True to collect one"
|
|
242
|
+
)
|
|
243
|
+
candidate = TrainingReport.from_dict(stored)
|
|
244
|
+
report = candidate
|
|
245
|
+
|
|
246
|
+
it = report.iteration
|
|
247
|
+
fig, axes = plt.subplots(2, 3, figsize=figsize)
|
|
248
|
+
fig.suptitle("BAE training diagnostics", fontsize=13)
|
|
249
|
+
|
|
250
|
+
ax = axes[0, 0]
|
|
251
|
+
ax.plot(it, report.loss_post_decoder, lw=1.8, color="#1f77b4", label="full-data")
|
|
252
|
+
ax.plot(it, report.train_loss, lw=1.0, color="#aaaaaa", alpha=0.9, label="minibatch running")
|
|
253
|
+
ax.set_title("Reconstruction loss")
|
|
254
|
+
ax.set_xlabel("iteration")
|
|
255
|
+
ax.set_ylabel("MSE")
|
|
256
|
+
ax.legend(fontsize=8)
|
|
257
|
+
|
|
258
|
+
# The two halves of the alternating optimizer, as loss change per iteration.
|
|
259
|
+
ax = axes[0, 1]
|
|
260
|
+
ax.axhline(0.0, color="#666666", lw=0.8)
|
|
261
|
+
ax.plot(it, report.encoder_delta, lw=1.4, color="#d62728", label="encoder (boosting)")
|
|
262
|
+
ax.plot(it, report.decoder_delta, lw=1.4, color="#2ca02c", label="decoder (SGD)")
|
|
263
|
+
ax.set_title("Loss change per update\n(negative = improved)")
|
|
264
|
+
ax.set_xlabel("iteration")
|
|
265
|
+
ax.set_ylabel("Δ MSE")
|
|
266
|
+
ax.legend(fontsize=8)
|
|
267
|
+
|
|
268
|
+
ax = axes[0, 2]
|
|
269
|
+
ax.plot(it, report.weight_change_rel, lw=1.6, color="#9467bd", label="rel. weight change")
|
|
270
|
+
ax.plot(it, 1.0 - report.support_jaccard, lw=1.2, color="#ff7f0e", label="1 − support Jaccard")
|
|
271
|
+
if log_scale:
|
|
272
|
+
ax.set_yscale("log")
|
|
273
|
+
ax.set_title("Encoder convergence")
|
|
274
|
+
ax.set_xlabel("iteration")
|
|
275
|
+
ax.legend(fontsize=8)
|
|
276
|
+
|
|
277
|
+
ax = axes[1, 0]
|
|
278
|
+
ax.plot(it, report.n_selected, lw=1.6, color="#8c564b")
|
|
279
|
+
ax.set_title("Selected genes")
|
|
280
|
+
ax.set_xlabel("iteration")
|
|
281
|
+
ax.set_ylabel("n genes with nonzero weight")
|
|
282
|
+
|
|
283
|
+
ax = axes[1, 1]
|
|
284
|
+
ax.plot(it, report.target_grad_norm, lw=1.6, color="#e377c2", label="‖∂L/∂z‖ (targets)")
|
|
285
|
+
ax.plot(it, report.decoder_grad_norm, lw=1.2, color="#7f7f7f", label="‖∇ decoder‖")
|
|
286
|
+
if log_scale:
|
|
287
|
+
ax.set_yscale("log")
|
|
288
|
+
ax.set_title("Gradient norms")
|
|
289
|
+
ax.set_xlabel("iteration")
|
|
290
|
+
ax.legend(fontsize=8)
|
|
291
|
+
|
|
292
|
+
ax = axes[1, 2]
|
|
293
|
+
ax.plot(it, report.boosting_r2, lw=1.6, color="#17becf")
|
|
294
|
+
ax.set_title("Boosting fit to targets")
|
|
295
|
+
ax.set_xlabel("iteration")
|
|
296
|
+
ax.set_ylabel("R²")
|
|
297
|
+
|
|
298
|
+
for ax in axes.ravel():
|
|
299
|
+
ax.grid(alpha=0.25, lw=0.6)
|
|
300
|
+
fig.tight_layout()
|
|
301
|
+
return fig
|