Spaces:
Running on Zero
Running on Zero
File size: 21,168 Bytes
fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 c4ce6c8 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 c357a0a dfc65a6 c357a0a dfc65a6 5278136 fe26600 c357a0a 5278136 c357a0a 5278136 c357a0a 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 c357a0a 5278136 c357a0a 5278136 c357a0a 5278136 c357a0a fc30842 c357a0a fc30842 c357a0a fc30842 c357a0a 5278136 c357a0a 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 0e84302 c357a0a 0e84302 5278136 c357a0a 5278136 c357a0a 5278136 c357a0a 5278136 fe26600 5278136 fe26600 5278136 fe26600 5278136 c357a0a 5278136 fc30842 c4ce6c8 fe26600 5278136 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 | """
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:
@staticmethod
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."
)
@spaces.GPU(duration=GPU_DURATION)
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()
|