from __future__ import annotations
from html import escape
from math import erf, sqrt
from pathlib import Path
from tempfile import NamedTemporaryFile
import gradio as gr
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parent
ARTIFACTS = ROOT / "artifacts"
CATALOG = pd.read_csv(ARTIFACTS / "gene_insight_catalog.csv").set_index("gene")
ALL_PRIOR = pd.read_csv(ARTIFACTS / "all_common_gene_prior.csv").set_index("gene")
EXPRESSION = pd.read_csv(ARTIFACTS / "candidate_expression_133.csv")
GENES = CATALOG.index.tolist()
DATASETS = ["GSE57475", "GSE6613", "GSE72267", "GSE99039"]
CATEGORY_LABELS = {
"direct_pd_gene": "direct PD evidence",
"synaptic_dopaminergic_neuronal": "synaptic and dopaminergic signaling",
"mitochondrial_oxidative_stress": "mitochondrial and oxidative-stress biology",
"neuroinflammation_immune": "neuroinflammation and immune signaling",
"apoptosis_stress_response": "apoptosis and cellular stress response",
"lysosome_autophagy_proteostasis": "lysosome, autophagy, and proteostasis",
}
PRIOR_MAX = float(CATALOG["evidence_score_norm"].max())
def normal_cdf(value: float) -> float:
return 0.5 * (1.0 + erf(value / sqrt(2.0)))
def mann_whitney_approx(control: np.ndarray, pd_values: np.ndarray) -> float:
combined = pd.Series(np.concatenate([control, pd_values]))
ranks = combined.rank(method="average").to_numpy()
n_control = len(control)
n_pd = len(pd_values)
u_value = ranks[n_control:].sum() - n_pd * (n_pd + 1) / 2
mean_u = n_control * n_pd / 2
counts = combined.value_counts().to_numpy()
total = n_control + n_pd
tie_term = np.sum(counts**3 - counts)
variance = n_control * n_pd / 12 * (
total + 1 - tie_term / (total * max(total - 1, 1))
)
if variance <= 0:
return 1.0
z_score = (u_value - mean_u) / sqrt(variance)
return float(2 * (1 - normal_cdf(abs(z_score))))
def dataset_evidence(gene: str) -> pd.DataFrame:
rows = []
for dataset in DATASETS:
cohort = EXPRESSION.loc[EXPRESSION["batch"].eq(dataset), [gene, "label"]].dropna()
control = cohort.loc[cohort["label"].eq(0), gene].astype(float).to_numpy()
pd_values = cohort.loc[cohort["label"].eq(1), gene].astype(float).to_numpy()
difference = float(pd_values.mean() - control.mean())
rows.append(
{
"dataset": dataset,
"mean_difference": difference,
"direction": "Higher in PD" if difference > 0 else "Lower in PD",
"p_value": mann_whitney_approx(control, pd_values),
}
)
return pd.DataFrame(rows)
def biological_themes(categories: str) -> list[str]:
if pd.isna(categories) or str(categories).strip().lower() in {"", "none", "nan"}:
return []
return [
CATEGORY_LABELS.get(item, item.replace("_", " "))
for item in str(categories).split(";")
]
def fallback_interpretation(gene: str, categories: str) -> str:
return (
f"{gene} is an evidence-supported PD candidate linked to "
f"{', '.join(biological_themes(categories))}. Gene-specific mechanisms "
"should be verified against primary literature."
)
def evidence_score(row: pd.Series, data: pd.DataFrame) -> int:
prior_component = 30 * float(row["evidence_score_norm"]) / PRIOR_MAX
stability_component = 30 * float(row["selection_frequency"]) / 4
gate_value = row.get("mean_gate_probability")
gate_component = 0.0
if not pd.isna(gate_value):
gate_component = 20 * float(gate_value)
directions = data["direction"].tolist()
direction_consistency = max(directions.count("Higher in PD"), directions.count("Lower in PD"))
data_component = 20 * direction_consistency / 4
return int(round(prior_component + stability_component + gate_component + data_component))
def support_label(score: int) -> str:
if score >= 75:
return "Strong candidate biomarker"
if score >= 50:
return "Moderate candidate biomarker"
return "Exploratory candidate biomarker"
def prior_support_label(level: int) -> str:
return {
0: "Not included in the final 133-gene candidate set",
1: "Not included in the final 133-gene candidate set",
2: "Evidence-supported candidate gene",
3: "High-evidence candidate gene",
}.get(level, "Not evaluated in the common-gene prior")
def format_number(value, digits: int = 3) -> str:
if pd.isna(value):
return "Not available"
return f"{float(value):.{digits}f}"
def metric_rows(items: list[tuple[str, str]]) -> str:
return "".join(
f'
{escape(label)}{escape(value)}
'
for label, value in items
)
def create_download(
gene: str,
row: pd.Series,
data: pd.DataFrame,
score: int,
label: str,
interpretation: str,
) -> str:
directions = "; ".join(
f"{item.dataset}: {item.direction} (p={item.p_value:.3g})"
for item in data.itertuples()
)
report = pd.DataFrame(
[
{
"gene": gene,
"candidate_status": label,
"overall_evidence_score": score,
"selected_by_stablegate": bool(row["selected_by_stablegate"]),
"selection_frequency": f"{int(row['selection_frequency'])}/4",
"stablegate_rank": row.get("stability_rank", ""),
"mean_gate_probability": row.get("mean_gate_probability", ""),
"shap_importance_rank": row.get("shap_importance_rank", ""),
"evidence_level": int(row["evidence_level"]),
"evidence_score_norm": row["evidence_score_norm"],
"biological_themes": "; ".join(biological_themes(row["evidence_categories"])),
"mechanism_summary": interpretation,
"dataset_expression_evidence": directions,
}
]
)
handle = NamedTemporaryFile(
delete=False,
suffix=f"_{gene}_evidence_summary.csv",
mode="w",
encoding="utf-8",
newline="",
)
report.to_csv(handle.name, index=False)
handle.close()
return handle.name
def query_gene(gene: str):
gene = str(gene or "").strip().upper()
if not gene:
gene = "UNSPECIFIED"
if gene not in CATALOG.index:
return query_non_candidate_gene(gene)
row = CATALOG.loc[gene].copy()
data = dataset_evidence(gene)
score = evidence_score(row, data)
label = support_label(score)
interpretation = row.get("interpretation")
if pd.isna(interpretation) or not str(interpretation).strip():
interpretation = fallback_interpretation(gene, row["evidence_categories"])
directions = data["direction"].tolist()
dominant_direction = max(set(directions), key=directions.count)
consistent_count = directions.count(dominant_direction)
significant_count = int(data["p_value"].lt(0.05).sum())
themes = ", ".join(biological_themes(row["evidence_categories"]))
result_card = f"""
{escape(gene)}Evidence-supported PD candidate gene
{escape(label)}
Overall score
{score}/ 100
"""
model_evidence = metric_rows(
[
("Gate score", format_number(row.get("mean_gate_probability"))),
("Selection frequency", f"{int(row['selection_frequency'])}/4 datasets"),
("StableGate importance rank", format_number(row.get("stability_rank"), 0)),
("SHAP importance rank", format_number(row.get("shap_importance_rank"), 0)),
]
)
data_evidence = metric_rows(
[
("Dominant expression direction", dominant_direction),
("Direction consistency", f"{consistent_count}/4 datasets"),
("Datasets with nominal p<0.05", f"{significant_count}/4 datasets"),
("Four-cohort analysis status", "Available"),
]
)
biological_evidence = (
metric_rows(
[
("Knowledge-prior evidence level", f"{int(row['evidence_level'])}/3"),
("Knowledge-prior rank", f"{int(row['knowledge_rank'])}/10,939 genes"),
("PD-related themes", themes),
]
)
+ f'{escape(str(interpretation))}
'
)
conclusion = (
f"{gene} is classified as a {label.lower()} based on StableGate selection "
f"stability, SHAP/gate evidence, expression consistency across four GEO datasets, "
f"and evidence-level {int(row['evidence_level'])} biological prior."
)
conclusion_card = f"""
Overall summary
{escape(conclusion)}
For biomarker research prioritization only. Not intended for clinical diagnosis.
"""
download = create_download(gene, row, data, score, label, str(interpretation))
return result_card, model_evidence, data_evidence, biological_evidence, conclusion_card, download
def query_non_candidate_gene(gene: str):
in_common_genes = gene in ALL_PRIOR.index
if in_common_genes:
row = ALL_PRIOR.loc[gene]
level = int(row["evidence_level"])
normalized_score = float(row["evidence_score_norm"])
score = int(round(normalized_score * 100))
categories = str(row["evidence_categories"])
rank_text = str(int(row["knowledge_rank"]))
level_text = f"{level} / 3"
status = prior_support_label(level)
themes = biological_themes(categories)
theme_text = ", ".join(themes) if themes else "No predefined PD-related theme"
interpretation = (
f"{gene} was evaluated among the 10,939 common genes but did not meet the "
"Level 2/3 threshold used to construct the final 133-gene candidate set."
)
coverage_text = "Evaluated among 10,939 common genes"
else:
level = 0
score = 0
rank_text = "Not available"
level_text = "Not evaluated"
status = "Not present in the 10,939 common-gene analysis space"
theme_text = "No evidence annotation available"
interpretation = (
f"{gene} was not present in the 10,939 genes shared across the four GEO "
"datasets; therefore, it was not evaluated by the knowledge-prior or model pipeline."
)
coverage_text = "Outside the common-gene analysis space"
result_card = f"""
{escape(gene)}{escape(coverage_text)}
{escape(status)}
"""
model_evidence = metric_rows(
[
("133-gene candidate set", "No"),
("StableGate evaluation", "Not evaluated"),
("Gate score", "Not available"),
("SHAP importance", "Not available"),
]
)
data_evidence = metric_rows(
[
("Four-cohort candidate expression analysis", "Not evaluated"),
("Expression direction consistency", "Not available"),
("Differential-expression evidence", "Not available"),
("Analysis-space status", coverage_text),
]
)
biological_evidence = (
metric_rows(
[
("Knowledge-prior evidence level", level_text),
("Knowledge-prior rank", rank_text),
("PD-related themes", theme_text),
]
)
+ f'{escape(interpretation)}
'
)
conclusion_card = f"""
Overall summary
{escape(interpretation)} This result should not be interpreted as evidence that the gene is unrelated to Parkinson's disease; it only describes its status under the predefined study pipeline.
For biomarker research prioritization only. Not intended for clinical diagnosis.
"""
report = pd.DataFrame(
[
{
"gene": gene,
"in_133_gene_candidate_set": False,
"in_10939_common_gene_space": in_common_genes,
"candidate_status": status,
"knowledge_prior_evidence_level": level_text,
"knowledge_prior_evidence_score": score,
"knowledge_prior_rank": rank_text,
"biological_themes": theme_text,
"model_evidence": "Not evaluated",
"interpretation": interpretation,
}
]
)
handle = NamedTemporaryFile(
delete=False,
suffix=f"_{gene}_evidence_summary.csv",
mode="w",
encoding="utf-8",
newline="",
)
report.to_csv(handle.name, index=False)
handle.close()
return result_card, model_evidence, data_evidence, biological_evidence, conclusion_card, handle.name
def show_results():
return gr.update(visible=True), gr.update(visible=True), gr.update(visible=True)
def clear_query():
return "", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
css = """
body { background: #FAFAF8 !important; }
.gradio-container {
width: calc(100% - 32px) !important; max-width: 920px !important; min-width: 0 !important;
margin: auto !important; padding-top: 16px !important;
}
footer { display: none !important; }
.panel-card, .result-panel, .evidence-panel, .summary-panel {
border: 1px solid #BCC1C4 !important; border-radius: 14px !important;
background: #FFFFFF !important; box-shadow: none !important;
box-sizing: border-box !important; width: 100% !important;
margin: 0 0 14px 0 !important;
}
.panel-card { padding: 18px 24px !important; }
.panel-card > *, .result-panel > *, .summary-panel > *,
.panel-card .html-container, .panel-card .form,
.result-panel .html-container,
.summary-panel .html-container, .summary-panel .form {
background: transparent !important; border: 0 !important;
box-shadow: none !important; padding-left: 0 !important; padding-right: 0 !important;
}
.platform-title { font-size: 19px; font-weight: 800; color: #191D21; margin-bottom: 12px;
text-align: center; width: 100%; }
.platform-note { height: 8px; width: 100%; max-width: none; border-radius: 4px; margin: 10px 0 4px;
background: linear-gradient(90deg,#ECEAE6 8%,#D9E6F0 28%,#AAC5DE 58%,#EDF0F2 100%); }
.gene-input-label { color: #59616B; font-size: 14px; margin: 14px 0 6px; }
.input-row { align-items: stretch !important; gap: 10px; }
.input-row .form { border: 0 !important; background: transparent !important; }
#gene-input, #gene-input input, #gene-input textarea, #search-button {
height: 44px !important; min-height: 44px !important; margin: 0 !important;
box-sizing: border-box !important;
}
#gene-input input, #gene-input textarea { padding-top: 10px !important; padding-bottom: 10px !important; }
#search-button, #clear-button {
height: 44px !important; min-height: 44px !important; border-radius: 12px !important;
align-self: stretch !important; padding-left: 14px !important; padding-right: 14px !important;
}
#search-button { width: 88px !important; min-width: 88px !important; }
#clear-button { width: 74px !important; min-width: 74px !important;
background: var(--button-secondary-background-fill) !important;
border: var(--button-secondary-border-width) solid var(--button-secondary-border-color) !important; }
.result-panel { padding: 20px 24px !important; }
.result-content { width: 100%; }
.result-top { display: flex; justify-content: space-between; align-items: center; gap: 20px; }
.gene-line { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.gene-name { font-family: Georgia, serif; font-size: 23px; color: #25292E; }
.gene-subtitle { font-size: 13px; color: #59616B; }
.candidate-label { display: inline-block; margin-top: 9px; padding: 3px 12px;
border-radius: 999px; background: #E8F3DC; color: #2F6B2D; font-size: 12px; font-weight: 700; }
.candidate-label.non-candidate { background: #FFF0D5; color: #946000; }
.score-block { text-align: right; min-width: 112px; color: #434A52; }
.score-block > span { font-size: 12px; }
.score-block b { font-size: 45px; color: #111; line-height: 1; }
.score-block small { font-size: 13px; margin-left: 4px; }
.score-track { height: 5px; background: #F1F0EB; border-radius: 4px; margin-top: 17px; overflow: hidden; }
.score-track div { height: 100%; background: #239E72; border-radius: 4px; }
.score-track.prior div { background: #D49A36; }
.evidence-panel { padding: 16px 24px 18px !important; }
.evidence-panel .tab-nav { border-bottom: 1px solid #D5D8DA !important; }
.evidence-panel button[role="tab"] { padding: 10px 18px !important; font-size: 14px !important; }
.evidence-panel button[aria-selected="true"] { color: #202529 !important; border-bottom: 2px solid #202529 !important; }
.metric-row { display: flex; justify-content: space-between; gap: 20px; padding: 10px 0;
border-bottom: 1px solid #E0E2E3; color: #454B52; font-size: 14px; }
.metric-row:last-child { border-bottom: 0; }
.metric-row b { color: #15191D; text-align: right; max-width: 58%; }
.explanation-text { color: #555E68; line-height: 1.65; padding-top: 14px; }
.conclusion-title { font-weight: 800; color: #202529; margin-bottom: 8px; }
.summary-panel { padding: 18px 24px !important; }
.conclusion-content { color: #454B52; line-height: 1.6; }
.research-note { color: #7A828B; font-size: 12px; margin-top: 7px; }
#download-button { width: fit-content !important; min-width: 130px !important; border-radius: 10px !important;
background: #FFFFFF !important; }
@media (max-width: 680px) {
.result-top { align-items: flex-start; }
.score-block b { font-size: 36px; }
.metric-row b { max-width: 52%; }
}
"""
with gr.Blocks(title="PD-BioMarker Evidence Query", css=css) as demo:
with gr.Column(elem_classes="panel-card"):
gr.HTML(
'Parkinson's Disease Blood Biomarker Evidence Portal
'
''
)
gr.HTML('Gene Symbol
')
with gr.Row(elem_classes="input-row"):
gene_input = gr.Textbox(
value="",
show_label=False,
container=False,
placeholder="Enter a standard gene symbol, e.g., SNCA",
scale=5,
min_width=0,
elem_id="gene-input",
)
query_button = gr.Button("Search", scale=0, min_width=88, elem_id="search-button")
clear_button = gr.Button("Clear", scale=0, min_width=74, elem_id="clear-button")
with gr.Column(elem_classes="result-panel", visible=False) as result_panel:
result_card = gr.HTML()
with gr.Tabs(elem_classes="evidence-panel", visible=False) as evidence_panel:
with gr.Tab("Model evidence"):
model_evidence = gr.HTML()
with gr.Tab("Data evidence"):
data_evidence = gr.HTML()
with gr.Tab("Biological interpretation"):
biological_evidence = gr.HTML()
with gr.Column(elem_classes="summary-panel", visible=False) as summary_panel:
conclusion_card = gr.HTML()
download_file = gr.DownloadButton("Download report", elem_id="download-button")
query_event = query_button.click(
fn=query_gene,
inputs=gene_input,
outputs=[
result_card,
model_evidence,
data_evidence,
biological_evidence,
conclusion_card,
download_file,
],
api_name="query_gene",
)
query_event.then(
fn=show_results,
inputs=None,
outputs=[result_panel, evidence_panel, summary_panel],
)
clear_button.click(
fn=clear_query,
inputs=None,
outputs=[
gene_input,
result_panel,
evidence_panel,
summary_panel,
],
api_name="clear_query",
)
if __name__ == "__main__":
demo.launch()