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