Subsampling specification#
This page is the specification of Cellucid’s subsampling: which cells a subsample keeps, given a request. Three implementations follow it and must agree on every cell:
Where |
Implementation |
|---|---|
Web app (the reader worker, while a dataset opens) |
|
Python |
|
R |
|
The shared conformance fixture subsample-conformance.json (web app
tests/fixtures/, Python tests/fixtures/subsample_conformance.json, R
tests/testthat/fixtures/subsample_conformance.json) is written by
cellucid/scripts/generate-subsample-conformance.py, a plain-Python reading of
this page independent of all three, and the three copies are pinned by one
SHA-256 in all three test suites. Changing anything below means regenerating
the fixture once and updating the digest everywhere.
Version: 1.
Terms#
n— the cells in the dataset (n_obs),0 ≤ n ≤ 2^32 − 1.A cell’s position — its row in
obs,0 ≤ p < n, a uint32.k— the subsample size, an exact count,1 ≤ k.k ≥ nkeeps every cell (no subsample).k = 0is refused. (The web app opens a subsample of at least 2 cells, since one cell has no coordinate variation to draw (At least two cells); the selection itself, specified here, keeps anyk ≥ 1.)seed— a uint32. The default is0.Every result is the ascending positions of exactly
kcells.
All integer arithmetic below is exact. ^ is bitwise XOR, >>> an unsigned
shift, ·₃₂ multiplication modulo 2^32.
The rank of a cell#
fmix32(h): # MurmurHash3's finaliser, a bijection on uint32
h = h ^ (h >>> 16)
h = h ·₃₂ 0x85ebca6b
h = h ^ (h >>> 13)
h = h ·₃₂ 0xc2b2ae35
h = h ^ (h >>> 16)
seedKey(seed) = fmix32(seed ^ 0x9e3779b9)
rank(seed, p) = fmix32(p ^ seedKey(seed))
For one seed rank is a bijection of uint32, so no two positions ever share
a rank. Wherever cells are ordered by rank, ties (which cannot occur) would
be broken by the smaller position. “The m lowest-ranked of a set” is the m
members with the smallest ranks.
Methods#
uniform#
The k lowest-ranked of all n cells.
Nested: with one seed, the cells kept at size k are among those kept at any
size k' > k. Moving the slider up keeps every cell already shown.
blocks (blockSize B ≥ 1)#
The cells are cut into blocks of B neighbours in file order: block j is
positions [j·B, min(n, (j+1)·B)), 0 ≤ j < ceil(n / B). Blocks are taken in
order of rank(seed, j) (the block number ranked like a position), whole,
until the next one would exceed k; that block contributes its first
k − taken cells.
Nested like uniform. With B = 1 a block’s number is its cell’s position,
so blocks keeps exactly uniform’s cells.
B is part of the specification of a subsample and is recorded with it:
whatever chose it, a session, a link carrying the subsample and Python’s or
R’s subsample_cells reproduce the same cells from the recorded B. When a
request leaves B out, the web app takes a rung of the file’s ladder
(subsample-plan.js, blockRung, blockSizeLadder): the storage’s block
B₀ (below) and its doublings B₀·2^j up to the cell count, so a larger
block holds whole smaller ones and every rung stays aligned to the storage.
The storage’s block comes from the layout alone (defaultBlockSize), from
the dominant per-cell array. That is the
expression matrix genes are read from by default (defaultExpressionPath):
the matrix the file’s launch defaults name (uns['cellucid']['defaults']['expression']),
else the default expression rule’s (raw.X when it holds more genes than
X or X holds scaled values, else X, else the layer the rule picks);
written M below:
a CSR
Mholding values: a chunked index (M/indices) givesB = max(1, round(4 × chunk / m)),mthe mean stored values per cell (about four of its chunks);else a dense
M:B = 4 ×its chunk’s rows;else (
Mis CSC, of another encoding, absent, or a CSR matrix with no value) theobsmarray with one row per cell and the most columns:B = 4 ×its chunk’s rows; with no such array,B = 1.
The same matrix prices the gene sample (the open cells a gene is read for,
by gene time and the gene index’s memory: geneTimeFit, geneIndexFit,
geneSampleOf): a file whose genes come from a cell-major raw.X beside a
gene-major X reads genes for the sample a gene of raw.X fits. Every cell
of a file in its own order, opened with a gene sample, opens in the priority
order of this method’s subsample at seed 0 (cellOrder), so the gene sample
of m cells is the blocks subsample of m cells. That order, and a
blocks subsample requested without B (a share typed or chosen in the
Cells to open control, auto-fit), take the same rung (blockRung):
a revisit keeps its rung: the rung a gene index kept in this browser was built under (
kept-column-index.js, filed by method, seed andB), whatever the link measures now; of several kept, the one holding most cells. Another rung would open other cells and read them again;a first visit chooses by request economics: the lowest rung at or past
requests × loadedRoundTrip × rate / (connections × stored bytes a cell)(requestswhat a block’s first gene reads: the runs of its arrays’ chunks and the chunk-index nodes that find them). Below it a sample is bound by round trips (fewer cells in the gene time); above it by bytes (no more cells, in coarser blocks). The opening’s own rate is a lower bound (its structure reads are a few requests on new connections), sorateis the higher of it and the seed: the rate the host sustained on earlier visits (host-link-memory.js) or this browser’s downlink measured on the page’s own assets (pageDownlinkBytesPerMs).B₀itself without a measured link.
An array read byte by byte instead of by chunks (stored in one piece, or an
uncompressed Zarr array whose chunks are read in part) has no chunk to align
to: B is then the cells whose bytes fill 64 KiB of it,
ceil(65536 / bytes per cell) (a CSR M: m × the index’s element size;
a dense M: genes × element size; an obsm array: columns × element size).
B₀ is never the link: it is a property of the file. The rung depends on
the link and on what this browser keeps, so two browsers may open a request
without B in different blocks; the recorded B reproduces either, and a
larger share of the same request (same B) keeps every cell of a smaller
one.
Splitting a size over strata#
stratified and spatial split k over strata, each a set of cells,
numbered 0 … S−1. Only occupied strata take part, in number order.
Each occupied stratum
s(sizen_s) first getsbase_s = min(m, n_s)cells,mthe minimum per stratum. IfΣ base_s > kthe request is refused.The rest,
R = k − Σ base_s, is split over the strata that still have room, capacityc_s = n_s − base_s > 0, in number order:proportional (largest remainder):
q_s = floor(R·c_s / C),C = Σ c_s, with the remainder(R·c_s) mod Ccomputed exactly (R·c_s < 2^64); theR − Σ q_sstrata with the largest remainders get one more, ties to the lower stratum number;balanced (water-filling): the largest level
LwithΣ min(c_s, L) ≤ R; each getsmin(c_s, L), and theR − Σ min(c_s, L)lowest-numbered strata withc_s > Lone more.
Stratum
skeeps itsbase_s + q_slowest-ranked cells.
No stratum ever gets more cells than it has.
stratified (a categorical field)#
A categorical field’s categories are in their stored order; plain values
(not categorical) are made categories in code-point order (Python’s
pandas Categorical, R sort(method = "radix")), never a locale’s.
Strata are the field’s categories in their stored order, codes 0 … C−1, and
one more stratum, C, for every cell without a category (a missing code:
negative, NaN). Categories with no cell take no part. Allocation
proportional (default) or balanced; a minimum per category m (default
0). Recorded: the field’s key, the allocation, m.
spatial (an embedding)#
The cells are placed on a grid of G boxes per axis (gridSize, default 32)
over one to three columns of an embedding, and k is split balanced (no
minimum) over the occupied boxes, so sparse regions keep their cells while
dense ones are thinned.
Coordinates are first rounded to float32 (as the viewer holds them), then
widened exactly to binary64. Over the cells whose every coordinate is finite,
each axis a has low_a (minimum) and high_a (maximum). A cell’s box on
axis a is
w = high_a − low_a
b_a = 0 if w = 0 (or no finite cell)
b_a = min(G − 1, floor(((x − low_a) × G) ÷ w)) otherwise
where x − low_a, × G and ÷ w are each one binary64 operation rounded to
nearest-even, in that order (never fused). The box is
b_0 + G·b_1 + G²·b_2. A cell with any non-finite coordinate is in box
G^d (d axes), after every other. Strata are the occupied boxes in
ascending box number.
Recorded: the embedding’s key, its columns, G.
Why blocks is sound#
blocks is cluster sampling: equal-size clusters of neighbouring cells,
chosen uniformly at random (by rank). Every cell has the same chance to be
kept, so proportions and means estimated from the subsample are unbiased.
Only their variance grows with how alike neighbouring cells are: the design
effect is 1 + (m − 1)ρ for blocks of m cells with intra-block
correlation ρ. With thousands of small blocks it stays small; a file sorted
by a category (a sample, a cell type) raises ρ, and the Session panel shows
the measured largest difference between a category’s share of the open cells
and of every cell, so it is visible.
Priority-ordered files#
A file Cellucid writes stores its cells in priority order: row r is
the cell of the r-th lowest rank(seed, p) under the file’s seed, with
uns['cellucid']['priority'] = {seed, specVersion: 1, nObs} (verified
against the file before it is used: the specification’s
priority order, “Verified before use”) and each row’s
original position in obs['cellucid_original_position']; a gene-major X
may add varm['cellucid_prefix_counts'] (n_vars × levels, with
uns['cellucid']['priority']['levels']): each gene’s stored values among the
first levels[j] rows. The writers’ side of this contract — the level
sequence (each floor(previous × 3 / 4) from n), both axes of obsp, MuData
modalities ordered by their cells’ priority — is the Cellucid-ready AnnData
specification’s priority order; specVersion is this
page’s version.
Then the file’s seed is the default seed, and a uniform subsample of size
k under it is file rows [0, k): every per-cell array is read as one
prefix (at most one partly used chunk each); a CSR X as entries
[0, indptr[k]); a gene of a gene-major X as the first
prefix_counts[gene, j] entries of its range for the smallest level
levels[j] ≥ k (one more entry when the gene has one: every counted row
below levels[j] and the next at or past it, else the file is refused);
obsp as rows [0, k) with columns < k. Any other seed or
method is resolved over original positions (per-cell inputs put in original
order through the file’s permutation) and read through the general path. The
reader computes the open cells’ original positions from the seed (the
positions sorted by rank) instead of reading them, checks them against the
stored column’s first chunk of rows (priority-layout.js). A file whose
record does not verify (subset, combined or reordered, then saved again with
anndata, which keeps uns) is never refused and never misread: it is read
as a file Cellucid did not write — each row its own original position,
blocks its default — and the opening says why. Open cells are in priority order (for
that prefix, and for every cell of such a file, file order); every boundary
still names cells by original position (cell-identity.js, openOrder).
Where a store in priority order comes from#
Not only prepare() and cellucid_prepare() files: every store a
Cellucid program makes for the browser is in priority order, under seed 0
(this page’s default), so the prefix path above applies to data of any
source layout.
Data |
The store the browser reads |
|---|---|
A file |
the file, as it is |
An AnnData or MuData object Python serves ( |
a Zarr v2 store made from the object as the browser reads it ( |
An AnnData file Python serves that is not in a verified priority order ( |
the same store, made from the file’s own arrays (h5py, zarr): a cell-major |
A MuData file Python serves that is not in a verified priority order ( |
a MuData Zarr store made the same way: the shared cells in priority order, each modality an AnnData group as above with its rows in the order of its cells ( |
A Loom file or a 10x Cell Ranger |
the AnnData the browser’s layout view presents from the file ( |
An R object R serves ( |
the |
An R object or MatrixMarket folder the browser converts |
the converted store (origin-private file system), written in priority order with the same records ( |
In each, a cell’s original position is the one prepare() and
cellucid_prepare() give it, the same in every path, by the one rule for a
stored order (stored_order() in Python, .stored_order() in R,
readPriorityLayout() here): uns['cellucid']['priority'] decides, and it
verifies only when its seed and specVersion are valid, its nObs is the
cell count, its levels are prefixLevels(nObs) and the position column,
when there, is the seed’s order. An object in a verified stored order (read
back from a file in priority order, its record kept) is put back in its
original order first, and a file in a verified order is served as it is, so
every cell keeps the position the file gave it. Anything else is cells
Cellucid did not write: each row is its own original position (the object’s
row order, the file’s rows), and a position column with no record that
describes the cells — a column alone, a subset or reordering of such an
object, a column that is not the seed’s order — names nothing: it is not
served or written, and a warning says why, in the same words on every path.
So a selection, a highlight or a session lands on the same cells whichever
path wrote or serves the data. A MuData object Python serves is served as a
MuData Zarr store in priority order, from a snapshot, with no file written. The web app’s own tests open each
foreign file Python serves both ways — the file through the browser’s view,
the served store — and compare them cell for cell
(tests/served-priority-store.test.mjs). Files the servers serve as they
are — MatrixMarket folders and R files given by path to Python, which the
browser reads once from the start and converts as above; any file given by
path to R, which has no HDF5 reader and no reader of Zarr chunks, and says
so when it serves one not in priority order — are read by the browser in
storage blocks (blocks).
The servers’ chunks are uncompressed (read over loopback or a notebook’s
connection). The browser reads a run of an uncompressed chunk’s elements
— a prefix of rows, a gene’s counted entries — as those bytes alone when
the bytes it leaves unread cost more than a request on the link
(formats/zarr/array.js), so the bytes a subsample transfers grow with k
beyond the chunk layout. Measured on a 1,000,000-cell × 2,000-gene AnnData
object (100 million stored values, CSR) served by Python: see the
performance page of the Python package
(Performance, scaling, and lazy loading).
Priority order: the order a subsample’s cells open in#
The kept cells (ascending, as every method returns them) are opened in their priority order:
blocks: byrank(seed, j)of their blockj = floor(p / B), then by position within the block — exactly the orderblockstakes them in;every other method: by
rank(seed, p).
The first m cells of this order are a sample at every m: for uniform
and blocks exactly the subsample of size m under the same seed (both are
nested), for stratified and spatial a uniform sample of the cells they
kept. The fixture lists each case’s order (order; orderHead and
orderTail for the large one), which the web app’s priorityOrder is held
to. It is the web app’s own: everything that leaves the app names cells by
original position. A session’s state values name cells by original position
too, whatever order they were open in (app/state/domains/cell-positions.js:
a cell list is an ascending Uint32Array of original positions, a per-cell
column {cells, values} with its cells ascending), so Python and R apply a
session saved on a subsample without knowing any order.
What the web app builds on it (subsample-plan.js, views/cell-subset.js,
prefix-column-index.js):
Reads in priority order. A read of open cells
[0, m)fetches the chunks of themhighest-priority cells first; a view’s reads are planned over the stored rows ascending (two rows share a read under the rule below) and each run is copied to its own place, bit for bit.A gene index in segments. A cell-major matrix (CSR, or dense not stored in gene strips) whose open cells are in priority order is indexed in segments of rows — the first about a thirty-second of the cells (at least one slab of the index’s work), each next one as large as all before it — built one after another. A gene asked for meanwhile is answered after each segment on the cells indexed so far (NaN, shown as missing, on the others): every partial answer is a sample of the open cells.
Growing and shrinking in place. Opening the same file again with other cells (the subsample control) reads it in the same reader worker through the store already open, and the new reading takes over the gene index of the open cells both share as a prefix: raising a
uniformorblockssubsample under the same seed indexes only the new cells, and lowering it reads nothing. Nothing is carried for cells the two openings do not share (another method or seed starts the index anew).Kept across visits (
kept-column-index.js). Each segment built (and the whole index of a matrix opened with every cell) is kept in the origin-private file system as one directory oftranscode/space.js, filed under the group: the file’s identity, the matrix, its shape and the order key (the subsample’s method, seed and method settings, neverk;filefor every cell). It records its first open row, its rows’ file rows and row pointers, and the store as built: the entry positions of each gene (a structure, over a link slower than a disk), each gene’s rows and values, or a dense column store. A later opening takes every kept run whose file rows equal its own at the same open positions — the whole run, or its first rows when the opening has fewer (a prefix of every gene’s entries) — and builds segments only where none is kept. Runs are shared exactly as far as two openings’ priority orders agree from the start: all of the smaller foruniformandblocks(nested), only the common start forstratifiedand the other methods that draw each size apart. Before use a run is checked (findKeptRuns): manifest revision (KEPT_INDEX_REVISION), identity digest and sizes; its file rows against the opening’s; its row pointers against the file’s, read now (a mismatch removes the run); the CRC-32s of its column starts and per-gene checksum tables. The file’s version is the one rule of every kept kind: runs are filed under the identity this opening established from the file’s source (a content name where the source has one, else its size with its Last-Modified date and strong ETag,space.jswriteVersion; a local file’s size and modification time), and opening a version first forgets what is kept of the file’s other versions (forgetOtherVersions), so a run of another version is never found. Each gene’s piece of every per-gene array (positions, rows, values) carries a CRC-32 checked as it is read; a mismatch removes the run, the gene is read from the file and the run is built and kept again (CRC-32, not SHA-256: 1.6 µs against 23 µs for a 3.2 KB piece, 45 µs against 93 µs for 96 KB, Node 26 on an M1 Pro; it finds accidental damage, not tampering). Kept across visits only under an identity that names a version (persistsAcrossVisits), else for the opening that built it (and its readings again with other cells). A kept run makes room only from other kept runs (optional), and after aQuotaExceededErrorremoves more and writes again while that frees anything; the index being built (column-store-placement.jsdiskFreeBytes) and a conversion make room from runs before converted stores, least recently used first within each. Every run an opening takes is held (a Web Lock) until its reader closes, and the writer of a run lets go of it only once the page holds it ({storeHeld}).Kept work is priced at no download. The gene sample’s fit by the first gene’s read time (
geneTimeFit) leaves out of the cost the first open cells whose index runs are kept (kept-column-index.jskeptIndexPrefix, from the runs’ manifests, for the open cells’ order: a subsample’s, or every cell’scellOrder, filed as theblockssubsample whose order it is) and every chunk of the matrix’s arrays this browser holds already (heldChunkBands: chunks of a file’s downloaded parts,formats/io/kept-parts.js, read from this machine’s disk). A later visit therefore reads genes for more cells within the same target; the fit records what was kept (genes.fit.kept:{cells, bytes}), which the details table names (Kept here).
Edge cases#
Case |
Result |
|---|---|
|
refused |
|
every cell (no subsample) |
a seed or position outside uint32 |
refused |
an empty category |
takes no part |
a minimum per category that does not fit |
refused (fixture |
|
refused (box numbers would not be exact) |
a spatial column that is negative or not an integer |
refused |
NaN or infinite coordinates |
the last box |
every coordinate of an axis equal |
that axis’ box is 0 |
no cell with finite coordinates |
every cell in the last box ( |
Cell set digest#
The digest names a kept set: 16 lowercase hex digits from two 32-bit lanes
over the ascending positions p_0 … p_{k−1} (for “every cell”, 0 … n−1):
a = 0x811c9dc5 ^ n
b = 0x9e3779b9 ^ k
for each p:
a = (a ^ p) ·₃₂ 0x01000193
x = b ^ p
b = (rotl32(x, 13) ·₃₂ 5) + 0xe6546b64 (mod 2^32)
digest = hex8(fmix32(a ^ k)) + hex8(fmix32(b ^ n))
Cell set encoding (sessions)#
A session stores a kept set as bytes: for each ascending position, the gap
from the previous one (the first from −1) minus one, as an unsigned LEB128
varint. Every cell is no bytes. In the session manifest the bytes are base64,
under cells.kept, beside cells.spec (the specification: method, nObs,
k, seed, and the method’s own keys) and cells.digest
(Sessions: persistence and serialization).
Cells to open in the history, and opening again from My datasets#
Cells to open (Session › Subsampling) is the action cells.open
(app/actions/definitions/mapping.js, provided by
ui/modules/subsample-control.js): {mode: 'all'} or {mode: 'subsample', count, method, seed, …}, one step of the dataset’s history on the same
tracker (Action layer, state domains and history). Its value is the cells
state domain’s (app/state/domains/cells.js): open, null for every cell or
the subsample’s specification with k the cells it grows to (it reproduces the
same cells exactly, strict), and genes, the count of open cells genes were
asked to be read for. A progressive opening growing toward its share and the
gene sample’s top-up are not a change of the value. Undoing the step puts the
cells value back, which reads the dataset again on the cells shown before
(reopenKeeping: what was made is kept by original cell position), and the
history captures every other domain again after it; redo reads the share
again (tests/browser/history-open-cells.spec.mjs). cells.read-genes
(Genes read for) is a step of the same domain; a smaller genes keeps what is
read.
My datasets (app/library/my-datasets.js) records the cells a dataset was
last open with (openCells). When they were chosen (a subsample’s method,
size and seed), opening the dataset again from My datasets while another one
is shown makes them the next opening’s request (reopenRequestOf: {mode: 'subsample', …spec, strict: false}, through setNextCellSubsetRequest in data/cell-subset-policy.js),
which the opening takes once (takeCellSubsetRequest) and which is withdrawn
when that opening ends without taking it (withdrawNextCellSubsetRequest), so
it never applies to whatever opens next. Every cell, or cells the device fit
chose, open as the tab opens datasets now (the tab’s request).
Where subsampling happens in the web app#
The reader worker resolves the request (assets/js/data/anndata/subsample-plan.js)
before the dataset’s structure is read, reading only what the method needs (a
stratified subsample the field’s codes; a spatial one the embedding’s
columns). It then reads the dataset through a view of the kept cells
(assets/js/data/anndata/views/cell-subset.js): everything above it — the
viewer, filters, analyses, gene reads, exports — sees a dataset of k cells.
Cell i of the view is original position openOrder[i], the kept cells in
priority order (assets/js/data/cell-identity.js); everything that leaves
the app names cells by original position.
Read path |
How it reads the kept cells |
|---|---|
obs columns, categorical codes, nullable values and masks, obs index |
the kept rows’ runs, chunk by chunk, in the stored order: a read of many open cells goes over the stored chunks in ascending order, a slab after another ( |
dense |
the same |
CSR |
the kept rows’ |
CSC |
a gene’s own entries, the kept cells’ kept |
|
the kept rows’ |
modalities (MuData, 10x feature types, Seurat assays) |
each modality’s rows the kept cells measured, its cell map renumbered |
Two kept rows share one read of the stored array only when no chunk lies
wholly between them (a chunk is decoded whole), or, for an array read byte by
byte (stored in one piece, or an unfiltered HDF5 chunk), when the gap costs
less than a request on the link as measured. A read never fetches a chunk
that holds no kept row. tests/subsample-gather-differential.test.mjs holds
every path to “read every cell, then take the kept rows”, bit for bit, and to
that chunk budget, over randomly drawn stores; tests/efficiency-floor.test.mjs
holds every element, at every share, to its floor: each stored chunk that holds
a kept cell fetched once, none other (scripts/efficiency-floor.mjs measures
the same floor over real files, links and the HDF5 reader).