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/alleles.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Allele and chromosome primitives shared by every module.
|
|
2
|
+
|
|
3
|
+
The rules here decide whether two records describe the same variant and which
|
|
4
|
+
way round its effect points, so they are kept small, explicit and in one place.
|
|
5
|
+
Ported unchanged from ldpred3's harmonisation module.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = ["normalize_chrom", "reverse_complement", "is_ambiguous",
|
|
11
|
+
"allele_orientation", "CHROM_ALIASES", "AUTOSOMES"]
|
|
12
|
+
|
|
13
|
+
_COMP = {"A": "T", "T": "A", "C": "G", "G": "C"}
|
|
14
|
+
|
|
15
|
+
#: PLINK numeric sex/mitochondrial codes and spellings -> canonical labels.
|
|
16
|
+
#: ``25`` (PLINK's pseudoautosomal code) and ``XY`` both map to ``X``: PAR
|
|
17
|
+
#: variants sit on X coordinates in PLINK filesets while GWAS summary
|
|
18
|
+
#: statistics label the same loci X, and matching is by (normalised chrom,
|
|
19
|
+
#: pos), so a separate XY label left every PAR variant silently unmatched.
|
|
20
|
+
CHROM_ALIASES = {"23": "X", "24": "Y", "25": "X", "26": "MT", "M": "MT",
|
|
21
|
+
"XY": "X"}
|
|
22
|
+
|
|
23
|
+
AUTOSOMES = frozenset(str(i) for i in range(1, 23))
|
|
24
|
+
|
|
25
|
+
_PALINDROMIC = frozenset((("A", "T"), ("T", "A"), ("C", "G"), ("G", "C")))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize_chrom(label):
|
|
29
|
+
"""Canonical chromosome label.
|
|
30
|
+
|
|
31
|
+
Drops a ``chr`` prefix (any case) and zero padding (``01`` -> ``1``), and
|
|
32
|
+
maps sex/mitochondrial codes (``23``/``25``/``XY`` -> ``X``, ``24`` -> ``Y``,
|
|
33
|
+
``26``/``M`` -> ``MT``). A ``chr1``/``1`` or ``X``/``23`` labelling clash is
|
|
34
|
+
one of the commonest reasons a real-data run silently matches nothing.
|
|
35
|
+
Sumstats that label PAR loci ``Y`` are not supported.
|
|
36
|
+
"""
|
|
37
|
+
s = str(label).strip()
|
|
38
|
+
if s[:3].lower() == "chr":
|
|
39
|
+
s = s[3:]
|
|
40
|
+
s = s.upper()
|
|
41
|
+
if s.isdecimal():
|
|
42
|
+
s = str(int(s))
|
|
43
|
+
return CHROM_ALIASES.get(s, s)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def reverse_complement(allele):
|
|
47
|
+
"""Reverse complement of a (single- or multi-base) allele; ``None`` if not ACGT."""
|
|
48
|
+
try:
|
|
49
|
+
return "".join(_COMP[b] for b in reversed(allele))
|
|
50
|
+
except KeyError:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_ambiguous(ea, oa):
|
|
55
|
+
"""True when a strand change exchanges the two alleles.
|
|
56
|
+
|
|
57
|
+
Such a pair cannot be oriented from alleles alone, because changing strand
|
|
58
|
+
also changes the effect sign. The single-base A/T and C/G case is the
|
|
59
|
+
common one; the general rule also catches multi-base pairs such as an
|
|
60
|
+
indel ``AA``/``TT``. Alleles that are individually self reverse
|
|
61
|
+
complementary (``AT``/``CG``) are safe: a strand change preserves their
|
|
62
|
+
order and therefore the sign. Inputs are expected upper-cased.
|
|
63
|
+
"""
|
|
64
|
+
if (ea, oa) in _PALINDROMIC:
|
|
65
|
+
return True
|
|
66
|
+
if len(ea) == 1 and len(oa) == 1:
|
|
67
|
+
return False
|
|
68
|
+
cea, coa = reverse_complement(ea), reverse_complement(oa)
|
|
69
|
+
return cea is not None and coa is not None and cea == oa and coa == ea
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def allele_orientation(ea, oa, a1, a2):
|
|
73
|
+
"""How ``(ea, oa)`` maps onto ``(a1, a2)``.
|
|
74
|
+
|
|
75
|
+
Returns ``(flip, strand)``: ``flip`` is True when the effect must be
|
|
76
|
+
negated to count ``a1``, ``strand`` is True when the match needed the
|
|
77
|
+
reverse complement. ``(None, False)`` means the allele pairs disagree.
|
|
78
|
+
Inputs are expected upper-cased.
|
|
79
|
+
"""
|
|
80
|
+
if ea == a1 and oa == a2:
|
|
81
|
+
return False, False
|
|
82
|
+
if ea == a2 and oa == a1:
|
|
83
|
+
return True, False
|
|
84
|
+
cea, coa = reverse_complement(ea), reverse_complement(oa)
|
|
85
|
+
if cea is not None and coa is not None:
|
|
86
|
+
if cea == a1 and coa == a2:
|
|
87
|
+
return False, True
|
|
88
|
+
if cea == a2 and coa == a1:
|
|
89
|
+
return True, True
|
|
90
|
+
return None, False
|
allelign/build.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Genome-build labels and the declared-build check.
|
|
2
|
+
|
|
3
|
+
A build mismatch does not look like an error: a GRCh38 file against a GRCh37
|
|
4
|
+
reference keeps about 1% of variants by coordinate and still yields numbers.
|
|
5
|
+
Declaring both builds turns that into a one-line refusal naming the cause.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = ["normalize_build", "check_build", "BuildMismatchError", "BUILDS"]
|
|
11
|
+
|
|
12
|
+
BUILDS = ("GRCh37", "GRCh38")
|
|
13
|
+
|
|
14
|
+
_ALIASES = {
|
|
15
|
+
"grch37": "GRCh37", "hg19": "GRCh37", "b37": "GRCh37", "build37": "GRCh37",
|
|
16
|
+
"37": "GRCh37", "hs37d5": "GRCh37",
|
|
17
|
+
"grch38": "GRCh38", "hg38": "GRCh38", "b38": "GRCh38", "build38": "GRCh38",
|
|
18
|
+
"38": "GRCh38",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BuildMismatchError(ValueError):
|
|
23
|
+
"""Two inputs are declared on different genome builds."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize_build(value, *, name="genome build"):
|
|
27
|
+
"""``'GRCh37'`` or ``'GRCh38'`` for any common spelling; ``None`` stays ``None``."""
|
|
28
|
+
if value is None:
|
|
29
|
+
return None
|
|
30
|
+
key = str(value).strip().lower()
|
|
31
|
+
try:
|
|
32
|
+
return _ALIASES[key]
|
|
33
|
+
except KeyError:
|
|
34
|
+
raise ValueError(f"unknown {name} {value!r}; use GRCh37 (hg19, b37) "
|
|
35
|
+
"or GRCh38 (hg38, b38)") from None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_build(declared, reference, *, what="summary statistics",
|
|
39
|
+
against="the reference"):
|
|
40
|
+
"""Refuse inputs declared on different builds.
|
|
41
|
+
|
|
42
|
+
Returns ``{"declared", "reference", "status"}`` with status ``match`` or
|
|
43
|
+
``unchecked`` (either side unknown), and raises
|
|
44
|
+
:class:`BuildMismatchError` on a disagreement, naming the remedies.
|
|
45
|
+
"""
|
|
46
|
+
declared = normalize_build(declared, name=f"{what} build")
|
|
47
|
+
reference = normalize_build(reference, name="reference build")
|
|
48
|
+
if declared is None or reference is None:
|
|
49
|
+
return {"declared": declared, "reference": reference,
|
|
50
|
+
"status": "unchecked"}
|
|
51
|
+
if declared != reference:
|
|
52
|
+
raise BuildMismatchError(
|
|
53
|
+
f"{what} are declared {declared} but {against} is {reference}. "
|
|
54
|
+
f"Lift the statistics over to {reference} (UCSC liftOver, CrossMap "
|
|
55
|
+
"or bcftools +liftover, keeping the chain and failed mappings), or "
|
|
56
|
+
"re-anchor on identifiers (allelign.reanchor_on_identifier).")
|
|
57
|
+
return {"declared": declared, "reference": reference, "status": "match"}
|
allelign/catalog.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Header rules for streamed GWAS Catalog normalisation.
|
|
2
|
+
|
|
3
|
+
This deposit profile preserves the Catalog consumers' column priorities and
|
|
4
|
+
output names. It is deliberately narrower than the general sumstats reader:
|
|
5
|
+
``n`` here records reported sample size, not an inferred effective N; the
|
|
6
|
+
consumer must apply its study-specific sample-size policy.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__all__ = ["resolve_gwas_catalog_columns"]
|
|
10
|
+
|
|
11
|
+
_ALIASES = {
|
|
12
|
+
"rsid": ("hm_rsid", "rsid", "rs_id", "variant_id"),
|
|
13
|
+
"chrom": ("hm_chrom", "chromosome", "chr"),
|
|
14
|
+
"pos": ("hm_pos", "base_pair_location", "position"),
|
|
15
|
+
"effect_allele": ("hm_effect_allele", "effect_allele"),
|
|
16
|
+
"other_allele": ("hm_other_allele", "other_allele"),
|
|
17
|
+
"beta": ("hm_beta", "beta"),
|
|
18
|
+
"se": ("standard_error", "se", "standarderror"),
|
|
19
|
+
"eaf": ("hm_effect_allele_frequency", "effect_allele_frequency", "eaf"),
|
|
20
|
+
"pval": ("p_value", "pvalue", "p"),
|
|
21
|
+
"n": ("n", "sample_size", "n_total"),
|
|
22
|
+
}
|
|
23
|
+
_ODDS = ("hm_odds_ratio", "odds_ratio")
|
|
24
|
+
# A z-score with a standard error IS an effect: beta = z * se, sign included.
|
|
25
|
+
_ZSCORE = ("hm_z_score", "z_score", "zscore", "z")
|
|
26
|
+
_REQUIRED = ("rsid", "effect_allele", "other_allele", "se")
|
|
27
|
+
|
|
28
|
+
def resolve_gwas_catalog_columns(fieldnames, extra_aliases=None):
|
|
29
|
+
"""Map normalised names onto whichever harmonised layout this file uses.
|
|
30
|
+
|
|
31
|
+
``extra_aliases`` adds provider-scoped source names per target, tried
|
|
32
|
+
before the Catalog aliases. Returns ``(mapping, odds, zscore)`` using
|
|
33
|
+
the original header spellings; the latter two columns supply per-row
|
|
34
|
+
fallback effects. Alleles, effects (including fallbacks) and frequency
|
|
35
|
+
come from one orientation: harmonised when a complete set is available,
|
|
36
|
+
submitted otherwise. Provider allele/effect aliases choose their own
|
|
37
|
+
orientation first. This is header-only: consumers keep their streaming
|
|
38
|
+
row filters and their sample-size provenance.
|
|
39
|
+
"""
|
|
40
|
+
have = {}
|
|
41
|
+
for field in (fieldnames or []):
|
|
42
|
+
have.setdefault(field.strip().lower(), field.strip())
|
|
43
|
+
aliases = dict(_ALIASES)
|
|
44
|
+
for target, names in (extra_aliases or {}).items():
|
|
45
|
+
# Provider names FIRST (only providers pass extras), so a generic
|
|
46
|
+
# column (``beta`` beside ``all_inv_var_meta_beta``) cannot override
|
|
47
|
+
# the explicit mapping.
|
|
48
|
+
aliases[target] = tuple(names) + tuple(aliases.get(target, ()))
|
|
49
|
+
mapping = {}
|
|
50
|
+
for target, names in aliases.items():
|
|
51
|
+
for alias in names:
|
|
52
|
+
if alias in have:
|
|
53
|
+
mapping[target] = have[alias]
|
|
54
|
+
break
|
|
55
|
+
# A finite submitted beta must not override a harmonised OR, nor may a
|
|
56
|
+
# row with missing hm_beta fall back to a submitted OR or z-score.
|
|
57
|
+
directional = ("effect_allele", "other_allele", "beta", "eaf")
|
|
58
|
+
provider_modes = {
|
|
59
|
+
mapping[target].lower().startswith("hm_")
|
|
60
|
+
for target in directional[:3]
|
|
61
|
+
if target in mapping and mapping[target].lower()
|
|
62
|
+
in (extra_aliases or {}).get(target, ())}
|
|
63
|
+
if len(provider_modes) > 1:
|
|
64
|
+
raise ValueError("provider aliases mix submitted and harmonised effects/alleles")
|
|
65
|
+
modes = (next(iter(provider_modes)),) if provider_modes else (True, False)
|
|
66
|
+
chosen, odds, zscore = {}, None, None
|
|
67
|
+
for harmonised in modes:
|
|
68
|
+
def pick(names):
|
|
69
|
+
return next((have[name] for name in names if name in have
|
|
70
|
+
and name.startswith("hm_") == harmonised), None)
|
|
71
|
+
|
|
72
|
+
candidate = {target: pick(aliases[target]) for target in directional}
|
|
73
|
+
candidate_odds, candidate_z = pick(_ODDS), pick(_ZSCORE)
|
|
74
|
+
if (candidate["effect_allele"] and candidate["other_allele"]
|
|
75
|
+
and (candidate["beta"] or candidate_odds or candidate_z)):
|
|
76
|
+
chosen = {target: name for target, name in candidate.items() if name}
|
|
77
|
+
odds, zscore = candidate_odds, candidate_z
|
|
78
|
+
break
|
|
79
|
+
for target in directional:
|
|
80
|
+
mapping.pop(target, None)
|
|
81
|
+
mapping.update(chosen)
|
|
82
|
+
missing = [c for c in _REQUIRED if c not in mapping]
|
|
83
|
+
if "beta" not in mapping and odds is None and zscore is None:
|
|
84
|
+
missing.append("beta/odds_ratio/z_score")
|
|
85
|
+
if missing:
|
|
86
|
+
raise ValueError(f"unmappable columns {missing}; header={fieldnames}")
|
|
87
|
+
return mapping, odds, zscore
|
|
88
|
+
|
allelign/cli.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Command-line interface: ``allelign inspect | clean | align | pgs-export | download``.
|
|
2
|
+
|
|
3
|
+
``align --ld-screen`` adds the LD-consistency screen, with LD computed from the
|
|
4
|
+
reference fileset's own genotypes (``.bed``/``.fam`` beside the ``.bim``).
|
|
5
|
+
``download`` fetches a published summary-statistics or scoring file by its
|
|
6
|
+
source reference (GWAS Catalog accession, ``FINNGEN:R11:I9_AF`` and the
|
|
7
|
+
other providers in :mod:`allelign.download`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from . import status as _st
|
|
20
|
+
from ._common import atomic_text
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _columns(pairs):
|
|
24
|
+
out = {}
|
|
25
|
+
for item in pairs or []:
|
|
26
|
+
if "=" not in item:
|
|
27
|
+
raise SystemExit(f"allelign: --column expects FIELD=COLUMN, got {item!r}")
|
|
28
|
+
field, col = item.split("=", 1)
|
|
29
|
+
out[field.strip()] = col.strip()
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _read_kwargs(args):
|
|
34
|
+
kwargs = _columns(args.column)
|
|
35
|
+
if args.n_eff is not None:
|
|
36
|
+
kwargs["n_eff"] = args.n_eff
|
|
37
|
+
if args.n_cases is not None or args.n_controls is not None:
|
|
38
|
+
kwargs["n_cases"] = args.n_cases
|
|
39
|
+
kwargs["n_controls"] = args.n_controls
|
|
40
|
+
return kwargs
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read(args):
|
|
44
|
+
from .sumstats import read_sumstats
|
|
45
|
+
return read_sumstats(args.sumstats, **_read_kwargs(args))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _qc(args, ss, status):
|
|
49
|
+
from .qc import qc_sumstats
|
|
50
|
+
if args.no_qc:
|
|
51
|
+
return np.ones(len(ss), dtype=bool), {"skipped": True}
|
|
52
|
+
return qc_sumstats(ss, min_n_ratio=args.min_n_ratio, min_maf=args.min_maf,
|
|
53
|
+
min_info=args.min_info, max_chisq=args.max_chisq,
|
|
54
|
+
drop_duplicates=not args.keep_duplicates, status=status)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _write_log(out, log):
|
|
58
|
+
with atomic_text(f"{out}.log.json") as fh:
|
|
59
|
+
json.dump(log, fh, indent=2, sort_keys=True, default=str)
|
|
60
|
+
fh.write("\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _write_status(path, ss, status):
|
|
64
|
+
_st.write_status_table(path, id=ss.id, chrom=ss.chrom, pos=ss.pos,
|
|
65
|
+
ea=ss.ea, oa=ss.oa, codes=status)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_inspect(args):
|
|
69
|
+
from .sumstats import inspect_columns
|
|
70
|
+
info = inspect_columns(args.sumstats, **_read_kwargs(args))
|
|
71
|
+
print(json.dumps(info, indent=2))
|
|
72
|
+
return 1 if info["missing"] else 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_clean(args):
|
|
76
|
+
ss = _read(args)
|
|
77
|
+
status = _st.new_status(len(ss))
|
|
78
|
+
keep, qc_log = _qc(args, ss, status)
|
|
79
|
+
out = ss.subset(keep)
|
|
80
|
+
out.write(args.out)
|
|
81
|
+
if args.status_out:
|
|
82
|
+
_write_status(args.status_out, ss, status)
|
|
83
|
+
_write_log(args.out, {"allelign": __version__, "parse": ss.parse_log,
|
|
84
|
+
"qc": qc_log, "status": _st.status_counts(status)})
|
|
85
|
+
print(f"allelign: kept {len(out):,} of {len(ss):,} rows -> {args.out}",
|
|
86
|
+
file=sys.stderr)
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_align(args):
|
|
91
|
+
from .build import BuildMismatchError, check_build, normalize_build
|
|
92
|
+
from .align import (coverage_message, harmonize, reanchor_on_identifier,
|
|
93
|
+
reference_coverage)
|
|
94
|
+
from .qc import gwas_af_on_counted_allele
|
|
95
|
+
from .sumstats import Sumstats
|
|
96
|
+
from .variants import read_variants
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
build = check_build(args.sumstats_build, args.reference_build)
|
|
100
|
+
except BuildMismatchError:
|
|
101
|
+
if not args.reanchor_on_identifier:
|
|
102
|
+
raise
|
|
103
|
+
build = {"declared": normalize_build(args.sumstats_build),
|
|
104
|
+
"reference": normalize_build(args.reference_build),
|
|
105
|
+
"status": "reanchored"}
|
|
106
|
+
build["output"] = (build["reference"] if args.reanchor_on_identifier
|
|
107
|
+
else build["reference"] or build["declared"])
|
|
108
|
+
ref = read_variants(args.reference).freeze()
|
|
109
|
+
ss = _read(args)
|
|
110
|
+
status = _st.new_status(len(ss))
|
|
111
|
+
keep, qc_log = _qc(args, ss, status)
|
|
112
|
+
rows = np.flatnonzero(keep)
|
|
113
|
+
sub = ss.subset(keep)
|
|
114
|
+
log = {"allelign": __version__, "parse": ss.parse_log, "qc": qc_log,
|
|
115
|
+
"build": build}
|
|
116
|
+
if args.reanchor_on_identifier:
|
|
117
|
+
sub, log["reanchor"], rkeep = reanchor_on_identifier(
|
|
118
|
+
sub, ref, return_keep=True)
|
|
119
|
+
status[rows[~rkeep]] = _st.REANCHOR_DROPPED
|
|
120
|
+
rows = rows[rkeep]
|
|
121
|
+
sub_status = _st.new_status(len(sub)) if args.status_out else None
|
|
122
|
+
h = harmonize(sub, ref, drop_ambiguous=not args.keep_ambiguous,
|
|
123
|
+
status=sub_status)
|
|
124
|
+
if sub_status is not None:
|
|
125
|
+
# Per-row harmonisation reasons (the row loop) only when a status
|
|
126
|
+
# table was asked for; otherwise the harmonize log has the counts.
|
|
127
|
+
status[rows] = np.where(status[rows] == 0, sub_status, status[rows])
|
|
128
|
+
log["harmonize"] = h.log
|
|
129
|
+
coverage = reference_coverage(sub, ref, h)
|
|
130
|
+
log["coverage"] = coverage
|
|
131
|
+
message = coverage_message(coverage, label=str(args.sumstats))
|
|
132
|
+
if message:
|
|
133
|
+
print(f"allelign: {message}", file=sys.stderr)
|
|
134
|
+
passed = np.ones(len(h), dtype=bool)
|
|
135
|
+
if args.ld_screen:
|
|
136
|
+
passed, log["ld_screen"] = _ld_screen(args, ref, h)
|
|
137
|
+
status[rows[h.src_index[~passed]]] = _st.LD_INCONSISTENT
|
|
138
|
+
vi, src = h.var_index[passed], h.src_index[passed]
|
|
139
|
+
aligned = Sumstats(
|
|
140
|
+
id=np.asarray(ref.id, dtype=object)[vi],
|
|
141
|
+
chrom=np.asarray(ref.chrom, dtype=object)[vi],
|
|
142
|
+
pos=np.asarray(ref.pos, dtype=np.int64)[vi],
|
|
143
|
+
ea=np.asarray(ref.a1, dtype=object)[vi],
|
|
144
|
+
oa=np.asarray(ref.a2, dtype=object)[vi],
|
|
145
|
+
beta=h.beta[passed], se=h.se[passed], n_eff=h.n_eff[passed],
|
|
146
|
+
eaf=gwas_af_on_counted_allele(sub.eaf, src, h.flipped[passed]),
|
|
147
|
+
info=np.asarray(sub.info, dtype=float)[src])
|
|
148
|
+
aligned.write(args.out)
|
|
149
|
+
if args.status_out:
|
|
150
|
+
_write_status(args.status_out, ss, status)
|
|
151
|
+
log["status"] = _st.status_counts(status)
|
|
152
|
+
else:
|
|
153
|
+
log["status_before_harmonize"] = _st.status_counts(status)
|
|
154
|
+
_write_log(args.out, log)
|
|
155
|
+
print(f"allelign: aligned {len(aligned):,} of {len(ss):,} rows to "
|
|
156
|
+
f"{len(ref):,} reference variants -> {args.out}", file=sys.stderr)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _ld_screen(args, ref, h):
|
|
161
|
+
"""Keep-mask over the aligned rows, and its log, from the reference genotypes."""
|
|
162
|
+
from .genotypes import (Bed, ld_consistency_screen_genotypes,
|
|
163
|
+
read_sample_list)
|
|
164
|
+
if not str(args.reference).endswith(".bim"):
|
|
165
|
+
raise ValueError("--ld-screen needs a PLINK 1 --reference (.bim with "
|
|
166
|
+
".bed and .fam beside it)")
|
|
167
|
+
samples = read_sample_list(args.ld_keep) if args.ld_keep else None
|
|
168
|
+
bed = Bed(args.reference, variants=ref, samples=samples)
|
|
169
|
+
options = {"rounds": args.ld_rounds, "threshold": args.ld_threshold}
|
|
170
|
+
keep, stats = ld_consistency_screen_genotypes(
|
|
171
|
+
bed, h.var_index, h.beta / h.se, max_block=args.ld_block_size,
|
|
172
|
+
seed=args.seed, return_stats=True, **options)
|
|
173
|
+
stats.update(options, n_reference_samples=bed.n_samples,
|
|
174
|
+
max_block=args.ld_block_size, seed=args.seed)
|
|
175
|
+
print(f"allelign: LD screen dropped {stats['n_dropped']:,} of "
|
|
176
|
+
f"{stats['n_tested']:,} tested variants ({bed.n_samples:,} "
|
|
177
|
+
"reference samples)", file=sys.stderr)
|
|
178
|
+
return keep, stats
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def cmd_pgs_export(args):
|
|
182
|
+
from .weights import read_weights, write_pgs_scoring_file
|
|
183
|
+
wt = read_weights(args.weights, sd_source=args.sd_source)
|
|
184
|
+
write_pgs_scoring_file(args.out, wt, genome_build=args.genome_build,
|
|
185
|
+
pgs_id=args.pgs_id, trait_reported=args.trait)
|
|
186
|
+
print(f"allelign: wrote {len(wt):,} variants -> {args.out}", file=sys.stderr)
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def cmd_download(args):
|
|
191
|
+
from .download import download_ref, read_keep_ids, resolve_ref
|
|
192
|
+
if args.dry_run:
|
|
193
|
+
plan = resolve_ref(args.ref, build=args.build)
|
|
194
|
+
print(json.dumps(plan, indent=2, sort_keys=True, default=str))
|
|
195
|
+
return 0
|
|
196
|
+
keep_ids = read_keep_ids(args.keep_ids) if args.keep_ids else None
|
|
197
|
+
record = download_ref(args.ref, args.out, build=args.build,
|
|
198
|
+
keep_ids=keep_ids, meta=not args.no_meta)
|
|
199
|
+
kept = f", kept {record['n_kept']:,} of {record['n_input']:,} rows" \
|
|
200
|
+
if "n_kept" in record else ""
|
|
201
|
+
print(f"allelign: downloaded {record['label']} -> {args.out} "
|
|
202
|
+
f"({record['n_bytes_written']:,} bytes{kept})", file=sys.stderr)
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _add_read_options(p):
|
|
207
|
+
p.add_argument("sumstats", help="summary-statistics file (.gz accepted)")
|
|
208
|
+
p.add_argument("--column", action="append", metavar="FIELD=COLUMN",
|
|
209
|
+
help="force a field to a column (name or 0-based index)")
|
|
210
|
+
p.add_argument("--n-eff", type=float, default=None,
|
|
211
|
+
help="constant effective N for every row")
|
|
212
|
+
p.add_argument("--n-cases", type=float, default=None)
|
|
213
|
+
p.add_argument("--n-controls", type=float, default=None)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _add_qc_options(p):
|
|
217
|
+
p.add_argument("--no-qc", action="store_true", help="skip file-level QC")
|
|
218
|
+
p.add_argument("--min-n-ratio", type=float, default=0.7)
|
|
219
|
+
p.add_argument("--min-maf", type=float, default=0.01)
|
|
220
|
+
p.add_argument("--min-info", type=float, default=0.7)
|
|
221
|
+
p.add_argument("--max-chisq", type=float, default=None)
|
|
222
|
+
p.add_argument("--keep-duplicates", action="store_true")
|
|
223
|
+
p.add_argument("--status-out", default=None,
|
|
224
|
+
help="per-input-row status table (TSV, .gz accepted)")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _ld_default(name):
|
|
228
|
+
from . import ldcheck
|
|
229
|
+
return getattr(ldcheck, name)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def build_parser():
|
|
233
|
+
ap = argparse.ArgumentParser(
|
|
234
|
+
prog="allelign",
|
|
235
|
+
description="Read, align and QC GWAS summary statistics and PGS weights.")
|
|
236
|
+
ap.add_argument("--version", action="version", version=f"allelign {__version__}")
|
|
237
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
238
|
+
|
|
239
|
+
p = sub.add_parser("inspect", help="show the detected column mapping")
|
|
240
|
+
_add_read_options(p)
|
|
241
|
+
p.set_defaults(func=cmd_inspect)
|
|
242
|
+
|
|
243
|
+
p = sub.add_parser("clean", help="parse, QC and write the canonical table")
|
|
244
|
+
_add_read_options(p)
|
|
245
|
+
_add_qc_options(p)
|
|
246
|
+
p.add_argument("-o", "--out", required=True)
|
|
247
|
+
p.set_defaults(func=cmd_clean)
|
|
248
|
+
|
|
249
|
+
p = sub.add_parser("align", help="parse, QC and align to a .bim/.pvar")
|
|
250
|
+
_add_read_options(p)
|
|
251
|
+
_add_qc_options(p)
|
|
252
|
+
p.add_argument("--reference", required=True, help=".bim or .pvar file")
|
|
253
|
+
p.add_argument("-o", "--out", required=True)
|
|
254
|
+
p.add_argument("--keep-ambiguous", action="store_true",
|
|
255
|
+
help="keep strand-ambiguous (A/T, C/G) variants")
|
|
256
|
+
p.add_argument("--reanchor-on-identifier", action="store_true",
|
|
257
|
+
help="take coordinates from the reference's rsIDs")
|
|
258
|
+
p.add_argument("--sumstats-build", default=None)
|
|
259
|
+
p.add_argument("--reference-build", default=None)
|
|
260
|
+
g = p.add_argument_group(
|
|
261
|
+
"LD-consistency screen",
|
|
262
|
+
"drop variants whose z-score disagrees with its neighbours' given the "
|
|
263
|
+
"reference LD. A reference of a few hundred samples flags more "
|
|
264
|
+
"variants than one of thousands; see docs/guide.md.")
|
|
265
|
+
g.add_argument("--ld-screen", action="store_true",
|
|
266
|
+
help="screen with LD from the --reference .bed")
|
|
267
|
+
g.add_argument("--ld-keep", default=None, metavar="FILE",
|
|
268
|
+
help="reference samples to use for LD (FID IID or IID per "
|
|
269
|
+
"line), e.g. the one ancestry matching the GWAS")
|
|
270
|
+
g.add_argument("--ld-block-size", type=int, default=2000,
|
|
271
|
+
help="maximum variants per LD block (default 2000)")
|
|
272
|
+
g.add_argument("--ld-rounds", type=int, default=_ld_default("DEFAULT_ROUNDS"))
|
|
273
|
+
g.add_argument("--ld-threshold", type=float,
|
|
274
|
+
default=_ld_default("DEFAULT_THRESHOLD"),
|
|
275
|
+
help="chi-square cut for an outlier")
|
|
276
|
+
g.add_argument("--seed", type=int, default=0)
|
|
277
|
+
p.set_defaults(func=cmd_align)
|
|
278
|
+
|
|
279
|
+
p = sub.add_parser("pgs-export",
|
|
280
|
+
help="ldpred3 weights -> PGS Catalog scoring file")
|
|
281
|
+
p.add_argument("weights")
|
|
282
|
+
p.add_argument("-o", "--out", required=True)
|
|
283
|
+
p.add_argument("--genome-build", required=True, choices=("GRCh37", "GRCh38"))
|
|
284
|
+
p.add_argument("--pgs-id", default=None)
|
|
285
|
+
p.add_argument("--trait", default=None)
|
|
286
|
+
p.add_argument("--sd-source", choices=("empirical", "hwe"), default=None)
|
|
287
|
+
p.set_defaults(func=cmd_pgs_export)
|
|
288
|
+
|
|
289
|
+
p = sub.add_parser(
|
|
290
|
+
"download", help="fetch published sumstats or a PGS score by reference",
|
|
291
|
+
description="Resolve a source reference (GWAS Catalog GCST accession, "
|
|
292
|
+
"FINNGEN:R11:I9_AF, COVIDHGI:R7:C2, GBMI:ASTHMA:EUR, "
|
|
293
|
+
"EQTL:QTS000001:QTD000001:ENSG00000008128 or a PGS id) "
|
|
294
|
+
"and download it with a <out>.meta.json provenance "
|
|
295
|
+
"sidecar.")
|
|
296
|
+
p.add_argument("ref")
|
|
297
|
+
p.add_argument("-o", "--out", required=True)
|
|
298
|
+
p.add_argument("--build", choices=("GRCh37", "GRCh38"), default=None,
|
|
299
|
+
help="preferred harmonised build for a PGS score "
|
|
300
|
+
"(default GRCh37, then GRCh38, then the submitted "
|
|
301
|
+
"file)")
|
|
302
|
+
p.add_argument("--keep-ids", default=None, metavar="FILE",
|
|
303
|
+
help="rsID list, .bim or .pvar: stream only matching rows "
|
|
304
|
+
"(comma-joined rsids match on any part)")
|
|
305
|
+
p.add_argument("--no-meta", action="store_true",
|
|
306
|
+
help="do not write the <out>.meta.json sidecar")
|
|
307
|
+
p.add_argument("--dry-run", action="store_true",
|
|
308
|
+
help="resolve and print the plan without downloading")
|
|
309
|
+
p.set_defaults(func=cmd_download)
|
|
310
|
+
return ap
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def main(argv=None):
|
|
314
|
+
args = build_parser().parse_args(argv)
|
|
315
|
+
try:
|
|
316
|
+
return args.func(args)
|
|
317
|
+
except (ValueError, OSError) as error:
|
|
318
|
+
print(f"allelign: error: {error}", file=sys.stderr)
|
|
319
|
+
return 2
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__": # pragma: no cover
|
|
323
|
+
sys.exit(main())
|