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/ldcheck.py
ADDED
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
"""LD-consistency screen: z-scores that their LD neighbours contradict.
|
|
2
|
+
|
|
3
|
+
A variant whose z-score disagrees with what its neighbours predict through the
|
|
4
|
+
LD matrix is a sign of an allele error, a mis-reported statistic or LD that is
|
|
5
|
+
not the matrix the GWAS was drawn against. The screen follows DENTIST's
|
|
6
|
+
split-half statistic (Chen et al. 2021, Nat Commun 12:7117)::
|
|
7
|
+
|
|
8
|
+
zhat_a = R[a,B] pinv(R[B,B]) z_B
|
|
9
|
+
T_a = (z_a - zhat_a)^2 / (1 - R[a,B] pinv(R[B,B]) R[B,a]) ~ chi2_1
|
|
10
|
+
|
|
11
|
+
on random halves of each window, and confirms every flag with the full-window
|
|
12
|
+
leave-one-out statistic ``T_j = (Omega z)_j^2 / Omega_jj``,
|
|
13
|
+
``Omega = (R + cI)^{-1}``. A confirmed variant is dropped only when it has an
|
|
14
|
+
LD neighbour (``|r| >= min_neighbor_r``) and its residual is not a private
|
|
15
|
+
effect consistent with ``R`` (``|(Omega z)_j| > private_z_ratio |z_j|``). It is
|
|
16
|
+
not the complete published DENTIST pipeline, so its calibration does not
|
|
17
|
+
transfer; validate the mask for the study at hand.
|
|
18
|
+
|
|
19
|
+
LD is supplied as blocks ``[(R, idx), ...]`` partitioning the variants. ``R``
|
|
20
|
+
can be a dense float array, an int8 array on ldpred3's ``round(127 r)`` scale,
|
|
21
|
+
a low-rank block (any object with ``U``, ``residual_diag`` and ``scale``
|
|
22
|
+
attributes, such as ldpred3's ``LowRankLD``), or any object with a
|
|
23
|
+
``window(rows)`` method returning dense correlations. Windows are solved in
|
|
24
|
+
float32. :func:`ld_blocks_from_genotypes` builds blocks from reference
|
|
25
|
+
genotypes; :func:`chromosome_blocks` splits a sorted panel into blocks.
|
|
26
|
+
|
|
27
|
+
Ported from ldpred3's ``ld_consistency_screen``; for the same blocks, z and
|
|
28
|
+
seed the mask is the same (serial path, SciPy present).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import functools
|
|
34
|
+
import warnings
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
from ._common import finite_control, integer_at_least, validate_boolean, validate_seed
|
|
39
|
+
from .warning_markers import LD_SCREEN_HIGH_DROP, LD_SCREEN_UNTESTED
|
|
40
|
+
|
|
41
|
+
__all__ = ["ld_consistency_screen", "ld_consistency_screen_selected",
|
|
42
|
+
"dentist_statistic", "loo_statistic",
|
|
43
|
+
"ld_blocks_from_genotypes", "dense_blocks", "chromosome_blocks",
|
|
44
|
+
"DEFAULT_WINDOW", "MIN_WINDOW", "DEFAULT_THRESHOLD",
|
|
45
|
+
"DEFAULT_ROUNDS"]
|
|
46
|
+
|
|
47
|
+
#: Variants per window (bounds the pseudo-inverse cost).
|
|
48
|
+
DEFAULT_WINDOW = 1000
|
|
49
|
+
#: Windows below this are skipped: too few variants per half to predict from.
|
|
50
|
+
MIN_WINDOW = 50
|
|
51
|
+
#: Eigenvalues below this fraction of the largest are dropped, not inverted.
|
|
52
|
+
DEFAULT_SPLIT_HALF_EIGENVALUE_FLOOR = 1e-3
|
|
53
|
+
#: chi2_1 at p = 5e-8.
|
|
54
|
+
DEFAULT_THRESHOLD = 29.72
|
|
55
|
+
DEFAULT_ROUNDS = 4
|
|
56
|
+
#: Confirm a flag only when a surviving neighbour has at least this |r|.
|
|
57
|
+
DEFAULT_MIN_NEIGHBOR_R = 0.1
|
|
58
|
+
#: Ridge on the full-window confirmation inverse.
|
|
59
|
+
DEFAULT_LOO_RIDGE = 0.01
|
|
60
|
+
#: Spectral floor for the confirmation window (int8 and thresholded LD are
|
|
61
|
+
#: not positive definite). 0.01 zeroed the null drop rate on real D8 and D32
|
|
62
|
+
#: HapMap3+ windows in ldpred3's calibration while keeping planted recovery.
|
|
63
|
+
DEFAULT_LOO_EIGENVALUE_FLOOR = 0.01
|
|
64
|
+
#: A flagged variant with ``|(Omega z)_j| <= ratio |z_j|`` is a private effect.
|
|
65
|
+
DEFAULT_PRIVATE_Z_RATIO = 2.0
|
|
66
|
+
DEFAULT_DROP_FRACTION_WARN = 0.05
|
|
67
|
+
DEFAULT_UNTESTED_FRACTION_WARN = 0.05
|
|
68
|
+
|
|
69
|
+
_WORK_DTYPE = np.float32
|
|
70
|
+
_Q8 = 127.0
|
|
71
|
+
_FLOOR_TOLERANCE = 0.99
|
|
72
|
+
_SHIFT_GRID = 1e-4
|
|
73
|
+
#: Window LD a block may keep between rounds.
|
|
74
|
+
_SPLIT_HALF_CACHE_BYTES = 64 * 1024 ** 2
|
|
75
|
+
|
|
76
|
+
try: # optional: float32 ssyevd via SciPy
|
|
77
|
+
from scipy.linalg import eigh as _scipy_eigh
|
|
78
|
+
_HAVE_SCIPY = True
|
|
79
|
+
except ImportError: # pragma: no cover - environment
|
|
80
|
+
_HAVE_SCIPY = False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _ridge_for_floor(lo, ridge, floor):
|
|
84
|
+
"""Ridge making every eigenvalue of ``R + cI`` at least ``floor``.
|
|
85
|
+
|
|
86
|
+
One uniform shift, rounded up onto a 1e-4 grid so the result is
|
|
87
|
+
reproducible across BLAS builds; ``ridge`` is returned unchanged when it
|
|
88
|
+
already suffices (any float LD block).
|
|
89
|
+
"""
|
|
90
|
+
if floor <= 0.0:
|
|
91
|
+
return ridge
|
|
92
|
+
if lo + ridge >= _FLOOR_TOLERANCE * floor:
|
|
93
|
+
return ridge
|
|
94
|
+
return float(np.ceil((floor - lo) / _SHIFT_GRID) * _SHIFT_GRID)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_lowrank(block):
|
|
98
|
+
return all(hasattr(block, a) for a in ("U", "residual_diag", "scale"))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _block_size(block):
|
|
102
|
+
if hasattr(block, "window") and not isinstance(block, np.ndarray):
|
|
103
|
+
size = getattr(block, "size", None)
|
|
104
|
+
if size is None:
|
|
105
|
+
try:
|
|
106
|
+
size = len(block)
|
|
107
|
+
except TypeError:
|
|
108
|
+
size = None
|
|
109
|
+
return None if size is None else int(size)
|
|
110
|
+
if _is_lowrank(block):
|
|
111
|
+
return int(np.asarray(block.U).shape[0])
|
|
112
|
+
arr = np.asarray(block)
|
|
113
|
+
if arr.ndim != 2 or arr.shape[0] != arr.shape[1]:
|
|
114
|
+
raise ValueError("each dense LD block must be a square matrix")
|
|
115
|
+
return int(arr.shape[0])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _window_ld(block, local):
|
|
119
|
+
"""Dense float32 correlations for the rows ``local`` of one block."""
|
|
120
|
+
if hasattr(block, "window") and not isinstance(block, np.ndarray):
|
|
121
|
+
return np.asarray(block.window(local), dtype=_WORK_DTYPE)
|
|
122
|
+
if _is_lowrank(block):
|
|
123
|
+
factor = np.multiply(np.asarray(block.U)[local],
|
|
124
|
+
_WORK_DTYPE(block.scale), dtype=_WORK_DTYPE)
|
|
125
|
+
out = factor @ factor.T
|
|
126
|
+
out[np.diag_indices(len(local))] += np.asarray(
|
|
127
|
+
block.residual_diag, dtype=_WORK_DTYPE)[local]
|
|
128
|
+
return out
|
|
129
|
+
raw = np.asarray(block)
|
|
130
|
+
window = raw[np.ix_(local, local)]
|
|
131
|
+
if raw.dtype == np.int8:
|
|
132
|
+
# ldpred3's dense int8 stores round(127 r).
|
|
133
|
+
return np.asarray(window, dtype=_WORK_DTYPE) * _WORK_DTYPE(1.0 / _Q8)
|
|
134
|
+
return np.asarray(window, dtype=_WORK_DTYPE)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _screen_eigh(a):
|
|
138
|
+
"""Every eigenpair of a symmetric window, ascending (float32 ``ssyevd``)."""
|
|
139
|
+
if a.dtype == _WORK_DTYPE and _HAVE_SCIPY:
|
|
140
|
+
return _scipy_eigh(a, check_finite=False, driver="evd")
|
|
141
|
+
return np.linalg.eigh(a)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _split_half_factor(ld, predictors, targets, eigenvalue_floor,
|
|
145
|
+
eigh=_screen_eigh):
|
|
146
|
+
"""The z-free part of the split-half statistic, or ``None`` if no rank."""
|
|
147
|
+
within = ld[np.ix_(predictors, predictors)]
|
|
148
|
+
across = ld[np.ix_(targets, predictors)]
|
|
149
|
+
values, vectors = eigh(within)
|
|
150
|
+
keep = values > eigenvalue_floor * max(float(values.max()), 1e-12)
|
|
151
|
+
if not keep.any():
|
|
152
|
+
return None
|
|
153
|
+
retained = vectors[:, keep]
|
|
154
|
+
values = values[keep]
|
|
155
|
+
projected = across @ retained
|
|
156
|
+
leverage = ((projected * projected) / values).sum(axis=1)
|
|
157
|
+
return retained, values, projected, np.clip(1.0 - leverage, 1e-6, None)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _split_half_apply(factor, z, predictors, targets):
|
|
161
|
+
if factor is None:
|
|
162
|
+
return np.zeros(len(targets))
|
|
163
|
+
retained, values, projected, residual = factor
|
|
164
|
+
predicted = projected @ ((retained.T @ z[predictors]) / values)
|
|
165
|
+
return (z[targets] - predicted) ** 2 / residual
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def dentist_statistic(ld, z, predictors, targets, *,
|
|
169
|
+
eigenvalue_floor=DEFAULT_SPLIT_HALF_EIGENVALUE_FLOOR):
|
|
170
|
+
"""Split-half ``T`` for ``targets`` predicted from ``predictors``.
|
|
171
|
+
|
|
172
|
+
``ld`` is a dense correlation matrix, ``z`` the matching z-scores, and the
|
|
173
|
+
two disjoint index arrays select positions in both. Under the working
|
|
174
|
+
Gaussian model with exact LD each value is approximately chi2_1.
|
|
175
|
+
"""
|
|
176
|
+
ld = np.asarray(ld, dtype=np.float64)
|
|
177
|
+
z = np.asarray(z, dtype=np.float64)
|
|
178
|
+
if ld.ndim != 2 or ld.shape[0] != ld.shape[1] or ld.shape[0] == 0:
|
|
179
|
+
raise ValueError("ld must be a non-empty square matrix")
|
|
180
|
+
if z.shape != (ld.shape[0],) or not np.all(np.isfinite(z)):
|
|
181
|
+
raise ValueError("z must be a matching finite vector")
|
|
182
|
+
if not np.all(np.isfinite(ld)):
|
|
183
|
+
raise ValueError("ld must contain only finite values")
|
|
184
|
+
|
|
185
|
+
def indices(value, name):
|
|
186
|
+
value = np.asarray(value)
|
|
187
|
+
if (value.ndim != 1 or value.size == 0
|
|
188
|
+
or not np.issubdtype(value.dtype, np.integer)):
|
|
189
|
+
raise ValueError(f"{name} must be a non-empty integer vector")
|
|
190
|
+
value = value.astype(np.int64, copy=False)
|
|
191
|
+
if (np.any((value < 0) | (value >= z.size))
|
|
192
|
+
or np.unique(value).size != value.size):
|
|
193
|
+
raise ValueError(f"{name} contains invalid or repeated indices")
|
|
194
|
+
return value
|
|
195
|
+
|
|
196
|
+
predictors = indices(predictors, "predictors")
|
|
197
|
+
targets = indices(targets, "targets")
|
|
198
|
+
if np.intersect1d(predictors, targets).size:
|
|
199
|
+
raise ValueError("predictors and targets must be disjoint")
|
|
200
|
+
eigenvalue_floor = finite_control("eigenvalue_floor", eigenvalue_floor,
|
|
201
|
+
lower=0.0)
|
|
202
|
+
if eigenvalue_floor >= 1:
|
|
203
|
+
raise ValueError("eigenvalue_floor must be < 1")
|
|
204
|
+
return _split_half_apply(
|
|
205
|
+
_split_half_factor(ld, predictors, targets, eigenvalue_floor),
|
|
206
|
+
z, predictors, targets)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _window_has_neighbor(ld, min_neighbor_r):
|
|
210
|
+
off = np.abs(ld, dtype=_WORK_DTYPE)
|
|
211
|
+
np.fill_diagonal(off, 0.0)
|
|
212
|
+
return off.max(axis=1) >= min_neighbor_r
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _precision_factor(ld, ridge, floor=DEFAULT_LOO_EIGENVALUE_FLOOR,
|
|
216
|
+
eigh=_screen_eigh):
|
|
217
|
+
"""``V``, ``1/(w+c)`` and ``Omega_jj`` for the full-window LOO statistic.
|
|
218
|
+
|
|
219
|
+
One eigendecomposition does the PSD repair and the inversion together:
|
|
220
|
+
``c`` is the ridge raised until every eigenvalue clears ``floor``,
|
|
221
|
+
``Omega = V diag(1/(w + c)) V'`` and ``Omega_jj = sum_k V_jk^2/(w_k + c)``.
|
|
222
|
+
"""
|
|
223
|
+
w, V = eigh(np.asarray(ld, dtype=_WORK_DTYPE))
|
|
224
|
+
shift = _ridge_for_floor(float(w.min()), float(ridge), float(floor))
|
|
225
|
+
inv_w = _WORK_DTYPE(1.0) / (w + _WORK_DTYPE(shift))
|
|
226
|
+
diag = (V * V) @ inv_w
|
|
227
|
+
return V, inv_w, np.maximum(diag, _WORK_DTYPE(1e-12))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _precision_apply(factor, z):
|
|
231
|
+
V, inv_w, diag = factor
|
|
232
|
+
tvec = V @ (inv_w * (V.T @ np.asarray(z, dtype=_WORK_DTYPE)))
|
|
233
|
+
stat = (tvec * tvec) / diag
|
|
234
|
+
return stat, tvec
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def loo_statistic(ld, z, *, ridge=DEFAULT_LOO_RIDGE,
|
|
238
|
+
eigenvalue_floor=DEFAULT_LOO_EIGENVALUE_FLOOR):
|
|
239
|
+
"""Leave-one-out ``T_j = (Omega z)_j^2 / Omega_jj`` for every variant.
|
|
240
|
+
|
|
241
|
+
``(Omega z)_j = Omega_jj (z_j - zhat_j)`` with ``zhat_j`` predicted from
|
|
242
|
+
the rest of the window, so ``T_j`` is the squared standardised residual of
|
|
243
|
+
``z_j``. ``Omega = (R + cI)^{-1}`` with the ridge raised until every
|
|
244
|
+
eigenvalue clears ``eigenvalue_floor``. Returns ``(T, Omega z)``.
|
|
245
|
+
"""
|
|
246
|
+
ld = np.asarray(ld)
|
|
247
|
+
z = np.asarray(z, dtype=np.float64)
|
|
248
|
+
if ld.ndim != 2 or ld.shape[0] != ld.shape[1] or z.shape != (ld.shape[0],):
|
|
249
|
+
raise ValueError("ld must be square and z must match it")
|
|
250
|
+
stat, tvec = _precision_apply(
|
|
251
|
+
_precision_factor(_window_ld(ld, np.arange(ld.shape[0])),
|
|
252
|
+
ridge, eigenvalue_floor), z)
|
|
253
|
+
return stat.astype(np.float64), tvec.astype(np.float64)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _confirmed_drops(ld, z, candidates, threshold, *,
|
|
257
|
+
min_neighbor_r=DEFAULT_MIN_NEIGHBOR_R,
|
|
258
|
+
ridge=DEFAULT_LOO_RIDGE,
|
|
259
|
+
private_z_ratio=DEFAULT_PRIVATE_Z_RATIO, shared=None,
|
|
260
|
+
eigh=_screen_eigh):
|
|
261
|
+
"""Split-half flags that also fail the full-window LOO statistic.
|
|
262
|
+
|
|
263
|
+
A large z whose ``|(Omega z)_j|`` is at most ``private_z_ratio |z_j|`` is a
|
|
264
|
+
private effect consistent with ``R``; if it is the window's largest
|
|
265
|
+
eligible residual, nothing in the window is dropped. Isolated variants
|
|
266
|
+
(no neighbour above ``min_neighbor_r``) are kept. ``shared`` memoises the
|
|
267
|
+
window's neighbour mask and precision factor across traits.
|
|
268
|
+
"""
|
|
269
|
+
if candidates.size == 0:
|
|
270
|
+
return candidates
|
|
271
|
+
if shared is None:
|
|
272
|
+
shared = {}
|
|
273
|
+
if not shared:
|
|
274
|
+
shared["has_nbr"] = _window_has_neighbor(ld, min_neighbor_r)
|
|
275
|
+
shared["precision"] = _precision_factor(ld, ridge, eigh=eigh)
|
|
276
|
+
has_nbr = shared["has_nbr"]
|
|
277
|
+
stat, tvec = _precision_apply(shared["precision"], z)
|
|
278
|
+
private = np.abs(tvec) <= private_z_ratio * np.abs(z)
|
|
279
|
+
eligible = np.where(has_nbr, stat, -np.inf)
|
|
280
|
+
lead = int(np.argmax(eligible))
|
|
281
|
+
if eligible[lead] > threshold and private[lead]:
|
|
282
|
+
return candidates[:0]
|
|
283
|
+
take = ((stat[candidates] > threshold)
|
|
284
|
+
& has_nbr[candidates]
|
|
285
|
+
& ~private[candidates])
|
|
286
|
+
return candidates[take]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _split_half_groups(keep):
|
|
290
|
+
groups = {}
|
|
291
|
+
for trait in range(keep.shape[0]):
|
|
292
|
+
groups.setdefault(keep[trait].tobytes(), []).append(trait)
|
|
293
|
+
return groups
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _split_half_tiles(live, window):
|
|
297
|
+
"""One round's windows over ``live``; the last is slid to the end."""
|
|
298
|
+
starts = list(range(0, max(live.size - window + 1, 1), window))
|
|
299
|
+
tail = max(0, live.size - window)
|
|
300
|
+
if starts[-1] != tail:
|
|
301
|
+
starts.append(tail)
|
|
302
|
+
tiles = (live[start:start + window] for start in starts)
|
|
303
|
+
return [local for local in tiles if local.size >= MIN_WINDOW]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class _SplitHalfWindows:
|
|
307
|
+
"""One block's window LD and confirmation memo, kept across rounds.
|
|
308
|
+
|
|
309
|
+
Entries are keyed by source rows; :meth:`retain` keeps only the windows
|
|
310
|
+
the next round tiles, within :data:`_SPLIT_HALF_CACHE_BYTES`.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
def __init__(self, block, source_rows):
|
|
314
|
+
self._block, self._source_rows = block, source_rows
|
|
315
|
+
self._entries = {}
|
|
316
|
+
self._charged = 0
|
|
317
|
+
|
|
318
|
+
def _rows(self, local):
|
|
319
|
+
return local if self._source_rows is None else self._source_rows[local]
|
|
320
|
+
|
|
321
|
+
def get(self, local):
|
|
322
|
+
rows = self._rows(local)
|
|
323
|
+
key = rows.tobytes()
|
|
324
|
+
hit = self._entries.get(key)
|
|
325
|
+
if hit is not None:
|
|
326
|
+
return hit[0]
|
|
327
|
+
entry = (_window_ld(self._block, rows), {})
|
|
328
|
+
cost = 2 * entry[0].nbytes + 16 * rows.size
|
|
329
|
+
if self._charged + cost <= _SPLIT_HALF_CACHE_BYTES:
|
|
330
|
+
self._entries[key] = (entry, cost)
|
|
331
|
+
self._charged += cost
|
|
332
|
+
return entry
|
|
333
|
+
|
|
334
|
+
def retain(self, tiles):
|
|
335
|
+
wanted = {self._rows(local).tobytes() for local in tiles}
|
|
336
|
+
for key in [key for key in self._entries if key not in wanted]:
|
|
337
|
+
self._charged -= self._entries.pop(key)[1]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _settle_block(task, *, z, window, threshold, eigenvalue_floor, eigh):
|
|
341
|
+
"""Screen one block through every round; return its own masks.
|
|
342
|
+
|
|
343
|
+
A block's schedule depends only on that block, its z-scores and its own
|
|
344
|
+
random streams, so serial and pooled runs agree by construction. Traits
|
|
345
|
+
whose survivors coincide share each window's LD and factors.
|
|
346
|
+
"""
|
|
347
|
+
block, idx, source_rows, round_seeds = task
|
|
348
|
+
n_traits = z.shape[0]
|
|
349
|
+
keep = np.ones((n_traits, idx.size), dtype=bool)
|
|
350
|
+
tested = np.zeros((n_traits, idx.size), dtype=bool)
|
|
351
|
+
zb = z[:, idx]
|
|
352
|
+
dropped = np.zeros((n_traits, len(round_seeds)), dtype=np.int64)
|
|
353
|
+
windows = _SplitHalfWindows(block, source_rows)
|
|
354
|
+
for round_no, round_seed in enumerate(round_seeds):
|
|
355
|
+
groups = _split_half_groups(keep)
|
|
356
|
+
n_before = keep.sum(axis=1)
|
|
357
|
+
for members in groups.values():
|
|
358
|
+
rng = np.random.default_rng(round_seed)
|
|
359
|
+
live = np.where(keep[members[0]])[0]
|
|
360
|
+
if live.size < MIN_WINDOW:
|
|
361
|
+
continue
|
|
362
|
+
for local in _split_half_tiles(live, window):
|
|
363
|
+
ld, shared = windows.get(local)
|
|
364
|
+
order = rng.permutation(local.size)
|
|
365
|
+
half = local.size // 2
|
|
366
|
+
first, second = order[:half], order[half:]
|
|
367
|
+
splits = [
|
|
368
|
+
(targets, predictors, _split_half_factor(
|
|
369
|
+
ld, predictors, targets, eigenvalue_floor, eigh))
|
|
370
|
+
for targets, predictors in ((first, second),
|
|
371
|
+
(second, first))]
|
|
372
|
+
for trait in members:
|
|
373
|
+
tested[trait, local] = True
|
|
374
|
+
zw = zb[trait, local]
|
|
375
|
+
flagged = []
|
|
376
|
+
for targets, predictors, factor in splits:
|
|
377
|
+
stat = _split_half_apply(factor, zw, predictors,
|
|
378
|
+
targets)
|
|
379
|
+
bad = targets[stat > threshold]
|
|
380
|
+
if bad.size:
|
|
381
|
+
flagged.append(bad)
|
|
382
|
+
if flagged:
|
|
383
|
+
candidates = np.unique(np.concatenate(flagged))
|
|
384
|
+
drop = _confirmed_drops(ld, zw, candidates, threshold,
|
|
385
|
+
shared=shared, eigh=eigh)
|
|
386
|
+
if drop.size:
|
|
387
|
+
keep[trait, local[drop]] = False
|
|
388
|
+
dropped[:, round_no] = n_before - keep.sum(axis=1)
|
|
389
|
+
if round_no + 1 < len(round_seeds):
|
|
390
|
+
windows.retain([
|
|
391
|
+
local for members in _split_half_groups(keep).values()
|
|
392
|
+
for local in _split_half_tiles(
|
|
393
|
+
np.where(keep[members[0]])[0], window)])
|
|
394
|
+
return keep, dropped, tested
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _validate_blocks(blocks, m, *, contiguous=False):
|
|
398
|
+
"""An exact, non-overlapping cover of ``0..m-1`` by ``(LD, idx)`` pairs."""
|
|
399
|
+
if m < 1 or not blocks:
|
|
400
|
+
raise ValueError("blocks must cover at least one variant")
|
|
401
|
+
seen = np.zeros(m, dtype=bool)
|
|
402
|
+
out = []
|
|
403
|
+
for item in blocks:
|
|
404
|
+
if not isinstance(item, (tuple, list)) or len(item) != 2:
|
|
405
|
+
raise ValueError("each LD block must be an (LD, index) pair")
|
|
406
|
+
corr, idx = item
|
|
407
|
+
idx = np.asarray(idx)
|
|
408
|
+
if idx.ndim != 1 or not np.issubdtype(idx.dtype, np.integer):
|
|
409
|
+
raise ValueError("each block index must be a 1-D integer array")
|
|
410
|
+
idx = idx.astype(np.int64, copy=False)
|
|
411
|
+
if idx.size == 0:
|
|
412
|
+
raise ValueError("LD blocks must not be empty")
|
|
413
|
+
if idx.min() < 0 or idx.max() >= m:
|
|
414
|
+
raise ValueError(f"block indices must lie in [0, {m})")
|
|
415
|
+
if seen[idx].any() or np.unique(idx).size != idx.size:
|
|
416
|
+
raise ValueError("LD blocks overlap or repeat an index")
|
|
417
|
+
if contiguous and idx.size > 1 and np.any(np.diff(idx) != 1):
|
|
418
|
+
raise ValueError("each LD block must cover consecutive indices")
|
|
419
|
+
seen[idx] = True
|
|
420
|
+
size = _block_size(corr)
|
|
421
|
+
if size is not None and size != idx.size:
|
|
422
|
+
raise ValueError(f"an LD block of size {size} has {idx.size} indices")
|
|
423
|
+
out.append((corr, idx))
|
|
424
|
+
if not seen.all():
|
|
425
|
+
raise ValueError("LD blocks do not cover every variant")
|
|
426
|
+
return out
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _progress_event(progress, step, done, total):
|
|
430
|
+
if progress is not None:
|
|
431
|
+
progress({"step": str(step), "done": int(done), "total": int(total),
|
|
432
|
+
"unit": "block"})
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _report_screen(keep, tested, per_round, total, *, label, verbose,
|
|
436
|
+
stats_out, warning_stacklevel):
|
|
437
|
+
"""One trait's counts, drop/untested warnings and verbose printout."""
|
|
438
|
+
n_tested = int(tested.sum())
|
|
439
|
+
n_untested = int((~tested).sum())
|
|
440
|
+
n_dropped = int((~keep).sum())
|
|
441
|
+
if stats_out is not None:
|
|
442
|
+
stats_out.update(n_tested=n_tested, n_untested=n_untested,
|
|
443
|
+
n_dropped=n_dropped)
|
|
444
|
+
if n_tested and (n_dropped / n_tested) > DEFAULT_DROP_FRACTION_WARN:
|
|
445
|
+
warnings.warn(
|
|
446
|
+
f"{label}{LD_SCREEN_HIGH_DROP} {n_dropped:,} of "
|
|
447
|
+
f"{n_tested:,} tested variants ({n_dropped / n_tested:.0%}). At "
|
|
448
|
+
"the default chi2 threshold the expected null rate is near zero; "
|
|
449
|
+
"rates of several percent usually mean the LD reference is "
|
|
450
|
+
"thresholded or indefinite (the eigenvalue floor and the leverage "
|
|
451
|
+
"then disagree). Inspect the reference before treating the mask "
|
|
452
|
+
"as QC.", RuntimeWarning, stacklevel=warning_stacklevel)
|
|
453
|
+
if n_untested and (n_tested == 0
|
|
454
|
+
or n_untested / total >= DEFAULT_UNTESTED_FRACTION_WARN):
|
|
455
|
+
warnings.warn(
|
|
456
|
+
f"{label}LD-consistency screen tested {n_tested:,} of {total:,} "
|
|
457
|
+
f"variants ({n_untested:,} {LD_SCREEN_UNTESTED} of size "
|
|
458
|
+
f"{MIN_WINDOW} and were kept unevaluated). A sparse panel can be "
|
|
459
|
+
"largely unscreened while still recording that a screen ran.",
|
|
460
|
+
RuntimeWarning, stacklevel=warning_stacklevel)
|
|
461
|
+
if verbose:
|
|
462
|
+
remaining = total
|
|
463
|
+
for round_no, count in enumerate(per_round):
|
|
464
|
+
remaining -= int(count)
|
|
465
|
+
print(f" {label}LD screen round {round_no + 1}: dropped "
|
|
466
|
+
f"{count:,}, {remaining:,} remain", flush=True)
|
|
467
|
+
extra = (f" ({n_untested:,} untested, kept without evaluation)"
|
|
468
|
+
if n_untested else "")
|
|
469
|
+
print(f" {label}LD screen tested {n_tested:,} of {total:,} "
|
|
470
|
+
f"variants{extra}", flush=True)
|
|
471
|
+
return {"n_tested": n_tested, "n_untested": n_untested,
|
|
472
|
+
"n_dropped": n_dropped, "per_round": [int(c) for c in per_round]}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _run_screen(specs, z, total, *, rounds, window, threshold,
|
|
476
|
+
eigenvalue_floor, seed, ncores, verbose, progress,
|
|
477
|
+
progress_label, stats_out, eigh, warning_stacklevel):
|
|
478
|
+
"""Validate controls and screen ``(block, idx, source_rows)`` specs."""
|
|
479
|
+
validate_boolean(verbose=verbose)
|
|
480
|
+
if progress is not None and not callable(progress):
|
|
481
|
+
raise TypeError(f"progress must be callable or None, got "
|
|
482
|
+
f"{type(progress).__name__}")
|
|
483
|
+
seed = validate_seed(seed)
|
|
484
|
+
rounds = integer_at_least("rounds", rounds, 1)
|
|
485
|
+
window = integer_at_least("window", window, MIN_WINDOW)
|
|
486
|
+
threshold = finite_control("threshold", threshold)
|
|
487
|
+
if threshold <= 0:
|
|
488
|
+
raise ValueError("threshold must be > 0")
|
|
489
|
+
eigenvalue_floor = finite_control("eigenvalue_floor", eigenvalue_floor,
|
|
490
|
+
lower=0.0)
|
|
491
|
+
if eigenvalue_floor >= 1:
|
|
492
|
+
raise ValueError("eigenvalue_floor must be < 1")
|
|
493
|
+
ncores = integer_at_least("ncores", ncores, 1)
|
|
494
|
+
eigh = _screen_eigh if eigh is None else eigh
|
|
495
|
+
|
|
496
|
+
single = np.ndim(z) == 1
|
|
497
|
+
if stats_out is not None and not single:
|
|
498
|
+
raise ValueError(
|
|
499
|
+
"stats_out records one trait; use return_stats=True for several "
|
|
500
|
+
"z columns")
|
|
501
|
+
zt = np.ascontiguousarray(
|
|
502
|
+
np.asarray(z, dtype=_WORK_DTYPE).reshape(total, -1).T)
|
|
503
|
+
# One independent stream per (block, round), derived in a fixed order.
|
|
504
|
+
root = np.random.SeedSequence(seed)
|
|
505
|
+
tasks = [(*spec, child.spawn(rounds))
|
|
506
|
+
for spec, child in zip(specs, root.spawn(len(specs)))]
|
|
507
|
+
settle = functools.partial(_settle_block, z=zt, window=window,
|
|
508
|
+
threshold=threshold,
|
|
509
|
+
eigenvalue_floor=eigenvalue_floor, eigh=eigh)
|
|
510
|
+
|
|
511
|
+
def _settled(source):
|
|
512
|
+
out = []
|
|
513
|
+
for done, item in enumerate(source, 1):
|
|
514
|
+
out.append(item)
|
|
515
|
+
_progress_event(progress, progress_label, done, len(tasks))
|
|
516
|
+
return out
|
|
517
|
+
|
|
518
|
+
if ncores > 1 and len(tasks) > 1:
|
|
519
|
+
# Blocks are independent; the caller is responsible for a BLAS that
|
|
520
|
+
# tolerates concurrent LAPACK calls (pin it to one thread).
|
|
521
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
522
|
+
with ThreadPoolExecutor(max_workers=ncores) as executor:
|
|
523
|
+
results = _settled(executor.map(settle, tasks))
|
|
524
|
+
else:
|
|
525
|
+
results = _settled(settle(task) for task in tasks)
|
|
526
|
+
|
|
527
|
+
n_traits = zt.shape[0]
|
|
528
|
+
keep = np.ones((n_traits, total), dtype=bool)
|
|
529
|
+
tested = np.zeros((n_traits, total), dtype=bool)
|
|
530
|
+
per_round = np.zeros((n_traits, rounds), dtype=np.int64)
|
|
531
|
+
for (block_keep, dropped, block_tested), spec in zip(results, specs):
|
|
532
|
+
idx = spec[1]
|
|
533
|
+
keep[:, idx] = block_keep
|
|
534
|
+
tested[:, idx] = block_tested
|
|
535
|
+
per_round += dropped
|
|
536
|
+
stats = []
|
|
537
|
+
for trait in range(n_traits):
|
|
538
|
+
stats.append(_report_screen(
|
|
539
|
+
keep[trait], tested[trait], per_round[trait], total,
|
|
540
|
+
label="" if single else f"z column {trait}: ", verbose=verbose,
|
|
541
|
+
stats_out=stats_out, warning_stacklevel=warning_stacklevel + 1))
|
|
542
|
+
return (keep[0] if single else keep.T), (stats[0] if single else stats)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _check_z(z, total):
|
|
546
|
+
z = np.asarray(z, dtype=np.float64)
|
|
547
|
+
if (z.ndim not in (1, 2) or z.shape[0] != total
|
|
548
|
+
or (z.ndim == 2 and z.shape[1] == 0)):
|
|
549
|
+
raise ValueError(
|
|
550
|
+
f"z has shape {z.shape}, but the blocks span {total} variants; "
|
|
551
|
+
"pass (m,) or (m, n_traits)")
|
|
552
|
+
if not np.all(np.isfinite(z)):
|
|
553
|
+
raise ValueError("z contains non-finite values; filter them first")
|
|
554
|
+
return z
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def ld_consistency_screen(blocks, z, *, rounds=DEFAULT_ROUNDS,
|
|
558
|
+
window=DEFAULT_WINDOW, threshold=DEFAULT_THRESHOLD,
|
|
559
|
+
eigenvalue_floor=DEFAULT_SPLIT_HALF_EIGENVALUE_FLOOR,
|
|
560
|
+
seed=0, ncores=1, verbose=False, progress=None,
|
|
561
|
+
progress_label="LD consistency screen",
|
|
562
|
+
return_stats=False, stats_out=None, _eigh=None,
|
|
563
|
+
_warning_stacklevel=2):
|
|
564
|
+
"""Keep-mask of variants whose z-scores are consistent with their LD.
|
|
565
|
+
|
|
566
|
+
Parameters
|
|
567
|
+
----------
|
|
568
|
+
blocks : list of (R, idx)
|
|
569
|
+
LD blocks whose indices partition ``0..m-1`` (see the module docs for
|
|
570
|
+
the accepted ``R`` forms). Use the LD the statistics will be fitted
|
|
571
|
+
or interpreted with.
|
|
572
|
+
z : array_like, shape (m,) or (m, n_traits)
|
|
573
|
+
``beta / se`` in block index order. Each column is screened on its own
|
|
574
|
+
schedule (bit-identical to screening it alone); several columns share
|
|
575
|
+
the costly window work while their survivors coincide.
|
|
576
|
+
rounds : int
|
|
577
|
+
Passes with fresh random splits; outliers are removed as found, so a
|
|
578
|
+
later pass can see a variant masked by a bad neighbour.
|
|
579
|
+
window, threshold, eigenvalue_floor, seed
|
|
580
|
+
Window size (variants), chi-square cut, pinv truncation and the seed
|
|
581
|
+
of the random splits.
|
|
582
|
+
ncores : int
|
|
583
|
+
Screen this many blocks concurrently. The mask equals ``ncores=1``
|
|
584
|
+
whatever the pool does, but the windows' LAPACK eigensolves then run
|
|
585
|
+
concurrently: use it only with a reentrant BLAS pinned to one thread.
|
|
586
|
+
progress : callable, optional
|
|
587
|
+
Called from this thread with ``{"step", "done", "total", "unit"}``
|
|
588
|
+
after each block.
|
|
589
|
+
return_stats : bool
|
|
590
|
+
Also return per-trait counts: ``n_tested``, ``n_untested`` (variants
|
|
591
|
+
in a block smaller than :data:`MIN_WINDOW`, kept unevaluated),
|
|
592
|
+
``n_dropped`` and ``per_round``.
|
|
593
|
+
stats_out : dict, optional
|
|
594
|
+
Filled in place with one trait's ``n_tested``, ``n_untested`` and
|
|
595
|
+
``n_dropped``. Several columns of ``z`` raise; use ``return_stats``
|
|
596
|
+
for the per-column list.
|
|
597
|
+
|
|
598
|
+
Returns
|
|
599
|
+
-------
|
|
600
|
+
keep : ndarray of bool shaped like ``z`` (and ``stats`` when requested)
|
|
601
|
+
High drop rates and large untested fractions emit RuntimeWarning.
|
|
602
|
+
"""
|
|
603
|
+
try:
|
|
604
|
+
blocks = list(blocks)
|
|
605
|
+
total = sum(len(idx) for _block, idx in blocks)
|
|
606
|
+
except (TypeError, ValueError):
|
|
607
|
+
raise ValueError("blocks must be a sequence of (LD, index) pairs") from None
|
|
608
|
+
z = _check_z(z, total)
|
|
609
|
+
blocks = _validate_blocks(blocks, total)
|
|
610
|
+
validate_boolean(return_stats=return_stats)
|
|
611
|
+
keep, stats = _run_screen(
|
|
612
|
+
[(block, idx, None) for block, idx in blocks], z, total,
|
|
613
|
+
rounds=rounds, window=window, threshold=threshold,
|
|
614
|
+
eigenvalue_floor=eigenvalue_floor, seed=seed, ncores=ncores,
|
|
615
|
+
verbose=verbose, progress=progress, progress_label=progress_label,
|
|
616
|
+
stats_out=stats_out, eigh=_eigh,
|
|
617
|
+
warning_stacklevel=_warning_stacklevel + 1)
|
|
618
|
+
return (keep, stats) if return_stats else keep
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def ld_consistency_screen_selected(blocks, selection, z, *,
|
|
622
|
+
rounds=DEFAULT_ROUNDS,
|
|
623
|
+
window=DEFAULT_WINDOW,
|
|
624
|
+
threshold=DEFAULT_THRESHOLD,
|
|
625
|
+
eigenvalue_floor=DEFAULT_SPLIT_HALF_EIGENVALUE_FLOOR,
|
|
626
|
+
seed=0, ncores=1, verbose=False,
|
|
627
|
+
progress=None,
|
|
628
|
+
progress_label="LD consistency screen",
|
|
629
|
+
return_stats=False, stats_out=None,
|
|
630
|
+
_eigh=None, _warning_stacklevel=2):
|
|
631
|
+
"""Screen the variants ``selection`` of contiguous blocks, in place.
|
|
632
|
+
|
|
633
|
+
``blocks`` tile ``0..M-1`` contiguously (a whole LD cache); ``selection``
|
|
634
|
+
is a strictly increasing index vector into that space and ``z`` is
|
|
635
|
+
aligned to it. Only window-sized submatrices of each source block are
|
|
636
|
+
formed, so the selected panel is never materialised. The result, seed
|
|
637
|
+
streams and progress events equal subsetting the blocks first and then
|
|
638
|
+
calling :func:`ld_consistency_screen`. ``stats_out``, when given, is
|
|
639
|
+
filled with ``n_tested``, ``n_untested`` and ``n_dropped``; ``z`` here is
|
|
640
|
+
one column, so that dict is the whole report.
|
|
641
|
+
"""
|
|
642
|
+
try:
|
|
643
|
+
source = list(blocks)
|
|
644
|
+
source_total = sum(len(idx) for _block, idx in source)
|
|
645
|
+
except (TypeError, ValueError):
|
|
646
|
+
raise ValueError("blocks must be a sequence of (LD, index) pairs") from None
|
|
647
|
+
source = _validate_blocks(source, source_total, contiguous=True)
|
|
648
|
+
expected = 0
|
|
649
|
+
for _block, idx in source:
|
|
650
|
+
if int(idx[0]) != expected:
|
|
651
|
+
raise ValueError("LD blocks must tile contiguous indices 0..m-1")
|
|
652
|
+
expected += int(idx.size)
|
|
653
|
+
values = np.asarray(selection)
|
|
654
|
+
if (values.ndim != 1 or not np.issubdtype(values.dtype, np.integer)
|
|
655
|
+
or np.issubdtype(values.dtype, np.bool_)):
|
|
656
|
+
raise ValueError("selection must be a one-dimensional integer array")
|
|
657
|
+
selection = values.astype(np.int64, copy=False)
|
|
658
|
+
if selection.size == 0:
|
|
659
|
+
raise ValueError("selection contains no variants")
|
|
660
|
+
if selection[0] < 0 or selection[-1] >= source_total:
|
|
661
|
+
raise IndexError(f"selection indices must lie in [0, {source_total})")
|
|
662
|
+
if selection.size > 1 and np.any(np.diff(selection) <= 0):
|
|
663
|
+
raise ValueError("selection must be strictly increasing and unique")
|
|
664
|
+
z = np.asarray(z, dtype=np.float64)
|
|
665
|
+
if z.shape != (selection.size,):
|
|
666
|
+
raise ValueError(f"z has shape {z.shape}, but selection spans "
|
|
667
|
+
f"{selection.size} variants")
|
|
668
|
+
if not np.all(np.isfinite(z)):
|
|
669
|
+
raise ValueError("z contains non-finite values; filter them first")
|
|
670
|
+
validate_boolean(return_stats=return_stats)
|
|
671
|
+
specs = []
|
|
672
|
+
out_start = 0
|
|
673
|
+
for block, idx in source:
|
|
674
|
+
start, stop = int(idx[0]), int(idx[-1]) + 1
|
|
675
|
+
left = int(np.searchsorted(selection, start, side="left"))
|
|
676
|
+
right = int(np.searchsorted(selection, stop, side="left"))
|
|
677
|
+
if left == right:
|
|
678
|
+
continue
|
|
679
|
+
source_rows = selection[left:right] - start
|
|
680
|
+
count = int(source_rows.size)
|
|
681
|
+
specs.append((block, np.arange(out_start, out_start + count,
|
|
682
|
+
dtype=np.int64), source_rows))
|
|
683
|
+
out_start += count
|
|
684
|
+
if out_start != selection.size:
|
|
685
|
+
raise ValueError("LD blocks do not cover every selected variant")
|
|
686
|
+
keep, stats = _run_screen(
|
|
687
|
+
specs, z, int(selection.size), rounds=rounds, window=window,
|
|
688
|
+
threshold=threshold, eigenvalue_floor=eigenvalue_floor, seed=seed,
|
|
689
|
+
ncores=ncores, verbose=verbose, progress=progress,
|
|
690
|
+
progress_label=progress_label, stats_out=stats_out, eigh=_eigh,
|
|
691
|
+
warning_stacklevel=_warning_stacklevel + 1)
|
|
692
|
+
return (keep, stats) if return_stats else keep
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def dense_blocks(R, sizes):
|
|
696
|
+
"""Cut a dense correlation matrix into diagonal blocks of ``sizes``."""
|
|
697
|
+
R = np.asarray(R)
|
|
698
|
+
sizes = [int(s) for s in sizes]
|
|
699
|
+
if R.ndim != 2 or R.shape[0] != R.shape[1] or sum(sizes) != R.shape[0]:
|
|
700
|
+
raise ValueError("sizes must partition a square matrix")
|
|
701
|
+
out, start = [], 0
|
|
702
|
+
for s in sizes:
|
|
703
|
+
out.append((R[start:start + s, start:start + s],
|
|
704
|
+
np.arange(start, start + s, dtype=np.int64)))
|
|
705
|
+
start += s
|
|
706
|
+
return out
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def chromosome_blocks(chrom, *, max_size=2000):
|
|
710
|
+
"""Block sizes for a panel sorted by chromosome and position.
|
|
711
|
+
|
|
712
|
+
Each chromosome run is split into the fewest near-equal blocks of at most
|
|
713
|
+
``max_size`` variants. Fixed-size blocks are adequate for a QC screen; use
|
|
714
|
+
published LD-block boundaries when blocks must be near-independent.
|
|
715
|
+
"""
|
|
716
|
+
from .alleles import normalize_chrom
|
|
717
|
+
max_size = integer_at_least("max_size", max_size, MIN_WINDOW)
|
|
718
|
+
labels = [normalize_chrom(c) for c in np.asarray(chrom, dtype=object)]
|
|
719
|
+
sizes = []
|
|
720
|
+
seen = set()
|
|
721
|
+
i = 0
|
|
722
|
+
while i < len(labels):
|
|
723
|
+
j = i
|
|
724
|
+
while j < len(labels) and labels[j] == labels[i]:
|
|
725
|
+
j += 1
|
|
726
|
+
if labels[i] in seen:
|
|
727
|
+
raise ValueError(f"chromosome {labels[i]} is not contiguous; sort "
|
|
728
|
+
"the panel by chromosome first")
|
|
729
|
+
seen.add(labels[i])
|
|
730
|
+
n = j - i
|
|
731
|
+
k = -(-n // max_size)
|
|
732
|
+
base, extra = divmod(n, k)
|
|
733
|
+
sizes += [base + 1] * extra + [base] * (k - extra)
|
|
734
|
+
i = j
|
|
735
|
+
return sizes
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def ld_blocks_from_genotypes(genotypes, sizes, *, dtype=np.float32):
|
|
739
|
+
"""Correlation blocks from a reference genotype matrix.
|
|
740
|
+
|
|
741
|
+
``genotypes`` is ``(n_samples, m)``: allele counts with ``-1`` or NaN for
|
|
742
|
+
missing calls (mean-imputed per variant). Returns ``[(R, idx), ...]`` in
|
|
743
|
+
``dtype``; monomorphic variants get zero off-diagonal correlation.
|
|
744
|
+
"""
|
|
745
|
+
G = np.asarray(genotypes)
|
|
746
|
+
if G.ndim != 2:
|
|
747
|
+
raise ValueError("genotypes must be (n_samples, n_variants)")
|
|
748
|
+
sizes = [int(s) for s in sizes]
|
|
749
|
+
if sum(sizes) != G.shape[1]:
|
|
750
|
+
raise ValueError("sizes must sum to the number of variants")
|
|
751
|
+
out, start = [], 0
|
|
752
|
+
for s in sizes:
|
|
753
|
+
X = np.asarray(G[:, start:start + s], dtype=np.float64)
|
|
754
|
+
missing = ~np.isfinite(X) | (X < 0)
|
|
755
|
+
X = np.where(missing, np.nan, X)
|
|
756
|
+
mean = np.nanmean(X, axis=0)
|
|
757
|
+
mean = np.where(np.isfinite(mean), mean, 0.0)
|
|
758
|
+
X = np.where(missing, mean, X) - mean
|
|
759
|
+
# Constant fractional dosages can leave a centering residue. Detect
|
|
760
|
+
# their exact called range before that residue is normalized into LD.
|
|
761
|
+
varies = (np.min(X, axis=0, where=~missing, initial=np.inf)
|
|
762
|
+
< np.max(X, axis=0, where=~missing, initial=-np.inf))
|
|
763
|
+
X[:, ~varies] = 0.0
|
|
764
|
+
sd = np.sqrt((X * X).mean(axis=0))
|
|
765
|
+
X = np.divide(X, sd, out=np.zeros_like(X), where=sd > 0)
|
|
766
|
+
R = (X.T @ X) / X.shape[0]
|
|
767
|
+
R[np.diag_indices(s)] = 1.0
|
|
768
|
+
out.append((R.astype(dtype),
|
|
769
|
+
np.arange(start, start + s, dtype=np.int64)))
|
|
770
|
+
start += s
|
|
771
|
+
return out
|