"""
A parser that extracts the site frequency spectrum (SFS) from a VCF file, a VCF-Zarr store, or a tskit
tree sequence (ARG). Stratifying the SFS is supported by providing a list of :class:`Stratification`
instances.
"""
__author__ = "Janek Sendrowski"
__contact__ = "sendrowski.janek@gmail.com"
__date__ = "2023-03-26"
import bisect
import functools
import itertools
import logging
import random
from abc import ABC, abstractmethod
from collections import Counter, defaultdict, deque
from typing import Any, Iterable, List, Callable, Literal, Optional, Dict, Tuple
import numpy as np
from Bio.SeqRecord import SeqRecord
from scipy.stats import hypergeom
from tqdm import tqdm
from .annotation import Annotation, SynonymyAnnotation, DegeneracyAnnotation, AncestralAlleleAnnotation
from .filtration import Filtration, PolyAllelicFiltration, SNPFiltration
from .io_handlers import bases, get_called_bases, FASTAHandler, NoTypeException, \
DummyVariant, Site, MultiHandler, VCFHandler, VariantReader, is_monomorphic_snp, SiteAlleles
from .settings import Settings
from .spectrum import Spectra, TwoSFS, TwoSpectra, JointSFS, JointSpectra
# logger
logger = logging.getLogger('sfsutils')
#: Number of sites between full rebuilds of the two-SFS running window sums from the sites in the window. The
#: sums are maintained by adding a site's vector when it enters the window and subtracting it when it leaves,
#: which accumulates rounding error over a long contig; rebuilding them at this interval bounds that drift while
#: costing one pass over the window per few thousand sites.
_TWO_SFS_RESYNC_INTERVAL = 4096
def _snapshot_state(component: object) -> Dict[str, Any]:
"""
Record the per-pass state of a parser component: its integer counters and its list-valued diagnostics,
found by type.
:param component: The filtration, annotation or stratification.
:return: The per-pass state, with the lists copied.
"""
state = {}
for name, value in vars(component).items():
if isinstance(value, bool):
continue
if isinstance(value, int):
state[name] = value
elif isinstance(value, list):
state[name] = list(value)
return state
def _restore_state(component: object, state: Dict[str, Any]):
"""
Put the per-pass state recorded by :func:`_snapshot_state` back on a component.
:param component: The component.
:param state: The state to restore.
"""
for name, value in state.items():
setattr(component, name, value)
def _count_valid_type(func: Callable) -> Callable:
"""
Decorator for counting the number of sites that had a valid type.
"""
@functools.wraps(func)
def wrapper(self, variant: Site):
"""
Wrapper function.
:param self: Class instance
:param variant: The site
:return: The result of the decorated function
"""
res = func(self, variant)
self.n_valid += 1
return res
return wrapper
[docs]
class Stratification(ABC):
"""
Abstract class for Stratifying the SFS by determining a site's type based on its properties.
"""
[docs]
def __init__(self):
"""
Create instance.
"""
self._logger = logger.getChild(self.__class__.__name__)
#: Parser instance
self.parser: Optional['Parser'] = None
#: The number of sites that didn't have a type.
self.n_valid: int = 0
def _setup(self, parser: 'Parser'):
"""
Provide the stratification with some context by specifying the parser.
This should be done before calling :meth:`get_type`.
:param parser: The parser
"""
self.parser = parser
def _rewind(self):
"""
Rewind the stratification.
"""
self.n_valid = 0
def _snapshot(self) -> Dict[str, Any]:
"""
Record the per-pass state, so that a second pass over the input (the sampling pass of
:class:`TargetSiteCounter`) can be reported on without discarding the counts of the first.
:return: The per-pass state.
"""
return _snapshot_state(self)
def _restore(self, state: Dict[str, Any]):
"""
Put the per-pass state recorded by :meth:`_snapshot` back on the stratification.
:param state: The state to restore.
"""
_restore_state(self, state)
def _teardown(self):
"""
Perform any necessary post-processing.
"""
self._logger.info(f"Number of sites with valid type: {self.n_valid}")
[docs]
@abstractmethod
def get_type(self, variant: Site) -> Optional[str]:
"""
Get type of given Variant. Only the types
given by :meth:`get_types()` are valid, or ``None`` if
no type could be determined.
:param variant: The site
:return: Type of the variant
"""
pass
[docs]
@abstractmethod
def get_types(self) -> List[str]:
"""
Get all possible types.
:return: List of types
"""
pass
[docs]
class SNPStratification(Stratification, ABC):
"""
Abstract class for stratifications that can only handle SNPs. We need to issue a warning in this case.
"""
def _setup(self, parser: 'Parser'):
"""
Set up the stratification.
:param parser: The parser
"""
super()._setup(parser)
# issue warning if we have an SNP stratification
self._logger.warning(f"{self.__class__.__name__} can only handle SNPs and not mono-allelic sites. "
"This means you have to update the number of mono-allelic sites manually.")
[docs]
class BaseContextStratification(Stratification, FASTAHandler):
"""
Stratify the SFS by the base context of the mutation. The number of flanking bases
can be configured. Note that we attempt to take the ancestral allele as the
middle base. If ``skip_non_polarized`` is set to ``False``, we use the reference
allele as the middle base.
"""
[docs]
def __init__(
self,
fasta: str,
n_flanking: int = 1,
aliases: Dict[str, List[str]] = {},
cache: bool = True
):
"""
Create instance. Note that we require a fasta file to be specified
for base context to be able to be inferred
:param fasta: The fasta file path, possibly gzipped or a URL
:param n_flanking: The number of flanking bases
:param aliases: Dictionary of aliases for the contigs in the input, e.g. ``{'chr1': ['1']}``.
This is used to match the contig names in the input with the contig names in the FASTA file and GFF file.
:param cache: Whether to cache files that are downloaded from URLs
"""
Stratification.__init__(self)
FASTAHandler.__init__(self, fasta, cache=cache, aliases=aliases)
#: The number of flanking bases
self.n_flanking: int = n_flanking
#: The current contig
self.contig: Optional[SeqRecord] = None
def _rewind(self):
"""
Rewind the stratification.
"""
Stratification._rewind(self)
FASTAHandler._rewind(self)
self.contig = None
[docs]
@_count_valid_type
def get_type(self, variant: Site) -> str:
"""
Get the base context for a given mutation
:param variant: The site
:return: Base context of the mutation
"""
pos = variant.POS - 1
# get the ancestral allele
aa = self.parser._get_ancestral(variant)
# get aliases
aliases = self.get_aliases(variant.CHROM)
# check if contig is up-to-date
if self.contig is None or self.contig.id not in aliases:
self._logger.debug(f"Fetching contig '{variant.CHROM}'.")
# fetch contig; a contig missing from the FASTA makes the site untypeable rather than being fatal,
# as every other consumer of get_contig already treats it
try:
self.contig = self.get_contig(aliases)
except LookupError as e:
raise NoTypeException(str(e))
# check if position is valid
if pos < 0 or pos >= len(self.contig):
raise NoTypeException("Invalid position: Position must be within the bounds of the sequence.")
# get upstream and downstream bases, upper-casing soft-masked (lowercase) reference bases so
# they match the upper-case contexts in get_types()
upstream_start = max(0, pos - self.n_flanking)
upstream_bases = str(self.contig.seq[upstream_start:pos]).upper()
downstream_end = min(len(self.contig), pos + self.n_flanking + 1)
downstream_bases = str(self.contig.seq[pos + 1:downstream_end]).upper()
context = f"{upstream_bases}{aa}{downstream_bases}"
# near a contig edge the flanking window is clamped, giving a context shorter than the full
# k-mer; such a truncated context is not among get_types() and would collide with others (a
# start-edge and an end-edge context of the same letters share a key), so skip the site
if len(context) != 2 * self.n_flanking + 1:
raise NoTypeException(f"Base context '{context}' is truncated at a contig edge at "
f"{variant.CHROM}:{variant.POS}")
# a flanking base outside ACGT (e.g. an N at an assembly gap) has no valid context; skip the site
# rather than letting it accrue into a spurious stratum
if any(b not in bases for b in context):
raise NoTypeException(f"Base context '{context}' contains a non-ACGT base at "
f"{variant.CHROM}:{variant.POS}")
return context
[docs]
def get_types(self) -> List[str]:
"""
Create all possible base contexts.
:return: List of contexts
"""
return [''.join(c) for c in itertools.product(bases, repeat=2 * self.n_flanking + 1)]
[docs]
class BaseTransitionStratification(SNPStratification):
"""
Stratify the SFS by the base transition of the mutation, i.e., ``A>T``.
.. warning::
This stratification only works for SNPs. You thus need to update the number of mono-allelic sites manually.
"""
[docs]
@_count_valid_type
def get_type(self, variant: Site) -> str:
"""
Get the base transition for the given variant.
:param variant: The site
:return: Base transition
:raises ~sfsutils.io_handlers.NoTypeException: if not type could be determined
"""
if variant.is_snp:
ancestral = self.parser._get_ancestral(variant)
derived = variant.REF if variant.REF != ancestral else variant.ALT[0]
if ancestral in bases and derived in bases and ancestral != derived:
return f"{ancestral}>{derived}"
raise NoTypeException("Not a valid base transition.")
raise NoTypeException("Site is not a SNP.")
[docs]
def get_types(self) -> List[str]:
"""
Get all possible base transitions.
:return: List of contexts
"""
return ['>'.join([a, b]) for a in bases for b in bases if a != b]
[docs]
class TransitionTransversionStratification(BaseTransitionStratification):
"""
Stratify the SFS by whether we have a transition or transversion.
.. warning::
This stratification only works for SNPs. You thus need to update the number of mono-allelic sites manually.
"""
[docs]
@_count_valid_type
def get_type(self, variant: Site) -> str:
"""
Get the mutation type (transition or transversion) for a given mutation.
:param variant: The site
:return: Mutation type
"""
if variant.is_snp:
if variant.ALT[0] not in bases:
raise NoTypeException("Invalid alternate allele: Alternate allele must be a valid base.")
if (variant.REF, variant.ALT[0]) in [('A', 'G'), ('G', 'A'), ('C', 'T'), ('T', 'C')]:
return "transition"
else:
return "transversion"
raise NoTypeException("Site is not a SNP.")
[docs]
def get_types(self) -> List[str]:
"""
All possible mutation types (transition and transversion).
:return: List of mutation types
"""
return ["transition", "transversion"]
[docs]
class AncestralBaseStratification(Stratification):
"""
Stratify the SFS by the base context of the mutation: the reference base.
If ``skip_non_polarized`` is set to ``False``, we use the reference allele as
ancestral base. By default, we use the ``AA`` tag to determine the ancestral allele.
Any subclass of :class:`~sfsutils.annotation.AncestralAlleleAnnotation` can be used to annotate the ancestral allele.
"""
[docs]
@_count_valid_type
def get_type(self, variant: Site) -> str:
"""
Get the type which is the reference allele.
:param variant: The site
:return: reference allele
"""
return self.parser._get_ancestral(variant)
[docs]
def get_types(self) -> List[str]:
"""
The possible base types.
:return: List of contexts
"""
return bases
[docs]
class DegeneracyStratification(Stratification):
"""
Stratify SFS by degeneracy. We only consider sides which 4-fold degenerate (neutral) or
0-fold degenerate (selected) which facilitates counting.
:class:`~sfsutils.annotation.DegeneracyAnnotation` can be used to annotate the degeneracy of a site.
"""
[docs]
def __init__(
self,
custom_callback: Callable[['cyvcf2.Variant'], str] = None,
):
"""
Initialize the stratification.
:param custom_callback: Custom callback to determine the type of mutation
"""
super().__init__()
#: Custom callback to determine the degeneracy of mutation
self.get_degeneracy = custom_callback if custom_callback is not None else self._get_degeneracy_default
@staticmethod
def _get_degeneracy_default(
variant: Site
) -> Optional[Literal['neutral', 'selected']]:
"""
Get degeneracy based on 'Degeneracy' tag.
:param variant: The site
:return: Type of the mutation
"""
degeneracy = variant.INFO.get('Degeneracy')
if degeneracy is None:
raise NoTypeException("No degeneracy tag found.")
else:
if degeneracy == 4:
return 'neutral'
if degeneracy == 0:
return 'selected'
raise NoTypeException(f"Degeneracy tag has invalid value: '{degeneracy}' at {variant.CHROM}:{variant.POS}")
[docs]
@_count_valid_type
def get_type(self, variant: Site) -> Literal['neutral', 'selected']:
"""
Get the degeneracy.
:param variant: The site
:return: Type of the mutation
:raises ~sfsutils.io_handlers.NoTypeException: If the mutation is not synonymous or non-synonymous
"""
return self.get_degeneracy(variant)
[docs]
def get_types(self) -> List[str]:
"""
Get all possible degeneracy type (``neutral`` and ``selected``).
:return: List of contexts
"""
return ['neutral', 'selected']
[docs]
class SynonymyStratification(SNPStratification):
"""
Stratify SFS by synonymy (neutral or selected).
:class:`~sfsutils.annotation.SynonymyAnnotation` can be used to annotate the synonymy of a site.
.. warning::
This stratification only works for SNPs. You thus need to update the number of mono-allelic sites manually.
"""
[docs]
def get_types(self) -> List[str]:
"""
Get all possible synonymy types (``neutral`` and ``selected``).
:return: List of contexts
"""
return ['neutral', 'selected']
[docs]
@_count_valid_type
def get_type(self, variant: Site) -> Literal['neutral', 'selected']:
"""
Get the synonymy using the custom synonymy annotation.
:param variant: The site
:return: Type of the mutation, either ``neutral`` or ``selected``
"""
synonymy = variant.INFO.get('Synonymy')
if synonymy is None:
raise NoTypeException("No synonymy tag found.")
else:
if synonymy == 1:
return 'neutral'
if synonymy == 0:
return 'selected'
raise NoTypeException(f"Synonymy tag has invalid value: '{synonymy}' at {variant.CHROM}:{variant.POS}")
[docs]
class VEPStratification(SynonymyStratification):
"""
Stratify SFS by synonymy (neutral or selected) based on annotation provided by VEP.
.. warning::
This stratification only works for SNPs. You thus need to update the number of mono-allelic sites manually.
"""
#: The tag used by VEP to annotate the synonymy
info_tag: str = 'CSQ'
[docs]
def get_types(self) -> List[str]:
"""
Get all possible synonymy types (``neutral`` and ``selected``).
:return: List of contexts
"""
return ['neutral', 'selected']
[docs]
@_count_valid_type
def get_type(self, variant: Site) -> Literal['neutral', 'selected']:
"""
Get the synonymy of a site.
:param variant: The site
:return: Type of the mutation, either ``neutral`` or ``selected``
"""
synonymy = variant.INFO.get(self.info_tag, '')
if 'synonymous_variant' in synonymy:
return 'neutral'
if 'missense_variant' in synonymy:
return 'selected'
raise NoTypeException(f"Synonymy tag has invalid value: '{synonymy}' at {variant.CHROM}:{variant.POS}")
[docs]
class SnpEffStratification(VEPStratification):
"""
Stratify SFS by synonymy (neutral or selected) based on annotation provided by SnpEff.
.. warning::
This stratification only works for SNPs. You thus need to update the number of mono-allelic sites manually.
"""
#: The tag used by SnpEff to annotate the synonymy
info_tag: str = 'ANN'
[docs]
class GenomePositionDependentStratification(Stratification, ABC):
"""
Base class for stratifications that derive the type from a site's genomic position.
"""
[docs]
class ContigStratification(GenomePositionDependentStratification):
"""
Stratify SFS by contig.
"""
[docs]
def __init__(self, contigs: List[str] = None):
"""
Initialize the stratification.
:param contigs: List of contigs to stratify by. Defaults to all contigs in the input.
"""
super().__init__()
#: List of contigs
self.contigs: List[str] = contigs
[docs]
@_count_valid_type
def get_type(self, variant: Site) -> str:
"""
Get the contig.
:param variant: The site
:return: The contig name
"""
if self.contigs is not None:
# match through the aliases, as ContigFiltration does, so an aliased contig is not dropped
# match through the aliases where a parser is attached, so an aliased contig is not dropped
aliases = self.parser.get_aliases(variant.CHROM) if self.parser is not None else [variant.CHROM]
matched = next((c for c in self.contigs if c in aliases), None)
if matched is None:
raise NoTypeException(f"Contig '{variant.CHROM}' not in list of contigs.")
return self._sanitize(matched)
return self._sanitize(variant.CHROM)
@staticmethod
def _sanitize(contig: str) -> str:
"""
Replace the dots of an accession-style contig name, which Spectra uses to separate stratification
levels and would otherwise split such a name into several levels.
:param contig: The contig name.
:return: The type name.
"""
return contig.replace('.', '_')
[docs]
def get_types(self) -> List[str]:
"""
Get all possible contig type.
:return: List of contexts
"""
return [self._sanitize(c) for c in (self.contigs or list(self.parser._reader.seqnames))]
[docs]
class ChunkedStratification(GenomePositionDependentStratification):
"""
Stratify SFS by creating ``n`` contiguous chunks of roughly equal size.
.. note::
Since the total number of sites is not known in advance, we cannot create contiguous
chunks of exactly equal size.
.. warning::
Chunk boundaries are sized from the raw input record count (``parser.n_sites``), but a site
is assigned to a chunk only once it has survived filtration and down-projection. When any
filtration or projection drops sites, all included sites fall within the first
``n_included / n_sites`` fraction of the record range, so they concentrate in the leading
chunks and the trailing chunks come out under-filled or empty. The included count is not known
at setup without an extra pass over the data, so the chunks cannot be pre-balanced; a warning
is logged at setup when the parser carries filtrations.
The first pass assigns sites by counting them and records the genomic position at which each chunk
begins. Any further pass over the same input (the :class:`TargetSiteCounter` sampling pass, which visits a
different number of sites) is assigned by position instead, so a sampled site lands in the same chunk as the
variants surrounding it.
"""
[docs]
def __init__(self, n_chunks: int):
"""
Initialize the stratification.
:param n_chunks: Number of sites per window
"""
super().__init__()
#: Number of chunks
self.n_chunks: int = int(n_chunks)
#: List of chunk sizes
self.chunk_sizes: Optional[List[int]] = None
#: Cumulative chunk sizes, i.e. the site counter at which each chunk ends
self._chunk_ends: List[int] = []
#: Number of sites seen so far
self.counter: int = 0
#: Index of each contig in the order in which the first pass encountered it
self._contig_order: Dict[str, int] = {}
#: Sort key (contig index, position) of the first site of each chunk, as recorded by the first pass
self._chunk_starts: List[Tuple[int, int]] = []
#: Whether sites are assigned by position rather than by counting them
self._positional: bool = False
def _setup(self, parser: 'Parser'):
"""
Set up the stratification.
:param parser: The parser
"""
super()._setup(parser)
# a parse derives the chunk boundaries from the input it is about to read, so any boundaries left
# over from an earlier parse of a different input or site cap are discarded
self._contig_order = {}
self._chunk_starts = []
self._positional = False
# chunk boundaries are sized from the raw record count, but sites are assigned only after
# surviving filtration and projection, so with active filtrations the included sites bunch into
# the leading chunks and the trailing chunks come out under-filled or empty. The included count
# is not available here without an extra pass, so warn rather than silently produce empty chunks.
if parser.filtrations:
self._logger.warning(
f"ChunkedStratification sizes its {self.n_chunks} chunks from the raw input record count, but "
f"{len(parser.filtrations)} filtration(s) are active and will drop sites before they are chunked. "
f"Included sites will concentrate in the leading chunks and the trailing chunks may be empty. "
f"To obtain balanced chunks, chunk a pre-filtered input or one whose records all pass filtration."
)
# compute base chunk size and remainder
base_chunk_size, remainder = divmod(parser.n_sites, self.n_chunks)
# create list of chunk sizes
self.chunk_sizes = [base_chunk_size + (i < remainder) for i in range(self.n_chunks)]
# the site counter is located among the chunk boundaries once per site, so the boundaries are
# accumulated here instead of being summed anew inside that lookup
self._chunk_ends = list(itertools.accumulate(self.chunk_sizes))
def _rewind(self):
"""
Rewind the stratification, also resetting the per-pass site counter so a second pass
(for example the :class:`TargetSiteCounter` sampling pass) restarts from the first chunk.
"""
super()._rewind()
self.counter = 0
def _teardown(self):
"""
Tear down the stratification, switching subsequent passes to positional assignment.
"""
super()._teardown()
# the boundaries recorded by the pass that just finished span the input, so any further pass can be
# assigned by position, which keeps its sites in the chunk their genomic neighbourhood belongs to
self._positional = len(self._chunk_starts) > 0
[docs]
def get_types(self) -> List[str]:
"""
Get all possible window types.
:return: List of contexts
"""
return [f'chunk{i}' for i in range(self.n_chunks)]
[docs]
@_count_valid_type
def get_type(self, variant: Site) -> str:
"""
Get the type.
:param variant: The site
:return: The type
"""
# sites are ordered by contig of first appearance and then by position, as the input is read
key = (self._contig_order.setdefault(variant.CHROM, len(self._contig_order)), variant.POS)
if self._positional:
# sites preceding the first recorded boundary, and contigs the first pass never saw, fall into
# the first and last chunk respectively
return f'chunk{max(bisect.bisect_right(self._chunk_starts, key) - 1, 0)}'
# the boundaries are held in step with the chunk sizes here as well, so that they are also correct
# when the sizes are assigned directly rather than by _setup
if len(self._chunk_ends) != len(self.chunk_sizes):
self._chunk_ends = list(itertools.accumulate(self.chunk_sizes))
# find the index of the chunk to which the current site belongs; a pass may process more sites than
# the chunk sizes account for (e.g. when a previous pass saw fewer), so fall back to the last chunk
chunk_index = min(bisect.bisect_right(self._chunk_ends, self.counter), self.n_chunks - 1)
# record where each chunk begins; chunks of size zero share the start of the chunk that follows them,
# and the bisect above then resolves such a tie to the last of them, which is the non-empty one
while len(self._chunk_starts) <= chunk_index:
self._chunk_starts.append(key)
# update the counter
self.counter += 1
return f'chunk{chunk_index}'
[docs]
class RandomStratification(Stratification):
"""
Stratify the SFS randomly into a fixed number of bins.
Can be used to analyze expected sampling variance between different stratifications.
"""
[docs]
def __init__(self, n_bins: int, seed: Optional[int] = 0):
"""
Initialize random stratification.
:param n_bins: Number of bins to randomly assign sites to.
:param seed: Random seed, for reproducible bin assignment.
"""
super().__init__()
if n_bins < 1:
raise ValueError("n_bins must be at least 1.")
#: Number of bins
self.num_bins: int = n_bins
#: Random seed for reproducibility
self.seed: Optional[int] = seed
#: Random generator instance
self.rng = random.Random(seed)
def _rewind(self):
"""
Rewind the stratification, re-seeding the random generator so that every pass draws the same bins.
Without this a second parse, or the sampling pass of a :class:`TargetSiteCounter`, would continue the
stream of the previous pass and assign the same sites to different bins.
"""
super()._rewind()
self.rng = random.Random(self.seed)
[docs]
@_count_valid_type
def get_type(self, variant: Site) -> str:
"""
Assign the variant to a random bin.
:param variant: The site
:return: Randomly chosen bin label
"""
return f"bin{self.rng.randint(0, self.num_bins - 1)}"
[docs]
def get_types(self) -> List[str]:
"""
Get all possible bin labels.
:return: List of bin labels
"""
return [f"bin{i}" for i in range(self.num_bins)]
[docs]
class TargetSiteCounter:
"""
Class for counting the number of target sites when parsing an input that does not contain monomorphic sites.
This class is used in conjunction with :class:`~sfsutils.parser.Parser` and samples sites from the given fasta
file that are found in between variants on the same contig that were parsed from the input.
Ideally, we obtain the SFS by parsing inputs that contain both mono- and polymorphic sites. This is because
we need to know about the number of mutational opportunities for synonymous and non-synonymous sites which
contain plenty of information on the strength of selection. It is recommended to use a :class:`~sfsutils.filtration.SNPFiltration` when
using this class to avoid biasing the result by monomorphic sites present in the input.
.. warning::
This class is not compatible with stratifications based on info tags that are pre-defined in the input, as
opposed to those added dynamically using the ``annotations`` argument of the parser. We also need to
stratify mono-allelic sites which, in this case, won't be present in the input so that they have no
info tags when sampling from the FASTA file, and are thus ignored by the stratifications. However, using the
``annotations`` argument of the parser, the info tags the stratifications are based on are added on-the-fly,
also for monomorphic sites sampled from the FASTA file.
.. note::
With the unstratified two-SFS (``two_sfs=True``) this counter extrapolates the monomorphic-involving pairs
from the target-site count. That anchors the marginal only approximately, so the resulting
:meth:`~sfsutils.spectrum.TwoSFS.cov` / ``corr`` are approximate (a real all-sites input is preferred);
:meth:`~sfsutils.spectrum.TwoSFS.fpmi` needs no monomorphic sites. The stratified two-SFS with a counter is
not supported.
"""
[docs]
def __init__(
self,
n_target_sites: int,
n_samples: int = int(1e5),
):
"""
Initialize counter.
:param n_target_sites: The total number of sites (mono- and polymorphic) that would be present in the input
if it contained monomorphic sites. This number should be considerably larger than the number of polymorphic
sites in the input. this value is not extremely important for downstream inference, the ratio of synonymous
to non-synonymous sites being more informative, but the order of magnitude should be correct, in any case.
:param n_samples: The number of sites to sample from the fasta file. Many sampled sites will not be valid as
they are non-coding. To obtain good estimates, a few thousand sites should be sampled per type of site
(depending on the stratifications used).
"""
#: The logger
self._logger = logger.getChild(self.__class__.__name__)
#: The total number of sites considered when parsing the input
self.n_target_sites: int | None = int(n_target_sites)
#: Number of samples
self.n_samples: int = int(n_samples)
#: The spectra before inferring the number of target sites
self._sfs_polymorphic: Spectra | None = None
#: The parser's filtrations while the SNP filtration is suspended
self._filtrations: List[Filtration] | None = None
def _setup(self, parser: 'Parser'):
"""
Set up the counter.
:param parser: The parser
"""
self.parser = parser
# with the two-SFS a TargetSiteCounter extrapolates the monomorphic-involving pairs from the target-site
# count (see _extrapolate_two_sfs), which makes the extrapolated cov()/corr() approximately usable on
# SNP-only input (real all-sites input is preferred). fpmi() ignores the monomorphic bins and works regardless.
# That extrapolation uses n_target_sites and the region length, not the FASTA, so only the single-SFS mode
# (which samples monomorphic sites from the reference) actually requires it.
if not self.parser.two_sfs:
self.parser._require_fasta(self.__class__.__name__)
# check if we have a SNPFiltration
if not any([isinstance(f, SNPFiltration) for f in self.parser.filtrations]):
self._logger.warning("It is recommended to use SNPFiltration together with "
"TargetSiteCounter to avoid biasing the result by monomorphic sites.")
# check if have degeneracy stratification but no degeneracy annotation
if any([isinstance(s, DegeneracyStratification) for s in self.parser.stratifications]) \
and not any([isinstance(a, DegeneracyAnnotation) for a in self.parser.annotations]):
self._logger.warning("When using TargetSiteCounter with DegeneracyStratification, "
"make sure to provide DegeneracyAnnotation to make sure the "
"sites sampled from the FASTA file have a degeneracy tag.")
def _suspend_snp_filtration(self):
"""
Suspend SNP filtration to make sure we sample can actually sample monomorphic sites.
"""
# store original filtrations
self._filtrations = self.parser.filtrations
# remove SNPFiltration
self.parser.filtrations = [f for f in self.parser.filtrations if not isinstance(f, SNPFiltration)]
def _resume_snp_filtration(self):
"""
Resume SNP filtration.
"""
# restore original filtrations
if self._filtrations is not None:
self.parser.filtrations = self._filtrations
self._filtrations = None
def _components(self) -> List[object]:
"""
The parser components the sampling pass rewinds and re-runs, and whose per-pass state it would
otherwise overwrite.
:return: The filtrations, annotations and stratifications.
"""
return [*self.parser.filtrations, *self.parser.annotations, *self.parser.stratifications]
def _snapshot_counters(self) -> List[Tuple[object, Dict[str, Any]]]:
"""
Record the per-pass state of every component, so the sampling pass can report its own counts without
discarding those of the pass that produced the spectra.
:return: One component and its state per component.
"""
# components that define the protocol themselves decide what their per-pass state is; the others
# (filtrations and annotations) are covered generically
return [(c, c._snapshot() if hasattr(c, '_snapshot') else _snapshot_state(c)) for c in self._components()]
def _restore_counters(self, counters: List[Tuple[object, Dict[str, Any]]]):
"""
Put the state of the pass that produced the spectra back on the components.
:param counters: The pairs returned by :meth:`_snapshot_counters`.
"""
for component, state in counters:
if hasattr(component, '_restore'):
component._restore(state)
else:
_restore_state(component, state)
[docs]
def count(self):
"""
Count the number of target sites.
:return: The number of target sites
"""
# the sampling pass rewinds and then re-runs the components, which would leave their per-pass
# counters and diagnostics describing the sampled sites rather than the sites that produced the spectra
counters = self._snapshot_counters()
# rewind parser components
self.parser._rewind()
# suspend SNP filtration
self._suspend_snp_filtration()
# initialize progress bar
pbar = tqdm(
total=self.n_samples,
desc=f'{self.__class__.__name__}>Sampling target sites',
disable=Settings.disable_pbar
)
try:
i = self._sample(pbar)
finally:
# the parser is handed back to the caller whatever happens here, so its filtrations are restored
# even when the sampling pass raises, which would otherwise leave it stripped of its SNP filtration
pbar.close()
self._resume_snp_filtration()
self._restore_counters(counters)
# a sampling pass that yields nothing leaves the target-site count unadjusted, which is a silent
# no-op rather than a cosmetic issue, so it is reported as an error
if i == 0 and self.n_samples > 0:
self._logger.error(f"None of the {self.n_samples} sampled sites were valid, so the number of "
f"target sites cannot be adjusted. Check that the FASTA file matches the input "
f"and that the filtrations and annotations accept monomorphic sites.")
else:
self._logger.info(f"{i} out of {self.n_samples} sampled sites were valid.")
def _sample(self, pbar: tqdm) -> int:
"""
Draw monomorphic sites from the FASTA file across the intervals spanned by the parsed variants and
feed them to the parser, which adds them to the SFS of their type.
:param pbar: The progress bar to advance once per sampled site.
:return: The number of sampled sites that were included in the SFS.
"""
# rewind fasta iterator
FASTAHandler._rewind(self.parser)
# initialize random number generator
rng = np.random.default_rng(self.parser.seed)
# get array of ranges per contig of parsed variants
ranges = np.array(list(self.parser._contig_bounds.values()))
# get range sizes
range_sizes = ranges[:, 1] - ranges[:, 0]
# every parsed contig spans a single position (e.g. one site per contig): there is no interval to
# sample monomorphic sites from, so sample nothing rather than dividing by a zero total into NaNs
total_range = np.sum(range_sizes)
if total_range == 0:
self._logger.info("No interval to sample target sites from (parsed sites span no interval); "
"skipping monomorphic sampling.")
samples = np.zeros(len(range_sizes), dtype=int)
else:
# determine sampling probabilities and sample the number of sites per contig
samples = rng.multinomial(self.n_samples, range_sizes / total_range)
# keep track of SFS before update (a raw dict of joint arrays for joint parsing, else a Spectra); the
# two-SFS counting pass only tallies per-stratum monomorphic sites and needs no pre-sampling snapshot
if self.parser.two_sfs:
self._sfs_polymorphic = None
elif self.parser.pops is not None:
self._sfs_polymorphic = {t: np.array(arr, dtype=float) for t, arr in self.parser.sfs.items()}
else:
self._sfs_polymorphic = Spectra(self.parser.sfs)
# initialize counter
i = 0
# iterate over contigs
for contig, bounds, n in zip(self.parser._contig_bounds.keys(), ranges, samples):
# get aliases
aliases = self.parser.get_aliases(contig)
# make sure we have a valid range
if bounds[1] > bounds[0] and n > 0:
self._logger.debug(f"Sampling {n} sites from contig '{contig}'.")
# the contigs come from the parsed variants, so the FASTA need not hold them all; a contig it
# does not cover contributes no target sites rather than discarding the whole first pass
try:
record = self.parser.get_contig(aliases, notify=False)
except LookupError:
self._logger.warning(f"Skipping contig '{contig}' when sampling target sites: none of its "
f"aliases {aliases} were found in the FASTA file.")
pbar.update(n)
continue
# the bounds come from the parsed variants, which may reach beyond the end of the FASTA record
# if the two files disagree on the contig; sample only from the part backed by the reference
upper = min(int(bounds[1]), len(record.seq))
if upper < bounds[1]:
self._logger.warning(
f"The FASTA record for contig '{contig}' is {len(record.seq)} bp long but the parsed "
f"variants reach position {int(bounds[1])}. Sampling target sites only up to "
f"{len(record.seq)}."
)
# nothing of the contig's variant span is backed by the reference, so it yields no sites
if upper <= bounds[0]:
self._logger.warning(f"Skipping contig '{contig}' when sampling target sites: its FASTA "
f"record is {len(record.seq)} bp long, which does not reach the first "
f"parsed variant at position {int(bounds[0])}.")
continue
# get positions
# we sort in ascending order as the parser expects the positions to be sorted
positions = np.sort(rng.integers(bounds[0], upper, size=n))
# sample sites
for pos in positions:
# create dummy variant (a monomorphic reference site); n_samples so its gt_bases
# aligns with the parser's sample masks, as the Site interface promises
variant = DummyVariant(
ref=record.seq[pos - 1].upper(), # fasta is 0-based; upper-case soft-masked bases
pos=pos, # VCF is 1-based
chrom=contig,
n_samples=len(self.parser._reader.samples),
)
# check if site was included in the SFS
if self.parser._process_site(variant):
i += 1
# update progress bar
pbar.update()
return i
def _update_target_sites(self, spectra: Spectra) -> Spectra:
"""
Update the target sites of the spectra.
:param spectra: The spectra, including the sampled monomorphic sites.
:return: The updated spectra.
"""
# copy spectra
spectra = spectra.copy()
# cast to float to avoid implicit type conversion later on
spectra.data = spectra.data.astype(float)
# the sampling pass can introduce stratification types absent from the pre-sampling snapshot (a
# stratum with no polymorphic site in the input but present among the sampled monomorphic sites).
# Align the snapshot onto the post-sampling types with zeros, so the label-aligned subtractions
# below do not write NaN into those types, mirroring the joint path's ``_before`` default.
before = self._sfs_polymorphic.data.reindex(columns=spectra.data.columns, fill_value=0)
before_n_polymorphic = self._sfs_polymorphic.n_polymorphic.reindex(spectra.data.columns, fill_value=0)
# the sampling pass only ever contributes all-ancestral sites, so bin 0 is the only bin it touches.
# Derive the sampled counts without mutating the spectra, so the bail-outs below can return the input
# untouched rather than a spectrum the subtraction has already mutilated.
sampled_monomorphic = spectra.data.iloc[0, :] - before.iloc[0, :]
# get number of monomorphic and polymorphic sites sampled from the FASTA and VCF file
n_monomorphic = sampled_monomorphic.sum()
n_polymorphic = spectra.data.iloc[1:-1, :].sum().sum()
# every site seen in the input consumes target-site budget, the fixed-derived (divergence) ones included
n_observed = before.iloc[1:, :].sum()
# check if we have enough target sites
if self.n_target_sites < n_polymorphic:
self._logger.warning(f"Number of polymorphic sites ({n_polymorphic}) exceeds the total "
f"number of target sites ({self.n_target_sites}) which does not make sense. "
f"The number of target sites unchanged is left unchanged.")
spectra.data = before.astype(float)
elif n_monomorphic == 0:
self._logger.warning(f"Number of monomorphic sites is zero which should only happen "
f"if there are very few sites considered. Failed to adjust "
f"the number of monomorphic sites.")
spectra.data = before.astype(float)
else:
# compute multiplicative factor to scale the total number of sites
# to the number of target sites plus the number of polymorphic sites
x = (self.n_target_sites + before_n_polymorphic.sum() - n_polymorphic) / n_monomorphic
# extrapolate the monomorphic counts and subtract the observed sites from them, so that the total
# number of sites per type is the type's share of the target sites. We do this to correct for the
# fact that, for a type, we have relatively fewer monomorphic sites if we have more polymorphic ones
spectra.data.iloc[0, :] = sampled_monomorphic * x - n_observed
# a type whose observed sites outnumber its share of the target sites would otherwise be assigned a
# negative mutational opportunity, which is meaningless downstream
negative = spectra.data.columns[spectra.data.iloc[0, :] < 0].tolist()
if negative:
self._logger.warning(f"The number of target sites is too small to accommodate the observed sites "
f"of type(s) {negative}; their monomorphic counts were clipped to zero.")
spectra.data.iloc[0, :] = spectra.data.iloc[0, :].clip(lower=0)
return spectra
def _update_target_sites_joint(self, sfs: Dict[str, np.ndarray]) -> Dict[str, np.ndarray]:
"""
Extrapolate the monomorphic corner of a joint SFS. All monomorphic (all-ancestral) sites map to the single
origin ``(0, ..., 0)`` of the joint spectrum, so the number of target sites fixes the mass placed there.
The sites sampled from the FASTA file are drawn without regard to polymorphism, so their per-type shares
estimate the composition of all sites: each type is scaled to its share of ``n_target_sites`` in total, and
the sites it was observed at in the input are subtracted from its monomorphic mass, exactly as in the
one-dimensional :meth:`_update_target_sites`.
:param sfs: The per-type joint SFS after sampling (its origin holds the sampled monomorphic mass).
:return: The per-type joint SFS with the origin set to the extrapolated monomorphic count.
"""
shape = self.parser._joint_shape
origin = (0,) * len(shape)
corner = tuple(n - 1 for n in shape)
def _before(t: str) -> np.ndarray:
# the type's polymorphic joint SFS from before sampling (zeros if the type only appears from sampling)
return np.array(self._sfs_polymorphic.get(t, np.zeros(shape)), dtype=float)
before = {t: _before(t) for t in sfs}
# every site seen in the input consumes target-site budget, the fixed-derived (divergence) ones included
n_observed = {t: float(arr.sum() - arr[origin]) for t, arr in before.items()}
# the divergence corner is monomorphic, so it is not part of the polymorphic mass
n_polymorphic = sum(n_observed[t] - float(before[t][corner]) for t in before)
# monomorphic mass sampled from the FASTA file per type (added at the origin during sampling)
sampled = {t: float(sfs[t][origin] - before[t][origin]) for t in sfs}
n_monomorphic = sum(sampled.values())
if self.n_target_sites < n_polymorphic:
self._logger.warning(f"Number of polymorphic sites ({n_polymorphic:.0f}) exceeds the total "
f"number of target sites ({self.n_target_sites}) which does not make sense. "
f"The number of target sites unchanged is left unchanged.")
# ``sfs`` has the sampled monomorphic mass at the origin, so return the pre-sampling spectrum
return before
if n_monomorphic == 0:
self._logger.warning(f"Number of monomorphic sites is zero which should only happen "
f"if there are very few sites considered. Failed to adjust "
f"the number of monomorphic sites.")
return before
# scale the sampled monomorphic mass so that each type totals its share of the target sites
x = self.n_target_sites / n_monomorphic
updated = {}
negative = []
for t in sfs:
arr = before[t]
arr[origin] = sampled[t] * x - n_observed[t]
# a type whose observed sites outnumber its share of the target sites would otherwise be assigned a
# negative mutational opportunity, which is meaningless downstream
if arr[origin] < 0:
negative.append(t)
arr[origin] = 0
updated[t] = arr
if negative:
self._logger.warning(f"The number of target sites is too small to accommodate the observed sites "
f"of type(s) {negative}; their monomorphic counts were clipped to zero.")
return updated
@staticmethod
def _window_factor(spans: Iterable[float], distance: int) -> float:
"""
The mean fraction of the nominal ``2 * distance`` window that falls on the site's own contig. Pairs form
within a contig only, so a site on a contig of span ``s`` has on average ``2 * distance * f`` partners
within the window, with ``f = 1 - distance / (2 * s)`` while the window fits (``distance <= s``) and
``f = s / (2 * distance)`` once it saturates at the contig's own site count. The per-contig factors are
averaged over the spans, which is where the sites lie under a uniform density.
:param spans: The per-contig lengths (bp).
:param distance: The two-SFS distance window width (bp).
:return: The factor, in ``(0, 1]``.
"""
spans = [float(s) for s in spans if s > 0]
if not spans or distance <= 0:
return 1.0
return sum(
s * (1 - distance / (2 * s)) if distance <= s else s * (s / (2 * distance)) for s in spans
) / sum(spans)
def _extrapolate_two_sfs(
self,
two_sfs: np.ndarray,
marginal: np.ndarray,
region_length: float,
distance: int,
contig_spans: Dict[str, float] | None = None,
) -> np.ndarray:
"""
Add the pairs involving the sites the input never showed to a two-SFS, extrapolating their number from
the target-site count.
The parse pairs every site it saw with every other site it saw within the window, so the observed matrix
is complete except for the pairs that involve a site missing from the input. Those missing sites number
``n_missing = n_target_sites - marginal.sum()`` and are monomorphic and ancestral (a site absent from a
SNP-only input carries no derived allele), so they belong to bin ``0``. Under a uniform site density the
``+/- distance`` window around a site holds ``2 * rho * distance`` of them, with
``rho = n_missing / region_length``. Pairs only form within a contig, so that count is scaled by
:meth:`_window_factor`, which accounts for the window running off the ends of a contig and saturating
once ``distance`` approaches the contig's span. An observed site of derived count ``j`` therefore
contributes that many pairs to ``(0, j)`` and ``(j, 0)`` together, which the symmetric storage splits
evenly between the two slots, and the missing sites contribute the same number of pairs per site among
themselves to ``(0, 0)``. This holds for every observed bin, the monomorphic ones included: a divergence
site (bin ``n``, all-derived) pairs with the missing sites just as a segregating site does, so the
divergence row and column are populated too. The window offset drops out: a band of width ``distance`` holds the same
expected number of sites regardless of where it starts.
Only the sites that are truly missing are extrapolated, so passing an all-sites input (where the
monomorphic sites are observed and their pairs already counted) does not double-count them. This anchors
the marginal for :meth:`~sfsutils.spectrum.TwoSFS.cov` / ``corr`` only approximately;
:meth:`~sfsutils.spectrum.TwoSFS.fpmi` needs no monomorphic sites and is preferred for SNP-only input.
:param two_sfs: The symmetrized two-SFS of the observed sites.
:param marginal: The one-dimensional marginal of the observed sites.
:param region_length: The length (bp) over which the sites are distributed.
:param distance: The two-SFS distance window width (bp).
:param contig_spans: The per-contig lengths (bp), or ``None`` to leave the window uncorrected for the
contig boundaries.
:return: The two-SFS with the pairs involving the missing sites added.
"""
two_sfs = np.array(two_sfs, dtype=float)
marginal = np.asarray(marginal, dtype=float)
# the sites of the region the input did not show, monomorphic sites it did show included
n_observed = float(marginal.sum())
n_missing = self.n_target_sites - n_observed
# all-ancestral sites in the marginal mean the input is not SNP-only, so there is little left to
# extrapolate and the counter is not what anchors the marginal
if marginal[0] > 0:
self._logger.warning("The input already contains all-ancestral sites, whose pairs the parse counted "
"directly; only the sites missing from the input are extrapolated. Consider "
"parsing an all-sites input without a TargetSiteCounter.")
if n_missing <= 0:
self._logger.warning(f"The number of target sites ({self.n_target_sites}) does not exceed the number of "
f"sites observed in the input ({n_observed:.0f}); the two-SFS is left unchanged.")
return two_sfs
if region_length <= 0:
self._logger.warning("The region length is zero; cannot extrapolate the monomorphic pairs of the two-SFS.")
return two_sfs
rho = n_missing / region_length
# without the per-contig geometry there is nothing to correct the window against
factor = self._window_factor(contig_spans.values(), distance) if contig_spans else 1.0
if factor < 0.9:
self._logger.info(f"The two-SFS distance ({distance} bp) is large relative to the contigs of the input, "
f"so a site has only {factor:.0%} of the partners a single long contig would give it; "
f"the extrapolated monomorphic pairs are scaled accordingly.")
# pairs of an observed site with a missing one, spread over the two symmetric slots. Bin 0 is not
# excluded here: the two slots coincide there, so an observed all-ancestral site correctly receives
# both halves of its 2 * rho * distance * factor partners
contribution = marginal * rho * distance * factor
two_sfs[0, :] += contribution
two_sfs[:, 0] += contribution
# pairs among the missing sites themselves
two_sfs[0, 0] += n_missing * rho * distance * factor
return two_sfs
[docs]
class Parser(MultiHandler):
"""
Parse site-frequency spectra from VCF files, VCF-Zarr stores, or tskit tree sequences.
By default, the parser looks at the ``AA`` tag in the VCF file's info field to retrieve
the correct polarization. Polymorphic sites for which this tag is not well-defined are by default
ignored (see :attr:`skip_non_polarized`).
This class also offers on-the-fly annotation of the sites such as site degeneracy and
ancestral allele state. This is done by providing a list of annotations to the parser which are
applied in the order they are provided.
The parser also allows to filter sites based on site properties which is done by
passing a list of filtrations. By default, we filter out poly-allelic sites as sites are assumed to be
at most bi-allelic.
In addition, the parser allows to stratify the SFS by providing a list of stratifications. This is useful
to obtain the SFS separately for different types of sites.
To correctly determine the number of target sites when parsing an input that does not contain monomorphic sites,
we can use a :class:`~sfsutils.parser.TargetSiteCounter`. This class is used in conjunction with the parser and
samples sites from the given FASTA file that are found in between variants on the same contig that were parsed
from the input.
Note that we assume the sites in the input to be sorted by position in ascending order (per contig).
Example usage:
::
import sfsutils as su
# Parse selected and neutral SFS from human chromosome 1.
p = su.Parser(
source="https://ngs.sanger.ac.uk/production/hgdp/hgdp_wgs.20190516/"
"hgdp_wgs.20190516.full.chr21.vcf.gz",
fasta="http://ftp.ensembl.org/pub/release-109/fasta/homo_sapiens/"
"dna/Homo_sapiens.GRCh38.dna.chromosome.21.fa.gz",
gff="http://ftp.ensembl.org/pub/release-109/gff3/homo_sapiens/"
"Homo_sapiens.GRCh38.109.chromosome.21.gff3.gz",
aliases=dict(chr21=['21']), # mapping for contig names
n=10, # SFS sample size
# we use a target site counter to infer the number of target sites.
target_site_counter=su.TargetSiteCounter(
n_samples=1000000,
# determine number of target sites by looking at total length of coding sequences
n_target_sites=su.Annotation.count_target_sites(
"http://ftp.ensembl.org/pub/release-109/gff3/homo_sapiens/"
"Homo_sapiens.GRCh38.109.chromosome.21.gff3.gz"
)['21']
),
# add degeneracy annotation for sites
annotations=[
su.DegeneracyAnnotation()
],
filtrations=[
# exclude non-SNPs as we infer monomorphic sites with target site counter
su.SNPFiltration(),
# filter out sites not in coding sequences
su.CodingSequenceFiltration()
],
# stratify by 4-fold/0-fold degeneracy
stratifications=[su.DegeneracyStratification()],
info_ancestral='AA_ensembl'
)
sfs = p.parse()
sfs.plot()
"""
[docs]
def __init__(
self,
source: "str | os.PathLike | 'tskit.TreeSequence' | VariantReader | None" = None,
n: int | Dict[str, int] | List[int] = None,
pops: Dict[str, List[str]] | None = None,
gff: str | None = None,
fasta: str | None = None,
info_ancestral: str = 'AA',
info_ancestral_prob: str = 'AA_prob',
skip_non_polarized: bool = True,
stratifications: List[Stratification] = [],
annotations: List[Annotation] = [],
filtrations: List[Filtration] = None,
include_samples: List[str] = None,
exclude_samples: List[str] = None,
max_sites: int = np.inf,
seed: int | None = 0,
cache: bool = True,
aliases: Dict[str, List[str]] = {},
target_site_counter: TargetSiteCounter = None,
subsample_mode: Literal['random', 'probabilistic'] = 'probabilistic',
polarize_probabilistically: bool = False,
two_sfs: bool = False,
d: int = 1000,
two_sfs_offset: int = 0,
vcf: "str | os.PathLike | 'tskit.TreeSequence' | VariantReader | None" = None
):
"""
Initialize the parser.
:param source: The variant source: a path to a VCF file (gzipped or a URL), a path to a VCF-Zarr store
(a ``.vcz`` or ``.zarr`` directory), a tskit tree sequence (a ``.trees`` file or an in-memory
:class:`tskit.TreeSequence`), or a pre-built :class:`~sfsutils.io_handlers.VariantReader`. A non-path,
non-tree-sequence source must be a ``VariantReader`` (it exposes the sample names, contig names and
site count the parser needs, and supports a fresh iteration pass); a bare iterable of sites or a
raw :class:`cyvcf2.VCF <cyvcf2.cyvcf2.VCF>` object is not accepted and raises a ``TypeError``. VCF-Zarr requires the optional
:mod:`zarr` package and tree sequences the optional :mod:`tskit` package.
:param gff: The path to the GFF file, possibly gzipped or a URL. This file is optional and depends on
the stratifications, annotations and filtrations that are used.
:param fasta: The path to the FASTA file, possibly gzipped or a URL. This file is optional and depends on
the annotations and filtrations that are used.
:param n: The size of the resulting SFS. We down-sample to this number by drawing without replacement from
the set of all available genotypes per site. Sites with fewer than ``n`` genotypes are skipped. For a
joint (multi-population) SFS (see ``pops``), this is the per-population sample size, given either as a
single ``int`` applied to every population, a list aligned with ``pops`` (in insertion order), or a
dictionary keyed by population name.
:param pops: Mapping of population name to the list of sample names making up that population. When given,
:meth:`parse` returns a :class:`~sfsutils.spectrum.JointSpectra` holding the joint SFS across these
populations (one :class:`~sfsutils.spectrum.JointSFS` per stratification type) instead of the
one-dimensional :class:`~sfsutils.spectrum.Spectra`. The joint SFS is obtained by down-projecting each
population independently and accumulating the outer product of the per-population projections, which is
the exact hypergeometric down-projection under independent sampling within populations. When ``None``
(default), a single-population, one-dimensional SFS is parsed as before. Note that ``pops`` supersedes
``include_samples`` / ``exclude_samples``. A ``target_site_counter`` is supported: monomorphic sites map
to the all-ancestral origin of the joint SFS, which is scaled to the target-site count.
:param info_ancestral: The tag in the INFO field that contains ancestral allele information. Consider using
an ancestral allele annotation if this information is not available yet.
:param info_ancestral_prob: The tag in the INFO field that holds the per-site probability that
``info_ancestral`` names the true ancestral allele, used when ``polarize_probabilistically`` is set.
:param skip_non_polarized: Whether to skip poly-morphic sites that are not polarized, i.e., without a valid
info tag providing the ancestral allele. If ``False``, we use the reference allele as ancestral allele
(only recommended if working with folded spectra).
:param stratifications: List of stratifications to use.
:param annotations: List of annotations to use.
:param filtrations: List of filtrations to use. By default, we use
:class:`~sfsutils.filtration.PolyAllelicFiltration`.
:param include_samples: List of sample names to consider when determining the SFS. If ``None``, all samples
are used. Note that this restriction does not apply to the annotations and filtrations.
:param exclude_samples: List of sample names to exclude when determining the SFS. If ``None``, no samples
are excluded. Note that this restriction does not apply to the annotations and filtrations.
:param max_sites: Maximum number of sites to parse from the input, which must be positive.
:param seed: Seed for the random number generator. Use ``None`` for no seed.
:param cache: Whether to cache files downloaded from URLs.
:param aliases: Dictionary of aliases for the contigs in the input, e.g. ``{'chr1': ['1']}``.
This is used to match the contig names in the input with the contig names in the FASTA file and GFF file.
:param target_site_counter: The target site counter, used to recover the number of monomorphic sites when the
input contains only polymorphic sites. If ``None``, no target sites are added. It applies to the
one-dimensional SFS (sampling monomorphic sites from the FASTA), the joint SFS (scaling the all-ancestral
corner to the target-site count), and the unstratified two-SFS (extrapolating the monomorphic-involving
pairs from the target-site count, which makes the resulting cov/corr only approximate).
:param subsample_mode: The subsampling mode. For ``random``, we draw once without replacement from the set of
all available genotypes per site. For ``probabilistic``, we add up the hypergeometric distribution for all
sites. This will produce a smoother SFS, especially when a small number of sites is considered.
:param polarize_probabilistically: Whether to probabilistically polarize sites. In addition to the ``AA`` tag
(see ``info_ancestral``), we use the ``AA_prob`` tag (see ``info_ancestral_prob``) to polarize sites
probabilistically. For example, if the ancestral allele is ``A`` with a probability of 0.8 and
the derived allele is ``G``, we assign 0.8 probability mass to the ancestral allele and 0.2 to the
derived allele. This should enhance accuracy, especially for small datasets. Whenever the ancestral
probability tag is not present, we assume a probability of 1 for the ancestral allele.
:param two_sfs: Whether to parse the two-dimensional (two-site) SFS instead of the ordinary SFS. When
``True``, :meth:`parse` returns a square :class:`~sfsutils.spectrum.TwoSFS` whose entry ``(i, j)`` counts
pairs of sites, on the same contig and within the distance window of one another, where one site has ``i``
and the other ``j`` derived alleles (down-projected to ``n``). As for the one-dimensional SFS, monomorphic
sites are retained (contributing to the zero-frequency row and column); add a
:class:`~sfsutils.filtration.SNPFiltration` to restrict the spectrum to segregating sites. Each site's
contribution is the outer product of the two per-site down-projection vectors, so it is exact when
``subsample_mode='random'`` at the full sample size and smoother under ``'probabilistic'``. The matrix is
symmetrized. Not compatible with ``pops``. Stratifications are supported (counting only within-stratum
pairs, into a :class:`~sfsutils.spectrum.TwoSpectra`). The unstratified two-SFS accepts a
``target_site_counter``, which extrapolates the monomorphic-involving pairs from the target-site count;
the resulting :meth:`~sfsutils.spectrum.TwoSFS.cov` / :meth:`~sfsutils.spectrum.TwoSFS.corr` are then only
approximate, so a real all-sites input (monomorphic sites retained) is preferred for an accurate
covariance, while :meth:`~sfsutils.spectrum.TwoSFS.fpmi` needs no monomorphic sites.
:param d: The width (in base pairs) of the distance window over which the two sites of a pair
are separated when ``two_sfs=True``. Together with ``two_sfs_offset`` it defines the window
``(two_sfs_offset, two_sfs_offset + d]``; with the default ``two_sfs_offset=0`` this is
simply the maximum separation. Restricting the window restricts the spectrum to (approximately) linked
pairs.
:param two_sfs_offset: The minimum genomic distance (in base pairs, exclusive) between the two sites of a
pair when ``two_sfs=True``; pairs are formed for separations in ``(two_sfs_offset, two_sfs_offset +
d]``. Defaults to ``0`` (pairs at any separation up to ``d``).
:param vcf: Deprecated alias for ``source``, kept for backward compatibility. Provide either
``source`` or ``vcf``, not both.
"""
# a non-positive cap would parse the whole input rather than nothing, as the site count it is
# compared against is itself capped at that value
if max_sites <= 0:
raise ValueError(f"'max_sites' must be positive, got {max_sites}.")
MultiHandler.__init__(
self,
source=source,
vcf=vcf,
gff=gff,
fasta=fasta,
info_ancestral=info_ancestral,
max_sites=max_sites,
seed=seed,
cache=cache,
aliases=aliases
)
# warn if SynonymyAnnotation is used
if any(isinstance(a, SynonymyAnnotation) for a in annotations):
logger.warning("SynonymyAnnotation is not recommended to be used with the parser as "
"it is not possible to determine the synonymy of monomorphic sites. "
"Consider using DegeneracyAnnotation instead.")
#: The target site counter
self.target_site_counter: TargetSiteCounter | None = target_site_counter
#: Mapping of population name to sample names for the joint SFS, or ``None`` for a single-population SFS
self.pops: Dict[str, List[str]] | None = pops
# bin 1 is the divergence bin for n == 1, so every segregating site would be booked as a fixed
# difference, and n == 0 collapses the ancestral and the divergence bin onto the same index
if n is None:
raise ValueError("'n' must be given: the SFS sample size (number of haplotypes to project down to), "
"at least 2.")
if self.pops is None:
#: The per-population sample size (single population)
self.n: int = int(n)
if self.n < 2:
raise ValueError(f"'n' must be at least 2, got {self.n}.")
#: Ordered population names (single ``all`` population)
self._pop_names: List[str] = []
#: Per-population sample sizes
self._n_per_pop: List[int] = [self.n]
else:
if len(self.pops) < 1:
raise ValueError("At least one population must be provided in 'pops'.")
self._pop_names = list(self.pops.keys())
# normalize n to a per-population list aligned with the population order
if isinstance(n, dict):
missing = set(self._pop_names) - set(n.keys())
if missing:
raise ValueError(f"Missing sample size in 'n' for populations: {sorted(missing)}.")
self._n_per_pop = [int(n[name]) for name in self._pop_names]
elif isinstance(n, (list, tuple, np.ndarray)):
if len(n) != len(self._pop_names):
raise ValueError(f"Length of 'n' ({len(n)}) must match the number of populations "
f"({len(self._pop_names)}).")
self._n_per_pop = [int(x) for x in n]
else:
self._n_per_pop = [int(n)] * len(self._pop_names)
invalid = {name: size for name, size in zip(self._pop_names, self._n_per_pop) if size < 2}
if invalid:
raise ValueError(f"'n' must be at least 2 for every population, got {invalid}.")
# the single-population sample size is undefined in joint mode
self.n: int | None = None
if include_samples is not None or exclude_samples is not None:
logger.warning("'include_samples' and 'exclude_samples' are ignored when 'pops' is given.")
#: The shape of the joint SFS array (one axis per population)
self._joint_shape: Tuple[int, ...] = tuple(x + 1 for x in self._n_per_pop)
#: Per-population sample masks (joint mode), set up in :meth:`_prepare_samples_mask`
self._pop_masks: List[np.ndarray] | None = None
#: The list of samples to include
self.include_samples: List[str] | None = include_samples
#: The list of samples to exclude
self.exclude_samples: List[str] | None = exclude_samples
#: The mask of samples to use
self._samples_mask: np.ndarray | None = None
#: Whether to skip sites that are not polarized, i.e., without a valid info tag providing the ancestral allele
self.skip_non_polarized: bool = skip_non_polarized
#: List of stratifications to use
self.stratifications: List[Stratification] = stratifications
#: List of annotations to use
self.annotations: List[Annotation] = annotations
#: List of filtrations to use
self.filtrations: List[Filtration] = [PolyAllelicFiltration()] if filtrations is None else filtrations
#: The number of sites that were skipped for various reasons
self.n_skipped: int = 0
# The number of sites with a valid ancestral allele probability
self.n_aa_prob: int = 0
#: The number of sites that were skipped because they had no valid ancestral allele
self.n_no_ancestral: int = 0
#: Dictionary of SFS indexed by (stratification) type. Each value is a 1-D array of length ``n + 1`` for a
#: single-population SFS, or a joint SFS array of shape :attr:`_joint_shape` when ``pops`` is given.
self.sfs: Dict[str, np.ndarray] = defaultdict(lambda: np.zeros(self._joint_shape))
#: 1-based positions of lowest and highest site position per contig (only when target_site_counter is used)
# noinspection PyTypeChecker
self._contig_bounds: Dict[str, Tuple[int, int]] = defaultdict(lambda: (np.inf, -np.inf))
if subsample_mode not in ['random', 'probabilistic']:
raise ValueError(f"Subsampling mode '{subsample_mode}' is not valid. "
f"Valid modes are 'random' and 'probabilistic'.")
#: The subsampling mode
self.subsample_mode: Literal['random', 'probabilistic'] = subsample_mode
#: The tag in the INFO field that contains the ancestral allele probability
self.info_ancestral_prob: str = info_ancestral_prob
#: Whether to probabilistically polarize sites
self.polarize_probabilistically: bool = polarize_probabilistically
#: Whether to parse the two-dimensional (two-site) SFS
self.two_sfs: bool = two_sfs
#: Maximum genomic distance (bp) between the two sites of a pair for the two-SFS
self.d: int = int(d)
#: Minimum genomic distance (bp, exclusive) between the two sites of a pair for the two-SFS
self.two_sfs_offset: int = int(two_sfs_offset)
if self.two_sfs:
if self.pops is not None:
raise NotImplementedError("The two-SFS (two_sfs=True) is not supported together with 'pops'.")
if self.d < 1:
raise ValueError("d must be at least 1.")
#: The accumulating two-SFS matrices of shape ``(n + 1, n + 1)`` keyed by stratification type. Stratified
#: parsing counts only within-stratum pairs, so each type accumulates independently (only when ``two_sfs``).
self._two_sfs_matrices: Dict[str, np.ndarray] = defaultdict(lambda: np.zeros((self.n + 1, self.n + 1)))
#: The accumulating one-dimensional marginal (summed down-projection) per type, i.e. the site-frequency
#: spectrum including the monomorphic bins; used to warn when the monomorphic sites appear to be missing.
self._two_sfs_marginal: Dict[str, np.ndarray] = defaultdict(lambda: np.zeros(self.n + 1))
#: Buffered ``(position, down-projection vector, type)`` of sites that are still closer than
#: :attr:`two_sfs_offset` to the current site and thus not yet eligible to form a pair
self._two_sfs_pending: deque = deque()
#: Buffered ``(position, down-projection vector, type)`` of the sites inside the eligible band
#: ``(two_sfs_offset, two_sfs_offset + d]``, i.e. those making up :attr:`_two_sfs_window_sums`
self._two_sfs_active: deque = deque()
#: Sum of the down-projection vectors of the sites in the eligible band, per type. Pairing the current
#: site with this sum in a single outer product is equivalent to pairing it with each site separately
self._two_sfs_window_sums: Dict[str, np.ndarray] = {}
#: Number of sites in the eligible band per type, used to drop a type's running sum once its band empties
self._two_sfs_window_counts: Dict[str, int] = {}
#: Number of sites processed since the running sums were last rebuilt from the window
self._two_sfs_since_resync: int = 0
#: The contig currently held in the two-SFS window
self._two_sfs_contig: str | None = None
#: Cache of hypergeometric down-projection vectors keyed by ``(M, n, N)``. The projection depends on
#: nothing else, and with a fixed sample set only a handful of keys occur, so it is filled lazily
self._projection_cache: Dict[Tuple[int, int, int], np.ndarray] = {}
def _reset(self):
"""
Discard the state of the previous pass: the accumulating spectra and counters, the two-SFS window, the
cached reader, and the seed of the RNG.
"""
VCFHandler._rewind(self)
self.n_skipped = 0
self.n_aa_prob = 0
self.n_no_ancestral = 0
self.sfs = defaultdict(lambda: np.zeros(self._joint_shape))
self._contig_bounds = defaultdict(lambda: (np.inf, -np.inf))
self._two_sfs_matrices = defaultdict(lambda: np.zeros((self.n + 1, self.n + 1)))
self._two_sfs_marginal = defaultdict(lambda: np.zeros(self.n + 1))
self._two_sfs_pending = deque()
self._two_sfs_active = deque()
self._two_sfs_window_sums = {}
self._two_sfs_window_counts = {}
self._two_sfs_since_resync = 0
self._two_sfs_contig = None
# the cached projections are only valid for the sample sizes of the pass that filled them
self._projection_cache = {}
self.rng = np.random.default_rng(seed=self.seed)
def _get_ancestral(self, variant: Site) -> str:
"""
Determine the ancestral allele.
:param variant: The site
:return: Ancestral allele
:raises ~sfsutils.io_handlers.NoTypeException: If the site is not polarized and ``skip_non_polarized`` is ``True`` or if
the ancestral allele or reference allele (in case of monomorphic sites) is not a valid base.
"""
if variant.is_snp:
# obtain ancestral allele
aa = variant.INFO.get(self.info_ancestral)
# return the ancestral allele if it is a valid base
if aa in bases:
return aa
# if we skip non-polarized sites, we raise an error here
if self.skip_non_polarized:
raise NoTypeException("No valid AA tag found")
# if we don't skip non-polarized sites, or if the site is not an SNP
# we return the reference allele if valid
if variant.REF in bases:
return variant.REF
# if the reference allele is not a valid base, we raise an error
raise NoTypeException("Reference allele is not a valid base")
def _get_ancestral_prob(self, variant: Site) -> float:
"""
Determine the ancestral allele probabilistically.
:param variant: The site
:return: The probability of the ancestral allele being the true ancestral allele
"""
if self.polarize_probabilistically:
raw = variant.INFO.get(self.info_ancestral_prob)
# INFO comes through typed from cyvcf2 but as a plain string from the VCF-Zarr backend, so
# cast explicitly; a missing value (None) or an empty / '.' sentinel means the site is not
# probabilistically polarized and is treated as certain (probability 1)
if raw is not None and raw not in ('', '.'):
prob = float(raw)
# the projection mixes the spectrum with its reflection using this as a weight, so anything
# outside [0, 1] would put negative counts into the SFS
if not 0.0 <= prob <= 1.0 or not np.isfinite(prob):
raise ValueError(f"The ancestral allele probability at {variant.CHROM}:{variant.POS} is "
f"{raw}, which is not a probability in [0, 1].")
self.n_aa_prob += 1
return prob
return 1.0
def _is_fixed_derived(self, variant: Site) -> bool:
"""
Whether a site without an alternate allele is fixed for the derived allele, i.e. whether its ancestral
allele is a base other than the reference allele. Such a site is a fixed difference and its mass belongs
in the divergence bin, not in the monomorphic-ancestral one. An absent or invalid ancestral allele leaves
the reference allele as the ancestral one, so that monomorphic sites, which carry no polarization
information of their own, keep counting as mutational opportunities.
:param variant: The site.
:return: Whether the site is fixed for the derived allele.
"""
aa = variant.INFO.get(self.info_ancestral)
return aa in bases and aa != variant.REF
def _parse_site(self, variant: Site) -> bool:
"""
Parse a single site, adding its down-projected mass to the (possibly joint) SFS of its type.
:param variant: The variant.
:return: Whether the site was included in the SFS.
"""
# the two-SFS pairs each site with nearby sites rather than accumulating a per-type spectrum
if self.two_sfs:
return self._parse_site_two_sfs(variant)
# compute the down-projected mass for this site (1-D or joint depending on whether populations are given)
m = self._project_joint(variant) if self.pops is not None else self._project(variant)
if m is None:
return False
# try to obtain type
try:
# create joint type
t = '.'.join([s.get_type(variant) for s in self.stratifications]) or 'all'
# add mass
self.sfs[t] += m
except NoTypeException as e:
self._logger.debug(e)
return False
return True
def _projection(self, n_samples: int, n_der: int, n: int) -> np.ndarray:
"""
The hypergeometric down-projection of a site onto ``n`` drawn genotypes: the probability of observing
``k = 0, ..., n`` derived alleles when drawing without replacement. The vector depends on nothing but its
three arguments, so it is looked up in :attr:`_projection_cache` and computed only on the first sighting.
:param n_samples: The number of called genotypes at the site.
:param n_der: The number of derived alleles among them.
:param n: The number of genotypes drawn.
:return: A read-only array of length ``n + 1``. It is handed out again on every matching site, so callers
must build a new array instead of writing to it.
"""
key = (n_samples, n_der, n)
m = self._projection_cache.get(key)
if m is None:
m = hypergeom.pmf(k=range(n + 1), M=n_samples, n=n_der, N=n)
m.flags.writeable = False
self._projection_cache[key] = m
return m
def _select_samples(self, values: np.ndarray) -> np.ndarray:
"""
Restrict per-sample values to the samples the SFS is determined from.
:param values: The per-sample values.
:return: The values of the selected samples, which are all of them when no restriction was requested.
"""
return values if self._samples_mask is None else values[self._samples_mask]
def _project(self, variant: Site) -> Optional[np.ndarray]:
"""
Down-project a single site to the one-dimensional SFS, returning the mass over derived-allele counts.
:param variant: The variant.
:return: The mass array of length ``n + 1``, or ``None`` if the site is skipped.
"""
# check `is_snp` property for performance reasons but site may still be monomorphic
if variant.is_snp:
# count the called alleles, from the numeric calls where the backend provides them and from the
# genotype strings otherwise. Multi-character alleles are left to the strings, which count a
# genotype character at a time and so read an ``AT`` call as two
site = SiteAlleles.from_site(variant)
if site is not None and site.single_character:
counter = site.counts(self._samples_mask)
else:
counter = Counter(get_called_bases(self._select_samples(variant.gt_bases)))
# number of samples
n_samples = sum(counter.values())
# skip if not enough samples
if n_samples < self.n:
self._logger.debug(f'Skipping site due to too few samples at {variant.CHROM}:{variant.POS}.')
return None
try:
# determine ancestral allele
aa = self._get_ancestral(variant)
except NoTypeException:
self.n_no_ancestral += 1
return None
# determine ancestral allele probability
aa_prob = self._get_ancestral_prob(variant)
# determine ancestral allele count
n_aa = counter.get(aa, 0)
# count the ancestral allele among the site's alleles: if the AA tag names a base that is
# absent from the observed genotypes the site is effectively multi-allelic (ancestral plus two
# derived) and must be skipped, not silently polarised into the all-derived bin
n_alleles = len(counter) + (0 if aa in counter else 1)
if n_alleles > 2:
self._logger.debug(f'Site has more than two alleles at {variant.CHROM}:{variant.POS} ({dict(counter)}, AA={aa})')
return None
# determine down-projected allele count.
if self.subsample_mode == 'random':
m = np.zeros(self.n + 1)
k = hypergeom.rvs(M=n_samples, n=n_samples - n_aa, N=self.n, random_state=self.rng)
# every site reaching here has at most two alleles, so the reflection is well defined. A site
# fixed for the derived allele shows a single observed base but is bi-allelic all the same,
# and its mass is reflected just as at a segregating site
if aa_prob != 1.0:
m[k] += aa_prob
m[self.n - k] += 1 - aa_prob
else:
m[k] += 1.0
else:
# subsample probabilistically drawing from the hypergeometric distribution (without replacement)
m = self._projection(n_samples, n_samples - n_aa, self.n)
# polarize probabilistically, which is well defined for every site reaching here
if aa_prob != 1.0:
m = aa_prob * m + (1 - aa_prob) * m[::-1]
# if we have a mono-allelic SNPs
elif is_monomorphic_snp(variant):
# apply the same coverage requirement as segregating sites, so a low-coverage monomorphic
# site is not asymmetrically retained and does not inflate the monomorphic:polymorphic ratio.
# A site sampled from the FASTA by the TargetSiteCounter stands for a fully covered site by
# construction, and its synthetic genotypes are diploid whatever the input's ploidy, so the
# gate is not applied to it: it would otherwise reject every sampled site once ``n`` exceeds
# twice the number of samples, silently turning the extrapolation into a no-op
if not isinstance(variant, DummyVariant):
site = SiteAlleles.from_site(variant)
if site is not None and site.single_character:
n_called = site.n_called(self._samples_mask)
else:
n_called = len(get_called_bases(self._select_samples(variant.gt_bases)))
if n_called < self.n:
self._logger.debug(
f'Skipping monomorphic site due to too few samples at {variant.CHROM}:{variant.POS}.')
return None
# a site fixed for the derived allele carries all n derived alleles and belongs in the
# divergence bin, while an ancestral monomorphic site carries none
n_der = self.n if self._is_fixed_derived(variant) else 0
aa_prob = self._get_ancestral_prob(variant)
m = np.zeros(self.n + 1)
# mispolarization turns one monomorphic bin into the other, so the mass is split between them
if aa_prob != 1.0:
m[n_der] = aa_prob
m[self.n - n_der] = 1 - aa_prob
else:
m[n_der] = 1
else:
# skip other types of sites
self._logger.debug(f'Site is not a valid single nucleotide site at {variant.CHROM}:{variant.POS}.')
return None
return m
def _project_joint(self, variant: Site) -> Optional[np.ndarray]:
"""
Down-project a single site to the joint (multi-population) SFS.
Each population is down-projected independently to a distribution over its derived-allele count, and the
site's contribution is the outer product of these per-population distributions. This is the exact
hypergeometric down-projection under sampling without replacement independently within each population.
:param variant: The variant.
:return: The joint mass array of shape :attr:`_joint_shape`, or ``None`` if the site is skipped.
"""
if variant.is_snp:
# count the called alleles per population, from the numeric calls where the backend provides them
site = SiteAlleles.from_site(variant)
if site is not None and site.single_character:
pop_counters = [site.counts(mask) for mask in self._pop_masks]
else:
pop_counters = [Counter(get_called_bases(variant.gt_bases[mask])) for mask in self._pop_masks]
pop_sizes = [sum(c.values()) for c in pop_counters]
# skip if any population has too few called genotypes
if any(size < n for size, n in zip(pop_sizes, self._n_per_pop)):
self._logger.debug(f'Skipping site due to too few samples at {variant.CHROM}:{variant.POS}.')
return None
try:
# determine ancestral allele (site-level, shared across populations)
aa = self._get_ancestral(variant)
except NoTypeException:
self.n_no_ancestral += 1
return None
# determine ancestral allele probability
aa_prob = self._get_ancestral_prob(variant)
# biallelic check across all populations combined; count the ancestral allele too, so a site
# whose AA is a third base absent from the genotypes is skipped rather than polarised into the
# all-derived joint corner
counter: Dict[str, int] = {}
for c in pop_counters:
for allele, count in c.items():
counter[allele] = counter.get(allele, 0) + count
n_alleles = len(counter) + (0 if aa in counter else 1)
if n_alleles > 2:
self._logger.debug(f'Site has more than two alleles at {variant.CHROM}:{variant.POS} ({dict(counter)}, AA={aa})')
return None
# down-project each population independently to a distribution over its derived-allele count
projections = []
for c, n_samples, n in zip(pop_counters, pop_sizes, self._n_per_pop):
n_der = n_samples - c.get(aa, 0)
if self.subsample_mode == 'random':
vec = np.zeros(n + 1)
vec[hypergeom.rvs(M=n_samples, n=n_der, N=n, random_state=self.rng)] = 1.0
else:
vec = self._projection(n_samples, n_der, n)
projections.append(vec)
# the joint mass is the outer product of the per-population projections
m = functools.reduce(np.multiply.outer, projections)
# probabilistically polarize: a mispolarized site flips every population's derived count
# simultaneously, i.e. reflects the joint array on all axes. This covers sites fixed for the
# derived allele, which show a single observed base but are bi-allelic all the same
if aa_prob != 1.0:
reflected = m[tuple(slice(None, None, -1) for _ in projections)]
m = aa_prob * m + (1 - aa_prob) * reflected
# if we have a mono-allelic SNP
elif is_monomorphic_snp(variant):
# apply the same per-population coverage requirement as segregating sites, exempting the sites the
# TargetSiteCounter samples from the FASTA, whose synthetic genotypes are diploid whatever the
# input's ploidy and would otherwise fail the gate for every sampled site (see :meth:`_project`)
if not isinstance(variant, DummyVariant):
site = SiteAlleles.from_site(variant)
if site is not None and site.single_character:
pop_sizes = [site.n_called(mask) for mask in self._pop_masks]
else:
pop_sizes = [len(get_called_bases(variant.gt_bases[mask])) for mask in self._pop_masks]
if any(size < n for size, n in zip(pop_sizes, self._n_per_pop)):
self._logger.debug(
f'Skipping monomorphic site due to too few samples at {variant.CHROM}:{variant.POS}.')
return None
# a site fixed for the derived allele has every population fixed for it, so its mass sits in the
# all-derived corner, while an ancestral monomorphic site sits at the origin
origin = (0,) * len(self._joint_shape)
corner = tuple(n - 1 for n in self._joint_shape)
fixed_derived = self._is_fixed_derived(variant)
aa_prob = self._get_ancestral_prob(variant)
m = np.zeros(self._joint_shape)
# mispolarization turns one monomorphic corner into the other, so the mass is split between them
if aa_prob != 1.0:
m[corner if fixed_derived else origin] = aa_prob
m[origin if fixed_derived else corner] = 1 - aa_prob
else:
m[corner if fixed_derived else origin] = 1.0
else:
# skip other types of sites
self._logger.debug(f'Site is not a valid single nucleotide site at {variant.CHROM}:{variant.POS}.')
return None
return m
def _parse_site_two_sfs(self, variant: Site) -> bool:
"""
Add a single site to the two-SFS by pairing it with the recently seen sites within the distance window.
The site's down-projection vector is paired (via an outer product) with that of every buffered site on the
same contig whose separation falls in ``(two_sfs_offset, two_sfs_offset + d]``. Since the outer product is
bilinear, all those pairs are accumulated in one outer product with the running sum of the vectors of the
sites currently in that band (:attr:`_two_sfs_window_sums`), which makes the cost per site independent of
how many sites the window holds. Sites arrive position-sorted, so the band is maintained with two sliding
buffers: a site waits in :attr:`_two_sfs_pending` until its separation exceeds ``two_sfs_offset``, then
moves to :attr:`_two_sfs_active` and joins the running sum, and leaves the sum once its separation exceeds
``two_sfs_offset + d``. When stratifications are used, only pairs of sites of the same type are counted,
into that type's matrix, so the running sums are kept per type.
:param variant: The variant.
:return: Whether the site was included (i.e. had a valid down-projection and, if stratified, a type).
"""
m = self._project(variant)
if m is None:
return False
# determine the site's stratification type; a site without a type is skipped (as in the ordinary SFS)
try:
t = '.'.join([s.get_type(variant) for s in self.stratifications]) or 'all'
except NoTypeException as e:
self._logger.debug(e)
return False
# register the stratum so it appears in the output even if it never forms a within-window pair
_ = self._two_sfs_matrices[t]
# accumulate the one-dimensional marginal (the site-frequency spectrum, incl. the monomorphic bins)
self._two_sfs_marginal[t] += m
# reset the window when we move to a new contig (pairs never cross contigs)
if variant.CHROM != self._two_sfs_contig:
self._two_sfs_pending.clear()
self._two_sfs_active.clear()
self._two_sfs_window_sums.clear()
self._two_sfs_window_counts.clear()
self._two_sfs_contig = variant.CHROM
pos = variant.POS
# the furthest separation at which a pair is still formed
max_distance = self.two_sfs_offset + self.d
# admit the sites whose separation has grown past the offset into the eligible band
while self._two_sfs_pending and pos - self._two_sfs_pending[0][0] > self.two_sfs_offset:
entry = self._two_sfs_pending.popleft()
self._two_sfs_active.append(entry)
t_prev = entry[2]
if t_prev in self._two_sfs_window_sums:
self._two_sfs_window_sums[t_prev] += entry[1]
self._two_sfs_window_counts[t_prev] += 1
else:
# a fresh writable array, as the projection vectors are shared read-only across sites
self._two_sfs_window_sums[t_prev] = np.array(entry[1], dtype=float)
self._two_sfs_window_counts[t_prev] = 1
# retire the sites that are now too far behind to pair with this or any later site
while self._two_sfs_active and pos - self._two_sfs_active[0][0] > max_distance:
entry = self._two_sfs_active.popleft()
t_prev = entry[2]
self._two_sfs_window_counts[t_prev] -= 1
# an emptied band is dropped rather than left as a sum of cancelling terms
if self._two_sfs_window_counts[t_prev] == 0:
del self._two_sfs_window_counts[t_prev]
del self._two_sfs_window_sums[t_prev]
else:
self._two_sfs_window_sums[t_prev] -= entry[1]
self._two_sfs_since_resync += 1
if self._two_sfs_since_resync >= _TWO_SFS_RESYNC_INTERVAL:
self._resync_two_sfs_window()
window_sum = self._two_sfs_window_sums.get(t)
# accumulate the (forward) within-stratum pairs; the matrix is symmetrized once at the end
if window_sum is not None:
self._two_sfs_matrices[t] += np.multiply.outer(window_sum, m)
self._two_sfs_pending.append((pos, m, t))
return True
def _resync_two_sfs_window(self):
"""
Rebuild the two-SFS running window sums from the sites currently in the eligible band, discarding the
rounding error accumulated by the incremental additions and subtractions (see
:data:`_TWO_SFS_RESYNC_INTERVAL`).
"""
sums: Dict[str, np.ndarray] = {}
counts: Dict[str, int] = {}
for _, m_prev, t_prev in self._two_sfs_active:
if t_prev in sums:
sums[t_prev] += m_prev
counts[t_prev] += 1
else:
sums[t_prev] = np.array(m_prev, dtype=float)
counts[t_prev] = 1
self._two_sfs_window_sums = sums
self._two_sfs_window_counts = counts
self._two_sfs_since_resync = 0
def _warn_if_monomorphic_missing(self):
"""
Warn when the parsed two-SFS appears to carry (almost) no monomorphic sites. The two-SFS covariance and
correlation (:meth:`~sfsutils.spectrum.TwoSFS.cov` / :meth:`~sfsutils.spectrum.TwoSFS.corr`) require the
monomorphic sites to anchor the marginal to the site-frequency spectrum, so an all-sites input is needed.
"""
marginal = sum(self._two_sfs_marginal.values(), np.zeros(self.n + 1))
n_sites = float(marginal.sum())
n_monomorphic = float(marginal[0] + marginal[-1])
if n_sites > 0 and n_monomorphic / n_sites < 0.95:
self._logger.warning(
"The number of monomorphic sites is unusually low. Including monomorphic sites is necessary for a "
"meaningful two-SFS covariance/correlation (they anchor the marginal to the site-frequency "
"spectrum). If your dataset does not contain monomorphic sites, provide an all-sites input."
)
def _declared_contig_lengths(self) -> Dict[str, float]:
"""
The contig lengths the source declares, read off the open reader. A VCF header and a VCF-Zarr store both
carry them, and a tree sequence reports a single total length.
:return: Mapping of contig name to length, empty when the source declares none.
"""
lengths = getattr(self, 'contig_lengths', None) or getattr(self._reader, 'contig_lengths', None)
if lengths is None:
sequence_length = getattr(self._reader, 'sequence_length', None)
names = list(getattr(self._reader, 'seqnames', None) or [])
# a source reporting one total length describes a single contig
if sequence_length is not None and len(names) == 1:
lengths = {names[0]: sequence_length}
return {str(k): float(v) for k, v in (lengths or {}).items() if v is not None and float(v) > 0}
def _contig_spans(self) -> Dict[str, float]:
"""
The length (bp) of each contig carrying parsed variants, used when a :class:`TargetSiteCounter`
extrapolates the monomorphic pairs of the two-SFS. The length the source declares is preferred, since the
observed variants never reach the contig ends and their span underestimates the region, inflating the site
density and every extrapolated entry; the observed span is the last resort.
:return: Mapping of contig name to length.
"""
declared, spans, undeclared = self._declared_contig_lengths(), {}, []
for contig, (low, high) in self._contig_bounds.items():
length = declared.get(contig)
if length is None:
undeclared.append(contig)
length = float(high - low) if np.isfinite(low) and np.isfinite(high) and high > low else 0.0
spans[contig] = float(length)
if undeclared:
self._logger.warning(f"The source declares no length for contig(s) {sorted(undeclared)[:5]} "
f"({len(undeclared)} in total); the span of the observed variants is used instead, "
f"which underestimates the region and inflates the extrapolated monomorphic pairs "
f"of the two-SFS.")
return spans
def _region_length(self) -> float:
"""
The total length (bp) of the region over which sites are distributed.
:return: The region length.
"""
return float(sum(self._contig_spans().values()))
def _process_site(self, variant: Site) -> bool:
"""
Handle a single variant.
:param variant: The variant
:return: Whether the site was included in the SFS.
"""
# filter the variant
for filtration in self.filtrations:
if not filtration.filter_site(variant):
return False
# apply annotations
for annotation in self.annotations:
annotation.annotate_site(variant)
# parse site
return self._parse_site(variant)
def _rewind(self):
"""
Rewind the filtrations, annotations and stratifications, and fasta handler.
"""
FASTAHandler._rewind(self)
for f in self.filtrations:
f._rewind()
for a in self.annotations:
a._rewind()
for s in self.stratifications:
s._rewind()
def _setup(self):
"""
Set up the parser.
"""
# restore the per-pass state of the components, which otherwise carries over into a second parse
self._rewind()
# set up target site counter
if self.target_site_counter is not None:
self.target_site_counter._setup(self)
# make parser available to stratifications
for s in self.stratifications:
s._setup(self)
# create a string representation of the stratifications
representation = '.'.join(['[' + ', '.join(s.get_types()) + ']' for s in self.stratifications]) or "[all]"
# log the stratifications
self._logger.info(f'Using stratification: {representation}.')
# prepare samples mask
self._prepare_samples_mask()
# setup annotations
for annotation in self.annotations:
annotation._setup(self)
# setup filtrations
for f in self.filtrations:
f._setup(self)
def _prepare_samples_mask(self):
"""
Prepare the samples mask, or per-population masks in joint mode.
"""
samples = np.array(self._reader.samples)
# in joint mode, build one boolean mask per population
if self.pops is not None:
self._pop_masks = []
for name in self._pop_names:
requested = set(self.pops[name])
mask = np.isin(samples, self.pops[name])
# a partial match would silently shrink the population, changing the projection without warning
missing = sorted(requested - set(samples))
if missing:
raise ValueError(f"Samples for population '{name}' were not found in the input: {missing}.")
self._pop_masks.append(mask)
# masked filtrations copy this mask, so without it they would fall back to their unmasked branch and
# decide from ALT / is_snp rather than from the genotypes of the populations being parsed
self._samples_mask = np.logical_or.reduce(self._pop_masks)
return
# an all-True mask would make every site fancy-index-copy the whole genotype array, so no mask is
# installed unless the samples are actually restricted. A mask of ``None`` means every sample,
# judged from the genotypes, both here and in the filtrations that copy it
if self.include_samples is None and self.exclude_samples is None:
self._samples_mask = None
return
# determine samples to include
if self.include_samples is None:
mask = np.ones(len(samples)).astype(bool)
else:
mask = np.isin(samples, self.include_samples)
# determine samples to exclude
if self.exclude_samples is not None:
mask &= ~np.isin(samples, self.exclude_samples)
self._samples_mask = mask
def _teardown(self):
"""
Tear down parser components.
"""
# tear down all objects
for f in self.filtrations:
f._teardown()
for s in self.stratifications:
s._teardown()
for a in self.annotations:
a._teardown()
[docs]
def parse(self) -> Spectra | JointSpectra | TwoSpectra:
"""
Parse the site-frequency spectrum from the configured source (VCF, VCF-Zarr, or tree sequence).
The return type is fixed by the parsing mode, and this mapping is a stable part of the API. Every mode
returns a collection keyed by stratification type (a single ``'all'`` key when no stratifications were
given), so the return type is consistent across modes:
- one-dimensional SFS (the default): a :class:`~sfsutils.spectrum.Spectra` of one-dimensional spectra;
- multi-population (``pops`` given): a :class:`~sfsutils.spectrum.JointSpectra` of joint spectra, keyed
by (sorted) stratification type;
- two-SFS (``two_sfs`` set, with or without stratifications): a
:class:`~sfsutils.spectrum.TwoSpectra` of per-type two-SFS matrices. Without stratifications this holds
the single ``'all'`` entry, so the two-SFS is reached as ``parse()['all']``.
:return: A :class:`~sfsutils.spectrum.Spectra`, :class:`~sfsutils.spectrum.JointSpectra`, or
:class:`~sfsutils.spectrum.TwoSpectra` as described above.
"""
# discard the state of any previous pass before setting up
self._reset()
self._setup()
pbar = self.get_pbar(
total=self.n_sites,
desc=f"{self.__class__.__name__}>Processing sites"
)
# iterate over variants
for i, variant in enumerate(self._reader):
# handle site
if self._process_site(variant):
if self.target_site_counter is not None:
# update bounds
low, high = self._contig_bounds[variant.CHROM]
self._contig_bounds[variant.CHROM] = (min(low, variant.POS), max(high, variant.POS))
else:
self.n_skipped += 1
# update progress bar
pbar.update()
# explicitly stopping after ``n`` sites fixes a bug with cyvcf2:
# 'error parsing variant with `htslib::bcf_read` error-code: 0 and ret: -2'
if i + 1 >= self.n_sites or i + 1 >= self.max_sites:
break
# close progress bar
pbar.close()
# tear down components
self._teardown()
# inform about number of sites without ancestral tag
if self.n_no_ancestral > 0:
self._logger.info(f'Skipped {self.n_no_ancestral} sites without ancestral allele information.')
if len(self.sfs) == 0 and not self.two_sfs:
self._logger.warning(f"No sites were included in the spectra. If this is not expected, "
"please check that all components work as expected. You can also "
"set the log level to DEBUG.")
# warn that sites might not be polarized
if self.skip_non_polarized and not any(isinstance(a, AncestralAlleleAnnotation) for a in self.annotations):
self._logger.warning("Your variants might not be polarized and are thus not included in the spectra. "
"If this is the case, consider annotating the ancestral states or setting "
"'Parser.skip_non_polarized' to False.")
else:
n_included = self.n_sites - self.n_skipped
self._logger.info(f'Included {n_included} out of {self.n_sites} sites in total from the input.')
if self.polarize_probabilistically:
self._logger.info(f'Considered {self.n_aa_prob} sites with valid ancestral allele probability.')
# the contig lengths are read off the still-open reader, as closing it below discards the cached reader
# and asking afterwards would load the whole source a second time
contig_spans = self._contig_spans() if self.two_sfs and self.target_site_counter is not None else None
# close VCF reader
VCFHandler._rewind(self)
# in two-SFS mode, return the symmetrized pair-count matrix (or a collection thereof when stratified)
if self.two_sfs:
total = sum(matrix.sum() for matrix in self._two_sfs_matrices.values())
self._logger.info(f'Counted {total:.0f} site pairs within {self.d} bp.')
if self.target_site_counter is None:
self._warn_if_monomorphic_missing()
# without stratifications, return a single-entry collection keyed 'all' (consistent with the other
# parsing modes); a TargetSiteCounter extrapolates the monomorphic-involving pairs from the
# target-site count (approximate; fpmi() ignores them)
if len(self.stratifications) == 0:
matrix = TwoSFS(self._two_sfs_matrices['all']).symmetrize().data
if self.target_site_counter is not None:
matrix = self.target_site_counter._extrapolate_two_sfs(
matrix, self._two_sfs_marginal['all'], float(sum(contig_spans.values())), self.d,
contig_spans=contig_spans
)
return TwoSpectra({'all': TwoSFS(matrix)})
if self.target_site_counter is not None:
raise NotImplementedError(
"A TargetSiteCounter with the stratified two-SFS is not supported. Parse an all-sites input, or "
"use the unstratified two-SFS with a TargetSiteCounter."
)
# one symmetrized two-SFS per (within-stratum) type
return TwoSpectra(
{t: TwoSFS(self._two_sfs_matrices[t]).symmetrize() for t in sorted(self._two_sfs_matrices)}
)
# in joint mode, return a collection of joint spectra keyed by (sorted) type
if self.pops is not None:
# extrapolate the monomorphic corner from the target-site count. The dict is built after the
# sampling pass, which adds the sampled monomorphic sites to self.sfs and may introduce types
# that no polymorphic site of the input carried
if self.target_site_counter is not None and self.n_skipped < self.n_sites:
self.target_site_counter.count()
sfs = self.target_site_counter._update_target_sites_joint(dict(self.sfs))
else:
sfs = dict(self.sfs)
return JointSpectra(
{t: JointSFS(sfs[t], self._pop_names) for t in sorted(sfs)}
)
# count target sites
if self.target_site_counter is not None and self.n_skipped < self.n_sites:
# count target sites
self.target_site_counter.count()
# update target sites (the sampled monomorphic sites have been added to self.sfs by now)
spectra = self.target_site_counter._update_target_sites(Spectra(dict(self.sfs)))
else:
spectra = Spectra(dict(self.sfs))
return spectra.sort_types()