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/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