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/_explorer.py
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
"""Interactive HTML explorer for BAE results.
|
|
2
|
+
|
|
3
|
+
Generates a self-contained HTML file with Plotly.js for interactive
|
|
4
|
+
exploration of UMAP embeddings, spatial tissue plots, gene expression
|
|
5
|
+
overlays, encoder coefficient bar charts, and dimension annotations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import warnings
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _subsample_adata(adata, *, max_cells: int, seed: int):
|
|
16
|
+
"""Subsample adata if it exceeds max_cells.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
adata
|
|
21
|
+
AnnData object.
|
|
22
|
+
max_cells
|
|
23
|
+
Maximum number of cells. If n_obs <= max_cells, returns adata unchanged.
|
|
24
|
+
seed
|
|
25
|
+
Random seed for reproducible subsampling.
|
|
26
|
+
|
|
27
|
+
Returns
|
|
28
|
+
-------
|
|
29
|
+
AnnData, possibly subsampled.
|
|
30
|
+
"""
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
if adata.n_obs <= max_cells:
|
|
34
|
+
return adata
|
|
35
|
+
|
|
36
|
+
warnings.warn(
|
|
37
|
+
f"Subsampling from {adata.n_obs} to {max_cells} cells for HTML explorer. "
|
|
38
|
+
f"Set max_cells to increase the limit.",
|
|
39
|
+
UserWarning,
|
|
40
|
+
stacklevel=3,
|
|
41
|
+
)
|
|
42
|
+
rng = np.random.default_rng(seed)
|
|
43
|
+
indices = rng.choice(adata.n_obs, size=max_cells, replace=False)
|
|
44
|
+
indices.sort()
|
|
45
|
+
return adata[indices].copy()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build_explorer_payload(
|
|
49
|
+
adata,
|
|
50
|
+
*,
|
|
51
|
+
model_key: str,
|
|
52
|
+
embedding_key: str,
|
|
53
|
+
spatial_key: str | None,
|
|
54
|
+
latent_key: str,
|
|
55
|
+
layers: list[str] | None,
|
|
56
|
+
obs_keys: list[str] | None,
|
|
57
|
+
top_k: int,
|
|
58
|
+
annotations_key: str | None,
|
|
59
|
+
) -> dict:
|
|
60
|
+
"""Extract all data needed for the interactive explorer.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
adata
|
|
65
|
+
AnnData object with fitted BAE results.
|
|
66
|
+
model_key
|
|
67
|
+
Key in adata.varm for encoder weights.
|
|
68
|
+
embedding_key
|
|
69
|
+
Key in adata.obsm for 2D embedding coordinates.
|
|
70
|
+
spatial_key
|
|
71
|
+
Key in adata.obsm for spatial coordinates, or None.
|
|
72
|
+
latent_key
|
|
73
|
+
Key in adata.obsm for latent representation.
|
|
74
|
+
layers
|
|
75
|
+
Layer names to include. None = adata.X + all adata.layers.
|
|
76
|
+
obs_keys
|
|
77
|
+
Categorical obs column names. None = auto-detect.
|
|
78
|
+
top_k
|
|
79
|
+
Max genes per sign group per dimension.
|
|
80
|
+
annotations_key
|
|
81
|
+
Key in adata.uns for dimension annotations, or None.
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
dict
|
|
86
|
+
JSON-serializable payload for the HTML template.
|
|
87
|
+
"""
|
|
88
|
+
import numpy as np
|
|
89
|
+
|
|
90
|
+
# --- Embedding coordinates ---
|
|
91
|
+
umap = np.asarray(adata.obsm[embedding_key], dtype=np.float64)
|
|
92
|
+
umap_list = umap.tolist()
|
|
93
|
+
|
|
94
|
+
# --- Spatial coordinates ---
|
|
95
|
+
spatial_list = None
|
|
96
|
+
if spatial_key is not None and spatial_key in adata.obsm:
|
|
97
|
+
spatial = np.asarray(adata.obsm[spatial_key], dtype=np.float64)
|
|
98
|
+
spatial_list = spatial.tolist()
|
|
99
|
+
|
|
100
|
+
# --- Latent representation ---
|
|
101
|
+
latent = np.asarray(adata.obsm[latent_key], dtype=np.float64)
|
|
102
|
+
latent_list = latent.tolist()
|
|
103
|
+
|
|
104
|
+
# --- Encoder weights and gene rankings per dimension ---
|
|
105
|
+
W = np.asarray(adata.varm[model_key], dtype=np.float64)
|
|
106
|
+
gene_names_all = list(adata.var_names)
|
|
107
|
+
_n_genes, latent_dim = W.shape
|
|
108
|
+
|
|
109
|
+
all_top_genes: set[str] = set()
|
|
110
|
+
dimensions_data: list[dict] = []
|
|
111
|
+
|
|
112
|
+
for dim_idx in range(latent_dim):
|
|
113
|
+
w = W[:, dim_idx]
|
|
114
|
+
|
|
115
|
+
# Positive genes
|
|
116
|
+
pos_mask = w > 0
|
|
117
|
+
pos_indices = np.where(pos_mask)[0]
|
|
118
|
+
pos_order = np.argsort(-w[pos_indices])
|
|
119
|
+
pos_indices = pos_indices[pos_order][:top_k]
|
|
120
|
+
pos_genes = [
|
|
121
|
+
{"name": gene_names_all[i], "weight": round(float(w[i]), 6)} for i in pos_indices
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
# Negative genes
|
|
125
|
+
neg_mask = w < 0
|
|
126
|
+
neg_indices = np.where(neg_mask)[0]
|
|
127
|
+
neg_order = np.argsort(w[neg_indices])
|
|
128
|
+
neg_indices = neg_indices[neg_order][:top_k]
|
|
129
|
+
neg_genes = [
|
|
130
|
+
{"name": gene_names_all[i], "weight": round(float(w[i]), 6)} for i in neg_indices
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
# Collect gene names for expression extraction
|
|
134
|
+
for g in pos_genes:
|
|
135
|
+
all_top_genes.add(g["name"])
|
|
136
|
+
for g in neg_genes:
|
|
137
|
+
all_top_genes.add(g["name"])
|
|
138
|
+
|
|
139
|
+
# Annotation for this dimension
|
|
140
|
+
annotation = None
|
|
141
|
+
if annotations_key and annotations_key in adata.uns:
|
|
142
|
+
ann_dict = adata.uns[annotations_key]
|
|
143
|
+
dim_key = str(dim_idx)
|
|
144
|
+
if dim_key in ann_dict:
|
|
145
|
+
a = ann_dict[dim_key]
|
|
146
|
+
annotation = {
|
|
147
|
+
"positive": a.get("positive_annotation", ""),
|
|
148
|
+
"negative": a.get("negative_annotation", ""),
|
|
149
|
+
"overall": a.get("overall_annotation", ""),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
dimensions_data.append(
|
|
153
|
+
{
|
|
154
|
+
"index": dim_idx,
|
|
155
|
+
"positive_genes": pos_genes,
|
|
156
|
+
"negative_genes": neg_genes,
|
|
157
|
+
"annotation": annotation,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# --- Expression data for top genes only ---
|
|
162
|
+
sorted_top_genes = sorted(all_top_genes)
|
|
163
|
+
gene_to_idx = {name: i for i, name in enumerate(gene_names_all)}
|
|
164
|
+
|
|
165
|
+
# Determine layers
|
|
166
|
+
layer_names: list[str] = []
|
|
167
|
+
if layers is not None:
|
|
168
|
+
layer_names = list(layers)
|
|
169
|
+
else:
|
|
170
|
+
# anndata >= 0.13 exposes ``.X`` as ``layers[None]``, so the mapping's
|
|
171
|
+
# keys include ``None``. Taking them verbatim would emit ``.X`` twice,
|
|
172
|
+
# once as "X" and again under a ``None`` key that `json.dumps` writes as
|
|
173
|
+
# "null" -- a duplicated matrix rather than a crash.
|
|
174
|
+
layer_names = ["X"] + [name for name in adata.layers if name is not None]
|
|
175
|
+
|
|
176
|
+
expression: dict[str, dict[str, list[float]]] = {}
|
|
177
|
+
for layer_name in layer_names:
|
|
178
|
+
layer_expr: dict[str, list[float]] = {}
|
|
179
|
+
matrix = adata.X if layer_name == "X" else adata.layers[layer_name]
|
|
180
|
+
for gene in sorted_top_genes:
|
|
181
|
+
idx = gene_to_idx[gene]
|
|
182
|
+
col = matrix[:, idx]
|
|
183
|
+
if hasattr(col, "toarray"):
|
|
184
|
+
col = col.toarray()
|
|
185
|
+
col = np.asarray(col, dtype=np.float64).ravel()
|
|
186
|
+
layer_expr[gene] = [round(float(v), 6) for v in col]
|
|
187
|
+
expression[layer_name] = layer_expr
|
|
188
|
+
|
|
189
|
+
# --- Obs columns ---
|
|
190
|
+
obs_data: dict[str, list[str]] = {}
|
|
191
|
+
if obs_keys is not None:
|
|
192
|
+
keys_to_use = obs_keys
|
|
193
|
+
else:
|
|
194
|
+
keys_to_use = [col for col in adata.obs.columns if hasattr(adata.obs[col], "cat")]
|
|
195
|
+
for key in keys_to_use:
|
|
196
|
+
obs_data[key] = [str(v) for v in adata.obs[key]]
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
"umap": umap_list,
|
|
200
|
+
"spatial": spatial_list,
|
|
201
|
+
"latent": latent_list,
|
|
202
|
+
"dimensions": dimensions_data,
|
|
203
|
+
"expression": expression,
|
|
204
|
+
"obs": obs_data,
|
|
205
|
+
"gene_names": sorted_top_genes,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _escape_html(text: str) -> str:
|
|
210
|
+
"""Escape HTML special characters."""
|
|
211
|
+
return (
|
|
212
|
+
text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _render_html(payload: dict, *, title: str) -> str:
|
|
217
|
+
"""Render the interactive HTML explorer from payload data.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
payload
|
|
222
|
+
Data payload from _build_explorer_payload.
|
|
223
|
+
title
|
|
224
|
+
HTML page title.
|
|
225
|
+
|
|
226
|
+
Returns
|
|
227
|
+
-------
|
|
228
|
+
str
|
|
229
|
+
Complete HTML document as a string.
|
|
230
|
+
"""
|
|
231
|
+
data_json = json.dumps(payload, separators=(",", ":"))
|
|
232
|
+
has_spatial = payload["spatial"] is not None
|
|
233
|
+
has_annotations = any(d["annotation"] is not None for d in payload["dimensions"])
|
|
234
|
+
|
|
235
|
+
# Build the spatial plot div and JS conditionally
|
|
236
|
+
spatial_div = ""
|
|
237
|
+
spatial_col_style = ""
|
|
238
|
+
if has_spatial:
|
|
239
|
+
spatial_div = '<div id="tissue-plot" style="width:100%;height:100%;"></div>'
|
|
240
|
+
spatial_col_style = "flex:1;min-width:300px;"
|
|
241
|
+
|
|
242
|
+
# Layout widths depend on spatial mode
|
|
243
|
+
left_col_style = "flex:1;min-width:350px;" if has_spatial else "flex:1.2;min-width:400px;"
|
|
244
|
+
|
|
245
|
+
html = f"""<!DOCTYPE html>
|
|
246
|
+
<html lang="en">
|
|
247
|
+
<head>
|
|
248
|
+
<meta charset="UTF-8">
|
|
249
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
250
|
+
<title>{_escape_html(title)}</title>
|
|
251
|
+
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
|
|
252
|
+
<style>
|
|
253
|
+
:root {{ --pos-color: #f2994a; --neg-color: #2b6cb0; }}
|
|
254
|
+
* {{ margin:0; padding:0; box-sizing:border-box; }}
|
|
255
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
256
|
+
background: #fafafa; color: #333; }}
|
|
257
|
+
h1 {{ padding: 12px 20px; background: #fff; border-bottom: 1px solid #ddd;
|
|
258
|
+
font-size: 1.3em; font-weight: 600; }}
|
|
259
|
+
.container {{ display: flex; flex-wrap: wrap; padding: 10px; gap: 10px;
|
|
260
|
+
height: calc(100vh - 52px); }}
|
|
261
|
+
.left-col {{ {left_col_style} display:flex; flex-direction:column; gap:10px; }}
|
|
262
|
+
.left-col > div {{ background:#fff; border:1px solid #ddd; border-radius:6px;
|
|
263
|
+
overflow:hidden; }}
|
|
264
|
+
.umap-box {{ flex:1; min-height:280px; }}
|
|
265
|
+
.coeff-box {{ flex:1; min-height:220px; }}
|
|
266
|
+
{
|
|
267
|
+
"".join(
|
|
268
|
+
[
|
|
269
|
+
".mid-col { "
|
|
270
|
+
+ spatial_col_style
|
|
271
|
+
+ " display:flex; flex-direction:column; gap:10px; }",
|
|
272
|
+
".mid-col > div { background:#fff; border:1px solid #ddd; border-radius:6px; overflow:hidden; flex:1; min-height:300px; }",
|
|
273
|
+
]
|
|
274
|
+
)
|
|
275
|
+
if has_spatial
|
|
276
|
+
else ""
|
|
277
|
+
}
|
|
278
|
+
.right-col {{ flex:0.7; min-width:280px; max-width:360px; display:flex;
|
|
279
|
+
flex-direction:column; gap:10px; }}
|
|
280
|
+
.controls {{ background:#fff; border:1px solid #ddd; border-radius:6px;
|
|
281
|
+
padding:14px; }}
|
|
282
|
+
.controls label {{ display:block; font-size:0.8em; font-weight:600;
|
|
283
|
+
color:#666; margin-bottom:3px; margin-top:10px; }}
|
|
284
|
+
.controls label:first-child {{ margin-top:0; }}
|
|
285
|
+
.controls select {{ width:100%; padding:6px 8px; border:1px solid #ccc;
|
|
286
|
+
border-radius:4px; font-size:0.9em; }}
|
|
287
|
+
.controls input[type="range"] {{ width:100%; accent-color:#1f77b4; }}
|
|
288
|
+
.controls .range-value {{ display:block; margin-top:2px; font-size:0.8em; color:#555; }}
|
|
289
|
+
.annotation-box {{ background:#fff; border:1px solid #ddd; border-radius:6px;
|
|
290
|
+
padding:14px; font-size:0.82em; line-height:1.5;
|
|
291
|
+
{"display:none;" if not has_annotations else ""} }}
|
|
292
|
+
.annotation-box h3 {{ font-size:0.85em; margin-bottom:6px; }}
|
|
293
|
+
.annotation-box .ann-label {{ font-weight:600; color:#555; }}
|
|
294
|
+
.gene-list {{ background:#fff; border:1px solid #ddd; border-radius:6px;
|
|
295
|
+
padding:10px; flex:1; overflow-y:auto; min-height:100px; }}
|
|
296
|
+
.gene-list h3 {{ font-size:0.85em; margin-bottom:8px; color:#444; }}
|
|
297
|
+
.gene-item {{ display:flex; justify-content:space-between; padding:3px 6px;
|
|
298
|
+
cursor:pointer; border-radius:3px; font-size:0.82em;
|
|
299
|
+
font-family:monospace; }}
|
|
300
|
+
.gene-item:hover {{ background:#e8f0fe; }}
|
|
301
|
+
.gene-item.selected {{ background:#c8ddf8; font-weight:bold; }}
|
|
302
|
+
.gene-pos {{ color: var(--pos-color); }}
|
|
303
|
+
.gene-neg {{ color: var(--neg-color); }}
|
|
304
|
+
</style>
|
|
305
|
+
</head>
|
|
306
|
+
<body>
|
|
307
|
+
<h1>{_escape_html(title)}</h1>
|
|
308
|
+
<div class="container">
|
|
309
|
+
<div class="left-col">
|
|
310
|
+
<div class="umap-box"><div id="umap-plot" style="width:100%;height:100%;"></div></div>
|
|
311
|
+
<div class="coeff-box"><div id="coeff-plot" style="width:100%;height:100%;"></div></div>
|
|
312
|
+
</div>
|
|
313
|
+
{"<div class='mid-col'><div>" + spatial_div + "</div></div>" if has_spatial else ""}
|
|
314
|
+
<div class="right-col">
|
|
315
|
+
<div class="controls">
|
|
316
|
+
<label for="color-select">Color by</label>
|
|
317
|
+
<select id="color-select"></select>
|
|
318
|
+
<label for="obs-select">Obs column</label>
|
|
319
|
+
<select id="obs-select"></select>
|
|
320
|
+
<label for="dim-select">Dimension</label>
|
|
321
|
+
<select id="dim-select"></select>
|
|
322
|
+
<label for="layer-select">Expression layer</label>
|
|
323
|
+
<select id="layer-select"></select>
|
|
324
|
+
<label for="colorscale-select">Continuous colors</label>
|
|
325
|
+
<select id="colorscale-select">
|
|
326
|
+
<option value="blueorange" selected>Blue-Orange</option>
|
|
327
|
+
<option value="redblue">Red-Blue</option>
|
|
328
|
+
<option value="viridis">Viridis</option>
|
|
329
|
+
</select>
|
|
330
|
+
<label for="point-size">Dot size</label>
|
|
331
|
+
<input id="point-size" type="range" min="1" max="12" step="0.5" value="3">
|
|
332
|
+
<span class="range-value" id="point-size-value">3.0</span>
|
|
333
|
+
</div>
|
|
334
|
+
<div class="annotation-box" id="annotation-box">
|
|
335
|
+
<h3>Dimension Annotation</h3>
|
|
336
|
+
<div id="annotation-content"></div>
|
|
337
|
+
</div>
|
|
338
|
+
<div class="gene-list" id="gene-list-box">
|
|
339
|
+
<h3>Top Genes</h3>
|
|
340
|
+
<div id="gene-list"></div>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
<script>
|
|
345
|
+
const DATA = {data_json};
|
|
346
|
+
const HAS_SPATIAL = {"true" if has_spatial else "false"};
|
|
347
|
+
const OBS_KEYS = Object.keys(DATA.obs);
|
|
348
|
+
const LAYER_KEYS = Object.keys(DATA.expression);
|
|
349
|
+
const HAS_OBS_OPTIONS = OBS_KEYS.length > 0;
|
|
350
|
+
const HAS_LAYER_OPTIONS = LAYER_KEYS.length > 0;
|
|
351
|
+
|
|
352
|
+
// --- Initialize controls ---
|
|
353
|
+
const colorSel = document.getElementById("color-select");
|
|
354
|
+
const obsSel = document.getElementById("obs-select");
|
|
355
|
+
const dimSel = document.getElementById("dim-select");
|
|
356
|
+
const layerSel = document.getElementById("layer-select");
|
|
357
|
+
const colorscaleSel = document.getElementById("colorscale-select");
|
|
358
|
+
const pointSizeInput = document.getElementById("point-size");
|
|
359
|
+
const pointSizeValue = document.getElementById("point-size-value");
|
|
360
|
+
|
|
361
|
+
// Color-by options: latent dims, then obs selector mode, then gene expression
|
|
362
|
+
DATA.dimensions.forEach(d => {{
|
|
363
|
+
const o = document.createElement("option");
|
|
364
|
+
o.value = "latent_" + d.index;
|
|
365
|
+
o.textContent = "Latent dim " + d.index;
|
|
366
|
+
colorSel.appendChild(o);
|
|
367
|
+
}});
|
|
368
|
+
if (HAS_OBS_OPTIONS) {{
|
|
369
|
+
const o = document.createElement("option");
|
|
370
|
+
o.value = "obs";
|
|
371
|
+
o.textContent = "Obs column";
|
|
372
|
+
colorSel.appendChild(o);
|
|
373
|
+
}}
|
|
374
|
+
if (HAS_LAYER_OPTIONS) {{
|
|
375
|
+
const o = document.createElement("option");
|
|
376
|
+
o.value = "gene";
|
|
377
|
+
o.textContent = "Gene expression";
|
|
378
|
+
colorSel.appendChild(o);
|
|
379
|
+
}}
|
|
380
|
+
|
|
381
|
+
// Obs dropdown
|
|
382
|
+
if (HAS_OBS_OPTIONS) {{
|
|
383
|
+
OBS_KEYS.forEach(k => {{
|
|
384
|
+
const o = document.createElement("option");
|
|
385
|
+
o.value = k;
|
|
386
|
+
o.textContent = k;
|
|
387
|
+
obsSel.appendChild(o);
|
|
388
|
+
}});
|
|
389
|
+
}} else {{
|
|
390
|
+
const o = document.createElement("option");
|
|
391
|
+
o.value = "";
|
|
392
|
+
o.textContent = "No obs columns available";
|
|
393
|
+
obsSel.appendChild(o);
|
|
394
|
+
}}
|
|
395
|
+
|
|
396
|
+
// Dimension dropdown
|
|
397
|
+
DATA.dimensions.forEach(d => {{
|
|
398
|
+
const o = document.createElement("option");
|
|
399
|
+
o.value = d.index;
|
|
400
|
+
o.textContent = "Dimension " + d.index;
|
|
401
|
+
dimSel.appendChild(o);
|
|
402
|
+
}});
|
|
403
|
+
|
|
404
|
+
// Layer dropdown
|
|
405
|
+
if (HAS_LAYER_OPTIONS) {{
|
|
406
|
+
LAYER_KEYS.forEach(k => {{
|
|
407
|
+
const o = document.createElement("option");
|
|
408
|
+
o.value = k;
|
|
409
|
+
o.textContent = k;
|
|
410
|
+
layerSel.appendChild(o);
|
|
411
|
+
}});
|
|
412
|
+
}} else {{
|
|
413
|
+
const o = document.createElement("option");
|
|
414
|
+
o.value = "";
|
|
415
|
+
o.textContent = "No layers available";
|
|
416
|
+
layerSel.appendChild(o);
|
|
417
|
+
}}
|
|
418
|
+
|
|
419
|
+
// --- State ---
|
|
420
|
+
let currentGene = null;
|
|
421
|
+
let currentDim = 0;
|
|
422
|
+
let currentPointSize = parseFloat(pointSizeInput.value);
|
|
423
|
+
|
|
424
|
+
// --- Plotting ---
|
|
425
|
+
const COLOR_SCALES = {{
|
|
426
|
+
blueorange: [
|
|
427
|
+
[0.0, "#2b6cb0"],
|
|
428
|
+
[0.5, "#f7f7f7"],
|
|
429
|
+
[1.0, "#f2994a"],
|
|
430
|
+
],
|
|
431
|
+
redblue: [
|
|
432
|
+
[0.0, "#2166ac"],
|
|
433
|
+
[0.5, "#f7f7f7"],
|
|
434
|
+
[1.0, "#b2182b"],
|
|
435
|
+
],
|
|
436
|
+
viridis: "Viridis",
|
|
437
|
+
}};
|
|
438
|
+
const SIGN_COLORS = {{
|
|
439
|
+
blueorange: {{ neg: "#2b6cb0", pos: "#f2994a" }},
|
|
440
|
+
redblue: {{ neg: "#2166ac", pos: "#b2182b" }},
|
|
441
|
+
viridis: {{ neg: "#440154", pos: "#fde725" }},
|
|
442
|
+
}};
|
|
443
|
+
|
|
444
|
+
function getActiveColorscale() {{
|
|
445
|
+
return COLOR_SCALES[colorscaleSel.value] || COLOR_SCALES.blueorange;
|
|
446
|
+
}}
|
|
447
|
+
|
|
448
|
+
function updateGeneSignColors() {{
|
|
449
|
+
const palette = SIGN_COLORS[colorscaleSel.value] || SIGN_COLORS.blueorange;
|
|
450
|
+
document.documentElement.style.setProperty("--pos-color", palette.pos);
|
|
451
|
+
document.documentElement.style.setProperty("--neg-color", palette.neg);
|
|
452
|
+
}}
|
|
453
|
+
|
|
454
|
+
function getColorRange(values) {{
|
|
455
|
+
const nums = values.filter(v => Number.isFinite(v));
|
|
456
|
+
if (!nums.length) return [0, 1];
|
|
457
|
+
|
|
458
|
+
const vmin = Math.min(...nums);
|
|
459
|
+
const vmax = Math.max(...nums);
|
|
460
|
+
if (colorscaleSel.value === "viridis") {{
|
|
461
|
+
if (vmin === vmax) return [vmin - 1, vmax + 1];
|
|
462
|
+
return [vmin, vmax];
|
|
463
|
+
}}
|
|
464
|
+
|
|
465
|
+
const absMax = Math.max(Math.abs(vmin), Math.abs(vmax), 1e-9);
|
|
466
|
+
return [-absMax, absMax];
|
|
467
|
+
}}
|
|
468
|
+
|
|
469
|
+
function makeScatter(divId, coords, color, showscale, isDiscrete, cmin, cmax) {{
|
|
470
|
+
const trace = {{
|
|
471
|
+
x: coords.map(c => c[0]),
|
|
472
|
+
y: coords.map(c => c[1]),
|
|
473
|
+
mode: "markers",
|
|
474
|
+
type: "scattergl",
|
|
475
|
+
marker: {{
|
|
476
|
+
size: currentPointSize,
|
|
477
|
+
showscale: showscale && !isDiscrete,
|
|
478
|
+
}},
|
|
479
|
+
hovertemplate: "%{{x:.2f}}, %{{y:.2f}}<br>%{{text}}<extra></extra>",
|
|
480
|
+
}};
|
|
481
|
+
if (isDiscrete) {{
|
|
482
|
+
trace.marker.color = color.map(v => _categoryColor(v));
|
|
483
|
+
trace.text = color;
|
|
484
|
+
}} else {{
|
|
485
|
+
trace.marker.color = color;
|
|
486
|
+
trace.marker.colorscale = getActiveColorscale();
|
|
487
|
+
trace.marker.cmin = cmin;
|
|
488
|
+
trace.marker.cmax = cmax;
|
|
489
|
+
trace.text = color.map(v => typeof v === "number" ? v.toFixed(3) : v);
|
|
490
|
+
}}
|
|
491
|
+
const layout = {{
|
|
492
|
+
margin: {{ t: 8, b: 30, l: 35, r: 10 }},
|
|
493
|
+
xaxis: {{ zeroline: false, showgrid: false }},
|
|
494
|
+
yaxis: {{
|
|
495
|
+
zeroline: false,
|
|
496
|
+
showgrid: false,
|
|
497
|
+
scaleanchor: divId === "tissue-plot" ? "x" : undefined,
|
|
498
|
+
}},
|
|
499
|
+
dragmode: "pan",
|
|
500
|
+
}};
|
|
501
|
+
Plotly.react(divId, [trace], layout, {{ responsive: true, scrollZoom: true }});
|
|
502
|
+
}}
|
|
503
|
+
|
|
504
|
+
function makeCoeffBar(dimIdx) {{
|
|
505
|
+
const dim = DATA.dimensions[dimIdx];
|
|
506
|
+
const genes = [...dim.positive_genes, ...dim.negative_genes];
|
|
507
|
+
const names = genes.map(g => g.name);
|
|
508
|
+
const weights = genes.map(g => g.weight);
|
|
509
|
+
const [cmin, cmax] = getColorRange(weights);
|
|
510
|
+
|
|
511
|
+
const trace = {{
|
|
512
|
+
y: names,
|
|
513
|
+
x: weights,
|
|
514
|
+
type: "bar",
|
|
515
|
+
orientation: "h",
|
|
516
|
+
marker: {{
|
|
517
|
+
color: weights,
|
|
518
|
+
colorscale: getActiveColorscale(),
|
|
519
|
+
cmin: cmin,
|
|
520
|
+
cmax: cmax,
|
|
521
|
+
}},
|
|
522
|
+
hovertemplate: "%{{y}}: %{{x:.4f}}<extra></extra>",
|
|
523
|
+
}};
|
|
524
|
+
const layout = {{
|
|
525
|
+
margin: {{ t: 8, b: 30, l: 80, r: 10 }},
|
|
526
|
+
xaxis: {{ title: "Encoder weight", zeroline: true }},
|
|
527
|
+
yaxis: {{ autorange: "reversed" }},
|
|
528
|
+
dragmode: false,
|
|
529
|
+
}};
|
|
530
|
+
Plotly.react("coeff-plot", [trace], layout, {{ responsive: true }});
|
|
531
|
+
|
|
532
|
+
// Click handler on bars
|
|
533
|
+
document.getElementById("coeff-plot").removeAllListeners?.("plotly_click");
|
|
534
|
+
document.getElementById("coeff-plot").on("plotly_click", function(data) {{
|
|
535
|
+
const geneName = data.points[0].y;
|
|
536
|
+
selectGene(geneName);
|
|
537
|
+
}});
|
|
538
|
+
}}
|
|
539
|
+
|
|
540
|
+
// Category colors (up to 20 distinct)
|
|
541
|
+
const CAT_COLORS = [
|
|
542
|
+
"#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd",
|
|
543
|
+
"#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf",
|
|
544
|
+
"#aec7e8","#ffbb78","#98df8a","#ff9896","#c5b0d5",
|
|
545
|
+
"#c49c94","#f7b6d2","#c7c7c7","#dbdb8d","#9edae5",
|
|
546
|
+
];
|
|
547
|
+
const _catMap = {{}};
|
|
548
|
+
let _catIdx = 0;
|
|
549
|
+
function _categoryColor(val) {{
|
|
550
|
+
if (!(val in _catMap)) {{
|
|
551
|
+
_catMap[val] = CAT_COLORS[_catIdx % CAT_COLORS.length];
|
|
552
|
+
_catIdx++;
|
|
553
|
+
}}
|
|
554
|
+
return _catMap[val];
|
|
555
|
+
}}
|
|
556
|
+
|
|
557
|
+
function resetCatColors() {{
|
|
558
|
+
for (const k in _catMap) delete _catMap[k];
|
|
559
|
+
_catIdx = 0;
|
|
560
|
+
}}
|
|
561
|
+
|
|
562
|
+
function updateControlStates() {{
|
|
563
|
+
const isObsMode = colorSel.value === "obs";
|
|
564
|
+
const isGeneMode = colorSel.value === "gene";
|
|
565
|
+
obsSel.disabled = !HAS_OBS_OPTIONS || !isObsMode;
|
|
566
|
+
layerSel.disabled = !HAS_LAYER_OPTIONS || !isGeneMode;
|
|
567
|
+
}}
|
|
568
|
+
|
|
569
|
+
// --- Update functions ---
|
|
570
|
+
function updatePlots() {{
|
|
571
|
+
const colorVal = colorSel.value;
|
|
572
|
+
let color, isDiscrete = false;
|
|
573
|
+
let cmin;
|
|
574
|
+
let cmax;
|
|
575
|
+
|
|
576
|
+
if (colorVal.startsWith("latent_")) {{
|
|
577
|
+
const idx = parseInt(colorVal.split("_")[1]);
|
|
578
|
+
color = DATA.latent.map(row => row[idx]);
|
|
579
|
+
[cmin, cmax] = getColorRange(color);
|
|
580
|
+
}} else if (colorVal === "obs" && HAS_OBS_OPTIONS) {{
|
|
581
|
+
const key = obsSel.value || OBS_KEYS[0];
|
|
582
|
+
color = DATA.obs[key];
|
|
583
|
+
isDiscrete = true;
|
|
584
|
+
resetCatColors();
|
|
585
|
+
}} else if (colorVal.startsWith("obs_")) {{
|
|
586
|
+
const key = colorVal.substring(4);
|
|
587
|
+
if (key in DATA.obs) {{
|
|
588
|
+
color = DATA.obs[key];
|
|
589
|
+
isDiscrete = true;
|
|
590
|
+
resetCatColors();
|
|
591
|
+
}} else {{
|
|
592
|
+
color = DATA.latent.map(row => row[0]);
|
|
593
|
+
[cmin, cmax] = getColorRange(color);
|
|
594
|
+
}}
|
|
595
|
+
}} else if (colorVal === "gene" && currentGene) {{
|
|
596
|
+
const layer = layerSel.value || LAYER_KEYS[0];
|
|
597
|
+
const layerExpr = DATA.expression[layer];
|
|
598
|
+
if (layerExpr && currentGene in layerExpr) {{
|
|
599
|
+
color = layerExpr[currentGene];
|
|
600
|
+
}} else {{
|
|
601
|
+
color = DATA.latent.map(row => row[0]);
|
|
602
|
+
[cmin, cmax] = getColorRange(color);
|
|
603
|
+
}}
|
|
604
|
+
}} else {{
|
|
605
|
+
// Default: first latent dim
|
|
606
|
+
color = DATA.latent.map(row => row[0]);
|
|
607
|
+
[cmin, cmax] = getColorRange(color);
|
|
608
|
+
}}
|
|
609
|
+
|
|
610
|
+
if (!isDiscrete && (cmin === undefined || cmax === undefined)) {{
|
|
611
|
+
[cmin, cmax] = getColorRange(color);
|
|
612
|
+
}}
|
|
613
|
+
|
|
614
|
+
makeScatter("umap-plot", DATA.umap, color, true, isDiscrete, cmin, cmax);
|
|
615
|
+
if (HAS_SPATIAL) {{
|
|
616
|
+
makeScatter("tissue-plot", DATA.spatial, color, false, isDiscrete, cmin, cmax);
|
|
617
|
+
}}
|
|
618
|
+
}}
|
|
619
|
+
|
|
620
|
+
function updateDimension() {{
|
|
621
|
+
currentDim = parseInt(dimSel.value);
|
|
622
|
+
makeCoeffBar(currentDim);
|
|
623
|
+
updateGeneList();
|
|
624
|
+
updateAnnotation();
|
|
625
|
+
|
|
626
|
+
// If in gene expression mode, select first gene automatically
|
|
627
|
+
if (colorSel.value === "gene" && HAS_LAYER_OPTIONS) {{
|
|
628
|
+
const dim = DATA.dimensions[currentDim];
|
|
629
|
+
const firstGene = dim.positive_genes.length > 0
|
|
630
|
+
? dim.positive_genes[0].name
|
|
631
|
+
: (dim.negative_genes.length > 0 ? dim.negative_genes[0].name : null);
|
|
632
|
+
if (firstGene) selectGene(firstGene);
|
|
633
|
+
}}
|
|
634
|
+
}}
|
|
635
|
+
|
|
636
|
+
function updateGeneList() {{
|
|
637
|
+
const dim = DATA.dimensions[currentDim];
|
|
638
|
+
const container = document.getElementById("gene-list");
|
|
639
|
+
container.innerHTML = "";
|
|
640
|
+
|
|
641
|
+
dim.positive_genes.forEach(g => {{
|
|
642
|
+
const div = document.createElement("div");
|
|
643
|
+
div.className = "gene-item" + (g.name === currentGene ? " selected" : "");
|
|
644
|
+
div.innerHTML = '<span class="gene-pos">+ ' + g.name + '</span><span>' + g.weight.toFixed(4) + '</span>';
|
|
645
|
+
div.onclick = () => selectGene(g.name);
|
|
646
|
+
container.appendChild(div);
|
|
647
|
+
}});
|
|
648
|
+
dim.negative_genes.forEach(g => {{
|
|
649
|
+
const div = document.createElement("div");
|
|
650
|
+
div.className = "gene-item" + (g.name === currentGene ? " selected" : "");
|
|
651
|
+
div.innerHTML = '<span class="gene-neg">\u2212 ' + g.name + '</span><span>' + g.weight.toFixed(4) + '</span>';
|
|
652
|
+
div.onclick = () => selectGene(g.name);
|
|
653
|
+
container.appendChild(div);
|
|
654
|
+
}});
|
|
655
|
+
}}
|
|
656
|
+
|
|
657
|
+
function updateAnnotation() {{
|
|
658
|
+
const dim = DATA.dimensions[currentDim];
|
|
659
|
+
const box = document.getElementById("annotation-box");
|
|
660
|
+
const content = document.getElementById("annotation-content");
|
|
661
|
+
if (dim.annotation) {{
|
|
662
|
+
box.style.display = "block";
|
|
663
|
+
content.innerHTML =
|
|
664
|
+
'<p><span class="ann-label">Positive:</span> ' + dim.annotation.positive + '</p>' +
|
|
665
|
+
'<p><span class="ann-label">Negative:</span> ' + dim.annotation.negative + '</p>' +
|
|
666
|
+
'<p><span class="ann-label">Overall:</span> ' + dim.annotation.overall + '</p>';
|
|
667
|
+
}} else {{
|
|
668
|
+
box.style.display = "none";
|
|
669
|
+
content.innerHTML = "";
|
|
670
|
+
}}
|
|
671
|
+
}}
|
|
672
|
+
|
|
673
|
+
function selectGene(geneName) {{
|
|
674
|
+
if (!HAS_LAYER_OPTIONS) return;
|
|
675
|
+
currentGene = geneName;
|
|
676
|
+
colorSel.value = "gene";
|
|
677
|
+
updateControlStates();
|
|
678
|
+
updatePlots();
|
|
679
|
+
updateGeneList();
|
|
680
|
+
}}
|
|
681
|
+
|
|
682
|
+
// --- Event listeners ---
|
|
683
|
+
colorSel.addEventListener("change", () => {{
|
|
684
|
+
updateControlStates();
|
|
685
|
+
updatePlots();
|
|
686
|
+
}});
|
|
687
|
+
obsSel.addEventListener("change", () => {{
|
|
688
|
+
if (colorSel.value === "obs") updatePlots();
|
|
689
|
+
}});
|
|
690
|
+
dimSel.addEventListener("change", updateDimension);
|
|
691
|
+
layerSel.addEventListener("change", () => {{
|
|
692
|
+
if (colorSel.value === "gene" && currentGene && HAS_LAYER_OPTIONS) updatePlots();
|
|
693
|
+
}});
|
|
694
|
+
colorscaleSel.addEventListener("change", () => {{
|
|
695
|
+
updateGeneSignColors();
|
|
696
|
+
updatePlots();
|
|
697
|
+
makeCoeffBar(currentDim);
|
|
698
|
+
}});
|
|
699
|
+
pointSizeInput.addEventListener("input", () => {{
|
|
700
|
+
currentPointSize = parseFloat(pointSizeInput.value);
|
|
701
|
+
pointSizeValue.textContent = currentPointSize.toFixed(1);
|
|
702
|
+
updatePlots();
|
|
703
|
+
}});
|
|
704
|
+
|
|
705
|
+
// --- Initialize ---
|
|
706
|
+
updateGeneSignColors();
|
|
707
|
+
updateControlStates();
|
|
708
|
+
pointSizeValue.textContent = currentPointSize.toFixed(1);
|
|
709
|
+
updateDimension();
|
|
710
|
+
updatePlots();
|
|
711
|
+
</script>
|
|
712
|
+
</body>
|
|
713
|
+
</html>"""
|
|
714
|
+
return html
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def export_interactive_html(
|
|
718
|
+
adata,
|
|
719
|
+
output_path: str | Path,
|
|
720
|
+
*,
|
|
721
|
+
model_key: str = "BAE_encoder_weights",
|
|
722
|
+
embedding_key: str = "X_umap",
|
|
723
|
+
spatial_key: str | None = "spatial",
|
|
724
|
+
latent_key: str | None = None,
|
|
725
|
+
layers: list[str] | None = None,
|
|
726
|
+
obs_keys: list[str] | None = None,
|
|
727
|
+
top_k: int = 20,
|
|
728
|
+
max_cells: int = 50_000,
|
|
729
|
+
annotations_key: str | None = None,
|
|
730
|
+
title: str = "BAE Explorer",
|
|
731
|
+
seed: int = 42,
|
|
732
|
+
) -> Path:
|
|
733
|
+
"""Export interactive HTML explorer for BAE results.
|
|
734
|
+
|
|
735
|
+
Generates a self-contained HTML file with Plotly.js for interactive
|
|
736
|
+
exploration of UMAP embeddings, gene expression overlays, encoder
|
|
737
|
+
coefficient bar charts, and (optionally) spatial tissue plots and
|
|
738
|
+
dimension annotations.
|
|
739
|
+
|
|
740
|
+
Parameters
|
|
741
|
+
----------
|
|
742
|
+
adata
|
|
743
|
+
AnnData object with fitted BAE results. Must contain
|
|
744
|
+
encoder weights in ``adata.varm[model_key]`` and a 2D embedding
|
|
745
|
+
in ``adata.obsm[embedding_key]``.
|
|
746
|
+
output_path
|
|
747
|
+
Path where the HTML file will be written.
|
|
748
|
+
model_key
|
|
749
|
+
Key in ``adata.varm`` for encoder weight matrix.
|
|
750
|
+
embedding_key
|
|
751
|
+
Key in ``adata.obsm`` for 2D embedding (e.g. UMAP). Must be
|
|
752
|
+
pre-computed.
|
|
753
|
+
spatial_key
|
|
754
|
+
Key in ``adata.obsm`` for spatial coordinates. Set to ``None``
|
|
755
|
+
to disable the spatial tissue plot. If the key is not found in
|
|
756
|
+
obsm, the spatial panel is silently omitted.
|
|
757
|
+
latent_key
|
|
758
|
+
Key in ``adata.obsm`` for latent representation. If ``None``,
|
|
759
|
+
defaults to ``"X_bae"``.
|
|
760
|
+
layers
|
|
761
|
+
Which expression layers to include. ``None`` includes
|
|
762
|
+
``adata.X`` (as ``"X"``) plus all keys in ``adata.layers``.
|
|
763
|
+
obs_keys
|
|
764
|
+
Categorical obs columns to include as color-by options. ``None``
|
|
765
|
+
auto-detects all categorical columns.
|
|
766
|
+
top_k
|
|
767
|
+
Number of top genes per sign group (positive/negative) per
|
|
768
|
+
latent dimension.
|
|
769
|
+
max_cells
|
|
770
|
+
If ``adata.n_obs`` exceeds this, randomly subsample with a
|
|
771
|
+
warning.
|
|
772
|
+
annotations_key
|
|
773
|
+
Key in ``adata.uns`` for dimension annotations. If ``None``,
|
|
774
|
+
auto-detected as ``"bae_dimension_annotations"``.
|
|
775
|
+
title
|
|
776
|
+
HTML page title.
|
|
777
|
+
seed
|
|
778
|
+
Random seed for reproducible subsampling.
|
|
779
|
+
|
|
780
|
+
Returns
|
|
781
|
+
-------
|
|
782
|
+
Path
|
|
783
|
+
The output file path.
|
|
784
|
+
|
|
785
|
+
Raises
|
|
786
|
+
------
|
|
787
|
+
KeyError
|
|
788
|
+
If ``embedding_key`` or ``model_key`` are not found.
|
|
789
|
+
"""
|
|
790
|
+
output_path = Path(output_path)
|
|
791
|
+
|
|
792
|
+
# --- Validate required keys ---
|
|
793
|
+
if embedding_key not in adata.obsm:
|
|
794
|
+
raise KeyError(
|
|
795
|
+
f"{embedding_key!r} not found in adata.obsm. Available keys: {list(adata.obsm.keys())}"
|
|
796
|
+
)
|
|
797
|
+
if model_key not in adata.varm:
|
|
798
|
+
raise KeyError(
|
|
799
|
+
f"{model_key!r} not found in adata.varm. Available keys: {list(adata.varm.keys())}"
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
# --- Auto-detect latent_key ---
|
|
803
|
+
if latent_key is None:
|
|
804
|
+
latent_key = "X_bae"
|
|
805
|
+
if latent_key not in adata.obsm:
|
|
806
|
+
raise KeyError(
|
|
807
|
+
f"Auto-detected latent_key {latent_key!r} not found in adata.obsm. "
|
|
808
|
+
f"Available keys: {list(adata.obsm.keys())}"
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
# --- Auto-detect spatial_key ---
|
|
812
|
+
if spatial_key is not None and spatial_key not in adata.obsm:
|
|
813
|
+
spatial_key = None
|
|
814
|
+
|
|
815
|
+
# --- Auto-detect annotations_key ---
|
|
816
|
+
if annotations_key is None:
|
|
817
|
+
candidate = "bae_dimension_annotations"
|
|
818
|
+
if candidate in adata.uns:
|
|
819
|
+
annotations_key = candidate
|
|
820
|
+
|
|
821
|
+
# --- Subsample ---
|
|
822
|
+
adata = _subsample_adata(adata, max_cells=max_cells, seed=seed)
|
|
823
|
+
|
|
824
|
+
# --- Build payload and render ---
|
|
825
|
+
payload = _build_explorer_payload(
|
|
826
|
+
adata,
|
|
827
|
+
model_key=model_key,
|
|
828
|
+
embedding_key=embedding_key,
|
|
829
|
+
spatial_key=spatial_key,
|
|
830
|
+
latent_key=latent_key,
|
|
831
|
+
layers=layers,
|
|
832
|
+
obs_keys=obs_keys,
|
|
833
|
+
top_k=top_k,
|
|
834
|
+
annotations_key=annotations_key,
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
html = _render_html(payload, title=title)
|
|
838
|
+
|
|
839
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
840
|
+
output_path.write_text(html, encoding="utf-8")
|
|
841
|
+
|
|
842
|
+
return output_path
|