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()