Source code for vdjtools.preprocess.downsample
"""Random down-sampling of clonotype frames (pure polars + numpy).
Reimplements the legacy vdjtools ``DownSampler`` / ``TopSampler`` family. Two
resampling regimes, matching the legacy ``--unweighted`` switch:
- **reads** (legacy default, weighted): draw ``size`` reads *without replacement*
from the multiset of reads implied by ``duplicate_count``. The legacy
``DownSampler`` shuffles a flattened per-read array and keeps the first ``size``
entries; the exact equivalent is the multivariate hypergeometric distribution
(``numpy.random.Generator.multivariate_hypergeometric``). The task brief phrased
this as "multinomial", but sampling a sequencing library to a fixed depth is a
*without-replacement* operation — a multinomial (with replacement) could return
more reads of a clonotype than were observed — so the hypergeometric is used to
stay faithful to the legacy behaviour and to the biology.
- **clones** (legacy ``--unweighted``): draw ``size`` unique clonotypes *uniformly*
at random without replacement, keeping each one's original count. Note the legacy
clonotype-level mode is uniform, not count-weighted (weighting by count is what
the read-level mode does).
"""
from __future__ import annotations
import numpy as np
import polars as pl
from ..io.schema import COUNT, recompute_frequency
[docs]
def downsample(df: pl.DataFrame, size: int, by: str = "reads",
seed: int = 0) -> pl.DataFrame:
"""Randomly down-sample a clonotype frame to a target size.
Args:
df: A clonotype frame with a ``duplicate_count`` column.
size: Target size — number of reads (``by="reads"``) or number of unique
clonotypes (``by="clones"``).
by: ``"reads"`` (default) draws ``size`` reads without replacement,
weighted by ``duplicate_count`` (multivariate hypergeometric);
``"clones"`` draws ``size`` unique clonotypes uniformly without
replacement, keeping their original counts.
seed: Seed for the numpy random generator (reproducible output).
Returns:
A new clonotype frame with ``frequency`` recomputed. Clonotypes that drew
zero reads (``by="reads"``) are dropped. If ``size`` is greater than or
equal to the available size the input is returned unchanged (legacy guard).
Raises:
ValueError: If ``by`` is not ``"reads"`` or ``"clones"``, or ``size`` < 0.
"""
if size < 0:
raise ValueError(f"size must be non-negative; got {size}")
rng = np.random.default_rng(seed)
if by == "reads":
counts = df[COUNT].to_numpy()
total = int(counts.sum())
if size >= total:
return df
# numpy's default method='marginals' raises once sum(counts) >= 1e9; use the
# slower-but-unbounded method='count' there (else keep the faster default).
method = "count" if total >= 1_000_000_000 else "marginals"
drawn = rng.multivariate_hypergeometric(counts.astype(np.int64), size, method=method)
out = df.with_columns(pl.Series(COUNT, drawn, dtype=pl.Int64))
out = out.filter(pl.col(COUNT) > 0)
return recompute_frequency(out)
if by == "clones":
n = df.height
if size >= n:
return df
idx = rng.choice(n, size=size, replace=False)
idx.sort()
out = df[idx.tolist()]
return recompute_frequency(out)
raise ValueError(f"by must be 'reads' or 'clones'; got {by!r}")
[docs]
def select_top(df: pl.DataFrame, n: int, renormalize: bool = True) -> pl.DataFrame:
"""Select the top ``n`` clonotypes by ``duplicate_count``.
Reimplements the legacy ``SelectTop`` / ``TopSampler`` (take the ``n`` largest
clonotypes). Ties are broken by the frame's existing order (a stable sort).
Args:
df: A clonotype frame with a ``duplicate_count`` column.
n: Number of top clonotypes to keep. If ``n`` is greater than or equal to
the number of clonotypes, all are returned.
renormalize: If ``True`` (legacy default), recompute ``frequency`` within
the selected subset so it sums to 1; if ``False``, preserve the input
frequencies (legacy ``--save-freqs``).
Returns:
The top-``n`` clonotype frame, sorted by descending ``duplicate_count``.
"""
out = df.sort(COUNT, descending=True, maintain_order=True).head(n)
if renormalize:
out = recompute_frequency(out)
return out