ervau commited on
Commit
02bd35b
·
1 Parent(s): 8d0bbe4

implement ec prediction in the app and interface

Browse files
Files changed (2) hide show
  1. ProtHGT_app.py +147 -29
  2. ui.py +2 -0
ProtHGT_app.py CHANGED
@@ -15,6 +15,7 @@ st.set_page_config(
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
 
@@ -38,7 +39,7 @@ CATEGORY_TO_KEY = {"Molecular Function": "GO_term_F", "Biological Process": "GO_
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]
@@ -49,6 +50,7 @@ DEFAULTS = {
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,
@@ -89,6 +91,16 @@ 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."""
@@ -201,8 +213,9 @@ hero_slot = st.empty() # always created so the elements below keep a stable pos
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:
@@ -354,12 +367,23 @@ def render_step_2():
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(
@@ -382,13 +406,13 @@ def render_step_2():
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
 
@@ -396,8 +420,8 @@ def render_step_2():
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:
@@ -423,6 +447,27 @@ def run_prediction(proteins, aspects, threshold):
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:
@@ -479,6 +524,69 @@ def render_predictions(df, threshold):
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:
@@ -532,31 +640,35 @@ def render_knowledge_graph(df, proteins, aspects):
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")
@@ -565,15 +677,21 @@ def render_step_3():
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
 
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 run_prothgt_ec_app import EC_KG_PATH, generate_ec_prediction_df, load_ec_kg, load_ec_model
19
  from visualize_kg import visualize_protein_subgraph
20
  from ui import GITHUB_URL, KG_VIEW_GUIDE, STRICTNESS, render_header, render_stepper
21
 
 
39
  # Widget values that must survive moving between steps (Streamlit drops the state of
40
  # widgets that are not rendered; re-assigning keeps it).
41
  PERSISTENT_KEYS = ["proteins", "aspect_GO_term_F", "aspect_GO_term_P", "aspect_GO_term_C",
42
+ "predict_ec", "strictness", "use_custom_threshold", "custom_threshold"]
43
  for _k in PERSISTENT_KEYS:
44
  if _k in st.session_state:
45
  st.session_state[_k] = st.session_state[_k]
 
50
  "aspect_GO_term_F": True,
51
  "aspect_GO_term_P": True,
52
  "aspect_GO_term_C": True,
53
+ "predict_ec": False,
54
  "strictness": "Balanced",
55
  "use_custom_threshold": False,
56
  "custom_threshold": 0.50,
 
91
  return load_model(go_cat, get_kg())
92
 
93
 
94
+ @st.cache_resource(show_spinner=False)
95
+ def get_ec_kg():
96
+ return load_ec_kg()
97
+
98
+
99
+ @st.cache_resource(show_spinner=False)
100
+ def get_ec_model():
101
+ return load_ec_model(get_ec_kg())
102
+
103
+
104
  @st.cache_data(show_spinner=False, max_entries=200)
105
  def search_proteins(query):
106
  """Exact ID → ID prefix → name substring (closest names first) → fuzzy fallback."""
 
213
  if step < 3:
214
  hero_slot.markdown(
215
  '<div class="phgt-hero"><h1>Protein function prediction with knowledge graphs</h1>'
216
+ "<p>Gene Ontology term and Enzyme Commission number predictions from a heterogeneous graph "
217
+ "transformer that combines protein language model embeddings with a large-scale biological "
218
+ "knowledge graph.</p></div>",
219
  unsafe_allow_html=True,
220
  )
221
  else:
 
367
  with main:
368
  with st.container(border=True):
369
  st.subheader("Which functions are you interested in?")
370
+ # 2x2 grid: the three GO aspects plus EC numbers. Fixed-height cards so
371
+ # every box is the same size regardless of how much text it holds.
372
+ items = [(f"aspect_{k}", "Gene Ontology", label, desc) for k, (label, desc) in ASPECTS.items()]
373
+ items.append((
374
+ "predict_ec", "Enzyme Commission", "EC number",
375
+ "EC numbers for enzymes, predicted across the four hierarchy levels (L1–L4) as a "
376
+ "consistent top-down chain. Non-enzymes return no EC number.",
377
+ ))
378
+ for i in range(0, len(items), 2):
379
+ cols = st.columns(2)
380
+ for col, (key, eyebrow, title, desc) in zip(cols, items[i:i + 2]):
381
+ with col:
382
+ with st.container(border=True, height=158):
383
+ st.markdown(f'<span class="phgt-eyebrow">{eyebrow}</span>',
384
+ unsafe_allow_html=True)
385
+ st.checkbox(f"**{title}**", key=key)
386
+ st.caption(desc)
387
 
388
  st.subheader("How strict should predictions be?")
389
  st.radio(
 
406
  st.caption("A very low threshold returns almost every GO term for each protein; "
407
  "expect a large table.")
408
 
409
+ anything = selected_aspects() or st.session_state.predict_ec
410
  back, _, run = st.columns([1, 1.7, 1.3])
411
  back.button("← Back", use_container_width=True, on_click=go_to, args=(1,))
412
  run.button("Run prediction →", type="primary", use_container_width=True,
413
+ disabled=not anything, on_click=go_to, args=(3,))
414
+ if not anything:
415
+ st.caption("Select at least one function category or EC numbers.")
416
  with aside:
417
  render_limit_aside()
418
 
 
420
  # ---------------------------------------------------------------------------
421
  # Step 3 — run & review
422
  # ---------------------------------------------------------------------------
423
+ def run_go_prediction(proteins, aspects, threshold):
424
+ with st.status("Running ProtHGT (GO terms)…", expanded=True) as status:
425
  if os.path.exists(KG_PATH):
426
  st.write("Loading the knowledge graph…")
427
  else:
 
447
  return df.sort_values("Probability", ascending=False, ignore_index=True)
448
 
449
 
450
+ def run_ec_prediction(proteins, threshold):
451
+ with st.status("Running ProtHGT (EC numbers)…", expanded=True) as status:
452
+ if os.path.exists(EC_KG_PATH):
453
+ st.write("Loading the EC knowledge graph…")
454
+ else:
455
+ st.write("Downloading EC dataset files for the first time, this may take a few minutes…")
456
+ heterodata = get_ec_kg()
457
+ model = get_ec_model()
458
+ progress = st.progress(0.0)
459
+
460
+ def on_protein(i, n):
461
+ progress.progress(i / n, text=f"Scoring EC numbers ({i + 1}/{n})")
462
+
463
+ _, df = generate_ec_prediction_df(
464
+ proteins, heterodata, model, threshold=threshold, progress_callback=on_protein)
465
+ progress.progress(1.0, text="Done")
466
+ status.update(label=f"Predicted {len(df):,} protein–EC number associations", state="complete",
467
+ expanded=False)
468
+ return df.sort_values("Probability", ascending=False, ignore_index=True)
469
+
470
+
471
  @st.fragment
472
  def render_predictions(df, threshold):
473
  if df.empty:
 
524
  use_container_width=True)
525
 
526
 
527
+ def _expasy_url(ec):
528
+ """ExPASy ENZYME URL for an EC number or class prefix (padded to four fields with '-')."""
529
+ parts = ec.split(".")
530
+ parts += ["-"] * (4 - len(parts))
531
+ return "https://enzyme.expasy.org/EC/" + ".".join(parts)
532
+
533
+
534
+ @st.fragment
535
+ def render_ec_predictions(df, threshold):
536
+ if df.empty:
537
+ st.info("No EC numbers passed the score threshold — none of these proteins were predicted as "
538
+ "enzymes at this strictness. Try a lower strictness in step 2.")
539
+ return
540
+
541
+ f1, f2, f3 = st.columns([1.2, 1.2, 1.6])
542
+ proteins = ["All proteins"] + sorted(df["UniProt_ID"].unique())
543
+ protein = f1.selectbox("Protein", proteins, key="ec_protein",
544
+ format_func=lambda p: p if p == "All proteins"
545
+ else f"{p} — {df.loc[df.UniProt_ID == p, 'Protein'].iat[0]}")
546
+ all_levels = ["L1", "L2", "L3", "L4"]
547
+ available = [lv for lv in all_levels if lv in set(df["EC_level"])]
548
+ levels = f2.pills("Level", available, selection_mode="multi", default=available, key="ec_levels")
549
+ text = f3.text_input("Search EC number", placeholder="e.g. 2.7.11", key="ec_search")
550
+ min_score = st.slider("Minimum score", float(threshold), 1.0, float(threshold), step=0.01, key="ec_min")
551
+
552
+ view = df
553
+ if protein != "All proteins":
554
+ view = view[view["UniProt_ID"] == protein]
555
+ view = view[view["EC_level"].isin(levels or [])]
556
+ if text.strip():
557
+ view = view[view["EC"].str.contains(text.strip(), case=False, regex=False)]
558
+ view = view[view["Probability"] >= min_score]
559
+
560
+ display = view.assign(
561
+ UniProt_ID="https://www.uniprot.org/uniprotkb/" + view["UniProt_ID"] + "/entry",
562
+ EC=view["EC"].map(_expasy_url),
563
+ )
564
+ st.dataframe(
565
+ display,
566
+ hide_index=True,
567
+ use_container_width=True,
568
+ height=520,
569
+ column_config={
570
+ "UniProt_ID": st.column_config.LinkColumn("UniProt ID", display_text=r"uniprotkb/([A-Z0-9]+)/entry"),
571
+ "Protein": st.column_config.TextColumn("Protein", width="large"),
572
+ "EC": st.column_config.LinkColumn("EC number", display_text=r"EC/([\d.]*\d)",
573
+ help="L1 (class) → L4 (full EC number). Opens the ExPASy ENZYME entry."),
574
+ "EC_name": st.column_config.TextColumn("Name", width="large",
575
+ help="Enzyme class (L1–L3) or enzyme name (L4)."),
576
+ "EC_level": st.column_config.TextColumn("Level", width="small"),
577
+ "Probability": st.column_config.NumberColumn("Score", format="%.3f", width="small"),
578
+ },
579
+ )
580
+ st.caption("Predictions form a consistent hierarchy: every predicted L4 number has its predicted "
581
+ "L3/L2/L1 ancestors. A protein can stop at an intermediate level when no deeper level "
582
+ "passes the threshold.")
583
+ c1, c2 = st.columns([3, 1.3], vertical_alignment="center")
584
+ c1.caption(f"Showing {len(view):,} of {len(df):,} predictions")
585
+ c2.download_button("Download CSV", view.to_csv(index=False).encode("utf-8"),
586
+ file_name="prothgt_ec_predictions.csv", mime="text/csv",
587
+ use_container_width=True, key="ec_download")
588
+
589
+
590
  @st.fragment
591
  def render_knowledge_graph(df, proteins, aspects):
592
  if len(proteins) > MAX_KG_VIZ_PROTEINS:
 
640
  def render_step_3():
641
  proteins = list(st.session_state.proteins)
642
  aspects = selected_aspects()
643
+ predict_ec = st.session_state.predict_ec
644
+ if not proteins or not (aspects or predict_ec):
645
  go_to(1 if not proteins else 2)
646
  st.rerun()
647
 
648
  threshold = current_threshold()
649
+ run_key = (tuple(proteins), tuple(aspects), predict_ec, threshold)
650
  results = st.session_state.results
651
  if results is None or results["key"] != run_key:
652
  clear_visualizations()
653
  cache = st.session_state.results_cache
654
  if run_key in cache: # switching back to settings already run in this session is instant
655
+ payload = cache[run_key]
656
  else:
657
  try:
658
+ df_go = run_go_prediction(proteins, aspects, threshold) if aspects else None
659
+ df_ec = run_ec_prediction(proteins, threshold) if predict_ec else None
660
  except Exception as exc: # surface failures instead of a blank page
661
  st.error(f"Prediction failed: {exc}")
662
  st.button("← Back to settings", on_click=go_to, args=(2,))
663
  return
664
+ payload = {"go": df_go, "ec": df_ec}
665
+ cache[run_key] = payload
666
  while len(cache) > 3: # keep memory per visitor bounded
667
  cache.pop(next(iter(cache)))
668
+ st.session_state.results = {"key": run_key, "payload": payload}
669
  results = st.session_state.results
670
+ payload = results["payload"]
671
+ df_go, df_ec = payload["go"], payload["ec"]
672
 
673
  head, b1, b2 = st.columns([3, 1.1, 1.1], vertical_alignment="bottom")
674
  head.subheader("Results")
 
677
 
678
  m1, m2, m3, m4 = st.columns(4)
679
  m1.metric("Proteins", len(proteins))
680
+ m2.metric("Predicted GO terms", f"{len(df_go):,}" if df_go is not None else "—")
681
+ m3.metric("Predicted EC numbers", f"{len(df_ec):,}" if df_ec is not None else "—")
682
  m4.metric("Score threshold", f"{threshold:.2f}")
683
 
684
+ tab_specs = []
685
+ if aspects:
686
+ tab_specs.append(("GO terms", lambda: render_predictions(df_go, threshold)))
687
+ if predict_ec:
688
+ tab_specs.append(("EC numbers", lambda: render_ec_predictions(df_ec, threshold)))
689
+ if aspects:
690
+ tab_specs.append(("Knowledge graph", lambda: render_knowledge_graph(df_go, proteins, aspects)))
691
+
692
+ for tab, (_, render) in zip(st.tabs([label for label, _ in tab_specs]), tab_specs):
693
+ with tab:
694
+ render()
695
 
696
 
697
  # Render the step inside one st.empty() slot so the previous step's widgets are replaced
ui.py CHANGED
@@ -110,6 +110,8 @@ 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 */
 
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
+ /* Small category label above a card title (e.g. "Gene Ontology" over "Molecular function") */
114
+ .phgt-eyebrow { display: block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: ACCENT; margin-bottom: 2px; }
115
  .phgt-meter { height: 6px; border-radius: 3px; background: #EDECE7; margin: 6px 0 4px; }
116
  .phgt-meter > div { height: 6px; border-radius: 3px; background: ACCENT; }
117
  .phgt-count { display: block; padding-bottom: 0.6rem; } /* breathing room below the protein count */