Erva Ulusoy commited on
Commit
475bc81
·
1 Parent(s): 614205a

redesign app as a three-step interface

Browse files
Files changed (4) hide show
  1. .streamlit/config.toml +14 -1
  2. ProtHGT_app.py +564 -705
  3. pages/User_Guide.py +43 -232
  4. ui.py +164 -0
.streamlit/config.toml CHANGED
@@ -1,2 +1,15 @@
1
  [theme]
2
- base="light"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  [theme]
2
+ base = "light"
3
+ primaryColor = "#3B4BD8"
4
+ backgroundColor = "#FFFFFF"
5
+ secondaryBackgroundColor = "#F3F2EE"
6
+ textColor = "#1A1C24"
7
+ font = "sans serif"
8
+
9
+ [client]
10
+ # Navigation is rendered in the page header (see ui.py), so hide the sidebar page list.
11
+ showSidebarNavigation = false
12
+ toolbarMode = "minimal"
13
+
14
+ [server]
15
+ maxUploadSize = 5
ProtHGT_app.py CHANGED
@@ -1,723 +1,582 @@
 
1
  import os
2
- import streamlit as st
3
- from rapidfuzz import process
4
  import random
 
5
 
6
- # with st.spinner("Initializing the environment... This may take up to 10 minutes at the start of each session."):
7
- # # Create a temporary placeholder for the message
8
- # loading_placeholder = st.empty()
9
-
10
- # # Show the info message only while the spinner is active
11
- # loading_placeholder.info("""
12
- # **Note:** This initialization is required at the start of each session.
13
- # Once the app is ready, you can run multiple predictions without re-initializing by clicking the **Reset** button in the sidebar.
14
- # """)
15
-
16
- # # Run setup script if not already executed
17
- # if not os.path.exists(".setup_done"):
18
- # start_time = time.time()
19
- # os.system("bash setup.sh")
20
- # end_time = time.time()
21
- # print(f"Environment prepared in {end_time - start_time:.2f} seconds")
22
- # with open(".setup_done", "w") as f:
23
- # f.write("done")
24
-
25
- # # Remove the info message after initialization is complete
26
- # loading_placeholder.empty()
27
-
28
- from run_prothgt_app import *
29
- from visualize_kg import *
30
-
31
- def convert_df(df):
32
- return df.to_csv(index=False).encode('utf-8')
33
-
34
- # Initialize session state variables
35
- if 'predictions_df' not in st.session_state:
36
- st.session_state.predictions_df = None
37
- if 'heterodata' not in st.session_state:
38
- st.session_state.heterodata = None
39
- if 'submitted' not in st.session_state:
40
- st.session_state.submitted = False
41
- if 'previous_inputs' not in st.session_state:
42
- st.session_state.previous_inputs = None
43
- if 'generating_predictions' not in st.session_state:
44
- st.session_state.generating_predictions = False
45
- if 'protein_visualizations' not in st.session_state:
46
- st.session_state.protein_visualizations = {}
47
-
48
-
49
- def reset_prediction_state():
50
- st.session_state.generating_predictions = False
51
- st.session_state.submitted = False
52
- st.session_state.predictions_df = None
53
- st.session_state.previous_inputs = None
54
- # Clean up visualization files
55
- if 'protein_visualizations' in st.session_state:
56
- for viz_info in st.session_state.protein_visualizations.values():
57
- try:
58
- os.unlink(viz_info['path'])
59
- except:
60
- pass
61
- st.session_state.protein_visualizations = {}
62
-
63
- def set_generating_predictions():
64
- st.session_state.generating_predictions = True
65
- st.session_state.submitted = True
66
-
67
- with st.expander("🚀 Upcoming Features"):
68
- st.info("""
69
- We are actively working on enhancing ProtHGT application with new capabilities:
70
-
71
- - **Real-time data retrieval for new proteins**: Currently, ProtHGT can only generate predictions for proteins that already exist in our knowledge graph. We are developing a new feature that will allow users to **predict functions for entirely new proteins starting from their sequences**. This will work by **retrieving relevant relationship data in real time from external source databases** (e.g., UniProt, STRING, and other biological repositories). The system will dynamically construct a knowledge graph for the query protein, incorporating its interactions, domains, pathways, and other biological associations before running function prediction. This approach will enable ProtHGT to analyze newly discovered or less-studied proteins even if they are not pre-annotated in our dataset.
72
- - **Expanded embedding options**: Currently, this application represents proteins using **TAPE embeddings**, which serve as the initial numerical representations of protein sequences before being processed in the heterogeneous graph model. We are working on integrating **ProtT5** and **ESM-2** as alternative initial embeddings, allowing users to choose different sequence representations that may enhance performance for specific tasks. A detailed comparison of how these embeddings influence function prediction accuracy will be included in our upcoming publication.
73
-
74
- Stay tuned for updates and future publications!
75
- """)
76
- st.success("""
77
- ✅ **Knowledge graph visualization for interpretability** – Now available! Explore the biological relationships behind ProtHGT's predictions interactively. For each query protein, you can inspect protein interactions, GO annotations, domains, pathways, and other key connections in a structured graphical format. Navigate to the **"View Knowledge Graphs"** tab after generating predictions to try it out.
78
- """)
79
-
80
- with st.sidebar:
81
-
82
- disabled = st.session_state.generating_predictions
83
-
84
- st.markdown("""
85
- <style>
86
- .title {
87
- font-size: 35px;
88
- font-weight: bold;
89
- color: #424242;
90
- margin-bottom: 0px;
91
- }
92
- .subtitle {
93
- font-size: 20px;
94
- color: #424242;
95
- margin-bottom: 20px;
96
- line-height: 1.5;
97
- }
98
- .badges {
99
- margin-top: 10px;
100
- margin-bottom: 20px;
101
- }
102
- </style>
103
-
104
- <div class="title">ProtHGT</div>
105
- <div class="subtitle">Heterogeneous Graph Transformers for Automated Protein Function Prediction Using Knowledge Graphs and Language Models</div>
106
- <div class="badges">
107
- <a href="https://github.com/HUBioDataLab/ProtHGT">
108
- <img src="https://img.shields.io/badge/GitHub-black?logo=github" alt="github-repository">
109
- </a>
110
- </div>
111
- """, unsafe_allow_html=True)
112
-
113
- available_proteins = get_available_proteins()
114
-
115
- if 'example_proteins' not in st.session_state:
116
- st.session_state.example_proteins = random.sample(available_proteins, 5)
117
-
118
- selected_proteins = []
119
-
120
- # Add protein selection methods
121
- selection_method = st.radio(
122
- "Choose input method:",
123
- ["Use example query", "Search proteins", "Upload protein ID file"],
124
- disabled=disabled
125
- )
126
 
