File size: 10,601 Bytes
78b2584 | 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 | # mgxLens
A retrieval-based metagenomic taxonomic profiler. Given short DNA reads (~150 bp) from a
sequencing run, it reports the estimated **relative abundance** of microbial taxa at the
**genus** and **species** level.
**Reference coverage.** The index covers **13,399 distinct species-level SGBs across 3,297 genera**
β the full MetaPhlAn vJan25 SGB reference. Every indexed SGB resolves to a named Linnaean species
(no unnamed placeholder bins among the *indexed* organisms; the naming rate over indexed SGBs is
100%). This means "closed-world" is about organisms *outside MetaPhlAn's reference entirely* β not
gaps within it. An organism that is in MetaPhlAn's reference is representable; one that is not will
be misassigned to its nearest reference genus (see Caveats β closed-world).
---
## 1. What's in the bundle
| File | What it is |
|---|---|
| `encoder.pt` | The neural encoder (seqLens-89M backbone + attention pooling β 256-d embedding). Turns a DNA string into a vector. |
| `index.faiss` | A FAISS vector index of ~2.32M reference marker embeddings. Cosine search via inner product. |
| `index.clades.npy` | Row-aligned to the FAISS index: the SGB (clade) id for each indexed vector. |
| `index.markers.npy` | Row-aligned marker source ids (provenance; not needed for inference). |
| `index.config.json` | Index build parameters (window=150, nwin=5, emb_dim=256, max_length=128). |
| `lineage.tsv` | `clade_id β genus, species, display_name`. Turns a retrieved SGB into a taxon name (display_name is MetaPhlAn-style). |
| `mgx_encoder.py` | The encoder class + `load_encoder()` / `embed()` helpers. Import this; don't reimplement. |
| `MANIFEST.json` | Machine-readable summary + file checksums + caveats. |
**Encoder and index are from the same training run** (`index.config.json.source_ckpt` ==
`encoder.pt`). Do not mix this encoder with any other index or vice-versa β the vectors would be
meaningless.
---
## 2. Environment
```bash
pip install torch transformers faiss-cpu numpy
# GPU optional: faiss-gpu + a CUDA torch build speed up large batches, but faiss-cpu is fine for
# moderate throughput. The encoder runs on CPU or GPU; GPU is ~10-50x faster for embedding.
```
The encoder downloads its base model (`omicseye/seqLens_4096_512_89M-at-base-multi`) from
HuggingFace on first load via `transformers`, so the serving host needs network access on first
run (or pre-cache the HF model). `trust_remote_code=True` is required for that base model.
---
## 3. How it works (the inference recipe)
For each input read:
1. **Window** the read to 150 bp (if longer, take a centered 150 bp window; if β€150, use as-is).
2. **Embed** it with the encoder β a 256-d L2-normalized vector.
3. **Search** the FAISS index for the top-k nearest reference vectors (k=25). Because vectors are
L2-normalized and the index is inner-product, the score **is** cosine similarity β [-1, 1].
4. **Gate**: if the top-1 cosine is below a threshold Ο (start with **0.66**), the read is
*abstained* (dropped, counted as "unclassified"). Otherwise it's assigned.
5. **Assign**: the read's taxon is the clade (`index.clades.npy`) of its top-1 hit. Map that clade
to genus/species via `lineage.tsv`.
6. **Aggregate** across all assigned reads: count reads per genus (and per species), normalize to
fractions β the abundance profile. Report the unclassified fraction separately.
That's the whole pipeline. It's nearest-neighbor retrieval, not a trained classifier.
---
## 4. Reference implementation (copy-paste starting point)
```python
import numpy as np, faiss, torch
from collections import defaultdict
from mgx_encoder import load_encoder, embed # ships in this bundle
BUNDLE = "." # path to the bundle dir
K = 25 # neighbors per read
GATE = 0.66 # top-1 cosine threshold; below -> abstain
WINDOW = 150
# ---- load once at startup ----
device = "cuda" if torch.cuda.is_available() else "cpu"
model, tok, cfg = load_encoder(f"{BUNDLE}/encoder.pt", device)
index = faiss.read_index(f"{BUNDLE}/index.faiss")
clades = np.load(f"{BUNDLE}/index.clades.npy", allow_pickle=True) # (N,) SGB id per vector
lineage = {} # clade_id -> (genus, species, display_name)
with open(f"{BUNDLE}/lineage.tsv") as fh:
next(fh)
for line in fh:
cid, g, s, disp = line.rstrip("\n").split("\t")
lineage[cid] = (g, s, disp)
def center_window(seq, w=WINDOW):
if len(seq) <= w:
return seq
s = (len(seq) - w) // 2
return seq[s:s+w]
def profile(reads, batch_size=4096):
"""reads: list of DNA strings. Returns genus/species abundance + unclassified fraction.
Species keys are MetaPhlAn-style display names ('Escherichia coli', or
'Escherichia sp. (SGB123)' for unnamed genome bins)."""
genus_counts, species_counts = defaultdict(float), defaultdict(float)
n_total = len(reads); n_assigned = 0
for i in range(0, n_total, batch_size):
batch = [center_window(r) for r in reads[i:i+batch_size]]
ml = cfg["max_length"] if "max_length" in cfg else 128
Z = embed(model, tok, batch, device, max_length=ml).astype("float32")
D, I = index.search(Z, K) # D=cosine sims, I=index rows
for r in range(len(batch)):
if D[r, 0] < GATE: # gate: abstain
continue
clade = str(clades[I[r, 0]]) # top-1 hit's clade
g, s, disp = lineage.get(clade, ("", "", ""))
if not g:
continue
n_assigned += 1
genus_counts[g] += 1
if disp:
species_counts[disp] += 1 # display name, not raw s__ / SGB id
def norm(d):
tot = sum(d.values()) or 1.0
return {k: v/tot for k, v in sorted(d.items(), key=lambda x: -x[1])}
return {
"genus": norm(genus_counts),
"species": norm(species_counts),
"n_reads": n_total,
"n_assigned": n_assigned,
"unclassified_fraction": 1.0 - (n_assigned / n_total if n_total else 0.0),
}
# ---- example ----
if __name__ == "__main__":
reads = ["ACGT..."] # your reads (from a FASTQ)
print(profile(reads))
```
**Reading FASTQ**: reads come from `.fastq`/`.fastq.gz`. Use `pysam`, `Bio.SeqIO`, or a
2nd/4th-line parser to get the sequence strings, then pass the list to `profile()`. Paired-end
R1/R2 can both be fed as independent reads.
---
## 5. Serving it (FastAPI sketch)
```python
from fastapi import FastAPI, UploadFile
app = FastAPI()
# model/index/lineage loaded once at module import (see section 4)
@app.post("/profile")
async def profile_endpoint(file: UploadFile):
reads = parse_fastq(await file.read()) # your FASTQ parser -> list[str]
return profile(reads) # JSON: {genus:{...}, species:{...}, ...}
```
Load the model, index, and lineage **once at startup** (they're large β encoder 342 MB, index
2.3 GB). Never reload per request. The index holds ~2.3 GB in RAM; size the host accordingly
(β₯8 GB RAM recommended, β₯16 GB comfortable).
---
## 6. Input / output contract
**Input**: DNA reads as strings (from FASTQ), ~150 bp each. Non-ACGT characters are tolerated by
the tokenizer but degrade the embedding; upstream QC/trimming (e.g. fastp) is assumed.
**Output** (JSON):
```json
{
"genus": {"escherichia": 0.29, "pseudomonas": 0.24, "...": 0.0},
"species": {"Escherichia coli": 0.21, "Pseudomonas aeruginosa": 0.18, "...": 0.0},
"n_reads": 200000,
"n_assigned": 94459,
"unclassified_fraction": 0.528
}
```
Abundances are fractions summing to ~1.0 within each level (over assigned reads). All 13,399
indexed SGBs have real species names, so species keys are Linnaean names (the `Genus sp. (SGBxxxx)`
fallback only appears if the index is rebuilt to include unnamed bins).
---
## 7. Tunable knobs
| Knob | Default | Effect |
|---|---|---|
| `GATE` (top-1 cosine) | 0.66 | Higher = stricter, more reads abstained, fewer false assignments (but see Caveats β it does **not** reliably reject novel organisms). |
| `K` (neighbors) | 25 | Only top-1 is used for assignment here; k>1 matters if you switch to k-NN vote aggregation. |
| aggregation | top-1 vote | Simplest. Alternatives (k-NN vote, similarity-weighted) exist in the research code but top-1 is the documented default. |
---
## 8. Caveats β read before building product on this
- **Proof of concept.** Not accuracy-tuned. The goal of this handoff is to enable infrastructure
and integration work, not to ship a validated diagnostic.
- **Genus is the reliable level; species is approximate.** At 150 bp, reads from different species
in the same genus are near-indistinguishable to the encoder β the model reliably gets the
*genus* right but frequently assigns the wrong *species within that genus*. Report species
output, but do not treat it as trustworthy.
- **Species naming.** All 13,399 indexed SGBs have real Linnaean species names, so normal output
shows names like "Escherichia coli", not bin ids. (The `display_name` logic still falls back to
`Genus sp. (SGBxxxx)` for unnamed bins β this only matters if the index is ever rebuilt to
include the unnamed SGBs that exist elsewhere in the MetaPhlAn taxonomy; the shipped index has
none.)
- **Closed-world β this is the big one.** The model can only assign a read to a taxon that exists
in its reference index. An organism that is **not** in the reference will still be confidently
assigned to its nearest reference genus and reported as present. There is effectively **no
novelty rejection** (abstain rate ~0% in evaluation), and this behavior was **never quantified**.
**Do not deploy this against arbitrary/open real-world samples** and present the output as
complete or trustworthy without adding a novelty-rejection layer. It is validated only in the
closed-world setting where the sample's organisms are known to be in the reference.
- **Evaluated on one mock community (Zymo D6300).** Broader validation was not done.
---
## 9. Provenance
- Encoder: `enc_attention_bg1.best_genus.pt` β seqLens-89M backbone, attention pooling, 256-d
projection, trained with contrastive InfoNCE + background negatives, checkpoint selected on
real-Zymo genus accuracy.
- Index: `index_bakeoff_mw_notest` β leakage-fixed (held-out test markers excluded).
- Base model pulled at runtime: `omicseye/seqLens_4096_512_89M-at-base-multi`.
See `MANIFEST.json` for file checksums and the machine-readable summary. |