CoolFace
Apppublic

multimolecule/regulatory-variant-effect

sourceHugging Faceagpl-3.0updated 4mo agoView on Hugging Face
0likes
app.py417 linesDownload Raw Back to root
1# MultiMolecule2# Copyright (C) 2024-Present  MultiMolecule3 4# This file is part of MultiMolecule.5 6# MultiMolecule is free software: you can redistribute it and/or modify7# it under the terms of the GNU Affero General Public License as published by8# the Free Software Foundation, either version 3 of the License, or9# any later version.10 11# MultiMolecule is distributed in the hope that it will be useful,12# but WITHOUT ANY WARRANTY; without even the implied warranty of13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the14# GNU Affero General Public License for more details.15 16# You should have received a copy of the GNU Affero General Public License17# along with this program.  If not, see <http://www.gnu.org/licenses/>.18 19# For additional terms and clarifications, please refer to our License FAQ at:20# <https://multimolecule.danling.org/about/license-faq>.21 22from __future__ import annotations23 24import csv25import json26import re27import tempfile28import time29from functools import lru_cache30from typing import Any, Mapping31from urllib.parse import parse_qs, urlparse32 33import gradio as gr34import matplotlib35import numpy as np36import torch37from transformers import pipeline38 39matplotlib.use("Agg")40 41import matplotlib.pyplot as plt  # noqa: E40242import multimolecule  # noqa: E402, F401 - registers MultiMolecule models and pipelines with Transformers43 44DEFAULT_REFERENCE_SEQUENCE = "ACGT" * 25045DEFAULT_ALTERNATIVE_SEQUENCE = "ACGT" * 125 + "TCGA" + "ACGT" * 12446DEFAULT_MODEL_LABEL = "DeepSEA"47 48MODEL_OPTIONS = {49    "A2Z Chromatin": "multimolecule/a2zchromatin",50    "Basset": "multimolecule/basset",51    "DeepMEL": "multimolecule/deepmel",52    "DeepSEA": "multimolecule/deepsea",53    "DeepSTARR": "multimolecule/deepstarr",54    "Malinois": "multimolecule/malinois",55    "MPRA-DragoNN": "multimolecule/mpradragonn",56    "scBasset": "multimolecule/scbasset",57    "Xpresso": "multimolecule/xpresso",58}59MODEL_LABELS = {model_id: label for label, model_id in MODEL_OPTIONS.items()}60 61TABLE_HEADERS = ["position", "nucleotide", "channel", "delta_score", "reference_score", "alternative_score"]62DNA_ALPHABET = set("ACGTN")63FLOAT_PATTERN = re.compile(r"[-+]?(?:(?:\d*\.\d+)|(?:\d+\.?))(?:[eE][-+]?\d+)?")64 65 66def _device() -> int:67    return 0 if torch.cuda.is_available() else -168 69 70@lru_cache(maxsize=2)71def load_predictor(model_id: str):72    return pipeline("regulatory-variant-effect", model=model_id, device=_device())73 74 75def clean_sequence(sequence: str, label: str) -> str:76    sequence = "".join(str(sequence or "").split()).upper().replace("U", "T")77    if not sequence:78        raise gr.Error(f"{label} sequence is empty.")79    invalid = sorted(set(sequence) - DNA_ALPHABET)80    if invalid:81        invalid_text = ", ".join(invalid)82        raise gr.Error(f"{label} sequence contains unsupported symbols: {invalid_text}. Use A, C, G, T, or N.")83    return sequence84 85 86def parse_features(features_text: str) -> Any | None:87    text = str(features_text or "").strip()88    if not text:89        return None90 91    try:92        parsed = json.loads(text)93    except json.JSONDecodeError:94        values = FLOAT_PATTERN.findall(text)95        if not values:96            raise gr.Error("Features must be JSON or comma/space-separated numbers.")97        return [float(value) for value in values]98 99    if isinstance(parsed, Mapping):100        for key in ("features", "values", "reference_features", "alternative_features"):101            if key in parsed:102                return parsed[key]103        if all(isinstance(value, int | float) for value in parsed.values()):104            return list(parsed.values())105        raise gr.Error("Feature JSON objects must contain a features/values list or only numeric values.")106    if isinstance(parsed, str):107        return parse_features(parsed)108    return parsed109 110 111def feature_summary(features: Any | None) -> dict[str, Any]:112    if features is None:113        return {"provided": False}114    try:115        array = np.asarray(features, dtype=float)116    except (TypeError, ValueError):117        return {"provided": True, "shape": None}118    return {"provided": True, "shape": list(array.shape)}119 120 121def unpack_prediction_result(result: Any) -> dict[str, Any]:122    if isinstance(result, list):123        if len(result) != 1:124            raise gr.Error(f"Expected one prediction result, got {len(result)}.")125        result = result[0]126    if not isinstance(result, dict):127        raise gr.Error(f"Expected a prediction dictionary, got {type(result).__name__}.")128    return result129 130 131def build_delta_rows(result: Mapping[str, Any]) -> list[dict[str, Any]]:132    if "delta_score" in result:133        return [134            {135                "position": "",136                "nucleotide": "",137                "channel": "score",138                "delta_score": result.get("delta_score"),139                "reference_score": result.get("reference_score", ""),140                "alternative_score": result.get("alternative_score", ""),141            }142        ]143 144    delta_scores = result.get("delta_scores")145    if isinstance(delta_scores, Mapping):146        reference_scores = result.get("reference_scores") if isinstance(result.get("reference_scores"), Mapping) else {}147        alternative_scores = (148            result.get("alternative_scores") if isinstance(result.get("alternative_scores"), Mapping) else {}149        )150        return [151            {152                "position": "",153                "nucleotide": "",154                "channel": str(channel),155                "delta_score": value,156                "reference_score": reference_scores.get(channel, ""),157                "alternative_score": alternative_scores.get(channel, ""),158            }159            for channel, value in delta_scores.items()160        ]161 162    if isinstance(delta_scores, list):163        return build_axis_delta_rows(result, delta_scores)164 165    raise gr.Error("The selected model did not return delta scores.")166 167 168def build_axis_delta_rows(result: Mapping[str, Any], delta_scores: list[Any]) -> list[dict[str, Any]]:169    channels = [str(channel) for channel in result.get("channels", [])]170    reference_scores = _index_axis_rows(result.get("reference_scores"))171    alternative_scores = _index_axis_rows(result.get("alternative_scores"))172    output_rows: list[dict[str, Any]] = []173 174    for row_index, row in enumerate(delta_scores):175        if not isinstance(row, Mapping):176            continue177        position = row.get("position", row.get("bin", row_index))178        channel_names = channels or [179            str(key) for key in row if key not in {"position", "bin", "nucleotide"} and _is_number(row[key])180        ]181        ref_row = reference_scores.get(position, {})182        alt_row = alternative_scores.get(position, {})183        for channel in channel_names:184            if channel not in row:185                continue186            output_rows.append(187                {188                    "position": position,189                    "nucleotide": row.get("nucleotide", ""),190                    "channel": channel,191                    "delta_score": row[channel],192                    "reference_score": ref_row.get(channel, ""),193                    "alternative_score": alt_row.get(channel, ""),194                }195            )196    return output_rows197 198 199def _index_axis_rows(rows: Any) -> dict[Any, Mapping[str, Any]]:200    if not isinstance(rows, list):201        return {}202    indexed = {}203    for row_index, row in enumerate(rows):204        if isinstance(row, Mapping):205            indexed[row.get("position", row.get("bin", row_index))] = row206    return indexed207 208 209def _is_number(value: Any) -> bool:210    return isinstance(value, int | float | np.number)211 212 213def table_values(rows: list[Mapping[str, Any]]) -> list[list[Any]]:214    return [[row.get(header, "") for header in TABLE_HEADERS] for row in rows]215 216 217def plot_delta_rows(rows: list[Mapping[str, Any]], max_bars: int = 24):218    numeric_rows = [row for row in rows if _is_number(row.get("delta_score"))]219    fig, ax = plt.subplots(figsize=(7.0, 2.4))220    if not numeric_rows:221        ax.text(0.5, 0.5, "No numeric delta scores", ha="center", va="center", transform=ax.transAxes)222        ax.set_axis_off()223        fig.tight_layout()224        return fig225 226    top_rows = sorted(numeric_rows, key=lambda row: abs(float(row["delta_score"])), reverse=True)[:max_bars]227    labels = [_row_label(row) for row in top_rows]228    values = [float(row["delta_score"]) for row in top_rows]229    colors = ["#1b9e77" if value >= 0 else "#d95f02" for value in values]230 231    height = min(7.0, max(2.4, 0.28 * len(top_rows) + 1.2))232    fig.set_size_inches(7.0, height, forward=True)233    ax.barh(range(len(top_rows)), values, color=colors)234    ax.axvline(0, color="#333333", linewidth=0.8)235    ax.set_yticks(range(len(top_rows)), labels)236    ax.invert_yaxis()237    ax.set_xlabel("Alternative - reference")238    ax.set_title("Largest absolute delta scores")239    ax.tick_params(axis="y", labelsize=8)240    fig.tight_layout()241    return fig242 243 244def _row_label(row: Mapping[str, Any]) -> str:245    channel = str(row.get("channel", "score"))246    position = row.get("position")247    if position not in ("", None):248        nucleotide = row.get("nucleotide")249        suffix = f" {nucleotide}" if nucleotide not in ("", None) else ""250        return f"{position}{suffix} {channel}"251    return channel252 253 254def write_result_files(255    model_id: str,256    result: Mapping[str, Any],257    rows: list[Mapping[str, Any]],258    metadata: Mapping[str, Any],259) -> tuple[str, str]:260    csv_file = tempfile.NamedTemporaryFile("w", suffix=".csv", newline="", delete=False)261    writer = csv.DictWriter(csv_file, fieldnames=TABLE_HEADERS)262    writer.writeheader()263    writer.writerows({header: row.get(header, "") for header in TABLE_HEADERS} for row in rows)264    csv_file.close()265 266    json_file = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False)267    json.dump(268        {269            "metadata": dict(metadata),270            "model": model_id,271            "result": result,272            "delta_table": [{header: row.get(header, "") for header in TABLE_HEADERS} for row in rows],273        },274        json_file,275        indent=2,276        default=_json_default,277    )278    json_file.close()279    return csv_file.name, json_file.name280 281 282def _json_default(value: Any):283    if isinstance(value, np.generic):284        return value.item()285    if isinstance(value, np.ndarray):286        return value.tolist()287    raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")288 289 290def predict(291    model_label: str,292    reference_sequence: str,293    alternative_sequence: str,294    reference_features_text: str,295    alternative_features_text: str,296):297    model_id = MODEL_OPTIONS[model_label]298    reference_sequence = clean_sequence(reference_sequence, "Reference")299    alternative_sequence = clean_sequence(alternative_sequence, "Alternative")300    if len(reference_sequence) != len(alternative_sequence):301        raise gr.Error(302            f"Reference and alternative sequences must have the same length. "303            f"Got {len(reference_sequence)} and {len(alternative_sequence)}."304        )305 306    reference_features = parse_features(reference_features_text)307    alternative_features = parse_features(alternative_features_text)308    started = time.perf_counter()309 310    predictor = load_predictor(model_id)311    try:312        result = predictor(313            reference_sequence,314            alternative=alternative_sequence,315            features=reference_features,316            alternative_features=alternative_features,317        )318    except Exception as error:319        raise gr.Error(f"Prediction failed for {model_id}: {error}") from error320 321    result = unpack_prediction_result(result)322    rows = build_delta_rows(result)323    if not rows:324        raise gr.Error("The selected model returned no tabular delta scores.")325 326    metadata = {327        "task": "regulatory-variant-effect",328        "model": model_id,329        "device": "cuda" if torch.cuda.is_available() else "cpu",330        "reference_length": len(reference_sequence),331        "alternative_length": len(alternative_sequence),332        "reference_features": feature_summary(reference_features),333        "alternative_features": feature_summary(alternative_features),334        "alternative_features_inherit_reference": alternative_features is None and reference_features is not None,335        "score_definition": "alternative_minus_reference",336        "num_delta_rows": len(rows),337        "has_reference_scores": any(row.get("reference_score") not in ("", None) for row in rows),338        "has_alternative_scores": any(row.get("alternative_score") not in ("", None) for row in rows),339        "elapsed_seconds": round(time.perf_counter() - started, 3),340    }341    csv_path, json_path = write_result_files(model_id, result, rows, metadata)342 343    return (344        table_values(rows),345        metadata,346        plot_delta_rows(rows),347        csv_path,348        json_path,349    )350 351 352def initial_model(request: gr.Request):353    if request is None:354        return DEFAULT_MODEL_LABEL355 356    query_params = getattr(request, "query_params", None)357    model_id = None358    if query_params is not None:359        model_id = query_params.get("model")360    if not model_id and getattr(request, "url", None):361        parsed = parse_qs(urlparse(str(request.url)).query)362        model_values = parsed.get("model")363        model_id = model_values[0] if model_values else None364 365    return MODEL_LABELS.get(model_id, DEFAULT_MODEL_LABEL)366 367 368with gr.Blocks(title="Regulatory Variant Effect") as demo:369    gr.Markdown(370        "# Regulatory Variant Effect\n"371        "Score matched reference and alternative DNA windows with MultiMolecule regulatory variant-effect models."372    )373 374    model = gr.Dropdown(375        choices=list(MODEL_OPTIONS.keys()),376        value=DEFAULT_MODEL_LABEL,377        label="Checkpoint",378    )379 380    with gr.Row():381        reference_sequence = gr.Textbox(label="Reference DNA sequence", value=DEFAULT_REFERENCE_SEQUENCE, lines=5)382        alternative_sequence = gr.Textbox(label="Alternative DNA sequence", value=DEFAULT_ALTERNATIVE_SEQUENCE, lines=5)383 384    with gr.Accordion("Optional numeric features", open=False), gr.Row():385        reference_features = gr.Textbox(386            label="Reference features JSON/text",387            placeholder='[0.1, 0.2, 0.3] or {"features": [0.1, 0.2, 0.3]}',388            lines=3,389        )390        alternative_features = gr.Textbox(391            label="Alternative features JSON/text",392            placeholder="Leave blank to reuse reference features when provided.",393            lines=3,394        )395 396    run = gr.Button("Run prediction", variant="primary")397 398    delta_table = gr.Dataframe(headers=TABLE_HEADERS, label="Delta scores", interactive=False, wrap=True)399    with gr.Row():400        metadata = gr.JSON(label="Run metadata")401        delta_plot = gr.Plot(label="Delta plot")402 403    with gr.Row():404        csv_download = gr.File(label="Download CSV")405        json_download = gr.File(label="Download JSON")406 407    run.click(408        predict,409        inputs=[model, reference_sequence, alternative_sequence, reference_features, alternative_features],410        outputs=[delta_table, metadata, delta_plot, csv_download, json_download],411    )412    demo.load(initial_model, outputs=model)413 414 415if __name__ == "__main__":416    demo.launch()417