gwas-sumstats-parser 0.5.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.
- gwas_sumstats_parser/__init__.py +33 -0
- gwas_sumstats_parser/_version.py +1 -0
- gwas_sumstats_parser/assembly.py +463 -0
- gwas_sumstats_parser/burgess.py +139 -0
- gwas_sumstats_parser/case_control.py +270 -0
- gwas_sumstats_parser/constants.py +90 -0
- gwas_sumstats_parser/coordinates.py +277 -0
- gwas_sumstats_parser/effects.py +166 -0
- gwas_sumstats_parser/errors.py +44 -0
- gwas_sumstats_parser/example_data/__init__.py +0 -0
- gwas_sumstats_parser/example_data/example_datasets/string_data.txt +1 -0
- gwas_sumstats_parser/example_data/examples.py +270 -0
- gwas_sumstats_parser/frame.py +336 -0
- gwas_sumstats_parser/frequencies.py +261 -0
- gwas_sumstats_parser/intervals.py +300 -0
- gwas_sumstats_parser/log.py +274 -0
- gwas_sumstats_parser/opening.py +369 -0
- gwas_sumstats_parser/parsers.py +222 -0
- gwas_sumstats_parser/probe_data/__init__.py +1 -0
- gwas_sumstats_parser/probe_data/assembly_probes.tsv +113 -0
- gwas_sumstats_parser/reader.py +1748 -0
- gwas_sumstats_parser/resolver.py +166 -0
- gwas_sumstats_parser/sample_counts.py +164 -0
- gwas_sumstats_parser/stats.py +321 -0
- gwas_sumstats_parser/vocabulary.py +155 -0
- gwas_sumstats_parser-0.5.0.dist-info/METADATA +801 -0
- gwas_sumstats_parser-0.5.0.dist-info/RECORD +30 -0
- gwas_sumstats_parser-0.5.0.dist-info/WHEEL +5 -0
- gwas_sumstats_parser-0.5.0.dist-info/licenses/LICENSE.txt +674 -0
- gwas_sumstats_parser-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Shared vocabulary, parsing and standardisation for GWAS summary-statistics
|
|
2
|
+
files.
|
|
3
|
+
|
|
4
|
+
This package is the common ingest layer consumed by both ``merit`` and
|
|
5
|
+
``gwas-norm``. It owns the column vocabulary, the value parsers and the
|
|
6
|
+
route table that says which quantity is derived from which columns; the
|
|
7
|
+
consumers own everything downstream of a standardised row.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
# The "as" form marks this as a deliberate re-export, so callers can read
|
|
13
|
+
# gwas_sumstats_parser.__version__ while the literal lives only in _version.py
|
|
14
|
+
from ._version import __version__ as __version__
|
|
15
|
+
from .assembly import AssemblyResult, check_assembly, load_probe_table
|
|
16
|
+
from .reader import DroppingReader, Reader
|
|
17
|
+
from .errors import ParseError
|
|
18
|
+
from .vocabulary import COLUMNS, Column
|
|
19
|
+
|
|
20
|
+
# Import examples into the main package
|
|
21
|
+
from .example_data import examples
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AssemblyResult",
|
|
25
|
+
"COLUMNS",
|
|
26
|
+
"Column",
|
|
27
|
+
"DroppingReader",
|
|
28
|
+
"ParseError",
|
|
29
|
+
"Reader",
|
|
30
|
+
"check_assembly",
|
|
31
|
+
"examples",
|
|
32
|
+
"load_probe_table",
|
|
33
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Check which of two genome builds an indexed file's coordinates follow.
|
|
2
|
+
|
|
3
|
+
The check is standalone: parsing never invokes it. It looks up a table of
|
|
4
|
+
probe variants, each with a position in build A and in build B, through the
|
|
5
|
+
reader's indexed region query, and reports how many probes it found at each
|
|
6
|
+
build's position together with a decision that may be undecided.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from os import PathLike
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .errors import ParseError
|
|
19
|
+
from .opening import detect_index, open_source
|
|
20
|
+
from .parsers import (
|
|
21
|
+
compile_allele_pattern,
|
|
22
|
+
parse_allele,
|
|
23
|
+
parse_chromosome,
|
|
24
|
+
parse_number,
|
|
25
|
+
parse_position,
|
|
26
|
+
)
|
|
27
|
+
from .reader import DroppingReader, Region
|
|
28
|
+
|
|
29
|
+
SPECIES_KEY = "#species"
|
|
30
|
+
BUILDS_KEY = "#builds"
|
|
31
|
+
PROBE_COLUMNS = (
|
|
32
|
+
"probe_id",
|
|
33
|
+
"chr_a",
|
|
34
|
+
"pos_a",
|
|
35
|
+
"chr_b",
|
|
36
|
+
"pos_b",
|
|
37
|
+
"ref",
|
|
38
|
+
"alt",
|
|
39
|
+
"coverage_score",
|
|
40
|
+
)
|
|
41
|
+
BUNDLED_PROBES = ("probe_data", "assembly_probes.tsv")
|
|
42
|
+
UNKNOWN = "unknown"
|
|
43
|
+
AMBIGUOUS = "ambiguous"
|
|
44
|
+
# Reader settings the check decides itself (specification 001, A-008): a
|
|
45
|
+
# probe match needs both alleles, and the widest alphabet loses no row.
|
|
46
|
+
FIXED_READER_SETTINGS: dict[str, object] = {
|
|
47
|
+
"allele_level": "iupac_indel",
|
|
48
|
+
"require_other_allele": True,
|
|
49
|
+
}
|
|
50
|
+
_PROBE_ALLELE_PATTERN = compile_allele_pattern("iupac_indel")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Probe:
|
|
55
|
+
"""One probe variant with its position in each of the two builds.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
probe_id : str
|
|
60
|
+
Identifier of the probe.
|
|
61
|
+
chr_a, chr_b : str
|
|
62
|
+
Chromosome in build A and in build B, as written in the table.
|
|
63
|
+
pos_a, pos_b : int
|
|
64
|
+
One-based position in build A and in build B.
|
|
65
|
+
ref, alt : str
|
|
66
|
+
The two alleles; matching ignores which is which.
|
|
67
|
+
coverage_score : float
|
|
68
|
+
How commonly the probe appears in files; carried, not used in scoring.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
probe_id: str
|
|
72
|
+
chr_a: str
|
|
73
|
+
pos_a: int
|
|
74
|
+
chr_b: str
|
|
75
|
+
pos_b: int
|
|
76
|
+
ref: str
|
|
77
|
+
alt: str
|
|
78
|
+
coverage_score: float
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class ProbeTable:
|
|
83
|
+
"""A species, its two builds in A then B order, and the probes.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
species : str
|
|
88
|
+
Species label from the table's species comment.
|
|
89
|
+
builds : tuple[str, str]
|
|
90
|
+
Build names from the table's builds comment, A first.
|
|
91
|
+
probes : tuple[Probe, ...]
|
|
92
|
+
Probe records in file order.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
species: str
|
|
96
|
+
builds: tuple[str, str]
|
|
97
|
+
probes: tuple[Probe, ...]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class AssemblyResult:
|
|
102
|
+
"""Evidence and decision of one assembly check.
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
species : str
|
|
107
|
+
Species label of the probe table used.
|
|
108
|
+
builds : tuple[str, str]
|
|
109
|
+
The two build names examined, A first.
|
|
110
|
+
decision : str
|
|
111
|
+
One of the two build names, ``"ambiguous"`` when both builds score
|
|
112
|
+
well and too close together, or ``"unknown"`` when too few probes
|
|
113
|
+
were found or neither build scores well enough.
|
|
114
|
+
hits : dict[str, int]
|
|
115
|
+
Per build, the probes found at that build's position with the
|
|
116
|
+
probe's two alleles.
|
|
117
|
+
scores : dict[str, float]
|
|
118
|
+
Per build, its hits divided by the examined count; zero when nothing
|
|
119
|
+
was examined.
|
|
120
|
+
examined : int
|
|
121
|
+
Probes found at their position in either build, whatever the alleles.
|
|
122
|
+
failed_rows : int
|
|
123
|
+
Fetched rows that failed to parse and were skipped as no evidence.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
species: str
|
|
127
|
+
builds: tuple[str, str]
|
|
128
|
+
decision: str
|
|
129
|
+
hits: dict[str, int]
|
|
130
|
+
scores: dict[str, float]
|
|
131
|
+
examined: int
|
|
132
|
+
failed_rows: int
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
136
|
+
def load_probe_table(path: str | PathLike[str] | None = None) -> ProbeTable:
|
|
137
|
+
"""Read and validate a probe table; without a path, the bundled human one.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
path : str or PathLike[str] or None
|
|
142
|
+
Tab-separated probe table. ``None`` reads the human GRCh37/GRCh38
|
|
143
|
+
table shipped inside the package.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
ProbeTable
|
|
148
|
+
Species, builds and probes.
|
|
149
|
+
|
|
150
|
+
Raises
|
|
151
|
+
------
|
|
152
|
+
ParseError
|
|
153
|
+
If the species or builds comment is absent or malformed, a required
|
|
154
|
+
column is absent, there are no probes, or a cell is invalid. A
|
|
155
|
+
supplied table never falls back to the human labels.
|
|
156
|
+
|
|
157
|
+
Notes
|
|
158
|
+
-----
|
|
159
|
+
The first line is ``#species``, a tab, and the species. The second is
|
|
160
|
+
``#builds``, a tab, and exactly two different build names separated by a
|
|
161
|
+
tab, A then B. The third is a header holding at least ``probe_id``,
|
|
162
|
+
``chr_a``, ``pos_a``, ``chr_b``, ``pos_b``, ``ref``, ``alt`` and
|
|
163
|
+
``coverage_score``, in any order. Alleles use the widest allele alphabet,
|
|
164
|
+
the one the check reads file rows with.
|
|
165
|
+
"""
|
|
166
|
+
if path is None:
|
|
167
|
+
text = (
|
|
168
|
+
resources.files(__package__).joinpath(*BUNDLED_PROBES).read_text()
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
text = Path(path).read_text()
|
|
172
|
+
lines = text.splitlines()
|
|
173
|
+
species = _comment_values(lines, 0, SPECIES_KEY)
|
|
174
|
+
if len(species) != 1:
|
|
175
|
+
raise ParseError(
|
|
176
|
+
"probe table must begin with a #species comment naming one species",
|
|
177
|
+
error_func=load_probe_table,
|
|
178
|
+
error_value=lines[0] if lines else "",
|
|
179
|
+
)
|
|
180
|
+
builds = _comment_values(lines, 1, BUILDS_KEY)
|
|
181
|
+
if len(builds) != 2 or builds[0] == builds[1]:
|
|
182
|
+
raise ParseError(
|
|
183
|
+
"probe table needs a #builds comment naming exactly two builds",
|
|
184
|
+
error_func=load_probe_table,
|
|
185
|
+
error_value=lines[1] if len(lines) > 1 else "",
|
|
186
|
+
)
|
|
187
|
+
records = csv.DictReader(lines[2:], delimiter="\t")
|
|
188
|
+
header = records.fieldnames or []
|
|
189
|
+
for name in PROBE_COLUMNS:
|
|
190
|
+
if name not in header:
|
|
191
|
+
raise ParseError(
|
|
192
|
+
"probe table lacks a required column",
|
|
193
|
+
error_func=load_probe_table,
|
|
194
|
+
error_value=name,
|
|
195
|
+
)
|
|
196
|
+
probes = tuple(_probe(record) for record in records)
|
|
197
|
+
if not probes:
|
|
198
|
+
raise ParseError(
|
|
199
|
+
"probe table holds no probes",
|
|
200
|
+
error_func=load_probe_table,
|
|
201
|
+
error_value=path,
|
|
202
|
+
)
|
|
203
|
+
return ProbeTable(species[0], (builds[0], builds[1]), probes)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
207
|
+
def _comment_values(lines: list[str], index: int, key: str) -> list[str]:
|
|
208
|
+
"""Return the non-empty values of the comment expected on one line.
|
|
209
|
+
|
|
210
|
+
An absent line, or a line that does not start with the key, gives an
|
|
211
|
+
empty list, which no caller accepts.
|
|
212
|
+
"""
|
|
213
|
+
if index >= len(lines):
|
|
214
|
+
return []
|
|
215
|
+
cells = lines[index].split("\t")
|
|
216
|
+
if cells[0] != key:
|
|
217
|
+
return []
|
|
218
|
+
return [cell.strip() for cell in cells[1:] if cell.strip()]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
222
|
+
def _probe(record: dict[str, str | None]) -> Probe:
|
|
223
|
+
"""Convert one table record, reporting the first invalid cell."""
|
|
224
|
+
for name in PROBE_COLUMNS:
|
|
225
|
+
if record[name] is None:
|
|
226
|
+
raise ParseError(
|
|
227
|
+
"probe record has too few cells",
|
|
228
|
+
error_func=load_probe_table,
|
|
229
|
+
error_value=name,
|
|
230
|
+
)
|
|
231
|
+
cells: dict[str, Any] = record
|
|
232
|
+
return Probe(
|
|
233
|
+
probe_id=cells["probe_id"].strip(),
|
|
234
|
+
chr_a=cells["chr_a"].strip(),
|
|
235
|
+
pos_a=parse_position(cells["pos_a"]),
|
|
236
|
+
chr_b=cells["chr_b"].strip(),
|
|
237
|
+
pos_b=parse_position(cells["pos_b"]),
|
|
238
|
+
ref=parse_allele(cells["ref"], _PROBE_ALLELE_PATTERN),
|
|
239
|
+
alt=parse_allele(cells["alt"], _PROBE_ALLELE_PATTERN),
|
|
240
|
+
coverage_score=float(parse_number(cells["coverage_score"])),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
245
|
+
def score_assembly(
|
|
246
|
+
species: str,
|
|
247
|
+
builds: tuple[str, str],
|
|
248
|
+
hits: tuple[int, int],
|
|
249
|
+
examined: int,
|
|
250
|
+
*,
|
|
251
|
+
failed_rows: int = 0,
|
|
252
|
+
match_threshold: float = 0.8,
|
|
253
|
+
confidence_delta_threshold: float = 0.1,
|
|
254
|
+
min_examined: int = 5,
|
|
255
|
+
) -> AssemblyResult:
|
|
256
|
+
"""Turn hit counts into per-build scores and a decision.
|
|
257
|
+
|
|
258
|
+
Parameters
|
|
259
|
+
----------
|
|
260
|
+
species : str
|
|
261
|
+
Species label to report.
|
|
262
|
+
builds : tuple[str, str]
|
|
263
|
+
Build names, A first.
|
|
264
|
+
hits : tuple[int, int]
|
|
265
|
+
Probes matched at the build A position and at the build B position.
|
|
266
|
+
examined : int
|
|
267
|
+
Probes found at their position in either build.
|
|
268
|
+
failed_rows : int
|
|
269
|
+
Fetched rows that failed to parse, reported unchanged.
|
|
270
|
+
match_threshold : float
|
|
271
|
+
Minimum score of the better build.
|
|
272
|
+
confidence_delta_threshold : float
|
|
273
|
+
Minimum difference between the two scores.
|
|
274
|
+
min_examined : int
|
|
275
|
+
Minimum examined probes.
|
|
276
|
+
|
|
277
|
+
Returns
|
|
278
|
+
-------
|
|
279
|
+
AssemblyResult
|
|
280
|
+
Evidence and decision.
|
|
281
|
+
|
|
282
|
+
Notes
|
|
283
|
+
-----
|
|
284
|
+
The decision is ``"unknown"`` when ``examined`` is below its minimum or
|
|
285
|
+
the better score is below its minimum; otherwise ``"ambiguous"`` when the
|
|
286
|
+
score difference is below its minimum; otherwise the better build. A
|
|
287
|
+
value equal to its minimum passes. The difference is the difference of
|
|
288
|
+
the hit counts divided by ``examined``, which is exact where subtracting
|
|
289
|
+
the two rounded scores is not. When the scores are level and the minimum
|
|
290
|
+
difference is zero, build B is returned.
|
|
291
|
+
"""
|
|
292
|
+
hits_a, hits_b = hits
|
|
293
|
+
if examined > 0:
|
|
294
|
+
score_a = hits_a / examined
|
|
295
|
+
score_b = hits_b / examined
|
|
296
|
+
difference = abs(hits_a - hits_b) / examined
|
|
297
|
+
else:
|
|
298
|
+
score_a = score_b = difference = 0.0
|
|
299
|
+
if examined < min_examined or max(score_a, score_b) < match_threshold:
|
|
300
|
+
decision = UNKNOWN
|
|
301
|
+
elif difference < confidence_delta_threshold:
|
|
302
|
+
decision = AMBIGUOUS
|
|
303
|
+
elif hits_a > hits_b:
|
|
304
|
+
decision = builds[0]
|
|
305
|
+
else:
|
|
306
|
+
decision = builds[1]
|
|
307
|
+
return AssemblyResult(
|
|
308
|
+
species=species,
|
|
309
|
+
builds=builds,
|
|
310
|
+
decision=decision,
|
|
311
|
+
hits={builds[0]: hits_a, builds[1]: hits_b},
|
|
312
|
+
scores={builds[0]: score_a, builds[1]: score_b},
|
|
313
|
+
examined=examined,
|
|
314
|
+
failed_rows=failed_rows,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
319
|
+
def check_assembly(
|
|
320
|
+
path: str | PathLike[str],
|
|
321
|
+
mapping: dict[str, str],
|
|
322
|
+
*,
|
|
323
|
+
effect_type: str,
|
|
324
|
+
probes: str | PathLike[str] | None = None,
|
|
325
|
+
match_threshold: float = 0.8,
|
|
326
|
+
confidence_delta_threshold: float = 0.1,
|
|
327
|
+
min_examined: int = 5,
|
|
328
|
+
**reader_settings: Any,
|
|
329
|
+
) -> AssemblyResult:
|
|
330
|
+
"""Check an indexed file against the two builds of a probe table.
|
|
331
|
+
|
|
332
|
+
Parameters
|
|
333
|
+
----------
|
|
334
|
+
path : str or PathLike[str]
|
|
335
|
+
Block-compressed file with a neighbouring tabix index.
|
|
336
|
+
mapping : dict[str, str]
|
|
337
|
+
Vocabulary names mapped to source headers, as for the reader. The
|
|
338
|
+
reader's required quantities apply, and the other allele is always
|
|
339
|
+
required.
|
|
340
|
+
effect_type : str
|
|
341
|
+
Interpretation of the effect column, as for the reader.
|
|
342
|
+
probes : str or PathLike[str] or None
|
|
343
|
+
Probe table for any species; ``None`` uses the bundled human
|
|
344
|
+
GRCh37/GRCh38 table. See :func:`load_probe_table` for the format.
|
|
345
|
+
match_threshold : float
|
|
346
|
+
Minimum score of the better build.
|
|
347
|
+
confidence_delta_threshold : float
|
|
348
|
+
Minimum difference between the two scores.
|
|
349
|
+
min_examined : int
|
|
350
|
+
Minimum probes found at their position in either build.
|
|
351
|
+
**reader_settings : object
|
|
352
|
+
Further reader settings describing the file, such as
|
|
353
|
+
``logged_pvalue``, ``chrpos_spec`` or ``chromosome_synonyms``, handed
|
|
354
|
+
unchanged to the reader. ``allele_level`` and
|
|
355
|
+
``require_other_allele`` are fixed by the check and refused.
|
|
356
|
+
|
|
357
|
+
Returns
|
|
358
|
+
-------
|
|
359
|
+
AssemblyResult
|
|
360
|
+
Species, builds, decision, per-build hits and scores, the examined
|
|
361
|
+
count and the number of rows skipped because they failed to parse.
|
|
362
|
+
|
|
363
|
+
Raises
|
|
364
|
+
------
|
|
365
|
+
ParseError
|
|
366
|
+
If a fixed reader setting is supplied, the probe table is invalid,
|
|
367
|
+
the file has no index, or the reader refuses the configuration.
|
|
368
|
+
|
|
369
|
+
Notes
|
|
370
|
+
-----
|
|
371
|
+
Rows are read with the widest allele alphabet and unusable rows are
|
|
372
|
+
dropped, so one malformed row never prevents a decision; it counts as no
|
|
373
|
+
evidence and is reported in ``failed_rows``. Probe and file chromosomes
|
|
374
|
+
are normalised alike, so ``chr1`` and ``1`` agree. A probe whose
|
|
375
|
+
chromosome the supplied synonyms reject is not looked up. See
|
|
376
|
+
:func:`score_assembly` for the decision rule.
|
|
377
|
+
"""
|
|
378
|
+
for setting in FIXED_READER_SETTINGS:
|
|
379
|
+
if setting in reader_settings:
|
|
380
|
+
raise ParseError(
|
|
381
|
+
"this reader setting is fixed by the assembly check",
|
|
382
|
+
error_func=check_assembly,
|
|
383
|
+
error_value=setting,
|
|
384
|
+
)
|
|
385
|
+
if detect_index(path) is None:
|
|
386
|
+
raise ParseError(
|
|
387
|
+
"an index is required for the assembly check",
|
|
388
|
+
error_func=check_assembly,
|
|
389
|
+
error_value=path,
|
|
390
|
+
)
|
|
391
|
+
table = load_probe_table(probes)
|
|
392
|
+
with open_source(path) as source:
|
|
393
|
+
reader = DroppingReader(
|
|
394
|
+
source,
|
|
395
|
+
mapping,
|
|
396
|
+
effect_type=effect_type,
|
|
397
|
+
**FIXED_READER_SETTINGS,
|
|
398
|
+
**reader_settings,
|
|
399
|
+
)
|
|
400
|
+
hits, examined = _count(reader, table.probes)
|
|
401
|
+
failed_rows = len(reader.failures)
|
|
402
|
+
return score_assembly(
|
|
403
|
+
table.species,
|
|
404
|
+
table.builds,
|
|
405
|
+
hits,
|
|
406
|
+
examined,
|
|
407
|
+
failed_rows=failed_rows,
|
|
408
|
+
match_threshold=match_threshold,
|
|
409
|
+
confidence_delta_threshold=confidence_delta_threshold,
|
|
410
|
+
min_examined=min_examined,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
415
|
+
def _count(
|
|
416
|
+
reader: DroppingReader, probes: tuple[Probe, ...]
|
|
417
|
+
) -> tuple[tuple[int, int], int]:
|
|
418
|
+
"""Fetch every probe position once and count hits and examined probes.
|
|
419
|
+
|
|
420
|
+
Returns the build A and build B hit counts, then the examined count.
|
|
421
|
+
"""
|
|
422
|
+
# The reader's own normalised synonym table, so a probe chromosome is
|
|
423
|
+
# spelled exactly as the reader spells a row's chromosome.
|
|
424
|
+
synonyms = reader.chromosome_synonyms
|
|
425
|
+
places: list[tuple[Probe, tuple[str, int] | None, tuple[str, int] | None]]
|
|
426
|
+
places = []
|
|
427
|
+
regions: list[Region] = []
|
|
428
|
+
for probe in probes:
|
|
429
|
+
keys: list[tuple[str, int] | None] = []
|
|
430
|
+
for chromosome, position in (
|
|
431
|
+
(probe.chr_a, probe.pos_a),
|
|
432
|
+
(probe.chr_b, probe.pos_b),
|
|
433
|
+
):
|
|
434
|
+
try:
|
|
435
|
+
name = parse_chromosome(chromosome, synonyms)
|
|
436
|
+
except ParseError:
|
|
437
|
+
keys.append(None)
|
|
438
|
+
continue
|
|
439
|
+
keys.append((name, position))
|
|
440
|
+
regions.append((chromosome, position, position))
|
|
441
|
+
places.append((probe, keys[0], keys[1]))
|
|
442
|
+
found: set[tuple[str, int]] = set()
|
|
443
|
+
matched: set[tuple[str, int, frozenset[str]]] = set()
|
|
444
|
+
if regions: # A query without regions would scan the whole file.
|
|
445
|
+
chromosome_column = reader.column_indices["chr_name"]
|
|
446
|
+
position_column = reader.column_indices["start_pos"]
|
|
447
|
+
effect_column = reader.column_indices["effect_allele"]
|
|
448
|
+
other_column = reader.column_indices["other_allele"]
|
|
449
|
+
for row in reader.fetch(*regions):
|
|
450
|
+
place = (row[chromosome_column], row[position_column])
|
|
451
|
+
found.add(place) # type: ignore[arg-type]
|
|
452
|
+
alleles = frozenset((row[effect_column], row[other_column]))
|
|
453
|
+
matched.add((*place, alleles)) # type: ignore[arg-type]
|
|
454
|
+
hits_a = hits_b = examined = 0
|
|
455
|
+
for probe, key_a, key_b in places:
|
|
456
|
+
alleles = frozenset((probe.ref, probe.alt))
|
|
457
|
+
if key_a in found or key_b in found:
|
|
458
|
+
examined += 1
|
|
459
|
+
if key_a is not None and (*key_a, alleles) in matched:
|
|
460
|
+
hits_a += 1
|
|
461
|
+
if key_b is not None and (*key_b, alleles) in matched:
|
|
462
|
+
hits_b += 1
|
|
463
|
+
return (hits_a, hits_b), examined
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Reference-calibrated scalar effects and standalone calibration estimation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from math import sqrt
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .errors import ParseError
|
|
12
|
+
from .resolver import Route
|
|
13
|
+
from .stats import _number, set_effect_size_pvalue
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
17
|
+
def average_variation(
|
|
18
|
+
standard_errors: Sequence[float], frequencies: Sequence[float]
|
|
19
|
+
) -> float:
|
|
20
|
+
"""Estimate calibration from supplied reference-study numeric sequences.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
standard_errors, frequencies : Sequence[float]
|
|
25
|
+
Paired reference errors and allele frequencies from the same study
|
|
26
|
+
and effect scale as the target variants.
|
|
27
|
+
|
|
28
|
+
Returns
|
|
29
|
+
-------
|
|
30
|
+
float
|
|
31
|
+
Mean finite SE*sqrt(f*(1-f)) term, or NaN when none survive.
|
|
32
|
+
This calibration incorporates study precision; it is not the
|
|
33
|
+
phenotype standard deviation. Comparable reference and target
|
|
34
|
+
precision, including sufficiently similar sample sizes, is assumed.
|
|
35
|
+
"""
|
|
36
|
+
errors = np.asarray(standard_errors, dtype=float)
|
|
37
|
+
frequency = np.asarray(frequencies, dtype=float)
|
|
38
|
+
with np.errstate(invalid="ignore", over="ignore"):
|
|
39
|
+
terms = errors * np.sqrt(frequency * (1 - frequency))
|
|
40
|
+
finite = terms[np.isfinite(terms)]
|
|
41
|
+
return float(np.mean(finite)) if finite.size else float("nan")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
45
|
+
def set_standard_error_burgess(frequency: Any, average_variation: Any) -> float:
|
|
46
|
+
"""Estimate error from reference calibration on the target effect scale.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
frequency, average_variation : object
|
|
51
|
+
Frequency strictly between zero and one, and positive finite
|
|
52
|
+
calibration from the same study and effect scale. Calibration
|
|
53
|
+
incorporates study precision, assuming comparable reference and
|
|
54
|
+
target precision including sufficiently similar sample sizes.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
float
|
|
59
|
+
Calibration divided by sqrt(f*(1-f)); no sample count is used.
|
|
60
|
+
"""
|
|
61
|
+
method = set_standard_error_burgess
|
|
62
|
+
f = _number(frequency, method)
|
|
63
|
+
calibration = _number(average_variation, method)
|
|
64
|
+
if not 0 < f < 1:
|
|
65
|
+
raise ParseError(
|
|
66
|
+
"Burgess frequency must be strictly between zero and one",
|
|
67
|
+
error_func=method,
|
|
68
|
+
error_value=frequency,
|
|
69
|
+
)
|
|
70
|
+
if calibration <= 0:
|
|
71
|
+
raise ParseError(
|
|
72
|
+
"average_variation must be positive",
|
|
73
|
+
error_func=method,
|
|
74
|
+
error_value=average_variation,
|
|
75
|
+
)
|
|
76
|
+
return _number(calibration / sqrt(f * (1 - f)), method)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
80
|
+
def set_effect_size_burgess(
|
|
81
|
+
logarithm: Any, frequency: Any, average_variation: Any, direction: Any
|
|
82
|
+
) -> float:
|
|
83
|
+
"""Estimate a signed linear or log-odds effect using reference precision.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
logarithm, frequency, average_variation, direction : object
|
|
88
|
+
Negative base-ten probability log, allele frequency, same-study
|
|
89
|
+
calibration on the target effect scale, and explicit sign (+1/-1).
|
|
90
|
+
Calibration already incorporates study precision; comparable
|
|
91
|
+
reference and target precision is assumed.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
float
|
|
96
|
+
Signed normal score times the calibrated error. The reference scale
|
|
97
|
+
determines linear units or log odds; no sample count is used.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
error = set_standard_error_burgess(frequency, average_variation)
|
|
101
|
+
return set_effect_size_pvalue(error, logarithm, direction)
|
|
102
|
+
except ParseError as failure:
|
|
103
|
+
failure.error_func = set_effect_size_burgess
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
108
|
+
def burgess_routes(effect_type: str) -> tuple[Route, ...]:
|
|
109
|
+
"""Supply fallback routes for explicitly declared effect directions.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
effect_type : str
|
|
114
|
+
Source interpretation, restricted to linear or log-odds direction.
|
|
115
|
+
|
|
116
|
+
Returns
|
|
117
|
+
-------
|
|
118
|
+
tuple[Route, ...]
|
|
119
|
+
Reference-calibrated routes to append after ordinary alternatives.
|
|
120
|
+
"""
|
|
121
|
+
if effect_type not in {"direction_beta", "direction_log_or"}:
|
|
122
|
+
return ()
|
|
123
|
+
return (
|
|
124
|
+
Route(
|
|
125
|
+
"standard_error",
|
|
126
|
+
("effect_allele_freq", "average_variation"),
|
|
127
|
+
set_standard_error_burgess,
|
|
128
|
+
),
|
|
129
|
+
Route(
|
|
130
|
+
"effect_size",
|
|
131
|
+
(
|
|
132
|
+
"mlog10_pvalue",
|
|
133
|
+
"effect_allele_freq",
|
|
134
|
+
"average_variation",
|
|
135
|
+
"effect_direction",
|
|
136
|
+
),
|
|
137
|
+
set_effect_size_burgess,
|
|
138
|
+
),
|
|
139
|
+
)
|