EphAsad's picture
Upload 9 files
2baf26e verified
Raw
History Blame Contribute Delete
17.7 kB
import json
from pathlib import Path
import gradio as gr
import pandas as pd
from phenotype_inference import PhenotypeClassifier
from phenotype_next_tests import PhenotypeNextTestRecommender
BASE_DIR = Path(__file__).parent
MODEL_PATH = BASE_DIR / "phenotype_tinytransformer_v1_temperature_scaled.pt"
REFERENCE_PATH = BASE_DIR / "phenotype_reference_distributions.json"
classifier = PhenotypeClassifier(MODEL_PATH)
recommender = PhenotypeNextTestRecommender(classifier, REFERENCE_PATH)
with open(REFERENCE_PATH, "r", encoding="utf-8") as f:
reference = json.load(f)
SCHEMA = reference["schema"]
GLOBAL_FIELD_VALUE_COUNTS = reference["global_field_value_counts"]
DEFAULT_RECOMMENDABLE_FIELDS = reference["default_recommendable_fields"]
DISCLAIMER = (
"This is a phenotype-based genus prediction tool. "
"It is not a confirmed laboratory identification, diagnostic result, "
"or replacement for validated microbiology workflows."
)
def get_field_choices(field):
counter = GLOBAL_FIELD_VALUE_COUNTS.get(field, {})
values = sorted(counter.keys())
# Keep dropdowns sensible.
if field in {"Colony Morphology", "Media Grown On", "Growth Temperature"}:
return values[:200]
return values
def clean_value(value):
if value is None:
return None
if isinstance(value, str):
value = value.strip()
if value == "":
return None
return value
def collect_features(*values):
features = {}
for field, value in zip(SCHEMA, values):
cleaned = clean_value(value)
if cleaned is None:
continue
# Gradio CheckboxGroup may return list.
if isinstance(cleaned, list):
cleaned = [str(v).strip() for v in cleaned if str(v).strip()]
if not cleaned:
continue
features[field] = "; ".join(cleaned)
else:
features[field] = str(cleaned).strip()
return features
def confidence_badge(confidence):
if confidence == "High":
return "🟢 High"
if confidence == "Medium":
return "🟡 Medium"
return "🔴 Low"
def format_prediction_markdown(prediction):
lines = []
lines.append("## Prediction")
lines.append("")
lines.append(f"**Top genus:** `{prediction['top_genus']}`")
lines.append(f"**Probability:** `{prediction['top_probability']:.4f}`")
lines.append(f"**Margin:** `{prediction['margin']:.4f}`")
lines.append(f"**Confidence:** {confidence_badge(prediction['confidence'])}")
lines.append(f"**Distinctness:** `{prediction['distinctness']}`")
lines.append(f"**Fields used:** `{prediction['num_provided_fields']}`")
lines.append(f"**Model tokens:** `{prediction['num_model_tokens']}`")
lines.append(f"**Unknown model tokens:** `{prediction['num_unknown_model_tokens']}`")
lines.append("")
lines.append(f"> {DISCLAIMER}")
return "\n".join(lines)
def ranked_genera_dataframe(prediction):
rows = []
for item in prediction["ranked_genera"]:
rows.append({
"Rank": item["rank"],
"Genus": item["genus"],
"Probability": round(item["probability"], 6),
})
return pd.DataFrame(rows)
def recommendations_dataframe(recommendations):
rows = []
for i, rec in enumerate(recommendations, start=1):
likely_outcomes = []
for value in rec["candidate_values"][:5]:
likely_outcomes.append(
f"{value['value']}{value['top_genus_after']} "
f"(w={value['estimated_outcome_weight']:.2f}, "
f"p={value['top_probability_after']:.2f})"
)
rows.append({
"Rank": i,
"Field": rec["field"],
"Discriminatory Score": round(rec.get("discriminatory_score", 0), 4),
"Confirmation Score": round(rec.get("confirmation_score", 0), 4),
"Model Info Gain": round(rec.get("model_information_gain_bits", 0), 4),
"Pairwise Separation": round(rec.get("empirical_pairwise_tv_separation", 0), 4),
"Challenge Rate": round(rec.get("challenge_rate", 0), 4),
"Evidence Records": rec.get("evidence_records_among_top_genera", 0),
"Likely Outcomes": " | ".join(likely_outcomes),
})
return pd.DataFrame(rows)
def provided_missing_markdown(prediction):
provided = prediction.get("provided_fields", [])
missing = prediction.get("missing_fields", [])
ignored = prediction.get("unknown_input_fields_ignored", [])
unknown_tokens = prediction.get("unknown_model_tokens", [])
lines = []
lines.append("## Input audit")
lines.append("")
lines.append("### Provided fields")
if provided:
lines.extend([f"- {field}" for field in provided])
else:
lines.append("- None")
lines.append("")
lines.append("### Missing fields")
if missing:
lines.extend([f"- {field}" for field in missing])
else:
lines.append("- None")
if ignored:
lines.append("")
lines.append("### Unknown input fields ignored")
lines.extend([f"- {field}" for field in ignored])
if unknown_tokens:
lines.append("")
lines.append("### Unknown model tokens")
lines.extend([f"- {token}" for token in unknown_tokens])
return "\n".join(lines)
def predict_and_recommend(
top_k,
n_recommendations,
top_competing_genera,
include_context_fields,
excluded_next_test_fields,
*field_values,
):
features = collect_features(*field_values)
if not features:
empty_df = pd.DataFrame(columns=["Rank", "Genus", "Probability"])
empty_rec_df = pd.DataFrame()
return (
"Please enter at least one phenotype field.",
empty_df,
empty_rec_df,
empty_rec_df,
"No input provided.",
"{}",
)
prediction = classifier.predict(features, top_k=int(top_k))
excluded_next_test_fields = set(excluded_next_test_fields or [])
if include_context_fields:
candidate_fields = [
field for field in SCHEMA
if field not in features and field not in excluded_next_test_fields
]
else:
candidate_fields = [
field for field in DEFAULT_RECOMMENDABLE_FIELDS
if field not in features and field not in excluded_next_test_fields
]
recommendations = recommender.recommend(
features,
n_recommendations=int(n_recommendations),
top_competing_genera=int(top_competing_genera),
max_candidate_values_per_field=8,
include_context_fields=bool(include_context_fields),
fields_to_consider=candidate_fields,
)
prediction_md = format_prediction_markdown(prediction)
ranked_df = ranked_genera_dataframe(prediction)
discriminatory_df = recommendations_dataframe(
recommendations["discriminatory_recommendations"]
)
confirmation_df = recommendations_dataframe(
recommendations["confirmation_recommendations"]
)
audit_md = provided_missing_markdown(prediction)
raw_json = json.dumps(
{
"input_features": features,
"prediction": prediction,
"recommendations": recommendations,
},
indent=2,
)
return prediction_md, ranked_df, discriminatory_df, confirmation_df, audit_md, raw_json
def example_achromobacter():
return {
"Gram Stain": "Negative",
"Shape": "Rods",
"Catalase": "Positive",
"Oxidase": "Positive",
"Motility": "Positive",
"Indole": "Negative",
"Citrate": "Positive",
"Urease": "Negative",
"Growth Temperature": "20//37",
"Media Grown On": "Blood Agar; MacConkey Agar",
}
def example_staphylococcus():
return {
"Gram Stain": "Positive",
"Shape": "Cocci",
"Catalase": "Positive",
"Oxidase": "Negative",
"Coagulase": "Positive",
"DNase": "Positive",
"Glucose Fermentation": "Positive",
"Mannitol Fermentation": "Positive",
"NaCl Tolerant (>=6%)": "Positive",
"Haemolysis": "Positive",
"Haemolysis Type": "Beta",
}
def example_salmonella_like():
return {
"Gram Stain": "Negative",
"Shape": "Rods",
"Catalase": "Positive",
"Oxidase": "Negative",
"Glucose Fermentation": "Positive",
"Lactose Fermentation": "Negative",
"Sucrose Fermentation": "Negative",
"H2S": "Positive",
"Urease": "Negative",
"Indole": "Negative",
"Citrate": "Positive",
"Motility": "Positive",
"TSI Pattern": "K/A",
"Gas Production": "Positive",
}
EXAMPLES = [
example_achromobacter(),
example_staphylococcus(),
example_salmonella_like(),
]
def values_from_example(example):
return [example.get(field, None) for field in SCHEMA]
def clear_all():
return [None for _ in SCHEMA]
with gr.Blocks(title="PhenotypeClassifier", theme=gr.themes.Soft()) as demo:
gr.Markdown(
"""
# PhenotypeClassifier
Phenotype-based bacterial genus prediction using a calibrated TinyTransformer model.
This demo returns a ranked genus prediction and recommends next tests in two ways:
- **Discriminatory tests**: best for separating the current top competing genera.
- **Confirmation tests**: best for strengthening/checking the current top prediction.
> This is not a confirmed laboratory identification and should not replace validated microbiology workflows.
"""
)
with gr.Row():
with gr.Column(scale=1):
gr.Markdown("## Input phenotype")
field_components = []
with gr.Accordion("Core morphology and basic tests", open=True):
core_fields = [
"Gram Stain",
"Shape",
"Catalase",
"Oxidase",
"Colony Morphology",
"Haemolysis",
"Haemolysis Type",
"Growth Temperature",
"Media Grown On",
"Motility",
"Oxygen Requirement",
]
for field in core_fields:
if field in {"Colony Morphology", "Media Grown On"}:
comp = gr.Textbox(
label=field,
placeholder="Use semicolons for multiple values, e.g. Smooth; Grey",
)
elif field == "Growth Temperature":
comp = gr.Textbox(
label=field,
placeholder="e.g. 20//37",
)
else:
comp = gr.Dropdown(
label=field,
choices=get_field_choices(field),
value=None,
allow_custom_value=True,
)
field_components.append((field, comp))
with gr.Accordion("Biochemical tests", open=True):
biochemical_fields = [
"Indole",
"Methyl Red",
"VP",
"Citrate",
"Urease",
"H2S",
"Nitrate Reduction",
"Lysine Decarboxylase",
"Ornithine Decarboxylase",
"Arginine dihydrolase",
"Gelatin Hydrolysis",
"Esculin Hydrolysis",
"DNase",
"ONPG",
"Lipase Test",
"Coagulase",
"TSI Pattern",
"Gas Production",
]
for field in biochemical_fields:
comp = gr.Dropdown(
label=field,
choices=get_field_choices(field),
value=None,
allow_custom_value=True,
)
field_components.append((field, comp))
with gr.Accordion("Fermentation and tolerance tests", open=False):
fermentation_fields = [
"Lactose Fermentation",
"Glucose Fermentation",
"Sucrose Fermentation",
"Xylose Fermentation",
"Rhamnose Fermentation",
"Mannitol Fermentation",
"Sorbitol Fermentation",
"Maltose Fermentation",
"Arabinose Fermentation",
"Raffinose Fermentation",
"Inositol Fermentation",
"Trehalose Fermentation",
"NaCl Tolerant (>=6%)",
]
for field in fermentation_fields:
comp = gr.Dropdown(
label=field,
choices=get_field_choices(field),
value=None,
allow_custom_value=True,
)
field_components.append((field, comp))
with gr.Accordion("Other structural features", open=False):
other_fields = [
"Motility Type",
"Capsule",
"Spore Formation",
]
for field in other_fields:
comp = gr.Dropdown(
label=field,
choices=get_field_choices(field),
value=None,
allow_custom_value=True,
)
field_components.append((field, comp))
# Reorder components to match SCHEMA exactly.
component_by_field = {field: comp for field, comp in field_components}
ordered_components = [component_by_field[field] for field in SCHEMA]
with gr.Row():
predict_button = gr.Button("Predict and recommend next tests", variant="primary")
clear_button = gr.Button("Clear")
with gr.Accordion("Examples", open=False):
example_buttons = []
example_buttons.append(gr.Button("Load Achromobacter-like example"))
example_buttons.append(gr.Button("Load Staphylococcus-like example"))
example_buttons.append(gr.Button("Load Salmonella-like example"))
with gr.Column(scale=1):
gr.Markdown("## Settings")
top_k = gr.Slider(
label="Number of ranked genera to show",
minimum=5,
maximum=30,
value=10,
step=1,
)
n_recommendations = gr.Slider(
label="Number of next-test recommendations",
minimum=3,
maximum=10,
value=5,
step=1,
)
top_competing_genera = gr.Slider(
label="Number of top competing genera used for next-test simulation",
minimum=3,
maximum=10,
value=5,
step=1,
)
include_context_fields = gr.Checkbox(
label="Allow context fields as next-test recommendations",
value=False,
info="If enabled, fields like Media Grown On, Colony Morphology, and Growth Temperature may be recommended.",
)
excluded_next_test_fields = gr.CheckboxGroup(
label="Exclude these fields from next-test recommendations",
choices=SCHEMA,
value=["Oxygen Requirement"],
info="Useful for hiding fields that are not practical as follow-up tests in your workflow.",
)
prediction_md = gr.Markdown()
ranked_df = gr.Dataframe(label="Ranked genera", interactive=False)
discriminatory_df = gr.Dataframe(label="Discriminatory next tests", interactive=False)
confirmation_df = gr.Dataframe(label="Confirmation next tests", interactive=False)
with gr.Accordion("Input audit", open=False):
audit_md = gr.Markdown()
with gr.Accordion("Raw JSON output", open=False):
raw_json = gr.Code(language="json")
predict_inputs = [
top_k,
n_recommendations,
top_competing_genera,
include_context_fields,
excluded_next_test_fields,
] + ordered_components
predict_outputs = [
prediction_md,
ranked_df,
discriminatory_df,
confirmation_df,
audit_md,
raw_json,
]
predict_button.click(
fn=predict_and_recommend,
inputs=predict_inputs,
outputs=predict_outputs,
)
clear_button.click(
fn=clear_all,
inputs=[],
outputs=ordered_components,
)
example_buttons[0].click(
fn=lambda: values_from_example(EXAMPLES[0]),
inputs=[],
outputs=ordered_components,
)
example_buttons[1].click(
fn=lambda: values_from_example(EXAMPLES[1]),
inputs=[],
outputs=ordered_components,
)
example_buttons[2].click(
fn=lambda: values_from_example(EXAMPLES[2]),
inputs=[],
outputs=ordered_components,
)
if __name__ == "__main__":
demo.launch()