Spaces:
Paused
Paused
Erva Ulusoy commited on
Commit ·
475bc81
1
Parent(s): 614205a
redesign app as a three-step interface
Browse files- .streamlit/config.toml +14 -1
- ProtHGT_app.py +564 -705
- pages/User_Guide.py +43 -232
- 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 |
-
|
| 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 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 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 |
-
|
| 155 |
-
|
| 156 |
-
|
| 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 |
-
|
| 262 |
-
|
| 263 |
-
|
| 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 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 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 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 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 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
)
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
.
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 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 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
)
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
st.
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
| 11 |
-
|
| 12 |
-
''')
|
| 13 |
|
|
|
|
| 14 |
st.markdown("""
|
| 15 |
-
ProtHGT
|
|
|
|
| 16 |
""")
|
| 17 |
|
| 18 |
-
st.subheader("1.
|
| 19 |
st.markdown("""
|
| 20 |
-
|
| 21 |
-
- **
|
| 22 |
-
- **Search
|
| 23 |
-
- **Upload
|
| 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 |
-
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 76 |
""")
|
| 77 |
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
st.markdown("""
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
3. Click **"Generate Visualization"** to render the graph.
|
| 85 |
""")
|
| 86 |
|
| 87 |
-
st.subheader("
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
-
**
|
| 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 |
-
|
| 237 |
""")
|
| 238 |
|
| 239 |
-
st.subheader("
|
| 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.
|
| 251 |
st.markdown("""
|
| 252 |
-
For
|
| 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", "✓", ""
|
| 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)
|