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.
@@ -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"
@@ -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