Spaces:
Running on Zero
Running on Zero
EmmaScharfmann HF Staff
Claude Opus 5
Report the sites the cutoff rejected, instead of nothing
c357a0a Download app.py from EmmaScharfmann/microproteins: direct link, hf CLI and curl.
- Browser
- Download file 21.2 kB
-
https://huggingface.co/spaces/EmmaScharfmann/microproteins/resolve/main/app.py
- Command line
-
hf download hf://spaces/EmmaScharfmann/microproteins/app.py
-
curl -L -o app.py https://huggingface.co/spaces/EmmaScharfmann/microproteins/resolve/main/app.py
21.2 kB
| """ | |
| Translatome -> microprotein -> candidate epitope, end to end. | |
| A demo of what can be assembled *today* from existing, openly-licensed tools: | |
| transcript sequence | |
| -> TIS Transformer P(translation initiation) per nucleotide [MIT] | |
| -> ORF typing uORF / intORF / N-ext / CDS ... [here] | |
| -> MHCflurry peptide presentation by HLA class I [Apache-2.0] | |
| Nothing in this Space is a new model. The point is the join, and what the join | |
| reveals: how few candidates survive every stage, and which of the surviving | |
| ones are actually novel rather than self. | |
| Written as a starting point for a collaboration on beta-cell translatome | |
| discovery -- hence the insulin-centric examples and the T1D risk alleles. | |
| """ | |
| import os | |
| import tempfile | |
| from pathlib import Path | |
| import gradio as gr | |
| import pandas as pd | |
| import mhc | |
| import pipeline | |
| # --- Config ----------------------------------------------------------------- | |
| MAX_SEQS = 20 | |
| MAX_LEN = 30_000 | |
| MAX_TOTAL_LEN = 150_000 | |
| GPU_DURATION = 120 | |
| TIMEOUT_S = 600 | |
| # Weights ship inside the transcript-transformer wheel (~128 MB), so there is | |
| # no download at first use and nothing to warm up. | |
| ACCELERATOR = os.environ.get("TRISTAN_ACCELERATOR", "gpu") | |
| EXAMPLE_FASTA = Path(__file__).parent / "examples.fa" | |
| # Unlike transcript-transformer, MHCflurry does not ship its weights in the | |
| # wheel. Fetch them once at container start rather than inside a request, | |
| # where the download would eat a GPU allocation and time out. Set | |
| # MHCFLURRY_BOOTSTRAP=0 locally if you have already fetched them. | |
| if os.environ.get("MHCFLURRY_BOOTSTRAP", "1") == "1": | |
| ok, _ = mhc.predictor_available() | |
| if not ok: | |
| import subprocess | |
| print("Fetching MHCflurry models (one time, ~100s of MB)...", flush=True) | |
| subprocess.run( | |
| ["mhcflurry-downloads", "fetch", "models_class1_presentation"], | |
| check=False, | |
| ) | |
| try: | |
| import spaces | |
| HAS_ZEROGPU = True | |
| except ImportError: | |
| HAS_ZEROGPU = False | |
| class _Stub: | |
| def GPU(*args, **kwargs): | |
| def wrap(fn): | |
| return fn | |
| return wrap | |
| spaces = _Stub() # type: ignore | |
| # --- The run ---------------------------------------------------------------- | |
| def _check_limits(records): | |
| if len(records) > MAX_SEQS: | |
| raise gr.Error(f"Too many sequences ({len(records)}). Limit is {MAX_SEQS}.") | |
| for name, seq in records: | |
| if len(seq) > MAX_LEN: | |
| raise gr.Error( | |
| f"Sequence '{name}' is {len(seq):,} nt. Limit is {MAX_LEN:,} nt." | |
| ) | |
| total = sum(len(s) for _, s in records) | |
| if total > MAX_TOTAL_LEN: | |
| raise gr.Error( | |
| f"Total length {total:,} nt exceeds the {MAX_TOTAL_LEN:,} nt limit." | |
| ) | |
| def _tis_on_gpu(fasta_path, workdir, model, fold, cutoff, codons, min_len): | |
| # In-process: under ZeroGPU a child process may not inherit the | |
| # allocation, and would silently fall back to CPU. | |
| return pipeline.run_tis_transformer( | |
| Path(fasta_path), Path(workdir), model=model, fold=int(fold), | |
| accelerator="gpu", prob_cutoff=float(cutoff), start_codons=codons, | |
| min_orf_len=int(min_len), in_process=True, | |
| ) | |
| def _tis_on_cpu(fasta_path, workdir, model, fold, cutoff, codons, min_len): | |
| return pipeline.run_tis_transformer( | |
| Path(fasta_path), Path(workdir), model=model, fold=int(fold), | |
| accelerator="cpu", prob_cutoff=float(cutoff), start_codons=codons, | |
| min_orf_len=int(min_len), in_process=False, timeout_s=TIMEOUT_S, | |
| ) | |
| def run(fasta_text, uploaded, model, fold, cutoff, codon_mode, min_len, | |
| alleles, novel_only): | |
| if uploaded: | |
| fasta_text = Path(uploaded).read_text() | |
| try: | |
| records, headers = pipeline.parse_fasta_with_headers(fasta_text or "") | |
| except ValueError as e: | |
| raise gr.Error(str(e)) | |
| _check_limits(records) | |
| if not alleles: | |
| raise gr.Error("Pick at least one HLA allele.") | |
| codons = ( | |
| pipeline.ATG_ONLY if codon_mode.startswith("ATG") | |
| else pipeline.DEFAULT_START_CODONS | |
| ) | |
| workdir = Path(tempfile.mkdtemp(prefix="tis_")) | |
| fasta_path = workdir / "input.fa" | |
| pipeline.write_fasta(records, fasta_path) | |
| # Score down to the floor whatever the user asked for, then apply their | |
| # cutoff here. Same work for the model, and it lets the run report what it | |
| # rejected instead of returning a bare "nothing found". | |
| cutoff = float(cutoff) | |
| floor = min(cutoff, pipeline.NEAR_MISS_FLOOR) | |
| # Try GPU first where there is one, then fall back to CPU. The model is | |
| # small enough that CPU takes seconds on inputs this size, so a GPU | |
| # problem should degrade the Space rather than break it. | |
| attempts = [_tis_on_gpu, _tis_on_cpu] if ACCELERATOR == "gpu" else [_tis_on_cpu] | |
| tis, failures = None, [] | |
| for attempt in attempts: | |
| try: | |
| tis = attempt(str(fasta_path), str(workdir), model, fold, floor, | |
| codons, min_len) | |
| break | |
| except Exception as e: | |
| failures.append( | |
| f"{attempt.__name__}: {type(e).__name__}: {e}".strip() | |
| ) | |
| # Clear anything a half-finished run left behind. | |
| for stale in workdir.glob("tis.*"): | |
| stale.unlink(missing_ok=True) | |
| if tis is None: | |
| raise gr.Error( | |
| "Initiation-site prediction failed.\n\n" + "\n\n".join(failures) | |
| ) | |
| total_nt = sum(len(s) for _, s in records) | |
| stages = [ | |
| pipeline.Stage("Transcripts submitted", len(records), f"{total_nt:,} nt"), | |
| ] | |
| scored = pipeline.annotate_orf_types(tis, headers) | |
| ann, below = pipeline.split_at_cutoff(scored, cutoff) | |
| below_view = _tis_view(below).head(50) | |
| if ann.empty: | |
| stages.append( | |
| pipeline.Stage("Initiation sites", 0, f"none scored >= {cutoff}") | |
| ) | |
| return ( | |
| _funnel_md(stages, cutoff, alleles, _near_miss_note(below, cutoff)), | |
| pd.DataFrame(), below_view, pd.DataFrame(), None, None, | |
| ) | |
| n_noncanon = int(ann["is_noncanonical"].sum()) | |
| n_micro = int(ann["is_microprotein"].sum()) | |
| stages += [ | |
| pipeline.Stage("Initiation sites", len(ann), f"score >= {cutoff}"), | |
| pipeline.Stage("Non-canonical", n_noncanon, "not the annotated CDS start"), | |
| pipeline.Stage("Microproteins", n_micro, "< 100 aa"), | |
| ] | |
| # --- Stage 3 --- | |
| proteins = { | |
| f"{r.transcript_id.split('_')[0]}@{r.tis_pos_0b}[{r.orf_type}]": | |
| str(r.protein_sequence or "") | |
| for r in ann.itertuples() | |
| } | |
| res = mhc.predict_presentation(proteins, alleles=list(alleles)) | |
| if res.error: | |
| stages.append(pipeline.Stage("Peptides scored", 0, res.error)) | |
| mhc_table = pd.DataFrame() | |
| else: | |
| canon = pipeline.canonical_proteome(records, headers) | |
| tbl = pipeline.flag_peptide_novelty(res.table, canon) | |
| n_strong = int((tbl["binder"] == "strong").sum()) | |
| stages += [ | |
| pipeline.Stage("Peptides scored", len(tbl), | |
| f"8-11mers x {len(alleles)} allele(s)"), | |
| pipeline.Stage("Strong binders", n_strong, | |
| f"presentation percentile <= " | |
| f"{mhc.STRONG_BINDER_PERCENTILE}"), | |
| ] | |
| if "novel" in tbl.columns: | |
| n_novel = int(((tbl["binder"] == "strong") & tbl["novel"]).sum()) | |
| stages.append( | |
| pipeline.Stage("...and not in a canonical CDS", n_novel, | |
| "absent from the submitted annotated proteins") | |
| ) | |
| if novel_only and "novel" in tbl.columns: | |
| tbl = tbl[tbl["novel"]] | |
| mhc_table = tbl[tbl["binder"].isin(["strong", "weak"])].head(500) | |
| tis_view = _tis_view(ann) | |
| tmp = Path(tempfile.gettempdir()) | |
| tis_csv = tmp / f"tis_sites_{os.getpid()}.csv" | |
| mhc_csv = tmp / f"peptides_{os.getpid()}.csv" | |
| tis_view.to_csv(tis_csv, index=False) | |
| if not mhc_table.empty: | |
| mhc_table.to_csv(mhc_csv, index=False) | |
| return ( | |
| _funnel_md(stages, cutoff, alleles, _near_miss_note(below, cutoff)), | |
| tis_view, | |
| below_view, | |
| mhc_table, | |
| str(tis_csv), | |
| str(mhc_csv) if not mhc_table.empty else None, | |
| ) | |
| def _tis_view(df): | |
| """The columns worth showing for a set of scored initiation sites.""" | |
| if df.empty: | |
| return pd.DataFrame() | |
| return df[[ | |
| "transcript_id", "tis_pos_0b", "output", "start_codon", "orf_type", | |
| "protein_length", "TTS_on_transcript", "protein_sequence", | |
| ]].rename(columns={ | |
| "tis_pos_0b": "TIS_pos_0based", | |
| "output": "TIS_score", | |
| "TTS_on_transcript": "has_stop", | |
| }) | |
| def _near_miss_note(below, cutoff): | |
| """One line on the best site the cutoff rejected. | |
| Without this, a transcript with no non-canonical hits looks like a tool | |
| that failed to look. It looked: the scores were simply tiny, which is the | |
| model's own verdict and the most informative thing it has to say. | |
| """ | |
| if below.empty: | |
| return ( | |
| f"Nothing at all scored above {pipeline.NEAR_MISS_FLOOR} outside " | |
| "what is shown β not a near miss in sight." | |
| ) | |
| nc = below[below["is_noncanonical"]] if "is_noncanonical" in below else below | |
| if nc.empty: | |
| return "" | |
| top = nc.iloc[0] | |
| return ( | |
| f"The best non-canonical site **below** the {cutoff} cutoff scored " | |
| f"**{top['output']:.4g}** ({top['orf_type']}, {top['start_codon']} at " | |
| f"position {int(top['tis_pos_0b'])} of `{top['transcript_id']}`). " | |
| "See the *Below the cutoff* tab β the size of that gap is the result." | |
| ) | |
| # Each example is (label, genes, fold). The fold is the one whose training and | |
| # validation sets exclude that gene's chromosome -- scoring a transcript on a | |
| # fold that trained on it inflates the result. Bundling the fold with the | |
| # sequence means nobody has to know that to get a correct run. | |
| # | |
| # The order is deliberate. MIEF1 shows the model finding a validated 70 aa | |
| # microprotein and scoring it above the canonical start, so the tool gets | |
| # credit before any limitation is shown. ATF4 then exposes the limit inside a | |
| # single transcript: uORF2 (59 aa) is found, uORF1 (3 aa) is not. TYK2 shows | |
| # that limit landing on a type 1 diabetes risk gene. INS and IAPP are the | |
| # negative controls, and the ones beta-cell people reach for first: on both, | |
| # the annotated start is the only site worth anything, and the Below-the-cutoff | |
| # tab shows by how much. | |
| EXAMPLE_SETS = [ | |
| ("Model finds a hidden microprotein, and misses a shorter one " | |
| "(ATF4 + MIEF1, chr22)", ["MIEF1", "ATF4"], 0), | |
| ("A validated upstream ORF the model scores at ~0 " | |
| "(TYK2, chr19, T1D risk gene)", ["TYK2"], 3), | |
| ("Reference: insulin, canonical start only (INS, chr11)", ["INS"], 4), | |
| ("Nothing non-canonical anywhere, and what that looks like " | |
| "(IAPP, chr12)", ["IAPP"], 3), | |
| ] | |
| def example_rows(): | |
| """Build (fasta_text, fold) rows and their labels from examples.fa.""" | |
| if not EXAMPLE_FASTA.exists(): | |
| return [], [] | |
| blocks: dict[str, str] = {} | |
| for chunk in EXAMPLE_FASTA.read_text().split(">"): | |
| if not chunk.strip(): | |
| continue | |
| gene = chunk.split()[0].split("_")[0] | |
| blocks[gene] = ">" + chunk.rstrip() + "\n" | |
| rows, labels = [], [] | |
| for label, genes, fold in EXAMPLE_SETS: | |
| fasta = "".join(blocks[g] for g in genes if g in blocks) | |
| if fasta: | |
| rows.append([fasta, fold]) | |
| labels.append(label) | |
| return rows, labels | |
| def _funnel_md(stages, cutoff, alleles, near_miss=""): | |
| """Render the attrition, because the attrition is the honest result.""" | |
| lines = [ | |
| "### How many candidates survive each stage", | |
| "", | |
| "| stage | n | filter |", | |
| "| --- | ---: | --- |", | |
| ] | |
| for s in stages: | |
| lines.append(f"| {s.name} | **{s.count:,}** | {s.detail} |") | |
| lines += [ | |
| "", | |
| f"*Alleles: {', '.join(alleles)}. TIS cutoff {cutoff}.*", | |
| "", | |
| "Each arrow multiplies one model's error by another's. Treat the final " | |
| "row as a shortlist to test, not as predicted neoantigens.", | |
| ] | |
| if near_miss: | |
| lines += ["", near_miss] | |
| return "\n".join(lines) | |
| # --- UI --------------------------------------------------------------------- | |
| with gr.Blocks(title="Translatome to epitope") as demo: | |
| gr.Markdown( | |
| """ | |
| # Translatome β microprotein β candidate epitope | |
| What you can already build today, end to end, from open tools: | |
| **transcript sequence** β `TIS Transformer` (predicted initiation sites, | |
| MIT) β **ORF typing** β `MHCflurry` (HLA class I presentation, | |
| Apache-2.0) β **candidate peptides** | |
| No new models here. The interesting part is what the join shows: how | |
| steeply candidates drop off at each stage, and how many survivors are | |
| merely *self* peptides rather than novel ones. | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| fasta_text = gr.Textbox( | |
| label="Transcript sequence(s), FASTA", | |
| placeholder=">my_transcript 5UTR=59nt CDS=333nt\nACGT...", | |
| info="Tag a header with 5UTR=<n>nt CDS=<n>nt to get ORF types " | |
| "(uORF, intORF, N-ext). Without tags, ORFs are reported " | |
| "as unannotated.", | |
| lines=10, | |
| ) | |
| uploaded = gr.File( | |
| label="or upload a .fa file", | |
| file_types=[".fa", ".fasta", ".txt"], | |
| ) | |
| with gr.Column(scale=2): | |
| with gr.Accordion("Initiation site prediction", open=True): | |
| model = gr.Radio(["human", "mouse"], value="human", label="Model") | |
| fold = gr.Dropdown( | |
| [0, 1, 2, 3, 4], value=0, label="Fold", | |
| info="Each fold held out different chromosomes during " | |
| "training. Pick one that did NOT train on your " | |
| "sequence's chromosome, or the score is inflated. " | |
| "For the examples: TYK2 and IAPP β 3, INS β 4. " | |
| "Fold 0 is the catch-all and will score anything.", | |
| ) | |
| cutoff = gr.Slider( | |
| 0.0005, 1.0, value=pipeline.DEFAULT_PROB_CUTOFF, step=0.0005, | |
| label="TIS score cutoff", | |
| info=f"Package default 0.03. Scoring always runs down to " | |
| f"{pipeline.NEAR_MISS_FLOOR}; everything this cutoff " | |
| f"rejects is listed in the Below-the-cutoff tab.", | |
| ) | |
| codon_mode = gr.Radio( | |
| ["Near-cognate (ATG, CTG, GTG, TTG)", "ATG only"], | |
| value="Near-cognate (ATG, CTG, GTG, TTG)", | |
| label="Start codons", | |
| info="Near-cognate is the package default and is where " | |
| "much non-canonical initiation happens.", | |
| ) | |
| min_len = gr.Slider(9, 300, value=pipeline.DEFAULT_MIN_ORF_LEN, | |
| step=3, label="Min ORF length (nt)") | |
| with gr.Accordion("Epitope prediction", open=True): | |
| alleles = gr.CheckboxGroup( | |
| choices=list(mhc.ALL_ALLELES.keys()), | |
| value=mhc.DEFAULT_ALLELES, | |
| label="HLA class I alleles", | |
| info="The first four are T1D-associated.", | |
| ) | |
| novel_only = gr.Checkbox( | |
| value=False, | |
| label="Show only peptides absent from the canonical CDS", | |
| info="Peptides shared with the canonical protein are self " | |
| "and are not neoantigen candidates.", | |
| ) | |
| go = gr.Button("Run pipeline", variant="primary") | |
| funnel = gr.Markdown("") | |
| with gr.Tab("Initiation sites"): | |
| tis_out = gr.Dataframe(label="Predicted initiation sites", wrap=True) | |
| tis_dl = gr.File(label="Download (CSV)") | |
| with gr.Tab("Below the cutoff"): | |
| gr.Markdown( | |
| f"Every run scores down to {pipeline.NEAR_MISS_FLOOR} regardless " | |
| "of the cutoff, so these are the sites the cutoff rejected, best " | |
| "first. On most transcripts the only non-canonical candidates " | |
| "live here, two or three orders of magnitude below the annotated " | |
| "start β that gap is the model telling you it sees nothing, not " | |
| "the pipeline failing to look." | |
| ) | |
| below_out = gr.Dataframe(label="Rejected sites (top 50)", wrap=True) | |
| with gr.Tab("Candidate peptides"): | |
| mhc_out = gr.Dataframe(label="Strong and weak binders", wrap=True) | |
| mhc_dl = gr.File(label="Download (CSV)") | |
| with gr.Tab("What this does not show"): | |
| gr.Markdown( | |
| """ | |
| Worth being explicit, because a chained pipeline flatters itself. | |
| **Sequence alone cannot be cell-type specific.** TIS Transformer | |
| sees only sequence, so it returns the same answer for a beta cell, | |
| a hepatocyte and a neuron. Beta-cell-specific initiation under | |
| inflammatory stress β the thing that actually matters for type 1 | |
| diabetes β is invisible to it. That needs ribosome profiling or | |
| expression conditioning. | |
| **It is largely a canonical-start detector.** Trained on annotated | |
| CDS starts, it reproduces them confidently and assigns most | |
| non-canonical sites a score near zero. On insulin, scored right | |
| down to 0.0005 on the fold that held out chr11, exactly two sites | |
| exist in the whole transcript: the annotated ATG at 0.91 and an | |
| in-frame N-truncation at 0.0037. Nothing in another frame scores | |
| at all. IAPP behaves the same way β annotated start 0.18, then one | |
| 3'UTR ORF at 0.0008. | |
| That is worth separating from the ribosome-profiling point above. | |
| A blank result on a gene with a documented alternative-frame | |
| product, such as the insulin DRiP of | |
| [Kracht et al.](https://doi.org/10.1038/nm.4289), is not caused by | |
| the missing Ribo-seq track: the model was shown the sequence and | |
| returned a near-zero probability. Conditioning on ribosome | |
| occupancy might change that answer, but the limit you are looking | |
| at here is the model's prior, not a missing input. | |
| **Novelty here is local.** The "novel" flag compares a peptide only | |
| against the canonical CDSs of the sequences you submitted. A real | |
| screen needs the whole human proteome, and ideally the canonical | |
| immunopeptidome too. | |
| **Class I only.** MHCflurry does not model class II, and the | |
| strongest genetic risk in T1D is class II (HLA-DQ8/DQ2). | |
| **Binding is not immunogenicity.** Presentation prediction says a | |
| peptide could be displayed. Whether a T cell responds depends on | |
| the repertoire, tolerance and much else. | |
| **No proteomic evidence is used.** Peptide-level support for | |
| non-canonical ORFs now exists at scale (the TransCODE consensus | |
| set); none of it is consulted here. | |
| """ | |
| ) | |
| gr.Markdown( | |
| f""" | |
| --- | |
| **Limits:** {MAX_SEQS} sequences, {MAX_LEN:,} nt each, | |
| {MAX_TOTAL_LEN:,} nt total. | |
| **Not included:** RiboTIE, the ribosome-profiling model from the same | |
| package. It needs mapped BAMs that are too large for a Space. | |
| TIS Transformer: Clauwaert et al., *NAR Genomics and Bioinformatics* | |
| (2023), [doi:10.1093/nargab/lqad021](https://doi.org/10.1093/nargab/lqad021). | |
| MHCflurry: O'Donnell et al., *Cell Systems* (2020), | |
| [doi:10.1016/j.cels.2020.06.010](https://doi.org/10.1016/j.cels.2020.06.010). | |
| """ | |
| ) | |
| go.click( | |
| run, | |
| inputs=[fasta_text, uploaded, model, fold, cutoff, codon_mode, min_len, | |
| alleles, novel_only], | |
| outputs=[funnel, tis_out, below_out, mhc_out, tis_dl, mhc_dl], | |
| ) | |
| rows, labels = example_rows() | |
| if rows: | |
| try: | |
| gr.Examples( | |
| examples=rows, | |
| inputs=[fasta_text, fold], | |
| example_labels=labels, | |
| label="Examples (each preselects the fold that held out its " | |
| "chromosome)", | |
| ) | |
| except TypeError: | |
| # example_labels landed in Gradio 4.x; degrade rather than break. | |
| gr.Examples( | |
| examples=rows, inputs=[fasta_text, fold], | |
| label="Examples (each preselects its correct fold)", | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue(max_size=20).launch() | |