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/status.py ADDED
@@ -0,0 +1,130 @@
1
+ """Per-variant terminal QC status: one code per input row.
2
+
3
+ Every filter and the harmoniser can stamp a caller-allocated ``int8`` array with
4
+ the reason a row was dropped (0 = kept). The codes are the vocabulary of
5
+ ldpred3's ``--qc-out`` table, kept numerically identical so tables written by
6
+ either package can be read by the other's consumers.
7
+
8
+ * file-level filters (:func:`allelign.qc.qc_sumstats`): codes 1-7;
9
+ * harmonisation (:func:`allelign.align.harmonize`): codes 8-13;
10
+ * reference-dependent checks: 14-20 (16-19 are
11
+ :func:`~allelign.qc.af_concordance_mask` and
12
+ :func:`~allelign.qc.sd_consistency_mask`, 20 the LD-consistency screen);
13
+ * 21: identifier re-anchoring could not anchor the row.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+
20
+ from ._common import atomic_text
21
+
22
+ __all__ = ["QC_REASON_NAMES", "new_status", "status_counts",
23
+ "write_status_table", "STATUS_HEADER"]
24
+
25
+ NONFINITE = 1 # beta/se non-finite, se <= 0, or N non-finite
26
+ NONPOSITIVE_N = 2 # N finite and <= 0 (beta/se valid)
27
+ DUPLICATE = 3 # id (or chrom:pos:alleles) appears more than once
28
+ LOW_N = 4 # n_eff < min_n_ratio * anchor
29
+ LOW_MAF = 5 # minor allele frequency below the floor
30
+ LOW_INFO = 6 # imputation INFO below the floor
31
+ CHISQ_OUTLIER = 7 # (beta/se)^2 above max_chisq
32
+ UNMATCHED = 8 # no reference row at this id or coordinate
33
+ AMBIGUOUS_ID = 9 # multi-locus id, coordinates do not resolve it
34
+ AMBIGUOUS_ALLELES = 10 # palindromic allele pair
35
+ DUPLICATE_MATCH = 11 # every candidate reference row already matched
36
+ ALLELE_MISMATCH = 12 # no orientation maps (ea, oa) onto (a1, a2)
37
+ NONFINITE_BETA_SE = 13 # matched, but beta/se non-finite
38
+ ABSENT_LD_CACHE = 14 # harmonised, but absent from the LD cache
39
+ LD_REFERENCE_INTERSECT = 15 # outside the target and LD-reference intersection
40
+ AF_DIFF = 16 # GWAS/reference allele-frequency gap too large
41
+ SD_LOW = 17 # sumstats-implied dosage SD too small
42
+ SD_HIGH = 18 # sumstats-implied dosage SD too large
43
+ SD_UNTESTABLE = 19 # non-finite SD or near-monomorphic reference
44
+ LD_INCONSISTENT = 20 # LD-consistency (DENTIST-style) outlier
45
+ REANCHOR_DROPPED = 21 # identifier not held once by the reference
46
+
47
+ #: Code -> reason name. 0 is "no drop". Names match ldpred3's table.
48
+ QC_REASON_NAMES = {
49
+ 0: "",
50
+ NONFINITE: "nonfinite",
51
+ NONPOSITIVE_N: "nonpositive_n",
52
+ DUPLICATE: "duplicate",
53
+ LOW_N: "low_n",
54
+ LOW_MAF: "low_maf",
55
+ LOW_INFO: "low_info",
56
+ CHISQ_OUTLIER: "chisq_outlier",
57
+ UNMATCHED: "unmatched",
58
+ AMBIGUOUS_ID: "ambiguous_id",
59
+ AMBIGUOUS_ALLELES: "ambiguous_alleles",
60
+ DUPLICATE_MATCH: "duplicate_match",
61
+ ALLELE_MISMATCH: "allele_mismatch",
62
+ NONFINITE_BETA_SE: "nonfinite_beta_se",
63
+ ABSENT_LD_CACHE: "absent_ld_cache",
64
+ LD_REFERENCE_INTERSECT: "ld_reference_intersect",
65
+ AF_DIFF: "af_diff",
66
+ SD_LOW: "sd_low",
67
+ SD_HIGH: "sd_high",
68
+ SD_UNTESTABLE: "sd_untestable",
69
+ LD_INCONSISTENT: "dentist_outlier",
70
+ REANCHOR_DROPPED: "reanchor_dropped",
71
+ }
72
+
73
+ #: Columns of the status table. ``EA``/``OA`` are the alleles as read.
74
+ STATUS_HEADER = ("ID", "CHR", "POS", "EA", "OA", "STATUS", "REASON")
75
+
76
+
77
+ def new_status(n):
78
+ """A zeroed ``int8`` status array for ``n`` input rows."""
79
+ return np.zeros(int(n), dtype=np.int8)
80
+
81
+
82
+ def check_status(status, n):
83
+ """Validate a caller-allocated status array; returns it or ``None``."""
84
+ if status is None:
85
+ return None
86
+ status = np.asarray(status)
87
+ if status.shape != (n,) or status.dtype != np.int8:
88
+ raise ValueError(f"status must be an int8 array of length {n}")
89
+ return status
90
+
91
+
92
+ def mark(status, code, bad):
93
+ """Stamp rows that are still unmarked and fail ``bad`` with ``code``."""
94
+ if status is not None:
95
+ status[(status == 0) & np.asarray(bad, dtype=bool)] = code
96
+
97
+
98
+ def status_counts(codes):
99
+ """``{reason name or 'kept': count}`` over a status array."""
100
+ codes = np.asarray(codes)
101
+ out = {}
102
+ values, counts = np.unique(codes, return_counts=True)
103
+ for value, count in zip(values, counts):
104
+ name = QC_REASON_NAMES.get(int(value))
105
+ if name is None:
106
+ raise ValueError(f"unknown QC status code {int(value)}")
107
+ out[name or "kept"] = int(count)
108
+ return out
109
+
110
+
111
+ def write_status_table(path, *, id, chrom, pos, ea, oa, codes):
112
+ """Write one TSV row per input variant with its terminal status.
113
+
114
+ Columns ``ID CHR POS EA OA STATUS REASON`` in input order; ``STATUS`` is
115
+ ``kept`` or ``dropped`` and ``REASON`` names the stage that dropped the
116
+ row. Published atomically; a ``.gz`` path is compressed. Returns ``path``.
117
+ """
118
+ n = len(np.asarray(codes))
119
+ for name, values in {"id": id, "chrom": chrom, "pos": pos, "ea": ea,
120
+ "oa": oa}.items():
121
+ if len(np.asarray(values)) != n:
122
+ raise ValueError(f"{name} must have one value per input row ({n})")
123
+ with atomic_text(path) as fh:
124
+ fh.write("\t".join(STATUS_HEADER) + "\n")
125
+ for vid, c, p, a1, a2, code in zip(id, chrom, pos, ea, oa, codes):
126
+ code = int(code)
127
+ status = "kept" if code == 0 else "dropped"
128
+ reason = QC_REASON_NAMES[code] if code else "."
129
+ fh.write(f"{vid}\t{c}\t{int(p)}\t{a1}\t{a2}\t{status}\t{reason}\n")
130
+ return path