allelign 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- allelign/__init__.py +69 -0
- allelign/__main__.py +5 -0
- allelign/_common.py +95 -0
- allelign/_numba.py +22 -0
- allelign/align.py +1252 -0
- allelign/alleles.py +90 -0
- allelign/build.py +57 -0
- allelign/catalog.py +88 -0
- allelign/cli.py +323 -0
- allelign/download.py +525 -0
- allelign/genotypes.py +241 -0
- allelign/ldcheck.py +771 -0
- allelign/qc.py +638 -0
- allelign/status.py +130 -0
- allelign/sumstats.py +1207 -0
- allelign/variants.py +155 -0
- allelign/warning_markers.py +15 -0
- allelign/weights.py +614 -0
- allelign-0.1.0.dist-info/METADATA +175 -0
- allelign-0.1.0.dist-info/RECORD +24 -0
- allelign-0.1.0.dist-info/WHEEL +5 -0
- allelign-0.1.0.dist-info/entry_points.txt +2 -0
- allelign-0.1.0.dist-info/licenses/LICENSE +21 -0
- allelign-0.1.0.dist-info/top_level.txt +1 -0
allelign/genotypes.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""PLINK 1 ``.bed`` genotypes, read by variant, and the LD screen against them.
|
|
2
|
+
|
|
3
|
+
An LD reference is often a PLINK fileset rather than a precomputed matrix.
|
|
4
|
+
:class:`Bed` reads selected variants as ``int8`` allele counts (``-1`` for a
|
|
5
|
+
missing call) without touching the rest of the file, and
|
|
6
|
+
:func:`ld_consistency_screen_genotypes` runs the split-half screen one block at
|
|
7
|
+
a time, so a genome-wide reference never holds more than one block's LD.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import warnings
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from ._common import integer_at_least, validate_seed
|
|
18
|
+
from .warning_markers import LD_SCREEN_HIGH_DROP, LD_SCREEN_UNTESTED
|
|
19
|
+
|
|
20
|
+
__all__ = ["Bed", "ld_consistency_screen_genotypes", "read_sample_list"]
|
|
21
|
+
|
|
22
|
+
_MAGIC = b"\x6c\x1b\x01" # PLINK 1, variant-major
|
|
23
|
+
# 2-bit code -> count of the .bim's first allele (A1): 00 hom A1, 01 missing,
|
|
24
|
+
# 10 het, 11 hom A2. Sample k of a byte sits in bits 2k..2k+1.
|
|
25
|
+
_CODE_TO_COUNT = np.array([2, -1, 1, 0], dtype=np.int8)
|
|
26
|
+
_BYTE_TO_COUNTS = _CODE_TO_COUNT[(np.arange(256)[:, None] >> (2 * np.arange(4))) & 3]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _prefix(path):
|
|
30
|
+
path = os.fspath(path)
|
|
31
|
+
for ext in (".bed", ".bim", ".fam"):
|
|
32
|
+
if path.endswith(ext):
|
|
33
|
+
return path[: -len(ext)]
|
|
34
|
+
return path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_fam_iids(path):
|
|
38
|
+
iids = []
|
|
39
|
+
with open(path) as fh:
|
|
40
|
+
for line_no, line in enumerate(fh, start=1):
|
|
41
|
+
f = line.split()
|
|
42
|
+
if not f:
|
|
43
|
+
continue
|
|
44
|
+
if len(f) < 2:
|
|
45
|
+
raise ValueError(f"{path}: line {line_no} has no IID column")
|
|
46
|
+
iids.append(f[1])
|
|
47
|
+
return np.array(iids, dtype=object)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def read_sample_list(path):
|
|
51
|
+
"""IIDs from a PLINK ``--keep``-style file: ``FID IID`` or one ``IID`` per line."""
|
|
52
|
+
out = []
|
|
53
|
+
with open(path) as fh:
|
|
54
|
+
for line in fh:
|
|
55
|
+
f = line.split()
|
|
56
|
+
if f and not f[0].startswith("#"):
|
|
57
|
+
out.append(f[1] if len(f) > 1 else f[0])
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Bed:
|
|
62
|
+
"""A PLINK 1 fileset: ``prefix.bed`` with its ``.bim`` and ``.fam``.
|
|
63
|
+
|
|
64
|
+
``variants`` (the ``.bim`` table) is read on construction unless given.
|
|
65
|
+
``samples`` restricts reads to some ``.fam`` rows: IIDs (strings), row
|
|
66
|
+
indices, or a boolean mask -- for instance one ancestry of a mixed panel.
|
|
67
|
+
Genotypes are read on demand.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, path, *, variants=None, samples=None):
|
|
71
|
+
from .variants import read_bim
|
|
72
|
+
prefix = _prefix(path)
|
|
73
|
+
self.path = prefix + ".bed"
|
|
74
|
+
self.variants = read_bim(prefix + ".bim") if variants is None else variants
|
|
75
|
+
self.iid = _read_fam_iids(prefix + ".fam")
|
|
76
|
+
self.n_variants = len(self.variants)
|
|
77
|
+
n_fam = self.iid.size
|
|
78
|
+
if n_fam == 0:
|
|
79
|
+
raise ValueError(f"{prefix}.fam lists no samples")
|
|
80
|
+
self._rows = self._sample_rows(samples, n_fam, prefix)
|
|
81
|
+
self.n_samples = n_fam if self._rows is None else int(self._rows.size)
|
|
82
|
+
self._n_fam = n_fam
|
|
83
|
+
self._stride = (n_fam + 3) // 4
|
|
84
|
+
with open(self.path, "rb") as fh:
|
|
85
|
+
if fh.read(3) != _MAGIC:
|
|
86
|
+
raise ValueError(f"{self.path}: not a variant-major PLINK 1 .bed")
|
|
87
|
+
fh.seek(0, os.SEEK_END)
|
|
88
|
+
size = fh.tell()
|
|
89
|
+
expected = 3 + self._stride * self.n_variants
|
|
90
|
+
if size != expected:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"{self.path}: {size - 3} genotype bytes, expected {expected - 3} "
|
|
93
|
+
f"for {self.n_variants} variants x {n_fam} samples")
|
|
94
|
+
|
|
95
|
+
def read(self, variant_idx=None):
|
|
96
|
+
"""Allele counts of the ``.bim``'s A1, ``(n_samples, n_selected)`` int8.
|
|
97
|
+
|
|
98
|
+
``variant_idx`` selects rows of the ``.bim`` (``None``: all of them);
|
|
99
|
+
consecutive rows are read in one call. ``-1`` marks a missing call.
|
|
100
|
+
"""
|
|
101
|
+
if variant_idx is None:
|
|
102
|
+
idx = np.arange(self.n_variants, dtype=np.int64)
|
|
103
|
+
else:
|
|
104
|
+
idx = np.asarray(variant_idx, dtype=np.int64).reshape(-1)
|
|
105
|
+
if idx.size and (idx.min() < 0 or idx.max() >= self.n_variants):
|
|
106
|
+
raise ValueError("variant_idx is out of range")
|
|
107
|
+
raw = np.empty((idx.size, self._stride), dtype=np.uint8)
|
|
108
|
+
with open(self.path, "rb") as fh:
|
|
109
|
+
start = 0
|
|
110
|
+
for stop in np.append(np.flatnonzero(np.diff(idx) != 1) + 1, idx.size):
|
|
111
|
+
if stop == start:
|
|
112
|
+
continue
|
|
113
|
+
fh.seek(3 + int(idx[start]) * self._stride)
|
|
114
|
+
count = int(stop - start) * self._stride
|
|
115
|
+
chunk = np.fromfile(fh, dtype=np.uint8, count=count)
|
|
116
|
+
if chunk.size != count:
|
|
117
|
+
raise ValueError(f"{self.path}: truncated genotype data")
|
|
118
|
+
raw[start:stop] = chunk.reshape(-1, self._stride)
|
|
119
|
+
start = stop
|
|
120
|
+
counts = _BYTE_TO_COUNTS[raw].reshape(idx.size, 4 * self._stride)
|
|
121
|
+
counts = counts[:, :self._n_fam]
|
|
122
|
+
if self._rows is not None:
|
|
123
|
+
counts = counts[:, self._rows]
|
|
124
|
+
return np.ascontiguousarray(counts.T)
|
|
125
|
+
|
|
126
|
+
def _sample_rows(self, samples, n_fam, prefix):
|
|
127
|
+
if samples is None:
|
|
128
|
+
return None
|
|
129
|
+
samples = np.asarray(samples)
|
|
130
|
+
if samples.dtype == bool:
|
|
131
|
+
if samples.shape != (n_fam,):
|
|
132
|
+
raise ValueError(f"a sample mask needs {n_fam} entries")
|
|
133
|
+
rows = np.flatnonzero(samples)
|
|
134
|
+
elif samples.dtype.kind in "iu":
|
|
135
|
+
rows = np.unique(samples.astype(np.int64))
|
|
136
|
+
if rows.size and (rows[0] < 0 or rows[-1] >= n_fam):
|
|
137
|
+
raise ValueError("sample index out of range")
|
|
138
|
+
else:
|
|
139
|
+
wanted = {str(s) for s in samples.tolist()}
|
|
140
|
+
rows = np.flatnonzero([iid in wanted for iid in self.iid])
|
|
141
|
+
missing = len(wanted) - len({self.iid[r] for r in rows})
|
|
142
|
+
if missing:
|
|
143
|
+
warnings.warn(f"{missing:,} requested samples are not in "
|
|
144
|
+
f"{prefix}.fam", RuntimeWarning, stacklevel=3)
|
|
145
|
+
if rows.size == 0:
|
|
146
|
+
raise ValueError("the sample selection keeps no samples")
|
|
147
|
+
return rows
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _block_seed(seed, block):
|
|
151
|
+
"""An independent 32-bit seed for one block, fixed by ``(seed, block)``."""
|
|
152
|
+
return int(np.random.SeedSequence([seed, block]).generate_state(1)[0])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def ld_consistency_screen_genotypes(bed, var_index, z, *, max_block=2000,
|
|
156
|
+
seed=0, progress=None, return_stats=False,
|
|
157
|
+
**screen_options):
|
|
158
|
+
"""The split-half LD screen with LD computed from reference genotypes.
|
|
159
|
+
|
|
160
|
+
``z[i]`` is the z-score of ``bed`` row ``var_index[i]`` (each row at most
|
|
161
|
+
once), on the ``.bim``'s A1 -- what :func:`allelign.harmonize` returns as
|
|
162
|
+
``var_index`` and ``beta / se``. Each chromosome, in ``.bim`` order, is
|
|
163
|
+
cut into near-equal blocks of at most ``max_block`` variants; every block's
|
|
164
|
+
correlation matrix is formed from the genotypes, screened with
|
|
165
|
+
:func:`allelign.ld_consistency_screen` (``screen_options``: ``rounds``,
|
|
166
|
+
``window``, ``threshold``, ``eigenvalue_floor``) and released. Each block
|
|
167
|
+
draws its splits from a stream fixed by ``(seed, block number)``.
|
|
168
|
+
|
|
169
|
+
Returns the keep mask aligned to ``z`` (and, with ``return_stats``,
|
|
170
|
+
``n_tested``, ``n_untested``, ``n_dropped`` and ``n_blocks``). Drop-rate
|
|
171
|
+
and untested-fraction warnings are issued once, for the whole screen.
|
|
172
|
+
|
|
173
|
+
A small reference makes the screen drop more: with a few hundred samples
|
|
174
|
+
the sampling noise in R alone can flag several percent of variants (see
|
|
175
|
+
docs/guide.md). Prefer the LD the statistics will be fitted with.
|
|
176
|
+
"""
|
|
177
|
+
from .ldcheck import (DEFAULT_DROP_FRACTION_WARN,
|
|
178
|
+
DEFAULT_UNTESTED_FRACTION_WARN, MIN_WINDOW,
|
|
179
|
+
chromosome_blocks, ld_blocks_from_genotypes,
|
|
180
|
+
ld_consistency_screen)
|
|
181
|
+
if not isinstance(bed, Bed):
|
|
182
|
+
raise TypeError("bed must be an allelign.Bed")
|
|
183
|
+
var_index = np.asarray(var_index, dtype=np.int64).reshape(-1)
|
|
184
|
+
z = np.asarray(z, dtype=np.float64).reshape(-1)
|
|
185
|
+
if z.shape != var_index.shape:
|
|
186
|
+
raise ValueError("z and var_index must have the same length")
|
|
187
|
+
if var_index.size and (var_index.min() < 0
|
|
188
|
+
or var_index.max() >= bed.n_variants):
|
|
189
|
+
raise ValueError("var_index is out of range for the .bed")
|
|
190
|
+
if np.unique(var_index).size != var_index.size:
|
|
191
|
+
raise ValueError("var_index must name each .bed row at most once")
|
|
192
|
+
max_block = integer_at_least("max_block", max_block, MIN_WINDOW)
|
|
193
|
+
seed = validate_seed(seed)
|
|
194
|
+
seed = 0 if seed is None else seed
|
|
195
|
+
|
|
196
|
+
order = np.argsort(var_index, kind="stable")
|
|
197
|
+
rows = var_index[order]
|
|
198
|
+
sizes = chromosome_blocks(np.asarray(bed.variants.chrom, dtype=object)[rows],
|
|
199
|
+
max_size=max_block) if rows.size else []
|
|
200
|
+
keep_sorted = np.ones(rows.size, dtype=bool)
|
|
201
|
+
n_tested = n_untested = 0
|
|
202
|
+
start = 0
|
|
203
|
+
with warnings.catch_warnings():
|
|
204
|
+
# Per-block rate warnings would repeat; one summary follows.
|
|
205
|
+
for marker in (LD_SCREEN_HIGH_DROP, LD_SCREEN_UNTESTED):
|
|
206
|
+
warnings.filterwarnings("ignore", message=f".*{marker}",
|
|
207
|
+
category=RuntimeWarning)
|
|
208
|
+
for b, size in enumerate(sizes):
|
|
209
|
+
sl = slice(start, start + size)
|
|
210
|
+
blocks = ld_blocks_from_genotypes(bed.read(rows[sl]), [size])
|
|
211
|
+
keep, stats = ld_consistency_screen(
|
|
212
|
+
blocks, z[order][sl], seed=_block_seed(seed, b),
|
|
213
|
+
return_stats=True, **screen_options)
|
|
214
|
+
keep_sorted[sl] = keep
|
|
215
|
+
n_tested += stats["n_tested"]
|
|
216
|
+
n_untested += stats["n_untested"]
|
|
217
|
+
start += size
|
|
218
|
+
if progress is not None:
|
|
219
|
+
progress({"step": "LD consistency screen", "done": b + 1,
|
|
220
|
+
"total": len(sizes), "unit": "block"})
|
|
221
|
+
keep = np.empty_like(keep_sorted)
|
|
222
|
+
keep[order] = keep_sorted
|
|
223
|
+
n_dropped = int((~keep).sum())
|
|
224
|
+
total = int(keep.size)
|
|
225
|
+
if n_tested and n_dropped / n_tested > DEFAULT_DROP_FRACTION_WARN:
|
|
226
|
+
warnings.warn(
|
|
227
|
+
f"{LD_SCREEN_HIGH_DROP} {n_dropped:,} of {n_tested:,} tested "
|
|
228
|
+
f"variants ({n_dropped / n_tested:.0%}). With LD from reference "
|
|
229
|
+
"genotypes, a small reference panel is the usual cause: sampling "
|
|
230
|
+
"noise in R flags variants that are consistent with the true LD.",
|
|
231
|
+
RuntimeWarning, stacklevel=2)
|
|
232
|
+
if n_untested and (n_tested == 0
|
|
233
|
+
or n_untested / total >= DEFAULT_UNTESTED_FRACTION_WARN):
|
|
234
|
+
warnings.warn(
|
|
235
|
+
f"LD-consistency screen tested {n_tested:,} of {total:,} variants "
|
|
236
|
+
f"({n_untested:,} {LD_SCREEN_UNTESTED} of size {MIN_WINDOW} and "
|
|
237
|
+
"were kept unevaluated).", RuntimeWarning, stacklevel=2)
|
|
238
|
+
if return_stats:
|
|
239
|
+
return keep, {"n_tested": int(n_tested), "n_untested": int(n_untested),
|
|
240
|
+
"n_dropped": n_dropped, "n_blocks": len(sizes)}
|
|
241
|
+
return keep
|