sachin1801 commited on
Commit
2fc42e1
·
1 Parent(s): 921262c

pyshiny visualizations added on results page

Browse files
webapp/app/api/routes.py CHANGED
@@ -15,6 +15,7 @@ from sqlalchemy import text, and_, or_
15
  from webapp.app.database import get_db
16
  from webapp.app.models.job import Job
17
  from webapp.app.services.predictor import get_predictor, SplicingPredictor
 
18
  from webapp.app.config import settings
19
  from webapp.app.api.schemas import (
20
  SequenceInput,
@@ -388,6 +389,45 @@ async def get_heatmap_data(
388
  raise HTTPException(status_code=500, detail=f"Error generating heatmap data: {str(e)}")
389
 
390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  @router.get("/example", response_model=ExampleSequencesResponse, tags=["examples"])
392
  async def get_example_sequences():
393
  """
 
15
  from webapp.app.database import get_db
16
  from webapp.app.models.job import Job
17
  from webapp.app.services.predictor import get_predictor, SplicingPredictor
18
+ from webapp.app.services.vis_data import get_vis_data
19
  from webapp.app.config import settings
20
  from webapp.app.api.schemas import (
21
  SequenceInput,
 
389
  raise HTTPException(status_code=500, detail=f"Error generating heatmap data: {str(e)}")
390
 
391
 
392
+ @router.get("/vis_data/{job_id}", tags=["visualization"])
393
+ async def get_vis_data_endpoint(
394
+ job_id: str,
395
+ batch_index: Optional[int] = Query(None, description="Index of sequence in batch job (0-based)"),
396
+ db: Session = Depends(get_db),
397
+ ):
398
+ """
399
+ Get hierarchical visualization data for silhouette and heatmap views.
400
+
401
+ Returns collapsed filter activations organized both by feature and by position.
402
+ This data powers the silhouette view and the new heatmap with diverging colors.
403
+ """
404
+ job = db.query(Job).filter(Job.id == job_id).first()
405
+ if not job:
406
+ raise HTTPException(status_code=404, detail="Job not found")
407
+
408
+ if job.status != "finished":
409
+ raise HTTPException(status_code=400, detail="Job not yet complete")
410
+
411
+ # Get the appropriate sequence
412
+ if job.is_batch and batch_index is not None:
413
+ # Get specific sequence from batch
414
+ results = job.get_batch_results()
415
+ if batch_index >= len(results):
416
+ raise HTTPException(status_code=404, detail=f"Batch index {batch_index} not found")
417
+ sequence = results[batch_index].get("sequence", "")
418
+ if not sequence:
419
+ raise HTTPException(status_code=400, detail="Sequence not found in batch results")
420
+ else:
421
+ # Use the main sequence (or first sequence for batch)
422
+ sequence = job.sequence
423
+
424
+ try:
425
+ vis_data = get_vis_data(sequence)
426
+ return vis_data
427
+ except Exception as e:
428
+ raise HTTPException(status_code=500, detail=f"Error generating visualization data: {str(e)}")
429
+
430
+
431
  @router.get("/example", response_model=ExampleSequencesResponse, tags=["examples"])
432
  async def get_example_sequences():
433
  """
webapp/app/main.py CHANGED
@@ -14,15 +14,16 @@ from webapp.app.database import init_db, get_db
14
  from webapp.app.api.routes import router as api_router
15
  from webapp.app.models.job import Job
16
 
17
- # PyShiny imports for heatmap visualization
18
  try:
19
  from shiny import App
20
  from webapp.app.shiny_apps.heatmap_app import create_app as create_heatmap_app
 
21
  SHINY_AVAILABLE = True
22
  except ImportError:
23
  SHINY_AVAILABLE = False
24
  logger = logging.getLogger(__name__)
25
- logger.warning("PyShiny not available - heatmap visualization will be disabled")
26
 
27
  # Configure logging
28
  logging.basicConfig(
@@ -102,7 +103,7 @@ static_path = Path(__file__).parent.parent / "static"
102
  if static_path.exists():
103
  app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
104
 
105
- # Mount PyShiny heatmap app
106
  if SHINY_AVAILABLE:
107
  try:
108
  heatmap_shiny_app = create_heatmap_app(api_base_url="http://localhost:8000")
@@ -111,6 +112,13 @@ if SHINY_AVAILABLE:
111
  except Exception as e:
112
  logger.error(f"Failed to mount PyShiny heatmap app: {e}")
113
 
 
 
 
 
 
 
 
114
  # Set up templates
115
  templates_path = Path(__file__).parent.parent / "templates"
116
  templates = Jinja2Templates(directory=str(templates_path)) if templates_path.exists() else None
 
14
  from webapp.app.api.routes import router as api_router
15
  from webapp.app.models.job import Job
16
 
17
+ # PyShiny imports for visualizations
18
  try:
19
  from shiny import App
20
  from webapp.app.shiny_apps.heatmap_app import create_app as create_heatmap_app
21
+ from webapp.app.shiny_apps.silhouette_app import create_app as create_silhouette_app
22
  SHINY_AVAILABLE = True
23
  except ImportError:
24
  SHINY_AVAILABLE = False
25
  logger = logging.getLogger(__name__)
26
+ logger.warning("PyShiny not available - visualizations will be disabled")
27
 
28
  # Configure logging
29
  logging.basicConfig(
 
103
  if static_path.exists():
104
  app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
105
 
106
+ # Mount PyShiny visualization apps
107
  if SHINY_AVAILABLE:
108
  try:
109
  heatmap_shiny_app = create_heatmap_app(api_base_url="http://localhost:8000")
 
112
  except Exception as e:
113
  logger.error(f"Failed to mount PyShiny heatmap app: {e}")
114
 
115
+ try:
116
+ silhouette_shiny_app = create_silhouette_app(api_base_url="http://localhost:8000")
117
+ app.mount("/shiny/silhouette", silhouette_shiny_app, name="shiny_silhouette")
118
+ logger.info("PyShiny silhouette app mounted at /shiny/silhouette")
119
+ except Exception as e:
120
+ logger.error(f"Failed to mount PyShiny silhouette app: {e}")
121
+
122
  # Set up templates
123
  templates_path = Path(__file__).parent.parent / "templates"
124
  templates = Jinja2Templates(directory=str(templates_path)) if templates_path.exists() else None
webapp/app/services/vis_data.py ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Visualization data computation for silhouette and heatmap views.
2
+
3
+ Adapted from interpretable-splicing-model-pyshiny/src/vis_data.py
4
+ """
5
+
6
+ import json
7
+ import numpy as np
8
+ import tensorflow as tf
9
+ from tensorflow.keras.models import Model
10
+ from pathlib import Path
11
+ from typing import Dict, Any, List, Tuple
12
+
13
+ from webapp.app.config import settings
14
+ from webapp.app.services.predictor import get_predictor
15
+
16
+
17
+ # Load model configuration data
18
+ def _load_model_data() -> Dict[str, Any]:
19
+ """Load model data configuration (filter groupings, boundaries, etc.)."""
20
+ model_data_path = Path(__file__).parent.parent.parent / "data" / "model_data_18.json"
21
+ with open(model_data_path, "r") as f:
22
+ return json.load(f)
23
+
24
+
25
+ def _load_dataset_data() -> Dict[str, Any]:
26
+ """Load dataset configuration (flanking sequences, etc.)."""
27
+ dataset_data_path = Path(__file__).parent.parent.parent / "data" / "datasets_data.json"
28
+ with open(dataset_data_path, "r") as f:
29
+ return json.load(f)
30
+
31
+
32
+ def shift_row(row: np.ndarray, shift: int, total_len: int = 90) -> np.ndarray:
33
+ """Shift an activation row by a given amount."""
34
+ shift = int(shift)
35
+ out = np.zeros(total_len)
36
+ out[shift:len(row)+shift] += row
37
+ return out
38
+
39
+
40
+ def collapse(
41
+ groups: Dict[str, List[int]],
42
+ shifted_acts: np.ndarray,
43
+ logo_boundaries: List[Dict],
44
+ sequence_length: int
45
+ ) -> np.ndarray:
46
+ """Collapse individual filter activations into grouped super-features."""
47
+ collapsed_acts = np.zeros((sequence_length, len(groups), 2))
48
+ for i in range(sequence_length):
49
+ for feature_id in groups.keys():
50
+ filter_strengths = shifted_acts[i, groups[feature_id]]
51
+ feature_strength = filter_strengths.sum()
52
+ repr_filter = groups[feature_id][np.argmax(filter_strengths)]
53
+ feature_length = logo_boundaries[repr_filter]["length"]
54
+ collapsed_acts[i, int(feature_id)-1, :] = [feature_strength, feature_length]
55
+ return collapsed_acts
56
+
57
+
58
+ def collapse_activations(
59
+ incl_acts: np.ndarray,
60
+ skip_acts: np.ndarray,
61
+ incl_seq_groups: Dict[str, List[int]],
62
+ skip_seq_groups: Dict[str, List[int]],
63
+ incl_struct_groups: Dict[str, List[int]],
64
+ skip_struct_groups: Dict[str, List[int]],
65
+ seq_logo_boundaries: Dict[str, List[Dict]],
66
+ struct_logo_boundaries: Dict[str, List[Dict]],
67
+ num_seq_filters: int,
68
+ num_struct_filters: int,
69
+ sequence_length: int
70
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
71
+ """Collapse filter activations into super-features with proper shifting."""
72
+ # Shift by start boundaries
73
+ seq_incl_shifts = [seq_logo_boundaries["incl"][i]["left"] for i in range(num_seq_filters)]
74
+ seq_skip_shifts = [seq_logo_boundaries["skip"][i]["left"] for i in range(num_seq_filters)]
75
+
76
+ struct_incl_shifts = [struct_logo_boundaries["incl"][i]["left"] for i in range(num_struct_filters)]
77
+ struct_skip_shifts = [struct_logo_boundaries["skip"][i]["left"] for i in range(num_struct_filters)]
78
+
79
+ # Separate activations
80
+ seq_incl_acts = incl_acts[:, :num_seq_filters]
81
+ seq_skip_acts = skip_acts[:, :num_seq_filters]
82
+ struct_incl_acts = incl_acts[:, num_seq_filters:]
83
+ struct_skip_acts = skip_acts[:, num_seq_filters:]
84
+
85
+ # Shift sequence activations
86
+ shifted_seq_incl_acts = np.array([
87
+ shift_row(row, row_shift, sequence_length)
88
+ for row, row_shift in zip(seq_incl_acts.T, seq_incl_shifts)
89
+ ]).T
90
+ shifted_seq_skip_acts = np.array([
91
+ shift_row(row, row_shift, sequence_length)
92
+ for row, row_shift in zip(seq_skip_acts.T, seq_skip_shifts)
93
+ ]).T
94
+
95
+ # Handle structure activations with padding for edges
96
+ left_padding_struct_incl_acts = np.copy(struct_incl_acts[:12, :])
97
+ right_padding_struct_incl_acts = np.copy(struct_incl_acts[-12:, :])
98
+ struct_incl_acts[:12, :] = 0.
99
+ struct_incl_acts[-12:, :] = 0.
100
+ shifted_struct_incl_acts = np.array([
101
+ shift_row(row, row_shift, sequence_length)
102
+ for row, row_shift in zip(struct_incl_acts.T, struct_incl_shifts)
103
+ ]).T
104
+ shifted_struct_incl_acts[:12, :] += left_padding_struct_incl_acts
105
+ shifted_struct_incl_acts[-12:, :] += right_padding_struct_incl_acts
106
+
107
+ left_padding_struct_skip_acts = np.copy(struct_skip_acts[:12, :])
108
+ right_padding_struct_skip_acts = np.copy(struct_skip_acts[-12:, :])
109
+ struct_skip_acts[:12, :] = 0.
110
+ struct_skip_acts[-12:, :] = 0.
111
+ shifted_struct_skip_acts = np.array([
112
+ shift_row(row, row_shift, sequence_length)
113
+ for row, row_shift in zip(struct_skip_acts.T, struct_skip_shifts)
114
+ ]).T
115
+ shifted_struct_skip_acts[:12, :] += left_padding_struct_skip_acts
116
+ shifted_struct_skip_acts[-12:, :] += right_padding_struct_skip_acts
117
+
118
+ # Collapse into super-features
119
+ collapsed_seq_incl_acts = collapse(
120
+ groups=incl_seq_groups,
121
+ shifted_acts=shifted_seq_incl_acts,
122
+ logo_boundaries=seq_logo_boundaries["incl"],
123
+ sequence_length=sequence_length
124
+ )
125
+ collapsed_struct_incl_acts = collapse(
126
+ groups=incl_struct_groups,
127
+ shifted_acts=shifted_struct_incl_acts,
128
+ logo_boundaries=struct_logo_boundaries["incl"],
129
+ sequence_length=sequence_length
130
+ )
131
+ collapsed_seq_skip_acts = collapse(
132
+ groups=skip_seq_groups,
133
+ shifted_acts=shifted_seq_skip_acts,
134
+ logo_boundaries=seq_logo_boundaries["skip"],
135
+ sequence_length=sequence_length
136
+ )
137
+ collapsed_struct_skip_acts = collapse(
138
+ groups=skip_struct_groups,
139
+ shifted_acts=shifted_struct_skip_acts,
140
+ logo_boundaries=struct_logo_boundaries["skip"],
141
+ sequence_length=sequence_length
142
+ )
143
+
144
+ return (
145
+ collapsed_seq_incl_acts, collapsed_struct_incl_acts,
146
+ collapsed_seq_skip_acts, collapsed_struct_skip_acts
147
+ )
148
+
149
+
150
+ def transform(d: Dict, parent: str) -> Dict:
151
+ """Transform dict tree to nested JSON format."""
152
+ if "strength" in d[parent].keys():
153
+ return {"name": parent, "strength": d[parent]["strength"], "length": d[parent]["length"]}
154
+ return {"name": parent, "children": [transform(d[parent], child) for child in d[parent]]}
155
+
156
+
157
+ def get_feature_activations_helper(
158
+ collapsed_acts: np.ndarray,
159
+ acts_dict: Dict,
160
+ name: str,
161
+ sequence_length: int,
162
+ threshold: float
163
+ ) -> None:
164
+ """Build feature activations tree."""
165
+ for fi in range(collapsed_acts.shape[1]):
166
+ acts_dict[f"{name}_{fi+1}"] = {}
167
+ for i in range(sequence_length):
168
+ feature_strength = collapsed_acts[i, fi, 0]
169
+ feature_length = int(collapsed_acts[i, fi, 1])
170
+ pos_ind = i + 1
171
+ if "struct" in name:
172
+ if i < 12 or i > 77:
173
+ feature_length = 1
174
+ else:
175
+ pos_ind -= 12
176
+ if feature_strength > threshold:
177
+ acts_dict[f"{name}_{fi+1}"][f"pos_{pos_ind}"] = {
178
+ "strength": feature_strength,
179
+ "length": feature_length,
180
+ }
181
+
182
+
183
+ def get_feature_activations(
184
+ collapsed_seq_incl_acts: np.ndarray,
185
+ collapsed_struct_incl_acts: np.ndarray,
186
+ collapsed_seq_skip_acts: np.ndarray,
187
+ collapsed_struct_skip_acts: np.ndarray,
188
+ sequence_length: int,
189
+ incl_bias: float,
190
+ skip_bias: float,
191
+ threshold: float = 0.001
192
+ ) -> List[Dict]:
193
+ """Build hierarchical feature activations structure."""
194
+ feature_activations = {
195
+ "incl": {"incl_bias": {"strength": incl_bias, "length": 0}},
196
+ "skip": {"skip_bias": {"strength": skip_bias, "length": 0}}
197
+ }
198
+ get_feature_activations_helper(collapsed_seq_incl_acts, feature_activations["incl"],
199
+ "incl", sequence_length, threshold)
200
+ get_feature_activations_helper(collapsed_struct_incl_acts, feature_activations["incl"],
201
+ "incl_struct", sequence_length, threshold)
202
+ get_feature_activations_helper(collapsed_seq_skip_acts, feature_activations["skip"],
203
+ "skip", sequence_length, threshold)
204
+ get_feature_activations_helper(collapsed_struct_skip_acts, feature_activations["skip"],
205
+ "skip_struct", sequence_length, threshold)
206
+ return [
207
+ transform(feature_activations, "incl"),
208
+ transform(feature_activations, "skip"),
209
+ ]
210
+
211
+
212
+ def get_nucleotide_activations_helper(
213
+ collapsed_acts: np.ndarray,
214
+ acts_dict: Dict,
215
+ name: str,
216
+ sequence_length: int,
217
+ filter_width: int,
218
+ threshold: float
219
+ ) -> None:
220
+ """Build per-nucleotide activations tree."""
221
+ for i in range(sequence_length):
222
+ for fi in range(collapsed_acts.shape[1]):
223
+ fix_feature_length = sequence_length
224
+ pos_ind = i + 1
225
+ if "struct" in name:
226
+ if i < 12 or i > 77:
227
+ first_start_ind = i
228
+ fix_feature_length = 1
229
+ else:
230
+ first_start_ind = max(12, i - filter_width + 1)
231
+ else:
232
+ first_start_ind = max(0, i - filter_width + 1)
233
+ for start_ind in range(first_start_ind, i + 1):
234
+ feature_strength = collapsed_acts[start_ind, fi, 0]
235
+ feature_length = min(fix_feature_length, int(collapsed_acts[start_ind, fi, 1]))
236
+ if feature_strength > threshold and start_ind + feature_length > i:
237
+ if f"{name}_{fi+1}" not in acts_dict[f"pos_{pos_ind}"].keys():
238
+ acts_dict[f"pos_{pos_ind}"][f"{name}_{fi+1}"] = {}
239
+ acts_dict[f"pos_{pos_ind}"][f"{name}_{fi+1}"][f"feature_pos_{i-start_ind+1}"] = {
240
+ "strength": feature_strength / feature_length,
241
+ "length": feature_length,
242
+ }
243
+
244
+
245
+ def get_nucleotide_activations(
246
+ collapsed_seq_incl_acts: np.ndarray,
247
+ collapsed_struct_incl_acts: np.ndarray,
248
+ collapsed_seq_skip_acts: np.ndarray,
249
+ collapsed_struct_skip_acts: np.ndarray,
250
+ sequence_length: int,
251
+ seq_filter_width: int,
252
+ struct_filter_width: int,
253
+ threshold: float = 0.001
254
+ ) -> List[Dict]:
255
+ """Build hierarchical per-nucleotide activations structure."""
256
+ nucleotide_activations = {"incl": {}, "skip": {}}
257
+ for i in range(sequence_length):
258
+ nucleotide_activations["incl"][f"pos_{i+1}"] = {}
259
+ nucleotide_activations["skip"][f"pos_{i+1}"] = {}
260
+
261
+ get_nucleotide_activations_helper(collapsed_seq_incl_acts, nucleotide_activations["incl"],
262
+ "incl", sequence_length, seq_filter_width, threshold)
263
+ get_nucleotide_activations_helper(collapsed_struct_incl_acts, nucleotide_activations["incl"],
264
+ "incl_struct", sequence_length, struct_filter_width, threshold)
265
+ get_nucleotide_activations_helper(collapsed_seq_skip_acts, nucleotide_activations["skip"],
266
+ "skip", sequence_length, seq_filter_width, threshold)
267
+ get_nucleotide_activations_helper(collapsed_struct_skip_acts, nucleotide_activations["skip"],
268
+ "skip_struct", sequence_length, struct_filter_width, threshold)
269
+
270
+ # Remove empty positions
271
+ for i in range(sequence_length):
272
+ if len(nucleotide_activations["incl"][f"pos_{i+1}"]) == 0:
273
+ nucleotide_activations["incl"].pop(f"pos_{i+1}")
274
+ if len(nucleotide_activations["skip"][f"pos_{i+1}"]) == 0:
275
+ nucleotide_activations["skip"].pop(f"pos_{i+1}")
276
+
277
+ return [
278
+ transform(nucleotide_activations, "incl"),
279
+ transform(nucleotide_activations, "skip"),
280
+ ]
281
+
282
+
283
+ def get_vis_data(exon_sequence: str, threshold: float = 0.001) -> Dict[str, Any]:
284
+ """
285
+ Compute visualization data for an exon sequence.
286
+
287
+ Args:
288
+ exon_sequence: The 70nt exon sequence
289
+ threshold: Minimum activation strength to include
290
+
291
+ Returns:
292
+ Dictionary with visualization data including:
293
+ - exon, sequence, structs
294
+ - predicted_psi, delta_force, incl_strength, skip_strength
295
+ - feature_activations (hierarchical feature contributions)
296
+ - nucleotide_activations (per-position contributions)
297
+ """
298
+ # Normalize input
299
+ exon = exon_sequence.upper().replace("T", "U")
300
+
301
+ # Get predictor and prepare input
302
+ predictor = get_predictor()
303
+
304
+ # Add flanking sequences (uses settings from webapp config)
305
+ full_sequence = predictor.add_flanking(exon_sequence.upper())
306
+ sequence_length = len(full_sequence)
307
+
308
+ # Get structure prediction
309
+ inputs, structure, mfe, warnings = predictor.prepare_input(exon_sequence)
310
+
311
+ # Get model and make prediction
312
+ model = predictor.model
313
+ predicted_psi = float(model.predict(inputs, verbose=0)[0, 0])
314
+
315
+ # Load model configuration
316
+ model_data = _load_model_data()
317
+
318
+ # Get configuration values
319
+ link_midpoint = model_data["link_midpoint"]
320
+ incl_bias, skip_bias = (abs(link_midpoint), 0) if link_midpoint < 0 else (0, abs(link_midpoint))
321
+
322
+ num_seq_filters = model_data["num_seq_filters"]
323
+ num_struct_filters = model_data["num_struct_filters"]
324
+ seq_filter_width = model_data["seq_filter_width"]
325
+ struct_filter_width = model_data["struct_filter_width"]
326
+
327
+ # Get filter groups
328
+ incl_seq_groups = model_data["incl_seq_groups"]
329
+ skip_seq_groups = model_data["skip_seq_groups"]
330
+ incl_struct_groups = model_data["incl_struct_groups"]
331
+ skip_struct_groups = model_data["skip_struct_groups"]
332
+
333
+ # Get filter boundaries
334
+ seq_logo_boundaries = model_data["seq_logo_boundaries"]
335
+ struct_logo_boundaries = model_data["struct_logo_boundaries"]
336
+
337
+ # Create intermediate model to extract activations
338
+ # activation_2 = inclusion activations, activation_3 = skipping activations
339
+ activations_model = Model(inputs=model.inputs, outputs=[
340
+ model.get_layer("activation_2").output,
341
+ model.get_layer("activation_3").output
342
+ ])
343
+
344
+ data_incl_acts, data_skip_acts = activations_model.predict(inputs, verbose=0)
345
+ incl_acts = data_incl_acts[0]
346
+ skip_acts = data_skip_acts[0]
347
+
348
+ incl_strength = incl_bias + incl_acts.sum()
349
+ skip_strength = skip_bias + skip_acts.sum()
350
+ delta_force = incl_strength - skip_strength
351
+
352
+ # Collapse filter activations into super-features
353
+ (
354
+ collapsed_seq_incl_acts, collapsed_struct_incl_acts,
355
+ collapsed_seq_skip_acts, collapsed_struct_skip_acts
356
+ ) = collapse_activations(
357
+ incl_acts=incl_acts,
358
+ skip_acts=skip_acts,
359
+ incl_seq_groups=incl_seq_groups,
360
+ skip_seq_groups=skip_seq_groups,
361
+ incl_struct_groups=incl_struct_groups,
362
+ skip_struct_groups=skip_struct_groups,
363
+ seq_logo_boundaries=seq_logo_boundaries,
364
+ struct_logo_boundaries=struct_logo_boundaries,
365
+ num_seq_filters=num_seq_filters,
366
+ num_struct_filters=num_struct_filters,
367
+ sequence_length=sequence_length,
368
+ )
369
+
370
+ # Build hierarchical data structures
371
+ feature_activations = get_feature_activations(
372
+ collapsed_seq_incl_acts=collapsed_seq_incl_acts,
373
+ collapsed_struct_incl_acts=collapsed_struct_incl_acts,
374
+ collapsed_seq_skip_acts=collapsed_seq_skip_acts,
375
+ collapsed_struct_skip_acts=collapsed_struct_skip_acts,
376
+ sequence_length=sequence_length,
377
+ incl_bias=incl_bias,
378
+ skip_bias=skip_bias,
379
+ threshold=threshold
380
+ )
381
+
382
+ nucleotide_activations = get_nucleotide_activations(
383
+ collapsed_seq_incl_acts=collapsed_seq_incl_acts,
384
+ collapsed_struct_incl_acts=collapsed_struct_incl_acts,
385
+ collapsed_seq_skip_acts=collapsed_seq_skip_acts,
386
+ collapsed_struct_skip_acts=collapsed_struct_skip_acts,
387
+ sequence_length=sequence_length,
388
+ seq_filter_width=seq_filter_width,
389
+ struct_filter_width=struct_filter_width,
390
+ threshold=threshold
391
+ )
392
+
393
+ return {
394
+ "exon": exon,
395
+ "sequence": full_sequence,
396
+ "structs": structure,
397
+ "predicted_psi": float(predicted_psi),
398
+ "delta_force": float(delta_force),
399
+ "incl_bias": float(incl_bias),
400
+ "skip_bias": float(skip_bias),
401
+ "incl_strength": float(incl_strength),
402
+ "skip_strength": float(skip_strength),
403
+ "feature_activations": {
404
+ "name": "feature_activations",
405
+ "children": feature_activations
406
+ },
407
+ "nucleotide_activations": {
408
+ "name": "nucleotide_activations",
409
+ "children": nucleotide_activations
410
+ },
411
+ }
webapp/app/shiny_apps/heatmap_app.py CHANGED
@@ -1,22 +1,110 @@
1
- """PyShiny app for Filter × Position heatmap visualization."""
 
 
 
 
 
 
2
 
3
  from shiny import App, ui, render, reactive
4
  import plotly.graph_objects as go
5
- from plotly.subplots import make_subplots
6
  import httpx
7
  import numpy as np
8
- from urllib.parse import parse_qs
9
 
10
 
11
- def create_app(api_base_url: str = "http://localhost:8000"):
12
- """Create the PyShiny heatmap app.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- Args:
15
- api_base_url: Base URL for the API to fetch heatmap data
 
 
 
16
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  app_ui = ui.page_fluid(
19
  ui.head_content(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  ui.tags.style("""
21
  body {
22
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@@ -63,15 +151,17 @@ def create_app(api_base_url: str = "http://localhost:8000"):
63
  3,
64
  ui.div(
65
  {"class": "filter-panel"},
66
- ui.h4("Filter × Position heatmap"),
 
 
67
  ui.div(
68
  {"class": "filter-section"},
69
- ui.h4("Inclusion Filters", style="color: #22c55e;"),
70
  ui.output_ui("inclusion_checkboxes"),
71
  ),
72
  ui.div(
73
  {"class": "filter-section"},
74
- ui.h4("Skipping Filters", style="color: #ef4444;"),
75
  ui.output_ui("skipping_checkboxes"),
76
  ),
77
  ui.div(
@@ -95,46 +185,35 @@ def create_app(api_base_url: str = "http://localhost:8000"):
95
  )
96
 
97
  def server(input, output, session):
98
- # Reactive value to store heatmap data
99
- heatmap_data = reactive.Value(None)
100
  error_message = reactive.Value(None)
101
- selected_filters = reactive.Value(set())
102
-
103
- def get_job_id_from_url():
104
- """Extract job_id from URL query parameters."""
105
- try:
106
- # Access the ASGI scope to get query string
107
- scope = session.http_conn.scope if hasattr(session, 'http_conn') else None
108
- if scope and 'query_string' in scope:
109
- query_string = scope['query_string'].decode('utf-8')
110
- params = parse_qs(query_string)
111
- if 'job_id' in params:
112
- return params['job_id'][0]
113
- except Exception:
114
- pass
115
- return None
116
 
117
  @reactive.Effect
118
- def fetch_data():
119
- """Fetch heatmap data from API on app load."""
120
- job_id = get_job_id_from_url()
 
 
 
121
 
122
  if not job_id:
123
- error_message.set("No job_id provided in URL. Please access this page from a result page.")
124
  return
125
 
 
 
126
  try:
127
- # Fetch heatmap data from API
128
- with httpx.Client(timeout=30.0) as client:
129
- response = client.get(f"{api_base_url}/api/heatmap/{job_id}")
 
 
 
130
  response.raise_for_status()
131
  data = response.json()
132
- heatmap_data.set(data)
133
-
134
- # Initialize all filters as selected
135
- if data and "filter_names" in data:
136
- selected_filters.set(set(data["filter_names"]))
137
-
138
  except httpx.HTTPError as e:
139
  error_message.set(f"Error fetching data: {str(e)}")
140
  except Exception as e:
@@ -143,11 +222,13 @@ def create_app(api_base_url: str = "http://localhost:8000"):
143
  @output
144
  @render.ui
145
  def inclusion_checkboxes():
146
- data = heatmap_data.get()
147
- if not data or "filter_names" not in data:
148
  return ui.p("Loading...")
149
 
150
- incl_filters = [f for f in data["filter_names"] if f.startswith("incl_") and not f.startswith("incl_struct")]
 
 
151
 
152
  return ui.input_checkbox_group(
153
  "incl_filters",
@@ -159,11 +240,13 @@ def create_app(api_base_url: str = "http://localhost:8000"):
159
  @output
160
  @render.ui
161
  def skipping_checkboxes():
162
- data = heatmap_data.get()
163
- if not data or "filter_names" not in data:
164
  return ui.p("Loading...")
165
 
166
- skip_filters = [f for f in data["filter_names"] if f.startswith("skip_") and not f.startswith("skip_struct")]
 
 
167
 
168
  return ui.input_checkbox_group(
169
  "skip_filters",
@@ -175,11 +258,13 @@ def create_app(api_base_url: str = "http://localhost:8000"):
175
  @output
176
  @render.ui
177
  def structure_checkboxes():
178
- data = heatmap_data.get()
179
- if not data or "filter_names" not in data:
180
  return ui.p("Loading...")
181
 
182
- struct_filters = [f for f in data["filter_names"] if "struct" in f]
 
 
183
 
184
  return ui.input_checkbox_group(
185
  "struct_filters",
@@ -191,13 +276,13 @@ def create_app(api_base_url: str = "http://localhost:8000"):
191
  @reactive.Effect
192
  @reactive.event(input.select_all)
193
  def select_all_filters():
194
- data = heatmap_data.get()
195
- if data and "filter_names" in data:
196
- # Update all checkbox groups
197
- incl_filters = [f for f in data["filter_names"] if f.startswith("incl_") and not f.startswith("incl_struct")]
198
- skip_filters = [f for f in data["filter_names"] if f.startswith("skip_") and not f.startswith("skip_struct")]
199
- struct_filters = [f for f in data["filter_names"] if "struct" in f]
200
-
201
  ui.update_checkbox_group("incl_filters", selected=incl_filters)
202
  ui.update_checkbox_group("skip_filters", selected=skip_filters)
203
  ui.update_checkbox_group("struct_filters", selected=struct_filters)
@@ -209,6 +294,20 @@ def create_app(api_base_url: str = "http://localhost:8000"):
209
  ui.update_checkbox_group("skip_filters", selected=[])
210
  ui.update_checkbox_group("struct_filters", selected=[])
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  @output
213
  @render.ui
214
  def heatmap_plot():
@@ -216,16 +315,32 @@ def create_app(api_base_url: str = "http://localhost:8000"):
216
  if err:
217
  return ui.div({"class": "error-message"}, err)
218
 
219
- data = heatmap_data.get()
220
  if not data:
221
- return ui.div({"class": "loading"}, "Loading heatmap data...")
222
 
223
- # Get selected filters from all checkbox groups
224
- incl_selected = list(input.incl_filters()) if input.incl_filters() else []
225
- skip_selected = list(input.skip_filters()) if input.skip_filters() else []
226
- struct_selected = list(input.struct_filters()) if input.struct_filters() else []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
- all_selected = incl_selected + skip_selected + struct_selected
229
 
230
  if not all_selected:
231
  return ui.div(
@@ -233,61 +348,94 @@ def create_app(api_base_url: str = "http://localhost:8000"):
233
  "Select at least one filter to display the heatmap."
234
  )
235
 
236
- # Filter data based on selected filters
237
- filter_names = data["filter_names"]
238
- activations = data["activations"]
239
- nucleotides = data["nucleotides"]
240
- positions = data["positions"]
241
-
242
- # Get indices of selected filters
243
- selected_indices = [i for i, name in enumerate(filter_names) if name in all_selected]
244
-
245
- if not selected_indices:
246
- return ui.div({"class": "loading"}, "No filters selected.")
247
-
248
- # Create filtered activation matrix
249
- filtered_names = [filter_names[i] for i in selected_indices]
250
- filtered_activations = [activations[i] for i in selected_indices]
251
-
252
- # Create x-axis labels (nucleotides)
253
- x_labels = [f"{nucleotides[i]}" for i in range(len(nucleotides))]
254
-
255
- # Create heatmap
256
- fig = go.Figure(data=go.Heatmap(
257
- z=filtered_activations,
258
- x=x_labels,
259
- y=filtered_names,
260
- colorscale="Viridis",
261
- colorbar=dict(
262
- title="Strength",
263
- titleside="right",
264
- ),
265
- hovertemplate=(
266
- "Position: %{x}<br>"
267
- "Filter: %{y}<br>"
268
- "Activation: %{z:.3f}<br>"
269
- "<extra></extra>"
270
- ),
271
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
 
273
  fig.update_layout(
274
- title=dict(
275
- text="Filter Activations by Position",
276
- x=0.5,
277
- font=dict(size=16),
278
- ),
279
  xaxis=dict(
280
- title="Sequence Position",
281
- tickangle=0,
282
- tickfont=dict(size=8),
283
- dtick=5, # Show every 5th tick
 
 
284
  ),
285
  yaxis=dict(
286
- title="Filter",
287
  tickfont=dict(size=10),
 
 
288
  ),
289
- height=max(400, len(filtered_names) * 20 + 100),
290
- margin=dict(l=100, r=50, t=50, b=100),
291
  )
292
 
293
  # Convert to HTML
 
1
+ """PyShiny app for Filter × Position heatmap visualization.
2
+
3
+ Shows collapsed filter activations with:
4
+ - Diverging colorscale (red = skipping, white = 0, blue = inclusion)
5
+ - Filter icons positioned next to y-axis labels
6
+ - Signed matrix (positive for inclusion, negative for skipping)
7
+ """
8
 
9
  from shiny import App, ui, render, reactive
10
  import plotly.graph_objects as go
 
11
  import httpx
12
  import numpy as np
 
13
 
14
 
15
+ def list_all_filters_collapsed(children):
16
+ """Get list of all filter names, collapsing skip_struct_* into skip_struct_ALL."""
17
+ names = set()
18
+ has_skip_struct = False
19
+ for side_node in children:
20
+ for pos_node in side_node.get("children", []):
21
+ for feat_node in pos_node.get("children", []):
22
+ n = feat_node["name"]
23
+ if n.startswith("skip_struct_"):
24
+ has_skip_struct = True
25
+ else:
26
+ names.add(n)
27
+ out = sorted(names)
28
+ if has_skip_struct:
29
+ out.append("skip_struct_ALL")
30
+ return out
31
+
32
+
33
+ def build_signed_filter_matrix_collapsed(children, L, filter_names):
34
+ """
35
+ Build a signed activation matrix.
36
 
37
+ M[row, pos]:
38
+ + strength for inclusion-side features
39
+ - strength for skipping-side features
40
+
41
+ Also collapses skip_struct_* into skip_struct_ALL.
42
  """
43
+ name_to_row = {name: i for i, name in enumerate(filter_names)}
44
+ M = np.zeros((len(filter_names), L), dtype=float)
45
+
46
+ incl_node = children[0]
47
+ skip_node = children[1]
48
+
49
+ def add_side(side_node, sign):
50
+ for pos_node in side_node.get("children", []):
51
+ pos = int(pos_node["name"].split("_")[1]) - 1
52
+ for feat_node in pos_node.get("children", []):
53
+ fname = feat_node["name"]
54
+
55
+ # Collapse skip_struct_* into skip_struct_ALL
56
+ if fname.startswith("skip_struct_") and "skip_struct_ALL" in name_to_row:
57
+ row = name_to_row["skip_struct_ALL"]
58
+ else:
59
+ if fname not in name_to_row:
60
+ continue
61
+ row = name_to_row[fname]
62
+
63
+ s = 0.0
64
+ for leaf in feat_node.get("children", []):
65
+ s += float(leaf.get("strength", 0.0))
66
+
67
+ M[row, pos] += sign * s
68
+
69
+ add_side(incl_node, +1.0)
70
+ add_side(skip_node, -1.0)
71
+ return M
72
+
73
+
74
+ def filter_to_icon_url(filter_name: str) -> str:
75
+ """Get URL for filter icon image."""
76
+ return f"/static/filters/{filter_name}.png"
77
+
78
+
79
+ def create_app(api_base_url: str = "http://localhost:8000"):
80
+ """Create the PyShiny heatmap app."""
81
 
82
  app_ui = ui.page_fluid(
83
  ui.head_content(
84
+ ui.tags.script("""
85
+ // Listen for postMessage from parent window
86
+ window.addEventListener('message', function(event) {
87
+ if (event.data && event.data.type === 'setParams') {
88
+ console.log('[Heatmap] Received params via postMessage:', event.data);
89
+ // Wait for Shiny to be ready, then set input values
90
+ if (typeof Shiny !== 'undefined' && Shiny.setInputValue) {
91
+ Shiny.setInputValue('pm_job_id', event.data.job_id);
92
+ Shiny.setInputValue('pm_batch_index', event.data.batch_index);
93
+ } else {
94
+ // Retry after Shiny loads
95
+ document.addEventListener('shiny:connected', function() {
96
+ Shiny.setInputValue('pm_job_id', event.data.job_id);
97
+ Shiny.setInputValue('pm_batch_index', event.data.batch_index);
98
+ });
99
+ }
100
+ }
101
+ });
102
+ // Request params from parent when Shiny is ready
103
+ document.addEventListener('shiny:connected', function() {
104
+ console.log('[Heatmap] Shiny connected, requesting params from parent');
105
+ window.parent.postMessage({type: 'ready', source: 'heatmap'}, '*');
106
+ });
107
+ """),
108
  ui.tags.style("""
109
  body {
110
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
 
151
  3,
152
  ui.div(
153
  {"class": "filter-panel"},
154
+ ui.h4("Filter × Position Heatmap"),
155
+ ui.p("Blue = Inclusion, Red = Skipping",
156
+ style="font-size: 12px; color: #6b7280; margin-bottom: 12px;"),
157
  ui.div(
158
  {"class": "filter-section"},
159
+ ui.h4("Inclusion Filters", style="color: #2563eb;"),
160
  ui.output_ui("inclusion_checkboxes"),
161
  ),
162
  ui.div(
163
  {"class": "filter-section"},
164
+ ui.h4("Skipping Filters", style="color: #dc2626;"),
165
  ui.output_ui("skipping_checkboxes"),
166
  ),
167
  ui.div(
 
185
  )
186
 
187
  def server(input, output, session):
188
+ vis_data = reactive.Value(None)
 
189
  error_message = reactive.Value(None)
190
+ params_received = reactive.Value(False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
  @reactive.Effect
193
+ @reactive.event(input.pm_job_id)
194
+ async def on_params_received():
195
+ """Triggered when params are received via postMessage."""
196
+ job_id = input.pm_job_id()
197
+ batch_index = input.pm_batch_index()
198
+ print(f"[Heatmap] Received postMessage params: job_id={job_id}, batch_index={batch_index}", flush=True)
199
 
200
  if not job_id:
201
+ error_message.set("Waiting for job parameters...")
202
  return
203
 
204
+ params_received.set(True)
205
+
206
  try:
207
+ url = f"{api_base_url}/api/vis_data/{job_id}"
208
+ if batch_index is not None:
209
+ url += f"?batch_index={batch_index}"
210
+
211
+ async with httpx.AsyncClient(timeout=60.0) as client:
212
+ response = await client.get(url)
213
  response.raise_for_status()
214
  data = response.json()
215
+ vis_data.set(data)
216
+ error_message.set(None) # Clear any error
 
 
 
 
217
  except httpx.HTTPError as e:
218
  error_message.set(f"Error fetching data: {str(e)}")
219
  except Exception as e:
 
222
  @output
223
  @render.ui
224
  def inclusion_checkboxes():
225
+ data = vis_data.get()
226
+ if not data:
227
  return ui.p("Loading...")
228
 
229
+ children = data["nucleotide_activations"]["children"]
230
+ all_filters = list_all_filters_collapsed(children)
231
+ incl_filters = [f for f in all_filters if f.startswith("incl_") and not f.startswith("incl_struct")]
232
 
233
  return ui.input_checkbox_group(
234
  "incl_filters",
 
240
  @output
241
  @render.ui
242
  def skipping_checkboxes():
243
+ data = vis_data.get()
244
+ if not data:
245
  return ui.p("Loading...")
246
 
247
+ children = data["nucleotide_activations"]["children"]
248
+ all_filters = list_all_filters_collapsed(children)
249
+ skip_filters = [f for f in all_filters if f.startswith("skip_") and not f.startswith("skip_struct")]
250
 
251
  return ui.input_checkbox_group(
252
  "skip_filters",
 
258
  @output
259
  @render.ui
260
  def structure_checkboxes():
261
+ data = vis_data.get()
262
+ if not data:
263
  return ui.p("Loading...")
264
 
265
+ children = data["nucleotide_activations"]["children"]
266
+ all_filters = list_all_filters_collapsed(children)
267
+ struct_filters = [f for f in all_filters if "struct" in f]
268
 
269
  return ui.input_checkbox_group(
270
  "struct_filters",
 
276
  @reactive.Effect
277
  @reactive.event(input.select_all)
278
  def select_all_filters():
279
+ data = vis_data.get()
280
+ if data:
281
+ children = data["nucleotide_activations"]["children"]
282
+ all_filters = list_all_filters_collapsed(children)
283
+ incl_filters = [f for f in all_filters if f.startswith("incl_") and not f.startswith("incl_struct")]
284
+ skip_filters = [f for f in all_filters if f.startswith("skip_") and not f.startswith("skip_struct")]
285
+ struct_filters = [f for f in all_filters if "struct" in f]
286
  ui.update_checkbox_group("incl_filters", selected=incl_filters)
287
  ui.update_checkbox_group("skip_filters", selected=skip_filters)
288
  ui.update_checkbox_group("struct_filters", selected=struct_filters)
 
294
  ui.update_checkbox_group("skip_filters", selected=[])
295
  ui.update_checkbox_group("struct_filters", selected=[])
296
 
297
+ @reactive.Effect
298
+ def trigger_initial_heatmap():
299
+ """Force checkbox updates when data loads to trigger heatmap rendering."""
300
+ data = vis_data.get()
301
+ if data:
302
+ children = data["nucleotide_activations"]["children"]
303
+ all_filters = list_all_filters_collapsed(children)
304
+ incl_filters = [f for f in all_filters if f.startswith("incl_") and not f.startswith("incl_struct")]
305
+ skip_filters = [f for f in all_filters if f.startswith("skip_") and not f.startswith("skip_struct")]
306
+ struct_filters = [f for f in all_filters if "struct" in f]
307
+ ui.update_checkbox_group("incl_filters", selected=incl_filters)
308
+ ui.update_checkbox_group("skip_filters", selected=skip_filters)
309
+ ui.update_checkbox_group("struct_filters", selected=struct_filters)
310
+
311
  @output
312
  @render.ui
313
  def heatmap_plot():
 
315
  if err:
316
  return ui.div({"class": "error-message"}, err)
317
 
318
+ data = vis_data.get()
319
  if not data:
320
+ return ui.div({"class": "loading"}, "Waiting for job parameters...")
321
 
322
+ # Get all available filters for defaults
323
+ children = data["nucleotide_activations"]["children"]
324
+ available_filters = list_all_filters_collapsed(children)
325
+ default_incl = [f for f in available_filters if f.startswith("incl_") and not f.startswith("incl_struct")]
326
+ default_skip = [f for f in available_filters if f.startswith("skip_") and not f.startswith("skip_struct")]
327
+ default_struct = [f for f in available_filters if "struct" in f]
328
+
329
+ # Get selected filters - default to all if inputs not yet available
330
+ try:
331
+ incl_selected = list(input.incl_filters()) if input.incl_filters() else default_incl
332
+ except Exception:
333
+ incl_selected = default_incl
334
+ try:
335
+ skip_selected = list(input.skip_filters()) if input.skip_filters() else default_skip
336
+ except Exception:
337
+ skip_selected = default_skip
338
+ try:
339
+ struct_selected = list(input.struct_filters()) if input.struct_filters() else default_struct
340
+ except Exception:
341
+ struct_selected = default_struct
342
 
343
+ all_selected = set(incl_selected + skip_selected + struct_selected)
344
 
345
  if not all_selected:
346
  return ui.div(
 
348
  "Select at least one filter to display the heatmap."
349
  )
350
 
351
+ # Extract data
352
+ full_seq = data["sequence"]
353
+ exon = data["exon"]
354
+ L = len(full_seq)
355
+
356
+ # Filter to only selected filters
357
+ filters = [f for f in available_filters if f in all_selected]
358
+
359
+ # Build signed matrix
360
+ M = build_signed_filter_matrix_collapsed(children, L, filters)
361
+
362
+ # Get exon boundaries for highlighting
363
+ start = full_seq.find(exon.replace("U", "T"))
364
+ if start == -1:
365
+ start = full_seq.upper().find(exon.upper().replace("U", "T"))
366
+ if start == -1:
367
+ start = 10
368
+ end = start + len(exon)
369
+
370
+ # Setup display
371
+ x_pos = list(range(L))
372
+ x_bases = list(full_seq)
373
+ filters_rev = list(reversed(filters))
374
+ M_rev = M[::-1, :]
375
+
376
+ # Symmetric z range so 0 is white
377
+ zmax = float(max(np.max(np.abs(M_rev)), 1e-6))
378
+
379
+ # Create heatmap with diverging colorscale
380
+ fig = go.Figure(
381
+ data=go.Heatmap(
382
+ z=M_rev,
383
+ x=x_pos,
384
+ y=filters_rev,
385
+ zmin=-zmax,
386
+ zmax=zmax,
387
+ colorscale=[
388
+ (0.0, "#dc2626"), # Red for skipping
389
+ (0.5, "#ffffff"), # White for neutral
390
+ (1.0, "#2563eb"), # Blue for inclusion
391
+ ],
392
+ hovertemplate="Filter %{y}<br>Base %{customdata}<br>Strength %{z:.4f}<extra></extra>",
393
+ customdata=np.array(x_bases)[None, :].repeat(len(filters_rev), axis=0),
394
+ colorbar=dict(title="Strength<br>(+ incl, - skip)"),
395
+ )
396
+ )
397
+
398
+ # Highlight exon region
399
+ fig.add_vrect(x0=start-0.5, x1=end-0.5, fillcolor="#d0d0d0", line_width=0, opacity=0.2)
400
+
401
+ # Add filter icons to the left of y-axis labels
402
+ fig.update_layout(images=[])
403
+ for y in filters_rev:
404
+ if y != "skip_struct_ALL":
405
+ fig.add_layout_image(
406
+ dict(
407
+ source=filter_to_icon_url(y),
408
+ xref="paper",
409
+ yref="y",
410
+ x=-0.03,
411
+ y=y,
412
+ xanchor="right",
413
+ yanchor="middle",
414
+ sizex=0.06,
415
+ sizey=0.8,
416
+ sizing="contain",
417
+ opacity=1.0,
418
+ layer="above",
419
+ )
420
+ )
421
 
422
  fig.update_layout(
423
+ height=max(850, 24 * len(filters_rev) + 250),
424
+ width=1400,
425
+ margin=dict(l=130, r=20, t=80, b=30),
 
 
426
  xaxis=dict(
427
+ side="top",
428
+ tickmode="array",
429
+ tickvals=x_pos,
430
+ ticktext=x_bases,
431
+ tickfont=dict(size=9),
432
+ showgrid=False,
433
  ),
434
  yaxis=dict(
 
435
  tickfont=dict(size=10),
436
+ categoryorder="array",
437
+ categoryarray=filters_rev,
438
  ),
 
 
439
  )
440
 
441
  # Convert to HTML
webapp/app/shiny_apps/silhouette_app.py ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PyShiny app for Silhouette View visualization.
2
+
3
+ Shows per-position inclusion/skipping strengths as a bar chart.
4
+ Blue bars (upward) = Inclusion strength
5
+ Red bars (downward) = Skipping strength
6
+ """
7
+
8
+ from shiny import App, ui, render, reactive
9
+ import matplotlib.pyplot as plt
10
+ import matplotlib
11
+ matplotlib.use('Agg') # Use non-interactive backend
12
+ import numpy as np
13
+ import httpx
14
+
15
+
16
+ def parse_total_position_strengths(nucleotide_activations_children, L):
17
+ """
18
+ Parse total position strengths from nucleotide activations.
19
+
20
+ Args:
21
+ nucleotide_activations_children: result["nucleotide_activations"]["children"]
22
+ L: sequence length
23
+
24
+ Returns:
25
+ (incl_total, skip_total) arrays of shape (L,)
26
+ """
27
+ def side_to_total(side_node):
28
+ total = np.zeros(L, dtype=float)
29
+ for pos_node in side_node.get("children", []):
30
+ pos = int(pos_node["name"].split("_")[1]) - 1
31
+ s = 0.0
32
+ for feat_node in pos_node.get("children", []):
33
+ for leaf in feat_node.get("children", []):
34
+ s += float(leaf.get("strength", 0.0))
35
+ total[pos] = s
36
+ return total
37
+
38
+ incl_node = nucleotide_activations_children[0]
39
+ skip_node = nucleotide_activations_children[1]
40
+ return side_to_total(incl_node), side_to_total(skip_node)
41
+
42
+
43
+ def list_all_filters_collapsed(children):
44
+ """Get list of all filter names, collapsing skip_struct_* into skip_struct_ALL."""
45
+ names = set()
46
+ has_skip_struct = False
47
+ for side_node in children:
48
+ for pos_node in side_node.get("children", []):
49
+ for feat_node in pos_node.get("children", []):
50
+ n = feat_node["name"]
51
+ if n.startswith("skip_struct_"):
52
+ has_skip_struct = True
53
+ else:
54
+ names.add(n)
55
+ out = sorted(names)
56
+ if has_skip_struct:
57
+ out.append("skip_struct_ALL")
58
+ return out
59
+
60
+
61
+ def position_totals_for_selected_filters(nucleotide_activations_children, L, selected):
62
+ """Calculate position totals for only selected filters."""
63
+ selected = set(selected)
64
+ expand_skip_struct = "skip_struct_ALL" in selected
65
+
66
+ def side_to_total(side_node):
67
+ total = np.zeros(L, dtype=float)
68
+ for pos_node in side_node.get("children", []):
69
+ pos = int(pos_node["name"].split("_")[1]) - 1
70
+ s = 0.0
71
+ for feat_node in pos_node.get("children", []):
72
+ name = feat_node["name"]
73
+ is_skip_struct = name.startswith("skip_struct_")
74
+ take = (name in selected) or (expand_skip_struct and is_skip_struct)
75
+ if not take:
76
+ continue
77
+ for leaf in feat_node.get("children", []):
78
+ s += float(leaf.get("strength", 0.0))
79
+ total[pos] = s
80
+ return total
81
+
82
+ incl_node = nucleotide_activations_children[0]
83
+ skip_node = nucleotide_activations_children[1]
84
+ return side_to_total(incl_node), side_to_total(skip_node)
85
+
86
+
87
+ def create_app(api_base_url: str = "http://localhost:8000"):
88
+ """Create the PyShiny silhouette app."""
89
+
90
+ app_ui = ui.page_fluid(
91
+ ui.head_content(
92
+ ui.tags.script("""
93
+ // Listen for postMessage from parent window
94
+ window.addEventListener('message', function(event) {
95
+ if (event.data && event.data.type === 'setParams') {
96
+ console.log('[Silhouette] Received params via postMessage:', event.data);
97
+ // Wait for Shiny to be ready, then set input values
98
+ if (typeof Shiny !== 'undefined' && Shiny.setInputValue) {
99
+ Shiny.setInputValue('pm_job_id', event.data.job_id);
100
+ Shiny.setInputValue('pm_batch_index', event.data.batch_index);
101
+ } else {
102
+ // Retry after Shiny loads
103
+ document.addEventListener('shiny:connected', function() {
104
+ Shiny.setInputValue('pm_job_id', event.data.job_id);
105
+ Shiny.setInputValue('pm_batch_index', event.data.batch_index);
106
+ });
107
+ }
108
+ }
109
+ });
110
+ // Request params from parent when Shiny is ready
111
+ document.addEventListener('shiny:connected', function() {
112
+ console.log('[Silhouette] Shiny connected, requesting params from parent');
113
+ window.parent.postMessage({type: 'ready', source: 'silhouette'}, '*');
114
+ });
115
+ """),
116
+ ui.tags.style("""
117
+ body {
118
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
119
+ background: #f9fafb;
120
+ margin: 0;
121
+ padding: 16px;
122
+ }
123
+ .filter-panel {
124
+ background: white;
125
+ border-radius: 8px;
126
+ padding: 16px;
127
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
128
+ max-height: 400px;
129
+ overflow-y: auto;
130
+ }
131
+ .filter-section h4 {
132
+ margin: 0 0 8px 0;
133
+ font-size: 14px;
134
+ color: #374151;
135
+ }
136
+ .filter-section {
137
+ margin-bottom: 16px;
138
+ }
139
+ .plot-container {
140
+ background: white;
141
+ border-radius: 8px;
142
+ padding: 16px;
143
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
144
+ }
145
+ .error-message {
146
+ color: #dc2626;
147
+ padding: 20px;
148
+ text-align: center;
149
+ }
150
+ .loading {
151
+ padding: 40px;
152
+ text-align: center;
153
+ color: #6b7280;
154
+ }
155
+ """)
156
+ ),
157
+ ui.row(
158
+ ui.column(
159
+ 3,
160
+ ui.div(
161
+ {"class": "filter-panel"},
162
+ ui.h4("Filter Selection"),
163
+ ui.p("Select which filters contribute to the silhouette view:",
164
+ style="font-size: 12px; color: #6b7280; margin-bottom: 12px;"),
165
+ ui.div(
166
+ {"class": "filter-section"},
167
+ ui.h4("Inclusion Filters", style="color: #2563eb;"),
168
+ ui.output_ui("inclusion_checkboxes"),
169
+ ),
170
+ ui.div(
171
+ {"class": "filter-section"},
172
+ ui.h4("Skipping Filters", style="color: #dc2626;"),
173
+ ui.output_ui("skipping_checkboxes"),
174
+ ),
175
+ ui.div(
176
+ {"class": "filter-section"},
177
+ ui.h4("Structure Filters"),
178
+ ui.output_ui("structure_checkboxes"),
179
+ ),
180
+ ui.hr(),
181
+ ui.input_action_button("select_all", "Select All", class_="btn-sm"),
182
+ ui.input_action_button("deselect_all", "Deselect All", class_="btn-sm"),
183
+ ),
184
+ ),
185
+ ui.column(
186
+ 9,
187
+ ui.div(
188
+ {"class": "plot-container"},
189
+ ui.output_ui("silhouette_plot"),
190
+ ),
191
+ ),
192
+ ),
193
+ )
194
+
195
+ def server(input, output, session):
196
+ vis_data = reactive.Value(None)
197
+ error_message = reactive.Value(None)
198
+ params_received = reactive.Value(False)
199
+
200
+ @reactive.Effect
201
+ @reactive.event(input.pm_job_id)
202
+ async def on_params_received():
203
+ """Triggered when params are received via postMessage."""
204
+ job_id = input.pm_job_id()
205
+ batch_index = input.pm_batch_index()
206
+ print(f"[Silhouette] Received postMessage params: job_id={job_id}, batch_index={batch_index}", flush=True)
207
+
208
+ if not job_id:
209
+ error_message.set("Waiting for job parameters...")
210
+ return
211
+
212
+ params_received.set(True)
213
+
214
+ try:
215
+ url = f"{api_base_url}/api/vis_data/{job_id}"
216
+ if batch_index is not None:
217
+ url += f"?batch_index={batch_index}"
218
+
219
+ async with httpx.AsyncClient(timeout=60.0) as client:
220
+ response = await client.get(url)
221
+ response.raise_for_status()
222
+ data = response.json()
223
+ vis_data.set(data)
224
+ error_message.set(None) # Clear any error
225
+ except httpx.HTTPError as e:
226
+ error_message.set(f"Error fetching data: {str(e)}")
227
+ except Exception as e:
228
+ error_message.set(f"Unexpected error: {str(e)}")
229
+
230
+ @output
231
+ @render.ui
232
+ def inclusion_checkboxes():
233
+ data = vis_data.get()
234
+ if not data:
235
+ return ui.p("Loading...")
236
+
237
+ children = data["nucleotide_activations"]["children"]
238
+ all_filters = list_all_filters_collapsed(children)
239
+ incl_filters = [f for f in all_filters if f.startswith("incl_") and not f.startswith("incl_struct")]
240
+
241
+ return ui.input_checkbox_group(
242
+ "incl_filters",
243
+ None,
244
+ choices={f: f for f in incl_filters},
245
+ selected=incl_filters,
246
+ )
247
+
248
+ @output
249
+ @render.ui
250
+ def skipping_checkboxes():
251
+ data = vis_data.get()
252
+ if not data:
253
+ return ui.p("Loading...")
254
+
255
+ children = data["nucleotide_activations"]["children"]
256
+ all_filters = list_all_filters_collapsed(children)
257
+ skip_filters = [f for f in all_filters if f.startswith("skip_") and not f.startswith("skip_struct")]
258
+
259
+ return ui.input_checkbox_group(
260
+ "skip_filters",
261
+ None,
262
+ choices={f: f for f in skip_filters},
263
+ selected=skip_filters,
264
+ )
265
+
266
+ @output
267
+ @render.ui
268
+ def structure_checkboxes():
269
+ data = vis_data.get()
270
+ if not data:
271
+ return ui.p("Loading...")
272
+
273
+ children = data["nucleotide_activations"]["children"]
274
+ all_filters = list_all_filters_collapsed(children)
275
+ struct_filters = [f for f in all_filters if "struct" in f]
276
+
277
+ return ui.input_checkbox_group(
278
+ "struct_filters",
279
+ None,
280
+ choices={f: f for f in struct_filters},
281
+ selected=struct_filters,
282
+ )
283
+
284
+ @reactive.Effect
285
+ @reactive.event(input.select_all)
286
+ def select_all_filters():
287
+ data = vis_data.get()
288
+ if data:
289
+ children = data["nucleotide_activations"]["children"]
290
+ all_filters = list_all_filters_collapsed(children)
291
+ incl_filters = [f for f in all_filters if f.startswith("incl_") and not f.startswith("incl_struct")]
292
+ skip_filters = [f for f in all_filters if f.startswith("skip_") and not f.startswith("skip_struct")]
293
+ struct_filters = [f for f in all_filters if "struct" in f]
294
+ ui.update_checkbox_group("incl_filters", selected=incl_filters)
295
+ ui.update_checkbox_group("skip_filters", selected=skip_filters)
296
+ ui.update_checkbox_group("struct_filters", selected=struct_filters)
297
+
298
+ @reactive.Effect
299
+ @reactive.event(input.deselect_all)
300
+ def deselect_all_filters():
301
+ ui.update_checkbox_group("incl_filters", selected=[])
302
+ ui.update_checkbox_group("skip_filters", selected=[])
303
+ ui.update_checkbox_group("struct_filters", selected=[])
304
+
305
+ @output
306
+ @render.ui
307
+ def silhouette_plot():
308
+ err = error_message.get()
309
+ if err:
310
+ return ui.div({"class": "error-message"}, err)
311
+
312
+ data = vis_data.get()
313
+ if not data:
314
+ return ui.div({"class": "loading"}, "Waiting for job parameters...")
315
+
316
+ # Get selected filters
317
+ incl_selected = list(input.incl_filters()) if input.incl_filters() else []
318
+ skip_selected = list(input.skip_filters()) if input.skip_filters() else []
319
+ struct_selected = list(input.struct_filters()) if input.struct_filters() else []
320
+ all_selected = incl_selected + skip_selected + struct_selected
321
+
322
+ if not all_selected:
323
+ return ui.div(
324
+ {"class": "loading"},
325
+ "Select at least one filter to display the silhouette view."
326
+ )
327
+
328
+ # Extract data
329
+ full_seq = data["sequence"]
330
+ exon = data["exon"]
331
+ struct_full = data["structs"]
332
+ L = len(full_seq)
333
+ children = data["nucleotide_activations"]["children"]
334
+
335
+ # Calculate full range from all filters for fixed y-axis
336
+ incl_total_all, skip_total_all = parse_total_position_strengths(children, L)
337
+ start = full_seq.find(exon.replace("U", "T"))
338
+ if start == -1:
339
+ start = full_seq.upper().find(exon.upper().replace("U", "T"))
340
+ if start == -1:
341
+ start = 10 # Default flanking length
342
+ end = start + len(exon)
343
+
344
+ # Get full range for y-axis
345
+ incl_exon_all = incl_total_all[start:end]
346
+ skip_exon_all = skip_total_all[start:end]
347
+ y_max_all = max(
348
+ np.max(incl_exon_all) if len(incl_exon_all) > 0 else 0,
349
+ np.max(skip_exon_all) if len(skip_exon_all) > 0 else 0
350
+ )
351
+ y_max_all = max(y_max_all, 0.1)
352
+ y_min_all = -y_max_all
353
+
354
+ # Calculate values for selected filters
355
+ incl_total, skip_total = position_totals_for_selected_filters(children, L, all_selected)
356
+
357
+ # Create plot
358
+ x = np.arange(L)
359
+ bases = list(full_seq)
360
+
361
+ fig, ax = plt.subplots(figsize=(28, 8))
362
+
363
+ ax.bar(x, incl_total, width=1, color="#bed2fd", label="Inclusion")
364
+ ax.bar(x, -skip_total, width=1, color="#f0a5a5", label="Skipping")
365
+
366
+ # Shade exon region
367
+ ax.axvspan(start - 0.5, end - 0.5, color="#d0d0d0", alpha=0.15)
368
+ ax.axhline(0, linewidth=1, color='black')
369
+
370
+ ax.set_xticks(x)
371
+ ax.set_xticklabels(bases, fontsize=7)
372
+
373
+ # Add secondary structure on second x-axis
374
+ ax2 = ax.twiny()
375
+ ax2.set_xlim(ax.get_xlim())
376
+ ax2.xaxis.set_ticks_position("bottom")
377
+ ax2.xaxis.set_label_position("bottom")
378
+ ax2.spines["bottom"].set_position(("outward", 18))
379
+ ax2.spines["top"].set_visible(False)
380
+ ax2.spines["bottom"].set_visible(False)
381
+ ax2.set_xticks(x)
382
+ ax2.set_xticklabels(list(struct_full), fontsize=7)
383
+ ax2.tick_params(axis="x", length=0, pad=2)
384
+
385
+ # Fixed symmetric limits
386
+ ax.set_ylim(y_min_all, y_max_all)
387
+
388
+ # Integer tick marks
389
+ max_tick = int(np.ceil(max(abs(y_min_all), abs(y_max_all))))
390
+ ticks = np.arange(-max_tick, max_tick + 1, 1)
391
+ ax.set_yticks(ticks)
392
+ ax.set_yticklabels([str(abs(t)) for t in ticks])
393
+
394
+ ax.set_title("Silhouette View - Position-wise Filter Contributions")
395
+ ax.set_ylabel("Strength")
396
+ ax.legend(loc='upper right')
397
+
398
+ plt.tight_layout()
399
+
400
+ # Convert to HTML
401
+ import io
402
+ import base64
403
+ buf = io.BytesIO()
404
+ fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
405
+ buf.seek(0)
406
+ img_base64 = base64.b64encode(buf.read()).decode('utf-8')
407
+ plt.close(fig)
408
+
409
+ return ui.HTML(f'<img src="data:image/png;base64,{img_base64}" style="max-width: 100%; height: auto;" />')
410
+
411
+ return App(app_ui, server)
412
+
413
+
414
+ # Create the app instance
415
+ app = create_app()
webapp/data/datasets_data.json ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "ES7": {
3
+ "basal_shift": 0,
4
+ "pre_flanking_sequence": "CATCCAGGTT",
5
+ "post_flanking_sequence": "CAGGTCTGAC",
6
+ "number_of_datapoints": 47962,
7
+ "exon_length": 70,
8
+ "sequence_length": 90,
9
+ "r_squared": 0.9069368881066787
10
+ },
11
+ "WT1_exon_5": {
12
+ "basal_shift": -5.8,
13
+ "pre_flanking_sequence": "TTTCTAG",
14
+ "post_flanking_sequence": "GTGAGTG",
15
+ "number_of_datapoints": 5560,
16
+ "exon_length": 51,
17
+ "sequence_length": 65,
18
+ "r_squared": 0.7756628909179489
19
+ },
20
+ "FAS_exon_6": {
21
+ "basal_shift": 3.6,
22
+ "pre_flanking_sequence": "",
23
+ "post_flanking_sequence": "",
24
+ "number_of_datapoints": 794,
25
+ "exon_length": 63,
26
+ "sequence_length": 63,
27
+ "r_squared": 0.9020387508616958
28
+ },
29
+ "SMN2_exon_7": {
30
+ "basal_shift": -0.2,
31
+ "pre_flanking_sequence": "AACTTCCTTTATTTTCCTTACAG",
32
+ "post_flanking_sequence": "GTAAGTCTGCC",
33
+ "number_of_datapoints": 56,
34
+ "exon_length": 54,
35
+ "sequence_length": 88,
36
+ "r_squared": 0.6485961639623267
37
+ },
38
+ "BRCA2_exon_7": {
39
+ "basal_shift": 3.8,
40
+ "pre_flanking_sequence": "TTTCTTTCCTCCCAG",
41
+ "post_flanking_sequence": "GTAATAATAGCAAAT",
42
+ "number_of_datapoints": 31,
43
+ "exon_length": 115,
44
+ "sequence_length": 145,
45
+ "r_squared": 0.6915058670445455
46
+ },
47
+ "CFTR_exon_13": {
48
+ "basal_shift": 4.8,
49
+ "pre_flanking_sequence": "CCATTTTCTTTTTAG",
50
+ "post_flanking_sequence": "GTATGTTCTTTGAAT",
51
+ "number_of_datapoints": 22,
52
+ "exon_length": 87,
53
+ "sequence_length": 117,
54
+ "r_squared": 0.8591799198918351
55
+ }
56
+ }
webapp/data/model_data_18.json ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "link_midpoint": -21.80809438066695,
3
+ "num_seq_filters": 20,
4
+ "num_struct_filters": 8,
5
+ "seq_filter_width": 6,
6
+ "struct_filter_width": 30,
7
+ "incl_seq_groups": {
8
+ "1": [
9
+ 6,
10
+ 9,
11
+ 11,
12
+ 14
13
+ ],
14
+ "3": [
15
+ 4,
16
+ 5,
17
+ 8,
18
+ 16,
19
+ 17
20
+ ],
21
+ "5": [
22
+ 3,
23
+ 10,
24
+ 15
25
+ ],
26
+ "2": [
27
+ 0,
28
+ 19
29
+ ],
30
+ "4": [
31
+ 18
32
+ ],
33
+ "6": [
34
+ 1
35
+ ],
36
+ "7": [
37
+ 2,
38
+ 7
39
+ ],
40
+ "8": [
41
+ 12
42
+ ],
43
+ "9": [
44
+ 13
45
+ ]
46
+ },
47
+ "skip_seq_groups": {
48
+ "6": [
49
+ 1,
50
+ 5
51
+ ],
52
+ "4": [
53
+ 3,
54
+ 10,
55
+ 14,
56
+ 15,
57
+ 18
58
+ ],
59
+ "3": [
60
+ 2,
61
+ 4,
62
+ 8,
63
+ 13
64
+ ],
65
+ "5": [
66
+ 6,
67
+ 7
68
+ ],
69
+ "1": [
70
+ 0,
71
+ 17
72
+ ],
73
+ "2": [
74
+ 11,
75
+ 16
76
+ ],
77
+ "7": [
78
+ 12
79
+ ],
80
+ "8": [
81
+ 9
82
+ ],
83
+ "9": [
84
+ 19
85
+ ]
86
+ },
87
+ "incl_struct_groups": {
88
+ "1": [
89
+ 0,
90
+ 1,
91
+ 2,
92
+ 3,
93
+ 4,
94
+ 5,
95
+ 6,
96
+ 7
97
+ ]
98
+ },
99
+ "skip_struct_groups": {
100
+ "1": [
101
+ 1
102
+ ],
103
+ "2": [
104
+ 0,
105
+ 2,
106
+ 3
107
+ ],
108
+ "3": [
109
+ 5,
110
+ 6,
111
+ 7
112
+ ],
113
+ "4": [
114
+ 4
115
+ ]
116
+ },
117
+ "seq_logo_boundaries": {
118
+ "incl": [
119
+ {
120
+ "left": 2.0,
121
+ "right": 3.0,
122
+ "length": 2.0
123
+ },
124
+ {
125
+ "left": 0.0,
126
+ "right": 4.0,
127
+ "length": 5.0
128
+ },
129
+ {
130
+ "left": 0.0,
131
+ "right": 2.0,
132
+ "length": 3.0
133
+ },
134
+ {
135
+ "left": 2.0,
136
+ "right": 5.0,
137
+ "length": 4.0
138
+ },
139
+ {
140
+ "left": 2.0,
141
+ "right": 5.0,
142
+ "length": 4.0
143
+ },
144
+ {
145
+ "left": 1.0,
146
+ "right": 2.0,
147
+ "length": 2.0
148
+ },
149
+ {
150
+ "left": 0.0,
151
+ "right": 3.0,
152
+ "length": 4.0
153
+ },
154
+ {
155
+ "left": 1.0,
156
+ "right": 3.0,
157
+ "length": 3.0
158
+ },
159
+ {
160
+ "left": 0.0,
161
+ "right": 1.0,
162
+ "length": 2.0
163
+ },
164
+ {
165
+ "left": 1.0,
166
+ "right": 5.0,
167
+ "length": 5.0
168
+ },
169
+ {
170
+ "left": 2.0,
171
+ "right": 4.0,
172
+ "length": 3.0
173
+ },
174
+ {
175
+ "left": 1.0,
176
+ "right": 4.0,
177
+ "length": 4.0
178
+ },
179
+ {
180
+ "left": 0.0,
181
+ "right": 3.0,
182
+ "length": 4.0
183
+ },
184
+ {
185
+ "left": 2.0,
186
+ "right": 4.0,
187
+ "length": 3.0
188
+ },
189
+ {
190
+ "left": 0.0,
191
+ "right": 3.0,
192
+ "length": 4.0
193
+ },
194
+ {
195
+ "left": 2.0,
196
+ "right": 4.0,
197
+ "length": 3.0
198
+ },
199
+ {
200
+ "left": 0.0,
201
+ "right": 2.0,
202
+ "length": 3.0
203
+ },
204
+ {
205
+ "left": 0.0,
206
+ "right": 3.0,
207
+ "length": 4.0
208
+ },
209
+ {
210
+ "left": 1.0,
211
+ "right": 4.0,
212
+ "length": 4.0
213
+ },
214
+ {
215
+ "left": 0.0,
216
+ "right": 5.0,
217
+ "length": 6.0
218
+ }
219
+ ],
220
+ "skip": [
221
+ {
222
+ "left": 0.0,
223
+ "right": 3.0,
224
+ "length": 4.0
225
+ },
226
+ {
227
+ "left": 1.0,
228
+ "right": 3.0,
229
+ "length": 3.0
230
+ },
231
+ {
232
+ "left": 2.0,
233
+ "right": 4.0,
234
+ "length": 3.0
235
+ },
236
+ {
237
+ "left": 3.0,
238
+ "right": 4.0,
239
+ "length": 2.0
240
+ },
241
+ {
242
+ "left": 3.0,
243
+ "right": 4.0,
244
+ "length": 2.0
245
+ },
246
+ {
247
+ "left": 0.0,
248
+ "right": 2.0,
249
+ "length": 3.0
250
+ },
251
+ {
252
+ "left": 0.0,
253
+ "right": 5.0,
254
+ "length": 6.0
255
+ },
256
+ {
257
+ "left": 1.0,
258
+ "right": 3.0,
259
+ "length": 3.0
260
+ },
261
+ {
262
+ "left": 0.0,
263
+ "right": 5.0,
264
+ "length": 6.0
265
+ },
266
+ {
267
+ "left": 0.0,
268
+ "right": 3.0,
269
+ "length": 4.0
270
+ },
271
+ {
272
+ "left": 0.0,
273
+ "right": 2.0,
274
+ "length": 3.0
275
+ },
276
+ {
277
+ "left": 0.0,
278
+ "right": 5.0,
279
+ "length": 6.0
280
+ },
281
+ {
282
+ "left": 0.0,
283
+ "right": 5.0,
284
+ "length": 6.0
285
+ },
286
+ {
287
+ "left": 1.0,
288
+ "right": 5.0,
289
+ "length": 5.0
290
+ },
291
+ {
292
+ "left": 1.0,
293
+ "right": 3.0,
294
+ "length": 3.0
295
+ },
296
+ {
297
+ "left": 2.0,
298
+ "right": 4.0,
299
+ "length": 3.0
300
+ },
301
+ {
302
+ "left": 0.0,
303
+ "right": 5.0,
304
+ "length": 6.0
305
+ },
306
+ {
307
+ "left": 1.0,
308
+ "right": 5.0,
309
+ "length": 5.0
310
+ },
311
+ {
312
+ "left": 0.0,
313
+ "right": 2.0,
314
+ "length": 3.0
315
+ },
316
+ {
317
+ "left": 1.0,
318
+ "right": 4.0,
319
+ "length": 4.0
320
+ }
321
+ ]
322
+ },
323
+ "struct_logo_boundaries": {
324
+ "incl": [
325
+ {
326
+ "left": 0.0,
327
+ "right": 29.0,
328
+ "length": 30.0
329
+ },
330
+ {
331
+ "left": 0.0,
332
+ "right": 29.0,
333
+ "length": 30.0
334
+ },
335
+ {
336
+ "left": 0.0,
337
+ "right": 29.0,
338
+ "length": 30.0
339
+ },
340
+ {
341
+ "left": 0.0,
342
+ "right": 29.0,
343
+ "length": 30.0
344
+ },
345
+ {
346
+ "left": 0.0,
347
+ "right": 29.0,
348
+ "length": 30.0
349
+ },
350
+ {
351
+ "left": 0.0,
352
+ "right": 29.0,
353
+ "length": 30.0
354
+ },
355
+ {
356
+ "left": 0.0,
357
+ "right": 29.0,
358
+ "length": 30.0
359
+ },
360
+ {
361
+ "left": 0.0,
362
+ "right": 29.0,
363
+ "length": 30.0
364
+ }
365
+ ],
366
+ "skip": [
367
+ {
368
+ "left": 3.0,
369
+ "right": 17.0,
370
+ "length": 15.0
371
+ },
372
+ {
373
+ "left": 0.0,
374
+ "right": 29.0,
375
+ "length": 30.0
376
+ },
377
+ {
378
+ "left": 5.0,
379
+ "right": 16.0,
380
+ "length": 12.0
381
+ },
382
+ {
383
+ "left": 5.0,
384
+ "right": 25.0,
385
+ "length": 21.0
386
+ },
387
+ {
388
+ "left": 0.0,
389
+ "right": 29.0,
390
+ "length": 30.0
391
+ },
392
+ {
393
+ "left": 0.0,
394
+ "right": 29.0,
395
+ "length": 30.0
396
+ },
397
+ {
398
+ "left": 0.0,
399
+ "right": 29.0,
400
+ "length": 30.0
401
+ },
402
+ {
403
+ "left": 0.0,
404
+ "right": 29.0,
405
+ "length": 30.0
406
+ }
407
+ ]
408
+ }
409
+ }
webapp/static/filters/incl_1.png ADDED
webapp/static/filters/incl_2.png ADDED
webapp/static/filters/incl_3.png ADDED
webapp/static/filters/incl_4.png ADDED
webapp/static/filters/incl_5.png ADDED
webapp/static/filters/incl_6.png ADDED
webapp/static/filters/incl_7.png ADDED
webapp/static/filters/incl_8.png ADDED
webapp/static/filters/incl_9.png ADDED
webapp/static/filters/skip_1.png ADDED
webapp/static/filters/skip_2.png ADDED
webapp/static/filters/skip_3.png ADDED
webapp/static/filters/skip_4.png ADDED
webapp/static/filters/skip_5.png ADDED
webapp/static/filters/skip_6.png ADDED
webapp/static/filters/skip_7.png ADDED
webapp/static/filters/skip_8.png ADDED
webapp/static/filters/skip_9.png ADDED
webapp/static/js/result.js CHANGED
@@ -93,7 +93,19 @@ function displayResults(data) {
93
  sequenceEl.textContent = data.sequence || 'N/A';
94
 
95
  // Force plot
96
- if (data.force_plot && data.force_plot.length > 0) {
 
 
 
 
 
 
 
 
 
 
 
 
97
  createForcePlot(data.force_plot);
98
  }
99
  }
 
93
  sequenceEl.textContent = data.sequence || 'N/A';
94
 
95
  // Force plot
96
+ if (data.force_plot_data && data.force_plot_data.activations) {
97
+ const activations = data.force_plot_data.activations;
98
+ // Combine qc_incl and qc_skip to get position-wise contribution
99
+ // Positive = promotes inclusion (green), Negative = promotes skipping (red)
100
+ if (activations.qc_incl && activations.qc_skip) {
101
+ const forceData = activations.qc_incl.map((incl, i) => {
102
+ const skip = activations.qc_skip[i] || 0;
103
+ return incl - skip;
104
+ });
105
+ createForcePlot(forceData);
106
+ }
107
+ } else if (data.force_plot && data.force_plot.length > 0) {
108
+ // Fallback for legacy format
109
  createForcePlot(data.force_plot);
110
  }
111
  }
webapp/templates/result.html CHANGED
@@ -3,7 +3,7 @@
3
  {% block title %}Prediction Result - {{ settings.app_name }}{% endblock %}
4
 
5
  {% block content %}
6
- <div class="max-w-5xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
7
  <!-- Header -->
8
  <div class="mb-6">
9
  <nav class="flex" aria-label="Breadcrumb">
@@ -70,34 +70,71 @@
70
  <p id="sequence" class="font-mono text-sm break-all text-gray-900 bg-gray-50 p-3 rounded"></p>
71
  </div>
72
 
73
- <!-- Force Plot -->
74
- <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
75
- <h3 class="text-sm font-medium text-gray-500 uppercase tracking-wide mb-4">
76
- Position-wise Contribution to PSI
77
- <span class="ml-2 text-gray-400 font-normal normal-case">
78
- (green = promotes inclusion, red = promotes skipping)
79
- </span>
80
- </h3>
81
- <div id="force-plot" class="w-full" style="height: 400px;"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  </div>
83
 
84
  <!-- Filter Activation Heatmap -->
85
- <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
86
- <h3 class="text-sm font-medium text-gray-500 uppercase tracking-wide mb-4">
87
- Filter Activation Heatmap
88
- <span class="ml-2 text-gray-400 font-normal normal-case">
89
- (shows where each neural network filter activates across the sequence)
90
- </span>
91
- </h3>
92
- <div id="heatmap-container" class="w-full" style="min-height: 500px;">
93
- <iframe
94
- id="heatmap-iframe"
95
- src="/shiny/heatmap/?job_id={{ job_id }}"
96
- class="w-full border-0"
97
- style="height: 600px; min-height: 500px;"
98
- loading="lazy"
99
- title="Filter Activation Heatmap"
100
- ></iframe>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  </div>
102
  </div>
103
 
@@ -144,6 +181,69 @@
144
  <script>
145
  const jobId = "{{ job_id }}";
146
  const batchIndex = {{ batch_index if batch_index is defined and batch_index is not none else 'null' }};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  </script>
148
  <script src="/static/js/result.js"></script>
149
  {% endblock %}
 
3
  {% block title %}Prediction Result - {{ settings.app_name }}{% endblock %}
4
 
5
  {% block content %}
6
+ <div class="max-w-[1920px] mx-auto py-8 px-4 sm:px-6 lg:px-8">
7
  <!-- Header -->
8
  <div class="mb-6">
9
  <nav class="flex" aria-label="Breadcrumb">
 
70
  <p id="sequence" class="font-mono text-sm break-all text-gray-900 bg-gray-50 p-3 rounded"></p>
71
  </div>
72
 
73
+ <!-- Silhouette View -->
74
+ <div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
75
+ <div class="p-4 border-b border-gray-200 flex items-center justify-between cursor-pointer" onclick="toggleSection('silhouette')">
76
+ <div>
77
+ <h3 class="text-sm font-medium text-gray-500 uppercase tracking-wide">
78
+ Silhouette View
79
+ <span class="ml-2 text-gray-400 font-normal normal-case">
80
+ (blue = inclusion strength, red = skipping strength per position)
81
+ </span>
82
+ </h3>
83
+ </div>
84
+ <button type="button" class="text-gray-400 hover:text-gray-600" id="silhouette-toggle">
85
+ <svg id="silhouette-chevron-down" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
86
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
87
+ </svg>
88
+ <svg id="silhouette-chevron-up" class="h-5 w-5 hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor">
89
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
90
+ </svg>
91
+ </button>
92
+ </div>
93
+ <div id="silhouette-content" class="p-6">
94
+ <div id="silhouette-container" class="w-full" style="min-height: 650px;">
95
+ <iframe
96
+ id="silhouette-iframe"
97
+ src="/shiny/silhouette/?job_id={{ job_id }}{% if batch_index is defined and batch_index is not none %}&batch_index={{ batch_index }}{% endif %}"
98
+ class="w-full border-0"
99
+ style="height: 650px; min-height: 600px;"
100
+ loading="lazy"
101
+ title="Silhouette View"
102
+ ></iframe>
103
+ </div>
104
+ </div>
105
  </div>
106
 
107
  <!-- Filter Activation Heatmap -->
108
+ <div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
109
+ <div class="p-4 border-b border-gray-200 flex items-center justify-between cursor-pointer" onclick="toggleSection('heatmap')">
110
+ <div>
111
+ <h3 class="text-sm font-medium text-gray-500 uppercase tracking-wide">
112
+ Filter Activation Heatmap
113
+ <span class="ml-2 text-gray-400 font-normal normal-case">
114
+ (blue = inclusion, red = skipping, with filter icons)
115
+ </span>
116
+ </h3>
117
+ </div>
118
+ <button type="button" class="text-gray-400 hover:text-gray-600" id="heatmap-toggle">
119
+ <svg id="heatmap-chevron-down" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
120
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
121
+ </svg>
122
+ <svg id="heatmap-chevron-up" class="h-5 w-5 hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor">
123
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
124
+ </svg>
125
+ </button>
126
+ </div>
127
+ <div id="heatmap-content" class="p-6">
128
+ <div id="heatmap-container" class="w-full" style="min-height: 900px;">
129
+ <iframe
130
+ id="heatmap-iframe"
131
+ src="/shiny/heatmap/?job_id={{ job_id }}{% if batch_index is defined and batch_index is not none %}&batch_index={{ batch_index }}{% endif %}"
132
+ class="w-full border-0"
133
+ style="height: 900px; min-height: 850px;"
134
+ loading="lazy"
135
+ title="Filter Activation Heatmap"
136
+ ></iframe>
137
+ </div>
138
  </div>
139
  </div>
140
 
 
181
  <script>
182
  const jobId = "{{ job_id }}";
183
  const batchIndex = {{ batch_index if batch_index is defined and batch_index is not none else 'null' }};
184
+
185
+ // Toggle collapsible sections
186
+ function toggleSection(section) {
187
+ const content = document.getElementById(section + '-content');
188
+ const chevronDown = document.getElementById(section + '-chevron-down');
189
+ const chevronUp = document.getElementById(section + '-chevron-up');
190
+
191
+ if (content.classList.contains('hidden')) {
192
+ content.classList.remove('hidden');
193
+ chevronDown.classList.remove('hidden');
194
+ chevronUp.classList.add('hidden');
195
+ } else {
196
+ content.classList.add('hidden');
197
+ chevronDown.classList.add('hidden');
198
+ chevronUp.classList.remove('hidden');
199
+ }
200
+ }
201
+
202
+ // Send params to PyShiny iframes via postMessage
203
+ function sendParamsToIframe(iframe) {
204
+ if (iframe && iframe.contentWindow) {
205
+ console.log('[Result] Sending params to iframe:', jobId, batchIndex);
206
+ iframe.contentWindow.postMessage({
207
+ type: 'setParams',
208
+ job_id: jobId,
209
+ batch_index: batchIndex
210
+ }, '*');
211
+ }
212
+ }
213
+
214
+ // Listen for 'ready' message from iframes
215
+ window.addEventListener('message', function(event) {
216
+ if (event.data && event.data.type === 'ready') {
217
+ console.log('[Result] Received ready from:', event.data.source);
218
+ // Send params back to the iframe that signaled ready
219
+ if (event.source) {
220
+ event.source.postMessage({
221
+ type: 'setParams',
222
+ job_id: jobId,
223
+ batch_index: batchIndex
224
+ }, '*');
225
+ }
226
+ }
227
+ });
228
+
229
+ // Also send on iframe load (backup)
230
+ document.addEventListener('DOMContentLoaded', function() {
231
+ const silhouetteIframe = document.getElementById('silhouette-iframe');
232
+ const heatmapIframe = document.getElementById('heatmap-iframe');
233
+
234
+ if (silhouetteIframe) {
235
+ silhouetteIframe.onload = function() {
236
+ // Small delay to ensure Shiny is ready
237
+ setTimeout(function() { sendParamsToIframe(silhouetteIframe); }, 500);
238
+ };
239
+ }
240
+ if (heatmapIframe) {
241
+ heatmapIframe.onload = function() {
242
+ // Small delay to ensure Shiny is ready
243
+ setTimeout(function() { sendParamsToIframe(heatmapIframe); }, 500);
244
+ };
245
+ }
246
+ });
247
  </script>
248
  <script src="/static/js/result.js"></script>
249
  {% endblock %}