127
- if selection_method == "Use example query":
128
- selected_proteins = st.session_state.example_proteins
129
- st.write(f"Selected proteins:")
130
- st.markdown(
131
- f"""
132
- <div style="
133
- height: 150px;
134
- overflow-y: scroll;
135
- border: 1px solid #ccc;
136
- border-radius: 4px;
137
- padding: 8px;
138
- margin-bottom: 16px;
139
- background-color: white;">
140
- {'<br>'.join(selected_proteins)}
141
- </div>
142
- """,
143
- unsafe_allow_html=True
144
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
- elif selection_method == "Search proteins":
147
- # User enters search term
148
- search_query = st.text_input(
149
- "1\\. Start typing a protein ID (at least 3 characters) and press Enter to see search results in the dropdown menu below (2)",
150
- "",
151
- disabled=disabled
152
- )
153
 
154
- # Initialize selected_proteins in session state if not exists
155
- if 'selected_proteins_search' not in st.session_state:
156
- st.session_state.selected_proteins_search = []
157
-
158
- # Apply fuzzy search only if query length is >= 3
159
- filtered_proteins = []
160
- if len(search_query) >= 3:
161
- # Case-insensitive search by converting query and proteins to lowercase
162
- matches = process.extract(
163
- search_query.upper(),
164
- {p: p.upper() for p in available_proteins},
165
- limit=50
166
- )
167
- filtered_proteins = [match[0] for match in matches] # Show top 50 matches
168
-
169
- with st.container():
170
- # Include previously selected proteins in options
171
- all_options = list(set(filtered_proteins + st.session_state.selected_proteins_search))
172
-
173
- selected_proteins = st.multiselect(
174
- "2\\. Select proteins from search results",
175
- options=all_options,
176
- default=st.session_state.selected_proteins_search,
177
- placeholder="Start typing a protein ID above (1) to see search results...",
178
- max_selections=100,
179
- disabled=disabled,
180
- key="protein_selector"
181
- )
182
-
183
- # Update session state with current selection
184
- st.session_state.selected_proteins_search = selected_proteins
185
-
186
- # Apply custom CSS to make container scrollable
187
- st.markdown("""
188
- <style>
189
- div[data-testid="stMultiSelect"] div:nth-child(2) {
190
- max-height: 250px;
191
- overflow-y: auto;
192
- }
193
- </style>
194
- """, unsafe_allow_html=True)
195
-
196
- else: # Upload file option
197
- uploaded_file = st.file_uploader(
198
- "Upload a text file with UniProt IDs (one per line, max 100)*",
199
- type=['txt'],
200
- disabled=disabled
201
- )
202
 
203
- if uploaded_file:
204
- protein_list = [line.strip() for line in uploaded_file.read().decode('utf-8').splitlines()]
205
-
206
- # Remove empty lines and duplicates
207
- protein_list = list(filter(None, protein_list))
208
- protein_list = list(dict.fromkeys(protein_list))
209
-
210
- # Check for proteins not in available_proteins
211
- proteins_not_found = [p for p in protein_list if p not in available_proteins]
212
- # Filter to keep only available proteins
213
- protein_list = [p for p in protein_list if p in available_proteins]
214
-
215
- if len(protein_list) > 100:
216
- st.error("Please upload a file with maximum 100 protein IDs.")
217
- selected_proteins = []
218
- else:
219
- selected_proteins = protein_list
220
- st.write(f"Loaded {len(selected_proteins)} proteins")
221
-
222
- if proteins_not_found:
223
- st.warning(f"""
224
- The following proteins were not found in our input knowledge graph and have been discarded:
225
- """)
226
- with st.expander("View Discarded Proteins"):
227
- # Create scrollable container with fixed height
228
- st.markdown(
229
- f"""
230
- <div style="
231
- height: 150px;
232
- overflow-y: scroll;
233
- border: 1px solid #ccc;
234
- border-radius: 4px;
235
- padding: 8px;
236
- margin-bottom: 16px;
237
- background-color: white;">
238
- {'<br>'.join(proteins_not_found)}
239
- </div>
240
- """,
241
- unsafe_allow_html=True
242
- )
243
-
244
- st.warning(f"""
245
- Currently, our system can only generate predictions for proteins that are already included in our knowledge graph. **Real-time retrieval of relationship data from external source databases is not yet supported.**
246
- We are actively working on integrating this capability in future updates. Stay tuned!
247
- """)
248
-
249
- if selected_proteins:
250
- st.write(f"Total proteins selected: {len(selected_proteins)}")
251
- # Add download button
252
- proteins_text = '\n'.join(selected_proteins)
253
- st.download_button(
254
- label="Download Selected Proteins List",
255
- data=proteins_text,
256
- file_name="selected_proteins.txt",
257
- mime="text/plain",
258
- key="download_selected_proteins"
259
- )
260
 
261
- # Add GO category selection
262
- go_category_options = {
263
- 'All Categories': None,
264
- 'Molecular Function': 'GO_term_F',
265
- 'Biological Process': 'GO_term_P',
266
- 'Cellular Component': 'GO_term_C'
267
- }
268
- selected_go_category = st.selectbox(
269
- "Select GO Category for predictions",
270
- options=list(go_category_options.keys()),
271
- help="Choose which GO category to generate predictions for. Selecting 'All Categories' will generate predictions for all three categories.",
272
- disabled=disabled
273
- )
274
 
275
- generation_threshold = st.number_input(
276
- "Generation threshold (optional)",
277
- min_value=0.0,
278
- max_value=1.0,
279
- value=0.0,
280
- step=0.05,
281
- help="If > 0, only predictions with Probability >= threshold are generated. This reduces output size and speeds up the app.",
282
- disabled=disabled,
283
- )
284
 
285
- if selected_proteins and selected_go_category:
286
-
287
- button_disabled = st.session_state.submitted
288
-
289
- # Add custom CSS for red button
290
- st.markdown("""
291
- <style>
292
- div.stButton > button:first-child {
293
- background-color: #ff4b4b;
294
- color: white;
295
- }
296
- div.stButton > button:hover {
297
- background-color: #ff0000;
298
- color: white;
299
- }
300
- div.stButton > button:focus {
301
- background-color: #ff0000;
302
- color: white;
303
- }
304
- </style>
305
- """, unsafe_allow_html=True)
306
-
307
- if st.button("Generate Predictions",
308
- disabled=button_disabled,
309
- key="generate_predictions",
310
- on_click=set_generating_predictions):
311
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
- # Create a tuple of current inputs to track changes
314
- current_inputs = (tuple(selected_proteins), selected_go_category)
315
-
316
- # Check if inputs have changed
317
- if st.session_state.previous_inputs != current_inputs:
318
- st.session_state.predictions_df = None
319
- st.session_state.submitted = False
320
- st.session_state.previous_inputs = current_inputs
321
-
322
- st.warning("⚠️ Due to memory and computational constraints, the maximum number of proteins that can be processed at once is limited to 100 proteins. For larger datasets, please consider running the model locally using our [GitHub repository](https://github.com/HUBioDataLab/ProtHGT).")
323
-
324
- if st.session_state.submitted:
325
- kg_path = "data/prothgt-kg.pt"
326
- first_run = not os.path.exists(kg_path)
327
-
328
- with st.spinner("Downloading dataset files for the first time, this may take a few minutes..." if first_run else "Generating predictions..."):
329
- # Generate predictions only if not already in session state
330
- if st.session_state.predictions_df is None:
331
-
332
- # Load model config from JSON file
333
- import json
334
- import os
335
-
336
- # Define data directory path
337
- data_dir = "data"
338
- models_dir = os.path.join(data_dir, "models")
339
-
340
- # Load model configuration
341
- model_config_paths = {
342
- 'GO_term_F': os.path.join(models_dir, "prothgt-config-molecular-function.yaml"),
343
- 'GO_term_P': os.path.join(models_dir, "prothgt-config-biological-process.yaml"),
344
- 'GO_term_C': os.path.join(models_dir, "prothgt-config-cellular-component.yaml")
345
- }
346
-
347
- # Paths for model and data
348
- model_paths = {
349
- 'GO_term_F': os.path.join(models_dir, "prothgt-model-molecular-function.pt"),
350
- 'GO_term_P': os.path.join(models_dir, "prothgt-model-biological-process.pt"),
351
- 'GO_term_C': os.path.join(models_dir, "prothgt-model-cellular-component.pt")
352
- }
353
-
354
- # Get the selected GO category
355
- go_category = go_category_options[selected_go_category]
356
-
357
- # If a specific category is selected, use that model path
358
- if go_category:
359
- model_config_paths = [model_config_paths[go_category]]
360
- model_paths = [model_paths[go_category]]
361
- go_categories = [go_category]
362
- else:
363
- model_config_paths = [model_config_paths[cat] for cat in ['GO_term_F', 'GO_term_P', 'GO_term_C']]
364
- model_paths = [model_paths[cat] for cat in ['GO_term_F', 'GO_term_P', 'GO_term_C']]
365
- go_categories = ['GO_term_F', 'GO_term_P', 'GO_term_C']
366
-
367
- # Generate predictions
368
- heterodata, predictions_df = generate_prediction_df(
369
- protein_ids=selected_proteins,
370
- model_paths=model_paths,
371
- model_config_paths=model_config_paths,
372
- go_category=go_categories,
373
- threshold=generation_threshold,
374
- )
375
 
376
- st.session_state.heterodata = heterodata
377
- st.session_state.predictions_df = predictions_df
378
-
379
- # Reset only the generating_predictions flag to release the sidebar
380
- st.session_state.generating_predictions = False
381
- st.rerun()
382
-
383
- # Display and filter predictions
384
- st.success("Predictions generated successfully!")
385
-
386
- # tabs for predictions and visualizations
387
- predictions_tab, kg_viz_tab = st.tabs(["View Predictions", "View Knowledge Graphs"])
388
-
389
- with predictions_tab:
390
- st.markdown("### Filter and View Predictions")
391
-
392
-
393
- # Create filters
394
- col1, col2, col3, col4 = st.columns(4)
395
-
396
- with col1:
397
- # Extract UniProt IDs from URLs for the selectbox
398
- uniprot_ids = st.session_state.predictions_df['UniProt_ID'].unique().tolist()
399
-
400
- # Protein filter
401
- selected_protein = st.selectbox(
402
- "Filter by Protein",
403
- options=['All'] + sorted(uniprot_ids)
404
- )
405
 
406
- with col2:
407
- # GO category filter
408
- selected_category = st.selectbox(
409
- "Filter by GO Category",
410
- options=['All'] + sorted(st.session_state.predictions_df['GO_category'].unique().tolist())
411
- )
412
 
413
- with col3:
414
- # GO term filter
415
- go_term_filter = st.text_input(
416
- "Filter by GO Term ID",
417
- placeholder="e.g., GO:0003674",
418
- help="Enter a GO term ID to filter results"
419
- ).strip()
420
-
421
- with col4:
422
- # Probability threshold range slider
423
- probability_range = st.slider(
424
- "Probability Range",
425
- min_value=0.0,
426
- max_value=1.0,
427
- value=(0.5, 1.0), # (min, max) default values
428
- step=0.05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  )
430
- min_probability_threshold, max_probability_threshold = probability_range
431
-
432
- # Filter the dataframe using session state data
433
- filtered_df = st.session_state.predictions_df.copy()
434
-
435
- if selected_protein != 'All':
436
- filtered_df = filtered_df[filtered_df['UniProt_ID'].str.contains(selected_protein)]
437
-
438
- if selected_category != 'All':
439
- filtered_df = filtered_df[filtered_df['GO_category'] == selected_category]
440
-
441
- if go_term_filter:
442
- filtered_df = filtered_df[filtered_df['GO_ID'] == go_term_filter]
443
-
444
- filtered_df = filtered_df[(filtered_df['Probability'] >= min_probability_threshold) &
445
- (filtered_df['Probability'] <= max_probability_threshold)]
446
-
447
- filtered_df['UniProt_ID'] = [f"https://www.uniprot.org/uniprotkb/{pid}/entry" for pid in filtered_df['UniProt_ID']]
448
- filtered_df['GO_ID'] = [f"https://www.ebi.ac.uk/QuickGO/term/{go_id}" for go_id in filtered_df['GO_ID']]
449
-
450
- # Custom CSS to increase table width and improve layout
451
- st.markdown("""
452
- <style>
453
- .stDataFrame {
454
- width: 100%;
455
- }
456
- .stDataFrame > div {
457
- width: 100%;
458
- }
459
- .stDataFrame [data-testid="stDataFrameResizable"] {
460
- width: 100%;
461
- min-width: 100%;
462
- }
463
- .pagination-info {
464
- font-size: 14px;
465
- color: #666;
466
- padding: 10px 0;
467
- }
468
- .page-controls {
469
- display: flex;
470
- align-items: center;
471
- justify-content: center;
472
- gap: 20px;
473
- padding: 10px 0;
474
- }
475
- </style>
476
- """, unsafe_allow_html=True)
477
-
478
- # Add pagination controls
479
- col1, col2, col3 = st.columns([2, 1, 2])
480
- with col2:
481
- rows_per_page = st.selectbox("Rows per page", [50, 100, 200, 500], index=1)
482
-
483
- total_rows = len(filtered_df)
484
- total_pages = (total_rows + rows_per_page - 1) // rows_per_page
485
-
486
- # Initialize page number in session state
487
- if "page_number" not in st.session_state:
488
- st.session_state.page_number = 0
489
-
490
- # Calculate start and end indices for current page
491
- start_idx = st.session_state.page_number * rows_per_page
492
- end_idx = min(start_idx + rows_per_page, total_rows)
493
-
494
- st.dataframe(
495
- filtered_df.iloc[start_idx:end_idx],
496
- hide_index=True,
497
- use_container_width=True,
498
- column_config={
499
- "UniProt_ID": st.column_config.LinkColumn(
500
- "UniProt ID",
501
- help="Click to view protein in UniProt",
502
- validate="^https://www\\.uniprot\\.org/uniprotkb/[A-Z0-9]+/entry$",
503
- display_text="^https://www\\.uniprot\\.org/uniprotkb/([A-Z0-9]+)/entry$"
504
- ),
505
- "GO_ID": st.column_config.LinkColumn(
506
- "GO ID",
507
- help="Click to view GO term in QuickGO",
508
- validate="^https://www\\.ebi\\.ac\\.uk/QuickGO/term/GO:[0-9]+$",
509
- display_text="^https://www\\.ebi\\.ac\\.uk/QuickGO/term/(GO:[0-9]+)$"
510
- ),
511
- "Probability": st.column_config.ProgressColumn(
512
- "Probability",
513
- format="%.2f",
514
- min_value=0,
515
- max_value=1,
516
- ),
517
- "Protein": st.column_config.TextColumn(
518
- "Protein",
519
- help="Protein Name",
520
- ),
521
- "GO_category": st.column_config.TextColumn(
522
- "GO Category",
523
- help="Gene Ontology Category",
524
- ),
525
- "GO_term": st.column_config.TextColumn(
526
- "GO Term",
527
- help="Gene Ontology Term Name",
528
- ),
529
- }
530
- )
531
- # Pagination controls with better layout
532
- col1, col2, col3 = st.columns([1, 3, 1])
533
- with col1:
534
- if st.button("Previous", disabled=st.session_state.page_number == 0):
535
- st.session_state.page_number -= 1
536
- st.rerun()
537
-
538
- with col2:
539
- st.markdown(f"""
540
- <div class="pagination-info" style="text-align: center">
541
- Page {st.session_state.page_number + 1} of {total_pages}<br>
542
- Showing rows {start_idx + 1} to {end_idx} of {total_rows}
543
- </div>
544
- """, unsafe_allow_html=True)
545
-
546
- with col3:
547
- if st.button("Next", disabled=st.session_state.page_number >= total_pages - 1):
548
- st.session_state.page_number += 1
549
- st.rerun()
550
-
551
- downloadable_df = filtered_df.copy()
552
- downloadable_df['UniProt_ID'] = downloadable_df['UniProt_ID'].apply(
553
- lambda x: x.split('/')[-2] # Gets the ID part from the URL
554
- )
555
- downloadable_df['GO_ID'] = downloadable_df['GO_ID'].apply(
556
- lambda x: x.split('/')[-1] # Gets the ID part from the URL
557
  )
558
- # Download filtered results
559
- st.download_button(
560
- label="Download Filtered Results",
561
- data=convert_df(downloadable_df),
562
- file_name="filtered_predictions.csv",
563
- mime="text/csv",
564
- key="download_filtered_predictions"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  )
566
-
567
- with kg_viz_tab:
568
- st.markdown("### Knowledge Graph Visualization")
569
-
570
- if not selected_proteins:
571
- st.info("Please select proteins from the sidebar to visualize their knowledge graphs.")
572
- elif len(selected_proteins) <= 10:
573
- st.markdown("Visualize the knowledge graph for query proteins to explore the biological relationships that contributed to the predictions. The current tool supports up to second-degree connections. For deeper exploration beyond these, you can download the complete knowledge graph from [here](https://drive.google.com/file/d/18u1o2sm8YjMo9joFw4Ilwvg0-rUU0PXK/view?usp=share_link).")
574
-
575
- protein_tabs = st.tabs([f"{protein_id}" for protein_id in selected_proteins])
576
-
577
- # Create visualizations in each tab
578
- for idx, protein_id in enumerate(selected_proteins):
579
- with protein_tabs[idx]:
580
- col1, col2 = st.columns([1, 1])
581
- with col1:
582
- max_node_count = st.slider(
583
- "Maximum neighbors per edge type (first-degree)",
584
- min_value=5,
585
- max_value=50,
586
- value=10,
587
- step=5,
588
- help="Control the maximum number of direct neighbors of the query protein shown for each relationship type",
589
- key=f"slider_{protein_id}"
590
- )
591
- with col2:
592
- second_degree_limit = st.slider(
593
- "Maximum neighbors per edge type (second-degree)",
594
- min_value=2,
595
- max_value=10,
596
- value=3,
597
- step=1,
598
- help="Control the maximum number of second-degree neighbors of the query protein shown for each relationship type. Second-degree edge limit is intentionally kept low to maintain visual clarity. Higher values may make the graph cluttered and difficult to interpret.",
599
- key=f"second_degree_slider_{protein_id}"
600
- )
601
-
602
- # Check if both visualizations exist for this protein
603
- viz_exists = (protein_id in st.session_state.protein_visualizations and
604
- 'first_degree' in st.session_state.protein_visualizations[protein_id] and
605
- 'second_degree' in st.session_state.protein_visualizations[protein_id])
606
-
607
- if not viz_exists:
608
- if st.button(f"Generate Visualization", key=f"viz_{protein_id}"):
609
- # Initialize the protein's visualizations if not exists
610
- if protein_id not in st.session_state.protein_visualizations:
611
- st.session_state.protein_visualizations[protein_id] = {}
612
-
613
- # Generate both visualizations upfront
614
- # First degree only
615
- html_path_1st, edges_1st = visualize_protein_subgraph(
616
- st.session_state.heterodata,
617
- protein_id,
618
- st.session_state.predictions_df,
619
- limit=max_node_count,
620
- second_degree_limit=second_degree_limit,
621
- include_second_degree=False
622
- )
623
-
624
- # With second degree
625
- html_path_2nd, edges_2nd = visualize_protein_subgraph(
626
- st.session_state.heterodata,
627
- protein_id,
628
- st.session_state.predictions_df,
629
- limit=max_node_count,
630
- second_degree_limit=second_degree_limit,
631
- include_second_degree=True
632
- )
633
-
634
- # Store both visualizations in session state
635
- st.session_state.protein_visualizations[protein_id]['first_degree'] = {
636
- 'path': html_path_1st,
637
- 'edges': edges_1st,
638
- 'settings': {
639
- 'max_node_count': max_node_count,
640
- 'second_degree_limit': second_degree_limit
641
- }
642
- }
643
- st.session_state.protein_visualizations[protein_id]['second_degree'] = {
644
- 'path': html_path_2nd,
645
- 'edges': edges_2nd,
646
- 'settings': {
647
- 'max_node_count': max_node_count,
648
- 'second_degree_limit': second_degree_limit
649
- }
650
- }
651
- st.rerun()
652
-
653
- # If visualization exists, show the toggle and display appropriate version
654
- if viz_exists:
655
- # Check if settings have changed, but handle cases where settings don't exist
656
- current_settings = {
657
- 'max_node_count': max_node_count,
658
- 'second_degree_limit': second_degree_limit
659
- }
660
-
661
- # Safely get stored settings or use None if they don't exist
662
- stored_settings = (st.session_state.protein_visualizations[protein_id]['first_degree'].get('settings')
663
- if 'first_degree' in st.session_state.protein_visualizations[protein_id]
664
- else None)
665
-
666
- include_second_degree = st.checkbox(
667
- "Include second-degree edges",
668
- value=False,
669
- key=f"second_degree_{protein_id}",
670
- help="Show connections between neighbor nodes"
671
- )
672
-
673
- # Get the appropriate visualization based on checkbox
674
- viz_type = 'second_degree' if include_second_degree else 'first_degree'
675
- viz_info = st.session_state.protein_visualizations[protein_id][viz_type]
676
-
677
- kg_viz_button_columns = st.columns([1, 1, 1])
678
-
679
- with kg_viz_button_columns[0]:
680
- # Format edges for download
681
- formatted_edges = {}
682
- for edge_type, edges in viz_info['edges'].items():
683
- edge_type_str = f"{edge_type[0]}_{edge_type[1]}_{edge_type[2]}"
684
- formatted_edges[edge_type_str] = [
685
- {"source": edge[0][0], "target": edge[0][1], "probability": edge[1]}
686
- for edge in edges
687
- ]
688
-
689
- st.download_button(
690
- label='Download Visualized Edges',
691
- data=json.dumps(formatted_edges, indent=2),
692
- file_name=f'{protein_id}_visualized_edges{"_with_2nd_degree" if include_second_degree else ""}.json',
693
- mime='application/json'
694
- )
695
-
696
- with kg_viz_button_columns[1]:
697
- if st.button("Regenerate Visualization", key=f"regenerate_{protein_id}"):
698
- # Clean up old files
699
- if protein_id in st.session_state.protein_visualizations:
700
- for viz_type in ['first_degree', 'second_degree']:
701
- if viz_type in st.session_state.protein_visualizations[protein_id]:
702
- try:
703
- old_path = st.session_state.protein_visualizations[protein_id][viz_type]['path']
704
- os.unlink(old_path)
705
- except:
706
- pass
707
- # Remove from session state
708
- del st.session_state.protein_visualizations[protein_id]
709
- st.rerun()
710
-
711
- if stored_settings is not None and current_settings != stored_settings:
712
- st.warning("⚠️ Settings have changed. Click 'Regenerate Visualization' to apply new settings.")
713
- elif stored_settings is None:
714
- st.warning("⚠️ Visualization was generated with default settings. Consider regenerating to apply custom settings.")
715
-
716
- # Display the appropriate visualization
717
- with open(viz_info['path'], 'r', encoding='utf-8') as f:
718
- html_content = f.read()
719
-
720
- st.components.v1.html(html_content, height=1200)
721
-
722
- else:
723
- st.warning("Knowledge graph visualization is only available when 10 or fewer proteins are selected.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ProtHGT — guided three-step interface (add proteins → choose what to predict → review results)."""
2
  import os
 
 
3
  import random
4
+ import re
5
 
6
+ import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
+ st.set_page_config(
9
+ page_title="ProtHGT · Protein function prediction",
10
+ page_icon="🧬",
11
+ layout="wide",
12
+ initial_sidebar_state="collapsed",
13
+ )
14
+
15
+ from rapidfuzz import fuzz, process
16
+
17
+ from run_prothgt_app import KG_PATH, generate_prediction_df, load_kg, load_model, load_name_info
18
+ from visualize_kg import visualize_protein_subgraph
19
+ from ui import GITHUB_URL, KG_VIEW_GUIDE, STRICTNESS, render_header, render_stepper
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Settings
23
+ # ---------------------------------------------------------------------------
24
+ MAX_PROTEINS = 100
25
+ MAX_KG_VIZ_PROTEINS = 10
26
+ SEARCH_LIMIT = 50
27
+ KG_DOWNLOAD_URL = "https://huggingface.co/datasets/HUBioDataLab/ProtHGT/blob/main/knowledge_graphs/prothgt-kg.pt"
28
+
29
+ ASPECTS = {
30
+ "GO_term_F": ("Molecular function", "What the protein does at the molecular level, e.g. binding or catalysis."),
31
+ "GO_term_P": ("Biological process", "Which larger pathways or programs it takes part in."),
32
+ "GO_term_C": ("Cellular component", "Where in the cell the protein is located."),
33
+ }
34
+ ASPECT_SHORT = {"GO_term_F": "MF", "GO_term_P": "BP", "GO_term_C": "CC"}
35
+ CATEGORY_TO_KEY = {"Molecular Function": "GO_term_F", "Biological Process": "GO_term_P", "Cellular Component": "GO_term_C"}
36
+
37
+
38
+ # Widget values that must survive moving between steps (Streamlit drops the state of
39
+ # widgets that are not rendered; re-assigning keeps it).
40
+ PERSISTENT_KEYS = ["proteins", "aspect_GO_term_F", "aspect_GO_term_P", "aspect_GO_term_C",
41
+ "strictness", "use_custom_threshold", "custom_threshold"]
42
+ for _k in PERSISTENT_KEYS:
43
+ if _k in st.session_state:
44
+ st.session_state[_k] = st.session_state[_k]
45
+
46
+ DEFAULTS = {
47
+ "step": 1,
48
+ "proteins": [],
49
+ "aspect_GO_term_F": True,
50
+ "aspect_GO_term_P": True,
51
+ "aspect_GO_term_C": True,
52
+ "strictness": "Balanced",
53
+ "use_custom_threshold": False,
54
+ "custom_threshold": 0.50,
55
+ "notice": None,
56
+ "results": None,
57
+ "results_cache": {},
58
+ "viz": {},
59
+ }
60
+ for _k, _v in DEFAULTS.items():
61
+ st.session_state.setdefault(_k, _v)
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Cached heavy resources (shared by every visitor of the Space, loaded once)
66
+ # ---------------------------------------------------------------------------
67
+ @st.cache_resource(show_spinner=False)
68
+ def get_protein_names():
69
+ """UniProt ID -> protein name, for every protein in the knowledge graph."""
70
+ return load_name_info()["Protein"]
71
+
72
+
73
+ @st.cache_resource(show_spinner=False)
74
+ def get_search_index():
75
+ """(id, lower-cased name) pairs plus fuzzy-match choices, built once per process."""
76
+ names = get_protein_names()
77
+ lowered = [(pid, name.lower()) for pid, name in names.items()]
78
+ choices = {pid: f"{pid} {name}" for pid, name in names.items()}
79
+ return lowered, choices
80
+
81
+
82
+ @st.cache_resource(show_spinner=False)
83
+ def get_kg():
84
+ return load_kg()
85
+
86
+
87
+ @st.cache_resource(show_spinner=False)
88
+ def get_model(go_cat):
89
+ return load_model(go_cat, get_kg())
90
+
91
+
92
+ @st.cache_data(show_spinner=False, max_entries=200)
93
+ def search_proteins(query):
94
+ """Exact ID → ID prefix → name substring (closest names first) → fuzzy fallback."""
95
+ q = query.strip()
96
+ qu, ql = q.upper(), q.lower()
97
+ names = get_protein_names()
98
+ lowered, choices = get_search_index()
99
+
100
+ hits = [qu] if qu in names else []
101
+ if re.fullmatch(r"[A-Za-z0-9_]+", q):
102
+ hits += sorted(pid for pid, _ in lowered if pid.startswith(qu))[:SEARCH_LIMIT]
103
+ substr = [(name != ql, len(name), pid) for pid, name in lowered if ql in name]
104
+ hits += [pid for _, _, pid in sorted(substr)[:SEARCH_LIMIT]]
105
+ if len(hits) < SEARCH_LIMIT:
106
+ hits += [m[2] for m in process.extract(q, choices, scorer=fuzz.WRatio, limit=SEARCH_LIMIT)]
107
+ return list(dict.fromkeys(hits))[:SEARCH_LIMIT]
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # State helpers / callbacks
112
+ # ---------------------------------------------------------------------------
113
+ def go_to(step):
114
+ st.session_state.step = step
115
+
116
+
117
+ def selected_aspects():
118
+ return [k for k in ASPECTS if st.session_state[f"aspect_{k}"]]
119
+
120
+
121
+ def current_threshold():
122
+ if st.session_state.use_custom_threshold:
123
+ return float(st.session_state.custom_threshold)
124
+ return STRICTNESS[st.session_state.strictness][0]
125
+
126
+
127
+ def add_proteins(candidates, source):
128
+ names = get_protein_names()
129
+ candidates = list(dict.fromkeys(c.strip().upper() for c in candidates if c and c.strip()))
130
+ known = [c for c in candidates if c in names]
131
+ unknown = [c for c in candidates if c not in names]
132
+
133
+ current = list(st.session_state.proteins)
134
+ new = [p for p in known if p not in current]
135
+ room = MAX_PROTEINS - len(current)
136
+ dropped = new[room:] if room < len(new) else []
137
+ current.extend(new[:max(room, 0)])
138
+ st.session_state.proteins = current
139
+
140
+ st.session_state.notice = {
141
+ "source": source,
142
+ "added": len(new) - len(dropped),
143
+ "duplicates": len(known) - len(new),
144
+ "unknown": unknown,
145
+ "over_limit": dropped,
146
+ }
147
 
 
 
 
 
 
 
 
148
 
149
+ def add_from_paste():
150
+ text = st.session_state.get("paste_text", "")
151
+ add_proteins(re.split(r"[\s,;]+", text), "pasted list")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
+ def add_from_search():
155
+ add_proteins(st.session_state.get("search_pick", []), "search")
156
+ st.session_state.search_pick = []
 
 
 
 
 
 
 
 
 
 
157
 
 
 
 
 
 
 
 
 
 
158
 
159
+ def add_from_file():
160
+ uploaded = st.session_state.get("upload_file")
161
+ if uploaded is None:
162
+ return
163
+ text = uploaded.getvalue().decode("utf-8", errors="ignore")
164
+ add_proteins(re.split(r"[\s,;]+", text), uploaded.name)
165
+
166
+
167
+ def add_example():
168
+ pool = list(get_protein_names().keys())
169
+ add_proteins(random.sample(pool, 5), "example set")
170
+
171
+
172
+ def clear_proteins():
173
+ st.session_state.proteins = []
174
+ st.session_state.notice = None
175
+
176
+
177
+ def clear_visualizations():
178
+ for path in st.session_state.viz.values():
179
+ try:
180
+ os.unlink(path["path"])
181
+ except OSError:
 
 
 
182
  pass
183
+ st.session_state.viz = {}
184
+
185
+
186
+ def start_new_prediction():
187
+ st.session_state.results = None
188
+ st.session_state.results_cache = {}
189
+ st.session_state.notice = None
190
+ clear_visualizations()
191
+ go_to(1)
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Page
196
+ # ---------------------------------------------------------------------------
197
+ render_header()
198
+
199
+ step = st.session_state.step
200
+ hero_slot = st.empty() # always created so the elements below keep a stable position across steps
201
+ if step < 3:
202
+ hero_slot.markdown(
203
+ '<div class="phgt-hero"><h1>Protein function prediction with knowledge graphs</h1>'
204
+ "<p>Gene Ontology term predictions from a heterogeneous graph transformer that combines "
205
+ "protein language model embeddings with a large-scale biological knowledge graph.</p></div>",
206
+ unsafe_allow_html=True,
207
+ )
208
+ else:
209
+ hero_slot.markdown('<div class="phgt-hero-spacer"></div>', unsafe_allow_html=True)
210
+ render_stepper(step)
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
213
+ def protein_chips_html(proteins, limit=40):
214
+ shown = "".join(f'<span class="phgt-chip">{p}</span>' for p in proteins[:limit])
215
+ more = f'<span class="phgt-muted">+{len(proteins) - limit} more</span>' if len(proteins) > limit else ""
216
+ return f'<div class="phgt-chips">{shown}{more}</div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
 
 
 
 
 
 
218
 
219
+ def render_limit_aside():
220
+ n = len(st.session_state.proteins)
221
+ with st.container(border=True):
222
+ st.markdown("**Your proteins**")
223
+ if n:
224
+ st.markdown(protein_chips_html(st.session_state.proteins), unsafe_allow_html=True)
225
+ else:
226
+ st.markdown('<span class="phgt-muted">None added yet.</span>', unsafe_allow_html=True)
227
+ st.markdown(
228
+ f'<div class="phgt-meter"><div style="width:{n / MAX_PROTEINS * 100:.0f}%"></div></div>'
229
+ f'<span class="phgt-muted phgt-count">{n} of {MAX_PROTEINS} proteins per run</span>',
230
+ unsafe_allow_html=True,
231
+ )
232
+ if step == 2:
233
+ st.button("Edit proteins", on_click=go_to, args=(1,), use_container_width=True)
234
+ with st.container(border=True):
235
+ st.markdown("**Need more than 100?**")
236
+ st.markdown(
237
+ f'<span class="phgt-muted">Run ProtHGT locally with the code and pretrained models on '
238
+ f'<a href="{GITHUB_URL}" target="_blank">GitHub</a>.</span>',
239
+ unsafe_allow_html=True,
240
+ )
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # Step 1 — add proteins
245
+ # ---------------------------------------------------------------------------
246
+ def render_step_1():
247
+ main, aside = st.columns([2.3, 1], gap="large")
248
+ with main:
249
+ with st.container(border=True):
250
+ st.subheader("Which proteins do you want to annotate?")
251
+ st.caption("ProtHGT works on the ~326K UniProtKB/Swiss-Prot proteins in its knowledge graph. "
252
+ "Up to 100 proteins per run.")
253
+
254
+ paste_tab, search_tab, upload_tab, example_tab = st.tabs(
255
+ ["Paste IDs", "Search", "Upload file", "Try an example"])
256
+
257
+ with paste_tab:
258
+ with st.form("paste_form", clear_on_submit=True, border=False):
259
+ st.text_area(
260
+ "UniProt accessions",
261
+ key="paste_text",
262
+ placeholder="P69905, P68871\nQ9Y6K9",
263
+ help="Separate IDs with commas, spaces or new lines.",
264
+ height=110,
265
+ )
266
+ st.form_submit_button("Add proteins", on_click=add_from_paste)
267
+
268
+ with search_tab:
269
+ query = st.text_input(
270
+ "Search by UniProt ID or protein name",
271
+ key="search_q",
272
+ placeholder="e.g. hemoglobin, kinase, P699…",
273
  )
274
+ if len(query.strip()) >= 3:
275
+ names = get_protein_names()
276
+ with st.spinner("Searching…"):
277
+ hits = search_proteins(query)
278
+ st.multiselect(
279
+ f"Top {len(hits)} matches" if len(hits) == SEARCH_LIMIT else f"Matches ({len(hits)})",
280
+ options=hits,
281
+ key="search_pick",
282
+ format_func=lambda p: f"{p} — {names.get(p, '')}",
283
+ placeholder="Pick one or more matches",
284
+ )
285
+ st.button("Add selected", on_click=add_from_search, key="add_search",
286
+ disabled=not st.session_state.get("search_pick"))
287
+ else:
288
+ st.caption("Type at least 3 characters.")
289
+
290
+ with upload_tab:
291
+ st.file_uploader("Text file with one UniProt ID per line", type=["txt", "csv", "tsv"],
292
+ key="upload_file")
293
+ st.button("Add from file", on_click=add_from_file, key="add_file",
294
+ disabled=st.session_state.get("upload_file") is None)
295
+
296
+ with example_tab:
297
+ st.markdown('<span class="phgt-muted">Load five random proteins from the knowledge graph '
298
+ "to see how ProtHGT works.</span>", unsafe_allow_html=True)
299
+ st.button("Load example proteins", on_click=add_example, key="add_example")
300
+
301
+ notice = st.session_state.notice
302
+ if notice:
303
+ parts = []
304
+ if notice["added"]:
305
+ parts.append(f"Added {notice['added']} protein{'s' if notice['added'] != 1 else ''} "
306
+ f"from {notice['source']}.")
307
+ if notice["duplicates"]:
308
+ parts.append(f"{notice['duplicates']} already in your list.")
309
+ if parts:
310
+ st.success(" ".join(parts))
311
+ elif not notice["unknown"]:
312
+ st.info("No UniProt IDs found in the input.")
313
+ if notice["unknown"]:
314
+ st.warning(
315
+ f"{len(notice['unknown'])} ID(s) are not in the ProtHGT knowledge graph and were skipped. "
316
+ "Predictions for proteins outside the graph are not supported yet."
317
+ )
318
+ with st.expander("Show skipped IDs"):
319
+ st.code("\n".join(notice["unknown"]), language=None)
320
+ if notice["over_limit"]:
321
+ st.error(f"{len(notice['over_limit'])} protein(s) were not added: the limit is "
322
+ f"{MAX_PROTEINS} per run.")
323
+
324
+ st.divider()
325
+ st.multiselect(
326
+ f"Selected proteins ({len(st.session_state.proteins)}/{MAX_PROTEINS})",
327
+ options=st.session_state.proteins,
328
+ key="proteins",
329
+ placeholder="Add proteins using one of the tabs above",
330
+ help="Click × on a protein to remove it.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  )
332
+ if st.session_state.proteins:
333
+ c1, c2, _ = st.columns([1, 1, 2])
334
+ c1.download_button("Download list", "\n".join(st.session_state.proteins),
335
+ file_name="selected_proteins.txt", mime="text/plain")
336
+ c2.button("Clear all", on_click=clear_proteins)
337
+
338
+ _, nav = st.columns([3, 1.3])
339
+ nav.button("Continue →", type="primary", use_container_width=True,
340
+ disabled=not st.session_state.proteins, on_click=go_to, args=(2,))
341
+ with aside:
342
+ render_limit_aside()
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # Step 2 — choose what to predict
347
+ # ---------------------------------------------------------------------------
348
+ def render_step_2():
349
+ if not st.session_state.proteins:
350
+ go_to(1)
351
+ st.rerun()
352
+
353
+ main, aside = st.columns([2.3, 1], gap="large")
354
+ with main:
355
+ with st.container(border=True):
356
+ st.subheader("Which functions are you interested in?")
357
+ cols = st.columns(3)
358
+ for col, (key, (label, desc)) in zip(cols, ASPECTS.items()):
359
+ with col:
360
+ with st.container(border=True):
361
+ st.checkbox(f"**{label}**", key=f"aspect_{key}")
362
+ st.caption(desc)
363
+
364
+ st.subheader("How strict should predictions be?")
365
+ st.radio(
366
+ "Prediction strictness",
367
+ options=list(STRICTNESS),
368
+ key="strictness",
369
+ horizontal=True,
370
+ label_visibility="collapsed",
371
+ captions=[f"{d} · score ≥ {t:.2f}" for t, d in STRICTNESS.values()],
372
+ disabled=st.session_state.use_custom_threshold,
373
  )
374
+ with st.expander("Advanced: set a custom score threshold",
375
+ expanded=st.session_state.use_custom_threshold):
376
+ st.checkbox("Use a custom threshold", key="use_custom_threshold")
377
+ st.slider("Minimum score", 0.0, 1.0, step=0.05, key="custom_threshold",
378
+ disabled=not st.session_state.use_custom_threshold,
379
+ help="Only GO terms scoring at or above this value are returned. "
380
+ "Lower values return many more terms and take longer.")
381
+ if st.session_state.use_custom_threshold and st.session_state.custom_threshold < 0.1:
382
+ st.caption("A very low threshold returns almost every GO term for each protein; "
383
+ "expect a large table.")
384
+
385
+ aspects = selected_aspects()
386
+ back, _, run = st.columns([1, 1.7, 1.3])
387
+ back.button("← Back", use_container_width=True, on_click=go_to, args=(1,))
388
+ run.button("Run prediction →", type="primary", use_container_width=True,
389
+ disabled=not aspects, on_click=go_to, args=(3,))
390
+ if not aspects:
391
+ st.caption("Select at least one function category.")
392
+ with aside:
393
+ render_limit_aside()
394
+
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # Step 3 — run & review
398
+ # ---------------------------------------------------------------------------
399
+ def run_prediction(proteins, aspects, threshold):
400
+ with st.status("Running ProtHGT…", expanded=True) as status:
401
+ if os.path.exists(KG_PATH):
402
+ st.write("Loading the knowledge graph…")
403
+ else:
404
+ st.write("Downloading dataset files for the first time, this may take a few minutes…")
405
+ heterodata = get_kg()
406
+ progress = st.progress(0.0)
407
+
408
+ def on_category(i, n, go_cat):
409
+ progress.progress(i / n, text=f"Scoring {ASPECTS[go_cat][0].lower()} terms ({i + 1}/{n})")
410
+
411
+ models = {cat: get_model(cat) for cat in aspects}
412
+ _, df = generate_prediction_df(
413
+ protein_ids=proteins,
414
+ go_category=aspects,
415
+ threshold=threshold,
416
+ heterodata=heterodata,
417
+ models=models,
418
+ progress_callback=on_category,
419
+ )
420
+ progress.progress(1.0, text="Done")
421
+ status.update(label=f"Predicted {len(df):,} protein–GO term associations", state="complete",
422
+ expanded=False)
423
+ return df.sort_values("Probability", ascending=False, ignore_index=True)
424
+
425
+
426
+ @st.fragment
427
+ def render_predictions(df, threshold):
428
+ if df.empty:
429
+ st.info("No GO terms passed the score threshold. Try a lower strictness in step 2.")
430
+ return
431
+
432
+ f1, f2, f3 = st.columns([1.2, 1.2, 1.6])
433
+ proteins = ["All proteins"] + sorted(df["UniProt_ID"].unique())
434
+ protein = f1.selectbox("Protein", proteins, format_func=lambda p: p if p == "All proteins"
435
+ else f"{p} — {df.loc[df.UniProt_ID == p, 'Protein'].iat[0]}")
436
+ available = [c for c in CATEGORY_TO_KEY if c in set(df["GO_category"])]
437
+ aspects = f2.pills("Aspect", available, selection_mode="multi", default=available,
438
+ format_func=lambda c: ASPECT_SHORT[CATEGORY_TO_KEY[c]])
439
+ text = f3.text_input("Search GO ID or term", placeholder="e.g. GO:0005524 or binding")
440
+ min_score = st.slider("Minimum score", float(threshold), 1.0, float(threshold), step=0.01)
441
+
442
+ view = df
443
+ if protein != "All proteins":
444
+ view = view[view["UniProt_ID"] == protein]
445
+ view = view[view["GO_category"].isin(aspects or [])]
446
+ if text.strip():
447
+ t = text.strip()
448
+ view = view[view["GO_ID"].str.contains(t, case=False, regex=False)
449
+ | view["GO_term"].str.contains(t, case=False, regex=False)]
450
+ view = view[view["Probability"] >= min_score]
451
+
452
+ display = view.assign(
453
+ UniProt_ID="https://www.uniprot.org/uniprotkb/" + view["UniProt_ID"] + "/entry",
454
+ GO_ID="https://www.ebi.ac.uk/QuickGO/term/" + view["GO_ID"],
455
+ GO_category=view["GO_category"].map(lambda c: ASPECT_SHORT[CATEGORY_TO_KEY[c]]), # MF/BP/CC; CSV keeps full names
456
+ )
457
+ # Fixed widths for the long text columns so the table fits the page instead of scrolling sideways;
458
+ # truncated cells show their full text on hover. Short columns keep their automatic width.
459
+ st.dataframe(
460
+ display,
461
+ hide_index=True,
462
+ use_container_width=True,
463
+ height=520,
464
+ column_config={
465
+ "UniProt_ID": st.column_config.LinkColumn("UniProt ID", display_text=r"uniprotkb/([A-Z0-9]+)/entry"),
466
+ "Protein": st.column_config.TextColumn("Protein", width="large"),
467
+ "GO_ID": st.column_config.LinkColumn("GO ID", display_text=r"term/(GO:[0-9]+)"),
468
+ "GO_term": st.column_config.TextColumn("GO term", width="medium"),
469
+ "GO_category": st.column_config.TextColumn(
470
+ "Aspect", help="MF: molecular function · BP: biological process · CC: cellular component"),
471
+ # Plain number: scores cluster near 1, so a bar adds little; 3 decimals keep close scores apart
472
+ "Probability": st.column_config.NumberColumn("Score", format="%.3f", width="small"),
473
+ },
474
+ )
475
+ c1, c2 = st.columns([3, 1.3], vertical_alignment="center")
476
+ c1.caption(f"Showing {len(view):,} of {len(df):,} predictions")
477
+ c2.download_button("Download CSV", view.to_csv(index=False).encode("utf-8"),
478
+ file_name="prothgt_predictions.csv", mime="text/csv",
479
+ use_container_width=True)
480
+
481
+
482
+ @st.fragment
483
+ def render_knowledge_graph(df, proteins, aspects):
484
+ if len(proteins) > MAX_KG_VIZ_PROTEINS:
485
+ st.info(f"Knowledge graph views are available for runs of up to {MAX_KG_VIZ_PROTEINS} proteins.")
486
+ return
487
+
488
+ st.markdown('<span class="phgt-muted">Explore the biological relationships around a protein that '
489
+ "the model draws on: GO annotations, domains, interactions, pathways and more. "
490
+ "Views go up to second-degree connections; for deeper exploration, "
491
+ f'<a href="{KG_DOWNLOAD_URL}" target="_blank">download the complete knowledge graph</a>.</span>',
492
+ unsafe_allow_html=True)
493
+ with st.expander("How to read this graph"):
494
+ st.markdown(KG_VIEW_GUIDE)
495
+ c1, c2, c3, c4 = st.columns([1.3, 1, 1, 1])
496
+ protein = c1.selectbox("Protein", proteins, key="viz_protein")
497
+ pred_limit = c2.slider(
498
+ "Predicted GO terms", 1, 20, 5,
499
+ help="Highest-scoring predictions shown per predicted GO category. Purple: also annotated "
500
+ "in the knowledge graph; red: novel. Known annotations that were predicted at a lower rank "
501
+ "are also shown in purple.")
502
+ limit = c3.slider(
503
+ "Neighbours per edge type", 5, 50, 10, step=5,
504
+ help="Maximum direct neighbours shown for each relationship type, including known GO "
505
+ "annotations outside the top predictions (purple if predicted, blue if not).")
506
+ second_limit = c4.slider(
507
+ "Second-degree neighbours", 0, 10, 0,
508
+ help="Second-degree neighbours are the neighbours of the protein's direct neighbours, "
509
+ "e.g. other proteins sharing a domain or pathway with it. 0 shows direct neighbours only; "
510
+ "any higher value adds up to that many per edge type. Large values quickly make the "
511
+ "graph dense and hard to read.")
512
+ include_second = second_limit > 0
513
+
514
+ viz_key = f"{protein}|{pred_limit}|{limit}|{second_limit}"
515
+ viz = st.session_state.viz.get(viz_key)
516
+ if viz is None:
517
+ if st.button("Show graph", type="primary", key="viz_go"):
518
+ with st.spinner("Building subgraph…"):
519
+ path, edges = visualize_protein_subgraph(
520
+ get_kg(), protein, df, limit=limit, prediction_limit=pred_limit, predicted_categories=aspects,
521
+ second_degree_limit=second_limit, include_second_degree=include_second)
522
+ st.session_state.viz[viz_key] = {"path": path, "edges": edges}
523
+ st.rerun(scope="fragment")
524
+ return
525
+
526
+ with open(viz["path"], "r", encoding="utf-8") as f:
527
+ # 760px graph with both legends inside it; PNG and edges-JSON downloads are in its toolbox
528
+ # (kg_graph_template.html)
529
+ st.components.v1.html(f.read(), height=775, scrolling=True)
530
+
531
+
532
+ def render_step_3():
533
+ proteins = list(st.session_state.proteins)
534
+ aspects = selected_aspects()
535
+ if not proteins or not aspects:
536
+ go_to(1 if not proteins else 2)
537
+ st.rerun()
538
+
539
+ threshold = current_threshold()
540
+ run_key = (tuple(proteins), tuple(aspects), threshold)
541
+ results = st.session_state.results
542
+ if results is None or results["key"] != run_key:
543
+ clear_visualizations()
544
+ cache = st.session_state.results_cache
545
+ if run_key in cache: # switching back to settings already run in this session is instant
546
+ df = cache[run_key]
547
+ else:
548
+ try:
549
+ df = run_prediction(proteins, aspects, threshold)
550
+ except Exception as exc: # surface failures instead of a blank page
551
+ st.error(f"Prediction failed: {exc}")
552
+ st.button("← Back to settings", on_click=go_to, args=(2,))
553
+ return
554
+ cache[run_key] = df
555
+ while len(cache) > 3: # keep memory per visitor bounded
556
+ cache.pop(next(iter(cache)))
557
+ st.session_state.results = {"key": run_key, "df": df}
558
+ results = st.session_state.results
559
+ df = results["df"]
560
+
561
+ head, b1, b2 = st.columns([3, 1.1, 1.1], vertical_alignment="bottom")
562
+ head.subheader("Results")
563
+ b1.button("Change settings", on_click=go_to, args=(2,), use_container_width=True)
564
+ b2.button("New prediction", on_click=start_new_prediction, use_container_width=True)
565
+
566
+ m1, m2, m3, m4 = st.columns(4)
567
+ m1.metric("Proteins", len(proteins))
568
+ m2.metric("Predicted GO terms", f"{len(df):,}")
569
+ m3.metric("Aspects", " · ".join(ASPECT_SHORT[a] for a in aspects))
570
+ m4.metric("Score threshold", f"{threshold:.2f}")
571
+
572
+ pred_tab, kg_tab = st.tabs(["Predictions", "Knowledge graph"])
573
+ with pred_tab:
574
+ render_predictions(df, threshold)
575
+ with kg_tab:
576
+ render_knowledge_graph(df, proteins, aspects)
577
+
578
+
579
+ # Render the step inside one st.empty() slot so the previous step's widgets are replaced
580
+ # immediately instead of lingering (greyed out) while a long prediction runs.
581
+ with st.empty().container():
582
+ {1: render_step_1, 2: render_step_2, 3: render_step_3}[step]()
pages/User_Guide.py CHANGED
@@ -1,257 +1,68 @@
1
  import streamlit as st
2
 
3
- st.sidebar.markdown('''
4
- # Sections
5
- - [How to use](#how-to-use)
6
- - [Knowledge Graph Visualization](#knowledge-graph-visualization)
7
- - [Running Locally](#running-locally)
8
- ''', unsafe_allow_html=True)
9
 
10
- st.markdown('''
11
- # ProtHGT User Guide
12
- ''')
13
 
 
14
  st.markdown("""
15
- ProtHGT is a web-based tool for **automated protein function prediction** using heterogeneous graph transformers and knowledge graphs. Follow the steps below to generate predictions for your proteins.
 
16
  """)
17
 
18
- st.subheader("1. Select Proteins")
19
  st.markdown("""
20
- In the **sidebar**, choose how to input your proteins:
21
- - **Use example query**: Loads a random set of 5 proteins from the dataset to quickly explore the tool.
22
- - **Search Proteins**: Select or search UniProt IDs from the available dataset.
23
- - **Upload a File**: Upload a text file (.txt) containing UniProt IDs (one per line, max 100).
24
- """)
25
- st.warning("⚠️ Currently, our system can only generate predictions for proteins that are already included in our knowledge graph. Real-time retrieval of relationship data from external source databases is not yet supported. We are actively working on integrating this capability in future updates. Stay tuned!")
26
- st.info("📥 Selected proteins can be downloaded as a txt file.")
27
-
28
-
29
- st.subheader("2. Choose Gene Ontology (GO) Category")
30
- st.markdown("""
31
- Select which **Gene Ontology (GO) sub-ontology** to use for function prediction:
32
- - **Molecular Function (MF)** – Biochemical activity of the protein
33
- - **Biological Process (BP)** – Biological roles and pathways
34
- - **Cellular Component (CC)** – Location within the cell
35
- - **All Categories** – Runs predictions for all three categories
36
- """)
37
-
38
- st.subheader("3. Set Generation Threshold (Optional)")
39
- st.markdown("""
40
- Use the **Generation threshold** slider to filter predictions at the point of generation. Only predictions with a probability equal to or above the threshold will be included in the output.
41
-
42
- Setting a threshold above 0 can significantly **reduce output size** and **speed up the app**, especially when processing many proteins or all GO categories.
43
- """)
44
-
45
- st.subheader("4. Generate Predictions")
46
- st.markdown("""
47
- Click **"Generate Predictions"** to start the analysis. The model will process the selected proteins and return predicted functional annotations.
48
-
49
- 🔄 **Processing time**: A few minutes (depending on input size). On the **first run of a new session**, the knowledge graph file (~1 GB) will be downloaded automatically — this may take a few additional minutes.
50
- """)
51
-
52
- st.subheader("5. View and Filter Results")
53
- st.markdown("""
54
- Once predictions are generated, use the filter options to refine the output:
55
- - **Filter by Protein** (UniProt ID)
56
- - **Filter by GO Category**
57
- - **Filter by GO Term ID** – Enter a specific GO term (e.g., GO:0003674) to search for it directly
58
- - **Set Probability Range** – Adjust prediction confidence thresholds
59
-
60
- Results are displayed in a sortable table, with **probabilities** indicating prediction confidence. UniProt IDs and GO IDs are clickable links that open the corresponding entry in UniProt and QuickGO, respectively.
61
- """)
62
-
63
- st.info("📥 Filtered predictions can be downloaded as a CSV file.")
64
 
65
- st.subheader("6. Start a New Query")
66
- st.markdown("""
67
- After generating predictions, you can start a new query by selecting different options from the sidebar. Changing the protein selection or GO category will automatically reset the results.
68
  """)
 
 
69
 
70
- st.divider()
71
-
72
- st.markdown("""## Knowledge Graph Visualization <span style="background-color: #28a745; color: white; font-size: 14px; font-weight: bold; padding: 3px 10px; border-radius: 12px; vertical-align: middle;">NEW</span>""", unsafe_allow_html=True)
73
-
74
  st.markdown("""
75
- After generating predictions, switch to the **"View Knowledge Graphs"** tab to explore the biological context behind the model's predictions. This feature is available when **10 or fewer proteins** are selected.
 
 
 
76
  """)
77
 
78
- st.subheader("Generating a Visualization")
 
 
 
 
 
 
79
  st.markdown("""
80
- Each query protein gets its own subtab. For each protein:
81
-
82
- 1. Use the **"Maximum neighbors per edge type (first-degree)"** slider to control how many direct neighbors are shown per relationship type. Higher values show a denser graph.
83
- 2. Use the **"Maximum neighbors per edge type (second-degree)"** slider to control neighbors-of-neighbors when second-degree edges are enabled. This is intentionally kept low (2–10) to avoid cluttered graphs.
84
- 3. Click **"Generate Visualization"** to render the graph.
85
  """)
86
 
87
- st.subheader("What the Graph Shows")
88
- st.markdown("""
89
- The visualization renders a **subgraph of the full ProtHGT knowledge graph**, centered on the query protein. Edges and nodes are selected as follows:
90
-
91
- - **First-degree edges**: All relationship types that directly connect the query protein to other nodes in the knowledge graph are included (e.g., protein-domain, PPI, GO term annotations). For GO term edges, nodes are ranked by predicted probability and the top-N are shown based on the slider setting.
92
- - **Second-degree edges**: When enabled via the **"Include second-degree edges"** checkbox, neighbor nodes are also expanded — their own connections (excluding edges back to the query protein) are added to the graph, again limited by the second-degree slider.
93
- """)
94
-
95
- st.subheader("Reading the Graph")
96
-
97
- st.components.v1.html("""
98
- <style>
99
- .kg-legend {
100
- margin-top: 10px;
101
- margin-bottom: 10px;
102
- padding: 20px;
103
- border: 1px solid #ddd;
104
- border-radius: 5px;
105
- font-family: Arial, sans-serif;
106
- display: flex;
107
- gap: 20px;
108
- }
109
- .legend-section-nodes {
110
- flex: 2;
111
- }
112
- .legend-section-edges {
113
- flex: 1;
114
- }
115
- .legend-title {
116
- margin-bottom: 15px;
117
- color: #333;
118
- font-size: 16px;
119
- font-weight: bold;
120
- }
121
- .nodes-grid {
122
- display: grid;
123
- grid-template-columns: repeat(2, 1fr);
124
- gap: 12px;
125
- }
126
- .edges-grid {
127
- display: grid;
128
- grid-template-columns: 1fr;
129
- gap: 12px;
130
- }
131
- .legend-item {
132
- display: flex;
133
- align-items: center;
134
- padding: 4px;
135
- }
136
- .node-indicator {
137
- width: 15px;
138
- height: 15px;
139
- border-radius: 50%;
140
- margin-right: 10px;
141
- flex-shrink: 0;
142
- }
143
- .edge-indicator {
144
- width: 40px;
145
- height: 3px;
146
- margin-right: 10px;
147
- flex-shrink: 0;
148
- }
149
- .legend-label {
150
- font-size: 14px;
151
- }
152
- </style>
153
- <div class="kg-legend">
154
- <div class="legend-section-nodes">
155
- <div class="legend-title">Node Types</div>
156
- <div class="nodes-grid">
157
- <div class="legend-item">
158
- <div class="node-indicator" style="background-color: #079dbb;"></div>
159
- <span class="legend-label">Disease</span>
160
- </div>
161
- <div class="legend-item">
162
- <div class="node-indicator" style="background-color: #58d0e8;"></div>
163
- <span class="legend-label">Phenotype</span>
164
- </div>
165
- <div class="legend-item">
166
- <div class="node-indicator" style="background-color: #815ac0;"></div>
167
- <span class="legend-label">Drug</span>
168
- </div>
169
- <div class="legend-item">
170
- <div class="node-indicator" style="background-color: #d2b7e5;"></div>
171
- <span class="legend-label">Compound</span>
172
- </div>
173
- <div class="legend-item">
174
- <div class="node-indicator" style="background-color: #6bbf59;"></div>
175
- <span class="legend-label">Domain</span>
176
- </div>
177
- <div class="legend-item">
178
- <div class="node-indicator" style="background-color: #ff8800;"></div>
179
- <span class="legend-label">Biological Process</span>
180
- </div>
181
- <div class="legend-item">
182
- <div class="node-indicator" style="background-color: #ffaa00;"></div>
183
- <span class="legend-label">Molecular Function</span>
184
- </div>
185
- <div class="legend-item">
186
- <div class="node-indicator" style="background-color: #ffc300;"></div>
187
- <span class="legend-label">Cellular Component</span>
188
- </div>
189
- <div class="legend-item">
190
- <div class="node-indicator" style="background-color: #720026;"></div>
191
- <span class="legend-label">Pathway</span>
192
- </div>
193
- <div class="legend-item">
194
- <div class="node-indicator" style="background-color: #ce4257;"></div>
195
- <span class="legend-label">EC Number</span>
196
- </div>
197
- <div class="legend-item">
198
- <div class="node-indicator" style="background-color: #3aa6a4;"></div>
199
- <span class="legend-label">Protein</span>
200
- </div>
201
- </div>
202
- </div>
203
- <div class="legend-section-edges">
204
- <div class="legend-title">Edge Colors</div>
205
- <div class="edges-grid">
206
- <div class="legend-item">
207
- <div class="edge-indicator" style="background-color: #8338ec;"></div>
208
- <span class="legend-label">Confirmed Prediction (Found in Ground Truth)</span>
209
- </div>
210
- <div class="legend-item">
211
- <div class="edge-indicator" style="background-color: #c1121f;"></div>
212
- <span class="legend-label">Novel Prediction (Not in Ground Truth)</span>
213
- </div>
214
- <div class="legend-item">
215
- <div class="edge-indicator" style="background-color: #219ebc;"></div>
216
- <span class="legend-label">Existing GO Term Annotation</span>
217
- </div>
218
- <div class="legend-item">
219
- <div class="edge-indicator" style="background-color: #666666;"></div>
220
- <span class="legend-label">Other Relationships</span>
221
- </div>
222
- </div>
223
- </div>
224
- </div>
225
- """, height=320)
226
-
227
  st.markdown("""
228
- **Node colors** indicate the biological entity type of each node in the graph. The **query protein** is always shown as a larger node with a white background and red border, making it easy to identify at a glance.
 
 
 
229
 
230
- **Edge colors** indicate the nature of each connection:
231
- - **Purple** – The predicted GO term was also found in the ground truth annotations, confirming the model's prediction.
232
- - **Red** – The predicted GO term was not in the ground truth — this is a novel prediction made by the model.
233
- - **Blue** – An existing GO term annotation already present in the knowledge graph, included for context but not generated as a new prediction.
234
- - **Gray** – Other biological relationships such as protein-protein interactions, protein-domain associations, drug-target links, and pathway memberships.
235
 
236
- Hovering over any node or edge reveals a **tooltip** with the entity name, type, and prediction probability where applicable. Nodes and GO IDs are clickable links to their respective database entries (UniProt, QuickGO, InterPro, etc.).
237
  """)
238
 
239
- st.subheader("Controls and Downloads")
240
- st.markdown("""
241
- - **Include second-degree edges** checkbox – Toggles between first-degree-only and expanded graph views.
242
- - **Regenerate Visualization** – Re-renders the graph with updated slider settings. Use this after changing the neighbor limits.
243
- - **Download Visualized Edges** – Downloads a JSON file containing all edges shown in the current visualization, including source/target node IDs and prediction probabilities.
244
- """)
245
-
246
- st.info("📥 For deeper exploration beyond second-degree connections, the complete knowledge graph can be downloaded from the link provided in the visualization tab.")
247
-
248
- st.divider()
249
 
250
- st.markdown("## Running Locally")
251
  st.markdown("""
252
- For **larger datasets** or **custom analyses**, you can run ProtHGT locally using our [**GitHub repository**](https://github.com/HUBioDataLab/ProtHGT).
253
- """)
254
-
255
- st.markdown("""
256
-
257
  """)
 
1
  import streamlit as st
2
 
3
+ st.set_page_config(page_title="User guide · ProtHGT", page_icon="🧬", layout="wide", initial_sidebar_state="collapsed")
 
 
 
 
 
4
 
5
+ from ui import KG_VIEW_GUIDE, STRICTNESS, render_header
6
+ render_header()
 
7
 
8
+ st.title("User guide")
9
  st.markdown("""
10
+ ProtHGT predicts **Gene Ontology (GO) terms** for proteins using a heterogeneous graph transformer
11
+ trained on a biological knowledge graph. A prediction takes three steps.
12
  """)
13
 
14
+ st.subheader("1. Add proteins")
15
  st.markdown("""
16
+ Add up to **100 UniProt accessions** per run, in any combination of:
17
+ - **Paste IDs** – type or paste accessions separated by commas, spaces or new lines.
18
+ - **Search** – find proteins by UniProt ID or protein name and add the matches you want.
19
+ - **Upload file** – a .txt/.csv file with one accession per line.
20
+ - **Try an example** – five random proteins from the knowledge graph.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
+ Remove a protein by clicking × on its chip. The list can be downloaded as a text file.
 
 
23
  """)
24
+ st.info("ProtHGT can currently only predict for proteins that are part of its knowledge graph "
25
+ "(~326K UniProtKB/Swiss-Prot entries). IDs outside the graph are listed and skipped.")
26
 
27
+ st.subheader("2. Choose what to predict")
 
 
 
28
  st.markdown("""
29
+ - **Function categories** – any of *Molecular function* (biochemical activity), *Biological process*
30
+ (pathways and programs) and *Cellular component* (location in the cell).
31
+ - **Strictness** – sets the minimum score a GO term needs to be returned. Each preset keeps the
32
+ terms whose predicted score falls in the range below:
33
  """)
34
 
35
+ _presets = list(STRICTNESS.items())
36
+ _lines = ["| Preset | Score range returned | Added on top of the stricter preset | Use it for |",
37
+ "|---|---|---|---|"]
38
+ for i, (name, (low, desc)) in enumerate(_presets):
39
+ added = "—" if i == 0 else f"{low:.2f} – {_presets[i - 1][1][0]:.2f}"
40
+ _lines.append(f"| **{name}** | {low:.2f} – 1.00 | {added} | {desc} |")
41
+ st.markdown("\n".join(_lines))
42
  st.markdown("""
43
+ Presets are cumulative: *Balanced* includes everything *Precise* returns plus terms scoring between
44
+ the two cut-offs, and *Exploratory* adds the next band down. Under *Advanced* you can set any
45
+ threshold between 0 and 1 instead; on the results page you can still raise the minimum score further,
46
+ but not below the threshold the run used.
 
47
  """)
48
 
49
+ st.subheader("3. Review results")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  st.markdown("""
51
+ - **Predictions** – a sortable table with links to UniProt and QuickGO. Filter by protein, aspect,
52
+ GO ID / term text and minimum score, then download the filtered table as CSV.
53
+ - **Knowledge graph** – for runs of up to 10 proteins, view the subgraph around a protein (with optional
54
+ second-degree edges) and download the shown edges as JSON.
55
 
56
+ Use **Change settings** to rerun with different categories or strictness, or **New prediction** to start over.
 
 
 
 
57
 
58
+ The first run after the Space starts downloads the knowledge graph (~1 GB), which can take a few extra minutes.
59
  """)
60
 
61
+ st.subheader("Knowledge graph view")
62
+ st.markdown(KG_VIEW_GUIDE)
 
 
 
 
 
 
 
 
63
 
64
+ st.subheader("Running locally")
65
  st.markdown("""
66
+ For more than 100 proteins or custom analyses, run ProtHGT locally using the
67
+ [GitHub repository](https://github.com/HUBioDataLab/ProtHGT).
 
 
 
68
  """)
ui.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared look-and-feel for the ProtHGT Streamlit pages (header, CSS, stepper)."""
2
+ import streamlit as st
3
+
4
+ ACCENT = "#3B4BD8"
5
+ GITHUB_URL = "https://github.com/HUBioDataLab/ProtHGT"
6
+ PAPER_URL = "https://www.biorxiv.org/content/10.1101/2025.04.19.649272"
7
+
8
+ # Minimum score behind each strictness preset (used by the app and the user guide).
9
+ # Replace "Balanced" with your validation-set Fmax threshold(s) to match the paper exactly.
10
+ STRICTNESS = {
11
+ "Precise": (0.70, "Fewer, high-confidence terms"),
12
+ "Balanced": (0.50, "Recommended default"),
13
+ "Exploratory": (0.30, "More candidates to screen"),
14
+ }
15
+
16
+ # How to read the knowledge graph view. Shown in the User guide and in the "How to read this graph"
17
+ # expander of the Knowledge graph tab, so the two stay in sync.
18
+ KG_VIEW_GUIDE = """
19
+ The view shows the part of the ProtHGT knowledge graph around one query protein, drawn at the centre
20
+ with a dark ring. Direct neighbours sit on an inner ring, grouped by kind (confirmed predictions, novel
21
+ predictions, known annotations, then other node types); second-degree neighbours sit on an outer ring
22
+ next to the neighbour they connect to. Choose the protein, then click **Show graph**.
23
+
24
+ - **Predicted GO terms** – how many of the highest-scoring predictions are shown for each predicted
25
+ GO category.
26
+ - **Neighbours per edge type** – how many knowledge-graph neighbours are shown for each relationship
27
+ type (domains, interactions, pathways, …), including known GO annotations outside the top predictions.
28
+ - **Second-degree neighbours** – set above 0 to also expand the neighbours' own connections, excluding
29
+ edges back to the query protein, up to that many per edge type. Keep it low: the graph quickly
30
+ becomes cluttered. Second-degree edges always come from the knowledge graph, never from predictions.
31
+
32
+ Node colours mark the entity type (see the legend at the top of the graph). Predictions are drawn for the
33
+ selected protein only; select another protein in **Protein** to see its predictions.
34
+
35
+ Edge colours mark the kind of link:
36
+ - **Purple** – predicted GO term that is also annotated in the knowledge graph. Known annotations that
37
+ the model predicted are purple even when they rank below the top terms set by **Predicted GO terms**.
38
+ - **Red** – novel prediction (one of the top predicted terms) not annotated in the knowledge graph.
39
+ - **Blue** – GO annotation in the knowledge graph that the model did not predict in this run.
40
+ - **Grey** – other relationships, such as protein–protein interactions, domains, drug targets, pathways,
41
+ domain–GO mappings and GO hierarchy links.
42
+
43
+ Interacting with the graph:
44
+ - **Hover** a node to highlight its connections and show their relationship names, with a card giving the
45
+ node's name, type and a link to the source database (UniProt, QuickGO, InterPro, …). Hover an edge to see
46
+ the relationship and, for GO terms, the score.
47
+ - **Drag** nodes to rearrange them; **scroll** to zoom and drag the background to pan.
48
+ - **Click a legend item** to hide that node type or edge kind; nodes left without visible edges are hidden
49
+ too. Click it again to bring it back.
50
+ - Use the icons at the top right to **reset** the view, save it as a **PNG**, or download the edges currently
51
+ shown as **JSON** (with their scores and edge kind; hidden edge kinds and node types are left out).
52
+
53
+ For exploration beyond second-degree connections, download the complete knowledge graph from the link at
54
+ the top of the Knowledge graph tab.
55
+ """
56
+
57
+ _CSS = """
58
+ <style>
59
+ @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=DM+Mono:wght@400;500&display=swap');
60
+
61
+ /* Material icons (incl. st.status's stExpanderIcon*) must keep their icon font */
62
+ .stApp :is(p, h1, h2, h3, h4, h5, label, li, a, button, input, textarea, select, div, span):not([data-testid^="stIcon"]):not([data-testid^="stExpanderIcon"]) {
63
+ font-family: 'Manrope', sans-serif;
64
+ }
65
+ code, .mono { font-family: 'DM Mono', monospace !important; }
66
+
67
+ /* Hide the sidebar entirely: navigation lives in the header */
68
+ [data-testid="stSidebar"], [data-testid="stSidebarCollapsedControl"] { display: none; }
69
+
70
+ /* Streamlit's fixed top bar (3.75rem) overlaps the page: make it transparent and start content below it */
71
+ header[data-testid="stHeader"] { background: transparent; }
72
+ .block-container { max-width: 1100px; padding-top: 4.25rem; padding-bottom: 4rem; }
73
+
74
+ /* Header */
75
+ .phgt-brand { font-weight: 800; font-size: 1.35rem; letter-spacing: -0.01em; margin: 0 !important; line-height: 2.4rem; }
76
+ div[data-testid="stPageLink"] a { justify-content: flex-end; }
77
+ div[data-testid="stPageLink"] p { font-weight: 500; color: #4A4E5C; }
78
+
79
+ /* Hero */
80
+ .phgt-hero h1 { font-size: 2.1rem; font-weight: 800; letter-spacing: -0.02em; line-height: 1.15; margin: 1.6rem 0 0.4rem; padding: 0; }
81
+ .phgt-hero p { font-size: 1.05rem; color: #4A4E5C; margin: 0 0 1.4rem; } /* full width, like the stepper */
82
+ .phgt-hero-spacer { height: 1.5rem; } /* step 3 has no hero: keep the stepper clear of the header */
83
+
84
+ /* Stepper */
85
+ .phgt-steps { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; list-style: none; margin: 0 0 1.5rem; padding: 0; }
86
+ .phgt-step { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 12px; background: #FFFFFF; border: 1px solid #E4E2DC; }
87
+ .phgt-step.done { background: #EEF0FC; border-color: #EEF0FC; }
88
+ .phgt-step.current { border: 2px solid ACCENT; }
89
+ .phgt-dot { flex: none; width: 30px; height: 30px; border-radius: 999px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; background: #EDECE7; color: #4A4E5C; box-sizing: border-box; }
90
+ .phgt-step.done .phgt-dot { background: ACCENT; color: #FFFFFF; }
91
+ .phgt-step.current .phgt-dot { background: #1E288A; color: #FFFFFF; }
92
+ .phgt-step small { display: block; font-size: 12px; color: #4A4E5C; }
93
+ .phgt-step span.t { font-weight: 700; font-size: 15px; }
94
+ @media (max-width: 640px) {
95
+ /* keep the header links on one wrapped row instead of stacking */
96
+ div[data-testid="stHorizontalBlock"]:has(.phgt-brand) { flex-direction: row !important; flex-wrap: wrap !important; gap: 0 14px !important; }
97
+ div[data-testid="stHorizontalBlock"]:has(.phgt-brand) > div[data-testid="stColumn"] { width: max-content !important; flex: 0 0 max-content !important; min-width: 0 !important; }
98
+ div[data-testid="stHorizontalBlock"]:has(.phgt-brand) > div[data-testid="stColumn"]:first-child { flex: 1 0 100% !important; }
99
+ div[data-testid="stHorizontalBlock"]:has(.phgt-brand) div[data-testid="stPageLink"] { width: auto !important; }
100
+ .phgt-step small { display: none; }
101
+ .phgt-step span.t { font-size: 13px; }
102
+ .phgt-hero h1 { font-size: 1.8rem; }
103
+ }
104
+
105
+ /* Cards & chips */
106
+ div[data-testid="stVerticalBlockBorderWrapper"] { border-radius: 16px; }
107
+ /* Dataframes use the same frame as the cards (Streamlit sets the border inline, hence !important) */
108
+ div[data-testid="stDataFrameResizable"] { border: 1px solid #E4E2DC !important; border-radius: 16px !important; overflow: hidden; }
109
+ div[data-testid="stDataFrame"] .stDataFrameGlideDataEditor { border-radius: 16px; }
110
+ .phgt-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 10px; }
111
+ .phgt-chip { padding: 4px 9px; border-radius: 8px; background: #F3F2EE; font: 500 13px 'DM Mono', monospace; }
112
+ .phgt-muted { color: #4A4E5C; font-size: 0.9rem; }
113
+ .phgt-meter { height: 6px; border-radius: 3px; background: #EDECE7; margin: 6px 0 4px; }
114
+ .phgt-meter > div { height: 6px; border-radius: 3px; background: ACCENT; }
115
+ .phgt-count { display: block; padding-bottom: 0.6rem; } /* breathing room below the protein count */
116
+
117
+ /* Multiselect chips use the accent tint instead of Streamlit red */
118
+ span[data-baseweb="tag"] { background-color: #E2E5FB !important; color: #1E288A !important; font-family: 'DM Mono', monospace; }
119
+ span[data-baseweb="tag"] svg { fill: #1E288A !important; }
120
+
121
+ /* Primary buttons */
122
+ .stButton > button, .stDownloadButton > button { border-radius: 12px; min-height: 44px; font-weight: 600; }
123
+ .stButton > button[kind="primary"] { font-weight: 700; }
124
+
125
+ /* Metrics */
126
+ div[data-testid="stMetric"] { background: #FFFFFF; border: 1px solid #E4E2DC; border-radius: 14px; padding: 14px 16px; }
127
+ </style>
128
+ """.replace("ACCENT", ACCENT)
129
+
130
+
131
+ def apply_style():
132
+ st.markdown(_CSS, unsafe_allow_html=True)
133
+
134
+
135
+ def render_header(current="predict"):
136
+ apply_style()
137
+ cols = st.columns([3, 1.1, 1.1, 0.9, 1.1], vertical_alignment="center")
138
+ with cols[0]:
139
+ st.markdown('<p class="phgt-brand">ProtHGT</p>', unsafe_allow_html=True)
140
+ with cols[1]:
141
+ st.page_link("ProtHGT_app.py", label="Predict")
142
+ with cols[2]:
143
+ st.page_link("pages/User_Guide.py", label="User guide")
144
+ with cols[3]:
145
+ st.page_link("pages/About.py", label="About")
146
+ with cols[4]:
147
+ st.page_link(GITHUB_URL, label="GitHub")
148
+
149
+
150
+ def render_stepper(step):
151
+ labels = ["Add proteins", "Choose what to predict", "Review results"]
152
+ items = []
153
+ for i, label in enumerate(labels, start=1):
154
+ if i < step:
155
+ cls, mark, state = "done", "&#10003;", ""
156
+ elif i == step:
157
+ cls, mark, state = "current", str(i), ' aria-current="step"'
158
+ else:
159
+ cls, mark, state = "", str(i), ""
160
+ items.append(
161
+ f'<li class="phgt-step {cls}"{state}><span class="phgt-dot">{mark}</span>'
162
+ f'<span><small>Step {i}</small><span class="t">{label}</span></span></li>'
163
+ )
164
+ st.markdown(f'<ol class="phgt-steps" aria-label="Progress">{"".join(items)}</ol>', unsafe_allow_html=True)