bixbench
dataAccess BixBench, a dataset of 205 open-ended bioinformatics questions paired with 59 data capsules taken from published papers' analysis notebooks. Load the index, browse questions by category and source paper, and pull a capsule's real data onto disk. A benchmark by origin, and a browsable corpus of worked analysis cases in its own right.
BixBench
205 open-ended bioinformatics questions, each attached to a capsule of the real data needed to answer it, drawn from published papers' own analysis notebooks.
It was built to evaluate agents, and that origin shapes it — but as a corpus it is more generally useful than that: a few dozen real analyses, with their inputs, their questions, and the notebook the authors actually ran. This skill gets you all of it.
Licence, and the version that bites
Apache-2.0, ungated. That makes it the permissive member of its family — LAB-Bench and its subsets are CC-BY-SA-4.0, which is a different conversation.
Cite the version, not the name. The paper describes 53 scenarios and 296 open-answer
questions. The dataset on main today is v1.5: 205 questions over 59 capsules, after the
maintainers re-reviewed items they judged underspecified for open-answer grading and flattened
the schema to one question per row. They say scores should shift upward as a result, so a
v1.0 number and a v1.5 number are not comparable. The original is preserved at tag v1.0.
Every row in the current file carries version: "1.5" — check it rather than assuming.
The index
One JSONL file, one row per question.
import json, urllib.request
BASE = "https://huggingface.co/datasets/futurehouse/BixBench/resolve/main"
with urllib.request.urlopen(f"{BASE}/BixBench.jsonl", timeout=120) as fh:
rows = [json.loads(line) for line in fh.read().decode().splitlines() if line.strip()]
# `paper` marks "unknown" with TWO different sentinels, so a plain set() over it
# counts both as if they were publications. See "Reading the questions" below.
SENTINELS = {"Not Available", ""}
papers = {str(r["paper"]).strip() for r in rows} - SENTINELS
print(f"{len(rows)} questions | {len({r['capsule_uuid'] for r in rows})} capsules "
f"| {len(papers)} source papers")
print(f"versions present: {sorted({r['version'] for r in rows})}")
print(f"fields per row : {len(rows[0])}")
Run 2026-08-23:
205 questions | 59 capsules | 17 source papers
versions present: ['1.5']
fields per row : 17
What the fields carry:
| field | |
|---|---|
question · ideal · distractors |
the question, the reference answer, and the wrong options for multiple-choice mode |
hypothesis · result · answer |
what the original analysis tested, what it found, and the graded answer |
eval_mode |
how the item is meant to be scored |
capsule_uuid · data_folder |
which capsule holds the data, and its zip filename |
categories · paper |
comma-joined topic labels, and the source publication |
id · tag · version · short_id · question_id |
identifiers |
canary |
see below |
Seventeen paper strings across fifty-nine capsules, so several capsules come from the same
study —
treat them as independent samples and you overstate your effective n.
One note on canary. Every row carries a canary string: the convention that lets anyone
later test whether this dataset leaked into a model's training corpus. Reading it is fine, it
is part of the data. Do not paste one somewhere it can be scraped — a public repo, an issue,
a blog post — because that is the one action that destroys its only function, for everyone,
permanently.
Browsing by topic
Continues from the block above — reuses rows.
import collections
def categories_of(row):
"""`categories` ships in TWO representations — some rows are a plain comma-joined
string, others a Python list repr like "['Genomics', 'RNA-seq']". Splitting on commas
without normalising yields labels such as "['Whole Genome Sequencing (WGS)'" and scatters
Genomics across four keys, so the plain label reads 18 of its 74 rows."""
raw = str(row["categories"]).strip()
if raw.startswith("[") and raw.endswith("]"):
raw = raw[1:-1]
return [c.strip().strip("'\"") for c in raw.split(",") if c.strip().strip("'\"")]
cats = collections.Counter(c for r in rows for c in categories_of(r))
for name, n in cats.most_common(12):
print(f"{n:4} {name}")
74 Genomics
69 RNA-seq
69 Transcriptomics
67 Differential Expression Analysis
48 Whole Genome Sequencing (WGS)
47 Phylogenetics and Evolutionary Analysis
30 Sequence Analysis
25 Other
21 Imaging
16 Genomic Variant Analysis
12 Epigenomics
10 Functional Genomics
Categories overlap — counts sum past 205 because most rows carry several, 2.5 on average across 20 distinct labels. RNA-seq and differential expression dominate; imaging or epigenomics is roughly one row in ten.
Normalise before counting. The column holds two representations — most rows are a plain
comma-joined string, some are a Python list repr. A naive split(",") yields labels like
['Whole Genome Sequencing (WGS)' and scatters Genomics across four keys — Genomics 18,
'Genomics' 47, ['Genomics' 6, 'Genomics'] 3 — so the label reads 18 instead of 74, and
RNA-seq and Transcriptomics displace it at the top. ## Try it asserts against exactly that.
Reading the questions
Counting topics tells you what is in here; this gets you the items themselves. Continues from
the first two blocks — reuses rows and categories_of.
WANT = "Genomics"
hits = [r for r in rows if WANT in categories_of(r)]
print(f"{len(hits)} questions tagged {WANT}, drawn from "
f"{len({r['capsule_uuid'] for r in hits})} capsules\n")
for r in hits[:3]:
# two sentinels, not one -- "Not Available" and the empty string
src = str(r["paper"]).strip() or "Not Available"
src = "source not recorded" if src == "Not Available" else src
print(f"[{r['short_id']}] {src}")
print(f" capsule {r['capsule_uuid']} ({r['eval_mode']})")
print(f" Q {' '.join(str(r['question']).split())[:160]}")
print(f" ideal {' '.join(str(r['ideal']).split())[:110]}")
n = len(r['distractors']) if isinstance(r['distractors'], list) else 'n/a'
print(f" wrong {n} distractors\n")
74 questions tagged Genomics, drawn from 28 capsules
[bix-11] source not recorded
capsule cd811ead-7887-4369-b175-05aff4223765 (llm_verifier)
Q What is the difference between median treeness values for fungi versus animals?
ideal 0.05
wrong 3 distractors
[bix-11] source not recorded
capsule cd811ead-7887-4369-b175-05aff4223765 (llm_verifier)
Q What percentage of fungal genes have treeness values above 0.06?
ideal 35%
wrong 3 distractors
[bix-11] source not recorded
capsule cd811ead-7887-4369-b175-05aff4223765 (llm_verifier)
Q What is the maximum treeness value among animal genes?
ideal 0.1126
wrong 3 distractors
Everything stays in memory. The index is a few hundred KB, so there is no reason to stage it on disk — only capsules are downloaded, and only when you want the data an item was written against.
A row carries the question, the reference answer in ideal, and exactly three wrong options in
distractors — all 205 rows, no exceptions — so the corpus reads as a set of worked analysis
problems with their answers attached. question, ideal, distractors and eval_mode are
populated on every row.
paper is not, and it fails in a way that quietly corrupts counts. It marks "unknown" with
two different sentinels — the literal string Not Available on 57 rows, and an empty string
on 11 more. A plain len({r["paper"] for r in rows}) therefore returns 19 and reads like
nineteen publications, when 68 of 205 rows name no source at all and only 17 distinct strings
remain. Guard against one sentinel and the other still slips through.
Nor is 17 a count of publications. The field is not one identifier per row: three values pack a
data repository and an article URL into a single comma-joined string, one DOI appears under two
spellings with and without the https:// prefix, and what it points at is a mix of DOI
references (8), Zenodo and Dryad data records, and publisher article pages — two of which embed
a DOI of their own, so even "how many DOIs" depends on where you look. Split on the commas and normalise
the spellings and you get 19 distinct references — more strings than you started with, not
fewer. There is no single correct total to quote here, which is the point: any number you derive
from this field depends on decisions you have to make yourself and state.
Filter on paper and you silently drop a third of the set. Treat it as a hint, not a join key —
capsule_uuid is the identifier that is actually complete.
Reading an item also means it is no longer a blind test of anything, which matters only if you later evaluate on the same items.
The count that does not add up, and why it matters
The repository publishes 64 capsule zips. The index references 59. The five extras are real files that no question points at. Continues from the first block.
import json, urllib.request
api = json.load(urllib.request.urlopen(
"https://huggingface.co/api/datasets/futurehouse/BixBench", timeout=90))
published = {s["rfilename"].removeprefix("CapsuleFolder-").removesuffix(".zip")
for s in api["siblings"] if s["rfilename"].endswith(".zip")}
referenced = {r["capsule_uuid"] for r in rows}
print(f"published {len(published)} | referenced {len(referenced)} | "
f"orphans {len(published - referenced)} | missing {len(referenced - published)}")
for u in sorted(published - referenced):
print(f" orphan: CapsuleFolder-{u}.zip")
published 64 | referenced 59 | orphans 5 | missing 0
orphan: CapsuleFolder-2ed9023e-75d7-40fe-a425-b6b84ac8329c.zip
orphan: CapsuleFolder-8690e7cb-7405-4d2a-9721-e85824d05822.zip
orphan: CapsuleFolder-a45810bb-beee-466a-b31f-ba60268afa53.zip
orphan: CapsuleFolder-d36654ed-2cd6-41c1-84f9-1a3a16be3de5.zip
orphan: CapsuleFolder-d49cde68-cc3a-479d-88cb-11302207ac3c.zip
Count the index, not the directory. A reader who lists zips concludes there are 64 tasks and is wrong by five.
The v1.0 tag settles why, so this needs no hedging: at v1.0 the dataset referenced 53 capsules
across 296 questions with zero orphans, and the set of capsules referenced at v1.0 but not at
v1.5 is exactly these five. Eleven zips were added and none removed, so the re-review dropped
their questions and left the data published. Four of the five are also the malformed capsules
described above — which is the likeliest reason they were dropped.
missing 0 is the reassuring half: every referenced capsule does have a zip, so nothing in the
index is a dead link.
Get the files
data_folder is the zip's filename, so a capsule needs no URL construction.
import io, json, os, urllib.request, zipfile
OUT = "Data/bixbench"
BASE = "https://huggingface.co/datasets/futurehouse/BixBench/resolve/main"
UUID = "33b801bb-9b47-4a0a-9314-05325c82fde7"
os.makedirs(OUT, exist_ok=True)
# Standalone: fetch the index here rather than inheriting it.
with urllib.request.urlopen(f"{BASE}/BixBench.jsonl", timeout=120) as fh:
rows = [json.loads(l) for l in fh.read().decode().splitlines() if l.strip()]
row = next(r for r in rows if r["capsule_uuid"] == UUID)
name = row["data_folder"] # already 'CapsuleFolder-<uuid>.zip'
raw = urllib.request.urlopen(f"{BASE}/{name}", timeout=600).read()
zf = zipfile.ZipFile(io.BytesIO(raw))
zf.extractall(OUT)
files = [n for n in zf.namelist() if not n.endswith("/")]
print(f"{name} {len(raw) / 1e6:.1f} MB -> {len(files)} file(s)")
for n in sorted(files):
print(f" {n.split('/')[-1][:52]:54} {zf.getinfo(n).file_size / 1e3:9.1f} KB")
with open(os.path.join(OUT, "manifest.json"), "w") as fh:
# normalise here too -- 68 of 205 rows would otherwise stamp a sentinel into the manifest
src = str(row["paper"]).strip()
json.dump({"capsule": UUID, "paper": src if ("." in src and "/" in src) else None,
"categories": row["categories"], "files": len(files)}, fh, indent=2)
Run 2026-08-23:
CapsuleFolder-33b801bb-9b47-4a0a-9314-05325c82fde7.zip 5.4 MB -> 5 file(s)
HGNC_05-09-19.txt 5202.8 KB
Issy_ASXL1_blood_coldata_gender.xlsx 10.0 KB
Issy_ASXL1_blood_featureCounts_GeneTable_final.txt 13866.6 KB
gencode.v31.primary_assembly.genes.csv 11271.9 KB
CapsuleNotebook-33b801bb-9b47-4a0a-9314-05325c82fde7 33.3 KB
A gene-symbol table, a sample sheet with sex covariates, a featureCounts gene table, a GENCODE gene list — and the notebook the authors ran. Real working data, which is the point.
The internal layout is not uniform, so do not construct paths you expect to exist. Reading every zip's central directory on 2026-08-23:
| capsules | layout |
|---|---|
| 39 | CapsuleData-<uuid>/ + CapsuleNotebook-<uuid>/ |
| 21 | CapsuleData-<uuid>/ only — no notebook in the archive |
| 3 | both, plus a second copy of the notebook inside CapsuleData- |
| 1 | everything nested one level deeper under CapsuleFolder-<uuid>/ |
Code assuming a fixed path breaks on about a third of the set. extractall, then walk what
landed. All four odd ones are orphans, so index-driven work never meets them.
Budget from the distribution. These figures come from each zip's central directory and the HF file listing, not from extracting the set — but re-deriving them from scratch means touching all 64 archives, so treat them as dated observations rather than something to spot-check. Measured across all 64: min 9 KB, median 10.2 MB, max 481.3 MB, 23 over 100 MB, 5.91 GB for the set. The example above is near the small end. The largest referenced capsule is ~1 GB uncompressed, and this block holds the whole archive in memory before extracting — stream to disk for anything that size.
What this skill will not do
It stops at the data. It does not answer questions, does not run the grader, and does not stand
up the evaluation environment — that is futurehouse/bixbench:aviary-notebook-env and the
harness in the upstream repository.
That boundary is deliberate. Fetching and browsing a benchmark is safe and useful; wiring an agent to its answers is how a benchmark stops measuring anything.
Try it
Data. BixBench v1.5 (futurehouse/BixBench on Hugging Face), Apache-2.0, ungated. Last
confirmed reachable 2026-08-22.
Run.
import collections, json, urllib.request
BASE = "https://huggingface.co/datasets/futurehouse/BixBench/resolve/main"
with urllib.request.urlopen(f"{BASE}/BixBench.jsonl", timeout=120) as fh:
rows = [json.loads(l) for l in fh.read().decode().splitlines() if l.strip()]
FIELDS = {"id", "tag", "version", "question", "ideal", "distractors", "canary", "capsule_uuid",
"short_id", "question_id", "hypothesis", "result", "answer", "categories", "paper",
"data_folder", "eval_mode"}
assert all(set(r) == FIELDS for r in rows), "schema changed — recheck the field table"
caps = {r["capsule_uuid"] for r in rows}
api = json.load(urllib.request.urlopen(
"https://huggingface.co/api/datasets/futurehouse/BixBench", timeout=90))
zips = {s["rfilename"].removeprefix("CapsuleFolder-").removesuffix(".zip")
for s in api["siblings"] if s["rfilename"].endswith(".zip")}
papers = {str(r["paper"]).strip() for r in rows} - {"Not Available", ""}
print(f"questions / capsules / papers : {len(rows)} / {len(caps)} / {len(papers)}")
print(f"versions : {sorted({r['version'] for r in rows})}")
print(f"zips / referenced / orphaned : {len(zips)} / {len(caps)} / {len(zips - caps)}")
assert not (caps - zips), f"referenced capsule with no zip: {caps - zips}"
assert all(r["capsule_uuid"] in r["data_folder"] for r in rows), "data_folder no longer names the capsule"
# Not `{"Not Available", ""} & papers` -- `papers` is built by subtracting exactly that set,
# so the intersection is empty by construction and the guard could never fire. Test the shape of
# what survives instead, which also catches a sentinel spelled some new way.
assert all("." in p and "/" in p for p in papers), \
f"`paper` value is not a reference -- new sentinel? {sorted(p for p in papers if not ('.' in p and '/' in p))}"
assert all(len(r["distractors"]) == 3 for r in rows), "not every row carries exactly three distractors"
assert all(str(r[f]).strip() for r in rows for f in ("question", "ideal", "eval_mode")), "a core field is blank"
def categories_of(row):
raw = str(row["categories"]).strip()
if raw.startswith("[") and raw.endswith("]"):
raw = raw[1:-1]
return [c.strip().strip("'\"") for c in raw.split(",") if c.strip().strip("'\"")]
top = collections.Counter(c for r in rows for c in categories_of(r))
# Quote-wrapped keys catch a fully naive split; bracket-bearing keys catch the subtler case
# where quotes are stripped but the list repr is not. The second is the regression that has
# actually happened here, and testing only the first would have missed it.
assert "Genomics" in top and "'Genomics'" not in top, "categories not normalised — quoted keys leaked"
assert not [k for k in top if "[" in k or "]" in k], \
f"categories not normalised — list-repr brackets leaked: {[k for k in top if '[' in k or ']' in k][:3]}"
print(f"top category : {top.most_common(1)[0][0]} ({top.most_common(1)[0][1]})")
print(f"categories per row (mean) : {sum(top.values()) / len(rows):.1f}")
print("all assertions passed")
Expect.
Invariants — these hold whatever the maintainers republish, and a failure means this page is wrong:
- Every row carries the same 17 fields. If that fires, the schema moved and the field table above needs rewriting before anyone relies on it.
- A
canaryfield exists. Its value is never printed. - Every referenced capsule has a zip (
caps - zipsis empty). The reverse is not an invariant — orphan zips exist by design of the re-review. data_foldercontains its row'scapsule_uuid, so a capsule URL never needs constructing.- Categories are multi-valued, so their counts sum above the row count.
Observed 2026-08-22 against v1.5 — these move when the dataset is revised, so treat a mismatch as drift to investigate rather than a failure:
questions / capsules / papers : 205 / 59 / 17
versions : ['1.5']
zips / referenced / orphaned : 64 / 59 / 5
top category : Genomics (74)
categories per row (mean) : 2.5
all assertions passed
Sources
- BixBench — Mitchener et al., BixBench: a comprehensive benchmark for LLM-based agents in
computational biology, arXiv 2503.00096 (2025). The
paper describes v1.0; the dataset on
mainis v1.5. - Dataset —
futurehouse/BixBench, Apache-2.0. Read the card for the canary policy rather than copying the value. - Harness and environment —
Future-House/BixBench, Apache-2.0.
Capsules derive from published papers' notebooks and data. Apache-2.0 covers the benchmark's own contribution; the underlying data carries whatever terms its source publication set. Fetch from upstream, and check the source paper before redistributing anything you pull out of a capsule.